@north-light/crouter 0.3.227 → 0.3.229

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 (312) 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/04-base-worker.md +1 -1
  7. package/dist/builtin-memory/insights/init.md +5 -5
  8. package/dist/builtin-memory/internal/INDEX.md +1 -1
  9. package/dist/builtin-memory/internal/agent-shaping.md +11 -11
  10. package/dist/builtin-memory/internal/examples/INDEX.md +1 -1
  11. package/dist/builtin-memory/internal/memory-loading.md +6 -6
  12. package/dist/builtin-memory/internal/plugins.md +1 -1
  13. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/memory-slash-commands.ts +50 -37
  14. package/dist/clients/attach/__tests__/pane-tag-successor.test.js +56 -0
  15. package/dist/clients/attach/__tests__/profile-files.test.js +21 -0
  16. package/dist/clients/attach/chrome/inbox-strip.js +11 -7
  17. package/dist/clients/attach/session/pane-tag.d.ts +28 -2
  18. package/dist/clients/attach/session/pane-tag.js +48 -22
  19. package/dist/clients/attach/session/profile-files.d.ts +6 -0
  20. package/dist/clients/attach/session/profile-files.js +44 -11
  21. package/dist/clients/attach/slash/dispatch.js +4 -7
  22. package/dist/clients/attach/viewer.js +588 -596
  23. package/dist/commands/__tests__/surface-reload-target.test.js +21 -0
  24. package/dist/commands/api-client.d.ts +3 -7
  25. package/dist/commands/api-client.js +7 -12
  26. package/dist/commands/memory/__tests__/command-selector-and-mutation-guards.test.js +292 -0
  27. package/dist/commands/memory/__tests__/repository-root-lint.test.js +146 -0
  28. package/dist/commands/memory/delete.js +51 -24
  29. package/dist/commands/memory/edit.js +50 -10
  30. package/dist/commands/memory/find.js +105 -56
  31. package/dist/commands/memory/history.js +51 -31
  32. package/dist/commands/memory/lint.d.ts +0 -9
  33. package/dist/commands/memory/lint.js +78 -322
  34. package/dist/commands/memory/list.d.ts +12 -4
  35. package/dist/commands/memory/list.js +64 -25
  36. package/dist/commands/memory/move.js +103 -73
  37. package/dist/commands/memory/origin.js +35 -5
  38. package/dist/commands/memory/read.d.ts +4 -0
  39. package/dist/commands/memory/read.js +132 -80
  40. package/dist/commands/memory/shared.d.ts +115 -25
  41. package/dist/commands/memory/shared.js +331 -74
  42. package/dist/commands/memory/write.js +72 -21
  43. package/dist/commands/memory.js +2 -2
  44. package/dist/commands/node/create.d.ts +1 -1
  45. package/dist/commands/node/create.js +1 -1
  46. package/dist/commands/node/inspect.js +1 -1
  47. package/dist/commands/node/lifecycle.js +5 -5
  48. package/dist/commands/pkg/market-manage.js +25 -24
  49. package/dist/commands/pkg/plugin-manage.d.ts +22 -6
  50. package/dist/commands/pkg/plugin-manage.js +118 -25
  51. package/dist/commands/pkg/shared.d.ts +8 -0
  52. package/dist/commands/pkg/shared.js +23 -20
  53. package/dist/commands/surface-reload.d.ts +5 -0
  54. package/dist/commands/surface-reload.js +9 -1
  55. package/dist/commands/sys/__tests__/migrate.test.js +1140 -22
  56. package/dist/commands/sys/__tests__/sync-project-guidance.test.js +218 -0
  57. package/dist/commands/sys/migrate.js +126 -140
  58. package/dist/commands/sys/panels/profiles-panel.d.ts +66 -0
  59. package/dist/commands/sys/panels/profiles-panel.js +599 -0
  60. package/dist/commands/sys/settings-shell.d.ts +4 -2
  61. package/dist/commands/sys/settings-shell.js +35 -4
  62. package/dist/commands/sys/settings.js +21 -5
  63. package/dist/commands/sys/sync-deps.d.ts +2 -0
  64. package/dist/commands/sys/sync-deps.js +26 -15
  65. package/dist/commands/sys/sync-project-guidance.js +228 -145
  66. package/dist/commands/sys/sync-skills.js +28 -17
  67. package/dist/commands/sys/update.js +11 -3
  68. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +15 -2
  69. package/dist/core/__tests__/config-change-delta.test.js +106 -0
  70. package/dist/core/__tests__/context-intro.test.js +13 -9
  71. package/dist/core/__tests__/daemon-boot.test.js +22 -16
  72. package/dist/core/__tests__/fixtures/memory-slash-live-probe.js +5 -1
  73. package/dist/core/__tests__/human-deliver.test.js +19 -0
  74. package/dist/core/__tests__/inline-memory-refs.test.js +50 -46
  75. package/dist/core/__tests__/{serial → integration}/broker-fork-seam.test.js +1 -1
  76. package/dist/core/__tests__/{serial → integration}/broker-sdk-wiring.test.js +1 -1
  77. package/dist/core/__tests__/{serial → integration}/broker-snapshot-history.test.js +1 -1
  78. package/dist/core/__tests__/{serial → integration}/command-plugins.test.js +94 -3
  79. package/dist/core/__tests__/{serial → integration}/deferred-no-wake.test.js +1 -1
  80. package/dist/core/__tests__/{serial → integration}/flagship-lifecycle.test.js +13 -27
  81. package/dist/core/__tests__/{serial → integration}/host-teardown-process-group.test.js +1 -1
  82. package/dist/core/__tests__/{serial → integration}/human-deliver-e2e.test.js +1 -1
  83. package/dist/core/__tests__/{live-mutation-verbs.test.js → integration/live-mutation-verbs.test.js} +22 -33
  84. package/dist/core/__tests__/{serial → integration}/live-mutation.test.js +21 -41
  85. package/dist/core/__tests__/integration/refresh-stall-recycle.test.d.ts +1 -0
  86. package/dist/core/__tests__/{serial → integration}/refresh-stall-recycle.test.js +1 -1
  87. package/dist/core/__tests__/integration/revive.test.d.ts +1 -0
  88. package/dist/core/__tests__/{serial → integration}/revive.test.js +38 -19
  89. package/dist/core/__tests__/integration/spawn-root.test.d.ts +1 -0
  90. package/dist/core/__tests__/{serial → integration}/spawn-root.test.js +104 -1
  91. package/dist/core/__tests__/integration/subscription-delivery.test.d.ts +1 -0
  92. package/dist/core/__tests__/{serial → integration}/subscription-delivery.test.js +1 -1
  93. package/dist/core/__tests__/integration/tmux-surface.test.d.ts +1 -0
  94. package/dist/core/__tests__/{serial → integration}/tmux-surface.test.js +1 -1
  95. package/dist/core/__tests__/integration/worktree-land.test.d.ts +1 -0
  96. package/dist/core/__tests__/integration/worktree-land.test.js +400 -0
  97. package/dist/core/__tests__/integration/worktree-reap.test.d.ts +1 -0
  98. package/dist/core/__tests__/{serial/worktree.test.js → integration/worktree-reap.test.js} +6 -338
  99. package/dist/core/__tests__/kickoff.test.js +16 -5
  100. package/dist/core/__tests__/memory-resolver-precedence.test.js +122 -91
  101. package/dist/core/__tests__/nested-store-discovery.test.js +5 -3
  102. package/dist/core/__tests__/on-read-crouter-home-fence.test.js +2 -1
  103. package/dist/core/__tests__/on-read-dedup-resume.test.js +39 -27
  104. package/dist/core/__tests__/on-read-nested-store.test.js +19 -13
  105. package/dist/core/__tests__/profile-project-memory-delivery.test.js +138 -44
  106. package/dist/core/__tests__/repository-association.test.d.ts +1 -0
  107. package/dist/core/__tests__/repository-association.test.js +153 -0
  108. package/dist/core/__tests__/repository-root-identity.test.d.ts +1 -0
  109. package/dist/core/__tests__/repository-root-identity.test.js +219 -0
  110. package/dist/core/__tests__/review-model-floor.test.js +12 -4
  111. package/dist/core/__tests__/seam/memory-slash-node-relative-inventory.test.js +27 -11
  112. package/dist/core/__tests__/warm-claim-preference-snapshot.test.d.ts +1 -0
  113. package/dist/core/__tests__/warm-claim-preference-snapshot.test.js +40 -0
  114. package/dist/core/canvas/canvas.d.ts +3 -0
  115. package/dist/core/canvas/canvas.js +30 -10
  116. package/dist/core/canvas/db.js +28 -1
  117. package/dist/core/canvas/paths.d.ts +4 -6
  118. package/dist/core/canvas/paths.js +8 -6
  119. package/dist/core/canvas/render-source.js +2 -2
  120. package/dist/core/canvas/types.d.ts +9 -13
  121. package/dist/core/config.d.ts +3 -3
  122. package/dist/core/config.js +3 -3
  123. package/dist/core/exclusive-lock.d.ts +2 -0
  124. package/dist/core/exclusive-lock.js +21 -0
  125. package/dist/core/git.d.ts +0 -1
  126. package/dist/core/git.js +0 -3
  127. package/dist/core/human/__tests__/integration/inbox-core.test.d.ts +1 -0
  128. package/dist/core/human/feedback-companion.js +3 -0
  129. package/dist/core/human/scan.js +8 -1
  130. package/dist/core/keybindings/catalog.d.ts +2 -2
  131. package/dist/core/keybindings/catalog.js +2 -1
  132. package/dist/core/memory/doc-link-grammar.js +2 -2
  133. package/dist/core/memory/history.d.ts +24 -0
  134. package/dist/core/memory/history.js +66 -1
  135. package/dist/core/memory/identity.d.ts +65 -0
  136. package/dist/core/memory/identity.js +185 -0
  137. package/dist/core/memory/inline-ref-guidance.d.ts +1 -1
  138. package/dist/core/memory/inline-ref-guidance.js +1 -1
  139. package/dist/core/memory/inline-ref-inventory.d.ts +3 -10
  140. package/dist/core/memory/inline-ref-inventory.js +30 -62
  141. package/dist/core/memory/lint.d.ts +129 -0
  142. package/dist/core/memory/lint.js +517 -0
  143. package/dist/core/memory/project-namespace.d.ts +31 -0
  144. package/dist/core/memory/project-namespace.js +85 -0
  145. package/dist/core/memory/repository-association.d.ts +31 -0
  146. package/dist/core/memory/repository-association.js +129 -0
  147. package/dist/core/memory/tree.d.ts +39 -0
  148. package/dist/core/memory/tree.js +93 -0
  149. package/dist/core/memory-resolver.d.ts +193 -90
  150. package/dist/core/memory-resolver.js +461 -375
  151. package/dist/core/nested-stores.js +6 -13
  152. package/dist/core/profiles/select.d.ts +4 -1
  153. package/dist/core/profiles/select.js +108 -48
  154. package/dist/core/review/__tests__/capture-origin.test.js +2 -2
  155. package/dist/core/review/__tests__/stage-identity.test.js +2 -2
  156. package/dist/core/review/companion.js +11 -2
  157. package/dist/core/runtime/bearings.d.ts +2 -2
  158. package/dist/core/runtime/bearings.js +3 -3
  159. package/dist/core/runtime/broker/daemon-ops.d.ts +2 -2
  160. package/dist/core/runtime/broker/rebind.js +5 -0
  161. package/dist/core/runtime/broker-extension-render.d.ts +7 -3
  162. package/dist/core/runtime/broker-extension-render.js +10 -5
  163. package/dist/core/runtime/broker-persona-guidance.d.ts +19 -5
  164. package/dist/core/runtime/broker-persona-guidance.js +122 -27
  165. package/dist/core/runtime/deliver-live.d.ts +16 -4
  166. package/dist/core/runtime/deliver-live.js +29 -15
  167. package/dist/core/runtime/kickoff.d.ts +3 -3
  168. package/dist/core/runtime/kickoff.js +8 -16
  169. package/dist/core/runtime/launch.js +1 -1
  170. package/dist/core/runtime/lifecycle.js +2 -3
  171. package/dist/core/runtime/nodes.d.ts +3 -4
  172. package/dist/core/runtime/nodes.js +3 -4
  173. package/dist/core/runtime/persona.d.ts +8 -12
  174. package/dist/core/runtime/persona.js +24 -96
  175. package/dist/core/runtime/promote.d.ts +2 -2
  176. package/dist/core/runtime/promote.js +8 -21
  177. package/dist/core/runtime/revive.js +20 -17
  178. package/dist/core/runtime/spawn.js +18 -7
  179. package/dist/core/runtime/tmux-bindings.js +2 -3
  180. package/dist/core/runtime/warm-pool.js +3 -4
  181. package/dist/core/scope.js +2 -0
  182. package/dist/core/self-update.d.ts +0 -2
  183. package/dist/core/self-update.js +2 -35
  184. package/dist/core/substrate/__tests__/surface-match-memory-read.test.d.ts +1 -0
  185. package/dist/core/substrate/__tests__/surface-match-memory-read.test.js +28 -0
  186. package/dist/core/substrate/index.d.ts +2 -2
  187. package/dist/core/substrate/index.js +1 -1
  188. package/dist/core/substrate/injected-store.d.ts +43 -27
  189. package/dist/core/substrate/injected-store.js +208 -104
  190. package/dist/core/substrate/listings.d.ts +19 -12
  191. package/dist/core/substrate/listings.js +75 -52
  192. package/dist/core/substrate/on-read-node.d.ts +4 -7
  193. package/dist/core/substrate/on-read-node.js +6 -8
  194. package/dist/core/substrate/on-read.d.ts +22 -25
  195. package/dist/core/substrate/on-read.js +103 -147
  196. package/dist/core/substrate/render-node.d.ts +4 -7
  197. package/dist/core/substrate/render-node.js +5 -7
  198. package/dist/core/substrate/render.d.ts +21 -3
  199. package/dist/core/substrate/render.js +291 -223
  200. package/dist/core/substrate/schema.d.ts +1 -13
  201. package/dist/core/substrate/schema.js +5 -40
  202. package/dist/core/substrate/session-cache.d.ts +14 -4
  203. package/dist/core/substrate/session-cache.js +40 -22
  204. package/dist/core/substrate/surface-match.d.ts +9 -7
  205. package/dist/core/substrate/surface-match.js +26 -25
  206. package/dist/daemon/__tests__/helpers/source-daemon.d.ts +30 -0
  207. package/dist/daemon/__tests__/helpers/source-daemon.js +174 -0
  208. package/dist/daemon/__tests__/integration/migration-startup.test.d.ts +1 -0
  209. package/dist/daemon/__tests__/integration/migration-startup.test.js +97 -0
  210. package/dist/daemon/api/__tests__/bridge-heartbeat.test.js +21 -5
  211. package/dist/daemon/api/handlers/broker-ops.js +21 -16
  212. package/dist/daemon/api/handlers/memory.js +2 -0
  213. package/dist/daemon/crtrd.js +2 -0
  214. package/dist/daemon/human/finish.js +8 -1
  215. package/dist/daemon/manage.d.ts +0 -1
  216. package/dist/daemon/manage.js +7 -16
  217. package/dist/daemon/startup-policy.d.ts +1 -0
  218. package/dist/daemon/startup-policy.js +1 -0
  219. package/dist/migrations/001-surfaces-frontmatter.js +21 -109
  220. package/dist/migrations/002-profile-project-memory.js +1 -0
  221. package/dist/migrations/003-repository-root-memory-identity/front-door.d.ts +26 -0
  222. package/dist/migrations/003-repository-root-memory-identity/front-door.js +231 -0
  223. package/dist/migrations/003-repository-root-memory-identity/index.d.ts +2 -0
  224. package/dist/migrations/003-repository-root-memory-identity/index.js +513 -0
  225. package/dist/migrations/003-repository-root-memory-identity/references.d.ts +95 -0
  226. package/dist/migrations/003-repository-root-memory-identity/references.js +469 -0
  227. package/dist/migrations/003-repository-root-memory-identity/repository-facts.d.ts +39 -0
  228. package/dist/migrations/003-repository-root-memory-identity/repository-facts.js +349 -0
  229. package/dist/migrations/__tests__/activation-concurrency.test.d.ts +1 -0
  230. package/dist/migrations/__tests__/activation-concurrency.test.js +145 -0
  231. package/dist/migrations/__tests__/activation.test.d.ts +1 -0
  232. package/dist/migrations/__tests__/activation.test.js +149 -0
  233. package/dist/migrations/__tests__/deletion-and-root-declaration.test.d.ts +1 -0
  234. package/dist/migrations/__tests__/deletion-and-root-declaration.test.js +149 -0
  235. package/dist/migrations/activation.d.ts +16 -0
  236. package/dist/migrations/activation.js +78 -0
  237. package/dist/migrations/convergent.d.ts +14 -3
  238. package/dist/migrations/convergent.js +21 -10
  239. package/dist/migrations/corpus.d.ts +78 -0
  240. package/dist/migrations/corpus.js +497 -0
  241. package/dist/migrations/frontmatter-splice.d.ts +15 -0
  242. package/dist/migrations/frontmatter-splice.js +176 -0
  243. package/dist/migrations/registry.d.ts +6 -1
  244. package/dist/migrations/registry.js +7 -2
  245. package/dist/migrations/runner.d.ts +41 -0
  246. package/dist/migrations/runner.js +81 -0
  247. package/dist/migrations/types.d.ts +148 -9
  248. package/dist/migrations/types.js +9 -2
  249. package/dist/pi-extensions/__tests__/canvas-context-intro.test.js +225 -17
  250. package/dist/pi-extensions/__tests__/canvas-goal-capture-envelope.test.js +11 -3
  251. package/dist/pi-extensions/canvas-context-intro.d.ts +3 -5
  252. package/dist/pi-extensions/canvas-context-intro.js +50 -46
  253. package/dist/pi-extensions/canvas-doc-substrate.d.ts +1 -8
  254. package/dist/pi-extensions/canvas-doc-substrate.js +55 -122
  255. package/dist/pi-extensions/canvas-stophook.js +6 -13
  256. package/dist/shared/generated-context.d.ts +0 -3
  257. package/dist/shared/generated-context.js +0 -57
  258. package/dist/shared/tool-groups.js +2 -3
  259. package/dist/types.d.ts +8 -11
  260. package/dist/types.js +4 -31
  261. package/package.json +5 -4
  262. package/runtime.lock.json +2 -2
  263. package/dist/builtin-memory/05-kinds/design/00-base.md +0 -17
  264. package/dist/builtin-memory/05-kinds/design/01-orchestrator.md +0 -15
  265. package/dist/builtin-memory/05-kinds/design/design-contract.md +0 -19
  266. package/dist/builtin-memory/05-kinds/developer/00-base.md +0 -17
  267. package/dist/builtin-memory/05-kinds/developer/01-orchestrator.md +0 -15
  268. package/dist/builtin-memory/05-kinds/plan/00-base.md +0 -17
  269. package/dist/builtin-memory/05-kinds/plan/01-orchestrator.md +0 -17
  270. package/dist/builtin-memory/05-kinds/plan/plan-contract.md +0 -28
  271. package/dist/builtin-memory/05-kinds/plan/reviewers/architecture-fit.md +0 -15
  272. package/dist/builtin-memory/05-kinds/plan/reviewers/code-smells.md +0 -15
  273. package/dist/builtin-memory/05-kinds/plan/reviewers/lens-contract.md +0 -13
  274. package/dist/builtin-memory/05-kinds/plan/reviewers/pattern-consistency.md +0 -17
  275. package/dist/builtin-memory/05-kinds/plan/reviewers/requirements-coverage.md +0 -17
  276. package/dist/builtin-memory/05-kinds/plan/reviewers/security.md +0 -17
  277. package/dist/builtin-memory/05-kinds/spec/00-base.md +0 -17
  278. package/dist/builtin-memory/05-kinds/spec/01-orchestrator.md +0 -15
  279. package/dist/builtin-memory/05-kinds/spec/requirements.md +0 -15
  280. package/dist/builtin-memory/design/guide.md +0 -35
  281. package/dist/builtin-memory/design/roadmap.md +0 -21
  282. package/dist/builtin-memory/development.md +0 -113
  283. package/dist/builtin-memory/plan/roadmap.md +0 -25
  284. package/dist/builtin-memory/spec/guide.md +0 -53
  285. package/dist/builtin-memory/spec/requirements.md +0 -29
  286. package/dist/builtin-memory/spec/roadmap.md +0 -36
  287. package/dist/builtin-memory/testing.md +0 -39
  288. /package/dist/api/__tests__/{serial → integration}/client.test.d.ts +0 -0
  289. /package/dist/api/__tests__/{serial → integration}/client.test.js +0 -0
  290. /package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/{serial → integration}/provider-rotation.test.ts +0 -0
  291. /package/dist/clients/{inbox/__tests__/serial/inbox-controller.test.d.ts → attach/__tests__/pane-tag-successor.test.d.ts} +0 -0
  292. /package/dist/clients/{inbox/__tests__/serial/mount-panel.test.d.ts → attach/__tests__/profile-files.test.d.ts} +0 -0
  293. /package/dist/{core/__tests__/live-mutation-verbs.test.d.ts → clients/inbox/__tests__/integration/inbox-controller.test.d.ts} +0 -0
  294. /package/dist/clients/inbox/__tests__/{serial → integration}/inbox-controller.test.js +0 -0
  295. /package/dist/{core/__tests__/serial/broker-fork-seam.test.d.ts → clients/inbox/__tests__/integration/mount-panel.test.d.ts} +0 -0
  296. /package/dist/clients/inbox/__tests__/{serial → integration}/mount-panel.test.js +0 -0
  297. /package/dist/{core/__tests__/serial/broker-sdk-wiring.test.d.ts → commands/__tests__/surface-reload-target.test.d.ts} +0 -0
  298. /package/dist/{core/__tests__/serial/broker-snapshot-history.test.d.ts → commands/memory/__tests__/command-selector-and-mutation-guards.test.d.ts} +0 -0
  299. /package/dist/{core/__tests__/serial/command-plugins.test.d.ts → commands/memory/__tests__/repository-root-lint.test.d.ts} +0 -0
  300. /package/dist/{core/__tests__/serial/deferred-no-wake.test.d.ts → commands/sys/__tests__/sync-project-guidance.test.d.ts} +0 -0
  301. /package/dist/core/__tests__/{serial/flagship-lifecycle.test.d.ts → config-change-delta.test.d.ts} +0 -0
  302. /package/dist/core/__tests__/{serial/host-teardown-process-group.test.d.ts → integration/broker-fork-seam.test.d.ts} +0 -0
  303. /package/dist/core/__tests__/{serial/human-deliver-e2e.test.d.ts → integration/broker-sdk-wiring.test.d.ts} +0 -0
  304. /package/dist/core/__tests__/{serial/live-mutation.test.d.ts → integration/broker-snapshot-history.test.d.ts} +0 -0
  305. /package/dist/core/__tests__/{serial/refresh-stall-recycle.test.d.ts → integration/command-plugins.test.d.ts} +0 -0
  306. /package/dist/core/__tests__/{serial/revive.test.d.ts → integration/deferred-no-wake.test.d.ts} +0 -0
  307. /package/dist/core/__tests__/{serial/spawn-root.test.d.ts → integration/flagship-lifecycle.test.d.ts} +0 -0
  308. /package/dist/core/__tests__/{serial/subscription-delivery.test.d.ts → integration/host-teardown-process-group.test.d.ts} +0 -0
  309. /package/dist/core/__tests__/{serial/tmux-surface.test.d.ts → integration/human-deliver-e2e.test.d.ts} +0 -0
  310. /package/dist/core/__tests__/{serial/worktree.test.d.ts → integration/live-mutation-verbs.test.d.ts} +0 -0
  311. /package/dist/core/{human/__tests__/serial/inbox-core.test.d.ts → __tests__/integration/live-mutation.test.d.ts} +0 -0
  312. /package/dist/core/human/__tests__/{serial → integration}/inbox-core.test.js +0 -0
@@ -1,10 +1,12 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import { spawnSync } from 'node:child_process';
3
- import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
- import { dirname, join } from 'node:path';
5
+ import { dirname, join, relative } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import test from 'node:test';
8
+ import { parse as parseYaml } from 'yaml';
9
+ import { onDiskMigrationIdentity } from '../../../migrations/registry.js';
8
10
  const HERE = dirname(fileURLToPath(import.meta.url));
9
11
  const CLI = join(HERE, '../../../cli.ts');
10
12
  const TSX_LOADER = import.meta.resolve('tsx/esm');
@@ -20,10 +22,159 @@ kind: knowledge
20
22
  ---
21
23
  # Current format
22
24
  `;
25
+ /** The exact bytes the superseded per-store migration 003 wrote when it created
26
+ * a nested store's synthetic root index. A corpus that already carries one is
27
+ * the second convergence start state, and the identity-only template is the
28
+ * only nested index this cut is allowed to delete. */
29
+ function supersededSyntheticIndex(namespace) {
30
+ return `---
31
+ kind: knowledge
32
+ namespace: ${namespace}
33
+ when-and-why-to-read: When working in \`${namespace}\`, this knowledge should be read because it names the namespace every document in this project answers under.
34
+ surfaces:
35
+ - on: workspace-open
36
+ at: content
37
+ - on: read
38
+ match: "./**"
39
+ at: content
40
+ ---
41
+ # ${namespace}
42
+
43
+ This project's memory store. Every document here answers at \`${namespace}/<local name>\`.
44
+ `;
45
+ }
23
46
  function write(path, content) {
24
47
  mkdirSync(dirname(path), { recursive: true });
25
48
  writeFileSync(path, content);
26
49
  }
50
+ /** Store keys and target ids are realpaths, so the fixture must be one too:
51
+ * macOS hands `mkdtemp` a `/var/folders` path whose realpath is `/private/...`. */
52
+ function fixtureHome(prefix) {
53
+ return realpathSync(mkdtempSync(join(tmpdir(), prefix)));
54
+ }
55
+ /** Fixture git, with identity and signing pinned so the ambient user's global
56
+ * config cannot fail a commit this test needs. */
57
+ function git(cwd, ...args) {
58
+ const result = spawnSync('git', ['-c', 'user.email=test@example.com', '-c', 'user.name=Test', '-c', 'commit.gpgsign=false', ...args], { cwd, encoding: 'utf8' });
59
+ assert.equal(result.status, 0, `git ${args.join(' ')} in ${cwd}: ${result.stderr}`);
60
+ }
61
+ /** A git checkout with one commit — the minimum `git worktree add` needs. */
62
+ function initRepo(dir) {
63
+ mkdirSync(dir, { recursive: true });
64
+ git(dir, 'init', '-b', 'main');
65
+ writeFileSync(join(dir, 'README.md'), '# fixture\n');
66
+ git(dir, 'add', 'README.md');
67
+ git(dir, 'commit', '-m', 'fixture');
68
+ }
69
+ function markerPath(home) {
70
+ return join(home, '.crouter', 'canvas', 'on-disk-migrations.json');
71
+ }
72
+ function readMarker(home) {
73
+ return JSON.parse(readFileSync(markerPath(home), 'utf8'));
74
+ }
75
+ /** Every file under `dir`, by path, so a run that must write NOTHING can be
76
+ * proved byte for byte rather than by a changed counter. */
77
+ function snapshotTree(dir) {
78
+ const files = new Map();
79
+ const walk = (current) => {
80
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
81
+ const path = join(current, entry.name);
82
+ if (entry.isDirectory())
83
+ walk(path);
84
+ else if (entry.isFile())
85
+ files.set(path, readFileSync(path, 'utf8'));
86
+ }
87
+ };
88
+ walk(dir);
89
+ return files;
90
+ }
91
+ /** The same tree keyed by home-relative path, with the home's own absolute path
92
+ * replaced by a token in every file's bytes — a profile manifest stores project
93
+ * paths absolutely — so two fixtures seeded in two temp homes can be compared
94
+ * byte for byte. */
95
+ function relativeTree(dir) {
96
+ const rebased = new Map();
97
+ for (const [path, content] of snapshotTree(dir))
98
+ rebased.set(relative(dir, path), content.split(dir).join('<home>'));
99
+ return rebased;
100
+ }
101
+ function assertTreeUnchanged(before, after) {
102
+ assert.deepEqual([...after.keys()].sort(), [...before.keys()].sort());
103
+ for (const [path, content] of before)
104
+ assert.equal(after.get(path), content, `${path} changed`);
105
+ }
106
+ /** A home the ambient node's own CRTR_* environment cannot leak into: every
107
+ * crtr variable is dropped, so the fixture's canvas/profile/node state is the
108
+ * only state the run can see. */
109
+ function migrateEnv(home, profileId) {
110
+ const env = { ...process.env, HOME: home };
111
+ for (const key of Object.keys(env)) {
112
+ if (key.startsWith('CRTR_'))
113
+ delete env[key];
114
+ }
115
+ env['CRTR_PROFILE_ID'] = profileId;
116
+ env['CRTR_NO_AUTO_INIT'] = '1';
117
+ env['CRTR_NO_AUTO_UPDATE'] = '1';
118
+ return env;
119
+ }
120
+ function runMigrate(home, opts = {}) {
121
+ const result = spawnSync(process.execPath, [
122
+ '--conditions=crtr-src',
123
+ '--import',
124
+ TSX_LOADER,
125
+ CLI,
126
+ 'sys',
127
+ 'migrate',
128
+ '--json',
129
+ ...(opts.dryRun === true ? ['--dry-run'] : []),
130
+ ...(opts.dirs ?? []).flatMap((dir) => ['--dir', dir]),
131
+ ], { cwd: home, env: migrateEnv(home, opts.profileId ?? 'root-00000000'), encoding: 'utf8' });
132
+ return { status: result.status, stdout: result.stdout, stderr: result.stderr };
133
+ }
134
+ function migrateJson(home, opts = {}) {
135
+ const result = runMigrate(home, opts);
136
+ assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
137
+ const output = JSON.parse(result.stdout);
138
+ assertReportInvariants(output);
139
+ return output;
140
+ }
141
+ /** The report-schema rules every run obeys: a declaration is always the
142
+ * repository root's own INDEX.md, a nested store's effective prefix is never
143
+ * reported as one, and only a repository-root row can carry a front door. */
144
+ function assertReportInvariants(output) {
145
+ const declarationRoots = new Set();
146
+ for (const row of output.repository_namespaces) {
147
+ assert.equal(row.declaration_path, join(row.repository_root, '.crouter', 'memory', 'INDEX.md'), 'a declaration path is always the repository root\'s own INDEX.md');
148
+ assert.equal(declarationRoots.has(row.repository_root), false, `two decisions for ${row.repository_root}`);
149
+ declarationRoots.add(row.repository_root);
150
+ }
151
+ const declaredNamespaces = new Set(output.repository_namespaces.map((row) => row.namespace));
152
+ for (const row of output.store_prefixes) {
153
+ if (row.owner_relative_path === '') {
154
+ assert.equal(row.owner_dir, row.repository_root);
155
+ continue;
156
+ }
157
+ assert.equal(row.front_door_action, 'none', `${row.store_root} is nested and cannot own a front door`);
158
+ assert.equal(declarationRoots.has(row.owner_dir), false, `${row.owner_dir} is nested, not a repository root`);
159
+ assert.equal(row.effective_prefix.endsWith(`/${row.owner_relative_path}`), true, `${row.effective_prefix} must compose the owner path`);
160
+ assert.equal(declaredNamespaces.has(row.effective_prefix), false, `${row.effective_prefix} is derived from a path, never a declaration`);
161
+ }
162
+ }
163
+ function repositoryRow(output, repositoryRoot) {
164
+ const row = output.repository_namespaces.find((candidate) => candidate.repository_root === repositoryRoot);
165
+ assert.ok(row, `no repository decision for ${repositoryRoot} in ${JSON.stringify(output.repository_namespaces)}`);
166
+ return row;
167
+ }
168
+ function prefixRow(output, ownerDir) {
169
+ const row = output.store_prefixes.find((candidate) => candidate.owner_dir === ownerDir);
170
+ assert.ok(row, `no store prefix for ${ownerDir} in ${JSON.stringify(output.store_prefixes)}`);
171
+ return row;
172
+ }
173
+ function migrateBlocked(home, opts = {}) {
174
+ const result = runMigrate(home, opts);
175
+ assert.notEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
176
+ return JSON.parse(result.stdout);
177
+ }
27
178
  function manifest(name, projects) {
28
179
  const first = projects[0];
29
180
  return JSON.stringify({
@@ -35,70 +186,1037 @@ function manifest(name, projects) {
35
186
  last_used_at: null,
36
187
  });
37
188
  }
38
- test('sys migrate converges profile manifests, then profile-aware refs and every descendant project/plugin store', () => {
39
- const home = mkdtempSync(join(tmpdir(), 'crtr-sys-migrate-'));
189
+ test('sys migrate converges manifests, documents, and the whole repository-root corpus', () => {
190
+ const home = fixtureHome('crtr-sys-migrate-');
40
191
  const userRoot = join(home, '.crouter');
41
192
  const rootProfile = join(userRoot, 'profiles', 'root-00000000');
42
193
  const appletProfile = join(userRoot, 'profiles', 'applet-deadbeef');
43
194
  const appletProject = join(home, 'inactive-applet-project');
44
195
  const nestedOnlyProject = join(home, 'nested-only-project');
45
- const deepRoot = join(appletProject, 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', '.crouter');
196
+ const extraProject = join(home, 'extra-dir-project');
197
+ const deepOwner = join(appletProject, 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h');
198
+ const deepRoot = join(deepOwner, '.crouter');
46
199
  const pluginRoot = join(deepRoot, 'plugins', 'deep-memory');
47
200
  const embeddedPluginRoot = join(nestedOnlyProject, 'embedded', '.crouter', 'plugins', 'embedded-memory');
201
+ const disabledPluginRoot = join(userRoot, 'plugins', 'disabled-memory');
202
+ const nodeId = 'node-aaaa1111';
203
+ const nodeStore = join(userRoot, 'canvas', 'nodes', nodeId, 'context', 'memory');
48
204
  const userDoc = join(userRoot, 'memory', 'user-link.md');
49
205
  const rootDoc = join(rootProfile, 'memory', 'root.md');
50
206
  const appletDoc = join(appletProfile, 'memory', 'identity.md');
207
+ const projectIndex = join(appletProject, '.crouter', 'memory', 'INDEX.md');
51
208
  const projectDoc = join(appletProject, '.crouter', 'memory', 'project.md');
209
+ const projectPlain = join(appletProject, '.crouter', 'memory', 'plain.md');
210
+ const projectReference = join(appletProject, '.crouter', 'memory', 'notes', 'reference.md');
52
211
  const deepDoc = join(deepRoot, 'memory', 'deep.md');
212
+ const deepIndex = join(deepRoot, 'memory', 'INDEX.md');
53
213
  const pluginDoc = join(pluginRoot, 'memory', 'plugin.md');
54
214
  const embeddedPluginDoc = join(embeddedPluginRoot, 'memory', 'plugin.md');
215
+ const disabledPluginDoc = join(disabledPluginRoot, 'memory', 'guide.md');
216
+ const nodeDoc = join(nodeStore, 'note.md');
55
217
  try {
56
218
  // Root's string entries migrate to `preview`; every other profile's to
57
219
  // `content`. The applet manifest is mixed: its already-current entry must
58
220
  // survive untouched while the string beside it converts.
59
221
  write(join(rootProfile, 'profile.json'), manifest('root', [appletProject]));
60
222
  write(join(appletProfile, 'profile.json'), manifest('applet-inactive', [{ path: appletProject, memory: 'name' }, nestedOnlyProject]));
223
+ // User store: `[[user/scoped]]` names no document under either identity
224
+ // rule, so it is a pre-existing dangling link the cut never touches.
61
225
  write(userDoc, `${OLD_DOC}\nSee [[user/scoped]].\n`);
62
226
  write(join(userRoot, 'memory', 'scoped', 'INDEX.md'), CURRENT_DOC);
63
- write(rootDoc, `${OLD_DOC}\nSee [[shared]].\n`);
227
+ // Root profile store: an INDEX spelling to collapse, an unqualified project
228
+ // name to qualify, and a name its own target stack cannot see.
229
+ write(rootDoc, `${OLD_DOC}\nSee [[notes/INDEX]], [[project]], and [[shared]].\n`);
230
+ write(join(rootProfile, 'memory', 'notes', 'INDEX.md'), CURRENT_DOC);
64
231
  write(appletDoc, `${OLD_DOC}\nSee [[shared]].\n`);
65
232
  write(join(appletProfile, 'memory', 'shared', 'INDEX.md'), CURRENT_DOC);
233
+ // Repository-root store: a root document that gains the repository
234
+ // declaration AND carries both a link and a route rewrite, so one file
235
+ // lands in both write phases; an explicit name to strip; a redundant
236
+ // explicit name to drop. The route's `match:` is a BLOCK SEQUENCE: this root
237
+ // owes a front door, and completing one re-renders the whole `surfaces:`
238
+ // list, so the translated glob must survive that round trip.
239
+ write(projectIndex, `---
240
+ kind: knowledge
241
+ surfaces:
242
+ - on: memory-read
243
+ match:
244
+ - topics/*
245
+ at: content
246
+ ---
247
+ # Applet Project
248
+
249
+ See [[project]].
250
+ `);
66
251
  write(projectDoc, OLD_DOC);
67
252
  write(join(appletProject, '.crouter', 'memory', 'scoped', 'INDEX.md'), CURRENT_DOC);
253
+ write(projectPlain, `---\nkind: knowledge\nname: plain\n---\n# Redundant name\n`);
254
+ // A FOLDED explicit name: its value spans two lines, so rewriting it must
255
+ // stop at the value's last character and leave the next key standing.
256
+ write(projectReference, `---\nname: >-\n applet-project/reference\nkind: knowledge\n---\n# Namespaced name\n`);
257
+ write(join(appletProject, '.crouter', 'memory', 'topics', 'one.md'), CURRENT_DOC);
258
+ write(join(appletProject, '.crouter', 'memory', 'topics', 'two.md'), CURRENT_DOC);
259
+ // A nested store eight directories below the repository root: it declares
260
+ // nothing, needs no root index, and answers under the repository namespace
261
+ // composed with its own path.
68
262
  write(deepDoc, OLD_DOC);
69
263
  write(join(pluginRoot, '.crouter-plugin', 'plugin.json'), JSON.stringify({ name: 'deep-memory', version: '1.0.0' }));
70
264
  write(pluginDoc, OLD_DOC);
71
265
  write(join(embeddedPluginRoot, '.crouter-plugin', 'plugin.json'), JSON.stringify({ name: 'embedded-memory', version: '1.0.0' }));
72
266
  write(join(nestedOnlyProject, 'embedded', '.git', 'HEAD'), 'ref: refs/heads/main\n');
73
267
  write(embeddedPluginDoc, OLD_DOC);
74
- const env = {
75
- ...process.env,
76
- HOME: home,
77
- CRTR_PROFILE_ID: 'root-00000000',
78
- CRTR_NO_AUTO_INIT: '1',
79
- CRTR_NO_AUTO_UPDATE: '1',
80
- };
81
- delete env.CRTR_NODE_ID;
82
- const result = spawnSync(process.execPath, ['--conditions=crtr-src', '--import', TSX_LOADER, CLI, 'sys', 'migrate', '--json'], { cwd: home, env, encoding: 'utf8' });
83
- assert.equal(result.status, 0, result.stderr);
84
- const output = JSON.parse(result.stdout);
268
+ // A DISABLED plugin: no target mounts it, so its references resolve against
269
+ // its own store alone — and it is still migrated.
270
+ write(join(userRoot, 'config.json'), JSON.stringify({ plugins: { 'disabled-memory': { enabled: false } } }));
271
+ write(join(disabledPluginRoot, '.crouter-plugin', 'plugin.json'), JSON.stringify({ name: 'disabled-memory', version: '1.0.0' }));
272
+ write(join(disabledPluginRoot, 'memory', 'notes', 'INDEX.md'), CURRENT_DOC);
273
+ write(disabledPluginDoc, `${CURRENT_DOC}\nSee [[disabled-memory/notes/INDEX]].\n`);
274
+ // A node store plus the durable meta.json the corpus reads with no daemon.
275
+ write(join(userRoot, 'canvas', 'nodes', nodeId, 'meta.json'), JSON.stringify({ node_id: nodeId, cwd: appletProject, profile_id: 'applet-deadbeef', kind: 'developer' }));
276
+ write(nodeDoc, `${CURRENT_DOC}\nSee [[project]].\n`);
277
+ // Reached by --dir only: no manifest names it and it is under no discovered
278
+ // project root. Its `name:` is the FIRST frontmatter key, so the removal and
279
+ // the `namespace:` insert share one offset — the shape that corrupts the
280
+ // file if the two splices apply in the wrong order.
281
+ write(join(extraProject, '.crouter', 'memory', 'INDEX.md'), `---\nname: extra-dir-project\nkind: knowledge\n---\n# Extra Dir Project\n`);
282
+ write(join(extraProject, '.crouter', 'memory', 'topic.md'), CURRENT_DOC);
283
+ const invisible = migrateJson(home, { dryRun: true });
284
+ assert.equal(existsSync(markerPath(home)), false);
285
+ assert.equal(invisible.discovered_stores.some((row) => row.store.startsWith(extraProject)), false, 'the --dir project must be outside the ambient discovery frontier');
286
+ const output = migrateJson(home, { dirs: [extraProject] });
287
+ assert.deepEqual(readMarker(home), onDiskMigrationIdentity());
85
288
  assert.equal(output.profiles, 2);
86
- assert.equal(output.stores, 7);
87
- assert.equal(output.changed, 9);
289
+ assert.equal(output.stores, 11);
290
+ assert.equal(output.blockers.length, 0);
291
+ // 002 — profile manifests.
88
292
  const readProjects = (dir) => JSON.parse(readFileSync(join(dir, 'profile.json'), 'utf8')).projects;
89
293
  assert.deepEqual(readProjects(rootProfile), [{ path: appletProject, memory: 'preview' }]);
90
294
  assert.deepEqual(readProjects(appletProfile), [
91
295
  { path: appletProject, memory: 'name' },
92
296
  { path: nestedOnlyProject, memory: 'content' },
93
297
  ]);
298
+ // 001 — every old-format document, in every store kind, converged.
94
299
  for (const path of [userDoc, rootDoc, appletDoc, projectDoc, deepDoc, pluginDoc, embeddedPluginDoc]) {
95
300
  const migrated = readFileSync(path, 'utf8');
96
301
  assert.doesNotMatch(migrated, /system-prompt-visibility|file-read-visibility/);
97
302
  assert.match(migrated, /surfaces:\n - on: boot\n at: content/);
98
303
  }
99
- assert.match(readFileSync(userDoc, 'utf8'), /\[\[user\/scoped\/INDEX\]\]/);
100
- assert.match(readFileSync(rootDoc, 'utf8'), /\[\[shared\]\]/);
101
- assert.match(readFileSync(appletDoc, 'utf8'), /\[\[shared\/INDEX\]\]/);
304
+ // Durable node metadata, read with no daemon.
305
+ assert.deepEqual(output.node_metadata, { available: true, nodes: 1 });
306
+ const nodeTarget = output.discovered_targets.find((row) => row.kind === 'node');
307
+ assert.equal(nodeTarget?.node_id, nodeId);
308
+ assert.equal(nodeTarget?.origin, nodeId);
309
+ // A disabled plugin: discovered, mounted by no target, migrated anyway.
310
+ const disabledStore = output.discovered_stores.find((row) => row.store.startsWith(disabledPluginRoot));
311
+ assert.equal(disabledStore?.writable, true);
312
+ assert.deepEqual(disabledStore?.targets, []);
313
+ assert.match(readFileSync(disabledPluginDoc, 'utf8'), /\[\[disabled-memory\/notes\]\]/);
314
+ // The --dir project entered discovery as an explicit root, and is its own
315
+ // repository: home is fenced out of the non-git ancestor walk.
316
+ const extraStore = output.discovered_stores.find((row) => row.store.startsWith(extraProject));
317
+ assert.equal(extraStore?.reasons.includes('explicit-dir'), true);
318
+ assert.deepEqual(repositoryRow(output, extraProject), {
319
+ repository_root: extraProject,
320
+ repository_key: `path:${extraProject}`,
321
+ declaration_path: join(extraProject, '.crouter', 'memory', 'INDEX.md'),
322
+ namespace: 'extra-dir-project',
323
+ source: 'root-name',
324
+ action: 'kept',
325
+ });
326
+ // The declaration replaces the first-key `name:`, byte for byte, and the
327
+ // root gains the front door the repository owes.
328
+ assert.equal(readFileSync(join(extraProject, '.crouter', 'memory', 'INDEX.md'), 'utf8'), `---
329
+ namespace: extra-dir-project
330
+ kind: knowledge
331
+ surfaces:
332
+ - on: workspace-open
333
+ at: content
334
+ - on: read
335
+ match: ./**
336
+ at: content
337
+ ---
338
+ # Extra Dir Project
339
+ `);
340
+ // ONE repository decision for the applet repository, derived from the root
341
+ // guide's H1 — the eight-deep descendant store adds no second decision.
342
+ assert.deepEqual(output.repository_namespaces.filter((row) => row.repository_root.startsWith(appletProject)), [
343
+ {
344
+ repository_root: appletProject,
345
+ repository_key: `path:${appletProject}`,
346
+ declaration_path: projectIndex,
347
+ namespace: 'applet-project',
348
+ source: 'root-guide-h1',
349
+ action: 'kept',
350
+ },
351
+ ]);
352
+ // One store-prefix row per project store: the repository root at `''`, the
353
+ // descendant at its normalized path under it. Plugin, user, profile, and
354
+ // node stores are not project stores and carry no row.
355
+ assert.deepEqual(output.store_prefixes.map((row) => [row.owner_dir, row.owner_relative_path, row.effective_prefix, row.front_door_action]).sort(), [
356
+ [appletProject, '', 'applet-project', 'created-root'],
357
+ [deepOwner, 'a/b/c/d/e/f/g/h', 'applet-project/a/b/c/d/e/f/g/h', 'none'],
358
+ [extraProject, '', 'extra-dir-project', 'created-root'],
359
+ ].sort());
360
+ assert.equal(prefixRow(output, deepOwner).nested_declaration_action, 'none');
361
+ assert.equal(prefixRow(output, deepOwner).old_rule, 'pre-003');
362
+ // A nested store owes no root index and is never handed a synthetic one.
363
+ assert.equal(existsSync(deepIndex), false);
364
+ // Discovery reports each store as the run FOUND it: before this cut nothing
365
+ // up the tree declares a namespace, so the deep store mounts nowhere.
366
+ const deepStoreBefore = output.discovered_stores.find((row) => row.store === join(deepRoot, 'memory'));
367
+ assert.equal(deepStoreBefore?.namespace, '');
368
+ assert.equal(deepStoreBefore?.mount_status, 'undeclared');
369
+ assert.equal(output.renames.some((row) => row.path === deepDoc && row.from === 'deep' && row.to === 'applet-project/a/b/c/d/e/f/g/h/deep'), true, JSON.stringify(output.renames));
370
+ // Identity rewrites in the repository-root store.
371
+ const projectIndexText = readFileSync(projectIndex, 'utf8');
372
+ assert.match(projectIndexText, /^---\nnamespace: applet-project\n/);
373
+ assert.equal(readFileSync(projectPlain, 'utf8'), `---\nkind: knowledge\n---\n# Redundant name\n`);
374
+ assert.equal(readFileSync(projectReference, 'utf8'), `---\nname: reference\nkind: knowledge\n---\n# Namespaced name\n`);
375
+ assert.equal(output.renames.some((row) => row.path === projectDoc && row.from === 'project' && row.to === 'applet-project/project'), true);
376
+ // Reference rewrites: qualification across stores, one INDEX collapse, and
377
+ // an absolute memory-read route split-proof — the route and the link ride
378
+ // the same file as its own identity rewrite and its own front-door
379
+ // completion.
380
+ const rootDocText = readFileSync(rootDoc, 'utf8');
381
+ assert.match(rootDocText, /\[\[notes\]\]/);
382
+ assert.match(rootDocText, /\[\[applet-project\/project\]\]/);
383
+ assert.match(rootDocText, /\[\[shared\]\]/);
384
+ assert.match(readFileSync(userDoc, 'utf8'), /\[\[user\/scoped\]\]/);
385
+ assert.match(readFileSync(appletDoc, 'utf8'), /\[\[shared\]\]/);
386
+ assert.match(readFileSync(nodeDoc, 'utf8'), /\[\[applet-project\/project\]\]/);
387
+ assert.equal(projectIndexText, `---
388
+ namespace: applet-project
389
+ kind: knowledge
390
+ surfaces:
391
+ - on: memory-read
392
+ match: applet-project/topics/*
393
+ at: content
394
+ - on: workspace-open
395
+ at: content
396
+ - on: read
397
+ match: ./**
398
+ at: content
399
+ ---
400
+ # Applet Project
401
+
402
+ See [[applet-project/project]].
403
+ `);
404
+ assert.equal(output.references.some((row) => row.kind === 'memory-read' && row.from === 'topics/*' && row.to === 'applet-project/topics/*'), true);
405
+ // A second run over converged state writes nothing.
406
+ const converged = snapshotTree(home);
407
+ // A no-change forced run still replaces an outdated completion marker.
408
+ writeFileSync(markerPath(home), JSON.stringify([{ id: 'older-migration', lane: 'convergent' }]));
409
+ const second = migrateJson(home, { dirs: [extraProject] });
410
+ assert.equal(second.changed, 0);
411
+ assert.deepEqual(readMarker(home), onDiskMigrationIdentity());
412
+ assert.deepEqual(second.applied, []);
413
+ assert.deepEqual(second.references, []);
414
+ // The old side is reconstructed from FILE TRUTH alone, and a cut corpus is
415
+ // final authority, not old authority: the repository root's kept declaration
416
+ // is what the deep store already answers under, and no document there
417
+ // carries an explicit name this cut would rewrite. So the deep store's row
418
+ // reports that same prefix as its old one and NOTHING moves — no rename, no
419
+ // reference rewrite, no splice, and a byte-identical tree.
420
+ assert.deepEqual(second.renames, []);
421
+ assert.equal(prefixRow(second, deepOwner).old_rule, 'per-store-declaration');
422
+ assert.equal(prefixRow(second, deepOwner).old_prefix, 'applet-project/a/b/c/d/e/f/g/h');
423
+ assert.equal(repositoryRow(second, appletProject).source, 'declared');
424
+ assert.equal(prefixRow(second, appletProject).front_door_action, 'kept-root');
425
+ // The runtime resolver composes exactly the prefix the migration reported:
426
+ // the deep store now mounts under the repository namespace plus its path.
427
+ const deepStoreAfter = second.discovered_stores.find((row) => row.store === join(deepRoot, 'memory'));
428
+ assert.equal(deepStoreAfter?.namespace, 'applet-project/a/b/c/d/e/f/g/h');
429
+ assert.equal(deepStoreAfter?.mount_status, 'ready');
430
+ assertTreeUnchanged(converged, snapshotTree(home));
431
+ const currentMarker = readFileSync(markerPath(home), 'utf8');
432
+ const preview = migrateJson(home, { dirs: [extraProject], dryRun: true });
433
+ assert.equal(preview.changed, 0);
434
+ assert.equal(readFileSync(markerPath(home), 'utf8'), currentMarker);
435
+ // Interruption-shaped state under a current marker: the explicit command
436
+ // must still finish the cut from file truth alone.
437
+ writeFileSync(projectIndex, projectIndexText.replace('namespace: applet-project\n', ''));
438
+ writeFileSync(projectPlain, readFileSync(projectPlain, 'utf8').replace('kind: knowledge\n', 'kind: knowledge\nname: plain\n'));
439
+ const resumed = migrateJson(home, { dirs: [extraProject] });
440
+ assert.equal(resumed.blockers.length, 0);
441
+ assert.deepEqual(readMarker(home), onDiskMigrationIdentity());
442
+ assertTreeUnchanged(converged, snapshotTree(home));
443
+ }
444
+ finally {
445
+ rmSync(home, { recursive: true, force: true });
446
+ }
447
+ });
448
+ /** The identity state a corpus can start from: never migrated at all, or
449
+ * carrying the superseded per-store 003 output plus its completion marker. */
450
+ function seedConvergenceFixture(home, start) {
451
+ const userRoot = join(home, '.crouter');
452
+ const rootProfile = join(userRoot, 'profiles', 'root-00000000');
453
+ const project = join(home, 'conv-project');
454
+ const nestedOwner = join(project, 'pkg');
455
+ write(join(rootProfile, 'profile.json'), manifest('root', [{ path: project, memory: 'content' }]));
456
+ write(join(project, '.crouter', 'memory', 'guide.md'), CURRENT_DOC);
457
+ write(join(nestedOwner, '.crouter', 'memory', 'note.md'), CURRENT_DOC);
458
+ if (start === 'pre-003') {
459
+ // Nothing declares a namespace, so both documents answer under their own
460
+ // local names and the user store's links name them that way.
461
+ write(join(userRoot, 'memory', 'pointer.md'), `${CURRENT_DOC}\nSee [[guide]] and [[note]].\n`);
462
+ write(join(project, '.crouter', 'memory', 'INDEX.md'), `---\nkind: knowledge\n---\n# Conv Project\n`);
463
+ return;
464
+ }
465
+ // Exactly what the superseded migration wrote: a per-store declaration on the
466
+ // repository root, a synthetic identity-only index on the nested store whose
467
+ // namespace is its own bare directory name, the links it translated to those
468
+ // two per-store prefixes, and the old id in the completion marker.
469
+ write(join(userRoot, 'memory', 'pointer.md'), `${CURRENT_DOC}\nSee [[conv-project/guide]] and [[pkg/note]].\n`);
470
+ write(join(project, '.crouter', 'memory', 'INDEX.md'), `---\nnamespace: conv-project\nkind: knowledge\n---\n# Conv Project\n`);
471
+ write(join(nestedOwner, '.crouter', 'memory', 'INDEX.md'), supersededSyntheticIndex('pkg'));
472
+ write(markerPath(home), JSON.stringify([{ id: '003-workspace-qualified-memory-identity', lane: 'corpus' }]));
473
+ }
474
+ test('pre-003 and superseded-003 corpora converge to the same bytes and the same current marker', () => {
475
+ const preHome = fixtureHome('crtr-sys-migrate-conv-pre-');
476
+ const oldHome = fixtureHome('crtr-sys-migrate-conv-old-');
477
+ try {
478
+ seedConvergenceFixture(preHome, 'pre-003');
479
+ seedConvergenceFixture(oldHome, 'superseded-003');
480
+ const fromPre = migrateJson(preHome);
481
+ const fromOld = migrateJson(oldHome);
482
+ assert.deepEqual(fromPre.blockers, []);
483
+ assert.deepEqual(fromOld.blockers, []);
484
+ assert.deepEqual(readMarker(preHome), onDiskMigrationIdentity());
485
+ assert.deepEqual(readMarker(oldHome), onDiskMigrationIdentity());
486
+ // The superseded run's synthetic nested index is identity-only and is
487
+ // deleted, not emptied — so both corpora hold the same files.
488
+ assert.equal(existsSync(join(oldHome, 'conv-project', 'pkg', '.crouter', 'memory', 'INDEX.md')), false);
489
+ assertTreeUnchanged(relativeTree(preHome), relativeTree(oldHome));
490
+ // Both start states' links land on the one composed name: the local name the
491
+ // pre-003 corpus used and the bare per-store prefix the superseded one used
492
+ // are the SAME document, and it now answers under the repository.
493
+ assert.equal(readFileSync(join(preHome, '.crouter', 'memory', 'pointer.md'), 'utf8'), `${CURRENT_DOC}\nSee [[conv-project/guide]] and [[conv-project/pkg/note]].\n`);
494
+ // Both report the same identity facts even though they started differently,
495
+ // and each proves the move its own legacy state owed: the nested document
496
+ // answered at a bare local name pre-003 and at its own per-store declaration
497
+ // after the superseded run, and lands on the repository-composed name from
498
+ // either. The second run over that finished corpus is the convergence proof
499
+ // — nothing moved, so nothing is renamed or rewritten.
500
+ for (const [home, output, oldRule, nestedOldName] of [
501
+ [preHome, fromPre, 'pre-003', 'note'],
502
+ [oldHome, fromOld, 'per-store-declaration', 'pkg/note'],
503
+ ]) {
504
+ const project = join(home, 'conv-project');
505
+ assert.equal(repositoryRow(output, project).namespace, 'conv-project');
506
+ assert.equal(prefixRow(output, join(project, 'pkg')).effective_prefix, 'conv-project/pkg');
507
+ assert.equal(prefixRow(output, join(project, 'pkg')).old_rule, oldRule);
508
+ assert.equal(output.renames.some((row) => row.path === join(project, 'pkg', '.crouter', 'memory', 'note.md') &&
509
+ row.from === nestedOldName &&
510
+ row.to === 'conv-project/pkg/note'), true, JSON.stringify(output.renames));
511
+ const converged = snapshotTree(home);
512
+ const secondRun = migrateJson(home);
513
+ assert.equal(secondRun.changed, 0);
514
+ assert.deepEqual(secondRun.renames, []);
515
+ assert.deepEqual(secondRun.references, []);
516
+ assertTreeUnchanged(converged, snapshotTree(home));
517
+ }
518
+ assert.equal(prefixRow(fromOld, join(oldHome, 'conv-project', 'pkg')).nested_declaration_action, 'synthetic-index-removed');
519
+ assert.equal(prefixRow(fromPre, join(preHome, 'conv-project', 'pkg')).nested_declaration_action, 'none');
520
+ }
521
+ finally {
522
+ rmSync(preHome, { recursive: true, force: true });
523
+ rmSync(oldHome, { recursive: true, force: true });
524
+ }
525
+ });
526
+ test('a repository declaration is kept when declared, derived from the root guide, or created when no root index exists', () => {
527
+ const home = fixtureHome('crtr-sys-migrate-decisions-');
528
+ const rootProfile = join(home, '.crouter', 'profiles', 'root-00000000');
529
+ const kept = join(home, 'kept-project');
530
+ const derived = join(home, 'derived-project');
531
+ const created = join(home, 'created-project');
532
+ try {
533
+ write(join(rootProfile, 'profile.json'), manifest('root', [kept, derived, created].map((path) => ({ path, memory: 'content' }))));
534
+ write(join(kept, '.crouter', 'memory', 'INDEX.md'), `---\nnamespace: kept-ns\nkind: knowledge\n---\n# Kept\n`);
535
+ write(join(kept, '.crouter', 'memory', 'note.md'), CURRENT_DOC);
536
+ write(join(derived, '.crouter', 'memory', 'INDEX.md'), `---\nkind: knowledge\n---\n# Derived Title\n`);
537
+ // No root index at all: the declaration is created rather than edited.
538
+ write(join(created, '.crouter', 'memory', 'note.md'), CURRENT_DOC);
539
+ const output = migrateJson(home);
540
+ assert.deepEqual(output.blockers, []);
541
+ assert.deepEqual(output.repository_namespaces.map((row) => [row.repository_root, row.namespace, row.source, row.action]).sort(), [
542
+ [created, 'created-project', 'owner-basename', 'created'],
543
+ [derived, 'derived-title', 'root-guide-h1', 'kept'],
544
+ [kept, 'kept-ns', 'declared', 'kept'],
545
+ ].sort());
546
+ assert.equal(readFileSync(join(kept, '.crouter', 'memory', 'INDEX.md'), 'utf8').startsWith('---\nnamespace: kept-ns\n'), true, 'a valid declaration is never rewritten');
547
+ assert.equal(readFileSync(join(created, '.crouter', 'memory', 'INDEX.md'), 'utf8'), `---
548
+ namespace: "created-project"
549
+ kind: knowledge
550
+ when-and-why-to-read: When working in \`created-project\`, this knowledge should be read because it names the namespace every document in this repository answers under.
551
+ surfaces:
552
+ - on: workspace-open
553
+ at: content
554
+ - on: read
555
+ match: "./**"
556
+ at: content
557
+ ---
558
+
559
+ # created-project
560
+
561
+ This project's memory store. Every document here answers at \`created-project/<local name>\`.
562
+ `);
563
+ assert.equal(migrateJson(home).changed, 0);
564
+ }
565
+ finally {
566
+ rmSync(home, { recursive: true, force: true });
567
+ }
568
+ });
569
+ test('a git repository whose only stores are descendants gets its repository-root declaration created', () => {
570
+ const home = fixtureHome('crtr-sys-migrate-git-descendant-');
571
+ const rootProfile = join(home, '.crouter', 'profiles', 'root-00000000');
572
+ const repo = join(home, 'pkg-repo');
573
+ const nestedOwner = join(repo, 'pkg');
574
+ try {
575
+ write(join(rootProfile, 'profile.json'), manifest('root', [{ path: repo, memory: 'content' }]));
576
+ initRepo(repo);
577
+ write(join(nestedOwner, '.crouter', 'memory', 'note.md'), CURRENT_DOC);
578
+ const output = migrateJson(home);
579
+ assert.deepEqual(output.blockers, []);
580
+ assert.deepEqual(output.repository_namespaces, [
581
+ {
582
+ repository_root: repo,
583
+ repository_key: `git:${join(repo, '.git')}`,
584
+ declaration_path: join(repo, '.crouter', 'memory', 'INDEX.md'),
585
+ namespace: 'pkg-repo',
586
+ source: 'git-worktree',
587
+ action: 'created',
588
+ },
589
+ ]);
590
+ assert.deepEqual(output.store_prefixes.map((row) => [row.owner_dir, row.owner_relative_path, row.effective_prefix]), [[nestedOwner, 'pkg', 'pkg-repo/pkg']]);
591
+ assert.match(readFileSync(join(repo, '.crouter', 'memory', 'INDEX.md'), 'utf8'), /^---\nnamespace: "pkg-repo"\n/);
592
+ assert.equal(output.renames.some((row) => row.from === 'note' && row.to === 'pkg-repo/pkg/note'), true, JSON.stringify(output.renames));
593
+ assert.equal(migrateJson(home).changed, 0);
594
+ }
595
+ finally {
596
+ rmSync(home, { recursive: true, force: true });
597
+ }
598
+ });
599
+ test('linked worktrees agree on one declaration, and a linked root missing one is filled from it', () => {
600
+ for (const mode of ['agree', 'fill']) {
601
+ const home = fixtureHome(`crtr-sys-migrate-worktree-${mode}-`);
602
+ const rootProfile = join(home, '.crouter', 'profiles', 'root-00000000');
603
+ const main = join(home, 'shared-main');
604
+ const linked = join(home, 'shared-linked');
605
+ try {
606
+ write(join(rootProfile, 'profile.json'), manifest('root', [main, linked].map((path) => ({ path, memory: 'content' }))));
607
+ initRepo(main);
608
+ git(main, 'worktree', 'add', linked, '-b', 'linked');
609
+ // Written after the worktree exists, so each checkout carries its own
610
+ // untracked declaration rather than a copy of the other's.
611
+ write(join(main, '.crouter', 'memory', 'INDEX.md'), `---\nnamespace: shared-code\nkind: knowledge\n---\n# Shared\n`);
612
+ write(join(main, '.crouter', 'memory', 'note.md'), CURRENT_DOC);
613
+ write(join(linked, '.crouter', 'memory', 'INDEX.md'), mode === 'agree'
614
+ ? `---\nnamespace: shared-code\nkind: knowledge\n---\n# Shared\n`
615
+ : `---\nkind: knowledge\n---\n# Shared\n`);
616
+ const output = migrateJson(home);
617
+ assert.deepEqual(output.blockers, []);
618
+ const mainRow = repositoryRow(output, main);
619
+ const linkedRow = repositoryRow(output, linked);
620
+ // One repository key, two physical roots, one namespace.
621
+ assert.equal(mainRow.repository_key, `git:${join(main, '.git')}`);
622
+ assert.equal(linkedRow.repository_key, mainRow.repository_key);
623
+ assert.equal(mainRow.namespace, 'shared-code');
624
+ assert.equal(linkedRow.namespace, 'shared-code');
625
+ assert.equal(mainRow.action, 'kept');
626
+ assert.equal(linkedRow.action, mode === 'agree' ? 'kept' : 'filled-linked-root');
627
+ assert.match(readFileSync(join(linked, '.crouter', 'memory', 'INDEX.md'), 'utf8'), /^---\nnamespace: shared-code\n/);
628
+ assert.equal(migrateJson(home).changed, 0);
629
+ }
630
+ finally {
631
+ rmSync(home, { recursive: true, force: true });
632
+ }
633
+ }
634
+ });
635
+ test('linked worktree roots declaring different namespaces block the corpus', () => {
636
+ const home = fixtureHome('crtr-sys-migrate-worktree-conflict-');
637
+ const rootProfile = join(home, '.crouter', 'profiles', 'root-00000000');
638
+ const main = join(home, 'shared-main');
639
+ const linked = join(home, 'shared-linked');
640
+ try {
641
+ write(join(rootProfile, 'profile.json'), manifest('root', [main, linked].map((path) => ({ path, memory: 'content' }))));
642
+ initRepo(main);
643
+ git(main, 'worktree', 'add', linked, '-b', 'linked');
644
+ write(join(main, '.crouter', 'memory', 'INDEX.md'), `---\nnamespace: main-ns\nkind: knowledge\n---\n# Shared\n`);
645
+ write(join(linked, '.crouter', 'memory', 'INDEX.md'), `---\nnamespace: linked-ns\nkind: knowledge\n---\n# Shared\n`);
646
+ const before = snapshotTree(main);
647
+ const payload = migrateBlocked(home);
648
+ const blocker = payload.received.blockers.find((row) => /declare different namespaces/.test(row.message));
649
+ assert.ok(blocker, JSON.stringify(payload.received.blockers));
650
+ assert.equal(blocker.kind, 'namespace');
651
+ assert.deepEqual(blocker.paths?.sort(), [
652
+ join(linked, '.crouter', 'memory', 'INDEX.md'),
653
+ join(main, '.crouter', 'memory', 'INDEX.md'),
654
+ ].sort());
655
+ assert.equal(payload.received.corpus_phase, 'not-started');
656
+ assertTreeUnchanged(before, snapshotTree(main));
657
+ }
658
+ finally {
659
+ rmSync(home, { recursive: true, force: true });
660
+ }
661
+ });
662
+ test('a nested declaration is removed, an exact synthetic index is deleted, and a substantive one is kept', () => {
663
+ const home = fixtureHome('crtr-sys-migrate-nested-declarations-');
664
+ const rootProfile = join(home, '.crouter', 'profiles', 'root-00000000');
665
+ const project = join(home, 'nest-repo');
666
+ const syntheticOwner = join(project, 'syn');
667
+ const substantiveOwner = join(project, 'sub');
668
+ const syntheticIndex = join(syntheticOwner, '.crouter', 'memory', 'INDEX.md');
669
+ const substantiveIndex = join(substantiveOwner, '.crouter', 'memory', 'INDEX.md');
670
+ try {
671
+ write(join(rootProfile, 'profile.json'), manifest('root', [{ path: project, memory: 'content' }]));
672
+ write(join(project, '.crouter', 'memory', 'INDEX.md'), `---\nnamespace: nest-repo\nkind: knowledge\n---\n# Nest Repo\n`);
673
+ write(syntheticIndex, supersededSyntheticIndex('syn'));
674
+ write(join(syntheticOwner, '.crouter', 'memory', 'note.md'), CURRENT_DOC);
675
+ write(substantiveIndex, `---\nnamespace: sub\nkind: knowledge\n---\n# Sub package\n\nReal guidance a reader loses if this file is deleted.\n`);
676
+ const output = migrateJson(home);
677
+ assert.deepEqual(output.blockers, []);
678
+ assert.equal(prefixRow(output, syntheticOwner).nested_declaration_action, 'synthetic-index-removed');
679
+ assert.equal(prefixRow(output, substantiveOwner).nested_declaration_action, 'removed');
680
+ assert.equal(existsSync(syntheticIndex), false, 'the identity-only template is deleted, not emptied');
681
+ assert.equal(readFileSync(substantiveIndex, 'utf8'), `---\nkind: knowledge\n---\n# Sub package\n\nReal guidance a reader loses if this file is deleted.\n`);
682
+ assert.equal(output.renames.some((row) => row.from === 'syn/note' && row.to === 'nest-repo/syn/note'), true, JSON.stringify(output.renames));
683
+ assert.equal(output.renames.some((row) => row.path === substantiveIndex && row.from === 'sub' && row.to === 'nest-repo/sub'), true, JSON.stringify(output.renames));
684
+ assert.equal(migrateJson(home).changed, 0);
685
+ }
686
+ finally {
687
+ rmSync(home, { recursive: true, force: true });
688
+ }
689
+ });
690
+ test('a rootless repository takes its front door from the one legacy guide that owns it', () => {
691
+ const home = fixtureHome('crtr-sys-migrate-front-door-');
692
+ const rootProfile = join(home, '.crouter', 'profiles', 'root-00000000');
693
+ const project = join(home, 'fd-root');
694
+ const guide = join(project, '.crouter', 'memory', 'guide.md');
695
+ const rootIndex = join(project, '.crouter', 'memory', 'INDEX.md');
696
+ try {
697
+ write(join(rootProfile, 'profile.json'), manifest('root', [{ path: project, memory: 'content' }]));
698
+ write(guide, `---
699
+ kind: knowledge
700
+ name: project-guide
701
+ surfaces:
702
+ - on: workspace-open
703
+ at: content
704
+ - on: read
705
+ match: "./**"
706
+ at: content
707
+ ---
708
+ # FD Root guide
709
+
710
+ The authored body, preserved byte for byte.
711
+ `);
712
+ const output = migrateJson(home);
713
+ assert.deepEqual(output.blockers, []);
714
+ assert.equal(repositoryRow(output, project).action, 'created');
715
+ assert.equal(prefixRow(output, project).front_door_action, 'transferred-from:.crouter/memory/guide.md');
716
+ // The created root links the leaf's FINAL canonical name.
717
+ assert.equal(readFileSync(rootIndex, 'utf8'), `---
718
+ namespace: "fd-root"
719
+ kind: knowledge
720
+ when-and-why-to-read: When working in \`fd-root\`, this knowledge should be read because it names the namespace every document in this repository answers under.
721
+ surfaces:
722
+ - on: workspace-open
723
+ at: content
724
+ - on: read
725
+ match: "./**"
726
+ at: content
727
+ ---
728
+
729
+ # fd-root
730
+
731
+ This project's memory store. Every document here answers at \`fd-root/<local name>\`. Read [[fd-root/project-guide]] for the detailed project guide.
732
+ `);
733
+ // The leaf keeps its path, local name, read route, and body — only the
734
+ // workspace-open entry leaves.
735
+ assert.equal(readFileSync(guide, 'utf8'), `---
736
+ kind: knowledge
737
+ name: project-guide
738
+ surfaces:
739
+ - on: read
740
+ match: "./**"
741
+ at: content
742
+ ---
743
+ # FD Root guide
744
+
745
+ The authored body, preserved byte for byte.
746
+ `);
747
+ const converged = snapshotTree(home);
748
+ const second = migrateJson(home);
749
+ assert.equal(second.changed, 0);
750
+ assert.equal(prefixRow(second, project).front_door_action, 'kept-root');
751
+ assertTreeUnchanged(converged, snapshotTree(home));
752
+ }
753
+ finally {
754
+ rmSync(home, { recursive: true, force: true });
755
+ }
756
+ });
757
+ test('repository-relative ordering prefixes do not block migration and derive canonical owner paths', () => {
758
+ const home = fixtureHome('crtr-sys-migrate-ordering-prefix-');
759
+ const rootProfile = join(home, '.crouter', 'profiles', 'root-00000000');
760
+ const repository = join(home, 'ordered-repository');
761
+ const nestedOwner = join(repository, '01-api');
762
+ const rootIndex = join(repository, '.crouter', 'memory', 'INDEX.md');
763
+ const nestedDoc = join(nestedOwner, '.crouter', 'memory', 'guide.md');
764
+ try {
765
+ initRepo(repository);
766
+ write(join(rootProfile, 'profile.json'), manifest('root', [{ path: repository, memory: 'content' }]));
767
+ write(rootIndex, '---\nnamespace: acme\nkind: knowledge\n---\n# Repository\n');
768
+ write(nestedDoc, '---\nkind: knowledge\n---\n# API guide\n');
769
+ const output = migrateJson(home);
770
+ assert.deepEqual(output.blockers, []);
771
+ assert.equal(prefixRow(output, nestedOwner).owner_relative_path, 'api');
772
+ assert.equal(prefixRow(output, nestedOwner).effective_prefix, 'acme/api');
773
+ }
774
+ finally {
775
+ rmSync(home, { recursive: true, force: true });
776
+ }
777
+ });
778
+ test('the legacy front door is found in a nested store of the same repository, and a half-done transfer finishes', () => {
779
+ const home = fixtureHome('crtr-sys-migrate-front-door-nested-');
780
+ const rootProfile = join(home, '.crouter', 'profiles', 'root-00000000');
781
+ const project = join(home, 'fd-nested');
782
+ const nestedOwner = join(project, 'pkg');
783
+ const rootIndex = join(project, '.crouter', 'memory', 'INDEX.md');
784
+ const guide = join(nestedOwner, '.crouter', 'memory', 'guide.md');
785
+ const legacyNamed = join(nestedOwner, '.crouter', 'memory', 'deep-note.md');
786
+ try {
787
+ write(join(rootProfile, 'profile.json'), manifest('root', [{ path: project, memory: 'content' }]));
788
+ write(rootIndex, `---\nnamespace: fd-nested\nkind: knowledge\n---\n# FD Nested\n`);
789
+ write(guide, `---
790
+ kind: knowledge
791
+ surfaces:
792
+ - on: workspace-open
793
+ at: content
794
+ - on: read
795
+ match: "./**"
796
+ at: content
797
+ ---
798
+ # Nested guide
799
+ `);
800
+ // The nested store declares nothing under a root that already does — the
801
+ // shape a cut corpus is also in. This fully qualified `name:` is one this
802
+ // cut never leaves behind, so it proves the store is still pre-003 and its
803
+ // documents answered at bare local names.
804
+ write(legacyNamed, `---\nname: fd-nested/pkg/legacy-note\nkind: knowledge\n---\n# Legacy named\n`);
805
+ const output = migrateJson(home);
806
+ assert.deepEqual(output.blockers, []);
807
+ assert.equal(prefixRow(output, project).front_door_action, 'transferred-from:pkg/.crouter/memory/guide.md');
808
+ assert.equal(prefixRow(output, nestedOwner).front_door_action, 'none');
809
+ assert.equal(prefixRow(output, nestedOwner).old_rule, 'pre-003');
810
+ assert.equal(output.renames.some((row) => row.path === guide && row.from === 'guide' && row.to === 'fd-nested/pkg/guide'), true, JSON.stringify(output.renames));
811
+ // Its address never moved — only the spelling of the name that carried it.
812
+ assert.equal(output.renames.some((row) => row.path === legacyNamed), false, JSON.stringify(output.renames));
813
+ assert.equal(readFileSync(legacyNamed, 'utf8'), `---\nname: legacy-note\nkind: knowledge\n---\n# Legacy named\n`);
814
+ // The repository root now carries the door; the nested leaf keeps its own
815
+ // owner-anchored read route.
816
+ assert.match(readFileSync(rootIndex, 'utf8'), /surfaces:\n {2}- on: workspace-open\n {4}at: content\n/);
817
+ assert.equal(readFileSync(guide, 'utf8'), `---
818
+ kind: knowledge
819
+ surfaces:
820
+ - on: read
821
+ match: "./**"
822
+ at: content
823
+ ---
824
+ # Nested guide
825
+ `);
826
+ // Nothing legacy is left in the nested store, so the second run reads the
827
+ // root declaration it already answers under as its old prefix too.
828
+ const second = migrateJson(home);
829
+ assert.equal(second.changed, 0);
830
+ assert.deepEqual(second.renames, []);
831
+ assert.equal(prefixRow(second, nestedOwner).old_rule, 'per-store-declaration');
832
+ assert.equal(prefixRow(second, nestedOwner).old_prefix, 'fd-nested/pkg');
833
+ // Interruption shape: the root already owns the pair while the leaf still
834
+ // carries workspace-open. State is read from the two documents alone, so
835
+ // the run re-detects the transfer and finishes it.
836
+ write(guide, `---
837
+ kind: knowledge
838
+ surfaces:
839
+ - on: workspace-open
840
+ at: content
841
+ - on: read
842
+ match: "./**"
843
+ at: content
844
+ ---
845
+ # Nested guide
846
+ `);
847
+ const resumed = migrateJson(home);
848
+ assert.deepEqual(resumed.blockers, []);
849
+ assert.equal(prefixRow(resumed, project).front_door_action, 'transferred-from:pkg/.crouter/memory/guide.md');
850
+ assert.doesNotMatch(readFileSync(guide, 'utf8'), /workspace-open/);
851
+ }
852
+ finally {
853
+ rmSync(home, { recursive: true, force: true });
854
+ }
855
+ });
856
+ test('two legacy front doors, and a root workspace-open that is not at content, each block the corpus', () => {
857
+ for (const shape of ['two-leaves', 'root-predicate']) {
858
+ const home = fixtureHome(`crtr-sys-migrate-front-door-${shape}-`);
859
+ const rootProfile = join(home, '.crouter', 'profiles', 'root-00000000');
860
+ const project = join(home, 'fd-blocked');
861
+ const rootIndex = join(project, '.crouter', 'memory', 'INDEX.md');
862
+ const frontDoor = `surfaces:
863
+ - on: workspace-open
864
+ at: content
865
+ - on: read
866
+ match: "./**"
867
+ at: content`;
868
+ try {
869
+ write(join(rootProfile, 'profile.json'), manifest('root', [{ path: project, memory: 'content' }]));
870
+ if (shape === 'two-leaves') {
871
+ write(rootIndex, `---\nnamespace: fd-blocked\nkind: knowledge\n---\n# FD Blocked\n`);
872
+ write(join(project, '.crouter', 'memory', 'one.md'), `---\nkind: knowledge\n${frontDoor}\n---\n# One\n`);
873
+ write(join(project, 'pkg', '.crouter', 'memory', 'two.md'), `---\nkind: knowledge\n${frontDoor}\n---\n# Two\n`);
874
+ }
875
+ else {
876
+ write(rootIndex, `---\nnamespace: fd-blocked\nkind: knowledge\nsurfaces:\n - on: workspace-open\n at: preview\n---\n# FD Blocked\n`);
877
+ write(join(project, '.crouter', 'memory', 'note.md'), CURRENT_DOC);
878
+ }
879
+ const before = snapshotTree(project);
880
+ const payload = migrateBlocked(home);
881
+ const blocker = payload.received.blockers.find((row) => row.kind === 'front-door');
882
+ assert.ok(blocker, JSON.stringify(payload.received.blockers));
883
+ if (shape === 'two-leaves') {
884
+ assert.match(blocker.message, /more than one non-root document carrying the complete workspace front door/);
885
+ assert.deepEqual(blocker.paths?.sort(), [
886
+ join(project, '.crouter', 'memory', 'one.md'),
887
+ join(project, 'pkg', '.crouter', 'memory', 'two.md'),
888
+ ].sort());
889
+ }
890
+ else {
891
+ assert.match(blocker.message, /workspace-open route that migration cannot make authoritative/);
892
+ assert.deepEqual(blocker.paths, [rootIndex]);
893
+ }
894
+ assert.equal(payload.received.corpus_phase, 'not-started');
895
+ assert.equal(existsSync(markerPath(home)), false);
896
+ assertTreeUnchanged(before, snapshotTree(project));
897
+ }
898
+ finally {
899
+ rmSync(home, { recursive: true, force: true });
900
+ }
901
+ }
902
+ });
903
+ test('a leaf carrying both the legacy front door and a translated route keeps every retained authored byte', () => {
904
+ const home = fixtureHome('crtr-sys-migrate-front-door-compose-');
905
+ const rootProfile = join(home, '.crouter', 'profiles', 'root-00000000');
906
+ const project = join(home, 'compose-repo');
907
+ const workspaceDoc = join(project, '.crouter', 'memory', 'workspace.md');
908
+ try {
909
+ write(join(rootProfile, 'profile.json'), manifest('root', [{ path: project, memory: 'content' }]));
910
+ // Pre-003 identity: nothing declares a namespace, so `topics/*` still names
911
+ // the store's own documents and must be translated in the same pass that
912
+ // strips this document's workspace-open entry.
913
+ write(join(project, '.crouter', 'memory', 'INDEX.md'), `---\nkind: knowledge\n---\n# Compose Repo\n`);
914
+ write(join(project, '.crouter', 'memory', 'topics', 'one.md'), CURRENT_DOC);
915
+ write(join(project, '.crouter', 'memory', 'topics', 'two.md'), CURRENT_DOC);
916
+ write(join(project, '.crouter', 'memory', 'guides', 'setup.md'), CURRENT_DOC);
917
+ write(workspaceDoc, `---
918
+ kind: knowledge
919
+ name: project-guide
920
+ surfaces:
921
+ - on: workspace-open
922
+ at: content
923
+ - on: read
924
+ match: "./**"
925
+ at: content
926
+ - on: memory-read
927
+ match:
928
+ - topics/*
929
+ - guides/*
930
+ at: content
931
+ ---
932
+ # Compose workspace
933
+
934
+ Body bytes that must survive both transforms.
935
+ `);
936
+ const output = migrateJson(home);
937
+ assert.deepEqual(output.blockers, []);
938
+ assert.equal(prefixRow(output, project).front_door_action, 'transferred-from:.crouter/memory/workspace.md');
939
+ // The front-door removal and the consumer route rewrite compose: the
940
+ // workspace-open entry is gone, the route is translated, the read route and
941
+ // the body are untouched.
942
+ assert.equal(readFileSync(workspaceDoc, 'utf8'), `---
943
+ kind: knowledge
944
+ name: project-guide
945
+ surfaces:
946
+ - on: read
947
+ match: "./**"
948
+ at: content
949
+ - on: memory-read
950
+ match:
951
+ ["compose-repo/topics/*", "compose-repo/guides/*"]
952
+ at: content
953
+ ---
954
+ # Compose workspace
955
+
956
+ Body bytes that must survive both transforms.
957
+ `);
958
+ // Those bytes are YAML the runtime can read, not just text that looks right.
959
+ assert.deepEqual(parseYaml(readFileSync(workspaceDoc, 'utf8').split('---\n')[1] ?? '')?.surfaces, [
960
+ { on: 'read', match: './**', at: 'content' },
961
+ { on: 'memory-read', match: ['compose-repo/topics/*', 'compose-repo/guides/*'], at: 'content' },
962
+ ]);
963
+ for (const [from, to] of [['topics/*', 'compose-repo/topics/*'], ['guides/*', 'compose-repo/guides/*']]) {
964
+ assert.equal(output.references.some((row) => row.path === workspaceDoc && row.kind === 'memory-read' && row.from === from && row.to === to), true, JSON.stringify(output.references));
965
+ }
966
+ assert.equal(migrateJson(home).changed, 0);
967
+ }
968
+ finally {
969
+ rmSync(home, { recursive: true, force: true });
970
+ }
971
+ });
972
+ test('an unreadable profile manifest blocks forced completion and remains in skipped output', () => {
973
+ const home = fixtureHome('crtr-sys-migrate-unreadable-profile-');
974
+ const userRoot = join(home, '.crouter');
975
+ const profileId = 'bad-deadbeef';
976
+ const profilePath = join(userRoot, 'profiles', profileId, 'profile.json');
977
+ try {
978
+ write(profilePath, '{not json');
979
+ write(markerPath(home), JSON.stringify(onDiskMigrationIdentity()));
980
+ const payload = migrateBlocked(home);
981
+ assert.equal(payload.received.blockers.length, 1);
982
+ assert.equal(payload.received.blockers[0]?.kind, 'parse-failure');
983
+ assert.equal(payload.received.blockers[0]?.path, profilePath);
984
+ assert.match(payload.received.blockers[0]?.message ?? '', /profile manifest is not valid JSON/);
985
+ assert.match(payload.received.blockers[0]?.message ?? '', /crtr sys migrate/);
986
+ assert.equal(payload.received.skipped.length, 1);
987
+ assert.equal(payload.received.skipped[0]?.store, join(userRoot, 'profiles'));
988
+ assert.equal(payload.received.skipped[0]?.relPath, `${profileId}/profile.json`);
989
+ assert.ok(payload.received.skipped[0]?.error);
990
+ assert.match(payload.next, /crtr sys migrate/);
991
+ assert.equal(existsSync(markerPath(home)), false);
992
+ }
993
+ finally {
994
+ rmSync(home, { recursive: true, force: true });
995
+ }
996
+ });
997
+ test('sys migrate blocks the whole corpus when one link names different documents per target, and writes nothing', () => {
998
+ const home = fixtureHome('crtr-sys-migrate-divergent-');
999
+ const userRoot = join(home, '.crouter');
1000
+ const rootProfile = join(userRoot, 'profiles', 'root-00000000');
1001
+ const alpha = join(home, 'alpha-project');
1002
+ const beta = join(home, 'beta-project');
1003
+ try {
1004
+ // Already current for the manifest and document lanes: the only thing this
1005
+ // run could write is the corpus cut, so "nothing was written" is provable
1006
+ // byte for byte.
1007
+ write(join(rootProfile, 'profile.json'), manifest('root', [{ path: alpha, memory: 'content' }, { path: beta, memory: 'content' }]));
1008
+ write(join(userRoot, 'memory', 'pointer.md'), `${CURRENT_DOC}\nSee [[guide]].\n`);
1009
+ for (const [project, title] of [[alpha, 'Alpha Project'], [beta, 'Beta Project']]) {
1010
+ write(join(project, '.crouter', 'memory', 'INDEX.md'), `---\nkind: knowledge\n---\n# ${title}\n`);
1011
+ write(join(project, '.crouter', 'memory', 'guide.md'), CURRENT_DOC);
1012
+ }
1013
+ const before = snapshotTree(home);
1014
+ const payload = migrateBlocked(home);
1015
+ assert.match(payload.message, /corpus migration blocked by 1 finding\(s\) — nothing was written/);
1016
+ assert.equal(payload.received.blockers.length, 1);
1017
+ assert.equal(payload.received.blockers[0]?.kind, 'divergent-reference');
1018
+ assert.equal(payload.received.blockers[0]?.name, 'guide');
1019
+ assert.match(payload.received.blockers[0]?.message ?? '', /different documents depending on the target view/);
1020
+ // The claim in the message is the claim in the payload: nothing on disk.
1021
+ assert.equal(payload.received.corpus_phase, 'not-started');
1022
+ assert.equal(payload.received.changed, 0);
1023
+ assert.deepEqual(payload.received.wrote, {
1024
+ profile_manifests: [],
1025
+ documents: [],
1026
+ corpus_consumers: [],
1027
+ corpus_identities: [],
1028
+ });
1029
+ assert.deepEqual(payload.received.applied, []);
1030
+ assert.equal(existsSync(markerPath(home)), false);
1031
+ assertTreeUnchanged(before, snapshotTree(home));
1032
+ // A blocked planner stays inspectable: the failure payload carries the same
1033
+ // report body a converged run returns, so the plan that WOULD have been
1034
+ // written can be read straight off the blocker.
1035
+ assertReportInvariants(payload.received);
1036
+ assert.equal(payload.received.dryRun, false);
1037
+ assert.ok(payload.received.discovery_roots.length > 0, JSON.stringify(payload.received.discovery_roots));
1038
+ assert.equal(payload.received.stores, payload.received.discovered_stores.length);
1039
+ for (const project of [alpha, beta]) {
1040
+ const store = join(project, '.crouter', 'memory');
1041
+ assert.ok(payload.received.discovered_stores.some((row) => row.store === store), JSON.stringify(payload.received.discovered_stores));
1042
+ assert.ok(payload.received.discovered_targets.some((row) => row.stores.includes(store)));
1043
+ assert.equal(repositoryRow(payload.received, project).namespace, relative(home, project));
1044
+ assert.equal(prefixRow(payload.received, project).effective_prefix, relative(home, project));
1045
+ assert.ok(payload.received.renames.some((row) => row.store_root === store && row.to.startsWith(`${relative(home, project)}/`)), JSON.stringify(payload.received.renames));
1046
+ }
1047
+ assert.ok(Array.isArray(payload.received.references));
1048
+ assert.equal(payload.received.node_metadata.available, true);
1049
+ }
1050
+ finally {
1051
+ rmSync(home, { recursive: true, force: true });
1052
+ }
1053
+ });
1054
+ test('a corpus blocker never claims nothing was written when the earlier lanes already wrote', () => {
1055
+ const home = fixtureHome('crtr-sys-migrate-earlier-writes-');
1056
+ const userRoot = join(home, '.crouter');
1057
+ const rootProfile = join(userRoot, 'profiles', 'root-00000000');
1058
+ const alpha = join(home, 'alpha-project');
1059
+ const beta = join(home, 'beta-project');
1060
+ const userDoc = join(userRoot, 'memory', 'pointer.md');
1061
+ try {
1062
+ // A PRE-migration manifest and an old-format document, so the manifest and
1063
+ // convergent lanes both write before the corpus lane plans — and then one
1064
+ // `[[guide]]` naming a different document per target blocks that lane.
1065
+ write(join(rootProfile, 'profile.json'), manifest('root', [alpha, beta]));
1066
+ write(userDoc, `${OLD_DOC}\nSee [[guide]].\n`);
1067
+ for (const [project, title] of [[alpha, 'Alpha Project'], [beta, 'Beta Project']]) {
1068
+ write(join(project, '.crouter', 'memory', 'INDEX.md'), `---\nkind: knowledge\n---\n# ${title}\n`);
1069
+ write(join(project, '.crouter', 'memory', 'guide.md'), CURRENT_DOC);
1070
+ }
1071
+ const projectsBefore = snapshotTree(alpha);
1072
+ const payload = migrateBlocked(home);
1073
+ assert.match(payload.message, /corpus migration blocked by 1 finding\(s\) before any corpus write/);
1074
+ assert.match(payload.message, /the corpus is unchanged, but 2 earlier manifest\/document rewrite\(s\) are already on disk/);
1075
+ assert.equal(payload.received.corpus_phase, 'not-started');
1076
+ assert.equal(payload.received.changed, 2);
1077
+ assert.deepEqual(payload.received.wrote.profile_manifests, [join(rootProfile, 'profile.json')]);
1078
+ assert.deepEqual(payload.received.wrote.documents, [userDoc]);
1079
+ assert.deepEqual(payload.received.wrote.corpus_consumers, []);
1080
+ assert.deepEqual(payload.received.wrote.corpus_identities, []);
1081
+ // Every file the payload names as written really did change, and every file
1082
+ // only the corpus lane could have touched did not.
1083
+ assert.doesNotMatch(readFileSync(userDoc, 'utf8'), /system-prompt-visibility/);
1084
+ assert.match(readFileSync(userDoc, 'utf8'), /See \[\[guide\]\]\.\n/);
1085
+ assert.deepEqual(JSON.parse(readFileSync(join(rootProfile, 'profile.json'), 'utf8')).projects, [{ path: alpha, memory: 'preview' }, { path: beta, memory: 'preview' }]);
1086
+ assertTreeUnchanged(projectsBefore, snapshotTree(alpha));
1087
+ }
1088
+ finally {
1089
+ rmSync(home, { recursive: true, force: true });
1090
+ }
1091
+ });
1092
+ test('a verification failure after both write phases reports the rewrites that are on disk', () => {
1093
+ const home = fixtureHome('crtr-sys-migrate-verification-');
1094
+ const userRoot = join(home, '.crouter');
1095
+ const rootProfile = join(userRoot, 'profiles', 'root-00000000');
1096
+ const project = join(home, 'verify-project');
1097
+ const projectIndex = join(project, '.crouter', 'memory', 'INDEX.md');
1098
+ const strayDoc = join(project, '.crouter', 'memory', 'stray.md');
1099
+ try {
1100
+ write(join(rootProfile, 'profile.json'), manifest('root', [{ path: project, memory: 'content' }]));
1101
+ write(projectIndex, `---\nkind: knowledge\n---\n# Verify Project\n`);
1102
+ // `namespace:` on a NON-root document: not an identity field this lane
1103
+ // rewrites, so the plan is clean and the writes land — and the post-write
1104
+ // lint against file truth then refuses the store.
1105
+ write(strayDoc, `---\nkind: knowledge\nnamespace: verify-project\n---\n# Stray declaration\n`);
1106
+ const payload = migrateBlocked(home);
1107
+ assert.match(payload.message, /corpus migration verification failed with 1 finding\(s\) after both write phases/);
1108
+ assert.match(payload.message, /identity rewrite\(s\) are on disk and are NOT rolled back/);
1109
+ assert.equal(payload.received.blockers[0]?.kind, 'verification');
1110
+ assert.match(payload.received.blockers[0]?.message ?? '', /namespace-placement/);
1111
+ assert.equal(payload.received.corpus_phase, 'written');
1112
+ assert.deepEqual(payload.received.wrote.corpus_identities, [projectIndex]);
1113
+ assert.equal(payload.received.changed, 1);
1114
+ assert.equal(payload.received.applied.some((row) => row.lane === 'corpus' && row.files.includes(projectIndex)), true);
1115
+ assert.match(payload.next, /re-run `crtr sys migrate`/);
1116
+ assert.equal(existsSync(markerPath(home)), false);
1117
+ // The inventory is true of the bytes: the declaration and the repository's
1118
+ // front door really are on disk.
1119
+ assert.equal(readFileSync(projectIndex, 'utf8'), `---
1120
+ namespace: verify-project
1121
+ kind: knowledge
1122
+ surfaces:
1123
+ - on: workspace-open
1124
+ at: content
1125
+ - on: read
1126
+ match: ./**
1127
+ at: content
1128
+ ---
1129
+ # Verify Project
1130
+ `);
1131
+ }
1132
+ finally {
1133
+ rmSync(home, { recursive: true, force: true });
1134
+ }
1135
+ });
1136
+ test('a nested store reached only through a node target inherits the ancestor repository declaration', () => {
1137
+ const home = fixtureHome('crtr-sys-migrate-node-nested-');
1138
+ const userRoot = join(home, '.crouter');
1139
+ const rootProfile = join(userRoot, 'profiles', 'root-00000000');
1140
+ const nodeId = 'node-bbbb2222';
1141
+ const nodeStore = join(userRoot, 'canvas', 'nodes', nodeId, 'context', 'memory');
1142
+ const nodeProject = join(home, 'node-cwd-project');
1143
+ const nestedOwner = join(nodeProject, 'nested');
1144
+ try {
1145
+ // No project in any manifest and none above the cwd: the node's durable
1146
+ // metadata is the ONLY path to this project root.
1147
+ write(join(rootProfile, 'profile.json'), manifest('root', []));
1148
+ write(join(userRoot, 'canvas', 'nodes', nodeId, 'meta.json'), JSON.stringify({ node_id: nodeId, cwd: nodeProject, profile_id: 'root-00000000', kind: 'developer' }));
1149
+ write(join(nodeStore, 'note.md'), CURRENT_DOC);
1150
+ write(join(nodeProject, '.crouter', 'memory', 'INDEX.md'), `---\nkind: knowledge\n---\n# Node Cwd Project\n`);
1151
+ write(join(nestedOwner, '.crouter', 'memory', 'topic.md'), CURRENT_DOC);
1152
+ const output = migrateJson(home, { dryRun: true });
1153
+ const storeRoots = output.discovered_stores.map((row) => row.store);
1154
+ assert.equal(storeRoots.includes(join(nodeProject, '.crouter', 'memory')), true, JSON.stringify(storeRoots));
1155
+ assert.equal(storeRoots.includes(join(nestedOwner, '.crouter', 'memory')), true, JSON.stringify(storeRoots));
1156
+ // The nested owner takes the ancestor repository's namespace composed with
1157
+ // its own path — and declares nothing of its own.
1158
+ assert.deepEqual(output.repository_namespaces.map((row) => row.repository_root), [nodeProject], JSON.stringify(output.repository_namespaces));
1159
+ const nested = prefixRow(output, nestedOwner);
1160
+ assert.equal(nested.repository_root, nodeProject);
1161
+ assert.equal(nested.owner_relative_path, 'nested');
1162
+ assert.equal(nested.effective_prefix, 'node-cwd-project/nested');
1163
+ assert.equal(nested.nested_declaration_action, 'none');
1164
+ assert.equal(nested.front_door_action, 'none');
1165
+ assert.equal(output.discovered_targets.some((row) => row.kind === 'project' && row.origin === nestedOwner), true);
1166
+ }
1167
+ finally {
1168
+ rmSync(home, { recursive: true, force: true });
1169
+ }
1170
+ });
1171
+ test('a memory-read route translates through an INDEX collapse and a root-plus-descendant split', () => {
1172
+ const home = fixtureHome('crtr-sys-migrate-routes-');
1173
+ const userRoot = join(home, '.crouter');
1174
+ const rootProfile = join(userRoot, 'profiles', 'root-00000000');
1175
+ const project = join(home, 'route-project');
1176
+ const carrier = join(project, '.crouter', 'memory', 'carrier.md');
1177
+ try {
1178
+ write(join(rootProfile, 'profile.json'), manifest('root', [{ path: project, memory: 'content' }]));
1179
+ write(join(project, '.crouter', 'memory', 'INDEX.md'), `---\nkind: knowledge\n---\n# Route Project\n`);
1180
+ // `topics/INDEX` collapses onto `route-project/topics`, and `topics/**`
1181
+ // named that same directory document plus its leaf — post-cut the leaf is
1182
+ // under `route-project/topics/**` and the directory document is not, so the
1183
+ // one entry splits into the exact pair.
1184
+ write(join(project, '.crouter', 'memory', 'topics', 'INDEX.md'), CURRENT_DOC);
1185
+ write(join(project, '.crouter', 'memory', 'topics', 'one.md'), CURRENT_DOC);
1186
+ write(carrier, `---
1187
+ kind: knowledge
1188
+ surfaces:
1189
+ - on: memory-read
1190
+ match: topics/INDEX
1191
+ at: content
1192
+ - on: memory-read
1193
+ match: topics/**
1194
+ at: content
1195
+ ---
1196
+ # Carrier
1197
+ `);
1198
+ const output = migrateJson(home);
1199
+ assert.deepEqual(output.blockers, []);
1200
+ assert.equal(readFileSync(carrier, 'utf8'), `---
1201
+ kind: knowledge
1202
+ surfaces:
1203
+ - on: memory-read
1204
+ match: "route-project/topics"
1205
+ at: content
1206
+ - on: memory-read
1207
+ match: ["route-project/topics/**", "route-project/topics"]
1208
+ at: content
1209
+ ---
1210
+ # Carrier
1211
+ `);
1212
+ assert.equal(output.references.some((row) => row.kind === 'memory-read' && row.from === 'topics/INDEX' && row.to === 'route-project/topics'), true, JSON.stringify(output.references));
1213
+ assert.equal(output.references.some((row) => row.kind === 'memory-read' &&
1214
+ row.from === 'topics/**' &&
1215
+ row.to === 'route-project/topics/**, route-project/topics'), true, JSON.stringify(output.references));
1216
+ // A second run over the translated routes plans nothing.
1217
+ const second = migrateJson(home);
1218
+ assert.equal(second.changed, 0);
1219
+ assert.deepEqual(second.references, []);
102
1220
  }
103
1221
  finally {
104
1222
  rmSync(home, { recursive: true, force: true });