@sublang/playbook 5.0.0 → 6.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 (65) hide show
  1. package/README.md +11 -7
  2. package/docs/cli.md +38 -35
  3. package/docs/configuration.md +58 -15
  4. package/docs/embedding.md +24 -16
  5. package/package.json +40 -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/code.fsm.d.ts +229 -94
  10. package/reference/sdlc/code.playbook/code.fsm.introspect.d.ts +26 -44
  11. package/reference/sdlc/code.playbook/code.fsm.introspect.js +61 -66
  12. package/reference/sdlc/code.playbook/code.fsm.introspect.ts +100 -149
  13. package/reference/sdlc/code.playbook/code.fsm.js +587 -1347
  14. package/reference/sdlc/code.playbook/code.fsm.ts +809 -1650
  15. package/reference/sdlc/code.playbook/code.gears.md +51 -263
  16. package/reference/sdlc/code.playbook/code.playbook.d.ts +8 -47
  17. package/reference/sdlc/code.playbook/code.playbook.js +69 -656
  18. package/reference/sdlc/code.playbook/code.playbook.ts +90 -867
  19. package/reference/sdlc/code.playbook/code.registry.d.ts +9 -25
  20. package/reference/sdlc/code.playbook/code.registry.js +20 -78
  21. package/reference/sdlc/code.playbook/code.registry.ts +58 -122
  22. package/reference/sdlc/code.playbook/playbook-captain.js +93 -15
  23. package/reference/sdlc/code.playbook/playbook-captain.ts +115 -19
  24. package/reference/sdlc/code.playbook/playbook.config.template.yaml +33 -22
  25. package/reference/sdlc/decide.md +54 -0
  26. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +261 -0
  27. package/reference/sdlc/decide.playbook/decide.fsm.js +894 -0
  28. package/reference/sdlc/decide.playbook/decide.fsm.ts +1152 -0
  29. package/reference/sdlc/decide.playbook/decide.gears.md +88 -0
  30. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +67 -0
  31. package/reference/sdlc/{discuss.playbook/discuss.playbook.js → decide.playbook/decide.playbook.js} +471 -362
  32. package/reference/sdlc/{discuss.playbook/discuss.playbook.ts → decide.playbook/decide.playbook.ts} +575 -443
  33. package/reference/sdlc/decide.playbook/decide.registry.d.ts +41 -0
  34. package/reference/sdlc/decide.playbook/decide.registry.js +60 -0
  35. package/reference/sdlc/decide.playbook/decide.registry.ts +125 -0
  36. package/reference/sdlc/review.md +81 -0
  37. package/reference/sdlc/review.playbook/review.fsm.d.ts +183 -0
  38. package/reference/sdlc/review.playbook/review.fsm.js +524 -0
  39. package/reference/sdlc/review.playbook/review.fsm.ts +652 -0
  40. package/reference/sdlc/review.playbook/review.gears.md +112 -0
  41. package/reference/sdlc/review.playbook/review.playbook.d.ts +12 -0
  42. package/reference/sdlc/review.playbook/review.playbook.js +112 -0
  43. package/reference/sdlc/review.playbook/review.playbook.ts +201 -0
  44. package/reference/sdlc/review.playbook/review.registry.d.ts +43 -0
  45. package/reference/sdlc/review.playbook/review.registry.js +73 -0
  46. package/reference/sdlc/review.playbook/review.registry.ts +138 -0
  47. package/slc/gears2fsm.md +13 -4
  48. package/slc/link.md +48 -2
  49. package/slc/text2gears.md +22 -2
  50. package/src/runtime.d.ts +7 -0
  51. package/src/runtime.ts +12 -0
  52. package/src/xstate-playbook-runtime.d.ts +9 -2
  53. package/src/xstate-playbook-runtime.js +255 -21
  54. package/src/xstate-playbook-runtime.ts +333 -28
  55. package/src/xstate-runtime.js +25 -0
  56. package/src/xstate-runtime.ts +51 -0
  57. package/reference/sdlc/discuss.md +0 -93
  58. package/reference/sdlc/discuss.playbook/discuss.fsm.d.ts +0 -396
  59. package/reference/sdlc/discuss.playbook/discuss.fsm.js +0 -2067
  60. package/reference/sdlc/discuss.playbook/discuss.fsm.ts +0 -2465
  61. package/reference/sdlc/discuss.playbook/discuss.gears.md +0 -258
  62. package/reference/sdlc/discuss.playbook/discuss.playbook.d.ts +0 -113
  63. package/reference/sdlc/discuss.playbook/discuss.registry.d.ts +0 -58
  64. package/reference/sdlc/discuss.playbook/discuss.registry.js +0 -97
  65. package/reference/sdlc/discuss.playbook/discuss.registry.ts +0 -153
@@ -150,6 +150,12 @@ export interface ScheduledStatus {
150
150
  data?: JsonValue;
151
151
  }
152
152
 
153
+ /** Boss-facing identity for one FSM state whose invoked actor is `player`. */
154
+ export interface XStatePlayerStateStatus {
155
+ player: string;
156
+ label: string;
157
+ }
158
+
153
159
  export interface XStateBossEventFieldSpec {
154
160
  /** The judge supplies routing data; the runtime supplies exact Boss text. */
155
161
  source: 'judge' | 'text';
@@ -391,8 +397,10 @@ export interface XStatePlaybookRuntimeSpec<TOptions> {
391
397
  * recoverable FSM-result failures instead.
392
398
  */
393
399
  captainStrategy?: XStateCaptainStrategy<TOptions>;
394
- /** Status line emitted after classification names an event. Default: none. */
400
+ /** Status line emitted after classification; canonical metadata defaults to the event type, legacy artifacts to none. */
395
401
  classificationStatus?: (event: EventObject) => string | undefined;
402
+ /** Complete FSM-derived Boss-facing metadata for every `player` state; its presence selects the canonical status profile. */
403
+ playerStates?: Readonly<Record<string, XStatePlayerStateStatus>>;
396
404
  /** Map a player-invoking state's input to the host player id. Default: lowercased player name. */
397
405
  resolvePlayerId?: (input: PlaybookPlayerInput, options: TOptions) => string;
398
406
  /** Compose the player prompt. Default: continuation blocks + `<field>` placeholder substitution. */
@@ -421,7 +429,7 @@ export interface XStatePlaybookRuntimeSpec<TOptions> {
421
429
  controlContextFields?: readonly string[];
422
430
  /** States that may suspend for a Boss reply. Default: targets of the FSM's `awaitBossReply` BOSS_REPLY transitions. */
423
431
  resumableStateIds?: ReadonlySet<string>;
424
- /** Human status lines for a root transition. Default: entry lines with question/failure surfacing. */
432
+ /** Human status lines for a root transition. Default: guard, declared-player, question, and failure lines. */
425
433
  statusesForState?: (
426
434
  state: PlaybookState,
427
435
  context: Record<string, unknown>,
@@ -1055,6 +1063,60 @@ function collectInvokeSources(machine: AnyStateMachine): ReadonlySet<string> {
1055
1063
  return sources;
1056
1064
  }
1057
1065
 
1066
+ function collectPlayerStatePlayers(
1067
+ machine: AnyStateMachine,
1068
+ ): ReadonlyMap<string, string> {
1069
+ const players = new Map<string, string>();
1070
+ const visit = (stateDef: unknown, stateKey: string): void => {
1071
+ if (!isPlainObject(stateDef)) return;
1072
+ const invoke = stateDef.invoke;
1073
+ const invokes = Array.isArray(invoke) ? invoke : invoke ? [invoke] : [];
1074
+ if (
1075
+ invokes.some(
1076
+ (entry) =>
1077
+ isPlainObject(entry) && entry.src === 'player',
1078
+ )
1079
+ ) {
1080
+ const playbookMeta = isPlainObject(stateDef.meta)
1081
+ ? stateDef.meta.playbook
1082
+ : undefined;
1083
+ const stateId =
1084
+ isPlainObject(playbookMeta) &&
1085
+ typeof playbookMeta.stateId === 'string'
1086
+ ? playbookMeta.stateId
1087
+ : typeof stateDef.id === 'string'
1088
+ ? stateDef.id
1089
+ : stateKey;
1090
+ if (stateId.trim().length === 0) {
1091
+ throw new TypeError(
1092
+ 'player state metadata must use a non-empty state id',
1093
+ );
1094
+ }
1095
+ const player = isPlainObject(playbookMeta)
1096
+ ? playbookMeta.player
1097
+ : undefined;
1098
+ if (typeof player !== 'string' || player.trim().length === 0) {
1099
+ throw new TypeError(
1100
+ `player state ${stateId} meta.playbook.player must be a non-empty string`,
1101
+ );
1102
+ }
1103
+ players.set(stateId, player);
1104
+ }
1105
+ if (isPlainObject(stateDef.states)) {
1106
+ for (const [childKey, child] of Object.entries(stateDef.states)) {
1107
+ visit(child, childKey);
1108
+ }
1109
+ }
1110
+ };
1111
+ const config = (machine as unknown as { config?: unknown }).config;
1112
+ if (isPlainObject(config) && isPlainObject(config.states)) {
1113
+ for (const [stateKey, stateDef] of Object.entries(config.states)) {
1114
+ visit(stateDef, stateKey);
1115
+ }
1116
+ }
1117
+ return players;
1118
+ }
1119
+
1058
1120
  function transitionTargets(transition: unknown): string[] {
1059
1121
  const arms = Array.isArray(transition) ? transition : [transition];
1060
1122
  const targets: string[] = [];
@@ -1206,7 +1268,70 @@ function makeDefaultNormalizeTransitionEvent(
1206
1268
  };
1207
1269
  }
1208
1270
 
1209
- function defaultStatusesForState(
1271
+ function snapshotPlayerStateStatuses(
1272
+ value: Readonly<Record<string, XStatePlayerStateStatus>> | undefined,
1273
+ label: string,
1274
+ machine: AnyStateMachine,
1275
+ stateDescriptions: ReadonlyMap<string, string>,
1276
+ ): ReadonlyMap<string, XStatePlayerStateStatus> {
1277
+ if (value === undefined) return new Map();
1278
+ if (!isPlainObject(value)) {
1279
+ throw new TypeError(`${label} playerStates must be an object`);
1280
+ }
1281
+ const declared = collectPlayerStatePlayers(machine);
1282
+ const statuses = new Map<string, XStatePlayerStateStatus>();
1283
+ for (const [stateId, candidate] of Object.entries(value)) {
1284
+ if (!declared.has(stateId)) {
1285
+ throw new TypeError(
1286
+ `${label} playerStates.${stateId} does not name a player state`,
1287
+ );
1288
+ }
1289
+ if (
1290
+ !isPlainObject(candidate) ||
1291
+ typeof candidate.player !== 'string' ||
1292
+ candidate.player.trim().length === 0 ||
1293
+ typeof candidate.label !== 'string' ||
1294
+ candidate.label.trim().length === 0
1295
+ ) {
1296
+ throw new TypeError(
1297
+ `${label} playerStates.${stateId} must carry non-empty player and label strings`,
1298
+ );
1299
+ }
1300
+ const expectedLabel = stateDescriptions.get(stateId);
1301
+ if (candidate.label !== expectedLabel) {
1302
+ throw new TypeError(
1303
+ `${label} playerStates.${stateId}.label must equal its FSM description`,
1304
+ );
1305
+ }
1306
+ if (candidate.player !== declared.get(stateId)) {
1307
+ throw new TypeError(
1308
+ `${label} playerStates.${stateId}.player must equal its FSM player`,
1309
+ );
1310
+ }
1311
+ statuses.set(stateId, {
1312
+ player: candidate.player,
1313
+ label: candidate.label,
1314
+ });
1315
+ }
1316
+ for (const stateId of declared.keys()) {
1317
+ if (!statuses.has(stateId)) {
1318
+ throw new TypeError(
1319
+ `${label} playerStates must declare player state ${stateId}`,
1320
+ );
1321
+ }
1322
+ }
1323
+ return statuses;
1324
+ }
1325
+
1326
+ function settlingGuard(event: unknown): string | undefined {
1327
+ if (!isPlainObject(event) || !isPlainObject(event.output)) return undefined;
1328
+ const guard = event.output.guard;
1329
+ return typeof guard === 'string' && guard.trim().length > 0
1330
+ ? guard
1331
+ : undefined;
1332
+ }
1333
+
1334
+ function legacyStatusesForState(
1210
1335
  state: PlaybookState,
1211
1336
  context: Record<string, unknown>,
1212
1337
  ): ScheduledStatus[] {
@@ -1214,11 +1339,14 @@ function defaultStatusesForState(
1214
1339
  if (stateId === undefined || SUPPRESSED_ENTRY_STATES.has(stateId)) return [];
1215
1340
  if (stateId === 'awaitBossReply') {
1216
1341
  const pending = pendingBossQuestionFromContext(context);
1217
- const message =
1218
- pending === undefined
1219
- ? 'Awaiting Boss reply.'
1220
- : `${pending.player} asks: ${pending.question}`;
1221
- return [{ message }];
1342
+ return [
1343
+ {
1344
+ message:
1345
+ pending === undefined
1346
+ ? 'Awaiting Boss reply.'
1347
+ : `${pending.player} asks: ${pending.question}`,
1348
+ },
1349
+ ];
1222
1350
  }
1223
1351
  if (stateId === 'failed') {
1224
1352
  const lastError = normalizeErrorFull(context.lastError);
@@ -1234,6 +1362,60 @@ function defaultStatusesForState(
1234
1362
  return [{ message: `Entered ${stateId}.` }];
1235
1363
  }
1236
1364
 
1365
+ function makeDefaultStatusesForState(
1366
+ playerStates: ReadonlyMap<string, XStatePlayerStateStatus>,
1367
+ ): NonNullable<XStatePlaybookRuntimeSpec<unknown>['statusesForState']> {
1368
+ return (state, context, event): ScheduledStatus[] => {
1369
+ const statuses: ScheduledStatus[] = [];
1370
+ const guard = settlingGuard(event);
1371
+ if (guard !== undefined) statuses.push({ message: `→ ${guard}` });
1372
+
1373
+ const stateId = state.stateId;
1374
+ if (stateId === undefined || SUPPRESSED_ENTRY_STATES.has(stateId)) {
1375
+ return statuses;
1376
+ }
1377
+ if (stateId === 'awaitBossReply') {
1378
+ const pending = pendingBossQuestionFromContext(context);
1379
+ if (pending === undefined) {
1380
+ return [...statuses, { message: 'Awaiting Boss reply.' }];
1381
+ }
1382
+ return [
1383
+ ...statuses,
1384
+ { message: `${pending.player} asks: ${pending.question}` },
1385
+ {
1386
+ message:
1387
+ `◆ awaiting Boss reply · ${pending.resumeStateId} · ` +
1388
+ `${pending.player} · ${pending.sourceItem}`,
1389
+ },
1390
+ ];
1391
+ }
1392
+ if (stateId === 'failed') {
1393
+ const lastError = normalizeErrorCompact(context.lastError);
1394
+ return [
1395
+ ...statuses,
1396
+ {
1397
+ message: '◆ workflow failed; awaiting Boss recovery.',
1398
+ ...(lastError === undefined
1399
+ ? {}
1400
+ : {
1401
+ data: snapshotJsonValue(
1402
+ { lastError },
1403
+ 'failed status data',
1404
+ ),
1405
+ }),
1406
+ },
1407
+ ];
1408
+ }
1409
+ const playerState = playerStates.get(stateId);
1410
+ if (playerState !== undefined) {
1411
+ statuses.push({
1412
+ message: `⤷ ${playerState.player}: ${playerState.label}`,
1413
+ });
1414
+ }
1415
+ return statuses;
1416
+ };
1417
+ }
1418
+
1237
1419
  // ---------------------------------------------------------------------------
1238
1420
  // Default parked-state classifier (slc/link.md §Boss-event mapping): the
1239
1421
  // runtime-owned textual fields are never requested from the judge; only the
@@ -1675,6 +1857,13 @@ export function createXStatePlaybookRuntime<TOptions>(
1675
1857
  // DR-029: source state descriptions label the control actions the
1676
1858
  // runtime advertises through `describe()`.
1677
1859
  const stateDescriptions = stateDescriptionsFromMachine(machine);
1860
+ const hasCanonicalStatusProfile = spec.playerStates !== undefined;
1861
+ const playerStates = snapshotPlayerStateStatuses(
1862
+ spec.playerStates,
1863
+ label,
1864
+ machine,
1865
+ stateDescriptions,
1866
+ );
1678
1867
  // PBRT-52: the artifact's own ControlView context projection. Nothing is
1679
1868
  // exported by default, so an FSM context member — including one added
1680
1869
  // after this artifact was linked — is private until named here. The two
@@ -1728,7 +1917,16 @@ export function createXStatePlaybookRuntime<TOptions>(
1728
1917
  const normalizeTransitionEvent =
1729
1918
  spec.normalizeTransitionEvent ??
1730
1919
  makeDefaultNormalizeTransitionEvent(spec.transitionEventFields ?? []);
1731
- const statusesForState = spec.statusesForState ?? defaultStatusesForState;
1920
+ const statusesForState =
1921
+ spec.statusesForState ??
1922
+ (hasCanonicalStatusProfile
1923
+ ? makeDefaultStatusesForState(playerStates)
1924
+ : legacyStatusesForState);
1925
+ const classificationStatus =
1926
+ spec.classificationStatus ??
1927
+ (hasCanonicalStatusProfile
1928
+ ? (event: EventObject) => event.type
1929
+ : () => undefined);
1732
1930
  const machineInput =
1733
1931
  spec.machineInput ?? ((options: TOptions) => options as unknown);
1734
1932
  const scriptCwd =
@@ -1793,6 +1991,71 @@ export function createXStatePlaybookRuntime<TOptions>(
1793
1991
  // imperative boundaries await their queued work directly.
1794
1992
  let emissionFailure: unknown;
1795
1993
 
1994
+ function selectPlayerResume(playerId: string): string | false {
1995
+ const selected = session?.playerSessions
1996
+ ? session.playerSessions.select(playerId)
1997
+ : playerResumeTokens.get(playerId) ?? false;
1998
+ if (
1999
+ selected !== false &&
2000
+ (typeof selected !== 'string' || selected.trim().length === 0)
2001
+ ) {
2002
+ throw new TypeError(
2003
+ `player session store returned an invalid resume token for ${playerId}`,
2004
+ );
2005
+ }
2006
+ return selected;
2007
+ }
2008
+
2009
+ function updatePlayerResume(
2010
+ playerId: string,
2011
+ resumeToken: string | undefined,
2012
+ ): void {
2013
+ if (session?.playerSessions) {
2014
+ session.playerSessions.update(playerId, resumeToken);
2015
+ } else if (resumeToken !== undefined && resumeToken.trim().length > 0) {
2016
+ playerResumeTokens.set(playerId, resumeToken);
2017
+ } else {
2018
+ playerResumeTokens.delete(playerId);
2019
+ }
2020
+ }
2021
+
2022
+ function snapshotPlayerResumeTokens(): Record<string, string> {
2023
+ const raw = session?.playerSessions
2024
+ ? session.playerSessions.snapshot()
2025
+ : Object.fromEntries(playerResumeTokens);
2026
+ if (!isPlainObject(raw)) {
2027
+ throw new TypeError('player session store snapshot must be an object');
2028
+ }
2029
+ const detached: Record<string, string> = {};
2030
+ for (const [playerId, token] of Object.entries(raw)) {
2031
+ if (playerId.trim().length === 0) {
2032
+ throw new TypeError(
2033
+ 'player session store snapshot player ids must be non-empty',
2034
+ );
2035
+ }
2036
+ if (typeof token !== 'string' || token.trim().length === 0) {
2037
+ throw new TypeError(
2038
+ `player session store snapshot token for ${playerId} must be a non-empty string`,
2039
+ );
2040
+ }
2041
+ detached[playerId] = token;
2042
+ }
2043
+ return detached;
2044
+ }
2045
+
2046
+ function restorePlayerResumeTokens(
2047
+ tokens: Readonly<Record<string, string>>,
2048
+ ): void {
2049
+ if (session?.playerSessions) {
2050
+ session.playerSessions.restore(tokens);
2051
+ return;
2052
+ }
2053
+ playerResumeTokens.clear();
2054
+ for (const [playerId, token] of Object.entries(tokens)) {
2055
+ playerResumeTokens.set(playerId, token);
2056
+ }
2057
+ }
2058
+
1796
2059
  function enqueueEmission(fn: () => Promise<void>): Promise<void> {
1797
2060
  const queued = emissionQueue.add(fn).then(() => undefined);
1798
2061
  activeEmissionCalls.add(queued);
@@ -2001,9 +2264,16 @@ export function createXStatePlaybookRuntime<TOptions>(
2001
2264
  // State-entry telemetry/status must precede the call they describe.
2002
2265
  await drainEmissions();
2003
2266
  const turnId = activeTurnId;
2004
- const callId = `player-${++playerCallSequence}`;
2005
2267
  const stateId = input.stateId;
2006
- const resume = playerResumeTokens.get(playerId) ?? false;
2268
+ let resume: string | false;
2269
+ try {
2270
+ signal.throwIfAborted();
2271
+ resume = selectPlayerResume(playerId);
2272
+ } catch (error) {
2273
+ if (!signal.aborted) controlPlaneError ??= error;
2274
+ throw error;
2275
+ }
2276
+ const callId = `player-${++playerCallSequence}`;
2007
2277
  const identity = {
2008
2278
  purpose: 'captain' as const,
2009
2279
  ...stateIdentity(stateId),
@@ -2095,13 +2365,26 @@ export function createXStatePlaybookRuntime<TOptions>(
2095
2365
  throw error;
2096
2366
  }
2097
2367
 
2098
- if (
2099
- typeof result.resumeToken === 'string' &&
2100
- result.resumeToken.trim().length > 0
2101
- ) {
2102
- playerResumeTokens.set(playerId, result.resumeToken);
2103
- } else {
2104
- playerResumeTokens.delete(playerId);
2368
+ try {
2369
+ updatePlayerResume(
2370
+ playerId,
2371
+ typeof result.resumeToken === 'string' &&
2372
+ result.resumeToken.trim().length > 0
2373
+ ? result.resumeToken
2374
+ : undefined,
2375
+ );
2376
+ } catch (error) {
2377
+ if (!signal.aborted) controlPlaneError ??= error;
2378
+ try {
2379
+ await emitTrace(
2380
+ 'player.call.finished',
2381
+ { ...identity, status: 'error', error: normalizeError(error) },
2382
+ position,
2383
+ );
2384
+ } catch {
2385
+ // The continuation-store failure remains authoritative.
2386
+ }
2387
+ throw error;
2105
2388
  }
2106
2389
 
2107
2390
  await emitTrace(
@@ -3099,7 +3382,7 @@ export function createXStatePlaybookRuntime<TOptions>(
3099
3382
  schemaVersion: 1,
3100
3383
  playbookId: session.playbookId,
3101
3384
  machine: machineSnapshot,
3102
- playerResumeTokens: Object.fromEntries(playerResumeTokens),
3385
+ playerResumeTokens: snapshotPlayerResumeTokens(),
3103
3386
  sequences: {
3104
3387
  trace: traceSequence,
3105
3388
  turn: turnSequence,
@@ -3142,6 +3425,10 @@ export function createXStatePlaybookRuntime<TOptions>(
3142
3425
  snapshot,
3143
3426
  boundSession.playbookId,
3144
3427
  );
3428
+ let priorExternalPlayerTokens:
3429
+ | Readonly<Record<string, string>>
3430
+ | undefined;
3431
+ let externalStoreRestoreAttempted = false;
3145
3432
  initialized = true;
3146
3433
  let finishInitialization!: () => void;
3147
3434
  const initialization = new Promise<void>((resolve) => {
@@ -3168,12 +3455,11 @@ export function createXStatePlaybookRuntime<TOptions>(
3168
3455
  // the persisted trace counter is a collision-safe id floor here
3169
3456
  // too, keeping `apply-<n>` call ids unique across restore.
3170
3457
  applyCallSequence = boundSnapshot.sequences.trace;
3171
- playerResumeTokens.clear();
3172
- for (const [playerId, token] of Object.entries(
3173
- boundSnapshot.playerResumeTokens,
3174
- )) {
3175
- playerResumeTokens.set(playerId, token);
3458
+ if (boundSession.playerSessions) {
3459
+ priorExternalPlayerTokens = snapshotPlayerResumeTokens();
3460
+ externalStoreRestoreAttempted = true;
3176
3461
  }
3462
+ restorePlayerResumeTokens(boundSnapshot.playerResumeTokens);
3177
3463
  suppressInspectionEmissions = true;
3178
3464
  actor = buildActor(runtimePorts, boundSnapshot.machine);
3179
3465
  actor.start();
@@ -3190,8 +3476,22 @@ export function createXStatePlaybookRuntime<TOptions>(
3190
3476
  try {
3191
3477
  await initTask;
3192
3478
  } catch (error) {
3193
- await cleanupFailedStart(error, { emitDisposal: false });
3194
- throw error;
3479
+ let failure = error;
3480
+ if (
3481
+ externalStoreRestoreAttempted &&
3482
+ priorExternalPlayerTokens !== undefined
3483
+ ) {
3484
+ try {
3485
+ boundSession.playerSessions!.restore(priorExternalPlayerTokens);
3486
+ } catch (rollbackError) {
3487
+ failure = new AggregateError(
3488
+ [error, rollbackError],
3489
+ 'createPlaybookRuntime.restore and player continuation rollback failed',
3490
+ );
3491
+ }
3492
+ }
3493
+ await cleanupFailedStart(failure, { emitDisposal: false });
3494
+ throw failure;
3195
3495
  } finally {
3196
3496
  finishInitialization();
3197
3497
  if (initInFlight === initialization) initInFlight = undefined;
@@ -3607,7 +3907,7 @@ export function createXStatePlaybookRuntime<TOptions>(
3607
3907
  } else {
3608
3908
  // 2. Optional Captain-pane classification line: the bare FSM
3609
3909
  // event type, emitted before the FSM advances.
3610
- const statusLine = spec.classificationStatus?.(event);
3910
+ const statusLine = classificationStatus(event);
3611
3911
  if (statusLine !== undefined) {
3612
3912
  await runtimePorts!.emitStatus(statusLine);
3613
3913
  }
@@ -3806,7 +4106,12 @@ export function createXStatePlaybookRuntime<TOptions>(
3806
4106
  }
3807
4107
  }
3808
4108
  } finally {
3809
- playerResumeTokens.clear();
4109
+ // A composing host owns the shared store for the complete root
4110
+ // engagement tree. Child disposal must not erase a token its
4111
+ // caller will resume. The private fallback remains runtime-owned.
4112
+ if (session?.playerSessions === undefined) {
4113
+ playerResumeTokens.clear();
4114
+ }
3810
4115
  activePlayerIds.clear();
3811
4116
  playbookCallTurnIds.clear();
3812
4117
  activeEmissionCalls.clear();
@@ -248,6 +248,26 @@ function capturedPort(descriptors, name) {
248
248
  }
249
249
  return value;
250
250
  }
251
+ function capturedSessionStore(descriptors) {
252
+ const captured = capturedDataValue(descriptors, 'playerSessions', 'playbook session playerSessions');
253
+ if (!isRecord(captured)) {
254
+ throw new TypeError('playbook session playerSessions must be an object');
255
+ }
256
+ const storeDescriptors = Object.getOwnPropertyDescriptors(captured);
257
+ const method = (name) => {
258
+ const value = capturedDataValue(storeDescriptors, name, `playbook session playerSessions.${name}`);
259
+ if (typeof value !== 'function') {
260
+ throw new TypeError(`playbook session playerSessions.${name} must be a function`);
261
+ }
262
+ return value;
263
+ };
264
+ return Object.freeze({
265
+ select: method('select'),
266
+ update: method('update'),
267
+ snapshot: method('snapshot'),
268
+ restore: method('restore'),
269
+ });
270
+ }
251
271
  /** Validate session causality and detach its immutable identity from the host. */
252
272
  export function snapshotPlaybookSession(session) {
253
273
  if (!isRecord(session)) {
@@ -266,6 +286,7 @@ export function snapshotPlaybookSession(session) {
266
286
  const capturedParentCallId = capturedDataValue(sessionDescriptors, 'parentCallId', 'playbook session parentCallId', false);
267
287
  const hasParentSessionId = Object.prototype.hasOwnProperty.call(sessionDescriptors, 'parentSessionId');
268
288
  const hasParentCallId = Object.prototype.hasOwnProperty.call(sessionDescriptors, 'parentCallId');
289
+ const hasPlayerSessions = Object.prototype.hasOwnProperty.call(sessionDescriptors, 'playerSessions');
269
290
  let parentSessionId;
270
291
  let parentCallId;
271
292
  if (depth === 0) {
@@ -296,6 +317,9 @@ export function snapshotPlaybookSession(session) {
296
317
  emitStatus: capturedPort(portDescriptors, 'emitStatus'),
297
318
  emitTelemetry: capturedPort(portDescriptors, 'emitTelemetry'),
298
319
  });
320
+ const playerSessions = hasPlayerSessions
321
+ ? capturedSessionStore(sessionDescriptors)
322
+ : undefined;
299
323
  return Object.freeze({
300
324
  sessionId,
301
325
  playbookId,
@@ -303,6 +327,7 @@ export function snapshotPlaybookSession(session) {
303
327
  ...(parentSessionId === undefined ? {} : { parentSessionId }),
304
328
  ...(parentCallId === undefined ? {} : { parentCallId }),
305
329
  depth,
330
+ ...(playerSessions === undefined ? {} : { playerSessions }),
306
331
  ports,
307
332
  });
308
333
  }
@@ -342,6 +342,49 @@ function capturedPort<K extends keyof PlaybookSession['ports']>(
342
342
  return value as PlaybookSession['ports'][K];
343
343
  }
344
344
 
345
+ function capturedSessionStore(
346
+ descriptors: PropertyDescriptorMap,
347
+ ): PlaybookSession['playerSessions'] {
348
+ const captured = capturedDataValue(
349
+ descriptors,
350
+ 'playerSessions',
351
+ 'playbook session playerSessions',
352
+ );
353
+ if (!isRecord(captured)) {
354
+ throw new TypeError('playbook session playerSessions must be an object');
355
+ }
356
+ const storeDescriptors = Object.getOwnPropertyDescriptors(captured);
357
+ const method = (
358
+ name: keyof NonNullable<PlaybookSession['playerSessions']>,
359
+ ): ((...args: never[]) => unknown) => {
360
+ const value = capturedDataValue(
361
+ storeDescriptors,
362
+ name,
363
+ `playbook session playerSessions.${name}`,
364
+ );
365
+ if (typeof value !== 'function') {
366
+ throw new TypeError(
367
+ `playbook session playerSessions.${name} must be a function`,
368
+ );
369
+ }
370
+ return value as (...args: never[]) => unknown;
371
+ };
372
+ return Object.freeze({
373
+ select: method('select') as NonNullable<
374
+ PlaybookSession['playerSessions']
375
+ >['select'],
376
+ update: method('update') as NonNullable<
377
+ PlaybookSession['playerSessions']
378
+ >['update'],
379
+ snapshot: method('snapshot') as NonNullable<
380
+ PlaybookSession['playerSessions']
381
+ >['snapshot'],
382
+ restore: method('restore') as NonNullable<
383
+ PlaybookSession['playerSessions']
384
+ >['restore'],
385
+ });
386
+ }
387
+
345
388
  /** Validate session causality and detach its immutable identity from the host. */
346
389
  export function snapshotPlaybookSession(
347
390
  session: PlaybookSession,
@@ -405,6 +448,10 @@ export function snapshotPlaybookSession(
405
448
  sessionDescriptors,
406
449
  'parentCallId',
407
450
  );
451
+ const hasPlayerSessions = Object.prototype.hasOwnProperty.call(
452
+ sessionDescriptors,
453
+ 'playerSessions',
454
+ );
408
455
  let parentSessionId: string | undefined;
409
456
  let parentCallId: string | undefined;
410
457
  if (depth === 0) {
@@ -450,6 +497,9 @@ export function snapshotPlaybookSession(
450
497
  emitStatus: capturedPort(portDescriptors, 'emitStatus'),
451
498
  emitTelemetry: capturedPort(portDescriptors, 'emitTelemetry'),
452
499
  });
500
+ const playerSessions = hasPlayerSessions
501
+ ? capturedSessionStore(sessionDescriptors)
502
+ : undefined;
453
503
  return Object.freeze({
454
504
  sessionId,
455
505
  playbookId,
@@ -457,6 +507,7 @@ export function snapshotPlaybookSession(
457
507
  ...(parentSessionId === undefined ? {} : { parentSessionId }),
458
508
  ...(parentCallId === undefined ? {} : { parentCallId }),
459
509
  depth,
510
+ ...(playerSessions === undefined ? {} : { playerSessions }),
460
511
  ports,
461
512
  });
462
513
  }