diff --git a/src/resources/extensions/gsd/auto-dispatch.ts b/src/resources/extensions/gsd/auto-dispatch.ts index 18f7aac26..51e5ff4fd 100644 --- a/src/resources/extensions/gsd/auto-dispatch.ts +++ b/src/resources/extensions/gsd/auto-dispatch.ts @@ -626,6 +626,25 @@ export const DISPATCH_RULES: DispatchRule[] = [ match: async ({ state, mid, midTitle, basePath }) => { if (state.phase !== "completing-milestone") return null; + // Safety guard (#2675): block completion when VALIDATION verdict is + // needs-remediation. The state machine treats needs-remediation as + // terminal (to prevent validate-milestone loops per #832), but + // completing-milestone should NOT proceed — remediation work is needed. + const validationFile = resolveMilestoneFile(basePath, mid, "VALIDATION"); + if (validationFile) { + const validationContent = await loadFile(validationFile); + if (validationContent) { + const verdict = extractVerdict(validationContent); + if (verdict === "needs-remediation") { + return { + action: "stop", + reason: `Cannot complete milestone ${mid}: VALIDATION verdict is "needs-remediation". Address the remediation findings and re-run validation, or update the verdict manually.`, + level: "warning", + }; + } + } + } + // Safety guard (#1368): verify all roadmap slices have SUMMARY files. const missingSlices = findMissingSummaries(basePath, mid); if (missingSlices.length > 0) { diff --git a/src/resources/extensions/gsd/tests/remediation-completion-guard.test.ts b/src/resources/extensions/gsd/tests/remediation-completion-guard.test.ts new file mode 100644 index 000000000..93a9b55bd --- /dev/null +++ b/src/resources/extensions/gsd/tests/remediation-completion-guard.test.ts @@ -0,0 +1,110 @@ +/** + * Regression test for #2675: completing-milestone dispatch rule must + * block completion when VALIDATION verdict is "needs-remediation". + * + * Without this guard, needs-remediation + allSlicesDone causes a loop: + * complete-milestone dispatched → agent refuses (correct) → no SUMMARY + * → re-dispatch → repeat until stuck detection fires. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { DISPATCH_RULES } from "../auto-dispatch.ts"; + +/** Find the completing-milestone dispatch rule */ +const completingRule = DISPATCH_RULES.find(r => r.name === "completing-milestone → complete-milestone"); + +test("completing-milestone dispatch rule exists", () => { + assert.ok(completingRule, "rule should exist in DISPATCH_RULES"); +}); + +test("completing-milestone blocks when VALIDATION verdict is needs-remediation (#2675)", async () => { + const base = mkdtempSync(join(tmpdir(), "gsd-remediation-")); + mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true }); + + try { + // Write a VALIDATION file with needs-remediation verdict + writeFileSync( + join(base, ".gsd", "milestones", "M001", "M001-VALIDATION.md"), + [ + "---", + "verdict: needs-remediation", + "remediation_round: 0", + "---", + "", + "# Validation Report", + "", + "3 success criteria failed. Remediation required.", + ].join("\n"), + ); + + const ctx = { + mid: "M001", + midTitle: "Test Milestone", + basePath: base, + state: { phase: "completing-milestone" } as any, + prefs: {} as any, + session: undefined, + }; + + const result = await completingRule!.match(ctx); + + assert.ok(result !== null, "rule should match"); + assert.equal(result!.action, "stop", "should return stop action"); + if (result!.action === "stop") { + assert.equal(result!.level, "warning", "should be warning level (pausable)"); + assert.ok( + result!.reason.includes("needs-remediation"), + "reason should mention needs-remediation", + ); + } + } finally { + rmSync(base, { recursive: true, force: true }); + } +}); + +test("completing-milestone proceeds normally when VALIDATION verdict is pass (#2675 guard)", async () => { + const base = mkdtempSync(join(tmpdir(), "gsd-remediation-")); + mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true }); + + try { + // Write a VALIDATION file with pass verdict + writeFileSync( + join(base, ".gsd", "milestones", "M001", "M001-VALIDATION.md"), + [ + "---", + "verdict: pass", + "---", + "", + "# Validation Report", + "", + "All criteria met.", + ].join("\n"), + ); + + const ctx = { + mid: "M001", + midTitle: "Test Milestone", + basePath: base, + state: { phase: "completing-milestone" } as any, + prefs: {} as any, + session: undefined, + }; + + const result = await completingRule!.match(ctx); + + // Should NOT return a stop — should either dispatch or return stop for + // a different reason (e.g. missing SUMMARY files, no implementation) + if (result && result.action === "stop") { + assert.ok( + !result.reason.includes("needs-remediation"), + "pass verdict should NOT trigger the remediation guard", + ); + } + } finally { + rmSync(base, { recursive: true, force: true }); + } +});