From 2756428e6ed5a64e2e889b3359a555228027b0e4 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 16 Mar 2026 18:01:17 -0400 Subject: [PATCH] fix: reject empty scaffold plan files in plan-slice artifact verification (#699) verifyExpectedArtifact() for plan-slice units only checked whether the plan file existed on disk, not whether it contained actual task entries. When a plan file was created as an empty scaffold during discussion/context (headings but no tasks), the artifact check considered it 'complete' and skipped the dispatch. Since deriveState still returned phase:'planning' (no tasks found), this created an infinite skip loop until auto-mode exhausted its retry budget and stopped silently. Added a content check that requires at least one task entry matching the pattern '- [ ] **T##:' or '- [x] **T##:' before considering a plan-slice artifact valid. This mirrors the existing content-aware check used for execute-task (which verifies checkbox state). Added 3 regression tests covering empty scaffold, valid tasks, and completed tasks. --- src/resources/extensions/gsd/auto-recovery.ts | 10 +++ .../gsd/tests/auto-recovery.test.ts | 64 +++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/src/resources/extensions/gsd/auto-recovery.ts b/src/resources/extensions/gsd/auto-recovery.ts index 4b304d356..6792f83e7 100644 --- a/src/resources/extensions/gsd/auto-recovery.ts +++ b/src/resources/extensions/gsd/auto-recovery.ts @@ -130,6 +130,16 @@ export function verifyExpectedArtifact(unitType: string, unitId: string, base: s if (!absPath) return unitType === "replan-slice"; if (!existsSync(absPath)) return false; + // plan-slice must produce a plan with actual task entries, not just a scaffold. + // The plan file may exist from a prior discussion/context step with only headings + // but no tasks. Without this check the artifact is considered "complete" and the + // unit gets skipped — but deriveState still returns phase:"planning" because the + // plan has no tasks, creating an infinite skip loop (#699). + if (unitType === "plan-slice") { + const planContent = readFileSync(absPath, "utf-8"); + if (!/^- \[[xX ]\] \*\*T\d+:/m.test(planContent)) return false; + } + // execute-task must also have its checkbox marked [x] in the slice plan if (unitType === "execute-task") { const parts = unitId.split("/"); diff --git a/src/resources/extensions/gsd/tests/auto-recovery.test.ts b/src/resources/extensions/gsd/tests/auto-recovery.test.ts index 4ea508ac4..3d0f0fd61 100644 --- a/src/resources/extensions/gsd/tests/auto-recovery.test.ts +++ b/src/resources/extensions/gsd/tests/auto-recovery.test.ts @@ -320,3 +320,67 @@ test("verifyExpectedArtifact detects roadmap [x] change despite parse cache", () cleanup(base); } }); + +// ─── verifyExpectedArtifact: plan-slice empty scaffold regression (#699) ── + +test("verifyExpectedArtifact rejects plan-slice with empty scaffold", () => { + const base = makeTmpBase(); + try { + const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01"); + mkdirSync(sliceDir, { recursive: true }); + writeFileSync(join(sliceDir, "S01-PLAN.md"), "# S01: Test Slice\n\n## Tasks\n\n"); + assert.strictEqual( + verifyExpectedArtifact("plan-slice", "M001/S01", base), + false, + "Empty scaffold should not be treated as completed artifact", + ); + } finally { + cleanup(base); + } +}); + +test("verifyExpectedArtifact accepts plan-slice with actual tasks", () => { + const base = makeTmpBase(); + try { + const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01"); + mkdirSync(sliceDir, { recursive: true }); + writeFileSync(join(sliceDir, "S01-PLAN.md"), [ + "# S01: Test Slice", + "", + "## Tasks", + "", + "- [ ] **T01: Implement feature** `est:2h`", + "- [ ] **T02: Write tests** `est:1h`", + ].join("\n")); + assert.strictEqual( + verifyExpectedArtifact("plan-slice", "M001/S01", base), + true, + "Plan with task entries should be treated as completed artifact", + ); + } finally { + cleanup(base); + } +}); + +test("verifyExpectedArtifact accepts plan-slice with completed tasks", () => { + const base = makeTmpBase(); + try { + const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01"); + mkdirSync(sliceDir, { recursive: true }); + writeFileSync(join(sliceDir, "S01-PLAN.md"), [ + "# S01: Test Slice", + "", + "## Tasks", + "", + "- [x] **T01: Implement feature** `est:2h`", + "- [ ] **T02: Write tests** `est:1h`", + ].join("\n")); + assert.strictEqual( + verifyExpectedArtifact("plan-slice", "M001/S01", base), + true, + "Plan with completed task entries should be treated as completed artifact", + ); + } finally { + cleanup(base); + } +});