fix(roadmap): detect ✓ completion marker in prose slice headers (#1816)

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) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-03-21 14:55:34 -04:00 committed by GitHub
parent 72f39b6e23
commit ab2eab01d2
3 changed files with 113 additions and 66 deletions

View file

@ -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);

View file

@ -209,16 +209,37 @@ export function parseRoadmapSlices(content: string): RoadmapSliceEntry[] {
*/
function parseProseSliceHeaders(content: string): RoadmapSliceEntry[] {
const slices: RoadmapSliceEntry[] = [];
// Match H1H4 headers containing S<digits> with optional "Slice" prefix and bold markers.
// Match H1-H4 headers containing S<digits> 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;

View file

@ -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"]);
});