diff --git a/src/resources/extensions/gsd/doctor-types.ts b/src/resources/extensions/gsd/doctor-types.ts index ecbf78499..29bce4f7b 100644 --- a/src/resources/extensions/gsd/doctor-types.ts +++ b/src/resources/extensions/gsd/doctor-types.ts @@ -57,6 +57,7 @@ export type DoctorIssueCode = // GSD state structural checks | "circular_slice_dependency" | "orphaned_slice_directory" + | "missing_slice_dir" | "duplicate_task_id" | "task_file_not_in_plan" | "stale_replan_file" diff --git a/src/resources/extensions/gsd/doctor.ts b/src/resources/extensions/gsd/doctor.ts index 44a3846bb..d683eb863 100644 --- a/src/resources/extensions/gsd/doctor.ts +++ b/src/resources/extensions/gsd/doctor.ts @@ -706,7 +706,26 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean; } const slicePath = resolveSlicePath(basePath, milestoneId, slice.id); - if (!slicePath) continue; + if (!slicePath) { + const expectedPath = relSlicePath(basePath, milestoneId, slice.id); + issues.push({ + severity: slice.done ? "warning" : "error", + code: "missing_slice_dir", + scope: "slice", + unitId, + message: slice.done + ? `Missing slice directory for ${unitId} (slice is complete — cosmetic only)` + : `Missing slice directory for ${unitId}`, + file: expectedPath, + fixable: true, + }); + if (fix) { + const absoluteSliceDir = join(milestonePath, "slices", slice.id); + mkdirSync(absoluteSliceDir, { recursive: true }); + fixesApplied.push(`created ${absoluteSliceDir}`); + } + continue; + } const tasksDir = resolveTasksDir(basePath, milestoneId, slice.id); if (!tasksDir) { diff --git a/src/resources/extensions/gsd/roadmap-slices.ts b/src/resources/extensions/gsd/roadmap-slices.ts index 34f942d67..4c4cb4ceb 100644 --- a/src/resources/extensions/gsd/roadmap-slices.ts +++ b/src/resources/extensions/gsd/roadmap-slices.ts @@ -184,6 +184,14 @@ export function parseRoadmapSlices(content: string): RoadmapSliceEntry[] { } if (currentSlice) slices.push(currentSlice); + + // When the ## Slices section exists but the checkbox parser found nothing + // (e.g. the LLM used H3 prose headers instead of checkboxes), fall through + // to the prose-header parser as a second-chance fallback. + if (slices.length === 0) { + return parseProseSliceHeaders(content); + } + return slices; } diff --git a/src/resources/extensions/gsd/tests/roadmap-slices.test.ts b/src/resources/extensions/gsd/tests/roadmap-slices.test.ts index 3188421f7..3a954d353 100644 --- a/src/resources/extensions/gsd/tests/roadmap-slices.test.ts +++ b/src/resources/extensions/gsd/tests/roadmap-slices.test.ts @@ -198,3 +198,58 @@ Not done. assert.equal(slices[0]?.title, "Done Slice"); assert.equal(slices[1]?.done, false); }); + +// ── Regression tests for #1711 ───────────────────────────────────────────── + +test("parseRoadmapSlices: H3 prose headers under ## Slices section triggers prose fallback (#1711)", () => { + const proseUnderSlices = `# M010: My Milestone + +**Vision:** Ship it. + +## Slices + +### S01 — Setup Environment +Set up the dev environment and tooling. + +### S02 — Build Core +Implement the core logic. +**Depends on:** S01 + +### S03 — Polish UI +Final polish and theming. +**Depends on:** S01, S02 +`; + const slices = parseRoadmapSlices(proseUnderSlices); + assert.equal(slices.length, 3, "should find 3 slices from H3 prose headers under ## Slices"); + assert.equal(slices[0]?.id, "S01"); + assert.equal(slices[0]?.title, "Setup Environment"); + assert.equal(slices[1]?.id, "S02"); + assert.deepEqual(slices[1]?.depends, ["S01"]); + assert.equal(slices[2]?.id, "S03"); + assert.deepEqual(slices[2]?.depends, ["S01", "S02"]); +}); + +test("parseRoadmapSlices: ## Slices with valid checkboxes does NOT invoke prose fallback", () => { + const slices = parseRoadmapSlices(content); + assert.equal(slices.length, 3); + assert.equal(slices[0]?.id, "S01"); + assert.equal(slices[0]?.done, true); +}); + +test("parseRoadmapSlices: ## Slices with only non-matching lines returns prose fallback results", () => { + const weirdContent = `# M020: Odd + +## Slices +Some introductory text that is not a checkbox or a slice header. + +### S01: First Thing +Do the first thing. + +### S02: Second Thing +Do the second thing. +`; + const slices = parseRoadmapSlices(weirdContent); + assert.equal(slices.length, 2, "should fall through to prose parser"); + assert.equal(slices[0]?.id, "S01"); + assert.equal(slices[1]?.id, "S02"); +});