Merge pull request #735 from trek-e/fix/699-plan-slice-empty-scaffold

fix: reject empty scaffold plan files in plan-slice artifact verification (#699)
This commit is contained in:
TÂCHES 2026-03-16 18:35:38 -06:00 committed by GitHub
commit 3354f6300c
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);
}
});