@sublang/playbook 1.0.0 → 2.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.
@@ -0,0 +1,2849 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
+ //
4
+ // Generic linked-playbook runtime factory (DR-019). The FSM-interpreter
5
+ // machinery that slc/link.md previously regenerated inside every linked
6
+ // `<name>.playbook.ts` artifact — actor wiring, boundary tracing, judge
7
+ // classification/adjudication, script execution, nested-playbook bridging,
8
+ // Boss-reply suspension, snapshot/restore, and disposal — lives here once.
9
+ // A linked artifact supplies only its per-workflow `spec` (options
10
+ // validation and any strategy overrides) and its own FSM; the factory
11
+ // interprets the FSM data the artifact already carries.
12
+ //
13
+ // The machinery is hoisted from the reference CODE artifact
14
+ // (reference/sdlc/code.playbook/code.playbook.ts) verbatim where possible;
15
+ // its behavior tests are the equivalence proof. Do not change observable
16
+ // behavior here without consulting those suites.
17
+
18
+ import { spawn } from 'node:child_process';
19
+ import PQueue from 'p-queue';
20
+ import { createActor, fromPromise } from 'xstate';
21
+ import type {
22
+ AnyStateMachine,
23
+ EventObject,
24
+ InspectionEvent,
25
+ PromiseActorLogic,
26
+ } from 'xstate';
27
+ import {
28
+ assertPlaybookRuntimeSnapshot,
29
+ combineAbortSignals,
30
+ createNestedPlaybookBridge,
31
+ detachPersistedMachineSnapshot,
32
+ normalizeError,
33
+ normalizePlaybookSnapshot,
34
+ snapshotJsonValue,
35
+ snapshotPlaybookSession,
36
+ validateCaptainResult,
37
+ validatePlayerResult,
38
+ waitForPlaybookQuiescence,
39
+ } from './xstate-runtime.js';
40
+ import type {
41
+ CaptainResult,
42
+ JsonValue,
43
+ PlaybookCallResult,
44
+ PlaybookPorts,
45
+ PlaybookRunResult,
46
+ PlaybookRuntime,
47
+ PlaybookRuntimeFactory,
48
+ PlaybookRuntimeSnapshot,
49
+ PlaybookSession,
50
+ PlaybookState,
51
+ PlaybookTraceEvent,
52
+ PlaybookTraceType,
53
+ PlayerResult,
54
+ } from './runtime.js';
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Structural actor-input contracts. FSM artifacts declare richer types; the
58
+ // factory needs only these fields, so any gears2fsm-produced input type is
59
+ // assignable by width subtyping.
60
+ // ---------------------------------------------------------------------------
61
+
62
+ export interface PlaybookPendingBossQuestionContext {
63
+ questionId: string;
64
+ resumeStateId: string;
65
+ sourceItem: string;
66
+ player: string;
67
+ question: string;
68
+ }
69
+
70
+ export interface PlaybookPlayerInput {
71
+ stateId: string;
72
+ player: string;
73
+ sourceItem: string;
74
+ prompt: string;
75
+ result: Readonly<Record<string, string>>;
76
+ pendingBossQuestion?: { readonly question: string };
77
+ bossReply?: string;
78
+ }
79
+
80
+ export interface PlaybookCaptainInput {
81
+ stateId: string;
82
+ sourceItem: string;
83
+ prompt: string;
84
+ result: Readonly<Record<string, string>>;
85
+ allowedTools?: readonly string[];
86
+ pendingBossQuestion?: { readonly question: string };
87
+ bossReply?: string;
88
+ }
89
+
90
+ export interface PlaybookScriptInput {
91
+ stateId: string;
92
+ sourceItem: string;
93
+ command: string;
94
+ result: Readonly<Record<string, string>>;
95
+ }
96
+
97
+ /** Adjudicated actor output: the selected guard plus payload fields. */
98
+ export type PlaybookActorOutput = Record<string, unknown> & { guard: string };
99
+
100
+ export type JudgePurpose =
101
+ | 'boss-input-classification'
102
+ | 'player-output-adjudication'
103
+ | 'captain-output-adjudication';
104
+
105
+ /**
106
+ * Traced runtime boundary used by the provided actors. The factory's runtime
107
+ * implements it; standalone helpers accept it optionally so verification can
108
+ * exercise composition/adjudication without a live runtime.
109
+ */
110
+ export interface RuntimeBoundaryCalls {
111
+ callPlayer(
112
+ input: PlaybookPlayerInput,
113
+ playerId: string,
114
+ prompt: string,
115
+ signal: AbortSignal,
116
+ ): Promise<PlayerResult>;
117
+ callJudge(
118
+ purpose: JudgePurpose,
119
+ stateId: string | undefined,
120
+ prompt: string,
121
+ signal: AbortSignal,
122
+ ): Promise<string>;
123
+ callCaptain?(
124
+ input: PlaybookCaptainInput,
125
+ prompt: string,
126
+ signal: AbortSignal,
127
+ ): Promise<CaptainResult>;
128
+ }
129
+
130
+ export interface ScheduledStatus {
131
+ message: string;
132
+ data?: JsonValue;
133
+ }
134
+
135
+ export interface XStateBossEventFieldSpec {
136
+ /** The judge supplies routing data; the runtime supplies exact Boss text. */
137
+ source: 'judge' | 'text';
138
+ /** Judge-authored fields are optional unless explicitly required. */
139
+ required?: boolean;
140
+ /** Optional closed set for a string-valued judge field. */
141
+ values?: readonly string[];
142
+ }
143
+
144
+ export interface XStateBossEventSpec {
145
+ type: string;
146
+ fields?: Readonly<Record<string, XStateBossEventFieldSpec>>;
147
+ }
148
+
149
+ export const BOSS_REPLY_ERRORS = {
150
+ missingQuestion: "needsBossReply outcome missing 'question' field",
151
+ unregisteredState: (stateId: string) =>
152
+ `state ${stateId} declared needsBossReply but is not registered as resumable`,
153
+ } as const;
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // A host agent result that is not `ok` (or is `ok` with no final text) is a
157
+ // recoverable FSM failure, not a control-plane error: it travels the invoked
158
+ // actor's XState error path to the failure state and the public boundary
159
+ // resolves `failed` (PBRT-47, matching the player boundary's PBRT-9). The
160
+ // direct-Captain boundary has to emit its paired finish trace before
161
+ // rethrowing, so it needs to tell that failure apart from the control-plane
162
+ // errors it does latch — a thrown port, a malformed result, a rejecting sink.
163
+ // ---------------------------------------------------------------------------
164
+
165
+ const fsmResultFailures = new WeakSet<object>();
166
+
167
+ function markFsmResultFailure(error: Error): Error {
168
+ fsmResultFailures.add(error);
169
+ return error;
170
+ }
171
+
172
+ function isFsmResultFailure(error: unknown): boolean {
173
+ return (
174
+ typeof error === 'object' &&
175
+ error !== null &&
176
+ fsmResultFailures.has(error as object)
177
+ );
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // The per-workflow spec. Every strategy member has a generic default derived
182
+ // from the FSM artifact's own data, so a linker-emitted thin module normally
183
+ // supplies only `snapshotOptions` and, where applicable, `entryEvent`, erased
184
+ // Boss-event field metadata, placeholder exceptions, and transition-event
185
+ // fields. Hand-maintained artifacts may override any member to preserve their
186
+ // existing observable behavior exactly.
187
+ // ---------------------------------------------------------------------------
188
+
189
+ export interface XStatePlaybookRuntimeSpec<TOptions> {
190
+ /** Diagnostic label used in internal invariant errors. Default 'playbook'. */
191
+ label?: string;
192
+ /** Validate and JSON-snapshot the caller's per-run options. */
193
+ snapshotOptions: (value: unknown) => TOptions;
194
+ /** Derive the FSM machine input from validated options. Default: identity. */
195
+ machineInput?: (options: TOptions, session: PlaybookSession) => unknown;
196
+ /**
197
+ * Deterministic textual entry event (slc/link.md §Boss-event mapping):
198
+ * where the ready or reconstructed terminal machine accepts exactly one
199
+ * ordinary textual entry event, send it without a judge call, carrying the
200
+ * exact Boss text in `textField`. Absent: every non-empty turn classifies.
201
+ */
202
+ entryEvent?: { type: string; textField: string };
203
+ /**
204
+ * Exact flat Boss-event contracts whose non-text fields the judge may
205
+ * select. `entryEvent` and scalar `BOSS_REPLY` contracts are supplied by
206
+ * the factory; linkers emit entries here for additional typed events such
207
+ * as `BOSS_INTERRUPT` when their erased payload cannot be recovered from
208
+ * the XState machine alone.
209
+ */
210
+ bossEvents?: readonly XStateBossEventSpec[];
211
+ /** Boss-input classifier override; default: generic parked-state classifier. */
212
+ classifyBossText?: (
213
+ text: string,
214
+ ports: PlaybookPorts,
215
+ signal: AbortSignal,
216
+ snapshotOrState: unknown,
217
+ boundary?: RuntimeBoundaryCalls,
218
+ ) => Promise<EventObject | undefined>;
219
+ /** Status line emitted after classification names an event. Default: none. */
220
+ classificationStatus?: (event: EventObject) => string | undefined;
221
+ /** Map a player-invoking state's input to the host player id. Default: lowercased player name. */
222
+ resolvePlayerId?: (input: PlaybookPlayerInput, options: TOptions) => string;
223
+ /** Compose the player prompt. Default: continuation blocks + `<field>` placeholder substitution. */
224
+ composePlayerPrompt?: (input: PlaybookPlayerInput) => string;
225
+ /** Compose the direct-Captain prompt. Default: continuation blocks + placeholder substitution with deterministic JSON rendering. */
226
+ composeCaptainPrompt?: (input: PlaybookCaptainInput) => string;
227
+ /** Linker-known exceptions to the default kebab-token → camel-field mapping. */
228
+ placeholderFields?: Readonly<Record<string, string>>;
229
+ /** Adjudicator prompt for delegated players. Default: generic guard menu. */
230
+ buildJudgePrompt?: (input: PlaybookPlayerInput, finalText: string) => string;
231
+ /** Required-payload-field extraction from a `result` description. Default: bilingual `Output shall include` clause scan. */
232
+ extractRequiredFields?: (description: string) => string[];
233
+ /** Required fields carried verbatim from the player's finalText instead of judge JSON. Default: none. */
234
+ verbatimPayloadFields?: ReadonlySet<string>;
235
+ /** States that may suspend for a Boss reply. Default: targets of the FSM's `awaitBossReply` BOSS_REPLY transitions. */
236
+ resumableStateIds?: ReadonlySet<string>;
237
+ /** Human status lines for a root transition. Default: entry lines with question/failure surfacing. */
238
+ statusesForState?: (
239
+ state: PlaybookState,
240
+ context: Record<string, unknown>,
241
+ event: unknown,
242
+ ) => readonly ScheduledStatus[];
243
+ /** Detached JSON-safe transition-event descriptor. Default: `type` + `transitionEventFields` strings + validated output + normalized error. */
244
+ normalizeTransitionEvent?: (event: unknown) => JsonValue | undefined;
245
+ /** String payload fields the default transition-event descriptor copies. */
246
+ transitionEventFields?: readonly string[];
247
+ /** Working directory for `script` actors. Default: the validated options' string `cwd`, else the process working directory. */
248
+ scriptCwd?: (options: TOptions) => string | undefined;
249
+ }
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // Tolerant judge-JSON recovery (slc/link.md §Boss-event mapping).
253
+ // ---------------------------------------------------------------------------
254
+
255
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
256
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
257
+ }
258
+
259
+ /** Strip a single Markdown code fence that wraps the whole string. */
260
+ export function stripCodeFence(text: string): string {
261
+ const fence = text.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/);
262
+ return fence ? fence[1].trim() : text;
263
+ }
264
+
265
+ function dropTrailingComma(out: string): string {
266
+ return out.replace(/,(\s*)$/, '$1');
267
+ }
268
+
269
+ // Scan from `start` (a `{`/`[` index), tracking string and bracket-nesting
270
+ // state, and emit the balanced JSON value rooted there. With `repair` false
271
+ // the span is returned only if it actually closes; with `repair` true a
272
+ // trailing comma, an unterminated string, and unclosed brackets are fixed.
273
+ export function extractJsonValue(
274
+ text: string,
275
+ start: number,
276
+ repair: boolean,
277
+ ): string | undefined {
278
+ const stack: string[] = [];
279
+ let out = '';
280
+ let inString = false;
281
+ let escaped = false;
282
+ for (let i = start; i < text.length; i++) {
283
+ const ch = text[i];
284
+ if (inString) {
285
+ out += ch;
286
+ if (escaped) escaped = false;
287
+ else if (ch === '\\') escaped = true;
288
+ else if (ch === '"') inString = false;
289
+ continue;
290
+ }
291
+ if (ch === '"') {
292
+ inString = true;
293
+ out += ch;
294
+ continue;
295
+ }
296
+ if (ch === '{' || ch === '[') {
297
+ stack.push(ch === '{' ? '}' : ']');
298
+ out += ch;
299
+ continue;
300
+ }
301
+ if (ch === '}' || ch === ']') {
302
+ if (repair) out = dropTrailingComma(out);
303
+ out += ch;
304
+ stack.pop();
305
+ if (stack.length === 0) return out; // top-level value complete
306
+ continue;
307
+ }
308
+ out += ch;
309
+ }
310
+ // End of input before the top-level value closed.
311
+ if (!repair) return undefined; // strict pass: no balanced span here
312
+ if (inString) out += '"';
313
+ out = dropTrailingComma(out);
314
+ while (stack.length > 0) out += stack.pop();
315
+ return out;
316
+ }
317
+
318
+ // Tolerant recovery shared by the classifier and adjudicator: prefer a strict
319
+ // balanced span at the earliest opening brace, then its repair, before
320
+ // advancing to a later candidate. The first plain object wins; the first
321
+ // value of any shape is remembered so a legitimately array/scalar reply still
322
+ // surfaces to the caller's own object check.
323
+ export function parseJudgeJson(raw: string): unknown {
324
+ const fenced = stripCodeFence(raw.trim());
325
+ // Fast path: a well-formed (optionally fenced) JSON body.
326
+ try {
327
+ return JSON.parse(fenced);
328
+ } catch {
329
+ // Fall through to lenient extraction + repair.
330
+ }
331
+ const starts: number[] = [];
332
+ for (let i = 0; i < fenced.length; i++) {
333
+ const ch = fenced[i];
334
+ if (ch === '{' || ch === '[') starts.push(i);
335
+ }
336
+ let firstValue: { value: unknown } | undefined;
337
+ for (const start of starts) {
338
+ let parsedHere: { value: unknown } | undefined;
339
+ for (const repair of [false, true]) {
340
+ const candidate = extractJsonValue(fenced, start, repair);
341
+ if (candidate === undefined) continue;
342
+ try {
343
+ parsedHere = { value: JSON.parse(candidate) };
344
+ } catch {
345
+ continue; // not parseable this way — try repair, then next start
346
+ }
347
+ break; // prefer the strict span at this start over its repair
348
+ }
349
+ if (parsedHere === undefined) continue;
350
+ if (isPlainObject(parsedHere.value)) return parsedHere.value;
351
+ if (firstValue === undefined) firstValue = parsedHere;
352
+ }
353
+ if (firstValue !== undefined) return firstValue.value;
354
+ throw new Error('adjudicate: judge response is not valid JSON');
355
+ }
356
+
357
+ // ---------------------------------------------------------------------------
358
+ // Shared error/context helpers.
359
+ // ---------------------------------------------------------------------------
360
+
361
+ export function normalizeErrorCompact(
362
+ err: unknown,
363
+ ): { name: string; message: string } | undefined {
364
+ if (err === undefined || err === null) return undefined;
365
+ const normalized = normalizeError(err);
366
+ return { name: normalized.name, message: normalized.message };
367
+ }
368
+
369
+ export function normalizeErrorFull(
370
+ err: unknown,
371
+ ): { name: string; message: string; stack?: string } | undefined {
372
+ if (err === undefined || err === null) return undefined;
373
+ return normalizeError(err);
374
+ }
375
+
376
+ function isAbortFailure(error: unknown, signal: AbortSignal): boolean {
377
+ return (
378
+ signal.aborted &&
379
+ (error === signal.reason || normalizeError(error).name === 'AbortError')
380
+ );
381
+ }
382
+
383
+ /** Read the FSM context's single pending Boss question, when well-formed. */
384
+ export function pendingBossQuestionFromContext(
385
+ context: Record<string, unknown>,
386
+ ): PlaybookPendingBossQuestionContext | undefined {
387
+ const pending = context.pendingBossQuestion;
388
+ if (
389
+ pending === undefined ||
390
+ pending === null ||
391
+ typeof pending !== 'object'
392
+ ) {
393
+ return undefined;
394
+ }
395
+ const candidate = pending as Partial<
396
+ Record<keyof PlaybookPendingBossQuestionContext, unknown>
397
+ >;
398
+ if (
399
+ typeof candidate.questionId !== 'string' ||
400
+ typeof candidate.resumeStateId !== 'string' ||
401
+ typeof candidate.sourceItem !== 'string' ||
402
+ typeof candidate.player !== 'string' ||
403
+ typeof candidate.question !== 'string'
404
+ ) {
405
+ return undefined;
406
+ }
407
+ return {
408
+ questionId: candidate.questionId,
409
+ resumeStateId: candidate.resumeStateId,
410
+ sourceItem: candidate.sourceItem,
411
+ player: candidate.player,
412
+ question: candidate.question,
413
+ };
414
+ }
415
+
416
+ // ---------------------------------------------------------------------------
417
+ // Generic strategy defaults.
418
+ // ---------------------------------------------------------------------------
419
+
420
+ const CONTINUATION_PREAMBLE =
421
+ 'You previously paused this task to ask Boss a question; Boss has now replied. Continue the same task using the reply below.';
422
+
423
+ function continuationBlocks(input: {
424
+ pendingBossQuestion?: { readonly question: string };
425
+ bossReply?: string;
426
+ }): string[] {
427
+ if (input.pendingBossQuestion === undefined || input.bossReply === undefined) {
428
+ return [];
429
+ }
430
+ return [
431
+ CONTINUATION_PREAMBLE,
432
+ `Boss question:\n${input.pendingBossQuestion.question}`,
433
+ `Boss reply:\n${input.bossReply}`,
434
+ ];
435
+ }
436
+
437
+ const PLACEHOLDER_PATTERN = /<(#|[A-Za-z_$][A-Za-z0-9_$-]*)>/g;
438
+
439
+ function placeholderFieldName(
440
+ token: string,
441
+ fields: Readonly<Record<string, string>>,
442
+ ): string {
443
+ const explicit = fields[token];
444
+ if (explicit !== undefined) return explicit;
445
+ if (token === '#') return 'irNumber';
446
+ return token.replace(/-([A-Za-z0-9])/g, (_match, next: string) =>
447
+ next.toUpperCase(),
448
+ );
449
+ }
450
+
451
+ /**
452
+ * Default player-prompt composer (slc/link.md §Player prompt composition).
453
+ * One callback-based pass substitutes each `<fieldName>` placeholder whose
454
+ * typed input field is a string; replacement text is literal, and
455
+ * placeholder-looking text inside a value is never re-substituted. The
456
+ * continuation preamble and Q/A blocks precede the domain body on resume.
457
+ */
458
+ export function defaultComposePlayerPrompt(
459
+ input: PlaybookPlayerInput,
460
+ placeholderFields: Readonly<Record<string, string>> = {},
461
+ ): string {
462
+ const blocks = continuationBlocks(input);
463
+ const fields = input as unknown as Record<string, unknown>;
464
+ const body = input.prompt.replace(PLACEHOLDER_PATTERN, (match, token) => {
465
+ const value =
466
+ fields[placeholderFieldName(token as string, placeholderFields)];
467
+ return typeof value === 'string' ? value : match;
468
+ });
469
+ blocks.push(body);
470
+ return blocks.join('\n\n');
471
+ }
472
+
473
+ function sortJson(value: JsonValue): JsonValue {
474
+ if (Array.isArray(value)) return value.map((entry) => sortJson(entry));
475
+ if (value !== null && typeof value === 'object') {
476
+ const record = value as { readonly [key: string]: JsonValue };
477
+ const sorted: Record<string, JsonValue> = {};
478
+ for (const key of Object.keys(record).sort()) {
479
+ sorted[key] = sortJson(record[key]);
480
+ }
481
+ return sorted;
482
+ }
483
+ return value;
484
+ }
485
+
486
+ function stableJson(value: unknown, path: string): string {
487
+ return JSON.stringify(sortJson(snapshotJsonValue(value, path)));
488
+ }
489
+
490
+ /**
491
+ * Default direct-Captain prompt composer (slc/link.md §Captain prompt
492
+ * composition). Placeholder substitution is presence-based: string fields
493
+ * substitute verbatim; JSON-safe arrays/objects render as deterministic JSON
494
+ * with lexicographically sorted keys at every depth.
495
+ */
496
+ export function defaultComposeCaptainPrompt(
497
+ input: PlaybookCaptainInput,
498
+ placeholderFields: Readonly<Record<string, string>> = {},
499
+ ): string {
500
+ const blocks = continuationBlocks(input);
501
+ const fields = input as unknown as Record<string, unknown>;
502
+ const body = input.prompt.replace(PLACEHOLDER_PATTERN, (match, token) => {
503
+ const field = placeholderFieldName(token as string, placeholderFields);
504
+ const value = fields[field];
505
+ if (typeof value === 'string') return value;
506
+ if (value !== null && typeof value === 'object') {
507
+ return stableJson(value, `CaptainInput.${field}`);
508
+ }
509
+ return match;
510
+ });
511
+ blocks.push(body);
512
+ return blocks.join('\n\n');
513
+ }
514
+
515
+ /** Default player binding: each player to its lowercased name. */
516
+ export function defaultResolvePlayerId(input: PlaybookPlayerInput): string {
517
+ return input.player.toLowerCase();
518
+ }
519
+
520
+ /**
521
+ * Default required-field extraction (slc/link.md §Captain adjudication).
522
+ * Limited to the description's `Output shall include` / `输出应包含` clause;
523
+ * recognizes both the bare backticked name and the annotated `name: <...>`
524
+ * form.
525
+ */
526
+ export function defaultExtractRequiredFields(description: string): string[] {
527
+ const markers = ['Output shall include', '输出应包含'];
528
+ let clauseStart = -1;
529
+ for (const marker of markers) {
530
+ const idx = description.indexOf(marker);
531
+ if (idx !== -1) {
532
+ clauseStart = idx + marker.length;
533
+ break;
534
+ }
535
+ }
536
+ if (clauseStart === -1) return [];
537
+ const clause = description.slice(clauseStart);
538
+ const fields: string[] = [];
539
+ const re = /`([A-Za-z_$][A-Za-z0-9_$]*)(?::[^`]*)?`/g;
540
+ for (const m of clause.matchAll(re)) fields.push(m[1]);
541
+ return fields;
542
+ }
543
+
544
+ /** Default delegated-player adjudicator prompt. */
545
+ export function defaultBuildJudgePrompt(
546
+ input: PlaybookPlayerInput,
547
+ finalText: string,
548
+ ): string {
549
+ const lines: string[] = [];
550
+ lines.push(`The ${input.player} just produced this output:`);
551
+ lines.push('');
552
+ lines.push('```');
553
+ lines.push(finalText);
554
+ lines.push('```');
555
+ lines.push('');
556
+ lines.push(
557
+ 'Pick exactly one outcome by `guard` and return JSON ' +
558
+ '`{ guard, …payloadFields }`. Required payload fields are named in the ' +
559
+ 'outcome description after "Output shall include" / "输出应包含".',
560
+ );
561
+ lines.push('');
562
+ for (const [key, description] of Object.entries(input.result)) {
563
+ lines.push(`- \`${key}\` — ${description}`);
564
+ }
565
+ return lines.join('\n');
566
+ }
567
+
568
+ // ---------------------------------------------------------------------------
569
+ // Player adjudication (slc/link.md §Captain adjudication).
570
+ // ---------------------------------------------------------------------------
571
+
572
+ export interface PlayerAdjudicationSpec {
573
+ buildJudgePrompt?: (input: PlaybookPlayerInput, finalText: string) => string;
574
+ extractRequiredFields?: (description: string) => string[];
575
+ verbatimPayloadFields?: ReadonlySet<string>;
576
+ }
577
+
578
+ const NO_VERBATIM_FIELDS: ReadonlySet<string> = new Set();
579
+
580
+ /**
581
+ * LLM-judge adjudicator for delegated players. Coerces the player's
582
+ * finalText into one of the state's declared guards, extracts every required
583
+ * payload field from the judge reply, and fails loudly (throws) on a missing
584
+ * JSON object, an undeclared guard, or a missing required field. Fields in
585
+ * `verbatimPayloadFields` carry `finalText.trim()` rather than round-tripping
586
+ * long-form prose through judge JSON.
587
+ */
588
+ export async function adjudicatePlayerOutput(
589
+ spec: PlayerAdjudicationSpec,
590
+ input: PlaybookPlayerInput,
591
+ finalText: string,
592
+ ports: PlaybookPorts,
593
+ signal: AbortSignal,
594
+ boundary?: RuntimeBoundaryCalls,
595
+ ): Promise<PlaybookActorOutput> {
596
+ const buildPrompt = spec.buildJudgePrompt ?? defaultBuildJudgePrompt;
597
+ const extractFields = spec.extractRequiredFields ?? defaultExtractRequiredFields;
598
+ const verbatimFields = spec.verbatimPayloadFields ?? NO_VERBATIM_FIELDS;
599
+ const prompt = buildPrompt(input, finalText);
600
+ const raw = boundary
601
+ ? await boundary.callJudge(
602
+ 'player-output-adjudication',
603
+ input.stateId,
604
+ prompt,
605
+ signal,
606
+ )
607
+ : await ports.callJudge(prompt, signal);
608
+ const parsed = parseJudgeJson(raw);
609
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
610
+ throw new Error('adjudicate: judge response is not a JSON object');
611
+ }
612
+ const obj = parsed as Record<string, unknown>;
613
+ const guard = obj.guard;
614
+ if (typeof guard !== 'string') {
615
+ throw new Error('adjudicate: judge response missing string "guard" field');
616
+ }
617
+ if (!Object.prototype.hasOwnProperty.call(input.result, guard)) {
618
+ throw new Error(
619
+ `adjudicate: unknown guard "${guard}" — declared guards: ${Object.keys(
620
+ input.result,
621
+ ).join(', ')}`,
622
+ );
623
+ }
624
+ const verbatim = finalText.trim();
625
+ for (const field of extractFields(input.result[guard])) {
626
+ if (verbatimFields.has(field)) {
627
+ obj[field] = verbatim;
628
+ continue;
629
+ }
630
+ if (typeof obj[field] !== 'string') {
631
+ if (guard === 'needsBossReply' && field === 'question') {
632
+ throw new Error(BOSS_REPLY_ERRORS.missingQuestion);
633
+ }
634
+ throw new Error(
635
+ `adjudicate: judge response missing required field "${field}" for guard "${guard}"`,
636
+ );
637
+ }
638
+ }
639
+ return obj as PlaybookActorOutput;
640
+ }
641
+
642
+ function validateBossReplyOutput(
643
+ input: { stateId: string },
644
+ output: PlaybookActorOutput,
645
+ resumableStateIds: ReadonlySet<string>,
646
+ ): void {
647
+ if (output.guard !== 'needsBossReply') return;
648
+ if (typeof output.question !== 'string') {
649
+ throw new Error(BOSS_REPLY_ERRORS.missingQuestion);
650
+ }
651
+ if (!resumableStateIds.has(input.stateId)) {
652
+ throw new Error(BOSS_REPLY_ERRORS.unregisteredState(input.stateId));
653
+ }
654
+ }
655
+
656
+ // ---------------------------------------------------------------------------
657
+ // Delegated-player actor bridge. One PromiseActorLogic the machine invokes
658
+ // from every player-invoking state: resolve the playerId, compose the prompt,
659
+ // await callPlayer, adjudicate the finalText. A non-`ok` result or missing
660
+ // finalText throws so XState routes via onError to the FSM's failure sink.
661
+ //
662
+ // `getActiveSignal` flows the Boss's public-boundary signal into the host
663
+ // port calls — fromPromise hands the bridge XState's actor-scoped signal,
664
+ // which only fires on actor.stop(), not on Boss abort.
665
+ // ---------------------------------------------------------------------------
666
+
667
+ export interface PlayerBridgeSpec {
668
+ resolvePlayerId: (input: PlaybookPlayerInput) => string;
669
+ composePlayerPrompt: (input: PlaybookPlayerInput) => string;
670
+ adjudication: PlayerAdjudicationSpec;
671
+ resumableStateIds: ReadonlySet<string>;
672
+ }
673
+
674
+ export function createPlayerBridge(
675
+ spec: PlayerBridgeSpec,
676
+ ports: PlaybookPorts,
677
+ getActiveSignal?: () => AbortSignal | undefined,
678
+ boundary?: RuntimeBoundaryCalls,
679
+ onControlPlaneError?: (error: unknown) => void,
680
+ ): PromiseActorLogic<PlaybookActorOutput, PlaybookPlayerInput> {
681
+ return fromPromise<PlaybookActorOutput, PlaybookPlayerInput>(
682
+ async ({ input, signal }) => {
683
+ const activeSignal = combineAbortSignals(signal, getActiveSignal?.());
684
+ const playerId = spec.resolvePlayerId(input);
685
+ const prompt = spec.composePlayerPrompt(input);
686
+ const result = boundary
687
+ ? await boundary.callPlayer(input, playerId, prompt, activeSignal)
688
+ : await ports.callPlayer(playerId, prompt, activeSignal, {
689
+ resume: false,
690
+ });
691
+ if (result.status !== 'ok') {
692
+ throw new Error(
693
+ result.error ?? `captainBridge: callPlayer status "${result.status}"`,
694
+ );
695
+ }
696
+ if (result.finalText === undefined) {
697
+ throw new Error(
698
+ 'captainBridge: callPlayer returned status=ok with no finalText',
699
+ );
700
+ }
701
+ try {
702
+ const output = await adjudicatePlayerOutput(
703
+ spec.adjudication,
704
+ input,
705
+ result.finalText,
706
+ ports,
707
+ activeSignal,
708
+ boundary,
709
+ );
710
+ validateBossReplyOutput(input, output, spec.resumableStateIds);
711
+ return output;
712
+ } catch (error) {
713
+ onControlPlaneError?.(error);
714
+ throw error;
715
+ }
716
+ },
717
+ );
718
+ }
719
+
720
+ // ---------------------------------------------------------------------------
721
+ // Direct-Captain adjudication (slc/link.md §Captain adjudication). The judge
722
+ // selects the guard and supplies only other structural fields; the runtime
723
+ // injects the exact visible finalText as the selected output's `question` or
724
+ // `response` and rejects a judge reply that supplies either presentation
725
+ // field as an undeclared extra key.
726
+ // ---------------------------------------------------------------------------
727
+
728
+ function buildCaptainJudgePrompt(
729
+ input: PlaybookCaptainInput,
730
+ finalText: string,
731
+ ): string {
732
+ const lines: string[] = [];
733
+ lines.push('Adjudicate the direct Captain output for this FSM state.');
734
+ lines.push(`State id: ${input.stateId}`);
735
+ lines.push(`Source item: ${input.sourceItem}`);
736
+ lines.push('');
737
+ lines.push('Visible Captain output:');
738
+ lines.push('```');
739
+ lines.push(finalText);
740
+ lines.push('```');
741
+ lines.push('');
742
+ lines.push('Result keys and descriptions:');
743
+ for (const [key, description] of Object.entries(input.result)) {
744
+ lines.push(`- \`${key}\` — ${description}`);
745
+ }
746
+ lines.push('');
747
+ lines.push(
748
+ 'Pick exactly one outcome by `guard` and return JSON ' +
749
+ '`{ guard, …structuralPayloadFields }`. Do not include `question` or ' +
750
+ '`response`; the runtime injects the visible text.',
751
+ );
752
+ return lines.join('\n');
753
+ }
754
+
755
+ function adjudicateCaptainOutput(
756
+ extractFields: (description: string) => string[],
757
+ input: PlaybookCaptainInput,
758
+ finalText: string,
759
+ judgeText: string,
760
+ ): PlaybookActorOutput {
761
+ const parsed = parseJudgeJson(judgeText);
762
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
763
+ throw new Error('adjudicate: judge response is not a JSON object');
764
+ }
765
+ const obj = parsed as Record<string, unknown>;
766
+ const guard = obj.guard;
767
+ if (typeof guard !== 'string') {
768
+ throw new Error('adjudicate: judge response missing string "guard" field');
769
+ }
770
+ if (!Object.prototype.hasOwnProperty.call(input.result, guard)) {
771
+ throw new Error(
772
+ `adjudicate: unknown guard "${guard}" — declared guards: ${Object.keys(
773
+ input.result,
774
+ ).join(', ')}`,
775
+ );
776
+ }
777
+ const required = extractFields(input.result[guard]);
778
+ const allowed = new Set(['guard']);
779
+ for (const field of required) {
780
+ if (field !== 'question' && field !== 'response') allowed.add(field);
781
+ }
782
+ for (const key of Object.keys(obj)) {
783
+ if (!allowed.has(key)) {
784
+ throw new Error(
785
+ `adjudicate: judge response supplied undeclared field "${key}" for guard "${guard}"`,
786
+ );
787
+ }
788
+ }
789
+ for (const field of required) {
790
+ if (field === 'question' || field === 'response') continue;
791
+ if (obj[field] === undefined || obj[field] === null) {
792
+ throw new Error(
793
+ `adjudicate: judge response missing required field "${field}" for guard "${guard}"`,
794
+ );
795
+ }
796
+ }
797
+ const output: Record<string, unknown> = { ...obj, guard };
798
+ if (required.includes('question')) output.question = finalText;
799
+ if (required.includes('response')) output.response = finalText;
800
+ return output as PlaybookActorOutput;
801
+ }
802
+
803
+ // ---------------------------------------------------------------------------
804
+ // FSM-artifact introspection over `machine.config` — internal but stable in
805
+ // XState v5: it preserves the literal `createMachine` argument.
806
+ // ---------------------------------------------------------------------------
807
+
808
+ function stripIdPrefix(target: string): string {
809
+ return target.startsWith('#') ? target.slice(1) : target;
810
+ }
811
+
812
+ function collectInvokeSources(machine: AnyStateMachine): ReadonlySet<string> {
813
+ const sources = new Set<string>();
814
+ const visit = (stateDef: unknown): void => {
815
+ if (!isPlainObject(stateDef)) return;
816
+ const invoke = stateDef.invoke;
817
+ const invokes = Array.isArray(invoke) ? invoke : invoke ? [invoke] : [];
818
+ for (const entry of invokes) {
819
+ if (isPlainObject(entry) && typeof entry.src === 'string') {
820
+ sources.add(entry.src);
821
+ }
822
+ }
823
+ if (isPlainObject(stateDef.states)) {
824
+ for (const child of Object.values(stateDef.states)) visit(child);
825
+ }
826
+ };
827
+ visit((machine as unknown as { config?: unknown }).config);
828
+ return sources;
829
+ }
830
+
831
+ function transitionTargets(transition: unknown): string[] {
832
+ const arms = Array.isArray(transition) ? transition : [transition];
833
+ const targets: string[] = [];
834
+ for (const arm of arms) {
835
+ if (typeof arm === 'string') {
836
+ targets.push(stripIdPrefix(arm));
837
+ } else if (isPlainObject(arm) && typeof arm.target === 'string') {
838
+ targets.push(stripIdPrefix(arm.target));
839
+ }
840
+ }
841
+ return targets;
842
+ }
843
+
844
+ /** Targets of the FSM's `awaitBossReply` BOSS_REPLY transitions. */
845
+ export function resumableStateIdsFromMachine(
846
+ machine: AnyStateMachine,
847
+ ): ReadonlySet<string> {
848
+ const config = (machine as unknown as { config?: unknown }).config;
849
+ if (!isPlainObject(config) || !isPlainObject(config.states)) {
850
+ return new Set();
851
+ }
852
+ const awaitState = config.states.awaitBossReply;
853
+ if (!isPlainObject(awaitState) || !isPlainObject(awaitState.on)) {
854
+ return new Set();
855
+ }
856
+ const bossReply = awaitState.on.BOSS_REPLY;
857
+ if (bossReply === undefined) return new Set();
858
+ return new Set(transitionTargets(bossReply));
859
+ }
860
+
861
+ // ---------------------------------------------------------------------------
862
+ // Default transition/status derivation.
863
+ // ---------------------------------------------------------------------------
864
+
865
+ const SUPPRESSED_ENTRY_STATES: ReadonlySet<string> = new Set(['ready', 'done']);
866
+
867
+ function makeDefaultNormalizeTransitionEvent(
868
+ transitionEventFields: readonly string[],
869
+ ): (event: unknown) => JsonValue {
870
+ return (event: unknown): JsonValue => {
871
+ if (event === null || typeof event !== 'object') {
872
+ return snapshotJsonValue(event ?? null, 'FSM event');
873
+ }
874
+ const e = event as Record<string, unknown>;
875
+ const out: Record<string, JsonValue> = {};
876
+ if (typeof e.type === 'string') out.type = e.type;
877
+ for (const field of transitionEventFields) {
878
+ if (typeof e[field] === 'string') out[field] = e[field] as string;
879
+ }
880
+ if (e.output !== undefined) {
881
+ out.output = snapshotJsonValue(e.output, 'FSM event output');
882
+ }
883
+ if (e.error !== undefined) {
884
+ out.error = snapshotJsonValue(normalizeError(e.error), 'FSM event error');
885
+ }
886
+ return snapshotJsonValue(out, 'FSM event');
887
+ };
888
+ }
889
+
890
+ function defaultStatusesForState(
891
+ state: PlaybookState,
892
+ context: Record<string, unknown>,
893
+ ): ScheduledStatus[] {
894
+ const stateId = state.stateId;
895
+ if (stateId === undefined || SUPPRESSED_ENTRY_STATES.has(stateId)) return [];
896
+ if (stateId === 'awaitBossReply') {
897
+ const pending = pendingBossQuestionFromContext(context);
898
+ const message =
899
+ pending === undefined
900
+ ? 'Awaiting Boss reply.'
901
+ : `${pending.player} asks: ${pending.question}`;
902
+ return [{ message }];
903
+ }
904
+ if (stateId === 'failed') {
905
+ const lastError = normalizeErrorFull(context.lastError);
906
+ return [
907
+ {
908
+ message: 'Workflow failed; awaiting Boss recovery.',
909
+ ...(lastError === undefined
910
+ ? {}
911
+ : { data: snapshotJsonValue({ lastError }, 'failed status data') }),
912
+ },
913
+ ];
914
+ }
915
+ return [{ message: `Entered ${stateId}.` }];
916
+ }
917
+
918
+ // ---------------------------------------------------------------------------
919
+ // Default parked-state classifier (slc/link.md §Boss-event mapping): the
920
+ // runtime-owned textual fields are never requested from the judge; only the
921
+ // event choice and non-text routing fields are.
922
+ // ---------------------------------------------------------------------------
923
+
924
+ interface ClassifierState {
925
+ value: unknown;
926
+ context: Record<string, unknown>;
927
+ }
928
+
929
+ function classifierState(snapshotOrState: unknown): ClassifierState {
930
+ if (
931
+ snapshotOrState !== null &&
932
+ typeof snapshotOrState === 'object' &&
933
+ 'value' in snapshotOrState
934
+ ) {
935
+ const candidate = snapshotOrState as { value?: unknown; context?: unknown };
936
+ return {
937
+ value: candidate.value,
938
+ context:
939
+ candidate.context !== null &&
940
+ typeof candidate.context === 'object' &&
941
+ !Array.isArray(candidate.context)
942
+ ? (candidate.context as Record<string, unknown>)
943
+ : {},
944
+ };
945
+ }
946
+ return { value: snapshotOrState, context: {} };
947
+ }
948
+
949
+ function configuredEventTypesForState(
950
+ machine: AnyStateMachine,
951
+ stateId: string | undefined,
952
+ ): ReadonlySet<string> {
953
+ const configured = new Set<string>();
954
+ const config = (machine as unknown as { config?: unknown }).config;
955
+ if (!isPlainObject(config)) return configured;
956
+ if (isPlainObject(config.on)) {
957
+ for (const type of Object.keys(config.on)) configured.add(type);
958
+ }
959
+ if (stateId !== undefined && isPlainObject(config.states)) {
960
+ const state = config.states[stateId];
961
+ if (isPlainObject(state) && isPlainObject(state.on)) {
962
+ for (const type of Object.keys(state.on)) configured.add(type);
963
+ }
964
+ }
965
+ return configured;
966
+ }
967
+
968
+ // Derived contracts merge into whatever the machine already yielded for the
969
+ // same type, so a deterministic entry event that shares a type with another
970
+ // derived contract keeps its exact-text ownership instead of being replaced.
971
+ function mergeDerivedContract(
972
+ contracts: Map<string, XStateBossEventSpec>,
973
+ contract: XStateBossEventSpec,
974
+ ): void {
975
+ const existing = contracts.get(contract.type);
976
+ contracts.set(contract.type, {
977
+ type: contract.type,
978
+ fields: { ...(existing?.fields ?? {}), ...(contract.fields ?? {}) },
979
+ });
980
+ }
981
+
982
+ function defaultBossEventSpecs(
983
+ machine: AnyStateMachine,
984
+ entryEvent: { type: string; textField: string } | undefined,
985
+ supplied: readonly XStateBossEventSpec[],
986
+ ): ReadonlyMap<string, XStateBossEventSpec> {
987
+ const contracts = new Map<string, XStateBossEventSpec>();
988
+ if (entryEvent !== undefined) {
989
+ mergeDerivedContract(contracts, {
990
+ type: entryEvent.type,
991
+ fields: { [entryEvent.textField]: { source: 'text', required: true } },
992
+ });
993
+ }
994
+
995
+ const config = (machine as unknown as { config?: unknown }).config;
996
+ const rootInterrupt =
997
+ isPlainObject(config) && isPlainObject(config.on)
998
+ ? config.on.BOSS_INTERRUPT
999
+ : undefined;
1000
+ const interruptTargets =
1001
+ rootInterrupt === undefined ? [] : transitionTargets(rootInterrupt);
1002
+ if (interruptTargets.length > 0) {
1003
+ mergeDerivedContract(contracts, {
1004
+ type: 'BOSS_INTERRUPT',
1005
+ fields: {
1006
+ targetId: {
1007
+ source: 'judge',
1008
+ required: true,
1009
+ values: [...new Set(interruptTargets)],
1010
+ },
1011
+ // slc/link.md §Boss-event mapping: for BOSS_INTENT and
1012
+ // BOSS_INTERRUPT the runtime, never the judge, attaches the exact
1013
+ // original Boss text as `bossIntent`.
1014
+ bossIntent: { source: 'text', required: true },
1015
+ },
1016
+ });
1017
+ }
1018
+
1019
+ for (const contract of supplied) {
1020
+ if (
1021
+ typeof contract.type !== 'string' ||
1022
+ contract.type.trim().length === 0
1023
+ ) {
1024
+ throw new TypeError(
1025
+ 'Boss event contract type must be a non-empty string',
1026
+ );
1027
+ }
1028
+ if (contract.type === 'NO_ACTION' || contract.type === 'BOSS_REPLY') {
1029
+ throw new TypeError(
1030
+ `Boss event contract ${contract.type} is runtime-owned`,
1031
+ );
1032
+ }
1033
+ const existing = contracts.get(contract.type);
1034
+ const fields: Record<string, XStateBossEventFieldSpec> = {
1035
+ ...(existing?.fields ?? {}),
1036
+ };
1037
+ for (const [field, fieldSpec] of Object.entries(contract.fields ?? {})) {
1038
+ if (field.length === 0 || field === 'type') {
1039
+ throw new TypeError(
1040
+ `Boss event contract ${contract.type} has invalid field ${JSON.stringify(field)}`,
1041
+ );
1042
+ }
1043
+ if (fieldSpec.source !== 'judge' && fieldSpec.source !== 'text') {
1044
+ throw new TypeError(
1045
+ `Boss event contract ${contract.type}.${field} has invalid source`,
1046
+ );
1047
+ }
1048
+ if (fieldSpec.values !== undefined) {
1049
+ if (
1050
+ fieldSpec.source !== 'judge' ||
1051
+ fieldSpec.values.length === 0 ||
1052
+ fieldSpec.values.some(
1053
+ (value) => typeof value !== 'string' || value.length === 0,
1054
+ )
1055
+ ) {
1056
+ throw new TypeError(
1057
+ `Boss event contract ${contract.type}.${field} has invalid values`,
1058
+ );
1059
+ }
1060
+ }
1061
+ const normalized: XStateBossEventFieldSpec = {
1062
+ source: fieldSpec.source,
1063
+ ...(fieldSpec.required === true ? { required: true } : {}),
1064
+ ...(fieldSpec.values === undefined
1065
+ ? {}
1066
+ : { values: [...new Set(fieldSpec.values)] }),
1067
+ };
1068
+ const derived = fields[field];
1069
+ if (derived !== undefined) {
1070
+ const derivedValues =
1071
+ derived.values === undefined
1072
+ ? undefined
1073
+ : new Set(derived.values);
1074
+ const normalizedValues =
1075
+ normalized.values === undefined
1076
+ ? undefined
1077
+ : new Set(normalized.values);
1078
+ const sameValues =
1079
+ derivedValues === undefined || normalizedValues === undefined
1080
+ ? derivedValues === normalizedValues
1081
+ : derivedValues.size === normalizedValues.size &&
1082
+ [...derivedValues].every((value) =>
1083
+ normalizedValues.has(value),
1084
+ );
1085
+ if (
1086
+ derived.source !== normalized.source ||
1087
+ (derived.required === true) !== (normalized.required === true) ||
1088
+ !sameValues
1089
+ ) {
1090
+ throw new TypeError(
1091
+ `Boss event contract ${contract.type}.${field} conflicts with the runtime-derived contract`,
1092
+ );
1093
+ }
1094
+ continue;
1095
+ }
1096
+ fields[field] = normalized;
1097
+ }
1098
+ contracts.set(contract.type, { type: contract.type, fields });
1099
+ }
1100
+
1101
+ contracts.set('BOSS_REPLY', {
1102
+ type: 'BOSS_REPLY',
1103
+ fields: {
1104
+ questionId: { source: 'judge' },
1105
+ answer: { source: 'text', required: true },
1106
+ },
1107
+ });
1108
+ return contracts;
1109
+ }
1110
+
1111
+ function eventContractPrompt(contract: XStateBossEventSpec): string {
1112
+ const fields = Object.entries(contract.fields ?? {}).filter(
1113
+ ([, field]) => field.source === 'judge',
1114
+ );
1115
+ const members = [
1116
+ `"type": ${JSON.stringify(contract.type)}`,
1117
+ ...fields.map(([name, field]) =>
1118
+ `${JSON.stringify(name)}: ${JSON.stringify(
1119
+ field.values?.[0] ?? '<string>',
1120
+ )}`,
1121
+ ),
1122
+ ];
1123
+ const notes = fields.flatMap(([name, field]) => [
1124
+ ...(field.required === true ? [] : [`${name} optional`]),
1125
+ ...(field.values === undefined
1126
+ ? []
1127
+ : [
1128
+ `${name} one of ${field.values
1129
+ .map((value) => JSON.stringify(value))
1130
+ .join(', ')}`,
1131
+ ]),
1132
+ ]);
1133
+ return `{ ${members.join(', ')} }${
1134
+ notes.length === 0 ? '' : ` (${notes.join('; ')})`
1135
+ }`;
1136
+ }
1137
+
1138
+ function makeDefaultClassifyBossText(
1139
+ machine: AnyStateMachine,
1140
+ entryEvent: { type: string; textField: string } | undefined,
1141
+ bossEvents: readonly XStateBossEventSpec[],
1142
+ ): (
1143
+ text: string,
1144
+ ports: PlaybookPorts,
1145
+ signal: AbortSignal,
1146
+ snapshotOrState: unknown,
1147
+ boundary?: RuntimeBoundaryCalls,
1148
+ ) => Promise<EventObject | undefined> {
1149
+ const contracts = defaultBossEventSpecs(machine, entryEvent, bossEvents);
1150
+ return async (text, ports, signal, snapshotOrState, boundary) => {
1151
+ const trimmed = text.trim();
1152
+ if (trimmed === '') return undefined;
1153
+ const state = classifierState(snapshotOrState);
1154
+ const stateId = typeof state.value === 'string' ? state.value : undefined;
1155
+ const currentState = stateId ?? JSON.stringify(state.value ?? null);
1156
+ const pending = pendingBossQuestionFromContext(state.context);
1157
+ const configuredTypes = configuredEventTypesForState(machine, stateId);
1158
+ const applicable = [...contracts.values()].filter(
1159
+ (contract) =>
1160
+ configuredTypes.has(contract.type) &&
1161
+ (contract.type !== 'BOSS_REPLY' || pending !== undefined),
1162
+ );
1163
+
1164
+ const lines = [
1165
+ 'Classify the following Boss message into exactly one event.',
1166
+ 'Respond with one exact flat JSON object. Do not add fields that are not shown.',
1167
+ 'The runtime, not the judge, attaches the exact Boss text to textual event fields.',
1168
+ '',
1169
+ `Current state: ${currentState}`,
1170
+ ];
1171
+ if (pending !== undefined) {
1172
+ lines.push(
1173
+ `Pending question id: ${pending.questionId}`,
1174
+ `Pending asking player: ${pending.player}`,
1175
+ `Pending Boss question: ${pending.question}`,
1176
+ );
1177
+ }
1178
+ lines.push('', 'Allowed JSON objects:', '- { "type": "NO_ACTION" }');
1179
+ for (const contract of applicable) {
1180
+ lines.push(`- ${eventContractPrompt(contract)}`);
1181
+ }
1182
+ lines.push('', 'Boss message:', '```', text, '```');
1183
+ const prompt = lines.join('\n');
1184
+
1185
+ const raw = boundary
1186
+ ? await boundary.callJudge(
1187
+ 'boss-input-classification',
1188
+ stateId,
1189
+ prompt,
1190
+ signal,
1191
+ )
1192
+ : await ports.callJudge(prompt, signal);
1193
+ let parsed: unknown;
1194
+ try {
1195
+ parsed = parseJudgeJson(raw);
1196
+ } catch {
1197
+ await ports.emitStatus('Classifier reply was not recoverable JSON');
1198
+ return undefined;
1199
+ }
1200
+ if (
1201
+ typeof parsed !== 'object' ||
1202
+ parsed === null ||
1203
+ Array.isArray(parsed)
1204
+ ) {
1205
+ await ports.emitStatus('Classifier returned a non-object JSON response');
1206
+ return undefined;
1207
+ }
1208
+ const obj = parsed as Record<string, unknown>;
1209
+ const eventType = obj.type;
1210
+ if (typeof eventType !== 'string') {
1211
+ await ports.emitStatus('Classifier did not name an event type');
1212
+ return undefined;
1213
+ }
1214
+ if (eventType === 'NO_ACTION') {
1215
+ if (Object.keys(obj).length !== 1) {
1216
+ await ports.emitStatus('Classifier supplied extra fields for NO_ACTION');
1217
+ return undefined;
1218
+ }
1219
+ return undefined;
1220
+ }
1221
+ const contract = applicable.find(
1222
+ (candidate) => candidate.type === eventType,
1223
+ );
1224
+ if (contract === undefined) {
1225
+ await ports.emitStatus(
1226
+ `Classifier returned unknown or inapplicable event type: ${eventType}`,
1227
+ );
1228
+ return undefined;
1229
+ }
1230
+ const fields = contract.fields ?? {};
1231
+ const judgeFields = new Set(
1232
+ Object.entries(fields)
1233
+ .filter(([, field]) => field.source === 'judge')
1234
+ .map(([field]) => field),
1235
+ );
1236
+ const extras = Object.keys(obj).filter(
1237
+ (field) => field !== 'type' && !judgeFields.has(field),
1238
+ );
1239
+ if (extras.length > 0) {
1240
+ await ports.emitStatus(
1241
+ `Classifier supplied undeclared field for ${eventType}: ${extras[0]}`,
1242
+ );
1243
+ return undefined;
1244
+ }
1245
+
1246
+ const event: Record<string, unknown> = { type: eventType };
1247
+ for (const [field, fieldSpec] of Object.entries(fields)) {
1248
+ if (fieldSpec.source === 'text') {
1249
+ event[field] = text;
1250
+ continue;
1251
+ }
1252
+ const value = obj[field];
1253
+ if (value === undefined && fieldSpec.required !== true) continue;
1254
+ if (typeof value !== 'string' || value.length === 0) {
1255
+ await ports.emitStatus(
1256
+ `Classifier omitted or invalidated ${field} for ${eventType}`,
1257
+ );
1258
+ return undefined;
1259
+ }
1260
+ if (fieldSpec.values !== undefined && !fieldSpec.values.includes(value)) {
1261
+ await ports.emitStatus(
1262
+ `Classifier supplied unknown ${field} for ${eventType}: ${value}`,
1263
+ );
1264
+ return undefined;
1265
+ }
1266
+ event[field] = value;
1267
+ }
1268
+
1269
+ if (eventType === 'BOSS_REPLY') {
1270
+ if (pending === undefined) {
1271
+ await ports.emitStatus(
1272
+ 'Classifier returned BOSS_REPLY without a pending question',
1273
+ );
1274
+ return undefined;
1275
+ }
1276
+ const questionId = event.questionId;
1277
+ if (questionId !== undefined && questionId !== pending.questionId) {
1278
+ await ports.emitStatus(
1279
+ `Classifier supplied unknown questionId for BOSS_REPLY: ${String(questionId)}`,
1280
+ );
1281
+ return undefined;
1282
+ }
1283
+ event.questionId = pending.questionId;
1284
+ }
1285
+ const can = (snapshotOrState as { can?: unknown } | null)?.can;
1286
+ if (
1287
+ typeof can === 'function' &&
1288
+ !(can as (candidate: EventObject) => boolean).call(
1289
+ snapshotOrState,
1290
+ event as EventObject,
1291
+ )
1292
+ ) {
1293
+ await ports.emitStatus(
1294
+ `Classifier selected ${eventType}, but its state guards rejected the event`,
1295
+ );
1296
+ return undefined;
1297
+ }
1298
+ return event as EventObject;
1299
+ };
1300
+ }
1301
+
1302
+ // ---------------------------------------------------------------------------
1303
+ // The generic runtime factory.
1304
+ // ---------------------------------------------------------------------------
1305
+
1306
+ type BossSettlementOutcome =
1307
+ | 'no-action'
1308
+ | 'quiescent'
1309
+ | 'failed'
1310
+ | 'terminal'
1311
+ | 'aborted'
1312
+ | 'suspended';
1313
+
1314
+ interface TracePosition {
1315
+ turnId?: number;
1316
+ callId?: string;
1317
+ }
1318
+
1319
+ /**
1320
+ * Build a `PlaybookRuntimeFactory` that interprets the given FSM artifact
1321
+ * under the slc/link.md contract. The factory provides every actor kind the
1322
+ * machine declares — `player`, `script`, `captain`, and nested `playbook`
1323
+ * (literal and dynamic) — and implements the full runtime lifecycle including
1324
+ * the optional parked-session snapshot capability (DR-014).
1325
+ *
1326
+ * Scope: single-region root machines (each snapshot exposes exactly one
1327
+ * playbook state id). Parallel-region FSMs keep their own linked runtimes.
1328
+ */
1329
+ export function createXStatePlaybookRuntime<TOptions>(
1330
+ machine: AnyStateMachine,
1331
+ spec: XStatePlaybookRuntimeSpec<TOptions>,
1332
+ ): PlaybookRuntimeFactory<TOptions> {
1333
+ const label = spec.label ?? 'playbook';
1334
+ const declaredActors = collectInvokeSources(machine);
1335
+ const resumableStateIds =
1336
+ spec.resumableStateIds ?? resumableStateIdsFromMachine(machine);
1337
+ const resolvePlayerIdSpec = spec.resolvePlayerId;
1338
+ const composePlayerPrompt =
1339
+ spec.composePlayerPrompt ??
1340
+ ((input: PlaybookPlayerInput) =>
1341
+ defaultComposePlayerPrompt(input, spec.placeholderFields));
1342
+ const composeCaptainPrompt =
1343
+ spec.composeCaptainPrompt ??
1344
+ ((input: PlaybookCaptainInput) =>
1345
+ defaultComposeCaptainPrompt(input, spec.placeholderFields));
1346
+ const adjudication: PlayerAdjudicationSpec = {
1347
+ ...(spec.buildJudgePrompt !== undefined
1348
+ ? { buildJudgePrompt: spec.buildJudgePrompt }
1349
+ : {}),
1350
+ ...(spec.extractRequiredFields !== undefined
1351
+ ? { extractRequiredFields: spec.extractRequiredFields }
1352
+ : {}),
1353
+ ...(spec.verbatimPayloadFields !== undefined
1354
+ ? { verbatimPayloadFields: spec.verbatimPayloadFields }
1355
+ : {}),
1356
+ };
1357
+ const extractFields =
1358
+ spec.extractRequiredFields ?? defaultExtractRequiredFields;
1359
+ // Build the derived classifier unconditionally: it is the sole validator of
1360
+ // supplied `bossEvents`, and DR-019 §2 requires a conflicting duplicate to
1361
+ // fail factory construction whether or not this spec overrides the
1362
+ // classifier that would have consumed the contracts.
1363
+ const derivedClassifyBossText = makeDefaultClassifyBossText(
1364
+ machine,
1365
+ spec.entryEvent,
1366
+ spec.bossEvents ?? [],
1367
+ );
1368
+ const classifyBossText = spec.classifyBossText ?? derivedClassifyBossText;
1369
+ const normalizeTransitionEvent =
1370
+ spec.normalizeTransitionEvent ??
1371
+ makeDefaultNormalizeTransitionEvent(spec.transitionEventFields ?? []);
1372
+ const statusesForState = spec.statusesForState ?? defaultStatusesForState;
1373
+ const machineInput =
1374
+ spec.machineInput ?? ((options: TOptions) => options as unknown);
1375
+ const scriptCwd =
1376
+ spec.scriptCwd ??
1377
+ ((options: TOptions): string | undefined => {
1378
+ const cwd = (options as Record<string, unknown> | null | undefined)?.cwd;
1379
+ return typeof cwd === 'string' ? cwd : undefined;
1380
+ });
1381
+
1382
+ return function createPlaybookRuntime(options: TOptions): PlaybookRuntime {
1383
+ const boundOptions = spec.snapshotOptions(options);
1384
+ const boundScriptCwd = scriptCwd(boundOptions);
1385
+ let actor: ReturnType<typeof createActor> | undefined;
1386
+ let session: PlaybookSession | undefined;
1387
+ let initialized = false;
1388
+ let initInFlight: Promise<void> | undefined;
1389
+ let disposalPromise: Promise<void> | undefined;
1390
+ let disposed = false;
1391
+ let savedPorts: PlaybookPorts | undefined;
1392
+ let runtimePorts: PlaybookPorts | undefined;
1393
+ // The Boss's per-turn AbortSignal, surfaced to the provided actors so
1394
+ // ports.callPlayer / callCaptain / callJudge see the right cancellation
1395
+ // source. undefined between turns; set by the public boundaries.
1396
+ let activeSignal: AbortSignal | undefined;
1397
+ let activeTurnId: number | undefined;
1398
+ let controlPlaneError: unknown;
1399
+ // Previous root-machine state for the inspect-driven telemetry /
1400
+ // status emitter. undefined before the first inspect firing.
1401
+ let priorState: PlaybookState | undefined;
1402
+ let suppressInspectionEmissions = false;
1403
+
1404
+ let traceSequence = 0;
1405
+ let turnSequence = 0;
1406
+ let judgeCallSequence = 0;
1407
+ let playerCallSequence = 0;
1408
+ let playbookCallSequence = 0;
1409
+ let captainCallSequence = 0;
1410
+ const playerResumeTokens = new Map<string, string>();
1411
+ const activePlayerIds = new Set<string>();
1412
+ const playbookCallTurnIds = new Map<string, number | undefined>();
1413
+ // Captain and judge work share one serialized lane (slc/link.md
1414
+ // §Session lifecycle).
1415
+ const judgeQueue = new PQueue({ concurrency: 1 });
1416
+ const emissionQueue = new PQueue({ concurrency: 1 });
1417
+ const activeEmissionCalls = new Set<Promise<void>>();
1418
+
1419
+ // All trace, state-telemetry, and status work shares this one queue.
1420
+ // Inspection callbacks enqueue a complete ordered batch synchronously;
1421
+ // imperative boundaries await their queued work directly.
1422
+ let emissionFailure: unknown;
1423
+
1424
+ function enqueueEmission(fn: () => Promise<void>): Promise<void> {
1425
+ const queued = emissionQueue.add(fn).then(() => undefined);
1426
+ activeEmissionCalls.add(queued);
1427
+ void queued.then(
1428
+ () => activeEmissionCalls.delete(queued),
1429
+ (error: unknown) => {
1430
+ activeEmissionCalls.delete(queued);
1431
+ emissionFailure ??= error;
1432
+ },
1433
+ );
1434
+ return queued;
1435
+ }
1436
+
1437
+ async function drainEmissions(): Promise<void> {
1438
+ while (true) {
1439
+ const active = [...activeEmissionCalls];
1440
+ if (active.length > 0) await Promise.allSettled(active);
1441
+ await emissionQueue.onIdle();
1442
+ if (
1443
+ activeEmissionCalls.size === 0 &&
1444
+ emissionQueue.size === 0 &&
1445
+ emissionQueue.pending === 0
1446
+ ) {
1447
+ break;
1448
+ }
1449
+ }
1450
+ if (emissionFailure !== undefined) {
1451
+ const error = emissionFailure;
1452
+ emissionFailure = undefined;
1453
+ throw error;
1454
+ }
1455
+ }
1456
+
1457
+ function requireSession(): PlaybookSession {
1458
+ if (!session) {
1459
+ throw new Error('createPlaybookRuntime: init must be called first');
1460
+ }
1461
+ return session;
1462
+ }
1463
+
1464
+ function requireHostPorts(): PlaybookPorts {
1465
+ if (!savedPorts) {
1466
+ throw new Error('createPlaybookRuntime: init must be called first');
1467
+ }
1468
+ return savedPorts;
1469
+ }
1470
+
1471
+ function createTraceEvent(
1472
+ type: PlaybookTraceType,
1473
+ payload: unknown,
1474
+ position: TracePosition = {},
1475
+ ): PlaybookTraceEvent {
1476
+ const currentSession = requireSession();
1477
+ const safePayload = snapshotJsonValue(payload, `trace ${type} payload`);
1478
+ return {
1479
+ schemaVersion: 2,
1480
+ sessionId: currentSession.sessionId,
1481
+ playbookId: currentSession.playbookId,
1482
+ rootSessionId: currentSession.rootSessionId,
1483
+ ...(currentSession.parentSessionId !== undefined
1484
+ ? { parentSessionId: currentSession.parentSessionId }
1485
+ : {}),
1486
+ ...(currentSession.parentCallId !== undefined
1487
+ ? { parentCallId: currentSession.parentCallId }
1488
+ : {}),
1489
+ depth: currentSession.depth,
1490
+ sequence: ++traceSequence,
1491
+ timestamp: Date.now(),
1492
+ type,
1493
+ ...(position.turnId !== undefined ? { turnId: position.turnId } : {}),
1494
+ ...(position.callId !== undefined ? { callId: position.callId } : {}),
1495
+ payload: safePayload,
1496
+ };
1497
+ }
1498
+
1499
+ function emitTrace(
1500
+ type: PlaybookTraceType,
1501
+ payload: unknown,
1502
+ position: TracePosition = {},
1503
+ ): Promise<void> {
1504
+ const currentSession = requireSession();
1505
+ const event = createTraceEvent(type, payload, position);
1506
+ return enqueueEmission(() =>
1507
+ currentSession.ports.emitTelemetry({
1508
+ topic: 'playbook.trace',
1509
+ payload: event,
1510
+ }),
1511
+ );
1512
+ }
1513
+
1514
+ function stateIdentity(stateId: string | undefined): { stateId?: string } {
1515
+ return stateId === undefined ? {} : { stateId };
1516
+ }
1517
+
1518
+ function currentState(): PlaybookState {
1519
+ if (!actor) {
1520
+ throw new Error('createPlaybookRuntime: actor is not initialized');
1521
+ }
1522
+ return normalizePlaybookSnapshot(actor.getSnapshot(), {
1523
+ pendingCall: nestedBridge.getPendingCall(),
1524
+ });
1525
+ }
1526
+
1527
+ function stateTracePayload(
1528
+ state = currentState(),
1529
+ ): Record<string, unknown> {
1530
+ return {
1531
+ state,
1532
+ ...stateIdentity(state.stateId),
1533
+ };
1534
+ }
1535
+
1536
+ function createRuntimePorts(hostPorts: PlaybookPorts): PlaybookPorts {
1537
+ return {
1538
+ callPlayer: (playerId, prompt, signal, callOptions) =>
1539
+ hostPorts.callPlayer(playerId, prompt, signal, callOptions),
1540
+ callCaptain: (prompt, signal, callOptions) =>
1541
+ hostPorts.callCaptain(prompt, signal, callOptions),
1542
+ callJudge: (prompt, signal) => hostPorts.callJudge(prompt, signal),
1543
+ callPlaybook: (request, signal) =>
1544
+ hostPorts.callPlaybook(request, signal),
1545
+ emitStatus: (message, data) => {
1546
+ const descriptor = actor ? currentState() : undefined;
1547
+ const safeData =
1548
+ data === undefined
1549
+ ? undefined
1550
+ : snapshotJsonValue(data, 'status data');
1551
+ const trace = createTraceEvent(
1552
+ 'status.emitted',
1553
+ {
1554
+ message,
1555
+ ...(safeData !== undefined ? { data: safeData } : {}),
1556
+ ...(descriptor !== undefined
1557
+ ? {
1558
+ state: descriptor,
1559
+ ...stateIdentity(descriptor.stateId),
1560
+ }
1561
+ : {}),
1562
+ },
1563
+ activeTurnId !== undefined ? { turnId: activeTurnId } : {},
1564
+ );
1565
+ return enqueueEmission(async () => {
1566
+ await hostPorts.emitTelemetry({
1567
+ topic: 'playbook.trace',
1568
+ payload: trace,
1569
+ });
1570
+ await hostPorts.emitStatus(message, safeData);
1571
+ });
1572
+ },
1573
+ emitTelemetry: (event) => {
1574
+ if (typeof event.topic !== 'string' || event.topic.length === 0) {
1575
+ throw new TypeError('telemetry topic must be a non-empty string');
1576
+ }
1577
+ const payload = snapshotJsonValue(event.payload, 'telemetry payload');
1578
+ return enqueueEmission(() =>
1579
+ hostPorts.emitTelemetry({ topic: event.topic, payload }),
1580
+ );
1581
+ },
1582
+ };
1583
+ }
1584
+
1585
+ async function emitCallStarted(
1586
+ startedType:
1587
+ | 'player.call.started'
1588
+ | 'judge.call.started'
1589
+ | 'captain.call.started',
1590
+ finishedType:
1591
+ | 'player.call.finished'
1592
+ | 'judge.call.finished'
1593
+ | 'captain.call.finished',
1594
+ identity: Record<string, unknown>,
1595
+ position: TracePosition,
1596
+ ): Promise<void> {
1597
+ try {
1598
+ await emitTrace(startedType, identity, position);
1599
+ } catch (error) {
1600
+ controlPlaneError ??= error;
1601
+ try {
1602
+ await emitTrace(
1603
+ finishedType,
1604
+ { ...identity, status: 'error', error: normalizeError(error) },
1605
+ position,
1606
+ );
1607
+ } catch {
1608
+ // Preserve the start failure after one best-effort finish attempt.
1609
+ }
1610
+ throw error;
1611
+ }
1612
+ }
1613
+
1614
+ const boundary: RuntimeBoundaryCalls = {
1615
+ async callPlayer(input, playerId, prompt, signal): Promise<PlayerResult> {
1616
+ // State-entry telemetry/status must precede the call they describe.
1617
+ await drainEmissions();
1618
+ const turnId = activeTurnId;
1619
+ const callId = `player-${++playerCallSequence}`;
1620
+ const stateId = input.stateId;
1621
+ const resume = playerResumeTokens.get(playerId) ?? false;
1622
+ const identity = {
1623
+ purpose: 'captain' as const,
1624
+ ...stateIdentity(stateId),
1625
+ sourceItem: input.sourceItem,
1626
+ playerId,
1627
+ resume,
1628
+ };
1629
+ const position: TracePosition = {
1630
+ ...(turnId !== undefined ? { turnId } : {}),
1631
+ callId,
1632
+ };
1633
+
1634
+ if (activePlayerIds.has(playerId)) {
1635
+ const error = new Error(
1636
+ `simultaneous calls to resolved player ${playerId} are not allowed`,
1637
+ );
1638
+ await emitCallStarted(
1639
+ 'player.call.started',
1640
+ 'player.call.finished',
1641
+ { ...identity, prompt },
1642
+ position,
1643
+ );
1644
+ await emitTrace(
1645
+ 'player.call.finished',
1646
+ { ...identity, status: 'error', error: normalizeError(error) },
1647
+ position,
1648
+ );
1649
+ throw error;
1650
+ }
1651
+ activePlayerIds.add(playerId);
1652
+
1653
+ try {
1654
+ await emitTrace(
1655
+ 'player.call.started',
1656
+ { ...identity, prompt },
1657
+ position,
1658
+ );
1659
+
1660
+ let rawResult: unknown;
1661
+ try {
1662
+ rawResult = await requireHostPorts().callPlayer(
1663
+ playerId,
1664
+ prompt,
1665
+ signal,
1666
+ { resume },
1667
+ );
1668
+ // A host promise is not required to honor cancellation. Do not let
1669
+ // a late result mutate continuity or publish a successful finish.
1670
+ signal.throwIfAborted();
1671
+ } catch (error) {
1672
+ if (!signal.aborted) controlPlaneError ??= error;
1673
+ try {
1674
+ await emitTrace(
1675
+ 'player.call.finished',
1676
+ {
1677
+ ...identity,
1678
+ status: signal.aborted ? 'aborted' : 'error',
1679
+ error: normalizeError(error),
1680
+ },
1681
+ position,
1682
+ );
1683
+ } catch {
1684
+ // The original non-abort port rejection remains authoritative.
1685
+ }
1686
+ // A thrown port call carries no authoritative result, so the
1687
+ // prior token remains available for a later explicit resume.
1688
+ throw error;
1689
+ }
1690
+
1691
+ let result: PlayerResult;
1692
+ try {
1693
+ result = validatePlayerResult(rawResult);
1694
+ } catch (error) {
1695
+ if (!signal.aborted) controlPlaneError ??= error;
1696
+ try {
1697
+ await emitTrace(
1698
+ 'player.call.finished',
1699
+ { ...identity, status: 'error', error: normalizeError(error) },
1700
+ position,
1701
+ );
1702
+ } catch {
1703
+ // The malformed host result remains authoritative.
1704
+ }
1705
+ throw error;
1706
+ }
1707
+
1708
+ if (
1709
+ typeof result.resumeToken === 'string' &&
1710
+ result.resumeToken.trim().length > 0
1711
+ ) {
1712
+ playerResumeTokens.set(playerId, result.resumeToken);
1713
+ } else {
1714
+ playerResumeTokens.delete(playerId);
1715
+ }
1716
+
1717
+ await emitTrace(
1718
+ 'player.call.finished',
1719
+ {
1720
+ ...identity,
1721
+ status: result.status,
1722
+ ...(result.finalText !== undefined
1723
+ ? { finalText: result.finalText }
1724
+ : {}),
1725
+ ...(result.error !== undefined
1726
+ ? { error: normalizeError(result.error) }
1727
+ : {}),
1728
+ ...(result.resumeToken !== undefined
1729
+ ? { resumeToken: result.resumeToken }
1730
+ : {}),
1731
+ },
1732
+ position,
1733
+ );
1734
+ return result;
1735
+ } finally {
1736
+ activePlayerIds.delete(playerId);
1737
+ }
1738
+ },
1739
+
1740
+ async callJudge(purpose, stateId, prompt, signal): Promise<string> {
1741
+ return judgeQueue.add(async () => {
1742
+ signal.throwIfAborted();
1743
+ // A transition/status queued synchronously by XState must reach
1744
+ // the host before the judge call that follows it.
1745
+ await drainEmissions();
1746
+ signal.throwIfAborted();
1747
+ const turnId = activeTurnId;
1748
+ const callId = `judge-${++judgeCallSequence}`;
1749
+ const identity = { purpose, ...stateIdentity(stateId) };
1750
+ const position: TracePosition = {
1751
+ ...(turnId !== undefined ? { turnId } : {}),
1752
+ callId,
1753
+ };
1754
+
1755
+ await emitCallStarted(
1756
+ 'judge.call.started',
1757
+ 'judge.call.finished',
1758
+ { ...identity, prompt },
1759
+ position,
1760
+ );
1761
+ let reply: unknown;
1762
+ try {
1763
+ reply = await requireHostPorts().callJudge(prompt, signal);
1764
+ signal.throwIfAborted();
1765
+ } catch (error) {
1766
+ if (!isAbortFailure(error, signal)) {
1767
+ controlPlaneError ??= error;
1768
+ }
1769
+ await emitTrace(
1770
+ 'judge.call.finished',
1771
+ {
1772
+ ...identity,
1773
+ status: signal.aborted ? 'aborted' : 'error',
1774
+ error: normalizeError(error),
1775
+ },
1776
+ position,
1777
+ );
1778
+ throw error;
1779
+ }
1780
+ if (typeof reply !== 'string') {
1781
+ const error = new TypeError('judge reply must be a string');
1782
+ controlPlaneError ??= error;
1783
+ await emitTrace(
1784
+ 'judge.call.finished',
1785
+ { ...identity, status: 'error', error: normalizeError(error) },
1786
+ position,
1787
+ );
1788
+ throw error;
1789
+ }
1790
+ // Keep the success finish outside the port-call catch. If a
1791
+ // telemetry sink records this boundary and then rejects, that sink
1792
+ // failure must not synthesize a second, contradictory finish.
1793
+ await emitTrace(
1794
+ 'judge.call.finished',
1795
+ { ...identity, status: 'ok', reply },
1796
+ position,
1797
+ );
1798
+ // The finish sink is part of the classifier boundary. A signal may
1799
+ // abort while that ordered emission drains; never let the already
1800
+ // classified event mutate the machine afterward.
1801
+ signal.throwIfAborted();
1802
+ return reply;
1803
+ }) as Promise<string>;
1804
+ },
1805
+
1806
+ async callCaptain(input, prompt, signal): Promise<CaptainResult> {
1807
+ return judgeQueue.add(async () => {
1808
+ signal.throwIfAborted();
1809
+ await drainEmissions();
1810
+ signal.throwIfAborted();
1811
+ const turnId = activeTurnId;
1812
+ const callId = `captain-${++captainCallSequence}`;
1813
+ const identity = {
1814
+ ...stateIdentity(input.stateId),
1815
+ sourceItem: input.sourceItem,
1816
+ visibility: 'visible' as const,
1817
+ resume: false as const,
1818
+ ...(input.allowedTools === undefined
1819
+ ? {}
1820
+ : { allowedTools: [...input.allowedTools] }),
1821
+ };
1822
+ const position: TracePosition = {
1823
+ ...(turnId !== undefined ? { turnId } : {}),
1824
+ callId,
1825
+ };
1826
+
1827
+ await emitCallStarted(
1828
+ 'captain.call.started',
1829
+ 'captain.call.finished',
1830
+ { ...identity, prompt },
1831
+ position,
1832
+ );
1833
+ let rawResult: unknown;
1834
+ try {
1835
+ rawResult = await requireHostPorts().callCaptain(prompt, signal, {
1836
+ visibility: 'visible',
1837
+ resume: false,
1838
+ ...(input.allowedTools !== undefined
1839
+ ? { allowedTools: input.allowedTools }
1840
+ : {}),
1841
+ });
1842
+ signal.throwIfAborted();
1843
+ } catch (error) {
1844
+ if (!isAbortFailure(error, signal)) controlPlaneError ??= error;
1845
+ await emitTrace(
1846
+ 'captain.call.finished',
1847
+ {
1848
+ ...identity,
1849
+ status: signal.aborted ? 'aborted' : 'error',
1850
+ error: normalizeError(error),
1851
+ },
1852
+ position,
1853
+ );
1854
+ throw error;
1855
+ }
1856
+ let result: CaptainResult;
1857
+ try {
1858
+ result = validateCaptainResult(rawResult);
1859
+ } catch (error) {
1860
+ controlPlaneError ??= error;
1861
+ await emitTrace(
1862
+ 'captain.call.finished',
1863
+ { ...identity, status: 'error', error: normalizeError(error) },
1864
+ position,
1865
+ );
1866
+ throw error;
1867
+ }
1868
+ // A non-`ok` host result is a recoverable FSM failure (PBRT-47), so
1869
+ // it is never latched as a control-plane error; it is still
1870
+ // authoritative for the actor's error path even when the required
1871
+ // finish emission fails or a coincident boundary abort lands.
1872
+ let resultFailure: Error | undefined;
1873
+ if (result.status !== 'ok') {
1874
+ resultFailure = markFsmResultFailure(
1875
+ new Error(
1876
+ result.error ??
1877
+ `captainActor: callCaptain status "${result.status}"`,
1878
+ ),
1879
+ );
1880
+ } else if (result.finalText === undefined || result.finalText === '') {
1881
+ resultFailure = markFsmResultFailure(
1882
+ new Error(
1883
+ 'captainActor: callCaptain returned status=ok with no finalText',
1884
+ ),
1885
+ );
1886
+ }
1887
+ try {
1888
+ await emitTrace(
1889
+ 'captain.call.finished',
1890
+ {
1891
+ ...identity,
1892
+ status: result.status,
1893
+ ...(result.finalText !== undefined
1894
+ ? { finalText: result.finalText }
1895
+ : {}),
1896
+ ...(result.error !== undefined
1897
+ ? { error: normalizeError(result.error) }
1898
+ : resultFailure !== undefined
1899
+ ? { error: normalizeError(resultFailure) }
1900
+ : {}),
1901
+ },
1902
+ position,
1903
+ );
1904
+ } catch (error) {
1905
+ // Keep the finish-sink failure in the emission queue for public
1906
+ // cleanup evidence, but do not replace an authoritative result
1907
+ // failure on the invoked actor's XState onError path.
1908
+ if (resultFailure !== undefined) throw resultFailure;
1909
+ throw error;
1910
+ }
1911
+ if (resultFailure !== undefined) {
1912
+ throw resultFailure;
1913
+ }
1914
+ return result;
1915
+ }) as Promise<CaptainResult>;
1916
+ },
1917
+ };
1918
+
1919
+ function resolvePlayerId(input: PlaybookPlayerInput): string {
1920
+ return resolvePlayerIdSpec
1921
+ ? resolvePlayerIdSpec(input, boundOptions)
1922
+ : defaultResolvePlayerId(input);
1923
+ }
1924
+
1925
+ function playerActor(
1926
+ ports: PlaybookPorts,
1927
+ ): PromiseActorLogic<PlaybookActorOutput, PlaybookPlayerInput> {
1928
+ return createPlayerBridge(
1929
+ {
1930
+ resolvePlayerId,
1931
+ composePlayerPrompt,
1932
+ adjudication,
1933
+ resumableStateIds,
1934
+ },
1935
+ ports,
1936
+ () => activeSignal,
1937
+ boundary,
1938
+ (error) => {
1939
+ if (!activeSignal?.aborted) controlPlaneError ??= error;
1940
+ },
1941
+ );
1942
+ }
1943
+
1944
+ // Direct-Captain actor (slc/link.md §Captain prompt composition,
1945
+ // §Captain adjudication): one visible callCaptain, then hidden judge
1946
+ // adjudication that injects the exact visible finalText as the selected
1947
+ // output's question/response.
1948
+ function captainActor(): PromiseActorLogic<
1949
+ PlaybookActorOutput,
1950
+ PlaybookCaptainInput
1951
+ > {
1952
+ return fromPromise<PlaybookActorOutput, PlaybookCaptainInput>(
1953
+ async ({ input, signal }) => {
1954
+ const active = combineAbortSignals(signal, activeSignal);
1955
+ try {
1956
+ await drainEmissions();
1957
+ const prompt = composeCaptainPrompt(input);
1958
+ const result = await boundary.callCaptain!(input, prompt, active);
1959
+ // The boundary owns result validation (PBRT-47) and throws the
1960
+ // authoritative failure itself, so a returned result is always
1961
+ // `ok` with visible text. Assert that invariant rather than
1962
+ // restating the failure semantics, which would drift.
1963
+ if (result.status !== 'ok' || !result.finalText) {
1964
+ throw new Error(
1965
+ 'captainActor: boundary returned an unvalidated Captain result',
1966
+ );
1967
+ }
1968
+ const judgePrompt = buildCaptainJudgePrompt(
1969
+ input,
1970
+ result.finalText,
1971
+ );
1972
+ const raw = await boundary.callJudge(
1973
+ 'captain-output-adjudication',
1974
+ input.stateId,
1975
+ judgePrompt,
1976
+ active,
1977
+ );
1978
+ const output = adjudicateCaptainOutput(
1979
+ extractFields,
1980
+ input,
1981
+ result.finalText,
1982
+ raw,
1983
+ );
1984
+ validateBossReplyOutput(input, output, resumableStateIds);
1985
+ return output;
1986
+ } catch (error) {
1987
+ // A host-reported Captain result failure routes to the FSM's
1988
+ // failure state (PBRT-47); everything else here — a drained
1989
+ // emission failure, prompt composition, the port itself,
1990
+ // adjudication — is control plane.
1991
+ if (!active.aborted && !isFsmResultFailure(error)) {
1992
+ controlPlaneError ??= error;
1993
+ }
1994
+ throw error;
1995
+ }
1996
+ },
1997
+ );
1998
+ }
1999
+
2000
+ // Deterministic script actor (slc/link.md §Script execution). Runs
2001
+ // `input.command` through `sh -c`, resolves the declared guard
2002
+ // mechanically from the exit status, and emits one status + one
2003
+ // `playbook.script` telemetry event. No agent call, no adjudication,
2004
+ // no `*.call.*` trace.
2005
+ function scriptActor(): PromiseActorLogic<
2006
+ PlaybookActorOutput,
2007
+ PlaybookScriptInput
2008
+ > {
2009
+ return fromPromise<PlaybookActorOutput, PlaybookScriptInput>(
2010
+ async ({ input, signal }) => {
2011
+ await drainEmissions();
2012
+ const active = combineAbortSignals(signal, activeSignal);
2013
+ const guards = Object.keys(input.result);
2014
+ const okGuard = guards[0];
2015
+ const failedGuard = guards[1] ?? guards[0];
2016
+ const cwd = boundScriptCwd ?? process.cwd();
2017
+ const ports = runtimePorts ?? requireHostPorts();
2018
+
2019
+ const exitStatus = await new Promise<number>((resolve, reject) => {
2020
+ let child: ReturnType<typeof spawn>;
2021
+ try {
2022
+ child = spawn('sh', ['-c', input.command], {
2023
+ cwd,
2024
+ stdio: 'ignore',
2025
+ });
2026
+ } catch (error) {
2027
+ reject(error);
2028
+ return;
2029
+ }
2030
+ const onAbort = (): void => {
2031
+ child.kill('SIGTERM');
2032
+ reject(active.reason ?? new Error('script aborted'));
2033
+ };
2034
+ if (active.aborted) {
2035
+ onAbort();
2036
+ return;
2037
+ }
2038
+ active.addEventListener('abort', onAbort, { once: true });
2039
+ child.on('error', (error) => {
2040
+ active.removeEventListener('abort', onAbort);
2041
+ reject(error);
2042
+ });
2043
+ child.on('close', (code) => {
2044
+ active.removeEventListener('abort', onAbort);
2045
+ resolve(typeof code === 'number' ? code : 1);
2046
+ });
2047
+ });
2048
+
2049
+ await ports.emitStatus(
2050
+ `Executed script for ${input.stateId} (exit ${exitStatus}).`,
2051
+ );
2052
+ await ports.emitTelemetry({
2053
+ topic: 'playbook.script',
2054
+ payload: {
2055
+ stateId: input.stateId,
2056
+ sourceItem: input.sourceItem,
2057
+ exitStatus,
2058
+ },
2059
+ });
2060
+
2061
+ if (exitStatus === 0) {
2062
+ return { guard: okGuard, exitStatus: 0 };
2063
+ }
2064
+ return { guard: failedGuard, exitStatus };
2065
+ },
2066
+ );
2067
+ }
2068
+
2069
+ const nestedBridge = createNestedPlaybookBridge({
2070
+ nextCallId: () => `playbook-${++playbookCallSequence}`,
2071
+ getBoundarySignal: () => activeSignal,
2072
+ callPlaybook: (request, signal) =>
2073
+ requireHostPorts().callPlaybook(request, signal),
2074
+ emitStarted: async (event) => {
2075
+ playbookCallTurnIds.set(event.callId, activeTurnId);
2076
+ await emitTrace(
2077
+ 'playbook.call.started',
2078
+ {
2079
+ stateId: event.stateId,
2080
+ playbookId: event.playbookId,
2081
+ text: event.text,
2082
+ },
2083
+ {
2084
+ ...(activeTurnId !== undefined ? { turnId: activeTurnId } : {}),
2085
+ callId: event.callId,
2086
+ },
2087
+ );
2088
+ },
2089
+ emitFinished: async (event) => {
2090
+ const turnId = playbookCallTurnIds.get(event.callId);
2091
+ try {
2092
+ await emitTrace(
2093
+ 'playbook.call.finished',
2094
+ {
2095
+ stateId: event.stateId,
2096
+ playbookId: event.playbookId,
2097
+ text: event.text,
2098
+ result: event.result,
2099
+ },
2100
+ {
2101
+ ...(turnId !== undefined ? { turnId } : {}),
2102
+ callId: event.callId,
2103
+ },
2104
+ );
2105
+ } finally {
2106
+ playbookCallTurnIds.delete(event.callId);
2107
+ }
2108
+ },
2109
+ drain: drainEmissions,
2110
+ bindResumeSignal: (signal) => {
2111
+ activeSignal = signal;
2112
+ },
2113
+ onControlPlaneError: (error) => {
2114
+ if (!activeSignal?.aborted) controlPlaneError ??= error;
2115
+ },
2116
+ onBackgroundError: (error) => {
2117
+ emissionFailure ??= error;
2118
+ },
2119
+ });
2120
+
2121
+ function tracePositionForActiveTurn(): TracePosition {
2122
+ return activeTurnId === undefined ? {} : { turnId: activeTurnId };
2123
+ }
2124
+
2125
+ function structuredStateTelemetryPayload(
2126
+ previousState: PlaybookState | undefined,
2127
+ state: PlaybookState,
2128
+ event: unknown,
2129
+ context: Record<string, unknown>,
2130
+ ): JsonValue {
2131
+ const payload: Record<string, unknown> = {
2132
+ from: previousState?.value ?? null,
2133
+ to: state.value,
2134
+ event: normalizeTransitionEvent(event) ?? null,
2135
+ previousState: previousState ?? null,
2136
+ state,
2137
+ };
2138
+ if (state.stateId === 'awaitBossReply') {
2139
+ const pendingBossQuestion = pendingBossQuestionFromContext(context);
2140
+ if (pendingBossQuestion !== undefined) {
2141
+ payload.pendingBossQuestion = pendingBossQuestion;
2142
+ }
2143
+ }
2144
+ if (state.stateId === 'failed') {
2145
+ const lastError = normalizeErrorFull(context.lastError);
2146
+ if (lastError !== undefined) payload.lastError = lastError;
2147
+ }
2148
+ return snapshotJsonValue(payload, 'FSM telemetry payload');
2149
+ }
2150
+
2151
+ function enqueueTransitionEmission(
2152
+ payload: JsonValue,
2153
+ state: PlaybookState,
2154
+ statuses: readonly ScheduledStatus[],
2155
+ position: TracePosition,
2156
+ ): void {
2157
+ const currentSession = requireSession();
2158
+ const transitionTrace = createTraceEvent(
2159
+ 'fsm.transition',
2160
+ payload,
2161
+ position,
2162
+ );
2163
+ const statusEmissions = statuses.map(({ message, data }) => ({
2164
+ message,
2165
+ data,
2166
+ trace: createTraceEvent(
2167
+ 'status.emitted',
2168
+ {
2169
+ message,
2170
+ ...(data === undefined ? {} : { data }),
2171
+ state,
2172
+ ...stateIdentity(state.stateId),
2173
+ },
2174
+ position,
2175
+ ),
2176
+ }));
2177
+ void enqueueEmission(async () => {
2178
+ await currentSession.ports.emitTelemetry({
2179
+ topic: 'playbook.trace',
2180
+ payload: transitionTrace,
2181
+ });
2182
+ await currentSession.ports.emitTelemetry({
2183
+ topic: 'playbook.fsm.state',
2184
+ payload,
2185
+ });
2186
+ for (const status of statusEmissions) {
2187
+ await currentSession.ports.emitTelemetry({
2188
+ topic: 'playbook.trace',
2189
+ payload: status.trace,
2190
+ });
2191
+ await currentSession.ports.emitStatus(status.message, status.data);
2192
+ }
2193
+ }).catch(() => undefined);
2194
+ }
2195
+
2196
+ function latchInspectionError(error: unknown): void {
2197
+ if (activeSignal !== undefined) controlPlaneError ??= error;
2198
+ else emissionFailure ??= error;
2199
+ }
2200
+
2201
+ function buildActor(
2202
+ ports: PlaybookPorts,
2203
+ machineSnapshot?: JsonValue,
2204
+ ): ReturnType<typeof createActor> {
2205
+ priorState = undefined;
2206
+ const actors: Record<string, unknown> = {};
2207
+ if (declaredActors.has('player')) actors.player = playerActor(ports);
2208
+ if (declaredActors.has('captain')) actors.captain = captainActor();
2209
+ if (declaredActors.has('script')) actors.script = scriptActor();
2210
+ if (declaredActors.has('playbook')) {
2211
+ actors.playbook = nestedBridge.actorLogic;
2212
+ }
2213
+ const provided = machine.provide({
2214
+ actors: actors as never,
2215
+ });
2216
+ let builtActor: ReturnType<typeof createActor>;
2217
+ builtActor = createActor(provided, {
2218
+ input: machineInput(boundOptions, requireSession()) as never,
2219
+ // DR-014 §1: a restore rehydrates the persisted machine snapshot;
2220
+ // XState derives context/value from it and ignores `input` then.
2221
+ ...(machineSnapshot === undefined
2222
+ ? {}
2223
+ : { snapshot: machineSnapshot as never }),
2224
+ inspect: (inspectionEvent: InspectionEvent) => {
2225
+ if (inspectionEvent.type !== '@xstate.snapshot') return;
2226
+ if (inspectionEvent.actorRef !== builtActor) return;
2227
+ if (suppressInspectionEmissions) return;
2228
+ try {
2229
+ const snap = inspectionEvent.snapshot;
2230
+ const state = normalizePlaybookSnapshot(snap);
2231
+ if (state.stateId === undefined) {
2232
+ throw new Error(
2233
+ `${label} root snapshot must expose exactly one playbook state id`,
2234
+ );
2235
+ }
2236
+ const previousState = priorState;
2237
+ const context = ((snap as { context?: unknown }).context ??
2238
+ {}) as Record<string, unknown>;
2239
+ const payload = structuredStateTelemetryPayload(
2240
+ previousState,
2241
+ state,
2242
+ inspectionEvent.event,
2243
+ context,
2244
+ );
2245
+ const statuses = statusesForState(
2246
+ state,
2247
+ context,
2248
+ inspectionEvent.event,
2249
+ );
2250
+ enqueueTransitionEmission(
2251
+ payload,
2252
+ state,
2253
+ statuses,
2254
+ tracePositionForActiveTurn(),
2255
+ );
2256
+ priorState = state;
2257
+ } catch (error) {
2258
+ latchInspectionError(error);
2259
+ }
2260
+ },
2261
+ });
2262
+ return builtActor;
2263
+ }
2264
+
2265
+ function runResultFor(
2266
+ outcome: BossSettlementOutcome,
2267
+ error?: unknown,
2268
+ ): PlaybookRunResult {
2269
+ const state = currentState();
2270
+ if (outcome === 'quiescent' || outcome === 'no-action') {
2271
+ return { outcome, state };
2272
+ }
2273
+ if (outcome === 'suspended') {
2274
+ const pendingCall = nestedBridge.getPendingCall();
2275
+ if (!pendingCall) {
2276
+ throw new Error('suspended runtime has no pending playbook call');
2277
+ }
2278
+ return { outcome, state, pendingCall };
2279
+ }
2280
+ if (outcome === 'terminal') {
2281
+ const output = (
2282
+ actor?.getSnapshot() as { output?: unknown } | undefined
2283
+ )?.output;
2284
+ if (output !== undefined) {
2285
+ return {
2286
+ outcome,
2287
+ state,
2288
+ output: snapshotJsonValue(output, 'terminal playbook output'),
2289
+ };
2290
+ }
2291
+ return { outcome, state };
2292
+ }
2293
+ const failure =
2294
+ error ??
2295
+ (outcome === 'failed'
2296
+ ? (actor?.getSnapshot() as { context?: { lastError?: unknown } })
2297
+ ?.context?.lastError
2298
+ : outcome === 'aborted'
2299
+ ? activeSignal?.reason
2300
+ : undefined);
2301
+ return {
2302
+ outcome,
2303
+ state,
2304
+ ...(failure !== undefined ? { error: normalizeError(failure) } : {}),
2305
+ };
2306
+ }
2307
+
2308
+ function settledOutcome(signal: AbortSignal): BossSettlementOutcome {
2309
+ if (nestedBridge.getPendingCall()) return 'suspended';
2310
+ if (signal.aborted) return 'aborted';
2311
+ const state = currentState();
2312
+ if (state.status === 'error') {
2313
+ const actorError = (
2314
+ actor?.getSnapshot() as { error?: unknown } | undefined
2315
+ )?.error;
2316
+ throw actorError ?? new Error(`${label} actor entered error status`);
2317
+ }
2318
+ if (state.status === 'done') return 'terminal';
2319
+ if (state.stateId === 'failed') return 'failed';
2320
+ return 'quiescent';
2321
+ }
2322
+
2323
+ function settlementTracePayload(
2324
+ result: PlaybookRunResult,
2325
+ ): Record<string, unknown> {
2326
+ return {
2327
+ ...result,
2328
+ ...stateIdentity(result.state.stateId),
2329
+ };
2330
+ }
2331
+
2332
+ // Shared failed-start cleanup for init and restore: stop the actor,
2333
+ // abort/drain nested and host work, optionally emit one best-effort
2334
+ // session.disposed boundary, and unbind every closure field so dispose
2335
+ // stays callable. The caller rethrows its original failure. A restore
2336
+ // failure skips the disposal trace — the parked session was never
2337
+ // re-bound in this process, so its persisted snapshot stays
2338
+ // authoritative (DR-014 §2).
2339
+ async function cleanupFailedStart(
2340
+ cause: unknown,
2341
+ options: { emitDisposal: boolean },
2342
+ ): Promise<void> {
2343
+ let finalState: PlaybookState | undefined;
2344
+ if (options.emitDisposal && actor) {
2345
+ try {
2346
+ finalState = currentState();
2347
+ } catch {
2348
+ // A state that cannot even normalize has no disposal descriptor.
2349
+ }
2350
+ }
2351
+ suppressInspectionEmissions = true;
2352
+ try {
2353
+ actor?.stop();
2354
+ } catch {
2355
+ // Preserve the original startup failure.
2356
+ }
2357
+ try {
2358
+ await nestedBridge.abortPending(cause);
2359
+ } catch {
2360
+ // Preserve the original startup failure.
2361
+ }
2362
+ try {
2363
+ await judgeQueue.onIdle();
2364
+ await drainEmissions();
2365
+ } catch {
2366
+ // Preserve the original startup failure.
2367
+ }
2368
+ if (options.emitDisposal) {
2369
+ try {
2370
+ await emitTrace(
2371
+ 'session.disposed',
2372
+ finalState === undefined
2373
+ ? {}
2374
+ : {
2375
+ state: finalState,
2376
+ ...stateIdentity(finalState.stateId),
2377
+ },
2378
+ );
2379
+ await drainEmissions();
2380
+ } catch {
2381
+ // The session-start error remains authoritative.
2382
+ }
2383
+ }
2384
+ playerResumeTokens.clear();
2385
+ activePlayerIds.clear();
2386
+ playbookCallTurnIds.clear();
2387
+ activeEmissionCalls.clear();
2388
+ emissionQueue.clear();
2389
+ judgeQueue.clear();
2390
+ actor = undefined;
2391
+ session = undefined;
2392
+ savedPorts = undefined;
2393
+ runtimePorts = undefined;
2394
+ activeSignal = undefined;
2395
+ activeTurnId = undefined;
2396
+ controlPlaneError = undefined;
2397
+ emissionFailure = undefined;
2398
+ priorState = undefined;
2399
+ suppressInspectionEmissions = false;
2400
+ initialized = false;
2401
+ traceSequence = 0;
2402
+ turnSequence = 0;
2403
+ judgeCallSequence = 0;
2404
+ playerCallSequence = 0;
2405
+ playbookCallSequence = 0;
2406
+ captainCallSequence = 0;
2407
+ }
2408
+
2409
+ const runtime = {
2410
+ async init(nextSession: PlaybookSession): Promise<void> {
2411
+ if (initialized || disposed || disposalPromise !== undefined) {
2412
+ throw new Error('createPlaybookRuntime.init: already initialized');
2413
+ }
2414
+ const boundSession = snapshotPlaybookSession(nextSession);
2415
+ initialized = true;
2416
+ let finishInitialization!: () => void;
2417
+ const initialization = new Promise<void>((resolve) => {
2418
+ finishInitialization = resolve;
2419
+ });
2420
+ initInFlight = initialization;
2421
+ const initTask = (async () => {
2422
+ session = boundSession;
2423
+ savedPorts = boundSession.ports;
2424
+ runtimePorts = createRuntimePorts(boundSession.ports);
2425
+ suppressInspectionEmissions = false;
2426
+ actor = buildActor(runtimePorts);
2427
+ await emitTrace('session.started', stateTracePayload());
2428
+ actor.start();
2429
+ await drainEmissions();
2430
+ })();
2431
+ try {
2432
+ await initTask;
2433
+ } catch (error) {
2434
+ await cleanupFailedStart(error, { emitDisposal: true });
2435
+ throw error;
2436
+ } finally {
2437
+ finishInitialization();
2438
+ if (initInFlight === initialization) initInFlight = undefined;
2439
+ }
2440
+ },
2441
+
2442
+ // DR-014 §1 / PBRT-45: JSON-safe capture of a parked session.
2443
+ // Defined only at a safe capture point — initialized, not disposing
2444
+ // or disposed, no active public boundary, no pending nested call,
2445
+ // and the actor quiescent with status `active`.
2446
+ exportSnapshot(): PlaybookRuntimeSnapshot | undefined {
2447
+ if (!actor || !session || disposed || disposalPromise !== undefined) {
2448
+ return undefined;
2449
+ }
2450
+ if (activeSignal !== undefined) return undefined;
2451
+ if (nestedBridge.getPendingCall()) return undefined;
2452
+ const state = currentState();
2453
+ if (state.status !== 'active' || !state.quiescent) return undefined;
2454
+ const machineSnapshot = detachPersistedMachineSnapshot(
2455
+ actor.getPersistedSnapshot(),
2456
+ );
2457
+ const context = (actor.getSnapshot() as { context?: unknown })
2458
+ .context as Record<string, unknown>;
2459
+ const pending = pendingBossQuestionFromContext(context ?? {});
2460
+ return {
2461
+ schemaVersion: 1,
2462
+ playbookId: session.playbookId,
2463
+ machine: machineSnapshot,
2464
+ playerResumeTokens: Object.fromEntries(playerResumeTokens),
2465
+ sequences: {
2466
+ trace: traceSequence,
2467
+ turn: turnSequence,
2468
+ judgeCall: judgeCallSequence,
2469
+ playerCall: playerCallSequence,
2470
+ playbookCall: playbookCallSequence,
2471
+ ...(declaredActors.has('captain')
2472
+ ? { captainCall: captainCallSequence }
2473
+ : {}),
2474
+ },
2475
+ state,
2476
+ pendingBossQuestions:
2477
+ pending === undefined
2478
+ ? []
2479
+ : [
2480
+ {
2481
+ questionId: pending.questionId,
2482
+ player: pending.player,
2483
+ question: pending.question,
2484
+ sourceItem: pending.sourceItem,
2485
+ },
2486
+ ],
2487
+ };
2488
+ },
2489
+
2490
+ // DR-014 §1 / PBRT-45: alternative to `init` that rehydrates an
2491
+ // exported snapshot under the same immutable session identity.
2492
+ // Emits no `session.started`, transition trace, or human status —
2493
+ // the session already started; the next public boundary continues
2494
+ // the contiguous trace sequence.
2495
+ async restore(
2496
+ nextSession: PlaybookSession,
2497
+ snapshot: PlaybookRuntimeSnapshot,
2498
+ ): Promise<void> {
2499
+ if (initialized || disposed || disposalPromise !== undefined) {
2500
+ throw new Error('createPlaybookRuntime.restore: already initialized');
2501
+ }
2502
+ const boundSession = snapshotPlaybookSession(nextSession);
2503
+ const boundSnapshot = assertPlaybookRuntimeSnapshot(
2504
+ snapshot,
2505
+ boundSession.playbookId,
2506
+ );
2507
+ initialized = true;
2508
+ let finishInitialization!: () => void;
2509
+ const initialization = new Promise<void>((resolve) => {
2510
+ finishInitialization = resolve;
2511
+ });
2512
+ initInFlight = initialization;
2513
+ const initTask = (async () => {
2514
+ session = boundSession;
2515
+ savedPorts = boundSession.ports;
2516
+ runtimePorts = createRuntimePorts(boundSession.ports);
2517
+ traceSequence = boundSnapshot.sequences.trace;
2518
+ turnSequence = boundSnapshot.sequences.turn;
2519
+ judgeCallSequence = boundSnapshot.sequences.judgeCall;
2520
+ playerCallSequence = boundSnapshot.sequences.playerCall;
2521
+ playbookCallSequence = boundSnapshot.sequences.playbookCall;
2522
+ captainCallSequence =
2523
+ boundSnapshot.sequences.captainCall ??
2524
+ // Legacy schema-v1 snapshots predate this dedicated counter.
2525
+ // Every Captain call already consumed at least one trace number,
2526
+ // so the global trace counter is a collision-safe id floor.
2527
+ boundSnapshot.sequences.trace;
2528
+ playerResumeTokens.clear();
2529
+ for (const [playerId, token] of Object.entries(
2530
+ boundSnapshot.playerResumeTokens,
2531
+ )) {
2532
+ playerResumeTokens.set(playerId, token);
2533
+ }
2534
+ suppressInspectionEmissions = true;
2535
+ actor = buildActor(runtimePorts, boundSnapshot.machine);
2536
+ actor.start();
2537
+ const restoredState = currentState();
2538
+ if (restoredState.status !== 'active') {
2539
+ throw new Error(
2540
+ `createPlaybookRuntime.restore: restored actor status is ${restoredState.status}, expected active`,
2541
+ );
2542
+ }
2543
+ suppressInspectionEmissions = false;
2544
+ priorState = restoredState;
2545
+ await drainEmissions();
2546
+ })();
2547
+ try {
2548
+ await initTask;
2549
+ } catch (error) {
2550
+ await cleanupFailedStart(error, { emitDisposal: false });
2551
+ throw error;
2552
+ } finally {
2553
+ finishInitialization();
2554
+ if (initInFlight === initialization) initInFlight = undefined;
2555
+ }
2556
+ },
2557
+
2558
+ async handleBossInput({
2559
+ text,
2560
+ signal,
2561
+ }: {
2562
+ text: string;
2563
+ signal: AbortSignal;
2564
+ }): Promise<PlaybookRunResult> {
2565
+ if (!actor || !savedPorts) {
2566
+ throw new Error(
2567
+ 'createPlaybookRuntime.handleBossInput: init must be called first',
2568
+ );
2569
+ }
2570
+ if (disposed || disposalPromise !== undefined) {
2571
+ throw new Error(
2572
+ 'createPlaybookRuntime.handleBossInput: runtime is disposing or disposed',
2573
+ );
2574
+ }
2575
+ if (activeSignal !== undefined) {
2576
+ throw new Error(
2577
+ 'createPlaybookRuntime.handleBossInput: another runtime turn is active',
2578
+ );
2579
+ }
2580
+ const turnId = ++turnSequence;
2581
+ activeTurnId = turnId;
2582
+ activeSignal = signal;
2583
+ controlPlaneError = undefined;
2584
+ let result: PlaybookRunResult | undefined;
2585
+ let operationError: unknown;
2586
+ try {
2587
+ await emitTrace('boss.input.received', { text }, { turnId });
2588
+ // 1. Map the Boss text to an FSM event: deterministic exact entry
2589
+ // where applicable (slc/link.md §Boss-event mapping), judge
2590
+ // classification otherwise.
2591
+ let event: EventObject | undefined;
2592
+ const trimmed = text.trim();
2593
+ if (trimmed !== '') {
2594
+ const snapshot = actor.getSnapshot();
2595
+ const terminal = snapshot.status === 'done';
2596
+ const stateId = normalizePlaybookSnapshot(snapshot).stateId;
2597
+ if (
2598
+ spec.entryEvent !== undefined &&
2599
+ (stateId === 'ready' || terminal)
2600
+ ) {
2601
+ event = {
2602
+ type: spec.entryEvent.type,
2603
+ [spec.entryEvent.textField]: text,
2604
+ };
2605
+ } else {
2606
+ event = await classifyBossText(
2607
+ text,
2608
+ runtimePorts!,
2609
+ signal,
2610
+ snapshot,
2611
+ boundary,
2612
+ );
2613
+ }
2614
+ signal.throwIfAborted();
2615
+ }
2616
+ // Empty input, no-action classifier output, or invalid classifier
2617
+ // output — nothing to send.
2618
+ if (event === undefined) {
2619
+ result = runResultFor('no-action');
2620
+ } else {
2621
+ // 2. Optional Captain-pane classification line: the bare FSM
2622
+ // event type, emitted before the FSM advances.
2623
+ const statusLine = spec.classificationStatus?.(event);
2624
+ if (statusLine !== undefined) {
2625
+ await runtimePorts!.emitStatus(statusLine);
2626
+ }
2627
+ signal.throwIfAborted();
2628
+ // 3. A final actor cannot accept new events; reconstruct only
2629
+ // after classification produced a real event.
2630
+ if (actor.getSnapshot().status === 'done') {
2631
+ actor.stop();
2632
+ actor = buildActor(runtimePorts!);
2633
+ actor.start();
2634
+ }
2635
+ actor.send(event);
2636
+ await waitForPlaybookQuiescence(actor, {
2637
+ pendingCalls: nestedBridge,
2638
+ });
2639
+ if (controlPlaneError !== undefined) throw controlPlaneError;
2640
+ result = runResultFor(settledOutcome(signal));
2641
+ }
2642
+ } catch (error) {
2643
+ operationError = error;
2644
+ }
2645
+
2646
+ let drainError: unknown;
2647
+ try {
2648
+ await drainEmissions();
2649
+ } catch (error) {
2650
+ drainError = error;
2651
+ }
2652
+ const latchedControlError = controlPlaneError;
2653
+ const primaryError =
2654
+ latchedControlError ?? drainError ?? operationError;
2655
+ const abortError =
2656
+ latchedControlError === undefined &&
2657
+ drainError === undefined &&
2658
+ operationError !== undefined &&
2659
+ isAbortFailure(operationError, signal);
2660
+ const settlementResult =
2661
+ primaryError === undefined
2662
+ ? (result ?? runResultFor('no-action'))
2663
+ : runResultFor(abortError ? 'aborted' : 'failed', primaryError);
2664
+
2665
+ let settlementEmissionError: unknown;
2666
+ try {
2667
+ await emitTrace(
2668
+ 'boss.input.settled',
2669
+ settlementTracePayload(settlementResult),
2670
+ { turnId },
2671
+ );
2672
+ } catch (error) {
2673
+ settlementEmissionError = error;
2674
+ }
2675
+ try {
2676
+ await drainEmissions();
2677
+ } catch (error) {
2678
+ settlementEmissionError ??= error;
2679
+ }
2680
+ const failure =
2681
+ controlPlaneError ??
2682
+ latchedControlError ??
2683
+ drainError ??
2684
+ (abortError
2685
+ ? (settlementEmissionError ?? operationError)
2686
+ : (operationError ?? settlementEmissionError));
2687
+ activeSignal = undefined;
2688
+ activeTurnId = undefined;
2689
+ controlPlaneError = undefined;
2690
+
2691
+ if (
2692
+ failure !== undefined &&
2693
+ !(abortError && settlementEmissionError === undefined)
2694
+ ) {
2695
+ throw failure;
2696
+ }
2697
+ return settlementResult;
2698
+ },
2699
+
2700
+ async resumePlaybookCall(input: {
2701
+ callId: string;
2702
+ result: PlaybookCallResult;
2703
+ signal: AbortSignal;
2704
+ }): Promise<PlaybookRunResult> {
2705
+ if (!actor || !savedPorts) {
2706
+ throw new Error(
2707
+ 'createPlaybookRuntime.resumePlaybookCall: init must be called first',
2708
+ );
2709
+ }
2710
+ if (disposed || disposalPromise !== undefined) {
2711
+ throw new Error(
2712
+ 'createPlaybookRuntime.resumePlaybookCall: runtime is disposing or disposed',
2713
+ );
2714
+ }
2715
+ if (activeSignal !== undefined) {
2716
+ throw new Error(
2717
+ 'createPlaybookRuntime.resumePlaybookCall: another runtime turn is active',
2718
+ );
2719
+ }
2720
+ activeTurnId = playbookCallTurnIds.get(input.callId);
2721
+ activeSignal = input.signal;
2722
+ controlPlaneError = undefined;
2723
+ let result: PlaybookRunResult | undefined;
2724
+ let operationError: unknown;
2725
+ try {
2726
+ await nestedBridge.resume(input);
2727
+ } catch (error) {
2728
+ operationError = error;
2729
+ }
2730
+ try {
2731
+ await waitForPlaybookQuiescence(actor, {
2732
+ pendingCalls: nestedBridge,
2733
+ });
2734
+ result = runResultFor(settledOutcome(input.signal));
2735
+ } catch (error) {
2736
+ operationError ??= error;
2737
+ }
2738
+ let drainError: unknown;
2739
+ try {
2740
+ await drainEmissions();
2741
+ } catch (error) {
2742
+ drainError = error;
2743
+ }
2744
+ const failure = controlPlaneError ?? drainError ?? operationError;
2745
+ activeSignal = undefined;
2746
+ activeTurnId = undefined;
2747
+ controlPlaneError = undefined;
2748
+ if (failure !== undefined) throw failure;
2749
+ if (result === undefined) {
2750
+ throw new Error('playbook resume produced no runtime result');
2751
+ }
2752
+ return result;
2753
+ },
2754
+
2755
+ dispose(): Promise<void> {
2756
+ if (disposalPromise !== undefined) return disposalPromise;
2757
+ if (disposed) return Promise.resolve();
2758
+ if (activeSignal !== undefined) {
2759
+ return Promise.reject(
2760
+ new Error(
2761
+ 'createPlaybookRuntime.dispose: cannot dispose during an active runtime boundary',
2762
+ ),
2763
+ );
2764
+ }
2765
+ const task = (async (): Promise<void> => {
2766
+ const failures: unknown[] = [];
2767
+ try {
2768
+ if (initInFlight !== undefined) {
2769
+ try {
2770
+ await initInFlight;
2771
+ } catch {
2772
+ // Dispose still releases whatever an unsuccessful init bound.
2773
+ }
2774
+ }
2775
+ const finalState = actor ? currentState() : undefined;
2776
+ // Stop the root before settling a suspended child. Its rejection
2777
+ // must not re-enter the FSM and start fresh work during disposal.
2778
+ if (actor) actor.stop();
2779
+ try {
2780
+ await nestedBridge.dispose();
2781
+ } catch (error) {
2782
+ failures.push(error);
2783
+ }
2784
+ try {
2785
+ await drainEmissions();
2786
+ } catch (error) {
2787
+ failures.push(error);
2788
+ }
2789
+ if (session !== undefined) {
2790
+ try {
2791
+ await emitTrace(
2792
+ 'session.disposed',
2793
+ finalState === undefined
2794
+ ? {}
2795
+ : {
2796
+ state: finalState,
2797
+ ...stateIdentity(finalState.stateId),
2798
+ },
2799
+ );
2800
+ await drainEmissions();
2801
+ } catch (error) {
2802
+ failures.push(error);
2803
+ }
2804
+ }
2805
+ } finally {
2806
+ playerResumeTokens.clear();
2807
+ activePlayerIds.clear();
2808
+ playbookCallTurnIds.clear();
2809
+ activeEmissionCalls.clear();
2810
+ emissionQueue.clear();
2811
+ judgeQueue.clear();
2812
+ actor = undefined;
2813
+ activeSignal = undefined;
2814
+ activeTurnId = undefined;
2815
+ controlPlaneError = undefined;
2816
+ emissionFailure = undefined;
2817
+ savedPorts = undefined;
2818
+ runtimePorts = undefined;
2819
+ session = undefined;
2820
+ disposed = true;
2821
+ }
2822
+ if (failures.length === 1) throw failures[0];
2823
+ if (failures.length > 1) {
2824
+ throw new AggregateError(
2825
+ failures,
2826
+ 'playbook runtime disposal failed',
2827
+ );
2828
+ }
2829
+ })();
2830
+ disposalPromise = task;
2831
+ return task;
2832
+ },
2833
+
2834
+ // @internal — test-only escape hatches for inspecting the underlying
2835
+ // actor, traced boundary, and nested bridge. Not part of the stable
2836
+ // public runtime contract.
2837
+ _getActor() {
2838
+ return actor;
2839
+ },
2840
+ _getBoundary() {
2841
+ return boundary;
2842
+ },
2843
+ _getNestedBridge() {
2844
+ return nestedBridge;
2845
+ },
2846
+ };
2847
+ return runtime as PlaybookRuntime;
2848
+ };
2849
+ }