@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
@@ -78,15 +78,15 @@ function isEmptyOkRetryFailure(error) {
78
78
  export const RUNTIME_ABI = 1;
79
79
  /** The linked-artifact schema versions this engine accepts (DR-022). */
80
80
  export const SUPPORTED_ARTIFACT_SCHEMAS = Object.freeze([
81
- 1,
81
+ 2,
82
82
  ]);
83
83
  // PBRT-50: validate a declaration against the loaded engine, schema first,
84
- // so one clear diagnostic covers a fully skewed artifact. Absent means a
85
- // legacy artifact emitted before the DR-022 contract; those must keep
86
- // loading unchanged (DR-019 §4), so there is nothing to check.
84
+ // so one clear diagnostic covers a fully skewed artifact. Declaration-free
85
+ // artifacts are schema 1 and cannot be interpreted as local-role artifacts.
87
86
  function assertRuntimeCompat(compat, label) {
88
- if (compat === undefined)
89
- return;
87
+ if (compat === undefined) {
88
+ throw new TypeError(`${label} spec.compat is required for local-role artifacts`);
89
+ }
90
90
  if (compat === null || typeof compat !== 'object') {
91
91
  throw new TypeError(`${label} spec.compat must be an object`);
92
92
  }
@@ -249,15 +249,31 @@ export function pendingBossQuestionFromContext(context) {
249
249
  if (typeof candidate.questionId !== 'string' ||
250
250
  typeof candidate.resumeStateId !== 'string' ||
251
251
  typeof candidate.sourceItem !== 'string' ||
252
- typeof candidate.player !== 'string' ||
252
+ !isPlainObject(candidate.asker) ||
253
253
  typeof candidate.question !== 'string') {
254
254
  return undefined;
255
255
  }
256
+ let asker;
257
+ if (candidate.asker.kind === 'captain') {
258
+ if (Object.keys(candidate.asker).some((key) => key !== 'kind')) {
259
+ return undefined;
260
+ }
261
+ asker = { kind: 'captain' };
262
+ }
263
+ else if (candidate.asker.kind === 'role' &&
264
+ typeof candidate.asker.roleId === 'string' &&
265
+ candidate.asker.roleId.trim().length > 0 &&
266
+ Object.keys(candidate.asker).every((key) => key === 'kind' || key === 'roleId')) {
267
+ asker = { kind: 'role', roleId: candidate.asker.roleId };
268
+ }
269
+ else {
270
+ return undefined;
271
+ }
256
272
  return {
257
273
  questionId: candidate.questionId,
258
274
  resumeStateId: candidate.resumeStateId,
259
275
  sourceItem: candidate.sourceItem,
260
- player: candidate.player,
276
+ asker,
261
277
  question: candidate.question,
262
278
  };
263
279
  }
@@ -339,10 +355,6 @@ export function defaultComposeCaptainPrompt(input, placeholderFields = {}) {
339
355
  blocks.push(body);
340
356
  return blocks.join('\n\n');
341
357
  }
342
- /** Default player binding: each player to its lowercased name. */
343
- export function defaultResolvePlayerId(input) {
344
- return input.player.toLowerCase();
345
- }
346
358
  /**
347
359
  * Default required-field extraction (slc/link.md §Captain adjudication).
348
360
  * Limited to the description's `Output shall include` / `输出应包含` clause;
@@ -375,7 +387,7 @@ export function defaultBuildJudgePrompt(input, finalText) {
375
387
  'seek external evidence. Decide only from the supplied player output ' +
376
388
  'and outcome descriptions. Reply with exactly one JSON object and no prose.');
377
389
  lines.push('');
378
- lines.push(`The ${input.player} just produced this output:`);
390
+ lines.push(`The ${input.role} role just produced this output:`);
379
391
  lines.push('');
380
392
  lines.push('```');
381
393
  lines.push(finalText);
@@ -447,11 +459,19 @@ function validateBossReplyOutput(input, output, resumableStateIds) {
447
459
  export function createPlayerBridge(spec, ports, getActiveSignal, boundary, onControlPlaneError) {
448
460
  return fromPromise(async ({ input, signal }) => {
449
461
  const activeSignal = combineAbortSignals(signal, getActiveSignal?.());
450
- const playerId = spec.resolvePlayerId(input);
451
- const prompt = spec.composePlayerPrompt(input);
462
+ let roleId;
463
+ let prompt;
464
+ try {
465
+ roleId = spec.resolveRoleId(input);
466
+ prompt = spec.composePlayerPrompt(input);
467
+ }
468
+ catch (error) {
469
+ onControlPlaneError?.(error);
470
+ throw error;
471
+ }
452
472
  const callPlayer = (resume) => boundary
453
- ? boundary.callPlayer(input, playerId, prompt, activeSignal)
454
- : ports.callPlayer(playerId, prompt, activeSignal, { resume });
473
+ ? boundary.callPlayer(input, roleId, prompt, activeSignal)
474
+ : ports.callPlayer(roleId, prompt, activeSignal, { resume });
455
475
  let result = await callPlayer(false);
456
476
  if (result.status === 'ok' && isEmptyFinalText(result.finalText)) {
457
477
  // An abort that lands between the empty first result and the
@@ -589,8 +609,8 @@ function collectInvokeSources(machine) {
589
609
  visit(machine.config);
590
610
  return sources;
591
611
  }
592
- function collectPlayerStatePlayers(machine) {
593
- const players = new Map();
612
+ function collectPlayerStateRoles(machine) {
613
+ const roles = new Map();
594
614
  const visit = (stateDef, stateKey) => {
595
615
  if (!isPlainObject(stateDef))
596
616
  return;
@@ -609,13 +629,13 @@ function collectPlayerStatePlayers(machine) {
609
629
  if (stateId.trim().length === 0) {
610
630
  throw new TypeError('player state metadata must use a non-empty state id');
611
631
  }
612
- const player = isPlainObject(playbookMeta)
613
- ? playbookMeta.player
632
+ const role = isPlainObject(playbookMeta)
633
+ ? playbookMeta.role
614
634
  : undefined;
615
- if (typeof player !== 'string' || player.trim().length === 0) {
616
- throw new TypeError(`player state ${stateId} meta.playbook.player must be a non-empty string`);
635
+ if (typeof role !== 'string' || role.trim().length === 0) {
636
+ throw new TypeError(`player state ${stateId} meta.playbook.role must be a non-empty string`);
617
637
  }
618
- players.set(stateId, player);
638
+ roles.set(stateId, role);
619
639
  }
620
640
  if (isPlainObject(stateDef.states)) {
621
641
  for (const [childKey, child] of Object.entries(stateDef.states)) {
@@ -629,7 +649,7 @@ function collectPlayerStatePlayers(machine) {
629
649
  visit(stateDef, stateKey);
630
650
  }
631
651
  }
632
- return players;
652
+ return roles;
633
653
  }
634
654
  function transitionTargets(transition) {
635
655
  const arms = Array.isArray(transition) ? transition : [transition];
@@ -774,40 +794,48 @@ function makeDefaultNormalizeTransitionEvent(transitionEventFields) {
774
794
  return snapshotJsonValue(out, 'FSM event');
775
795
  };
776
796
  }
777
- function snapshotPlayerStateStatuses(value, label, machine, stateDescriptions) {
778
- if (value === undefined)
779
- return new Map();
780
- if (!isPlainObject(value)) {
781
- throw new TypeError(`${label} playerStates must be an object`);
797
+ function snapshotRoleStateStatuses(value, label, machine, stateDescriptions) {
798
+ if (value === undefined) {
799
+ throw new TypeError(`${label} roleStates must be supplied for schema 2`);
800
+ }
801
+ const captured = snapshotJsonValue(value, `${label} roleStates`);
802
+ if (!isPlainObject(captured)) {
803
+ throw new TypeError(`${label} roleStates must be an object`);
782
804
  }
783
- const declared = collectPlayerStatePlayers(machine);
805
+ const declared = collectPlayerStateRoles(machine);
784
806
  const statuses = new Map();
785
- for (const [stateId, candidate] of Object.entries(value)) {
807
+ for (const [stateId, candidate] of Object.entries(captured)) {
786
808
  if (!declared.has(stateId)) {
787
- throw new TypeError(`${label} playerStates.${stateId} does not name a player state`);
809
+ throw new TypeError(`${label} roleStates.${stateId} does not name a player state`);
810
+ }
811
+ if (isPlainObject(candidate)) {
812
+ const extra = Object.keys(candidate).find((key) => key !== 'role' && key !== 'label');
813
+ if (extra !== undefined) {
814
+ throw new TypeError(`${label} roleStates.${stateId}.${extra} is not allowed`);
815
+ }
788
816
  }
789
817
  if (!isPlainObject(candidate) ||
790
- typeof candidate.player !== 'string' ||
791
- candidate.player.trim().length === 0 ||
818
+ typeof candidate.role !== 'string' ||
819
+ candidate.role.trim().length === 0 ||
792
820
  typeof candidate.label !== 'string' ||
793
821
  candidate.label.trim().length === 0) {
794
- throw new TypeError(`${label} playerStates.${stateId} must carry non-empty player and label strings`);
822
+ throw new TypeError(`${label} roleStates.${stateId} must carry non-empty role and label strings`);
795
823
  }
796
824
  const expectedLabel = stateDescriptions.get(stateId);
797
825
  if (candidate.label !== expectedLabel) {
798
- throw new TypeError(`${label} playerStates.${stateId}.label must equal its FSM description`);
826
+ throw new TypeError(`${label} roleStates.${stateId}.label must equal its FSM description`);
799
827
  }
800
- if (candidate.player !== declared.get(stateId)) {
801
- throw new TypeError(`${label} playerStates.${stateId}.player must equal its FSM player`);
828
+ if (candidate.role !== declared.get(stateId)) {
829
+ throw new TypeError(`${label} roleStates.${stateId}.role must equal its FSM role`);
802
830
  }
803
831
  statuses.set(stateId, {
804
- player: candidate.player,
832
+ role: candidate.role,
805
833
  label: candidate.label,
806
834
  });
807
835
  }
808
836
  for (const stateId of declared.keys()) {
809
837
  if (!statuses.has(stateId)) {
810
- throw new TypeError(`${label} playerStates must declare player state ${stateId}`);
838
+ throw new TypeError(`${label} roleStates must declare player state ${stateId}`);
811
839
  }
812
840
  }
813
841
  return statuses;
@@ -820,34 +848,10 @@ function settlingGuard(event) {
820
848
  ? guard
821
849
  : undefined;
822
850
  }
823
- function legacyStatusesForState(state, context) {
824
- const stateId = state.stateId;
825
- if (stateId === undefined || SUPPRESSED_ENTRY_STATES.has(stateId))
826
- return [];
827
- if (stateId === 'awaitBossReply') {
828
- const pending = pendingBossQuestionFromContext(context);
829
- return [
830
- {
831
- message: pending === undefined
832
- ? 'Awaiting Boss reply.'
833
- : `${pending.player} asks: ${pending.question}`,
834
- },
835
- ];
836
- }
837
- if (stateId === 'failed') {
838
- const lastError = normalizeErrorFull(context.lastError);
839
- return [
840
- {
841
- message: 'Workflow failed; awaiting Boss recovery.',
842
- ...(lastError === undefined
843
- ? {}
844
- : { data: snapshotJsonValue({ lastError }, 'failed status data') }),
845
- },
846
- ];
847
- }
848
- return [{ message: `Entered ${stateId}.` }];
851
+ function askerLabel(asker) {
852
+ return asker.kind === 'captain' ? 'Captain' : asker.roleId;
849
853
  }
850
- function makeDefaultStatusesForState(playerStates) {
854
+ function makeDefaultStatusesForState(roleStates) {
851
855
  return (state, context, event) => {
852
856
  const statuses = [];
853
857
  const guard = settlingGuard(event);
@@ -864,10 +868,10 @@ function makeDefaultStatusesForState(playerStates) {
864
868
  }
865
869
  return [
866
870
  ...statuses,
867
- { message: `${pending.player} asks: ${pending.question}` },
871
+ { message: `${askerLabel(pending.asker)} asks: ${pending.question}` },
868
872
  {
869
873
  message: `◆ awaiting Boss reply · ${pending.resumeStateId} · ` +
870
- `${pending.player} · ${pending.sourceItem}`,
874
+ `${askerLabel(pending.asker)} · ${pending.sourceItem}`,
871
875
  },
872
876
  ];
873
877
  }
@@ -885,10 +889,10 @@ function makeDefaultStatusesForState(playerStates) {
885
889
  },
886
890
  ];
887
891
  }
888
- const playerState = playerStates.get(stateId);
889
- if (playerState !== undefined) {
892
+ const roleState = roleStates.get(stateId);
893
+ if (roleState !== undefined) {
890
894
  statuses.push({
891
- message: `⤷ ${playerState.player}: ${playerState.label}`,
895
+ message: `⤷ ${roleState.role}: ${roleState.label}`,
892
896
  });
893
897
  }
894
898
  return statuses;
@@ -1071,7 +1075,7 @@ function makeDefaultClassifyBossText(machine, entryEvent, bossEvents) {
1071
1075
  `Current state: ${currentState}`,
1072
1076
  ];
1073
1077
  if (pending !== undefined) {
1074
- lines.push(`Pending question id: ${pending.questionId}`, `Pending asking player: ${pending.player}`, `Pending Boss question: ${pending.question}`);
1078
+ lines.push(`Pending question id: ${pending.questionId}`, `Pending asker: ${askerLabel(pending.asker)}`, `Pending Boss question: ${pending.question}`);
1075
1079
  }
1076
1080
  lines.push('', 'Allowed JSON objects:', '- { "type": "NO_ACTION" }');
1077
1081
  for (const contract of applicable) {
@@ -1191,6 +1195,13 @@ export function createXStatePlaybookRuntime(machine, spec) {
1191
1195
  // DR-022 / PBRT-50: reject an incompatible artifact declaration before any
1192
1196
  // machine interpretation, against this loaded engine's own self-report.
1193
1197
  assertRuntimeCompat(spec.compat, label);
1198
+ const specDescriptors = Object.getOwnPropertyDescriptors(spec);
1199
+ if (Object.prototype.hasOwnProperty.call(specDescriptors, 'playerStates')) {
1200
+ throw new TypeError(`${label} schema-2 artifacts must supply roleStates, not playerStates`);
1201
+ }
1202
+ if (Object.prototype.hasOwnProperty.call(specDescriptors, 'resolvePlayerId')) {
1203
+ throw new TypeError(`${label} schema-2 artifacts must not derive concrete player bindings`);
1204
+ }
1194
1205
  if (machineDeclaresParallelState(machine)) {
1195
1206
  throw new Error(`${label} uses a parallel state; the shared runtime supports only single-region FSMs`);
1196
1207
  }
@@ -1199,8 +1210,15 @@ export function createXStatePlaybookRuntime(machine, spec) {
1199
1210
  // DR-029: source state descriptions label the control actions the
1200
1211
  // runtime advertises through `describe()`.
1201
1212
  const stateDescriptions = stateDescriptionsFromMachine(machine);
1202
- const hasCanonicalStatusProfile = spec.playerStates !== undefined;
1203
- const playerStates = snapshotPlayerStateStatuses(spec.playerStates, label, machine, stateDescriptions);
1213
+ const roleStatesDescriptor = specDescriptors.roleStates;
1214
+ if (roleStatesDescriptor !== undefined &&
1215
+ !Object.prototype.hasOwnProperty.call(roleStatesDescriptor, 'value')) {
1216
+ throw new TypeError(`${label} roleStates must be an own data property`);
1217
+ }
1218
+ const roleStates = snapshotRoleStateStatuses(roleStatesDescriptor?.value, label, machine, stateDescriptions);
1219
+ const declaredRoleIds = Object.freeze([
1220
+ ...new Set([...roleStates.values()].map(({ role }) => role)),
1221
+ ]);
1204
1222
  // PBRT-52: the artifact's own ControlView context projection. Nothing is
1205
1223
  // exported by default, so an FSM context member — including one added
1206
1224
  // after this artifact was linked — is private until named here. The two
@@ -1215,7 +1233,6 @@ export function createXStatePlaybookRuntime(machine, spec) {
1215
1233
  throw new Error(`${label} controlContextFields must not name ${field}: the control view surfaces it first-class`);
1216
1234
  }
1217
1235
  }
1218
- const resolvePlayerIdSpec = spec.resolvePlayerId;
1219
1236
  const composePlayerPrompt = spec.composePlayerPrompt ??
1220
1237
  ((input) => defaultComposePlayerPrompt(input, spec.placeholderFields));
1221
1238
  const composeCaptainPrompt = spec.composeCaptainPrompt ??
@@ -1241,13 +1258,9 @@ export function createXStatePlaybookRuntime(machine, spec) {
1241
1258
  const normalizeTransitionEvent = spec.normalizeTransitionEvent ??
1242
1259
  makeDefaultNormalizeTransitionEvent(spec.transitionEventFields ?? []);
1243
1260
  const statusesForState = spec.statusesForState ??
1244
- (hasCanonicalStatusProfile
1245
- ? makeDefaultStatusesForState(playerStates)
1246
- : legacyStatusesForState);
1261
+ makeDefaultStatusesForState(roleStates);
1247
1262
  const classificationStatus = spec.classificationStatus ??
1248
- (hasCanonicalStatusProfile
1249
- ? (event) => event.type
1250
- : () => undefined);
1263
+ ((event) => event.type);
1251
1264
  const machineInput = spec.machineInput ?? ((options) => options);
1252
1265
  const scriptCwd = spec.scriptCwd ??
1253
1266
  ((options) => {
@@ -1294,8 +1307,8 @@ export function createXStatePlaybookRuntime(machine, spec) {
1294
1307
  // acceptance records nothing, so a later call with that key may still
1295
1308
  // execute.
1296
1309
  const appliedReceipts = new Map();
1297
- const playerResumeTokens = new Map();
1298
- const activePlayerIds = new Set();
1310
+ const privateResumeTokens = new Map();
1311
+ const activePlayerKeys = new Set();
1299
1312
  const playbookCallTurnIds = new Map();
1300
1313
  // Captain and judge work share one serialized lane (slc/link.md
1301
1314
  // §Session lifecycle).
@@ -1306,55 +1319,143 @@ export function createXStatePlaybookRuntime(machine, spec) {
1306
1319
  // Inspection callbacks enqueue a complete ordered batch synchronously;
1307
1320
  // imperative boundaries await their queued work directly.
1308
1321
  let emissionFailure;
1309
- function selectPlayerResume(playerId) {
1322
+ function bindSession(nextSession) {
1323
+ const bound = snapshotPlaybookSession(nextSession);
1324
+ if (bound.roleBindings === undefined)
1325
+ return bound;
1326
+ const actual = Object.keys(bound.roleBindings).sort();
1327
+ const expected = [...declaredRoleIds].sort();
1328
+ const missing = expected.filter((roleId) => !actual.includes(roleId));
1329
+ const extra = actual.filter((roleId) => !expected.includes(roleId));
1330
+ if (missing.length > 0 || extra.length > 0) {
1331
+ throw new TypeError(`${label} session roleBindings must cover exactly [${expected.join(', ')}]` +
1332
+ `${missing.length === 0 ? '' : `; missing [${missing.join(', ')}]`}` +
1333
+ `${extra.length === 0 ? '' : `; extra [${extra.join(', ')}]`}`);
1334
+ }
1335
+ return bound;
1336
+ }
1337
+ function requireRoleId(input) {
1338
+ const roleId = input.role;
1339
+ if (typeof roleId !== 'string' ||
1340
+ roleId.trim().length === 0 ||
1341
+ !declaredRoleIds.includes(roleId)) {
1342
+ throw new TypeError(`${label} player input role must name a declared local role`);
1343
+ }
1344
+ return roleId;
1345
+ }
1346
+ function resolvedPlayerId(roleId) {
1347
+ return session?.roleBindings?.[roleId]?.playerId;
1348
+ }
1349
+ function promptIdentity(roleId) {
1350
+ if (!declaredRoleIds.includes(roleId)) {
1351
+ throw new TypeError(`${label} prompt identity lookup rejected undeclared role ${roleId}`);
1352
+ }
1353
+ return session?.roleBindings?.[roleId]?.promptIdentity ?? roleId;
1354
+ }
1355
+ function composeBoundPlayerPrompt(input) {
1356
+ let active = true;
1357
+ const lookup = (roleId) => {
1358
+ if (!active) {
1359
+ throw new Error(`${label} prompt identity lookup is no longer active`);
1360
+ }
1361
+ return promptIdentity(roleId);
1362
+ };
1363
+ try {
1364
+ return composePlayerPrompt(input, lookup);
1365
+ }
1366
+ finally {
1367
+ active = false;
1368
+ }
1369
+ }
1370
+ function continuationKey(roleId, playerId) {
1371
+ return playerId ?? roleId;
1372
+ }
1373
+ function roleTokensByContinuationKey(tokens) {
1374
+ const byKey = new Map();
1375
+ for (const [roleId, token] of Object.entries(tokens)) {
1376
+ if (!declaredRoleIds.includes(roleId)) {
1377
+ throw new TypeError(`runtime role tokens contain unknown role ${roleId}`);
1378
+ }
1379
+ const key = continuationKey(roleId, resolvedPlayerId(roleId));
1380
+ const existing = byKey.get(key);
1381
+ if (existing !== undefined && existing !== token) {
1382
+ throw new TypeError(`runtime snapshot assigns conflicting tokens to roles bound to player ${key}`);
1383
+ }
1384
+ byKey.set(key, token);
1385
+ }
1386
+ const rolesByKey = new Map();
1387
+ for (const roleId of declaredRoleIds) {
1388
+ const key = continuationKey(roleId, resolvedPlayerId(roleId));
1389
+ rolesByKey.set(key, [...(rolesByKey.get(key) ?? []), roleId]);
1390
+ }
1391
+ for (const [key, roles] of rolesByKey) {
1392
+ if (roles.length < 2)
1393
+ continue;
1394
+ const present = roles.filter((roleId) => tokens[roleId] !== undefined);
1395
+ if (present.length !== 0 && present.length !== roles.length) {
1396
+ throw new TypeError(`runtime role tokens must project player ${key} through every aliased role [${roles.join(', ')}]`);
1397
+ }
1398
+ }
1399
+ return byKey;
1400
+ }
1401
+ function selectPlayerResume(roleId, playerId) {
1402
+ const key = continuationKey(roleId, playerId);
1310
1403
  const selected = session?.playerSessions
1311
- ? session.playerSessions.select(playerId)
1312
- : playerResumeTokens.get(playerId) ?? false;
1404
+ ? session.playerSessions.select(roleId)
1405
+ : privateResumeTokens.get(key) ?? false;
1313
1406
  if (selected !== false &&
1314
1407
  (typeof selected !== 'string' || selected.trim().length === 0)) {
1315
- throw new TypeError(`player session store returned an invalid resume token for ${playerId}`);
1408
+ throw new TypeError(`player session store returned an invalid resume token for role ${roleId}`);
1316
1409
  }
1317
1410
  return selected;
1318
1411
  }
1319
- function updatePlayerResume(playerId, resumeToken) {
1412
+ function updatePlayerResume(roleId, playerId, result) {
1413
+ const resumeToken = result.resumeToken;
1414
+ if (resumeToken === undefined && result.status !== 'ok')
1415
+ return;
1416
+ const key = continuationKey(roleId, playerId);
1320
1417
  if (session?.playerSessions) {
1321
- session.playerSessions.update(playerId, resumeToken);
1418
+ session.playerSessions.update(roleId, resumeToken);
1322
1419
  }
1323
- else if (resumeToken !== undefined && resumeToken.trim().length > 0) {
1324
- playerResumeTokens.set(playerId, resumeToken);
1420
+ else if (resumeToken !== undefined) {
1421
+ privateResumeTokens.set(key, resumeToken);
1325
1422
  }
1326
1423
  else {
1327
- playerResumeTokens.delete(playerId);
1424
+ privateResumeTokens.delete(key);
1328
1425
  }
1329
1426
  }
1330
- function snapshotPlayerResumeTokens() {
1331
- const raw = session?.playerSessions
1427
+ function snapshotRoleResumeTokens() {
1428
+ const raw = snapshotJsonValue(session?.playerSessions
1332
1429
  ? session.playerSessions.snapshot()
1333
- : Object.fromEntries(playerResumeTokens);
1430
+ : Object.fromEntries(declaredRoleIds.flatMap((roleId) => {
1431
+ const token = privateResumeTokens.get(continuationKey(roleId, resolvedPlayerId(roleId)));
1432
+ return token === undefined ? [] : [[roleId, token]];
1433
+ })), 'player session store snapshot');
1334
1434
  if (!isPlainObject(raw)) {
1335
1435
  throw new TypeError('player session store snapshot must be an object');
1336
1436
  }
1337
1437
  const detached = {};
1338
- for (const [playerId, token] of Object.entries(raw)) {
1339
- if (playerId.trim().length === 0) {
1340
- throw new TypeError('player session store snapshot player ids must be non-empty');
1438
+ for (const [roleId, token] of Object.entries(raw)) {
1439
+ if (!declaredRoleIds.includes(roleId)) {
1440
+ throw new TypeError(`player session store snapshot contains unknown role ${roleId}`);
1341
1441
  }
1342
1442
  if (typeof token !== 'string' || token.trim().length === 0) {
1343
- throw new TypeError(`player session store snapshot token for ${playerId} must be a non-empty string`);
1443
+ throw new TypeError(`player session store snapshot token for ${roleId} must be a non-empty string`);
1344
1444
  }
1345
- detached[playerId] = token;
1445
+ detached[roleId] = token;
1346
1446
  }
1447
+ roleTokensByContinuationKey(detached);
1347
1448
  return detached;
1348
1449
  }
1349
- function restorePlayerResumeTokens(tokens) {
1450
+ function restoreRoleResumeTokens(tokens) {
1451
+ const byKey = roleTokensByContinuationKey(tokens);
1350
1452
  if (session?.playerSessions) {
1351
1453
  session.playerSessions.restore(tokens);
1352
1454
  return;
1353
1455
  }
1354
- playerResumeTokens.clear();
1355
- for (const [playerId, token] of Object.entries(tokens)) {
1356
- playerResumeTokens.set(playerId, token);
1357
- }
1456
+ privateResumeTokens.clear();
1457
+ for (const [key, token] of byKey)
1458
+ privateResumeTokens.set(key, token);
1358
1459
  }
1359
1460
  function enqueueEmission(fn) {
1360
1461
  const queued = emissionQueue.add(fn).then(() => undefined);
@@ -1399,7 +1500,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
1399
1500
  const currentSession = requireSession();
1400
1501
  const safePayload = snapshotJsonValue(payload, `trace ${type} payload`);
1401
1502
  return {
1402
- schemaVersion: 2,
1503
+ schemaVersion: 3,
1403
1504
  sessionId: currentSession.sessionId,
1404
1505
  playbookId: currentSession.playbookId,
1405
1506
  rootSessionId: currentSession.rootSessionId,
@@ -1508,15 +1609,16 @@ export function createXStatePlaybookRuntime(machine, spec) {
1508
1609
  }
1509
1610
  }
1510
1611
  const boundary = {
1511
- async callPlayer(input, playerId, prompt, signal) {
1612
+ async callPlayer(input, roleId, prompt, signal) {
1512
1613
  // State-entry telemetry/status must precede the call they describe.
1513
1614
  await drainEmissions();
1514
1615
  const turnId = activeTurnId;
1515
1616
  const stateId = input.stateId;
1617
+ const playerId = resolvedPlayerId(roleId);
1516
1618
  let resume;
1517
1619
  try {
1518
1620
  signal.throwIfAborted();
1519
- resume = selectPlayerResume(playerId);
1621
+ resume = selectPlayerResume(roleId, playerId);
1520
1622
  }
1521
1623
  catch (error) {
1522
1624
  if (!signal.aborted)
@@ -1525,23 +1627,24 @@ export function createXStatePlaybookRuntime(machine, spec) {
1525
1627
  }
1526
1628
  const callId = `player-${++playerCallSequence}`;
1527
1629
  const identity = {
1528
- purpose: 'captain',
1529
1630
  ...stateIdentity(stateId),
1530
1631
  sourceItem: input.sourceItem,
1531
- playerId,
1632
+ roleId,
1633
+ ...(playerId === undefined ? {} : { playerId }),
1532
1634
  resume,
1533
1635
  };
1534
1636
  const position = {
1535
1637
  ...(turnId !== undefined ? { turnId } : {}),
1536
1638
  callId,
1537
1639
  };
1538
- if (activePlayerIds.has(playerId)) {
1539
- const error = new Error(`simultaneous calls to resolved player ${playerId} are not allowed`);
1640
+ const playerKey = continuationKey(roleId, playerId);
1641
+ if (activePlayerKeys.has(playerKey)) {
1642
+ const error = new Error(`simultaneous calls to player key ${playerKey} are not allowed`);
1540
1643
  await emitCallStarted('player.call.started', 'player.call.finished', { ...identity, prompt }, position);
1541
1644
  await emitTrace('player.call.finished', { ...identity, status: 'error', error: normalizeError(error) }, position);
1542
1645
  throw error;
1543
1646
  }
1544
- activePlayerIds.add(playerId);
1647
+ activePlayerKeys.add(playerKey);
1545
1648
  try {
1546
1649
  await emitTrace('player.call.started', { ...identity, prompt }, position);
1547
1650
  let rawResult;
@@ -1551,7 +1654,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
1551
1654
  // never start after abort, so settle the already-started pair
1552
1655
  // as `aborted` through the catch below.
1553
1656
  signal.throwIfAborted();
1554
- rawResult = await requireHostPorts().callPlayer(playerId, prompt, signal, { resume });
1657
+ rawResult = await requireHostPorts().callPlayer(roleId, prompt, signal, { resume });
1555
1658
  // A host promise is not required to honor cancellation. Do not let
1556
1659
  // a late result mutate continuity or publish a successful finish.
1557
1660
  signal.throwIfAborted();
@@ -1589,10 +1692,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
1589
1692
  throw error;
1590
1693
  }
1591
1694
  try {
1592
- updatePlayerResume(playerId, typeof result.resumeToken === 'string' &&
1593
- result.resumeToken.trim().length > 0
1594
- ? result.resumeToken
1595
- : undefined);
1695
+ updatePlayerResume(roleId, playerId, result);
1596
1696
  }
1597
1697
  catch (error) {
1598
1698
  if (!signal.aborted)
@@ -1621,7 +1721,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
1621
1721
  return result;
1622
1722
  }
1623
1723
  finally {
1624
- activePlayerIds.delete(playerId);
1724
+ activePlayerKeys.delete(playerKey);
1625
1725
  }
1626
1726
  },
1627
1727
  async callJudge(purpose, stateId, prompt, signal) {
@@ -1786,15 +1886,10 @@ export function createXStatePlaybookRuntime(machine, spec) {
1786
1886
  });
1787
1887
  },
1788
1888
  };
1789
- function resolvePlayerId(input) {
1790
- return resolvePlayerIdSpec
1791
- ? resolvePlayerIdSpec(input, boundOptions)
1792
- : defaultResolvePlayerId(input);
1793
- }
1794
1889
  function playerActor(ports) {
1795
1890
  return createPlayerBridge({
1796
- resolvePlayerId,
1797
- composePlayerPrompt,
1891
+ resolveRoleId: requireRoleId,
1892
+ composePlayerPrompt: composeBoundPlayerPrompt,
1798
1893
  adjudication,
1799
1894
  resumableStateIds,
1800
1895
  }, ports, () => activeSignal, boundary, (error) => {
@@ -2210,8 +2305,8 @@ export function createXStatePlaybookRuntime(machine, spec) {
2210
2305
  // The session-start error remains authoritative.
2211
2306
  }
2212
2307
  }
2213
- playerResumeTokens.clear();
2214
- activePlayerIds.clear();
2308
+ privateResumeTokens.clear();
2309
+ activePlayerKeys.clear();
2215
2310
  playbookCallTurnIds.clear();
2216
2311
  activeEmissionCalls.clear();
2217
2312
  emissionQueue.clear();
@@ -2394,7 +2489,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
2394
2489
  if (initialized || disposed || disposalPromise !== undefined) {
2395
2490
  throw new Error('createPlaybookRuntime.init: already initialized');
2396
2491
  }
2397
- const boundSession = snapshotPlaybookSession(nextSession);
2492
+ const boundSession = bindSession(nextSession);
2398
2493
  initialized = true;
2399
2494
  let finishInitialization;
2400
2495
  const initialization = new Promise((resolve) => {
@@ -2468,10 +2563,10 @@ export function createXStatePlaybookRuntime(machine, spec) {
2468
2563
  .context;
2469
2564
  const pending = pendingBossQuestionFromContext(context ?? {});
2470
2565
  return {
2471
- schemaVersion: 2,
2566
+ schemaVersion: 3,
2472
2567
  playbookId: session.playbookId,
2473
2568
  machine: machineSnapshot,
2474
- playerResumeTokens: snapshotPlayerResumeTokens(),
2569
+ roleResumeTokens: snapshotRoleResumeTokens(),
2475
2570
  sequences: {
2476
2571
  trace: traceSequence,
2477
2572
  turn: turnSequence,
@@ -2488,7 +2583,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
2488
2583
  : [
2489
2584
  {
2490
2585
  questionId: pending.questionId,
2491
- player: pending.player,
2586
+ asker: pending.asker,
2492
2587
  question: pending.question,
2493
2588
  sourceItem: pending.sourceItem,
2494
2589
  },
@@ -2505,11 +2600,13 @@ export function createXStatePlaybookRuntime(machine, spec) {
2505
2600
  if (initialized || disposed || disposalPromise !== undefined) {
2506
2601
  throw new Error('createPlaybookRuntime.restore: already initialized');
2507
2602
  }
2508
- const boundSession = snapshotPlaybookSession(nextSession);
2603
+ const boundSession = bindSession(nextSession);
2509
2604
  const boundSnapshot = assertPlaybookRuntimeSnapshot(snapshot, boundSession.playbookId, { allowSuspendedCall: true });
2510
- const suspendedCall = boundSnapshot.schemaVersion === 2
2511
- ? boundSnapshot.suspendedCall
2512
- : undefined;
2605
+ if (declaredActors.has('captain') &&
2606
+ boundSnapshot.sequences.captainCall === undefined) {
2607
+ throw new TypeError('runtime snapshot sequences.captainCall is required for a direct-Captain artifact');
2608
+ }
2609
+ const suspendedCall = boundSnapshot.suspendedCall;
2513
2610
  let priorExternalPlayerTokens;
2514
2611
  let externalStoreRestoreAttempted = false;
2515
2612
  initialized = true;
@@ -2527,22 +2624,17 @@ export function createXStatePlaybookRuntime(machine, spec) {
2527
2624
  judgeCallSequence = boundSnapshot.sequences.judgeCall;
2528
2625
  playerCallSequence = boundSnapshot.sequences.playerCall;
2529
2626
  playbookCallSequence = boundSnapshot.sequences.playbookCall;
2530
- captainCallSequence =
2531
- boundSnapshot.sequences.captainCall ??
2532
- // Legacy schema-v1 snapshots predate this dedicated counter.
2533
- // Every Captain call already consumed at least one trace number,
2534
- // so the global trace counter is a collision-safe id floor.
2535
- boundSnapshot.sequences.trace;
2627
+ captainCallSequence = boundSnapshot.sequences.captainCall ?? 0;
2536
2628
  // The runtime snapshot carries no apply counter (PBRT-50); every
2537
2629
  // apply boundary consumed trace numbers, so the persisted trace
2538
2630
  // counter is a collision-safe id floor here too, keeping
2539
2631
  // `apply-<n>` call ids unique across restore.
2540
2632
  applyCallSequence = boundSnapshot.sequences.trace;
2541
2633
  if (boundSession.playerSessions) {
2542
- priorExternalPlayerTokens = snapshotPlayerResumeTokens();
2634
+ priorExternalPlayerTokens = snapshotRoleResumeTokens();
2543
2635
  externalStoreRestoreAttempted = true;
2544
2636
  }
2545
- restorePlayerResumeTokens(boundSnapshot.playerResumeTokens);
2637
+ restoreRoleResumeTokens(boundSnapshot.roleResumeTokens);
2546
2638
  nestedBridge.prepareRestore(suspendedCall);
2547
2639
  if (suspendedCall !== undefined) {
2548
2640
  playbookCallTurnIds.set(suspendedCall.callId, suspendedCall.turnId);
@@ -2632,7 +2724,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
2632
2724
  : [
2633
2725
  {
2634
2726
  questionId: pending.questionId,
2635
- player: pending.player,
2727
+ asker: pending.asker,
2636
2728
  question: pending.question,
2637
2729
  sourceItem: pending.sourceItem,
2638
2730
  },
@@ -3126,9 +3218,9 @@ export function createXStatePlaybookRuntime(machine, spec) {
3126
3218
  // engagement tree. Child disposal must not erase a token its
3127
3219
  // caller will resume. The private fallback remains runtime-owned.
3128
3220
  if (session?.playerSessions === undefined) {
3129
- playerResumeTokens.clear();
3221
+ privateResumeTokens.clear();
3130
3222
  }
3131
- activePlayerIds.clear();
3223
+ activePlayerKeys.clear();
3132
3224
  playbookCallTurnIds.clear();
3133
3225
  activeEmissionCalls.clear();
3134
3226
  emissionQueue.clear();