@crouton-kit/crouter 0.3.47 → 0.3.49
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 +425 -425
- package/dist/clients/attach/titled-editor.js +6 -3
- package/dist/commands/human/shared.js +7 -3
- 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 +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/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 +15 -7
- package/dist/core/runtime/bearings.js +26 -85
- package/dist/core/runtime/broker.js +5 -4
- 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/on-read.js +9 -6
- 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 +2 -2
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/nested-context.ts +0 -327
- 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
|
@@ -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');
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Resolve cwd to the same absolute, realpath'd form the profile selector and
|
|
2
|
+
* manifest use, so the mangled-cwd workspace key lines up with the realpath'd
|
|
3
|
+
* project dirs. Falls back to the plain resolved path when the dir can't be
|
|
4
|
+
* stat'd. This is the single resolver every entry point (selector + the
|
|
5
|
+
* `profile default` commands) routes through, so a pin set from one is found
|
|
6
|
+
* by the other. */
|
|
7
|
+
export declare function resolveBindingCwd(cwd: string): string;
|
|
8
|
+
export declare function getDefaultProfileId(cwd: string): string | null;
|
|
9
|
+
export declare function setDefaultProfileId(cwd: string, profileId: string): void;
|
|
10
|
+
export declare function clearDefaultProfile(cwd: string): void;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// A per-cwd "default profile" pin — a small binding stored in the per-cwd
|
|
2
|
+
// workspace root (`~/.crouter/workspaces/<mangled-cwd>/default-profile.json`)
|
|
3
|
+
// that says "when a node boots at THIS directory, prefer THIS profile". It is
|
|
4
|
+
// deliberately NOT manifest state: a profile's `projects` list is what it can
|
|
5
|
+
// see (coverage/purview), whereas this pin is which of several covering
|
|
6
|
+
// profiles the user chose to default to here. The two are independent — you
|
|
7
|
+
// can pin a profile that only covers cwd from a parent dir without widening
|
|
8
|
+
// its purview, and widening purview never changes the pin.
|
|
9
|
+
//
|
|
10
|
+
// Consulted by `selectProfileForCwd` (it wins over global-MRU and stops the
|
|
11
|
+
// prompt) and written from the startup menu (`d`) or `crtr profile default`.
|
|
12
|
+
// A stale pin (its profile deleted, or no longer covering cwd) is self-healed
|
|
13
|
+
// away by the selector rather than trusted.
|
|
14
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
15
|
+
import { join, resolve as resolvePath } from 'node:path';
|
|
16
|
+
import { workspaceRoot } from '../artifact.js';
|
|
17
|
+
import { readJsonIfExists, writeJson, ensureDir, removePath } from '../fs-utils.js';
|
|
18
|
+
/** Resolve cwd to the same absolute, realpath'd form the profile selector and
|
|
19
|
+
* manifest use, so the mangled-cwd workspace key lines up with the realpath'd
|
|
20
|
+
* project dirs. Falls back to the plain resolved path when the dir can't be
|
|
21
|
+
* stat'd. This is the single resolver every entry point (selector + the
|
|
22
|
+
* `profile default` commands) routes through, so a pin set from one is found
|
|
23
|
+
* by the other. */
|
|
24
|
+
export function resolveBindingCwd(cwd) {
|
|
25
|
+
const abs = resolvePath(cwd);
|
|
26
|
+
if (!existsSync(abs))
|
|
27
|
+
return abs;
|
|
28
|
+
try {
|
|
29
|
+
return realpathSync(abs);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return abs;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function bindingPath(cwd) {
|
|
36
|
+
return join(workspaceRoot(resolveBindingCwd(cwd)), 'default-profile.json');
|
|
37
|
+
}
|
|
38
|
+
export function getDefaultProfileId(cwd) {
|
|
39
|
+
const binding = readJsonIfExists(bindingPath(cwd));
|
|
40
|
+
const id = binding?.profileId;
|
|
41
|
+
return typeof id === 'string' && id !== '' ? id : null;
|
|
42
|
+
}
|
|
43
|
+
export function setDefaultProfileId(cwd, profileId) {
|
|
44
|
+
const resolved = resolveBindingCwd(cwd);
|
|
45
|
+
ensureDir(workspaceRoot(resolved));
|
|
46
|
+
writeJson(join(workspaceRoot(resolved), 'default-profile.json'), { profileId });
|
|
47
|
+
}
|
|
48
|
+
export function clearDefaultProfile(cwd) {
|
|
49
|
+
removePath(bindingPath(cwd));
|
|
50
|
+
}
|
|
@@ -1,9 +1,21 @@
|
|
|
1
|
+
import { type ProfileEntry } from './manifest.js';
|
|
2
|
+
/** Whether a profile's purview covers `cwd` (cwd at/under one of its project
|
|
3
|
+
* dirs, or a project dir under cwd — the workspace-root case). Exported for
|
|
4
|
+
* `crtr profile default set`, which rejects pinning a profile that doesn't
|
|
5
|
+
* cover cwd (the selector would ignore/self-heal such a pin anyway). */
|
|
6
|
+
export declare function profileCoversCwd(entry: ProfileEntry, cwd: string): boolean;
|
|
1
7
|
/** Select the profile a node about to boot at `cwd` should run under.
|
|
2
8
|
*
|
|
3
9
|
* 1. `explicitProfile` present → resolve id/name via `loadProfileManifest`. If
|
|
4
10
|
* its manifest does not already cover `cwd` and the session is interactive,
|
|
5
11
|
* offer to add `cwd` to its purview (default yes). Bump `last_used_at`,
|
|
6
12
|
* return the id.
|
|
13
|
+
* 1b. Else, a per-cwd PINNED default (menu `d`) that still covers `cwd` is
|
|
14
|
+
* auto-picked outright — interactive and headless alike, no prompt (that is
|
|
15
|
+
* what "default" means). Interactive prints a breadcrumb naming it and how
|
|
16
|
+
* to change it. `forcePicker` (`crtr --pick-profile`) bypasses the pin to
|
|
17
|
+
* re-open the menu. A stale pin (profile gone / no longer covering) is
|
|
18
|
+
* cleared and ignored.
|
|
7
19
|
* 2. Else, if EXACTLY ONE profile's project dir IS `cwd` (you're at a project
|
|
8
20
|
* root, unambiguously one owner), auto-pick it with no prompt — the
|
|
9
21
|
* strongest signal. If SEVERAL profiles claim `cwd` exactly, it's genuinely
|
|
@@ -18,4 +30,4 @@
|
|
|
18
30
|
* create a profile here or proceed as root (root lives ONLY here). Non-
|
|
19
31
|
* interactive (no TTY): default to root (null) and print the recovery
|
|
20
32
|
* instruction to STDERR — never stdout, which the caller may be piping. */
|
|
21
|
-
export declare function selectProfileForCwd(cwd: string, explicitProfile?: string | null): Promise<string | null>;
|
|
33
|
+
export declare function selectProfileForCwd(cwd: string, explicitProfile?: string | null, forcePicker?: boolean): Promise<string | null>;
|