@north-light/crouter 0.3.214 → 0.3.216

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 (99) hide show
  1. package/dist/api/dto/nodes.d.ts +2 -1
  2. package/dist/api/dto/profiles.d.ts +13 -1
  3. package/dist/builtin-memory/00-runtime-base.md +12 -11
  4. package/dist/builtin-memory/01-spine/00-has-manager.md +1 -11
  5. package/dist/builtin-memory/02-lifecycle/00-terminal.md +4 -10
  6. package/dist/builtin-memory/04-orchestration-kernel.md +9 -35
  7. package/dist/builtin-memory/05-kinds/advisor/{00-base.md → advice-contract.md} +1 -1
  8. package/dist/builtin-memory/05-kinds/design/00-base.md +2 -2
  9. package/dist/builtin-memory/05-kinds/design/01-orchestrator.md +1 -1
  10. package/dist/builtin-memory/05-kinds/developer/00-base.md +2 -2
  11. package/dist/builtin-memory/05-kinds/explore/00-base.md +1 -1
  12. package/dist/builtin-memory/05-kinds/general/00-base.md +2 -0
  13. package/dist/builtin-memory/05-kinds/plan/00-base.md +1 -1
  14. package/dist/builtin-memory/05-kinds/plan/reviewers/{00-base.md → lens-contract.md} +1 -1
  15. package/dist/builtin-memory/05-kinds/plan/reviewers/security.md +1 -1
  16. package/dist/builtin-memory/05-kinds/review/00-base.md +1 -3
  17. package/dist/builtin-memory/05-kinds/review/01-orchestrator.md +1 -1
  18. package/dist/builtin-memory/05-kinds/review/security-findings.md +12 -0
  19. package/dist/builtin-memory/05-kinds/spec/00-base.md +1 -1
  20. package/dist/clients/attach/__tests__/context-message.test.js +28 -1
  21. package/dist/clients/attach/chrome/canvas-panels.d.ts +0 -4
  22. package/dist/clients/attach/chrome/canvas-panels.js +5 -7
  23. package/dist/clients/attach/chrome/inbox-strip.d.ts +31 -0
  24. package/dist/clients/attach/chrome/inbox-strip.js +187 -0
  25. package/dist/clients/attach/chrome/roster.d.ts +6 -2
  26. package/dist/clients/attach/chrome/roster.js +13 -52
  27. package/dist/clients/attach/chrome/ticket-panel.d.ts +29 -0
  28. package/dist/clients/attach/chrome/ticket-panel.js +236 -0
  29. package/dist/clients/attach/render/card-presentation.d.ts +8 -3
  30. package/dist/clients/attach/render/card-presentation.js +48 -10
  31. package/dist/clients/attach/render/context-message.d.ts +5 -0
  32. package/dist/clients/attach/render/context-message.js +20 -14
  33. package/dist/clients/attach/session/context.d.ts +3 -0
  34. package/dist/clients/attach/session/frame.d.ts +4 -0
  35. package/dist/clients/attach/session/frame.js +8 -3
  36. package/dist/clients/attach/session/keys.d.ts +7 -0
  37. package/dist/clients/attach/session/keys.js +7 -0
  38. package/dist/clients/attach/session/layout.js +6 -3
  39. package/dist/clients/attach/session/pane-focus.d.ts +8 -0
  40. package/dist/clients/attach/session/pane-focus.js +42 -0
  41. package/dist/clients/attach/viewer.js +793 -789
  42. package/dist/clients/inbox/controller.d.ts +9 -0
  43. package/dist/clients/inbox/controller.js +57 -2
  44. package/dist/clients/inbox/surface.d.ts +2 -0
  45. package/dist/clients/inbox/surface.js +18 -3
  46. package/dist/clients/inbox/tui.d.ts +2 -0
  47. package/dist/clients/inbox/tui.js +11 -1
  48. package/dist/commands/node/create.js +3 -3
  49. package/dist/commands/profile/kind.d.ts +1 -0
  50. package/dist/commands/profile/kind.js +39 -0
  51. package/dist/commands/profile/list.js +5 -1
  52. package/dist/commands/profile/meta.d.ts +4 -0
  53. package/dist/commands/profile/meta.js +69 -0
  54. package/dist/commands/profile/new.js +34 -4
  55. package/dist/commands/profile/pause.d.ts +2 -0
  56. package/dist/commands/profile/pause.js +60 -0
  57. package/dist/commands/profile/show.js +10 -2
  58. package/dist/commands/profile.js +5 -8
  59. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +40 -0
  60. package/dist/core/__tests__/parse-argv-stdin-secret.test.js +28 -0
  61. package/dist/core/__tests__/seam/dormancy-release.test.js +7 -0
  62. package/dist/core/command.js +23 -23
  63. package/dist/core/config.d.ts +5 -0
  64. package/dist/core/config.js +15 -0
  65. package/dist/core/help.d.ts +1 -1
  66. package/dist/core/keybindings/attach-control.d.ts +3 -0
  67. package/dist/core/keybindings/attach-control.js +1 -0
  68. package/dist/core/keybindings/catalog.js +1 -0
  69. package/dist/core/profiles/deletion-reservation.d.ts +7 -3
  70. package/dist/core/profiles/deletion-reservation.js +9 -5
  71. package/dist/core/profiles/manifest.d.ts +39 -1
  72. package/dist/core/profiles/manifest.js +171 -4
  73. package/dist/core/runtime/boot-root.js +3 -3
  74. package/dist/core/runtime/close.js +10 -5
  75. package/dist/core/runtime/revive.js +2 -0
  76. package/dist/core/runtime/spawn-env.d.ts +9 -1
  77. package/dist/core/runtime/spawn-env.js +17 -1
  78. package/dist/core/termrender/termrender.d.ts +7 -2
  79. package/dist/core/termrender/termrender.js +11 -6
  80. package/dist/core/termrender/version.d.ts +1 -1
  81. package/dist/core/termrender/version.js +1 -1
  82. package/dist/daemon/api/__tests__/profile-launch-gates.test.d.ts +1 -0
  83. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +111 -0
  84. package/dist/daemon/api/handlers/messages.js +9 -2
  85. package/dist/daemon/api/handlers/nodes.d.ts +2 -0
  86. package/dist/daemon/api/handlers/nodes.js +15 -8
  87. package/dist/daemon/api/handlers/profiles.js +12 -2
  88. package/dist/daemon/api/map.js +4 -0
  89. package/dist/daemon/fleet.js +1 -6
  90. package/dist/daemon/reconcilers/broker-supervision.js +15 -25
  91. package/dist/daemon/reconcilers/dormant-inbox.js +10 -10
  92. package/dist/daemon/reconcilers/live-obligation.d.ts +5 -1
  93. package/dist/daemon/reconcilers/live-obligation.js +11 -19
  94. package/dist/pi-extensions/canvas-inbox-watcher.js +15 -0
  95. package/dist/types.d.ts +9 -0
  96. package/package.json +1 -1
  97. package/runtime.lock.json +2 -2
  98. package/dist/builtin-memory/01-spine/01-no-manager.md +0 -11
  99. package/dist/builtin-memory/05-kinds/general/01-orchestrator.md +0 -8
@@ -12,6 +12,8 @@ export interface InboxControllerOptions {
12
12
  scan?: () => TicketSummary[];
13
13
  /** Replaces the default synchronous rescan after inbox activity. */
14
14
  onInboxActivity?: () => void;
15
+ /** Ticket dir to seat the selection on at construction, when present. */
16
+ initialSelection?: string;
15
17
  }
16
18
  type Screen = 'list' | 'detail';
17
19
  /** Host-agnostic model for scanning, stable selection, claims, and rendering. */
@@ -50,9 +52,16 @@ export declare class InboxController {
50
52
  resize(cols: number, rows: number): void;
51
53
  render(): string[];
52
54
  handleKey(input: string, key: Key): void;
55
+ /** Settle the selected ticket from the list with what crouter already put on
56
+ * its summary — the agent's recommended option, or a notice's empty map. A
57
+ * row without a fast action says so rather than opening the page: the key
58
+ * means one thing, and the page is what Enter is for. */
59
+ private sendFastAction;
53
60
  private cancelFromInbox;
54
61
  activate(): void;
55
62
  private activateReview;
63
+ /** Seat the selection on a ticket dir, when present in the current list. */
64
+ selectDir(dir: string): void;
56
65
  reloadSelectedPage(): void;
57
66
  close(): void;
58
67
  private complete;
@@ -1,7 +1,7 @@
1
1
  import { watch } from 'node:fs';
2
2
  import { renderMarkdown } from '../../core/termrender/termrender.js';
3
3
  import { BOLD, CYAN, DIM, GRAY, RESET, YELLOW, clipLine } from './tui/ansi.js';
4
- import { buildInboxLines } from './tui.js';
4
+ import { buildInboxLines, recommendedLabel } from './tui.js';
5
5
  import { inboxLayout } from './layout.js';
6
6
  import { scanInbox } from '../../core/human/scan.js';
7
7
  import { resolvePageComponents } from '../../core/config.js';
@@ -42,6 +42,8 @@ export class InboxController {
42
42
  this.scan = options.scan ?? (() => scanInbox());
43
43
  this.cols = options.cols ?? 80;
44
44
  this.rows = options.rows ?? 24;
45
+ // Seeded before the first rescan, which preserves a selected dir it finds.
46
+ this.selectedDir = options.initialSelection;
45
47
  this.rescan();
46
48
  }
47
49
  snapshot() {
@@ -159,6 +161,11 @@ export class InboxController {
159
161
  this.markDirty();
160
162
  return;
161
163
  }
164
+ if (input === 's' || input === 'S') {
165
+ void this.sendFastAction();
166
+ this.markDirty();
167
+ return;
168
+ }
162
169
  if (key.escape || input === 'q') {
163
170
  this.close();
164
171
  return;
@@ -173,6 +180,43 @@ export class InboxController {
173
180
  this.activate();
174
181
  this.markDirty();
175
182
  }
183
+ /** Settle the selected ticket from the list with what crouter already put on
184
+ * its summary — the agent's recommended option, or a notice's empty map. A
185
+ * row without a fast action says so rather than opening the page: the key
186
+ * means one thing, and the page is what Enter is for. */
187
+ async sendFastAction() {
188
+ const item = this.items[this.selectedIndex];
189
+ if (item === undefined)
190
+ return;
191
+ if (item.kind !== 'page' || item.fastAction === undefined) {
192
+ this.status = 'no recommended answer on this ticket — Enter opens it';
193
+ return;
194
+ }
195
+ const claim = claimTicket(item.dir);
196
+ if (claim === null) {
197
+ this.status = 'ticket is being edited by another inbox';
198
+ return;
199
+ }
200
+ const settled = item.fastAction.kind === 'acknowledge'
201
+ ? 'acknowledged'
202
+ : `sent: ${recommendedLabel(item) ?? 'recommended answer'}`;
203
+ this.submittingDir = item.dir;
204
+ try {
205
+ await resolveTicketPage(item.dir, item.fastAction.responses);
206
+ this.status = settled;
207
+ }
208
+ catch (error) {
209
+ this.status = resolveFailureKind(error) === 'already_resolved'
210
+ ? canonicalResultStatus(readTicketResult(item.dir))
211
+ : daemonErrorMessage(error);
212
+ }
213
+ finally {
214
+ releaseClaim(item.dir, claim.token);
215
+ this.submittingDir = undefined;
216
+ this.rescan();
217
+ this.markDirty();
218
+ }
219
+ }
176
220
  async cancelFromInbox(dir) {
177
221
  this.submittingDir = dir;
178
222
  try {
@@ -247,6 +291,14 @@ export class InboxController {
247
291
  this.markDirty();
248
292
  }
249
293
  }
294
+ /** Seat the selection on a ticket dir, when present in the current list. */
295
+ selectDir(dir) {
296
+ const index = this.items.findIndex((item) => item.dir === dir);
297
+ if (index >= 0) {
298
+ this.select(index);
299
+ this.markDirty();
300
+ }
301
+ }
250
302
  reloadSelectedPage() {
251
303
  if (this.adapter === undefined || this.claim === undefined) {
252
304
  this.markDirty();
@@ -350,8 +402,11 @@ export class InboxController {
350
402
  if (selected === undefined)
351
403
  return this.passiveDetailLines(width).slice(0, rows);
352
404
  const focusHint = this.focusAvailable(selected.dir) ? ` ${DIM}g${RESET} chat` : '';
405
+ const sendHint = selected.kind === 'page' && selected.fastAction !== undefined
406
+ ? ` ${DIM}s${RESET} ${selected.fastAction.kind === 'acknowledge' ? 'acknowledge' : 'send recommended'}`
407
+ : '';
353
408
  const footer = selected.kind === 'page'
354
- ? [` ${DIM}Enter${RESET} opens the full page ${DIM}j/k${RESET} select ${DIM}x${RESET} cancel`, ` ${DIM}Page:${RESET} c comment u/d scroll${focusHint} ${DIM}q${RESET} close`]
409
+ ? [` ${DIM}Enter${RESET} opens the full page ${DIM}j/k${RESET} select${sendHint} ${DIM}x${RESET} cancel`, ` ${DIM}Page:${RESET} c comment u/d scroll${focusHint} ${DIM}q${RESET} close`]
355
410
  : [` ${DIM}Enter${RESET} opens the full review ${DIM}j/k${RESET} select ${DIM}x${RESET} cancel`, ` ${focusHint} ${DIM}q${RESET} close`];
356
411
  const preview = this.passiveDetailLines(width).slice(0, Math.max(0, rows - footer.length));
357
412
  return [...preview, ...footer.map((line) => clipLine(line, width))];
@@ -8,6 +8,8 @@ export interface InboxSurfaceOptions {
8
8
  export interface InboxSurfaceOwner {
9
9
  /** Mount when closed and close when open. */
10
10
  toggle(): void;
11
+ /** Mount if closed; seat the selection on `preselectDir` when given. */
12
+ open(preselectDir?: string): void;
11
13
  /** Idempotent surface and controller teardown. */
12
14
  close(): void;
13
15
  isOpen(): boolean;
@@ -69,6 +69,7 @@ export function createInboxSurface(opts) {
69
69
  onClosed: close,
70
70
  scan: () => filterVisibleReviewTickets(localScan(), current.visibleIds),
71
71
  onInboxActivity: () => refresh(current),
72
+ ...(current.preselectDir === undefined ? {} : { initialSelection: current.preselectDir }),
72
73
  });
73
74
  const component = new InboxComponent(nextController);
74
75
  current.controller = nextController;
@@ -105,21 +106,35 @@ export function createInboxSurface(opts) {
105
106
  opts.host.notice(`Could not load inbox visibility: ${error instanceof Error ? error.message : String(error)}`);
106
107
  });
107
108
  };
108
- const toggle = () => {
109
+ const open = (preselectDir) => {
109
110
  if (session !== undefined) {
110
- close();
111
+ if (preselectDir !== undefined)
112
+ session.controller?.selectDir(preselectDir);
111
113
  return;
112
114
  }
113
115
  if (opts.host.capabilities.remote) {
114
116
  opts.host.notice('The inbox is unavailable in a remote attach.');
115
117
  return;
116
118
  }
117
- const current = { closed: false, refreshGeneration: 0, visibleIds: new Set() };
119
+ const current = {
120
+ closed: false,
121
+ refreshGeneration: 0,
122
+ visibleIds: new Set(),
123
+ ...(preselectDir === undefined ? {} : { preselectDir }),
124
+ };
118
125
  session = current;
119
126
  refresh(current, true);
120
127
  };
128
+ const toggle = () => {
129
+ if (session !== undefined) {
130
+ close();
131
+ return;
132
+ }
133
+ open();
134
+ };
121
135
  return {
122
136
  toggle,
137
+ open,
123
138
  close,
124
139
  isOpen: () => session !== undefined,
125
140
  };
@@ -4,3 +4,5 @@ export declare const KIND_COLOR: Record<string, string>;
4
4
  export declare function formatTimeAgo(iso: string): string;
5
5
  /** Pure inbox rows; selection and terminal ownership belong to InboxController. */
6
6
  export declare function buildInboxLines(items: TicketSummary[], width: number, selectedIndex: number, height?: number): string[];
7
+ /** What the agent suggests, for a row that says so without opening the page. */
8
+ export declare function recommendedLabel(item: TicketSummary): string | undefined;
@@ -46,7 +46,17 @@ function buildItemLines(item, index, selectedIndex, contentWidth) {
46
46
  const titleWidth = Math.max(1, contentWidth - age.length - 6);
47
47
  const row = `${cursor}${ansiColor(icon, KIND_COLOR[kind] ?? 'cyan')} ${BOLD}${truncateRow(item.title || `(${item.id.slice(0, 8)})`, titleWidth)}${RESET} ${DIM}${age}${RESET}`;
48
48
  const subtitle = item.claim ? `claimed by ${item.claim.owner} · ${item.subtitle}` : item.subtitle;
49
- return [row, ` ${DIM}${truncateRow(subtitle, contentWidth - 6)}${RESET}`];
49
+ const lines = [row, ` ${DIM}${truncateRow(subtitle, contentWidth - 6)}${RESET}`];
50
+ const recommended = recommendedLabel(item);
51
+ if (recommended !== undefined)
52
+ lines.push(` ${DIM}Recommended: ${truncateRow(recommended, Math.max(1, contentWidth - 19))}${RESET}`);
53
+ return lines;
54
+ }
55
+ /** What the agent suggests, for a row that says so without opening the page. */
56
+ export function recommendedLabel(item) {
57
+ if (item.kind !== 'page' || item.recommendedOptions.length === 0)
58
+ return undefined;
59
+ return item.recommendedOptions.map((option) => option.label).join(' · ');
50
60
  }
51
61
  /** Select a contiguous row window that always contains the selected ticket. */
52
62
  function visibleWindow(rows, selectedIndex, capacity) {
@@ -69,7 +69,7 @@ export function kindsStateBlock() {
69
69
  function nodeNewParams() {
70
70
  return [
71
71
  { kind: 'stdin', name: 'prompt', required: false, constraint: 'First user message. Required unless --no-kickoff is given.' },
72
- { kind: 'flag', name: 'kind', type: 'string', required: false, default: 'general', constraint: 'Persona kind. The <kinds> list below names every top-level installable kind.' },
72
+ { kind: 'flag', name: 'kind', type: 'string', required: false, constraint: "Persona kind. Defaults to the profile's default kind; `general` when the profile has none. The <kinds> list below names every top-level installable kind." },
73
73
  { kind: 'flag', name: 'mode', type: 'enum', choices: ['base', 'orchestrator'], required: false, default: 'base', constraint: 'Persona mode. base for hands-on work; orchestrator when the unit itself needs decomposition across children.' },
74
74
  { kind: 'flag', name: 'cwd', type: 'path', required: false, constraint: 'Pin the node to this directory. Defaults to the spawner\u2019s directory for a managed child, and to the directory this command runs in for a root.' },
75
75
  { kind: 'flag', name: 'name', type: 'string', required: false, constraint: 'Display name. Defaults to the kind.' },
@@ -104,7 +104,7 @@ async function runNodeCreation(input) {
104
104
  if (noKickoff && prompt.trim() !== '') {
105
105
  throw new InputError({ error: 'no_kickoff_with_prompt', message: '--no-kickoff cannot be combined with a prompt', field: 'no-kickoff', next: 'Drop --no-kickoff, or drop the prompt.' });
106
106
  }
107
- const kind = input['kind'] ?? 'general';
107
+ const kind = input['kind'];
108
108
  const mode = (input['mode'] ?? 'base');
109
109
  const pinCwd = input['cwd']?.trim();
110
110
  const name = input['name'];
@@ -138,7 +138,7 @@ async function runNodeCreation(input) {
138
138
  const parentId = parent ?? process.env['CRTR_NODE_ID'];
139
139
  mark('node_new.create_start', { root });
140
140
  const detail = await cliClient().createNode({
141
- kind,
141
+ ...(kind !== undefined ? { kind } : {}),
142
142
  mode,
143
143
  // The caller's directory is the profile-SELECTION context; where the node
144
144
  // actually lands is resolved server-side (pin > spawner's cwd > profile
@@ -0,0 +1 @@
1
+ export declare const kindLeaf: import("../../core/command.js").LeafDef;
@@ -0,0 +1,39 @@
1
+ import { defineLeaf } from '../../core/command.js';
2
+ import { assertInstalledKind, readMergedLaunchConfig } from '../../core/config.js';
3
+ import { loadProfileManifest, setProfileDefaultKind } from '../../core/profiles/manifest.js';
4
+ import { stateBlock } from '../../core/help.js';
5
+ function kindsStateBlock() {
6
+ return stateBlock('kinds', { count: Object.keys(readMergedLaunchConfig().kinds).length }, Object.keys(readMergedLaunchConfig().kinds).sort().join('\n'));
7
+ }
8
+ export const kindLeaf = defineLeaf({
9
+ name: 'kind',
10
+ description: "set a profile's default node kind",
11
+ whenToUse: 'nodes created under a profile without `--kind` should use a persona other than `general`.',
12
+ help: {
13
+ name: 'profile kind',
14
+ summary: "set a profile's manifest `default_kind` for creates that omit --kind",
15
+ params: [
16
+ { kind: 'positional', name: 'profile', required: true, constraint: 'Exact profile id, or a unique manifest name.' },
17
+ { kind: 'positional', name: 'kind', required: true, constraint: 'Installed persona kind to use by default. The live <kinds> list below is the accepted value set.' },
18
+ ],
19
+ output: [
20
+ { name: 'profile_id', type: 'string', required: true, constraint: 'Stable directory id.' },
21
+ { name: 'default_kind', type: 'string', required: true, constraint: 'The installed kind stored for creates that omit --kind.' },
22
+ { name: 'follow_up', type: 'string', required: true, constraint: 'Concrete next command.' },
23
+ ],
24
+ dynamicState: kindsStateBlock,
25
+ outputKind: 'object',
26
+ effects: ["Sets the profile manifest's `default_kind`. Future node creates under the profile that omit --kind resolve to it server-side."],
27
+ },
28
+ run: async (input) => {
29
+ const kind = input['kind'];
30
+ assertInstalledKind(kind);
31
+ const { profileId } = loadProfileManifest(input['profile']);
32
+ const { manifest } = setProfileDefaultKind(profileId, kind);
33
+ return {
34
+ profile_id: profileId,
35
+ default_kind: manifest.default_kind,
36
+ follow_up: `Create a node under it without --kind, or inspect it with \`crtr profile show ${profileId}\`.`,
37
+ };
38
+ },
39
+ });
@@ -4,7 +4,7 @@ import { listProfiles } from '../../core/profiles/manifest.js';
4
4
  export const listLeaf = defineLeaf({
5
5
  name: 'list',
6
6
  description: 'inventory every profile',
7
- whenToUse: 'browse every profile that exists — one line each with its id, name, project count, home directory, and last-used time. Reach for `profile show` once you have picked one, for its full project list.',
7
+ whenToUse: 'browse every profile that exists — one line each with its id, name, project count, home directory, paused state, and last-used time. Reach for `profile show` once you have picked one, for its full manifest.',
8
8
  help: {
9
9
  name: 'profile list',
10
10
  summary: 'every profile as JSONL, one line each, sorted by name then profile_id',
@@ -13,6 +13,8 @@ export const listLeaf = defineLeaf({
13
13
  { name: 'name', type: 'string', required: true, constraint: 'Manifest name.' },
14
14
  { name: 'projects_count', type: 'int', required: true, constraint: 'Number of project directories on the manifest.' },
15
15
  { name: 'home', type: 'string | null', required: true, constraint: 'Directory the profile\u2019s nodes are pinned to, or null when it owns no projects.' },
16
+ { name: 'paused_at', type: 'string | null', required: true, constraint: 'ISO 8601 pause timestamp, or null when the profile is active.' },
17
+ { name: 'default_kind', type: 'string | null', required: true, constraint: 'Stored default node kind, or null when creates default to general.' },
16
18
  { name: 'last_used_at', type: 'string | null', required: true, constraint: 'ISO 8601 timestamp of the most recent selection, or null if never selected.' },
17
19
  ],
18
20
  outputKind: 'jsonl',
@@ -29,6 +31,8 @@ export const listLeaf = defineLeaf({
29
31
  name: manifest.name,
30
32
  projects_count: manifest.projects.length,
31
33
  home: manifest.home,
34
+ paused_at: manifest.paused_at,
35
+ default_kind: manifest.default_kind ?? null,
32
36
  last_used_at: manifest.last_used_at,
33
37
  });
34
38
  }
@@ -0,0 +1,4 @@
1
+ /** Fold `KEY=VALUE` flag repetitions into one map. Key-shape validation stays
2
+ * with the manifest store (`assertProfileMetadata`); this only splits. */
3
+ export declare function resolveMetaPairs(raw: unknown, field: string): Record<string, string>;
4
+ export declare const metaLeaf: import("../../core/command.js").LeafDef;
@@ -0,0 +1,69 @@
1
+ import { defineLeaf } from '../../core/command.js';
2
+ import { InputError } from '../../core/io.js';
3
+ import { loadProfileManifest, updateProfileMetadata } from '../../core/profiles/manifest.js';
4
+ /** Fold `KEY=VALUE` flag repetitions into one map. Key-shape validation stays
5
+ * with the manifest store (`assertProfileMetadata`); this only splits. */
6
+ export function resolveMetaPairs(raw, field) {
7
+ const pairs = Array.isArray(raw) ? raw.map(String) : typeof raw === 'string' && raw !== '' ? [raw] : [];
8
+ // Null prototype: on a plain object a `__proto__` key would silently
9
+ // vanish here instead of reaching the store's key-shape rejection.
10
+ const out = Object.create(null);
11
+ for (const pair of pairs) {
12
+ const eq = pair.indexOf('=');
13
+ if (eq <= 0) {
14
+ throw new InputError({
15
+ error: 'bad_meta',
16
+ message: `"${pair}" is not KEY=VALUE`,
17
+ field,
18
+ next: `Pass --${field} KEY=VALUE (repeat the flag per entry).`,
19
+ });
20
+ }
21
+ out[pair.slice(0, eq)] = pair.slice(eq + 1);
22
+ }
23
+ return out;
24
+ }
25
+ function resolveNames(raw) {
26
+ return Array.isArray(raw) ? raw.map(String) : typeof raw === 'string' && raw !== '' ? [raw] : [];
27
+ }
28
+ export const metaLeaf = defineLeaf({
29
+ name: 'meta',
30
+ description: "set or remove entries in a profile's metadata map",
31
+ whenToUse: 'a profile should carry (or stop carrying) identity facts — a display name, role, description — that every node launched under it reads from its env as `CRTR_PROFILE_META_<KEY>`. For secrets, use `profile env` instead: metadata is readable back from every projection.',
32
+ help: {
33
+ name: 'profile meta',
34
+ summary: "merge entries into (or drop entries from) a profile manifest's `metadata` map",
35
+ params: [
36
+ { kind: 'positional', name: 'profile', required: true, constraint: 'Exact profile id, or a unique manifest name.' },
37
+ { kind: 'flag', name: 'set', type: 'string', required: false, repeatable: true, constraint: 'KEY=VALUE to merge over the stored map (repeat per entry). KEY starts with a letter or digit, followed by letters, digits, `_`, or `-`; an existing key is overwritten.' },
38
+ { kind: 'flag', name: 'unset', type: 'string', required: false, repeatable: true, constraint: 'KEY to remove (repeat per entry). Removing an absent key is a no-op.' },
39
+ ],
40
+ output: [
41
+ { name: 'profile_id', type: 'string', required: true, constraint: 'Stable directory id.' },
42
+ { name: 'metadata', type: 'object', required: true, constraint: 'The full resulting metadata map.' },
43
+ { name: 'follow_up', type: 'string', required: true, constraint: 'Concrete next command.' },
44
+ ],
45
+ outputKind: 'object',
46
+ effects: [
47
+ "Rewrites the profile manifest's `metadata` map (omitted entirely when the result is empty).",
48
+ 'Every broker launched under this profile from now on sees each entry as `CRTR_PROFILE_META_<KEY>` (key uppercased, non-alphanumeric runs → `_`). Already-running nodes pick up changes on their next revive.',
49
+ ],
50
+ },
51
+ run: async (input) => {
52
+ const set = resolveMetaPairs(input['set'], 'set');
53
+ const unset = resolveNames(input['unset']);
54
+ if (Object.keys(set).length === 0 && unset.length === 0) {
55
+ throw new InputError({
56
+ error: 'nothing_to_do',
57
+ message: 'pass at least one --set KEY=VALUE or --unset KEY',
58
+ next: 'Repeat --set/--unset per entry.',
59
+ });
60
+ }
61
+ const { profileId } = loadProfileManifest(input['profile']);
62
+ const { manifest } = updateProfileMetadata(profileId, set, unset);
63
+ return {
64
+ profile_id: profileId,
65
+ metadata: manifest.metadata ?? {},
66
+ follow_up: `Inspect it with \`crtr profile show ${profileId}\`.`,
67
+ };
68
+ },
69
+ });
@@ -1,6 +1,8 @@
1
1
  import { defineLeaf } from '../../core/command.js';
2
2
  import { usage } from '../../core/errors.js';
3
+ import { assertInstalledKind } from '../../core/config.js';
3
4
  import { createProfile, ensureRootProfile, profileRoot } from '../../core/profiles/manifest.js';
5
+ import { resolveMetaPairs } from './meta.js';
4
6
  export const newLeaf = defineLeaf({
5
7
  name: 'new',
6
8
  description: 'create a new profile',
@@ -21,7 +23,7 @@ export const newLeaf = defineLeaf({
21
23
  name: 'root',
22
24
  type: 'bool',
23
25
  required: false,
24
- constraint: 'Provision the canonical stable root profile (fixed id `root-00000000`, name `root`) — the fallback default identity for a home — instead of a fresh named one. Idempotent: re-running returns the existing root. Node-free (no `node new --root` needed). Mutually exclusive with `--name`/`--project`.',
26
+ constraint: 'Provision the canonical stable root profile (fixed id `root-00000000`, name `root`) — the fallback default identity for a home — instead of a fresh named one. Idempotent: re-running returns the existing root. Node-free (no `node new --root` needed). Mutually exclusive with `--name`/`--project`/`--default-kind`/`--meta`.',
25
27
  },
26
28
  {
27
29
  kind: 'flag',
@@ -30,6 +32,21 @@ export const newLeaf = defineLeaf({
30
32
  required: false,
31
33
  constraint: "One initial project directory. Must already exist and be a directory; resolved to its absolute real path before storing. Also becomes the profile's home — where its nodes are pinned. Singular — add more with `crtr profile project add` after creation.",
32
34
  },
35
+ {
36
+ kind: 'flag',
37
+ name: 'default-kind',
38
+ type: 'string',
39
+ required: false,
40
+ constraint: 'Installed persona kind for node creates under this profile that omit --kind. Defaults to general when omitted; validated against the installed kind registry.',
41
+ },
42
+ {
43
+ kind: 'flag',
44
+ name: 'meta',
45
+ type: 'string',
46
+ required: false,
47
+ repeatable: true,
48
+ constraint: 'KEY=VALUE metadata entry (repeat per entry). KEY starts with a letter or digit, followed by letters, digits, `_`, or `-`. Each entry reaches every broker launched under the profile as `CRTR_PROFILE_META_<KEY>` env; readable back via `profile show` — not for secrets (those go to `profile env`).',
49
+ },
33
50
  ],
34
51
  output: [
35
52
  { name: 'profile_id', type: 'string', required: true, constraint: 'Stable directory id (`<slug>-<shortid>`). Pass this, or the name, to every other `profile` verb and to `--profile` elsewhere.' },
@@ -37,6 +54,8 @@ export const newLeaf = defineLeaf({
37
54
  { name: 'path', type: 'string', required: true, constraint: 'Absolute path to the profile directory.' },
38
55
  { name: 'projects', type: 'string[]', required: true, constraint: 'Resolved absolute project directories, in manifest order. Empty when --project was omitted.' },
39
56
  { name: 'home', type: 'string | null', required: true, constraint: 'Directory the profile\u2019s nodes are pinned to — the initial project, or null when none was given.' },
57
+ { name: 'default_kind', type: 'string | null', required: true, constraint: 'Stored default persona kind, or null when node creates default to general.' },
58
+ { name: 'metadata', type: 'object', required: true, constraint: 'Stored metadata map; empty when --meta was omitted.' },
40
59
  { name: 'created_at', type: 'string', required: true, constraint: 'ISO 8601 creation timestamp.' },
41
60
  { name: 'follow_up', type: 'string', required: true, constraint: 'Concrete next commands.' },
42
61
  ],
@@ -46,10 +65,12 @@ export const newLeaf = defineLeaf({
46
65
  run: async (input) => {
47
66
  const name = input['name'];
48
67
  const project = input['project'];
68
+ const defaultKind = input['defaultKind'];
69
+ const metadata = resolveMetaPairs(input['meta'], 'meta');
49
70
  const root = input['root'] === true;
50
71
  if (root) {
51
- if (name !== undefined || project !== undefined) {
52
- throw usage('`--root` provisions the canonical stable root profile and takes no `--name`/`--project`.');
72
+ if (name !== undefined || project !== undefined || defaultKind !== undefined || Object.keys(metadata).length > 0) {
73
+ throw usage('`--root` provisions the canonical stable root profile and takes no `--name`/`--project`/`--default-kind`/`--meta`.');
53
74
  }
54
75
  const { profileId, manifest } = ensureRootProfile();
55
76
  return {
@@ -58,19 +79,28 @@ export const newLeaf = defineLeaf({
58
79
  path: profileRoot(profileId),
59
80
  projects: manifest.projects,
60
81
  home: manifest.home,
82
+ default_kind: manifest.default_kind ?? null,
83
+ metadata: manifest.metadata ?? {},
61
84
  created_at: manifest.created_at,
62
85
  follow_up: `Root profile ensured. Spawn nodes under it with \`--profile ${profileId}\`, or widen its purview with \`crtr profile project add ${profileId} --dir <path>\`.`,
63
86
  };
64
87
  }
65
88
  if (name === undefined)
66
89
  throw usage('provide `--name <name>` to create a profile, or `--root` to provision the stable root profile.');
67
- const { profileId, manifest } = createProfile(name, project !== undefined ? [project] : []);
90
+ if (defaultKind !== undefined)
91
+ assertInstalledKind(defaultKind);
92
+ const { profileId, manifest } = createProfile(name, project !== undefined ? [project] : [], {
93
+ defaultKind,
94
+ ...(Object.keys(metadata).length > 0 ? { metadata } : {}),
95
+ });
68
96
  return {
69
97
  profile_id: profileId,
70
98
  name: manifest.name,
71
99
  path: profileRoot(profileId),
72
100
  projects: manifest.projects,
73
101
  home: manifest.home,
102
+ default_kind: manifest.default_kind ?? null,
103
+ metadata: manifest.metadata ?? {},
74
104
  created_at: manifest.created_at,
75
105
  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\`.`,
76
106
  };
@@ -0,0 +1,2 @@
1
+ export declare const pauseLeaf: import("../../core/command.js").LeafDef;
2
+ export declare const resumeLeaf: import("../../core/command.js").LeafDef;
@@ -0,0 +1,60 @@
1
+ import { defineLeaf } from '../../core/command.js';
2
+ import { loadProfileManifest, pauseProfile, resumeProfile } from '../../core/profiles/manifest.js';
3
+ const profileParam = {
4
+ kind: 'positional',
5
+ name: 'profile',
6
+ required: true,
7
+ constraint: 'Exact profile id, or a unique manifest name.',
8
+ };
9
+ export const pauseLeaf = defineLeaf({
10
+ name: 'pause',
11
+ description: 'make a profile inert',
12
+ whenToUse: 'new nodes and revives under a profile must stop while its running broker is allowed to finish its current turn.',
13
+ help: {
14
+ name: 'profile pause',
15
+ summary: 'pause a profile so it creates and revives no nodes',
16
+ params: [profileParam],
17
+ output: [
18
+ { name: 'profile_id', type: 'string', required: true, constraint: 'Stable directory id.' },
19
+ { name: 'paused_at', type: 'string', required: true, constraint: 'ISO 8601 timestamp when the profile became inert.' },
20
+ { name: 'follow_up', type: 'string', required: true, constraint: 'Concrete resume command.' },
21
+ ],
22
+ outputKind: 'object',
23
+ effects: ['Writes `paused_at` to the profile manifest. Existing brokers continue their current turn; queued inbox messages do not revive nodes until resume.'],
24
+ },
25
+ run: async (input) => {
26
+ const { profileId } = loadProfileManifest(input['profile']);
27
+ const { manifest } = pauseProfile(profileId);
28
+ return {
29
+ profile_id: profileId,
30
+ paused_at: manifest.paused_at,
31
+ follow_up: `Resume it with \`crtr profile resume ${profileId}\`.`,
32
+ };
33
+ },
34
+ });
35
+ export const resumeLeaf = defineLeaf({
36
+ name: 'resume',
37
+ description: 'make a paused profile active',
38
+ whenToUse: 'nodes under a paused profile may create and revive again, including queued inbox delivery.',
39
+ help: {
40
+ name: 'profile resume',
41
+ summary: 'resume a profile so it may create and revive nodes again',
42
+ params: [profileParam],
43
+ output: [
44
+ { name: 'profile_id', type: 'string', required: true, constraint: 'Stable directory id.' },
45
+ { name: 'paused_at', type: 'null', required: true, constraint: 'Always null after resume; null means active.' },
46
+ { name: 'follow_up', type: 'string', required: true, constraint: 'Concrete next command.' },
47
+ ],
48
+ outputKind: 'object',
49
+ effects: ['Clears `paused_at` in the profile manifest. Future creates and revives are allowed.'],
50
+ },
51
+ run: async (input) => {
52
+ const { profileId } = loadProfileManifest(input['profile']);
53
+ const { manifest } = resumeProfile(profileId);
54
+ return {
55
+ profile_id: profileId,
56
+ paused_at: manifest.paused_at,
57
+ follow_up: `Inspect it with \`crtr profile show ${profileId}\`.`,
58
+ };
59
+ },
60
+ });
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { defineLeaf } from '../../core/command.js';
4
- import { loadProfileManifest, profileRoot } from '../../core/profiles/manifest.js';
4
+ import { loadProfileManifest, profileRoot, sanitizeProfileMetadata } from '../../core/profiles/manifest.js';
5
5
  import { CONFIG_FILE } from '../../types.js';
6
6
  export const showLeaf = defineLeaf({
7
7
  name: 'show',
@@ -23,6 +23,9 @@ export const showLeaf = defineLeaf({
23
23
  { name: 'name', type: 'string', required: true, constraint: 'Manifest name.' },
24
24
  { name: 'projects', type: 'string[]', required: true, constraint: 'Absolute project directories, in manifest order.' },
25
25
  { name: 'home', type: 'string | null', required: true, constraint: 'Where a node under this profile runs when its create names no directory at all — an explicit --cwd and the directory a root is launched from both win over it. Defaults to the first project; re-point it with `profile project add --home`. Null when the profile owns no projects (the root profile).' },
26
+ { name: 'paused_at', type: 'string | null', required: true, constraint: 'ISO 8601 pause timestamp, or null when the profile is active.' },
27
+ { name: 'default_kind', type: 'string | null', required: true, constraint: 'Stored default node kind, or null when creates default to general.' },
28
+ { name: 'metadata', type: 'object', required: true, constraint: 'Stored metadata map — each entry reaches brokers launched under the profile as `CRTR_PROFILE_META_<KEY>` env. Empty when none.' },
26
29
  { name: 'created_at', type: 'string', required: true, constraint: 'ISO 8601 creation timestamp.' },
27
30
  { name: 'last_used_at', type: 'string | null', required: true, constraint: 'ISO 8601 timestamp of the most recent selection, or null if never selected.' },
28
31
  { name: 'path', type: 'string', required: true, constraint: 'Absolute path to the profile directory.' },
@@ -41,12 +44,17 @@ export const showLeaf = defineLeaf({
41
44
  name: manifest.name,
42
45
  projects: manifest.projects,
43
46
  home: manifest.home,
47
+ paused_at: manifest.paused_at,
48
+ default_kind: manifest.default_kind ?? null,
49
+ metadata: sanitizeProfileMetadata(manifest.metadata),
44
50
  created_at: manifest.created_at,
45
51
  last_used_at: manifest.last_used_at,
46
52
  path: profileRoot(profileId),
47
53
  config_path: configPath,
48
54
  config_exists: existsSync(configPath),
49
- follow_up: `Add a project with \`crtr profile project add ${profileId} --dir <path>\`, or rename with \`crtr profile rename ${profileId} --name <name>\`.`,
55
+ follow_up: manifest.paused_at === null
56
+ ? `Pause it with \`crtr profile pause ${profileId}\`, or set its default kind with \`crtr profile kind ${profileId} <kind>\`.`
57
+ : `Resume it with \`crtr profile resume ${profileId}\`.`,
50
58
  };
51
59
  },
52
60
  });
@@ -2,12 +2,6 @@
2
2
  // identities defined at the user root, each owning a manifest naming project
3
3
  // directories plus its own memory store.
4
4
  //
5
- // Five flat manifest verbs (new/list/show/rename/delete) plus three sub-nouns:
6
- // `project` (the profile's project purview), `default` (the per-cwd default
7
- // pin), and `env` (a profile-scoped store of env vars injected directly into
8
- // its brokers). Eight children — past the 5-7 soft cap, but `env` is a
9
- // coherent, self-contained sub-noun with no overlap against the existing six,
10
- // so it stays flat rather than inventing a deeper nest for one addition.
11
5
  // Every manifest verb but `new`/`list` takes <profile> as an exact id OR a
12
6
  // unique manifest name.
13
7
  import { defineBranch } from '../core/command.js';
@@ -16,6 +10,9 @@ import { listLeaf } from './profile/list.js';
16
10
  import { showLeaf } from './profile/show.js';
17
11
  import { renameLeaf } from './profile/rename.js';
18
12
  import { deleteLeaf } from './profile/delete.js';
13
+ import { pauseLeaf, resumeLeaf } from './profile/pause.js';
14
+ import { kindLeaf } from './profile/kind.js';
15
+ import { metaLeaf } from './profile/meta.js';
19
16
  import { projectBranch } from './profile/project.js';
20
17
  import { defaultBranch } from './profile/default.js';
21
18
  import { envBranch } from './profile/env.js';
@@ -30,8 +27,8 @@ export function registerProfile() {
30
27
  help: {
31
28
  name: 'profile',
32
29
  summary: 'create, inspect, and manage profiles \u2014 agent identities with their own project purview and memory store',
33
- model: 'A profile owns a `home` — the first of its project dirs unless `project add --home` re-points it — which is where a node under the profile runs when its create names no directory at all. A node lands in the directory it was launched from when there is one: `node new --cwd`, an inherited spawner directory (notably inside a managed worktree), or the directory a root was started in. So one profile can cover many working directories — cwd selects the identity, and the launch decides where the work happens. `new` creates one from a name (+ at most one initial `--project`, which becomes its home). `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 --force` removes the profile after cascading through its nodes, matching schedules, originating inbox/review state, and default pins; `--detach --force` instead preserves nodes and human history while clearing the profile identity and globalizing its schedules. Both modes block while a matching node owns an open managed worktree and never touch the project directories the profile 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. `env` manages a profile-scoped 0600 store of env vars injected directly into every broker launched under that profile \u2014 set on stdin, list names only (never values), or remove one; no `spawnEnv.allow` entry needed. 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
+ model: 'A profile owns a `home` — the first of its project dirs unless `project add --home` re-points it — which is where a node under the profile runs when its create names no directory at all. A node lands in the directory it was launched from when there is one: `node new --cwd`, an inherited spawner directory (notably inside a managed worktree), or the directory a root was started in. So one profile can cover many working directories — cwd selects the identity, and the launch decides where the work happens. `new` creates one from a name (+ at most one initial `--project`, which becomes its home). `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 --force` removes the profile after cascading through its nodes, matching schedules, originating inbox/review state, and default pins; `--detach --force` instead preserves nodes and human history while clearing the profile identity and globalizing its schedules. Both modes block while a matching node owns an open managed worktree and never touch the project directories the profile 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. `env` manages a profile-scoped 0600 store of env vars injected directly into every broker launched under that profile \u2014 set on stdin, list names only (never values), or remove one; no `spawnEnv.allow` entry needed. `meta` edits the manifest\u2019s readable metadata map \u2014 identity facts each broker under the profile sees as `CRTR_PROFILE_META_<KEY>` env (secrets stay in `env`). 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.',
34
31
  },
35
- children: [newLeaf, listLeaf, showLeaf, renameLeaf, deleteLeaf, projectBranch, defaultBranch, envBranch],
32
+ children: [newLeaf, listLeaf, showLeaf, renameLeaf, pauseLeaf, resumeLeaf, kindLeaf, metaLeaf, deleteLeaf, projectBranch, defaultBranch, envBranch],
36
33
  });
37
34
  }