@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
@@ -98,58 +98,20 @@ export type {
98
98
  PlaybookTraceType,
99
99
  };
100
100
 
101
- type PlayerName = 'Coder' | 'Reviewer';
101
+ type RoleId = 'coder' | 'reviewer';
102
102
 
103
- export interface PlaybookRuntimeOptions extends DecideInput {
104
- playerBinding?: Partial<Record<PlayerName, string>>;
105
- }
106
-
107
- const DEFAULT_PLAYER_BINDING: Readonly<Record<PlayerName, string>> = {
108
- Coder: 'coder',
109
- Reviewer: 'reviewer',
110
- };
103
+ export type PlaybookRuntimeOptions = DecideInput;
111
104
 
112
105
  function snapshotDecideRuntimeOptions(value: unknown): PlaybookRuntimeOptions {
113
106
  const captured = snapshotJsonValue(value, 'DECIDE runtime options');
114
107
  if (!isPlainObject(captured)) {
115
108
  throw new TypeError('DECIDE runtime options must be an object');
116
109
  }
117
- const allowed = new Set(['coderLlm', 'playerBinding']);
118
- for (const key of Object.keys(captured)) {
119
- if (!allowed.has(key)) {
120
- throw new TypeError(`DECIDE runtime options.${key} is not declared`);
121
- }
122
- }
123
- if (
124
- typeof captured.coderLlm !== 'string' ||
125
- captured.coderLlm.trim().length === 0
126
- ) {
127
- throw new TypeError(
128
- 'DECIDE runtime options.coderLlm must be a non-empty string',
129
- );
130
- }
131
- if ('playerBinding' in captured) {
132
- const playerBinding = captured.playerBinding;
133
- if (!isPlainObject(playerBinding)) {
134
- throw new TypeError(
135
- 'DECIDE runtime options.playerBinding must be an object',
136
- );
137
- }
138
- const playerNames = new Set<PlayerName>(['Coder', 'Reviewer']);
139
- for (const [player, playerId] of Object.entries(playerBinding)) {
140
- if (!playerNames.has(player as PlayerName)) {
141
- throw new TypeError(
142
- `DECIDE runtime options.playerBinding.${player} is not declared`,
143
- );
144
- }
145
- if (typeof playerId !== 'string' || playerId.trim().length === 0) {
146
- throw new TypeError(
147
- `DECIDE runtime options.playerBinding.${player} must be a non-empty string`,
148
- );
149
- }
150
- }
110
+ const [unknown] = Object.keys(captured);
111
+ if (unknown !== undefined) {
112
+ throw new TypeError(`DECIDE runtime options.${unknown} is not declared`);
151
113
  }
152
- return captured as unknown as PlaybookRuntimeOptions;
114
+ return Object.freeze({});
153
115
  }
154
116
 
155
117
  const STATE_DESCRIPTIONS: Readonly<Record<string, string>> = {
@@ -167,20 +129,26 @@ const STATE_DESCRIPTIONS: Readonly<Record<string, string>> = {
167
129
  done: 'DECIDE completed with an approved commit.',
168
130
  };
169
131
 
170
- const PLAYER_STATES = [
171
- { stateId: 'askCoderProposal', player: 'Coder', sourceItem: 'DECIDE-1' },
132
+ const ROLE_STATES = [
133
+ { stateId: 'askCoderProposal', role: 'coder', sourceItem: 'DECIDE-1' },
172
134
  {
173
135
  stateId: 'askReviewerProposal',
174
- player: 'Reviewer',
136
+ role: 'reviewer',
175
137
  sourceItem: 'DECIDE-2',
176
138
  },
177
- { stateId: 'commitCoderProposal', player: 'Coder', sourceItem: 'DECIDE-3' },
139
+ { stateId: 'commitCoderProposal', role: 'coder', sourceItem: 'DECIDE-3' },
178
140
  ] as const;
179
141
 
180
- const PLAYER_STATE_IDS: ReadonlySet<string> = new Set(
181
- PLAYER_STATES.map((state) => state.stateId),
142
+ const ROLE_STATE_IDS: ReadonlySet<string> = new Set(
143
+ ROLE_STATES.map((state) => state.stateId),
182
144
  );
183
145
 
146
+ const ROLE_IDS = ['coder', 'reviewer'] as const;
147
+ const ROLE_ID_SET: ReadonlySet<string> = new Set(ROLE_IDS);
148
+
149
+ const roleLabel = (roleId: RoleId): string =>
150
+ roleId === 'coder' ? 'Coder' : 'Reviewer';
151
+
184
152
  const BOSS_INTERRUPT_TARGETS = ['independentProposals'] as const;
185
153
 
186
154
  const BOSS_INTERRUPT_TARGET_IDS: ReadonlySet<string> = new Set(
@@ -194,10 +162,7 @@ const CONTINUATION_PREAMBLE =
194
162
  'You previously paused this task to ask Boss a question; Boss has now replied. Continue the same task using the reply below.';
195
163
 
196
164
  const PLACEHOLDER_FIELDS: ReadonlyArray<readonly [string, keyof PlayerInput]> =
197
- [
198
- ['<caller-topic>', 'callerTopic'],
199
- ['<coder-llm>', 'coderLlm'],
200
- ];
165
+ [['<caller-topic>', 'callerTopic']];
201
166
 
202
167
  const VERBATIM_PAYLOAD_FIELDS: ReadonlySet<string> = new Set([
203
168
  'coderProposal',
@@ -205,7 +170,12 @@ const VERBATIM_PAYLOAD_FIELDS: ReadonlySet<string> = new Set([
205
170
  'coderOutput',
206
171
  ]);
207
172
 
208
- function composePlayerPrompt(input: PlayerInput): string {
173
+ type PromptIdentity = (roleId: RoleId) => string;
174
+
175
+ function composePlayerPrompt(
176
+ input: PlayerInput,
177
+ promptIdentity: PromptIdentity,
178
+ ): string {
209
179
  const blocks: string[] = [];
210
180
 
211
181
  if (input.pendingBossQuestion && input.bossReply !== undefined) {
@@ -227,6 +197,9 @@ function composePlayerPrompt(input: PlayerInput): string {
227
197
  const value = input[field];
228
198
  if (typeof value === 'string') replacements.set(placeholder, value);
229
199
  }
200
+ if (input.prompt.includes('<coder-llm>')) {
201
+ replacements.set('<coder-llm>', promptIdentity('coder'));
202
+ }
230
203
  const body = input.prompt.replace(
231
204
  /<caller-topic>|<coder-llm>/g,
232
205
  (placeholder, offset: number, source: string) => {
@@ -243,22 +216,6 @@ function composePlayerPrompt(input: PlayerInput): string {
243
216
  return blocks.join('\n\n');
244
217
  }
245
218
 
246
- function resolvePlayerId(
247
- input: PlayerInput,
248
- binding: Record<PlayerName, string>,
249
- ): string {
250
- switch (input.player) {
251
- case 'Coder':
252
- return binding.Coder;
253
- case 'Reviewer':
254
- return binding.Reviewer;
255
- default: {
256
- const exhaustive: never = input.player;
257
- throw new Error(`unknown player ${String(exhaustive)}`);
258
- }
259
- }
260
- }
261
-
262
219
  // A `result` description names required payload fields in its
263
220
  // "Output shall include ..." sentence.
264
221
  function requiredFieldsFor(description: string): string[] {
@@ -413,7 +370,7 @@ function buildClassifierPrompt(
413
370
  lines.push('Pending Boss questions:');
414
371
  for (const pending of ctx.pendingQuestions) {
415
372
  lines.push(
416
- `- ${pending.questionId} (${pending.player}): ${pending.question}`,
373
+ `- ${pending.questionId} (${pending.asker.roleId}): ${pending.question}`,
417
374
  );
418
375
  }
419
376
  lines.push(
@@ -513,7 +470,7 @@ function buildAdjudicatorPrompt(
513
470
  'and guard descriptions. Reply with exactly one JSON object and no prose.',
514
471
  );
515
472
  lines.push(
516
- `The player "${input.player}" produced the output below for source item ${input.sourceItem}.`,
473
+ `The role "${roleLabel(input.role)}" produced the output below for source item ${input.sourceItem}.`,
517
474
  );
518
475
  lines.push('Choose exactly one guard whose description matches that output.');
519
476
  lines.push('');
@@ -649,7 +606,9 @@ function pendingQuestionsFromContext(
649
606
  obj.questionId === key &&
650
607
  typeof obj.resumeStateId === 'string' &&
651
608
  typeof obj.sourceItem === 'string' &&
652
- typeof obj.player === 'string' &&
609
+ isPlainObject(obj.asker) &&
610
+ obj.asker.kind === 'role' &&
611
+ ROLE_ID_SET.has(String(obj.asker.roleId)) &&
653
612
  typeof obj.question === 'string'
654
613
  ) {
655
614
  questions.push(obj as unknown as PendingBossQuestion);
@@ -671,7 +630,7 @@ const WAIT_STATE_IDS: ReadonlySet<string> = new Set([
671
630
  ]);
672
631
 
673
632
  const STATUS_STATE_IDS: ReadonlySet<string> = new Set([
674
- ...PLAYER_STATE_IDS,
633
+ ...ROLE_STATE_IDS,
675
634
  ...WAIT_STATE_IDS,
676
635
  'failed',
677
636
  ]);
@@ -749,14 +708,7 @@ function telemetryPayload(
749
708
  export const createPlaybookRuntime: PlaybookRuntimeFactory<
750
709
  PlaybookRuntimeOptions
751
710
  > = (options) => {
752
- const boundOptions = snapshotDecideRuntimeOptions(options);
753
- const binding: Record<PlayerName, string> = {
754
- ...DEFAULT_PLAYER_BINDING,
755
- ...(boundOptions.playerBinding ?? {}),
756
- };
757
- const fsmInput: DecideInput = {
758
- coderLlm: boundOptions.coderLlm,
759
- };
711
+ const fsmInput = snapshotDecideRuntimeOptions(options);
760
712
 
761
713
  type SessionIdentity = Readonly<PlaybookSession>;
762
714
 
@@ -779,9 +731,9 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
779
731
  let disposalPromise: Promise<void> | undefined;
780
732
  let controlPlaneError: unknown;
781
733
  let nestedBridge: NestedPlaybookBridge<PlaybookInput>;
782
- const playerResumeTokens = new Map<string, string>();
734
+ const privateResumeTokens = new Map<string, string>();
783
735
  const playbookCallTurnIds = new Map<string, number | undefined>();
784
- const inFlightPlayerIds = new Set<string>();
736
+ const inFlightPlayerKeys = new Set<string>();
785
737
  const activeBoundaryCalls = new Set<Promise<unknown>>();
786
738
  const activeEmissionCalls = new Set<Promise<void>>();
787
739
  const emissionQueue = new PQueue({ concurrency: 1 });
@@ -869,73 +821,176 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
869
821
  }
870
822
  return sessionIdentity;
871
823
  };
872
- const selectPlayerResume = (playerId: string): string | false => {
824
+
825
+ const bindSession = (nextSession: PlaybookSession): SessionIdentity => {
826
+ const bound = snapshotPlaybookSession(nextSession);
827
+ if (bound.roleBindings === undefined) return bound;
828
+ const actual = Object.keys(bound.roleBindings).sort();
829
+ const expected = [...ROLE_IDS].sort();
830
+ const missing = expected.filter((roleId) => !actual.includes(roleId));
831
+ const extra = actual.filter((roleId) => !ROLE_ID_SET.has(roleId));
832
+ if (missing.length > 0 || extra.length > 0) {
833
+ throw new TypeError(
834
+ `DECIDE session roleBindings must cover exactly [${expected.join(', ')}]` +
835
+ `${missing.length === 0 ? '' : `; missing [${missing.join(', ')}]`}` +
836
+ `${extra.length === 0 ? '' : `; extra [${extra.join(', ')}]`}`,
837
+ );
838
+ }
839
+ return bound;
840
+ };
841
+
842
+ const resolvedPlayerId = (roleId: RoleId): string | undefined =>
843
+ requireSessionIdentity().roleBindings?.[roleId]?.playerId;
844
+
845
+ const promptIdentity = (roleId: RoleId): string =>
846
+ requireSessionIdentity().roleBindings?.[roleId]?.promptIdentity ?? roleId;
847
+
848
+ const composeInvocationPrompt = (input: PlayerInput): string => {
849
+ let active = true;
850
+ const lookup: PromptIdentity = (roleId) => {
851
+ if (!active) {
852
+ throw new Error(
853
+ 'DECIDE prompt identity lookup is no longer active for this invocation',
854
+ );
855
+ }
856
+ if (!ROLE_ID_SET.has(roleId)) {
857
+ throw new TypeError(
858
+ `DECIDE prompt identity lookup rejected undeclared role ${String(roleId)}`,
859
+ );
860
+ }
861
+ return promptIdentity(roleId);
862
+ };
863
+ try {
864
+ return composePlayerPrompt(input, lookup);
865
+ } finally {
866
+ active = false;
867
+ }
868
+ };
869
+
870
+ const continuationKey = (
871
+ roleId: RoleId,
872
+ playerId: string | undefined,
873
+ ): string => playerId ?? roleId;
874
+
875
+ const tokensByContinuationKey = (
876
+ tokens: Readonly<Record<string, string>>,
877
+ ): Map<string, string> => {
878
+ const byKey = new Map<string, string>();
879
+ for (const [roleId, token] of Object.entries(tokens)) {
880
+ if (!ROLE_ID_SET.has(roleId)) {
881
+ throw new TypeError(
882
+ `DECIDE role tokens contain unknown role ${roleId}`,
883
+ );
884
+ }
885
+ const typedRole = roleId as RoleId;
886
+ const key = continuationKey(typedRole, resolvedPlayerId(typedRole));
887
+ const prior = byKey.get(key);
888
+ if (prior !== undefined && prior !== token) {
889
+ throw new TypeError(
890
+ `DECIDE runtime snapshot assigns conflicting tokens to roles bound to player ${key}`,
891
+ );
892
+ }
893
+ byKey.set(key, token);
894
+ }
895
+ const rolesByKey = new Map<string, RoleId[]>();
896
+ for (const roleId of ROLE_IDS) {
897
+ const key = continuationKey(roleId, resolvedPlayerId(roleId));
898
+ rolesByKey.set(key, [...(rolesByKey.get(key) ?? []), roleId]);
899
+ }
900
+ for (const [key, roles] of rolesByKey) {
901
+ if (roles.length < 2) continue;
902
+ const present = roles.filter((roleId) => tokens[roleId] !== undefined);
903
+ if (present.length !== 0 && present.length !== roles.length) {
904
+ throw new TypeError(
905
+ `DECIDE role tokens must project player ${key} through every aliased role [${roles.join(', ')}]`,
906
+ );
907
+ }
908
+ }
909
+ return byKey;
910
+ };
911
+
912
+ const selectPlayerResume = (
913
+ roleId: RoleId,
914
+ playerId: string | undefined,
915
+ ): string | false => {
873
916
  const session = requireSessionIdentity();
874
917
  const selected = session.playerSessions
875
- ? session.playerSessions.select(playerId)
876
- : playerResumeTokens.get(playerId) ?? false;
918
+ ? session.playerSessions.select(roleId)
919
+ : privateResumeTokens.get(continuationKey(roleId, playerId)) ?? false;
877
920
  if (
878
921
  selected !== false &&
879
922
  (typeof selected !== 'string' || selected.trim().length === 0)
880
923
  ) {
881
924
  throw new TypeError(
882
- `player session store returned an invalid resume token for ${playerId}`,
925
+ `player session store returned an invalid resume token for role ${roleId}`,
883
926
  );
884
927
  }
885
928
  return selected;
886
929
  };
887
930
  const updatePlayerResume = (
888
- playerId: string,
889
- resumeToken: string | undefined,
931
+ roleId: RoleId,
932
+ playerId: string | undefined,
933
+ result: PlayerResult,
890
934
  ): void => {
935
+ if (result.resumeToken === undefined && result.status !== 'ok') return;
891
936
  const session = requireSessionIdentity();
892
937
  if (session.playerSessions) {
893
- session.playerSessions.update(playerId, resumeToken);
894
- } else if (resumeToken !== undefined && resumeToken.trim().length > 0) {
895
- playerResumeTokens.set(playerId, resumeToken);
938
+ session.playerSessions.update(roleId, result.resumeToken);
939
+ } else if (result.resumeToken !== undefined) {
940
+ privateResumeTokens.set(
941
+ continuationKey(roleId, playerId),
942
+ result.resumeToken,
943
+ );
896
944
  } else {
897
- playerResumeTokens.delete(playerId);
945
+ privateResumeTokens.delete(continuationKey(roleId, playerId));
898
946
  }
899
947
  };
900
- const snapshotPlayerResumeTokens = (): Record<string, string> => {
948
+ const snapshotRoleResumeTokens = (): Record<string, string> => {
901
949
  const session = requireSessionIdentity();
902
950
  const captured = snapshotJsonValue(
903
951
  session.playerSessions
904
952
  ? session.playerSessions.snapshot()
905
- : Object.fromEntries(playerResumeTokens),
953
+ : Object.fromEntries(
954
+ ROLE_IDS.flatMap((roleId) => {
955
+ const token = privateResumeTokens.get(
956
+ continuationKey(roleId, resolvedPlayerId(roleId)),
957
+ );
958
+ return token === undefined ? [] : [[roleId, token]];
959
+ }),
960
+ ),
906
961
  'player session store snapshot',
907
962
  );
908
963
  if (!isPlainObject(captured)) {
909
964
  throw new TypeError('player session store snapshot must be an object');
910
965
  }
911
966
  const tokens: Record<string, string> = {};
912
- for (const [playerId, token] of Object.entries(captured)) {
913
- if (playerId.trim().length === 0) {
967
+ for (const [roleId, token] of Object.entries(captured)) {
968
+ if (!ROLE_ID_SET.has(roleId)) {
914
969
  throw new TypeError(
915
- 'player session store snapshot player ids must be non-empty',
970
+ `player session store snapshot contains unknown role ${roleId}`,
916
971
  );
917
972
  }
918
973
  if (typeof token !== 'string' || token.trim().length === 0) {
919
974
  throw new TypeError(
920
- `player session store snapshot token for ${playerId} must be a non-empty string`,
975
+ `player session store snapshot token for ${roleId} must be a non-empty string`,
921
976
  );
922
977
  }
923
- tokens[playerId] = token;
978
+ tokens[roleId] = token;
924
979
  }
980
+ tokensByContinuationKey(tokens);
925
981
  return tokens;
926
982
  };
927
- const restorePlayerResumeTokens = (
983
+ const restoreRoleResumeTokens = (
928
984
  tokens: Readonly<Record<string, string>>,
929
985
  ): void => {
986
+ const byKey = tokensByContinuationKey(tokens);
930
987
  const session = requireSessionIdentity();
931
988
  if (session.playerSessions) {
932
989
  session.playerSessions.restore(tokens);
933
990
  return;
934
991
  }
935
- playerResumeTokens.clear();
936
- for (const [playerId, token] of Object.entries(tokens)) {
937
- playerResumeTokens.set(playerId, token);
938
- }
992
+ privateResumeTokens.clear();
993
+ for (const [key, token] of byKey) privateResumeTokens.set(key, token);
939
994
  };
940
995
  const currentState = (
941
996
  pendingCall: PlaybookPendingCall | undefined =
@@ -962,7 +1017,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
962
1017
  const identity = requireSessionIdentity();
963
1018
  const jsonPayload = snapshotJsonValue(payload, `trace ${type} payload`);
964
1019
  const trace: PlaybookTraceEvent = Object.freeze({
965
- schemaVersion: 2,
1020
+ schemaVersion: 3,
966
1021
  sessionId: identity.sessionId,
967
1022
  playbookId: identity.playbookId,
968
1023
  rootSessionId: identity.rootSessionId,
@@ -1118,28 +1173,34 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1118
1173
  const runPlayerCall = async (
1119
1174
  input: PlayerInput,
1120
1175
  signal: AbortSignal,
1121
- ): Promise<{ playerId: string; result: PlayerResult }> => {
1122
- const playerId = resolvePlayerId(input, binding);
1123
- if (inFlightPlayerIds.has(playerId)) {
1124
- throw new Error(
1125
- `resolved player "${playerId}" already has an in-flight call`,
1176
+ ): Promise<{
1177
+ roleId: RoleId;
1178
+ playerId?: string;
1179
+ result: PlayerResult;
1180
+ }> => {
1181
+ if (!ROLE_ID_SET.has(input.role)) {
1182
+ throw new TypeError(
1183
+ `DECIDE player input role must name a declared local role`,
1126
1184
  );
1127
1185
  }
1128
- const prompt = composePlayerPrompt(input);
1186
+ const roleId = input.role;
1187
+ const playerId = resolvedPlayerId(roleId);
1188
+ const playerKey = continuationKey(roleId, playerId);
1189
+ const prompt = composeInvocationPrompt(input);
1129
1190
  let resume: PlayerCallOptions['resume'];
1130
1191
  try {
1131
1192
  signal.throwIfAborted();
1132
- resume = selectPlayerResume(playerId);
1193
+ resume = selectPlayerResume(roleId, playerId);
1133
1194
  } catch (error) {
1134
1195
  latchControlPlaneError(error, signal);
1135
1196
  throw error;
1136
1197
  }
1137
1198
  const callId = `player-${++playerCallSequence}`;
1138
1199
  const identity = {
1139
- purpose: 'captain',
1140
1200
  stateId: input.stateId,
1141
1201
  sourceItem: input.sourceItem,
1142
- playerId,
1202
+ roleId,
1203
+ ...(playerId === undefined ? {} : { playerId }),
1143
1204
  resume,
1144
1205
  };
1145
1206
  const emitFailure = (error: unknown): Promise<void> =>
@@ -1155,7 +1216,21 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1155
1216
  },
1156
1217
  { turnId: currentTurnId, callId },
1157
1218
  );
1158
- inFlightPlayerIds.add(playerId);
1219
+ if (inFlightPlayerKeys.has(playerKey)) {
1220
+ const error = new Error(
1221
+ `resolved player key "${playerKey}" already has an in-flight call`,
1222
+ );
1223
+ await emitCallStarted(
1224
+ 'player.call.started',
1225
+ 'player.call.finished',
1226
+ { ...identity, prompt },
1227
+ { turnId: currentTurnId, callId },
1228
+ signal,
1229
+ );
1230
+ await emitFailure(error);
1231
+ throw error;
1232
+ }
1233
+ inFlightPlayerKeys.add(playerKey);
1159
1234
  try {
1160
1235
  await emitCallStarted(
1161
1236
  'player.call.started',
@@ -1169,7 +1244,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1169
1244
  try {
1170
1245
  signal.throwIfAborted();
1171
1246
  const boundary = Promise.resolve(
1172
- requirePorts().callPlayer(playerId, prompt, signal, { resume }),
1247
+ requirePorts().callPlayer(roleId, prompt, signal, { resume }),
1173
1248
  );
1174
1249
  rawResult = await boundary;
1175
1250
  // An XState sibling cancellation does not cancel an arbitrary coder
@@ -1205,13 +1280,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1205
1280
  // The resolved result is authoritative even on aborted/error status.
1206
1281
  // Update continuation state before interpreting that status.
1207
1282
  try {
1208
- updatePlayerResume(
1209
- playerId,
1210
- typeof result.resumeToken === 'string' &&
1211
- result.resumeToken.trim().length > 0
1212
- ? result.resumeToken
1213
- : undefined,
1214
- );
1283
+ updatePlayerResume(roleId, playerId, result);
1215
1284
  } catch (error) {
1216
1285
  latchControlPlaneError(error, signal);
1217
1286
  try {
@@ -1242,16 +1311,24 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1242
1311
  },
1243
1312
  { turnId: currentTurnId, callId },
1244
1313
  );
1245
- return { playerId, result };
1314
+ return {
1315
+ roleId,
1316
+ ...(playerId === undefined ? {} : { playerId }),
1317
+ result,
1318
+ };
1246
1319
  } finally {
1247
- inFlightPlayerIds.delete(playerId);
1320
+ inFlightPlayerKeys.delete(playerKey);
1248
1321
  }
1249
1322
  };
1250
1323
 
1251
1324
  const callPlayer = (
1252
1325
  input: PlayerInput,
1253
1326
  signal: AbortSignal,
1254
- ): Promise<{ playerId: string; result: PlayerResult }> => {
1327
+ ): Promise<{
1328
+ roleId: RoleId;
1329
+ playerId?: string;
1330
+ result: PlayerResult;
1331
+ }> => {
1255
1332
  return trackBoundaryCall(runPlayerCall(input, signal));
1256
1333
  };
1257
1334
 
@@ -1269,7 +1346,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1269
1346
  }
1270
1347
  combined.throwIfAborted();
1271
1348
 
1272
- let { playerId, result } = await callPlayer(input, combined);
1349
+ let { roleId, playerId, result } = await callPlayer(input, combined);
1273
1350
  if (result.status === 'ok' && isEmptyFinalText(result.finalText)) {
1274
1351
  // DR-028: an `ok` result whose finalText is missing, empty, or
1275
1352
  // whitespace-only earns exactly one corrective re-ask — the same
@@ -1280,11 +1357,13 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1280
1357
  // are never retried), and a rejecting finish emission rejects
1281
1358
  // `callPlayer` itself, so it never reaches this branch (PBRT-47).
1282
1359
  combined.throwIfAborted();
1283
- ({ playerId, result } = await callPlayer(input, combined));
1360
+ ({ roleId, playerId, result } = await callPlayer(input, combined));
1284
1361
  }
1285
1362
  if (result.status !== 'ok') {
1286
1363
  throw new Error(
1287
- `player "${playerId}" returned status "${result.status}"${
1364
+ `${roleLabel(roleId)}${
1365
+ playerId === undefined ? '' : ` (${playerId})`
1366
+ } returned status "${result.status}"${
1288
1367
  result.error ? `: ${result.error}` : ''
1289
1368
  }`,
1290
1369
  );
@@ -1292,7 +1371,9 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1292
1371
  const finalText = result.finalText ?? '';
1293
1372
  if (isEmptyFinalText(finalText)) {
1294
1373
  throw new Error(
1295
- `player "${playerId}" returned status "ok" with no finalText`,
1374
+ `${roleLabel(roleId)}${
1375
+ playerId === undefined ? '' : ` (${playerId})`
1376
+ } returned status "ok" with no finalText`,
1296
1377
  );
1297
1378
  }
1298
1379
  combined.throwIfAborted();
@@ -1443,11 +1524,11 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1443
1524
  );
1444
1525
  if (pending) {
1445
1526
  scheduleStatus(
1446
- `${pending.player} asks: ${pending.question}`,
1527
+ `${pending.asker.roleId} asks: ${pending.question}`,
1447
1528
  activeStateId,
1448
1529
  );
1449
1530
  scheduleStatus(
1450
- `◆ awaiting Boss reply · ${pending.resumeStateId} · ${pending.player} · ${pending.sourceItem}`,
1531
+ `◆ awaiting Boss reply · ${pending.resumeStateId} · ${pending.asker.roleId} · ${pending.sourceItem}`,
1451
1532
  activeStateId,
1452
1533
  );
1453
1534
  }
@@ -1459,13 +1540,13 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1459
1540
  : undefined;
1460
1541
  const description = STATE_DESCRIPTIONS[activeStateId];
1461
1542
  if (description === undefined) continue;
1462
- const playerState = PLAYER_STATES.find(
1543
+ const roleState = ROLE_STATES.find(
1463
1544
  (candidate) => candidate.stateId === activeStateId,
1464
1545
  );
1465
1546
  scheduleStatus(
1466
- playerState === undefined
1547
+ roleState === undefined
1467
1548
  ? '◆ workflow failed; awaiting Boss recovery.'
1468
- : `⤷ ${playerState.player}: ${description}`,
1549
+ : `⤷ ${roleLabel(roleState.role)}: ${description}`,
1469
1550
  activeStateId,
1470
1551
  lastError === undefined ? undefined : { lastError },
1471
1552
  );
@@ -1652,8 +1733,8 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1652
1733
  // The session-start error remains authoritative.
1653
1734
  }
1654
1735
  }
1655
- playerResumeTokens.clear();
1656
- inFlightPlayerIds.clear();
1736
+ privateResumeTokens.clear();
1737
+ inFlightPlayerKeys.clear();
1657
1738
  activeBoundaryCalls.clear();
1658
1739
  activeEmissionCalls.clear();
1659
1740
  emissionQueue.clear();
@@ -1688,7 +1769,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1688
1769
  'decide runtime: init(session) may only be called once',
1689
1770
  );
1690
1771
  }
1691
- const identity = snapshotPlaybookSession(session);
1772
+ const identity = bindSession(session);
1692
1773
  let finishInitialization!: () => void;
1693
1774
  const initialization = new Promise<void>((resolve) => {
1694
1775
  finishInitialization = resolve;
@@ -1773,10 +1854,10 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1773
1854
  actor.getSnapshot() as SnapshotFrom<typeof decideMachine>
1774
1855
  ).context as unknown as Record<string, unknown>;
1775
1856
  return {
1776
- schemaVersion: 2,
1857
+ schemaVersion: 3,
1777
1858
  playbookId: sessionIdentity.playbookId,
1778
1859
  machine,
1779
- playerResumeTokens: snapshotPlayerResumeTokens(),
1860
+ roleResumeTokens: snapshotRoleResumeTokens(),
1780
1861
  sequences: {
1781
1862
  trace: traceSequence,
1782
1863
  turn: turnSequence,
@@ -1788,7 +1869,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1788
1869
  pendingBossQuestions: pendingQuestionsFromContext(context).map(
1789
1870
  (pending) => ({
1790
1871
  questionId: pending.questionId,
1791
- player: pending.player,
1872
+ asker: pending.asker,
1792
1873
  question: pending.question,
1793
1874
  sourceItem: pending.sourceItem,
1794
1875
  }),
@@ -1816,16 +1897,13 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1816
1897
  'decide runtime: restore(session, snapshot) may only be called once',
1817
1898
  );
1818
1899
  }
1819
- const identity = snapshotPlaybookSession(session);
1900
+ const identity = bindSession(session);
1820
1901
  const boundSnapshot = assertPlaybookRuntimeSnapshot(
1821
1902
  snapshot,
1822
1903
  identity.playbookId,
1823
1904
  { allowSuspendedCall: true },
1824
1905
  );
1825
- const suspendedCall =
1826
- boundSnapshot.schemaVersion === 2
1827
- ? boundSnapshot.suspendedCall
1828
- : undefined;
1906
+ const suspendedCall = boundSnapshot.suspendedCall;
1829
1907
  let finishInitialization!: () => void;
1830
1908
  const initialization = new Promise<void>((resolve) => {
1831
1909
  finishInitialization = resolve;
@@ -1834,7 +1912,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1834
1912
  lifecycleStarted = true;
1835
1913
  ports = identity.ports;
1836
1914
  sessionIdentity = identity;
1837
- let priorExternalPlayerTokens:
1915
+ let priorExternalRoleTokens:
1838
1916
  | Readonly<Record<string, string>>
1839
1917
  | undefined;
1840
1918
  try {
@@ -1844,9 +1922,9 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1844
1922
  playerCallSequence = boundSnapshot.sequences.playerCall;
1845
1923
  playbookCallSequence = boundSnapshot.sequences.playbookCall;
1846
1924
  if (identity.playerSessions) {
1847
- priorExternalPlayerTokens = snapshotPlayerResumeTokens();
1925
+ priorExternalRoleTokens = snapshotRoleResumeTokens();
1848
1926
  }
1849
- restorePlayerResumeTokens(boundSnapshot.playerResumeTokens);
1927
+ restoreRoleResumeTokens(boundSnapshot.roleResumeTokens);
1850
1928
  nestedBridge.prepareRestore(suspendedCall);
1851
1929
  if (suspendedCall !== undefined) {
1852
1930
  playbookCallTurnIds.set(suspendedCall.callId, suspendedCall.turnId);
@@ -1877,9 +1955,9 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1877
1955
  nestedBridge.confirmRestore();
1878
1956
  } catch (error) {
1879
1957
  let failure = error;
1880
- if (priorExternalPlayerTokens !== undefined) {
1958
+ if (priorExternalRoleTokens !== undefined) {
1881
1959
  try {
1882
- identity.playerSessions!.restore(priorExternalPlayerTokens);
1960
+ identity.playerSessions!.restore(priorExternalRoleTokens);
1883
1961
  } catch (rollbackError) {
1884
1962
  failure = new AggregateError(
1885
1963
  [error, rollbackError],
@@ -2123,9 +2201,9 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
2123
2201
  } catch (error) {
2124
2202
  collectFailure(failures, error);
2125
2203
  } finally {
2126
- playerResumeTokens.clear();
2204
+ privateResumeTokens.clear();
2127
2205
  playbookCallTurnIds.clear();
2128
- inFlightPlayerIds.clear();
2206
+ inFlightPlayerKeys.clear();
2129
2207
  activeBoundaryCalls.clear();
2130
2208
  activeEmissionCalls.clear();
2131
2209
  emissionQueue.clear();
@@ -2151,7 +2229,6 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
2151
2229
 
2152
2230
  export const _internal = {
2153
2231
  composePlayerPrompt,
2154
- resolvePlayerId,
2155
2232
  requiredFieldsFor,
2156
2233
  extractJson,
2157
2234
  buildClassifierPrompt,
@@ -2162,10 +2239,9 @@ export const _internal = {
2162
2239
  pendingQuestionsFromContext,
2163
2240
  normalizeErrorCompact,
2164
2241
  normalizeErrorFull,
2165
- DEFAULT_PLAYER_BINDING,
2166
2242
  STATE_DESCRIPTIONS,
2167
- PLAYER_STATES,
2168
- PLAYER_STATE_IDS,
2243
+ ROLE_STATES,
2244
+ ROLE_STATE_IDS,
2169
2245
  VERBATIM_PAYLOAD_FIELDS,
2170
2246
  BOSS_INTERRUPT_TARGETS,
2171
2247
  CONTINUATION_PREAMBLE,