@sublang/playbook 8.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 (66) hide show
  1. package/README.md +3 -3
  2. package/docs/cli.md +66 -23
  3. package/docs/configuration.md +13 -8
  4. package/docs/embedding.md +45 -14
  5. package/package.json +7 -3
  6. package/reference/sdlc/captain.md +14 -10
  7. package/reference/sdlc/captain.playbook/captain.fsm.d.ts +33 -13
  8. package/reference/sdlc/captain.playbook/captain.fsm.js +80 -9
  9. package/reference/sdlc/captain.playbook/captain.fsm.ts +137 -18
  10. package/reference/sdlc/captain.playbook/captain.gears.md +10 -6
  11. package/reference/sdlc/captain.playbook/captain.playbook.d.ts +5 -1
  12. package/reference/sdlc/captain.playbook/captain.playbook.js +151 -10
  13. package/reference/sdlc/captain.playbook/captain.playbook.ts +200 -14
  14. package/reference/sdlc/code.md +0 -1
  15. package/reference/sdlc/code.playbook/bin/interactive-session.js +170 -17
  16. package/reference/sdlc/code.playbook/bin/launch-config.js +136 -4
  17. package/reference/sdlc/code.playbook/bin/playbook.js +81 -4
  18. package/reference/sdlc/code.playbook/bin/repository-effects.js +2930 -0
  19. package/reference/sdlc/code.playbook/bin/run.js +365 -63
  20. package/reference/sdlc/code.playbook/bin/session-store.js +2877 -209
  21. package/reference/sdlc/code.playbook/code.fsm.d.ts +11 -1
  22. package/reference/sdlc/code.playbook/code.fsm.js +85 -29
  23. package/reference/sdlc/code.playbook/code.fsm.ts +95 -33
  24. package/reference/sdlc/code.playbook/code.gears.md +0 -2
  25. package/reference/sdlc/code.playbook/code.playbook.d.ts +5 -2
  26. package/reference/sdlc/code.playbook/code.playbook.js +67 -4
  27. package/reference/sdlc/code.playbook/code.playbook.ts +87 -8
  28. package/reference/sdlc/code.playbook/code.registry.d.ts +10 -3
  29. package/reference/sdlc/code.playbook/code.registry.js +10 -3
  30. package/reference/sdlc/code.playbook/code.registry.ts +23 -5
  31. package/reference/sdlc/code.playbook/playbook-captain.d.ts +99 -7
  32. package/reference/sdlc/code.playbook/playbook-captain.js +1894 -82
  33. package/reference/sdlc/code.playbook/playbook-captain.ts +2809 -109
  34. package/reference/sdlc/decide.md +0 -1
  35. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +8 -1
  36. package/reference/sdlc/decide.playbook/decide.fsm.js +80 -29
  37. package/reference/sdlc/decide.playbook/decide.fsm.ts +89 -31
  38. package/reference/sdlc/decide.playbook/decide.gears.md +0 -1
  39. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +15 -5
  40. package/reference/sdlc/decide.playbook/decide.playbook.js +1994 -191
  41. package/reference/sdlc/decide.playbook/decide.playbook.ts +3209 -404
  42. package/reference/sdlc/decide.playbook/decide.registry.d.ts +7 -3
  43. package/reference/sdlc/decide.playbook/decide.registry.js +10 -3
  44. package/reference/sdlc/decide.playbook/decide.registry.ts +20 -5
  45. package/reference/sdlc/review.playbook/review.fsm.d.ts +7 -0
  46. package/reference/sdlc/review.playbook/review.fsm.js +133 -12
  47. package/reference/sdlc/review.playbook/review.fsm.ts +140 -12
  48. package/reference/sdlc/review.playbook/review.playbook.d.ts +5 -2
  49. package/reference/sdlc/review.playbook/review.playbook.js +78 -4
  50. package/reference/sdlc/review.playbook/review.playbook.ts +95 -8
  51. package/reference/sdlc/review.playbook/review.registry.d.ts +10 -3
  52. package/reference/sdlc/review.playbook/review.registry.js +10 -3
  53. package/reference/sdlc/review.playbook/review.registry.ts +23 -5
  54. package/slc/gears2fsm.md +25 -7
  55. package/slc/link.md +727 -82
  56. package/src/accepted-outcome.d.ts +18 -0
  57. package/src/accepted-outcome.js +94 -0
  58. package/src/accepted-outcome.ts +140 -0
  59. package/src/runtime.d.ts +165 -3
  60. package/src/runtime.ts +214 -2
  61. package/src/xstate-playbook-runtime.d.ts +162 -13
  62. package/src/xstate-playbook-runtime.js +3344 -564
  63. package/src/xstate-playbook-runtime.ts +4873 -637
  64. package/src/xstate-runtime.d.ts +76 -8
  65. package/src/xstate-runtime.js +1001 -64
  66. package/src/xstate-runtime.ts +1640 -91
@@ -19,6 +19,8 @@ import type { Effort, PermissionPolicy } from '@sublang/cligent';
19
19
  import type {
20
20
  JsonValue,
21
21
  NormalizedError,
22
+ PlaybookEffectLedger,
23
+ PlaybookEffectLedgerCommandBatch,
22
24
  PlaybookCallRequest,
23
25
  PlaybookCallResult,
24
26
  PlaybookCallStart,
@@ -33,7 +35,10 @@ import type {
33
35
  } from '@sublang/playbook/runtime';
34
36
  import {
35
37
  assertPlaybookRuntimeSnapshot,
38
+ assertPlaybookEffectLedger,
39
+ emptyPlaybookEffectLedger,
36
40
  hiddenControlEnvelope,
41
+ isPlaybookEffectLedgerMonotonicExtension,
37
42
  registerPlaybookAbortCleanup,
38
43
  snapshotJsonValue,
39
44
  validatePlayerResult,
@@ -69,6 +74,25 @@ type DeepReadonly<T> = T extends (...args: never[]) => unknown
69
74
  ? { readonly [Key in keyof T]: DeepReadonly<T[Key]> }
70
75
  : T;
71
76
 
77
+ export interface PlaybookCaptainUnresolvedEffect {
78
+ readonly classification:
79
+ | 'one-descendant-commit'
80
+ | 'multiple-commits'
81
+ | 'rewritten-or-non-descendant'
82
+ | 'worktree-only-change'
83
+ | 'concurrent-or-foreign-change'
84
+ | 'observation-ambiguous'
85
+ | 'incomplete';
86
+ readonly baselineHead: string;
87
+ readonly afterHead?: string;
88
+ readonly commitOid?: string;
89
+ }
90
+
91
+ interface PlaybookCaptainUnresolvedEffectSettlementInput {
92
+ readonly rootPlaybookId: string;
93
+ readonly unresolvedEffects: readonly PlaybookCaptainUnresolvedEffect[];
94
+ }
95
+
72
96
  type SnapshotAgentEnvelope = DeepReadonly<
73
97
  Omit<SessionAgent, 'model' | 'effort'>
74
98
  >;
@@ -78,6 +102,9 @@ type PlayerLedgerSnapshotEntry = DeepReadonly<PlayerLedgerEntry>;
78
102
  export interface PlaybookCaptainDeps {
79
103
  loadModule?: (specifier: string) => Promise<unknown>;
80
104
  createSessionId?: () => string;
105
+ hostCapabilities?: Readonly<
106
+ Record<string, PlaybookHostConstructionCapabilities>
107
+ >;
81
108
  createCaptainRuntime?: (options: {
82
109
  readonly enabledPlaybooks: readonly {
83
110
  readonly id: string;
@@ -86,20 +113,81 @@ export interface PlaybookCaptainDeps {
86
113
  }[];
87
114
  readonly controller: CaptainControllerPort;
88
115
  }) => PlaybookRuntime;
116
+ unresolvedEffectSettlement?: {
117
+ begin(input: PlaybookCaptainUnresolvedEffectSettlementInput): Promise<void>;
118
+ complete(input: PlaybookCaptainUnresolvedEffectSettlementInput): Promise<void>;
119
+ };
120
+ }
121
+
122
+ /** Live, artifact-typed host facilities supplied outside configured options. */
123
+ export interface PlaybookHostConstructionCapabilities {
124
+ readonly authority: {
125
+ readonly playbookId: string;
126
+ readonly artifactSchema: 3;
127
+ readonly cwd: string;
128
+ readonly sessionId: string;
129
+ readonly leaseOwnerToken: string;
130
+ readonly canonicalWorktree: {
131
+ readonly worktree: string;
132
+ readonly gitDir: string;
133
+ };
134
+ readonly requiredRoleIds: readonly string[];
135
+ readonly concurrentRoleSets: readonly (readonly string[])[];
136
+ };
137
+ readonly repository: {
138
+ readonly identity: {
139
+ readonly worktree: string;
140
+ readonly gitDir: string;
141
+ };
142
+ readonly observe: (options?: unknown) => Promise<unknown>;
143
+ readonly acquire: (options?: unknown) => Promise<unknown>;
144
+ readonly runExclusive: (options: unknown) => Promise<unknown>;
145
+ readonly runCohort: (options: unknown) => Promise<unknown>;
146
+ readonly runDeferred: (options: unknown) => Promise<unknown>;
147
+ };
148
+ readonly effectLedger: {
149
+ readonly snapshot: () => PlaybookEffectLedger;
150
+ readonly writeAhead: (
151
+ commands: PlaybookEffectLedgerCommandBatch,
152
+ ) => Promise<PlaybookEffectLedger>;
153
+ };
89
154
  }
90
155
 
91
- export interface PlaybookCaptainRegistryEntry {
156
+ export type PlaybookCaptainRuntimeProfile =
157
+ | {
158
+ readonly kind: 'shared-factory';
159
+ readonly compat: {
160
+ readonly artifactSchema: 3;
161
+ readonly runtimeAbi: number;
162
+ };
163
+ }
164
+ | {
165
+ readonly kind: 'bespoke';
166
+ readonly artifactSchema: 3;
167
+ };
168
+
169
+ interface PlaybookCaptainRegistryEntryBase {
92
170
  id: string;
93
171
  command: string;
94
172
  intent: string;
95
- artifactSchema: 2;
173
+ runtimeProfile: PlaybookCaptainRuntimeProfile;
96
174
  requiredRoleIds: readonly string[];
97
175
  concurrentRoleSets: readonly (readonly string[])[];
98
176
  summaryPolicy?: PlaybookSummaryPolicy;
99
177
  validateOptions(optionSlice: unknown): unknown;
100
- createRuntime(options: unknown): PlaybookRuntime;
101
178
  }
102
179
 
180
+ export interface PlaybookCaptainRegistryEntryV3
181
+ extends PlaybookCaptainRegistryEntryBase {
182
+ artifactSchema: 3;
183
+ createRuntime(
184
+ configuredOptions: unknown,
185
+ hostCapabilities: PlaybookHostConstructionCapabilities,
186
+ ): PlaybookRuntime;
187
+ }
188
+
189
+ export type PlaybookCaptainRegistryEntry = PlaybookCaptainRegistryEntryV3;
190
+
103
191
  type PlaybookCaptainConversationSnapshot =
104
192
  | { readonly kind: 'unopened' }
105
193
  | { readonly kind: 'pinned'; readonly token: string }
@@ -117,7 +205,7 @@ interface PlaybookCaptainJournalRecord {
117
205
  readonly payload: JsonValue;
118
206
  }
119
207
 
120
- interface PlaybookCaptainFrameSnapshot {
208
+ export interface PlaybookCaptainFrameSnapshot {
121
209
  readonly playbookId: string;
122
210
  readonly sessionId: string;
123
211
  readonly rootSessionId: string;
@@ -130,7 +218,8 @@ interface PlaybookCaptainFrameSnapshot {
130
218
  }
131
219
 
132
220
  interface PlaybookCaptainShellSnapshotFields {
133
- readonly schemaVersion: 3;
221
+ readonly schemaVersion: 4;
222
+ readonly effectLedger: DeepReadonly<PlaybookEffectLedger>;
134
223
  readonly captain: {
135
224
  readonly sessionId: string;
136
225
  readonly runtime: DeepReadonly<PlaybookRuntimeSnapshot>;
@@ -149,6 +238,7 @@ interface PlaybookCaptainShellSnapshotFields {
149
238
  | 'respond'
150
239
  | 'start'
151
240
  | 'switch'
241
+ | 'resume'
152
242
  | 'dismiss'
153
243
  | 'deliver'
154
244
  | 'runtime';
@@ -172,6 +262,7 @@ type PlaybookCaptainShellSnapshotValue =
172
262
  readonly mode: 'engaged.parked';
173
263
  /** Root-to-leaf engagement order. */
174
264
  readonly frames: readonly PlaybookCaptainFrameSnapshot[];
265
+ readonly retainedEffectReconciliation?: PlaybookCaptainRetainedEffectReconciliation;
175
266
  readonly pendingBossQuestions?: JsonValue;
176
267
  readonly lastError?: { readonly name: string; readonly message: string };
177
268
  }
@@ -180,9 +271,76 @@ type PlaybookCaptainShellSnapshotValue =
180
271
  export type PlaybookCaptainShellSnapshot =
181
272
  DeepReadonly<PlaybookCaptainShellSnapshotValue>;
182
273
 
274
+ export interface PlaybookCaptainRetainedGeneration {
275
+ /** Repository-effect checkpoint reflected by the retained machine state. */
276
+ readonly effectLedger: DeepReadonly<PlaybookEffectLedger>;
277
+ readonly frames: readonly PlaybookCaptainFrameSnapshot[];
278
+ readonly retainedEffectReconciliation?: {
279
+ readonly sourceGenerationId: string;
280
+ };
281
+ /** Boss-facing description published for the retained root state, if any. */
282
+ readonly rootStateDescription?: string;
283
+ }
284
+
285
+ interface PlaybookCaptainRetainedEffectReconciliation {
286
+ readonly sourceGenerationId: string;
287
+ readonly checkpoint: DeepReadonly<PlaybookEffectLedger>;
288
+ }
289
+
290
+ function retainedEffectLedgerCanRebase(
291
+ checkpoint: PlaybookEffectLedger,
292
+ current: PlaybookEffectLedger,
293
+ ): boolean {
294
+ if (
295
+ checkpoint.boundaries.some(
296
+ ({ physicalReceipt }) => physicalReceipt === undefined,
297
+ )
298
+ ) {
299
+ return false;
300
+ }
301
+ if (!isPlaybookEffectLedgerMonotonicExtension(checkpoint, current)) {
302
+ return false;
303
+ }
304
+ if (
305
+ !isDeepStrictEqual(
306
+ current.boundaries.slice(0, checkpoint.boundaries.length),
307
+ checkpoint.boundaries,
308
+ ) ||
309
+ !isDeepStrictEqual(current.logicalOperations, checkpoint.logicalOperations)
310
+ ) {
311
+ return false;
312
+ }
313
+ return current.boundaries
314
+ .slice(checkpoint.boundaries.length)
315
+ .every(
316
+ ({ physicalReceipt }) =>
317
+ physicalReceipt?.classification === 'unchanged',
318
+ );
319
+ }
320
+
321
+ export type PlaybookCaptainRetentionUpdate =
322
+ | {
323
+ readonly kind: 'retain';
324
+ readonly rootPlaybookId: string;
325
+ readonly generation: PlaybookCaptainRetainedGeneration;
326
+ }
327
+ | { readonly kind: 'clear'; readonly rootPlaybookId: string };
328
+
329
+ export interface PlaybookCaptainSettlement {
330
+ readonly snapshot: PlaybookCaptainShellSnapshot;
331
+ readonly retentionUpdates: readonly PlaybookCaptainRetentionUpdate[];
332
+ readonly unresolvedEffects: readonly PlaybookCaptainUnresolvedEffect[];
333
+ }
334
+
183
335
  /** tmux and headless front ends share this one durable Captain shell API. */
184
336
  export interface PlaybookCaptainShell extends Captain {
337
+ installRetainedGenerations(
338
+ generations: Readonly<
339
+ Record<string, PlaybookCaptainRetainedGeneration>
340
+ >,
341
+ ): Promise<void>;
185
342
  exportSnapshot(): PlaybookCaptainShellSnapshot | undefined;
343
+ exportSettlement(): PlaybookCaptainSettlement | undefined;
186
344
  restore(
187
345
  session: CaptainSession,
188
346
  snapshot: PlaybookCaptainShellSnapshot,
@@ -193,11 +351,28 @@ export interface PlaybookCaptainShell extends Captain {
193
351
  // normalized `captain.options.playbooks.<id>` role map.
194
352
  interface Enablement {
195
353
  entry: PlaybookCaptainRegistryEntry;
354
+ artifactSchema: 3;
196
355
  command: string;
197
356
  options: JsonValue;
198
357
  roleBindings: ReadonlyMap<string, EffectivePlayerBinding>;
199
358
  }
200
359
 
360
+ function createRuntimeForEnablement(
361
+ enablement: Enablement,
362
+ hostCapabilitiesById: ReadonlyMap<
363
+ string,
364
+ PlaybookHostConstructionCapabilities
365
+ >,
366
+ ): PlaybookRuntime {
367
+ const hostCapabilities = hostCapabilitiesById.get(enablement.entry.id);
368
+ if (hostCapabilities === undefined) {
369
+ throw new Error(
370
+ `/${enablement.command} schema-3 runtime requires current-host construction capabilities`,
371
+ );
372
+ }
373
+ return enablement.entry.createRuntime(enablement.options, hostCapabilities);
374
+ }
375
+
201
376
  interface EffectivePlayerBinding {
202
377
  readonly playerId: string;
203
378
  readonly model: TuningSelection;
@@ -289,7 +464,12 @@ async function classifySettingsCall<T>(call: () => Promise<T>): Promise<T> {
289
464
  }
290
465
  }
291
466
 
292
- type DisposalReason = 'dismiss' | 'final' | 'dispose' | 'failure';
467
+ type DisposalReason =
468
+ | 'dismiss'
469
+ | 'final'
470
+ | 'dispose'
471
+ | 'failure'
472
+ | 'unresolved-effect';
293
473
 
294
474
  interface ControlLedger {
295
475
  activePlaybookId?: string;
@@ -366,6 +546,12 @@ const UUID_PATTERN =
366
546
  /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
367
547
  const PLAYER_ID_PATTERN = /^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)*$/;
368
548
  const ROLE_ID_PATTERN = /^[a-z][a-z0-9_-]*$/;
549
+ const HOST_CAPABILITIES_OPTION_KEY = 'hostCapabilities';
550
+ const UNRESOLVED_EFFECT_RECONCILIATION_ACTION_ID =
551
+ 'reconcile:unresolved-effect';
552
+ const UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID = 'abandon:unresolved-effect';
553
+ const RESUMPTION_DUPLICATE_EFFECT_WARNING =
554
+ 'Warning: resumption may duplicate external effects attempted after the retained boundary; verify the current world before continuing.';
369
555
 
370
556
  interface TurnSummaryCounts {
371
557
  interruptions: number;
@@ -376,6 +562,7 @@ interface ActiveTurnSummary {
376
562
  owner: EngagementFrame;
377
563
  counts: TurnSummaryCounts;
378
564
  stateCounts: Map<string, number>;
565
+ acceptedOutcomeTraceKeys: Set<string>;
379
566
  }
380
567
 
381
568
  /** The shell state of one Boss turn (DR-029). */
@@ -402,6 +589,8 @@ interface ActiveTurn {
402
589
  presentationAttempted: boolean;
403
590
  /** The exact rejected presentation boundary, propagated without retry. */
404
591
  presentationError?: unknown;
592
+ /** Shell-authored safety text that must accompany the one visible reply. */
593
+ mandatoryPresentationSuffix?: string;
405
594
  /** Facts accumulated while the selected action runs, including partial work. */
406
595
  readonly settlementFacts: string[];
407
596
  report?: OutcomeReport;
@@ -416,8 +605,8 @@ interface ActiveTurn {
416
605
  readonly settingsPreflightFailures: Set<unknown>;
417
606
  /**
418
607
  * Every value that escaped an effect invocation this turn — a runtime
419
- * driven, an engagement constructed, a stack disposed, an advertised action
420
- * applied — recorded by `runEffect` at the throw itself.
608
+ * driven or adopted, an engagement constructed, a stack disposed, an
609
+ * advertised action applied — recorded by `runEffect` at the throw itself.
421
610
  *
422
611
  * Attribution follows the operation that threw rather than a latch set
423
612
  * before it. A latch is turn-scoped, so once any effect has been attempted
@@ -446,6 +635,8 @@ interface ActiveTurn {
446
635
  outcomePending?: boolean;
447
636
  /** Whether this turn already has a closing journal outcome. */
448
637
  outcomeRecorded: boolean;
638
+ /** Canonical host evidence frozen before the controller result phase. */
639
+ unresolvedEffects?: readonly PlaybookCaptainUnresolvedEffect[];
449
640
  }
450
641
 
451
642
  function parseRegisteredCommand(
@@ -561,6 +752,31 @@ function stateDigestLine(
561
752
  ].join('; ');
562
753
  }
563
754
 
755
+ // CAPTAIN-5's mirrored ledger member holds the runtime snapshot's
756
+ // pending-question projection — entries of `{ questionId, asker, question,
757
+ // sourceItem }` — never a raw telemetry payload: the linked runtime's state
758
+ // telemetry carries the singular full-context question, whose extra
759
+ // runtime-internal fields (`resumeStateId`) fail the durable snapshot's
760
+ // leaf-projection equality (CAPTAIN-41) and with it headless settlement.
761
+ function mirroredBossQuestions(value: unknown): unknown {
762
+ if (value === undefined || value === null) return undefined;
763
+ const entries = Array.isArray(value) ? value : [value];
764
+ return entries.map((entry) => {
765
+ if (typeof entry !== 'object' || entry === null) return entry;
766
+ const record = entry as Record<string, unknown>;
767
+ const projected: Record<string, unknown> = {};
768
+ if (record.questionId !== undefined) {
769
+ projected.questionId = record.questionId;
770
+ }
771
+ if (record.asker !== undefined) projected.asker = record.asker;
772
+ if (record.question !== undefined) projected.question = record.question;
773
+ if (record.sourceItem !== undefined) {
774
+ projected.sourceItem = record.sourceItem;
775
+ }
776
+ return projected;
777
+ });
778
+ }
779
+
564
780
  function pendingQuestionLines(pending: unknown): string[] {
565
781
  const list = Array.isArray(pending)
566
782
  ? pending
@@ -880,11 +1096,62 @@ interface OutcomeReport {
880
1096
  savedLine?: string;
881
1097
  }
882
1098
 
1099
+ type ControllerSettlementDraft = Omit<
1100
+ SettlementEvidence,
1101
+ 'unresolvedEffects'
1102
+ >;
1103
+
1104
+ function unresolvedEffectReportLines(
1105
+ unresolvedEffects: readonly PlaybookCaptainUnresolvedEffect[],
1106
+ ): readonly string[] {
1107
+ return unresolvedEffects.map((effect, index) => {
1108
+ const merelyPossible =
1109
+ effect.classification === 'observation-ambiguous' ||
1110
+ effect.classification === 'incomplete';
1111
+ return [
1112
+ `${index + 1}. ${merelyPossible ? 'Possible repository effect; a change could not be excluded' : 'Observed repository change'} (${effect.classification})`,
1113
+ `baseline HEAD ${effect.baselineHead}`,
1114
+ effect.afterHead === undefined
1115
+ ? 'after HEAD was not available'
1116
+ : `after HEAD ${effect.afterHead}`,
1117
+ ...(effect.commitOid === undefined
1118
+ ? []
1119
+ : [`proven commit OID ${effect.commitOid}`]),
1120
+ ].join('; ') + '.';
1121
+ });
1122
+ }
1123
+
1124
+ function unresolvedEffectBossReport(
1125
+ unresolvedEffects: readonly PlaybookCaptainUnresolvedEffect[],
1126
+ ): string | undefined {
1127
+ if (unresolvedEffects.length === 0) return undefined;
1128
+ return [
1129
+ 'Repository-effect evidence:',
1130
+ ...unresolvedEffectReportLines(unresolvedEffects).map((line) => `- ${line}`),
1131
+ 'This evidence does not establish workflow completion or attribute any repository change or commit to this workflow.',
1132
+ ].join('\n');
1133
+ }
1134
+
1135
+ function appendMandatoryPresentationSuffix(
1136
+ turn: ActiveTurn,
1137
+ suffix: string,
1138
+ ): void {
1139
+ const current = turn.mandatoryPresentationSuffix;
1140
+ if (current === undefined) {
1141
+ turn.mandatoryPresentationSuffix = suffix;
1142
+ } else if (!current.includes(suffix)) {
1143
+ turn.mandatoryPresentationSuffix = `${current}\n\n${suffix}`;
1144
+ }
1145
+ }
1146
+
883
1147
  // CAPTAIN-20: the result-phase block the shell supplies inside the closing
884
1148
  // reply call's envelope — the settlement's outcome-report facts verbatim, the
885
1149
  // exact counts, and the saved-counts line only when counted activity is
886
1150
  // nonzero.
887
- function outcomeReportBlock(report: OutcomeReport): string {
1151
+ function outcomeReportBlock(
1152
+ report: OutcomeReport,
1153
+ unresolvedEffects: readonly PlaybookCaptainUnresolvedEffect[],
1154
+ ): string {
888
1155
  const lines: string[] = [
889
1156
  `Settlement status: ${report.status}`,
890
1157
  ...(report.playbookId === undefined
@@ -910,6 +1177,14 @@ function outcomeReportBlock(report: OutcomeReport): string {
910
1177
  if (report.leafStateSummary !== undefined) {
911
1178
  lines.push(`Resulting leaf state: ${report.leafStateSummary}`);
912
1179
  }
1180
+ const effectLines = unresolvedEffectReportLines(unresolvedEffects);
1181
+ if (effectLines.length > 0) {
1182
+ lines.push('Repository-effect evidence (canonical, in ledger order):');
1183
+ lines.push(...effectLines.map((line) => `- ${line}`));
1184
+ lines.push(
1185
+ 'Report this evidence without claiming workflow completion or attributing any repository change or commit to the workflow.',
1186
+ );
1187
+ }
913
1188
  lines.push(`Progress counts: ${report.progressPhrase}`);
914
1189
  lines.push(
915
1190
  `Counts: ${JSON.stringify({
@@ -959,12 +1234,209 @@ function summaryProgressRoundCount(
959
1234
  return [...stateCounts.values()].reduce((total, count) => total + count, 0);
960
1235
  }
961
1236
 
962
- function guardFromJudgeReply(finalText: string): string | undefined {
963
- return /"guard"\s*:\s*"([^"]+)"/.exec(finalText)?.[1];
1237
+ interface ValidatedRuntimeProfile {
1238
+ readonly kind: 'shared-factory' | 'bespoke';
1239
+ readonly artifactSchema: 3;
1240
+ }
1241
+
1242
+ function captureRegistryEntry(value: unknown): unknown {
1243
+ if (value === null || typeof value !== 'object') return value;
1244
+ const source = value as Record<string, unknown>;
1245
+ return {
1246
+ id: source.id,
1247
+ command: source.command,
1248
+ intent: source.intent,
1249
+ artifactSchema: source.artifactSchema,
1250
+ runtimeProfile: source.runtimeProfile,
1251
+ requiredRoleIds: source.requiredRoleIds,
1252
+ concurrentRoleSets: source.concurrentRoleSets,
1253
+ summaryPolicy: source.summaryPolicy,
1254
+ validateOptions: source.validateOptions,
1255
+ createRuntime: source.createRuntime,
1256
+ };
1257
+ }
1258
+
1259
+ function exactOwnDataRecord(
1260
+ value: unknown,
1261
+ keys: readonly string[],
1262
+ ): Readonly<Record<string, unknown>> | undefined {
1263
+ if (
1264
+ value === null ||
1265
+ typeof value !== 'object' ||
1266
+ Array.isArray(value) ||
1267
+ (Object.getPrototypeOf(value) !== Object.prototype &&
1268
+ Object.getPrototypeOf(value) !== null)
1269
+ ) {
1270
+ return undefined;
1271
+ }
1272
+ const descriptors = Object.getOwnPropertyDescriptors(value);
1273
+ if (
1274
+ Reflect.ownKeys(descriptors).length !== keys.length ||
1275
+ keys.some(
1276
+ (key) =>
1277
+ !Object.hasOwn(descriptors, key) ||
1278
+ !Object.hasOwn(descriptors[key]!, 'value') ||
1279
+ descriptors[key]!.enumerable !== true,
1280
+ )
1281
+ ) {
1282
+ return undefined;
1283
+ }
1284
+ return Object.fromEntries(
1285
+ keys.map((key) => [key, descriptors[key]!.value]),
1286
+ );
1287
+ }
1288
+
1289
+ function captureHostCapabilityRecord(
1290
+ value: PlaybookCaptainDeps['hostCapabilities'],
1291
+ ): Readonly<Record<string, PlaybookHostConstructionCapabilities>> {
1292
+ if (value === undefined) return Object.freeze({});
1293
+ const captured = exactOwnDataRecord(value, Object.keys(value));
1294
+ if (captured === undefined) {
1295
+ throw new TypeError(
1296
+ 'current-host construction capabilities must be an exact data-property record',
1297
+ );
1298
+ }
1299
+ return captured as Readonly<
1300
+ Record<string, PlaybookHostConstructionCapabilities>
1301
+ >;
1302
+ }
1303
+
1304
+ function validateHostCapabilities(
1305
+ value: unknown,
1306
+ entry: PlaybookCaptainRegistryEntryV3,
1307
+ command: string,
1308
+ ): PlaybookHostConstructionCapabilities {
1309
+ if (value === undefined) {
1310
+ throw new Error(
1311
+ `/${command} schema-3 runtime requires current-host construction capabilities`,
1312
+ );
1313
+ }
1314
+ const capability = exactOwnDataRecord(value, [
1315
+ 'authority',
1316
+ 'repository',
1317
+ 'effectLedger',
1318
+ ]);
1319
+ const authority = exactOwnDataRecord(capability?.authority, [
1320
+ 'playbookId',
1321
+ 'artifactSchema',
1322
+ 'cwd',
1323
+ 'sessionId',
1324
+ 'leaseOwnerToken',
1325
+ 'canonicalWorktree',
1326
+ 'requiredRoleIds',
1327
+ 'concurrentRoleSets',
1328
+ ]);
1329
+ const canonicalWorktree = exactOwnDataRecord(
1330
+ authority?.canonicalWorktree,
1331
+ ['worktree', 'gitDir'],
1332
+ );
1333
+ const repository = exactOwnDataRecord(capability?.repository, [
1334
+ 'identity',
1335
+ 'observe',
1336
+ 'acquire',
1337
+ 'runExclusive',
1338
+ 'runCohort',
1339
+ 'runDeferred',
1340
+ ]);
1341
+ const identity = exactOwnDataRecord(repository?.identity, [
1342
+ 'worktree',
1343
+ 'gitDir',
1344
+ ]);
1345
+ const effectLedger = exactOwnDataRecord(capability?.effectLedger, [
1346
+ 'snapshot',
1347
+ 'writeAhead',
1348
+ ]);
1349
+ if (
1350
+ authority?.playbookId !== entry.id ||
1351
+ authority.artifactSchema !== 3 ||
1352
+ typeof authority.cwd !== 'string' ||
1353
+ authority.cwd.length === 0 ||
1354
+ typeof authority.sessionId !== 'string' ||
1355
+ authority.sessionId.length === 0 ||
1356
+ typeof authority.leaseOwnerToken !== 'string' ||
1357
+ authority.leaseOwnerToken.length === 0 ||
1358
+ canonicalWorktree === undefined ||
1359
+ typeof canonicalWorktree.worktree !== 'string' ||
1360
+ canonicalWorktree.worktree.length === 0 ||
1361
+ typeof canonicalWorktree.gitDir !== 'string' ||
1362
+ canonicalWorktree.gitDir.length === 0 ||
1363
+ !isDeepStrictEqual(authority.requiredRoleIds, entry.requiredRoleIds) ||
1364
+ !isDeepStrictEqual(
1365
+ authority.concurrentRoleSets,
1366
+ entry.concurrentRoleSets,
1367
+ ) ||
1368
+ identity === undefined ||
1369
+ !isDeepStrictEqual(identity, canonicalWorktree) ||
1370
+ typeof repository?.observe !== 'function' ||
1371
+ typeof repository.acquire !== 'function' ||
1372
+ typeof repository.runExclusive !== 'function' ||
1373
+ typeof repository.runCohort !== 'function' ||
1374
+ typeof repository.runDeferred !== 'function' ||
1375
+ typeof effectLedger?.snapshot !== 'function' ||
1376
+ typeof effectLedger.writeAhead !== 'function'
1377
+ ) {
1378
+ throw new Error(
1379
+ `/${command} schema-3 current-host capability authority does not match its imported artifact`,
1380
+ );
1381
+ }
1382
+ return value as PlaybookHostConstructionCapabilities;
1383
+ }
1384
+
1385
+ function effectLedgerMirrorFromCapabilities(
1386
+ capabilities: ReadonlyMap<string, PlaybookHostConstructionCapabilities>,
1387
+ ): PlaybookEffectLedger {
1388
+ const values = [...capabilities.values()];
1389
+ if (values.length === 0) return emptyPlaybookEffectLedger();
1390
+ const mirror = assertPlaybookEffectLedger(values[0]!.effectLedger.snapshot());
1391
+ for (const capability of values.slice(1)) {
1392
+ if (
1393
+ !isDeepStrictEqual(
1394
+ assertPlaybookEffectLedger(capability.effectLedger.snapshot()),
1395
+ mirror,
1396
+ )
1397
+ ) {
1398
+ throw new Error(
1399
+ 'schema-3 current-host capabilities disagree on their effect ledger',
1400
+ );
1401
+ }
1402
+ }
1403
+ return mirror;
1404
+ }
1405
+
1406
+ function validateRuntimeProfile(
1407
+ value: unknown,
1408
+ ): ValidatedRuntimeProfile | undefined {
1409
+ const shared = exactOwnDataRecord(value, ['kind', 'compat']);
1410
+ if (shared?.kind === 'shared-factory') {
1411
+ const compat = exactOwnDataRecord(shared.compat, [
1412
+ 'artifactSchema',
1413
+ 'runtimeAbi',
1414
+ ]);
1415
+ if (
1416
+ compat?.artifactSchema === 3 &&
1417
+ typeof compat.runtimeAbi === 'number' &&
1418
+ Number.isSafeInteger(compat.runtimeAbi)
1419
+ ) {
1420
+ return {
1421
+ kind: 'shared-factory',
1422
+ artifactSchema: compat.artifactSchema,
1423
+ };
1424
+ }
1425
+ return undefined;
1426
+ }
1427
+ const bespoke = exactOwnDataRecord(value, ['kind', 'artifactSchema']);
1428
+ if (
1429
+ bespoke?.kind === 'bespoke' &&
1430
+ bespoke.artifactSchema === 3
1431
+ ) {
1432
+ return { kind: 'bespoke', artifactSchema: bespoke.artifactSchema };
1433
+ }
1434
+ return undefined;
964
1435
  }
965
1436
 
966
1437
  function isValidRegistryEntry(
967
1438
  value: unknown,
1439
+ artifactSchema: unknown,
968
1440
  ): value is PlaybookCaptainRegistryEntry {
969
1441
  if (typeof value !== 'object' || value === null) return false;
970
1442
  const e = value as Record<string, unknown>;
@@ -1000,7 +1472,7 @@ function isValidRegistryEntry(
1000
1472
  typeof e.id === 'string' &&
1001
1473
  typeof e.command === 'string' &&
1002
1474
  typeof e.intent === 'string' &&
1003
- e.artifactSchema === 2 &&
1475
+ artifactSchema === 3 &&
1004
1476
  typeof e.validateOptions === 'function' &&
1005
1477
  typeof e.createRuntime === 'function'
1006
1478
  );
@@ -1010,6 +1482,7 @@ const SNAPSHOT_ACTIONS = new Set([
1010
1482
  'respond',
1011
1483
  'start',
1012
1484
  'switch',
1485
+ 'resume',
1013
1486
  'dismiss',
1014
1487
  'deliver',
1015
1488
  'runtime',
@@ -1026,6 +1499,93 @@ const SNAPSHOT_JOURNAL_KINDS = new Set([
1026
1499
  'action',
1027
1500
  'outcome',
1028
1501
  ] as const);
1502
+ const UNRESOLVED_EFFECT_CLASSIFICATIONS = new Set<
1503
+ PlaybookCaptainUnresolvedEffect['classification']
1504
+ >([
1505
+ 'one-descendant-commit',
1506
+ 'multiple-commits',
1507
+ 'rewritten-or-non-descendant',
1508
+ 'worktree-only-change',
1509
+ 'concurrent-or-foreign-change',
1510
+ 'observation-ambiguous',
1511
+ 'incomplete',
1512
+ ]);
1513
+ const GIT_OID_PATTERN = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/;
1514
+
1515
+ export function assertPlaybookCaptainUnresolvedEffects(
1516
+ value: unknown,
1517
+ ): readonly PlaybookCaptainUnresolvedEffect[] {
1518
+ const detached = snapshotJsonValue(
1519
+ value,
1520
+ 'Captain unresolved effects',
1521
+ );
1522
+ if (!Array.isArray(detached)) {
1523
+ throw new TypeError('Captain unresolved effects must be an array');
1524
+ }
1525
+ for (const [index, raw] of detached.entries()) {
1526
+ const path = `Captain unresolved effects[${index}]`;
1527
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
1528
+ throw new TypeError(`${path} must be an object`);
1529
+ }
1530
+ const entry = raw as Record<string, JsonValue>;
1531
+ const allowed = new Set([
1532
+ 'classification',
1533
+ 'baselineHead',
1534
+ 'afterHead',
1535
+ 'commitOid',
1536
+ ]);
1537
+ const unknown = Object.keys(entry).find((key) => !allowed.has(key));
1538
+ if (unknown !== undefined) {
1539
+ throw new TypeError(`${path} has unknown field ${JSON.stringify(unknown)}`);
1540
+ }
1541
+ if (
1542
+ typeof entry.classification !== 'string' ||
1543
+ !UNRESOLVED_EFFECT_CLASSIFICATIONS.has(
1544
+ entry.classification as PlaybookCaptainUnresolvedEffect['classification'],
1545
+ )
1546
+ ) {
1547
+ throw new TypeError(`${path}.classification is not supported`);
1548
+ }
1549
+ if (
1550
+ typeof entry.baselineHead !== 'string' ||
1551
+ !GIT_OID_PATTERN.test(entry.baselineHead)
1552
+ ) {
1553
+ throw new TypeError(`${path}.baselineHead must be a Git OID`);
1554
+ }
1555
+ if (
1556
+ entry.afterHead !== undefined &&
1557
+ (typeof entry.afterHead !== 'string' ||
1558
+ !GIT_OID_PATTERN.test(entry.afterHead))
1559
+ ) {
1560
+ throw new TypeError(`${path}.afterHead must be a Git OID`);
1561
+ }
1562
+ if (
1563
+ entry.classification !== 'observation-ambiguous' &&
1564
+ entry.classification !== 'incomplete' &&
1565
+ entry.afterHead === undefined
1566
+ ) {
1567
+ throw new TypeError(
1568
+ `${path}.afterHead is required for ${entry.classification}`,
1569
+ );
1570
+ }
1571
+ if (entry.classification === 'one-descendant-commit') {
1572
+ if (
1573
+ typeof entry.commitOid !== 'string' ||
1574
+ !GIT_OID_PATTERN.test(entry.commitOid) ||
1575
+ entry.commitOid !== entry.afterHead
1576
+ ) {
1577
+ throw new TypeError(
1578
+ `${path}.commitOid must equal afterHead for one-descendant-commit`,
1579
+ );
1580
+ }
1581
+ } else if (entry.commitOid !== undefined) {
1582
+ throw new TypeError(
1583
+ `${path}.commitOid is permitted only for one-descendant-commit`,
1584
+ );
1585
+ }
1586
+ }
1587
+ return detached as unknown as readonly PlaybookCaptainUnresolvedEffect[];
1588
+ }
1029
1589
 
1030
1590
  function snapshotRecord(
1031
1591
  value: JsonValue | undefined,
@@ -1287,9 +1847,20 @@ export function assertPlaybookCaptainShellSnapshot(
1287
1847
  ): PlaybookCaptainShellSnapshot {
1288
1848
  const detached = snapshotJsonValue(value, 'Captain shell snapshot');
1289
1849
  const snapshot = snapshotRecord(detached, 'Captain shell snapshot');
1850
+ if (snapshot.schemaVersion !== 4) {
1851
+ if (snapshot.schemaVersion === 1) {
1852
+ throw new TypeError(
1853
+ 'Captain shell snapshot schemaVersion 1 has incompatible player identity; schema 4 is required',
1854
+ );
1855
+ }
1856
+ throw new TypeError(
1857
+ `Captain shell snapshot.schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 4)`,
1858
+ );
1859
+ }
1290
1860
  const mode = snapshot.mode;
1291
1861
  const commonKeys = [
1292
1862
  'schemaVersion',
1863
+ 'effectLedger',
1293
1864
  'captain',
1294
1865
  'playerSessions',
1295
1866
  'issuedSessionIds',
@@ -1307,6 +1878,7 @@ export function assertPlaybookCaptainShellSnapshot(
1307
1878
  [
1308
1879
  ...commonKeys,
1309
1880
  'frames',
1881
+ 'retainedEffectReconciliation',
1310
1882
  'pendingBossQuestions',
1311
1883
  'lastError',
1312
1884
  ],
@@ -1317,12 +1889,6 @@ export function assertPlaybookCaptainShellSnapshot(
1317
1889
  'Captain shell snapshot.mode must be "chat" or "engaged.parked"',
1318
1890
  );
1319
1891
  }
1320
- if (snapshot.schemaVersion !== 3) {
1321
- throw new TypeError(
1322
- `Captain shell snapshot.schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 3)`,
1323
- );
1324
- }
1325
-
1326
1892
  const captain = snapshotRecord(
1327
1893
  snapshot.captain,
1328
1894
  'Captain shell snapshot.captain',
@@ -1340,6 +1906,50 @@ export function assertPlaybookCaptainShellSnapshot(
1340
1906
  captain.runtime,
1341
1907
  INTERNAL_CAPTAIN_ID,
1342
1908
  );
1909
+ const effectLedger = assertPlaybookEffectLedger(snapshot.effectLedger);
1910
+ let retainedEffectReconciliation:
1911
+ | PlaybookCaptainRetainedEffectReconciliation
1912
+ | undefined;
1913
+ if (snapshot.retainedEffectReconciliation !== undefined) {
1914
+ const reconciliation = snapshotRecord(
1915
+ snapshot.retainedEffectReconciliation,
1916
+ 'Captain shell snapshot.retainedEffectReconciliation',
1917
+ );
1918
+ rejectSnapshotKeys(
1919
+ reconciliation,
1920
+ ['sourceGenerationId', 'checkpoint'],
1921
+ 'Captain shell snapshot.retainedEffectReconciliation',
1922
+ );
1923
+ const checkpoint = assertPlaybookEffectLedger(
1924
+ reconciliation.checkpoint,
1925
+ 'Captain shell snapshot retained-effect checkpoint',
1926
+ );
1927
+ if (
1928
+ isDeepStrictEqual(checkpoint, effectLedger) ||
1929
+ !isPlaybookEffectLedgerMonotonicExtension(checkpoint, effectLedger)
1930
+ ) {
1931
+ throw new TypeError(
1932
+ 'Captain shell retained-effect checkpoint must be a strict monotonic prefix of its current mirror',
1933
+ );
1934
+ }
1935
+ retainedEffectReconciliation = {
1936
+ sourceGenerationId: snapshotUuid(
1937
+ reconciliation.sourceGenerationId,
1938
+ 'Captain shell snapshot.retainedEffectReconciliation.sourceGenerationId',
1939
+ ),
1940
+ checkpoint,
1941
+ };
1942
+ }
1943
+ if (
1944
+ !isDeepStrictEqual(
1945
+ captainRuntime.effectLedger,
1946
+ emptyPlaybookEffectLedger(),
1947
+ )
1948
+ ) {
1949
+ throw new TypeError(
1950
+ 'Captain shell snapshot internal Captain runtime effect ledger must be empty',
1951
+ );
1952
+ }
1343
1953
  const captainAgent = snapshotFixedAgent(
1344
1954
  captain.agent,
1345
1955
  'Captain shell snapshot.captain.agent',
@@ -1569,7 +2179,8 @@ export function assertPlaybookCaptainShellSnapshot(
1569
2179
  'Captain shell snapshot.playerSessions',
1570
2180
  );
1571
2181
  const common: PlaybookCaptainShellSnapshotFields = {
1572
- schemaVersion: 3,
2182
+ schemaVersion: 4,
2183
+ effectLedger,
1573
2184
  captain: {
1574
2185
  sessionId: captainSessionId,
1575
2186
  runtime: captainRuntime,
@@ -1718,6 +2329,51 @@ export function assertPlaybookCaptainShellSnapshot(
1718
2329
  const issuedIds = new Set(issued);
1719
2330
  const rootSessionId = normalizedFrames[0]!.sessionId;
1720
2331
  for (const [index, frame] of normalizedFrames.entries()) {
2332
+ const frameLedger = frame.runtime.effectLedger;
2333
+ if (
2334
+ !isDeepStrictEqual(frameLedger, emptyPlaybookEffectLedger()) &&
2335
+ !isDeepStrictEqual(frameLedger, effectLedger)
2336
+ ) {
2337
+ throw new TypeError(
2338
+ `Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} effect ledger is neither empty nor the shell mirror`,
2339
+ );
2340
+ }
2341
+ const frameReconciliation = frame.runtime.retainedEffectReconciliation;
2342
+ if (retainedEffectReconciliation === undefined) {
2343
+ if (frameReconciliation !== undefined) {
2344
+ throw new TypeError(
2345
+ `Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} carries an unmirrored retained-effect fence`,
2346
+ );
2347
+ }
2348
+ } else if (isDeepStrictEqual(frameLedger, effectLedger)) {
2349
+ if (
2350
+ frameReconciliation === undefined ||
2351
+ !isDeepStrictEqual(
2352
+ frameReconciliation.checkpoint,
2353
+ retainedEffectReconciliation.checkpoint,
2354
+ )
2355
+ ) {
2356
+ throw new TypeError(
2357
+ `Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} does not mirror the root retained-effect fence`,
2358
+ );
2359
+ }
2360
+ if (
2361
+ index === 0 &&
2362
+ frameReconciliation.sourceSessionId !==
2363
+ retainedEffectReconciliation.sourceGenerationId
2364
+ ) {
2365
+ throw new TypeError(
2366
+ 'Captain shell snapshot retained-effect root source identity differs from its generation',
2367
+ );
2368
+ }
2369
+ } else if (
2370
+ frameReconciliation !== undefined ||
2371
+ frame.runtime.retainedEffectSourceSessionId !== undefined
2372
+ ) {
2373
+ throw new TypeError(
2374
+ `Captain shell snapshot empty-ledger frame ${JSON.stringify(frame.playbookId)} carries retained-effect adoption state`,
2375
+ );
2376
+ }
1721
2377
  if (activePlaybooks.has(frame.playbookId)) {
1722
2378
  throw new TypeError(
1723
2379
  'Captain shell snapshot engagement path must not contain a playbook cycle',
@@ -1815,14 +2471,20 @@ export function assertPlaybookCaptainShellSnapshot(
1815
2471
  'Captain shell snapshot leaf runtime must be parked without a dangling suspended child call',
1816
2472
  );
1817
2473
  }
1818
- if (
1819
- !isDeepStrictEqual(
1820
- snapshot.pendingBossQuestions ?? [],
1821
- leafRuntime.pendingBossQuestions,
1822
- )
1823
- ) {
2474
+ if (retainedEffectReconciliation === undefined) {
2475
+ if (
2476
+ !isDeepStrictEqual(
2477
+ snapshot.pendingBossQuestions ?? [],
2478
+ leafRuntime.pendingBossQuestions,
2479
+ )
2480
+ ) {
2481
+ throw new TypeError(
2482
+ 'Captain shell snapshot pending Boss questions must equal the leaf runtime projection',
2483
+ );
2484
+ }
2485
+ } else if (snapshot.pendingBossQuestions !== undefined) {
1824
2486
  throw new TypeError(
1825
- 'Captain shell snapshot pending Boss questions must equal the leaf runtime projection',
2487
+ 'Captain shell snapshot must withhold pending Boss questions behind retained-effect reconciliation',
1826
2488
  );
1827
2489
  }
1828
2490
  return snapshotJsonValue(
@@ -1830,6 +2492,9 @@ export function assertPlaybookCaptainShellSnapshot(
1830
2492
  ...common,
1831
2493
  mode,
1832
2494
  frames: normalizedFrames,
2495
+ ...(retainedEffectReconciliation === undefined
2496
+ ? {}
2497
+ : { retainedEffectReconciliation }),
1833
2498
  ...(snapshot.pendingBossQuestions === undefined
1834
2499
  ? {}
1835
2500
  : { pendingBossQuestions: snapshot.pendingBossQuestions }),
@@ -1846,6 +2511,10 @@ interface BuiltRegistry {
1846
2511
  byCommand: Map<string, PlaybookCaptainRegistryEntry>;
1847
2512
  byId: Map<string, PlaybookCaptainRegistryEntry>;
1848
2513
  enablementById: Map<string, Enablement>;
2514
+ hostCapabilitiesById: ReadonlyMap<
2515
+ string,
2516
+ PlaybookHostConstructionCapabilities
2517
+ >;
1849
2518
  captainAgent: SessionAgent;
1850
2519
  playerAgents: Map<string, SessionAgent>;
1851
2520
  }
@@ -1950,17 +2619,38 @@ function promptIdentity(binding: EffectivePlayerBinding): string {
1950
2619
  : binding.agent.adapter;
1951
2620
  }
1952
2621
 
2622
+ function rejectConfiguredHostCapabilities(value: JsonValue | undefined, path: string): void {
2623
+ if (
2624
+ value !== null &&
2625
+ typeof value === 'object' &&
2626
+ !Array.isArray(value) &&
2627
+ Object.prototype.hasOwnProperty.call(value, HOST_CAPABILITIES_OPTION_KEY)
2628
+ ) {
2629
+ throw new Error(
2630
+ `${path}.${HOST_CAPABILITIES_OPTION_KEY} is host-owned and cannot be configured`,
2631
+ );
2632
+ }
2633
+ }
2634
+
1953
2635
  // Resolve the active registry at init from exact normalized role and session
1954
2636
  // agent projections (CAPTAIN-16). No role, ancestor, or generated-name fallback
1955
2637
  // exists at this boundary.
1956
2638
  async function buildEnablements(
1957
2639
  options: unknown,
1958
2640
  loadModule: (specifier: string) => Promise<unknown>,
2641
+ hostCapabilities: PlaybookCaptainDeps['hostCapabilities'],
1959
2642
  ): Promise<BuiltRegistry> {
1960
2643
  const entries: PlaybookCaptainRegistryEntry[] = [];
1961
2644
  const byCommand = new Map<string, PlaybookCaptainRegistryEntry>();
1962
2645
  const byId = new Map<string, PlaybookCaptainRegistryEntry>();
1963
2646
  const enablementById = new Map<string, Enablement>();
2647
+ const hostCapabilitiesById = new Map<
2648
+ string,
2649
+ PlaybookHostConstructionCapabilities
2650
+ >();
2651
+ const suppliedHostCapabilities =
2652
+ captureHostCapabilityRecord(hostCapabilities);
2653
+ const expectedHostCapabilityIds: string[] = [];
1964
2654
 
1965
2655
  const detached = snapshotJsonValue(options, 'captain.options');
1966
2656
  const top = snapshotRecord(detached, 'captain.options');
@@ -2042,6 +2732,10 @@ async function buildEnablements(
2042
2732
  ['from', 'command', 'roles', 'options'],
2043
2733
  `captain.options.playbooks.${id}`,
2044
2734
  );
2735
+ rejectConfiguredHostCapabilities(
2736
+ record.options,
2737
+ `captain.options.playbooks.${id}.options`,
2738
+ );
2045
2739
  const from = record.from;
2046
2740
  if (typeof from !== 'string' || from.length === 0) {
2047
2741
  throw new Error(
@@ -2058,12 +2752,44 @@ async function buildEnablements(
2058
2752
  )}`,
2059
2753
  );
2060
2754
  }
2061
- const entry = (mod as { default?: unknown })?.default;
2062
- if (!isValidRegistryEntry(entry)) {
2755
+ const capturedEntry = captureRegistryEntry(
2756
+ (mod as { default?: unknown })?.default,
2757
+ );
2758
+ const artifactSchema = (
2759
+ capturedEntry as { artifactSchema?: unknown } | null
2760
+ )
2761
+ ?.artifactSchema;
2762
+ const runtimeProfile = validateRuntimeProfile(
2763
+ (capturedEntry as { runtimeProfile?: unknown } | null)?.runtimeProfile,
2764
+ );
2765
+ if (
2766
+ artifactSchema !== 3 ||
2767
+ runtimeProfile === undefined ||
2768
+ !isValidRegistryEntry(capturedEntry, artifactSchema)
2769
+ ) {
2063
2770
  throw new Error(
2064
2771
  `captain.options.playbooks.${id}.from "${from}" exposes no valid registry entry`,
2065
2772
  );
2066
2773
  }
2774
+ const entry = Object.freeze({
2775
+ ...capturedEntry,
2776
+ requiredRoleIds: Object.freeze([...capturedEntry.requiredRoleIds]),
2777
+ concurrentRoleSets: Object.freeze(
2778
+ capturedEntry.concurrentRoleSets.map((roles) =>
2779
+ Object.freeze([...roles]),
2780
+ ),
2781
+ ),
2782
+ }) as PlaybookCaptainRegistryEntry;
2783
+ if (runtimeProfile.artifactSchema !== artifactSchema) {
2784
+ const implementation =
2785
+ runtimeProfile.kind === 'shared-factory'
2786
+ ? 'shared factory'
2787
+ : 'bespoke runtime';
2788
+ throw new Error(
2789
+ `captain.options.playbooks.${id}.from "${from}" advertises artifact schema ${artifactSchema} ` +
2790
+ `but its ${implementation} implements schema ${runtimeProfile.artifactSchema}`,
2791
+ );
2792
+ }
2067
2793
  if (entry.id !== id) {
2068
2794
  throw new Error(
2069
2795
  `captain.options.playbooks.${id} key must equal the module manifest id "${entry.id}"`,
@@ -2139,16 +2865,42 @@ async function buildEnablements(
2139
2865
  entry.validateOptions(record.options),
2140
2866
  `captain.options.playbooks.${id}.options`,
2141
2867
  );
2868
+ rejectConfiguredHostCapabilities(
2869
+ validatedOptions,
2870
+ `captain.options.playbooks.${id}.options`,
2871
+ );
2142
2872
  entries.push(entry);
2143
2873
  byId.set(entry.id, entry);
2144
2874
  byCommand.set(command, entry);
2145
- enablementById.set(entry.id, {
2875
+ const hostCapability = validateHostCapabilities(
2876
+ suppliedHostCapabilities[entry.id],
2146
2877
  entry,
2147
2878
  command,
2879
+ );
2880
+ expectedHostCapabilityIds.push(entry.id);
2881
+ hostCapabilitiesById.set(entry.id, hostCapability);
2882
+ enablementById.set(entry.id, {
2883
+ entry,
2884
+ artifactSchema,
2885
+ command,
2148
2886
  options: validatedOptions,
2149
2887
  roleBindings,
2150
2888
  });
2151
2889
  }
2890
+ const suppliedHostCapabilityIds = Object.keys(
2891
+ suppliedHostCapabilities,
2892
+ ).sort();
2893
+ expectedHostCapabilityIds.sort();
2894
+ if (
2895
+ !isDeepStrictEqual(
2896
+ suppliedHostCapabilityIds,
2897
+ expectedHostCapabilityIds,
2898
+ )
2899
+ ) {
2900
+ throw new Error(
2901
+ 'current-host construction capabilities must exactly cover schema-3 playbooks',
2902
+ );
2903
+ }
2152
2904
  const referenced = new Set(
2153
2905
  [...enablementById.values()].flatMap((enablement) =>
2154
2906
  [...enablement.roleBindings.values()].map((binding) => binding.playerId),
@@ -2165,6 +2917,7 @@ async function buildEnablements(
2165
2917
  byCommand,
2166
2918
  byId,
2167
2919
  enablementById,
2920
+ hostCapabilitiesById,
2168
2921
  captainAgent,
2169
2922
  playerAgents,
2170
2923
  };
@@ -2180,6 +2933,17 @@ export function createPlaybookCaptainShell(
2180
2933
  const createCaptainRuntime: NonNullable<
2181
2934
  PlaybookCaptainDeps['createCaptainRuntime']
2182
2935
  > = deps.createCaptainRuntime ?? createDefaultCaptainRuntime;
2936
+ const unresolvedEffectSettlement = deps.unresolvedEffectSettlement;
2937
+ let pendingHostCapabilities = deps.hostCapabilities;
2938
+ let currentEffectLedger = () => emptyPlaybookEffectLedger();
2939
+ // The returned shell must not retain the caller's aggregate dependency
2940
+ // object after its one live capability input has moved to a clearable slot.
2941
+ deps = {};
2942
+ const buildCurrentEnablements = async (): Promise<BuiltRegistry> => {
2943
+ const hostCapabilities = pendingHostCapabilities;
2944
+ pendingHostCapabilities = undefined;
2945
+ return buildEnablements(options, loadModule, hostCapabilities);
2946
+ };
2183
2947
  let captainAgent: SessionAgent | undefined;
2184
2948
  let captainAdapter: string | undefined;
2185
2949
  let playerAgents = new Map<string, SessionAgent>();
@@ -2189,6 +2953,10 @@ export function createPlaybookCaptainShell(
2189
2953
  let byCommand = new Map<string, PlaybookCaptainRegistryEntry>();
2190
2954
  let byId = new Map<string, PlaybookCaptainRegistryEntry>();
2191
2955
  let enablementById = new Map<string, Enablement>();
2956
+ let hostCapabilitiesById = new Map<
2957
+ string,
2958
+ PlaybookHostConstructionCapabilities
2959
+ >();
2192
2960
  let session: CaptainSession | undefined;
2193
2961
  let sessionEmissionsOpen = false;
2194
2962
  let closedGateAttempted = false;
@@ -2203,6 +2971,9 @@ export function createPlaybookCaptainShell(
2203
2971
  let activeContext: CaptainContext | undefined;
2204
2972
  const frames: EngagementFrame[] = [];
2205
2973
  let mode: ShellMode = 'chat';
2974
+ let retainedEffectReconciliation:
2975
+ | PlaybookCaptainRetainedEffectReconciliation
2976
+ | undefined;
2206
2977
  let pendingBossQuestions: unknown;
2207
2978
  let lastError: { name: string; message: string } | undefined;
2208
2979
  let activeTurnSummary: ActiveTurnSummary | undefined;
@@ -2283,6 +3054,50 @@ export function createPlaybookCaptainShell(
2283
3054
  | undefined;
2284
3055
  let lastAction: ControllerAction | undefined;
2285
3056
  let lastSettlementStatus: SettlementEvidence['status'] | undefined;
3057
+ // DR-038 §2: a turn can dispose its root before the durable caller asks
3058
+ // for settlement. Keep only that turn's latest safe pre-terminal generation
3059
+ // and its per-root retain/clear decisions until `exportSettlement()` pairs
3060
+ // them with the complete shell snapshot.
3061
+ type RetainedGenerationCandidate =
3062
+ | {
3063
+ readonly status: 'captured';
3064
+ readonly generation: PlaybookCaptainRetainedGeneration;
3065
+ }
3066
+ | { readonly status: 'incapable' }
3067
+ | { readonly status: 'unsafe' };
3068
+ const retainedGenerationCandidates = new Map<
3069
+ string,
3070
+ RetainedGenerationCandidate
3071
+ >();
3072
+ const pendingRetentionUpdates = new Map<
3073
+ string,
3074
+ PlaybookCaptainRetentionUpdate
3075
+ >();
3076
+ interface RetainedGenerationOffer {
3077
+ readonly generation: PlaybookCaptainRetainedGeneration;
3078
+ readonly requiresEffectReconciliation: boolean;
3079
+ /** Fresh, uninitialized runtimes reserved for one adoption attempt. */
3080
+ readonly runtimes: readonly PlaybookRuntime[];
3081
+ }
3082
+ const retainedGenerations = new Map<
3083
+ string,
3084
+ PlaybookCaptainRetainedGeneration
3085
+ >();
3086
+ const retainedGenerationOffers = new Map<
3087
+ string,
3088
+ RetainedGenerationOffer
3089
+ >();
3090
+ const ineligibleRetainedGenerations = new Set<string>();
3091
+ const retainedGenerationRootClears = new Set<string>();
3092
+ const retiredRetainedRuntimes: PlaybookRuntime[] = [];
3093
+ let retainedGenerationsInstalled = false;
3094
+ let retainedGenerationInstallationInProgress = false;
3095
+ let retainedGenerationInstallationClosed = false;
3096
+ let retentionSettlementReady = false;
3097
+ let abandonmentSettlementUnsafe = false;
3098
+ let settledTurnUnresolvedEffects:
3099
+ | readonly PlaybookCaptainUnresolvedEffect[]
3100
+ | undefined;
2286
3101
  // DR-029: a run that lands in the runtime's own failure state
2287
3102
  // is an outcome the report must name. `processFrameResult` records it here
2288
3103
  // and the settling selection folds it into its facts, so the grounding the
@@ -2294,6 +3109,826 @@ export function createPlaybookCaptainShell(
2294
3109
  const frameLabel = (frame: EngagementFrame): string =>
2295
3110
  `/${frame.enablement.command}`;
2296
3111
 
3112
+ type UnresolvedEnvelopeReference =
3113
+ | { readonly kind: 'boundary'; readonly boundaryId: string }
3114
+ | { readonly kind: 'logical-operation'; readonly operationId: string };
3115
+
3116
+ const capturedUnresolvedEnvelopeReferences = (
3117
+ frame: EngagementFrame,
3118
+ ): readonly UnresolvedEnvelopeReference[] => {
3119
+ let advertisesUnresolved = false;
3120
+ try {
3121
+ advertisesUnresolved =
3122
+ frame.runtime
3123
+ .describe?.()
3124
+ .actions.some(
3125
+ ({ id }) =>
3126
+ id === UNRESOLVED_EFFECT_RECONCILIATION_ACTION_ID ||
3127
+ id === UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID,
3128
+ ) === true;
3129
+ } catch {
3130
+ advertisesUnresolved = true;
3131
+ }
3132
+ if (typeof frame.runtime.unresolvedEffectEnvelopes !== 'function') {
3133
+ if (advertisesUnresolved) {
3134
+ throw new Error(
3135
+ `${frameLabel(frame)} unresolved-effect runtime exposes no envelope identities`,
3136
+ );
3137
+ }
3138
+ return [];
3139
+ }
3140
+ const detached = snapshotJsonValue(
3141
+ frame.runtime.unresolvedEffectEnvelopes(),
3142
+ `${frameLabel(frame)} unresolved effect envelope identities`,
3143
+ );
3144
+ if (!Array.isArray(detached)) {
3145
+ throw new TypeError(
3146
+ `${frameLabel(frame)} unresolved effect envelope identities must be an array`,
3147
+ );
3148
+ }
3149
+ return detached.map((raw, index) => {
3150
+ const path = `${frameLabel(frame)} unresolved effect envelope identities[${index}]`;
3151
+ const record = snapshotRecord(raw, path);
3152
+ if (record.kind === 'boundary') {
3153
+ rejectSnapshotKeys(record, ['kind', 'boundaryId'], path);
3154
+ return {
3155
+ kind: 'boundary' as const,
3156
+ boundaryId: snapshotString(record.boundaryId, `${path}.boundaryId`),
3157
+ };
3158
+ }
3159
+ if (record.kind === 'logical-operation') {
3160
+ rejectSnapshotKeys(record, ['kind', 'operationId'], path);
3161
+ return {
3162
+ kind: 'logical-operation' as const,
3163
+ operationId: snapshotString(record.operationId, `${path}.operationId`),
3164
+ };
3165
+ }
3166
+ throw new TypeError(`${path}.kind is not supported`);
3167
+ });
3168
+ };
3169
+
3170
+ const unresolvedEffectFromReceipt = (
3171
+ receipt: NonNullable<
3172
+ PlaybookEffectLedger['boundaries'][number]['physicalReceipt']
3173
+ >,
3174
+ baselineHead = receipt.baseline.head,
3175
+ ): PlaybookCaptainUnresolvedEffect | undefined => {
3176
+ if (receipt.classification === 'unchanged') return undefined;
3177
+ return {
3178
+ classification: receipt.classification,
3179
+ baselineHead,
3180
+ ...(receipt.after === undefined ? {} : { afterHead: receipt.after.head }),
3181
+ ...(receipt.commitOid === undefined ? {} : { commitOid: receipt.commitOid }),
3182
+ };
3183
+ };
3184
+
3185
+ const cumulativeOpenLogicalEffect = (
3186
+ operation: PlaybookEffectLedger['logicalOperations'][number],
3187
+ boundaries: readonly PlaybookEffectLedger['boundaries'][number][],
3188
+ ): PlaybookCaptainUnresolvedEffect | undefined => {
3189
+ const original = operation.originalBaseline;
3190
+ const latest = boundaries.at(-1)!;
3191
+ const after = latest.after ?? operation.checkpoint;
3192
+ const receipt = latest.physicalReceipt;
3193
+ if (receipt === undefined) {
3194
+ return {
3195
+ classification: 'incomplete',
3196
+ baselineHead: original.head,
3197
+ ...(after === undefined ? {} : { afterHead: after.head }),
3198
+ };
3199
+ }
3200
+ if (after === undefined) {
3201
+ return unresolvedEffectFromReceipt(receipt, original.head);
3202
+ }
3203
+
3204
+ // An open deferred chain has no authoritative cumulative receipt. Reuse
3205
+ // physical ancestry only while every preceding checkpoint is one of the
3206
+ // same-HEAD dispositions that can lawfully keep a deferred operation
3207
+ // open. Any other history is bounded but not cumulatively attributable.
3208
+ const checkpointChainIsSafe = boundaries
3209
+ .slice(0, -1)
3210
+ .every(
3211
+ (boundary) =>
3212
+ boundary.after?.head === original.head &&
3213
+ (boundary.physicalReceipt?.classification === 'unchanged' ||
3214
+ boundary.physicalReceipt?.classification ===
3215
+ 'worktree-only-change'),
3216
+ );
3217
+ if (!checkpointChainIsSafe || latest.baseline.head !== original.head) {
3218
+ return {
3219
+ classification: 'observation-ambiguous',
3220
+ baselineHead: original.head,
3221
+ afterHead: after.head,
3222
+ };
3223
+ }
3224
+
3225
+ if (
3226
+ receipt.classification !== 'unchanged' &&
3227
+ receipt.classification !== 'worktree-only-change' &&
3228
+ receipt.classification !== 'one-descendant-commit'
3229
+ ) {
3230
+ return unresolvedEffectFromReceipt(receipt, original.head);
3231
+ }
3232
+
3233
+ const sameProjection = isDeepStrictEqual(
3234
+ original.projection,
3235
+ after.projection,
3236
+ );
3237
+ if (after.head === original.head) {
3238
+ if (
3239
+ receipt.classification !== 'unchanged' &&
3240
+ receipt.classification !== 'worktree-only-change'
3241
+ ) {
3242
+ return {
3243
+ classification: 'observation-ambiguous',
3244
+ baselineHead: original.head,
3245
+ afterHead: after.head,
3246
+ };
3247
+ }
3248
+ if (sameProjection) return undefined;
3249
+ const preservesOriginal = Object.entries(original.projection).every(
3250
+ ([path, entry]) =>
3251
+ Object.hasOwn(after.projection, path) &&
3252
+ isDeepStrictEqual(entry, after.projection[path]),
3253
+ );
3254
+ return {
3255
+ classification: preservesOriginal
3256
+ ? 'worktree-only-change'
3257
+ : 'observation-ambiguous',
3258
+ baselineHead: original.head,
3259
+ afterHead: after.head,
3260
+ };
3261
+ }
3262
+
3263
+ if (
3264
+ receipt.classification === 'one-descendant-commit' &&
3265
+ sameProjection
3266
+ ) {
3267
+ return {
3268
+ classification: 'one-descendant-commit',
3269
+ baselineHead: original.head,
3270
+ afterHead: after.head,
3271
+ commitOid: after.head,
3272
+ };
3273
+ }
3274
+ return {
3275
+ classification: 'observation-ambiguous',
3276
+ baselineHead: original.head,
3277
+ afterHead: after.head,
3278
+ };
3279
+ };
3280
+
3281
+ const projectUnresolvedEffects = (
3282
+ ledger: PlaybookEffectLedger,
3283
+ references: readonly UnresolvedEnvelopeReference[],
3284
+ ): readonly PlaybookCaptainUnresolvedEffect[] => {
3285
+ const pendingReferences = [...references];
3286
+ const projected: Array<{
3287
+ readonly order: number;
3288
+ readonly effect: PlaybookCaptainUnresolvedEffect;
3289
+ }> = [];
3290
+ const seenBoundaries = new Set<string>();
3291
+ const seenOperations = new Set<string>();
3292
+ for (let index = 0; index < pendingReferences.length; index += 1) {
3293
+ const reference = pendingReferences[index]!;
3294
+ if (reference.kind === 'boundary') {
3295
+ if (seenBoundaries.has(reference.boundaryId)) continue;
3296
+ seenBoundaries.add(reference.boundaryId);
3297
+ const boundary = ledger.boundaries.find(
3298
+ ({ boundaryId }) => boundaryId === reference.boundaryId,
3299
+ );
3300
+ if (boundary === undefined) {
3301
+ throw new Error(
3302
+ `unresolved effect boundary ${JSON.stringify(reference.boundaryId)} is absent from the authoritative ledger`,
3303
+ );
3304
+ }
3305
+ if (boundary.logicalOperationId !== undefined) {
3306
+ if (!seenOperations.has(boundary.logicalOperationId)) {
3307
+ pendingReferences.push({
3308
+ kind: 'logical-operation',
3309
+ operationId: boundary.logicalOperationId,
3310
+ });
3311
+ }
3312
+ continue;
3313
+ }
3314
+ const effect =
3315
+ boundary.physicalReceipt === undefined
3316
+ ? {
3317
+ classification: 'incomplete' as const,
3318
+ baselineHead: boundary.baseline.head,
3319
+ ...(boundary.after === undefined
3320
+ ? {}
3321
+ : { afterHead: boundary.after.head }),
3322
+ }
3323
+ : unresolvedEffectFromReceipt(boundary.physicalReceipt);
3324
+ if (effect !== undefined) {
3325
+ projected.push({ order: boundary.sequence, effect });
3326
+ }
3327
+ continue;
3328
+ }
3329
+
3330
+ if (seenOperations.has(reference.operationId)) continue;
3331
+ seenOperations.add(reference.operationId);
3332
+ const operation = ledger.logicalOperations.find(
3333
+ ({ operationId }) => operationId === reference.operationId,
3334
+ );
3335
+ if (operation === undefined) {
3336
+ throw new Error(
3337
+ `unresolved logical operation ${JSON.stringify(reference.operationId)} is absent from the authoritative ledger`,
3338
+ );
3339
+ }
3340
+ const boundaries = operation.boundaryIds.map((boundaryId) => {
3341
+ const boundary = ledger.boundaries.find(
3342
+ (candidate) => candidate.boundaryId === boundaryId,
3343
+ );
3344
+ if (boundary === undefined) {
3345
+ throw new Error(
3346
+ `unresolved logical operation ${JSON.stringify(reference.operationId)} names an absent boundary`,
3347
+ );
3348
+ }
3349
+ seenBoundaries.add(boundaryId);
3350
+ return boundary;
3351
+ });
3352
+ let effect: PlaybookCaptainUnresolvedEffect | undefined;
3353
+ if (operation.logicalReceipt !== undefined) {
3354
+ effect = unresolvedEffectFromReceipt(
3355
+ operation.logicalReceipt,
3356
+ operation.originalBaseline.head,
3357
+ );
3358
+ } else {
3359
+ effect = cumulativeOpenLogicalEffect(operation, boundaries);
3360
+ }
3361
+ if (effect !== undefined) {
3362
+ projected.push({ order: boundaries[0]!.sequence, effect });
3363
+ }
3364
+ }
3365
+ return assertPlaybookCaptainUnresolvedEffects(
3366
+ projected
3367
+ .sort((left, right) => left.order - right.order)
3368
+ .map(({ effect }) => effect),
3369
+ );
3370
+ };
3371
+
3372
+ const currentUnresolvedEffects =
3373
+ (): readonly PlaybookCaptainUnresolvedEffect[] => {
3374
+ const ledger = assertPlaybookEffectLedger(currentEffectLedger());
3375
+ const references: UnresolvedEnvelopeReference[] = [];
3376
+ for (const frame of frames) {
3377
+ references.push(...capturedUnresolvedEnvelopeReferences(frame));
3378
+ }
3379
+ if (retainedEffectReconciliation !== undefined) {
3380
+ for (const boundary of ledger.boundaries.slice(
3381
+ retainedEffectReconciliation.checkpoint.boundaries.length,
3382
+ )) {
3383
+ if (boundary.physicalReceipt?.classification === 'unchanged') {
3384
+ continue;
3385
+ }
3386
+ references.push(
3387
+ boundary.logicalOperationId === undefined
3388
+ ? { kind: 'boundary', boundaryId: boundary.boundaryId }
3389
+ : {
3390
+ kind: 'logical-operation',
3391
+ operationId: boundary.logicalOperationId,
3392
+ },
3393
+ );
3394
+ }
3395
+ }
3396
+ return projectUnresolvedEffects(ledger, references);
3397
+ };
3398
+
3399
+ const freezeTurnUnresolvedEffects =
3400
+ (): readonly PlaybookCaptainUnresolvedEffect[] => {
3401
+ const turn = activeTurn;
3402
+ if (turn?.unresolvedEffects !== undefined) return turn.unresolvedEffects;
3403
+ const frozen = currentUnresolvedEffects();
3404
+ if (turn !== undefined) turn.unresolvedEffects = frozen;
3405
+ settledTurnUnresolvedEffects = frozen;
3406
+ return frozen;
3407
+ };
3408
+
3409
+ const normalizeInstalledRetainedGenerations = (
3410
+ value: Readonly<Record<string, PlaybookCaptainRetainedGeneration>>,
3411
+ ): Map<string, PlaybookCaptainRetainedGeneration> => {
3412
+ const path = 'Captain retained generations';
3413
+ const detached = snapshotJsonValue(value, path);
3414
+ const record = snapshotRecord(detached, path);
3415
+ const authoritativeEffectLedger = assertPlaybookEffectLedger(
3416
+ currentEffectLedger(),
3417
+ `${path} current host effect ledger`,
3418
+ );
3419
+ const sourceSessionIds = new Set<string>();
3420
+ const normalized = new Map<string, PlaybookCaptainRetainedGeneration>();
3421
+ for (const [rootPlaybookId, rawGeneration] of Object.entries(record)) {
3422
+ const generationPath = `${path}[${JSON.stringify(rootPlaybookId)}]`;
3423
+ const enablement = enablementById.get(rootPlaybookId);
3424
+ if (enablement === undefined) {
3425
+ throw new TypeError(
3426
+ `${generationPath} names a disabled root playbook`,
3427
+ );
3428
+ }
3429
+ const generation = snapshotRecord(rawGeneration, generationPath);
3430
+ rejectSnapshotKeys(
3431
+ generation,
3432
+ [
3433
+ 'effectLedger',
3434
+ 'frames',
3435
+ 'retainedEffectReconciliation',
3436
+ 'rootStateDescription',
3437
+ ],
3438
+ generationPath,
3439
+ );
3440
+ const generationEffectLedger = assertPlaybookEffectLedger(
3441
+ generation.effectLedger,
3442
+ `${generationPath}.effectLedger`,
3443
+ );
3444
+ if (
3445
+ generationEffectLedger.boundaries.some(
3446
+ ({ physicalReceipt }) => physicalReceipt === undefined,
3447
+ )
3448
+ ) {
3449
+ throw new TypeError(
3450
+ `${generationPath}.effectLedger contains an incomplete physical boundary`,
3451
+ );
3452
+ }
3453
+ if (
3454
+ !isPlaybookEffectLedgerMonotonicExtension(
3455
+ generationEffectLedger,
3456
+ authoritativeEffectLedger,
3457
+ )
3458
+ ) {
3459
+ throw new TypeError(
3460
+ `${generationPath}.effectLedger is not a monotonic prefix of the current host mirror`,
3461
+ );
3462
+ }
3463
+ const generationReconciliation =
3464
+ generation.retainedEffectReconciliation === undefined
3465
+ ? undefined
3466
+ : snapshotRecord(
3467
+ generation.retainedEffectReconciliation,
3468
+ `${generationPath}.retainedEffectReconciliation`,
3469
+ );
3470
+ if (generationReconciliation !== undefined) {
3471
+ rejectSnapshotKeys(
3472
+ generationReconciliation,
3473
+ ['sourceGenerationId'],
3474
+ `${generationPath}.retainedEffectReconciliation`,
3475
+ );
3476
+ }
3477
+ const sourceGenerationId =
3478
+ generationReconciliation === undefined
3479
+ ? undefined
3480
+ : snapshotUuid(
3481
+ generationReconciliation.sourceGenerationId,
3482
+ `${generationPath}.retainedEffectReconciliation.sourceGenerationId`,
3483
+ );
3484
+ if (!Array.isArray(generation.frames) || generation.frames.length === 0) {
3485
+ throw new TypeError(`${generationPath}.frames must be non-empty`);
3486
+ }
3487
+ const rootStateDescription =
3488
+ generation.rootStateDescription === undefined
3489
+ ? undefined
3490
+ : snapshotString(
3491
+ generation.rootStateDescription,
3492
+ `${generationPath}.rootStateDescription`,
3493
+ );
3494
+ const normalizedFrames: PlaybookCaptainFrameSnapshot[] = [];
3495
+ const playbookIds = new Set<string>();
3496
+ let markedCaptureEffectLedger:
3497
+ | DeepReadonly<PlaybookEffectLedger>
3498
+ | undefined;
3499
+ for (const [index, rawFrame] of generation.frames.entries()) {
3500
+ const framePath = `${generationPath}.frames[${index}]`;
3501
+ const frame = snapshotRecord(rawFrame, framePath);
3502
+ rejectSnapshotKeys(
3503
+ frame,
3504
+ [
3505
+ 'playbookId',
3506
+ 'sessionId',
3507
+ 'rootSessionId',
3508
+ 'depth',
3509
+ 'parentSessionId',
3510
+ 'parentCallId',
3511
+ 'options',
3512
+ 'roleBindings',
3513
+ 'runtime',
3514
+ ],
3515
+ framePath,
3516
+ );
3517
+ const playbookId = snapshotString(
3518
+ frame.playbookId,
3519
+ `${framePath}.playbookId`,
3520
+ );
3521
+ const frameEnablement = enablementById.get(playbookId);
3522
+ if (frameEnablement === undefined) {
3523
+ throw new TypeError(`${framePath} names a disabled playbook`);
3524
+ }
3525
+ if (playbookIds.has(playbookId)) {
3526
+ throw new TypeError(
3527
+ `${generationPath}.frames must not contain a playbook cycle`,
3528
+ );
3529
+ }
3530
+ playbookIds.add(playbookId);
3531
+ const sessionId = snapshotUuid(
3532
+ frame.sessionId,
3533
+ `${framePath}.sessionId`,
3534
+ );
3535
+ if (sourceSessionIds.has(sessionId)) {
3536
+ throw new TypeError(
3537
+ `${path} frame session ids must be unique across generations`,
3538
+ );
3539
+ }
3540
+ sourceSessionIds.add(sessionId);
3541
+ const rootSessionId = snapshotUuid(
3542
+ frame.rootSessionId,
3543
+ `${framePath}.rootSessionId`,
3544
+ );
3545
+ const depth = snapshotInteger(frame.depth, `${framePath}.depth`);
3546
+ const parentSessionId =
3547
+ frame.parentSessionId === undefined
3548
+ ? undefined
3549
+ : snapshotUuid(
3550
+ frame.parentSessionId,
3551
+ `${framePath}.parentSessionId`,
3552
+ );
3553
+ const parentCallId =
3554
+ frame.parentCallId === undefined
3555
+ ? undefined
3556
+ : snapshotString(
3557
+ frame.parentCallId,
3558
+ `${framePath}.parentCallId`,
3559
+ );
3560
+ const options = frame.options as JsonValue;
3561
+ if (
3562
+ index === 0 &&
3563
+ !isDeepStrictEqual(options, frameEnablement.options)
3564
+ ) {
3565
+ throw new TypeError(`${framePath}.options changed`);
3566
+ }
3567
+ const roleBindings = snapshotFrameRoleBindings(
3568
+ frame.roleBindings,
3569
+ `${framePath}.roleBindings`,
3570
+ );
3571
+ if (
3572
+ !isDeepStrictEqual(
3573
+ Object.keys(roleBindings).sort(),
3574
+ [...frameEnablement.entry.requiredRoleIds].sort(),
3575
+ )
3576
+ ) {
3577
+ throw new TypeError(
3578
+ `${framePath}.roleBindings do not cover the current role set`,
3579
+ );
3580
+ }
3581
+ const runtime = assertPlaybookRuntimeSnapshot(
3582
+ frame.runtime,
3583
+ playbookId,
3584
+ { allowSuspendedCall: true },
3585
+ );
3586
+ const retainedReconciliation = runtime.retainedEffectReconciliation;
3587
+ if (retainedReconciliation === undefined) {
3588
+ if (!isDeepStrictEqual(runtime.effectLedger, generationEffectLedger)) {
3589
+ throw new TypeError(
3590
+ `${framePath}.runtime effect ledger differs from the retained checkpoint`,
3591
+ );
3592
+ }
3593
+ } else {
3594
+ if (
3595
+ !isDeepStrictEqual(
3596
+ retainedReconciliation.checkpoint,
3597
+ generationEffectLedger,
3598
+ ) ||
3599
+ isDeepStrictEqual(runtime.effectLedger, generationEffectLedger) ||
3600
+ !isPlaybookEffectLedgerMonotonicExtension(
3601
+ runtime.effectLedger,
3602
+ authoritativeEffectLedger,
3603
+ )
3604
+ ) {
3605
+ throw new TypeError(
3606
+ `${framePath}.runtime retained-effect evidence is inconsistent`,
3607
+ );
3608
+ }
3609
+ if (markedCaptureEffectLedger === undefined) {
3610
+ markedCaptureEffectLedger = runtime.effectLedger;
3611
+ } else if (
3612
+ !isDeepStrictEqual(
3613
+ runtime.effectLedger,
3614
+ markedCaptureEffectLedger,
3615
+ )
3616
+ ) {
3617
+ throw new TypeError(
3618
+ `${framePath}.runtime effect ledger differs from the marked generation capture mirror`,
3619
+ );
3620
+ }
3621
+ }
3622
+ if (
3623
+ runtime.state.status !== 'active' ||
3624
+ !runtime.state.quiescent ||
3625
+ typeof runtime.state.stateId !== 'string' ||
3626
+ runtime.state.stateId.trim().length === 0
3627
+ ) {
3628
+ throw new TypeError(
3629
+ `${framePath}.runtime must be active, quiescent, and state-identified`,
3630
+ );
3631
+ }
3632
+ for (const question of runtime.pendingBossQuestions) {
3633
+ if (
3634
+ question.asker.kind === 'role' &&
3635
+ roleBindings[question.asker.roleId] === undefined
3636
+ ) {
3637
+ throw new TypeError(
3638
+ `${framePath}.runtime pending question names an unbound role`,
3639
+ );
3640
+ }
3641
+ }
3642
+ for (const roleId of Object.keys(runtime.roleResumeTokens)) {
3643
+ if (roleBindings[roleId] === undefined) {
3644
+ throw new TypeError(
3645
+ `${framePath}.runtime role-resume token names an unbound role`,
3646
+ );
3647
+ }
3648
+ }
3649
+ normalizedFrames.push({
3650
+ playbookId,
3651
+ sessionId,
3652
+ rootSessionId,
3653
+ depth,
3654
+ ...(parentSessionId === undefined ? {} : { parentSessionId }),
3655
+ ...(parentCallId === undefined ? {} : { parentCallId }),
3656
+ options,
3657
+ roleBindings,
3658
+ runtime,
3659
+ });
3660
+ }
3661
+ const sourceRootSessionId = normalizedFrames[0]!.sessionId;
3662
+ for (const [index, frame] of normalizedFrames.entries()) {
3663
+ if (frame.depth !== index || frame.rootSessionId !== sourceRootSessionId) {
3664
+ throw new TypeError(
3665
+ `${generationPath}.frames have inconsistent depth or root identity`,
3666
+ );
3667
+ }
3668
+ if (index === 0) {
3669
+ if (
3670
+ frame.playbookId !== rootPlaybookId ||
3671
+ frame.sessionId !== frame.rootSessionId ||
3672
+ frame.parentSessionId !== undefined ||
3673
+ frame.parentCallId !== undefined
3674
+ ) {
3675
+ throw new TypeError(
3676
+ `${generationPath}.frames[0] is not the named root`,
3677
+ );
3678
+ }
3679
+ continue;
3680
+ }
3681
+ const parent = normalizedFrames[index - 1]!;
3682
+ const suspended = parent.runtime.suspendedCall;
3683
+ if (
3684
+ frame.parentSessionId !== parent.sessionId ||
3685
+ frame.parentCallId === undefined ||
3686
+ suspended === undefined ||
3687
+ suspended.callId !== frame.parentCallId ||
3688
+ suspended.playbookId !== frame.playbookId ||
3689
+ suspended.childSessionId !== frame.sessionId
3690
+ ) {
3691
+ throw new TypeError(
3692
+ `${generationPath}.frames[${index}] does not match its suspended parent edge`,
3693
+ );
3694
+ }
3695
+ }
3696
+ const leaf = normalizedFrames.at(-1)!;
3697
+ if (
3698
+ leaf.runtime.suspendedCall !== undefined ||
3699
+ !leaf.runtime.state.tags.includes('playbook.parked')
3700
+ ) {
3701
+ throw new TypeError(
3702
+ `${generationPath} leaf must be parked without a suspended child`,
3703
+ );
3704
+ }
3705
+ const markedFrames = normalizedFrames.filter(
3706
+ ({ runtime }) => runtime.retainedEffectReconciliation !== undefined,
3707
+ );
3708
+ if (
3709
+ (sourceGenerationId === undefined && markedFrames.length !== 0) ||
3710
+ (sourceGenerationId !== undefined &&
3711
+ markedFrames.length !== normalizedFrames.length) ||
3712
+ (sourceGenerationId !== undefined &&
3713
+ normalizedFrames[0]!.runtime.retainedEffectReconciliation
3714
+ ?.sourceSessionId !== sourceGenerationId)
3715
+ ) {
3716
+ throw new TypeError(
3717
+ `${generationPath} retained-effect source marker is inconsistent`,
3718
+ );
3719
+ }
3720
+ normalized.set(
3721
+ rootPlaybookId,
3722
+ snapshotJsonValue(
3723
+ {
3724
+ effectLedger: generationEffectLedger,
3725
+ frames: normalizedFrames,
3726
+ ...(sourceGenerationId === undefined
3727
+ ? {}
3728
+ : {
3729
+ retainedEffectReconciliation: { sourceGenerationId },
3730
+ }),
3731
+ ...(rootStateDescription === undefined
3732
+ ? {}
3733
+ : { rootStateDescription }),
3734
+ },
3735
+ generationPath,
3736
+ ) as unknown as PlaybookCaptainRetainedGeneration,
3737
+ );
3738
+ }
3739
+ return normalized;
3740
+ };
3741
+
3742
+ const runtimeRetainsGenerations = (runtime: PlaybookRuntime): boolean => {
3743
+ const metadata = runtime.retainedGenerationMetadata;
3744
+ return (
3745
+ typeof runtime.exportSnapshot === 'function' &&
3746
+ typeof runtime.restore === 'function' &&
3747
+ typeof runtime.adopt === 'function' &&
3748
+ metadata !== undefined &&
3749
+ Array.isArray(metadata.unfinishedFinalStateIds) &&
3750
+ metadata.unfinishedFinalStateIds.every(
3751
+ (stateId) => typeof stateId === 'string',
3752
+ )
3753
+ );
3754
+ };
3755
+
3756
+ class RetainedRuntimeCleanupError extends AggregateError {
3757
+ readonly failedRuntimes: readonly PlaybookRuntime[];
3758
+
3759
+ constructor(
3760
+ failures: readonly unknown[],
3761
+ message: string,
3762
+ failedRuntimes: readonly PlaybookRuntime[] = [],
3763
+ ) {
3764
+ super(failures, message);
3765
+ this.name = 'RetainedRuntimeCleanupError';
3766
+ this.failedRuntimes = failedRuntimes;
3767
+ }
3768
+ }
3769
+
3770
+ const disposeRetainedRuntimeSet = async (
3771
+ runtimes: readonly PlaybookRuntime[],
3772
+ message: string,
3773
+ ): Promise<void> => {
3774
+ const failures: unknown[] = [];
3775
+ const failedRuntimes: PlaybookRuntime[] = [];
3776
+ for (const runtime of [...runtimes].reverse()) {
3777
+ try {
3778
+ await runtime.dispose();
3779
+ } catch (error) {
3780
+ failures.push(error);
3781
+ failedRuntimes.unshift(runtime);
3782
+ }
3783
+ }
3784
+ if (failures.length > 0) {
3785
+ throw new RetainedRuntimeCleanupError(
3786
+ failures,
3787
+ message,
3788
+ failedRuntimes,
3789
+ );
3790
+ }
3791
+ };
3792
+
3793
+ const retireRetainedOffer = (rootPlaybookId: string): void => {
3794
+ const offer = retainedGenerationOffers.get(rootPlaybookId);
3795
+ if (offer === undefined) return;
3796
+ retainedGenerationOffers.delete(rootPlaybookId);
3797
+ retiredRetainedRuntimes.push(...offer.runtimes);
3798
+ };
3799
+
3800
+ const applyRetentionUpdateToCatalog = (
3801
+ update: PlaybookCaptainRetentionUpdate,
3802
+ ): void => {
3803
+ if (update.kind === 'clear') {
3804
+ retireRetainedOffer(update.rootPlaybookId);
3805
+ retainedGenerations.delete(update.rootPlaybookId);
3806
+ ineligibleRetainedGenerations.delete(update.rootPlaybookId);
3807
+ retainedGenerationRootClears.delete(update.rootPlaybookId);
3808
+ return;
3809
+ }
3810
+ const prior = retainedGenerations.get(update.rootPlaybookId);
3811
+ if (isDeepStrictEqual(prior, update.generation)) return;
3812
+ retireRetainedOffer(update.rootPlaybookId);
3813
+ retainedGenerations.set(update.rootPlaybookId, update.generation);
3814
+ ineligibleRetainedGenerations.delete(update.rootPlaybookId);
3815
+ retainedGenerationRootClears.delete(update.rootPlaybookId);
3816
+ };
3817
+
3818
+ const drainRetiredRetainedRuntimes = async (): Promise<void> => {
3819
+ if (retiredRetainedRuntimes.length === 0) return;
3820
+ const runtimes = retiredRetainedRuntimes.splice(0);
3821
+ try {
3822
+ await disposeRetainedRuntimeSet(
3823
+ runtimes,
3824
+ 'retired retained-generation runtime cleanup failed',
3825
+ );
3826
+ } catch (error) {
3827
+ if (error instanceof RetainedRuntimeCleanupError) {
3828
+ retiredRetainedRuntimes.unshift(...error.failedRuntimes);
3829
+ }
3830
+ terminallyDisposed = true;
3831
+ lifecycle = 'closed';
3832
+ throw error;
3833
+ }
3834
+ };
3835
+
3836
+ const takeRetainedOfferRuntimes = (): readonly PlaybookRuntime[] => {
3837
+ const runtimes = [
3838
+ ...[...retainedGenerationOffers.values()].flatMap((offer) => [
3839
+ ...offer.runtimes,
3840
+ ]),
3841
+ ...retiredRetainedRuntimes.splice(0),
3842
+ ];
3843
+ retainedGenerationOffers.clear();
3844
+ return runtimes;
3845
+ };
3846
+
3847
+ const prepareRetainedGenerationOffers = async (): Promise<void> => {
3848
+ if (rootFrame() !== undefined) return;
3849
+ for (const [rootPlaybookId, generation] of [...retainedGenerations].sort(
3850
+ ([left], [right]) => left.localeCompare(right),
3851
+ )) {
3852
+ if (
3853
+ retainedGenerationOffers.has(rootPlaybookId) ||
3854
+ ineligibleRetainedGenerations.has(rootPlaybookId)
3855
+ ) {
3856
+ continue;
3857
+ }
3858
+ const runtimes: PlaybookRuntime[] = [];
3859
+ try {
3860
+ for (const sourceFrame of generation.frames) {
3861
+ const enablement = enablementById.get(sourceFrame.playbookId)!;
3862
+ runtimes.push(
3863
+ createRuntimeForEnablement(enablement, hostCapabilitiesById),
3864
+ );
3865
+ }
3866
+ if (runtimes.some((runtime) => !runtimeRetainsGenerations(runtime))) {
3867
+ const rootRetainsGenerations = runtimeRetainsGenerations(runtimes[0]!);
3868
+ await disposeRetainedRuntimeSet(
3869
+ runtimes,
3870
+ `/${enablementById.get(rootPlaybookId)!.command} retained-generation capability cleanup failed`,
3871
+ );
3872
+ runtimes.splice(0);
3873
+ ineligibleRetainedGenerations.add(rootPlaybookId);
3874
+ if (!rootRetainsGenerations) {
3875
+ retainedGenerationRootClears.add(rootPlaybookId);
3876
+ }
3877
+ continue;
3878
+ }
3879
+ retainedGenerationOffers.set(rootPlaybookId, {
3880
+ generation,
3881
+ requiresEffectReconciliation:
3882
+ generation.retainedEffectReconciliation !== undefined ||
3883
+ generation.frames.some(
3884
+ ({ runtime }) =>
3885
+ runtime.retainedEffectReconciliation !== undefined,
3886
+ ) ||
3887
+ !retainedEffectLedgerCanRebase(
3888
+ generation.effectLedger as PlaybookEffectLedger,
3889
+ assertPlaybookEffectLedger(currentEffectLedger()),
3890
+ ),
3891
+ runtimes,
3892
+ });
3893
+ } catch (error) {
3894
+ if (error instanceof RetainedRuntimeCleanupError) {
3895
+ retiredRetainedRuntimes.push(...error.failedRuntimes);
3896
+ terminallyDisposed = true;
3897
+ lifecycle = 'closed';
3898
+ throw error;
3899
+ }
3900
+ let cleanupError: unknown;
3901
+ if (runtimes.length > 0) {
3902
+ try {
3903
+ await disposeRetainedRuntimeSet(
3904
+ runtimes,
3905
+ 'retained-generation preparation cleanup failed',
3906
+ );
3907
+ } catch (caught) {
3908
+ cleanupError = caught;
3909
+ }
3910
+ }
3911
+ if (cleanupError !== undefined) {
3912
+ if (cleanupError instanceof RetainedRuntimeCleanupError) {
3913
+ retiredRetainedRuntimes.push(
3914
+ ...cleanupError.failedRuntimes,
3915
+ );
3916
+ }
3917
+ terminallyDisposed = true;
3918
+ lifecycle = 'closed';
3919
+ throw new RetainedRuntimeCleanupError(
3920
+ [error, cleanupError],
3921
+ 'retained-generation preparation and cleanup failed',
3922
+ cleanupError instanceof RetainedRuntimeCleanupError
3923
+ ? cleanupError.failedRuntimes
3924
+ : [],
3925
+ );
3926
+ }
3927
+ ineligibleRetainedGenerations.add(rootPlaybookId);
3928
+ }
3929
+ }
3930
+ };
3931
+
2297
3932
  const bindingFor = (
2298
3933
  frame: EngagementFrame,
2299
3934
  localRole: string,
@@ -2345,7 +3980,10 @@ export function createPlaybookCaptainShell(
2345
3980
  ...(leafFrame()?.state
2346
3981
  ? { latestSubRuntimeState: leafFrame()!.state }
2347
3982
  : {}),
2348
- ...(pendingBossQuestions !== undefined ? { pendingBossQuestions } : {}),
3983
+ ...(retainedEffectReconciliation === undefined &&
3984
+ pendingBossQuestions !== undefined
3985
+ ? { pendingBossQuestions }
3986
+ : {}),
2349
3987
  ...(lastError ? { lastError } : {}),
2350
3988
  ...(captainSessionId ? { captainSessionId } : {}),
2351
3989
  // Presence only: the pinned token value never reaches telemetry
@@ -2553,8 +4191,9 @@ export function createPlaybookCaptainShell(
2553
4191
  }
2554
4192
 
2555
4193
  if (leafFrame() === frame) {
2556
- pendingBossQuestions =
2557
- record.pendingBossQuestions ?? record.pendingBossQuestion;
4194
+ pendingBossQuestions = mirroredBossQuestions(
4195
+ record.pendingBossQuestions ?? record.pendingBossQuestion,
4196
+ );
2558
4197
  lastError = normalizeErrorCompact(record.lastError);
2559
4198
  if (state.quiescent && state.tags.includes('playbook.parked')) {
2560
4199
  await setMode(
@@ -2565,6 +4204,69 @@ export function createPlaybookCaptainShell(
2565
4204
  }
2566
4205
  };
2567
4206
 
4207
+ const observeSummaryTrace = (
4208
+ frame: EngagementFrame,
4209
+ payload: unknown,
4210
+ ): void => {
4211
+ const trace = payloadRecord(payload);
4212
+ const turn = activeTurn;
4213
+ const summary = activeTurnSummary;
4214
+ const expectedParentSessionId = frame.parent?.frame.sessionId;
4215
+ const expectedParentCallId = frame.parent?.callId;
4216
+ if (
4217
+ trace?.schemaVersion !== 4 ||
4218
+ trace.sessionId !== frame.sessionId ||
4219
+ trace.playbookId !== frame.entry.id ||
4220
+ trace.rootSessionId !== frame.rootSessionId ||
4221
+ (expectedParentSessionId === undefined
4222
+ ? Object.hasOwn(trace, 'parentSessionId')
4223
+ : !Object.hasOwn(trace, 'parentSessionId') ||
4224
+ trace.parentSessionId !== expectedParentSessionId) ||
4225
+ (expectedParentCallId === undefined
4226
+ ? Object.hasOwn(trace, 'parentCallId')
4227
+ : !Object.hasOwn(trace, 'parentCallId') ||
4228
+ trace.parentCallId !== expectedParentCallId) ||
4229
+ trace.depth !== frame.depth ||
4230
+ turn === undefined ||
4231
+ !Number.isSafeInteger(trace.turnId) ||
4232
+ (trace.turnId as number) <= 0 ||
4233
+ !Number.isSafeInteger(trace.sequence) ||
4234
+ (trace.sequence as number) <= 0 ||
4235
+ summary === undefined ||
4236
+ !summaryIncludes(frame)
4237
+ ) {
4238
+ return;
4239
+ }
4240
+ if (trace.type !== 'outcome.accepted') return;
4241
+ const receipt = exactOwnDataRecord(trace.payload, [
4242
+ 'source',
4243
+ 'target',
4244
+ 'acceptedOutcome',
4245
+ ]);
4246
+ if (
4247
+ receipt === undefined ||
4248
+ typeof receipt.source !== 'string' ||
4249
+ receipt.source.trim().length === 0 ||
4250
+ typeof receipt.target !== 'string' ||
4251
+ receipt.target.trim().length === 0 ||
4252
+ typeof receipt.acceptedOutcome !== 'string' ||
4253
+ receipt.acceptedOutcome.trim().length === 0
4254
+ ) {
4255
+ return;
4256
+ }
4257
+ const traceKey = `${frame.sessionId}:${trace.sequence}`;
4258
+ if (summary.acceptedOutcomeTraceKeys.has(traceKey)) return;
4259
+ summary.acceptedOutcomeTraceKeys.add(traceKey);
4260
+ summary.counts.interruptions++;
4261
+ if (
4262
+ frame.entry.summaryPolicy?.copyPasteGuardNames.includes(
4263
+ receipt.acceptedOutcome,
4264
+ )
4265
+ ) {
4266
+ summary.counts.copyPastes++;
4267
+ }
4268
+ };
4269
+
2568
4270
  let callNestedPlaybook: (
2569
4271
  frame: EngagementFrame,
2570
4272
  request: PlaybookCallRequest,
@@ -2761,16 +4463,6 @@ export function createPlaybookCaptainShell(
2761
4463
  if (result.finalText === undefined) {
2762
4464
  throw new Error('callCaptain returned status=ok with no finalText');
2763
4465
  }
2764
- const guard = guardFromJudgeReply(result.finalText);
2765
- const summary = activeTurnSummary;
2766
- if (
2767
- guard &&
2768
- summary &&
2769
- summaryIncludes(frame) &&
2770
- frame.entry.summaryPolicy?.copyPasteGuardNames.includes(guard)
2771
- ) {
2772
- summary.counts.copyPastes++;
2773
- }
2774
4466
  return result.finalText;
2775
4467
  },
2776
4468
  callPlaybook: (request, signal) => {
@@ -2806,6 +4498,9 @@ export function createPlaybookCaptainShell(
2806
4498
  await mirrorSubRuntimeTelemetry(frame, event.payload);
2807
4499
  }
2808
4500
  await requireSession().emitTelemetry(event);
4501
+ if (event.topic === 'playbook.trace') {
4502
+ observeSummaryTrace(frame, event.payload);
4503
+ }
2809
4504
  })();
2810
4505
  return trackHostCall(frame, emission);
2811
4506
  },
@@ -2829,7 +4524,7 @@ export function createPlaybookCaptainShell(
2829
4524
  }
2830
4525
  };
2831
4526
 
2832
- const allocateSessionId = (): string => {
4527
+ const generatedSessionId = (): string => {
2833
4528
  const sessionId = createSessionId();
2834
4529
  if (!UUID_PATTERN.test(sessionId)) {
2835
4530
  throw new Error(
@@ -2838,6 +4533,11 @@ export function createPlaybookCaptainShell(
2838
4533
  )}`,
2839
4534
  );
2840
4535
  }
4536
+ return sessionId;
4537
+ };
4538
+
4539
+ const allocateSessionId = (): string => {
4540
+ const sessionId = generatedSessionId();
2841
4541
  if (issuedSessionIds.has(sessionId)) {
2842
4542
  throw new Error(`playbook session id collision: ${sessionId}`);
2843
4543
  }
@@ -2845,6 +4545,30 @@ export function createPlaybookCaptainShell(
2845
4545
  return sessionId;
2846
4546
  };
2847
4547
 
4548
+ const allocateAdoptionSessionIds = (
4549
+ count: number,
4550
+ sourceSessionIds: ReadonlySet<string>,
4551
+ ): readonly string[] => {
4552
+ const candidates: string[] = [];
4553
+ const rejectedSourceIds = new Set<string>();
4554
+ while (candidates.length < count) {
4555
+ const candidate = generatedSessionId();
4556
+ if (sourceSessionIds.has(candidate)) {
4557
+ if (rejectedSourceIds.has(candidate)) {
4558
+ throw new Error(`playbook source session id collision: ${candidate}`);
4559
+ }
4560
+ rejectedSourceIds.add(candidate);
4561
+ continue;
4562
+ }
4563
+ if (issuedSessionIds.has(candidate) || candidates.includes(candidate)) {
4564
+ throw new Error(`playbook session id collision: ${candidate}`);
4565
+ }
4566
+ candidates.push(candidate);
4567
+ }
4568
+ for (const candidate of candidates) issuedSessionIds.add(candidate);
4569
+ return candidates;
4570
+ };
4571
+
2848
4572
  const normalizeErrorFull = (value: unknown): NormalizedError => {
2849
4573
  const compact = normalizeErrorCompact(value) ?? {
2850
4574
  name: 'Error',
@@ -2872,7 +4596,10 @@ export function createPlaybookCaptainShell(
2872
4596
  const entry = enablement.entry;
2873
4597
  const sessionId = allocateSessionId();
2874
4598
  const playerBindings = makePlayerBindings(enablement);
2875
- const runtime = entry.createRuntime(enablement.options);
4599
+ const runtime = createRuntimeForEnablement(
4600
+ enablement,
4601
+ hostCapabilitiesById,
4602
+ );
2876
4603
  return {
2877
4604
  entry,
2878
4605
  enablement,
@@ -2893,7 +4620,10 @@ export function createPlaybookCaptainShell(
2893
4620
  ): EngagementFrame => {
2894
4621
  const entry = enablement.entry;
2895
4622
  const playerBindings = makePlayerBindings(enablement);
2896
- const runtime = entry.createRuntime(enablement.options);
4623
+ const runtime = createRuntimeForEnablement(
4624
+ enablement,
4625
+ hostCapabilitiesById,
4626
+ );
2897
4627
  return {
2898
4628
  entry,
2899
4629
  enablement,
@@ -2950,16 +4680,6 @@ export function createPlaybookCaptainShell(
2950
4680
  try {
2951
4681
  if (resumeToken === undefined) delete ledger.resumeToken;
2952
4682
  else ledger.resumeToken = resumeToken;
2953
- // CAPTAIN-20: a result counts only after the runtime validated it and
2954
- // atomically published its authorized continuation transition.
2955
- const summary = activeTurnSummary;
2956
- if (
2957
- pending.status === 'ok' &&
2958
- summary &&
2959
- summaryIncludes(frame)
2960
- ) {
2961
- summary.counts.interruptions++;
2962
- }
2963
4683
  } finally {
2964
4684
  playerTransactions.delete(binding.playerId);
2965
4685
  }
@@ -3239,6 +4959,7 @@ export function createPlaybookCaptainShell(
3239
4959
  }
3240
4960
  }
3241
4961
  clearLeafLedger();
4962
+ if (frames.length === 0) retainedEffectReconciliation = undefined;
3242
4963
  if (failures.length === 1) throw failures[0];
3243
4964
  if (failures.length > 1) {
3244
4965
  throw new AggregateError(
@@ -3348,10 +5069,62 @@ export function createPlaybookCaptainShell(
3348
5069
  }
3349
5070
  };
3350
5071
 
5072
+ /**
5073
+ * DR-040 task 11: abandonment is a host settlement, not an authored FSM
5074
+ * result. Freeze the bounded evidence while the complete stack and its
5075
+ * runtime-owned envelope identities still exist, durably fence recovery,
5076
+ * dispose leaf-to-root without resuming a parent, then publish the matching
5077
+ * root clear and evidence as one durable completion before the controller
5078
+ * may return an executed receipt to its result phase.
5079
+ */
5080
+ const settleUnresolvedEffectAbandonment = async (
5081
+ unresolvedLeaf: EngagementFrame,
5082
+ ): Promise<void> => {
5083
+ if (leafFrame() !== unresolvedLeaf) {
5084
+ throw new Error(
5085
+ 'unresolved-effect abandonment requires the active leaf',
5086
+ );
5087
+ }
5088
+ const root = rootFrame();
5089
+ if (root === undefined) {
5090
+ throw new Error(
5091
+ 'unresolved-effect abandonment requires an active root',
5092
+ );
5093
+ }
5094
+ const unresolvedEffects = freezeTurnUnresolvedEffects();
5095
+ if (unresolvedEffects.length === 0) {
5096
+ throw new Error(
5097
+ 'unresolved-effect abandonment requires nonempty effect evidence',
5098
+ );
5099
+ }
5100
+ if (unresolvedEffectSettlement === undefined) {
5101
+ throw new Error(
5102
+ 'unresolved-effect abandonment requires durable host settlement',
5103
+ );
5104
+ }
5105
+ const rootPlaybookId = root.entry.id;
5106
+ const settlement = Object.freeze({
5107
+ rootPlaybookId,
5108
+ unresolvedEffects,
5109
+ });
5110
+ await runEffect(() => unresolvedEffectSettlement.begin(settlement));
5111
+ await runEffect(() => disposeStack('unresolved-effect'));
5112
+ pendingRetentionUpdates.set(rootPlaybookId, {
5113
+ kind: 'clear',
5114
+ rootPlaybookId,
5115
+ });
5116
+ await runEffect(() => unresolvedEffectSettlement.complete(settlement));
5117
+ };
5118
+
3351
5119
  const callResultFor = (
3352
5120
  frame: EngagementFrame,
3353
5121
  result: PlaybookRunResult,
3354
5122
  ): PlaybookCallResult => {
5123
+ if (result.outcome === 'unresolved-effect') {
5124
+ throw new Error(
5125
+ `playbook ${frame.entry.id} unresolved-effect result cannot resume a parent`,
5126
+ );
5127
+ }
3355
5128
  if (result.outcome === 'terminal') {
3356
5129
  return {
3357
5130
  status: 'ok',
@@ -3399,6 +5172,11 @@ export function createPlaybookCaptainShell(
3399
5172
  if (leafFrame() !== frame) {
3400
5173
  throw new Error('only the active leaf may receive Boss input');
3401
5174
  }
5175
+ if (retainedEffectReconciliation !== undefined) {
5176
+ throw new Error(
5177
+ 'retained repository-effect reconciliation is required before Boss input',
5178
+ );
5179
+ }
3402
5180
  // CAPTAIN-35: the leaf check, the visibility request, and the mode change
3403
5181
  // are shell control work performed on the way to the runtime, not the
3404
5182
  // effect. Only the call below is the effect, so only it is inside the
@@ -3425,6 +5203,11 @@ export function createPlaybookCaptainShell(
3425
5203
  const parentLink = child.parent;
3426
5204
  if (!parentLink) throw new Error('root playbook has no caller');
3427
5205
  const parent = parentLink.frame;
5206
+ if (retainedEffectReconciliation !== undefined) {
5207
+ throw new Error(
5208
+ 'retained repository-effect reconciliation is required before parent resumption',
5209
+ );
5210
+ }
3428
5211
  const invocationSignal = child.invocationSignal;
3429
5212
  let effectiveResult = callResult;
3430
5213
  let ownsReturn = false;
@@ -3509,6 +5292,16 @@ export function createPlaybookCaptainShell(
3509
5292
  result: PlaybookRunResult,
3510
5293
  context: CaptainContext,
3511
5294
  ): Promise<void> {
5295
+ if (result.outcome === 'unresolved-effect') {
5296
+ // Task 10 exposes the runtime-owned abandonment signal without
5297
+ // translating it into a nested result or claiming workflow completion.
5298
+ // Task 11 owns the durable host settlement and complete-stack disposal.
5299
+ assertRetainableResult(frame, result);
5300
+ if (leafFrame() === frame) {
5301
+ await setMode('engaged.parked', 'turn:unresolved-effect');
5302
+ }
5303
+ return;
5304
+ }
3512
5305
  if (result.outcome === 'terminal') {
3513
5306
  if (frame.parent) {
3514
5307
  await resumeParent(frame, callResultFor(frame, result), context);
@@ -3518,7 +5311,8 @@ export function createPlaybookCaptainShell(
3518
5311
  // runtime publishes before disposal removes the frame. The opaque run
3519
5312
  // output remains runtime-to-runtime data and never becomes Captain
3520
5313
  // evidence (CAPPLAY-10).
3521
- activeTurn?.settlementFacts.push(rootCompletionFact(frame));
5314
+ activeTurn?.settlementFacts.push(rootCompletionFact(frame, result));
5315
+ recordTerminalRetention(frame, result);
3522
5316
  await runEffect(() => disposeStack('final'));
3523
5317
  }
3524
5318
  return;
@@ -3735,7 +5529,14 @@ export function createPlaybookCaptainShell(
3735
5529
  const policy = frame.entry.summaryPolicy;
3736
5530
  const counts: TurnSummaryCounts = { interruptions: 0, copyPastes: 0 };
3737
5531
  const stateCounts = new Map<string, number>();
3738
- activeTurnSummary = policy ? { owner: frame, counts, stateCounts } : undefined;
5532
+ activeTurnSummary = policy
5533
+ ? {
5534
+ owner: frame,
5535
+ counts,
5536
+ stateCounts,
5537
+ acceptedOutcomeTraceKeys: new Set(),
5538
+ }
5539
+ : undefined;
3739
5540
  let result: T | undefined;
3740
5541
  let error: unknown;
3741
5542
  try {
@@ -3808,12 +5609,79 @@ export function createPlaybookCaptainShell(
3808
5609
  ];
3809
5610
  };
3810
5611
 
5612
+ const retainedResumptionDigest = (): string => {
5613
+ if (rootFrame() !== undefined) {
5614
+ return 'Retained resumptions: unavailable while a playbook is engaged.';
5615
+ }
5616
+ const offers = [...retainedGenerationOffers].sort(([left], [right]) =>
5617
+ left.localeCompare(right),
5618
+ );
5619
+ if (offers.length === 0) return 'Retained resumptions: none.';
5620
+ const lines = ['Retained resumptions:'];
5621
+ for (const [rootPlaybookId, offer] of offers) {
5622
+ const enablement = enablementById.get(rootPlaybookId)!;
5623
+ lines.push(
5624
+ digestLine`- ${rootPlaybookId} (/${enablement.command}): ${
5625
+ offer.generation.rootStateDescription ??
5626
+ '(no published root-state description was retained)'
5627
+ }`,
5628
+ );
5629
+ }
5630
+ return lines.join('\n');
5631
+ };
5632
+
3811
5633
  const controlViewDigest = (): string => {
3812
5634
  const leaf = leafFrame();
3813
5635
  const lines: string[] = [digestLine`Active path: ${activePathDigest()}`];
3814
5636
  if (!leaf) {
3815
5637
  lines.push('The shell is idle: no leaf state, no pending question.');
3816
5638
  lines.push('Advertised actions: none.');
5639
+ lines.push(retainedResumptionDigest());
5640
+ return lines.join('\n');
5641
+ }
5642
+ refreshRetainedEffectFence();
5643
+ if (retainedEffectReconciliation !== undefined) {
5644
+ let reconciliationActions: readonly {
5645
+ readonly id: string;
5646
+ readonly label: string;
5647
+ }[] = [];
5648
+ if (
5649
+ typeof leaf.runtime.describe === 'function' &&
5650
+ typeof leaf.runtime.apply === 'function'
5651
+ ) {
5652
+ try {
5653
+ reconciliationActions = leaf.runtime
5654
+ .describe()
5655
+ .actions.filter(
5656
+ ({ id }) =>
5657
+ id === UNRESOLVED_EFFECT_RECONCILIATION_ACTION_ID ||
5658
+ id === UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID,
5659
+ );
5660
+ } catch {
5661
+ // An unreadable control surface cannot open a fail-closed fence.
5662
+ }
5663
+ }
5664
+ for (const action of reconciliationActions) {
5665
+ recordSuppliedIdentifier(action.id);
5666
+ }
5667
+ lines.push(
5668
+ `Leaf ${frameLabel(leaf)} is parked for repository-effect reconciliation.`,
5669
+ );
5670
+ lines.push('Pending Boss questions: withheld until reconciliation.');
5671
+ lines.push(
5672
+ reconciliationActions.length === 0
5673
+ ? 'Advertised actions: none.'
5674
+ : [
5675
+ 'Advertised actions:',
5676
+ ...reconciliationActions.map(
5677
+ (action) => digestLine`- ${action.id}: ${action.label}`,
5678
+ ),
5679
+ ].join('\n'),
5680
+ );
5681
+ lines.push(
5682
+ '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.',
5683
+ );
5684
+ lines.push(retainedResumptionDigest());
3817
5685
  return lines.join('\n');
3818
5686
  }
3819
5687
  let view: PlaybookControlView | undefined;
@@ -3871,6 +5739,7 @@ export function createPlaybookCaptainShell(
3871
5739
  ? '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.'
3872
5740
  : '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.',
3873
5741
  );
5742
+ lines.push(retainedResumptionDigest());
3874
5743
  return lines.join('\n');
3875
5744
  }
3876
5745
  // CAPTAIN-9: the guarded set is what the digest supplies *for selection* —
@@ -3923,6 +5792,7 @@ export function createPlaybookCaptainShell(
3923
5792
  ),
3924
5793
  ].join('\n'),
3925
5794
  );
5795
+ lines.push(retainedResumptionDigest());
3926
5796
  return lines.join('\n');
3927
5797
  };
3928
5798
 
@@ -4017,9 +5887,14 @@ export function createPlaybookCaptainShell(
4017
5887
  // all of this prose. Preserve the exact attempt before crossing the
4018
5888
  // boundary; the uncertainty record below keeps recovery from pretending
4019
5889
  // delivery was confirmed while still understanding a Boss follow-up.
4020
- appendJournal('reply', settlement.text);
5890
+ const suffix = turn?.mandatoryPresentationSuffix;
5891
+ const visibleText =
5892
+ suffix === undefined || settlement.text.includes(suffix)
5893
+ ? settlement.text
5894
+ : `${settlement.text.trimEnd()}\n\n${suffix}`;
5895
+ appendJournal('reply', visibleText);
4021
5896
  try {
4022
- await trackTurnCall(settlement.context.emitReply(settlement.text));
5897
+ await trackTurnCall(settlement.context.emitReply(visibleText));
4023
5898
  } catch (error) {
4024
5899
  conversation = { kind: 'needsSeeding' };
4025
5900
  const normalized = normalizeErrorCompact(error) ?? {
@@ -4185,8 +6060,9 @@ export function createPlaybookCaptainShell(
4185
6060
  };
4186
6061
 
4187
6062
  /**
4188
- * CAPTAIN-35: the one wrapper an effect runs through — a runtime driven, an
4189
- * engagement constructed, a stack disposed, an advertised action applied.
6063
+ * CAPTAIN-35: the one wrapper an effect runs through — a runtime driven or
6064
+ * adopted, an engagement constructed, a stack disposed, an advertised
6065
+ * action applied.
4190
6066
  * Attribution is recorded here, at the operation that threw, and nowhere
4191
6067
  * else: an error acquires the mark by escaping this call, so no later
4192
6068
  * failure can inherit it.
@@ -4199,7 +6075,7 @@ export function createPlaybookCaptainShell(
4199
6075
  * instead of settling, so a misfiling costs the Boss their only settlement.
4200
6076
  *
4201
6077
  * Neither can a boundary drawn around a *region* of the turn. `operation` is
4202
- * therefore always one call expression naming one of those four operations,
6078
+ * therefore always one call expression naming one of those five operations,
4203
6079
  * never a closure that also performs the shell work leading to it: the leaf
4204
6080
  * check, the visibility request, the mode change, and the processing of what
4205
6081
  * the runtime returned are all shell control work, and a boundary wide
@@ -4467,7 +6343,10 @@ export function createPlaybookCaptainShell(
4467
6343
  ...(kind === 'closingReply' && turn?.report
4468
6344
  ? [
4469
6345
  labeledBlock('ControlView digest', controlViewDigest()),
4470
- outcomeReportBlock(turn.report),
6346
+ outcomeReportBlock(
6347
+ turn.report,
6348
+ turn.unresolvedEffects ?? [],
6349
+ ),
4471
6350
  ]
4472
6351
  : []),
4473
6352
  ...(options.proseRejection === undefined
@@ -4606,10 +6485,10 @@ export function createPlaybookCaptainShell(
4606
6485
  // The controller port (DR-029): host validation is the sole effector.
4607
6486
  // -------------------------------------------------------------------------
4608
6487
 
4609
- // The leaf's published state description, read from its control view the
4610
- // same way the digest reads it. A leaf without the pair or one whose view
4611
- // cannot be read at this moment publishes none, and the summary then says
4612
- // so instead of falling back to the state id.
6488
+ // The legacy state-description channel, read from the live control view the
6489
+ // same way the digest reads it. DR-037 makes the terminal result authoritative
6490
+ // for completion; this remains only for an older runtime that omits the new
6491
+ // optional member.
4613
6492
  const leafStateDescription = (
4614
6493
  frame: EngagementFrame,
4615
6494
  ): string | undefined => {
@@ -4621,10 +6500,21 @@ export function createPlaybookCaptainShell(
4621
6500
  }
4622
6501
  };
4623
6502
 
4624
- const rootCompletionFact = (frame: EngagementFrame): string => {
4625
- const published = leafStateDescription(frame);
6503
+ const rootCompletionFact = (
6504
+ frame: EngagementFrame,
6505
+ result: Extract<PlaybookRunResult, { outcome: 'terminal' }>,
6506
+ ): string => {
6507
+ const returned =
6508
+ result.stateDescription === undefined
6509
+ ? ''
6510
+ : compactEvidence(result.stateDescription);
6511
+ const legacy = returned === '' ? leafStateDescription(frame) : undefined;
4626
6512
  const description =
4627
- published === undefined ? '' : compactEvidence(published);
6513
+ returned !== ''
6514
+ ? returned
6515
+ : legacy === undefined
6516
+ ? ''
6517
+ : compactEvidence(legacy);
4628
6518
  return description === ''
4629
6519
  ? `${frameLabel(frame)} completed; its runtime published no result description.`
4630
6520
  : `${frameLabel(frame)} completed; its runtime-published result meaning was ${quoteEvidence(description)}.`;
@@ -4633,6 +6523,9 @@ export function createPlaybookCaptainShell(
4633
6523
  const leafStateSummary = (): string | undefined => {
4634
6524
  const leaf = leafFrame();
4635
6525
  if (!leaf) return 'idle: no playbook is engaged';
6526
+ if (retainedEffectReconciliation !== undefined) {
6527
+ return `${frameLabel(leaf)} parked for repository-effect reconciliation`;
6528
+ }
4636
6529
  if (!leaf.state) return `${frameLabel(leaf)} engaged`;
4637
6530
  return `${frameLabel(leaf)} at ${stateDigestLine(
4638
6531
  leaf.state,
@@ -4644,9 +6537,9 @@ export function createPlaybookCaptainShell(
4644
6537
  selection: CaptainControllerSelection | undefined,
4645
6538
  reason: string,
4646
6539
  options: { silent?: boolean } = {},
4647
- ): Promise<SettlementEvidence> => {
6540
+ ): Promise<ControllerSettlementDraft> => {
4648
6541
  const summary = leafStateSummary();
4649
- const settlement: SettlementEvidence = {
6542
+ const settlement: ControllerSettlementDraft = {
4650
6543
  status: 'rejected',
4651
6544
  facts: [`Rejected: ${reason}.`],
4652
6545
  reason,
@@ -4692,6 +6585,9 @@ export function createPlaybookCaptainShell(
4692
6585
  const root = rootFrame();
4693
6586
  if (!root) return false;
4694
6587
  const label = frameLabel(root);
6588
+ // Dismissal leaves the procedure unfinished. Persist the latest safe
6589
+ // generation captured for this turn before disposal erases the frames.
6590
+ retainOrClearDisposedRoot(root);
4695
6591
  try {
4696
6592
  await runEffect(() => disposeStack('dismiss'));
4697
6593
  facts.push(`Dismissed the ${label} engagement.`);
@@ -4748,6 +6644,163 @@ export function createPlaybookCaptainShell(
4748
6644
  return { frame, report: outcome.report, failed: false };
4749
6645
  };
4750
6646
 
6647
+ const adoptRetainedGeneration = async (
6648
+ rootPlaybookId: string,
6649
+ offer: RetainedGenerationOffer,
6650
+ ): Promise<readonly string[]> => {
6651
+ const generation = offer.generation;
6652
+ const currentLedger = assertPlaybookEffectLedger(currentEffectLedger());
6653
+ const requiresEffectReconciliation =
6654
+ offer.requiresEffectReconciliation ||
6655
+ !retainedEffectLedgerCanRebase(
6656
+ generation.effectLedger as PlaybookEffectLedger,
6657
+ currentLedger,
6658
+ );
6659
+ const sourceSessionIds = new Set(
6660
+ generation.frames.map((frame) => frame.sessionId),
6661
+ );
6662
+ const targetSessionIds = allocateAdoptionSessionIds(
6663
+ generation.frames.length,
6664
+ sourceSessionIds,
6665
+ );
6666
+ const targetRootSessionId = targetSessionIds[0]!;
6667
+ const adoptedFrames: EngagementFrame[] = [];
6668
+ for (const [index, sourceFrame] of generation.frames.entries()) {
6669
+ const enablement = enablementById.get(sourceFrame.playbookId)!;
6670
+ const parent = adoptedFrames.at(-1);
6671
+ adoptedFrames.push({
6672
+ entry: enablement.entry,
6673
+ enablement,
6674
+ runtime: offer.runtimes[index]!,
6675
+ sessionId: targetSessionIds[index]!,
6676
+ rootSessionId: targetRootSessionId,
6677
+ depth: index,
6678
+ playerBindings: makePlayerBindings(enablement),
6679
+ ...(parent
6680
+ ? { parent: { frame: parent, callId: 'playbook-1' } }
6681
+ : {}),
6682
+ state: sourceFrame.runtime.state,
6683
+ inFlightHostCalls: new Set(),
6684
+ });
6685
+ }
6686
+
6687
+ retainedGenerationOffers.delete(rootPlaybookId);
6688
+ let installed = false;
6689
+ try {
6690
+ for (const [index, frame] of adoptedFrames.entries()) {
6691
+ const sourceFrame = generation.frames[index]!;
6692
+ const targetChild = adoptedFrames[index + 1];
6693
+ await runEffect(() =>
6694
+ frame.runtime.adopt!(
6695
+ frameSession(frame),
6696
+ sourceFrame.runtime,
6697
+ {
6698
+ sourceSessionId: sourceFrame.sessionId,
6699
+ sourceGenerationId: generation.frames[0]!.rootSessionId,
6700
+ ...(targetChild === undefined
6701
+ ? {}
6702
+ : { targetChildSessionId: targetChild.sessionId }),
6703
+ },
6704
+ ),
6705
+ );
6706
+ }
6707
+
6708
+ frames.push(...adoptedFrames);
6709
+ installed = true;
6710
+ retainedEffectReconciliation = requiresEffectReconciliation
6711
+ ? {
6712
+ sourceGenerationId:
6713
+ generation.retainedEffectReconciliation?.sourceGenerationId ??
6714
+ generation.frames[0]!.runtime.retainedEffectSourceSessionId ??
6715
+ generation.frames[0]!.rootSessionId,
6716
+ checkpoint: generation.effectLedger,
6717
+ }
6718
+ : undefined;
6719
+ for (const parent of adoptedFrames.slice(0, -1)) {
6720
+ pendingChildParents.add(parent);
6721
+ }
6722
+ const retainedQuestions = generation.frames.at(-1)!.runtime
6723
+ .pendingBossQuestions;
6724
+ pendingBossQuestions =
6725
+ requiresEffectReconciliation || retainedQuestions.length === 0
6726
+ ? undefined
6727
+ : mirroredBossQuestions(retainedQuestions);
6728
+ lastError = undefined;
6729
+ retainedGenerations.delete(rootPlaybookId);
6730
+ ineligibleRetainedGenerations.delete(rootPlaybookId);
6731
+ await setMode(
6732
+ 'engaged.parked',
6733
+ 'resume',
6734
+ rootPlaybookId,
6735
+ targetRootSessionId,
6736
+ );
6737
+ if (requiresEffectReconciliation) {
6738
+ await requireSession().setVisiblePlayers([]);
6739
+ } else {
6740
+ await requestVisibility(adoptedFrames.at(-1)!);
6741
+ }
6742
+ await requireSession().emitStatus(
6743
+ `◇ /${enablementById.get(rootPlaybookId)!.command} resumed`,
6744
+ );
6745
+ if (activeTurn) {
6746
+ appendMandatoryPresentationSuffix(
6747
+ activeTurn,
6748
+ requiresEffectReconciliation
6749
+ ? 'The retained work remains parked until its repository-effect evidence is reconciled.'
6750
+ : RESUMPTION_DUPLICATE_EFFECT_WARNING,
6751
+ );
6752
+ }
6753
+ return [
6754
+ generation.rootStateDescription === undefined
6755
+ ? `Resumed /${enablementById.get(rootPlaybookId)!.command} from its retained state; no published root-state description was retained.`
6756
+ : `Resumed /${enablementById.get(rootPlaybookId)!.command} from the retained state described as ${quoteEvidence(compactEvidence(generation.rootStateDescription))}.`,
6757
+ requiresEffectReconciliation
6758
+ ? 'The retained work remains parked until its repository-effect evidence is reconciled; no ordinary action was resumed.'
6759
+ : RESUMPTION_DUPLICATE_EFFECT_WARNING,
6760
+ ];
6761
+ } catch (error) {
6762
+ if (installed) {
6763
+ frames.splice(0);
6764
+ pendingChildParents.clear();
6765
+ retainedEffectReconciliation = undefined;
6766
+ clearLeafLedger();
6767
+ }
6768
+ const cleanupFailures: unknown[] = [];
6769
+ const failedCleanupRuntimes: PlaybookRuntime[] = [];
6770
+ for (const frame of [...adoptedFrames].reverse()) {
6771
+ try {
6772
+ await disposeFrame(frame);
6773
+ } catch (cleanupError) {
6774
+ cleanupFailures.push(cleanupError);
6775
+ failedCleanupRuntimes.push(frame.runtime);
6776
+ }
6777
+ }
6778
+ const rollbackFailures: unknown[] = [];
6779
+ if (installed) {
6780
+ try {
6781
+ await setMode('chat', 'resume.failed');
6782
+ } catch (rollbackError) {
6783
+ rollbackFailures.push(rollbackError);
6784
+ }
6785
+ } else {
6786
+ mode = 'chat';
6787
+ }
6788
+ if (cleanupFailures.length > 0 || rollbackFailures.length > 0) {
6789
+ retiredRetainedRuntimes.push(...failedCleanupRuntimes);
6790
+ ineligibleRetainedGenerations.add(rootPlaybookId);
6791
+ terminallyDisposed = true;
6792
+ lifecycle = 'closed';
6793
+ throw new AggregateError(
6794
+ [error, ...cleanupFailures, ...rollbackFailures],
6795
+ 'retained-generation adoption and rollback failed',
6796
+ );
6797
+ }
6798
+ retainedGenerations.set(rootPlaybookId, generation);
6799
+ ineligibleRetainedGenerations.delete(rootPlaybookId);
6800
+ throw error;
6801
+ }
6802
+ };
6803
+
4751
6804
  const driveAndProcess = async (
4752
6805
  frame: EngagementFrame,
4753
6806
  text: string,
@@ -4778,14 +6831,46 @@ export function createPlaybookCaptainShell(
4778
6831
  }
4779
6832
  };
4780
6833
 
6834
+ const containsEffectThrow = (turn: ActiveTurn, error: unknown): boolean => {
6835
+ if (turn.effectThrows.has(error)) return true;
6836
+ return (
6837
+ error instanceof AggregateError &&
6838
+ error.errors.some((nested) => containsEffectThrow(turn, nested))
6839
+ );
6840
+ };
6841
+
4781
6842
  const settleSelection = async (
4782
6843
  selection: CaptainControllerSelection,
4783
6844
  signal: AbortSignal,
4784
6845
  ): Promise<SettlementEvidence> => {
4785
6846
  const turn = activeTurn;
4786
6847
  runFailureFacts = [];
6848
+ let frozenUnresolvedEffects:
6849
+ | readonly PlaybookCaptainUnresolvedEffect[]
6850
+ | undefined;
6851
+ const freezeControllerEvidence =
6852
+ (): readonly PlaybookCaptainUnresolvedEffect[] => {
6853
+ frozenUnresolvedEffects ??= freezeTurnUnresolvedEffects();
6854
+ const report = unresolvedEffectBossReport(frozenUnresolvedEffects);
6855
+ if (turn !== undefined && report !== undefined) {
6856
+ appendMandatoryPresentationSuffix(turn, report);
6857
+ }
6858
+ return frozenUnresolvedEffects;
6859
+ };
6860
+ const finalizeSettlement = (
6861
+ settlement: ControllerSettlementDraft,
6862
+ ): SettlementEvidence =>
6863
+ Object.freeze({
6864
+ ...settlement,
6865
+ unresolvedEffects: freezeControllerEvidence(),
6866
+ });
4787
6867
  try {
4788
- return await executeSelection(selection, signal);
6868
+ // `respond` has no result phase: freeze its no-effect projection before
6869
+ // its decision-call prose crosses the presentation boundary. Acting
6870
+ // selections freeze after their work and before reporting begins.
6871
+ if (selection.action === 'respond') freezeControllerEvidence();
6872
+ const settlement = await executeSelection(selection, signal);
6873
+ return finalizeSettlement(settlement);
4789
6874
  } catch (error) {
4790
6875
  if (turn?.presentationError === error) throw error;
4791
6876
  const aborted = signal.aborted || activeContext?.signal.aborted === true;
@@ -4816,7 +6901,7 @@ export function createPlaybookCaptainShell(
4816
6901
  }
4817
6902
  const mayHaveApplied =
4818
6903
  selection.action !== 'respond' &&
4819
- turn.effectThrows.has(error);
6904
+ containsEffectThrow(turn, error);
4820
6905
  turn.settlementFacts.push(
4821
6906
  mayHaveApplied
4822
6907
  ? `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.`
@@ -4859,7 +6944,7 @@ export function createPlaybookCaptainShell(
4859
6944
  };
4860
6945
  turn.settled = true;
4861
6946
  lastSettlementStatus = 'failed';
4862
- return {
6947
+ const settlement: ControllerSettlementDraft = {
4863
6948
  status: 'failed',
4864
6949
  facts: [...turn.settlementFacts],
4865
6950
  ...(turn.report.receipt === undefined
@@ -4867,6 +6952,7 @@ export function createPlaybookCaptainShell(
4867
6952
  : { receipt: turn.report.receipt }),
4868
6953
  ...(summary === undefined ? {} : { leafStateSummary: summary }),
4869
6954
  };
6955
+ return finalizeSettlement(settlement);
4870
6956
  } finally {
4871
6957
  runFailureFacts = undefined;
4872
6958
  }
@@ -4883,7 +6969,7 @@ export function createPlaybookCaptainShell(
4883
6969
  const executeSelection = async (
4884
6970
  selection: CaptainControllerSelection,
4885
6971
  signal: AbortSignal,
4886
- ): Promise<SettlementEvidence> => {
6972
+ ): Promise<ControllerSettlementDraft> => {
4887
6973
  const context = activeContext;
4888
6974
  const turn = activeTurn;
4889
6975
  if (!context || !turn) {
@@ -4930,9 +7016,66 @@ export function createPlaybookCaptainShell(
4930
7016
  return {
4931
7017
  status: 'ok',
4932
7018
  facts: [...facts],
4933
- ...(leafStateSummary() === undefined
4934
- ? {}
4935
- : { leafStateSummary: leafStateSummary()! }),
7019
+ ...(leafStateSummary() === undefined
7020
+ ? {}
7021
+ : { leafStateSummary: leafStateSummary()! }),
7022
+ };
7023
+ }
7024
+
7025
+ refreshRetainedEffectFence();
7026
+ const fencedLeaf = leafFrame();
7027
+ const routesRetainedReconciliation =
7028
+ selection.action === 'runtime' &&
7029
+ (selection.actionId === UNRESOLVED_EFFECT_RECONCILIATION_ACTION_ID ||
7030
+ selection.actionId === UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID) &&
7031
+ fencedLeaf !== undefined;
7032
+ if (
7033
+ retainedEffectReconciliation !== undefined &&
7034
+ !routesRetainedReconciliation
7035
+ ) {
7036
+ return rejectSelection(
7037
+ selection,
7038
+ 'retained work must reconcile its repository-effect evidence before an ordinary action can run',
7039
+ );
7040
+ }
7041
+
7042
+ if (selection.action === 'resume') {
7043
+ const entry = byId.get(selection.playbookId);
7044
+ if (entry === undefined) {
7045
+ return rejectSelection(
7046
+ selection,
7047
+ `"${selection.playbookId}" is not an enabled playbook`,
7048
+ );
7049
+ }
7050
+ if (rootFrame() !== undefined) {
7051
+ return rejectSelection(
7052
+ selection,
7053
+ 'a playbook is already engaged; its live actions take precedence',
7054
+ );
7055
+ }
7056
+ const offer = retainedGenerationOffers.get(entry.id);
7057
+ if (offer === undefined) {
7058
+ return rejectSelection(
7059
+ selection,
7060
+ `/${enablementById.get(entry.id)!.command} has no resumable retained generation`,
7061
+ );
7062
+ }
7063
+ turn.settled = true;
7064
+ journalAction({ action: 'resume', playbookId: entry.id });
7065
+ facts.push(...(await adoptRetainedGeneration(entry.id, offer)));
7066
+ const summary = leafStateSummary();
7067
+ turn.report = {
7068
+ ...emptyReport(),
7069
+ facts,
7070
+ status: 'ok',
7071
+ ...(summary === undefined ? {} : { leafStateSummary: summary }),
7072
+ };
7073
+ journalOutcome([...facts]);
7074
+ lastSettlementStatus = 'ok';
7075
+ return {
7076
+ status: 'ok',
7077
+ facts: [...facts],
7078
+ ...(summary === undefined ? {} : { leafStateSummary: summary }),
4936
7079
  };
4937
7080
  }
4938
7081
 
@@ -5202,6 +7345,7 @@ export function createPlaybookCaptainShell(
5202
7345
  );
5203
7346
  if (outcome.error !== undefined) throw outcome.error;
5204
7347
  const receipt = outcome.result!;
7348
+ refreshRetainedEffectFence();
5205
7349
  let status: SettlementEvidence['status'] =
5206
7350
  receipt.disposition === 'executed'
5207
7351
  ? 'ok'
@@ -5222,9 +7366,53 @@ export function createPlaybookCaptainShell(
5222
7366
  }
5223
7367
  : {}),
5224
7368
  };
7369
+ const unresolvedAbandonment =
7370
+ receipt.disposition === 'executed' &&
7371
+ actionId === UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID;
5225
7372
  if (receipt.disposition === 'executed') {
5226
- facts.push(`Applied "${actionId}" on ${frameLabel(leaf)}.`);
7373
+ if (
7374
+ unresolvedAbandonment &&
7375
+ receipt.run?.outcome !== 'unresolved-effect'
7376
+ ) {
7377
+ throw new Error(
7378
+ 'unresolved-effect abandonment returned no unresolved-effect result',
7379
+ );
7380
+ }
5227
7381
  const establishedSummary = leafStateSummary();
7382
+ if (unresolvedAbandonment) {
7383
+ try {
7384
+ await settleUnresolvedEffectAbandonment(leaf);
7385
+ } catch (error) {
7386
+ abandonmentSettlementUnsafe = true;
7387
+ const normalized = normalizeErrorCompact(error) ?? {
7388
+ name: 'Error',
7389
+ message: String(error),
7390
+ };
7391
+ // The runtime action was accepted, but its distinct host-level
7392
+ // settlement did not complete. Do not expose an `executed` control
7393
+ // receipt until both disposal and durable publication have
7394
+ // succeeded.
7395
+ turn.report = {
7396
+ ...outcome.report,
7397
+ facts: [...facts],
7398
+ bossFacts: facts.map((fact) =>
7399
+ fact
7400
+ .split(`"${actionId}"`)
7401
+ .join(`"${compactEvidence(actionLabel)}"`),
7402
+ ),
7403
+ status: 'failed',
7404
+ receipt: {
7405
+ disposition: 'failed',
7406
+ error: normalized,
7407
+ },
7408
+ ...(establishedSummary === undefined
7409
+ ? {}
7410
+ : { leafStateSummary: establishedSummary }),
7411
+ };
7412
+ throw error;
7413
+ }
7414
+ }
7415
+ facts.push(`Applied "${actionId}" on ${frameLabel(leaf)}.`);
5228
7416
  // Execution is now proven. Preserve that receipt and the counts already
5229
7417
  // collected before processing the returned run, because disposal,
5230
7418
  // telemetry, or parent resumption can still fail afterward.
@@ -5242,7 +7430,11 @@ export function createPlaybookCaptainShell(
5242
7430
  ? {}
5243
7431
  : { leafStateSummary: establishedSummary }),
5244
7432
  };
5245
- if (receipt.run !== undefined) {
7433
+ if (
7434
+ !unresolvedAbandonment &&
7435
+ receipt.run !== undefined &&
7436
+ retainedEffectReconciliation === undefined
7437
+ ) {
5246
7438
  // The same rule as the drive path: processing the run the receipt
5247
7439
  // carried is not itself an effect, and the resume or disposal it may
5248
7440
  // perform is marked where it happens (CAPTAIN-35).
@@ -5483,6 +7675,42 @@ export function createPlaybookCaptainShell(
5483
7675
  `Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} role bindings changed`,
5484
7676
  );
5485
7677
  }
7678
+ if (
7679
+ !isDeepStrictEqual(frame.runtime.effectLedger, snapshot.effectLedger)
7680
+ ) {
7681
+ throw new TypeError(
7682
+ `Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} effect ledger does not match its artifact schema`,
7683
+ );
7684
+ }
7685
+ const runtimeReconciliation =
7686
+ frame.runtime.retainedEffectReconciliation;
7687
+ if (snapshot.retainedEffectReconciliation === undefined) {
7688
+ if (runtimeReconciliation !== undefined) {
7689
+ throw new TypeError(
7690
+ `Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} carries an unmirrored retained-effect fence`,
7691
+ );
7692
+ }
7693
+ } else if (
7694
+ runtimeReconciliation === undefined ||
7695
+ !isDeepStrictEqual(
7696
+ runtimeReconciliation.checkpoint,
7697
+ snapshot.retainedEffectReconciliation.checkpoint,
7698
+ )
7699
+ ) {
7700
+ throw new TypeError(
7701
+ `Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} does not mirror the root retained-effect fence`,
7702
+ );
7703
+ }
7704
+ if (
7705
+ frame.depth === 0 &&
7706
+ snapshot.retainedEffectReconciliation !== undefined &&
7707
+ runtimeReconciliation?.sourceSessionId !==
7708
+ snapshot.retainedEffectReconciliation.sourceGenerationId
7709
+ ) {
7710
+ throw new TypeError(
7711
+ 'Captain shell snapshot retained-effect root source identity differs from its generation',
7712
+ );
7713
+ }
5486
7714
  }
5487
7715
  };
5488
7716
 
@@ -5548,40 +7776,44 @@ export function createPlaybookCaptainShell(
5548
7776
  });
5549
7777
  };
5550
7778
 
5551
- const exportShellSnapshot = (): PlaybookCaptainShellSnapshot | undefined => {
5552
- if (
5553
- !safeCapturePoint() ||
5554
- !captainRuntime ||
5555
- !captainSessionId ||
5556
- !captainAgent
5557
- ) {
5558
- return undefined;
5559
- }
7779
+ const captureFrameSnapshots = (
7780
+ requireRecordedState: boolean,
7781
+ ): readonly PlaybookCaptainFrameSnapshot[] | undefined => {
7782
+ const captured: PlaybookCaptainFrameSnapshot[] = [];
5560
7783
  try {
5561
- if (
5562
- typeof captainRuntime.exportSnapshot !== 'function' ||
5563
- typeof captainRuntime.restore !== 'function'
5564
- ) {
5565
- return undefined;
5566
- }
5567
- const captainSnapshot = captainRuntime.exportSnapshot();
5568
- if (captainSnapshot === undefined) return undefined;
5569
- const frameSnapshots: PlaybookCaptainFrameSnapshot[] = [];
5570
- for (const frame of frames) {
7784
+ for (const [index, frame] of frames.entries()) {
5571
7785
  if (
5572
7786
  typeof frame.runtime.exportSnapshot !== 'function' ||
5573
7787
  typeof frame.runtime.restore !== 'function'
5574
7788
  ) {
5575
7789
  return undefined;
5576
7790
  }
5577
- const runtime = frame.runtime.exportSnapshot();
7791
+ const exported = frame.runtime.exportSnapshot();
7792
+ if (exported === undefined) return undefined;
7793
+ const runtime = assertPlaybookRuntimeSnapshot(
7794
+ exported,
7795
+ frame.entry.id,
7796
+ { allowSuspendedCall: true },
7797
+ );
7798
+ if (
7799
+ runtime.state.status !== 'active' ||
7800
+ !runtime.state.quiescent ||
7801
+ (requireRecordedState && frame.state === undefined) ||
7802
+ (frame.state !== undefined &&
7803
+ !isDeepStrictEqual(frame.state, runtime.state))
7804
+ ) {
7805
+ return undefined;
7806
+ }
7807
+ const isLeaf = index === frames.length - 1;
5578
7808
  if (
5579
- runtime === undefined ||
5580
- !isDeepStrictEqual(frame.state, runtime.state)
7809
+ (isLeaf &&
7810
+ (runtime.suspendedCall !== undefined ||
7811
+ !runtime.state.tags.includes('playbook.parked'))) ||
7812
+ (!isLeaf && runtime.suspendedCall === undefined)
5581
7813
  ) {
5582
7814
  return undefined;
5583
7815
  }
5584
- frameSnapshots.push({
7816
+ captured.push({
5585
7817
  playbookId: frame.entry.id,
5586
7818
  sessionId: frame.sessionId,
5587
7819
  rootSessionId: frame.rootSessionId,
@@ -5602,8 +7834,253 @@ export function createPlaybookCaptainShell(
5602
7834
  runtime,
5603
7835
  });
5604
7836
  }
7837
+ for (let index = 1; index < captured.length; index += 1) {
7838
+ const parent = captured[index - 1]!;
7839
+ const child = captured[index]!;
7840
+ if (
7841
+ child.parentSessionId !== parent.sessionId ||
7842
+ child.parentCallId === undefined ||
7843
+ parent.runtime.suspendedCall?.callId !== child.parentCallId ||
7844
+ parent.runtime.suspendedCall.playbookId !== child.playbookId ||
7845
+ parent.runtime.suspendedCall.childSessionId !== child.sessionId
7846
+ ) {
7847
+ return undefined;
7848
+ }
7849
+ }
7850
+ return captured;
7851
+ } catch {
7852
+ return undefined;
7853
+ }
7854
+ };
7855
+
7856
+ const refreshRetainedEffectFence = (
7857
+ capturedFrames?: readonly PlaybookCaptainFrameSnapshot[],
7858
+ capturedLedger?: PlaybookEffectLedger,
7859
+ ): void => {
7860
+ const fence = retainedEffectReconciliation;
7861
+ if (fence === undefined) return;
7862
+ try {
7863
+ const ledger =
7864
+ capturedLedger ?? assertPlaybookEffectLedger(currentEffectLedger());
7865
+ if (
7866
+ !retainedEffectLedgerCanRebase(
7867
+ fence.checkpoint as PlaybookEffectLedger,
7868
+ ledger,
7869
+ )
7870
+ ) {
7871
+ return;
7872
+ }
7873
+ const snapshots = capturedFrames ?? captureFrameSnapshots(true);
7874
+ if (snapshots === undefined || snapshots.length !== frames.length) return;
7875
+ for (const frameSnapshot of snapshots) {
7876
+ const runtime = frameSnapshot.runtime;
7877
+ if (
7878
+ !isDeepStrictEqual(runtime.effectLedger, ledger) ||
7879
+ runtime.retainedEffectSourceSessionId === undefined ||
7880
+ runtime.retainedEffectReconciliation !== undefined
7881
+ ) {
7882
+ return;
7883
+ }
7884
+ }
7885
+ const retainedQuestions = snapshots.at(-1)!.runtime.pendingBossQuestions;
7886
+ const restoredQuestions =
7887
+ retainedQuestions.length === 0
7888
+ ? undefined
7889
+ : mirroredBossQuestions(retainedQuestions);
7890
+ pendingBossQuestions = restoredQuestions;
7891
+ retainedEffectReconciliation = undefined;
7892
+ } catch {
7893
+ // Any host-ledger or runtime-snapshot defect leaves the root fence shut.
7894
+ }
7895
+ };
7896
+
7897
+ const rootStateDescriptionForRetention = (
7898
+ root: EngagementFrame,
7899
+ retainedState: PlaybookState,
7900
+ ): string | undefined => {
7901
+ if (typeof root.runtime.describe !== 'function') return undefined;
7902
+ try {
7903
+ const view = root.runtime.describe();
7904
+ if (!isDeepStrictEqual(view.state, retainedState)) return undefined;
7905
+ return typeof view.stateDescription === 'string' &&
7906
+ view.stateDescription.trim().length > 0
7907
+ ? view.stateDescription
7908
+ : undefined;
7909
+ } catch {
7910
+ return undefined;
7911
+ }
7912
+ };
7913
+
7914
+ const retainedGenerationFromFrames = (
7915
+ root: EngagementFrame,
7916
+ frameSnapshots: readonly PlaybookCaptainFrameSnapshot[],
7917
+ ): PlaybookCaptainRetainedGeneration => {
7918
+ const rootStateDescription = rootStateDescriptionForRetention(
7919
+ root,
7920
+ frameSnapshots[0]!.runtime.state,
7921
+ );
7922
+ const checkpoint =
7923
+ retainedEffectReconciliation?.checkpoint ??
7924
+ assertPlaybookEffectLedger(currentEffectLedger());
7925
+ if (
7926
+ checkpoint.boundaries.some(
7927
+ ({ physicalReceipt }) => physicalReceipt === undefined,
7928
+ )
7929
+ ) {
7930
+ throw new TypeError(
7931
+ 'Captain retained-generation checkpoint contains an incomplete physical boundary',
7932
+ );
7933
+ }
7934
+ return snapshotJsonValue(
7935
+ {
7936
+ effectLedger: checkpoint,
7937
+ frames: frameSnapshots,
7938
+ ...(retainedEffectReconciliation === undefined
7939
+ ? {}
7940
+ : {
7941
+ retainedEffectReconciliation: {
7942
+ sourceGenerationId:
7943
+ retainedEffectReconciliation.sourceGenerationId,
7944
+ },
7945
+ }),
7946
+ ...(rootStateDescription === undefined
7947
+ ? {}
7948
+ : { rootStateDescription }),
7949
+ },
7950
+ 'Captain retained generation',
7951
+ ) as unknown as PlaybookCaptainRetainedGeneration;
7952
+ };
7953
+
7954
+ const captureRetainedGeneration =
7955
+ (): PlaybookCaptainRetainedGeneration | undefined => {
7956
+ const root = rootFrame();
7957
+ if (
7958
+ !root ||
7959
+ frames.some((frame) => !runtimeRetainsGenerations(frame.runtime))
7960
+ ) {
7961
+ return undefined;
7962
+ }
7963
+ const frameSnapshots = captureFrameSnapshots(true);
7964
+ if (frameSnapshots === undefined || frameSnapshots.length === 0) {
7965
+ return undefined;
7966
+ }
7967
+ return retainedGenerationFromFrames(root, frameSnapshots);
7968
+ };
7969
+
7970
+ const rememberRetainedGeneration = (): void => {
7971
+ const root = rootFrame();
7972
+ if (!root) return;
7973
+ if (frames.some((frame) => !runtimeRetainsGenerations(frame.runtime))) {
7974
+ retainedGenerationCandidates.set(root.entry.id, {
7975
+ status: 'incapable',
7976
+ });
7977
+ return;
7978
+ }
7979
+ const generation = captureRetainedGeneration();
7980
+ retainedGenerationCandidates.set(
7981
+ root.entry.id,
7982
+ generation === undefined
7983
+ ? { status: 'unsafe' }
7984
+ : { status: 'captured', generation },
7985
+ );
7986
+ };
7987
+
7988
+ const retentionUpdateForPriorGeneration = (
7989
+ root: EngagementFrame,
7990
+ ): PlaybookCaptainRetentionUpdate | undefined => {
7991
+ const rootPlaybookId = root.entry.id;
7992
+ if (!runtimeRetainsGenerations(root.runtime)) {
7993
+ return {
7994
+ kind: 'clear',
7995
+ rootPlaybookId,
7996
+ };
7997
+ }
7998
+ const candidate = retainedGenerationCandidates.get(rootPlaybookId);
7999
+ if (candidate?.status === 'incapable') {
8000
+ return undefined;
8001
+ }
8002
+ if (candidate?.status !== 'captured') {
8003
+ throw new Error(
8004
+ `${frameLabel(root)} could not capture its pre-terminal retained generation`,
8005
+ );
8006
+ }
8007
+ return {
8008
+ kind: 'retain',
8009
+ rootPlaybookId,
8010
+ generation: candidate.generation,
8011
+ };
8012
+ };
8013
+
8014
+ const retainOrClearDisposedRoot = (root: EngagementFrame): void => {
8015
+ const update = retentionUpdateForPriorGeneration(root);
8016
+ if (update !== undefined) {
8017
+ pendingRetentionUpdates.set(update.rootPlaybookId, update);
8018
+ }
8019
+ };
8020
+
8021
+ const recordTerminalRetention = (
8022
+ root: EngagementFrame,
8023
+ result: Extract<PlaybookRunResult, { outcome: 'terminal' }>,
8024
+ ): void => {
8025
+ const rootPlaybookId = root.entry.id;
8026
+ if (!runtimeRetainsGenerations(root.runtime)) {
8027
+ pendingRetentionUpdates.set(rootPlaybookId, {
8028
+ kind: 'clear',
8029
+ rootPlaybookId,
8030
+ });
8031
+ return;
8032
+ }
8033
+ const terminalStateId = result.state.stateId;
8034
+ if (
8035
+ typeof terminalStateId !== 'string' ||
8036
+ terminalStateId.trim().length === 0
8037
+ ) {
8038
+ throw new Error(
8039
+ `${frameLabel(root)} terminal result has no stable state id for retention`,
8040
+ );
8041
+ }
8042
+ const unfinished =
8043
+ root.runtime.retainedGenerationMetadata!.unfinishedFinalStateIds.includes(
8044
+ terminalStateId,
8045
+ );
8046
+ if (unfinished) {
8047
+ // A root opened and terminated within this turn has no pre-turn,
8048
+ // work-bearing generation. Leave any earlier store entry untouched.
8049
+ if (!retainedGenerationCandidates.has(rootPlaybookId)) return;
8050
+ retainOrClearDisposedRoot(root);
8051
+ } else {
8052
+ pendingRetentionUpdates.set(rootPlaybookId, {
8053
+ kind: 'clear',
8054
+ rootPlaybookId,
8055
+ });
8056
+ }
8057
+ };
8058
+
8059
+ const exportShellSnapshot = (): PlaybookCaptainShellSnapshot | undefined => {
8060
+ if (
8061
+ !safeCapturePoint() ||
8062
+ !captainRuntime ||
8063
+ !captainSessionId ||
8064
+ !captainAgent
8065
+ ) {
8066
+ return undefined;
8067
+ }
8068
+ try {
8069
+ if (
8070
+ typeof captainRuntime.exportSnapshot !== 'function' ||
8071
+ typeof captainRuntime.restore !== 'function'
8072
+ ) {
8073
+ return undefined;
8074
+ }
8075
+ const captainSnapshot = captainRuntime.exportSnapshot();
8076
+ if (captainSnapshot === undefined) return undefined;
8077
+ const frameSnapshots = captureFrameSnapshots(true);
8078
+ if (frameSnapshots === undefined) return undefined;
8079
+ const effectLedger = assertPlaybookEffectLedger(currentEffectLedger());
8080
+ refreshRetainedEffectFence(frameSnapshots, effectLedger);
5605
8081
  const common = {
5606
- schemaVersion: 3 as const,
8082
+ schemaVersion: 4 as const,
8083
+ effectLedger,
5607
8084
  captain: {
5608
8085
  sessionId: captainSessionId,
5609
8086
  runtime: captainSnapshot,
@@ -5626,6 +8103,9 @@ export function createPlaybookCaptainShell(
5626
8103
  ...common,
5627
8104
  mode: 'engaged.parked',
5628
8105
  frames: frameSnapshots,
8106
+ ...(retainedEffectReconciliation === undefined
8107
+ ? {}
8108
+ : { retainedEffectReconciliation }),
5629
8109
  ...(pendingBossQuestions === undefined
5630
8110
  ? {}
5631
8111
  : { pendingBossQuestions: pendingBossQuestions as JsonValue }),
@@ -5639,6 +8119,66 @@ export function createPlaybookCaptainShell(
5639
8119
  }
5640
8120
  };
5641
8121
 
8122
+ const exportSettlement = (): PlaybookCaptainSettlement | undefined => {
8123
+ if (!retentionSettlementReady || abandonmentSettlementUnsafe) {
8124
+ return undefined;
8125
+ }
8126
+ const snapshot = exportShellSnapshot();
8127
+ if (snapshot === undefined) return undefined;
8128
+ let unresolvedEffects: readonly PlaybookCaptainUnresolvedEffect[];
8129
+ try {
8130
+ unresolvedEffects =
8131
+ settledTurnUnresolvedEffects ?? currentUnresolvedEffects();
8132
+ } catch {
8133
+ return undefined;
8134
+ }
8135
+ const updates = new Map(pendingRetentionUpdates);
8136
+ for (const rootPlaybookId of retainedGenerationRootClears) {
8137
+ updates.set(rootPlaybookId, { kind: 'clear', rootPlaybookId });
8138
+ }
8139
+ const root = rootFrame();
8140
+ if (root !== undefined) {
8141
+ const rootPlaybookId = root.entry.id;
8142
+ if (frames.every((frame) => runtimeRetainsGenerations(frame.runtime))) {
8143
+ const generation =
8144
+ snapshot.mode === 'engaged.parked'
8145
+ ? retainedGenerationFromFrames(root, snapshot.frames)
8146
+ : undefined;
8147
+ if (generation !== undefined) {
8148
+ updates.set(rootPlaybookId, {
8149
+ kind: 'retain',
8150
+ rootPlaybookId,
8151
+ generation,
8152
+ });
8153
+ }
8154
+ } else if (!runtimeRetainsGenerations(root.runtime)) {
8155
+ updates.set(rootPlaybookId, { kind: 'clear', rootPlaybookId });
8156
+ } else if (retainedGenerationCandidates.has(rootPlaybookId)) {
8157
+ try {
8158
+ const update = retentionUpdateForPriorGeneration(root);
8159
+ if (update !== undefined) {
8160
+ updates.set(rootPlaybookId, update);
8161
+ }
8162
+ } catch {
8163
+ return undefined;
8164
+ }
8165
+ }
8166
+ }
8167
+ for (const update of updates.values()) {
8168
+ applyRetentionUpdateToCatalog(update);
8169
+ }
8170
+ return snapshotJsonValue(
8171
+ {
8172
+ snapshot,
8173
+ retentionUpdates: [...updates.values()].sort((left, right) =>
8174
+ left.rootPlaybookId.localeCompare(right.rootPlaybookId),
8175
+ ),
8176
+ unresolvedEffects,
8177
+ },
8178
+ 'Captain settlement',
8179
+ ) as unknown as PlaybookCaptainSettlement;
8180
+ };
8181
+
5642
8182
  const verifyRestoredRuntime = (
5643
8183
  runtime: PlaybookRuntime,
5644
8184
  expected: PlaybookRuntimeSnapshot,
@@ -5662,6 +8202,9 @@ export function createPlaybookCaptainShell(
5662
8202
  'sequences',
5663
8203
  'pendingBossQuestions',
5664
8204
  'suspendedCall',
8205
+ 'effectLedger',
8206
+ 'retainedEffectSourceSessionId',
8207
+ 'retainedEffectReconciliation',
5665
8208
  ] as const) {
5666
8209
  if (!isDeepStrictEqual(normalized[key], expected[key])) {
5667
8210
  throw new Error(
@@ -5681,6 +8224,17 @@ export function createPlaybookCaptainShell(
5681
8224
  cleanupFailures.push(error);
5682
8225
  }
5683
8226
  }
8227
+ const retainedRuntimes = takeRetainedOfferRuntimes();
8228
+ if (retainedRuntimes.length > 0) {
8229
+ try {
8230
+ await disposeRetainedRuntimeSet(
8231
+ retainedRuntimes,
8232
+ 'retained-generation restore cleanup failed',
8233
+ );
8234
+ } catch (error) {
8235
+ cleanupFailures.push(error);
8236
+ }
8237
+ }
5684
8238
  if (captainRuntime) {
5685
8239
  shuttingDown = true;
5686
8240
  try {
@@ -5697,11 +8251,20 @@ export function createPlaybookCaptainShell(
5697
8251
  byCommand = new Map();
5698
8252
  byId = new Map();
5699
8253
  enablementById = new Map();
8254
+ hostCapabilitiesById = new Map();
8255
+ pendingHostCapabilities = undefined;
8256
+ currentEffectLedger = () => emptyPlaybookEffectLedger();
5700
8257
  captainAgent = undefined;
5701
8258
  captainAdapter = undefined;
5702
8259
  playerAgents = new Map();
5703
8260
  playerLedger.clear();
5704
8261
  playerTransactions.clear();
8262
+ retainedGenerations.clear();
8263
+ ineligibleRetainedGenerations.clear();
8264
+ retainedGenerationRootClears.clear();
8265
+ retainedGenerationsInstalled = false;
8266
+ retainedGenerationInstallationInProgress = false;
8267
+ retainedGenerationInstallationClosed = false;
5705
8268
  session = undefined;
5706
8269
  sessionEmissionsOpen = false;
5707
8270
  closedGateAttempted = false;
@@ -5709,6 +8272,7 @@ export function createPlaybookCaptainShell(
5709
8272
  captainSessionId = undefined;
5710
8273
  conversation = { kind: 'unopened' };
5711
8274
  mode = 'chat';
8275
+ retainedEffectReconciliation = undefined;
5712
8276
  pendingBossQuestions = undefined;
5713
8277
  lastError = undefined;
5714
8278
  journalSeq = 0;
@@ -5738,7 +8302,16 @@ export function createPlaybookCaptainShell(
5738
8302
  lifecycle = 'restoring';
5739
8303
  try {
5740
8304
  const snapshot = assertPlaybookCaptainShellSnapshot(untrusted);
5741
- const built = await buildEnablements(options, loadModule);
8305
+ const built = await buildCurrentEnablements();
8306
+ const builtHostCapabilities = new Map(built.hostCapabilitiesById);
8307
+ const readEffectLedger = () =>
8308
+ effectLedgerMirrorFromCapabilities(builtHostCapabilities);
8309
+ const hostLedger = readEffectLedger();
8310
+ if (!isDeepStrictEqual(snapshot.effectLedger, hostLedger)) {
8311
+ throw new Error(
8312
+ 'Captain shell restore effect ledger does not match current-host authority',
8313
+ );
8314
+ }
5742
8315
  captainAgent = built.captainAgent;
5743
8316
  captainAdapter = captainAgent.adapter;
5744
8317
  playerAgents = built.playerAgents;
@@ -5749,6 +8322,8 @@ export function createPlaybookCaptainShell(
5749
8322
  byCommand = built.byCommand;
5750
8323
  byId = built.byId;
5751
8324
  enablementById = built.enablementById;
8325
+ hostCapabilitiesById = builtHostCapabilities;
8326
+ currentEffectLedger = readEffectLedger;
5752
8327
  for (const [playerId, saved] of Object.entries(snapshot.playerSessions)) {
5753
8328
  playerLedger.set(playerId, {
5754
8329
  adapter: saved.adapter,
@@ -5854,6 +8429,8 @@ export function createPlaybookCaptainShell(
5854
8429
  lastSettlementStatus = snapshot.lastSettlementStatus;
5855
8430
  mode = snapshot.mode;
5856
8431
  if (snapshot.mode === 'engaged.parked') {
8432
+ retainedEffectReconciliation =
8433
+ snapshot.retainedEffectReconciliation;
5857
8434
  pendingBossQuestions = snapshot.pendingBossQuestions;
5858
8435
  lastError = snapshot.lastError;
5859
8436
  }
@@ -5872,6 +8449,74 @@ export function createPlaybookCaptainShell(
5872
8449
  }
5873
8450
  };
5874
8451
 
8452
+ const installRetainedGenerations = async (
8453
+ generations: Readonly<
8454
+ Record<string, PlaybookCaptainRetainedGeneration>
8455
+ >,
8456
+ ): Promise<void> => {
8457
+ if (lifecycle !== 'ready' || terminallyDisposed) {
8458
+ throw new Error(
8459
+ 'retained generations require an initialized or restored Captain shell',
8460
+ );
8461
+ }
8462
+ if (
8463
+ retainedGenerationsInstalled ||
8464
+ retainedGenerationInstallationInProgress ||
8465
+ retainedGenerationInstallationClosed ||
8466
+ activeTurnHostCalls !== undefined
8467
+ ) {
8468
+ throw new Error(
8469
+ 'retained generations may be installed exactly once before the first nonempty Boss turn',
8470
+ );
8471
+ }
8472
+ const normalized = normalizeInstalledRetainedGenerations(generations);
8473
+ retainedGenerationInstallationInProgress = true;
8474
+ try {
8475
+ retainedGenerations.clear();
8476
+ retainedGenerationOffers.clear();
8477
+ ineligibleRetainedGenerations.clear();
8478
+ retainedGenerationRootClears.clear();
8479
+ for (const [rootPlaybookId, generation] of normalized) {
8480
+ retainedGenerations.set(rootPlaybookId, generation);
8481
+ }
8482
+ await prepareRetainedGenerationOffers();
8483
+ retainedGenerationsInstalled = true;
8484
+ } catch (error) {
8485
+ const preparationCleanupFailed =
8486
+ error instanceof RetainedRuntimeCleanupError;
8487
+ const runtimes = [...retainedGenerationOffers.values()].flatMap(
8488
+ (offer) => [...offer.runtimes],
8489
+ );
8490
+ retainedGenerationOffers.clear();
8491
+ retainedGenerations.clear();
8492
+ ineligibleRetainedGenerations.clear();
8493
+ retainedGenerationRootClears.clear();
8494
+ try {
8495
+ await disposeRetainedRuntimeSet(
8496
+ runtimes,
8497
+ 'retained-generation installation cleanup failed',
8498
+ );
8499
+ } catch (cleanupError) {
8500
+ if (cleanupError instanceof RetainedRuntimeCleanupError) {
8501
+ retiredRetainedRuntimes.push(...cleanupError.failedRuntimes);
8502
+ }
8503
+ terminallyDisposed = true;
8504
+ lifecycle = 'closed';
8505
+ throw new AggregateError(
8506
+ [error, cleanupError],
8507
+ 'retained-generation installation and cleanup failed',
8508
+ );
8509
+ }
8510
+ if (preparationCleanupFailed) {
8511
+ terminallyDisposed = true;
8512
+ lifecycle = 'closed';
8513
+ }
8514
+ throw error;
8515
+ } finally {
8516
+ retainedGenerationInstallationInProgress = false;
8517
+ }
8518
+ };
8519
+
5875
8520
  return {
5876
8521
  async init(initSession: CaptainSession): Promise<void> {
5877
8522
  if (lifecycle !== 'fresh' || terminallyDisposed) {
@@ -5883,11 +8528,17 @@ export function createPlaybookCaptainShell(
5883
8528
  lifecycle = 'initializing';
5884
8529
  try {
5885
8530
  installSession(initSession, true);
5886
- const built = await buildEnablements(options, loadModule);
8531
+ const built = await buildCurrentEnablements();
8532
+ const builtHostCapabilities = new Map(built.hostCapabilitiesById);
8533
+ const readEffectLedger = () =>
8534
+ effectLedgerMirrorFromCapabilities(builtHostCapabilities);
8535
+ readEffectLedger();
5887
8536
  entries = built.entries;
5888
8537
  byCommand = built.byCommand;
5889
8538
  byId = built.byId;
5890
8539
  enablementById = built.enablementById;
8540
+ hostCapabilitiesById = builtHostCapabilities;
8541
+ currentEffectLedger = readEffectLedger;
5891
8542
  captainAgent = built.captainAgent;
5892
8543
  captainAdapter = captainAgent.adapter;
5893
8544
  playerAgents = built.playerAgents;
@@ -5913,8 +8564,12 @@ export function createPlaybookCaptainShell(
5913
8564
 
5914
8565
  exportSnapshot: exportShellSnapshot,
5915
8566
 
8567
+ exportSettlement,
8568
+
5916
8569
  restore: restoreShellSnapshot,
5917
8570
 
8571
+ installRetainedGenerations,
8572
+
5918
8573
  async handleBossTurn(
5919
8574
  turn: BossTurn,
5920
8575
  context: CaptainContext,
@@ -5931,9 +8586,26 @@ export function createPlaybookCaptainShell(
5931
8586
  if (activeTurnHostCalls !== undefined) {
5932
8587
  throw new Error('cannot handle concurrent Boss turns');
5933
8588
  }
8589
+ if (retainedGenerationInstallationInProgress) {
8590
+ throw new Error(
8591
+ 'cannot handle a Boss turn while retained generations are installing',
8592
+ );
8593
+ }
5934
8594
  // Empty or whitespace-only input allocates no call, session, or
5935
8595
  // telemetry (CAPTAIN-7).
8596
+ retentionSettlementReady = false;
8597
+ abandonmentSettlementUnsafe = false;
5936
8598
  if (turn.prompt.trim().length === 0) return;
8599
+ settledTurnUnresolvedEffects = undefined;
8600
+ retainedGenerationInstallationClosed = true;
8601
+ for (const update of pendingRetentionUpdates.values()) {
8602
+ applyRetentionUpdateToCatalog(update);
8603
+ }
8604
+ retainedGenerationCandidates.clear();
8605
+ pendingRetentionUpdates.clear();
8606
+ // A terminal or dismissal can remove the whole stack during this turn;
8607
+ // take the latest already-settled generation before controller work.
8608
+ rememberRetainedGeneration();
5937
8609
  const turnHostCalls = new Set<Promise<unknown>>();
5938
8610
  activeTurnHostCalls = turnHostCalls;
5939
8611
  activeContext = context;
@@ -5956,6 +8628,8 @@ export function createPlaybookCaptainShell(
5956
8628
  decisionCall = undefined;
5957
8629
  appendJournal('boss', turn.prompt);
5958
8630
  try {
8631
+ await drainRetiredRetainedRuntimes();
8632
+ await prepareRetainedGenerationOffers();
5959
8633
  const result = await captainRuntime.handleBossInput({
5960
8634
  text: turn.prompt,
5961
8635
  signal: context.signal,
@@ -6027,11 +8701,16 @@ export function createPlaybookCaptainShell(
6027
8701
  activeTurnHostCalls = undefined;
6028
8702
  }
6029
8703
  activeContext = undefined;
8704
+ retentionSettlementReady = true;
6030
8705
  }
6031
8706
  },
6032
8707
 
6033
8708
  async prepareDispose(): Promise<void> {
6034
- if (lifecycle === 'initializing' || lifecycle === 'restoring') {
8709
+ if (
8710
+ lifecycle === 'initializing' ||
8711
+ lifecycle === 'restoring' ||
8712
+ retainedGenerationInstallationInProgress
8713
+ ) {
6035
8714
  throw new Error('cannot dispose while Captain shell setup is in progress');
6036
8715
  }
6037
8716
  activeContext = undefined;
@@ -6039,7 +8718,11 @@ export function createPlaybookCaptainShell(
6039
8718
  },
6040
8719
 
6041
8720
  async dispose(): Promise<void> {
6042
- if (lifecycle === 'initializing' || lifecycle === 'restoring') {
8721
+ if (
8722
+ lifecycle === 'initializing' ||
8723
+ lifecycle === 'restoring' ||
8724
+ retainedGenerationInstallationInProgress
8725
+ ) {
6043
8726
  throw new Error('cannot dispose while Captain shell setup is in progress');
6044
8727
  }
6045
8728
  activeContext = undefined;
@@ -6058,6 +8741,20 @@ export function createPlaybookCaptainShell(
6058
8741
  } catch (error) {
6059
8742
  failure = error;
6060
8743
  }
8744
+ const retainedRuntimes = takeRetainedOfferRuntimes();
8745
+ if (retainedRuntimes.length > 0) {
8746
+ try {
8747
+ await disposeRetainedRuntimeSet(
8748
+ retainedRuntimes,
8749
+ 'retained-generation shell cleanup failed',
8750
+ );
8751
+ } catch (error) {
8752
+ failure ??= error;
8753
+ }
8754
+ }
8755
+ retainedGenerations.clear();
8756
+ ineligibleRetainedGenerations.clear();
8757
+ retainedGenerationRootClears.clear();
6061
8758
  const runtime = captainRuntime;
6062
8759
  captainRuntime = undefined;
6063
8760
  if (runtime) {
@@ -6071,6 +8768,9 @@ export function createPlaybookCaptainShell(
6071
8768
  // Quarantine is session-wide by design. Only terminal teardown may drop
6072
8769
  // its ownership after every frame host call and the Captain are drained.
6073
8770
  playerTransactions.clear();
8771
+ hostCapabilitiesById = new Map();
8772
+ pendingHostCapabilities = undefined;
8773
+ currentEffectLedger = () => emptyPlaybookEffectLedger();
6074
8774
  lifecycle = 'closed';
6075
8775
  if (failure !== undefined) throw failure;
6076
8776
  }