@crouton-kit/crouter 0.3.46 → 0.3.48
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 +0 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/frontmatter-rules/index.ts +5 -3
- package/dist/clients/attach/attach-cmd.js +442 -442
- package/dist/commands/sys/__tests__/sync-import.test.js +89 -1
- package/dist/commands/sys/sync.js +242 -12
- package/dist/core/__tests__/broker-sdk-wiring.test.js +7 -5
- package/dist/core/__tests__/context-intro.test.js +12 -19
- package/dist/core/runtime/bearings.d.ts +15 -7
- package/dist/core/runtime/bearings.js +26 -85
- package/dist/core/runtime/broker.js +5 -4
- package/dist/core/substrate/on-read.js +9 -6
- package/dist/web-client/assets/index-dpd0Rzuw.js +80 -0
- package/dist/web-client/index.html +1 -1
- package/package.json +2 -2
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/nested-context.ts +0 -327
- package/dist/web-client/assets/index-Di-gSsVn.js +0 -80
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// Regression: `crtr sys sync` is the one-shot migration path from legacy
|
|
2
2
|
// Agent Skill bundles into crouter memory docs. The hard cut removes SKILL.md as
|
|
3
|
-
// an active guidance surface, but must keep the converter.
|
|
3
|
+
// an active guidance surface, but must keep the converter. It is ALSO the
|
|
4
|
+
// migration path for CLAUDE.md/AGENTS.md/.claude/rules — the hard cut that
|
|
5
|
+
// removes crtr's own bolted-on project-context loader (bearings.ts,
|
|
6
|
+
// nested-context.ts) in favor of the document substrate.
|
|
4
7
|
//
|
|
5
8
|
// Run: node --import tsx/esm --test src/commands/sys/__tests__/sync-import.test.ts
|
|
6
9
|
import { afterEach, beforeEach, test } from 'node:test';
|
|
@@ -10,6 +13,12 @@ import { tmpdir } from 'node:os';
|
|
|
10
13
|
import { join } from 'node:path';
|
|
11
14
|
import { sysSyncLeaf } from '../sync.js';
|
|
12
15
|
import { parseFrontmatterGeneric } from '../../../core/frontmatter.js';
|
|
16
|
+
import { gitSync } from '../../../core/git.js';
|
|
17
|
+
function git(args, cwd) {
|
|
18
|
+
const res = gitSync(args, cwd);
|
|
19
|
+
if (res.status !== 0)
|
|
20
|
+
throw new Error(`git ${args.join(' ')} failed in ${cwd}: ${res.stderr || res.stdout}`);
|
|
21
|
+
}
|
|
13
22
|
let home;
|
|
14
23
|
let prevHome;
|
|
15
24
|
beforeEach(() => {
|
|
@@ -70,3 +79,82 @@ test('permanently ignores selected SKILL.md bundles', async () => {
|
|
|
70
79
|
assert.notEqual(rendered, undefined);
|
|
71
80
|
assert.match(rendered, /would ignore/);
|
|
72
81
|
});
|
|
82
|
+
test('migrates CLAUDE.md and a .claude/rules file into that dir\'s own .crouter/memory/, copy-not-move, skip-existing, --overwrite replaces', async () => {
|
|
83
|
+
const source = mkdtempSync(join(tmpdir(), 'crtr-sys-sync-projectdocs-'));
|
|
84
|
+
writeFileSync(join(source, 'CLAUDE.md'), '# Demo Project\n\nBuild then commit.\n', 'utf8');
|
|
85
|
+
mkdirSync(join(source, '.claude', 'rules'), { recursive: true });
|
|
86
|
+
writeFileSync(join(source, '.claude', 'rules', 'foo.md'), '---\nname: foo\ndescription: Applies when touching src files.\npaths:\n - "src/**/*.ts"\n---\n\nDo the foo thing.\n', 'utf8');
|
|
87
|
+
const projectRows = (r) => r.results.filter((row) => row.target.includes('.crouter/memory'));
|
|
88
|
+
const dry = await sysSyncLeaf.run({ source, dryRun: true });
|
|
89
|
+
const dryRows = projectRows(dry);
|
|
90
|
+
assert.equal(dryRows.length, 2, 'both the AGENTS.md and the rule dry-run as candidates');
|
|
91
|
+
assert.ok(dryRows.every((r) => r.status === 'would-import'));
|
|
92
|
+
assert.equal(existsSync(join(source, '.crouter', 'memory', 'AGENTS.md')), false, 'dry-run writes nothing');
|
|
93
|
+
await sysSyncLeaf.run({ source });
|
|
94
|
+
const agentsTarget = join(source, '.crouter', 'memory', 'AGENTS.md');
|
|
95
|
+
const ruleTarget = join(source, '.crouter', 'memory', 'foo.md');
|
|
96
|
+
assert.equal(existsSync(agentsTarget), true);
|
|
97
|
+
assert.equal(existsSync(ruleTarget), true);
|
|
98
|
+
const agentsDoc = parseFrontmatterGeneric(readFileSync(agentsTarget, 'utf8'));
|
|
99
|
+
assert.equal(agentsDoc.data?.kind, 'knowledge');
|
|
100
|
+
assert.equal(agentsDoc.data?.['system-prompt-visibility'], 'content');
|
|
101
|
+
assert.equal(agentsDoc.data?.['file-read-visibility'], 'content');
|
|
102
|
+
assert.match(agentsDoc.body, /Build then commit\./);
|
|
103
|
+
const ruleDoc = parseFrontmatterGeneric(readFileSync(ruleTarget, 'utf8'));
|
|
104
|
+
assert.equal(ruleDoc.data?.kind, 'knowledge');
|
|
105
|
+
assert.equal(ruleDoc.data?.['system-prompt-visibility'], 'none');
|
|
106
|
+
assert.equal(ruleDoc.data?.['file-read-visibility'], 'content');
|
|
107
|
+
assert.deepEqual(ruleDoc.data?.['applies-to'], ['src/**/*.ts']);
|
|
108
|
+
assert.match(ruleDoc.body, /Do the foo thing\./);
|
|
109
|
+
// Originals untouched — copy, not move.
|
|
110
|
+
assert.equal(existsSync(join(source, 'CLAUDE.md')), true);
|
|
111
|
+
assert.equal(existsSync(join(source, '.claude', 'rules', 'foo.md')), true);
|
|
112
|
+
// Re-run skips existing targets.
|
|
113
|
+
const rerun = await sysSyncLeaf.run({ source });
|
|
114
|
+
const rerunRows = projectRows(rerun);
|
|
115
|
+
assert.ok(rerunRows.every((r) => r.status === 'skipped'));
|
|
116
|
+
// --overwrite replaces.
|
|
117
|
+
writeFileSync(join(source, 'CLAUDE.md'), '# Demo Project v2\n\nBuild, THEN commit.\n', 'utf8');
|
|
118
|
+
const overwritten = await sysSyncLeaf.run({ source, overwrite: true });
|
|
119
|
+
const overwriteRows = projectRows(overwritten);
|
|
120
|
+
assert.ok(overwriteRows.some((r) => r.status === 'imported'));
|
|
121
|
+
const agentsDoc2 = parseFrontmatterGeneric(readFileSync(agentsTarget, 'utf8'));
|
|
122
|
+
assert.match(agentsDoc2.body, /Build, THEN commit\./);
|
|
123
|
+
rmSync(source, { recursive: true, force: true });
|
|
124
|
+
});
|
|
125
|
+
test('routes the walk-root AGENTS.md doc by the real git repo name, not a worktree\'s own basename', async () => {
|
|
126
|
+
// Regression: running the migration from a linked git worktree (e.g. a
|
|
127
|
+
// canvas node's checkout, whose directory is named after a node id) must
|
|
128
|
+
// not bake that node id into the migrated doc's routing text — it should
|
|
129
|
+
// resolve through to the actual repo name shared by every worktree.
|
|
130
|
+
const base = mkdtempSync(join(tmpdir(), 'crtr-sys-sync-worktree-'));
|
|
131
|
+
const mainRepo = join(base, 'demo-repo');
|
|
132
|
+
mkdirSync(mainRepo, { recursive: true });
|
|
133
|
+
git(['init', '-b', 'main'], mainRepo);
|
|
134
|
+
git(['config', 'user.email', 'test@example.com'], mainRepo);
|
|
135
|
+
git(['config', 'user.name', 'Test'], mainRepo);
|
|
136
|
+
writeFileSync(join(mainRepo, 'README.md'), 'hello\n', 'utf8');
|
|
137
|
+
git(['add', '.'], mainRepo);
|
|
138
|
+
git(['commit', '-m', 'init'], mainRepo);
|
|
139
|
+
// A linked worktree whose basename looks nothing like the repo name.
|
|
140
|
+
const linkedWorktree = join(base, 'mr9xyz12-abcd3456');
|
|
141
|
+
git(['worktree', 'add', linkedWorktree, '-b', 'feature'], mainRepo);
|
|
142
|
+
writeFileSync(join(linkedWorktree, 'CLAUDE.md'), '# Fallback Title\n\nRepo guidance.\n', 'utf8');
|
|
143
|
+
const result = await sysSyncLeaf.run({ source: linkedWorktree });
|
|
144
|
+
assert.equal(result?.imported, 1);
|
|
145
|
+
const target = join(linkedWorktree, '.crouter', 'memory', 'AGENTS.md');
|
|
146
|
+
const doc = parseFrontmatterGeneric(readFileSync(target, 'utf8'));
|
|
147
|
+
assert.equal(doc.data?.['when-and-why-to-read'], 'When working in demo-repo, this knowledge should be read because it is the project\'s operating guide.');
|
|
148
|
+
git(['worktree', 'remove', '--force', linkedWorktree], mainRepo);
|
|
149
|
+
rmSync(base, { recursive: true, force: true });
|
|
150
|
+
});
|
|
151
|
+
test('falls back to the doc H1 title when the walk root is not inside a git repo', async () => {
|
|
152
|
+
const source = mkdtempSync(join(tmpdir(), 'crtr-sys-sync-nogit-'));
|
|
153
|
+
writeFileSync(join(source, 'CLAUDE.md'), '# Standalone Docs Project\n\nDo the thing.\n', 'utf8');
|
|
154
|
+
const result = await sysSyncLeaf.run({ source });
|
|
155
|
+
assert.equal(result?.imported, 1);
|
|
156
|
+
const target = join(source, '.crouter', 'memory', 'AGENTS.md');
|
|
157
|
+
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.');
|
|
159
|
+
rmSync(source, { recursive: true, force: true });
|
|
160
|
+
});
|
|
@@ -1,18 +1,29 @@
|
|
|
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 } from 'node:fs';
|
|
4
|
+
import { existsSync, readdirSync, 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';
|
|
8
8
|
import { usage } from '../../core/errors.js';
|
|
9
9
|
import { parseFrontmatterGeneric } from '../../core/frontmatter.js';
|
|
10
|
+
import { gitSync } from '../../core/git.js';
|
|
10
11
|
import { pathExists, readJsonIfExists, readText, walkFiles, writeJson, writeText } from '../../core/fs-utils.js';
|
|
12
|
+
import { loadProfileManifest } from '../../core/profiles/manifest.js';
|
|
11
13
|
import { findProjectScopeRoot, scopeMemoryDir } from '../../core/scope.js';
|
|
14
|
+
import { CRTR_DIR_NAME } from '../../types.js';
|
|
12
15
|
import { memoryFilePath, resolveWriteTarget, serializeMemoryDoc } from '../memory/shared.js';
|
|
13
16
|
const LEGACY_BOOT_SKILL_MARKER_PREFIX = '<!-- crtr-boot-skill v';
|
|
14
17
|
const HOST_SKILL_FILE = 'SKILL.md';
|
|
15
18
|
const IGNORE_FILE = 'skill-import-ignore.json';
|
|
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).
|
|
22
|
+
const PROJECT_DOC_JUNK_DIRS = new Set([
|
|
23
|
+
'node_modules', '.git', '.venv', 'venv', 'dist', 'build', '.next', '.cache', '.yalc', '.sisyphus', CRTR_DIR_NAME,
|
|
24
|
+
]);
|
|
25
|
+
// First-match-per-dir order mirrors pi's own loader (AGENTS.md wins over CLAUDE.md).
|
|
26
|
+
const PROJECT_DOC_FILENAMES = ['AGENTS.md', 'CLAUDE.md'];
|
|
16
27
|
function projectDir() {
|
|
17
28
|
const root = findProjectScopeRoot();
|
|
18
29
|
return root ? dirname(root) : null;
|
|
@@ -115,6 +126,208 @@ function targetMemoryDir(scope) {
|
|
|
115
126
|
return resolveWriteTarget(scope).memoryDir;
|
|
116
127
|
return dir;
|
|
117
128
|
}
|
|
129
|
+
/** Discovery roots for the project-doc walk: an explicit `--source` pins a
|
|
130
|
+
* single dir; otherwise cwd plus (when a profile is selected) every project
|
|
131
|
+
* in its purview — the same profile-widened idea `findProjectScopeRoots`
|
|
132
|
+
* applies to the boot render, so one invocation can migrate a whole
|
|
133
|
+
* workspace instead of one repo at a time. Deduped, cwd first; never throws
|
|
134
|
+
* on a missing/unreadable profile (cwd-only fallback). */
|
|
135
|
+
function projectDocRoots(sourceArg) {
|
|
136
|
+
if (sourceArg !== undefined)
|
|
137
|
+
return [resolve(sourceArg)];
|
|
138
|
+
const roots = [resolve(process.cwd())];
|
|
139
|
+
const profileId = process.env['CRTR_PROFILE_ID'];
|
|
140
|
+
if (profileId !== undefined && profileId !== '') {
|
|
141
|
+
try {
|
|
142
|
+
const { manifest } = loadProfileManifest(profileId);
|
|
143
|
+
for (const p of manifest.projects) {
|
|
144
|
+
const r = resolve(p);
|
|
145
|
+
if (!roots.includes(r))
|
|
146
|
+
roots.push(r);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
// No/unreadable profile — cwd-only root.
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return roots;
|
|
154
|
+
}
|
|
155
|
+
/** Walk `root` (skipping PROJECT_DOC_JUNK_DIRS) collecting every dir's
|
|
156
|
+
* CLAUDE.md/AGENTS.md (first match per dir) plus every `.claude/rules/*.md`.
|
|
157
|
+
* Each result names the directory whose OWN `.crouter/memory/` it targets. */
|
|
158
|
+
function collectProjectDocCandidates(root) {
|
|
159
|
+
const out = [];
|
|
160
|
+
if (!existsSync(root) || !statSync(root).isDirectory())
|
|
161
|
+
return out;
|
|
162
|
+
const rootResolved = resolve(root);
|
|
163
|
+
const stack = [rootResolved];
|
|
164
|
+
while (stack.length > 0) {
|
|
165
|
+
const dir = stack.pop();
|
|
166
|
+
const isWalkRoot = dir === rootResolved;
|
|
167
|
+
for (const name of PROJECT_DOC_FILENAMES) {
|
|
168
|
+
const p = join(dir, name);
|
|
169
|
+
if (existsSync(p) && statSync(p).isFile()) {
|
|
170
|
+
out.push({ kind: 'agents', path: p, sourceDir: dir, isWalkRoot });
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const rulesDir = join(dir, '.claude', 'rules');
|
|
175
|
+
if (existsSync(rulesDir)) {
|
|
176
|
+
for (const file of walkFiles(rulesDir, (n) => n.endsWith('.md'))) {
|
|
177
|
+
out.push({ kind: 'rule', path: file, sourceDir: dir, isWalkRoot });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
let entries;
|
|
181
|
+
try {
|
|
182
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
for (const e of entries) {
|
|
188
|
+
if (e.isDirectory() && !PROJECT_DOC_JUNK_DIRS.has(e.name))
|
|
189
|
+
stack.push(join(dir, e.name));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
/** First non-empty line, heading markers stripped, truncated — the `short-form`
|
|
195
|
+
* gist synthesized from a body with no frontmatter description to draw from. */
|
|
196
|
+
function synthesizeShortForm(body) {
|
|
197
|
+
for (const raw of body.split('\n')) {
|
|
198
|
+
const cleaned = raw.replace(/^#+\s*/, '').replace(/\s+/g, ' ').trim();
|
|
199
|
+
if (cleaned !== '')
|
|
200
|
+
return cleaned.length > 200 ? `${cleaned.slice(0, 197)}...` : cleaned;
|
|
201
|
+
}
|
|
202
|
+
return 'Migrated project guidance.';
|
|
203
|
+
}
|
|
204
|
+
/** `.claude/rules` frontmatter `paths:` normalizes to a glob list — a single
|
|
205
|
+
* string, an array, or absent (positional, unconditional rule). */
|
|
206
|
+
function normalizeRulePaths(v) {
|
|
207
|
+
if (typeof v === 'string')
|
|
208
|
+
return v.trim() === '' ? [] : [v.trim()];
|
|
209
|
+
if (Array.isArray(v))
|
|
210
|
+
return v.filter((g) => typeof g === 'string' && g.trim() !== '');
|
|
211
|
+
return [];
|
|
212
|
+
}
|
|
213
|
+
/** The main repo root's directory name, found via `git rev-parse
|
|
214
|
+
* --git-common-dir` — unlike `--show-toplevel`, this resolves through a
|
|
215
|
+
* LINKED WORKTREE to the shared `.git` of the repo it belongs to, so a
|
|
216
|
+
* canvas worktree checked out under a node-id path (e.g.
|
|
217
|
+
* `.../worktrees/mr9vsbtn-ecd74d1b`) still resolves to the real project
|
|
218
|
+
* name (`crouter`), not the worktree's own basename. Returns undefined when
|
|
219
|
+
* `dir` isn't inside a git repo at all. */
|
|
220
|
+
function gitRepoRootName(dir) {
|
|
221
|
+
const res = gitSync(['rev-parse', '--path-format=absolute', '--git-common-dir'], dir);
|
|
222
|
+
if (res.status !== 0)
|
|
223
|
+
return undefined;
|
|
224
|
+
const gitCommonDir = res.stdout.trim();
|
|
225
|
+
if (gitCommonDir === '')
|
|
226
|
+
return undefined;
|
|
227
|
+
return basename(dirname(gitCommonDir));
|
|
228
|
+
}
|
|
229
|
+
/** First Markdown H1 heading in the doc body, heading marker stripped. */
|
|
230
|
+
function h1Title(body) {
|
|
231
|
+
for (const raw of body.split('\n')) {
|
|
232
|
+
const m = raw.match(/^#\s+(.+)$/);
|
|
233
|
+
if (m) {
|
|
234
|
+
const title = m[1].replace(/\s+/g, ' ').trim();
|
|
235
|
+
if (title !== '')
|
|
236
|
+
return title;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
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;
|
|
249
|
+
}
|
|
250
|
+
/** AGENTS.md/CLAUDE.md → `<sourceDir>/.crouter/memory/AGENTS.md` at
|
|
251
|
+
* content/content (the CTO's ruling, 2026-07-06: an AGENTS.md-style doc is
|
|
252
|
+
* 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). */
|
|
255
|
+
function prepareAgentsCandidate(candidate) {
|
|
256
|
+
const body = parseFrontmatterGeneric(readText(candidate.path)).body;
|
|
257
|
+
const dirLabel = candidate.isWalkRoot
|
|
258
|
+
? projectRootLabel(candidate.sourceDir, body)
|
|
259
|
+
: basename(candidate.sourceDir) || candidate.sourceDir;
|
|
260
|
+
const frontmatter = {
|
|
261
|
+
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.`,
|
|
263
|
+
'short-form': synthesizeShortForm(body),
|
|
264
|
+
'system-prompt-visibility': 'content',
|
|
265
|
+
'file-read-visibility': 'content',
|
|
266
|
+
};
|
|
267
|
+
return {
|
|
268
|
+
candidate,
|
|
269
|
+
name: 'AGENTS',
|
|
270
|
+
target: memoryFilePath(join(candidate.sourceDir, CRTR_DIR_NAME, 'memory'), 'AGENTS'),
|
|
271
|
+
frontmatter,
|
|
272
|
+
body,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
/** `.claude/rules/<name>.md` → `<sourceDir>/.crouter/memory/<name>.md`. A
|
|
276
|
+
* `paths:` rule becomes an `applies-to` glob trigger at none/content; a
|
|
277
|
+
* pathless rule is positional (surfaces like AGENTS.md on any read under its
|
|
278
|
+
* own project scope) at the same none/content rungs. */
|
|
279
|
+
function prepareRuleCandidate(candidate) {
|
|
280
|
+
const raw = readText(candidate.path);
|
|
281
|
+
const parsed = parseFrontmatterGeneric(raw);
|
|
282
|
+
const fm = parsed.data ?? {};
|
|
283
|
+
const name = normalizedName(basename(candidate.path));
|
|
284
|
+
const description = scalarString(fm['description']) ?? '';
|
|
285
|
+
const dirLabel = candidate.isWalkRoot
|
|
286
|
+
? projectRootLabel(candidate.sourceDir, parsed.body)
|
|
287
|
+
: basename(candidate.sourceDir) || candidate.sourceDir;
|
|
288
|
+
const frontmatter = {
|
|
289
|
+
kind: 'knowledge',
|
|
290
|
+
'when-and-why-to-read': description !== ''
|
|
291
|
+
? routeFromDescription(description, 'knowledge')
|
|
292
|
+
: `When working under ${dirLabel}, this knowledge should be read because it is migrated project rule guidance.`,
|
|
293
|
+
'short-form': description !== '' ? description.replace(/\s+/g, ' ').trim() : synthesizeShortForm(parsed.body),
|
|
294
|
+
'system-prompt-visibility': 'none',
|
|
295
|
+
'file-read-visibility': 'content',
|
|
296
|
+
};
|
|
297
|
+
const globs = normalizeRulePaths(fm['paths']);
|
|
298
|
+
if (globs.length > 0)
|
|
299
|
+
frontmatter['applies-to'] = globs;
|
|
300
|
+
return {
|
|
301
|
+
candidate,
|
|
302
|
+
name,
|
|
303
|
+
target: memoryFilePath(join(candidate.sourceDir, CRTR_DIR_NAME, 'memory'), name),
|
|
304
|
+
frontmatter,
|
|
305
|
+
body: parsed.body,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
/** Write (or dry-run report) a prepared project doc — copy-not-move,
|
|
309
|
+
* skip-existing unless `--overwrite`, mirroring `convertPrepared` above. */
|
|
310
|
+
function convertProjectDoc(prepared, opts) {
|
|
311
|
+
if (pathExists(prepared.target) && !opts.overwrite) {
|
|
312
|
+
return {
|
|
313
|
+
source: prepared.candidate.path,
|
|
314
|
+
target: prepared.target,
|
|
315
|
+
name: prepared.name,
|
|
316
|
+
scope: 'project',
|
|
317
|
+
status: 'skipped',
|
|
318
|
+
reason: 'target memory doc already exists; re-run with --overwrite to replace it',
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
if (!opts.dryRun)
|
|
322
|
+
writeText(prepared.target, serializeMemoryDoc(prepared.frontmatter, prepared.body));
|
|
323
|
+
return {
|
|
324
|
+
source: prepared.candidate.path,
|
|
325
|
+
target: prepared.target,
|
|
326
|
+
name: prepared.name,
|
|
327
|
+
scope: 'project',
|
|
328
|
+
status: opts.dryRun ? 'would-import' : 'imported',
|
|
329
|
+
};
|
|
330
|
+
}
|
|
118
331
|
function readIgnoreState() {
|
|
119
332
|
let doc;
|
|
120
333
|
try {
|
|
@@ -257,32 +470,33 @@ function renderSummary(results, ignoreMode) {
|
|
|
257
470
|
}
|
|
258
471
|
export const sysSyncLeaf = defineLeaf({
|
|
259
472
|
name: 'sync',
|
|
260
|
-
description: 'convert legacy SKILL.md bundles into crouter memory docs',
|
|
261
|
-
whenToUse: 'migrating
|
|
473
|
+
description: 'convert legacy SKILL.md bundles and CLAUDE.md/AGENTS.md/.claude/rules into crouter memory docs',
|
|
474
|
+
whenToUse: 'migrating legacy guidance surfaces into the crouter memory-doc model: host-native Agent Skill bundles (SKILL.md, one-way import into a scope-root memory dir) AND per-project CLAUDE.md/AGENTS.md/.claude/rules (one-way copy into that same directory\'s OWN .crouter/memory/, so the doc travels with the repo and gets positional on-read for free). Both are one-way; sync never exports memory docs back to any of these files and never treats them as an active crouter guidance surface once migrated — crouter stops reading the originals unconditionally.',
|
|
262
475
|
help: {
|
|
263
476
|
name: 'sys sync',
|
|
264
|
-
summary: 'one-way import: SKILL.md bundles → crouter memory docs',
|
|
477
|
+
summary: 'one-way import: SKILL.md bundles + CLAUDE.md/AGENTS.md/.claude/rules → crouter memory docs',
|
|
265
478
|
params: [
|
|
266
|
-
{ kind: 'flag', name: 'source', type: 'string', required: false, constraint: '
|
|
267
|
-
{ kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project'], required: false, constraint: 'Target memory scope. Default: user for user host roots and project for project host roots; explicit value applies to every imported source.' },
|
|
479
|
+
{ kind: 'flag', name: 'source', type: 'string', required: false, constraint: 'For SKILL.md: a SKILL.md file, a single skill bundle dir containing SKILL.md, or a skills root to scan recursively; default is the user/project Claude and pi skill roots that exist. For CLAUDE.md/AGENTS.md/.claude/rules: the root to walk recursively for those files; default is cwd plus every project in the current profile\'s purview (when one is selected). One value drives both discoveries.' },
|
|
480
|
+
{ kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project'], required: false, constraint: 'Target memory scope for imported SKILL.md sources only. Default: user for user host roots and project for project host roots; explicit value applies to every imported SKILL.md source. Does not affect CLAUDE.md/AGENTS.md/.claude/rules migration — those always target their own source directory\'s .crouter/memory/, never a scope root.' },
|
|
268
481
|
{ kind: 'flag', name: 'dry-run', type: 'bool', required: false, constraint: 'Show what would be imported without writing memory docs.' },
|
|
269
482
|
{ kind: 'flag', name: 'overwrite', type: 'bool', required: false, constraint: 'Replace an existing target memory doc. Default: skip existing docs to avoid clobbering user-authored memory.' },
|
|
270
|
-
{ kind: 'flag', name: 'ignore', type: 'bool', required: false, constraint: `Permanently ignore every selected source by recording it in ~/.crouter/${IGNORE_FILE}. Honors --source/--scope; with no --source, ignores every currently discovered default host skill candidate.` },
|
|
271
|
-
{ kind: 'flag', name: 'include-ignored', type: 'bool', required: false, constraint: 'Include ignored candidates in the report without making sync read-only. Pair with --dry-run to audit the full candidate set without importing non-ignored skills.' },
|
|
483
|
+
{ kind: 'flag', name: 'ignore', type: 'bool', required: false, constraint: `Permanently ignore every selected SKILL.md source by recording it in ~/.crouter/${IGNORE_FILE}. Honors --source/--scope; with no --source, ignores every currently discovered default host skill candidate. Does not apply to CLAUDE.md/AGENTS.md/.claude/rules — that migration is skipped entirely when --ignore is present.` },
|
|
484
|
+
{ kind: 'flag', name: 'include-ignored', type: 'bool', required: false, constraint: 'Include ignored SKILL.md candidates in the report without making sync read-only. Pair with --dry-run to audit the full candidate set without importing non-ignored skills.' },
|
|
272
485
|
],
|
|
273
486
|
output: [
|
|
274
|
-
{ name: 'imported', type: 'number', required: true, constraint: 'Count of memory docs written.' },
|
|
275
|
-
{ name: 'skipped', type: 'number', required: true, constraint: 'Count of
|
|
487
|
+
{ name: 'imported', type: 'number', required: true, constraint: 'Count of memory docs written, across both SKILL.md and CLAUDE.md/AGENTS.md/.claude/rules sources.' },
|
|
488
|
+
{ name: 'skipped', type: 'number', required: true, constraint: 'Count of source files skipped (existing target without --overwrite, or an unimportable SKILL.md).' },
|
|
276
489
|
{ name: 'ignored', type: 'number', required: true, constraint: 'Count of selected SKILL.md files that are permanently ignored.' },
|
|
277
490
|
{ name: 'wouldIgnore', type: 'number', required: true, constraint: 'Count of selected SKILL.md files that would be ignored under --dry-run.' },
|
|
278
|
-
{ name: 'results', type: 'object[]', required: true, constraint: 'Each: {source,target,name,scope,status,reason?}; status may be imported, skipped, would-import, ignored, or would-ignore.' },
|
|
491
|
+
{ name: 'results', type: 'object[]', required: true, constraint: 'Each: {source,target,name,scope,status,reason?}; status may be imported, skipped, would-import, ignored, or would-ignore. A CLAUDE.md/AGENTS.md/.claude/rules row never carries ignored/would-ignore.' },
|
|
279
492
|
],
|
|
280
493
|
outputKind: 'object',
|
|
281
494
|
effects: [
|
|
282
495
|
'Writes memory/<name>.md in the selected crouter scope with substrate frontmatter and the SKILL.md body.',
|
|
496
|
+
'Copies each discovered CLAUDE.md/AGENTS.md into <dir>/.crouter/memory/AGENTS.md at system-prompt-visibility/file-read-visibility content/content, and each .claude/rules/*.md into <dir>/.crouter/memory/<name>.md at none/content (applies-to set from the rule\'s paths: globs when present) — copy, not move; original files are left untouched.',
|
|
283
497
|
'Skips existing memory docs unless --overwrite is present.',
|
|
284
498
|
'Skips marker-bearing generated crtr boot skills; host exports prune those legacy artifacts instead.',
|
|
285
|
-
`With --ignore: writes ~/.crouter/${IGNORE_FILE} and does not import the selected sources.`,
|
|
499
|
+
`With --ignore: writes ~/.crouter/${IGNORE_FILE} and does not import the selected SKILL.md sources; the CLAUDE.md/AGENTS.md/.claude/rules migration does not run at all in this mode.`,
|
|
286
500
|
'With --dry-run: read-only; writes nothing.',
|
|
287
501
|
],
|
|
288
502
|
},
|
|
@@ -328,6 +542,22 @@ export const sysSyncLeaf = defineLeaf({
|
|
|
328
542
|
}
|
|
329
543
|
if (changedIgnoreState)
|
|
330
544
|
writeIgnoreState(ignoreState);
|
|
545
|
+
// Project-doc migration (CLAUDE.md/AGENTS.md/.claude/rules) — independent
|
|
546
|
+
// of --ignore (a skill-only verb); an explicit --source pins its walk root
|
|
547
|
+
// too, so one invocation with --source drives both machineries over the
|
|
548
|
+
// same tree.
|
|
549
|
+
if (!ignoreMode) {
|
|
550
|
+
const docSeen = new Set();
|
|
551
|
+
for (const root of projectDocRoots(sourceArg)) {
|
|
552
|
+
for (const c of collectProjectDocCandidates(root)) {
|
|
553
|
+
if (docSeen.has(c.path))
|
|
554
|
+
continue;
|
|
555
|
+
docSeen.add(c.path);
|
|
556
|
+
const prepared = c.kind === 'agents' ? prepareAgentsCandidate(c) : prepareRuleCandidate(c);
|
|
557
|
+
results.push(convertProjectDoc(prepared, { dryRun, overwrite }));
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
331
561
|
const imported = results.filter((r) => r.status === 'imported').length;
|
|
332
562
|
const wouldImport = results.filter((r) => r.status === 'would-import').length;
|
|
333
563
|
const skipped = results.filter((r) => r.status === 'skipped').length;
|
|
@@ -83,11 +83,13 @@ 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
|
|
87
|
-
// `noContextFiles: true` in buildBrokerSession
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
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.)
|
|
91
93
|
// ===========================================================================
|
|
92
94
|
// C2 — zero-viewer dialogs resolve to deny/cancel IMMEDIATELY (noOp), never
|
|
93
95
|
// hanging the turn and never waiting on a per-dialog timeout. Drives the REAL
|
|
@@ -20,14 +20,9 @@ import { personaDrift } from '../runtime/persona.js';
|
|
|
20
20
|
import { memoryDir } from '../runtime/memory.js';
|
|
21
21
|
import registerCanvasContextIntro, { buildContextIntro, renderContextMessage, CONTEXT_INTRO_CUSTOM_TYPE, } from '../../pi-extensions/canvas-context-intro.js';
|
|
22
22
|
let home;
|
|
23
|
-
let prevAgentDir;
|
|
24
23
|
before(() => {
|
|
25
24
|
home = mkdtempSync(join(tmpdir(), 'crtr-ctxintro-'));
|
|
26
25
|
process.env['CRTR_HOME'] = home;
|
|
27
|
-
// Isolate pi's global agent dir to an empty path so buildProjectContextBlock
|
|
28
|
-
// can't pick up the dev machine's real ~/.pi/agent/AGENTS.md (hermeticity).
|
|
29
|
-
prevAgentDir = process.env['PI_CODING_AGENT_DIR'];
|
|
30
|
-
process.env['PI_CODING_AGENT_DIR'] = join(home, 'no-agent-dir');
|
|
31
26
|
});
|
|
32
27
|
beforeEach(() => {
|
|
33
28
|
closeDb();
|
|
@@ -38,10 +33,6 @@ after(() => {
|
|
|
38
33
|
rmSync(home, { recursive: true, force: true });
|
|
39
34
|
delete process.env['CRTR_HOME'];
|
|
40
35
|
delete process.env['CRTR_NODE_ID'];
|
|
41
|
-
if (prevAgentDir === undefined)
|
|
42
|
-
delete process.env['PI_CODING_AGENT_DIR'];
|
|
43
|
-
else
|
|
44
|
-
process.env['PI_CODING_AGENT_DIR'] = prevAgentDir;
|
|
45
36
|
});
|
|
46
37
|
test('worker bearings: base framing + <knowledge> block, NO across-cycles framing', () => {
|
|
47
38
|
// Bug-regression: review finding M1 — buildContextBearings renders the
|
|
@@ -78,20 +69,22 @@ test('worker bearings: base framing + <knowledge> block, NO across-cycles framin
|
|
|
78
69
|
assert.doesNotMatch(block, /refresh cycles/, 'no across-cycles framing for a terminal worker');
|
|
79
70
|
assert.match(block, /<\/crtr-bearings>/);
|
|
80
71
|
});
|
|
81
|
-
test('
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
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.
|
|
86
79
|
const proj = mkdtempSync(join(tmpdir(), 'crtr-proj-'));
|
|
87
80
|
writeFileSync(join(proj, 'CLAUDE.md'), 'PROJECT_MARKER: build then commit.\n');
|
|
88
81
|
const meta = spawnNode({ kind: 'general', cwd: proj, parent: null });
|
|
89
82
|
const block = buildContextIntro(meta.node_id);
|
|
90
|
-
assert.
|
|
91
|
-
assert.
|
|
92
|
-
assert.match(block,
|
|
93
|
-
assert.match(block, /<environment cwd=".*crtr-proj-.*">/, 'environment block
|
|
94
|
-
assert.match(block, /Directory:\n CLAUDE\.md\n/, 'environment block includes an ls-style listing');
|
|
83
|
+
assert.doesNotMatch(block, /<project_instructions/, 'no <project_instructions> block is ever emitted');
|
|
84
|
+
assert.doesNotMatch(block, /PROJECT_MARKER: build then commit\./, 'the CLAUDE.md body never rides the bearings');
|
|
85
|
+
assert.match(block, /<project_context>/, 'project_context wrapper still present');
|
|
86
|
+
assert.match(block, /<environment cwd=".*crtr-proj-.*">/, 'environment block still rides inside project_context');
|
|
87
|
+
assert.match(block, /Directory:\n CLAUDE\.md\n/, 'environment block still includes an ls-style listing');
|
|
95
88
|
assert.match(block, /Git: not a git repository\./, 'git snapshot degrades cleanly for a non-repo');
|
|
96
89
|
assert.match(block, /<environment[\s\S]*<\/environment>\n<\/project_context>$/, 'environment block closes project_context');
|
|
97
90
|
assert.ok(block.indexOf('</crtr-bearings>') < block.indexOf('<project_context>'), 'project_context follows the crtr-bearings block');
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { type Trigger } from '../canvas/index.js';
|
|
2
|
-
/** The `<project_context>` block
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
/** The `<project_context>` block — now just the cwd/git `<environment>`
|
|
3
|
+
* snapshot (dir listing + git status/worktrees). This USED to also carry a
|
|
4
|
+
* `<project_instructions>` block re-rendering the node's AGENTS.md/CLAUDE.md;
|
|
5
|
+
* that machinery is gone (hard cut, 2026-07-06) — project guidance now lives
|
|
6
|
+
* in the document substrate (`crtr sys sync` migrates each CLAUDE.md/AGENTS.md
|
|
7
|
+
* into that dir's own `.crouter/memory/AGENTS.md` at content/content, so it
|
|
8
|
+
* surfaces via the `<memory kind="knowledge">` block instead). Always emits
|
|
9
|
+
* the wrapper because the environment snapshot is always present. Exported
|
|
10
|
+
* for testing. */
|
|
6
11
|
export declare function buildProjectContextBlock(cwd: string): string;
|
|
7
12
|
/** The context-directory note — names the path INLINE (no XML attribute, so it
|
|
8
13
|
* never scopes a sibling block) and frames the dir as durable, shared scratch.
|
|
@@ -85,7 +90,10 @@ export declare function buildWakeBearings(origin: WakeOrigin): string;
|
|
|
85
90
|
* from.
|
|
86
91
|
* 2. `<memory kind="knowledge">` — the consultable catalog, a SIBLING of the
|
|
87
92
|
* bearings (never nested under the context dir). Dropped when empty.
|
|
88
|
-
* 3. `<project_context>` — the
|
|
89
|
-
*
|
|
90
|
-
*
|
|
93
|
+
* 3. `<project_context>` — the cwd/git environment snapshot. pi's own
|
|
94
|
+
* AGENTS.md/CLAUDE.md system-prompt injection stays suppressed
|
|
95
|
+
* (noContextFiles in broker.ts); project guidance instead rides the
|
|
96
|
+
* `<memory kind="knowledge">` block above via the document substrate
|
|
97
|
+
* (`crtr sys sync` migrates each CLAUDE.md/AGENTS.md into that dir's own
|
|
98
|
+
* `.crouter/memory/AGENTS.md`). */
|
|
91
99
|
export declare function buildContextBearings(nodeId: string): string;
|