@sublang/playbook 9.0.0 → 11.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 (71) hide show
  1. package/README.md +1 -1
  2. package/docs/cli.md +109 -21
  3. package/docs/configuration.md +89 -40
  4. package/docs/embedding.md +126 -12
  5. package/package.json +14 -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 +140 -10
  13. package/reference/sdlc/captain.playbook/captain.playbook.ts +188 -16
  14. package/reference/sdlc/code.md +35 -16
  15. package/reference/sdlc/code.playbook/bin/interactive-session.js +228 -23
  16. package/reference/sdlc/code.playbook/bin/launch-config.js +611 -221
  17. package/reference/sdlc/code.playbook/bin/playbook.js +304 -178
  18. package/reference/sdlc/code.playbook/bin/replay-observer.js +221 -0
  19. package/reference/sdlc/code.playbook/bin/repository-effects.js +2930 -0
  20. package/reference/sdlc/code.playbook/bin/run.js +669 -215
  21. package/reference/sdlc/code.playbook/bin/session-store.js +4546 -502
  22. package/reference/sdlc/code.playbook/code.fsm.d.ts +7 -0
  23. package/reference/sdlc/code.playbook/code.fsm.js +74 -25
  24. package/reference/sdlc/code.playbook/code.fsm.ts +83 -29
  25. package/reference/sdlc/code.playbook/code.gears.md +0 -2
  26. package/reference/sdlc/code.playbook/code.playbook.d.ts +5 -2
  27. package/reference/sdlc/code.playbook/code.playbook.js +54 -2
  28. package/reference/sdlc/code.playbook/code.playbook.ts +75 -6
  29. package/reference/sdlc/code.playbook/code.registry.d.ts +10 -3
  30. package/reference/sdlc/code.playbook/code.registry.js +10 -3
  31. package/reference/sdlc/code.playbook/code.registry.ts +23 -5
  32. package/reference/sdlc/code.playbook/playbook-captain.d.ts +103 -8
  33. package/reference/sdlc/code.playbook/playbook-captain.js +1871 -75
  34. package/reference/sdlc/code.playbook/playbook-captain.ts +2801 -102
  35. package/reference/sdlc/code.playbook/playbook.config.template.yaml +14 -10
  36. package/reference/sdlc/code.playbook/session-store.d.ts +82 -0
  37. package/reference/sdlc/code.playbook/session-store.js +113 -0
  38. package/reference/sdlc/decide.md +24 -16
  39. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +7 -0
  40. package/reference/sdlc/decide.playbook/decide.fsm.js +80 -29
  41. package/reference/sdlc/decide.playbook/decide.fsm.ts +89 -31
  42. package/reference/sdlc/decide.playbook/decide.gears.md +0 -1
  43. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +13 -5
  44. package/reference/sdlc/decide.playbook/decide.playbook.js +1712 -91
  45. package/reference/sdlc/decide.playbook/decide.playbook.ts +2677 -136
  46. package/reference/sdlc/decide.playbook/decide.registry.d.ts +7 -3
  47. package/reference/sdlc/decide.playbook/decide.registry.js +10 -3
  48. package/reference/sdlc/decide.playbook/decide.registry.ts +20 -5
  49. package/reference/sdlc/review.md +36 -18
  50. package/reference/sdlc/review.playbook/review.fsm.d.ts +7 -0
  51. package/reference/sdlc/review.playbook/review.fsm.js +133 -12
  52. package/reference/sdlc/review.playbook/review.fsm.ts +140 -12
  53. package/reference/sdlc/review.playbook/review.playbook.d.ts +5 -2
  54. package/reference/sdlc/review.playbook/review.playbook.js +65 -2
  55. package/reference/sdlc/review.playbook/review.playbook.ts +83 -6
  56. package/reference/sdlc/review.playbook/review.registry.d.ts +10 -3
  57. package/reference/sdlc/review.playbook/review.registry.js +10 -3
  58. package/reference/sdlc/review.playbook/review.registry.ts +23 -5
  59. package/slc/gears2fsm.md +6 -5
  60. package/slc/link.md +544 -41
  61. package/src/accepted-outcome.d.ts +18 -0
  62. package/src/accepted-outcome.js +94 -0
  63. package/src/accepted-outcome.ts +140 -0
  64. package/src/runtime.d.ts +164 -3
  65. package/src/runtime.ts +213 -2
  66. package/src/xstate-playbook-runtime.d.ts +149 -10
  67. package/src/xstate-playbook-runtime.js +2569 -270
  68. package/src/xstate-playbook-runtime.ts +4133 -490
  69. package/src/xstate-runtime.d.ts +59 -1
  70. package/src/xstate-runtime.js +866 -7
  71. package/src/xstate-runtime.ts +1397 -7
@@ -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,
@@ -50,6 +55,9 @@ interface SessionAgent {
50
55
  readonly adapter: string;
51
56
  readonly model: TuningSelection;
52
57
  readonly effort: TuningSelection<Effort>;
58
+ /** Adapter-scoped fast mode. Absence is the provider default; `false` is a
59
+ * literal request, so this carries no provider-default sentinel. */
60
+ readonly fastMode?: boolean;
53
61
  readonly instruction?: string;
54
62
  readonly permissions?: PermissionPolicy;
55
63
  }
@@ -69,8 +77,27 @@ type DeepReadonly<T> = T extends (...args: never[]) => unknown
69
77
  ? { readonly [Key in keyof T]: DeepReadonly<T[Key]> }
70
78
  : T;
71
79
 
80
+ export interface PlaybookCaptainUnresolvedEffect {
81
+ readonly classification:
82
+ | 'one-descendant-commit'
83
+ | 'multiple-commits'
84
+ | 'rewritten-or-non-descendant'
85
+ | 'worktree-only-change'
86
+ | 'concurrent-or-foreign-change'
87
+ | 'observation-ambiguous'
88
+ | 'incomplete';
89
+ readonly baselineHead: string;
90
+ readonly afterHead?: string;
91
+ readonly commitOid?: string;
92
+ }
93
+
94
+ interface PlaybookCaptainUnresolvedEffectSettlementInput {
95
+ readonly rootPlaybookId: string;
96
+ readonly unresolvedEffects: readonly PlaybookCaptainUnresolvedEffect[];
97
+ }
98
+
72
99
  type SnapshotAgentEnvelope = DeepReadonly<
73
- Omit<SessionAgent, 'model' | 'effort'>
100
+ Omit<SessionAgent, 'model' | 'effort' | 'fastMode'>
74
101
  >;
75
102
 
76
103
  type PlayerLedgerSnapshotEntry = DeepReadonly<PlayerLedgerEntry>;
@@ -78,6 +105,9 @@ type PlayerLedgerSnapshotEntry = DeepReadonly<PlayerLedgerEntry>;
78
105
  export interface PlaybookCaptainDeps {
79
106
  loadModule?: (specifier: string) => Promise<unknown>;
80
107
  createSessionId?: () => string;
108
+ hostCapabilities?: Readonly<
109
+ Record<string, PlaybookHostConstructionCapabilities>
110
+ >;
81
111
  createCaptainRuntime?: (options: {
82
112
  readonly enabledPlaybooks: readonly {
83
113
  readonly id: string;
@@ -86,20 +116,81 @@ export interface PlaybookCaptainDeps {
86
116
  }[];
87
117
  readonly controller: CaptainControllerPort;
88
118
  }) => PlaybookRuntime;
119
+ unresolvedEffectSettlement?: {
120
+ begin(input: PlaybookCaptainUnresolvedEffectSettlementInput): Promise<void>;
121
+ complete(input: PlaybookCaptainUnresolvedEffectSettlementInput): Promise<void>;
122
+ };
123
+ }
124
+
125
+ /** Live, artifact-typed host facilities supplied outside configured options. */
126
+ export interface PlaybookHostConstructionCapabilities {
127
+ readonly authority: {
128
+ readonly playbookId: string;
129
+ readonly artifactSchema: 3;
130
+ readonly cwd: string;
131
+ readonly sessionId: string;
132
+ readonly leaseOwnerToken: string;
133
+ readonly canonicalWorktree: {
134
+ readonly worktree: string;
135
+ readonly gitDir: string;
136
+ };
137
+ readonly requiredRoleIds: readonly string[];
138
+ readonly concurrentRoleSets: readonly (readonly string[])[];
139
+ };
140
+ readonly repository: {
141
+ readonly identity: {
142
+ readonly worktree: string;
143
+ readonly gitDir: string;
144
+ };
145
+ readonly observe: (options?: unknown) => Promise<unknown>;
146
+ readonly acquire: (options?: unknown) => Promise<unknown>;
147
+ readonly runExclusive: (options: unknown) => Promise<unknown>;
148
+ readonly runCohort: (options: unknown) => Promise<unknown>;
149
+ readonly runDeferred: (options: unknown) => Promise<unknown>;
150
+ };
151
+ readonly effectLedger: {
152
+ readonly snapshot: () => PlaybookEffectLedger;
153
+ readonly writeAhead: (
154
+ commands: PlaybookEffectLedgerCommandBatch,
155
+ ) => Promise<PlaybookEffectLedger>;
156
+ };
89
157
  }
90
158
 
91
- export interface PlaybookCaptainRegistryEntry {
159
+ export type PlaybookCaptainRuntimeProfile =
160
+ | {
161
+ readonly kind: 'shared-factory';
162
+ readonly compat: {
163
+ readonly artifactSchema: 3;
164
+ readonly runtimeAbi: number;
165
+ };
166
+ }
167
+ | {
168
+ readonly kind: 'bespoke';
169
+ readonly artifactSchema: 3;
170
+ };
171
+
172
+ interface PlaybookCaptainRegistryEntryBase {
92
173
  id: string;
93
174
  command: string;
94
175
  intent: string;
95
- artifactSchema: 2;
176
+ runtimeProfile: PlaybookCaptainRuntimeProfile;
96
177
  requiredRoleIds: readonly string[];
97
178
  concurrentRoleSets: readonly (readonly string[])[];
98
179
  summaryPolicy?: PlaybookSummaryPolicy;
99
180
  validateOptions(optionSlice: unknown): unknown;
100
- createRuntime(options: unknown): PlaybookRuntime;
101
181
  }
102
182
 
183
+ export interface PlaybookCaptainRegistryEntryV3
184
+ extends PlaybookCaptainRegistryEntryBase {
185
+ artifactSchema: 3;
186
+ createRuntime(
187
+ configuredOptions: unknown,
188
+ hostCapabilities: PlaybookHostConstructionCapabilities,
189
+ ): PlaybookRuntime;
190
+ }
191
+
192
+ export type PlaybookCaptainRegistryEntry = PlaybookCaptainRegistryEntryV3;
193
+
103
194
  type PlaybookCaptainConversationSnapshot =
104
195
  | { readonly kind: 'unopened' }
105
196
  | { readonly kind: 'pinned'; readonly token: string }
@@ -117,7 +208,7 @@ interface PlaybookCaptainJournalRecord {
117
208
  readonly payload: JsonValue;
118
209
  }
119
210
 
120
- interface PlaybookCaptainFrameSnapshot {
211
+ export interface PlaybookCaptainFrameSnapshot {
121
212
  readonly playbookId: string;
122
213
  readonly sessionId: string;
123
214
  readonly rootSessionId: string;
@@ -130,7 +221,8 @@ interface PlaybookCaptainFrameSnapshot {
130
221
  }
131
222
 
132
223
  interface PlaybookCaptainShellSnapshotFields {
133
- readonly schemaVersion: 3;
224
+ readonly schemaVersion: 4;
225
+ readonly effectLedger: DeepReadonly<PlaybookEffectLedger>;
134
226
  readonly captain: {
135
227
  readonly sessionId: string;
136
228
  readonly runtime: DeepReadonly<PlaybookRuntimeSnapshot>;
@@ -149,6 +241,7 @@ interface PlaybookCaptainShellSnapshotFields {
149
241
  | 'respond'
150
242
  | 'start'
151
243
  | 'switch'
244
+ | 'resume'
152
245
  | 'dismiss'
153
246
  | 'deliver'
154
247
  | 'runtime';
@@ -172,6 +265,7 @@ type PlaybookCaptainShellSnapshotValue =
172
265
  readonly mode: 'engaged.parked';
173
266
  /** Root-to-leaf engagement order. */
174
267
  readonly frames: readonly PlaybookCaptainFrameSnapshot[];
268
+ readonly retainedEffectReconciliation?: PlaybookCaptainRetainedEffectReconciliation;
175
269
  readonly pendingBossQuestions?: JsonValue;
176
270
  readonly lastError?: { readonly name: string; readonly message: string };
177
271
  }
@@ -180,9 +274,76 @@ type PlaybookCaptainShellSnapshotValue =
180
274
  export type PlaybookCaptainShellSnapshot =
181
275
  DeepReadonly<PlaybookCaptainShellSnapshotValue>;
182
276
 
277
+ export interface PlaybookCaptainRetainedGeneration {
278
+ /** Repository-effect checkpoint reflected by the retained machine state. */
279
+ readonly effectLedger: DeepReadonly<PlaybookEffectLedger>;
280
+ readonly frames: readonly PlaybookCaptainFrameSnapshot[];
281
+ readonly retainedEffectReconciliation?: {
282
+ readonly sourceGenerationId: string;
283
+ };
284
+ /** Boss-facing description published for the retained root state, if any. */
285
+ readonly rootStateDescription?: string;
286
+ }
287
+
288
+ interface PlaybookCaptainRetainedEffectReconciliation {
289
+ readonly sourceGenerationId: string;
290
+ readonly checkpoint: DeepReadonly<PlaybookEffectLedger>;
291
+ }
292
+
293
+ function retainedEffectLedgerCanRebase(
294
+ checkpoint: PlaybookEffectLedger,
295
+ current: PlaybookEffectLedger,
296
+ ): boolean {
297
+ if (
298
+ checkpoint.boundaries.some(
299
+ ({ physicalReceipt }) => physicalReceipt === undefined,
300
+ )
301
+ ) {
302
+ return false;
303
+ }
304
+ if (!isPlaybookEffectLedgerMonotonicExtension(checkpoint, current)) {
305
+ return false;
306
+ }
307
+ if (
308
+ !isDeepStrictEqual(
309
+ current.boundaries.slice(0, checkpoint.boundaries.length),
310
+ checkpoint.boundaries,
311
+ ) ||
312
+ !isDeepStrictEqual(current.logicalOperations, checkpoint.logicalOperations)
313
+ ) {
314
+ return false;
315
+ }
316
+ return current.boundaries
317
+ .slice(checkpoint.boundaries.length)
318
+ .every(
319
+ ({ physicalReceipt }) =>
320
+ physicalReceipt?.classification === 'unchanged',
321
+ );
322
+ }
323
+
324
+ export type PlaybookCaptainRetentionUpdate =
325
+ | {
326
+ readonly kind: 'retain';
327
+ readonly rootPlaybookId: string;
328
+ readonly generation: PlaybookCaptainRetainedGeneration;
329
+ }
330
+ | { readonly kind: 'clear'; readonly rootPlaybookId: string };
331
+
332
+ export interface PlaybookCaptainSettlement {
333
+ readonly snapshot: PlaybookCaptainShellSnapshot;
334
+ readonly retentionUpdates: readonly PlaybookCaptainRetentionUpdate[];
335
+ readonly unresolvedEffects: readonly PlaybookCaptainUnresolvedEffect[];
336
+ }
337
+
183
338
  /** tmux and headless front ends share this one durable Captain shell API. */
184
339
  export interface PlaybookCaptainShell extends Captain {
340
+ installRetainedGenerations(
341
+ generations: Readonly<
342
+ Record<string, PlaybookCaptainRetainedGeneration>
343
+ >,
344
+ ): Promise<void>;
185
345
  exportSnapshot(): PlaybookCaptainShellSnapshot | undefined;
346
+ exportSettlement(): PlaybookCaptainSettlement | undefined;
186
347
  restore(
187
348
  session: CaptainSession,
188
349
  snapshot: PlaybookCaptainShellSnapshot,
@@ -193,15 +354,33 @@ export interface PlaybookCaptainShell extends Captain {
193
354
  // normalized `captain.options.playbooks.<id>` role map.
194
355
  interface Enablement {
195
356
  entry: PlaybookCaptainRegistryEntry;
357
+ artifactSchema: 3;
196
358
  command: string;
197
359
  options: JsonValue;
198
360
  roleBindings: ReadonlyMap<string, EffectivePlayerBinding>;
199
361
  }
200
362
 
363
+ function createRuntimeForEnablement(
364
+ enablement: Enablement,
365
+ hostCapabilitiesById: ReadonlyMap<
366
+ string,
367
+ PlaybookHostConstructionCapabilities
368
+ >,
369
+ ): PlaybookRuntime {
370
+ const hostCapabilities = hostCapabilitiesById.get(enablement.entry.id);
371
+ if (hostCapabilities === undefined) {
372
+ throw new Error(
373
+ `/${enablement.command} schema-3 runtime requires current-host construction capabilities`,
374
+ );
375
+ }
376
+ return enablement.entry.createRuntime(enablement.options, hostCapabilities);
377
+ }
378
+
201
379
  interface EffectivePlayerBinding {
202
380
  readonly playerId: string;
203
381
  readonly model: TuningSelection;
204
382
  readonly effort: TuningSelection<Effort>;
383
+ readonly fastMode?: boolean;
205
384
  readonly agent: SessionAgent;
206
385
  }
207
386
 
@@ -289,7 +468,12 @@ async function classifySettingsCall<T>(call: () => Promise<T>): Promise<T> {
289
468
  }
290
469
  }
291
470
 
292
- type DisposalReason = 'dismiss' | 'final' | 'dispose' | 'failure';
471
+ type DisposalReason =
472
+ | 'dismiss'
473
+ | 'final'
474
+ | 'dispose'
475
+ | 'failure'
476
+ | 'unresolved-effect';
293
477
 
294
478
  interface ControlLedger {
295
479
  activePlaybookId?: string;
@@ -366,6 +550,12 @@ const UUID_PATTERN =
366
550
  /^[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
551
  const PLAYER_ID_PATTERN = /^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)*$/;
368
552
  const ROLE_ID_PATTERN = /^[a-z][a-z0-9_-]*$/;
553
+ const HOST_CAPABILITIES_OPTION_KEY = 'hostCapabilities';
554
+ const UNRESOLVED_EFFECT_RECONCILIATION_ACTION_ID =
555
+ 'reconcile:unresolved-effect';
556
+ const UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID = 'abandon:unresolved-effect';
557
+ const RESUMPTION_DUPLICATE_EFFECT_WARNING =
558
+ 'Warning: resumption may duplicate external effects attempted after the retained boundary; verify the current world before continuing.';
369
559
 
370
560
  interface TurnSummaryCounts {
371
561
  interruptions: number;
@@ -376,6 +566,7 @@ interface ActiveTurnSummary {
376
566
  owner: EngagementFrame;
377
567
  counts: TurnSummaryCounts;
378
568
  stateCounts: Map<string, number>;
569
+ acceptedOutcomeTraceKeys: Set<string>;
379
570
  }
380
571
 
381
572
  /** The shell state of one Boss turn (DR-029). */
@@ -402,6 +593,8 @@ interface ActiveTurn {
402
593
  presentationAttempted: boolean;
403
594
  /** The exact rejected presentation boundary, propagated without retry. */
404
595
  presentationError?: unknown;
596
+ /** Shell-authored safety text that must accompany the one visible reply. */
597
+ mandatoryPresentationSuffix?: string;
405
598
  /** Facts accumulated while the selected action runs, including partial work. */
406
599
  readonly settlementFacts: string[];
407
600
  report?: OutcomeReport;
@@ -416,8 +609,8 @@ interface ActiveTurn {
416
609
  readonly settingsPreflightFailures: Set<unknown>;
417
610
  /**
418
611
  * 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.
612
+ * driven or adopted, an engagement constructed, a stack disposed, an
613
+ * advertised action applied — recorded by `runEffect` at the throw itself.
421
614
  *
422
615
  * Attribution follows the operation that threw rather than a latch set
423
616
  * before it. A latch is turn-scoped, so once any effect has been attempted
@@ -446,6 +639,8 @@ interface ActiveTurn {
446
639
  outcomePending?: boolean;
447
640
  /** Whether this turn already has a closing journal outcome. */
448
641
  outcomeRecorded: boolean;
642
+ /** Canonical host evidence frozen before the controller result phase. */
643
+ unresolvedEffects?: readonly PlaybookCaptainUnresolvedEffect[];
449
644
  }
450
645
 
451
646
  function parseRegisteredCommand(
@@ -905,11 +1100,62 @@ interface OutcomeReport {
905
1100
  savedLine?: string;
906
1101
  }
907
1102
 
1103
+ type ControllerSettlementDraft = Omit<
1104
+ SettlementEvidence,
1105
+ 'unresolvedEffects'
1106
+ >;
1107
+
1108
+ function unresolvedEffectReportLines(
1109
+ unresolvedEffects: readonly PlaybookCaptainUnresolvedEffect[],
1110
+ ): readonly string[] {
1111
+ return unresolvedEffects.map((effect, index) => {
1112
+ const merelyPossible =
1113
+ effect.classification === 'observation-ambiguous' ||
1114
+ effect.classification === 'incomplete';
1115
+ return [
1116
+ `${index + 1}. ${merelyPossible ? 'Possible repository effect; a change could not be excluded' : 'Observed repository change'} (${effect.classification})`,
1117
+ `baseline HEAD ${effect.baselineHead}`,
1118
+ effect.afterHead === undefined
1119
+ ? 'after HEAD was not available'
1120
+ : `after HEAD ${effect.afterHead}`,
1121
+ ...(effect.commitOid === undefined
1122
+ ? []
1123
+ : [`proven commit OID ${effect.commitOid}`]),
1124
+ ].join('; ') + '.';
1125
+ });
1126
+ }
1127
+
1128
+ function unresolvedEffectBossReport(
1129
+ unresolvedEffects: readonly PlaybookCaptainUnresolvedEffect[],
1130
+ ): string | undefined {
1131
+ if (unresolvedEffects.length === 0) return undefined;
1132
+ return [
1133
+ 'Repository-effect evidence:',
1134
+ ...unresolvedEffectReportLines(unresolvedEffects).map((line) => `- ${line}`),
1135
+ 'This evidence does not establish workflow completion or attribute any repository change or commit to this workflow.',
1136
+ ].join('\n');
1137
+ }
1138
+
1139
+ function appendMandatoryPresentationSuffix(
1140
+ turn: ActiveTurn,
1141
+ suffix: string,
1142
+ ): void {
1143
+ const current = turn.mandatoryPresentationSuffix;
1144
+ if (current === undefined) {
1145
+ turn.mandatoryPresentationSuffix = suffix;
1146
+ } else if (!current.includes(suffix)) {
1147
+ turn.mandatoryPresentationSuffix = `${current}\n\n${suffix}`;
1148
+ }
1149
+ }
1150
+
908
1151
  // CAPTAIN-20: the result-phase block the shell supplies inside the closing
909
1152
  // reply call's envelope — the settlement's outcome-report facts verbatim, the
910
1153
  // exact counts, and the saved-counts line only when counted activity is
911
1154
  // nonzero.
912
- function outcomeReportBlock(report: OutcomeReport): string {
1155
+ function outcomeReportBlock(
1156
+ report: OutcomeReport,
1157
+ unresolvedEffects: readonly PlaybookCaptainUnresolvedEffect[],
1158
+ ): string {
913
1159
  const lines: string[] = [
914
1160
  `Settlement status: ${report.status}`,
915
1161
  ...(report.playbookId === undefined
@@ -935,6 +1181,14 @@ function outcomeReportBlock(report: OutcomeReport): string {
935
1181
  if (report.leafStateSummary !== undefined) {
936
1182
  lines.push(`Resulting leaf state: ${report.leafStateSummary}`);
937
1183
  }
1184
+ const effectLines = unresolvedEffectReportLines(unresolvedEffects);
1185
+ if (effectLines.length > 0) {
1186
+ lines.push('Repository-effect evidence (canonical, in ledger order):');
1187
+ lines.push(...effectLines.map((line) => `- ${line}`));
1188
+ lines.push(
1189
+ 'Report this evidence without claiming workflow completion or attributing any repository change or commit to the workflow.',
1190
+ );
1191
+ }
938
1192
  lines.push(`Progress counts: ${report.progressPhrase}`);
939
1193
  lines.push(
940
1194
  `Counts: ${JSON.stringify({
@@ -984,12 +1238,209 @@ function summaryProgressRoundCount(
984
1238
  return [...stateCounts.values()].reduce((total, count) => total + count, 0);
985
1239
  }
986
1240
 
987
- function guardFromJudgeReply(finalText: string): string | undefined {
988
- return /"guard"\s*:\s*"([^"]+)"/.exec(finalText)?.[1];
1241
+ interface ValidatedRuntimeProfile {
1242
+ readonly kind: 'shared-factory' | 'bespoke';
1243
+ readonly artifactSchema: 3;
1244
+ }
1245
+
1246
+ function captureRegistryEntry(value: unknown): unknown {
1247
+ if (value === null || typeof value !== 'object') return value;
1248
+ const source = value as Record<string, unknown>;
1249
+ return {
1250
+ id: source.id,
1251
+ command: source.command,
1252
+ intent: source.intent,
1253
+ artifactSchema: source.artifactSchema,
1254
+ runtimeProfile: source.runtimeProfile,
1255
+ requiredRoleIds: source.requiredRoleIds,
1256
+ concurrentRoleSets: source.concurrentRoleSets,
1257
+ summaryPolicy: source.summaryPolicy,
1258
+ validateOptions: source.validateOptions,
1259
+ createRuntime: source.createRuntime,
1260
+ };
1261
+ }
1262
+
1263
+ function exactOwnDataRecord(
1264
+ value: unknown,
1265
+ keys: readonly string[],
1266
+ ): Readonly<Record<string, unknown>> | undefined {
1267
+ if (
1268
+ value === null ||
1269
+ typeof value !== 'object' ||
1270
+ Array.isArray(value) ||
1271
+ (Object.getPrototypeOf(value) !== Object.prototype &&
1272
+ Object.getPrototypeOf(value) !== null)
1273
+ ) {
1274
+ return undefined;
1275
+ }
1276
+ const descriptors = Object.getOwnPropertyDescriptors(value);
1277
+ if (
1278
+ Reflect.ownKeys(descriptors).length !== keys.length ||
1279
+ keys.some(
1280
+ (key) =>
1281
+ !Object.hasOwn(descriptors, key) ||
1282
+ !Object.hasOwn(descriptors[key]!, 'value') ||
1283
+ descriptors[key]!.enumerable !== true,
1284
+ )
1285
+ ) {
1286
+ return undefined;
1287
+ }
1288
+ return Object.fromEntries(
1289
+ keys.map((key) => [key, descriptors[key]!.value]),
1290
+ );
1291
+ }
1292
+
1293
+ function captureHostCapabilityRecord(
1294
+ value: PlaybookCaptainDeps['hostCapabilities'],
1295
+ ): Readonly<Record<string, PlaybookHostConstructionCapabilities>> {
1296
+ if (value === undefined) return Object.freeze({});
1297
+ const captured = exactOwnDataRecord(value, Object.keys(value));
1298
+ if (captured === undefined) {
1299
+ throw new TypeError(
1300
+ 'current-host construction capabilities must be an exact data-property record',
1301
+ );
1302
+ }
1303
+ return captured as Readonly<
1304
+ Record<string, PlaybookHostConstructionCapabilities>
1305
+ >;
1306
+ }
1307
+
1308
+ function validateHostCapabilities(
1309
+ value: unknown,
1310
+ entry: PlaybookCaptainRegistryEntryV3,
1311
+ command: string,
1312
+ ): PlaybookHostConstructionCapabilities {
1313
+ if (value === undefined) {
1314
+ throw new Error(
1315
+ `/${command} schema-3 runtime requires current-host construction capabilities`,
1316
+ );
1317
+ }
1318
+ const capability = exactOwnDataRecord(value, [
1319
+ 'authority',
1320
+ 'repository',
1321
+ 'effectLedger',
1322
+ ]);
1323
+ const authority = exactOwnDataRecord(capability?.authority, [
1324
+ 'playbookId',
1325
+ 'artifactSchema',
1326
+ 'cwd',
1327
+ 'sessionId',
1328
+ 'leaseOwnerToken',
1329
+ 'canonicalWorktree',
1330
+ 'requiredRoleIds',
1331
+ 'concurrentRoleSets',
1332
+ ]);
1333
+ const canonicalWorktree = exactOwnDataRecord(
1334
+ authority?.canonicalWorktree,
1335
+ ['worktree', 'gitDir'],
1336
+ );
1337
+ const repository = exactOwnDataRecord(capability?.repository, [
1338
+ 'identity',
1339
+ 'observe',
1340
+ 'acquire',
1341
+ 'runExclusive',
1342
+ 'runCohort',
1343
+ 'runDeferred',
1344
+ ]);
1345
+ const identity = exactOwnDataRecord(repository?.identity, [
1346
+ 'worktree',
1347
+ 'gitDir',
1348
+ ]);
1349
+ const effectLedger = exactOwnDataRecord(capability?.effectLedger, [
1350
+ 'snapshot',
1351
+ 'writeAhead',
1352
+ ]);
1353
+ if (
1354
+ authority?.playbookId !== entry.id ||
1355
+ authority.artifactSchema !== 3 ||
1356
+ typeof authority.cwd !== 'string' ||
1357
+ authority.cwd.length === 0 ||
1358
+ typeof authority.sessionId !== 'string' ||
1359
+ authority.sessionId.length === 0 ||
1360
+ typeof authority.leaseOwnerToken !== 'string' ||
1361
+ authority.leaseOwnerToken.length === 0 ||
1362
+ canonicalWorktree === undefined ||
1363
+ typeof canonicalWorktree.worktree !== 'string' ||
1364
+ canonicalWorktree.worktree.length === 0 ||
1365
+ typeof canonicalWorktree.gitDir !== 'string' ||
1366
+ canonicalWorktree.gitDir.length === 0 ||
1367
+ !isDeepStrictEqual(authority.requiredRoleIds, entry.requiredRoleIds) ||
1368
+ !isDeepStrictEqual(
1369
+ authority.concurrentRoleSets,
1370
+ entry.concurrentRoleSets,
1371
+ ) ||
1372
+ identity === undefined ||
1373
+ !isDeepStrictEqual(identity, canonicalWorktree) ||
1374
+ typeof repository?.observe !== 'function' ||
1375
+ typeof repository.acquire !== 'function' ||
1376
+ typeof repository.runExclusive !== 'function' ||
1377
+ typeof repository.runCohort !== 'function' ||
1378
+ typeof repository.runDeferred !== 'function' ||
1379
+ typeof effectLedger?.snapshot !== 'function' ||
1380
+ typeof effectLedger.writeAhead !== 'function'
1381
+ ) {
1382
+ throw new Error(
1383
+ `/${command} schema-3 current-host capability authority does not match its imported artifact`,
1384
+ );
1385
+ }
1386
+ return value as PlaybookHostConstructionCapabilities;
1387
+ }
1388
+
1389
+ function effectLedgerMirrorFromCapabilities(
1390
+ capabilities: ReadonlyMap<string, PlaybookHostConstructionCapabilities>,
1391
+ ): PlaybookEffectLedger {
1392
+ const values = [...capabilities.values()];
1393
+ if (values.length === 0) return emptyPlaybookEffectLedger();
1394
+ const mirror = assertPlaybookEffectLedger(values[0]!.effectLedger.snapshot());
1395
+ for (const capability of values.slice(1)) {
1396
+ if (
1397
+ !isDeepStrictEqual(
1398
+ assertPlaybookEffectLedger(capability.effectLedger.snapshot()),
1399
+ mirror,
1400
+ )
1401
+ ) {
1402
+ throw new Error(
1403
+ 'schema-3 current-host capabilities disagree on their effect ledger',
1404
+ );
1405
+ }
1406
+ }
1407
+ return mirror;
1408
+ }
1409
+
1410
+ function validateRuntimeProfile(
1411
+ value: unknown,
1412
+ ): ValidatedRuntimeProfile | undefined {
1413
+ const shared = exactOwnDataRecord(value, ['kind', 'compat']);
1414
+ if (shared?.kind === 'shared-factory') {
1415
+ const compat = exactOwnDataRecord(shared.compat, [
1416
+ 'artifactSchema',
1417
+ 'runtimeAbi',
1418
+ ]);
1419
+ if (
1420
+ compat?.artifactSchema === 3 &&
1421
+ typeof compat.runtimeAbi === 'number' &&
1422
+ Number.isSafeInteger(compat.runtimeAbi)
1423
+ ) {
1424
+ return {
1425
+ kind: 'shared-factory',
1426
+ artifactSchema: compat.artifactSchema,
1427
+ };
1428
+ }
1429
+ return undefined;
1430
+ }
1431
+ const bespoke = exactOwnDataRecord(value, ['kind', 'artifactSchema']);
1432
+ if (
1433
+ bespoke?.kind === 'bespoke' &&
1434
+ bespoke.artifactSchema === 3
1435
+ ) {
1436
+ return { kind: 'bespoke', artifactSchema: bespoke.artifactSchema };
1437
+ }
1438
+ return undefined;
989
1439
  }
990
1440
 
991
1441
  function isValidRegistryEntry(
992
1442
  value: unknown,
1443
+ artifactSchema: unknown,
993
1444
  ): value is PlaybookCaptainRegistryEntry {
994
1445
  if (typeof value !== 'object' || value === null) return false;
995
1446
  const e = value as Record<string, unknown>;
@@ -1025,7 +1476,7 @@ function isValidRegistryEntry(
1025
1476
  typeof e.id === 'string' &&
1026
1477
  typeof e.command === 'string' &&
1027
1478
  typeof e.intent === 'string' &&
1028
- e.artifactSchema === 2 &&
1479
+ artifactSchema === 3 &&
1029
1480
  typeof e.validateOptions === 'function' &&
1030
1481
  typeof e.createRuntime === 'function'
1031
1482
  );
@@ -1035,6 +1486,7 @@ const SNAPSHOT_ACTIONS = new Set([
1035
1486
  'respond',
1036
1487
  'start',
1037
1488
  'switch',
1489
+ 'resume',
1038
1490
  'dismiss',
1039
1491
  'deliver',
1040
1492
  'runtime',
@@ -1051,6 +1503,93 @@ const SNAPSHOT_JOURNAL_KINDS = new Set([
1051
1503
  'action',
1052
1504
  'outcome',
1053
1505
  ] as const);
1506
+ const UNRESOLVED_EFFECT_CLASSIFICATIONS = new Set<
1507
+ PlaybookCaptainUnresolvedEffect['classification']
1508
+ >([
1509
+ 'one-descendant-commit',
1510
+ 'multiple-commits',
1511
+ 'rewritten-or-non-descendant',
1512
+ 'worktree-only-change',
1513
+ 'concurrent-or-foreign-change',
1514
+ 'observation-ambiguous',
1515
+ 'incomplete',
1516
+ ]);
1517
+ const GIT_OID_PATTERN = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/;
1518
+
1519
+ export function assertPlaybookCaptainUnresolvedEffects(
1520
+ value: unknown,
1521
+ ): readonly PlaybookCaptainUnresolvedEffect[] {
1522
+ const detached = snapshotJsonValue(
1523
+ value,
1524
+ 'Captain unresolved effects',
1525
+ );
1526
+ if (!Array.isArray(detached)) {
1527
+ throw new TypeError('Captain unresolved effects must be an array');
1528
+ }
1529
+ for (const [index, raw] of detached.entries()) {
1530
+ const path = `Captain unresolved effects[${index}]`;
1531
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
1532
+ throw new TypeError(`${path} must be an object`);
1533
+ }
1534
+ const entry = raw as Record<string, JsonValue>;
1535
+ const allowed = new Set([
1536
+ 'classification',
1537
+ 'baselineHead',
1538
+ 'afterHead',
1539
+ 'commitOid',
1540
+ ]);
1541
+ const unknown = Object.keys(entry).find((key) => !allowed.has(key));
1542
+ if (unknown !== undefined) {
1543
+ throw new TypeError(`${path} has unknown field ${JSON.stringify(unknown)}`);
1544
+ }
1545
+ if (
1546
+ typeof entry.classification !== 'string' ||
1547
+ !UNRESOLVED_EFFECT_CLASSIFICATIONS.has(
1548
+ entry.classification as PlaybookCaptainUnresolvedEffect['classification'],
1549
+ )
1550
+ ) {
1551
+ throw new TypeError(`${path}.classification is not supported`);
1552
+ }
1553
+ if (
1554
+ typeof entry.baselineHead !== 'string' ||
1555
+ !GIT_OID_PATTERN.test(entry.baselineHead)
1556
+ ) {
1557
+ throw new TypeError(`${path}.baselineHead must be a Git OID`);
1558
+ }
1559
+ if (
1560
+ entry.afterHead !== undefined &&
1561
+ (typeof entry.afterHead !== 'string' ||
1562
+ !GIT_OID_PATTERN.test(entry.afterHead))
1563
+ ) {
1564
+ throw new TypeError(`${path}.afterHead must be a Git OID`);
1565
+ }
1566
+ if (
1567
+ entry.classification !== 'observation-ambiguous' &&
1568
+ entry.classification !== 'incomplete' &&
1569
+ entry.afterHead === undefined
1570
+ ) {
1571
+ throw new TypeError(
1572
+ `${path}.afterHead is required for ${entry.classification}`,
1573
+ );
1574
+ }
1575
+ if (entry.classification === 'one-descendant-commit') {
1576
+ if (
1577
+ typeof entry.commitOid !== 'string' ||
1578
+ !GIT_OID_PATTERN.test(entry.commitOid) ||
1579
+ entry.commitOid !== entry.afterHead
1580
+ ) {
1581
+ throw new TypeError(
1582
+ `${path}.commitOid must equal afterHead for one-descendant-commit`,
1583
+ );
1584
+ }
1585
+ } else if (entry.commitOid !== undefined) {
1586
+ throw new TypeError(
1587
+ `${path}.commitOid is permitted only for one-descendant-commit`,
1588
+ );
1589
+ }
1590
+ }
1591
+ return detached as unknown as readonly PlaybookCaptainUnresolvedEffect[];
1592
+ }
1054
1593
 
1055
1594
  function snapshotRecord(
1056
1595
  value: JsonValue | undefined,
@@ -1312,9 +1851,20 @@ export function assertPlaybookCaptainShellSnapshot(
1312
1851
  ): PlaybookCaptainShellSnapshot {
1313
1852
  const detached = snapshotJsonValue(value, 'Captain shell snapshot');
1314
1853
  const snapshot = snapshotRecord(detached, 'Captain shell snapshot');
1854
+ if (snapshot.schemaVersion !== 4) {
1855
+ if (snapshot.schemaVersion === 1) {
1856
+ throw new TypeError(
1857
+ 'Captain shell snapshot schemaVersion 1 has incompatible player identity; schema 4 is required',
1858
+ );
1859
+ }
1860
+ throw new TypeError(
1861
+ `Captain shell snapshot.schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 4)`,
1862
+ );
1863
+ }
1315
1864
  const mode = snapshot.mode;
1316
1865
  const commonKeys = [
1317
1866
  'schemaVersion',
1867
+ 'effectLedger',
1318
1868
  'captain',
1319
1869
  'playerSessions',
1320
1870
  'issuedSessionIds',
@@ -1332,6 +1882,7 @@ export function assertPlaybookCaptainShellSnapshot(
1332
1882
  [
1333
1883
  ...commonKeys,
1334
1884
  'frames',
1885
+ 'retainedEffectReconciliation',
1335
1886
  'pendingBossQuestions',
1336
1887
  'lastError',
1337
1888
  ],
@@ -1342,12 +1893,6 @@ export function assertPlaybookCaptainShellSnapshot(
1342
1893
  'Captain shell snapshot.mode must be "chat" or "engaged.parked"',
1343
1894
  );
1344
1895
  }
1345
- if (snapshot.schemaVersion !== 3) {
1346
- throw new TypeError(
1347
- `Captain shell snapshot.schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 3)`,
1348
- );
1349
- }
1350
-
1351
1896
  const captain = snapshotRecord(
1352
1897
  snapshot.captain,
1353
1898
  'Captain shell snapshot.captain',
@@ -1365,6 +1910,50 @@ export function assertPlaybookCaptainShellSnapshot(
1365
1910
  captain.runtime,
1366
1911
  INTERNAL_CAPTAIN_ID,
1367
1912
  );
1913
+ const effectLedger = assertPlaybookEffectLedger(snapshot.effectLedger);
1914
+ let retainedEffectReconciliation:
1915
+ | PlaybookCaptainRetainedEffectReconciliation
1916
+ | undefined;
1917
+ if (snapshot.retainedEffectReconciliation !== undefined) {
1918
+ const reconciliation = snapshotRecord(
1919
+ snapshot.retainedEffectReconciliation,
1920
+ 'Captain shell snapshot.retainedEffectReconciliation',
1921
+ );
1922
+ rejectSnapshotKeys(
1923
+ reconciliation,
1924
+ ['sourceGenerationId', 'checkpoint'],
1925
+ 'Captain shell snapshot.retainedEffectReconciliation',
1926
+ );
1927
+ const checkpoint = assertPlaybookEffectLedger(
1928
+ reconciliation.checkpoint,
1929
+ 'Captain shell snapshot retained-effect checkpoint',
1930
+ );
1931
+ if (
1932
+ isDeepStrictEqual(checkpoint, effectLedger) ||
1933
+ !isPlaybookEffectLedgerMonotonicExtension(checkpoint, effectLedger)
1934
+ ) {
1935
+ throw new TypeError(
1936
+ 'Captain shell retained-effect checkpoint must be a strict monotonic prefix of its current mirror',
1937
+ );
1938
+ }
1939
+ retainedEffectReconciliation = {
1940
+ sourceGenerationId: snapshotUuid(
1941
+ reconciliation.sourceGenerationId,
1942
+ 'Captain shell snapshot.retainedEffectReconciliation.sourceGenerationId',
1943
+ ),
1944
+ checkpoint,
1945
+ };
1946
+ }
1947
+ if (
1948
+ !isDeepStrictEqual(
1949
+ captainRuntime.effectLedger,
1950
+ emptyPlaybookEffectLedger(),
1951
+ )
1952
+ ) {
1953
+ throw new TypeError(
1954
+ 'Captain shell snapshot internal Captain runtime effect ledger must be empty',
1955
+ );
1956
+ }
1368
1957
  const captainAgent = snapshotFixedAgent(
1369
1958
  captain.agent,
1370
1959
  'Captain shell snapshot.captain.agent',
@@ -1594,7 +2183,8 @@ export function assertPlaybookCaptainShellSnapshot(
1594
2183
  'Captain shell snapshot.playerSessions',
1595
2184
  );
1596
2185
  const common: PlaybookCaptainShellSnapshotFields = {
1597
- schemaVersion: 3,
2186
+ schemaVersion: 4,
2187
+ effectLedger,
1598
2188
  captain: {
1599
2189
  sessionId: captainSessionId,
1600
2190
  runtime: captainRuntime,
@@ -1743,6 +2333,51 @@ export function assertPlaybookCaptainShellSnapshot(
1743
2333
  const issuedIds = new Set(issued);
1744
2334
  const rootSessionId = normalizedFrames[0]!.sessionId;
1745
2335
  for (const [index, frame] of normalizedFrames.entries()) {
2336
+ const frameLedger = frame.runtime.effectLedger;
2337
+ if (
2338
+ !isDeepStrictEqual(frameLedger, emptyPlaybookEffectLedger()) &&
2339
+ !isDeepStrictEqual(frameLedger, effectLedger)
2340
+ ) {
2341
+ throw new TypeError(
2342
+ `Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} effect ledger is neither empty nor the shell mirror`,
2343
+ );
2344
+ }
2345
+ const frameReconciliation = frame.runtime.retainedEffectReconciliation;
2346
+ if (retainedEffectReconciliation === undefined) {
2347
+ if (frameReconciliation !== undefined) {
2348
+ throw new TypeError(
2349
+ `Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} carries an unmirrored retained-effect fence`,
2350
+ );
2351
+ }
2352
+ } else if (isDeepStrictEqual(frameLedger, effectLedger)) {
2353
+ if (
2354
+ frameReconciliation === undefined ||
2355
+ !isDeepStrictEqual(
2356
+ frameReconciliation.checkpoint,
2357
+ retainedEffectReconciliation.checkpoint,
2358
+ )
2359
+ ) {
2360
+ throw new TypeError(
2361
+ `Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} does not mirror the root retained-effect fence`,
2362
+ );
2363
+ }
2364
+ if (
2365
+ index === 0 &&
2366
+ frameReconciliation.sourceSessionId !==
2367
+ retainedEffectReconciliation.sourceGenerationId
2368
+ ) {
2369
+ throw new TypeError(
2370
+ 'Captain shell snapshot retained-effect root source identity differs from its generation',
2371
+ );
2372
+ }
2373
+ } else if (
2374
+ frameReconciliation !== undefined ||
2375
+ frame.runtime.retainedEffectSourceSessionId !== undefined
2376
+ ) {
2377
+ throw new TypeError(
2378
+ `Captain shell snapshot empty-ledger frame ${JSON.stringify(frame.playbookId)} carries retained-effect adoption state`,
2379
+ );
2380
+ }
1746
2381
  if (activePlaybooks.has(frame.playbookId)) {
1747
2382
  throw new TypeError(
1748
2383
  'Captain shell snapshot engagement path must not contain a playbook cycle',
@@ -1840,14 +2475,20 @@ export function assertPlaybookCaptainShellSnapshot(
1840
2475
  'Captain shell snapshot leaf runtime must be parked without a dangling suspended child call',
1841
2476
  );
1842
2477
  }
1843
- if (
1844
- !isDeepStrictEqual(
1845
- snapshot.pendingBossQuestions ?? [],
1846
- leafRuntime.pendingBossQuestions,
1847
- )
1848
- ) {
2478
+ if (retainedEffectReconciliation === undefined) {
2479
+ if (
2480
+ !isDeepStrictEqual(
2481
+ snapshot.pendingBossQuestions ?? [],
2482
+ leafRuntime.pendingBossQuestions,
2483
+ )
2484
+ ) {
2485
+ throw new TypeError(
2486
+ 'Captain shell snapshot pending Boss questions must equal the leaf runtime projection',
2487
+ );
2488
+ }
2489
+ } else if (snapshot.pendingBossQuestions !== undefined) {
1849
2490
  throw new TypeError(
1850
- 'Captain shell snapshot pending Boss questions must equal the leaf runtime projection',
2491
+ 'Captain shell snapshot must withhold pending Boss questions behind retained-effect reconciliation',
1851
2492
  );
1852
2493
  }
1853
2494
  return snapshotJsonValue(
@@ -1855,6 +2496,9 @@ export function assertPlaybookCaptainShellSnapshot(
1855
2496
  ...common,
1856
2497
  mode,
1857
2498
  frames: normalizedFrames,
2499
+ ...(retainedEffectReconciliation === undefined
2500
+ ? {}
2501
+ : { retainedEffectReconciliation }),
1858
2502
  ...(snapshot.pendingBossQuestions === undefined
1859
2503
  ? {}
1860
2504
  : { pendingBossQuestions: snapshot.pendingBossQuestions }),
@@ -1871,6 +2515,10 @@ interface BuiltRegistry {
1871
2515
  byCommand: Map<string, PlaybookCaptainRegistryEntry>;
1872
2516
  byId: Map<string, PlaybookCaptainRegistryEntry>;
1873
2517
  enablementById: Map<string, Enablement>;
2518
+ hostCapabilitiesById: ReadonlyMap<
2519
+ string,
2520
+ PlaybookHostConstructionCapabilities
2521
+ >;
1874
2522
  captainAgent: SessionAgent;
1875
2523
  playerAgents: Map<string, SessionAgent>;
1876
2524
  }
@@ -1918,6 +2566,16 @@ function snapshotEffortSelection(
1918
2566
  return selection as TuningSelection<Effort>;
1919
2567
  }
1920
2568
 
2569
+ function snapshotFastMode(
2570
+ value: JsonValue | undefined,
2571
+ path: string,
2572
+ ): boolean | undefined {
2573
+ if (value !== undefined && typeof value !== 'boolean') {
2574
+ throw new TypeError(`${path} must be a boolean`);
2575
+ }
2576
+ return value;
2577
+ }
2578
+
1921
2579
  function snapshotSessionAgent(
1922
2580
  value: JsonValue | undefined,
1923
2581
  path: string,
@@ -1925,13 +2583,14 @@ function snapshotSessionAgent(
1925
2583
  const agent = snapshotRecord(value, path);
1926
2584
  rejectSnapshotKeys(
1927
2585
  agent,
1928
- ['adapter', 'model', 'effort', 'instruction', 'permissions'],
2586
+ ['adapter', 'model', 'effort', 'fastMode', 'instruction', 'permissions'],
1929
2587
  path,
1930
2588
  );
1931
2589
  const fixed = snapshotFixedAgent(
1932
2590
  Object.fromEntries(
1933
2591
  Object.entries(agent).filter(
1934
- ([key]) => key !== 'model' && key !== 'effort',
2592
+ ([key]) =>
2593
+ key !== 'model' && key !== 'effort' && key !== 'fastMode',
1935
2594
  ),
1936
2595
  ) as JsonValue,
1937
2596
  path,
@@ -1946,10 +2605,15 @@ function snapshotSessionAgent(
1946
2605
  : { permissions: livePermissions(fixed.permissions) }),
1947
2606
  model: snapshotTuningSelection(agent.model, `${path}.model`),
1948
2607
  effort: snapshotEffortSelection(agent.effort, `${path}.effort`),
2608
+ ...(agent.fastMode === undefined
2609
+ ? {}
2610
+ : { fastMode: snapshotFastMode(agent.fastMode, `${path}.fastMode`) }),
1949
2611
  };
1950
2612
  }
1951
2613
 
1952
- function fixedAgent(agent: SessionAgent): Omit<SessionAgent, 'model' | 'effort'> {
2614
+ function fixedAgent(
2615
+ agent: SessionAgent,
2616
+ ): Omit<SessionAgent, 'model' | 'effort' | 'fastMode'> {
1953
2617
  return {
1954
2618
  adapter: agent.adapter,
1955
2619
  ...(agent.instruction === undefined ? {} : { instruction: agent.instruction }),
@@ -1959,11 +2623,15 @@ function fixedAgent(agent: SessionAgent): Omit<SessionAgent, 'model' | 'effort'>
1959
2623
 
1960
2624
  function callSettings(
1961
2625
  agent: SessionAgent,
1962
- tuning: Pick<SessionAgent, 'model' | 'effort'> = agent,
2626
+ tuning: Pick<SessionAgent, 'model' | 'effort' | 'fastMode'> = agent,
1963
2627
  ): AgentCallSettings {
2628
+ // cligent treats supplied call settings as a complete replacement, so an
2629
+ // omitted fastMode here is a request for the provider default, never an
2630
+ // inheritance of whatever the previous call left behind.
1964
2631
  return {
1965
2632
  model: tuning.model,
1966
2633
  effort: tuning.effort,
2634
+ ...(tuning.fastMode === undefined ? {} : { fastMode: tuning.fastMode }),
1967
2635
  ...(agent.instruction === undefined ? {} : { instruction: agent.instruction }),
1968
2636
  ...(agent.permissions === undefined ? {} : { permissions: agent.permissions }),
1969
2637
  };
@@ -1975,17 +2643,38 @@ function promptIdentity(binding: EffectivePlayerBinding): string {
1975
2643
  : binding.agent.adapter;
1976
2644
  }
1977
2645
 
2646
+ function rejectConfiguredHostCapabilities(value: JsonValue | undefined, path: string): void {
2647
+ if (
2648
+ value !== null &&
2649
+ typeof value === 'object' &&
2650
+ !Array.isArray(value) &&
2651
+ Object.prototype.hasOwnProperty.call(value, HOST_CAPABILITIES_OPTION_KEY)
2652
+ ) {
2653
+ throw new Error(
2654
+ `${path}.${HOST_CAPABILITIES_OPTION_KEY} is host-owned and cannot be configured`,
2655
+ );
2656
+ }
2657
+ }
2658
+
1978
2659
  // Resolve the active registry at init from exact normalized role and session
1979
2660
  // agent projections (CAPTAIN-16). No role, ancestor, or generated-name fallback
1980
2661
  // exists at this boundary.
1981
2662
  async function buildEnablements(
1982
2663
  options: unknown,
1983
2664
  loadModule: (specifier: string) => Promise<unknown>,
2665
+ hostCapabilities: PlaybookCaptainDeps['hostCapabilities'],
1984
2666
  ): Promise<BuiltRegistry> {
1985
2667
  const entries: PlaybookCaptainRegistryEntry[] = [];
1986
2668
  const byCommand = new Map<string, PlaybookCaptainRegistryEntry>();
1987
2669
  const byId = new Map<string, PlaybookCaptainRegistryEntry>();
1988
2670
  const enablementById = new Map<string, Enablement>();
2671
+ const hostCapabilitiesById = new Map<
2672
+ string,
2673
+ PlaybookHostConstructionCapabilities
2674
+ >();
2675
+ const suppliedHostCapabilities =
2676
+ captureHostCapabilityRecord(hostCapabilities);
2677
+ const expectedHostCapabilityIds: string[] = [];
1989
2678
 
1990
2679
  const detached = snapshotJsonValue(options, 'captain.options');
1991
2680
  const top = snapshotRecord(detached, 'captain.options');
@@ -2067,6 +2756,10 @@ async function buildEnablements(
2067
2756
  ['from', 'command', 'roles', 'options'],
2068
2757
  `captain.options.playbooks.${id}`,
2069
2758
  );
2759
+ rejectConfiguredHostCapabilities(
2760
+ record.options,
2761
+ `captain.options.playbooks.${id}.options`,
2762
+ );
2070
2763
  const from = record.from;
2071
2764
  if (typeof from !== 'string' || from.length === 0) {
2072
2765
  throw new Error(
@@ -2083,13 +2776,45 @@ async function buildEnablements(
2083
2776
  )}`,
2084
2777
  );
2085
2778
  }
2086
- const entry = (mod as { default?: unknown })?.default;
2087
- if (!isValidRegistryEntry(entry)) {
2779
+ const capturedEntry = captureRegistryEntry(
2780
+ (mod as { default?: unknown })?.default,
2781
+ );
2782
+ const artifactSchema = (
2783
+ capturedEntry as { artifactSchema?: unknown } | null
2784
+ )
2785
+ ?.artifactSchema;
2786
+ const runtimeProfile = validateRuntimeProfile(
2787
+ (capturedEntry as { runtimeProfile?: unknown } | null)?.runtimeProfile,
2788
+ );
2789
+ if (
2790
+ artifactSchema !== 3 ||
2791
+ runtimeProfile === undefined ||
2792
+ !isValidRegistryEntry(capturedEntry, artifactSchema)
2793
+ ) {
2088
2794
  throw new Error(
2089
2795
  `captain.options.playbooks.${id}.from "${from}" exposes no valid registry entry`,
2090
2796
  );
2091
2797
  }
2092
- if (entry.id !== id) {
2798
+ const entry = Object.freeze({
2799
+ ...capturedEntry,
2800
+ requiredRoleIds: Object.freeze([...capturedEntry.requiredRoleIds]),
2801
+ concurrentRoleSets: Object.freeze(
2802
+ capturedEntry.concurrentRoleSets.map((roles) =>
2803
+ Object.freeze([...roles]),
2804
+ ),
2805
+ ),
2806
+ }) as PlaybookCaptainRegistryEntry;
2807
+ if (runtimeProfile.artifactSchema !== artifactSchema) {
2808
+ const implementation =
2809
+ runtimeProfile.kind === 'shared-factory'
2810
+ ? 'shared factory'
2811
+ : 'bespoke runtime';
2812
+ throw new Error(
2813
+ `captain.options.playbooks.${id}.from "${from}" advertises artifact schema ${artifactSchema} ` +
2814
+ `but its ${implementation} implements schema ${runtimeProfile.artifactSchema}`,
2815
+ );
2816
+ }
2817
+ if (entry.id !== id) {
2093
2818
  throw new Error(
2094
2819
  `captain.options.playbooks.${id} key must equal the module manifest id "${entry.id}"`,
2095
2820
  );
@@ -2132,7 +2857,11 @@ async function buildEnablements(
2132
2857
  for (const role of entry.requiredRoleIds) {
2133
2858
  const path = `captain.options.playbooks.${id}.roles.${role}`;
2134
2859
  const rawBinding = snapshotRecord(roleRecord[role], path);
2135
- rejectSnapshotKeys(rawBinding, ['playerId', 'model', 'effort'], path);
2860
+ rejectSnapshotKeys(
2861
+ rawBinding,
2862
+ ['playerId', 'model', 'effort', 'fastMode'],
2863
+ path,
2864
+ );
2136
2865
  const playerId = snapshotString(rawBinding.playerId, `${path}.playerId`);
2137
2866
  if (!PLAYER_ID_PATTERN.test(playerId) || playerId === INTERNAL_CAPTAIN_ID) {
2138
2867
  throw new Error(`${path}.playerId is not a canonical player id`);
@@ -2147,6 +2876,14 @@ async function buildEnablements(
2147
2876
  playerId,
2148
2877
  model: snapshotTuningSelection(rawBinding.model, `${path}.model`),
2149
2878
  effort: snapshotEffortSelection(rawBinding.effort, `${path}.effort`),
2879
+ ...(rawBinding.fastMode === undefined
2880
+ ? {}
2881
+ : {
2882
+ fastMode: snapshotFastMode(
2883
+ rawBinding.fastMode,
2884
+ `${path}.fastMode`,
2885
+ ),
2886
+ }),
2150
2887
  agent,
2151
2888
  });
2152
2889
  }
@@ -2164,16 +2901,42 @@ async function buildEnablements(
2164
2901
  entry.validateOptions(record.options),
2165
2902
  `captain.options.playbooks.${id}.options`,
2166
2903
  );
2904
+ rejectConfiguredHostCapabilities(
2905
+ validatedOptions,
2906
+ `captain.options.playbooks.${id}.options`,
2907
+ );
2167
2908
  entries.push(entry);
2168
2909
  byId.set(entry.id, entry);
2169
2910
  byCommand.set(command, entry);
2911
+ const hostCapability = validateHostCapabilities(
2912
+ suppliedHostCapabilities[entry.id],
2913
+ entry,
2914
+ command,
2915
+ );
2916
+ expectedHostCapabilityIds.push(entry.id);
2917
+ hostCapabilitiesById.set(entry.id, hostCapability);
2170
2918
  enablementById.set(entry.id, {
2171
2919
  entry,
2920
+ artifactSchema,
2172
2921
  command,
2173
2922
  options: validatedOptions,
2174
2923
  roleBindings,
2175
2924
  });
2176
2925
  }
2926
+ const suppliedHostCapabilityIds = Object.keys(
2927
+ suppliedHostCapabilities,
2928
+ ).sort();
2929
+ expectedHostCapabilityIds.sort();
2930
+ if (
2931
+ !isDeepStrictEqual(
2932
+ suppliedHostCapabilityIds,
2933
+ expectedHostCapabilityIds,
2934
+ )
2935
+ ) {
2936
+ throw new Error(
2937
+ 'current-host construction capabilities must exactly cover schema-3 playbooks',
2938
+ );
2939
+ }
2177
2940
  const referenced = new Set(
2178
2941
  [...enablementById.values()].flatMap((enablement) =>
2179
2942
  [...enablement.roleBindings.values()].map((binding) => binding.playerId),
@@ -2190,6 +2953,7 @@ async function buildEnablements(
2190
2953
  byCommand,
2191
2954
  byId,
2192
2955
  enablementById,
2956
+ hostCapabilitiesById,
2193
2957
  captainAgent,
2194
2958
  playerAgents,
2195
2959
  };
@@ -2205,6 +2969,17 @@ export function createPlaybookCaptainShell(
2205
2969
  const createCaptainRuntime: NonNullable<
2206
2970
  PlaybookCaptainDeps['createCaptainRuntime']
2207
2971
  > = deps.createCaptainRuntime ?? createDefaultCaptainRuntime;
2972
+ const unresolvedEffectSettlement = deps.unresolvedEffectSettlement;
2973
+ let pendingHostCapabilities = deps.hostCapabilities;
2974
+ let currentEffectLedger = () => emptyPlaybookEffectLedger();
2975
+ // The returned shell must not retain the caller's aggregate dependency
2976
+ // object after its one live capability input has moved to a clearable slot.
2977
+ deps = {};
2978
+ const buildCurrentEnablements = async (): Promise<BuiltRegistry> => {
2979
+ const hostCapabilities = pendingHostCapabilities;
2980
+ pendingHostCapabilities = undefined;
2981
+ return buildEnablements(options, loadModule, hostCapabilities);
2982
+ };
2208
2983
  let captainAgent: SessionAgent | undefined;
2209
2984
  let captainAdapter: string | undefined;
2210
2985
  let playerAgents = new Map<string, SessionAgent>();
@@ -2214,6 +2989,10 @@ export function createPlaybookCaptainShell(
2214
2989
  let byCommand = new Map<string, PlaybookCaptainRegistryEntry>();
2215
2990
  let byId = new Map<string, PlaybookCaptainRegistryEntry>();
2216
2991
  let enablementById = new Map<string, Enablement>();
2992
+ let hostCapabilitiesById = new Map<
2993
+ string,
2994
+ PlaybookHostConstructionCapabilities
2995
+ >();
2217
2996
  let session: CaptainSession | undefined;
2218
2997
  let sessionEmissionsOpen = false;
2219
2998
  let closedGateAttempted = false;
@@ -2228,6 +3007,9 @@ export function createPlaybookCaptainShell(
2228
3007
  let activeContext: CaptainContext | undefined;
2229
3008
  const frames: EngagementFrame[] = [];
2230
3009
  let mode: ShellMode = 'chat';
3010
+ let retainedEffectReconciliation:
3011
+ | PlaybookCaptainRetainedEffectReconciliation
3012
+ | undefined;
2231
3013
  let pendingBossQuestions: unknown;
2232
3014
  let lastError: { name: string; message: string } | undefined;
2233
3015
  let activeTurnSummary: ActiveTurnSummary | undefined;
@@ -2308,6 +3090,50 @@ export function createPlaybookCaptainShell(
2308
3090
  | undefined;
2309
3091
  let lastAction: ControllerAction | undefined;
2310
3092
  let lastSettlementStatus: SettlementEvidence['status'] | undefined;
3093
+ // DR-038 §2: a turn can dispose its root before the durable caller asks
3094
+ // for settlement. Keep only that turn's latest safe pre-terminal generation
3095
+ // and its per-root retain/clear decisions until `exportSettlement()` pairs
3096
+ // them with the complete shell snapshot.
3097
+ type RetainedGenerationCandidate =
3098
+ | {
3099
+ readonly status: 'captured';
3100
+ readonly generation: PlaybookCaptainRetainedGeneration;
3101
+ }
3102
+ | { readonly status: 'incapable' }
3103
+ | { readonly status: 'unsafe' };
3104
+ const retainedGenerationCandidates = new Map<
3105
+ string,
3106
+ RetainedGenerationCandidate
3107
+ >();
3108
+ const pendingRetentionUpdates = new Map<
3109
+ string,
3110
+ PlaybookCaptainRetentionUpdate
3111
+ >();
3112
+ interface RetainedGenerationOffer {
3113
+ readonly generation: PlaybookCaptainRetainedGeneration;
3114
+ readonly requiresEffectReconciliation: boolean;
3115
+ /** Fresh, uninitialized runtimes reserved for one adoption attempt. */
3116
+ readonly runtimes: readonly PlaybookRuntime[];
3117
+ }
3118
+ const retainedGenerations = new Map<
3119
+ string,
3120
+ PlaybookCaptainRetainedGeneration
3121
+ >();
3122
+ const retainedGenerationOffers = new Map<
3123
+ string,
3124
+ RetainedGenerationOffer
3125
+ >();
3126
+ const ineligibleRetainedGenerations = new Set<string>();
3127
+ const retainedGenerationRootClears = new Set<string>();
3128
+ const retiredRetainedRuntimes: PlaybookRuntime[] = [];
3129
+ let retainedGenerationsInstalled = false;
3130
+ let retainedGenerationInstallationInProgress = false;
3131
+ let retainedGenerationInstallationClosed = false;
3132
+ let retentionSettlementReady = false;
3133
+ let abandonmentSettlementUnsafe = false;
3134
+ let settledTurnUnresolvedEffects:
3135
+ | readonly PlaybookCaptainUnresolvedEffect[]
3136
+ | undefined;
2311
3137
  // DR-029: a run that lands in the runtime's own failure state
2312
3138
  // is an outcome the report must name. `processFrameResult` records it here
2313
3139
  // and the settling selection folds it into its facts, so the grounding the
@@ -2319,6 +3145,826 @@ export function createPlaybookCaptainShell(
2319
3145
  const frameLabel = (frame: EngagementFrame): string =>
2320
3146
  `/${frame.enablement.command}`;
2321
3147
 
3148
+ type UnresolvedEnvelopeReference =
3149
+ | { readonly kind: 'boundary'; readonly boundaryId: string }
3150
+ | { readonly kind: 'logical-operation'; readonly operationId: string };
3151
+
3152
+ const capturedUnresolvedEnvelopeReferences = (
3153
+ frame: EngagementFrame,
3154
+ ): readonly UnresolvedEnvelopeReference[] => {
3155
+ let advertisesUnresolved = false;
3156
+ try {
3157
+ advertisesUnresolved =
3158
+ frame.runtime
3159
+ .describe?.()
3160
+ .actions.some(
3161
+ ({ id }) =>
3162
+ id === UNRESOLVED_EFFECT_RECONCILIATION_ACTION_ID ||
3163
+ id === UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID,
3164
+ ) === true;
3165
+ } catch {
3166
+ advertisesUnresolved = true;
3167
+ }
3168
+ if (typeof frame.runtime.unresolvedEffectEnvelopes !== 'function') {
3169
+ if (advertisesUnresolved) {
3170
+ throw new Error(
3171
+ `${frameLabel(frame)} unresolved-effect runtime exposes no envelope identities`,
3172
+ );
3173
+ }
3174
+ return [];
3175
+ }
3176
+ const detached = snapshotJsonValue(
3177
+ frame.runtime.unresolvedEffectEnvelopes(),
3178
+ `${frameLabel(frame)} unresolved effect envelope identities`,
3179
+ );
3180
+ if (!Array.isArray(detached)) {
3181
+ throw new TypeError(
3182
+ `${frameLabel(frame)} unresolved effect envelope identities must be an array`,
3183
+ );
3184
+ }
3185
+ return detached.map((raw, index) => {
3186
+ const path = `${frameLabel(frame)} unresolved effect envelope identities[${index}]`;
3187
+ const record = snapshotRecord(raw, path);
3188
+ if (record.kind === 'boundary') {
3189
+ rejectSnapshotKeys(record, ['kind', 'boundaryId'], path);
3190
+ return {
3191
+ kind: 'boundary' as const,
3192
+ boundaryId: snapshotString(record.boundaryId, `${path}.boundaryId`),
3193
+ };
3194
+ }
3195
+ if (record.kind === 'logical-operation') {
3196
+ rejectSnapshotKeys(record, ['kind', 'operationId'], path);
3197
+ return {
3198
+ kind: 'logical-operation' as const,
3199
+ operationId: snapshotString(record.operationId, `${path}.operationId`),
3200
+ };
3201
+ }
3202
+ throw new TypeError(`${path}.kind is not supported`);
3203
+ });
3204
+ };
3205
+
3206
+ const unresolvedEffectFromReceipt = (
3207
+ receipt: NonNullable<
3208
+ PlaybookEffectLedger['boundaries'][number]['physicalReceipt']
3209
+ >,
3210
+ baselineHead = receipt.baseline.head,
3211
+ ): PlaybookCaptainUnresolvedEffect | undefined => {
3212
+ if (receipt.classification === 'unchanged') return undefined;
3213
+ return {
3214
+ classification: receipt.classification,
3215
+ baselineHead,
3216
+ ...(receipt.after === undefined ? {} : { afterHead: receipt.after.head }),
3217
+ ...(receipt.commitOid === undefined ? {} : { commitOid: receipt.commitOid }),
3218
+ };
3219
+ };
3220
+
3221
+ const cumulativeOpenLogicalEffect = (
3222
+ operation: PlaybookEffectLedger['logicalOperations'][number],
3223
+ boundaries: readonly PlaybookEffectLedger['boundaries'][number][],
3224
+ ): PlaybookCaptainUnresolvedEffect | undefined => {
3225
+ const original = operation.originalBaseline;
3226
+ const latest = boundaries.at(-1)!;
3227
+ const after = latest.after ?? operation.checkpoint;
3228
+ const receipt = latest.physicalReceipt;
3229
+ if (receipt === undefined) {
3230
+ return {
3231
+ classification: 'incomplete',
3232
+ baselineHead: original.head,
3233
+ ...(after === undefined ? {} : { afterHead: after.head }),
3234
+ };
3235
+ }
3236
+ if (after === undefined) {
3237
+ return unresolvedEffectFromReceipt(receipt, original.head);
3238
+ }
3239
+
3240
+ // An open deferred chain has no authoritative cumulative receipt. Reuse
3241
+ // physical ancestry only while every preceding checkpoint is one of the
3242
+ // same-HEAD dispositions that can lawfully keep a deferred operation
3243
+ // open. Any other history is bounded but not cumulatively attributable.
3244
+ const checkpointChainIsSafe = boundaries
3245
+ .slice(0, -1)
3246
+ .every(
3247
+ (boundary) =>
3248
+ boundary.after?.head === original.head &&
3249
+ (boundary.physicalReceipt?.classification === 'unchanged' ||
3250
+ boundary.physicalReceipt?.classification ===
3251
+ 'worktree-only-change'),
3252
+ );
3253
+ if (!checkpointChainIsSafe || latest.baseline.head !== original.head) {
3254
+ return {
3255
+ classification: 'observation-ambiguous',
3256
+ baselineHead: original.head,
3257
+ afterHead: after.head,
3258
+ };
3259
+ }
3260
+
3261
+ if (
3262
+ receipt.classification !== 'unchanged' &&
3263
+ receipt.classification !== 'worktree-only-change' &&
3264
+ receipt.classification !== 'one-descendant-commit'
3265
+ ) {
3266
+ return unresolvedEffectFromReceipt(receipt, original.head);
3267
+ }
3268
+
3269
+ const sameProjection = isDeepStrictEqual(
3270
+ original.projection,
3271
+ after.projection,
3272
+ );
3273
+ if (after.head === original.head) {
3274
+ if (
3275
+ receipt.classification !== 'unchanged' &&
3276
+ receipt.classification !== 'worktree-only-change'
3277
+ ) {
3278
+ return {
3279
+ classification: 'observation-ambiguous',
3280
+ baselineHead: original.head,
3281
+ afterHead: after.head,
3282
+ };
3283
+ }
3284
+ if (sameProjection) return undefined;
3285
+ const preservesOriginal = Object.entries(original.projection).every(
3286
+ ([path, entry]) =>
3287
+ Object.hasOwn(after.projection, path) &&
3288
+ isDeepStrictEqual(entry, after.projection[path]),
3289
+ );
3290
+ return {
3291
+ classification: preservesOriginal
3292
+ ? 'worktree-only-change'
3293
+ : 'observation-ambiguous',
3294
+ baselineHead: original.head,
3295
+ afterHead: after.head,
3296
+ };
3297
+ }
3298
+
3299
+ if (
3300
+ receipt.classification === 'one-descendant-commit' &&
3301
+ sameProjection
3302
+ ) {
3303
+ return {
3304
+ classification: 'one-descendant-commit',
3305
+ baselineHead: original.head,
3306
+ afterHead: after.head,
3307
+ commitOid: after.head,
3308
+ };
3309
+ }
3310
+ return {
3311
+ classification: 'observation-ambiguous',
3312
+ baselineHead: original.head,
3313
+ afterHead: after.head,
3314
+ };
3315
+ };
3316
+
3317
+ const projectUnresolvedEffects = (
3318
+ ledger: PlaybookEffectLedger,
3319
+ references: readonly UnresolvedEnvelopeReference[],
3320
+ ): readonly PlaybookCaptainUnresolvedEffect[] => {
3321
+ const pendingReferences = [...references];
3322
+ const projected: Array<{
3323
+ readonly order: number;
3324
+ readonly effect: PlaybookCaptainUnresolvedEffect;
3325
+ }> = [];
3326
+ const seenBoundaries = new Set<string>();
3327
+ const seenOperations = new Set<string>();
3328
+ for (let index = 0; index < pendingReferences.length; index += 1) {
3329
+ const reference = pendingReferences[index]!;
3330
+ if (reference.kind === 'boundary') {
3331
+ if (seenBoundaries.has(reference.boundaryId)) continue;
3332
+ seenBoundaries.add(reference.boundaryId);
3333
+ const boundary = ledger.boundaries.find(
3334
+ ({ boundaryId }) => boundaryId === reference.boundaryId,
3335
+ );
3336
+ if (boundary === undefined) {
3337
+ throw new Error(
3338
+ `unresolved effect boundary ${JSON.stringify(reference.boundaryId)} is absent from the authoritative ledger`,
3339
+ );
3340
+ }
3341
+ if (boundary.logicalOperationId !== undefined) {
3342
+ if (!seenOperations.has(boundary.logicalOperationId)) {
3343
+ pendingReferences.push({
3344
+ kind: 'logical-operation',
3345
+ operationId: boundary.logicalOperationId,
3346
+ });
3347
+ }
3348
+ continue;
3349
+ }
3350
+ const effect =
3351
+ boundary.physicalReceipt === undefined
3352
+ ? {
3353
+ classification: 'incomplete' as const,
3354
+ baselineHead: boundary.baseline.head,
3355
+ ...(boundary.after === undefined
3356
+ ? {}
3357
+ : { afterHead: boundary.after.head }),
3358
+ }
3359
+ : unresolvedEffectFromReceipt(boundary.physicalReceipt);
3360
+ if (effect !== undefined) {
3361
+ projected.push({ order: boundary.sequence, effect });
3362
+ }
3363
+ continue;
3364
+ }
3365
+
3366
+ if (seenOperations.has(reference.operationId)) continue;
3367
+ seenOperations.add(reference.operationId);
3368
+ const operation = ledger.logicalOperations.find(
3369
+ ({ operationId }) => operationId === reference.operationId,
3370
+ );
3371
+ if (operation === undefined) {
3372
+ throw new Error(
3373
+ `unresolved logical operation ${JSON.stringify(reference.operationId)} is absent from the authoritative ledger`,
3374
+ );
3375
+ }
3376
+ const boundaries = operation.boundaryIds.map((boundaryId) => {
3377
+ const boundary = ledger.boundaries.find(
3378
+ (candidate) => candidate.boundaryId === boundaryId,
3379
+ );
3380
+ if (boundary === undefined) {
3381
+ throw new Error(
3382
+ `unresolved logical operation ${JSON.stringify(reference.operationId)} names an absent boundary`,
3383
+ );
3384
+ }
3385
+ seenBoundaries.add(boundaryId);
3386
+ return boundary;
3387
+ });
3388
+ let effect: PlaybookCaptainUnresolvedEffect | undefined;
3389
+ if (operation.logicalReceipt !== undefined) {
3390
+ effect = unresolvedEffectFromReceipt(
3391
+ operation.logicalReceipt,
3392
+ operation.originalBaseline.head,
3393
+ );
3394
+ } else {
3395
+ effect = cumulativeOpenLogicalEffect(operation, boundaries);
3396
+ }
3397
+ if (effect !== undefined) {
3398
+ projected.push({ order: boundaries[0]!.sequence, effect });
3399
+ }
3400
+ }
3401
+ return assertPlaybookCaptainUnresolvedEffects(
3402
+ projected
3403
+ .sort((left, right) => left.order - right.order)
3404
+ .map(({ effect }) => effect),
3405
+ );
3406
+ };
3407
+
3408
+ const currentUnresolvedEffects =
3409
+ (): readonly PlaybookCaptainUnresolvedEffect[] => {
3410
+ const ledger = assertPlaybookEffectLedger(currentEffectLedger());
3411
+ const references: UnresolvedEnvelopeReference[] = [];
3412
+ for (const frame of frames) {
3413
+ references.push(...capturedUnresolvedEnvelopeReferences(frame));
3414
+ }
3415
+ if (retainedEffectReconciliation !== undefined) {
3416
+ for (const boundary of ledger.boundaries.slice(
3417
+ retainedEffectReconciliation.checkpoint.boundaries.length,
3418
+ )) {
3419
+ if (boundary.physicalReceipt?.classification === 'unchanged') {
3420
+ continue;
3421
+ }
3422
+ references.push(
3423
+ boundary.logicalOperationId === undefined
3424
+ ? { kind: 'boundary', boundaryId: boundary.boundaryId }
3425
+ : {
3426
+ kind: 'logical-operation',
3427
+ operationId: boundary.logicalOperationId,
3428
+ },
3429
+ );
3430
+ }
3431
+ }
3432
+ return projectUnresolvedEffects(ledger, references);
3433
+ };
3434
+
3435
+ const freezeTurnUnresolvedEffects =
3436
+ (): readonly PlaybookCaptainUnresolvedEffect[] => {
3437
+ const turn = activeTurn;
3438
+ if (turn?.unresolvedEffects !== undefined) return turn.unresolvedEffects;
3439
+ const frozen = currentUnresolvedEffects();
3440
+ if (turn !== undefined) turn.unresolvedEffects = frozen;
3441
+ settledTurnUnresolvedEffects = frozen;
3442
+ return frozen;
3443
+ };
3444
+
3445
+ const normalizeInstalledRetainedGenerations = (
3446
+ value: Readonly<Record<string, PlaybookCaptainRetainedGeneration>>,
3447
+ ): Map<string, PlaybookCaptainRetainedGeneration> => {
3448
+ const path = 'Captain retained generations';
3449
+ const detached = snapshotJsonValue(value, path);
3450
+ const record = snapshotRecord(detached, path);
3451
+ const authoritativeEffectLedger = assertPlaybookEffectLedger(
3452
+ currentEffectLedger(),
3453
+ `${path} current host effect ledger`,
3454
+ );
3455
+ const sourceSessionIds = new Set<string>();
3456
+ const normalized = new Map<string, PlaybookCaptainRetainedGeneration>();
3457
+ for (const [rootPlaybookId, rawGeneration] of Object.entries(record)) {
3458
+ const generationPath = `${path}[${JSON.stringify(rootPlaybookId)}]`;
3459
+ const enablement = enablementById.get(rootPlaybookId);
3460
+ if (enablement === undefined) {
3461
+ throw new TypeError(
3462
+ `${generationPath} names a disabled root playbook`,
3463
+ );
3464
+ }
3465
+ const generation = snapshotRecord(rawGeneration, generationPath);
3466
+ rejectSnapshotKeys(
3467
+ generation,
3468
+ [
3469
+ 'effectLedger',
3470
+ 'frames',
3471
+ 'retainedEffectReconciliation',
3472
+ 'rootStateDescription',
3473
+ ],
3474
+ generationPath,
3475
+ );
3476
+ const generationEffectLedger = assertPlaybookEffectLedger(
3477
+ generation.effectLedger,
3478
+ `${generationPath}.effectLedger`,
3479
+ );
3480
+ if (
3481
+ generationEffectLedger.boundaries.some(
3482
+ ({ physicalReceipt }) => physicalReceipt === undefined,
3483
+ )
3484
+ ) {
3485
+ throw new TypeError(
3486
+ `${generationPath}.effectLedger contains an incomplete physical boundary`,
3487
+ );
3488
+ }
3489
+ if (
3490
+ !isPlaybookEffectLedgerMonotonicExtension(
3491
+ generationEffectLedger,
3492
+ authoritativeEffectLedger,
3493
+ )
3494
+ ) {
3495
+ throw new TypeError(
3496
+ `${generationPath}.effectLedger is not a monotonic prefix of the current host mirror`,
3497
+ );
3498
+ }
3499
+ const generationReconciliation =
3500
+ generation.retainedEffectReconciliation === undefined
3501
+ ? undefined
3502
+ : snapshotRecord(
3503
+ generation.retainedEffectReconciliation,
3504
+ `${generationPath}.retainedEffectReconciliation`,
3505
+ );
3506
+ if (generationReconciliation !== undefined) {
3507
+ rejectSnapshotKeys(
3508
+ generationReconciliation,
3509
+ ['sourceGenerationId'],
3510
+ `${generationPath}.retainedEffectReconciliation`,
3511
+ );
3512
+ }
3513
+ const sourceGenerationId =
3514
+ generationReconciliation === undefined
3515
+ ? undefined
3516
+ : snapshotUuid(
3517
+ generationReconciliation.sourceGenerationId,
3518
+ `${generationPath}.retainedEffectReconciliation.sourceGenerationId`,
3519
+ );
3520
+ if (!Array.isArray(generation.frames) || generation.frames.length === 0) {
3521
+ throw new TypeError(`${generationPath}.frames must be non-empty`);
3522
+ }
3523
+ const rootStateDescription =
3524
+ generation.rootStateDescription === undefined
3525
+ ? undefined
3526
+ : snapshotString(
3527
+ generation.rootStateDescription,
3528
+ `${generationPath}.rootStateDescription`,
3529
+ );
3530
+ const normalizedFrames: PlaybookCaptainFrameSnapshot[] = [];
3531
+ const playbookIds = new Set<string>();
3532
+ let markedCaptureEffectLedger:
3533
+ | DeepReadonly<PlaybookEffectLedger>
3534
+ | undefined;
3535
+ for (const [index, rawFrame] of generation.frames.entries()) {
3536
+ const framePath = `${generationPath}.frames[${index}]`;
3537
+ const frame = snapshotRecord(rawFrame, framePath);
3538
+ rejectSnapshotKeys(
3539
+ frame,
3540
+ [
3541
+ 'playbookId',
3542
+ 'sessionId',
3543
+ 'rootSessionId',
3544
+ 'depth',
3545
+ 'parentSessionId',
3546
+ 'parentCallId',
3547
+ 'options',
3548
+ 'roleBindings',
3549
+ 'runtime',
3550
+ ],
3551
+ framePath,
3552
+ );
3553
+ const playbookId = snapshotString(
3554
+ frame.playbookId,
3555
+ `${framePath}.playbookId`,
3556
+ );
3557
+ const frameEnablement = enablementById.get(playbookId);
3558
+ if (frameEnablement === undefined) {
3559
+ throw new TypeError(`${framePath} names a disabled playbook`);
3560
+ }
3561
+ if (playbookIds.has(playbookId)) {
3562
+ throw new TypeError(
3563
+ `${generationPath}.frames must not contain a playbook cycle`,
3564
+ );
3565
+ }
3566
+ playbookIds.add(playbookId);
3567
+ const sessionId = snapshotUuid(
3568
+ frame.sessionId,
3569
+ `${framePath}.sessionId`,
3570
+ );
3571
+ if (sourceSessionIds.has(sessionId)) {
3572
+ throw new TypeError(
3573
+ `${path} frame session ids must be unique across generations`,
3574
+ );
3575
+ }
3576
+ sourceSessionIds.add(sessionId);
3577
+ const rootSessionId = snapshotUuid(
3578
+ frame.rootSessionId,
3579
+ `${framePath}.rootSessionId`,
3580
+ );
3581
+ const depth = snapshotInteger(frame.depth, `${framePath}.depth`);
3582
+ const parentSessionId =
3583
+ frame.parentSessionId === undefined
3584
+ ? undefined
3585
+ : snapshotUuid(
3586
+ frame.parentSessionId,
3587
+ `${framePath}.parentSessionId`,
3588
+ );
3589
+ const parentCallId =
3590
+ frame.parentCallId === undefined
3591
+ ? undefined
3592
+ : snapshotString(
3593
+ frame.parentCallId,
3594
+ `${framePath}.parentCallId`,
3595
+ );
3596
+ const options = frame.options as JsonValue;
3597
+ if (
3598
+ index === 0 &&
3599
+ !isDeepStrictEqual(options, frameEnablement.options)
3600
+ ) {
3601
+ throw new TypeError(`${framePath}.options changed`);
3602
+ }
3603
+ const roleBindings = snapshotFrameRoleBindings(
3604
+ frame.roleBindings,
3605
+ `${framePath}.roleBindings`,
3606
+ );
3607
+ if (
3608
+ !isDeepStrictEqual(
3609
+ Object.keys(roleBindings).sort(),
3610
+ [...frameEnablement.entry.requiredRoleIds].sort(),
3611
+ )
3612
+ ) {
3613
+ throw new TypeError(
3614
+ `${framePath}.roleBindings do not cover the current role set`,
3615
+ );
3616
+ }
3617
+ const runtime = assertPlaybookRuntimeSnapshot(
3618
+ frame.runtime,
3619
+ playbookId,
3620
+ { allowSuspendedCall: true },
3621
+ );
3622
+ const retainedReconciliation = runtime.retainedEffectReconciliation;
3623
+ if (retainedReconciliation === undefined) {
3624
+ if (!isDeepStrictEqual(runtime.effectLedger, generationEffectLedger)) {
3625
+ throw new TypeError(
3626
+ `${framePath}.runtime effect ledger differs from the retained checkpoint`,
3627
+ );
3628
+ }
3629
+ } else {
3630
+ if (
3631
+ !isDeepStrictEqual(
3632
+ retainedReconciliation.checkpoint,
3633
+ generationEffectLedger,
3634
+ ) ||
3635
+ isDeepStrictEqual(runtime.effectLedger, generationEffectLedger) ||
3636
+ !isPlaybookEffectLedgerMonotonicExtension(
3637
+ runtime.effectLedger,
3638
+ authoritativeEffectLedger,
3639
+ )
3640
+ ) {
3641
+ throw new TypeError(
3642
+ `${framePath}.runtime retained-effect evidence is inconsistent`,
3643
+ );
3644
+ }
3645
+ if (markedCaptureEffectLedger === undefined) {
3646
+ markedCaptureEffectLedger = runtime.effectLedger;
3647
+ } else if (
3648
+ !isDeepStrictEqual(
3649
+ runtime.effectLedger,
3650
+ markedCaptureEffectLedger,
3651
+ )
3652
+ ) {
3653
+ throw new TypeError(
3654
+ `${framePath}.runtime effect ledger differs from the marked generation capture mirror`,
3655
+ );
3656
+ }
3657
+ }
3658
+ if (
3659
+ runtime.state.status !== 'active' ||
3660
+ !runtime.state.quiescent ||
3661
+ typeof runtime.state.stateId !== 'string' ||
3662
+ runtime.state.stateId.trim().length === 0
3663
+ ) {
3664
+ throw new TypeError(
3665
+ `${framePath}.runtime must be active, quiescent, and state-identified`,
3666
+ );
3667
+ }
3668
+ for (const question of runtime.pendingBossQuestions) {
3669
+ if (
3670
+ question.asker.kind === 'role' &&
3671
+ roleBindings[question.asker.roleId] === undefined
3672
+ ) {
3673
+ throw new TypeError(
3674
+ `${framePath}.runtime pending question names an unbound role`,
3675
+ );
3676
+ }
3677
+ }
3678
+ for (const roleId of Object.keys(runtime.roleResumeTokens)) {
3679
+ if (roleBindings[roleId] === undefined) {
3680
+ throw new TypeError(
3681
+ `${framePath}.runtime role-resume token names an unbound role`,
3682
+ );
3683
+ }
3684
+ }
3685
+ normalizedFrames.push({
3686
+ playbookId,
3687
+ sessionId,
3688
+ rootSessionId,
3689
+ depth,
3690
+ ...(parentSessionId === undefined ? {} : { parentSessionId }),
3691
+ ...(parentCallId === undefined ? {} : { parentCallId }),
3692
+ options,
3693
+ roleBindings,
3694
+ runtime,
3695
+ });
3696
+ }
3697
+ const sourceRootSessionId = normalizedFrames[0]!.sessionId;
3698
+ for (const [index, frame] of normalizedFrames.entries()) {
3699
+ if (frame.depth !== index || frame.rootSessionId !== sourceRootSessionId) {
3700
+ throw new TypeError(
3701
+ `${generationPath}.frames have inconsistent depth or root identity`,
3702
+ );
3703
+ }
3704
+ if (index === 0) {
3705
+ if (
3706
+ frame.playbookId !== rootPlaybookId ||
3707
+ frame.sessionId !== frame.rootSessionId ||
3708
+ frame.parentSessionId !== undefined ||
3709
+ frame.parentCallId !== undefined
3710
+ ) {
3711
+ throw new TypeError(
3712
+ `${generationPath}.frames[0] is not the named root`,
3713
+ );
3714
+ }
3715
+ continue;
3716
+ }
3717
+ const parent = normalizedFrames[index - 1]!;
3718
+ const suspended = parent.runtime.suspendedCall;
3719
+ if (
3720
+ frame.parentSessionId !== parent.sessionId ||
3721
+ frame.parentCallId === undefined ||
3722
+ suspended === undefined ||
3723
+ suspended.callId !== frame.parentCallId ||
3724
+ suspended.playbookId !== frame.playbookId ||
3725
+ suspended.childSessionId !== frame.sessionId
3726
+ ) {
3727
+ throw new TypeError(
3728
+ `${generationPath}.frames[${index}] does not match its suspended parent edge`,
3729
+ );
3730
+ }
3731
+ }
3732
+ const leaf = normalizedFrames.at(-1)!;
3733
+ if (
3734
+ leaf.runtime.suspendedCall !== undefined ||
3735
+ !leaf.runtime.state.tags.includes('playbook.parked')
3736
+ ) {
3737
+ throw new TypeError(
3738
+ `${generationPath} leaf must be parked without a suspended child`,
3739
+ );
3740
+ }
3741
+ const markedFrames = normalizedFrames.filter(
3742
+ ({ runtime }) => runtime.retainedEffectReconciliation !== undefined,
3743
+ );
3744
+ if (
3745
+ (sourceGenerationId === undefined && markedFrames.length !== 0) ||
3746
+ (sourceGenerationId !== undefined &&
3747
+ markedFrames.length !== normalizedFrames.length) ||
3748
+ (sourceGenerationId !== undefined &&
3749
+ normalizedFrames[0]!.runtime.retainedEffectReconciliation
3750
+ ?.sourceSessionId !== sourceGenerationId)
3751
+ ) {
3752
+ throw new TypeError(
3753
+ `${generationPath} retained-effect source marker is inconsistent`,
3754
+ );
3755
+ }
3756
+ normalized.set(
3757
+ rootPlaybookId,
3758
+ snapshotJsonValue(
3759
+ {
3760
+ effectLedger: generationEffectLedger,
3761
+ frames: normalizedFrames,
3762
+ ...(sourceGenerationId === undefined
3763
+ ? {}
3764
+ : {
3765
+ retainedEffectReconciliation: { sourceGenerationId },
3766
+ }),
3767
+ ...(rootStateDescription === undefined
3768
+ ? {}
3769
+ : { rootStateDescription }),
3770
+ },
3771
+ generationPath,
3772
+ ) as unknown as PlaybookCaptainRetainedGeneration,
3773
+ );
3774
+ }
3775
+ return normalized;
3776
+ };
3777
+
3778
+ const runtimeRetainsGenerations = (runtime: PlaybookRuntime): boolean => {
3779
+ const metadata = runtime.retainedGenerationMetadata;
3780
+ return (
3781
+ typeof runtime.exportSnapshot === 'function' &&
3782
+ typeof runtime.restore === 'function' &&
3783
+ typeof runtime.adopt === 'function' &&
3784
+ metadata !== undefined &&
3785
+ Array.isArray(metadata.unfinishedFinalStateIds) &&
3786
+ metadata.unfinishedFinalStateIds.every(
3787
+ (stateId) => typeof stateId === 'string',
3788
+ )
3789
+ );
3790
+ };
3791
+
3792
+ class RetainedRuntimeCleanupError extends AggregateError {
3793
+ readonly failedRuntimes: readonly PlaybookRuntime[];
3794
+
3795
+ constructor(
3796
+ failures: readonly unknown[],
3797
+ message: string,
3798
+ failedRuntimes: readonly PlaybookRuntime[] = [],
3799
+ ) {
3800
+ super(failures, message);
3801
+ this.name = 'RetainedRuntimeCleanupError';
3802
+ this.failedRuntimes = failedRuntimes;
3803
+ }
3804
+ }
3805
+
3806
+ const disposeRetainedRuntimeSet = async (
3807
+ runtimes: readonly PlaybookRuntime[],
3808
+ message: string,
3809
+ ): Promise<void> => {
3810
+ const failures: unknown[] = [];
3811
+ const failedRuntimes: PlaybookRuntime[] = [];
3812
+ for (const runtime of [...runtimes].reverse()) {
3813
+ try {
3814
+ await runtime.dispose();
3815
+ } catch (error) {
3816
+ failures.push(error);
3817
+ failedRuntimes.unshift(runtime);
3818
+ }
3819
+ }
3820
+ if (failures.length > 0) {
3821
+ throw new RetainedRuntimeCleanupError(
3822
+ failures,
3823
+ message,
3824
+ failedRuntimes,
3825
+ );
3826
+ }
3827
+ };
3828
+
3829
+ const retireRetainedOffer = (rootPlaybookId: string): void => {
3830
+ const offer = retainedGenerationOffers.get(rootPlaybookId);
3831
+ if (offer === undefined) return;
3832
+ retainedGenerationOffers.delete(rootPlaybookId);
3833
+ retiredRetainedRuntimes.push(...offer.runtimes);
3834
+ };
3835
+
3836
+ const applyRetentionUpdateToCatalog = (
3837
+ update: PlaybookCaptainRetentionUpdate,
3838
+ ): void => {
3839
+ if (update.kind === 'clear') {
3840
+ retireRetainedOffer(update.rootPlaybookId);
3841
+ retainedGenerations.delete(update.rootPlaybookId);
3842
+ ineligibleRetainedGenerations.delete(update.rootPlaybookId);
3843
+ retainedGenerationRootClears.delete(update.rootPlaybookId);
3844
+ return;
3845
+ }
3846
+ const prior = retainedGenerations.get(update.rootPlaybookId);
3847
+ if (isDeepStrictEqual(prior, update.generation)) return;
3848
+ retireRetainedOffer(update.rootPlaybookId);
3849
+ retainedGenerations.set(update.rootPlaybookId, update.generation);
3850
+ ineligibleRetainedGenerations.delete(update.rootPlaybookId);
3851
+ retainedGenerationRootClears.delete(update.rootPlaybookId);
3852
+ };
3853
+
3854
+ const drainRetiredRetainedRuntimes = async (): Promise<void> => {
3855
+ if (retiredRetainedRuntimes.length === 0) return;
3856
+ const runtimes = retiredRetainedRuntimes.splice(0);
3857
+ try {
3858
+ await disposeRetainedRuntimeSet(
3859
+ runtimes,
3860
+ 'retired retained-generation runtime cleanup failed',
3861
+ );
3862
+ } catch (error) {
3863
+ if (error instanceof RetainedRuntimeCleanupError) {
3864
+ retiredRetainedRuntimes.unshift(...error.failedRuntimes);
3865
+ }
3866
+ terminallyDisposed = true;
3867
+ lifecycle = 'closed';
3868
+ throw error;
3869
+ }
3870
+ };
3871
+
3872
+ const takeRetainedOfferRuntimes = (): readonly PlaybookRuntime[] => {
3873
+ const runtimes = [
3874
+ ...[...retainedGenerationOffers.values()].flatMap((offer) => [
3875
+ ...offer.runtimes,
3876
+ ]),
3877
+ ...retiredRetainedRuntimes.splice(0),
3878
+ ];
3879
+ retainedGenerationOffers.clear();
3880
+ return runtimes;
3881
+ };
3882
+
3883
+ const prepareRetainedGenerationOffers = async (): Promise<void> => {
3884
+ if (rootFrame() !== undefined) return;
3885
+ for (const [rootPlaybookId, generation] of [...retainedGenerations].sort(
3886
+ ([left], [right]) => left.localeCompare(right),
3887
+ )) {
3888
+ if (
3889
+ retainedGenerationOffers.has(rootPlaybookId) ||
3890
+ ineligibleRetainedGenerations.has(rootPlaybookId)
3891
+ ) {
3892
+ continue;
3893
+ }
3894
+ const runtimes: PlaybookRuntime[] = [];
3895
+ try {
3896
+ for (const sourceFrame of generation.frames) {
3897
+ const enablement = enablementById.get(sourceFrame.playbookId)!;
3898
+ runtimes.push(
3899
+ createRuntimeForEnablement(enablement, hostCapabilitiesById),
3900
+ );
3901
+ }
3902
+ if (runtimes.some((runtime) => !runtimeRetainsGenerations(runtime))) {
3903
+ const rootRetainsGenerations = runtimeRetainsGenerations(runtimes[0]!);
3904
+ await disposeRetainedRuntimeSet(
3905
+ runtimes,
3906
+ `/${enablementById.get(rootPlaybookId)!.command} retained-generation capability cleanup failed`,
3907
+ );
3908
+ runtimes.splice(0);
3909
+ ineligibleRetainedGenerations.add(rootPlaybookId);
3910
+ if (!rootRetainsGenerations) {
3911
+ retainedGenerationRootClears.add(rootPlaybookId);
3912
+ }
3913
+ continue;
3914
+ }
3915
+ retainedGenerationOffers.set(rootPlaybookId, {
3916
+ generation,
3917
+ requiresEffectReconciliation:
3918
+ generation.retainedEffectReconciliation !== undefined ||
3919
+ generation.frames.some(
3920
+ ({ runtime }) =>
3921
+ runtime.retainedEffectReconciliation !== undefined,
3922
+ ) ||
3923
+ !retainedEffectLedgerCanRebase(
3924
+ generation.effectLedger as PlaybookEffectLedger,
3925
+ assertPlaybookEffectLedger(currentEffectLedger()),
3926
+ ),
3927
+ runtimes,
3928
+ });
3929
+ } catch (error) {
3930
+ if (error instanceof RetainedRuntimeCleanupError) {
3931
+ retiredRetainedRuntimes.push(...error.failedRuntimes);
3932
+ terminallyDisposed = true;
3933
+ lifecycle = 'closed';
3934
+ throw error;
3935
+ }
3936
+ let cleanupError: unknown;
3937
+ if (runtimes.length > 0) {
3938
+ try {
3939
+ await disposeRetainedRuntimeSet(
3940
+ runtimes,
3941
+ 'retained-generation preparation cleanup failed',
3942
+ );
3943
+ } catch (caught) {
3944
+ cleanupError = caught;
3945
+ }
3946
+ }
3947
+ if (cleanupError !== undefined) {
3948
+ if (cleanupError instanceof RetainedRuntimeCleanupError) {
3949
+ retiredRetainedRuntimes.push(
3950
+ ...cleanupError.failedRuntimes,
3951
+ );
3952
+ }
3953
+ terminallyDisposed = true;
3954
+ lifecycle = 'closed';
3955
+ throw new RetainedRuntimeCleanupError(
3956
+ [error, cleanupError],
3957
+ 'retained-generation preparation and cleanup failed',
3958
+ cleanupError instanceof RetainedRuntimeCleanupError
3959
+ ? cleanupError.failedRuntimes
3960
+ : [],
3961
+ );
3962
+ }
3963
+ ineligibleRetainedGenerations.add(rootPlaybookId);
3964
+ }
3965
+ }
3966
+ };
3967
+
2322
3968
  const bindingFor = (
2323
3969
  frame: EngagementFrame,
2324
3970
  localRole: string,
@@ -2370,7 +4016,10 @@ export function createPlaybookCaptainShell(
2370
4016
  ...(leafFrame()?.state
2371
4017
  ? { latestSubRuntimeState: leafFrame()!.state }
2372
4018
  : {}),
2373
- ...(pendingBossQuestions !== undefined ? { pendingBossQuestions } : {}),
4019
+ ...(retainedEffectReconciliation === undefined &&
4020
+ pendingBossQuestions !== undefined
4021
+ ? { pendingBossQuestions }
4022
+ : {}),
2374
4023
  ...(lastError ? { lastError } : {}),
2375
4024
  ...(captainSessionId ? { captainSessionId } : {}),
2376
4025
  // Presence only: the pinned token value never reaches telemetry
@@ -2591,6 +4240,69 @@ export function createPlaybookCaptainShell(
2591
4240
  }
2592
4241
  };
2593
4242
 
4243
+ const observeSummaryTrace = (
4244
+ frame: EngagementFrame,
4245
+ payload: unknown,
4246
+ ): void => {
4247
+ const trace = payloadRecord(payload);
4248
+ const turn = activeTurn;
4249
+ const summary = activeTurnSummary;
4250
+ const expectedParentSessionId = frame.parent?.frame.sessionId;
4251
+ const expectedParentCallId = frame.parent?.callId;
4252
+ if (
4253
+ trace?.schemaVersion !== 4 ||
4254
+ trace.sessionId !== frame.sessionId ||
4255
+ trace.playbookId !== frame.entry.id ||
4256
+ trace.rootSessionId !== frame.rootSessionId ||
4257
+ (expectedParentSessionId === undefined
4258
+ ? Object.hasOwn(trace, 'parentSessionId')
4259
+ : !Object.hasOwn(trace, 'parentSessionId') ||
4260
+ trace.parentSessionId !== expectedParentSessionId) ||
4261
+ (expectedParentCallId === undefined
4262
+ ? Object.hasOwn(trace, 'parentCallId')
4263
+ : !Object.hasOwn(trace, 'parentCallId') ||
4264
+ trace.parentCallId !== expectedParentCallId) ||
4265
+ trace.depth !== frame.depth ||
4266
+ turn === undefined ||
4267
+ !Number.isSafeInteger(trace.turnId) ||
4268
+ (trace.turnId as number) <= 0 ||
4269
+ !Number.isSafeInteger(trace.sequence) ||
4270
+ (trace.sequence as number) <= 0 ||
4271
+ summary === undefined ||
4272
+ !summaryIncludes(frame)
4273
+ ) {
4274
+ return;
4275
+ }
4276
+ if (trace.type !== 'outcome.accepted') return;
4277
+ const receipt = exactOwnDataRecord(trace.payload, [
4278
+ 'source',
4279
+ 'target',
4280
+ 'acceptedOutcome',
4281
+ ]);
4282
+ if (
4283
+ receipt === undefined ||
4284
+ typeof receipt.source !== 'string' ||
4285
+ receipt.source.trim().length === 0 ||
4286
+ typeof receipt.target !== 'string' ||
4287
+ receipt.target.trim().length === 0 ||
4288
+ typeof receipt.acceptedOutcome !== 'string' ||
4289
+ receipt.acceptedOutcome.trim().length === 0
4290
+ ) {
4291
+ return;
4292
+ }
4293
+ const traceKey = `${frame.sessionId}:${trace.sequence}`;
4294
+ if (summary.acceptedOutcomeTraceKeys.has(traceKey)) return;
4295
+ summary.acceptedOutcomeTraceKeys.add(traceKey);
4296
+ summary.counts.interruptions++;
4297
+ if (
4298
+ frame.entry.summaryPolicy?.copyPasteGuardNames.includes(
4299
+ receipt.acceptedOutcome,
4300
+ )
4301
+ ) {
4302
+ summary.counts.copyPastes++;
4303
+ }
4304
+ };
4305
+
2594
4306
  let callNestedPlaybook: (
2595
4307
  frame: EngagementFrame,
2596
4308
  request: PlaybookCallRequest,
@@ -2787,16 +4499,6 @@ export function createPlaybookCaptainShell(
2787
4499
  if (result.finalText === undefined) {
2788
4500
  throw new Error('callCaptain returned status=ok with no finalText');
2789
4501
  }
2790
- const guard = guardFromJudgeReply(result.finalText);
2791
- const summary = activeTurnSummary;
2792
- if (
2793
- guard &&
2794
- summary &&
2795
- summaryIncludes(frame) &&
2796
- frame.entry.summaryPolicy?.copyPasteGuardNames.includes(guard)
2797
- ) {
2798
- summary.counts.copyPastes++;
2799
- }
2800
4502
  return result.finalText;
2801
4503
  },
2802
4504
  callPlaybook: (request, signal) => {
@@ -2832,6 +4534,9 @@ export function createPlaybookCaptainShell(
2832
4534
  await mirrorSubRuntimeTelemetry(frame, event.payload);
2833
4535
  }
2834
4536
  await requireSession().emitTelemetry(event);
4537
+ if (event.topic === 'playbook.trace') {
4538
+ observeSummaryTrace(frame, event.payload);
4539
+ }
2835
4540
  })();
2836
4541
  return trackHostCall(frame, emission);
2837
4542
  },
@@ -2855,7 +4560,7 @@ export function createPlaybookCaptainShell(
2855
4560
  }
2856
4561
  };
2857
4562
 
2858
- const allocateSessionId = (): string => {
4563
+ const generatedSessionId = (): string => {
2859
4564
  const sessionId = createSessionId();
2860
4565
  if (!UUID_PATTERN.test(sessionId)) {
2861
4566
  throw new Error(
@@ -2864,6 +4569,11 @@ export function createPlaybookCaptainShell(
2864
4569
  )}`,
2865
4570
  );
2866
4571
  }
4572
+ return sessionId;
4573
+ };
4574
+
4575
+ const allocateSessionId = (): string => {
4576
+ const sessionId = generatedSessionId();
2867
4577
  if (issuedSessionIds.has(sessionId)) {
2868
4578
  throw new Error(`playbook session id collision: ${sessionId}`);
2869
4579
  }
@@ -2871,6 +4581,30 @@ export function createPlaybookCaptainShell(
2871
4581
  return sessionId;
2872
4582
  };
2873
4583
 
4584
+ const allocateAdoptionSessionIds = (
4585
+ count: number,
4586
+ sourceSessionIds: ReadonlySet<string>,
4587
+ ): readonly string[] => {
4588
+ const candidates: string[] = [];
4589
+ const rejectedSourceIds = new Set<string>();
4590
+ while (candidates.length < count) {
4591
+ const candidate = generatedSessionId();
4592
+ if (sourceSessionIds.has(candidate)) {
4593
+ if (rejectedSourceIds.has(candidate)) {
4594
+ throw new Error(`playbook source session id collision: ${candidate}`);
4595
+ }
4596
+ rejectedSourceIds.add(candidate);
4597
+ continue;
4598
+ }
4599
+ if (issuedSessionIds.has(candidate) || candidates.includes(candidate)) {
4600
+ throw new Error(`playbook session id collision: ${candidate}`);
4601
+ }
4602
+ candidates.push(candidate);
4603
+ }
4604
+ for (const candidate of candidates) issuedSessionIds.add(candidate);
4605
+ return candidates;
4606
+ };
4607
+
2874
4608
  const normalizeErrorFull = (value: unknown): NormalizedError => {
2875
4609
  const compact = normalizeErrorCompact(value) ?? {
2876
4610
  name: 'Error',
@@ -2898,7 +4632,10 @@ export function createPlaybookCaptainShell(
2898
4632
  const entry = enablement.entry;
2899
4633
  const sessionId = allocateSessionId();
2900
4634
  const playerBindings = makePlayerBindings(enablement);
2901
- const runtime = entry.createRuntime(enablement.options);
4635
+ const runtime = createRuntimeForEnablement(
4636
+ enablement,
4637
+ hostCapabilitiesById,
4638
+ );
2902
4639
  return {
2903
4640
  entry,
2904
4641
  enablement,
@@ -2919,7 +4656,10 @@ export function createPlaybookCaptainShell(
2919
4656
  ): EngagementFrame => {
2920
4657
  const entry = enablement.entry;
2921
4658
  const playerBindings = makePlayerBindings(enablement);
2922
- const runtime = entry.createRuntime(enablement.options);
4659
+ const runtime = createRuntimeForEnablement(
4660
+ enablement,
4661
+ hostCapabilitiesById,
4662
+ );
2923
4663
  return {
2924
4664
  entry,
2925
4665
  enablement,
@@ -2976,16 +4716,6 @@ export function createPlaybookCaptainShell(
2976
4716
  try {
2977
4717
  if (resumeToken === undefined) delete ledger.resumeToken;
2978
4718
  else ledger.resumeToken = resumeToken;
2979
- // CAPTAIN-20: a result counts only after the runtime validated it and
2980
- // atomically published its authorized continuation transition.
2981
- const summary = activeTurnSummary;
2982
- if (
2983
- pending.status === 'ok' &&
2984
- summary &&
2985
- summaryIncludes(frame)
2986
- ) {
2987
- summary.counts.interruptions++;
2988
- }
2989
4719
  } finally {
2990
4720
  playerTransactions.delete(binding.playerId);
2991
4721
  }
@@ -3265,6 +4995,7 @@ export function createPlaybookCaptainShell(
3265
4995
  }
3266
4996
  }
3267
4997
  clearLeafLedger();
4998
+ if (frames.length === 0) retainedEffectReconciliation = undefined;
3268
4999
  if (failures.length === 1) throw failures[0];
3269
5000
  if (failures.length > 1) {
3270
5001
  throw new AggregateError(
@@ -3374,10 +5105,62 @@ export function createPlaybookCaptainShell(
3374
5105
  }
3375
5106
  };
3376
5107
 
5108
+ /**
5109
+ * DR-040 task 11: abandonment is a host settlement, not an authored FSM
5110
+ * result. Freeze the bounded evidence while the complete stack and its
5111
+ * runtime-owned envelope identities still exist, durably fence recovery,
5112
+ * dispose leaf-to-root without resuming a parent, then publish the matching
5113
+ * root clear and evidence as one durable completion before the controller
5114
+ * may return an executed receipt to its result phase.
5115
+ */
5116
+ const settleUnresolvedEffectAbandonment = async (
5117
+ unresolvedLeaf: EngagementFrame,
5118
+ ): Promise<void> => {
5119
+ if (leafFrame() !== unresolvedLeaf) {
5120
+ throw new Error(
5121
+ 'unresolved-effect abandonment requires the active leaf',
5122
+ );
5123
+ }
5124
+ const root = rootFrame();
5125
+ if (root === undefined) {
5126
+ throw new Error(
5127
+ 'unresolved-effect abandonment requires an active root',
5128
+ );
5129
+ }
5130
+ const unresolvedEffects = freezeTurnUnresolvedEffects();
5131
+ if (unresolvedEffects.length === 0) {
5132
+ throw new Error(
5133
+ 'unresolved-effect abandonment requires nonempty effect evidence',
5134
+ );
5135
+ }
5136
+ if (unresolvedEffectSettlement === undefined) {
5137
+ throw new Error(
5138
+ 'unresolved-effect abandonment requires durable host settlement',
5139
+ );
5140
+ }
5141
+ const rootPlaybookId = root.entry.id;
5142
+ const settlement = Object.freeze({
5143
+ rootPlaybookId,
5144
+ unresolvedEffects,
5145
+ });
5146
+ await runEffect(() => unresolvedEffectSettlement.begin(settlement));
5147
+ await runEffect(() => disposeStack('unresolved-effect'));
5148
+ pendingRetentionUpdates.set(rootPlaybookId, {
5149
+ kind: 'clear',
5150
+ rootPlaybookId,
5151
+ });
5152
+ await runEffect(() => unresolvedEffectSettlement.complete(settlement));
5153
+ };
5154
+
3377
5155
  const callResultFor = (
3378
5156
  frame: EngagementFrame,
3379
5157
  result: PlaybookRunResult,
3380
5158
  ): PlaybookCallResult => {
5159
+ if (result.outcome === 'unresolved-effect') {
5160
+ throw new Error(
5161
+ `playbook ${frame.entry.id} unresolved-effect result cannot resume a parent`,
5162
+ );
5163
+ }
3381
5164
  if (result.outcome === 'terminal') {
3382
5165
  return {
3383
5166
  status: 'ok',
@@ -3425,6 +5208,11 @@ export function createPlaybookCaptainShell(
3425
5208
  if (leafFrame() !== frame) {
3426
5209
  throw new Error('only the active leaf may receive Boss input');
3427
5210
  }
5211
+ if (retainedEffectReconciliation !== undefined) {
5212
+ throw new Error(
5213
+ 'retained repository-effect reconciliation is required before Boss input',
5214
+ );
5215
+ }
3428
5216
  // CAPTAIN-35: the leaf check, the visibility request, and the mode change
3429
5217
  // are shell control work performed on the way to the runtime, not the
3430
5218
  // effect. Only the call below is the effect, so only it is inside the
@@ -3451,6 +5239,11 @@ export function createPlaybookCaptainShell(
3451
5239
  const parentLink = child.parent;
3452
5240
  if (!parentLink) throw new Error('root playbook has no caller');
3453
5241
  const parent = parentLink.frame;
5242
+ if (retainedEffectReconciliation !== undefined) {
5243
+ throw new Error(
5244
+ 'retained repository-effect reconciliation is required before parent resumption',
5245
+ );
5246
+ }
3454
5247
  const invocationSignal = child.invocationSignal;
3455
5248
  let effectiveResult = callResult;
3456
5249
  let ownsReturn = false;
@@ -3535,6 +5328,16 @@ export function createPlaybookCaptainShell(
3535
5328
  result: PlaybookRunResult,
3536
5329
  context: CaptainContext,
3537
5330
  ): Promise<void> {
5331
+ if (result.outcome === 'unresolved-effect') {
5332
+ // Task 10 exposes the runtime-owned abandonment signal without
5333
+ // translating it into a nested result or claiming workflow completion.
5334
+ // Task 11 owns the durable host settlement and complete-stack disposal.
5335
+ assertRetainableResult(frame, result);
5336
+ if (leafFrame() === frame) {
5337
+ await setMode('engaged.parked', 'turn:unresolved-effect');
5338
+ }
5339
+ return;
5340
+ }
3538
5341
  if (result.outcome === 'terminal') {
3539
5342
  if (frame.parent) {
3540
5343
  await resumeParent(frame, callResultFor(frame, result), context);
@@ -3545,6 +5348,7 @@ export function createPlaybookCaptainShell(
3545
5348
  // output remains runtime-to-runtime data and never becomes Captain
3546
5349
  // evidence (CAPPLAY-10).
3547
5350
  activeTurn?.settlementFacts.push(rootCompletionFact(frame, result));
5351
+ recordTerminalRetention(frame, result);
3548
5352
  await runEffect(() => disposeStack('final'));
3549
5353
  }
3550
5354
  return;
@@ -3761,7 +5565,14 @@ export function createPlaybookCaptainShell(
3761
5565
  const policy = frame.entry.summaryPolicy;
3762
5566
  const counts: TurnSummaryCounts = { interruptions: 0, copyPastes: 0 };
3763
5567
  const stateCounts = new Map<string, number>();
3764
- activeTurnSummary = policy ? { owner: frame, counts, stateCounts } : undefined;
5568
+ activeTurnSummary = policy
5569
+ ? {
5570
+ owner: frame,
5571
+ counts,
5572
+ stateCounts,
5573
+ acceptedOutcomeTraceKeys: new Set(),
5574
+ }
5575
+ : undefined;
3765
5576
  let result: T | undefined;
3766
5577
  let error: unknown;
3767
5578
  try {
@@ -3834,12 +5645,79 @@ export function createPlaybookCaptainShell(
3834
5645
  ];
3835
5646
  };
3836
5647
 
5648
+ const retainedResumptionDigest = (): string => {
5649
+ if (rootFrame() !== undefined) {
5650
+ return 'Retained resumptions: unavailable while a playbook is engaged.';
5651
+ }
5652
+ const offers = [...retainedGenerationOffers].sort(([left], [right]) =>
5653
+ left.localeCompare(right),
5654
+ );
5655
+ if (offers.length === 0) return 'Retained resumptions: none.';
5656
+ const lines = ['Retained resumptions:'];
5657
+ for (const [rootPlaybookId, offer] of offers) {
5658
+ const enablement = enablementById.get(rootPlaybookId)!;
5659
+ lines.push(
5660
+ digestLine`- ${rootPlaybookId} (/${enablement.command}): ${
5661
+ offer.generation.rootStateDescription ??
5662
+ '(no published root-state description was retained)'
5663
+ }`,
5664
+ );
5665
+ }
5666
+ return lines.join('\n');
5667
+ };
5668
+
3837
5669
  const controlViewDigest = (): string => {
3838
5670
  const leaf = leafFrame();
3839
5671
  const lines: string[] = [digestLine`Active path: ${activePathDigest()}`];
3840
5672
  if (!leaf) {
3841
5673
  lines.push('The shell is idle: no leaf state, no pending question.');
3842
5674
  lines.push('Advertised actions: none.');
5675
+ lines.push(retainedResumptionDigest());
5676
+ return lines.join('\n');
5677
+ }
5678
+ refreshRetainedEffectFence();
5679
+ if (retainedEffectReconciliation !== undefined) {
5680
+ let reconciliationActions: readonly {
5681
+ readonly id: string;
5682
+ readonly label: string;
5683
+ }[] = [];
5684
+ if (
5685
+ typeof leaf.runtime.describe === 'function' &&
5686
+ typeof leaf.runtime.apply === 'function'
5687
+ ) {
5688
+ try {
5689
+ reconciliationActions = leaf.runtime
5690
+ .describe()
5691
+ .actions.filter(
5692
+ ({ id }) =>
5693
+ id === UNRESOLVED_EFFECT_RECONCILIATION_ACTION_ID ||
5694
+ id === UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID,
5695
+ );
5696
+ } catch {
5697
+ // An unreadable control surface cannot open a fail-closed fence.
5698
+ }
5699
+ }
5700
+ for (const action of reconciliationActions) {
5701
+ recordSuppliedIdentifier(action.id);
5702
+ }
5703
+ lines.push(
5704
+ `Leaf ${frameLabel(leaf)} is parked for repository-effect reconciliation.`,
5705
+ );
5706
+ lines.push('Pending Boss questions: withheld until reconciliation.');
5707
+ lines.push(
5708
+ reconciliationActions.length === 0
5709
+ ? 'Advertised actions: none.'
5710
+ : [
5711
+ 'Advertised actions:',
5712
+ ...reconciliationActions.map(
5713
+ (action) => digestLine`- ${action.id}: ${action.label}`,
5714
+ ),
5715
+ ].join('\n'),
5716
+ );
5717
+ lines.push(
5718
+ '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.',
5719
+ );
5720
+ lines.push(retainedResumptionDigest());
3843
5721
  return lines.join('\n');
3844
5722
  }
3845
5723
  let view: PlaybookControlView | undefined;
@@ -3897,6 +5775,7 @@ export function createPlaybookCaptainShell(
3897
5775
  ? '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.'
3898
5776
  : '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.',
3899
5777
  );
5778
+ lines.push(retainedResumptionDigest());
3900
5779
  return lines.join('\n');
3901
5780
  }
3902
5781
  // CAPTAIN-9: the guarded set is what the digest supplies *for selection* —
@@ -3949,6 +5828,7 @@ export function createPlaybookCaptainShell(
3949
5828
  ),
3950
5829
  ].join('\n'),
3951
5830
  );
5831
+ lines.push(retainedResumptionDigest());
3952
5832
  return lines.join('\n');
3953
5833
  };
3954
5834
 
@@ -4043,9 +5923,14 @@ export function createPlaybookCaptainShell(
4043
5923
  // all of this prose. Preserve the exact attempt before crossing the
4044
5924
  // boundary; the uncertainty record below keeps recovery from pretending
4045
5925
  // delivery was confirmed while still understanding a Boss follow-up.
4046
- appendJournal('reply', settlement.text);
5926
+ const suffix = turn?.mandatoryPresentationSuffix;
5927
+ const visibleText =
5928
+ suffix === undefined || settlement.text.includes(suffix)
5929
+ ? settlement.text
5930
+ : `${settlement.text.trimEnd()}\n\n${suffix}`;
5931
+ appendJournal('reply', visibleText);
4047
5932
  try {
4048
- await trackTurnCall(settlement.context.emitReply(settlement.text));
5933
+ await trackTurnCall(settlement.context.emitReply(visibleText));
4049
5934
  } catch (error) {
4050
5935
  conversation = { kind: 'needsSeeding' };
4051
5936
  const normalized = normalizeErrorCompact(error) ?? {
@@ -4211,8 +6096,9 @@ export function createPlaybookCaptainShell(
4211
6096
  };
4212
6097
 
4213
6098
  /**
4214
- * CAPTAIN-35: the one wrapper an effect runs through — a runtime driven, an
4215
- * engagement constructed, a stack disposed, an advertised action applied.
6099
+ * CAPTAIN-35: the one wrapper an effect runs through — a runtime driven or
6100
+ * adopted, an engagement constructed, a stack disposed, an advertised
6101
+ * action applied.
4216
6102
  * Attribution is recorded here, at the operation that threw, and nowhere
4217
6103
  * else: an error acquires the mark by escaping this call, so no later
4218
6104
  * failure can inherit it.
@@ -4225,7 +6111,7 @@ export function createPlaybookCaptainShell(
4225
6111
  * instead of settling, so a misfiling costs the Boss their only settlement.
4226
6112
  *
4227
6113
  * Neither can a boundary drawn around a *region* of the turn. `operation` is
4228
- * therefore always one call expression naming one of those four operations,
6114
+ * therefore always one call expression naming one of those five operations,
4229
6115
  * never a closure that also performs the shell work leading to it: the leaf
4230
6116
  * check, the visibility request, the mode change, and the processing of what
4231
6117
  * the runtime returned are all shell control work, and a boundary wide
@@ -4493,7 +6379,10 @@ export function createPlaybookCaptainShell(
4493
6379
  ...(kind === 'closingReply' && turn?.report
4494
6380
  ? [
4495
6381
  labeledBlock('ControlView digest', controlViewDigest()),
4496
- outcomeReportBlock(turn.report),
6382
+ outcomeReportBlock(
6383
+ turn.report,
6384
+ turn.unresolvedEffects ?? [],
6385
+ ),
4497
6386
  ]
4498
6387
  : []),
4499
6388
  ...(options.proseRejection === undefined
@@ -4670,6 +6559,9 @@ export function createPlaybookCaptainShell(
4670
6559
  const leafStateSummary = (): string | undefined => {
4671
6560
  const leaf = leafFrame();
4672
6561
  if (!leaf) return 'idle: no playbook is engaged';
6562
+ if (retainedEffectReconciliation !== undefined) {
6563
+ return `${frameLabel(leaf)} parked for repository-effect reconciliation`;
6564
+ }
4673
6565
  if (!leaf.state) return `${frameLabel(leaf)} engaged`;
4674
6566
  return `${frameLabel(leaf)} at ${stateDigestLine(
4675
6567
  leaf.state,
@@ -4681,9 +6573,9 @@ export function createPlaybookCaptainShell(
4681
6573
  selection: CaptainControllerSelection | undefined,
4682
6574
  reason: string,
4683
6575
  options: { silent?: boolean } = {},
4684
- ): Promise<SettlementEvidence> => {
6576
+ ): Promise<ControllerSettlementDraft> => {
4685
6577
  const summary = leafStateSummary();
4686
- const settlement: SettlementEvidence = {
6578
+ const settlement: ControllerSettlementDraft = {
4687
6579
  status: 'rejected',
4688
6580
  facts: [`Rejected: ${reason}.`],
4689
6581
  reason,
@@ -4729,6 +6621,9 @@ export function createPlaybookCaptainShell(
4729
6621
  const root = rootFrame();
4730
6622
  if (!root) return false;
4731
6623
  const label = frameLabel(root);
6624
+ // Dismissal leaves the procedure unfinished. Persist the latest safe
6625
+ // generation captured for this turn before disposal erases the frames.
6626
+ retainOrClearDisposedRoot(root);
4732
6627
  try {
4733
6628
  await runEffect(() => disposeStack('dismiss'));
4734
6629
  facts.push(`Dismissed the ${label} engagement.`);
@@ -4785,6 +6680,163 @@ export function createPlaybookCaptainShell(
4785
6680
  return { frame, report: outcome.report, failed: false };
4786
6681
  };
4787
6682
 
6683
+ const adoptRetainedGeneration = async (
6684
+ rootPlaybookId: string,
6685
+ offer: RetainedGenerationOffer,
6686
+ ): Promise<readonly string[]> => {
6687
+ const generation = offer.generation;
6688
+ const currentLedger = assertPlaybookEffectLedger(currentEffectLedger());
6689
+ const requiresEffectReconciliation =
6690
+ offer.requiresEffectReconciliation ||
6691
+ !retainedEffectLedgerCanRebase(
6692
+ generation.effectLedger as PlaybookEffectLedger,
6693
+ currentLedger,
6694
+ );
6695
+ const sourceSessionIds = new Set(
6696
+ generation.frames.map((frame) => frame.sessionId),
6697
+ );
6698
+ const targetSessionIds = allocateAdoptionSessionIds(
6699
+ generation.frames.length,
6700
+ sourceSessionIds,
6701
+ );
6702
+ const targetRootSessionId = targetSessionIds[0]!;
6703
+ const adoptedFrames: EngagementFrame[] = [];
6704
+ for (const [index, sourceFrame] of generation.frames.entries()) {
6705
+ const enablement = enablementById.get(sourceFrame.playbookId)!;
6706
+ const parent = adoptedFrames.at(-1);
6707
+ adoptedFrames.push({
6708
+ entry: enablement.entry,
6709
+ enablement,
6710
+ runtime: offer.runtimes[index]!,
6711
+ sessionId: targetSessionIds[index]!,
6712
+ rootSessionId: targetRootSessionId,
6713
+ depth: index,
6714
+ playerBindings: makePlayerBindings(enablement),
6715
+ ...(parent
6716
+ ? { parent: { frame: parent, callId: 'playbook-1' } }
6717
+ : {}),
6718
+ state: sourceFrame.runtime.state,
6719
+ inFlightHostCalls: new Set(),
6720
+ });
6721
+ }
6722
+
6723
+ retainedGenerationOffers.delete(rootPlaybookId);
6724
+ let installed = false;
6725
+ try {
6726
+ for (const [index, frame] of adoptedFrames.entries()) {
6727
+ const sourceFrame = generation.frames[index]!;
6728
+ const targetChild = adoptedFrames[index + 1];
6729
+ await runEffect(() =>
6730
+ frame.runtime.adopt!(
6731
+ frameSession(frame),
6732
+ sourceFrame.runtime,
6733
+ {
6734
+ sourceSessionId: sourceFrame.sessionId,
6735
+ sourceGenerationId: generation.frames[0]!.rootSessionId,
6736
+ ...(targetChild === undefined
6737
+ ? {}
6738
+ : { targetChildSessionId: targetChild.sessionId }),
6739
+ },
6740
+ ),
6741
+ );
6742
+ }
6743
+
6744
+ frames.push(...adoptedFrames);
6745
+ installed = true;
6746
+ retainedEffectReconciliation = requiresEffectReconciliation
6747
+ ? {
6748
+ sourceGenerationId:
6749
+ generation.retainedEffectReconciliation?.sourceGenerationId ??
6750
+ generation.frames[0]!.runtime.retainedEffectSourceSessionId ??
6751
+ generation.frames[0]!.rootSessionId,
6752
+ checkpoint: generation.effectLedger,
6753
+ }
6754
+ : undefined;
6755
+ for (const parent of adoptedFrames.slice(0, -1)) {
6756
+ pendingChildParents.add(parent);
6757
+ }
6758
+ const retainedQuestions = generation.frames.at(-1)!.runtime
6759
+ .pendingBossQuestions;
6760
+ pendingBossQuestions =
6761
+ requiresEffectReconciliation || retainedQuestions.length === 0
6762
+ ? undefined
6763
+ : mirroredBossQuestions(retainedQuestions);
6764
+ lastError = undefined;
6765
+ retainedGenerations.delete(rootPlaybookId);
6766
+ ineligibleRetainedGenerations.delete(rootPlaybookId);
6767
+ await setMode(
6768
+ 'engaged.parked',
6769
+ 'resume',
6770
+ rootPlaybookId,
6771
+ targetRootSessionId,
6772
+ );
6773
+ if (requiresEffectReconciliation) {
6774
+ await requireSession().setVisiblePlayers([]);
6775
+ } else {
6776
+ await requestVisibility(adoptedFrames.at(-1)!);
6777
+ }
6778
+ await requireSession().emitStatus(
6779
+ `◇ /${enablementById.get(rootPlaybookId)!.command} resumed`,
6780
+ );
6781
+ if (activeTurn) {
6782
+ appendMandatoryPresentationSuffix(
6783
+ activeTurn,
6784
+ requiresEffectReconciliation
6785
+ ? 'The retained work remains parked until its repository-effect evidence is reconciled.'
6786
+ : RESUMPTION_DUPLICATE_EFFECT_WARNING,
6787
+ );
6788
+ }
6789
+ return [
6790
+ generation.rootStateDescription === undefined
6791
+ ? `Resumed /${enablementById.get(rootPlaybookId)!.command} from its retained state; no published root-state description was retained.`
6792
+ : `Resumed /${enablementById.get(rootPlaybookId)!.command} from the retained state described as ${quoteEvidence(compactEvidence(generation.rootStateDescription))}.`,
6793
+ requiresEffectReconciliation
6794
+ ? 'The retained work remains parked until its repository-effect evidence is reconciled; no ordinary action was resumed.'
6795
+ : RESUMPTION_DUPLICATE_EFFECT_WARNING,
6796
+ ];
6797
+ } catch (error) {
6798
+ if (installed) {
6799
+ frames.splice(0);
6800
+ pendingChildParents.clear();
6801
+ retainedEffectReconciliation = undefined;
6802
+ clearLeafLedger();
6803
+ }
6804
+ const cleanupFailures: unknown[] = [];
6805
+ const failedCleanupRuntimes: PlaybookRuntime[] = [];
6806
+ for (const frame of [...adoptedFrames].reverse()) {
6807
+ try {
6808
+ await disposeFrame(frame);
6809
+ } catch (cleanupError) {
6810
+ cleanupFailures.push(cleanupError);
6811
+ failedCleanupRuntimes.push(frame.runtime);
6812
+ }
6813
+ }
6814
+ const rollbackFailures: unknown[] = [];
6815
+ if (installed) {
6816
+ try {
6817
+ await setMode('chat', 'resume.failed');
6818
+ } catch (rollbackError) {
6819
+ rollbackFailures.push(rollbackError);
6820
+ }
6821
+ } else {
6822
+ mode = 'chat';
6823
+ }
6824
+ if (cleanupFailures.length > 0 || rollbackFailures.length > 0) {
6825
+ retiredRetainedRuntimes.push(...failedCleanupRuntimes);
6826
+ ineligibleRetainedGenerations.add(rootPlaybookId);
6827
+ terminallyDisposed = true;
6828
+ lifecycle = 'closed';
6829
+ throw new AggregateError(
6830
+ [error, ...cleanupFailures, ...rollbackFailures],
6831
+ 'retained-generation adoption and rollback failed',
6832
+ );
6833
+ }
6834
+ retainedGenerations.set(rootPlaybookId, generation);
6835
+ ineligibleRetainedGenerations.delete(rootPlaybookId);
6836
+ throw error;
6837
+ }
6838
+ };
6839
+
4788
6840
  const driveAndProcess = async (
4789
6841
  frame: EngagementFrame,
4790
6842
  text: string,
@@ -4815,14 +6867,46 @@ export function createPlaybookCaptainShell(
4815
6867
  }
4816
6868
  };
4817
6869
 
6870
+ const containsEffectThrow = (turn: ActiveTurn, error: unknown): boolean => {
6871
+ if (turn.effectThrows.has(error)) return true;
6872
+ return (
6873
+ error instanceof AggregateError &&
6874
+ error.errors.some((nested) => containsEffectThrow(turn, nested))
6875
+ );
6876
+ };
6877
+
4818
6878
  const settleSelection = async (
4819
6879
  selection: CaptainControllerSelection,
4820
6880
  signal: AbortSignal,
4821
6881
  ): Promise<SettlementEvidence> => {
4822
6882
  const turn = activeTurn;
4823
6883
  runFailureFacts = [];
6884
+ let frozenUnresolvedEffects:
6885
+ | readonly PlaybookCaptainUnresolvedEffect[]
6886
+ | undefined;
6887
+ const freezeControllerEvidence =
6888
+ (): readonly PlaybookCaptainUnresolvedEffect[] => {
6889
+ frozenUnresolvedEffects ??= freezeTurnUnresolvedEffects();
6890
+ const report = unresolvedEffectBossReport(frozenUnresolvedEffects);
6891
+ if (turn !== undefined && report !== undefined) {
6892
+ appendMandatoryPresentationSuffix(turn, report);
6893
+ }
6894
+ return frozenUnresolvedEffects;
6895
+ };
6896
+ const finalizeSettlement = (
6897
+ settlement: ControllerSettlementDraft,
6898
+ ): SettlementEvidence =>
6899
+ Object.freeze({
6900
+ ...settlement,
6901
+ unresolvedEffects: freezeControllerEvidence(),
6902
+ });
4824
6903
  try {
4825
- return await executeSelection(selection, signal);
6904
+ // `respond` has no result phase: freeze its no-effect projection before
6905
+ // its decision-call prose crosses the presentation boundary. Acting
6906
+ // selections freeze after their work and before reporting begins.
6907
+ if (selection.action === 'respond') freezeControllerEvidence();
6908
+ const settlement = await executeSelection(selection, signal);
6909
+ return finalizeSettlement(settlement);
4826
6910
  } catch (error) {
4827
6911
  if (turn?.presentationError === error) throw error;
4828
6912
  const aborted = signal.aborted || activeContext?.signal.aborted === true;
@@ -4853,7 +6937,7 @@ export function createPlaybookCaptainShell(
4853
6937
  }
4854
6938
  const mayHaveApplied =
4855
6939
  selection.action !== 'respond' &&
4856
- turn.effectThrows.has(error);
6940
+ containsEffectThrow(turn, error);
4857
6941
  turn.settlementFacts.push(
4858
6942
  mayHaveApplied
4859
6943
  ? `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.`
@@ -4896,7 +6980,7 @@ export function createPlaybookCaptainShell(
4896
6980
  };
4897
6981
  turn.settled = true;
4898
6982
  lastSettlementStatus = 'failed';
4899
- return {
6983
+ const settlement: ControllerSettlementDraft = {
4900
6984
  status: 'failed',
4901
6985
  facts: [...turn.settlementFacts],
4902
6986
  ...(turn.report.receipt === undefined
@@ -4904,6 +6988,7 @@ export function createPlaybookCaptainShell(
4904
6988
  : { receipt: turn.report.receipt }),
4905
6989
  ...(summary === undefined ? {} : { leafStateSummary: summary }),
4906
6990
  };
6991
+ return finalizeSettlement(settlement);
4907
6992
  } finally {
4908
6993
  runFailureFacts = undefined;
4909
6994
  }
@@ -4920,7 +7005,7 @@ export function createPlaybookCaptainShell(
4920
7005
  const executeSelection = async (
4921
7006
  selection: CaptainControllerSelection,
4922
7007
  signal: AbortSignal,
4923
- ): Promise<SettlementEvidence> => {
7008
+ ): Promise<ControllerSettlementDraft> => {
4924
7009
  const context = activeContext;
4925
7010
  const turn = activeTurn;
4926
7011
  if (!context || !turn) {
@@ -4973,6 +7058,63 @@ export function createPlaybookCaptainShell(
4973
7058
  };
4974
7059
  }
4975
7060
 
7061
+ refreshRetainedEffectFence();
7062
+ const fencedLeaf = leafFrame();
7063
+ const routesRetainedReconciliation =
7064
+ selection.action === 'runtime' &&
7065
+ (selection.actionId === UNRESOLVED_EFFECT_RECONCILIATION_ACTION_ID ||
7066
+ selection.actionId === UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID) &&
7067
+ fencedLeaf !== undefined;
7068
+ if (
7069
+ retainedEffectReconciliation !== undefined &&
7070
+ !routesRetainedReconciliation
7071
+ ) {
7072
+ return rejectSelection(
7073
+ selection,
7074
+ 'retained work must reconcile its repository-effect evidence before an ordinary action can run',
7075
+ );
7076
+ }
7077
+
7078
+ if (selection.action === 'resume') {
7079
+ const entry = byId.get(selection.playbookId);
7080
+ if (entry === undefined) {
7081
+ return rejectSelection(
7082
+ selection,
7083
+ `"${selection.playbookId}" is not an enabled playbook`,
7084
+ );
7085
+ }
7086
+ if (rootFrame() !== undefined) {
7087
+ return rejectSelection(
7088
+ selection,
7089
+ 'a playbook is already engaged; its live actions take precedence',
7090
+ );
7091
+ }
7092
+ const offer = retainedGenerationOffers.get(entry.id);
7093
+ if (offer === undefined) {
7094
+ return rejectSelection(
7095
+ selection,
7096
+ `/${enablementById.get(entry.id)!.command} has no resumable retained generation`,
7097
+ );
7098
+ }
7099
+ turn.settled = true;
7100
+ journalAction({ action: 'resume', playbookId: entry.id });
7101
+ facts.push(...(await adoptRetainedGeneration(entry.id, offer)));
7102
+ const summary = leafStateSummary();
7103
+ turn.report = {
7104
+ ...emptyReport(),
7105
+ facts,
7106
+ status: 'ok',
7107
+ ...(summary === undefined ? {} : { leafStateSummary: summary }),
7108
+ };
7109
+ journalOutcome([...facts]);
7110
+ lastSettlementStatus = 'ok';
7111
+ return {
7112
+ status: 'ok',
7113
+ facts: [...facts],
7114
+ ...(summary === undefined ? {} : { leafStateSummary: summary }),
7115
+ };
7116
+ }
7117
+
4976
7118
  if (selection.action === 'start' || selection.action === 'switch') {
4977
7119
  const entry = byId.get(selection.playbookId);
4978
7120
  if (!entry) {
@@ -5239,6 +7381,7 @@ export function createPlaybookCaptainShell(
5239
7381
  );
5240
7382
  if (outcome.error !== undefined) throw outcome.error;
5241
7383
  const receipt = outcome.result!;
7384
+ refreshRetainedEffectFence();
5242
7385
  let status: SettlementEvidence['status'] =
5243
7386
  receipt.disposition === 'executed'
5244
7387
  ? 'ok'
@@ -5259,9 +7402,53 @@ export function createPlaybookCaptainShell(
5259
7402
  }
5260
7403
  : {}),
5261
7404
  };
7405
+ const unresolvedAbandonment =
7406
+ receipt.disposition === 'executed' &&
7407
+ actionId === UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID;
5262
7408
  if (receipt.disposition === 'executed') {
5263
- facts.push(`Applied "${actionId}" on ${frameLabel(leaf)}.`);
7409
+ if (
7410
+ unresolvedAbandonment &&
7411
+ receipt.run?.outcome !== 'unresolved-effect'
7412
+ ) {
7413
+ throw new Error(
7414
+ 'unresolved-effect abandonment returned no unresolved-effect result',
7415
+ );
7416
+ }
5264
7417
  const establishedSummary = leafStateSummary();
7418
+ if (unresolvedAbandonment) {
7419
+ try {
7420
+ await settleUnresolvedEffectAbandonment(leaf);
7421
+ } catch (error) {
7422
+ abandonmentSettlementUnsafe = true;
7423
+ const normalized = normalizeErrorCompact(error) ?? {
7424
+ name: 'Error',
7425
+ message: String(error),
7426
+ };
7427
+ // The runtime action was accepted, but its distinct host-level
7428
+ // settlement did not complete. Do not expose an `executed` control
7429
+ // receipt until both disposal and durable publication have
7430
+ // succeeded.
7431
+ turn.report = {
7432
+ ...outcome.report,
7433
+ facts: [...facts],
7434
+ bossFacts: facts.map((fact) =>
7435
+ fact
7436
+ .split(`"${actionId}"`)
7437
+ .join(`"${compactEvidence(actionLabel)}"`),
7438
+ ),
7439
+ status: 'failed',
7440
+ receipt: {
7441
+ disposition: 'failed',
7442
+ error: normalized,
7443
+ },
7444
+ ...(establishedSummary === undefined
7445
+ ? {}
7446
+ : { leafStateSummary: establishedSummary }),
7447
+ };
7448
+ throw error;
7449
+ }
7450
+ }
7451
+ facts.push(`Applied "${actionId}" on ${frameLabel(leaf)}.`);
5265
7452
  // Execution is now proven. Preserve that receipt and the counts already
5266
7453
  // collected before processing the returned run, because disposal,
5267
7454
  // telemetry, or parent resumption can still fail afterward.
@@ -5279,7 +7466,11 @@ export function createPlaybookCaptainShell(
5279
7466
  ? {}
5280
7467
  : { leafStateSummary: establishedSummary }),
5281
7468
  };
5282
- if (receipt.run !== undefined) {
7469
+ if (
7470
+ !unresolvedAbandonment &&
7471
+ receipt.run !== undefined &&
7472
+ retainedEffectReconciliation === undefined
7473
+ ) {
5283
7474
  // The same rule as the drive path: processing the run the receipt
5284
7475
  // carried is not itself an effect, and the resume or disposal it may
5285
7476
  // perform is marked where it happens (CAPTAIN-35).
@@ -5520,6 +7711,42 @@ export function createPlaybookCaptainShell(
5520
7711
  `Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} role bindings changed`,
5521
7712
  );
5522
7713
  }
7714
+ if (
7715
+ !isDeepStrictEqual(frame.runtime.effectLedger, snapshot.effectLedger)
7716
+ ) {
7717
+ throw new TypeError(
7718
+ `Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} effect ledger does not match its artifact schema`,
7719
+ );
7720
+ }
7721
+ const runtimeReconciliation =
7722
+ frame.runtime.retainedEffectReconciliation;
7723
+ if (snapshot.retainedEffectReconciliation === undefined) {
7724
+ if (runtimeReconciliation !== undefined) {
7725
+ throw new TypeError(
7726
+ `Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} carries an unmirrored retained-effect fence`,
7727
+ );
7728
+ }
7729
+ } else if (
7730
+ runtimeReconciliation === undefined ||
7731
+ !isDeepStrictEqual(
7732
+ runtimeReconciliation.checkpoint,
7733
+ snapshot.retainedEffectReconciliation.checkpoint,
7734
+ )
7735
+ ) {
7736
+ throw new TypeError(
7737
+ `Captain shell snapshot frame ${JSON.stringify(frame.playbookId)} does not mirror the root retained-effect fence`,
7738
+ );
7739
+ }
7740
+ if (
7741
+ frame.depth === 0 &&
7742
+ snapshot.retainedEffectReconciliation !== undefined &&
7743
+ runtimeReconciliation?.sourceSessionId !==
7744
+ snapshot.retainedEffectReconciliation.sourceGenerationId
7745
+ ) {
7746
+ throw new TypeError(
7747
+ 'Captain shell snapshot retained-effect root source identity differs from its generation',
7748
+ );
7749
+ }
5523
7750
  }
5524
7751
  };
5525
7752
 
@@ -5585,40 +7812,44 @@ export function createPlaybookCaptainShell(
5585
7812
  });
5586
7813
  };
5587
7814
 
5588
- const exportShellSnapshot = (): PlaybookCaptainShellSnapshot | undefined => {
5589
- if (
5590
- !safeCapturePoint() ||
5591
- !captainRuntime ||
5592
- !captainSessionId ||
5593
- !captainAgent
5594
- ) {
5595
- return undefined;
5596
- }
7815
+ const captureFrameSnapshots = (
7816
+ requireRecordedState: boolean,
7817
+ ): readonly PlaybookCaptainFrameSnapshot[] | undefined => {
7818
+ const captured: PlaybookCaptainFrameSnapshot[] = [];
5597
7819
  try {
5598
- if (
5599
- typeof captainRuntime.exportSnapshot !== 'function' ||
5600
- typeof captainRuntime.restore !== 'function'
5601
- ) {
5602
- return undefined;
5603
- }
5604
- const captainSnapshot = captainRuntime.exportSnapshot();
5605
- if (captainSnapshot === undefined) return undefined;
5606
- const frameSnapshots: PlaybookCaptainFrameSnapshot[] = [];
5607
- for (const frame of frames) {
7820
+ for (const [index, frame] of frames.entries()) {
5608
7821
  if (
5609
7822
  typeof frame.runtime.exportSnapshot !== 'function' ||
5610
7823
  typeof frame.runtime.restore !== 'function'
5611
7824
  ) {
5612
7825
  return undefined;
5613
7826
  }
5614
- const runtime = frame.runtime.exportSnapshot();
7827
+ const exported = frame.runtime.exportSnapshot();
7828
+ if (exported === undefined) return undefined;
7829
+ const runtime = assertPlaybookRuntimeSnapshot(
7830
+ exported,
7831
+ frame.entry.id,
7832
+ { allowSuspendedCall: true },
7833
+ );
7834
+ if (
7835
+ runtime.state.status !== 'active' ||
7836
+ !runtime.state.quiescent ||
7837
+ (requireRecordedState && frame.state === undefined) ||
7838
+ (frame.state !== undefined &&
7839
+ !isDeepStrictEqual(frame.state, runtime.state))
7840
+ ) {
7841
+ return undefined;
7842
+ }
7843
+ const isLeaf = index === frames.length - 1;
5615
7844
  if (
5616
- runtime === undefined ||
5617
- !isDeepStrictEqual(frame.state, runtime.state)
7845
+ (isLeaf &&
7846
+ (runtime.suspendedCall !== undefined ||
7847
+ !runtime.state.tags.includes('playbook.parked'))) ||
7848
+ (!isLeaf && runtime.suspendedCall === undefined)
5618
7849
  ) {
5619
7850
  return undefined;
5620
7851
  }
5621
- frameSnapshots.push({
7852
+ captured.push({
5622
7853
  playbookId: frame.entry.id,
5623
7854
  sessionId: frame.sessionId,
5624
7855
  rootSessionId: frame.rootSessionId,
@@ -5639,8 +7870,253 @@ export function createPlaybookCaptainShell(
5639
7870
  runtime,
5640
7871
  });
5641
7872
  }
7873
+ for (let index = 1; index < captured.length; index += 1) {
7874
+ const parent = captured[index - 1]!;
7875
+ const child = captured[index]!;
7876
+ if (
7877
+ child.parentSessionId !== parent.sessionId ||
7878
+ child.parentCallId === undefined ||
7879
+ parent.runtime.suspendedCall?.callId !== child.parentCallId ||
7880
+ parent.runtime.suspendedCall.playbookId !== child.playbookId ||
7881
+ parent.runtime.suspendedCall.childSessionId !== child.sessionId
7882
+ ) {
7883
+ return undefined;
7884
+ }
7885
+ }
7886
+ return captured;
7887
+ } catch {
7888
+ return undefined;
7889
+ }
7890
+ };
7891
+
7892
+ const refreshRetainedEffectFence = (
7893
+ capturedFrames?: readonly PlaybookCaptainFrameSnapshot[],
7894
+ capturedLedger?: PlaybookEffectLedger,
7895
+ ): void => {
7896
+ const fence = retainedEffectReconciliation;
7897
+ if (fence === undefined) return;
7898
+ try {
7899
+ const ledger =
7900
+ capturedLedger ?? assertPlaybookEffectLedger(currentEffectLedger());
7901
+ if (
7902
+ !retainedEffectLedgerCanRebase(
7903
+ fence.checkpoint as PlaybookEffectLedger,
7904
+ ledger,
7905
+ )
7906
+ ) {
7907
+ return;
7908
+ }
7909
+ const snapshots = capturedFrames ?? captureFrameSnapshots(true);
7910
+ if (snapshots === undefined || snapshots.length !== frames.length) return;
7911
+ for (const frameSnapshot of snapshots) {
7912
+ const runtime = frameSnapshot.runtime;
7913
+ if (
7914
+ !isDeepStrictEqual(runtime.effectLedger, ledger) ||
7915
+ runtime.retainedEffectSourceSessionId === undefined ||
7916
+ runtime.retainedEffectReconciliation !== undefined
7917
+ ) {
7918
+ return;
7919
+ }
7920
+ }
7921
+ const retainedQuestions = snapshots.at(-1)!.runtime.pendingBossQuestions;
7922
+ const restoredQuestions =
7923
+ retainedQuestions.length === 0
7924
+ ? undefined
7925
+ : mirroredBossQuestions(retainedQuestions);
7926
+ pendingBossQuestions = restoredQuestions;
7927
+ retainedEffectReconciliation = undefined;
7928
+ } catch {
7929
+ // Any host-ledger or runtime-snapshot defect leaves the root fence shut.
7930
+ }
7931
+ };
7932
+
7933
+ const rootStateDescriptionForRetention = (
7934
+ root: EngagementFrame,
7935
+ retainedState: PlaybookState,
7936
+ ): string | undefined => {
7937
+ if (typeof root.runtime.describe !== 'function') return undefined;
7938
+ try {
7939
+ const view = root.runtime.describe();
7940
+ if (!isDeepStrictEqual(view.state, retainedState)) return undefined;
7941
+ return typeof view.stateDescription === 'string' &&
7942
+ view.stateDescription.trim().length > 0
7943
+ ? view.stateDescription
7944
+ : undefined;
7945
+ } catch {
7946
+ return undefined;
7947
+ }
7948
+ };
7949
+
7950
+ const retainedGenerationFromFrames = (
7951
+ root: EngagementFrame,
7952
+ frameSnapshots: readonly PlaybookCaptainFrameSnapshot[],
7953
+ ): PlaybookCaptainRetainedGeneration => {
7954
+ const rootStateDescription = rootStateDescriptionForRetention(
7955
+ root,
7956
+ frameSnapshots[0]!.runtime.state,
7957
+ );
7958
+ const checkpoint =
7959
+ retainedEffectReconciliation?.checkpoint ??
7960
+ assertPlaybookEffectLedger(currentEffectLedger());
7961
+ if (
7962
+ checkpoint.boundaries.some(
7963
+ ({ physicalReceipt }) => physicalReceipt === undefined,
7964
+ )
7965
+ ) {
7966
+ throw new TypeError(
7967
+ 'Captain retained-generation checkpoint contains an incomplete physical boundary',
7968
+ );
7969
+ }
7970
+ return snapshotJsonValue(
7971
+ {
7972
+ effectLedger: checkpoint,
7973
+ frames: frameSnapshots,
7974
+ ...(retainedEffectReconciliation === undefined
7975
+ ? {}
7976
+ : {
7977
+ retainedEffectReconciliation: {
7978
+ sourceGenerationId:
7979
+ retainedEffectReconciliation.sourceGenerationId,
7980
+ },
7981
+ }),
7982
+ ...(rootStateDescription === undefined
7983
+ ? {}
7984
+ : { rootStateDescription }),
7985
+ },
7986
+ 'Captain retained generation',
7987
+ ) as unknown as PlaybookCaptainRetainedGeneration;
7988
+ };
7989
+
7990
+ const captureRetainedGeneration =
7991
+ (): PlaybookCaptainRetainedGeneration | undefined => {
7992
+ const root = rootFrame();
7993
+ if (
7994
+ !root ||
7995
+ frames.some((frame) => !runtimeRetainsGenerations(frame.runtime))
7996
+ ) {
7997
+ return undefined;
7998
+ }
7999
+ const frameSnapshots = captureFrameSnapshots(true);
8000
+ if (frameSnapshots === undefined || frameSnapshots.length === 0) {
8001
+ return undefined;
8002
+ }
8003
+ return retainedGenerationFromFrames(root, frameSnapshots);
8004
+ };
8005
+
8006
+ const rememberRetainedGeneration = (): void => {
8007
+ const root = rootFrame();
8008
+ if (!root) return;
8009
+ if (frames.some((frame) => !runtimeRetainsGenerations(frame.runtime))) {
8010
+ retainedGenerationCandidates.set(root.entry.id, {
8011
+ status: 'incapable',
8012
+ });
8013
+ return;
8014
+ }
8015
+ const generation = captureRetainedGeneration();
8016
+ retainedGenerationCandidates.set(
8017
+ root.entry.id,
8018
+ generation === undefined
8019
+ ? { status: 'unsafe' }
8020
+ : { status: 'captured', generation },
8021
+ );
8022
+ };
8023
+
8024
+ const retentionUpdateForPriorGeneration = (
8025
+ root: EngagementFrame,
8026
+ ): PlaybookCaptainRetentionUpdate | undefined => {
8027
+ const rootPlaybookId = root.entry.id;
8028
+ if (!runtimeRetainsGenerations(root.runtime)) {
8029
+ return {
8030
+ kind: 'clear',
8031
+ rootPlaybookId,
8032
+ };
8033
+ }
8034
+ const candidate = retainedGenerationCandidates.get(rootPlaybookId);
8035
+ if (candidate?.status === 'incapable') {
8036
+ return undefined;
8037
+ }
8038
+ if (candidate?.status !== 'captured') {
8039
+ throw new Error(
8040
+ `${frameLabel(root)} could not capture its pre-terminal retained generation`,
8041
+ );
8042
+ }
8043
+ return {
8044
+ kind: 'retain',
8045
+ rootPlaybookId,
8046
+ generation: candidate.generation,
8047
+ };
8048
+ };
8049
+
8050
+ const retainOrClearDisposedRoot = (root: EngagementFrame): void => {
8051
+ const update = retentionUpdateForPriorGeneration(root);
8052
+ if (update !== undefined) {
8053
+ pendingRetentionUpdates.set(update.rootPlaybookId, update);
8054
+ }
8055
+ };
8056
+
8057
+ const recordTerminalRetention = (
8058
+ root: EngagementFrame,
8059
+ result: Extract<PlaybookRunResult, { outcome: 'terminal' }>,
8060
+ ): void => {
8061
+ const rootPlaybookId = root.entry.id;
8062
+ if (!runtimeRetainsGenerations(root.runtime)) {
8063
+ pendingRetentionUpdates.set(rootPlaybookId, {
8064
+ kind: 'clear',
8065
+ rootPlaybookId,
8066
+ });
8067
+ return;
8068
+ }
8069
+ const terminalStateId = result.state.stateId;
8070
+ if (
8071
+ typeof terminalStateId !== 'string' ||
8072
+ terminalStateId.trim().length === 0
8073
+ ) {
8074
+ throw new Error(
8075
+ `${frameLabel(root)} terminal result has no stable state id for retention`,
8076
+ );
8077
+ }
8078
+ const unfinished =
8079
+ root.runtime.retainedGenerationMetadata!.unfinishedFinalStateIds.includes(
8080
+ terminalStateId,
8081
+ );
8082
+ if (unfinished) {
8083
+ // A root opened and terminated within this turn has no pre-turn,
8084
+ // work-bearing generation. Leave any earlier store entry untouched.
8085
+ if (!retainedGenerationCandidates.has(rootPlaybookId)) return;
8086
+ retainOrClearDisposedRoot(root);
8087
+ } else {
8088
+ pendingRetentionUpdates.set(rootPlaybookId, {
8089
+ kind: 'clear',
8090
+ rootPlaybookId,
8091
+ });
8092
+ }
8093
+ };
8094
+
8095
+ const exportShellSnapshot = (): PlaybookCaptainShellSnapshot | undefined => {
8096
+ if (
8097
+ !safeCapturePoint() ||
8098
+ !captainRuntime ||
8099
+ !captainSessionId ||
8100
+ !captainAgent
8101
+ ) {
8102
+ return undefined;
8103
+ }
8104
+ try {
8105
+ if (
8106
+ typeof captainRuntime.exportSnapshot !== 'function' ||
8107
+ typeof captainRuntime.restore !== 'function'
8108
+ ) {
8109
+ return undefined;
8110
+ }
8111
+ const captainSnapshot = captainRuntime.exportSnapshot();
8112
+ if (captainSnapshot === undefined) return undefined;
8113
+ const frameSnapshots = captureFrameSnapshots(true);
8114
+ if (frameSnapshots === undefined) return undefined;
8115
+ const effectLedger = assertPlaybookEffectLedger(currentEffectLedger());
8116
+ refreshRetainedEffectFence(frameSnapshots, effectLedger);
5642
8117
  const common = {
5643
- schemaVersion: 3 as const,
8118
+ schemaVersion: 4 as const,
8119
+ effectLedger,
5644
8120
  captain: {
5645
8121
  sessionId: captainSessionId,
5646
8122
  runtime: captainSnapshot,
@@ -5663,6 +8139,9 @@ export function createPlaybookCaptainShell(
5663
8139
  ...common,
5664
8140
  mode: 'engaged.parked',
5665
8141
  frames: frameSnapshots,
8142
+ ...(retainedEffectReconciliation === undefined
8143
+ ? {}
8144
+ : { retainedEffectReconciliation }),
5666
8145
  ...(pendingBossQuestions === undefined
5667
8146
  ? {}
5668
8147
  : { pendingBossQuestions: pendingBossQuestions as JsonValue }),
@@ -5676,6 +8155,66 @@ export function createPlaybookCaptainShell(
5676
8155
  }
5677
8156
  };
5678
8157
 
8158
+ const exportSettlement = (): PlaybookCaptainSettlement | undefined => {
8159
+ if (!retentionSettlementReady || abandonmentSettlementUnsafe) {
8160
+ return undefined;
8161
+ }
8162
+ const snapshot = exportShellSnapshot();
8163
+ if (snapshot === undefined) return undefined;
8164
+ let unresolvedEffects: readonly PlaybookCaptainUnresolvedEffect[];
8165
+ try {
8166
+ unresolvedEffects =
8167
+ settledTurnUnresolvedEffects ?? currentUnresolvedEffects();
8168
+ } catch {
8169
+ return undefined;
8170
+ }
8171
+ const updates = new Map(pendingRetentionUpdates);
8172
+ for (const rootPlaybookId of retainedGenerationRootClears) {
8173
+ updates.set(rootPlaybookId, { kind: 'clear', rootPlaybookId });
8174
+ }
8175
+ const root = rootFrame();
8176
+ if (root !== undefined) {
8177
+ const rootPlaybookId = root.entry.id;
8178
+ if (frames.every((frame) => runtimeRetainsGenerations(frame.runtime))) {
8179
+ const generation =
8180
+ snapshot.mode === 'engaged.parked'
8181
+ ? retainedGenerationFromFrames(root, snapshot.frames)
8182
+ : undefined;
8183
+ if (generation !== undefined) {
8184
+ updates.set(rootPlaybookId, {
8185
+ kind: 'retain',
8186
+ rootPlaybookId,
8187
+ generation,
8188
+ });
8189
+ }
8190
+ } else if (!runtimeRetainsGenerations(root.runtime)) {
8191
+ updates.set(rootPlaybookId, { kind: 'clear', rootPlaybookId });
8192
+ } else if (retainedGenerationCandidates.has(rootPlaybookId)) {
8193
+ try {
8194
+ const update = retentionUpdateForPriorGeneration(root);
8195
+ if (update !== undefined) {
8196
+ updates.set(rootPlaybookId, update);
8197
+ }
8198
+ } catch {
8199
+ return undefined;
8200
+ }
8201
+ }
8202
+ }
8203
+ for (const update of updates.values()) {
8204
+ applyRetentionUpdateToCatalog(update);
8205
+ }
8206
+ return snapshotJsonValue(
8207
+ {
8208
+ snapshot,
8209
+ retentionUpdates: [...updates.values()].sort((left, right) =>
8210
+ left.rootPlaybookId.localeCompare(right.rootPlaybookId),
8211
+ ),
8212
+ unresolvedEffects,
8213
+ },
8214
+ 'Captain settlement',
8215
+ ) as unknown as PlaybookCaptainSettlement;
8216
+ };
8217
+
5679
8218
  const verifyRestoredRuntime = (
5680
8219
  runtime: PlaybookRuntime,
5681
8220
  expected: PlaybookRuntimeSnapshot,
@@ -5699,6 +8238,9 @@ export function createPlaybookCaptainShell(
5699
8238
  'sequences',
5700
8239
  'pendingBossQuestions',
5701
8240
  'suspendedCall',
8241
+ 'effectLedger',
8242
+ 'retainedEffectSourceSessionId',
8243
+ 'retainedEffectReconciliation',
5702
8244
  ] as const) {
5703
8245
  if (!isDeepStrictEqual(normalized[key], expected[key])) {
5704
8246
  throw new Error(
@@ -5718,6 +8260,17 @@ export function createPlaybookCaptainShell(
5718
8260
  cleanupFailures.push(error);
5719
8261
  }
5720
8262
  }
8263
+ const retainedRuntimes = takeRetainedOfferRuntimes();
8264
+ if (retainedRuntimes.length > 0) {
8265
+ try {
8266
+ await disposeRetainedRuntimeSet(
8267
+ retainedRuntimes,
8268
+ 'retained-generation restore cleanup failed',
8269
+ );
8270
+ } catch (error) {
8271
+ cleanupFailures.push(error);
8272
+ }
8273
+ }
5721
8274
  if (captainRuntime) {
5722
8275
  shuttingDown = true;
5723
8276
  try {
@@ -5734,11 +8287,20 @@ export function createPlaybookCaptainShell(
5734
8287
  byCommand = new Map();
5735
8288
  byId = new Map();
5736
8289
  enablementById = new Map();
8290
+ hostCapabilitiesById = new Map();
8291
+ pendingHostCapabilities = undefined;
8292
+ currentEffectLedger = () => emptyPlaybookEffectLedger();
5737
8293
  captainAgent = undefined;
5738
8294
  captainAdapter = undefined;
5739
8295
  playerAgents = new Map();
5740
8296
  playerLedger.clear();
5741
8297
  playerTransactions.clear();
8298
+ retainedGenerations.clear();
8299
+ ineligibleRetainedGenerations.clear();
8300
+ retainedGenerationRootClears.clear();
8301
+ retainedGenerationsInstalled = false;
8302
+ retainedGenerationInstallationInProgress = false;
8303
+ retainedGenerationInstallationClosed = false;
5742
8304
  session = undefined;
5743
8305
  sessionEmissionsOpen = false;
5744
8306
  closedGateAttempted = false;
@@ -5746,6 +8308,7 @@ export function createPlaybookCaptainShell(
5746
8308
  captainSessionId = undefined;
5747
8309
  conversation = { kind: 'unopened' };
5748
8310
  mode = 'chat';
8311
+ retainedEffectReconciliation = undefined;
5749
8312
  pendingBossQuestions = undefined;
5750
8313
  lastError = undefined;
5751
8314
  journalSeq = 0;
@@ -5775,7 +8338,16 @@ export function createPlaybookCaptainShell(
5775
8338
  lifecycle = 'restoring';
5776
8339
  try {
5777
8340
  const snapshot = assertPlaybookCaptainShellSnapshot(untrusted);
5778
- const built = await buildEnablements(options, loadModule);
8341
+ const built = await buildCurrentEnablements();
8342
+ const builtHostCapabilities = new Map(built.hostCapabilitiesById);
8343
+ const readEffectLedger = () =>
8344
+ effectLedgerMirrorFromCapabilities(builtHostCapabilities);
8345
+ const hostLedger = readEffectLedger();
8346
+ if (!isDeepStrictEqual(snapshot.effectLedger, hostLedger)) {
8347
+ throw new Error(
8348
+ 'Captain shell restore effect ledger does not match current-host authority',
8349
+ );
8350
+ }
5779
8351
  captainAgent = built.captainAgent;
5780
8352
  captainAdapter = captainAgent.adapter;
5781
8353
  playerAgents = built.playerAgents;
@@ -5786,6 +8358,8 @@ export function createPlaybookCaptainShell(
5786
8358
  byCommand = built.byCommand;
5787
8359
  byId = built.byId;
5788
8360
  enablementById = built.enablementById;
8361
+ hostCapabilitiesById = builtHostCapabilities;
8362
+ currentEffectLedger = readEffectLedger;
5789
8363
  for (const [playerId, saved] of Object.entries(snapshot.playerSessions)) {
5790
8364
  playerLedger.set(playerId, {
5791
8365
  adapter: saved.adapter,
@@ -5891,6 +8465,8 @@ export function createPlaybookCaptainShell(
5891
8465
  lastSettlementStatus = snapshot.lastSettlementStatus;
5892
8466
  mode = snapshot.mode;
5893
8467
  if (snapshot.mode === 'engaged.parked') {
8468
+ retainedEffectReconciliation =
8469
+ snapshot.retainedEffectReconciliation;
5894
8470
  pendingBossQuestions = snapshot.pendingBossQuestions;
5895
8471
  lastError = snapshot.lastError;
5896
8472
  }
@@ -5909,6 +8485,74 @@ export function createPlaybookCaptainShell(
5909
8485
  }
5910
8486
  };
5911
8487
 
8488
+ const installRetainedGenerations = async (
8489
+ generations: Readonly<
8490
+ Record<string, PlaybookCaptainRetainedGeneration>
8491
+ >,
8492
+ ): Promise<void> => {
8493
+ if (lifecycle !== 'ready' || terminallyDisposed) {
8494
+ throw new Error(
8495
+ 'retained generations require an initialized or restored Captain shell',
8496
+ );
8497
+ }
8498
+ if (
8499
+ retainedGenerationsInstalled ||
8500
+ retainedGenerationInstallationInProgress ||
8501
+ retainedGenerationInstallationClosed ||
8502
+ activeTurnHostCalls !== undefined
8503
+ ) {
8504
+ throw new Error(
8505
+ 'retained generations may be installed exactly once before the first nonempty Boss turn',
8506
+ );
8507
+ }
8508
+ const normalized = normalizeInstalledRetainedGenerations(generations);
8509
+ retainedGenerationInstallationInProgress = true;
8510
+ try {
8511
+ retainedGenerations.clear();
8512
+ retainedGenerationOffers.clear();
8513
+ ineligibleRetainedGenerations.clear();
8514
+ retainedGenerationRootClears.clear();
8515
+ for (const [rootPlaybookId, generation] of normalized) {
8516
+ retainedGenerations.set(rootPlaybookId, generation);
8517
+ }
8518
+ await prepareRetainedGenerationOffers();
8519
+ retainedGenerationsInstalled = true;
8520
+ } catch (error) {
8521
+ const preparationCleanupFailed =
8522
+ error instanceof RetainedRuntimeCleanupError;
8523
+ const runtimes = [...retainedGenerationOffers.values()].flatMap(
8524
+ (offer) => [...offer.runtimes],
8525
+ );
8526
+ retainedGenerationOffers.clear();
8527
+ retainedGenerations.clear();
8528
+ ineligibleRetainedGenerations.clear();
8529
+ retainedGenerationRootClears.clear();
8530
+ try {
8531
+ await disposeRetainedRuntimeSet(
8532
+ runtimes,
8533
+ 'retained-generation installation cleanup failed',
8534
+ );
8535
+ } catch (cleanupError) {
8536
+ if (cleanupError instanceof RetainedRuntimeCleanupError) {
8537
+ retiredRetainedRuntimes.push(...cleanupError.failedRuntimes);
8538
+ }
8539
+ terminallyDisposed = true;
8540
+ lifecycle = 'closed';
8541
+ throw new AggregateError(
8542
+ [error, cleanupError],
8543
+ 'retained-generation installation and cleanup failed',
8544
+ );
8545
+ }
8546
+ if (preparationCleanupFailed) {
8547
+ terminallyDisposed = true;
8548
+ lifecycle = 'closed';
8549
+ }
8550
+ throw error;
8551
+ } finally {
8552
+ retainedGenerationInstallationInProgress = false;
8553
+ }
8554
+ };
8555
+
5912
8556
  return {
5913
8557
  async init(initSession: CaptainSession): Promise<void> {
5914
8558
  if (lifecycle !== 'fresh' || terminallyDisposed) {
@@ -5920,11 +8564,17 @@ export function createPlaybookCaptainShell(
5920
8564
  lifecycle = 'initializing';
5921
8565
  try {
5922
8566
  installSession(initSession, true);
5923
- const built = await buildEnablements(options, loadModule);
8567
+ const built = await buildCurrentEnablements();
8568
+ const builtHostCapabilities = new Map(built.hostCapabilitiesById);
8569
+ const readEffectLedger = () =>
8570
+ effectLedgerMirrorFromCapabilities(builtHostCapabilities);
8571
+ readEffectLedger();
5924
8572
  entries = built.entries;
5925
8573
  byCommand = built.byCommand;
5926
8574
  byId = built.byId;
5927
8575
  enablementById = built.enablementById;
8576
+ hostCapabilitiesById = builtHostCapabilities;
8577
+ currentEffectLedger = readEffectLedger;
5928
8578
  captainAgent = built.captainAgent;
5929
8579
  captainAdapter = captainAgent.adapter;
5930
8580
  playerAgents = built.playerAgents;
@@ -5950,8 +8600,12 @@ export function createPlaybookCaptainShell(
5950
8600
 
5951
8601
  exportSnapshot: exportShellSnapshot,
5952
8602
 
8603
+ exportSettlement,
8604
+
5953
8605
  restore: restoreShellSnapshot,
5954
8606
 
8607
+ installRetainedGenerations,
8608
+
5955
8609
  async handleBossTurn(
5956
8610
  turn: BossTurn,
5957
8611
  context: CaptainContext,
@@ -5968,9 +8622,26 @@ export function createPlaybookCaptainShell(
5968
8622
  if (activeTurnHostCalls !== undefined) {
5969
8623
  throw new Error('cannot handle concurrent Boss turns');
5970
8624
  }
8625
+ if (retainedGenerationInstallationInProgress) {
8626
+ throw new Error(
8627
+ 'cannot handle a Boss turn while retained generations are installing',
8628
+ );
8629
+ }
5971
8630
  // Empty or whitespace-only input allocates no call, session, or
5972
8631
  // telemetry (CAPTAIN-7).
8632
+ retentionSettlementReady = false;
8633
+ abandonmentSettlementUnsafe = false;
5973
8634
  if (turn.prompt.trim().length === 0) return;
8635
+ settledTurnUnresolvedEffects = undefined;
8636
+ retainedGenerationInstallationClosed = true;
8637
+ for (const update of pendingRetentionUpdates.values()) {
8638
+ applyRetentionUpdateToCatalog(update);
8639
+ }
8640
+ retainedGenerationCandidates.clear();
8641
+ pendingRetentionUpdates.clear();
8642
+ // A terminal or dismissal can remove the whole stack during this turn;
8643
+ // take the latest already-settled generation before controller work.
8644
+ rememberRetainedGeneration();
5974
8645
  const turnHostCalls = new Set<Promise<unknown>>();
5975
8646
  activeTurnHostCalls = turnHostCalls;
5976
8647
  activeContext = context;
@@ -5993,6 +8664,8 @@ export function createPlaybookCaptainShell(
5993
8664
  decisionCall = undefined;
5994
8665
  appendJournal('boss', turn.prompt);
5995
8666
  try {
8667
+ await drainRetiredRetainedRuntimes();
8668
+ await prepareRetainedGenerationOffers();
5996
8669
  const result = await captainRuntime.handleBossInput({
5997
8670
  text: turn.prompt,
5998
8671
  signal: context.signal,
@@ -6064,11 +8737,16 @@ export function createPlaybookCaptainShell(
6064
8737
  activeTurnHostCalls = undefined;
6065
8738
  }
6066
8739
  activeContext = undefined;
8740
+ retentionSettlementReady = true;
6067
8741
  }
6068
8742
  },
6069
8743
 
6070
8744
  async prepareDispose(): Promise<void> {
6071
- if (lifecycle === 'initializing' || lifecycle === 'restoring') {
8745
+ if (
8746
+ lifecycle === 'initializing' ||
8747
+ lifecycle === 'restoring' ||
8748
+ retainedGenerationInstallationInProgress
8749
+ ) {
6072
8750
  throw new Error('cannot dispose while Captain shell setup is in progress');
6073
8751
  }
6074
8752
  activeContext = undefined;
@@ -6076,7 +8754,11 @@ export function createPlaybookCaptainShell(
6076
8754
  },
6077
8755
 
6078
8756
  async dispose(): Promise<void> {
6079
- if (lifecycle === 'initializing' || lifecycle === 'restoring') {
8757
+ if (
8758
+ lifecycle === 'initializing' ||
8759
+ lifecycle === 'restoring' ||
8760
+ retainedGenerationInstallationInProgress
8761
+ ) {
6080
8762
  throw new Error('cannot dispose while Captain shell setup is in progress');
6081
8763
  }
6082
8764
  activeContext = undefined;
@@ -6095,6 +8777,20 @@ export function createPlaybookCaptainShell(
6095
8777
  } catch (error) {
6096
8778
  failure = error;
6097
8779
  }
8780
+ const retainedRuntimes = takeRetainedOfferRuntimes();
8781
+ if (retainedRuntimes.length > 0) {
8782
+ try {
8783
+ await disposeRetainedRuntimeSet(
8784
+ retainedRuntimes,
8785
+ 'retained-generation shell cleanup failed',
8786
+ );
8787
+ } catch (error) {
8788
+ failure ??= error;
8789
+ }
8790
+ }
8791
+ retainedGenerations.clear();
8792
+ ineligibleRetainedGenerations.clear();
8793
+ retainedGenerationRootClears.clear();
6098
8794
  const runtime = captainRuntime;
6099
8795
  captainRuntime = undefined;
6100
8796
  if (runtime) {
@@ -6108,6 +8804,9 @@ export function createPlaybookCaptainShell(
6108
8804
  // Quarantine is session-wide by design. Only terminal teardown may drop
6109
8805
  // its ownership after every frame host call and the Captain are drained.
6110
8806
  playerTransactions.clear();
8807
+ hostCapabilitiesById = new Map();
8808
+ pendingHostCapabilities = undefined;
8809
+ currentEffectLedger = () => emptyPlaybookEffectLedger();
6111
8810
  lifecycle = 'closed';
6112
8811
  if (failure !== undefined) throw failure;
6113
8812
  }