@north-light/crouter 0.3.207 → 0.3.209

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 (172) hide show
  1. package/dist/api/client.d.ts +3 -1
  2. package/dist/api/client.js +4 -0
  3. package/dist/api/dto/broker-ops.d.ts +2 -12
  4. package/dist/api/dto/nodes.d.ts +16 -0
  5. package/dist/api/routes.d.ts +1 -0
  6. package/dist/api/routes.js +1 -0
  7. package/dist/builtin-memory/00-runtime-base.md +3 -2
  8. package/dist/builtin-memory/01-spine/00-has-manager.md +3 -2
  9. package/dist/builtin-memory/01-spine/01-no-manager.md +3 -2
  10. package/dist/builtin-memory/02-lifecycle/00-terminal.md +3 -2
  11. package/dist/builtin-memory/02-lifecycle/01-resident.md +3 -2
  12. package/dist/builtin-memory/04-base-worker.md +3 -2
  13. package/dist/builtin-memory/04-orchestration-kernel.md +3 -2
  14. package/dist/builtin-memory/05-kinds/advisor/00-base.md +3 -2
  15. package/dist/builtin-memory/05-kinds/advisor/01-orchestrator.md +3 -2
  16. package/dist/builtin-memory/05-kinds/design/00-base.md +3 -2
  17. package/dist/builtin-memory/05-kinds/design/01-orchestrator.md +3 -2
  18. package/dist/builtin-memory/05-kinds/developer/00-base.md +3 -2
  19. package/dist/builtin-memory/05-kinds/developer/01-orchestrator.md +3 -2
  20. package/dist/builtin-memory/05-kinds/explore/00-base.md +3 -2
  21. package/dist/builtin-memory/05-kinds/explore/01-orchestrator.md +3 -2
  22. package/dist/builtin-memory/05-kinds/general/00-base.md +3 -2
  23. package/dist/builtin-memory/05-kinds/general/01-orchestrator.md +3 -2
  24. package/dist/builtin-memory/05-kinds/plan/00-base.md +3 -2
  25. package/dist/builtin-memory/05-kinds/plan/01-orchestrator.md +3 -2
  26. package/dist/builtin-memory/05-kinds/plan/reviewers/00-base.md +3 -2
  27. package/dist/builtin-memory/05-kinds/plan/reviewers/architecture-fit.md +3 -2
  28. package/dist/builtin-memory/05-kinds/plan/reviewers/code-smells.md +3 -2
  29. package/dist/builtin-memory/05-kinds/plan/reviewers/pattern-consistency.md +3 -2
  30. package/dist/builtin-memory/05-kinds/plan/reviewers/requirements-coverage.md +3 -2
  31. package/dist/builtin-memory/05-kinds/plan/reviewers/security.md +3 -2
  32. package/dist/builtin-memory/05-kinds/review/00-base.md +3 -2
  33. package/dist/builtin-memory/05-kinds/review/01-orchestrator.md +3 -2
  34. package/dist/builtin-memory/05-kinds/review/companion/00-base.md +3 -2
  35. package/dist/builtin-memory/05-kinds/spec/00-base.md +3 -2
  36. package/dist/builtin-memory/05-kinds/spec/01-orchestrator.md +3 -2
  37. package/dist/builtin-memory/05-kinds/spec/requirements.md +3 -2
  38. package/dist/builtin-memory/advisor/council.md +0 -2
  39. package/dist/builtin-memory/design.md +3 -2
  40. package/dist/builtin-memory/development.md +3 -2
  41. package/dist/builtin-memory/insights/capture.md +1 -3
  42. package/dist/builtin-memory/insights/init.md +1 -3
  43. package/dist/builtin-memory/insights/listen.md +3 -2
  44. package/dist/builtin-memory/internal/INDEX.md +5 -4
  45. package/dist/builtin-memory/internal/agent-shaping.md +5 -4
  46. package/dist/builtin-memory/internal/examples/INDEX.md +3 -2
  47. package/dist/builtin-memory/internal/examples/imessage-assistant.md +3 -2
  48. package/dist/builtin-memory/internal/marketplaces.md +3 -2
  49. package/dist/builtin-memory/internal/memory-loading.md +31 -20
  50. package/dist/builtin-memory/internal/nodes-and-canvas.md +3 -2
  51. package/dist/builtin-memory/internal/plugins.md +7 -6
  52. package/dist/builtin-memory/internal/storage-tiers.md +3 -2
  53. package/dist/builtin-memory/plan/roadmap.md +3 -2
  54. package/dist/builtin-memory/spec/guide.md +0 -2
  55. package/dist/builtin-memory/spec/requirements.md +0 -2
  56. package/dist/builtin-memory/spec/roadmap.md +3 -2
  57. package/dist/builtin-memory/testing.md +1 -3
  58. package/dist/builtin-memory/wedged-child-on-runaway-bash.md +3 -2
  59. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +1 -1
  60. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/frontmatter-rules/index.ts +3 -3
  61. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/memory-slash-commands.ts +1 -4
  62. package/dist/clients/attach/__tests__/group-activity.test.js +8 -1
  63. package/dist/clients/attach/render/card-presentation.d.ts +31 -0
  64. package/dist/clients/attach/render/card-presentation.js +163 -0
  65. package/dist/clients/attach/render/chat-view.d.ts +0 -2
  66. package/dist/clients/attach/render/chat-view.js +12 -23
  67. package/dist/clients/attach/render/context-message.d.ts +5 -9
  68. package/dist/clients/attach/render/context-message.js +20 -43
  69. package/dist/clients/attach/render/group-activity.d.ts +28 -4
  70. package/dist/clients/attach/render/group-activity.js +65 -11
  71. package/dist/clients/attach/render/group-recap.d.ts +3 -0
  72. package/dist/clients/attach/render/group-recap.js +17 -0
  73. package/dist/clients/attach/viewer.js +516 -516
  74. package/dist/commands/memory/delete.js +3 -18
  75. package/dist/commands/memory/edit.js +23 -30
  76. package/dist/commands/memory/history.js +1 -1
  77. package/dist/commands/memory/lint.d.ts +7 -8
  78. package/dist/commands/memory/lint.js +178 -132
  79. package/dist/commands/memory/list.d.ts +0 -1
  80. package/dist/commands/memory/list.js +2 -12
  81. package/dist/commands/memory/move.d.ts +1 -0
  82. package/dist/commands/memory/move.js +195 -0
  83. package/dist/commands/memory/read.js +135 -141
  84. package/dist/commands/memory/shared.d.ts +18 -17
  85. package/dist/commands/memory/shared.js +93 -39
  86. package/dist/commands/memory/write.js +23 -33
  87. package/dist/commands/memory.js +5 -4
  88. package/dist/commands/pkg/browse/catalog.js +2 -4
  89. package/dist/commands/pkg/browse/doc-view.js +17 -11
  90. package/dist/commands/pkg/browse/model.d.ts +7 -9
  91. package/dist/commands/sys/migrate.d.ts +1 -0
  92. package/dist/commands/sys/migrate.js +106 -0
  93. package/dist/commands/sys/sync-deps.js +5 -10
  94. package/dist/commands/sys/sync-project-guidance.js +36 -16
  95. package/dist/commands/sys/sync-skills.js +8 -4
  96. package/dist/commands/sys.js +3 -2
  97. package/dist/core/__tests__/canvas-inbox-watcher.test.js +1 -1
  98. package/dist/core/__tests__/helpers/broker-clients.d.ts +3 -1
  99. package/dist/core/__tests__/helpers/broker-clients.js +7 -2
  100. package/dist/core/__tests__/inline-memory-refs.test.js +8 -5
  101. package/dist/core/__tests__/memory-resolver-precedence.test.js +5 -4
  102. package/dist/core/__tests__/nested-store-discovery.test.js +1 -3
  103. package/dist/core/__tests__/on-read-crouter-home-fence.test.js +3 -3
  104. package/dist/core/__tests__/on-read-dedup-resume.test.js +12 -12
  105. package/dist/core/__tests__/on-read-nested-store.test.js +23 -20
  106. package/dist/core/__tests__/seam/broker-attach-multiclient.test.js +9 -0
  107. package/dist/core/__tests__/serial/broker-snapshot-history.test.js +52 -1
  108. package/dist/core/__tests__/serial/flagship-lifecycle.test.js +1 -1
  109. package/dist/core/canvas/db.d.ts +3 -1
  110. package/dist/core/canvas/db.js +12 -2
  111. package/dist/core/memory/history.d.ts +4 -1
  112. package/dist/core/memory/history.js +1 -0
  113. package/dist/core/memory/inline-ref-inventory.d.ts +5 -4
  114. package/dist/core/memory/inline-ref-inventory.js +23 -19
  115. package/dist/core/memory-resolver.d.ts +4 -4
  116. package/dist/core/memory-resolver.js +16 -43
  117. package/dist/core/runtime/bearings.d.ts +4 -3
  118. package/dist/core/runtime/bearings.js +4 -4
  119. package/dist/core/runtime/broker/fault-retry.js +2 -7
  120. package/dist/core/runtime/broker/frame-dispatch.js +1 -1
  121. package/dist/core/runtime/broker/tool-groups.js +7 -6
  122. package/dist/core/runtime/broker-extension-render.d.ts +3 -2
  123. package/dist/core/runtime/broker-extension-render.js +3 -3
  124. package/dist/core/runtime/broker.js +4 -4
  125. package/dist/core/runtime/kickoff.js +4 -10
  126. package/dist/core/runtime/memory.js +2 -3
  127. package/dist/core/runtime/node-read.js +7 -1
  128. package/dist/core/runtime/stop-guard.js +3 -3
  129. package/dist/core/substrate/index.d.ts +7 -4
  130. package/dist/core/substrate/index.js +6 -4
  131. package/dist/core/substrate/injected-store.d.ts +24 -12
  132. package/dist/core/substrate/injected-store.js +80 -33
  133. package/dist/core/substrate/listings.d.ts +21 -0
  134. package/dist/core/substrate/listings.js +88 -0
  135. package/dist/core/substrate/on-read-node.d.ts +5 -5
  136. package/dist/core/substrate/on-read-node.js +4 -5
  137. package/dist/core/substrate/on-read.d.ts +25 -4
  138. package/dist/core/substrate/on-read.js +81 -102
  139. package/dist/core/substrate/render-node.d.ts +5 -2
  140. package/dist/core/substrate/render-node.js +5 -3
  141. package/dist/core/substrate/render.d.ts +9 -8
  142. package/dist/core/substrate/render.js +104 -96
  143. package/dist/core/substrate/schema.d.ts +34 -18
  144. package/dist/core/substrate/schema.js +75 -32
  145. package/dist/core/substrate/surface-match.d.ts +32 -0
  146. package/dist/core/substrate/surface-match.js +179 -0
  147. package/dist/daemon/api/handlers/nodes.js +9 -0
  148. package/dist/daemon/review/comment-notify.js +1 -8
  149. package/dist/daemon/review/deliver.js +1 -1
  150. package/dist/daemon/review/finish.js +1 -1
  151. package/dist/migrations/001-surfaces-frontmatter.d.ts +2 -0
  152. package/dist/migrations/001-surfaces-frontmatter.js +276 -0
  153. package/dist/migrations/convergent.d.ts +31 -0
  154. package/dist/migrations/convergent.js +71 -0
  155. package/dist/migrations/registry.d.ts +2 -0
  156. package/dist/migrations/registry.js +19 -0
  157. package/dist/migrations/types.d.ts +40 -0
  158. package/dist/migrations/types.js +11 -0
  159. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +1 -1
  160. package/dist/pi-extensions/canvas-context-intro.d.ts +2 -1
  161. package/dist/pi-extensions/canvas-doc-substrate.d.ts +2 -1
  162. package/dist/pi-extensions/canvas-doc-substrate.js +57 -34
  163. package/dist/pi-extensions/canvas-review-boundary.js +1 -1
  164. package/dist/shared/__tests__/generated-context-grammar.test.js +19 -22
  165. package/dist/shared/generated-context.d.ts +0 -18
  166. package/dist/shared/generated-context.js +16 -120
  167. package/dist/shared/tool-groups.d.ts +1 -0
  168. package/dist/shared/tool-groups.js +7 -0
  169. package/package.json +1 -1
  170. package/runtime.lock.json +2 -2
  171. package/dist/core/substrate/ceiling.d.ts +0 -17
  172. package/dist/core/substrate/ceiling.js +0 -67
@@ -20,10 +20,12 @@ export function renderPreferencesSection(nodeId) {
20
20
  }
21
21
  /** The first-message `<memory kind="knowledge">` block for `nodeId`: assemble
22
22
  * the node's subject from the canvas-db and render the consultable catalog.
23
- * Returns '' for an unknown id or when nothing is eligible. */
24
- export function renderKnowledgeBlock(nodeId) {
23
+ * Returns '' for an unknown id or when nothing is eligible. `seen` (optional,
24
+ * write-only) receives each rendered doc at its displayed rung — boot
25
+ * delivery seeding for the transcript dedup map. */
26
+ export function renderKnowledgeBlock(nodeId, seen) {
25
27
  const subject = assembleNodeSubject(nodeId);
26
28
  if (subject === null)
27
29
  return '';
28
- return renderKnowledgeForSubject(subject, nodeId);
30
+ return renderKnowledgeForSubject(subject, nodeId, seen);
29
31
  }
@@ -1,6 +1,7 @@
1
+ import { type InjectedDocs } from './injected-store.js';
1
2
  import { type SubstrateDoc } from './schema.js';
2
3
  import type { NodeConfigSubject } from './subject-fields.js';
3
- export declare function renderPreferencesForSubject(subject: NodeConfigSubject | null): {
4
+ export declare function renderPreferencesForSubject(subject: NodeConfigSubject | null, seen?: InjectedDocs): {
4
5
  block: string;
5
6
  contentDocs: SubstrateDoc[];
6
7
  tree: string;
@@ -12,10 +13,10 @@ export declare function renderPreferencesForSubject(subject: NodeConfigSubject |
12
13
  * first-wins dedup over a same-named node-local doc. GROUPED by rung exactly
13
14
  * like the preference block: content prose first (general→specific,
14
15
  * node-local last), then the preview/name catalog tree. Node-local docs are
15
- * floored HERE — a rung-none node-local doc's effective rung is bumped to
16
- * `name` before grouping, so it still surfaces as a bare-name tree entry
17
- * rather than collapsing into a `[+N more]` count (its body still never
18
- * renders: floored docs never reach the `content` rung). Procedural guidance
19
- * and factual references both live here as `knowledge`. Returns '' when
20
- * nothing is eligible. */
21
- export declare function renderKnowledgeForSubject(subject: NodeConfigSubject, nodeId: string): string;
16
+ * floored HERE — a bootless node-local doc's rung is bumped to `name` before
17
+ * grouping, so it still surfaces as a bare-name tree entry rather than
18
+ * collapsing into a `[+N more]` count (its body still never renders: floored
19
+ * docs never reach the `content` rung). Procedural guidance and factual
20
+ * references both live here as `knowledge`. Returns '' when nothing is
21
+ * eligible. */
22
+ export declare function renderKnowledgeForSubject(subject: NodeConfigSubject, nodeId: string, seen?: InjectedDocs): string;
@@ -2,14 +2,14 @@
2
2
  //
3
3
  // Two boot targets, one shape per kind: each kind renders as ONE wrapped block —
4
4
  // intro prose → GROUPED content → an update directive. The render is grouped
5
- // by visibility rung, not interleaved: `content`-rung docs render FIRST as
5
+ // by boot rung, not interleaved: `content`-rung docs render FIRST as
6
6
  // clean prose bodies (verbatim, no tree chrome, no name label — this is what
7
7
  // makes content-rung the persona-prose mechanism), in general→specific order;
8
- // then the `preview`+`name`(+hidden `none`) docs render as ONE catalog tree,
9
- // each at its own rung (preview → a `# read when:` routing line; name → a bare
10
- // entry); a `none`-rung doc leaks only into its dir's `[+N more]` count, never
11
- // a name — EXCEPT a node-local doc, whose contract is "node-local rides into
12
- // knowledge" unconditionally: renderKnowledgeBlock floors a rung-none
8
+ // then the `preview`+`name` docs render as ONE catalog tree, each at its own
9
+ // rung (preview → a `# read when:` routing line; name → a bare entry); a doc
10
+ // with NO boot entry leaks only into its dir's `[+N more]` count, never a
11
+ // name — EXCEPT a node-local doc, whose contract is "node-local rides into
12
+ // knowledge" unconditionally: renderKnowledgeForSubject floors a bootless
13
13
  // node-local doc up to `name` so it still shows its bare name rather than
14
14
  // collapsing into a hidden count. Only gate evaluation removes a node-local
15
15
  // doc outright.
@@ -27,12 +27,13 @@
27
27
  //
28
28
  // The pipeline is explicitly TWO-STEP and must never run in the other order:
29
29
  // 1. SELECT WINNERS — MemoryDoc → parseSubstrateDoc → (null-filter
30
- // non-substrate) → ceiling-capped effective rung → gatePasses →
31
- // first-wins DEDUP by name over the resolver's precedence order (nearest
32
- // project > user > builtin). Node-local docs follow the same gate rule,
33
- // but the knowledge-block render floors a rung-none node-local doc's
34
- // EFFECTIVE rung to `name` — the one rung-floor exemption, scoped to the
35
- // node-local store only.
30
+ // non-substrate) → per-doc boot rung (`bootRung`, the max `at` over the
31
+ // doc's `on: boot` surfaces entries) → gatePasses → first-wins DEDUP by
32
+ // name over the resolver's precedence order (nearest project > user >
33
+ // builtin). Node-local docs follow the same gate rule, but the
34
+ // knowledge-block render floors a bootless node-local doc's rung to
35
+ // `name` — the one rung-floor exemption, scoped to the node-local store
36
+ // only.
36
37
  // 2. RENDER IN DISPLAY ORDER — split winners into content vs. the rest;
37
38
  // order content general→specific (builtin, user, project outermost→
38
39
  // nearest, node-local last) then by tree position then filename; place
@@ -43,29 +44,28 @@
43
44
  // orderings and must not be conflated.
44
45
  //
45
46
  // Pure + defensive: reads the resolver, canvas-db (subject assembly), and the
46
- // node-local memory dir; performs no writes and no side effects. A single
47
- // malformed doc never throws the whole render — parsing is isolated upstream
48
- // (parseSubstrateDoc returns null for a non-substrate doc; per-file node-local
49
- // loads are wrapped), and tree construction is pure string work.
47
+ // node-local memory dir; performs no writes and no side effects beyond the
48
+ // caller-supplied `seen` delivery map (write-only seeding — boot docs render
49
+ // every session and are never skipped). A single malformed doc never throws
50
+ // the whole render — parsing is isolated upstream (parseSubstrateDoc returns
51
+ // null for a non-substrate doc; per-file node-local loads are wrapped), and
52
+ // tree construction is pure string work.
50
53
  import { relative, sep } from 'node:path';
51
54
  import { interpolateNodePaths } from '../canvas/paths.js';
52
55
  import { listAllMemoryDocs, resolveMemoryDoc } from '../memory-resolver.js';
53
56
  import { subKindsAvailableTo } from '../config.js';
54
57
  import { parseFrontmatterGeneric } from '../frontmatter.js';
55
- import { pathExists, readText, walkFiles } from '../fs-utils.js';
58
+ import { pathExists, readText, realpathOrSelf, walkFiles } from '../fs-utils.js';
56
59
  import { memoryDir } from '../runtime/memory.js';
57
60
  import { projectScopeRoots } from '../scope.js';
61
+ import { gatePasses } from './gate.js';
58
62
  // De-barreled to LEAF modules on purpose: importing from './index.js' would
59
63
  // pull subject.js (canvas-db) transitively, re-tainting every CLI consumer of
60
64
  // the pure render fns. The canvas-db wrappers (renderPreferencesSection /
61
65
  // renderKnowledgeBlock) live in render-node.js instead.
62
- import { buildCeilingIndex, effectiveSystemPromptRung, indexDirOf, isIndexName } from './ceiling.js';
63
- import { gatePasses } from './gate.js';
64
- import { normalizeDocName, parseSubstrateDoc, parseSubstrateFrontmatter, previewLine, resolveDocName, rungRank, } from './schema.js';
66
+ import { recordDelivery } from './injected-store.js';
67
+ import { bootRung, normalizeDocName, parseSubstrateDoc, parseSubstrateFrontmatter, previewLine, resolveDocName, rungRank, } from './schema.js';
65
68
  import { cachedSubstrateDocs } from './session-cache.js';
66
- // ---------------------------------------------------------------------------
67
- // Step 1 — winner selection (ceiling + gate + first-wins dedup).
68
- // ---------------------------------------------------------------------------
69
69
  /** First-wins dedup by (already-normalized) name, preserving the input's
70
70
  * precedence order. The array's ORDER is the precedence signal — callers must
71
71
  * pass docs already ordered highest-precedence-first; this function never
@@ -82,18 +82,14 @@ function dedupeFirstWins(docs) {
82
82
  return out;
83
83
  }
84
84
  /** STEP 1 — the resolver-provided WINNER docs of one `kind`, eligible at boot
85
- * for `subject`: parsed (non-substrate docs null-filtered), ceiling-capped,
86
- * gate-passed, then first-wins DEDUPED by name over the resolver's precedence
87
- * order (nearest project > user > builtin) — at the EFFECTIVE system-prompt
88
- * rung, INCLUDING `none`-rung docs (the tree counts them into `[+N more]`).
89
- * This is winner selection ONLY; display ordering is a separate, later step
90
- * (see renderGrouped) so a builtin doc can never mask a project/user override
91
- * by virtue of sorting first. Uses the per-session cache so the full corpus
92
- * is scanned + parsed at most once per session across the boot-render calls.
93
- *
94
- * Ceilings are computed over the WHOLE corpus (cross-kind) BEFORE the kind
95
- * filter, and the effective rung is written back into systemPromptVisibility —
96
- * but each doc keeps its canonical logical identifier, such as `taste/INDEX`. */
85
+ * for `subject`: parsed (non-substrate docs null-filtered), gate-passed, then
86
+ * first-wins DEDUPED by name over the resolver's precedence order (nearest
87
+ * project > user > builtin) — each at its own boot rung, INCLUDING docs with
88
+ * NO boot entry (the tree counts them into `[+N more]`). This is winner
89
+ * selection ONLY; display ordering is a separate, later step (see
90
+ * renderGrouped) so a builtin doc can never mask a project/user override by
91
+ * virtue of sorting first. Uses the per-session cache so the full corpus is
92
+ * scanned + parsed at most once per session across the boot-render calls. */
97
93
  function selectWinners(subject, kind) {
98
94
  let docs;
99
95
  try {
@@ -102,17 +98,29 @@ function selectWinners(subject, kind) {
102
98
  catch {
103
99
  return [];
104
100
  }
105
- const ceil = buildCeilingIndex(docs);
106
101
  const eligible = docs
107
- .map((d) => {
108
- const rung = effectiveSystemPromptRung(d, ceil);
109
- return rung === d.systemPromptVisibility ? d : { ...d, systemPromptVisibility: rung };
110
- })
111
102
  .filter((d) => d.kind === kind)
112
- .filter((d) => gatePasses(d, subject));
103
+ .filter((d) => gatePasses(d, subject))
104
+ .map((d) => ({ ...d, bootRung: bootRung(d) }));
113
105
  return dedupeFirstWins(eligible);
114
106
  }
115
107
  // ---------------------------------------------------------------------------
108
+ // Boot-delivery seeding — the injected-docs map grows at render time.
109
+ // ---------------------------------------------------------------------------
110
+ /** Record every VISIBLY rendered winner into the caller's transcript dedup map
111
+ * at its displayed rung (write-only — boot renders every session and never
112
+ * skips). Seeding is what lets a later listing line or on-read injection skip
113
+ * a doc the boot catalog already discloses at that rank. */
114
+ function seedBootDelivery(seen, docs) {
115
+ if (seen === undefined)
116
+ return;
117
+ for (const d of docs) {
118
+ if (!isVisible(d.bootRung))
119
+ continue;
120
+ recordDelivery(seen, realpathOrSelf(d.path), d.bootRung);
121
+ }
122
+ }
123
+ // ---------------------------------------------------------------------------
116
124
  // Step 2 — display ordering + grouped render.
117
125
  // ---------------------------------------------------------------------------
118
126
  /** Content-prose ordering rank: general → specific. builtin=0, user=1,
@@ -171,15 +179,15 @@ function compareTreePosition(a, b) {
171
179
  /** STEP 2 — split deduped `winners` by rung and render each group in its
172
180
  * display order: `content` docs first, as clean prose (general→specific,
173
181
  * then tree position, then filename — no tree chrome, no name label); every
174
- * other winner (preview/name/hidden-none) folds into ONE catalog tree exactly
175
- * as before. `nodeLocalSet` marks which winners came from the node-local
176
- * store (ranked most-specific in content ordering); omit it for a pure
177
- * resolver render. */
182
+ * other winner (preview/name/hidden-bootless) folds into ONE catalog tree
183
+ * exactly as before. `nodeLocalSet` marks which winners came from the
184
+ * node-local store (ranked most-specific in content ordering); omit it for a
185
+ * pure resolver render. */
178
186
  function renderGrouped(winners, rootLabel, nodeLocalSet = new Set()) {
179
187
  const content = [];
180
188
  const rest = [];
181
189
  for (const d of winners) {
182
- if (d.systemPromptVisibility === 'content')
190
+ if (d.bootRung === 'content')
183
191
  content.push(d);
184
192
  else
185
193
  rest.push(d);
@@ -204,10 +212,10 @@ function renderGrouped(winners, rootLabel, nodeLocalSet = new Set()) {
204
212
  * Node-local docs follow the same GATE rule as every other store — gate
205
213
  * evaluation is the only thing that removes a doc here. Rung is different:
206
214
  * node-local's contract is "node-local rides into knowledge" unconditionally,
207
- * so a `none`-rung node-local doc is retained AT its parsed rung by this
208
- * function (never rewritten here) — it is the caller (renderKnowledgeBlock)
209
- * that floors a rung-none node-local doc up to `name` so it still shows its
210
- * bare name instead of collapsing into a `[+N more]` count. */
215
+ * so a doc with no boot entry is retained by this function — it is the caller
216
+ * (renderKnowledgeForSubject) that floors a bootless node-local doc up to
217
+ * `name` so it still shows its bare name instead of collapsing into a
218
+ * `[+N more]` count. */
211
219
  function nodeLocalDocs(nodeId, subject) {
212
220
  const dir = memoryDir(nodeId);
213
221
  if (!pathExists(dir))
@@ -246,7 +254,6 @@ function newDir(path) {
246
254
  path,
247
255
  children: new Map(),
248
256
  leaves: [],
249
- index: null,
250
257
  hiddenHere: 0,
251
258
  renders: false,
252
259
  ownCount: 0,
@@ -281,9 +288,9 @@ function isVisible(rung) {
281
288
  return rungRank(rung) >= rungRank('name');
282
289
  }
283
290
  /** Bottom-up pass: decide which dirs render and where hidden counts land. A dir
284
- * renders when it has ≥1 visible leaf, a visible INDEX, ≥1 rendering child dir,
285
- * or it is top-level (a direct child of the root always renders so its subtree
286
- * count is visible — the root itself always renders). `none`-rung docs in a
291
+ * renders when it has ≥1 visible leaf, ≥1 rendering child dir, or it is
292
+ * top-level (a direct child of the root always renders so its subtree count
293
+ * is visible — the root itself always renders). Bootless docs in a
287
294
  * NON-rendering dir bubble up to the nearest rendering ancestor's `[+N more]`;
288
295
  * a rendering dir keeps its own subtree count. Returns the count to bubble up. */
289
296
  function resolveDir(dir, isTopLevel) {
@@ -292,10 +299,8 @@ function resolveDir(dir, isTopLevel) {
292
299
  totalHidden += resolveDir(child, dir.path === '');
293
300
  }
294
301
  const hasVisibleLeaf = dir.leaves.length > 0;
295
- const hasVisibleIndex = dir.index !== null && isVisible(dir.index.systemPromptVisibility);
296
302
  const anyChildRenders = [...dir.children.values()].some((c) => c.renders);
297
- dir.renders =
298
- hasVisibleLeaf || hasVisibleIndex || anyChildRenders || isTopLevel || dir.path === '';
303
+ dir.renders = hasVisibleLeaf || anyChildRenders || isTopLevel || dir.path === '';
299
304
  dir.ownCount = dir.renders ? totalHidden : 0;
300
305
  return dir.renders ? 0 : totalHidden;
301
306
  }
@@ -306,9 +311,17 @@ function itemSortKey(item) {
306
311
  return leafSegment(item.doc.name);
307
312
  return '';
308
313
  }
309
- /** Render one canonical logical doc entry at its effective rung. */
314
+ /** DISPLAY-ORDER PIN ONLY: a leaf whose last name segment is `INDEX` sorts
315
+ * first among its directory's items. `INDEX` carries no mechanism anywhere in
316
+ * the substrate anymore — these are ordinary docs — but the ex-router docs
317
+ * keeping that name are each their dir's orientation entry, and localeCompare
318
+ * would drop them mid-list. */
319
+ function isIndexPinned(item) {
320
+ return item.kind === 'leaf' && leafSegment(item.doc.name) === 'INDEX';
321
+ }
322
+ /** Render one canonical logical doc entry at its boot rung. */
310
323
  function renderDocEntry(doc, label, entryPrefix, childPrefix, lines) {
311
- switch (doc.systemPromptVisibility) {
324
+ switch (doc.bootRung) {
312
325
  case 'preview': {
313
326
  const pl = previewLine(doc);
314
327
  lines.push(pl === '' ? `${entryPrefix}${label}` : `${entryPrefix}${label} # read when: ${pl}`);
@@ -329,23 +342,19 @@ function renderDocEntry(doc, label, entryPrefix, childPrefix, lines) {
329
342
  }
330
343
  }
331
344
  /** Render the children of a rendering dir, with `childPrefix` carrying the tree
332
- * guides for this depth. Order: the dir's canonical INDEX identity first, then
333
- * child dirs and leaves intermixed alphabetically, then the `[+N more]` count
334
- * last. */
345
+ * guides for this depth. Order: an `INDEX`-named leaf first (display pin),
346
+ * then child dirs and leaves intermixed alphabetically, then the `[+N more]`
347
+ * count last. */
335
348
  function renderChildren(dir, childPrefix, lines) {
336
- const middle = [];
349
+ const ordered = [];
337
350
  for (const child of dir.children.values()) {
338
351
  if (child.renders)
339
- middle.push({ kind: 'dir', node: child });
352
+ ordered.push({ kind: 'dir', node: child });
340
353
  }
341
354
  for (const leaf of dir.leaves)
342
- middle.push({ kind: 'leaf', doc: leaf });
343
- middle.sort((a, b) => itemSortKey(a).localeCompare(itemSortKey(b)));
344
- const ordered = [];
345
- if (dir.index !== null && isVisible(dir.index.systemPromptVisibility)) {
346
- ordered.push({ kind: 'index', doc: dir.index });
347
- }
348
- ordered.push(...middle);
355
+ ordered.push({ kind: 'leaf', doc: leaf });
356
+ ordered.sort((a, b) => Number(isIndexPinned(b)) - Number(isIndexPinned(a)) ||
357
+ itemSortKey(a).localeCompare(itemSortKey(b)));
349
358
  if (dir.ownCount > 0)
350
359
  ordered.push({ kind: 'more', count: dir.ownCount });
351
360
  ordered.forEach((item, i) => {
@@ -360,7 +369,6 @@ function renderChildren(dir, childPrefix, lines) {
360
369
  lines.push(`${entryPrefix}${item.node.segment}/`);
361
370
  renderChildren(item.node, nextPrefix, lines);
362
371
  break;
363
- case 'index':
364
372
  case 'leaf':
365
373
  renderDocEntry(item.doc, leafSegment(item.doc.name), entryPrefix, nextPrefix, lines);
366
374
  break;
@@ -385,18 +393,11 @@ function buildTree(docs, rootLabel) {
385
393
  if (seen.has(d.name))
386
394
  continue; // first-wins cross-scope dedup
387
395
  seen.add(d.name);
388
- if (isIndexName(d.name)) {
389
- const dir = ensureDir(root, indexDirOf(d.name));
390
- if (dir.index === null)
391
- dir.index = d; // precedence-ordered → keep first (highest)
392
- }
393
- else {
394
- const parent = ensureDir(root, parentDirOf(d.name));
395
- if (isVisible(d.systemPromptVisibility))
396
- parent.leaves.push(d);
397
- else
398
- parent.hiddenHere += 1;
399
- }
396
+ const parent = ensureDir(root, parentDirOf(d.name));
397
+ if (isVisible(d.bootRung))
398
+ parent.leaves.push(d);
399
+ else
400
+ parent.hiddenHere += 1;
400
401
  }
401
402
  resolveDir(root, false);
402
403
  const lines = [rootLabel];
@@ -416,7 +417,9 @@ const KNOWLEDGE_INTRO = 'Knowledge documents are what you consult — playbooks
416
417
  'references on the user, projects, and this node. They are aggregated from your memory stores: ' +
417
418
  'user-global (`~/.crouter/memory/`), every ancestor project store (`<dir>/.crouter/memory/`) ' +
418
419
  "from the cwd upward, the selected profile's memory store (when one is selected), and node-local " +
419
- "(this node's `context/memory/`). To read one, run `crtr memory read <name>`. " +
420
+ "(this node's `context/memory/`). To read one, run `crtr memory read <name>`; a directory " +
421
+ 'name is a valid read target too — it answers with its listing, and walking directories is ' +
422
+ 'the sanctioned way to browse. ' +
420
423
  'Each doc exists to prevent a specific mistake you would make without it, so when your task ' +
421
424
  'matches one — by its name or its `# read when:` line — read it before you act; consulting it ' +
422
425
  'only after the work is done forfeits the entire reason it exists. ' +
@@ -485,12 +488,13 @@ function buildSubPersonaMenu(kind) {
485
488
  * preview/name catalog tree. ALWAYS returns a non-empty `<memory
486
489
  * kind="preference">` block — the guidance frame is unconditional, so the
487
490
  * system-prompt splice is never empty even when no preference is eligible. */
488
- function buildPreferencesBlock(subject) {
491
+ function buildPreferencesBlock(subject, seen) {
489
492
  let body = MEMORY_USAGE_GUIDANCE;
490
493
  let contentDocs = [];
491
494
  let tree = '';
492
495
  if (subject !== null) {
493
496
  const winners = selectWinners(subject, 'preference');
497
+ seedBootDelivery(seen, winners);
494
498
  const grouped = renderGrouped(winners, 'preferences');
495
499
  contentDocs = grouped.contentDocs;
496
500
  tree = grouped.tree;
@@ -508,8 +512,8 @@ function buildPreferencesBlock(subject) {
508
512
  }
509
513
  return { block: `<memory kind="preference">\n${body}\n</memory>`, contentDocs, tree };
510
514
  }
511
- export function renderPreferencesForSubject(subject) {
512
- return buildPreferencesBlock(subject);
515
+ export function renderPreferencesForSubject(subject, seen) {
516
+ return buildPreferencesBlock(subject, seen);
513
517
  }
514
518
  // ---------------------------------------------------------------------------
515
519
  // 2. Knowledge — `<memory kind="knowledge">` (the first-message bearings).
@@ -521,18 +525,22 @@ export function renderPreferencesForSubject(subject) {
521
525
  * first-wins dedup over a same-named node-local doc. GROUPED by rung exactly
522
526
  * like the preference block: content prose first (general→specific,
523
527
  * node-local last), then the preview/name catalog tree. Node-local docs are
524
- * floored HERE — a rung-none node-local doc's effective rung is bumped to
525
- * `name` before grouping, so it still surfaces as a bare-name tree entry
526
- * rather than collapsing into a `[+N more]` count (its body still never
527
- * renders: floored docs never reach the `content` rung). Procedural guidance
528
- * and factual references both live here as `knowledge`. Returns '' when
529
- * nothing is eligible. */
530
- export function renderKnowledgeForSubject(subject, nodeId) {
531
- const nodeLocal = nodeLocalDocs(nodeId, subject).map((d) => rungRank(d.systemPromptVisibility) >= rungRank('name') ? d : { ...d, systemPromptVisibility: 'name' });
528
+ * floored HERE — a bootless node-local doc's rung is bumped to `name` before
529
+ * grouping, so it still surfaces as a bare-name tree entry rather than
530
+ * collapsing into a `[+N more]` count (its body still never renders: floored
531
+ * docs never reach the `content` rung). Procedural guidance and factual
532
+ * references both live here as `knowledge`. Returns '' when nothing is
533
+ * eligible. */
534
+ export function renderKnowledgeForSubject(subject, nodeId, seen) {
535
+ const nodeLocal = nodeLocalDocs(nodeId, subject).map((d) => {
536
+ const r = bootRung(d);
537
+ return { ...d, bootRung: isVisible(r) ? r : 'name' };
538
+ });
532
539
  const nodeLocalSet = new Set(nodeLocal);
533
540
  // Resolver winners first: first-wins dedup keeps the resolver's copy over a
534
541
  // same-named node-local doc, matching the original precedence.
535
542
  const winners = dedupeFirstWins([...selectWinners(subject, 'knowledge'), ...nodeLocal]);
543
+ seedBootDelivery(seen, winners);
536
544
  const { contentProse, tree } = renderGrouped(winners, 'knowledge', nodeLocalSet);
537
545
  if (contentProse === '' && tree === '')
538
546
  return '';
@@ -7,10 +7,8 @@ export declare const RUNGS: readonly ["none", "name", "preview", "content"];
7
7
  export type Rung = (typeof RUNGS)[number];
8
8
  /** Ordinal of a rung on the ladder (none=0 … content=3). */
9
9
  export declare function rungRank(r: Rung): number;
10
- /** Does rung `r` disclose at least as much as `min`? — e.g.
11
- * `rungAtLeast(doc.systemPromptVisibility, 'name')` ⇒ "shows at boot at all". */
10
+ /** Does rung `r` disclose at least as much as `min`? */
12
11
  export declare function rungAtLeast(r: Rung, min: Rung): boolean;
13
- export declare const FALLBACK_RUNG: Rung;
14
12
  /** Strip an optional `NN-` ordering prefix from ONE path segment (file or
15
13
  * directory display name). `00-runtime-base` -> `runtime-base`; `spine` (no
16
14
  * prefix) is unchanged. */
@@ -24,6 +22,32 @@ export declare function normalizeDocName(name: string): string;
24
22
  * `name` field wins (trimmed + normalized), else `fallbackName` (the
25
23
  * normalized path-derived name). */
26
24
  export declare function resolveDocName(fm: Record<string, unknown> | null | undefined, fallbackName: string): string;
25
+ export declare const SURFACE_EVENTS: readonly ["boot", "workspace-open", "read", "memory-read", "command"];
26
+ export type SurfaceEvent = (typeof SURFACE_EVENTS)[number];
27
+ /** The rungs an entry may deliver at. There is no `none` — silence is the
28
+ * absence of an entry. (`Rung`'s `none` survives only as the internal
29
+ * ranking floor, e.g. `bootRung` of a doc with no boot entry.) */
30
+ export declare const SURFACE_RUNGS: readonly ["name", "preview", "content"];
31
+ export type SurfaceRung = (typeof SURFACE_RUNGS)[number];
32
+ /** One routing entry: when event `on` exposes a subject that fits `match`
33
+ * (and `matchFrontmatter`, `read` only), the doc delivers at rung `at`.
34
+ * Constraints within one entry AND together (each present constraint must
35
+ * fit; a `match` list is satisfied by any one glob); multiple entries per
36
+ * event OR together. */
37
+ export interface SurfaceEntry {
38
+ on: SurfaceEvent;
39
+ /** Globs vs the event's subject namespace. Required on read/memory-read/
40
+ * command (a read entry may carry `matchFrontmatter` instead); meaningless
41
+ * on boot/workspace-open (presence of the entry is the match). */
42
+ match?: string[];
43
+ /** Predicate over the read file's own YAML frontmatter — `read` event only.
44
+ * Frontmatter key `match-frontmatter`. */
45
+ matchFrontmatter?: GatePredicate;
46
+ at: SurfaceRung;
47
+ }
48
+ /** A doc's boot rung: the highest `at` over its `boot` entries, `none` when
49
+ * it carries no boot entry. The boot render's per-doc selection input. */
50
+ export declare function bootRung(doc: Pick<SubstrateSchema, 'surfaces'>): Rung;
27
51
  /** A gate predicate tree, evaluated by predicate.ts (`evalCondition`) against
28
52
  * the node-config subject. Typed loosely on purpose — the matcher engine owns
29
53
  * validation; structurally it is a field→matcher map with optional
@@ -46,25 +70,17 @@ export interface SubstrateSchema {
46
70
  /** Human-facing abbreviation for `crtr memory list`. NEVER loaded into an
47
71
  * agent's context (design §3). Empty string when absent. */
48
72
  shortForm: string;
49
- /** How much surfaces at boot (system prompt / autoloaded context). */
50
- systemPromptVisibility: Rung;
51
- /** How much surfaces on-read (when a related file is read). */
52
- fileReadVisibility: Rung;
73
+ /** Suppress this doc from directory listings (default false). Suppression,
74
+ * not a type — the doc is otherwise ordinary. */
75
+ unlisted: boolean;
76
+ /** Explicit event routing. Empty = no explicit routing (listings still
77
+ * apply, unless `unlisted`). Invalid entries are dropped by the tolerant
78
+ * runtime parser; lint enforces strictly. */
79
+ surfaces: SurfaceEntry[];
53
80
  /** Optional eligibility predicate over the node's own config. Absent ⇒ always
54
81
  * eligible. An empty `{}` is carried as-is and is inert (never matches) — see
55
82
  * `gatePasses`. */
56
83
  gate?: GatePredicate;
57
- /** Explicit file-context routes. `.` is the project workspace-open target;
58
- * every other value is a glob matched after an actual file read. A single
59
- * value is normalized to a 1-list. */
60
- appliesTo?: string[];
61
- /** Optional condition over the READ FILE's own frontmatter — the on-read
62
- * frontmatter trigger (Stream A native rules). Same coercion + predicate
63
- * vocabulary as `gate` (non-null non-array object carried; empty `{}` inert),
64
- * but evaluated by `evalCondition` against the read file's parsed YAML rather
65
- * than the node subject. Absent ⇒ no frontmatter trigger. Frontmatter key
66
- * `read-when`. */
67
- readWhen?: GatePredicate;
68
84
  /** Opt-in: this doc is invocable as a pi slash command (`/<name>`, `/` in a
69
85
  * nested name rendered as `:`). Default `false` — most docs are consulted,
70
86
  * not invoked. Frontmatter key `slash`. */
@@ -4,13 +4,9 @@
4
4
  // downstream track (CLI verbs, boot render, on-read render, migrator) builds
5
5
  // against — pure and side-effect free. See design-substrate.md §4 (schema).
6
6
  //
7
- // There is NO kind-based default for visibility: the right rung is a
8
- // case-by-case authoring call, so both rungs are required at authoring time
9
- // (enforced by `crtr memory write` on create and by `crtr memory lint`). The
10
- // runtime parser is tolerant by contract (it maps over many docs and must
11
- // never throw), so a doc missing/with an invalid rung falls back to the neutral
12
- // floor `none` — a malformed doc renders invisible rather than crashing, and
13
- // lint flags it. Valid docs never hit the fallback.
7
+ // The runtime parser is tolerant by contract (it maps over many docs and must
8
+ // never throw): invalid `surfaces` entries are dropped rather than crashing a
9
+ // render, and `crtr memory lint` owns strict authoring-time enforcement.
14
10
  // ---------------------------------------------------------------------------
15
11
  // Kinds — the two semantic kinds (design §3): `knowledge` (consult — procedural
16
12
  // playbooks + factual references merged) vs `preference` (behave — standing
@@ -31,19 +27,11 @@ export const RUNGS = ['none', 'name', 'preview', 'content'];
31
27
  export function rungRank(r) {
32
28
  return RUNGS.indexOf(r);
33
29
  }
34
- /** Does rung `r` disclose at least as much as `min`? — e.g.
35
- * `rungAtLeast(doc.systemPromptVisibility, 'name')` ⇒ "shows at boot at all". */
30
+ /** Does rung `r` disclose at least as much as `min`? */
36
31
  export function rungAtLeast(r, min) {
37
32
  return rungRank(r) >= rungRank(min);
38
33
  }
39
34
  // ---------------------------------------------------------------------------
40
- // The neutral fallback rung used only when a doc omits a visibility field or
41
- // carries an invalid one. NOT a default an author may lean on — authoring-time
42
- // enforcement requires explicit rungs; this is purely the runtime parser's
43
- // never-throw floor (a malformed doc renders invisible, and lint flags it).
44
- // ---------------------------------------------------------------------------
45
- export const FALLBACK_RUNG = 'none';
46
- // ---------------------------------------------------------------------------
47
35
  // Display-name normalization — the optional `NN-` ordering pin. A doc's
48
36
  // physical path may carry a two-digit numeric prefix on a file or directory
49
37
  // segment (`00-runtime-base.md`, `01-spine/`) purely to pin structural order
@@ -86,6 +74,31 @@ export function resolveDocName(fm, fallbackName) {
86
74
  return explicit !== '' ? explicit : fallbackName;
87
75
  }
88
76
  // ---------------------------------------------------------------------------
77
+ // Surfaces — explicit event routing (design: surfaces-design.md). A doc
78
+ // declares WHEN it delivers as (event, match, rung) entries; matches are
79
+ // always globs, always absolute in the event's namespace, with `./` as the
80
+ // one relative spelling (anchoring to the doc's own container). A doc with
81
+ // no `surfaces` at all does exactly one thing: appears in its directory's
82
+ // listing.
83
+ // ---------------------------------------------------------------------------
84
+ export const SURFACE_EVENTS = ['boot', 'workspace-open', 'read', 'memory-read', 'command'];
85
+ /** The rungs an entry may deliver at. There is no `none` — silence is the
86
+ * absence of an entry. (`Rung`'s `none` survives only as the internal
87
+ * ranking floor, e.g. `bootRung` of a doc with no boot entry.) */
88
+ export const SURFACE_RUNGS = ['name', 'preview', 'content'];
89
+ /** A doc's boot rung: the highest `at` over its `boot` entries, `none` when
90
+ * it carries no boot entry. The boot render's per-doc selection input. */
91
+ export function bootRung(doc) {
92
+ let r = 'none';
93
+ for (const e of doc.surfaces) {
94
+ if (e.on !== 'boot')
95
+ continue;
96
+ if (rungRank(e.at) > rungRank(r))
97
+ r = e.at;
98
+ }
99
+ return r;
100
+ }
101
+ // ---------------------------------------------------------------------------
89
102
  // Parse / validate.
90
103
  // ---------------------------------------------------------------------------
91
104
  /** Parse a raw frontmatter record (from `parseFrontmatterGeneric`, via the
@@ -106,11 +119,9 @@ export function parseSubstrateFrontmatter(fm) {
106
119
  kind,
107
120
  whenAndWhyToRead: strField(fm['when-and-why-to-read']),
108
121
  shortForm: strField(fm['short-form']),
109
- systemPromptVisibility: parseRung(fm['system-prompt-visibility'], FALLBACK_RUNG),
110
- fileReadVisibility: parseRung(fm['file-read-visibility'], FALLBACK_RUNG),
122
+ unlisted: fm['unlisted'] === true,
123
+ surfaces: parseSurfaces(fm['surfaces']),
111
124
  gate: parseGate(fm.gate),
112
- appliesTo: parseAppliesTo(fm['applies-to']),
113
- readWhen: parseGate(fm['read-when']),
114
125
  slash: fm.slash === true,
115
126
  rationale: parseRationale(fm['rationale']),
116
127
  };
@@ -161,13 +172,6 @@ function strField(v) {
161
172
  function parseRationale(v) {
162
173
  return typeof v === 'string' && v.trim() !== '' ? v : undefined;
163
174
  }
164
- /** Resolve a visibility field to a ladder rung, falling back to the neutral
165
- * floor when absent/invalid. */
166
- function parseRung(v, fallback) {
167
- return typeof v === 'string' && RUNGS.includes(v)
168
- ? v
169
- : fallback;
170
- }
171
175
  /** A gate is engaged only when frontmatter carries a non-null, non-array object
172
176
  * (the predicate vocabulary's field→matcher map). Anything else (absent, null,
173
177
  * scalar, array) ⇒ no gate ⇒ always eligible — the safe default that never
@@ -177,14 +181,53 @@ function parseGate(v) {
177
181
  ? v
178
182
  : undefined;
179
183
  }
180
- /** Normalize `applies-to` to a non-empty route list, or undefined when no
181
- * file-context event is declared. Accepts a string or an array of strings. */
182
- function parseAppliesTo(v) {
184
+ /** Tolerant `surfaces` parse: a non-array yields `[]`; invalid entries are
185
+ * dropped (bad `on`, bad `at`, a read/memory-read/command entry left with
186
+ * neither `match` nor — read only — `match-frontmatter`). `match-frontmatter`
187
+ * on a non-read event is dropped from the entry, which survives iff it still
188
+ * carries a `match`. Lint owns strict enforcement; the runtime parser maps
189
+ * over many docs and must never throw. */
190
+ function parseSurfaces(v) {
191
+ if (!Array.isArray(v))
192
+ return [];
193
+ const out = [];
194
+ for (const raw of v) {
195
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
196
+ continue;
197
+ const rec = raw;
198
+ const on = rec['on'];
199
+ if (typeof on !== 'string' || !SURFACE_EVENTS.includes(on))
200
+ continue;
201
+ const at = rec['at'];
202
+ if (typeof at !== 'string' || !SURFACE_RUNGS.includes(at))
203
+ continue;
204
+ const match = parseMatchGlobs(rec['match']);
205
+ const matchFrontmatter = on === 'read' ? parseGate(rec['match-frontmatter']) : undefined;
206
+ if ((on === 'read' || on === 'memory-read' || on === 'command') &&
207
+ match === undefined &&
208
+ matchFrontmatter === undefined) {
209
+ continue;
210
+ }
211
+ out.push({
212
+ on: on,
213
+ at: at,
214
+ ...(match !== undefined ? { match } : {}),
215
+ ...(matchFrontmatter !== undefined ? { matchFrontmatter } : {}),
216
+ });
217
+ }
218
+ return out;
219
+ }
220
+ /** Normalize an entry's `match` to a non-empty glob list, or undefined.
221
+ * Accepts a string or an array of strings; blanks are dropped. */
222
+ function parseMatchGlobs(v) {
183
223
  if (typeof v === 'string') {
184
- return v.trim() === '' ? undefined : [v];
224
+ const t = v.trim();
225
+ return t === '' ? undefined : [t];
185
226
  }
186
227
  if (Array.isArray(v)) {
187
- const globs = v.filter((g) => typeof g === 'string' && g.trim() !== '');
228
+ const globs = v
229
+ .filter((g) => typeof g === 'string' && g.trim() !== '')
230
+ .map((g) => g.trim());
188
231
  return globs.length > 0 ? globs : undefined;
189
232
  }
190
233
  return undefined;