From 358dc1da6b93e6347c9b01f2a0c47c389e57e56e Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 21 Mar 2026 17:22:23 -0400 Subject: [PATCH] fix(doctor): cascade slice uncheck when task_done_missing_summary unchecks tasks (#1850) (#1858) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/resources/extensions/gsd/doctor.ts | 11 ++ ...sk-done-missing-summary-slice-loop.test.ts | 174 ++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 src/resources/extensions/gsd/tests/doctor-task-done-missing-summary-slice-loop.test.ts diff --git a/src/resources/extensions/gsd/doctor.ts b/src/resources/extensions/gsd/doctor.ts index d683eb863..5e74e0a42 100644 --- a/src/resources/extensions/gsd/doctor.ts +++ b/src/resources/extensions/gsd/doctor.ts @@ -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) { diff --git a/src/resources/extensions/gsd/tests/doctor-task-done-missing-summary-slice-loop.test.ts b/src/resources/extensions/gsd/tests/doctor-task-done-missing-summary-slice-loop.test.ts new file mode 100644 index 000000000..102cd8f1e --- /dev/null +++ b/src/resources/extensions/gsd/tests/doctor-task-done-missing-summary-slice-loop.test.ts @@ -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 { + // ─── 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();