@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.
- package/README.md +243 -193
- package/package.json +52 -17
- package/reference/sdlc/captain.md +102 -0
- package/reference/sdlc/captain.playbook/captain.fsm.d.ts +227 -0
- package/reference/sdlc/captain.playbook/captain.fsm.js +628 -0
- package/reference/sdlc/captain.playbook/captain.fsm.ts +851 -0
- package/reference/sdlc/captain.playbook/captain.gears.md +60 -0
- package/reference/sdlc/captain.playbook/captain.playbook.d.ts +23 -0
- package/reference/sdlc/captain.playbook/captain.playbook.js +1053 -0
- package/reference/sdlc/captain.playbook/captain.playbook.ts +1144 -0
- package/reference/sdlc/code.playbook/bin/playbook.js +580 -0
- package/reference/sdlc/code.playbook/bin/run.js +893 -0
- package/reference/sdlc/code.playbook/code.fsm.d.ts +11 -4
- package/reference/sdlc/code.playbook/code.fsm.introspect.d.ts +2 -2
- package/reference/sdlc/code.playbook/code.fsm.introspect.js +1 -1
- package/reference/sdlc/code.playbook/code.fsm.introspect.ts +6 -6
- package/reference/sdlc/code.playbook/code.fsm.js +334 -102
- package/reference/sdlc/code.playbook/code.fsm.ts +470 -182
- package/reference/sdlc/code.playbook/code.gears.md +11 -10
- package/reference/sdlc/code.playbook/code.playbook.d.ts +18 -9
- package/reference/sdlc/code.playbook/code.playbook.js +1098 -202
- package/reference/sdlc/code.playbook/code.playbook.ts +1440 -258
- package/reference/sdlc/code.playbook/code.registry.d.ts +17 -5
- package/reference/sdlc/code.playbook/code.registry.js +49 -34
- package/reference/sdlc/code.playbook/code.registry.ts +75 -41
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +16 -8
- package/reference/sdlc/code.playbook/playbook-captain.js +1005 -240
- package/reference/sdlc/code.playbook/playbook-captain.ts +1310 -301
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +68 -0
- package/reference/sdlc/discuss.playbook/discuss.fsm.d.ts +396 -0
- package/reference/sdlc/discuss.playbook/discuss.fsm.js +2066 -0
- package/reference/sdlc/discuss.playbook/discuss.fsm.ts +2464 -0
- package/reference/sdlc/discuss.playbook/discuss.gears.md +251 -0
- package/reference/sdlc/discuss.playbook/discuss.playbook.d.ts +113 -0
- package/reference/sdlc/discuss.playbook/discuss.playbook.js +1514 -0
- package/reference/sdlc/discuss.playbook/discuss.playbook.ts +1926 -0
- package/reference/sdlc/discuss.playbook/discuss.registry.d.ts +58 -0
- package/reference/sdlc/discuss.playbook/discuss.registry.js +97 -0
- package/reference/sdlc/discuss.playbook/discuss.registry.ts +153 -0
- package/slc/gears2fsm.md +557 -57
- package/slc/link.md +1097 -80
- package/slc/optimize.md +88 -0
- package/slc/text2gears.md +247 -5
- package/src/runtime.d.ts +145 -3
- package/src/runtime.ts +200 -2
- package/src/xstate-runtime.d.ts +94 -0
- package/src/xstate-runtime.js +1247 -0
- package/src/xstate-runtime.ts +1802 -0
- package/reference/sdlc/code.playbook/bin/playbook-code.js +0 -487
- package/reference/sdlc/code.playbook/code.tmux-play.d.ts +0 -4
- package/reference/sdlc/code.playbook/code.tmux-play.js +0 -11
- package/reference/sdlc/code.playbook/code.tmux-play.ts +0 -29
- package/reference/sdlc/code.playbook/playbook-code.config.template.yaml +0 -72
- package/reference/sdlc/code.playbook/tmux-play.config.yaml +0 -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 {
|
|
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
|
-
|
|
7
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
95
|
-
|
|
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
|
|
173
|
+
return { entries, byCommand, byId, enablementById };
|
|
99
174
|
}
|
|
100
|
-
export function createPlaybookCaptainShell(options,
|
|
101
|
-
const
|
|
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
|
-
|
|
187
|
+
const frames = [];
|
|
106
188
|
let mode = 'chat';
|
|
107
|
-
let
|
|
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 =
|
|
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
|
-
...(
|
|
123
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
166
|
-
const record = payloadRecord(
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
return typeof record.state === 'string' ? record.state : undefined;
|
|
278
|
+
}
|
|
279
|
+
return record;
|
|
172
280
|
};
|
|
173
|
-
const
|
|
174
|
-
if (
|
|
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
|
|
178
|
-
if (
|
|
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 (
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
198
|
-
|
|
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
|
|
203
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
228
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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:
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
|
274
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
1098
|
+
progressRounds,
|
|
1099
|
+
savedLine: policy.savedCountsLine(summaryCounts, progressRounds),
|
|
296
1100
|
});
|
|
297
1101
|
}
|
|
298
1102
|
};
|
|
299
|
-
const
|
|
300
|
-
const
|
|
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
|
|
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
|
|
340
|
-
'You are the Playbook Captain shell
|
|
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":"
|
|
344
|
-
'{"decision":"
|
|
345
|
-
'
|
|
346
|
-
'
|
|
347
|
-
'
|
|
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
|
|
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 === '
|
|
376
|
-
return
|
|
377
|
-
|
|
378
|
-
|
|
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
|
|
405
|
-
const
|
|
406
|
-
if (
|
|
407
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
await
|
|
413
|
-
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
439
|
-
await
|
|
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
|
-
|
|
448
|
-
|
|
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
|
|
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
|
-
|
|
463
|
-
|
|
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
|
-
|
|
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
|
|
1252
|
+
await disposeStack('dispose');
|
|
488
1253
|
},
|
|
489
1254
|
};
|
|
490
1255
|
}
|