@north-light/crouter 0.3.236 → 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 (174) 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 +10 -2
  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 +426 -426
  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/memory/lint.js +22 -13
  20. package/dist/commands/memory/shared.js +1 -1
  21. package/dist/commands/node/create.d.ts +1 -1
  22. package/dist/commands/node/create.js +1 -1
  23. package/dist/commands/node/lifecycle.js +1 -1
  24. package/dist/commands/node-worktree.js +39 -8
  25. package/dist/commands/revive.js +11 -2
  26. package/dist/commands/sys/context/admin/actions.d.ts +48 -0
  27. package/dist/commands/sys/context/admin/actions.js +246 -0
  28. package/dist/commands/sys/context/admin/axes-panel.d.ts +21 -0
  29. package/dist/commands/sys/context/admin/axes-panel.js +117 -0
  30. package/dist/commands/sys/context/admin/detail-panel.d.ts +53 -0
  31. package/dist/commands/sys/context/admin/detail-panel.js +178 -0
  32. package/dist/commands/sys/context/admin/docs-panel.d.ts +45 -0
  33. package/dist/commands/sys/context/admin/docs-panel.js +186 -0
  34. package/dist/commands/sys/context/admin/model.d.ts +75 -0
  35. package/dist/commands/sys/context/admin/model.js +209 -0
  36. package/dist/commands/sys/context/admin/shell.d.ts +82 -0
  37. package/dist/commands/sys/context/admin/shell.js +669 -0
  38. package/dist/commands/sys/context/admin.d.ts +1 -0
  39. package/dist/commands/sys/context/admin.js +124 -0
  40. package/dist/commands/sys/context/doc.d.ts +1 -0
  41. package/dist/commands/sys/context/doc.js +196 -0
  42. package/dist/commands/sys/context/prompt-review.d.ts +1 -0
  43. package/dist/commands/sys/{prompt-review.js → context/prompt-review.js} +10 -10
  44. package/dist/commands/sys/context/resolve.d.ts +55 -0
  45. package/dist/commands/sys/context/resolve.js +428 -0
  46. package/dist/commands/sys/context/sysprompt.d.ts +1 -0
  47. package/dist/commands/sys/{sysprompt.js → context/sysprompt.js} +11 -11
  48. package/dist/commands/sys/context.d.ts +2 -0
  49. package/dist/commands/sys/context.js +20 -0
  50. package/dist/commands/sys.js +3 -4
  51. package/dist/core/__tests__/child-death-wake.test.js +38 -22
  52. package/dist/core/__tests__/cron-self-cancel-delivery.test.js +72 -0
  53. package/dist/core/__tests__/daemon-boot.test.js +40 -28
  54. package/dist/core/__tests__/human-deliver.test.js +1 -1
  55. package/dist/core/__tests__/human-node-not-supervised.test.js +10 -15
  56. package/dist/core/__tests__/integration/revive.test.js +12 -12
  57. package/dist/core/__tests__/integration/spawn-root.test.js +6 -0
  58. package/dist/core/__tests__/integration/worktree-reap.test.js +32 -0
  59. package/dist/core/__tests__/lifecycle-terminal-reasons.test.d.ts +1 -0
  60. package/dist/core/__tests__/lifecycle-terminal-reasons.test.js +61 -0
  61. package/dist/core/__tests__/migration.test.js +38 -1
  62. package/dist/core/__tests__/push-final-guard.test.js +1 -1
  63. package/dist/core/__tests__/revive-capacity.test.d.ts +1 -0
  64. package/dist/core/__tests__/revive-capacity.test.js +127 -0
  65. package/dist/core/__tests__/revive-parked-fresh.test.js +7 -7
  66. package/dist/core/__tests__/seam/broker-cap-freeze.test.d.ts +1 -0
  67. package/dist/core/__tests__/seam/broker-cap-freeze.test.js +81 -0
  68. package/dist/core/__tests__/seam/dormancy-release.test.js +7 -7
  69. package/dist/core/__tests__/seam/yield-refresh-transaction.test.js +1 -1
  70. package/dist/core/canvas/browse/render.js +11 -3
  71. package/dist/core/canvas/canvas.d.ts +15 -3
  72. package/dist/core/canvas/canvas.js +52 -16
  73. package/dist/core/canvas/migrations.js +21 -0
  74. package/dist/core/canvas/nav-render.js +3 -1
  75. package/dist/core/canvas/remote-canvas-source.js +2 -0
  76. package/dist/core/canvas/render-source.d.ts +3 -1
  77. package/dist/core/canvas/render-source.js +7 -3
  78. package/dist/core/canvas/status-glyph.d.ts +10 -1
  79. package/dist/core/canvas/status-glyph.js +19 -3
  80. package/dist/core/canvas/types.d.ts +20 -1
  81. package/dist/core/feed/feed.js +1 -1
  82. package/dist/core/human/feedback-companion.js +5 -2
  83. package/dist/core/memory/lint.d.ts +19 -0
  84. package/dist/core/memory/lint.js +97 -0
  85. package/dist/core/memory-resolver.d.ts +4 -0
  86. package/dist/core/memory-resolver.js +7 -7
  87. package/dist/core/preview-registry.js +4 -4
  88. package/dist/core/review/realize.js +4 -2
  89. package/dist/core/runtime/close.js +1 -1
  90. package/dist/core/runtime/fleet.d.ts +29 -1
  91. package/dist/core/runtime/fleet.js +23 -0
  92. package/dist/core/runtime/host.js +32 -11
  93. package/dist/core/runtime/launch-prompt.d.ts +1 -1
  94. package/dist/core/runtime/launch-prompt.js +2 -2
  95. package/dist/core/runtime/lifecycle.d.ts +4 -2
  96. package/dist/core/runtime/lifecycle.js +12 -1
  97. package/dist/core/runtime/memory.js +1 -1
  98. package/dist/core/runtime/reopen.js +10 -1
  99. package/dist/core/runtime/reset.js +1 -1
  100. package/dist/core/runtime/revive-all.d.ts +3 -0
  101. package/dist/core/runtime/revive-all.js +10 -4
  102. package/dist/core/runtime/revive.d.ts +12 -0
  103. package/dist/core/runtime/revive.js +62 -3
  104. package/dist/core/runtime/spawn.d.ts +4 -0
  105. package/dist/core/runtime/spawn.js +36 -7
  106. package/dist/core/substrate/frontmatter-validation.js +1 -1
  107. package/dist/core/substrate/index.d.ts +6 -3
  108. package/dist/core/substrate/index.js +4 -3
  109. package/dist/core/substrate/on-read.d.ts +0 -8
  110. package/dist/core/substrate/on-read.js +45 -124
  111. package/dist/core/substrate/plan.d.ts +94 -0
  112. package/dist/core/substrate/plan.js +266 -0
  113. package/dist/core/substrate/render.d.ts +5 -6
  114. package/dist/core/substrate/render.js +76 -129
  115. package/dist/core/substrate/schema.d.ts +1 -6
  116. package/dist/core/substrate/schema.js +1 -15
  117. package/dist/core/substrate/session-cache.d.ts +2 -10
  118. package/dist/core/substrate/session-cache.js +9 -47
  119. package/dist/core/substrate/subject-fields.d.ts +1 -4
  120. package/dist/core/substrate/subject-fields.js +2 -5
  121. package/dist/core/substrate/surface-match.d.ts +37 -17
  122. package/dist/core/substrate/surface-match.js +44 -53
  123. package/dist/core/tui/page-host.d.ts +6 -0
  124. package/dist/core/tui/page-host.js +12 -3
  125. package/dist/core/worktree.d.ts +8 -0
  126. package/dist/core/worktree.js +65 -0
  127. package/dist/daemon/__tests__/reconciler-signature.test.js +3 -2
  128. package/dist/daemon/__tests__/thaw-order.test.d.ts +1 -0
  129. package/dist/daemon/__tests__/thaw-order.test.js +55 -0
  130. package/dist/daemon/api/__tests__/canvas-snapshot-fields.test.js +6 -0
  131. package/dist/daemon/api/__tests__/node-create-description.test.js +6 -0
  132. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +1 -1
  133. package/dist/daemon/api/bridge.js +5 -1
  134. package/dist/daemon/api/handlers/attach.js +5 -1
  135. package/dist/daemon/api/handlers/bash-jobs.js +1 -1
  136. package/dist/daemon/api/handlers/messages.js +10 -2
  137. package/dist/daemon/api/handlers/nodes.js +8 -1
  138. package/dist/daemon/api/handlers/worktree.js +10 -1
  139. package/dist/daemon/api/map.js +7 -0
  140. package/dist/daemon/companion-retire.js +1 -1
  141. package/dist/daemon/cron/passes.d.ts +0 -5
  142. package/dist/daemon/cron/passes.js +0 -20
  143. package/dist/daemon/cron/sinks.js +1 -1
  144. package/dist/daemon/cron-run.d.ts +5 -6
  145. package/dist/daemon/cron-run.js +7 -16
  146. package/dist/daemon/crtrd.d.ts +0 -10
  147. package/dist/daemon/crtrd.js +23 -61
  148. package/dist/daemon/fleet.d.ts +10 -7
  149. package/dist/daemon/fleet.js +71 -12
  150. package/dist/daemon/messaging/node-message.js +3 -1
  151. package/dist/daemon/profile-delete.js +1 -1
  152. package/dist/daemon/reconcilers/broker-supervision.d.ts +26 -9
  153. package/dist/daemon/reconcilers/broker-supervision.js +39 -52
  154. package/dist/daemon/reconcilers/controller-death.d.ts +7 -0
  155. package/dist/daemon/reconcilers/controller-death.js +26 -0
  156. package/dist/daemon/reconcilers/cron-lane.d.ts +0 -3
  157. package/dist/daemon/reconcilers/cron-lane.js +0 -3
  158. package/dist/daemon/reconcilers/live-obligation.js +3 -1
  159. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.d.ts +53 -0
  160. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +170 -0
  161. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.d.ts +5 -0
  162. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.js +16 -0
  163. package/dist/daemon/reconcilers/node-lifecycle/terminating.d.ts +4 -0
  164. package/dist/daemon/reconcilers/node-lifecycle/terminating.js +13 -0
  165. package/dist/daemon/reconcilers/node-lifecycle/tick.d.ts +26 -0
  166. package/dist/daemon/reconcilers/node-lifecycle/tick.js +140 -0
  167. package/package.json +1 -1
  168. package/runtime.lock.json +2 -2
  169. package/dist/commands/sys/prompt-review.d.ts +0 -1
  170. package/dist/commands/sys/sysprompt.d.ts +0 -1
  171. package/dist/core/__tests__/cron-broker-capacity.test.js +0 -119
  172. package/dist/daemon/reconcilers/dormant-inbox.d.ts +0 -18
  173. package/dist/daemon/reconcilers/dormant-inbox.js +0 -157
  174. /package/dist/core/__tests__/{cron-broker-capacity.test.d.ts → cron-self-cancel-delivery.test.d.ts} +0 -0
@@ -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');
@@ -0,0 +1,94 @@
1
+ import { type MemoryDoc, type MemoryScope, type MemoryTarget } from '../memory-resolver.js';
2
+ import { type DocKind, type Rung, type SubstrateDoc, type SurfaceEntry, type SurfaceEvent } from './schema.js';
3
+ import type { EventCorpusDoc } from './session-cache.js';
4
+ import type { NodeConfigSubject } from './subject-fields.js';
5
+ /** What the event exposes to match against. Every field is optional: an event
6
+ * invoked without its payload matches nothing rather than throwing. */
7
+ export interface DeliveryPayload {
8
+ /** `read`: the file that was read. Realpathed by the planner. */
9
+ absoluteFile?: string;
10
+ /** `read`: the read file's own frontmatter. Parsed from the file when omitted. */
11
+ readFrontmatter?: Record<string, unknown>;
12
+ /** `memory-read`: the exact canonical name the read resolved to. */
13
+ resolvedCanonicalName?: string;
14
+ /** `memory-read`: realpath of the resolved document, which never delivers to
15
+ * itself. */
16
+ resolvedDocPath?: string;
17
+ /** `command` / `pre-command`: the command string. */
18
+ command?: string;
19
+ }
20
+ /** A gate verdict with the reason a failure carries. */
21
+ export type GateOutcome = {
22
+ pass: true;
23
+ } | {
24
+ pass: false;
25
+ reason: string;
26
+ };
27
+ /** One authored surface entry of this event and how it fared. `matched` folds
28
+ * the entry's constraints AND its own gate; `gate` isolates the gate so a
29
+ * reader can tell "did not fit" from "excluded by this node's config". */
30
+ export interface EntryDecision {
31
+ entry: SurfaceEntry;
32
+ matched: boolean;
33
+ gate: GateOutcome;
34
+ }
35
+ /** One document's delivery decision for one event. */
36
+ export interface DeliveryRecord {
37
+ /** Canonical name — the identity dedup resolves. */
38
+ name: string;
39
+ kind: DocKind;
40
+ scope: MemoryScope;
41
+ /** Absolute path to the document file. */
42
+ storePath: string;
43
+ /** From the node's own store, which is floored rather than capped at boot. */
44
+ nodeLocal: boolean;
45
+ /** Every entry the document authored for this event. */
46
+ authoredEntries: EntryDecision[];
47
+ /** The highest-rung entry that matched, or null when none did. */
48
+ matchedEntry: SurfaceEntry | null;
49
+ /** The matched entry's rung — what the author asked for, before the cap. */
50
+ authoredRung: Rung;
51
+ /** The profile relationship's cap on this document's store, or null when the
52
+ * event is uncapped or the document is node-local. */
53
+ projectMemoryCap: Rung | null;
54
+ /** `minRung(authoredRung, projectMemoryCap)`. */
55
+ cappedRung: Rung;
56
+ docGate: GateOutcome;
57
+ /** The matched entry's gate, or the verdict that excluded every authored
58
+ * entry; null when the document authored no entry for this event. */
59
+ entryGate: GateOutcome | null;
60
+ /** What this document delivers at after gates, cap, and the boot node-local
61
+ * floor. `none` = delivers nothing. */
62
+ finalRung: Rung;
63
+ /** This document is the one its canonical name resolves to for this event. */
64
+ winner: boolean;
65
+ /** The document that took this one's name, when it lost the dedup. */
66
+ shadowedBy: {
67
+ name: string;
68
+ scope: MemoryScope;
69
+ path: string;
70
+ } | null;
71
+ /** The parsed document — the renderers' input. */
72
+ doc: SubstrateDoc;
73
+ /** The resolver descriptor behind it: physical path, representation, and the
74
+ * routing anchor a `./` memory-read glob matches against. */
75
+ source: MemoryDoc;
76
+ }
77
+ /** Every document considered for one event, in corpus (precedence) order. */
78
+ export interface DeliveryPlan {
79
+ event: SurfaceEvent;
80
+ subject: NodeConfigSubject | null;
81
+ target: MemoryTarget;
82
+ docs: DeliveryRecord[];
83
+ }
84
+ /** Which boot block a record belongs to. The node's own store is the catch-all
85
+ * this-node store and rides into the knowledge block whatever its kind, so a
86
+ * record's kind alone does not answer this. */
87
+ export declare function bootPartitionOf(record: Pick<DeliveryRecord, 'kind' | 'nodeLocal'>): DocKind;
88
+ /** Plan one event's delivery.
89
+ *
90
+ * `corpus` is the already-loaded document set. The live renderers pass their
91
+ * session cache (the corpus is scanned and parsed once per session); an
92
+ * inspection caller omits it and the planner loads the event's corpus from
93
+ * `target`. Either way the planner writes nothing. */
94
+ export declare function planDelivery(subject: NodeConfigSubject | null, target: MemoryTarget, event: SurfaceEvent, payload?: DeliveryPayload, corpus?: readonly EventCorpusDoc[]): DeliveryPlan;
@@ -0,0 +1,266 @@
1
+ // plan.ts — the pure delivery planner: for one (subject, target, event,
2
+ // payload) tuple it computes the structured per-document delivery decision the
3
+ // renderers turn into text and the inspection commands print as a table. It
4
+ // renders nothing and touches no exposure state.
5
+ //
6
+ // The pipeline has two halves and this module is the UPSTREAM one:
7
+ //
8
+ // planDelivery (subject,event -> decisions) -> exposure ledger dedup ->
9
+ // render -> registerDocumentExposure
10
+ //
11
+ // The ledger (injected-store.ts) records what a live context has ALREADY been
12
+ // given; the planner computes what a state WOULD be given. Renderers own the
13
+ // downstream half, so a plan can be computed for a hypothetical subject without
14
+ // disturbing any node's real ledger.
15
+ //
16
+ // Two decisions are event-specific and deliberately so:
17
+ //
18
+ // • THE PROFILE CAP reaches boot and workspace-open only. Every other event
19
+ // is reached by an explicit act (a read, a command) and delivers at the
20
+ // authored rung.
21
+ // • DEDUP PARTICIPATION differs. At boot every ELIGIBLE document participates
22
+ // — including one with no boot entry, because the boot catalog counts it
23
+ // into its directory's `[+N more]` — and the dedup runs per boot partition,
24
+ // since the boot render emits an independent block per partition. At every
25
+ // other event only a document that actually DELIVERS participates, so a
26
+ // nearer document that does not match never suppresses a farther one that
27
+ // does.
28
+ import { homedir } from 'node:os';
29
+ import { dirname, parse, sep } from 'node:path';
30
+ import { parseFrontmatterGeneric } from '../frontmatter.js';
31
+ import { readText, realpathOrSelf } from '../fs-utils.js';
32
+ import { listProjectMemoryDocs, loadMemoryTargetView, loadStoreMemoryDocs, openProjectMemoryStore, } from '../memory-resolver.js';
33
+ import { userScopeRoot } from '../scope.js';
34
+ import { gatePasses, surfaceEntryGatePasses } from './gate.js';
35
+ import { minRung, parseSubstrateDoc, rungAtLeast, } from './schema.js';
36
+ import { matchingSurfaceEntry, surfaceEntryParticipates } from './surface-match.js';
37
+ /** Which boot block a record belongs to. The node's own store is the catch-all
38
+ * this-node store and rides into the knowledge block whatever its kind, so a
39
+ * record's kind alone does not answer this. */
40
+ export function bootPartitionOf(record) {
41
+ return record.nodeLocal ? 'knowledge' : record.kind;
42
+ }
43
+ /** Plan one event's delivery.
44
+ *
45
+ * `corpus` is the already-loaded document set. The live renderers pass their
46
+ * session cache (the corpus is scanned and parsed once per session); an
47
+ * inspection caller omits it and the planner loads the event's corpus from
48
+ * `target`. Either way the planner writes nothing. */
49
+ export function planDelivery(subject, target, event, payload = {}, corpus) {
50
+ const resolved = resolvePayload(event, payload);
51
+ const considered = considerFor(event, corpus ?? loadEventCorpus(target, event), resolved);
52
+ const docs = considered.map(({ doc, source }) => decide(doc, source, subject, event, resolved));
53
+ resolveWinners(event, docs);
54
+ return { event, subject, target, docs };
55
+ }
56
+ function resolvePayload(event, payload) {
57
+ if (event !== 'read' || payload.absoluteFile === undefined) {
58
+ return { ...payload, readFrontmatter: payload.readFrontmatter ?? {} };
59
+ }
60
+ const absoluteFile = realpathOrSelf(payload.absoluteFile);
61
+ return {
62
+ ...payload,
63
+ absoluteFile,
64
+ readFrontmatter: payload.readFrontmatter ?? readFileFrontmatter(absoluteFile),
65
+ };
66
+ }
67
+ function readFileFrontmatter(absReadFile) {
68
+ if (!/\.(md|mdx|markdown)$/i.test(absReadFile))
69
+ return {};
70
+ try {
71
+ return parseFrontmatterGeneric(readText(absReadFile)).data ?? {};
72
+ }
73
+ catch {
74
+ return {};
75
+ }
76
+ }
77
+ // The considered set — the corpus plus the event's own discovery and
78
+ // self-exclusion rules.
79
+ function considerFor(event, corpus, payload) {
80
+ if (event === 'read') {
81
+ const abs = payload.absoluteFile;
82
+ if (abs === undefined)
83
+ return [...corpus];
84
+ // Enclosing stores lead: a store discovered from the read file is the
85
+ // nearest source of an identity the mounted corpus may also carry.
86
+ return [...enclosingProjectDocs(abs), ...corpus].filter(({ doc }) => realpathOrSelf(doc.path) !== abs);
87
+ }
88
+ if (event === 'memory-read' && payload.resolvedDocPath !== undefined) {
89
+ const exclude = payload.resolvedDocPath;
90
+ return corpus.filter(({ doc }) => realpathOrSelf(doc.path) !== exclude);
91
+ }
92
+ return [...corpus];
93
+ }
94
+ const JUNK_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.cache', '.yalc']);
95
+ function isJunkAncestor(dir) {
96
+ return dir.split(sep).some((segment) => JUNK_DIRS.has(segment));
97
+ }
98
+ /** Project docs from every workspace store enclosing the read file, nearest
99
+ * first. This is a discovery walk only: each returned doc must still match an
100
+ * explicit trigger. A store that declares no namespace does not mount here
101
+ * either — an unmounted store has no canonical identities to deliver under.
102
+ * The stores carry the neutral ceiling: the read event is uncapped by design,
103
+ * reached only by an explicit file read. */
104
+ function enclosingProjectDocs(absReadFile) {
105
+ const out = [];
106
+ const seen = new Set();
107
+ const home = realpathOrSelf(homedir());
108
+ const userRoot = realpathOrSelf(userScopeRoot());
109
+ const fsRoot = parse(absReadFile).root;
110
+ let dir = dirname(absReadFile);
111
+ while (true) {
112
+ if (dir !== home && dir !== userRoot && !isJunkAncestor(dir)) {
113
+ const store = openProjectMemoryStore(dir);
114
+ if (store.mountStatus === 'ready' && !seen.has(store.storeRoot)) {
115
+ seen.add(store.storeRoot);
116
+ for (const source of loadStoreMemoryDocs(store, true)) {
117
+ const doc = parseSubstrateDoc(source);
118
+ if (doc !== null)
119
+ out.push({ doc, source });
120
+ }
121
+ }
122
+ }
123
+ if (dir === home || dir === fsRoot)
124
+ break;
125
+ const parent = dirname(dir);
126
+ if (parent === dir)
127
+ break;
128
+ dir = parent;
129
+ }
130
+ return out;
131
+ }
132
+ /** The event's corpus for a target, for callers that hold no session cache.
133
+ * Boot orders node-local docs LAST so a resolver document keeps its identity
134
+ * against a same-named node-local one; workspace-open sees project stores
135
+ * only; every other event sees the whole mounted corpus. */
136
+ function loadEventCorpus(target, event) {
137
+ const sources = event === 'workspace-open'
138
+ ? listProjectMemoryDocs(target.cwd, target.profileId)
139
+ : loadMemoryTargetView(target, { quiet: true }).docs;
140
+ const parsed = [];
141
+ for (const source of sources) {
142
+ const doc = parseSubstrateDoc(source);
143
+ if (doc !== null)
144
+ parsed.push({ doc, source });
145
+ }
146
+ if (event !== 'boot')
147
+ return parsed;
148
+ return [
149
+ ...parsed.filter((e) => e.doc.scope !== 'node'),
150
+ ...parsed.filter((e) => e.doc.scope === 'node'),
151
+ ];
152
+ }
153
+ // The per-document decision.
154
+ function decide(doc, source, subject, event, payload) {
155
+ const match = {
156
+ absoluteFile: payload.absoluteFile,
157
+ readFrontmatter: payload.readFrontmatter,
158
+ resolvedCanonicalName: payload.resolvedCanonicalName,
159
+ routingAnchor: source.routingAnchor,
160
+ command: payload.command,
161
+ };
162
+ const docGate = documentGateOutcome(doc, subject);
163
+ const authoredEntries = doc.surfaces
164
+ .filter((entry) => entry.on === event)
165
+ .map((entry) => ({
166
+ entry,
167
+ matched: surfaceEntryParticipates(entry, doc, subject, event, match),
168
+ gate: entryGateOutcome(entry, subject),
169
+ }));
170
+ const matchedEntry = matchingSurfaceEntry(doc, subject, event, match);
171
+ const authoredRung = matchedEntry?.at ?? 'none';
172
+ const nodeLocal = doc.scope === 'node';
173
+ const capped = event === 'boot' || event === 'workspace-open';
174
+ const projectMemoryCap = capped && !nodeLocal ? doc.projectMemory : null;
175
+ const cappedRung = projectMemoryCap === null ? authoredRung : minRung(authoredRung, projectMemoryCap);
176
+ let finalRung = docGate.pass ? cappedRung : 'none';
177
+ // The one rung FLOOR, scoped to the node store at boot: a node-local doc with
178
+ // no boot entry still shows its bare name rather than collapsing into its
179
+ // directory's hidden count. A node-local doc whose boot entries all fail
180
+ // stays absent.
181
+ if (event === 'boot' &&
182
+ nodeLocal &&
183
+ docGate.pass &&
184
+ !rungAtLeast(finalRung, 'name') &&
185
+ !doc.surfaces.some((entry) => entry.on === 'boot')) {
186
+ finalRung = 'name';
187
+ }
188
+ return {
189
+ name: doc.name,
190
+ kind: doc.kind,
191
+ scope: doc.scope,
192
+ storePath: doc.path,
193
+ nodeLocal,
194
+ authoredEntries,
195
+ matchedEntry,
196
+ authoredRung,
197
+ projectMemoryCap,
198
+ cappedRung,
199
+ docGate,
200
+ entryGate: recordEntryGate(authoredEntries, matchedEntry),
201
+ finalRung,
202
+ winner: false,
203
+ shadowedBy: null,
204
+ doc,
205
+ source,
206
+ };
207
+ }
208
+ /** A missing subject cannot satisfy a gate, so a gated document is skipped and
209
+ * an ungated one still delivers. A predicate that throws excludes only its own
210
+ * document — never the whole render. */
211
+ function documentGateOutcome(doc, subject) {
212
+ if (doc.gate === undefined)
213
+ return { pass: true };
214
+ if (subject === null)
215
+ return { pass: false, reason: 'gated document with no node config to match' };
216
+ try {
217
+ return gatePasses(doc, subject)
218
+ ? { pass: true }
219
+ : { pass: false, reason: 'document gate does not match this node config' };
220
+ }
221
+ catch (err) {
222
+ return { pass: false, reason: `document gate failed to evaluate: ${String(err)}` };
223
+ }
224
+ }
225
+ function entryGateOutcome(entry, subject) {
226
+ if (entry.gate === undefined)
227
+ return { pass: true };
228
+ return surfaceEntryGatePasses(entry, subject)
229
+ ? { pass: true }
230
+ : { pass: false, reason: 'entry gate does not match this node config' };
231
+ }
232
+ function recordEntryGate(entries, matched) {
233
+ if (entries.length === 0)
234
+ return null;
235
+ if (matched !== null)
236
+ return { pass: true };
237
+ const gatedOut = entries.find((e) => !e.gate.pass);
238
+ return gatedOut === undefined ? { pass: true } : gatedOut.gate;
239
+ }
240
+ // Winner resolution — first-win by canonical name over the corpus's precedence
241
+ // order, within the event's participating set.
242
+ function participates(record, event) {
243
+ if (!record.docGate.pass)
244
+ return false;
245
+ // Boot counts an eligible document even when it delivers nothing, because the
246
+ // catalog tree still counts it. A `none` cap removes the document from the
247
+ // corpus entirely, so it cannot shadow a wider-scope document either.
248
+ if (event === 'boot')
249
+ return record.projectMemoryCap !== 'none';
250
+ return rungAtLeast(record.finalRung, 'name');
251
+ }
252
+ function resolveWinners(event, docs) {
253
+ const winners = new Map();
254
+ for (const record of docs) {
255
+ if (!participates(record, event))
256
+ continue;
257
+ const key = event === 'boot' ? `${bootPartitionOf(record)}:${record.name}` : record.name;
258
+ const held = winners.get(key);
259
+ if (held === undefined) {
260
+ winners.set(key, record);
261
+ record.winner = true;
262
+ continue;
263
+ }
264
+ record.shadowedBy = { name: held.name, scope: held.scope, path: held.storePath };
265
+ }
266
+ }
@@ -4,7 +4,7 @@ import type { NodeConfigSubject } from './subject-fields.js';
4
4
  /** The computed "sub-personas you may spawn" menu for a subject's kind — the
5
5
  * live restoration of the old `personas/resolve.ts` static menu, now derived
6
6
  * at render time from the merged kind registry (`subKindsAvailableTo`) so it
7
- * can never drift from `sys prompt-review --list`'s `subPersonas` metadata.
7
+ * can never drift from `sys context prompt-review --list`'s `subPersonas` metadata.
8
8
  * Returns '' when the kind has zero available sub-kinds (no dangling empty
9
9
  * header). Emitted for BOTH base and orchestrator modes per the CTO's ruling —
10
10
  * mode is irrelevant to which sub-kinds are spawnable. */
@@ -20,11 +20,10 @@ export declare function renderPreferencesForSubject(subject: NodeConfigSubject |
20
20
  * doc PLUS the node-local memory docs (any kind) — resolver docs win
21
21
  * first-wins dedup over a same-named node-local doc. GROUPED by rung exactly
22
22
  * like the preference block: content prose first (general→specific,
23
- * node-local last), then the preview/name catalog tree. Node-local docs are
24
- * floored HERE — a bootless node-local doc's rung is bumped to `name` before
25
- * grouping, so it still surfaces as a bare-name tree entry rather than
26
- * collapsing into a `[+N more]` count (its body still never renders: floored
27
- * docs never reach the `content` rung). Procedural guidance and factual
23
+ * node-local last), then the preview/name catalog tree. A bootless node-local
24
+ * doc arrives floored to `name`, so it surfaces as a bare-name tree entry
25
+ * rather than collapsing into a `[+N more]` count (its body still never
26
+ * renders: floored docs never reach the `content` rung). Procedural guidance and factual
28
27
  * references both live here as `knowledge`. Returns '' when nothing is
29
28
  * eligible. */
30
29
  export declare function renderKnowledgeForSubject(subject: NodeConfigSubject, nodeId: string, target?: ExposureTarget): string;