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.
This commit is contained in:
Tom Boucher 2026-03-16 18:01:17 -04:00
parent d10412bb1e
commit 2756428e6e
2 changed files with 74 additions and 0 deletions

View file

@ -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("/");

View file

@ -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);
}
});