From a2f7274a82b0ef67f480767c4faafc92fe296cfb Mon Sep 17 00:00:00 2001 From: Tibsfox Date: Sun, 5 Apr 2026 10:13:25 -0700 Subject: [PATCH] fix(gsd): rebuild STATE.md before guided-flow dispatch --- src/resources/extensions/gsd/doctor.ts | 3 +- src/resources/extensions/gsd/guided-flow.ts | 16 ++- .../tests/guided-flow-state-rebuild.test.ts | 103 ++++++++++++++++++ 3 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 src/resources/extensions/gsd/tests/guided-flow-state-rebuild.test.ts diff --git a/src/resources/extensions/gsd/doctor.ts b/src/resources/extensions/gsd/doctor.ts index 05630093e..b10362efc 100644 --- a/src/resources/extensions/gsd/doctor.ts +++ b/src/resources/extensions/gsd/doctor.ts @@ -87,7 +87,8 @@ function validatePreferenceShape(preferences: GSDPreferences): string[] { return issues; } -function buildStateMarkdown(state: Awaited>): string { +/** Build STATE.md content from derived state. Exported for guided-flow pre-dispatch rebuild (#3475). */ +export function buildStateMarkdown(state: Awaited>): string { const lines: string[] = []; lines.push("# GSD State", ""); diff --git a/src/resources/extensions/gsd/guided-flow.ts b/src/resources/extensions/gsd/guided-flow.ts index d0f400448..9349aecec 100644 --- a/src/resources/extensions/gsd/guided-flow.ts +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -8,7 +8,7 @@ import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from "@gsd/pi-coding-agent"; import { showNextAction } from "../shared/tui.js"; -import { loadFile } from "./files.js"; +import { loadFile, saveFile } from "./files.js"; import { isDbAvailable, getMilestoneSlices } from "./gsd-db.js"; import { parseRoadmapSlices } from "./roadmap-slices.js"; import { loadPrompt, inlineTemplate } from "./prompt-loader.js"; @@ -585,6 +585,14 @@ export async function showDiscuss( const state = await deriveState(basePath); + // Rebuild STATE.md from derived state before any dispatch (#3475). + // Without this, guided prompts read a stale STATE.md cache and the + // agent bootstraps from the wrong milestone. + try { + const { buildStateMarkdown } = await import("./doctor.js"); + await saveFile(resolveGsdRootFile(basePath, "STATE"), buildStateMarkdown(state)); + } catch { /* non-fatal — stale STATE.md is better than crashing */ } + // No active milestone (or corrupted milestone with undefined id) — // check for pending milestones to discuss instead if (!state.activeMilestone?.id) { @@ -1137,6 +1145,12 @@ export async function showSmartEntry( const state = await deriveState(basePath); + // Rebuild STATE.md from derived state before any dispatch (#3475). + try { + const { buildStateMarkdown } = await import("./doctor.js"); + await saveFile(resolveGsdRootFile(basePath, "STATE"), buildStateMarkdown(state)); + } catch { /* non-fatal */ } + if (!state.activeMilestone?.id) { // Guard: if a discuss session is already in flight, don't re-inject the prompt. // Both /gsd and /gsd auto reach this branch when no milestone exists yet. diff --git a/src/resources/extensions/gsd/tests/guided-flow-state-rebuild.test.ts b/src/resources/extensions/gsd/tests/guided-flow-state-rebuild.test.ts new file mode 100644 index 000000000..b2506dab5 --- /dev/null +++ b/src/resources/extensions/gsd/tests/guided-flow-state-rebuild.test.ts @@ -0,0 +1,103 @@ +/** + * Regression test for #3475: guided-flow must rebuild STATE.md from derived + * state before dispatching workflows. + * + * Verifies that buildStateMarkdown() produces content matching the derived + * state (not a stale on-disk cache), and that the rebuild helper is wired + * correctly from doctor.ts. + */ + +import { describe, test, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { deriveState, invalidateStateCache } from "../state.ts"; +import { buildStateMarkdown, rebuildState } from "../doctor.ts"; +import { resolveGsdRootFile } from "../paths.ts"; +import { + openDatabase, + closeDatabase, + insertMilestone, + insertSlice, + insertTask, +} from "../gsd-db.ts"; + +function createFixtureBase(): string { + const base = mkdtempSync(join(tmpdir(), "gsd-guided-state-")); + mkdirSync(join(base, ".gsd", "milestones"), { recursive: true }); + return base; +} + +function writeFile(base: string, relativePath: string, content: string): void { + const full = join(base, ".gsd", relativePath); + mkdirSync(join(full, ".."), { recursive: true }); + writeFileSync(full, content); +} + +describe("guided-flow STATE.md rebuild (#3475)", () => { + let base: string; + + afterEach(() => { + closeDatabase(); + if (base) rmSync(base, { recursive: true, force: true }); + }); + + test("rebuildState writes STATE.md matching derived state, not stale cache", async () => { + base = createFixtureBase(); + openDatabase(":memory:"); + + // Set up real active milestone M010 + insertMilestone({ id: "M010", title: "Real Active", status: "active" }); + insertSlice({ id: "S03", milestoneId: "M010", title: "Slice Three", status: "active", risk: "low", depends: [] }); + insertTask({ id: "T05", sliceId: "S03", milestoneId: "M010", title: "Task Five", status: "pending" }); + writeFile(base, "milestones/M010/M010-CONTEXT.md", "# M010: Real Active\n\nReal work here."); + writeFile(base, "milestones/M010/M010-ROADMAP.md", "# M010\n\n## Slices\n\n- [ ] **S03: Slice Three**"); + + // Write a STALE STATE.md pointing to wrong milestone + writeFile(base, "STATE.md", [ + "# GSD State", + "", + "**Active Milestone:** M008: Old Queued", + "**Active Slice:** None", + "**Phase:** pre-planning", + "", + "## Next Action", + "Milestone M008 has a roadmap but no slices defined.", + ].join("\n")); + + // Derive state — should return M010 + invalidateStateCache(); + const state = await deriveState(base); + assert.equal(state.activeMilestone?.id, "M010", "Derived state should be M010"); + + // Rebuild STATE.md + await rebuildState(base); + + // Read the rebuilt STATE.md + const statePath = resolveGsdRootFile(base, "STATE"); + const rebuilt = readFileSync(statePath, "utf-8"); + + // Should contain M010, NOT M008 + assert.ok(rebuilt.includes("M010"), "Rebuilt STATE.md should reference M010"); + assert.ok(!rebuilt.includes("M008"), "Rebuilt STATE.md should NOT reference stale M008"); + }); + + test("buildStateMarkdown produces correct active milestone from GSDState", async () => { + base = createFixtureBase(); + openDatabase(":memory:"); + + insertMilestone({ id: "M070", title: "Current Work", status: "active" }); + insertSlice({ id: "S01", milestoneId: "M070", title: "First Slice", status: "active", risk: "low", depends: [] }); + writeFile(base, "milestones/M070/M070-CONTEXT.md", "# M070: Current Work"); + writeFile(base, "milestones/M070/M070-ROADMAP.md", "# M070\n\n## Slices\n\n- [ ] **S01: First Slice**"); + + invalidateStateCache(); + const state = await deriveState(base); + const md = buildStateMarkdown(state); + + assert.ok(md.includes("M070"), "State markdown should include active milestone M070"); + assert.ok(md.includes("Current Work") || md.includes("M070"), "State markdown should include milestone title or ID"); + }); +});