@sublang/playbook 9.0.0 → 10.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 (64) hide show
  1. package/docs/cli.md +51 -8
  2. package/docs/embedding.md +38 -12
  3. package/package.json +7 -3
  4. package/reference/sdlc/captain.md +14 -10
  5. package/reference/sdlc/captain.playbook/captain.fsm.d.ts +33 -13
  6. package/reference/sdlc/captain.playbook/captain.fsm.js +80 -9
  7. package/reference/sdlc/captain.playbook/captain.fsm.ts +137 -18
  8. package/reference/sdlc/captain.playbook/captain.gears.md +10 -6
  9. package/reference/sdlc/captain.playbook/captain.playbook.d.ts +5 -1
  10. package/reference/sdlc/captain.playbook/captain.playbook.js +140 -10
  11. package/reference/sdlc/captain.playbook/captain.playbook.ts +188 -16
  12. package/reference/sdlc/code.md +0 -1
  13. package/reference/sdlc/code.playbook/bin/interactive-session.js +170 -17
  14. package/reference/sdlc/code.playbook/bin/launch-config.js +136 -4
  15. package/reference/sdlc/code.playbook/bin/playbook.js +81 -4
  16. package/reference/sdlc/code.playbook/bin/repository-effects.js +2930 -0
  17. package/reference/sdlc/code.playbook/bin/run.js +365 -63
  18. package/reference/sdlc/code.playbook/bin/session-store.js +2877 -209
  19. package/reference/sdlc/code.playbook/code.fsm.d.ts +7 -0
  20. package/reference/sdlc/code.playbook/code.fsm.js +74 -25
  21. package/reference/sdlc/code.playbook/code.fsm.ts +83 -29
  22. package/reference/sdlc/code.playbook/code.gears.md +0 -2
  23. package/reference/sdlc/code.playbook/code.playbook.d.ts +5 -2
  24. package/reference/sdlc/code.playbook/code.playbook.js +54 -2
  25. package/reference/sdlc/code.playbook/code.playbook.ts +75 -6
  26. package/reference/sdlc/code.playbook/code.registry.d.ts +10 -3
  27. package/reference/sdlc/code.playbook/code.registry.js +10 -3
  28. package/reference/sdlc/code.playbook/code.registry.ts +23 -5
  29. package/reference/sdlc/code.playbook/playbook-captain.d.ts +99 -7
  30. package/reference/sdlc/code.playbook/playbook-captain.js +1850 -72
  31. package/reference/sdlc/code.playbook/playbook-captain.ts +2759 -96
  32. package/reference/sdlc/decide.md +0 -1
  33. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +7 -0
  34. package/reference/sdlc/decide.playbook/decide.fsm.js +80 -29
  35. package/reference/sdlc/decide.playbook/decide.fsm.ts +89 -31
  36. package/reference/sdlc/decide.playbook/decide.gears.md +0 -1
  37. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +13 -5
  38. package/reference/sdlc/decide.playbook/decide.playbook.js +1712 -91
  39. package/reference/sdlc/decide.playbook/decide.playbook.ts +2677 -136
  40. package/reference/sdlc/decide.playbook/decide.registry.d.ts +7 -3
  41. package/reference/sdlc/decide.playbook/decide.registry.js +10 -3
  42. package/reference/sdlc/decide.playbook/decide.registry.ts +20 -5
  43. package/reference/sdlc/review.playbook/review.fsm.d.ts +7 -0
  44. package/reference/sdlc/review.playbook/review.fsm.js +133 -12
  45. package/reference/sdlc/review.playbook/review.fsm.ts +140 -12
  46. package/reference/sdlc/review.playbook/review.playbook.d.ts +5 -2
  47. package/reference/sdlc/review.playbook/review.playbook.js +65 -2
  48. package/reference/sdlc/review.playbook/review.playbook.ts +83 -6
  49. package/reference/sdlc/review.playbook/review.registry.d.ts +10 -3
  50. package/reference/sdlc/review.playbook/review.registry.js +10 -3
  51. package/reference/sdlc/review.playbook/review.registry.ts +23 -5
  52. package/slc/gears2fsm.md +6 -5
  53. package/slc/link.md +544 -41
  54. package/src/accepted-outcome.d.ts +18 -0
  55. package/src/accepted-outcome.js +94 -0
  56. package/src/accepted-outcome.ts +140 -0
  57. package/src/runtime.d.ts +164 -3
  58. package/src/runtime.ts +213 -2
  59. package/src/xstate-playbook-runtime.d.ts +149 -10
  60. package/src/xstate-playbook-runtime.js +2569 -270
  61. package/src/xstate-playbook-runtime.ts +4133 -490
  62. package/src/xstate-runtime.d.ts +59 -1
  63. package/src/xstate-runtime.js +866 -7
  64. package/src/xstate-runtime.ts +1397 -7
@@ -4,8 +4,30 @@ import { randomUUID } from 'node:crypto';
4
4
  import { isDeepStrictEqual } from 'node:util';
5
5
  import PQueue from 'p-queue';
6
6
  import { isAgentCallSettingsError, } from '@sublang/cligent/tmux-play';
7
- import { assertPlaybookRuntimeSnapshot, hiddenControlEnvelope, registerPlaybookAbortCleanup, snapshotJsonValue, validatePlayerResult, } from '../../../src/xstate-runtime.js';
7
+ import { assertPlaybookRuntimeSnapshot, assertPlaybookEffectLedger, emptyPlaybookEffectLedger, hiddenControlEnvelope, isPlaybookEffectLedgerMonotonicExtension, registerPlaybookAbortCleanup, snapshotJsonValue, validatePlayerResult, } from '../../../src/xstate-runtime.js';
8
8
  import createDefaultCaptainRuntime from '../captain.playbook/captain.playbook.js';
9
+ function retainedEffectLedgerCanRebase(checkpoint, current) {
10
+ if (checkpoint.boundaries.some(({ physicalReceipt }) => physicalReceipt === undefined)) {
11
+ return false;
12
+ }
13
+ if (!isPlaybookEffectLedgerMonotonicExtension(checkpoint, current)) {
14
+ return false;
15
+ }
16
+ if (!isDeepStrictEqual(current.boundaries.slice(0, checkpoint.boundaries.length), checkpoint.boundaries) ||
17
+ !isDeepStrictEqual(current.logicalOperations, checkpoint.logicalOperations)) {
18
+ return false;
19
+ }
20
+ return current.boundaries
21
+ .slice(checkpoint.boundaries.length)
22
+ .every(({ physicalReceipt }) => physicalReceipt?.classification === 'unchanged');
23
+ }
24
+ function createRuntimeForEnablement(enablement, hostCapabilitiesById) {
25
+ const hostCapabilities = hostCapabilitiesById.get(enablement.entry.id);
26
+ if (hostCapabilities === undefined) {
27
+ throw new Error(`/${enablement.command} schema-3 runtime requires current-host construction capabilities`);
28
+ }
29
+ return enablement.entry.createRuntime(enablement.options, hostCapabilities);
30
+ }
9
31
  class VisibilityControlError extends Error {
10
32
  constructor(cause) {
11
33
  super(`playbook visibility request failed: ${String(cause?.message ?? cause)}`, { cause });
@@ -39,6 +61,10 @@ const INTERNAL_CAPTAIN_ID = 'captain';
39
61
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
40
62
  const PLAYER_ID_PATTERN = /^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)*$/;
41
63
  const ROLE_ID_PATTERN = /^[a-z][a-z0-9_-]*$/;
64
+ const HOST_CAPABILITIES_OPTION_KEY = 'hostCapabilities';
65
+ const UNRESOLVED_EFFECT_RECONCILIATION_ACTION_ID = 'reconcile:unresolved-effect';
66
+ const UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID = 'abandon:unresolved-effect';
67
+ const RESUMPTION_DUPLICATE_EFFECT_WARNING = 'Warning: resumption may duplicate external effects attempted after the retained boundary; verify the current world before continuing.';
42
68
  function parseRegisteredCommand(prompt) {
43
69
  const match = /^\/([A-Za-z][A-Za-z0-9_-]*)(?:\s+([\s\S]*))?$/.exec(prompt.trim());
44
70
  if (!match)
@@ -405,11 +431,45 @@ function forwardedToolOptions(requested, captainAdapter) {
405
431
  return { allowedTools: requested };
406
432
  }
407
433
  const hiddenJudgeEnvelope = hiddenControlEnvelope;
434
+ function unresolvedEffectReportLines(unresolvedEffects) {
435
+ return unresolvedEffects.map((effect, index) => {
436
+ const merelyPossible = effect.classification === 'observation-ambiguous' ||
437
+ effect.classification === 'incomplete';
438
+ return [
439
+ `${index + 1}. ${merelyPossible ? 'Possible repository effect; a change could not be excluded' : 'Observed repository change'} (${effect.classification})`,
440
+ `baseline HEAD ${effect.baselineHead}`,
441
+ effect.afterHead === undefined
442
+ ? 'after HEAD was not available'
443
+ : `after HEAD ${effect.afterHead}`,
444
+ ...(effect.commitOid === undefined
445
+ ? []
446
+ : [`proven commit OID ${effect.commitOid}`]),
447
+ ].join('; ') + '.';
448
+ });
449
+ }
450
+ function unresolvedEffectBossReport(unresolvedEffects) {
451
+ if (unresolvedEffects.length === 0)
452
+ return undefined;
453
+ return [
454
+ 'Repository-effect evidence:',
455
+ ...unresolvedEffectReportLines(unresolvedEffects).map((line) => `- ${line}`),
456
+ 'This evidence does not establish workflow completion or attribute any repository change or commit to this workflow.',
457
+ ].join('\n');
458
+ }
459
+ function appendMandatoryPresentationSuffix(turn, suffix) {
460
+ const current = turn.mandatoryPresentationSuffix;
461
+ if (current === undefined) {
462
+ turn.mandatoryPresentationSuffix = suffix;
463
+ }
464
+ else if (!current.includes(suffix)) {
465
+ turn.mandatoryPresentationSuffix = `${current}\n\n${suffix}`;
466
+ }
467
+ }
408
468
  // CAPTAIN-20: the result-phase block the shell supplies inside the closing
409
469
  // reply call's envelope — the settlement's outcome-report facts verbatim, the
410
470
  // exact counts, and the saved-counts line only when counted activity is
411
471
  // nonzero.
412
- function outcomeReportBlock(report) {
472
+ function outcomeReportBlock(report, unresolvedEffects) {
413
473
  const lines = [
414
474
  `Settlement status: ${report.status}`,
415
475
  ...(report.playbookId === undefined
@@ -433,6 +493,12 @@ function outcomeReportBlock(report) {
433
493
  if (report.leafStateSummary !== undefined) {
434
494
  lines.push(`Resulting leaf state: ${report.leafStateSummary}`);
435
495
  }
496
+ const effectLines = unresolvedEffectReportLines(unresolvedEffects);
497
+ if (effectLines.length > 0) {
498
+ lines.push('Repository-effect evidence (canonical, in ledger order):');
499
+ lines.push(...effectLines.map((line) => `- ${line}`));
500
+ lines.push('Report this evidence without claiming workflow completion or attributing any repository change or commit to the workflow.');
501
+ }
436
502
  lines.push(`Progress counts: ${report.progressPhrase}`);
437
503
  lines.push(`Counts: ${JSON.stringify({
438
504
  ...report.counts,
@@ -467,10 +533,150 @@ function summaryProgressPhrase(stateCounts) {
467
533
  function summaryProgressRoundCount(stateCounts) {
468
534
  return [...stateCounts.values()].reduce((total, count) => total + count, 0);
469
535
  }
470
- function guardFromJudgeReply(finalText) {
471
- return /"guard"\s*:\s*"([^"]+)"/.exec(finalText)?.[1];
536
+ function captureRegistryEntry(value) {
537
+ if (value === null || typeof value !== 'object')
538
+ return value;
539
+ const source = value;
540
+ return {
541
+ id: source.id,
542
+ command: source.command,
543
+ intent: source.intent,
544
+ artifactSchema: source.artifactSchema,
545
+ runtimeProfile: source.runtimeProfile,
546
+ requiredRoleIds: source.requiredRoleIds,
547
+ concurrentRoleSets: source.concurrentRoleSets,
548
+ summaryPolicy: source.summaryPolicy,
549
+ validateOptions: source.validateOptions,
550
+ createRuntime: source.createRuntime,
551
+ };
552
+ }
553
+ function exactOwnDataRecord(value, keys) {
554
+ if (value === null ||
555
+ typeof value !== 'object' ||
556
+ Array.isArray(value) ||
557
+ (Object.getPrototypeOf(value) !== Object.prototype &&
558
+ Object.getPrototypeOf(value) !== null)) {
559
+ return undefined;
560
+ }
561
+ const descriptors = Object.getOwnPropertyDescriptors(value);
562
+ if (Reflect.ownKeys(descriptors).length !== keys.length ||
563
+ keys.some((key) => !Object.hasOwn(descriptors, key) ||
564
+ !Object.hasOwn(descriptors[key], 'value') ||
565
+ descriptors[key].enumerable !== true)) {
566
+ return undefined;
567
+ }
568
+ return Object.fromEntries(keys.map((key) => [key, descriptors[key].value]));
569
+ }
570
+ function captureHostCapabilityRecord(value) {
571
+ if (value === undefined)
572
+ return Object.freeze({});
573
+ const captured = exactOwnDataRecord(value, Object.keys(value));
574
+ if (captured === undefined) {
575
+ throw new TypeError('current-host construction capabilities must be an exact data-property record');
576
+ }
577
+ return captured;
578
+ }
579
+ function validateHostCapabilities(value, entry, command) {
580
+ if (value === undefined) {
581
+ throw new Error(`/${command} schema-3 runtime requires current-host construction capabilities`);
582
+ }
583
+ const capability = exactOwnDataRecord(value, [
584
+ 'authority',
585
+ 'repository',
586
+ 'effectLedger',
587
+ ]);
588
+ const authority = exactOwnDataRecord(capability?.authority, [
589
+ 'playbookId',
590
+ 'artifactSchema',
591
+ 'cwd',
592
+ 'sessionId',
593
+ 'leaseOwnerToken',
594
+ 'canonicalWorktree',
595
+ 'requiredRoleIds',
596
+ 'concurrentRoleSets',
597
+ ]);
598
+ const canonicalWorktree = exactOwnDataRecord(authority?.canonicalWorktree, ['worktree', 'gitDir']);
599
+ const repository = exactOwnDataRecord(capability?.repository, [
600
+ 'identity',
601
+ 'observe',
602
+ 'acquire',
603
+ 'runExclusive',
604
+ 'runCohort',
605
+ 'runDeferred',
606
+ ]);
607
+ const identity = exactOwnDataRecord(repository?.identity, [
608
+ 'worktree',
609
+ 'gitDir',
610
+ ]);
611
+ const effectLedger = exactOwnDataRecord(capability?.effectLedger, [
612
+ 'snapshot',
613
+ 'writeAhead',
614
+ ]);
615
+ if (authority?.playbookId !== entry.id ||
616
+ authority.artifactSchema !== 3 ||
617
+ typeof authority.cwd !== 'string' ||
618
+ authority.cwd.length === 0 ||
619
+ typeof authority.sessionId !== 'string' ||
620
+ authority.sessionId.length === 0 ||
621
+ typeof authority.leaseOwnerToken !== 'string' ||
622
+ authority.leaseOwnerToken.length === 0 ||
623
+ canonicalWorktree === undefined ||
624
+ typeof canonicalWorktree.worktree !== 'string' ||
625
+ canonicalWorktree.worktree.length === 0 ||
626
+ typeof canonicalWorktree.gitDir !== 'string' ||
627
+ canonicalWorktree.gitDir.length === 0 ||
628
+ !isDeepStrictEqual(authority.requiredRoleIds, entry.requiredRoleIds) ||
629
+ !isDeepStrictEqual(authority.concurrentRoleSets, entry.concurrentRoleSets) ||
630
+ identity === undefined ||
631
+ !isDeepStrictEqual(identity, canonicalWorktree) ||
632
+ typeof repository?.observe !== 'function' ||
633
+ typeof repository.acquire !== 'function' ||
634
+ typeof repository.runExclusive !== 'function' ||
635
+ typeof repository.runCohort !== 'function' ||
636
+ typeof repository.runDeferred !== 'function' ||
637
+ typeof effectLedger?.snapshot !== 'function' ||
638
+ typeof effectLedger.writeAhead !== 'function') {
639
+ throw new Error(`/${command} schema-3 current-host capability authority does not match its imported artifact`);
640
+ }
641
+ return value;
642
+ }
643
+ function effectLedgerMirrorFromCapabilities(capabilities) {
644
+ const values = [...capabilities.values()];
645
+ if (values.length === 0)
646
+ return emptyPlaybookEffectLedger();
647
+ const mirror = assertPlaybookEffectLedger(values[0].effectLedger.snapshot());
648
+ for (const capability of values.slice(1)) {
649
+ if (!isDeepStrictEqual(assertPlaybookEffectLedger(capability.effectLedger.snapshot()), mirror)) {
650
+ throw new Error('schema-3 current-host capabilities disagree on their effect ledger');
651
+ }
652
+ }
653
+ return mirror;
654
+ }
655
+ function validateRuntimeProfile(value) {
656
+ const shared = exactOwnDataRecord(value, ['kind', 'compat']);
657
+ if (shared?.kind === 'shared-factory') {
658
+ const compat = exactOwnDataRecord(shared.compat, [
659
+ 'artifactSchema',
660
+ 'runtimeAbi',
661
+ ]);
662
+ if (compat?.artifactSchema === 3 &&
663
+ typeof compat.runtimeAbi === 'number' &&
664
+ Number.isSafeInteger(compat.runtimeAbi)) {
665
+ return {
666
+ kind: 'shared-factory',
667
+ artifactSchema: compat.artifactSchema,
668
+ };
669
+ }
670
+ return undefined;
671
+ }
672
+ const bespoke = exactOwnDataRecord(value, ['kind', 'artifactSchema']);
673
+ if (bespoke?.kind === 'bespoke' &&
674
+ bespoke.artifactSchema === 3) {
675
+ return { kind: 'bespoke', artifactSchema: bespoke.artifactSchema };
676
+ }
677
+ return undefined;
472
678
  }
473
- function isValidRegistryEntry(value) {
679
+ function isValidRegistryEntry(value, artifactSchema) {
474
680
  if (typeof value !== 'object' || value === null)
475
681
  return false;
476
682
  const e = value;
@@ -495,7 +701,7 @@ function isValidRegistryEntry(value) {
495
701
  return (typeof e.id === 'string' &&
496
702
  typeof e.command === 'string' &&
497
703
  typeof e.intent === 'string' &&
498
- e.artifactSchema === 2 &&
704
+ artifactSchema === 3 &&
499
705
  typeof e.validateOptions === 'function' &&
500
706
  typeof e.createRuntime === 'function');
501
707
  }
@@ -503,6 +709,7 @@ const SNAPSHOT_ACTIONS = new Set([
503
709
  'respond',
504
710
  'start',
505
711
  'switch',
712
+ 'resume',
506
713
  'dismiss',
507
714
  'deliver',
508
715
  'runtime',
@@ -519,6 +726,68 @@ const SNAPSHOT_JOURNAL_KINDS = new Set([
519
726
  'action',
520
727
  'outcome',
521
728
  ]);
729
+ const UNRESOLVED_EFFECT_CLASSIFICATIONS = new Set([
730
+ 'one-descendant-commit',
731
+ 'multiple-commits',
732
+ 'rewritten-or-non-descendant',
733
+ 'worktree-only-change',
734
+ 'concurrent-or-foreign-change',
735
+ 'observation-ambiguous',
736
+ 'incomplete',
737
+ ]);
738
+ const GIT_OID_PATTERN = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/;
739
+ export function assertPlaybookCaptainUnresolvedEffects(value) {
740
+ const detached = snapshotJsonValue(value, 'Captain unresolved effects');
741
+ if (!Array.isArray(detached)) {
742
+ throw new TypeError('Captain unresolved effects must be an array');
743
+ }
744
+ for (const [index, raw] of detached.entries()) {
745
+ const path = `Captain unresolved effects[${index}]`;
746
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
747
+ throw new TypeError(`${path} must be an object`);
748
+ }
749
+ const entry = raw;
750
+ const allowed = new Set([
751
+ 'classification',
752
+ 'baselineHead',
753
+ 'afterHead',
754
+ 'commitOid',
755
+ ]);
756
+ const unknown = Object.keys(entry).find((key) => !allowed.has(key));
757
+ if (unknown !== undefined) {
758
+ throw new TypeError(`${path} has unknown field ${JSON.stringify(unknown)}`);
759
+ }
760
+ if (typeof entry.classification !== 'string' ||
761
+ !UNRESOLVED_EFFECT_CLASSIFICATIONS.has(entry.classification)) {
762
+ throw new TypeError(`${path}.classification is not supported`);
763
+ }
764
+ if (typeof entry.baselineHead !== 'string' ||
765
+ !GIT_OID_PATTERN.test(entry.baselineHead)) {
766
+ throw new TypeError(`${path}.baselineHead must be a Git OID`);
767
+ }
768
+ if (entry.afterHead !== undefined &&
769
+ (typeof entry.afterHead !== 'string' ||
770
+ !GIT_OID_PATTERN.test(entry.afterHead))) {
771
+ throw new TypeError(`${path}.afterHead must be a Git OID`);
772
+ }
773
+ if (entry.classification !== 'observation-ambiguous' &&
774
+ entry.classification !== 'incomplete' &&
775
+ entry.afterHead === undefined) {
776
+ throw new TypeError(`${path}.afterHead is required for ${entry.classification}`);
777
+ }
778
+ if (entry.classification === 'one-descendant-commit') {
779
+ if (typeof entry.commitOid !== 'string' ||
780
+ !GIT_OID_PATTERN.test(entry.commitOid) ||
781
+ entry.commitOid !== entry.afterHead) {
782
+ throw new TypeError(`${path}.commitOid must equal afterHead for one-descendant-commit`);
783
+ }
784
+ }
785
+ else if (entry.commitOid !== undefined) {
786
+ throw new TypeError(`${path}.commitOid is permitted only for one-descendant-commit`);
787
+ }
788
+ }
789
+ return detached;
790
+ }
522
791
  function snapshotRecord(value, path) {
523
792
  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
524
793
  throw new TypeError(`${path} must be an object`);
@@ -706,9 +975,16 @@ function normalizeHostPlayerResult(value, expectedPlayerId) {
706
975
  export function assertPlaybookCaptainShellSnapshot(value) {
707
976
  const detached = snapshotJsonValue(value, 'Captain shell snapshot');
708
977
  const snapshot = snapshotRecord(detached, 'Captain shell snapshot');
978
+ if (snapshot.schemaVersion !== 4) {
979
+ if (snapshot.schemaVersion === 1) {
980
+ throw new TypeError('Captain shell snapshot schemaVersion 1 has incompatible player identity; schema 4 is required');
981
+ }
982
+ throw new TypeError(`Captain shell snapshot.schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 4)`);
983
+ }
709
984
  const mode = snapshot.mode;
710
985
  const commonKeys = [
711
986
  'schemaVersion',
987
+ 'effectLedger',
712
988
  'captain',
713
989
  'playerSessions',
714
990
  'issuedSessionIds',
@@ -725,6 +1001,7 @@ export function assertPlaybookCaptainShellSnapshot(value) {
725
1001
  rejectSnapshotKeys(snapshot, [
726
1002
  ...commonKeys,
727
1003
  'frames',
1004
+ 'retainedEffectReconciliation',
728
1005
  'pendingBossQuestions',
729
1006
  'lastError',
730
1007
  ], 'Captain shell snapshot');
@@ -732,13 +1009,28 @@ export function assertPlaybookCaptainShellSnapshot(value) {
732
1009
  else {
733
1010
  throw new TypeError('Captain shell snapshot.mode must be "chat" or "engaged.parked"');
734
1011
  }
735
- if (snapshot.schemaVersion !== 3) {
736
- throw new TypeError(`Captain shell snapshot.schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 3)`);
737
- }
738
1012
  const captain = snapshotRecord(snapshot.captain, 'Captain shell snapshot.captain');
739
1013
  rejectSnapshotKeys(captain, ['sessionId', 'runtime', 'agent', 'conversation'], 'Captain shell snapshot.captain');
740
1014
  const captainSessionId = snapshotUuid(captain.sessionId, 'Captain shell snapshot.captain.sessionId');
741
1015
  const captainRuntime = assertPlaybookRuntimeSnapshot(captain.runtime, INTERNAL_CAPTAIN_ID);
1016
+ const effectLedger = assertPlaybookEffectLedger(snapshot.effectLedger);
1017
+ let retainedEffectReconciliation;
1018
+ if (snapshot.retainedEffectReconciliation !== undefined) {
1019
+ const reconciliation = snapshotRecord(snapshot.retainedEffectReconciliation, 'Captain shell snapshot.retainedEffectReconciliation');
1020
+ rejectSnapshotKeys(reconciliation, ['sourceGenerationId', 'checkpoint'], 'Captain shell snapshot.retainedEffectReconciliation');
1021
+ const checkpoint = assertPlaybookEffectLedger(reconciliation.checkpoint, 'Captain shell snapshot retained-effect checkpoint');
1022
+ if (isDeepStrictEqual(checkpoint, effectLedger) ||
1023
+ !isPlaybookEffectLedgerMonotonicExtension(checkpoint, effectLedger)) {
1024
+ throw new TypeError('Captain shell retained-effect checkpoint must be a strict monotonic prefix of its current mirror');
1025
+ }
1026
+ retainedEffectReconciliation = {
1027
+ sourceGenerationId: snapshotUuid(reconciliation.sourceGenerationId, 'Captain shell snapshot.retainedEffectReconciliation.sourceGenerationId'),
1028
+ checkpoint,
1029
+ };
1030
+ }
1031
+ if (!isDeepStrictEqual(captainRuntime.effectLedger, emptyPlaybookEffectLedger())) {
1032
+ throw new TypeError('Captain shell snapshot internal Captain runtime effect ledger must be empty');
1033
+ }
742
1034
  const captainAgent = snapshotFixedAgent(captain.agent, 'Captain shell snapshot.captain.agent');
743
1035
  const conversation = snapshotRecord(captain.conversation, 'Captain shell snapshot.captain.conversation');
744
1036
  let normalizedConversation;
@@ -855,7 +1147,8 @@ export function assertPlaybookCaptainShellSnapshot(value) {
855
1147
  }
856
1148
  const playerSessions = snapshotPlayerSessions(snapshot.playerSessions, 'Captain shell snapshot.playerSessions');
857
1149
  const common = {
858
- schemaVersion: 3,
1150
+ schemaVersion: 4,
1151
+ effectLedger,
859
1152
  captain: {
860
1153
  sessionId: captainSessionId,
861
1154
  runtime: captainRuntime,
@@ -941,6 +1234,32 @@ export function assertPlaybookCaptainShellSnapshot(value) {
941
1234
  const issuedIds = new Set(issued);
942
1235
  const rootSessionId = normalizedFrames[0].sessionId;
943
1236
  for (const [index, frame] of normalizedFrames.entries()) {
1237
+ const frameLedger = frame.runtime.effectLedger;
1238
+ if (!isDeepStrictEqual(frameLedger, emptyPlaybookEffectLedger()) &&
1239
+ !isDeepStrictEqual(frameLedger, effectLedger)) {
1240
+ throw new TypeError(`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} effect ledger is neither empty nor the shell mirror`);
1241
+ }
1242
+ const frameReconciliation = frame.runtime.retainedEffectReconciliation;
1243
+ if (retainedEffectReconciliation === undefined) {
1244
+ if (frameReconciliation !== undefined) {
1245
+ throw new TypeError(`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} carries an unmirrored retained-effect fence`);
1246
+ }
1247
+ }
1248
+ else if (isDeepStrictEqual(frameLedger, effectLedger)) {
1249
+ if (frameReconciliation === undefined ||
1250
+ !isDeepStrictEqual(frameReconciliation.checkpoint, retainedEffectReconciliation.checkpoint)) {
1251
+ throw new TypeError(`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} does not mirror the root retained-effect fence`);
1252
+ }
1253
+ if (index === 0 &&
1254
+ frameReconciliation.sourceSessionId !==
1255
+ retainedEffectReconciliation.sourceGenerationId) {
1256
+ throw new TypeError('Captain shell snapshot retained-effect root source identity differs from its generation');
1257
+ }
1258
+ }
1259
+ else if (frameReconciliation !== undefined ||
1260
+ frame.runtime.retainedEffectSourceSessionId !== undefined) {
1261
+ throw new TypeError(`Captain shell snapshot empty-ledger frame ${JSON.stringify(frame.playbookId)} carries retained-effect adoption state`);
1262
+ }
944
1263
  if (activePlaybooks.has(frame.playbookId)) {
945
1264
  throw new TypeError('Captain shell snapshot engagement path must not contain a playbook cycle');
946
1265
  }
@@ -1003,13 +1322,21 @@ export function assertPlaybookCaptainShellSnapshot(value) {
1003
1322
  !leafRuntime.state.tags.includes('playbook.parked')) {
1004
1323
  throw new TypeError('Captain shell snapshot leaf runtime must be parked without a dangling suspended child call');
1005
1324
  }
1006
- if (!isDeepStrictEqual(snapshot.pendingBossQuestions ?? [], leafRuntime.pendingBossQuestions)) {
1007
- throw new TypeError('Captain shell snapshot pending Boss questions must equal the leaf runtime projection');
1325
+ if (retainedEffectReconciliation === undefined) {
1326
+ if (!isDeepStrictEqual(snapshot.pendingBossQuestions ?? [], leafRuntime.pendingBossQuestions)) {
1327
+ throw new TypeError('Captain shell snapshot pending Boss questions must equal the leaf runtime projection');
1328
+ }
1329
+ }
1330
+ else if (snapshot.pendingBossQuestions !== undefined) {
1331
+ throw new TypeError('Captain shell snapshot must withhold pending Boss questions behind retained-effect reconciliation');
1008
1332
  }
1009
1333
  return snapshotJsonValue({
1010
1334
  ...common,
1011
1335
  mode,
1012
1336
  frames: normalizedFrames,
1337
+ ...(retainedEffectReconciliation === undefined
1338
+ ? {}
1339
+ : { retainedEffectReconciliation }),
1013
1340
  ...(snapshot.pendingBossQuestions === undefined
1014
1341
  ? {}
1015
1342
  : { pendingBossQuestions: snapshot.pendingBossQuestions }),
@@ -1088,14 +1415,25 @@ function promptIdentity(binding) {
1088
1415
  ? binding.model.value
1089
1416
  : binding.agent.adapter;
1090
1417
  }
1418
+ function rejectConfiguredHostCapabilities(value, path) {
1419
+ if (value !== null &&
1420
+ typeof value === 'object' &&
1421
+ !Array.isArray(value) &&
1422
+ Object.prototype.hasOwnProperty.call(value, HOST_CAPABILITIES_OPTION_KEY)) {
1423
+ throw new Error(`${path}.${HOST_CAPABILITIES_OPTION_KEY} is host-owned and cannot be configured`);
1424
+ }
1425
+ }
1091
1426
  // Resolve the active registry at init from exact normalized role and session
1092
1427
  // agent projections (CAPTAIN-16). No role, ancestor, or generated-name fallback
1093
1428
  // exists at this boundary.
1094
- async function buildEnablements(options, loadModule) {
1429
+ async function buildEnablements(options, loadModule, hostCapabilities) {
1095
1430
  const entries = [];
1096
1431
  const byCommand = new Map();
1097
1432
  const byId = new Map();
1098
1433
  const enablementById = new Map();
1434
+ const hostCapabilitiesById = new Map();
1435
+ const suppliedHostCapabilities = captureHostCapabilityRecord(hostCapabilities);
1436
+ const expectedHostCapabilityIds = [];
1099
1437
  const detached = snapshotJsonValue(options, 'captain.options');
1100
1438
  const top = snapshotRecord(detached, 'captain.options');
1101
1439
  rejectSnapshotKeys(top, ['playbooks', 'sessionAgents', 'captainAdapter'], 'captain.options');
@@ -1135,6 +1473,7 @@ async function buildEnablements(options, loadModule) {
1135
1473
  }
1136
1474
  const record = block;
1137
1475
  rejectSnapshotKeys(record, ['from', 'command', 'roles', 'options'], `captain.options.playbooks.${id}`);
1476
+ rejectConfiguredHostCapabilities(record.options, `captain.options.playbooks.${id}.options`);
1138
1477
  const from = record.from;
1139
1478
  if (typeof from !== 'string' || from.length === 0) {
1140
1479
  throw new Error(`captain.options.playbooks.${id}.from must be a module specifier`);
@@ -1146,10 +1485,27 @@ async function buildEnablements(options, loadModule) {
1146
1485
  catch (cause) {
1147
1486
  throw new Error(`captain.options.playbooks.${id}.from "${from}" failed to import: ${String(cause?.message ?? cause)}`);
1148
1487
  }
1149
- const entry = mod?.default;
1150
- if (!isValidRegistryEntry(entry)) {
1488
+ const capturedEntry = captureRegistryEntry(mod?.default);
1489
+ const artifactSchema = capturedEntry
1490
+ ?.artifactSchema;
1491
+ const runtimeProfile = validateRuntimeProfile(capturedEntry?.runtimeProfile);
1492
+ if (artifactSchema !== 3 ||
1493
+ runtimeProfile === undefined ||
1494
+ !isValidRegistryEntry(capturedEntry, artifactSchema)) {
1151
1495
  throw new Error(`captain.options.playbooks.${id}.from "${from}" exposes no valid registry entry`);
1152
1496
  }
1497
+ const entry = Object.freeze({
1498
+ ...capturedEntry,
1499
+ requiredRoleIds: Object.freeze([...capturedEntry.requiredRoleIds]),
1500
+ concurrentRoleSets: Object.freeze(capturedEntry.concurrentRoleSets.map((roles) => Object.freeze([...roles]))),
1501
+ });
1502
+ if (runtimeProfile.artifactSchema !== artifactSchema) {
1503
+ const implementation = runtimeProfile.kind === 'shared-factory'
1504
+ ? 'shared factory'
1505
+ : 'bespoke runtime';
1506
+ throw new Error(`captain.options.playbooks.${id}.from "${from}" advertises artifact schema ${artifactSchema} ` +
1507
+ `but its ${implementation} implements schema ${runtimeProfile.artifactSchema}`);
1508
+ }
1153
1509
  if (entry.id !== id) {
1154
1510
  throw new Error(`captain.options.playbooks.${id} key must equal the module manifest id "${entry.id}"`);
1155
1511
  }
@@ -1200,16 +1556,26 @@ async function buildEnablements(options, loadModule) {
1200
1556
  }
1201
1557
  }
1202
1558
  const validatedOptions = snapshotJsonValue(entry.validateOptions(record.options), `captain.options.playbooks.${id}.options`);
1559
+ rejectConfiguredHostCapabilities(validatedOptions, `captain.options.playbooks.${id}.options`);
1203
1560
  entries.push(entry);
1204
1561
  byId.set(entry.id, entry);
1205
1562
  byCommand.set(command, entry);
1563
+ const hostCapability = validateHostCapabilities(suppliedHostCapabilities[entry.id], entry, command);
1564
+ expectedHostCapabilityIds.push(entry.id);
1565
+ hostCapabilitiesById.set(entry.id, hostCapability);
1206
1566
  enablementById.set(entry.id, {
1207
1567
  entry,
1568
+ artifactSchema,
1208
1569
  command,
1209
1570
  options: validatedOptions,
1210
1571
  roleBindings,
1211
1572
  });
1212
1573
  }
1574
+ const suppliedHostCapabilityIds = Object.keys(suppliedHostCapabilities).sort();
1575
+ expectedHostCapabilityIds.sort();
1576
+ if (!isDeepStrictEqual(suppliedHostCapabilityIds, expectedHostCapabilityIds)) {
1577
+ throw new Error('current-host construction capabilities must exactly cover schema-3 playbooks');
1578
+ }
1213
1579
  const referenced = new Set([...enablementById.values()].flatMap((enablement) => [...enablement.roleBindings.values()].map((binding) => binding.playerId)));
1214
1580
  const unreferenced = [...playerAgents.keys()].find((id) => !referenced.has(id));
1215
1581
  if (unreferenced !== undefined) {
@@ -1220,6 +1586,7 @@ async function buildEnablements(options, loadModule) {
1220
1586
  byCommand,
1221
1587
  byId,
1222
1588
  enablementById,
1589
+ hostCapabilitiesById,
1223
1590
  captainAgent,
1224
1591
  playerAgents,
1225
1592
  };
@@ -1228,6 +1595,17 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1228
1595
  const loadModule = deps.loadModule ?? ((specifier) => import(specifier));
1229
1596
  const createSessionId = deps.createSessionId ?? randomUUID;
1230
1597
  const createCaptainRuntime = deps.createCaptainRuntime ?? createDefaultCaptainRuntime;
1598
+ const unresolvedEffectSettlement = deps.unresolvedEffectSettlement;
1599
+ let pendingHostCapabilities = deps.hostCapabilities;
1600
+ let currentEffectLedger = () => emptyPlaybookEffectLedger();
1601
+ // The returned shell must not retain the caller's aggregate dependency
1602
+ // object after its one live capability input has moved to a clearable slot.
1603
+ deps = {};
1604
+ const buildCurrentEnablements = async () => {
1605
+ const hostCapabilities = pendingHostCapabilities;
1606
+ pendingHostCapabilities = undefined;
1607
+ return buildEnablements(options, loadModule, hostCapabilities);
1608
+ };
1231
1609
  let captainAgent;
1232
1610
  let captainAdapter;
1233
1611
  let playerAgents = new Map();
@@ -1237,6 +1615,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1237
1615
  let byCommand = new Map();
1238
1616
  let byId = new Map();
1239
1617
  let enablementById = new Map();
1618
+ let hostCapabilitiesById = new Map();
1240
1619
  let session;
1241
1620
  let sessionEmissionsOpen = false;
1242
1621
  let closedGateAttempted = false;
@@ -1245,6 +1624,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1245
1624
  let activeContext;
1246
1625
  const frames = [];
1247
1626
  let mode = 'chat';
1627
+ let retainedEffectReconciliation;
1248
1628
  let pendingBossQuestions;
1249
1629
  let lastError;
1250
1630
  let activeTurnSummary;
@@ -1314,6 +1694,19 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1314
1694
  let decisionCall;
1315
1695
  let lastAction;
1316
1696
  let lastSettlementStatus;
1697
+ const retainedGenerationCandidates = new Map();
1698
+ const pendingRetentionUpdates = new Map();
1699
+ const retainedGenerations = new Map();
1700
+ const retainedGenerationOffers = new Map();
1701
+ const ineligibleRetainedGenerations = new Set();
1702
+ const retainedGenerationRootClears = new Set();
1703
+ const retiredRetainedRuntimes = [];
1704
+ let retainedGenerationsInstalled = false;
1705
+ let retainedGenerationInstallationInProgress = false;
1706
+ let retainedGenerationInstallationClosed = false;
1707
+ let retentionSettlementReady = false;
1708
+ let abandonmentSettlementUnsafe = false;
1709
+ let settledTurnUnresolvedEffects;
1317
1710
  // DR-029: a run that lands in the runtime's own failure state
1318
1711
  // is an outcome the report must name. `processFrameResult` records it here
1319
1712
  // and the settling selection folds it into its facts, so the grounding the
@@ -1322,6 +1715,568 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1322
1715
  const rootFrame = () => frames[0];
1323
1716
  const leafFrame = () => frames.at(-1);
1324
1717
  const frameLabel = (frame) => `/${frame.enablement.command}`;
1718
+ const capturedUnresolvedEnvelopeReferences = (frame) => {
1719
+ let advertisesUnresolved = false;
1720
+ try {
1721
+ advertisesUnresolved =
1722
+ frame.runtime
1723
+ .describe?.()
1724
+ .actions.some(({ id }) => id === UNRESOLVED_EFFECT_RECONCILIATION_ACTION_ID ||
1725
+ id === UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID) === true;
1726
+ }
1727
+ catch {
1728
+ advertisesUnresolved = true;
1729
+ }
1730
+ if (typeof frame.runtime.unresolvedEffectEnvelopes !== 'function') {
1731
+ if (advertisesUnresolved) {
1732
+ throw new Error(`${frameLabel(frame)} unresolved-effect runtime exposes no envelope identities`);
1733
+ }
1734
+ return [];
1735
+ }
1736
+ const detached = snapshotJsonValue(frame.runtime.unresolvedEffectEnvelopes(), `${frameLabel(frame)} unresolved effect envelope identities`);
1737
+ if (!Array.isArray(detached)) {
1738
+ throw new TypeError(`${frameLabel(frame)} unresolved effect envelope identities must be an array`);
1739
+ }
1740
+ return detached.map((raw, index) => {
1741
+ const path = `${frameLabel(frame)} unresolved effect envelope identities[${index}]`;
1742
+ const record = snapshotRecord(raw, path);
1743
+ if (record.kind === 'boundary') {
1744
+ rejectSnapshotKeys(record, ['kind', 'boundaryId'], path);
1745
+ return {
1746
+ kind: 'boundary',
1747
+ boundaryId: snapshotString(record.boundaryId, `${path}.boundaryId`),
1748
+ };
1749
+ }
1750
+ if (record.kind === 'logical-operation') {
1751
+ rejectSnapshotKeys(record, ['kind', 'operationId'], path);
1752
+ return {
1753
+ kind: 'logical-operation',
1754
+ operationId: snapshotString(record.operationId, `${path}.operationId`),
1755
+ };
1756
+ }
1757
+ throw new TypeError(`${path}.kind is not supported`);
1758
+ });
1759
+ };
1760
+ const unresolvedEffectFromReceipt = (receipt, baselineHead = receipt.baseline.head) => {
1761
+ if (receipt.classification === 'unchanged')
1762
+ return undefined;
1763
+ return {
1764
+ classification: receipt.classification,
1765
+ baselineHead,
1766
+ ...(receipt.after === undefined ? {} : { afterHead: receipt.after.head }),
1767
+ ...(receipt.commitOid === undefined ? {} : { commitOid: receipt.commitOid }),
1768
+ };
1769
+ };
1770
+ const cumulativeOpenLogicalEffect = (operation, boundaries) => {
1771
+ const original = operation.originalBaseline;
1772
+ const latest = boundaries.at(-1);
1773
+ const after = latest.after ?? operation.checkpoint;
1774
+ const receipt = latest.physicalReceipt;
1775
+ if (receipt === undefined) {
1776
+ return {
1777
+ classification: 'incomplete',
1778
+ baselineHead: original.head,
1779
+ ...(after === undefined ? {} : { afterHead: after.head }),
1780
+ };
1781
+ }
1782
+ if (after === undefined) {
1783
+ return unresolvedEffectFromReceipt(receipt, original.head);
1784
+ }
1785
+ // An open deferred chain has no authoritative cumulative receipt. Reuse
1786
+ // physical ancestry only while every preceding checkpoint is one of the
1787
+ // same-HEAD dispositions that can lawfully keep a deferred operation
1788
+ // open. Any other history is bounded but not cumulatively attributable.
1789
+ const checkpointChainIsSafe = boundaries
1790
+ .slice(0, -1)
1791
+ .every((boundary) => boundary.after?.head === original.head &&
1792
+ (boundary.physicalReceipt?.classification === 'unchanged' ||
1793
+ boundary.physicalReceipt?.classification ===
1794
+ 'worktree-only-change'));
1795
+ if (!checkpointChainIsSafe || latest.baseline.head !== original.head) {
1796
+ return {
1797
+ classification: 'observation-ambiguous',
1798
+ baselineHead: original.head,
1799
+ afterHead: after.head,
1800
+ };
1801
+ }
1802
+ if (receipt.classification !== 'unchanged' &&
1803
+ receipt.classification !== 'worktree-only-change' &&
1804
+ receipt.classification !== 'one-descendant-commit') {
1805
+ return unresolvedEffectFromReceipt(receipt, original.head);
1806
+ }
1807
+ const sameProjection = isDeepStrictEqual(original.projection, after.projection);
1808
+ if (after.head === original.head) {
1809
+ if (receipt.classification !== 'unchanged' &&
1810
+ receipt.classification !== 'worktree-only-change') {
1811
+ return {
1812
+ classification: 'observation-ambiguous',
1813
+ baselineHead: original.head,
1814
+ afterHead: after.head,
1815
+ };
1816
+ }
1817
+ if (sameProjection)
1818
+ return undefined;
1819
+ const preservesOriginal = Object.entries(original.projection).every(([path, entry]) => Object.hasOwn(after.projection, path) &&
1820
+ isDeepStrictEqual(entry, after.projection[path]));
1821
+ return {
1822
+ classification: preservesOriginal
1823
+ ? 'worktree-only-change'
1824
+ : 'observation-ambiguous',
1825
+ baselineHead: original.head,
1826
+ afterHead: after.head,
1827
+ };
1828
+ }
1829
+ if (receipt.classification === 'one-descendant-commit' &&
1830
+ sameProjection) {
1831
+ return {
1832
+ classification: 'one-descendant-commit',
1833
+ baselineHead: original.head,
1834
+ afterHead: after.head,
1835
+ commitOid: after.head,
1836
+ };
1837
+ }
1838
+ return {
1839
+ classification: 'observation-ambiguous',
1840
+ baselineHead: original.head,
1841
+ afterHead: after.head,
1842
+ };
1843
+ };
1844
+ const projectUnresolvedEffects = (ledger, references) => {
1845
+ const pendingReferences = [...references];
1846
+ const projected = [];
1847
+ const seenBoundaries = new Set();
1848
+ const seenOperations = new Set();
1849
+ for (let index = 0; index < pendingReferences.length; index += 1) {
1850
+ const reference = pendingReferences[index];
1851
+ if (reference.kind === 'boundary') {
1852
+ if (seenBoundaries.has(reference.boundaryId))
1853
+ continue;
1854
+ seenBoundaries.add(reference.boundaryId);
1855
+ const boundary = ledger.boundaries.find(({ boundaryId }) => boundaryId === reference.boundaryId);
1856
+ if (boundary === undefined) {
1857
+ throw new Error(`unresolved effect boundary ${JSON.stringify(reference.boundaryId)} is absent from the authoritative ledger`);
1858
+ }
1859
+ if (boundary.logicalOperationId !== undefined) {
1860
+ if (!seenOperations.has(boundary.logicalOperationId)) {
1861
+ pendingReferences.push({
1862
+ kind: 'logical-operation',
1863
+ operationId: boundary.logicalOperationId,
1864
+ });
1865
+ }
1866
+ continue;
1867
+ }
1868
+ const effect = boundary.physicalReceipt === undefined
1869
+ ? {
1870
+ classification: 'incomplete',
1871
+ baselineHead: boundary.baseline.head,
1872
+ ...(boundary.after === undefined
1873
+ ? {}
1874
+ : { afterHead: boundary.after.head }),
1875
+ }
1876
+ : unresolvedEffectFromReceipt(boundary.physicalReceipt);
1877
+ if (effect !== undefined) {
1878
+ projected.push({ order: boundary.sequence, effect });
1879
+ }
1880
+ continue;
1881
+ }
1882
+ if (seenOperations.has(reference.operationId))
1883
+ continue;
1884
+ seenOperations.add(reference.operationId);
1885
+ const operation = ledger.logicalOperations.find(({ operationId }) => operationId === reference.operationId);
1886
+ if (operation === undefined) {
1887
+ throw new Error(`unresolved logical operation ${JSON.stringify(reference.operationId)} is absent from the authoritative ledger`);
1888
+ }
1889
+ const boundaries = operation.boundaryIds.map((boundaryId) => {
1890
+ const boundary = ledger.boundaries.find((candidate) => candidate.boundaryId === boundaryId);
1891
+ if (boundary === undefined) {
1892
+ throw new Error(`unresolved logical operation ${JSON.stringify(reference.operationId)} names an absent boundary`);
1893
+ }
1894
+ seenBoundaries.add(boundaryId);
1895
+ return boundary;
1896
+ });
1897
+ let effect;
1898
+ if (operation.logicalReceipt !== undefined) {
1899
+ effect = unresolvedEffectFromReceipt(operation.logicalReceipt, operation.originalBaseline.head);
1900
+ }
1901
+ else {
1902
+ effect = cumulativeOpenLogicalEffect(operation, boundaries);
1903
+ }
1904
+ if (effect !== undefined) {
1905
+ projected.push({ order: boundaries[0].sequence, effect });
1906
+ }
1907
+ }
1908
+ return assertPlaybookCaptainUnresolvedEffects(projected
1909
+ .sort((left, right) => left.order - right.order)
1910
+ .map(({ effect }) => effect));
1911
+ };
1912
+ const currentUnresolvedEffects = () => {
1913
+ const ledger = assertPlaybookEffectLedger(currentEffectLedger());
1914
+ const references = [];
1915
+ for (const frame of frames) {
1916
+ references.push(...capturedUnresolvedEnvelopeReferences(frame));
1917
+ }
1918
+ if (retainedEffectReconciliation !== undefined) {
1919
+ for (const boundary of ledger.boundaries.slice(retainedEffectReconciliation.checkpoint.boundaries.length)) {
1920
+ if (boundary.physicalReceipt?.classification === 'unchanged') {
1921
+ continue;
1922
+ }
1923
+ references.push(boundary.logicalOperationId === undefined
1924
+ ? { kind: 'boundary', boundaryId: boundary.boundaryId }
1925
+ : {
1926
+ kind: 'logical-operation',
1927
+ operationId: boundary.logicalOperationId,
1928
+ });
1929
+ }
1930
+ }
1931
+ return projectUnresolvedEffects(ledger, references);
1932
+ };
1933
+ const freezeTurnUnresolvedEffects = () => {
1934
+ const turn = activeTurn;
1935
+ if (turn?.unresolvedEffects !== undefined)
1936
+ return turn.unresolvedEffects;
1937
+ const frozen = currentUnresolvedEffects();
1938
+ if (turn !== undefined)
1939
+ turn.unresolvedEffects = frozen;
1940
+ settledTurnUnresolvedEffects = frozen;
1941
+ return frozen;
1942
+ };
1943
+ const normalizeInstalledRetainedGenerations = (value) => {
1944
+ const path = 'Captain retained generations';
1945
+ const detached = snapshotJsonValue(value, path);
1946
+ const record = snapshotRecord(detached, path);
1947
+ const authoritativeEffectLedger = assertPlaybookEffectLedger(currentEffectLedger(), `${path} current host effect ledger`);
1948
+ const sourceSessionIds = new Set();
1949
+ const normalized = new Map();
1950
+ for (const [rootPlaybookId, rawGeneration] of Object.entries(record)) {
1951
+ const generationPath = `${path}[${JSON.stringify(rootPlaybookId)}]`;
1952
+ const enablement = enablementById.get(rootPlaybookId);
1953
+ if (enablement === undefined) {
1954
+ throw new TypeError(`${generationPath} names a disabled root playbook`);
1955
+ }
1956
+ const generation = snapshotRecord(rawGeneration, generationPath);
1957
+ rejectSnapshotKeys(generation, [
1958
+ 'effectLedger',
1959
+ 'frames',
1960
+ 'retainedEffectReconciliation',
1961
+ 'rootStateDescription',
1962
+ ], generationPath);
1963
+ const generationEffectLedger = assertPlaybookEffectLedger(generation.effectLedger, `${generationPath}.effectLedger`);
1964
+ if (generationEffectLedger.boundaries.some(({ physicalReceipt }) => physicalReceipt === undefined)) {
1965
+ throw new TypeError(`${generationPath}.effectLedger contains an incomplete physical boundary`);
1966
+ }
1967
+ if (!isPlaybookEffectLedgerMonotonicExtension(generationEffectLedger, authoritativeEffectLedger)) {
1968
+ throw new TypeError(`${generationPath}.effectLedger is not a monotonic prefix of the current host mirror`);
1969
+ }
1970
+ const generationReconciliation = generation.retainedEffectReconciliation === undefined
1971
+ ? undefined
1972
+ : snapshotRecord(generation.retainedEffectReconciliation, `${generationPath}.retainedEffectReconciliation`);
1973
+ if (generationReconciliation !== undefined) {
1974
+ rejectSnapshotKeys(generationReconciliation, ['sourceGenerationId'], `${generationPath}.retainedEffectReconciliation`);
1975
+ }
1976
+ const sourceGenerationId = generationReconciliation === undefined
1977
+ ? undefined
1978
+ : snapshotUuid(generationReconciliation.sourceGenerationId, `${generationPath}.retainedEffectReconciliation.sourceGenerationId`);
1979
+ if (!Array.isArray(generation.frames) || generation.frames.length === 0) {
1980
+ throw new TypeError(`${generationPath}.frames must be non-empty`);
1981
+ }
1982
+ const rootStateDescription = generation.rootStateDescription === undefined
1983
+ ? undefined
1984
+ : snapshotString(generation.rootStateDescription, `${generationPath}.rootStateDescription`);
1985
+ const normalizedFrames = [];
1986
+ const playbookIds = new Set();
1987
+ let markedCaptureEffectLedger;
1988
+ for (const [index, rawFrame] of generation.frames.entries()) {
1989
+ const framePath = `${generationPath}.frames[${index}]`;
1990
+ const frame = snapshotRecord(rawFrame, framePath);
1991
+ rejectSnapshotKeys(frame, [
1992
+ 'playbookId',
1993
+ 'sessionId',
1994
+ 'rootSessionId',
1995
+ 'depth',
1996
+ 'parentSessionId',
1997
+ 'parentCallId',
1998
+ 'options',
1999
+ 'roleBindings',
2000
+ 'runtime',
2001
+ ], framePath);
2002
+ const playbookId = snapshotString(frame.playbookId, `${framePath}.playbookId`);
2003
+ const frameEnablement = enablementById.get(playbookId);
2004
+ if (frameEnablement === undefined) {
2005
+ throw new TypeError(`${framePath} names a disabled playbook`);
2006
+ }
2007
+ if (playbookIds.has(playbookId)) {
2008
+ throw new TypeError(`${generationPath}.frames must not contain a playbook cycle`);
2009
+ }
2010
+ playbookIds.add(playbookId);
2011
+ const sessionId = snapshotUuid(frame.sessionId, `${framePath}.sessionId`);
2012
+ if (sourceSessionIds.has(sessionId)) {
2013
+ throw new TypeError(`${path} frame session ids must be unique across generations`);
2014
+ }
2015
+ sourceSessionIds.add(sessionId);
2016
+ const rootSessionId = snapshotUuid(frame.rootSessionId, `${framePath}.rootSessionId`);
2017
+ const depth = snapshotInteger(frame.depth, `${framePath}.depth`);
2018
+ const parentSessionId = frame.parentSessionId === undefined
2019
+ ? undefined
2020
+ : snapshotUuid(frame.parentSessionId, `${framePath}.parentSessionId`);
2021
+ const parentCallId = frame.parentCallId === undefined
2022
+ ? undefined
2023
+ : snapshotString(frame.parentCallId, `${framePath}.parentCallId`);
2024
+ const options = frame.options;
2025
+ if (index === 0 &&
2026
+ !isDeepStrictEqual(options, frameEnablement.options)) {
2027
+ throw new TypeError(`${framePath}.options changed`);
2028
+ }
2029
+ const roleBindings = snapshotFrameRoleBindings(frame.roleBindings, `${framePath}.roleBindings`);
2030
+ if (!isDeepStrictEqual(Object.keys(roleBindings).sort(), [...frameEnablement.entry.requiredRoleIds].sort())) {
2031
+ throw new TypeError(`${framePath}.roleBindings do not cover the current role set`);
2032
+ }
2033
+ const runtime = assertPlaybookRuntimeSnapshot(frame.runtime, playbookId, { allowSuspendedCall: true });
2034
+ const retainedReconciliation = runtime.retainedEffectReconciliation;
2035
+ if (retainedReconciliation === undefined) {
2036
+ if (!isDeepStrictEqual(runtime.effectLedger, generationEffectLedger)) {
2037
+ throw new TypeError(`${framePath}.runtime effect ledger differs from the retained checkpoint`);
2038
+ }
2039
+ }
2040
+ else {
2041
+ if (!isDeepStrictEqual(retainedReconciliation.checkpoint, generationEffectLedger) ||
2042
+ isDeepStrictEqual(runtime.effectLedger, generationEffectLedger) ||
2043
+ !isPlaybookEffectLedgerMonotonicExtension(runtime.effectLedger, authoritativeEffectLedger)) {
2044
+ throw new TypeError(`${framePath}.runtime retained-effect evidence is inconsistent`);
2045
+ }
2046
+ if (markedCaptureEffectLedger === undefined) {
2047
+ markedCaptureEffectLedger = runtime.effectLedger;
2048
+ }
2049
+ else if (!isDeepStrictEqual(runtime.effectLedger, markedCaptureEffectLedger)) {
2050
+ throw new TypeError(`${framePath}.runtime effect ledger differs from the marked generation capture mirror`);
2051
+ }
2052
+ }
2053
+ if (runtime.state.status !== 'active' ||
2054
+ !runtime.state.quiescent ||
2055
+ typeof runtime.state.stateId !== 'string' ||
2056
+ runtime.state.stateId.trim().length === 0) {
2057
+ throw new TypeError(`${framePath}.runtime must be active, quiescent, and state-identified`);
2058
+ }
2059
+ for (const question of runtime.pendingBossQuestions) {
2060
+ if (question.asker.kind === 'role' &&
2061
+ roleBindings[question.asker.roleId] === undefined) {
2062
+ throw new TypeError(`${framePath}.runtime pending question names an unbound role`);
2063
+ }
2064
+ }
2065
+ for (const roleId of Object.keys(runtime.roleResumeTokens)) {
2066
+ if (roleBindings[roleId] === undefined) {
2067
+ throw new TypeError(`${framePath}.runtime role-resume token names an unbound role`);
2068
+ }
2069
+ }
2070
+ normalizedFrames.push({
2071
+ playbookId,
2072
+ sessionId,
2073
+ rootSessionId,
2074
+ depth,
2075
+ ...(parentSessionId === undefined ? {} : { parentSessionId }),
2076
+ ...(parentCallId === undefined ? {} : { parentCallId }),
2077
+ options,
2078
+ roleBindings,
2079
+ runtime,
2080
+ });
2081
+ }
2082
+ const sourceRootSessionId = normalizedFrames[0].sessionId;
2083
+ for (const [index, frame] of normalizedFrames.entries()) {
2084
+ if (frame.depth !== index || frame.rootSessionId !== sourceRootSessionId) {
2085
+ throw new TypeError(`${generationPath}.frames have inconsistent depth or root identity`);
2086
+ }
2087
+ if (index === 0) {
2088
+ if (frame.playbookId !== rootPlaybookId ||
2089
+ frame.sessionId !== frame.rootSessionId ||
2090
+ frame.parentSessionId !== undefined ||
2091
+ frame.parentCallId !== undefined) {
2092
+ throw new TypeError(`${generationPath}.frames[0] is not the named root`);
2093
+ }
2094
+ continue;
2095
+ }
2096
+ const parent = normalizedFrames[index - 1];
2097
+ const suspended = parent.runtime.suspendedCall;
2098
+ if (frame.parentSessionId !== parent.sessionId ||
2099
+ frame.parentCallId === undefined ||
2100
+ suspended === undefined ||
2101
+ suspended.callId !== frame.parentCallId ||
2102
+ suspended.playbookId !== frame.playbookId ||
2103
+ suspended.childSessionId !== frame.sessionId) {
2104
+ throw new TypeError(`${generationPath}.frames[${index}] does not match its suspended parent edge`);
2105
+ }
2106
+ }
2107
+ const leaf = normalizedFrames.at(-1);
2108
+ if (leaf.runtime.suspendedCall !== undefined ||
2109
+ !leaf.runtime.state.tags.includes('playbook.parked')) {
2110
+ throw new TypeError(`${generationPath} leaf must be parked without a suspended child`);
2111
+ }
2112
+ const markedFrames = normalizedFrames.filter(({ runtime }) => runtime.retainedEffectReconciliation !== undefined);
2113
+ if ((sourceGenerationId === undefined && markedFrames.length !== 0) ||
2114
+ (sourceGenerationId !== undefined &&
2115
+ markedFrames.length !== normalizedFrames.length) ||
2116
+ (sourceGenerationId !== undefined &&
2117
+ normalizedFrames[0].runtime.retainedEffectReconciliation
2118
+ ?.sourceSessionId !== sourceGenerationId)) {
2119
+ throw new TypeError(`${generationPath} retained-effect source marker is inconsistent`);
2120
+ }
2121
+ normalized.set(rootPlaybookId, snapshotJsonValue({
2122
+ effectLedger: generationEffectLedger,
2123
+ frames: normalizedFrames,
2124
+ ...(sourceGenerationId === undefined
2125
+ ? {}
2126
+ : {
2127
+ retainedEffectReconciliation: { sourceGenerationId },
2128
+ }),
2129
+ ...(rootStateDescription === undefined
2130
+ ? {}
2131
+ : { rootStateDescription }),
2132
+ }, generationPath));
2133
+ }
2134
+ return normalized;
2135
+ };
2136
+ const runtimeRetainsGenerations = (runtime) => {
2137
+ const metadata = runtime.retainedGenerationMetadata;
2138
+ return (typeof runtime.exportSnapshot === 'function' &&
2139
+ typeof runtime.restore === 'function' &&
2140
+ typeof runtime.adopt === 'function' &&
2141
+ metadata !== undefined &&
2142
+ Array.isArray(metadata.unfinishedFinalStateIds) &&
2143
+ metadata.unfinishedFinalStateIds.every((stateId) => typeof stateId === 'string'));
2144
+ };
2145
+ class RetainedRuntimeCleanupError extends AggregateError {
2146
+ failedRuntimes;
2147
+ constructor(failures, message, failedRuntimes = []) {
2148
+ super(failures, message);
2149
+ this.name = 'RetainedRuntimeCleanupError';
2150
+ this.failedRuntimes = failedRuntimes;
2151
+ }
2152
+ }
2153
+ const disposeRetainedRuntimeSet = async (runtimes, message) => {
2154
+ const failures = [];
2155
+ const failedRuntimes = [];
2156
+ for (const runtime of [...runtimes].reverse()) {
2157
+ try {
2158
+ await runtime.dispose();
2159
+ }
2160
+ catch (error) {
2161
+ failures.push(error);
2162
+ failedRuntimes.unshift(runtime);
2163
+ }
2164
+ }
2165
+ if (failures.length > 0) {
2166
+ throw new RetainedRuntimeCleanupError(failures, message, failedRuntimes);
2167
+ }
2168
+ };
2169
+ const retireRetainedOffer = (rootPlaybookId) => {
2170
+ const offer = retainedGenerationOffers.get(rootPlaybookId);
2171
+ if (offer === undefined)
2172
+ return;
2173
+ retainedGenerationOffers.delete(rootPlaybookId);
2174
+ retiredRetainedRuntimes.push(...offer.runtimes);
2175
+ };
2176
+ const applyRetentionUpdateToCatalog = (update) => {
2177
+ if (update.kind === 'clear') {
2178
+ retireRetainedOffer(update.rootPlaybookId);
2179
+ retainedGenerations.delete(update.rootPlaybookId);
2180
+ ineligibleRetainedGenerations.delete(update.rootPlaybookId);
2181
+ retainedGenerationRootClears.delete(update.rootPlaybookId);
2182
+ return;
2183
+ }
2184
+ const prior = retainedGenerations.get(update.rootPlaybookId);
2185
+ if (isDeepStrictEqual(prior, update.generation))
2186
+ return;
2187
+ retireRetainedOffer(update.rootPlaybookId);
2188
+ retainedGenerations.set(update.rootPlaybookId, update.generation);
2189
+ ineligibleRetainedGenerations.delete(update.rootPlaybookId);
2190
+ retainedGenerationRootClears.delete(update.rootPlaybookId);
2191
+ };
2192
+ const drainRetiredRetainedRuntimes = async () => {
2193
+ if (retiredRetainedRuntimes.length === 0)
2194
+ return;
2195
+ const runtimes = retiredRetainedRuntimes.splice(0);
2196
+ try {
2197
+ await disposeRetainedRuntimeSet(runtimes, 'retired retained-generation runtime cleanup failed');
2198
+ }
2199
+ catch (error) {
2200
+ if (error instanceof RetainedRuntimeCleanupError) {
2201
+ retiredRetainedRuntimes.unshift(...error.failedRuntimes);
2202
+ }
2203
+ terminallyDisposed = true;
2204
+ lifecycle = 'closed';
2205
+ throw error;
2206
+ }
2207
+ };
2208
+ const takeRetainedOfferRuntimes = () => {
2209
+ const runtimes = [
2210
+ ...[...retainedGenerationOffers.values()].flatMap((offer) => [
2211
+ ...offer.runtimes,
2212
+ ]),
2213
+ ...retiredRetainedRuntimes.splice(0),
2214
+ ];
2215
+ retainedGenerationOffers.clear();
2216
+ return runtimes;
2217
+ };
2218
+ const prepareRetainedGenerationOffers = async () => {
2219
+ if (rootFrame() !== undefined)
2220
+ return;
2221
+ for (const [rootPlaybookId, generation] of [...retainedGenerations].sort(([left], [right]) => left.localeCompare(right))) {
2222
+ if (retainedGenerationOffers.has(rootPlaybookId) ||
2223
+ ineligibleRetainedGenerations.has(rootPlaybookId)) {
2224
+ continue;
2225
+ }
2226
+ const runtimes = [];
2227
+ try {
2228
+ for (const sourceFrame of generation.frames) {
2229
+ const enablement = enablementById.get(sourceFrame.playbookId);
2230
+ runtimes.push(createRuntimeForEnablement(enablement, hostCapabilitiesById));
2231
+ }
2232
+ if (runtimes.some((runtime) => !runtimeRetainsGenerations(runtime))) {
2233
+ const rootRetainsGenerations = runtimeRetainsGenerations(runtimes[0]);
2234
+ await disposeRetainedRuntimeSet(runtimes, `/${enablementById.get(rootPlaybookId).command} retained-generation capability cleanup failed`);
2235
+ runtimes.splice(0);
2236
+ ineligibleRetainedGenerations.add(rootPlaybookId);
2237
+ if (!rootRetainsGenerations) {
2238
+ retainedGenerationRootClears.add(rootPlaybookId);
2239
+ }
2240
+ continue;
2241
+ }
2242
+ retainedGenerationOffers.set(rootPlaybookId, {
2243
+ generation,
2244
+ requiresEffectReconciliation: generation.retainedEffectReconciliation !== undefined ||
2245
+ generation.frames.some(({ runtime }) => runtime.retainedEffectReconciliation !== undefined) ||
2246
+ !retainedEffectLedgerCanRebase(generation.effectLedger, assertPlaybookEffectLedger(currentEffectLedger())),
2247
+ runtimes,
2248
+ });
2249
+ }
2250
+ catch (error) {
2251
+ if (error instanceof RetainedRuntimeCleanupError) {
2252
+ retiredRetainedRuntimes.push(...error.failedRuntimes);
2253
+ terminallyDisposed = true;
2254
+ lifecycle = 'closed';
2255
+ throw error;
2256
+ }
2257
+ let cleanupError;
2258
+ if (runtimes.length > 0) {
2259
+ try {
2260
+ await disposeRetainedRuntimeSet(runtimes, 'retained-generation preparation cleanup failed');
2261
+ }
2262
+ catch (caught) {
2263
+ cleanupError = caught;
2264
+ }
2265
+ }
2266
+ if (cleanupError !== undefined) {
2267
+ if (cleanupError instanceof RetainedRuntimeCleanupError) {
2268
+ retiredRetainedRuntimes.push(...cleanupError.failedRuntimes);
2269
+ }
2270
+ terminallyDisposed = true;
2271
+ lifecycle = 'closed';
2272
+ throw new RetainedRuntimeCleanupError([error, cleanupError], 'retained-generation preparation and cleanup failed', cleanupError instanceof RetainedRuntimeCleanupError
2273
+ ? cleanupError.failedRuntimes
2274
+ : []);
2275
+ }
2276
+ ineligibleRetainedGenerations.add(rootPlaybookId);
2277
+ }
2278
+ }
2279
+ };
1325
2280
  const bindingFor = (frame, localRole) => {
1326
2281
  const binding = frame.playerBindings.get(localRole);
1327
2282
  if (!binding) {
@@ -1364,7 +2319,10 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1364
2319
  ...(leafFrame()?.state
1365
2320
  ? { latestSubRuntimeState: leafFrame().state }
1366
2321
  : {}),
1367
- ...(pendingBossQuestions !== undefined ? { pendingBossQuestions } : {}),
2322
+ ...(retainedEffectReconciliation === undefined &&
2323
+ pendingBossQuestions !== undefined
2324
+ ? { pendingBossQuestions }
2325
+ : {}),
1368
2326
  ...(lastError ? { lastError } : {}),
1369
2327
  ...(captainSessionId ? { captainSessionId } : {}),
1370
2328
  // Presence only: the pinned token value never reaches telemetry
@@ -1526,6 +2484,59 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1526
2484
  }
1527
2485
  }
1528
2486
  };
2487
+ const observeSummaryTrace = (frame, payload) => {
2488
+ const trace = payloadRecord(payload);
2489
+ const turn = activeTurn;
2490
+ const summary = activeTurnSummary;
2491
+ const expectedParentSessionId = frame.parent?.frame.sessionId;
2492
+ const expectedParentCallId = frame.parent?.callId;
2493
+ if (trace?.schemaVersion !== 4 ||
2494
+ trace.sessionId !== frame.sessionId ||
2495
+ trace.playbookId !== frame.entry.id ||
2496
+ trace.rootSessionId !== frame.rootSessionId ||
2497
+ (expectedParentSessionId === undefined
2498
+ ? Object.hasOwn(trace, 'parentSessionId')
2499
+ : !Object.hasOwn(trace, 'parentSessionId') ||
2500
+ trace.parentSessionId !== expectedParentSessionId) ||
2501
+ (expectedParentCallId === undefined
2502
+ ? Object.hasOwn(trace, 'parentCallId')
2503
+ : !Object.hasOwn(trace, 'parentCallId') ||
2504
+ trace.parentCallId !== expectedParentCallId) ||
2505
+ trace.depth !== frame.depth ||
2506
+ turn === undefined ||
2507
+ !Number.isSafeInteger(trace.turnId) ||
2508
+ trace.turnId <= 0 ||
2509
+ !Number.isSafeInteger(trace.sequence) ||
2510
+ trace.sequence <= 0 ||
2511
+ summary === undefined ||
2512
+ !summaryIncludes(frame)) {
2513
+ return;
2514
+ }
2515
+ if (trace.type !== 'outcome.accepted')
2516
+ return;
2517
+ const receipt = exactOwnDataRecord(trace.payload, [
2518
+ 'source',
2519
+ 'target',
2520
+ 'acceptedOutcome',
2521
+ ]);
2522
+ if (receipt === undefined ||
2523
+ typeof receipt.source !== 'string' ||
2524
+ receipt.source.trim().length === 0 ||
2525
+ typeof receipt.target !== 'string' ||
2526
+ receipt.target.trim().length === 0 ||
2527
+ typeof receipt.acceptedOutcome !== 'string' ||
2528
+ receipt.acceptedOutcome.trim().length === 0) {
2529
+ return;
2530
+ }
2531
+ const traceKey = `${frame.sessionId}:${trace.sequence}`;
2532
+ if (summary.acceptedOutcomeTraceKeys.has(traceKey))
2533
+ return;
2534
+ summary.acceptedOutcomeTraceKeys.add(traceKey);
2535
+ summary.counts.interruptions++;
2536
+ if (frame.entry.summaryPolicy?.copyPasteGuardNames.includes(receipt.acceptedOutcome)) {
2537
+ summary.counts.copyPastes++;
2538
+ }
2539
+ };
1529
2540
  let callNestedPlaybook;
1530
2541
  const createPorts = (frame) => ({
1531
2542
  callPlayer: async (roleId, prompt, signal, options) => {
@@ -1687,14 +2698,6 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1687
2698
  if (result.finalText === undefined) {
1688
2699
  throw new Error('callCaptain returned status=ok with no finalText');
1689
2700
  }
1690
- const guard = guardFromJudgeReply(result.finalText);
1691
- const summary = activeTurnSummary;
1692
- if (guard &&
1693
- summary &&
1694
- summaryIncludes(frame) &&
1695
- frame.entry.summaryPolicy?.copyPasteGuardNames.includes(guard)) {
1696
- summary.counts.copyPastes++;
1697
- }
1698
2701
  return result.finalText;
1699
2702
  },
1700
2703
  callPlaybook: (request, signal) => {
@@ -1727,6 +2730,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1727
2730
  await mirrorSubRuntimeTelemetry(frame, event.payload);
1728
2731
  }
1729
2732
  await requireSession().emitTelemetry(event);
2733
+ if (event.topic === 'playbook.trace') {
2734
+ observeSummaryTrace(frame, event.payload);
2735
+ }
1730
2736
  })();
1731
2737
  return trackHostCall(frame, emission);
1732
2738
  },
@@ -1748,17 +2754,42 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1748
2754
  throw new VisibilityControlError(error);
1749
2755
  }
1750
2756
  };
1751
- const allocateSessionId = () => {
2757
+ const generatedSessionId = () => {
1752
2758
  const sessionId = createSessionId();
1753
2759
  if (!UUID_PATTERN.test(sessionId)) {
1754
2760
  throw new Error(`playbook session id generator returned a non-UUID value: ${JSON.stringify(sessionId)}`);
1755
2761
  }
2762
+ return sessionId;
2763
+ };
2764
+ const allocateSessionId = () => {
2765
+ const sessionId = generatedSessionId();
1756
2766
  if (issuedSessionIds.has(sessionId)) {
1757
2767
  throw new Error(`playbook session id collision: ${sessionId}`);
1758
2768
  }
1759
2769
  issuedSessionIds.add(sessionId);
1760
2770
  return sessionId;
1761
2771
  };
2772
+ const allocateAdoptionSessionIds = (count, sourceSessionIds) => {
2773
+ const candidates = [];
2774
+ const rejectedSourceIds = new Set();
2775
+ while (candidates.length < count) {
2776
+ const candidate = generatedSessionId();
2777
+ if (sourceSessionIds.has(candidate)) {
2778
+ if (rejectedSourceIds.has(candidate)) {
2779
+ throw new Error(`playbook source session id collision: ${candidate}`);
2780
+ }
2781
+ rejectedSourceIds.add(candidate);
2782
+ continue;
2783
+ }
2784
+ if (issuedSessionIds.has(candidate) || candidates.includes(candidate)) {
2785
+ throw new Error(`playbook session id collision: ${candidate}`);
2786
+ }
2787
+ candidates.push(candidate);
2788
+ }
2789
+ for (const candidate of candidates)
2790
+ issuedSessionIds.add(candidate);
2791
+ return candidates;
2792
+ };
1762
2793
  const normalizeErrorFull = (value) => {
1763
2794
  const compact = normalizeErrorCompact(value) ?? {
1764
2795
  name: 'Error',
@@ -1778,7 +2809,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1778
2809
  const entry = enablement.entry;
1779
2810
  const sessionId = allocateSessionId();
1780
2811
  const playerBindings = makePlayerBindings(enablement);
1781
- const runtime = entry.createRuntime(enablement.options);
2812
+ const runtime = createRuntimeForEnablement(enablement, hostCapabilitiesById);
1782
2813
  return {
1783
2814
  entry,
1784
2815
  enablement,
@@ -1794,7 +2825,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1794
2825
  const makeRestoredFrame = (enablement, snapshot, parent) => {
1795
2826
  const entry = enablement.entry;
1796
2827
  const playerBindings = makePlayerBindings(enablement);
1797
- const runtime = entry.createRuntime(enablement.options);
2828
+ const runtime = createRuntimeForEnablement(enablement, hostCapabilitiesById);
1798
2829
  return {
1799
2830
  entry,
1800
2831
  enablement,
@@ -1844,14 +2875,6 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1844
2875
  delete ledger.resumeToken;
1845
2876
  else
1846
2877
  ledger.resumeToken = resumeToken;
1847
- // CAPTAIN-20: a result counts only after the runtime validated it and
1848
- // atomically published its authorized continuation transition.
1849
- const summary = activeTurnSummary;
1850
- if (pending.status === 'ok' &&
1851
- summary &&
1852
- summaryIncludes(frame)) {
1853
- summary.counts.interruptions++;
1854
- }
1855
2878
  }
1856
2879
  finally {
1857
2880
  playerTransactions.delete(binding.playerId);
@@ -2101,6 +3124,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2101
3124
  }
2102
3125
  }
2103
3126
  clearLeafLedger();
3127
+ if (frames.length === 0)
3128
+ retainedEffectReconciliation = undefined;
2104
3129
  if (failures.length === 1)
2105
3130
  throw failures[0];
2106
3131
  if (failures.length > 1) {
@@ -2205,7 +3230,46 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2205
3230
  throw new AggregateError(failures, 'playbook stack disposal failed');
2206
3231
  }
2207
3232
  };
3233
+ /**
3234
+ * DR-040 task 11: abandonment is a host settlement, not an authored FSM
3235
+ * result. Freeze the bounded evidence while the complete stack and its
3236
+ * runtime-owned envelope identities still exist, durably fence recovery,
3237
+ * dispose leaf-to-root without resuming a parent, then publish the matching
3238
+ * root clear and evidence as one durable completion before the controller
3239
+ * may return an executed receipt to its result phase.
3240
+ */
3241
+ const settleUnresolvedEffectAbandonment = async (unresolvedLeaf) => {
3242
+ if (leafFrame() !== unresolvedLeaf) {
3243
+ throw new Error('unresolved-effect abandonment requires the active leaf');
3244
+ }
3245
+ const root = rootFrame();
3246
+ if (root === undefined) {
3247
+ throw new Error('unresolved-effect abandonment requires an active root');
3248
+ }
3249
+ const unresolvedEffects = freezeTurnUnresolvedEffects();
3250
+ if (unresolvedEffects.length === 0) {
3251
+ throw new Error('unresolved-effect abandonment requires nonempty effect evidence');
3252
+ }
3253
+ if (unresolvedEffectSettlement === undefined) {
3254
+ throw new Error('unresolved-effect abandonment requires durable host settlement');
3255
+ }
3256
+ const rootPlaybookId = root.entry.id;
3257
+ const settlement = Object.freeze({
3258
+ rootPlaybookId,
3259
+ unresolvedEffects,
3260
+ });
3261
+ await runEffect(() => unresolvedEffectSettlement.begin(settlement));
3262
+ await runEffect(() => disposeStack('unresolved-effect'));
3263
+ pendingRetentionUpdates.set(rootPlaybookId, {
3264
+ kind: 'clear',
3265
+ rootPlaybookId,
3266
+ });
3267
+ await runEffect(() => unresolvedEffectSettlement.complete(settlement));
3268
+ };
2208
3269
  const callResultFor = (frame, result) => {
3270
+ if (result.outcome === 'unresolved-effect') {
3271
+ throw new Error(`playbook ${frame.entry.id} unresolved-effect result cannot resume a parent`);
3272
+ }
2209
3273
  if (result.outcome === 'terminal') {
2210
3274
  return {
2211
3275
  status: 'ok',
@@ -2240,6 +3304,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2240
3304
  if (leafFrame() !== frame) {
2241
3305
  throw new Error('only the active leaf may receive Boss input');
2242
3306
  }
3307
+ if (retainedEffectReconciliation !== undefined) {
3308
+ throw new Error('retained repository-effect reconciliation is required before Boss input');
3309
+ }
2243
3310
  // CAPTAIN-35: the leaf check, the visibility request, and the mode change
2244
3311
  // are shell control work performed on the way to the runtime, not the
2245
3312
  // effect. Only the call below is the effect, so only it is inside the
@@ -2257,6 +3324,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2257
3324
  if (!parentLink)
2258
3325
  throw new Error('root playbook has no caller');
2259
3326
  const parent = parentLink.frame;
3327
+ if (retainedEffectReconciliation !== undefined) {
3328
+ throw new Error('retained repository-effect reconciliation is required before parent resumption');
3329
+ }
2260
3330
  const invocationSignal = child.invocationSignal;
2261
3331
  let effectiveResult = callResult;
2262
3332
  let ownsReturn = false;
@@ -2325,6 +3395,16 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2325
3395
  }, context);
2326
3396
  }
2327
3397
  async function processFrameResult(frame, result, context) {
3398
+ if (result.outcome === 'unresolved-effect') {
3399
+ // Task 10 exposes the runtime-owned abandonment signal without
3400
+ // translating it into a nested result or claiming workflow completion.
3401
+ // Task 11 owns the durable host settlement and complete-stack disposal.
3402
+ assertRetainableResult(frame, result);
3403
+ if (leafFrame() === frame) {
3404
+ await setMode('engaged.parked', 'turn:unresolved-effect');
3405
+ }
3406
+ return;
3407
+ }
2328
3408
  if (result.outcome === 'terminal') {
2329
3409
  if (frame.parent) {
2330
3410
  await resumeParent(frame, callResultFor(frame, result), context);
@@ -2336,6 +3416,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2336
3416
  // output remains runtime-to-runtime data and never becomes Captain
2337
3417
  // evidence (CAPPLAY-10).
2338
3418
  activeTurn?.settlementFacts.push(rootCompletionFact(frame, result));
3419
+ recordTerminalRetention(frame, result);
2339
3420
  await runEffect(() => disposeStack('final'));
2340
3421
  }
2341
3422
  return;
@@ -2515,7 +3596,14 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2515
3596
  const policy = frame.entry.summaryPolicy;
2516
3597
  const counts = { interruptions: 0, copyPastes: 0 };
2517
3598
  const stateCounts = new Map();
2518
- activeTurnSummary = policy ? { owner: frame, counts, stateCounts } : undefined;
3599
+ activeTurnSummary = policy
3600
+ ? {
3601
+ owner: frame,
3602
+ counts,
3603
+ stateCounts,
3604
+ acceptedOutcomeTraceKeys: new Set(),
3605
+ }
3606
+ : undefined;
2519
3607
  let result;
2520
3608
  let error;
2521
3609
  try {
@@ -2580,12 +3668,58 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2580
3668
  ...entries.map(([key, value]) => digestLine `- ${key}: ${JSON.stringify(value)}`),
2581
3669
  ];
2582
3670
  };
3671
+ const retainedResumptionDigest = () => {
3672
+ if (rootFrame() !== undefined) {
3673
+ return 'Retained resumptions: unavailable while a playbook is engaged.';
3674
+ }
3675
+ const offers = [...retainedGenerationOffers].sort(([left], [right]) => left.localeCompare(right));
3676
+ if (offers.length === 0)
3677
+ return 'Retained resumptions: none.';
3678
+ const lines = ['Retained resumptions:'];
3679
+ for (const [rootPlaybookId, offer] of offers) {
3680
+ const enablement = enablementById.get(rootPlaybookId);
3681
+ lines.push(digestLine `- ${rootPlaybookId} (/${enablement.command}): ${offer.generation.rootStateDescription ??
3682
+ '(no published root-state description was retained)'}`);
3683
+ }
3684
+ return lines.join('\n');
3685
+ };
2583
3686
  const controlViewDigest = () => {
2584
3687
  const leaf = leafFrame();
2585
3688
  const lines = [digestLine `Active path: ${activePathDigest()}`];
2586
3689
  if (!leaf) {
2587
3690
  lines.push('The shell is idle: no leaf state, no pending question.');
2588
3691
  lines.push('Advertised actions: none.');
3692
+ lines.push(retainedResumptionDigest());
3693
+ return lines.join('\n');
3694
+ }
3695
+ refreshRetainedEffectFence();
3696
+ if (retainedEffectReconciliation !== undefined) {
3697
+ let reconciliationActions = [];
3698
+ if (typeof leaf.runtime.describe === 'function' &&
3699
+ typeof leaf.runtime.apply === 'function') {
3700
+ try {
3701
+ reconciliationActions = leaf.runtime
3702
+ .describe()
3703
+ .actions.filter(({ id }) => id === UNRESOLVED_EFFECT_RECONCILIATION_ACTION_ID ||
3704
+ id === UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID);
3705
+ }
3706
+ catch {
3707
+ // An unreadable control surface cannot open a fail-closed fence.
3708
+ }
3709
+ }
3710
+ for (const action of reconciliationActions) {
3711
+ recordSuppliedIdentifier(action.id);
3712
+ }
3713
+ lines.push(`Leaf ${frameLabel(leaf)} is parked for repository-effect reconciliation.`);
3714
+ lines.push('Pending Boss questions: withheld until reconciliation.');
3715
+ lines.push(reconciliationActions.length === 0
3716
+ ? 'Advertised actions: none.'
3717
+ : [
3718
+ 'Advertised actions:',
3719
+ ...reconciliationActions.map((action) => digestLine `- ${action.id}: ${action.label}`),
3720
+ ].join('\n'));
3721
+ lines.push('Ordinary delivery, switching, dismissal, and runtime actions are unavailable while retained effect evidence is unresolved. Only the advertised unresolved-effect controls may run. Conversation is unaffected: `respond` stays valid for any turn.');
3722
+ lines.push(retainedResumptionDigest());
2589
3723
  return lines.join('\n');
2590
3724
  }
2591
3725
  let view;
@@ -2634,6 +3768,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2634
3768
  lines.push(describeFailure === undefined
2635
3769
  ? 'This leaf advertises no runtime action, so plain text delivery is the only machine verb against it and a `runtime` selection is invalid. Conversation is unaffected: `respond` stays valid for any turn.'
2636
3770
  : 'No runtime action can be validated while the control view is unreadable, so plain text delivery is the only machine verb against it this turn and a `runtime` selection is invalid. Conversation is unaffected: `respond` stays valid for any turn.');
3771
+ lines.push(retainedResumptionDigest());
2637
3772
  return lines.join('\n');
2638
3773
  }
2639
3774
  // CAPTAIN-9: the guarded set is what the digest supplies *for selection* —
@@ -2672,6 +3807,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2672
3807
  'Advertised actions:',
2673
3808
  ...view.actions.map((action) => digestLine `- ${action.id}: ${action.label}`),
2674
3809
  ].join('\n'));
3810
+ lines.push(retainedResumptionDigest());
2675
3811
  return lines.join('\n');
2676
3812
  };
2677
3813
  // The catalog is registry-authored, not shell-authored: an id, a command,
@@ -2747,9 +3883,13 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2747
3883
  // all of this prose. Preserve the exact attempt before crossing the
2748
3884
  // boundary; the uncertainty record below keeps recovery from pretending
2749
3885
  // delivery was confirmed while still understanding a Boss follow-up.
2750
- appendJournal('reply', settlement.text);
3886
+ const suffix = turn?.mandatoryPresentationSuffix;
3887
+ const visibleText = suffix === undefined || settlement.text.includes(suffix)
3888
+ ? settlement.text
3889
+ : `${settlement.text.trimEnd()}\n\n${suffix}`;
3890
+ appendJournal('reply', visibleText);
2751
3891
  try {
2752
- await trackTurnCall(settlement.context.emitReply(settlement.text));
3892
+ await trackTurnCall(settlement.context.emitReply(visibleText));
2753
3893
  }
2754
3894
  catch (error) {
2755
3895
  conversation = { kind: 'needsSeeding' };
@@ -2889,8 +4029,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2889
4029
  }
2890
4030
  };
2891
4031
  /**
2892
- * CAPTAIN-35: the one wrapper an effect runs through — a runtime driven, an
2893
- * engagement constructed, a stack disposed, an advertised action applied.
4032
+ * CAPTAIN-35: the one wrapper an effect runs through — a runtime driven or
4033
+ * adopted, an engagement constructed, a stack disposed, an advertised
4034
+ * action applied.
2894
4035
  * Attribution is recorded here, at the operation that threw, and nowhere
2895
4036
  * else: an error acquires the mark by escaping this call, so no later
2896
4037
  * failure can inherit it.
@@ -2903,7 +4044,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2903
4044
  * instead of settling, so a misfiling costs the Boss their only settlement.
2904
4045
  *
2905
4046
  * Neither can a boundary drawn around a *region* of the turn. `operation` is
2906
- * therefore always one call expression naming one of those four operations,
4047
+ * therefore always one call expression naming one of those five operations,
2907
4048
  * never a closure that also performs the shell work leading to it: the leaf
2908
4049
  * check, the visibility request, the mode change, and the processing of what
2909
4050
  * the runtime returned are all shell control work, and a boundary wide
@@ -3103,7 +4244,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3103
4244
  ...(kind === 'closingReply' && turn?.report
3104
4245
  ? [
3105
4246
  labeledBlock('ControlView digest', controlViewDigest()),
3106
- outcomeReportBlock(turn.report),
4247
+ outcomeReportBlock(turn.report, turn.unresolvedEffects ?? []),
3107
4248
  ]
3108
4249
  : []),
3109
4250
  ...(options.proseRejection === undefined
@@ -3259,6 +4400,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3259
4400
  const leaf = leafFrame();
3260
4401
  if (!leaf)
3261
4402
  return 'idle: no playbook is engaged';
4403
+ if (retainedEffectReconciliation !== undefined) {
4404
+ return `${frameLabel(leaf)} parked for repository-effect reconciliation`;
4405
+ }
3262
4406
  if (!leaf.state)
3263
4407
  return `${frameLabel(leaf)} engaged`;
3264
4408
  return `${frameLabel(leaf)} at ${stateDigestLine(leaf.state, leafStateDescription(leaf))}`;
@@ -3305,6 +4449,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3305
4449
  if (!root)
3306
4450
  return false;
3307
4451
  const label = frameLabel(root);
4452
+ // Dismissal leaves the procedure unfinished. Persist the latest safe
4453
+ // generation captured for this turn before disposal erases the frames.
4454
+ retainOrClearDisposedRoot(root);
3308
4455
  try {
3309
4456
  await runEffect(() => disposeStack('dismiss'));
3310
4457
  facts.push(`Dismissed the ${label} engagement.`);
@@ -3351,6 +4498,133 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3351
4498
  }
3352
4499
  return { frame, report: outcome.report, failed: false };
3353
4500
  };
4501
+ const adoptRetainedGeneration = async (rootPlaybookId, offer) => {
4502
+ const generation = offer.generation;
4503
+ const currentLedger = assertPlaybookEffectLedger(currentEffectLedger());
4504
+ const requiresEffectReconciliation = offer.requiresEffectReconciliation ||
4505
+ !retainedEffectLedgerCanRebase(generation.effectLedger, currentLedger);
4506
+ const sourceSessionIds = new Set(generation.frames.map((frame) => frame.sessionId));
4507
+ const targetSessionIds = allocateAdoptionSessionIds(generation.frames.length, sourceSessionIds);
4508
+ const targetRootSessionId = targetSessionIds[0];
4509
+ const adoptedFrames = [];
4510
+ for (const [index, sourceFrame] of generation.frames.entries()) {
4511
+ const enablement = enablementById.get(sourceFrame.playbookId);
4512
+ const parent = adoptedFrames.at(-1);
4513
+ adoptedFrames.push({
4514
+ entry: enablement.entry,
4515
+ enablement,
4516
+ runtime: offer.runtimes[index],
4517
+ sessionId: targetSessionIds[index],
4518
+ rootSessionId: targetRootSessionId,
4519
+ depth: index,
4520
+ playerBindings: makePlayerBindings(enablement),
4521
+ ...(parent
4522
+ ? { parent: { frame: parent, callId: 'playbook-1' } }
4523
+ : {}),
4524
+ state: sourceFrame.runtime.state,
4525
+ inFlightHostCalls: new Set(),
4526
+ });
4527
+ }
4528
+ retainedGenerationOffers.delete(rootPlaybookId);
4529
+ let installed = false;
4530
+ try {
4531
+ for (const [index, frame] of adoptedFrames.entries()) {
4532
+ const sourceFrame = generation.frames[index];
4533
+ const targetChild = adoptedFrames[index + 1];
4534
+ await runEffect(() => frame.runtime.adopt(frameSession(frame), sourceFrame.runtime, {
4535
+ sourceSessionId: sourceFrame.sessionId,
4536
+ sourceGenerationId: generation.frames[0].rootSessionId,
4537
+ ...(targetChild === undefined
4538
+ ? {}
4539
+ : { targetChildSessionId: targetChild.sessionId }),
4540
+ }));
4541
+ }
4542
+ frames.push(...adoptedFrames);
4543
+ installed = true;
4544
+ retainedEffectReconciliation = requiresEffectReconciliation
4545
+ ? {
4546
+ sourceGenerationId: generation.retainedEffectReconciliation?.sourceGenerationId ??
4547
+ generation.frames[0].runtime.retainedEffectSourceSessionId ??
4548
+ generation.frames[0].rootSessionId,
4549
+ checkpoint: generation.effectLedger,
4550
+ }
4551
+ : undefined;
4552
+ for (const parent of adoptedFrames.slice(0, -1)) {
4553
+ pendingChildParents.add(parent);
4554
+ }
4555
+ const retainedQuestions = generation.frames.at(-1).runtime
4556
+ .pendingBossQuestions;
4557
+ pendingBossQuestions =
4558
+ requiresEffectReconciliation || retainedQuestions.length === 0
4559
+ ? undefined
4560
+ : mirroredBossQuestions(retainedQuestions);
4561
+ lastError = undefined;
4562
+ retainedGenerations.delete(rootPlaybookId);
4563
+ ineligibleRetainedGenerations.delete(rootPlaybookId);
4564
+ await setMode('engaged.parked', 'resume', rootPlaybookId, targetRootSessionId);
4565
+ if (requiresEffectReconciliation) {
4566
+ await requireSession().setVisiblePlayers([]);
4567
+ }
4568
+ else {
4569
+ await requestVisibility(adoptedFrames.at(-1));
4570
+ }
4571
+ await requireSession().emitStatus(`◇ /${enablementById.get(rootPlaybookId).command} resumed`);
4572
+ if (activeTurn) {
4573
+ appendMandatoryPresentationSuffix(activeTurn, requiresEffectReconciliation
4574
+ ? 'The retained work remains parked until its repository-effect evidence is reconciled.'
4575
+ : RESUMPTION_DUPLICATE_EFFECT_WARNING);
4576
+ }
4577
+ return [
4578
+ generation.rootStateDescription === undefined
4579
+ ? `Resumed /${enablementById.get(rootPlaybookId).command} from its retained state; no published root-state description was retained.`
4580
+ : `Resumed /${enablementById.get(rootPlaybookId).command} from the retained state described as ${quoteEvidence(compactEvidence(generation.rootStateDescription))}.`,
4581
+ requiresEffectReconciliation
4582
+ ? 'The retained work remains parked until its repository-effect evidence is reconciled; no ordinary action was resumed.'
4583
+ : RESUMPTION_DUPLICATE_EFFECT_WARNING,
4584
+ ];
4585
+ }
4586
+ catch (error) {
4587
+ if (installed) {
4588
+ frames.splice(0);
4589
+ pendingChildParents.clear();
4590
+ retainedEffectReconciliation = undefined;
4591
+ clearLeafLedger();
4592
+ }
4593
+ const cleanupFailures = [];
4594
+ const failedCleanupRuntimes = [];
4595
+ for (const frame of [...adoptedFrames].reverse()) {
4596
+ try {
4597
+ await disposeFrame(frame);
4598
+ }
4599
+ catch (cleanupError) {
4600
+ cleanupFailures.push(cleanupError);
4601
+ failedCleanupRuntimes.push(frame.runtime);
4602
+ }
4603
+ }
4604
+ const rollbackFailures = [];
4605
+ if (installed) {
4606
+ try {
4607
+ await setMode('chat', 'resume.failed');
4608
+ }
4609
+ catch (rollbackError) {
4610
+ rollbackFailures.push(rollbackError);
4611
+ }
4612
+ }
4613
+ else {
4614
+ mode = 'chat';
4615
+ }
4616
+ if (cleanupFailures.length > 0 || rollbackFailures.length > 0) {
4617
+ retiredRetainedRuntimes.push(...failedCleanupRuntimes);
4618
+ ineligibleRetainedGenerations.add(rootPlaybookId);
4619
+ terminallyDisposed = true;
4620
+ lifecycle = 'closed';
4621
+ throw new AggregateError([error, ...cleanupFailures, ...rollbackFailures], 'retained-generation adoption and rollback failed');
4622
+ }
4623
+ retainedGenerations.set(rootPlaybookId, generation);
4624
+ ineligibleRetainedGenerations.delete(rootPlaybookId);
4625
+ throw error;
4626
+ }
4627
+ };
3354
4628
  const driveAndProcess = async (frame, text, context, onDriven) => {
3355
4629
  try {
3356
4630
  // CAPTAIN-35: no boundary here. `driveFrame` marks the runtime call and
@@ -3377,11 +4651,36 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3377
4651
  }
3378
4652
  }
3379
4653
  };
4654
+ const containsEffectThrow = (turn, error) => {
4655
+ if (turn.effectThrows.has(error))
4656
+ return true;
4657
+ return (error instanceof AggregateError &&
4658
+ error.errors.some((nested) => containsEffectThrow(turn, nested)));
4659
+ };
3380
4660
  const settleSelection = async (selection, signal) => {
3381
4661
  const turn = activeTurn;
3382
4662
  runFailureFacts = [];
4663
+ let frozenUnresolvedEffects;
4664
+ const freezeControllerEvidence = () => {
4665
+ frozenUnresolvedEffects ??= freezeTurnUnresolvedEffects();
4666
+ const report = unresolvedEffectBossReport(frozenUnresolvedEffects);
4667
+ if (turn !== undefined && report !== undefined) {
4668
+ appendMandatoryPresentationSuffix(turn, report);
4669
+ }
4670
+ return frozenUnresolvedEffects;
4671
+ };
4672
+ const finalizeSettlement = (settlement) => Object.freeze({
4673
+ ...settlement,
4674
+ unresolvedEffects: freezeControllerEvidence(),
4675
+ });
3383
4676
  try {
3384
- return await executeSelection(selection, signal);
4677
+ // `respond` has no result phase: freeze its no-effect projection before
4678
+ // its decision-call prose crosses the presentation boundary. Acting
4679
+ // selections freeze after their work and before reporting begins.
4680
+ if (selection.action === 'respond')
4681
+ freezeControllerEvidence();
4682
+ const settlement = await executeSelection(selection, signal);
4683
+ return finalizeSettlement(settlement);
3385
4684
  }
3386
4685
  catch (error) {
3387
4686
  if (turn?.presentationError === error)
@@ -3405,7 +4704,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3405
4704
  turn.settlementFacts.push(...runFailureFacts.splice(0));
3406
4705
  }
3407
4706
  const mayHaveApplied = selection.action !== 'respond' &&
3408
- turn.effectThrows.has(error);
4707
+ containsEffectThrow(turn, error);
3409
4708
  turn.settlementFacts.push(mayHaveApplied
3410
4709
  ? `The ${selection.action} action failed before its complete outcome could be confirmed and may have changed the session: ${normalized.name}: ${compactEvidence(normalized.message)}. It was not repeated automatically.`
3411
4710
  : `The ${selection.action} action failed before its complete outcome could be confirmed: ${normalized.name}: ${compactEvidence(normalized.message)}. It was not repeated automatically.`);
@@ -3440,7 +4739,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3440
4739
  };
3441
4740
  turn.settled = true;
3442
4741
  lastSettlementStatus = 'failed';
3443
- return {
4742
+ const settlement = {
3444
4743
  status: 'failed',
3445
4744
  facts: [...turn.settlementFacts],
3446
4745
  ...(turn.report.receipt === undefined
@@ -3448,6 +4747,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3448
4747
  : { receipt: turn.report.receipt }),
3449
4748
  ...(summary === undefined ? {} : { leafStateSummary: summary }),
3450
4749
  };
4750
+ return finalizeSettlement(settlement);
3451
4751
  }
3452
4752
  finally {
3453
4753
  runFailureFacts = undefined;
@@ -3505,6 +4805,46 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3505
4805
  : { leafStateSummary: leafStateSummary() }),
3506
4806
  };
3507
4807
  }
4808
+ refreshRetainedEffectFence();
4809
+ const fencedLeaf = leafFrame();
4810
+ const routesRetainedReconciliation = selection.action === 'runtime' &&
4811
+ (selection.actionId === UNRESOLVED_EFFECT_RECONCILIATION_ACTION_ID ||
4812
+ selection.actionId === UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID) &&
4813
+ fencedLeaf !== undefined;
4814
+ if (retainedEffectReconciliation !== undefined &&
4815
+ !routesRetainedReconciliation) {
4816
+ return rejectSelection(selection, 'retained work must reconcile its repository-effect evidence before an ordinary action can run');
4817
+ }
4818
+ if (selection.action === 'resume') {
4819
+ const entry = byId.get(selection.playbookId);
4820
+ if (entry === undefined) {
4821
+ return rejectSelection(selection, `"${selection.playbookId}" is not an enabled playbook`);
4822
+ }
4823
+ if (rootFrame() !== undefined) {
4824
+ return rejectSelection(selection, 'a playbook is already engaged; its live actions take precedence');
4825
+ }
4826
+ const offer = retainedGenerationOffers.get(entry.id);
4827
+ if (offer === undefined) {
4828
+ return rejectSelection(selection, `/${enablementById.get(entry.id).command} has no resumable retained generation`);
4829
+ }
4830
+ turn.settled = true;
4831
+ journalAction({ action: 'resume', playbookId: entry.id });
4832
+ facts.push(...(await adoptRetainedGeneration(entry.id, offer)));
4833
+ const summary = leafStateSummary();
4834
+ turn.report = {
4835
+ ...emptyReport(),
4836
+ facts,
4837
+ status: 'ok',
4838
+ ...(summary === undefined ? {} : { leafStateSummary: summary }),
4839
+ };
4840
+ journalOutcome([...facts]);
4841
+ lastSettlementStatus = 'ok';
4842
+ return {
4843
+ status: 'ok',
4844
+ facts: [...facts],
4845
+ ...(summary === undefined ? {} : { leafStateSummary: summary }),
4846
+ };
4847
+ }
3508
4848
  if (selection.action === 'start' || selection.action === 'switch') {
3509
4849
  const entry = byId.get(selection.playbookId);
3510
4850
  if (!entry) {
@@ -3726,6 +5066,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3726
5066
  if (outcome.error !== undefined)
3727
5067
  throw outcome.error;
3728
5068
  const receipt = outcome.result;
5069
+ refreshRetainedEffectFence();
3729
5070
  let status = receipt.disposition === 'executed'
3730
5071
  ? 'ok'
3731
5072
  : receipt.disposition === 'rejected'
@@ -3745,9 +5086,47 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3745
5086
  }
3746
5087
  : {}),
3747
5088
  };
5089
+ const unresolvedAbandonment = receipt.disposition === 'executed' &&
5090
+ actionId === UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID;
3748
5091
  if (receipt.disposition === 'executed') {
3749
- facts.push(`Applied "${actionId}" on ${frameLabel(leaf)}.`);
5092
+ if (unresolvedAbandonment &&
5093
+ receipt.run?.outcome !== 'unresolved-effect') {
5094
+ throw new Error('unresolved-effect abandonment returned no unresolved-effect result');
5095
+ }
3750
5096
  const establishedSummary = leafStateSummary();
5097
+ if (unresolvedAbandonment) {
5098
+ try {
5099
+ await settleUnresolvedEffectAbandonment(leaf);
5100
+ }
5101
+ catch (error) {
5102
+ abandonmentSettlementUnsafe = true;
5103
+ const normalized = normalizeErrorCompact(error) ?? {
5104
+ name: 'Error',
5105
+ message: String(error),
5106
+ };
5107
+ // The runtime action was accepted, but its distinct host-level
5108
+ // settlement did not complete. Do not expose an `executed` control
5109
+ // receipt until both disposal and durable publication have
5110
+ // succeeded.
5111
+ turn.report = {
5112
+ ...outcome.report,
5113
+ facts: [...facts],
5114
+ bossFacts: facts.map((fact) => fact
5115
+ .split(`"${actionId}"`)
5116
+ .join(`"${compactEvidence(actionLabel)}"`)),
5117
+ status: 'failed',
5118
+ receipt: {
5119
+ disposition: 'failed',
5120
+ error: normalized,
5121
+ },
5122
+ ...(establishedSummary === undefined
5123
+ ? {}
5124
+ : { leafStateSummary: establishedSummary }),
5125
+ };
5126
+ throw error;
5127
+ }
5128
+ }
5129
+ facts.push(`Applied "${actionId}" on ${frameLabel(leaf)}.`);
3751
5130
  // Execution is now proven. Preserve that receipt and the counts already
3752
5131
  // collected before processing the returned run, because disposal,
3753
5132
  // telemetry, or parent resumption can still fail afterward.
@@ -3763,7 +5142,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3763
5142
  ? {}
3764
5143
  : { leafStateSummary: establishedSummary }),
3765
5144
  };
3766
- if (receipt.run !== undefined) {
5145
+ if (!unresolvedAbandonment &&
5146
+ receipt.run !== undefined &&
5147
+ retainedEffectReconciliation === undefined) {
3767
5148
  // The same rule as the drive path: processing the run the receipt
3768
5149
  // carried is not itself an effect, and the resume or disposal it may
3769
5150
  // perform is marked where it happens (CAPTAIN-35).
@@ -3959,6 +5340,25 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3959
5340
  if (!isDeepStrictEqual(frame.roleBindings, configuredBindings)) {
3960
5341
  throw new TypeError(`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} role bindings changed`);
3961
5342
  }
5343
+ if (!isDeepStrictEqual(frame.runtime.effectLedger, snapshot.effectLedger)) {
5344
+ throw new TypeError(`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} effect ledger does not match its artifact schema`);
5345
+ }
5346
+ const runtimeReconciliation = frame.runtime.retainedEffectReconciliation;
5347
+ if (snapshot.retainedEffectReconciliation === undefined) {
5348
+ if (runtimeReconciliation !== undefined) {
5349
+ throw new TypeError(`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} carries an unmirrored retained-effect fence`);
5350
+ }
5351
+ }
5352
+ else if (runtimeReconciliation === undefined ||
5353
+ !isDeepStrictEqual(runtimeReconciliation.checkpoint, snapshot.retainedEffectReconciliation.checkpoint)) {
5354
+ throw new TypeError(`Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} does not mirror the root retained-effect fence`);
5355
+ }
5356
+ if (frame.depth === 0 &&
5357
+ snapshot.retainedEffectReconciliation !== undefined &&
5358
+ runtimeReconciliation?.sourceSessionId !==
5359
+ snapshot.retainedEffectReconciliation.sourceGenerationId) {
5360
+ throw new TypeError('Captain shell snapshot retained-effect root source identity differs from its generation');
5361
+ }
3962
5362
  }
3963
5363
  };
3964
5364
  const safeCapturePoint = () => {
@@ -4013,33 +5413,33 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4013
5413
  frame.abortListener !== undefined)));
4014
5414
  });
4015
5415
  };
4016
- const exportShellSnapshot = () => {
4017
- if (!safeCapturePoint() ||
4018
- !captainRuntime ||
4019
- !captainSessionId ||
4020
- !captainAgent) {
4021
- return undefined;
4022
- }
5416
+ const captureFrameSnapshots = (requireRecordedState) => {
5417
+ const captured = [];
4023
5418
  try {
4024
- if (typeof captainRuntime.exportSnapshot !== 'function' ||
4025
- typeof captainRuntime.restore !== 'function') {
4026
- return undefined;
4027
- }
4028
- const captainSnapshot = captainRuntime.exportSnapshot();
4029
- if (captainSnapshot === undefined)
4030
- return undefined;
4031
- const frameSnapshots = [];
4032
- for (const frame of frames) {
5419
+ for (const [index, frame] of frames.entries()) {
4033
5420
  if (typeof frame.runtime.exportSnapshot !== 'function' ||
4034
5421
  typeof frame.runtime.restore !== 'function') {
4035
5422
  return undefined;
4036
5423
  }
4037
- const runtime = frame.runtime.exportSnapshot();
4038
- if (runtime === undefined ||
4039
- !isDeepStrictEqual(frame.state, runtime.state)) {
5424
+ const exported = frame.runtime.exportSnapshot();
5425
+ if (exported === undefined)
5426
+ return undefined;
5427
+ const runtime = assertPlaybookRuntimeSnapshot(exported, frame.entry.id, { allowSuspendedCall: true });
5428
+ if (runtime.state.status !== 'active' ||
5429
+ !runtime.state.quiescent ||
5430
+ (requireRecordedState && frame.state === undefined) ||
5431
+ (frame.state !== undefined &&
5432
+ !isDeepStrictEqual(frame.state, runtime.state))) {
5433
+ return undefined;
5434
+ }
5435
+ const isLeaf = index === frames.length - 1;
5436
+ if ((isLeaf &&
5437
+ (runtime.suspendedCall !== undefined ||
5438
+ !runtime.state.tags.includes('playbook.parked'))) ||
5439
+ (!isLeaf && runtime.suspendedCall === undefined)) {
4040
5440
  return undefined;
4041
5441
  }
4042
- frameSnapshots.push({
5442
+ captured.push({
4043
5443
  playbookId: frame.entry.id,
4044
5444
  sessionId: frame.sessionId,
4045
5445
  rootSessionId: frame.rootSessionId,
@@ -4058,8 +5458,198 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4058
5458
  runtime,
4059
5459
  });
4060
5460
  }
5461
+ for (let index = 1; index < captured.length; index += 1) {
5462
+ const parent = captured[index - 1];
5463
+ const child = captured[index];
5464
+ if (child.parentSessionId !== parent.sessionId ||
5465
+ child.parentCallId === undefined ||
5466
+ parent.runtime.suspendedCall?.callId !== child.parentCallId ||
5467
+ parent.runtime.suspendedCall.playbookId !== child.playbookId ||
5468
+ parent.runtime.suspendedCall.childSessionId !== child.sessionId) {
5469
+ return undefined;
5470
+ }
5471
+ }
5472
+ return captured;
5473
+ }
5474
+ catch {
5475
+ return undefined;
5476
+ }
5477
+ };
5478
+ const refreshRetainedEffectFence = (capturedFrames, capturedLedger) => {
5479
+ const fence = retainedEffectReconciliation;
5480
+ if (fence === undefined)
5481
+ return;
5482
+ try {
5483
+ const ledger = capturedLedger ?? assertPlaybookEffectLedger(currentEffectLedger());
5484
+ if (!retainedEffectLedgerCanRebase(fence.checkpoint, ledger)) {
5485
+ return;
5486
+ }
5487
+ const snapshots = capturedFrames ?? captureFrameSnapshots(true);
5488
+ if (snapshots === undefined || snapshots.length !== frames.length)
5489
+ return;
5490
+ for (const frameSnapshot of snapshots) {
5491
+ const runtime = frameSnapshot.runtime;
5492
+ if (!isDeepStrictEqual(runtime.effectLedger, ledger) ||
5493
+ runtime.retainedEffectSourceSessionId === undefined ||
5494
+ runtime.retainedEffectReconciliation !== undefined) {
5495
+ return;
5496
+ }
5497
+ }
5498
+ const retainedQuestions = snapshots.at(-1).runtime.pendingBossQuestions;
5499
+ const restoredQuestions = retainedQuestions.length === 0
5500
+ ? undefined
5501
+ : mirroredBossQuestions(retainedQuestions);
5502
+ pendingBossQuestions = restoredQuestions;
5503
+ retainedEffectReconciliation = undefined;
5504
+ }
5505
+ catch {
5506
+ // Any host-ledger or runtime-snapshot defect leaves the root fence shut.
5507
+ }
5508
+ };
5509
+ const rootStateDescriptionForRetention = (root, retainedState) => {
5510
+ if (typeof root.runtime.describe !== 'function')
5511
+ return undefined;
5512
+ try {
5513
+ const view = root.runtime.describe();
5514
+ if (!isDeepStrictEqual(view.state, retainedState))
5515
+ return undefined;
5516
+ return typeof view.stateDescription === 'string' &&
5517
+ view.stateDescription.trim().length > 0
5518
+ ? view.stateDescription
5519
+ : undefined;
5520
+ }
5521
+ catch {
5522
+ return undefined;
5523
+ }
5524
+ };
5525
+ const retainedGenerationFromFrames = (root, frameSnapshots) => {
5526
+ const rootStateDescription = rootStateDescriptionForRetention(root, frameSnapshots[0].runtime.state);
5527
+ const checkpoint = retainedEffectReconciliation?.checkpoint ??
5528
+ assertPlaybookEffectLedger(currentEffectLedger());
5529
+ if (checkpoint.boundaries.some(({ physicalReceipt }) => physicalReceipt === undefined)) {
5530
+ throw new TypeError('Captain retained-generation checkpoint contains an incomplete physical boundary');
5531
+ }
5532
+ return snapshotJsonValue({
5533
+ effectLedger: checkpoint,
5534
+ frames: frameSnapshots,
5535
+ ...(retainedEffectReconciliation === undefined
5536
+ ? {}
5537
+ : {
5538
+ retainedEffectReconciliation: {
5539
+ sourceGenerationId: retainedEffectReconciliation.sourceGenerationId,
5540
+ },
5541
+ }),
5542
+ ...(rootStateDescription === undefined
5543
+ ? {}
5544
+ : { rootStateDescription }),
5545
+ }, 'Captain retained generation');
5546
+ };
5547
+ const captureRetainedGeneration = () => {
5548
+ const root = rootFrame();
5549
+ if (!root ||
5550
+ frames.some((frame) => !runtimeRetainsGenerations(frame.runtime))) {
5551
+ return undefined;
5552
+ }
5553
+ const frameSnapshots = captureFrameSnapshots(true);
5554
+ if (frameSnapshots === undefined || frameSnapshots.length === 0) {
5555
+ return undefined;
5556
+ }
5557
+ return retainedGenerationFromFrames(root, frameSnapshots);
5558
+ };
5559
+ const rememberRetainedGeneration = () => {
5560
+ const root = rootFrame();
5561
+ if (!root)
5562
+ return;
5563
+ if (frames.some((frame) => !runtimeRetainsGenerations(frame.runtime))) {
5564
+ retainedGenerationCandidates.set(root.entry.id, {
5565
+ status: 'incapable',
5566
+ });
5567
+ return;
5568
+ }
5569
+ const generation = captureRetainedGeneration();
5570
+ retainedGenerationCandidates.set(root.entry.id, generation === undefined
5571
+ ? { status: 'unsafe' }
5572
+ : { status: 'captured', generation });
5573
+ };
5574
+ const retentionUpdateForPriorGeneration = (root) => {
5575
+ const rootPlaybookId = root.entry.id;
5576
+ if (!runtimeRetainsGenerations(root.runtime)) {
5577
+ return {
5578
+ kind: 'clear',
5579
+ rootPlaybookId,
5580
+ };
5581
+ }
5582
+ const candidate = retainedGenerationCandidates.get(rootPlaybookId);
5583
+ if (candidate?.status === 'incapable') {
5584
+ return undefined;
5585
+ }
5586
+ if (candidate?.status !== 'captured') {
5587
+ throw new Error(`${frameLabel(root)} could not capture its pre-terminal retained generation`);
5588
+ }
5589
+ return {
5590
+ kind: 'retain',
5591
+ rootPlaybookId,
5592
+ generation: candidate.generation,
5593
+ };
5594
+ };
5595
+ const retainOrClearDisposedRoot = (root) => {
5596
+ const update = retentionUpdateForPriorGeneration(root);
5597
+ if (update !== undefined) {
5598
+ pendingRetentionUpdates.set(update.rootPlaybookId, update);
5599
+ }
5600
+ };
5601
+ const recordTerminalRetention = (root, result) => {
5602
+ const rootPlaybookId = root.entry.id;
5603
+ if (!runtimeRetainsGenerations(root.runtime)) {
5604
+ pendingRetentionUpdates.set(rootPlaybookId, {
5605
+ kind: 'clear',
5606
+ rootPlaybookId,
5607
+ });
5608
+ return;
5609
+ }
5610
+ const terminalStateId = result.state.stateId;
5611
+ if (typeof terminalStateId !== 'string' ||
5612
+ terminalStateId.trim().length === 0) {
5613
+ throw new Error(`${frameLabel(root)} terminal result has no stable state id for retention`);
5614
+ }
5615
+ const unfinished = root.runtime.retainedGenerationMetadata.unfinishedFinalStateIds.includes(terminalStateId);
5616
+ if (unfinished) {
5617
+ // A root opened and terminated within this turn has no pre-turn,
5618
+ // work-bearing generation. Leave any earlier store entry untouched.
5619
+ if (!retainedGenerationCandidates.has(rootPlaybookId))
5620
+ return;
5621
+ retainOrClearDisposedRoot(root);
5622
+ }
5623
+ else {
5624
+ pendingRetentionUpdates.set(rootPlaybookId, {
5625
+ kind: 'clear',
5626
+ rootPlaybookId,
5627
+ });
5628
+ }
5629
+ };
5630
+ const exportShellSnapshot = () => {
5631
+ if (!safeCapturePoint() ||
5632
+ !captainRuntime ||
5633
+ !captainSessionId ||
5634
+ !captainAgent) {
5635
+ return undefined;
5636
+ }
5637
+ try {
5638
+ if (typeof captainRuntime.exportSnapshot !== 'function' ||
5639
+ typeof captainRuntime.restore !== 'function') {
5640
+ return undefined;
5641
+ }
5642
+ const captainSnapshot = captainRuntime.exportSnapshot();
5643
+ if (captainSnapshot === undefined)
5644
+ return undefined;
5645
+ const frameSnapshots = captureFrameSnapshots(true);
5646
+ if (frameSnapshots === undefined)
5647
+ return undefined;
5648
+ const effectLedger = assertPlaybookEffectLedger(currentEffectLedger());
5649
+ refreshRetainedEffectFence(frameSnapshots, effectLedger);
4061
5650
  const common = {
4062
- schemaVersion: 3,
5651
+ schemaVersion: 4,
5652
+ effectLedger,
4063
5653
  captain: {
4064
5654
  sessionId: captainSessionId,
4065
5655
  runtime: captainSnapshot,
@@ -4081,6 +5671,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4081
5671
  ...common,
4082
5672
  mode: 'engaged.parked',
4083
5673
  frames: frameSnapshots,
5674
+ ...(retainedEffectReconciliation === undefined
5675
+ ? {}
5676
+ : { retainedEffectReconciliation }),
4084
5677
  ...(pendingBossQuestions === undefined
4085
5678
  ? {}
4086
5679
  : { pendingBossQuestions: pendingBossQuestions }),
@@ -4094,6 +5687,64 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4094
5687
  return undefined;
4095
5688
  }
4096
5689
  };
5690
+ const exportSettlement = () => {
5691
+ if (!retentionSettlementReady || abandonmentSettlementUnsafe) {
5692
+ return undefined;
5693
+ }
5694
+ const snapshot = exportShellSnapshot();
5695
+ if (snapshot === undefined)
5696
+ return undefined;
5697
+ let unresolvedEffects;
5698
+ try {
5699
+ unresolvedEffects =
5700
+ settledTurnUnresolvedEffects ?? currentUnresolvedEffects();
5701
+ }
5702
+ catch {
5703
+ return undefined;
5704
+ }
5705
+ const updates = new Map(pendingRetentionUpdates);
5706
+ for (const rootPlaybookId of retainedGenerationRootClears) {
5707
+ updates.set(rootPlaybookId, { kind: 'clear', rootPlaybookId });
5708
+ }
5709
+ const root = rootFrame();
5710
+ if (root !== undefined) {
5711
+ const rootPlaybookId = root.entry.id;
5712
+ if (frames.every((frame) => runtimeRetainsGenerations(frame.runtime))) {
5713
+ const generation = snapshot.mode === 'engaged.parked'
5714
+ ? retainedGenerationFromFrames(root, snapshot.frames)
5715
+ : undefined;
5716
+ if (generation !== undefined) {
5717
+ updates.set(rootPlaybookId, {
5718
+ kind: 'retain',
5719
+ rootPlaybookId,
5720
+ generation,
5721
+ });
5722
+ }
5723
+ }
5724
+ else if (!runtimeRetainsGenerations(root.runtime)) {
5725
+ updates.set(rootPlaybookId, { kind: 'clear', rootPlaybookId });
5726
+ }
5727
+ else if (retainedGenerationCandidates.has(rootPlaybookId)) {
5728
+ try {
5729
+ const update = retentionUpdateForPriorGeneration(root);
5730
+ if (update !== undefined) {
5731
+ updates.set(rootPlaybookId, update);
5732
+ }
5733
+ }
5734
+ catch {
5735
+ return undefined;
5736
+ }
5737
+ }
5738
+ }
5739
+ for (const update of updates.values()) {
5740
+ applyRetentionUpdateToCatalog(update);
5741
+ }
5742
+ return snapshotJsonValue({
5743
+ snapshot,
5744
+ retentionUpdates: [...updates.values()].sort((left, right) => left.rootPlaybookId.localeCompare(right.rootPlaybookId)),
5745
+ unresolvedEffects,
5746
+ }, 'Captain settlement');
5747
+ };
4097
5748
  const verifyRestoredRuntime = (runtime, expected, playbookId, allowSuspendedCall) => {
4098
5749
  const actual = runtime.exportSnapshot?.();
4099
5750
  if (actual === undefined) {
@@ -4106,6 +5757,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4106
5757
  'sequences',
4107
5758
  'pendingBossQuestions',
4108
5759
  'suspendedCall',
5760
+ 'effectLedger',
5761
+ 'retainedEffectSourceSessionId',
5762
+ 'retainedEffectReconciliation',
4109
5763
  ]) {
4110
5764
  if (!isDeepStrictEqual(normalized[key], expected[key])) {
4111
5765
  throw new Error(`restored ${playbookId} runtime changed snapshot field ${key}`);
@@ -4123,6 +5777,15 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4123
5777
  cleanupFailures.push(error);
4124
5778
  }
4125
5779
  }
5780
+ const retainedRuntimes = takeRetainedOfferRuntimes();
5781
+ if (retainedRuntimes.length > 0) {
5782
+ try {
5783
+ await disposeRetainedRuntimeSet(retainedRuntimes, 'retained-generation restore cleanup failed');
5784
+ }
5785
+ catch (error) {
5786
+ cleanupFailures.push(error);
5787
+ }
5788
+ }
4126
5789
  if (captainRuntime) {
4127
5790
  shuttingDown = true;
4128
5791
  try {
@@ -4140,11 +5803,20 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4140
5803
  byCommand = new Map();
4141
5804
  byId = new Map();
4142
5805
  enablementById = new Map();
5806
+ hostCapabilitiesById = new Map();
5807
+ pendingHostCapabilities = undefined;
5808
+ currentEffectLedger = () => emptyPlaybookEffectLedger();
4143
5809
  captainAgent = undefined;
4144
5810
  captainAdapter = undefined;
4145
5811
  playerAgents = new Map();
4146
5812
  playerLedger.clear();
4147
5813
  playerTransactions.clear();
5814
+ retainedGenerations.clear();
5815
+ ineligibleRetainedGenerations.clear();
5816
+ retainedGenerationRootClears.clear();
5817
+ retainedGenerationsInstalled = false;
5818
+ retainedGenerationInstallationInProgress = false;
5819
+ retainedGenerationInstallationClosed = false;
4148
5820
  session = undefined;
4149
5821
  sessionEmissionsOpen = false;
4150
5822
  closedGateAttempted = false;
@@ -4152,6 +5824,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4152
5824
  captainSessionId = undefined;
4153
5825
  conversation = { kind: 'unopened' };
4154
5826
  mode = 'chat';
5827
+ retainedEffectReconciliation = undefined;
4155
5828
  pendingBossQuestions = undefined;
4156
5829
  lastError = undefined;
4157
5830
  journalSeq = 0;
@@ -4178,7 +5851,13 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4178
5851
  lifecycle = 'restoring';
4179
5852
  try {
4180
5853
  const snapshot = assertPlaybookCaptainShellSnapshot(untrusted);
4181
- const built = await buildEnablements(options, loadModule);
5854
+ const built = await buildCurrentEnablements();
5855
+ const builtHostCapabilities = new Map(built.hostCapabilitiesById);
5856
+ const readEffectLedger = () => effectLedgerMirrorFromCapabilities(builtHostCapabilities);
5857
+ const hostLedger = readEffectLedger();
5858
+ if (!isDeepStrictEqual(snapshot.effectLedger, hostLedger)) {
5859
+ throw new Error('Captain shell restore effect ledger does not match current-host authority');
5860
+ }
4182
5861
  captainAgent = built.captainAgent;
4183
5862
  captainAdapter = captainAgent.adapter;
4184
5863
  playerAgents = built.playerAgents;
@@ -4188,6 +5867,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4188
5867
  byCommand = built.byCommand;
4189
5868
  byId = built.byId;
4190
5869
  enablementById = built.enablementById;
5870
+ hostCapabilitiesById = builtHostCapabilities;
5871
+ currentEffectLedger = readEffectLedger;
4191
5872
  for (const [playerId, saved] of Object.entries(snapshot.playerSessions)) {
4192
5873
  playerLedger.set(playerId, {
4193
5874
  adapter: saved.adapter,
@@ -4267,6 +5948,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4267
5948
  lastSettlementStatus = snapshot.lastSettlementStatus;
4268
5949
  mode = snapshot.mode;
4269
5950
  if (snapshot.mode === 'engaged.parked') {
5951
+ retainedEffectReconciliation =
5952
+ snapshot.retainedEffectReconciliation;
4270
5953
  pendingBossQuestions = snapshot.pendingBossQuestions;
4271
5954
  lastError = snapshot.lastError;
4272
5955
  }
@@ -4282,6 +5965,57 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4282
5965
  throw error;
4283
5966
  }
4284
5967
  };
5968
+ const installRetainedGenerations = async (generations) => {
5969
+ if (lifecycle !== 'ready' || terminallyDisposed) {
5970
+ throw new Error('retained generations require an initialized or restored Captain shell');
5971
+ }
5972
+ if (retainedGenerationsInstalled ||
5973
+ retainedGenerationInstallationInProgress ||
5974
+ retainedGenerationInstallationClosed ||
5975
+ activeTurnHostCalls !== undefined) {
5976
+ throw new Error('retained generations may be installed exactly once before the first nonempty Boss turn');
5977
+ }
5978
+ const normalized = normalizeInstalledRetainedGenerations(generations);
5979
+ retainedGenerationInstallationInProgress = true;
5980
+ try {
5981
+ retainedGenerations.clear();
5982
+ retainedGenerationOffers.clear();
5983
+ ineligibleRetainedGenerations.clear();
5984
+ retainedGenerationRootClears.clear();
5985
+ for (const [rootPlaybookId, generation] of normalized) {
5986
+ retainedGenerations.set(rootPlaybookId, generation);
5987
+ }
5988
+ await prepareRetainedGenerationOffers();
5989
+ retainedGenerationsInstalled = true;
5990
+ }
5991
+ catch (error) {
5992
+ const preparationCleanupFailed = error instanceof RetainedRuntimeCleanupError;
5993
+ const runtimes = [...retainedGenerationOffers.values()].flatMap((offer) => [...offer.runtimes]);
5994
+ retainedGenerationOffers.clear();
5995
+ retainedGenerations.clear();
5996
+ ineligibleRetainedGenerations.clear();
5997
+ retainedGenerationRootClears.clear();
5998
+ try {
5999
+ await disposeRetainedRuntimeSet(runtimes, 'retained-generation installation cleanup failed');
6000
+ }
6001
+ catch (cleanupError) {
6002
+ if (cleanupError instanceof RetainedRuntimeCleanupError) {
6003
+ retiredRetainedRuntimes.push(...cleanupError.failedRuntimes);
6004
+ }
6005
+ terminallyDisposed = true;
6006
+ lifecycle = 'closed';
6007
+ throw new AggregateError([error, cleanupError], 'retained-generation installation and cleanup failed');
6008
+ }
6009
+ if (preparationCleanupFailed) {
6010
+ terminallyDisposed = true;
6011
+ lifecycle = 'closed';
6012
+ }
6013
+ throw error;
6014
+ }
6015
+ finally {
6016
+ retainedGenerationInstallationInProgress = false;
6017
+ }
6018
+ };
4285
6019
  return {
4286
6020
  async init(initSession) {
4287
6021
  if (lifecycle !== 'fresh' || terminallyDisposed) {
@@ -4293,11 +6027,16 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4293
6027
  lifecycle = 'initializing';
4294
6028
  try {
4295
6029
  installSession(initSession, true);
4296
- const built = await buildEnablements(options, loadModule);
6030
+ const built = await buildCurrentEnablements();
6031
+ const builtHostCapabilities = new Map(built.hostCapabilitiesById);
6032
+ const readEffectLedger = () => effectLedgerMirrorFromCapabilities(builtHostCapabilities);
6033
+ readEffectLedger();
4297
6034
  entries = built.entries;
4298
6035
  byCommand = built.byCommand;
4299
6036
  byId = built.byId;
4300
6037
  enablementById = built.enablementById;
6038
+ hostCapabilitiesById = builtHostCapabilities;
6039
+ currentEffectLedger = readEffectLedger;
4301
6040
  captainAgent = built.captainAgent;
4302
6041
  captainAdapter = captainAgent.adapter;
4303
6042
  playerAgents = built.playerAgents;
@@ -4322,7 +6061,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4322
6061
  }
4323
6062
  },
4324
6063
  exportSnapshot: exportShellSnapshot,
6064
+ exportSettlement,
4325
6065
  restore: restoreShellSnapshot,
6066
+ installRetainedGenerations,
4326
6067
  async handleBossTurn(turn, context) {
4327
6068
  if (lifecycle !== 'ready' || terminallyDisposed) {
4328
6069
  throw new Error('init must be called first, or restore must complete before handling a Boss turn');
@@ -4334,10 +6075,25 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4334
6075
  if (activeTurnHostCalls !== undefined) {
4335
6076
  throw new Error('cannot handle concurrent Boss turns');
4336
6077
  }
6078
+ if (retainedGenerationInstallationInProgress) {
6079
+ throw new Error('cannot handle a Boss turn while retained generations are installing');
6080
+ }
4337
6081
  // Empty or whitespace-only input allocates no call, session, or
4338
6082
  // telemetry (CAPTAIN-7).
6083
+ retentionSettlementReady = false;
6084
+ abandonmentSettlementUnsafe = false;
4339
6085
  if (turn.prompt.trim().length === 0)
4340
6086
  return;
6087
+ settledTurnUnresolvedEffects = undefined;
6088
+ retainedGenerationInstallationClosed = true;
6089
+ for (const update of pendingRetentionUpdates.values()) {
6090
+ applyRetentionUpdateToCatalog(update);
6091
+ }
6092
+ retainedGenerationCandidates.clear();
6093
+ pendingRetentionUpdates.clear();
6094
+ // A terminal or dismissal can remove the whole stack during this turn;
6095
+ // take the latest already-settled generation before controller work.
6096
+ rememberRetainedGeneration();
4341
6097
  const turnHostCalls = new Set();
4342
6098
  activeTurnHostCalls = turnHostCalls;
4343
6099
  activeContext = context;
@@ -4360,6 +6116,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4360
6116
  decisionCall = undefined;
4361
6117
  appendJournal('boss', turn.prompt);
4362
6118
  try {
6119
+ await drainRetiredRetainedRuntimes();
6120
+ await prepareRetainedGenerationOffers();
4363
6121
  const result = await captainRuntime.handleBossInput({
4364
6122
  text: turn.prompt,
4365
6123
  signal: context.signal,
@@ -4422,17 +6180,22 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4422
6180
  activeTurnHostCalls = undefined;
4423
6181
  }
4424
6182
  activeContext = undefined;
6183
+ retentionSettlementReady = true;
4425
6184
  }
4426
6185
  },
4427
6186
  async prepareDispose() {
4428
- if (lifecycle === 'initializing' || lifecycle === 'restoring') {
6187
+ if (lifecycle === 'initializing' ||
6188
+ lifecycle === 'restoring' ||
6189
+ retainedGenerationInstallationInProgress) {
4429
6190
  throw new Error('cannot dispose while Captain shell setup is in progress');
4430
6191
  }
4431
6192
  activeContext = undefined;
4432
6193
  await teardown();
4433
6194
  },
4434
6195
  async dispose() {
4435
- if (lifecycle === 'initializing' || lifecycle === 'restoring') {
6196
+ if (lifecycle === 'initializing' ||
6197
+ lifecycle === 'restoring' ||
6198
+ retainedGenerationInstallationInProgress) {
4436
6199
  throw new Error('cannot dispose while Captain shell setup is in progress');
4437
6200
  }
4438
6201
  activeContext = undefined;
@@ -4451,6 +6214,18 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4451
6214
  catch (error) {
4452
6215
  failure = error;
4453
6216
  }
6217
+ const retainedRuntimes = takeRetainedOfferRuntimes();
6218
+ if (retainedRuntimes.length > 0) {
6219
+ try {
6220
+ await disposeRetainedRuntimeSet(retainedRuntimes, 'retained-generation shell cleanup failed');
6221
+ }
6222
+ catch (error) {
6223
+ failure ??= error;
6224
+ }
6225
+ }
6226
+ retainedGenerations.clear();
6227
+ ineligibleRetainedGenerations.clear();
6228
+ retainedGenerationRootClears.clear();
4454
6229
  const runtime = captainRuntime;
4455
6230
  captainRuntime = undefined;
4456
6231
  if (runtime) {
@@ -4465,6 +6240,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4465
6240
  // Quarantine is session-wide by design. Only terminal teardown may drop
4466
6241
  // its ownership after every frame host call and the Captain are drained.
4467
6242
  playerTransactions.clear();
6243
+ hostCapabilitiesById = new Map();
6244
+ pendingHostCapabilities = undefined;
6245
+ currentEffectLedger = () => emptyPlaybookEffectLedger();
4468
6246
  lifecycle = 'closed';
4469
6247
  if (failure !== undefined)
4470
6248
  throw failure;