@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
@@ -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[] {
@@ -324,6 +281,23 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
324
281
  return value !== null && typeof value === 'object' && !Array.isArray(value);
325
282
  }
326
283
 
284
+ function sortJson(value: JsonValue): JsonValue {
285
+ if (Array.isArray(value)) return value.map((entry) => sortJson(entry));
286
+ if (value !== null && typeof value === 'object') {
287
+ const record = value as { readonly [key: string]: JsonValue };
288
+ const sorted: Record<string, JsonValue> = {};
289
+ for (const key of Object.keys(record).sort()) {
290
+ sorted[key] = sortJson(record[key]);
291
+ }
292
+ return sorted;
293
+ }
294
+ return value;
295
+ }
296
+
297
+ function stableJson(value: unknown, path: string): string {
298
+ return JSON.stringify(sortJson(snapshotJsonValue(value, path)));
299
+ }
300
+
327
301
  function stripCodeFence(text: string): string {
328
302
  const fence = text.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/i);
329
303
  return fence ? fence[1].trim() : text;
@@ -396,7 +370,7 @@ function buildClassifierPrompt(
396
370
  lines.push('Pending Boss questions:');
397
371
  for (const pending of ctx.pendingQuestions) {
398
372
  lines.push(
399
- `- ${pending.questionId} (${pending.player}): ${pending.question}`,
373
+ `- ${pending.questionId} (${pending.asker.roleId}): ${pending.question}`,
400
374
  );
401
375
  }
402
376
  lines.push(
@@ -496,7 +470,7 @@ function buildAdjudicatorPrompt(
496
470
  'and guard descriptions. Reply with exactly one JSON object and no prose.',
497
471
  );
498
472
  lines.push(
499
- `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}.`,
500
474
  );
501
475
  lines.push('Choose exactly one guard whose description matches that output.');
502
476
  lines.push('');
@@ -632,7 +606,9 @@ function pendingQuestionsFromContext(
632
606
  obj.questionId === key &&
633
607
  typeof obj.resumeStateId === 'string' &&
634
608
  typeof obj.sourceItem === 'string' &&
635
- typeof obj.player === 'string' &&
609
+ isPlainObject(obj.asker) &&
610
+ obj.asker.kind === 'role' &&
611
+ ROLE_ID_SET.has(String(obj.asker.roleId)) &&
636
612
  typeof obj.question === 'string'
637
613
  ) {
638
614
  questions.push(obj as unknown as PendingBossQuestion);
@@ -654,7 +630,7 @@ const WAIT_STATE_IDS: ReadonlySet<string> = new Set([
654
630
  ]);
655
631
 
656
632
  const STATUS_STATE_IDS: ReadonlySet<string> = new Set([
657
- ...PLAYER_STATE_IDS,
633
+ ...ROLE_STATE_IDS,
658
634
  ...WAIT_STATE_IDS,
659
635
  'failed',
660
636
  ]);
@@ -732,14 +708,7 @@ function telemetryPayload(
732
708
  export const createPlaybookRuntime: PlaybookRuntimeFactory<
733
709
  PlaybookRuntimeOptions
734
710
  > = (options) => {
735
- const boundOptions = snapshotDecideRuntimeOptions(options);
736
- const binding: Record<PlayerName, string> = {
737
- ...DEFAULT_PLAYER_BINDING,
738
- ...(boundOptions.playerBinding ?? {}),
739
- };
740
- const fsmInput: DecideInput = {
741
- coderLlm: boundOptions.coderLlm,
742
- };
711
+ const fsmInput = snapshotDecideRuntimeOptions(options);
743
712
 
744
713
  type SessionIdentity = Readonly<PlaybookSession>;
745
714
 
@@ -762,9 +731,9 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
762
731
  let disposalPromise: Promise<void> | undefined;
763
732
  let controlPlaneError: unknown;
764
733
  let nestedBridge: NestedPlaybookBridge<PlaybookInput>;
765
- const playerResumeTokens = new Map<string, string>();
734
+ const privateResumeTokens = new Map<string, string>();
766
735
  const playbookCallTurnIds = new Map<string, number | undefined>();
767
- const inFlightPlayerIds = new Set<string>();
736
+ const inFlightPlayerKeys = new Set<string>();
768
737
  const activeBoundaryCalls = new Set<Promise<unknown>>();
769
738
  const activeEmissionCalls = new Set<Promise<void>>();
770
739
  const emissionQueue = new PQueue({ concurrency: 1 });
@@ -852,81 +821,187 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
852
821
  }
853
822
  return sessionIdentity;
854
823
  };
855
- 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 => {
856
916
  const session = requireSessionIdentity();
857
917
  const selected = session.playerSessions
858
- ? session.playerSessions.select(playerId)
859
- : playerResumeTokens.get(playerId) ?? false;
918
+ ? session.playerSessions.select(roleId)
919
+ : privateResumeTokens.get(continuationKey(roleId, playerId)) ?? false;
860
920
  if (
861
921
  selected !== false &&
862
922
  (typeof selected !== 'string' || selected.trim().length === 0)
863
923
  ) {
864
924
  throw new TypeError(
865
- `player session store returned an invalid resume token for ${playerId}`,
925
+ `player session store returned an invalid resume token for role ${roleId}`,
866
926
  );
867
927
  }
868
928
  return selected;
869
929
  };
870
930
  const updatePlayerResume = (
871
- playerId: string,
872
- resumeToken: string | undefined,
931
+ roleId: RoleId,
932
+ playerId: string | undefined,
933
+ result: PlayerResult,
873
934
  ): void => {
935
+ if (result.resumeToken === undefined && result.status !== 'ok') return;
874
936
  const session = requireSessionIdentity();
875
937
  if (session.playerSessions) {
876
- session.playerSessions.update(playerId, resumeToken);
877
- } else if (resumeToken !== undefined && resumeToken.trim().length > 0) {
878
- 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
+ );
879
944
  } else {
880
- playerResumeTokens.delete(playerId);
945
+ privateResumeTokens.delete(continuationKey(roleId, playerId));
881
946
  }
882
947
  };
883
- const snapshotPlayerResumeTokens = (): Record<string, string> => {
948
+ const snapshotRoleResumeTokens = (): Record<string, string> => {
884
949
  const session = requireSessionIdentity();
885
950
  const captured = snapshotJsonValue(
886
951
  session.playerSessions
887
952
  ? session.playerSessions.snapshot()
888
- : 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
+ ),
889
961
  'player session store snapshot',
890
962
  );
891
963
  if (!isPlainObject(captured)) {
892
964
  throw new TypeError('player session store snapshot must be an object');
893
965
  }
894
966
  const tokens: Record<string, string> = {};
895
- for (const [playerId, token] of Object.entries(captured)) {
896
- if (playerId.trim().length === 0) {
967
+ for (const [roleId, token] of Object.entries(captured)) {
968
+ if (!ROLE_ID_SET.has(roleId)) {
897
969
  throw new TypeError(
898
- 'player session store snapshot player ids must be non-empty',
970
+ `player session store snapshot contains unknown role ${roleId}`,
899
971
  );
900
972
  }
901
973
  if (typeof token !== 'string' || token.trim().length === 0) {
902
974
  throw new TypeError(
903
- `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`,
904
976
  );
905
977
  }
906
- tokens[playerId] = token;
978
+ tokens[roleId] = token;
907
979
  }
980
+ tokensByContinuationKey(tokens);
908
981
  return tokens;
909
982
  };
910
- const restorePlayerResumeTokens = (
983
+ const restoreRoleResumeTokens = (
911
984
  tokens: Readonly<Record<string, string>>,
912
985
  ): void => {
986
+ const byKey = tokensByContinuationKey(tokens);
913
987
  const session = requireSessionIdentity();
914
988
  if (session.playerSessions) {
915
989
  session.playerSessions.restore(tokens);
916
990
  return;
917
991
  }
918
- playerResumeTokens.clear();
919
- for (const [playerId, token] of Object.entries(tokens)) {
920
- playerResumeTokens.set(playerId, token);
921
- }
992
+ privateResumeTokens.clear();
993
+ for (const [key, token] of byKey) privateResumeTokens.set(key, token);
922
994
  };
923
- const currentState = (): PlaybookState => {
995
+ const currentState = (
996
+ pendingCall: PlaybookPendingCall | undefined =
997
+ nestedBridge.getPendingCall(),
998
+ ): PlaybookState => {
924
999
  const live = actor;
925
1000
  if (!live) {
926
1001
  throw new Error('decide runtime: actor is not initialized');
927
1002
  }
928
1003
  return normalizePlaybookSnapshot(live.getSnapshot(), {
929
- pendingCall: nestedBridge.getPendingCall(),
1004
+ pendingCall,
930
1005
  });
931
1006
  };
932
1007
  const stateIdentity = (state: PlaybookState): { stateId?: string } => {
@@ -942,7 +1017,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
942
1017
  const identity = requireSessionIdentity();
943
1018
  const jsonPayload = snapshotJsonValue(payload, `trace ${type} payload`);
944
1019
  const trace: PlaybookTraceEvent = Object.freeze({
945
- schemaVersion: 2,
1020
+ schemaVersion: 3,
946
1021
  sessionId: identity.sessionId,
947
1022
  playbookId: identity.playbookId,
948
1023
  rootSessionId: identity.rootSessionId,
@@ -1098,28 +1173,34 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1098
1173
  const runPlayerCall = async (
1099
1174
  input: PlayerInput,
1100
1175
  signal: AbortSignal,
1101
- ): Promise<{ playerId: string; result: PlayerResult }> => {
1102
- const playerId = resolvePlayerId(input, binding);
1103
- if (inFlightPlayerIds.has(playerId)) {
1104
- throw new Error(
1105
- `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`,
1106
1184
  );
1107
1185
  }
1108
- 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);
1109
1190
  let resume: PlayerCallOptions['resume'];
1110
1191
  try {
1111
1192
  signal.throwIfAborted();
1112
- resume = selectPlayerResume(playerId);
1193
+ resume = selectPlayerResume(roleId, playerId);
1113
1194
  } catch (error) {
1114
1195
  latchControlPlaneError(error, signal);
1115
1196
  throw error;
1116
1197
  }
1117
1198
  const callId = `player-${++playerCallSequence}`;
1118
1199
  const identity = {
1119
- purpose: 'captain',
1120
1200
  stateId: input.stateId,
1121
1201
  sourceItem: input.sourceItem,
1122
- playerId,
1202
+ roleId,
1203
+ ...(playerId === undefined ? {} : { playerId }),
1123
1204
  resume,
1124
1205
  };
1125
1206
  const emitFailure = (error: unknown): Promise<void> =>
@@ -1135,7 +1216,21 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1135
1216
  },
1136
1217
  { turnId: currentTurnId, callId },
1137
1218
  );
1138
- 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);
1139
1234
  try {
1140
1235
  await emitCallStarted(
1141
1236
  'player.call.started',
@@ -1149,7 +1244,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1149
1244
  try {
1150
1245
  signal.throwIfAborted();
1151
1246
  const boundary = Promise.resolve(
1152
- requirePorts().callPlayer(playerId, prompt, signal, { resume }),
1247
+ requirePorts().callPlayer(roleId, prompt, signal, { resume }),
1153
1248
  );
1154
1249
  rawResult = await boundary;
1155
1250
  // An XState sibling cancellation does not cancel an arbitrary coder
@@ -1185,13 +1280,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1185
1280
  // The resolved result is authoritative even on aborted/error status.
1186
1281
  // Update continuation state before interpreting that status.
1187
1282
  try {
1188
- updatePlayerResume(
1189
- playerId,
1190
- typeof result.resumeToken === 'string' &&
1191
- result.resumeToken.trim().length > 0
1192
- ? result.resumeToken
1193
- : undefined,
1194
- );
1283
+ updatePlayerResume(roleId, playerId, result);
1195
1284
  } catch (error) {
1196
1285
  latchControlPlaneError(error, signal);
1197
1286
  try {
@@ -1222,16 +1311,24 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1222
1311
  },
1223
1312
  { turnId: currentTurnId, callId },
1224
1313
  );
1225
- return { playerId, result };
1314
+ return {
1315
+ roleId,
1316
+ ...(playerId === undefined ? {} : { playerId }),
1317
+ result,
1318
+ };
1226
1319
  } finally {
1227
- inFlightPlayerIds.delete(playerId);
1320
+ inFlightPlayerKeys.delete(playerKey);
1228
1321
  }
1229
1322
  };
1230
1323
 
1231
1324
  const callPlayer = (
1232
1325
  input: PlayerInput,
1233
1326
  signal: AbortSignal,
1234
- ): Promise<{ playerId: string; result: PlayerResult }> => {
1327
+ ): Promise<{
1328
+ roleId: RoleId;
1329
+ playerId?: string;
1330
+ result: PlayerResult;
1331
+ }> => {
1235
1332
  return trackBoundaryCall(runPlayerCall(input, signal));
1236
1333
  };
1237
1334
 
@@ -1249,7 +1346,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1249
1346
  }
1250
1347
  combined.throwIfAborted();
1251
1348
 
1252
- let { playerId, result } = await callPlayer(input, combined);
1349
+ let { roleId, playerId, result } = await callPlayer(input, combined);
1253
1350
  if (result.status === 'ok' && isEmptyFinalText(result.finalText)) {
1254
1351
  // DR-028: an `ok` result whose finalText is missing, empty, or
1255
1352
  // whitespace-only earns exactly one corrective re-ask — the same
@@ -1260,11 +1357,13 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1260
1357
  // are never retried), and a rejecting finish emission rejects
1261
1358
  // `callPlayer` itself, so it never reaches this branch (PBRT-47).
1262
1359
  combined.throwIfAborted();
1263
- ({ playerId, result } = await callPlayer(input, combined));
1360
+ ({ roleId, playerId, result } = await callPlayer(input, combined));
1264
1361
  }
1265
1362
  if (result.status !== 'ok') {
1266
1363
  throw new Error(
1267
- `player "${playerId}" returned status "${result.status}"${
1364
+ `${roleLabel(roleId)}${
1365
+ playerId === undefined ? '' : ` (${playerId})`
1366
+ } returned status "${result.status}"${
1268
1367
  result.error ? `: ${result.error}` : ''
1269
1368
  }`,
1270
1369
  );
@@ -1272,7 +1371,9 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1272
1371
  const finalText = result.finalText ?? '';
1273
1372
  if (isEmptyFinalText(finalText)) {
1274
1373
  throw new Error(
1275
- `player "${playerId}" returned status "ok" with no finalText`,
1374
+ `${roleLabel(roleId)}${
1375
+ playerId === undefined ? '' : ` (${playerId})`
1376
+ } returned status "ok" with no finalText`,
1276
1377
  );
1277
1378
  }
1278
1379
  combined.throwIfAborted();
@@ -1423,11 +1524,11 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1423
1524
  );
1424
1525
  if (pending) {
1425
1526
  scheduleStatus(
1426
- `${pending.player} asks: ${pending.question}`,
1527
+ `${pending.asker.roleId} asks: ${pending.question}`,
1427
1528
  activeStateId,
1428
1529
  );
1429
1530
  scheduleStatus(
1430
- `◆ awaiting Boss reply · ${pending.resumeStateId} · ${pending.player} · ${pending.sourceItem}`,
1531
+ `◆ awaiting Boss reply · ${pending.resumeStateId} · ${pending.asker.roleId} · ${pending.sourceItem}`,
1431
1532
  activeStateId,
1432
1533
  );
1433
1534
  }
@@ -1439,13 +1540,13 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1439
1540
  : undefined;
1440
1541
  const description = STATE_DESCRIPTIONS[activeStateId];
1441
1542
  if (description === undefined) continue;
1442
- const playerState = PLAYER_STATES.find(
1543
+ const roleState = ROLE_STATES.find(
1443
1544
  (candidate) => candidate.stateId === activeStateId,
1444
1545
  );
1445
1546
  scheduleStatus(
1446
- playerState === undefined
1547
+ roleState === undefined
1447
1548
  ? '◆ workflow failed; awaiting Boss recovery.'
1448
- : `⤷ ${playerState.player}: ${description}`,
1549
+ : `⤷ ${roleLabel(roleState.role)}: ${description}`,
1449
1550
  activeStateId,
1450
1551
  lastError === undefined ? undefined : { lastError },
1451
1552
  );
@@ -1592,9 +1693,10 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1592
1693
  // The caller rethrows its original failure. A restore failure skips
1593
1694
  // the disposal trace — the parked session was never re-bound in this
1594
1695
  // process, so its persisted snapshot stays authoritative (DR-014 §2).
1595
- const cleanupFailedStart = async (options: {
1596
- emitDisposal: boolean;
1597
- }): Promise<void> => {
1696
+ const cleanupFailedStart = async (
1697
+ cause: unknown,
1698
+ options: { emitDisposal: boolean },
1699
+ ): Promise<void> => {
1598
1700
  let finalState: PlaybookState | undefined;
1599
1701
  if (options.emitDisposal && actor) {
1600
1702
  try {
@@ -1608,6 +1710,11 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1608
1710
  } catch {
1609
1711
  // Preserve the original startup failure.
1610
1712
  }
1713
+ try {
1714
+ await nestedBridge.abortPending(cause);
1715
+ } catch {
1716
+ // Preserve the original startup failure.
1717
+ }
1611
1718
  try {
1612
1719
  await judgeQueue.onIdle();
1613
1720
  await drainBoundaryCallsAndEmissions();
@@ -1626,8 +1733,8 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1626
1733
  // The session-start error remains authoritative.
1627
1734
  }
1628
1735
  }
1629
- playerResumeTokens.clear();
1630
- inFlightPlayerIds.clear();
1736
+ privateResumeTokens.clear();
1737
+ inFlightPlayerKeys.clear();
1631
1738
  activeBoundaryCalls.clear();
1632
1739
  activeEmissionCalls.clear();
1633
1740
  emissionQueue.clear();
@@ -1662,7 +1769,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1662
1769
  'decide runtime: init(session) may only be called once',
1663
1770
  );
1664
1771
  }
1665
- const identity = snapshotPlaybookSession(session);
1772
+ const identity = bindSession(session);
1666
1773
  let finishInitialization!: () => void;
1667
1774
  const initialization = new Promise<void>((resolve) => {
1668
1775
  finishInitialization = resolve;
@@ -1682,7 +1789,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1682
1789
  actor?.start();
1683
1790
  await flush();
1684
1791
  } catch (error) {
1685
- await cleanupFailedStart({ emitDisposal: true });
1792
+ await cleanupFailedStart(error, { emitDisposal: true });
1686
1793
  throw error;
1687
1794
  } finally {
1688
1795
  finishInitialization();
@@ -1690,10 +1797,11 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1690
1797
  }
1691
1798
  },
1692
1799
 
1693
- // DR-014 §1 / PBRT-45: JSON-safe capture of a parked session.
1800
+ // DR-014 §1 / DR-031 §5 / PBRT-45: JSON-safe capture of a parked
1801
+ // session, including one already-started suspended REVIEW call.
1694
1802
  // Defined only at a safe capture point — initialized, not disposing
1695
- // or disposed, no active public boundary, and the actor quiescent
1696
- // with status `active` and no pending nested REVIEW call.
1803
+ // or disposed, no active public boundary, and the actor quiescent with
1804
+ // status `active`.
1697
1805
  exportSnapshot(): PlaybookRuntimeSnapshot | undefined {
1698
1806
  if (
1699
1807
  !actor ||
@@ -1706,7 +1814,37 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1706
1814
  if (currentTurnId !== undefined || currentSignal !== undefined) {
1707
1815
  return undefined;
1708
1816
  }
1709
- if (nestedBridge.getPendingCall()) return undefined;
1817
+ const pendingCall = nestedBridge.getPendingCall();
1818
+ const bridgeSuspendedCall = nestedBridge.getSuspendedCall();
1819
+ if ((pendingCall === undefined) !== (bridgeSuspendedCall === undefined)) {
1820
+ return undefined;
1821
+ }
1822
+ if (
1823
+ pendingCall !== undefined &&
1824
+ bridgeSuspendedCall !== undefined &&
1825
+ (pendingCall.callId !== bridgeSuspendedCall.callId ||
1826
+ pendingCall.playbookId !== bridgeSuspendedCall.playbookId ||
1827
+ pendingCall.childSessionId !== bridgeSuspendedCall.childSessionId)
1828
+ ) {
1829
+ return undefined;
1830
+ }
1831
+ let suspendedCall: typeof bridgeSuspendedCall;
1832
+ if (bridgeSuspendedCall !== undefined) {
1833
+ if (!playbookCallTurnIds.has(bridgeSuspendedCall.callId)) {
1834
+ return undefined;
1835
+ }
1836
+ const turnId = playbookCallTurnIds.get(bridgeSuspendedCall.callId);
1837
+ if (
1838
+ bridgeSuspendedCall.turnId !== undefined &&
1839
+ bridgeSuspendedCall.turnId !== turnId
1840
+ ) {
1841
+ return undefined;
1842
+ }
1843
+ suspendedCall = {
1844
+ ...bridgeSuspendedCall,
1845
+ ...(turnId === undefined ? {} : { turnId }),
1846
+ };
1847
+ }
1710
1848
  const state = currentState();
1711
1849
  if (state.status !== 'active' || !state.quiescent) return undefined;
1712
1850
  const machine = detachPersistedMachineSnapshot(
@@ -1716,10 +1854,10 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1716
1854
  actor.getSnapshot() as SnapshotFrom<typeof decideMachine>
1717
1855
  ).context as unknown as Record<string, unknown>;
1718
1856
  return {
1719
- schemaVersion: 1,
1857
+ schemaVersion: 3,
1720
1858
  playbookId: sessionIdentity.playbookId,
1721
1859
  machine,
1722
- playerResumeTokens: snapshotPlayerResumeTokens(),
1860
+ roleResumeTokens: snapshotRoleResumeTokens(),
1723
1861
  sequences: {
1724
1862
  trace: traceSequence,
1725
1863
  turn: turnSequence,
@@ -1731,11 +1869,12 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1731
1869
  pendingBossQuestions: pendingQuestionsFromContext(context).map(
1732
1870
  (pending) => ({
1733
1871
  questionId: pending.questionId,
1734
- player: pending.player,
1872
+ asker: pending.asker,
1735
1873
  question: pending.question,
1736
1874
  sourceItem: pending.sourceItem,
1737
1875
  }),
1738
1876
  ),
1877
+ ...(suspendedCall === undefined ? {} : { suspendedCall }),
1739
1878
  };
1740
1879
  },
1741
1880
 
@@ -1758,11 +1897,13 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1758
1897
  'decide runtime: restore(session, snapshot) may only be called once',
1759
1898
  );
1760
1899
  }
1761
- const identity = snapshotPlaybookSession(session);
1900
+ const identity = bindSession(session);
1762
1901
  const boundSnapshot = assertPlaybookRuntimeSnapshot(
1763
1902
  snapshot,
1764
1903
  identity.playbookId,
1904
+ { allowSuspendedCall: true },
1765
1905
  );
1906
+ const suspendedCall = boundSnapshot.suspendedCall;
1766
1907
  let finishInitialization!: () => void;
1767
1908
  const initialization = new Promise<void>((resolve) => {
1768
1909
  finishInitialization = resolve;
@@ -1771,7 +1912,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1771
1912
  lifecycleStarted = true;
1772
1913
  ports = identity.ports;
1773
1914
  sessionIdentity = identity;
1774
- let priorExternalPlayerTokens:
1915
+ let priorExternalRoleTokens:
1775
1916
  | Readonly<Record<string, string>>
1776
1917
  | undefined;
1777
1918
  try {
@@ -1781,26 +1922,42 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1781
1922
  playerCallSequence = boundSnapshot.sequences.playerCall;
1782
1923
  playbookCallSequence = boundSnapshot.sequences.playbookCall;
1783
1924
  if (identity.playerSessions) {
1784
- priorExternalPlayerTokens = snapshotPlayerResumeTokens();
1925
+ priorExternalRoleTokens = snapshotRoleResumeTokens();
1926
+ }
1927
+ restoreRoleResumeTokens(boundSnapshot.roleResumeTokens);
1928
+ nestedBridge.prepareRestore(suspendedCall);
1929
+ if (suspendedCall !== undefined) {
1930
+ playbookCallTurnIds.set(suspendedCall.callId, suspendedCall.turnId);
1785
1931
  }
1786
- restorePlayerResumeTokens(boundSnapshot.playerResumeTokens);
1787
1932
  suppressInspectionEmissions = true;
1788
1933
  createRuntimeActor(boundSnapshot.machine);
1789
1934
  actor?.start();
1790
- const restoredState = currentState();
1935
+ const restoredState = currentState(suspendedCall);
1791
1936
  if (restoredState.status !== 'active') {
1792
1937
  throw new Error(
1793
1938
  `decide runtime: restored actor status is ${restoredState.status}, expected active`,
1794
1939
  );
1795
1940
  }
1796
- suppressInspectionEmissions = false;
1941
+ if (
1942
+ stableJson(restoredState, 'restored runtime state') !==
1943
+ stableJson(boundSnapshot.state, 'runtime snapshot state')
1944
+ ) {
1945
+ throw new Error(
1946
+ 'decide runtime: restored actor state does not match snapshot state',
1947
+ );
1948
+ }
1797
1949
  previousState = restoredState;
1798
1950
  await flush();
1951
+ suppressInspectionEmissions = false;
1952
+ // Final fallible step: after this publication the authoritative
1953
+ // child has rejoined ordinary resume/abort ownership, so no later
1954
+ // restore validation may trigger failed-start rollback.
1955
+ nestedBridge.confirmRestore();
1799
1956
  } catch (error) {
1800
1957
  let failure = error;
1801
- if (priorExternalPlayerTokens !== undefined) {
1958
+ if (priorExternalRoleTokens !== undefined) {
1802
1959
  try {
1803
- identity.playerSessions!.restore(priorExternalPlayerTokens);
1960
+ identity.playerSessions!.restore(priorExternalRoleTokens);
1804
1961
  } catch (rollbackError) {
1805
1962
  failure = new AggregateError(
1806
1963
  [error, rollbackError],
@@ -1808,7 +1965,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1808
1965
  );
1809
1966
  }
1810
1967
  }
1811
- await cleanupFailedStart({ emitDisposal: false });
1968
+ await cleanupFailedStart(failure, { emitDisposal: false });
1812
1969
  throw failure;
1813
1970
  } finally {
1814
1971
  finishInitialization();
@@ -2044,9 +2201,9 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
2044
2201
  } catch (error) {
2045
2202
  collectFailure(failures, error);
2046
2203
  } finally {
2047
- playerResumeTokens.clear();
2204
+ privateResumeTokens.clear();
2048
2205
  playbookCallTurnIds.clear();
2049
- inFlightPlayerIds.clear();
2206
+ inFlightPlayerKeys.clear();
2050
2207
  activeBoundaryCalls.clear();
2051
2208
  activeEmissionCalls.clear();
2052
2209
  emissionQueue.clear();
@@ -2072,7 +2229,6 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
2072
2229
 
2073
2230
  export const _internal = {
2074
2231
  composePlayerPrompt,
2075
- resolvePlayerId,
2076
2232
  requiredFieldsFor,
2077
2233
  extractJson,
2078
2234
  buildClassifierPrompt,
@@ -2083,10 +2239,9 @@ export const _internal = {
2083
2239
  pendingQuestionsFromContext,
2084
2240
  normalizeErrorCompact,
2085
2241
  normalizeErrorFull,
2086
- DEFAULT_PLAYER_BINDING,
2087
2242
  STATE_DESCRIPTIONS,
2088
- PLAYER_STATES,
2089
- PLAYER_STATE_IDS,
2243
+ ROLE_STATES,
2244
+ ROLE_STATE_IDS,
2090
2245
  VERBATIM_PAYLOAD_FIELDS,
2091
2246
  BOSS_INTERRUPT_TARGETS,
2092
2247
  CONTINUATION_PREAMBLE,