@sublang/playbook 6.0.0 → 8.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 (63) hide show
  1. package/README.md +28 -11
  2. package/docs/cli.md +158 -68
  3. package/docs/configuration.md +246 -108
  4. package/docs/embedding.md +71 -25
  5. package/package.json +6 -3
  6. package/reference/sdlc/captain.playbook/captain.playbook.js +3 -3
  7. package/reference/sdlc/captain.playbook/captain.playbook.ts +3 -3
  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 +1900 -0
  11. package/reference/sdlc/code.playbook/bin/playbook.js +573 -535
  12. package/reference/sdlc/code.playbook/bin/provision.js +84 -38
  13. package/reference/sdlc/code.playbook/bin/run.js +1164 -991
  14. package/reference/sdlc/code.playbook/bin/session-store.js +1961 -0
  15. package/reference/sdlc/code.playbook/code.fsm.d.ts +5 -5
  16. package/reference/sdlc/code.playbook/code.fsm.introspect.js +2 -2
  17. package/reference/sdlc/code.playbook/code.fsm.introspect.ts +2 -2
  18. package/reference/sdlc/code.playbook/code.fsm.js +7 -11
  19. package/reference/sdlc/code.playbook/code.fsm.ts +9 -17
  20. package/reference/sdlc/code.playbook/code.gears.md +1 -1
  21. package/reference/sdlc/code.playbook/code.playbook.d.ts +2 -1
  22. package/reference/sdlc/code.playbook/code.playbook.js +12 -13
  23. package/reference/sdlc/code.playbook/code.playbook.ts +22 -15
  24. package/reference/sdlc/code.playbook/code.registry.d.ts +5 -13
  25. package/reference/sdlc/code.playbook/code.registry.js +3 -10
  26. package/reference/sdlc/code.playbook/code.registry.ts +7 -32
  27. package/reference/sdlc/code.playbook/playbook-captain.d.ts +101 -9
  28. package/reference/sdlc/code.playbook/playbook-captain.js +1690 -213
  29. package/reference/sdlc/code.playbook/playbook-captain.ts +2492 -253
  30. package/reference/sdlc/code.playbook/playbook.config.template.yaml +44 -62
  31. package/reference/sdlc/decide.md +4 -4
  32. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +9 -9
  33. package/reference/sdlc/decide.playbook/decide.fsm.js +21 -14
  34. package/reference/sdlc/decide.playbook/decide.fsm.ts +27 -23
  35. package/reference/sdlc/decide.playbook/decide.gears.md +3 -5
  36. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +9 -13
  37. package/reference/sdlc/decide.playbook/decide.playbook.js +244 -143
  38. package/reference/sdlc/decide.playbook/decide.playbook.ts +326 -171
  39. package/reference/sdlc/decide.playbook/decide.registry.d.ts +5 -13
  40. package/reference/sdlc/decide.playbook/decide.registry.js +3 -9
  41. package/reference/sdlc/decide.playbook/decide.registry.ts +7 -31
  42. package/reference/sdlc/review.md +4 -5
  43. package/reference/sdlc/review.playbook/review.fsm.d.ts +9 -11
  44. package/reference/sdlc/review.playbook/review.fsm.js +30 -24
  45. package/reference/sdlc/review.playbook/review.fsm.ts +39 -35
  46. package/reference/sdlc/review.playbook/review.gears.md +6 -5
  47. package/reference/sdlc/review.playbook/review.playbook.d.ts +2 -1
  48. package/reference/sdlc/review.playbook/review.playbook.js +16 -21
  49. package/reference/sdlc/review.playbook/review.playbook.ts +26 -26
  50. package/reference/sdlc/review.playbook/review.registry.d.ts +5 -13
  51. package/reference/sdlc/review.playbook/review.registry.js +3 -16
  52. package/reference/sdlc/review.playbook/review.registry.ts +7 -38
  53. package/slc/gears2fsm.md +27 -23
  54. package/slc/link.md +140 -97
  55. package/slc/text2gears.md +19 -18
  56. package/src/runtime.d.ts +24 -8
  57. package/src/runtime.ts +29 -13
  58. package/src/xstate-playbook-runtime.d.ts +21 -17
  59. package/src/xstate-playbook-runtime.js +301 -159
  60. package/src/xstate-playbook-runtime.ts +405 -186
  61. package/src/xstate-runtime.d.ts +19 -2
  62. package/src/xstate-runtime.js +403 -62
  63. package/src/xstate-runtime.ts +566 -78
@@ -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,41 +19,16 @@ 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');
40
- }
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`);
53
- }
54
- }
27
+ const [unknown] = Object.keys(captured);
28
+ if (unknown !== undefined) {
29
+ throw new TypeError(`DECIDE runtime options.${unknown} is not declared`);
55
30
  }
56
- return captured;
31
+ return Object.freeze({});
57
32
  }
58
33
  const STATE_DESCRIPTIONS = {
59
34
  ready: 'Waiting for a topic to decide.',
@@ -68,31 +43,31 @@ const STATE_DESCRIPTIONS = {
68
43
  reportedReviewFailure: 'DECIDE reports REVIEW’s failure and its last commit.',
69
44
  done: 'DECIDE completed with an approved commit.',
70
45
  };
71
- const PLAYER_STATES = [
72
- { stateId: 'askCoderProposal', player: 'Coder', sourceItem: 'DECIDE-1' },
46
+ const ROLE_STATES = [
47
+ { stateId: 'askCoderProposal', role: 'coder', sourceItem: 'DECIDE-1' },
73
48
  {
74
49
  stateId: 'askReviewerProposal',
75
- player: 'Reviewer',
50
+ role: 'reviewer',
76
51
  sourceItem: 'DECIDE-2',
77
52
  },
78
- { stateId: 'commitCoderProposal', player: 'Coder', sourceItem: 'DECIDE-3' },
53
+ { stateId: 'commitCoderProposal', role: 'coder', sourceItem: 'DECIDE-3' },
79
54
  ];
80
- const PLAYER_STATE_IDS = new Set(PLAYER_STATES.map((state) => state.stateId));
55
+ const ROLE_STATE_IDS = new Set(ROLE_STATES.map((state) => state.stateId));
56
+ const ROLE_IDS = ['coder', 'reviewer'];
57
+ const ROLE_ID_SET = new Set(ROLE_IDS);
58
+ const roleLabel = (roleId) => roleId === 'coder' ? 'Coder' : 'Reviewer';
81
59
  const BOSS_INTERRUPT_TARGETS = ['independentProposals'];
82
60
  const BOSS_INTERRUPT_TARGET_IDS = new Set(BOSS_INTERRUPT_TARGETS);
83
61
  const TELEMETRY_TOPIC = 'playbook.fsm.state';
84
62
  const TRACE_TOPIC = 'playbook.trace';
85
63
  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
- ];
64
+ const PLACEHOLDER_FIELDS = [['<caller-topic>', 'callerTopic']];
90
65
  const VERBATIM_PAYLOAD_FIELDS = new Set([
91
66
  'coderProposal',
92
67
  'reviewerProposal',
93
68
  'coderOutput',
94
69
  ]);
95
- function composePlayerPrompt(input) {
70
+ function composePlayerPrompt(input, promptIdentity) {
96
71
  const blocks = [];
97
72
  if (input.pendingBossQuestion && input.bossReply !== undefined) {
98
73
  blocks.push([
@@ -111,6 +86,9 @@ function composePlayerPrompt(input) {
111
86
  if (typeof value === 'string')
112
87
  replacements.set(placeholder, value);
113
88
  }
89
+ if (input.prompt.includes('<coder-llm>')) {
90
+ replacements.set('<coder-llm>', promptIdentity('coder'));
91
+ }
114
92
  const body = input.prompt.replace(/<caller-topic>|<coder-llm>/g, (placeholder, offset, source) => {
115
93
  const value = replacements.get(placeholder);
116
94
  if (value === undefined)
@@ -123,18 +101,6 @@ function composePlayerPrompt(input) {
123
101
  blocks.push(body);
124
102
  return blocks.join('\n\n');
125
103
  }
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
104
  // A `result` description names required payload fields in its
139
105
  // "Output shall include ..." sentence.
140
106
  function requiredFieldsFor(description) {
@@ -203,6 +169,22 @@ function parseJudgeJson(raw) {
203
169
  function isPlainObject(value) {
204
170
  return value !== null && typeof value === 'object' && !Array.isArray(value);
205
171
  }
172
+ function sortJson(value) {
173
+ if (Array.isArray(value))
174
+ return value.map((entry) => sortJson(entry));
175
+ if (value !== null && typeof value === 'object') {
176
+ const record = value;
177
+ const sorted = {};
178
+ for (const key of Object.keys(record).sort()) {
179
+ sorted[key] = sortJson(record[key]);
180
+ }
181
+ return sorted;
182
+ }
183
+ return value;
184
+ }
185
+ function stableJson(value, path) {
186
+ return JSON.stringify(sortJson(snapshotJsonValue(value, path)));
187
+ }
206
188
  function stripCodeFence(text) {
207
189
  const fence = text.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/i);
208
190
  return fence ? fence[1].trim() : text;
@@ -267,7 +249,7 @@ function buildClassifierPrompt(text, ctx) {
267
249
  if (ctx.pendingQuestions.length > 0) {
268
250
  lines.push('Pending Boss questions:');
269
251
  for (const pending of ctx.pendingQuestions) {
270
- lines.push(`- ${pending.questionId} (${pending.player}): ${pending.question}`);
252
+ lines.push(`- ${pending.questionId} (${pending.asker.roleId}): ${pending.question}`);
271
253
  }
272
254
  lines.push('If the Boss message answers a pending question, classify it as BOSS_REPLY; if it is a fresh directive, classify it accordingly.');
273
255
  }
@@ -338,7 +320,7 @@ function buildAdjudicatorPrompt(input, playerOutput) {
338
320
  lines.push('This is hidden control work. Do not call tools, inspect files, or ' +
339
321
  'seek external evidence. Decide only from the supplied player output ' +
340
322
  'and guard descriptions. Reply with exactly one JSON object and no prose.');
341
- lines.push(`The player "${input.player}" produced the output below for source item ${input.sourceItem}.`);
323
+ lines.push(`The role "${roleLabel(input.role)}" produced the output below for source item ${input.sourceItem}.`);
342
324
  lines.push('Choose exactly one guard whose description matches that output.');
343
325
  lines.push('');
344
326
  lines.push('Player output (verbatim):');
@@ -436,7 +418,9 @@ function pendingQuestionsFromContext(context) {
436
418
  obj.questionId === key &&
437
419
  typeof obj.resumeStateId === 'string' &&
438
420
  typeof obj.sourceItem === 'string' &&
439
- typeof obj.player === 'string' &&
421
+ isPlainObject(obj.asker) &&
422
+ obj.asker.kind === 'role' &&
423
+ ROLE_ID_SET.has(String(obj.asker.roleId)) &&
440
424
  typeof obj.question === 'string') {
441
425
  questions.push(obj);
442
426
  }
@@ -452,7 +436,7 @@ const WAIT_STATE_IDS = new Set([
452
436
  'awaitBossReply',
453
437
  ]);
454
438
  const STATUS_STATE_IDS = new Set([
455
- ...PLAYER_STATE_IDS,
439
+ ...ROLE_STATE_IDS,
456
440
  ...WAIT_STATE_IDS,
457
441
  'failed',
458
442
  ]);
@@ -511,14 +495,7 @@ function telemetryPayload(previousState, state, event, context) {
511
495
  return payload;
512
496
  }
513
497
  export const createPlaybookRuntime = (options) => {
514
- const boundOptions = snapshotDecideRuntimeOptions(options);
515
- const binding = {
516
- ...DEFAULT_PLAYER_BINDING,
517
- ...(boundOptions.playerBinding ?? {}),
518
- };
519
- const fsmInput = {
520
- coderLlm: boundOptions.coderLlm,
521
- };
498
+ const fsmInput = snapshotDecideRuntimeOptions(options);
522
499
  let ports;
523
500
  let sessionIdentity;
524
501
  let actor;
@@ -538,9 +515,9 @@ export const createPlaybookRuntime = (options) => {
538
515
  let disposalPromise;
539
516
  let controlPlaneError;
540
517
  let nestedBridge;
541
- const playerResumeTokens = new Map();
518
+ const privateResumeTokens = new Map();
542
519
  const playbookCallTurnIds = new Map();
543
- const inFlightPlayerIds = new Set();
520
+ const inFlightPlayerKeys = new Set();
544
521
  const activeBoundaryCalls = new Set();
545
522
  const activeEmissionCalls = new Set();
546
523
  const emissionQueue = new PQueue({ concurrency: 1 });
@@ -623,67 +600,138 @@ export const createPlaybookRuntime = (options) => {
623
600
  }
624
601
  return sessionIdentity;
625
602
  };
626
- const selectPlayerResume = (playerId) => {
603
+ const bindSession = (nextSession) => {
604
+ const bound = snapshotPlaybookSession(nextSession);
605
+ if (bound.roleBindings === undefined)
606
+ return bound;
607
+ const actual = Object.keys(bound.roleBindings).sort();
608
+ const expected = [...ROLE_IDS].sort();
609
+ const missing = expected.filter((roleId) => !actual.includes(roleId));
610
+ const extra = actual.filter((roleId) => !ROLE_ID_SET.has(roleId));
611
+ if (missing.length > 0 || extra.length > 0) {
612
+ throw new TypeError(`DECIDE session roleBindings must cover exactly [${expected.join(', ')}]` +
613
+ `${missing.length === 0 ? '' : `; missing [${missing.join(', ')}]`}` +
614
+ `${extra.length === 0 ? '' : `; extra [${extra.join(', ')}]`}`);
615
+ }
616
+ return bound;
617
+ };
618
+ const resolvedPlayerId = (roleId) => requireSessionIdentity().roleBindings?.[roleId]?.playerId;
619
+ const promptIdentity = (roleId) => requireSessionIdentity().roleBindings?.[roleId]?.promptIdentity ?? roleId;
620
+ const composeInvocationPrompt = (input) => {
621
+ let active = true;
622
+ const lookup = (roleId) => {
623
+ if (!active) {
624
+ throw new Error('DECIDE prompt identity lookup is no longer active for this invocation');
625
+ }
626
+ if (!ROLE_ID_SET.has(roleId)) {
627
+ throw new TypeError(`DECIDE prompt identity lookup rejected undeclared role ${String(roleId)}`);
628
+ }
629
+ return promptIdentity(roleId);
630
+ };
631
+ try {
632
+ return composePlayerPrompt(input, lookup);
633
+ }
634
+ finally {
635
+ active = false;
636
+ }
637
+ };
638
+ const continuationKey = (roleId, playerId) => playerId ?? roleId;
639
+ const tokensByContinuationKey = (tokens) => {
640
+ const byKey = new Map();
641
+ for (const [roleId, token] of Object.entries(tokens)) {
642
+ if (!ROLE_ID_SET.has(roleId)) {
643
+ throw new TypeError(`DECIDE role tokens contain unknown role ${roleId}`);
644
+ }
645
+ const typedRole = roleId;
646
+ const key = continuationKey(typedRole, resolvedPlayerId(typedRole));
647
+ const prior = byKey.get(key);
648
+ if (prior !== undefined && prior !== token) {
649
+ throw new TypeError(`DECIDE runtime snapshot assigns conflicting tokens to roles bound to player ${key}`);
650
+ }
651
+ byKey.set(key, token);
652
+ }
653
+ const rolesByKey = new Map();
654
+ for (const roleId of ROLE_IDS) {
655
+ const key = continuationKey(roleId, resolvedPlayerId(roleId));
656
+ rolesByKey.set(key, [...(rolesByKey.get(key) ?? []), roleId]);
657
+ }
658
+ for (const [key, roles] of rolesByKey) {
659
+ if (roles.length < 2)
660
+ continue;
661
+ const present = roles.filter((roleId) => tokens[roleId] !== undefined);
662
+ if (present.length !== 0 && present.length !== roles.length) {
663
+ throw new TypeError(`DECIDE role tokens must project player ${key} through every aliased role [${roles.join(', ')}]`);
664
+ }
665
+ }
666
+ return byKey;
667
+ };
668
+ const selectPlayerResume = (roleId, playerId) => {
627
669
  const session = requireSessionIdentity();
628
670
  const selected = session.playerSessions
629
- ? session.playerSessions.select(playerId)
630
- : playerResumeTokens.get(playerId) ?? false;
671
+ ? session.playerSessions.select(roleId)
672
+ : privateResumeTokens.get(continuationKey(roleId, playerId)) ?? false;
631
673
  if (selected !== false &&
632
674
  (typeof selected !== 'string' || selected.trim().length === 0)) {
633
- throw new TypeError(`player session store returned an invalid resume token for ${playerId}`);
675
+ throw new TypeError(`player session store returned an invalid resume token for role ${roleId}`);
634
676
  }
635
677
  return selected;
636
678
  };
637
- const updatePlayerResume = (playerId, resumeToken) => {
679
+ const updatePlayerResume = (roleId, playerId, result) => {
680
+ if (result.resumeToken === undefined && result.status !== 'ok')
681
+ return;
638
682
  const session = requireSessionIdentity();
639
683
  if (session.playerSessions) {
640
- session.playerSessions.update(playerId, resumeToken);
684
+ session.playerSessions.update(roleId, result.resumeToken);
641
685
  }
642
- else if (resumeToken !== undefined && resumeToken.trim().length > 0) {
643
- playerResumeTokens.set(playerId, resumeToken);
686
+ else if (result.resumeToken !== undefined) {
687
+ privateResumeTokens.set(continuationKey(roleId, playerId), result.resumeToken);
644
688
  }
645
689
  else {
646
- playerResumeTokens.delete(playerId);
690
+ privateResumeTokens.delete(continuationKey(roleId, playerId));
647
691
  }
648
692
  };
649
- const snapshotPlayerResumeTokens = () => {
693
+ const snapshotRoleResumeTokens = () => {
650
694
  const session = requireSessionIdentity();
651
695
  const captured = snapshotJsonValue(session.playerSessions
652
696
  ? session.playerSessions.snapshot()
653
- : Object.fromEntries(playerResumeTokens), 'player session store snapshot');
697
+ : Object.fromEntries(ROLE_IDS.flatMap((roleId) => {
698
+ const token = privateResumeTokens.get(continuationKey(roleId, resolvedPlayerId(roleId)));
699
+ return token === undefined ? [] : [[roleId, token]];
700
+ })), 'player session store snapshot');
654
701
  if (!isPlainObject(captured)) {
655
702
  throw new TypeError('player session store snapshot must be an object');
656
703
  }
657
704
  const tokens = {};
658
- for (const [playerId, token] of Object.entries(captured)) {
659
- if (playerId.trim().length === 0) {
660
- throw new TypeError('player session store snapshot player ids must be non-empty');
705
+ for (const [roleId, token] of Object.entries(captured)) {
706
+ if (!ROLE_ID_SET.has(roleId)) {
707
+ throw new TypeError(`player session store snapshot contains unknown role ${roleId}`);
661
708
  }
662
709
  if (typeof token !== 'string' || token.trim().length === 0) {
663
- throw new TypeError(`player session store snapshot token for ${playerId} must be a non-empty string`);
710
+ throw new TypeError(`player session store snapshot token for ${roleId} must be a non-empty string`);
664
711
  }
665
- tokens[playerId] = token;
712
+ tokens[roleId] = token;
666
713
  }
714
+ tokensByContinuationKey(tokens);
667
715
  return tokens;
668
716
  };
669
- const restorePlayerResumeTokens = (tokens) => {
717
+ const restoreRoleResumeTokens = (tokens) => {
718
+ const byKey = tokensByContinuationKey(tokens);
670
719
  const session = requireSessionIdentity();
671
720
  if (session.playerSessions) {
672
721
  session.playerSessions.restore(tokens);
673
722
  return;
674
723
  }
675
- playerResumeTokens.clear();
676
- for (const [playerId, token] of Object.entries(tokens)) {
677
- playerResumeTokens.set(playerId, token);
678
- }
724
+ privateResumeTokens.clear();
725
+ for (const [key, token] of byKey)
726
+ privateResumeTokens.set(key, token);
679
727
  };
680
- const currentState = () => {
728
+ const currentState = (pendingCall = nestedBridge.getPendingCall()) => {
681
729
  const live = actor;
682
730
  if (!live) {
683
731
  throw new Error('decide runtime: actor is not initialized');
684
732
  }
685
733
  return normalizePlaybookSnapshot(live.getSnapshot(), {
686
- pendingCall: nestedBridge.getPendingCall(),
734
+ pendingCall,
687
735
  });
688
736
  };
689
737
  const stateIdentity = (state) => {
@@ -694,7 +742,7 @@ export const createPlaybookRuntime = (options) => {
694
742
  const identity = requireSessionIdentity();
695
743
  const jsonPayload = snapshotJsonValue(payload, `trace ${type} payload`);
696
744
  const trace = Object.freeze({
697
- schemaVersion: 2,
745
+ schemaVersion: 3,
698
746
  sessionId: identity.sessionId,
699
747
  playbookId: identity.playbookId,
700
748
  rootSessionId: identity.rootSessionId,
@@ -796,15 +844,17 @@ export const createPlaybookRuntime = (options) => {
796
844
  };
797
845
  const callJudge = (prompt, signal, purpose, callStateId) => trackBoundaryCall(runJudgeCall(prompt, signal, purpose, callStateId));
798
846
  const runPlayerCall = async (input, signal) => {
799
- const playerId = resolvePlayerId(input, binding);
800
- if (inFlightPlayerIds.has(playerId)) {
801
- throw new Error(`resolved player "${playerId}" already has an in-flight call`);
847
+ if (!ROLE_ID_SET.has(input.role)) {
848
+ throw new TypeError(`DECIDE player input role must name a declared local role`);
802
849
  }
803
- const prompt = composePlayerPrompt(input);
850
+ const roleId = input.role;
851
+ const playerId = resolvedPlayerId(roleId);
852
+ const playerKey = continuationKey(roleId, playerId);
853
+ const prompt = composeInvocationPrompt(input);
804
854
  let resume;
805
855
  try {
806
856
  signal.throwIfAborted();
807
- resume = selectPlayerResume(playerId);
857
+ resume = selectPlayerResume(roleId, playerId);
808
858
  }
809
859
  catch (error) {
810
860
  latchControlPlaneError(error, signal);
@@ -812,10 +862,10 @@ export const createPlaybookRuntime = (options) => {
812
862
  }
813
863
  const callId = `player-${++playerCallSequence}`;
814
864
  const identity = {
815
- purpose: 'captain',
816
865
  stateId: input.stateId,
817
866
  sourceItem: input.sourceItem,
818
- playerId,
867
+ roleId,
868
+ ...(playerId === undefined ? {} : { playerId }),
819
869
  resume,
820
870
  };
821
871
  const emitFailure = (error) => emitTrace('player.call.finished', {
@@ -826,13 +876,19 @@ export const createPlaybookRuntime = (options) => {
826
876
  message: String(error),
827
877
  },
828
878
  }, { turnId: currentTurnId, callId });
829
- inFlightPlayerIds.add(playerId);
879
+ if (inFlightPlayerKeys.has(playerKey)) {
880
+ const error = new Error(`resolved player key "${playerKey}" already has an in-flight call`);
881
+ await emitCallStarted('player.call.started', 'player.call.finished', { ...identity, prompt }, { turnId: currentTurnId, callId }, signal);
882
+ await emitFailure(error);
883
+ throw error;
884
+ }
885
+ inFlightPlayerKeys.add(playerKey);
830
886
  try {
831
887
  await emitCallStarted('player.call.started', 'player.call.finished', { ...identity, prompt }, { turnId: currentTurnId, callId }, signal);
832
888
  let rawResult;
833
889
  try {
834
890
  signal.throwIfAborted();
835
- const boundary = Promise.resolve(requirePorts().callPlayer(playerId, prompt, signal, { resume }));
891
+ const boundary = Promise.resolve(requirePorts().callPlayer(roleId, prompt, signal, { resume }));
836
892
  rawResult = await boundary;
837
893
  // An XState sibling cancellation does not cancel an arbitrary coder
838
894
  // promise. Re-check before a late resolution can mutate continuity or
@@ -869,10 +925,7 @@ export const createPlaybookRuntime = (options) => {
869
925
  // The resolved result is authoritative even on aborted/error status.
870
926
  // Update continuation state before interpreting that status.
871
927
  try {
872
- updatePlayerResume(playerId, typeof result.resumeToken === 'string' &&
873
- result.resumeToken.trim().length > 0
874
- ? result.resumeToken
875
- : undefined);
928
+ updatePlayerResume(roleId, playerId, result);
876
929
  }
877
930
  catch (error) {
878
931
  latchControlPlaneError(error, signal);
@@ -900,10 +953,14 @@ export const createPlaybookRuntime = (options) => {
900
953
  ? { error: normalizeErrorFull(result.error) }
901
954
  : {}),
902
955
  }, { turnId: currentTurnId, callId });
903
- return { playerId, result };
956
+ return {
957
+ roleId,
958
+ ...(playerId === undefined ? {} : { playerId }),
959
+ result,
960
+ };
904
961
  }
905
962
  finally {
906
- inFlightPlayerIds.delete(playerId);
963
+ inFlightPlayerKeys.delete(playerKey);
907
964
  }
908
965
  };
909
966
  const callPlayer = (input, signal) => {
@@ -922,7 +979,7 @@ export const createPlaybookRuntime = (options) => {
922
979
  throw error;
923
980
  }
924
981
  combined.throwIfAborted();
925
- let { playerId, result } = await callPlayer(input, combined);
982
+ let { roleId, playerId, result } = await callPlayer(input, combined);
926
983
  if (result.status === 'ok' && isEmptyFinalText(result.finalText)) {
927
984
  // DR-028: an `ok` result whose finalText is missing, empty, or
928
985
  // whitespace-only earns exactly one corrective re-ask — the same
@@ -933,14 +990,14 @@ export const createPlaybookRuntime = (options) => {
933
990
  // are never retried), and a rejecting finish emission rejects
934
991
  // `callPlayer` itself, so it never reaches this branch (PBRT-47).
935
992
  combined.throwIfAborted();
936
- ({ playerId, result } = await callPlayer(input, combined));
993
+ ({ roleId, playerId, result } = await callPlayer(input, combined));
937
994
  }
938
995
  if (result.status !== 'ok') {
939
- throw new Error(`player "${playerId}" returned status "${result.status}"${result.error ? `: ${result.error}` : ''}`);
996
+ throw new Error(`${roleLabel(roleId)}${playerId === undefined ? '' : ` (${playerId})`} returned status "${result.status}"${result.error ? `: ${result.error}` : ''}`);
940
997
  }
941
998
  const finalText = result.finalText ?? '';
942
999
  if (isEmptyFinalText(finalText)) {
943
- throw new Error(`player "${playerId}" returned status "ok" with no finalText`);
1000
+ throw new Error(`${roleLabel(roleId)}${playerId === undefined ? '' : ` (${playerId})`} returned status "ok" with no finalText`);
944
1001
  }
945
1002
  combined.throwIfAborted();
946
1003
  try {
@@ -1041,8 +1098,8 @@ export const createPlaybookRuntime = (options) => {
1041
1098
  if (WAIT_STATE_IDS.has(activeStateId)) {
1042
1099
  const pending = questionForWaitState(activeStateId, pendingQuestions);
1043
1100
  if (pending) {
1044
- scheduleStatus(`${pending.player} asks: ${pending.question}`, activeStateId);
1045
- scheduleStatus(`◆ awaiting Boss reply · ${pending.resumeStateId} · ${pending.player} · ${pending.sourceItem}`, activeStateId);
1101
+ scheduleStatus(`${pending.asker.roleId} asks: ${pending.question}`, activeStateId);
1102
+ scheduleStatus(`◆ awaiting Boss reply · ${pending.resumeStateId} · ${pending.asker.roleId} · ${pending.sourceItem}`, activeStateId);
1046
1103
  }
1047
1104
  continue;
1048
1105
  }
@@ -1052,10 +1109,10 @@ export const createPlaybookRuntime = (options) => {
1052
1109
  const description = STATE_DESCRIPTIONS[activeStateId];
1053
1110
  if (description === undefined)
1054
1111
  continue;
1055
- const playerState = PLAYER_STATES.find((candidate) => candidate.stateId === activeStateId);
1056
- scheduleStatus(playerState === undefined
1112
+ const roleState = ROLE_STATES.find((candidate) => candidate.stateId === activeStateId);
1113
+ scheduleStatus(roleState === undefined
1057
1114
  ? '◆ workflow failed; awaiting Boss recovery.'
1058
- : `⤷ ${playerState.player}: ${description}`, activeStateId, lastError === undefined ? undefined : { lastError });
1115
+ : `⤷ ${roleLabel(roleState.role)}: ${description}`, activeStateId, lastError === undefined ? undefined : { lastError });
1059
1116
  }
1060
1117
  }
1061
1118
  catch (error) {
@@ -1181,7 +1238,7 @@ export const createPlaybookRuntime = (options) => {
1181
1238
  // The caller rethrows its original failure. A restore failure skips
1182
1239
  // the disposal trace — the parked session was never re-bound in this
1183
1240
  // process, so its persisted snapshot stays authoritative (DR-014 §2).
1184
- const cleanupFailedStart = async (options) => {
1241
+ const cleanupFailedStart = async (cause, options) => {
1185
1242
  let finalState;
1186
1243
  if (options.emitDisposal && actor) {
1187
1244
  try {
@@ -1197,6 +1254,12 @@ export const createPlaybookRuntime = (options) => {
1197
1254
  catch {
1198
1255
  // Preserve the original startup failure.
1199
1256
  }
1257
+ try {
1258
+ await nestedBridge.abortPending(cause);
1259
+ }
1260
+ catch {
1261
+ // Preserve the original startup failure.
1262
+ }
1200
1263
  try {
1201
1264
  await judgeQueue.onIdle();
1202
1265
  await drainBoundaryCallsAndEmissions();
@@ -1217,8 +1280,8 @@ export const createPlaybookRuntime = (options) => {
1217
1280
  // The session-start error remains authoritative.
1218
1281
  }
1219
1282
  }
1220
- playerResumeTokens.clear();
1221
- inFlightPlayerIds.clear();
1283
+ privateResumeTokens.clear();
1284
+ inFlightPlayerKeys.clear();
1222
1285
  activeBoundaryCalls.clear();
1223
1286
  activeEmissionCalls.clear();
1224
1287
  emissionQueue.clear();
@@ -1248,7 +1311,7 @@ export const createPlaybookRuntime = (options) => {
1248
1311
  disposalPromise !== undefined) {
1249
1312
  throw new Error('decide runtime: init(session) may only be called once');
1250
1313
  }
1251
- const identity = snapshotPlaybookSession(session);
1314
+ const identity = bindSession(session);
1252
1315
  let finishInitialization;
1253
1316
  const initialization = new Promise((resolve) => {
1254
1317
  finishInitialization = resolve;
@@ -1269,7 +1332,7 @@ export const createPlaybookRuntime = (options) => {
1269
1332
  await flush();
1270
1333
  }
1271
1334
  catch (error) {
1272
- await cleanupFailedStart({ emitDisposal: true });
1335
+ await cleanupFailedStart(error, { emitDisposal: true });
1273
1336
  throw error;
1274
1337
  }
1275
1338
  finally {
@@ -1278,10 +1341,11 @@ export const createPlaybookRuntime = (options) => {
1278
1341
  initInFlight = undefined;
1279
1342
  }
1280
1343
  },
1281
- // DR-014 §1 / PBRT-45: JSON-safe capture of a parked session.
1344
+ // DR-014 §1 / DR-031 §5 / PBRT-45: JSON-safe capture of a parked
1345
+ // session, including one already-started suspended REVIEW call.
1282
1346
  // Defined only at a safe capture point — initialized, not disposing
1283
- // or disposed, no active public boundary, and the actor quiescent
1284
- // with status `active` and no pending nested REVIEW call.
1347
+ // or disposed, no active public boundary, and the actor quiescent with
1348
+ // status `active`.
1285
1349
  exportSnapshot() {
1286
1350
  if (!actor ||
1287
1351
  !sessionIdentity ||
@@ -1292,18 +1356,43 @@ export const createPlaybookRuntime = (options) => {
1292
1356
  if (currentTurnId !== undefined || currentSignal !== undefined) {
1293
1357
  return undefined;
1294
1358
  }
1295
- if (nestedBridge.getPendingCall())
1359
+ const pendingCall = nestedBridge.getPendingCall();
1360
+ const bridgeSuspendedCall = nestedBridge.getSuspendedCall();
1361
+ if ((pendingCall === undefined) !== (bridgeSuspendedCall === undefined)) {
1362
+ return undefined;
1363
+ }
1364
+ if (pendingCall !== undefined &&
1365
+ bridgeSuspendedCall !== undefined &&
1366
+ (pendingCall.callId !== bridgeSuspendedCall.callId ||
1367
+ pendingCall.playbookId !== bridgeSuspendedCall.playbookId ||
1368
+ pendingCall.childSessionId !== bridgeSuspendedCall.childSessionId)) {
1296
1369
  return undefined;
1370
+ }
1371
+ let suspendedCall;
1372
+ if (bridgeSuspendedCall !== undefined) {
1373
+ if (!playbookCallTurnIds.has(bridgeSuspendedCall.callId)) {
1374
+ return undefined;
1375
+ }
1376
+ const turnId = playbookCallTurnIds.get(bridgeSuspendedCall.callId);
1377
+ if (bridgeSuspendedCall.turnId !== undefined &&
1378
+ bridgeSuspendedCall.turnId !== turnId) {
1379
+ return undefined;
1380
+ }
1381
+ suspendedCall = {
1382
+ ...bridgeSuspendedCall,
1383
+ ...(turnId === undefined ? {} : { turnId }),
1384
+ };
1385
+ }
1297
1386
  const state = currentState();
1298
1387
  if (state.status !== 'active' || !state.quiescent)
1299
1388
  return undefined;
1300
1389
  const machine = detachPersistedMachineSnapshot(actor.getPersistedSnapshot());
1301
1390
  const context = actor.getSnapshot().context;
1302
1391
  return {
1303
- schemaVersion: 1,
1392
+ schemaVersion: 3,
1304
1393
  playbookId: sessionIdentity.playbookId,
1305
1394
  machine,
1306
- playerResumeTokens: snapshotPlayerResumeTokens(),
1395
+ roleResumeTokens: snapshotRoleResumeTokens(),
1307
1396
  sequences: {
1308
1397
  trace: traceSequence,
1309
1398
  turn: turnSequence,
@@ -1314,10 +1403,11 @@ export const createPlaybookRuntime = (options) => {
1314
1403
  state,
1315
1404
  pendingBossQuestions: pendingQuestionsFromContext(context).map((pending) => ({
1316
1405
  questionId: pending.questionId,
1317
- player: pending.player,
1406
+ asker: pending.asker,
1318
1407
  question: pending.question,
1319
1408
  sourceItem: pending.sourceItem,
1320
1409
  })),
1410
+ ...(suspendedCall === undefined ? {} : { suspendedCall }),
1321
1411
  };
1322
1412
  },
1323
1413
  // DR-014 §1 / PBRT-45: alternative to `init` that rehydrates an
@@ -1332,8 +1422,9 @@ export const createPlaybookRuntime = (options) => {
1332
1422
  disposalPromise !== undefined) {
1333
1423
  throw new Error('decide runtime: restore(session, snapshot) may only be called once');
1334
1424
  }
1335
- const identity = snapshotPlaybookSession(session);
1336
- const boundSnapshot = assertPlaybookRuntimeSnapshot(snapshot, identity.playbookId);
1425
+ const identity = bindSession(session);
1426
+ const boundSnapshot = assertPlaybookRuntimeSnapshot(snapshot, identity.playbookId, { allowSuspendedCall: true });
1427
+ const suspendedCall = boundSnapshot.suspendedCall;
1337
1428
  let finishInitialization;
1338
1429
  const initialization = new Promise((resolve) => {
1339
1430
  finishInitialization = resolve;
@@ -1342,7 +1433,7 @@ export const createPlaybookRuntime = (options) => {
1342
1433
  lifecycleStarted = true;
1343
1434
  ports = identity.ports;
1344
1435
  sessionIdentity = identity;
1345
- let priorExternalPlayerTokens;
1436
+ let priorExternalRoleTokens;
1346
1437
  try {
1347
1438
  traceSequence = boundSnapshot.sequences.trace;
1348
1439
  turnSequence = boundSnapshot.sequences.turn;
@@ -1350,31 +1441,43 @@ export const createPlaybookRuntime = (options) => {
1350
1441
  playerCallSequence = boundSnapshot.sequences.playerCall;
1351
1442
  playbookCallSequence = boundSnapshot.sequences.playbookCall;
1352
1443
  if (identity.playerSessions) {
1353
- priorExternalPlayerTokens = snapshotPlayerResumeTokens();
1444
+ priorExternalRoleTokens = snapshotRoleResumeTokens();
1445
+ }
1446
+ restoreRoleResumeTokens(boundSnapshot.roleResumeTokens);
1447
+ nestedBridge.prepareRestore(suspendedCall);
1448
+ if (suspendedCall !== undefined) {
1449
+ playbookCallTurnIds.set(suspendedCall.callId, suspendedCall.turnId);
1354
1450
  }
1355
- restorePlayerResumeTokens(boundSnapshot.playerResumeTokens);
1356
1451
  suppressInspectionEmissions = true;
1357
1452
  createRuntimeActor(boundSnapshot.machine);
1358
1453
  actor?.start();
1359
- const restoredState = currentState();
1454
+ const restoredState = currentState(suspendedCall);
1360
1455
  if (restoredState.status !== 'active') {
1361
1456
  throw new Error(`decide runtime: restored actor status is ${restoredState.status}, expected active`);
1362
1457
  }
1363
- suppressInspectionEmissions = false;
1458
+ if (stableJson(restoredState, 'restored runtime state') !==
1459
+ stableJson(boundSnapshot.state, 'runtime snapshot state')) {
1460
+ throw new Error('decide runtime: restored actor state does not match snapshot state');
1461
+ }
1364
1462
  previousState = restoredState;
1365
1463
  await flush();
1464
+ suppressInspectionEmissions = false;
1465
+ // Final fallible step: after this publication the authoritative
1466
+ // child has rejoined ordinary resume/abort ownership, so no later
1467
+ // restore validation may trigger failed-start rollback.
1468
+ nestedBridge.confirmRestore();
1366
1469
  }
1367
1470
  catch (error) {
1368
1471
  let failure = error;
1369
- if (priorExternalPlayerTokens !== undefined) {
1472
+ if (priorExternalRoleTokens !== undefined) {
1370
1473
  try {
1371
- identity.playerSessions.restore(priorExternalPlayerTokens);
1474
+ identity.playerSessions.restore(priorExternalRoleTokens);
1372
1475
  }
1373
1476
  catch (rollbackError) {
1374
1477
  failure = new AggregateError([error, rollbackError], 'DECIDE restore and player continuation rollback failed');
1375
1478
  }
1376
1479
  }
1377
- await cleanupFailedStart({ emitDisposal: false });
1480
+ await cleanupFailedStart(failure, { emitDisposal: false });
1378
1481
  throw failure;
1379
1482
  }
1380
1483
  finally {
@@ -1607,9 +1710,9 @@ export const createPlaybookRuntime = (options) => {
1607
1710
  collectFailure(failures, error);
1608
1711
  }
1609
1712
  finally {
1610
- playerResumeTokens.clear();
1713
+ privateResumeTokens.clear();
1611
1714
  playbookCallTurnIds.clear();
1612
- inFlightPlayerIds.clear();
1715
+ inFlightPlayerKeys.clear();
1613
1716
  activeBoundaryCalls.clear();
1614
1717
  activeEmissionCalls.clear();
1615
1718
  emissionQueue.clear();
@@ -1635,7 +1738,6 @@ export const createPlaybookRuntime = (options) => {
1635
1738
  };
1636
1739
  export const _internal = {
1637
1740
  composePlayerPrompt,
1638
- resolvePlayerId,
1639
1741
  requiredFieldsFor,
1640
1742
  extractJson,
1641
1743
  buildClassifierPrompt,
@@ -1646,10 +1748,9 @@ export const _internal = {
1646
1748
  pendingQuestionsFromContext,
1647
1749
  normalizeErrorCompact,
1648
1750
  normalizeErrorFull,
1649
- DEFAULT_PLAYER_BINDING,
1650
1751
  STATE_DESCRIPTIONS,
1651
- PLAYER_STATES,
1652
- PLAYER_STATE_IDS,
1752
+ ROLE_STATES,
1753
+ ROLE_STATE_IDS,
1653
1754
  VERBATIM_PAYLOAD_FIELDS,
1654
1755
  BOSS_INTERRUPT_TARGETS,
1655
1756
  CONTINUATION_PREAMBLE,