@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.
@@ -0,0 +1,701 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
+
4
+ import type {
5
+ BossTurn,
6
+ Captain,
7
+ CaptainContext,
8
+ CaptainSession,
9
+ } from '@sublang/cligent/tmux-play';
10
+ import type {
11
+ PlaybookPorts,
12
+ PlaybookRuntime,
13
+ } from './code.playbook.js';
14
+ import {
15
+ codePlaybookRegistryEntry,
16
+ type RegistryPlayer,
17
+ } from './code.registry.js';
18
+
19
+ export interface CreatePlaybookRuntimeOptions {
20
+ captainOptions: unknown;
21
+ players: readonly RegistryPlayer[];
22
+ }
23
+
24
+ export interface PlaybookCaptainRegistryEntry {
25
+ id: string;
26
+ command: string;
27
+ intent: string;
28
+ idleStateId: string;
29
+ finalStateId: string;
30
+ copyPasteGuardNames: readonly string[];
31
+ stateCountLabels?: Readonly<Record<string, string>>;
32
+ validateOptions(captainOptions: unknown): unknown;
33
+ createRuntime(options: CreatePlaybookRuntimeOptions): PlaybookRuntime;
34
+ }
35
+
36
+ interface ActiveEngagement {
37
+ entry: PlaybookCaptainRegistryEntry;
38
+ runtime: PlaybookRuntime;
39
+ }
40
+
41
+ type RouterDecision =
42
+ | { decision: 'chat'; text: string }
43
+ | { decision: 'dispatch'; playbookId: string; text: string }
44
+ | { decision: 'sub'; text: string }
45
+ | { decision: 'dismiss'; text?: string };
46
+
47
+ type DisposalReason = 'dismiss' | 'final' | 'dispose';
48
+
49
+ interface ControlLedger {
50
+ activePlaybookId?: string;
51
+ mode: ShellMode;
52
+ latestSubRuntimeStateId?: string;
53
+ pendingBossQuestion?: unknown;
54
+ lastError?: { name: string; message: string };
55
+ lastRouteDecision?: RouterDecision['decision'];
56
+ }
57
+
58
+ type ShellMode = 'chat' | 'engaged.driving' | 'engaged.parked';
59
+
60
+ const SUB_RUNTIME_FSM_TOPIC = 'playbook.fsm.state';
61
+ const SHELL_FSM_TOPIC = 'playbook.captain.fsm.state';
62
+
63
+ interface TurnSummaryCounts {
64
+ interruptions: number;
65
+ copyPastes: number;
66
+ }
67
+
68
+ interface ActiveTurnSummary {
69
+ counts: TurnSummaryCounts;
70
+ stateCounts: Map<string, number>;
71
+ }
72
+
73
+ export const playbookCaptainRegistry: readonly PlaybookCaptainRegistryEntry[] = [
74
+ codePlaybookRegistryEntry,
75
+ ];
76
+
77
+ function parseRegisteredCommand(
78
+ prompt: string,
79
+ ): { command: string; text: string } | undefined {
80
+ const match = /^\/([A-Za-z][A-Za-z0-9_-]*)(?:\s+([\s\S]*))?$/.exec(
81
+ prompt.trim(),
82
+ );
83
+ if (!match) return undefined;
84
+ return { command: match[1], text: (match[2] ?? '').trim() };
85
+ }
86
+
87
+ function playbookCommandLabel(entry: PlaybookCaptainRegistryEntry): string {
88
+ return `/${entry.command}`;
89
+ }
90
+
91
+ function visibleChatEnvelope(message: string): string {
92
+ return [
93
+ 'You are the Playbook Captain shell.',
94
+ 'This is visible Boss chat. Do not reveal hidden control JSON, hidden router decisions, or hidden judge replies.',
95
+ message,
96
+ ].join('\n\n');
97
+ }
98
+
99
+ function visibleTurnSummaryEnvelope(input: {
100
+ playbookId: string;
101
+ submittedText: string;
102
+ counts: TurnSummaryCounts;
103
+ progressPhrase: string;
104
+ reviewRebuttalRounds: number;
105
+ }): string {
106
+ const savedLine = savedCountsLine(input.counts, input.reviewRebuttalRounds);
107
+ return [
108
+ 'You are the Playbook Captain shell.',
109
+ 'This is visible Boss chat after a sub-playbook command completed. Do not reveal hidden control JSON, hidden router decisions, or hidden judge replies.',
110
+ 'Write a brief, clearly formatted turn-summary block for Boss.',
111
+ 'Use a natural, chat-like tone and no more than two short sentences before the saved-counts line.',
112
+ 'State only what was done or what changed; do not explain how it was done.',
113
+ 'Do not list raw state names, transitions, guard names, prompts, tools, hidden calls, or reasoning.',
114
+ 'If progress detail is useful, use only the aggregate progress phrase supplied below.',
115
+ 'Do not mention counts for plan or implementation steps, tests green, or any other internal state.',
116
+ `Then write the saved-counts line exactly: ${savedLine}`,
117
+ 'Use the exact counts supplied; do not change them.',
118
+ 'Do not repeat the exact review/rebuttal round count outside the saved-counts line.',
119
+ `Playbook: ${input.playbookId}`,
120
+ `Submitted Boss text:\n${input.submittedText}`,
121
+ `Progress counts:\n${input.progressPhrase}`,
122
+ `Counts:\n${JSON.stringify({
123
+ ...input.counts,
124
+ reviewRebuttalRounds: input.reviewRebuttalRounds,
125
+ })}`,
126
+ ].join('\n\n');
127
+ }
128
+
129
+ function countNoun(count: number, singular: string, plural = `${singular}s`): string {
130
+ return `${count} ${count === 1 ? singular : plural}`;
131
+ }
132
+
133
+ function savedCountsLine(
134
+ counts: TurnSummaryCounts,
135
+ reviewRebuttalRounds: number,
136
+ ): string {
137
+ return [
138
+ 'Saved you',
139
+ countNoun(counts.interruptions, 'interruption'),
140
+ 'and',
141
+ countNoun(counts.copyPastes, 'copy-paste'),
142
+ 'across',
143
+ countNoun(reviewRebuttalRounds, 'round'),
144
+ 'of reviews/rebuttals.',
145
+ ].join(' ');
146
+ }
147
+
148
+ function stateCountLabel(
149
+ stateId: string,
150
+ entry: PlaybookCaptainRegistryEntry,
151
+ ): string | undefined {
152
+ if (stateId === entry.idleStateId || stateId === entry.finalStateId) {
153
+ return undefined;
154
+ }
155
+ const registryLabel = entry.stateCountLabels?.[stateId]?.trim();
156
+ return registryLabel || undefined;
157
+ }
158
+
159
+ function pluralizeStateCount(label: string, count: number): string {
160
+ if (count === 1) return `1 ${label}`;
161
+ if (label.endsWith('y')) return `${count} ${label.slice(0, -1)}ies`;
162
+ if (label.endsWith('s')) return `${count} ${label}es`;
163
+ return `${count} ${label}s`;
164
+ }
165
+
166
+ function summaryProgressPhrase(stateCounts: ReadonlyMap<string, number>): string {
167
+ if (stateCounts.size === 0) return 'none';
168
+ return [...stateCounts.entries()]
169
+ .map(([label, count]) => pluralizeStateCount(label, count))
170
+ .join(', ');
171
+ }
172
+
173
+ function summaryProgressRoundCount(stateCounts: ReadonlyMap<string, number>): number {
174
+ return [...stateCounts.values()].reduce((total, count) => total + count, 0);
175
+ }
176
+
177
+ function guardFromJudgeReply(finalText: string): string | undefined {
178
+ return /"guard"\s*:\s*"([^"]+)"/.exec(finalText)?.[1];
179
+ }
180
+
181
+ function normalizeRegistry(
182
+ registry: readonly PlaybookCaptainRegistryEntry[],
183
+ ): {
184
+ entries: readonly PlaybookCaptainRegistryEntry[];
185
+ byCommand: Map<string, PlaybookCaptainRegistryEntry>;
186
+ byId: Map<string, PlaybookCaptainRegistryEntry>;
187
+ } {
188
+ const byCommand = new Map<string, PlaybookCaptainRegistryEntry>();
189
+ const byId = new Map<string, PlaybookCaptainRegistryEntry>();
190
+ for (const entry of registry) {
191
+ byCommand.set(entry.command, entry);
192
+ byId.set(entry.id, entry);
193
+ }
194
+ return { entries: registry, byCommand, byId };
195
+ }
196
+
197
+ export function createPlaybookCaptainShell(
198
+ options: unknown,
199
+ registry: readonly PlaybookCaptainRegistryEntry[] = playbookCaptainRegistry,
200
+ ): Captain {
201
+ const { entries, byCommand, byId } = normalizeRegistry(registry);
202
+ let session: CaptainSession | undefined;
203
+ let players: readonly RegistryPlayer[] = [];
204
+ let activeContext: CaptainContext | undefined;
205
+ let active: ActiveEngagement | undefined;
206
+ let mode: ShellMode = 'chat';
207
+ let latestSubRuntimeStateId: string | undefined;
208
+ let pendingBossQuestion: unknown;
209
+ let lastError: { name: string; message: string } | undefined;
210
+ let lastRouteDecision: RouterDecision['decision'] | undefined;
211
+ let finalDisposalRequested: ActiveEngagement | undefined;
212
+ let activeTurnSummary: ActiveTurnSummary | undefined;
213
+
214
+ const requireSession = (): CaptainSession => {
215
+ if (!session) {
216
+ throw new Error('init must be called first');
217
+ }
218
+ return session;
219
+ };
220
+
221
+ const ledgerSnapshot = (
222
+ playbookId: string | undefined = active?.entry.id,
223
+ ): ControlLedger => ({
224
+ ...(playbookId ? { activePlaybookId: playbookId } : {}),
225
+ mode,
226
+ ...(latestSubRuntimeStateId ? { latestSubRuntimeStateId } : {}),
227
+ ...(pendingBossQuestion !== undefined ? { pendingBossQuestion } : {}),
228
+ ...(lastError ? { lastError } : {}),
229
+ ...(lastRouteDecision ? { lastRouteDecision } : {}),
230
+ });
231
+
232
+ const emitShellTelemetry = async (
233
+ from: ShellMode,
234
+ to: ShellMode,
235
+ event: string,
236
+ playbookId: string | undefined = active?.entry.id,
237
+ ): Promise<void> => {
238
+ await requireSession().emitTelemetry({
239
+ topic: SHELL_FSM_TOPIC,
240
+ payload: {
241
+ from,
242
+ to,
243
+ event,
244
+ ledger: ledgerSnapshot(playbookId),
245
+ },
246
+ });
247
+ };
248
+
249
+ const setMode = async (
250
+ nextMode: ShellMode,
251
+ event: string,
252
+ playbookId: string | undefined = active?.entry.id,
253
+ ): Promise<void> => {
254
+ if (mode === nextMode) return;
255
+ const from = mode;
256
+ mode = nextMode;
257
+ await emitShellTelemetry(from, nextMode, event, playbookId);
258
+ };
259
+
260
+ const normalizeErrorCompact = (
261
+ value: unknown,
262
+ ): { name: string; message: string } | undefined => {
263
+ if (value === undefined || value === null) return undefined;
264
+ if (value instanceof Error) {
265
+ return { name: value.name, message: value.message };
266
+ }
267
+ if (typeof value === 'object') {
268
+ const record = value as Record<string, unknown>;
269
+ if (typeof record.message === 'string') {
270
+ return {
271
+ name: typeof record.name === 'string' ? record.name : 'Error',
272
+ message: record.message,
273
+ };
274
+ }
275
+ }
276
+ return { name: 'Error', message: String(value) };
277
+ };
278
+
279
+ const payloadRecord = (
280
+ payload: unknown,
281
+ ): Record<string, unknown> | undefined =>
282
+ typeof payload === 'object' && payload !== null && !Array.isArray(payload)
283
+ ? (payload as Record<string, unknown>)
284
+ : undefined;
285
+
286
+ const mirroredStateId = (payload: unknown): string | undefined => {
287
+ const record = payloadRecord(payload);
288
+ if (!record) return undefined;
289
+ if (typeof record.to === 'string') return record.to;
290
+ return typeof record.state === 'string' ? record.state : undefined;
291
+ };
292
+
293
+ const mirrorSubRuntimeTelemetry = async (payload: unknown): Promise<void> => {
294
+ if (!active) return;
295
+ const record = payloadRecord(payload);
296
+ const stateId = mirroredStateId(payload);
297
+ if (stateId === undefined) return;
298
+
299
+ const countLabel = stateCountLabel(stateId, active.entry);
300
+ if (activeTurnSummary && countLabel) {
301
+ activeTurnSummary.stateCounts.set(
302
+ countLabel,
303
+ (activeTurnSummary.stateCounts.get(countLabel) ?? 0) + 1,
304
+ );
305
+ }
306
+
307
+ latestSubRuntimeStateId = stateId;
308
+ pendingBossQuestion = record?.pendingBossQuestion;
309
+ lastError = normalizeErrorCompact(record?.lastError);
310
+
311
+ if (stateId === active.entry.finalStateId) {
312
+ finalDisposalRequested = active;
313
+ return;
314
+ }
315
+
316
+ if (
317
+ stateId === active.entry.idleStateId ||
318
+ stateId === 'failed' ||
319
+ stateId === 'awaitBossReply'
320
+ ) {
321
+ await setMode('engaged.parked', `sub-runtime:${stateId}`);
322
+ }
323
+ };
324
+
325
+ const createPorts = (): PlaybookPorts => ({
326
+ callPlayer: async (playerId, prompt, _signal) => {
327
+ if (!activeContext) {
328
+ throw new Error('callPlayer invoked outside a Boss turn');
329
+ }
330
+ const result = await activeContext.callPlayer(playerId, prompt);
331
+ if (activeTurnSummary) {
332
+ activeTurnSummary.counts.interruptions++;
333
+ }
334
+ return {
335
+ status: result.status,
336
+ finalText: result.finalText,
337
+ error: result.error,
338
+ };
339
+ },
340
+ callJudge: async (prompt, _signal) => {
341
+ if (!activeContext) {
342
+ throw new Error('callJudge invoked outside a Boss turn');
343
+ }
344
+ const result = await activeContext.callCaptain(prompt, {
345
+ visibility: 'hidden',
346
+ });
347
+ if (result.status !== 'ok') {
348
+ throw new Error(
349
+ result.error ?? `callCaptain status "${result.status}"`,
350
+ );
351
+ }
352
+ if (result.finalText === undefined) {
353
+ throw new Error('callCaptain returned status=ok with no finalText');
354
+ }
355
+ const guard = guardFromJudgeReply(result.finalText);
356
+ if (
357
+ guard &&
358
+ active?.entry.copyPasteGuardNames.includes(guard) &&
359
+ activeTurnSummary
360
+ ) {
361
+ activeTurnSummary.counts.copyPastes++;
362
+ }
363
+ return result.finalText;
364
+ },
365
+ emitStatus: async (message, data) => {
366
+ await requireSession().emitStatus(
367
+ message,
368
+ data as Record<string, unknown> | undefined,
369
+ );
370
+ },
371
+ emitTelemetry: async (event) => {
372
+ if (event.topic === SUB_RUNTIME_FSM_TOPIC) {
373
+ await mirrorSubRuntimeTelemetry(event.payload);
374
+ }
375
+ await requireSession().emitTelemetry(event);
376
+ },
377
+ });
378
+
379
+ const engage = async (
380
+ entry: PlaybookCaptainRegistryEntry,
381
+ ): Promise<ActiveEngagement> => {
382
+ if (active?.entry.id === entry.id) return active;
383
+ const runtime = entry.createRuntime({
384
+ captainOptions: options,
385
+ players,
386
+ });
387
+ active = { entry, runtime };
388
+ latestSubRuntimeStateId = undefined;
389
+ pendingBossQuestion = undefined;
390
+ lastError = undefined;
391
+ finalDisposalRequested = undefined;
392
+ await setMode('engaged.parked', 'engage', entry.id);
393
+ await runtime.init(createPorts());
394
+ await requireSession().emitStatus(
395
+ `◇ ${playbookCommandLabel(entry)} started`,
396
+ );
397
+ return active;
398
+ };
399
+
400
+ const submitToActive = async (
401
+ engagement: ActiveEngagement,
402
+ text: string,
403
+ context: CaptainContext,
404
+ ): Promise<void> => {
405
+ const summaryCounts: TurnSummaryCounts = {
406
+ interruptions: 0,
407
+ copyPastes: 0,
408
+ };
409
+ const summaryStateCounts = new Map<string, number>();
410
+ let shouldSummarize = false;
411
+ activeTurnSummary = {
412
+ counts: summaryCounts,
413
+ stateCounts: summaryStateCounts,
414
+ };
415
+ await setMode('engaged.driving', 'submit');
416
+ try {
417
+ await engagement.runtime.handleBossInput({
418
+ text,
419
+ signal: context.signal,
420
+ });
421
+ shouldSummarize = true;
422
+ } finally {
423
+ activeTurnSummary = undefined;
424
+ if (active === engagement && finalDisposalRequested === engagement) {
425
+ finalDisposalRequested = undefined;
426
+ await disposeActive('final');
427
+ } else if (active === engagement && mode === 'engaged.driving') {
428
+ await setMode('engaged.parked', 'turn.settled');
429
+ }
430
+ }
431
+ if (shouldSummarize) {
432
+ await callVisibleTurnSummary(context, {
433
+ playbookId: engagement.entry.id,
434
+ submittedText: text,
435
+ counts: summaryCounts,
436
+ progressPhrase: summaryProgressPhrase(summaryStateCounts),
437
+ reviewRebuttalRounds: summaryProgressRoundCount(summaryStateCounts),
438
+ });
439
+ }
440
+ };
441
+
442
+ const disposeActive = async (
443
+ reason: DisposalReason,
444
+ ): Promise<void> => {
445
+ const engagement = active;
446
+ if (!engagement) return;
447
+ const playbookId = engagement.entry.id;
448
+ const commandLabel = playbookCommandLabel(engagement.entry);
449
+ active = undefined;
450
+ finalDisposalRequested = undefined;
451
+ if (reason === 'dispose') {
452
+ mode = 'chat';
453
+ await engagement.runtime.dispose();
454
+ latestSubRuntimeStateId = undefined;
455
+ pendingBossQuestion = undefined;
456
+ lastError = undefined;
457
+ return;
458
+ }
459
+ await setMode('chat', reason, playbookId);
460
+ await engagement.runtime.dispose();
461
+ if (reason === 'dismiss') {
462
+ await requireSession().emitStatus(`◇ ${commandLabel} stopped`);
463
+ } else if (reason === 'final') {
464
+ await requireSession().emitStatus(`◇ ${commandLabel} finished`);
465
+ }
466
+ latestSubRuntimeStateId = undefined;
467
+ pendingBossQuestion = undefined;
468
+ lastError = undefined;
469
+ };
470
+
471
+ const callVisibleChat = async (
472
+ context: CaptainContext,
473
+ message: string,
474
+ ): Promise<void> => {
475
+ const result = await context.callCaptain(visibleChatEnvelope(message));
476
+ if (result.status !== 'ok') {
477
+ throw new Error(
478
+ result.error ?? `callCaptain status "${result.status}"`,
479
+ );
480
+ }
481
+ };
482
+
483
+ const callVisibleTurnSummary = async (
484
+ context: CaptainContext,
485
+ input: {
486
+ playbookId: string;
487
+ submittedText: string;
488
+ counts: TurnSummaryCounts;
489
+ progressPhrase: string;
490
+ reviewRebuttalRounds: number;
491
+ },
492
+ ): Promise<void> => {
493
+ const result = await context.callCaptain(visibleTurnSummaryEnvelope(input));
494
+ if (result.status !== 'ok') {
495
+ throw new Error(
496
+ result.error ?? `callCaptain status "${result.status}"`,
497
+ );
498
+ }
499
+ };
500
+
501
+ const hiddenRouterEnvelope = (prompt: string): string =>
502
+ [
503
+ 'You are the Playbook Captain shell router.',
504
+ 'This is hidden control work. Return only one JSON object and no prose.',
505
+ 'Allowed decisions:',
506
+ '{"decision":"chat","text":"visible clarification or chat reply"}',
507
+ '{"decision":"dispatch","playbookId":"<registered id>","text":"Boss text for that playbook"}',
508
+ '{"decision":"sub","text":"Boss text for the active playbook"}',
509
+ '{"decision":"dismiss","text":"optional visible dismissal reply"}',
510
+ 'Use chat for near-miss command-like input or low-confidence playbook selection.',
511
+ 'Treat unregistered slash-prefixed input as ordinary router input.',
512
+ `Ledger:\n${JSON.stringify(ledgerSnapshot())}`,
513
+ `Registry:\n${JSON.stringify(
514
+ entries.map((entry) => ({
515
+ id: entry.id,
516
+ command: entry.command,
517
+ intent: entry.intent,
518
+ })),
519
+ )}`,
520
+ `Boss message:\n${prompt}`,
521
+ ].join('\n\n');
522
+
523
+ const routerClarification = async (
524
+ context: CaptainContext,
525
+ ): Promise<void> => {
526
+ await callVisibleChat(
527
+ context,
528
+ "I'm not sure whether this should be Captain chat or a /code task. Please clarify.",
529
+ );
530
+ };
531
+
532
+ const parseRouterDecision = (
533
+ finalText: string,
534
+ ): RouterDecision | undefined => {
535
+ let parsed: unknown;
536
+ try {
537
+ parsed = JSON.parse(finalText);
538
+ } catch {
539
+ return undefined;
540
+ }
541
+ if (
542
+ typeof parsed !== 'object' ||
543
+ parsed === null ||
544
+ Array.isArray(parsed)
545
+ ) {
546
+ return undefined;
547
+ }
548
+ const record = parsed as Record<string, unknown>;
549
+ const decision = record.decision;
550
+ if (decision === 'chat') {
551
+ return typeof record.text === 'string' && record.text.trim()
552
+ ? { decision, text: record.text.trim() }
553
+ : undefined;
554
+ }
555
+ if (decision === 'dispatch') {
556
+ return typeof record.playbookId === 'string' &&
557
+ byId.has(record.playbookId) &&
558
+ typeof record.text === 'string' &&
559
+ record.text.trim()
560
+ ? {
561
+ decision,
562
+ playbookId: record.playbookId,
563
+ text: record.text.trim(),
564
+ }
565
+ : undefined;
566
+ }
567
+ if (decision === 'sub') {
568
+ return typeof record.text === 'string' && record.text.trim()
569
+ ? { decision, text: record.text.trim() }
570
+ : undefined;
571
+ }
572
+ if (decision === 'dismiss') {
573
+ return typeof record.text === 'string' && record.text.trim()
574
+ ? { decision, text: record.text.trim() }
575
+ : { decision };
576
+ }
577
+ return undefined;
578
+ };
579
+
580
+ const routeHidden = async (
581
+ turn: BossTurn,
582
+ context: CaptainContext,
583
+ ): Promise<void> => {
584
+ const result = await context.callCaptain(
585
+ hiddenRouterEnvelope(turn.prompt),
586
+ { visibility: 'hidden' },
587
+ );
588
+ if (result.status !== 'ok' || result.finalText === undefined) {
589
+ await routerClarification(context);
590
+ return;
591
+ }
592
+ const decision = parseRouterDecision(result.finalText);
593
+ if (!decision) {
594
+ await routerClarification(context);
595
+ return;
596
+ }
597
+ lastRouteDecision = decision.decision;
598
+
599
+ if (decision.decision === 'chat') {
600
+ await callVisibleChat(context, decision.text);
601
+ return;
602
+ }
603
+
604
+ if (decision.decision === 'dispatch') {
605
+ const entry = byId.get(decision.playbookId);
606
+ if (!entry || (active && active.entry.id !== entry.id)) {
607
+ await routerClarification(context);
608
+ return;
609
+ }
610
+ const engagement = await engage(entry);
611
+ await submitToActive(engagement, decision.text, context);
612
+ return;
613
+ }
614
+
615
+ if (decision.decision === 'sub') {
616
+ if (!active) {
617
+ await routerClarification(context);
618
+ return;
619
+ }
620
+ await submitToActive(active, decision.text, context);
621
+ return;
622
+ }
623
+
624
+ if (!active) {
625
+ await routerClarification(context);
626
+ return;
627
+ }
628
+
629
+ const dismissedCommandLabel = playbookCommandLabel(active.entry);
630
+ await disposeActive('dismiss');
631
+ await callVisibleChat(
632
+ context,
633
+ decision.text ?? `${dismissedCommandLabel} stopped.`,
634
+ );
635
+ };
636
+
637
+ const handleRegisteredCommand = async (
638
+ entry: PlaybookCaptainRegistryEntry,
639
+ text: string,
640
+ context: CaptainContext,
641
+ ): Promise<void> => {
642
+ if (active && active.entry.id !== entry.id) {
643
+ await callVisibleChat(
644
+ context,
645
+ `/${active.entry.command} is already running. Finish or stop it before starting /${entry.command}.`,
646
+ );
647
+ return;
648
+ }
649
+
650
+ const engagement = await engage(entry);
651
+ if (text.length === 0) {
652
+ await callVisibleChat(
653
+ context,
654
+ `Ask what task to run with /${entry.command}.`,
655
+ );
656
+ return;
657
+ }
658
+
659
+ await submitToActive(engagement, text, context);
660
+ };
661
+
662
+ return {
663
+ async init(initSession: CaptainSession): Promise<void> {
664
+ session = initSession;
665
+ players = initSession.players;
666
+ for (const entry of entries) {
667
+ entry.validateOptions(options);
668
+ }
669
+ await setMode('chat', 'init');
670
+ },
671
+
672
+ async handleBossTurn(
673
+ turn: BossTurn,
674
+ context: CaptainContext,
675
+ ): Promise<void> {
676
+ requireSession();
677
+ activeContext = context;
678
+ try {
679
+ const command = parseRegisteredCommand(turn.prompt);
680
+ if (command !== undefined) {
681
+ const entry = byCommand.get(command.command);
682
+ if (entry) {
683
+ await handleRegisteredCommand(entry, command.text, context);
684
+ return;
685
+ }
686
+ }
687
+
688
+ await routeHidden(turn, context);
689
+ } finally {
690
+ activeContext = undefined;
691
+ }
692
+ },
693
+
694
+ async dispose(): Promise<void> {
695
+ activeContext = undefined;
696
+ await disposeActive('dispose');
697
+ },
698
+ };
699
+ }
700
+
701
+ export default createPlaybookCaptainShell;