@sublang/playbook 0.4.2 → 0.6.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 +42 -19
- package/package.json +18 -6
- package/reference/sdlc/code.playbook/bin/playbook-code.js +277 -104
- package/reference/sdlc/code.playbook/code.fsm.d.ts +1 -0
- package/reference/sdlc/code.playbook/code.fsm.js +67 -29
- package/reference/sdlc/code.playbook/code.fsm.ts +74 -29
- package/reference/sdlc/code.playbook/code.gears.md +66 -30
- package/reference/sdlc/code.playbook/code.playbook.d.ts +19 -3
- package/reference/sdlc/code.playbook/code.playbook.js +292 -34
- package/reference/sdlc/code.playbook/code.playbook.ts +290 -35
- package/reference/sdlc/code.playbook/code.registry.d.ts +43 -0
- package/reference/sdlc/code.playbook/code.registry.js +109 -0
- package/reference/sdlc/code.playbook/code.registry.ts +159 -0
- package/reference/sdlc/code.playbook/code.tmux-play.d.ts +2 -0
- package/reference/sdlc/code.playbook/code.tmux-play.js +7 -93
- package/reference/sdlc/code.playbook/code.tmux-play.ts +20 -118
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +21 -0
- package/reference/sdlc/code.playbook/playbook-captain.js +491 -0
- package/reference/sdlc/code.playbook/playbook-captain.ts +701 -0
- package/reference/sdlc/code.playbook/playbook-code.config.template.yaml +50 -25
- package/reference/sdlc/code.playbook/tmux-play.config.yaml +13 -8
- package/reference/sdlc/code.playbook/tmux-play.production.config.yaml +8 -4
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
|
|
3
|
+
import { codePlaybookRegistryEntry, } from './code.registry.js';
|
|
4
|
+
const SUB_RUNTIME_FSM_TOPIC = 'playbook.fsm.state';
|
|
5
|
+
const SHELL_FSM_TOPIC = 'playbook.captain.fsm.state';
|
|
6
|
+
export const playbookCaptainRegistry = [
|
|
7
|
+
codePlaybookRegistryEntry,
|
|
8
|
+
];
|
|
9
|
+
function parseRegisteredCommand(prompt) {
|
|
10
|
+
const match = /^\/([A-Za-z][A-Za-z0-9_-]*)(?:\s+([\s\S]*))?$/.exec(prompt.trim());
|
|
11
|
+
if (!match)
|
|
12
|
+
return undefined;
|
|
13
|
+
return { command: match[1], text: (match[2] ?? '').trim() };
|
|
14
|
+
}
|
|
15
|
+
function playbookCommandLabel(entry) {
|
|
16
|
+
return `/${entry.command}`;
|
|
17
|
+
}
|
|
18
|
+
function visibleChatEnvelope(message) {
|
|
19
|
+
return [
|
|
20
|
+
'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.',
|
|
22
|
+
message,
|
|
23
|
+
].join('\n\n');
|
|
24
|
+
}
|
|
25
|
+
function visibleTurnSummaryEnvelope(input) {
|
|
26
|
+
const savedLine = savedCountsLine(input.counts, input.reviewRebuttalRounds);
|
|
27
|
+
return [
|
|
28
|
+
'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.',
|
|
30
|
+
'Write a brief, clearly formatted turn-summary block for Boss.',
|
|
31
|
+
'Use a natural, chat-like tone and no more than two short sentences before the saved-counts line.',
|
|
32
|
+
'State only what was done or what changed; do not explain how it was done.',
|
|
33
|
+
'Do not list raw state names, transitions, guard names, prompts, tools, hidden calls, or reasoning.',
|
|
34
|
+
'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}`,
|
|
37
|
+
'Use the exact counts supplied; do not change them.',
|
|
38
|
+
'Do not repeat the exact review/rebuttal round count outside the saved-counts line.',
|
|
39
|
+
`Playbook: ${input.playbookId}`,
|
|
40
|
+
`Submitted Boss text:\n${input.submittedText}`,
|
|
41
|
+
`Progress counts:\n${input.progressPhrase}`,
|
|
42
|
+
`Counts:\n${JSON.stringify({
|
|
43
|
+
...input.counts,
|
|
44
|
+
reviewRebuttalRounds: input.reviewRebuttalRounds,
|
|
45
|
+
})}`,
|
|
46
|
+
].join('\n\n');
|
|
47
|
+
}
|
|
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
|
+
function stateCountLabel(stateId, entry) {
|
|
63
|
+
if (stateId === entry.idleStateId || stateId === entry.finalStateId) {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
const registryLabel = entry.stateCountLabels?.[stateId]?.trim();
|
|
67
|
+
return registryLabel || undefined;
|
|
68
|
+
}
|
|
69
|
+
function pluralizeStateCount(label, count) {
|
|
70
|
+
if (count === 1)
|
|
71
|
+
return `1 ${label}`;
|
|
72
|
+
if (label.endsWith('y'))
|
|
73
|
+
return `${count} ${label.slice(0, -1)}ies`;
|
|
74
|
+
if (label.endsWith('s'))
|
|
75
|
+
return `${count} ${label}es`;
|
|
76
|
+
return `${count} ${label}s`;
|
|
77
|
+
}
|
|
78
|
+
function summaryProgressPhrase(stateCounts) {
|
|
79
|
+
if (stateCounts.size === 0)
|
|
80
|
+
return 'none';
|
|
81
|
+
return [...stateCounts.entries()]
|
|
82
|
+
.map(([label, count]) => pluralizeStateCount(label, count))
|
|
83
|
+
.join(', ');
|
|
84
|
+
}
|
|
85
|
+
function summaryProgressRoundCount(stateCounts) {
|
|
86
|
+
return [...stateCounts.values()].reduce((total, count) => total + count, 0);
|
|
87
|
+
}
|
|
88
|
+
function guardFromJudgeReply(finalText) {
|
|
89
|
+
return /"guard"\s*:\s*"([^"]+)"/.exec(finalText)?.[1];
|
|
90
|
+
}
|
|
91
|
+
function normalizeRegistry(registry) {
|
|
92
|
+
const byCommand = new Map();
|
|
93
|
+
const byId = new Map();
|
|
94
|
+
for (const entry of registry) {
|
|
95
|
+
byCommand.set(entry.command, entry);
|
|
96
|
+
byId.set(entry.id, entry);
|
|
97
|
+
}
|
|
98
|
+
return { entries: registry, byCommand, byId };
|
|
99
|
+
}
|
|
100
|
+
export function createPlaybookCaptainShell(options, registry = playbookCaptainRegistry) {
|
|
101
|
+
const { entries, byCommand, byId } = normalizeRegistry(registry);
|
|
102
|
+
let session;
|
|
103
|
+
let players = [];
|
|
104
|
+
let activeContext;
|
|
105
|
+
let active;
|
|
106
|
+
let mode = 'chat';
|
|
107
|
+
let latestSubRuntimeStateId;
|
|
108
|
+
let pendingBossQuestion;
|
|
109
|
+
let lastError;
|
|
110
|
+
let lastRouteDecision;
|
|
111
|
+
let finalDisposalRequested;
|
|
112
|
+
let activeTurnSummary;
|
|
113
|
+
const requireSession = () => {
|
|
114
|
+
if (!session) {
|
|
115
|
+
throw new Error('init must be called first');
|
|
116
|
+
}
|
|
117
|
+
return session;
|
|
118
|
+
};
|
|
119
|
+
const ledgerSnapshot = (playbookId = active?.entry.id) => ({
|
|
120
|
+
...(playbookId ? { activePlaybookId: playbookId } : {}),
|
|
121
|
+
mode,
|
|
122
|
+
...(latestSubRuntimeStateId ? { latestSubRuntimeStateId } : {}),
|
|
123
|
+
...(pendingBossQuestion !== undefined ? { pendingBossQuestion } : {}),
|
|
124
|
+
...(lastError ? { lastError } : {}),
|
|
125
|
+
...(lastRouteDecision ? { lastRouteDecision } : {}),
|
|
126
|
+
});
|
|
127
|
+
const emitShellTelemetry = async (from, to, event, playbookId = active?.entry.id) => {
|
|
128
|
+
await requireSession().emitTelemetry({
|
|
129
|
+
topic: SHELL_FSM_TOPIC,
|
|
130
|
+
payload: {
|
|
131
|
+
from,
|
|
132
|
+
to,
|
|
133
|
+
event,
|
|
134
|
+
ledger: ledgerSnapshot(playbookId),
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
const setMode = async (nextMode, event, playbookId = active?.entry.id) => {
|
|
139
|
+
if (mode === nextMode)
|
|
140
|
+
return;
|
|
141
|
+
const from = mode;
|
|
142
|
+
mode = nextMode;
|
|
143
|
+
await emitShellTelemetry(from, nextMode, event, playbookId);
|
|
144
|
+
};
|
|
145
|
+
const normalizeErrorCompact = (value) => {
|
|
146
|
+
if (value === undefined || value === null)
|
|
147
|
+
return undefined;
|
|
148
|
+
if (value instanceof Error) {
|
|
149
|
+
return { name: value.name, message: value.message };
|
|
150
|
+
}
|
|
151
|
+
if (typeof value === 'object') {
|
|
152
|
+
const record = value;
|
|
153
|
+
if (typeof record.message === 'string') {
|
|
154
|
+
return {
|
|
155
|
+
name: typeof record.name === 'string' ? record.name : 'Error',
|
|
156
|
+
message: record.message,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return { name: 'Error', message: String(value) };
|
|
161
|
+
};
|
|
162
|
+
const payloadRecord = (payload) => typeof payload === 'object' && payload !== null && !Array.isArray(payload)
|
|
163
|
+
? payload
|
|
164
|
+
: undefined;
|
|
165
|
+
const mirroredStateId = (payload) => {
|
|
166
|
+
const record = payloadRecord(payload);
|
|
167
|
+
if (!record)
|
|
168
|
+
return undefined;
|
|
169
|
+
if (typeof record.to === 'string')
|
|
170
|
+
return record.to;
|
|
171
|
+
return typeof record.state === 'string' ? record.state : undefined;
|
|
172
|
+
};
|
|
173
|
+
const mirrorSubRuntimeTelemetry = async (payload) => {
|
|
174
|
+
if (!active)
|
|
175
|
+
return;
|
|
176
|
+
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;
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (stateId === active.entry.idleStateId ||
|
|
192
|
+
stateId === 'failed' ||
|
|
193
|
+
stateId === 'awaitBossReply') {
|
|
194
|
+
await setMode('engaged.parked', `sub-runtime:${stateId}`);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
const createPorts = () => ({
|
|
198
|
+
callPlayer: async (playerId, prompt, _signal) => {
|
|
199
|
+
if (!activeContext) {
|
|
200
|
+
throw new Error('callPlayer invoked outside a Boss turn');
|
|
201
|
+
}
|
|
202
|
+
const result = await activeContext.callPlayer(playerId, prompt);
|
|
203
|
+
if (activeTurnSummary) {
|
|
204
|
+
activeTurnSummary.counts.interruptions++;
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
status: result.status,
|
|
208
|
+
finalText: result.finalText,
|
|
209
|
+
error: result.error,
|
|
210
|
+
};
|
|
211
|
+
},
|
|
212
|
+
callJudge: async (prompt, _signal) => {
|
|
213
|
+
if (!activeContext) {
|
|
214
|
+
throw new Error('callJudge invoked outside a Boss turn');
|
|
215
|
+
}
|
|
216
|
+
const result = await activeContext.callCaptain(prompt, {
|
|
217
|
+
visibility: 'hidden',
|
|
218
|
+
});
|
|
219
|
+
if (result.status !== 'ok') {
|
|
220
|
+
throw new Error(result.error ?? `callCaptain status "${result.status}"`);
|
|
221
|
+
}
|
|
222
|
+
if (result.finalText === undefined) {
|
|
223
|
+
throw new Error('callCaptain returned status=ok with no finalText');
|
|
224
|
+
}
|
|
225
|
+
const guard = guardFromJudgeReply(result.finalText);
|
|
226
|
+
if (guard &&
|
|
227
|
+
active?.entry.copyPasteGuardNames.includes(guard) &&
|
|
228
|
+
activeTurnSummary) {
|
|
229
|
+
activeTurnSummary.counts.copyPastes++;
|
|
230
|
+
}
|
|
231
|
+
return result.finalText;
|
|
232
|
+
},
|
|
233
|
+
emitStatus: async (message, data) => {
|
|
234
|
+
await requireSession().emitStatus(message, data);
|
|
235
|
+
},
|
|
236
|
+
emitTelemetry: async (event) => {
|
|
237
|
+
if (event.topic === SUB_RUNTIME_FSM_TOPIC) {
|
|
238
|
+
await mirrorSubRuntimeTelemetry(event.payload);
|
|
239
|
+
}
|
|
240
|
+
await requireSession().emitTelemetry(event);
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
const engage = async (entry) => {
|
|
244
|
+
if (active?.entry.id === entry.id)
|
|
245
|
+
return active;
|
|
246
|
+
const runtime = entry.createRuntime({
|
|
247
|
+
captainOptions: options,
|
|
248
|
+
players,
|
|
249
|
+
});
|
|
250
|
+
active = { entry, runtime };
|
|
251
|
+
latestSubRuntimeStateId = undefined;
|
|
252
|
+
pendingBossQuestion = undefined;
|
|
253
|
+
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
|
+
};
|
|
260
|
+
const submitToActive = async (engagement, text, context) => {
|
|
261
|
+
const summaryCounts = {
|
|
262
|
+
interruptions: 0,
|
|
263
|
+
copyPastes: 0,
|
|
264
|
+
};
|
|
265
|
+
const summaryStateCounts = new Map();
|
|
266
|
+
let shouldSummarize = false;
|
|
267
|
+
activeTurnSummary = {
|
|
268
|
+
counts: summaryCounts,
|
|
269
|
+
stateCounts: summaryStateCounts,
|
|
270
|
+
};
|
|
271
|
+
await setMode('engaged.driving', 'submit');
|
|
272
|
+
try {
|
|
273
|
+
await engagement.runtime.handleBossInput({
|
|
274
|
+
text,
|
|
275
|
+
signal: context.signal,
|
|
276
|
+
});
|
|
277
|
+
shouldSummarize = true;
|
|
278
|
+
}
|
|
279
|
+
finally {
|
|
280
|
+
activeTurnSummary = undefined;
|
|
281
|
+
if (active === engagement && finalDisposalRequested === engagement) {
|
|
282
|
+
finalDisposalRequested = undefined;
|
|
283
|
+
await disposeActive('final');
|
|
284
|
+
}
|
|
285
|
+
else if (active === engagement && mode === 'engaged.driving') {
|
|
286
|
+
await setMode('engaged.parked', 'turn.settled');
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (shouldSummarize) {
|
|
290
|
+
await callVisibleTurnSummary(context, {
|
|
291
|
+
playbookId: engagement.entry.id,
|
|
292
|
+
submittedText: text,
|
|
293
|
+
counts: summaryCounts,
|
|
294
|
+
progressPhrase: summaryProgressPhrase(summaryStateCounts),
|
|
295
|
+
reviewRebuttalRounds: summaryProgressRoundCount(summaryStateCounts),
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
};
|
|
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));
|
|
329
|
+
if (result.status !== 'ok') {
|
|
330
|
+
throw new Error(result.error ?? `callCaptain status "${result.status}"`);
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
const callVisibleTurnSummary = async (context, input) => {
|
|
334
|
+
const result = await context.callCaptain(visibleTurnSummaryEnvelope(input));
|
|
335
|
+
if (result.status !== 'ok') {
|
|
336
|
+
throw new Error(result.error ?? `callCaptain status "${result.status}"`);
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
const hiddenRouterEnvelope = (prompt) => [
|
|
340
|
+
'You are the Playbook Captain shell router.',
|
|
341
|
+
'This is hidden control work. Return only one JSON object and no prose.',
|
|
342
|
+
'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
|
+
})))}`,
|
|
355
|
+
`Boss message:\n${prompt}`,
|
|
356
|
+
].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) => {
|
|
361
|
+
let parsed;
|
|
362
|
+
try {
|
|
363
|
+
parsed = JSON.parse(finalText);
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
return undefined;
|
|
367
|
+
}
|
|
368
|
+
if (typeof parsed !== 'object' ||
|
|
369
|
+
parsed === null ||
|
|
370
|
+
Array.isArray(parsed)) {
|
|
371
|
+
return undefined;
|
|
372
|
+
}
|
|
373
|
+
const record = parsed;
|
|
374
|
+
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
|
+
}
|
|
402
|
+
return undefined;
|
|
403
|
+
};
|
|
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;
|
|
409
|
+
}
|
|
410
|
+
const decision = parseRouterDecision(result.finalText);
|
|
411
|
+
if (!decision) {
|
|
412
|
+
await routerClarification(context);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
lastRouteDecision = decision.decision;
|
|
416
|
+
if (decision.decision === 'chat') {
|
|
417
|
+
await callVisibleChat(context, decision.text);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
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);
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
if (decision.decision === 'sub') {
|
|
431
|
+
if (!active) {
|
|
432
|
+
await routerClarification(context);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
await submitToActive(active, decision.text, context);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
if (!active) {
|
|
439
|
+
await routerClarification(context);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
const dismissedCommandLabel = playbookCommandLabel(active.entry);
|
|
443
|
+
await disposeActive('dismiss');
|
|
444
|
+
await callVisibleChat(context, decision.text ?? `${dismissedCommandLabel} stopped.`);
|
|
445
|
+
};
|
|
446
|
+
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}.`);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
const engagement = await engage(entry);
|
|
452
|
+
if (text.length === 0) {
|
|
453
|
+
await callVisibleChat(context, `Ask what task to run with /${entry.command}.`);
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
await submitToActive(engagement, text, context);
|
|
457
|
+
};
|
|
458
|
+
return {
|
|
459
|
+
async init(initSession) {
|
|
460
|
+
session = initSession;
|
|
461
|
+
players = initSession.players;
|
|
462
|
+
for (const entry of entries) {
|
|
463
|
+
entry.validateOptions(options);
|
|
464
|
+
}
|
|
465
|
+
await setMode('chat', 'init');
|
|
466
|
+
},
|
|
467
|
+
async handleBossTurn(turn, context) {
|
|
468
|
+
requireSession();
|
|
469
|
+
activeContext = context;
|
|
470
|
+
try {
|
|
471
|
+
const command = parseRegisteredCommand(turn.prompt);
|
|
472
|
+
if (command !== undefined) {
|
|
473
|
+
const entry = byCommand.get(command.command);
|
|
474
|
+
if (entry) {
|
|
475
|
+
await handleRegisteredCommand(entry, command.text, context);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
await routeHidden(turn, context);
|
|
480
|
+
}
|
|
481
|
+
finally {
|
|
482
|
+
activeContext = undefined;
|
|
483
|
+
}
|
|
484
|
+
},
|
|
485
|
+
async dispose() {
|
|
486
|
+
activeContext = undefined;
|
|
487
|
+
await disposeActive('dispose');
|
|
488
|
+
},
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
export default createPlaybookCaptainShell;
|