@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
@@ -1,8 +1,9 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
3
  import { randomUUID } from 'node:crypto';
4
+ import { isDeepStrictEqual } from 'node:util';
4
5
  import PQueue from 'p-queue';
5
- import { hiddenControlEnvelope, registerPlaybookAbortCleanup, } from '../../../src/xstate-runtime.js';
6
+ import { assertPlaybookRuntimeSnapshot, hiddenControlEnvelope, registerPlaybookAbortCleanup, snapshotJsonValue, } from '../../../src/xstate-runtime.js';
6
7
  import createDefaultCaptainRuntime from '../captain.playbook/captain.playbook.js';
7
8
  class VisibilityControlError extends Error {
8
9
  constructor(cause) {
@@ -414,6 +415,267 @@ function isValidRegistryEntry(value) {
414
415
  typeof e.validateOptions === 'function' &&
415
416
  typeof e.createRuntime === 'function');
416
417
  }
418
+ const SNAPSHOT_ACTIONS = new Set([
419
+ 'respond',
420
+ 'start',
421
+ 'switch',
422
+ 'dismiss',
423
+ 'deliver',
424
+ 'runtime',
425
+ ]);
426
+ const SNAPSHOT_SETTLEMENT_STATUSES = new Set([
427
+ 'ok',
428
+ 'rejected',
429
+ 'failed',
430
+ ]);
431
+ const SNAPSHOT_JOURNAL_KINDS = new Set([
432
+ 'boss',
433
+ 'reply',
434
+ 'handoff',
435
+ 'action',
436
+ 'outcome',
437
+ ]);
438
+ function snapshotRecord(value, path) {
439
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
440
+ throw new TypeError(`${path} must be an object`);
441
+ }
442
+ return value;
443
+ }
444
+ function rejectSnapshotKeys(value, allowed, path) {
445
+ const allowedKeys = new Set(allowed);
446
+ const unknown = Object.keys(value).filter((key) => !allowedKeys.has(key));
447
+ if (unknown.length > 0) {
448
+ throw new TypeError(`${path} has unknown field ${JSON.stringify(unknown[0])}`);
449
+ }
450
+ }
451
+ function snapshotString(value, path, allowEmpty = false) {
452
+ if (typeof value !== 'string' ||
453
+ (!allowEmpty && value.trim().length === 0)) {
454
+ throw new TypeError(`${path} must be a ${allowEmpty ? '' : 'non-empty '}string`);
455
+ }
456
+ return value;
457
+ }
458
+ function snapshotInteger(value, path, minimum = 0) {
459
+ if (!Number.isSafeInteger(value) || value < minimum) {
460
+ throw new TypeError(`${path} must be an integer >= ${minimum}`);
461
+ }
462
+ return value;
463
+ }
464
+ function snapshotUuid(value, path) {
465
+ const id = snapshotString(value, path);
466
+ if (!UUID_PATTERN.test(id)) {
467
+ throw new TypeError(`${path} must be a UUID`);
468
+ }
469
+ return id;
470
+ }
471
+ /** Validate, detach, and freeze one untrusted shell snapshot. */
472
+ function assertPlaybookCaptainShellSnapshot(value) {
473
+ const detached = snapshotJsonValue(value, 'Captain shell snapshot');
474
+ const snapshot = snapshotRecord(detached, 'Captain shell snapshot');
475
+ const mode = snapshot.mode;
476
+ const commonKeys = [
477
+ 'schemaVersion',
478
+ 'captain',
479
+ 'issuedSessionIds',
480
+ 'sequences',
481
+ 'journal',
482
+ 'lastAction',
483
+ 'lastSettlementStatus',
484
+ 'mode',
485
+ ];
486
+ if (mode === 'chat') {
487
+ rejectSnapshotKeys(snapshot, commonKeys, 'Captain shell snapshot');
488
+ }
489
+ else if (mode === 'engaged.parked') {
490
+ rejectSnapshotKeys(snapshot, [
491
+ ...commonKeys,
492
+ 'frames',
493
+ 'rootPlayerResumeTokens',
494
+ 'pendingBossQuestions',
495
+ 'lastError',
496
+ ], 'Captain shell snapshot');
497
+ }
498
+ else {
499
+ throw new TypeError('Captain shell snapshot.mode must be "chat" or "engaged.parked"');
500
+ }
501
+ if (snapshot.schemaVersion !== 1) {
502
+ throw new TypeError(`Captain shell snapshot.schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 1)`);
503
+ }
504
+ const captain = snapshotRecord(snapshot.captain, 'Captain shell snapshot.captain');
505
+ rejectSnapshotKeys(captain, ['sessionId', 'runtime', 'conversation'], 'Captain shell snapshot.captain');
506
+ const captainSessionId = snapshotUuid(captain.sessionId, 'Captain shell snapshot.captain.sessionId');
507
+ const captainRuntime = assertPlaybookRuntimeSnapshot(captain.runtime, INTERNAL_CAPTAIN_ID);
508
+ const conversation = snapshotRecord(captain.conversation, 'Captain shell snapshot.captain.conversation');
509
+ let normalizedConversation;
510
+ if (conversation.kind === 'pinned') {
511
+ rejectSnapshotKeys(conversation, ['kind', 'token'], 'Captain shell snapshot.captain.conversation');
512
+ normalizedConversation = {
513
+ kind: 'pinned',
514
+ token: snapshotString(conversation.token, 'Captain shell snapshot.captain.conversation.token', true),
515
+ };
516
+ }
517
+ else if (conversation.kind === 'unopened' ||
518
+ conversation.kind === 'needsSeeding') {
519
+ rejectSnapshotKeys(conversation, ['kind'], 'Captain shell snapshot.captain.conversation');
520
+ normalizedConversation = { kind: conversation.kind };
521
+ }
522
+ else {
523
+ throw new TypeError('Captain shell snapshot.captain.conversation.kind is not supported');
524
+ }
525
+ if (!Array.isArray(snapshot.issuedSessionIds)) {
526
+ throw new TypeError('Captain shell snapshot.issuedSessionIds must be an array');
527
+ }
528
+ const issued = snapshot.issuedSessionIds.map((id, index) => snapshotUuid(id, `Captain shell snapshot.issuedSessionIds[${index}]`));
529
+ if (new Set(issued).size !== issued.length) {
530
+ throw new TypeError('Captain shell snapshot.issuedSessionIds must not contain duplicates');
531
+ }
532
+ if (issued[0] !== captainSessionId) {
533
+ throw new TypeError('Captain shell snapshot Captain session id must be the first issued id');
534
+ }
535
+ const sequences = snapshotRecord(snapshot.sequences, 'Captain shell snapshot.sequences');
536
+ rejectSnapshotKeys(sequences, ['turn', 'journal'], 'Captain shell snapshot.sequences');
537
+ const turnSequence = snapshotInteger(sequences.turn, 'Captain shell snapshot.sequences.turn');
538
+ const journalSequence = snapshotInteger(sequences.journal, 'Captain shell snapshot.sequences.journal');
539
+ if (!Array.isArray(snapshot.journal)) {
540
+ throw new TypeError('Captain shell snapshot.journal must be an array');
541
+ }
542
+ const normalizedJournal = [];
543
+ let previousTurn = 0;
544
+ let bossRecords = 0;
545
+ for (const [index, value] of snapshot.journal.entries()) {
546
+ const record = snapshotRecord(value, `Captain shell snapshot.journal[${index}]`);
547
+ rejectSnapshotKeys(record, ['seq', 'turnId', 'kind', 'payload'], `Captain shell snapshot.journal[${index}]`);
548
+ const seq = snapshotInteger(record.seq, `Captain shell snapshot.journal[${index}].seq`, 1);
549
+ if (seq !== index + 1) {
550
+ throw new TypeError('Captain shell snapshot journal sequence must be contiguous from one');
551
+ }
552
+ const turnId = snapshotInteger(record.turnId, `Captain shell snapshot.journal[${index}].turnId`, 1);
553
+ if (turnId < previousTurn || turnId > turnSequence) {
554
+ throw new TypeError('Captain shell snapshot journal turn ids must be ordered and in range');
555
+ }
556
+ const kind = record.kind;
557
+ if (typeof kind !== 'string' ||
558
+ !SNAPSHOT_JOURNAL_KINDS.has(kind)) {
559
+ throw new TypeError(`Captain shell snapshot.journal[${index}].kind is not supported`);
560
+ }
561
+ if (turnId !== previousTurn) {
562
+ if (turnId !== previousTurn + 1 || kind !== 'boss') {
563
+ throw new TypeError('Captain shell snapshot journal must begin every turn with one boss record');
564
+ }
565
+ bossRecords++;
566
+ previousTurn = turnId;
567
+ }
568
+ else if (kind === 'boss') {
569
+ throw new TypeError('Captain shell snapshot journal must contain one boss record per turn');
570
+ }
571
+ normalizedJournal.push({
572
+ seq,
573
+ turnId,
574
+ kind: kind,
575
+ payload: record.payload,
576
+ });
577
+ }
578
+ if (journalSequence !== normalizedJournal.length ||
579
+ bossRecords !== turnSequence) {
580
+ throw new TypeError('Captain shell snapshot sequences do not match the complete journal');
581
+ }
582
+ let lastAction;
583
+ if (snapshot.lastAction !== undefined) {
584
+ if (typeof snapshot.lastAction !== 'string' ||
585
+ !SNAPSHOT_ACTIONS.has(snapshot.lastAction)) {
586
+ throw new TypeError('Captain shell snapshot.lastAction is not supported');
587
+ }
588
+ lastAction = snapshot.lastAction;
589
+ }
590
+ let lastSettlementStatus;
591
+ if (snapshot.lastSettlementStatus !== undefined) {
592
+ if (typeof snapshot.lastSettlementStatus !== 'string' ||
593
+ !SNAPSHOT_SETTLEMENT_STATUSES.has(snapshot.lastSettlementStatus)) {
594
+ throw new TypeError('Captain shell snapshot.lastSettlementStatus is not supported');
595
+ }
596
+ lastSettlementStatus = snapshot.lastSettlementStatus;
597
+ }
598
+ const common = {
599
+ schemaVersion: 1,
600
+ captain: {
601
+ sessionId: captainSessionId,
602
+ runtime: captainRuntime,
603
+ conversation: normalizedConversation,
604
+ },
605
+ issuedSessionIds: issued,
606
+ sequences: { turn: turnSequence, journal: journalSequence },
607
+ journal: normalizedJournal,
608
+ ...(lastAction === undefined ? {} : { lastAction }),
609
+ ...(lastSettlementStatus === undefined
610
+ ? {}
611
+ : { lastSettlementStatus }),
612
+ };
613
+ if (mode === 'chat') {
614
+ return snapshotJsonValue({ ...common, mode }, 'Captain shell snapshot');
615
+ }
616
+ if (!Array.isArray(snapshot.frames) || snapshot.frames.length === 0) {
617
+ throw new TypeError('Captain shell snapshot.frames must be a non-empty array');
618
+ }
619
+ const normalizedFrames = [];
620
+ for (const [index, value] of snapshot.frames.entries()) {
621
+ const frame = snapshotRecord(value, `Captain shell snapshot.frames[${index}]`);
622
+ rejectSnapshotKeys(frame, [
623
+ 'playbookId',
624
+ 'sessionId',
625
+ 'rootSessionId',
626
+ 'depth',
627
+ 'parentSessionId',
628
+ 'parentCallId',
629
+ 'runtime',
630
+ ], `Captain shell snapshot.frames[${index}]`);
631
+ const playbookId = snapshotString(frame.playbookId, `Captain shell snapshot.frames[${index}].playbookId`);
632
+ const sessionId = snapshotUuid(frame.sessionId, `Captain shell snapshot.frames[${index}].sessionId`);
633
+ const rootSessionId = snapshotUuid(frame.rootSessionId, `Captain shell snapshot.frames[${index}].rootSessionId`);
634
+ const depth = snapshotInteger(frame.depth, `Captain shell snapshot.frames[${index}].depth`);
635
+ const parentSessionId = frame.parentSessionId === undefined
636
+ ? undefined
637
+ : snapshotUuid(frame.parentSessionId, `Captain shell snapshot.frames[${index}].parentSessionId`);
638
+ const parentCallId = frame.parentCallId === undefined
639
+ ? undefined
640
+ : snapshotString(frame.parentCallId, `Captain shell snapshot.frames[${index}].parentCallId`);
641
+ const runtime = assertPlaybookRuntimeSnapshot(frame.runtime, playbookId, { allowSuspendedCall: true });
642
+ normalizedFrames.push({
643
+ playbookId,
644
+ sessionId,
645
+ rootSessionId,
646
+ depth,
647
+ ...(parentSessionId === undefined ? {} : { parentSessionId }),
648
+ ...(parentCallId === undefined ? {} : { parentCallId }),
649
+ runtime,
650
+ });
651
+ }
652
+ const rootTokens = snapshotRecord(snapshot.rootPlayerResumeTokens, 'Captain shell snapshot.rootPlayerResumeTokens');
653
+ const normalizedRootTokens = Object.fromEntries(Object.entries(rootTokens).map(([playerId, token]) => [
654
+ playerId,
655
+ snapshotString(token, `Captain shell snapshot.rootPlayerResumeTokens.${playerId}`),
656
+ ]));
657
+ let normalizedLastError;
658
+ if (snapshot.lastError !== undefined) {
659
+ const error = snapshotRecord(snapshot.lastError, 'Captain shell snapshot.lastError');
660
+ rejectSnapshotKeys(error, ['name', 'message'], 'Captain shell snapshot.lastError');
661
+ normalizedLastError = {
662
+ name: snapshotString(error.name, 'Captain shell snapshot.lastError.name', true),
663
+ message: snapshotString(error.message, 'Captain shell snapshot.lastError.message', true),
664
+ };
665
+ }
666
+ return snapshotJsonValue({
667
+ ...common,
668
+ mode,
669
+ frames: normalizedFrames,
670
+ rootPlayerResumeTokens: normalizedRootTokens,
671
+ ...(snapshot.pendingBossQuestions === undefined
672
+ ? {}
673
+ : { pendingBossQuestions: snapshot.pendingBossQuestions }),
674
+ ...(normalizedLastError === undefined
675
+ ? {}
676
+ : { lastError: normalizedLastError }),
677
+ }, 'Captain shell snapshot');
678
+ }
417
679
  function readPlaybooksConfig(options) {
418
680
  if (typeof options !== 'object' || options === null)
419
681
  return undefined;
@@ -495,7 +757,6 @@ async function buildEnablements(options, players, loadModule) {
495
757
  optionInput: record.options,
496
758
  boundPlayers,
497
759
  hostPlayerId: (localRole) => `${entry.id}-${localRole}`,
498
- visiblePlayerIds: entry.requiredRoleIds.map((role) => `${entry.id}-${role}`),
499
760
  });
500
761
  }
501
762
  return { entries, byCommand, byId, enablementById };
@@ -513,6 +774,10 @@ export function createPlaybookCaptainShell(options, deps = {}) {
513
774
  let byId = new Map();
514
775
  let enablementById = new Map();
515
776
  let session;
777
+ let sessionEmissionsOpen = false;
778
+ let closedGateAttempted = false;
779
+ let lifecycle = 'fresh';
780
+ let terminallyDisposed = false;
516
781
  let players = [];
517
782
  let activeContext;
518
783
  const frames = [];
@@ -525,6 +790,41 @@ export function createPlaybookCaptainShell(options, deps = {}) {
525
790
  const pendingChildParents = new Set();
526
791
  const captainQueue = new PQueue({ concurrency: 1 });
527
792
  let disposing = false;
793
+ const admitHostBoundary = () => {
794
+ if (sessionEmissionsOpen)
795
+ return;
796
+ closedGateAttempted = true;
797
+ throw new Error('Captain shell host boundaries are closed during restore');
798
+ };
799
+ const admitHostEmission = () => {
800
+ if (sessionEmissionsOpen)
801
+ return true;
802
+ closedGateAttempted = true;
803
+ return false;
804
+ };
805
+ const installSession = (initSession, emissionsOpen) => {
806
+ sessionEmissionsOpen = emissionsOpen;
807
+ closedGateAttempted = false;
808
+ session = {
809
+ signal: initSession.signal,
810
+ players: initSession.players,
811
+ emitStatus: async (message, data) => {
812
+ if (!admitHostEmission())
813
+ return;
814
+ await initSession.emitStatus(message, data);
815
+ },
816
+ emitTelemetry: async (event) => {
817
+ if (!admitHostEmission())
818
+ return;
819
+ await initSession.emitTelemetry(event);
820
+ },
821
+ setVisiblePlayers: async (playerIds) => {
822
+ if (!admitHostEmission())
823
+ return;
824
+ await initSession.setVisiblePlayers(playerIds);
825
+ },
826
+ };
827
+ };
528
828
  // --- session Captain, durable conversation, and journal (CAPTAIN-16/31/35)
529
829
  let captainRuntime;
530
830
  let captainSessionId;
@@ -555,6 +855,24 @@ export function createPlaybookCaptainShell(options, deps = {}) {
555
855
  const rootFrame = () => frames[0];
556
856
  const leafFrame = () => frames.at(-1);
557
857
  const frameLabel = (frame) => `/${frame.enablement.command}`;
858
+ const bindingFor = (frame, localRole) => {
859
+ const binding = frame.playerBindings.get(localRole);
860
+ if (!binding) {
861
+ throw new Error(`${frameLabel(frame)} resolved undeclared player role ${JSON.stringify(localRole)}`);
862
+ }
863
+ return binding;
864
+ };
865
+ const summaryIncludes = (frame) => {
866
+ const owner = activeTurnSummary?.owner;
867
+ if (!owner)
868
+ return false;
869
+ for (let current = frame; current;) {
870
+ if (current === owner)
871
+ return true;
872
+ current = current.parent?.frame;
873
+ }
874
+ return false;
875
+ };
558
876
  const requireSession = () => {
559
877
  if (!session) {
560
878
  throw new Error('init must be called first');
@@ -717,7 +1035,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
717
1035
  return;
718
1036
  const previousActiveIds = new Set(frame.state?.activeStateIds ?? []);
719
1037
  frame.state = state;
720
- if (activeTurnSummary?.owner === frame) {
1038
+ const summary = activeTurnSummary;
1039
+ if (summary && summaryIncludes(frame)) {
721
1040
  for (const stateId of state.activeStateIds) {
722
1041
  const newlyActive = !previousActiveIds.has(stateId);
723
1042
  const structuredEntry = stateValueContains(record.to, stateId) &&
@@ -726,7 +1045,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
726
1045
  continue;
727
1046
  const countLabel = stateCountLabel(stateId, frame.entry);
728
1047
  if (countLabel) {
729
- activeTurnSummary.stateCounts.set(countLabel, (activeTurnSummary.stateCounts.get(countLabel) ?? 0) + 1);
1048
+ summary.stateCounts.set(countLabel, (summary.stateCounts.get(countLabel) ?? 0) + 1);
730
1049
  }
731
1050
  }
732
1051
  }
@@ -742,12 +1061,13 @@ export function createPlaybookCaptainShell(options, deps = {}) {
742
1061
  let callNestedPlaybook;
743
1062
  const createPorts = (frame) => ({
744
1063
  callPlayer: async (playerId, prompt, signal, options) => {
1064
+ admitHostBoundary();
745
1065
  if (!activeContext) {
746
1066
  throw new Error('callPlayer invoked outside a Boss turn');
747
1067
  }
748
1068
  const context = activeContext;
749
1069
  signal.throwIfAborted();
750
- const hostPlayerId = frame.enablement.hostPlayerId(playerId);
1070
+ const hostPlayerId = bindingFor(frame, playerId).hostPlayerId;
751
1071
  const result = await trackHostCall(frame, context.callPlayer(hostPlayerId, prompt, {
752
1072
  resume: options.resume,
753
1073
  }));
@@ -759,8 +1079,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
759
1079
  // CAPTAIN-20: only a player call that actually produced work is an
760
1080
  // interruption the Boss was spared. A call that errored or aborted
761
1081
  // saved nothing, so it never feeds the saved-counts gate.
762
- if (activeTurnSummary?.owner === frame && result.status === 'ok') {
763
- activeTurnSummary.counts.interruptions++;
1082
+ const summary = activeTurnSummary;
1083
+ if (summary && summaryIncludes(frame) && result.status === 'ok') {
1084
+ summary.counts.interruptions++;
764
1085
  }
765
1086
  return {
766
1087
  status: result.status,
@@ -774,6 +1095,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
774
1095
  };
775
1096
  },
776
1097
  callCaptain: async (prompt, signal, options) => {
1098
+ admitHostBoundary();
777
1099
  if (!activeContext) {
778
1100
  throw new Error('callCaptain invoked outside a Boss turn');
779
1101
  }
@@ -791,6 +1113,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
791
1113
  };
792
1114
  },
793
1115
  callJudge: async (prompt, signal) => {
1116
+ admitHostBoundary();
794
1117
  if (!activeContext) {
795
1118
  throw new Error('callJudge invoked outside a Boss turn');
796
1119
  }
@@ -806,14 +1129,17 @@ export function createPlaybookCaptainShell(options, deps = {}) {
806
1129
  throw new Error('callCaptain returned status=ok with no finalText');
807
1130
  }
808
1131
  const guard = guardFromJudgeReply(result.finalText);
1132
+ const summary = activeTurnSummary;
809
1133
  if (guard &&
810
- activeTurnSummary?.owner === frame &&
1134
+ summary &&
1135
+ summaryIncludes(frame) &&
811
1136
  frame.entry.summaryPolicy?.copyPasteGuardNames.includes(guard)) {
812
- activeTurnSummary.counts.copyPastes++;
1137
+ summary.counts.copyPastes++;
813
1138
  }
814
1139
  return result.finalText;
815
1140
  },
816
1141
  callPlaybook: (request, signal) => {
1142
+ admitHostBoundary();
817
1143
  const opening = callNestedPlaybook(frame, request, signal);
818
1144
  let exposed;
819
1145
  const registerOpeningCleanup = () => {
@@ -827,22 +1153,31 @@ export function createPlaybookCaptainShell(options, deps = {}) {
827
1153
  registerOpeningCleanup();
828
1154
  return exposed;
829
1155
  },
830
- emitStatus: async (message, data) => {
831
- await requireSession().emitStatus(message, data);
1156
+ emitStatus: (message, data) => {
1157
+ if (!admitHostEmission())
1158
+ return Promise.resolve();
1159
+ return trackHostCall(frame, (async () => {
1160
+ await requireSession().emitStatus(message, data);
1161
+ })());
832
1162
  },
833
- emitTelemetry: async (event) => {
834
- if (event.topic === SUB_RUNTIME_FSM_TOPIC) {
835
- await mirrorSubRuntimeTelemetry(frame, event.payload);
836
- }
837
- await requireSession().emitTelemetry(event);
1163
+ emitTelemetry: (event) => {
1164
+ if (!admitHostEmission())
1165
+ return Promise.resolve();
1166
+ const emission = (async () => {
1167
+ if (event.topic === SUB_RUNTIME_FSM_TOPIC) {
1168
+ await mirrorSubRuntimeTelemetry(frame, event.payload);
1169
+ }
1170
+ await requireSession().emitTelemetry(event);
1171
+ })();
1172
+ return trackHostCall(frame, emission);
838
1173
  },
839
1174
  });
840
1175
  // CAPTAIN-22: before dispatching to a playbook, request tmux-play
841
1176
  // visibility for that playbook's generated host players. A pane
842
1177
  // reconciliation failure is display-only in tmux-play and does not
843
1178
  // reject; the legacy path carries no generated set and skips this.
844
- const requestVisibility = async (enablement) => {
845
- const ids = enablement.visiblePlayerIds;
1179
+ const requestVisibility = async (frame) => {
1180
+ const ids = [...new Set([...frame.playerBindings.values()].map(({ hostPlayerId }) => hostPlayerId))];
846
1181
  if (!ids || ids.length === 0 || !activeContext)
847
1182
  return;
848
1183
  try {
@@ -875,12 +1210,38 @@ export function createPlaybookCaptainShell(options, deps = {}) {
875
1210
  : undefined;
876
1211
  return typeof stack === 'string' ? { ...compact, stack } : compact;
877
1212
  };
1213
+ const makePlayerBindings = (enablement, parent) => {
1214
+ const entry = enablement.entry;
1215
+ const playerBindings = new Map();
1216
+ for (const role of entry.requiredRoleIds) {
1217
+ let inherited;
1218
+ for (let ancestor = parent?.frame; ancestor && inherited === undefined; ancestor = ancestor.parent?.frame) {
1219
+ inherited = ancestor.playerBindings.get(role);
1220
+ }
1221
+ if (inherited) {
1222
+ playerBindings.set(role, inherited);
1223
+ continue;
1224
+ }
1225
+ const configured = enablement.boundPlayers.find((player) => player.id === role) ?? { id: role };
1226
+ playerBindings.set(role, {
1227
+ hostPlayerId: enablement.hostPlayerId(role),
1228
+ player: configured,
1229
+ });
1230
+ }
1231
+ return playerBindings;
1232
+ };
878
1233
  const makeFrame = (enablement, parent) => {
879
1234
  const entry = enablement.entry;
880
1235
  const sessionId = allocateSessionId();
1236
+ const playerBindings = makePlayerBindings(enablement, parent);
1237
+ const playerResumeTokens = parent?.frame.playerResumeTokens ?? new Map();
881
1238
  const runtime = entry.createRuntime({
882
1239
  captainOptions: enablement.optionInput,
883
- players: enablement.boundPlayers,
1240
+ players: [...playerBindings].map(([role, { player }]) => ({
1241
+ id: role,
1242
+ ...(player.adapter === undefined ? {} : { adapter: player.adapter }),
1243
+ ...(player.model === undefined ? {} : { model: player.model }),
1244
+ })),
884
1245
  });
885
1246
  return {
886
1247
  entry,
@@ -889,24 +1250,86 @@ export function createPlaybookCaptainShell(options, deps = {}) {
889
1250
  sessionId,
890
1251
  rootSessionId: parent?.frame.rootSessionId ?? sessionId,
891
1252
  depth: parent ? parent.frame.depth + 1 : 0,
1253
+ playerBindings,
1254
+ playerResumeTokens,
892
1255
  ...(parent ? { parent } : {}),
893
1256
  inFlightHostCalls: new Set(),
894
1257
  };
895
1258
  };
896
- const initFrame = async (frame) => {
897
- await frame.runtime.init({
898
- sessionId: frame.sessionId,
899
- playbookId: frame.entry.id,
900
- rootSessionId: frame.rootSessionId,
901
- ...(frame.parent
902
- ? {
903
- parentSessionId: frame.parent.frame.sessionId,
904
- parentCallId: frame.parent.callId,
905
- }
906
- : {}),
907
- depth: frame.depth,
908
- ports: createPorts(frame),
1259
+ const makeRestoredFrame = (enablement, snapshot, rootPlayerResumeTokens, parent) => {
1260
+ const entry = enablement.entry;
1261
+ const playerBindings = makePlayerBindings(enablement, parent);
1262
+ const runtime = entry.createRuntime({
1263
+ captainOptions: enablement.optionInput,
1264
+ players: [...playerBindings].map(([role, { player }]) => ({
1265
+ id: role,
1266
+ ...(player.adapter === undefined ? {} : { adapter: player.adapter }),
1267
+ ...(player.model === undefined ? {} : { model: player.model }),
1268
+ })),
909
1269
  });
1270
+ return {
1271
+ entry,
1272
+ enablement,
1273
+ runtime,
1274
+ sessionId: snapshot.sessionId,
1275
+ rootSessionId: snapshot.rootSessionId,
1276
+ depth: snapshot.depth,
1277
+ playerBindings,
1278
+ playerResumeTokens: rootPlayerResumeTokens,
1279
+ ...(parent ? { parent } : {}),
1280
+ state: snapshot.runtime.state,
1281
+ inFlightHostCalls: new Set(),
1282
+ };
1283
+ };
1284
+ const playerSessionStore = (frame) => ({
1285
+ select(playerId) {
1286
+ const binding = bindingFor(frame, playerId);
1287
+ return frame.playerResumeTokens.get(binding.hostPlayerId) ?? false;
1288
+ },
1289
+ update(playerId, resumeToken) {
1290
+ const binding = bindingFor(frame, playerId);
1291
+ if (resumeToken === undefined) {
1292
+ frame.playerResumeTokens.delete(binding.hostPlayerId);
1293
+ }
1294
+ else {
1295
+ frame.playerResumeTokens.set(binding.hostPlayerId, resumeToken);
1296
+ }
1297
+ },
1298
+ snapshot() {
1299
+ const tokens = {};
1300
+ for (const [playerId, binding] of frame.playerBindings) {
1301
+ const token = frame.playerResumeTokens.get(binding.hostPlayerId);
1302
+ if (token !== undefined)
1303
+ tokens[playerId] = token;
1304
+ }
1305
+ return tokens;
1306
+ },
1307
+ restore(tokens) {
1308
+ for (const binding of frame.playerBindings.values()) {
1309
+ frame.playerResumeTokens.delete(binding.hostPlayerId);
1310
+ }
1311
+ for (const [playerId, token] of Object.entries(tokens)) {
1312
+ const binding = bindingFor(frame, playerId);
1313
+ frame.playerResumeTokens.set(binding.hostPlayerId, token);
1314
+ }
1315
+ },
1316
+ });
1317
+ const frameSession = (frame) => ({
1318
+ sessionId: frame.sessionId,
1319
+ playbookId: frame.entry.id,
1320
+ rootSessionId: frame.rootSessionId,
1321
+ ...(frame.parent
1322
+ ? {
1323
+ parentSessionId: frame.parent.frame.sessionId,
1324
+ parentCallId: frame.parent.callId,
1325
+ }
1326
+ : {}),
1327
+ depth: frame.depth,
1328
+ playerSessions: playerSessionStore(frame),
1329
+ ports: createPorts(frame),
1330
+ });
1331
+ const initFrame = async (frame) => {
1332
+ await frame.runtime.init(frameSession(frame));
910
1333
  };
911
1334
  const clearLeafLedger = () => {
912
1335
  pendingBossQuestions = undefined;
@@ -1076,7 +1499,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1076
1499
  }
1077
1500
  let visibilityError;
1078
1501
  try {
1079
- await requestVisibility(parent.enablement);
1502
+ await requestVisibility(parent);
1080
1503
  }
1081
1504
  catch (error) {
1082
1505
  visibilityError = error;
@@ -1184,7 +1607,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1184
1607
  // boundary — a `setVisiblePlayers` or telemetry rejection here leaves the
1185
1608
  // runtime uninvoked and owes the Boss the CAPTAIN-34 reply rather than an
1186
1609
  // exception filed against an effect that never ran.
1187
- await requestVisibility(frame.enablement);
1610
+ await requestVisibility(frame);
1188
1611
  await setMode('engaged.driving', 'submit');
1189
1612
  const result = await runEffect(() => frame.runtime.handleBossInput({ text, signal }));
1190
1613
  frame.state = result.state;
@@ -1268,6 +1691,12 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1268
1691
  await resumeParent(frame, callResultFor(frame, result), context);
1269
1692
  }
1270
1693
  else {
1694
+ // CAPTAIN-20: the root is still alive here, so this is the one
1695
+ // authoritative boundary that can retain the Boss-facing meaning its
1696
+ // runtime publishes before disposal removes the frame. The opaque run
1697
+ // output remains runtime-to-runtime data and never becomes Captain
1698
+ // evidence (CAPPLAY-10).
1699
+ activeTurn?.settlementFacts.push(rootCompletionFact(frame));
1271
1700
  await runEffect(() => disposeStack('final'));
1272
1701
  }
1273
1702
  return;
@@ -1306,7 +1735,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1306
1735
  cleanupError = error;
1307
1736
  }
1308
1737
  if (frames.includes(parent)) {
1309
- await requestVisibility(parent.enablement);
1738
+ await requestVisibility(parent);
1310
1739
  }
1311
1740
  if (cleanupError !== undefined)
1312
1741
  throw cleanupError;
@@ -1418,7 +1847,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1418
1847
  }
1419
1848
  if (frames.includes(parent)) {
1420
1849
  try {
1421
- await requestVisibility(parent.enablement);
1850
+ await requestVisibility(parent);
1422
1851
  }
1423
1852
  catch (visibilityError) {
1424
1853
  visibilityControlFailure = true;
@@ -1947,9 +2376,11 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1947
2376
  // -------------------------------------------------------------------------
1948
2377
  const captainPorts = () => ({
1949
2378
  callPlayer: async () => {
2379
+ admitHostBoundary();
1950
2380
  throw new Error('the session Captain has no players');
1951
2381
  },
1952
2382
  callCaptain: async (prompt, signal) => {
2383
+ admitHostBoundary();
1953
2384
  if (!activeContext) {
1954
2385
  throw new Error('the session Captain called out of a Boss turn');
1955
2386
  }
@@ -2007,29 +2438,38 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2007
2438
  return { status: 'ok', finalText: 'ok' };
2008
2439
  },
2009
2440
  callJudge: async () => {
2441
+ admitHostBoundary();
2010
2442
  throw new Error('the session Captain makes no judge call');
2011
2443
  },
2012
2444
  callPlaybook: async () => {
2445
+ admitHostBoundary();
2013
2446
  throw new Error('the session Captain never calls a playbook');
2014
2447
  },
2015
2448
  // CAPTAIN-9: the session Captain's human status stream is suppressed while
2016
2449
  // its structured telemetry is forwarded.
2017
- emitStatus: async () => { },
2018
- emitTelemetry: async (event) => {
2019
- if (event.topic === 'playbook.trace') {
2020
- const payload = payloadRecord(event.payload);
2021
- if (payload?.type === 'captain.call.started') {
2022
- const identity = payloadRecord(payload.payload);
2023
- const stateId = identity?.stateId;
2024
- servingCall =
2025
- stateId === 'reporting'
2026
- ? 'closingReply'
2027
- : stateId === 'answeringCommand'
2028
- ? 'commandReply'
2029
- : 'decision';
2450
+ emitStatus: async () => {
2451
+ admitHostEmission();
2452
+ },
2453
+ emitTelemetry: (event) => {
2454
+ if (!admitHostEmission())
2455
+ return Promise.resolve();
2456
+ const emission = (async () => {
2457
+ if (event.topic === 'playbook.trace') {
2458
+ const payload = payloadRecord(event.payload);
2459
+ if (payload?.type === 'captain.call.started') {
2460
+ const identity = payloadRecord(payload.payload);
2461
+ const stateId = identity?.stateId;
2462
+ servingCall =
2463
+ stateId === 'reporting'
2464
+ ? 'closingReply'
2465
+ : stateId === 'answeringCommand'
2466
+ ? 'commandReply'
2467
+ : 'decision';
2468
+ }
2030
2469
  }
2031
- }
2032
- await requireSession().emitTelemetry(event);
2470
+ await requireSession().emitTelemetry(event);
2471
+ })();
2472
+ return trackTurnCall(emission);
2033
2473
  },
2034
2474
  });
2035
2475
  const resolveCommandTurn = (text) => {
@@ -2097,6 +2537,13 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2097
2537
  return undefined;
2098
2538
  }
2099
2539
  };
2540
+ const rootCompletionFact = (frame) => {
2541
+ const published = leafStateDescription(frame);
2542
+ const description = published === undefined ? '' : compactEvidence(published);
2543
+ return description === ''
2544
+ ? `${frameLabel(frame)} completed; its runtime published no result description.`
2545
+ : `${frameLabel(frame)} completed; its runtime-published result meaning was ${quoteEvidence(description)}.`;
2546
+ };
2100
2547
  const leafStateSummary = () => {
2101
2548
  const leaf = leafFrame();
2102
2549
  if (!leaf)
@@ -2495,9 +2942,6 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2495
2942
  };
2496
2943
  throw outcome.error;
2497
2944
  }
2498
- if (!frames.includes(leaf)) {
2499
- facts.push(`${frameLabel(leaf)} finished and was disposed.`);
2500
- }
2501
2945
  const runFailed = drainRunFailureFacts(facts);
2502
2946
  const summary = leafStateSummary();
2503
2947
  turn.report = {
@@ -2652,8 +3096,14 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2652
3096
  };
2653
3097
  };
2654
3098
  const controller = {
2655
- submit: (selection, signal) => settleSelection(selection, signal),
2656
- resolveParsedTurn: () => shuttingDown ? { kind: 'shutdown' } : activeTurn?.resolution,
3099
+ submit: (selection, signal) => {
3100
+ admitHostBoundary();
3101
+ return settleSelection(selection, signal);
3102
+ },
3103
+ resolveParsedTurn: () => {
3104
+ admitHostBoundary();
3105
+ return shuttingDown ? { kind: 'shutdown' } : activeTurn?.resolution;
3106
+ },
2657
3107
  };
2658
3108
  // -------------------------------------------------------------------------
2659
3109
  // Failure surface (CAPTAIN-34): a Boss-appropriate reply naming a concrete
@@ -2722,40 +3172,455 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2722
3172
  text: failureReplyText(),
2723
3173
  });
2724
3174
  };
2725
- return {
2726
- async init(initSession) {
2727
- session = initSession;
3175
+ const enabledCatalog = () => Object.freeze(entries.map((entry) => Object.freeze({
3176
+ id: entry.id,
3177
+ command: enablementById.get(entry.id).command,
3178
+ intent: entry.intent,
3179
+ })));
3180
+ const captainPlaybookSession = (id) => ({
3181
+ sessionId: id,
3182
+ playbookId: INTERNAL_CAPTAIN_ID,
3183
+ rootSessionId: id,
3184
+ depth: 0,
3185
+ ports: captainPorts(),
3186
+ });
3187
+ const tokenRecord = (tokens) => Object.fromEntries(tokens);
3188
+ const assertSnapshotMatchesEnablements = (snapshot, enabled) => {
3189
+ const captain = snapshot.captain.runtime;
3190
+ if (captain.schemaVersion !== 2 ||
3191
+ captain.state.status !== 'active' ||
3192
+ !captain.state.quiescent ||
3193
+ !captain.state.tags.includes('playbook.parked') ||
3194
+ captain.suspendedCall !== undefined ||
3195
+ Object.keys(captain.playerResumeTokens).length > 0 ||
3196
+ captain.pendingBossQuestions.length > 0) {
3197
+ throw new TypeError('Captain shell snapshot Captain runtime must be active, quiescent, playerless, and unsuspended');
3198
+ }
3199
+ if (captain.sequences.turn !== snapshot.sequences.turn) {
3200
+ throw new TypeError('Captain shell snapshot Captain and shell turn sequences must match');
3201
+ }
3202
+ const emptyHistory = snapshot.sequences.turn === 0 && snapshot.journal.length === 0;
3203
+ if ((snapshot.captain.conversation.kind === 'unopened') !== emptyHistory) {
3204
+ throw new TypeError('Captain shell snapshot unopened conversation must exactly match an empty session history');
3205
+ }
3206
+ if (snapshot.mode === 'chat')
3207
+ return;
3208
+ const activePlaybooks = new Set();
3209
+ const activeSessionIds = new Set([snapshot.captain.sessionId]);
3210
+ const issuedIds = new Set(snapshot.issuedSessionIds);
3211
+ const allowedHostPlayerIds = new Set();
3212
+ for (const enablement of enabled.values()) {
3213
+ for (const role of enablement.entry.requiredRoleIds) {
3214
+ allowedHostPlayerIds.add(enablement.hostPlayerId(role));
3215
+ }
3216
+ }
3217
+ for (const playerId of Object.keys(snapshot.rootPlayerResumeTokens)) {
3218
+ if (!allowedHostPlayerIds.has(playerId)) {
3219
+ throw new TypeError(`Captain shell snapshot root token names unknown host player ${JSON.stringify(playerId)}`);
3220
+ }
3221
+ }
3222
+ const bindingMaps = [];
3223
+ const rootSessionId = snapshot.frames[0].sessionId;
3224
+ for (const [index, frame] of snapshot.frames.entries()) {
3225
+ const enablement = enabled.get(frame.playbookId);
3226
+ if (!enablement) {
3227
+ throw new TypeError(`Captain shell snapshot frame names disabled playbook ${JSON.stringify(frame.playbookId)}`);
3228
+ }
3229
+ if (activePlaybooks.has(frame.playbookId)) {
3230
+ throw new TypeError('Captain shell snapshot engagement path must not contain a playbook cycle');
3231
+ }
3232
+ activePlaybooks.add(frame.playbookId);
3233
+ if (activeSessionIds.has(frame.sessionId)) {
3234
+ throw new TypeError('Captain shell snapshot frame session ids must be unique');
3235
+ }
3236
+ activeSessionIds.add(frame.sessionId);
3237
+ if (!issuedIds.has(frame.sessionId)) {
3238
+ throw new TypeError('Captain shell snapshot frame session id was not historically issued');
3239
+ }
3240
+ if (frame.depth !== index ||
3241
+ frame.rootSessionId !== rootSessionId ||
3242
+ frame.runtime.state.status !== 'active' ||
3243
+ !frame.runtime.state.quiescent) {
3244
+ throw new TypeError('Captain shell snapshot frame depth, root, or parked runtime state is inconsistent');
3245
+ }
3246
+ if (index === 0) {
3247
+ if (frame.sessionId !== frame.rootSessionId ||
3248
+ frame.parentSessionId !== undefined ||
3249
+ frame.parentCallId !== undefined) {
3250
+ throw new TypeError('Captain shell snapshot root frame has child-only identity fields');
3251
+ }
3252
+ }
3253
+ else {
3254
+ const parent = snapshot.frames[index - 1];
3255
+ if (frame.parentSessionId !== parent.sessionId ||
3256
+ frame.parentCallId === undefined) {
3257
+ throw new TypeError('Captain shell snapshot child frame does not identify its immediate parent');
3258
+ }
3259
+ const pending = parent.runtime.suspendedCall;
3260
+ if (!pending ||
3261
+ pending.callId !== frame.parentCallId ||
3262
+ pending.playbookId !== frame.playbookId ||
3263
+ pending.childSessionId !== frame.sessionId) {
3264
+ throw new TypeError('Captain shell snapshot parent suspended call does not match its child edge');
3265
+ }
3266
+ }
3267
+ const roleBindings = new Map();
3268
+ for (const role of enablement.entry.requiredRoleIds) {
3269
+ let inherited;
3270
+ for (let ancestor = index - 1; ancestor >= 0; ancestor--) {
3271
+ inherited = bindingMaps[ancestor]?.get(role);
3272
+ if (inherited !== undefined)
3273
+ break;
3274
+ }
3275
+ roleBindings.set(role, inherited ?? enablement.hostPlayerId(role));
3276
+ }
3277
+ bindingMaps.push(roleBindings);
3278
+ const projectedTokens = Object.fromEntries([...roleBindings].flatMap(([role, hostPlayerId]) => {
3279
+ const token = snapshot.rootPlayerResumeTokens[hostPlayerId];
3280
+ return token === undefined ? [] : [[role, token]];
3281
+ }));
3282
+ if (!isDeepStrictEqual(projectedTokens, frame.runtime.playerResumeTokens)) {
3283
+ throw new TypeError(`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} player tokens do not match root-owned continuation`);
3284
+ }
3285
+ }
3286
+ const leafRuntime = snapshot.frames.at(-1).runtime;
3287
+ if (leafRuntime.suspendedCall !== undefined ||
3288
+ !leafRuntime.state.tags.includes('playbook.parked')) {
3289
+ throw new TypeError('Captain shell snapshot leaf runtime must be parked without a dangling suspended child call');
3290
+ }
3291
+ };
3292
+ const safeCapturePoint = () => {
3293
+ if (lifecycle !== 'ready' ||
3294
+ terminallyDisposed ||
3295
+ !sessionEmissionsOpen ||
3296
+ !session ||
3297
+ session.signal.aborted ||
3298
+ !captainRuntime ||
3299
+ disposing ||
3300
+ shuttingDown ||
3301
+ activeContext !== undefined ||
3302
+ activeTurn !== undefined ||
3303
+ activeTurnHostCalls !== undefined ||
3304
+ activeTurnSummary !== undefined ||
3305
+ runFailureFacts !== undefined ||
3306
+ servingCall !== undefined ||
3307
+ decisionCall !== undefined ||
3308
+ captainQueue.pending !== 0 ||
3309
+ captainQueue.size !== 0 ||
3310
+ (mode !== 'chat' && mode !== 'engaged.parked')) {
3311
+ return false;
3312
+ }
3313
+ if ((mode === 'chat' &&
3314
+ (frames.length !== 0 ||
3315
+ pendingChildParents.size !== 0 ||
3316
+ pendingBossQuestions !== undefined ||
3317
+ lastError !== undefined)) ||
3318
+ (mode === 'engaged.parked' && frames.length === 0)) {
3319
+ return false;
3320
+ }
3321
+ const expectedParents = new Set(frames.slice(0, -1));
3322
+ if (pendingChildParents.size !== expectedParents.size ||
3323
+ [...pendingChildParents].some((frame) => !expectedParents.has(frame))) {
3324
+ return false;
3325
+ }
3326
+ return frames.every((frame, index) => {
3327
+ const liveInvocation = frame.invocationSignal !== undefined || frame.abortListener !== undefined;
3328
+ return (frame.state !== undefined &&
3329
+ frame.state.status === 'active' &&
3330
+ frame.state.quiescent &&
3331
+ !frame.disposing &&
3332
+ frame.disposePromise === undefined &&
3333
+ frame.removal === undefined &&
3334
+ frame.inFlightHostCalls.size === 0 &&
3335
+ (index === 0
3336
+ ? !liveInvocation
3337
+ : !liveInvocation ||
3338
+ (frame.invocationSignal !== undefined &&
3339
+ !frame.invocationSignal.aborted &&
3340
+ frame.abortListener !== undefined)));
3341
+ });
3342
+ };
3343
+ const exportShellSnapshot = () => {
3344
+ if (!safeCapturePoint() || !captainRuntime || !captainSessionId) {
3345
+ return undefined;
3346
+ }
3347
+ try {
3348
+ if (typeof captainRuntime.exportSnapshot !== 'function' ||
3349
+ typeof captainRuntime.restore !== 'function') {
3350
+ return undefined;
3351
+ }
3352
+ const captainSnapshot = captainRuntime.exportSnapshot();
3353
+ if (captainSnapshot === undefined)
3354
+ return undefined;
3355
+ const frameSnapshots = [];
3356
+ for (const frame of frames) {
3357
+ if (typeof frame.runtime.exportSnapshot !== 'function' ||
3358
+ typeof frame.runtime.restore !== 'function') {
3359
+ return undefined;
3360
+ }
3361
+ const runtime = frame.runtime.exportSnapshot();
3362
+ if (runtime === undefined ||
3363
+ !isDeepStrictEqual(frame.state, runtime.state)) {
3364
+ return undefined;
3365
+ }
3366
+ frameSnapshots.push({
3367
+ playbookId: frame.entry.id,
3368
+ sessionId: frame.sessionId,
3369
+ rootSessionId: frame.rootSessionId,
3370
+ depth: frame.depth,
3371
+ ...(frame.parent
3372
+ ? {
3373
+ parentSessionId: frame.parent.frame.sessionId,
3374
+ parentCallId: frame.parent.callId,
3375
+ }
3376
+ : {}),
3377
+ runtime,
3378
+ });
3379
+ }
3380
+ const common = {
3381
+ schemaVersion: 1,
3382
+ captain: {
3383
+ sessionId: captainSessionId,
3384
+ runtime: captainSnapshot,
3385
+ conversation,
3386
+ },
3387
+ issuedSessionIds: [...issuedSessionIds],
3388
+ sequences: { turn: turnSequence, journal: journalSeq },
3389
+ journal,
3390
+ ...(lastAction === undefined ? {} : { lastAction }),
3391
+ ...(lastSettlementStatus === undefined
3392
+ ? {}
3393
+ : { lastSettlementStatus }),
3394
+ };
3395
+ const candidate = mode === 'chat'
3396
+ ? { ...common, mode }
3397
+ : {
3398
+ ...common,
3399
+ mode: 'engaged.parked',
3400
+ frames: frameSnapshots,
3401
+ rootPlayerResumeTokens: tokenRecord(rootFrame().playerResumeTokens),
3402
+ ...(pendingBossQuestions === undefined
3403
+ ? {}
3404
+ : { pendingBossQuestions: pendingBossQuestions }),
3405
+ ...(lastError === undefined ? {} : { lastError }),
3406
+ };
3407
+ const normalized = assertPlaybookCaptainShellSnapshot(candidate);
3408
+ assertSnapshotMatchesEnablements(normalized, enablementById);
3409
+ return normalized;
3410
+ }
3411
+ catch {
3412
+ return undefined;
3413
+ }
3414
+ };
3415
+ const verifyRestoredRuntime = (runtime, expected, playbookId, allowSuspendedCall) => {
3416
+ const actual = runtime.exportSnapshot?.();
3417
+ if (actual === undefined) {
3418
+ throw new Error(`restored ${playbookId} runtime did not reach a safe snapshot boundary`);
3419
+ }
3420
+ const normalized = assertPlaybookRuntimeSnapshot(actual, playbookId, allowSuspendedCall ? { allowSuspendedCall: true } : {});
3421
+ for (const key of [
3422
+ 'state',
3423
+ 'playerResumeTokens',
3424
+ 'sequences',
3425
+ 'pendingBossQuestions',
3426
+ 'suspendedCall',
3427
+ ]) {
3428
+ if (!isDeepStrictEqual(normalized[key], expected[key])) {
3429
+ throw new Error(`restored ${playbookId} runtime changed snapshot field ${key}`);
3430
+ }
3431
+ }
3432
+ };
3433
+ const resetFailedRestore = async () => {
3434
+ const cleanupFailures = [];
3435
+ for (const frame of [...frames].reverse()) {
3436
+ frame.disposing = true;
3437
+ try {
3438
+ await frame.runtime.dispose();
3439
+ }
3440
+ catch (error) {
3441
+ cleanupFailures.push(error);
3442
+ }
3443
+ }
3444
+ if (captainRuntime) {
3445
+ shuttingDown = true;
3446
+ try {
3447
+ await captainRuntime.dispose();
3448
+ }
3449
+ catch (error) {
3450
+ cleanupFailures.push(error);
3451
+ }
3452
+ }
3453
+ frames.splice(0);
3454
+ pendingChildParents.clear();
3455
+ issuedSessionIds.clear();
3456
+ journal.splice(0);
3457
+ entries = [];
3458
+ byCommand = new Map();
3459
+ byId = new Map();
3460
+ enablementById = new Map();
3461
+ players = [];
3462
+ session = undefined;
3463
+ sessionEmissionsOpen = false;
3464
+ closedGateAttempted = false;
3465
+ captainRuntime = undefined;
3466
+ captainSessionId = undefined;
3467
+ conversation = { kind: 'unopened' };
3468
+ mode = 'chat';
3469
+ pendingBossQuestions = undefined;
3470
+ lastError = undefined;
3471
+ journalSeq = 0;
3472
+ turnSequence = 0;
3473
+ lastAction = undefined;
3474
+ lastSettlementStatus = undefined;
3475
+ shuttingDown = false;
3476
+ if (cleanupFailures.length > 0) {
3477
+ terminallyDisposed = true;
3478
+ lifecycle = 'closed';
3479
+ }
3480
+ else {
3481
+ lifecycle = 'fresh';
3482
+ }
3483
+ return cleanupFailures;
3484
+ };
3485
+ const restoreShellSnapshot = async (initSession, untrusted) => {
3486
+ if (lifecycle !== 'fresh' || terminallyDisposed) {
3487
+ throw new Error('Captain shell restore requires a fresh shell');
3488
+ }
3489
+ if (initSession.signal.aborted) {
3490
+ throw new Error('cannot restore an aborted Captain session');
3491
+ }
3492
+ lifecycle = 'restoring';
3493
+ try {
3494
+ const snapshot = assertPlaybookCaptainShellSnapshot(untrusted);
3495
+ const built = await buildEnablements(options, initSession.players, loadModule);
3496
+ for (const enablement of built.enablementById.values()) {
3497
+ enablement.entry.validateOptions(enablement.optionInput);
3498
+ }
3499
+ assertSnapshotMatchesEnablements(snapshot, built.enablementById);
3500
+ installSession(initSession, false);
2728
3501
  players = initSession.players;
2729
- const built = await buildEnablements(options, players, loadModule);
2730
3502
  entries = built.entries;
2731
3503
  byCommand = built.byCommand;
2732
3504
  byId = built.byId;
2733
3505
  enablementById = built.enablementById;
2734
- for (const enablement of enablementById.values()) {
2735
- enablement.entry.validateOptions(enablement.optionInput);
2736
- }
2737
- await setMode('chat', 'init');
2738
- // CAPTAIN-16: the session Captain exists from `init`, outside the
2739
- // engagement stack, with its own playbook session id.
2740
- const catalog = Object.freeze(entries.map((entry) => Object.freeze({
2741
- id: entry.id,
2742
- command: enablementById.get(entry.id).command,
2743
- intent: entry.intent,
2744
- })));
2745
- captainSessionId = allocateSessionId();
2746
3506
  captainRuntime = createCaptainRuntime({
2747
- enabledPlaybooks: catalog,
3507
+ enabledPlaybooks: enabledCatalog(),
2748
3508
  controller,
2749
3509
  });
2750
- await captainRuntime.init({
2751
- sessionId: captainSessionId,
2752
- playbookId: INTERNAL_CAPTAIN_ID,
2753
- rootSessionId: captainSessionId,
2754
- depth: 0,
2755
- ports: captainPorts(),
2756
- });
3510
+ if (typeof captainRuntime.restore !== 'function') {
3511
+ throw new Error('session Captain runtime does not support restore');
3512
+ }
3513
+ if (snapshot.mode === 'engaged.parked') {
3514
+ const rootTokens = new Map(Object.entries(snapshot.rootPlayerResumeTokens));
3515
+ for (const [index, frameSnapshot] of snapshot.frames.entries()) {
3516
+ const parentFrame = frames.at(-1);
3517
+ const frame = makeRestoredFrame(enablementById.get(frameSnapshot.playbookId), frameSnapshot, rootTokens, index === 0
3518
+ ? undefined
3519
+ : {
3520
+ frame: parentFrame,
3521
+ callId: frameSnapshot.parentCallId,
3522
+ });
3523
+ if (typeof frame.runtime.restore !== 'function') {
3524
+ throw new Error(`playbook ${frame.entry.id} runtime does not support restore`);
3525
+ }
3526
+ frames.push(frame);
3527
+ if (parentFrame)
3528
+ pendingChildParents.add(parentFrame);
3529
+ }
3530
+ }
3531
+ await captainRuntime.restore(captainPlaybookSession(snapshot.captain.sessionId), snapshot.captain.runtime);
3532
+ if (snapshot.mode === 'engaged.parked') {
3533
+ for (const [index, frame] of frames.entries()) {
3534
+ await frame.runtime.restore(frameSession(frame), snapshot.frames[index].runtime);
3535
+ }
3536
+ }
3537
+ if (closedGateAttempted) {
3538
+ throw new Error('a runtime attempted a host emission during restore');
3539
+ }
3540
+ verifyRestoredRuntime(captainRuntime, snapshot.captain.runtime, INTERNAL_CAPTAIN_ID, false);
3541
+ if (snapshot.mode === 'engaged.parked') {
3542
+ for (const [index, frame] of frames.entries()) {
3543
+ verifyRestoredRuntime(frame.runtime, snapshot.frames[index].runtime, frame.entry.id, true);
3544
+ }
3545
+ if (!isDeepStrictEqual(tokenRecord(rootFrame().playerResumeTokens), snapshot.rootPlayerResumeTokens)) {
3546
+ throw new Error('restored root-owned player continuation changed during restore');
3547
+ }
3548
+ }
3549
+ if (closedGateAttempted) {
3550
+ throw new Error('a runtime attempted a host emission during restore');
3551
+ }
3552
+ if (requireSession().signal.aborted) {
3553
+ throw new Error('Captain session aborted during restore');
3554
+ }
3555
+ for (const id of snapshot.issuedSessionIds)
3556
+ issuedSessionIds.add(id);
3557
+ journal.push(...snapshot.journal);
3558
+ journalSeq = snapshot.sequences.journal;
3559
+ turnSequence = snapshot.sequences.turn;
3560
+ conversation = snapshot.captain.conversation;
3561
+ captainSessionId = snapshot.captain.sessionId;
3562
+ lastAction = snapshot.lastAction;
3563
+ lastSettlementStatus = snapshot.lastSettlementStatus;
3564
+ mode = snapshot.mode;
3565
+ if (snapshot.mode === 'engaged.parked') {
3566
+ pendingBossQuestions = snapshot.pendingBossQuestions;
3567
+ lastError = snapshot.lastError;
3568
+ }
3569
+ lifecycle = 'ready';
3570
+ // The final commit is deliberately one non-throwing assignment.
3571
+ sessionEmissionsOpen = true;
3572
+ }
3573
+ catch (error) {
3574
+ const cleanupFailures = await resetFailedRestore();
3575
+ if (cleanupFailures.length > 0) {
3576
+ throw new AggregateError([error, ...cleanupFailures], 'Captain shell restore and cleanup failed');
3577
+ }
3578
+ throw error;
3579
+ }
3580
+ };
3581
+ return {
3582
+ async init(initSession) {
3583
+ if (lifecycle !== 'fresh' || terminallyDisposed) {
3584
+ throw new Error('Captain shell requires a fresh instance for init');
3585
+ }
3586
+ if (initSession.signal.aborted) {
3587
+ throw new Error('cannot initialize an aborted Captain session');
3588
+ }
3589
+ lifecycle = 'initializing';
3590
+ try {
3591
+ installSession(initSession, true);
3592
+ players = initSession.players;
3593
+ const built = await buildEnablements(options, players, loadModule);
3594
+ entries = built.entries;
3595
+ byCommand = built.byCommand;
3596
+ byId = built.byId;
3597
+ enablementById = built.enablementById;
3598
+ for (const enablement of enablementById.values()) {
3599
+ enablement.entry.validateOptions(enablement.optionInput);
3600
+ }
3601
+ await setMode('chat', 'init');
3602
+ // CAPTAIN-16: the session Captain exists from `init`, outside the
3603
+ // engagement stack, with its own playbook session id.
3604
+ captainSessionId = allocateSessionId();
3605
+ captainRuntime = createCaptainRuntime({
3606
+ enabledPlaybooks: enabledCatalog(),
3607
+ controller,
3608
+ });
3609
+ await captainRuntime.init(captainPlaybookSession(captainSessionId));
3610
+ lifecycle = 'ready';
3611
+ }
3612
+ catch (error) {
3613
+ terminallyDisposed = true;
3614
+ lifecycle = 'closed';
3615
+ throw error;
3616
+ }
2757
3617
  },
3618
+ exportSnapshot: exportShellSnapshot,
3619
+ restore: restoreShellSnapshot,
2758
3620
  async handleBossTurn(turn, context) {
3621
+ if (lifecycle !== 'ready' || terminallyDisposed) {
3622
+ throw new Error('init must be called first, or restore must complete before handling a Boss turn');
3623
+ }
2759
3624
  requireSession();
2760
3625
  if (!captainRuntime) {
2761
3626
  throw new Error('init must be called first');
@@ -2848,10 +3713,16 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2848
3713
  }
2849
3714
  },
2850
3715
  async prepareDispose() {
3716
+ if (lifecycle === 'initializing' || lifecycle === 'restoring') {
3717
+ throw new Error('cannot dispose while Captain shell setup is in progress');
3718
+ }
2851
3719
  activeContext = undefined;
2852
3720
  await teardown();
2853
3721
  },
2854
3722
  async dispose() {
3723
+ if (lifecycle === 'initializing' || lifecycle === 'restoring') {
3724
+ throw new Error('cannot dispose while Captain shell setup is in progress');
3725
+ }
2855
3726
  activeContext = undefined;
2856
3727
  await teardown();
2857
3728
  },
@@ -2859,6 +3730,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2859
3730
  // CAPTAIN-16: dispose every active frame from leaf to root, then the
2860
3731
  // session Captain last.
2861
3732
  async function teardown() {
3733
+ terminallyDisposed = true;
3734
+ lifecycle = 'disposing';
2862
3735
  let failure;
2863
3736
  try {
2864
3737
  await disposeStack('dispose');
@@ -2877,6 +3750,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2877
3750
  failure ??= error;
2878
3751
  }
2879
3752
  }
3753
+ lifecycle = 'closed';
2880
3754
  if (failure !== undefined)
2881
3755
  throw failure;
2882
3756
  }