@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,14 +1,17 @@
1
1
  // Startup profile selection — the CTO-decided order applied at the front door
2
- // and at any non-inherited `crtr node new`: explicit `--profile` > a profile
3
- // whose project dir IS cwd exactly (auto) > an interactive menu of profiles
4
- // covering cwd from a parent/workspace dir + create (MRU auto when headless) >
5
- // synchronous create-or-root decision when nothing covers. This is the ONLY
6
- // place that decision runs; front-door.ts/spawn.ts call it, never re-derive it.
2
+ // and at any non-inherited `crtr node new`: explicit `--profile` > a per-cwd
3
+ // PINNED default (set from the menu with `d`, wins over MRU while it still
4
+ // covers cwd) > a profile whose project dir IS cwd exactly (auto) > an
5
+ // interactive menu of profiles covering cwd from a parent/workspace dir +
6
+ // create (MRU auto when headless) > synchronous create-or-root decision when
7
+ // nothing covers. This is the ONLY place that decision runs; front-door.ts/
8
+ // spawn.ts call it, never re-derive it.
7
9
  import { existsSync, realpathSync } from 'node:fs';
8
10
  import { homedir } from 'node:os';
9
11
  import { basename, resolve as resolvePath, sep } from 'node:path';
10
12
  import { createInterface } from 'node:readline/promises';
11
13
  import { listProfiles, loadProfileManifest, updateProfileLastUsed, createProfile, addProfileProject, } from './manifest.js';
14
+ import { getDefaultProfileId, setDefaultProfileId, clearDefaultProfile, } from './default-binding.js';
12
15
  import { stdoutColor } from '../output.js';
13
16
  /** Resolve cwd to an absolute, realpath'd form so it compares against the
14
17
  * realpath'd project dirs `createProfile`/`addProfileProject` already store
@@ -38,7 +41,11 @@ function projectCovers(cwd, project) {
38
41
  return true;
39
42
  return false;
40
43
  }
41
- function profileCoversCwd(entry, cwd) {
44
+ /** Whether a profile's purview covers `cwd` (cwd at/under one of its project
45
+ * dirs, or a project dir under cwd — the workspace-root case). Exported for
46
+ * `crtr profile default set`, which rejects pinning a profile that doesn't
47
+ * cover cwd (the selector would ignore/self-heal such a pin anyway). */
48
+ export function profileCoversCwd(entry, cwd) {
42
49
  return entry.manifest.projects.some((p) => projectCovers(cwd, p));
43
50
  }
44
51
  /** `a` more recent than `b`, treating null as the oldest possible value (never
@@ -114,10 +121,11 @@ function writeHeader(title, subtitle) {
114
121
  process.stdout.write(`\n ${bold(title)}\n${sub}\n`);
115
122
  }
116
123
  /** One option row: ` <key> <label> <dim detail> · default`. `label` is
117
- * passed pre-styled; `detail` is dimmed here; the default row gets a marker. */
118
- function optionRow(k, label, detail, isDefault) {
124
+ * passed pre-styled; `detail` is dimmed here; the default row gets a marker
125
+ * whose text (`default` vs `default (pinned)`) the caller supplies. */
126
+ function optionRow(k, label, detail, isDefault, defaultText = 'default') {
119
127
  const det = detail !== '' ? ' ' + dim(detail) : '';
120
- const def = isDefault ? ' ' + accent('\u00b7 default') : '';
128
+ const def = isDefault ? ' ' + accent('\u00b7 ' + defaultText) : '';
121
129
  return ` ${key(k)} ${label}${det}${def}\n`;
122
130
  }
123
131
  /** The trailing readline prompt: ` › <dim hint> `. */
@@ -141,7 +149,7 @@ function recoveryMessage(cwd) {
141
149
  /** Explicit `--profile` names a profile whose manifest does NOT cover cwd.
142
150
  * Interactive only: offer to widen the profile's purview to include this
143
151
  * directory (default yes), so the next node started here is covered without a
144
- * separate `crtr profile add-project`. Declining leaves the manifest untouched
152
+ * separate `crtr profile project add`. Declining leaves the manifest untouched
145
153
  * and runs the node under the profile anyway. Same raw-readline rationale as
146
154
  * `promptCreateOrRoot` — this gates pi's boot on the same TTY. */
147
155
  async function promptAddDirToProfile(entry, cwd) {
@@ -196,10 +204,15 @@ async function promptCreateOrRoot(cwd) {
196
204
  * here: a claimed/covered directory always has at least a covering profile as a
197
205
  * sane identity, so `[r]oot` belongs only to the no-coverage prompt
198
206
  * (`promptCreateOrRoot`). Same raw-readline / boot-gating rationale. */
199
- async function promptPickProfileOrCreate(candidates, cwd, exact) {
200
- // MRU-first so the most-recently-used candidate is choice 1 / the bare-Enter
201
- // default — the same profile the non-interactive path auto-picks.
202
- const ordered = [...candidates].sort((a, b) => isMoreRecent(a.manifest.last_used_at, b.manifest.last_used_at) ? -1 : 1);
207
+ async function promptPickProfileOrCreate(candidates, cwd, exact, pinnedId) {
208
+ // MRU-first so the most-recently-used candidate is the bare-Enter default —
209
+ // the same profile the non-interactive path auto-picks. A per-cwd pin (set
210
+ // here with `d`) overrides that: it sorts to the top and becomes the
211
+ // Enter-default, so a single Enter keeps picking the user's chosen profile
212
+ // for this directory even as global-MRU drifts to other workspaces.
213
+ const mru = [...candidates].sort((a, b) => isMoreRecent(a.manifest.last_used_at, b.manifest.last_used_at) ? -1 : 1);
214
+ const pinned = pinnedId !== null ? mru.find((e) => e.profileId === pinnedId) ?? null : null;
215
+ const ordered = pinned !== null ? [pinned, ...mru.filter((e) => e !== pinned)] : mru;
203
216
  // Pad names to a shared column so the dim detail lines up down the menu.
204
217
  const nameWidth = Math.max(...ordered.map((e) => e.manifest.name.length));
205
218
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -215,21 +228,46 @@ async function promptPickProfileOrCreate(candidates, cwd, exact) {
215
228
  ? relativeUsed(entry.manifest.last_used_at)
216
229
  : `covers ${tildify(entry.manifest.projects.find((p) => projectCovers(cwd, p)) ?? entry.manifest.projects[0])}`;
217
230
  const label = bold(entry.manifest.name.padEnd(nameWidth));
218
- process.stdout.write(optionRow(String(i + 1), label, detail, i === 0));
231
+ const defaultText = pinned !== null && ordered[0] === pinned ? 'default (pinned)' : 'default';
232
+ process.stdout.write(optionRow(String(i + 1), label, detail, i === 0, defaultText));
219
233
  });
220
234
  process.stdout.write(optionRow('c', dim('create a new profile here'), '', false));
221
235
  process.stdout.write('\n');
236
+ // Action suffixes on a pick: `<n>d` also pins #n as this directory's
237
+ // default; `<n>a` also widens #n's purview to include cwd (meaningless when
238
+ // the candidates already claim cwd exactly, so hidden there). `u` clears an
239
+ // existing pin. A bare letter applies to the default row (choice 1).
240
+ const parts = ['d=set default'];
241
+ if (!exact)
242
+ parts.push('a=add dir here');
243
+ if (pinned !== null)
244
+ parts.push('u=unpin');
245
+ process.stdout.write(` ${dim(parts.join(' '))}\n\n`);
222
246
  const answer = (await rl.question(caret('[1]'))).trim().toLowerCase();
223
247
  if (answer === 'c' || answer === 'create')
224
248
  return await createProfileHere(rl, cwd);
225
- if (answer === '')
249
+ if (answer === 'u' || answer === 'unpin') {
250
+ clearDefaultProfile(cwd);
251
+ process.stdout.write(` ${accent('\u2713')} ${dim('cleared the default for this directory')}\n`);
226
252
  return ordered[0].profileId;
227
- const n = Number.parseInt(answer, 10);
228
- if (Number.isInteger(n) && n >= 1 && n <= ordered.length)
229
- return ordered[n - 1].profileId;
230
- // Unrecognized input the safe default (the MRU candidate); never loop,
231
- // since this prompt is gating pi's boot on the shared TTY.
232
- return ordered[0].profileId;
253
+ }
254
+ const m = /^(\d+)?([da])?$/.exec(answer);
255
+ // Unrecognized input → the safe default (choice 1); never loop, since this
256
+ // prompt is gating pi's boot on the shared TTY.
257
+ if (m === null)
258
+ return ordered[0].profileId;
259
+ const [, numStr, action] = m;
260
+ const idx = numStr !== undefined ? Number.parseInt(numStr, 10) - 1 : 0;
261
+ const picked = idx >= 0 && idx < ordered.length ? ordered[idx] : ordered[0];
262
+ if (action === 'd') {
263
+ setDefaultProfileId(cwd, picked.profileId);
264
+ process.stdout.write(` ${accent('\u2713')} ${dim(`"${picked.manifest.name}" is now the default here`)}\n`);
265
+ }
266
+ else if (action === 'a') {
267
+ addProfileProject(picked.profileId, cwd);
268
+ process.stdout.write(` ${accent('\u2713')} ${dim(`added this directory to "${picked.manifest.name}"`)}\n`);
269
+ }
270
+ return picked.profileId;
233
271
  }
234
272
  finally {
235
273
  rl.close();
@@ -241,6 +279,12 @@ async function promptPickProfileOrCreate(candidates, cwd, exact) {
241
279
  * its manifest does not already cover `cwd` and the session is interactive,
242
280
  * offer to add `cwd` to its purview (default yes). Bump `last_used_at`,
243
281
  * return the id.
282
+ * 1b. Else, a per-cwd PINNED default (menu `d`) that still covers `cwd` is
283
+ * auto-picked outright — interactive and headless alike, no prompt (that is
284
+ * what "default" means). Interactive prints a breadcrumb naming it and how
285
+ * to change it. `forcePicker` (`crtr --pick-profile`) bypasses the pin to
286
+ * re-open the menu. A stale pin (profile gone / no longer covering) is
287
+ * cleared and ignored.
244
288
  * 2. Else, if EXACTLY ONE profile's project dir IS `cwd` (you're at a project
245
289
  * root, unambiguously one owner), auto-pick it with no prompt — the
246
290
  * strongest signal. If SEVERAL profiles claim `cwd` exactly, it's genuinely
@@ -255,7 +299,7 @@ async function promptPickProfileOrCreate(candidates, cwd, exact) {
255
299
  * create a profile here or proceed as root (root lives ONLY here). Non-
256
300
  * interactive (no TTY): default to root (null) and print the recovery
257
301
  * instruction to STDERR — never stdout, which the caller may be piping. */
258
- export async function selectProfileForCwd(cwd, explicitProfile) {
302
+ export async function selectProfileForCwd(cwd, explicitProfile, forcePicker = false) {
259
303
  const resolvedCwd = resolveCwd(cwd);
260
304
  if (explicitProfile !== undefined && explicitProfile !== null && explicitProfile !== '') {
261
305
  const entry = loadProfileManifest(explicitProfile);
@@ -269,9 +313,30 @@ export async function selectProfileForCwd(cwd, explicitProfile) {
269
313
  }
270
314
  const covering = listProfiles().filter((p) => profileCoversCwd(p, resolvedCwd));
271
315
  const exact = covering.filter((p) => p.manifest.projects.some((proj) => proj === resolvedCwd));
316
+ // A per-cwd pinned default (set from the menu with `d`) is the user's
317
+ // explicit "default to THIS profile HERE". It outranks both the exact-single
318
+ // auto-pick and global-MRU, so long as it still covers cwd; a stale pin
319
+ // (profile deleted, or its dir removed from purview) is self-healed away.
320
+ const pinnedId = getDefaultProfileId(resolvedCwd);
321
+ const pinned = pinnedId !== null ? covering.find((p) => p.profileId === pinnedId) ?? null : null;
322
+ if (pinnedId !== null && pinned === null)
323
+ clearDefaultProfile(resolvedCwd);
324
+ // A valid pin decides it outright and STOPS ASKING — the whole point of
325
+ // setting a default. Auto-pick it (interactive too), printing a one-line
326
+ // breadcrumb so the choice isn't invisible and the user knows how to change
327
+ // it. `crtr --pick-profile` (forcePicker) re-opens the menu to re-pin/unpin.
328
+ if (pinned !== null && !forcePicker) {
329
+ updateProfileLastUsed(pinned.profileId);
330
+ if (isInteractive()) {
331
+ process.stdout.write(` ${accent('\u2713')} ${bold(pinned.manifest.name)} ${dim('\u00b7 default for this directory')}` +
332
+ ` ${dim('(crtr --pick-profile to change)')}\n`);
333
+ }
334
+ return pinned.profileId;
335
+ }
272
336
  // Exactly one profile's project dir IS cwd — unambiguous project root, use it
273
337
  // silently, never a prompt. (Several exact claims fall through to the menu.)
274
- if (exact.length === 1) {
338
+ // Suppressed under forcePicker so `--pick-profile` can pin even a lone owner.
339
+ if (!forcePicker && pinned === null && exact.length === 1) {
275
340
  updateProfileLastUsed(exact[0].profileId);
276
341
  return exact[0].profileId;
277
342
  }
@@ -290,10 +355,11 @@ export async function selectProfileForCwd(cwd, explicitProfile) {
290
355
  // Interactive. Several exact claimants → choose among them. Else covered from
291
356
  // a parent/workspace dir → menu of covering profiles. Else nothing covers →
292
357
  // the create-or-root prompt (root is only ever offered there).
358
+ const validPinnedId = pinned !== null ? pinned.profileId : null;
293
359
  const chosen = exact.length > 1
294
- ? await promptPickProfileOrCreate(exact, resolvedCwd, true)
360
+ ? await promptPickProfileOrCreate(exact, resolvedCwd, true, validPinnedId)
295
361
  : covering.length > 0
296
- ? await promptPickProfileOrCreate(covering, resolvedCwd, false)
362
+ ? await promptPickProfileOrCreate(covering, resolvedCwd, false, validPinnedId)
297
363
  : await promptCreateOrRoot(resolvedCwd);
298
364
  if (chosen !== null)
299
365
  updateProfileLastUsed(chosen);
@@ -1,8 +1,13 @@
1
1
  import { type Trigger } from '../canvas/index.js';
2
- /** The `<project_context>` block for the files discovered from `cwd`, plus the
3
- * cwd/git environment snapshot that now rides at the end of the wrapper.
4
- * Always emits the wrapper because the environment snapshot is always present
5
- * even when no project instructions exist. Exported for testing. */
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 AGENTS.md/CLAUDE.md instructions + the cwd/git
89
- * environment snapshot, moved OUT of the system prompt (pi's copy is
90
- * suppressed via noContextFiles in broker.ts) so it rides this message. */
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;
@@ -13,10 +13,15 @@
13
13
  // the source's whole conversation and then impersonates it. A non-fork
14
14
  // node's bearings are its FIRST entry, so there is nothing earlier to
15
15
  // disown; it gets only the declarative identity line. The message also
16
- // carries the <project_context> block (buildProjectContextBlock) — the
17
- // AGENTS.md/CLAUDE.md project instructions, moved OUT of the system prompt
18
- // (pi's copy is suppressed via noContextFiles in broker.ts) so they ride
19
- // this user message instead;
16
+ // carries the <project_context> block (buildProjectContextBlock) — now just
17
+ // the cwd/git <environment> snapshot. Project instructions (what used to be
18
+ // a re-rendered AGENTS.md/CLAUDE.md here) come from the document substrate
19
+ // instead: `crtr sys sync` migrates each CLAUDE.md/AGENTS.md into that
20
+ // dir's own `.crouter/memory/AGENTS.md` at content/content, so it rides the
21
+ // <memory kind="knowledge"> block below (renderKnowledgeBlock) via the
22
+ // profile-widened boot render, exactly like any other substrate doc — no
23
+ // parallel re-implementation of pi's project-context loader lives here
24
+ // anymore (see `crtr memory read taste/document-substrate`);
20
25
  // • promote.ts folds orchestratorContextNote() into the promotion guidance
21
26
  // dump, so a node that becomes an orchestrator MID-LIFE gets the
22
27
  // orchestrator framing it never received at spawn — it spawned as a base
@@ -36,78 +41,10 @@
36
41
  // This across-cycles note is the ONE thing a terminal worker's bearings drop.
37
42
  import { contextDir, getNode, fullName, subscriptionsOf, } from '../canvas/index.js';
38
43
  import { execSync } from 'node:child_process';
39
- import { existsSync, readFileSync, readdirSync } from 'node:fs';
40
- import { homedir } from 'node:os';
41
- import { join, resolve } from 'node:path';
44
+ import { readdirSync } from 'node:fs';
42
45
  import { cadenceDisplay } from '../wake.js';
43
46
  import { renderKnowledgeBlock } from '../substrate/index.js';
44
47
  import { loadProfileManifest } from '../profiles/manifest.js';
45
- // ---------------------------------------------------------------------------
46
- // Project context (<project_context>) — the AGENTS.md/CLAUDE.md files pi would
47
- // otherwise inject into its SYSTEM PROMPT, rendered here so they ride the
48
- // first-message bearings instead (pi's copy is suppressed via
49
- // resourceLoaderOptions.noContextFiles in broker.ts). We re-implement pi's
50
- // loadProjectContextFiles + getAgentDir (resource-loader.js / config.js) rather
51
- // than IMPORT pi: bearings.ts is statically imported by the thin daemon (crtrd,
52
- // for wakeOriginFrom), so a static @earendil-works import would pull the whole
53
- // pi engine into the supervisor. The discovery is a stable convention + pure fs.
54
- // ---------------------------------------------------------------------------
55
- /** pi's getAgentDir(): the global agent config dir holding a user-wide context
56
- * file. Honors PI_CODING_AGENT_DIR (tilde-expanded), else ~/.pi/agent (pi's
57
- * piConfig.configDir is ".pi"). */
58
- function piAgentDir() {
59
- const env = process.env['PI_CODING_AGENT_DIR'];
60
- if (env !== undefined && env !== '') {
61
- return env.startsWith('~') ? join(homedir(), env.slice(1)) : env;
62
- }
63
- return join(homedir(), '.pi', 'agent');
64
- }
65
- /** Per-dir context-file candidates, in pi's precedence order (first match wins). */
66
- const CONTEXT_FILE_CANDIDATES = ['AGENTS.md', 'AGENTS.MD', 'CLAUDE.md', 'CLAUDE.MD'];
67
- function loadContextFileFromDir(dir) {
68
- for (const name of CONTEXT_FILE_CANDIDATES) {
69
- const p = join(dir, name);
70
- if (existsSync(p)) {
71
- try {
72
- return { path: p, content: readFileSync(p, 'utf8') };
73
- }
74
- catch {
75
- // Unreadable candidate — skip, mirroring pi (best-effort).
76
- }
77
- }
78
- }
79
- return null;
80
- }
81
- /** Mirror of pi's loadProjectContextFiles: the global agent-dir file first, then
82
- * every AGENTS.md/CLAUDE.md up the cwd's ancestry, ordered root→cwd (so the
83
- * most-specific project file reads last). Deduped by absolute path. */
84
- function loadProjectContextFiles(cwd) {
85
- const files = [];
86
- const seen = new Set();
87
- const global = loadContextFileFromDir(piAgentDir());
88
- if (global !== null) {
89
- files.push(global);
90
- seen.add(global.path);
91
- }
92
- const ancestors = [];
93
- let dir = resolve(cwd);
94
- const root = resolve('/');
95
- for (;;) {
96
- const f = loadContextFileFromDir(dir);
97
- if (f !== null && !seen.has(f.path)) {
98
- ancestors.unshift(f);
99
- seen.add(f.path);
100
- }
101
- if (dir === root)
102
- break;
103
- const parent = resolve(dir, '..');
104
- if (parent === dir)
105
- break;
106
- dir = parent;
107
- }
108
- files.push(...ancestors);
109
- return files;
110
- }
111
48
  function escapeAttribute(value) {
112
49
  return value.replaceAll('&', '&amp;').replaceAll('"', '&quot;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
113
50
  }
@@ -175,18 +112,19 @@ function parseWorktrees(lines) {
175
112
  }
176
113
  return worktrees;
177
114
  }
178
- /** The `<project_context>` block for the files discovered from `cwd`, plus the
179
- * cwd/git environment snapshot that now rides at the end of the wrapper.
180
- * Always emits the wrapper because the environment snapshot is always present
181
- * even when no project instructions exist. Exported for testing. */
115
+ /** The `<project_context>` block now just the cwd/git `<environment>`
116
+ * snapshot (dir listing + git status/worktrees). This USED to also carry a
117
+ * `<project_instructions>` block re-rendering the node's AGENTS.md/CLAUDE.md;
118
+ * that machinery is gone (hard cut, 2026-07-06) project guidance now lives
119
+ * in the document substrate (`crtr sys sync` migrates each CLAUDE.md/AGENTS.md
120
+ * into that dir's own `.crouter/memory/AGENTS.md` at content/content, so it
121
+ * surfaces via the `<memory kind="knowledge">` block instead). Always emits
122
+ * the wrapper because the environment snapshot is always present. Exported
123
+ * for testing. */
182
124
  export function buildProjectContextBlock(cwd) {
183
- const files = loadProjectContextFiles(cwd);
184
125
  const envLines = directoryListingLines(cwd);
185
126
  const git = gitSnapshot(cwd);
186
- let out = '<project_context>\n\nProject-specific instructions and guidelines:\n\n';
187
- for (const { path, content } of files) {
188
- out += `<project_instructions path="${path}">\n${content}\n</project_instructions>\n\n`;
189
- }
127
+ let out = '<project_context>\n\n';
190
128
  out += `<environment cwd="${escapeAttribute(cwd)}">\n`;
191
129
  out += `Directory:\n${envLines.map((line) => ` ${line}`).join('\n')}\n`;
192
130
  out += `${git.lines.map((line) => `${line}`).join('\n')}\n`;
@@ -432,9 +370,12 @@ export function buildWakeBearings(origin) {
432
370
  * from.
433
371
  * 2. `<memory kind="knowledge">` — the consultable catalog, a SIBLING of the
434
372
  * bearings (never nested under the context dir). Dropped when empty.
435
- * 3. `<project_context>` — the AGENTS.md/CLAUDE.md instructions + the cwd/git
436
- * environment snapshot, moved OUT of the system prompt (pi's copy is
437
- * suppressed via noContextFiles in broker.ts) so it rides this message. */
373
+ * 3. `<project_context>` — the cwd/git environment snapshot. pi's own
374
+ * AGENTS.md/CLAUDE.md system-prompt injection stays suppressed
375
+ * (noContextFiles in broker.ts); project guidance instead rides the
376
+ * `<memory kind="knowledge">` block above via the document substrate
377
+ * (`crtr sys sync` migrates each CLAUDE.md/AGENTS.md into that dir's own
378
+ * `.crouter/memory/AGENTS.md`). */
438
379
  export function buildContextBearings(nodeId) {
439
380
  const node = getNode(nodeId);
440
381
  const bearings = ['<crtr-bearings>', buildIdentityAssertion(nodeId)];
@@ -1706,10 +1706,11 @@ export async function buildBrokerSession(engine, cfg) {
1706
1706
  resourceLoaderOptions: {
1707
1707
  additionalExtensionPaths: cfg.extensionPaths,
1708
1708
  appendSystemPrompt: cfg.appendSystemPromptPath !== undefined ? [cfg.appendSystemPromptPath] : undefined,
1709
- // Suppress pi's <project_context> in the SYSTEM PROMPT — crouter
1710
- // re-renders the same AGENTS.md/CLAUDE.md files into the first-message
1711
- // <crtr-context> bearings instead (buildProjectContextBlock), so all
1712
- // project instructions ride the user message, not the system prompt.
1709
+ // Suppress pi's <project_context> AGENTS.md/CLAUDE.md injection in the
1710
+ // SYSTEM PROMPT unconditionally crouter reads project guidance through
1711
+ // the document substrate instead (a migrated AGENTS.md doc rides the
1712
+ // <memory kind="knowledge"> block in the first-message bearings; see
1713
+ // bearings.ts and `crtr sys sync`), never pi's own file-based loader.
1713
1714
  noContextFiles: true,
1714
1715
  },
1715
1716
  });
@@ -5,8 +5,11 @@
5
5
  // crtr [dir] ["prompt"] → root with a starter prompt
6
6
  // crtr --name NAME ... → named root
7
7
  // crtr --profile <id-or-name> → root under an explicit profile (else the
8
- // startup selector: MRU covering cwd, else a
9
- // synchronous create-or-root prompt)
8
+ // startup selector: a per-cwd pinned default,
9
+ // else MRU covering cwd, else a synchronous
10
+ // create-or-root prompt)
11
+ // crtr --pick-profile → force the profile chooser open even when a
12
+ // per-cwd default is pinned (to re-pin/unpin)
10
13
  // crtr <subcommand> ... → falls through to the normal dispatcher
11
14
  // crtr -h | --help → root help (dispatcher)
12
15
  //
@@ -28,7 +31,7 @@ function isDir(p) {
28
31
  * positional dir/prompt). A leading token in this set still boots a root —
29
32
  * without it, `crtr --name X` would fall through to the dispatcher and error as
30
33
  * an unknown subcommand. */
31
- const FRONT_DOOR_FLAGS = new Set(['--name', '--kind', '--profile']);
34
+ const FRONT_DOOR_FLAGS = new Set(['--name', '--kind', '--profile', '--pick-profile']);
32
35
  /** Parse `[dir] [prompt]` positionals + the front-door flags out of the leftover
33
36
  * tokens after the bare `crtr`. */
34
37
  function parseRootArgs(tokens) {
@@ -36,6 +39,7 @@ function parseRootArgs(tokens) {
36
39
  let name;
37
40
  let kind;
38
41
  let profile;
42
+ let pickProfile = false;
39
43
  const positionals = [];
40
44
  for (let i = 0; i < tokens.length; i++) {
41
45
  const t = tokens[i];
@@ -48,6 +52,9 @@ function parseRootArgs(tokens) {
48
52
  else if (t === '--profile') {
49
53
  profile = tokens[++i];
50
54
  }
55
+ else if (t === '--pick-profile') {
56
+ pickProfile = true;
57
+ }
51
58
  else if (t.startsWith('--')) {
52
59
  // ignore unknown flags for the front door
53
60
  }
@@ -60,7 +67,7 @@ function parseRootArgs(tokens) {
60
67
  cwd = resolvePath(positionals.shift());
61
68
  }
62
69
  const prompt = positionals.length > 0 ? positionals.join(' ') : undefined;
63
- return { cwd, prompt, name, kind, profile };
70
+ return { cwd, prompt, name, kind, profile, pickProfile };
64
71
  }
65
72
  /** Env marker set on every pi the front door boots. Its presence means we are
66
73
  * already inside a front-door-booted root, so a nested front-door launch must
@@ -203,7 +203,8 @@ export function reviveNode(nodeId, opts) {
203
203
  // without this a manager waiting dormant on this child hangs forever,
204
204
  // never told it will not come back on its own. Mirrors closeNode's step-4
205
205
  // fan-out (same subscribers table, same active/passive split).
206
- fanDoctrineWake(nodeId, subscribersOf(nodeId), `Child crashed — ${fullName(meta)} (${nodeId}) failed to relaunch and is now dead. It will NOT resume on its own.`, { reason: 'child-crashed', child: nodeId });
206
+ fanDoctrineWake(nodeId, subscribersOf(nodeId), `Child crashed — ${fullName(meta)} (${nodeId}) failed to relaunch and is now dead: ${error.message}\n\n` +
207
+ `It stays dead until you revive it — \`crtr node lifecycle revive ${nodeId}\`.`, { reason: 'child-crashed', child: nodeId });
207
208
  }
208
209
  catch {
209
210
  /* best-effort cleanup */
@@ -11,6 +11,10 @@ export interface BootRootOpts {
11
11
  * selector for `cwd` (see `selectProfileForCwd`) — a root has no spawner to
12
12
  * inherit from, so this is the front door's only source of profile identity. */
13
13
  profile?: string | null;
14
+ /** `crtr --pick-profile`: force the startup profile chooser to open even when
15
+ * a per-cwd default is pinned, so the user can re-pin or unpin it. Without
16
+ * this a pinned default auto-picks silently (that is what "default" means). */
17
+ pickProfile?: boolean;
14
18
  }
15
19
  /** Create the front-door root: launch its broker engine, then exec `crtr surface attach`
16
20
  * inline so THIS terminal becomes the broker's controller-viewer. Does not
@@ -69,7 +69,7 @@ export async function bootRoot(opts) {
69
69
  // The front door's only source of profile identity — a root has no spawner
70
70
  // to inherit from. Runs BEFORE spawnNode: explicit --profile > MRU profile
71
71
  // covering cwd > a synchronous create-or-root prompt (or root, headless).
72
- const profileId = await selectProfileForCwd(opts.cwd, opts.profile);
72
+ const profileId = await selectProfileForCwd(opts.cwd, opts.profile, opts.pickProfile ?? false);
73
73
  // A born-resident root starts in base mode; it earns the orchestrator persona
74
74
  // the first time it delegates (or on promotion). Resident lifecycle either way.
75
75
  const { launch } = buildLaunchSpec(kind, 'base', { lifecycle: 'resident', hasManager: false });
@@ -9,10 +9,12 @@
9
9
  // • POSITIONAL — walk the read file's ancestor dirs; any doc living in an
10
10
  // ancestor PROJECT `.crouter/memory/` surfaces (a doc surfaces when a file
11
11
  // beside/under its own project scope dir is read), UNLESS it carries an
12
- // explicit read-trigger (see D5 below). This mirrors nested-context's
13
- // `.claude/rules` ancestor walk, but keyed on `.crouter/memory/`. The
14
- // user-global `~/.crouter/memory/` store is NOT positional; user docs only
15
- // fire on reads through explicit `applies-to` / `read-when` triggers.
12
+ // explicit read-trigger (see D5 below). This is the substrate's own
13
+ // ancestor walk keyed on `.crouter/memory/` — the same shape the retired
14
+ // nested-context pi-extension used for `.claude/rules` before that path
15
+ // was migrated into substrate docs (`crtr sys sync`). The user-global
16
+ // `~/.crouter/memory/` store is NOT positional; user docs only fire on
17
+ // reads through explicit `applies-to` / `read-when` triggers.
16
18
  // • applies-to GLOB — any RESOLVED substrate doc (user/project/builtin scope)
17
19
  // whose `appliesTo` glob matches the read file path surfaces, regardless of
18
20
  // where the read file sits relative to the doc.
@@ -63,8 +65,9 @@ import { cachedSubstrateDocs } from './session-cache.js';
63
65
  // is the segment we explicitly join onto each surviving ancestor).
64
66
  const JUNK_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.cache', '.yalc']);
65
67
  // ---------------------------------------------------------------------------
66
- // Small path helpers (mirror the on-read precedent — nested-context /
67
- // frontmatter-rules — so the injected envelope matches their faithful shape).
68
+ // Small path helpers (mirror the on-read precedent — frontmatter-rules, and
69
+ // the retired nested-context extension — so the injected envelope matches
70
+ // their faithful shape).
68
71
  // ---------------------------------------------------------------------------
69
72
  function realpathOrSelf(p) {
70
73
  try {
@@ -119,6 +119,26 @@ const REVIVE_GRACE_MS = 20_000;
119
119
  // Per-node first-observed-dead timestamps, for the grace window above. In-memory
120
120
  // only — a daemon restart resets it (worst case: one extra grace interval).
121
121
  const unhealthySince = new Map();
122
+ // Stranded-relaunch retry cap (2026-07-06 diagnosis, Q4): a relaunch whose
123
+ // broker never re-records its pid (dies before session_start, over and over)
124
+ // used to retry via the boot-grace clock above FOREVER — no attempt cap, no
125
+ // backoff — observed at 5,642 iterations on real nodes before the daemon was
126
+ // finally restarted. Bound the attempts with exponential backoff, then give up
127
+ // ONCE with a doctrine wake instead of spinning quietly for days.
128
+ const STRANDED_RELAUNCH_MAX_ATTEMPTS = 6;
129
+ const STRANDED_RELAUNCH_MAX_BACKOFF_MS = 10 * 60_000; // 10 minutes
130
+ // Per-node stranded-relaunch attempt count + the wall-clock time of the last
131
+ // attempt, backing the cap/backoff above. In-memory only, like unhealthySince
132
+ // — a daemon restart resets them (worst case: one extra round of retries).
133
+ const strandedRelaunchAttempts = new Map();
134
+ const strandedRelaunchLastAttempt = new Map();
135
+ function strandedRelaunchBackoffMs(attempts) {
136
+ return Math.min(REVIVE_GRACE_MS * 2 ** attempts, STRANDED_RELAUNCH_MAX_BACKOFF_MS);
137
+ }
138
+ function clearStrandedRelaunchState(id) {
139
+ strandedRelaunchAttempts.delete(id);
140
+ strandedRelaunchLastAttempt.delete(id);
141
+ }
122
142
  // §H refresh-authority grace: how long a node may sit with intent='refresh', its
123
143
  // turn OVER (busy marker absent) and its engine still ALIVE before the daemon
124
144
  // concludes the refresh stalled and force-kills the engine. The healthy window is
@@ -686,6 +706,7 @@ async function handleNodeLiveness(row, now, revivedThisTick) {
686
706
  // engine so the dead-pid refresh branch below revives it fresh.
687
707
  if (pid != null && isPidAlive(pid)) {
688
708
  unhealthySince.delete(id);
709
+ clearStrandedRelaunchState(id); // a live engine means the stranding resolved
689
710
  handleYieldStall(row, pid, now);
690
711
  handleWedgeDetection(id, pid, now);
691
712
  handleFatalFault(id, now);
@@ -741,8 +762,29 @@ async function handleNodeLiveness(row, now, revivedThisTick) {
741
762
  // the pid-clear, before re-record. Resume it on the saved session — unless
742
763
  // that dead relaunch was itself a cycling attempt (cycle_pending), in which
743
764
  // case retry it AS a cycle (retryResumeMode) instead of stranding it as a
744
- // strict resume into an empty marker branch.
745
- process.stderr.write(`[crtrd] revive ${id} (stranded relaunch pid never re-recorded)\n`);
765
+ // strict resume into an empty marker branch. Capped + backed off (above) so
766
+ // a node whose broker NEVER re-records a pid can't spin this branch forever.
767
+ const attempts = strandedRelaunchAttempts.get(id) ?? 0;
768
+ if (attempts >= STRANDED_RELAUNCH_MAX_ATTEMPTS) {
769
+ process.stderr.write(`[crtrd] giving up on ${id} after ${attempts} stranded-relaunch attempts (pid never re-recorded)\n`);
770
+ clearStrandedRelaunchState(id);
771
+ transition(id, 'crash');
772
+ try {
773
+ fanDoctrineWake(id, subscribersOf(id), `Child crashed — ${fullName(meta)} (${id}) failed to relaunch after ${attempts} attempts (its engine kept dying before it could re-record a pid) and is now dead. ` +
774
+ `It stays dead until you revive it — \`crtr node lifecycle revive ${id}\`.`, { reason: 'child-crashed', child: id });
775
+ }
776
+ catch {
777
+ /* best-effort, mirrors every other fan-out in this file */
778
+ }
779
+ return;
780
+ }
781
+ const lastAttempt = strandedRelaunchLastAttempt.get(id);
782
+ const backoff = strandedRelaunchBackoffMs(attempts);
783
+ if (lastAttempt !== undefined && now - lastAttempt < backoff)
784
+ return; // backing off before the next attempt
785
+ strandedRelaunchAttempts.set(id, attempts + 1);
786
+ strandedRelaunchLastAttempt.set(id, now);
787
+ process.stderr.write(`[crtrd] revive ${id} (stranded relaunch — pid never re-recorded, attempt ${attempts + 1}/${STRANDED_RELAUNCH_MAX_ATTEMPTS})\n`);
746
788
  reviveNode(id, { resume: retryResumeMode(meta) });
747
789
  revivedThisTick.add(id); // third-pass bare double-spawn guard (Maj-4)
748
790
  return;
@@ -1,3 +1,23 @@
1
+ /** Env keys that must NEVER reach the daemon process. Restarting crtrd is
2
+ * overwhelmingly done from inside an agent node's own bash tool (`crtr sys
3
+ * daemon stop && start`), which inherits that node's FULL env — its identity
4
+ * (`CRTR_NODE_ID`/`CRTR_KIND`/…, the `nodeEnv()` shape in `core/runtime/
5
+ * nodes.ts`), its front-door recursion-guard flag, and any pi-engine
6
+ * resolution seam a prior test/dev session left exported. The daemon is a
7
+ * singleton supervisor, never "a node" itself, so none of this belongs in its
8
+ * env regardless of whether today's code happens to read it — and at least one
9
+ * of these IS actively read: `host.ts`'s broker-engine resolution falls back to
10
+ * `process.env['CRTR_BROKER_ENGINE']` verbatim (nodeEnv() never sets that key,
11
+ * so nothing overrides it per child launch), so a daemon that inherits a
12
+ * stale/dev override throws inside `headlessBrokerHost.launch()` on EVERY
13
+ * relaunch it ever attempts, for its whole lifetime — the 2026-07-06 diagnosis
14
+ * root cause behind 19 nodes killed with "failed to relaunch and is now dead". */
15
+ export declare const DAEMON_ENV_STRIP_KEYS: readonly string[];
16
+ /** A copy of `process.env` with every `DAEMON_ENV_STRIP_KEYS` entry removed.
17
+ * Deliberate global config the user actually wants the daemon to see —
18
+ * `CRTR_HOME`, `CRTR_SUBTREE`, `CRTR_LOG`, `CRTR_DEBUG`, etc. — passes through
19
+ * untouched; only the node-identity/engine-poisoning surface is stripped. */
20
+ export declare function sanitizedDaemonEnv(): NodeJS.ProcessEnv;
1
21
  export interface SpawnDaemonResult {
2
22
  /** True when a new daemon process was spawned. */
3
23
  started: boolean;