diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index c830badd9..55b13a80d 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -1245,12 +1245,16 @@ export async function handleAgentEnd( // fixLevel:"task" ensures doctor only fixes task-level issues (e.g. marking // checkboxes). Slice/milestone completion transitions (summary stubs, // roadmap [x] marking) are left for the complete-slice dispatch unit. - // Exception: after complete-slice itself, use fixLevel:"all" so roadmap - // checkboxes get fixed even if complete-slice crashed (#839). + // Exception: after complete-slice and run-uat, use fixLevel:"all" so roadmap + // checkboxes get fixed. run-uat is the terminal unit for a slice — if the + // roadmap checkbox wasn't marked done by complete-slice (e.g. edit failure), + // fixing it here prevents the state machine from re-dispatching run-uat + // indefinitely (#839, #1063). try { const scopeParts = s.currentUnit.id.split("/").slice(0, 2); const doctorScope = scopeParts.join("/"); - const effectiveFixLevel = s.currentUnit.type === "complete-slice" ? "all" as const : "task" as const; + const sliceTerminalUnits = new Set(["complete-slice", "run-uat"]); + const effectiveFixLevel = sliceTerminalUnits.has(s.currentUnit.type) ? "all" as const : "task" as const; const report = await runGSDDoctor(s.basePath, { fix: true, scope: doctorScope, fixLevel: effectiveFixLevel }); if (report.fixesApplied.length > 0) { ctx.ui.notify(`Post-hook: applied ${report.fixesApplied.length} fix(es).`, "info"); diff --git a/src/resources/extensions/gsd/doctor.ts b/src/resources/extensions/gsd/doctor.ts index 24135f316..b18d8b3f6 100644 --- a/src/resources/extensions/gsd/doctor.ts +++ b/src/resources/extensions/gsd/doctor.ts @@ -308,7 +308,13 @@ async function markTaskDoneInPlan(basePath: string, milestoneId: string, sliceId if (!planPath) return; const content = await loadFile(planPath); if (!content) return; - const updated = content.replace(new RegExp(`^-\\s+\\[ \\]\\s+\\*\\*${taskId}:`, "m"), `- [x] **${taskId}:`); + // Allow optional leading whitespace to match the same patterns the plan parser + // accepts. Capture the leading whitespace + "- " so the replacement preserves + // indentation instead of collapsing it (#1063). + const updated = content.replace( + new RegExp(`^(\\s*-\\s+)\\[ \\]\\s+\\*\\*${taskId}:`, "m"), + `$1[x] **${taskId}:`, + ); if (updated !== content) { await saveFile(planPath, updated); fixesApplied.push(`marked ${taskId} done in ${planPath}`); @@ -320,7 +326,13 @@ async function markSliceDoneInRoadmap(basePath: string, milestoneId: string, sli if (!roadmapPath) return; const content = await loadFile(roadmapPath); if (!content) return; - const updated = content.replace(new RegExp(`^-\\s+\\[ \\]\\s+\\*\\*${sliceId}:`, "m"), `- [x] **${sliceId}:`); + // Allow optional leading whitespace to match the same patterns the roadmap + // parser accepts (^\s*-\s+ in roadmap-slices.ts). Capture the prefix so the + // replacement preserves original indentation (#1063). + const updated = content.replace( + new RegExp(`^(\\s*-\\s+)\\[ \\]\\s+\\*\\*${sliceId}:`, "m"), + `$1[x] **${sliceId}:`, + ); if (updated !== content) { await saveFile(roadmapPath, updated); fixesApplied.push(`marked ${sliceId} done in ${roadmapPath}`); diff --git a/src/resources/extensions/gsd/tests/doctor-fixlevel.test.ts b/src/resources/extensions/gsd/tests/doctor-fixlevel.test.ts index b6c52127f..05f6f7f74 100644 --- a/src/resources/extensions/gsd/tests/doctor-fixlevel.test.ts +++ b/src/resources/extensions/gsd/tests/doctor-fixlevel.test.ts @@ -115,6 +115,81 @@ test("fixLevel:all (default) — detects AND fixes completion issues", async () } }); +test("fixLevel:all — marks indented roadmap checkboxes done (#1063)", async () => { + const tmp = makeTmp("indented-roadmap"); + try { + buildScaffold(tmp); + + // Overwrite roadmap with indented checkbox (LLM formatting drift) + writeFileSync(join(tmp, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), `# M001: Test + +## Slices + + - [ ] **S01: Test Slice** \`risk:low\` \`depends:[]\` + > Demo text +`); + + const report = await runGSDDoctor(tmp, { fix: true }); + + const roadmapContent = readFileSync(join(tmp, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "utf8"); + // Should mark [x] while preserving the leading whitespace + assert.ok(roadmapContent.includes(" - [x] **S01"), "indented roadmap checkbox should be marked done"); + // Verify indentation is preserved: line should start with " -", not just "-" + const checkedLine = roadmapContent.split("\n").find(l => l.includes("[x] **S01")); + assert.ok(checkedLine?.startsWith(" -"), `should preserve leading whitespace, got: "${checkedLine}"`); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("fixLevel:all — marks indented task checkboxes done (#1063)", async () => { + const tmp = makeTmp("indented-task"); + try { + const gsd = join(tmp, ".gsd"); + const m = join(gsd, "milestones", "M001"); + const s = join(m, "slices", "S01", "tasks"); + mkdirSync(s, { recursive: true }); + + writeFileSync(join(m, "M001-ROADMAP.md"), `# M001: Test + +## Slices + +- [ ] **S01: Test Slice** \`risk:low\` \`depends:[]\` +`); + + // Plan with indented checkbox + writeFileSync(join(m, "slices", "S01", "S01-PLAN.md"), `# S01: Test Slice + +**Goal:** test + +## Tasks + + - [ ] **T01: Do stuff** \`est:5m\` +`); + + writeFileSync(join(s, "T01-SUMMARY.md"), `--- +id: T01 +parent: S01 +milestone: M001 +duration: 5m +verification_result: passed +completed_at: 2026-01-01 +--- + +# T01: Do stuff + +Done. +`); + + const report = await runGSDDoctor(tmp, { fix: true, fixLevel: "task" }); + + const planContent = readFileSync(join(m, "slices", "S01", "S01-PLAN.md"), "utf8"); + assert.ok(planContent.includes(" - [x] **T01"), "indented task checkbox should be marked done"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + test("fixLevel:task — still fixes task-level bookkeeping (checkbox marking)", async () => { const tmp = makeTmp("task-checkbox"); try {