@sublang/playbook 0.8.0 → 1.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.
Files changed (55) hide show
  1. package/README.md +243 -193
  2. package/package.json +52 -17
  3. package/reference/sdlc/captain.md +102 -0
  4. package/reference/sdlc/captain.playbook/captain.fsm.d.ts +227 -0
  5. package/reference/sdlc/captain.playbook/captain.fsm.js +628 -0
  6. package/reference/sdlc/captain.playbook/captain.fsm.ts +851 -0
  7. package/reference/sdlc/captain.playbook/captain.gears.md +60 -0
  8. package/reference/sdlc/captain.playbook/captain.playbook.d.ts +23 -0
  9. package/reference/sdlc/captain.playbook/captain.playbook.js +1053 -0
  10. package/reference/sdlc/captain.playbook/captain.playbook.ts +1144 -0
  11. package/reference/sdlc/code.playbook/bin/playbook.js +580 -0
  12. package/reference/sdlc/code.playbook/bin/run.js +893 -0
  13. package/reference/sdlc/code.playbook/code.fsm.d.ts +11 -4
  14. package/reference/sdlc/code.playbook/code.fsm.introspect.d.ts +2 -2
  15. package/reference/sdlc/code.playbook/code.fsm.introspect.js +1 -1
  16. package/reference/sdlc/code.playbook/code.fsm.introspect.ts +6 -6
  17. package/reference/sdlc/code.playbook/code.fsm.js +334 -102
  18. package/reference/sdlc/code.playbook/code.fsm.ts +470 -182
  19. package/reference/sdlc/code.playbook/code.gears.md +11 -10
  20. package/reference/sdlc/code.playbook/code.playbook.d.ts +18 -9
  21. package/reference/sdlc/code.playbook/code.playbook.js +1098 -202
  22. package/reference/sdlc/code.playbook/code.playbook.ts +1440 -258
  23. package/reference/sdlc/code.playbook/code.registry.d.ts +17 -5
  24. package/reference/sdlc/code.playbook/code.registry.js +49 -34
  25. package/reference/sdlc/code.playbook/code.registry.ts +75 -41
  26. package/reference/sdlc/code.playbook/playbook-captain.d.ts +16 -8
  27. package/reference/sdlc/code.playbook/playbook-captain.js +1005 -240
  28. package/reference/sdlc/code.playbook/playbook-captain.ts +1310 -301
  29. package/reference/sdlc/code.playbook/playbook.config.template.yaml +68 -0
  30. package/reference/sdlc/discuss.playbook/discuss.fsm.d.ts +396 -0
  31. package/reference/sdlc/discuss.playbook/discuss.fsm.js +2066 -0
  32. package/reference/sdlc/discuss.playbook/discuss.fsm.ts +2464 -0
  33. package/reference/sdlc/discuss.playbook/discuss.gears.md +251 -0
  34. package/reference/sdlc/discuss.playbook/discuss.playbook.d.ts +113 -0
  35. package/reference/sdlc/discuss.playbook/discuss.playbook.js +1514 -0
  36. package/reference/sdlc/discuss.playbook/discuss.playbook.ts +1926 -0
  37. package/reference/sdlc/discuss.playbook/discuss.registry.d.ts +58 -0
  38. package/reference/sdlc/discuss.playbook/discuss.registry.js +97 -0
  39. package/reference/sdlc/discuss.playbook/discuss.registry.ts +153 -0
  40. package/slc/gears2fsm.md +557 -57
  41. package/slc/link.md +1097 -80
  42. package/slc/optimize.md +88 -0
  43. package/slc/text2gears.md +247 -5
  44. package/src/runtime.d.ts +145 -3
  45. package/src/runtime.ts +200 -2
  46. package/src/xstate-runtime.d.ts +94 -0
  47. package/src/xstate-runtime.js +1247 -0
  48. package/src/xstate-runtime.ts +1802 -0
  49. package/reference/sdlc/code.playbook/bin/playbook-code.js +0 -487
  50. package/reference/sdlc/code.playbook/code.tmux-play.d.ts +0 -4
  51. package/reference/sdlc/code.playbook/code.tmux-play.js +0 -11
  52. package/reference/sdlc/code.playbook/code.tmux-play.ts +0 -29
  53. package/reference/sdlc/code.playbook/playbook-code.config.template.yaml +0 -72
  54. package/reference/sdlc/code.playbook/tmux-play.config.yaml +0 -55
  55. package/reference/sdlc/code.playbook/tmux-play.production.config.yaml +0 -38
@@ -11,12 +11,40 @@
11
11
  // alias's first alternative)
12
12
  // Boss event: free-text judge classification
13
13
  // Adjudication: LLM-judge per state
14
- // Contract: PlayerResult / PlaybookPorts / PlaybookRuntime imported
15
- // and re-exported from @sublang/playbook/runtime
14
+ // Contract: PlayerResult / PlaybookPorts / PlaybookSession /
15
+ // PlaybookRuntime imported and re-exported from
16
+ // @sublang/playbook/runtime
16
17
  // (slc/link.md §Output, DR-004 Addendum A4)
18
+ import PQueue from 'p-queue';
17
19
  import { createActor, fromPromise } from 'xstate';
20
+ import { assertPlaybookRuntimeSnapshot, combineAbortSignals, createNestedPlaybookBridge, detachPersistedMachineSnapshot, normalizeError, normalizePlaybookSnapshot, snapshotJsonValue, snapshotPlaybookSession, validatePlayerResult, waitForPlaybookQuiescence, } from '../../../src/xstate-runtime.js';
18
21
  import { codingMachine, } from './code.fsm.js';
19
22
  import { enumerateAwaitBossReply, enumerateCaptainStates, enumerateRootEvents, } from './code.fsm.introspect.js';
23
+ function snapshotCodePlaybookOptions(value) {
24
+ const captured = snapshotJsonValue(value, 'CODE runtime options');
25
+ if (captured === null ||
26
+ typeof captured !== 'object' ||
27
+ Array.isArray(captured)) {
28
+ throw new TypeError('CODE runtime options must be an object');
29
+ }
30
+ const record = captured;
31
+ const allowed = new Set([
32
+ 'intent',
33
+ 'irNumber',
34
+ 'coderPlayer',
35
+ 'reviewerPlayer',
36
+ 'committerPlayer',
37
+ ]);
38
+ for (const [key, option] of Object.entries(record)) {
39
+ if (!allowed.has(key)) {
40
+ throw new TypeError(`CODE runtime options.${key} is not declared`);
41
+ }
42
+ if (typeof option !== 'string') {
43
+ throw new TypeError(`CODE runtime options.${key} must be a string`);
44
+ }
45
+ }
46
+ return captured;
47
+ }
20
48
  const BOSS_REPLY_ERRORS = {
21
49
  missingQuestion: "needsBossReply outcome missing 'question' field",
22
50
  unregisteredState: (stateId) => `state ${stateId} declared needsBossReply but is not registered as resumable`,
@@ -36,49 +64,61 @@ const VERBATIM_PAYLOAD_FIELDS = new Set([
36
64
  function normalizeErrorCompact(err) {
37
65
  if (err === undefined || err === null)
38
66
  return undefined;
39
- if (err instanceof Error) {
40
- return { name: err.name, message: err.message };
41
- }
42
- if (typeof err === 'object') {
43
- const o = err;
44
- if (typeof o.message === 'string') {
45
- return {
46
- name: typeof o.name === 'string' ? o.name : 'Error',
47
- message: o.message,
48
- };
49
- }
50
- }
51
- return { name: 'Error', message: String(err) };
67
+ const normalized = normalizeError(err);
68
+ return { name: normalized.name, message: normalized.message };
52
69
  }
53
70
  // Normalize an unknown error value to the full `{ name, message, stack }`
54
71
  // shape used by telemetry emissions. Returns `undefined` for nullish
55
72
  // input. `stack` is omitted when not available on the source value.
56
73
  function normalizeErrorFull(err) {
57
- const compact = normalizeErrorCompact(err);
58
- if (compact === undefined)
74
+ if (err === undefined || err === null)
59
75
  return undefined;
60
- if (err instanceof Error) {
61
- return err.stack !== undefined ? { ...compact, stack: err.stack } : compact;
62
- }
63
- if (typeof err === 'object' && err !== null) {
64
- const stack = err.stack;
65
- if (typeof stack === 'string') {
66
- return { ...compact, stack };
67
- }
68
- }
69
- return compact;
76
+ return normalizeError(err);
77
+ }
78
+ function isAbortFailure(error, signal) {
79
+ return (signal.aborted &&
80
+ (error === signal.reason || normalizeError(error).name === 'AbortError'));
70
81
  }
71
82
  // Normalize any `error` field inside a telemetry event so failed
72
83
  // transitions don't leak raw Error instances through the channel.
73
84
  function normalizeEventForTelemetry(event) {
74
- if (event === null || typeof event !== 'object' || Array.isArray(event)) {
75
- return event;
76
- }
77
- const e = event;
78
- if (!('error' in e))
79
- return event;
80
- const normalized = normalizeErrorFull(e.error);
81
- return { ...e, error: normalized };
85
+ if (event === undefined)
86
+ return undefined;
87
+ return normalizeEventValue(event, 'FSM event', new Set());
88
+ }
89
+ function normalizeEventValue(value, path, ancestors) {
90
+ if (Array.isArray(value))
91
+ return snapshotJsonValue(value, path);
92
+ if (value === null || typeof value !== 'object') {
93
+ return snapshotJsonValue(value, path);
94
+ }
95
+ if (ancestors.has(value)) {
96
+ throw new TypeError(`${path} must not contain a JSON cycle`);
97
+ }
98
+ const prototype = Object.getPrototypeOf(value);
99
+ if (prototype !== Object.prototype && prototype !== null) {
100
+ return snapshotJsonValue(value, path);
101
+ }
102
+ if (Object.getOwnPropertySymbols(value).length > 0) {
103
+ return snapshotJsonValue(value, path);
104
+ }
105
+ const nextAncestors = new Set(ancestors).add(value);
106
+ const normalized = {};
107
+ for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(value))) {
108
+ if (!descriptor.enumerable) {
109
+ throw new TypeError(`${path}.${key} must be an enumerable JSON property`);
110
+ }
111
+ if (!Object.prototype.hasOwnProperty.call(descriptor, 'value')) {
112
+ throw new TypeError(`${path}.${key} must be a JSON data property`);
113
+ }
114
+ if (descriptor.value === undefined)
115
+ continue;
116
+ normalized[key] =
117
+ key === 'error'
118
+ ? snapshotJsonValue(normalizeError(descriptor.value), `${path}.error`)
119
+ : normalizeEventValue(descriptor.value, `${path}.${key}`, nextAncestors);
120
+ }
121
+ return snapshotJsonValue(normalized, path);
82
122
  }
83
123
  // Internal capabilities (DR-004 §10). Each ships with its final
84
124
  // signature; behavior lands in the per-capability task noted by the
@@ -131,8 +171,9 @@ function composePlayerPrompt(input) {
131
171
  // Non-composite: Coder→'coder', Reviewer→'reviewer'. The composite
132
172
  // Committer (= Coder | Reviewer per code.gears.md) resolves to the
133
173
  // configured alias when present: `input.committerPlayer`, the
134
- // validated `captain.options.code.committer` (PBRT-8 / PBRT-30),
135
- // already a baked player id ('coder' / 'reviewer'). Absent a
174
+ // validated `captain.options.playbooks.code.options.committer`
175
+ // (PBRT-8 / PBRT-30), already a baked player id ('coder' / 'reviewer').
176
+ // Absent a
136
177
  // configured alias it falls back to the DR-004 §2 baked binding by
137
178
  // populated <playerName>Player field: prefer `coderPlayer` (CODE-18
138
179
  // wires only coderPlayer; CODE-19 wires both so coderPlayer still wins
@@ -168,9 +209,11 @@ function resolvePlayerId(input) {
168
209
  // object once the chosen guard is one of the input.result keys.
169
210
  // Adjudicator failures (malformed JSON, missing/unknown guard) are
170
211
  // control-plane errors and propagate via throw per slc/link.md.
171
- async function adjudicate(input, finalText, ports, signal) {
212
+ async function adjudicate(input, finalText, ports, signal, boundary) {
172
213
  const prompt = buildJudgePrompt(input, finalText);
173
- const raw = await ports.callJudge(prompt, signal);
214
+ const raw = boundary
215
+ ? await boundary.callJudge('player-output-adjudication', input.stateId, prompt, signal)
216
+ : await ports.callJudge(prompt, signal);
174
217
  const parsed = parseJudgeJson(raw);
175
218
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
176
219
  throw new Error('adjudicate: judge response is not a JSON object');
@@ -383,19 +426,22 @@ function dropTrailingComma(out) {
383
426
  // classifier is state-aware so awaitBossReply can distinguish a direct
384
427
  // answer (BOSS_REPLY) from a fresh directive that abandons the pending
385
428
  // question through the FSM's existing transitions.
386
- async function classifyBossText(text, ports, signal, snapshotOrState) {
429
+ async function classifyBossText(text, ports, signal, snapshotOrState, boundary) {
387
430
  const trimmed = text.trim();
388
431
  if (trimmed === '')
389
432
  return undefined;
390
- return classifyWithLlm(text, ports, signal, snapshotOrState);
433
+ return classifyWithLlm(text, ports, signal, snapshotOrState, boundary);
391
434
  }
392
435
  const rootEvents = enumerateRootEvents(codingMachine);
393
436
  const bossInterruptTargets = rootEvents.bossInterruptTargetDescriptions;
394
437
  const bossInterruptTargetIds = new Set(bossInterruptTargets.map((target) => target.stateId));
395
- async function classifyWithLlm(text, ports, signal, snapshotOrState) {
438
+ async function classifyWithLlm(text, ports, signal, snapshotOrState, boundary) {
396
439
  const state = classifierState(snapshotOrState);
397
440
  const prompt = buildClassifierPrompt(text, state);
398
- const raw = await ports.callJudge(prompt, signal);
441
+ const stateId = typeof state.value === 'string' ? state.value : undefined;
442
+ const raw = boundary
443
+ ? await boundary.callJudge('boss-input-classification', stateId, prompt, signal)
444
+ : await ports.callJudge(prompt, signal);
399
445
  let parsed;
400
446
  try {
401
447
  parsed = parseJudgeJson(raw);
@@ -478,7 +524,26 @@ async function classifyWithLlm(text, ports, signal, snapshotOrState) {
478
524
  await ports.emitStatus('Classifier omitted answer for BOSS_REPLY');
479
525
  return undefined;
480
526
  }
481
- return { type: 'BOSS_REPLY', answer: payload.answer };
527
+ const pending = pendingBossQuestionFromContext(state.context);
528
+ if (!pending) {
529
+ await ports.emitStatus('Classifier returned BOSS_REPLY without a pending question');
530
+ return undefined;
531
+ }
532
+ if (payload.questionId !== undefined &&
533
+ typeof payload.questionId !== 'string') {
534
+ await ports.emitStatus('Classifier supplied a non-string questionId for BOSS_REPLY');
535
+ return undefined;
536
+ }
537
+ if (typeof payload.questionId === 'string' &&
538
+ payload.questionId !== pending.questionId) {
539
+ await ports.emitStatus(`Classifier supplied unknown questionId for BOSS_REPLY: ${payload.questionId}`);
540
+ return undefined;
541
+ }
542
+ return {
543
+ type: 'BOSS_REPLY',
544
+ answer: payload.answer,
545
+ questionId: pending.questionId,
546
+ };
482
547
  }
483
548
  default:
484
549
  await ports.emitStatus(`Classifier returned unknown event type: ${eventType}`);
@@ -502,7 +567,9 @@ function classifierState(snapshotOrState) {
502
567
  return { value: snapshotOrState, context: {} };
503
568
  }
504
569
  function buildClassifierPrompt(text, state) {
505
- const currentState = typeof state.value === 'string' ? state.value : 'unknown';
570
+ const currentState = typeof state.value === 'string'
571
+ ? state.value
572
+ : JSON.stringify(state.value ?? null);
506
573
  const pendingBossQuestion = pendingBossQuestionFromContext(state.context);
507
574
  const lines = [
508
575
  'Classify the following Boss message into exactly one of these events.',
@@ -512,14 +579,14 @@ function buildClassifierPrompt(text, state) {
512
579
  `Current state: ${currentState}`,
513
580
  ];
514
581
  if (pendingBossQuestion !== undefined) {
515
- lines.push(`Pending Boss question: ${pendingBossQuestion.question}`, `Pending resume state: ${pendingBossQuestion.resumeStateId}`);
582
+ lines.push(`Pending question id: ${pendingBossQuestion.questionId}`, `Pending asking player: ${pendingBossQuestion.player}`, `Pending Boss question: ${pendingBossQuestion.question}`);
516
583
  }
517
584
  lines.push('', 'Events:', '- START_CODING: payload { intent: "<free-form goal>" }', '- CONTINUE_IR: payload { irNumber: "<number>" }', '- SUMMARIZE_IR: payload { irNumber: "<number>" }', '- BOSS_INTERRUPT: payload { targetId: "<stateId>", intent?: "<free-form goal>", irNumber?: "<number>" }', ' targetId must be one of these jumpable states:');
518
585
  for (const target of bossInterruptTargets) {
519
586
  lines.push(` - ${target.stateId}: ${target.description}`);
520
587
  }
521
588
  if (currentState === 'awaitBossReply') {
522
- lines.push('- BOSS_REPLY: payload { answer: "<verbatim Boss answer>" }');
589
+ lines.push('- BOSS_REPLY: payload { answer: "<verbatim Boss answer>", questionId?: "<pending question id>" }');
523
590
  }
524
591
  else {
525
592
  lines.push('- BOSS_REPLY: valid only when Current state is awaitBossReply');
@@ -527,35 +594,45 @@ function buildClassifierPrompt(text, state) {
527
594
  lines.push('', 'Boss message:', '```', text, '```');
528
595
  return lines.join('\n');
529
596
  }
530
- // Captain-actor bridge — DR-004 §7. One PromiseActorLogic that the
531
- // codingMachine invokes from every captain-invoking state. Per turn:
597
+ // Delegated-player actor bridge — DR-004 §7. One PromiseActorLogic that the
598
+ // codingMachine invokes from every player-invoking state. Per turn:
532
599
  // resolve playerId, compose the player prompt, await
533
600
  // ports.callPlayer, adjudicate the finalText. PlayerResult status of
534
601
  // 'aborted' or 'error' throws so XState routes via onError → #failed
535
602
  // (the single fail-stop sink for both Captain errors and player
536
- // failures).
603
+ // failures). Captain remains the orchestrator and adjudicator; it is not
604
+ // encoded as the delegated FSM actor.
537
605
  //
538
606
  // `getActiveSignal` is the runtime's hook for flowing the Boss's
539
607
  // `handleBossInput.signal` into the host port calls — fromPromise
540
608
  // hands the bridge XState's actor-scoped signal, which only fires
541
609
  // on actor.stop(), not on Boss abort. When omitted (e.g. direct
542
610
  // captainBridge tests), the bridge falls back to XState's signal.
543
- function captainBridge(ports, getActiveSignal) {
611
+ function captainBridge(ports, getActiveSignal, boundary, onControlPlaneError) {
544
612
  return fromPromise(async ({ input, signal }) => {
545
- const activeSignal = getActiveSignal?.() ?? signal;
613
+ const activeSignal = combineAbortSignals(signal, getActiveSignal?.());
546
614
  const playerId = resolvePlayerId(input);
547
615
  const prompt = composePlayerPrompt(input);
548
- const result = await ports.callPlayer(playerId, prompt, activeSignal);
616
+ const result = boundary
617
+ ? await boundary.callPlayer(input, playerId, prompt, activeSignal)
618
+ : await ports.callPlayer(playerId, prompt, activeSignal, {
619
+ resume: false,
620
+ });
549
621
  if (result.status !== 'ok') {
550
- throw new Error(result.error ??
551
- `captainBridge: callPlayer status "${result.status}"`);
622
+ throw new Error(result.error ?? `captainBridge: callPlayer status "${result.status}"`);
552
623
  }
553
624
  if (result.finalText === undefined) {
554
625
  throw new Error('captainBridge: callPlayer returned status=ok with no finalText');
555
626
  }
556
- const output = await adjudicate(input, result.finalText, ports, activeSignal);
557
- validateBossReplyOutput(input, output);
558
- return output;
627
+ try {
628
+ const output = await adjudicate(input, result.finalText, ports, activeSignal, boundary);
629
+ validateBossReplyOutput(input, output);
630
+ return output;
631
+ }
632
+ catch (error) {
633
+ onControlPlaneError?.(error);
634
+ throw error;
635
+ }
559
636
  });
560
637
  }
561
638
  // Captain pane display — PBRT-3 / PBRT-14.
@@ -564,7 +641,7 @@ function captainBridge(ports, getActiveSignal) {
564
641
  // glance:
565
642
  // (no glyph) bare FSM event type — host renders as captain speech
566
643
  // (e.g., `captain> START_CODING`)
567
- // ⤷ captain-invoking state entry: `<Player>: <label>`
644
+ // ⤷ player-invoking state entry: `<Player>: <label>`
568
645
  // → transition guard outcome (`· field=N` tallies
569
646
  // appended); the host presenter owns any visual
570
647
  // nesting under the preceding ⤷ entry
@@ -601,17 +678,13 @@ const stateMetadata = (() => {
601
678
  for (const s of enumerateCaptainStates(codingMachine)) {
602
679
  const label = STATE_LABELS[s.stateId];
603
680
  if (!label) {
604
- throw new Error(`code.playbook.ts: STATE_LABELS missing entry for captain-invoking state '${s.stateId}'`);
681
+ throw new Error(`code.playbook.ts: STATE_LABELS missing entry for player-invoking state '${s.stateId}'`);
605
682
  }
606
683
  const input = s.getInput({});
607
684
  m.set(s.stateId, { player: input.player, sourceItem: s.sourceItem, label });
608
685
  }
609
686
  return m;
610
687
  })();
611
- const stateIdBySourceItem = new Map([...stateMetadata.entries()].map(([stateId, meta]) => [
612
- meta.sourceItem,
613
- stateId,
614
- ]));
615
688
  const registeredResumableStateIds = new Set(enumerateAwaitBossReply(codingMachine).bossReplyTransitions.map((transition) => transition.target));
616
689
  function validateBossReplyOutput(input, output) {
617
690
  if (output.guard !== 'needsBossReply')
@@ -619,9 +692,9 @@ function validateBossReplyOutput(input, output) {
619
692
  if (typeof output.question !== 'string') {
620
693
  throw new Error(BOSS_REPLY_ERRORS.missingQuestion);
621
694
  }
622
- const stateId = stateIdBySourceItem.get(input.sourceItem);
623
- if (stateId === undefined || !registeredResumableStateIds.has(stateId)) {
624
- throw new Error(BOSS_REPLY_ERRORS.unregisteredState(stateId ?? input.sourceItem));
695
+ const stateId = input.stateId;
696
+ if (!registeredResumableStateIds.has(stateId)) {
697
+ throw new Error(BOSS_REPLY_ERRORS.unregisteredState(stateId));
625
698
  }
626
699
  }
627
700
  const QUIESCENT_STATES = new Set([
@@ -634,11 +707,8 @@ const QUIESCENT_STATES = new Set([
634
707
  // pane per PBRT-3: the readline returning to its `boss>` prompt is
635
708
  // the implicit "turn over" signal, so a `◆ ready` / `◆ done`
636
709
  // tombstone is redundant.
637
- const SUPPRESSED_ENTRY_STATES = new Set([
638
- 'ready',
639
- 'done',
640
- ]);
641
- // Captain-pane surface (PBRT-3): every captain-invoking state plus
710
+ const SUPPRESSED_ENTRY_STATES = new Set(['ready', 'done']);
711
+ // Captain-pane surface (PBRT-3): every player-invoking state plus
642
712
  // the quiescent states whose entry still carries information
643
713
  // (failure with `lastError`, awaitBossReply with the pending
644
714
  // question). `ready` and `done` flow through the inspect handler
@@ -655,12 +725,14 @@ function pendingBossQuestionFromContext(context) {
655
725
  }
656
726
  const candidate = pending;
657
727
  if (typeof candidate.resumeStateId !== 'string' ||
728
+ typeof candidate.questionId !== 'string' ||
658
729
  typeof candidate.sourceItem !== 'string' ||
659
730
  typeof candidate.player !== 'string' ||
660
731
  typeof candidate.question !== 'string') {
661
732
  return undefined;
662
733
  }
663
734
  return {
735
+ questionId: candidate.questionId,
664
736
  resumeStateId: candidate.resumeStateId,
665
737
  sourceItem: candidate.sourceItem,
666
738
  player: candidate.player,
@@ -725,9 +797,9 @@ function formatClassification(eventType) {
725
797
  }
726
798
  function stateTelemetryPayload(from, to, event, context) {
727
799
  const payload = {
728
- from,
800
+ from: from ?? null,
729
801
  to,
730
- event: normalizeEventForTelemetry(event),
802
+ event: normalizeEventForTelemetry(event) ?? null,
731
803
  };
732
804
  if (to === 'awaitBossReply') {
733
805
  const pendingBossQuestion = pendingBossQuestionFromContext(context);
@@ -743,6 +815,27 @@ function stateTelemetryPayload(from, to, event, context) {
743
815
  }
744
816
  return payload;
745
817
  }
818
+ function structuredStateTelemetryPayload(previousState, state, event, context) {
819
+ const payload = {
820
+ from: previousState?.value ?? null,
821
+ to: state.value,
822
+ event: normalizeEventForTelemetry(event) ?? null,
823
+ previousState: previousState ?? null,
824
+ state,
825
+ };
826
+ if (state.stateId === 'awaitBossReply') {
827
+ const pendingBossQuestion = pendingBossQuestionFromContext(context);
828
+ if (pendingBossQuestion !== undefined) {
829
+ payload.pendingBossQuestion = pendingBossQuestion;
830
+ }
831
+ }
832
+ if (state.stateId === 'failed') {
833
+ const lastError = normalizeErrorFull(context.lastError);
834
+ if (lastError !== undefined)
835
+ payload.lastError = lastError;
836
+ }
837
+ return snapshotJsonValue(payload, 'FSM telemetry payload');
838
+ }
746
839
  // Internal export surface for tests. Not part of the stable public API;
747
840
  // the leading underscore signals "subject to change." Each member is
748
841
  // referenced here so `noUnusedLocals` stays clean while later tasks
@@ -768,163 +861,978 @@ export const _internal = {
768
861
  VERBATIM_PAYLOAD_FIELDS,
769
862
  };
770
863
  export default function createPlaybookRuntime(options) {
864
+ const boundOptions = snapshotCodePlaybookOptions(options);
771
865
  let actor;
866
+ let session;
867
+ let initialized = false;
868
+ let initInFlight;
869
+ let disposalPromise;
870
+ let disposed = false;
772
871
  let savedPorts;
872
+ let runtimePorts;
773
873
  // The Boss's per-turn AbortSignal, surfaced to captainBridge so
774
874
  // ports.callPlayer / callJudge see the right cancellation source.
775
875
  // null between turns; set by handleBossInput.
776
876
  let activeSignal;
877
+ let activeTurnId;
878
+ let controlPlaneError;
777
879
  // Previous root-machine state for the inspect-driven telemetry /
778
880
  // status emitter. undefined before the first inspect firing.
779
881
  let priorState;
780
- // Emission queue. slc/link.md says emissions "shall be ordered,
781
- // awaited, and never-dropped"; subscribe/inspect callbacks are
782
- // synchronous and can't await, so each emit is enqueued and a
783
- // single drainer processes them sequentially.
784
- const emitQueue = [];
785
- let drainer;
786
- function enqueueEmit(fn) {
787
- emitQueue.push(fn);
788
- if (!drainer) {
789
- drainer = (async () => {
790
- while (emitQueue.length > 0) {
882
+ let suppressInspectionEmissions = false;
883
+ let traceSequence = 0;
884
+ let turnSequence = 0;
885
+ let judgeCallSequence = 0;
886
+ let playerCallSequence = 0;
887
+ let playbookCallSequence = 0;
888
+ const playerResumeTokens = new Map();
889
+ const activePlayerIds = new Set();
890
+ const playbookCallTurnIds = new Map();
891
+ const judgeQueue = new PQueue({ concurrency: 1 });
892
+ const emissionQueue = new PQueue({ concurrency: 1 });
893
+ const activeEmissionCalls = new Set();
894
+ // All trace, state-telemetry, and status work shares this one queue.
895
+ // Inspection callbacks enqueue a complete ordered batch synchronously;
896
+ // imperative boundaries await their queued work directly.
897
+ let emissionFailure;
898
+ function enqueueEmission(fn) {
899
+ const queued = emissionQueue.add(fn).then(() => undefined);
900
+ activeEmissionCalls.add(queued);
901
+ void queued.then(() => activeEmissionCalls.delete(queued), (error) => {
902
+ activeEmissionCalls.delete(queued);
903
+ emissionFailure ??= error;
904
+ });
905
+ return queued;
906
+ }
907
+ async function drainEmissions() {
908
+ while (true) {
909
+ const active = [...activeEmissionCalls];
910
+ if (active.length > 0)
911
+ await Promise.allSettled(active);
912
+ await emissionQueue.onIdle();
913
+ if (activeEmissionCalls.size === 0 &&
914
+ emissionQueue.size === 0 &&
915
+ emissionQueue.pending === 0) {
916
+ break;
917
+ }
918
+ }
919
+ if (emissionFailure !== undefined) {
920
+ const error = emissionFailure;
921
+ emissionFailure = undefined;
922
+ throw error;
923
+ }
924
+ }
925
+ function requireSession() {
926
+ if (!session) {
927
+ throw new Error('createPlaybookRuntime: init must be called first');
928
+ }
929
+ return session;
930
+ }
931
+ function requireHostPorts() {
932
+ if (!savedPorts) {
933
+ throw new Error('createPlaybookRuntime: init must be called first');
934
+ }
935
+ return savedPorts;
936
+ }
937
+ function createTraceEvent(type, payload, position = {}) {
938
+ const currentSession = requireSession();
939
+ const safePayload = snapshotJsonValue(payload, `trace ${type} payload`);
940
+ return {
941
+ schemaVersion: 2,
942
+ sessionId: currentSession.sessionId,
943
+ playbookId: currentSession.playbookId,
944
+ rootSessionId: currentSession.rootSessionId,
945
+ ...(currentSession.parentSessionId !== undefined
946
+ ? { parentSessionId: currentSession.parentSessionId }
947
+ : {}),
948
+ ...(currentSession.parentCallId !== undefined
949
+ ? { parentCallId: currentSession.parentCallId }
950
+ : {}),
951
+ depth: currentSession.depth,
952
+ sequence: ++traceSequence,
953
+ timestamp: Date.now(),
954
+ type,
955
+ ...(position.turnId !== undefined ? { turnId: position.turnId } : {}),
956
+ ...(position.callId !== undefined ? { callId: position.callId } : {}),
957
+ payload: safePayload,
958
+ };
959
+ }
960
+ function emitTrace(type, payload, position = {}) {
961
+ const currentSession = requireSession();
962
+ const event = createTraceEvent(type, payload, position);
963
+ return enqueueEmission(() => currentSession.ports.emitTelemetry({
964
+ topic: 'playbook.trace',
965
+ payload: event,
966
+ }));
967
+ }
968
+ function stateIdentity(stateId) {
969
+ return stateId === undefined ? {} : { stateId };
970
+ }
971
+ function currentState() {
972
+ if (!actor) {
973
+ throw new Error('createPlaybookRuntime: actor is not initialized');
974
+ }
975
+ return normalizePlaybookSnapshot(actor.getSnapshot(), {
976
+ pendingCall: nestedBridge.getPendingCall(),
977
+ });
978
+ }
979
+ function stateTracePayload(state = currentState()) {
980
+ return {
981
+ state,
982
+ ...stateIdentity(state.stateId),
983
+ };
984
+ }
985
+ function createRuntimePorts(hostPorts) {
986
+ return {
987
+ callPlayer: (playerId, prompt, signal, callOptions) => hostPorts.callPlayer(playerId, prompt, signal, callOptions),
988
+ callCaptain: (prompt, signal, callOptions) => hostPorts.callCaptain(prompt, signal, callOptions),
989
+ callJudge: (prompt, signal) => hostPorts.callJudge(prompt, signal),
990
+ callPlaybook: (request, signal) => hostPorts.callPlaybook(request, signal),
991
+ emitStatus: (message, data) => {
992
+ const descriptor = actor ? currentState() : undefined;
993
+ const safeData = data === undefined
994
+ ? undefined
995
+ : snapshotJsonValue(data, 'status data');
996
+ const trace = createTraceEvent('status.emitted', {
997
+ message,
998
+ ...(safeData !== undefined ? { data: safeData } : {}),
999
+ ...(descriptor !== undefined
1000
+ ? {
1001
+ state: descriptor,
1002
+ ...stateIdentity(descriptor.stateId),
1003
+ }
1004
+ : {}),
1005
+ }, activeTurnId !== undefined ? { turnId: activeTurnId } : {});
1006
+ return enqueueEmission(async () => {
1007
+ await hostPorts.emitTelemetry({
1008
+ topic: 'playbook.trace',
1009
+ payload: trace,
1010
+ });
1011
+ await hostPorts.emitStatus(message, safeData);
1012
+ });
1013
+ },
1014
+ emitTelemetry: (event) => {
1015
+ if (typeof event.topic !== 'string' || event.topic.length === 0) {
1016
+ throw new TypeError('telemetry topic must be a non-empty string');
1017
+ }
1018
+ const payload = snapshotJsonValue(event.payload, 'telemetry payload');
1019
+ return enqueueEmission(() => hostPorts.emitTelemetry({ topic: event.topic, payload }));
1020
+ },
1021
+ };
1022
+ }
1023
+ async function emitCallStarted(startedType, finishedType, identity, position) {
1024
+ try {
1025
+ await emitTrace(startedType, identity, position);
1026
+ }
1027
+ catch (error) {
1028
+ controlPlaneError ??= error;
1029
+ try {
1030
+ await emitTrace(finishedType, { ...identity, status: 'error', error: normalizeError(error) }, position);
1031
+ }
1032
+ catch {
1033
+ // Preserve the start failure after one best-effort finish attempt.
1034
+ }
1035
+ throw error;
1036
+ }
1037
+ }
1038
+ const boundary = {
1039
+ async callPlayer(input, playerId, prompt, signal) {
1040
+ // State-entry telemetry/status must precede the call they describe.
1041
+ await drainEmissions();
1042
+ const turnId = activeTurnId;
1043
+ const callId = `player-${++playerCallSequence}`;
1044
+ const stateId = input.stateId;
1045
+ const resume = playerResumeTokens.get(playerId) ?? false;
1046
+ const identity = {
1047
+ purpose: 'captain',
1048
+ ...stateIdentity(stateId),
1049
+ sourceItem: input.sourceItem,
1050
+ playerId,
1051
+ resume,
1052
+ };
1053
+ if (activePlayerIds.has(playerId)) {
1054
+ const error = new Error(`simultaneous calls to resolved player ${playerId} are not allowed`);
1055
+ await emitCallStarted('player.call.started', 'player.call.finished', { ...identity, prompt }, {
1056
+ ...(turnId !== undefined ? { turnId } : {}),
1057
+ callId,
1058
+ });
1059
+ await emitTrace('player.call.finished', { ...identity, status: 'error', error: normalizeError(error) }, {
1060
+ ...(turnId !== undefined ? { turnId } : {}),
1061
+ callId,
1062
+ });
1063
+ throw error;
1064
+ }
1065
+ activePlayerIds.add(playerId);
1066
+ try {
1067
+ await emitTrace('player.call.started', { ...identity, prompt }, {
1068
+ ...(turnId !== undefined ? { turnId } : {}),
1069
+ callId,
1070
+ });
1071
+ let rawResult;
1072
+ try {
1073
+ rawResult = await requireHostPorts().callPlayer(playerId, prompt, signal, { resume });
1074
+ // A host promise is not required to honor cancellation. Do not let
1075
+ // a late result mutate continuity or publish a successful finish.
1076
+ signal.throwIfAborted();
1077
+ }
1078
+ catch (error) {
1079
+ if (!signal.aborted)
1080
+ controlPlaneError ??= error;
791
1081
  try {
792
- await emitQueue.shift()();
1082
+ await emitTrace('player.call.finished', {
1083
+ ...identity,
1084
+ status: signal.aborted ? 'aborted' : 'error',
1085
+ error: normalizeError(error),
1086
+ }, {
1087
+ ...(turnId !== undefined ? { turnId } : {}),
1088
+ callId,
1089
+ });
793
1090
  }
794
1091
  catch {
795
- // Suppress host-side emission errors; the control plane
796
- // surfaces real failures via handleBossInput throws.
1092
+ // The original non-abort port rejection remains authoritative.
797
1093
  }
1094
+ // A thrown port call carries no authoritative result, so the
1095
+ // prior token remains available for a later explicit resume.
1096
+ throw error;
798
1097
  }
799
- drainer = undefined;
800
- })();
801
- }
1098
+ let result;
1099
+ try {
1100
+ result = validatePlayerResult(rawResult);
1101
+ }
1102
+ catch (error) {
1103
+ if (!signal.aborted)
1104
+ controlPlaneError ??= error;
1105
+ try {
1106
+ await emitTrace('player.call.finished', { ...identity, status: 'error', error: normalizeError(error) }, {
1107
+ ...(turnId !== undefined ? { turnId } : {}),
1108
+ callId,
1109
+ });
1110
+ }
1111
+ catch {
1112
+ // The malformed host result remains authoritative.
1113
+ }
1114
+ throw error;
1115
+ }
1116
+ if (typeof result.resumeToken === 'string' &&
1117
+ result.resumeToken.trim().length > 0) {
1118
+ playerResumeTokens.set(playerId, result.resumeToken);
1119
+ }
1120
+ else {
1121
+ playerResumeTokens.delete(playerId);
1122
+ }
1123
+ await emitTrace('player.call.finished', {
1124
+ ...identity,
1125
+ status: result.status,
1126
+ ...(result.finalText !== undefined
1127
+ ? { finalText: result.finalText }
1128
+ : {}),
1129
+ ...(result.error !== undefined
1130
+ ? { error: normalizeError(result.error) }
1131
+ : {}),
1132
+ ...(result.resumeToken !== undefined
1133
+ ? { resumeToken: result.resumeToken }
1134
+ : {}),
1135
+ }, {
1136
+ ...(turnId !== undefined ? { turnId } : {}),
1137
+ callId,
1138
+ });
1139
+ return result;
1140
+ }
1141
+ finally {
1142
+ activePlayerIds.delete(playerId);
1143
+ }
1144
+ },
1145
+ async callJudge(purpose, stateId, prompt, signal) {
1146
+ return judgeQueue.add(async () => {
1147
+ signal.throwIfAborted();
1148
+ // A transition/status queued synchronously by XState must reach
1149
+ // the host before the judge call that follows it.
1150
+ await drainEmissions();
1151
+ signal.throwIfAborted();
1152
+ const turnId = activeTurnId;
1153
+ const callId = `judge-${++judgeCallSequence}`;
1154
+ const identity = { purpose, ...stateIdentity(stateId) };
1155
+ await emitCallStarted('judge.call.started', 'judge.call.finished', { ...identity, prompt }, {
1156
+ ...(turnId !== undefined ? { turnId } : {}),
1157
+ callId,
1158
+ });
1159
+ let reply;
1160
+ try {
1161
+ reply = await requireHostPorts().callJudge(prompt, signal);
1162
+ signal.throwIfAborted();
1163
+ }
1164
+ catch (error) {
1165
+ if (!isAbortFailure(error, signal)) {
1166
+ controlPlaneError ??= error;
1167
+ }
1168
+ await emitTrace('judge.call.finished', {
1169
+ ...identity,
1170
+ status: signal.aborted ? 'aborted' : 'error',
1171
+ error: normalizeError(error),
1172
+ }, {
1173
+ ...(turnId !== undefined ? { turnId } : {}),
1174
+ callId,
1175
+ });
1176
+ throw error;
1177
+ }
1178
+ if (typeof reply !== 'string') {
1179
+ const error = new TypeError('judge reply must be a string');
1180
+ controlPlaneError ??= error;
1181
+ await emitTrace('judge.call.finished', { ...identity, status: 'error', error: normalizeError(error) }, {
1182
+ ...(turnId !== undefined ? { turnId } : {}),
1183
+ callId,
1184
+ });
1185
+ throw error;
1186
+ }
1187
+ // Keep the success finish outside the port-call catch. If a
1188
+ // telemetry sink records this boundary and then rejects, that sink
1189
+ // failure must not synthesize a second, contradictory finish.
1190
+ await emitTrace('judge.call.finished', { ...identity, status: 'ok', reply }, {
1191
+ ...(turnId !== undefined ? { turnId } : {}),
1192
+ callId,
1193
+ });
1194
+ return reply;
1195
+ });
1196
+ },
1197
+ };
1198
+ const nestedBridge = createNestedPlaybookBridge({
1199
+ nextCallId: () => `playbook-${++playbookCallSequence}`,
1200
+ getBoundarySignal: () => activeSignal,
1201
+ callPlaybook: (request, signal) => requireHostPorts().callPlaybook(request, signal),
1202
+ emitStarted: async (event) => {
1203
+ playbookCallTurnIds.set(event.callId, activeTurnId);
1204
+ await emitTrace('playbook.call.started', {
1205
+ stateId: event.stateId,
1206
+ playbookId: event.playbookId,
1207
+ text: event.text,
1208
+ }, {
1209
+ ...(activeTurnId !== undefined ? { turnId: activeTurnId } : {}),
1210
+ callId: event.callId,
1211
+ });
1212
+ },
1213
+ emitFinished: async (event) => {
1214
+ const turnId = playbookCallTurnIds.get(event.callId);
1215
+ try {
1216
+ await emitTrace('playbook.call.finished', {
1217
+ stateId: event.stateId,
1218
+ playbookId: event.playbookId,
1219
+ text: event.text,
1220
+ result: event.result,
1221
+ }, {
1222
+ ...(turnId !== undefined ? { turnId } : {}),
1223
+ callId: event.callId,
1224
+ });
1225
+ }
1226
+ finally {
1227
+ playbookCallTurnIds.delete(event.callId);
1228
+ }
1229
+ },
1230
+ drain: drainEmissions,
1231
+ bindResumeSignal: (signal) => {
1232
+ activeSignal = signal;
1233
+ },
1234
+ onControlPlaneError: (error) => {
1235
+ if (!activeSignal?.aborted)
1236
+ controlPlaneError ??= error;
1237
+ },
1238
+ onBackgroundError: (error) => {
1239
+ emissionFailure ??= error;
1240
+ },
1241
+ });
1242
+ function tracePositionForActiveTurn() {
1243
+ return activeTurnId === undefined ? {} : { turnId: activeTurnId };
802
1244
  }
803
- function drainEmissions() {
804
- return drainer ?? Promise.resolve();
1245
+ function enqueueTransitionEmission(payload, state, statuses, position) {
1246
+ const currentSession = requireSession();
1247
+ const transitionTrace = createTraceEvent('fsm.transition', payload, position);
1248
+ const statusEmissions = statuses.map(({ message, data }) => ({
1249
+ message,
1250
+ data,
1251
+ trace: createTraceEvent('status.emitted', {
1252
+ message,
1253
+ ...(data === undefined ? {} : { data }),
1254
+ state,
1255
+ ...stateIdentity(state.stateId),
1256
+ }, position),
1257
+ }));
1258
+ void enqueueEmission(async () => {
1259
+ await currentSession.ports.emitTelemetry({
1260
+ topic: 'playbook.trace',
1261
+ payload: transitionTrace,
1262
+ });
1263
+ await currentSession.ports.emitTelemetry({
1264
+ topic: 'playbook.fsm.state',
1265
+ payload,
1266
+ });
1267
+ for (const status of statusEmissions) {
1268
+ await currentSession.ports.emitTelemetry({
1269
+ topic: 'playbook.trace',
1270
+ payload: status.trace,
1271
+ });
1272
+ await currentSession.ports.emitStatus(status.message, status.data);
1273
+ }
1274
+ }).catch(() => undefined);
1275
+ }
1276
+ function latchInspectionError(error) {
1277
+ if (activeSignal !== undefined)
1278
+ controlPlaneError ??= error;
1279
+ else
1280
+ emissionFailure ??= error;
805
1281
  }
806
- function buildActor(ports) {
1282
+ function buildActor(ports, machineSnapshot) {
807
1283
  priorState = undefined;
808
- return createActor(codingMachine.provide({
809
- actors: { captain: captainBridge(ports, () => activeSignal) },
1284
+ let builtActor;
1285
+ builtActor = createActor(codingMachine.provide({
1286
+ actors: {
1287
+ player: captainBridge(ports, () => activeSignal, boundary, (error) => {
1288
+ if (!activeSignal?.aborted)
1289
+ controlPlaneError ??= error;
1290
+ }),
1291
+ },
810
1292
  }), {
811
- input: options,
1293
+ input: boundOptions,
1294
+ // DR-014 §1: a restore rehydrates the persisted machine snapshot;
1295
+ // XState derives context/value from it and ignores `input` then.
1296
+ ...(machineSnapshot === undefined
1297
+ ? {}
1298
+ : {
1299
+ snapshot: machineSnapshot,
1300
+ }),
812
1301
  inspect: (inspectionEvent) => {
813
1302
  if (inspectionEvent.type !== '@xstate.snapshot')
814
1303
  return;
815
- const snap = inspectionEvent.snapshot;
816
- // Filter out captain sub-actor (fromPromise) snapshots —
817
- // only the root codingMachine snapshot has a string value.
818
- if (typeof snap.value !== 'string')
1304
+ if (inspectionEvent.actorRef !== builtActor)
819
1305
  return;
820
- const to = snap.value;
821
- if (priorState === to)
1306
+ if (suppressInspectionEmissions)
822
1307
  return;
823
- const from = priorState;
824
- priorState = to;
825
- // Telemetry on every transition (PBRT-14).
826
- const context = snap.context ?? {};
827
- enqueueEmit(() => ports.emitTelemetry({
828
- topic: 'playbook.fsm.state',
829
- payload: stateTelemetryPayload(from, to, inspectionEvent.event, context),
830
- }));
831
- // Captain pane (PBRT-3 / PBRT-14): show the transition
832
- // guard first (when this is an actor-done transition with
833
- // a known guard), then the new state entry, then any
834
- // context riders the entering state cares about. Terminal
835
- // entry to `failed` carries `lastError` as the data arg.
836
- if (!CAPTAIN_PANE_STATES.has(to))
837
- return;
838
- const transitionLine = formatTransition(inspectionEvent.event);
839
- if (transitionLine !== undefined) {
840
- enqueueEmit(() => ports.emitStatus(transitionLine));
841
- }
842
- // awaitBossReply surfaces two lines per PBRT-3 / PBRT-14: the
843
- // full player question as captain speech, then the rider-less
844
- // routing marker. The full-question telemetry rides
845
- // stateTelemetryPayload above.
846
- if (to === 'awaitBossReply') {
847
- const questionLine = formatAwaitBossReplyQuestion(context);
848
- const markerLine = formatAwaitBossReplyMarker(context);
849
- enqueueEmit(() => ports.emitStatus(questionLine));
850
- enqueueEmit(() => ports.emitStatus(markerLine));
851
- return;
852
- }
853
- const entryLine = formatStateEntry(to);
854
- if (entryLine === undefined)
855
- return;
856
- if (to === 'failed') {
857
- const lastError = snap.context
858
- ?.lastError;
859
- const data = { lastError: normalizeErrorCompact(lastError) };
860
- enqueueEmit(() => ports.emitStatus(entryLine, data));
1308
+ try {
1309
+ const snap = inspectionEvent.snapshot;
1310
+ const state = normalizePlaybookSnapshot(snap);
1311
+ const to = state.stateId;
1312
+ if (to === undefined) {
1313
+ throw new Error('CODE root snapshot must expose exactly one playbook state id');
1314
+ }
1315
+ const previousState = priorState;
1316
+ const context = snap.context;
1317
+ const payload = structuredStateTelemetryPayload(previousState, state, inspectionEvent.event, context);
1318
+ const statuses = [];
1319
+ if (CAPTAIN_PANE_STATES.has(to)) {
1320
+ const transitionLine = formatTransition(inspectionEvent.event);
1321
+ if (transitionLine !== undefined) {
1322
+ statuses.push({ message: transitionLine });
1323
+ }
1324
+ if (to === 'awaitBossReply') {
1325
+ statuses.push({ message: formatAwaitBossReplyQuestion(context) }, { message: formatAwaitBossReplyMarker(context) });
1326
+ }
1327
+ else {
1328
+ const entryLine = formatStateEntry(to);
1329
+ if (entryLine !== undefined) {
1330
+ const lastError = to === 'failed'
1331
+ ? normalizeErrorCompact(snap.context.lastError)
1332
+ : undefined;
1333
+ statuses.push({
1334
+ message: entryLine,
1335
+ ...(lastError === undefined
1336
+ ? {}
1337
+ : {
1338
+ data: snapshotJsonValue({ lastError }, 'failed status data'),
1339
+ }),
1340
+ });
1341
+ }
1342
+ }
1343
+ }
1344
+ enqueueTransitionEmission(payload, state, statuses, tracePositionForActiveTurn());
1345
+ priorState = state;
861
1346
  }
862
- else {
863
- enqueueEmit(() => ports.emitStatus(entryLine));
1347
+ catch (error) {
1348
+ latchInspectionError(error);
864
1349
  }
865
1350
  },
866
1351
  });
1352
+ return builtActor;
867
1353
  }
868
- const runtime = {
869
- async init(ports) {
870
- savedPorts = ports;
871
- actor = buildActor(ports);
872
- actor.start();
1354
+ function runResultFor(outcome, error) {
1355
+ const state = currentState();
1356
+ if (outcome === 'quiescent' || outcome === 'no-action') {
1357
+ return { outcome, state };
1358
+ }
1359
+ if (outcome === 'suspended') {
1360
+ const pendingCall = nestedBridge.getPendingCall();
1361
+ if (!pendingCall) {
1362
+ throw new Error('suspended runtime has no pending playbook call');
1363
+ }
1364
+ return { outcome, state, pendingCall };
1365
+ }
1366
+ if (outcome === 'terminal') {
1367
+ const output = actor?.getSnapshot()
1368
+ ?.output;
1369
+ if (output !== undefined) {
1370
+ return {
1371
+ outcome,
1372
+ state,
1373
+ output: snapshotJsonValue(output, 'terminal playbook output'),
1374
+ };
1375
+ }
1376
+ return { outcome, state };
1377
+ }
1378
+ const failure = error ??
1379
+ (outcome === 'failed'
1380
+ ? actor?.getSnapshot()
1381
+ ?.context?.lastError
1382
+ : outcome === 'aborted'
1383
+ ? activeSignal?.reason
1384
+ : undefined);
1385
+ return {
1386
+ outcome,
1387
+ state,
1388
+ ...(failure !== undefined ? { error: normalizeError(failure) } : {}),
1389
+ };
1390
+ }
1391
+ function settledOutcome(signal) {
1392
+ if (nestedBridge.getPendingCall())
1393
+ return 'suspended';
1394
+ if (signal.aborted)
1395
+ return 'aborted';
1396
+ const state = currentState();
1397
+ if (state.status === 'error') {
1398
+ const actorError = actor?.getSnapshot()?.error;
1399
+ throw actorError ?? new Error('CODE actor entered error status');
1400
+ }
1401
+ if (state.status === 'done')
1402
+ return 'terminal';
1403
+ if (state.stateId === 'failed')
1404
+ return 'failed';
1405
+ return 'quiescent';
1406
+ }
1407
+ function settlementTracePayload(result) {
1408
+ return {
1409
+ ...result,
1410
+ ...stateIdentity(result.state.stateId),
1411
+ };
1412
+ }
1413
+ // Shared failed-start cleanup for init and restore: stop the actor,
1414
+ // abort/drain nested and host work, optionally emit one best-effort
1415
+ // session.disposed boundary, and unbind every closure field so dispose
1416
+ // stays callable. The caller rethrows its original failure. A restore
1417
+ // failure skips the disposal trace — the parked session was never
1418
+ // re-bound in this process, so its persisted snapshot stays
1419
+ // authoritative (DR-014 §2).
1420
+ async function cleanupFailedStart(cause, options) {
1421
+ let finalState;
1422
+ if (options.emitDisposal && actor) {
1423
+ try {
1424
+ finalState = currentState();
1425
+ }
1426
+ catch {
1427
+ // A state that cannot even normalize has no disposal descriptor.
1428
+ }
1429
+ }
1430
+ suppressInspectionEmissions = true;
1431
+ try {
1432
+ actor?.stop();
1433
+ }
1434
+ catch {
1435
+ // Preserve the original startup failure.
1436
+ }
1437
+ try {
1438
+ await nestedBridge.abortPending(cause);
1439
+ }
1440
+ catch {
1441
+ // Preserve the original startup failure.
1442
+ }
1443
+ try {
1444
+ await judgeQueue.onIdle();
873
1445
  await drainEmissions();
1446
+ }
1447
+ catch {
1448
+ // Preserve the original startup failure.
1449
+ }
1450
+ if (options.emitDisposal) {
1451
+ try {
1452
+ await emitTrace('session.disposed', finalState === undefined
1453
+ ? {}
1454
+ : {
1455
+ state: finalState,
1456
+ ...stateIdentity(finalState.stateId),
1457
+ });
1458
+ await drainEmissions();
1459
+ }
1460
+ catch {
1461
+ // The session-start error remains authoritative.
1462
+ }
1463
+ }
1464
+ playerResumeTokens.clear();
1465
+ activePlayerIds.clear();
1466
+ playbookCallTurnIds.clear();
1467
+ activeEmissionCalls.clear();
1468
+ emissionQueue.clear();
1469
+ judgeQueue.clear();
1470
+ actor = undefined;
1471
+ session = undefined;
1472
+ savedPorts = undefined;
1473
+ runtimePorts = undefined;
1474
+ activeSignal = undefined;
1475
+ activeTurnId = undefined;
1476
+ controlPlaneError = undefined;
1477
+ emissionFailure = undefined;
1478
+ priorState = undefined;
1479
+ suppressInspectionEmissions = false;
1480
+ initialized = false;
1481
+ traceSequence = 0;
1482
+ turnSequence = 0;
1483
+ judgeCallSequence = 0;
1484
+ playerCallSequence = 0;
1485
+ playbookCallSequence = 0;
1486
+ }
1487
+ const runtime = {
1488
+ async init(nextSession) {
1489
+ if (initialized || disposed || disposalPromise !== undefined) {
1490
+ throw new Error('createPlaybookRuntime.init: already initialized');
1491
+ }
1492
+ const boundSession = snapshotPlaybookSession(nextSession);
1493
+ initialized = true;
1494
+ let finishInitialization;
1495
+ const initialization = new Promise((resolve) => {
1496
+ finishInitialization = resolve;
1497
+ });
1498
+ initInFlight = initialization;
1499
+ const initTask = (async () => {
1500
+ session = boundSession;
1501
+ savedPorts = boundSession.ports;
1502
+ runtimePorts = createRuntimePorts(boundSession.ports);
1503
+ suppressInspectionEmissions = false;
1504
+ actor = buildActor(runtimePorts);
1505
+ await emitTrace('session.started', stateTracePayload());
1506
+ actor.start();
1507
+ await drainEmissions();
1508
+ })();
1509
+ try {
1510
+ await initTask;
1511
+ }
1512
+ catch (error) {
1513
+ await cleanupFailedStart(error, { emitDisposal: true });
1514
+ throw error;
1515
+ }
1516
+ finally {
1517
+ finishInitialization();
1518
+ if (initInFlight === initialization)
1519
+ initInFlight = undefined;
1520
+ }
1521
+ },
1522
+ // DR-014 §1 / PBRT-45: JSON-safe capture of a parked session.
1523
+ // Defined only at a safe capture point — initialized, not disposing
1524
+ // or disposed, no active public boundary, no pending nested call,
1525
+ // and the actor quiescent with status `active`.
1526
+ exportSnapshot() {
1527
+ if (!actor || !session || disposed || disposalPromise !== undefined) {
1528
+ return undefined;
1529
+ }
1530
+ if (activeSignal !== undefined)
1531
+ return undefined;
1532
+ if (nestedBridge.getPendingCall())
1533
+ return undefined;
1534
+ const state = currentState();
1535
+ if (state.status !== 'active' || !state.quiescent)
1536
+ return undefined;
1537
+ const machine = detachPersistedMachineSnapshot(actor.getPersistedSnapshot());
1538
+ const context = actor.getSnapshot()
1539
+ .context;
1540
+ const pending = pendingBossQuestionFromContext(context ?? {});
1541
+ return {
1542
+ schemaVersion: 1,
1543
+ playbookId: session.playbookId,
1544
+ machine,
1545
+ playerResumeTokens: Object.fromEntries(playerResumeTokens),
1546
+ sequences: {
1547
+ trace: traceSequence,
1548
+ turn: turnSequence,
1549
+ judgeCall: judgeCallSequence,
1550
+ playerCall: playerCallSequence,
1551
+ playbookCall: playbookCallSequence,
1552
+ },
1553
+ state,
1554
+ pendingBossQuestions: pending === undefined
1555
+ ? []
1556
+ : [
1557
+ {
1558
+ questionId: pending.questionId,
1559
+ player: pending.player,
1560
+ question: pending.question,
1561
+ sourceItem: pending.sourceItem,
1562
+ },
1563
+ ],
1564
+ };
1565
+ },
1566
+ // DR-014 §1 / PBRT-45: alternative to `init` that rehydrates an
1567
+ // exported snapshot under the same immutable session identity.
1568
+ // Emits no `session.started`, transition trace, or human status —
1569
+ // the session already started; the next public boundary continues
1570
+ // the contiguous trace sequence.
1571
+ async restore(nextSession, snapshot) {
1572
+ if (initialized || disposed || disposalPromise !== undefined) {
1573
+ throw new Error('createPlaybookRuntime.restore: already initialized');
1574
+ }
1575
+ const boundSession = snapshotPlaybookSession(nextSession);
1576
+ const boundSnapshot = assertPlaybookRuntimeSnapshot(snapshot, boundSession.playbookId);
1577
+ initialized = true;
1578
+ let finishInitialization;
1579
+ const initialization = new Promise((resolve) => {
1580
+ finishInitialization = resolve;
1581
+ });
1582
+ initInFlight = initialization;
1583
+ const initTask = (async () => {
1584
+ session = boundSession;
1585
+ savedPorts = boundSession.ports;
1586
+ runtimePorts = createRuntimePorts(boundSession.ports);
1587
+ traceSequence = boundSnapshot.sequences.trace;
1588
+ turnSequence = boundSnapshot.sequences.turn;
1589
+ judgeCallSequence = boundSnapshot.sequences.judgeCall;
1590
+ playerCallSequence = boundSnapshot.sequences.playerCall;
1591
+ playbookCallSequence = boundSnapshot.sequences.playbookCall;
1592
+ playerResumeTokens.clear();
1593
+ for (const [playerId, token] of Object.entries(boundSnapshot.playerResumeTokens)) {
1594
+ playerResumeTokens.set(playerId, token);
1595
+ }
1596
+ suppressInspectionEmissions = true;
1597
+ actor = buildActor(runtimePorts, boundSnapshot.machine);
1598
+ actor.start();
1599
+ const restoredState = currentState();
1600
+ if (restoredState.status !== 'active') {
1601
+ throw new Error(`createPlaybookRuntime.restore: restored actor status is ${restoredState.status}, expected active`);
1602
+ }
1603
+ suppressInspectionEmissions = false;
1604
+ priorState = restoredState;
1605
+ await drainEmissions();
1606
+ })();
1607
+ try {
1608
+ await initTask;
1609
+ }
1610
+ catch (error) {
1611
+ await cleanupFailedStart(error, { emitDisposal: false });
1612
+ throw error;
1613
+ }
1614
+ finally {
1615
+ finishInitialization();
1616
+ if (initInFlight === initialization)
1617
+ initInFlight = undefined;
1618
+ }
874
1619
  },
875
1620
  async handleBossInput({ text, signal, }) {
876
1621
  if (!actor || !savedPorts) {
877
1622
  throw new Error('createPlaybookRuntime.handleBossInput: init must be called first');
878
1623
  }
1624
+ if (disposed || disposalPromise !== undefined) {
1625
+ throw new Error('createPlaybookRuntime.handleBossInput: runtime is disposing or disposed');
1626
+ }
1627
+ if (activeSignal !== undefined) {
1628
+ throw new Error('createPlaybookRuntime.handleBossInput: another runtime turn is active');
1629
+ }
1630
+ const turnId = ++turnSequence;
1631
+ activeTurnId = turnId;
879
1632
  activeSignal = signal;
1633
+ controlPlaneError = undefined;
1634
+ let result;
1635
+ let operationError;
880
1636
  try {
1637
+ await emitTrace('boss.input.received', { text }, { turnId });
881
1638
  // 1. Classify non-empty text into an FSM event through the judge.
882
- const event = await classifyBossText(text, savedPorts, signal, actor.getSnapshot());
1639
+ const event = await classifyBossText(text, runtimePorts, signal, actor.getSnapshot(), boundary);
883
1640
  // Empty input, no-action classifier output, or invalid classifier
884
1641
  // output — nothing to send.
885
1642
  if (event === undefined) {
886
- await drainEmissions();
887
- return;
1643
+ result = runResultFor('no-action');
888
1644
  }
889
- // 2. Captain-pane classification line (PBRT-14): the bare
890
- // FSM event type, emitted before the FSM advances so the
891
- // host can render it as captain speech (e.g.,
892
- // `captain> START_CODING`). Enqueued so it interleaves
893
- // cleanly with the inspect-driven transition emissions.
894
- const echoPorts = savedPorts;
895
- enqueueEmit(() => echoPorts.emitStatus(formatClassification(event.type)));
896
- // 3. final state ('done') cannot accept new events — dispose
897
- // and reconstruct per DR-004 §5.
898
- if (actor.getSnapshot().status === 'done') {
899
- actor.stop();
900
- actor = buildActor(savedPorts);
901
- actor.start();
1645
+ else {
1646
+ // 2. Captain-pane classification line (PBRT-14): the bare
1647
+ // FSM event type, emitted before the FSM advances.
1648
+ await runtimePorts.emitStatus(formatClassification(event.type));
1649
+ // 3. A final actor cannot accept new events; reconstruct only after
1650
+ // classification produced a real event.
1651
+ if (actor.getSnapshot().status === 'done') {
1652
+ actor.stop();
1653
+ actor = buildActor(runtimePorts);
1654
+ actor.start();
1655
+ }
1656
+ actor.send(event);
1657
+ await waitForPlaybookQuiescence(actor, {
1658
+ pendingCalls: nestedBridge,
1659
+ });
1660
+ if (controlPlaneError !== undefined)
1661
+ throw controlPlaneError;
1662
+ result = runResultFor(settledOutcome(signal));
902
1663
  }
903
- // 4. Send the event.
904
- actor.send(event);
905
- // 5. Drive to quiescence. On signal-abort we take no FSM
906
- // action: the captain bridge's awaited callPlayer rejects
907
- // naturally, the bridge throws, XState routes through
908
- // onError → #failed, and this loop sees the quiescent
909
- // snapshot and returns (DR-004 §8 natural rejection).
910
- await driveToQuiescence(actor);
911
- // Drain transition emissions before returning so the Boss
912
- // sees the final status line for this turn.
1664
+ }
1665
+ catch (error) {
1666
+ operationError = error;
1667
+ }
1668
+ let drainError;
1669
+ try {
913
1670
  await drainEmissions();
914
1671
  }
915
- finally {
916
- activeSignal = undefined;
1672
+ catch (error) {
1673
+ drainError = error;
917
1674
  }
1675
+ const latchedControlError = controlPlaneError;
1676
+ const primaryError = latchedControlError ?? drainError ?? operationError;
1677
+ const abortError = latchedControlError === undefined &&
1678
+ drainError === undefined &&
1679
+ operationError !== undefined &&
1680
+ isAbortFailure(operationError, signal);
1681
+ const settlementResult = primaryError === undefined
1682
+ ? (result ?? runResultFor('no-action'))
1683
+ : runResultFor(abortError ? 'aborted' : 'failed', primaryError);
1684
+ let settlementEmissionError;
1685
+ try {
1686
+ await emitTrace('boss.input.settled', settlementTracePayload(settlementResult), { turnId });
1687
+ }
1688
+ catch (error) {
1689
+ settlementEmissionError = error;
1690
+ }
1691
+ try {
1692
+ await drainEmissions();
1693
+ }
1694
+ catch (error) {
1695
+ settlementEmissionError ??= error;
1696
+ }
1697
+ const failure = controlPlaneError ??
1698
+ latchedControlError ??
1699
+ drainError ??
1700
+ (abortError
1701
+ ? (settlementEmissionError ?? operationError)
1702
+ : (operationError ?? settlementEmissionError));
1703
+ activeSignal = undefined;
1704
+ activeTurnId = undefined;
1705
+ controlPlaneError = undefined;
1706
+ if (failure !== undefined &&
1707
+ !(abortError && settlementEmissionError === undefined)) {
1708
+ throw failure;
1709
+ }
1710
+ return settlementResult;
918
1711
  },
919
- async dispose() {
920
- if (actor) {
921
- actor.stop();
922
- actor = undefined;
1712
+ async resumePlaybookCall(input) {
1713
+ if (!actor || !savedPorts) {
1714
+ throw new Error('createPlaybookRuntime.resumePlaybookCall: init must be called first');
923
1715
  }
924
- // Drain any in-flight emissions per slc/link.md §Session
925
- // lifecycle ("stop the actor and drain pending port emissions").
926
- await drainEmissions();
927
- savedPorts = undefined;
1716
+ if (disposed || disposalPromise !== undefined) {
1717
+ throw new Error('createPlaybookRuntime.resumePlaybookCall: runtime is disposing or disposed');
1718
+ }
1719
+ if (activeSignal !== undefined) {
1720
+ throw new Error('createPlaybookRuntime.resumePlaybookCall: another runtime turn is active');
1721
+ }
1722
+ activeTurnId = playbookCallTurnIds.get(input.callId);
1723
+ activeSignal = input.signal;
1724
+ controlPlaneError = undefined;
1725
+ let result;
1726
+ let operationError;
1727
+ try {
1728
+ await nestedBridge.resume(input);
1729
+ }
1730
+ catch (error) {
1731
+ operationError = error;
1732
+ }
1733
+ try {
1734
+ await waitForPlaybookQuiescence(actor, {
1735
+ pendingCalls: nestedBridge,
1736
+ });
1737
+ result = runResultFor(settledOutcome(input.signal));
1738
+ }
1739
+ catch (error) {
1740
+ operationError ??= error;
1741
+ }
1742
+ let drainError;
1743
+ try {
1744
+ await drainEmissions();
1745
+ }
1746
+ catch (error) {
1747
+ drainError = error;
1748
+ }
1749
+ const failure = controlPlaneError ?? drainError ?? operationError;
1750
+ activeSignal = undefined;
1751
+ activeTurnId = undefined;
1752
+ controlPlaneError = undefined;
1753
+ if (failure !== undefined)
1754
+ throw failure;
1755
+ if (result === undefined) {
1756
+ throw new Error('playbook resume produced no runtime result');
1757
+ }
1758
+ return result;
1759
+ },
1760
+ dispose() {
1761
+ if (disposalPromise !== undefined)
1762
+ return disposalPromise;
1763
+ if (disposed)
1764
+ return Promise.resolve();
1765
+ if (activeSignal !== undefined) {
1766
+ return Promise.reject(new Error('createPlaybookRuntime.dispose: cannot dispose during an active runtime boundary'));
1767
+ }
1768
+ const task = (async () => {
1769
+ const failures = [];
1770
+ try {
1771
+ if (initInFlight !== undefined) {
1772
+ try {
1773
+ await initInFlight;
1774
+ }
1775
+ catch {
1776
+ // Dispose still releases whatever an unsuccessful init bound.
1777
+ }
1778
+ }
1779
+ const finalState = actor ? currentState() : undefined;
1780
+ // Stop the root before settling a suspended child. Its rejection
1781
+ // must not re-enter CODE and start fresh work during disposal.
1782
+ if (actor)
1783
+ actor.stop();
1784
+ try {
1785
+ await nestedBridge.dispose();
1786
+ }
1787
+ catch (error) {
1788
+ failures.push(error);
1789
+ }
1790
+ try {
1791
+ await drainEmissions();
1792
+ }
1793
+ catch (error) {
1794
+ failures.push(error);
1795
+ }
1796
+ if (session !== undefined) {
1797
+ try {
1798
+ await emitTrace('session.disposed', finalState === undefined
1799
+ ? {}
1800
+ : {
1801
+ state: finalState,
1802
+ ...stateIdentity(finalState.stateId),
1803
+ });
1804
+ await drainEmissions();
1805
+ }
1806
+ catch (error) {
1807
+ failures.push(error);
1808
+ }
1809
+ }
1810
+ }
1811
+ finally {
1812
+ playerResumeTokens.clear();
1813
+ activePlayerIds.clear();
1814
+ playbookCallTurnIds.clear();
1815
+ activeEmissionCalls.clear();
1816
+ emissionQueue.clear();
1817
+ judgeQueue.clear();
1818
+ actor = undefined;
1819
+ activeSignal = undefined;
1820
+ activeTurnId = undefined;
1821
+ controlPlaneError = undefined;
1822
+ emissionFailure = undefined;
1823
+ savedPorts = undefined;
1824
+ runtimePorts = undefined;
1825
+ session = undefined;
1826
+ disposed = true;
1827
+ }
1828
+ if (failures.length === 1)
1829
+ throw failures[0];
1830
+ if (failures.length > 1) {
1831
+ throw new AggregateError(failures, 'playbook runtime disposal failed');
1832
+ }
1833
+ })();
1834
+ disposalPromise = task;
1835
+ return task;
928
1836
  },
929
1837
  // @internal — test-only escape hatch for inspecting the
930
1838
  // underlying actor's snapshot. Most state assertions are now
@@ -935,24 +1843,12 @@ export default function createPlaybookRuntime(options) {
935
1843
  _getActor() {
936
1844
  return actor;
937
1845
  },
1846
+ _getBoundary() {
1847
+ return boundary;
1848
+ },
1849
+ _getNestedBridge() {
1850
+ return nestedBridge;
1851
+ },
938
1852
  };
939
1853
  return runtime;
940
1854
  }
941
- function driveToQuiescence(actor) {
942
- return new Promise((resolve) => {
943
- if (isQuiescent(actor.getSnapshot())) {
944
- resolve();
945
- return;
946
- }
947
- const sub = actor.subscribe((snap) => {
948
- if (isQuiescent(snap)) {
949
- sub.unsubscribe();
950
- resolve();
951
- }
952
- });
953
- });
954
- }
955
- function isQuiescent(snap) {
956
- const v = snap.value;
957
- return typeof v === 'string' && QUIESCENT_STATES.has(v);
958
- }