@sublang/playbook 5.0.0 → 7.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 (72) hide show
  1. package/README.md +22 -14
  2. package/docs/cli.md +138 -90
  3. package/docs/configuration.md +113 -29
  4. package/docs/embedding.md +24 -16
  5. package/package.json +42 -21
  6. package/reference/sdlc/captain.playbook/captain.playbook.js +2 -0
  7. package/reference/sdlc/captain.playbook/captain.playbook.ts +2 -0
  8. package/reference/sdlc/code.md +55 -97
  9. package/reference/sdlc/code.playbook/bin/launch-config.js +938 -0
  10. package/reference/sdlc/code.playbook/bin/playbook.js +145 -562
  11. package/reference/sdlc/code.playbook/bin/provision.js +84 -38
  12. package/reference/sdlc/code.playbook/bin/run.js +1171 -983
  13. package/reference/sdlc/code.playbook/bin/session-store.js +1169 -0
  14. package/reference/sdlc/code.playbook/code.fsm.d.ts +229 -94
  15. package/reference/sdlc/code.playbook/code.fsm.introspect.d.ts +26 -44
  16. package/reference/sdlc/code.playbook/code.fsm.introspect.js +61 -66
  17. package/reference/sdlc/code.playbook/code.fsm.introspect.ts +100 -149
  18. package/reference/sdlc/code.playbook/code.fsm.js +587 -1347
  19. package/reference/sdlc/code.playbook/code.fsm.ts +809 -1650
  20. package/reference/sdlc/code.playbook/code.gears.md +51 -263
  21. package/reference/sdlc/code.playbook/code.playbook.d.ts +8 -47
  22. package/reference/sdlc/code.playbook/code.playbook.js +69 -656
  23. package/reference/sdlc/code.playbook/code.playbook.ts +90 -867
  24. package/reference/sdlc/code.playbook/code.registry.d.ts +9 -25
  25. package/reference/sdlc/code.playbook/code.registry.js +20 -78
  26. package/reference/sdlc/code.playbook/code.registry.ts +58 -122
  27. package/reference/sdlc/code.playbook/playbook-captain.d.ts +70 -3
  28. package/reference/sdlc/code.playbook/playbook-captain.js +954 -80
  29. package/reference/sdlc/code.playbook/playbook-captain.ts +1408 -80
  30. package/reference/sdlc/code.playbook/playbook.config.template.yaml +37 -36
  31. package/reference/sdlc/decide.md +54 -0
  32. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +261 -0
  33. package/reference/sdlc/decide.playbook/decide.fsm.js +894 -0
  34. package/reference/sdlc/decide.playbook/decide.fsm.ts +1152 -0
  35. package/reference/sdlc/decide.playbook/decide.gears.md +88 -0
  36. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +67 -0
  37. package/reference/sdlc/{discuss.playbook/discuss.playbook.js → decide.playbook/decide.playbook.js} +545 -372
  38. package/reference/sdlc/{discuss.playbook/discuss.playbook.ts → decide.playbook/decide.playbook.ts} +665 -454
  39. package/reference/sdlc/decide.playbook/decide.registry.d.ts +41 -0
  40. package/reference/sdlc/decide.playbook/decide.registry.js +60 -0
  41. package/reference/sdlc/decide.playbook/decide.registry.ts +125 -0
  42. package/reference/sdlc/review.md +81 -0
  43. package/reference/sdlc/review.playbook/review.fsm.d.ts +183 -0
  44. package/reference/sdlc/review.playbook/review.fsm.js +524 -0
  45. package/reference/sdlc/review.playbook/review.fsm.ts +652 -0
  46. package/reference/sdlc/review.playbook/review.gears.md +112 -0
  47. package/reference/sdlc/review.playbook/review.playbook.d.ts +12 -0
  48. package/reference/sdlc/review.playbook/review.playbook.js +112 -0
  49. package/reference/sdlc/review.playbook/review.playbook.ts +201 -0
  50. package/reference/sdlc/review.playbook/review.registry.d.ts +43 -0
  51. package/reference/sdlc/review.playbook/review.registry.js +73 -0
  52. package/reference/sdlc/review.playbook/review.registry.ts +138 -0
  53. package/slc/gears2fsm.md +13 -4
  54. package/slc/link.md +83 -14
  55. package/slc/text2gears.md +22 -2
  56. package/src/runtime.d.ts +21 -2
  57. package/src/runtime.ts +38 -6
  58. package/src/xstate-playbook-runtime.d.ts +9 -2
  59. package/src/xstate-playbook-runtime.js +319 -35
  60. package/src/xstate-playbook-runtime.ts +412 -41
  61. package/src/xstate-runtime.d.ts +19 -2
  62. package/src/xstate-runtime.js +384 -57
  63. package/src/xstate-runtime.ts +542 -71
  64. package/reference/sdlc/discuss.md +0 -93
  65. package/reference/sdlc/discuss.playbook/discuss.fsm.d.ts +0 -396
  66. package/reference/sdlc/discuss.playbook/discuss.fsm.js +0 -2067
  67. package/reference/sdlc/discuss.playbook/discuss.fsm.ts +0 -2465
  68. package/reference/sdlc/discuss.playbook/discuss.gears.md +0 -258
  69. package/reference/sdlc/discuss.playbook/discuss.playbook.d.ts +0 -113
  70. package/reference/sdlc/discuss.playbook/discuss.registry.d.ts +0 -58
  71. package/reference/sdlc/discuss.playbook/discuss.registry.js +0 -97
  72. package/reference/sdlc/discuss.playbook/discuss.registry.ts +0 -153
@@ -589,6 +589,48 @@ function collectInvokeSources(machine) {
589
589
  visit(machine.config);
590
590
  return sources;
591
591
  }
592
+ function collectPlayerStatePlayers(machine) {
593
+ const players = new Map();
594
+ const visit = (stateDef, stateKey) => {
595
+ if (!isPlainObject(stateDef))
596
+ return;
597
+ const invoke = stateDef.invoke;
598
+ const invokes = Array.isArray(invoke) ? invoke : invoke ? [invoke] : [];
599
+ if (invokes.some((entry) => isPlainObject(entry) && entry.src === 'player')) {
600
+ const playbookMeta = isPlainObject(stateDef.meta)
601
+ ? stateDef.meta.playbook
602
+ : undefined;
603
+ const stateId = isPlainObject(playbookMeta) &&
604
+ typeof playbookMeta.stateId === 'string'
605
+ ? playbookMeta.stateId
606
+ : typeof stateDef.id === 'string'
607
+ ? stateDef.id
608
+ : stateKey;
609
+ if (stateId.trim().length === 0) {
610
+ throw new TypeError('player state metadata must use a non-empty state id');
611
+ }
612
+ const player = isPlainObject(playbookMeta)
613
+ ? playbookMeta.player
614
+ : 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`);
617
+ }
618
+ players.set(stateId, player);
619
+ }
620
+ if (isPlainObject(stateDef.states)) {
621
+ for (const [childKey, child] of Object.entries(stateDef.states)) {
622
+ visit(child, childKey);
623
+ }
624
+ }
625
+ };
626
+ const config = machine.config;
627
+ if (isPlainObject(config) && isPlainObject(config.states)) {
628
+ for (const [stateKey, stateDef] of Object.entries(config.states)) {
629
+ visit(stateDef, stateKey);
630
+ }
631
+ }
632
+ return players;
633
+ }
592
634
  function transitionTargets(transition) {
593
635
  const arms = Array.isArray(transition) ? transition : [transition];
594
636
  const targets = [];
@@ -732,16 +774,65 @@ function makeDefaultNormalizeTransitionEvent(transitionEventFields) {
732
774
  return snapshotJsonValue(out, 'FSM event');
733
775
  };
734
776
  }
735
- function defaultStatusesForState(state, context) {
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`);
782
+ }
783
+ const declared = collectPlayerStatePlayers(machine);
784
+ const statuses = new Map();
785
+ for (const [stateId, candidate] of Object.entries(value)) {
786
+ if (!declared.has(stateId)) {
787
+ throw new TypeError(`${label} playerStates.${stateId} does not name a player state`);
788
+ }
789
+ if (!isPlainObject(candidate) ||
790
+ typeof candidate.player !== 'string' ||
791
+ candidate.player.trim().length === 0 ||
792
+ typeof candidate.label !== 'string' ||
793
+ candidate.label.trim().length === 0) {
794
+ throw new TypeError(`${label} playerStates.${stateId} must carry non-empty player and label strings`);
795
+ }
796
+ const expectedLabel = stateDescriptions.get(stateId);
797
+ if (candidate.label !== expectedLabel) {
798
+ throw new TypeError(`${label} playerStates.${stateId}.label must equal its FSM description`);
799
+ }
800
+ if (candidate.player !== declared.get(stateId)) {
801
+ throw new TypeError(`${label} playerStates.${stateId}.player must equal its FSM player`);
802
+ }
803
+ statuses.set(stateId, {
804
+ player: candidate.player,
805
+ label: candidate.label,
806
+ });
807
+ }
808
+ for (const stateId of declared.keys()) {
809
+ if (!statuses.has(stateId)) {
810
+ throw new TypeError(`${label} playerStates must declare player state ${stateId}`);
811
+ }
812
+ }
813
+ return statuses;
814
+ }
815
+ function settlingGuard(event) {
816
+ if (!isPlainObject(event) || !isPlainObject(event.output))
817
+ return undefined;
818
+ const guard = event.output.guard;
819
+ return typeof guard === 'string' && guard.trim().length > 0
820
+ ? guard
821
+ : undefined;
822
+ }
823
+ function legacyStatusesForState(state, context) {
736
824
  const stateId = state.stateId;
737
825
  if (stateId === undefined || SUPPRESSED_ENTRY_STATES.has(stateId))
738
826
  return [];
739
827
  if (stateId === 'awaitBossReply') {
740
828
  const pending = pendingBossQuestionFromContext(context);
741
- const message = pending === undefined
742
- ? 'Awaiting Boss reply.'
743
- : `${pending.player} asks: ${pending.question}`;
744
- return [{ message }];
829
+ return [
830
+ {
831
+ message: pending === undefined
832
+ ? 'Awaiting Boss reply.'
833
+ : `${pending.player} asks: ${pending.question}`,
834
+ },
835
+ ];
745
836
  }
746
837
  if (stateId === 'failed') {
747
838
  const lastError = normalizeErrorFull(context.lastError);
@@ -756,6 +847,53 @@ function defaultStatusesForState(state, context) {
756
847
  }
757
848
  return [{ message: `Entered ${stateId}.` }];
758
849
  }
850
+ function makeDefaultStatusesForState(playerStates) {
851
+ return (state, context, event) => {
852
+ const statuses = [];
853
+ const guard = settlingGuard(event);
854
+ if (guard !== undefined)
855
+ statuses.push({ message: `→ ${guard}` });
856
+ const stateId = state.stateId;
857
+ if (stateId === undefined || SUPPRESSED_ENTRY_STATES.has(stateId)) {
858
+ return statuses;
859
+ }
860
+ if (stateId === 'awaitBossReply') {
861
+ const pending = pendingBossQuestionFromContext(context);
862
+ if (pending === undefined) {
863
+ return [...statuses, { message: 'Awaiting Boss reply.' }];
864
+ }
865
+ return [
866
+ ...statuses,
867
+ { message: `${pending.player} asks: ${pending.question}` },
868
+ {
869
+ message: `◆ awaiting Boss reply · ${pending.resumeStateId} · ` +
870
+ `${pending.player} · ${pending.sourceItem}`,
871
+ },
872
+ ];
873
+ }
874
+ if (stateId === 'failed') {
875
+ const lastError = normalizeErrorCompact(context.lastError);
876
+ return [
877
+ ...statuses,
878
+ {
879
+ message: '◆ workflow failed; awaiting Boss recovery.',
880
+ ...(lastError === undefined
881
+ ? {}
882
+ : {
883
+ data: snapshotJsonValue({ lastError }, 'failed status data'),
884
+ }),
885
+ },
886
+ ];
887
+ }
888
+ const playerState = playerStates.get(stateId);
889
+ if (playerState !== undefined) {
890
+ statuses.push({
891
+ message: `⤷ ${playerState.player}: ${playerState.label}`,
892
+ });
893
+ }
894
+ return statuses;
895
+ };
896
+ }
759
897
  function classifierState(snapshotOrState) {
760
898
  if (snapshotOrState !== null &&
761
899
  typeof snapshotOrState === 'object' &&
@@ -1061,6 +1199,8 @@ export function createXStatePlaybookRuntime(machine, spec) {
1061
1199
  // DR-029: source state descriptions label the control actions the
1062
1200
  // runtime advertises through `describe()`.
1063
1201
  const stateDescriptions = stateDescriptionsFromMachine(machine);
1202
+ const hasCanonicalStatusProfile = spec.playerStates !== undefined;
1203
+ const playerStates = snapshotPlayerStateStatuses(spec.playerStates, label, machine, stateDescriptions);
1064
1204
  // PBRT-52: the artifact's own ControlView context projection. Nothing is
1065
1205
  // exported by default, so an FSM context member — including one added
1066
1206
  // after this artifact was linked — is private until named here. The two
@@ -1100,7 +1240,14 @@ export function createXStatePlaybookRuntime(machine, spec) {
1100
1240
  const classifyBossText = spec.classifyBossText ?? derivedClassifyBossText;
1101
1241
  const normalizeTransitionEvent = spec.normalizeTransitionEvent ??
1102
1242
  makeDefaultNormalizeTransitionEvent(spec.transitionEventFields ?? []);
1103
- const statusesForState = spec.statusesForState ?? defaultStatusesForState;
1243
+ const statusesForState = spec.statusesForState ??
1244
+ (hasCanonicalStatusProfile
1245
+ ? makeDefaultStatusesForState(playerStates)
1246
+ : legacyStatusesForState);
1247
+ const classificationStatus = spec.classificationStatus ??
1248
+ (hasCanonicalStatusProfile
1249
+ ? (event) => event.type
1250
+ : () => undefined);
1104
1251
  const machineInput = spec.machineInput ?? ((options) => options);
1105
1252
  const scriptCwd = spec.scriptCwd ??
1106
1253
  ((options) => {
@@ -1138,8 +1285,8 @@ export function createXStatePlaybookRuntime(machine, spec) {
1138
1285
  // DR-029: the last event a public Boss boundary sent into the
1139
1286
  // machine — classified, deterministic entry, or Boss reply — kept with
1140
1287
  // its recorded payload so a failure-state retry action can replay the
1141
- // event that drove the run into `failed`. Process-local: the schema-1
1142
- // parked snapshot does not persist it (PBRT-50: no schema bump).
1288
+ // event that drove the run into `failed`. Process-local: the durable
1289
+ // runtime snapshot does not persist it (PBRT-50).
1143
1290
  let lastBossEvent;
1144
1291
  // DR-029: process-local at-most-once `apply` execution — the accepted receipt
1145
1292
  // recorded for each idempotency key, returned verbatim on a repeated
@@ -1159,6 +1306,56 @@ export function createXStatePlaybookRuntime(machine, spec) {
1159
1306
  // Inspection callbacks enqueue a complete ordered batch synchronously;
1160
1307
  // imperative boundaries await their queued work directly.
1161
1308
  let emissionFailure;
1309
+ function selectPlayerResume(playerId) {
1310
+ const selected = session?.playerSessions
1311
+ ? session.playerSessions.select(playerId)
1312
+ : playerResumeTokens.get(playerId) ?? false;
1313
+ if (selected !== false &&
1314
+ (typeof selected !== 'string' || selected.trim().length === 0)) {
1315
+ throw new TypeError(`player session store returned an invalid resume token for ${playerId}`);
1316
+ }
1317
+ return selected;
1318
+ }
1319
+ function updatePlayerResume(playerId, resumeToken) {
1320
+ if (session?.playerSessions) {
1321
+ session.playerSessions.update(playerId, resumeToken);
1322
+ }
1323
+ else if (resumeToken !== undefined && resumeToken.trim().length > 0) {
1324
+ playerResumeTokens.set(playerId, resumeToken);
1325
+ }
1326
+ else {
1327
+ playerResumeTokens.delete(playerId);
1328
+ }
1329
+ }
1330
+ function snapshotPlayerResumeTokens() {
1331
+ const raw = session?.playerSessions
1332
+ ? session.playerSessions.snapshot()
1333
+ : Object.fromEntries(playerResumeTokens);
1334
+ if (!isPlainObject(raw)) {
1335
+ throw new TypeError('player session store snapshot must be an object');
1336
+ }
1337
+ 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');
1341
+ }
1342
+ 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`);
1344
+ }
1345
+ detached[playerId] = token;
1346
+ }
1347
+ return detached;
1348
+ }
1349
+ function restorePlayerResumeTokens(tokens) {
1350
+ if (session?.playerSessions) {
1351
+ session.playerSessions.restore(tokens);
1352
+ return;
1353
+ }
1354
+ playerResumeTokens.clear();
1355
+ for (const [playerId, token] of Object.entries(tokens)) {
1356
+ playerResumeTokens.set(playerId, token);
1357
+ }
1358
+ }
1162
1359
  function enqueueEmission(fn) {
1163
1360
  const queued = emissionQueue.add(fn).then(() => undefined);
1164
1361
  activeEmissionCalls.add(queued);
@@ -1315,9 +1512,18 @@ export function createXStatePlaybookRuntime(machine, spec) {
1315
1512
  // State-entry telemetry/status must precede the call they describe.
1316
1513
  await drainEmissions();
1317
1514
  const turnId = activeTurnId;
1318
- const callId = `player-${++playerCallSequence}`;
1319
1515
  const stateId = input.stateId;
1320
- const resume = playerResumeTokens.get(playerId) ?? false;
1516
+ let resume;
1517
+ try {
1518
+ signal.throwIfAborted();
1519
+ resume = selectPlayerResume(playerId);
1520
+ }
1521
+ catch (error) {
1522
+ if (!signal.aborted)
1523
+ controlPlaneError ??= error;
1524
+ throw error;
1525
+ }
1526
+ const callId = `player-${++playerCallSequence}`;
1321
1527
  const identity = {
1322
1528
  purpose: 'captain',
1323
1529
  ...stateIdentity(stateId),
@@ -1382,12 +1588,22 @@ export function createXStatePlaybookRuntime(machine, spec) {
1382
1588
  }
1383
1589
  throw error;
1384
1590
  }
1385
- if (typeof result.resumeToken === 'string' &&
1386
- result.resumeToken.trim().length > 0) {
1387
- playerResumeTokens.set(playerId, result.resumeToken);
1591
+ try {
1592
+ updatePlayerResume(playerId, typeof result.resumeToken === 'string' &&
1593
+ result.resumeToken.trim().length > 0
1594
+ ? result.resumeToken
1595
+ : undefined);
1388
1596
  }
1389
- else {
1390
- playerResumeTokens.delete(playerId);
1597
+ catch (error) {
1598
+ if (!signal.aborted)
1599
+ controlPlaneError ??= error;
1600
+ try {
1601
+ await emitTrace('player.call.finished', { ...identity, status: 'error', error: normalizeError(error) }, position);
1602
+ }
1603
+ catch {
1604
+ // The continuation-store failure remains authoritative.
1605
+ }
1606
+ throw error;
1391
1607
  }
1392
1608
  await emitTrace('player.call.finished', {
1393
1609
  ...identity,
@@ -2208,18 +2424,42 @@ export function createXStatePlaybookRuntime(machine, spec) {
2208
2424
  initInFlight = undefined;
2209
2425
  }
2210
2426
  },
2211
- // DR-014 §1 / PBRT-45: JSON-safe capture of a parked session.
2427
+ // DR-014 §1 / DR-031 §5 / PBRT-45: JSON-safe capture of a parked
2428
+ // session, including one already-started suspended nested call.
2212
2429
  // Defined only at a safe capture point — initialized, not disposing
2213
- // or disposed, no active public boundary, no pending nested call,
2214
- // and the actor quiescent with status `active`.
2430
+ // or disposed, no active public boundary, and the actor quiescent with
2431
+ // status `active`.
2215
2432
  exportSnapshot() {
2216
2433
  if (!actor || !session || disposed || disposalPromise !== undefined) {
2217
2434
  return undefined;
2218
2435
  }
2219
2436
  if (activeSignal !== undefined)
2220
2437
  return undefined;
2221
- if (nestedBridge.getPendingCall())
2438
+ const pendingCall = nestedBridge.getPendingCall();
2439
+ const bridgeSuspendedCall = nestedBridge.getSuspendedCall();
2440
+ if ((pendingCall === undefined) !== (bridgeSuspendedCall === undefined)) {
2222
2441
  return undefined;
2442
+ }
2443
+ let suspendedCall;
2444
+ if (bridgeSuspendedCall !== undefined) {
2445
+ if (pendingCall?.callId !== bridgeSuspendedCall.callId ||
2446
+ pendingCall?.playbookId !== bridgeSuspendedCall.playbookId ||
2447
+ pendingCall?.childSessionId !== bridgeSuspendedCall.childSessionId) {
2448
+ return undefined;
2449
+ }
2450
+ if (!playbookCallTurnIds.has(bridgeSuspendedCall.callId)) {
2451
+ return undefined;
2452
+ }
2453
+ const turnId = playbookCallTurnIds.get(bridgeSuspendedCall.callId);
2454
+ if (bridgeSuspendedCall.turnId !== undefined &&
2455
+ bridgeSuspendedCall.turnId !== turnId) {
2456
+ return undefined;
2457
+ }
2458
+ suspendedCall = {
2459
+ ...bridgeSuspendedCall,
2460
+ ...(turnId === undefined ? {} : { turnId }),
2461
+ };
2462
+ }
2223
2463
  const state = currentState();
2224
2464
  if (state.status !== 'active' || !state.quiescent)
2225
2465
  return undefined;
@@ -2228,10 +2468,10 @@ export function createXStatePlaybookRuntime(machine, spec) {
2228
2468
  .context;
2229
2469
  const pending = pendingBossQuestionFromContext(context ?? {});
2230
2470
  return {
2231
- schemaVersion: 1,
2471
+ schemaVersion: 2,
2232
2472
  playbookId: session.playbookId,
2233
2473
  machine: machineSnapshot,
2234
- playerResumeTokens: Object.fromEntries(playerResumeTokens),
2474
+ playerResumeTokens: snapshotPlayerResumeTokens(),
2235
2475
  sequences: {
2236
2476
  trace: traceSequence,
2237
2477
  turn: turnSequence,
@@ -2253,6 +2493,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
2253
2493
  sourceItem: pending.sourceItem,
2254
2494
  },
2255
2495
  ],
2496
+ ...(suspendedCall === undefined ? {} : { suspendedCall }),
2256
2497
  };
2257
2498
  },
2258
2499
  // DR-014 §1 / PBRT-45: alternative to `init` that rehydrates an
@@ -2265,7 +2506,12 @@ export function createXStatePlaybookRuntime(machine, spec) {
2265
2506
  throw new Error('createPlaybookRuntime.restore: already initialized');
2266
2507
  }
2267
2508
  const boundSession = snapshotPlaybookSession(nextSession);
2268
- const boundSnapshot = assertPlaybookRuntimeSnapshot(snapshot, boundSession.playbookId);
2509
+ const boundSnapshot = assertPlaybookRuntimeSnapshot(snapshot, boundSession.playbookId, { allowSuspendedCall: true });
2510
+ const suspendedCall = boundSnapshot.schemaVersion === 2
2511
+ ? boundSnapshot.suspendedCall
2512
+ : undefined;
2513
+ let priorExternalPlayerTokens;
2514
+ let externalStoreRestoreAttempted = false;
2269
2515
  initialized = true;
2270
2516
  let finishInitialization;
2271
2517
  const initialization = new Promise((resolve) => {
@@ -2287,32 +2533,65 @@ export function createXStatePlaybookRuntime(machine, spec) {
2287
2533
  // Every Captain call already consumed at least one trace number,
2288
2534
  // so the global trace counter is a collision-safe id floor.
2289
2535
  boundSnapshot.sequences.trace;
2290
- // The schema-1 snapshot carries no apply counter (PBRT-50: no
2291
- // schema bump); every apply boundary consumed trace numbers, so
2292
- // the persisted trace counter is a collision-safe id floor here
2293
- // too, keeping `apply-<n>` call ids unique across restore.
2536
+ // The runtime snapshot carries no apply counter (PBRT-50); every
2537
+ // apply boundary consumed trace numbers, so the persisted trace
2538
+ // counter is a collision-safe id floor here too, keeping
2539
+ // `apply-<n>` call ids unique across restore.
2294
2540
  applyCallSequence = boundSnapshot.sequences.trace;
2295
- playerResumeTokens.clear();
2296
- for (const [playerId, token] of Object.entries(boundSnapshot.playerResumeTokens)) {
2297
- playerResumeTokens.set(playerId, token);
2541
+ if (boundSession.playerSessions) {
2542
+ priorExternalPlayerTokens = snapshotPlayerResumeTokens();
2543
+ externalStoreRestoreAttempted = true;
2544
+ }
2545
+ restorePlayerResumeTokens(boundSnapshot.playerResumeTokens);
2546
+ nestedBridge.prepareRestore(suspendedCall);
2547
+ if (suspendedCall !== undefined) {
2548
+ playbookCallTurnIds.set(suspendedCall.callId, suspendedCall.turnId);
2298
2549
  }
2299
2550
  suppressInspectionEmissions = true;
2300
2551
  actor = buildActor(runtimePorts, boundSnapshot.machine);
2301
2552
  actor.start();
2302
- const restoredState = currentState();
2553
+ if (controlPlaneError !== undefined)
2554
+ throw controlPlaneError;
2555
+ const restoredState = normalizePlaybookSnapshot(actor.getSnapshot(), suspendedCall === undefined
2556
+ ? {}
2557
+ : {
2558
+ pendingCall: {
2559
+ callId: suspendedCall.callId,
2560
+ playbookId: suspendedCall.playbookId,
2561
+ childSessionId: suspendedCall.childSessionId,
2562
+ },
2563
+ });
2303
2564
  if (restoredState.status !== 'active') {
2304
2565
  throw new Error(`createPlaybookRuntime.restore: restored actor status is ${restoredState.status}, expected active`);
2305
2566
  }
2306
- suppressInspectionEmissions = false;
2567
+ if (stableJson(restoredState, 'restored runtime state') !==
2568
+ stableJson(boundSnapshot.state, 'runtime snapshot state')) {
2569
+ throw new Error('createPlaybookRuntime.restore: restored actor state does not match snapshot state');
2570
+ }
2307
2571
  priorState = restoredState;
2308
2572
  await drainEmissions();
2573
+ suppressInspectionEmissions = false;
2574
+ // Final fallible step: after this publication the authoritative
2575
+ // child has rejoined ordinary resume/abort ownership, so no later
2576
+ // restore validation may trigger failed-start rollback.
2577
+ nestedBridge.confirmRestore();
2309
2578
  })();
2310
2579
  try {
2311
2580
  await initTask;
2312
2581
  }
2313
2582
  catch (error) {
2314
- await cleanupFailedStart(error, { emitDisposal: false });
2315
- throw error;
2583
+ let failure = error;
2584
+ if (externalStoreRestoreAttempted &&
2585
+ priorExternalPlayerTokens !== undefined) {
2586
+ try {
2587
+ boundSession.playerSessions.restore(priorExternalPlayerTokens);
2588
+ }
2589
+ catch (rollbackError) {
2590
+ failure = new AggregateError([error, rollbackError], 'createPlaybookRuntime.restore and player continuation rollback failed');
2591
+ }
2592
+ }
2593
+ await cleanupFailedStart(failure, { emitDisposal: false });
2594
+ throw failure;
2316
2595
  }
2317
2596
  finally {
2318
2597
  finishInitialization();
@@ -2662,7 +2941,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
2662
2941
  else {
2663
2942
  // 2. Optional Captain-pane classification line: the bare FSM
2664
2943
  // event type, emitted before the FSM advances.
2665
- const statusLine = spec.classificationStatus?.(event);
2944
+ const statusLine = classificationStatus(event);
2666
2945
  if (statusLine !== undefined) {
2667
2946
  await runtimePorts.emitStatus(statusLine);
2668
2947
  }
@@ -2843,7 +3122,12 @@ export function createXStatePlaybookRuntime(machine, spec) {
2843
3122
  }
2844
3123
  }
2845
3124
  finally {
2846
- playerResumeTokens.clear();
3125
+ // A composing host owns the shared store for the complete root
3126
+ // engagement tree. Child disposal must not erase a token its
3127
+ // caller will resume. The private fallback remains runtime-owned.
3128
+ if (session?.playerSessions === undefined) {
3129
+ playerResumeTokens.clear();
3130
+ }
2847
3131
  activePlayerIds.clear();
2848
3132
  playbookCallTurnIds.clear();
2849
3133
  activeEmissionCalls.clear();