From 96df01063f2cb85f9850aadfed2ccb00caa5a1f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Sun, 15 Mar 2026 18:26:55 -0600 Subject: [PATCH] fix: auto-mode worktree path and resource sync bugs (#557) * fix(auto): add missing import for resolveSkillDiscoveryMode Used at line 687 but not imported, causing "resolveSkillDiscoveryMode is not defined" crash on auto-mode startup. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(auto): add workingDirectory to all auto-mode prompt templates Six prompt templates (reassess-roadmap, complete-milestone, replan-slice, run-uat, research-milestone, plan-milestone) were missing the working directory directive. Without it, the LLM infers the main repo path from system context and cd's there instead of staying in the worktree. This causes artifacts to be written to the wrong location, preventing the dispatch loop from detecting completion and triggering infinite re-dispatches of the same unit. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(auto): detect mid-session resource updates and stop gracefully Templates are read from disk on each dispatch but extension code is loaded once at startup. If resources are re-synced mid-session (via /gsd:update, npm update, or dev copy-resources), templates may expect variables the in-memory code doesn't provide, causing a crash. Add a syncedAt timestamp to managed-resources.json. Auto-mode captures this at startup and checks before each dispatch. If resources changed, it stops with a clear message instead of crashing. Co-Authored-By: Claude Opus 4.6 (1M context) * test: add workingDirectory to prompt template test fixtures Tests that load prompt templates via loadPromptFromWorktree now pass the workingDirectory variable, matching the updated templates that include the {{workingDirectory}} directive. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- src/resource-loader.ts | 12 +++++- src/resources/extensions/gsd/auto-prompts.ts | 6 +++ src/resources/extensions/gsd/auto.ts | 43 ++++++++++++++++++- .../gsd/prompts/complete-milestone.md | 4 ++ .../extensions/gsd/prompts/plan-milestone.md | 4 ++ .../gsd/prompts/reassess-roadmap.md | 4 ++ .../extensions/gsd/prompts/replan-slice.md | 4 ++ .../gsd/prompts/research-milestone.md | 4 ++ .../extensions/gsd/prompts/run-uat.md | 4 ++ .../gsd/tests/complete-milestone.test.ts | 3 ++ .../gsd/tests/reassess-prompt.test.ts | 3 ++ .../extensions/gsd/tests/replan-slice.test.ts | 3 ++ .../extensions/gsd/tests/run-uat.test.ts | 1 + 13 files changed, 93 insertions(+), 2 deletions(-) diff --git a/src/resource-loader.ts b/src/resource-loader.ts index 31c4ae528..ce5b68de3 100644 --- a/src/resource-loader.ts +++ b/src/resource-loader.ts @@ -22,6 +22,7 @@ const resourceVersionManifestName = 'managed-resources.json' interface ManagedResourceManifest { gsdVersion: string + syncedAt?: number } function isExtensionFile(name: string): boolean { @@ -102,7 +103,7 @@ function getBundledGsdVersion(): string { } function writeManagedResourceManifest(agentDir: string): void { - const manifest: ManagedResourceManifest = { gsdVersion: getBundledGsdVersion() } + const manifest: ManagedResourceManifest = { gsdVersion: getBundledGsdVersion(), syncedAt: Date.now() } writeFileSync(getManagedResourceManifestPath(agentDir), JSON.stringify(manifest)) } @@ -115,6 +116,15 @@ export function readManagedResourceVersion(agentDir: string): string | null { } } +export function readManagedResourceSyncedAt(agentDir: string): number | null { + try { + const manifest = JSON.parse(readFileSync(getManagedResourceManifestPath(agentDir), 'utf-8')) as ManagedResourceManifest + return typeof manifest?.syncedAt === 'number' ? manifest.syncedAt : null + } catch { + return null + } +} + export function getNewerManagedResourceVersion(agentDir: string, currentVersion: string): string | null { const managedVersion = readManagedResourceVersion(agentDir) if (!managedVersion) { diff --git a/src/resources/extensions/gsd/auto-prompts.ts b/src/resources/extensions/gsd/auto-prompts.ts index 14c589884..8e4aa8d1d 100644 --- a/src/resources/extensions/gsd/auto-prompts.ts +++ b/src/resources/extensions/gsd/auto-prompts.ts @@ -383,6 +383,7 @@ export async function buildResearchMilestonePrompt(mid: string, midTitle: string const outputRelPath = relMilestoneFile(base, mid, "RESEARCH"); return loadPrompt("research-milestone", { + workingDirectory: base, milestoneId: mid, milestoneTitle: midTitle, milestonePath: relMilestonePath(base, mid), contextPath: contextRel, @@ -422,6 +423,7 @@ export async function buildPlanMilestonePrompt(mid: string, midTitle: string, ba const outputRelPath = relMilestoneFile(base, mid, "ROADMAP"); const secretsOutputPath = relMilestoneFile(base, mid, "SECRETS"); return loadPrompt("plan-milestone", { + workingDirectory: base, milestoneId: mid, milestoneTitle: midTitle, milestonePath: relMilestonePath(base, mid), contextPath: contextRel, @@ -667,6 +669,7 @@ export async function buildCompleteMilestonePrompt( const milestoneSummaryPath = `${relMilestonePath(base, mid)}/${mid}-SUMMARY.md`; return loadPrompt("complete-milestone", { + workingDirectory: base, milestoneId: mid, milestoneTitle: midTitle, roadmapPath: roadmapRel, @@ -715,6 +718,7 @@ export async function buildReplanSlicePrompt( const replanPath = `${relSlicePath(base, mid, sid)}/${sid}-REPLAN.md`; return loadPrompt("replan-slice", { + workingDirectory: base, milestoneId: mid, sliceId: sid, sliceTitle: sTitle, @@ -748,6 +752,7 @@ export async function buildRunUatPrompt( const uatType = extractUatType(uatContent) ?? "human-experience"; return loadPrompt("run-uat", { + workingDirectory: base, milestoneId: mid, sliceId, uatPath, @@ -780,6 +785,7 @@ export async function buildReassessRoadmapPrompt( const assessmentPath = relSliceFile(base, mid, completedSliceId, "ASSESSMENT"); return loadPrompt("reassess-roadmap", { + workingDirectory: base, milestoneId: mid, milestoneTitle: midTitle, completedSliceId, diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 5b46e3472..1b86b797a 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -39,7 +39,7 @@ import { readUnitRuntimeRecord, writeUnitRuntimeRecord, } from "./unit-runtime.js"; -import { resolveAutoSupervisorConfig, resolveModelWithFallbacksForUnit, loadEffectiveGSDPreferences } from "./preferences.js"; +import { resolveAutoSupervisorConfig, resolveModelWithFallbacksForUnit, loadEffectiveGSDPreferences, resolveSkillDiscoveryMode } from "./preferences.js"; import { sendDesktopNotification } from "./notifications.js"; import type { GSDPreferences } from "./preferences.js"; import { @@ -68,6 +68,7 @@ import { } from "./metrics.js"; import { join } from "node:path"; import { sep as pathSep } from "node:path"; +import { homedir } from "node:os"; import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync, unlinkSync, statSync } from "node:fs"; import { execSync, execFileSync } from "node:child_process"; import { @@ -156,6 +157,33 @@ const unitRecoveryCount = new Map(); /** Persisted completed-unit keys — survives restarts. Loaded from .gsd/completed-units.json. */ const completedKeySet = new Set(); +/** Resource sync timestamp captured at auto-mode start. If the managed-resources + * manifest changes mid-session (e.g. /gsd:update or dev edit + copy-resources), + * templates on disk may expect variables the in-memory code doesn't provide. + * Detect this and stop gracefully instead of crashing. */ +let resourceSyncedAtOnStart: number | null = null; + +function readResourceSyncedAt(): number | null { + const agentDir = process.env.GSD_CODING_AGENT_DIR || join(homedir(), ".gsd", "agent"); + const manifestPath = join(agentDir, "managed-resources.json"); + try { + const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")); + return typeof manifest?.syncedAt === "number" ? manifest.syncedAt : null; + } catch { + return null; + } +} + +function checkResourcesStale(): string | null { + if (resourceSyncedAtOnStart === null) return null; + const current = readResourceSyncedAt(); + if (current === null) return null; + if (current !== resourceSyncedAtOnStart) { + return "GSD resources were updated since this session started. Restart gsd to load the new code."; + } + return null; +} + /** * Resolve whether auto-mode should use worktree isolation. * Returns true for worktree mode (default), false for branch mode. @@ -618,6 +646,7 @@ export async function startAuto( resetHookState(); restoreHookState(base); autoStartTime = Date.now(); + resourceSyncedAtOnStart = readResourceSyncedAt(); completedUnits = []; currentUnit = null; currentMilestoneId = state.activeMilestone?.id ?? null; @@ -1141,6 +1170,18 @@ async function dispatchNextUnit( await new Promise(r => setTimeout(r, 200)); } + // Resource version guard: detect mid-session resource updates. + // Templates are read from disk on each dispatch but extension code is loaded + // once at startup. If resources were re-synced (e.g. /gsd:update, npm update, + // or dev copy-resources), templates may expect variables the in-memory code + // doesn't provide. Stop gracefully instead of crashing. + const staleMsg = checkResourcesStale(); + if (staleMsg) { + await stopAuto(ctx, pi); + ctx.ui.notify(staleMsg, "error"); + return; + } + // Clear all caches so deriveState sees fresh disk state (#431). // Parse cache is also cleared — doctor may have re-populated it with // stale data between handleAgentEnd and this dispatch call (Path B fix). diff --git a/src/resources/extensions/gsd/prompts/complete-milestone.md b/src/resources/extensions/gsd/prompts/complete-milestone.md index 993f53da6..a7e228fcf 100644 --- a/src/resources/extensions/gsd/prompts/complete-milestone.md +++ b/src/resources/extensions/gsd/prompts/complete-milestone.md @@ -2,6 +2,10 @@ You are executing GSD auto-mode. ## UNIT: Complete Milestone {{milestoneId}} ("{{milestoneTitle}}") +## Working Directory + +Your working directory is `{{workingDirectory}}`. All file reads, writes, and shell commands MUST operate relative to this directory. Do NOT `cd` to any other directory. + ## Your Role in the Pipeline All slices are done. You are closing out the milestone — verifying that the assembled work actually delivers the promised outcome, writing the milestone summary, and updating project state. The milestone summary is the final record. After you finish, the system merges the worktree back to the integration branch. If there are queued milestones, the next one starts its own research → plan → execute cycle from a clean slate — the milestone summary is how it learns what was already built. diff --git a/src/resources/extensions/gsd/prompts/plan-milestone.md b/src/resources/extensions/gsd/prompts/plan-milestone.md index 72c97a260..ea70a0467 100644 --- a/src/resources/extensions/gsd/prompts/plan-milestone.md +++ b/src/resources/extensions/gsd/prompts/plan-milestone.md @@ -2,6 +2,10 @@ You are executing GSD auto-mode. ## UNIT: Plan Milestone {{milestoneId}} ("{{milestoneTitle}}") +## Working Directory + +Your working directory is `{{workingDirectory}}`. All file reads, writes, and shell commands MUST operate relative to this directory. Do NOT `cd` to any other directory. + All relevant context has been preloaded below — start working immediately without re-reading these files. {{inlinedContext}} diff --git a/src/resources/extensions/gsd/prompts/reassess-roadmap.md b/src/resources/extensions/gsd/prompts/reassess-roadmap.md index 31f4cfdae..933e6a580 100644 --- a/src/resources/extensions/gsd/prompts/reassess-roadmap.md +++ b/src/resources/extensions/gsd/prompts/reassess-roadmap.md @@ -2,6 +2,10 @@ You are executing GSD auto-mode. ## UNIT: Reassess Roadmap — Milestone {{milestoneId}} after {{completedSliceId}} +## Working Directory + +Your working directory is `{{workingDirectory}}`. All file reads, writes, and shell commands MUST operate relative to this directory. Do NOT `cd` to any other directory. + ## Your Role in the Pipeline A slice just completed. The **complete-slice agent** verified the work and wrote a slice summary. You decide whether the remaining roadmap still makes sense given what was actually built. If you change the roadmap, the next slice's **researcher** and **planner** agents work from your updated version. If you confirm it's fine, the pipeline moves to the next slice immediately. diff --git a/src/resources/extensions/gsd/prompts/replan-slice.md b/src/resources/extensions/gsd/prompts/replan-slice.md index 6b6ae86af..0548b9d08 100644 --- a/src/resources/extensions/gsd/prompts/replan-slice.md +++ b/src/resources/extensions/gsd/prompts/replan-slice.md @@ -2,6 +2,10 @@ You are executing GSD auto-mode. ## UNIT: Replan Slice {{sliceId}} ("{{sliceTitle}}") — Milestone {{milestoneId}} +## Working Directory + +Your working directory is `{{workingDirectory}}`. All file reads, writes, and shell commands MUST operate relative to this directory. Do NOT `cd` to any other directory. + A completed task reported `blocker_discovered: true`, meaning the current slice plan cannot be executed as-is. Your job is to rewrite the remaining tasks in the slice plan to address the blocker while preserving all completed work. All relevant context has been preloaded below — the roadmap, current slice plan, the blocker task summary, and decisions are inlined. Start working immediately without re-reading these files. diff --git a/src/resources/extensions/gsd/prompts/research-milestone.md b/src/resources/extensions/gsd/prompts/research-milestone.md index 59c0184fa..b67516e3b 100644 --- a/src/resources/extensions/gsd/prompts/research-milestone.md +++ b/src/resources/extensions/gsd/prompts/research-milestone.md @@ -2,6 +2,10 @@ You are executing GSD auto-mode. ## UNIT: Research Milestone {{milestoneId}} ("{{milestoneTitle}}") +## Working Directory + +Your working directory is `{{workingDirectory}}`. All file reads, writes, and shell commands MUST operate relative to this directory. Do NOT `cd` to any other directory. + All relevant context has been preloaded below — start working immediately without re-reading these files. {{inlinedContext}} diff --git a/src/resources/extensions/gsd/prompts/run-uat.md b/src/resources/extensions/gsd/prompts/run-uat.md index 8e54ab352..f00d2cb4c 100644 --- a/src/resources/extensions/gsd/prompts/run-uat.md +++ b/src/resources/extensions/gsd/prompts/run-uat.md @@ -2,6 +2,10 @@ You are executing GSD auto-mode. ## UNIT: Run UAT — {{milestoneId}}/{{sliceId}} +## Working Directory + +Your working directory is `{{workingDirectory}}`. All file reads, writes, and shell commands MUST operate relative to this directory. Do NOT `cd` to any other directory. + All relevant context has been preloaded below. Start working immediately without re-reading these files. {{inlinedContext}} diff --git a/src/resources/extensions/gsd/tests/complete-milestone.test.ts b/src/resources/extensions/gsd/tests/complete-milestone.test.ts index 8037ef317..cb1a7124a 100644 --- a/src/resources/extensions/gsd/tests/complete-milestone.test.ts +++ b/src/resources/extensions/gsd/tests/complete-milestone.test.ts @@ -62,6 +62,7 @@ async function main(): Promise { let threw = false; try { result = loadPromptFromWorktree("complete-milestone", { + workingDirectory: "/tmp/test-project", milestoneId: "M001", milestoneTitle: "Test Milestone", roadmapPath: ".gsd/milestones/M001/M001-ROADMAP.md", @@ -81,6 +82,7 @@ async function main(): Promise { console.log("\n=== prompt variable substitution ==="); { const prompt = loadPromptFromWorktree("complete-milestone", { + workingDirectory: "/tmp/test-project", milestoneId: "M001", milestoneTitle: "Integration Feature", roadmapPath: ".gsd/milestones/M001/M001-ROADMAP.md", @@ -101,6 +103,7 @@ async function main(): Promise { console.log("\n=== prompt content integrity ==="); { const prompt = loadPromptFromWorktree("complete-milestone", { + workingDirectory: "/tmp/test-project", milestoneId: "M002", milestoneTitle: "Completion Workflow", roadmapPath: ".gsd/milestones/M002/M002-ROADMAP.md", diff --git a/src/resources/extensions/gsd/tests/reassess-prompt.test.ts b/src/resources/extensions/gsd/tests/reassess-prompt.test.ts index a1616888f..2f34f6311 100644 --- a/src/resources/extensions/gsd/tests/reassess-prompt.test.ts +++ b/src/resources/extensions/gsd/tests/reassess-prompt.test.ts @@ -33,6 +33,7 @@ async function main(): Promise { console.log("\n=== reassess-roadmap prompt loads and substitutes ==="); { const testVars = { + workingDirectory: "/tmp/test-project", milestoneId: "M099", completedSliceId: "S03", assessmentPath: ".gsd/milestones/M099/slices/S03/S03-ASSESSMENT.md", @@ -72,6 +73,7 @@ async function main(): Promise { console.log("\n=== reassess-roadmap contains coverage-check instruction ==="); { const prompt = loadPromptFromWorktree("reassess-roadmap", { + workingDirectory: "/tmp/test-project", milestoneId: "M001", completedSliceId: "S01", assessmentPath: ".gsd/milestones/M001/slices/S01/S01-ASSESSMENT.md", @@ -111,6 +113,7 @@ async function main(): Promise { console.log("\n=== coverage-check requires at-least-one semantics ==="); { const prompt = loadPromptFromWorktree("reassess-roadmap", { + workingDirectory: "/tmp/test-project", milestoneId: "M001", completedSliceId: "S01", assessmentPath: ".gsd/milestones/M001/slices/S01/S01-ASSESSMENT.md", diff --git a/src/resources/extensions/gsd/tests/replan-slice.test.ts b/src/resources/extensions/gsd/tests/replan-slice.test.ts index 6b81f0ee6..d682a2b20 100644 --- a/src/resources/extensions/gsd/tests/replan-slice.test.ts +++ b/src/resources/extensions/gsd/tests/replan-slice.test.ts @@ -360,6 +360,7 @@ console.log('\n=== deriveState: completed task with no summary file → executin console.log('\n=== prompt: replan-slice template loads and substitutes variables ==='); { const prompt = loadPromptFromWorktree('replan-slice', { + workingDirectory: '/tmp/test-project', milestoneId: 'M001', sliceId: 'S01', sliceTitle: 'Test Slice', @@ -378,6 +379,7 @@ console.log('\n=== prompt: replan-slice template loads and substitutes variables console.log('\n=== prompt: replan-slice contains preserve-completed-tasks instruction ==='); { const prompt = loadPromptFromWorktree('replan-slice', { + workingDirectory: '/tmp/test-project', milestoneId: 'M001', sliceId: 'S01', sliceTitle: 'Test Slice', @@ -424,6 +426,7 @@ console.log('\n=== dispatch: diagnoseExpectedArtifact returns REPLAN.md path === console.log('\n=== display: replan-slice prompt template has correct unit header ==='); { const prompt = loadPromptFromWorktree('replan-slice', { + workingDirectory: '/tmp/test-project', milestoneId: 'M001', sliceId: 'S01', sliceTitle: 'Test Slice', diff --git a/src/resources/extensions/gsd/tests/run-uat.test.ts b/src/resources/extensions/gsd/tests/run-uat.test.ts index 7a392e3fa..dde1276b5 100644 --- a/src/resources/extensions/gsd/tests/run-uat.test.ts +++ b/src/resources/extensions/gsd/tests/run-uat.test.ts @@ -214,6 +214,7 @@ async function main(): Promise { let promptThrew = false; try { promptResult = loadPromptFromWorktree('run-uat', { + workingDirectory: '/tmp/test-project', milestoneId, sliceId, uatPath,