@crouton-kit/crouter 0.3.49 → 0.3.51

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.
@@ -0,0 +1,68 @@
1
+ // The on-read POSITIONAL loader resolves a doc's identity with the SAME
2
+ // explicit-name-then-path-fallback rule the boot resolver uses (schema.ts's
3
+ // resolveDocName), never deriving `name` from the physical path alone.
4
+ //
5
+ // A migrated AGENTS.md doc (`crtr sys sync`'s CLAUDE.md/AGENTS.md conversion)
6
+ // is written at `file-read-visibility: content` with an explicit frontmatter
7
+ // `name` (the project label, e.g. `crouter`) so sibling project roots do not
8
+ // collapse onto the shared `AGENTS` leaf. `loadPositionalDoc` must inject the
9
+ // doc under that explicit name, not the path-derived `AGENTS`.
10
+ //
11
+ // Run: node --import tsx/esm --test src/core/__tests__/on-read-identity.test.ts
12
+ import { test, before, beforeEach, after } from 'node:test';
13
+ import assert from 'node:assert/strict';
14
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
15
+ import { tmpdir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { closeDb } from '../canvas/db.js';
18
+ import { resetScopeCache } from '../scope.js';
19
+ import { spawnNode } from '../runtime/nodes.js';
20
+ import { renderOnReadDocs } from '../substrate/on-read.js';
21
+ let home;
22
+ let work;
23
+ let prevHomeEnv;
24
+ before(() => {
25
+ home = mkdtempSync(join(tmpdir(), 'crtr-onread-identity-home-'));
26
+ process.env['CRTR_HOME'] = home;
27
+ prevHomeEnv = process.env['HOME'];
28
+ });
29
+ beforeEach(() => {
30
+ closeDb();
31
+ resetScopeCache();
32
+ rmSync(home, { recursive: true, force: true });
33
+ if (prevHomeEnv === undefined)
34
+ delete process.env['HOME'];
35
+ else
36
+ process.env['HOME'] = prevHomeEnv;
37
+ work = mkdtempSync(join(tmpdir(), 'crtr-onread-identity-work-'));
38
+ });
39
+ after(() => {
40
+ closeDb();
41
+ resetScopeCache();
42
+ rmSync(home, { recursive: true, force: true });
43
+ rmSync(work, { recursive: true, force: true });
44
+ delete process.env['CRTR_HOME'];
45
+ if (prevHomeEnv === undefined)
46
+ delete process.env['HOME'];
47
+ else
48
+ process.env['HOME'] = prevHomeEnv;
49
+ });
50
+ test('a migrated AGENTS doc injects on-read under its explicit frontmatter name, not the path-derived one', () => {
51
+ const node = spawnNode({ kind: 'general', cwd: work, parent: null }).node_id;
52
+ // Shape of a `crtr sys sync` CLAUDE.md/AGENTS.md migration: physical filename
53
+ // stays AGENTS.md, but frontmatter carries the project's explicit identity.
54
+ const memDir = join(work, '.crouter', 'memory');
55
+ mkdirSync(memDir, { recursive: true });
56
+ writeFileSync(join(memDir, 'AGENTS.md'), '---\nkind: knowledge\n' +
57
+ 'name: acme-project\n' +
58
+ 'when-and-why-to-read: When working in acme-project, this knowledge should be read because it is the project\'s operating guide.\n' +
59
+ 'file-read-visibility: content\n---\n' +
60
+ 'ACME PROJECT OPERATING GUIDE\n');
61
+ const readFile = join(work, 'src', 'file.ts');
62
+ mkdirSync(join(work, 'src'), { recursive: true });
63
+ writeFileSync(readFile, 'export const x = 1;\n');
64
+ const rendered = renderOnReadDocs(node, readFile, new Set());
65
+ assert.ok(rendered.includes('ACME PROJECT OPERATING GUIDE'), 'the migrated doc fires positionally');
66
+ assert.ok(rendered.includes('<memory kind="knowledge" name="acme-project"'), `expected explicit frontmatter name "acme-project" in the injected envelope, got: ${rendered}`);
67
+ assert.ok(!rendered.includes('name="AGENTS"'), 'must not fall back to the path-derived name when frontmatter sets one');
68
+ });
@@ -15,5 +15,5 @@ export declare function linkOrCopy(target: string, linkPath: string, opts?: {
15
15
  noSymlink?: boolean;
16
16
  }): 'symlink' | 'copy';
17
17
  export declare function readSymlinkTarget(path: string): string | null;
18
- export declare function walkFiles(root: string, predicate?: (name: string) => boolean): string[];
18
+ export declare function walkFiles(root: string, predicate?: (name: string) => boolean, skipDir?: (name: string) => boolean): string[];
19
19
  export declare function nowIso(): string;
@@ -90,7 +90,7 @@ export function readSymlinkTarget(path) {
90
90
  return null;
91
91
  }
92
92
  }
93
- export function walkFiles(root, predicate = () => true) {
93
+ export function walkFiles(root, predicate = () => true, skipDir = () => false) {
94
94
  const out = [];
95
95
  if (!existsSync(root))
96
96
  return out;
@@ -106,8 +106,10 @@ export function walkFiles(root, predicate = () => true) {
106
106
  }
107
107
  for (const e of entries) {
108
108
  const full = join(dir, e.name);
109
- if (e.isDirectory())
110
- stack.push(full);
109
+ if (e.isDirectory()) {
110
+ if (!skipDir(e.name))
111
+ stack.push(full);
112
+ }
111
113
  else if (e.isFile() && predicate(e.name))
112
114
  out.push(full);
113
115
  }
@@ -1,10 +1,17 @@
1
1
  import type { InstalledPlugin, Scope } from '../types.js';
2
2
  /**
3
- * Thin memory-document resolver for the document substrate. Resolution is scope
4
- * + leaf/path only: qualifier parse scope precedence direct path lookup →
5
- * leaf-name fallback. The memory scopes resolve in precedence order project
6
- * stack > profile > user > builtin. It returns the raw parsed frontmatter +
7
- * body; it does NOT interpret the schema, kind, gate, or rungs that is the
3
+ * Thin memory-document resolver for the document substrate. Precedence is the
4
+ * OUTER loop, not a global scan: scopes/sources are tried NEAREST-FIRST
5
+ * (project stack nearest > ... > profile > user > builtin), and WITHIN each
6
+ * source, in order: (1) exact substrate-identity match (`doc.name === query`
7
+ * the explicit frontmatter `name`, or its path-derived fallback), (2) direct
8
+ * `memory/<name>.md` physical-path lookup (plus the bare-dir/bare-plugin-name
9
+ * → `INDEX.md` convenience neither identity nor a literal path expresses),
10
+ * (3) bare leaf-name fallback (final path segment only). The first hit wins,
11
+ * so a nearer source's match of ANY kind always beats a farther source's
12
+ * match of any kind — a farther-scope identity can never shadow a
13
+ * nearer-scope physical file. It returns the raw parsed frontmatter + body;
14
+ * it does NOT interpret the schema, kind, gate, or rungs — that is the
8
15
  * schema/gate layer's job (callers filter by `frontmatter.kind`). Project
9
16
  * resolution is a nearest-first stack of every ancestor `.crouter/` (widened by
10
17
  * a selected profile's `projects` manifest entries — see `findProjectScopeRoots`
@@ -12,8 +19,9 @@ import type { InstalledPlugin, Scope } from '../types.js';
12
19
  * (from `CRTR_PROFILE_ID`), and user/builtin remain singleton scopes.
13
20
  */
14
21
  export interface MemoryDoc {
15
- /** Path-derived identity: the doc's path under the scope's memory/ root, no
16
- * extension, slash-separated e.g. memory/taste/foo.md "taste/foo". */
22
+ /** Resolver identity: the doc's explicit frontmatter `name` when present,
23
+ * otherwise its normalized path under the scope's memory/ root — e.g.
24
+ * memory/taste/foo.md → "taste/foo". */
17
25
  name: string;
18
26
  scope: MemoryScope;
19
27
  /** Absolute path to the resolved .md file. */
@@ -59,15 +67,23 @@ export declare function listPluginMemoryDocs(plugin: InstalledPlugin, scope: Mem
59
67
  * caller's first-wins dedup. */
60
68
  export declare function listAllMemoryDocs(scope?: MemoryScope, quiet?: boolean): MemoryDoc[];
61
69
  /**
62
- * Resolve a path-derived name to a single memory document.
70
+ * Resolve a memory document name to a single memory document.
63
71
  *
64
72
  * Accepted identifier forms:
65
73
  * <name> — bare name; resolved by scope precedence project>user>builtin
66
74
  * <scope>/<name> — pinned to one scope (user|project)
67
75
  * `<name>` may carry topical subdirs (`taste/foo`); a bare leaf (`foo`) falls
68
- * back to a last-segment match across the resolved scopes.
76
+ * back to a last-segment match within the resolved scopes.
69
77
  *
70
- * Resolution order: parse qualifier direct memory/<name>.md lookup
71
- * (precedence-first) leaf-name fallback (ambiguity error listing candidates).
78
+ * Precedence is the outer loop: sources are visited nearest-first, and the
79
+ * FIRST source with any hit wins outright — a farther source is never even
80
+ * consulted once a nearer one matches. Within each source, in order: (1)
81
+ * exact substrate-identity match (`doc.name === query` — the explicit
82
+ * frontmatter `name`, else its normalized path-derived fallback, the SAME
83
+ * identity `listAllMemoryDocs` reports); (2) direct `memory/<name>.md`
84
+ * physical-path lookup (the bare-dir → INDEX.md and bare-plugin-name → plugin
85
+ * root INDEX.md conveniences neither identity nor a literal path expresses);
86
+ * (3) bare leaf-name fallback (final path segment), ambiguous only when two
87
+ * DIFFERENT identities in the SAME source share a leaf.
72
88
  */
73
89
  export declare function resolveMemoryDoc(rawName: string, opts?: MemoryResolutionOpts): MemoryDoc;
@@ -6,7 +6,7 @@ import { listInstalledPlugins, listInstalledPluginsInRoot, parseSkillQualifier }
6
6
  import { ambiguous, notFound, usage } from './errors.js';
7
7
  import { warn } from './output.js';
8
8
  import { pluginMemoryDir, projectScopeRoot, projectScopeRoots, scopeMemoryDir } from './scope.js';
9
- import { normalizeDocName, normalizeNameSegment } from './substrate/schema.js';
9
+ import { normalizeDocName, normalizeNameSegment, resolveDocName } from './substrate/schema.js';
10
10
  import { loadProfileManifest, profileMemoryDir } from './profiles/manifest.js';
11
11
  /** Canonical, unambiguous identifier for a memory document: `<scope>/<name>`. */
12
12
  export function memoryDocId(doc) {
@@ -75,14 +75,14 @@ function memorySourcesInPrecedence(scope) {
75
75
  }
76
76
  return out;
77
77
  }
78
- function loadMemoryDoc(name, scope, path) {
78
+ function loadMemoryDoc(scope, path, fallbackName) {
79
79
  const { data, body } = parseFrontmatterGeneric(readText(path));
80
- return { name, scope, path, frontmatter: data, body };
80
+ return { name: resolveDocName(data, fallbackName), scope, path, frontmatter: data, body };
81
81
  }
82
82
  /** All memory docs in one memory/ dir, scanned recursively for *.md (topical
83
- * subdirs supported), sorted by path-derived name. SKILL.md bundles are legacy
84
- * Agent Skills and are ignored; crouter memory docs are plain .md files under
85
- * memory/. */
83
+ * subdirs supported), sorted by resolver identity (explicit frontmatter name
84
+ * when present, else path-derived). SKILL.md bundles are legacy Agent Skills
85
+ * and are ignored; crouter memory docs are plain .md files under memory/. */
86
86
  function listMemoryDocsInDir(scope, dir, quiet = false) {
87
87
  if (!dir || !pathExists(dir))
88
88
  return [];
@@ -117,7 +117,7 @@ function listMemoryDocsInDir(scope, dir, quiet = false) {
117
117
  // `quiet` suppresses the notice for a targeted resolve (a leaf-name read),
118
118
  // where another doc's health is irrelevant noise before the result.
119
119
  try {
120
- docs.push(loadMemoryDoc(name, scope, path));
120
+ docs.push(loadMemoryDoc(scope, path, name));
121
121
  }
122
122
  catch (e) {
123
123
  const msg = (e instanceof Error ? e.message : String(e)).split('\n')[0];
@@ -164,7 +164,7 @@ export function listPluginMemoryDocs(plugin, scope, quiet = false) {
164
164
  continue;
165
165
  const name = `${plugin.name}/${derived}`;
166
166
  try {
167
- docs.push({ ...loadMemoryDoc(name, scope, file), plugin: plugin.name });
167
+ docs.push({ ...loadMemoryDoc(scope, file, name), plugin: plugin.name });
168
168
  }
169
169
  catch (e) {
170
170
  const msg = (e instanceof Error ? e.message : String(e)).split('\n')[0];
@@ -174,16 +174,23 @@ export function listPluginMemoryDocs(plugin, scope, quiet = false) {
174
174
  }
175
175
  return docs.sort((a, b) => a.name.localeCompare(b.name));
176
176
  }
177
+ /** All docs belonging to a single resolved source: native docs first, then
178
+ * enabled-plugin docs — native wins on a first-wins dedup within the source.
179
+ * Shared by `listAllMemoryDocs` (flattens every source) and `resolveMemoryDoc`
180
+ * (consults one source's docs at a time, nearest source first). */
181
+ function sourceMemoryDocs(source, quiet = false) {
182
+ return [
183
+ ...listMemoryDocsInDir(source.scope, source.memoryDir, quiet),
184
+ ...source.plugins.flatMap((p) => listPluginMemoryDocs(p, source.scope, quiet)),
185
+ ];
186
+ }
177
187
  /** All memory docs across the resolved sources, in precedence order: each
178
188
  * ancestor project `.crouter/` from nearest to farthest, then the selected
179
189
  * profile's memory (if any), then user, then builtin. Within each source,
180
190
  * native docs are emitted before enabled-plugin docs, so native wins on the
181
191
  * caller's first-wins dedup. */
182
192
  export function listAllMemoryDocs(scope, quiet = false) {
183
- return memorySourcesInPrecedence(scope).flatMap((source) => [
184
- ...listMemoryDocsInDir(source.scope, source.memoryDir, quiet),
185
- ...source.plugins.flatMap((p) => listPluginMemoryDocs(p, source.scope, quiet)),
186
- ]);
193
+ return memorySourcesInPrecedence(scope).flatMap((source) => sourceMemoryDocs(source, quiet));
187
194
  }
188
195
  /** Find the direct child of `dir` — a `.md` file (matched on name minus
189
196
  * extension) or a directory — whose NORMALIZED display name equals
@@ -246,9 +253,10 @@ function resolveNormalizedPath(baseDir, segments) {
246
253
  dirPath: matchNormalizedChild(curDir, last, 'dir'),
247
254
  };
248
255
  }
249
- /** Direct full-path lookup of memory/<name>.md across scopes, precedence-first.
250
- * Returns every scope's hit in precedence order; the resolver takes the first
251
- * (highest-precedence) one a fully-specified name is never ambiguous.
256
+ /** Direct full-path lookup of memory/<name>.md within ONE source. Returns that
257
+ * source's single hit, or undefined a source can produce at most one direct
258
+ * match (native wins over plugin within the source, and at most one plugin's
259
+ * name can match).
252
260
  *
253
261
  * A directory INDEX is the cleaner contract: when `<name>.md` is absent but
254
262
  * `<name>/INDEX.md` exists, the bare dir name (`taste`) resolves to the dir's
@@ -259,94 +267,72 @@ function resolveNormalizedPath(baseDir, segments) {
259
267
  * never authored with a numeric prefix); resolution against the physical tree
260
268
  * is prefix-blind via `resolveNormalizedPath` so a normalized name finds an
261
269
  * `NN-`-pinned physical file/dir. */
262
- function findMemoryMatches(name, scope) {
263
- const matches = [];
264
- const segments = name.split('/');
270
+ function findMemoryMatchInSource(name, segments, source) {
265
271
  const isLegacySkillDoc = segments.at(-1) === 'SKILL';
266
- for (const source of memorySourcesInPrecedence(scope)) {
267
- // Native memory dir first inside this source (native-before-plugin
268
- // precedence), then its enabled plugins. The next source is the next
269
- // ancestor `.crouter/`, then user, then builtin.
270
- const dir = source.memoryDir;
271
- if (dir) {
272
- const { filePath, dirPath } = resolveNormalizedPath(dir, segments);
273
- if (!isLegacySkillDoc && filePath !== null) {
274
- matches.push(loadMemoryDoc(name, source.scope, filePath));
275
- continue;
276
- }
277
- if (dirPath !== null) {
278
- const indexPath = join(dirPath, 'INDEX.md');
279
- if (pathExists(indexPath)) {
280
- matches.push(loadMemoryDoc(name, source.scope, indexPath));
281
- continue;
282
- }
283
- }
284
- }
285
- // Plugin memory dir: a `<plugin>/<rest>` name resolves against that enabled
286
- // plugin's memory/ tree (the `<pluginName>/` mount that listAllMemoryDocs
287
- // enumerates — `read` must resolve what `list` shows). A BARE plugin name
288
- // (no slash) resolves the plugin-root INDEX.md, mirroring the native
289
- // bare-dir-name -> INDEX.md contract for the plugin mount root.
290
- const slash = name.indexOf('/');
291
- const pluginName = slash > 0 ? name.slice(0, slash) : name;
292
- const rest = slash > 0 ? name.slice(slash + 1) : '';
293
- for (const p of source.plugins) {
294
- if (p.name !== pluginName)
295
- continue;
296
- const pdir = pluginMemoryDir(p);
297
- const restSegments = rest ? rest.split('/') : [];
298
- if (rest) {
299
- const { filePath } = resolveNormalizedPath(pdir, restSegments);
300
- if (restSegments.at(-1) !== 'SKILL' && filePath !== null) {
301
- matches.push(loadMemoryDoc(name, source.scope, filePath));
302
- break;
303
- }
304
- }
305
- // Bare name -> <plugin>/memory/INDEX.md; slashed name -> dir INDEX.
306
- const pIndexDir = rest ? resolveNormalizedPath(pdir, restSegments).dirPath : pdir;
307
- if (pIndexDir !== null) {
308
- const pindex = join(pIndexDir, 'INDEX.md');
309
- if (pathExists(pindex)) {
310
- matches.push(loadMemoryDoc(name, source.scope, pindex));
311
- break;
312
- }
313
- }
272
+ // Native memory dir first inside this source (native-before-plugin
273
+ // precedence), then its enabled plugins.
274
+ const dir = source.memoryDir;
275
+ if (dir) {
276
+ const { filePath, dirPath } = resolveNormalizedPath(dir, segments);
277
+ if (!isLegacySkillDoc && filePath !== null)
278
+ return loadMemoryDoc(source.scope, filePath, name);
279
+ if (dirPath !== null) {
280
+ const indexPath = join(dirPath, 'INDEX.md');
281
+ if (pathExists(indexPath))
282
+ return loadMemoryDoc(source.scope, indexPath, name);
314
283
  }
315
284
  }
316
- return matches;
317
- }
318
- /** Leaf-name fallback: match docs whose final path segment equals `leaf`.
319
- * Only meaningful for a bare segment (a slashed query can never equal a single
320
- * segment). Returns matches precedence-ordered. */
321
- function findMemoryByLeaf(leaf, scope) {
322
- if (leaf.includes('/'))
323
- return [];
324
- let all;
325
- try {
326
- // Quiet: a targeted read must not spew other docs' frontmatter warnings
327
- // before its own result (esp. a not_found) — corpus health is `lint`'s job.
328
- all = listAllMemoryDocs(scope, true);
329
- }
330
- catch {
331
- return [];
285
+ // Plugin memory dir: a `<plugin>/<rest>` name resolves against that enabled
286
+ // plugin's memory/ tree (the `<pluginName>/` mount that listAllMemoryDocs
287
+ // enumerates `read` must resolve what `list` shows). A BARE plugin name
288
+ // (no slash) resolves the plugin-root INDEX.md, mirroring the native
289
+ // bare-dir-name -> INDEX.md contract for the plugin mount root.
290
+ const slash = name.indexOf('/');
291
+ const pluginName = slash > 0 ? name.slice(0, slash) : name;
292
+ const rest = slash > 0 ? name.slice(slash + 1) : '';
293
+ for (const p of source.plugins) {
294
+ if (p.name !== pluginName)
295
+ continue;
296
+ const pdir = pluginMemoryDir(p);
297
+ const restSegments = rest ? rest.split('/') : [];
298
+ if (rest) {
299
+ const { filePath } = resolveNormalizedPath(pdir, restSegments);
300
+ if (restSegments.at(-1) !== 'SKILL' && filePath !== null)
301
+ return loadMemoryDoc(source.scope, filePath, name);
302
+ }
303
+ // Bare name -> <plugin>/memory/INDEX.md; slashed name -> dir INDEX.
304
+ const pIndexDir = rest ? resolveNormalizedPath(pdir, restSegments).dirPath : pdir;
305
+ if (pIndexDir !== null) {
306
+ const pindex = join(pIndexDir, 'INDEX.md');
307
+ if (pathExists(pindex))
308
+ return loadMemoryDoc(source.scope, pindex, name);
309
+ }
332
310
  }
333
- return all.filter((d) => (d.name.split('/').pop() ?? d.name) === leaf);
311
+ return undefined;
334
312
  }
335
313
  function formatLeafAmbiguous(leaf, matches) {
336
314
  const ids = matches.map(memoryDocId).join(', ');
337
315
  return `ambiguous memory document: ${leaf} matches multiple documents: ${ids}`;
338
316
  }
339
317
  /**
340
- * Resolve a path-derived name to a single memory document.
318
+ * Resolve a memory document name to a single memory document.
341
319
  *
342
320
  * Accepted identifier forms:
343
321
  * <name> — bare name; resolved by scope precedence project>user>builtin
344
322
  * <scope>/<name> — pinned to one scope (user|project)
345
323
  * `<name>` may carry topical subdirs (`taste/foo`); a bare leaf (`foo`) falls
346
- * back to a last-segment match across the resolved scopes.
324
+ * back to a last-segment match within the resolved scopes.
347
325
  *
348
- * Resolution order: parse qualifier direct memory/<name>.md lookup
349
- * (precedence-first) leaf-name fallback (ambiguity error listing candidates).
326
+ * Precedence is the outer loop: sources are visited nearest-first, and the
327
+ * FIRST source with any hit wins outright — a farther source is never even
328
+ * consulted once a nearer one matches. Within each source, in order: (1)
329
+ * exact substrate-identity match (`doc.name === query` — the explicit
330
+ * frontmatter `name`, else its normalized path-derived fallback, the SAME
331
+ * identity `listAllMemoryDocs` reports); (2) direct `memory/<name>.md`
332
+ * physical-path lookup (the bare-dir → INDEX.md and bare-plugin-name → plugin
333
+ * root INDEX.md conveniences neither identity nor a literal path expresses);
334
+ * (3) bare leaf-name fallback (final path segment), ambiguous only when two
335
+ * DIFFERENT identities in the SAME source share a leaf.
350
336
  */
351
337
  export function resolveMemoryDoc(rawName, opts = {}) {
352
338
  const parsed = parseSkillQualifier(rawName);
@@ -357,28 +343,38 @@ export function resolveMemoryDoc(rawName, opts = {}) {
357
343
  const name = parsed.segments.join('/');
358
344
  if (name === '')
359
345
  throw usage(`memory document name required`);
360
- // Direct full-path lookup: a fully-specified name resolves by scope precedence.
361
- const direct = findMemoryMatches(name, effectiveScope);
362
- if (direct.length > 0)
363
- return direct[0];
364
- // Leaf-name fallback: the caller gave only the final segment (e.g. "foo" for
365
- // "taste/foo"). Match by last segment across the resolved scope dirs.
366
- const byLeaf = findMemoryByLeaf(name, effectiveScope);
367
- if (byLeaf.length > 0) {
368
- // Same path-derived name across scopes → precedence wins (return first);
369
- // genuinely different docs sharing a leaf → ambiguous.
370
- const distinctNames = new Set(byLeaf.map((d) => d.name));
371
- if (distinctNames.size === 1)
372
- return byLeaf[0];
373
- throw ambiguous(formatLeafAmbiguous(name, byLeaf), {
374
- memory: name,
375
- candidates: byLeaf.map((d) => ({
376
- id: memoryDocId(d),
377
- scope: d.scope,
378
- path: d.path,
379
- })),
380
- next: 'Multiple documents share this leaf name. Re-run with one of the full names in candidates.',
381
- });
346
+ const segments = name.split('/');
347
+ const isLeafQuery = !name.includes('/');
348
+ for (const source of memorySourcesInPrecedence(effectiveScope)) {
349
+ // Quiet: a targeted read must not spew other docs' frontmatter warnings
350
+ // before its own result (esp. a not_found) corpus health is `lint`'s job.
351
+ const docs = sourceMemoryDocs(source, true);
352
+ const identity = docs.find((d) => d.name === name);
353
+ if (identity)
354
+ return identity;
355
+ const direct = findMemoryMatchInSource(name, segments, source);
356
+ if (direct)
357
+ return direct;
358
+ if (isLeafQuery) {
359
+ const leafMatches = docs.filter((d) => (d.name.split('/').pop() ?? d.name) === name);
360
+ if (leafMatches.length > 0) {
361
+ // Same path-derived name within this source → precedence wins (return
362
+ // first); genuinely different docs sharing a leaf → ambiguous. Either
363
+ // way this source has an answer — a farther source is never checked.
364
+ const distinctNames = new Set(leafMatches.map((d) => d.name));
365
+ if (distinctNames.size === 1)
366
+ return leafMatches[0];
367
+ throw ambiguous(formatLeafAmbiguous(name, leafMatches), {
368
+ memory: name,
369
+ candidates: leafMatches.map((d) => ({
370
+ id: memoryDocId(d),
371
+ scope: d.scope,
372
+ path: d.path,
373
+ })),
374
+ next: 'Multiple documents share this leaf name. Re-run with one of the full names in candidates.',
375
+ });
376
+ }
377
+ }
382
378
  }
383
379
  throw notFound(`memory document not found: ${rawName}`, {
384
380
  memory: name,
@@ -1,13 +1,8 @@
1
1
  import { type Trigger } from '../canvas/index.js';
2
- /** The `<project_context>` block — now just the cwd/git `<environment>`
3
- * snapshot (dir listing + git status/worktrees). This USED to also carry a
4
- * `<project_instructions>` block re-rendering the node's AGENTS.md/CLAUDE.md;
5
- * that machinery is gone (hard cut, 2026-07-06) project guidance now lives
6
- * in the document substrate (`crtr sys sync` migrates each CLAUDE.md/AGENTS.md
7
- * into that dir's own `.crouter/memory/AGENTS.md` at content/content, so it
8
- * surfaces via the `<memory kind="knowledge">` block instead). Always emits
9
- * the wrapper because the environment snapshot is always present. Exported
10
- * for testing. */
2
+ /** The `<project_context>` block — cwd/git `<environment>` snapshot only
3
+ * (dir listing + git status/worktrees). Project guidance lives in the document
4
+ * substrate, so this wrapper stays environment-only. It always emits because
5
+ * the environment snapshot is always present. Exported for testing. */
11
6
  export declare function buildProjectContextBlock(cwd: string): string;
12
7
  /** The context-directory note — names the path INLINE (no XML attribute, so it
13
8
  * never scopes a sibling block) and frames the dir as durable, shared scratch.
@@ -13,15 +13,13 @@
13
13
  // the source's whole conversation and then impersonates it. A non-fork
14
14
  // node's bearings are its FIRST entry, so there is nothing earlier to
15
15
  // disown; it gets only the declarative identity line. The message also
16
- // carries the <project_context> block (buildProjectContextBlock) now just
17
- // the cwd/git <environment> snapshot. Project instructions (what used to be
18
- // a re-rendered AGENTS.md/CLAUDE.md here) come from the document substrate
19
- // instead: `crtr sys sync` migrates each CLAUDE.md/AGENTS.md into that
20
- // dir's own `.crouter/memory/AGENTS.md` at content/content, so it rides the
16
+ // carries the <project_context> block (buildProjectContextBlock), which is
17
+ // now only the cwd/git <environment> snapshot. Project guidance rides the
18
+ // document substrate instead: migrated CLAUDE.md/AGENTS.md docs live in the
19
+ // source dir's own `.crouter/memory/AGENTS.md` and surface through the
21
20
  // <memory kind="knowledge"> block below (renderKnowledgeBlock) via the
22
- // profile-widened boot render, exactly like any other substrate doc — no
23
- // parallel re-implementation of pi's project-context loader lives here
24
- // anymore (see `crtr memory read taste/document-substrate`);
21
+ // profile-widened boot render no parallel project-context loader lives
22
+ // here anymore (see `crtr memory read taste/document-substrate`);
25
23
  // • promote.ts folds orchestratorContextNote() into the promotion guidance
26
24
  // dump, so a node that becomes an orchestrator MID-LIFE gets the
27
25
  // orchestrator framing it never received at spawn — it spawned as a base
@@ -112,15 +110,10 @@ function parseWorktrees(lines) {
112
110
  }
113
111
  return worktrees;
114
112
  }
115
- /** The `<project_context>` block — now just the cwd/git `<environment>`
116
- * snapshot (dir listing + git status/worktrees). This USED to also carry a
117
- * `<project_instructions>` block re-rendering the node's AGENTS.md/CLAUDE.md;
118
- * that machinery is gone (hard cut, 2026-07-06) project guidance now lives
119
- * in the document substrate (`crtr sys sync` migrates each CLAUDE.md/AGENTS.md
120
- * into that dir's own `.crouter/memory/AGENTS.md` at content/content, so it
121
- * surfaces via the `<memory kind="knowledge">` block instead). Always emits
122
- * the wrapper because the environment snapshot is always present. Exported
123
- * for testing. */
113
+ /** The `<project_context>` block — cwd/git `<environment>` snapshot only
114
+ * (dir listing + git status/worktrees). Project guidance lives in the document
115
+ * substrate, so this wrapper stays environment-only. It always emits because
116
+ * the environment snapshot is always present. Exported for testing. */
124
117
  export function buildProjectContextBlock(cwd) {
125
118
  const envLines = directoryListingLines(cwd);
126
119
  const git = gitSnapshot(cwd);
@@ -1,4 +1,4 @@
1
- export { KINDS, isDocKind, RUNGS, rungRank, rungAtLeast, FALLBACK_RUNG, parseSubstrateFrontmatter, parseSubstrateDoc, previewLine, normalizeNameSegment, normalizeDocName, } from './schema.js';
1
+ export { KINDS, isDocKind, RUNGS, rungRank, rungAtLeast, FALLBACK_RUNG, parseSubstrateFrontmatter, parseSubstrateDoc, previewLine, normalizeNameSegment, normalizeDocName, resolveDocName, } from './schema.js';
2
2
  export type { DocKind, Rung, GatePredicate, SubstrateSchema, SubstrateDoc } from './schema.js';
3
3
  export { scopeForCwd, spineDepth, assembleNodeSubject, profileNameFor } from './subject.js';
4
4
  export type { NodeConfigSubject } from './subject.js';
@@ -13,7 +13,9 @@ FALLBACK_RUNG,
13
13
  // parse + render-shared helpers
14
14
  parseSubstrateFrontmatter, parseSubstrateDoc, previewLine,
15
15
  // display-name normalization
16
- normalizeNameSegment, normalizeDocName, } from './schema.js';
16
+ normalizeNameSegment, normalizeDocName,
17
+ // substrate identity (explicit-name-then-path-fallback)
18
+ resolveDocName, } from './schema.js';
17
19
  export { scopeForCwd, spineDepth, assembleNodeSubject, profileNameFor } from './subject.js';
18
20
  export { gatePasses } from './gate.js';
19
21
  export { INDEX_NAME, isIndexName, indexDirOf, displayName, buildCeilingIndex, effectiveRung, } from './ceiling.js';
@@ -10,9 +10,9 @@
10
10
  // ancestor PROJECT `.crouter/memory/` surfaces (a doc surfaces when a file
11
11
  // beside/under its own project scope dir is read), UNLESS it carries an
12
12
  // explicit read-trigger (see D5 below). This is the substrate's own
13
- // ancestor walk keyed on `.crouter/memory/` — the same shape the retired
14
- // nested-context pi-extension used for `.claude/rules` before that path
15
- // was migrated into substrate docs (`crtr sys sync`). The user-global
13
+ // ancestor walk keyed on `.crouter/memory/` — project guidance that starts
14
+ // in CLAUDE.md/AGENTS.md or `.claude/rules` now lives here after
15
+ // `crtr sys sync` migrates it. The user-global
16
16
  // `~/.crouter/memory/` store is NOT positional; user docs only fire on
17
17
  // reads through explicit `applies-to` / `read-when` triggers.
18
18
  // • applies-to GLOB — any RESOLVED substrate doc (user/project/builtin scope)
@@ -58,16 +58,16 @@ import { pathExists, readText, walkFiles } from '../fs-utils.js';
58
58
  import { parseFrontmatterGeneric } from '../frontmatter.js';
59
59
  import { evalCondition } from '../predicate.js';
60
60
  import { listAllMemoryDocs } from '../memory-resolver.js';
61
- import { assembleNodeSubject, buildCeilingIndex, displayName, effectiveRung, gatePasses, normalizeDocName, parseSubstrateDoc, parseSubstrateFrontmatter, previewLine, } from './index.js';
61
+ import { assembleNodeSubject, buildCeilingIndex, displayName, effectiveRung, gatePasses, normalizeDocName, parseSubstrateDoc, parseSubstrateFrontmatter, previewLine, resolveDocName, } from './index.js';
62
62
  import { cachedSubstrateDocs } from './session-cache.js';
63
63
  // Ancestor dirs we never look inside for a `.crouter/memory/` store (the read
64
64
  // file may live under a build/dependency tree; `.crouter` is NOT junk here — it
65
65
  // is the segment we explicitly join onto each surviving ancestor).
66
66
  const JUNK_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.cache', '.yalc']);
67
67
  // ---------------------------------------------------------------------------
68
- // Small path helpers (mirror the on-read precedent frontmatter-rules, and
69
- // the retired nested-context extension — so the injected envelope matches
70
- // their faithful shape).
68
+ // Small path helpers (mirror the on-read precedent established by
69
+ // frontmatter-rules so the injected envelope keeps the faithful substrate
70
+ // shape).
71
71
  // ---------------------------------------------------------------------------
72
72
  function realpathOrSelf(p) {
73
73
  try {
@@ -120,13 +120,18 @@ function loadPositionalDoc(file, memDir, scope) {
120
120
  .replace(/\.md$/i, '')
121
121
  .split(sep)
122
122
  .join('/');
123
- const name = normalizeDocName(raw);
124
- if (name === '')
123
+ const fallbackName = normalizeDocName(raw);
124
+ if (fallbackName === '')
125
125
  return null;
126
126
  const { data, body } = parseFrontmatterGeneric(readText(file));
127
127
  const schema = parseSubstrateFrontmatter(data);
128
128
  if (schema === null)
129
129
  return null;
130
+ // ONE substrate-identity rule (schema.ts's resolveDocName), shared with the
131
+ // resolver's loadMemoryDoc: an explicit frontmatter `name` wins over the
132
+ // path-derived fallback, so a positionally-discovered doc surfaces on-read
133
+ // under the SAME identity the boot resolver would give it.
134
+ const name = resolveDocName(data, fallbackName);
130
135
  return { ...schema, name, scope, path: file, body };
131
136
  }
132
137
  catch {