@opengsd/gsd-pi 1.2.0-dev.4c756166 → 1.2.0-dev.955e4da0

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 (246) hide show
  1. package/dist/resources/.managed-resources-content-hash +1 -1
  2. package/dist/resources/extensions/bg-shell/utilities.js +2 -2
  3. package/dist/resources/extensions/claude-code-cli/models.js +9 -0
  4. package/dist/resources/extensions/claude-code-cli/stream-adapter.js +8 -2
  5. package/dist/resources/extensions/gsd/auto/orchestrator.js +33 -4
  6. package/dist/resources/extensions/gsd/auto/phases.js +6 -1
  7. package/dist/resources/extensions/gsd/auto-post-unit.js +8 -6
  8. package/dist/resources/extensions/gsd/auto-start.js +8 -13
  9. package/dist/resources/extensions/gsd/auto-worktree-repair.js +10 -2
  10. package/dist/resources/extensions/gsd/auto-worktree.js +13 -270
  11. package/dist/resources/extensions/gsd/auto.js +4 -7
  12. package/dist/resources/extensions/gsd/bootstrap/dynamic-tools.js +9 -6
  13. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +32 -3
  14. package/dist/resources/extensions/gsd/bootstrap/write-gate.js +26 -4
  15. package/dist/resources/extensions/gsd/captures.js +5 -13
  16. package/dist/resources/extensions/gsd/closeout-recovery.js +3 -2
  17. package/dist/resources/extensions/gsd/commands/catalog.js +6 -62
  18. package/dist/resources/extensions/gsd/db/engine.js +755 -0
  19. package/dist/resources/extensions/gsd/db/queries.js +372 -0
  20. package/dist/resources/extensions/gsd/db/sql-constants.js +11 -0
  21. package/dist/resources/extensions/gsd/db/writers/cascades.js +194 -0
  22. package/dist/resources/extensions/gsd/db/writers/import-restore.js +182 -0
  23. package/dist/resources/extensions/gsd/db/writers/memory.js +149 -0
  24. package/dist/resources/extensions/gsd/db/writers/reconcile.js +458 -0
  25. package/dist/resources/extensions/gsd/db/writers/status.js +70 -0
  26. package/dist/resources/extensions/gsd/doctor-environment.js +8 -10
  27. package/dist/resources/extensions/gsd/doctor-git-checks.js +4 -3
  28. package/dist/resources/extensions/gsd/doctor-runtime-checks.js +9 -2
  29. package/dist/resources/extensions/gsd/git-service.js +1 -0
  30. package/dist/resources/extensions/gsd/gitignore.js +3 -0
  31. package/dist/resources/extensions/gsd/gsd-db.js +171 -2048
  32. package/dist/resources/extensions/gsd/guided-flow.js +34 -3
  33. package/dist/resources/extensions/gsd/migrate/safety.js +17 -9
  34. package/dist/resources/extensions/gsd/migration-auto-check.js +24 -3
  35. package/dist/resources/extensions/gsd/model-cost-table.js +1 -0
  36. package/dist/resources/extensions/gsd/model-router.js +3 -0
  37. package/dist/resources/extensions/gsd/parallel-merge.js +14 -11
  38. package/dist/resources/extensions/gsd/parallel-monitor-overlay.js +7 -5
  39. package/dist/resources/extensions/gsd/paths.js +10 -24
  40. package/dist/resources/extensions/gsd/preferences.js +14 -0
  41. package/dist/resources/extensions/gsd/recovery-classification.js +12 -1
  42. package/dist/resources/extensions/gsd/safety/evidence-collector.js +37 -4
  43. package/dist/resources/extensions/gsd/safety/evidence-cross-ref.js +7 -2
  44. package/dist/resources/extensions/gsd/safety/file-change-validator.js +10 -0
  45. package/dist/resources/extensions/gsd/state-transition-matrix.js +38 -0
  46. package/dist/resources/extensions/gsd/status-guards.js +56 -8
  47. package/dist/resources/extensions/gsd/tools/complete-slice.js +24 -43
  48. package/dist/resources/extensions/gsd/tools/exec-tool.js +5 -5
  49. package/dist/resources/extensions/gsd/tools/reopen-milestone.js +11 -29
  50. package/dist/resources/extensions/gsd/tools/reopen-slice.js +14 -33
  51. package/dist/resources/extensions/gsd/tools/skip-slice.js +18 -36
  52. package/dist/resources/extensions/gsd/undo.js +8 -7
  53. package/dist/resources/extensions/gsd/worktree-git-recovery.js +287 -0
  54. package/dist/resources/extensions/gsd/worktree-lifecycle.js +9 -1
  55. package/dist/resources/extensions/gsd/worktree-manager.js +45 -28
  56. package/dist/resources/extensions/gsd/worktree-placement.js +59 -0
  57. package/dist/resources/extensions/gsd/worktree-reentry.js +12 -8
  58. package/dist/resources/extensions/gsd/worktree-root.js +17 -6
  59. package/dist/resources/extensions/gsd/worktree-safety.js +8 -5
  60. package/dist/resources/extensions/gsd/worktree-session-state.js +12 -10
  61. package/dist/resources/skills/gsd-browser/SKILL.md +1 -1
  62. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  63. package/dist/web/standalone/.next/BUILD_ID +1 -1
  64. package/dist/web/standalone/.next/app-path-routes-manifest.json +13 -13
  65. package/dist/web/standalone/.next/build-manifest.json +2 -2
  66. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  67. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  68. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  69. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  70. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  71. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  72. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  73. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  74. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  75. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  76. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  77. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  78. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  79. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  80. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  81. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  82. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  83. package/dist/web/standalone/.next/server/app/api/boot/route.js.nft.json +1 -1
  84. package/dist/web/standalone/.next/server/app/api/bridge-terminal/input/route.js.nft.json +1 -1
  85. package/dist/web/standalone/.next/server/app/api/bridge-terminal/resize/route.js.nft.json +1 -1
  86. package/dist/web/standalone/.next/server/app/api/bridge-terminal/stream/route.js.nft.json +1 -1
  87. package/dist/web/standalone/.next/server/app/api/captures/route.js.nft.json +1 -1
  88. package/dist/web/standalone/.next/server/app/api/cleanup/route.js.nft.json +1 -1
  89. package/dist/web/standalone/.next/server/app/api/doctor/route.js.nft.json +1 -1
  90. package/dist/web/standalone/.next/server/app/api/export-data/route.js.nft.json +1 -1
  91. package/dist/web/standalone/.next/server/app/api/files/route.js.nft.json +1 -1
  92. package/dist/web/standalone/.next/server/app/api/forensics/route.js.nft.json +1 -1
  93. package/dist/web/standalone/.next/server/app/api/git/route.js.nft.json +1 -1
  94. package/dist/web/standalone/.next/server/app/api/history/route.js.nft.json +1 -1
  95. package/dist/web/standalone/.next/server/app/api/hooks/route.js.nft.json +1 -1
  96. package/dist/web/standalone/.next/server/app/api/inspect/route.js.nft.json +1 -1
  97. package/dist/web/standalone/.next/server/app/api/knowledge/route.js.nft.json +1 -1
  98. package/dist/web/standalone/.next/server/app/api/live-state/route.js.nft.json +1 -1
  99. package/dist/web/standalone/.next/server/app/api/mcp-connections/route.js.nft.json +1 -1
  100. package/dist/web/standalone/.next/server/app/api/notifications/route.js.nft.json +1 -1
  101. package/dist/web/standalone/.next/server/app/api/onboarding/route.js.nft.json +1 -1
  102. package/dist/web/standalone/.next/server/app/api/projects/route.js.nft.json +1 -1
  103. package/dist/web/standalone/.next/server/app/api/recovery/route.js.nft.json +1 -1
  104. package/dist/web/standalone/.next/server/app/api/session/browser/route.js.nft.json +1 -1
  105. package/dist/web/standalone/.next/server/app/api/session/command/route.js.nft.json +1 -1
  106. package/dist/web/standalone/.next/server/app/api/session/events/route.js.nft.json +1 -1
  107. package/dist/web/standalone/.next/server/app/api/session/manage/route.js.nft.json +1 -1
  108. package/dist/web/standalone/.next/server/app/api/settings-data/route.js.nft.json +1 -1
  109. package/dist/web/standalone/.next/server/app/api/shutdown/route.js.nft.json +1 -1
  110. package/dist/web/standalone/.next/server/app/api/skill-health/route.js.nft.json +1 -1
  111. package/dist/web/standalone/.next/server/app/api/steer/route.js.nft.json +1 -1
  112. package/dist/web/standalone/.next/server/app/api/switch-root/route.js.nft.json +1 -1
  113. package/dist/web/standalone/.next/server/app/api/terminal/sessions/route.js.nft.json +1 -1
  114. package/dist/web/standalone/.next/server/app/api/terminal/stream/route.js.nft.json +1 -1
  115. package/dist/web/standalone/.next/server/app/api/undo/route.js.nft.json +1 -1
  116. package/dist/web/standalone/.next/server/app/api/visualizer/route.js.nft.json +1 -1
  117. package/dist/web/standalone/.next/server/app/index.html +1 -1
  118. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  119. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  120. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  121. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  122. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  123. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  124. package/dist/web/standalone/.next/server/app-paths-manifest.json +13 -13
  125. package/dist/web/standalone/.next/server/chunks/{5047.js → 5942.js} +2 -2
  126. package/dist/web/standalone/.next/server/chunks/8357.js +1 -1
  127. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  128. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  129. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  130. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  131. package/dist/worktree-status-banner.js +7 -3
  132. package/package.json +1 -1
  133. package/packages/cloud-mcp-gateway/package.json +2 -2
  134. package/packages/contracts/package.json +1 -1
  135. package/packages/daemon/package.json +4 -4
  136. package/packages/gsd-agent-core/package.json +5 -5
  137. package/packages/gsd-agent-modes/package.json +7 -7
  138. package/packages/mcp-server/dist/workflow-tools.d.ts.map +1 -1
  139. package/packages/mcp-server/dist/workflow-tools.js +30 -21
  140. package/packages/mcp-server/dist/workflow-tools.js.map +1 -1
  141. package/packages/mcp-server/package.json +3 -3
  142. package/packages/native/package.json +1 -1
  143. package/packages/pi-agent-core/package.json +1 -1
  144. package/packages/pi-ai/dist/models.generated.d.ts +266 -35
  145. package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
  146. package/packages/pi-ai/dist/models.generated.js +235 -46
  147. package/packages/pi-ai/dist/models.generated.js.map +1 -1
  148. package/packages/pi-ai/package.json +1 -1
  149. package/packages/pi-coding-agent/dist/core/capability-patches.d.ts.map +1 -1
  150. package/packages/pi-coding-agent/dist/core/capability-patches.js +3 -1
  151. package/packages/pi-coding-agent/dist/core/capability-patches.js.map +1 -1
  152. package/packages/pi-coding-agent/package.json +7 -7
  153. package/packages/pi-tui/package.json +2 -2
  154. package/packages/rpc-client/package.json +2 -2
  155. package/pkg/package.json +1 -1
  156. package/src/resources/extensions/bg-shell/utilities.ts +2 -2
  157. package/src/resources/extensions/claude-code-cli/models.ts +9 -0
  158. package/src/resources/extensions/claude-code-cli/stream-adapter.ts +6 -0
  159. package/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts +28 -0
  160. package/src/resources/extensions/gsd/auto/loop-deps.ts +1 -1
  161. package/src/resources/extensions/gsd/auto/orchestrator.ts +39 -5
  162. package/src/resources/extensions/gsd/auto/phases.ts +10 -1
  163. package/src/resources/extensions/gsd/auto-post-unit.ts +12 -5
  164. package/src/resources/extensions/gsd/auto-start.ts +8 -14
  165. package/src/resources/extensions/gsd/auto-worktree-repair.ts +13 -2
  166. package/src/resources/extensions/gsd/auto-worktree.ts +20 -280
  167. package/src/resources/extensions/gsd/auto.ts +12 -9
  168. package/src/resources/extensions/gsd/bootstrap/dynamic-tools.ts +10 -6
  169. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +32 -3
  170. package/src/resources/extensions/gsd/bootstrap/write-gate.ts +25 -3
  171. package/src/resources/extensions/gsd/captures.ts +5 -14
  172. package/src/resources/extensions/gsd/closeout-recovery.ts +2 -1
  173. package/src/resources/extensions/gsd/commands/catalog.ts +6 -68
  174. package/src/resources/extensions/gsd/db/engine.ts +809 -0
  175. package/src/resources/extensions/gsd/db/queries.ts +453 -0
  176. package/src/resources/extensions/gsd/db/sql-constants.ts +12 -0
  177. package/src/resources/extensions/gsd/db/writers/cascades.ts +237 -0
  178. package/src/resources/extensions/gsd/db/writers/import-restore.ts +310 -0
  179. package/src/resources/extensions/gsd/db/writers/memory.ts +220 -0
  180. package/src/resources/extensions/gsd/db/writers/reconcile.ts +500 -0
  181. package/src/resources/extensions/gsd/db/writers/status.ts +88 -0
  182. package/src/resources/extensions/gsd/doctor-environment.ts +8 -11
  183. package/src/resources/extensions/gsd/doctor-git-checks.ts +3 -3
  184. package/src/resources/extensions/gsd/doctor-runtime-checks.ts +10 -3
  185. package/src/resources/extensions/gsd/git-service.ts +1 -0
  186. package/src/resources/extensions/gsd/gitignore.ts +3 -0
  187. package/src/resources/extensions/gsd/gsd-db.ts +173 -2373
  188. package/src/resources/extensions/gsd/guided-flow.ts +34 -3
  189. package/src/resources/extensions/gsd/migrate/safety.ts +15 -7
  190. package/src/resources/extensions/gsd/migration-auto-check.ts +28 -3
  191. package/src/resources/extensions/gsd/model-cost-table.ts +1 -0
  192. package/src/resources/extensions/gsd/model-router.ts +3 -0
  193. package/src/resources/extensions/gsd/parallel-merge.ts +12 -9
  194. package/src/resources/extensions/gsd/parallel-monitor-overlay.ts +6 -5
  195. package/src/resources/extensions/gsd/paths.ts +9 -22
  196. package/src/resources/extensions/gsd/preferences.ts +18 -0
  197. package/src/resources/extensions/gsd/recovery-classification.ts +14 -1
  198. package/src/resources/extensions/gsd/safety/evidence-collector.ts +36 -4
  199. package/src/resources/extensions/gsd/safety/evidence-cross-ref.ts +7 -2
  200. package/src/resources/extensions/gsd/safety/file-change-validator.ts +14 -0
  201. package/src/resources/extensions/gsd/state-transition-matrix.ts +42 -0
  202. package/src/resources/extensions/gsd/status-guards.ts +59 -8
  203. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +123 -0
  204. package/src/resources/extensions/gsd/tests/auto-paused-ui-cleanup.test.ts +3 -1
  205. package/src/resources/extensions/gsd/tests/auto-post-unit-evidence-crossref-4909.test.ts +46 -0
  206. package/src/resources/extensions/gsd/tests/auto-worktree-registry.test.ts +2 -2
  207. package/src/resources/extensions/gsd/tests/auto-worktree-repair.test.ts +4 -2
  208. package/src/resources/extensions/gsd/tests/clear-stale-autostart.test.ts +22 -0
  209. package/src/resources/extensions/gsd/tests/evidence-xref-gsd-exec.test.ts +157 -0
  210. package/src/resources/extensions/gsd/tests/file-change-validator.test.ts +33 -1
  211. package/src/resources/extensions/gsd/tests/integration/auto-worktree-milestone-merge.test.ts +5 -4
  212. package/src/resources/extensions/gsd/tests/integration/auto-worktree.test.ts +1 -1
  213. package/src/resources/extensions/gsd/tests/integration/git-service.test.ts +3 -2
  214. package/src/resources/extensions/gsd/tests/migration-auto-check.test.ts +85 -1
  215. package/src/resources/extensions/gsd/tests/recovery-classification-illegal-transition.test.ts +30 -0
  216. package/src/resources/extensions/gsd/tests/register-hooks-depth-verification.test.ts +91 -1
  217. package/src/resources/extensions/gsd/tests/safety-harness-false-positives.test.ts +38 -0
  218. package/src/resources/extensions/gsd/tests/session-switch-clears-pending-autostart.test.ts +108 -0
  219. package/src/resources/extensions/gsd/tests/single-writer-invariant.test.ts +43 -6
  220. package/src/resources/extensions/gsd/tests/state-transition-matrix.test.ts +36 -0
  221. package/src/resources/extensions/gsd/tests/status-guards.test.ts +38 -0
  222. package/src/resources/extensions/gsd/tests/worktree-lifecycle.test.ts +41 -4
  223. package/src/resources/extensions/gsd/tests/worktree-manager.test.ts +22 -1
  224. package/src/resources/extensions/gsd/tests/worktree-placement.test.ts +113 -0
  225. package/src/resources/extensions/gsd/tests/worktree-reentry.test.ts +1 -1
  226. package/src/resources/extensions/gsd/tests/worktree-safety.test.ts +3 -1
  227. package/src/resources/extensions/gsd/tests/worktree-symlink-removal.test.ts +12 -6
  228. package/src/resources/extensions/gsd/tests/worktree-teardown-safety.test.ts +2 -2
  229. package/src/resources/extensions/gsd/tests/write-gate.test.ts +42 -0
  230. package/src/resources/extensions/gsd/tools/complete-slice.ts +23 -58
  231. package/src/resources/extensions/gsd/tools/exec-tool.ts +5 -5
  232. package/src/resources/extensions/gsd/tools/reopen-milestone.ts +11 -38
  233. package/src/resources/extensions/gsd/tools/reopen-slice.ts +14 -42
  234. package/src/resources/extensions/gsd/tools/skip-slice.ts +18 -44
  235. package/src/resources/extensions/gsd/undo.ts +9 -8
  236. package/src/resources/extensions/gsd/worktree-git-recovery.ts +308 -0
  237. package/src/resources/extensions/gsd/worktree-lifecycle.ts +10 -1
  238. package/src/resources/extensions/gsd/worktree-manager.ts +47 -28
  239. package/src/resources/extensions/gsd/worktree-placement.ts +63 -0
  240. package/src/resources/extensions/gsd/worktree-reentry.ts +10 -7
  241. package/src/resources/extensions/gsd/worktree-root.ts +17 -6
  242. package/src/resources/extensions/gsd/worktree-safety.ts +8 -5
  243. package/src/resources/extensions/gsd/worktree-session-state.ts +12 -10
  244. package/src/resources/skills/gsd-browser/SKILL.md +1 -1
  245. /package/dist/web/standalone/.next/static/{DUFWcMFRH3iXh7d2fbrOF → C24pqUd-aru-l0Dp0gLZP}/_buildManifest.js +0 -0
  246. /package/dist/web/standalone/.next/static/{DUFWcMFRH3iXh7d2fbrOF → C24pqUd-aru-l0Dp0gLZP}/_ssgManifest.js +0 -0
@@ -1,6 +1,6 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
3
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { tmpdir } from "node:os";
6
6
 
@@ -13,7 +13,9 @@ import { closeDatabase, getMilestone } from "../gsd-db.ts";
13
13
  import { deriveState, invalidateStateCache } from "../state.ts";
14
14
  import {
15
15
  getPendingGate,
16
+ loadWriteGateSnapshot,
16
17
  resetWriteGateState,
18
+ setPendingGate,
17
19
  shouldBlockContextArtifactSave,
18
20
  } from "../bootstrap/write-gate.ts";
19
21
  import { classifyCommand } from "../safety/destructive-guard.ts";
@@ -802,3 +804,91 @@ test("register-hooks message_update does NOT pause while an interactive elicitat
802
804
  "prose-only approval with no elicitation in flight must still arm the pause notice",
803
805
  );
804
806
  });
807
+
808
+ test("register-hooks agent_end does not re-arm deferred gate after workflow MCP verified write-gate on disk", async (t) => {
809
+ const dir = makeTempDir("mcp-disk-sync");
810
+ const originalCwd = process.cwd();
811
+ const originalEnv = process.env.GSD_PERSIST_WRITE_GATE_STATE;
812
+ process.chdir(dir);
813
+ resetWriteGateState(dir);
814
+ clearPendingAutoStart(dir);
815
+ process.env.GSD_PERSIST_WRITE_GATE_STATE = "1";
816
+
817
+ const gateId = "depth_verification_M005_confirm";
818
+ const statePath = join(dir, ".gsd", "runtime", "write-gate-state.json");
819
+
820
+ t.after(() => {
821
+ try {
822
+ resetWriteGateState(dir);
823
+ clearPendingAutoStart(dir);
824
+ } finally {
825
+ if (originalEnv === undefined) {
826
+ delete process.env.GSD_PERSIST_WRITE_GATE_STATE;
827
+ } else {
828
+ process.env.GSD_PERSIST_WRITE_GATE_STATE = originalEnv;
829
+ }
830
+ process.chdir(originalCwd);
831
+ rmSync(dir, { recursive: true, force: true });
832
+ }
833
+ });
834
+
835
+ const handlers = new Map<string, Array<(event: any, ctx?: any) => Promise<any> | any>>();
836
+ const pi = {
837
+ on(event: string, handler: (event: any, ctx?: any) => Promise<any> | any) {
838
+ const existing = handlers.get(event) ?? [];
839
+ existing.push(handler);
840
+ handlers.set(event, existing);
841
+ },
842
+ } as any;
843
+
844
+ const ctx = {
845
+ cwd: dir,
846
+ ui: { notify: () => undefined },
847
+ } as any;
848
+
849
+ registerHooks(pi, []);
850
+
851
+ setPendingAutoStart(dir, {
852
+ basePath: dir,
853
+ milestoneId: "M005",
854
+ ctx,
855
+ pi: { sendMessage: () => undefined } as any,
856
+ });
857
+
858
+ const approvalMessage = {
859
+ role: "assistant",
860
+ content: [
861
+ { type: "text", text: "Did I capture the depth right?" },
862
+ ],
863
+ };
864
+
865
+ for (const handler of handlers.get("message_update") ?? []) {
866
+ await handler({ message: approvalMessage }, ctx);
867
+ }
868
+
869
+ setPendingGate(gateId, dir);
870
+ mkdirSync(join(dir, ".gsd", "runtime"), { recursive: true });
871
+ writeFileSync(statePath, JSON.stringify({
872
+ verifiedDepthMilestones: ["M005"],
873
+ verifiedApprovalGates: [gateId],
874
+ activeQueuePhase: false,
875
+ pendingGateId: null,
876
+ }, null, 2), "utf-8");
877
+
878
+ for (const handler of handlers.get("agent_end") ?? []) {
879
+ await handler({ messages: [] }, ctx);
880
+ }
881
+
882
+ assert.equal(getPendingGate(dir), null, "agent_end must not re-arm a gate the MCP subprocess already verified");
883
+ assert.equal(
884
+ shouldBlockContextArtifactSave("CONTEXT", "M005", null, dir).block,
885
+ false,
886
+ "verified milestone context writes must stay unlocked after agent_end",
887
+ );
888
+ assert.deepEqual(loadWriteGateSnapshot(dir), {
889
+ verifiedDepthMilestones: ["M005"],
890
+ verifiedApprovalGates: [gateId],
891
+ activeQueuePhase: false,
892
+ pendingGateId: null,
893
+ });
894
+ });
@@ -266,3 +266,41 @@ test("safety-harness: planned changed file avoids unexpected-file warning", (t)
266
266
  assert.deepEqual(audit!.unexpectedFiles, [], "planned index.html must not be unexpected");
267
267
  assert.deepEqual(audit!.missingFiles, [], "planned index.html must not be missing");
268
268
  });
269
+
270
+ // ─── External engine evidence (claude-code-cli pre-executed tools) ──────────
271
+ // External engines skip beforeToolCall/tool_call, so register-hooks.ts records
272
+ // evidence at tool_execution_start instead. These tests lock the collector
273
+ // semantics that wiring relies on: idempotent recording by toolCallId, and
274
+ // capitalized external tool names (Bash/Write/Edit) being recognized.
275
+
276
+ test("safety-harness: recordToolCall is idempotent by toolCallId", () => {
277
+ resetEvidence();
278
+
279
+ // Native tools fire tool_execution_start AND tool_call — both record.
280
+ recordToolCall("tc-dup-1", "bash", { command: "npm test" });
281
+ recordToolCall("tc-dup-1", "bash", { command: "npm test" });
282
+
283
+ assert.equal(getEvidence().length, 1, "same toolCallId must not duplicate");
284
+ });
285
+
286
+ test("safety-harness: external capitalized Bash call records and resolves evidence", () => {
287
+ resetEvidence();
288
+
289
+ // tool_execution_start with Claude Code's native tool name
290
+ recordToolCall("ext-bash-1", "Bash", { command: "node /tmp/t01-verify.mjs" });
291
+ const entries = getEvidence().filter((e): e is BashEvidence => e.kind === "bash");
292
+ assert.equal(entries.length, 1, "capitalized Bash must be recognized as execution tool");
293
+ assert.equal(entries[0]!.command, "node /tmp/t01-verify.mjs");
294
+
295
+ // tool_execution_end fills the result
296
+ recordToolResult("ext-bash-1", "Bash", { content: [{ type: "text", text: "13 checks passed" }] }, false);
297
+ assert.equal(entries[0]!.exitCode, 0, "non-error external result resolves to exitCode 0");
298
+ });
299
+
300
+ test("safety-harness: external Write call records file evidence", () => {
301
+ resetEvidence();
302
+
303
+ recordToolCall("ext-write-1", "Write", { file_path: "/tmp/app/index.html" });
304
+ const writes = getEvidence().filter((e) => e.kind === "write");
305
+ assert.equal(writes.length, 1, "capitalized Write must record file evidence");
306
+ });
@@ -0,0 +1,108 @@
1
+ // gsd-pi — A fresh conversation (/clear, /new) must clear pending auto-start.
2
+ //
3
+ // The discuss→auto handoff entry lives in-memory and is consumed on agent_end
4
+ // of the live interview. Once the milestone CONTEXT artifact is saved, the
5
+ // guided-flow staleness heuristic (which requires the CONTEXT file to be
6
+ // absent) can never fire — so a discussion interrupted by /clear left an
7
+ // immortal entry and every subsequent /gsd dead-ended on "Discussion already
8
+ // in progress — answer the question above" with no question above.
9
+ //
10
+ // session_switch with reason "new" means the conversation that contained the
11
+ // interview is gone; the entry must go with it. Reason "resume" restores the
12
+ // interview transcript, so the entry must survive.
13
+
14
+ import { describe, it, beforeEach, afterEach } from "node:test";
15
+ import assert from "node:assert/strict";
16
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { tmpdir } from "node:os";
19
+
20
+ import { registerHooks } from "../bootstrap/register-hooks.ts";
21
+ import {
22
+ setPendingAutoStart,
23
+ clearPendingAutoStart,
24
+ _getPendingAutoStart,
25
+ } from "../pending-auto-start.ts";
26
+
27
+ function makeProjectDir(): string {
28
+ const dir = mkdtempSync(join(tmpdir(), "gsd-session-switch-pas-"));
29
+ const milestoneDir = join(dir, ".gsd", "milestones", "M001");
30
+ mkdirSync(milestoneDir, { recursive: true });
31
+ // The post-CONTEXT state that made the entry immortal before the fix.
32
+ writeFileSync(join(milestoneDir, "M001-CONTEXT.md"), "# M001 Context\n");
33
+ return dir;
34
+ }
35
+
36
+ function fakeCtx(base: string): any {
37
+ return {
38
+ cwd: base,
39
+ ui: {
40
+ notify: () => undefined,
41
+ setWidget: () => undefined,
42
+ setStatus: () => undefined,
43
+ },
44
+ };
45
+ }
46
+
47
+ function armPendingAutoStart(base: string): void {
48
+ setPendingAutoStart(base, {
49
+ basePath: base,
50
+ milestoneId: "M001",
51
+ ctx: { ui: { notify: () => undefined } } as any,
52
+ pi: { sendMessage: () => undefined } as any,
53
+ });
54
+ }
55
+
56
+ describe("session_switch clears pending auto-start on conversation reset", () => {
57
+ let base: string;
58
+ const handlers = new Map<string, Function>();
59
+
60
+ beforeEach(() => {
61
+ clearPendingAutoStart();
62
+ base = makeProjectDir();
63
+ handlers.clear();
64
+ registerHooks({ on(event: string, handler: Function) { handlers.set(event, handler); } } as any, []);
65
+ });
66
+
67
+ afterEach(() => {
68
+ clearPendingAutoStart();
69
+ rmSync(base, { recursive: true, force: true });
70
+ });
71
+
72
+ async function fireSessionSwitch(reason: "new" | "resume"): Promise<void> {
73
+ const handler = handlers.get("session_switch");
74
+ assert.ok(handler, "session_switch handler should be registered");
75
+ try {
76
+ await handler({ type: "session_switch", reason, previousSessionFile: undefined }, fakeCtx(base));
77
+ } catch {
78
+ // The handler also performs session plumbing (MCP prep, service tier
79
+ // sync) that may throw against the minimal fake ctx. Pending auto-start
80
+ // is cleared before that plumbing runs, so the assertions below remain
81
+ // valid either way.
82
+ }
83
+ }
84
+
85
+ it('reason "new" (/clear, /new) drops the entry even after CONTEXT was saved', async () => {
86
+ armPendingAutoStart(base);
87
+ assert.ok(_getPendingAutoStart(base), "entry should be armed");
88
+
89
+ await fireSessionSwitch("new");
90
+
91
+ assert.equal(
92
+ _getPendingAutoStart(base),
93
+ null,
94
+ "a fresh conversation destroyed the interview — the handoff entry must not outlive it",
95
+ );
96
+ });
97
+
98
+ it('reason "resume" keeps the entry (the interview transcript is restored)', async () => {
99
+ armPendingAutoStart(base);
100
+
101
+ await fireSessionSwitch("resume");
102
+
103
+ assert.ok(
104
+ _getPendingAutoStart(base),
105
+ "resuming restores the interview — the in-flight handoff must survive",
106
+ );
107
+ });
108
+ });
@@ -24,10 +24,24 @@ import { join, relative } from "node:path";
24
24
 
25
25
  const gsdDir = join(process.cwd(), "src/resources/extensions/gsd");
26
26
 
27
- const ALLOWLIST = new Set([
28
- "gsd-db.ts",
29
- "unit-ownership.ts",
30
- ]);
27
+ // The single-writer invariant is enforced on a directory layer, not a single
28
+ // filename. Write SQL may live only in:
29
+ // - db/engine.ts — connection lifecycle, schema/migrations (DDL), and the
30
+ // BEGIN/COMMIT transaction primitives. The shared handle every writer reads.
31
+ // - db/writers/**.ts — the Single Writer Layer: one cohesive write subsystem
32
+ // per file (hierarchy, memory, gates, escalation, reconcile, manifest,
33
+ // legacy-import, cascades).
34
+ // - gsd-db.ts — the barrel that re-exports the layer (still holds wrappers
35
+ // mid-migration).
36
+ // - unit-ownership.ts — a separate .gsd/unit-claims.db, intentionally outside.
37
+ // db/queries.ts is explicitly NOT allowed write SQL (asserted separately below).
38
+ function isSingleWriterFile(rel: string): boolean {
39
+ const norm = rel.split("\\").join("/");
40
+ if (norm === "gsd-db.ts" || norm === "unit-ownership.ts") return true;
41
+ if (norm === "db/engine.ts") return true;
42
+ if (norm.startsWith("db/writers/") && norm.endsWith(".ts")) return true;
43
+ return false;
44
+ }
31
45
 
32
46
  /** Walk the gsd extension dir and return all .ts files outside tests/. */
33
47
  function walkTsFiles(root: string): string[] {
@@ -106,8 +120,7 @@ test("no module outside gsd-db.ts issues raw write SQL against the engine DB", (
106
120
 
107
121
  for (const abs of files) {
108
122
  const rel = relative(gsdDir, abs);
109
- const base = rel.split("/").pop()!;
110
- if (ALLOWLIST.has(base)) continue;
123
+ if (isSingleWriterFile(rel)) continue;
111
124
 
112
125
  let content: string;
113
126
  try {
@@ -154,6 +167,30 @@ test("no module outside gsd-db.ts issues raw write SQL against the engine DB", (
154
167
  }
155
168
  });
156
169
 
170
+ test("db/queries.ts (the Query Module) is read-only — contains no write SQL", () => {
171
+ // The read seam is separate from the single-writer layer. queries.ts holds
172
+ // SELECT-only wrappers so read-only callers depend on a read seam, not the
173
+ // write surface. (test 1 above also forbids this, since queries.ts is not in
174
+ // db/writers/ — this is the explicit, positive statement of intent.)
175
+ const queriesPath = join(gsdDir, "db", "queries.ts");
176
+ const content = readFileSync(queriesPath, "utf-8");
177
+ const lines = content.split("\n");
178
+ const violations: Violation[] = [];
179
+ for (let i = 0; i < lines.length; i++) {
180
+ const line = lines[i];
181
+ const m = PREPARE_WRITE_RE.exec(line) ?? EXEC_WRITE_RE.exec(line);
182
+ if (m) {
183
+ violations.push({ file: "db/queries.ts", line: i + 1, snippet: line.trim(), kind: m[1].toUpperCase() });
184
+ }
185
+ }
186
+ assert.equal(
187
+ violations.length,
188
+ 0,
189
+ `db/queries.ts must contain no write SQL — move write wrappers to db/writers/:\n` +
190
+ violations.map((v) => ` db/queries.ts:${v.line} [${v.kind}] — ${v.snippet}`).join("\n"),
191
+ );
192
+ });
193
+
157
194
  test("gsd-db.ts exports the expected single-writer wrappers", async () => {
158
195
  // Positive assertion — fail loudly if the module layout changes so this
159
196
  // structural test can't silently become a no-op.
@@ -5,6 +5,8 @@ import {
5
5
  STATE_TRANSITION_MATRIX,
6
6
  findTransition,
7
7
  validateTransitionMatrix,
8
+ isLegalEdge,
9
+ IllegalPhaseTransitionError,
8
10
  } from "../state-transition-matrix.ts";
9
11
 
10
12
  test("state transition matrix covers required swarm hardening events", () => {
@@ -42,3 +44,37 @@ test("state transition matrix entries all have guard and reason codes", () => {
42
44
  assert.ok(entry.reasonCode.length > 0, `${entry.event} must include reason code`);
43
45
  }
44
46
  });
47
+
48
+ // ─── ADR-030: Phase Transition Invariant ───────────────────────────────────
49
+
50
+ test("isLegalEdge treats a self-edge as trivially legal", () => {
51
+ assert.equal(isLegalEdge("executing", "executing"), true);
52
+ assert.equal(isLegalEdge("planning", "planning"), true);
53
+ });
54
+
55
+ test("isLegalEdge accepts edges enumerated in the matrix", () => {
56
+ assert.equal(isLegalEdge("planning", "executing"), true);
57
+ assert.equal(isLegalEdge("executing", "summarizing"), true);
58
+ assert.equal(isLegalEdge("summarizing", "validating-milestone"), true);
59
+ assert.equal(isLegalEdge("completing-milestone", "complete"), true);
60
+ });
61
+
62
+ test("isLegalEdge honors the * wildcard rows (any -> blocked, any -> executing)", () => {
63
+ assert.equal(isLegalEdge("planning", "blocked"), true);
64
+ assert.equal(isLegalEdge("summarizing", "executing"), true);
65
+ });
66
+
67
+ test("isLegalEdge rejects an edge no matrix entry permits", () => {
68
+ // executing -> complete skips validation — exactly the illegal jump the
69
+ // invariant exists to catch.
70
+ assert.equal(isLegalEdge("executing", "complete"), false);
71
+ assert.equal(isLegalEdge("planning", "summarizing"), false);
72
+ });
73
+
74
+ test("IllegalPhaseTransitionError carries both endpoints and a descriptive message", () => {
75
+ const err = new IllegalPhaseTransitionError("executing", "complete");
76
+ assert.equal(err.from, "executing");
77
+ assert.equal(err.to, "complete");
78
+ assert.equal(err.name, "IllegalPhaseTransitionError");
79
+ assert.match(err.message, /executing -> complete/);
80
+ });
@@ -9,7 +9,10 @@ import {
9
9
  isDeferredStatus,
10
10
  isInactiveStatus,
11
11
  isSkippedForDispatch,
12
+ toStatus,
13
+ RAW_CLOSED_STATUSES,
12
14
  } from '../status-guards.ts';
15
+ import { TERMINAL_STATUS_SQL } from '../db/sql-constants.ts';
13
16
 
14
17
  test('isClosedStatus: "complete" returns true', () => {
15
18
  assert.equal(isClosedStatus('complete'), true);
@@ -95,3 +98,38 @@ test('isSkippedForDispatch does NOT skip pending/active/planned', () => {
95
98
  assert.equal(isSkippedForDispatch(s), false, `${s} should block dispatch ordering`);
96
99
  }
97
100
  });
101
+
102
+ // ─── ADR-030: canonical Status vocabulary + normalization ──────────────────
103
+
104
+ test('toStatus passes canonical values through unchanged', () => {
105
+ for (const s of ['pending', 'queued', 'active', 'parked', 'in_progress', 'blocked', 'complete', 'skipped', 'deferred']) {
106
+ assert.equal(toStatus(s), s, `${s} is canonical and should be returned verbatim`);
107
+ }
108
+ });
109
+
110
+ test('toStatus maps known aliases to canonical', () => {
111
+ assert.equal(toStatus('done'), 'complete');
112
+ assert.equal(toStatus('closed'), 'complete');
113
+ assert.equal(toStatus('planned'), 'pending');
114
+ assert.equal(toStatus('in-progress'), 'in_progress');
115
+ });
116
+
117
+ test('toStatus trims surrounding whitespace before matching', () => {
118
+ assert.equal(toStatus(' complete '), 'complete');
119
+ assert.equal(toStatus(' done '), 'complete');
120
+ });
121
+
122
+ test('toStatus quarantines unknown values verbatim (tolerant read, no throw)', () => {
123
+ assert.equal(toStatus('weird-legacy-value'), 'weird-legacy-value');
124
+ });
125
+
126
+ test('RAW_CLOSED_STATUSES is the single source: every member is closed', () => {
127
+ for (const s of RAW_CLOSED_STATUSES) {
128
+ assert.equal(isClosedStatus(s), true, `${s} is in RAW_CLOSED_STATUSES so must be closed`);
129
+ }
130
+ });
131
+
132
+ test('TERMINAL_STATUS_SQL is derived from RAW_CLOSED_STATUSES and renders identically', () => {
133
+ assert.equal(TERMINAL_STATUS_SQL, "'complete', 'done', 'skipped', 'closed'");
134
+ assert.equal(TERMINAL_STATUS_SQL, RAW_CLOSED_STATUSES.map((s) => `'${s}'`).join(', '));
135
+ });
@@ -161,13 +161,13 @@ test("enterMilestone returns ok:true mode:worktree on successful create", (t) =>
161
161
  if (result.ok) {
162
162
  assert.equal(result.mode, "worktree");
163
163
  assert.ok(
164
- result.path.endsWith("/.gsd/worktrees/M001"),
165
- `expected path to end with /.gsd/worktrees/M001, got ${result.path}`,
164
+ result.path.endsWith("/.gsd-worktrees/M001"),
165
+ `expected path to end with /.gsd-worktrees/M001, got ${result.path}`,
166
166
  );
167
167
  }
168
168
  assert.ok(
169
- s.basePath.endsWith("/.gsd/worktrees/M001"),
170
- `expected s.basePath to end with /.gsd/worktrees/M001, got ${s.basePath}`,
169
+ s.basePath.endsWith("/.gsd-worktrees/M001"),
170
+ `expected s.basePath to end with /.gsd-worktrees/M001, got ${s.basePath}`,
171
171
  );
172
172
  // After C3 (#5626) `invalidateAllCaches` is inlined; assertion against
173
173
  // `deps.calls` for cache invalidation is no longer possible.
@@ -240,6 +240,43 @@ test("adoptStrandedMilestone forces branch recovery even when normal preferences
240
240
  assert.equal(currentBranch, "milestone/M001");
241
241
  });
242
242
 
243
+ test("enterMilestone honors stranded branch recovery instead of recreating the worktree", (t) => {
244
+ // Regression: after adoptStrandedMilestone checks out milestone/M001 in
245
+ // the project root, a plain enterMilestone under isolation:worktree used
246
+ // to attempt `git worktree add`, which git refuses ("branch is already in
247
+ // use by another worktree" — the root checkout IS the conflicting
248
+ // worktree), tripping a creation-failed warning and degrading isolation.
249
+ // The recovery override must keep re-entries in branch mode.
250
+ const previousCwd = process.cwd();
251
+ const base = makeGitRepoBase({ isolation: "worktree" });
252
+ t.after(() => cleanupRepoBase(base, previousCwd));
253
+
254
+ const s = makeSession({ basePath: base, originalBasePath: base });
255
+ const deps = makeDeps();
256
+ const ctx = makeCtx();
257
+ const lifecycle = new WorktreeLifecycle(s, deps);
258
+
259
+ const adopted = lifecycle.adoptStrandedMilestone("M001", base, ctx, {
260
+ mode: "branch",
261
+ });
262
+ assert.equal(adopted.ok, true, `expected adopt ok:true, got: ${JSON.stringify(adopted)}`);
263
+
264
+ const result = lifecycle.enterMilestone("M001", ctx);
265
+
266
+ assert.equal(result.ok, true, `expected ok:true, got: ${JSON.stringify(result)}`);
267
+ if (result.ok) {
268
+ assert.equal(result.mode, "branch");
269
+ assert.equal(result.path, base);
270
+ }
271
+ assert.equal(s.basePath, base);
272
+ assert.equal(s.isolationDegraded, false, "intentional branch adoption must not degrade isolation");
273
+ assert.equal(
274
+ ctx.messages.some((m) => m.msg.includes("creation for M001 failed")),
275
+ false,
276
+ "re-entry must not attempt (and fail) canonical worktree creation",
277
+ );
278
+ });
279
+
243
280
  test("enterMilestone returns ok:false reason:isolation-degraded when session degraded", () => {
244
281
  const s = makeSession({ isolationDegraded: true });
245
282
  const deps = makeDeps({ getIsolationMode: () => "branch" });
@@ -142,6 +142,27 @@ describe("createWorktree", () => {
142
142
  run("git rev-parse --git-dir", info.path);
143
143
  assert.ok(!existsSync(join(info.path, "orphan.txt")), "stale file removed by recovery");
144
144
  });
145
+
146
+ test("removes stale canonical directory when legacy orphan is cleaned and canonical target is stale", () => {
147
+ // Scenario: a stale .gsd-worktrees/M020 directory (no .git — aborted prior
148
+ // creation) coexists with an orphaned .gsd/worktrees/M020 dir (.git file not
149
+ // registered with git). worktreePathFor returns the legacy path (canonical has
150
+ // no .git marker), so only the legacy path was previously cleaned — the stale
151
+ // canonical blocked git worktree add. The fix ensures createWorktree also
152
+ // removes the stale canonical before calling git worktree add.
153
+ const canonicalDir = join(base, ".gsd-worktrees", "M020");
154
+ const legacyDir = join(base, ".gsd", "worktrees", "M020");
155
+
156
+ mkdirSync(canonicalDir, { recursive: true }); // stale canonical: exists, no .git
157
+ mkdirSync(legacyDir, { recursive: true });
158
+ writeFileSync(join(legacyDir, ".git"), "gitdir: ../../../../.git/worktrees/M020\n", "utf-8");
159
+
160
+ const info = createWorktree(base, "M020");
161
+ assert.strictEqual(info.name, "M020");
162
+ assert.ok(existsSync(info.path), "worktree path should exist after creation");
163
+ assert.ok(existsSync(join(info.path, ".git")), "new worktree has .git marker");
164
+ run("git rev-parse --git-dir", info.path);
165
+ });
145
166
  });
146
167
 
147
168
  describe("createWorktree — duplicate rejection", () => {
@@ -180,7 +201,7 @@ describe("createWorktree — branch cleanup on add failure", () => {
180
201
 
181
202
  // Make the worktrees parent directory non-writable so `git worktree add`
182
203
  // fails after the branch has already been force-reset.
183
- const parentDir = join(base, ".gsd", "worktrees");
204
+ const parentDir = join(base, ".gsd-worktrees");
184
205
  mkdirSync(parentDir, { recursive: true });
185
206
  run(`chmod 555 "${parentDir}"`, base);
186
207
 
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Tests for worktreePathFor — the forward seam (project + name → path).
3
+ *
4
+ * Key invariant: a stale canonical directory (no .git marker) must NOT
5
+ * shadow a live legacy worktree (.gsd/worktrees/<name> with .git).
6
+ */
7
+
8
+ import test from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { mkdirSync, writeFileSync, rmSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { tmpdir } from "node:os";
13
+ import { randomUUID } from "node:crypto";
14
+
15
+ import { worktreePathFor, canonicalWorktreesDir, legacyWorktreesDir } from "../worktree-placement.ts";
16
+
17
+ function makeTmpRoot(): string {
18
+ const root = join(tmpdir(), `gsd-placement-test-${randomUUID()}`);
19
+ mkdirSync(root, { recursive: true });
20
+ return root;
21
+ }
22
+
23
+ function cleanup(root: string): void {
24
+ try { rmSync(root, { recursive: true, force: true }); } catch { /* */ }
25
+ }
26
+
27
+ function makeCanonicalDir(root: string, name: string): string {
28
+ const p = join(canonicalWorktreesDir(root), name);
29
+ mkdirSync(p, { recursive: true });
30
+ return p;
31
+ }
32
+
33
+ function makeLiveCanonical(root: string, name: string): string {
34
+ const p = makeCanonicalDir(root, name);
35
+ writeFileSync(join(p, ".git"), `gitdir: ${join(root, ".git", "worktrees", name)}\n`);
36
+ return p;
37
+ }
38
+
39
+ function makeLiveLegacy(root: string, name: string): string {
40
+ const p = join(legacyWorktreesDir(root), name);
41
+ mkdirSync(p, { recursive: true });
42
+ writeFileSync(join(p, ".git"), `gitdir: ${join(root, ".git", "worktrees", name)}\n`);
43
+ return p;
44
+ }
45
+
46
+ test("returns canonical path when canonical has .git marker", () => {
47
+ const root = makeTmpRoot();
48
+ try {
49
+ const canonical = makeLiveCanonical(root, "M001");
50
+ assert.equal(worktreePathFor(root, "M001"), canonical);
51
+ } finally {
52
+ cleanup(root);
53
+ }
54
+ });
55
+
56
+ test("returns legacy path when only legacy exists with .git marker", () => {
57
+ const root = makeTmpRoot();
58
+ try {
59
+ const legacy = makeLiveLegacy(root, "M001");
60
+ assert.equal(worktreePathFor(root, "M001"), legacy);
61
+ } finally {
62
+ cleanup(root);
63
+ }
64
+ });
65
+
66
+ test("returns legacy path when canonical dir exists but has no .git (stale canonical)", () => {
67
+ const root = makeTmpRoot();
68
+ try {
69
+ makeCanonicalDir(root, "M001"); // stale: dir exists, no .git
70
+ const legacy = makeLiveLegacy(root, "M001");
71
+ assert.equal(
72
+ worktreePathFor(root, "M001"),
73
+ legacy,
74
+ "stale canonical must not shadow live legacy worktree",
75
+ );
76
+ } finally {
77
+ cleanup(root);
78
+ }
79
+ });
80
+
81
+ test("returns canonical path for new-worktree creation when neither path exists", () => {
82
+ const root = makeTmpRoot();
83
+ try {
84
+ const expected = join(canonicalWorktreesDir(root), "M001");
85
+ assert.equal(worktreePathFor(root, "M001"), expected);
86
+ } finally {
87
+ cleanup(root);
88
+ }
89
+ });
90
+
91
+ test("prefers live canonical over live legacy when both exist", () => {
92
+ const root = makeTmpRoot();
93
+ try {
94
+ const canonical = makeLiveCanonical(root, "M001");
95
+ makeLiveLegacy(root, "M001");
96
+ assert.equal(worktreePathFor(root, "M001"), canonical);
97
+ } finally {
98
+ cleanup(root);
99
+ }
100
+ });
101
+
102
+ test("returns legacy when canonical is stale and legacy has no .git (both stale)", () => {
103
+ const root = makeTmpRoot();
104
+ try {
105
+ makeCanonicalDir(root, "M001"); // stale canonical
106
+ const legacy = join(legacyWorktreesDir(root), "M001");
107
+ mkdirSync(legacy, { recursive: true }); // stale legacy (no .git)
108
+ // Falls through to legacy existsSync since canonical has no .git
109
+ assert.equal(worktreePathFor(root, "M001"), legacy);
110
+ } finally {
111
+ cleanup(root);
112
+ }
113
+ });
@@ -65,7 +65,7 @@ describe("reenterActiveWorktreeIfNeeded", () => {
65
65
  const entered = await reenterActiveWorktreeIfNeeded(dir);
66
66
  assert.ok(entered, "re-entry returned a worktree path");
67
67
  assert.strictEqual(realpathSync(process.cwd()), realpathSync(entered!), "cwd moved into the worktree");
68
- assert.strictEqual(entered, join(dir, ".gsd", "worktrees", "M001"));
68
+ assert.strictEqual(entered, join(dir, ".gsd-worktrees", "M001"));
69
69
  });
70
70
 
71
71
  test("no-op when already inside a worktree", async (t) => {
@@ -126,7 +126,9 @@ describe("Worktree Safety module", () => {
126
126
  assert.equal(result.ok, false);
127
127
  assert.equal(result.kind, "invalid-root");
128
128
  assert.equal(result.details?.unitRoot, outsideRoot);
129
- assert.equal(result.details?.expectedRoot, unitRoot);
129
+ // The reported expected root is the canonical container; the legacy
130
+ // .gsd/worktrees/ location is also accepted but not surfaced here.
131
+ assert.equal(result.details?.expectedRoot, join(projectRoot, ".gsd-worktrees", "M001"));
130
132
  });
131
133
 
132
134
  test("accepts project root for source-writing Unit when isolation mode is none", () => {