@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
@@ -6,25 +6,53 @@ class FakeSocket extends EventEmitter {
6
6
  pings = 0;
7
7
  terminated = 0;
8
8
  ping() { this.pings += 1; }
9
- terminate() { this.terminated += 1; }
9
+ terminate() {
10
+ this.terminated += 1;
11
+ this.emit('terminated');
12
+ }
10
13
  }
11
14
  function sleep(ms) {
12
15
  return new Promise((resolve) => setTimeout(resolve, ms));
13
16
  }
14
- test('remote attach heartbeat pings an idle viewer and keeps a ponging observer alive', async () => {
17
+ function waitForTermination(ws, timeoutMs = 1_000) {
18
+ return new Promise((resolve, reject) => {
19
+ const timeout = setTimeout(() => reject(new Error('heartbeat did not terminate the peer')), timeoutMs);
20
+ timeout.unref();
21
+ ws.once('terminated', () => {
22
+ clearTimeout(timeout);
23
+ resolve();
24
+ });
25
+ });
26
+ }
27
+ test('a quiet attach puts nothing on the wire', async () => {
28
+ // Regression: an unconditional 25s ping made a hosted guest's connection never
29
+ // idle, so the sandbox never suspended and whole fleets billed for 15h awake.
30
+ const ws = new FakeSocket();
31
+ // The first timer tick is deliberately after the quiet window, so this test
32
+ // observes the contract without depending on scheduler timing.
33
+ const heartbeat = startRemoteAttachHeartbeat(ws, 20, 15);
34
+ await sleep(40);
35
+ assert.equal(ws.pings, 0, 'a quiet attach sends no heartbeat after its quiet window');
36
+ assert.equal(ws.terminated, 0, 'a quiet attach is left open, never terminated by the heartbeat');
37
+ heartbeat.stop();
38
+ });
39
+ test('relayed traffic arms the heartbeat and a pong keeps a relaying observer alive', async () => {
15
40
  const ws = new FakeSocket();
16
- const stop = startRemoteAttachHeartbeat(ws, 20);
41
+ const heartbeat = startRemoteAttachHeartbeat(ws, 20, 10_000);
42
+ heartbeat.noteActivity();
17
43
  await sleep(25);
18
- assert.equal(ws.pings, 1, 'an idle attach receives a WebSocket ping before proxy idle timeout');
44
+ assert.equal(ws.pings, 1, 'a relaying attach is pinged across a gap between frames');
19
45
  ws.emit('pong');
20
46
  await sleep(10);
21
47
  assert.equal(ws.terminated, 0, 'a pong renews the heartbeat without terminating the observer');
22
- stop();
48
+ heartbeat.stop();
23
49
  });
24
- test('remote attach heartbeat drops only an unresponsive observer socket', async () => {
50
+ test('an unresponsive peer is dropped only while the relay is active', async () => {
25
51
  const ws = new FakeSocket();
26
- const stop = startRemoteAttachHeartbeat(ws, 5);
27
- await sleep(16);
52
+ const heartbeat = startRemoteAttachHeartbeat(ws, 5, 10_000);
53
+ const terminated = waitForTermination(ws);
54
+ heartbeat.noteActivity();
55
+ await terminated;
28
56
  assert.equal(ws.terminated, 1, 'two missed heartbeat intervals terminate the detached viewer');
29
- stop();
57
+ heartbeat.stop();
30
58
  });
@@ -1,11 +1,42 @@
1
1
  import type { IncomingMessage } from 'node:http';
2
2
  import type { Duplex } from 'node:stream';
3
3
  import { type WebSocket } from 'ws';
4
- /** Keep an otherwise quiet observer connection alive through common 60s proxy
5
- * idle limits. A viewer only observes a broker, so a missed heartbeat may close
6
- * this socket but must never affect the broker or its in-flight work. */
4
+ /** Keep a RELAYING connection alive across a gap between frames, through common
5
+ * 60s proxy idle limits. A viewer only observes a broker, so a missed heartbeat
6
+ * may close this socket but must never affect the broker or its in-flight work. */
7
7
  export declare const REMOTE_ATTACH_HEARTBEAT_MS = 25000;
8
- export declare function startRemoteAttachHeartbeat(ws: WebSocket, intervalMs?: number): () => void;
8
+ /** How long after the last relayed frame the heartbeat keeps pinging. Past this,
9
+ * the attach is carrying nothing — no viewer output, no streaming node — and the
10
+ * heartbeat goes silent so the network path underneath can reap the connection.
11
+ *
12
+ * This matters far beyond a socket: on a hosted guest (Blaxel) a connection that
13
+ * is pinged every 25s is never idle, so the host never suspends the sandbox. An
14
+ * unconditional heartbeat held whole fleets awake for 15h at a time. A drop on a
15
+ * quiet attach is a normal close the remote peer re-attaches from, and real work
16
+ * is anchored by its own keep-alive, not by this ping.
17
+ *
18
+ * The clock starts when the attach OPENS, not at its first relayed frame: a
19
+ * never-relaying attach still gets pings until this window elapses. The welcome
20
+ * frame lands in milliseconds, so this only pads the tail of the very first
21
+ * window.
22
+ *
23
+ * ROLLOUT ORDER — this half is only half the answer, and shipping it FIRST is
24
+ * strictly worse than shipping nothing. The host that owns the attach must also
25
+ * let go of a quiet one; Northlight Core does that at its own 60s quiesce
26
+ * (`CROUTER_FOLLOW_QUIESCE_MS`). Against a host that holds the attach forever,
27
+ * going quiet here only moves the drop to the network path, the host reports a
28
+ * detach, its client reconnects, and the box wakes again — the same pin, now
29
+ * with a full re-snapshot every couple of minutes. Ship the host's quiesce
30
+ * first, then roll the guest image. */
31
+ export declare const REMOTE_ATTACH_QUIET_MS = 60000;
32
+ export interface RemoteAttachHeartbeat {
33
+ /** Stop the heartbeat permanently (teardown). Idempotent. */
34
+ stop: () => void;
35
+ /** Record a relayed frame in either direction — this is what keeps the
36
+ * heartbeat armed, and what re-arms it after a quiet stretch. */
37
+ noteActivity: () => void;
38
+ }
39
+ export declare function startRemoteAttachHeartbeat(ws: WebSocket, intervalMs?: number, quietMs?: number): RemoteAttachHeartbeat;
9
40
  /** Handle the HTTP upgrade for `GET /v1/nodes/{id}/attach`. A-9 matches the
10
41
  * route, extracts `{id}`, and calls this with the raw upgrade triplet; this
11
42
  * owns the WS handshake (its own noServer `wss` above) AND the spec §5.3
@@ -33,13 +33,38 @@ import { probeViewSocket, waitForBrokerViewSocket } from '../../core/runtime/vie
33
33
  * client cap) so a remote peer cannot buffer 256MiB before the broker's 24MiB
34
34
  * decoder rejects the message. */
35
35
  const wss = new WebSocketServer({ noServer: true, maxPayload: BROKER_READ_CAPS.maxLineBytes });
36
- /** Keep an otherwise quiet observer connection alive through common 60s proxy
37
- * idle limits. A viewer only observes a broker, so a missed heartbeat may close
38
- * this socket but must never affect the broker or its in-flight work. */
36
+ /** Keep a RELAYING connection alive across a gap between frames, through common
37
+ * 60s proxy idle limits. A viewer only observes a broker, so a missed heartbeat
38
+ * may close this socket but must never affect the broker or its in-flight work. */
39
39
  export const REMOTE_ATTACH_HEARTBEAT_MS = 25_000;
40
- export function startRemoteAttachHeartbeat(ws, intervalMs = REMOTE_ATTACH_HEARTBEAT_MS) {
40
+ /** How long after the last relayed frame the heartbeat keeps pinging. Past this,
41
+ * the attach is carrying nothing — no viewer output, no streaming node — and the
42
+ * heartbeat goes silent so the network path underneath can reap the connection.
43
+ *
44
+ * This matters far beyond a socket: on a hosted guest (Blaxel) a connection that
45
+ * is pinged every 25s is never idle, so the host never suspends the sandbox. An
46
+ * unconditional heartbeat held whole fleets awake for 15h at a time. A drop on a
47
+ * quiet attach is a normal close the remote peer re-attaches from, and real work
48
+ * is anchored by its own keep-alive, not by this ping.
49
+ *
50
+ * The clock starts when the attach OPENS, not at its first relayed frame: a
51
+ * never-relaying attach still gets pings until this window elapses. The welcome
52
+ * frame lands in milliseconds, so this only pads the tail of the very first
53
+ * window.
54
+ *
55
+ * ROLLOUT ORDER — this half is only half the answer, and shipping it FIRST is
56
+ * strictly worse than shipping nothing. The host that owns the attach must also
57
+ * let go of a quiet one; Northlight Core does that at its own 60s quiesce
58
+ * (`CROUTER_FOLLOW_QUIESCE_MS`). Against a host that holds the attach forever,
59
+ * going quiet here only moves the drop to the network path, the host reports a
60
+ * detach, its client reconnects, and the box wakes again — the same pin, now
61
+ * with a full re-snapshot every couple of minutes. Ship the host's quiesce
62
+ * first, then roll the guest image. */
63
+ export const REMOTE_ATTACH_QUIET_MS = 60_000;
64
+ export function startRemoteAttachHeartbeat(ws, intervalMs = REMOTE_ATTACH_HEARTBEAT_MS, quietMs = REMOTE_ATTACH_QUIET_MS) {
41
65
  let alive = true;
42
66
  let stopped = false;
67
+ let lastActivityAt = Date.now();
43
68
  const acknowledge = () => { alive = true; };
44
69
  const stop = () => {
45
70
  if (stopped)
@@ -50,6 +75,13 @@ export function startRemoteAttachHeartbeat(ws, intervalMs = REMOTE_ATTACH_HEARTB
50
75
  };
51
76
  ws.on('pong', acknowledge);
52
77
  const timer = setInterval(() => {
78
+ if (Date.now() - lastActivityAt > quietMs) {
79
+ // Nothing is flowing: send no ping and run no liveness check, so an idle
80
+ // attach puts zero packets on the wire. A dead peer here is closed by the
81
+ // network path or by teardown, not by us.
82
+ alive = true;
83
+ return;
84
+ }
53
85
  if (!alive) {
54
86
  stop();
55
87
  ws.terminate();
@@ -65,7 +97,15 @@ export function startRemoteAttachHeartbeat(ws, intervalMs = REMOTE_ATTACH_HEARTB
65
97
  }
66
98
  }, intervalMs);
67
99
  timer.unref();
68
- return stop;
100
+ return {
101
+ stop,
102
+ // Frames are not liveness evidence: only a pong proves the peer is there.
103
+ // Marking a relayed frame alive would disable dead-peer detection for
104
+ // exactly as long as a turn streams — the one stretch it is for.
105
+ noteActivity: () => {
106
+ lastActivityAt = Date.now();
107
+ },
108
+ };
69
109
  }
70
110
  /** A WS close reason is capped at 123 UTF-8 bytes by the protocol. */
71
111
  function clampReason(reason) {
@@ -140,7 +180,7 @@ function bridgeConnection(ws, nodeId) {
140
180
  const decoder = new FrameDecoder(CLIENT_READ_CAPS);
141
181
  let closed = false;
142
182
  let viewerFaultRecorded = false;
143
- let stopHeartbeat;
183
+ let heartbeat;
144
184
  socket.on('connect', () => {
145
185
  clearFault(nodeId, { link: 'relay↔broker' });
146
186
  });
@@ -148,7 +188,7 @@ function bridgeConnection(ws, nodeId) {
148
188
  if (closed)
149
189
  return;
150
190
  closed = true;
151
- stopHeartbeat?.();
191
+ heartbeat?.stop();
152
192
  if (!socket.destroyed) {
153
193
  try {
154
194
  socket.destroy();
@@ -164,9 +204,10 @@ function bridgeConnection(ws, nodeId) {
164
204
  /* ignore */
165
205
  }
166
206
  };
167
- stopHeartbeat = startRemoteAttachHeartbeat(ws);
207
+ heartbeat = startRemoteAttachHeartbeat(ws);
168
208
  // socket → WS: bounded decode, one WS text message per complete frame.
169
209
  socket.on('data', (chunk) => {
210
+ heartbeat?.noteActivity();
170
211
  let frames;
171
212
  try {
172
213
  frames = decoder.push(chunk);
@@ -245,6 +286,7 @@ function bridgeConnection(ws, nodeId) {
245
286
  ws.on('message', (data) => {
246
287
  if (closed || socket.destroyed)
247
288
  return;
289
+ heartbeat?.noteActivity();
248
290
  const buf = Array.isArray(data)
249
291
  ? Buffer.concat(data)
250
292
  : Buffer.isBuffer(data)
@@ -1,8 +1,7 @@
1
1
  // Broker-originated canvas operations. The broker sends Pi lifecycle facts;
2
2
  // crtrd validates and commits their durable effects while the handler stays
3
3
  // strictly canvas-write-only — no broker launch, kill, or view.sock round trip.
4
- import { getNode, isWarmSpare, listNodes, recordPid, setPresence, subscriptionsOf, updateNode } from '../../../core/canvas/canvas.js';
5
- import { isSafeNodeId } from '../../../core/canvas/paths.js';
4
+ import { canonicalSessionFile, findNodeBySessionFile, getNode, isWarmSpare, listNodes, recordPid, setPresence, subscriptionsOf, updateNode } from '../../../core/canvas/canvas.js';
6
5
  import { editorLabel } from '../../../core/canvas/labels.js';
7
6
  import { assembleNodeSubject } from '../../../core/substrate/subject.js';
8
7
  import { withCanvasWrite } from '../../../core/canvas/db.js';
@@ -128,9 +127,9 @@ function extensionState(nodeId) {
128
127
  .filter((child) => child !== null && child.kind !== 'human')
129
128
  .sort((a, b) => (a.status === b.status ? 0 : a.status === 'active' ? -1 : b.status === 'active' ? 1 : 0))
130
129
  .map(extensionNode);
131
- const source = node.fork_from === undefined || node.fork_from === null || node.fork_from === '' || !isSafeNodeId(node.fork_from)
132
- ? null
133
- : getNode(node.fork_from);
130
+ const source = typeof node.fork_source_file === 'string'
131
+ ? findNodeBySessionFile(node.fork_source_file)
132
+ : null;
134
133
  const subject = assembleNodeSubject(nodeId);
135
134
  if (subject === null)
136
135
  throw notFound(`unknown node: ${nodeId}`, { received: nodeId });
@@ -146,7 +145,7 @@ function extensionState(nodeId) {
146
145
  };
147
146
  }
148
147
  function samePersona(a, b) {
149
- return a?.mode === b.mode && a?.lifecycle === b.lifecycle;
148
+ return a?.kind === b.kind && a?.mode === b.mode && a?.lifecycle === b.lifecycle;
150
149
  }
151
150
  function parseGeneratedNameBody(body) {
152
151
  const value = objectBody(body, 'broker generated-name');
@@ -169,6 +168,7 @@ function parsePersonaAckBody(body) {
169
168
  const parse = (key) => {
170
169
  const persona = value[key];
171
170
  if (typeof persona !== 'object' || persona === null || Array.isArray(persona)
171
+ || typeof persona['kind'] !== 'string'
172
172
  || (persona['mode'] !== 'base' && persona['mode'] !== 'orchestrator')
173
173
  || (persona['lifecycle'] !== 'terminal' && persona['lifecycle'] !== 'resident')) {
174
174
  throw usage(`${key} must be a persona state`);
@@ -180,6 +180,7 @@ function parsePersonaAckBody(body) {
180
180
  function handleSessionBound(ctx) {
181
181
  const nodeId = ctx.params['id'];
182
182
  const request = parseSessionBoundBody(ctx.body);
183
+ const sessionFile = request.sessionFile === null ? null : canonicalSessionFile(request.sessionFile);
183
184
  const result = withCanvasWrite(() => {
184
185
  const before = requireNode(nodeId);
185
186
  // The broker reports the markers present in Pi's current branch; crtrd owns
@@ -196,10 +197,10 @@ function handleSessionBound(ctx) {
196
197
  action = 'relaunch_root';
197
198
  }
198
199
  else {
199
- handleNewSession(nodeId, request.piSessionId, request.sessionFile);
200
+ handleNewSession(nodeId, request.piSessionId, sessionFile);
200
201
  }
201
202
  }
202
- else if (before.fork_from != null && before.pi_session_id == null && request.sessionFile === null) {
203
+ else if (before.fork_from != null && before.pi_session_id == null && sessionFile === null) {
203
204
  // Pi has not exposed complete writable coordinates for this ordinary fork.
204
205
  // Leave it unbound so recovery cannot fork provenance twice.
205
206
  action = 'shutdown';
@@ -210,7 +211,7 @@ function handleSessionBound(ctx) {
210
211
  clearYieldMessage(nodeId);
211
212
  updateNode(nodeId, {
212
213
  pi_session_id: request.piSessionId,
213
- pi_session_file: request.sessionFile,
214
+ pi_session_file: sessionFile,
214
215
  cycle_pending: false,
215
216
  });
216
217
  recordPid(nodeId, request.pid);
@@ -378,20 +379,24 @@ function handleGeneratedName(ctx) {
378
379
  });
379
380
  return { status: 200, body: result };
380
381
  }
381
- /** Ack after Pi accepted transition guidance. The expected transition prevents a
382
- * stale broker from acknowledging a later persona change it did not describe. */
382
+ /** Ack after the matching baseline or transition context is durable. The
383
+ * expected pair prevents a stale broker from acknowledging a later change. */
383
384
  function handlePersonaAck(ctx) {
384
385
  const nodeId = ctx.params['id'];
385
386
  const request = parsePersonaAckBody(ctx.body);
386
- withCanvasWrite(() => {
387
+ const result = withCanvasWrite(() => {
387
388
  const node = requireNode(nodeId);
388
- const current = { mode: node.mode, lifecycle: node.lifecycle };
389
- const acknowledged = node.persona_ack ?? current;
389
+ const current = { kind: node.kind, mode: node.mode, lifecycle: node.lifecycle };
390
+ // A pre-kind ack reads its kind as the node's current one, matching how the
391
+ // broker resolved the `from` it is acknowledging.
392
+ const ack = node.persona_ack;
393
+ const acknowledged = ack === undefined ? current : { kind: ack.kind ?? node.kind, mode: ack.mode, lifecycle: ack.lifecycle };
390
394
  if (!samePersona(current, request.to) || !samePersona(acknowledged, request.from))
391
- return;
395
+ return { applied: false };
392
396
  updateNode(nodeId, { persona_ack: request.to });
397
+ return { applied: true };
393
398
  });
394
- return { status: 204 };
399
+ return { status: 200, body: result };
395
400
  }
396
401
  export const brokerOperationRoutes = [
397
402
  { method: 'POST', pattern: '/v1/nodes/:id/broker/session-bound', handler: handleSessionBound },
@@ -39,6 +39,8 @@ function handleResolve(ctx) {
39
39
  name: doc.name,
40
40
  scope: doc.scope,
41
41
  path: doc.path,
42
+ store_root: doc.store.storeRoot,
43
+ physical_relative_path: doc.physicalRelativePath,
42
44
  ...(doc.plugin === undefined ? {} : { plugin: doc.plugin }),
43
45
  };
44
46
  return { status: 200, body };
@@ -75,6 +75,7 @@ import { migrateLegacyInboxIdentities } from '../core/feed/inbox-migrate.js';
75
75
  import { emitEvent } from '../core/events/emit.js';
76
76
  import { operationIdContext } from '../core/events/operation-id.js';
77
77
  import { bindDaemonEventSource } from '../core/events/source.js';
78
+ import { ensureOnDiskMigrations } from '../migrations/activation.js';
78
79
  // A broker DECLARES its owning canvas in argv at spawn (host.ts launch:
79
80
  // `broker-cli.js --canvas-home <home> --epoch <id> <nodeId>`), so ownership is
80
81
  // an exact match on this daemon's own canvas home — no path inference. An
@@ -350,6 +351,7 @@ export async function superviseTick(now = Date.now(), lifecycle = directTickLife
350
351
  * quiesces admitted work, removes its readiness mirror, and releases its exact
351
352
  * claim. */
352
353
  export async function runDaemon(opts = {}) {
354
+ await ensureOnDiskMigrations();
353
355
  const ownership = claimDaemonOwnership(randomBytes(16).toString('hex'));
354
356
  if (!ownership.claimed) {
355
357
  // A losing claim isn't always a singleton race: `own_identity_indeterminate`
@@ -282,7 +282,14 @@ export async function deliverTerminalResult(ticketId) {
282
282
  }
283
283
  if (failedTo.length > 0)
284
284
  throw new Error(`cancellation notice failed for ${failedTo.join(', ')}`);
285
- transition(bridgeNodeId, 'finish');
285
+ // Parking the bridge done is the cancellation's END STATE, not a step it
286
+ // owes: a bridge already done — parked by an earlier cancel replay, or by a
287
+ // close that finished it while the page was still pending — is already
288
+ // there, and `finish` is illegal from done. Re-running it would turn a
289
+ // recorded, delivered cancellation into delivery_failed and make every
290
+ // retry repeat that error.
291
+ if (node.status !== 'done')
292
+ transition(bridgeNodeId, 'finish');
286
293
  return true;
287
294
  }
288
295
  if (result.kind === 'page') {
@@ -47,7 +47,6 @@ export interface SpawnDaemonResult {
47
47
  /** PID of the already-running daemon, if it was already up. */
48
48
  existing_pid?: number;
49
49
  }
50
- export declare const DAEMON_VERIFY_WINDOW_MS = 20000;
51
50
  export interface DaemonWaitDeps {
52
51
  readPidfile?: () => number | null;
53
52
  isPidAlive?: (pid: number) => boolean;
@@ -14,6 +14,7 @@ import { hostExecPath } from '../core/runtime/branded-host.js';
14
14
  // Pulled from the lean db-free pidfile module (NOT crtrd.js, whose module graph
15
15
  // reaches openDb) so the CLI daemon front door stays canvas.db-free (plan B-0).
16
16
  import { isDaemonRunning, readPidfile, isPidAlive } from './pidfile.js';
17
+ import { DAEMON_STARTUP_WINDOW_MS } from './startup-policy.js';
17
18
  // ---------------------------------------------------------------------------
18
19
  // Daemon env sanitization
19
20
  // ---------------------------------------------------------------------------
@@ -204,18 +205,8 @@ export async function execDaemon() {
204
205
  const launchArgs = await resolveDaemonLaunch();
205
206
  return execve(executable, [executable, ...launchArgs, '--canvas-home', crtrHome()], sanitizedDaemonEnv());
206
207
  }
207
- // Daemon lifecycle transitions are bounded so callers never claim a state
208
- // change before the pidfile and process liveness agree.
209
- //
210
- // A cold boot right after `install-runtime` (empty module cache, fresh
211
- // generation, possible pending db migration) can take several seconds to reach
212
- // writePidfile(), so the window is wide enough to tolerate that slow-but-valid
213
- // cold start rather than report a false "did not become ready"; a process exit
214
- // (signal/non-zero code) still fails immediately regardless of this window.
215
- // Exported so `commands/api-client.ts` can align the CLI-side `CrtrClient`'s
216
- // cold-start `/healthz` poll window with this authoritative valid-startup bound
217
- // instead of the two windows drifting independently.
218
- export const DAEMON_VERIFY_WINDOW_MS = 20_000;
208
+ // Shutdown stays short even though startup may include corpus migration.
209
+ const DAEMON_SHUTDOWN_WINDOW_MS = 20_000;
219
210
  const DAEMON_VERIFY_POLL_MS = 10;
220
211
  function sleepMs(ms) {
221
212
  return new Promise((resolve) => {
@@ -235,7 +226,7 @@ function sleepMs(ms) {
235
226
  *
236
227
  * Returns null when the spawned pid owns the pidfile; returns a different live
237
228
  * daemon pid when startup lost the race to an already-running daemon. */
238
- export async function verifyDaemonStartup(pid, timeoutMs = DAEMON_VERIFY_WINDOW_MS, deps = {}) {
229
+ export async function verifyDaemonStartup(pid, timeoutMs = DAEMON_STARTUP_WINDOW_MS, deps = {}) {
239
230
  const read = deps.readPidfile ?? readPidfile;
240
231
  const alive = deps.isPidAlive ?? isPidAlive;
241
232
  const exited = deps.childExited;
@@ -265,7 +256,7 @@ export async function verifyDaemonStartup(pid, timeoutMs = DAEMON_VERIFY_WINDOW_
265
256
  /** Wait until a signaled daemon has exited and relinquished its pidfile. This
266
257
  * makes a following start safe to claim the singleton rather than racing its
267
258
  * previous owner. */
268
- export async function waitForDaemonExit(pid, timeoutMs = DAEMON_VERIFY_WINDOW_MS, deps = {}) {
259
+ export async function waitForDaemonExit(pid, timeoutMs = DAEMON_SHUTDOWN_WINDOW_MS, deps = {}) {
269
260
  const read = deps.readPidfile ?? readPidfile;
270
261
  const alive = deps.isPidAlive ?? isPidAlive;
271
262
  const sleep = deps.sleepMs ?? sleepMs;
@@ -298,7 +289,7 @@ const DAEMON_KILL_WINDOW_MS = 2_000;
298
289
  * waits on process death alone after a kill, never on pidfile release.
299
290
  *
300
291
  * Returns how the daemon went down, for a truthful report. */
301
- export async function stopDaemonProcess(pid, timeoutMs = DAEMON_VERIFY_WINDOW_MS, deps = {}) {
292
+ export async function stopDaemonProcess(pid, timeoutMs = DAEMON_SHUTDOWN_WINDOW_MS, deps = {}) {
302
293
  const alive = deps.isPidAlive ?? isPidAlive;
303
294
  const sleep = deps.sleepMs ?? sleepMs;
304
295
  const now = deps.now ?? Date.now;
@@ -377,7 +368,7 @@ export async function spawnDaemon() {
377
368
  exitState = { code, signal };
378
369
  });
379
370
  child.unref();
380
- const existingPid = await verifyDaemonStartup(pid, DAEMON_VERIFY_WINDOW_MS, {
371
+ const existingPid = await verifyDaemonStartup(pid, DAEMON_STARTUP_WINDOW_MS, {
381
372
  childExited: () => exitState,
382
373
  });
383
374
  if (existingPid !== null) {
@@ -0,0 +1 @@
1
+ export declare const DAEMON_STARTUP_WINDOW_MS: number;
@@ -0,0 +1 @@
1
+ export const DAEMON_STARTUP_WINDOW_MS = 5 * 60 * 1_000;
@@ -1,9 +1,7 @@
1
1
  // 001 — the surfaces cut's frontmatter codemod. Folds the four old routing
2
2
  // fields (`system-prompt-visibility`, `file-read-visibility`, `applies-to`,
3
- // `read-when`) into explicit `surfaces` entries, bakes INDEX boot ceilings into
4
- // each doc's boot rung (per store), and points bare-directory `[[refs]]` at
5
- // their `[[dir/INDEX]]` docs (a bare-dir ref used to deliver the INDEX body;
6
- // after the cut it returns the directory listing).
3
+ // `read-when`) into explicit `surfaces` entries and bakes INDEX boot ceilings
4
+ // into each doc's boot rung (per store).
7
5
  //
8
6
  // Only docs with a valid substrate `kind` are touched. The old-schema parsing
9
7
  // and ceiling walk are FROZEN COPIES of the pre-cut substrate code: the live
@@ -13,14 +11,13 @@
13
11
  // Serialization is a textual splice: the dead keys' exact line spans are cut
14
12
  // from the raw frontmatter and a rendered `surfaces:` block is appended, so
15
13
  // every other byte survives verbatim and a migrated doc's diff shows only the
16
- // dead fields dying, `surfaces` appearing, and rule-5 ref rewrites — never
17
- // unrelated reformatting (re-serializing through the Document API would unfold
18
- // folded scalars). Each splice is validated by re-parsing: the result must
14
+ // dead fields dying and `surfaces` appearing — never unrelated reformatting
15
+ // (re-serializing through the Document API would unfold folded scalars). Each splice is validated by re-parsing: the result must
19
16
  // equal the original record minus the dead fields plus `surfaces`, or the
20
17
  // migration throws naming the doc.
21
- import { isMap, parse as parseYaml, parseDocument, stringify } from 'yaml';
18
+ import { parse as parseYaml } from 'yaml';
22
19
  import { parseFrontmatterGeneric } from '../core/frontmatter.js';
23
- import { findDocLinks } from '../core/memory/doc-link-grammar.js';
20
+ import { appendRenderedKey, dropTopLevelKeys, proveFrontmatterRoundTrip, rebuildDocSource, } from './frontmatter-splice.js';
24
21
  const OLD_FIELDS = ['system-prompt-visibility', 'file-read-visibility', 'applies-to', 'read-when'];
25
22
  // ---------------------------------------------------------------------------
26
23
  // Frozen old-schema parsing (pre-cut substrate/schema.ts semantics).
@@ -133,122 +130,37 @@ function planEntries(d, bootRung) {
133
130
  return entries;
134
131
  }
135
132
  // ---------------------------------------------------------------------------
136
- // Rule 5: bare-dir `[[ref]]` → `[[dir/INDEX]]`. A ref is rewritten only when
137
- // the owning target corpus proves the old bare-directory convenience and the
138
- // explicit INDEX spelling resolve to the same file. The decision cache belongs
139
- // to one migration target; profile-local names never leak across stores.
133
+ // Serialization: cut the dead keys' exact line spans out of the raw
134
+ // frontmatter text, append the rendered `surfaces:` block, re-parse to prove
135
+ // the splice is meaning-preserving.
140
136
  // ---------------------------------------------------------------------------
141
- function rewriteBareDirRefs(body, context, decisions) {
142
- const links = findDocLinks(body);
143
- if (links.length === 0)
144
- return body;
145
- let out = '';
146
- let pos = 0;
147
- for (const link of links) {
148
- let repl = decisions.get(link.name);
149
- if (repl === undefined) {
150
- repl = context?.legacyDirectoryIndex(link.name) ?? null;
151
- decisions.set(link.name, repl);
152
- }
153
- if (repl === null)
154
- continue;
155
- out += body.slice(pos, link.start) + `[[${repl}]]`;
156
- pos = link.end;
157
- }
158
- return out + body.slice(pos);
159
- }
160
- function deepEqual(a, b) {
161
- if (a === b)
162
- return true;
163
- if (Array.isArray(a) || Array.isArray(b)) {
164
- if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
165
- return false;
166
- return a.every((v, i) => deepEqual(v, b[i]));
167
- }
168
- if (a !== null && b !== null && typeof a === 'object' && typeof b === 'object') {
169
- const ak = Object.keys(a);
170
- const bk = Object.keys(b);
171
- if (ak.length !== bk.length)
172
- return false;
173
- return ak.every((k) => deepEqual(a[k], b[k]));
174
- }
175
- return false;
176
- }
177
137
  function migratedYaml(rawYaml, entries, docPath) {
178
- const ydoc = parseDocument(rawYaml);
179
- if (ydoc.errors.length > 0 || !isMap(ydoc.contents)) {
180
- throw new Error(`surfaces migration: unparseable frontmatter in ${docPath}`);
181
- }
182
- // Whole-line spans of the dead top-level keys (multi-line values included:
183
- // the value's node-end bounds its last continuation line).
184
- const spans = [];
185
- for (const item of ydoc.contents.items) {
186
- if (!OLD_FIELDS.includes(String(item.key)))
187
- continue;
188
- const keyRange = item.key.range;
189
- const valueRange = item.value?.range;
190
- if (keyRange === undefined)
191
- throw new Error(`surfaces migration: rangeless key in ${docPath}`);
192
- const start = rawYaml.lastIndexOf('\n', keyRange[0] - 1) + 1;
193
- let end = valueRange?.[2] ?? keyRange[2];
194
- // Consume through end-of-line unless a block value already owns its newline.
195
- if (!(end > 0 && rawYaml[end - 1] === '\n')) {
196
- while (end < rawYaml.length && (rawYaml[end] === ' ' || rawYaml[end] === '\t' || rawYaml[end] === '\r'))
197
- end++;
198
- if (rawYaml[end] === '\n')
199
- end++;
200
- }
201
- spans.push([start, end]);
202
- }
203
- spans.sort((a, b) => b[0] - a[0]);
204
- let out = rawYaml;
205
- for (const [s, e] of spans)
206
- out = out.slice(0, s) + out.slice(e);
207
- if (entries.length > 0) {
208
- // lineWidth 0: render each entry's scalars on one line, never folded.
209
- const block = stringify({ surfaces: entries }, { lineWidth: 0 });
210
- out = out === '' ? block : (out.endsWith('\n') ? out : out + '\n') + block;
211
- }
212
- out = out.replace(/\r?\n$/, '');
138
+ let out = dropTopLevelKeys(rawYaml, OLD_FIELDS, docPath);
139
+ if (entries.length > 0)
140
+ out = appendRenderedKey(out, 'surfaces', entries, docPath);
141
+ else
142
+ out = out.replace(/\r?\n$/, '');
213
143
  const expected = { ...parseYaml(rawYaml) };
214
144
  for (const k of OLD_FIELDS)
215
145
  delete expected[k];
216
146
  if (entries.length > 0)
217
147
  expected['surfaces'] = entries;
218
- const actual = parseYaml(out === '' ? '{}' : out);
219
- if (!deepEqual(actual ?? {}, expected)) {
220
- throw new Error(`surfaces migration: spliced frontmatter does not round-trip in ${docPath}`);
221
- }
148
+ proveFrontmatterRoundTrip(out, expected, docPath);
222
149
  return out;
223
150
  }
224
- function rebuildSource(d, newYaml, newBody) {
225
- const { doc } = d;
226
- const bodyStart = doc.source.length - doc.body.length;
227
- const fmBlock = doc.source.slice(0, bodyStart);
228
- if (newYaml === null)
229
- return fmBlock + newBody;
230
- const { raw } = parseFrontmatterGeneric(doc.source);
231
- const at = fmBlock.indexOf(raw);
232
- if (raw === '' || at < 0)
233
- throw new Error(`surfaces migration: cannot locate frontmatter block in ${doc.path}`);
234
- return fmBlock.slice(0, at) + newYaml + fmBlock.slice(at + raw.length) + newBody;
235
- }
236
151
  export const surfacesFrontmatterMigration = {
152
+ id: '001-surfaces-frontmatter',
237
153
  lane: 'convergent',
238
- description: 'surfaces frontmatter: fold visibility/applies-to/read-when routing into surfaces entries; rewrite bare-directory [[refs]] to [[dir/INDEX]]',
239
- apply(store, context) {
154
+ description: 'surfaces frontmatter: fold visibility/applies-to/read-when routing into surfaces entries',
155
+ apply(store) {
240
156
  const docs = store.docs.map(classify).filter((d) => d !== null);
241
157
  const ceiling = buildCeiling(docs);
242
- const decisions = new Map();
243
158
  const changes = [];
244
159
  for (const d of docs) {
245
- const newBody = rewriteBareDirRefs(d.doc.body, context, decisions);
246
- if (!d.hasOldFields && newBody === d.doc.body)
160
+ if (!d.hasOldFields)
247
161
  continue;
248
- const newYaml = d.hasOldFields
249
- ? migratedYaml(parseFrontmatterGeneric(d.doc.source).raw, planEntries(d, effectiveBootRung(d, ceiling)), d.doc.path)
250
- : null;
251
- changes.push({ path: d.doc.path, after: rebuildSource(d, newYaml, newBody) });
162
+ const newYaml = migratedYaml(parseFrontmatterGeneric(d.doc.source).raw, planEntries(d, effectiveBootRung(d, ceiling)), d.doc.path);
163
+ changes.push({ path: d.doc.path, after: rebuildDocSource(d.doc.source, newYaml, d.doc.path) });
252
164
  }
253
165
  return changes;
254
166
  },
@@ -32,6 +32,7 @@ function isMemory(value) {
32
32
  return typeof value === 'string' && PROFILE_PROJECT_MEMORY_VALUES.includes(value);
33
33
  }
34
34
  export const profileProjectMemoryMigration = {
35
+ id: '002-profile-project-memory',
35
36
  lane: 'profile-manifest',
36
37
  description: 'profile projects carry an explicit memory value',
37
38
  apply(input) {