@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,69 +1,56 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
- import { codePlaybookRegistryEntry, } from './code.registry.js';
3
+ import { randomUUID } from 'node:crypto';
4
+ import PQueue from 'p-queue';
5
+ import { registerPlaybookAbortCleanup } from '../../../src/xstate-runtime.js';
6
+ import createDefaultCaptainRuntime from '../captain.playbook/captain.playbook.js';
7
+ class VisibilityControlError extends Error {
8
+ constructor(cause) {
9
+ super(`playbook visibility request failed: ${String(cause?.message ?? cause)}`, { cause });
10
+ this.name = 'VisibilityControlError';
11
+ }
12
+ }
4
13
  const SUB_RUNTIME_FSM_TOPIC = 'playbook.fsm.state';
5
14
  const SHELL_FSM_TOPIC = 'playbook.captain.fsm.state';
6
- export const playbookCaptainRegistry = [
7
- codePlaybookRegistryEntry,
8
- ];
15
+ const INTERNAL_CAPTAIN_ID = 'captain';
16
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
9
17
  function parseRegisteredCommand(prompt) {
10
18
  const match = /^\/([A-Za-z][A-Za-z0-9_-]*)(?:\s+([\s\S]*))?$/.exec(prompt.trim());
11
19
  if (!match)
12
20
  return undefined;
13
21
  return { command: match[1], text: (match[2] ?? '').trim() };
14
22
  }
15
- function playbookCommandLabel(entry) {
16
- return `/${entry.command}`;
17
- }
18
23
  function visibleChatEnvelope(message) {
19
24
  return [
20
25
  'You are the Playbook Captain shell.',
21
- 'This is visible Boss chat. Do not reveal hidden control JSON, hidden router decisions, or hidden judge replies.',
26
+ 'This is visible Boss chat. Do not reveal hidden control JSON, hidden lifecycle decisions, or hidden judge replies.',
22
27
  message,
23
28
  ].join('\n\n');
24
29
  }
25
30
  function visibleTurnSummaryEnvelope(input) {
26
- const savedLine = savedCountsLine(input.counts, input.reviewRebuttalRounds);
27
31
  return [
28
32
  'You are the Playbook Captain shell.',
29
- 'This is visible Boss chat after a sub-playbook command completed. Do not reveal hidden control JSON, hidden router decisions, or hidden judge replies.',
33
+ 'This is visible Boss chat after a sub-playbook command completed. Do not reveal hidden control JSON, hidden lifecycle decisions, or hidden judge replies.',
30
34
  'Write a brief, clearly formatted turn-summary block for Boss.',
31
35
  'Use a natural, chat-like tone and no more than two short sentences before the saved-counts line.',
32
36
  'State only what was done or what changed; do not explain how it was done.',
33
37
  'Do not list raw state names, transitions, guard names, prompts, tools, hidden calls, or reasoning.',
34
38
  'If progress detail is useful, use only the aggregate progress phrase supplied below.',
35
- 'Do not mention counts for plan or implementation steps, tests green, or any other internal state.',
36
- `Then write the saved-counts line exactly: ${savedLine}`,
39
+ "Do not mention counts for states the active playbook's summary policy does not label.",
40
+ `Then write the saved-counts line exactly: ${input.savedLine}`,
37
41
  'Use the exact counts supplied; do not change them.',
38
- 'Do not repeat the exact review/rebuttal round count outside the saved-counts line.',
42
+ 'Do not repeat the exact progress round count outside the saved-counts line.',
39
43
  `Playbook: ${input.playbookId}`,
40
44
  `Submitted Boss text:\n${input.submittedText}`,
41
45
  `Progress counts:\n${input.progressPhrase}`,
42
46
  `Counts:\n${JSON.stringify({
43
47
  ...input.counts,
44
- reviewRebuttalRounds: input.reviewRebuttalRounds,
48
+ progressRounds: input.progressRounds,
45
49
  })}`,
46
50
  ].join('\n\n');
47
51
  }
48
- function countNoun(count, singular, plural = `${singular}s`) {
49
- return `${count} ${count === 1 ? singular : plural}`;
50
- }
51
- function savedCountsLine(counts, reviewRebuttalRounds) {
52
- return [
53
- 'Saved you',
54
- countNoun(counts.interruptions, 'interruption'),
55
- 'and',
56
- countNoun(counts.copyPastes, 'copy-paste'),
57
- 'across',
58
- countNoun(reviewRebuttalRounds, 'round'),
59
- 'of reviews/rebuttals.',
60
- ].join(' ');
61
- }
62
52
  function stateCountLabel(stateId, entry) {
63
- if (stateId === entry.idleStateId || stateId === entry.finalStateId) {
64
- return undefined;
65
- }
66
- const registryLabel = entry.stateCountLabels?.[stateId]?.trim();
53
+ const registryLabel = entry.summaryPolicy?.stateCountLabels?.[stateId]?.trim();
67
54
  return registryLabel || undefined;
68
55
  }
69
56
  function pluralizeStateCount(label, count) {
@@ -88,59 +75,174 @@ function summaryProgressRoundCount(stateCounts) {
88
75
  function guardFromJudgeReply(finalText) {
89
76
  return /"guard"\s*:\s*"([^"]+)"/.exec(finalText)?.[1];
90
77
  }
91
- function normalizeRegistry(registry) {
78
+ function isValidRegistryEntry(value) {
79
+ if (typeof value !== 'object' || value === null)
80
+ return false;
81
+ const e = value;
82
+ return (typeof e.id === 'string' &&
83
+ typeof e.command === 'string' &&
84
+ typeof e.intent === 'string' &&
85
+ Array.isArray(e.requiredRoleIds) &&
86
+ typeof e.validateOptions === 'function' &&
87
+ typeof e.createRuntime === 'function');
88
+ }
89
+ function readPlaybooksConfig(options) {
90
+ if (typeof options !== 'object' || options === null)
91
+ return undefined;
92
+ const pb = options.playbooks;
93
+ if (typeof pb !== 'object' || pb === null || Array.isArray(pb)) {
94
+ return undefined;
95
+ }
96
+ return pb;
97
+ }
98
+ // Resolve the active registry at init from `captain.options.playbooks`
99
+ // (CAPTAIN-16): each enabled playbook is loaded from its explicit `from`
100
+ // module and bound to namespaced `<id>-<role>` host players.
101
+ async function buildEnablements(options, players, loadModule) {
102
+ const entries = [];
92
103
  const byCommand = new Map();
93
104
  const byId = new Map();
94
- for (const entry of registry) {
95
- byCommand.set(entry.command, entry);
105
+ const enablementById = new Map();
106
+ const config = readPlaybooksConfig(options);
107
+ if (config === undefined) {
108
+ throw new Error('captain.options.playbooks is required');
109
+ }
110
+ const ids = Object.keys(config);
111
+ if (ids.length === 0) {
112
+ throw new Error('captain.options.playbooks must enable at least one playbook');
113
+ }
114
+ for (const id of ids) {
115
+ if (id === INTERNAL_CAPTAIN_ID) {
116
+ throw new Error(`captain.options.playbooks.${id} collides with the reserved internal Captain id`);
117
+ }
118
+ const block = config[id];
119
+ if (typeof block !== 'object' || block === null || Array.isArray(block)) {
120
+ throw new Error(`captain.options.playbooks.${id} must be an object`);
121
+ }
122
+ const record = block;
123
+ const from = record.from;
124
+ if (typeof from !== 'string' || from.length === 0) {
125
+ throw new Error(`captain.options.playbooks.${id}.from must be a module specifier`);
126
+ }
127
+ let mod;
128
+ try {
129
+ mod = await loadModule(from);
130
+ }
131
+ catch (cause) {
132
+ throw new Error(`captain.options.playbooks.${id}.from "${from}" failed to import: ${String(cause?.message ?? cause)}`);
133
+ }
134
+ const entry = mod?.default;
135
+ if (!isValidRegistryEntry(entry)) {
136
+ throw new Error(`captain.options.playbooks.${id}.from "${from}" exposes no valid registry entry`);
137
+ }
138
+ if (entry.id !== id) {
139
+ throw new Error(`captain.options.playbooks.${id} key must equal the module manifest id "${entry.id}"`);
140
+ }
141
+ if (byId.has(entry.id)) {
142
+ throw new Error(`captain.options.playbooks has a duplicate playbook id "${entry.id}"`);
143
+ }
144
+ const command = typeof record.command === 'string' && record.command.length > 0
145
+ ? record.command
146
+ : entry.command;
147
+ if (command === INTERNAL_CAPTAIN_ID) {
148
+ throw new Error(`captain.options.playbooks.${id} command collides with the reserved internal Captain command`);
149
+ }
150
+ if (byCommand.has(command)) {
151
+ throw new Error(`captain.options.playbooks has a duplicate effective command "${command}"`);
152
+ }
153
+ const boundPlayers = entry.requiredRoleIds.map((role) => {
154
+ const host = players.find((p) => p.id === `${entry.id}-${role}`);
155
+ return {
156
+ id: role,
157
+ ...(host?.adapter !== undefined ? { adapter: host.adapter } : {}),
158
+ ...(host?.model !== undefined ? { model: host.model } : {}),
159
+ };
160
+ });
161
+ entries.push(entry);
96
162
  byId.set(entry.id, entry);
163
+ byCommand.set(command, entry);
164
+ enablementById.set(entry.id, {
165
+ entry,
166
+ command,
167
+ optionInput: record.options,
168
+ boundPlayers,
169
+ hostPlayerId: (localRole) => `${entry.id}-${localRole}`,
170
+ visiblePlayerIds: entry.requiredRoleIds.map((role) => `${entry.id}-${role}`),
171
+ });
97
172
  }
98
- return { entries: registry, byCommand, byId };
173
+ return { entries, byCommand, byId, enablementById };
99
174
  }
100
- export function createPlaybookCaptainShell(options, registry = playbookCaptainRegistry) {
101
- const { entries, byCommand, byId } = normalizeRegistry(registry);
175
+ export function createPlaybookCaptainShell(options, deps = {}) {
176
+ const loadModule = deps.loadModule ?? ((specifier) => import(specifier));
177
+ const createSessionId = deps.createSessionId ?? randomUUID;
178
+ const createCaptainRuntime = deps.createCaptainRuntime ?? createDefaultCaptainRuntime;
179
+ let entries = [];
180
+ let byCommand = new Map();
181
+ let byId = new Map();
182
+ let enablementById = new Map();
183
+ let internalCaptainEnablement;
102
184
  let session;
103
185
  let players = [];
104
186
  let activeContext;
105
- let active;
187
+ const frames = [];
106
188
  let mode = 'chat';
107
- let latestSubRuntimeStateId;
108
- let pendingBossQuestion;
189
+ let pendingBossQuestions;
109
190
  let lastError;
110
191
  let lastRouteDecision;
111
- let finalDisposalRequested;
112
192
  let activeTurnSummary;
193
+ let activeTurnHostCalls;
194
+ const issuedSessionIds = new Set();
195
+ const pendingChildParents = new Set();
196
+ const captainQueue = new PQueue({ concurrency: 1 });
197
+ let disposing = false;
198
+ const rootFrame = () => frames[0];
199
+ const leafFrame = () => frames.at(-1);
200
+ const frameLabel = (frame) => frame.internal ? 'Captain' : `/${frame.enablement.command}`;
113
201
  const requireSession = () => {
114
202
  if (!session) {
115
203
  throw new Error('init must be called first');
116
204
  }
117
205
  return session;
118
206
  };
119
- const ledgerSnapshot = (playbookId = active?.entry.id) => ({
207
+ const ledgerSnapshot = (playbookId = leafFrame()?.entry.id, activeSessionId = leafFrame()?.sessionId) => ({
120
208
  ...(playbookId ? { activePlaybookId: playbookId } : {}),
209
+ ...(activeSessionId ? { activeSessionId } : {}),
210
+ ...(rootFrame()
211
+ ? {
212
+ rootPlaybookId: rootFrame().entry.id,
213
+ rootSessionId: rootFrame().sessionId,
214
+ }
215
+ : {}),
216
+ stackDepth: frames.length,
217
+ stackPath: frames.map((frame) => frame.entry.id),
121
218
  mode,
122
- ...(latestSubRuntimeStateId ? { latestSubRuntimeStateId } : {}),
123
- ...(pendingBossQuestion !== undefined ? { pendingBossQuestion } : {}),
219
+ ...(leafFrame()?.state?.stateId
220
+ ? { latestSubRuntimeStateId: leafFrame().state.stateId }
221
+ : {}),
222
+ ...(leafFrame()?.state
223
+ ? { latestSubRuntimeState: leafFrame().state }
224
+ : {}),
225
+ ...(pendingBossQuestions !== undefined ? { pendingBossQuestions } : {}),
124
226
  ...(lastError ? { lastError } : {}),
125
227
  ...(lastRouteDecision ? { lastRouteDecision } : {}),
126
228
  });
127
- const emitShellTelemetry = async (from, to, event, playbookId = active?.entry.id) => {
229
+ const emitShellTelemetry = async (from, to, event, playbookId = leafFrame()?.entry.id, activeSessionId = leafFrame()?.sessionId) => {
128
230
  await requireSession().emitTelemetry({
129
231
  topic: SHELL_FSM_TOPIC,
130
232
  payload: {
131
233
  from,
132
234
  to,
133
235
  event,
134
- ledger: ledgerSnapshot(playbookId),
236
+ ledger: ledgerSnapshot(playbookId, activeSessionId),
135
237
  },
136
238
  });
137
239
  };
138
- const setMode = async (nextMode, event, playbookId = active?.entry.id) => {
240
+ const setMode = async (nextMode, event, playbookId = leafFrame()?.entry.id, activeSessionId = leafFrame()?.sessionId) => {
139
241
  if (mode === nextMode)
140
242
  return;
141
243
  const from = mode;
142
244
  mode = nextMode;
143
- await emitShellTelemetry(from, nextMode, event, playbookId);
245
+ await emitShellTelemetry(from, nextMode, event, playbookId, activeSessionId);
144
246
  };
145
247
  const normalizeErrorCompact = (value) => {
146
248
  if (value === undefined || value === null)
@@ -162,60 +264,141 @@ export function createPlaybookCaptainShell(options, registry = playbookCaptainRe
162
264
  const payloadRecord = (payload) => typeof payload === 'object' && payload !== null && !Array.isArray(payload)
163
265
  ? payload
164
266
  : undefined;
165
- const mirroredStateId = (payload) => {
166
- const record = payloadRecord(payload);
167
- if (!record)
267
+ const playbookState = (value) => {
268
+ const record = payloadRecord(value);
269
+ if (!record ||
270
+ !Array.isArray(record.activeStateIds) ||
271
+ !record.activeStateIds.every((id) => typeof id === 'string') ||
272
+ !Array.isArray(record.tags) ||
273
+ !record.tags.every((tag) => typeof tag === 'string') ||
274
+ typeof record.status !== 'string' ||
275
+ typeof record.quiescent !== 'boolean' ||
276
+ !('value' in record)) {
168
277
  return undefined;
169
- if (typeof record.to === 'string')
170
- return record.to;
171
- return typeof record.state === 'string' ? record.state : undefined;
278
+ }
279
+ return record;
172
280
  };
173
- const mirrorSubRuntimeTelemetry = async (payload) => {
174
- if (!active)
175
- return;
281
+ const stateValueContains = (value, stateId) => {
282
+ if (typeof value === 'string')
283
+ return value === stateId;
284
+ const record = payloadRecord(value);
285
+ if (!record)
286
+ return false;
287
+ return Object.entries(record).some(([key, nested]) => key === stateId || stateValueContains(nested, stateId));
288
+ };
289
+ const drainHostCalls = async (calls) => {
290
+ while (calls.size > 0) {
291
+ await Promise.allSettled([...calls]);
292
+ }
293
+ };
294
+ const trackHostCall = (frame, call) => {
295
+ // Cligent's host methods are scoped to the whole Boss turn, while an
296
+ // XState invocation can carry a narrower sibling-cancellation signal.
297
+ // Keep both frame and turn ownership after XState stops awaiting the
298
+ // promise so the host cannot outlive frame disposal or turn settlement.
299
+ const turnCalls = activeTurnHostCalls;
300
+ let tracked;
301
+ tracked = call.finally(() => {
302
+ frame.inFlightHostCalls.delete(tracked);
303
+ turnCalls?.delete(tracked);
304
+ });
305
+ frame.inFlightHostCalls.add(tracked);
306
+ turnCalls?.add(tracked);
307
+ return tracked;
308
+ };
309
+ const callCaptainQueued = (frame, context, prompt, options, signal) => {
310
+ const queued = captainQueue.add(async () => {
311
+ signal.throwIfAborted();
312
+ const result = await trackHostCall(frame, context.callCaptain(prompt, options));
313
+ signal.throwIfAborted();
314
+ return result;
315
+ });
316
+ return trackHostCall(frame, queued);
317
+ };
318
+ const mirrorSubRuntimeTelemetry = async (frame, payload) => {
176
319
  const record = payloadRecord(payload);
177
- const stateId = mirroredStateId(payload);
178
- if (stateId === undefined)
179
- return;
180
- const countLabel = stateCountLabel(stateId, active.entry);
181
- if (activeTurnSummary && countLabel) {
182
- activeTurnSummary.stateCounts.set(countLabel, (activeTurnSummary.stateCounts.get(countLabel) ?? 0) + 1);
183
- }
184
- latestSubRuntimeStateId = stateId;
185
- pendingBossQuestion = record?.pendingBossQuestion;
186
- lastError = normalizeErrorCompact(record?.lastError);
187
- if (stateId === active.entry.finalStateId) {
188
- finalDisposalRequested = active;
320
+ const state = playbookState(record?.state);
321
+ if (!record || !state)
189
322
  return;
323
+ const previousActiveIds = new Set(frame.state?.activeStateIds ?? []);
324
+ frame.state = state;
325
+ if (activeTurnSummary?.owner === frame) {
326
+ for (const stateId of state.activeStateIds) {
327
+ const newlyActive = !previousActiveIds.has(stateId);
328
+ const structuredEntry = stateValueContains(record.to, stateId) &&
329
+ !stateValueContains(record.from, stateId);
330
+ if (!newlyActive && !structuredEntry)
331
+ continue;
332
+ const countLabel = stateCountLabel(stateId, frame.entry);
333
+ if (countLabel) {
334
+ activeTurnSummary.stateCounts.set(countLabel, (activeTurnSummary.stateCounts.get(countLabel) ?? 0) + 1);
335
+ }
336
+ }
190
337
  }
191
- if (stateId === active.entry.idleStateId ||
192
- stateId === 'failed' ||
193
- stateId === 'awaitBossReply') {
194
- await setMode('engaged.parked', `sub-runtime:${stateId}`);
338
+ if (leafFrame() === frame) {
339
+ pendingBossQuestions =
340
+ record.pendingBossQuestions ?? record.pendingBossQuestion;
341
+ lastError = normalizeErrorCompact(record.lastError);
342
+ if (state.quiescent && state.tags.includes('playbook.parked')) {
343
+ await setMode('engaged.parked', `sub-runtime:${state.stateId ?? 'structured'}`);
344
+ }
195
345
  }
196
346
  };
197
- const createPorts = () => ({
198
- callPlayer: async (playerId, prompt, _signal) => {
347
+ let callNestedPlaybook;
348
+ const createPorts = (frame) => ({
349
+ callPlayer: async (playerId, prompt, signal, options) => {
199
350
  if (!activeContext) {
200
351
  throw new Error('callPlayer invoked outside a Boss turn');
201
352
  }
202
- const result = await activeContext.callPlayer(playerId, prompt);
203
- if (activeTurnSummary) {
353
+ const context = activeContext;
354
+ signal.throwIfAborted();
355
+ const hostPlayerId = frame.enablement.hostPlayerId(playerId);
356
+ const result = await trackHostCall(frame, context.callPlayer(hostPlayerId, prompt, {
357
+ resume: options.resume,
358
+ }));
359
+ // CaptainContext is turn-scoped and cannot accept a narrower XState
360
+ // invocation signal. Recheck after the host call so a sibling
361
+ // cancellation is still reported as aborted and cannot rotate a
362
+ // stopped branch's player token in the linked runtime.
363
+ signal.throwIfAborted();
364
+ if (activeTurnSummary?.owner === frame) {
204
365
  activeTurnSummary.counts.interruptions++;
205
366
  }
206
367
  return {
207
368
  status: result.status,
208
- finalText: result.finalText,
209
- error: result.error,
369
+ ...(result.resumeToken !== undefined
370
+ ? { resumeToken: result.resumeToken }
371
+ : {}),
372
+ ...(result.finalText !== undefined
373
+ ? { finalText: result.finalText }
374
+ : {}),
375
+ ...(result.error !== undefined ? { error: result.error } : {}),
210
376
  };
211
377
  },
212
- callJudge: async (prompt, _signal) => {
378
+ callCaptain: async (prompt, signal, options) => {
379
+ if (!activeContext) {
380
+ throw new Error('callCaptain invoked outside a Boss turn');
381
+ }
382
+ const result = await callCaptainQueued(frame, activeContext, prompt, {
383
+ visibility: options.visibility,
384
+ resume: options.resume,
385
+ ...(options.allowedTools === undefined
386
+ ? {}
387
+ : { allowedTools: options.allowedTools }),
388
+ }, signal);
389
+ return {
390
+ status: result.status,
391
+ ...(result.finalText !== undefined
392
+ ? { finalText: result.finalText }
393
+ : {}),
394
+ ...(result.error !== undefined ? { error: result.error } : {}),
395
+ };
396
+ },
397
+ callJudge: async (prompt, signal) => {
213
398
  if (!activeContext) {
214
399
  throw new Error('callJudge invoked outside a Boss turn');
215
400
  }
216
- const result = await activeContext.callCaptain(prompt, {
217
- visibility: 'hidden',
218
- });
401
+ const result = await callCaptainQueued(frame, activeContext, prompt, { visibility: 'hidden', resume: false, allowedTools: [] }, signal);
219
402
  if (result.status !== 'ok') {
220
403
  throw new Error(result.error ?? `callCaptain status "${result.status}"`);
221
404
  }
@@ -224,140 +407,723 @@ export function createPlaybookCaptainShell(options, registry = playbookCaptainRe
224
407
  }
225
408
  const guard = guardFromJudgeReply(result.finalText);
226
409
  if (guard &&
227
- active?.entry.copyPasteGuardNames.includes(guard) &&
228
- activeTurnSummary) {
410
+ activeTurnSummary?.owner === frame &&
411
+ frame.entry.summaryPolicy?.copyPasteGuardNames.includes(guard)) {
229
412
  activeTurnSummary.counts.copyPastes++;
230
413
  }
231
414
  return result.finalText;
232
415
  },
416
+ callPlaybook: (request, signal) => {
417
+ const opening = callNestedPlaybook(frame, request, signal);
418
+ let exposed;
419
+ const registerOpeningCleanup = () => {
420
+ registerPlaybookAbortCleanup(signal, exposed);
421
+ };
422
+ exposed = opening.finally(() => {
423
+ signal.removeEventListener('abort', registerOpeningCleanup);
424
+ });
425
+ signal.addEventListener('abort', registerOpeningCleanup, { once: true });
426
+ if (signal.aborted)
427
+ registerOpeningCleanup();
428
+ return exposed;
429
+ },
233
430
  emitStatus: async (message, data) => {
431
+ if (frame.internal)
432
+ return;
234
433
  await requireSession().emitStatus(message, data);
235
434
  },
236
435
  emitTelemetry: async (event) => {
237
436
  if (event.topic === SUB_RUNTIME_FSM_TOPIC) {
238
- await mirrorSubRuntimeTelemetry(event.payload);
437
+ await mirrorSubRuntimeTelemetry(frame, event.payload);
239
438
  }
240
439
  await requireSession().emitTelemetry(event);
241
440
  },
242
441
  });
243
- const engage = async (entry) => {
244
- if (active?.entry.id === entry.id)
245
- return active;
442
+ // CAPTAIN-22: before dispatching to a playbook, request tmux-play
443
+ // visibility for that playbook's generated host players. A pane
444
+ // reconciliation failure is display-only in tmux-play and does not
445
+ // reject; the legacy path carries no generated set and skips this.
446
+ const requestVisibility = async (enablement) => {
447
+ const ids = enablement.visiblePlayerIds;
448
+ if (!ids || ids.length === 0 || !activeContext)
449
+ return;
450
+ try {
451
+ await activeContext.setVisiblePlayers(ids);
452
+ }
453
+ catch (error) {
454
+ throw new VisibilityControlError(error);
455
+ }
456
+ };
457
+ const allocateSessionId = () => {
458
+ const sessionId = createSessionId();
459
+ if (!UUID_PATTERN.test(sessionId)) {
460
+ throw new Error(`playbook session id generator returned a non-UUID value: ${JSON.stringify(sessionId)}`);
461
+ }
462
+ if (issuedSessionIds.has(sessionId)) {
463
+ throw new Error(`playbook session id collision: ${sessionId}`);
464
+ }
465
+ issuedSessionIds.add(sessionId);
466
+ return sessionId;
467
+ };
468
+ const normalizeErrorFull = (value) => {
469
+ const compact = normalizeErrorCompact(value) ?? {
470
+ name: 'Error',
471
+ message: String(value),
472
+ };
473
+ const stack = value instanceof Error
474
+ ? value.stack
475
+ : typeof value === 'object' && value !== null
476
+ ? value.stack
477
+ : undefined;
478
+ return typeof stack === 'string' ? { ...compact, stack } : compact;
479
+ };
480
+ const makeFrame = (enablement, parent, internal = false) => {
481
+ const entry = enablement.entry;
482
+ const sessionId = allocateSessionId();
246
483
  const runtime = entry.createRuntime({
247
- captainOptions: options,
248
- players,
484
+ captainOptions: enablement.optionInput,
485
+ players: enablement.boundPlayers,
486
+ });
487
+ return {
488
+ entry,
489
+ enablement,
490
+ runtime,
491
+ sessionId,
492
+ rootSessionId: parent?.frame.rootSessionId ?? sessionId,
493
+ depth: parent ? parent.frame.depth + 1 : 0,
494
+ ...(parent ? { parent } : {}),
495
+ inFlightHostCalls: new Set(),
496
+ internal,
497
+ };
498
+ };
499
+ const initFrame = async (frame) => {
500
+ await frame.runtime.init({
501
+ sessionId: frame.sessionId,
502
+ playbookId: frame.entry.id,
503
+ rootSessionId: frame.rootSessionId,
504
+ ...(frame.parent
505
+ ? {
506
+ parentSessionId: frame.parent.frame.sessionId,
507
+ parentCallId: frame.parent.callId,
508
+ }
509
+ : {}),
510
+ depth: frame.depth,
511
+ ports: createPorts(frame),
249
512
  });
250
- active = { entry, runtime };
251
- latestSubRuntimeStateId = undefined;
252
- pendingBossQuestion = undefined;
513
+ };
514
+ const clearLeafLedger = () => {
515
+ pendingBossQuestions = undefined;
253
516
  lastError = undefined;
254
- finalDisposalRequested = undefined;
255
- await setMode('engaged.parked', 'engage', entry.id);
256
- await runtime.init(createPorts());
257
- await requireSession().emitStatus(`◇ ${playbookCommandLabel(entry)} started`);
258
- return active;
259
517
  };
260
- const submitToActive = async (engagement, text, context) => {
261
- const summaryCounts = {
262
- interruptions: 0,
263
- copyPastes: 0,
518
+ const engageEnablement = async (enablement, internal) => {
519
+ const entry = enablement.entry;
520
+ const existing = rootFrame();
521
+ if (existing?.entry.id === entry.id && frames.length === 1) {
522
+ return existing;
523
+ }
524
+ if (existing) {
525
+ throw new Error('cannot engage a second root playbook');
526
+ }
527
+ const frame = makeFrame(enablement, undefined, internal);
528
+ frames.push(frame);
529
+ clearLeafLedger();
530
+ try {
531
+ await setMode('engaged.parked', 'engage', entry.id, frame.sessionId);
532
+ await initFrame(frame);
533
+ if (!internal) {
534
+ await requireSession().emitStatus(`◇ ${frameLabel(frame)} started`);
535
+ }
536
+ return frame;
537
+ }
538
+ catch (error) {
539
+ if (leafFrame() === frame)
540
+ frames.pop();
541
+ clearLeafLedger();
542
+ try {
543
+ await frame.runtime.dispose();
544
+ }
545
+ catch {
546
+ // Preserve the initialization failure while still making a
547
+ // best-effort attempt to release partially acquired resources.
548
+ }
549
+ try {
550
+ await setMode('chat', 'engage.failed');
551
+ }
552
+ catch {
553
+ // setMode updates the authoritative mode before telemetry; preserve
554
+ // the initialization failure if that recovery emission also fails.
555
+ mode = 'chat';
556
+ }
557
+ throw error;
558
+ }
559
+ };
560
+ const engage = async (entry) => engageEnablement(enablementById.get(entry.id), false);
561
+ const createInternalCaptainEnablement = () => {
562
+ const catalog = Object.freeze(entries.map((entry) => Object.freeze({
563
+ id: entry.id,
564
+ command: enablementById.get(entry.id).command,
565
+ intent: entry.intent,
566
+ })));
567
+ const entry = {
568
+ id: INTERNAL_CAPTAIN_ID,
569
+ command: INTERNAL_CAPTAIN_ID,
570
+ intent: 'internal orchestration policy',
571
+ requiredRoleIds: [],
572
+ validateOptions: () => undefined,
573
+ createRuntime: () => createCaptainRuntime({ enabledPlaybooks: catalog }),
264
574
  };
265
- const summaryStateCounts = new Map();
266
- let shouldSummarize = false;
267
- activeTurnSummary = {
268
- counts: summaryCounts,
269
- stateCounts: summaryStateCounts,
575
+ return {
576
+ entry,
577
+ command: INTERNAL_CAPTAIN_ID,
578
+ optionInput: undefined,
579
+ boundPlayers: [],
580
+ hostPlayerId(localRole) {
581
+ throw new Error(`internal Captain has no player binding for ${JSON.stringify(localRole)}`);
582
+ },
270
583
  };
584
+ };
585
+ const engageInternalCaptain = async () => {
586
+ if (!internalCaptainEnablement) {
587
+ throw new Error('internal Captain enablement is unavailable before init');
588
+ }
589
+ return engageEnablement(internalCaptainEnablement, true);
590
+ };
591
+ const disposeFrame = (frame) => {
592
+ if (frame.disposePromise)
593
+ return frame.disposePromise;
594
+ const operation = (async () => {
595
+ if (frame.invocationSignal && frame.abortListener) {
596
+ frame.invocationSignal.removeEventListener('abort', frame.abortListener);
597
+ }
598
+ frame.invocationSignal = undefined;
599
+ frame.abortListener = undefined;
600
+ let disposeError;
601
+ try {
602
+ await frame.runtime.dispose();
603
+ }
604
+ catch (error) {
605
+ disposeError = error;
606
+ }
607
+ await drainHostCalls(frame.inFlightHostCalls);
608
+ if (disposeError !== undefined)
609
+ throw disposeError;
610
+ })();
611
+ frame.disposePromise = operation;
612
+ return operation;
613
+ };
614
+ const removeTopFrame = (frame, reason) => {
615
+ if (frame.removal) {
616
+ return {
617
+ claimed: false,
618
+ reason: frame.removal.reason,
619
+ promise: frame.removal.promise,
620
+ };
621
+ }
622
+ const operation = (async () => {
623
+ if (leafFrame() !== frame) {
624
+ throw new Error('nested playbook stack is not LIFO');
625
+ }
626
+ let removalError;
627
+ try {
628
+ await disposeFrame(frame);
629
+ }
630
+ catch (error) {
631
+ removalError = error;
632
+ }
633
+ finally {
634
+ if (leafFrame() === frame) {
635
+ frames.pop();
636
+ if (frame.parent) {
637
+ pendingChildParents.delete(frame.parent.frame);
638
+ }
639
+ pendingChildParents.delete(frame);
640
+ }
641
+ else if (frames.includes(frame)) {
642
+ const stackError = new Error('nested playbook stack changed during frame removal');
643
+ removalError =
644
+ removalError === undefined
645
+ ? stackError
646
+ : new AggregateError([removalError, stackError], 'nested playbook frame removal failed');
647
+ }
648
+ }
649
+ if (removalError !== undefined)
650
+ throw removalError;
651
+ })();
652
+ frame.removal = { reason, promise: operation };
653
+ return { claimed: true, reason, promise: operation };
654
+ };
655
+ const unwindFramesFrom = async (frame, reason = 'stack') => {
656
+ const index = frames.indexOf(frame);
657
+ if (index < 0)
658
+ return;
659
+ const failures = [];
660
+ while (frames.length > index) {
661
+ const current = leafFrame();
662
+ const removal = removeTopFrame(current, reason);
663
+ try {
664
+ await removal.promise;
665
+ }
666
+ catch (error) {
667
+ failures.push(error);
668
+ }
669
+ if (frames.includes(current)) {
670
+ failures.push(new Error('nested playbook frame remained after removal attempt'));
671
+ break;
672
+ }
673
+ }
674
+ clearLeafLedger();
675
+ if (failures.length === 1)
676
+ throw failures[0];
677
+ if (failures.length > 1) {
678
+ throw new AggregateError(failures, 'nested playbook stack disposal failed');
679
+ }
680
+ };
681
+ const popChild = async (frame, status) => {
682
+ if (!frame.parent || (leafFrame() !== frame && !frame.removal)) {
683
+ throw new Error('nested playbook stack is not LIFO');
684
+ }
685
+ const parent = frame.parent.frame;
686
+ const removal = removeTopFrame(frame, 'return');
687
+ if (!removal.claimed) {
688
+ await removal.promise;
689
+ return false;
690
+ }
691
+ let cleanupError;
692
+ try {
693
+ await removal.promise;
694
+ }
695
+ catch (error) {
696
+ cleanupError = error;
697
+ }
698
+ const message = status === 'returned'
699
+ ? `◇ ${frameLabel(frame)} returned to ${frameLabel(parent)}`
700
+ : `◇ ${frameLabel(frame)} stopped; returning to ${frameLabel(parent)}`;
701
+ try {
702
+ await requireSession().emitStatus(message);
703
+ }
704
+ catch (error) {
705
+ cleanupError =
706
+ cleanupError === undefined
707
+ ? error
708
+ : new AggregateError([cleanupError, error], 'nested playbook return cleanup failed');
709
+ }
710
+ let visibilityError;
711
+ try {
712
+ await requestVisibility(parent.enablement);
713
+ }
714
+ catch (error) {
715
+ visibilityError = error;
716
+ }
717
+ if (visibilityError !== undefined) {
718
+ if (cleanupError !== undefined) {
719
+ throw new VisibilityControlError(new AggregateError([cleanupError, visibilityError], 'nested playbook return and visibility failed'));
720
+ }
721
+ throw visibilityError;
722
+ }
723
+ if (cleanupError !== undefined)
724
+ throw cleanupError;
725
+ return true;
726
+ };
727
+ const disposeStack = async (reason) => {
728
+ const root = rootFrame();
729
+ if (!root)
730
+ return;
731
+ const rootId = root.entry.id;
732
+ const rootSessionId = root.sessionId;
733
+ const failures = [];
734
+ disposing = true;
735
+ try {
736
+ if (reason !== 'dispose') {
737
+ try {
738
+ await setMode('chat', reason, rootId, rootSessionId);
739
+ }
740
+ catch (error) {
741
+ failures.push(error);
742
+ mode = 'chat';
743
+ }
744
+ }
745
+ else {
746
+ mode = 'chat';
747
+ }
748
+ try {
749
+ await unwindFramesFrom(root);
750
+ }
751
+ catch (error) {
752
+ failures.push(error);
753
+ }
754
+ }
755
+ finally {
756
+ disposing = false;
757
+ pendingChildParents.clear();
758
+ clearLeafLedger();
759
+ }
760
+ if (!root.internal) {
761
+ try {
762
+ if (reason === 'dismiss') {
763
+ await requireSession().emitStatus(`◇ ${frameLabel(root)} stopped`);
764
+ }
765
+ else if (reason === 'final') {
766
+ await requireSession().emitStatus(`◇ ${frameLabel(root)} finished`);
767
+ }
768
+ }
769
+ catch (error) {
770
+ failures.push(error);
771
+ }
772
+ }
773
+ if (failures.length === 1)
774
+ throw failures[0];
775
+ if (failures.length > 1) {
776
+ throw new AggregateError(failures, 'playbook stack disposal failed');
777
+ }
778
+ };
779
+ const callResultFor = (frame, result) => {
780
+ if (result.outcome === 'terminal') {
781
+ return {
782
+ status: 'ok',
783
+ playbookId: frame.entry.id,
784
+ childSessionId: frame.sessionId,
785
+ state: result.state,
786
+ ...(result.output !== undefined ? { output: result.output } : {}),
787
+ };
788
+ }
789
+ if (result.outcome === 'aborted') {
790
+ return {
791
+ status: 'aborted',
792
+ playbookId: frame.entry.id,
793
+ childSessionId: frame.sessionId,
794
+ state: result.state,
795
+ ...(result.error ? { error: result.error } : {}),
796
+ };
797
+ }
798
+ throw new Error(`playbook ${frame.entry.id} has not returned`);
799
+ };
800
+ const assertRetainableResult = (frame, result) => {
801
+ if (result.outcome === 'suspended')
802
+ return;
803
+ if (result.state.quiescent &&
804
+ result.state.tags.includes('playbook.parked')) {
805
+ return;
806
+ }
807
+ throw new Error(`playbook ${frame.entry.id} returned outcome "${result.outcome}" ` +
808
+ 'without a quiescent playbook.parked state');
809
+ };
810
+ const driveFrame = async (frame, text, context, signal = context.signal) => {
811
+ if (leafFrame() !== frame) {
812
+ throw new Error('only the active leaf may receive Boss input');
813
+ }
814
+ await requestVisibility(frame.enablement);
271
815
  await setMode('engaged.driving', 'submit');
816
+ const result = await frame.runtime.handleBossInput({
817
+ text,
818
+ signal,
819
+ });
820
+ frame.state = result.state;
821
+ return result;
822
+ };
823
+ async function resumeParent(child, callResult, context, status = 'returned') {
824
+ const parentLink = child.parent;
825
+ if (!parentLink)
826
+ throw new Error('root playbook has no caller');
827
+ const parent = parentLink.frame;
828
+ const invocationSignal = child.invocationSignal;
829
+ let effectiveResult = callResult;
830
+ let ownsReturn = false;
831
+ let visibilityControlError;
272
832
  try {
273
- await engagement.runtime.handleBossInput({
274
- text,
833
+ ownsReturn = await popChild(child, status);
834
+ }
835
+ catch (error) {
836
+ ownsReturn = child.removal?.reason === 'return';
837
+ if (error instanceof VisibilityControlError) {
838
+ visibilityControlError = error;
839
+ }
840
+ else {
841
+ effectiveResult = {
842
+ status: context.signal.aborted ? 'aborted' : 'error',
843
+ playbookId: child.entry.id,
844
+ childSessionId: child.sessionId,
845
+ ...(child.state ? { state: child.state } : {}),
846
+ error: normalizeErrorFull(error),
847
+ };
848
+ }
849
+ }
850
+ if (!ownsReturn ||
851
+ disposing ||
852
+ invocationSignal?.aborted ||
853
+ !frames.includes(parent)) {
854
+ return;
855
+ }
856
+ let result;
857
+ try {
858
+ result = await parent.runtime.resumePlaybookCall({
859
+ callId: parentLink.callId,
860
+ result: effectiveResult,
275
861
  signal: context.signal,
276
862
  });
277
- shouldSummarize = true;
863
+ }
864
+ catch (error) {
865
+ if (disposing || invocationSignal?.aborted)
866
+ return;
867
+ await returnBoundaryFailure(parent, error, context);
868
+ return;
869
+ }
870
+ parent.state = result.state;
871
+ await processFrameResult(parent, result, context);
872
+ if (visibilityControlError !== undefined)
873
+ throw visibilityControlError;
874
+ }
875
+ async function returnBoundaryFailure(frame, error, context) {
876
+ if (!frame.parent)
877
+ throw error;
878
+ await resumeParent(frame, {
879
+ status: context.signal.aborted ? 'aborted' : 'error',
880
+ playbookId: frame.entry.id,
881
+ childSessionId: frame.sessionId,
882
+ ...(frame.state ? { state: frame.state } : {}),
883
+ error: normalizeErrorFull(error),
884
+ }, context);
885
+ }
886
+ async function processFrameResult(frame, result, context) {
887
+ if (result.outcome === 'terminal') {
888
+ if (frame.parent) {
889
+ await resumeParent(frame, callResultFor(frame, result), context);
890
+ }
891
+ else {
892
+ await disposeStack('final');
893
+ }
894
+ return;
895
+ }
896
+ if (result.outcome === 'aborted' && frame.parent) {
897
+ await resumeParent(frame, callResultFor(frame, result), context);
898
+ return;
899
+ }
900
+ assertRetainableResult(frame, result);
901
+ if (leafFrame()) {
902
+ await setMode('engaged.parked', `turn:${result.outcome}`);
903
+ }
904
+ }
905
+ const disposeAbandonedChild = async (child) => {
906
+ if (disposing || !frames.includes(child) || !child.parent)
907
+ return;
908
+ if (child.removal) {
909
+ await child.removal.promise;
910
+ return;
911
+ }
912
+ const parent = child.parent.frame;
913
+ let cleanupError;
914
+ try {
915
+ await unwindFramesFrom(child, 'abandoned');
916
+ }
917
+ catch (error) {
918
+ cleanupError = error;
919
+ }
920
+ if (frames.includes(parent)) {
921
+ await requestVisibility(parent.enablement);
922
+ }
923
+ if (cleanupError !== undefined)
924
+ throw cleanupError;
925
+ };
926
+ callNestedPlaybook = async (parent, request, invocationSignal) => {
927
+ if (!activeContext) {
928
+ throw new Error('callPlaybook invoked outside a Boss turn');
929
+ }
930
+ invocationSignal.throwIfAborted();
931
+ if (leafFrame() !== parent) {
932
+ throw new Error('only the active leaf may call a child playbook');
933
+ }
934
+ if (pendingChildParents.has(parent)) {
935
+ throw new Error('playbook frame already has an outstanding child');
936
+ }
937
+ if (typeof request.callId !== 'string' || request.callId.trim() === '') {
938
+ throw new Error('nested playbook call id must be a non-empty string');
939
+ }
940
+ if (typeof request.playbookId !== 'string' ||
941
+ request.playbookId.trim() === '') {
942
+ throw new Error('nested playbook id must be a non-empty string');
943
+ }
944
+ if (typeof request.text !== 'string') {
945
+ throw new Error('nested playbook input text must be a string');
946
+ }
947
+ if (request.playbookId === INTERNAL_CAPTAIN_ID) {
948
+ throw new Error('the internal Captain playbook cannot call itself');
949
+ }
950
+ const entry = byId.get(request.playbookId);
951
+ if (!entry) {
952
+ throw new Error(`playbook "${request.playbookId}" is not enabled`);
953
+ }
954
+ if (frames.some((frame) => frame.entry.id === request.playbookId)) {
955
+ throw new Error(`nested playbook cycle: ${[
956
+ ...frames.map((frame) => frame.entry.id),
957
+ request.playbookId,
958
+ ].join(' -> ')}`);
959
+ }
960
+ pendingChildParents.add(parent);
961
+ let child;
962
+ try {
963
+ child = makeFrame(enablementById.get(entry.id), {
964
+ frame: parent,
965
+ callId: request.callId,
966
+ });
967
+ }
968
+ catch (error) {
969
+ pendingChildParents.delete(parent);
970
+ throw error;
971
+ }
972
+ frames.push(child);
973
+ clearLeafLedger();
974
+ let calledStatusEmitted = false;
975
+ let returnStatusHandled = false;
976
+ try {
977
+ await initFrame(child);
978
+ invocationSignal.throwIfAborted();
979
+ await requireSession().emitStatus(`◇ ${frameLabel(child)} called by ${frameLabel(parent)}`);
980
+ calledStatusEmitted = true;
981
+ const result = await driveFrame(child, request.text, activeContext, AbortSignal.any([invocationSignal, activeContext.signal]));
982
+ if (result.outcome === 'terminal' || result.outcome === 'aborted') {
983
+ const callResult = callResultFor(child, result);
984
+ returnStatusHandled = true;
985
+ const returned = await popChild(child, result.outcome === 'aborted' ? 'stopped' : 'returned');
986
+ if (!returned) {
987
+ throw new Error('nested playbook return lost its active frame');
988
+ }
989
+ return { state: 'settled', result: callResult };
990
+ }
991
+ assertRetainableResult(child, result);
992
+ if (invocationSignal.aborted) {
993
+ const callResult = {
994
+ status: 'aborted',
995
+ playbookId: child.entry.id,
996
+ childSessionId: child.sessionId,
997
+ state: result.state,
998
+ };
999
+ returnStatusHandled = true;
1000
+ const returned = await popChild(child, 'stopped');
1001
+ if (!returned) {
1002
+ throw new Error('nested playbook abort lost its active frame');
1003
+ }
1004
+ return { state: 'settled', result: callResult };
1005
+ }
1006
+ const abortListener = () => {
1007
+ registerPlaybookAbortCleanup(invocationSignal, disposeAbandonedChild(child));
1008
+ };
1009
+ child.invocationSignal = invocationSignal;
1010
+ child.abortListener = abortListener;
1011
+ invocationSignal.addEventListener('abort', abortListener, { once: true });
1012
+ return { state: 'suspended', childSessionId: child.sessionId };
1013
+ }
1014
+ catch (error) {
1015
+ let boundaryError = error;
1016
+ let visibilityControlFailure = error instanceof VisibilityControlError;
1017
+ if (frames.includes(child)) {
1018
+ try {
1019
+ await unwindFramesFrom(child, 'stack');
1020
+ }
1021
+ catch (cleanupError) {
1022
+ boundaryError = new AggregateError([error, cleanupError], 'nested playbook call and cleanup failed');
1023
+ }
1024
+ }
1025
+ pendingChildParents.delete(parent);
1026
+ if (calledStatusEmitted && !returnStatusHandled) {
1027
+ try {
1028
+ await requireSession().emitStatus(`◇ ${frameLabel(child)} stopped; returning to ${frameLabel(parent)}`);
1029
+ }
1030
+ catch (statusError) {
1031
+ boundaryError = new AggregateError([boundaryError, statusError], 'nested playbook failure status emission failed');
1032
+ }
1033
+ }
1034
+ if (frames.includes(parent)) {
1035
+ try {
1036
+ await requestVisibility(parent.enablement);
1037
+ }
1038
+ catch (visibilityError) {
1039
+ visibilityControlFailure = true;
1040
+ boundaryError = new AggregateError([boundaryError, visibilityError], 'nested playbook call return failed');
1041
+ }
1042
+ }
1043
+ if (visibilityControlFailure)
1044
+ throw boundaryError;
1045
+ return {
1046
+ state: 'settled',
1047
+ result: {
1048
+ status: invocationSignal.aborted ? 'aborted' : 'error',
1049
+ playbookId: request.playbookId,
1050
+ childSessionId: child.sessionId,
1051
+ error: normalizeErrorFull(boundaryError),
1052
+ },
1053
+ };
1054
+ }
1055
+ };
1056
+ const submitToActive = async (frame, text, context) => {
1057
+ const policy = frame.entry.summaryPolicy;
1058
+ const summaryCounts = {
1059
+ interruptions: 0,
1060
+ copyPastes: 0,
1061
+ };
1062
+ const summaryStateCounts = new Map();
1063
+ activeTurnSummary = policy
1064
+ ? {
1065
+ owner: frame,
1066
+ counts: summaryCounts,
1067
+ stateCounts: summaryStateCounts,
1068
+ }
1069
+ : undefined;
1070
+ let completed = false;
1071
+ try {
1072
+ const result = await driveFrame(frame, text, context);
1073
+ await processFrameResult(frame, result, context);
1074
+ completed = true;
1075
+ }
1076
+ catch (error) {
1077
+ if (frame.parent && frames.includes(frame)) {
1078
+ await returnBoundaryFailure(frame, error, context);
1079
+ completed = true;
1080
+ }
1081
+ else {
1082
+ throw error;
1083
+ }
278
1084
  }
279
1085
  finally {
280
1086
  activeTurnSummary = undefined;
281
- if (active === engagement && finalDisposalRequested === engagement) {
282
- finalDisposalRequested = undefined;
283
- await disposeActive('final');
284
- }
285
- else if (active === engagement && mode === 'engaged.driving') {
1087
+ if (leafFrame() && mode === 'engaged.driving') {
286
1088
  await setMode('engaged.parked', 'turn.settled');
287
1089
  }
288
1090
  }
289
- if (shouldSummarize) {
290
- await callVisibleTurnSummary(context, {
291
- playbookId: engagement.entry.id,
1091
+ if (completed && policy) {
1092
+ const progressRounds = summaryProgressRoundCount(summaryStateCounts);
1093
+ await callVisibleTurnSummary(frame, context, {
1094
+ playbookId: frame.entry.id,
292
1095
  submittedText: text,
293
1096
  counts: summaryCounts,
294
1097
  progressPhrase: summaryProgressPhrase(summaryStateCounts),
295
- reviewRebuttalRounds: summaryProgressRoundCount(summaryStateCounts),
1098
+ progressRounds,
1099
+ savedLine: policy.savedCountsLine(summaryCounts, progressRounds),
296
1100
  });
297
1101
  }
298
1102
  };
299
- const disposeActive = async (reason) => {
300
- const engagement = active;
301
- if (!engagement)
302
- return;
303
- const playbookId = engagement.entry.id;
304
- const commandLabel = playbookCommandLabel(engagement.entry);
305
- active = undefined;
306
- finalDisposalRequested = undefined;
307
- if (reason === 'dispose') {
308
- mode = 'chat';
309
- await engagement.runtime.dispose();
310
- latestSubRuntimeStateId = undefined;
311
- pendingBossQuestion = undefined;
312
- lastError = undefined;
313
- return;
314
- }
315
- await setMode('chat', reason, playbookId);
316
- await engagement.runtime.dispose();
317
- if (reason === 'dismiss') {
318
- await requireSession().emitStatus(`◇ ${commandLabel} stopped`);
319
- }
320
- else if (reason === 'final') {
321
- await requireSession().emitStatus(`◇ ${commandLabel} finished`);
322
- }
323
- latestSubRuntimeStateId = undefined;
324
- pendingBossQuestion = undefined;
325
- lastError = undefined;
326
- };
327
- const callVisibleChat = async (context, message) => {
328
- const result = await context.callCaptain(visibleChatEnvelope(message));
1103
+ const callVisibleChat = async (frame, context, message) => {
1104
+ const result = await callCaptainQueued(frame, context, visibleChatEnvelope(message), { visibility: 'visible', resume: false, allowedTools: [] }, context.signal);
329
1105
  if (result.status !== 'ok') {
330
1106
  throw new Error(result.error ?? `callCaptain status "${result.status}"`);
331
1107
  }
332
1108
  };
333
- const callVisibleTurnSummary = async (context, input) => {
334
- const result = await context.callCaptain(visibleTurnSummaryEnvelope(input));
1109
+ const callVisibleTurnSummary = async (frame, context, input) => {
1110
+ const result = await callCaptainQueued(frame, context, visibleTurnSummaryEnvelope(input), { visibility: 'visible', resume: false, allowedTools: [] }, context.signal);
335
1111
  if (result.status !== 'ok') {
336
1112
  throw new Error(result.error ?? `callCaptain status "${result.status}"`);
337
1113
  }
338
1114
  };
339
- const hiddenRouterEnvelope = (prompt) => [
340
- 'You are the Playbook Captain shell router.',
1115
+ const hiddenLifecycleEnvelope = (prompt) => [
1116
+ 'You are the Playbook Captain shell lifecycle classifier.',
341
1117
  'This is hidden control work. Return only one JSON object and no prose.',
342
1118
  'Allowed decisions:',
343
- '{"decision":"chat","text":"visible clarification or chat reply"}',
344
- '{"decision":"dispatch","playbookId":"<registered id>","text":"Boss text for that playbook"}',
345
- '{"decision":"sub","text":"Boss text for the active playbook"}',
346
- '{"decision":"dismiss","text":"optional visible dismissal reply"}',
347
- 'Use chat for near-miss command-like input or low-confidence playbook selection.',
348
- 'Treat unregistered slash-prefixed input as ordinary router input.',
349
- `Ledger:\n${JSON.stringify(ledgerSnapshot())}`,
350
- `Registry:\n${JSON.stringify(entries.map((entry) => ({
351
- id: entry.id,
352
- command: entry.command,
353
- intent: entry.intent,
354
- })))}`,
1119
+ '{"decision":"deliver"}',
1120
+ '{"decision":"dismiss"}',
1121
+ 'Choose dismiss only when Boss explicitly asks to stop or dismiss the current active engagement.',
1122
+ 'Choose deliver for every task instruction, answer, clarification, continuation, command-like near miss, or ambiguous message.',
1123
+ 'Do not rewrite, summarize, or copy the Boss message into the result.',
355
1124
  `Boss message:\n${prompt}`,
356
1125
  ].join('\n\n');
357
- const routerClarification = async (context) => {
358
- await callVisibleChat(context, "I'm not sure whether this should be Captain chat or a /code task. Please clarify.");
359
- };
360
- const parseRouterDecision = (finalText) => {
1126
+ const parseLifecycleDecision = (finalText) => {
361
1127
  let parsed;
362
1128
  try {
363
1129
  parsed = JSON.parse(finalText);
@@ -372,85 +1138,57 @@ export function createPlaybookCaptainShell(options, registry = playbookCaptainRe
372
1138
  }
373
1139
  const record = parsed;
374
1140
  const decision = record.decision;
375
- if (decision === 'chat') {
376
- return typeof record.text === 'string' && record.text.trim()
377
- ? { decision, text: record.text.trim() }
378
- : undefined;
379
- }
380
- if (decision === 'dispatch') {
381
- return typeof record.playbookId === 'string' &&
382
- byId.has(record.playbookId) &&
383
- typeof record.text === 'string' &&
384
- record.text.trim()
385
- ? {
386
- decision,
387
- playbookId: record.playbookId,
388
- text: record.text.trim(),
389
- }
390
- : undefined;
391
- }
392
- if (decision === 'sub') {
393
- return typeof record.text === 'string' && record.text.trim()
394
- ? { decision, text: record.text.trim() }
395
- : undefined;
396
- }
397
- if (decision === 'dismiss') {
398
- return typeof record.text === 'string' && record.text.trim()
399
- ? { decision, text: record.text.trim() }
400
- : { decision };
401
- }
1141
+ if (decision === 'deliver')
1142
+ return { decision };
1143
+ if (decision === 'dismiss')
1144
+ return { decision };
402
1145
  return undefined;
403
1146
  };
404
- const routeHidden = async (turn, context) => {
405
- const result = await context.callCaptain(hiddenRouterEnvelope(turn.prompt), { visibility: 'hidden' });
406
- if (result.status !== 'ok' || result.finalText === undefined) {
407
- await routerClarification(context);
408
- return;
1147
+ const routeEngaged = async (turn, context) => {
1148
+ const leaf = leafFrame();
1149
+ if (!leaf) {
1150
+ throw new Error('engaged lifecycle routing requires an active leaf');
409
1151
  }
410
- const decision = parseRouterDecision(result.finalText);
411
- if (!decision) {
412
- await routerClarification(context);
413
- return;
1152
+ let decision;
1153
+ try {
1154
+ const result = await callCaptainQueued(leaf, context, hiddenLifecycleEnvelope(turn.prompt), { visibility: 'hidden', resume: false, allowedTools: [] }, context.signal);
1155
+ if (result.status === 'ok' && result.finalText !== undefined) {
1156
+ decision = parseLifecycleDecision(result.finalText);
1157
+ }
414
1158
  }
415
- lastRouteDecision = decision.decision;
416
- if (decision.decision === 'chat') {
417
- await callVisibleChat(context, decision.text);
418
- return;
1159
+ catch {
1160
+ // Lifecycle classification is advisory. Delivery is fail-open so an
1161
+ // unavailable classifier can never consume a parked leaf's Boss reply.
419
1162
  }
420
- if (decision.decision === 'dispatch') {
421
- const entry = byId.get(decision.playbookId);
422
- if (!entry || (active && active.entry.id !== entry.id)) {
423
- await routerClarification(context);
424
- return;
425
- }
426
- const engagement = await engage(entry);
427
- await submitToActive(engagement, decision.text, context);
1163
+ if (decision?.decision !== 'dismiss') {
1164
+ lastRouteDecision = 'deliver';
1165
+ await submitToActive(leaf, turn.prompt, context);
428
1166
  return;
429
1167
  }
430
- if (decision.decision === 'sub') {
431
- if (!active) {
432
- await routerClarification(context);
433
- return;
434
- }
435
- await submitToActive(active, decision.text, context);
436
- return;
1168
+ lastRouteDecision = 'dismiss';
1169
+ if (leaf.parent) {
1170
+ await resumeParent(leaf, {
1171
+ status: 'aborted',
1172
+ playbookId: leaf.entry.id,
1173
+ childSessionId: leaf.sessionId,
1174
+ ...(leaf.state ? { state: leaf.state } : {}),
1175
+ }, context, 'stopped');
437
1176
  }
438
- if (!active) {
439
- await routerClarification(context);
440
- return;
1177
+ else {
1178
+ await disposeStack('dismiss');
441
1179
  }
442
- const dismissedCommandLabel = playbookCommandLabel(active.entry);
443
- await disposeActive('dismiss');
444
- await callVisibleChat(context, decision.text ?? `${dismissedCommandLabel} stopped.`);
445
1180
  };
446
1181
  const handleRegisteredCommand = async (entry, text, context) => {
447
- if (active && active.entry.id !== entry.id) {
448
- await callVisibleChat(context, `/${active.entry.command} is already running. Finish or stop it before starting /${entry.command}.`);
1182
+ const enablement = enablementById.get(entry.id);
1183
+ const leaf = leafFrame();
1184
+ if (leaf && leaf.entry.id !== entry.id) {
1185
+ await callVisibleChat(leaf, context, `${frameLabel(leaf)} is already running. Finish or stop it before starting /${enablement.command}.`);
449
1186
  return;
450
1187
  }
451
- const engagement = await engage(entry);
1188
+ const engagement = leaf ?? (await engage(entry));
452
1189
  if (text.length === 0) {
453
- await callVisibleChat(context, `Ask what task to run with /${entry.command}.`);
1190
+ await requestVisibility(engagement.enablement);
1191
+ await callVisibleChat(engagement, context, `Ask what task to run with /${enablement.command}.`);
454
1192
  return;
455
1193
  }
456
1194
  await submitToActive(engagement, text, context);
@@ -459,13 +1197,24 @@ export function createPlaybookCaptainShell(options, registry = playbookCaptainRe
459
1197
  async init(initSession) {
460
1198
  session = initSession;
461
1199
  players = initSession.players;
462
- for (const entry of entries) {
463
- entry.validateOptions(options);
1200
+ const built = await buildEnablements(options, players, loadModule);
1201
+ entries = built.entries;
1202
+ byCommand = built.byCommand;
1203
+ byId = built.byId;
1204
+ enablementById = built.enablementById;
1205
+ for (const enablement of enablementById.values()) {
1206
+ enablement.entry.validateOptions(enablement.optionInput);
464
1207
  }
1208
+ internalCaptainEnablement = createInternalCaptainEnablement();
465
1209
  await setMode('chat', 'init');
466
1210
  },
467
1211
  async handleBossTurn(turn, context) {
468
1212
  requireSession();
1213
+ if (activeTurnHostCalls !== undefined) {
1214
+ throw new Error('cannot handle concurrent Boss turns');
1215
+ }
1216
+ const turnHostCalls = new Set();
1217
+ activeTurnHostCalls = turnHostCalls;
469
1218
  activeContext = context;
470
1219
  try {
471
1220
  const command = parseRegisteredCommand(turn.prompt);
@@ -476,15 +1225,31 @@ export function createPlaybookCaptainShell(options, registry = playbookCaptainRe
476
1225
  return;
477
1226
  }
478
1227
  }
479
- await routeHidden(turn, context);
1228
+ const leaf = leafFrame();
1229
+ if (leaf) {
1230
+ await routeEngaged(turn, context);
1231
+ return;
1232
+ }
1233
+ if (turn.prompt.trim().length === 0)
1234
+ return;
1235
+ const captain = await engageInternalCaptain();
1236
+ await submitToActive(captain, turn.prompt, context);
480
1237
  }
481
1238
  finally {
1239
+ await drainHostCalls(turnHostCalls);
1240
+ if (activeTurnHostCalls === turnHostCalls) {
1241
+ activeTurnHostCalls = undefined;
1242
+ }
482
1243
  activeContext = undefined;
483
1244
  }
484
1245
  },
1246
+ async prepareDispose() {
1247
+ activeContext = undefined;
1248
+ await disposeStack('dispose');
1249
+ },
485
1250
  async dispose() {
486
1251
  activeContext = undefined;
487
- await disposeActive('dispose');
1252
+ await disposeStack('dispose');
488
1253
  },
489
1254
  };
490
1255
  }