From ab2eab01d275e016d83ab0fd596d45f98f1b12ae Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 21 Mar 2026 14:55:34 -0400 Subject: [PATCH] =?UTF-8?q?fix(roadmap):=20detect=20=E2=9C=93=20completion?= =?UTF-8?q?=20marker=20in=20prose=20slice=20headers=20(#1816)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseProseSliceHeaders() always set done:false regardless of ✓ or (Complete) markers in the title, causing auto-mode to repeatedly dispatch complete-slice for already-finished slices. - Detect ✓ prefix before slice ID ("## ✓ S01: Title") - Detect ✓ after separator ("## S01: ✓ Title") - Detect (Complete) suffix ("## S01: Title (Complete)") - Strip markers from title so downstream consumers get clean names - Add prose format support to markSliceDoneInRoadmap Fixes #1803 Co-authored-by: Claude Opus 4.6 (1M context) --- .../extensions/gsd/roadmap-mutations.ts | 15 +- .../extensions/gsd/roadmap-slices.ts | 27 +++- .../gsd/tests/roadmap-slices.test.ts | 137 ++++++++++-------- 3 files changed, 113 insertions(+), 66 deletions(-) diff --git a/src/resources/extensions/gsd/roadmap-mutations.ts b/src/resources/extensions/gsd/roadmap-mutations.ts index a2a55b45c..39521462b 100644 --- a/src/resources/extensions/gsd/roadmap-mutations.ts +++ b/src/resources/extensions/gsd/roadmap-mutations.ts @@ -27,11 +27,24 @@ export function markSliceDoneInRoadmap(basePath: string, mid: string, sid: strin return false; } - const updated = content.replace( + // Try checkbox format first: "- [ ] **S01: Title**" + let updated = content.replace( new RegExp(`^(\\s*-\\s+)\\[ \\]\\s+\\*\\*${sid}:`, "m"), `$1[x] **${sid}:`, ); + // If checkbox format didn't match, try prose format: "## S01: Title" -> "## S01: \u2713 Title" + if (updated === content) { + updated = content.replace( + new RegExp(`^(#{1,4}\\s+(?:\\*{0,2})(?:Slice\\s+)?${sid}\\*{0,2}[:\\s.\\u2014\\u2013-]+\\s*)(.+)`, "m"), + (match, prefix, title) => { + // Already marked done — no-op + if (/^\u2713/.test(title) || /\(Complete\)\s*$/i.test(title)) return match; + return `${prefix}\u2713 ${title}`; + }, + ); + } + if (updated === content) return false; atomicWriteSync(roadmapFile, updated); diff --git a/src/resources/extensions/gsd/roadmap-slices.ts b/src/resources/extensions/gsd/roadmap-slices.ts index 43ac53b92..34f942d67 100644 --- a/src/resources/extensions/gsd/roadmap-slices.ts +++ b/src/resources/extensions/gsd/roadmap-slices.ts @@ -209,16 +209,37 @@ export function parseRoadmapSlices(content: string): RoadmapSliceEntry[] { */ function parseProseSliceHeaders(content: string): RoadmapSliceEntry[] { const slices: RoadmapSliceEntry[] = []; - // Match H1–H4 headers containing S with optional "Slice" prefix and bold markers. + // Match H1-H4 headers containing S with optional "Slice" prefix, bold markers, + // and optional checkmark completion marker before the slice ID. // Separator after the ID is flexible: colon, dash, em/en dash, dot, or just whitespace. - const headerPattern = /^#{1,4}\s+\*{0,2}(?:Slice\s+)?(S\d+)\*{0,2}[:\s.—–-]*\s*(.+)/gm; + const headerPattern = /^#{1,4}\s+\*{0,2}(?:\u2713\s+)?(?:Slice\s+)?(S\d+)\*{0,2}[:\s.\u2014\u2013-]*\s*(.+)/gm; let match: RegExpExecArray | null; + // Check for checkmark before the slice ID (e.g., "## checkmark S01: Title") + const prefixCheckPattern = /^#{1,4}\s+\*{0,2}\u2713\s+/; + while ((match = headerPattern.exec(content)) !== null) { const id = match[1]!; let title = match[2]!.trim().replace(/\*{1,2}$/g, "").trim(); // strip trailing bold markers if (!title) continue; // skip if we only matched the ID with no title + // Detect completion markers: + // 1. Checkmark before the slice ID: "## checkmark S01: Title" + // 2. Checkmark after separator: "## S01: checkmark Title" + // 3. (Complete) suffix: "## S01: Title (Complete)" + const line = match[0]; + let done = prefixCheckPattern.test(line); + + if (!done && title.startsWith("\u2713")) { + done = true; + title = title.replace(/^\u2713\s*/, ""); + } + + if (!done && /\(Complete\)\s*$/i.test(title)) { + done = true; + title = title.replace(/\s*\(Complete\)\s*$/i, ""); + } + // Try to extract depends from prose: "Depends on: S01" or "**Depends on:** S01, S02" const afterHeader = content.slice(match.index + match[0].length); const nextHeader = afterHeader.search(/^#{1,4}\s/m); @@ -235,7 +256,7 @@ function parseProseSliceHeaders(content: string): RoadmapSliceEntry[] { } } - slices.push({ id, title, risk: "medium" as RiskLevel, depends, done: false, demo: "" }); + slices.push({ id, title, risk: "medium" as RiskLevel, depends, done, demo: "" }); } 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 b51d98dca..3188421f7 100644 --- a/src/resources/extensions/gsd/tests/roadmap-slices.test.ts +++ b/src/resources/extensions/gsd/tests/roadmap-slices.test.ts @@ -71,117 +71,130 @@ test("parseRoadmapSlices: comma-separated depends still works", () => { test("parseRoadmapSlices: table format under ## Slices heading (#1736)", () => { const tableContent = [ - "# M001: Test Project", - "", - "## Slices", - "", + "# M001: Test Project", "", "## Slices", "", "| Slice | Title | Risk | Status |", "| --- | --- | --- | --- |", "| S01 | Setup Foundation | Low | [x] Done |", "| S02 | Core Features | High | [ ] Pending |", "| S03 | Polish | Medium | [x] Done |", - "", - "## Boundary Map", + "", "## Boundary Map", ].join("\n"); - const slices = parseRoadmapSlices(tableContent); assert.equal(slices.length, 3, "should parse 3 slices from table"); assert.equal(slices[0]?.id, "S01"); - assert.equal(slices[0]?.title, "Setup Foundation"); assert.equal(slices[0]?.done, true); - assert.equal(slices[0]?.risk, "low"); assert.equal(slices[1]?.id, "S02"); assert.equal(slices[1]?.done, false); - assert.equal(slices[1]?.risk, "high"); - assert.equal(slices[2]?.id, "S03"); assert.equal(slices[2]?.done, true); - assert.equal(slices[2]?.risk, "medium"); }); test("parseRoadmapSlices: table format under ## Slice Overview heading (#1736)", () => { const tableContent = [ - "# M002: Another Project", - "", - "## Slice Overview", - "", - "| ID | Description | Risk | Done |", - "|---|---|---|---|", + "# M002: Another Project", "", "## Slice Overview", "", + "| ID | Description | Risk | Done |", "|---|---|---|---|", "| S01 | Foundation Work | High | [x] |", - "| S02 | API Layer | Medium | [ ] |", - "", + "| S02 | API Layer | Medium | [ ] |", "", ].join("\n"); - const slices = parseRoadmapSlices(tableContent); - assert.equal(slices.length, 2, "should parse slices from Slice Overview table"); - assert.equal(slices[0]?.id, "S01"); - assert.equal(slices[0]?.title, "Foundation Work"); + assert.equal(slices.length, 2); assert.equal(slices[0]?.done, true); - assert.equal(slices[0]?.risk, "high"); - assert.equal(slices[1]?.id, "S02"); assert.equal(slices[1]?.done, false); }); test("parseRoadmapSlices: table with Status Done/Complete text (#1736)", () => { const tableContent = [ - "# M003: Status Text", - "", - "## Slices", - "", - "| Slice | Title | Risk | Status |", - "|---|---|---|---|", + "# M003: Status Text", "", "## Slices", "", + "| Slice | Title | Risk | Status |", "|---|---|---|---|", "| S01 | First | Low | Done |", "| S02 | Second | High | Pending |", - "| S03 | Third | Medium | Completed |", - "", + "| S03 | Third | Medium | Completed |", "", ].join("\n"); - const slices = parseRoadmapSlices(tableContent); assert.equal(slices.length, 3); - assert.equal(slices[0]?.done, true, "Done text marks slice as done"); - assert.equal(slices[1]?.done, false, "Pending text marks slice as not done"); - assert.equal(slices[2]?.done, true, "Completed text marks slice as done"); + assert.equal(slices[0]?.done, true); + assert.equal(slices[1]?.done, false); + assert.equal(slices[2]?.done, true); }); test("parseRoadmapSlices: table with dependencies column (#1736)", () => { const tableContent = [ - "# M004: Deps", - "", - "## Slices", - "", - "| Slice | Title | Risk | Depends | Status |", - "|---|---|---|---|---|", + "# M004: Deps", "", "## Slices", "", + "| Slice | Title | Risk | Depends | Status |", "|---|---|---|---|---|", "| S01 | First | Low | None | Done |", "| S02 | Second | High | S01 | Pending |", - "| S03 | Third | Medium | S01, S02 | [ ] |", - "", + "| S03 | Third | Medium | S01, S02 | [ ] |", "", ].join("\n"); - const slices = parseRoadmapSlices(tableContent); assert.equal(slices.length, 3); - assert.deepEqual(slices[0]?.depends, [], "None deps parsed as empty"); - assert.deepEqual(slices[1]?.depends, ["S01"], "Single dep parsed"); - assert.deepEqual(slices[2]?.depends, ["S01", "S02"], "Multiple deps parsed"); + assert.deepEqual(slices[0]?.depends, []); + assert.deepEqual(slices[1]?.depends, ["S01"]); + assert.deepEqual(slices[2]?.depends, ["S01", "S02"]); }); -test("parseRoadmapSlices: standard checkbox format still works after table support (#1736)", () => { - // Verify the existing checkbox format is not broken by the table parsing addition +test("parseRoadmapSlices: standard checkbox format still works (#1736)", () => { const checkboxContent = [ - "# M005: Unchanged", - "", - "## Slices", - "", + "# M005: Unchanged", "", "## Slices", "", "- [x] **S01: First Slice** `risk:low` `depends:[]`", " > After this: First demo works.", - "- [ ] **S02: Second Slice** `risk:medium` `depends:[S01]`", - "", + "- [ ] **S02: Second Slice** `risk:medium` `depends:[S01]`", "", ].join("\n"); - const slices = parseRoadmapSlices(checkboxContent); assert.equal(slices.length, 2); + assert.equal(slices[0]?.done, true); + assert.equal(slices[1]?.done, false); +}); + +// --- Prose slice header completion marker tests (#1803) --- + +test("parseRoadmapSlices: prose headers with ✓ marker detected as done", () => { + const proseContent = `# M010: Prose Roadmap + +## S01: ✓ First Feature +Some description. + +## S02: Second Feature +Not done yet. + +## S03: ✓ Third Feature +Also done. +`; + const slices = parseRoadmapSlices(proseContent); + assert.equal(slices.length, 3); assert.equal(slices[0]?.id, "S01"); assert.equal(slices[0]?.done, true); - assert.equal(slices[0]?.demo, "First demo works."); - assert.equal(slices[1]?.id, "S02"); + assert.equal(slices[0]?.title, "First Feature"); + assert.equal(slices[1]?.done, false); + assert.equal(slices[2]?.done, true); +}); + +test("parseRoadmapSlices: prose headers with (Complete) marker detected as done", () => { + const proseContent = `# M011: Prose Roadmap + +## S01: First Feature (Complete) +Done slice. + +## S02: Second Feature +In progress. +`; + const slices = parseRoadmapSlices(proseContent); + assert.equal(slices.length, 2); + assert.equal(slices[0]?.done, true); + assert.equal(slices[0]?.title, "First Feature"); + assert.equal(slices[1]?.done, false); +}); + +test("parseRoadmapSlices: prose headers with ✓ prefix before title", () => { + const proseContent = `# M012: Prose + +## ✓ S01: Done Slice +Complete. + +## S02: Pending Slice +Not done. +`; + const slices = parseRoadmapSlices(proseContent); + assert.equal(slices.length, 2); + assert.equal(slices[0]?.done, true); + assert.equal(slices[0]?.title, "Done Slice"); assert.equal(slices[1]?.done, false); - assert.deepEqual(slices[1]?.depends, ["S01"]); });