@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
@@ -8,25 +8,46 @@
8
8
  // Run: node --import tsx/esm --test src/commands/sys/__tests__/sync-import.test.ts
9
9
  import { afterEach, beforeEach, test } from 'node:test';
10
10
  import assert from 'node:assert/strict';
11
- import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
11
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync, realpathSync } from 'node:fs';
12
12
  import { tmpdir } from 'node:os';
13
13
  import { join } from 'node:path';
14
- import { sysSyncLeaf } from '../sync.js';
14
+ import { createNode } from '../../../core/canvas/canvas.js';
15
+ import { closeDb } from '../../../core/canvas/db.js';
16
+ import { resolveMemoryDoc } from '../../../core/memory-resolver.js';
15
17
  import { parseFrontmatterGeneric } from '../../../core/frontmatter.js';
16
18
  import { gitSync } from '../../../core/git.js';
19
+ import { renderKnowledgeBlock } from '../../../core/substrate/render.js';
20
+ import { clearSessionCache } from '../../../core/substrate/session-cache.js';
21
+ import { sysSyncLeaf } from '../sync.js';
17
22
  function git(args, cwd) {
18
23
  const res = gitSync(args, cwd);
19
24
  if (res.status !== 0)
20
25
  throw new Error(`git ${args.join(' ')} failed in ${cwd}: ${res.stderr || res.stdout}`);
21
26
  }
27
+ function node(id, cwd) {
28
+ return {
29
+ node_id: id,
30
+ name: id,
31
+ created: new Date().toISOString(),
32
+ cwd,
33
+ kind: 'general',
34
+ mode: 'base',
35
+ lifecycle: 'terminal',
36
+ status: 'active',
37
+ };
38
+ }
22
39
  let home;
23
40
  let prevHome;
24
41
  beforeEach(() => {
42
+ closeDb();
43
+ clearSessionCache();
25
44
  prevHome = process.env.HOME;
26
45
  home = mkdtempSync(join(tmpdir(), 'crtr-sys-sync-'));
27
46
  process.env.HOME = home;
28
47
  });
29
48
  afterEach(() => {
49
+ closeDb();
50
+ clearSessionCache();
30
51
  if (prevHome === undefined)
31
52
  delete process.env.HOME;
32
53
  else
@@ -49,6 +70,21 @@ test('imports a SKILL.md bundle as a plain crouter memory doc', async () => {
49
70
  assert.equal(parsed.data?.['when-and-why-to-read'], 'When demo work appears, this knowledge should be read because handles demos.');
50
71
  assert.match(parsed.body, /Do the demo\./);
51
72
  });
73
+ test('skips SKILL.md files vendored under node_modules while still importing a top-level authored SKILL.md', async () => {
74
+ const sourceRoot = join(home, 'repo');
75
+ const vendored = join(sourceRoot, 'node_modules', '@tanstack', 'some-pkg');
76
+ mkdirSync(vendored, { recursive: true });
77
+ writeFileSync(join(vendored, 'SKILL.md'), '---\nname: Vendored Skill\ndescription: Should never be imported.\n---\n\n# Vendored\n', 'utf8');
78
+ const authored = join(sourceRoot, 'my-skill');
79
+ mkdirSync(authored, { recursive: true });
80
+ writeFileSync(join(authored, 'SKILL.md'), '---\nname: My Skill\ndescription: Handles the real thing. Use when real work appears.\n---\n\n# Real\n\nDo the real thing.\n', 'utf8');
81
+ const result = await sysSyncLeaf.run({ source: sourceRoot, scope: 'user' });
82
+ assert.equal(result?.imported, 1);
83
+ assert.equal(existsSync(join(home, '.crouter', 'memory', 'vendored-skill.md')), false);
84
+ assert.equal(existsSync(join(home, '.crouter', 'memory', 'my-skill.md')), true);
85
+ const rows = (result?.results).map((r) => r.source);
86
+ assert.ok(!rows.some((s) => s.includes('node_modules')));
87
+ });
52
88
  test('skips generated crtr boot skills instead of importing them', async () => {
53
89
  const bundle = join(home, '.pi', 'agent', 'skills', 'crtr-skills');
54
90
  mkdirSync(bundle, { recursive: true });
@@ -97,9 +133,19 @@ test('migrates CLAUDE.md and a .claude/rules file into that dir\'s own .crouter/
97
133
  assert.equal(existsSync(ruleTarget), true);
98
134
  const agentsDoc = parseFrontmatterGeneric(readFileSync(agentsTarget, 'utf8'));
99
135
  assert.equal(agentsDoc.data?.kind, 'knowledge');
136
+ assert.equal(agentsDoc.data?.name, 'demo-project');
100
137
  assert.equal(agentsDoc.data?.['system-prompt-visibility'], 'content');
101
138
  assert.equal(agentsDoc.data?.['file-read-visibility'], 'content');
102
139
  assert.match(agentsDoc.body, /Build then commit\./);
140
+ const prevCwd = process.cwd();
141
+ process.chdir(source);
142
+ try {
143
+ assert.equal(resolveMemoryDoc('demo-project').path, realpathSync(agentsTarget));
144
+ assert.equal(resolveMemoryDoc('demo-project').name, 'demo-project');
145
+ }
146
+ finally {
147
+ process.chdir(prevCwd);
148
+ }
103
149
  const ruleDoc = parseFrontmatterGeneric(readFileSync(ruleTarget, 'utf8'));
104
150
  assert.equal(ruleDoc.data?.kind, 'knowledge');
105
151
  assert.equal(ruleDoc.data?.['system-prompt-visibility'], 'none');
@@ -148,6 +194,113 @@ test('routes the walk-root AGENTS.md doc by the real git repo name, not a worktr
148
194
  git(['worktree', 'remove', '--force', linkedWorktree], mainRepo);
149
195
  rmSync(base, { recursive: true, force: true });
150
196
  });
197
+ test('sibling purview roots keep distinct AGENTS names so boot render does not dedupe them away', async () => {
198
+ const base = mkdtempSync(join(tmpdir(), 'crtr-sys-sync-purview-'));
199
+ const rootA = join(base, 'alpha-root');
200
+ const rootB = join(base, 'beta-root');
201
+ mkdirSync(rootA, { recursive: true });
202
+ mkdirSync(rootB, { recursive: true });
203
+ writeFileSync(join(rootA, 'CLAUDE.md'), '# Alpha Root\n\nAlpha body.\n', 'utf8');
204
+ writeFileSync(join(rootB, 'CLAUDE.md'), '# Beta Root\n\nBeta body.\n', 'utf8');
205
+ await sysSyncLeaf.run({ source: rootA });
206
+ await sysSyncLeaf.run({ source: rootB });
207
+ const profileId = 'purview-b7a4d1f0';
208
+ const profileDir = join(home, '.crouter', 'profiles', profileId);
209
+ mkdirSync(profileDir, { recursive: true });
210
+ writeFileSync(join(profileDir, 'profile.json'), JSON.stringify({ name: 'purview-pair', projects: [rootA, rootB], created_at: new Date().toISOString() }, null, 2) + '\n', 'utf8');
211
+ const runDir = join(base, 'run');
212
+ mkdirSync(runDir, { recursive: true });
213
+ const prevCwd = process.cwd();
214
+ const prevProfileId = process.env.CRTR_PROFILE_ID;
215
+ try {
216
+ process.env.CRTR_PROFILE_ID = profileId;
217
+ process.chdir(runDir);
218
+ clearSessionCache();
219
+ const alphaDoc = resolveMemoryDoc('alpha-root');
220
+ assert.equal(alphaDoc.path, join(rootA, '.crouter', 'memory', 'AGENTS.md'));
221
+ assert.equal(alphaDoc.name, 'alpha-root');
222
+ const betaDoc = resolveMemoryDoc('beta-root');
223
+ assert.equal(betaDoc.path, join(rootB, '.crouter', 'memory', 'AGENTS.md'));
224
+ assert.equal(betaDoc.name, 'beta-root');
225
+ createNode(node('bootcheck', runDir));
226
+ const block = renderKnowledgeBlock('bootcheck');
227
+ assert.match(block, /Alpha body\./, 'first purview root survives the boot render');
228
+ assert.match(block, /Beta body\./, 'second purview root survives the boot render');
229
+ }
230
+ finally {
231
+ process.chdir(prevCwd);
232
+ if (prevProfileId === undefined)
233
+ delete process.env.CRTR_PROFILE_ID;
234
+ else
235
+ process.env.CRTR_PROFILE_ID = prevProfileId;
236
+ }
237
+ rmSync(base, { recursive: true, force: true });
238
+ });
239
+ test('a nested project-subdir AGENTS/CLAUDE doc migrates to its OWN <nested>/.crouter/memory/AGENTS.md with a deterministic, non-colliding explicit name', async () => {
240
+ const source = mkdtempSync(join(tmpdir(), 'crtr-sys-sync-nested-'));
241
+ writeFileSync(join(source, 'CLAUDE.md'), '# Nested Demo\n\nRoot guidance.\n', 'utf8');
242
+ const nestedDir = join(source, 'packages', 'widgets');
243
+ mkdirSync(nestedDir, { recursive: true });
244
+ writeFileSync(join(nestedDir, 'AGENTS.md'), '# Widgets Package\n\nNested guidance.\n', 'utf8');
245
+ const result = await sysSyncLeaf.run({ source });
246
+ const projectRows = (result?.results).filter((row) => row.target.includes('.crouter/memory'));
247
+ assert.equal(projectRows.length, 2, 'both the root and nested docs are candidates');
248
+ assert.ok(projectRows.every((r) => r.status === 'imported'));
249
+ const rootTarget = join(source, '.crouter', 'memory', 'AGENTS.md');
250
+ const nestedTarget = join(nestedDir, '.crouter', 'memory', 'AGENTS.md');
251
+ assert.equal(existsSync(rootTarget), true, 'root doc writes to the root\'s OWN .crouter/memory/AGENTS.md');
252
+ assert.equal(existsSync(nestedTarget), true, 'nested doc writes to the NESTED dir\'s OWN .crouter/memory/AGENTS.md, not the root store');
253
+ const rootDoc = parseFrontmatterGeneric(readFileSync(rootTarget, 'utf8'));
254
+ const nestedDoc = parseFrontmatterGeneric(readFileSync(nestedTarget, 'utf8'));
255
+ assert.equal(rootDoc.data?.name, 'nested-demo');
256
+ assert.equal(nestedDoc.data?.name, 'nested-demo/packages/widgets');
257
+ assert.notEqual(rootDoc.data?.name, nestedDoc.data?.name, 'root and nested identities never collide');
258
+ assert.match(rootDoc.body, /Root guidance\./);
259
+ assert.match(nestedDoc.body, /Nested guidance\./);
260
+ const prevCwd = process.cwd();
261
+ try {
262
+ process.chdir(nestedDir);
263
+ clearSessionCache();
264
+ const resolved = resolveMemoryDoc('nested-demo/packages/widgets');
265
+ assert.equal(resolved.path, realpathSync(nestedTarget), 'resolveMemoryDoc finds a migrated nested doc by its explicit slashed identity');
266
+ assert.equal(resolved.name, 'nested-demo/packages/widgets');
267
+ assert.match(resolved.body, /Nested guidance\./);
268
+ }
269
+ finally {
270
+ process.chdir(prevCwd);
271
+ }
272
+ rmSync(source, { recursive: true, force: true });
273
+ });
274
+ test('a nested project-subdir doc migrated when --source POINTS AT THE NESTED DIR ITSELF still gets a suffix-qualified, non-colliding name', async () => {
275
+ // A nested doc's name suffix is computed against the git worktree top-level,
276
+ // not the supplied walk root, so `--source <nested-dir>` still yields a
277
+ // qualified, non-colliding name even when the walk starts at the nested dir.
278
+ const base = mkdtempSync(join(tmpdir(), 'crtr-sys-sync-nested-source-'));
279
+ const repoRoot = join(base, 'demo-repo');
280
+ mkdirSync(repoRoot, { recursive: true });
281
+ git(['init', '-b', 'main'], repoRoot);
282
+ git(['config', 'user.email', 'test@example.com'], repoRoot);
283
+ git(['config', 'user.name', 'Test'], repoRoot);
284
+ writeFileSync(join(repoRoot, 'CLAUDE.md'), '# Demo Repo\n\nRoot guidance.\n', 'utf8');
285
+ const nestedDir = join(repoRoot, 'packages', 'widgets');
286
+ mkdirSync(nestedDir, { recursive: true });
287
+ writeFileSync(join(nestedDir, 'AGENTS.md'), '# Widgets Package\n\nNested guidance.\n', 'utf8');
288
+ git(['add', '.'], repoRoot);
289
+ git(['commit', '-m', 'init'], repoRoot);
290
+ // --source pinned directly at the nested dir, with the root project doc left
291
+ // unmigrated, so the nested doc's name must stay qualified on its own.
292
+ const nestedResult = await sysSyncLeaf.run({ source: nestedDir });
293
+ assert.equal(nestedResult?.imported, 1);
294
+ const nestedTarget = join(nestedDir, '.crouter', 'memory', 'AGENTS.md');
295
+ assert.equal(existsSync(nestedTarget), true);
296
+ const nestedDoc = parseFrontmatterGeneric(readFileSync(nestedTarget, 'utf8'));
297
+ assert.equal(nestedDoc.data?.name, 'demo-repo/packages/widgets', 'nested doc name is suffix-qualified even though --source pinned the nested dir directly');
298
+ assert.notEqual(nestedDoc.data?.name, 'demo-repo', 'nested identity never collapses to the bare root project label it would collide with');
299
+ assert.match(nestedDoc.body, /Nested guidance\./);
300
+ const projectRows = (nestedResult?.results).filter((row) => row.target.includes('.crouter/memory'));
301
+ assert.ok(projectRows.some((r) => r.status === 'imported' && r.target === nestedTarget));
302
+ rmSync(base, { recursive: true, force: true });
303
+ });
151
304
  test('falls back to the doc H1 title when the walk root is not inside a git repo', async () => {
152
305
  const source = mkdtempSync(join(tmpdir(), 'crtr-sys-sync-nogit-'));
153
306
  writeFileSync(join(source, 'CLAUDE.md'), '# Standalone Docs Project\n\nDo the thing.\n', 'utf8');
@@ -155,6 +308,6 @@ test('falls back to the doc H1 title when the walk root is not inside a git repo
155
308
  assert.equal(result?.imported, 1);
156
309
  const target = join(source, '.crouter', 'memory', 'AGENTS.md');
157
310
  const doc = parseFrontmatterGeneric(readFileSync(target, 'utf8'));
158
- assert.equal(doc.data?.['when-and-why-to-read'], 'When working in Standalone Docs Project, this knowledge should be read because it is the project\'s operating guide.');
311
+ assert.equal(doc.data?.['when-and-why-to-read'], 'When working in standalone-docs-project, this knowledge should be read because it is the project\'s operating guide.');
159
312
  rmSync(source, { recursive: true, force: true });
160
313
  });
@@ -1,7 +1,7 @@
1
1
  // `crtr sys sync` — explicit migration from legacy host Agent Skill bundles into
2
2
  // crouter memory documents. It is NOT a bidirectional SKILL.md sync surface: the
3
3
  // crouter side after a sync is a plain memory/<name>.md document.
4
- import { existsSync, readdirSync, statSync } from 'node:fs';
4
+ import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs';
5
5
  import { homedir } from 'node:os';
6
6
  import { basename, dirname, join, relative, resolve, sep } from 'node:path';
7
7
  import { defineLeaf } from '../../core/command.js';
@@ -17,8 +17,7 @@ const LEGACY_BOOT_SKILL_MARKER_PREFIX = '<!-- crtr-boot-skill v';
17
17
  const HOST_SKILL_FILE = 'SKILL.md';
18
18
  const IGNORE_FILE = 'skill-import-ignore.json';
19
19
  // Directories a project-doc walk never descends into — build/dependency trees
20
- // plus crouter's own state dir (mirrors nested-context's JUNK_DIRS, the
21
- // now-deleted extension this migration replaces).
20
+ // plus crouter's own state dir.
22
21
  const PROJECT_DOC_JUNK_DIRS = new Set([
23
22
  'node_modules', '.git', '.venv', 'venv', 'dist', 'build', '.next', '.cache', '.yalc', '.sisyphus', CRTR_DIR_NAME,
24
23
  ]);
@@ -67,7 +66,7 @@ function collectCandidates(rootDef) {
67
66
  candidates.push({ path: directSkill, root: dirname(statRoot), source: rootDef.label, defaultScope: rootDef.defaultScope });
68
67
  return candidates;
69
68
  }
70
- for (const file of walkFiles(statRoot, (name) => name === HOST_SKILL_FILE)) {
69
+ for (const file of walkFiles(statRoot, (name) => name === HOST_SKILL_FILE, (name) => PROJECT_DOC_JUNK_DIRS.has(name))) {
71
70
  candidates.push({ path: file, root: statRoot, source: rootDef.label, defaultScope: rootDef.defaultScope });
72
71
  }
73
72
  return candidates;
@@ -163,18 +162,17 @@ function collectProjectDocCandidates(root) {
163
162
  const stack = [rootResolved];
164
163
  while (stack.length > 0) {
165
164
  const dir = stack.pop();
166
- const isWalkRoot = dir === rootResolved;
167
165
  for (const name of PROJECT_DOC_FILENAMES) {
168
166
  const p = join(dir, name);
169
167
  if (existsSync(p) && statSync(p).isFile()) {
170
- out.push({ kind: 'agents', path: p, sourceDir: dir, isWalkRoot });
168
+ out.push({ kind: 'agents', path: p, sourceDir: dir, walkRoot: rootResolved });
171
169
  break;
172
170
  }
173
171
  }
174
172
  const rulesDir = join(dir, '.claude', 'rules');
175
173
  if (existsSync(rulesDir)) {
176
174
  for (const file of walkFiles(rulesDir, (n) => n.endsWith('.md'))) {
177
- out.push({ kind: 'rule', path: file, sourceDir: dir, isWalkRoot });
175
+ out.push({ kind: 'rule', path: file, sourceDir: dir, walkRoot: rootResolved });
178
176
  }
179
177
  }
180
178
  let entries;
@@ -226,6 +224,33 @@ function gitRepoRootName(dir) {
226
224
  return undefined;
227
225
  return basename(dirname(gitCommonDir));
228
226
  }
227
+ /** The current git worktree's OWN top-level directory (`git rev-parse
228
+ * --show-toplevel`) — unlike `--git-common-dir` above, this does NOT resolve
229
+ * through a linked worktree to the shared repo storage; it is the actual
230
+ * checked-out tree containing `dir`. This is the stable root to compute a
231
+ * doc's subdir suffix against, even when the discovery walk itself started
232
+ * strictly inside that tree — e.g. `--source` pointed directly at a nested
233
+ * package dir, so the walk root and the source dir are the same directory
234
+ * and a walk-root-relative suffix would be empty. Returns undefined when
235
+ * `dir` isn't inside a git working tree at all (the non-git fallback keeps
236
+ * using the walk root itself). */
237
+ function gitWorktreeRoot(dir) {
238
+ const res = gitSync(['rev-parse', '--path-format=absolute', '--show-toplevel'], dir);
239
+ if (res.status !== 0)
240
+ return undefined;
241
+ const top = res.stdout.trim();
242
+ return top === '' ? undefined : resolve(top);
243
+ }
244
+ /** `realpathSync`, tolerant of a path that doesn't exist or can't be resolved
245
+ * (falls back to the path as given). */
246
+ function realpathOrSelf(p) {
247
+ try {
248
+ return realpathSync(p);
249
+ }
250
+ catch {
251
+ return p;
252
+ }
253
+ }
229
254
  /** First Markdown H1 heading in the doc body, heading marker stripped. */
230
255
  function h1Title(body) {
231
256
  for (const raw of body.split('\n')) {
@@ -238,35 +263,69 @@ function h1Title(body) {
238
263
  }
239
264
  return undefined;
240
265
  }
241
- /** Routing-text label for the WALK-ROOT project doc: the raw basename of that
242
- * directory is unreliable (a worktree/canvas checkout's basename is a node
243
- * id, not the project name), so prefer the actual git repo's root dirname,
244
- * falling back to the doc's own H1 title, falling back to the raw basename
245
- * only as a last resort. Nested-dir docs don't need this — their own
246
- * subdirectory basename already names them correctly. */
247
- function projectRootLabel(dir, body) {
248
- return gitRepoRootName(dir) ?? h1Title(body) ?? basename(dir) ?? dir;
266
+ /** Namespace label for a walk root: the raw basename of that directory is
267
+ * unreliable (a worktree/canvas checkout's basename is a node id, not the
268
+ * project name), so prefer the actual git repo's root dirname, then the root
269
+ * doc's own H1 title if one exists there, then the raw basename. */
270
+ function projectRootLabel(root) {
271
+ const gitLabel = gitRepoRootName(root);
272
+ if (gitLabel !== undefined)
273
+ return normalizedName(gitLabel);
274
+ for (const name of PROJECT_DOC_FILENAMES) {
275
+ const p = join(root, name);
276
+ if (existsSync(p) && statSync(p).isFile()) {
277
+ const title = h1Title(parseFrontmatterGeneric(readText(p)).body);
278
+ if (title !== undefined)
279
+ return normalizedName(title);
280
+ }
281
+ }
282
+ return normalizedName(basename(root) || root);
283
+ }
284
+ /** Explicit substrate identity for one migrated project doc: the root label
285
+ * from the walk root (repo name/H1/basename), plus any relative subdir under
286
+ * the STABLE SUFFIX ROOT. That suffix root is the git worktree top-level
287
+ * when the source is git-backed, NOT the supplied walk root — so a
288
+ * `--source` pointed directly at a nested subdir (walk root === source dir,
289
+ * a walk-root-relative suffix would be empty) still gets a suffix relative
290
+ * to the real project root and cannot collide with that root's own doc
291
+ * identity. Non-git sources have no stable project root to prefer, so they
292
+ * keep the walk-root fallback unchanged. Root AGENTS.md docs become
293
+ * `crouter`/`hearth`-shaped identities while nested AGENTS.md docs get a
294
+ * deterministic `root/subdir` name — regardless of whether the walk started
295
+ * at that root or at the nested dir itself. */
296
+ function projectDocLabel(candidate) {
297
+ const rootLabel = projectRootLabel(candidate.walkRoot);
298
+ const suffixRoot = gitWorktreeRoot(candidate.walkRoot) ?? candidate.walkRoot;
299
+ // `git rev-parse --show-toplevel` resolves through symlinks (e.g. macOS's
300
+ // /tmp -> /private/tmp); realpath BOTH sides before diffing so a symlinked
301
+ // walk root doesn't produce a bogus `../../..` suffix against the git-
302
+ // resolved top-level.
303
+ const relDir = relative(realpathOrSelf(suffixRoot), realpathOrSelf(candidate.sourceDir))
304
+ .split(sep)
305
+ .join('/')
306
+ .replace(/^\.$/, '');
307
+ return relDir === '' ? rootLabel : normalizedName(`${rootLabel}/${relDir}`);
249
308
  }
250
309
  /** AGENTS.md/CLAUDE.md → `<sourceDir>/.crouter/memory/AGENTS.md` at
251
310
  * content/content (the CTO's ruling, 2026-07-06: an AGENTS.md-style doc is
252
311
  * always-relevant AND wholly-important, so full-body at `content` is
253
- * sanctioned — see `taste/document-substrate`). Doc name is fixed `AGENTS`
254
- * (never `INDEX`, which carries ceiling/subtree-capping semantics here). */
312
+ * sanctioned — see `taste/document-substrate`). The on-disk filename stays
313
+ * `AGENTS.md`, but the substrate identity is the explicit project label so
314
+ * sibling roots do not collapse on a shared `AGENTS` leaf. */
255
315
  function prepareAgentsCandidate(candidate) {
256
316
  const body = parseFrontmatterGeneric(readText(candidate.path)).body;
257
- const dirLabel = candidate.isWalkRoot
258
- ? projectRootLabel(candidate.sourceDir, body)
259
- : basename(candidate.sourceDir) || candidate.sourceDir;
317
+ const docLabel = projectDocLabel(candidate);
260
318
  const frontmatter = {
261
319
  kind: 'knowledge',
262
- 'when-and-why-to-read': `When working in ${dirLabel}, this knowledge should be read because it is the project's operating guide.`,
320
+ name: docLabel,
321
+ 'when-and-why-to-read': `When working in ${docLabel}, this knowledge should be read because it is the project's operating guide.`,
263
322
  'short-form': synthesizeShortForm(body),
264
323
  'system-prompt-visibility': 'content',
265
324
  'file-read-visibility': 'content',
266
325
  };
267
326
  return {
268
327
  candidate,
269
- name: 'AGENTS',
328
+ name: docLabel,
270
329
  target: memoryFilePath(join(candidate.sourceDir, CRTR_DIR_NAME, 'memory'), 'AGENTS'),
271
330
  frontmatter,
272
331
  body,
@@ -282,9 +341,7 @@ function prepareRuleCandidate(candidate) {
282
341
  const fm = parsed.data ?? {};
283
342
  const name = normalizedName(basename(candidate.path));
284
343
  const description = scalarString(fm['description']) ?? '';
285
- const dirLabel = candidate.isWalkRoot
286
- ? projectRootLabel(candidate.sourceDir, parsed.body)
287
- : basename(candidate.sourceDir) || candidate.sourceDir;
344
+ const dirLabel = projectDocLabel(candidate);
288
345
  const frontmatter = {
289
346
  kind: 'knowledge',
290
347
  'when-and-why-to-read': description !== ''
@@ -83,13 +83,10 @@ test('C3b — broker splits model thinking suffix before SDK registry lookup', a
83
83
  rmSync(cwd, { recursive: true, force: true });
84
84
  }
85
85
  });
86
- // (C4 — project AGENTS.md/CLAUDE.md injection was removed: crouter sets
87
- // `noContextFiles: true` in buildBrokerSession unconditionally (broker.ts) and
88
- // no longer re-renders those files into the bearings either (hard cut,
89
- // 2026-07-06) — project guidance now comes from the document substrate
90
- // (`crtr sys sync` migrates CLAUDE.md/AGENTS.md into a memory doc). Covered by
91
- // context-intro.test.ts's "a CLAUDE.md in the node's cwd no longer rides the
92
- // bearings" test.)
86
+ // (C4 — project guidance now comes from the document substrate: crouter keeps
87
+ // `noContextFiles: true` in buildBrokerSession, and buildContextIntro's
88
+ // `<project_context>` block is environment-only. Covered by
89
+ // context-intro.test.ts's environment-only regression.)
93
90
  // ===========================================================================
94
91
  // C2 — zero-viewer dialogs resolve to deny/cancel IMMEDIATELY (noOp), never
95
92
  // hanging the turn and never waiting on a per-dialog timeout. Drives the REAL
@@ -69,13 +69,21 @@ test('worker bearings: base framing + <knowledge> block, NO across-cycles framin
69
69
  assert.doesNotMatch(block, /refresh cycles/, 'no across-cycles framing for a terminal worker');
70
70
  assert.match(block, /<\/crtr-bearings>/);
71
71
  });
72
- test('a CLAUDE.md in the node\'s cwd no longer rides the bearings; only the <environment> snapshot does (hard cut, 2026-07-06)', () => {
73
- // buildProjectContextBlock used to re-render the node's AGENTS.md/CLAUDE.md
74
- // into a <project_instructions> block (pi's own copy stays suppressed via
75
- // broker.ts noContextFiles). That machinery is gone: project guidance now
76
- // comes from the document substrate (a migrated AGENTS.md doc rides the
77
- // <memory kind="knowledge"> block instead — see `crtr sys sync`), so a bare
78
- // on-disk CLAUDE.md is simply never read by crtr at boot any more.
72
+ test('node-local doc identity honors explicit frontmatter `name`, not the physical filename', () => {
73
+ // node-local docs follow the one substrate identity rule (resolveDocName):
74
+ // an explicit `name:` renders and dedups under that name, not the filename.
75
+ const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
76
+ const dir = memoryDir(meta.node_id);
77
+ mkdirSync(dir, { recursive: true });
78
+ writeFileSync(join(dir, 'physical-filename.md'), '---\nkind: knowledge\nname: explicit-identity\nwhen-and-why-to-read: When testing, this reference should be read because it is a regression fixture\nsystem-prompt-visibility: preview\n---\nTest body.\n');
79
+ const block = buildContextIntro(meta.node_id);
80
+ assert.match(block, /\u2500 explicit-identity {2}# read when:/, 'tree entry renders under the explicit frontmatter name');
81
+ assert.ok(!block.includes('physical-filename'), 'the physical filename never surfaces once an explicit name is set');
82
+ });
83
+ test('a CLAUDE.md in the node\'s cwd does not enter the bearings; only the <environment> snapshot remains', () => {
84
+ // buildProjectContextBlock now emits only the cwd/git environment snapshot.
85
+ // Project guidance lives in the document substrate instead, so a bare on-disk
86
+ // CLAUDE.md stays out of the bearings entirely.
79
87
  const proj = mkdtempSync(join(tmpdir(), 'crtr-proj-'));
80
88
  writeFileSync(join(proj, 'CLAUDE.md'), 'PROJECT_MARKER: build then commit.\n');
81
89
  const meta = spawnNode({ kind: 'general', cwd: proj, parent: null });
@@ -0,0 +1,144 @@
1
+ // Regression matrix for `resolveMemoryDoc`'s precedence rule: scopes/sources
2
+ // are visited NEAREST-FIRST, and the first source with ANY hit — by identity,
3
+ // direct physical path, or bare-leaf fallback — wins outright. A farther
4
+ // source's match, even an EXACT identity match, must never be consulted once
5
+ // a nearer source has answered by any means. This locks in the fix for the
6
+ // regression where a global identity-first scan let a farther-scope doc named
7
+ // exactly `AGENTS` shadow a nearer worktree's own `AGENTS.md` (whose explicit
8
+ // frontmatter `name` had since diverged from the physical filename).
9
+ //
10
+ // Fixture shape: two nested project scopes on disk —
11
+ // root/.crouter/memory/... (farther project scope)
12
+ // root/child/.crouter/memory/... (nearer project scope; cwd lives here)
13
+ // `findProjectScopeRoots` walks every ancestor `.crouter/` from cwd upward, so
14
+ // from `root/child` the resolved stack is exactly [child, root], nearest first
15
+ // — no profile indirection needed to exercise multi-scope precedence.
16
+ //
17
+ // Run: node --import tsx/esm --test src/core/__tests__/memory-resolver-precedence.test.ts
18
+ import { test, before, beforeEach, after } from 'node:test';
19
+ import assert from 'node:assert/strict';
20
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, realpathSync } from 'node:fs';
21
+ import { tmpdir } from 'node:os';
22
+ import { join } from 'node:path';
23
+ import { resetScopeCache } from '../scope.js';
24
+ import { resolveMemoryDoc } from '../memory-resolver.js';
25
+ let home;
26
+ let base;
27
+ let root;
28
+ let child;
29
+ let grandchild;
30
+ let prevCwd;
31
+ let prevHome;
32
+ function writeDoc(path, body, frontmatterName) {
33
+ mkdirSync(join(path, '..'), { recursive: true });
34
+ const fm = frontmatterName ? `---\nname: ${frontmatterName}\n---\n` : '';
35
+ writeFileSync(path, `${fm}${body}\n`, 'utf8');
36
+ }
37
+ before(() => {
38
+ home = mkdtempSync(join(tmpdir(), 'crtr-resolver-precedence-home-'));
39
+ prevHome = process.env.HOME;
40
+ process.env.HOME = home;
41
+ });
42
+ beforeEach(() => {
43
+ resetScopeCache();
44
+ base = realpathSync(mkdtempSync(join(tmpdir(), 'crtr-resolver-precedence-')));
45
+ root = join(base, 'root');
46
+ child = join(root, 'child');
47
+ grandchild = join(child, 'grandchild');
48
+ mkdirSync(root, { recursive: true });
49
+ mkdirSync(child, { recursive: true });
50
+ mkdirSync(grandchild, { recursive: true });
51
+ // --- root (FARTHER project scope) ---
52
+ writeDoc(join(root, '.crouter', 'memory', 'AGENTS.md'), 'ROOT AGENTS BODY'); // derived name "AGENTS"
53
+ writeDoc(join(root, '.crouter', 'memory', 'shared.md'), 'ROOT SHARED BODY'); // derived name "shared"
54
+ writeDoc(join(root, '.crouter', 'memory', 'taste', 'document-substrate.md'), 'ROOT TASTE BODY'); // derived "taste/document-substrate"
55
+ writeDoc(join(root, '.crouter', 'memory', 'taste', 'INDEX.md'), 'ROOT TASTE INDEX'); // derived "taste/INDEX"
56
+ // --- child (NEARER project scope; cwd resolves here) ---
57
+ writeDoc(join(child, '.crouter', 'memory', 'AGENTS.md'), 'CHILD AGENTS BODY', 'crouter'); // physical AGENTS.md, identity diverged to "crouter"
58
+ writeDoc(join(child, '.crouter', 'memory', 'sub', 'shared.md'), 'CHILD SUB SHARED BODY'); // derived "sub/shared" — leaf "shared"
59
+ writeDoc(join(child, '.crouter', 'memory', 'taste', 'document-substrate.md'), 'CHILD TASTE BODY'); // derived "taste/document-substrate"
60
+ writeDoc(join(child, '.crouter', 'memory', 'taste', 'INDEX.md'), 'CHILD TASTE INDEX'); // derived "taste/INDEX"
61
+ // --- grandchild (nearest still; own migrated slashed identity) ---
62
+ writeDoc(join(grandchild, '.crouter', 'memory', 'AGENTS.md'), 'GRANDCHILD SUBDIR BODY', 'root/subdir');
63
+ prevCwd = process.cwd();
64
+ });
65
+ after(() => {
66
+ process.chdir(prevCwd);
67
+ resetScopeCache();
68
+ rmSync(base, { recursive: true, force: true });
69
+ rmSync(home, { recursive: true, force: true });
70
+ if (prevHome === undefined)
71
+ delete process.env.HOME;
72
+ else
73
+ process.env.HOME = prevHome;
74
+ });
75
+ test('a physical-path query whose nearest match diverged in identity from a farther same-derived-name doc resolves to the NEAREST physical file (the AGENTS shadowing case)', () => {
76
+ process.chdir(child);
77
+ resetScopeCache();
78
+ try {
79
+ const byPhysicalName = resolveMemoryDoc('AGENTS');
80
+ assert.equal(byPhysicalName.path, join(child, '.crouter', 'memory', 'AGENTS.md'));
81
+ assert.match(byPhysicalName.body, /CHILD AGENTS BODY/);
82
+ const byExplicitIdentity = resolveMemoryDoc('crouter');
83
+ assert.equal(byExplicitIdentity.path, join(child, '.crouter', 'memory', 'AGENTS.md'));
84
+ assert.match(byExplicitIdentity.body, /CHILD AGENTS BODY/);
85
+ }
86
+ finally {
87
+ process.chdir(prevCwd);
88
+ }
89
+ });
90
+ test('a migrated nested doc resolves by its explicit slashed identity', () => {
91
+ process.chdir(grandchild);
92
+ resetScopeCache();
93
+ try {
94
+ const doc = resolveMemoryDoc('root/subdir');
95
+ assert.equal(doc.path, join(grandchild, '.crouter', 'memory', 'AGENTS.md'));
96
+ assert.equal(doc.name, 'root/subdir');
97
+ assert.match(doc.body, /GRANDCHILD SUBDIR BODY/);
98
+ }
99
+ finally {
100
+ process.chdir(prevCwd);
101
+ }
102
+ });
103
+ test('a plain path-named doc with no explicit name resolves to the nearest copy', () => {
104
+ process.chdir(child);
105
+ resetScopeCache();
106
+ try {
107
+ const doc = resolveMemoryDoc('taste/document-substrate');
108
+ assert.equal(doc.path, join(child, '.crouter', 'memory', 'taste', 'document-substrate.md'));
109
+ assert.match(doc.body, /CHILD TASTE BODY/);
110
+ }
111
+ finally {
112
+ process.chdir(prevCwd);
113
+ }
114
+ });
115
+ test('a bare-directory query resolves to that dir\'s INDEX.md, nearest copy first', () => {
116
+ process.chdir(child);
117
+ resetScopeCache();
118
+ try {
119
+ const doc = resolveMemoryDoc('taste');
120
+ assert.equal(doc.path, join(child, '.crouter', 'memory', 'taste', 'INDEX.md'));
121
+ assert.match(doc.body, /CHILD TASTE INDEX/);
122
+ }
123
+ finally {
124
+ process.chdir(prevCwd);
125
+ }
126
+ });
127
+ test('two docs sharing a path-derived leaf name across scopes: nearest wins even over a farther EXACT identity match', () => {
128
+ process.chdir(child);
129
+ resetScopeCache();
130
+ try {
131
+ // Nearest scope's doc lives at "sub/shared.md" (derived name "sub/shared",
132
+ // leaf "shared"); the farther scope's doc lives at top-level "shared.md"
133
+ // (derived name "shared", an EXACT identity match for the bare query).
134
+ // Precedence-as-outer-loop means the nearer source's mere leaf-fallback
135
+ // hit must still beat the farther source's exact identity hit.
136
+ const doc = resolveMemoryDoc('shared');
137
+ assert.equal(doc.path, join(child, '.crouter', 'memory', 'sub', 'shared.md'));
138
+ assert.equal(doc.name, 'sub/shared');
139
+ assert.match(doc.body, /CHILD SUB SHARED BODY/);
140
+ }
141
+ finally {
142
+ process.chdir(prevCwd);
143
+ }
144
+ });
@@ -0,0 +1 @@
1
+ export {};