@sublang/playbook 14.0.0 → 14.1.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 (34) hide show
  1. package/docs/cli.md +14 -14
  2. package/package.json +1 -1
  3. package/reference/sdlc/branch.playbook/branch.fsm.d.ts +1 -1
  4. package/reference/sdlc/code.playbook/bin/repository-effects.js +187 -14
  5. package/reference/sdlc/code.playbook/code.fsm.d.ts +1 -1
  6. package/reference/sdlc/code.playbook/host-capabilities.d.ts +6 -0
  7. package/reference/sdlc/code.playbook/playbook-captain.d.ts +3 -1
  8. package/reference/sdlc/code.playbook/playbook-captain.js +357 -16
  9. package/reference/sdlc/code.playbook/playbook-captain.ts +459 -34
  10. package/reference/sdlc/code.playbook/session-host.d.ts +6 -3
  11. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +1 -1
  12. package/reference/sdlc/decide.playbook/decide.playbook.js +25 -1
  13. package/reference/sdlc/decide.playbook/decide.playbook.ts +35 -2
  14. package/reference/sdlc/dev.md +15 -8
  15. package/reference/sdlc/dev.playbook/dev.fsm.d.ts +1 -1
  16. package/reference/sdlc/dev.playbook/dev.fsm.js +18 -12
  17. package/reference/sdlc/dev.playbook/dev.fsm.ts +18 -13
  18. package/reference/sdlc/dev.playbook/dev.gears.md +19 -12
  19. package/reference/sdlc/dev.playbook/dev.playbook.js +1 -1
  20. package/reference/sdlc/dev.playbook/dev.playbook.ts +1 -2
  21. package/reference/sdlc/dev.playbook/dev.registry.js +1 -1
  22. package/reference/sdlc/dev.playbook/dev.registry.ts +1 -1
  23. package/reference/sdlc/pr.playbook/pr.fsm.d.ts +1 -1
  24. package/reference/sdlc/review.playbook/review.fsm.d.ts +1 -1
  25. package/slc/link.md +32 -0
  26. package/src/runtime.d.ts +70 -0
  27. package/src/runtime.js +232 -1
  28. package/src/runtime.ts +358 -1
  29. package/src/xstate-playbook-runtime.d.ts +9 -10
  30. package/src/xstate-playbook-runtime.js +372 -20
  31. package/src/xstate-playbook-runtime.ts +460 -19
  32. package/src/xstate-runtime.d.ts +9 -1
  33. package/src/xstate-runtime.js +299 -19
  34. package/src/xstate-runtime.ts +362 -21
@@ -31,9 +31,11 @@ import {
31
31
  createAcceptedOutcomeConsumer,
32
32
  type AcceptedOutcomeReceipt,
33
33
  } from './accepted-outcome.js';
34
+ import { assertPlaybookFailureCause } from './runtime.js';
34
35
  import {
35
36
  assertPlaybookRuntimeSnapshot,
36
37
  assertPlaybookEffectLedger,
38
+ attachPlaybookFailureCause,
37
39
  combineAbortSignals,
38
40
  createNestedPlaybookBridge,
39
41
  detachPersistedMachineSnapshot,
@@ -60,7 +62,9 @@ import type {
60
62
  PlaybookEffectBoundaryStart,
61
63
  PlaybookEffectLedger,
62
64
  PlaybookEffectLedgerCapability,
65
+ NormalizedError,
63
66
  PlaybookEffectLogicalOperation,
67
+ PlaybookFailureCause,
64
68
  PlaybookPendingBossQuestion,
65
69
  PlaybookPorts,
66
70
  PlaybookRepositoryReceipt,
@@ -149,6 +153,12 @@ export interface RuntimeBoundaryCalls {
149
153
  result: PlayerResult,
150
154
  output: PlaybookActorOutput,
151
155
  ): void;
156
+ /**
157
+ * DR-063 §2: decorate the failure the bridge builds for a non-`ok` result
158
+ * with the cause the boundary decided at the call itself, where the role,
159
+ * the resolved player, and the reported error are known.
160
+ */
161
+ markPlayerResultFailure?(error: Error): Error;
152
162
  callJudge(
153
163
  purpose: JudgePurpose,
154
164
  stateId: string | undefined,
@@ -236,6 +246,18 @@ const fsmResultFailures = new WeakSet<object>();
236
246
 
237
247
  function markFsmResultFailure(error: Error): Error {
238
248
  fsmResultFailures.add(error);
249
+ // DR-063 §2: there is no failure without a cause. A failure the runtime
250
+ // marks with nothing more specific is a runtime defect carrying its own
251
+ // message; a decision site that knows better attaches its cause afterwards.
252
+ attachPlaybookFailureCause(error, {
253
+ code: 'runtime-defect',
254
+ evidence: {
255
+ reason:
256
+ typeof error.message === 'string' && error.message.length > 0
257
+ ? error.message
258
+ : 'unknown runtime defect',
259
+ },
260
+ });
239
261
  return error;
240
262
  }
241
263
 
@@ -247,6 +269,74 @@ function isFsmResultFailure(error: unknown): boolean {
247
269
  );
248
270
  }
249
271
 
272
+ // ---------------------------------------------------------------------------
273
+ // DR-063 §1/§2: there is no failure without a cause. Each decision site builds
274
+ // the cause its own evidence states, the runtime attaches it to the error it
275
+ // marks as the FSM failure, and anything else the runtime marks becomes
276
+ // `runtime-defect` carrying its message as `reason`.
277
+ // ---------------------------------------------------------------------------
278
+
279
+ /** Adjudication reasons this runtime decides itself, and their codes. */
280
+ const GOVERNED_JUDGE_FAILURE_REASONS: readonly string[] = [
281
+ 'judge transport failed',
282
+ 'corrective judge failed',
283
+ 'corrective semantic candidate is invalid',
284
+ 'semantic correction budget is unavailable',
285
+ ];
286
+
287
+ const ABORTED_FAILURE_CAUSE: PlaybookFailureCause = assertPlaybookFailureCause({
288
+ code: 'aborted',
289
+ evidence: {},
290
+ });
291
+
292
+ function runtimeDefectCause(reason: string): PlaybookFailureCause {
293
+ return assertPlaybookFailureCause({
294
+ code: 'runtime-defect',
295
+ evidence: { reason: reason.length === 0 ? 'unknown runtime defect' : reason },
296
+ });
297
+ }
298
+
299
+ function failureErrorEvidence(
300
+ error: unknown,
301
+ ): { readonly name: string; readonly message: string } | undefined {
302
+ if (error === undefined || error === null) return undefined;
303
+ const { name, message } = normalizeError(error);
304
+ return { name, message };
305
+ }
306
+
307
+ /** A string `code` the rejecting port reported, when it reported one. */
308
+ function failureErrorCode(error: unknown): string | undefined {
309
+ if (typeof error !== 'object' || error === null) return undefined;
310
+ try {
311
+ const code = (error as { code?: unknown }).code;
312
+ return typeof code === 'string' && code.length > 0 ? code : undefined;
313
+ } catch {
314
+ return undefined;
315
+ }
316
+ }
317
+
318
+ function playerFailureCause(input: {
319
+ readonly roleId: string;
320
+ readonly playerId?: string;
321
+ readonly error: unknown;
322
+ }): PlaybookFailureCause {
323
+ return assertPlaybookFailureCause({
324
+ code: 'player-failed',
325
+ evidence: {
326
+ roleId: input.roleId,
327
+ ...(input.playerId === undefined ? {} : { playerId: input.playerId }),
328
+ error: failureErrorEvidence(input.error) ?? {
329
+ name: 'Error',
330
+ message: 'Unknown error',
331
+ },
332
+ ...(failureErrorCode(input.error) === undefined
333
+ ? {}
334
+ : { errorCode: failureErrorCode(input.error)! }),
335
+ },
336
+ });
337
+ }
338
+
339
+
250
340
  // ---------------------------------------------------------------------------
251
341
  // DR-028: both call boundaries treat an `ok` result whose `finalText` is
252
342
  // missing, empty, or whitespace-only under one empty predicate, and that
@@ -946,15 +1036,21 @@ export function parseJudgeJson(raw: string): unknown {
946
1036
 
947
1037
  export function normalizeErrorCompact(
948
1038
  err: unknown,
949
- ): { name: string; message: string } | undefined {
1039
+ ): Omit<NormalizedError, 'stack'> | undefined {
950
1040
  if (err === undefined || err === null) return undefined;
951
1041
  const normalized = normalizeError(err);
952
- return { name: normalized.name, message: normalized.message };
1042
+ return {
1043
+ name: normalized.name,
1044
+ message: normalized.message,
1045
+ // DR-063 §2: on a failure the cause is why the compact form exists, so it
1046
+ // survives where the stack does not.
1047
+ ...(normalized.cause === undefined ? {} : { cause: normalized.cause }),
1048
+ };
953
1049
  }
954
1050
 
955
1051
  export function normalizeErrorFull(
956
1052
  err: unknown,
957
- ): { name: string; message: string; stack?: string } | undefined {
1053
+ ): NormalizedError | undefined {
958
1054
  if (err === undefined || err === null) return undefined;
959
1055
  return normalizeError(err);
960
1056
  }
@@ -1090,6 +1186,98 @@ export function composePlayerContinuation(
1090
1186
  return [...continuationBlocks(input, resuming), body].join('\n\n');
1091
1187
  }
1092
1188
 
1189
+ // DR-062 §4: the Coder is told which uncommitted changes were already in the
1190
+ // worktree when its own call started. The block is composed from the call's
1191
+ // own baseline observation, names paths only, and is bounded.
1192
+ const PRE_EXISTING_CHANGE_GROUPS = [
1193
+ 'staged',
1194
+ 'modified',
1195
+ 'deleted',
1196
+ 'untracked',
1197
+ 'renamed',
1198
+ 'unmerged',
1199
+ 'other',
1200
+ ] as const;
1201
+
1202
+ type PreExistingChangeGroup = (typeof PRE_EXISTING_CHANGE_GROUPS)[number];
1203
+
1204
+ const PRE_EXISTING_CHANGE_PATH_LIMIT = 40;
1205
+
1206
+ /**
1207
+ * The group one baseline projection entry belongs to. The shapes are the
1208
+ * porcelain-v2 records the repository observer projects: an `untracked`,
1209
+ * `rename-or-copy`, or `unmerged` kind, and otherwise an `ordinary` record
1210
+ * whose two-character `xy` carries the index state first and the worktree
1211
+ * state second.
1212
+ */
1213
+ function preExistingChangeGroup(entry: unknown): PreExistingChangeGroup {
1214
+ if (!isPlainObject(entry)) return 'other';
1215
+ if (entry.kind === 'untracked') return 'untracked';
1216
+ if (entry.kind === 'rename-or-copy') return 'renamed';
1217
+ if (entry.kind === 'unmerged') return 'unmerged';
1218
+ const xy = entry.xy;
1219
+ if (entry.kind !== 'ordinary' || typeof xy !== 'string' || xy.length !== 2) {
1220
+ return 'other';
1221
+ }
1222
+ if (xy[1] === 'D') return 'deleted';
1223
+ if (xy[1] === '.') return xy[0] === '.' ? 'other' : 'staged';
1224
+ return 'modified';
1225
+ }
1226
+
1227
+ /**
1228
+ * The pre-existing-changes block appended to an effect-authorized player
1229
+ * prompt, or `undefined` for an empty baseline projection.
1230
+ */
1231
+ function preExistingChangesBlock(
1232
+ baseline: PlaybookRepositoryReceipt['baseline'] | undefined,
1233
+ ): string | undefined {
1234
+ const projection = baseline?.projection;
1235
+ if (!isPlainObject(projection)) return undefined;
1236
+ const grouped = new Map<PreExistingChangeGroup, string[]>();
1237
+ let total = 0;
1238
+ for (const path of Object.keys(projection)) {
1239
+ const group = preExistingChangeGroup(projection[path]);
1240
+ const paths = grouped.get(group);
1241
+ if (paths === undefined) grouped.set(group, [path]);
1242
+ else paths.push(path);
1243
+ total += 1;
1244
+ }
1245
+ if (total === 0) return undefined;
1246
+ const omitted = Math.max(0, total - PRE_EXISTING_CHANGE_PATH_LIMIT);
1247
+ const lines: string[] = [];
1248
+ let budget = PRE_EXISTING_CHANGE_PATH_LIMIT;
1249
+ for (const group of PRE_EXISTING_CHANGE_GROUPS) {
1250
+ const paths = grouped.get(group);
1251
+ if (paths === undefined || budget <= 0) continue;
1252
+ const printed = paths.slice().sort().slice(0, budget);
1253
+ budget -= printed.length;
1254
+ lines.push(`> - ${group}: ${printed.join(', ')}`);
1255
+ }
1256
+ if (omitted > 0 && lines.length > 0) {
1257
+ lines[lines.length - 1] += `, … and ${omitted} more`;
1258
+ }
1259
+ return [
1260
+ '> Uncommitted changes present before this call, belonging to the Boss:',
1261
+ ...lines,
1262
+ "> Leave them exactly as they are unless the task or the Boss's request requires building on them; never revert or delete them; when you commit any of them, name them in your final report.",
1263
+ ].join('\n');
1264
+ }
1265
+
1266
+ /**
1267
+ * The block for one governed call, or `undefined` where the call is not
1268
+ * effect-authorized — a call whose declared outcomes carry no
1269
+ * `one-descendant-commit` disposition may commit nothing, so it is told
1270
+ * nothing.
1271
+ */
1272
+ function effectAuthorizedPreExistingBlock(
1273
+ effectBoundary: Pick<XStateEffectBoundarySeed, 'dispositions'>,
1274
+ baseline: PlaybookRepositoryReceipt['baseline'] | undefined,
1275
+ ): string | undefined {
1276
+ return effectBoundary.dispositions.includes('one-descendant-commit')
1277
+ ? preExistingChangesBlock(baseline)
1278
+ : undefined;
1279
+ }
1280
+
1093
1281
  const PLACEHOLDER_PATTERN = /<(#|[A-Za-z_$][A-Za-z0-9_$-]*)>/g;
1094
1282
 
1095
1283
  function placeholderFieldName(
@@ -1692,9 +1880,10 @@ export function createPlayerBridge(
1692
1880
  );
1693
1881
  }
1694
1882
  if (result.status !== 'ok') {
1695
- throw new Error(
1883
+ const failure = new Error(
1696
1884
  result.error ?? `captainBridge: callPlayer status "${result.status}"`,
1697
1885
  );
1886
+ throw boundary?.markPlayerResultFailure?.(failure) ?? failure;
1698
1887
  }
1699
1888
  const finalText = result.finalText ?? '';
1700
1889
  if (isEmptyFinalText(finalText)) {
@@ -3371,6 +3560,8 @@ export function createXStatePlaybookRuntime<
3371
3560
  | {
3372
3561
  readonly operationId: string;
3373
3562
  readonly effectBoundary: XStateEffectBoundarySeed;
3563
+ /** The continuation's own baseline observation (DR-062 §4). */
3564
+ baseline?: PlaybookRepositoryReceipt['baseline'];
3374
3565
  playerContinuation?: string | false;
3375
3566
  input?: PlaybookPlayerInput;
3376
3567
  roleId?: string;
@@ -3386,6 +3577,15 @@ export function createXStatePlaybookRuntime<
3386
3577
  let deferInspectionEmissions = false;
3387
3578
  let deferredInspectionEmissions: Array<() => void> = [];
3388
3579
  let controlPlaneError: unknown;
3580
+ // DR-063 §2: the cause decided at the site the failure was decided, kept
3581
+ // for the failures whose Error the compiled artifact builds itself and the
3582
+ // runtime therefore cannot decorate. It is cleared at each Boss turn start
3583
+ // and at each successful player result, so it is never stale, and it is
3584
+ // only ever read as the cause of an error that carries none of its own.
3585
+ let lastFailureCause: PlaybookFailureCause | undefined;
3586
+ const rememberFailureCause = (cause: PlaybookFailureCause): void => {
3587
+ lastFailureCause = cause;
3588
+ };
3389
3589
  // Previous root-machine state for the inspect-driven telemetry /
3390
3590
  // status emitter. undefined before the first inspect firing.
3391
3591
  let priorState: PlaybookState | undefined;
@@ -3881,6 +4081,19 @@ export function createXStatePlaybookRuntime<
3881
4081
  refreshRetainedEffectReconciliation(current);
3882
4082
  syncDeferredReconciliationOverlay();
3883
4083
  refreshUnresolvedSemanticReconciliation(current);
4084
+ return projectUnresolvedEffectEnvelopes(current);
4085
+ }
4086
+
4087
+ /**
4088
+ * The unresolved envelopes of one already-refreshed ledger mirror. Pure:
4089
+ * `describe()` reads it through the control view without moving anything.
4090
+ */
4091
+ function projectUnresolvedEffectEnvelopes(
4092
+ current: PlaybookEffectLedger,
4093
+ ): readonly (
4094
+ | { readonly kind: 'boundary'; readonly boundaryId: string }
4095
+ | { readonly kind: 'logical-operation'; readonly operationId: string }
4096
+ )[] {
3884
4097
  if (!hasUnresolvedReconciliation()) return [];
3885
4098
 
3886
4099
  const boundaryIds = new Set(unresolvedSemanticBoundaryIds);
@@ -4652,13 +4865,47 @@ export function createXStatePlaybookRuntime<
4652
4865
  });
4653
4866
  }
4654
4867
 
4868
+ // DR-063 §1: the cause of an unresolved governed settlement. A reconciled
4869
+ // mismatch supplies its own receipt-read cause; the reasons this function
4870
+ // owns map to adjudication, abort, and runtime-defect codes.
4871
+ function governedSettlementCause(
4872
+ reason: string,
4873
+ error: unknown,
4874
+ aborted: boolean,
4875
+ supplied: PlaybookFailureCause | undefined,
4876
+ ): PlaybookFailureCause {
4877
+ if (supplied !== undefined) return supplied;
4878
+ if (aborted || reason.includes('aborted')) return ABORTED_FAILURE_CAUSE;
4879
+ if (GOVERNED_JUDGE_FAILURE_REASONS.includes(reason)) {
4880
+ const transport = failureErrorEvidence(error);
4881
+ return assertPlaybookFailureCause({
4882
+ code: 'judge-failed',
4883
+ evidence: {
4884
+ reason,
4885
+ ...(transport === undefined ? {} : { error: transport }),
4886
+ },
4887
+ });
4888
+ }
4889
+ return runtimeDefectCause(reason);
4890
+ }
4891
+
4655
4892
  function unresolvedGovernedSettlement(
4656
4893
  reason: string,
4657
4894
  error?: unknown,
4658
4895
  signal: AbortSignal | undefined = activeSignal,
4896
+ cause?: PlaybookFailureCause,
4659
4897
  ): GovernedPlayerSettlement {
4660
- if (error !== undefined && signal?.aborted && Object.is(error, signal.reason)) {
4661
- return { status: 'unresolved', error };
4898
+ const aborted =
4899
+ error !== undefined && signal?.aborted === true
4900
+ ? Object.is(error, signal.reason)
4901
+ : false;
4902
+ const decided = governedSettlementCause(reason, error, aborted, cause);
4903
+ rememberFailureCause(decided);
4904
+ if (aborted) {
4905
+ return {
4906
+ status: 'unresolved',
4907
+ error: attachPlaybookFailureCause(error, decided),
4908
+ };
4662
4909
  }
4663
4910
  const failure =
4664
4911
  error instanceof Error
@@ -4666,7 +4913,10 @@ export function createXStatePlaybookRuntime<
4666
4913
  : new Error(`${label} governed outcome remains unresolved: ${reason}`);
4667
4914
  return {
4668
4915
  status: 'unresolved',
4669
- error: markFsmResultFailure(failure),
4916
+ error: attachPlaybookFailureCause(
4917
+ markFsmResultFailure(failure),
4918
+ decided,
4919
+ ),
4670
4920
  };
4671
4921
  }
4672
4922
 
@@ -4914,7 +5164,12 @@ export function createXStatePlaybookRuntime<
4914
5164
  if (reconciliation.status === 'unresolved') {
4915
5165
  governedSettlementsByBoundaryId.set(
4916
5166
  completion.boundary.boundaryId,
4917
- unresolvedGovernedSettlement(reconciliation.reason),
5167
+ unresolvedGovernedSettlement(
5168
+ reconciliation.reason,
5169
+ undefined,
5170
+ undefined,
5171
+ reconciliation.cause,
5172
+ ),
4918
5173
  );
4919
5174
  return { finalText, semanticCandidate, unresolved: true };
4920
5175
  }
@@ -5462,9 +5717,19 @@ export function createXStatePlaybookRuntime<
5462
5717
  try {
5463
5718
  const runTracedPlayerCall = async (
5464
5719
  resume: string | false = selectedResume,
5720
+ // DR-062 §4: composed from this call's own baseline observation,
5721
+ // so the prompt the trace records is the prompt the player is sent.
5722
+ preExistingBlock?: string,
5465
5723
  ): Promise<PlayerResult> => {
5466
5724
  const identity = callIdentity(resume);
5467
- const callPrompt = resume === false ? freshPrompt : prompt;
5725
+ const withPreExisting = (text: string): string =>
5726
+ preExistingBlock === undefined
5727
+ ? text
5728
+ : `${text}\n\n${preExistingBlock}`;
5729
+ const callPrompt = withPreExisting(
5730
+ resume === false ? freshPrompt : prompt,
5731
+ );
5732
+ const callFreshPrompt = withPreExisting(freshPrompt);
5468
5733
  await emitCallStarted(
5469
5734
  'player.call.started',
5470
5735
  'player.call.finished',
@@ -5484,7 +5749,12 @@ export function createXStatePlaybookRuntime<
5484
5749
  roleId,
5485
5750
  callPrompt,
5486
5751
  signal,
5487
- { resume, ...(callPrompt === freshPrompt ? {} : { freshPrompt }) },
5752
+ {
5753
+ resume,
5754
+ ...(callPrompt === callFreshPrompt
5755
+ ? {}
5756
+ : { freshPrompt: callFreshPrompt }),
5757
+ },
5488
5758
  );
5489
5759
  // A host promise is not required to honor cancellation. Do not
5490
5760
  // let a late result mutate continuity or publish a successful
@@ -5507,7 +5777,7 @@ export function createXStatePlaybookRuntime<
5507
5777
  }
5508
5778
  // A thrown port call carries no authoritative result, so the
5509
5779
  // prior token remains available for a later explicit resume.
5510
- throw error;
5780
+ throw decidePlayerCallFailure(error, roleId, playerId, signal);
5511
5781
  }
5512
5782
 
5513
5783
  let result: PlayerResult;
@@ -5524,7 +5794,7 @@ export function createXStatePlaybookRuntime<
5524
5794
  } catch {
5525
5795
  // The malformed host result remains authoritative.
5526
5796
  }
5527
- throw error;
5797
+ throw decidePlayerCallFailure(error, roleId, playerId, signal);
5528
5798
  }
5529
5799
 
5530
5800
  try {
@@ -5560,6 +5830,25 @@ export function createXStatePlaybookRuntime<
5560
5830
  },
5561
5831
  position,
5562
5832
  );
5833
+ // DR-063 §2: the player bridge builds the failure Error for a
5834
+ // non-`ok` result, so the cause is decided here, where the role,
5835
+ // the player, and the reported error are known, and a successful
5836
+ // result clears it so no later failure inherits it.
5837
+ if (result.status === 'ok') {
5838
+ lastFailureCause = undefined;
5839
+ } else if (result.status === 'aborted') {
5840
+ rememberFailureCause(ABORTED_FAILURE_CAUSE);
5841
+ } else {
5842
+ rememberFailureCause(
5843
+ playerFailureCause({
5844
+ roleId,
5845
+ ...(playerId === undefined ? {} : { playerId }),
5846
+ error:
5847
+ result.error ??
5848
+ `callPlayer status ${JSON.stringify(result.status)}`,
5849
+ }),
5850
+ );
5851
+ }
5563
5852
  return result;
5564
5853
  };
5565
5854
 
@@ -5600,6 +5889,10 @@ export function createXStatePlaybookRuntime<
5600
5889
  try {
5601
5890
  deferredContinuation.result = await runTracedPlayerCall(
5602
5891
  selectedResume,
5892
+ effectAuthorizedPreExistingBlock(
5893
+ effectBoundary,
5894
+ deferredContinuation.baseline,
5895
+ ),
5603
5896
  );
5604
5897
  } catch (error) {
5605
5898
  deferredContinuation.callError = error;
@@ -5621,7 +5914,18 @@ export function createXStatePlaybookRuntime<
5621
5914
  const exclusive = await repositoryCapability.runExclusive({
5622
5915
  signal,
5623
5916
  effectBoundary,
5624
- operation: () => runTracedPlayerCall(),
5917
+ // DR-062 §4: the call's own baseline, supplied by the host that
5918
+ // captured it under this exclusive claim. A host that supplies
5919
+ // none states no pre-existing change, exactly as an empty
5920
+ // baseline projection does.
5921
+ operation: (context) =>
5922
+ runTracedPlayerCall(
5923
+ selectedResume,
5924
+ effectAuthorizedPreExistingBlock(
5925
+ effectBoundary,
5926
+ context?.baseline,
5927
+ ),
5928
+ ),
5625
5929
  completeEffectBoundary: completionEvidenceFor(
5626
5930
  input,
5627
5931
  roleId,
@@ -5652,6 +5956,12 @@ export function createXStatePlaybookRuntime<
5652
5956
  }
5653
5957
  },
5654
5958
 
5959
+ markPlayerResultFailure(error): Error {
5960
+ return lastFailureCause === undefined
5961
+ ? error
5962
+ : attachPlaybookFailureCause(error, lastFailureCause);
5963
+ },
5964
+
5655
5965
  takeGovernedPlayerOutput(result): GovernedPlayerSettlement | undefined {
5656
5966
  const settlement = governedPlayerSettlements.get(result);
5657
5967
  if (settlement !== undefined) governedPlayerSettlements.delete(result);
@@ -6292,6 +6602,80 @@ export function createXStatePlaybookRuntime<
6292
6602
  return activeTurnId === undefined ? {} : { turnId: activeTurnId };
6293
6603
  }
6294
6604
 
6605
+ // DR-063 §2: the parked failure's error as every public surface publishes
6606
+ // it — the FSM's own `lastError`, completed with the cause decided where
6607
+ // the failure was decided. An error that already carries a valid cause
6608
+ // keeps it; an artifact-built error takes the runtime's own slot; anything
6609
+ // else is a runtime defect carrying its message. There is no failure
6610
+ // without a cause.
6611
+ function failedStateError(
6612
+ context: Record<string, unknown>,
6613
+ ): NormalizedError | undefined {
6614
+ const normalized: NormalizedError | undefined = normalizeErrorFull(
6615
+ context.lastError,
6616
+ );
6617
+ if (normalized === undefined || normalized.cause !== undefined) {
6618
+ return normalized;
6619
+ }
6620
+ return {
6621
+ ...normalized,
6622
+ cause: lastFailureCause ?? runtimeDefectCause(normalized.message),
6623
+ };
6624
+ }
6625
+
6626
+ // DR-063 §2: a rejected player port or a result the runtime cannot read is
6627
+ // a `player-failed` failure named by role; an abort is `aborted`.
6628
+ function decidePlayerCallFailure(
6629
+ error: unknown,
6630
+ roleId: string,
6631
+ playerId: string | undefined,
6632
+ signal: AbortSignal,
6633
+ ): unknown {
6634
+ const cause = isAbortFailure(error, signal)
6635
+ ? ABORTED_FAILURE_CAUSE
6636
+ : playerFailureCause({
6637
+ roleId,
6638
+ ...(playerId === undefined ? {} : { playerId }),
6639
+ error,
6640
+ });
6641
+ rememberFailureCause(cause);
6642
+ return attachPlaybookFailureCause(error, cause);
6643
+ }
6644
+
6645
+ function completeFailedStatuses(
6646
+ statuses: readonly ScheduledStatus[],
6647
+ state: PlaybookState,
6648
+ context: Record<string, unknown>,
6649
+ ): readonly ScheduledStatus[] {
6650
+ if (state.stateId !== 'failed') return statuses;
6651
+ const lastError = failedStateError(context);
6652
+ if (lastError?.cause === undefined) return statuses;
6653
+ const cause = lastError.cause as unknown as JsonValue;
6654
+ return statuses.map((status) => {
6655
+ const data = status.data;
6656
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) {
6657
+ return status;
6658
+ }
6659
+ const recorded = data as { readonly [key: string]: JsonValue };
6660
+ const recordedError = recorded.lastError;
6661
+ if (
6662
+ recordedError === undefined ||
6663
+ recordedError === null ||
6664
+ typeof recordedError !== 'object' ||
6665
+ Array.isArray(recordedError)
6666
+ ) {
6667
+ return status;
6668
+ }
6669
+ return {
6670
+ ...status,
6671
+ data: snapshotJsonValue(
6672
+ { ...recorded, lastError: { ...recordedError, cause } },
6673
+ 'failed status data',
6674
+ ),
6675
+ };
6676
+ });
6677
+ }
6678
+
6295
6679
  function structuredStateTelemetryPayload(
6296
6680
  previousState: PlaybookState | undefined,
6297
6681
  state: PlaybookState,
@@ -6313,7 +6697,7 @@ export function createXStatePlaybookRuntime<
6313
6697
  payload.pendingBossQuestion = pendingBossQuestion;
6314
6698
  }
6315
6699
  if (state.stateId === 'failed') {
6316
- const lastError = normalizeErrorFull(context.lastError);
6700
+ const lastError = failedStateError(context);
6317
6701
  if (lastError !== undefined) payload.lastError = lastError;
6318
6702
  }
6319
6703
  return snapshotJsonValue(payload, 'FSM telemetry payload');
@@ -6518,10 +6902,10 @@ export function createXStatePlaybookRuntime<
6518
6902
  inspectionEvent.event,
6519
6903
  context,
6520
6904
  );
6521
- const stateStatuses = statusesForState(
6905
+ const stateStatuses = completeFailedStatuses(
6906
+ statusesForState(state, context, inspectionEvent.event),
6522
6907
  state,
6523
6908
  context,
6524
- inspectionEvent.event,
6525
6909
  );
6526
6910
  const outcomeStatuses = usesDefaultStatuses
6527
6911
  ? acceptedOutcomes.map(({ acceptedOutcome }) => ({
@@ -6621,10 +7005,17 @@ export function createXStatePlaybookRuntime<
6621
7005
  : outcome === 'aborted'
6622
7006
  ? activeSignal?.reason
6623
7007
  : undefined);
7008
+ if (failure === undefined) return { outcome, state };
7009
+ // DR-063 §2: a `failed` settlement publishes the same cause-completed
7010
+ // error the failed state's status line and telemetry publish.
7011
+ const normalized =
7012
+ outcome === 'failed'
7013
+ ? failedStateError({ lastError: failure })
7014
+ : normalizeError(failure);
6624
7015
  return {
6625
7016
  outcome,
6626
7017
  state,
6627
- ...(failure !== undefined ? { error: normalizeError(failure) } : {}),
7018
+ ...(normalized === undefined ? {} : { error: normalized }),
6628
7019
  };
6629
7020
  }
6630
7021
 
@@ -6853,11 +7244,46 @@ export function createXStatePlaybookRuntime<
6853
7244
  action: {
6854
7245
  id: `retry:${retryEvent.type}`,
6855
7246
  label: `Retry: ${description}`,
7247
+ // DR-063 §3: retrying a failed call is `ready` — the runtime cannot
7248
+ // see whether the Boss changed the outside world since it failed.
7249
+ standing: 'ready',
6856
7250
  },
6857
7251
  event: retryEvent,
6858
7252
  };
6859
7253
  }
6860
7254
 
7255
+ /**
7256
+ * DR-063 §3: reconciliation only re-reads the host's ledger, which the
7257
+ * control view has just done. Where no checkpoint restoration is eligible
7258
+ * and every unresolved envelope's boundary already holds a complete
7259
+ * receipt, re-reading can resolve nothing, so the action is a no-op.
7260
+ */
7261
+ function reconciliationStanding(
7262
+ deferredRestoreOperationId: string | undefined,
7263
+ ): Pick<PlaybookControlAction, 'standing' | 'reason'> {
7264
+ if (deferredRestoreOperationId !== undefined) return { standing: 'ready' };
7265
+ const current = effectLedgerMirror;
7266
+ const envelopes = projectUnresolvedEffectEnvelopes(current);
7267
+ if (envelopes.length === 0) return { standing: 'ready' };
7268
+ const boundaryIds = envelopes.flatMap((envelope) =>
7269
+ envelope.kind === 'boundary'
7270
+ ? [envelope.boundaryId]
7271
+ : (current.logicalOperations.find(
7272
+ ({ operationId }) => operationId === envelope.operationId,
7273
+ )?.boundaryIds ?? []),
7274
+ );
7275
+ if (boundaryIds.length === 0) return { standing: 'ready' };
7276
+ const complete = boundaryIds.every(
7277
+ (boundaryId) =>
7278
+ current.boundaries.find(
7279
+ (candidate) => candidate.boundaryId === boundaryId,
7280
+ )?.physicalReceipt !== undefined,
7281
+ );
7282
+ return complete
7283
+ ? { standing: 'no-op', reason: 'receipt-complete' }
7284
+ : { standing: 'ready' };
7285
+ }
7286
+
6861
7287
  function deriveControlActions(snapshot: unknown): DerivedControlAction[] {
6862
7288
  // Actions derive only at the safe point the parked snapshot also
6863
7289
  // uses — quiescent actor with status `active` and no pending nested
@@ -6896,6 +7322,7 @@ export function createXStatePlaybookRuntime<
6896
7322
  action: {
6897
7323
  id: UNRESOLVED_EFFECT_RECONCILIATION_ACTION_ID,
6898
7324
  label: 'Retry unresolved effect reconciliation',
7325
+ ...reconciliationStanding(deferredRestoreOperationId),
6899
7326
  },
6900
7327
  unresolvedEffectAction: 'reconcile',
6901
7328
  ...(deferredRestoreOperationId === undefined
@@ -6906,6 +7333,8 @@ export function createXStatePlaybookRuntime<
6906
7333
  action: {
6907
7334
  id: UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID,
6908
7335
  label: 'Abandon unresolved workflow attempt',
7336
+ // DR-063 §3: abandonment always changes the session's standing.
7337
+ standing: 'ready',
6909
7338
  },
6910
7339
  unresolvedEffectAction: 'abandon',
6911
7340
  },
@@ -6930,6 +7359,8 @@ export function createXStatePlaybookRuntime<
6930
7359
  action: {
6931
7360
  id: `jump:${targetId}`,
6932
7361
  label: `Resume from: ${description}`,
7362
+ // DR-063 §3: a jump always moves the machine.
7363
+ standing: 'ready',
6933
7364
  },
6934
7365
  event,
6935
7366
  });
@@ -7421,8 +7852,12 @@ export function createXStatePlaybookRuntime<
7421
7852
  signal,
7422
7853
  operationId: operation.operationId,
7423
7854
  effectBoundary,
7424
- operation: async ({ playerContinuation }) => {
7855
+ operation: async (context) => {
7856
+ const { playerContinuation } = context ?? {};
7425
7857
  validateBinding(playerContinuation);
7858
+ // DR-062 §4: the continuation's own baseline, captured before the
7859
+ // FSM re-enters the bound player state that reads it.
7860
+ continuation.baseline = context?.baseline;
7426
7861
  continuation.playerContinuation = selectPlayerResume(
7427
7862
  effectBoundary.roleId, boundPlayerId,
7428
7863
  );
@@ -7926,7 +8361,10 @@ export function createXStatePlaybookRuntime<
7926
8361
  !hasUnresolvedReconciliation()
7927
8362
  ? pendingBossQuestionForState(state, context)
7928
8363
  : undefined;
7929
- const lastError = normalizeErrorFull(context.lastError);
8364
+ const lastError =
8365
+ state.stateId === 'failed'
8366
+ ? failedStateError(context)
8367
+ : normalizeErrorFull(context.lastError);
7930
8368
  const projectedContext = projectControlContext(context);
7931
8369
  const stateDescription =
7932
8370
  !hasUnresolvedReconciliation()
@@ -8325,6 +8763,9 @@ export function createXStatePlaybookRuntime<
8325
8763
  activeAborts = abortReasonClassifier(signal);
8326
8764
  activeAbortEmission = undefined;
8327
8765
  controlPlaneError = undefined;
8766
+ // DR-063 §2: each turn decides its own failures, so the slot never
8767
+ // carries a previous turn's cause into this one.
8768
+ lastFailureCause = undefined;
8328
8769
  let result: PlaybookRunResult | undefined;
8329
8770
  let operationError: unknown;
8330
8771
  // The boundary sentinel releases on every exit: a settlement defect