@sublang/playbook 0.8.0 → 1.0.0

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 (55) hide show
  1. package/README.md +243 -193
  2. package/package.json +52 -17
  3. package/reference/sdlc/captain.md +102 -0
  4. package/reference/sdlc/captain.playbook/captain.fsm.d.ts +227 -0
  5. package/reference/sdlc/captain.playbook/captain.fsm.js +628 -0
  6. package/reference/sdlc/captain.playbook/captain.fsm.ts +851 -0
  7. package/reference/sdlc/captain.playbook/captain.gears.md +60 -0
  8. package/reference/sdlc/captain.playbook/captain.playbook.d.ts +23 -0
  9. package/reference/sdlc/captain.playbook/captain.playbook.js +1053 -0
  10. package/reference/sdlc/captain.playbook/captain.playbook.ts +1144 -0
  11. package/reference/sdlc/code.playbook/bin/playbook.js +580 -0
  12. package/reference/sdlc/code.playbook/bin/run.js +893 -0
  13. package/reference/sdlc/code.playbook/code.fsm.d.ts +11 -4
  14. package/reference/sdlc/code.playbook/code.fsm.introspect.d.ts +2 -2
  15. package/reference/sdlc/code.playbook/code.fsm.introspect.js +1 -1
  16. package/reference/sdlc/code.playbook/code.fsm.introspect.ts +6 -6
  17. package/reference/sdlc/code.playbook/code.fsm.js +334 -102
  18. package/reference/sdlc/code.playbook/code.fsm.ts +470 -182
  19. package/reference/sdlc/code.playbook/code.gears.md +11 -10
  20. package/reference/sdlc/code.playbook/code.playbook.d.ts +18 -9
  21. package/reference/sdlc/code.playbook/code.playbook.js +1098 -202
  22. package/reference/sdlc/code.playbook/code.playbook.ts +1440 -258
  23. package/reference/sdlc/code.playbook/code.registry.d.ts +17 -5
  24. package/reference/sdlc/code.playbook/code.registry.js +49 -34
  25. package/reference/sdlc/code.playbook/code.registry.ts +75 -41
  26. package/reference/sdlc/code.playbook/playbook-captain.d.ts +16 -8
  27. package/reference/sdlc/code.playbook/playbook-captain.js +1005 -240
  28. package/reference/sdlc/code.playbook/playbook-captain.ts +1310 -301
  29. package/reference/sdlc/code.playbook/playbook.config.template.yaml +68 -0
  30. package/reference/sdlc/discuss.playbook/discuss.fsm.d.ts +396 -0
  31. package/reference/sdlc/discuss.playbook/discuss.fsm.js +2066 -0
  32. package/reference/sdlc/discuss.playbook/discuss.fsm.ts +2464 -0
  33. package/reference/sdlc/discuss.playbook/discuss.gears.md +251 -0
  34. package/reference/sdlc/discuss.playbook/discuss.playbook.d.ts +113 -0
  35. package/reference/sdlc/discuss.playbook/discuss.playbook.js +1514 -0
  36. package/reference/sdlc/discuss.playbook/discuss.playbook.ts +1926 -0
  37. package/reference/sdlc/discuss.playbook/discuss.registry.d.ts +58 -0
  38. package/reference/sdlc/discuss.playbook/discuss.registry.js +97 -0
  39. package/reference/sdlc/discuss.playbook/discuss.registry.ts +153 -0
  40. package/slc/gears2fsm.md +557 -57
  41. package/slc/link.md +1097 -80
  42. package/slc/optimize.md +88 -0
  43. package/slc/text2gears.md +247 -5
  44. package/src/runtime.d.ts +145 -3
  45. package/src/runtime.ts +200 -2
  46. package/src/xstate-runtime.d.ts +94 -0
  47. package/src/xstate-runtime.js +1247 -0
  48. package/src/xstate-runtime.ts +1802 -0
  49. package/reference/sdlc/code.playbook/bin/playbook-code.js +0 -487
  50. package/reference/sdlc/code.playbook/code.tmux-play.d.ts +0 -4
  51. package/reference/sdlc/code.playbook/code.tmux-play.js +0 -11
  52. package/reference/sdlc/code.playbook/code.tmux-play.ts +0 -29
  53. package/reference/sdlc/code.playbook/playbook-code.config.template.yaml +0 -72
  54. package/reference/sdlc/code.playbook/tmux-play.config.yaml +0 -55
  55. package/reference/sdlc/code.playbook/tmux-play.production.config.yaml +0 -38
@@ -1,6 +1,9 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
3
 
4
+ import { randomUUID } from 'node:crypto';
5
+ import PQueue from 'p-queue';
6
+
4
7
  import type {
5
8
  BossTurn,
6
9
  Captain,
@@ -8,57 +11,119 @@ import type {
8
11
  CaptainSession,
9
12
  } from '@sublang/cligent/tmux-play';
10
13
  import type {
14
+ NormalizedError,
15
+ PlaybookCallRequest,
16
+ PlaybookCallResult,
17
+ PlaybookCallStart,
11
18
  PlaybookPorts,
19
+ PlaybookRunResult,
12
20
  PlaybookRuntime,
13
- } from './code.playbook.js';
14
- import {
15
- codePlaybookRegistryEntry,
16
- type RegistryPlayer,
17
- } from './code.registry.js';
21
+ PlaybookState,
22
+ } from '@sublang/playbook/runtime';
23
+ import { registerPlaybookAbortCleanup } from '../../../src/xstate-runtime.js';
24
+ import createDefaultCaptainRuntime from '../captain.playbook/captain.playbook.js';
25
+ import type { PlaybookSummaryPolicy, RegistryPlayer } from './code.registry.js';
18
26
 
19
27
  export interface CreatePlaybookRuntimeOptions {
20
28
  captainOptions: unknown;
21
29
  players: readonly RegistryPlayer[];
22
30
  }
23
31
 
32
+ export interface PlaybookCaptainDeps {
33
+ loadModule?: (specifier: string) => Promise<unknown>;
34
+ createSessionId?: () => string;
35
+ createCaptainRuntime?: (options: {
36
+ readonly enabledPlaybooks: readonly {
37
+ readonly id: string;
38
+ readonly command: string;
39
+ readonly intent: string;
40
+ }[];
41
+ }) => PlaybookRuntime;
42
+ }
43
+
24
44
  export interface PlaybookCaptainRegistryEntry {
25
45
  id: string;
26
46
  command: string;
27
47
  intent: string;
28
- idleStateId: string;
29
- finalStateId: string;
30
- copyPasteGuardNames: readonly string[];
31
- stateCountLabels?: Readonly<Record<string, string>>;
48
+ requiredRoleIds: readonly string[];
49
+ summaryPolicy?: PlaybookSummaryPolicy;
32
50
  validateOptions(captainOptions: unknown): unknown;
33
51
  createRuntime(options: CreatePlaybookRuntimeOptions): PlaybookRuntime;
34
52
  }
35
53
 
36
- interface ActiveEngagement {
54
+ // Per-enabled-playbook binding the shell resolves at init from
55
+ // `captain.options.playbooks`: each playbook binds its local roles to
56
+ // `<id>-<role>` host players and carries the generated visible set.
57
+ interface Enablement {
58
+ entry: PlaybookCaptainRegistryEntry;
59
+ command: string;
60
+ optionInput: unknown;
61
+ boundPlayers: readonly RegistryPlayer[];
62
+ hostPlayerId: (localRole: string) => string;
63
+ visiblePlayerIds?: readonly string[];
64
+ }
65
+
66
+ interface EngagementFrame {
37
67
  entry: PlaybookCaptainRegistryEntry;
68
+ enablement: Enablement;
38
69
  runtime: PlaybookRuntime;
70
+ sessionId: string;
71
+ rootSessionId: string;
72
+ depth: number;
73
+ parent?: {
74
+ frame: EngagementFrame;
75
+ callId: string;
76
+ };
77
+ state?: PlaybookState;
78
+ abortListener?: () => void;
79
+ invocationSignal?: AbortSignal;
80
+ inFlightHostCalls: Set<Promise<unknown>>;
81
+ disposePromise?: Promise<void>;
82
+ removal?: {
83
+ reason: 'return' | 'abandoned' | 'stack';
84
+ promise: Promise<void>;
85
+ };
86
+ internal: boolean;
39
87
  }
40
88
 
41
- type RouterDecision =
42
- | { decision: 'chat'; text: string }
43
- | { decision: 'dispatch'; playbookId: string; text: string }
44
- | { decision: 'sub'; text: string }
45
- | { decision: 'dismiss'; text?: string };
89
+ type LifecycleDecision = { decision: 'deliver' | 'dismiss' };
90
+
91
+ class VisibilityControlError extends Error {
92
+ constructor(cause: unknown) {
93
+ super(
94
+ `playbook visibility request failed: ${String(
95
+ (cause as { message?: unknown })?.message ?? cause,
96
+ )}`,
97
+ { cause },
98
+ );
99
+ this.name = 'VisibilityControlError';
100
+ }
101
+ }
46
102
 
47
103
  type DisposalReason = 'dismiss' | 'final' | 'dispose';
48
104
 
49
105
  interface ControlLedger {
50
106
  activePlaybookId?: string;
107
+ activeSessionId?: string;
108
+ rootPlaybookId?: string;
109
+ rootSessionId?: string;
110
+ stackDepth: number;
111
+ stackPath: readonly string[];
51
112
  mode: ShellMode;
52
113
  latestSubRuntimeStateId?: string;
53
- pendingBossQuestion?: unknown;
114
+ latestSubRuntimeState?: PlaybookState;
115
+ pendingBossQuestions?: unknown;
54
116
  lastError?: { name: string; message: string };
55
- lastRouteDecision?: RouterDecision['decision'];
117
+ lastRouteDecision?: LifecycleDecision['decision'];
56
118
  }
57
119
 
58
120
  type ShellMode = 'chat' | 'engaged.driving' | 'engaged.parked';
59
121
 
60
122
  const SUB_RUNTIME_FSM_TOPIC = 'playbook.fsm.state';
61
123
  const SHELL_FSM_TOPIC = 'playbook.captain.fsm.state';
124
+ const INTERNAL_CAPTAIN_ID = 'captain';
125
+ const UUID_PATTERN =
126
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
62
127
 
63
128
  interface TurnSummaryCounts {
64
129
  interruptions: number;
@@ -66,14 +131,11 @@ interface TurnSummaryCounts {
66
131
  }
67
132
 
68
133
  interface ActiveTurnSummary {
134
+ owner: EngagementFrame;
69
135
  counts: TurnSummaryCounts;
70
136
  stateCounts: Map<string, number>;
71
137
  }
72
138
 
73
- export const playbookCaptainRegistry: readonly PlaybookCaptainRegistryEntry[] = [
74
- codePlaybookRegistryEntry,
75
- ];
76
-
77
139
  function parseRegisteredCommand(
78
140
  prompt: string,
79
141
  ): { command: string; text: string } | undefined {
@@ -84,14 +146,10 @@ function parseRegisteredCommand(
84
146
  return { command: match[1], text: (match[2] ?? '').trim() };
85
147
  }
86
148
 
87
- function playbookCommandLabel(entry: PlaybookCaptainRegistryEntry): string {
88
- return `/${entry.command}`;
89
- }
90
-
91
149
  function visibleChatEnvelope(message: string): string {
92
150
  return [
93
151
  'You are the Playbook Captain shell.',
94
- 'This is visible Boss chat. Do not reveal hidden control JSON, hidden router decisions, or hidden judge replies.',
152
+ 'This is visible Boss chat. Do not reveal hidden control JSON, hidden lifecycle decisions, or hidden judge replies.',
95
153
  message,
96
154
  ].join('\n\n');
97
155
  }
@@ -101,58 +159,37 @@ function visibleTurnSummaryEnvelope(input: {
101
159
  submittedText: string;
102
160
  counts: TurnSummaryCounts;
103
161
  progressPhrase: string;
104
- reviewRebuttalRounds: number;
162
+ progressRounds: number;
163
+ savedLine: string;
105
164
  }): string {
106
- const savedLine = savedCountsLine(input.counts, input.reviewRebuttalRounds);
107
165
  return [
108
166
  'You are the Playbook Captain shell.',
109
- 'This is visible Boss chat after a sub-playbook command completed. Do not reveal hidden control JSON, hidden router decisions, or hidden judge replies.',
167
+ 'This is visible Boss chat after a sub-playbook command completed. Do not reveal hidden control JSON, hidden lifecycle decisions, or hidden judge replies.',
110
168
  'Write a brief, clearly formatted turn-summary block for Boss.',
111
169
  'Use a natural, chat-like tone and no more than two short sentences before the saved-counts line.',
112
170
  'State only what was done or what changed; do not explain how it was done.',
113
171
  'Do not list raw state names, transitions, guard names, prompts, tools, hidden calls, or reasoning.',
114
172
  'If progress detail is useful, use only the aggregate progress phrase supplied below.',
115
- 'Do not mention counts for plan or implementation steps, tests green, or any other internal state.',
116
- `Then write the saved-counts line exactly: ${savedLine}`,
173
+ "Do not mention counts for states the active playbook's summary policy does not label.",
174
+ `Then write the saved-counts line exactly: ${input.savedLine}`,
117
175
  'Use the exact counts supplied; do not change them.',
118
- 'Do not repeat the exact review/rebuttal round count outside the saved-counts line.',
176
+ 'Do not repeat the exact progress round count outside the saved-counts line.',
119
177
  `Playbook: ${input.playbookId}`,
120
178
  `Submitted Boss text:\n${input.submittedText}`,
121
179
  `Progress counts:\n${input.progressPhrase}`,
122
180
  `Counts:\n${JSON.stringify({
123
181
  ...input.counts,
124
- reviewRebuttalRounds: input.reviewRebuttalRounds,
182
+ progressRounds: input.progressRounds,
125
183
  })}`,
126
184
  ].join('\n\n');
127
185
  }
128
186
 
129
- function countNoun(count: number, singular: string, plural = `${singular}s`): string {
130
- return `${count} ${count === 1 ? singular : plural}`;
131
- }
132
-
133
- function savedCountsLine(
134
- counts: TurnSummaryCounts,
135
- reviewRebuttalRounds: number,
136
- ): string {
137
- return [
138
- 'Saved you',
139
- countNoun(counts.interruptions, 'interruption'),
140
- 'and',
141
- countNoun(counts.copyPastes, 'copy-paste'),
142
- 'across',
143
- countNoun(reviewRebuttalRounds, 'round'),
144
- 'of reviews/rebuttals.',
145
- ].join(' ');
146
- }
147
-
148
187
  function stateCountLabel(
149
188
  stateId: string,
150
189
  entry: PlaybookCaptainRegistryEntry,
151
190
  ): string | undefined {
152
- if (stateId === entry.idleStateId || stateId === entry.finalStateId) {
153
- return undefined;
154
- }
155
- const registryLabel = entry.stateCountLabels?.[stateId]?.trim();
191
+ const registryLabel =
192
+ entry.summaryPolicy?.stateCountLabels?.[stateId]?.trim();
156
193
  return registryLabel || undefined;
157
194
  }
158
195
 
@@ -163,14 +200,18 @@ function pluralizeStateCount(label: string, count: number): string {
163
200
  return `${count} ${label}s`;
164
201
  }
165
202
 
166
- function summaryProgressPhrase(stateCounts: ReadonlyMap<string, number>): string {
203
+ function summaryProgressPhrase(
204
+ stateCounts: ReadonlyMap<string, number>,
205
+ ): string {
167
206
  if (stateCounts.size === 0) return 'none';
168
207
  return [...stateCounts.entries()]
169
208
  .map(([label, count]) => pluralizeStateCount(label, count))
170
209
  .join(', ');
171
210
  }
172
211
 
173
- function summaryProgressRoundCount(stateCounts: ReadonlyMap<string, number>): number {
212
+ function summaryProgressRoundCount(
213
+ stateCounts: ReadonlyMap<string, number>,
214
+ ): number {
174
215
  return [...stateCounts.values()].reduce((total, count) => total + count, 0);
175
216
  }
176
217
 
@@ -178,38 +219,179 @@ function guardFromJudgeReply(finalText: string): string | undefined {
178
219
  return /"guard"\s*:\s*"([^"]+)"/.exec(finalText)?.[1];
179
220
  }
180
221
 
181
- function normalizeRegistry(
182
- registry: readonly PlaybookCaptainRegistryEntry[],
183
- ): {
222
+ function isValidRegistryEntry(
223
+ value: unknown,
224
+ ): value is PlaybookCaptainRegistryEntry {
225
+ if (typeof value !== 'object' || value === null) return false;
226
+ const e = value as Record<string, unknown>;
227
+ return (
228
+ typeof e.id === 'string' &&
229
+ typeof e.command === 'string' &&
230
+ typeof e.intent === 'string' &&
231
+ Array.isArray(e.requiredRoleIds) &&
232
+ typeof e.validateOptions === 'function' &&
233
+ typeof e.createRuntime === 'function'
234
+ );
235
+ }
236
+
237
+ function readPlaybooksConfig(
238
+ options: unknown,
239
+ ): Record<string, unknown> | undefined {
240
+ if (typeof options !== 'object' || options === null) return undefined;
241
+ const pb = (options as Record<string, unknown>).playbooks;
242
+ if (typeof pb !== 'object' || pb === null || Array.isArray(pb)) {
243
+ return undefined;
244
+ }
245
+ return pb as Record<string, unknown>;
246
+ }
247
+
248
+ interface BuiltRegistry {
184
249
  entries: readonly PlaybookCaptainRegistryEntry[];
185
250
  byCommand: Map<string, PlaybookCaptainRegistryEntry>;
186
251
  byId: Map<string, PlaybookCaptainRegistryEntry>;
187
- } {
252
+ enablementById: Map<string, Enablement>;
253
+ }
254
+
255
+ // Resolve the active registry at init from `captain.options.playbooks`
256
+ // (CAPTAIN-16): each enabled playbook is loaded from its explicit `from`
257
+ // module and bound to namespaced `<id>-<role>` host players.
258
+ async function buildEnablements(
259
+ options: unknown,
260
+ players: readonly RegistryPlayer[],
261
+ loadModule: (specifier: string) => Promise<unknown>,
262
+ ): Promise<BuiltRegistry> {
263
+ const entries: PlaybookCaptainRegistryEntry[] = [];
188
264
  const byCommand = new Map<string, PlaybookCaptainRegistryEntry>();
189
265
  const byId = new Map<string, PlaybookCaptainRegistryEntry>();
190
- for (const entry of registry) {
191
- byCommand.set(entry.command, entry);
266
+ const enablementById = new Map<string, Enablement>();
267
+
268
+ const config = readPlaybooksConfig(options);
269
+ if (config === undefined) {
270
+ throw new Error('captain.options.playbooks is required');
271
+ }
272
+
273
+ const ids = Object.keys(config);
274
+ if (ids.length === 0) {
275
+ throw new Error(
276
+ 'captain.options.playbooks must enable at least one playbook',
277
+ );
278
+ }
279
+ for (const id of ids) {
280
+ if (id === INTERNAL_CAPTAIN_ID) {
281
+ throw new Error(
282
+ `captain.options.playbooks.${id} collides with the reserved internal Captain id`,
283
+ );
284
+ }
285
+ const block = config[id];
286
+ if (typeof block !== 'object' || block === null || Array.isArray(block)) {
287
+ throw new Error(`captain.options.playbooks.${id} must be an object`);
288
+ }
289
+ const record = block as Record<string, unknown>;
290
+ const from = record.from;
291
+ if (typeof from !== 'string' || from.length === 0) {
292
+ throw new Error(
293
+ `captain.options.playbooks.${id}.from must be a module specifier`,
294
+ );
295
+ }
296
+ let mod: unknown;
297
+ try {
298
+ mod = await loadModule(from);
299
+ } catch (cause) {
300
+ throw new Error(
301
+ `captain.options.playbooks.${id}.from "${from}" failed to import: ${String(
302
+ (cause as { message?: unknown })?.message ?? cause,
303
+ )}`,
304
+ );
305
+ }
306
+ const entry = (mod as { default?: unknown })?.default;
307
+ if (!isValidRegistryEntry(entry)) {
308
+ throw new Error(
309
+ `captain.options.playbooks.${id}.from "${from}" exposes no valid registry entry`,
310
+ );
311
+ }
312
+ if (entry.id !== id) {
313
+ throw new Error(
314
+ `captain.options.playbooks.${id} key must equal the module manifest id "${entry.id}"`,
315
+ );
316
+ }
317
+ if (byId.has(entry.id)) {
318
+ throw new Error(
319
+ `captain.options.playbooks has a duplicate playbook id "${entry.id}"`,
320
+ );
321
+ }
322
+ const command =
323
+ typeof record.command === 'string' && record.command.length > 0
324
+ ? record.command
325
+ : entry.command;
326
+ if (command === INTERNAL_CAPTAIN_ID) {
327
+ throw new Error(
328
+ `captain.options.playbooks.${id} command collides with the reserved internal Captain command`,
329
+ );
330
+ }
331
+ if (byCommand.has(command)) {
332
+ throw new Error(
333
+ `captain.options.playbooks has a duplicate effective command "${command}"`,
334
+ );
335
+ }
336
+ const boundPlayers = entry.requiredRoleIds.map((role) => {
337
+ const host = players.find((p) => p.id === `${entry.id}-${role}`);
338
+ return {
339
+ id: role,
340
+ ...(host?.adapter !== undefined ? { adapter: host.adapter } : {}),
341
+ ...(host?.model !== undefined ? { model: host.model } : {}),
342
+ };
343
+ });
344
+ entries.push(entry);
192
345
  byId.set(entry.id, entry);
346
+ byCommand.set(command, entry);
347
+ enablementById.set(entry.id, {
348
+ entry,
349
+ command,
350
+ optionInput: record.options,
351
+ boundPlayers,
352
+ hostPlayerId: (localRole) => `${entry.id}-${localRole}`,
353
+ visiblePlayerIds: entry.requiredRoleIds.map(
354
+ (role) => `${entry.id}-${role}`,
355
+ ),
356
+ });
193
357
  }
194
- return { entries: registry, byCommand, byId };
358
+ return { entries, byCommand, byId, enablementById };
195
359
  }
196
360
 
197
361
  export function createPlaybookCaptainShell(
198
362
  options: unknown,
199
- registry: readonly PlaybookCaptainRegistryEntry[] = playbookCaptainRegistry,
363
+ deps: PlaybookCaptainDeps = {},
200
364
  ): Captain {
201
- const { entries, byCommand, byId } = normalizeRegistry(registry);
365
+ const loadModule =
366
+ deps.loadModule ?? ((specifier: string) => import(specifier));
367
+ const createSessionId = deps.createSessionId ?? randomUUID;
368
+ const createCaptainRuntime: NonNullable<
369
+ PlaybookCaptainDeps['createCaptainRuntime']
370
+ > = deps.createCaptainRuntime ?? createDefaultCaptainRuntime;
371
+ let entries: readonly PlaybookCaptainRegistryEntry[] = [];
372
+ let byCommand = new Map<string, PlaybookCaptainRegistryEntry>();
373
+ let byId = new Map<string, PlaybookCaptainRegistryEntry>();
374
+ let enablementById = new Map<string, Enablement>();
375
+ let internalCaptainEnablement: Enablement | undefined;
202
376
  let session: CaptainSession | undefined;
203
377
  let players: readonly RegistryPlayer[] = [];
204
378
  let activeContext: CaptainContext | undefined;
205
- let active: ActiveEngagement | undefined;
379
+ const frames: EngagementFrame[] = [];
206
380
  let mode: ShellMode = 'chat';
207
- let latestSubRuntimeStateId: string | undefined;
208
- let pendingBossQuestion: unknown;
381
+ let pendingBossQuestions: unknown;
209
382
  let lastError: { name: string; message: string } | undefined;
210
- let lastRouteDecision: RouterDecision['decision'] | undefined;
211
- let finalDisposalRequested: ActiveEngagement | undefined;
383
+ let lastRouteDecision: LifecycleDecision['decision'] | undefined;
212
384
  let activeTurnSummary: ActiveTurnSummary | undefined;
385
+ let activeTurnHostCalls: Set<Promise<unknown>> | undefined;
386
+ const issuedSessionIds = new Set<string>();
387
+ const pendingChildParents = new Set<EngagementFrame>();
388
+ const captainQueue = new PQueue({ concurrency: 1 });
389
+ let disposing = false;
390
+
391
+ const rootFrame = (): EngagementFrame | undefined => frames[0];
392
+ const leafFrame = (): EngagementFrame | undefined => frames.at(-1);
393
+ const frameLabel = (frame: EngagementFrame): string =>
394
+ frame.internal ? 'Captain' : `/${frame.enablement.command}`;
213
395
 
214
396
  const requireSession = (): CaptainSession => {
215
397
  if (!session) {
@@ -219,12 +401,27 @@ export function createPlaybookCaptainShell(
219
401
  };
220
402
 
221
403
  const ledgerSnapshot = (
222
- playbookId: string | undefined = active?.entry.id,
404
+ playbookId: string | undefined = leafFrame()?.entry.id,
405
+ activeSessionId: string | undefined = leafFrame()?.sessionId,
223
406
  ): ControlLedger => ({
224
407
  ...(playbookId ? { activePlaybookId: playbookId } : {}),
408
+ ...(activeSessionId ? { activeSessionId } : {}),
409
+ ...(rootFrame()
410
+ ? {
411
+ rootPlaybookId: rootFrame()!.entry.id,
412
+ rootSessionId: rootFrame()!.sessionId,
413
+ }
414
+ : {}),
415
+ stackDepth: frames.length,
416
+ stackPath: frames.map((frame) => frame.entry.id),
225
417
  mode,
226
- ...(latestSubRuntimeStateId ? { latestSubRuntimeStateId } : {}),
227
- ...(pendingBossQuestion !== undefined ? { pendingBossQuestion } : {}),
418
+ ...(leafFrame()?.state?.stateId
419
+ ? { latestSubRuntimeStateId: leafFrame()!.state!.stateId }
420
+ : {}),
421
+ ...(leafFrame()?.state
422
+ ? { latestSubRuntimeState: leafFrame()!.state }
423
+ : {}),
424
+ ...(pendingBossQuestions !== undefined ? { pendingBossQuestions } : {}),
228
425
  ...(lastError ? { lastError } : {}),
229
426
  ...(lastRouteDecision ? { lastRouteDecision } : {}),
230
427
  });
@@ -233,7 +430,8 @@ export function createPlaybookCaptainShell(
233
430
  from: ShellMode,
234
431
  to: ShellMode,
235
432
  event: string,
236
- playbookId: string | undefined = active?.entry.id,
433
+ playbookId: string | undefined = leafFrame()?.entry.id,
434
+ activeSessionId: string | undefined = leafFrame()?.sessionId,
237
435
  ): Promise<void> => {
238
436
  await requireSession().emitTelemetry({
239
437
  topic: SHELL_FSM_TOPIC,
@@ -241,7 +439,7 @@ export function createPlaybookCaptainShell(
241
439
  from,
242
440
  to,
243
441
  event,
244
- ledger: ledgerSnapshot(playbookId),
442
+ ledger: ledgerSnapshot(playbookId, activeSessionId),
245
443
  },
246
444
  });
247
445
  };
@@ -249,12 +447,19 @@ export function createPlaybookCaptainShell(
249
447
  const setMode = async (
250
448
  nextMode: ShellMode,
251
449
  event: string,
252
- playbookId: string | undefined = active?.entry.id,
450
+ playbookId: string | undefined = leafFrame()?.entry.id,
451
+ activeSessionId: string | undefined = leafFrame()?.sessionId,
253
452
  ): Promise<void> => {
254
453
  if (mode === nextMode) return;
255
454
  const from = mode;
256
455
  mode = nextMode;
257
- await emitShellTelemetry(from, nextMode, event, playbookId);
456
+ await emitShellTelemetry(
457
+ from,
458
+ nextMode,
459
+ event,
460
+ playbookId,
461
+ activeSessionId,
462
+ );
258
463
  };
259
464
 
260
465
  const normalizeErrorCompact = (
@@ -283,67 +488,193 @@ export function createPlaybookCaptainShell(
283
488
  ? (payload as Record<string, unknown>)
284
489
  : undefined;
285
490
 
286
- const mirroredStateId = (payload: unknown): string | undefined => {
287
- const record = payloadRecord(payload);
288
- if (!record) return undefined;
289
- if (typeof record.to === 'string') return record.to;
290
- return typeof record.state === 'string' ? record.state : undefined;
491
+ const playbookState = (value: unknown): PlaybookState | undefined => {
492
+ const record = payloadRecord(value);
493
+ if (
494
+ !record ||
495
+ !Array.isArray(record.activeStateIds) ||
496
+ !record.activeStateIds.every((id) => typeof id === 'string') ||
497
+ !Array.isArray(record.tags) ||
498
+ !record.tags.every((tag) => typeof tag === 'string') ||
499
+ typeof record.status !== 'string' ||
500
+ typeof record.quiescent !== 'boolean' ||
501
+ !('value' in record)
502
+ ) {
503
+ return undefined;
504
+ }
505
+ return record as unknown as PlaybookState;
291
506
  };
292
507
 
293
- const mirrorSubRuntimeTelemetry = async (payload: unknown): Promise<void> => {
294
- if (!active) return;
295
- const record = payloadRecord(payload);
296
- const stateId = mirroredStateId(payload);
297
- if (stateId === undefined) return;
298
-
299
- const countLabel = stateCountLabel(stateId, active.entry);
300
- if (activeTurnSummary && countLabel) {
301
- activeTurnSummary.stateCounts.set(
302
- countLabel,
303
- (activeTurnSummary.stateCounts.get(countLabel) ?? 0) + 1,
304
- );
508
+ const stateValueContains = (value: unknown, stateId: string): boolean => {
509
+ if (typeof value === 'string') return value === stateId;
510
+ const record = payloadRecord(value);
511
+ if (!record) return false;
512
+ return Object.entries(record).some(
513
+ ([key, nested]) => key === stateId || stateValueContains(nested, stateId),
514
+ );
515
+ };
516
+
517
+ const drainHostCalls = async (
518
+ calls: Set<Promise<unknown>>,
519
+ ): Promise<void> => {
520
+ while (calls.size > 0) {
521
+ await Promise.allSettled([...calls]);
305
522
  }
523
+ };
306
524
 
307
- latestSubRuntimeStateId = stateId;
308
- pendingBossQuestion = record?.pendingBossQuestion;
309
- lastError = normalizeErrorCompact(record?.lastError);
525
+ const trackHostCall = <T>(
526
+ frame: EngagementFrame,
527
+ call: Promise<T>,
528
+ ): Promise<T> => {
529
+ // Cligent's host methods are scoped to the whole Boss turn, while an
530
+ // XState invocation can carry a narrower sibling-cancellation signal.
531
+ // Keep both frame and turn ownership after XState stops awaiting the
532
+ // promise so the host cannot outlive frame disposal or turn settlement.
533
+ const turnCalls = activeTurnHostCalls;
534
+ let tracked!: Promise<T>;
535
+ tracked = call.finally(() => {
536
+ frame.inFlightHostCalls.delete(tracked);
537
+ turnCalls?.delete(tracked);
538
+ });
539
+ frame.inFlightHostCalls.add(tracked);
540
+ turnCalls?.add(tracked);
541
+ return tracked;
542
+ };
310
543
 
311
- if (stateId === active.entry.finalStateId) {
312
- finalDisposalRequested = active;
313
- return;
544
+ const callCaptainQueued = (
545
+ frame: EngagementFrame,
546
+ context: CaptainContext,
547
+ prompt: string,
548
+ options: Parameters<CaptainContext['callCaptain']>[1],
549
+ signal: AbortSignal,
550
+ ): ReturnType<CaptainContext['callCaptain']> => {
551
+ const queued = captainQueue.add(async () => {
552
+ signal.throwIfAborted();
553
+ const result = await trackHostCall(
554
+ frame,
555
+ context.callCaptain(prompt, options),
556
+ );
557
+ signal.throwIfAborted();
558
+ return result;
559
+ });
560
+ return trackHostCall(frame, queued);
561
+ };
562
+
563
+ const mirrorSubRuntimeTelemetry = async (
564
+ frame: EngagementFrame,
565
+ payload: unknown,
566
+ ): Promise<void> => {
567
+ const record = payloadRecord(payload);
568
+ const state = playbookState(record?.state);
569
+ if (!record || !state) return;
570
+ const previousActiveIds = new Set(frame.state?.activeStateIds ?? []);
571
+ frame.state = state;
572
+
573
+ if (activeTurnSummary?.owner === frame) {
574
+ for (const stateId of state.activeStateIds) {
575
+ const newlyActive = !previousActiveIds.has(stateId);
576
+ const structuredEntry =
577
+ stateValueContains(record.to, stateId) &&
578
+ !stateValueContains(record.from, stateId);
579
+ if (!newlyActive && !structuredEntry) continue;
580
+ const countLabel = stateCountLabel(stateId, frame.entry);
581
+ if (countLabel) {
582
+ activeTurnSummary.stateCounts.set(
583
+ countLabel,
584
+ (activeTurnSummary.stateCounts.get(countLabel) ?? 0) + 1,
585
+ );
586
+ }
587
+ }
314
588
  }
315
589
 
316
- if (
317
- stateId === active.entry.idleStateId ||
318
- stateId === 'failed' ||
319
- stateId === 'awaitBossReply'
320
- ) {
321
- await setMode('engaged.parked', `sub-runtime:${stateId}`);
590
+ if (leafFrame() === frame) {
591
+ pendingBossQuestions =
592
+ record.pendingBossQuestions ?? record.pendingBossQuestion;
593
+ lastError = normalizeErrorCompact(record.lastError);
594
+ if (state.quiescent && state.tags.includes('playbook.parked')) {
595
+ await setMode(
596
+ 'engaged.parked',
597
+ `sub-runtime:${state.stateId ?? 'structured'}`,
598
+ );
599
+ }
322
600
  }
323
601
  };
324
602
 
325
- const createPorts = (): PlaybookPorts => ({
326
- callPlayer: async (playerId, prompt, _signal) => {
603
+ let callNestedPlaybook: (
604
+ frame: EngagementFrame,
605
+ request: PlaybookCallRequest,
606
+ signal: AbortSignal,
607
+ ) => Promise<PlaybookCallStart>;
608
+
609
+ const createPorts = (frame: EngagementFrame): PlaybookPorts => ({
610
+ callPlayer: async (playerId, prompt, signal, options) => {
327
611
  if (!activeContext) {
328
612
  throw new Error('callPlayer invoked outside a Boss turn');
329
613
  }
330
- const result = await activeContext.callPlayer(playerId, prompt);
331
- if (activeTurnSummary) {
614
+ const context = activeContext;
615
+ signal.throwIfAborted();
616
+ const hostPlayerId = frame.enablement.hostPlayerId(playerId);
617
+ const result = await trackHostCall(
618
+ frame,
619
+ context.callPlayer(hostPlayerId, prompt, {
620
+ resume: options.resume,
621
+ }),
622
+ );
623
+ // CaptainContext is turn-scoped and cannot accept a narrower XState
624
+ // invocation signal. Recheck after the host call so a sibling
625
+ // cancellation is still reported as aborted and cannot rotate a
626
+ // stopped branch's player token in the linked runtime.
627
+ signal.throwIfAborted();
628
+ if (activeTurnSummary?.owner === frame) {
332
629
  activeTurnSummary.counts.interruptions++;
333
630
  }
334
631
  return {
335
632
  status: result.status,
336
- finalText: result.finalText,
337
- error: result.error,
633
+ ...(result.resumeToken !== undefined
634
+ ? { resumeToken: result.resumeToken }
635
+ : {}),
636
+ ...(result.finalText !== undefined
637
+ ? { finalText: result.finalText }
638
+ : {}),
639
+ ...(result.error !== undefined ? { error: result.error } : {}),
640
+ };
641
+ },
642
+ callCaptain: async (prompt, signal, options) => {
643
+ if (!activeContext) {
644
+ throw new Error('callCaptain invoked outside a Boss turn');
645
+ }
646
+ const result = await callCaptainQueued(
647
+ frame,
648
+ activeContext,
649
+ prompt,
650
+ {
651
+ visibility: options.visibility,
652
+ resume: options.resume,
653
+ ...(options.allowedTools === undefined
654
+ ? {}
655
+ : { allowedTools: options.allowedTools }),
656
+ },
657
+ signal,
658
+ );
659
+ return {
660
+ status: result.status,
661
+ ...(result.finalText !== undefined
662
+ ? { finalText: result.finalText }
663
+ : {}),
664
+ ...(result.error !== undefined ? { error: result.error } : {}),
338
665
  };
339
666
  },
340
- callJudge: async (prompt, _signal) => {
667
+ callJudge: async (prompt, signal) => {
341
668
  if (!activeContext) {
342
669
  throw new Error('callJudge invoked outside a Boss turn');
343
670
  }
344
- const result = await activeContext.callCaptain(prompt, {
345
- visibility: 'hidden',
346
- });
671
+ const result = await callCaptainQueued(
672
+ frame,
673
+ activeContext,
674
+ prompt,
675
+ { visibility: 'hidden', resume: false, allowedTools: [] },
676
+ signal,
677
+ );
347
678
  if (result.status !== 'ok') {
348
679
  throw new Error(
349
680
  result.error ?? `callCaptain status "${result.status}"`,
@@ -355,14 +686,28 @@ export function createPlaybookCaptainShell(
355
686
  const guard = guardFromJudgeReply(result.finalText);
356
687
  if (
357
688
  guard &&
358
- active?.entry.copyPasteGuardNames.includes(guard) &&
359
- activeTurnSummary
689
+ activeTurnSummary?.owner === frame &&
690
+ frame.entry.summaryPolicy?.copyPasteGuardNames.includes(guard)
360
691
  ) {
361
692
  activeTurnSummary.counts.copyPastes++;
362
693
  }
363
694
  return result.finalText;
364
695
  },
696
+ callPlaybook: (request, signal) => {
697
+ const opening = callNestedPlaybook(frame, request, signal);
698
+ let exposed!: Promise<PlaybookCallStart>;
699
+ const registerOpeningCleanup = (): void => {
700
+ registerPlaybookAbortCleanup(signal, exposed);
701
+ };
702
+ exposed = opening.finally(() => {
703
+ signal.removeEventListener('abort', registerOpeningCleanup);
704
+ });
705
+ signal.addEventListener('abort', registerOpeningCleanup, { once: true });
706
+ if (signal.aborted) registerOpeningCleanup();
707
+ return exposed;
708
+ },
365
709
  emitStatus: async (message, data) => {
710
+ if (frame.internal) return;
366
711
  await requireSession().emitStatus(
367
712
  message,
368
713
  data as Record<string, unknown> | undefined,
@@ -370,168 +715,834 @@ export function createPlaybookCaptainShell(
370
715
  },
371
716
  emitTelemetry: async (event) => {
372
717
  if (event.topic === SUB_RUNTIME_FSM_TOPIC) {
373
- await mirrorSubRuntimeTelemetry(event.payload);
718
+ await mirrorSubRuntimeTelemetry(frame, event.payload);
374
719
  }
375
720
  await requireSession().emitTelemetry(event);
376
721
  },
377
722
  });
378
723
 
379
- const engage = async (
380
- entry: PlaybookCaptainRegistryEntry,
381
- ): Promise<ActiveEngagement> => {
382
- if (active?.entry.id === entry.id) return active;
724
+ // CAPTAIN-22: before dispatching to a playbook, request tmux-play
725
+ // visibility for that playbook's generated host players. A pane
726
+ // reconciliation failure is display-only in tmux-play and does not
727
+ // reject; the legacy path carries no generated set and skips this.
728
+ const requestVisibility = async (enablement: Enablement): Promise<void> => {
729
+ const ids = enablement.visiblePlayerIds;
730
+ if (!ids || ids.length === 0 || !activeContext) return;
731
+ try {
732
+ await activeContext.setVisiblePlayers(ids);
733
+ } catch (error) {
734
+ throw new VisibilityControlError(error);
735
+ }
736
+ };
737
+
738
+ const allocateSessionId = (): string => {
739
+ const sessionId = createSessionId();
740
+ if (!UUID_PATTERN.test(sessionId)) {
741
+ throw new Error(
742
+ `playbook session id generator returned a non-UUID value: ${JSON.stringify(
743
+ sessionId,
744
+ )}`,
745
+ );
746
+ }
747
+ if (issuedSessionIds.has(sessionId)) {
748
+ throw new Error(`playbook session id collision: ${sessionId}`);
749
+ }
750
+ issuedSessionIds.add(sessionId);
751
+ return sessionId;
752
+ };
753
+
754
+ const normalizeErrorFull = (value: unknown): NormalizedError => {
755
+ const compact = normalizeErrorCompact(value) ?? {
756
+ name: 'Error',
757
+ message: String(value),
758
+ };
759
+ const stack =
760
+ value instanceof Error
761
+ ? value.stack
762
+ : typeof value === 'object' && value !== null
763
+ ? (value as Record<string, unknown>).stack
764
+ : undefined;
765
+ return typeof stack === 'string' ? { ...compact, stack } : compact;
766
+ };
767
+
768
+ const makeFrame = (
769
+ enablement: Enablement,
770
+ parent?: { frame: EngagementFrame; callId: string },
771
+ internal = false,
772
+ ): EngagementFrame => {
773
+ const entry = enablement.entry;
774
+ const sessionId = allocateSessionId();
383
775
  const runtime = entry.createRuntime({
384
- captainOptions: options,
385
- players,
776
+ captainOptions: enablement.optionInput,
777
+ players: enablement.boundPlayers,
778
+ });
779
+ return {
780
+ entry,
781
+ enablement,
782
+ runtime,
783
+ sessionId,
784
+ rootSessionId: parent?.frame.rootSessionId ?? sessionId,
785
+ depth: parent ? parent.frame.depth + 1 : 0,
786
+ ...(parent ? { parent } : {}),
787
+ inFlightHostCalls: new Set(),
788
+ internal,
789
+ };
790
+ };
791
+
792
+ const initFrame = async (frame: EngagementFrame): Promise<void> => {
793
+ await frame.runtime.init({
794
+ sessionId: frame.sessionId,
795
+ playbookId: frame.entry.id,
796
+ rootSessionId: frame.rootSessionId,
797
+ ...(frame.parent
798
+ ? {
799
+ parentSessionId: frame.parent.frame.sessionId,
800
+ parentCallId: frame.parent.callId,
801
+ }
802
+ : {}),
803
+ depth: frame.depth,
804
+ ports: createPorts(frame),
386
805
  });
387
- active = { entry, runtime };
388
- latestSubRuntimeStateId = undefined;
389
- pendingBossQuestion = undefined;
806
+ };
807
+
808
+ const clearLeafLedger = (): void => {
809
+ pendingBossQuestions = undefined;
390
810
  lastError = undefined;
391
- finalDisposalRequested = undefined;
392
- await setMode('engaged.parked', 'engage', entry.id);
393
- await runtime.init(createPorts());
394
- await requireSession().emitStatus(
395
- `◇ ${playbookCommandLabel(entry)} started`,
811
+ };
812
+
813
+ const engageEnablement = async (
814
+ enablement: Enablement,
815
+ internal: boolean,
816
+ ): Promise<EngagementFrame> => {
817
+ const entry = enablement.entry;
818
+ const existing = rootFrame();
819
+ if (existing?.entry.id === entry.id && frames.length === 1) {
820
+ return existing;
821
+ }
822
+ if (existing) {
823
+ throw new Error('cannot engage a second root playbook');
824
+ }
825
+ const frame = makeFrame(enablement, undefined, internal);
826
+ frames.push(frame);
827
+ clearLeafLedger();
828
+ try {
829
+ await setMode('engaged.parked', 'engage', entry.id, frame.sessionId);
830
+ await initFrame(frame);
831
+ if (!internal) {
832
+ await requireSession().emitStatus(`◇ ${frameLabel(frame)} started`);
833
+ }
834
+ return frame;
835
+ } catch (error) {
836
+ if (leafFrame() === frame) frames.pop();
837
+ clearLeafLedger();
838
+ try {
839
+ await frame.runtime.dispose();
840
+ } catch {
841
+ // Preserve the initialization failure while still making a
842
+ // best-effort attempt to release partially acquired resources.
843
+ }
844
+ try {
845
+ await setMode('chat', 'engage.failed');
846
+ } catch {
847
+ // setMode updates the authoritative mode before telemetry; preserve
848
+ // the initialization failure if that recovery emission also fails.
849
+ mode = 'chat';
850
+ }
851
+ throw error;
852
+ }
853
+ };
854
+
855
+ const engage = async (
856
+ entry: PlaybookCaptainRegistryEntry,
857
+ ): Promise<EngagementFrame> =>
858
+ engageEnablement(enablementById.get(entry.id)!, false);
859
+
860
+ const createInternalCaptainEnablement = (): Enablement => {
861
+ const catalog = Object.freeze(
862
+ entries.map((entry) =>
863
+ Object.freeze({
864
+ id: entry.id,
865
+ command: enablementById.get(entry.id)!.command,
866
+ intent: entry.intent,
867
+ }),
868
+ ),
869
+ );
870
+ const entry: PlaybookCaptainRegistryEntry = {
871
+ id: INTERNAL_CAPTAIN_ID,
872
+ command: INTERNAL_CAPTAIN_ID,
873
+ intent: 'internal orchestration policy',
874
+ requiredRoleIds: [],
875
+ validateOptions: () => undefined,
876
+ createRuntime: () => createCaptainRuntime({ enabledPlaybooks: catalog }),
877
+ };
878
+ return {
879
+ entry,
880
+ command: INTERNAL_CAPTAIN_ID,
881
+ optionInput: undefined,
882
+ boundPlayers: [],
883
+ hostPlayerId(localRole) {
884
+ throw new Error(
885
+ `internal Captain has no player binding for ${JSON.stringify(localRole)}`,
886
+ );
887
+ },
888
+ };
889
+ };
890
+
891
+ const engageInternalCaptain = async (): Promise<EngagementFrame> => {
892
+ if (!internalCaptainEnablement) {
893
+ throw new Error('internal Captain enablement is unavailable before init');
894
+ }
895
+ return engageEnablement(internalCaptainEnablement, true);
896
+ };
897
+
898
+ const disposeFrame = (frame: EngagementFrame): Promise<void> => {
899
+ if (frame.disposePromise) return frame.disposePromise;
900
+ const operation = (async (): Promise<void> => {
901
+ if (frame.invocationSignal && frame.abortListener) {
902
+ frame.invocationSignal.removeEventListener(
903
+ 'abort',
904
+ frame.abortListener,
905
+ );
906
+ }
907
+ frame.invocationSignal = undefined;
908
+ frame.abortListener = undefined;
909
+ let disposeError: unknown;
910
+ try {
911
+ await frame.runtime.dispose();
912
+ } catch (error) {
913
+ disposeError = error;
914
+ }
915
+ await drainHostCalls(frame.inFlightHostCalls);
916
+ if (disposeError !== undefined) throw disposeError;
917
+ })();
918
+ frame.disposePromise = operation;
919
+ return operation;
920
+ };
921
+
922
+ const removeTopFrame = (
923
+ frame: EngagementFrame,
924
+ reason: NonNullable<EngagementFrame['removal']>['reason'],
925
+ ): {
926
+ claimed: boolean;
927
+ reason: NonNullable<EngagementFrame['removal']>['reason'];
928
+ promise: Promise<void>;
929
+ } => {
930
+ if (frame.removal) {
931
+ return {
932
+ claimed: false,
933
+ reason: frame.removal.reason,
934
+ promise: frame.removal.promise,
935
+ };
936
+ }
937
+ const operation = (async (): Promise<void> => {
938
+ if (leafFrame() !== frame) {
939
+ throw new Error('nested playbook stack is not LIFO');
940
+ }
941
+ let removalError: unknown;
942
+ try {
943
+ await disposeFrame(frame);
944
+ } catch (error) {
945
+ removalError = error;
946
+ } finally {
947
+ if (leafFrame() === frame) {
948
+ frames.pop();
949
+ if (frame.parent) {
950
+ pendingChildParents.delete(frame.parent.frame);
951
+ }
952
+ pendingChildParents.delete(frame);
953
+ } else if (frames.includes(frame)) {
954
+ const stackError = new Error(
955
+ 'nested playbook stack changed during frame removal',
956
+ );
957
+ removalError =
958
+ removalError === undefined
959
+ ? stackError
960
+ : new AggregateError(
961
+ [removalError, stackError],
962
+ 'nested playbook frame removal failed',
963
+ );
964
+ }
965
+ }
966
+ if (removalError !== undefined) throw removalError;
967
+ })();
968
+ frame.removal = { reason, promise: operation };
969
+ return { claimed: true, reason, promise: operation };
970
+ };
971
+
972
+ const unwindFramesFrom = async (
973
+ frame: EngagementFrame,
974
+ reason: NonNullable<EngagementFrame['removal']>['reason'] = 'stack',
975
+ ): Promise<void> => {
976
+ const index = frames.indexOf(frame);
977
+ if (index < 0) return;
978
+ const failures: unknown[] = [];
979
+ while (frames.length > index) {
980
+ const current = leafFrame()!;
981
+ const removal = removeTopFrame(current, reason);
982
+ try {
983
+ await removal.promise;
984
+ } catch (error) {
985
+ failures.push(error);
986
+ }
987
+ if (frames.includes(current)) {
988
+ failures.push(
989
+ new Error('nested playbook frame remained after removal attempt'),
990
+ );
991
+ break;
992
+ }
993
+ }
994
+ clearLeafLedger();
995
+ if (failures.length === 1) throw failures[0];
996
+ if (failures.length > 1) {
997
+ throw new AggregateError(
998
+ failures,
999
+ 'nested playbook stack disposal failed',
1000
+ );
1001
+ }
1002
+ };
1003
+
1004
+ const popChild = async (
1005
+ frame: EngagementFrame,
1006
+ status: 'returned' | 'stopped',
1007
+ ): Promise<boolean> => {
1008
+ if (!frame.parent || (leafFrame() !== frame && !frame.removal)) {
1009
+ throw new Error('nested playbook stack is not LIFO');
1010
+ }
1011
+ const parent = frame.parent.frame;
1012
+ const removal = removeTopFrame(frame, 'return');
1013
+ if (!removal.claimed) {
1014
+ await removal.promise;
1015
+ return false;
1016
+ }
1017
+ let cleanupError: unknown;
1018
+ try {
1019
+ await removal.promise;
1020
+ } catch (error) {
1021
+ cleanupError = error;
1022
+ }
1023
+ const message =
1024
+ status === 'returned'
1025
+ ? `◇ ${frameLabel(frame)} returned to ${frameLabel(parent)}`
1026
+ : `◇ ${frameLabel(frame)} stopped; returning to ${frameLabel(parent)}`;
1027
+ try {
1028
+ await requireSession().emitStatus(message);
1029
+ } catch (error) {
1030
+ cleanupError =
1031
+ cleanupError === undefined
1032
+ ? error
1033
+ : new AggregateError(
1034
+ [cleanupError, error],
1035
+ 'nested playbook return cleanup failed',
1036
+ );
1037
+ }
1038
+ let visibilityError: unknown;
1039
+ try {
1040
+ await requestVisibility(parent.enablement);
1041
+ } catch (error) {
1042
+ visibilityError = error;
1043
+ }
1044
+ if (visibilityError !== undefined) {
1045
+ if (cleanupError !== undefined) {
1046
+ throw new VisibilityControlError(
1047
+ new AggregateError(
1048
+ [cleanupError, visibilityError],
1049
+ 'nested playbook return and visibility failed',
1050
+ ),
1051
+ );
1052
+ }
1053
+ throw visibilityError;
1054
+ }
1055
+ if (cleanupError !== undefined) throw cleanupError;
1056
+ return true;
1057
+ };
1058
+
1059
+ const disposeStack = async (reason: DisposalReason): Promise<void> => {
1060
+ const root = rootFrame();
1061
+ if (!root) return;
1062
+ const rootId = root.entry.id;
1063
+ const rootSessionId = root.sessionId;
1064
+ const failures: unknown[] = [];
1065
+ disposing = true;
1066
+ try {
1067
+ if (reason !== 'dispose') {
1068
+ try {
1069
+ await setMode('chat', reason, rootId, rootSessionId);
1070
+ } catch (error) {
1071
+ failures.push(error);
1072
+ mode = 'chat';
1073
+ }
1074
+ } else {
1075
+ mode = 'chat';
1076
+ }
1077
+ try {
1078
+ await unwindFramesFrom(root);
1079
+ } catch (error) {
1080
+ failures.push(error);
1081
+ }
1082
+ } finally {
1083
+ disposing = false;
1084
+ pendingChildParents.clear();
1085
+ clearLeafLedger();
1086
+ }
1087
+ if (!root.internal) {
1088
+ try {
1089
+ if (reason === 'dismiss') {
1090
+ await requireSession().emitStatus(`◇ ${frameLabel(root)} stopped`);
1091
+ } else if (reason === 'final') {
1092
+ await requireSession().emitStatus(`◇ ${frameLabel(root)} finished`);
1093
+ }
1094
+ } catch (error) {
1095
+ failures.push(error);
1096
+ }
1097
+ }
1098
+ if (failures.length === 1) throw failures[0];
1099
+ if (failures.length > 1) {
1100
+ throw new AggregateError(failures, 'playbook stack disposal failed');
1101
+ }
1102
+ };
1103
+
1104
+ const callResultFor = (
1105
+ frame: EngagementFrame,
1106
+ result: PlaybookRunResult,
1107
+ ): PlaybookCallResult => {
1108
+ if (result.outcome === 'terminal') {
1109
+ return {
1110
+ status: 'ok',
1111
+ playbookId: frame.entry.id,
1112
+ childSessionId: frame.sessionId,
1113
+ state: result.state,
1114
+ ...(result.output !== undefined ? { output: result.output } : {}),
1115
+ };
1116
+ }
1117
+ if (result.outcome === 'aborted') {
1118
+ return {
1119
+ status: 'aborted',
1120
+ playbookId: frame.entry.id,
1121
+ childSessionId: frame.sessionId,
1122
+ state: result.state,
1123
+ ...(result.error ? { error: result.error } : {}),
1124
+ };
1125
+ }
1126
+ throw new Error(`playbook ${frame.entry.id} has not returned`);
1127
+ };
1128
+
1129
+ const assertRetainableResult = (
1130
+ frame: EngagementFrame,
1131
+ result: PlaybookRunResult,
1132
+ ): void => {
1133
+ if (result.outcome === 'suspended') return;
1134
+ if (
1135
+ result.state.quiescent &&
1136
+ result.state.tags.includes('playbook.parked')
1137
+ ) {
1138
+ return;
1139
+ }
1140
+ throw new Error(
1141
+ `playbook ${frame.entry.id} returned outcome "${result.outcome}" ` +
1142
+ 'without a quiescent playbook.parked state',
396
1143
  );
397
- return active;
1144
+ };
1145
+
1146
+ const driveFrame = async (
1147
+ frame: EngagementFrame,
1148
+ text: string,
1149
+ context: CaptainContext,
1150
+ signal: AbortSignal = context.signal,
1151
+ ): Promise<PlaybookRunResult> => {
1152
+ if (leafFrame() !== frame) {
1153
+ throw new Error('only the active leaf may receive Boss input');
1154
+ }
1155
+ await requestVisibility(frame.enablement);
1156
+ await setMode('engaged.driving', 'submit');
1157
+ const result = await frame.runtime.handleBossInput({
1158
+ text,
1159
+ signal,
1160
+ });
1161
+ frame.state = result.state;
1162
+ return result;
1163
+ };
1164
+
1165
+ async function resumeParent(
1166
+ child: EngagementFrame,
1167
+ callResult: PlaybookCallResult,
1168
+ context: CaptainContext,
1169
+ status: 'returned' | 'stopped' = 'returned',
1170
+ ): Promise<void> {
1171
+ const parentLink = child.parent;
1172
+ if (!parentLink) throw new Error('root playbook has no caller');
1173
+ const parent = parentLink.frame;
1174
+ const invocationSignal = child.invocationSignal;
1175
+ let effectiveResult = callResult;
1176
+ let ownsReturn = false;
1177
+ let visibilityControlError: unknown;
1178
+ try {
1179
+ ownsReturn = await popChild(child, status);
1180
+ } catch (error) {
1181
+ ownsReturn = child.removal?.reason === 'return';
1182
+ if (error instanceof VisibilityControlError) {
1183
+ visibilityControlError = error;
1184
+ } else {
1185
+ effectiveResult = {
1186
+ status: context.signal.aborted ? 'aborted' : 'error',
1187
+ playbookId: child.entry.id,
1188
+ childSessionId: child.sessionId,
1189
+ ...(child.state ? { state: child.state } : {}),
1190
+ error: normalizeErrorFull(error),
1191
+ };
1192
+ }
1193
+ }
1194
+ if (
1195
+ !ownsReturn ||
1196
+ disposing ||
1197
+ invocationSignal?.aborted ||
1198
+ !frames.includes(parent)
1199
+ ) {
1200
+ return;
1201
+ }
1202
+ let result: PlaybookRunResult;
1203
+ try {
1204
+ result = await parent.runtime.resumePlaybookCall({
1205
+ callId: parentLink.callId,
1206
+ result: effectiveResult,
1207
+ signal: context.signal,
1208
+ });
1209
+ } catch (error) {
1210
+ if (disposing || invocationSignal?.aborted) return;
1211
+ await returnBoundaryFailure(parent, error, context);
1212
+ return;
1213
+ }
1214
+ parent.state = result.state;
1215
+ await processFrameResult(parent, result, context);
1216
+ if (visibilityControlError !== undefined) throw visibilityControlError;
1217
+ }
1218
+
1219
+ async function returnBoundaryFailure(
1220
+ frame: EngagementFrame,
1221
+ error: unknown,
1222
+ context: CaptainContext,
1223
+ ): Promise<void> {
1224
+ if (!frame.parent) throw error;
1225
+ await resumeParent(
1226
+ frame,
1227
+ {
1228
+ status: context.signal.aborted ? 'aborted' : 'error',
1229
+ playbookId: frame.entry.id,
1230
+ childSessionId: frame.sessionId,
1231
+ ...(frame.state ? { state: frame.state } : {}),
1232
+ error: normalizeErrorFull(error),
1233
+ },
1234
+ context,
1235
+ );
1236
+ }
1237
+
1238
+ async function processFrameResult(
1239
+ frame: EngagementFrame,
1240
+ result: PlaybookRunResult,
1241
+ context: CaptainContext,
1242
+ ): Promise<void> {
1243
+ if (result.outcome === 'terminal') {
1244
+ if (frame.parent) {
1245
+ await resumeParent(frame, callResultFor(frame, result), context);
1246
+ } else {
1247
+ await disposeStack('final');
1248
+ }
1249
+ return;
1250
+ }
1251
+ if (result.outcome === 'aborted' && frame.parent) {
1252
+ await resumeParent(frame, callResultFor(frame, result), context);
1253
+ return;
1254
+ }
1255
+ assertRetainableResult(frame, result);
1256
+ if (leafFrame()) {
1257
+ await setMode('engaged.parked', `turn:${result.outcome}`);
1258
+ }
1259
+ }
1260
+
1261
+ const disposeAbandonedChild = async (
1262
+ child: EngagementFrame,
1263
+ ): Promise<void> => {
1264
+ if (disposing || !frames.includes(child) || !child.parent) return;
1265
+ if (child.removal) {
1266
+ await child.removal.promise;
1267
+ return;
1268
+ }
1269
+ const parent = child.parent.frame;
1270
+ let cleanupError: unknown;
1271
+ try {
1272
+ await unwindFramesFrom(child, 'abandoned');
1273
+ } catch (error) {
1274
+ cleanupError = error;
1275
+ }
1276
+ if (frames.includes(parent)) {
1277
+ await requestVisibility(parent.enablement);
1278
+ }
1279
+ if (cleanupError !== undefined) throw cleanupError;
1280
+ };
1281
+
1282
+ callNestedPlaybook = async (
1283
+ parent,
1284
+ request,
1285
+ invocationSignal,
1286
+ ): Promise<PlaybookCallStart> => {
1287
+ if (!activeContext) {
1288
+ throw new Error('callPlaybook invoked outside a Boss turn');
1289
+ }
1290
+ invocationSignal.throwIfAborted();
1291
+ if (leafFrame() !== parent) {
1292
+ throw new Error('only the active leaf may call a child playbook');
1293
+ }
1294
+ if (pendingChildParents.has(parent)) {
1295
+ throw new Error('playbook frame already has an outstanding child');
1296
+ }
1297
+ if (typeof request.callId !== 'string' || request.callId.trim() === '') {
1298
+ throw new Error('nested playbook call id must be a non-empty string');
1299
+ }
1300
+ if (
1301
+ typeof request.playbookId !== 'string' ||
1302
+ request.playbookId.trim() === ''
1303
+ ) {
1304
+ throw new Error('nested playbook id must be a non-empty string');
1305
+ }
1306
+ if (typeof request.text !== 'string') {
1307
+ throw new Error('nested playbook input text must be a string');
1308
+ }
1309
+ if (request.playbookId === INTERNAL_CAPTAIN_ID) {
1310
+ throw new Error('the internal Captain playbook cannot call itself');
1311
+ }
1312
+ const entry = byId.get(request.playbookId);
1313
+ if (!entry) {
1314
+ throw new Error(`playbook "${request.playbookId}" is not enabled`);
1315
+ }
1316
+ if (frames.some((frame) => frame.entry.id === request.playbookId)) {
1317
+ throw new Error(
1318
+ `nested playbook cycle: ${[
1319
+ ...frames.map((frame) => frame.entry.id),
1320
+ request.playbookId,
1321
+ ].join(' -> ')}`,
1322
+ );
1323
+ }
1324
+
1325
+ pendingChildParents.add(parent);
1326
+ let child: EngagementFrame;
1327
+ try {
1328
+ child = makeFrame(enablementById.get(entry.id)!, {
1329
+ frame: parent,
1330
+ callId: request.callId,
1331
+ });
1332
+ } catch (error) {
1333
+ pendingChildParents.delete(parent);
1334
+ throw error;
1335
+ }
1336
+ frames.push(child);
1337
+ clearLeafLedger();
1338
+ let calledStatusEmitted = false;
1339
+ let returnStatusHandled = false;
1340
+ try {
1341
+ await initFrame(child);
1342
+ invocationSignal.throwIfAborted();
1343
+ await requireSession().emitStatus(
1344
+ `◇ ${frameLabel(child)} called by ${frameLabel(parent)}`,
1345
+ );
1346
+ calledStatusEmitted = true;
1347
+ const result = await driveFrame(
1348
+ child,
1349
+ request.text,
1350
+ activeContext,
1351
+ AbortSignal.any([invocationSignal, activeContext.signal]),
1352
+ );
1353
+ if (result.outcome === 'terminal' || result.outcome === 'aborted') {
1354
+ const callResult = callResultFor(child, result);
1355
+ returnStatusHandled = true;
1356
+ const returned = await popChild(
1357
+ child,
1358
+ result.outcome === 'aborted' ? 'stopped' : 'returned',
1359
+ );
1360
+ if (!returned) {
1361
+ throw new Error('nested playbook return lost its active frame');
1362
+ }
1363
+ return { state: 'settled', result: callResult };
1364
+ }
1365
+ assertRetainableResult(child, result);
1366
+ if (invocationSignal.aborted) {
1367
+ const callResult: PlaybookCallResult = {
1368
+ status: 'aborted',
1369
+ playbookId: child.entry.id,
1370
+ childSessionId: child.sessionId,
1371
+ state: result.state,
1372
+ };
1373
+ returnStatusHandled = true;
1374
+ const returned = await popChild(child, 'stopped');
1375
+ if (!returned) {
1376
+ throw new Error('nested playbook abort lost its active frame');
1377
+ }
1378
+ return { state: 'settled', result: callResult };
1379
+ }
1380
+ const abortListener = (): void => {
1381
+ registerPlaybookAbortCleanup(
1382
+ invocationSignal,
1383
+ disposeAbandonedChild(child),
1384
+ );
1385
+ };
1386
+ child.invocationSignal = invocationSignal;
1387
+ child.abortListener = abortListener;
1388
+ invocationSignal.addEventListener('abort', abortListener, { once: true });
1389
+ return { state: 'suspended', childSessionId: child.sessionId };
1390
+ } catch (error) {
1391
+ let boundaryError = error;
1392
+ let visibilityControlFailure = error instanceof VisibilityControlError;
1393
+ if (frames.includes(child)) {
1394
+ try {
1395
+ await unwindFramesFrom(child, 'stack');
1396
+ } catch (cleanupError) {
1397
+ boundaryError = new AggregateError(
1398
+ [error, cleanupError],
1399
+ 'nested playbook call and cleanup failed',
1400
+ );
1401
+ }
1402
+ }
1403
+ pendingChildParents.delete(parent);
1404
+ if (calledStatusEmitted && !returnStatusHandled) {
1405
+ try {
1406
+ await requireSession().emitStatus(
1407
+ `◇ ${frameLabel(child)} stopped; returning to ${frameLabel(parent)}`,
1408
+ );
1409
+ } catch (statusError) {
1410
+ boundaryError = new AggregateError(
1411
+ [boundaryError, statusError],
1412
+ 'nested playbook failure status emission failed',
1413
+ );
1414
+ }
1415
+ }
1416
+ if (frames.includes(parent)) {
1417
+ try {
1418
+ await requestVisibility(parent.enablement);
1419
+ } catch (visibilityError) {
1420
+ visibilityControlFailure = true;
1421
+ boundaryError = new AggregateError(
1422
+ [boundaryError, visibilityError],
1423
+ 'nested playbook call return failed',
1424
+ );
1425
+ }
1426
+ }
1427
+ if (visibilityControlFailure) throw boundaryError;
1428
+ return {
1429
+ state: 'settled',
1430
+ result: {
1431
+ status: invocationSignal.aborted ? 'aborted' : 'error',
1432
+ playbookId: request.playbookId,
1433
+ childSessionId: child.sessionId,
1434
+ error: normalizeErrorFull(boundaryError),
1435
+ },
1436
+ };
1437
+ }
398
1438
  };
399
1439
 
400
1440
  const submitToActive = async (
401
- engagement: ActiveEngagement,
1441
+ frame: EngagementFrame,
402
1442
  text: string,
403
1443
  context: CaptainContext,
404
1444
  ): Promise<void> => {
1445
+ const policy = frame.entry.summaryPolicy;
405
1446
  const summaryCounts: TurnSummaryCounts = {
406
1447
  interruptions: 0,
407
1448
  copyPastes: 0,
408
1449
  };
409
1450
  const summaryStateCounts = new Map<string, number>();
410
- let shouldSummarize = false;
411
- activeTurnSummary = {
412
- counts: summaryCounts,
413
- stateCounts: summaryStateCounts,
414
- };
415
- await setMode('engaged.driving', 'submit');
1451
+ activeTurnSummary = policy
1452
+ ? {
1453
+ owner: frame,
1454
+ counts: summaryCounts,
1455
+ stateCounts: summaryStateCounts,
1456
+ }
1457
+ : undefined;
1458
+ let completed = false;
416
1459
  try {
417
- await engagement.runtime.handleBossInput({
418
- text,
419
- signal: context.signal,
420
- });
421
- shouldSummarize = true;
1460
+ const result = await driveFrame(frame, text, context);
1461
+ await processFrameResult(frame, result, context);
1462
+ completed = true;
1463
+ } catch (error) {
1464
+ if (frame.parent && frames.includes(frame)) {
1465
+ await returnBoundaryFailure(frame, error, context);
1466
+ completed = true;
1467
+ } else {
1468
+ throw error;
1469
+ }
422
1470
  } finally {
423
1471
  activeTurnSummary = undefined;
424
- if (active === engagement && finalDisposalRequested === engagement) {
425
- finalDisposalRequested = undefined;
426
- await disposeActive('final');
427
- } else if (active === engagement && mode === 'engaged.driving') {
1472
+ if (leafFrame() && mode === 'engaged.driving') {
428
1473
  await setMode('engaged.parked', 'turn.settled');
429
1474
  }
430
1475
  }
431
- if (shouldSummarize) {
432
- await callVisibleTurnSummary(context, {
433
- playbookId: engagement.entry.id,
1476
+ if (completed && policy) {
1477
+ const progressRounds = summaryProgressRoundCount(summaryStateCounts);
1478
+ await callVisibleTurnSummary(frame, context, {
1479
+ playbookId: frame.entry.id,
434
1480
  submittedText: text,
435
1481
  counts: summaryCounts,
436
1482
  progressPhrase: summaryProgressPhrase(summaryStateCounts),
437
- reviewRebuttalRounds: summaryProgressRoundCount(summaryStateCounts),
1483
+ progressRounds,
1484
+ savedLine: policy.savedCountsLine(summaryCounts, progressRounds),
438
1485
  });
439
1486
  }
440
1487
  };
441
1488
 
442
- const disposeActive = async (
443
- reason: DisposalReason,
444
- ): Promise<void> => {
445
- const engagement = active;
446
- if (!engagement) return;
447
- const playbookId = engagement.entry.id;
448
- const commandLabel = playbookCommandLabel(engagement.entry);
449
- active = undefined;
450
- finalDisposalRequested = undefined;
451
- if (reason === 'dispose') {
452
- mode = 'chat';
453
- await engagement.runtime.dispose();
454
- latestSubRuntimeStateId = undefined;
455
- pendingBossQuestion = undefined;
456
- lastError = undefined;
457
- return;
458
- }
459
- await setMode('chat', reason, playbookId);
460
- await engagement.runtime.dispose();
461
- if (reason === 'dismiss') {
462
- await requireSession().emitStatus(`◇ ${commandLabel} stopped`);
463
- } else if (reason === 'final') {
464
- await requireSession().emitStatus(`◇ ${commandLabel} finished`);
465
- }
466
- latestSubRuntimeStateId = undefined;
467
- pendingBossQuestion = undefined;
468
- lastError = undefined;
469
- };
470
-
471
1489
  const callVisibleChat = async (
1490
+ frame: EngagementFrame,
472
1491
  context: CaptainContext,
473
1492
  message: string,
474
1493
  ): Promise<void> => {
475
- const result = await context.callCaptain(visibleChatEnvelope(message));
1494
+ const result = await callCaptainQueued(
1495
+ frame,
1496
+ context,
1497
+ visibleChatEnvelope(message),
1498
+ { visibility: 'visible', resume: false, allowedTools: [] },
1499
+ context.signal,
1500
+ );
476
1501
  if (result.status !== 'ok') {
477
- throw new Error(
478
- result.error ?? `callCaptain status "${result.status}"`,
479
- );
1502
+ throw new Error(result.error ?? `callCaptain status "${result.status}"`);
480
1503
  }
481
1504
  };
482
1505
 
483
1506
  const callVisibleTurnSummary = async (
1507
+ frame: EngagementFrame,
484
1508
  context: CaptainContext,
485
1509
  input: {
486
1510
  playbookId: string;
487
1511
  submittedText: string;
488
1512
  counts: TurnSummaryCounts;
489
1513
  progressPhrase: string;
490
- reviewRebuttalRounds: number;
1514
+ progressRounds: number;
1515
+ savedLine: string;
491
1516
  },
492
1517
  ): Promise<void> => {
493
- const result = await context.callCaptain(visibleTurnSummaryEnvelope(input));
1518
+ const result = await callCaptainQueued(
1519
+ frame,
1520
+ context,
1521
+ visibleTurnSummaryEnvelope(input),
1522
+ { visibility: 'visible', resume: false, allowedTools: [] },
1523
+ context.signal,
1524
+ );
494
1525
  if (result.status !== 'ok') {
495
- throw new Error(
496
- result.error ?? `callCaptain status "${result.status}"`,
497
- );
1526
+ throw new Error(result.error ?? `callCaptain status "${result.status}"`);
498
1527
  }
499
1528
  };
500
1529
 
501
- const hiddenRouterEnvelope = (prompt: string): string =>
1530
+ const hiddenLifecycleEnvelope = (prompt: string): string =>
502
1531
  [
503
- 'You are the Playbook Captain shell router.',
1532
+ 'You are the Playbook Captain shell lifecycle classifier.',
504
1533
  'This is hidden control work. Return only one JSON object and no prose.',
505
1534
  'Allowed decisions:',
506
- '{"decision":"chat","text":"visible clarification or chat reply"}',
507
- '{"decision":"dispatch","playbookId":"<registered id>","text":"Boss text for that playbook"}',
508
- '{"decision":"sub","text":"Boss text for the active playbook"}',
509
- '{"decision":"dismiss","text":"optional visible dismissal reply"}',
510
- 'Use chat for near-miss command-like input or low-confidence playbook selection.',
511
- 'Treat unregistered slash-prefixed input as ordinary router input.',
512
- `Ledger:\n${JSON.stringify(ledgerSnapshot())}`,
513
- `Registry:\n${JSON.stringify(
514
- entries.map((entry) => ({
515
- id: entry.id,
516
- command: entry.command,
517
- intent: entry.intent,
518
- })),
519
- )}`,
1535
+ '{"decision":"deliver"}',
1536
+ '{"decision":"dismiss"}',
1537
+ 'Choose dismiss only when Boss explicitly asks to stop or dismiss the current active engagement.',
1538
+ 'Choose deliver for every task instruction, answer, clarification, continuation, command-like near miss, or ambiguous message.',
1539
+ 'Do not rewrite, summarize, or copy the Boss message into the result.',
520
1540
  `Boss message:\n${prompt}`,
521
1541
  ].join('\n\n');
522
1542
 
523
- const routerClarification = async (
524
- context: CaptainContext,
525
- ): Promise<void> => {
526
- await callVisibleChat(
527
- context,
528
- "I'm not sure whether this should be Captain chat or a /code task. Please clarify.",
529
- );
530
- };
531
-
532
- const parseRouterDecision = (
1543
+ const parseLifecycleDecision = (
533
1544
  finalText: string,
534
- ): RouterDecision | undefined => {
1545
+ ): LifecycleDecision | undefined => {
535
1546
  let parsed: unknown;
536
1547
  try {
537
1548
  parsed = JSON.parse(finalText);
@@ -547,91 +1558,57 @@ export function createPlaybookCaptainShell(
547
1558
  }
548
1559
  const record = parsed as Record<string, unknown>;
549
1560
  const decision = record.decision;
550
- if (decision === 'chat') {
551
- return typeof record.text === 'string' && record.text.trim()
552
- ? { decision, text: record.text.trim() }
553
- : undefined;
554
- }
555
- if (decision === 'dispatch') {
556
- return typeof record.playbookId === 'string' &&
557
- byId.has(record.playbookId) &&
558
- typeof record.text === 'string' &&
559
- record.text.trim()
560
- ? {
561
- decision,
562
- playbookId: record.playbookId,
563
- text: record.text.trim(),
564
- }
565
- : undefined;
566
- }
567
- if (decision === 'sub') {
568
- return typeof record.text === 'string' && record.text.trim()
569
- ? { decision, text: record.text.trim() }
570
- : undefined;
571
- }
572
- if (decision === 'dismiss') {
573
- return typeof record.text === 'string' && record.text.trim()
574
- ? { decision, text: record.text.trim() }
575
- : { decision };
576
- }
1561
+ if (decision === 'deliver') return { decision };
1562
+ if (decision === 'dismiss') return { decision };
577
1563
  return undefined;
578
1564
  };
579
1565
 
580
- const routeHidden = async (
1566
+ const routeEngaged = async (
581
1567
  turn: BossTurn,
582
1568
  context: CaptainContext,
583
1569
  ): Promise<void> => {
584
- const result = await context.callCaptain(
585
- hiddenRouterEnvelope(turn.prompt),
586
- { visibility: 'hidden' },
587
- );
588
- if (result.status !== 'ok' || result.finalText === undefined) {
589
- await routerClarification(context);
590
- return;
591
- }
592
- const decision = parseRouterDecision(result.finalText);
593
- if (!decision) {
594
- await routerClarification(context);
595
- return;
596
- }
597
- lastRouteDecision = decision.decision;
598
-
599
- if (decision.decision === 'chat') {
600
- await callVisibleChat(context, decision.text);
601
- return;
1570
+ const leaf = leafFrame();
1571
+ if (!leaf) {
1572
+ throw new Error('engaged lifecycle routing requires an active leaf');
602
1573
  }
603
-
604
- if (decision.decision === 'dispatch') {
605
- const entry = byId.get(decision.playbookId);
606
- if (!entry || (active && active.entry.id !== entry.id)) {
607
- await routerClarification(context);
608
- return;
1574
+ let decision: LifecycleDecision | undefined;
1575
+ try {
1576
+ const result = await callCaptainQueued(
1577
+ leaf,
1578
+ context,
1579
+ hiddenLifecycleEnvelope(turn.prompt),
1580
+ { visibility: 'hidden', resume: false, allowedTools: [] },
1581
+ context.signal,
1582
+ );
1583
+ if (result.status === 'ok' && result.finalText !== undefined) {
1584
+ decision = parseLifecycleDecision(result.finalText);
609
1585
  }
610
- const engagement = await engage(entry);
611
- await submitToActive(engagement, decision.text, context);
612
- return;
1586
+ } catch {
1587
+ // Lifecycle classification is advisory. Delivery is fail-open so an
1588
+ // unavailable classifier can never consume a parked leaf's Boss reply.
613
1589
  }
614
-
615
- if (decision.decision === 'sub') {
616
- if (!active) {
617
- await routerClarification(context);
618
- return;
619
- }
620
- await submitToActive(active, decision.text, context);
1590
+ if (decision?.decision !== 'dismiss') {
1591
+ lastRouteDecision = 'deliver';
1592
+ await submitToActive(leaf, turn.prompt, context);
621
1593
  return;
622
1594
  }
623
1595
 
624
- if (!active) {
625
- await routerClarification(context);
626
- return;
1596
+ lastRouteDecision = 'dismiss';
1597
+ if (leaf.parent) {
1598
+ await resumeParent(
1599
+ leaf,
1600
+ {
1601
+ status: 'aborted',
1602
+ playbookId: leaf.entry.id,
1603
+ childSessionId: leaf.sessionId,
1604
+ ...(leaf.state ? { state: leaf.state } : {}),
1605
+ },
1606
+ context,
1607
+ 'stopped',
1608
+ );
1609
+ } else {
1610
+ await disposeStack('dismiss');
627
1611
  }
628
-
629
- const dismissedCommandLabel = playbookCommandLabel(active.entry);
630
- await disposeActive('dismiss');
631
- await callVisibleChat(
632
- context,
633
- decision.text ?? `${dismissedCommandLabel} stopped.`,
634
- );
635
1612
  };
636
1613
 
637
1614
  const handleRegisteredCommand = async (
@@ -639,19 +1616,24 @@ export function createPlaybookCaptainShell(
639
1616
  text: string,
640
1617
  context: CaptainContext,
641
1618
  ): Promise<void> => {
642
- if (active && active.entry.id !== entry.id) {
1619
+ const enablement = enablementById.get(entry.id)!;
1620
+ const leaf = leafFrame();
1621
+ if (leaf && leaf.entry.id !== entry.id) {
643
1622
  await callVisibleChat(
1623
+ leaf,
644
1624
  context,
645
- `/${active.entry.command} is already running. Finish or stop it before starting /${entry.command}.`,
1625
+ `${frameLabel(leaf)} is already running. Finish or stop it before starting /${enablement.command}.`,
646
1626
  );
647
1627
  return;
648
1628
  }
649
1629
 
650
- const engagement = await engage(entry);
1630
+ const engagement = leaf ?? (await engage(entry));
651
1631
  if (text.length === 0) {
1632
+ await requestVisibility(engagement.enablement);
652
1633
  await callVisibleChat(
1634
+ engagement,
653
1635
  context,
654
- `Ask what task to run with /${entry.command}.`,
1636
+ `Ask what task to run with /${enablement.command}.`,
655
1637
  );
656
1638
  return;
657
1639
  }
@@ -663,9 +1645,15 @@ export function createPlaybookCaptainShell(
663
1645
  async init(initSession: CaptainSession): Promise<void> {
664
1646
  session = initSession;
665
1647
  players = initSession.players;
666
- for (const entry of entries) {
667
- entry.validateOptions(options);
1648
+ const built = await buildEnablements(options, players, loadModule);
1649
+ entries = built.entries;
1650
+ byCommand = built.byCommand;
1651
+ byId = built.byId;
1652
+ enablementById = built.enablementById;
1653
+ for (const enablement of enablementById.values()) {
1654
+ enablement.entry.validateOptions(enablement.optionInput);
668
1655
  }
1656
+ internalCaptainEnablement = createInternalCaptainEnablement();
669
1657
  await setMode('chat', 'init');
670
1658
  },
671
1659
 
@@ -674,6 +1662,11 @@ export function createPlaybookCaptainShell(
674
1662
  context: CaptainContext,
675
1663
  ): Promise<void> {
676
1664
  requireSession();
1665
+ if (activeTurnHostCalls !== undefined) {
1666
+ throw new Error('cannot handle concurrent Boss turns');
1667
+ }
1668
+ const turnHostCalls = new Set<Promise<unknown>>();
1669
+ activeTurnHostCalls = turnHostCalls;
677
1670
  activeContext = context;
678
1671
  try {
679
1672
  const command = parseRegisteredCommand(turn.prompt);
@@ -685,15 +1678,31 @@ export function createPlaybookCaptainShell(
685
1678
  }
686
1679
  }
687
1680
 
688
- await routeHidden(turn, context);
1681
+ const leaf = leafFrame();
1682
+ if (leaf) {
1683
+ await routeEngaged(turn, context);
1684
+ return;
1685
+ }
1686
+ if (turn.prompt.trim().length === 0) return;
1687
+ const captain = await engageInternalCaptain();
1688
+ await submitToActive(captain, turn.prompt, context);
689
1689
  } finally {
1690
+ await drainHostCalls(turnHostCalls);
1691
+ if (activeTurnHostCalls === turnHostCalls) {
1692
+ activeTurnHostCalls = undefined;
1693
+ }
690
1694
  activeContext = undefined;
691
1695
  }
692
1696
  },
693
1697
 
1698
+ async prepareDispose(): Promise<void> {
1699
+ activeContext = undefined;
1700
+ await disposeStack('dispose');
1701
+ },
1702
+
694
1703
  async dispose(): Promise<void> {
695
1704
  activeContext = undefined;
696
- await disposeActive('dispose');
1705
+ await disposeStack('dispose');
697
1706
  },
698
1707
  };
699
1708
  }