@crouton-kit/crouter 0.3.49 → 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/commands/human/prompts.js +1 -1
- package/dist/commands/memory/write.js +2 -0
- 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/runtime/bearings.d.ts +4 -9
- package/dist/core/runtime/bearings.js +10 -17
- 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/package.json +3 -3
|
@@ -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 {
|
|
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 {};
|