@sublang/playbook 7.0.0 → 9.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 (62) hide show
  1. package/README.md +20 -7
  2. package/docs/cli.md +88 -43
  3. package/docs/configuration.md +221 -119
  4. package/docs/embedding.md +78 -27
  5. package/package.json +4 -3
  6. package/reference/sdlc/captain.playbook/captain.playbook.js +16 -5
  7. package/reference/sdlc/captain.playbook/captain.playbook.ts +20 -6
  8. package/reference/sdlc/code.md +1 -1
  9. package/reference/sdlc/code.playbook/bin/interactive-session.js +816 -0
  10. package/reference/sdlc/code.playbook/bin/launch-config.js +1078 -116
  11. package/reference/sdlc/code.playbook/bin/playbook.js +489 -34
  12. package/reference/sdlc/code.playbook/bin/run.js +283 -298
  13. package/reference/sdlc/code.playbook/bin/session-store.js +818 -26
  14. package/reference/sdlc/code.playbook/code.fsm.d.ts +9 -6
  15. package/reference/sdlc/code.playbook/code.fsm.introspect.js +2 -2
  16. package/reference/sdlc/code.playbook/code.fsm.introspect.ts +2 -2
  17. package/reference/sdlc/code.playbook/code.fsm.js +18 -15
  18. package/reference/sdlc/code.playbook/code.fsm.ts +21 -21
  19. package/reference/sdlc/code.playbook/code.gears.md +1 -1
  20. package/reference/sdlc/code.playbook/code.playbook.d.ts +2 -1
  21. package/reference/sdlc/code.playbook/code.playbook.js +25 -15
  22. package/reference/sdlc/code.playbook/code.playbook.ts +34 -17
  23. package/reference/sdlc/code.playbook/code.registry.d.ts +5 -13
  24. package/reference/sdlc/code.playbook/code.registry.js +3 -10
  25. package/reference/sdlc/code.playbook/code.registry.ts +7 -32
  26. package/reference/sdlc/code.playbook/playbook-captain.d.ts +39 -14
  27. package/reference/sdlc/code.playbook/playbook-captain.js +1014 -299
  28. package/reference/sdlc/code.playbook/playbook-captain.ts +1450 -406
  29. package/reference/sdlc/code.playbook/playbook.config.template.yaml +41 -49
  30. package/reference/sdlc/decide.md +4 -4
  31. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +10 -10
  32. package/reference/sdlc/decide.playbook/decide.fsm.js +21 -14
  33. package/reference/sdlc/decide.playbook/decide.fsm.ts +27 -23
  34. package/reference/sdlc/decide.playbook/decide.gears.md +3 -5
  35. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +11 -13
  36. package/reference/sdlc/decide.playbook/decide.playbook.js +465 -246
  37. package/reference/sdlc/decide.playbook/decide.playbook.ts +623 -283
  38. package/reference/sdlc/decide.playbook/decide.registry.d.ts +5 -13
  39. package/reference/sdlc/decide.playbook/decide.registry.js +3 -9
  40. package/reference/sdlc/decide.playbook/decide.registry.ts +7 -31
  41. package/reference/sdlc/review.md +4 -5
  42. package/reference/sdlc/review.playbook/review.fsm.d.ts +9 -11
  43. package/reference/sdlc/review.playbook/review.fsm.js +30 -24
  44. package/reference/sdlc/review.playbook/review.fsm.ts +39 -35
  45. package/reference/sdlc/review.playbook/review.gears.md +6 -5
  46. package/reference/sdlc/review.playbook/review.playbook.d.ts +2 -1
  47. package/reference/sdlc/review.playbook/review.playbook.js +29 -23
  48. package/reference/sdlc/review.playbook/review.playbook.ts +38 -28
  49. package/reference/sdlc/review.playbook/review.registry.d.ts +5 -13
  50. package/reference/sdlc/review.playbook/review.registry.js +3 -16
  51. package/reference/sdlc/review.playbook/review.registry.ts +7 -38
  52. package/slc/gears2fsm.md +45 -24
  53. package/slc/link.md +297 -135
  54. package/slc/text2gears.md +19 -18
  55. package/src/runtime.d.ts +21 -16
  56. package/src/runtime.ts +20 -23
  57. package/src/xstate-playbook-runtime.d.ts +34 -20
  58. package/src/xstate-playbook-runtime.js +973 -400
  59. package/src/xstate-playbook-runtime.ts +1203 -457
  60. package/src/xstate-runtime.d.ts +17 -7
  61. package/src/xstate-runtime.js +198 -81
  62. package/src/xstate-runtime.ts +339 -112
@@ -7,8 +7,8 @@
7
7
  // Linker inputs:
8
8
  // FSM artifact: ./decide.fsm.ts
9
9
  // Link target: @sublang/playbook/runtime
10
- // Player binding: Coder -> coder, Reviewer -> reviewer
11
- // (default binding: lowercased player name)
10
+ // Role binding: canonical coder and reviewer roles; concrete players
11
+ // and prompt identities are supplied by the host session
12
12
  // Adjudication: LLM-judge per state (default)
13
13
  // Boss-event mapping: free-text judge classification (default)
14
14
  // Abort strategy: natural rejection; every player-invoking state's
@@ -19,80 +19,64 @@ import PQueue from 'p-queue';
19
19
  import { createActor, fromPromise } from 'xstate';
20
20
  import { assertJsonSafe, assertPlaybookRuntimeSnapshot, combineAbortSignals, createNestedPlaybookBridge, detachPersistedMachineSnapshot, normalizeError, normalizePlaybookSnapshot, snapshotJsonValue, snapshotPlaybookSession, validatePlayerResult, waitForPlaybookQuiescence, } from '../../../src/xstate-runtime.js';
21
21
  import decideMachine from './decide.fsm.js';
22
- const DEFAULT_PLAYER_BINDING = {
23
- Coder: 'coder',
24
- Reviewer: 'reviewer',
25
- };
26
22
  function snapshotDecideRuntimeOptions(value) {
27
23
  const captured = snapshotJsonValue(value, 'DECIDE runtime options');
28
24
  if (!isPlainObject(captured)) {
29
25
  throw new TypeError('DECIDE runtime options must be an object');
30
26
  }
31
- const allowed = new Set(['coderLlm', 'playerBinding']);
32
- for (const key of Object.keys(captured)) {
33
- if (!allowed.has(key)) {
34
- throw new TypeError(`DECIDE runtime options.${key} is not declared`);
35
- }
36
- }
37
- if (typeof captured.coderLlm !== 'string' ||
38
- captured.coderLlm.trim().length === 0) {
39
- throw new TypeError('DECIDE runtime options.coderLlm must be a non-empty string');
27
+ const [unknown] = Object.keys(captured);
28
+ if (unknown !== undefined) {
29
+ throw new TypeError(`DECIDE runtime options.${unknown} is not declared`);
40
30
  }
41
- if ('playerBinding' in captured) {
42
- const playerBinding = captured.playerBinding;
43
- if (!isPlainObject(playerBinding)) {
44
- throw new TypeError('DECIDE runtime options.playerBinding must be an object');
45
- }
46
- const playerNames = new Set(['Coder', 'Reviewer']);
47
- for (const [player, playerId] of Object.entries(playerBinding)) {
48
- if (!playerNames.has(player)) {
49
- throw new TypeError(`DECIDE runtime options.playerBinding.${player} is not declared`);
50
- }
51
- if (typeof playerId !== 'string' || playerId.trim().length === 0) {
52
- throw new TypeError(`DECIDE runtime options.playerBinding.${player} must be a non-empty string`);
31
+ return Object.freeze({});
32
+ }
33
+ function authoredStateDescriptions(states) {
34
+ const descriptions = {};
35
+ const visit = (children) => {
36
+ for (const state of Object.values(children ?? {})) {
37
+ const stateId = state.meta?.playbook?.stateId;
38
+ const description = state.meta?.playbook?.description;
39
+ if (typeof stateId === 'string' &&
40
+ typeof description === 'string' &&
41
+ description.trim().length > 0) {
42
+ const existing = descriptions[stateId];
43
+ if (existing !== undefined && existing !== description) {
44
+ throw new Error(`DECIDE state ${stateId} declares conflicting descriptions`);
45
+ }
46
+ descriptions[stateId] = description;
53
47
  }
48
+ visit(state.states);
54
49
  }
55
- }
56
- return captured;
50
+ };
51
+ visit(states);
52
+ return Object.freeze(descriptions);
57
53
  }
58
- const STATE_DESCRIPTIONS = {
59
- ready: 'Waiting for a topic to decide.',
60
- askCoderProposal: 'Coder independently proposes a spec design.',
61
- askReviewerProposal: 'Reviewer independently proposes a spec design.',
62
- waitCoderProposalReply: 'Coder waits for Boss to answer a question.',
63
- waitReviewerProposalReply: 'Reviewer waits for Boss to answer a question.',
64
- commitCoderProposal: 'Coder writes and commits Coder’s independent proposal.',
65
- awaitBossReply: 'Waiting for Boss to answer Coder’s question.',
66
- reviewCommit: 'REVIEW examines the committed proposal.',
67
- failed: 'DECIDE failed and is waiting for a new topic.',
68
- reportedReviewFailure: 'DECIDE reports REVIEW’s failure and its last commit.',
69
- done: 'DECIDE completed with an approved commit.',
70
- };
71
- const PLAYER_STATES = [
72
- { stateId: 'askCoderProposal', player: 'Coder', sourceItem: 'DECIDE-1' },
54
+ const STATE_DESCRIPTIONS = authoredStateDescriptions(decideMachine.config.states);
55
+ const ROLE_STATES = [
56
+ { stateId: 'askCoderProposal', role: 'coder', sourceItem: 'DECIDE-1' },
73
57
  {
74
58
  stateId: 'askReviewerProposal',
75
- player: 'Reviewer',
59
+ role: 'reviewer',
76
60
  sourceItem: 'DECIDE-2',
77
61
  },
78
- { stateId: 'commitCoderProposal', player: 'Coder', sourceItem: 'DECIDE-3' },
62
+ { stateId: 'commitCoderProposal', role: 'coder', sourceItem: 'DECIDE-3' },
79
63
  ];
80
- const PLAYER_STATE_IDS = new Set(PLAYER_STATES.map((state) => state.stateId));
64
+ const ROLE_STATE_IDS = new Set(ROLE_STATES.map((state) => state.stateId));
65
+ const ROLE_IDS = ['coder', 'reviewer'];
66
+ const ROLE_ID_SET = new Set(ROLE_IDS);
67
+ const roleLabel = (roleId) => roleId === 'coder' ? 'Coder' : 'Reviewer';
81
68
  const BOSS_INTERRUPT_TARGETS = ['independentProposals'];
82
69
  const BOSS_INTERRUPT_TARGET_IDS = new Set(BOSS_INTERRUPT_TARGETS);
83
70
  const TELEMETRY_TOPIC = 'playbook.fsm.state';
84
71
  const TRACE_TOPIC = 'playbook.trace';
85
72
  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.';
86
- const PLACEHOLDER_FIELDS = [
87
- ['<caller-topic>', 'callerTopic'],
88
- ['<coder-llm>', 'coderLlm'],
89
- ];
73
+ const PLACEHOLDER_FIELDS = [['<caller-topic>', 'callerTopic']];
90
74
  const VERBATIM_PAYLOAD_FIELDS = new Set([
91
75
  'coderProposal',
92
76
  'reviewerProposal',
93
77
  'coderOutput',
94
78
  ]);
95
- function composePlayerPrompt(input) {
79
+ function composePlayerPrompt(input, promptIdentity) {
96
80
  const blocks = [];
97
81
  if (input.pendingBossQuestion && input.bossReply !== undefined) {
98
82
  blocks.push([
@@ -111,6 +95,9 @@ function composePlayerPrompt(input) {
111
95
  if (typeof value === 'string')
112
96
  replacements.set(placeholder, value);
113
97
  }
98
+ if (input.prompt.includes('<coder-llm>')) {
99
+ replacements.set('<coder-llm>', promptIdentity('coder'));
100
+ }
114
101
  const body = input.prompt.replace(/<caller-topic>|<coder-llm>/g, (placeholder, offset, source) => {
115
102
  const value = replacements.get(placeholder);
116
103
  if (value === undefined)
@@ -123,18 +110,6 @@ function composePlayerPrompt(input) {
123
110
  blocks.push(body);
124
111
  return blocks.join('\n\n');
125
112
  }
126
- function resolvePlayerId(input, binding) {
127
- switch (input.player) {
128
- case 'Coder':
129
- return binding.Coder;
130
- case 'Reviewer':
131
- return binding.Reviewer;
132
- default: {
133
- const exhaustive = input.player;
134
- throw new Error(`unknown player ${String(exhaustive)}`);
135
- }
136
- }
137
- }
138
113
  // A `result` description names required payload fields in its
139
114
  // "Output shall include ..." sentence.
140
115
  function requiredFieldsFor(description) {
@@ -283,7 +258,7 @@ function buildClassifierPrompt(text, ctx) {
283
258
  if (ctx.pendingQuestions.length > 0) {
284
259
  lines.push('Pending Boss questions:');
285
260
  for (const pending of ctx.pendingQuestions) {
286
- lines.push(`- ${pending.questionId} (${pending.player}): ${pending.question}`);
261
+ lines.push(`- ${pending.questionId} (${pending.asker.roleId}): ${pending.question}`);
287
262
  }
288
263
  lines.push('If the Boss message answers a pending question, classify it as BOSS_REPLY; if it is a fresh directive, classify it accordingly.');
289
264
  }
@@ -354,7 +329,7 @@ function buildAdjudicatorPrompt(input, playerOutput) {
354
329
  lines.push('This is hidden control work. Do not call tools, inspect files, or ' +
355
330
  'seek external evidence. Decide only from the supplied player output ' +
356
331
  'and guard descriptions. Reply with exactly one JSON object and no prose.');
357
- lines.push(`The player "${input.player}" produced the output below for source item ${input.sourceItem}.`);
332
+ lines.push(`The role "${roleLabel(input.role)}" produced the output below for source item ${input.sourceItem}.`);
358
333
  lines.push('Choose exactly one guard whose description matches that output.');
359
334
  lines.push('');
360
335
  lines.push('Player output (verbatim):');
@@ -435,6 +410,14 @@ function isEmptyFinalText(finalText) {
435
410
  function isAbortFailure(error, signal) {
436
411
  return signal.aborted && Object.is(error, signal.reason);
437
412
  }
413
+ function abortReasonClassifier(...sources) {
414
+ const captured = sources.filter((source) => source !== undefined);
415
+ return Object.freeze({
416
+ isAbortReason: (error) => captured.some((source) => source instanceof AbortSignal
417
+ ? isAbortFailure(error, source)
418
+ : source.isAbortReason(error)),
419
+ });
420
+ }
438
421
  function pendingQuestionsFromContext(context) {
439
422
  const pending = context.pendingBossQuestions;
440
423
  if (pending === undefined ||
@@ -452,7 +435,9 @@ function pendingQuestionsFromContext(context) {
452
435
  obj.questionId === key &&
453
436
  typeof obj.resumeStateId === 'string' &&
454
437
  typeof obj.sourceItem === 'string' &&
455
- typeof obj.player === 'string' &&
438
+ isPlainObject(obj.asker) &&
439
+ obj.asker.kind === 'role' &&
440
+ ROLE_ID_SET.has(String(obj.asker.roleId)) &&
456
441
  typeof obj.question === 'string') {
457
442
  questions.push(obj);
458
443
  }
@@ -468,10 +453,26 @@ const WAIT_STATE_IDS = new Set([
468
453
  'awaitBossReply',
469
454
  ]);
470
455
  const STATUS_STATE_IDS = new Set([
471
- ...PLAYER_STATE_IDS,
456
+ ...ROLE_STATE_IDS,
472
457
  ...WAIT_STATE_IDS,
473
458
  'failed',
474
459
  ]);
460
+ // PBRT-45: a question is pending only while its authored reply-wait state
461
+ // is active. The context retains an answered question through the resumed
462
+ // player call so the Q+A continuation prompt can quote it, and each branch
463
+ // keeps its own entry through the parallel region — so an unfiltered
464
+ // projection would report the answered question as still awaiting during
465
+ // the resume, and both branch questions after only one remains pending.
466
+ const RESUME_WAIT_STATE_IDS = {
467
+ ...Object.fromEntries(Object.entries(WAIT_STATE_RESUME_IDS).map(([waitStateId, resumeStateId]) => [
468
+ resumeStateId,
469
+ waitStateId,
470
+ ])),
471
+ commitCoderProposal: 'awaitBossReply',
472
+ };
473
+ function pendingQuestionsForState(state, context) {
474
+ return pendingQuestionsFromContext(context).filter((pending) => state.activeStateIds.includes(RESUME_WAIT_STATE_IDS[pending.resumeStateId] ?? ''));
475
+ }
475
476
  function questionForWaitState(stateId, pendingQuestions) {
476
477
  const resumeStateId = WAIT_STATE_RESUME_IDS[stateId];
477
478
  if (resumeStateId !== undefined) {
@@ -510,7 +511,7 @@ function normalizedTransitionEvent(event) {
510
511
  return snapshotJsonValue(descriptor, 'FSM event');
511
512
  }
512
513
  function telemetryPayload(previousState, state, event, context) {
513
- const pendingBossQuestions = pendingQuestionsFromContext(context);
514
+ const pendingBossQuestions = pendingQuestionsForState(state, context);
514
515
  const prior = previousState ?? state;
515
516
  const payload = {
516
517
  from: prior.value,
@@ -527,18 +528,14 @@ function telemetryPayload(previousState, state, event, context) {
527
528
  return payload;
528
529
  }
529
530
  export const createPlaybookRuntime = (options) => {
530
- const boundOptions = snapshotDecideRuntimeOptions(options);
531
- const binding = {
532
- ...DEFAULT_PLAYER_BINDING,
533
- ...(boundOptions.playerBinding ?? {}),
534
- };
535
- const fsmInput = {
536
- coderLlm: boundOptions.coderLlm,
537
- };
531
+ const fsmInput = snapshotDecideRuntimeOptions(options);
538
532
  let ports;
539
533
  let sessionIdentity;
540
534
  let actor;
541
535
  let currentSignal;
536
+ let currentAborts;
537
+ const actorSettlementAborts = [];
538
+ let actorSettlementErrorAborts;
542
539
  let currentTurnId;
543
540
  let previousState;
544
541
  let suppressInspectionEmissions = false;
@@ -554,9 +551,9 @@ export const createPlaybookRuntime = (options) => {
554
551
  let disposalPromise;
555
552
  let controlPlaneError;
556
553
  let nestedBridge;
557
- const playerResumeTokens = new Map();
554
+ const privateResumeTokens = new Map();
558
555
  const playbookCallTurnIds = new Map();
559
- const inFlightPlayerIds = new Set();
556
+ const inFlightPlayerKeys = new Set();
560
557
  const activeBoundaryCalls = new Set();
561
558
  const activeEmissionCalls = new Set();
562
559
  const emissionQueue = new PQueue({ concurrency: 1 });
@@ -575,24 +572,27 @@ export const createPlaybookRuntime = (options) => {
575
572
  if (!isAbortFailure(error, signal))
576
573
  controlPlaneError ??= error;
577
574
  };
578
- const latchInspectionError = (error) => {
579
- if (currentSignal !== undefined) {
580
- latchControlPlaneError(error, currentSignal);
581
- }
582
- else {
575
+ const latchInspectionError = (error, aborts = currentAborts) => {
576
+ if (aborts?.isAbortReason(error))
577
+ return;
578
+ if (currentSignal !== undefined)
579
+ controlPlaneError ??= error;
580
+ else
583
581
  collectFailure(emissionFailures, error);
584
- }
585
582
  };
586
- const enqueue = (fn) => {
583
+ const enqueue = (fn, aborts = currentAborts) => {
584
+ const enqueueAborts = aborts;
587
585
  const queued = emissionQueue.add(fn);
588
586
  activeEmissionCalls.add(queued);
589
587
  void queued.then(() => activeEmissionCalls.delete(queued), (error) => {
590
588
  activeEmissionCalls.delete(queued);
591
- collectFailure(emissionFailures, error);
589
+ if (!enqueueAborts?.isAbortReason(error)) {
590
+ collectFailure(emissionFailures, error);
591
+ }
592
592
  });
593
593
  return queued;
594
594
  };
595
- const flush = async () => {
595
+ const flush = async (_aborts = currentAborts) => {
596
596
  while (true) {
597
597
  const active = [...activeEmissionCalls];
598
598
  if (active.length > 0)
@@ -608,9 +608,15 @@ export const createPlaybookRuntime = (options) => {
608
608
  return;
609
609
  const failures = emissionFailures;
610
610
  emissionFailures = [];
611
- if (failures.length === 1)
612
- throw failures[0];
613
- throw new AggregateError(failures, 'decide runtime emissions failed');
611
+ const failure = failures.length === 1
612
+ ? failures[0]
613
+ : new AggregateError(failures, 'decide runtime emissions failed');
614
+ // Enqueue ownership already classified every stored failure as distinct.
615
+ // Preserve that classification if an unrelated public boundary drains
616
+ // it with a signal whose reason happens to be the same object.
617
+ if (currentSignal !== undefined)
618
+ controlPlaneError ??= failure;
619
+ throw failure;
614
620
  };
615
621
  const drainBoundaryCallsAndEmissions = async () => {
616
622
  while (true) {
@@ -639,59 +645,130 @@ export const createPlaybookRuntime = (options) => {
639
645
  }
640
646
  return sessionIdentity;
641
647
  };
642
- const selectPlayerResume = (playerId) => {
648
+ const bindSession = (nextSession) => {
649
+ const bound = snapshotPlaybookSession(nextSession);
650
+ if (bound.roleBindings === undefined)
651
+ return bound;
652
+ const actual = Object.keys(bound.roleBindings).sort();
653
+ const expected = [...ROLE_IDS].sort();
654
+ const missing = expected.filter((roleId) => !actual.includes(roleId));
655
+ const extra = actual.filter((roleId) => !ROLE_ID_SET.has(roleId));
656
+ if (missing.length > 0 || extra.length > 0) {
657
+ throw new TypeError(`DECIDE session roleBindings must cover exactly [${expected.join(', ')}]` +
658
+ `${missing.length === 0 ? '' : `; missing [${missing.join(', ')}]`}` +
659
+ `${extra.length === 0 ? '' : `; extra [${extra.join(', ')}]`}`);
660
+ }
661
+ return bound;
662
+ };
663
+ const resolvedPlayerId = (roleId) => requireSessionIdentity().roleBindings?.[roleId]?.playerId;
664
+ const promptIdentity = (roleId) => requireSessionIdentity().roleBindings?.[roleId]?.promptIdentity ?? roleId;
665
+ const composeInvocationPrompt = (input) => {
666
+ let active = true;
667
+ const lookup = (roleId) => {
668
+ if (!active) {
669
+ throw new Error('DECIDE prompt identity lookup is no longer active for this invocation');
670
+ }
671
+ if (!ROLE_ID_SET.has(roleId)) {
672
+ throw new TypeError(`DECIDE prompt identity lookup rejected undeclared role ${String(roleId)}`);
673
+ }
674
+ return promptIdentity(roleId);
675
+ };
676
+ try {
677
+ return composePlayerPrompt(input, lookup);
678
+ }
679
+ finally {
680
+ active = false;
681
+ }
682
+ };
683
+ const continuationKey = (roleId, playerId) => playerId ?? roleId;
684
+ const tokensByContinuationKey = (tokens) => {
685
+ const byKey = new Map();
686
+ for (const [roleId, token] of Object.entries(tokens)) {
687
+ if (!ROLE_ID_SET.has(roleId)) {
688
+ throw new TypeError(`DECIDE role tokens contain unknown role ${roleId}`);
689
+ }
690
+ const typedRole = roleId;
691
+ const key = continuationKey(typedRole, resolvedPlayerId(typedRole));
692
+ const prior = byKey.get(key);
693
+ if (prior !== undefined && prior !== token) {
694
+ throw new TypeError(`DECIDE runtime snapshot assigns conflicting tokens to roles bound to player ${key}`);
695
+ }
696
+ byKey.set(key, token);
697
+ }
698
+ const rolesByKey = new Map();
699
+ for (const roleId of ROLE_IDS) {
700
+ const key = continuationKey(roleId, resolvedPlayerId(roleId));
701
+ rolesByKey.set(key, [...(rolesByKey.get(key) ?? []), roleId]);
702
+ }
703
+ for (const [key, roles] of rolesByKey) {
704
+ if (roles.length < 2)
705
+ continue;
706
+ const present = roles.filter((roleId) => tokens[roleId] !== undefined);
707
+ if (present.length !== 0 && present.length !== roles.length) {
708
+ throw new TypeError(`DECIDE role tokens must project player ${key} through every aliased role [${roles.join(', ')}]`);
709
+ }
710
+ }
711
+ return byKey;
712
+ };
713
+ const selectPlayerResume = (roleId, playerId) => {
643
714
  const session = requireSessionIdentity();
644
715
  const selected = session.playerSessions
645
- ? session.playerSessions.select(playerId)
646
- : playerResumeTokens.get(playerId) ?? false;
716
+ ? session.playerSessions.select(roleId)
717
+ : privateResumeTokens.get(continuationKey(roleId, playerId)) ?? false;
647
718
  if (selected !== false &&
648
719
  (typeof selected !== 'string' || selected.trim().length === 0)) {
649
- throw new TypeError(`player session store returned an invalid resume token for ${playerId}`);
720
+ throw new TypeError(`player session store returned an invalid resume token for role ${roleId}`);
650
721
  }
651
722
  return selected;
652
723
  };
653
- const updatePlayerResume = (playerId, resumeToken) => {
724
+ const updatePlayerResume = (roleId, playerId, result) => {
725
+ if (result.resumeToken === undefined && result.status !== 'ok')
726
+ return;
654
727
  const session = requireSessionIdentity();
655
728
  if (session.playerSessions) {
656
- session.playerSessions.update(playerId, resumeToken);
729
+ session.playerSessions.update(roleId, result.resumeToken);
657
730
  }
658
- else if (resumeToken !== undefined && resumeToken.trim().length > 0) {
659
- playerResumeTokens.set(playerId, resumeToken);
731
+ else if (result.resumeToken !== undefined) {
732
+ privateResumeTokens.set(continuationKey(roleId, playerId), result.resumeToken);
660
733
  }
661
734
  else {
662
- playerResumeTokens.delete(playerId);
735
+ privateResumeTokens.delete(continuationKey(roleId, playerId));
663
736
  }
664
737
  };
665
- const snapshotPlayerResumeTokens = () => {
738
+ const snapshotRoleResumeTokens = () => {
666
739
  const session = requireSessionIdentity();
667
740
  const captured = snapshotJsonValue(session.playerSessions
668
741
  ? session.playerSessions.snapshot()
669
- : Object.fromEntries(playerResumeTokens), 'player session store snapshot');
742
+ : Object.fromEntries(ROLE_IDS.flatMap((roleId) => {
743
+ const token = privateResumeTokens.get(continuationKey(roleId, resolvedPlayerId(roleId)));
744
+ return token === undefined ? [] : [[roleId, token]];
745
+ })), 'player session store snapshot');
670
746
  if (!isPlainObject(captured)) {
671
747
  throw new TypeError('player session store snapshot must be an object');
672
748
  }
673
749
  const tokens = {};
674
- for (const [playerId, token] of Object.entries(captured)) {
675
- if (playerId.trim().length === 0) {
676
- throw new TypeError('player session store snapshot player ids must be non-empty');
750
+ for (const [roleId, token] of Object.entries(captured)) {
751
+ if (!ROLE_ID_SET.has(roleId)) {
752
+ throw new TypeError(`player session store snapshot contains unknown role ${roleId}`);
677
753
  }
678
754
  if (typeof token !== 'string' || token.trim().length === 0) {
679
- throw new TypeError(`player session store snapshot token for ${playerId} must be a non-empty string`);
755
+ throw new TypeError(`player session store snapshot token for ${roleId} must be a non-empty string`);
680
756
  }
681
- tokens[playerId] = token;
757
+ tokens[roleId] = token;
682
758
  }
759
+ tokensByContinuationKey(tokens);
683
760
  return tokens;
684
761
  };
685
- const restorePlayerResumeTokens = (tokens) => {
762
+ const restoreRoleResumeTokens = (tokens) => {
763
+ const byKey = tokensByContinuationKey(tokens);
686
764
  const session = requireSessionIdentity();
687
765
  if (session.playerSessions) {
688
766
  session.playerSessions.restore(tokens);
689
767
  return;
690
768
  }
691
- playerResumeTokens.clear();
692
- for (const [playerId, token] of Object.entries(tokens)) {
693
- playerResumeTokens.set(playerId, token);
694
- }
769
+ privateResumeTokens.clear();
770
+ for (const [key, token] of byKey)
771
+ privateResumeTokens.set(key, token);
695
772
  };
696
773
  const currentState = (pendingCall = nestedBridge.getPendingCall()) => {
697
774
  const live = actor;
@@ -705,12 +782,12 @@ export const createPlaybookRuntime = (options) => {
705
782
  const stateIdentity = (state) => {
706
783
  return state.stateId === undefined ? {} : { stateId: state.stateId };
707
784
  };
708
- const enqueueTracedEmission = (type, payload, meta = {}, describedEmission) => {
785
+ const enqueueTracedEmission = (type, payload, meta = {}, describedEmission, aborts) => {
709
786
  const runtimePorts = requirePorts();
710
787
  const identity = requireSessionIdentity();
711
788
  const jsonPayload = snapshotJsonValue(payload, `trace ${type} payload`);
712
789
  const trace = Object.freeze({
713
- schemaVersion: 2,
790
+ schemaVersion: 3,
714
791
  sessionId: identity.sessionId,
715
792
  playbookId: identity.playbookId,
716
793
  rootSessionId: identity.rootSessionId,
@@ -731,9 +808,9 @@ export const createPlaybookRuntime = (options) => {
731
808
  return enqueue(async () => {
732
809
  await runtimePorts.emitTelemetry({ topic: TRACE_TOPIC, payload: trace });
733
810
  await describedEmission?.(runtimePorts);
734
- });
811
+ }, aborts);
735
812
  };
736
- const emitTrace = (type, payload, meta = {}) => enqueueTracedEmission(type, payload, meta);
813
+ const emitTrace = (type, payload, meta = {}, aborts) => enqueueTracedEmission(type, payload, meta, undefined, aborts);
737
814
  const emitBoundaryStatus = async (message, state) => {
738
815
  const bossRelevantStateIds = state.activeStateIds.filter((stateId) => STATUS_STATE_IDS.has(stateId));
739
816
  await enqueueTracedEmission('status.emitted', {
@@ -745,20 +822,24 @@ export const createPlaybookRuntime = (options) => {
745
822
  }, { turnId: currentTurnId }, (runtimePorts) => runtimePorts.emitStatus(message));
746
823
  };
747
824
  const emitCallStarted = async (startedType, finishedType, identity, meta, signal) => {
825
+ const aborts = abortReasonClassifier(signal);
748
826
  try {
749
- await emitTrace(startedType, identity, meta);
827
+ await emitTrace(startedType, identity, meta, aborts);
750
828
  }
751
829
  catch (error) {
752
830
  latchControlPlaneError(error, signal);
753
831
  try {
754
832
  await emitTrace(finishedType, {
755
833
  ...identity,
756
- status: 'error',
834
+ // A started-trace sink rejection causally identical to the
835
+ // boundary reason is the abort's own evidence: the pair
836
+ // finishes 'aborted', not 'error' (DR-036 §4).
837
+ status: isAbortFailure(error, signal) ? 'aborted' : 'error',
757
838
  error: normalizeErrorFull(error) ?? {
758
839
  name: 'Error',
759
840
  message: String(error),
760
841
  },
761
- }, meta);
842
+ }, meta, aborts);
762
843
  }
763
844
  catch {
764
845
  // Preserve the start failure after one best-effort finish attempt.
@@ -767,6 +848,7 @@ export const createPlaybookRuntime = (options) => {
767
848
  }
768
849
  };
769
850
  const runJudgeCall = async (prompt, signal, purpose, callStateId) => {
851
+ const aborts = abortReasonClassifier(signal);
770
852
  const identity = {
771
853
  purpose,
772
854
  ...(callStateId !== undefined ? { stateId: callStateId } : {}),
@@ -794,15 +876,18 @@ export const createPlaybookRuntime = (options) => {
794
876
  latchControlPlaneError(error, signal);
795
877
  await emitTrace('judge.call.finished', {
796
878
  ...identity,
797
- status: signal.aborted ? 'aborted' : 'error',
879
+ // Only the exact abort reason is cancellation; a distinct
880
+ // failure under an aborted signal stays an error
881
+ // (slc/link.md §Abort).
882
+ status: isAbortFailure(error, signal) ? 'aborted' : 'error',
798
883
  error: normalizeErrorFull(error) ?? {
799
884
  name: 'Error',
800
885
  message: String(error),
801
886
  },
802
- }, { turnId: currentTurnId, callId });
887
+ }, { turnId: currentTurnId, callId }, aborts);
803
888
  throw error;
804
889
  }
805
- await emitTrace('judge.call.finished', { ...identity, status: 'ok', reply: finalText }, { turnId: currentTurnId, callId });
890
+ await emitTrace('judge.call.finished', { ...identity, status: 'ok', reply: finalText }, { turnId: currentTurnId, callId }, aborts);
806
891
  return finalText;
807
892
  });
808
893
  if (queued === undefined) {
@@ -812,15 +897,18 @@ export const createPlaybookRuntime = (options) => {
812
897
  };
813
898
  const callJudge = (prompt, signal, purpose, callStateId) => trackBoundaryCall(runJudgeCall(prompt, signal, purpose, callStateId));
814
899
  const runPlayerCall = async (input, signal) => {
815
- const playerId = resolvePlayerId(input, binding);
816
- if (inFlightPlayerIds.has(playerId)) {
817
- throw new Error(`resolved player "${playerId}" already has an in-flight call`);
818
- }
819
- const prompt = composePlayerPrompt(input);
900
+ const aborts = abortReasonClassifier(signal);
901
+ if (!ROLE_ID_SET.has(input.role)) {
902
+ throw new TypeError(`DECIDE player input role must name a declared local role`);
903
+ }
904
+ const roleId = input.role;
905
+ const playerId = resolvedPlayerId(roleId);
906
+ const playerKey = continuationKey(roleId, playerId);
907
+ const prompt = composeInvocationPrompt(input);
820
908
  let resume;
821
909
  try {
822
910
  signal.throwIfAborted();
823
- resume = selectPlayerResume(playerId);
911
+ resume = selectPlayerResume(roleId, playerId);
824
912
  }
825
913
  catch (error) {
826
914
  latchControlPlaneError(error, signal);
@@ -828,27 +916,36 @@ export const createPlaybookRuntime = (options) => {
828
916
  }
829
917
  const callId = `player-${++playerCallSequence}`;
830
918
  const identity = {
831
- purpose: 'captain',
832
919
  stateId: input.stateId,
833
920
  sourceItem: input.sourceItem,
834
- playerId,
921
+ roleId,
922
+ ...(playerId === undefined ? {} : { playerId }),
835
923
  resume,
836
924
  };
837
925
  const emitFailure = (error) => emitTrace('player.call.finished', {
838
926
  ...identity,
839
- status: signal.aborted ? 'aborted' : 'error',
927
+ // Only the exact abort reason is cancellation; a distinct
928
+ // failure under an aborted signal stays an error
929
+ // (slc/link.md §Abort).
930
+ status: isAbortFailure(error, signal) ? 'aborted' : 'error',
840
931
  error: normalizeErrorFull(error) ?? {
841
932
  name: 'Error',
842
933
  message: String(error),
843
934
  },
844
- }, { turnId: currentTurnId, callId });
845
- inFlightPlayerIds.add(playerId);
935
+ }, { turnId: currentTurnId, callId }, aborts);
936
+ if (inFlightPlayerKeys.has(playerKey)) {
937
+ const error = new Error(`resolved player key "${playerKey}" already has an in-flight call`);
938
+ await emitCallStarted('player.call.started', 'player.call.finished', { ...identity, prompt }, { turnId: currentTurnId, callId }, signal);
939
+ await emitFailure(error);
940
+ throw error;
941
+ }
942
+ inFlightPlayerKeys.add(playerKey);
846
943
  try {
847
944
  await emitCallStarted('player.call.started', 'player.call.finished', { ...identity, prompt }, { turnId: currentTurnId, callId }, signal);
848
945
  let rawResult;
849
946
  try {
850
947
  signal.throwIfAborted();
851
- const boundary = Promise.resolve(requirePorts().callPlayer(playerId, prompt, signal, { resume }));
948
+ const boundary = Promise.resolve(requirePorts().callPlayer(roleId, prompt, signal, { resume }));
852
949
  rawResult = await boundary;
853
950
  // An XState sibling cancellation does not cancel an arbitrary coder
854
951
  // promise. Re-check before a late resolution can mutate continuity or
@@ -885,10 +982,7 @@ export const createPlaybookRuntime = (options) => {
885
982
  // The resolved result is authoritative even on aborted/error status.
886
983
  // Update continuation state before interpreting that status.
887
984
  try {
888
- updatePlayerResume(playerId, typeof result.resumeToken === 'string' &&
889
- result.resumeToken.trim().length > 0
890
- ? result.resumeToken
891
- : undefined);
985
+ updatePlayerResume(roleId, playerId, result);
892
986
  }
893
987
  catch (error) {
894
988
  latchControlPlaneError(error, signal);
@@ -915,11 +1009,15 @@ export const createPlaybookRuntime = (options) => {
915
1009
  ...(result.error !== undefined
916
1010
  ? { error: normalizeErrorFull(result.error) }
917
1011
  : {}),
918
- }, { turnId: currentTurnId, callId });
919
- return { playerId, result };
1012
+ }, { turnId: currentTurnId, callId }, aborts);
1013
+ return {
1014
+ roleId,
1015
+ ...(playerId === undefined ? {} : { playerId }),
1016
+ result,
1017
+ };
920
1018
  }
921
1019
  finally {
922
- inFlightPlayerIds.delete(playerId);
1020
+ inFlightPlayerKeys.delete(playerKey);
923
1021
  }
924
1022
  };
925
1023
  const callPlayer = (input, signal) => {
@@ -927,52 +1025,59 @@ export const createPlaybookRuntime = (options) => {
927
1025
  };
928
1026
  const player = fromPromise(async ({ input, signal }) => {
929
1027
  const combined = combineSignals(signal, currentSignal);
930
- // XState starts invoked actors while publishing the entering snapshot.
931
- // Yield through the runtime emission queue before crossing the player
932
- // boundary so state trace/status always precede its call-start trace.
1028
+ const settlementAborts = abortReasonClassifier(combined);
933
1029
  try {
934
- await flush();
935
- }
936
- catch (error) {
937
- latchControlPlaneError(error, combined);
938
- throw error;
939
- }
940
- combined.throwIfAborted();
941
- let { playerId, result } = await callPlayer(input, combined);
942
- if (result.status === 'ok' && isEmptyFinalText(result.finalText)) {
943
- // DR-028: an `ok` result whose finalText is missing, empty, or
944
- // whitespace-only earns exactly one corrective re-ask — the same
945
- // composed call repeated, traced by runPlayerCall as its own
946
- // player-call pair, with the resume selection re-read from the
947
- // token map the first result left (PBRT-38). An abort that lands
948
- // between the two calls ends the turn without the re-ask (aborts
949
- // are never retried), and a rejecting finish emission rejects
950
- // `callPlayer` itself, so it never reaches this branch (PBRT-47).
1030
+ // XState starts invoked actors while publishing the entering snapshot.
1031
+ // Yield through the runtime emission queue before crossing the player
1032
+ // boundary so state trace/status always precede its call-start trace.
951
1033
  combined.throwIfAborted();
952
- ({ playerId, result } = await callPlayer(input, combined));
953
- }
954
- if (result.status !== 'ok') {
955
- throw new Error(`player "${playerId}" returned status "${result.status}"${result.error ? `: ${result.error}` : ''}`);
956
- }
957
- const finalText = result.finalText ?? '';
958
- if (isEmptyFinalText(finalText)) {
959
- throw new Error(`player "${playerId}" returned status "ok" with no finalText`);
960
- }
961
- combined.throwIfAborted();
962
- try {
963
- const prompt = buildAdjudicatorPrompt(input, finalText);
964
- return parseAdjudication(await callJudge(prompt, combined, 'player-output-adjudication', input.stateId), input, finalText);
1034
+ try {
1035
+ await flush(settlementAborts);
1036
+ }
1037
+ catch (error) {
1038
+ latchControlPlaneError(error, combined);
1039
+ throw error;
1040
+ }
1041
+ combined.throwIfAborted();
1042
+ let { roleId, playerId, result } = await callPlayer(input, combined);
1043
+ if (result.status === 'ok' && isEmptyFinalText(result.finalText)) {
1044
+ // DR-028: an `ok` result whose finalText is missing, empty, or
1045
+ // whitespace-only earns exactly one corrective re-ask — the same
1046
+ // composed call repeated, traced by runPlayerCall as its own
1047
+ // player-call pair, with the resume selection re-read from the
1048
+ // token map the first result left (PBRT-38). An abort that lands
1049
+ // between the two calls ends the turn without the re-ask (aborts
1050
+ // are never retried), and a rejecting finish emission rejects
1051
+ // `callPlayer` itself, so it never reaches this branch (PBRT-47).
1052
+ combined.throwIfAborted();
1053
+ ({ roleId, playerId, result } = await callPlayer(input, combined));
1054
+ }
1055
+ if (result.status !== 'ok') {
1056
+ throw new Error(`${roleLabel(roleId)}${playerId === undefined ? '' : ` (${playerId})`} returned status "${result.status}"${result.error ? `: ${result.error}` : ''}`);
1057
+ }
1058
+ const finalText = result.finalText ?? '';
1059
+ if (isEmptyFinalText(finalText)) {
1060
+ throw new Error(`${roleLabel(roleId)}${playerId === undefined ? '' : ` (${playerId})`} returned status "ok" with no finalText`);
1061
+ }
1062
+ combined.throwIfAborted();
1063
+ try {
1064
+ const prompt = buildAdjudicatorPrompt(input, finalText);
1065
+ return parseAdjudication(await callJudge(prompt, combined, 'player-output-adjudication', input.stateId), input, finalText);
1066
+ }
1067
+ catch (error) {
1068
+ latchControlPlaneError(error, combined);
1069
+ throw error;
1070
+ }
965
1071
  }
966
- catch (error) {
967
- latchControlPlaneError(error, combined);
968
- throw error;
1072
+ finally {
1073
+ actorSettlementAborts.push(settlementAborts);
969
1074
  }
970
1075
  });
971
1076
  nestedBridge = createNestedPlaybookBridge({
972
1077
  nextCallId: () => `playbook-${++playbookCallSequence}`,
973
1078
  getBoundarySignal: () => currentSignal,
974
1079
  callPlaybook: (request, signal) => trackBoundaryCall(Promise.resolve(requirePorts().callPlaybook(request, signal))),
975
- emitStarted: async (event) => {
1080
+ emitStarted: async (event, aborts) => {
976
1081
  playbookCallTurnIds.set(event.callId, currentTurnId);
977
1082
  await emitTrace('playbook.call.started', {
978
1083
  stateId: event.stateId,
@@ -981,9 +1086,9 @@ export const createPlaybookRuntime = (options) => {
981
1086
  }, {
982
1087
  ...(currentTurnId === undefined ? {} : { turnId: currentTurnId }),
983
1088
  callId: event.callId,
984
- });
1089
+ }, aborts);
985
1090
  },
986
- emitFinished: async (event) => {
1091
+ emitFinished: async (event, aborts) => {
987
1092
  const turnId = playbookCallTurnIds.get(event.callId);
988
1093
  try {
989
1094
  await emitTrace('playbook.call.finished', {
@@ -994,29 +1099,48 @@ export const createPlaybookRuntime = (options) => {
994
1099
  }, {
995
1100
  ...(turnId === undefined ? {} : { turnId }),
996
1101
  callId: event.callId,
997
- });
1102
+ }, aborts);
998
1103
  }
999
1104
  finally {
1000
1105
  playbookCallTurnIds.delete(event.callId);
1001
1106
  }
1002
1107
  },
1003
1108
  drain: flush,
1004
- bindResumeSignal: (signal) => {
1109
+ bindResumeSignal: (signal, aborts) => {
1005
1110
  currentSignal = signal;
1111
+ currentAborts = aborts ?? abortReasonClassifier(signal);
1006
1112
  },
1007
- onControlPlaneError: (error) => {
1008
- const signal = currentSignal;
1009
- if (!signal || !isAbortFailure(error, signal)) {
1113
+ bindActorSettlement: (aborts) => {
1114
+ actorSettlementAborts.push(aborts);
1115
+ },
1116
+ onControlPlaneError: (error, aborts) => {
1117
+ if (!aborts?.isAbortReason(error) &&
1118
+ !currentAborts?.isAbortReason(error)) {
1010
1119
  controlPlaneError ??= error;
1011
1120
  }
1012
1121
  },
1013
- onBackgroundError: (error) => {
1014
- collectFailure(emissionFailures, error);
1122
+ onBackgroundError: (error, aborts) => {
1123
+ if (!aborts?.isAbortReason(error)) {
1124
+ collectFailure(emissionFailures, error);
1125
+ }
1015
1126
  },
1016
1127
  });
1017
1128
  const providedMachine = decideMachine.provide({
1018
1129
  actors: { player, playbook: nestedBridge.actorLogic },
1019
1130
  });
1131
+ const consumeActorSettlementAborts = (forSnapshot = false) => {
1132
+ const aborts = actorSettlementAborts.shift() ?? actorSettlementErrorAborts;
1133
+ actorSettlementErrorAborts = undefined;
1134
+ if (forSnapshot && aborts !== undefined) {
1135
+ actorSettlementErrorAborts = aborts;
1136
+ queueMicrotask(() => {
1137
+ if (actorSettlementErrorAborts === aborts) {
1138
+ actorSettlementErrorAborts = undefined;
1139
+ }
1140
+ });
1141
+ }
1142
+ return aborts;
1143
+ };
1020
1144
  const inspect = (event) => {
1021
1145
  if (event.type !== '@xstate.snapshot')
1022
1146
  return;
@@ -1024,6 +1148,7 @@ export const createPlaybookRuntime = (options) => {
1024
1148
  return;
1025
1149
  if (suppressInspectionEmissions)
1026
1150
  return;
1151
+ const settlementAborts = consumeActorSettlementAborts(true);
1027
1152
  try {
1028
1153
  const snapshot = event.snapshot;
1029
1154
  const state = normalizePlaybookSnapshot(snapshot);
@@ -1034,7 +1159,7 @@ export const createPlaybookRuntime = (options) => {
1034
1159
  void enqueueTracedEmission('fsm.transition', fsmPayload, { turnId: currentTurnId }, (emissionPorts) => emissionPorts.emitTelemetry({
1035
1160
  topic: TELEMETRY_TOPIC,
1036
1161
  payload: describedFsmPayload,
1037
- })).catch(() => undefined);
1162
+ }), settlementAborts).catch(() => undefined);
1038
1163
  const priorIds = new Set(previousState?.activeStateIds ?? []);
1039
1164
  previousState = state;
1040
1165
  const pendingQuestions = pendingQuestionsFromContext(context);
@@ -1047,7 +1172,7 @@ export const createPlaybookRuntime = (options) => {
1047
1172
  ...(data !== undefined ? { data } : {}),
1048
1173
  };
1049
1174
  assertJsonSafe(tracePayload);
1050
- void enqueueTracedEmission('status.emitted', tracePayload, { turnId: currentTurnId }, (emissionPorts) => emissionPorts.emitStatus(message, data)).catch(() => undefined);
1175
+ void enqueueTracedEmission('status.emitted', tracePayload, { turnId: currentTurnId }, (emissionPorts) => emissionPorts.emitStatus(message, data), settlementAborts).catch(() => undefined);
1051
1176
  };
1052
1177
  for (const activeStateId of state.activeStateIds) {
1053
1178
  if (priorIds.has(activeStateId) ||
@@ -1057,8 +1182,8 @@ export const createPlaybookRuntime = (options) => {
1057
1182
  if (WAIT_STATE_IDS.has(activeStateId)) {
1058
1183
  const pending = questionForWaitState(activeStateId, pendingQuestions);
1059
1184
  if (pending) {
1060
- scheduleStatus(`${pending.player} asks: ${pending.question}`, activeStateId);
1061
- scheduleStatus(`◆ awaiting Boss reply · ${pending.resumeStateId} · ${pending.player} · ${pending.sourceItem}`, activeStateId);
1185
+ scheduleStatus(`${pending.asker.roleId} asks: ${pending.question}`, activeStateId);
1186
+ scheduleStatus(`◆ awaiting Boss reply · ${pending.resumeStateId} · ${pending.asker.roleId} · ${pending.sourceItem}`, activeStateId);
1062
1187
  }
1063
1188
  continue;
1064
1189
  }
@@ -1068,14 +1193,14 @@ export const createPlaybookRuntime = (options) => {
1068
1193
  const description = STATE_DESCRIPTIONS[activeStateId];
1069
1194
  if (description === undefined)
1070
1195
  continue;
1071
- const playerState = PLAYER_STATES.find((candidate) => candidate.stateId === activeStateId);
1072
- scheduleStatus(playerState === undefined
1196
+ const roleState = ROLE_STATES.find((candidate) => candidate.stateId === activeStateId);
1197
+ scheduleStatus(roleState === undefined
1073
1198
  ? '◆ workflow failed; awaiting Boss recovery.'
1074
- : `⤷ ${playerState.player}: ${description}`, activeStateId, lastError === undefined ? undefined : { lastError });
1199
+ : `⤷ ${roleLabel(roleState.role)}: ${description}`, activeStateId, lastError === undefined ? undefined : { lastError });
1075
1200
  }
1076
1201
  }
1077
1202
  catch (error) {
1078
- latchInspectionError(error);
1203
+ latchInspectionError(error, settlementAborts);
1079
1204
  }
1080
1205
  };
1081
1206
  const createRuntimeActor = (machineSnapshot) => {
@@ -1091,6 +1216,15 @@ export const createPlaybookRuntime = (options) => {
1091
1216
  }),
1092
1217
  inspect,
1093
1218
  });
1219
+ // A synchronous FSM action throw errors the actor without any pending
1220
+ // boundary await to observe it; unobserved, XState would surface it via
1221
+ // reportUnhandledError as an uncaughtException. Observe it here: latch
1222
+ // it as a control error while a turn signal is active (unless it is
1223
+ // the abort reason itself), otherwise collect it with the emission
1224
+ // failures (slc/link.md §Abort).
1225
+ actor.subscribe({
1226
+ error: (error) => latchInspectionError(error, consumeActorSettlementAborts()),
1227
+ });
1094
1228
  };
1095
1229
  // PBRT-6: the single seam that stops this runtime's actor. Stopping a
1096
1230
  // still-running actor fires one more `@xstate.snapshot` for the *unchanged*
@@ -1126,7 +1260,7 @@ export const createPlaybookRuntime = (options) => {
1126
1260
  const state = normalizePlaybookSnapshot(snapshot, {
1127
1261
  pendingCall: nestedBridge.getPendingCall(),
1128
1262
  });
1129
- const pendingQuestions = pendingQuestionsFromContext(context);
1263
+ const pendingQuestions = pendingQuestionsForState(state, context);
1130
1264
  if (pendingQuestions.length === 0 &&
1131
1265
  (snapshot.status === 'done' ||
1132
1266
  state.activeStateIds.includes('ready') ||
@@ -1150,33 +1284,53 @@ export const createPlaybookRuntime = (options) => {
1150
1284
  const pendingCall = nestedBridge.getPendingCall();
1151
1285
  const state = normalizePlaybookSnapshot(snapshot, { pendingCall });
1152
1286
  const context = snapshot.context;
1153
- if (signal?.aborted) {
1154
- return {
1155
- outcome: 'aborted',
1156
- state,
1157
- ...(signal.reason === undefined
1158
- ? {}
1159
- : {
1160
- error: normalizeErrorFull(signal.reason) ?? {
1161
- name: 'AbortError',
1162
- message: String(signal.reason),
1163
- },
1164
- }),
1165
- };
1166
- }
1287
+ const abortedResult = (abortSignal) => ({
1288
+ outcome: 'aborted',
1289
+ state,
1290
+ ...(abortSignal.reason === undefined
1291
+ ? {}
1292
+ : {
1293
+ error: normalizeErrorFull(abortSignal.reason) ?? {
1294
+ name: 'AbortError',
1295
+ message: String(abortSignal.reason),
1296
+ },
1297
+ }),
1298
+ });
1299
+ if (snapshot.status === 'error') {
1300
+ // An errored actor outranks a coincident abort unless the actor's
1301
+ // error is the abort reason itself (slc/link.md §Abort).
1302
+ const actorError = snapshot.error;
1303
+ if (actorError !== undefined &&
1304
+ signal !== undefined &&
1305
+ isAbortFailure(actorError, signal)) {
1306
+ return abortedResult(signal);
1307
+ }
1308
+ throw (actorError ?? new Error('decide runtime actor entered error status'));
1309
+ }
1310
+ // Terminal completion outranks a coincident abort (DR-036 §3): reporting
1311
+ // 'aborted' over a completed machine would hide a terminal state that the
1312
+ // next turn silently restarts, duplicating the workflow's side effects.
1167
1313
  if (snapshot.status === 'done') {
1168
1314
  const output = snapshot.output;
1169
1315
  if (output !== undefined)
1170
1316
  assertJsonSafe(output, 'terminal output');
1317
+ const stateDescription = state.activeStateIds.includes('done')
1318
+ ? STATE_DESCRIPTIONS.done
1319
+ : state.activeStateIds.includes('reportedReviewFailure')
1320
+ ? STATE_DESCRIPTIONS.reportedReviewFailure
1321
+ : undefined;
1322
+ if (stateDescription === undefined) {
1323
+ throw new Error('decide runtime: completed actor has no authored final-state description');
1324
+ }
1171
1325
  return {
1172
1326
  outcome: 'terminal',
1173
1327
  state,
1328
+ stateDescription,
1174
1329
  ...(output === undefined ? {} : { output }),
1175
1330
  };
1176
1331
  }
1177
- if (snapshot.status === 'error') {
1178
- throw (snapshot.error ??
1179
- new Error('decide runtime actor entered error status'));
1332
+ if (signal?.aborted) {
1333
+ return abortedResult(signal);
1180
1334
  }
1181
1335
  if (state.activeStateIds.includes('failed')) {
1182
1336
  const error = normalizeErrorFull(context.lastError);
@@ -1239,14 +1393,17 @@ export const createPlaybookRuntime = (options) => {
1239
1393
  // The session-start error remains authoritative.
1240
1394
  }
1241
1395
  }
1242
- playerResumeTokens.clear();
1243
- inFlightPlayerIds.clear();
1396
+ privateResumeTokens.clear();
1397
+ inFlightPlayerKeys.clear();
1244
1398
  activeBoundaryCalls.clear();
1245
1399
  activeEmissionCalls.clear();
1246
1400
  emissionQueue.clear();
1247
1401
  judgeQueue.clear();
1248
1402
  actor = undefined;
1249
1403
  currentSignal = undefined;
1404
+ currentAborts = undefined;
1405
+ actorSettlementAborts.length = 0;
1406
+ actorSettlementErrorAborts = undefined;
1250
1407
  currentTurnId = undefined;
1251
1408
  ports = undefined;
1252
1409
  sessionIdentity = undefined;
@@ -1270,7 +1427,7 @@ export const createPlaybookRuntime = (options) => {
1270
1427
  disposalPromise !== undefined) {
1271
1428
  throw new Error('decide runtime: init(session) may only be called once');
1272
1429
  }
1273
- const identity = snapshotPlaybookSession(session);
1430
+ const identity = bindSession(session);
1274
1431
  let finishInitialization;
1275
1432
  const initialization = new Promise((resolve) => {
1276
1433
  finishInitialization = resolve;
@@ -1348,10 +1505,10 @@ export const createPlaybookRuntime = (options) => {
1348
1505
  const machine = detachPersistedMachineSnapshot(actor.getPersistedSnapshot());
1349
1506
  const context = actor.getSnapshot().context;
1350
1507
  return {
1351
- schemaVersion: 2,
1508
+ schemaVersion: 3,
1352
1509
  playbookId: sessionIdentity.playbookId,
1353
1510
  machine,
1354
- playerResumeTokens: snapshotPlayerResumeTokens(),
1511
+ roleResumeTokens: snapshotRoleResumeTokens(),
1355
1512
  sequences: {
1356
1513
  trace: traceSequence,
1357
1514
  turn: turnSequence,
@@ -1360,9 +1517,9 @@ export const createPlaybookRuntime = (options) => {
1360
1517
  playbookCall: playbookCallSequence,
1361
1518
  },
1362
1519
  state,
1363
- pendingBossQuestions: pendingQuestionsFromContext(context).map((pending) => ({
1520
+ pendingBossQuestions: pendingQuestionsForState(state, context).map((pending) => ({
1364
1521
  questionId: pending.questionId,
1365
- player: pending.player,
1522
+ asker: pending.asker,
1366
1523
  question: pending.question,
1367
1524
  sourceItem: pending.sourceItem,
1368
1525
  })),
@@ -1381,11 +1538,9 @@ export const createPlaybookRuntime = (options) => {
1381
1538
  disposalPromise !== undefined) {
1382
1539
  throw new Error('decide runtime: restore(session, snapshot) may only be called once');
1383
1540
  }
1384
- const identity = snapshotPlaybookSession(session);
1541
+ const identity = bindSession(session);
1385
1542
  const boundSnapshot = assertPlaybookRuntimeSnapshot(snapshot, identity.playbookId, { allowSuspendedCall: true });
1386
- const suspendedCall = boundSnapshot.schemaVersion === 2
1387
- ? boundSnapshot.suspendedCall
1388
- : undefined;
1543
+ const suspendedCall = boundSnapshot.suspendedCall;
1389
1544
  let finishInitialization;
1390
1545
  const initialization = new Promise((resolve) => {
1391
1546
  finishInitialization = resolve;
@@ -1394,7 +1549,7 @@ export const createPlaybookRuntime = (options) => {
1394
1549
  lifecycleStarted = true;
1395
1550
  ports = identity.ports;
1396
1551
  sessionIdentity = identity;
1397
- let priorExternalPlayerTokens;
1552
+ let priorExternalRoleTokens;
1398
1553
  try {
1399
1554
  traceSequence = boundSnapshot.sequences.trace;
1400
1555
  turnSequence = boundSnapshot.sequences.turn;
@@ -1402,9 +1557,9 @@ export const createPlaybookRuntime = (options) => {
1402
1557
  playerCallSequence = boundSnapshot.sequences.playerCall;
1403
1558
  playbookCallSequence = boundSnapshot.sequences.playbookCall;
1404
1559
  if (identity.playerSessions) {
1405
- priorExternalPlayerTokens = snapshotPlayerResumeTokens();
1560
+ priorExternalRoleTokens = snapshotRoleResumeTokens();
1406
1561
  }
1407
- restorePlayerResumeTokens(boundSnapshot.playerResumeTokens);
1562
+ restoreRoleResumeTokens(boundSnapshot.roleResumeTokens);
1408
1563
  nestedBridge.prepareRestore(suspendedCall);
1409
1564
  if (suspendedCall !== undefined) {
1410
1565
  playbookCallTurnIds.set(suspendedCall.callId, suspendedCall.turnId);
@@ -1430,9 +1585,9 @@ export const createPlaybookRuntime = (options) => {
1430
1585
  }
1431
1586
  catch (error) {
1432
1587
  let failure = error;
1433
- if (priorExternalPlayerTokens !== undefined) {
1588
+ if (priorExternalRoleTokens !== undefined) {
1434
1589
  try {
1435
- identity.playerSessions.restore(priorExternalPlayerTokens);
1590
+ identity.playerSessions.restore(priorExternalRoleTokens);
1436
1591
  }
1437
1592
  catch (rollbackError) {
1438
1593
  failure = new AggregateError([error, rollbackError], 'DECIDE restore and player continuation rollback failed');
@@ -1461,12 +1616,17 @@ export const createPlaybookRuntime = (options) => {
1461
1616
  const turnId = ++turnSequence;
1462
1617
  currentTurnId = turnId;
1463
1618
  currentSignal = turn.signal;
1619
+ currentAborts = abortReasonClassifier(turn.signal);
1464
1620
  controlPlaneError = undefined;
1465
1621
  let result = resultForSnapshot(turn.signal);
1466
1622
  let settlement = result;
1467
1623
  const failures = [];
1468
1624
  try {
1469
1625
  await emitTrace('boss.input.received', { text: turn.text }, { turnId });
1626
+ // A boundary entered aborted records the attempted input, then refuses
1627
+ // delivery before deterministic mapping or the classifier can perform
1628
+ // any host-visible work (DR-036 §5).
1629
+ turn.signal.throwIfAborted();
1470
1630
  if (turn.text.trim().length === 0) {
1471
1631
  const state = currentState();
1472
1632
  result = { outcome: 'no-action', state };
@@ -1502,16 +1662,19 @@ export const createPlaybookRuntime = (options) => {
1502
1662
  }
1503
1663
  catch (error) {
1504
1664
  const primaryError = controlPlaneError;
1665
+ // Only a rejection that is the exact abort reason settles as the
1666
+ // cancellation; a distinct failure observed while the signal is
1667
+ // aborted remains a control error (slc/link.md §Abort).
1505
1668
  if (primaryError !== undefined) {
1506
1669
  collectFailure(failures, primaryError);
1507
1670
  }
1508
- else if (!turn.signal.aborted) {
1671
+ else if (!isAbortFailure(error, turn.signal)) {
1509
1672
  collectFailure(failures, error);
1510
1673
  }
1511
1674
  const state = currentState();
1512
1675
  const effectiveError = primaryError ?? error;
1513
1676
  result =
1514
- turn.signal.aborted && primaryError === undefined
1677
+ isAbortFailure(error, turn.signal) && primaryError === undefined
1515
1678
  ? resultForSnapshot(turn.signal)
1516
1679
  : {
1517
1680
  outcome: 'failed',
@@ -1532,12 +1695,14 @@ export const createPlaybookRuntime = (options) => {
1532
1695
  catch (error) {
1533
1696
  const primaryError = controlPlaneError;
1534
1697
  const effectiveError = primaryError ?? error;
1535
- collectFailure(failures, effectiveError);
1698
+ // A drain rejection that is the exact abort reason evidences the
1699
+ // cancellation, not a control-plane failure (slc/link.md §Abort).
1700
+ const drainAborted = isAbortFailure(effectiveError, turn.signal);
1701
+ if (!drainAborted)
1702
+ collectFailure(failures, effectiveError);
1536
1703
  const state = currentState();
1537
1704
  result = {
1538
- outcome: turn.signal.aborted && primaryError === undefined
1539
- ? 'aborted'
1540
- : 'failed',
1705
+ outcome: drainAborted ? 'aborted' : 'failed',
1541
1706
  state,
1542
1707
  error: normalizeErrorFull(effectiveError) ?? {
1543
1708
  name: 'Error',
@@ -1546,21 +1711,31 @@ export const createPlaybookRuntime = (options) => {
1546
1711
  };
1547
1712
  settlement = { ...result, ...stateIdentity(state) };
1548
1713
  }
1549
- currentSignal = undefined;
1550
1714
  try {
1551
1715
  await emitTrace('boss.input.settled', settlement, { turnId });
1552
1716
  }
1553
1717
  catch (error) {
1554
- collectFailure(failures, error);
1718
+ // A settlement-trace rejection that is the exact abort reason also
1719
+ // evidences the cancellation (slc/link.md §Abort).
1720
+ if (!isAbortFailure(error, turn.signal)) {
1721
+ collectFailure(failures, error);
1722
+ }
1555
1723
  }
1556
1724
  try {
1557
1725
  await flush();
1558
1726
  }
1559
1727
  catch (error) {
1560
- collectFailure(failures, error);
1728
+ // A late flush rejection that is the exact abort reason likewise
1729
+ // evidences the cancellation; the settled result already labels
1730
+ // the turn aborted then (slc/link.md §Abort).
1731
+ if (!isAbortFailure(error, turn.signal)) {
1732
+ collectFailure(failures, error);
1733
+ }
1561
1734
  }
1562
1735
  finally {
1563
1736
  const primaryError = controlPlaneError;
1737
+ currentSignal = undefined;
1738
+ currentAborts = undefined;
1564
1739
  currentTurnId = undefined;
1565
1740
  controlPlaneError = undefined;
1566
1741
  if (primaryError !== undefined)
@@ -1586,6 +1761,7 @@ export const createPlaybookRuntime = (options) => {
1586
1761
  }
1587
1762
  currentTurnId = playbookCallTurnIds.get(callId);
1588
1763
  currentSignal = signal;
1764
+ currentAborts = abortReasonClassifier(signal);
1589
1765
  controlPlaneError = undefined;
1590
1766
  let runResult;
1591
1767
  let operationError;
@@ -1615,14 +1791,50 @@ export const createPlaybookRuntime = (options) => {
1615
1791
  catch (error) {
1616
1792
  drainError = error;
1617
1793
  }
1618
- const failure = controlPlaneError ?? drainError ?? operationError;
1794
+ const aborts = currentAborts ?? abortReasonClassifier(signal);
1795
+ // The control latch has already classified its failure as distinct
1796
+ // under the operation that owned it. Only still-unclassified drain and
1797
+ // operation candidates may be cancellation evidence for this resume.
1798
+ const controlFailure = controlPlaneError;
1799
+ const drainAbort = controlFailure === undefined &&
1800
+ drainError !== undefined &&
1801
+ aborts.isAbortReason(drainError);
1802
+ const operationAbort = controlFailure === undefined &&
1803
+ operationError !== undefined &&
1804
+ aborts.isAbortReason(operationError);
1805
+ const abortEvidence = (drainAbort ? drainError : undefined) ??
1806
+ (operationAbort ? operationError : undefined);
1807
+ const failure = controlFailure ??
1808
+ (drainAbort ? undefined : drainError) ??
1809
+ (operationAbort ? undefined : operationError);
1619
1810
  currentSignal = undefined;
1811
+ currentAborts = undefined;
1620
1812
  currentTurnId = undefined;
1621
1813
  controlPlaneError = undefined;
1622
1814
  if (failure !== undefined)
1623
1815
  throw failure;
1816
+ if (abortEvidence !== undefined &&
1817
+ runResult?.outcome !== 'terminal' &&
1818
+ runResult?.outcome !== 'suspended') {
1819
+ const state = currentState();
1820
+ runResult = {
1821
+ outcome: 'aborted',
1822
+ state,
1823
+ error: normalizeErrorFull(abortEvidence) ?? {
1824
+ name: 'AbortError',
1825
+ message: String(abortEvidence),
1826
+ },
1827
+ };
1828
+ }
1624
1829
  if (runResult === undefined) {
1625
- throw new Error('decide runtime: playbook resume produced no result');
1830
+ if (signal.aborted) {
1831
+ // Every candidate was the abort's own evidence: settle on the
1832
+ // machine's state under the aborted boundary signal (DR-036 §4).
1833
+ runResult = resultForSnapshot(signal);
1834
+ }
1835
+ else {
1836
+ throw new Error('decide runtime: playbook resume produced no result');
1837
+ }
1626
1838
  }
1627
1839
  return runResult;
1628
1840
  },
@@ -1671,15 +1883,18 @@ export const createPlaybookRuntime = (options) => {
1671
1883
  collectFailure(failures, error);
1672
1884
  }
1673
1885
  finally {
1674
- playerResumeTokens.clear();
1886
+ privateResumeTokens.clear();
1675
1887
  playbookCallTurnIds.clear();
1676
- inFlightPlayerIds.clear();
1888
+ inFlightPlayerKeys.clear();
1677
1889
  activeBoundaryCalls.clear();
1678
1890
  activeEmissionCalls.clear();
1679
1891
  emissionQueue.clear();
1680
1892
  judgeQueue.clear();
1681
1893
  actor = undefined;
1682
1894
  currentSignal = undefined;
1895
+ currentAborts = undefined;
1896
+ actorSettlementAborts.length = 0;
1897
+ actorSettlementErrorAborts = undefined;
1683
1898
  currentTurnId = undefined;
1684
1899
  ports = undefined;
1685
1900
  sessionIdentity = undefined;
@@ -1695,11 +1910,15 @@ export const createPlaybookRuntime = (options) => {
1695
1910
  })();
1696
1911
  return disposalPromise;
1697
1912
  },
1913
+ // @internal — test-only parity with the shared factory's bridge escape
1914
+ // hatch. This is hidden by the PlaybookRuntime return type.
1915
+ _getNestedBridge() {
1916
+ return nestedBridge;
1917
+ },
1698
1918
  };
1699
1919
  };
1700
1920
  export const _internal = {
1701
1921
  composePlayerPrompt,
1702
- resolvePlayerId,
1703
1922
  requiredFieldsFor,
1704
1923
  extractJson,
1705
1924
  buildClassifierPrompt,
@@ -1708,12 +1927,12 @@ export const _internal = {
1708
1927
  parseAdjudication,
1709
1928
  combineSignals,
1710
1929
  pendingQuestionsFromContext,
1930
+ pendingQuestionsForState,
1711
1931
  normalizeErrorCompact,
1712
1932
  normalizeErrorFull,
1713
- DEFAULT_PLAYER_BINDING,
1714
1933
  STATE_DESCRIPTIONS,
1715
- PLAYER_STATES,
1716
- PLAYER_STATE_IDS,
1934
+ ROLE_STATES,
1935
+ ROLE_STATE_IDS,
1717
1936
  VERBATIM_PAYLOAD_FIELDS,
1718
1937
  BOSS_INTERRUPT_TARGETS,
1719
1938
  CONTINUATION_PREAMBLE,