@north-light/crouter 0.3.162 → 0.3.163

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 (95) hide show
  1. package/dist/builtin-memory/00-runtime-base.md +1 -0
  2. package/dist/builtin-memory/04-orchestration-kernel.md +1 -0
  3. package/dist/builtin-memory/init.md +38 -0
  4. package/dist/builtin-memory/wedged-child-on-runaway-bash.md +2 -2
  5. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +0 -1
  6. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +34 -2
  7. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +14 -3
  8. package/dist/clients/attach/__tests__/crtr-output.test.js +14 -9
  9. package/dist/clients/attach/__tests__/edit-diff.test.d.ts +1 -0
  10. package/dist/clients/attach/__tests__/edit-diff.test.js +34 -0
  11. package/dist/clients/attach/__tests__/editor-frame-title.test.d.ts +1 -0
  12. package/dist/clients/attach/__tests__/editor-frame-title.test.js +19 -0
  13. package/dist/clients/attach/render/chat-view.js +8 -3
  14. package/dist/clients/attach/render/crtr-output.js +31 -2
  15. package/dist/clients/attach/render/edit-diff.js +26 -2
  16. package/dist/clients/attach/render/tool-calls.d.ts +7 -2
  17. package/dist/clients/attach/render/tool-calls.js +25 -4
  18. package/dist/clients/attach/session/connection.d.ts +1 -1
  19. package/dist/clients/attach/session/editor-frame.d.ts +5 -1
  20. package/dist/clients/attach/session/editor-frame.js +21 -15
  21. package/dist/clients/attach/session/identity.d.ts +1 -1
  22. package/dist/clients/attach/viewer.js +621 -619
  23. package/dist/commands/__tests__/api-canvas-source.test.d.ts +1 -0
  24. package/dist/commands/__tests__/api-canvas-source.test.js +37 -0
  25. package/dist/commands/__tests__/human.test.js +2 -0
  26. package/dist/commands/__tests__/search-contents.test.d.ts +1 -0
  27. package/dist/commands/__tests__/search-contents.test.js +52 -0
  28. package/dist/commands/api-client.js +1 -0
  29. package/dist/commands/human/prompts.js +2 -2
  30. package/dist/commands/human/shared.d.ts +1 -0
  31. package/dist/commands/human/shared.js +7 -4
  32. package/dist/commands/memory/lint.d.ts +6 -0
  33. package/dist/commands/memory/lint.js +177 -35
  34. package/dist/commands/memory/read.js +22 -1
  35. package/dist/commands/memory/write.js +18 -4
  36. package/dist/commands/memory.js +1 -1
  37. package/dist/commands/search/contents.js +2 -2
  38. package/dist/commands/sys/__tests__/sync-deps.test.js +12 -21
  39. package/dist/commands/sys/__tests__/sync-import.test.js +30 -29
  40. package/dist/commands/sys/setup-wizard.d.ts +8 -1
  41. package/dist/commands/sys/setup-wizard.js +209 -58
  42. package/dist/commands/sys/sync-deps.js +14 -18
  43. package/dist/commands/sys/sync-project-guidance.js +16 -19
  44. package/dist/core/__tests__/migration.test.js +8 -3
  45. package/dist/core/__tests__/on-read-crouter-home-fence.test.js +7 -10
  46. package/dist/core/__tests__/on-read-dedup-resume.test.js +13 -25
  47. package/dist/core/__tests__/on-read-identity.test.js +8 -15
  48. package/dist/core/__tests__/revive.test.js +2 -2
  49. package/dist/core/__tests__/tmux-surface.test.js +10 -4
  50. package/dist/core/__tests__/worktree.test.js +3 -3
  51. package/dist/core/canvas/extensions.d.ts +1 -1
  52. package/dist/core/canvas/extensions.js +18 -11
  53. package/dist/core/canvas/labels.d.ts +4 -5
  54. package/dist/core/canvas/labels.js +9 -9
  55. package/dist/core/command-manifests/schema.js +18 -5
  56. package/dist/core/command.js +17 -7
  57. package/dist/core/configured-clis/invoker.js +2 -0
  58. package/dist/core/help.d.ts +3 -1
  59. package/dist/core/help.js +5 -2
  60. package/dist/core/keybindings/__tests__/resolve.test.js +5 -2
  61. package/dist/core/keybindings/catalog.d.ts +3 -2
  62. package/dist/core/keybindings/catalog.js +34 -25
  63. package/dist/core/keybindings/index.d.ts +1 -1
  64. package/dist/core/keybindings/resolve.js +8 -0
  65. package/dist/core/keybindings/types.d.ts +6 -1
  66. package/dist/core/memory/doc-link-grammar.d.ts +20 -0
  67. package/dist/core/memory/doc-link-grammar.js +110 -0
  68. package/dist/core/memory-resolver.d.ts +5 -0
  69. package/dist/core/memory-resolver.js +12 -1
  70. package/dist/core/runtime/bearings.d.ts +8 -8
  71. package/dist/core/runtime/bearings.js +20 -16
  72. package/dist/core/runtime/broker.js +3 -13
  73. package/dist/core/runtime/canvas-extensions.d.ts +1 -0
  74. package/dist/core/runtime/canvas-extensions.js +2 -0
  75. package/dist/core/runtime/launch.d.ts +1 -1
  76. package/dist/core/runtime/launch.js +1 -1
  77. package/dist/core/runtime/tmux.js +136 -100
  78. package/dist/core/scope.js +4 -5
  79. package/dist/core/substrate/ceiling.d.ts +3 -6
  80. package/dist/core/substrate/ceiling.js +13 -15
  81. package/dist/core/substrate/index.d.ts +2 -3
  82. package/dist/core/substrate/index.js +2 -2
  83. package/dist/core/substrate/on-read.d.ts +4 -13
  84. package/dist/core/substrate/on-read.js +142 -262
  85. package/dist/core/substrate/render.js +4 -4
  86. package/dist/core/substrate/schema.d.ts +3 -2
  87. package/dist/core/substrate/schema.js +5 -5
  88. package/dist/pi-extensions/__tests__/canvas-tool-guide.test.d.ts +1 -0
  89. package/dist/pi-extensions/__tests__/canvas-tool-guide.test.js +96 -0
  90. package/dist/pi-extensions/canvas-doc-substrate.js +7 -8
  91. package/dist/pi-extensions/canvas-tool-guide.d.ts +14 -0
  92. package/dist/pi-extensions/canvas-tool-guide.js +74 -0
  93. package/package.json +1 -1
  94. package/runtime.lock.json +2 -2
  95. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/crouter-help.ts +0 -95
@@ -0,0 +1,37 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { ApiCanvasSource } from '../api-client.js';
4
+ const detail = {
5
+ node_id: 'node-1',
6
+ name: 'test',
7
+ kind: 'general',
8
+ mode: 'base',
9
+ lifecycle: 'resident',
10
+ status: 'active',
11
+ cwd: '/tmp/project',
12
+ host_kind: 'broker',
13
+ profile_id: null,
14
+ parent: null,
15
+ created: '2026-07-31T00:00:00.000Z',
16
+ intent: null,
17
+ waiting_for: null,
18
+ pi_pid: null,
19
+ final_report: null,
20
+ finalized_at: null,
21
+ description: 'generated-description',
22
+ icon: '\uf1fc',
23
+ edges: { parent: null, spawned_by: null, subscribes_to: [], subscribers: [], children: [] },
24
+ paths: {
25
+ node_dir: '/tmp/node',
26
+ context_dir: '/tmp/node/context',
27
+ reports_dir: '/tmp/node/reports',
28
+ meta_path: '/tmp/node/meta.json',
29
+ inbox_path: '/tmp/node/inbox.jsonl',
30
+ transcript_path: '/tmp/node/transcript.jsonl',
31
+ view_socket: '/tmp/node/view.sock',
32
+ },
33
+ };
34
+ test('the API canvas source retains the node icon needed by a reloaded viewer', async () => {
35
+ const source = new ApiCanvasSource({ getNode: async () => detail });
36
+ assert.equal((await source.getNode(detail.node_id))?.icon, '\uf1fc');
37
+ });
@@ -167,6 +167,8 @@ describe('human ask: stdin question + strict deck', () => {
167
167
  assert.match(out, /^ stdin\s+optional\. Simple open-text ask body\./m);
168
168
  assert.match(out, /single-quoted heredoc/);
169
169
  assert.match(out, /--context-file PATH/);
170
+ assert.match(out, /bodyPath is a relative path to the body file, resolved from the directory containing the deck JSON/);
171
+ assert.match(out, /paths escaping with \.\. or symlinks are rejected; absolute paths are unsupported/);
170
172
  assert.doesNotMatch(out, /crtr human ask "<question>"/);
171
173
  });
172
174
  test('reads an explicit title and literal question bytes', async () => {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,52 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { parseArgv } from '../../core/command.js';
4
+ import { contentsLeaf } from '../search/contents.js';
5
+ async function requestBody(argv) {
6
+ const previousKey = process.env['EXA_API_KEY'];
7
+ const previousFetch = globalThis.fetch;
8
+ let body;
9
+ let requestCount = 0;
10
+ process.env['EXA_API_KEY'] = 'test-key';
11
+ globalThis.fetch = (async (_input, init) => {
12
+ requestCount++;
13
+ body = JSON.parse(String(init?.body));
14
+ return new Response(JSON.stringify({ results: [], statuses: [] }), {
15
+ status: 200,
16
+ headers: { 'content-type': 'application/json' },
17
+ });
18
+ });
19
+ try {
20
+ const input = await parseArgv(contentsLeaf.help.params ?? [], argv);
21
+ await contentsLeaf.run(input);
22
+ assert.equal(requestCount, 1);
23
+ assert.ok(body !== undefined);
24
+ return body;
25
+ }
26
+ finally {
27
+ globalThis.fetch = previousFetch;
28
+ if (previousKey === undefined)
29
+ delete process.env['EXA_API_KEY'];
30
+ else
31
+ process.env['EXA_API_KEY'] = previousKey;
32
+ }
33
+ }
34
+ test('search contents accepts separate URL positional argv tokens in one request', async () => {
35
+ const body = await requestBody(['https://example.com/one', 'https://example.com/two']);
36
+ assert.deepEqual(body['urls'], ['https://example.com/one', 'https://example.com/two']);
37
+ });
38
+ test('search contents flattens comma and whitespace-separated URLs across positional tokens', async () => {
39
+ const body = await requestBody([
40
+ 'https://example.com/one,https://example.com/two',
41
+ 'https://example.com/three https://example.com/four',
42
+ ]);
43
+ assert.deepEqual(body['urls'], [
44
+ 'https://example.com/one',
45
+ 'https://example.com/two',
46
+ 'https://example.com/three',
47
+ 'https://example.com/four',
48
+ ]);
49
+ });
50
+ test('search contents still rejects zero URL positional tokens', async () => {
51
+ await assert.rejects(() => parseArgv(contentsLeaf.help.params ?? [], []), (error) => error instanceof Error && error.message.includes('required parameter is missing'));
52
+ });
@@ -195,6 +195,7 @@ function detailToMeta(d) {
195
195
  node_id: d.node_id,
196
196
  name: d.name,
197
197
  description: d.description,
198
+ icon: d.icon,
198
199
  cycles: d.cycles,
199
200
  created: d.created,
200
201
  cwd: d.cwd,
@@ -14,7 +14,7 @@ import { tmuxServerReachable } from '../../core/spawn.js';
14
14
  import { BrokerClient, BrokerUnavailableError } from '../../core/broker-client/index.js';
15
15
  import { markStopSignal } from '../../core/runtime/stop-signals.js';
16
16
  import { validateDeck, notifyDeck, atomicWriteJson, submitDeck, submitReview, display, } from '@crouton-kit/humanloop';
17
- import { DECK_SCHEMA_HINT, registerCrouterRoot, resolveMaxPanes } from './shared.js';
17
+ import { BODY_PATH_CONTRACT, DECK_SCHEMA_HINT, registerCrouterRoot, resolveMaxPanes } from './shared.js';
18
18
  import { inboxOpenInstruction, inboxPopupHint } from '../../core/keybindings/index.js';
19
19
  /** The asking node's id, or null when run from a bare shell (no parent to route to). */
20
20
  function askingNode() {
@@ -159,7 +159,7 @@ export const humanAsk = defineLeaf({
159
159
  { kind: 'flag', name: 'title', type: 'string', required: false, constraint: 'Required with stdin: a short ≤~4-word inbox topic. Mutually exclusive with --context-file, whose JSON carries its own required title.' },
160
160
  { kind: 'flag', name: 'subtitle', type: 'string', required: false, constraint: 'Required with stdin: one plain-English sentence stating the decision, recommendation, and stakes. Mutually exclusive with --context-file, whose JSON requires every interaction subtitle.' },
161
161
  { kind: 'stdin', name: 'question', required: false, constraint: "Simple open-text ask body. Pipe markdown from a single-quoted heredoc (`<<'EOF'`) to preserve literal bytes. Requires --title and --subtitle; mutually exclusive with --context-file." },
162
- { kind: 'context-file', name: 'deck', required: false, constraint: 'Strict structured deck JSON with a required deck title and non-empty subtitle on every interaction. Use this for multiple interactions, options, or other deck structure. Mutually exclusive with --title, --subtitle, and the question shorthand.', shape: DECK_SCHEMA_HINT },
162
+ { kind: 'context-file', name: 'deck', required: false, constraint: `Strict structured deck JSON with a required deck title and non-empty subtitle on every interaction. ${BODY_PATH_CONTRACT}. Use this for multiple interactions, options, or other deck structure. Mutually exclusive with --title, --subtitle, and the question shorthand.`, shape: DECK_SCHEMA_HINT },
163
163
  ],
164
164
  output: [
165
165
  { name: 'job_id', type: 'string', required: true, constraint: 'Node id of this human interaction. Its answer is pushed to your inbox when the human responds.' },
@@ -1,4 +1,5 @@
1
1
  import type { CompletionHandler } from '@crouton-kit/humanloop';
2
+ export declare const BODY_PATH_CONTRACT: string;
2
3
  export declare const DECK_SCHEMA_HINT: string;
3
4
  /**
4
5
  * Crouter's private per-interaction record. Reduced to the delivery essentials:
@@ -5,14 +5,17 @@ import { existsSync, mkdirSync, readdirSync, renameSync, rmdirSync, statSync } f
5
5
  import { readConfig } from '../../core/config.js';
6
6
  import { nodesRoot } from '../../core/canvas/paths.js';
7
7
  import { interactionState, listInboxRoots, registerInboxRoot, unregisterInboxRoot } from '@crouton-kit/humanloop';
8
+ export const BODY_PATH_CONTRACT = 'bodyPath is a relative path to the body file, resolved from the directory containing the deck JSON; ' +
9
+ 'it must stay inside that directory, and paths escaping with .. or symlinks are rejected; absolute paths are unsupported';
8
10
  export const DECK_SCHEMA_HINT = 'Deck must match the humanloop deck schema: {title (required short inbox topic), ' +
9
11
  'source?:{sessionName?,askedBy?,blockedSince?}, interactions:[{id, ' +
10
12
  'title (short ≤~4-word inbox topic that identifies the decision for a cold reader), ' +
11
13
  'subtitle (required ONE plain-English sentence stating the decision, your recommendation, and its stakes), ' +
12
- '(body?|bodyPath?) (a self-contained decision brief: give the minimum plain-language ' +
13
- 'context and tradeoffs needed to decide, then place supporting evidence, chronology, and ' +
14
- 'technical detail under ## Details; the ONLY place for long or rich prose — directive-flavored ' +
15
- 'markdown rendered by termrender), options:[{id,label,description?}], multiSelect?, ' +
14
+ `(body?|bodyPath?) (a self-contained decision brief: give the minimum plain-language ` +
15
+ `context and tradeoffs needed to decide, then place supporting evidence, chronology, and ` +
16
+ `technical detail under ## Details; the ONLY place for long or rich prose — directive-flavored ` +
17
+ `markdown rendered by termrender). ${BODY_PATH_CONTRACT}. ` +
18
+ 'body and bodyPath are mutually exclusive, options:[{id,label,description?}], multiSelect?, ' +
16
19
  'allowFreetext?, freetextLabel?, ' +
17
20
  "kind?:'notify'|'decision'|'context'|'error'}]}.";
18
21
  export function resolveMaxPanes() {
@@ -1,3 +1,9 @@
1
+ /** The length rule. Caps are computed from the RAW rung strings — an invalid
2
+ * rung already fails the schema check, so it simply matches no cap here.
3
+ * `docName` is the doc's canonical resolver identity (explicit frontmatter
4
+ * `name`, else the normalized path-derived name) — persona layers under
5
+ * `kinds/` are recognized by it and never capped. */
6
+ export declare function lintBodyLength(fm: Record<string, unknown>, body: string, docName: string): string | null;
1
7
  /** Schema checks for a doc living in a substrate memory dir: a memory store
2
8
  * holds ONLY substrate docs, so a missing/invalid `kind` is an authoring
3
9
  * error here (elsewhere it just means "not a substrate doc"). Both rungs are
@@ -13,10 +13,75 @@ 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, RUNGS } from '../../core/substrate/schema.js';
17
- /** The parser normalizes the `always` alias to `content`, so it lints valid. */
18
- const VALID_RUNGS = [...RUNGS, 'always'];
16
+ import { isDocKind, normalizeDocName, resolveDocName, RUNGS } from '../../core/substrate/schema.js';
17
+ import { displayName } from '../../core/substrate/ceiling.js';
18
+ import { docLinkNames } from '../../core/memory/doc-link-grammar.js';
19
+ import { listAllMemoryDocs } from '../../core/memory-resolver.js';
20
+ const VALID_RUNGS = [...RUNGS];
19
21
  const RUNG_FIELDS = ['system-prompt-visibility', 'file-read-visibility'];
22
+ /** Rung-scaled body-length caps, measured in WORDS (frontmatter excluded).
23
+ * Words, not lines: house style writes each paragraph as ONE logical line
24
+ * and lets the editor soft-wrap, so a line count measures wrapping style
25
+ * rather than context cost — words track what the reader actually pays.
26
+ *
27
+ * `content` on the system axis inlines the whole body into every agent's
28
+ * system prompt at boot; `preview`/`content` on the file-read axis surface
29
+ * the whole body through a workspace mount or a matching file read — both
30
+ * cap at 1000 words. `preview` on the system axis routes a deliberate
31
+ * reader into the whole body, so its cap is the longest doc worth reading
32
+ * end-to-end (calibrated to the humanizer doc, 2936 words). `name`/`none`
33
+ * rungs never cap: such a doc is only reached deliberately (browse or a
34
+ * [[link]]), so its length is the reader's choice. The strictest
35
+ * applicable cap wins.
36
+ *
37
+ * Persona layers — canonical names under `kinds/`, the docs the prompt
38
+ * render composes into an agent's persona — are structurally exempt: an
39
+ * agent reads its whole persona by construction, so persona length is a
40
+ * persona-design choice, not an authoring smell. Suppression elsewhere is
41
+ * deliberate and per-doc: `lint-ignore: length` in the frontmatter,
42
+ * surfaced ONLY by the finding itself — never advertised in authoring
43
+ * help. */
44
+ const SYSTEM_CONTENT_MAX_WORDS = 1000;
45
+ const FILE_READ_MAX_WORDS = 1000;
46
+ const SYSTEM_PREVIEW_MAX_WORDS = 3000;
47
+ /** Rules a doc may suppress via frontmatter `lint-ignore`. */
48
+ const SUPPRESSIBLE_RULES = ['length'];
49
+ function ignoresRule(fm, rule) {
50
+ const v = fm['lint-ignore'];
51
+ return v === rule || (Array.isArray(v) && v.includes(rule));
52
+ }
53
+ function countBodyWords(body) {
54
+ const trimmed = body.trim();
55
+ return trimmed === '' ? 0 : trimmed.split(/\s+/).length;
56
+ }
57
+ /** The length rule. Caps are computed from the RAW rung strings — an invalid
58
+ * rung already fails the schema check, so it simply matches no cap here.
59
+ * `docName` is the doc's canonical resolver identity (explicit frontmatter
60
+ * `name`, else the normalized path-derived name) — persona layers under
61
+ * `kinds/` are recognized by it and never capped. */
62
+ export function lintBodyLength(fm, body, docName) {
63
+ if (ignoresRule(fm, 'length'))
64
+ return null;
65
+ // Persona exemption: render composes an agent's persona from the docs
66
+ // resolving under `kinds/...`, and an agent reads its whole persona by
67
+ // construction — persona length is persona design, never an authoring smell.
68
+ if (docName === 'kinds' || docName.startsWith('kinds/'))
69
+ return null;
70
+ const sys = fm['system-prompt-visibility'];
71
+ const file = fm['file-read-visibility'];
72
+ const words = countBodyWords(body);
73
+ 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). 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.';
74
+ if (sys === 'content' && words > SYSTEM_CONTENT_MAX_WORDS) {
75
+ 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}`;
76
+ }
77
+ if ((file === 'content' || file === 'preview') && words > FILE_READ_MAX_WORDS) {
78
+ 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}`;
79
+ }
80
+ if (sys === 'preview' && words > SYSTEM_PREVIEW_MAX_WORDS) {
81
+ 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}`;
82
+ }
83
+ return null;
84
+ }
20
85
  /** Schema checks for a doc living in a substrate memory dir: a memory store
21
86
  * holds ONLY substrate docs, so a missing/invalid `kind` is an authoring
22
87
  * error here (elsewhere it just means "not a substrate doc"). Both rungs are
@@ -53,10 +118,20 @@ export function lintSubstrateSchema(fm) {
53
118
  return `invalid gate: ${JSON.stringify(gate)} (expected a field→matcher object)`;
54
119
  }
55
120
  const appliesTo = fm['applies-to'];
56
- if (appliesTo !== undefined &&
57
- typeof appliesTo !== 'string' &&
58
- !(Array.isArray(appliesTo) && appliesTo.every((g) => typeof g === 'string'))) {
59
- return `invalid applies-to: ${JSON.stringify(appliesTo)} (expected a glob or glob list)`;
121
+ let appliesToGlobs;
122
+ if (appliesTo !== undefined) {
123
+ if (typeof appliesTo === 'string') {
124
+ appliesToGlobs = [appliesTo];
125
+ }
126
+ else if (Array.isArray(appliesTo) && appliesTo.every((g) => typeof g === 'string')) {
127
+ appliesToGlobs = appliesTo;
128
+ }
129
+ else {
130
+ return `invalid applies-to: ${JSON.stringify(appliesTo)} (expected one non-empty file glob or a non-empty list of file globs)`;
131
+ }
132
+ if (appliesToGlobs.length === 0 || appliesToGlobs.some((glob) => glob.trim() === '')) {
133
+ return `invalid applies-to: ${JSON.stringify(appliesTo)} (expected one non-empty file glob or a non-empty list of file globs)`;
134
+ }
60
135
  }
61
136
  // read-when (Stream A on-read frontmatter trigger): same well-formed-object
62
137
  // contract as gate — a non-object is inert (never fires), so catch it here.
@@ -64,6 +139,11 @@ export function lintSubstrateSchema(fm) {
64
139
  if (readWhen !== undefined && (readWhen === null || typeof readWhen !== 'object' || Array.isArray(readWhen))) {
65
140
  return `invalid read-when: ${JSON.stringify(readWhen)} (expected a field→matcher object)`;
66
141
  }
142
+ // Every authored file-read surface needs an explicit event boundary. Runtime
143
+ // parsing stays tolerant so one bad doc cannot break an agent's context.
144
+ if (fm['file-read-visibility'] !== 'none' && appliesToGlobs === undefined) {
145
+ 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, 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.`;
146
+ }
67
147
  // A dead on-read trigger: an explicit applies-to/read-when with nothing to
68
148
  // surface (file-read-visibility none) can never fire — flag it loudly rather
69
149
  // than store a silent no-op.
@@ -80,14 +160,26 @@ export function lintSubstrateSchema(fm) {
80
160
  if (rationale !== undefined && typeof rationale !== 'string') {
81
161
  return `invalid rationale: ${JSON.stringify(rationale)} (expected a string)`;
82
162
  }
163
+ const lintIgnore = fm['lint-ignore'];
164
+ if (lintIgnore !== undefined) {
165
+ const rules = Array.isArray(lintIgnore) ? lintIgnore : [lintIgnore];
166
+ if (rules.length === 0 || !rules.every((r) => typeof r === 'string' && SUPPRESSIBLE_RULES.includes(r))) {
167
+ return `invalid lint-ignore: ${JSON.stringify(lintIgnore)} (the only suppressible rule is \`length\`)`;
168
+ }
169
+ }
83
170
  return null;
84
171
  }
85
172
  /** Strict-parse one file; push a finding on a YAML error, then run the
86
- * schema check when the file lives in a substrate store. */
87
- function lintFile(file, substrateStore, findings) {
173
+ * schema check when the file lives in a substrate store, then validate every
174
+ * `[[canonical/name]]` doc link in the body against the exact resolvable
175
+ * corpus. A dangling link is an authoring error caught HERE, never silently
176
+ * carried; leaf-name fallback is deliberately excluded so links stay stable
177
+ * as the graph grows and another document acquires the same leaf name. */
178
+ function lintFile(file, substrateStore, findings, corpusNames, fallbackName) {
88
179
  let fm;
180
+ let body;
89
181
  try {
90
- fm = parseFrontmatterGeneric(readText(file)).data;
182
+ ({ data: fm, body } = parseFrontmatterGeneric(readText(file)));
91
183
  }
92
184
  catch (e) {
93
185
  const msg = (e instanceof Error ? e.message : String(e)).split('\n')[0];
@@ -99,18 +191,31 @@ function lintFile(file, substrateStore, findings) {
99
191
  const schemaError = lintSubstrateSchema(fm);
100
192
  if (schemaError !== null)
101
193
  findings.push({ path: file, error: schemaError });
194
+ if (fm !== null) {
195
+ const lengthError = lintBodyLength(fm, body, resolveDocName(fm, fallbackName));
196
+ if (lengthError !== null)
197
+ findings.push({ path: file, error: lengthError });
198
+ }
199
+ for (const name of docLinkNames(body)) {
200
+ if (!corpusNames.has(name)) {
201
+ findings.push({
202
+ path: file,
203
+ 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`,
204
+ });
205
+ }
206
+ }
102
207
  }
103
208
  export const lintLeaf = defineLeaf({
104
209
  name: 'lint',
105
- description: 'validate frontmatter across the whole bounded document corpus',
106
- 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 or substrate schema violation; also flags any dir the selected profile manages that lacks an AGENTS.md operating guide and warns when the directory where lint runs lacks a routed INDEX.md front door. Run it before shipping doc changes; CI-friendly (non-zero exit on findings, warnings remain non-fatal).',
210
+ description: 'validate frontmatter and body length across the whole bounded document corpus',
211
+ 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).',
107
212
  help: {
108
213
  name: 'memory lint',
109
- summary: 'strict-parse frontmatter across the bounded corpus; warn when the working directory lacks an INDEX',
214
+ summary: 'strict-parse the bounded memory corpus and validate project root INDEX front doors',
110
215
  params: [],
111
216
  output: [
112
217
  { name: 'checked', type: 'number', required: true, constraint: 'Files linted across all corpora.' },
113
- { name: 'corpora', type: 'object', required: true, constraint: 'Per-corpus counts: {memory_stores (files), profile_projects (managed dirs checked for AGENTS.md)}.' },
218
+ { 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)}.' },
114
219
  { name: 'findings', type: 'object[]', required: true, constraint: 'One row per failure: {path, error}. Empty when green.' },
115
220
  { name: 'warnings', type: 'string[]', required: true, constraint: 'Non-fatal authoring gaps detected for the directory where lint ran.' },
116
221
  ],
@@ -121,14 +226,7 @@ export const lintLeaf = defineLeaf({
121
226
  const findings = [];
122
227
  const warnings = [];
123
228
  let memoryCount = 0;
124
- // An INDEX gives the directory a discoverable, routed front door. It is
125
- // intentionally advisory: lint still validates every available store when
126
- // invoked from a directory that has not adopted project memory yet.
127
229
  const workingDirectory = process.cwd();
128
- const workingDirectoryIndex = join(workingDirectory, '.crouter', 'memory', 'INDEX.md');
129
- if (!pathExists(workingDirectoryIndex)) {
130
- warnings.push(`${workingDirectory}: no .crouter/memory/INDEX.md — create the directory's routed front door with \`crtr memory write INDEX -h\``);
131
- }
132
230
  // Substrate memory stores (ancestor projects/user/builtin), schema-aware.
133
231
  // A source's corpus is its NATIVE memory dir plus each enabled plugin's memory dir —
134
232
  // plugin docs are substrate docs and lint through the same schema gate.
@@ -137,14 +235,24 @@ export const lintLeaf = defineLeaf({
137
235
  // path never yields a doc name, so e.g. the maintainer store shipped
138
236
  // at builtin-memory/.crouter can never register) — lint must not
139
237
  // flag files the substrate can never load.
238
+ // Exact canonical names in the current resolvable corpus. INDEX docs also
239
+ // expose their folded bare-directory name (`taste`, not `taste/INDEX`).
240
+ const corpusNames = new Set();
241
+ for (const doc of listAllMemoryDocs(undefined, true)) {
242
+ const canonical = displayName(doc.name);
243
+ if (canonical !== '')
244
+ corpusNames.add(canonical);
245
+ }
140
246
  const lintDir = (dir) => {
141
247
  if (!dir || !pathExists(dir))
142
248
  return;
143
249
  for (const file of walkFiles(dir, (n) => n.endsWith('.md'), (d) => d.startsWith('.'))) {
144
- if (!relative(dir, file).split(sep).join('/'))
250
+ const relPath = relative(dir, file).split(sep).join('/');
251
+ if (!relPath)
145
252
  continue;
146
253
  memoryCount += 1;
147
- lintFile(file, basename(file) !== 'MEMORY.md', findings);
254
+ const fallbackName = normalizeDocName(relPath.replace(/\.md$/, ''));
255
+ lintFile(file, basename(file) !== 'MEMORY.md', findings, corpusNames, fallbackName);
148
256
  }
149
257
  };
150
258
  for (const root of projectScopeRoots()) {
@@ -161,32 +269,66 @@ export const lintLeaf = defineLeaf({
161
269
  lintDir(pluginMemoryDir(plugin));
162
270
  }
163
271
  }
164
- // Profile coverage: the selected profile's manifest names the dirs the
165
- // operator manages. Lint the profile's own store, and require every
166
- // managed project dir to carry an always-loaded operating guide
167
- // (.crouter/memory/AGENTS.md) — a managed dir without one boots every
168
- // agent there blind, so its absence is an authoring finding like any
169
- // schema violation. (The managed dirs' stores themselves already lint
170
- // above: projectScopeRoots folds the profile's project pointers in.)
272
+ // Profile coverage: each managed project has one direct root INDEX front
273
+ // door. It enters first-message context through the workspace-open `.`
274
+ // route, so its exact contract is knowledge + system none/file content +
275
+ // applies-to containing `.`.
171
276
  let profileProjects = 0;
277
+ let profileResolved = false;
172
278
  const profileIdOrName = process.env['CRTR_PROFILE_ID'] || getDefaultProfileId(process.cwd());
173
279
  if (profileIdOrName) {
174
280
  try {
175
281
  const { profileId, manifest } = loadProfileManifest(profileIdOrName);
282
+ profileResolved = true;
176
283
  lintDir(profileMemoryDir(profileId));
177
284
  for (const dir of manifest.projects) {
178
285
  profileProjects += 1;
179
- if (!pathExists(join(dir, '.crouter', 'memory', 'AGENTS.md'))) {
286
+ const indexPath = join(dir, '.crouter', 'memory', 'INDEX.md');
287
+ if (!pathExists(indexPath)) {
180
288
  findings.push({
181
289
  path: dir,
182
- error: `profile "${manifest.name}" manages this dir but it has no .crouter/memory/AGENTS.md — author its always-loaded operating guide (name the doc AGENTS, target the dir with --dir; run \`crtr memory write -h\` first)`,
290
+ 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`,
183
291
  });
292
+ continue;
293
+ }
294
+ try {
295
+ const fm = parseFrontmatterGeneric(readText(indexPath)).data;
296
+ const problems = [];
297
+ if (fm?.kind !== 'knowledge')
298
+ problems.push('kind must be `knowledge`');
299
+ if (fm?.['system-prompt-visibility'] !== 'none')
300
+ problems.push('system-prompt-visibility must be `none`');
301
+ if (fm?.['file-read-visibility'] !== 'content')
302
+ problems.push('file-read-visibility must be `content`');
303
+ const applies = fm?.['applies-to'];
304
+ const globs = typeof applies === 'string'
305
+ ? [applies]
306
+ : Array.isArray(applies) && applies.every((value) => typeof value === 'string')
307
+ ? applies
308
+ : [];
309
+ if (!globs.some((glob) => glob.trim() === '.'))
310
+ problems.push('applies-to must include `.`');
311
+ if (problems.length > 0) {
312
+ findings.push({ path: indexPath, error: `invalid project root front door: ${problems.join('; ')}` });
313
+ }
314
+ }
315
+ catch (e) {
316
+ const msg = (e instanceof Error ? e.message : String(e)).split('\n')[0];
317
+ findings.push({ path: indexPath, error: `invalid project root front door YAML: ${msg}` });
184
318
  }
185
319
  }
186
320
  }
187
321
  catch {
188
- // Unresolvable profile (deleted/corrupt manifest): profile coverage
189
- // just doesn't apply — the file corpus above already linted fully.
322
+ // Unresolvable profile: profile coverage does not apply. Treat cwd as
323
+ // unprofiled for the advisory front-door warning below.
324
+ }
325
+ }
326
+ // Without a selected, resolvable profile there is no managed-project
327
+ // finding to carry this signal, so keep a non-fatal cwd adoption warning.
328
+ if (!profileResolved) {
329
+ const workingDirectoryIndex = join(workingDirectory, '.crouter', 'memory', 'INDEX.md');
330
+ if (!pathExists(workingDirectoryIndex)) {
331
+ 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`);
190
332
  }
191
333
  }
192
334
  const checked = memoryCount;
@@ -203,7 +345,7 @@ export const lintLeaf = defineLeaf({
203
345
  checked,
204
346
  findings: findings.map((f) => ({ path: f.path, error: f.error })),
205
347
  warnings,
206
- next: 'Fix each doc (quote YAML values containing `: `; use a valid kind/rung/gate); for a profile-managed dir missing AGENTS.md, author one — run `crtr memory write -h` — then re-run `crtr memory lint`.',
348
+ 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 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`.',
207
349
  });
208
350
  }
209
351
  return {
@@ -2,8 +2,10 @@ import { defineLeaf } from '../../core/command.js';
2
2
  import { CrtrError, notFound } from '../../core/errors.js';
3
3
  import { resolveMemoryDoc } from '../../core/memory-resolver.js';
4
4
  import { effectiveDocKind } from '../../core/substrate/schema.js';
5
+ import { displayName } from '../../core/substrate/ceiling.js';
5
6
  import { interpolateNodePaths } from '../../core/canvas/paths.js';
6
7
  import { readText } from '../../core/fs-utils.js';
8
+ import { docLinkNames } from '../../core/memory/doc-link-grammar.js';
7
9
  import { MEMORY_KINDS } from './shared.js';
8
10
  export const readLeaf = defineLeaf({
9
11
  name: 'read',
@@ -23,6 +25,7 @@ export const readLeaf = defineLeaf({
23
25
  { name: 'scope', type: 'string', required: true, constraint: 'Scope the document was resolved from: node, project, profile, user, or builtin.' },
24
26
  { name: 'path', type: 'string', required: true, constraint: 'Absolute path to the document on disk — direct edits here are for a small body-only tweak; any routing/frontmatter/visibility change goes through `crtr memory write`.' },
25
27
  { name: 'content', type: 'string', required: true, constraint: 'Document body. Frontmatter stripped unless --frontmatter is set.' },
28
+ { 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>`. Omitted when the body carries no resolvable links.' },
26
29
  { name: 'follow_up', type: 'string', required: true, constraint: 'Hints at variant flags or next commands.' },
27
30
  ],
28
31
  outputKind: 'object',
@@ -55,13 +58,31 @@ export const readLeaf = defineLeaf({
55
58
  // reading generically (env unset) keeps the token literal.
56
59
  const nodeId = process.env['CRTR_NODE_ID'];
57
60
  const content = nodeId ? interpolateNodePaths(raw, nodeId) : raw;
61
+ // `[[name]]` doc links are pointers, never transclusion: surface which
62
+ // linked names actually resolve so the reader can follow one when the
63
+ // task needs that depth, without ever auto-loading a linked body.
64
+ const links = docLinkNames(doc.body).filter((linkName) => {
65
+ try {
66
+ // `resolveMemoryDoc` permits leaf-name fallback for interactive reads;
67
+ // a stored graph edge does not. Compare the resolved canonical name
68
+ // so an old shorthand never masquerades as a first-class doc link.
69
+ return displayName(resolveMemoryDoc(linkName).name) === linkName;
70
+ }
71
+ catch {
72
+ return false;
73
+ }
74
+ });
58
75
  return {
59
76
  name: doc.name,
60
77
  kind,
61
78
  scope: doc.scope,
62
79
  path: doc.path,
63
80
  content,
64
- follow_up: 'Use --frontmatter on this same command to inspect the YAML frontmatter, or edit `path` directly for a body-only tweak. Browse the inventory with `crtr memory list`.',
81
+ ...(links.length > 0 ? { links } : {}),
82
+ follow_up: (links.length > 0
83
+ ? 'The `[[name]]` links in the body are further reading — follow one with `crtr memory read <name>` only when the task needs that depth. '
84
+ : '') +
85
+ 'Use --frontmatter on this same command to inspect the YAML frontmatter, or edit `path` directly for a body-only tweak. Browse the inventory with `crtr memory list`.',
65
86
  };
66
87
  }
67
88
  throw notFound(`memory document not found: ${nameRaw}`, {