@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,2099 @@
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
+ import { spawn } from 'node:child_process';
18
+ import PQueue from 'p-queue';
19
+ import { createActor, fromPromise } from 'xstate';
20
+ import { assertPlaybookRuntimeSnapshot, combineAbortSignals, createNestedPlaybookBridge, detachPersistedMachineSnapshot, normalizeError, normalizePlaybookSnapshot, snapshotJsonValue, snapshotPlaybookSession, validateCaptainResult, validatePlayerResult, waitForPlaybookQuiescence, } from './xstate-runtime.js';
21
+ export const BOSS_REPLY_ERRORS = {
22
+ missingQuestion: "needsBossReply outcome missing 'question' field",
23
+ unregisteredState: (stateId) => `state ${stateId} declared needsBossReply but is not registered as resumable`,
24
+ };
25
+ // ---------------------------------------------------------------------------
26
+ // A host agent result that is not `ok` (or is `ok` with no final text) is a
27
+ // recoverable FSM failure, not a control-plane error: it travels the invoked
28
+ // actor's XState error path to the failure state and the public boundary
29
+ // resolves `failed` (PBRT-47, matching the player boundary's PBRT-9). The
30
+ // direct-Captain boundary has to emit its paired finish trace before
31
+ // rethrowing, so it needs to tell that failure apart from the control-plane
32
+ // errors it does latch — a thrown port, a malformed result, a rejecting sink.
33
+ // ---------------------------------------------------------------------------
34
+ const fsmResultFailures = new WeakSet();
35
+ function markFsmResultFailure(error) {
36
+ fsmResultFailures.add(error);
37
+ return error;
38
+ }
39
+ function isFsmResultFailure(error) {
40
+ return (typeof error === 'object' &&
41
+ error !== null &&
42
+ fsmResultFailures.has(error));
43
+ }
44
+ // ---------------------------------------------------------------------------
45
+ // Tolerant judge-JSON recovery (slc/link.md §Boss-event mapping).
46
+ // ---------------------------------------------------------------------------
47
+ function isPlainObject(value) {
48
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
49
+ }
50
+ /** Strip a single Markdown code fence that wraps the whole string. */
51
+ export function stripCodeFence(text) {
52
+ const fence = text.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/);
53
+ return fence ? fence[1].trim() : text;
54
+ }
55
+ function dropTrailingComma(out) {
56
+ return out.replace(/,(\s*)$/, '$1');
57
+ }
58
+ // Scan from `start` (a `{`/`[` index), tracking string and bracket-nesting
59
+ // state, and emit the balanced JSON value rooted there. With `repair` false
60
+ // the span is returned only if it actually closes; with `repair` true a
61
+ // trailing comma, an unterminated string, and unclosed brackets are fixed.
62
+ export function extractJsonValue(text, start, repair) {
63
+ const stack = [];
64
+ let out = '';
65
+ let inString = false;
66
+ let escaped = false;
67
+ for (let i = start; i < text.length; i++) {
68
+ const ch = text[i];
69
+ if (inString) {
70
+ out += ch;
71
+ if (escaped)
72
+ escaped = false;
73
+ else if (ch === '\\')
74
+ escaped = true;
75
+ else if (ch === '"')
76
+ inString = false;
77
+ continue;
78
+ }
79
+ if (ch === '"') {
80
+ inString = true;
81
+ out += ch;
82
+ continue;
83
+ }
84
+ if (ch === '{' || ch === '[') {
85
+ stack.push(ch === '{' ? '}' : ']');
86
+ out += ch;
87
+ continue;
88
+ }
89
+ if (ch === '}' || ch === ']') {
90
+ if (repair)
91
+ out = dropTrailingComma(out);
92
+ out += ch;
93
+ stack.pop();
94
+ if (stack.length === 0)
95
+ return out; // top-level value complete
96
+ continue;
97
+ }
98
+ out += ch;
99
+ }
100
+ // End of input before the top-level value closed.
101
+ if (!repair)
102
+ return undefined; // strict pass: no balanced span here
103
+ if (inString)
104
+ out += '"';
105
+ out = dropTrailingComma(out);
106
+ while (stack.length > 0)
107
+ out += stack.pop();
108
+ return out;
109
+ }
110
+ // Tolerant recovery shared by the classifier and adjudicator: prefer a strict
111
+ // balanced span at the earliest opening brace, then its repair, before
112
+ // advancing to a later candidate. The first plain object wins; the first
113
+ // value of any shape is remembered so a legitimately array/scalar reply still
114
+ // surfaces to the caller's own object check.
115
+ export function parseJudgeJson(raw) {
116
+ const fenced = stripCodeFence(raw.trim());
117
+ // Fast path: a well-formed (optionally fenced) JSON body.
118
+ try {
119
+ return JSON.parse(fenced);
120
+ }
121
+ catch {
122
+ // Fall through to lenient extraction + repair.
123
+ }
124
+ const starts = [];
125
+ for (let i = 0; i < fenced.length; i++) {
126
+ const ch = fenced[i];
127
+ if (ch === '{' || ch === '[')
128
+ starts.push(i);
129
+ }
130
+ let firstValue;
131
+ for (const start of starts) {
132
+ let parsedHere;
133
+ for (const repair of [false, true]) {
134
+ const candidate = extractJsonValue(fenced, start, repair);
135
+ if (candidate === undefined)
136
+ continue;
137
+ try {
138
+ parsedHere = { value: JSON.parse(candidate) };
139
+ }
140
+ catch {
141
+ continue; // not parseable this way — try repair, then next start
142
+ }
143
+ break; // prefer the strict span at this start over its repair
144
+ }
145
+ if (parsedHere === undefined)
146
+ continue;
147
+ if (isPlainObject(parsedHere.value))
148
+ return parsedHere.value;
149
+ if (firstValue === undefined)
150
+ firstValue = parsedHere;
151
+ }
152
+ if (firstValue !== undefined)
153
+ return firstValue.value;
154
+ throw new Error('adjudicate: judge response is not valid JSON');
155
+ }
156
+ // ---------------------------------------------------------------------------
157
+ // Shared error/context helpers.
158
+ // ---------------------------------------------------------------------------
159
+ export function normalizeErrorCompact(err) {
160
+ if (err === undefined || err === null)
161
+ return undefined;
162
+ const normalized = normalizeError(err);
163
+ return { name: normalized.name, message: normalized.message };
164
+ }
165
+ export function normalizeErrorFull(err) {
166
+ if (err === undefined || err === null)
167
+ return undefined;
168
+ return normalizeError(err);
169
+ }
170
+ function isAbortFailure(error, signal) {
171
+ return (signal.aborted &&
172
+ (error === signal.reason || normalizeError(error).name === 'AbortError'));
173
+ }
174
+ /** Read the FSM context's single pending Boss question, when well-formed. */
175
+ export function pendingBossQuestionFromContext(context) {
176
+ const pending = context.pendingBossQuestion;
177
+ if (pending === undefined ||
178
+ pending === null ||
179
+ typeof pending !== 'object') {
180
+ return undefined;
181
+ }
182
+ const candidate = pending;
183
+ if (typeof candidate.questionId !== 'string' ||
184
+ typeof candidate.resumeStateId !== 'string' ||
185
+ typeof candidate.sourceItem !== 'string' ||
186
+ typeof candidate.player !== 'string' ||
187
+ typeof candidate.question !== 'string') {
188
+ return undefined;
189
+ }
190
+ return {
191
+ questionId: candidate.questionId,
192
+ resumeStateId: candidate.resumeStateId,
193
+ sourceItem: candidate.sourceItem,
194
+ player: candidate.player,
195
+ question: candidate.question,
196
+ };
197
+ }
198
+ // ---------------------------------------------------------------------------
199
+ // Generic strategy defaults.
200
+ // ---------------------------------------------------------------------------
201
+ const CONTINUATION_PREAMBLE = 'You previously paused this task to ask Boss a question; Boss has now replied. Continue the same task using the reply below.';
202
+ function continuationBlocks(input) {
203
+ if (input.pendingBossQuestion === undefined || input.bossReply === undefined) {
204
+ return [];
205
+ }
206
+ return [
207
+ CONTINUATION_PREAMBLE,
208
+ `Boss question:\n${input.pendingBossQuestion.question}`,
209
+ `Boss reply:\n${input.bossReply}`,
210
+ ];
211
+ }
212
+ const PLACEHOLDER_PATTERN = /<(#|[A-Za-z_$][A-Za-z0-9_$-]*)>/g;
213
+ function placeholderFieldName(token, fields) {
214
+ const explicit = fields[token];
215
+ if (explicit !== undefined)
216
+ return explicit;
217
+ if (token === '#')
218
+ return 'irNumber';
219
+ return token.replace(/-([A-Za-z0-9])/g, (_match, next) => next.toUpperCase());
220
+ }
221
+ /**
222
+ * Default player-prompt composer (slc/link.md §Player prompt composition).
223
+ * One callback-based pass substitutes each `<fieldName>` placeholder whose
224
+ * typed input field is a string; replacement text is literal, and
225
+ * placeholder-looking text inside a value is never re-substituted. The
226
+ * continuation preamble and Q/A blocks precede the domain body on resume.
227
+ */
228
+ export function defaultComposePlayerPrompt(input, placeholderFields = {}) {
229
+ const blocks = continuationBlocks(input);
230
+ const fields = input;
231
+ const body = input.prompt.replace(PLACEHOLDER_PATTERN, (match, token) => {
232
+ const value = fields[placeholderFieldName(token, placeholderFields)];
233
+ return typeof value === 'string' ? value : match;
234
+ });
235
+ blocks.push(body);
236
+ return blocks.join('\n\n');
237
+ }
238
+ function sortJson(value) {
239
+ if (Array.isArray(value))
240
+ return value.map((entry) => sortJson(entry));
241
+ if (value !== null && typeof value === 'object') {
242
+ const record = value;
243
+ const sorted = {};
244
+ for (const key of Object.keys(record).sort()) {
245
+ sorted[key] = sortJson(record[key]);
246
+ }
247
+ return sorted;
248
+ }
249
+ return value;
250
+ }
251
+ function stableJson(value, path) {
252
+ return JSON.stringify(sortJson(snapshotJsonValue(value, path)));
253
+ }
254
+ /**
255
+ * Default direct-Captain prompt composer (slc/link.md §Captain prompt
256
+ * composition). Placeholder substitution is presence-based: string fields
257
+ * substitute verbatim; JSON-safe arrays/objects render as deterministic JSON
258
+ * with lexicographically sorted keys at every depth.
259
+ */
260
+ export function defaultComposeCaptainPrompt(input, placeholderFields = {}) {
261
+ const blocks = continuationBlocks(input);
262
+ const fields = input;
263
+ const body = input.prompt.replace(PLACEHOLDER_PATTERN, (match, token) => {
264
+ const field = placeholderFieldName(token, placeholderFields);
265
+ const value = fields[field];
266
+ if (typeof value === 'string')
267
+ return value;
268
+ if (value !== null && typeof value === 'object') {
269
+ return stableJson(value, `CaptainInput.${field}`);
270
+ }
271
+ return match;
272
+ });
273
+ blocks.push(body);
274
+ return blocks.join('\n\n');
275
+ }
276
+ /** Default player binding: each player to its lowercased name. */
277
+ export function defaultResolvePlayerId(input) {
278
+ return input.player.toLowerCase();
279
+ }
280
+ /**
281
+ * Default required-field extraction (slc/link.md §Captain adjudication).
282
+ * Limited to the description's `Output shall include` / `输出应包含` clause;
283
+ * recognizes both the bare backticked name and the annotated `name: <...>`
284
+ * form.
285
+ */
286
+ export function defaultExtractRequiredFields(description) {
287
+ const markers = ['Output shall include', '输出应包含'];
288
+ let clauseStart = -1;
289
+ for (const marker of markers) {
290
+ const idx = description.indexOf(marker);
291
+ if (idx !== -1) {
292
+ clauseStart = idx + marker.length;
293
+ break;
294
+ }
295
+ }
296
+ if (clauseStart === -1)
297
+ return [];
298
+ const clause = description.slice(clauseStart);
299
+ const fields = [];
300
+ const re = /`([A-Za-z_$][A-Za-z0-9_$]*)(?::[^`]*)?`/g;
301
+ for (const m of clause.matchAll(re))
302
+ fields.push(m[1]);
303
+ return fields;
304
+ }
305
+ /** Default delegated-player adjudicator prompt. */
306
+ export function defaultBuildJudgePrompt(input, finalText) {
307
+ const lines = [];
308
+ lines.push(`The ${input.player} just produced this output:`);
309
+ lines.push('');
310
+ lines.push('```');
311
+ lines.push(finalText);
312
+ lines.push('```');
313
+ lines.push('');
314
+ lines.push('Pick exactly one outcome by `guard` and return JSON ' +
315
+ '`{ guard, …payloadFields }`. Required payload fields are named in the ' +
316
+ 'outcome description after "Output shall include" / "输出应包含".');
317
+ lines.push('');
318
+ for (const [key, description] of Object.entries(input.result)) {
319
+ lines.push(`- \`${key}\` — ${description}`);
320
+ }
321
+ return lines.join('\n');
322
+ }
323
+ const NO_VERBATIM_FIELDS = new Set();
324
+ /**
325
+ * LLM-judge adjudicator for delegated players. Coerces the player's
326
+ * finalText into one of the state's declared guards, extracts every required
327
+ * payload field from the judge reply, and fails loudly (throws) on a missing
328
+ * JSON object, an undeclared guard, or a missing required field. Fields in
329
+ * `verbatimPayloadFields` carry `finalText.trim()` rather than round-tripping
330
+ * long-form prose through judge JSON.
331
+ */
332
+ export async function adjudicatePlayerOutput(spec, input, finalText, ports, signal, boundary) {
333
+ const buildPrompt = spec.buildJudgePrompt ?? defaultBuildJudgePrompt;
334
+ const extractFields = spec.extractRequiredFields ?? defaultExtractRequiredFields;
335
+ const verbatimFields = spec.verbatimPayloadFields ?? NO_VERBATIM_FIELDS;
336
+ const prompt = buildPrompt(input, finalText);
337
+ const raw = boundary
338
+ ? await boundary.callJudge('player-output-adjudication', input.stateId, prompt, signal)
339
+ : await ports.callJudge(prompt, signal);
340
+ const parsed = parseJudgeJson(raw);
341
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
342
+ throw new Error('adjudicate: judge response is not a JSON object');
343
+ }
344
+ const obj = parsed;
345
+ const guard = obj.guard;
346
+ if (typeof guard !== 'string') {
347
+ throw new Error('adjudicate: judge response missing string "guard" field');
348
+ }
349
+ if (!Object.prototype.hasOwnProperty.call(input.result, guard)) {
350
+ throw new Error(`adjudicate: unknown guard "${guard}" — declared guards: ${Object.keys(input.result).join(', ')}`);
351
+ }
352
+ const verbatim = finalText.trim();
353
+ for (const field of extractFields(input.result[guard])) {
354
+ if (verbatimFields.has(field)) {
355
+ obj[field] = verbatim;
356
+ continue;
357
+ }
358
+ if (typeof obj[field] !== 'string') {
359
+ if (guard === 'needsBossReply' && field === 'question') {
360
+ throw new Error(BOSS_REPLY_ERRORS.missingQuestion);
361
+ }
362
+ throw new Error(`adjudicate: judge response missing required field "${field}" for guard "${guard}"`);
363
+ }
364
+ }
365
+ return obj;
366
+ }
367
+ function validateBossReplyOutput(input, output, resumableStateIds) {
368
+ if (output.guard !== 'needsBossReply')
369
+ return;
370
+ if (typeof output.question !== 'string') {
371
+ throw new Error(BOSS_REPLY_ERRORS.missingQuestion);
372
+ }
373
+ if (!resumableStateIds.has(input.stateId)) {
374
+ throw new Error(BOSS_REPLY_ERRORS.unregisteredState(input.stateId));
375
+ }
376
+ }
377
+ export function createPlayerBridge(spec, ports, getActiveSignal, boundary, onControlPlaneError) {
378
+ return fromPromise(async ({ input, signal }) => {
379
+ const activeSignal = combineAbortSignals(signal, getActiveSignal?.());
380
+ const playerId = spec.resolvePlayerId(input);
381
+ const prompt = spec.composePlayerPrompt(input);
382
+ const result = boundary
383
+ ? await boundary.callPlayer(input, playerId, prompt, activeSignal)
384
+ : await ports.callPlayer(playerId, prompt, activeSignal, {
385
+ resume: false,
386
+ });
387
+ if (result.status !== 'ok') {
388
+ throw new Error(result.error ?? `captainBridge: callPlayer status "${result.status}"`);
389
+ }
390
+ if (result.finalText === undefined) {
391
+ throw new Error('captainBridge: callPlayer returned status=ok with no finalText');
392
+ }
393
+ try {
394
+ const output = await adjudicatePlayerOutput(spec.adjudication, input, result.finalText, ports, activeSignal, boundary);
395
+ validateBossReplyOutput(input, output, spec.resumableStateIds);
396
+ return output;
397
+ }
398
+ catch (error) {
399
+ onControlPlaneError?.(error);
400
+ throw error;
401
+ }
402
+ });
403
+ }
404
+ // ---------------------------------------------------------------------------
405
+ // Direct-Captain adjudication (slc/link.md §Captain adjudication). The judge
406
+ // selects the guard and supplies only other structural fields; the runtime
407
+ // injects the exact visible finalText as the selected output's `question` or
408
+ // `response` and rejects a judge reply that supplies either presentation
409
+ // field as an undeclared extra key.
410
+ // ---------------------------------------------------------------------------
411
+ function buildCaptainJudgePrompt(input, finalText) {
412
+ const lines = [];
413
+ lines.push('Adjudicate the direct Captain output for this FSM state.');
414
+ lines.push(`State id: ${input.stateId}`);
415
+ lines.push(`Source item: ${input.sourceItem}`);
416
+ lines.push('');
417
+ lines.push('Visible Captain output:');
418
+ lines.push('```');
419
+ lines.push(finalText);
420
+ lines.push('```');
421
+ lines.push('');
422
+ lines.push('Result keys and descriptions:');
423
+ for (const [key, description] of Object.entries(input.result)) {
424
+ lines.push(`- \`${key}\` — ${description}`);
425
+ }
426
+ lines.push('');
427
+ lines.push('Pick exactly one outcome by `guard` and return JSON ' +
428
+ '`{ guard, …structuralPayloadFields }`. Do not include `question` or ' +
429
+ '`response`; the runtime injects the visible text.');
430
+ return lines.join('\n');
431
+ }
432
+ function adjudicateCaptainOutput(extractFields, input, finalText, judgeText) {
433
+ const parsed = parseJudgeJson(judgeText);
434
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
435
+ throw new Error('adjudicate: judge response is not a JSON object');
436
+ }
437
+ const obj = parsed;
438
+ const guard = obj.guard;
439
+ if (typeof guard !== 'string') {
440
+ throw new Error('adjudicate: judge response missing string "guard" field');
441
+ }
442
+ if (!Object.prototype.hasOwnProperty.call(input.result, guard)) {
443
+ throw new Error(`adjudicate: unknown guard "${guard}" — declared guards: ${Object.keys(input.result).join(', ')}`);
444
+ }
445
+ const required = extractFields(input.result[guard]);
446
+ const allowed = new Set(['guard']);
447
+ for (const field of required) {
448
+ if (field !== 'question' && field !== 'response')
449
+ allowed.add(field);
450
+ }
451
+ for (const key of Object.keys(obj)) {
452
+ if (!allowed.has(key)) {
453
+ throw new Error(`adjudicate: judge response supplied undeclared field "${key}" for guard "${guard}"`);
454
+ }
455
+ }
456
+ for (const field of required) {
457
+ if (field === 'question' || field === 'response')
458
+ continue;
459
+ if (obj[field] === undefined || obj[field] === null) {
460
+ throw new Error(`adjudicate: judge response missing required field "${field}" for guard "${guard}"`);
461
+ }
462
+ }
463
+ const output = { ...obj, guard };
464
+ if (required.includes('question'))
465
+ output.question = finalText;
466
+ if (required.includes('response'))
467
+ output.response = finalText;
468
+ return output;
469
+ }
470
+ // ---------------------------------------------------------------------------
471
+ // FSM-artifact introspection over `machine.config` — internal but stable in
472
+ // XState v5: it preserves the literal `createMachine` argument.
473
+ // ---------------------------------------------------------------------------
474
+ function stripIdPrefix(target) {
475
+ return target.startsWith('#') ? target.slice(1) : target;
476
+ }
477
+ function collectInvokeSources(machine) {
478
+ const sources = new Set();
479
+ const visit = (stateDef) => {
480
+ if (!isPlainObject(stateDef))
481
+ return;
482
+ const invoke = stateDef.invoke;
483
+ const invokes = Array.isArray(invoke) ? invoke : invoke ? [invoke] : [];
484
+ for (const entry of invokes) {
485
+ if (isPlainObject(entry) && typeof entry.src === 'string') {
486
+ sources.add(entry.src);
487
+ }
488
+ }
489
+ if (isPlainObject(stateDef.states)) {
490
+ for (const child of Object.values(stateDef.states))
491
+ visit(child);
492
+ }
493
+ };
494
+ visit(machine.config);
495
+ return sources;
496
+ }
497
+ function transitionTargets(transition) {
498
+ const arms = Array.isArray(transition) ? transition : [transition];
499
+ const targets = [];
500
+ for (const arm of arms) {
501
+ if (typeof arm === 'string') {
502
+ targets.push(stripIdPrefix(arm));
503
+ }
504
+ else if (isPlainObject(arm) && typeof arm.target === 'string') {
505
+ targets.push(stripIdPrefix(arm.target));
506
+ }
507
+ }
508
+ return targets;
509
+ }
510
+ /** Targets of the FSM's `awaitBossReply` BOSS_REPLY transitions. */
511
+ export function resumableStateIdsFromMachine(machine) {
512
+ const config = machine.config;
513
+ if (!isPlainObject(config) || !isPlainObject(config.states)) {
514
+ return new Set();
515
+ }
516
+ const awaitState = config.states.awaitBossReply;
517
+ if (!isPlainObject(awaitState) || !isPlainObject(awaitState.on)) {
518
+ return new Set();
519
+ }
520
+ const bossReply = awaitState.on.BOSS_REPLY;
521
+ if (bossReply === undefined)
522
+ return new Set();
523
+ return new Set(transitionTargets(bossReply));
524
+ }
525
+ // ---------------------------------------------------------------------------
526
+ // Default transition/status derivation.
527
+ // ---------------------------------------------------------------------------
528
+ const SUPPRESSED_ENTRY_STATES = new Set(['ready', 'done']);
529
+ function makeDefaultNormalizeTransitionEvent(transitionEventFields) {
530
+ return (event) => {
531
+ if (event === null || typeof event !== 'object') {
532
+ return snapshotJsonValue(event ?? null, 'FSM event');
533
+ }
534
+ const e = event;
535
+ const out = {};
536
+ if (typeof e.type === 'string')
537
+ out.type = e.type;
538
+ for (const field of transitionEventFields) {
539
+ if (typeof e[field] === 'string')
540
+ out[field] = e[field];
541
+ }
542
+ if (e.output !== undefined) {
543
+ out.output = snapshotJsonValue(e.output, 'FSM event output');
544
+ }
545
+ if (e.error !== undefined) {
546
+ out.error = snapshotJsonValue(normalizeError(e.error), 'FSM event error');
547
+ }
548
+ return snapshotJsonValue(out, 'FSM event');
549
+ };
550
+ }
551
+ function defaultStatusesForState(state, context) {
552
+ const stateId = state.stateId;
553
+ if (stateId === undefined || SUPPRESSED_ENTRY_STATES.has(stateId))
554
+ return [];
555
+ if (stateId === 'awaitBossReply') {
556
+ const pending = pendingBossQuestionFromContext(context);
557
+ const message = pending === undefined
558
+ ? 'Awaiting Boss reply.'
559
+ : `${pending.player} asks: ${pending.question}`;
560
+ return [{ message }];
561
+ }
562
+ if (stateId === 'failed') {
563
+ const lastError = normalizeErrorFull(context.lastError);
564
+ return [
565
+ {
566
+ message: 'Workflow failed; awaiting Boss recovery.',
567
+ ...(lastError === undefined
568
+ ? {}
569
+ : { data: snapshotJsonValue({ lastError }, 'failed status data') }),
570
+ },
571
+ ];
572
+ }
573
+ return [{ message: `Entered ${stateId}.` }];
574
+ }
575
+ function classifierState(snapshotOrState) {
576
+ if (snapshotOrState !== null &&
577
+ typeof snapshotOrState === 'object' &&
578
+ 'value' in snapshotOrState) {
579
+ const candidate = snapshotOrState;
580
+ return {
581
+ value: candidate.value,
582
+ context: candidate.context !== null &&
583
+ typeof candidate.context === 'object' &&
584
+ !Array.isArray(candidate.context)
585
+ ? candidate.context
586
+ : {},
587
+ };
588
+ }
589
+ return { value: snapshotOrState, context: {} };
590
+ }
591
+ function configuredEventTypesForState(machine, stateId) {
592
+ const configured = new Set();
593
+ const config = machine.config;
594
+ if (!isPlainObject(config))
595
+ return configured;
596
+ if (isPlainObject(config.on)) {
597
+ for (const type of Object.keys(config.on))
598
+ configured.add(type);
599
+ }
600
+ if (stateId !== undefined && isPlainObject(config.states)) {
601
+ const state = config.states[stateId];
602
+ if (isPlainObject(state) && isPlainObject(state.on)) {
603
+ for (const type of Object.keys(state.on))
604
+ configured.add(type);
605
+ }
606
+ }
607
+ return configured;
608
+ }
609
+ // Derived contracts merge into whatever the machine already yielded for the
610
+ // same type, so a deterministic entry event that shares a type with another
611
+ // derived contract keeps its exact-text ownership instead of being replaced.
612
+ function mergeDerivedContract(contracts, contract) {
613
+ const existing = contracts.get(contract.type);
614
+ contracts.set(contract.type, {
615
+ type: contract.type,
616
+ fields: { ...(existing?.fields ?? {}), ...(contract.fields ?? {}) },
617
+ });
618
+ }
619
+ function defaultBossEventSpecs(machine, entryEvent, supplied) {
620
+ const contracts = new Map();
621
+ if (entryEvent !== undefined) {
622
+ mergeDerivedContract(contracts, {
623
+ type: entryEvent.type,
624
+ fields: { [entryEvent.textField]: { source: 'text', required: true } },
625
+ });
626
+ }
627
+ const config = machine.config;
628
+ const rootInterrupt = isPlainObject(config) && isPlainObject(config.on)
629
+ ? config.on.BOSS_INTERRUPT
630
+ : undefined;
631
+ const interruptTargets = rootInterrupt === undefined ? [] : transitionTargets(rootInterrupt);
632
+ if (interruptTargets.length > 0) {
633
+ mergeDerivedContract(contracts, {
634
+ type: 'BOSS_INTERRUPT',
635
+ fields: {
636
+ targetId: {
637
+ source: 'judge',
638
+ required: true,
639
+ values: [...new Set(interruptTargets)],
640
+ },
641
+ // slc/link.md §Boss-event mapping: for BOSS_INTENT and
642
+ // BOSS_INTERRUPT the runtime, never the judge, attaches the exact
643
+ // original Boss text as `bossIntent`.
644
+ bossIntent: { source: 'text', required: true },
645
+ },
646
+ });
647
+ }
648
+ for (const contract of supplied) {
649
+ if (typeof contract.type !== 'string' ||
650
+ contract.type.trim().length === 0) {
651
+ throw new TypeError('Boss event contract type must be a non-empty string');
652
+ }
653
+ if (contract.type === 'NO_ACTION' || contract.type === 'BOSS_REPLY') {
654
+ throw new TypeError(`Boss event contract ${contract.type} is runtime-owned`);
655
+ }
656
+ const existing = contracts.get(contract.type);
657
+ const fields = {
658
+ ...(existing?.fields ?? {}),
659
+ };
660
+ for (const [field, fieldSpec] of Object.entries(contract.fields ?? {})) {
661
+ if (field.length === 0 || field === 'type') {
662
+ throw new TypeError(`Boss event contract ${contract.type} has invalid field ${JSON.stringify(field)}`);
663
+ }
664
+ if (fieldSpec.source !== 'judge' && fieldSpec.source !== 'text') {
665
+ throw new TypeError(`Boss event contract ${contract.type}.${field} has invalid source`);
666
+ }
667
+ if (fieldSpec.values !== undefined) {
668
+ if (fieldSpec.source !== 'judge' ||
669
+ fieldSpec.values.length === 0 ||
670
+ fieldSpec.values.some((value) => typeof value !== 'string' || value.length === 0)) {
671
+ throw new TypeError(`Boss event contract ${contract.type}.${field} has invalid values`);
672
+ }
673
+ }
674
+ const normalized = {
675
+ source: fieldSpec.source,
676
+ ...(fieldSpec.required === true ? { required: true } : {}),
677
+ ...(fieldSpec.values === undefined
678
+ ? {}
679
+ : { values: [...new Set(fieldSpec.values)] }),
680
+ };
681
+ const derived = fields[field];
682
+ if (derived !== undefined) {
683
+ const derivedValues = derived.values === undefined
684
+ ? undefined
685
+ : new Set(derived.values);
686
+ const normalizedValues = normalized.values === undefined
687
+ ? undefined
688
+ : new Set(normalized.values);
689
+ const sameValues = derivedValues === undefined || normalizedValues === undefined
690
+ ? derivedValues === normalizedValues
691
+ : derivedValues.size === normalizedValues.size &&
692
+ [...derivedValues].every((value) => normalizedValues.has(value));
693
+ if (derived.source !== normalized.source ||
694
+ (derived.required === true) !== (normalized.required === true) ||
695
+ !sameValues) {
696
+ throw new TypeError(`Boss event contract ${contract.type}.${field} conflicts with the runtime-derived contract`);
697
+ }
698
+ continue;
699
+ }
700
+ fields[field] = normalized;
701
+ }
702
+ contracts.set(contract.type, { type: contract.type, fields });
703
+ }
704
+ contracts.set('BOSS_REPLY', {
705
+ type: 'BOSS_REPLY',
706
+ fields: {
707
+ questionId: { source: 'judge' },
708
+ answer: { source: 'text', required: true },
709
+ },
710
+ });
711
+ return contracts;
712
+ }
713
+ function eventContractPrompt(contract) {
714
+ const fields = Object.entries(contract.fields ?? {}).filter(([, field]) => field.source === 'judge');
715
+ const members = [
716
+ `"type": ${JSON.stringify(contract.type)}`,
717
+ ...fields.map(([name, field]) => `${JSON.stringify(name)}: ${JSON.stringify(field.values?.[0] ?? '<string>')}`),
718
+ ];
719
+ const notes = fields.flatMap(([name, field]) => [
720
+ ...(field.required === true ? [] : [`${name} optional`]),
721
+ ...(field.values === undefined
722
+ ? []
723
+ : [
724
+ `${name} one of ${field.values
725
+ .map((value) => JSON.stringify(value))
726
+ .join(', ')}`,
727
+ ]),
728
+ ]);
729
+ return `{ ${members.join(', ')} }${notes.length === 0 ? '' : ` (${notes.join('; ')})`}`;
730
+ }
731
+ function makeDefaultClassifyBossText(machine, entryEvent, bossEvents) {
732
+ const contracts = defaultBossEventSpecs(machine, entryEvent, bossEvents);
733
+ return async (text, ports, signal, snapshotOrState, boundary) => {
734
+ const trimmed = text.trim();
735
+ if (trimmed === '')
736
+ return undefined;
737
+ const state = classifierState(snapshotOrState);
738
+ const stateId = typeof state.value === 'string' ? state.value : undefined;
739
+ const currentState = stateId ?? JSON.stringify(state.value ?? null);
740
+ const pending = pendingBossQuestionFromContext(state.context);
741
+ const configuredTypes = configuredEventTypesForState(machine, stateId);
742
+ const applicable = [...contracts.values()].filter((contract) => configuredTypes.has(contract.type) &&
743
+ (contract.type !== 'BOSS_REPLY' || pending !== undefined));
744
+ const lines = [
745
+ 'Classify the following Boss message into exactly one event.',
746
+ 'Respond with one exact flat JSON object. Do not add fields that are not shown.',
747
+ 'The runtime, not the judge, attaches the exact Boss text to textual event fields.',
748
+ '',
749
+ `Current state: ${currentState}`,
750
+ ];
751
+ if (pending !== undefined) {
752
+ lines.push(`Pending question id: ${pending.questionId}`, `Pending asking player: ${pending.player}`, `Pending Boss question: ${pending.question}`);
753
+ }
754
+ lines.push('', 'Allowed JSON objects:', '- { "type": "NO_ACTION" }');
755
+ for (const contract of applicable) {
756
+ lines.push(`- ${eventContractPrompt(contract)}`);
757
+ }
758
+ lines.push('', 'Boss message:', '```', text, '```');
759
+ const prompt = lines.join('\n');
760
+ const raw = boundary
761
+ ? await boundary.callJudge('boss-input-classification', stateId, prompt, signal)
762
+ : await ports.callJudge(prompt, signal);
763
+ let parsed;
764
+ try {
765
+ parsed = parseJudgeJson(raw);
766
+ }
767
+ catch {
768
+ await ports.emitStatus('Classifier reply was not recoverable JSON');
769
+ return undefined;
770
+ }
771
+ if (typeof parsed !== 'object' ||
772
+ parsed === null ||
773
+ Array.isArray(parsed)) {
774
+ await ports.emitStatus('Classifier returned a non-object JSON response');
775
+ return undefined;
776
+ }
777
+ const obj = parsed;
778
+ const eventType = obj.type;
779
+ if (typeof eventType !== 'string') {
780
+ await ports.emitStatus('Classifier did not name an event type');
781
+ return undefined;
782
+ }
783
+ if (eventType === 'NO_ACTION') {
784
+ if (Object.keys(obj).length !== 1) {
785
+ await ports.emitStatus('Classifier supplied extra fields for NO_ACTION');
786
+ return undefined;
787
+ }
788
+ return undefined;
789
+ }
790
+ const contract = applicable.find((candidate) => candidate.type === eventType);
791
+ if (contract === undefined) {
792
+ await ports.emitStatus(`Classifier returned unknown or inapplicable event type: ${eventType}`);
793
+ return undefined;
794
+ }
795
+ const fields = contract.fields ?? {};
796
+ const judgeFields = new Set(Object.entries(fields)
797
+ .filter(([, field]) => field.source === 'judge')
798
+ .map(([field]) => field));
799
+ const extras = Object.keys(obj).filter((field) => field !== 'type' && !judgeFields.has(field));
800
+ if (extras.length > 0) {
801
+ await ports.emitStatus(`Classifier supplied undeclared field for ${eventType}: ${extras[0]}`);
802
+ return undefined;
803
+ }
804
+ const event = { type: eventType };
805
+ for (const [field, fieldSpec] of Object.entries(fields)) {
806
+ if (fieldSpec.source === 'text') {
807
+ event[field] = text;
808
+ continue;
809
+ }
810
+ const value = obj[field];
811
+ if (value === undefined && fieldSpec.required !== true)
812
+ continue;
813
+ if (typeof value !== 'string' || value.length === 0) {
814
+ await ports.emitStatus(`Classifier omitted or invalidated ${field} for ${eventType}`);
815
+ return undefined;
816
+ }
817
+ if (fieldSpec.values !== undefined && !fieldSpec.values.includes(value)) {
818
+ await ports.emitStatus(`Classifier supplied unknown ${field} for ${eventType}: ${value}`);
819
+ return undefined;
820
+ }
821
+ event[field] = value;
822
+ }
823
+ if (eventType === 'BOSS_REPLY') {
824
+ if (pending === undefined) {
825
+ await ports.emitStatus('Classifier returned BOSS_REPLY without a pending question');
826
+ return undefined;
827
+ }
828
+ const questionId = event.questionId;
829
+ if (questionId !== undefined && questionId !== pending.questionId) {
830
+ await ports.emitStatus(`Classifier supplied unknown questionId for BOSS_REPLY: ${String(questionId)}`);
831
+ return undefined;
832
+ }
833
+ event.questionId = pending.questionId;
834
+ }
835
+ const can = snapshotOrState?.can;
836
+ if (typeof can === 'function' &&
837
+ !can.call(snapshotOrState, event)) {
838
+ await ports.emitStatus(`Classifier selected ${eventType}, but its state guards rejected the event`);
839
+ return undefined;
840
+ }
841
+ return event;
842
+ };
843
+ }
844
+ /**
845
+ * Build a `PlaybookRuntimeFactory` that interprets the given FSM artifact
846
+ * under the slc/link.md contract. The factory provides every actor kind the
847
+ * machine declares — `player`, `script`, `captain`, and nested `playbook`
848
+ * (literal and dynamic) — and implements the full runtime lifecycle including
849
+ * the optional parked-session snapshot capability (DR-014).
850
+ *
851
+ * Scope: single-region root machines (each snapshot exposes exactly one
852
+ * playbook state id). Parallel-region FSMs keep their own linked runtimes.
853
+ */
854
+ export function createXStatePlaybookRuntime(machine, spec) {
855
+ const label = spec.label ?? 'playbook';
856
+ const declaredActors = collectInvokeSources(machine);
857
+ const resumableStateIds = spec.resumableStateIds ?? resumableStateIdsFromMachine(machine);
858
+ const resolvePlayerIdSpec = spec.resolvePlayerId;
859
+ const composePlayerPrompt = spec.composePlayerPrompt ??
860
+ ((input) => defaultComposePlayerPrompt(input, spec.placeholderFields));
861
+ const composeCaptainPrompt = spec.composeCaptainPrompt ??
862
+ ((input) => defaultComposeCaptainPrompt(input, spec.placeholderFields));
863
+ const adjudication = {
864
+ ...(spec.buildJudgePrompt !== undefined
865
+ ? { buildJudgePrompt: spec.buildJudgePrompt }
866
+ : {}),
867
+ ...(spec.extractRequiredFields !== undefined
868
+ ? { extractRequiredFields: spec.extractRequiredFields }
869
+ : {}),
870
+ ...(spec.verbatimPayloadFields !== undefined
871
+ ? { verbatimPayloadFields: spec.verbatimPayloadFields }
872
+ : {}),
873
+ };
874
+ const extractFields = spec.extractRequiredFields ?? defaultExtractRequiredFields;
875
+ // Build the derived classifier unconditionally: it is the sole validator of
876
+ // supplied `bossEvents`, and DR-019 §2 requires a conflicting duplicate to
877
+ // fail factory construction whether or not this spec overrides the
878
+ // classifier that would have consumed the contracts.
879
+ const derivedClassifyBossText = makeDefaultClassifyBossText(machine, spec.entryEvent, spec.bossEvents ?? []);
880
+ const classifyBossText = spec.classifyBossText ?? derivedClassifyBossText;
881
+ const normalizeTransitionEvent = spec.normalizeTransitionEvent ??
882
+ makeDefaultNormalizeTransitionEvent(spec.transitionEventFields ?? []);
883
+ const statusesForState = spec.statusesForState ?? defaultStatusesForState;
884
+ const machineInput = spec.machineInput ?? ((options) => options);
885
+ const scriptCwd = spec.scriptCwd ??
886
+ ((options) => {
887
+ const cwd = options?.cwd;
888
+ return typeof cwd === 'string' ? cwd : undefined;
889
+ });
890
+ return function createPlaybookRuntime(options) {
891
+ const boundOptions = spec.snapshotOptions(options);
892
+ const boundScriptCwd = scriptCwd(boundOptions);
893
+ let actor;
894
+ let session;
895
+ let initialized = false;
896
+ let initInFlight;
897
+ let disposalPromise;
898
+ let disposed = false;
899
+ let savedPorts;
900
+ let runtimePorts;
901
+ // The Boss's per-turn AbortSignal, surfaced to the provided actors so
902
+ // ports.callPlayer / callCaptain / callJudge see the right cancellation
903
+ // source. undefined between turns; set by the public boundaries.
904
+ let activeSignal;
905
+ let activeTurnId;
906
+ let controlPlaneError;
907
+ // Previous root-machine state for the inspect-driven telemetry /
908
+ // status emitter. undefined before the first inspect firing.
909
+ let priorState;
910
+ let suppressInspectionEmissions = false;
911
+ let traceSequence = 0;
912
+ let turnSequence = 0;
913
+ let judgeCallSequence = 0;
914
+ let playerCallSequence = 0;
915
+ let playbookCallSequence = 0;
916
+ let captainCallSequence = 0;
917
+ const playerResumeTokens = new Map();
918
+ const activePlayerIds = new Set();
919
+ const playbookCallTurnIds = new Map();
920
+ // Captain and judge work share one serialized lane (slc/link.md
921
+ // §Session lifecycle).
922
+ const judgeQueue = new PQueue({ concurrency: 1 });
923
+ const emissionQueue = new PQueue({ concurrency: 1 });
924
+ const activeEmissionCalls = new Set();
925
+ // All trace, state-telemetry, and status work shares this one queue.
926
+ // Inspection callbacks enqueue a complete ordered batch synchronously;
927
+ // imperative boundaries await their queued work directly.
928
+ let emissionFailure;
929
+ function enqueueEmission(fn) {
930
+ const queued = emissionQueue.add(fn).then(() => undefined);
931
+ activeEmissionCalls.add(queued);
932
+ void queued.then(() => activeEmissionCalls.delete(queued), (error) => {
933
+ activeEmissionCalls.delete(queued);
934
+ emissionFailure ??= error;
935
+ });
936
+ return queued;
937
+ }
938
+ async function drainEmissions() {
939
+ while (true) {
940
+ const active = [...activeEmissionCalls];
941
+ if (active.length > 0)
942
+ await Promise.allSettled(active);
943
+ await emissionQueue.onIdle();
944
+ if (activeEmissionCalls.size === 0 &&
945
+ emissionQueue.size === 0 &&
946
+ emissionQueue.pending === 0) {
947
+ break;
948
+ }
949
+ }
950
+ if (emissionFailure !== undefined) {
951
+ const error = emissionFailure;
952
+ emissionFailure = undefined;
953
+ throw error;
954
+ }
955
+ }
956
+ function requireSession() {
957
+ if (!session) {
958
+ throw new Error('createPlaybookRuntime: init must be called first');
959
+ }
960
+ return session;
961
+ }
962
+ function requireHostPorts() {
963
+ if (!savedPorts) {
964
+ throw new Error('createPlaybookRuntime: init must be called first');
965
+ }
966
+ return savedPorts;
967
+ }
968
+ function createTraceEvent(type, payload, position = {}) {
969
+ const currentSession = requireSession();
970
+ const safePayload = snapshotJsonValue(payload, `trace ${type} payload`);
971
+ return {
972
+ schemaVersion: 2,
973
+ sessionId: currentSession.sessionId,
974
+ playbookId: currentSession.playbookId,
975
+ rootSessionId: currentSession.rootSessionId,
976
+ ...(currentSession.parentSessionId !== undefined
977
+ ? { parentSessionId: currentSession.parentSessionId }
978
+ : {}),
979
+ ...(currentSession.parentCallId !== undefined
980
+ ? { parentCallId: currentSession.parentCallId }
981
+ : {}),
982
+ depth: currentSession.depth,
983
+ sequence: ++traceSequence,
984
+ timestamp: Date.now(),
985
+ type,
986
+ ...(position.turnId !== undefined ? { turnId: position.turnId } : {}),
987
+ ...(position.callId !== undefined ? { callId: position.callId } : {}),
988
+ payload: safePayload,
989
+ };
990
+ }
991
+ function emitTrace(type, payload, position = {}) {
992
+ const currentSession = requireSession();
993
+ const event = createTraceEvent(type, payload, position);
994
+ return enqueueEmission(() => currentSession.ports.emitTelemetry({
995
+ topic: 'playbook.trace',
996
+ payload: event,
997
+ }));
998
+ }
999
+ function stateIdentity(stateId) {
1000
+ return stateId === undefined ? {} : { stateId };
1001
+ }
1002
+ function currentState() {
1003
+ if (!actor) {
1004
+ throw new Error('createPlaybookRuntime: actor is not initialized');
1005
+ }
1006
+ return normalizePlaybookSnapshot(actor.getSnapshot(), {
1007
+ pendingCall: nestedBridge.getPendingCall(),
1008
+ });
1009
+ }
1010
+ function stateTracePayload(state = currentState()) {
1011
+ return {
1012
+ state,
1013
+ ...stateIdentity(state.stateId),
1014
+ };
1015
+ }
1016
+ function createRuntimePorts(hostPorts) {
1017
+ return {
1018
+ callPlayer: (playerId, prompt, signal, callOptions) => hostPorts.callPlayer(playerId, prompt, signal, callOptions),
1019
+ callCaptain: (prompt, signal, callOptions) => hostPorts.callCaptain(prompt, signal, callOptions),
1020
+ callJudge: (prompt, signal) => hostPorts.callJudge(prompt, signal),
1021
+ callPlaybook: (request, signal) => hostPorts.callPlaybook(request, signal),
1022
+ emitStatus: (message, data) => {
1023
+ const descriptor = actor ? currentState() : undefined;
1024
+ const safeData = data === undefined
1025
+ ? undefined
1026
+ : snapshotJsonValue(data, 'status data');
1027
+ const trace = createTraceEvent('status.emitted', {
1028
+ message,
1029
+ ...(safeData !== undefined ? { data: safeData } : {}),
1030
+ ...(descriptor !== undefined
1031
+ ? {
1032
+ state: descriptor,
1033
+ ...stateIdentity(descriptor.stateId),
1034
+ }
1035
+ : {}),
1036
+ }, activeTurnId !== undefined ? { turnId: activeTurnId } : {});
1037
+ return enqueueEmission(async () => {
1038
+ await hostPorts.emitTelemetry({
1039
+ topic: 'playbook.trace',
1040
+ payload: trace,
1041
+ });
1042
+ await hostPorts.emitStatus(message, safeData);
1043
+ });
1044
+ },
1045
+ emitTelemetry: (event) => {
1046
+ if (typeof event.topic !== 'string' || event.topic.length === 0) {
1047
+ throw new TypeError('telemetry topic must be a non-empty string');
1048
+ }
1049
+ const payload = snapshotJsonValue(event.payload, 'telemetry payload');
1050
+ return enqueueEmission(() => hostPorts.emitTelemetry({ topic: event.topic, payload }));
1051
+ },
1052
+ };
1053
+ }
1054
+ async function emitCallStarted(startedType, finishedType, identity, position) {
1055
+ try {
1056
+ await emitTrace(startedType, identity, position);
1057
+ }
1058
+ catch (error) {
1059
+ controlPlaneError ??= error;
1060
+ try {
1061
+ await emitTrace(finishedType, { ...identity, status: 'error', error: normalizeError(error) }, position);
1062
+ }
1063
+ catch {
1064
+ // Preserve the start failure after one best-effort finish attempt.
1065
+ }
1066
+ throw error;
1067
+ }
1068
+ }
1069
+ const boundary = {
1070
+ async callPlayer(input, playerId, prompt, signal) {
1071
+ // State-entry telemetry/status must precede the call they describe.
1072
+ await drainEmissions();
1073
+ const turnId = activeTurnId;
1074
+ const callId = `player-${++playerCallSequence}`;
1075
+ const stateId = input.stateId;
1076
+ const resume = playerResumeTokens.get(playerId) ?? false;
1077
+ const identity = {
1078
+ purpose: 'captain',
1079
+ ...stateIdentity(stateId),
1080
+ sourceItem: input.sourceItem,
1081
+ playerId,
1082
+ resume,
1083
+ };
1084
+ const position = {
1085
+ ...(turnId !== undefined ? { turnId } : {}),
1086
+ callId,
1087
+ };
1088
+ if (activePlayerIds.has(playerId)) {
1089
+ const error = new Error(`simultaneous calls to resolved player ${playerId} are not allowed`);
1090
+ await emitCallStarted('player.call.started', 'player.call.finished', { ...identity, prompt }, position);
1091
+ await emitTrace('player.call.finished', { ...identity, status: 'error', error: normalizeError(error) }, position);
1092
+ throw error;
1093
+ }
1094
+ activePlayerIds.add(playerId);
1095
+ try {
1096
+ await emitTrace('player.call.started', { ...identity, prompt }, position);
1097
+ let rawResult;
1098
+ try {
1099
+ rawResult = await requireHostPorts().callPlayer(playerId, prompt, signal, { resume });
1100
+ // A host promise is not required to honor cancellation. Do not let
1101
+ // a late result mutate continuity or publish a successful finish.
1102
+ signal.throwIfAborted();
1103
+ }
1104
+ catch (error) {
1105
+ if (!signal.aborted)
1106
+ controlPlaneError ??= error;
1107
+ try {
1108
+ await emitTrace('player.call.finished', {
1109
+ ...identity,
1110
+ status: signal.aborted ? 'aborted' : 'error',
1111
+ error: normalizeError(error),
1112
+ }, position);
1113
+ }
1114
+ catch {
1115
+ // The original non-abort port rejection remains authoritative.
1116
+ }
1117
+ // A thrown port call carries no authoritative result, so the
1118
+ // prior token remains available for a later explicit resume.
1119
+ throw error;
1120
+ }
1121
+ let result;
1122
+ try {
1123
+ result = validatePlayerResult(rawResult);
1124
+ }
1125
+ catch (error) {
1126
+ if (!signal.aborted)
1127
+ controlPlaneError ??= error;
1128
+ try {
1129
+ await emitTrace('player.call.finished', { ...identity, status: 'error', error: normalizeError(error) }, position);
1130
+ }
1131
+ catch {
1132
+ // The malformed host result remains authoritative.
1133
+ }
1134
+ throw error;
1135
+ }
1136
+ if (typeof result.resumeToken === 'string' &&
1137
+ result.resumeToken.trim().length > 0) {
1138
+ playerResumeTokens.set(playerId, result.resumeToken);
1139
+ }
1140
+ else {
1141
+ playerResumeTokens.delete(playerId);
1142
+ }
1143
+ await emitTrace('player.call.finished', {
1144
+ ...identity,
1145
+ status: result.status,
1146
+ ...(result.finalText !== undefined
1147
+ ? { finalText: result.finalText }
1148
+ : {}),
1149
+ ...(result.error !== undefined
1150
+ ? { error: normalizeError(result.error) }
1151
+ : {}),
1152
+ ...(result.resumeToken !== undefined
1153
+ ? { resumeToken: result.resumeToken }
1154
+ : {}),
1155
+ }, position);
1156
+ return result;
1157
+ }
1158
+ finally {
1159
+ activePlayerIds.delete(playerId);
1160
+ }
1161
+ },
1162
+ async callJudge(purpose, stateId, prompt, signal) {
1163
+ return judgeQueue.add(async () => {
1164
+ signal.throwIfAborted();
1165
+ // A transition/status queued synchronously by XState must reach
1166
+ // the host before the judge call that follows it.
1167
+ await drainEmissions();
1168
+ signal.throwIfAborted();
1169
+ const turnId = activeTurnId;
1170
+ const callId = `judge-${++judgeCallSequence}`;
1171
+ const identity = { purpose, ...stateIdentity(stateId) };
1172
+ const position = {
1173
+ ...(turnId !== undefined ? { turnId } : {}),
1174
+ callId,
1175
+ };
1176
+ await emitCallStarted('judge.call.started', 'judge.call.finished', { ...identity, prompt }, position);
1177
+ let reply;
1178
+ try {
1179
+ reply = await requireHostPorts().callJudge(prompt, signal);
1180
+ signal.throwIfAborted();
1181
+ }
1182
+ catch (error) {
1183
+ if (!isAbortFailure(error, signal)) {
1184
+ controlPlaneError ??= error;
1185
+ }
1186
+ await emitTrace('judge.call.finished', {
1187
+ ...identity,
1188
+ status: signal.aborted ? 'aborted' : 'error',
1189
+ error: normalizeError(error),
1190
+ }, position);
1191
+ throw error;
1192
+ }
1193
+ if (typeof reply !== 'string') {
1194
+ const error = new TypeError('judge reply must be a string');
1195
+ controlPlaneError ??= error;
1196
+ await emitTrace('judge.call.finished', { ...identity, status: 'error', error: normalizeError(error) }, position);
1197
+ throw error;
1198
+ }
1199
+ // Keep the success finish outside the port-call catch. If a
1200
+ // telemetry sink records this boundary and then rejects, that sink
1201
+ // failure must not synthesize a second, contradictory finish.
1202
+ await emitTrace('judge.call.finished', { ...identity, status: 'ok', reply }, position);
1203
+ // The finish sink is part of the classifier boundary. A signal may
1204
+ // abort while that ordered emission drains; never let the already
1205
+ // classified event mutate the machine afterward.
1206
+ signal.throwIfAborted();
1207
+ return reply;
1208
+ });
1209
+ },
1210
+ async callCaptain(input, prompt, signal) {
1211
+ return judgeQueue.add(async () => {
1212
+ signal.throwIfAborted();
1213
+ await drainEmissions();
1214
+ signal.throwIfAborted();
1215
+ const turnId = activeTurnId;
1216
+ const callId = `captain-${++captainCallSequence}`;
1217
+ const identity = {
1218
+ ...stateIdentity(input.stateId),
1219
+ sourceItem: input.sourceItem,
1220
+ visibility: 'visible',
1221
+ resume: false,
1222
+ ...(input.allowedTools === undefined
1223
+ ? {}
1224
+ : { allowedTools: [...input.allowedTools] }),
1225
+ };
1226
+ const position = {
1227
+ ...(turnId !== undefined ? { turnId } : {}),
1228
+ callId,
1229
+ };
1230
+ await emitCallStarted('captain.call.started', 'captain.call.finished', { ...identity, prompt }, position);
1231
+ let rawResult;
1232
+ try {
1233
+ rawResult = await requireHostPorts().callCaptain(prompt, signal, {
1234
+ visibility: 'visible',
1235
+ resume: false,
1236
+ ...(input.allowedTools !== undefined
1237
+ ? { allowedTools: input.allowedTools }
1238
+ : {}),
1239
+ });
1240
+ signal.throwIfAborted();
1241
+ }
1242
+ catch (error) {
1243
+ if (!isAbortFailure(error, signal))
1244
+ controlPlaneError ??= error;
1245
+ await emitTrace('captain.call.finished', {
1246
+ ...identity,
1247
+ status: signal.aborted ? 'aborted' : 'error',
1248
+ error: normalizeError(error),
1249
+ }, position);
1250
+ throw error;
1251
+ }
1252
+ let result;
1253
+ try {
1254
+ result = validateCaptainResult(rawResult);
1255
+ }
1256
+ catch (error) {
1257
+ controlPlaneError ??= error;
1258
+ await emitTrace('captain.call.finished', { ...identity, status: 'error', error: normalizeError(error) }, position);
1259
+ throw error;
1260
+ }
1261
+ // A non-`ok` host result is a recoverable FSM failure (PBRT-47), so
1262
+ // it is never latched as a control-plane error; it is still
1263
+ // authoritative for the actor's error path even when the required
1264
+ // finish emission fails or a coincident boundary abort lands.
1265
+ let resultFailure;
1266
+ if (result.status !== 'ok') {
1267
+ resultFailure = markFsmResultFailure(new Error(result.error ??
1268
+ `captainActor: callCaptain status "${result.status}"`));
1269
+ }
1270
+ else if (result.finalText === undefined || result.finalText === '') {
1271
+ resultFailure = markFsmResultFailure(new Error('captainActor: callCaptain returned status=ok with no finalText'));
1272
+ }
1273
+ try {
1274
+ await emitTrace('captain.call.finished', {
1275
+ ...identity,
1276
+ status: result.status,
1277
+ ...(result.finalText !== undefined
1278
+ ? { finalText: result.finalText }
1279
+ : {}),
1280
+ ...(result.error !== undefined
1281
+ ? { error: normalizeError(result.error) }
1282
+ : resultFailure !== undefined
1283
+ ? { error: normalizeError(resultFailure) }
1284
+ : {}),
1285
+ }, position);
1286
+ }
1287
+ catch (error) {
1288
+ // Keep the finish-sink failure in the emission queue for public
1289
+ // cleanup evidence, but do not replace an authoritative result
1290
+ // failure on the invoked actor's XState onError path.
1291
+ if (resultFailure !== undefined)
1292
+ throw resultFailure;
1293
+ throw error;
1294
+ }
1295
+ if (resultFailure !== undefined) {
1296
+ throw resultFailure;
1297
+ }
1298
+ return result;
1299
+ });
1300
+ },
1301
+ };
1302
+ function resolvePlayerId(input) {
1303
+ return resolvePlayerIdSpec
1304
+ ? resolvePlayerIdSpec(input, boundOptions)
1305
+ : defaultResolvePlayerId(input);
1306
+ }
1307
+ function playerActor(ports) {
1308
+ return createPlayerBridge({
1309
+ resolvePlayerId,
1310
+ composePlayerPrompt,
1311
+ adjudication,
1312
+ resumableStateIds,
1313
+ }, ports, () => activeSignal, boundary, (error) => {
1314
+ if (!activeSignal?.aborted)
1315
+ controlPlaneError ??= error;
1316
+ });
1317
+ }
1318
+ // Direct-Captain actor (slc/link.md §Captain prompt composition,
1319
+ // §Captain adjudication): one visible callCaptain, then hidden judge
1320
+ // adjudication that injects the exact visible finalText as the selected
1321
+ // output's question/response.
1322
+ function captainActor() {
1323
+ return fromPromise(async ({ input, signal }) => {
1324
+ const active = combineAbortSignals(signal, activeSignal);
1325
+ try {
1326
+ await drainEmissions();
1327
+ const prompt = composeCaptainPrompt(input);
1328
+ const result = await boundary.callCaptain(input, prompt, active);
1329
+ // The boundary owns result validation (PBRT-47) and throws the
1330
+ // authoritative failure itself, so a returned result is always
1331
+ // `ok` with visible text. Assert that invariant rather than
1332
+ // restating the failure semantics, which would drift.
1333
+ if (result.status !== 'ok' || !result.finalText) {
1334
+ throw new Error('captainActor: boundary returned an unvalidated Captain result');
1335
+ }
1336
+ const judgePrompt = buildCaptainJudgePrompt(input, result.finalText);
1337
+ const raw = await boundary.callJudge('captain-output-adjudication', input.stateId, judgePrompt, active);
1338
+ const output = adjudicateCaptainOutput(extractFields, input, result.finalText, raw);
1339
+ validateBossReplyOutput(input, output, resumableStateIds);
1340
+ return output;
1341
+ }
1342
+ catch (error) {
1343
+ // A host-reported Captain result failure routes to the FSM's
1344
+ // failure state (PBRT-47); everything else here — a drained
1345
+ // emission failure, prompt composition, the port itself,
1346
+ // adjudication — is control plane.
1347
+ if (!active.aborted && !isFsmResultFailure(error)) {
1348
+ controlPlaneError ??= error;
1349
+ }
1350
+ throw error;
1351
+ }
1352
+ });
1353
+ }
1354
+ // Deterministic script actor (slc/link.md §Script execution). Runs
1355
+ // `input.command` through `sh -c`, resolves the declared guard
1356
+ // mechanically from the exit status, and emits one status + one
1357
+ // `playbook.script` telemetry event. No agent call, no adjudication,
1358
+ // no `*.call.*` trace.
1359
+ function scriptActor() {
1360
+ return fromPromise(async ({ input, signal }) => {
1361
+ await drainEmissions();
1362
+ const active = combineAbortSignals(signal, activeSignal);
1363
+ const guards = Object.keys(input.result);
1364
+ const okGuard = guards[0];
1365
+ const failedGuard = guards[1] ?? guards[0];
1366
+ const cwd = boundScriptCwd ?? process.cwd();
1367
+ const ports = runtimePorts ?? requireHostPorts();
1368
+ const exitStatus = await new Promise((resolve, reject) => {
1369
+ let child;
1370
+ try {
1371
+ child = spawn('sh', ['-c', input.command], {
1372
+ cwd,
1373
+ stdio: 'ignore',
1374
+ });
1375
+ }
1376
+ catch (error) {
1377
+ reject(error);
1378
+ return;
1379
+ }
1380
+ const onAbort = () => {
1381
+ child.kill('SIGTERM');
1382
+ reject(active.reason ?? new Error('script aborted'));
1383
+ };
1384
+ if (active.aborted) {
1385
+ onAbort();
1386
+ return;
1387
+ }
1388
+ active.addEventListener('abort', onAbort, { once: true });
1389
+ child.on('error', (error) => {
1390
+ active.removeEventListener('abort', onAbort);
1391
+ reject(error);
1392
+ });
1393
+ child.on('close', (code) => {
1394
+ active.removeEventListener('abort', onAbort);
1395
+ resolve(typeof code === 'number' ? code : 1);
1396
+ });
1397
+ });
1398
+ await ports.emitStatus(`Executed script for ${input.stateId} (exit ${exitStatus}).`);
1399
+ await ports.emitTelemetry({
1400
+ topic: 'playbook.script',
1401
+ payload: {
1402
+ stateId: input.stateId,
1403
+ sourceItem: input.sourceItem,
1404
+ exitStatus,
1405
+ },
1406
+ });
1407
+ if (exitStatus === 0) {
1408
+ return { guard: okGuard, exitStatus: 0 };
1409
+ }
1410
+ return { guard: failedGuard, exitStatus };
1411
+ });
1412
+ }
1413
+ const nestedBridge = createNestedPlaybookBridge({
1414
+ nextCallId: () => `playbook-${++playbookCallSequence}`,
1415
+ getBoundarySignal: () => activeSignal,
1416
+ callPlaybook: (request, signal) => requireHostPorts().callPlaybook(request, signal),
1417
+ emitStarted: async (event) => {
1418
+ playbookCallTurnIds.set(event.callId, activeTurnId);
1419
+ await emitTrace('playbook.call.started', {
1420
+ stateId: event.stateId,
1421
+ playbookId: event.playbookId,
1422
+ text: event.text,
1423
+ }, {
1424
+ ...(activeTurnId !== undefined ? { turnId: activeTurnId } : {}),
1425
+ callId: event.callId,
1426
+ });
1427
+ },
1428
+ emitFinished: async (event) => {
1429
+ const turnId = playbookCallTurnIds.get(event.callId);
1430
+ try {
1431
+ await emitTrace('playbook.call.finished', {
1432
+ stateId: event.stateId,
1433
+ playbookId: event.playbookId,
1434
+ text: event.text,
1435
+ result: event.result,
1436
+ }, {
1437
+ ...(turnId !== undefined ? { turnId } : {}),
1438
+ callId: event.callId,
1439
+ });
1440
+ }
1441
+ finally {
1442
+ playbookCallTurnIds.delete(event.callId);
1443
+ }
1444
+ },
1445
+ drain: drainEmissions,
1446
+ bindResumeSignal: (signal) => {
1447
+ activeSignal = signal;
1448
+ },
1449
+ onControlPlaneError: (error) => {
1450
+ if (!activeSignal?.aborted)
1451
+ controlPlaneError ??= error;
1452
+ },
1453
+ onBackgroundError: (error) => {
1454
+ emissionFailure ??= error;
1455
+ },
1456
+ });
1457
+ function tracePositionForActiveTurn() {
1458
+ return activeTurnId === undefined ? {} : { turnId: activeTurnId };
1459
+ }
1460
+ function structuredStateTelemetryPayload(previousState, state, event, context) {
1461
+ const payload = {
1462
+ from: previousState?.value ?? null,
1463
+ to: state.value,
1464
+ event: normalizeTransitionEvent(event) ?? null,
1465
+ previousState: previousState ?? null,
1466
+ state,
1467
+ };
1468
+ if (state.stateId === 'awaitBossReply') {
1469
+ const pendingBossQuestion = pendingBossQuestionFromContext(context);
1470
+ if (pendingBossQuestion !== undefined) {
1471
+ payload.pendingBossQuestion = pendingBossQuestion;
1472
+ }
1473
+ }
1474
+ if (state.stateId === 'failed') {
1475
+ const lastError = normalizeErrorFull(context.lastError);
1476
+ if (lastError !== undefined)
1477
+ payload.lastError = lastError;
1478
+ }
1479
+ return snapshotJsonValue(payload, 'FSM telemetry payload');
1480
+ }
1481
+ function enqueueTransitionEmission(payload, state, statuses, position) {
1482
+ const currentSession = requireSession();
1483
+ const transitionTrace = createTraceEvent('fsm.transition', payload, position);
1484
+ const statusEmissions = statuses.map(({ message, data }) => ({
1485
+ message,
1486
+ data,
1487
+ trace: createTraceEvent('status.emitted', {
1488
+ message,
1489
+ ...(data === undefined ? {} : { data }),
1490
+ state,
1491
+ ...stateIdentity(state.stateId),
1492
+ }, position),
1493
+ }));
1494
+ void enqueueEmission(async () => {
1495
+ await currentSession.ports.emitTelemetry({
1496
+ topic: 'playbook.trace',
1497
+ payload: transitionTrace,
1498
+ });
1499
+ await currentSession.ports.emitTelemetry({
1500
+ topic: 'playbook.fsm.state',
1501
+ payload,
1502
+ });
1503
+ for (const status of statusEmissions) {
1504
+ await currentSession.ports.emitTelemetry({
1505
+ topic: 'playbook.trace',
1506
+ payload: status.trace,
1507
+ });
1508
+ await currentSession.ports.emitStatus(status.message, status.data);
1509
+ }
1510
+ }).catch(() => undefined);
1511
+ }
1512
+ function latchInspectionError(error) {
1513
+ if (activeSignal !== undefined)
1514
+ controlPlaneError ??= error;
1515
+ else
1516
+ emissionFailure ??= error;
1517
+ }
1518
+ function buildActor(ports, machineSnapshot) {
1519
+ priorState = undefined;
1520
+ const actors = {};
1521
+ if (declaredActors.has('player'))
1522
+ actors.player = playerActor(ports);
1523
+ if (declaredActors.has('captain'))
1524
+ actors.captain = captainActor();
1525
+ if (declaredActors.has('script'))
1526
+ actors.script = scriptActor();
1527
+ if (declaredActors.has('playbook')) {
1528
+ actors.playbook = nestedBridge.actorLogic;
1529
+ }
1530
+ const provided = machine.provide({
1531
+ actors: actors,
1532
+ });
1533
+ let builtActor;
1534
+ builtActor = createActor(provided, {
1535
+ input: machineInput(boundOptions, requireSession()),
1536
+ // DR-014 §1: a restore rehydrates the persisted machine snapshot;
1537
+ // XState derives context/value from it and ignores `input` then.
1538
+ ...(machineSnapshot === undefined
1539
+ ? {}
1540
+ : { snapshot: machineSnapshot }),
1541
+ inspect: (inspectionEvent) => {
1542
+ if (inspectionEvent.type !== '@xstate.snapshot')
1543
+ return;
1544
+ if (inspectionEvent.actorRef !== builtActor)
1545
+ return;
1546
+ if (suppressInspectionEmissions)
1547
+ return;
1548
+ try {
1549
+ const snap = inspectionEvent.snapshot;
1550
+ const state = normalizePlaybookSnapshot(snap);
1551
+ if (state.stateId === undefined) {
1552
+ throw new Error(`${label} root snapshot must expose exactly one playbook state id`);
1553
+ }
1554
+ const previousState = priorState;
1555
+ const context = (snap.context ??
1556
+ {});
1557
+ const payload = structuredStateTelemetryPayload(previousState, state, inspectionEvent.event, context);
1558
+ const statuses = statusesForState(state, context, inspectionEvent.event);
1559
+ enqueueTransitionEmission(payload, state, statuses, tracePositionForActiveTurn());
1560
+ priorState = state;
1561
+ }
1562
+ catch (error) {
1563
+ latchInspectionError(error);
1564
+ }
1565
+ },
1566
+ });
1567
+ return builtActor;
1568
+ }
1569
+ function runResultFor(outcome, error) {
1570
+ const state = currentState();
1571
+ if (outcome === 'quiescent' || outcome === 'no-action') {
1572
+ return { outcome, state };
1573
+ }
1574
+ if (outcome === 'suspended') {
1575
+ const pendingCall = nestedBridge.getPendingCall();
1576
+ if (!pendingCall) {
1577
+ throw new Error('suspended runtime has no pending playbook call');
1578
+ }
1579
+ return { outcome, state, pendingCall };
1580
+ }
1581
+ if (outcome === 'terminal') {
1582
+ const output = actor?.getSnapshot()?.output;
1583
+ if (output !== undefined) {
1584
+ return {
1585
+ outcome,
1586
+ state,
1587
+ output: snapshotJsonValue(output, 'terminal playbook output'),
1588
+ };
1589
+ }
1590
+ return { outcome, state };
1591
+ }
1592
+ const failure = error ??
1593
+ (outcome === 'failed'
1594
+ ? actor?.getSnapshot()
1595
+ ?.context?.lastError
1596
+ : outcome === 'aborted'
1597
+ ? activeSignal?.reason
1598
+ : undefined);
1599
+ return {
1600
+ outcome,
1601
+ state,
1602
+ ...(failure !== undefined ? { error: normalizeError(failure) } : {}),
1603
+ };
1604
+ }
1605
+ function settledOutcome(signal) {
1606
+ if (nestedBridge.getPendingCall())
1607
+ return 'suspended';
1608
+ if (signal.aborted)
1609
+ return 'aborted';
1610
+ const state = currentState();
1611
+ if (state.status === 'error') {
1612
+ const actorError = actor?.getSnapshot()?.error;
1613
+ throw actorError ?? new Error(`${label} actor entered error status`);
1614
+ }
1615
+ if (state.status === 'done')
1616
+ return 'terminal';
1617
+ if (state.stateId === 'failed')
1618
+ return 'failed';
1619
+ return 'quiescent';
1620
+ }
1621
+ function settlementTracePayload(result) {
1622
+ return {
1623
+ ...result,
1624
+ ...stateIdentity(result.state.stateId),
1625
+ };
1626
+ }
1627
+ // Shared failed-start cleanup for init and restore: stop the actor,
1628
+ // abort/drain nested and host work, optionally emit one best-effort
1629
+ // session.disposed boundary, and unbind every closure field so dispose
1630
+ // stays callable. The caller rethrows its original failure. A restore
1631
+ // failure skips the disposal trace — the parked session was never
1632
+ // re-bound in this process, so its persisted snapshot stays
1633
+ // authoritative (DR-014 §2).
1634
+ async function cleanupFailedStart(cause, options) {
1635
+ let finalState;
1636
+ if (options.emitDisposal && actor) {
1637
+ try {
1638
+ finalState = currentState();
1639
+ }
1640
+ catch {
1641
+ // A state that cannot even normalize has no disposal descriptor.
1642
+ }
1643
+ }
1644
+ suppressInspectionEmissions = true;
1645
+ try {
1646
+ actor?.stop();
1647
+ }
1648
+ catch {
1649
+ // Preserve the original startup failure.
1650
+ }
1651
+ try {
1652
+ await nestedBridge.abortPending(cause);
1653
+ }
1654
+ catch {
1655
+ // Preserve the original startup failure.
1656
+ }
1657
+ try {
1658
+ await judgeQueue.onIdle();
1659
+ await drainEmissions();
1660
+ }
1661
+ catch {
1662
+ // Preserve the original startup failure.
1663
+ }
1664
+ if (options.emitDisposal) {
1665
+ try {
1666
+ await emitTrace('session.disposed', finalState === undefined
1667
+ ? {}
1668
+ : {
1669
+ state: finalState,
1670
+ ...stateIdentity(finalState.stateId),
1671
+ });
1672
+ await drainEmissions();
1673
+ }
1674
+ catch {
1675
+ // The session-start error remains authoritative.
1676
+ }
1677
+ }
1678
+ playerResumeTokens.clear();
1679
+ activePlayerIds.clear();
1680
+ playbookCallTurnIds.clear();
1681
+ activeEmissionCalls.clear();
1682
+ emissionQueue.clear();
1683
+ judgeQueue.clear();
1684
+ actor = undefined;
1685
+ session = undefined;
1686
+ savedPorts = undefined;
1687
+ runtimePorts = undefined;
1688
+ activeSignal = undefined;
1689
+ activeTurnId = undefined;
1690
+ controlPlaneError = undefined;
1691
+ emissionFailure = undefined;
1692
+ priorState = undefined;
1693
+ suppressInspectionEmissions = false;
1694
+ initialized = false;
1695
+ traceSequence = 0;
1696
+ turnSequence = 0;
1697
+ judgeCallSequence = 0;
1698
+ playerCallSequence = 0;
1699
+ playbookCallSequence = 0;
1700
+ captainCallSequence = 0;
1701
+ }
1702
+ const runtime = {
1703
+ async init(nextSession) {
1704
+ if (initialized || disposed || disposalPromise !== undefined) {
1705
+ throw new Error('createPlaybookRuntime.init: already initialized');
1706
+ }
1707
+ const boundSession = snapshotPlaybookSession(nextSession);
1708
+ initialized = true;
1709
+ let finishInitialization;
1710
+ const initialization = new Promise((resolve) => {
1711
+ finishInitialization = resolve;
1712
+ });
1713
+ initInFlight = initialization;
1714
+ const initTask = (async () => {
1715
+ session = boundSession;
1716
+ savedPorts = boundSession.ports;
1717
+ runtimePorts = createRuntimePorts(boundSession.ports);
1718
+ suppressInspectionEmissions = false;
1719
+ actor = buildActor(runtimePorts);
1720
+ await emitTrace('session.started', stateTracePayload());
1721
+ actor.start();
1722
+ await drainEmissions();
1723
+ })();
1724
+ try {
1725
+ await initTask;
1726
+ }
1727
+ catch (error) {
1728
+ await cleanupFailedStart(error, { emitDisposal: true });
1729
+ throw error;
1730
+ }
1731
+ finally {
1732
+ finishInitialization();
1733
+ if (initInFlight === initialization)
1734
+ initInFlight = undefined;
1735
+ }
1736
+ },
1737
+ // DR-014 §1 / PBRT-45: JSON-safe capture of a parked session.
1738
+ // Defined only at a safe capture point — initialized, not disposing
1739
+ // or disposed, no active public boundary, no pending nested call,
1740
+ // and the actor quiescent with status `active`.
1741
+ exportSnapshot() {
1742
+ if (!actor || !session || disposed || disposalPromise !== undefined) {
1743
+ return undefined;
1744
+ }
1745
+ if (activeSignal !== undefined)
1746
+ return undefined;
1747
+ if (nestedBridge.getPendingCall())
1748
+ return undefined;
1749
+ const state = currentState();
1750
+ if (state.status !== 'active' || !state.quiescent)
1751
+ return undefined;
1752
+ const machineSnapshot = detachPersistedMachineSnapshot(actor.getPersistedSnapshot());
1753
+ const context = actor.getSnapshot()
1754
+ .context;
1755
+ const pending = pendingBossQuestionFromContext(context ?? {});
1756
+ return {
1757
+ schemaVersion: 1,
1758
+ playbookId: session.playbookId,
1759
+ machine: machineSnapshot,
1760
+ playerResumeTokens: Object.fromEntries(playerResumeTokens),
1761
+ sequences: {
1762
+ trace: traceSequence,
1763
+ turn: turnSequence,
1764
+ judgeCall: judgeCallSequence,
1765
+ playerCall: playerCallSequence,
1766
+ playbookCall: playbookCallSequence,
1767
+ ...(declaredActors.has('captain')
1768
+ ? { captainCall: captainCallSequence }
1769
+ : {}),
1770
+ },
1771
+ state,
1772
+ pendingBossQuestions: pending === undefined
1773
+ ? []
1774
+ : [
1775
+ {
1776
+ questionId: pending.questionId,
1777
+ player: pending.player,
1778
+ question: pending.question,
1779
+ sourceItem: pending.sourceItem,
1780
+ },
1781
+ ],
1782
+ };
1783
+ },
1784
+ // DR-014 §1 / PBRT-45: alternative to `init` that rehydrates an
1785
+ // exported snapshot under the same immutable session identity.
1786
+ // Emits no `session.started`, transition trace, or human status —
1787
+ // the session already started; the next public boundary continues
1788
+ // the contiguous trace sequence.
1789
+ async restore(nextSession, snapshot) {
1790
+ if (initialized || disposed || disposalPromise !== undefined) {
1791
+ throw new Error('createPlaybookRuntime.restore: already initialized');
1792
+ }
1793
+ const boundSession = snapshotPlaybookSession(nextSession);
1794
+ const boundSnapshot = assertPlaybookRuntimeSnapshot(snapshot, boundSession.playbookId);
1795
+ initialized = true;
1796
+ let finishInitialization;
1797
+ const initialization = new Promise((resolve) => {
1798
+ finishInitialization = resolve;
1799
+ });
1800
+ initInFlight = initialization;
1801
+ const initTask = (async () => {
1802
+ session = boundSession;
1803
+ savedPorts = boundSession.ports;
1804
+ runtimePorts = createRuntimePorts(boundSession.ports);
1805
+ traceSequence = boundSnapshot.sequences.trace;
1806
+ turnSequence = boundSnapshot.sequences.turn;
1807
+ judgeCallSequence = boundSnapshot.sequences.judgeCall;
1808
+ playerCallSequence = boundSnapshot.sequences.playerCall;
1809
+ playbookCallSequence = boundSnapshot.sequences.playbookCall;
1810
+ captainCallSequence =
1811
+ boundSnapshot.sequences.captainCall ??
1812
+ // Legacy schema-v1 snapshots predate this dedicated counter.
1813
+ // Every Captain call already consumed at least one trace number,
1814
+ // so the global trace counter is a collision-safe id floor.
1815
+ boundSnapshot.sequences.trace;
1816
+ playerResumeTokens.clear();
1817
+ for (const [playerId, token] of Object.entries(boundSnapshot.playerResumeTokens)) {
1818
+ playerResumeTokens.set(playerId, token);
1819
+ }
1820
+ suppressInspectionEmissions = true;
1821
+ actor = buildActor(runtimePorts, boundSnapshot.machine);
1822
+ actor.start();
1823
+ const restoredState = currentState();
1824
+ if (restoredState.status !== 'active') {
1825
+ throw new Error(`createPlaybookRuntime.restore: restored actor status is ${restoredState.status}, expected active`);
1826
+ }
1827
+ suppressInspectionEmissions = false;
1828
+ priorState = restoredState;
1829
+ await drainEmissions();
1830
+ })();
1831
+ try {
1832
+ await initTask;
1833
+ }
1834
+ catch (error) {
1835
+ await cleanupFailedStart(error, { emitDisposal: false });
1836
+ throw error;
1837
+ }
1838
+ finally {
1839
+ finishInitialization();
1840
+ if (initInFlight === initialization)
1841
+ initInFlight = undefined;
1842
+ }
1843
+ },
1844
+ async handleBossInput({ text, signal, }) {
1845
+ if (!actor || !savedPorts) {
1846
+ throw new Error('createPlaybookRuntime.handleBossInput: init must be called first');
1847
+ }
1848
+ if (disposed || disposalPromise !== undefined) {
1849
+ throw new Error('createPlaybookRuntime.handleBossInput: runtime is disposing or disposed');
1850
+ }
1851
+ if (activeSignal !== undefined) {
1852
+ throw new Error('createPlaybookRuntime.handleBossInput: another runtime turn is active');
1853
+ }
1854
+ const turnId = ++turnSequence;
1855
+ activeTurnId = turnId;
1856
+ activeSignal = signal;
1857
+ controlPlaneError = undefined;
1858
+ let result;
1859
+ let operationError;
1860
+ try {
1861
+ await emitTrace('boss.input.received', { text }, { turnId });
1862
+ // 1. Map the Boss text to an FSM event: deterministic exact entry
1863
+ // where applicable (slc/link.md §Boss-event mapping), judge
1864
+ // classification otherwise.
1865
+ let event;
1866
+ const trimmed = text.trim();
1867
+ if (trimmed !== '') {
1868
+ const snapshot = actor.getSnapshot();
1869
+ const terminal = snapshot.status === 'done';
1870
+ const stateId = normalizePlaybookSnapshot(snapshot).stateId;
1871
+ if (spec.entryEvent !== undefined &&
1872
+ (stateId === 'ready' || terminal)) {
1873
+ event = {
1874
+ type: spec.entryEvent.type,
1875
+ [spec.entryEvent.textField]: text,
1876
+ };
1877
+ }
1878
+ else {
1879
+ event = await classifyBossText(text, runtimePorts, signal, snapshot, boundary);
1880
+ }
1881
+ signal.throwIfAborted();
1882
+ }
1883
+ // Empty input, no-action classifier output, or invalid classifier
1884
+ // output — nothing to send.
1885
+ if (event === undefined) {
1886
+ result = runResultFor('no-action');
1887
+ }
1888
+ else {
1889
+ // 2. Optional Captain-pane classification line: the bare FSM
1890
+ // event type, emitted before the FSM advances.
1891
+ const statusLine = spec.classificationStatus?.(event);
1892
+ if (statusLine !== undefined) {
1893
+ await runtimePorts.emitStatus(statusLine);
1894
+ }
1895
+ signal.throwIfAborted();
1896
+ // 3. A final actor cannot accept new events; reconstruct only
1897
+ // after classification produced a real event.
1898
+ if (actor.getSnapshot().status === 'done') {
1899
+ actor.stop();
1900
+ actor = buildActor(runtimePorts);
1901
+ actor.start();
1902
+ }
1903
+ actor.send(event);
1904
+ await waitForPlaybookQuiescence(actor, {
1905
+ pendingCalls: nestedBridge,
1906
+ });
1907
+ if (controlPlaneError !== undefined)
1908
+ throw controlPlaneError;
1909
+ result = runResultFor(settledOutcome(signal));
1910
+ }
1911
+ }
1912
+ catch (error) {
1913
+ operationError = error;
1914
+ }
1915
+ let drainError;
1916
+ try {
1917
+ await drainEmissions();
1918
+ }
1919
+ catch (error) {
1920
+ drainError = error;
1921
+ }
1922
+ const latchedControlError = controlPlaneError;
1923
+ const primaryError = latchedControlError ?? drainError ?? operationError;
1924
+ const abortError = latchedControlError === undefined &&
1925
+ drainError === undefined &&
1926
+ operationError !== undefined &&
1927
+ isAbortFailure(operationError, signal);
1928
+ const settlementResult = primaryError === undefined
1929
+ ? (result ?? runResultFor('no-action'))
1930
+ : runResultFor(abortError ? 'aborted' : 'failed', primaryError);
1931
+ let settlementEmissionError;
1932
+ try {
1933
+ await emitTrace('boss.input.settled', settlementTracePayload(settlementResult), { turnId });
1934
+ }
1935
+ catch (error) {
1936
+ settlementEmissionError = error;
1937
+ }
1938
+ try {
1939
+ await drainEmissions();
1940
+ }
1941
+ catch (error) {
1942
+ settlementEmissionError ??= error;
1943
+ }
1944
+ const failure = controlPlaneError ??
1945
+ latchedControlError ??
1946
+ drainError ??
1947
+ (abortError
1948
+ ? (settlementEmissionError ?? operationError)
1949
+ : (operationError ?? settlementEmissionError));
1950
+ activeSignal = undefined;
1951
+ activeTurnId = undefined;
1952
+ controlPlaneError = undefined;
1953
+ if (failure !== undefined &&
1954
+ !(abortError && settlementEmissionError === undefined)) {
1955
+ throw failure;
1956
+ }
1957
+ return settlementResult;
1958
+ },
1959
+ async resumePlaybookCall(input) {
1960
+ if (!actor || !savedPorts) {
1961
+ throw new Error('createPlaybookRuntime.resumePlaybookCall: init must be called first');
1962
+ }
1963
+ if (disposed || disposalPromise !== undefined) {
1964
+ throw new Error('createPlaybookRuntime.resumePlaybookCall: runtime is disposing or disposed');
1965
+ }
1966
+ if (activeSignal !== undefined) {
1967
+ throw new Error('createPlaybookRuntime.resumePlaybookCall: another runtime turn is active');
1968
+ }
1969
+ activeTurnId = playbookCallTurnIds.get(input.callId);
1970
+ activeSignal = input.signal;
1971
+ controlPlaneError = undefined;
1972
+ let result;
1973
+ let operationError;
1974
+ try {
1975
+ await nestedBridge.resume(input);
1976
+ }
1977
+ catch (error) {
1978
+ operationError = error;
1979
+ }
1980
+ try {
1981
+ await waitForPlaybookQuiescence(actor, {
1982
+ pendingCalls: nestedBridge,
1983
+ });
1984
+ result = runResultFor(settledOutcome(input.signal));
1985
+ }
1986
+ catch (error) {
1987
+ operationError ??= error;
1988
+ }
1989
+ let drainError;
1990
+ try {
1991
+ await drainEmissions();
1992
+ }
1993
+ catch (error) {
1994
+ drainError = error;
1995
+ }
1996
+ const failure = controlPlaneError ?? drainError ?? operationError;
1997
+ activeSignal = undefined;
1998
+ activeTurnId = undefined;
1999
+ controlPlaneError = undefined;
2000
+ if (failure !== undefined)
2001
+ throw failure;
2002
+ if (result === undefined) {
2003
+ throw new Error('playbook resume produced no runtime result');
2004
+ }
2005
+ return result;
2006
+ },
2007
+ dispose() {
2008
+ if (disposalPromise !== undefined)
2009
+ return disposalPromise;
2010
+ if (disposed)
2011
+ return Promise.resolve();
2012
+ if (activeSignal !== undefined) {
2013
+ return Promise.reject(new Error('createPlaybookRuntime.dispose: cannot dispose during an active runtime boundary'));
2014
+ }
2015
+ const task = (async () => {
2016
+ const failures = [];
2017
+ try {
2018
+ if (initInFlight !== undefined) {
2019
+ try {
2020
+ await initInFlight;
2021
+ }
2022
+ catch {
2023
+ // Dispose still releases whatever an unsuccessful init bound.
2024
+ }
2025
+ }
2026
+ const finalState = actor ? currentState() : undefined;
2027
+ // Stop the root before settling a suspended child. Its rejection
2028
+ // must not re-enter the FSM and start fresh work during disposal.
2029
+ if (actor)
2030
+ actor.stop();
2031
+ try {
2032
+ await nestedBridge.dispose();
2033
+ }
2034
+ catch (error) {
2035
+ failures.push(error);
2036
+ }
2037
+ try {
2038
+ await drainEmissions();
2039
+ }
2040
+ catch (error) {
2041
+ failures.push(error);
2042
+ }
2043
+ if (session !== undefined) {
2044
+ try {
2045
+ await emitTrace('session.disposed', finalState === undefined
2046
+ ? {}
2047
+ : {
2048
+ state: finalState,
2049
+ ...stateIdentity(finalState.stateId),
2050
+ });
2051
+ await drainEmissions();
2052
+ }
2053
+ catch (error) {
2054
+ failures.push(error);
2055
+ }
2056
+ }
2057
+ }
2058
+ finally {
2059
+ playerResumeTokens.clear();
2060
+ activePlayerIds.clear();
2061
+ playbookCallTurnIds.clear();
2062
+ activeEmissionCalls.clear();
2063
+ emissionQueue.clear();
2064
+ judgeQueue.clear();
2065
+ actor = undefined;
2066
+ activeSignal = undefined;
2067
+ activeTurnId = undefined;
2068
+ controlPlaneError = undefined;
2069
+ emissionFailure = undefined;
2070
+ savedPorts = undefined;
2071
+ runtimePorts = undefined;
2072
+ session = undefined;
2073
+ disposed = true;
2074
+ }
2075
+ if (failures.length === 1)
2076
+ throw failures[0];
2077
+ if (failures.length > 1) {
2078
+ throw new AggregateError(failures, 'playbook runtime disposal failed');
2079
+ }
2080
+ })();
2081
+ disposalPromise = task;
2082
+ return task;
2083
+ },
2084
+ // @internal — test-only escape hatches for inspecting the underlying
2085
+ // actor, traced boundary, and nested bridge. Not part of the stable
2086
+ // public runtime contract.
2087
+ _getActor() {
2088
+ return actor;
2089
+ },
2090
+ _getBoundary() {
2091
+ return boundary;
2092
+ },
2093
+ _getNestedBridge() {
2094
+ return nestedBridge;
2095
+ },
2096
+ };
2097
+ return runtime;
2098
+ };
2099
+ }