@north-light/crouter 0.3.235 → 0.3.237

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 (181) hide show
  1. package/dist/api/client.d.ts +2 -1
  2. package/dist/api/client.js +3 -0
  3. package/dist/api/dto/broker-ops.d.ts +1 -1
  4. package/dist/api/dto/common.d.ts +2 -0
  5. package/dist/api/dto/lifecycle.d.ts +9 -2
  6. package/dist/api/dto/messages.d.ts +5 -0
  7. package/dist/api/dto/nodes.d.ts +14 -5
  8. package/dist/api/dto/worktree.d.ts +10 -0
  9. package/dist/api/routes.d.ts +1 -0
  10. package/dist/api/routes.js +1 -0
  11. package/dist/builtin-memory/00-runtime-base/00-authoring.md +1 -1
  12. package/dist/builtin-memory/internal/agent-shaping.md +1 -1
  13. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +1 -1
  14. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/sysprompt-window.ts +1 -1
  15. package/dist/clients/attach/render/tool-calls.js +18 -3
  16. package/dist/clients/attach/viewer.js +399 -399
  17. package/dist/clients/inbox/review/__tests__/editor-roundtrip.test.js +6 -0
  18. package/dist/clients/inbox/review/roundtrip.js +1 -1
  19. package/dist/commands/__tests__/node-new.test.js +58 -0
  20. package/dist/commands/memory/lint.js +22 -13
  21. package/dist/commands/memory/shared.js +1 -1
  22. package/dist/commands/node/create.d.ts +1 -1
  23. package/dist/commands/node/create.js +4 -1
  24. package/dist/commands/node/lifecycle.js +2 -2
  25. package/dist/commands/node-worktree.js +39 -8
  26. package/dist/commands/revive.js +11 -2
  27. package/dist/commands/sys/context/admin/actions.d.ts +48 -0
  28. package/dist/commands/sys/context/admin/actions.js +246 -0
  29. package/dist/commands/sys/context/admin/axes-panel.d.ts +21 -0
  30. package/dist/commands/sys/context/admin/axes-panel.js +117 -0
  31. package/dist/commands/sys/context/admin/detail-panel.d.ts +53 -0
  32. package/dist/commands/sys/context/admin/detail-panel.js +178 -0
  33. package/dist/commands/sys/context/admin/docs-panel.d.ts +45 -0
  34. package/dist/commands/sys/context/admin/docs-panel.js +186 -0
  35. package/dist/commands/sys/context/admin/model.d.ts +75 -0
  36. package/dist/commands/sys/context/admin/model.js +209 -0
  37. package/dist/commands/sys/context/admin/shell.d.ts +82 -0
  38. package/dist/commands/sys/context/admin/shell.js +669 -0
  39. package/dist/commands/sys/context/admin.d.ts +1 -0
  40. package/dist/commands/sys/context/admin.js +124 -0
  41. package/dist/commands/sys/context/doc.d.ts +1 -0
  42. package/dist/commands/sys/context/doc.js +196 -0
  43. package/dist/commands/sys/context/prompt-review.d.ts +1 -0
  44. package/dist/commands/sys/{prompt-review.js → context/prompt-review.js} +10 -10
  45. package/dist/commands/sys/context/resolve.d.ts +55 -0
  46. package/dist/commands/sys/context/resolve.js +428 -0
  47. package/dist/commands/sys/context/sysprompt.d.ts +1 -0
  48. package/dist/commands/sys/{sysprompt.js → context/sysprompt.js} +11 -11
  49. package/dist/commands/sys/context.d.ts +2 -0
  50. package/dist/commands/sys/context.js +20 -0
  51. package/dist/commands/sys.js +3 -4
  52. package/dist/core/__tests__/canvas-inbox-watcher-naming.test.d.ts +1 -0
  53. package/dist/core/__tests__/canvas-inbox-watcher-naming.test.js +258 -0
  54. package/dist/core/__tests__/child-death-wake.test.js +38 -22
  55. package/dist/core/__tests__/cron-self-cancel-delivery.test.d.ts +1 -0
  56. package/dist/core/__tests__/cron-self-cancel-delivery.test.js +72 -0
  57. package/dist/core/__tests__/daemon-boot.test.js +40 -28
  58. package/dist/core/__tests__/helpers/harness.js +1 -0
  59. package/dist/core/__tests__/human-deliver.test.js +1 -1
  60. package/dist/core/__tests__/human-node-not-supervised.test.js +10 -15
  61. package/dist/core/__tests__/integration/revive.test.js +12 -12
  62. package/dist/core/__tests__/integration/spawn-root.test.js +6 -0
  63. package/dist/core/__tests__/integration/worktree-reap.test.js +32 -0
  64. package/dist/core/__tests__/lifecycle-terminal-reasons.test.d.ts +1 -0
  65. package/dist/core/__tests__/lifecycle-terminal-reasons.test.js +61 -0
  66. package/dist/core/__tests__/migration.test.js +38 -1
  67. package/dist/core/__tests__/push-final-guard.test.js +1 -1
  68. package/dist/core/__tests__/revive-capacity.test.d.ts +1 -0
  69. package/dist/core/__tests__/revive-capacity.test.js +127 -0
  70. package/dist/core/__tests__/revive-parked-fresh.test.js +7 -7
  71. package/dist/core/__tests__/seam/broker-cap-freeze.test.d.ts +1 -0
  72. package/dist/core/__tests__/seam/broker-cap-freeze.test.js +81 -0
  73. package/dist/core/__tests__/seam/dormancy-release.test.js +76 -7
  74. package/dist/core/__tests__/seam/yield-refresh-transaction.test.js +1 -1
  75. package/dist/core/canvas/browse/render.js +11 -3
  76. package/dist/core/canvas/canvas.d.ts +15 -3
  77. package/dist/core/canvas/canvas.js +52 -16
  78. package/dist/core/canvas/migrations.js +21 -0
  79. package/dist/core/canvas/nav-render.js +3 -1
  80. package/dist/core/canvas/remote-canvas-source.js +2 -0
  81. package/dist/core/canvas/render-source.d.ts +3 -1
  82. package/dist/core/canvas/render-source.js +7 -3
  83. package/dist/core/canvas/status-glyph.d.ts +10 -1
  84. package/dist/core/canvas/status-glyph.js +19 -3
  85. package/dist/core/canvas/types.d.ts +22 -3
  86. package/dist/core/feed/feed.js +1 -1
  87. package/dist/core/human/feedback-companion.js +5 -2
  88. package/dist/core/memory/lint.d.ts +19 -0
  89. package/dist/core/memory/lint.js +97 -0
  90. package/dist/core/memory-resolver.d.ts +4 -0
  91. package/dist/core/memory-resolver.js +7 -7
  92. package/dist/core/preview-registry.js +4 -4
  93. package/dist/core/review/realize.js +4 -2
  94. package/dist/core/runtime/close.js +1 -1
  95. package/dist/core/runtime/fleet.d.ts +29 -1
  96. package/dist/core/runtime/fleet.js +23 -0
  97. package/dist/core/runtime/host.js +32 -11
  98. package/dist/core/runtime/launch-prompt.d.ts +1 -1
  99. package/dist/core/runtime/launch-prompt.js +2 -2
  100. package/dist/core/runtime/launch-target.js +1 -1
  101. package/dist/core/runtime/lifecycle.d.ts +4 -2
  102. package/dist/core/runtime/lifecycle.js +12 -1
  103. package/dist/core/runtime/memory.js +1 -1
  104. package/dist/core/runtime/reopen.js +10 -1
  105. package/dist/core/runtime/reset.js +1 -1
  106. package/dist/core/runtime/revive-all.d.ts +3 -0
  107. package/dist/core/runtime/revive-all.js +10 -4
  108. package/dist/core/runtime/revive.d.ts +12 -0
  109. package/dist/core/runtime/revive.js +62 -3
  110. package/dist/core/runtime/spawn.d.ts +9 -13
  111. package/dist/core/runtime/spawn.js +40 -17
  112. package/dist/core/substrate/frontmatter-validation.js +1 -1
  113. package/dist/core/substrate/index.d.ts +6 -3
  114. package/dist/core/substrate/index.js +4 -3
  115. package/dist/core/substrate/on-read.d.ts +0 -8
  116. package/dist/core/substrate/on-read.js +45 -124
  117. package/dist/core/substrate/plan.d.ts +94 -0
  118. package/dist/core/substrate/plan.js +266 -0
  119. package/dist/core/substrate/render.d.ts +5 -6
  120. package/dist/core/substrate/render.js +76 -129
  121. package/dist/core/substrate/schema.d.ts +1 -6
  122. package/dist/core/substrate/schema.js +1 -15
  123. package/dist/core/substrate/session-cache.d.ts +2 -10
  124. package/dist/core/substrate/session-cache.js +9 -47
  125. package/dist/core/substrate/subject-fields.d.ts +1 -4
  126. package/dist/core/substrate/subject-fields.js +2 -5
  127. package/dist/core/substrate/surface-match.d.ts +37 -17
  128. package/dist/core/substrate/surface-match.js +44 -53
  129. package/dist/core/tui/page-host.d.ts +6 -0
  130. package/dist/core/tui/page-host.js +12 -3
  131. package/dist/core/worktree.d.ts +8 -0
  132. package/dist/core/worktree.js +65 -0
  133. package/dist/daemon/__tests__/reconciler-signature.test.js +3 -2
  134. package/dist/daemon/__tests__/thaw-order.test.d.ts +1 -0
  135. package/dist/daemon/__tests__/thaw-order.test.js +55 -0
  136. package/dist/daemon/api/__tests__/canvas-snapshot-fields.test.js +6 -0
  137. package/dist/daemon/api/__tests__/node-create-description.test.js +6 -0
  138. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +1 -1
  139. package/dist/daemon/api/bridge.js +5 -1
  140. package/dist/daemon/api/handlers/attach.js +5 -1
  141. package/dist/daemon/api/handlers/bash-jobs.js +1 -1
  142. package/dist/daemon/api/handlers/messages.js +10 -2
  143. package/dist/daemon/api/handlers/nodes.js +8 -4
  144. package/dist/daemon/api/handlers/worktree.js +10 -1
  145. package/dist/daemon/api/map.js +7 -0
  146. package/dist/daemon/companion-retire.js +1 -1
  147. package/dist/daemon/cron/passes.d.ts +0 -5
  148. package/dist/daemon/cron/passes.js +0 -20
  149. package/dist/daemon/cron/sinks.js +1 -1
  150. package/dist/daemon/cron-run.d.ts +5 -6
  151. package/dist/daemon/cron-run.js +7 -16
  152. package/dist/daemon/crtrd.d.ts +0 -10
  153. package/dist/daemon/crtrd.js +23 -61
  154. package/dist/daemon/fleet.d.ts +10 -7
  155. package/dist/daemon/fleet.js +71 -12
  156. package/dist/daemon/messaging/node-message.js +3 -1
  157. package/dist/daemon/profile-delete.js +1 -1
  158. package/dist/daemon/reconcilers/broker-supervision.d.ts +26 -9
  159. package/dist/daemon/reconcilers/broker-supervision.js +39 -52
  160. package/dist/daemon/reconcilers/controller-death.d.ts +7 -0
  161. package/dist/daemon/reconcilers/controller-death.js +26 -0
  162. package/dist/daemon/reconcilers/cron-lane.d.ts +0 -3
  163. package/dist/daemon/reconcilers/cron-lane.js +0 -3
  164. package/dist/daemon/reconcilers/live-obligation.js +3 -1
  165. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.d.ts +53 -0
  166. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +170 -0
  167. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.d.ts +5 -0
  168. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.js +16 -0
  169. package/dist/daemon/reconcilers/node-lifecycle/terminating.d.ts +4 -0
  170. package/dist/daemon/reconcilers/node-lifecycle/terminating.js +13 -0
  171. package/dist/daemon/reconcilers/node-lifecycle/tick.d.ts +26 -0
  172. package/dist/daemon/reconcilers/node-lifecycle/tick.js +140 -0
  173. package/dist/pi-extensions/canvas-inbox-watcher.js +12 -0
  174. package/package.json +1 -1
  175. package/runtime.lock.json +2 -2
  176. package/dist/commands/sys/prompt-review.d.ts +0 -1
  177. package/dist/commands/sys/sysprompt.d.ts +0 -1
  178. package/dist/core/__tests__/cron-broker-capacity.test.js +0 -119
  179. package/dist/daemon/reconcilers/dormant-inbox.d.ts +0 -18
  180. package/dist/daemon/reconcilers/dormant-inbox.js +0 -157
  181. /package/dist/{core/__tests__/cron-broker-capacity.test.d.ts → commands/__tests__/node-new.test.d.ts} +0 -0
@@ -24,7 +24,8 @@
24
24
  // reviveNode remains the ONLY sanctioned launcher of the node engine.
25
25
  import { existsSync } from 'node:fs';
26
26
  import { isAbsolute } from 'node:path';
27
- import { findNodeBySessionFile, getNode, updateNode, clearPid, recordPid, fullName, subscribersOf, } from '../canvas/index.js';
27
+ import { findNodeBySessionFile, getNode, updateNode, clearPid, recordPid, setFrozen, clearFrozen, fullName, subscribersOf, } from '../canvas/index.js';
28
+ import { emitEvent } from '../events/emit.js';
28
29
  import { cancelCronsOnWake } from '../canvas/crons.js';
29
30
  import { isParked, transition } from './lifecycle.js';
30
31
  import { fanDoctrineWake } from './close.js';
@@ -34,7 +35,7 @@ import { buildReviveKickoff, drainBearings, readGoal, readYieldMessage, RUNTIME_
34
35
  import { readRoadmap } from './roadmap.js';
35
36
  import { hasUnseenWakeMail } from '../feed/inbox.js';
36
37
  import { headlessBrokerHost } from './host.js';
37
- import { boundFleet } from './fleet.js';
38
+ import { boundFleet, brokerCapReached, brokerThresholdsForDaemon } from './fleet.js';
38
39
  import { clearFault, beginBootFaultAttempt } from './fault.js';
39
40
  import { clearContextExposureState, initializeForkContextExposure, } from '../substrate/injected-store.js';
40
41
  import { rootOfSpine } from './nodes.js';
@@ -126,13 +127,67 @@ export function reviveNode(nodeId, opts) {
126
127
  // check before the first's registration lands. A node with a live fleet
127
128
  // entry was already revived by another path — re-launching would put a
128
129
  // SECOND broker on the same session file.
129
- if (boundFleet().has(nodeId)) {
130
+ const fleet = boundFleet();
131
+ if (fleet.has(nodeId)) {
132
+ // The node is already running, but the ROW may still say terminal: a reopen
133
+ // or a retask lands on a live broker, and only this transition puts the row
134
+ // back where its liveness already is. Both specs accept any prior status,
135
+ // so this is a no-op for a row that is already active.
136
+ transition(nodeId, meta.intent === 'refresh' ? 'refresh-launch' : 'revive');
130
137
  return {
131
138
  window: null,
132
139
  session: meta.tmux_session ?? null,
133
140
  resumed: false,
141
+ outcome: 'already-live',
134
142
  };
135
143
  }
144
+ // The cap, consulted BEFORE any mutation below: a node that cannot get a
145
+ // broker must not have its cycle counter bumped, its session identity
146
+ // cleared, or its bearings drained on the way to being turned away.
147
+ if (!fleet.reserveSlot(nodeId)) {
148
+ if (opts.capacity === 'refuse') {
149
+ throw brokerCapReached(brokerThresholdsForDaemon().automaticReviveCap, fleet.size());
150
+ }
151
+ // Freezing is durable and once-per-episode: the mark stays until a launch
152
+ // clears it, so a node frozen across many saturated ticks announces itself
153
+ // exactly once and keeps its original wait timestamp (the thaw order reads
154
+ // it).
155
+ if (meta.frozen_at == null) {
156
+ setFrozen(nodeId, new Date().toISOString());
157
+ // The row holds no engine and is not getting one this pass, so a pid left
158
+ // over from the instance that just died would read as stale-active on
159
+ // every status surface. Clearing it is what makes `isFrozenRow` true and
160
+ // what tells the tick this row has never launched OR has already been
161
+ // written off, so the freeze lane owes it a launch rather than a verdict.
162
+ clearPid(nodeId);
163
+ emitEvent({
164
+ level: 'info',
165
+ event: 'broker.frozen',
166
+ node_id: nodeId,
167
+ fields: { live_count: fleet.size(), hard_cap: brokerThresholdsForDaemon().automaticReviveCap },
168
+ });
169
+ }
170
+ return {
171
+ window: null,
172
+ session: meta.tmux_session ?? null,
173
+ resumed: false,
174
+ outcome: 'frozen',
175
+ };
176
+ }
177
+ try {
178
+ return launchRevive(nodeId, meta, opts);
179
+ }
180
+ finally {
181
+ // The slot is either consumed by the fleet registration inside `launch`
182
+ // (making this a no-op) or given back here — the one release path covering
183
+ // every preflight refusal and launch failure between the claim and the
184
+ // registration.
185
+ fleet.releaseReservation(nodeId);
186
+ }
187
+ }
188
+ /** The launch itself, from the cwd preflight through `recordPid`. Split out so
189
+ * the capacity claim above has exactly one release path around it. */
190
+ function launchRevive(nodeId, meta, opts) {
136
191
  // Preflight, before any state mutation below: a recorded cwd that no longer
137
192
  // exists (e.g. a closed managed worktree whose tombstone dir was later
138
193
  // reaped) can never host a launch. Fail loud with a typed, user-visible
@@ -345,11 +400,15 @@ export function reviveNode(nodeId, opts) {
345
400
  // and status surfaces (canvas views, the daemon's identity rechecks at
346
401
  // signal boundaries) still read it.
347
402
  recordPid(nodeId, launched.pid);
403
+ // A launch is the definition of not-frozen, and every launcher passes
404
+ // through here — so this is the one place the wait mark is retired.
405
+ clearFrozen(nodeId);
348
406
  return {
349
407
  window: null,
350
408
  session: meta.tmux_session ?? null,
351
409
  resumed: resuming,
352
410
  launch: launched,
353
411
  faultExplanation: bootFault.explanation,
412
+ outcome: 'launched',
354
413
  };
355
414
  }
@@ -15,19 +15,11 @@ export interface SpawnChildOpts {
15
15
  * subscription back to the spawner, spawned_by=spawner. Resident and brought
16
16
  * forefront by default — see `rootLifecycle` for the unattended variant. */
17
17
  root?: boolean;
18
- /** Only meaningful with `root`. The independent node's lifecycle.
19
- *
20
- * `'resident'` (default) is the ATTENDED root: a standing conversation a
21
- * human picks up, so it owes no final, never reaps on its own, and is
22
- * brought forefront in tmux on spawn.
23
- *
24
- * `'terminal'` is the UNATTENDED root: still parentless (nobody subscribes,
25
- * so nothing receives its push) but it still owes a final, so it reaps when
26
- * its work is done — and it is NEVER brought forefront. This is what
27
- * machinery firing with no human present must birth: the cron executor
28
- * spawns one when its cron's creating node is gone. A resident root there
29
- * would accrete one permanently-live node per fire (the count-based prune
30
- * never deletes live nodes) and steal the human's tmux focus every time. */
18
+ /** Lifecycle for the new node. Roots default to resident and managed
19
+ * children default to terminal. A resident child remains an interactable,
20
+ * wakeable conversation without owing a final; a terminal child remains a
21
+ * bounded worker that must finalize. For roots, terminal keeps the
22
+ * unattended no-viewer behavior. */
31
23
  rootLifecycle?: Lifecycle;
32
24
  /** Direct root creation waits until the broker accepts viewers before it
33
25
  * returns. Cron-launched births use `launch`: the daemon must never wait in
@@ -103,6 +95,10 @@ export declare function assertLaunchModelRegistered(request: ModelRequest | unde
103
95
  }, registry?: ModelRegistryLike): Promise<void>;
104
96
  export interface SpawnChildResult {
105
97
  node: NodeMeta;
98
+ /** The birth found the broker cap full: the row exists and is marked frozen,
99
+ * but no engine was launched. The node-lifecycle tick starts it when a slot
100
+ * frees. */
101
+ frozen: boolean;
106
102
  /** The viewer window opened for a --root in tmux; null otherwise (a managed
107
103
  * child opens no viewer, and a --root spawned outside tmux opens none). */
108
104
  window: string | null;
@@ -21,7 +21,9 @@ import { writeGoal } from './kickoff.js';
21
21
  import { appendSituationalContext, formatSituationalProse } from './situational-context.js';
22
22
  import { hasRoadmap, seedRoadmap } from './roadmap.js';
23
23
  import { buildWakeBearings } from './bearings.js';
24
- import { canonicalSessionFile, contextDir, findNodeBySessionFile, getNode, fullName, recordPid } from '../canvas/index.js';
24
+ import { canonicalSessionFile, contextDir, findNodeBySessionFile, getNode, fullName, recordPid, setFrozen } from '../canvas/index.js';
25
+ import { boundFleet, brokerThresholdsForDaemon } from './fleet.js';
26
+ import { emitEvent } from '../events/emit.js';
25
27
  import { jobDir } from '../canvas/paths.js';
26
28
  import { openViewerWindow, focusOf, windowOfPane, } from './placement.js';
27
29
  import { waitForBrokerViewSocket } from './placement-tmux.js';
@@ -209,16 +211,10 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
209
211
  const root = opts.root === true;
210
212
  const spawner = resolveSpawner(opts.parent, ctx.nodeId ?? null, root);
211
213
  const mode = opts.mode ?? 'base';
212
- // Lifecycle keys on ROOT-ness only, independent of mode: an independent root
213
- // (or `--root`) is resident by default (a conversation that persists, woken by
214
- // inbox/human); every spawned child is terminal — it owes a final up the spine
215
- // and reaps when done. A child born as an orchestrator is terminal/orchestrator
216
- // (delegates + holds a roadmap, but still reports up), NOT resident.
217
- //
218
- // `rootLifecycle: 'terminal'` is the one deliberate exception: a parentless
219
- // node that STILL owes a final (see the option's docblock). It is unattended
220
- // by construction, so `attendedRoot` below also suppresses viewer placement.
221
- const lifecycle = root ? (opts.rootLifecycle ?? 'resident') : 'terminal';
214
+ // Roots are resident by default; managed children are terminal workers by
215
+ // default. The one lifecycle option deliberately applies to both shapes so a
216
+ // child can be born as a standing, wakeable conversation too.
217
+ const lifecycle = opts.rootLifecycle ?? (root ? 'resident' : 'terminal');
222
218
  const attendedRoot = root && lifecycle === 'resident';
223
219
  // Born WITHOUT a name — the canvas-goal-capture extension names it async from
224
220
  // its first message (the kickoff task) inside its own pi process, so spawn
@@ -341,16 +337,43 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
341
337
  // Snapshot the boot-fault attempt the instant before launch — see
342
338
  // beginBootFaultAttempt — so a later diagnostic read (root+await only, below)
343
339
  // can only ever surface a fault THIS launch recorded, never a stale one.
340
+ // Claim the slot BEFORE the launch: `launchBrokerOrCrash` turns any launch
341
+ // failure — including the ledger's refusal — into a crashed row, and a birth
342
+ // that merely has to wait for capacity must not be crashed. Consulting the
343
+ // ledger here keeps the freeze decision ahead of that conversion. The
344
+ // reservation is consumed by the fleet's own `register` on a successful
345
+ // launch, so the release below is a no-op then and a give-back on any path
346
+ // that never registered a handle.
347
+ const fleet = boundFleet();
348
+ if (!fleet.reserveSlot(meta.node_id)) {
349
+ // Freezing is durable and once-per-episode; a birth is by definition the
350
+ // first, so the mark and its announcement always land here.
351
+ setFrozen(meta.node_id, new Date().toISOString());
352
+ emitEvent({
353
+ level: 'info',
354
+ event: 'broker.frozen',
355
+ node_id: meta.node_id,
356
+ fields: { live_count: fleet.size(), hard_cap: brokerThresholdsForDaemon().automaticReviveCap },
357
+ });
358
+ // No engine, so no readiness to wait for and no viewer to place.
359
+ return { node: getNode(meta.node_id), frozen: true, window: null, session: null };
360
+ }
344
361
  const bootFault = beginBootFaultAttempt(meta.node_id);
345
362
  // Broker is the only host: launch the detached broker ENGINE. A managed child
346
363
  // gets NO viewer — spawning it returns once the engine is launched and a human
347
364
  // opens a viewer on demand via `crtr surface node focus` / surface attach. A --root gets
348
365
  // a foreground viewer below.
349
- const placed = launchBrokerOrCrash(meta.node_id, inv, {
350
- cwd: meta.cwd,
351
- name: fullName(meta),
352
- resuming: false,
353
- });
366
+ let placed;
367
+ try {
368
+ placed = launchBrokerOrCrash(meta.node_id, inv, {
369
+ cwd: meta.cwd,
370
+ name: fullName(meta),
371
+ resuming: false,
372
+ });
373
+ }
374
+ finally {
375
+ fleet.releaseReservation(meta.node_id);
376
+ }
354
377
  // Definitive failure: no broker pid ⇒ the node has no engine. Crash it (so the
355
378
  // daemon doesn't watch a zombie 'active') and throw so `crtr node new` exits
356
379
  // non-zero. transition('crash') is a legal from-LIVE move (still 'active' from
@@ -418,7 +441,7 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
418
441
  catch { /* best-effort — a headless spawn must never fail on viewer placement */ }
419
442
  }
420
443
  const saved = getNode(meta.node_id);
421
- return { node: saved, window, session };
444
+ return { node: saved, frozen: false, window, session };
422
445
  }
423
446
  catch (err) {
424
447
  // Cleanup scope: a managed worktree is only removed on failure while NO
@@ -1,7 +1,7 @@
1
1
  import { isDocKind, parseSubstrateFrontmatter, SURFACE_EVENTS, SURFACE_RUNGS } from './schema.js';
2
2
  const RETIRED_FIELDS = ['system-prompt-visibility', 'file-read-visibility', 'applies-to', 'read-when'];
3
3
  const SURFACE_ENTRY_KEYS = ['on', 'match', 'match-frontmatter', 'gate', 'at'];
4
- export const SUPPRESSIBLE_RULES = ['length', 'broad-memory-read'];
4
+ export const SUPPRESSIBLE_RULES = ['length', 'broad-memory-read', 'orphan'];
5
5
  /** Strict validation of one raw routing entry. Runtime parsing deliberately
6
6
  * tolerates malformed docs; this authoring/preflight contract does not. */
7
7
  export function lintSubstrateSurfaces(value) {
@@ -1,7 +1,10 @@
1
- export { KINDS, isDocKind, RUNGS, rungRank, rungAtLeast, SURFACE_EVENTS, SURFACE_RUNGS, bootRung, parseSubstrateFrontmatter, parseSubstrateDoc, previewLine, normalizeNameSegment, normalizeDocName, resolveDocName, } from './schema.js';
1
+ export { KINDS, isDocKind, RUNGS, rungRank, rungAtLeast, SURFACE_EVENTS, SURFACE_RUNGS, parseSubstrateFrontmatter, parseSubstrateDoc, previewLine, normalizeNameSegment, normalizeDocName, resolveDocName, } from './schema.js';
2
2
  export type { DocKind, Rung, GatePredicate, SubstrateSchema, SubstrateDoc, SurfaceEntry, SurfaceEvent, SurfaceRung } from './schema.js';
3
3
  export { lintSubstrateFrontmatter, lintSubstrateSchema, lintSubstrateSurfaces, parsedSubstrateSurfaces } from './frontmatter-validation.js';
4
- export { matchesReadEntry, matchesMemoryReadEntry, matchesCommandEntry, readDeliveryRung, workspaceOpenRung, memoryReadDeliveryRung, commandDeliveryRung, owningRootOf, underOwningRoot } from './surface-match.js';
4
+ export { matchesReadEntry, matchesMemoryReadEntry, matchesCommandEntry, readDeliveryRung, workspaceOpenRung, memoryReadDeliveryRung, commandDeliveryRung, matchingSurfaceEntry, surfaceEntryParticipates, owningRootOf, underOwningRoot } from './surface-match.js';
5
+ export type { SurfaceMatchPayload } from './surface-match.js';
6
+ export { planDelivery, bootPartitionOf } from './plan.js';
7
+ export type { DeliveryPlan, DeliveryRecord, DeliveryPayload, EntryDecision, GateOutcome } from './plan.js';
5
8
  export { dirDedupKey, docsByName, isDirName, ancestorDirsOf, renderDirListing } from './listings.js';
6
9
  export { clearContextExposureState, cloneContextExposureState, cloneTranscriptExposureState, contentExposureIdentity, contextExposureStatesEqual, documentExposedAtOrAbove, emptyContextExposureState, exposedAtOrAbove, exposureTarget, freezePreferenceSnapshot, hasTranscriptExposure, initializeForkContextExposure, loadContextExposureState, mergeContextExposureState, registerDocumentExposure, registerExposure, replaceContextExposureState, saveContextExposureState, sharedContextExposureState, } from './injected-store.js';
7
10
  export type { ContextExposureState, ExposureSource, ExposureTarget } from './injected-store.js';
@@ -11,5 +14,5 @@ export { spineDepth, assembleNodeSubject } from './subject.js';
11
14
  export { gatePasses } from './gate.js';
12
15
  export { renderPreferencesForSubject, renderKnowledgeForSubject } from './render.js';
13
16
  export { renderPreferencesSection, renderKnowledgeBlock } from './render-node.js';
14
- export { renderOnReadDocsForSubject, renderWorkspaceOpenDocsForSubject, renderOnCommandDocsForSubject, renderCandidateBlocks, memoryReadDocBlocks } from './on-read.js';
17
+ export { renderOnReadDocsForSubject, renderWorkspaceOpenDocsForSubject, renderOnCommandDocsForSubject, memoryReadDocBlocks } from './on-read.js';
15
18
  export { renderOnReadDocs, renderWorkspaceOpenDocs } from './on-read-node.js';
@@ -9,7 +9,7 @@ KINDS, isDocKind,
9
9
  // ladder
10
10
  RUNGS, rungRank, rungAtLeast,
11
11
  // surfaces (event routing)
12
- SURFACE_EVENTS, SURFACE_RUNGS, bootRung,
12
+ SURFACE_EVENTS, SURFACE_RUNGS,
13
13
  // parse + render-shared helpers
14
14
  parseSubstrateFrontmatter, parseSubstrateDoc, previewLine,
15
15
  // display-name normalization
@@ -17,7 +17,8 @@ normalizeNameSegment, normalizeDocName,
17
17
  // substrate identity (explicit-name-then-path-fallback)
18
18
  resolveDocName, } from './schema.js';
19
19
  export { lintSubstrateFrontmatter, lintSubstrateSchema, lintSubstrateSurfaces, parsedSubstrateSurfaces } from './frontmatter-validation.js';
20
- export { matchesReadEntry, matchesMemoryReadEntry, matchesCommandEntry, readDeliveryRung, workspaceOpenRung, memoryReadDeliveryRung, commandDeliveryRung, owningRootOf, underOwningRoot } from './surface-match.js';
20
+ export { matchesReadEntry, matchesMemoryReadEntry, matchesCommandEntry, readDeliveryRung, workspaceOpenRung, memoryReadDeliveryRung, commandDeliveryRung, matchingSurfaceEntry, surfaceEntryParticipates, owningRootOf, underOwningRoot } from './surface-match.js';
21
+ export { planDelivery, bootPartitionOf } from './plan.js';
21
22
  export { dirDedupKey, docsByName, isDirName, ancestorDirsOf, renderDirListing } from './listings.js';
22
23
  export { clearContextExposureState, cloneContextExposureState, cloneTranscriptExposureState, contentExposureIdentity, contextExposureStatesEqual, documentExposedAtOrAbove, emptyContextExposureState, exposedAtOrAbove, exposureTarget, freezePreferenceSnapshot, hasTranscriptExposure, initializeForkContextExposure, loadContextExposureState, mergeContextExposureState, registerDocumentExposure, registerExposure, replaceContextExposureState, saveContextExposureState, sharedContextExposureState, } from './injected-store.js';
23
24
  export { scopeForCwd, profileNameFor } from './subject-fields.js';
@@ -25,5 +26,5 @@ export { spineDepth, assembleNodeSubject } from './subject.js';
25
26
  export { gatePasses } from './gate.js';
26
27
  export { renderPreferencesForSubject, renderKnowledgeForSubject } from './render.js';
27
28
  export { renderPreferencesSection, renderKnowledgeBlock } from './render-node.js';
28
- export { renderOnReadDocsForSubject, renderWorkspaceOpenDocsForSubject, renderOnCommandDocsForSubject, renderCandidateBlocks, memoryReadDocBlocks } from './on-read.js';
29
+ export { renderOnReadDocsForSubject, renderWorkspaceOpenDocsForSubject, renderOnCommandDocsForSubject, memoryReadDocBlocks } from './on-read.js';
29
30
  export { renderOnReadDocs, renderWorkspaceOpenDocs } from './on-read-node.js';
@@ -1,12 +1,5 @@
1
1
  import { type ContextExposureState, type ExposureTarget } from './injected-store.js';
2
- import { type Rung, type SubstrateDoc } from './schema.js';
3
2
  import type { NodeConfigSubject } from './subject-fields.js';
4
- interface EventCandidate {
5
- doc: SubstrateDoc;
6
- rung: Rung;
7
- }
8
- /** Render the selected candidates not already loaded at their matched rung. */
9
- export declare function renderCandidateBlocks(subject: NodeConfigSubject | null, candidates: EventCandidate[], target: ExposureTarget): string[];
10
3
  /** Surface docs whose `read` entries match a successfully read file, using
11
4
  * the caller-supplied daemon snapshot subject. */
12
5
  export declare function renderOnReadDocsForSubject(subject: NodeConfigSubject, readFilePath: string, target?: ExposureTarget): string;
@@ -46,4 +39,3 @@ export declare function corpusHasPreCommandSurfaces(): boolean;
46
39
  * held once and then silently absent for the rest of the session. Lives here
47
40
  * rather than in the extension because the corpus accessor is private. */
48
41
  export declare function demotePreCommandExposures(state: ContextExposureState): void;
49
- export {};
@@ -33,20 +33,20 @@
33
33
  // renders at the highest rung matched by that event.
34
34
  //
35
35
  // Equal-canonical candidates (the same identity in two physical stores) do not
36
- // each deliver: `selectEventCandidates` collapses them to the first one
37
- // ELIGIBLE for that event, in source order.
38
- import { homedir } from 'node:os';
39
- import { dirname, parse, sep } from 'node:path';
40
- import { readText, realpathOrSelf } from '../fs-utils.js';
41
- import { parseFrontmatterGeneric } from '../frontmatter.js';
42
- import { listAllMemoryDocs, listProjectMemoryDocs, loadStoreMemoryDocs, openProjectMemoryStore } from '../memory-resolver.js';
43
- import { userScopeRoot } from '../scope.js';
44
- import { gatePasses } from './gate.js';
45
- import { commandDeliveryRung, memoryReadDeliveryRung, owningRootOf, preCommandDeliveryRung, readDeliveryRung, workspaceOpenRung } from './surface-match.js';
36
+ // each deliver: the plan collapses them to the first one ELIGIBLE for that
37
+ // event, in corpus order.
38
+ //
39
+ // Every selection decision — discovery, gates, the profile cap, the rung fold,
40
+ // the first-wins collapse — belongs to planDelivery (plan.ts). This module is
41
+ // the plan→text adapter: it owns the `<memory>` envelope, the
42
+ // `<auto-loaded-context>` wrapper, and the exposure ledger.
43
+ import { sep } from 'node:path';
44
+ import { ambientMemoryTarget, listAllMemoryDocs } from '../memory-resolver.js';
45
+ import { owningRootOf } from './surface-match.js';
46
46
  import { demoteDocumentTranscriptExposure, documentExposedAtOrAbove, emptyContextExposureState, exposureTarget, registerDocumentExposure, } from './injected-store.js';
47
- import { minRung, parseSubstrateDoc, previewLine } from './schema.js';
47
+ import { planDelivery } from './plan.js';
48
+ import { parseSubstrateDoc, previewLine } from './schema.js';
48
49
  import { cachedEventCorpusInclusive } from './session-cache.js';
49
- const JUNK_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.cache', '.yalc']);
50
50
  function attr(s) {
51
51
  return s
52
52
  .replace(/&/g, '&amp;')
@@ -54,48 +54,11 @@ function attr(s) {
54
54
  .replace(/</g, '&lt;')
55
55
  .replace(/>/g, '&gt;');
56
56
  }
57
- function isJunkAncestor(dir) {
58
- return dir.split(sep).some((segment) => JUNK_DIRS.has(segment));
59
- }
60
- /** Project docs from every workspace store enclosing the read file, nearest
61
- * first. This is a discovery walk only: each returned doc must still match an
62
- * explicit trigger. A store that declares no namespace does not mount here
63
- * either — an unmounted store has no canonical identities to deliver under.
64
- * The stores carry the neutral ceiling: the read event is uncapped by design,
65
- * reached only by an explicit file read. */
66
- function enclosingProjectDocs(absReadFile) {
67
- const out = [];
68
- const seen = new Set();
69
- const home = realpathOrSelf(homedir());
70
- const userRoot = realpathOrSelf(userScopeRoot());
71
- const fsRoot = parse(absReadFile).root;
72
- let dir = dirname(absReadFile);
73
- while (true) {
74
- if (dir !== home && dir !== userRoot && !isJunkAncestor(dir)) {
75
- const store = openProjectMemoryStore(dir);
76
- if (store.mountStatus === 'ready' && !seen.has(store.storeRoot)) {
77
- seen.add(store.storeRoot);
78
- for (const source of loadStoreMemoryDocs(store, true)) {
79
- const doc = parseSubstrateDoc(source);
80
- if (doc !== null)
81
- out.push({ doc, source });
82
- }
83
- }
84
- }
85
- if (dir === home || dir === fsRoot)
86
- break;
87
- const parent = dirname(dir);
88
- if (parent === dir)
89
- break;
90
- dir = parent;
91
- }
92
- return out;
93
- }
94
57
  // The event corpus INCLUDES node-scope docs: surfaces entries are explicit
95
58
  // authored routing, so a node-local doc's read/memory-read/command entry
96
- // fires like any other store's. Only boot excludes node scope (render.ts's
97
- // nodeLocalDocs owns node-local boot rendering); the active exposure state
98
- // prevents a lower-rung repeat here.
59
+ // fires like any other store's. Only boot excludes node scope (render.ts owns
60
+ // node-local boot rendering); the active exposure state prevents a lower-rung
61
+ // repeat here.
99
62
  function resolvedDocs() {
100
63
  try {
101
64
  return cachedEventCorpusInclusive(listAllMemoryDocs, parseSubstrateDoc);
@@ -104,16 +67,6 @@ function resolvedDocs() {
104
67
  return [];
105
68
  }
106
69
  }
107
- function readFileFrontmatter(absReadFile) {
108
- if (!/\.(md|mdx|markdown)$/i.test(absReadFile))
109
- return {};
110
- try {
111
- return parseFrontmatterGeneric(readText(absReadFile)).data ?? {};
112
- }
113
- catch {
114
- return {};
115
- }
116
- }
117
70
  // Two shapes: element-with-body means the content is here; self-closing means
118
71
  // it is not — a preview carries its routing line as `readWhen` metadata so the
119
72
  // one-liner can never be mistaken for the document's content. No disk path:
@@ -135,37 +88,14 @@ function renderDocEnvelope(doc, rung) {
135
88
  }
136
89
  return `<memory ${attrs} />`;
137
90
  }
138
- /** The eligible candidate per canonical identity, in the caller's SOURCE order.
139
- * Kind and mount are settled by the loader and the profile cap by the caller's
140
- * rung fold, so what remains is the document gate over candidates the event
141
- * already gave a delivering rung. A nearer candidate that is gated off or does
142
- * not deliver never suppresses a farther one — the filter runs before the
143
- * first-wins collapse, not after it.
144
- *
145
- * A `null` subject skips gated documents and still delivers ungated ones. */
146
- function selectEventCandidates(subject, candidates) {
147
- const selected = [];
148
- const seen = new Set();
149
- for (const candidate of candidates) {
150
- const { doc, rung } = candidate;
151
- if (rung === 'none' || seen.has(doc.name))
152
- continue;
153
- try {
154
- if (subject === null ? doc.gate !== undefined : !gatePasses(doc, subject))
155
- continue;
156
- }
157
- catch {
158
- continue;
159
- }
160
- seen.add(doc.name);
161
- selected.push(candidate);
162
- }
163
- return selected;
91
+ /** One winner per doc, in plan (precedence) order. */
92
+ function planWinners(plan) {
93
+ return plan.docs.filter((r) => r.winner).map((r) => ({ doc: r.doc, rung: r.finalRung }));
164
94
  }
165
- /** Render the selected candidates not already loaded at their matched rung. */
166
- export function renderCandidateBlocks(subject, candidates, target) {
95
+ /** Render the plan's winners that are not already loaded at their matched rung. */
96
+ function renderWinnerBlocks(winners, target) {
167
97
  const rendered = [];
168
- for (const { doc, rung } of selectEventCandidates(subject, candidates)) {
98
+ for (const { doc, rung } of winners) {
169
99
  if (documentExposedAtOrAbove(target.state, doc.path, doc.body, rung))
170
100
  continue;
171
101
  const block = renderDocEnvelope(doc, rung);
@@ -179,23 +109,19 @@ export function renderCandidateBlocks(subject, candidates, target) {
179
109
  function transientTranscriptTarget() {
180
110
  return exposureTarget(emptyContextExposureState(), 'transcript');
181
111
  }
182
- function renderCandidates(subject, candidates, target) {
183
- const rendered = renderCandidateBlocks(subject, candidates, target);
184
- return rendered.length === 0
185
- ? ''
186
- : `<auto-loaded-context>\n${rendered.join('\n')}\n</auto-loaded-context>`;
112
+ function wrapAutoLoaded(blocks) {
113
+ return blocks.length === 0 ? '' : `<auto-loaded-context>\n${blocks.join('\n')}\n</auto-loaded-context>`;
114
+ }
115
+ /** The corpus-carrying plan for one event. Every renderer here passes the
116
+ * session-cached corpus so a hook never re-walks the filesystem. */
117
+ function eventPlan(subject, event, payload) {
118
+ return planDelivery(subject, ambientMemoryTarget(), event, payload, resolvedDocs());
187
119
  }
188
120
  /** Surface docs whose `read` entries match a successfully read file, using
189
121
  * the caller-supplied daemon snapshot subject. */
190
122
  export function renderOnReadDocsForSubject(subject, readFilePath, target = transientTranscriptTarget()) {
191
- const absReadFile = realpathOrSelf(readFilePath);
192
- const readFrontmatter = readFileFrontmatter(absReadFile);
193
- // Enclosing stores lead: a store discovered from the read file is the nearest
194
- // source of an identity the mounted corpus may also carry.
195
- const candidates = [...enclosingProjectDocs(absReadFile), ...resolvedDocs()]
196
- .filter(({ doc }) => realpathOrSelf(doc.path) !== absReadFile)
197
- .map(({ doc }) => ({ doc, rung: readDeliveryRung(doc, subject, absReadFile, readFrontmatter) }));
198
- return renderCandidates(subject, candidates, target);
123
+ const plan = eventPlan(subject, 'read', { absoluteFile: readFilePath });
124
+ return wrapAutoLoaded(renderWinnerBlocks(planWinners(plan), target));
199
125
  }
200
126
  /** Workspace boot docs using the snapshot's cwd/profile instead of a canvas
201
127
  * lookup. The shared exposure target prevents a later read from repeating a
@@ -206,25 +132,24 @@ export function renderOnReadDocsForSubject(subject, readFilePath, target = trans
206
132
  * registration. A later explicit `crtr memory read` can still raise a document
207
133
  * that workspace-open disclosed at a lower rung. */
208
134
  export function renderWorkspaceOpenDocsForSubject(subject, cwd, profileId, target = transientTranscriptTarget()) {
209
- let docs;
135
+ const workspaceTarget = { cwd, profileId, nodeId: null };
136
+ let winners;
210
137
  try {
211
- docs = listProjectMemoryDocs(cwd, profileId)
212
- .map(parseSubstrateDoc)
213
- .filter((doc) => doc !== null);
138
+ winners = planWinners(planDelivery(subject, workspaceTarget, 'workspace-open'));
214
139
  }
215
140
  catch {
216
141
  return '';
217
142
  }
218
- // Selection runs on the corpus's SOURCE order, so the nearest store wins an
219
- // equal-canonical tie; the sort below is display order only — outermost
220
- // workspace first, so context arrives general before specific.
221
- const ordered = selectEventCandidates(subject, docs.map((doc) => ({ doc, rung: minRung(workspaceOpenRung(doc, subject), doc.projectMemory) }))).sort((a, b) => {
143
+ // Selection ran on the corpus's PRECEDENCE order, so the nearest store won an
144
+ // equal-canonical tie; this sort is display order only — outermost workspace
145
+ // first, so context arrives general before specific.
146
+ winners.sort((a, b) => {
222
147
  const aRoot = owningRootOf(a.doc) ?? '';
223
148
  const bRoot = owningRootOf(b.doc) ?? '';
224
149
  const depth = aRoot.split(sep).filter(Boolean).length - bRoot.split(sep).filter(Boolean).length;
225
150
  return depth || aRoot.localeCompare(bRoot) || a.doc.path.localeCompare(b.doc.path);
226
151
  });
227
- return renderCandidates(subject, ordered, target);
152
+ return wrapAutoLoaded(renderWinnerBlocks(winners, target));
228
153
  }
229
154
  /** Inner `<memory>` blocks for the memory-read event: docs whose
230
155
  * `memory-read` entries match the doc a `crtr memory read` just resolved
@@ -233,20 +158,17 @@ export function renderWorkspaceOpenDocsForSubject(subject, cwd, profileId, targe
233
158
  * unwrapped blocks — the read leaf composes one `<auto-loaded-context>`
234
159
  * envelope from these plus the directory listings. */
235
160
  export function memoryReadDocBlocks(subject, excludeRealpath, name, target) {
236
- const candidates = resolvedDocs()
237
- .filter(({ doc }) => realpathOrSelf(doc.path) !== excludeRealpath)
238
- .map(({ doc, source }) => ({
239
- doc,
240
- rung: memoryReadDeliveryRung(doc, source.routingAnchor, subject, name),
241
- }));
242
- return renderCandidateBlocks(subject, candidates, target);
161
+ const plan = eventPlan(subject, 'memory-read', {
162
+ resolvedCanonicalName: name,
163
+ resolvedDocPath: excludeRealpath,
164
+ });
165
+ return renderWinnerBlocks(planWinners(plan), target);
243
166
  }
244
167
  /** Surface docs whose `command` entries match an executed bash command.
245
168
  * The corpus is the resolved cwd/profile set; a command has no file from which
246
169
  * to discover enclosing project stores. */
247
170
  export function renderOnCommandDocsForSubject(subject, command, target = transientTranscriptTarget()) {
248
- const candidates = resolvedDocs().map(({ doc }) => ({ doc, rung: commandDeliveryRung(doc, subject, command) }));
249
- return renderCandidates(subject, candidates, target);
171
+ return wrapAutoLoaded(renderWinnerBlocks(planWinners(eventPlan(subject, 'command', { command })), target));
250
172
  }
251
173
  /** Surface docs whose `pre-command` entries match a bash command that has NOT
252
174
  * run yet. The exact twin of the post-execution renderer above, and its empty
@@ -254,8 +176,7 @@ export function renderOnCommandDocsForSubject(subject, command, target = transie
254
176
  * its matching rung or above, which is the release — the caller lets the
255
177
  * command through. */
256
178
  export function renderPreCommandDocsForSubject(subject, command, target = transientTranscriptTarget()) {
257
- const candidates = resolvedDocs().map(({ doc }) => ({ doc, rung: preCommandDeliveryRung(doc, subject, command) }));
258
- return renderCandidates(subject, candidates, target);
179
+ return wrapAutoLoaded(renderWinnerBlocks(planWinners(eventPlan(subject, 'pre-command', { command })), target));
259
180
  }
260
181
  function carriesPreCommandEntry(doc) {
261
182
  return doc.surfaces.some((entry) => entry.on === 'pre-command');