@crouton-kit/crouter 0.3.48 → 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.
@@ -10,9 +10,12 @@ import { truncateToWidth, visibleWidth, } from '@earendil-works/pi-tui';
10
10
  /** The thinking color scale is a deliberate cool→warm ramp between a blue and
11
11
  * its orange complement, evenly spaced across the five active levels so each
12
12
  * step stays readable without jumping through unrelated hues. `off` falls
13
- * back to the caller's default border color. */
14
- const THINKING_START = [68, 120, 228]; // blue
15
- const THINKING_END = [228, 152, 68]; // orange
13
+ * back to the caller's default border color. The endpoints are deliberately
14
+ * deep (not bright) hues: the same colors double as the title-chip BACKGROUND
15
+ * under bold white text, so every step must clear ~4.5:1 white-on-color
16
+ * contrast — a lighter blue/orange washes the label out. */
17
+ const THINKING_START = [46, 92, 190]; // deep blue
18
+ const THINKING_END = [166, 98, 24]; // deep amber
16
19
  function interpolateRgb(start, end, t) {
17
20
  return [
18
21
  Math.round(start[0] + (end[0] - start[0]) * t),
@@ -5,9 +5,13 @@ import { atomicWriteJson, readJson } from '@crouton-kit/humanloop';
5
5
  import { countPanesInWindow, spawnAndDetach, shellQuote, attachedClientPane, paneAlive, } from '../../core/spawn.js';
6
6
  import { graphSurfaceTarget } from '../../core/runtime/placement.js';
7
7
  export const DECK_SCHEMA_HINT = 'Deck must match the humanloop deck schema: {title?, ' +
8
- 'source?:{sessionName?,askedBy?,blockedSince?}, ' +
9
- 'interactions:[{id,title,subtitle?,(body?|bodyPath?),options:[{id,label,' +
10
- 'description?}],multiSelect?,allowFreetext?,freetextLabel?,' +
8
+ 'source?:{sessionName?,askedBy?,blockedSince?}, interactions:[{id, ' +
9
+ 'title (short ≤~4-word topic — the thing being decided, not the decision), ' +
10
+ 'subtitle? (ONE plain-English sentence — the TL;DR/stakes, not a paragraph), ' +
11
+ '(body?|bodyPath?) (the full explanation and the ONLY place for long or rich ' +
12
+ 'prose — directive-flavored markdown rendered by termrender; never put the ' +
13
+ 'wall of detail in subtitle), options:[{id,label,description?}], multiSelect?, ' +
14
+ 'allowFreetext?, freetextLabel?, ' +
11
15
  "kind?:'notify'|'decision'|'context'|'error'}]}.";
12
16
  export function resolveMaxPanes() {
13
17
  return readConfig('user').max_panes_per_window;
@@ -22,6 +22,7 @@ import { nodeContextLeaf } from './node-context.js';
22
22
  import { nodeArtifactsLeaf } from './node-inspect-artifacts.js';
23
23
  import { nodeWorktreeBranch } from './node-worktree.js';
24
24
  import { nodeReviveLeaf } from './node-lifecycle-revive.js';
25
+ import { isPidAlive } from '../core/canvas/pid.js';
25
26
  import { recycleNode } from '../core/runtime/recycle.js';
26
27
  import { readFaultAsync } from '../core/runtime/fault.js';
27
28
  import { activeFaultForDisplay } from '../core/canvas/render.js';
@@ -34,7 +35,7 @@ import { appendInbox } from '../core/feed/inbox.js';
34
35
  import { readMergedLaunchConfig } from '../core/config.js';
35
36
  import { listProfiles } from '../core/profiles/manifest.js';
36
37
  import { stateBlock } from '../core/help.js';
37
- import { getNode, updateNode, listNodes, subscribe, unsubscribe, subscriptionsOf, subscribersOf, readContextTokens, view, armTrigger, listTriggers, cancelTrigger, TriggerArmError, fullName, } from '../core/canvas/index.js';
38
+ import { getNode, updateNode, listNodes, subscribe, unsubscribe, subscriptionsOf, subscribersOf, contextDir, reportsDir, nodeArtifacts, readContextTokens, view, armTrigger, listTriggers, cancelTrigger, TriggerArmError, fullName, } from '../core/canvas/index.js';
38
39
  // Past this much context, an ORCHESTRATOR that spawns a managed child is better
39
40
  // off yielding than holding its fat window open for the child's result: the
40
41
  // child's finish revives it fresh against its roadmap, so a clean window absorbs
@@ -436,26 +437,42 @@ const nodeList = defineLeaf({
436
437
  // ---------------------------------------------------------------------------
437
438
  // node inspect show — a node + its place in the spine
438
439
  // ---------------------------------------------------------------------------
440
+ // Enrich a bare subscription edge with the neighbor's name/kind/status so a
441
+ // debugger reads who a report/manager IS without a second `show` per id.
442
+ function enrichNeighbor(ref) {
443
+ const n = getNode(ref.node_id);
444
+ return {
445
+ node_id: ref.node_id,
446
+ name: n ? fullName(n) : '(missing)',
447
+ kind: n?.kind ?? '?',
448
+ status: n ? (n.intent ? `${n.status}/${n.intent}` : n.status) : '?',
449
+ active: ref.active,
450
+ };
451
+ }
439
452
  const nodeShow = defineLeaf({
440
453
  name: 'show',
441
- description: 'show a node + its spine neighbors',
442
- whenToUse: 'you want one node in depth: its meta plus its spine neighbors who it reports to (subscriptions) and who manages it (subscribers). Use `node inspect list` instead for the flat roster of every node, or `canvas dashboard` to see the whole-tree shape',
454
+ description: 'show a node + its spine neighbors, artifacts, and drill-in pointers',
455
+ whenToUse: 'you want one node in depth — the debugging entrypoint: a glanceable meta slice (status/intent, liveness, cwd, session file), its named spine neighbors (reports down, managers up), how many artifacts/triggers it has, and the exact next commands + on-disk paths to read its context/reports dirs and children. Use `node inspect list` instead for the flat roster of every node, or `canvas dashboard` to see the whole-tree shape',
443
456
  help: {
444
457
  name: 'node inspect show',
445
- summary: 'show a node\'s meta plus its subscriptions (reports) and subscribers (managers)',
458
+ summary: 'show a node in depth — meta slice, named neighbors (reports/managers), artifact/trigger counts, and drill-in pointers (context dir, session file, children). The debugging entrypoint for one node.',
446
459
  params: [
447
460
  { kind: 'positional', name: 'node', required: true, constraint: 'Node id.' },
448
461
  ],
449
462
  output: [
450
- { name: 'node', type: 'object', required: true, constraint: 'The node meta.' },
451
- { name: 'reports', type: 'object[]', required: true, constraint: 'Who this node subscribes to (its reports/down).' },
452
- { name: 'managers', type: 'object[]', required: true, constraint: 'Who subscribes to this node (its managers/up).' },
463
+ { name: 'node', type: 'object', required: true, constraint: 'The full node meta (system prompt + every telemetry field). The render surfaces a glanceable slice; `--json` carries the complete dump.' },
464
+ { name: 'reports', type: 'object[]', required: true, constraint: 'Who this node subscribes to (its reports/down), each enriched with {node_id, name, kind, status, active} — these are typically its spawned children.' },
465
+ { name: 'managers', type: 'object[]', required: true, constraint: 'Who subscribes to this node (its managers/up), same enriched shape.' },
466
+ { name: 'artifacts', type: 'object', required: true, constraint: 'Counts of what the node left on disk by corpus: {report, doc, roadmap}. Enumerate them with `node inspect artifacts <id>`.' },
467
+ { name: 'pending_triggers', type: 'number', required: true, constraint: 'How many clock/predicate triggers this node has armed. Inspect with `node triggers list --node <id>`.' },
468
+ { name: 'paths', type: 'object', required: true, constraint: 'Resolved on-disk locations: {context_dir, reports_dir, session_file} — read these directly to debug.' },
469
+ { name: 'follow_up', type: 'string', required: true, constraint: 'The concrete next commands to drill deeper.' },
453
470
  ],
454
471
  outputKind: 'object',
455
- effects: ['Read-only: reads the node meta + canvas.db edges.'],
472
+ effects: ['Read-only: reads the node meta, canvas.db edges, on-disk artifacts, and armed triggers.'],
456
473
  // The node meta object inlines the full system prompt + every telemetry
457
- // field — a machine-readable dump with no useful human-glance slice to
458
- // synthesize (unlike dashboard/list's tabular rows), same rationale as
474
+ // field — a machine-readable dump we suppress from the viewer preview; the
475
+ // render below surfaces only a glanceable slice. Same preview rationale as
459
476
  // canvas snapshot / node inspect snapshot.
460
477
  preview: { suppressOutput: true },
461
478
  },
@@ -465,7 +482,75 @@ const nodeShow = defineLeaf({
465
482
  if (node === null) {
466
483
  throw new InputError({ error: 'not_found', message: `no node: ${id}`, next: 'List nodes with `crtr node inspect list`.' });
467
484
  }
468
- return { node, reports: subscriptionsOf(id), managers: subscribersOf(id) };
485
+ const reports = subscriptionsOf(id).map(enrichNeighbor);
486
+ const managers = subscribersOf(id).map(enrichNeighbor);
487
+ const arts = nodeArtifacts(id, ['report', 'doc', 'roadmap']);
488
+ const artifacts = {
489
+ report: arts.filter((a) => a.source === 'report').length,
490
+ doc: arts.filter((a) => a.source === 'doc').length,
491
+ roadmap: arts.filter((a) => a.source === 'roadmap').length,
492
+ };
493
+ const pending_triggers = listTriggers({ node: id }).length;
494
+ const paths = {
495
+ context_dir: contextDir(id),
496
+ reports_dir: reportsDir(id),
497
+ session_file: node.pi_session_file ?? null,
498
+ };
499
+ // Auto-gathered drill-in pointers: the exact commands a debugger reaches for
500
+ // next, populated only when there is actually something behind them.
501
+ const hints = [];
502
+ const artTotal = artifacts.report + artifacts.doc + artifacts.roadmap;
503
+ if (artTotal > 0)
504
+ hints.push(`Read its ${artTotal} artifact(s): \`crtr node inspect artifacts ${id}\` → each ref feeds \`crtr canvas history read <ref>\`.`);
505
+ hints.push(`Browse its context + reports dirs (and its subscriptions') in nvim: \`crtr node inspect context ${id}\`.`);
506
+ if (paths.session_file)
507
+ hints.push(`Read the raw conversation transcript: ${paths.session_file}`);
508
+ hints.push(`On-disk context dir (ls/read directly): ${paths.context_dir}`);
509
+ if (pending_triggers > 0)
510
+ hints.push(`It has ${pending_triggers} armed trigger(s): \`crtr node triggers list --node ${id}\`.`);
511
+ if (reports.length > 0)
512
+ hints.push(`Drill into a child/report: \`crtr node inspect show <node_id>\` (${reports.map((r) => r['node_id']).join(', ')}).`);
513
+ hints.push(`Full machine dump (system prompt + all telemetry): \`crtr --json node inspect show ${id}\`.`);
514
+ return { node, reports, managers, artifacts, pending_triggers, paths, follow_up: hints.join('\n') };
515
+ },
516
+ render: (r) => {
517
+ const node = r['node'];
518
+ const id = String(node['node_id']);
519
+ const pid = node['pi_pid'];
520
+ const alive = isPidAlive(pid) ? 'alive' : 'dead';
521
+ const intent = node['intent'] ? `/${String(node['intent'])}` : '';
522
+ const parts = [];
523
+ // 1) Glanceable meta slice — the debug-critical fields, not the full dump.
524
+ parts.push(`# ${fullName(node)} (${id})`);
525
+ const meta = [
526
+ `- kind/mode/lifecycle: ${node['kind']} / ${node['mode']} / ${node['lifecycle']}`,
527
+ `- status: ${node['status']}${intent}`,
528
+ `- broker pid: ${pid ?? '(none)'} — ${alive}`,
529
+ `- cwd: ${node['cwd']}`,
530
+ `- host: ${node['host_kind']}` + (node['profile_id'] ? ` · profile ${node['profile_id']}` : ''),
531
+ `- cycles: ${node['cycles']} · created ${node['created']}`,
532
+ ];
533
+ if (node['parent'])
534
+ meta.push(`- parent: ${node['parent']}`);
535
+ parts.push(meta.join('\n'));
536
+ // 2) Neighbor tables (reports down, managers up), pre-enriched with names.
537
+ const table = (label, rows) => {
538
+ if (rows.length === 0)
539
+ return `${label}: none`;
540
+ const cols = ['node_id', 'name', 'kind', 'status', 'active'];
541
+ const head = `| ${cols.join(' | ')} |`;
542
+ const sep = `| ${cols.map(() => '---').join(' | ')} |`;
543
+ const body = rows.map((row) => `| ${cols.map((c) => String(row[c] ?? '').replace(/\|/g, '\\|').replace(/\n/g, ' ')).join(' | ')} |`).join('\n');
544
+ return `${rows.length} ${label}:\n\n${head}\n${sep}\n${body}`;
545
+ };
546
+ parts.push(table('reports (down — subscriptions, usually children)', r['reports']));
547
+ parts.push(table('managers (up — subscribers)', r['managers']));
548
+ // 3) On-disk footprint at a glance.
549
+ const a = r['artifacts'];
550
+ parts.push(`artifacts: ${a['report']} report(s), ${a['doc']} doc(s), ${a['roadmap']} roadmap · ${r['pending_triggers']} pending trigger(s)`);
551
+ // 4) Auto-gathered drill-in pointers.
552
+ parts.push(`Drill deeper:\n${String(r['follow_up'])}`);
553
+ return parts.join('\n\n');
469
554
  },
470
555
  });
471
556
  // ---------------------------------------------------------------------------
@@ -0,0 +1,2 @@
1
+ import type { BranchDef } from '../../core/command.js';
2
+ export declare const defaultBranch: BranchDef;
@@ -0,0 +1,143 @@
1
+ // `crtr profile default` — the per-cwd default-profile pin. A pin says "when a
2
+ // node boots in THIS directory, use THIS profile" and stops the startup
3
+ // chooser from asking. It is directory-scoped, not manifest state: independent
4
+ // of a profile's project purview (`crtr profile project`). The startup menu's
5
+ // `d` action writes the same pin; these verbs are the explicit surface for it.
6
+ import { defineLeaf, defineBranch } from '../../core/command.js';
7
+ import { InputError } from '../../core/io.js';
8
+ import { loadProfileManifest } from '../../core/profiles/manifest.js';
9
+ import { profileCoversCwd } from '../../core/profiles/select.js';
10
+ import { getDefaultProfileId, setDefaultProfileId, clearDefaultProfile, resolveBindingCwd, } from '../../core/profiles/default-binding.js';
11
+ const setLeaf = defineLeaf({
12
+ name: 'set',
13
+ description: 'pin a profile as the default for the current directory',
14
+ whenToUse: 'you want crtr to stop asking which profile to use here and always default to a chosen one — the command form of the startup menu\u2019s `d` action. The profile must already cover this directory (add it with `crtr profile project add` first if not).',
15
+ help: {
16
+ name: 'profile default set',
17
+ summary: "pin a profile as the current directory's default, so the startup chooser stops asking",
18
+ params: [
19
+ {
20
+ kind: 'positional',
21
+ name: 'profile',
22
+ required: true,
23
+ constraint: 'Exact profile id, or a unique manifest name. Must already cover the current directory (be at/above/below it in its project purview).',
24
+ },
25
+ ],
26
+ output: [
27
+ { name: 'dir', type: 'string', required: true, constraint: "The resolved directory the pin was set for." },
28
+ { name: 'profile_id', type: 'string', required: true, constraint: 'The pinned profile id.' },
29
+ { name: 'name', type: 'string', required: true, constraint: 'The pinned profile name.' },
30
+ { name: 'follow_up', type: 'string', required: true, constraint: 'Concrete next commands.' },
31
+ ],
32
+ outputKind: 'object',
33
+ effects: ['Writes `default-profile.json` in the per-cwd workspace root. The next launch here auto-picks this profile with no prompt (override once with `crtr --pick-profile`).'],
34
+ },
35
+ run: async (_input) => {
36
+ const cwd = process.cwd();
37
+ const resolved = resolveBindingCwd(cwd);
38
+ const entry = loadProfileManifest(_input['profile']);
39
+ if (!profileCoversCwd(entry, resolved)) {
40
+ throw new InputError({
41
+ error: 'not_covered',
42
+ message: `profile "${entry.manifest.name}" does not cover ${resolved}, so a default pinned here would be ignored.`,
43
+ next: `Widen its purview first: \`crtr profile project add ${entry.profileId} --dir ${resolved}\`, then re-run this.`,
44
+ });
45
+ }
46
+ setDefaultProfileId(cwd, entry.profileId);
47
+ return {
48
+ dir: resolved,
49
+ profile_id: entry.profileId,
50
+ name: entry.manifest.name,
51
+ follow_up: `Launches here now auto-pick "${entry.manifest.name}". Clear with \`crtr profile default clear\`, or reopen the chooser once with \`crtr --pick-profile\`.`,
52
+ };
53
+ },
54
+ });
55
+ const clearLeaf = defineLeaf({
56
+ name: 'clear',
57
+ description: "remove the current directory's default-profile pin",
58
+ whenToUse: 'you no longer want a fixed default here — go back to the normal startup chooser (MRU / covering menu). No-op if nothing was pinned.',
59
+ help: {
60
+ name: 'profile default clear',
61
+ summary: "remove the current directory's default-profile pin",
62
+ params: [],
63
+ output: [
64
+ { name: 'dir', type: 'string', required: true, constraint: 'The resolved directory the pin was cleared for.' },
65
+ { name: 'cleared', type: 'boolean', required: true, constraint: 'True when a pin existed and was removed; false when nothing was pinned.' },
66
+ { name: 'follow_up', type: 'string', required: true, constraint: 'Concrete next commands.' },
67
+ ],
68
+ outputKind: 'object',
69
+ effects: ['Deletes `default-profile.json` from the per-cwd workspace root, if present.'],
70
+ },
71
+ run: async (_input) => {
72
+ const cwd = process.cwd();
73
+ const resolved = resolveBindingCwd(cwd);
74
+ const had = getDefaultProfileId(cwd) !== null;
75
+ clearDefaultProfile(cwd);
76
+ return {
77
+ dir: resolved,
78
+ cleared: had,
79
+ follow_up: had
80
+ ? 'The startup chooser will ask again here. Re-pin with `crtr profile default set <profile>`.'
81
+ : 'Nothing was pinned here.',
82
+ };
83
+ },
84
+ });
85
+ const showLeaf = defineLeaf({
86
+ name: 'show',
87
+ description: "show the current directory's default-profile pin",
88
+ whenToUse: "you want to know which profile (if any) is pinned as this directory's default.",
89
+ help: {
90
+ name: 'profile default show',
91
+ summary: "report the current directory's pinned default profile, or none",
92
+ params: [],
93
+ output: [
94
+ { name: 'dir', type: 'string', required: true, constraint: 'The resolved directory queried.' },
95
+ { name: 'pinned', type: 'boolean', required: true, constraint: 'True when a default is pinned here.' },
96
+ { name: 'profile_id', type: 'string', required: false, constraint: 'The pinned profile id, when pinned.' },
97
+ { name: 'name', type: 'string', required: false, constraint: 'The pinned profile name, when it still resolves.' },
98
+ { name: 'stale', type: 'boolean', required: false, constraint: 'True when a pin exists but its profile no longer resolves (deleted); the selector will self-heal it on next launch.' },
99
+ { name: 'follow_up', type: 'string', required: true, constraint: 'Concrete next commands.' },
100
+ ],
101
+ outputKind: 'object',
102
+ effects: ['Read-only.'],
103
+ },
104
+ run: async (_input) => {
105
+ const cwd = process.cwd();
106
+ const resolved = resolveBindingCwd(cwd);
107
+ const id = getDefaultProfileId(cwd);
108
+ if (id === null) {
109
+ return {
110
+ dir: resolved,
111
+ pinned: false,
112
+ follow_up: 'Pin one with `crtr profile default set <profile>`.',
113
+ };
114
+ }
115
+ let name;
116
+ try {
117
+ name = loadProfileManifest(id).manifest.name;
118
+ }
119
+ catch {
120
+ name = undefined;
121
+ }
122
+ return {
123
+ dir: resolved,
124
+ pinned: true,
125
+ profile_id: id,
126
+ ...(name !== undefined ? { name } : { stale: true }),
127
+ follow_up: name !== undefined
128
+ ? 'Change it with `crtr profile default set <profile>`, or clear it with `crtr profile default clear`.'
129
+ : 'This profile no longer exists; clear the stale pin with `crtr profile default clear`.',
130
+ };
131
+ },
132
+ });
133
+ export const defaultBranch = defineBranch({
134
+ name: 'default',
135
+ description: "manage this directory's default-profile pin",
136
+ whenToUse: "setting, clearing, or inspecting which profile a directory defaults to at startup — so crtr stops asking. Directory-scoped, independent of a profile's project purview.",
137
+ help: {
138
+ name: 'profile default',
139
+ summary: "the per-directory default profile — pin one so the startup chooser stops asking",
140
+ model: "A pin binds the CURRENT directory to a profile: launches here auto-pick it with no prompt (`crtr --pick-profile` reopens the chooser once). `set` pins a profile (which must already cover this dir), `clear` removes the pin, `show` reports it. This is directory-scoped selection, NOT manifest state — independent of `crtr profile project` (a profile's purview). The startup menu's `d` action writes the same pin.",
141
+ },
142
+ children: [setLeaf, clearLeaf, showLeaf],
143
+ });
@@ -3,7 +3,7 @@ import { createProfile, profileRoot } from '../../core/profiles/manifest.js';
3
3
  export const newLeaf = defineLeaf({
4
4
  name: 'new',
5
5
  description: 'create a new profile',
6
- whenToUse: 'you are establishing a new agent identity — a stable id, a display name, and its own memory store. Pass at most one initial project directory here (the parser has no repeatable-flag support); use `profile add-project` afterward for every additional one.',
6
+ whenToUse: 'you are establishing a new agent identity — a stable id, a display name, and its own memory store. Pass at most one initial project directory here (the parser has no repeatable-flag support); use `profile project add` afterward for every additional one.',
7
7
  help: {
8
8
  name: 'profile new',
9
9
  summary: 'create a profile: `<profiles-root>/<slug>-<id>/` with a manifest + its own `memory/` store',
@@ -20,7 +20,7 @@ export const newLeaf = defineLeaf({
20
20
  name: 'project',
21
21
  type: 'path',
22
22
  required: false,
23
- constraint: 'One initial project directory. Must already exist and be a directory; resolved to its absolute real path before storing. Singular — add more with `crtr profile add-project` after creation.',
23
+ constraint: 'One initial project directory. Must already exist and be a directory; resolved to its absolute real path before storing. Singular — add more with `crtr profile project add` after creation.',
24
24
  },
25
25
  ],
26
26
  output: [
@@ -44,7 +44,7 @@ export const newLeaf = defineLeaf({
44
44
  path: profileRoot(profileId),
45
45
  projects: manifest.projects,
46
46
  created_at: manifest.created_at,
47
- follow_up: `Show it with \`crtr profile show ${profileId}\`, add another project with \`crtr profile add-project ${profileId} --dir <path>\`, or list every profile with \`crtr profile list\`.`,
47
+ follow_up: `Show it with \`crtr profile show ${profileId}\`, add another project with \`crtr profile project add ${profileId} --dir <path>\`, or list every profile with \`crtr profile list\`.`,
48
48
  };
49
49
  },
50
50
  });
@@ -0,0 +1,2 @@
1
+ import type { BranchDef } from '../../core/command.js';
2
+ export declare const projectBranch: BranchDef;
@@ -0,0 +1,97 @@
1
+ // `crtr profile project` — the project-purview sub-noun of `profile`. A
2
+ // profile's `projects` list is what it can SEE (memory + config resolution).
3
+ // These two verbs widen or narrow that list after creation; `crtr profile
4
+ // show` reads it back.
5
+ import { defineLeaf, defineBranch } from '../../core/command.js';
6
+ import { addProfileProject, removeProfileProject, loadProfileManifest } from '../../core/profiles/manifest.js';
7
+ const addLeaf = defineLeaf({
8
+ name: 'add',
9
+ description: "add a project directory to a profile's purview",
10
+ whenToUse: "a profile needs to see (memory + config from) another project directory — extend its purview after creation, including mid-session from a node already running under that profile.",
11
+ help: {
12
+ name: 'profile project add',
13
+ summary: "append a project directory to a profile's manifest, deduped by real path",
14
+ params: [
15
+ {
16
+ kind: 'positional',
17
+ name: 'profile',
18
+ required: true,
19
+ constraint: 'Exact profile id, or a unique manifest name.',
20
+ },
21
+ {
22
+ kind: 'flag',
23
+ name: 'dir',
24
+ type: 'path',
25
+ required: true,
26
+ constraint: 'Directory to add. Must exist and be a directory; resolved to its absolute real path before storing. A path already on the manifest is a no-op, not an error.',
27
+ },
28
+ ],
29
+ output: [
30
+ { name: 'profile_id', type: 'string', required: true, constraint: 'Stable directory id.' },
31
+ { name: 'projects', type: 'string[]', required: true, constraint: 'The updated, deduped project list in manifest order.' },
32
+ { name: 'follow_up', type: 'string', required: true, constraint: 'Concrete next commands.' },
33
+ ],
34
+ outputKind: 'object',
35
+ effects: ["Appends to the profile manifest's `projects` array. Invalidates the process scope cache."],
36
+ },
37
+ run: async (input) => {
38
+ const { profileId } = loadProfileManifest(input['profile']);
39
+ const { manifest } = addProfileProject(profileId, input['dir']);
40
+ return {
41
+ profile_id: profileId,
42
+ projects: manifest.projects,
43
+ follow_up: `Show the full manifest with \`crtr profile show ${profileId}\`.`,
44
+ };
45
+ },
46
+ });
47
+ const removeLeaf = defineLeaf({
48
+ name: 'remove',
49
+ description: "remove a project directory from a profile's purview",
50
+ whenToUse: "a profile no longer needs purview over one of its listed project directories — narrow it back. Works even if the directory itself was since deleted.",
51
+ help: {
52
+ name: 'profile project remove',
53
+ summary: "remove a project directory from a profile's manifest",
54
+ params: [
55
+ {
56
+ kind: 'positional',
57
+ name: 'profile',
58
+ required: true,
59
+ constraint: 'Exact profile id, or a unique manifest name.',
60
+ },
61
+ {
62
+ kind: 'flag',
63
+ name: 'dir',
64
+ type: 'path',
65
+ required: true,
66
+ constraint: 'Directory to remove, matched by its resolved real path against the manifest. Does not require the directory to still exist on disk.',
67
+ },
68
+ ],
69
+ output: [
70
+ { name: 'profile_id', type: 'string', required: true, constraint: 'Stable directory id.' },
71
+ { name: 'projects', type: 'string[]', required: true, constraint: 'The updated project list in manifest order.' },
72
+ { name: 'follow_up', type: 'string', required: true, constraint: 'Concrete next commands.' },
73
+ ],
74
+ outputKind: 'object',
75
+ effects: ["Removes an entry from the profile manifest's `projects` array. Invalidates the process scope cache."],
76
+ },
77
+ run: async (input) => {
78
+ const { profileId } = loadProfileManifest(input['profile']);
79
+ const { manifest } = removeProfileProject(profileId, input['dir']);
80
+ return {
81
+ profile_id: profileId,
82
+ projects: manifest.projects,
83
+ follow_up: `Show the full manifest with \`crtr profile show ${profileId}\`.`,
84
+ };
85
+ },
86
+ });
87
+ export const projectBranch = defineBranch({
88
+ name: 'project',
89
+ description: "widen or narrow a profile's project purview",
90
+ whenToUse: "changing which project directories a profile can see (its memory + config resolution) after creation — add or remove one.",
91
+ help: {
92
+ name: 'profile project',
93
+ summary: "manage a profile's project purview — the directories it resolves memory and config from",
94
+ model: "A profile's `projects` list is the set of directories it inherits memory and config from. `add` widens that purview by one directory (deduped by real path); `remove` narrows it. Read the current list with `crtr profile show`. Purview is independent of a per-cwd default pin (`crtr profile default`) — widening purview never changes which profile a directory defaults to.",
95
+ },
96
+ children: [addLeaf, removeLeaf],
97
+ });
@@ -44,7 +44,7 @@ export const showLeaf = defineLeaf({
44
44
  path: profileRoot(profileId),
45
45
  config_path: configPath,
46
46
  config_exists: existsSync(configPath),
47
- follow_up: `Add a project with \`crtr profile add-project ${profileId} --dir <path>\`, or rename with \`crtr profile rename ${profileId} --name <name>\`.`,
47
+ follow_up: `Add a project with \`crtr profile project add ${profileId} --dir <path>\`, or rename with \`crtr profile rename ${profileId} --name <name>\`.`,
48
48
  };
49
49
  },
50
50
  });
@@ -1,33 +1,32 @@
1
1
  // `crtr profile` subtree — the profile store (spec §2.1/§2.5): agent
2
2
  // identities defined at the user root, each owning a manifest naming project
3
- // directories plus its own memory store. Flat leaves; no sub-branches — 7
4
- // verbs fits the 5-7 hard cap.
3
+ // directories plus its own memory store.
5
4
  //
6
- // Every verb but `new`/`list` takes <profile> as an exact id OR a unique
7
- // manifest name (never two positionals: `add-project`/`remove-project` pair
8
- // <profile> with `--dir`, `rename` pairs it with `--name`, matching the
9
- // one-positional-per-leaf argv contract used across the whole tree).
5
+ // Five flat manifest verbs (new/list/show/rename/delete) plus two sub-nouns:
6
+ // `project` (the profile's project purview) and `default` (the per-cwd default
7
+ // pin). Seven children at the breadth cap. Every manifest verb but
8
+ // `new`/`list` takes <profile> as an exact id OR a unique manifest name.
10
9
  import { defineBranch } from '../core/command.js';
11
10
  import { newLeaf } from './profile/new.js';
12
11
  import { listLeaf } from './profile/list.js';
13
12
  import { showLeaf } from './profile/show.js';
14
- import { addProjectLeaf } from './profile/add-project.js';
15
- import { removeProjectLeaf } from './profile/remove-project.js';
16
13
  import { renameLeaf } from './profile/rename.js';
17
14
  import { deleteLeaf } from './profile/delete.js';
15
+ import { projectBranch } from './profile/project.js';
16
+ import { defaultBranch } from './profile/default.js';
18
17
  export function registerProfile() {
19
18
  return defineBranch({
20
19
  name: 'profile',
21
20
  rootEntry: {
22
21
  concept: 'an agent identity defined at the user root — a manifest naming project directories plus its own memory store',
23
22
  desc: 'create, inspect, and manage profiles',
24
- useWhen: 'creating a new agent identity, inspecting or listing existing ones, or changing a profile\u2019s project purview or display name. Selecting a profile for a running node (`--profile`, startup selection) lives elsewhere; this family only manages the manifest.',
23
+ useWhen: 'creating a new agent identity, inspecting or listing existing ones, changing a profile\u2019s project purview, or pinning which profile a directory defaults to at startup. Selecting a profile for a one-off run (`--profile`) lives elsewhere.',
25
24
  },
26
25
  help: {
27
26
  name: 'profile',
28
27
  summary: 'create, inspect, and manage profiles \u2014 agent identities with their own project purview and memory store',
29
- model: '`new` creates one from a name (+ at most one initial `--project`). `list` inventories every profile as JSONL. `show` prints one manifest in full. `add-project` / `remove-project` mutate the ordered project list after creation, including mid-session on an already-running profile. `rename` changes only the manifest name \u2014 the profile_id is stable across a rename. `delete` removes a profile\u2019s directory (manifest + its own memory store) entirely and does not touch the project directories it pointed at. Every verb but `new`/`list` takes <profile> as an exact id or a unique manifest name; an ambiguous name fails listing every matching id.',
28
+ model: '`new` creates one from a name (+ at most one initial `--project`). `list` inventories every profile as JSONL. `show` prints one manifest in full. `rename` changes only the manifest name \u2014 the profile_id is stable across a rename. `delete` removes a profile\u2019s directory (manifest + its own memory store) entirely and does not touch the project directories it pointed at. `project` widens/narrows a profile\u2019s project purview (the dirs it resolves memory + config from). `default` pins which profile a directory defaults to at startup so the chooser stops asking \u2014 directory-scoped, independent of purview. Every manifest verb but `new`/`list` takes <profile> as an exact id or a unique manifest name; an ambiguous name fails listing every matching id.',
30
29
  },
31
- children: [newLeaf, listLeaf, showLeaf, addProjectLeaf, removeProjectLeaf, renameLeaf, deleteLeaf],
30
+ children: [newLeaf, listLeaf, showLeaf, renameLeaf, deleteLeaf, projectBranch, defaultBranch],
32
31
  });
33
32
  }
@@ -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>;