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); +});