@sublang/playbook 7.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 (61) hide show
  1. package/README.md +17 -4
  2. package/docs/cli.md +74 -29
  3. package/docs/configuration.md +209 -112
  4. package/docs/embedding.md +71 -25
  5. package/package.json +4 -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 +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 +5 -5
  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 +7 -11
  18. package/reference/sdlc/code.playbook/code.fsm.ts +9 -17
  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 +12 -13
  22. package/reference/sdlc/code.playbook/code.playbook.ts +22 -15
  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 +970 -289
  28. package/reference/sdlc/code.playbook/playbook-captain.ts +1403 -396
  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 +9 -9
  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 +9 -13
  36. package/reference/sdlc/decide.playbook/decide.playbook.js +171 -134
  37. package/reference/sdlc/decide.playbook/decide.playbook.ts +238 -162
  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 +16 -21
  48. package/reference/sdlc/review.playbook/review.playbook.ts +26 -26
  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 +27 -23
  53. package/slc/link.md +113 -93
  54. package/slc/text2gears.md +19 -18
  55. package/src/runtime.d.ts +20 -16
  56. package/src/runtime.ts +19 -23
  57. package/src/xstate-playbook-runtime.d.ts +21 -17
  58. package/src/xstate-playbook-runtime.js +241 -149
  59. package/src/xstate-playbook-runtime.ts +331 -178
  60. package/src/xstate-runtime.js +63 -24
  61. package/src/xstate-runtime.ts +96 -28
@@ -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');
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`);
53
- }
54
- }
55
- }
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) {
@@ -283,7 +249,7 @@ function buildClassifierPrompt(text, ctx) {
283
249
  if (ctx.pendingQuestions.length > 0) {
284
250
  lines.push('Pending Boss questions:');
285
251
  for (const pending of ctx.pendingQuestions) {
286
- lines.push(`- ${pending.questionId} (${pending.player}): ${pending.question}`);
252
+ lines.push(`- ${pending.questionId} (${pending.asker.roleId}): ${pending.question}`);
287
253
  }
288
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.');
289
255
  }
@@ -354,7 +320,7 @@ function buildAdjudicatorPrompt(input, playerOutput) {
354
320
  lines.push('This is hidden control work. Do not call tools, inspect files, or ' +
355
321
  'seek external evidence. Decide only from the supplied player output ' +
356
322
  '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}.`);
323
+ lines.push(`The role "${roleLabel(input.role)}" produced the output below for source item ${input.sourceItem}.`);
358
324
  lines.push('Choose exactly one guard whose description matches that output.');
359
325
  lines.push('');
360
326
  lines.push('Player output (verbatim):');
@@ -452,7 +418,9 @@ function pendingQuestionsFromContext(context) {
452
418
  obj.questionId === key &&
453
419
  typeof obj.resumeStateId === 'string' &&
454
420
  typeof obj.sourceItem === 'string' &&
455
- typeof obj.player === 'string' &&
421
+ isPlainObject(obj.asker) &&
422
+ obj.asker.kind === 'role' &&
423
+ ROLE_ID_SET.has(String(obj.asker.roleId)) &&
456
424
  typeof obj.question === 'string') {
457
425
  questions.push(obj);
458
426
  }
@@ -468,7 +436,7 @@ const WAIT_STATE_IDS = new Set([
468
436
  'awaitBossReply',
469
437
  ]);
470
438
  const STATUS_STATE_IDS = new Set([
471
- ...PLAYER_STATE_IDS,
439
+ ...ROLE_STATE_IDS,
472
440
  ...WAIT_STATE_IDS,
473
441
  'failed',
474
442
  ]);
@@ -527,14 +495,7 @@ function telemetryPayload(previousState, state, event, context) {
527
495
  return payload;
528
496
  }
529
497
  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
- };
498
+ const fsmInput = snapshotDecideRuntimeOptions(options);
538
499
  let ports;
539
500
  let sessionIdentity;
540
501
  let actor;
@@ -554,9 +515,9 @@ export const createPlaybookRuntime = (options) => {
554
515
  let disposalPromise;
555
516
  let controlPlaneError;
556
517
  let nestedBridge;
557
- const playerResumeTokens = new Map();
518
+ const privateResumeTokens = new Map();
558
519
  const playbookCallTurnIds = new Map();
559
- const inFlightPlayerIds = new Set();
520
+ const inFlightPlayerKeys = new Set();
560
521
  const activeBoundaryCalls = new Set();
561
522
  const activeEmissionCalls = new Set();
562
523
  const emissionQueue = new PQueue({ concurrency: 1 });
@@ -639,59 +600,130 @@ export const createPlaybookRuntime = (options) => {
639
600
  }
640
601
  return sessionIdentity;
641
602
  };
642
- 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) => {
643
669
  const session = requireSessionIdentity();
644
670
  const selected = session.playerSessions
645
- ? session.playerSessions.select(playerId)
646
- : playerResumeTokens.get(playerId) ?? false;
671
+ ? session.playerSessions.select(roleId)
672
+ : privateResumeTokens.get(continuationKey(roleId, playerId)) ?? false;
647
673
  if (selected !== false &&
648
674
  (typeof selected !== 'string' || selected.trim().length === 0)) {
649
- 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}`);
650
676
  }
651
677
  return selected;
652
678
  };
653
- const updatePlayerResume = (playerId, resumeToken) => {
679
+ const updatePlayerResume = (roleId, playerId, result) => {
680
+ if (result.resumeToken === undefined && result.status !== 'ok')
681
+ return;
654
682
  const session = requireSessionIdentity();
655
683
  if (session.playerSessions) {
656
- session.playerSessions.update(playerId, resumeToken);
684
+ session.playerSessions.update(roleId, result.resumeToken);
657
685
  }
658
- else if (resumeToken !== undefined && resumeToken.trim().length > 0) {
659
- playerResumeTokens.set(playerId, resumeToken);
686
+ else if (result.resumeToken !== undefined) {
687
+ privateResumeTokens.set(continuationKey(roleId, playerId), result.resumeToken);
660
688
  }
661
689
  else {
662
- playerResumeTokens.delete(playerId);
690
+ privateResumeTokens.delete(continuationKey(roleId, playerId));
663
691
  }
664
692
  };
665
- const snapshotPlayerResumeTokens = () => {
693
+ const snapshotRoleResumeTokens = () => {
666
694
  const session = requireSessionIdentity();
667
695
  const captured = snapshotJsonValue(session.playerSessions
668
696
  ? session.playerSessions.snapshot()
669
- : 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');
670
701
  if (!isPlainObject(captured)) {
671
702
  throw new TypeError('player session store snapshot must be an object');
672
703
  }
673
704
  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');
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}`);
677
708
  }
678
709
  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`);
710
+ throw new TypeError(`player session store snapshot token for ${roleId} must be a non-empty string`);
680
711
  }
681
- tokens[playerId] = token;
712
+ tokens[roleId] = token;
682
713
  }
714
+ tokensByContinuationKey(tokens);
683
715
  return tokens;
684
716
  };
685
- const restorePlayerResumeTokens = (tokens) => {
717
+ const restoreRoleResumeTokens = (tokens) => {
718
+ const byKey = tokensByContinuationKey(tokens);
686
719
  const session = requireSessionIdentity();
687
720
  if (session.playerSessions) {
688
721
  session.playerSessions.restore(tokens);
689
722
  return;
690
723
  }
691
- playerResumeTokens.clear();
692
- for (const [playerId, token] of Object.entries(tokens)) {
693
- playerResumeTokens.set(playerId, token);
694
- }
724
+ privateResumeTokens.clear();
725
+ for (const [key, token] of byKey)
726
+ privateResumeTokens.set(key, token);
695
727
  };
696
728
  const currentState = (pendingCall = nestedBridge.getPendingCall()) => {
697
729
  const live = actor;
@@ -710,7 +742,7 @@ export const createPlaybookRuntime = (options) => {
710
742
  const identity = requireSessionIdentity();
711
743
  const jsonPayload = snapshotJsonValue(payload, `trace ${type} payload`);
712
744
  const trace = Object.freeze({
713
- schemaVersion: 2,
745
+ schemaVersion: 3,
714
746
  sessionId: identity.sessionId,
715
747
  playbookId: identity.playbookId,
716
748
  rootSessionId: identity.rootSessionId,
@@ -812,15 +844,17 @@ export const createPlaybookRuntime = (options) => {
812
844
  };
813
845
  const callJudge = (prompt, signal, purpose, callStateId) => trackBoundaryCall(runJudgeCall(prompt, signal, purpose, callStateId));
814
846
  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`);
847
+ if (!ROLE_ID_SET.has(input.role)) {
848
+ throw new TypeError(`DECIDE player input role must name a declared local role`);
818
849
  }
819
- 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);
820
854
  let resume;
821
855
  try {
822
856
  signal.throwIfAborted();
823
- resume = selectPlayerResume(playerId);
857
+ resume = selectPlayerResume(roleId, playerId);
824
858
  }
825
859
  catch (error) {
826
860
  latchControlPlaneError(error, signal);
@@ -828,10 +862,10 @@ export const createPlaybookRuntime = (options) => {
828
862
  }
829
863
  const callId = `player-${++playerCallSequence}`;
830
864
  const identity = {
831
- purpose: 'captain',
832
865
  stateId: input.stateId,
833
866
  sourceItem: input.sourceItem,
834
- playerId,
867
+ roleId,
868
+ ...(playerId === undefined ? {} : { playerId }),
835
869
  resume,
836
870
  };
837
871
  const emitFailure = (error) => emitTrace('player.call.finished', {
@@ -842,13 +876,19 @@ export const createPlaybookRuntime = (options) => {
842
876
  message: String(error),
843
877
  },
844
878
  }, { turnId: currentTurnId, callId });
845
- 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);
846
886
  try {
847
887
  await emitCallStarted('player.call.started', 'player.call.finished', { ...identity, prompt }, { turnId: currentTurnId, callId }, signal);
848
888
  let rawResult;
849
889
  try {
850
890
  signal.throwIfAborted();
851
- const boundary = Promise.resolve(requirePorts().callPlayer(playerId, prompt, signal, { resume }));
891
+ const boundary = Promise.resolve(requirePorts().callPlayer(roleId, prompt, signal, { resume }));
852
892
  rawResult = await boundary;
853
893
  // An XState sibling cancellation does not cancel an arbitrary coder
854
894
  // promise. Re-check before a late resolution can mutate continuity or
@@ -885,10 +925,7 @@ export const createPlaybookRuntime = (options) => {
885
925
  // The resolved result is authoritative even on aborted/error status.
886
926
  // Update continuation state before interpreting that status.
887
927
  try {
888
- updatePlayerResume(playerId, typeof result.resumeToken === 'string' &&
889
- result.resumeToken.trim().length > 0
890
- ? result.resumeToken
891
- : undefined);
928
+ updatePlayerResume(roleId, playerId, result);
892
929
  }
893
930
  catch (error) {
894
931
  latchControlPlaneError(error, signal);
@@ -916,10 +953,14 @@ export const createPlaybookRuntime = (options) => {
916
953
  ? { error: normalizeErrorFull(result.error) }
917
954
  : {}),
918
955
  }, { turnId: currentTurnId, callId });
919
- return { playerId, result };
956
+ return {
957
+ roleId,
958
+ ...(playerId === undefined ? {} : { playerId }),
959
+ result,
960
+ };
920
961
  }
921
962
  finally {
922
- inFlightPlayerIds.delete(playerId);
963
+ inFlightPlayerKeys.delete(playerKey);
923
964
  }
924
965
  };
925
966
  const callPlayer = (input, signal) => {
@@ -938,7 +979,7 @@ export const createPlaybookRuntime = (options) => {
938
979
  throw error;
939
980
  }
940
981
  combined.throwIfAborted();
941
- let { playerId, result } = await callPlayer(input, combined);
982
+ let { roleId, playerId, result } = await callPlayer(input, combined);
942
983
  if (result.status === 'ok' && isEmptyFinalText(result.finalText)) {
943
984
  // DR-028: an `ok` result whose finalText is missing, empty, or
944
985
  // whitespace-only earns exactly one corrective re-ask — the same
@@ -949,14 +990,14 @@ export const createPlaybookRuntime = (options) => {
949
990
  // are never retried), and a rejecting finish emission rejects
950
991
  // `callPlayer` itself, so it never reaches this branch (PBRT-47).
951
992
  combined.throwIfAborted();
952
- ({ playerId, result } = await callPlayer(input, combined));
993
+ ({ roleId, playerId, result } = await callPlayer(input, combined));
953
994
  }
954
995
  if (result.status !== 'ok') {
955
- 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}` : ''}`);
956
997
  }
957
998
  const finalText = result.finalText ?? '';
958
999
  if (isEmptyFinalText(finalText)) {
959
- 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`);
960
1001
  }
961
1002
  combined.throwIfAborted();
962
1003
  try {
@@ -1057,8 +1098,8 @@ export const createPlaybookRuntime = (options) => {
1057
1098
  if (WAIT_STATE_IDS.has(activeStateId)) {
1058
1099
  const pending = questionForWaitState(activeStateId, pendingQuestions);
1059
1100
  if (pending) {
1060
- scheduleStatus(`${pending.player} asks: ${pending.question}`, activeStateId);
1061
- 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);
1062
1103
  }
1063
1104
  continue;
1064
1105
  }
@@ -1068,10 +1109,10 @@ export const createPlaybookRuntime = (options) => {
1068
1109
  const description = STATE_DESCRIPTIONS[activeStateId];
1069
1110
  if (description === undefined)
1070
1111
  continue;
1071
- const playerState = PLAYER_STATES.find((candidate) => candidate.stateId === activeStateId);
1072
- scheduleStatus(playerState === undefined
1112
+ const roleState = ROLE_STATES.find((candidate) => candidate.stateId === activeStateId);
1113
+ scheduleStatus(roleState === undefined
1073
1114
  ? '◆ workflow failed; awaiting Boss recovery.'
1074
- : `⤷ ${playerState.player}: ${description}`, activeStateId, lastError === undefined ? undefined : { lastError });
1115
+ : `⤷ ${roleLabel(roleState.role)}: ${description}`, activeStateId, lastError === undefined ? undefined : { lastError });
1075
1116
  }
1076
1117
  }
1077
1118
  catch (error) {
@@ -1239,8 +1280,8 @@ export const createPlaybookRuntime = (options) => {
1239
1280
  // The session-start error remains authoritative.
1240
1281
  }
1241
1282
  }
1242
- playerResumeTokens.clear();
1243
- inFlightPlayerIds.clear();
1283
+ privateResumeTokens.clear();
1284
+ inFlightPlayerKeys.clear();
1244
1285
  activeBoundaryCalls.clear();
1245
1286
  activeEmissionCalls.clear();
1246
1287
  emissionQueue.clear();
@@ -1270,7 +1311,7 @@ export const createPlaybookRuntime = (options) => {
1270
1311
  disposalPromise !== undefined) {
1271
1312
  throw new Error('decide runtime: init(session) may only be called once');
1272
1313
  }
1273
- const identity = snapshotPlaybookSession(session);
1314
+ const identity = bindSession(session);
1274
1315
  let finishInitialization;
1275
1316
  const initialization = new Promise((resolve) => {
1276
1317
  finishInitialization = resolve;
@@ -1348,10 +1389,10 @@ export const createPlaybookRuntime = (options) => {
1348
1389
  const machine = detachPersistedMachineSnapshot(actor.getPersistedSnapshot());
1349
1390
  const context = actor.getSnapshot().context;
1350
1391
  return {
1351
- schemaVersion: 2,
1392
+ schemaVersion: 3,
1352
1393
  playbookId: sessionIdentity.playbookId,
1353
1394
  machine,
1354
- playerResumeTokens: snapshotPlayerResumeTokens(),
1395
+ roleResumeTokens: snapshotRoleResumeTokens(),
1355
1396
  sequences: {
1356
1397
  trace: traceSequence,
1357
1398
  turn: turnSequence,
@@ -1362,7 +1403,7 @@ export const createPlaybookRuntime = (options) => {
1362
1403
  state,
1363
1404
  pendingBossQuestions: pendingQuestionsFromContext(context).map((pending) => ({
1364
1405
  questionId: pending.questionId,
1365
- player: pending.player,
1406
+ asker: pending.asker,
1366
1407
  question: pending.question,
1367
1408
  sourceItem: pending.sourceItem,
1368
1409
  })),
@@ -1381,11 +1422,9 @@ export const createPlaybookRuntime = (options) => {
1381
1422
  disposalPromise !== undefined) {
1382
1423
  throw new Error('decide runtime: restore(session, snapshot) may only be called once');
1383
1424
  }
1384
- const identity = snapshotPlaybookSession(session);
1425
+ const identity = bindSession(session);
1385
1426
  const boundSnapshot = assertPlaybookRuntimeSnapshot(snapshot, identity.playbookId, { allowSuspendedCall: true });
1386
- const suspendedCall = boundSnapshot.schemaVersion === 2
1387
- ? boundSnapshot.suspendedCall
1388
- : undefined;
1427
+ const suspendedCall = boundSnapshot.suspendedCall;
1389
1428
  let finishInitialization;
1390
1429
  const initialization = new Promise((resolve) => {
1391
1430
  finishInitialization = resolve;
@@ -1394,7 +1433,7 @@ export const createPlaybookRuntime = (options) => {
1394
1433
  lifecycleStarted = true;
1395
1434
  ports = identity.ports;
1396
1435
  sessionIdentity = identity;
1397
- let priorExternalPlayerTokens;
1436
+ let priorExternalRoleTokens;
1398
1437
  try {
1399
1438
  traceSequence = boundSnapshot.sequences.trace;
1400
1439
  turnSequence = boundSnapshot.sequences.turn;
@@ -1402,9 +1441,9 @@ export const createPlaybookRuntime = (options) => {
1402
1441
  playerCallSequence = boundSnapshot.sequences.playerCall;
1403
1442
  playbookCallSequence = boundSnapshot.sequences.playbookCall;
1404
1443
  if (identity.playerSessions) {
1405
- priorExternalPlayerTokens = snapshotPlayerResumeTokens();
1444
+ priorExternalRoleTokens = snapshotRoleResumeTokens();
1406
1445
  }
1407
- restorePlayerResumeTokens(boundSnapshot.playerResumeTokens);
1446
+ restoreRoleResumeTokens(boundSnapshot.roleResumeTokens);
1408
1447
  nestedBridge.prepareRestore(suspendedCall);
1409
1448
  if (suspendedCall !== undefined) {
1410
1449
  playbookCallTurnIds.set(suspendedCall.callId, suspendedCall.turnId);
@@ -1430,9 +1469,9 @@ export const createPlaybookRuntime = (options) => {
1430
1469
  }
1431
1470
  catch (error) {
1432
1471
  let failure = error;
1433
- if (priorExternalPlayerTokens !== undefined) {
1472
+ if (priorExternalRoleTokens !== undefined) {
1434
1473
  try {
1435
- identity.playerSessions.restore(priorExternalPlayerTokens);
1474
+ identity.playerSessions.restore(priorExternalRoleTokens);
1436
1475
  }
1437
1476
  catch (rollbackError) {
1438
1477
  failure = new AggregateError([error, rollbackError], 'DECIDE restore and player continuation rollback failed');
@@ -1671,9 +1710,9 @@ export const createPlaybookRuntime = (options) => {
1671
1710
  collectFailure(failures, error);
1672
1711
  }
1673
1712
  finally {
1674
- playerResumeTokens.clear();
1713
+ privateResumeTokens.clear();
1675
1714
  playbookCallTurnIds.clear();
1676
- inFlightPlayerIds.clear();
1715
+ inFlightPlayerKeys.clear();
1677
1716
  activeBoundaryCalls.clear();
1678
1717
  activeEmissionCalls.clear();
1679
1718
  emissionQueue.clear();
@@ -1699,7 +1738,6 @@ export const createPlaybookRuntime = (options) => {
1699
1738
  };
1700
1739
  export const _internal = {
1701
1740
  composePlayerPrompt,
1702
- resolvePlayerId,
1703
1741
  requiredFieldsFor,
1704
1742
  extractJson,
1705
1743
  buildClassifierPrompt,
@@ -1710,10 +1748,9 @@ export const _internal = {
1710
1748
  pendingQuestionsFromContext,
1711
1749
  normalizeErrorCompact,
1712
1750
  normalizeErrorFull,
1713
- DEFAULT_PLAYER_BINDING,
1714
1751
  STATE_DESCRIPTIONS,
1715
- PLAYER_STATES,
1716
- PLAYER_STATE_IDS,
1752
+ ROLE_STATES,
1753
+ ROLE_STATE_IDS,
1717
1754
  VERBATIM_PAYLOAD_FIELDS,
1718
1755
  BOSS_INTERRUPT_TARGETS,
1719
1756
  CONTINUATION_PREAMBLE,