@sublang/playbook 0.1.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,598 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
+ //
4
+ // Generated by slc/link.md (FSM-to-Runtime linker).
5
+ // Source FSM: ./code.fsm.ts
6
+ // Player bind: Coder→coder, Reviewer→reviewer,
7
+ // Committer→{coder per CODE-18/19} (CODE-19 wires both
8
+ // coderPlayer and reviewerPlayer; Coder wins as the
9
+ // alias's first alternative)
10
+ // Boss event: slash-prefix (LLM-classifier fallback)
11
+ // Adjudication: LLM-judge per state
12
+ import { createActor, fromPromise } from 'xstate';
13
+ import { codingMachine, } from './code.fsm.js';
14
+ import { enumerateCaptainStates } from './code.fsm.introspect.js';
15
+ // Internal capabilities (DR-004 §10). Each ships with its final
16
+ // signature; behavior lands in the per-capability task noted by the
17
+ // TODO marker.
18
+ // Player-prompt composer — DR-004 §6.
19
+ // Substitutes the three placeholder tokens in `input.prompt` (literal
20
+ // string replace, no escaping) and prepends labelled blocks for any
21
+ // populated structured field. The FSM's prompt body is never re-flowed.
22
+ function composePlayerPrompt(input) {
23
+ const blocks = [];
24
+ if (input.intent !== undefined) {
25
+ blocks.push(`Boss intent:\n${input.intent}`);
26
+ }
27
+ if (input.reviews !== undefined) {
28
+ blocks.push(`Review items:\n${input.reviews}`);
29
+ }
30
+ if (input.challenges !== undefined) {
31
+ blocks.push(`Rebuttals:\n${input.challenges}`);
32
+ }
33
+ if (input.taskDescription !== undefined) {
34
+ blocks.push(`Task description:\n${input.taskDescription}`);
35
+ }
36
+ let body = input.prompt;
37
+ if (input.irNumber !== undefined) {
38
+ body = body.replaceAll('<#>', input.irNumber);
39
+ }
40
+ if (input.coderPlayer !== undefined) {
41
+ body = body.replaceAll('<coder-llm>', input.coderPlayer);
42
+ }
43
+ if (input.reviewerPlayer !== undefined) {
44
+ body = body.replaceAll('<reviewer-llm>', input.reviewerPlayer);
45
+ }
46
+ blocks.push(body);
47
+ return blocks.join('\n\n');
48
+ }
49
+ // Player-id resolver — DR-004 §2.
50
+ // Non-composite: Coder→'coder', Reviewer→'reviewer'. The composite
51
+ // Committer (= Coder | Reviewer per code.gears.md) resolves per
52
+ // populated <playerName>Player field on `CaptainInput`: prefer
53
+ // `coderPlayer` (CODE-18 wires only coderPlayer; CODE-19 wires both
54
+ // so coderPlayer still wins as the alias's first alternative), fall
55
+ // back to `reviewerPlayer` only if a future gear ships a
56
+ // reviewer-only Committer item, and finally to the alias's first
57
+ // alternative (Coder) when neither is set.
58
+ function resolvePlayerId(input) {
59
+ switch (input.player) {
60
+ case 'Coder':
61
+ return 'coder';
62
+ case 'Reviewer':
63
+ return 'reviewer';
64
+ case 'Committer':
65
+ if (input.coderPlayer !== undefined)
66
+ return 'coder';
67
+ if (input.reviewerPlayer !== undefined)
68
+ return 'reviewer';
69
+ return 'coder';
70
+ default: {
71
+ const exhaustive = input.player;
72
+ throw new Error(`resolvePlayerId: unknown player ${String(exhaustive)}`);
73
+ }
74
+ }
75
+ }
76
+ // LLM judge — DR-004 §4. Builds a prompt that lists each declared
77
+ // outcome verbatim, asks ports.callJudge for a JSON
78
+ // `{ guard, …payloadFields }` response, and returns the parsed
79
+ // object once the chosen guard is one of the input.result keys.
80
+ // Adjudicator failures (malformed JSON, missing/unknown guard) are
81
+ // control-plane errors and propagate via throw per slc/link.md.
82
+ async function adjudicate(input, finalText, ports, signal) {
83
+ const prompt = buildJudgePrompt(input, finalText);
84
+ const raw = await ports.callJudge(prompt, signal);
85
+ const parsed = parseJudgeJson(raw);
86
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
87
+ throw new Error('adjudicate: judge response is not a JSON object');
88
+ }
89
+ const obj = parsed;
90
+ const guard = obj.guard;
91
+ if (typeof guard !== 'string') {
92
+ throw new Error('adjudicate: judge response missing string "guard" field');
93
+ }
94
+ if (!Object.prototype.hasOwnProperty.call(input.result, guard)) {
95
+ throw new Error(`adjudicate: unknown guard "${guard}" — declared guards: ${Object.keys(input.result).join(', ')}`);
96
+ }
97
+ // Per slc/link.md, a missing payload field the state's `result`
98
+ // description requires is a control-plane error. The FSM names
99
+ // required fields with the literal phrase
100
+ // Output shall include `<fieldName>: <...>`
101
+ // so we extract those tokens and require each to be a string in
102
+ // the judge response.
103
+ for (const field of extractRequiredFields(input.result[guard])) {
104
+ if (typeof obj[field] !== 'string') {
105
+ throw new Error(`adjudicate: judge response missing required field "${field}" for guard "${guard}"`);
106
+ }
107
+ }
108
+ return obj;
109
+ }
110
+ function extractRequiredFields(description) {
111
+ const fields = [];
112
+ const re = /Output shall include `([A-Za-z_][A-Za-z0-9_]*):/g;
113
+ for (const m of description.matchAll(re)) {
114
+ fields.push(m[1]);
115
+ }
116
+ return fields;
117
+ }
118
+ function buildJudgePrompt(input, finalText) {
119
+ const lines = [];
120
+ lines.push(`The ${input.player} just produced this output:`);
121
+ lines.push('');
122
+ lines.push('```');
123
+ lines.push(finalText);
124
+ lines.push('```');
125
+ lines.push('');
126
+ lines.push('Pick exactly one outcome by `guard` and return JSON ' +
127
+ '`{ guard, …payloadFields }`. Required payload fields are ' +
128
+ 'named in the outcome description.');
129
+ lines.push('');
130
+ for (const [key, description] of Object.entries(input.result)) {
131
+ lines.push(`- \`${key}\` — ${description}`);
132
+ }
133
+ return lines.join('\n');
134
+ }
135
+ function parseJudgeJson(raw) {
136
+ let text = raw.trim();
137
+ const fence = text.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/);
138
+ if (fence)
139
+ text = fence[1].trim();
140
+ try {
141
+ return JSON.parse(text);
142
+ }
143
+ catch (e) {
144
+ throw new Error(`adjudicate: judge response is not valid JSON: ${e.message}`);
145
+ }
146
+ }
147
+ // Boss-event classifier — DR-004 §3.
148
+ // Slash forms tried first; unknown slash forms surface as
149
+ // emitStatus and return undefined per slc/link.md ("Unknown
150
+ // commands surface as emitStatus, not as a silently-dropped
151
+ // turn"). Anything that isn't a slash form falls through to the
152
+ // LLM classifier via ports.callJudge with a fixed prompt that
153
+ // names the four event types and their payload shapes.
154
+ async function classifyBossText(text, ports, signal) {
155
+ const trimmed = text.trim();
156
+ if (trimmed === '')
157
+ return undefined;
158
+ if (trimmed === '/start' || trimmed.startsWith('/start ')) {
159
+ return { type: 'START_CODING', intent: trimmed.slice('/start'.length).trim() };
160
+ }
161
+ if (trimmed === '/continue' || trimmed.startsWith('/continue ')) {
162
+ return {
163
+ type: 'CONTINUE_IR',
164
+ irNumber: trimmed.slice('/continue'.length).trim(),
165
+ };
166
+ }
167
+ if (trimmed === '/summarize' || trimmed.startsWith('/summarize ')) {
168
+ return {
169
+ type: 'SUMMARIZE_IR',
170
+ irNumber: trimmed.slice('/summarize'.length).trim(),
171
+ };
172
+ }
173
+ if (trimmed === '/interrupt' || trimmed.startsWith('/interrupt ')) {
174
+ return parseInterruptSlash(trimmed, ports);
175
+ }
176
+ if (trimmed.startsWith('/')) {
177
+ const cmd = trimmed.split(/\s+/)[0];
178
+ await ports.emitStatus(`Unknown slash command: ${cmd}`);
179
+ return undefined;
180
+ }
181
+ return classifyWithLlm(trimmed, ports, signal);
182
+ }
183
+ async function parseInterruptSlash(trimmed, ports) {
184
+ const rest = trimmed.slice('/interrupt'.length).trim();
185
+ if (rest === '') {
186
+ await ports.emitStatus('/interrupt requires a stateId');
187
+ return undefined;
188
+ }
189
+ const firstSpace = rest.search(/\s/);
190
+ const targetId = firstSpace === -1 ? rest : rest.slice(0, firstSpace);
191
+ const intent = firstSpace === -1 ? '' : rest.slice(firstSpace).trim();
192
+ return {
193
+ type: 'BOSS_INTERRUPT',
194
+ targetId: targetId,
195
+ ...(intent ? { intent } : {}),
196
+ };
197
+ }
198
+ async function classifyWithLlm(text, ports, signal) {
199
+ const prompt = buildClassifierPrompt(text);
200
+ const raw = await ports.callJudge(prompt, signal);
201
+ const parsed = parseJudgeJson(raw);
202
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
203
+ await ports.emitStatus('Classifier returned a non-object JSON response');
204
+ return undefined;
205
+ }
206
+ const obj = parsed;
207
+ const eventType = obj.event;
208
+ if (typeof eventType !== 'string') {
209
+ await ports.emitStatus('Classifier did not name an event type');
210
+ return undefined;
211
+ }
212
+ const payload = typeof obj.payload === 'object' &&
213
+ obj.payload !== null &&
214
+ !Array.isArray(obj.payload)
215
+ ? obj.payload
216
+ : {};
217
+ switch (eventType) {
218
+ case 'START_CODING': {
219
+ if (typeof payload.intent !== 'string') {
220
+ await ports.emitStatus('Classifier omitted intent for START_CODING');
221
+ return undefined;
222
+ }
223
+ return { type: 'START_CODING', intent: payload.intent };
224
+ }
225
+ case 'CONTINUE_IR': {
226
+ if (typeof payload.irNumber !== 'string') {
227
+ await ports.emitStatus('Classifier omitted irNumber for CONTINUE_IR');
228
+ return undefined;
229
+ }
230
+ return { type: 'CONTINUE_IR', irNumber: payload.irNumber };
231
+ }
232
+ case 'SUMMARIZE_IR': {
233
+ if (typeof payload.irNumber !== 'string') {
234
+ await ports.emitStatus('Classifier omitted irNumber for SUMMARIZE_IR');
235
+ return undefined;
236
+ }
237
+ return { type: 'SUMMARIZE_IR', irNumber: payload.irNumber };
238
+ }
239
+ case 'BOSS_INTERRUPT': {
240
+ if (typeof payload.targetId !== 'string') {
241
+ await ports.emitStatus('Classifier omitted targetId for BOSS_INTERRUPT');
242
+ return undefined;
243
+ }
244
+ return {
245
+ type: 'BOSS_INTERRUPT',
246
+ targetId: payload.targetId,
247
+ ...(typeof payload.intent === 'string'
248
+ ? { intent: payload.intent }
249
+ : {}),
250
+ ...(typeof payload.irNumber === 'string'
251
+ ? { irNumber: payload.irNumber }
252
+ : {}),
253
+ };
254
+ }
255
+ default:
256
+ await ports.emitStatus(`Classifier returned unknown event type: ${eventType}`);
257
+ return undefined;
258
+ }
259
+ }
260
+ function buildClassifierPrompt(text) {
261
+ return [
262
+ 'Classify the following Boss message into exactly one of these events.',
263
+ 'Respond with JSON: { "event": "<TYPE>", "payload": { ...fields } }.',
264
+ '',
265
+ 'Events:',
266
+ '- START_CODING: payload { intent: "<free-form goal>" }',
267
+ '- CONTINUE_IR: payload { irNumber: "<number>" }',
268
+ '- SUMMARIZE_IR: payload { irNumber: "<number>" }',
269
+ '- BOSS_INTERRUPT: payload { targetId: "<stateId>", intent?: "<free-form goal>", irNumber?: "<number>" }',
270
+ '',
271
+ 'Boss message:',
272
+ '```',
273
+ text,
274
+ '```',
275
+ ].join('\n');
276
+ }
277
+ // Captain-actor bridge — DR-004 §7. One PromiseActorLogic that the
278
+ // codingMachine invokes from every captain-invoking state. Per turn:
279
+ // resolve playerId, compose the player prompt, await
280
+ // ports.callPlayer, adjudicate the finalText. PlayerResult status of
281
+ // 'aborted' or 'error' throws so XState routes via onError → #failed
282
+ // (the single fail-stop sink for both Captain errors and player
283
+ // failures).
284
+ //
285
+ // `getActiveSignal` is the runtime's hook for flowing the Boss's
286
+ // `handleBossInput.signal` into the host port calls — fromPromise
287
+ // hands the bridge XState's actor-scoped signal, which only fires
288
+ // on actor.stop(), not on Boss abort. When omitted (e.g. direct
289
+ // captainBridge tests), the bridge falls back to XState's signal.
290
+ function captainBridge(ports, getActiveSignal) {
291
+ return fromPromise(async ({ input, signal }) => {
292
+ const activeSignal = getActiveSignal?.() ?? signal;
293
+ const playerId = resolvePlayerId(input);
294
+ const prompt = composePlayerPrompt(input);
295
+ const result = await ports.callPlayer(playerId, prompt, activeSignal);
296
+ if (result.status !== 'ok') {
297
+ throw new Error(result.error ??
298
+ `captainBridge: callPlayer status "${result.status}"`);
299
+ }
300
+ if (result.finalText === undefined) {
301
+ throw new Error('captainBridge: callPlayer returned status=ok with no finalText');
302
+ }
303
+ return adjudicate(input, result.finalText, ports, activeSignal);
304
+ });
305
+ }
306
+ // Captain pane display — PBRT-3 / PBRT-14.
307
+ // The Captain pane is a stream keyed on four glyphs so a reader can
308
+ // parse each line at a glance:
309
+ // ◆ terminal entry (ready / done / failed)
310
+ // ▸ Boss input echo
311
+ // ⮕ captain-invoking state entry (label + player + CODE-N)
312
+ // ⤷ transition (guard fired by the just-finished captain call)
313
+ // Prompts and full player output ride the player panes; the Captain
314
+ // pane keeps to the state-machine shape.
315
+ const STATE_LABELS = {
316
+ planAndImplement: 'plan & implement',
317
+ respondToReview: 'respond to review',
318
+ continueIr: 'continue IR task',
319
+ summarizeSpecs: 'summarize IR into specs',
320
+ reviewBossCommitSpecs: 'review Boss-intent commit (specs only)',
321
+ reviewBossCommitCode: 'review Boss-intent commit (code only)',
322
+ reviewBossCommitMixed: 'review Boss-intent commit (specs + code)',
323
+ reviewIrTaskCommitSpecs: 'review IR-task commit (specs only)',
324
+ reviewIrTaskCommitCode: 'review IR-task commit (code only)',
325
+ reviewIrTaskCommitMixed: 'review IR-task commit (specs + code)',
326
+ reviewChangesSpecs: 'review uncommitted edits (specs only)',
327
+ reviewChangesCode: 'review uncommitted edits (code only)',
328
+ reviewChangesMixed: 'review uncommitted edits (specs + code)',
329
+ reviewChangesAndChallengesSpecs: 'review uncommitted edits + rebuttals (specs only)',
330
+ reviewChangesAndChallengesCode: 'review uncommitted edits + rebuttals (code only)',
331
+ reviewChangesAndChallengesMixed: 'review uncommitted edits + rebuttals (specs + code)',
332
+ adjudicateChallenges: 'adjudicate rebuttals',
333
+ commitCoderInitial: "commit Coder's initial changes",
334
+ commitJoint: 'commit reviewed changes',
335
+ };
336
+ const stateMetadata = (() => {
337
+ const m = new Map();
338
+ for (const s of enumerateCaptainStates(codingMachine)) {
339
+ const label = STATE_LABELS[s.stateId];
340
+ if (!label) {
341
+ throw new Error(`code.playbook.ts: STATE_LABELS missing entry for captain-invoking state '${s.stateId}'`);
342
+ }
343
+ const input = s.getInput({});
344
+ m.set(s.stateId, { player: input.player, sourceItem: s.sourceItem, label });
345
+ }
346
+ return m;
347
+ })();
348
+ const TERMINAL_STATES = new Set([
349
+ 'ready',
350
+ 'done',
351
+ 'failed',
352
+ ]);
353
+ // Captain-pane surface (PBRT-3): every captain-invoking state plus
354
+ // the three terminal/idle states. Wider than the prior
355
+ // "Boss-relevant" set per slc/link.md's default "emit on every
356
+ // transition; let the host filter."
357
+ const CAPTAIN_PANE_STATES = new Set([
358
+ ...stateMetadata.keys(),
359
+ ...TERMINAL_STATES,
360
+ ]);
361
+ function formatStateEntry(stateId) {
362
+ if (TERMINAL_STATES.has(stateId))
363
+ return `◆ ${stateId}`;
364
+ const meta = stateMetadata.get(stateId);
365
+ if (!meta)
366
+ return `⮕ ${stateId}`;
367
+ return `⮕ ${meta.label} ${meta.player} per ${meta.sourceItem}`;
368
+ }
369
+ function formatTransition(event) {
370
+ const output = event?.output;
371
+ if (!output || typeof output.guard !== 'string')
372
+ return undefined;
373
+ const tallies = [];
374
+ for (const field of ['reviews', 'challenges']) {
375
+ const value = output[field];
376
+ if (typeof value === 'string' && value.trim().length > 0) {
377
+ const items = value.match(/^\s*\d+\.\s/gm);
378
+ tallies.push(`${field}=${items ? items.length : 1}`);
379
+ }
380
+ }
381
+ const suffix = tallies.length > 0 ? ` ${tallies.join(' ')}` : '';
382
+ return `⤷ ${output.guard}${suffix}`;
383
+ }
384
+ function formatBossEcho(text, eventType) {
385
+ return eventType !== undefined
386
+ ? `▸ BOSS ${text} → ${eventType}`
387
+ : `▸ BOSS ${text}`;
388
+ }
389
+ // Rider fields PBRT-14 names — surfaced inline on a state entry
390
+ // whenever the FSM context populates them, regardless of which
391
+ // captain-invoking state is being entered. Routing-only context
392
+ // (reviewSubject, afterReview, etc.) stays out of the pane and is
393
+ // visible via emitTelemetry instead.
394
+ const RIDER_FIELDS = ['intent', 'irNumber', 'taskDescription'];
395
+ function formatRiders(context) {
396
+ const parts = [];
397
+ for (const f of RIDER_FIELDS) {
398
+ const v = context[f];
399
+ if (typeof v === 'string' && v.length > 0) {
400
+ parts.push(`${f}=${JSON.stringify(v)}`);
401
+ }
402
+ }
403
+ return parts.length > 0 ? ` ${parts.join(' ')}` : '';
404
+ }
405
+ // Internal export surface for tests. Not part of the stable public API;
406
+ // the leading underscore signals "subject to change." Each member is
407
+ // referenced here so `noUnusedLocals` stays clean while later tasks
408
+ // wire the factory body to use them.
409
+ export const _internal = {
410
+ composePlayerPrompt,
411
+ resolvePlayerId,
412
+ adjudicate,
413
+ classifyBossText,
414
+ captainBridge,
415
+ STATE_LABELS,
416
+ stateMetadata,
417
+ formatStateEntry,
418
+ formatTransition,
419
+ formatBossEcho,
420
+ formatRiders,
421
+ };
422
+ export default function createPlaybookRuntime(options) {
423
+ let actor;
424
+ let savedPorts;
425
+ // The Boss's per-turn AbortSignal, surfaced to captainBridge so
426
+ // ports.callPlayer / callJudge see the right cancellation source.
427
+ // null between turns; set by handleBossInput.
428
+ let activeSignal;
429
+ // Previous root-machine state for the inspect-driven telemetry /
430
+ // status emitter. undefined before the first inspect firing.
431
+ let priorState;
432
+ // Emission queue. slc/link.md says emissions "shall be ordered,
433
+ // awaited, and never-dropped"; subscribe/inspect callbacks are
434
+ // synchronous and can't await, so each emit is enqueued and a
435
+ // single drainer processes them sequentially.
436
+ const emitQueue = [];
437
+ let drainer;
438
+ function enqueueEmit(fn) {
439
+ emitQueue.push(fn);
440
+ if (!drainer) {
441
+ drainer = (async () => {
442
+ while (emitQueue.length > 0) {
443
+ try {
444
+ await emitQueue.shift()();
445
+ }
446
+ catch {
447
+ // Suppress host-side emission errors; the control plane
448
+ // surfaces real failures via handleBossInput throws.
449
+ }
450
+ }
451
+ drainer = undefined;
452
+ })();
453
+ }
454
+ }
455
+ function drainEmissions() {
456
+ return drainer ?? Promise.resolve();
457
+ }
458
+ function buildActor(ports) {
459
+ priorState = undefined;
460
+ return createActor(codingMachine.provide({
461
+ actors: { captain: captainBridge(ports, () => activeSignal) },
462
+ }), {
463
+ input: options,
464
+ inspect: (inspectionEvent) => {
465
+ if (inspectionEvent.type !== '@xstate.snapshot')
466
+ return;
467
+ const snap = inspectionEvent.snapshot;
468
+ // Filter out captain sub-actor (fromPromise) snapshots —
469
+ // only the root codingMachine snapshot has a string value.
470
+ if (typeof snap.value !== 'string')
471
+ return;
472
+ const to = snap.value;
473
+ if (priorState === to)
474
+ return;
475
+ const from = priorState;
476
+ priorState = to;
477
+ // Telemetry on every transition (PBRT-14).
478
+ enqueueEmit(() => ports.emitTelemetry({
479
+ topic: 'playbook.fsm.state',
480
+ payload: { from, to, event: inspectionEvent.event },
481
+ }));
482
+ // Captain pane (PBRT-3 / PBRT-14): show the transition
483
+ // guard first (when this is an actor-done transition with
484
+ // a known guard), then the new state entry, then any
485
+ // context riders the entering state cares about. Terminal
486
+ // entry to `failed` carries `lastError` as the data arg.
487
+ if (!CAPTAIN_PANE_STATES.has(to))
488
+ return;
489
+ const transitionLine = formatTransition(inspectionEvent.event);
490
+ if (transitionLine !== undefined) {
491
+ enqueueEmit(() => ports.emitStatus(transitionLine));
492
+ }
493
+ const entryLine = formatStateEntry(to);
494
+ const riderSuffix = stateMetadata.has(to)
495
+ ? formatRiders(snap.context ?? {})
496
+ : '';
497
+ const message = entryLine + riderSuffix;
498
+ if (to === 'failed') {
499
+ const lastError = snap.context
500
+ ?.lastError;
501
+ enqueueEmit(() => ports.emitStatus(message, { lastError }));
502
+ }
503
+ else {
504
+ enqueueEmit(() => ports.emitStatus(message));
505
+ }
506
+ },
507
+ });
508
+ }
509
+ const runtime = {
510
+ async init(ports) {
511
+ savedPorts = ports;
512
+ actor = buildActor(ports);
513
+ actor.start();
514
+ await drainEmissions();
515
+ },
516
+ async handleBossInput({ text, signal, }) {
517
+ if (!actor || !savedPorts) {
518
+ throw new Error('createPlaybookRuntime.handleBossInput: init must be called first');
519
+ }
520
+ activeSignal = signal;
521
+ try {
522
+ // 1. Classify text into an FSM event (slash → event; else LLM).
523
+ const event = await classifyBossText(text, savedPorts, signal);
524
+ // Unknown slash or empty input — classifier already surfaced
525
+ // status; nothing to send.
526
+ if (event === undefined) {
527
+ await drainEmissions();
528
+ return;
529
+ }
530
+ // 2. Captain-pane Boss-input echo (PBRT-14): the verbatim
531
+ // Boss text and the FSM event it classified to, before
532
+ // the FSM advances. Enqueued so it interleaves cleanly
533
+ // with the inspect-driven transition emissions.
534
+ const echoPorts = savedPorts;
535
+ enqueueEmit(() => echoPorts.emitStatus(formatBossEcho(text, event.type)));
536
+ // 3. final state ('done') cannot accept new events — dispose
537
+ // and reconstruct per DR-004 §5.
538
+ if (actor.getSnapshot().status === 'done') {
539
+ actor.stop();
540
+ actor = buildActor(savedPorts);
541
+ actor.start();
542
+ }
543
+ // 4. Send the event.
544
+ actor.send(event);
545
+ // 5. Drive to quiescence. On signal-abort we take no FSM
546
+ // action: the captain bridge's awaited callPlayer rejects
547
+ // naturally, the bridge throws, XState routes through
548
+ // onError → #failed, and this loop sees the quiescent
549
+ // snapshot and returns (DR-004 §8 natural rejection).
550
+ await driveToQuiescence(actor);
551
+ // Drain transition emissions before returning so the Boss
552
+ // sees the final status line for this turn.
553
+ await drainEmissions();
554
+ }
555
+ finally {
556
+ activeSignal = undefined;
557
+ }
558
+ },
559
+ async dispose() {
560
+ if (actor) {
561
+ actor.stop();
562
+ actor = undefined;
563
+ }
564
+ // Drain any in-flight emissions per slc/link.md §Session
565
+ // lifecycle ("stop the actor and drain pending port emissions").
566
+ await drainEmissions();
567
+ savedPorts = undefined;
568
+ },
569
+ // @internal — test-only escape hatch for inspecting the
570
+ // underlying actor's snapshot. Most state assertions are now
571
+ // expressible via the recorded emitStatus / emitTelemetry
572
+ // calls (DR-004 §9); the hatch stays for the few cases where
573
+ // direct context inspection is clearer (e.g., the dispose
574
+ // teardown test).
575
+ _getActor() {
576
+ return actor;
577
+ },
578
+ };
579
+ return runtime;
580
+ }
581
+ function driveToQuiescence(actor) {
582
+ return new Promise((resolve) => {
583
+ if (isQuiescent(actor.getSnapshot())) {
584
+ resolve();
585
+ return;
586
+ }
587
+ const sub = actor.subscribe((snap) => {
588
+ if (isQuiescent(snap)) {
589
+ sub.unsubscribe();
590
+ resolve();
591
+ }
592
+ });
593
+ });
594
+ }
595
+ function isQuiescent(snap) {
596
+ const v = snap.value;
597
+ return v === 'ready' || v === 'failed' || v === 'done';
598
+ }