@north-light/crouter 0.3.208 → 0.3.210

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 (138) 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/viewer.js +366 -366
  63. package/dist/commands/memory/delete.js +3 -18
  64. package/dist/commands/memory/edit.js +23 -30
  65. package/dist/commands/memory/history.js +1 -1
  66. package/dist/commands/memory/lint.d.ts +7 -8
  67. package/dist/commands/memory/lint.js +178 -132
  68. package/dist/commands/memory/list.d.ts +0 -1
  69. package/dist/commands/memory/list.js +2 -12
  70. package/dist/commands/memory/move.d.ts +1 -0
  71. package/dist/commands/memory/move.js +195 -0
  72. package/dist/commands/memory/read.js +135 -141
  73. package/dist/commands/memory/shared.d.ts +18 -17
  74. package/dist/commands/memory/shared.js +93 -39
  75. package/dist/commands/memory/write.js +23 -33
  76. package/dist/commands/memory.js +5 -4
  77. package/dist/commands/pkg/browse/catalog.js +2 -4
  78. package/dist/commands/pkg/browse/doc-view.js +17 -11
  79. package/dist/commands/pkg/browse/model.d.ts +7 -9
  80. package/dist/commands/sys/migrate.d.ts +1 -0
  81. package/dist/commands/sys/migrate.js +106 -0
  82. package/dist/commands/sys/sync-deps.js +5 -10
  83. package/dist/commands/sys/sync-project-guidance.js +36 -16
  84. package/dist/commands/sys/sync-skills.js +8 -4
  85. package/dist/commands/sys.js +3 -2
  86. package/dist/core/__tests__/inline-memory-refs.test.js +8 -5
  87. package/dist/core/__tests__/memory-resolver-precedence.test.js +5 -4
  88. package/dist/core/__tests__/nested-store-discovery.test.js +1 -3
  89. package/dist/core/__tests__/on-read-crouter-home-fence.test.js +3 -3
  90. package/dist/core/__tests__/on-read-dedup-resume.test.js +12 -12
  91. package/dist/core/__tests__/on-read-nested-store.test.js +23 -20
  92. package/dist/core/canvas/db.d.ts +3 -1
  93. package/dist/core/canvas/db.js +12 -2
  94. package/dist/core/memory/history.d.ts +4 -1
  95. package/dist/core/memory/history.js +1 -0
  96. package/dist/core/memory/inline-ref-inventory.d.ts +5 -4
  97. package/dist/core/memory/inline-ref-inventory.js +23 -19
  98. package/dist/core/memory-resolver.d.ts +4 -4
  99. package/dist/core/memory-resolver.js +16 -43
  100. package/dist/core/runtime/bearings.d.ts +4 -3
  101. package/dist/core/runtime/bearings.js +4 -4
  102. package/dist/core/runtime/broker-extension-render.d.ts +3 -2
  103. package/dist/core/runtime/broker-extension-render.js +3 -3
  104. package/dist/core/runtime/memory.js +2 -3
  105. package/dist/core/substrate/index.d.ts +7 -4
  106. package/dist/core/substrate/index.js +6 -4
  107. package/dist/core/substrate/injected-store.d.ts +24 -12
  108. package/dist/core/substrate/injected-store.js +80 -33
  109. package/dist/core/substrate/listings.d.ts +21 -0
  110. package/dist/core/substrate/listings.js +88 -0
  111. package/dist/core/substrate/on-read-node.d.ts +5 -5
  112. package/dist/core/substrate/on-read-node.js +4 -5
  113. package/dist/core/substrate/on-read.d.ts +25 -4
  114. package/dist/core/substrate/on-read.js +81 -102
  115. package/dist/core/substrate/render-node.d.ts +5 -2
  116. package/dist/core/substrate/render-node.js +5 -3
  117. package/dist/core/substrate/render.d.ts +9 -8
  118. package/dist/core/substrate/render.js +104 -96
  119. package/dist/core/substrate/schema.d.ts +34 -18
  120. package/dist/core/substrate/schema.js +75 -32
  121. package/dist/core/substrate/surface-match.d.ts +32 -0
  122. package/dist/core/substrate/surface-match.js +179 -0
  123. package/dist/daemon/api/handlers/nodes.js +9 -0
  124. package/dist/migrations/001-surfaces-frontmatter.d.ts +2 -0
  125. package/dist/migrations/001-surfaces-frontmatter.js +276 -0
  126. package/dist/migrations/convergent.d.ts +31 -0
  127. package/dist/migrations/convergent.js +71 -0
  128. package/dist/migrations/registry.d.ts +2 -0
  129. package/dist/migrations/registry.js +19 -0
  130. package/dist/migrations/types.d.ts +40 -0
  131. package/dist/migrations/types.js +11 -0
  132. package/dist/pi-extensions/canvas-context-intro.d.ts +2 -1
  133. package/dist/pi-extensions/canvas-doc-substrate.d.ts +2 -1
  134. package/dist/pi-extensions/canvas-doc-substrate.js +57 -34
  135. package/package.json +1 -1
  136. package/runtime.lock.json +2 -2
  137. package/dist/core/substrate/ceiling.d.ts +0 -17
  138. package/dist/core/substrate/ceiling.js +0 -67
@@ -13,27 +13,29 @@ import { listInstalledPlugins, listInstalledPluginsInRoot } from '../../core/res
13
13
  import { pluginMemoryDir, projectScopeRoots, scopeMemoryDir } from '../../core/scope.js';
14
14
  import { loadProfileManifest, profileMemoryDir } from '../../core/profiles/manifest.js';
15
15
  import { getDefaultProfileId } from '../../core/profiles/default-binding.js';
16
- import { isDocKind, normalizeDocName, resolveDocName, RUNGS } from '../../core/substrate/schema.js';
17
- import { displayName } from '../../core/substrate/ceiling.js';
16
+ import { isDocKind, normalizeDocName, parseSubstrateFrontmatter, resolveDocName, SURFACE_EVENTS, SURFACE_RUNGS, } from '../../core/substrate/schema.js';
18
17
  import { docLinkNames } from '../../core/memory/doc-link-grammar.js';
19
18
  import { listAllMemoryDocs } from '../../core/memory-resolver.js';
20
19
  import { descendantStoreRoots } from '../../core/nested-stores.js';
21
- const VALID_RUNGS = [...RUNGS];
22
- const RUNG_FIELDS = ['system-prompt-visibility', 'file-read-visibility'];
20
+ /** The four retired visibility-axis fields. The cut is hard: an old-format doc
21
+ * fails loudly and names the migration, never gets a dual-parse fallback. */
22
+ const RETIRED_FIELDS = ['system-prompt-visibility', 'file-read-visibility', 'applies-to', 'read-when'];
23
+ /** The frontmatter keys one `surfaces` entry may carry. */
24
+ const SURFACE_ENTRY_KEYS = ['on', 'match', 'match-frontmatter', 'at'];
23
25
  /** Rung-scaled body-length caps, measured in WORDS (frontmatter excluded).
24
26
  * Words, not lines: house style writes each paragraph as ONE logical line
25
27
  * and lets the editor soft-wrap, so a line count measures wrapping style
26
28
  * rather than context cost — words track what the reader actually pays.
27
29
  *
28
- * `content` on the system axis inlines the whole body into every agent's
29
- * system prompt at boot; `preview`/`content` on the file-read axis surface
30
- * the whole body through a workspace mount or a matching file read — both
31
- * cap at 1000 words. `preview` on the system axis routes a deliberate
30
+ * A `boot` entry at `content` inlines the whole body into every agent's
31
+ * system prompt; a `content` entry on any other event (workspace-open, read,
32
+ * memory-read, command) surfaces the whole body whenever the entry fires —
33
+ * both cap at 1000 words. A `boot` entry at `preview` routes a deliberate
32
34
  * reader into the whole body, so its cap is the longest doc worth reading
33
- * end-to-end (calibrated to the humanizer doc, 2936 words). `name`/`none`
34
- * rungs never cap: such a doc is only reached deliberately (browse or a
35
- * [[link]]), so its length is the reader's choice. The strictest
36
- * applicable cap wins.
35
+ * end-to-end (calibrated to the humanizer doc, 2936 words). `name` entries
36
+ * and surface-less docs never cap: such a doc is only reached deliberately
37
+ * (a listing, browse, or a [[link]]), so its length is the reader's choice.
38
+ * The strictest applicable cap wins.
37
39
  *
38
40
  * Persona layers — canonical names under `kinds/`, the docs the prompt
39
41
  * render composes into an agent's persona — are structurally exempt: an
@@ -42,9 +44,9 @@ const RUNG_FIELDS = ['system-prompt-visibility', 'file-read-visibility'];
42
44
  * deliberate and per-doc: `lint-ignore: length` in the frontmatter,
43
45
  * surfaced ONLY by the finding itself — never advertised in authoring
44
46
  * help. */
45
- const SYSTEM_CONTENT_MAX_WORDS = 1000;
46
- const FILE_READ_MAX_WORDS = 1000;
47
- const SYSTEM_PREVIEW_MAX_WORDS = 3000;
47
+ const BOOT_CONTENT_MAX_WORDS = 1000;
48
+ const EVENT_CONTENT_MAX_WORDS = 1000;
49
+ const BOOT_PREVIEW_MAX_WORDS = 3000;
48
50
  /** A routing line costs more than it saves for a short rule or fact. */
49
51
  const PREVIEW_TO_CONTENT_MAX_WORDS = 30;
50
52
  /** Rules a doc may suppress via frontmatter `lint-ignore`. */
@@ -57,24 +59,28 @@ function countBodyWords(body) {
57
59
  const trimmed = body.trim();
58
60
  return trimmed === '' ? 0 : trimmed.split(/\s+/).length;
59
61
  }
60
- /** The length rule. Caps are computed from the RAW rung strings — an invalid
61
- * rung already fails the schema check, so it simply matches no cap here.
62
- * `docName` is the doc's canonical resolver identity (explicit frontmatter
63
- * `name`, else the normalized path-derived name) — persona layers under
64
- * `kinds/` are recognized by it and never capped. */
62
+ /** The doc's surfaces entries as the runtime will read them. Used by the cap
63
+ * checks AFTER the strict schema check has run — for a doc that passes it,
64
+ * the tolerant parse and the strict contract agree. */
65
+ function parsedSurfaces(fm) {
66
+ return parseSubstrateFrontmatter(fm)?.surfaces ?? [];
67
+ }
68
+ /** Warn when a body short enough to inline still pays a routing line. */
65
69
  export function lintShortPreviewBody(fm, body) {
66
70
  const words = countBodyWords(body);
67
71
  if (words >= PREVIEW_TO_CONTENT_MAX_WORDS)
68
72
  return null;
69
- const previewAxes = [
70
- fm['system-prompt-visibility'] === 'preview' ? 'system-prompt-visibility' : undefined,
71
- fm['file-read-visibility'] === 'preview' ? 'file-read-visibility' : undefined,
72
- ].filter((axis) => axis !== undefined);
73
- if (previewAxes.length === 0)
73
+ const previewEvents = [...new Set(parsedSurfaces(fm).filter((e) => e.at === 'preview').map((e) => e.on))];
74
+ if (previewEvents.length === 0)
74
75
  return null;
75
- const verb = previewAxes.length === 1 ? 'is' : 'are';
76
- return `body is ${words} words but ${previewAxes.join(' and ')} ${verb} \`preview\`; use \`content\` on that axis so agents receive the whole rule without a separate memory read`;
76
+ const noun = previewEvents.length === 1 ? 'entry delivers' : 'entries deliver';
77
+ return `body is ${words} words but the ${previewEvents.join('/')} ${noun} \`preview\`; use \`at: content\` so agents receive the whole rule without a separate memory read`;
77
78
  }
79
+ /** The length rule. Caps are computed from the parsed entries — an invalid
80
+ * entry already fails the schema check, so it simply matches no cap here.
81
+ * `docName` is the doc's canonical resolver identity (explicit frontmatter
82
+ * `name`, else the normalized path-derived name) — persona layers under
83
+ * `kinds/` are recognized by it and never capped. */
78
84
  export function lintBodyLength(fm, body, docName) {
79
85
  if (ignoresRule(fm, 'length'))
80
86
  return null;
@@ -83,28 +89,78 @@ export function lintBodyLength(fm, body, docName) {
83
89
  // construction — persona length is persona design, never an authoring smell.
84
90
  if (docName === 'kinds' || docName.startsWith('kinds/'))
85
91
  return null;
86
- const sys = fm['system-prompt-visibility'];
87
- const file = fm['file-read-visibility'];
92
+ const entries = parsedSurfaces(fm);
88
93
  const words = countBodyWords(body);
89
- const remedy = 'Keep the load-bearing core here and split the depth into [[linked]] reference docs saved at `none` visibility on both axes (the link is how they are found, so they cost nothing until followed). Split by subject: each leaf covers a different subject a task might need on its own; never split off “further evidence”, examples, or references — a references leaf is never followed, so supporting material stays next to the point it supports or gets cut. Keep it whole — `lint-ignore: length` in the frontmatter — only when every reader who surfaces this doc genuinely benefits from reading 100% of it, or it is one indivisible body of knowledge; then splitting just adds hops.';
90
- if (sys === 'content' && words > SYSTEM_CONTENT_MAX_WORDS) {
91
- return `body is ${words} words but system-prompt-visibility: content inlines every word into every agent's system prompt at boot — capped at ${SYSTEM_CONTENT_MAX_WORDS} words (system-prompt preview gets ${SYSTEM_PREVIEW_MAX_WORDS}; name/none are never capped on that axis). ${remedy}`;
94
+ const remedy = 'Keep the load-bearing core here and split the depth into [[linked]] reference docs carrying no surfaces at all (the listing and the link are how they are found, so they cost nothing until followed). Split by subject: each leaf covers a different subject a task might need on its own; never split off “further evidence”, examples, or references — a references leaf is never followed, so supporting material stays next to the point it supports or gets cut. Keep it whole — `lint-ignore: length` in the frontmatter — only when every reader who surfaces this doc genuinely benefits from reading 100% of it, or it is one indivisible body of knowledge; then splitting just adds hops.';
95
+ if (entries.some((e) => e.on === 'boot' && e.at === 'content') && words > BOOT_CONTENT_MAX_WORDS) {
96
+ return `body is ${words} words but a boot entry at \`content\` inlines every word into every agent's system prompt — capped at ${BOOT_CONTENT_MAX_WORDS} words (boot preview gets ${BOOT_PREVIEW_MAX_WORDS}; name entries are never capped). ${remedy}`;
92
97
  }
93
- if ((file === 'content' || file === 'preview') && words > FILE_READ_MAX_WORDS) {
94
- return `body is ${words} words, over the ${FILE_READ_MAX_WORDS}-word cap for file-read routed rungs (file-read-visibility: preview|content surfaces the whole body through a workspace mount or a matching file read; name/none are never capped on that axis). ${remedy}`;
98
+ const contentEvents = [...new Set(entries.filter((e) => e.on !== 'boot' && e.at === 'content').map((e) => e.on))];
99
+ if (contentEvents.length > 0 && words > EVENT_CONTENT_MAX_WORDS) {
100
+ return `body is ${words} words, over the ${EVENT_CONTENT_MAX_WORDS}-word cap for a \`content\` entry on ${contentEvents.join('/')} (the whole body delivers every time the entry fires; name entries are never capped). ${remedy}`;
95
101
  }
96
- if (sys === 'preview' && words > SYSTEM_PREVIEW_MAX_WORDS) {
97
- return `body is ${words} words, over the ${SYSTEM_PREVIEW_MAX_WORDS}-word cap for system-prompt preview (the routing line invites every reader into the whole body, so the cap is the longest doc worth reading end-to-end; system-prompt content is capped at ${SYSTEM_CONTENT_MAX_WORDS} words; name/none are never capped on that axis). ${remedy}`;
102
+ if (entries.some((e) => e.on === 'boot' && e.at === 'preview') && words > BOOT_PREVIEW_MAX_WORDS) {
103
+ return `body is ${words} words, over the ${BOOT_PREVIEW_MAX_WORDS}-word cap for boot preview (the routing line invites every reader into the whole body, so the cap is the longest doc worth reading end-to-end; boot content is capped at ${BOOT_CONTENT_MAX_WORDS} words; name entries are never capped). ${remedy}`;
104
+ }
105
+ return null;
106
+ }
107
+ /** Strict validation of a raw `surfaces` value — the mirror of `coerceSurface`
108
+ * in shared.ts: everything the runtime parser would silently drop or trim is
109
+ * an error here. Returns the first problem found, or null. */
110
+ function lintSurfacesField(v) {
111
+ if (v === undefined)
112
+ return null;
113
+ if (!Array.isArray(v))
114
+ return `invalid surfaces: ${JSON.stringify(v)} (expected a list of {on, at, match?, match-frontmatter?} entries)`;
115
+ for (const raw of v) {
116
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
117
+ return `invalid surfaces entry: ${JSON.stringify(raw)} (expected an {on, at, match?, match-frontmatter?} object)`;
118
+ }
119
+ const rec = raw;
120
+ for (const key of Object.keys(rec)) {
121
+ if (!SURFACE_ENTRY_KEYS.includes(key)) {
122
+ return `invalid surfaces entry: unknown key \`${key}\` (an entry carries only on, match, match-frontmatter, at)`;
123
+ }
124
+ }
125
+ const on = rec['on'];
126
+ if (typeof on !== 'string' || !SURFACE_EVENTS.includes(on)) {
127
+ return `invalid surfaces entry \`on\`: ${JSON.stringify(on)} (expected ${SURFACE_EVENTS.join('|')})`;
128
+ }
129
+ const at = rec['at'];
130
+ if (typeof at !== 'string' || !SURFACE_RUNGS.includes(at)) {
131
+ return `invalid surfaces entry \`at\`: ${JSON.stringify(at)} (expected ${SURFACE_RUNGS.join('|')})`;
132
+ }
133
+ let hasMatch = false;
134
+ if (rec['match'] !== undefined) {
135
+ if (on === 'boot' || on === 'workspace-open') {
136
+ return `invalid surfaces entry: \`match\` is meaningless on \`${on}\` — the entry's presence is the match; drop it`;
137
+ }
138
+ const m = rec['match'];
139
+ const globs = typeof m === 'string' ? [m] : Array.isArray(m) && m.every((g) => typeof g === 'string') ? m : null;
140
+ if (globs === null || globs.length === 0 || globs.some((g) => g.trim() === '')) {
141
+ return `invalid surfaces entry \`match\`: ${JSON.stringify(m)} (expected a non-empty glob or non-empty glob list)`;
142
+ }
143
+ hasMatch = true;
144
+ }
145
+ const mf = rec['match-frontmatter'];
146
+ if (mf !== undefined) {
147
+ if (on !== 'read')
148
+ return 'invalid surfaces entry: `match-frontmatter` is a `read`-event predicate only';
149
+ if (mf === null || typeof mf !== 'object' || Array.isArray(mf)) {
150
+ return `invalid surfaces entry \`match-frontmatter\`: ${JSON.stringify(mf)} (expected a field→matcher object)`;
151
+ }
152
+ }
153
+ if ((on === 'read' || on === 'memory-read' || on === 'command') && !hasMatch && mf === undefined) {
154
+ return `invalid surfaces entry: a \`${on}\` entry requires \`match\`${on === 'read' ? ' or `match-frontmatter`' : ''}`;
155
+ }
98
156
  }
99
157
  return null;
100
158
  }
101
159
  /** Schema checks for a doc living in a substrate memory dir: a memory store
102
160
  * holds ONLY substrate docs, so a missing/invalid `kind` is an authoring
103
- * error here (elsewhere it just means "not a substrate doc"). Both rungs are
104
- * REQUIRED — there is no kind default to lean on, so a missing rung is an
105
- * authoring error. Rung and gate values are checked RAW — the runtime parser
106
- * silently falls back to the neutral floor / inert gates, which is exactly the
107
- * silent tolerance this lint exists to catch at authoring time. */
161
+ * error here (elsewhere it just means "not a substrate doc"). Fields are
162
+ * checked RAW — the runtime parser silently drops invalid entries, which is
163
+ * exactly the silent tolerance this lint exists to catch at authoring time. */
108
164
  export function lintSubstrateSchema(fm) {
109
165
  if (fm === null)
110
166
  return 'missing frontmatter: a memory store doc requires `kind: knowledge|preference`';
@@ -120,52 +176,22 @@ export function lintSubstrateSchema(fm) {
120
176
  if (typeof fm['when-and-why-to-read'] !== 'string' || fm['when-and-why-to-read'].trim() === '') {
121
177
  return 'missing `when-and-why-to-read`: one routing line — "When <circumstance>, this <kind> should be read because <broader downstream payoff>." WHY is the benefit unlocked by reading, not the document summary, rule, or obedience rationale.';
122
178
  }
123
- for (const field of RUNG_FIELDS) {
124
- const v = fm[field];
125
- if (v === undefined) {
126
- return `missing ${field}: choose a rung explicitly (${RUNGS.join('|')}) — there is no kind default`;
127
- }
128
- if (typeof v !== 'string' || !VALID_RUNGS.includes(v)) {
129
- return `invalid ${field}: ${JSON.stringify(v)} (expected ${RUNGS.join('|')})`;
179
+ for (const field of RETIRED_FIELDS) {
180
+ if (field in fm) {
181
+ return `retired \`${field}\` key: the visibility axes were replaced by \`surfaces\` event routing — run \`crtr sys migrate\` to convert old-format docs, or author \`surfaces\` entries by hand (\`crtr memory write -h\`)`;
130
182
  }
131
183
  }
184
+ const unlisted = fm['unlisted'];
185
+ if (unlisted !== undefined && typeof unlisted !== 'boolean') {
186
+ return `invalid unlisted: ${JSON.stringify(unlisted)} (expected a boolean)`;
187
+ }
188
+ const surfacesError = lintSurfacesField(fm['surfaces']);
189
+ if (surfacesError !== null)
190
+ return surfacesError;
132
191
  const gate = fm.gate;
133
192
  if (gate !== undefined && (gate === null || typeof gate !== 'object' || Array.isArray(gate))) {
134
193
  return `invalid gate: ${JSON.stringify(gate)} (expected a field→matcher object)`;
135
194
  }
136
- const appliesTo = fm['applies-to'];
137
- let appliesToGlobs;
138
- if (appliesTo !== undefined) {
139
- if (typeof appliesTo === 'string') {
140
- appliesToGlobs = [appliesTo];
141
- }
142
- else if (Array.isArray(appliesTo) && appliesTo.every((g) => typeof g === 'string')) {
143
- appliesToGlobs = appliesTo;
144
- }
145
- else {
146
- return `invalid applies-to: ${JSON.stringify(appliesTo)} (expected one non-empty file glob or a non-empty list of file globs)`;
147
- }
148
- if (appliesToGlobs.length === 0 || appliesToGlobs.some((glob) => glob.trim() === '')) {
149
- return `invalid applies-to: ${JSON.stringify(appliesTo)} (expected one non-empty file glob or a non-empty list of file globs)`;
150
- }
151
- }
152
- // read-when (Stream A on-read frontmatter trigger): same well-formed-object
153
- // contract as gate — a non-object is inert (never fires), so catch it here.
154
- const readWhen = fm['read-when'];
155
- if (readWhen !== undefined && (readWhen === null || typeof readWhen !== 'object' || Array.isArray(readWhen))) {
156
- return `invalid read-when: ${JSON.stringify(readWhen)} (expected a field→matcher object)`;
157
- }
158
- // Every authored file-read surface needs an explicit event boundary. Runtime
159
- // parsing stays tolerant so one bad doc cannot break an agent's context.
160
- if (fm['file-read-visibility'] !== 'none' && appliesToGlobs === undefined) {
161
- return `missing applies-to: file-read-visibility is \`${fm['file-read-visibility']}\`, so this memory must declare when it surfaces. Use \`applies-to: "."\` for context loaded when cwd or a selected profile mounts this project store and on any file read beneath the store's owning dir, use a non-empty file glob (or YAML list) for context loaded only after a matching file is read, or set \`file-read-visibility: none\` when this memory has no file-context route. File globs match the read file's absolute path, basename, or path relative to the memory's owning project root. \`read-when\` matches file metadata but does not replace the required workspace/file boundary. Run \`crtr memory write -h\` before changing routing frontmatter.`;
162
- }
163
- // A dead on-read trigger: an explicit applies-to/read-when with nothing to
164
- // surface (file-read-visibility none) can never fire — flag it loudly rather
165
- // than store a silent no-op.
166
- if (fm['file-read-visibility'] === 'none' && (appliesTo !== undefined || readWhen !== undefined)) {
167
- return 'dead on-read trigger: applies-to/read-when is set but file-read-visibility is `none` — raise the rung or drop the trigger';
168
- }
169
195
  const slash = fm.slash;
170
196
  if (slash !== undefined && typeof slash !== 'boolean') {
171
197
  return `invalid slash: ${JSON.stringify(slash)} (expected a boolean)`;
@@ -214,27 +240,64 @@ function lintFile(file, substrateStore, findings, warnings, corpusNames, fallbac
214
240
  const shortPreviewWarning = lintShortPreviewBody(fm, body);
215
241
  if (shortPreviewWarning !== null)
216
242
  warnings.push(`${file}: ${shortPreviewWarning}`);
243
+ // An over-broad memory-read glob fires on EVERY memory read — almost
244
+ // always an authoring accident, so flag it without failing the corpus.
245
+ for (const entry of parsedSurfaces(fm)) {
246
+ if (entry.on !== 'memory-read')
247
+ continue;
248
+ for (const glob of entry.match ?? []) {
249
+ if (glob === '**' || glob === '*') {
250
+ warnings.push(`${file}: memory-read glob \`${glob}\` fires on every memory read — scope it to a name subtree`);
251
+ }
252
+ }
253
+ }
217
254
  }
218
255
  for (const name of docLinkNames(body)) {
219
256
  if (!corpusNames.has(name)) {
220
257
  findings.push({
221
258
  path: file,
222
- error: `dangling doc link [[${name}]]: no memory document has that exact canonical name — retarget the link (\`crtr memory find ${name.split('/').pop()}\`) or drop it`,
259
+ error: `dangling doc link [[${name}]]: no memory document or directory has that exact canonical name — retarget the link (\`crtr memory find ${name.split('/').pop()}\`) or drop it`,
223
260
  });
224
261
  }
225
262
  }
226
263
  }
264
+ /** The files in a project store carrying a workspace front-door entry
265
+ * ({on: workspace-open, at: content}). Raw fm scan; a missing store or an
266
+ * unparseable doc contributes zero (the YAML failure is its own finding). */
267
+ function frontDoorDocs(storeDir) {
268
+ const hits = [];
269
+ if (!pathExists(storeDir))
270
+ return hits;
271
+ for (const file of walkFiles(storeDir, (n) => n.endsWith('.md'), (d) => d.startsWith('.'))) {
272
+ if (basename(file) === 'MEMORY.md')
273
+ continue;
274
+ let fm;
275
+ try {
276
+ fm = parseFrontmatterGeneric(readText(file)).data;
277
+ }
278
+ catch {
279
+ continue;
280
+ }
281
+ if (fm === null)
282
+ continue;
283
+ const entries = parsedSurfaces(fm);
284
+ if (entries.some((e) => e.on === 'workspace-open' && e.at === 'content'))
285
+ hits.push(file);
286
+ }
287
+ return hits;
288
+ }
289
+ const FRONT_DOOR_REMEDY = 'author the project\'s operating guide as an ordinary doc with `surfaces: [{on: workspace-open, at: content}, {on: read, match: "./**", at: content}]`; run `crtr memory write -h` first';
227
290
  export const lintLeaf = defineLeaf({
228
291
  name: 'lint',
229
292
  description: 'validate frontmatter and body length across the whole bounded document corpus',
230
- whenToUse: 'you authored or migrated documents and want the authoring-time gate: strict-parse every doc in the bounded corpus (the substrate memory stores) and fail loudly on any invalid YAML, substrate schema violation, body longer than its visibility rung earns, or dangling `[[canonical/name]]` doc link; also validates the root INDEX.md front door of every project managed by the selected profile and warns when an unprofiled working directory has no front door. Run it before shipping doc changes; CI-friendly (non-zero exit on findings, warnings remain non-fatal).',
293
+ whenToUse: 'you authored or migrated documents and want the authoring-time gate: strict-parse every doc in the bounded corpus (the substrate memory stores) and fail loudly on any invalid YAML, substrate schema violation (including retired visibility fields and malformed `surfaces` entries), body longer than its delivery entries earn, or dangling `[[canonical/name]]` doc link; also validates that every project managed by the selected profile has exactly one workspace front door and warns when an unprofiled working directory has none. Run it before shipping doc changes; CI-friendly (non-zero exit on findings, warnings remain non-fatal).',
231
294
  help: {
232
295
  name: 'memory lint',
233
- summary: 'strict-parse the bounded memory corpus and validate project root INDEX front doors',
296
+ summary: 'strict-parse the bounded memory corpus and validate project workspace front doors',
234
297
  params: [],
235
298
  output: [
236
299
  { name: 'checked', type: 'number', required: true, constraint: 'Files linted across all corpora.' },
237
- { name: 'corpora', type: 'object', required: true, constraint: 'Per-corpus counts: {memory_stores (files), profile_projects (managed dirs checked for a root INDEX.md front door)}.' },
300
+ { name: 'corpora', type: 'object', required: true, constraint: 'Per-corpus counts: {memory_stores (files), profile_projects (managed dirs checked for a workspace front door)}.' },
238
301
  { name: 'findings', type: 'object[]', required: true, constraint: 'One row per failure: {path, error}. Empty when green.' },
239
302
  { name: 'warnings', type: 'string[]', required: true, constraint: 'Non-fatal authoring gaps detected for the directory where lint ran.' },
240
303
  ],
@@ -254,13 +317,17 @@ export const lintLeaf = defineLeaf({
254
317
  // path never yields a doc name, so e.g. the maintainer store shipped
255
318
  // at builtin-memory/.crouter can never register) — lint must not
256
319
  // flag files the substrate can never load.
257
- // Exact canonical names in the current resolvable corpus. INDEX docs also
258
- // expose their folded bare-directory name (`taste`, not `taste/INDEX`).
320
+ // The resolvable link targets: exact doc names, plus every proper name
321
+ // prefix — a bare-dir [[ref]] is a legal listing link (`crtr memory read
322
+ // <dir>` answers with the directory listing).
259
323
  const corpusNames = new Set();
260
324
  for (const doc of listAllMemoryDocs(undefined, true, true)) {
261
- const canonical = displayName(doc.name);
262
- if (canonical !== '')
263
- corpusNames.add(canonical);
325
+ if (doc.name === '')
326
+ continue;
327
+ corpusNames.add(doc.name);
328
+ const segs = doc.name.split('/');
329
+ for (let i = 1; i < segs.length; i++)
330
+ corpusNames.add(segs.slice(0, i).join('/'));
264
331
  }
265
332
  const lintDir = (dir) => {
266
333
  if (!dir || !pathExists(dir))
@@ -281,9 +348,9 @@ export const lintLeaf = defineLeaf({
281
348
  lintDir(pluginMemoryDir(plugin));
282
349
  }
283
350
  }
284
- // Nested descendant stores: same schema gate, plus a boot-rung warning —
285
- // the boot catalog deliberately never includes nested stores, so a
286
- // system-prompt-visibility above `none` there is an inert rung.
351
+ // Nested descendant stores: same schema gate, plus a boot-entry warning —
352
+ // the boot catalog deliberately never includes nested stores, so a boot
353
+ // surfaces entry there is inert.
287
354
  for (const root of descendantStoreRoots(projectScopeRoots())) {
288
355
  const dir = join(root, 'memory');
289
356
  lintDir(dir);
@@ -299,9 +366,8 @@ export const lintLeaf = defineLeaf({
299
366
  catch {
300
367
  continue; // invalid YAML is already a finding from lintDir
301
368
  }
302
- const sys = fm?.['system-prompt-visibility'];
303
- if (sys !== undefined && sys !== 'none') {
304
- warnings.push(`${file}: nested-store docs never ride the boot catalog, so this \`system-prompt-visibility\` rung is inert — set \`none\``);
369
+ if (fm !== null && parsedSurfaces(fm).some((e) => e.on === 'boot')) {
370
+ warnings.push(`${file}: nested-store docs never ride the boot catalog, so its boot surfaces entries are inert — drop them`);
305
371
  }
306
372
  }
307
373
  }
@@ -312,10 +378,11 @@ export const lintLeaf = defineLeaf({
312
378
  lintDir(pluginMemoryDir(plugin));
313
379
  }
314
380
  }
315
- // Profile coverage: each managed project has one direct root INDEX front
316
- // door. It enters first-message context through the workspace-open `.`
317
- // route, so its exact contract is knowledge + system none/file content +
318
- // applies-to containing `.`.
381
+ // Profile coverage: each managed project has exactly ONE workspace front
382
+ // door — a doc carrying {on: workspace-open, at: content}, the guide that
383
+ // enters first-message context when cwd/profile mounts the store. Zero
384
+ // means agents open the workspace blind; two means both deliver whole at
385
+ // every open, and the store needs consolidating.
319
386
  let profileProjects = 0;
320
387
  let profileResolved = false;
321
388
  const profileIdOrName = process.env['CRTR_PROFILE_ID'] || getDefaultProfileId(process.cwd());
@@ -326,38 +393,18 @@ export const lintLeaf = defineLeaf({
326
393
  lintDir(profileMemoryDir(profileId));
327
394
  for (const dir of manifest.projects) {
328
395
  profileProjects += 1;
329
- const indexPath = join(dir, '.crouter', 'memory', 'INDEX.md');
330
- if (!pathExists(indexPath)) {
396
+ const doors = frontDoorDocs(join(dir, '.crouter', 'memory'));
397
+ if (doors.length === 0) {
331
398
  findings.push({
332
399
  path: dir,
333
- error: `profile "${manifest.name}" manages this dir but it has no .crouter/memory/INDEX.md root front door — author INDEX in that exact project with kind knowledge, system-prompt-visibility none, file-read-visibility content, and applies-to "."; run \`crtr memory write -h\` first`,
400
+ error: `profile "${manifest.name}" manages this dir but no doc in its .crouter/memory carries a {on: workspace-open, at: content} surfaces entry — ${FRONT_DOOR_REMEDY}`,
334
401
  });
335
- continue;
336
- }
337
- try {
338
- const fm = parseFrontmatterGeneric(readText(indexPath)).data;
339
- const problems = [];
340
- if (fm?.kind !== 'knowledge')
341
- problems.push('kind must be `knowledge`');
342
- if (fm?.['system-prompt-visibility'] !== 'none')
343
- problems.push('system-prompt-visibility must be `none`');
344
- if (fm?.['file-read-visibility'] !== 'content')
345
- problems.push('file-read-visibility must be `content`');
346
- const applies = fm?.['applies-to'];
347
- const globs = typeof applies === 'string'
348
- ? [applies]
349
- : Array.isArray(applies) && applies.every((value) => typeof value === 'string')
350
- ? applies
351
- : [];
352
- if (!globs.some((glob) => glob.trim() === '.'))
353
- problems.push('applies-to must include `.`');
354
- if (problems.length > 0) {
355
- findings.push({ path: indexPath, error: `invalid project root front door: ${problems.join('; ')}` });
356
- }
357
402
  }
358
- catch (e) {
359
- const msg = (e instanceof Error ? e.message : String(e)).split('\n')[0];
360
- findings.push({ path: indexPath, error: `invalid project root front door YAML: ${msg}` });
403
+ else if (doors.length > 1) {
404
+ findings.push({
405
+ path: dir,
406
+ error: `multiple workspace front doors (${doors.length}): ${doors.join(', ')} — exactly one doc per project store may carry {on: workspace-open, at: content}; consolidate the others into it or lower their entries`,
407
+ });
361
408
  }
362
409
  }
363
410
  }
@@ -369,9 +416,8 @@ export const lintLeaf = defineLeaf({
369
416
  // Without a selected, resolvable profile there is no managed-project
370
417
  // finding to carry this signal, so keep a non-fatal cwd adoption warning.
371
418
  if (!profileResolved) {
372
- const workingDirectoryIndex = join(workingDirectory, '.crouter', 'memory', 'INDEX.md');
373
- if (!pathExists(workingDirectoryIndex)) {
374
- warnings.push(`${workingDirectory}: no .crouter/memory/INDEX.md root front door — author INDEX for this project with kind knowledge, system-prompt-visibility none, file-read-visibility content, and applies-to "."; run \`crtr memory write -h\` first`);
419
+ if (frontDoorDocs(join(workingDirectory, '.crouter', 'memory')).length === 0) {
420
+ warnings.push(`${workingDirectory}: no workspace front door — ${FRONT_DOOR_REMEDY}`);
375
421
  }
376
422
  }
377
423
  const checked = memoryCount;
@@ -388,7 +434,7 @@ export const lintLeaf = defineLeaf({
388
434
  checked,
389
435
  findings: findings.map((f) => ({ path: f.path, error: f.error })),
390
436
  warnings,
391
- next: 'Fix each doc (quote YAML values containing `: `; use a valid kind/rung/gate; route every non-`none` file-read rung with `applies-to: "."` for workspace-open context plus reads beneath the store\'s owning dir, or a non-empty file glob for matching reads; otherwise use file-read-visibility `none`; retarget or drop dangling [[links]]; split an over-length body into [[linked]] `none`-visibility reference docs); make every profile-managed project root INDEX match the front-door contract; then re-run `crtr memory lint`.',
437
+ next: 'Fix each doc (quote YAML values containing `: `; use a valid kind; run `crtr sys migrate` for retired visibility fields; give each surfaces entry a valid on/at and the match its event requires; retarget or drop dangling [[links]]; split an over-length body into [[linked]] surface-less reference docs); give every profile-managed project exactly one workspace front door; then re-run `crtr memory lint`.',
392
438
  });
393
439
  }
394
440
  return {
@@ -8,7 +8,6 @@ export interface ListedMemoryDoc {
8
8
  scope: MemoryScope;
9
9
  path: string;
10
10
  shortForm: string;
11
- isDir: boolean;
12
11
  slash: boolean;
13
12
  }
14
13
  export declare function listMemoryDocs(kindFilter?: string, scopeFilter?: MemoryScope, quiet?: boolean, docs?: readonly MemoryDoc[]): ListedMemoryDoc[];
@@ -1,7 +1,6 @@
1
1
  import { defineLeaf } from '../../core/command.js';
2
2
  import { listAllMemoryDocs } from '../../core/memory-resolver.js';
3
3
  import { parseSubstrateDoc } from '../../core/substrate/schema.js';
4
- import { isIndexName, indexDirOf } from '../../core/substrate/ceiling.js';
5
4
  import { paginate } from '../../core/pagination.js';
6
5
  import { MEMORY_KINDS, MEMORY_SCOPES, scopeRank } from './shared.js';
7
6
  export function listMemoryDocs(kindFilter, scopeFilter, quiet = false, docs = listAllMemoryDocs(scopeFilter, quiet, true)) {
@@ -21,15 +20,7 @@ export function listMemoryDocs(kindFilter, scopeFilter, quiet = false, docs = li
21
20
  continue;
22
21
  if (kindFilter !== undefined && sub.kind !== kindFilter)
23
22
  continue;
24
- // A directory INDEX surfaces as a dir entry: its dir path with a trailing
25
- // slash, flagged is_dir, so the inventory distinguishes it from a doc. A
26
- // store-root INDEX has no dir path, so it keeps its canonical `INDEX`
27
- // name — every name in this column must be one `crtr memory read` and a
28
- // `[[link]]` accept, and a bare `/` is neither.
29
- const isDir = isIndexName(sub.name);
30
- const dirPath = isDir ? indexDirOf(sub.name) : '';
31
- const name = dirPath === '' ? sub.name : dirPath + '/';
32
- addItem({ name, kind: sub.kind, scope: sub.scope, path: sub.path, shortForm: sub.shortForm, isDir, slash: sub.slash });
23
+ addItem({ name: sub.name, kind: sub.kind, scope: sub.scope, path: sub.path, shortForm: sub.shortForm, slash: sub.slash });
33
24
  }
34
25
  items.sort((a, b) => {
35
26
  const sr = scopeRank(a.scope) - scopeRank(b.scope);
@@ -57,7 +48,7 @@ export const listLeaf = defineLeaf({
57
48
  { kind: 'flag', name: 'cursor', type: 'string', required: false, constraint: 'Opaque token from next_cursor. Omit on first call.' },
58
49
  ],
59
50
  output: [
60
- { name: 'items', type: 'object[]', required: true, constraint: 'One row per document: {name, short_form, kind, scope, is_dir}. path is included only with --paths. A directory INDEX.md surfaces as a dir entry (is_dir true, name = the directory path with a trailing slash, or `INDEX` for a store-root INDEX that has no directory path); is_dir is false for an ordinary doc. Every name is readable as-is with `crtr memory read`. short_form is the abbreviated hook — shown here and nowhere else. Sorted by scope then kind then name ascending.' },
51
+ { name: 'items', type: 'object[]', required: true, constraint: 'One row per document: {name, short_form, kind, scope}. path is included only with --paths. Every name is readable as-is with `crtr memory read` (a directory segment of a name is readable too — it answers with the directory listing). short_form is the abbreviated hook — shown here and nowhere else. Sorted by scope then kind then name ascending.' },
61
52
  { name: 'next_cursor', type: 'string | null', required: true, constraint: 'Opaque token for the next page; null means no more items.' },
62
53
  { name: 'total', type: 'integer | null', required: true, constraint: 'Exact total across all pages when cheap to compute (always cheap here); never null in practice for this leaf.' },
63
54
  { name: 'follow_up', type: 'string', required: true, constraint: 'Concrete next commands — read a document in full or narrow the inventory.' },
@@ -81,7 +72,6 @@ export const listLeaf = defineLeaf({
81
72
  short_form: d.shortForm,
82
73
  kind: d.kind,
83
74
  scope: d.scope,
84
- is_dir: d.isDir,
85
75
  ...(includePaths ? { path: d.path } : {}),
86
76
  })),
87
77
  next_cursor: result.next_cursor,
@@ -0,0 +1 @@
1
+ export declare const moveLeaf: import("../../core/command.js").LeafDef;