@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.
Files changed (38) hide show
  1. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +0 -1
  2. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/frontmatter-rules/index.ts +5 -3
  3. package/dist/clients/attach/attach-cmd.js +425 -425
  4. package/dist/clients/attach/titled-editor.js +6 -3
  5. package/dist/commands/human/shared.js +7 -3
  6. package/dist/commands/node.js +96 -11
  7. package/dist/commands/profile/default.d.ts +2 -0
  8. package/dist/commands/profile/default.js +143 -0
  9. package/dist/commands/profile/new.js +3 -3
  10. package/dist/commands/profile/project.d.ts +2 -0
  11. package/dist/commands/profile/project.js +97 -0
  12. package/dist/commands/profile/show.js +1 -1
  13. package/dist/commands/profile.js +10 -11
  14. package/dist/commands/sys/__tests__/sync-import.test.js +89 -1
  15. package/dist/commands/sys/sync.js +242 -12
  16. package/dist/core/__tests__/broker-sdk-wiring.test.js +7 -5
  17. package/dist/core/__tests__/context-intro.test.js +12 -19
  18. package/dist/core/profiles/default-binding.d.ts +10 -0
  19. package/dist/core/profiles/default-binding.js +50 -0
  20. package/dist/core/profiles/select.d.ts +13 -1
  21. package/dist/core/profiles/select.js +92 -26
  22. package/dist/core/runtime/bearings.d.ts +15 -7
  23. package/dist/core/runtime/bearings.js +26 -85
  24. package/dist/core/runtime/broker.js +5 -4
  25. package/dist/core/runtime/front-door.js +11 -4
  26. package/dist/core/runtime/revive.js +2 -1
  27. package/dist/core/runtime/spawn.d.ts +4 -0
  28. package/dist/core/runtime/spawn.js +1 -1
  29. package/dist/core/substrate/on-read.js +9 -6
  30. package/dist/daemon/crtrd.js +44 -2
  31. package/dist/daemon/manage.d.ts +20 -0
  32. package/dist/daemon/manage.js +64 -2
  33. package/package.json +2 -2
  34. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/nested-context.ts +0 -327
  35. package/dist/commands/profile/add-project.d.ts +0 -1
  36. package/dist/commands/profile/add-project.js +0 -42
  37. package/dist/commands/profile/remove-project.d.ts +0 -1
  38. 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 host-native Agent Skill bundles into the crouter memory-doc model. This is a one-way import from SKILL.md to memory/<name>.md; it never exports memory docs back to SKILL.md and never treats SKILL.md as an active crouter guidance surface.',
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: 'A SKILL.md file, a single skill bundle dir containing SKILL.md, or a skills root to scan recursively. Default: user/project Claude and pi skill roots that exist.' },
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 SKILL.md files skipped.' },
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 now sets
87
- // `noContextFiles: true` in buildBrokerSession and re-renders project context
88
- // into the first-message <crtr-context> bearings instead of pi's system prompt
89
- // (broker.ts). That behavior is covered by context-intro.test.ts's "project
90
- // instructions ride the bearings, not the system prompt" test.)
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('project instructions (AGENTS.md/CLAUDE.md) ride the bearings, not the system prompt', () => {
82
- // pi's <project_context> is suppressed in the system prompt (broker.ts
83
- // noContextFiles) and re-rendered into the first-message bearings here. The
84
- // discovery walks the node cwd ancestry, so a CLAUDE.md in the node's cwd must
85
- // surface inside a <project_context> block AFTER the <crtr-bearings> block.
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.match(block, /<project_context>/, 'project_context block present in the bearings');
91
- assert.match(block, new RegExp(`<project_instructions path="${join(proj, 'CLAUDE.md')}">`), 'the project CLAUDE.md is rendered with its path');
92
- assert.match(block, /PROJECT_MARKER: build then commit\./, 'project file content rides the message');
93
- assert.match(block, /<environment cwd=".*crtr-proj-.*">/, 'environment block is appended inside project_context');
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>;