gsd-pi 2.78.1-dev.e9d88a536 → 2.78.1-dev.eccf86e27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. package/README.md +5 -7
  2. package/dist/help-text.js +1 -1
  3. package/dist/resource-loader.js +6 -1
  4. package/dist/resources/.managed-resources-content-hash +1 -1
  5. package/dist/resources/extensions/gsd/auto/detect-stuck.js +41 -5
  6. package/dist/resources/extensions/gsd/auto/loop.js +235 -36
  7. package/dist/resources/extensions/gsd/auto/phases.js +14 -7
  8. package/dist/resources/extensions/gsd/auto/session.js +36 -0
  9. package/dist/resources/extensions/gsd/auto-dispatch.js +49 -4
  10. package/dist/resources/extensions/gsd/auto-post-unit.js +26 -12
  11. package/dist/resources/extensions/gsd/auto-worktree.js +185 -201
  12. package/dist/resources/extensions/gsd/auto.js +139 -49
  13. package/dist/resources/extensions/gsd/bootstrap/agent-end-recovery.js +1 -1
  14. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +26 -20
  15. package/dist/resources/extensions/gsd/bootstrap/write-gate.js +67 -55
  16. package/dist/resources/extensions/gsd/crash-recovery.js +160 -47
  17. package/dist/resources/extensions/gsd/db/auto-workers.js +227 -0
  18. package/dist/resources/extensions/gsd/db/command-queue.js +105 -0
  19. package/dist/resources/extensions/gsd/db/milestone-leases.js +210 -0
  20. package/dist/resources/extensions/gsd/db/runtime-kv.js +91 -0
  21. package/dist/resources/extensions/gsd/db/unit-dispatches.js +322 -0
  22. package/dist/resources/extensions/gsd/db-writer.js +96 -16
  23. package/dist/resources/extensions/gsd/delegation-policy.js +155 -0
  24. package/dist/resources/extensions/gsd/docs/COORDINATION.md +42 -0
  25. package/dist/resources/extensions/gsd/doctor-proactive.js +4 -0
  26. package/dist/resources/extensions/gsd/doctor-runtime-checks.js +22 -6
  27. package/dist/resources/extensions/gsd/doctor.js +12 -2
  28. package/dist/resources/extensions/gsd/gsd-db.js +355 -3
  29. package/dist/resources/extensions/gsd/guided-flow-queue.js +1 -1
  30. package/dist/resources/extensions/gsd/guided-flow.js +116 -26
  31. package/dist/resources/extensions/gsd/interrupted-session.js +18 -15
  32. package/dist/resources/extensions/gsd/metrics.js +287 -1
  33. package/dist/resources/extensions/gsd/paths.js +79 -8
  34. package/dist/resources/extensions/gsd/prompts/complete-slice.md +4 -4
  35. package/dist/resources/extensions/gsd/prompts/execute-task.md +3 -3
  36. package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +8 -1
  37. package/dist/resources/extensions/gsd/prompts/guided-discuss-project.md +22 -7
  38. package/dist/resources/extensions/gsd/prompts/guided-discuss-requirements.md +6 -2
  39. package/dist/resources/extensions/gsd/prompts/guided-discuss-slice.md +8 -1
  40. package/dist/resources/extensions/gsd/state.js +21 -6
  41. package/dist/resources/extensions/gsd/templates/project.md +10 -0
  42. package/dist/resources/extensions/gsd/workflow-mcp.js +2 -2
  43. package/dist/resources/extensions/gsd/workspace.js +59 -0
  44. package/dist/resources/extensions/gsd/worktree-resolver.js +79 -2
  45. package/dist/resources/extensions/gsd/write-intercept.js +3 -3
  46. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  47. package/dist/web/standalone/.next/BUILD_ID +1 -1
  48. package/dist/web/standalone/.next/app-path-routes-manifest.json +14 -14
  49. package/dist/web/standalone/.next/build-manifest.json +2 -2
  50. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  51. package/dist/web/standalone/.next/required-server-files.json +1 -1
  52. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  53. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  54. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  55. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  56. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  57. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  58. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  59. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  60. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  61. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  62. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  63. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  64. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  65. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  66. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  67. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  68. package/dist/web/standalone/.next/server/app/index.html +1 -1
  69. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  70. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  71. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  72. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  73. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  74. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  75. package/dist/web/standalone/.next/server/app-paths-manifest.json +14 -14
  76. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  77. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  78. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  79. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  80. package/dist/web/standalone/server.js +1 -1
  81. package/package.json +1 -1
  82. package/packages/mcp-server/README.md +2 -11
  83. package/packages/mcp-server/dist/remote-questions.d.ts +27 -0
  84. package/packages/mcp-server/dist/remote-questions.d.ts.map +1 -1
  85. package/packages/mcp-server/dist/remote-questions.js +28 -0
  86. package/packages/mcp-server/dist/remote-questions.js.map +1 -1
  87. package/packages/mcp-server/dist/server.d.ts +28 -0
  88. package/packages/mcp-server/dist/server.d.ts.map +1 -1
  89. package/packages/mcp-server/dist/server.js +94 -4
  90. package/packages/mcp-server/dist/server.js.map +1 -1
  91. package/packages/mcp-server/dist/workflow-tools.js.map +1 -1
  92. package/packages/mcp-server/src/mcp-server.test.ts +226 -0
  93. package/packages/mcp-server/src/remote-questions.test.ts +103 -0
  94. package/packages/mcp-server/src/remote-questions.ts +35 -0
  95. package/packages/mcp-server/src/server.ts +129 -6
  96. package/packages/mcp-server/src/workflow-tools.ts +1 -1
  97. package/packages/mcp-server/tsconfig.tsbuildinfo +1 -1
  98. package/src/resources/extensions/gsd/auto/detect-stuck.ts +37 -5
  99. package/src/resources/extensions/gsd/auto/loop.ts +263 -41
  100. package/src/resources/extensions/gsd/auto/phases.ts +15 -7
  101. package/src/resources/extensions/gsd/auto/session.ts +40 -0
  102. package/src/resources/extensions/gsd/auto-dispatch.ts +63 -4
  103. package/src/resources/extensions/gsd/auto-post-unit.ts +27 -12
  104. package/src/resources/extensions/gsd/auto-worktree.ts +218 -225
  105. package/src/resources/extensions/gsd/auto.ts +166 -43
  106. package/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +1 -1
  107. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +26 -21
  108. package/src/resources/extensions/gsd/bootstrap/tests/write-gate-basepath.test.ts +103 -0
  109. package/src/resources/extensions/gsd/bootstrap/write-gate.ts +80 -55
  110. package/src/resources/extensions/gsd/crash-recovery.ts +177 -43
  111. package/src/resources/extensions/gsd/db/auto-workers.ts +273 -0
  112. package/src/resources/extensions/gsd/db/command-queue.ts +149 -0
  113. package/src/resources/extensions/gsd/db/milestone-leases.ts +274 -0
  114. package/src/resources/extensions/gsd/db/runtime-kv.ts +127 -0
  115. package/src/resources/extensions/gsd/db/unit-dispatches.ts +446 -0
  116. package/src/resources/extensions/gsd/db-writer.ts +113 -17
  117. package/src/resources/extensions/gsd/delegation-policy.ts +197 -0
  118. package/src/resources/extensions/gsd/docs/COORDINATION.md +42 -0
  119. package/src/resources/extensions/gsd/doctor-proactive.ts +4 -0
  120. package/src/resources/extensions/gsd/doctor-runtime-checks.ts +24 -6
  121. package/src/resources/extensions/gsd/doctor.ts +10 -2
  122. package/src/resources/extensions/gsd/gsd-db.ts +354 -3
  123. package/src/resources/extensions/gsd/guided-flow-queue.ts +1 -1
  124. package/src/resources/extensions/gsd/guided-flow.ts +152 -26
  125. package/src/resources/extensions/gsd/interrupted-session.ts +19 -12
  126. package/src/resources/extensions/gsd/metrics.ts +321 -1
  127. package/src/resources/extensions/gsd/paths.ts +67 -8
  128. package/src/resources/extensions/gsd/prompts/complete-slice.md +4 -4
  129. package/src/resources/extensions/gsd/prompts/execute-task.md +3 -3
  130. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +8 -1
  131. package/src/resources/extensions/gsd/prompts/guided-discuss-project.md +22 -7
  132. package/src/resources/extensions/gsd/prompts/guided-discuss-requirements.md +6 -2
  133. package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +8 -1
  134. package/src/resources/extensions/gsd/state.ts +44 -6
  135. package/src/resources/extensions/gsd/templates/project.md +10 -0
  136. package/src/resources/extensions/gsd/tests/auto-discuss-milestone-deadlock-4973.test.ts +14 -14
  137. package/src/resources/extensions/gsd/tests/auto-loop-no-copy-artifacts.test.ts +72 -0
  138. package/src/resources/extensions/gsd/tests/auto-loop-symlink-worktree.test.ts +190 -0
  139. package/src/resources/extensions/gsd/tests/auto-session-scope.test.ts +331 -0
  140. package/src/resources/extensions/gsd/tests/auto-workers.test.ts +105 -0
  141. package/src/resources/extensions/gsd/tests/auto-worktree-registry.test.ts +176 -0
  142. package/src/resources/extensions/gsd/tests/command-queue.test.ts +141 -0
  143. package/src/resources/extensions/gsd/tests/crash-recovery-via-db.test.ts +203 -0
  144. package/src/resources/extensions/gsd/tests/crash-recovery.test.ts +169 -59
  145. package/src/resources/extensions/gsd/tests/db-writer-path-containment.test.ts +152 -0
  146. package/src/resources/extensions/gsd/tests/db-writer-root-artifact.test.ts +221 -0
  147. package/src/resources/extensions/gsd/tests/db-writer-scope.test.ts +230 -0
  148. package/src/resources/extensions/gsd/tests/delegation-policy.test.ts +151 -0
  149. package/src/resources/extensions/gsd/tests/detect-stuck-respects-retry.test.ts +173 -0
  150. package/src/resources/extensions/gsd/tests/dispatch-backgroundable-annotation.test.ts +55 -0
  151. package/src/resources/extensions/gsd/tests/draft-promotion.test.ts +3 -23
  152. package/src/resources/extensions/gsd/tests/gate-1b-orphan-discrimination.test.ts +193 -0
  153. package/src/resources/extensions/gsd/tests/gate-1b-recovery-bound-corrections.test.ts +246 -0
  154. package/src/resources/extensions/gsd/tests/gate-1b-recovery-bound.test.ts +218 -0
  155. package/src/resources/extensions/gsd/tests/gsd-db-failed-open-restore.test.ts +117 -0
  156. package/src/resources/extensions/gsd/tests/gsd-db-workspace-scope.test.ts +226 -0
  157. package/src/resources/extensions/gsd/tests/gsd-root-canonical.test.ts +66 -0
  158. package/src/resources/extensions/gsd/tests/gsd-root-home-guard.test.ts +68 -5
  159. package/src/resources/extensions/gsd/tests/guided-flow-prompt-consolidation.test.ts +4 -4
  160. package/src/resources/extensions/gsd/tests/integration/auto-worktree.test.ts +22 -12
  161. package/src/resources/extensions/gsd/tests/integration/doctor-proactive.test.ts +24 -10
  162. package/src/resources/extensions/gsd/tests/integration/doctor-runtime.test.ts +35 -23
  163. package/src/resources/extensions/gsd/tests/integration/workspace-collapse-integration.test.ts +369 -0
  164. package/src/resources/extensions/gsd/tests/interrupted-session-auto.test.ts +72 -25
  165. package/src/resources/extensions/gsd/tests/interrupted-session-ui.test.ts +72 -25
  166. package/src/resources/extensions/gsd/tests/memory-pressure-stuck-state.test.ts +9 -6
  167. package/src/resources/extensions/gsd/tests/metrics-atomic-merge.test.ts +222 -0
  168. package/src/resources/extensions/gsd/tests/metrics-lock-hardening.test.ts +400 -0
  169. package/src/resources/extensions/gsd/tests/metrics-lock-not-acquired.test.ts +141 -0
  170. package/src/resources/extensions/gsd/tests/metrics-lock-retry-sleep.test.ts +287 -0
  171. package/src/resources/extensions/gsd/tests/metrics-prune-cache-invalidation.test.ts +149 -0
  172. package/src/resources/extensions/gsd/tests/metrics-scope.test.ts +378 -0
  173. package/src/resources/extensions/gsd/tests/milestone-leases.test.ts +152 -0
  174. package/src/resources/extensions/gsd/tests/originalbase-path-comparison.test.ts +329 -0
  175. package/src/resources/extensions/gsd/tests/parallel-milestone-isolation.test.ts +106 -0
  176. package/src/resources/extensions/gsd/tests/path-cache-decoupled.test.ts +209 -0
  177. package/src/resources/extensions/gsd/tests/path-normalization-unified.test.ts +175 -0
  178. package/src/resources/extensions/gsd/tests/paths-cache.test.ts +170 -0
  179. package/src/resources/extensions/gsd/tests/paused-session-via-db.test.ts +119 -0
  180. package/src/resources/extensions/gsd/tests/pending-autostart-scope.test.ts +120 -0
  181. package/src/resources/extensions/gsd/tests/pipeline-variant-dispatch.test.ts +58 -0
  182. package/src/resources/extensions/gsd/tests/preferences-worktree-sync.test.ts +3 -17
  183. package/src/resources/extensions/gsd/tests/prompt-contracts.test.ts +150 -7
  184. package/src/resources/extensions/gsd/tests/register-hooks-depth-verification.test.ts +138 -16
  185. package/src/resources/extensions/gsd/tests/resume-missing-worktree-warning.test.ts +209 -0
  186. package/src/resources/extensions/gsd/tests/runtime-kv.test.ts +120 -0
  187. package/src/resources/extensions/gsd/tests/skipped-validation-completion.test.ts +133 -28
  188. package/src/resources/extensions/gsd/tests/skipped-validation-db-atomicity.test.ts +17 -0
  189. package/src/resources/extensions/gsd/tests/stuck-state-via-db.test.ts +134 -0
  190. package/src/resources/extensions/gsd/tests/sync-layer-scope.test.ts +434 -0
  191. package/src/resources/extensions/gsd/tests/teardown-chdir-failure-clears-registry.test.ts +162 -0
  192. package/src/resources/extensions/gsd/tests/teardown-cleanup-parity.test.ts +98 -0
  193. package/src/resources/extensions/gsd/tests/teardown-failure-clears-registry.test.ts +186 -0
  194. package/src/resources/extensions/gsd/tests/tool-invocation-error-loop-break.test.ts +1 -1
  195. package/src/resources/extensions/gsd/tests/unit-dispatches.test.ts +247 -0
  196. package/src/resources/extensions/gsd/tests/validate-milestone.test.ts +41 -1
  197. package/src/resources/extensions/gsd/tests/validator-scope-parity.test.ts +239 -0
  198. package/src/resources/extensions/gsd/tests/workflow-mcp.test.ts +2 -2
  199. package/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +9 -15
  200. package/src/resources/extensions/gsd/tests/workspace.test.ts +196 -0
  201. package/src/resources/extensions/gsd/tests/write-gate-predicates.test.ts +35 -35
  202. package/src/resources/extensions/gsd/tests/write-gate.test.ts +94 -71
  203. package/src/resources/extensions/gsd/tests/write-intercept.test.ts +1 -1
  204. package/src/resources/extensions/gsd/workflow-mcp.ts +2 -2
  205. package/src/resources/extensions/gsd/workspace.ts +95 -0
  206. package/src/resources/extensions/gsd/worktree-resolver.ts +78 -2
  207. package/src/resources/extensions/gsd/write-intercept.ts +3 -3
  208. package/src/resources/extensions/gsd/tests/auto-lock-creation.test.ts +0 -213
  209. package/src/resources/extensions/gsd/tests/auto-stale-lock-self-kill.test.ts +0 -87
  210. package/src/resources/extensions/gsd/tests/stop-auto-remote.test.ts +0 -159
  211. /package/dist/web/standalone/.next/static/{oZGTPvJBQX_IDKKnuV8Bt → Y5UeGFkXTYM9WIQOWHkot}/_buildManifest.js +0 -0
  212. /package/dist/web/standalone/.next/static/{oZGTPvJBQX_IDKKnuV8Bt → Y5UeGFkXTYM9WIQOWHkot}/_ssgManifest.js +0 -0
@@ -0,0 +1,329 @@
1
+ // GSD-2 — Regression tests for originalBase path comparison correctness (M5 fix)
2
+ //
3
+ // After commit ade55a7f5, getAutoWorktreeOriginalBase() returns ws.projectRoot
4
+ // which is realpath-normalized. Callers that compare it with string === against
5
+ // a non-canonical s.basePath (trailing slash, symlink path, etc.) can get false
6
+ // mismatches. M5 replaced those comparisons with isSamePath-based helpers.
7
+ //
8
+ // Tests here verify:
9
+ // 1. getAutoWorktreeOriginalBase() always returns a canonical (realpath) path.
10
+ // 2. normalizeWorktreePathForCompare(canonical) === normalizeWorktreePathForCompare(non-canonical)
11
+ // — i.e. the new comparison is true when raw === would be false.
12
+ // 3. WorktreeResolver._mergeWorktreeMode: when s.basePath has a trailing slash
13
+ // (non-canonical form of originalBase), the roadmap-fallback branch is NOT
14
+ // triggered (correct behaviour post-fix); with raw ===, it would have been.
15
+
16
+ import { describe, test, beforeEach, afterEach } from "node:test";
17
+ import assert from "node:assert/strict";
18
+ import {
19
+ mkdtempSync,
20
+ mkdirSync,
21
+ writeFileSync,
22
+ rmSync,
23
+ realpathSync,
24
+ } from "node:fs";
25
+ import { join } from "node:path";
26
+ import { tmpdir } from "node:os";
27
+ import { execFileSync } from "node:child_process";
28
+
29
+ import {
30
+ getAutoWorktreeOriginalBase,
31
+ _resetAutoWorktreeOriginalBaseForTests,
32
+ createAutoWorktree,
33
+ teardownAutoWorktree,
34
+ } from "../auto-worktree.ts";
35
+ import { normalizeWorktreePathForCompare } from "../worktree-root.ts";
36
+ import {
37
+ WorktreeResolver,
38
+ type WorktreeResolverDeps,
39
+ type NotifyCtx,
40
+ } from "../worktree-resolver.ts";
41
+ import { AutoSession } from "../auto/session.ts";
42
+
43
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
44
+
45
+ function git(args: string[], cwd: string): void {
46
+ execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
47
+ }
48
+
49
+ function createTempRepo(t: { after: (fn: () => void) => void }): string {
50
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), "m5-obpath-")));
51
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
52
+ git(["init"], dir);
53
+ git(["config", "user.email", "test@test.com"], dir);
54
+ git(["config", "user.name", "Test"], dir);
55
+ writeFileSync(join(dir, "README.md"), "# test\n");
56
+ mkdirSync(join(dir, ".gsd"), { recursive: true });
57
+ git(["add", "."], dir);
58
+ git(["commit", "-m", "init"], dir);
59
+ git(["branch", "-M", "main"], dir);
60
+ return dir;
61
+ }
62
+
63
+ function makeSession(overrides?: Partial<{ basePath: string; originalBasePath: string }>): AutoSession {
64
+ const s = new AutoSession();
65
+ s.basePath = overrides?.basePath ?? "/project";
66
+ s.originalBasePath = overrides?.originalBasePath ?? "/project";
67
+ return s;
68
+ }
69
+
70
+ interface CallLog { fn: string; args: unknown[] }
71
+
72
+ function makeDeps(overrides?: Partial<WorktreeResolverDeps>): WorktreeResolverDeps & { calls: CallLog[] } {
73
+ const calls: CallLog[] = [];
74
+
75
+ const deps: WorktreeResolverDeps & { calls: CallLog[] } = {
76
+ calls,
77
+ isInAutoWorktree: (basePath: string) => {
78
+ calls.push({ fn: "isInAutoWorktree", args: [basePath] });
79
+ return basePath.includes("worktrees");
80
+ },
81
+ shouldUseWorktreeIsolation: () => true,
82
+ getIsolationMode: () => "worktree",
83
+ mergeMilestoneToMain: (basePath: string, milestoneId: string, roadmapContent: string) => {
84
+ calls.push({ fn: "mergeMilestoneToMain", args: [basePath, milestoneId, roadmapContent] });
85
+ return { pushed: false, codeFilesChanged: true };
86
+ },
87
+ syncWorktreeStateBack: (mainBasePath: string, worktreePath: string, milestoneId: string) => {
88
+ calls.push({ fn: "syncWorktreeStateBack", args: [mainBasePath, worktreePath, milestoneId] });
89
+ return { synced: [] };
90
+ },
91
+ teardownAutoWorktree: (basePath: string, milestoneId: string, opts?: { preserveBranch?: boolean }) => {
92
+ calls.push({ fn: "teardownAutoWorktree", args: [basePath, milestoneId, opts] });
93
+ },
94
+ createAutoWorktree: (basePath: string, milestoneId: string) => {
95
+ calls.push({ fn: "createAutoWorktree", args: [basePath, milestoneId] });
96
+ return `${basePath}/.gsd/worktrees/${milestoneId}`;
97
+ },
98
+ enterAutoWorktree: (basePath: string, milestoneId: string) => {
99
+ calls.push({ fn: "enterAutoWorktree", args: [basePath, milestoneId] });
100
+ return `${basePath}/.gsd/worktrees/${milestoneId}`;
101
+ },
102
+ getAutoWorktreePath: (basePath: string, milestoneId: string) => {
103
+ calls.push({ fn: "getAutoWorktreePath", args: [basePath, milestoneId] });
104
+ return null;
105
+ },
106
+ autoCommitCurrentBranch: (basePath: string, reason: string, milestoneId: string) => {
107
+ calls.push({ fn: "autoCommitCurrentBranch", args: [basePath, reason, milestoneId] });
108
+ },
109
+ getCurrentBranch: (basePath: string) => {
110
+ calls.push({ fn: "getCurrentBranch", args: [basePath] });
111
+ return "main";
112
+ },
113
+ autoWorktreeBranch: (milestoneId: string) => `milestone/${milestoneId}`,
114
+ resolveMilestoneFile: (basePath: string, milestoneId: string, fileType: string) => {
115
+ calls.push({ fn: "resolveMilestoneFile", args: [basePath, milestoneId, fileType] });
116
+ return null;
117
+ },
118
+ readFileSync: (path: string, _enc: string) => {
119
+ calls.push({ fn: "readFileSync", args: [path] });
120
+ return "# Roadmap\n";
121
+ },
122
+ GitServiceImpl: class {
123
+ constructor(_basePath: string, _gitConfig: unknown) {}
124
+ } as unknown as WorktreeResolverDeps["GitServiceImpl"],
125
+ loadEffectiveGSDPreferences: () => ({ preferences: { git: {} } }),
126
+ invalidateAllCaches: () => { calls.push({ fn: "invalidateAllCaches", args: [] }); },
127
+ captureIntegrationBranch: (_basePath: string, _mid: string) => {},
128
+ enterBranchModeForMilestone: (_basePath: string, _milestoneId: string) => {},
129
+ ...overrides,
130
+ };
131
+
132
+ if (overrides) {
133
+ for (const [key, val] of Object.entries(overrides)) {
134
+ if (key !== "calls") (deps as unknown as Record<string, unknown>)[key] = val;
135
+ }
136
+ }
137
+ return deps;
138
+ }
139
+
140
+ function makeNotifyCtx(): NotifyCtx & { messages: Array<{ msg: string; level?: string }> } {
141
+ const messages: Array<{ msg: string; level?: string }> = [];
142
+ return {
143
+ messages,
144
+ notify: (msg: string, level?: "info" | "warning" | "error" | "success") => {
145
+ messages.push({ msg, level });
146
+ },
147
+ };
148
+ }
149
+
150
+ // ─── Suite 1: getAutoWorktreeOriginalBase() returns realpath-canonical path ──
151
+
152
+ describe("getAutoWorktreeOriginalBase() is realpath-normalised", () => {
153
+ const savedCwd = process.cwd();
154
+
155
+ beforeEach(() => {
156
+ _resetAutoWorktreeOriginalBaseForTests();
157
+ });
158
+
159
+ afterEach(() => {
160
+ _resetAutoWorktreeOriginalBaseForTests();
161
+ try { process.chdir(savedCwd); } catch { /* ignore */ }
162
+ });
163
+
164
+ test("returns canonical realpath even when called from a realpath-resolved dir", (t) => {
165
+ const tempDir = createTempRepo(t);
166
+ // tempDir is already realpathSync()-resolved by createTempRepo
167
+ const msDir = join(tempDir, ".gsd", "milestones", "M001");
168
+ mkdirSync(msDir, { recursive: true });
169
+ writeFileSync(join(msDir, "CONTEXT.md"), "# M001\n");
170
+ git(["add", "."], tempDir);
171
+ git(["commit", "-m", "add M001"], tempDir);
172
+
173
+ createAutoWorktree(tempDir, "M001");
174
+
175
+ const base = getAutoWorktreeOriginalBase();
176
+ assert.ok(base !== null, "originalBase is set after createAutoWorktree");
177
+
178
+ // The returned path must equal its own realpathSync — i.e. it is canonical
179
+ let realBase: string;
180
+ try { realBase = realpathSync(base); } catch { realBase = base; }
181
+ assert.strictEqual(base, realBase,
182
+ "getAutoWorktreeOriginalBase() must return a realpath-normalised path");
183
+
184
+ teardownAutoWorktree(tempDir, "M001");
185
+ try { process.chdir(savedCwd); } catch { /* ignore */ }
186
+ });
187
+ });
188
+
189
+ // ─── Suite 2: normalizeWorktreePathForCompare makes trailing-slash safe ───────
190
+
191
+ describe("normalizeWorktreePathForCompare equalises canonical vs non-canonical forms", () => {
192
+ test("trailing slash: normalize(p/) === normalize(p)", () => {
193
+ // Use a path that definitely exists on this machine
194
+ const base = realpathSync(tmpdir());
195
+ const withSlash = base + "/";
196
+ assert.strictEqual(
197
+ normalizeWorktreePathForCompare(withSlash),
198
+ normalizeWorktreePathForCompare(base),
199
+ "trailing slash should be stripped by normalizeWorktreePathForCompare",
200
+ );
201
+ // Confirm raw === would have returned false (test validity check)
202
+ assert.notStrictEqual(
203
+ withSlash,
204
+ base,
205
+ "sanity: raw === IS false for trailing-slash path (test is meaningful)",
206
+ );
207
+ });
208
+
209
+ test("double trailing slashes: normalize(p//) === normalize(p)", () => {
210
+ const base = realpathSync(tmpdir());
211
+ const withDoubleSlash = base + "//";
212
+ assert.strictEqual(
213
+ normalizeWorktreePathForCompare(withDoubleSlash),
214
+ normalizeWorktreePathForCompare(base),
215
+ );
216
+ });
217
+
218
+ test("same realpath, different string forms: isSamePath-style comparison is true; raw === is false", () => {
219
+ const base = realpathSync(tmpdir());
220
+ const canonical = normalizeWorktreePathForCompare(base);
221
+ const nonCanonical = normalizeWorktreePathForCompare(base + "/");
222
+ // Post-fix: the two forms compare as equal
223
+ assert.strictEqual(canonical, nonCanonical,
224
+ "isSamePath-style comparison returns true for same physical path");
225
+ });
226
+ });
227
+
228
+ // ─── Suite 3: WorktreeResolver roadmap-fallback branch under cwd-drift ───────
229
+ //
230
+ // The buggy line was:
231
+ // if (!roadmapPath && this.s.basePath !== originalBase) { /* try worktree */ }
232
+ //
233
+ // After the fix:
234
+ // if (!roadmapPath && !isSamePath(this.s.basePath, originalBase)) { ... }
235
+ //
236
+ // When s.basePath is a non-canonical form of originalBase (e.g. trailing slash),
237
+ // the old code falsely entered the fallback branch (attempted a second
238
+ // resolveMilestoneFile call against the "worktree" path that is actually the
239
+ // same directory). The new code correctly skips the fallback.
240
+
241
+ describe("WorktreeResolver: roadmap-fallback skipped when basePath is same physical path as originalBase", () => {
242
+ test("with trailing-slash basePath equal to originalBase: resolveMilestoneFile called once", () => {
243
+ // originalBase is canonical (as returned by workspace registry)
244
+ const canonicalBase = "/tmp/m5-test-project";
245
+ // s.basePath has a trailing slash — same physical dir, non-canonical string
246
+ const trailingSlashBase = canonicalBase + "/";
247
+
248
+ const s = makeSession({
249
+ basePath: trailingSlashBase,
250
+ originalBasePath: canonicalBase,
251
+ });
252
+
253
+ const calls: CallLog[] = [];
254
+ const deps = makeDeps({
255
+ // isInAutoWorktree: basePath has trailing slash but is NOT a worktree
256
+ isInAutoWorktree: (basePath: string) => {
257
+ calls.push({ fn: "isInAutoWorktree", args: [basePath] });
258
+ return false;
259
+ },
260
+ // resolveMilestoneFile always returns null (no roadmap found)
261
+ resolveMilestoneFile: (basePath: string, milestoneId: string, fileType: string) => {
262
+ calls.push({ fn: "resolveMilestoneFile", args: [basePath, milestoneId, fileType] });
263
+ return null;
264
+ },
265
+ teardownAutoWorktree: (basePath: string, milestoneId: string, opts?: { preserveBranch?: boolean }) => {
266
+ calls.push({ fn: "teardownAutoWorktree", args: [basePath, milestoneId, opts] });
267
+ },
268
+ });
269
+
270
+ // Override calls ref so we can inspect it directly
271
+ (deps as unknown as { calls: CallLog[] }).calls = calls;
272
+
273
+ const resolver = new WorktreeResolver(s, deps);
274
+ const ctx = makeNotifyCtx();
275
+
276
+ // mergeAndExit → _mergeWorktreeMode
277
+ // originalBase = s.originalBasePath = canonicalBase
278
+ // s.basePath = trailingSlashBase — physically same as canonicalBase
279
+ // Post-fix: isSamePath(trailingSlashBase, canonicalBase) is true
280
+ // → roadmap fallback branch is skipped (resolveMilestoneFile called once)
281
+ // Pre-fix (bug): trailingSlashBase !== canonicalBase → fallback entered
282
+ // → resolveMilestoneFile called twice
283
+
284
+ resolver.mergeAndExit("M001", ctx);
285
+
286
+ const rmfCalls = calls.filter(c => c.fn === "resolveMilestoneFile");
287
+ assert.strictEqual(rmfCalls.length, 1,
288
+ "resolveMilestoneFile must be called exactly once — fallback should be skipped when " +
289
+ "s.basePath is the same physical path as originalBase (isSamePath fix)");
290
+ });
291
+
292
+ test("with genuinely different basePath (inside worktree): resolveMilestoneFile called twice", () => {
293
+ // originalBase is the project root
294
+ const projectRoot = "/tmp/m5-test-project";
295
+ // s.basePath is inside a worktree — a physically different path
296
+ const worktreePath = projectRoot + "/.gsd/worktrees/M002";
297
+
298
+ const s = makeSession({
299
+ basePath: worktreePath,
300
+ originalBasePath: projectRoot,
301
+ });
302
+
303
+ const calls: CallLog[] = [];
304
+ const deps = makeDeps({
305
+ isInAutoWorktree: (basePath: string) => {
306
+ calls.push({ fn: "isInAutoWorktree", args: [basePath] });
307
+ return basePath.includes("worktrees");
308
+ },
309
+ resolveMilestoneFile: (basePath: string, milestoneId: string, fileType: string) => {
310
+ calls.push({ fn: "resolveMilestoneFile", args: [basePath, milestoneId, fileType] });
311
+ return null; // no roadmap in either location
312
+ },
313
+ teardownAutoWorktree: (basePath: string, milestoneId: string, opts?: { preserveBranch?: boolean }) => {
314
+ calls.push({ fn: "teardownAutoWorktree", args: [basePath, milestoneId, opts] });
315
+ },
316
+ });
317
+ (deps as unknown as { calls: CallLog[] }).calls = calls;
318
+
319
+ const resolver = new WorktreeResolver(s, deps);
320
+ const ctx = makeNotifyCtx();
321
+
322
+ resolver.mergeAndExit("M002", ctx);
323
+
324
+ const rmfCalls = calls.filter(c => c.fn === "resolveMilestoneFile");
325
+ assert.strictEqual(rmfCalls.length, 2,
326
+ "resolveMilestoneFile must be called twice when basePath is a genuine worktree path " +
327
+ "(fallback should run for different physical paths)");
328
+ });
329
+ });
@@ -0,0 +1,106 @@
1
+ // gsd-2 + Parallel-worker isolation regression (Phase B coordination)
2
+ //
3
+ // Two simulated workers attempt to claim leases on the same project. The
4
+ // lease infrastructure must guarantee:
5
+ // - On the same milestone: only one wins; the loser sees held_by error
6
+ // - On different milestones: both succeed independently
7
+ //
8
+ // This is the integration check that ties registerAutoWorker +
9
+ // claimMilestoneLease + recordDispatchClaim together end-to-end.
10
+
11
+ import test from "node:test";
12
+ import assert from "node:assert/strict";
13
+ import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { tmpdir } from "node:os";
16
+
17
+ import {
18
+ openDatabase,
19
+ closeDatabase,
20
+ insertMilestone,
21
+ } from "../gsd-db.ts";
22
+ import { registerAutoWorker } from "../db/auto-workers.ts";
23
+ import { claimMilestoneLease } from "../db/milestone-leases.ts";
24
+ import { recordDispatchClaim } from "../db/unit-dispatches.ts";
25
+
26
+ function makeBase(): string {
27
+ const base = mkdtempSync(join(tmpdir(), "gsd-parallel-iso-"));
28
+ mkdirSync(join(base, ".gsd"), { recursive: true });
29
+ return base;
30
+ }
31
+
32
+ function cleanup(base: string): void {
33
+ try { closeDatabase(); } catch { /* noop */ }
34
+ try { rmSync(base, { recursive: true, force: true }); } catch { /* noop */ }
35
+ }
36
+
37
+ test("two workers contesting the same milestone: only one wins the lease", (t) => {
38
+ const base = makeBase();
39
+ t.after(() => cleanup(base));
40
+ openDatabase(join(base, ".gsd", "gsd.db"));
41
+ insertMilestone({ id: "M001", title: "Contested", status: "active" });
42
+
43
+ const w1 = registerAutoWorker({ projectRootRealpath: base });
44
+ const w2 = registerAutoWorker({ projectRootRealpath: base });
45
+
46
+ const r1 = claimMilestoneLease(w1, "M001");
47
+ const r2 = claimMilestoneLease(w2, "M001");
48
+
49
+ assert.equal(r1.ok, true, "first claim wins");
50
+ assert.equal(r2.ok, false, "second claim is rejected");
51
+ if (!r2.ok) {
52
+ assert.equal(r2.error, "held_by");
53
+ assert.equal(r2.byWorker, w1);
54
+ }
55
+ });
56
+
57
+ test("two workers on different milestones can both proceed independently", (t) => {
58
+ const base = makeBase();
59
+ t.after(() => cleanup(base));
60
+ openDatabase(join(base, ".gsd", "gsd.db"));
61
+ insertMilestone({ id: "M001", title: "First", status: "active" });
62
+ insertMilestone({ id: "M002", title: "Second", status: "active" });
63
+
64
+ const w1 = registerAutoWorker({ projectRootRealpath: base });
65
+ const w2 = registerAutoWorker({ projectRootRealpath: base });
66
+
67
+ const r1 = claimMilestoneLease(w1, "M001");
68
+ const r2 = claimMilestoneLease(w2, "M002");
69
+
70
+ assert.equal(r1.ok, true);
71
+ assert.equal(r2.ok, true);
72
+ if (r1.ok && r2.ok) {
73
+ assert.equal(r1.token, 1);
74
+ assert.equal(r2.token, 1);
75
+ }
76
+ });
77
+
78
+ test("dispatch ledger ties unit_id uniqueness to active status", (t) => {
79
+ const base = makeBase();
80
+ t.after(() => cleanup(base));
81
+ openDatabase(join(base, ".gsd", "gsd.db"));
82
+ insertMilestone({ id: "M001", title: "T", status: "active" });
83
+
84
+ const w1 = registerAutoWorker({ projectRootRealpath: base });
85
+ const lease = claimMilestoneLease(w1, "M001");
86
+ assert.equal(lease.ok, true);
87
+ if (!lease.ok) return;
88
+
89
+ // The same lease holder attempts to claim the same unit twice.
90
+ // The partial unique index on unit_dispatches.unit_id WHERE status IN
91
+ // ('claimed','running') must serialize the writes even before the unit transitions.
92
+ const claim1 = recordDispatchClaim({
93
+ traceId: "t1", workerId: w1, milestoneLeaseToken: lease.token,
94
+ milestoneId: "M001", unitType: "plan-slice", unitId: "M001/S01",
95
+ });
96
+ const claim2 = recordDispatchClaim({
97
+ traceId: "t2", workerId: w1, milestoneLeaseToken: lease.token,
98
+ milestoneId: "M001", unitType: "plan-slice", unitId: "M001/S01",
99
+ });
100
+
101
+ assert.equal(claim1.ok, true);
102
+ assert.equal(claim2.ok, false);
103
+ if (!claim2.ok) {
104
+ assert.equal(claim2.error, "already_active");
105
+ }
106
+ });
@@ -0,0 +1,209 @@
1
+ // GSD-2 — Tests verifying gsdRootCache is decoupled from per-turn clearPathCache()
2
+
3
+ import { describe, test, beforeEach, afterEach } from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import { mkdtempSync, mkdirSync, rmSync, renameSync, realpathSync } from 'node:fs';
6
+ import { tmpdir } from 'node:os';
7
+ import { join } from 'node:path';
8
+
9
+ import { gsdRoot, clearPathCache, _clearGsdRootCache } from '../paths.ts';
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Shared test setup helpers
13
+ // ---------------------------------------------------------------------------
14
+
15
+ interface Fixture {
16
+ projectDir: string;
17
+ fakeHome: string;
18
+ savedHome: string | undefined;
19
+ savedUserProfile: string | undefined;
20
+ savedGsdHome: string | undefined;
21
+ }
22
+
23
+ function makeFixture(): Fixture {
24
+ const projectDir = realpathSync(mkdtempSync(join(tmpdir(), 'gsd-decoupled-')));
25
+ mkdirSync(join(projectDir, '.gsd'), { recursive: true });
26
+
27
+ const fakeHome = realpathSync(mkdtempSync(join(tmpdir(), 'gsd-decoupled-home-')));
28
+
29
+ const savedHome = process.env.HOME;
30
+ const savedUserProfile = process.env.USERPROFILE;
31
+ const savedGsdHome = process.env.GSD_HOME;
32
+
33
+ // Redirect HOME so gsdRoot never accidentally resolves to the real ~/.gsd.
34
+ process.env.HOME = fakeHome;
35
+ process.env.USERPROFILE = fakeHome;
36
+ process.env.GSD_HOME = join(fakeHome, '.gsd');
37
+
38
+ _clearGsdRootCache();
39
+
40
+ return { projectDir, fakeHome, savedHome, savedUserProfile, savedGsdHome };
41
+ }
42
+
43
+ function teardownFixture(f: Fixture): void {
44
+ if (f.savedHome === undefined) delete process.env.HOME;
45
+ else process.env.HOME = f.savedHome;
46
+ if (f.savedUserProfile === undefined) delete process.env.USERPROFILE;
47
+ else process.env.USERPROFILE = f.savedUserProfile;
48
+ if (f.savedGsdHome === undefined) delete process.env.GSD_HOME;
49
+ else process.env.GSD_HOME = f.savedGsdHome;
50
+
51
+ _clearGsdRootCache();
52
+ clearPathCache();
53
+ rmSync(f.projectDir, { recursive: true, force: true });
54
+ rmSync(f.fakeHome, { recursive: true, force: true });
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // 1. gsdRoot() populates the cache
59
+ // ---------------------------------------------------------------------------
60
+
61
+ describe('gsdRoot cache population', () => {
62
+ let f: Fixture;
63
+ beforeEach(() => { f = makeFixture(); });
64
+ afterEach(() => teardownFixture(f));
65
+
66
+ test('first call populates cache; second call returns same value without re-probing', (t) => {
67
+ const first = gsdRoot(f.projectDir);
68
+ assert.equal(first, join(f.projectDir, '.gsd'), 'must resolve to projectDir/.gsd');
69
+
70
+ // Hide .gsd so a re-probe would yield the creation fallback (same path in this
71
+ // case, but the rename lets us verify no re-probe happens).
72
+ renameSync(join(f.projectDir, '.gsd'), join(f.projectDir, '.gsd-hidden'));
73
+ t.after(() => {
74
+ try { renameSync(join(f.projectDir, '.gsd-hidden'), join(f.projectDir, '.gsd')); } catch { /* ignore */ }
75
+ });
76
+
77
+ const second = gsdRoot(f.projectDir);
78
+ assert.equal(second, first, 'second call must return cached result, not re-probe');
79
+ });
80
+ });
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // 2. clearPathCache() does NOT invalidate gsdRootCache
84
+ // ---------------------------------------------------------------------------
85
+
86
+ describe('clearPathCache() does not evict gsdRootCache', () => {
87
+ let f: Fixture;
88
+ beforeEach(() => { f = makeFixture(); });
89
+ afterEach(() => teardownFixture(f));
90
+
91
+ test('cached gsdRoot survives clearPathCache()', (t) => {
92
+ // Prime the cache.
93
+ const primed = gsdRoot(f.projectDir);
94
+ assert.equal(primed, join(f.projectDir, '.gsd'));
95
+
96
+ // Mutate the filesystem so a fresh probe would return a different path.
97
+ renameSync(join(f.projectDir, '.gsd'), join(f.projectDir, '.gsd-gone'));
98
+ t.after(() => {
99
+ try { renameSync(join(f.projectDir, '.gsd-gone'), join(f.projectDir, '.gsd')); } catch { /* ignore */ }
100
+ });
101
+
102
+ // clearPathCache() only clears volatile dir caches — must not touch gsdRootCache.
103
+ clearPathCache();
104
+
105
+ const afterClear = gsdRoot(f.projectDir);
106
+ assert.equal(
107
+ afterClear,
108
+ primed,
109
+ 'gsdRoot must return the original cached value after clearPathCache(), not re-probe',
110
+ );
111
+ });
112
+
113
+ test('multiple clearPathCache() calls still preserve gsdRoot cache', (t) => {
114
+ const primed = gsdRoot(f.projectDir);
115
+
116
+ renameSync(join(f.projectDir, '.gsd'), join(f.projectDir, '.gsd-gone'));
117
+ t.after(() => {
118
+ try { renameSync(join(f.projectDir, '.gsd-gone'), join(f.projectDir, '.gsd')); } catch { /* ignore */ }
119
+ });
120
+
121
+ // Simulate many agent turn-ends.
122
+ for (let i = 0; i < 10; i++) clearPathCache();
123
+
124
+ assert.equal(
125
+ gsdRoot(f.projectDir),
126
+ primed,
127
+ 'gsdRoot cache must survive repeated clearPathCache() calls',
128
+ );
129
+ });
130
+ });
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // 3. _clearGsdRootCache() DOES invalidate gsdRootCache
134
+ // ---------------------------------------------------------------------------
135
+
136
+ describe('_clearGsdRootCache() evicts gsdRootCache', () => {
137
+ let f: Fixture;
138
+ beforeEach(() => { f = makeFixture(); });
139
+ afterEach(() => teardownFixture(f));
140
+
141
+ test('gsdRoot re-probes after _clearGsdRootCache()', (t) => {
142
+ // Prime the cache.
143
+ const primed = gsdRoot(f.projectDir);
144
+ assert.equal(primed, join(f.projectDir, '.gsd'));
145
+
146
+ // Hide .gsd — next probe would see it absent.
147
+ renameSync(join(f.projectDir, '.gsd'), join(f.projectDir, '.gsd-hidden'));
148
+ t.after(() => {
149
+ try { renameSync(join(f.projectDir, '.gsd-hidden'), join(f.projectDir, '.gsd')); } catch { /* ignore */ }
150
+ });
151
+
152
+ // _clearGsdRootCache() must evict, triggering a fresh probe.
153
+ _clearGsdRootCache();
154
+ const afterRootClear = gsdRoot(f.projectDir);
155
+
156
+ // Probe with .gsd absent falls through to creation fallback (same path value,
157
+ // but the probe definitely ran). Restore and re-prime to confirm it returns
158
+ // the live value rather than a stale cached one.
159
+ renameSync(join(f.projectDir, '.gsd-hidden'), join(f.projectDir, '.gsd'));
160
+ _clearGsdRootCache();
161
+ const reprobe = gsdRoot(f.projectDir);
162
+ assert.equal(reprobe, join(f.projectDir, '.gsd'), 're-probe with .gsd restored must find it');
163
+
164
+ // The result after root-clear + removal fell back to the creation path (same
165
+ // string as primed), which confirms the probe ran (not from cache).
166
+ assert.equal(afterRootClear, join(f.projectDir, '.gsd'));
167
+ });
168
+ });
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // 4. Realpath-normalized keys — /foo and /foo/ share the same cache entry
172
+ // (regression of A2 / H2 behavior)
173
+ // ---------------------------------------------------------------------------
174
+
175
+ describe('realpath normalization: trailing slash shares cache entry', () => {
176
+ let f: Fixture;
177
+ beforeEach(() => { f = makeFixture(); });
178
+ afterEach(() => teardownFixture(f));
179
+
180
+ test('/foo and /foo/ map to the same cache entry', () => {
181
+ const withoutSlash = gsdRoot(f.projectDir);
182
+
183
+ // Hide .gsd — if a re-probe happened, the result would differ.
184
+ renameSync(join(f.projectDir, '.gsd'), join(f.projectDir, '.gsd-hidden'));
185
+ try {
186
+ const withSlash = gsdRoot(f.projectDir + '/');
187
+ assert.equal(
188
+ withSlash,
189
+ withoutSlash,
190
+ 'trailing-slash variant must hit the same cache entry as no-slash variant',
191
+ );
192
+ } finally {
193
+ try { renameSync(join(f.projectDir, '.gsd-hidden'), join(f.projectDir, '.gsd')); } catch { /* ignore */ }
194
+ }
195
+ });
196
+
197
+ test('_clearGsdRootCache() + gsdRoot with trailing slash re-probes correctly', () => {
198
+ const first = gsdRoot(f.projectDir);
199
+
200
+ _clearGsdRootCache();
201
+ const second = gsdRoot(f.projectDir + '/');
202
+
203
+ assert.equal(
204
+ first,
205
+ second,
206
+ '_clearGsdRootCache then call with trailing slash must return same resolved path',
207
+ );
208
+ });
209
+ });