@crouton-kit/crouter 0.3.48 → 0.3.50

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 (50) hide show
  1. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +1 -1
  2. package/dist/clients/attach/attach-cmd.js +825 -863
  3. package/dist/clients/attach/titled-editor.js +6 -3
  4. package/dist/commands/human/prompts.js +1 -1
  5. package/dist/commands/human/shared.js +7 -3
  6. package/dist/commands/memory/write.js +2 -0
  7. package/dist/commands/node.js +96 -11
  8. package/dist/commands/profile/default.d.ts +2 -0
  9. package/dist/commands/profile/default.js +143 -0
  10. package/dist/commands/profile/new.js +3 -3
  11. package/dist/commands/profile/project.d.ts +2 -0
  12. package/dist/commands/profile/project.js +97 -0
  13. package/dist/commands/profile/show.js +1 -1
  14. package/dist/commands/profile.js +10 -11
  15. package/dist/commands/sys/__tests__/sync-import.test.js +156 -3
  16. package/dist/commands/sys/sync.js +82 -25
  17. package/dist/core/__tests__/broker-sdk-wiring.test.js +4 -7
  18. package/dist/core/__tests__/context-intro.test.js +15 -7
  19. package/dist/core/__tests__/memory-resolver-precedence.test.d.ts +1 -0
  20. package/dist/core/__tests__/memory-resolver-precedence.test.js +144 -0
  21. package/dist/core/__tests__/on-read-identity.test.d.ts +1 -0
  22. package/dist/core/__tests__/on-read-identity.test.js +68 -0
  23. package/dist/core/fs-utils.d.ts +1 -1
  24. package/dist/core/fs-utils.js +5 -3
  25. package/dist/core/memory-resolver.d.ts +27 -11
  26. package/dist/core/memory-resolver.js +105 -109
  27. package/dist/core/profiles/default-binding.d.ts +10 -0
  28. package/dist/core/profiles/default-binding.js +50 -0
  29. package/dist/core/profiles/select.d.ts +13 -1
  30. package/dist/core/profiles/select.js +92 -26
  31. package/dist/core/runtime/bearings.d.ts +4 -9
  32. package/dist/core/runtime/bearings.js +10 -17
  33. package/dist/core/runtime/front-door.js +11 -4
  34. package/dist/core/runtime/revive.js +2 -1
  35. package/dist/core/runtime/spawn.d.ts +4 -0
  36. package/dist/core/runtime/spawn.js +1 -1
  37. package/dist/core/substrate/index.d.ts +1 -1
  38. package/dist/core/substrate/index.js +3 -1
  39. package/dist/core/substrate/on-read.js +14 -9
  40. package/dist/core/substrate/render.js +7 -3
  41. package/dist/core/substrate/schema.d.ts +8 -2
  42. package/dist/core/substrate/schema.js +19 -2
  43. package/dist/daemon/crtrd.js +44 -2
  44. package/dist/daemon/manage.d.ts +20 -0
  45. package/dist/daemon/manage.js +64 -2
  46. package/package.json +3 -3
  47. package/dist/commands/profile/add-project.d.ts +0 -1
  48. package/dist/commands/profile/add-project.js +0 -42
  49. package/dist/commands/profile/remove-project.d.ts +0 -1
  50. package/dist/commands/profile/remove-project.js +0 -42
@@ -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,
@@ -0,0 +1,10 @@
1
+ /** Resolve cwd to the same absolute, realpath'd form the profile selector and
2
+ * manifest use, so the mangled-cwd workspace key lines up with the realpath'd
3
+ * project dirs. Falls back to the plain resolved path when the dir can't be
4
+ * stat'd. This is the single resolver every entry point (selector + the
5
+ * `profile default` commands) routes through, so a pin set from one is found
6
+ * by the other. */
7
+ export declare function resolveBindingCwd(cwd: string): string;
8
+ export declare function getDefaultProfileId(cwd: string): string | null;
9
+ export declare function setDefaultProfileId(cwd: string, profileId: string): void;
10
+ export declare function clearDefaultProfile(cwd: string): void;
@@ -0,0 +1,50 @@
1
+ // A per-cwd "default profile" pin — a small binding stored in the per-cwd
2
+ // workspace root (`~/.crouter/workspaces/<mangled-cwd>/default-profile.json`)
3
+ // that says "when a node boots at THIS directory, prefer THIS profile". It is
4
+ // deliberately NOT manifest state: a profile's `projects` list is what it can
5
+ // see (coverage/purview), whereas this pin is which of several covering
6
+ // profiles the user chose to default to here. The two are independent — you
7
+ // can pin a profile that only covers cwd from a parent dir without widening
8
+ // its purview, and widening purview never changes the pin.
9
+ //
10
+ // Consulted by `selectProfileForCwd` (it wins over global-MRU and stops the
11
+ // prompt) and written from the startup menu (`d`) or `crtr profile default`.
12
+ // A stale pin (its profile deleted, or no longer covering cwd) is self-healed
13
+ // away by the selector rather than trusted.
14
+ import { existsSync, realpathSync } from 'node:fs';
15
+ import { join, resolve as resolvePath } from 'node:path';
16
+ import { workspaceRoot } from '../artifact.js';
17
+ import { readJsonIfExists, writeJson, ensureDir, removePath } from '../fs-utils.js';
18
+ /** Resolve cwd to the same absolute, realpath'd form the profile selector and
19
+ * manifest use, so the mangled-cwd workspace key lines up with the realpath'd
20
+ * project dirs. Falls back to the plain resolved path when the dir can't be
21
+ * stat'd. This is the single resolver every entry point (selector + the
22
+ * `profile default` commands) routes through, so a pin set from one is found
23
+ * by the other. */
24
+ export function resolveBindingCwd(cwd) {
25
+ const abs = resolvePath(cwd);
26
+ if (!existsSync(abs))
27
+ return abs;
28
+ try {
29
+ return realpathSync(abs);
30
+ }
31
+ catch {
32
+ return abs;
33
+ }
34
+ }
35
+ function bindingPath(cwd) {
36
+ return join(workspaceRoot(resolveBindingCwd(cwd)), 'default-profile.json');
37
+ }
38
+ export function getDefaultProfileId(cwd) {
39
+ const binding = readJsonIfExists(bindingPath(cwd));
40
+ const id = binding?.profileId;
41
+ return typeof id === 'string' && id !== '' ? id : null;
42
+ }
43
+ export function setDefaultProfileId(cwd, profileId) {
44
+ const resolved = resolveBindingCwd(cwd);
45
+ ensureDir(workspaceRoot(resolved));
46
+ writeJson(join(workspaceRoot(resolved), 'default-profile.json'), { profileId });
47
+ }
48
+ export function clearDefaultProfile(cwd) {
49
+ removePath(bindingPath(cwd));
50
+ }
@@ -1,9 +1,21 @@
1
+ import { type ProfileEntry } from './manifest.js';
2
+ /** Whether a profile's purview covers `cwd` (cwd at/under one of its project
3
+ * dirs, or a project dir under cwd — the workspace-root case). Exported for
4
+ * `crtr profile default set`, which rejects pinning a profile that doesn't
5
+ * cover cwd (the selector would ignore/self-heal such a pin anyway). */
6
+ export declare function profileCoversCwd(entry: ProfileEntry, cwd: string): boolean;
1
7
  /** Select the profile a node about to boot at `cwd` should run under.
2
8
  *
3
9
  * 1. `explicitProfile` present → resolve id/name via `loadProfileManifest`. If
4
10
  * its manifest does not already cover `cwd` and the session is interactive,
5
11
  * offer to add `cwd` to its purview (default yes). Bump `last_used_at`,
6
12
  * return the id.
13
+ * 1b. Else, a per-cwd PINNED default (menu `d`) that still covers `cwd` is
14
+ * auto-picked outright — interactive and headless alike, no prompt (that is
15
+ * what "default" means). Interactive prints a breadcrumb naming it and how
16
+ * to change it. `forcePicker` (`crtr --pick-profile`) bypasses the pin to
17
+ * re-open the menu. A stale pin (profile gone / no longer covering) is
18
+ * cleared and ignored.
7
19
  * 2. Else, if EXACTLY ONE profile's project dir IS `cwd` (you're at a project
8
20
  * root, unambiguously one owner), auto-pick it with no prompt — the
9
21
  * strongest signal. If SEVERAL profiles claim `cwd` exactly, it's genuinely
@@ -18,4 +30,4 @@
18
30
  * create a profile here or proceed as root (root lives ONLY here). Non-
19
31
  * interactive (no TTY): default to root (null) and print the recovery
20
32
  * instruction to STDERR — never stdout, which the caller may be piping. */
21
- export declare function selectProfileForCwd(cwd: string, explicitProfile?: string | null): Promise<string | null>;
33
+ export declare function selectProfileForCwd(cwd: string, explicitProfile?: string | null, forcePicker?: boolean): Promise<string | null>;