@north-light/crouter 0.3.226 → 0.3.228

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 (288) hide show
  1. package/dist/api/client.d.ts +5 -5
  2. package/dist/api/client.js +3 -3
  3. package/dist/api/dto/broker-ops.d.ts +8 -0
  4. package/dist/api/dto/memory.d.ts +8 -7
  5. package/dist/api/dto/memory.js +2 -2
  6. package/dist/builtin-memory/05-kinds/design/00-base.md +3 -4
  7. package/dist/builtin-memory/05-kinds/design/01-orchestrator.md +4 -3
  8. package/dist/builtin-memory/05-kinds/design/design-contract.md +3 -6
  9. package/dist/builtin-memory/05-kinds/plan/00-base.md +3 -4
  10. package/dist/builtin-memory/05-kinds/plan/01-orchestrator.md +4 -5
  11. package/dist/builtin-memory/05-kinds/plan/plan-contract.md +5 -13
  12. package/dist/builtin-memory/design/guide.md +38 -16
  13. package/dist/builtin-memory/design/roadmap.md +4 -4
  14. package/dist/builtin-memory/insights/init.md +5 -5
  15. package/dist/builtin-memory/internal/INDEX.md +1 -1
  16. package/dist/builtin-memory/internal/examples/INDEX.md +1 -1
  17. package/dist/builtin-memory/internal/memory-loading.md +6 -6
  18. package/dist/builtin-memory/internal/plugins.md +1 -1
  19. package/dist/builtin-memory/plan/guide.md +53 -0
  20. package/dist/builtin-memory/plan/roadmap.md +10 -8
  21. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/memory-slash-commands.ts +50 -37
  22. package/dist/clients/attach/__tests__/pane-tag-successor.test.js +56 -0
  23. package/dist/clients/attach/__tests__/profile-files.test.js +21 -0
  24. package/dist/clients/attach/chrome/inbox-strip.js +11 -7
  25. package/dist/clients/attach/session/pane-tag.d.ts +28 -2
  26. package/dist/clients/attach/session/pane-tag.js +48 -22
  27. package/dist/clients/attach/session/profile-files.d.ts +6 -0
  28. package/dist/clients/attach/session/profile-files.js +44 -11
  29. package/dist/clients/attach/slash/dispatch.js +4 -7
  30. package/dist/clients/attach/viewer.js +588 -596
  31. package/dist/commands/__tests__/surface-reload-target.test.js +21 -0
  32. package/dist/commands/api-client.d.ts +3 -7
  33. package/dist/commands/api-client.js +7 -12
  34. package/dist/commands/memory/__tests__/command-selector-and-mutation-guards.test.js +292 -0
  35. package/dist/commands/memory/__tests__/repository-root-lint.test.js +146 -0
  36. package/dist/commands/memory/delete.js +51 -24
  37. package/dist/commands/memory/edit.js +50 -10
  38. package/dist/commands/memory/find.js +105 -56
  39. package/dist/commands/memory/history.js +51 -31
  40. package/dist/commands/memory/lint.d.ts +0 -9
  41. package/dist/commands/memory/lint.js +78 -322
  42. package/dist/commands/memory/list.d.ts +12 -4
  43. package/dist/commands/memory/list.js +64 -25
  44. package/dist/commands/memory/move.js +103 -73
  45. package/dist/commands/memory/origin.js +35 -5
  46. package/dist/commands/memory/read.d.ts +4 -0
  47. package/dist/commands/memory/read.js +132 -80
  48. package/dist/commands/memory/shared.d.ts +115 -25
  49. package/dist/commands/memory/shared.js +331 -74
  50. package/dist/commands/memory/write.js +72 -21
  51. package/dist/commands/memory.js +2 -2
  52. package/dist/commands/node/lifecycle.js +2 -2
  53. package/dist/commands/pkg/market-manage.js +25 -24
  54. package/dist/commands/pkg/plugin-manage.d.ts +22 -6
  55. package/dist/commands/pkg/plugin-manage.js +118 -25
  56. package/dist/commands/pkg/shared.d.ts +8 -0
  57. package/dist/commands/pkg/shared.js +23 -20
  58. package/dist/commands/surface-reload.d.ts +5 -0
  59. package/dist/commands/surface-reload.js +9 -1
  60. package/dist/commands/sys/__tests__/migrate.test.js +1140 -22
  61. package/dist/commands/sys/__tests__/sync-project-guidance.test.js +218 -0
  62. package/dist/commands/sys/migrate.js +126 -140
  63. package/dist/commands/sys/panels/profiles-panel.d.ts +66 -0
  64. package/dist/commands/sys/panels/profiles-panel.js +599 -0
  65. package/dist/commands/sys/settings-shell.d.ts +4 -2
  66. package/dist/commands/sys/settings-shell.js +35 -4
  67. package/dist/commands/sys/settings.js +21 -5
  68. package/dist/commands/sys/sync-deps.d.ts +2 -0
  69. package/dist/commands/sys/sync-deps.js +26 -15
  70. package/dist/commands/sys/sync-project-guidance.js +228 -145
  71. package/dist/commands/sys/sync-skills.js +28 -17
  72. package/dist/commands/sys/update.js +11 -3
  73. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +15 -2
  74. package/dist/core/__tests__/config-change-delta.test.js +106 -0
  75. package/dist/core/__tests__/context-intro.test.js +13 -9
  76. package/dist/core/__tests__/daemon-boot.test.js +22 -16
  77. package/dist/core/__tests__/fixtures/memory-slash-live-probe.js +5 -1
  78. package/dist/core/__tests__/human-deliver.test.js +19 -0
  79. package/dist/core/__tests__/inline-memory-refs.test.js +50 -46
  80. package/dist/core/__tests__/{serial → integration}/broker-fork-seam.test.js +1 -1
  81. package/dist/core/__tests__/{serial → integration}/broker-sdk-wiring.test.js +1 -1
  82. package/dist/core/__tests__/{serial → integration}/broker-snapshot-history.test.js +1 -1
  83. package/dist/core/__tests__/{serial → integration}/command-plugins.test.js +94 -3
  84. package/dist/core/__tests__/{serial → integration}/deferred-no-wake.test.js +1 -1
  85. package/dist/core/__tests__/{serial → integration}/flagship-lifecycle.test.js +13 -27
  86. package/dist/core/__tests__/{serial → integration}/host-teardown-process-group.test.js +1 -1
  87. package/dist/core/__tests__/{serial → integration}/human-deliver-e2e.test.js +1 -1
  88. package/dist/core/__tests__/{live-mutation-verbs.test.js → integration/live-mutation-verbs.test.js} +22 -33
  89. package/dist/core/__tests__/{serial → integration}/live-mutation.test.js +21 -41
  90. package/dist/core/__tests__/integration/refresh-stall-recycle.test.d.ts +1 -0
  91. package/dist/core/__tests__/{serial → integration}/refresh-stall-recycle.test.js +1 -1
  92. package/dist/core/__tests__/integration/revive.test.d.ts +1 -0
  93. package/dist/core/__tests__/{serial → integration}/revive.test.js +38 -19
  94. package/dist/core/__tests__/integration/spawn-root.test.d.ts +1 -0
  95. package/dist/core/__tests__/{serial → integration}/spawn-root.test.js +104 -1
  96. package/dist/core/__tests__/integration/subscription-delivery.test.d.ts +1 -0
  97. package/dist/core/__tests__/{serial → integration}/subscription-delivery.test.js +1 -1
  98. package/dist/core/__tests__/integration/tmux-surface.test.d.ts +1 -0
  99. package/dist/core/__tests__/{serial → integration}/tmux-surface.test.js +1 -1
  100. package/dist/core/__tests__/integration/worktree-land.test.d.ts +1 -0
  101. package/dist/core/__tests__/integration/worktree-land.test.js +400 -0
  102. package/dist/core/__tests__/integration/worktree-reap.test.d.ts +1 -0
  103. package/dist/core/__tests__/{serial/worktree.test.js → integration/worktree-reap.test.js} +6 -338
  104. package/dist/core/__tests__/kickoff.test.js +16 -5
  105. package/dist/core/__tests__/memory-resolver-precedence.test.js +122 -91
  106. package/dist/core/__tests__/nested-store-discovery.test.js +5 -3
  107. package/dist/core/__tests__/on-read-crouter-home-fence.test.js +2 -1
  108. package/dist/core/__tests__/on-read-dedup-resume.test.js +39 -27
  109. package/dist/core/__tests__/on-read-nested-store.test.js +19 -13
  110. package/dist/core/__tests__/profile-project-memory-delivery.test.js +138 -44
  111. package/dist/core/__tests__/repository-association.test.d.ts +1 -0
  112. package/dist/core/__tests__/repository-association.test.js +153 -0
  113. package/dist/core/__tests__/repository-root-identity.test.d.ts +1 -0
  114. package/dist/core/__tests__/repository-root-identity.test.js +219 -0
  115. package/dist/core/__tests__/seam/memory-slash-node-relative-inventory.test.js +27 -11
  116. package/dist/core/__tests__/warm-claim-preference-snapshot.test.d.ts +1 -0
  117. package/dist/core/__tests__/warm-claim-preference-snapshot.test.js +40 -0
  118. package/dist/core/canvas/canvas.d.ts +3 -0
  119. package/dist/core/canvas/canvas.js +30 -10
  120. package/dist/core/canvas/db.js +28 -1
  121. package/dist/core/canvas/paths.d.ts +4 -6
  122. package/dist/core/canvas/paths.js +8 -6
  123. package/dist/core/canvas/render-source.js +2 -2
  124. package/dist/core/canvas/types.d.ts +9 -13
  125. package/dist/core/exclusive-lock.d.ts +2 -0
  126. package/dist/core/exclusive-lock.js +21 -0
  127. package/dist/core/git.d.ts +0 -1
  128. package/dist/core/git.js +0 -3
  129. package/dist/core/human/__tests__/integration/inbox-core.test.d.ts +1 -0
  130. package/dist/core/human/feedback-companion.js +3 -0
  131. package/dist/core/human/scan.js +8 -1
  132. package/dist/core/keybindings/catalog.d.ts +2 -2
  133. package/dist/core/keybindings/catalog.js +2 -1
  134. package/dist/core/memory/doc-link-grammar.js +2 -2
  135. package/dist/core/memory/history.d.ts +24 -0
  136. package/dist/core/memory/history.js +66 -1
  137. package/dist/core/memory/identity.d.ts +65 -0
  138. package/dist/core/memory/identity.js +185 -0
  139. package/dist/core/memory/inline-ref-guidance.d.ts +1 -1
  140. package/dist/core/memory/inline-ref-guidance.js +1 -1
  141. package/dist/core/memory/inline-ref-inventory.d.ts +3 -10
  142. package/dist/core/memory/inline-ref-inventory.js +30 -62
  143. package/dist/core/memory/lint.d.ts +129 -0
  144. package/dist/core/memory/lint.js +517 -0
  145. package/dist/core/memory/project-namespace.d.ts +31 -0
  146. package/dist/core/memory/project-namespace.js +85 -0
  147. package/dist/core/memory/repository-association.d.ts +31 -0
  148. package/dist/core/memory/repository-association.js +129 -0
  149. package/dist/core/memory/tree.d.ts +39 -0
  150. package/dist/core/memory/tree.js +93 -0
  151. package/dist/core/memory-resolver.d.ts +193 -90
  152. package/dist/core/memory-resolver.js +461 -375
  153. package/dist/core/nested-stores.js +6 -13
  154. package/dist/core/profiles/select.d.ts +4 -1
  155. package/dist/core/profiles/select.js +108 -48
  156. package/dist/core/review/__tests__/capture-origin.test.js +2 -2
  157. package/dist/core/review/__tests__/stage-identity.test.js +2 -2
  158. package/dist/core/review/companion.js +11 -2
  159. package/dist/core/runtime/bearings.d.ts +2 -2
  160. package/dist/core/runtime/bearings.js +3 -3
  161. package/dist/core/runtime/broker/daemon-ops.d.ts +2 -2
  162. package/dist/core/runtime/broker/rebind.js +5 -0
  163. package/dist/core/runtime/broker-extension-render.d.ts +7 -3
  164. package/dist/core/runtime/broker-extension-render.js +10 -5
  165. package/dist/core/runtime/broker-persona-guidance.d.ts +19 -5
  166. package/dist/core/runtime/broker-persona-guidance.js +122 -27
  167. package/dist/core/runtime/deliver-live.d.ts +16 -4
  168. package/dist/core/runtime/deliver-live.js +29 -15
  169. package/dist/core/runtime/kickoff.d.ts +3 -3
  170. package/dist/core/runtime/kickoff.js +8 -16
  171. package/dist/core/runtime/lifecycle.js +2 -3
  172. package/dist/core/runtime/nodes.d.ts +3 -4
  173. package/dist/core/runtime/nodes.js +3 -4
  174. package/dist/core/runtime/persona.d.ts +8 -12
  175. package/dist/core/runtime/persona.js +24 -96
  176. package/dist/core/runtime/promote.d.ts +2 -2
  177. package/dist/core/runtime/promote.js +8 -21
  178. package/dist/core/runtime/revive.js +20 -17
  179. package/dist/core/runtime/spawn.js +18 -7
  180. package/dist/core/runtime/tmux-bindings.js +2 -3
  181. package/dist/core/runtime/warm-pool.js +3 -4
  182. package/dist/core/scope.js +2 -0
  183. package/dist/core/self-update.d.ts +0 -2
  184. package/dist/core/self-update.js +2 -35
  185. package/dist/core/substrate/__tests__/surface-match-memory-read.test.d.ts +1 -0
  186. package/dist/core/substrate/__tests__/surface-match-memory-read.test.js +28 -0
  187. package/dist/core/substrate/index.d.ts +2 -2
  188. package/dist/core/substrate/index.js +1 -1
  189. package/dist/core/substrate/injected-store.d.ts +43 -27
  190. package/dist/core/substrate/injected-store.js +208 -104
  191. package/dist/core/substrate/listings.d.ts +19 -12
  192. package/dist/core/substrate/listings.js +75 -52
  193. package/dist/core/substrate/on-read-node.d.ts +4 -7
  194. package/dist/core/substrate/on-read-node.js +6 -8
  195. package/dist/core/substrate/on-read.d.ts +22 -25
  196. package/dist/core/substrate/on-read.js +103 -147
  197. package/dist/core/substrate/render-node.d.ts +4 -7
  198. package/dist/core/substrate/render-node.js +5 -7
  199. package/dist/core/substrate/render.d.ts +21 -3
  200. package/dist/core/substrate/render.js +291 -223
  201. package/dist/core/substrate/schema.d.ts +1 -13
  202. package/dist/core/substrate/schema.js +5 -40
  203. package/dist/core/substrate/session-cache.d.ts +14 -4
  204. package/dist/core/substrate/session-cache.js +40 -22
  205. package/dist/core/substrate/surface-match.d.ts +9 -7
  206. package/dist/core/substrate/surface-match.js +26 -25
  207. package/dist/daemon/__tests__/helpers/source-daemon.d.ts +30 -0
  208. package/dist/daemon/__tests__/helpers/source-daemon.js +174 -0
  209. package/dist/daemon/__tests__/integration/migration-startup.test.d.ts +1 -0
  210. package/dist/daemon/__tests__/integration/migration-startup.test.js +97 -0
  211. package/dist/daemon/api/__tests__/bridge-heartbeat.test.js +37 -9
  212. package/dist/daemon/api/bridge.d.ts +35 -4
  213. package/dist/daemon/api/bridge.js +50 -8
  214. package/dist/daemon/api/handlers/broker-ops.js +21 -16
  215. package/dist/daemon/api/handlers/memory.js +2 -0
  216. package/dist/daemon/crtrd.js +2 -0
  217. package/dist/daemon/human/finish.js +8 -1
  218. package/dist/daemon/manage.d.ts +0 -1
  219. package/dist/daemon/manage.js +7 -16
  220. package/dist/daemon/startup-policy.d.ts +1 -0
  221. package/dist/daemon/startup-policy.js +1 -0
  222. package/dist/migrations/001-surfaces-frontmatter.js +21 -109
  223. package/dist/migrations/002-profile-project-memory.js +1 -0
  224. package/dist/migrations/003-repository-root-memory-identity/front-door.d.ts +26 -0
  225. package/dist/migrations/003-repository-root-memory-identity/front-door.js +231 -0
  226. package/dist/migrations/003-repository-root-memory-identity/index.d.ts +2 -0
  227. package/dist/migrations/003-repository-root-memory-identity/index.js +513 -0
  228. package/dist/migrations/003-repository-root-memory-identity/references.d.ts +95 -0
  229. package/dist/migrations/003-repository-root-memory-identity/references.js +469 -0
  230. package/dist/migrations/003-repository-root-memory-identity/repository-facts.d.ts +39 -0
  231. package/dist/migrations/003-repository-root-memory-identity/repository-facts.js +349 -0
  232. package/dist/migrations/__tests__/activation-concurrency.test.d.ts +1 -0
  233. package/dist/migrations/__tests__/activation-concurrency.test.js +145 -0
  234. package/dist/migrations/__tests__/activation.test.d.ts +1 -0
  235. package/dist/migrations/__tests__/activation.test.js +149 -0
  236. package/dist/migrations/__tests__/deletion-and-root-declaration.test.d.ts +1 -0
  237. package/dist/migrations/__tests__/deletion-and-root-declaration.test.js +149 -0
  238. package/dist/migrations/activation.d.ts +16 -0
  239. package/dist/migrations/activation.js +78 -0
  240. package/dist/migrations/convergent.d.ts +14 -3
  241. package/dist/migrations/convergent.js +21 -10
  242. package/dist/migrations/corpus.d.ts +78 -0
  243. package/dist/migrations/corpus.js +497 -0
  244. package/dist/migrations/frontmatter-splice.d.ts +15 -0
  245. package/dist/migrations/frontmatter-splice.js +176 -0
  246. package/dist/migrations/registry.d.ts +6 -1
  247. package/dist/migrations/registry.js +7 -2
  248. package/dist/migrations/runner.d.ts +41 -0
  249. package/dist/migrations/runner.js +81 -0
  250. package/dist/migrations/types.d.ts +148 -9
  251. package/dist/migrations/types.js +9 -2
  252. package/dist/pi-extensions/__tests__/canvas-context-intro.test.js +225 -17
  253. package/dist/pi-extensions/__tests__/canvas-goal-capture-envelope.test.js +11 -3
  254. package/dist/pi-extensions/canvas-context-intro.d.ts +3 -5
  255. package/dist/pi-extensions/canvas-context-intro.js +50 -46
  256. package/dist/pi-extensions/canvas-doc-substrate.d.ts +1 -8
  257. package/dist/pi-extensions/canvas-doc-substrate.js +55 -122
  258. package/dist/pi-extensions/canvas-stophook.js +6 -13
  259. package/dist/shared/generated-context.d.ts +0 -3
  260. package/dist/shared/generated-context.js +0 -57
  261. package/dist/shared/tool-groups.js +2 -3
  262. package/package.json +5 -4
  263. package/runtime.lock.json +2 -2
  264. /package/dist/api/__tests__/{serial → integration}/client.test.d.ts +0 -0
  265. /package/dist/api/__tests__/{serial → integration}/client.test.js +0 -0
  266. /package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/{serial → integration}/provider-rotation.test.ts +0 -0
  267. /package/dist/clients/{inbox/__tests__/serial/inbox-controller.test.d.ts → attach/__tests__/pane-tag-successor.test.d.ts} +0 -0
  268. /package/dist/clients/{inbox/__tests__/serial/mount-panel.test.d.ts → attach/__tests__/profile-files.test.d.ts} +0 -0
  269. /package/dist/{core/__tests__/live-mutation-verbs.test.d.ts → clients/inbox/__tests__/integration/inbox-controller.test.d.ts} +0 -0
  270. /package/dist/clients/inbox/__tests__/{serial → integration}/inbox-controller.test.js +0 -0
  271. /package/dist/{core/__tests__/serial/broker-fork-seam.test.d.ts → clients/inbox/__tests__/integration/mount-panel.test.d.ts} +0 -0
  272. /package/dist/clients/inbox/__tests__/{serial → integration}/mount-panel.test.js +0 -0
  273. /package/dist/{core/__tests__/serial/broker-sdk-wiring.test.d.ts → commands/__tests__/surface-reload-target.test.d.ts} +0 -0
  274. /package/dist/{core/__tests__/serial/broker-snapshot-history.test.d.ts → commands/memory/__tests__/command-selector-and-mutation-guards.test.d.ts} +0 -0
  275. /package/dist/{core/__tests__/serial/command-plugins.test.d.ts → commands/memory/__tests__/repository-root-lint.test.d.ts} +0 -0
  276. /package/dist/{core/__tests__/serial/deferred-no-wake.test.d.ts → commands/sys/__tests__/sync-project-guidance.test.d.ts} +0 -0
  277. /package/dist/core/__tests__/{serial/flagship-lifecycle.test.d.ts → config-change-delta.test.d.ts} +0 -0
  278. /package/dist/core/__tests__/{serial/host-teardown-process-group.test.d.ts → integration/broker-fork-seam.test.d.ts} +0 -0
  279. /package/dist/core/__tests__/{serial/human-deliver-e2e.test.d.ts → integration/broker-sdk-wiring.test.d.ts} +0 -0
  280. /package/dist/core/__tests__/{serial/live-mutation.test.d.ts → integration/broker-snapshot-history.test.d.ts} +0 -0
  281. /package/dist/core/__tests__/{serial/refresh-stall-recycle.test.d.ts → integration/command-plugins.test.d.ts} +0 -0
  282. /package/dist/core/__tests__/{serial/revive.test.d.ts → integration/deferred-no-wake.test.d.ts} +0 -0
  283. /package/dist/core/__tests__/{serial/spawn-root.test.d.ts → integration/flagship-lifecycle.test.d.ts} +0 -0
  284. /package/dist/core/__tests__/{serial/subscription-delivery.test.d.ts → integration/host-teardown-process-group.test.d.ts} +0 -0
  285. /package/dist/core/__tests__/{serial/tmux-surface.test.d.ts → integration/human-deliver-e2e.test.d.ts} +0 -0
  286. /package/dist/core/__tests__/{serial/worktree.test.d.ts → integration/live-mutation-verbs.test.d.ts} +0 -0
  287. /package/dist/core/{human/__tests__/serial/inbox-core.test.d.ts → __tests__/integration/live-mutation.test.d.ts} +0 -0
  288. /package/dist/core/human/__tests__/{serial → integration}/inbox-core.test.js +0 -0
@@ -3,7 +3,7 @@
3
3
  // only — no subscription wires back to the spawner.
4
4
  import { test, before, after, beforeEach } from 'node:test';
5
5
  import assert from 'node:assert/strict';
6
- import { mkdtempSync, rmSync, existsSync } from 'node:fs';
6
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
7
7
  import { tmpdir } from 'node:os';
8
8
  import { join } from 'node:path';
9
9
  import { createServer } from 'node:net';
@@ -17,6 +17,8 @@ import { readGoal } from '../../runtime/kickoff.js';
17
17
  import { spawnChild, resolveSpawner } from '../../runtime/spawn.js';
18
18
  import { headlessBrokerHost } from '../../runtime/host.js';
19
19
  import { recordFault } from '../../runtime/fault.js';
20
+ import { piSessionsRoot } from '../../runtime/pi-vendored.js';
21
+ import { emptyContextExposureState, exposureTarget, loadContextExposureState, registerExposure, saveContextExposureState, } from '../../substrate/injected-store.js';
20
22
  import { ROOT_PROFILE_ID } from '../../profiles/manifest.js';
21
23
  let home;
22
24
  function spawner(id) {
@@ -113,6 +115,107 @@ test('spawnChild crashes the node when launch/preflight throws', async () => {
113
115
  rmSync(home, { recursive: true, force: true });
114
116
  }
115
117
  });
118
+ test('fork spawn resolves path and UUID provenance to the same transcript-only exposure seed', async () => {
119
+ const previousHome = process.env['HOME'];
120
+ const previousPiDir = process.env['PI_CODING_AGENT_DIR'];
121
+ const previousNodeId = process.env['CRTR_NODE_ID'];
122
+ const previousTmux = process.env['TMUX'];
123
+ const previousTmuxPane = process.env['TMUX_PANE'];
124
+ const localHome = mkdtempSync(join(tmpdir(), 'crtr-fork-seed-home-'));
125
+ const localCwd = mkdtempSync(join(tmpdir(), 'crtr-fork-seed-cwd-'));
126
+ const originalLaunch = headlessBrokerHost.launch;
127
+ const launched = [];
128
+ try {
129
+ process.env['HOME'] = localHome;
130
+ process.env['PI_CODING_AGENT_DIR'] = join(localHome, 'pi-agent');
131
+ process.env['CRTR_NODE_ID'] = 'fork-source';
132
+ delete process.env['TMUX'];
133
+ delete process.env['TMUX_PANE'];
134
+ const sessionId = '12345678-1234-4234-8234-123456789abc';
135
+ const sourceFile = join(piSessionsRoot(), 'source-project', `2026-08-19T00-00-00-000Z_${sessionId}.jsonl`);
136
+ mkdirSync(join(piSessionsRoot(), 'source-project'), { recursive: true });
137
+ writeFileSync(sourceFile, `${JSON.stringify({
138
+ type: 'session',
139
+ version: 3,
140
+ id: sessionId,
141
+ timestamp: new Date().toISOString(),
142
+ cwd: localCwd,
143
+ })}\n`);
144
+ createNode({
145
+ ...spawner('fork-source'),
146
+ cwd: localCwd,
147
+ pi_session_id: sessionId,
148
+ pi_session_file: sourceFile,
149
+ });
150
+ const canonicalSourceFile = getNode('fork-source').pi_session_file;
151
+ const sourceExposure = emptyContextExposureState();
152
+ registerExposure(exposureTarget(sourceExposure, 'system'), 'source-system', 'content');
153
+ registerExposure(exposureTarget(sourceExposure, 'transcript'), 'source-transcript', 'preview');
154
+ saveContextExposureState('fork-source', sourceExposure);
155
+ headlessBrokerHost.launch = (nodeId, inv) => {
156
+ launched.push({ nodeId, argv: inv.argv });
157
+ return { pid: process.pid, exited: new Promise(() => { }) };
158
+ };
159
+ const byPath = await spawnChild({
160
+ kind: 'developer',
161
+ cwd: localCwd,
162
+ root: true,
163
+ readiness: 'launch',
164
+ nodeId: 'fork-by-path',
165
+ prompt: 'path fork',
166
+ forkFrom: sourceFile,
167
+ });
168
+ const byUuid = await spawnChild({
169
+ kind: 'developer',
170
+ cwd: localCwd,
171
+ root: true,
172
+ readiness: 'launch',
173
+ nodeId: 'fork-by-uuid',
174
+ prompt: 'uuid fork',
175
+ forkFrom: sessionId,
176
+ });
177
+ for (const [result, rawOperand] of [[byPath, sourceFile], [byUuid, sessionId]]) {
178
+ const meta = getNode(result.node.node_id);
179
+ assert.equal(meta.fork_from, rawOperand);
180
+ assert.equal(meta.fork_source_file, canonicalSourceFile);
181
+ const exposure = loadContextExposureState(meta.node_id);
182
+ assert.deepEqual(exposure.exposures.get('source-transcript'), { transcript: 2 });
183
+ assert.equal(exposure.exposures.has('source-system'), false);
184
+ assert.equal(exposure.preferenceSnapshot, null);
185
+ }
186
+ assert.equal(launched.length, 2);
187
+ for (const launch of launched) {
188
+ const forkIndex = launch.argv.indexOf('--fork');
189
+ assert.ok(forkIndex >= 0, `${launch.nodeId} launches through Pi's fork seam`);
190
+ assert.equal(launch.argv[forkIndex + 1], canonicalSourceFile);
191
+ }
192
+ }
193
+ finally {
194
+ headlessBrokerHost.launch = originalLaunch;
195
+ if (previousHome === undefined)
196
+ delete process.env['HOME'];
197
+ else
198
+ process.env['HOME'] = previousHome;
199
+ if (previousPiDir === undefined)
200
+ delete process.env['PI_CODING_AGENT_DIR'];
201
+ else
202
+ process.env['PI_CODING_AGENT_DIR'] = previousPiDir;
203
+ if (previousNodeId === undefined)
204
+ delete process.env['CRTR_NODE_ID'];
205
+ else
206
+ process.env['CRTR_NODE_ID'] = previousNodeId;
207
+ if (previousTmux === undefined)
208
+ delete process.env['TMUX'];
209
+ else
210
+ process.env['TMUX'] = previousTmux;
211
+ if (previousTmuxPane === undefined)
212
+ delete process.env['TMUX_PANE'];
213
+ else
214
+ process.env['TMUX_PANE'] = previousTmuxPane;
215
+ rmSync(localHome, { recursive: true, force: true });
216
+ rmSync(localCwd, { recursive: true, force: true });
217
+ }
218
+ });
116
219
  test('spawnChild: a root from a shell gets the stable root profile id', async () => {
117
220
  const previousHome = process.env.HOME;
118
221
  const previousNodeId = process.env.CRTR_NODE_ID;
@@ -1,4 +1,4 @@
1
- // Run with: npm run test:serial
1
+ // Run with: npm run test:integration
2
2
  //
3
3
  // THE DAEMON WAKE HALF of delivery-vs-wake, on a graph >=2 levels deep. Routing
4
4
  // itself (active → inbox.jsonl, passive → passive.jsonl) is owned by
@@ -1,4 +1,4 @@
1
- // Run with: npm run test:serial
1
+ // Run with: npm run test:integration
2
2
  //
3
3
  // STEP 2 of the placement/focus migration: guards on the tmux Surface (driver).
4
4
  //
@@ -0,0 +1,400 @@
1
+ // Managed worktrees: create, and the land/close half — base-ref recording,
2
+ // rebase-onto-captured-SHA, the dirty gate, and what close is allowed to touch.
3
+ // Split from its reap half (worktree-reap.test.ts) only so the two run as two
4
+ // parallel files; they share the fixture shape, not state.
5
+ import { test, before, after, beforeEach } from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import { mkdtempSync, mkdirSync, rmSync, existsSync, statSync, writeFileSync, chmodSync } from 'node:fs';
8
+ import { spawn } from 'node:child_process';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import { gitSync } from '../../git.js';
12
+ import { createManagedWorktree, closeManagedWorktree, managedWorktreePath, rollbackManagedWorktree } from '../../worktree.js';
13
+ import { createNode, getNode } from '../../canvas/canvas.js';
14
+ import { closeDb } from '../../canvas/db.js';
15
+ let home;
16
+ let remoteDir;
17
+ let sourceDir;
18
+ function run(args, cwd) {
19
+ const res = gitSync(args, cwd);
20
+ if (res.status !== 0) {
21
+ throw new Error(`git ${args.join(' ')} failed in ${cwd}: ${res.stderr || res.stdout}`);
22
+ }
23
+ }
24
+ function node(id, over = {}) {
25
+ return {
26
+ node_id: id,
27
+ name: id,
28
+ created: new Date().toISOString(),
29
+ cwd: '/tmp/work',
30
+ kind: 'general',
31
+ mode: 'base',
32
+ lifecycle: 'terminal',
33
+ status: 'active',
34
+ ...over,
35
+ };
36
+ }
37
+ before(() => {
38
+ home = mkdtempSync(join(tmpdir(), 'crtr-worktree-home-'));
39
+ process.env['CRTR_HOME'] = home;
40
+ // Bare "remote" wired up as origin so the test can prove landing NEVER pushes
41
+ // to it (managed worktrees are based on and land onto local `main` only).
42
+ remoteDir = mkdtempSync(join(tmpdir(), 'crtr-worktree-remote-'));
43
+ run(['init', '--bare'], remoteDir);
44
+ sourceDir = mkdtempSync(join(tmpdir(), 'crtr-worktree-source-'));
45
+ run(['init', '-b', 'main'], sourceDir);
46
+ run(['config', 'user.email', 'test@example.com'], sourceDir);
47
+ run(['config', 'user.name', 'Test'], sourceDir);
48
+ writeFileSync(join(sourceDir, 'README.md'), 'hello\n');
49
+ run(['add', '.'], sourceDir);
50
+ run(['commit', '-m', 'init'], sourceDir);
51
+ run(['remote', 'add', 'origin', remoteDir], sourceDir);
52
+ run(['push', '-u', 'origin', 'main'], sourceDir);
53
+ });
54
+ beforeEach(() => {
55
+ closeDb();
56
+ rmSync(home, { recursive: true, force: true });
57
+ process.env['CRTR_HOME'] = home;
58
+ });
59
+ after(() => {
60
+ closeDb();
61
+ rmSync(home, { recursive: true, force: true });
62
+ rmSync(remoteDir, { recursive: true, force: true });
63
+ rmSync(sourceDir, { recursive: true, force: true });
64
+ delete process.env['CRTR_HOME'];
65
+ });
66
+ // Regression for #710: close lands from a live caller, so it must preserve the
67
+ // caller checkout rather than starting Git's recursive removal beneath it.
68
+ test('closeManagedWorktree leaves the active caller checkout intact with named deferred cleanup', () => {
69
+ const nodeId = 'wt-close-1';
70
+ const wt = createManagedWorktree(sourceDir, nodeId);
71
+ assert.equal(wt.base_ref, 'main', 'default base must remain backward-compatible with persisted main worktrees');
72
+ assert.equal(wt.path, managedWorktreePath(nodeId));
73
+ assert.ok(existsSync(wt.path), 'worktree checkout should exist after create');
74
+ createNode(node(nodeId, { cwd: wt.path, managed_worktree: wt }));
75
+ const result = closeManagedWorktree(nodeId);
76
+ assert.equal(result.worktree_removed, false);
77
+ assert.match(result.worktree_remove_error ?? '', /cleanup pending/);
78
+ assert.ok(existsSync(join(wt.path, 'README.md')), 'the caller checkout keeps its tracked files');
79
+ assert.ok(statSync(wt.path).isDirectory(), 'the caller cwd remains a directory');
80
+ const list = gitSync(['worktree', 'list', '--porcelain'], sourceDir);
81
+ assert.ok(list.stdout.includes(wt.path), 'Git keeps the intact caller checkout registered');
82
+ const meta = getNode(nodeId);
83
+ assert.equal(meta?.managed_worktree?.state, 'closed');
84
+ assert.equal(meta?.managed_worktree?.cleanup, 'pending');
85
+ assert.equal(meta?.cwd, wt.repo_root, 'a later revive uses the durable base checkout');
86
+ });
87
+ test('two concurrent creates from linked worktrees serialize on their shared repository', async () => {
88
+ const worktreeModule = new URL('../../worktree.ts', import.meta.url).href;
89
+ const createFrom = (cwd, nodeId) => new Promise((resolve, reject) => {
90
+ const script = `import { createManagedWorktree } from ${JSON.stringify(worktreeModule)}; createManagedWorktree(${JSON.stringify(cwd)}, ${JSON.stringify(nodeId)});`;
91
+ const child = spawn(process.execPath, ['--import', 'tsx/esm', '--input-type=module', '--eval', script], { env: { ...process.env, CRTR_HOME: home }, stdio: 'pipe' });
92
+ let stderr = '';
93
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
94
+ child.on('error', reject);
95
+ child.on('exit', (code) => code === 0 ? resolve() : reject(new Error(`concurrent create exited ${code}: ${stderr}`)));
96
+ });
97
+ const linkedCheckout = mkdtempSync(join(tmpdir(), 'crtr-worktree-linked-'));
98
+ rmSync(linkedCheckout, { recursive: true, force: true });
99
+ run(['worktree', 'add', '-b', 'wt-concurrent-source', linkedCheckout], sourceDir);
100
+ try {
101
+ await Promise.all([createFrom(sourceDir, 'wt-concurrent-1'), createFrom(linkedCheckout, 'wt-concurrent-2')]);
102
+ for (const nodeId of ['wt-concurrent-1', 'wt-concurrent-2']) {
103
+ const path = managedWorktreePath(nodeId);
104
+ assert.ok(existsSync(path), `${nodeId} checkout should exist`);
105
+ assert.equal(gitSync(['rev-parse', '--abbrev-ref', 'HEAD'], path).stdout.trim(), `crtr/${nodeId}`);
106
+ rollbackManagedWorktree({
107
+ state: 'open', path, branch: `crtr/${nodeId}`, repo_root: sourceDir, base_ref: 'main',
108
+ base_sha: gitSync(['rev-parse', 'main'], sourceDir).stdout.trim(), created: new Date().toISOString(),
109
+ });
110
+ }
111
+ }
112
+ finally {
113
+ run(['worktree', 'remove', '--force', linkedCheckout], sourceDir);
114
+ run(['branch', '-D', 'wt-concurrent-source'], sourceDir);
115
+ }
116
+ });
117
+ test('failure after branch creation removes only the failed attempt resources', () => {
118
+ // A file at the managed-worktree root makes `git worktree add` fail only
119
+ // after this attempt created its branch. No checkout can be registered.
120
+ mkdirSync(home, { recursive: true });
121
+ writeFileSync(join(home, 'worktrees'), 'blocked');
122
+ const nodeId = 'wt-failed-checkout';
123
+ assert.throws(() => createManagedWorktree(sourceDir, nodeId));
124
+ assert.equal(gitSync(['show-ref', '--verify', '--quiet', `refs/heads/crtr/${nodeId}`], sourceDir).status, 1, 'failed attempt branch must be removed');
125
+ assert.ok(!gitSync(['worktree', 'list', '--porcelain'], sourceDir).stdout.includes(`crtr/${nodeId}`), 'failed attempt must not leave a worktree registration');
126
+ assert.ok(!existsSync(managedWorktreePath(nodeId)), 'failed attempt must not leave a checkout directory');
127
+ });
128
+ // Landing is purely local: a commit made in the managed worktree must advance
129
+ // the source repo's LOCAL main to that commit, and origin/main must stay put
130
+ // (close never pushes to origin).
131
+ test('closeManagedWorktree fast-forwards local main to the worktree tip without touching origin', () => {
132
+ const nodeId = 'wt-close-2';
133
+ const originMainBefore = gitSync(['rev-parse', 'origin/main'], sourceDir).stdout.trim();
134
+ const wt = createManagedWorktree(sourceDir, nodeId);
135
+ createNode(node(nodeId, { cwd: wt.path, managed_worktree: wt }));
136
+ // Commit real work inside the managed worktree.
137
+ writeFileSync(join(wt.path, 'feature.txt'), 'work\n');
138
+ run(['add', '.'], wt.path);
139
+ run(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'feature'], wt.path);
140
+ const workSha = gitSync(['rev-parse', 'HEAD'], wt.path).stdout.trim();
141
+ const result = closeManagedWorktree(nodeId);
142
+ assert.equal(result.landed_sha, workSha, 'landed_sha should be the committed work');
143
+ // Local main in the source (primary) checkout advanced to the work.
144
+ const localMainAfter = gitSync(['rev-parse', 'main'], sourceDir).stdout.trim();
145
+ assert.equal(localMainAfter, workSha, 'local main must fast-forward to the worktree tip');
146
+ // origin/main is untouched — nothing was pushed.
147
+ const originMainAfter = gitSync(['rev-parse', 'origin/main'], sourceDir).stdout.trim();
148
+ assert.equal(originMainAfter, originMainBefore, 'origin/main must not move — landing is local-only');
149
+ });
150
+ test('custom local base is recorded and fast-forwarded without touching main or origin', () => {
151
+ const nodeId = 'wt-custom-base';
152
+ run(['branch', 'feature/custom-base'], sourceDir);
153
+ const baseSha = gitSync(['rev-parse', 'feature/custom-base'], sourceDir).stdout.trim();
154
+ const mainBefore = gitSync(['rev-parse', 'main'], sourceDir).stdout.trim();
155
+ const originMainBefore = gitSync(['rev-parse', 'origin/main'], sourceDir).stdout.trim();
156
+ const wt = createManagedWorktree(sourceDir, nodeId, 'feature/custom-base');
157
+ assert.equal(wt.base_ref, 'feature/custom-base');
158
+ assert.equal(wt.base_sha, baseSha);
159
+ assert.equal(gitSync(['rev-parse', 'HEAD'], wt.path).stdout.trim(), baseSha, 'dedicated branch starts at the custom base');
160
+ assert.equal(gitSync(['rev-parse', '--abbrev-ref', 'HEAD'], wt.path).stdout.trim(), `crtr/${nodeId}`, 'the worktree checks out its own dedicated crtr/<node-id> branch, not the base branch');
161
+ createNode(node(nodeId, { cwd: wt.path, managed_worktree: wt }));
162
+ writeFileSync(join(wt.path, 'custom-base.txt'), 'work\n');
163
+ run(['add', '.'], wt.path);
164
+ run(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'custom base work'], wt.path);
165
+ // The base can advance while the child works. Close must rebase onto that
166
+ // new tip before atomically advancing the base to the child work.
167
+ run(['checkout', 'feature/custom-base'], sourceDir);
168
+ writeFileSync(join(sourceDir, 'base-advance.txt'), 'base work\n');
169
+ run(['add', '.'], sourceDir);
170
+ run(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'base advance'], sourceDir);
171
+ const advancedBaseSha = gitSync(['rev-parse', 'HEAD'], sourceDir).stdout.trim();
172
+ run(['checkout', 'main'], sourceDir);
173
+ const originalWorkSha = gitSync(['rev-parse', 'HEAD'], wt.path).stdout.trim();
174
+ const result = closeManagedWorktree(nodeId);
175
+ assert.equal(gitSync(['merge-base', '--is-ancestor', originalWorkSha, result.landed_sha], sourceDir).status, 1, 'rebasing onto an advanced base must rewrite the child commit');
176
+ assert.equal(gitSync(['merge-base', '--is-ancestor', advancedBaseSha, result.landed_sha], sourceDir).status, 0, 'landed work must be rebased onto the latest base tip');
177
+ assert.equal(gitSync(['rev-parse', 'feature/custom-base'], sourceDir).stdout.trim(), result.landed_sha, 'custom base must fast-forward to the worktree tip');
178
+ assert.equal(gitSync(['rev-parse', 'main'], sourceDir).stdout.trim(), mainBefore, 'main must remain untouched');
179
+ assert.equal(gitSync(['rev-parse', 'origin/main'], sourceDir).stdout.trim(), originMainBefore, 'origin/main must remain untouched');
180
+ });
181
+ test('refuses a custom-base close when its unchecked base advances after rebase', () => {
182
+ const nodeId = 'wt-custom-base-cas-race';
183
+ const base = 'feature/cas-race-base';
184
+ run(['branch', base], sourceDir);
185
+ const baseSha = gitSync(['rev-parse', base], sourceDir).stdout.trim();
186
+ const wt = createManagedWorktree(sourceDir, nodeId, base);
187
+ createNode(node(nodeId, { cwd: wt.path, managed_worktree: wt }));
188
+ writeFileSync(join(wt.path, 'cas-child.txt'), 'child work\n');
189
+ run(['add', '.'], wt.path);
190
+ run(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'cas child work'], wt.path);
191
+ // Pre-create the other writer's commit, then reset the base so the rebase
192
+ // begins from baseSha. A test-only git wrapper advances it when close calls
193
+ // its final update-ref CAS, immediately before the real git receives that
194
+ // CAS request.
195
+ run(['checkout', base], sourceDir);
196
+ writeFileSync(join(sourceDir, 'cas-base.txt'), 'base work\n');
197
+ run(['add', '.'], sourceDir);
198
+ run(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'cas base work'], sourceDir);
199
+ const advancedBaseSha = gitSync(['rev-parse', 'HEAD'], sourceDir).stdout.trim();
200
+ run(['checkout', 'main'], sourceDir);
201
+ run(['update-ref', `refs/heads/${base}`, baseSha], sourceDir);
202
+ const wrapperDir = mkdtempSync(join(tmpdir(), 'crtr-git-wrapper-'));
203
+ const wrapperPath = join(wrapperDir, 'git');
204
+ const originalPath = process.env['PATH'] ?? '';
205
+ writeFileSync(wrapperPath, `#!/bin/sh
206
+ if [ "$1" = "update-ref" ] && [ "$2" = "refs/heads/${base}" ] && [ "$4" = "${baseSha}" ]; then
207
+ PATH="$CRTR_TEST_REAL_PATH" git -C "${sourceDir}" update-ref "refs/heads/${base}" "${advancedBaseSha}"
208
+ fi
209
+ PATH="$CRTR_TEST_REAL_PATH" exec git "$@"
210
+ `);
211
+ chmodSync(wrapperPath, 0o755);
212
+ process.env['CRTR_TEST_REAL_PATH'] = originalPath;
213
+ process.env['PATH'] = `${wrapperDir}:${originalPath}`;
214
+ try {
215
+ assert.throws(() => closeManagedWorktree(nodeId), (error) => error instanceof Error && 'code' in error && error.code === 'land_base_failed');
216
+ }
217
+ finally {
218
+ process.env['PATH'] = originalPath;
219
+ delete process.env['CRTR_TEST_REAL_PATH'];
220
+ rmSync(wrapperDir, { recursive: true, force: true });
221
+ }
222
+ assert.equal(gitSync(['rev-parse', base], sourceDir).stdout.trim(), advancedBaseSha, 'CAS failure must preserve the concurrent base advance');
223
+ assert.equal(getNode(nodeId)?.managed_worktree?.state, 'open', 'failed landing must leave the worktree open for retry or human resolution');
224
+ });
225
+ test('rebases onto the captured SHA when an unchecked base advances before rebase starts', () => {
226
+ const nodeId = 'wt-custom-base-rebase-race';
227
+ const base = 'feature/rebase-race-base';
228
+ run(['branch', base], sourceDir);
229
+ const baseSha = gitSync(['rev-parse', base], sourceDir).stdout.trim();
230
+ const wt = createManagedWorktree(sourceDir, nodeId, base);
231
+ createNode(node(nodeId, { cwd: wt.path, managed_worktree: wt }));
232
+ writeFileSync(join(wt.path, 'rebase-race-child.txt'), 'child work\n');
233
+ run(['add', '.'], wt.path);
234
+ run(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'rebase race child work'], wt.path);
235
+ // Prepare another writer's commit, then move the unchecked base back to the
236
+ // SHA close will capture. The wrapper advances the base immediately before
237
+ // the real rebase starts. Close must still rebase onto its captured SHA, not
238
+ // resolve the now-advanced symbolic branch, and the stale landing CAS fails.
239
+ run(['checkout', base], sourceDir);
240
+ writeFileSync(join(sourceDir, 'rebase-race-base.txt'), 'base work\n');
241
+ run(['add', '.'], sourceDir);
242
+ run(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'rebase race base work'], sourceDir);
243
+ const advancedBaseSha = gitSync(['rev-parse', 'HEAD'], sourceDir).stdout.trim();
244
+ run(['checkout', 'main'], sourceDir);
245
+ run(['update-ref', `refs/heads/${base}`, baseSha], sourceDir);
246
+ const wrapperDir = mkdtempSync(join(tmpdir(), 'crtr-git-wrapper-'));
247
+ const wrapperPath = join(wrapperDir, 'git');
248
+ const originalPath = process.env['PATH'] ?? '';
249
+ writeFileSync(wrapperPath, `#!/bin/sh
250
+ if [ "$1" = "rebase" ] && [ "$2" = "${baseSha}" ]; then
251
+ PATH="$CRTR_TEST_REAL_PATH" git -C "${sourceDir}" update-ref "refs/heads/${base}" "${advancedBaseSha}"
252
+ fi
253
+ PATH="$CRTR_TEST_REAL_PATH" exec git "$@"
254
+ `);
255
+ chmodSync(wrapperPath, 0o755);
256
+ process.env['CRTR_TEST_REAL_PATH'] = originalPath;
257
+ process.env['PATH'] = `${wrapperDir}:${originalPath}`;
258
+ try {
259
+ assert.throws(() => closeManagedWorktree(nodeId), (error) => error instanceof Error && 'code' in error && error.code === 'land_base_failed');
260
+ }
261
+ finally {
262
+ process.env['PATH'] = originalPath;
263
+ delete process.env['CRTR_TEST_REAL_PATH'];
264
+ rmSync(wrapperDir, { recursive: true, force: true });
265
+ }
266
+ const rebasedChildSha = gitSync(['rev-parse', 'HEAD'], wt.path).stdout.trim();
267
+ assert.equal(gitSync(['rev-parse', base], sourceDir).stdout.trim(), advancedBaseSha, 'CAS failure must preserve the advance that raced with rebase');
268
+ assert.equal(gitSync(['merge-base', '--is-ancestor', advancedBaseSha, rebasedChildSha], sourceDir).status, 1, 'the child must not rebase onto a symbolic base advance after baseSha was captured');
269
+ assert.equal(getNode(nodeId)?.managed_worktree?.state, 'open', 'failed landing must leave the worktree open for retry or human resolution');
270
+ });
271
+ test('refuses to land when a checked-out base is reset after its SHA is captured', () => {
272
+ const nodeId = 'wt-custom-base-checked-out-reset-race';
273
+ const base = 'feature/checked-out-reset-race-base';
274
+ run(['checkout', '-b', base], sourceDir);
275
+ const resetSha = gitSync(['rev-parse', 'HEAD'], sourceDir).stdout.trim();
276
+ writeFileSync(join(sourceDir, 'checked-out-reset-base.txt'), 'base work\n');
277
+ run(['add', '.'], sourceDir);
278
+ run(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'checked out reset base work'], sourceDir);
279
+ const baseSha = gitSync(['rev-parse', 'HEAD'], sourceDir).stdout.trim();
280
+ const wt = createManagedWorktree(sourceDir, nodeId, base);
281
+ createNode(node(nodeId, { cwd: wt.path, managed_worktree: wt }));
282
+ writeFileSync(join(wt.path, 'checked-out-reset-child.txt'), 'child work\n');
283
+ run(['add', '.'], wt.path);
284
+ run(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'checked out reset child work'], wt.path);
285
+ // Reset the checked-out base backward immediately before the child rebases.
286
+ // The real rebase still targets captured baseSha, so only the explicit HEAD
287
+ // equality check can prevent an ff-only merge from restoring discarded work.
288
+ const wrapperDir = mkdtempSync(join(tmpdir(), 'crtr-git-wrapper-'));
289
+ const wrapperPath = join(wrapperDir, 'git');
290
+ const originalPath = process.env['PATH'] ?? '';
291
+ writeFileSync(wrapperPath, `#!/bin/sh
292
+ if [ "$1" = "rebase" ] && [ "$2" = "${baseSha}" ]; then
293
+ PATH="$CRTR_TEST_REAL_PATH" git -C "${sourceDir}" reset --hard "${resetSha}"
294
+ fi
295
+ PATH="$CRTR_TEST_REAL_PATH" exec git "$@"
296
+ `);
297
+ chmodSync(wrapperPath, 0o755);
298
+ process.env['CRTR_TEST_REAL_PATH'] = originalPath;
299
+ process.env['PATH'] = `${wrapperDir}:${originalPath}`;
300
+ try {
301
+ assert.throws(() => closeManagedWorktree(nodeId), (error) => error instanceof Error && 'code' in error && error.code === 'land_base_failed');
302
+ }
303
+ finally {
304
+ process.env['PATH'] = originalPath;
305
+ delete process.env['CRTR_TEST_REAL_PATH'];
306
+ rmSync(wrapperDir, { recursive: true, force: true });
307
+ }
308
+ assert.equal(gitSync(['rev-parse', 'HEAD'], sourceDir).stdout.trim(), resetSha, 'failed landing must leave the checked-out base at the concurrent reset SHA');
309
+ assert.equal(gitSync(['rev-parse', base], sourceDir).stdout.trim(), resetSha, 'failed landing must not restore discarded base commits');
310
+ assert.equal(getNode(nodeId)?.managed_worktree?.state, 'open', 'failed landing must leave the worktree open for retry or human resolution');
311
+ run(['checkout', 'main'], sourceDir);
312
+ });
313
+ test('close fast-forwards a custom base in its checked-out worktree', () => {
314
+ const nodeId = 'wt-custom-base-checked-out';
315
+ run(['checkout', '-b', 'feature/checked-out-base'], sourceDir);
316
+ const wt = createManagedWorktree(sourceDir, nodeId, 'feature/checked-out-base');
317
+ createNode(node(nodeId, { cwd: wt.path, managed_worktree: wt }));
318
+ writeFileSync(join(wt.path, 'checked-out-base.txt'), 'work\n');
319
+ run(['add', '.'], wt.path);
320
+ run(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'checked out base work'], wt.path);
321
+ const workSha = gitSync(['rev-parse', 'HEAD'], wt.path).stdout.trim();
322
+ closeManagedWorktree(nodeId);
323
+ assert.equal(gitSync(['rev-parse', 'feature/checked-out-base'], sourceDir).stdout.trim(), workSha);
324
+ assert.equal(gitSync(['rev-parse', 'HEAD'], sourceDir).stdout.trim(), workSha, 'checked-out base worktree must advance with its ref');
325
+ run(['checkout', 'main'], sourceDir);
326
+ });
327
+ test('rejects remote and nonexistent worktree bases before creating any resources', () => {
328
+ for (const [nodeId, base] of [['wt-remote-base', 'origin/main'], ['wt-bogus-base', 'feature/does-not-exist']]) {
329
+ assert.throws(() => createManagedWorktree(sourceDir, nodeId, base), (error) => error instanceof Error
330
+ && 'code' in error
331
+ && error.code === 'missing_base_branch'
332
+ && error.message.includes(base));
333
+ assert.ok(!existsSync(managedWorktreePath(nodeId)), `${base} must not leave a worktree directory`);
334
+ assert.equal(gitSync(['show-ref', '--verify', '--quiet', `refs/heads/crtr/${nodeId}`], sourceDir).status, 1, `${base} must not leave a dedicated branch`);
335
+ }
336
+ });
337
+ test('refuses to close when the recorded base was deleted and leaves the worktree open', () => {
338
+ const nodeId = 'wt-deleted-base';
339
+ run(['branch', 'feature/deleted-base'], sourceDir);
340
+ const mainBefore = gitSync(['rev-parse', 'main'], sourceDir).stdout.trim();
341
+ const wt = createManagedWorktree(sourceDir, nodeId, 'feature/deleted-base');
342
+ createNode(node(nodeId, { cwd: wt.path, managed_worktree: wt }));
343
+ writeFileSync(join(wt.path, 'deleted-base.txt'), 'work\n');
344
+ run(['add', '.'], wt.path);
345
+ run(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'deleted base work'], wt.path);
346
+ run(['branch', '-D', 'feature/deleted-base'], sourceDir);
347
+ assert.throws(() => closeManagedWorktree(nodeId), (error) => error instanceof Error && 'code' in error && error.code === 'missing_base_branch');
348
+ assert.equal(getNode(nodeId)?.managed_worktree?.state, 'open', 'a missing base must not silently close or retarget the worktree');
349
+ assert.ok(existsSync(wt.path), 'the worktree must remain available for a human decision');
350
+ assert.equal(gitSync(['rev-parse', 'main'], sourceDir).stdout.trim(), mainBefore, 'missing base must never auto-land onto main');
351
+ });
352
+ // Regression for #330/#331: crtr's own untracked `.crouter/config.json` /
353
+ // `state.json` bookkeeping (written by ordinary project-scope init) must
354
+ // never block `close`'s dirty check — Fix A seeds `.git/info/exclude` for
355
+ // exactly those two filenames at worktree-creation time.
356
+ test('close ignores crtr\'s own untracked .crouter/config.json and state.json bookkeeping', () => {
357
+ const nodeId = 'wt-crtr-bookkeeping';
358
+ const wt = createManagedWorktree(sourceDir, nodeId);
359
+ createNode(node(nodeId, { cwd: wt.path, managed_worktree: wt }));
360
+ mkdirSync(join(wt.path, '.crouter'), { recursive: true });
361
+ writeFileSync(join(wt.path, '.crouter', 'config.json'), '{}\n');
362
+ writeFileSync(join(wt.path, '.crouter', 'state.json'), '{}\n');
363
+ // The seeded exclude is on the SHARED common git dir — assert git itself
364
+ // reports a clean tree, not just that close happens not to notice.
365
+ assert.equal(gitSync(['status', '--porcelain'], wt.path).stdout.trim(), '', 'git status must already be clean with only crtr bookkeeping present');
366
+ const result = closeManagedWorktree(nodeId);
367
+ assert.equal(result.worktree_removed, false);
368
+ assert.match(result.worktree_remove_error ?? '', /cleanup pending/);
369
+ });
370
+ // The exclude must be narrow: it must NEVER hide real, committable node work
371
+ // that merely happens to live under `.crouter/` — this mirrors crouter's OWN
372
+ // repo, which tracks `.crouter/memory/`. Set up a dedicated repo (not the
373
+ // shared sourceDir, which never tracks `.crouter/`) that already tracks a
374
+ // memory doc, then prove BOTH a modification to it and a brand-new untracked
375
+ // doc beside it still block close exactly as before Fix A.
376
+ test('close still blocks on real .crouter/memory/ work — a tracked-file edit and a new untracked doc', () => {
377
+ const memoryRepo = mkdtempSync(join(tmpdir(), 'crtr-worktree-memoryrepo-'));
378
+ run(['init', '-b', 'main'], memoryRepo);
379
+ run(['config', 'user.email', 'test@example.com'], memoryRepo);
380
+ run(['config', 'user.name', 'Test'], memoryRepo);
381
+ mkdirSync(join(memoryRepo, '.crouter', 'memory'), { recursive: true });
382
+ writeFileSync(join(memoryRepo, '.crouter', 'memory', 'tracked.md'), 'tracked memory doc\n');
383
+ run(['add', '.'], memoryRepo);
384
+ run(['commit', '-m', 'init with tracked memory'], memoryRepo);
385
+ const editNodeId = 'wt-real-crouter-memory-edit';
386
+ const editWt = createManagedWorktree(memoryRepo, editNodeId);
387
+ createNode(node(editNodeId, { cwd: editWt.path, managed_worktree: editWt }));
388
+ writeFileSync(join(editWt.path, '.crouter', 'memory', 'tracked.md'), 'edited tracked memory doc\n');
389
+ const editStatus = gitSync(['status', '--porcelain'], editWt.path).stdout;
390
+ assert.ok(editStatus.includes('M .crouter/memory/tracked.md'), 'a tracked .crouter/memory/ edit must still show as modified — exclude can never hide a tracked-file change');
391
+ assert.throws(() => closeManagedWorktree(editNodeId), (error) => error instanceof Error && 'code' in error && error.code === 'dirty_worktree');
392
+ const newDocNodeId = 'wt-real-crouter-memory-new-doc';
393
+ const newDocWt = createManagedWorktree(memoryRepo, newDocNodeId);
394
+ createNode(node(newDocNodeId, { cwd: newDocWt.path, managed_worktree: newDocWt }));
395
+ writeFileSync(join(newDocWt.path, '.crouter', 'memory', 'new-doc.md'), 'a legitimate new memory doc\n');
396
+ const newDocStatus = gitSync(['status', '--porcelain'], newDocWt.path).stdout;
397
+ assert.ok(newDocStatus.includes('.crouter/memory/new-doc.md'), 'a brand-new untracked doc under an already-tracked .crouter/memory/ must still show as dirty');
398
+ assert.throws(() => closeManagedWorktree(newDocNodeId), (error) => error instanceof Error && 'code' in error && error.code === 'dirty_worktree');
399
+ rmSync(memoryRepo, { recursive: true, force: true });
400
+ });