@sublang/playbook 16.0.0 → 17.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 (40) hide show
  1. package/README.md +2 -0
  2. package/docs/cli.md +36 -18
  3. package/docs/configuration.md +7 -5
  4. package/docs/embedding.md +26 -5
  5. package/package.json +4 -4
  6. package/reference/sdlc/captain.md +19 -10
  7. package/reference/sdlc/captain.playbook/captain.fsm.d.ts +6 -2
  8. package/reference/sdlc/captain.playbook/captain.fsm.js +19 -7
  9. package/reference/sdlc/captain.playbook/captain.fsm.ts +25 -10
  10. package/reference/sdlc/captain.playbook/captain.gears.md +16 -8
  11. package/reference/sdlc/captain.playbook/captain.playbook.d.ts +1 -1
  12. package/reference/sdlc/captain.playbook/captain.playbook.js +3 -2
  13. package/reference/sdlc/captain.playbook/captain.playbook.ts +4 -3
  14. package/reference/sdlc/code.playbook/bin/interactive-session.js +14 -1
  15. package/reference/sdlc/code.playbook/bin/portable-codec.js +3 -0
  16. package/reference/sdlc/code.playbook/bin/repository-effects.js +1 -0
  17. package/reference/sdlc/code.playbook/bin/run.js +128 -85
  18. package/reference/sdlc/code.playbook/bin/session-host.js +40 -8
  19. package/reference/sdlc/code.playbook/bin/session-store.js +162 -34
  20. package/reference/sdlc/code.playbook/code.fsm.d.ts +5 -5
  21. package/reference/sdlc/code.playbook/host-capabilities.d.ts +3 -0
  22. package/reference/sdlc/code.playbook/playbook-captain.d.ts +32 -2
  23. package/reference/sdlc/code.playbook/playbook-captain.js +729 -284
  24. package/reference/sdlc/code.playbook/playbook-captain.ts +771 -358
  25. package/reference/sdlc/code.playbook/session-host.d.ts +4 -1
  26. package/reference/sdlc/code.playbook/session-store.d.ts +17 -0
  27. package/reference/sdlc/code.playbook/session-store.js +1 -0
  28. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +5 -5
  29. package/reference/sdlc/dev.playbook/dev.fsm.d.ts +5 -5
  30. package/reference/sdlc/pr.playbook/pr.fsm.d.ts +5 -5
  31. package/slc/gears2fsm.md +5 -5
  32. package/slc/link.md +77 -33
  33. package/src/runtime.d.ts +46 -2
  34. package/src/runtime.ts +42 -2
  35. package/src/xstate-playbook-runtime.d.ts +9 -8
  36. package/src/xstate-playbook-runtime.js +471 -215
  37. package/src/xstate-playbook-runtime.ts +525 -238
  38. package/src/xstate-runtime.d.ts +3 -4
  39. package/src/xstate-runtime.js +52 -0
  40. package/src/xstate-runtime.ts +59 -4
@@ -966,6 +966,7 @@ function buildGovernedJudgePrompt(input, finalText, outcomes, correction) {
966
966
  '```',
967
967
  '',
968
968
  'Pick exactly one declared `guard` and reply with exactly that outcome\'s reply shape below: `guard` plus its semantic-owned fields and nothing else.',
969
+ 'If no declared outcome matches the player output, instead reply exactly {"blocked":"explain the missing outcome or contradictory requirements"}. Never force a match or invent a guard. A blocked reply pauses the playbook for Boss; it is not completion.',
969
970
  'A field listed as runtime-supplied is owned by presentation, effect, or runtime evidence; the runtime fills it itself, and a reply that includes one is structurally invalid.',
970
971
  '',
971
972
  ];
@@ -1045,7 +1046,7 @@ function validateBossReplyOutput(input, output, resumableStateIds) {
1045
1046
  }
1046
1047
  }
1047
1048
  export function createPlayerBridge(spec, ports, getActiveSignal, boundary, onControlPlaneError) {
1048
- return fromPromise(async ({ input, signal }) => {
1049
+ const execute = async ({ input, signal }) => {
1049
1050
  const activeSignal = combineAbortSignals(signal, getActiveSignal?.());
1050
1051
  let roleId;
1051
1052
  let prompt;
@@ -1113,7 +1114,9 @@ export function createPlayerBridge(spec, ports, getActiveSignal, boundary, onCon
1113
1114
  }
1114
1115
  throw error;
1115
1116
  }
1116
- });
1117
+ };
1118
+ return fromPromise((args) => spec.run
1119
+ ? spec.run(args.input, args.signal, () => execute(args)) : execute(args));
1117
1120
  }
1118
1121
  // ---------------------------------------------------------------------------
1119
1122
  // Direct-Captain adjudication (slc/link.md §Captain adjudication). The judge
@@ -1975,10 +1978,11 @@ function makeDefaultStatusesForState(roleStates) {
1975
1978
  }
1976
1979
  return [
1977
1980
  ...statuses,
1978
- { message: `${askerLabel(pending.asker)} asks: ${pending.question}` },
1981
+ { message: `${askerLabel(pending.asker)} asks: ${pending.question}`, data: { kind: 'boss-question' } },
1979
1982
  {
1980
1983
  message: `◆ awaiting Boss reply · ${pending.resumeStateId} · ` +
1981
1984
  `${askerLabel(pending.asker)} · ${pending.sourceItem}`,
1985
+ data: { kind: 'boss-question' },
1982
1986
  },
1983
1987
  ];
1984
1988
  }
@@ -2572,9 +2576,11 @@ export function createXStatePlaybookRuntime(machine, spec) {
2572
2576
  for (const pending of questions) {
2573
2577
  statuses.push({
2574
2578
  message: `${askerLabel(pending.asker)} asks: ${pending.question}`,
2579
+ data: { kind: 'boss-question' },
2575
2580
  }, {
2576
2581
  message: `◆ awaiting Boss reply · ${pending.resumeStateId} · ` +
2577
2582
  `${askerLabel(pending.asker)} · ${pending.sourceItem}`,
2583
+ data: { kind: 'boss-question' },
2578
2584
  });
2579
2585
  }
2580
2586
  continue;
@@ -2638,6 +2644,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
2638
2644
  }
2639
2645
  const currentEffectLedger = () => assertPlaybookEffectLedger(effectLedgerCapability.snapshot(), `${label} current host effect ledger`);
2640
2646
  let effectLedgerMirror = currentEffectLedger();
2647
+ let recoveryCheckpoint;
2641
2648
  let retainedEffectSourceSessionId;
2642
2649
  let retainedEffectReconciliation;
2643
2650
  let retainedEffectReconciliationRequired = false;
@@ -2739,12 +2746,6 @@ export function createXStatePlaybookRuntime(machine, spec) {
2739
2746
  let playbookCallSequence = 0;
2740
2747
  let captainCallSequence = 0;
2741
2748
  let applyCallSequence = 0;
2742
- // DR-029: the last event a public Boss boundary sent into the
2743
- // machine — classified, deterministic entry, or Boss reply — kept with
2744
- // its recorded payload so a failure-state retry action can replay the
2745
- // event that drove the run into `failed`. Process-local: the durable
2746
- // runtime snapshot does not persist it (PBRT-50).
2747
- let lastBossEvent;
2748
2749
  // DR-029: process-local at-most-once `apply` execution — the accepted receipt
2749
2750
  // recorded for each idempotency key, returned verbatim on a repeated
2750
2751
  // key. A key whose call settled `rejected` or threw before reaching
@@ -2919,7 +2920,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
2919
2920
  // cumulative receipt from its original baseline, not by its own step.
2920
2921
  function boundaryExcludesEffect(boundary) {
2921
2922
  return (boundary.logicalOperationId === undefined &&
2922
- boundary.physicalReceipt?.classification === 'unchanged');
2923
+ (boundary.physicalReceipt?.classification === 'unchanged' || boundary.restored !== undefined));
2923
2924
  }
2924
2925
  function boundaryNeedsSemanticReconciliation(candidate, ledger) {
2925
2926
  if (!runtimeBoundaryIsOwned(candidate))
@@ -2974,7 +2975,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
2974
2975
  const candidate = reconstructedGovernedPrefixSequence === undefined
2975
2976
  ? owned.at(-1)
2976
2977
  : owned.find(({ sequence }) => sequence > reconstructedGovernedPrefixSequence);
2977
- if (candidate === undefined || candidate.sourceStateId !== state.stateId) {
2978
+ if (candidate === undefined || candidate.restored !== undefined || candidate.sourceStateId !== state.stateId) {
2978
2979
  return;
2979
2980
  }
2980
2981
  const persisted = persistedBoundaryReconciliation(candidate, ledger);
@@ -4090,6 +4091,356 @@ export function createXStatePlaybookRuntime(machine, spec) {
4090
4091
  return (boundaries.length > 0 &&
4091
4092
  boundaries.every(({ physicalReceipt }) => physicalReceipt?.classification === 'unchanged'));
4092
4093
  }
4094
+ let acceptedStepResult;
4095
+ let requiredRecoveryBaseline;
4096
+ function captureRecoveryCheckpoint(stateId, prompt, boundaryPrefix) {
4097
+ if (requiredRecoveryBaseline !== undefined)
4098
+ return;
4099
+ // Capture after the entry emissions drain: the invocation is now a
4100
+ // real active child in XState's persisted snapshot, before host work.
4101
+ const id = recoveryCheckpoint?.stateId === stateId ? recoveryCheckpoint.id : undefined;
4102
+ recoveryCheckpoint = undefined;
4103
+ if (!actor || currentState().stateId !== stateId)
4104
+ return;
4105
+ try {
4106
+ const current = currentEffectLedger();
4107
+ recoveryCheckpoint = deepFreeze({
4108
+ stateId,
4109
+ prompt,
4110
+ ...(id === undefined ? {} : { id }),
4111
+ machine: detachPersistedMachineSnapshot(actor.getPersistedSnapshot()),
4112
+ boundaryPrefix: boundaryPrefix ?? current.boundaries.length,
4113
+ });
4114
+ }
4115
+ catch {
4116
+ // An in-memory-only context is still executable; it simply cannot
4117
+ // promise a durable step retry. Keep the legacy control behavior.
4118
+ }
4119
+ }
4120
+ function stepReplayIsSafe() {
4121
+ if (!recoveryCheckpoint || hasUnresolvedReconciliation())
4122
+ return false;
4123
+ const current = currentEffectLedger();
4124
+ return current.boundaries
4125
+ .slice(recoveryCheckpoint.boundaryPrefix)
4126
+ .every((boundary) => runtimeBoundaryIsOwned(boundary) &&
4127
+ boundary.logicalOperationId === undefined &&
4128
+ (boundary.physicalReceipt?.classification === 'unchanged' || boundary.restored !== undefined));
4129
+ }
4130
+ function repairableReadOnlyBoundary() {
4131
+ if (!recoveryCheckpoint || !repositoryCapability?.observe || !repositoryCapability.acquire ||
4132
+ retainedEffectReconciliationRequired || deferredReconciliationOperationId !== undefined)
4133
+ return undefined;
4134
+ const suffix = currentEffectLedger().boundaries.slice(recoveryCheckpoint.boundaryPrefix);
4135
+ if (suffix.length !== 1)
4136
+ return undefined;
4137
+ const saved = suffix[0];
4138
+ if (!runtimeBoundaryIsOwned(saved) || saved.restored !== undefined ||
4139
+ saved.sourceStateId !== recoveryCheckpoint.stateId || saved.cohortId !== undefined ||
4140
+ saved.logicalOperationId !== undefined || saved.dispositions.some((item) => item !== 'unchanged') ||
4141
+ saved.physicalReceipt === undefined || saved.after?.head !== saved.baseline.head ||
4142
+ saved.physicalReceipt.classification === 'unchanged' ||
4143
+ [...unresolvedSemanticBoundaryIds].some((id) => id !== saved.boundaryId))
4144
+ return undefined;
4145
+ return saved;
4146
+ }
4147
+ async function confirmReadOnlyRestoration(saved, signal) {
4148
+ const claim = await repositoryCapability.acquire({ signal });
4149
+ try {
4150
+ await claim.assertOwner();
4151
+ const restored = snapshotJsonValue(await repositoryCapability.observe());
4152
+ if (!isDeepStrictEqual(restored, saved.baseline)) {
4153
+ throw new Error(`${label} preparation has not restored the stopped step's repository; it remains paused`);
4154
+ }
4155
+ signal.throwIfAborted();
4156
+ effectLedgerMirror = assertPlaybookEffectLedger(await effectLedgerCapability.writeAhead([
4157
+ { kind: 'replace-boundaries', replacements: [{ expected: saved, next: { ...saved, restored } }] },
4158
+ ]));
4159
+ if (!isDeepStrictEqual(effectLedgerMirror.boundaries.find(({ boundaryId }) => boundaryId === saved.boundaryId)?.restored, restored)) {
4160
+ throw new Error(`${label} repository restoration was not durably acknowledged`);
4161
+ }
4162
+ refreshUnresolvedSemanticReconciliation(effectLedgerMirror);
4163
+ }
4164
+ finally {
4165
+ await claim.release();
4166
+ }
4167
+ }
4168
+ // A completed commit with saved presentation needs only the missing
4169
+ // tool-free judgment. Never repeat the player to recover that evidence.
4170
+ function recoverableJudgment() {
4171
+ if (!recoveryCheckpoint || recoveryCheckpoint.result !== undefined || recoveryCheckpoint.delivered ||
4172
+ retainedEffectReconciliationRequired ||
4173
+ deferredReconciliationOperationId !== undefined)
4174
+ return undefined;
4175
+ const suffix = currentEffectLedger().boundaries.slice(recoveryCheckpoint.boundaryPrefix);
4176
+ if (suffix.length !== 1)
4177
+ return undefined;
4178
+ const saved = suffix[0];
4179
+ if (!runtimeBoundaryIsOwned(saved) ||
4180
+ saved.sourceStateId !== recoveryCheckpoint.stateId ||
4181
+ saved.logicalOperationId !== undefined ||
4182
+ saved.correctionBudget.spent ||
4183
+ saved.physicalReceipt?.classification !== 'one-descendant-commit' ||
4184
+ !saved.finalText?.trim() ||
4185
+ governedOutcomesForBoundary(saved) === undefined ||
4186
+ (saved.semanticCandidate !== undefined &&
4187
+ persistedBoundaryReconciliation(saved, currentEffectLedger())
4188
+ ?.reconciliation.status !== 'resolved') ||
4189
+ [...unresolvedSemanticBoundaryIds].some((id) => id !== saved.boundaryId))
4190
+ return undefined;
4191
+ return saved;
4192
+ }
4193
+ async function recoverJudgment(saved, signal) {
4194
+ const checkpoint = recoveryCheckpoint;
4195
+ const persisted = checkpoint.machine;
4196
+ const input = Object.values(persisted.children)[0];
4197
+ const actorInput = input.snapshot.input;
4198
+ const outcomes = governedOutcomesForBoundary(saved);
4199
+ const candidate = saved.semanticCandidate ??
4200
+ parseGovernedSemanticCandidate(await boundary.callJudge('player-output-adjudication', saved.sourceStateId, buildGovernedJudgePrompt(actorInput, saved.finalText, outcomes), signal));
4201
+ const result = reconcilePlaybookSemanticEvidence({
4202
+ outcomes,
4203
+ semanticCandidate: candidate,
4204
+ finalText: saved.finalText,
4205
+ receipt: saved.physicalReceipt,
4206
+ });
4207
+ signal.throwIfAborted();
4208
+ const next = {
4209
+ ...saved,
4210
+ semanticCandidate: snapshotJsonValue(result.evidence.semanticCandidate),
4211
+ };
4212
+ const acknowledged = assertPlaybookEffectLedger(await effectLedgerCapability.writeAhead([
4213
+ {
4214
+ kind: 'replace-boundaries',
4215
+ replacements: [{ expected: saved, next }],
4216
+ },
4217
+ ]));
4218
+ if (!isDeepStrictEqual(acknowledged.boundaries.find((b) => b.boundaryId === saved.boundaryId), next)) {
4219
+ throw new Error(`${label} recovered judgment was not acknowledged exactly`);
4220
+ }
4221
+ effectLedgerMirror = acknowledged;
4222
+ refreshUnresolvedSemanticReconciliation(acknowledged);
4223
+ if (result.status !== 'resolved') {
4224
+ const error = attachPlaybookFailureCause(new Error(result.status === 'unresolved' && 'reason' in result.cause.evidence && result.cause.evidence.reason
4225
+ ? String(result.cause.evidence.reason)
4226
+ : `${label} saved result could not be reconciled; the playbook remains parked`), result.status === 'unresolved' ? result.cause : runtimeDefectCause('Saved result still needs a Boss answer'));
4227
+ const paused = detachPersistedMachineSnapshot(actor.getPersistedSnapshot());
4228
+ stopActor();
4229
+ actor = buildActor(runtimePorts, {
4230
+ ...paused, context: { ...paused.context, lastError: snapshotJsonValue(normalizeErrorFull(error)) },
4231
+ });
4232
+ suppressInspectionEmissions = false;
4233
+ actor.start();
4234
+ throw error;
4235
+ }
4236
+ await resumeCheckpoint({ ...checkpoint, result: snapshotJsonValue(result.output) });
4237
+ }
4238
+ async function resumeCheckpoint(checkpoint) {
4239
+ acceptedStepResult = checkpoint;
4240
+ try {
4241
+ stopActor();
4242
+ actor = buildActor(runtimePorts, checkpoint.machine);
4243
+ suppressInspectionEmissions = false;
4244
+ actor.start();
4245
+ await waitForPlaybookQuiescence(actor, { pendingCalls: nestedBridge });
4246
+ }
4247
+ finally {
4248
+ acceptedStepResult = undefined;
4249
+ }
4250
+ }
4251
+ function exportRuntimeSnapshot(checkpoint) {
4252
+ if (!actor || !session || disposed || disposalPromise !== undefined) {
4253
+ return undefined;
4254
+ }
4255
+ if (activeSignal !== undefined && checkpoint === undefined)
4256
+ return undefined;
4257
+ if (deferredSettlementClosure !== undefined)
4258
+ return undefined;
4259
+ const pendingCall = checkpoint?.child ?? nestedBridge.getPendingCall();
4260
+ const bridgeSuspendedCall = checkpoint?.child ? nestedBridge.checkpointCall(checkpoint.child) : nestedBridge.getSuspendedCall();
4261
+ if ((pendingCall === undefined) !== (bridgeSuspendedCall === undefined)) {
4262
+ return undefined;
4263
+ }
4264
+ let suspendedCall;
4265
+ if (bridgeSuspendedCall !== undefined) {
4266
+ if (pendingCall?.callId !== bridgeSuspendedCall.callId ||
4267
+ pendingCall?.playbookId !== bridgeSuspendedCall.playbookId ||
4268
+ pendingCall?.childSessionId !== bridgeSuspendedCall.childSessionId) {
4269
+ return undefined;
4270
+ }
4271
+ if (!playbookCallTurnIds.has(bridgeSuspendedCall.callId)) {
4272
+ return undefined;
4273
+ }
4274
+ const turnId = playbookCallTurnIds.get(bridgeSuspendedCall.callId);
4275
+ if (bridgeSuspendedCall.turnId !== undefined &&
4276
+ bridgeSuspendedCall.turnId !== turnId) {
4277
+ return undefined;
4278
+ }
4279
+ suspendedCall = {
4280
+ ...bridgeSuspendedCall,
4281
+ ...(turnId === undefined ? {} : { turnId }),
4282
+ ...(hasGovernedPlayerStates
4283
+ ? {
4284
+ effectBoundaryPrefixSequence: playbookCallEffectPrefixes.get(bridgeSuspendedCall.callId) ?? null,
4285
+ }
4286
+ : {}),
4287
+ };
4288
+ }
4289
+ const interrupted = checkpoint?.interrupted && recoveryCheckpoint && machine.root.states.failed;
4290
+ const state = interrupted
4291
+ ? normalizePlaybookSnapshot(machine.resolveState({ value: 'failed', context: {} }))
4292
+ : checkpoint?.child ? normalizePlaybookSnapshot(actor.getSnapshot(), { pendingCall }) : currentState();
4293
+ if (state.status !== 'active' || !state.quiescent)
4294
+ return undefined;
4295
+ const context = actor.getSnapshot()
4296
+ .context;
4297
+ // DR-063 §2: the failed state's `lastError` is persisted as every live
4298
+ // surface publishes it, completed from the runtime's own cause slot, so
4299
+ // a thrown value that refused the cause still explains itself after a
4300
+ // restart.
4301
+ const machineSnapshot = detachPersistedMachineSnapshot(interrupted ? { ...recoveryCheckpoint.machine, value: 'failed', children: {} } : actor.getPersistedSnapshot(), state.stateId === 'failed'
4302
+ ? { lastError: interrupted ? { name: 'InterruptedStep', message: 'The process stopped during this step. Review the recorded work before choosing how to continue.', cause: runtimeDefectCause('The process stopped during this step. Review the recorded work before choosing how to continue.') } : failedStateError(context ?? {}) }
4303
+ : {});
4304
+ effectLedgerMirror = currentEffectLedger();
4305
+ refreshRetainedEffectReconciliation(effectLedgerMirror);
4306
+ syncDeferredReconciliationOverlay();
4307
+ refreshUnresolvedSemanticReconciliation(effectLedgerMirror);
4308
+ const pendingQuestions = !hasUnresolvedReconciliation()
4309
+ ? pendingBossQuestionsFor(state, context ?? {})
4310
+ : [];
4311
+ const interruptedPrefix = interrupted ? activeEffectLedgerPrefixSequence : undefined;
4312
+ const interruptedAttempts = new Set(effectLedgerMirror.boundaries.filter(({ sequence }) => interruptedPrefix !== undefined && sequence > interruptedPrefix).map(({ attemptId }) => attemptId));
4313
+ const failedEffectAttempt = interrupted && interruptedPrefix !== undefined && interruptedAttempts.size <= 1
4314
+ ? { boundaryPrefix: interruptedPrefix, attemptId: interruptedAttempts.values().next().value ?? null }
4315
+ :
4316
+ hasGovernedPlayerStates &&
4317
+ !interrupted && state.stateId === 'failed' &&
4318
+ failedAttemptMatchesCurrentLedger(effectLedgerMirror)
4319
+ ? {
4320
+ boundaryPrefix: failedEffectBoundaryPrefix,
4321
+ attemptId: failedGovernedAttemptId ?? null,
4322
+ }
4323
+ : undefined;
4324
+ return {
4325
+ schemaVersion: 4,
4326
+ playbookId: session.playbookId,
4327
+ machine: machineSnapshot,
4328
+ roleResumeTokens: snapshotRoleResumeTokens(),
4329
+ sequences: {
4330
+ trace: traceSequence,
4331
+ turn: turnSequence,
4332
+ judgeCall: judgeCallSequence,
4333
+ playerCall: playerCallSequence,
4334
+ playbookCall: playbookCallSequence,
4335
+ ...(declaredActors.has('captain')
4336
+ ? { captainCall: captainCallSequence }
4337
+ : {}),
4338
+ },
4339
+ state,
4340
+ pendingBossQuestions: pendingQuestions.map((pending) => ({
4341
+ questionId: pending.questionId,
4342
+ asker: pending.asker,
4343
+ question: pending.question,
4344
+ sourceItem: pending.sourceItem,
4345
+ })),
4346
+ effectLedger: effectLedgerMirror,
4347
+ ...(retainedEffectSourceSessionId === undefined
4348
+ ? {}
4349
+ : { retainedEffectSourceSessionId }),
4350
+ ...(retainedEffectReconciliation === undefined
4351
+ ? {}
4352
+ : { retainedEffectReconciliation }),
4353
+ ...(failedEffectAttempt === undefined
4354
+ ? {}
4355
+ : { failedEffectAttempt }),
4356
+ ...((state.stateId === 'failed' || pendingQuestions.length > 0) && recoveryCheckpoint !== undefined
4357
+ ? { recoveryCheckpoint } : {}),
4358
+ ...(suspendedCall === undefined ? {} : { suspendedCall }),
4359
+ };
4360
+ }
4361
+ async function runJournalledStep(kind, input, signal, execute) {
4362
+ const saved = acceptedStepResult;
4363
+ let output;
4364
+ if (saved?.stateId === input.stateId && saved.result !== undefined) {
4365
+ acceptedStepResult = undefined;
4366
+ recoveryCheckpoint = saved;
4367
+ output = snapshotJsonValue(saved.result);
4368
+ }
4369
+ else {
4370
+ const record = savedPorts?.recordStep;
4371
+ await drainEmissions();
4372
+ signal.throwIfAborted();
4373
+ try {
4374
+ captureRecoveryCheckpoint(input.stateId, 'command' in input ? input.command : 'role' in input ? composeBoundPlayerPrompt(input) : composeCaptainPrompt(input));
4375
+ }
4376
+ catch (error) {
4377
+ if (!isAbortFailure(error, signal))
4378
+ controlPlaneError ??= error;
4379
+ throw error;
4380
+ }
4381
+ if (record) {
4382
+ const id = randomUUID();
4383
+ if (recoveryCheckpoint)
4384
+ recoveryCheckpoint = { ...recoveryCheckpoint, id };
4385
+ const step = { id, kind, stateId: input.stateId };
4386
+ await record(step, exportRuntimeSnapshot({ interrupted: true }));
4387
+ signal.throwIfAborted();
4388
+ const result = snapshotJsonValue(await execute());
4389
+ // Keep the known output if its save loses acknowledgement. A drained
4390
+ // failure can then preserve it without treating the call as unexecuted.
4391
+ if (recoveryCheckpoint?.id === id)
4392
+ recoveryCheckpoint = { ...recoveryCheckpoint, result };
4393
+ await record({ ...step, result });
4394
+ output = result;
4395
+ }
4396
+ else {
4397
+ output = await execute();
4398
+ }
4399
+ }
4400
+ if (recoveryCheckpoint?.stateId === input.stateId) {
4401
+ const { result: _result, ...start } = recoveryCheckpoint;
4402
+ recoveryCheckpoint = { ...start, delivered: true };
4403
+ }
4404
+ return output;
4405
+ }
4406
+ function validateRecoveryCheckpoint(checkpoint) {
4407
+ if (!checkpoint)
4408
+ return;
4409
+ const node = machine.root.states[checkpoint.stateId];
4410
+ const persisted = checkpoint.machine;
4411
+ const children = persisted.children;
4412
+ if (!node ||
4413
+ node.invoke.length !== 1 ||
4414
+ !['player', 'captain', 'script'].includes(String(node.invoke[0].src)) ||
4415
+ !isPlainObject(children) ||
4416
+ Object.keys(children).length !== 1) {
4417
+ throw new TypeError(`${label} recovery checkpoint does not name one current invocation`);
4418
+ }
4419
+ const child = children[node.invoke[0].id];
4420
+ if (!child ||
4421
+ child.src !== node.invoke[0].src ||
4422
+ !isPlainObject(child.snapshot) ||
4423
+ child.snapshot.status !== 'active' ||
4424
+ !isPlainObject(child.snapshot.input) ||
4425
+ child.snapshot.input.stateId !== checkpoint.stateId) {
4426
+ throw new TypeError(`${label} recovery checkpoint invocation is incompatible`);
4427
+ }
4428
+ if (checkpoint.result !== undefined) {
4429
+ const output = checkpoint.result;
4430
+ const input = child.snapshot.input;
4431
+ if (!isPlainObject(output) || typeof output.guard !== 'string' ||
4432
+ !isPlainObject(input.result) || !Object.hasOwn(input.result, output.guard) ||
4433
+ (node.invoke[0].src === 'script' &&
4434
+ (!Number.isInteger(output.exitStatus) || Object.keys(output).sort().join(',') !== 'exitStatus,guard' ||
4435
+ output.guard !== Object.keys(input.result)[output.exitStatus === 0 ? 0 : Math.min(1, Object.keys(input.result).length - 1)]))) {
4436
+ throw new TypeError(`${label} saved step result is incompatible with its invocation`);
4437
+ }
4438
+ for (const field of extractFields(String(input.result[output.guard]))) {
4439
+ if (output[field] === undefined || output[field] === null)
4440
+ throw new TypeError(`${label} saved step result is missing ${field}`);
4441
+ }
4442
+ }
4443
+ }
4093
4444
  function captureEffectLedgerPrefixSequence() {
4094
4445
  if (!hasGovernedPlayerStates)
4095
4446
  return undefined;
@@ -4675,7 +5026,16 @@ export function createXStatePlaybookRuntime(machine, spec) {
4675
5026
  // captured it under this exclusive claim. A host that supplies
4676
5027
  // none states no pre-existing change, exactly as an empty
4677
5028
  // baseline projection does.
4678
- operation: (context) => runTracedPlayerCall(selectedResume, effectAuthorizedPreExistingBlock(effectBoundary, context?.baseline)),
5029
+ operation: async (context) => {
5030
+ if (requiredRecoveryBaseline !== undefined) {
5031
+ if (!isDeepStrictEqual(snapshotJsonValue(context.baseline), requiredRecoveryBaseline)) {
5032
+ throw new Error(`${label} repository changed after preparation; the stopped step remains paused`);
5033
+ }
5034
+ requiredRecoveryBaseline = undefined;
5035
+ captureRecoveryCheckpoint(input.stateId, freshPrompt, currentEffectLedger().boundaries.findIndex(({ boundaryId }) => boundaryId === effectBoundary.boundaryId));
5036
+ }
5037
+ return runTracedPlayerCall(selectedResume, effectAuthorizedPreExistingBlock(effectBoundary, context?.baseline));
5038
+ },
4679
5039
  completeEffectBoundary: completionEvidenceFor(input, roleId, playerId, signal, undefined),
4680
5040
  });
4681
5041
  return acknowledgeGovernedPlayerResult(exclusive, effectBoundary.boundaryId);
@@ -4901,6 +5261,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
4901
5261
  }
4902
5262
  function playerActor(ports) {
4903
5263
  return createPlayerBridge({
5264
+ run: (input, signal, execute) => runJournalledStep('player', input, signal, execute),
4904
5265
  resolveRoleId: requireRoleId,
4905
5266
  validateInput: (input) => assertGovernedPlayerInput(outcomeAuthority, input, extractFields, label),
4906
5267
  composePlayerPrompt: composeBoundPlayerPrompt,
@@ -4918,7 +5279,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
4918
5279
  // adjudication that injects the exact visible finalText as the selected
4919
5280
  // output's question/response.
4920
5281
  function captainActor() {
4921
- return fromPromise(async ({ input, signal }) => {
5282
+ return fromPromise(({ input, signal }) => runJournalledStep('captain', input, signal, async () => {
4922
5283
  const active = combineAbortSignals(signal, activeSignal);
4923
5284
  try {
4924
5285
  await drainEmissions();
@@ -4982,7 +5343,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
4982
5343
  }
4983
5344
  throw error;
4984
5345
  }
4985
- });
5346
+ }));
4986
5347
  }
4987
5348
  // Deterministic script actor (slc/link.md §Script execution). Runs
4988
5349
  // `input.command` through `sh -c`, resolves the declared guard
@@ -4990,7 +5351,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
4990
5351
  // `playbook.script` telemetry event. No agent call, no adjudication,
4991
5352
  // no `*.call.*` trace.
4992
5353
  function scriptActor() {
4993
- return fromPromise(async ({ input, signal }) => {
5354
+ return fromPromise(({ input, signal }) => runJournalledStep('script', input, signal, async () => {
4994
5355
  await drainEmissions();
4995
5356
  const active = combineAbortSignals(signal, activeSignal);
4996
5357
  const guards = Object.keys(input.result);
@@ -5162,7 +5523,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
5162
5523
  clearTimeout(killTimer);
5163
5524
  }
5164
5525
  }
5165
- });
5526
+ }));
5166
5527
  }
5167
5528
  const nestedBridge = createNestedPlaybookBridge({
5168
5529
  nextCallId: () => `playbook-${++playbookCallSequence}`,
@@ -5487,6 +5848,10 @@ export function createXStatePlaybookRuntime(machine, spec) {
5487
5848
  latchRuntimeError(error);
5488
5849
  }
5489
5850
  if (state.stateId === 'failed') {
5851
+ if (previousState?.stateId !== 'failed' &&
5852
+ previousState?.stateId !== recoveryCheckpoint?.stateId) {
5853
+ recoveryCheckpoint = undefined;
5854
+ }
5490
5855
  if (previousState?.stateId !== 'failed' ||
5491
5856
  activeGovernedBoundarySeen) {
5492
5857
  latchFailedGovernedAttempt();
@@ -5734,7 +6099,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
5734
6099
  activeDeferredContinuation = undefined;
5735
6100
  deferInspectionEmissions = false;
5736
6101
  deferredInspectionEmissions = [];
5737
- lastBossEvent = undefined;
6102
+ recoveryCheckpoint = undefined;
5738
6103
  suppressInspectionEmissions = false;
5739
6104
  initialized = false;
5740
6105
  traceSequence = 0;
@@ -5751,77 +6116,26 @@ export function createXStatePlaybookRuntime(machine, spec) {
5751
6116
  can.call(snapshot, event) ===
5752
6117
  true);
5753
6118
  }
5754
- // DR-034: where the artifact names the FSM context member its entry
5755
- // action copies the exact Boss text into, that member of the live
5756
- // snapshot is the retry payload's source. The persisted machine snapshot
5757
- // carries it, so the candidate derives identically in the process that
5758
- // exported the snapshot and in one that restored it, and a failure
5759
- // reached after a Boss reply — whose recorded event the failure state
5760
- // refuses — is recoverable too. Naming the member is the artifact's
5761
- // statement that it holds the entry text: a same-named member is never
5762
- // assumed, since inferring one would turn any matching context member
5763
- // into a replay payload without its author saying so.
5764
- // Declared and absent or empty excludes the candidate rather than
5765
- // falling back to the record, which would make the action depend on the
5766
- // process again — the very thing this source exists to end.
5767
- function retryEventFrom(snapshot) {
5768
- const entryEvent = spec.entryEvent;
5769
- if (entryEvent?.contextField === undefined)
5770
- return lastBossEvent;
5771
- const context = snapshot?.context;
5772
- const text = isPlainObject(context)
5773
- ? context[entryEvent.contextField]
5774
- : undefined;
5775
- if (typeof text !== 'string' || text.trim() === '')
5776
- return undefined;
5777
- return { type: entryEvent.type, [entryEvent.textField]: text };
5778
- }
5779
- // The failure-state retry entry replays the recorded last classified
5780
- // event with its recorded payload, or the entry event the declared
5781
- // context member above sources. A candidate whose event the live
5782
- // snapshot does not accept — or whose payload the runtime can source
5783
- // from neither — is excluded rather than completed with invented text.
5784
- // The caller has already applied the PBRT-71 replay fence.
5785
- function retryActionFor(snapshot, stateId) {
5786
- if (stateId !== 'failed')
6119
+ function retryActionFor(stateId) {
6120
+ if (stateId !== 'failed' || !recoveryCheckpoint)
5787
6121
  return undefined;
5788
- const retryEvent = retryEventFrom(snapshot);
5789
- if (retryEvent === undefined)
5790
- return undefined;
5791
- if (!snapshotCan(snapshot, retryEvent))
5792
- return undefined;
5793
- // A recorded explicit-state-jump event names the exact state its
5794
- // replay re-enters: the root BOSS_INTERRUPT shape is a guarded
5795
- // multi-arm list keyed on `targetId`, so the first configured arm
5796
- // may label a different state than the one the recorded event
5797
- // actually resumes.
5798
- const recordedTargetId = retryEvent.type === JUMP_EVENT_TYPE
5799
- ? retryEvent.targetId
5800
- : undefined;
5801
- const target = typeof recordedTargetId === 'string' &&
5802
- recordedTargetId.trim().length > 0
5803
- ? recordedTargetId
5804
- : firstTransitionTarget(machine, stateId, retryEvent.type);
5805
- // PBRT-52: a label is written from a source state description, never
5806
- // from an identifier. Falling back to the target id — or, with no
5807
- // resolvable target, to the FSM event type — makes the label *be* the
5808
- // internal name, which defeats the substitution the label exists for
5809
- // and puts a machine identifier into Boss-facing text
5810
- // (CAPPLAY-5). A candidate whose label can only be an id is excluded
5811
- // exactly like one whose payload cannot be sourced.
5812
- const description = (target === undefined ? undefined : stateDescriptions.get(target)) ??
5813
- stateDescriptions.get(stateId);
5814
- if (description === undefined)
6122
+ const description = stateDescriptions.get(recoveryCheckpoint.stateId);
6123
+ const savedResult = recoveryCheckpoint.result !== undefined;
6124
+ const safe = savedResult
6125
+ ? !hasUnresolvedReconciliation() && currentEffectLedger().boundaries.slice(recoveryCheckpoint.boundaryPrefix)
6126
+ .every((boundary) => runtimeBoundaryIsOwned(boundary) && boundary.sourceStateId === recoveryCheckpoint.stateId)
6127
+ : stepReplayIsSafe();
6128
+ if (description === undefined || !safe)
5815
6129
  return undefined;
6130
+ const entry = spec.entryEvent;
6131
+ const target = entry && firstTransitionTarget(machine, stateId, entry.type);
5816
6132
  return {
5817
6133
  action: {
5818
- id: `retry:${retryEvent.type}`,
5819
- label: `Retry: ${description}`,
5820
- // DR-063 §3: retrying a failed call is `ready` — the runtime cannot
5821
- // see whether the Boss changed the outside world since it failed.
6134
+ id: target === recoveryCheckpoint.stateId ? `retry:${entry.type}` : 'retry:step',
6135
+ label: `${savedResult ? 'Continue from saved result' : 'Retry'}: ${description}`,
5822
6136
  standing: 'ready',
5823
6137
  },
5824
- event: retryEvent,
6138
+ retryStep: true,
5825
6139
  };
5826
6140
  }
5827
6141
  /**
@@ -5867,6 +6181,21 @@ export function createXStatePlaybookRuntime(machine, spec) {
5867
6181
  return [];
5868
6182
  }
5869
6183
  const derived = [];
6184
+ const repairable = state.stateId === 'failed' ? repairableReadOnlyBoundary() : undefined;
6185
+ if (repairable !== undefined)
6186
+ derived.push({
6187
+ action: { id: 'retry:restored-step', label: 'Restore the repository and retry the read-only step', standing: 'ready' },
6188
+ retryStep: true,
6189
+ restoreReadOnly: repairable,
6190
+ });
6191
+ const judgment = state.stateId === 'failed' ? recoverableJudgment() : undefined;
6192
+ if (judgment !== undefined)
6193
+ derived.push({
6194
+ action: { id: 'retry:adjudication', label: 'Retry assessment of the saved result', standing: 'ready' },
6195
+ retryJudgment: judgment,
6196
+ });
6197
+ if (judgment !== undefined && !hasUnresolvedReconciliation())
6198
+ return derived;
5870
6199
  if (hasUnresolvedReconciliation()) {
5871
6200
  const operation = deferredReconciliationOperationId === undefined
5872
6201
  ? undefined
@@ -5895,18 +6224,14 @@ export function createXStatePlaybookRuntime(machine, spec) {
5895
6224
  });
5896
6225
  return derived;
5897
6226
  }
5898
- // PBRT-71 / DR-040 §4: out of the failure state, a jump restarts the
5899
- // failed attempt's work exactly as the retry does, so one fence gates
5900
- // both. Unless every governed boundary of that attempt carries a
5901
- // complete `unchanged` receipt, neither is advertised — and `apply`,
5902
- // which revalidates against this list, accepts neither — so no
5903
- // control can replay a player call whose effect may already exist.
6227
+ const retry = retryActionFor(state.stateId);
6228
+ if (retry !== undefined)
6229
+ derived.push(retry);
6230
+ // A jump can repeat earlier work, unlike checkpoint retry. Its whole
6231
+ // failed attempt must therefore still pass the replay fence.
5904
6232
  if (state.stateId === 'failed' && !failedAttemptAllowsReplay()) {
5905
6233
  return derived;
5906
6234
  }
5907
- const retry = retryActionFor(snapshot, state.stateId);
5908
- if (retry !== undefined)
5909
- derived.push(retry);
5910
6235
  // Jump entries: resumable targets whose explicit-state-jump event the
5911
6236
  // live snapshot accepts (state guards included), sent with the
5912
6237
  // advertised target id and optional textual fields omitted.
@@ -6018,6 +6343,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
6018
6343
  }
6019
6344
  const boundSession = bindSession(nextSession);
6020
6345
  const boundSnapshot = assertPlaybookRuntimeSnapshot(snapshot, boundSession.playbookId, { allowSuspendedCall: true });
6346
+ validateRecoveryCheckpoint(boundSnapshot.recoveryCheckpoint);
6021
6347
  if (kind === 'adopt' &&
6022
6348
  effectLedgerCapability !== undefined &&
6023
6349
  (boundSnapshot.retainedEffectReconciliation?.checkpoint ??
@@ -6084,6 +6410,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
6084
6410
  initInFlight = initialization;
6085
6411
  const initTask = (async () => {
6086
6412
  session = boundSession;
6413
+ recoveryCheckpoint = boundSnapshot.recoveryCheckpoint;
6087
6414
  syncDeferredReconciliationOverlay();
6088
6415
  refreshUnresolvedSemanticReconciliation(effectLedgerMirror);
6089
6416
  prepareReconstructedGovernedDelivery(boundSnapshot.state, effectLedgerMirror);
@@ -6278,7 +6605,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
6278
6605
  emission();
6279
6606
  }
6280
6607
  }
6281
- async function continueBoundDeferredOperation(operation, event, signal, turnId, classificationLine) {
6608
+ async function continueBoundDeferredOperation(operation, event, signal, turnId, classificationLine, onAccepted) {
6282
6609
  if (repositoryCapability === undefined) {
6283
6610
  throw new Error(`${label} deferred continuation requires repository.runDeferred`);
6284
6611
  }
@@ -6324,6 +6651,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
6324
6651
  continuation.baseline = context?.baseline;
6325
6652
  continuation.playerContinuation = selectPlayerResume(effectBoundary.roleId, boundPlayerId);
6326
6653
  continuationStarted = true;
6654
+ onAccepted?.();
6327
6655
  actor.send(event);
6328
6656
  // The host has durably started this boundary and the FSM has
6329
6657
  // moved: publish the buffered classification line and the
@@ -6573,106 +6901,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
6573
6901
  // Defined only at a safe capture point — initialized, not disposing
6574
6902
  // or disposed, no active public boundary, and the actor quiescent with
6575
6903
  // status `active`.
6576
- exportSnapshot() {
6577
- if (!actor || !session || disposed || disposalPromise !== undefined) {
6578
- return undefined;
6579
- }
6580
- if (activeSignal !== undefined)
6581
- return undefined;
6582
- if (deferredSettlementClosure !== undefined)
6583
- return undefined;
6584
- const pendingCall = nestedBridge.getPendingCall();
6585
- const bridgeSuspendedCall = nestedBridge.getSuspendedCall();
6586
- if ((pendingCall === undefined) !== (bridgeSuspendedCall === undefined)) {
6587
- return undefined;
6588
- }
6589
- let suspendedCall;
6590
- if (bridgeSuspendedCall !== undefined) {
6591
- if (pendingCall?.callId !== bridgeSuspendedCall.callId ||
6592
- pendingCall?.playbookId !== bridgeSuspendedCall.playbookId ||
6593
- pendingCall?.childSessionId !== bridgeSuspendedCall.childSessionId) {
6594
- return undefined;
6595
- }
6596
- if (!playbookCallTurnIds.has(bridgeSuspendedCall.callId)) {
6597
- return undefined;
6598
- }
6599
- const turnId = playbookCallTurnIds.get(bridgeSuspendedCall.callId);
6600
- if (bridgeSuspendedCall.turnId !== undefined &&
6601
- bridgeSuspendedCall.turnId !== turnId) {
6602
- return undefined;
6603
- }
6604
- suspendedCall = {
6605
- ...bridgeSuspendedCall,
6606
- ...(turnId === undefined ? {} : { turnId }),
6607
- ...(hasGovernedPlayerStates
6608
- ? {
6609
- effectBoundaryPrefixSequence: playbookCallEffectPrefixes.get(bridgeSuspendedCall.callId) ?? null,
6610
- }
6611
- : {}),
6612
- };
6613
- }
6614
- const state = currentState();
6615
- if (state.status !== 'active' || !state.quiescent)
6616
- return undefined;
6617
- const context = actor.getSnapshot()
6618
- .context;
6619
- // DR-063 §2: the failed state's `lastError` is persisted as every live
6620
- // surface publishes it, completed from the runtime's own cause slot, so
6621
- // a thrown value that refused the cause still explains itself after a
6622
- // restart.
6623
- const machineSnapshot = detachPersistedMachineSnapshot(actor.getPersistedSnapshot(), state.stateId === 'failed'
6624
- ? { lastError: failedStateError(context ?? {}) }
6625
- : {});
6626
- effectLedgerMirror = currentEffectLedger();
6627
- refreshRetainedEffectReconciliation(effectLedgerMirror);
6628
- syncDeferredReconciliationOverlay();
6629
- refreshUnresolvedSemanticReconciliation(effectLedgerMirror);
6630
- const pendingQuestions = !hasUnresolvedReconciliation()
6631
- ? pendingBossQuestionsFor(state, context ?? {})
6632
- : [];
6633
- const failedEffectAttempt = hasGovernedPlayerStates &&
6634
- state.stateId === 'failed' &&
6635
- failedAttemptMatchesCurrentLedger(effectLedgerMirror)
6636
- ? {
6637
- boundaryPrefix: failedEffectBoundaryPrefix,
6638
- attemptId: failedGovernedAttemptId ?? null,
6639
- }
6640
- : undefined;
6641
- return {
6642
- schemaVersion: 4,
6643
- playbookId: session.playbookId,
6644
- machine: machineSnapshot,
6645
- roleResumeTokens: snapshotRoleResumeTokens(),
6646
- sequences: {
6647
- trace: traceSequence,
6648
- turn: turnSequence,
6649
- judgeCall: judgeCallSequence,
6650
- playerCall: playerCallSequence,
6651
- playbookCall: playbookCallSequence,
6652
- ...(declaredActors.has('captain')
6653
- ? { captainCall: captainCallSequence }
6654
- : {}),
6655
- },
6656
- state,
6657
- pendingBossQuestions: pendingQuestions.map((pending) => ({
6658
- questionId: pending.questionId,
6659
- asker: pending.asker,
6660
- question: pending.question,
6661
- sourceItem: pending.sourceItem,
6662
- })),
6663
- effectLedger: effectLedgerMirror,
6664
- ...(retainedEffectSourceSessionId === undefined
6665
- ? {}
6666
- : { retainedEffectSourceSessionId }),
6667
- ...(retainedEffectReconciliation === undefined
6668
- ? {}
6669
- : { retainedEffectReconciliation }),
6670
- ...(failedEffectAttempt === undefined
6671
- ? {}
6672
- : { failedEffectAttempt }),
6673
- ...(suspendedCall === undefined ? {} : { suspendedCall }),
6674
- };
6675
- },
6904
+ exportSnapshot: (checkpoint) => exportRuntimeSnapshot(checkpoint?.child ? { child: checkpoint.child } : undefined),
6676
6905
  // DR-014 §1 / PBRT-45: alternative to `init` that rehydrates an
6677
6906
  // exported snapshot under the same immutable session identity.
6678
6907
  // Emits no `session.started`, transition trace, or human status —
@@ -6725,6 +6954,30 @@ export function createXStatePlaybookRuntime(machine, spec) {
6725
6954
  const stateDescription = !hasUnresolvedReconciliation()
6726
6955
  ? stateDescriptionFor(state)
6727
6956
  : undefined;
6957
+ const actions = deriveControlActions(snapshot).map(({ action }) => action);
6958
+ const retry = actions.find(({ id, standing }) => id.startsWith('retry:') &&
6959
+ (standing ?? 'ready') === 'ready');
6960
+ const stoppedBoundary = recoveryCheckpoint === undefined ? undefined :
6961
+ currentEffectLedger().boundaries.slice(recoveryCheckpoint.boundaryPrefix)
6962
+ .filter((boundary) => runtimeBoundaryIsOwned(boundary) && boundary.sourceStateId === recoveryCheckpoint.stateId).at(-1);
6963
+ const recovery = recoveryCheckpoint === undefined ||
6964
+ (pendingQuestions.length === 0 && retry === undefined) ? undefined : {
6965
+ prompt: recoveryCheckpoint.prompt,
6966
+ ...(stoppedBoundary === undefined ? {} : { evidence: snapshotJsonValue({
6967
+ declaredResults: stoppedBoundary.sourceOutcomeSchema,
6968
+ ...(stoppedBoundary.finalText === undefined ? {} : { savedPlayerText: stoppedBoundary.finalText }),
6969
+ ...(stoppedBoundary.semanticCandidate === undefined ? {} : { selectedResult: stoppedBoundary.semanticCandidate }),
6970
+ ...(stoppedBoundary.physicalReceipt === undefined ? {} : { repositoryReceipt: stoppedBoundary.physicalReceipt }),
6971
+ }) }),
6972
+ ...(repairableReadOnlyBoundary() === undefined ? {} : {
6973
+ preparation: `This read-only step changed the repository. Restore its exact original HEAD and visible files before retrying; do not accept its saved result. Preserve user work. You may put generated outputs aside and prevent their return using local environment or ignore settings. Original observation: ${JSON.stringify(repairableReadOnlyBoundary().baseline)}. Observed after the failed step: ${JSON.stringify(repairableReadOnlyBoundary().after)}.`,
6974
+ }),
6975
+ ...(stateDescriptions.get(recoveryCheckpoint.stateId) === undefined ? {} :
6976
+ { description: stateDescriptions.get(recoveryCheckpoint.stateId) }),
6977
+ continuation: pendingQuestions.length === 0
6978
+ ? { kind: 'runtime', actionId: retry.id }
6979
+ : { kind: 'reply' },
6980
+ };
6728
6981
  return deepFreeze({
6729
6982
  state,
6730
6983
  ...(stateDescription === undefined ? {} : { stateDescription }),
@@ -6738,7 +6991,8 @@ export function createXStatePlaybookRuntime(machine, spec) {
6738
6991
  sourceItem: pending.sourceItem,
6739
6992
  })),
6740
6993
  ...(lastError !== undefined ? { lastError } : {}),
6741
- actions: deriveControlActions(snapshot).map(({ action }) => action),
6994
+ actions,
6995
+ ...(recovery === undefined ? {} : { recovery }),
6742
6996
  });
6743
6997
  },
6744
6998
  // DR-029 / PBRT-52: revalidate the named action against the live
@@ -6900,7 +7154,11 @@ export function createXStatePlaybookRuntime(machine, spec) {
6900
7154
  accepted = true;
6901
7155
  try {
6902
7156
  let run;
6903
- if (candidate.unresolvedEffectAction === 'abandon') {
7157
+ if (candidate.retryJudgment !== undefined) {
7158
+ await recoverJudgment(candidate.retryJudgment, signal);
7159
+ run = runResultFor(settledOutcome(signal));
7160
+ }
7161
+ else if (candidate.unresolvedEffectAction === 'abandon') {
6904
7162
  signal.throwIfAborted();
6905
7163
  run = runResultFor('unresolved-effect');
6906
7164
  }
@@ -6919,6 +7177,21 @@ export function createXStatePlaybookRuntime(machine, spec) {
6919
7177
  ? 'no-action'
6920
7178
  : 'quiescent');
6921
7179
  }
7180
+ else if (candidate.retryStep) {
7181
+ if (candidate.restoreReadOnly !== undefined) {
7182
+ await confirmReadOnlyRestoration(candidate.restoreReadOnly, signal);
7183
+ }
7184
+ const checkpoint = recoveryCheckpoint;
7185
+ requiredRecoveryBaseline = currentEffectLedger().boundaries
7186
+ .slice(checkpoint.boundaryPrefix).find(({ restored }) => restored !== undefined)?.restored;
7187
+ try {
7188
+ await resumeCheckpoint(checkpoint);
7189
+ }
7190
+ finally {
7191
+ requiredRecoveryBaseline = undefined;
7192
+ }
7193
+ run = runResultFor(settledOutcome(signal));
7194
+ }
6922
7195
  else {
6923
7196
  actor.send(candidate.event);
6924
7197
  await waitForPlaybookQuiescence(actor, {
@@ -6931,6 +7204,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
6931
7204
  receipt = settledReceipt(run.outcome === 'failed' || run.outcome === 'aborted'
6932
7205
  ? {
6933
7206
  disposition: 'failed',
7207
+ run,
6934
7208
  error: ('error' in run ? run.error : undefined) ??
6935
7209
  normalizeError(new Error(`apply settled with outcome ${run.outcome}`)),
6936
7210
  }
@@ -7039,7 +7313,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
7039
7313
  return receipt;
7040
7314
  },
7041
7315
  unresolvedEffectEnvelopes: unresolvedEffectEnvelopeIdentities,
7042
- async handleBossInput({ text, signal, }) {
7316
+ async handleBossInput({ text, signal, onAccepted, }) {
7043
7317
  if (!actor || !savedPorts) {
7044
7318
  throw new Error('createPlaybookRuntime.handleBossInput: init must be called first');
7045
7319
  }
@@ -7143,14 +7417,8 @@ export function createXStatePlaybookRuntime(machine, spec) {
7143
7417
  deferredOperation !== undefined) {
7144
7418
  if (isExactDeferredBossReply(classifiedSnapshot, event, deferredPending)) {
7145
7419
  const statusLine = classificationStatus(event);
7146
- const continuation = await continueBoundDeferredOperation(deferredOperation, event, signal, turnId, statusLine);
7420
+ const continuation = await continueBoundDeferredOperation(deferredOperation, event, signal, turnId, statusLine, onAccepted);
7147
7421
  if (continuation === 'continued') {
7148
- try {
7149
- lastBossEvent = snapshotJsonValue(event, 'recorded Boss event');
7150
- }
7151
- catch {
7152
- lastBossEvent = undefined;
7153
- }
7154
7422
  if (controlPlaneError !== undefined) {
7155
7423
  throw controlPlaneError;
7156
7424
  }
@@ -7159,7 +7427,6 @@ export function createXStatePlaybookRuntime(machine, spec) {
7159
7427
  : 'no-action');
7160
7428
  }
7161
7429
  else {
7162
- lastBossEvent = undefined;
7163
7430
  result = runResultFor('no-action');
7164
7431
  }
7165
7432
  handledDeferred = true;
@@ -7171,7 +7438,6 @@ export function createXStatePlaybookRuntime(machine, spec) {
7171
7438
  }
7172
7439
  else {
7173
7440
  await parkBoundDeferredOperation(deferredOperation.operationId, signal);
7174
- lastBossEvent = undefined;
7175
7441
  result = runResultFor('no-action');
7176
7442
  handledDeferred = true;
7177
7443
  }
@@ -7202,16 +7468,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
7202
7468
  suppressInspectionEmissions = false;
7203
7469
  actor.start();
7204
7470
  }
7205
- // DR-029: keep the classified event with its recorded payload
7206
- // as the retry-replay source. Recording is sanitizing, not
7207
- // load-bearing: an override classifier's non-JSON-safe event is
7208
- // simply not recorded, and the turn proceeds unchanged.
7209
- try {
7210
- lastBossEvent = snapshotJsonValue(event, 'recorded Boss event');
7211
- }
7212
- catch {
7213
- lastBossEvent = undefined;
7214
- }
7471
+ onAccepted?.();
7215
7472
  actor.send(event);
7216
7473
  await waitForPlaybookQuiescence(actor, {
7217
7474
  pendingCalls: nestedBridge,
@@ -7491,7 +7748,6 @@ export function createXStatePlaybookRuntime(machine, spec) {
7491
7748
  failedGovernedAttemptId = undefined;
7492
7749
  controlPlaneError = undefined;
7493
7750
  emissionFailure = undefined;
7494
- lastBossEvent = undefined;
7495
7751
  retainedEffectSourceSessionId = undefined;
7496
7752
  retainedEffectReconciliation = undefined;
7497
7753
  retainedEffectReconciliationRequired = false;