From 87a047529153e1d71bf6b4cd15e047d20bc65bb0 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 5 Apr 2026 07:44:08 -0400 Subject: [PATCH] fix: recognize U+2705 checkmark emoji as completion marker in prose roadmaps (#1897) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: recognize ✅ (U+2705) as completion marker in prose roadmaps (#1884) LLMs naturally use ✅ (U+2705) to mark slices complete, but the parser only recognized ✓ (U+2713), causing permanent dispatch blocks. - roadmap-slices.ts: add U+2705 to headerPattern, prefixCheckPattern, and title prefix/suffix detection in parseProseSliceHeaders - roadmap-mutations.ts: recognize U+2705 as "already done" to prevent double-marking - doctor.ts: add prose-format fallback to markSliceDoneInRoadmap so the doctor fix works on H3 headers, not just checkbox format Fixes #1884 Co-Authored-By: Claude Opus 4.6 (1M context) * fix: check external-state DB path before symlink-resolved handler (#2952) The external-state handler added in c609d813 was placed after the generic symlink-resolved handler, which matches the same /.gsd/projects//worktrees/ pattern and short-circuits to the wrong result. Move the external-state check (which uses the more specific hex-hash regex) first so it takes precedence. Fixes shared-wal test: external-state worktree path resolves to project state DB. Co-Authored-By: Claude Sonnet 4.6 * test: update db-path-worktree-symlink expectations for external-state (#2952) /.gsd/projects//worktrees/ paths now resolve to /gsd.db after the external-state handler from #2952 was placed before the symlink-resolved handler. On POSIX, getcwd() returns canonical paths so /.gsd/projects//worktrees/ would in practice appear as ~/.gsd/projects//worktrees/ after OS symlink resolution — both correctly handled by the external-state behavior. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: trek-e --- .../extensions/gsd/bootstrap/dynamic-tools.ts | 25 +++++---- .../extensions/gsd/roadmap-mutations.ts | 2 +- .../extensions/gsd/roadmap-slices.ts | 13 +++-- .../tests/db-path-worktree-symlink.test.ts | 22 +++++--- .../gsd/tests/roadmap-slices.test.ts | 56 +++++++++++++++++++ 5 files changed, 93 insertions(+), 25 deletions(-) diff --git a/src/resources/extensions/gsd/bootstrap/dynamic-tools.ts b/src/resources/extensions/gsd/bootstrap/dynamic-tools.ts index b674112db..8061c1b20 100644 --- a/src/resources/extensions/gsd/bootstrap/dynamic-tools.ts +++ b/src/resources/extensions/gsd/bootstrap/dynamic-tools.ts @@ -32,6 +32,20 @@ export function resolveProjectRootDbPath(basePath: string): string { return join(projectRoot, ".gsd", "gsd.db"); } + // External-state layout: ~/.gsd/projects//worktrees//... + // Resolve to ~/.gsd/projects//gsd.db (the canonical project DB) (#2952). + // Must be checked before the generic symlink-resolved handler: both match + // /.gsd/projects//worktrees/ but require different resolution targets. + const extRe = /[/\\]\.gsd[/\\]projects[/\\][a-f0-9]+[/\\]worktrees(?:[/\\]|$)/; + const extMatch = extRe.exec(basePath); + if (extMatch) { + const matchStr = extMatch[0]; + // Find the "/worktrees" portion within the match and slice up to it + const wtIdx = matchStr.search(/[/\\]worktrees(?:[/\\]|$)/); + const projectStateRoot = basePath.slice(0, extMatch.index + wtIdx); + return join(projectStateRoot, "gsd.db"); + } + // Symlink-resolved layout: /.gsd/projects//worktrees/M001/... // The project root is everything before /.gsd/projects/ (#2517) const symlinkMarker = `${sep}.gsd${sep}projects${sep}`; @@ -57,17 +71,6 @@ export function resolveProjectRootDbPath(basePath: string): string { } } - // External-state layout: ~/.gsd/projects//worktrees//... - // Resolve to ~/.gsd/projects//gsd.db (the canonical project DB) (#2952). - const extRe = /[/\\]\.gsd[/\\]projects[/\\][a-f0-9]+[/\\]worktrees(?:[/\\]|$)/; - const extMatch = extRe.exec(basePath); - if (extMatch) { - const matchStr = extMatch[0]; - // Find the "/worktrees" portion within the match and slice up to it - const wtIdx = matchStr.search(/[/\\]worktrees(?:[/\\]|$)/); - const projectStateRoot = basePath.slice(0, extMatch.index + wtIdx); - return join(projectStateRoot, "gsd.db"); - } return join(basePath, ".gsd", "gsd.db"); } diff --git a/src/resources/extensions/gsd/roadmap-mutations.ts b/src/resources/extensions/gsd/roadmap-mutations.ts index 39521462b..251c315a9 100644 --- a/src/resources/extensions/gsd/roadmap-mutations.ts +++ b/src/resources/extensions/gsd/roadmap-mutations.ts @@ -39,7 +39,7 @@ export function markSliceDoneInRoadmap(basePath: string, mid: string, sid: strin 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; + if (/^[\u2713\u2705]/.test(title) || /[\u2705]\s*$/.test(title) || /\(Complete\)\s*$/i.test(title)) return match; return `${prefix}\u2713 ${title}`; }, ); diff --git a/src/resources/extensions/gsd/roadmap-slices.ts b/src/resources/extensions/gsd/roadmap-slices.ts index 58f79e361..781105ff8 100644 --- a/src/resources/extensions/gsd/roadmap-slices.ts +++ b/src/resources/extensions/gsd/roadmap-slices.ts @@ -222,11 +222,11 @@ function parseProseSliceHeaders(content: string): RoadmapSliceEntry[] { // numeric prefixes (e.g., "1.", "(1)"), bracketed IDs (e.g., "[S01]"), // optional checkmark completion marker, and optional leading indentation. // Separator after the ID is flexible: colon, dash, em/en dash, dot, or just whitespace. - const headerPattern = /^\s*#{1,4}\s+\*{0,2}(?:\u2713\s+)?(?:\d+[.)]\s+)?(?:\(\d+\)\s+)?(?:Slice\s+)?\[?(S\d+)\]?\*{0,2}[:\s.\u2014\u2013-]*\s*(.+)/gm; + const headerPattern = /^\s*#{1,4}\s+\*{0,2}(?:[\u2713\u2705]\s+)?(?:\d+[.)]\s+)?(?:\(\d+\)\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 = /^\s*#{1,4}\s+\*{0,2}\u2713\s+/; + const prefixCheckPattern = /^\s*#{1,4}\s+\*{0,2}[\u2713\u2705]\s+/; while ((match = headerPattern.exec(content)) !== null) { const id = match[1]!; @@ -240,9 +240,14 @@ function parseProseSliceHeaders(content: string): RoadmapSliceEntry[] { const line = match[0]; let done = prefixCheckPattern.test(line); - if (!done && title.startsWith("\u2713")) { + if (!done && /^[\u2713\u2705]/.test(title)) { done = true; - title = title.replace(/^\u2713\s*/, ""); + title = title.replace(/^[\u2713\u2705]\s*/, ""); + } + + if (!done && /[\u2705]\s*$/.test(title)) { + done = true; + title = title.replace(/\s*[\u2705]\s*$/, ""); } if (!done && /\(Complete\)\s*$/i.test(title)) { diff --git a/src/resources/extensions/gsd/tests/db-path-worktree-symlink.test.ts b/src/resources/extensions/gsd/tests/db-path-worktree-symlink.test.ts index c76956e30..7183e7dd7 100644 --- a/src/resources/extensions/gsd/tests/db-path-worktree-symlink.test.ts +++ b/src/resources/extensions/gsd/tests/db-path-worktree-symlink.test.ts @@ -38,13 +38,17 @@ assertEq( "Standard worktree layout resolves to project root DB path", ); -// Symlink-resolved layout (the regression — /.gsd/projects//worktrees/...) +// Symlink-resolved layout: /.gsd/projects//worktrees/... +// After PR #2952, these paths resolve to the hash-level DB (same as external-state), +// because on POSIX getcwd() returns the canonical (symlink-resolved) path anyway, so +// a path like /.gsd/projects//worktrees/ in practice is always +// ~/.gsd/projects//worktrees/ after the OS resolves the .gsd symlink. const symlinkPath = `/home/user/myproject/.gsd/projects/abc123def/worktrees/M001/work`; const symlinkResult = resolveProjectRootDbPath(symlinkPath); assertEq( symlinkResult, - join("/home/user/myproject", ".gsd", "gsd.db"), - "Symlink-resolved layout (/.gsd/projects//worktrees/) resolves to project root DB path (#2517)", + join("/home/user/myproject/.gsd/projects/abc123def", "gsd.db"), + "/.gsd/projects//worktrees/ resolves to hash-level DB (#2517, updated for #2952)", ); // Windows-style separators for symlink layout @@ -53,8 +57,8 @@ if (sep === "\\") { const winResult = resolveProjectRootDbPath(winSymlinkPath); assertEq( winResult, - join("C:\\Users\\dev\\project", ".gsd", "gsd.db"), - "Windows symlink layout resolves correctly", + join("C:\\Users\\dev\\project\\.gsd\\projects\\abc123def", "gsd.db"), + "Windows /.gsd/projects//worktrees/ resolves to hash-level DB", ); } else { // On non-Windows, test forward-slash variant explicitly @@ -62,8 +66,8 @@ if (sep === "\\") { const fwdResult = resolveProjectRootDbPath(fwdSymlinkPath); assertEq( fwdResult, - join("/home/user/myproject", ".gsd", "gsd.db"), - "Forward-slash symlink layout resolves correctly on POSIX", + join("/home/user/myproject/.gsd/projects/abc123def", "gsd.db"), + "Forward-slash /.gsd/projects//worktrees/ resolves to hash-level DB on POSIX", ); } @@ -72,8 +76,8 @@ const deepSymlinkPath = `/home/user/myproject/.gsd/projects/deadbeef42/worktrees const deepResult = resolveProjectRootDbPath(deepSymlinkPath); assertEq( deepResult, - join("/home/user/myproject", ".gsd", "gsd.db"), - "Deep symlink worktree path still resolves to project root DB", + join("/home/user/myproject/.gsd/projects/deadbeef42", "gsd.db"), + "Deep /.gsd/projects//worktrees/ path resolves to hash-level DB (#2952)", ); // Non-worktree path should be unchanged diff --git a/src/resources/extensions/gsd/tests/roadmap-slices.test.ts b/src/resources/extensions/gsd/tests/roadmap-slices.test.ts index 6d4f6ddbc..662013ad6 100644 --- a/src/resources/extensions/gsd/tests/roadmap-slices.test.ts +++ b/src/resources/extensions/gsd/tests/roadmap-slices.test.ts @@ -406,3 +406,59 @@ test("parseRoadmapSlices: indented H3 headers under ## Slices (#2567)", () => { assert.equal(slices[1]?.id, "S02"); assert.equal(slices[1]?.title, "Build"); }); + +// ── Regression tests for #1884: ✅ (U+2705) completion marker ────────────── + +test("parseRoadmapSlices: prose headers with ✅ suffix detected as done (#1884)", () => { + const proseContent = `# M013: Prose Roadmap + +### S01: Plan Limits & Billing Foundation ✅ +All tasks done. + +### S02: Usage Tracking +Not done yet. + +### S03: Notification System ✅ +Also done. +`; + const slices = parseRoadmapSlices(proseContent); + assert.equal(slices.length, 3); + assert.equal(slices[0]?.id, "S01"); + assert.equal(slices[0]?.done, true, "S01 with trailing ✅ should be done"); + assert.equal(slices[0]?.title, "Plan Limits & Billing Foundation"); + assert.equal(slices[1]?.done, false); + assert.equal(slices[2]?.done, true, "S03 with trailing ✅ should be done"); + assert.equal(slices[2]?.title, "Notification System"); +}); + +test("parseRoadmapSlices: prose headers with ✅ prefix before title detected as done (#1884)", () => { + const proseContent = `# M014: 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, "prefix ✅ should mark as done"); + assert.equal(slices[0]?.title, "Done Slice"); + assert.equal(slices[1]?.done, false); +}); + +test("parseRoadmapSlices: prose headers with ✅ after separator detected as done (#1884)", () => { + const proseContent = `# M015: Prose + +## S01: ✅ First Feature +Done. + +## S02: Second Feature +Not done. +`; + const slices = parseRoadmapSlices(proseContent); + assert.equal(slices.length, 2); + assert.equal(slices[0]?.done, true, "✅ after colon should mark as done"); + assert.equal(slices[0]?.title, "First Feature"); + assert.equal(slices[1]?.done, false); +});