@north-light/crouter 0.3.217 → 0.3.218

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 (59) hide show
  1. package/dist/api/client.d.ts +5 -2
  2. package/dist/api/client.js +7 -1
  3. package/dist/api/dto/canvas.d.ts +9 -0
  4. package/dist/api/dto/nodes.d.ts +27 -1
  5. package/dist/builtin-memory/04-base-worker.md +7 -1
  6. package/dist/builtin-memory/internal/plugins.md +67 -0
  7. package/dist/builtin-memory/memory-read-orientation.md +13 -0
  8. package/dist/clients/attach/chrome/bash-jobs.js +1 -1
  9. package/dist/clients/attach/photon_rs_bg.wasm +0 -0
  10. package/dist/clients/attach/viewer.js +429 -429
  11. package/dist/commands/human/shared.js +1 -1
  12. package/dist/commands/memory/edit.js +6 -1
  13. package/dist/commands/memory/lint.d.ts +1 -6
  14. package/dist/commands/memory/lint.js +18 -126
  15. package/dist/commands/memory/list.d.ts +1 -0
  16. package/dist/commands/memory/list.js +17 -2
  17. package/dist/commands/memory/read.js +15 -6
  18. package/dist/commands/memory/shared.d.ts +32 -2
  19. package/dist/commands/memory/shared.js +179 -0
  20. package/dist/commands/memory/write.js +6 -1
  21. package/dist/commands/pkg/browse/catalog.js +2 -0
  22. package/dist/commands/pkg/browse/model.d.ts +4 -1
  23. package/dist/commands/pkg/plugin-manage.js +209 -148
  24. package/dist/core/bash-jobs.d.ts +2 -5
  25. package/dist/core/bash-jobs.js +4 -8
  26. package/dist/core/command-plugins/bundle.d.ts +4 -1
  27. package/dist/core/command-plugins/bundle.js +16 -2
  28. package/dist/core/human/component-docs.js +1 -0
  29. package/dist/core/human/scan.d.ts +5 -4
  30. package/dist/core/human/scan.js +7 -4
  31. package/dist/core/io.js +3 -2
  32. package/dist/core/manifest.d.ts +2 -0
  33. package/dist/core/manifest.js +5 -0
  34. package/dist/core/memory/extensions.d.ts +30 -0
  35. package/dist/core/memory/extensions.js +219 -0
  36. package/dist/core/preview-result-path.d.ts +4 -0
  37. package/dist/core/preview-result-path.js +25 -0
  38. package/dist/core/substrate/frontmatter-validation.d.ts +13 -0
  39. package/dist/core/substrate/frontmatter-validation.js +101 -0
  40. package/dist/core/substrate/index.d.ts +1 -0
  41. package/dist/core/substrate/index.js +1 -0
  42. package/dist/daemon/api/__tests__/nodes-activity-query.test.d.ts +1 -0
  43. package/dist/daemon/api/__tests__/nodes-activity-query.test.js +101 -0
  44. package/dist/daemon/api/handlers/canvas.js +3 -0
  45. package/dist/daemon/api/handlers/inbox.js +4 -3
  46. package/dist/daemon/api/handlers/nodes.d.ts +1 -0
  47. package/dist/daemon/api/handlers/nodes.js +101 -19
  48. package/dist/daemon/api/handlers/reports.d.ts +4 -0
  49. package/dist/daemon/api/handlers/reports.js +14 -8
  50. package/dist/daemon/crtrd.js +7 -5
  51. package/dist/daemon/manage.d.ts +3 -0
  52. package/dist/daemon/manage.js +14 -0
  53. package/dist/pi-extensions/canvas-bash-valve.d.ts +4 -3
  54. package/dist/pi-extensions/canvas-bash-valve.js +19 -6
  55. package/dist/pi-extensions/canvas-preview-result.d.ts +0 -8
  56. package/dist/pi-extensions/canvas-preview-result.js +9 -23
  57. package/dist/types.d.ts +31 -0
  58. package/package.json +1 -1
  59. package/runtime.lock.json +2 -2
@@ -3,7 +3,7 @@ import { CORE_COMPONENT_SELECTION_LINES } from '../../core/human/component-docs.
3
3
  // The reader's missing context is the gap syntax cannot close: an inbox page
4
4
  // is opened away from the conversation that produced it, by someone who never
5
5
  // followed the work.
6
- const PAGE_BRIEF = 'Write the page for where it is read. A page projected to the inbox is opened away from this conversation by someone who has not followed the work, so it stands alone; an inline page can lean on what the conversation already said. Title names the topic; subtitle states the decision, your recommendation, and the stakes. Content carries only what is needed to decide, with the evidence behind it below the ask rather than ahead of it. Ask only what changes your next step, and offer only options you would actually take. A page that asks nothing states what happened and what it changes.';
6
+ const PAGE_BRIEF = 'Write the page for where it is read. A page projected to the inbox is opened away from this conversation by someone who has not followed the work, so it stands alone; an inline page can lean on what the conversation already said. Title names the topic; subtitle states the decision, your recommendation, and the stakes. Content carries only what is needed to decide, with the evidence behind it below the ask rather than ahead of it. Ask only what changes your next step, and offer only options you would actually take. The reader takes a page one step at a time, so hold each step — and a stepless page — to one question or request for action; several decisions means several `<Step>` children, never one long scroll of stacked context and asks. A page that asks nothing states what happened and what it changes.';
7
7
  const PAGE_AUTHORING = 'Write one `.tsx` module to `$CRTR_CONTEXT_DIR/pages/<name>.tsx` that default-exports a component whose root element is `<Page title="…" subtitle="…">`; a multi-step page puts its content in `<Step>` children. Or send a complete `.html` file with a nonempty `<title>`; HTML is delivered verbatim and collects no response. Delivery is chosen by `human send` flags, never a Page prop. Registry components and React are already in scope, so the module contains no `import` and no `require`. Full JS is yours: state, expressions, `.map` over your data, event handlers, plain elements with Tailwind classes. `usePageHost` is a reserved hook whose product-supplied members are opaque here. Never render submit or dismiss chrome — the host owns it. Display-only pages are first-class: a page with no questions may be sent without inbox or reply delivery. The module is compiled when you submit it and a compile error rejects the submit; there is no pre-check.';
8
8
  const DISPLAY_SET_LINE = 'The shadcn display set — `Card`, `Badge`, `Button`, `Table`, `Tabs`, `Separator`, `Alert`, `Progress`, `Accordion` and their parts — is in scope too, for presentation only.';
9
9
  /** The one page authoring model for `human -h`. */
@@ -4,7 +4,7 @@ import { parseFrontmatterGeneric } from '../../core/frontmatter.js';
4
4
  import { readText, writeText } from '../../core/fs-utils.js';
5
5
  import { resolveMemoryDoc } from '../../core/memory-resolver.js';
6
6
  import { appendHistoryRecord, buildHistoryRecord, historyLogPathFor, readHistoryRecords, } from '../../core/memory/history.js';
7
- import { DOC_RATIONALE_CONSTRAINT, GUIDE_DOC_LINKS, GUIDE_PREDICATE_VOCABULARY, GUIDE_ROUTING_LINE, GUIDE_SURFACES, MEMORY_SCOPES, coerceGate, coerceSurface, overlayParam, literalBodySegment, serializeMemoryDocLiteral, } from './shared.js';
7
+ import { DOC_RATIONALE_CONSTRAINT, GUIDE_DOC_LINKS, GUIDE_PREDICATE_VOCABULARY, GUIDE_ROUTING_LINE, GUIDE_SURFACES, MEMORY_SCOPES, coerceGate, coerceSurface, memoryExtensionCatalogForDoc, memoryExtensionFieldCatalogHelp, overlayParam, parseRequestedExtensionChanges, applyExtensionChanges, literalBodySegment, serializeMemoryDocLiteral, } from './shared.js';
8
8
  /** Frontmatter fields an overlay flag cannot express removing. `kind` and the
9
9
  * routing line are the document's contract (a doc without them is malformed);
10
10
  * `origin` and `last-updated` are runtime provenance. */
@@ -39,6 +39,8 @@ export const editLeaf = defineLeaf({
39
39
  overlayParam('gate'),
40
40
  overlayParam('slash', {}, 'Presence SETS the field; remove it with `--unset slash`.'),
41
41
  { kind: 'flag', name: 'doc-rationale', type: 'string', required: false, constraint: DOC_RATIONALE_CONSTRAINT },
42
+ { kind: 'flag', name: 'extension', type: 'string', required: false, repeatable: true, constraint: 'Set one declared plugin field as `extensions.<plugin>.<field>=VALUE`. The path is required in full; booleans accept only true or false, numbers require finite numeric syntax, and strings/enums preserve the literal text after the first =. Every requested field validates before the document is revised.' },
43
+ { kind: 'flag', name: 'unset-extension', type: 'string', required: false, repeatable: true, constraint: 'Remove one explicitly persisted declared field by its full `extensions.<plugin>.<field>` path. A removed explicit value lets its declaration default apply only in structured output.' },
42
44
  { kind: 'flag', name: 'unset', type: 'enum', choices: [...CLEARABLE_FIELDS], required: false, repeatable: true, constraint: 'Remove a frontmatter field an overlay flag cannot express removing. One field per occurrence. `kind`, `when-and-why-to-read`, `origin`, and `last-updated` are never clearable.' },
43
45
  { kind: 'flag', name: 'scope', type: 'enum', choices: [...MEMORY_SCOPES], required: false, constraint: 'Restrict resolution to this scope before editing. A disambiguation filter only \u2014 never a creation target, and never a way to move a doc between stores.' },
44
46
  { kind: 'stdin', name: 'body', required: false, constraint: 'Full replacement body (markdown, no frontmatter), saved byte for byte. Piped on stdin only \u2014 this leaf already claims the one positional for NAME. Absent or empty stdin leaves the existing body untouched, which is how a frontmatter-only revision is made.' },
@@ -54,6 +56,7 @@ export const editLeaf = defineLeaf({
54
56
  { name: 'follow_up', type: 'string', required: true, constraint: 'Concrete next commands \u2014 read the revisions or read the doc back.' },
55
57
  ],
56
58
  outputKind: 'object',
59
+ dynamicState: () => memoryExtensionFieldCatalogHelp('edit'),
57
60
  effects: [
58
61
  'Rewrites memory/<name>.md at the resolved scope and appends one `edit` record to memory/.history/<name>.jsonl.',
59
62
  ],
@@ -86,6 +89,7 @@ export const editLeaf = defineLeaf({
86
89
  if (doc.scope === 'builtin') {
87
90
  throw usage(`${doc.name} is a builtin document shipped with the package (read-only) \u2014 it cannot be edited. Override it with a same-named doc at a writable scope instead (\`crtr memory write ${doc.name} ...\`).`, { memory: doc.name, scope: 'builtin' });
88
91
  }
92
+ const extensionChanges = parseRequestedExtensionChanges(input['extension'], input['unsetExtension'], memoryExtensionCatalogForDoc(doc));
89
93
  const before = readText(doc.path);
90
94
  const parsed = parseFrontmatterGeneric(before);
91
95
  const previous = { ...(parsed.data ?? {}) };
@@ -107,6 +111,7 @@ export const editLeaf = defineLeaf({
107
111
  if (input['slash'] === true)
108
112
  frontmatter['slash'] = true;
109
113
  setIf('rationale', input['docRationale']);
114
+ applyExtensionChanges(frontmatter, extensionChanges.sets, extensionChanges.unsets);
110
115
  for (const field of input['unset'] ?? []) {
111
116
  delete frontmatter[field];
112
117
  }
@@ -1,3 +1,4 @@
1
+ export { lintSubstrateFrontmatter as lintSubstrateSchema } from '../../core/substrate/frontmatter-validation.js';
1
2
  /** Warn when a body short enough to inline still pays a routing line. */
2
3
  export declare function lintShortPreviewBody(fm: Record<string, unknown>, body: string): string | null;
3
4
  /** The length rule. Caps are computed from the parsed entries — an invalid
@@ -6,10 +7,4 @@ export declare function lintShortPreviewBody(fm: Record<string, unknown>, body:
6
7
  * `name`, else the normalized path-derived name) — persona layers under
7
8
  * `kinds/` are recognized by it and never capped. */
8
9
  export declare function lintBodyLength(fm: Record<string, unknown>, body: string, docName: string): string | null;
9
- /** Schema checks for a doc living in a substrate memory dir: a memory store
10
- * holds ONLY substrate docs, so a missing/invalid `kind` is an authoring
11
- * error here (elsewhere it just means "not a substrate doc"). Fields are
12
- * checked RAW — the runtime parser silently drops invalid entries, which is
13
- * exactly the silent tolerance this lint exists to catch at authoring time. */
14
- export declare function lintSubstrateSchema(fm: Record<string, unknown> | null): string | null;
15
10
  export declare const lintLeaf: import("../../core/command.js").LeafDef;
@@ -13,15 +13,13 @@ 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, parseSubstrateFrontmatter, resolveDocName, SURFACE_EVENTS, SURFACE_RUNGS, } from '../../core/substrate/schema.js';
16
+ import { normalizeDocName, parseSubstrateFrontmatter, resolveDocName, } from '../../core/substrate/schema.js';
17
+ import { lintSubstrateFrontmatter } from '../../core/substrate/frontmatter-validation.js';
18
+ export { lintSubstrateFrontmatter as lintSubstrateSchema } from '../../core/substrate/frontmatter-validation.js';
19
+ import { memoryExtensionValidationCatalog, validateMemoryExtensionValues } from '../../core/memory/extensions.js';
17
20
  import { docLinkNames } from '../../core/memory/doc-link-grammar.js';
18
21
  import { listAllMemoryDocs } from '../../core/memory-resolver.js';
19
22
  import { descendantStoreRoots } from '../../core/nested-stores.js';
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'];
25
23
  /** Rung-scaled body-length caps, measured in WORDS (frontmatter excluded).
26
24
  * Words, not lines: house style writes each paragraph as ONE logical line
27
25
  * and lets the editor soft-wrap, so a line count measures wrapping style
@@ -49,8 +47,6 @@ const EVENT_CONTENT_MAX_WORDS = 1000;
49
47
  const BOOT_PREVIEW_MAX_WORDS = 3000;
50
48
  /** A routing line costs more than it saves for a short rule or fact. */
51
49
  const PREVIEW_TO_CONTENT_MAX_WORDS = 30;
52
- /** Rules a doc may suppress via frontmatter `lint-ignore`. */
53
- const SUPPRESSIBLE_RULES = ['length'];
54
50
  function ignoresRule(fm, rule) {
55
51
  const v = fm['lint-ignore'];
56
52
  return v === rule || (Array.isArray(v) && v.includes(rule));
@@ -104,120 +100,13 @@ export function lintBodyLength(fm, body, docName) {
104
100
  }
105
101
  return null;
106
102
  }
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
- }
156
- }
157
- return null;
158
- }
159
- /** Schema checks for a doc living in a substrate memory dir: a memory store
160
- * holds ONLY substrate docs, so a missing/invalid `kind` is an authoring
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. */
164
- export function lintSubstrateSchema(fm) {
165
- if (fm === null)
166
- return 'missing frontmatter: a memory store doc requires `kind: knowledge|preference`';
167
- if (!isDocKind(fm.kind)) {
168
- return `invalid kind: ${JSON.stringify(fm.kind)} (expected knowledge|preference)`;
169
- }
170
- // The retired `when`/`why` pair was merged into one read-routing field. The
171
- // hard cut is enforced HERE: an old-shape doc must fail, never be silently
172
- // read at runtime.
173
- if ('when' in fm || 'why' in fm) {
174
- return 'retired `when`/`why` keys: merge them into one `when-and-why-to-read` 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.';
175
- }
176
- if (typeof fm['when-and-why-to-read'] !== 'string' || fm['when-and-why-to-read'].trim() === '') {
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.';
178
- }
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\`)`;
182
- }
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;
191
- const gate = fm.gate;
192
- if (gate !== undefined && (gate === null || typeof gate !== 'object' || Array.isArray(gate))) {
193
- return `invalid gate: ${JSON.stringify(gate)} (expected a field→matcher object)`;
194
- }
195
- const slash = fm.slash;
196
- if (slash !== undefined && typeof slash !== 'boolean') {
197
- return `invalid slash: ${JSON.stringify(slash)} (expected a boolean)`;
198
- }
199
- // rationale (maintainer-facing gap this doc closes) is optional everywhere —
200
- // no nagging when absent, just a type check when present.
201
- const rationale = fm.rationale;
202
- if (rationale !== undefined && typeof rationale !== 'string') {
203
- return `invalid rationale: ${JSON.stringify(rationale)} (expected a string)`;
204
- }
205
- const lintIgnore = fm['lint-ignore'];
206
- if (lintIgnore !== undefined) {
207
- const rules = Array.isArray(lintIgnore) ? lintIgnore : [lintIgnore];
208
- if (rules.length === 0 || !rules.every((r) => typeof r === 'string' && SUPPRESSIBLE_RULES.includes(r))) {
209
- return `invalid lint-ignore: ${JSON.stringify(lintIgnore)} (the only suppressible rule is \`length\`)`;
210
- }
211
- }
212
- return null;
213
- }
214
103
  /** Strict-parse one file; push a finding on a YAML error, then run the
215
104
  * schema check when the file lives in a substrate store, then validate every
216
105
  * `[[canonical/name]]` doc link in the body against the exact resolvable
217
106
  * corpus. A dangling link is an authoring error caught HERE, never silently
218
107
  * carried; leaf-name fallback is deliberately excluded so links stay stable
219
108
  * as the graph grows and another document acquires the same leaf name. */
220
- function lintFile(file, substrateStore, findings, warnings, corpusNames, fallbackName) {
109
+ function lintFile(file, substrateStore, findings, warnings, corpusNames, fallbackName, scope) {
221
110
  let fm;
222
111
  let body;
223
112
  try {
@@ -230,10 +119,13 @@ function lintFile(file, substrateStore, findings, warnings, corpusNames, fallbac
230
119
  }
231
120
  if (!substrateStore)
232
121
  return;
233
- const schemaError = lintSubstrateSchema(fm);
122
+ const schemaError = lintSubstrateFrontmatter(fm);
234
123
  if (schemaError !== null)
235
124
  findings.push({ path: file, error: schemaError });
236
125
  if (fm !== null) {
126
+ for (const issue of validateMemoryExtensionValues(fm['extensions'], memoryExtensionValidationCatalog({ scope, path: file }))) {
127
+ findings.push({ path: file, error: `${issue.path}: ${issue.message}` });
128
+ }
237
129
  const lengthError = lintBodyLength(fm, body, resolveDocName(fm, fallbackName));
238
130
  if (lengthError !== null)
239
131
  findings.push({ path: file, error: lengthError });
@@ -246,7 +138,7 @@ function lintFile(file, substrateStore, findings, warnings, corpusNames, fallbac
246
138
  if (entry.on !== 'memory-read')
247
139
  continue;
248
140
  for (const glob of entry.match ?? []) {
249
- if (glob === '**' || glob === '*') {
141
+ if ((glob === '**' || glob === '*') && !ignoresRule(fm, 'broad-memory-read')) {
250
142
  warnings.push(`${file}: memory-read glob \`${glob}\` fires on every memory read — scope it to a name subtree`);
251
143
  }
252
144
  }
@@ -329,7 +221,7 @@ export const lintLeaf = defineLeaf({
329
221
  for (let i = 1; i < segs.length; i++)
330
222
  corpusNames.add(segs.slice(0, i).join('/'));
331
223
  }
332
- const lintDir = (dir) => {
224
+ const lintDir = (dir, scope) => {
333
225
  if (!dir || !pathExists(dir))
334
226
  return;
335
227
  for (const file of walkFiles(dir, (n) => n.endsWith('.md'), (d) => d.startsWith('.'))) {
@@ -338,14 +230,14 @@ export const lintLeaf = defineLeaf({
338
230
  continue;
339
231
  memoryCount += 1;
340
232
  const fallbackName = normalizeDocName(relPath.replace(/\.md$/, ''));
341
- lintFile(file, basename(file) !== 'MEMORY.md', findings, warnings, corpusNames, fallbackName);
233
+ lintFile(file, basename(file) !== 'MEMORY.md', findings, warnings, corpusNames, fallbackName, scope);
342
234
  }
343
235
  };
344
236
  for (const root of projectScopeRoots()) {
345
- lintDir(join(root, 'memory'));
237
+ lintDir(join(root, 'memory'), 'project');
346
238
  for (const plugin of listInstalledPluginsInRoot('project', root)) {
347
239
  if (plugin.enabled)
348
- lintDir(pluginMemoryDir(plugin));
240
+ lintDir(pluginMemoryDir(plugin), 'project');
349
241
  }
350
242
  }
351
243
  // Nested descendant stores: same schema gate, plus a boot-entry warning —
@@ -353,7 +245,7 @@ export const lintLeaf = defineLeaf({
353
245
  // surfaces entry there is inert.
354
246
  for (const root of descendantStoreRoots(projectScopeRoots())) {
355
247
  const dir = join(root, 'memory');
356
- lintDir(dir);
248
+ lintDir(dir, 'project');
357
249
  if (!pathExists(dir))
358
250
  continue;
359
251
  for (const file of walkFiles(dir, (n) => n.endsWith('.md'), (d) => d.startsWith('.'))) {
@@ -372,10 +264,10 @@ export const lintLeaf = defineLeaf({
372
264
  }
373
265
  }
374
266
  for (const scope of ['user', 'builtin']) {
375
- lintDir(scopeMemoryDir(scope));
267
+ lintDir(scopeMemoryDir(scope), scope);
376
268
  for (const plugin of listInstalledPlugins(scope)) {
377
269
  if (plugin.enabled)
378
- lintDir(pluginMemoryDir(plugin));
270
+ lintDir(pluginMemoryDir(plugin), scope);
379
271
  }
380
272
  }
381
273
  // Profile coverage: each managed project has exactly ONE workspace front
@@ -390,7 +282,7 @@ export const lintLeaf = defineLeaf({
390
282
  try {
391
283
  const { profileId, manifest } = loadProfileManifest(profileIdOrName);
392
284
  profileResolved = true;
393
- lintDir(profileMemoryDir(profileId));
285
+ lintDir(profileMemoryDir(profileId), 'user');
394
286
  for (const dir of manifest.projects) {
395
287
  profileProjects += 1;
396
288
  const doors = frontDoorDocs(join(dir, '.crouter', 'memory'));
@@ -9,6 +9,7 @@ export interface ListedMemoryDoc {
9
9
  path: string;
10
10
  shortForm: string;
11
11
  slash: boolean;
12
+ extensions: Record<string, Record<string, string | boolean | number>>;
12
13
  }
13
14
  export declare function listMemoryDocs(kindFilter?: string, scopeFilter?: MemoryScope, quiet?: boolean, docs?: readonly MemoryDoc[]): ListedMemoryDoc[];
14
15
  export declare const listLeaf: import("../../core/command.js").LeafDef;
@@ -1,5 +1,7 @@
1
1
  import { defineLeaf } from '../../core/command.js';
2
2
  import { listAllMemoryDocs } from '../../core/memory-resolver.js';
3
+ import { memoryExtensionEffectiveCatalog, projectEffectiveMemoryExtensions } from '../../core/memory/extensions.js';
4
+ import { renderResult } from '../../core/render.js';
3
5
  import { parseSubstrateDoc } from '../../core/substrate/schema.js';
4
6
  import { paginate } from '../../core/pagination.js';
5
7
  import { MEMORY_KINDS, MEMORY_SCOPES, scopeRank } from './shared.js';
@@ -20,7 +22,15 @@ export function listMemoryDocs(kindFilter, scopeFilter, quiet = false, docs = li
20
22
  continue;
21
23
  if (kindFilter !== undefined && sub.kind !== kindFilter)
22
24
  continue;
23
- addItem({ name: sub.name, kind: sub.kind, scope: sub.scope, path: sub.path, shortForm: sub.shortForm, slash: sub.slash });
25
+ addItem({
26
+ name: sub.name,
27
+ kind: sub.kind,
28
+ scope: sub.scope,
29
+ path: sub.path,
30
+ shortForm: sub.shortForm,
31
+ slash: sub.slash,
32
+ extensions: projectEffectiveMemoryExtensions(doc.frontmatter?.['extensions'], memoryExtensionEffectiveCatalog(doc)),
33
+ });
24
34
  }
25
35
  items.sort((a, b) => {
26
36
  const sr = scopeRank(a.scope) - scopeRank(b.scope);
@@ -48,7 +58,7 @@ export const listLeaf = defineLeaf({
48
58
  { kind: 'flag', name: 'cursor', type: 'string', required: false, constraint: 'Opaque token from next_cursor. Omit on first call.' },
49
59
  ],
50
60
  output: [
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
+ { name: 'items', type: 'object[]', required: true, constraint: 'One row per document: {name, short_form, kind, scope, extensions}. extensions carries effective valid plugin-owned metadata; enabled defaults appear here without becoming frontmatter, while disabled and unresolved namespaces are omitted. 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.' },
52
62
  { name: 'next_cursor', type: 'string | null', required: true, constraint: 'Opaque token for the next page; null means no more items.' },
53
63
  { 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.' },
54
64
  { name: 'follow_up', type: 'string', required: true, constraint: 'Concrete next commands — read a document in full or narrow the inventory.' },
@@ -72,6 +82,7 @@ export const listLeaf = defineLeaf({
72
82
  short_form: d.shortForm,
73
83
  kind: d.kind,
74
84
  scope: d.scope,
85
+ extensions: d.extensions,
75
86
  ...(includePaths ? { path: d.path } : {}),
76
87
  })),
77
88
  next_cursor: result.next_cursor,
@@ -79,4 +90,8 @@ export const listLeaf = defineLeaf({
79
90
  follow_up: 'Read one in full with `crtr memory read <name>`, or revise one with `crtr memory edit <name> --rationale "<why>"` (body, routing, frontmatter, and visibility all go through that one verb, and it records the change). Narrow with --kind / --scope, page with --cursor, or search a topic with `crtr memory find <query>`.',
80
91
  };
81
92
  },
93
+ render: (result) => {
94
+ const items = result['items'].map(({ extensions: _extensions, ...item }) => item);
95
+ return renderResult({ ...result, items }, listLeaf.help);
96
+ },
82
97
  });
@@ -2,6 +2,7 @@ import { CrtrClient } from '../../api/index.js';
2
2
  import { interpolateNodePaths } from '../../core/canvas/paths.js';
3
3
  import { defineLeaf } from '../../core/command.js';
4
4
  import { CrtrError, notFound } from '../../core/errors.js';
5
+ import { memoryExtensionEffectiveCatalog, projectEffectiveMemoryExtensions } from '../../core/memory/extensions.js';
5
6
  import { readText, realpathOrSelf } from '../../core/fs-utils.js';
6
7
  import { parseFrontmatterGeneric } from '../../core/frontmatter.js';
7
8
  import { docLinkNames } from '../../core/memory/doc-link-grammar.js';
@@ -10,6 +11,7 @@ import { expandShellBlocks, hasShellBlocks, makeNodeShellRunner } from '../../co
10
11
  import { deliveredAtOrAbove, loadInjectedDocs, recordDelivery, saveInjectedDocs } from '../../core/substrate/injected-store.js';
11
12
  import { ancestorDirsOf, dirDedupKey, docsByName, isDirName, renderDirListing } from '../../core/substrate/listings.js';
12
13
  import { memoryReadDocBlocks } from '../../core/substrate/on-read.js';
14
+ import { renderResult } from '../../core/render.js';
13
15
  import { effectiveDocKind, normalizeDocName } from '../../core/substrate/schema.js';
14
16
  import { MEMORY_KINDS } from './shared.js';
15
17
  export { createMemoryDocSnapshot, resolveMemoryDocs };
@@ -72,8 +74,9 @@ export const readLeaf = defineLeaf({
72
74
  { name: 'path', type: 'string', required: false, constraint: 'Absolute path to the document on disk. Revise it with `crtr memory edit`, never by editing this file — an edit records why the change happened and lands in the doc’s revision history. Absent on a directory read.' },
73
75
  { name: 'content', type: 'string', required: false, constraint: 'Document body. Frontmatter stripped unless --frontmatter is set. A document may embed shell as `!`cmd`` or a ```! fenced block; each runs once per read, in the current working directory, and is replaced by its output. Read `path` off disk when you need the literal unexecuted text. May be preceded by one `<auto-loaded-context>` block carrying the doc\u2019s neighborhood listings and any docs routed to this read. Absent on a directory read.' },
74
76
  { name: 'listing', type: 'string[]', required: false, constraint: 'Present only on a directory read: one `[[name]]: <when-and-why>` line per member doc, one bare `[[name]]` per subdirectory. Follow any line with `crtr memory read <name>`.' },
77
+ { name: 'extensions', type: 'object', required: false, constraint: 'Effective valid plugin-owned metadata, keyed by plugin namespace then field. Includes enabled declaration defaults without writing them to frontmatter; disabled and unresolved namespaces are omitted. Present only on a document read.' },
75
78
  { name: 'links', type: 'string[]', required: false, constraint: 'Canonical names this document links to via `[[name]]` that resolve in the current corpus — further reading, loaded only on demand with `crtr memory read <name>`. A directory link returns that directory\u2019s listing. Omitted when the body carries no resolvable links.' },
76
- { name: 'follow_up', type: 'string', required: true, constraint: 'Hints at variant flags or next commands.' },
79
+ { name: 'follow_up', type: 'string', required: false, constraint: 'Present on a directory read, or on a document read whose body links to further memory documents.' },
77
80
  ],
78
81
  outputKind: 'object',
79
82
  effects: [
@@ -191,11 +194,17 @@ export const readLeaf = defineLeaf({
191
194
  scope: doc.scope,
192
195
  path: doc.path,
193
196
  content: finalContent,
194
- ...(links.length > 0 ? { links } : {}),
195
- follow_up: (links.length > 0
196
- ? 'The `[[name]]` links in the body are further reading — follow one with `crtr memory read <name>` only when the task needs that depth. '
197
- : '') +
198
- 'Use --frontmatter on this same command to inspect the YAML frontmatter, or revise the doc with `crtr memory edit` (recorded, with a rationale). Browse the inventory with `crtr memory list`.',
197
+ extensions: projectEffectiveMemoryExtensions(doc.frontmatter?.['extensions'], memoryExtensionEffectiveCatalog(doc)),
198
+ ...(links.length > 0
199
+ ? {
200
+ links,
201
+ follow_up: 'The `[[name]]` links in the body are further reading — follow one with `crtr memory read <name>` only when the task needs that depth.',
202
+ }
203
+ : {}),
199
204
  };
200
205
  },
206
+ render: (result) => {
207
+ const { extensions: _extensions, ...withoutExtensions } = result;
208
+ return renderResult(withoutExtensions, readLeaf.help);
209
+ },
201
210
  });
@@ -1,5 +1,7 @@
1
- import type { FlagParam } from '../../core/help.js';
2
- import type { MemoryScope } from '../../core/memory-resolver.js';
1
+ import { type FlagParam } from '../../core/help.js';
2
+ import type { MemoryDoc, MemoryScope } from '../../core/memory-resolver.js';
3
+ import { type MemoryExtensionCatalog } from '../../core/memory/extensions.js';
4
+ import type { MemoryExtensionScalar } from '../../types.js';
3
5
  export declare const MEMORY_KINDS: readonly ["knowledge", "preference"];
4
6
  export declare const MEMORY_SCOPES: readonly ["user", "project", "profile", "node"];
5
7
  /** Scope sort weight matching resolution precedence (node > project stack >
@@ -64,6 +66,33 @@ export declare function literalBodySegment(source: string): string;
64
66
  * untransformed. Creation uses `serializeMemoryDoc`, which normalizes a first
65
67
  * body into the canonical shape. */
66
68
  export declare function serializeMemoryDocLiteral(frontmatter: Record<string, unknown>, bodySegment: string): string;
69
+ export interface RequestedExtensionSet {
70
+ path: string;
71
+ namespace: string;
72
+ field: string;
73
+ value: MemoryExtensionScalar;
74
+ }
75
+ interface RequestedExtensionPath {
76
+ path: string;
77
+ namespace: string;
78
+ field: string;
79
+ }
80
+ /** Resolve declarations at the same scope and path a document will use. The
81
+ * catalog intentionally includes disabled plugins: they still authorize typed
82
+ * edits to values that stay inert until their plugin is enabled again. */
83
+ export declare function memoryExtensionCatalogForDoc(doc: Pick<MemoryDoc, 'scope' | 'path'>): MemoryExtensionCatalog;
84
+ /** A bounded declaration catalog for the two authoring leaves. The manifest is
85
+ * the source for both the type and operation-specific guidance. */
86
+ export declare function memoryExtensionFieldCatalogHelp(operation: 'write' | 'edit'): string | null;
87
+ /** Parse every requested extension mutation before the caller constructs any
88
+ * frontmatter. Set values are deliberately declaration-typed rather than YAML. */
89
+ export declare function parseRequestedExtensionChanges(setRaw: readonly string[] | undefined, unsetRaw: readonly string[] | undefined, catalog: MemoryExtensionCatalog): {
90
+ sets: RequestedExtensionSet[];
91
+ unsets: RequestedExtensionPath[];
92
+ };
93
+ /** Apply only requested extension paths. Existing unknown namespaces remain
94
+ * untouched; an unset prunes the empty namespace and empty root mapping. */
95
+ export declare function applyExtensionChanges(frontmatter: Record<string, unknown>, sets: readonly RequestedExtensionSet[], unsets: readonly RequestedExtensionPath[]): void;
67
96
  /** The frontmatter-overlay flags, keyed by flag name, carrying the prose true
68
97
  * on BOTH leaves. `write` appends its creation clauses with `withConstraint`;
69
98
  * neither leaf restates the other's rules. */
@@ -80,3 +109,4 @@ export declare const GUIDE_SURFACES = "Every doc appears in its directory\u2019s
80
109
  export declare const GUIDE_ROUTING_LINE = "The routing line (--when-and-why-to-read) is the only text an agent reads before deciding to load the doc. The test for its when-clause: it names an observable circumstance in the reader\u2019s current work, not the doc\u2019s topic reworded as an activity. If the WHEN can be inferred from the title alone, it is not a real trigger. Bad on a todo list: \"When planning or prioritizing work across this profile.\" Good: \"When the user mentions something from their todos, or asks what is still outstanding across this profile.\" The test for its because-clause: if it can be derived by paraphrasing the doc\u2019s advice, it is a restatement, not a payoff \u2014 a real payoff names a consequence in the reader\u2019s world that the document itself never asserts. Bad: \"because only genuine first principles belong in taste memory.\" Bad: \"because keeping the test loop fast and free of speculative tests protects the development pace\" \u2014 the doc\u2019s rule as an outcome, derivable straight from its advice. Good: \"because identifying throughlines in user taste lets future decisions be made faster and with less re-litigation.\" Someone mid-task who has not read the doc must be able to decide from this line alone whether the read is worth it. If you cannot name the concrete situation that triggers it, you do not yet understand the memory \u2014 ask the user one sharp question instead of improvising.";
81
110
  export declare const GUIDE_PREDICATE_VOCABULARY = "Gate and match-frontmatter share the same predicate language: a field map is AND-ed across fields; dotted fields resolve nested values; field matchers may be scalar, array, or object. Scalar matchers do exact equality, with arrays matching any element. Array matchers do membership or intersection. Object matchers accept `eq`, `ne`, `in`, `nin`, `exists`, `contains`, `containsAll`, `containsAny`, `matches`, `imatches`, `gt`, `gte`, `lt`, and `lte`. Combinators are `all`, `any`, and `not`; sibling field matchers next to them are AND-ed in. An empty condition is inert. An unknown op never matches.";
82
111
  export declare const GUIDE_DOC_LINKS = "Link related docs with `[[canonical/name]]`. A doc link in any body is a first-class cross-reference to another memory document by its exact canonical name \u2014 the same identifier `crtr memory read` takes. A bare directory name is a valid link too: following it returns that directory\u2019s listing, a browse entrance rather than a doc. Links are pointers, never transclusion: the linked body is loaded only when a reader follows it, so a link costs nothing until needed. `crtr memory lint` fails on a link that resolves to no document or directory, and `crtr memory read` lists a doc\u2019s resolvable links alongside its body. There is no alias or label form.";
112
+ export {};