@sublang/playbook 9.0.0 → 10.0.0

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