@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
@@ -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) => {
@@ -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,
@@ -2231,7 +2447,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
2231
2447
  schemaVersion: 1,
2232
2448
  playbookId: session.playbookId,
2233
2449
  machine: machineSnapshot,
2234
- playerResumeTokens: Object.fromEntries(playerResumeTokens),
2450
+ playerResumeTokens: snapshotPlayerResumeTokens(),
2235
2451
  sequences: {
2236
2452
  trace: traceSequence,
2237
2453
  turn: turnSequence,
@@ -2266,6 +2482,8 @@ export function createXStatePlaybookRuntime(machine, spec) {
2266
2482
  }
2267
2483
  const boundSession = snapshotPlaybookSession(nextSession);
2268
2484
  const boundSnapshot = assertPlaybookRuntimeSnapshot(snapshot, boundSession.playbookId);
2485
+ let priorExternalPlayerTokens;
2486
+ let externalStoreRestoreAttempted = false;
2269
2487
  initialized = true;
2270
2488
  let finishInitialization;
2271
2489
  const initialization = new Promise((resolve) => {
@@ -2292,10 +2510,11 @@ export function createXStatePlaybookRuntime(machine, spec) {
2292
2510
  // the persisted trace counter is a collision-safe id floor here
2293
2511
  // too, keeping `apply-<n>` call ids unique across restore.
2294
2512
  applyCallSequence = boundSnapshot.sequences.trace;
2295
- playerResumeTokens.clear();
2296
- for (const [playerId, token] of Object.entries(boundSnapshot.playerResumeTokens)) {
2297
- playerResumeTokens.set(playerId, token);
2513
+ if (boundSession.playerSessions) {
2514
+ priorExternalPlayerTokens = snapshotPlayerResumeTokens();
2515
+ externalStoreRestoreAttempted = true;
2298
2516
  }
2517
+ restorePlayerResumeTokens(boundSnapshot.playerResumeTokens);
2299
2518
  suppressInspectionEmissions = true;
2300
2519
  actor = buildActor(runtimePorts, boundSnapshot.machine);
2301
2520
  actor.start();
@@ -2311,8 +2530,18 @@ export function createXStatePlaybookRuntime(machine, spec) {
2311
2530
  await initTask;
2312
2531
  }
2313
2532
  catch (error) {
2314
- await cleanupFailedStart(error, { emitDisposal: false });
2315
- throw error;
2533
+ let failure = error;
2534
+ if (externalStoreRestoreAttempted &&
2535
+ priorExternalPlayerTokens !== undefined) {
2536
+ try {
2537
+ boundSession.playerSessions.restore(priorExternalPlayerTokens);
2538
+ }
2539
+ catch (rollbackError) {
2540
+ failure = new AggregateError([error, rollbackError], 'createPlaybookRuntime.restore and player continuation rollback failed');
2541
+ }
2542
+ }
2543
+ await cleanupFailedStart(failure, { emitDisposal: false });
2544
+ throw failure;
2316
2545
  }
2317
2546
  finally {
2318
2547
  finishInitialization();
@@ -2662,7 +2891,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
2662
2891
  else {
2663
2892
  // 2. Optional Captain-pane classification line: the bare FSM
2664
2893
  // event type, emitted before the FSM advances.
2665
- const statusLine = spec.classificationStatus?.(event);
2894
+ const statusLine = classificationStatus(event);
2666
2895
  if (statusLine !== undefined) {
2667
2896
  await runtimePorts.emitStatus(statusLine);
2668
2897
  }
@@ -2843,7 +3072,12 @@ export function createXStatePlaybookRuntime(machine, spec) {
2843
3072
  }
2844
3073
  }
2845
3074
  finally {
2846
- playerResumeTokens.clear();
3075
+ // A composing host owns the shared store for the complete root
3076
+ // engagement tree. Child disposal must not erase a token its
3077
+ // caller will resume. The private fallback remains runtime-owned.
3078
+ if (session?.playerSessions === undefined) {
3079
+ playerResumeTokens.clear();
3080
+ }
2847
3081
  activePlayerIds.clear();
2848
3082
  playbookCallTurnIds.clear();
2849
3083
  activeEmissionCalls.clear();