@pugi/cli 0.1.0-alpha.3 → 0.1.0-alpha.6

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 (44) hide show
  1. package/README.md +20 -0
  2. package/dist/commands/jobs.js +245 -0
  3. package/dist/core/agents/registry.js +69 -0
  4. package/dist/core/bash-classifier.js +1001 -0
  5. package/dist/core/context/builder.js +114 -0
  6. package/dist/core/context/compaction-events.js +99 -0
  7. package/dist/core/context/compaction.js +602 -0
  8. package/dist/core/context/invariants.js +250 -0
  9. package/dist/core/context/markdown-loader.js +270 -0
  10. package/dist/core/engine/compaction-hook.js +154 -0
  11. package/dist/core/engine/index.js +5 -0
  12. package/dist/core/engine/prompts.js +42 -0
  13. package/dist/core/engine/tool-bridge.js +159 -61
  14. package/dist/core/hooks.js +415 -0
  15. package/dist/core/jobs/registry.js +462 -0
  16. package/dist/core/mcp/client.js +316 -0
  17. package/dist/core/mcp/registry.js +171 -0
  18. package/dist/core/mcp/trust.js +91 -0
  19. package/dist/core/permission.js +221 -116
  20. package/dist/core/repl/cap-warning.js +91 -0
  21. package/dist/core/repl/session.js +399 -0
  22. package/dist/core/repl/slash-commands.js +116 -0
  23. package/dist/core/session.js +168 -0
  24. package/dist/core/subagents/dispatcher.js +258 -0
  25. package/dist/core/subagents/index.js +26 -0
  26. package/dist/core/subagents/spawn.js +86 -0
  27. package/dist/core/trust.js +109 -0
  28. package/dist/runtime/cli.js +158 -46
  29. package/dist/runtime/commands/budget.js +192 -0
  30. package/dist/runtime/commands/config.js +231 -0
  31. package/dist/runtime/commands/privacy.js +107 -0
  32. package/dist/runtime/commands/undo.js +329 -0
  33. package/dist/tools/bash.js +660 -0
  34. package/dist/tui/agent-tree.js +66 -0
  35. package/dist/tui/conversation-pane.js +45 -0
  36. package/dist/tui/input-box.js +91 -0
  37. package/dist/tui/login-picker.js +69 -0
  38. package/dist/tui/render.js +68 -0
  39. package/dist/tui/repl-render.js +218 -0
  40. package/dist/tui/repl.js +152 -0
  41. package/dist/tui/splash-data.js +61 -0
  42. package/dist/tui/splash.js +31 -0
  43. package/dist/tui/status-bar.js +58 -0
  44. package/package.json +11 -5
@@ -0,0 +1,399 @@
1
+ /**
2
+ * REPL session lifecycle - Sprint α5.7 (ADR-0056 PR-PUGI-CLI-REPL-DEFAULT).
3
+ *
4
+ * Owns the state machine that the REPL UI subscribes to:
5
+ *
6
+ * 1. Open a server-side Pugi session via POST /api/pugi/sessions.
7
+ * The CLI keeps a sessionId; reconnect uses it.
8
+ * 2. Subscribe to GET /api/pugi/sessions/:id/stream (SSE). Each event
9
+ * pushes one of: agent.spawned, agent.step, agent.tokens,
10
+ * agent.completed, agent.blocked, agent.failed.
11
+ * 3. Dispatch a brief via POST /api/pugi/sessions/:id/brief.
12
+ * 4. Track active dispatches so the cap-warning gate has a number.
13
+ * 5. Reconnect with Last-Event-ID on transient failure (10 retries,
14
+ * exponential backoff capped at 5s) so the operator sees a stable
15
+ * stream even on flaky connections.
16
+ *
17
+ * The module is environment-agnostic: callers inject `fetch` (Node 22
18
+ * native or a stub from a test) and `EventSource` (a polyfill or
19
+ * a fake). The REPL UI passes the production singletons in
20
+ * `runtime/cli.ts`; tests pass in-memory stand-ins so the contract
21
+ * surface is exercisable without a network.
22
+ *
23
+ * Brand voice: the conversation transcript is line-based, persona-
24
+ * prefixed (Mira / Marcus / Hiroshi / Vera / Anika / Olivia / Diego /
25
+ * Sofia per @pugi/personas). Forbidden words gate applies to every
26
+ * line we synthesize client-side; server-side events are passed through
27
+ * verbatim - the brand gate on those happens at the controller.
28
+ */
29
+ import { randomUUID } from 'node:crypto';
30
+ import { listRoles, getPersonaForRole } from '../agents/registry.js';
31
+ import { evaluateCap, describeVerdict } from './cap-warning.js';
32
+ import { parseSlashCommand } from './slash-commands.js';
33
+ const MAX_TRANSCRIPT_ROWS = 500;
34
+ const MAX_RECONNECT_ATTEMPTS = 10;
35
+ const RECONNECT_BASE_MS = 250;
36
+ const RECONNECT_MAX_MS = 5_000;
37
+ export class ReplSession {
38
+ options;
39
+ subscribers = new Set();
40
+ state;
41
+ streamHandle;
42
+ lastEventId;
43
+ reconnectAttempt = 0;
44
+ reconnectTimer;
45
+ closed = false;
46
+ constructor(options) {
47
+ this.options = options;
48
+ this.state = {
49
+ sessionId: undefined,
50
+ workspaceLabel: options.workspaceLabel,
51
+ cliVersion: options.cliVersion,
52
+ connection: 'connecting',
53
+ agents: [],
54
+ transcript: [],
55
+ tokensDownstreamTotal: 0,
56
+ briefStartedAtEpochMs: undefined,
57
+ };
58
+ }
59
+ /* ------------- subscribe / state -------------- */
60
+ subscribe(callback) {
61
+ this.subscribers.add(callback);
62
+ callback(this.state);
63
+ return () => {
64
+ this.subscribers.delete(callback);
65
+ };
66
+ }
67
+ getState() {
68
+ return this.state;
69
+ }
70
+ /* ------------- lifecycle -------------- */
71
+ /**
72
+ * Create the server-side session and open the SSE stream. Idempotent;
73
+ * a second call after `close()` resurrects the connection but
74
+ * issues a fresh server session id.
75
+ */
76
+ async start() {
77
+ this.closed = false;
78
+ try {
79
+ const { sessionId } = await this.options.transport.createSession({
80
+ apiUrl: this.options.apiUrl,
81
+ apiKey: this.options.apiKey,
82
+ });
83
+ this.patch({ sessionId, connection: 'connecting' });
84
+ this.openStream();
85
+ }
86
+ catch (error) {
87
+ this.appendSystemLine(`Could not open Pugi session: ${this.errorMessage(error)}`);
88
+ this.patch({ connection: 'offline' });
89
+ }
90
+ }
91
+ /**
92
+ * Tear down the SSE stream and stop the reconnect timer. The session
93
+ * id stays valid server-side; `pugi resume <id>` reopens later.
94
+ */
95
+ close() {
96
+ this.closed = true;
97
+ if (this.streamHandle) {
98
+ this.streamHandle.close();
99
+ this.streamHandle = undefined;
100
+ }
101
+ if (this.reconnectTimer) {
102
+ clearTimeout(this.reconnectTimer);
103
+ this.reconnectTimer = undefined;
104
+ }
105
+ }
106
+ /* ------------- input handling -------------- */
107
+ /**
108
+ * Run one line of operator input through the slash command parser
109
+ * and the cap gate, then forward to the transport. Returns the
110
+ * parser verdict so the REPL UI can clear the input box, focus
111
+ * pointer, etc.
112
+ */
113
+ async handleInput(input) {
114
+ const verdict = parseSlashCommand(input);
115
+ switch (verdict.kind) {
116
+ case 'noop':
117
+ return verdict;
118
+ case 'help':
119
+ case 'roster':
120
+ // UI overlays - no transport interaction.
121
+ return verdict;
122
+ case 'quit':
123
+ this.appendSystemLine('Brief it. It ships.');
124
+ return verdict;
125
+ case 'error':
126
+ this.appendSystemLine(verdict.message);
127
+ return verdict;
128
+ case 'stop': {
129
+ await this.dispatchStop(verdict.persona);
130
+ return verdict;
131
+ }
132
+ case 'dispatch': {
133
+ await this.dispatchBrief(verdict.brief);
134
+ return verdict;
135
+ }
136
+ }
137
+ }
138
+ /* ------------- dispatch -------------- */
139
+ async dispatchBrief(brief) {
140
+ const sessionId = this.state.sessionId;
141
+ if (!sessionId) {
142
+ this.appendSystemLine('No server session yet - try again in a moment.');
143
+ return;
144
+ }
145
+ const capVerdict = evaluateCap({
146
+ active: this.activeAgentCount(),
147
+ env: this.options.env ?? process.env,
148
+ });
149
+ const capLine = describeVerdict(capVerdict);
150
+ if (capVerdict.kind === 'block') {
151
+ this.appendSystemLine(capLine);
152
+ return;
153
+ }
154
+ if (capLine.length > 0) {
155
+ this.appendSystemLine(capLine);
156
+ }
157
+ this.appendOperatorLine(brief);
158
+ this.patch({ briefStartedAtEpochMs: this.now() });
159
+ try {
160
+ await this.options.transport.postBrief({
161
+ apiUrl: this.options.apiUrl,
162
+ apiKey: this.options.apiKey,
163
+ sessionId,
164
+ brief,
165
+ });
166
+ }
167
+ catch (error) {
168
+ this.appendSystemLine(`Brief dispatch refused: ${this.errorMessage(error)}`);
169
+ }
170
+ }
171
+ async dispatchStop(persona) {
172
+ const sessionId = this.state.sessionId;
173
+ if (!sessionId) {
174
+ this.appendSystemLine('No server session yet - nothing to stop.');
175
+ return;
176
+ }
177
+ try {
178
+ const { stopped } = await this.options.transport.postStop({
179
+ apiUrl: this.options.apiUrl,
180
+ apiKey: this.options.apiKey,
181
+ sessionId,
182
+ persona,
183
+ });
184
+ this.appendSystemLine(stopped
185
+ ? `Stopped persona '${persona}'.`
186
+ : `No active dispatch matched persona '${persona}'.`);
187
+ }
188
+ catch (error) {
189
+ this.appendSystemLine(`Stop refused: ${this.errorMessage(error)}`);
190
+ }
191
+ }
192
+ /* ------------- SSE consumer + reconnect -------------- */
193
+ openStream() {
194
+ const sessionId = this.state.sessionId;
195
+ if (!sessionId)
196
+ return;
197
+ if (this.streamHandle) {
198
+ this.streamHandle.close();
199
+ this.streamHandle = undefined;
200
+ }
201
+ this.streamHandle = this.options.transport.subscribe({
202
+ apiUrl: this.options.apiUrl,
203
+ apiKey: this.options.apiKey,
204
+ sessionId,
205
+ lastEventId: this.lastEventId,
206
+ onOpen: () => {
207
+ this.reconnectAttempt = 0;
208
+ this.patch({ connection: 'on_watch' });
209
+ },
210
+ onEvent: (event, eventId) => {
211
+ this.lastEventId = eventId;
212
+ this.handleServerEvent(event);
213
+ },
214
+ onError: (error) => {
215
+ if (this.closed)
216
+ return;
217
+ this.patch({ connection: 'reconnecting' });
218
+ this.appendSystemLine(`Stream interrupted (${this.errorMessage(error)}). Reconnecting.`);
219
+ this.scheduleReconnect();
220
+ },
221
+ });
222
+ }
223
+ scheduleReconnect() {
224
+ if (this.closed)
225
+ return;
226
+ if (this.reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) {
227
+ this.appendSystemLine('Gave up reconnecting - type /quit and `pugi resume` to retry.');
228
+ this.patch({ connection: 'offline' });
229
+ return;
230
+ }
231
+ const delay = Math.min(RECONNECT_BASE_MS * 2 ** this.reconnectAttempt, RECONNECT_MAX_MS);
232
+ this.reconnectAttempt += 1;
233
+ this.reconnectTimer = setTimeout(() => {
234
+ this.reconnectTimer = undefined;
235
+ this.openStream();
236
+ }, delay);
237
+ }
238
+ /* ------------- event reducer -------------- */
239
+ handleServerEvent(event) {
240
+ switch (event.type) {
241
+ case 'agent.spawned': {
242
+ const persona = safePersonaName(event.role);
243
+ const node = {
244
+ taskId: event.taskId,
245
+ role: event.role,
246
+ personaSlug: event.personaSlug,
247
+ personaName: persona,
248
+ status: 'queued',
249
+ detail: 'queued for dispatch',
250
+ startedAtEpochMs: this.now(),
251
+ tokensIn: 0,
252
+ tokensOut: 0,
253
+ };
254
+ this.patch({ agents: [node, ...this.state.agents] });
255
+ // The conversation pane already prefixes persona rows with the
256
+ // persona name in the persona's hue colour. Skip embedding the
257
+ // name in the body text to avoid the `Marcus Marcus dispatched`
258
+ // double-print. `void persona` keeps the resolved name in scope
259
+ // for the agent tree node above without leaking it into the
260
+ // transcript body.
261
+ void persona;
262
+ this.appendPersonaLine(event.personaSlug, `dispatched (${event.role}).`);
263
+ return;
264
+ }
265
+ case 'agent.step': {
266
+ this.patch({
267
+ agents: this.state.agents.map((a) => a.taskId === event.taskId
268
+ ? { ...a, status: 'thinking', detail: event.detail }
269
+ : a),
270
+ });
271
+ return;
272
+ }
273
+ case 'agent.tokens': {
274
+ const delta = event.tokensIn + event.tokensOut;
275
+ this.patch({
276
+ tokensDownstreamTotal: this.state.tokensDownstreamTotal + delta,
277
+ agents: this.state.agents.map((a) => a.taskId === event.taskId
278
+ ? {
279
+ ...a,
280
+ tokensIn: a.tokensIn + event.tokensIn,
281
+ tokensOut: a.tokensOut + event.tokensOut,
282
+ }
283
+ : a),
284
+ });
285
+ return;
286
+ }
287
+ case 'agent.completed': {
288
+ const target = this.state.agents.find((a) => a.taskId === event.taskId);
289
+ this.patch({
290
+ agents: this.state.agents.map((a) => a.taskId === event.taskId
291
+ ? { ...a, status: 'shipped', detail: 'shipped' }
292
+ : a),
293
+ });
294
+ if (target) {
295
+ this.appendPersonaLine(target.personaSlug, 'shipped.');
296
+ }
297
+ return;
298
+ }
299
+ case 'agent.blocked': {
300
+ const target = this.state.agents.find((a) => a.taskId === event.taskId);
301
+ this.patch({
302
+ agents: this.state.agents.map((a) => a.taskId === event.taskId
303
+ ? { ...a, status: 'blocked', detail: event.detail }
304
+ : a),
305
+ });
306
+ if (target) {
307
+ this.appendPersonaLine(target.personaSlug, `blocked: ${event.detail}`);
308
+ }
309
+ return;
310
+ }
311
+ case 'agent.failed': {
312
+ const target = this.state.agents.find((a) => a.taskId === event.taskId);
313
+ this.patch({
314
+ agents: this.state.agents.map((a) => a.taskId === event.taskId
315
+ ? { ...a, status: 'failed', detail: event.error }
316
+ : a),
317
+ });
318
+ if (target) {
319
+ this.appendPersonaLine(target.personaSlug, `failed: ${event.error}`);
320
+ }
321
+ return;
322
+ }
323
+ }
324
+ }
325
+ /* ------------- transcript helpers -------------- */
326
+ appendOperatorLine(text) {
327
+ this.appendRow({ source: 'operator', text });
328
+ }
329
+ appendSystemLine(text) {
330
+ this.appendRow({ source: 'system', text });
331
+ }
332
+ appendPersonaLine(personaSlug, text) {
333
+ this.appendRow({ source: 'persona', text, personaSlug });
334
+ }
335
+ appendRow(input) {
336
+ if (input.text.length === 0)
337
+ return;
338
+ const row = {
339
+ id: randomUUID(),
340
+ source: input.source,
341
+ text: input.text,
342
+ personaSlug: input.personaSlug,
343
+ timestampEpochMs: this.now(),
344
+ };
345
+ const next = this.state.transcript.concat(row).slice(-MAX_TRANSCRIPT_ROWS);
346
+ this.patch({ transcript: next });
347
+ }
348
+ /* ------------- agent count + clock -------------- */
349
+ activeAgentCount() {
350
+ return this.state.agents.filter((a) => a.status === 'queued' || a.status === 'thinking').length;
351
+ }
352
+ /** Exported so the cap-warning gate can be exercised in isolation. */
353
+ activeRoles() {
354
+ return this.state.agents
355
+ .filter((a) => a.status === 'queued' || a.status === 'thinking')
356
+ .map((a) => a.role);
357
+ }
358
+ now() {
359
+ return (this.options.now ?? Date.now)();
360
+ }
361
+ patch(partial) {
362
+ this.state = { ...this.state, ...partial };
363
+ for (const subscriber of this.subscribers) {
364
+ subscriber(this.state);
365
+ }
366
+ }
367
+ errorMessage(error) {
368
+ if (error instanceof Error)
369
+ return error.message;
370
+ return String(error);
371
+ }
372
+ }
373
+ /* ------------------------------------------------------------------ */
374
+ /* Helpers */
375
+ /* ------------------------------------------------------------------ */
376
+ /**
377
+ * Resolve role → display name without throwing on unknown roles. The
378
+ * dispatcher always passes a known role, but the SSE wire format is
379
+ * forward-compatible - if a future server pushes a role the client
380
+ * does not recognise, the REPL still renders something usable rather
381
+ * than crashing mid-frame.
382
+ */
383
+ function safePersonaName(role) {
384
+ try {
385
+ return getPersonaForRole(role).name;
386
+ }
387
+ catch {
388
+ return role;
389
+ }
390
+ }
391
+ /**
392
+ * Convenience: list the legal role slugs the operator can target with
393
+ * `/stop`. Surfaced in the slash command help overlay and in the
394
+ * cap-warning helper when the operator types an unrecognised slug.
395
+ */
396
+ export function knownRoles() {
397
+ return listRoles();
398
+ }
399
+ //# sourceMappingURL=session.js.map
@@ -0,0 +1,116 @@
1
+ /**
2
+ * REPL slash command registry - Sprint α5.7 (ADR-0056 PR-PUGI-CLI-REPL-DEFAULT).
3
+ *
4
+ * The REPL input box surfaces a small palette of slash commands that the
5
+ * operator can run from inside a persistent session: dispatch a brief,
6
+ * inspect the agent roster, stop a running persona, open the help
7
+ * overlay, or quit. The registry is intentionally narrow at M1 -
8
+ * complementary surfaces (`/sync`, `/handoff`, `/budget`) ship as proper
9
+ * subcommands in α5.8+ and stay reachable from a non-REPL `pugi
10
+ * <command>` invocation.
11
+ *
12
+ * The registry is pure: each entry returns a `SlashCommandResult`
13
+ * describing what the REPL session should do next. The session module
14
+ * owns the side effects (network calls, dispatcher invocation, exit).
15
+ * Keeping the surface pure lets the unit test exercise every shape
16
+ * without standing up an Ink runtime or an Anvil endpoint.
17
+ *
18
+ * Brand voice (brandbook §08): power words `brief / dispatch / stop /
19
+ * agents / quit`. Tagline `Brief it. It ships.` reserved for `/quit`
20
+ * confirmation and `/help` footer - never inline.
21
+ */
22
+ import { listRoles } from '../agents/registry.js';
23
+ export const SLASH_COMMAND_HELP = Object.freeze([
24
+ { name: 'brief', args: '<text>', gloss: 'Dispatch a brief to the workforce' },
25
+ { name: 'agents', args: '', gloss: 'List the on-watch agent roster' },
26
+ { name: 'stop', args: '<persona>', gloss: 'Stop one agent by persona slug' },
27
+ { name: 'help', args: '', gloss: 'Show this help overlay' },
28
+ { name: 'quit', args: '', gloss: 'Exit the REPL (session resumes via pugi resume)' },
29
+ ]);
30
+ /**
31
+ * Parse one line of input from the REPL. The contract:
32
+ *
33
+ * - Empty / whitespace-only input returns `noop` with the original
34
+ * text so the REPL can ignore it without printing anything.
35
+ * - Input that does not start with `/` is treated as an implicit
36
+ * `/brief <text>` - the most-common operator action.
37
+ * - `/<name> [args]` resolves the name against the registry; unknown
38
+ * names return `error` so the REPL can render a one-line tip
39
+ * instead of silently dropping the input.
40
+ *
41
+ * The function never throws. Bad input maps to a structured result the
42
+ * REPL can render - the alternative (throwing from a keystroke handler)
43
+ * would unmount Ink mid-frame.
44
+ */
45
+ export function parseSlashCommand(input) {
46
+ const trimmed = input.trim();
47
+ if (trimmed.length === 0) {
48
+ return { kind: 'noop', text: '' };
49
+ }
50
+ if (!trimmed.startsWith('/')) {
51
+ return { kind: 'dispatch', brief: trimmed };
52
+ }
53
+ // `/` with no name → render help overlay so the operator discovers
54
+ // the command palette without typing `/help`.
55
+ if (trimmed === '/') {
56
+ return { kind: 'help' };
57
+ }
58
+ const space = trimmed.indexOf(' ');
59
+ const head = space === -1 ? trimmed.slice(1) : trimmed.slice(1, space);
60
+ const tail = space === -1 ? '' : trimmed.slice(space + 1).trim();
61
+ const name = head.toLowerCase();
62
+ switch (name) {
63
+ case 'brief': {
64
+ if (tail.length === 0) {
65
+ return { kind: 'error', message: 'Usage: /brief <text>' };
66
+ }
67
+ return { kind: 'dispatch', brief: tail };
68
+ }
69
+ case 'agents':
70
+ case 'agent':
71
+ case 'roster': {
72
+ return { kind: 'roster' };
73
+ }
74
+ case 'stop':
75
+ case 'kill': {
76
+ if (tail.length === 0) {
77
+ return {
78
+ kind: 'error',
79
+ message: `Usage: /stop <persona> (try one of: ${listRoles().join(', ')})`,
80
+ };
81
+ }
82
+ return { kind: 'stop', persona: tail.toLowerCase() };
83
+ }
84
+ case 'help':
85
+ case '?': {
86
+ return { kind: 'help' };
87
+ }
88
+ case 'quit':
89
+ case 'exit':
90
+ case 'q': {
91
+ return { kind: 'quit' };
92
+ }
93
+ default: {
94
+ return {
95
+ kind: 'error',
96
+ message: `Unknown command /${head}. Try /help for the palette.`,
97
+ };
98
+ }
99
+ }
100
+ }
101
+ /**
102
+ * Filter SLASH_COMMAND_HELP rows whose name starts with the typed
103
+ * prefix. The REPL input box uses this to render an inline palette
104
+ * after the operator types `/`.
105
+ *
106
+ * Matching is case-insensitive; the prefix is normalized to lowercase
107
+ * before comparison so the operator can type `/HELP` and still see
108
+ * suggestions.
109
+ */
110
+ export function matchSlashPrefix(prefix) {
111
+ const normalized = prefix.toLowerCase().replace(/^\//, '');
112
+ if (normalized.length === 0)
113
+ return SLASH_COMMAND_HELP;
114
+ return SLASH_COMMAND_HELP.filter((row) => row.name.startsWith(normalized));
115
+ }
116
+ //# sourceMappingURL=slash-commands.js.map
@@ -81,6 +81,174 @@ export function recordFileMutation(session, input) {
81
81
  ...input,
82
82
  }, session.eventsPath);
83
83
  }
84
+ export function recordHookInvoked(session, input) {
85
+ if (!session.enabled)
86
+ return;
87
+ appendEvent({
88
+ id: randomUUID(),
89
+ sessionId: session.id,
90
+ timestamp: now(),
91
+ type: 'hook.invoked',
92
+ event: input.event,
93
+ matchSummary: input.matchSummary,
94
+ runSummary: input.runSummary,
95
+ }, session.eventsPath);
96
+ }
97
+ export function recordHookResult(session, input) {
98
+ if (!session.enabled)
99
+ return;
100
+ appendEvent({
101
+ id: randomUUID(),
102
+ sessionId: session.id,
103
+ timestamp: now(),
104
+ type: 'hook.result',
105
+ event: input.event,
106
+ ok: input.ok,
107
+ exitCode: input.exitCode,
108
+ elapsedMs: input.elapsedMs,
109
+ stdoutLen: input.stdoutLen,
110
+ stderrLen: input.stderrLen,
111
+ }, session.eventsPath);
112
+ }
113
+ export function recordHookSkipped(session, input) {
114
+ if (!session.enabled)
115
+ return;
116
+ appendEvent({
117
+ id: randomUUID(),
118
+ sessionId: session.id,
119
+ timestamp: now(),
120
+ type: 'hook.skipped',
121
+ event: input.event,
122
+ reason: input.reason,
123
+ }, session.eventsPath);
124
+ }
125
+ /**
126
+ * Record a `subagent.spawned` event in the session audit log. Inputs
127
+ * arrive untyped (the dispatcher emits JSON to stay decoupled from this
128
+ * module); the recorder validates the role and isolation at the typed
129
+ * audit-event union boundary, so a malformed role would fail at the
130
+ * `AuditEvent` cast site rather than silently writing garbage. The cast
131
+ * itself is the load-bearing check: `assertSubagentRole` and
132
+ * `assertSubagentIsolation` narrow at runtime.
133
+ */
134
+ export function recordSubagentSpawned(session, input) {
135
+ if (!session.enabled)
136
+ return;
137
+ appendEvent({
138
+ id: randomUUID(),
139
+ sessionId: session.id,
140
+ timestamp: now(),
141
+ type: 'subagent.spawned',
142
+ taskId: input.taskId,
143
+ role: assertSubagentRole(input.role),
144
+ personaSlug: input.personaSlug,
145
+ parentSessionId: input.parentSessionId,
146
+ isolation: assertSubagentIsolation(input.isolation),
147
+ }, session.eventsPath);
148
+ }
149
+ export function recordSubagentToolCall(session, input) {
150
+ if (!session.enabled)
151
+ return;
152
+ appendEvent({
153
+ id: randomUUID(),
154
+ sessionId: session.id,
155
+ timestamp: now(),
156
+ type: 'subagent.tool_call',
157
+ taskId: input.taskId,
158
+ role: assertSubagentRole(input.role),
159
+ personaSlug: input.personaSlug,
160
+ toolName: input.toolName,
161
+ toolCallId: input.toolCallId,
162
+ }, session.eventsPath);
163
+ }
164
+ export function recordSubagentCompleted(session, input) {
165
+ if (!session.enabled)
166
+ return;
167
+ appendEvent({
168
+ id: randomUUID(),
169
+ sessionId: session.id,
170
+ timestamp: now(),
171
+ type: 'subagent.completed',
172
+ taskId: input.taskId,
173
+ role: assertSubagentRole(input.role),
174
+ personaSlug: input.personaSlug,
175
+ toolCallCount: input.toolCallCount,
176
+ tokensIn: input.tokensIn,
177
+ tokensOut: input.tokensOut,
178
+ durationMs: input.durationMs,
179
+ }, session.eventsPath);
180
+ }
181
+ export function recordSubagentBlocked(session, input) {
182
+ if (!session.enabled)
183
+ return;
184
+ appendEvent({
185
+ id: randomUUID(),
186
+ sessionId: session.id,
187
+ timestamp: now(),
188
+ type: 'subagent.blocked',
189
+ taskId: input.taskId,
190
+ role: assertSubagentRole(input.role),
191
+ personaSlug: input.personaSlug,
192
+ reason: assertSubagentBlockedReason(input.reason),
193
+ detail: input.detail,
194
+ }, session.eventsPath);
195
+ }
196
+ export function recordSubagentFailed(session, input) {
197
+ if (!session.enabled)
198
+ return;
199
+ appendEvent({
200
+ id: randomUUID(),
201
+ sessionId: session.id,
202
+ timestamp: now(),
203
+ type: 'subagent.failed',
204
+ taskId: input.taskId,
205
+ role: assertSubagentRole(input.role),
206
+ personaSlug: input.personaSlug,
207
+ error: input.error,
208
+ }, session.eventsPath);
209
+ }
210
+ const SUBAGENT_ROLES = new Set([
211
+ 'orchestrator',
212
+ 'architect',
213
+ 'coder',
214
+ 'verifier',
215
+ 'reviewer',
216
+ 'researcher',
217
+ 'release',
218
+ 'devops',
219
+ 'design_qa',
220
+ ]);
221
+ const SUBAGENT_ISOLATIONS = new Set([
222
+ 'prompt_only',
223
+ 'shared_fs_readonly',
224
+ 'shared_fs_serialized',
225
+ 'worktree',
226
+ 'remote_vm',
227
+ ]);
228
+ const SUBAGENT_BLOCKED_REASONS = new Set([
229
+ 'budget_exhausted',
230
+ 'plan_mode_refused',
231
+ 'permission_denied',
232
+ 'tool_unavailable',
233
+ ]);
234
+ function assertSubagentRole(value) {
235
+ if (!SUBAGENT_ROLES.has(value)) {
236
+ throw new Error(`recordSubagent*: unknown role '${value}'`);
237
+ }
238
+ return value;
239
+ }
240
+ function assertSubagentIsolation(value) {
241
+ if (!SUBAGENT_ISOLATIONS.has(value)) {
242
+ throw new Error(`recordSubagent*: unknown isolation '${value}'`);
243
+ }
244
+ return value;
245
+ }
246
+ function assertSubagentBlockedReason(value) {
247
+ if (!SUBAGENT_BLOCKED_REASONS.has(value)) {
248
+ throw new Error(`recordSubagent*: unknown blocked reason '${value}'`);
249
+ }
250
+ return value;
251
+ }
84
252
  function appendEvent(event, eventsPath) {
85
253
  appendFileSync(eventsPath, `${JSON.stringify(event)}\n`, { encoding: 'utf8', mode: 0o600 });
86
254
  }