@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.
@@ -36,7 +36,7 @@ export const humanAsk = defineLeaf({
36
36
  help: {
37
37
  name: 'human ask',
38
38
  summary: 'put a humanloop decision deck in front of a person; returns a job handle immediately. This is the default, expected channel for posing ANY question or decision to the user — reach for it instead of writing the question as prose in your reply.',
39
- guide: 'Use exactly one of these forms: `crtr human ask "<question>"` for a simple open-text prompt (the CLI builds a one-item humanloop deck internally), or `crtr human ask --context-file deck.json` for a fully structured deck. The kickoff is instant and NEVER blocks — "never block on the result" refers only to not busy-waiting on the job; the human answering on their own time is not a reason to avoid asking or to fall back to inline prose. The deck body is directive-flavored markdown rendered by termrender (panels, columns, trees, callouts, mermaid) — see `termrender doc -h` for the directive set before authoring one.',
39
+ guide: 'Use exactly one of these forms: `crtr human ask "<question>"` for a simple open-text prompt (the CLI builds a one-item humanloop deck internally), or `crtr human ask --context-file deck.json` for a fully structured deck. The kickoff is instant and NEVER blocks — "never block on the result" refers only to not busy-waiting on the job; the human answering on their own time is not a reason to avoid asking or to fall back to inline prose. The deck body is directive-flavored markdown rendered by termrender (panels, columns, trees, callouts, mermaid) — see `termrender doc -h` for the directive set before authoring one. Author a GOOD deck: ask only what materially changes your next step (each question spends the human\'s attention), offer genuine alternatives as `options` (not one real pick plus filler), and layer content by progressive disclosure — `title` a short topic, `subtitle` the one-sentence TL;DR, `body` the plain-language detail with denser material under a `## Details` heading the reader can skip.',
40
40
  params: [
41
41
  { kind: 'positional', name: 'question', type: 'string', required: false, constraint: 'Bare-question shorthand for a simple open-text ask. Mutually exclusive with `--context-file`.' },
42
42
  { kind: 'context-file', name: 'deck', required: false, constraint: 'Strict complex-ask deck JSON. Use this when you need multiple interactions, options, or other deck structure. Mutually exclusive with the question shorthand.', shape: DECK_SCHEMA_HINT },
@@ -12,7 +12,9 @@ export const writeLeaf = defineLeaf({
12
12
  summary: 'create or update memory/<name>.md at the resolved scope from frontmatter flags + a stdin body',
13
13
  guide: 'The body is the easy part; the craft is routing — every frontmatter flag decides who sees this doc, when, and at what context cost. Each rung up is paid by every future agent at every boot or read, forever, so default each rung down.\n\n' +
14
14
  'Pick the kind. knowledge is consulted for facts or procedures; preference directs behavior. The kind choice is about how the doc is used, not about how long it is.\n\n' +
15
+ 'Store reusable current truth, not session notes. Useful memories are non-obvious procedures, gotchas, durable preferences, cross-repo conventions, and amendments to plans/specs that future agents must honor. Put plan/spec amendments under path names like `projects/<topic>/...`; add `projects/<topic>/INDEX.md` with `name` visibility so the topic is discoverable without loading the whole body. Do not store chat summaries, implementation history, or facts already recorded in the repo.\n\n' +
15
16
  'Set both visibility rungs explicitly on create. There is no kind default. `none`, `name`, `preview`, and `content` move from least to most loaded: `none` keeps the doc out of auto-load and on-read surfaces, `name` is the bare doc tag only, `preview` is the name + envelope + routing line (`when-and-why-to-read`), and `content` inlines the whole body when the body is short enough to justify it. Each axis is independent; usually one carries a real rung and the other is `none`. When a doc is short enough to state in a single sentence — a one-line preference, or a knowledge fact that fits in a sentence — skip `preview` and use `name` or `content` directly: the routing line would run longer than the doc itself, so a `preview` rung just adds words. Sentence-length `content` docs are not just acceptable, they are correct. Never pad a memory to be more verbose than the rule or fact it carries.\n\n' +
17
+ 'Choose the scope. `project` is for facts any agent in one repo needs. `user` is for person-wide facts and preferences that should follow the user everywhere. `profile` is for the profile’s bundle of dirs: cross-repo conventions, how the pieces relate, or the user’s stance toward that body of work. When unsure, choose the narrowest scope that will still reach the next agent who needs it.\n\n' +
16
18
  'Choose the hook — boot vs file-read. Behavior and how-to procedure surface at boot. Knowledge about code belongs next to the code: put the file in that directory’s .crouter/memory/ (`--dir <project-dir>` targets it exactly, from anywhere) and it fires positionally when files there are read, costing nothing at boot. A knowledge doc about a person or process has no code directory to anchor to, so on-read triggering is meaningless — route it through boot instead and set the boot rung to `preview` when you want the routing line to surface.\n\n' +
17
19
  'Write the routing line (--when-and-why-to-read) first, before storing anything: "When <circumstance>, this <kind> should be read because <payoff>." The test is whether a stranger mid-task can decide from that one line alone whether to spend the read. If you cannot name the concrete situation that triggers it, you do not yet understand the memory — ask the user one sharp question instead of improvising. Never paraphrase the advice in the routing line; keep it about when to read and why the read is worth it.\n\n' +
18
20
  'Gate and read-when share the same predicate language: a field map is AND-ed across fields; dotted fields resolve nested values; field matchers may be scalar, array, or object. Scalar matchers do exact equality, with arrays matching any element. Array matchers do membership or intersection. Object matchers accept `eq`, `ne`, `in`, `nin`, `exists`, `contains`, `containsAll`, `containsAny`, `matches`, `imatches`, `gt`, `gte`, `lt`, and `lte`. Combinators are `all`, `any`, and `not`; sibling field matchers next to them are AND-ed in. An empty condition is inert. An unknown op never matches.\n\n' +
@@ -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 {};