@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.
- package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +1 -1
- package/dist/clients/attach/attach-cmd.js +825 -863
- package/dist/clients/attach/titled-editor.js +6 -3
- package/dist/commands/human/prompts.js +1 -1
- package/dist/commands/human/shared.js +7 -3
- package/dist/commands/memory/write.js +2 -0
- package/dist/commands/node.js +96 -11
- package/dist/commands/profile/default.d.ts +2 -0
- package/dist/commands/profile/default.js +143 -0
- package/dist/commands/profile/new.js +3 -3
- package/dist/commands/profile/project.d.ts +2 -0
- package/dist/commands/profile/project.js +97 -0
- package/dist/commands/profile/show.js +1 -1
- package/dist/commands/profile.js +10 -11
- package/dist/commands/sys/__tests__/sync-import.test.js +156 -3
- package/dist/commands/sys/sync.js +82 -25
- package/dist/core/__tests__/broker-sdk-wiring.test.js +4 -7
- package/dist/core/__tests__/context-intro.test.js +15 -7
- package/dist/core/__tests__/memory-resolver-precedence.test.d.ts +1 -0
- package/dist/core/__tests__/memory-resolver-precedence.test.js +144 -0
- package/dist/core/__tests__/on-read-identity.test.d.ts +1 -0
- package/dist/core/__tests__/on-read-identity.test.js +68 -0
- package/dist/core/fs-utils.d.ts +1 -1
- package/dist/core/fs-utils.js +5 -3
- package/dist/core/memory-resolver.d.ts +27 -11
- package/dist/core/memory-resolver.js +105 -109
- package/dist/core/profiles/default-binding.d.ts +10 -0
- package/dist/core/profiles/default-binding.js +50 -0
- package/dist/core/profiles/select.d.ts +13 -1
- package/dist/core/profiles/select.js +92 -26
- package/dist/core/runtime/bearings.d.ts +4 -9
- package/dist/core/runtime/bearings.js +10 -17
- package/dist/core/runtime/front-door.js +11 -4
- package/dist/core/runtime/revive.js +2 -1
- package/dist/core/runtime/spawn.d.ts +4 -0
- package/dist/core/runtime/spawn.js +1 -1
- package/dist/core/substrate/index.d.ts +1 -1
- package/dist/core/substrate/index.js +3 -1
- package/dist/core/substrate/on-read.js +14 -9
- package/dist/core/substrate/render.js +7 -3
- package/dist/core/substrate/schema.d.ts +8 -2
- package/dist/core/substrate/schema.js +19 -2
- package/dist/daemon/crtrd.js +44 -2
- package/dist/daemon/manage.d.ts +20 -0
- package/dist/daemon/manage.js +64 -2
- package/package.json +3 -3
- package/dist/commands/profile/add-project.d.ts +0 -1
- package/dist/commands/profile/add-project.js +0 -42
- package/dist/commands/profile/remove-project.d.ts +0 -1
- 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 {
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
/**
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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`).
|
|
254
|
-
*
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
87
|
-
// `noContextFiles: true` in buildBrokerSession
|
|
88
|
-
//
|
|
89
|
-
//
|
|
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('
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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 {};
|