From 583e84e9329ef45230cb638bc532cfcaefb2c765 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 18 Mar 2026 22:03:59 -0400 Subject: [PATCH] =?UTF-8?q?refactor:=20dispatch=20loop=20hardening=20?= =?UTF-8?q?=E2=80=94=20defensive=20guards,=20regression=20tests,=20lock=20?= =?UTF-8?q?alignment=20(#1310)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/resources/extensions/gsd/auto-dispatch.ts | 52 +- .../gsd/tests/loop-regression.test.ts | 839 ++++++++++++++++++ 2 files changed, 879 insertions(+), 12 deletions(-) create mode 100644 src/resources/extensions/gsd/tests/loop-regression.test.ts diff --git a/src/resources/extensions/gsd/auto-dispatch.ts b/src/resources/extensions/gsd/auto-dispatch.ts index e1c506127..2e9e69778 100644 --- a/src/resources/extensions/gsd/auto-dispatch.ts +++ b/src/resources/extensions/gsd/auto-dispatch.ts @@ -65,6 +65,28 @@ export function resetRewriteCircuitBreaker(): void { rewriteAttemptCount = 0; } +/** + * Guard for accessing activeSlice/activeTask in dispatch rules. + * Returns a stop action if the expected ref is null (corrupt state). + */ +function requireSlice(state: GSDState): { sid: string; sTitle: string } | DispatchAction { + if (!state.activeSlice) { + return { action: "stop", reason: `Phase "${state.phase}" but no active slice — run /gsd doctor.`, level: "error" }; + } + return { sid: state.activeSlice.id, sTitle: state.activeSlice.title }; +} + +function requireTask(state: GSDState): { sid: string; sTitle: string; tid: string; tTitle: string } | DispatchAction { + if (!state.activeSlice || !state.activeTask) { + return { action: "stop", reason: `Phase "${state.phase}" but no active slice/task — run /gsd doctor.`, level: "error" }; + } + return { sid: state.activeSlice.id, sTitle: state.activeSlice.title, tid: state.activeTask.id, tTitle: state.activeTask.title }; +} + +function isStopAction(v: unknown): v is DispatchAction { + return typeof v === "object" && v !== null && "action" in v; +} + // ─── Rules ──────────────────────────────────────────────────────────────── const DISPATCH_RULES: DispatchRule[] = [ @@ -93,8 +115,9 @@ const DISPATCH_RULES: DispatchRule[] = [ name: "summarizing → complete-slice", match: async ({ state, mid, midTitle, basePath }) => { if (state.phase !== "summarizing") return null; - const sid = state.activeSlice!.id; - const sTitle = state.activeSlice!.title; + const sliceRef = requireSlice(state); + if (isStopAction(sliceRef)) return sliceRef as DispatchAction; + const { sid, sTitle } = sliceRef; return { action: "dispatch", unitType: "complete-slice", @@ -222,8 +245,9 @@ const DISPATCH_RULES: DispatchRule[] = [ if (state.phase !== "planning") return null; // Phase skip: skip research when preference or profile says so if (prefs?.phases?.skip_research || prefs?.phases?.skip_slice_research) return null; - const sid = state.activeSlice!.id; - const sTitle = state.activeSlice!.title; + const sliceRef = requireSlice(state); + if (isStopAction(sliceRef)) return sliceRef as DispatchAction; + const { sid, sTitle } = sliceRef; const researchFile = resolveSliceFile(basePath, mid, sid, "RESEARCH"); if (researchFile) return null; // has research, fall through // Skip slice research for S01 when milestone research already exists — @@ -242,8 +266,9 @@ const DISPATCH_RULES: DispatchRule[] = [ name: "planning → plan-slice", match: async ({ state, mid, midTitle, basePath }) => { if (state.phase !== "planning") return null; - const sid = state.activeSlice!.id; - const sTitle = state.activeSlice!.title; + const sliceRef = requireSlice(state); + if (isStopAction(sliceRef)) return sliceRef as DispatchAction; + const { sid, sTitle } = sliceRef; return { action: "dispatch", unitType: "plan-slice", @@ -256,8 +281,9 @@ const DISPATCH_RULES: DispatchRule[] = [ name: "replanning-slice → replan-slice", match: async ({ state, mid, midTitle, basePath }) => { if (state.phase !== "replanning-slice") return null; - const sid = state.activeSlice!.id; - const sTitle = state.activeSlice!.title; + const sliceRef = requireSlice(state); + if (isStopAction(sliceRef)) return sliceRef as DispatchAction; + const { sid, sTitle } = sliceRef; return { action: "dispatch", unitType: "replan-slice", @@ -270,8 +296,9 @@ const DISPATCH_RULES: DispatchRule[] = [ name: "executing → execute-task (recover missing task plan → plan-slice)", match: async ({ state, mid, midTitle, basePath }) => { if (state.phase !== "executing" || !state.activeTask) return null; - const sid = state.activeSlice!.id; - const sTitle = state.activeSlice!.title; + const sliceRef = requireSlice(state); + if (isStopAction(sliceRef)) return sliceRef as DispatchAction; + const { sid, sTitle } = sliceRef; const tid = state.activeTask.id; // Guard: if the slice plan exists but the individual task plan files are @@ -296,8 +323,9 @@ const DISPATCH_RULES: DispatchRule[] = [ name: "executing → execute-task", match: async ({ state, mid, basePath }) => { if (state.phase !== "executing" || !state.activeTask) return null; - const sid = state.activeSlice!.id; - const sTitle = state.activeSlice!.title; + const sliceRef = requireSlice(state); + if (isStopAction(sliceRef)) return sliceRef as DispatchAction; + const { sid, sTitle } = sliceRef; const tid = state.activeTask.id; const tTitle = state.activeTask.title; diff --git a/src/resources/extensions/gsd/tests/loop-regression.test.ts b/src/resources/extensions/gsd/tests/loop-regression.test.ts new file mode 100644 index 000000000..92d90db49 --- /dev/null +++ b/src/resources/extensions/gsd/tests/loop-regression.test.ts @@ -0,0 +1,839 @@ +/** + * Regression test suite for the auto-mode dispatch loop. + * Covers phase transitions, dispatch rule matching, state derivation edge cases, + * and every fix from the #1308 issue catalog. + * + * Run: node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs \ + * --experimental-strip-types --test src/resources/extensions/gsd/tests/loop-regression.test.ts + */ + +import { mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { deriveState } from "../state.ts"; +import { resolveDispatch, getDispatchRuleNames } from "../auto-dispatch.ts"; +import type { GSDState } from "../types.ts"; + +// ─── Helpers ────────────────────────────────────────────────────────────── + +function makeTmp(name: string): string { + const dir = join(tmpdir(), `loop-regression-${name}-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function writeGsdFile(base: string, ...pathParts: string[]): void { + const fullPath = join(base, ".gsd", ...pathParts); + mkdirSync(join(fullPath, ".."), { recursive: true }); + // Default to empty content; callers use writeGsdFileContent for real content +} + +function writeGsdFileContent(base: string, relativePath: string, content: string): void { + const fullPath = join(base, ".gsd", relativePath); + mkdirSync(join(fullPath, ".."), { recursive: true }); + writeFileSync(fullPath, content, "utf-8"); +} + +function buildMinimalRoadmap(slices: Array<{ id: string; title: string; done: boolean; depends?: string[] }>): string { + const lines = ["# M001: Test Milestone", "", "## Slices", ""]; + for (const s of slices) { + const cb = s.done ? "x" : " "; + const deps = s.depends?.length ? ` \`depends:[${s.depends.join(",")}]\`` : " `depends:[]`"; + lines.push(`- [${cb}] **${s.id}: ${s.title}** \`risk:low\`${deps}`); + lines.push(` > Demo text for ${s.id}`); + lines.push(""); + } + return lines.join("\n"); +} + +function buildMinimalPlan(tasks: Array<{ id: string; title: string; done: boolean }>): string { + const lines = ["# S01: Test Slice", "", "**Goal:** test", "", "## Tasks", ""]; + for (const t of tasks) { + const cb = t.done ? "x" : " "; + lines.push(`- [${cb}] **${t.id}: ${t.title}** \`est:5m\``); + } + return lines.join("\n"); +} + +function buildMinimalSummary(id: string): string { + return [ + "---", + `id: ${id}`, + "parent: S01", + "milestone: M001", + "duration: 5m", + "verification_result: passed", + `completed_at: ${new Date().toISOString()}`, + "---", + "", + `# ${id}: Done`, + "", + "Completed.", + ].join("\n"); +} + +// ─── Phase 1: Dispatch Rule Ordering ────────────────────────────────────── + +test("dispatch rules are in the expected order", () => { + const names = getDispatchRuleNames(); + assert.ok(names.length >= 15, `expected ≥15 rules, got ${names.length}`); + + // Verify critical ordering: override gate first, complete-slice before UAT, + // needs-discussion before pre-planning, executing last + const overrideIdx = names.indexOf("rewrite-docs (override gate)"); + const completeSliceIdx = names.indexOf("summarizing → complete-slice"); + const uatGateIdx = names.indexOf("uat-verdict-gate (non-PASS blocks progression)"); + const needsDiscussIdx = names.indexOf("needs-discussion → stop"); + const prePlanNoCtxIdx = names.indexOf("pre-planning (no context) → stop"); + const executeIdx = names.indexOf("executing → execute-task"); + + assert.ok(overrideIdx === 0, "override gate should be first rule"); + assert.ok(completeSliceIdx < uatGateIdx, "complete-slice should fire before UAT gate"); + assert.ok(needsDiscussIdx < prePlanNoCtxIdx, "needs-discussion should fire before pre-planning"); + assert.ok(executeIdx > prePlanNoCtxIdx, "execute-task should fire after pre-planning rules"); +}); + +// ─── Phase 2: State Derivation — Phase Transitions ─────────────────────── + +test("deriveState: empty project → pre-planning with no milestones", async () => { + const tmp = makeTmp("empty"); + try { + mkdirSync(join(tmp, ".gsd", "milestones"), { recursive: true }); + const state = await deriveState(tmp); + assert.equal(state.phase, "pre-planning"); + assert.equal(state.activeMilestone, null); + assert.deepEqual(state.registry, []); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("deriveState: milestone with context but no roadmap → pre-planning", async () => { + const tmp = makeTmp("no-roadmap"); + try { + const mDir = join(tmp, ".gsd", "milestones", "M001"); + mkdirSync(mDir, { recursive: true }); + writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001: Test\n\nContext here."); + const state = await deriveState(tmp); + assert.equal(state.phase, "pre-planning"); + assert.equal(state.activeMilestone?.id, "M001"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("deriveState: milestone with CONTEXT-DRAFT.md → needs-discussion", async () => { + const tmp = makeTmp("draft"); + try { + const mDir = join(tmp, ".gsd", "milestones", "M001"); + mkdirSync(mDir, { recursive: true }); + writeFileSync(join(mDir, "M001-CONTEXT-DRAFT.md"), "# Draft\n\nSome ideas."); + const state = await deriveState(tmp); + assert.equal(state.phase, "needs-discussion"); + assert.equal(state.activeMilestone?.id, "M001"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("deriveState: roadmap with no plan → planning", async () => { + const tmp = makeTmp("planning"); + try { + const mDir = join(tmp, ".gsd", "milestones", "M001"); + mkdirSync(join(mDir, "slices", "S01"), { recursive: true }); + writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext."); + writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([ + { id: "S01", title: "First Slice", done: false }, + ])); + const state = await deriveState(tmp); + assert.equal(state.phase, "planning"); + assert.equal(state.activeSlice?.id, "S01"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("deriveState: plan with incomplete tasks → executing", async () => { + const tmp = makeTmp("executing"); + try { + const mDir = join(tmp, ".gsd", "milestones", "M001"); + const sDir = join(mDir, "slices", "S01"); + mkdirSync(join(sDir, "tasks"), { recursive: true }); + writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext."); + writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([ + { id: "S01", title: "First Slice", done: false }, + ])); + writeFileSync(join(sDir, "S01-PLAN.md"), buildMinimalPlan([ + { id: "T01", title: "Task One", done: false }, + { id: "T02", title: "Task Two", done: false }, + ])); + writeFileSync(join(sDir, "tasks", "T01-PLAN.md"), "# T01 Plan\n\nDo stuff."); + writeFileSync(join(sDir, "tasks", "T02-PLAN.md"), "# T02 Plan\n\nDo more."); + const state = await deriveState(tmp); + assert.equal(state.phase, "executing"); + assert.equal(state.activeTask?.id, "T01"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("deriveState: all tasks done → summarizing", async () => { + const tmp = makeTmp("summarizing"); + try { + const mDir = join(tmp, ".gsd", "milestones", "M001"); + const sDir = join(mDir, "slices", "S01"); + mkdirSync(join(sDir, "tasks"), { recursive: true }); + writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext."); + writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([ + { id: "S01", title: "First Slice", done: false }, + ])); + writeFileSync(join(sDir, "S01-PLAN.md"), buildMinimalPlan([ + { id: "T01", title: "Task One", done: true }, + ])); + writeFileSync(join(sDir, "tasks", "T01-SUMMARY.md"), buildMinimalSummary("T01")); + const state = await deriveState(tmp); + assert.equal(state.phase, "summarizing"); + assert.equal(state.activeSlice?.id, "S01"); + assert.equal(state.activeTask, null); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("deriveState: all slices done → validating-milestone", async () => { + const tmp = makeTmp("validating"); + try { + const mDir = join(tmp, ".gsd", "milestones", "M001"); + const sDir = join(mDir, "slices", "S01"); + mkdirSync(join(sDir, "tasks"), { recursive: true }); + writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext."); + writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([ + { id: "S01", title: "First Slice", done: true }, + ])); + writeFileSync(join(sDir, "S01-PLAN.md"), buildMinimalPlan([ + { id: "T01", title: "Task One", done: true }, + ])); + writeFileSync(join(sDir, "tasks", "T01-SUMMARY.md"), buildMinimalSummary("T01")); + writeFileSync(join(sDir, "S01-SUMMARY.md"), "# S01 Summary\n\nDone."); + const state = await deriveState(tmp); + assert.equal(state.phase, "validating-milestone"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("deriveState: validation terminal → completing-milestone", async () => { + const tmp = makeTmp("completing"); + try { + const mDir = join(tmp, ".gsd", "milestones", "M001"); + const sDir = join(mDir, "slices", "S01"); + mkdirSync(join(sDir, "tasks"), { recursive: true }); + writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext."); + writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([ + { id: "S01", title: "First Slice", done: true }, + ])); + writeFileSync(join(sDir, "S01-PLAN.md"), buildMinimalPlan([ + { id: "T01", title: "Task One", done: true }, + ])); + writeFileSync(join(sDir, "tasks", "T01-SUMMARY.md"), buildMinimalSummary("T01")); + writeFileSync(join(sDir, "S01-SUMMARY.md"), "# S01 Summary\n\nDone."); + writeFileSync(join(mDir, "M001-VALIDATION.md"), "---\nverdict: pass\nremediation_round: 0\n---\n\n# Validation\n\nAll good."); + const state = await deriveState(tmp); + assert.equal(state.phase, "completing-milestone"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("deriveState: milestone with summary → complete", async () => { + const tmp = makeTmp("complete"); + try { + const mDir = join(tmp, ".gsd", "milestones", "M001"); + mkdirSync(mDir, { recursive: true }); + writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([ + { id: "S01", title: "First Slice", done: true }, + ])); + writeFileSync(join(mDir, "M001-SUMMARY.md"), "# M001 Summary\n\nMilestone complete."); + const state = await deriveState(tmp); + assert.equal(state.phase, "complete"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +// ─── Phase 3: Regression Tests for Specific Bug Fixes ──────────────────── + +test("#1155: completion-transition codes should NOT be fixable at task level", async () => { + // Verify COMPLETION_TRANSITION_CODES exists and contains expected codes + const { COMPLETION_TRANSITION_CODES } = await import("../doctor-types.ts"); + assert.ok(COMPLETION_TRANSITION_CODES.has("all_tasks_done_missing_slice_summary")); + assert.ok(COMPLETION_TRANSITION_CODES.has("all_tasks_done_missing_slice_uat")); + assert.ok(COMPLETION_TRANSITION_CODES.has("all_tasks_done_roadmap_not_checked")); +}); + +test("#1170: needs-discussion phase is correctly derived from CONTEXT-DRAFT.md", async () => { + const tmp = makeTmp("needs-discussion"); + try { + const mDir = join(tmp, ".gsd", "milestones", "M001"); + mkdirSync(mDir, { recursive: true }); + writeFileSync(join(mDir, "M001-CONTEXT-DRAFT.md"), "# Draft\n\nDraft context."); + const state = await deriveState(tmp); + assert.equal(state.phase, "needs-discussion"); + // Verify the dispatch table returns stop for needs-discussion + const result = await resolveDispatch({ + basePath: tmp, mid: "M001", midTitle: "Test", state, prefs: undefined, + }); + assert.equal(result.action, "stop"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("#1176: state.registry is always an array even with corrupt/missing state", async () => { + const tmp = makeTmp("empty-registry"); + try { + mkdirSync(join(tmp, ".gsd", "milestones"), { recursive: true }); + const state = await deriveState(tmp); + assert.ok(Array.isArray(state.registry), "registry should be an array"); + assert.equal(state.registry.length, 0); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("#1243: prose H3 slice headers are parsed correctly", async () => { + const { parseRoadmapSlices } = await import("../roadmap-slices.ts"); + const content = `# M001: Test + +## Roadmap + +### S01: First Feature +Depends on: none + +### S02: Second Feature +Depends on: S01 + +### S03: Third Feature +`; + const slices = parseRoadmapSlices(content); + assert.equal(slices.length, 3, "should parse 3 H3 slices"); + assert.equal(slices[0]!.id, "S01"); + assert.equal(slices[1]!.id, "S02"); + assert.equal(slices[2]!.id, "S03"); + assert.deepEqual(slices[1]!.depends, ["S01"]); +}); + +test("#1243: bold-wrapped and dot-separator slice headers are parsed", async () => { + const { parseRoadmapSlices } = await import("../roadmap-slices.ts"); + const content = `# M001 + +## **S01: Bold Wrapped** +> Demo + +## S02. Dot Separator Title +> Demo +`; + const slices = parseRoadmapSlices(content); + assert.equal(slices.length, 2); + assert.equal(slices[0]!.id, "S01"); + assert.ok(slices[0]!.title.includes("Bold"), `title should contain Bold, got: ${slices[0]!.title}`); + assert.equal(slices[1]!.id, "S02"); +}); + +test("slice dependency blocking → phase: blocked", async () => { + const tmp = makeTmp("dep-blocked"); + try { + const mDir = join(tmp, ".gsd", "milestones", "M001"); + mkdirSync(join(mDir, "slices"), { recursive: true }); + writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext."); + // S01 depends on S02 and S02 depends on S01 — circular! + writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([ + { id: "S01", title: "Slice A", done: false, depends: ["S02"] }, + { id: "S02", title: "Slice B", done: false, depends: ["S01"] }, + ])); + const state = await deriveState(tmp); + assert.equal(state.phase, "blocked"); + assert.ok(state.blockers.length > 0, "should have blockers"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("multi-milestone: M001 complete, M002 active", async () => { + const tmp = makeTmp("multi-milestone"); + try { + // M001 — complete + const m1Dir = join(tmp, ".gsd", "milestones", "M001"); + mkdirSync(m1Dir, { recursive: true }); + writeFileSync(join(m1Dir, "M001-ROADMAP.md"), buildMinimalRoadmap([ + { id: "S01", title: "Done", done: true }, + ])); + writeFileSync(join(m1Dir, "M001-SUMMARY.md"), "# M001 Summary\n\nComplete."); + + // M002 — active, needs planning + const m2Dir = join(tmp, ".gsd", "milestones", "M002"); + mkdirSync(m2Dir, { recursive: true }); + writeFileSync(join(m2Dir, "M002-CONTEXT.md"), "# M002\n\nNew work."); + + const state = await deriveState(tmp); + assert.equal(state.activeMilestone?.id, "M002"); + assert.equal(state.phase, "pre-planning"); + assert.equal(state.registry.length, 2); + assert.equal(state.registry[0]!.status, "complete"); + assert.equal(state.registry[1]!.status, "active"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("blocker_discovered in task summary → replanning-slice", async () => { + const tmp = makeTmp("replan"); + try { + const mDir = join(tmp, ".gsd", "milestones", "M001"); + const sDir = join(mDir, "slices", "S01"); + mkdirSync(join(sDir, "tasks"), { recursive: true }); + writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext."); + writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([ + { id: "S01", title: "Test", done: false }, + ])); + writeFileSync(join(sDir, "S01-PLAN.md"), buildMinimalPlan([ + { id: "T01", title: "Done", done: true }, + { id: "T02", title: "Todo", done: false }, + ])); + writeFileSync(join(sDir, "tasks", "T01-PLAN.md"), "# T01\nPlan."); + writeFileSync(join(sDir, "tasks", "T02-PLAN.md"), "# T02\nPlan."); + writeFileSync(join(sDir, "tasks", "T01-SUMMARY.md"), [ + "---", + "id: T01", + "parent: S01", + "milestone: M001", + "blocker_discovered: true", + "---", + "", + "# T01: Blocker found", + "", + "API doesn't support this.", + ].join("\n")); + + const state = await deriveState(tmp); + assert.equal(state.phase, "replanning-slice"); + assert.ok(state.blockers[0]!.includes("T01"), "blocker should reference T01"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +// ─── Phase 4: Edge Cases ───────────────────────────────────────────────── + +test("empty plan file (0 tasks) → stays in planning", async () => { + const tmp = makeTmp("empty-plan"); + try { + const mDir = join(tmp, ".gsd", "milestones", "M001"); + const sDir = join(mDir, "slices", "S01"); + mkdirSync(join(sDir, "tasks"), { recursive: true }); + writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext."); + writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([ + { id: "S01", title: "Test", done: false }, + ])); + // Plan file exists but has no task entries + writeFileSync(join(sDir, "S01-PLAN.md"), "# S01: Test\n\n**Goal:** test\n\n## Tasks\n"); + + const state = await deriveState(tmp); + assert.equal(state.phase, "planning"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("parked milestone is not treated as active or complete", async () => { + const tmp = makeTmp("parked"); + try { + const mDir = join(tmp, ".gsd", "milestones", "M001"); + mkdirSync(mDir, { recursive: true }); + writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([ + { id: "S01", title: "Test", done: false }, + ])); + writeFileSync(join(mDir, "M001-PARKED.md"), "Parked for later."); + + const state = await deriveState(tmp); + assert.equal(state.registry[0]!.status, "parked"); + assert.equal(state.activeMilestone, null); + // Phase should be pre-planning (all milestones parked, not complete) + assert.equal(state.phase, "pre-planning"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +// ─── Phase 5: Defensive Guards ─────────────────────────────────────────── + +test("dispatch returns stop when phase=summarizing but activeSlice is null (corrupt state)", async () => { + const corruptState: GSDState = { + activeMilestone: { id: "M001", title: "Test" }, + activeSlice: null, // BUG: summarizing should always have activeSlice + activeTask: null, + phase: "summarizing", + recentDecisions: [], + blockers: [], + nextAction: "", + registry: [{ id: "M001", title: "Test", status: "active" }], + requirements: { active: 0, validated: 0, deferred: 0, outOfScope: 0, blocked: 0, total: 0 }, + progress: { milestones: { done: 0, total: 1 } }, + }; + const result = await resolveDispatch({ + basePath: "/tmp/fake", mid: "M001", midTitle: "Test", state: corruptState, prefs: undefined, + }); + assert.equal(result.action, "stop", "should stop instead of crashing"); + assert.ok((result as any).reason.includes("no active slice"), `reason should mention missing slice: ${(result as any).reason}`); +}); + +test("dispatch returns stop when phase=executing but activeSlice is null (corrupt state)", async () => { + const corruptState: GSDState = { + activeMilestone: { id: "M001", title: "Test" }, + activeSlice: null, + activeTask: { id: "T01", title: "Task" }, + phase: "executing", + recentDecisions: [], + blockers: [], + nextAction: "", + registry: [{ id: "M001", title: "Test", status: "active" }], + requirements: { active: 0, validated: 0, deferred: 0, outOfScope: 0, blocked: 0, total: 0 }, + progress: { milestones: { done: 0, total: 1 } }, + }; + const result = await resolveDispatch({ + basePath: "/tmp/fake", mid: "M001", midTitle: "Test", state: corruptState, prefs: undefined, + }); + assert.equal(result.action, "stop", "should stop instead of crashing"); +}); + +// ─── Phase 6: Worktree & Lock Consistency ──────────────────────────────── + +test("repoIdentity returns same hash for main repo and worktree", async () => { + // This test verifies the fix for #1288 — identity hash must be stable + // across worktree and non-worktree contexts. + const { repoIdentity } = await import("../repo-identity.ts"); + // Call from the current directory (main repo) + const hash = repoIdentity(process.cwd()); + assert.ok(hash.length === 12, `hash should be 12 hex chars, got: ${hash}`); + assert.match(hash, /^[a-f0-9]{12}$/, `hash should be hex, got: ${hash}`); +}); + +test("session lock settings: retry path matches primary stale timeout", async () => { + // Verify the fix for #1304 — retry lock must use same settings as primary + const lockSource = (await import("node:fs")).readFileSync( + "src/resources/extensions/gsd/session-lock.ts", "utf-8" + ); + // Find all stale: settings + const staleMatches = [...lockSource.matchAll(/stale:\s*(\d[\d_]*)/g)]; + const staleValues = staleMatches.map(m => parseInt(m[1]!.replace(/_/g, ""), 10)); + // All stale values should be the same (primary and retry aligned) + const uniqueStale = [...new Set(staleValues)]; + assert.equal(uniqueStale.length, 1, `all stale timeouts should be identical, got: ${staleValues.join(", ")}`); +}); + +test("COMPLETION_TRANSITION_CODES are a subset of DoctorIssueCode", async () => { + const { COMPLETION_TRANSITION_CODES } = await import("../doctor-types.ts"); + // Just verify the set is non-empty and contains expected codes + assert.ok(COMPLETION_TRANSITION_CODES.size >= 3, "should have at least 3 transition codes"); + for (const code of COMPLETION_TRANSITION_CODES) { + assert.ok(typeof code === "string", `code should be string: ${code}`); + assert.ok(code.startsWith("all_tasks_done_"), `code should start with all_tasks_done_: ${code}`); + } +}); + +// ─── Scope 2: State Derivation — Array Safety ──────────────────────────── + +test("deriveState: registry is always an array with malformed roadmap", async () => { + const tmp = makeTmp("malformed-roadmap"); + try { + const mDir = join(tmp, ".gsd", "milestones", "M001"); + mkdirSync(mDir, { recursive: true }); + writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext."); + // Roadmap exists but is completely empty + writeFileSync(join(mDir, "M001-ROADMAP.md"), ""); + const state = await deriveState(tmp); + assert.ok(Array.isArray(state.registry), "registry must be array"); + assert.equal(state.activeMilestone?.id, "M001"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("deriveState: plan with garbled content still returns valid state", async () => { + const tmp = makeTmp("garbled-plan"); + try { + const mDir = join(tmp, ".gsd", "milestones", "M001"); + const sDir = join(mDir, "slices", "S01"); + mkdirSync(join(sDir, "tasks"), { recursive: true }); + writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext."); + writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([ + { id: "S01", title: "Test", done: false }, + ])); + // Plan file exists but contains garbage + writeFileSync(join(sDir, "S01-PLAN.md"), "just some random text\nno tasks here\n!!!"); + const state = await deriveState(tmp); + // Should fall back to planning since no tasks parsed + assert.equal(state.phase, "planning"); + assert.equal(state.activeSlice?.id, "S01"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +// ─── Scope 4: Lock Management — Exit Handler Verification ──────────────── + +test("session lock: releaseSessionLock removes auto.lock file", async () => { + const tmp = makeTmp("lock-release"); + try { + const gsd = join(tmp, ".gsd"); + mkdirSync(gsd, { recursive: true }); + const lockFile = join(gsd, "auto.lock"); + writeFileSync(lockFile, JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() })); + assert.ok(existsSync(lockFile), "lock file should exist before release"); + + const { releaseSessionLock } = await import("../session-lock.ts"); + releaseSessionLock(tmp); + + assert.ok(!existsSync(lockFile), "lock file should be removed after release"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("session lock: onCompromised handler exists in both primary and retry paths", async () => { + const lockSource = readFileSync( + "src/resources/extensions/gsd/session-lock.ts", "utf-8" + ); + const compromisedMatches = [...lockSource.matchAll(/onCompromised/g)]; + // Should have at least 2 onCompromised handlers (primary + retry) + // plus the flag declaration and the check in validateSessionLock + assert.ok(compromisedMatches.length >= 3, + `expected ≥3 onCompromised references (primary + retry + flag), got ${compromisedMatches.length}`); +}); + +// ─── Scope 5: Crash Recovery — Message Guidance per Unit Type ──────────── + +test("crash recovery: formatCrashInfo includes guidance for bootstrap crash", async () => { + const { formatCrashInfo } = await import("../crash-recovery.ts"); + const info = formatCrashInfo({ + pid: 12345, + startedAt: new Date().toISOString(), + unitType: "starting", + unitId: "bootstrap", + unitStartedAt: new Date().toISOString(), + completedUnits: 0, + }); + assert.ok(info.includes("bootstrap"), "should mention bootstrap"); + assert.ok(info.includes("No work was lost") || info.includes("/gsd auto"), + "should include recovery guidance for bootstrap crash"); +}); + +test("crash recovery: formatCrashInfo includes guidance for execute-task crash", async () => { + const { formatCrashInfo } = await import("../crash-recovery.ts"); + const info = formatCrashInfo({ + pid: 12345, + startedAt: new Date().toISOString(), + unitType: "execute-task", + unitId: "M001/S01/T02", + unitStartedAt: new Date().toISOString(), + completedUnits: 5, + }); + assert.ok(info.includes("execute"), "should mention execute"); + assert.ok(info.includes("resume") || info.includes("preserved") || info.includes("/gsd auto"), + "should include recovery guidance for task crash"); +}); + +test("crash recovery: formatCrashInfo includes guidance for complete-slice crash", async () => { + const { formatCrashInfo } = await import("../crash-recovery.ts"); + const info = formatCrashInfo({ + pid: 12345, + startedAt: new Date().toISOString(), + unitType: "complete-slice", + unitId: "M001/S01", + unitStartedAt: new Date().toISOString(), + completedUnits: 10, + }); + assert.ok(info.includes("complete"), "should mention complete"); + assert.ok(info.includes("finish") || info.includes("/gsd auto"), + "should include recovery guidance for completion crash"); +}); + +test("crash recovery: formatCrashInfo includes guidance for research crash", async () => { + const { formatCrashInfo } = await import("../crash-recovery.ts"); + const info = formatCrashInfo({ + pid: 12345, + startedAt: new Date().toISOString(), + unitType: "research-milestone", + unitId: "M001", + unitStartedAt: new Date().toISOString(), + completedUnits: 1, + }); + assert.ok(info.includes("research"), "should mention research"); + assert.ok(info.includes("incomplete") || info.includes("re-run") || info.includes("/gsd auto"), + "should include recovery guidance for research crash"); +}); + +// ─── Scope 6: Milestone Transitions — Dispatch Flow ───────────────────── + +test("dispatch: needs-discussion stops with discussion guidance", async () => { + const tmp = makeTmp("dispatch-discussion"); + try { + const mDir = join(tmp, ".gsd", "milestones", "M001"); + mkdirSync(mDir, { recursive: true }); + writeFileSync(join(mDir, "M001-CONTEXT-DRAFT.md"), "# Draft\n\nIdeas."); + const state = await deriveState(tmp); + const result = await resolveDispatch({ + basePath: tmp, mid: "M001", midTitle: "Test", state, prefs: undefined, + }); + assert.equal(result.action, "stop"); + assert.ok((result as any).reason.includes("discussion") || (result as any).reason.includes("discuss"), + "stop reason should mention discussion"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("dispatch: pre-planning without context stops with guidance", async () => { + const tmp = makeTmp("dispatch-no-context"); + try { + const mDir = join(tmp, ".gsd", "milestones", "M001"); + mkdirSync(mDir, { recursive: true }); + // No context, no roadmap — just a bare milestone directory + const state = await deriveState(tmp); + const result = await resolveDispatch({ + basePath: tmp, mid: "M001", midTitle: "Test", state, prefs: undefined, + }); + assert.equal(result.action, "stop"); + assert.ok((result as any).reason.includes("context") || (result as any).reason.includes("discuss"), + "stop reason should mention missing context"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("dispatch: pre-planning with context dispatches research-milestone", async () => { + const tmp = makeTmp("dispatch-research"); + try { + const mDir = join(tmp, ".gsd", "milestones", "M001"); + mkdirSync(mDir, { recursive: true }); + writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nBuild a thing."); + const state = await deriveState(tmp); + const result = await resolveDispatch({ + basePath: tmp, mid: "M001", midTitle: "Test", state, prefs: undefined, + }); + assert.equal(result.action, "dispatch"); + assert.equal((result as any).unitType, "research-milestone"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("dispatch: executing phase dispatches execute-task", async () => { + const tmp = makeTmp("dispatch-execute"); + try { + const mDir = join(tmp, ".gsd", "milestones", "M001"); + const sDir = join(mDir, "slices", "S01"); + mkdirSync(join(sDir, "tasks"), { recursive: true }); + writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext."); + writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([ + { id: "S01", title: "Test", done: false }, + ])); + writeFileSync(join(sDir, "S01-PLAN.md"), buildMinimalPlan([ + { id: "T01", title: "Do work", done: false }, + ])); + writeFileSync(join(sDir, "tasks", "T01-PLAN.md"), "# T01\nDo the thing."); + const state = await deriveState(tmp); + assert.equal(state.phase, "executing"); + const result = await resolveDispatch({ + basePath: tmp, mid: "M001", midTitle: "Test", state, prefs: undefined, + }); + assert.equal(result.action, "dispatch"); + assert.equal((result as any).unitType, "execute-task"); + assert.equal((result as any).unitId, "M001/S01/T01"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("dispatch: summarizing phase dispatches complete-slice", async () => { + const tmp = makeTmp("dispatch-complete-slice"); + try { + const mDir = join(tmp, ".gsd", "milestones", "M001"); + const sDir = join(mDir, "slices", "S01"); + mkdirSync(join(sDir, "tasks"), { recursive: true }); + writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext."); + writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([ + { id: "S01", title: "Test", done: false }, + ])); + writeFileSync(join(sDir, "S01-PLAN.md"), buildMinimalPlan([ + { id: "T01", title: "Done task", done: true }, + ])); + writeFileSync(join(sDir, "tasks", "T01-SUMMARY.md"), buildMinimalSummary("T01")); + const state = await deriveState(tmp); + assert.equal(state.phase, "summarizing"); + const result = await resolveDispatch({ + basePath: tmp, mid: "M001", midTitle: "Test", state, prefs: undefined, + }); + assert.equal(result.action, "dispatch"); + assert.equal((result as any).unitType, "complete-slice"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("dispatch: validating-milestone dispatches validate-milestone", async () => { + const tmp = makeTmp("dispatch-validate"); + try { + const mDir = join(tmp, ".gsd", "milestones", "M001"); + const sDir = join(mDir, "slices", "S01"); + mkdirSync(join(sDir, "tasks"), { recursive: true }); + writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext."); + writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([ + { id: "S01", title: "Test", done: true }, + ])); + writeFileSync(join(sDir, "S01-PLAN.md"), buildMinimalPlan([ + { id: "T01", title: "Done", done: true }, + ])); + writeFileSync(join(sDir, "tasks", "T01-SUMMARY.md"), buildMinimalSummary("T01")); + writeFileSync(join(sDir, "S01-SUMMARY.md"), "# Summary\nDone."); + const state = await deriveState(tmp); + assert.equal(state.phase, "validating-milestone"); + const result = await resolveDispatch({ + basePath: tmp, mid: "M001", midTitle: "Test", state, prefs: undefined, + }); + assert.equal(result.action, "dispatch"); + assert.equal((result as any).unitType, "validate-milestone"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("dispatch: completing-milestone dispatches complete-milestone", async () => { + const tmp = makeTmp("dispatch-complete-ms"); + try { + const mDir = join(tmp, ".gsd", "milestones", "M001"); + const sDir = join(mDir, "slices", "S01"); + mkdirSync(join(sDir, "tasks"), { recursive: true }); + writeFileSync(join(mDir, "M001-CONTEXT.md"), "# M001\n\nContext."); + writeFileSync(join(mDir, "M001-ROADMAP.md"), buildMinimalRoadmap([ + { id: "S01", title: "Test", done: true }, + ])); + writeFileSync(join(sDir, "S01-PLAN.md"), buildMinimalPlan([ + { id: "T01", title: "Done", done: true }, + ])); + writeFileSync(join(sDir, "tasks", "T01-SUMMARY.md"), buildMinimalSummary("T01")); + writeFileSync(join(sDir, "S01-SUMMARY.md"), "# Summary\nDone."); + writeFileSync(join(mDir, "M001-VALIDATION.md"), "---\nverdict: pass\nremediation_round: 0\n---\n# Validation\nPassed."); + const state = await deriveState(tmp); + assert.equal(state.phase, "completing-milestone"); + const result = await resolveDispatch({ + basePath: tmp, mid: "M001", midTitle: "Test", state, prefs: undefined, + }); + assert.equal(result.action, "dispatch"); + assert.equal((result as any).unitType, "complete-milestone"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +});