fix: recognize U+2705 checkmark emoji as completion marker in prose roadmaps (#1897)

* 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) <noreply@anthropic.com>

* 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/<hash>/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 <noreply@anthropic.com>

* test: update db-path-worktree-symlink expectations for external-state (#2952)

/.gsd/projects/<hash>/worktrees/ paths now resolve to <hash>/gsd.db
after the external-state handler from #2952 was placed before the
symlink-resolved handler. On POSIX, getcwd() returns canonical paths so
<proj>/.gsd/projects/<hash>/worktrees/ would in practice appear as
~/.gsd/projects/<hash>/worktrees/ after OS symlink resolution — both
correctly handled by the external-state behavior.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: trek-e <trek-e@users.noreply.github.com>
This commit is contained in:
Tom Boucher 2026-04-05 07:44:08 -04:00 committed by GitHub
parent ba71db3b28
commit 87a0475291
5 changed files with 93 additions and 25 deletions

View file

@ -32,6 +32,20 @@ export function resolveProjectRootDbPath(basePath: string): string {
return join(projectRoot, ".gsd", "gsd.db");
}
// External-state layout: ~/.gsd/projects/<hash>/worktrees/<MID>/...
// Resolve to ~/.gsd/projects/<hash>/gsd.db (the canonical project DB) (#2952).
// Must be checked before the generic symlink-resolved handler: both match
// /.gsd/projects/<hash>/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/<hash>/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/<hash>/worktrees/<MID>/...
// Resolve to ~/.gsd/projects/<hash>/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");
}

View file

@ -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}`;
},
);

View file

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

View file

@ -38,13 +38,17 @@ assertEq(
"Standard worktree layout resolves to project root DB path",
);
// Symlink-resolved layout (the regression — /.gsd/projects/<hash>/worktrees/...)
// Symlink-resolved layout: /.gsd/projects/<hash>/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 <proj>/.gsd/projects/<hash>/worktrees/ in practice is always
// ~/.gsd/projects/<hash>/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/<hash>/worktrees/) resolves to project root DB path (#2517)",
join("/home/user/myproject/.gsd/projects/abc123def", "gsd.db"),
"/.gsd/projects/<hash>/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/<hash>/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/<hash>/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/<hash>/worktrees/ path resolves to hash-level DB (#2952)",
);
// Non-worktree path should be unchanged

View file

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