fix(doctor): cascade slice uncheck when task_done_missing_summary unchecks tasks (#1850) (#1858)

When tasks are [x] done but no T##-SUMMARY.md exists, doctor unchecks
the tasks but left the slice [x] done in the roadmap. The state machine
skips done slices, so unchecked tasks never re-execute and doctor fires
again on every start — infinite loop.

After unchecking tasks via task_done_missing_summary, also uncheck the
slice in the roadmap so the state machine re-enters the executing phase.

Fixes #1850

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-03-21 17:22:23 -04:00 committed by GitHub
parent 1ad1b5d061
commit 358dc1da6b
2 changed files with 185 additions and 0 deletions

View file

@ -792,6 +792,7 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
} catch { /* non-fatal */ }
let allTasksDone = plan.tasks.length > 0;
let taskUncheckedByDoctor = false;
for (const task of plan.tasks) {
const taskUnitId = `${unitId}/${task.id}`;
const summaryPath = resolveTaskFile(basePath, milestoneId, slice.id, task.id, "SUMMARY");
@ -810,6 +811,7 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
dryRunCanFix("task_done_missing_summary", `uncheck ${task.id} in plan for ${taskUnitId}`);
if (shouldFix("task_done_missing_summary")) {
await markTaskUndoneInPlan(basePath, milestoneId, slice.id, task.id, fixesApplied);
taskUncheckedByDoctor = true;
}
}
@ -873,6 +875,15 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
allTasksDone = allTasksDone && task.done;
}
// ── #1850: cascade slice uncheck when task_done_missing_summary fires ──
// When doctor unchecks tasks inside a done slice, the slice must also be
// unchecked so the state machine re-enters the executing phase. Without
// this, state.ts skips done slices and the unchecked tasks never run,
// causing doctor to fire again on every start (infinite loop).
if (taskUncheckedByDoctor && slice.done) {
await markSliceUndoneInRoadmap(basePath, milestoneId, slice.id, fixesApplied);
}
// Blocker-without-replan detection
const replanPath = resolveSliceFile(basePath, milestoneId, slice.id, "REPLAN");
if (!replanPath) {

View file

@ -0,0 +1,174 @@
/**
* Regression test for #1850: doctor task_done_missing_summary fix leaves
* slice [x] done in roadmap, causing an infinite doctor loop.
*
* Scenario: A slice is [x] done in the roadmap, has S01-SUMMARY.md (so
* slice_checked_missing_summary never fires), but tasks are [x] done with
* no T##-SUMMARY.md files. Doctor unchecks the tasks but must also uncheck
* the slice so the state machine re-enters the executing phase.
*/
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { runGSDDoctor } from "../doctor.js";
import { createTestContext } from "./test-helpers.ts";
const { assertEq, assertTrue, report } = createTestContext();
async function main(): Promise<void> {
// ─── Setup: slice [x] done with S01-SUMMARY.md, tasks [x] but NO task summaries ───
console.log("\n=== #1850: task_done_missing_summary fix must also uncheck slice ===");
{
const base = mkdtempSync(join(tmpdir(), "gsd-doctor-1850-"));
const gsd = join(base, ".gsd");
const mDir = join(gsd, "milestones", "M001");
const sDir = join(mDir, "slices", "S01");
const tDir = join(sDir, "tasks");
mkdirSync(tDir, { recursive: true });
// Roadmap: slice is [x] done
writeFileSync(join(mDir, "M001-ROADMAP.md"), `# M001: Test Milestone
## Slices
- [x] **S01: Guided Slice** \`risk:low\` \`depends:[]\`
> After this: guided flow works
`);
// Plan: tasks are [x] done
writeFileSync(join(sDir, "S01-PLAN.md"), `# S01: Guided Slice
**Goal:** Test guided flow
**Demo:** Works
## Tasks
- [x] **T01: First task** \`est:10m\`
Do the first thing.
- [x] **T02: Second task** \`est:10m\`
Do the second thing.
- [x] **T03: Third task** \`est:10m\`
Do the third thing.
`);
// Slice summary EXISTS (so slice_checked_missing_summary guard does NOT fire)
writeFileSync(join(sDir, "S01-SUMMARY.md"), `---
id: S01
parent: M001
---
# S01: Guided Slice
Done via guided flow.
`);
// Slice UAT exists
writeFileSync(join(sDir, "S01-UAT.md"), `# S01 UAT
Verified.
`);
// NO task summaries on disk — this is the trigger condition
// ── First pass: diagnose ──
const diagReport = await runGSDDoctor(base, { fix: false });
const taskDoneMissing = diagReport.issues.filter(i => i.code === "task_done_missing_summary");
assertEq(taskDoneMissing.length, 3, "detects 3 tasks with task_done_missing_summary");
// ── Second pass: fix ──
const fixReport = await runGSDDoctor(base, { fix: true });
// Tasks should be unchecked in plan
const plan = readFileSync(join(sDir, "S01-PLAN.md"), "utf-8");
assertTrue(plan.includes("- [ ] **T01:"), "T01 is unchecked in plan after fix");
assertTrue(plan.includes("- [ ] **T02:"), "T02 is unchecked in plan after fix");
assertTrue(plan.includes("- [ ] **T03:"), "T03 is unchecked in plan after fix");
// CRITICAL: Slice must also be unchecked in roadmap to prevent infinite loop
const roadmap = readFileSync(join(mDir, "M001-ROADMAP.md"), "utf-8");
assertTrue(
roadmap.includes("- [ ] **S01:"),
"slice is unchecked in roadmap after task_done_missing_summary fix (prevents infinite loop)"
);
assertTrue(
!roadmap.includes("- [x] **S01:"),
"slice is NOT still [x] done in roadmap"
);
// ── Third pass: re-run doctor should NOT re-detect task_done_missing_summary ──
const rerunReport = await runGSDDoctor(base, { fix: false });
const rerunTaskDone = rerunReport.issues.filter(i => i.code === "task_done_missing_summary");
assertEq(rerunTaskDone.length, 0, "no task_done_missing_summary on re-run (no infinite loop)");
rmSync(base, { recursive: true, force: true });
}
// ─── Partial fix: only some tasks missing summaries ───
console.log("\n=== #1850: partial — some tasks have summaries, some do not ===");
{
const base = mkdtempSync(join(tmpdir(), "gsd-doctor-1850-partial-"));
const gsd = join(base, ".gsd");
const mDir = join(gsd, "milestones", "M001");
const sDir = join(mDir, "slices", "S01");
const tDir = join(sDir, "tasks");
mkdirSync(tDir, { recursive: true });
writeFileSync(join(mDir, "M001-ROADMAP.md"), `# M001: Test Milestone
## Slices
- [x] **S01: Partial Slice** \`risk:low\` \`depends:[]\`
> After this: partial
`);
writeFileSync(join(sDir, "S01-PLAN.md"), `# S01: Partial Slice
**Goal:** Test partial
**Demo:** Works
## Tasks
- [x] **T01: Has summary** \`est:10m\`
This task has a summary.
- [x] **T02: Missing summary** \`est:10m\`
This task does not.
`);
// T01 has a summary, T02 does not
writeFileSync(join(tDir, "T01-SUMMARY.md"), `---
id: T01
parent: S01
milestone: M001
---
# T01: Has summary
**Done**
## What Happened
Done.
`);
writeFileSync(join(sDir, "S01-SUMMARY.md"), `---
id: S01
parent: M001
---
# S01: Partial
`);
writeFileSync(join(sDir, "S01-UAT.md"), `# S01 UAT
Done.
`);
const fixReport = await runGSDDoctor(base, { fix: true });
// T02 should be unchecked, T01 should stay checked
const plan = readFileSync(join(sDir, "S01-PLAN.md"), "utf-8");
assertTrue(plan.includes("- [x] **T01:"), "T01 stays checked (has summary)");
assertTrue(plan.includes("- [ ] **T02:"), "T02 is unchecked (missing summary)");
// Slice must be unchecked because not all tasks are done anymore
const roadmap = readFileSync(join(mDir, "M001-ROADMAP.md"), "utf-8");
assertTrue(
roadmap.includes("- [ ] **S01:"),
"slice is unchecked when any task is unchecked by task_done_missing_summary"
);
rmSync(base, { recursive: true, force: true });
}
report();
}
main();