@sublang/playbook 14.0.0 → 15.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 (51) hide show
  1. package/README.md +1 -1
  2. package/docs/cli.md +14 -14
  3. package/docs/configuration.md +15 -9
  4. package/package.json +1 -1
  5. package/reference/sdlc/branch.playbook/branch.fsm.d.ts +1 -1
  6. package/reference/sdlc/code.md +1 -1
  7. package/reference/sdlc/code.playbook/bin/launch-config.js +55 -16
  8. package/reference/sdlc/code.playbook/bin/playbook.js +14 -9
  9. package/reference/sdlc/code.playbook/bin/repository-effects.js +187 -14
  10. package/reference/sdlc/code.playbook/bin/run.js +12 -8
  11. package/reference/sdlc/code.playbook/code.fsm.d.ts +1 -1
  12. package/reference/sdlc/code.playbook/code.fsm.js +1 -1
  13. package/reference/sdlc/code.playbook/code.fsm.ts +1 -1
  14. package/reference/sdlc/code.playbook/code.gears.md +2 -2
  15. package/reference/sdlc/code.playbook/host-capabilities.d.ts +6 -0
  16. package/reference/sdlc/code.playbook/playbook-captain.d.ts +3 -1
  17. package/reference/sdlc/code.playbook/playbook-captain.js +357 -16
  18. package/reference/sdlc/code.playbook/playbook-captain.ts +459 -34
  19. package/reference/sdlc/code.playbook/session-host.d.ts +6 -3
  20. package/reference/sdlc/decide.md +1 -1
  21. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +1 -1
  22. package/reference/sdlc/decide.playbook/decide.fsm.js +1 -1
  23. package/reference/sdlc/decide.playbook/decide.fsm.ts +1 -1
  24. package/reference/sdlc/decide.playbook/decide.gears.md +1 -1
  25. package/reference/sdlc/decide.playbook/decide.playbook.js +25 -1
  26. package/reference/sdlc/decide.playbook/decide.playbook.ts +35 -2
  27. package/reference/sdlc/dev.md +15 -8
  28. package/reference/sdlc/dev.playbook/dev.fsm.d.ts +1 -1
  29. package/reference/sdlc/dev.playbook/dev.fsm.js +18 -12
  30. package/reference/sdlc/dev.playbook/dev.fsm.ts +18 -13
  31. package/reference/sdlc/dev.playbook/dev.gears.md +19 -12
  32. package/reference/sdlc/dev.playbook/dev.playbook.js +1 -1
  33. package/reference/sdlc/dev.playbook/dev.playbook.ts +1 -2
  34. package/reference/sdlc/dev.playbook/dev.registry.js +1 -1
  35. package/reference/sdlc/dev.playbook/dev.registry.ts +1 -1
  36. package/reference/sdlc/pr.playbook/pr.fsm.d.ts +1 -1
  37. package/reference/sdlc/review.md +1 -1
  38. package/reference/sdlc/review.playbook/review.fsm.d.ts +1 -1
  39. package/reference/sdlc/review.playbook/review.fsm.js +1 -1
  40. package/reference/sdlc/review.playbook/review.fsm.ts +1 -1
  41. package/reference/sdlc/review.playbook/review.gears.md +1 -1
  42. package/slc/link.md +32 -0
  43. package/src/runtime.d.ts +70 -0
  44. package/src/runtime.js +232 -1
  45. package/src/runtime.ts +358 -1
  46. package/src/xstate-playbook-runtime.d.ts +9 -10
  47. package/src/xstate-playbook-runtime.js +372 -20
  48. package/src/xstate-playbook-runtime.ts +460 -19
  49. package/src/xstate-runtime.d.ts +9 -1
  50. package/src/xstate-runtime.js +299 -19
  51. package/src/xstate-runtime.ts +362 -21
@@ -21,7 +21,8 @@ import { isDeepStrictEqual } from 'node:util';
21
21
  import PQueue from 'p-queue';
22
22
  import { createActor, fromPromise } from 'xstate';
23
23
  import { createAcceptedOutcomeConsumer, } from './accepted-outcome.js';
24
- import { assertPlaybookRuntimeSnapshot, assertPlaybookEffectLedger, combineAbortSignals, createNestedPlaybookBridge, detachPersistedMachineSnapshot, normalizeError, normalizePlaybookSnapshot, snapshotJsonValue, snapshotPlaybookSession, isPlaybookEffectLedgerMonotonicExtension, PlaybookSemanticCandidateStructureError, reconcilePlaybookSemanticEvidence, validateCaptainResult, validatePlayerResult, waitForPlaybookQuiescence, } from './xstate-runtime.js';
24
+ import { assertPlaybookFailureCause } from './runtime.js';
25
+ import { assertPlaybookRuntimeSnapshot, assertPlaybookEffectLedger, attachPlaybookFailureCause, combineAbortSignals, createNestedPlaybookBridge, detachPersistedMachineSnapshot, normalizeError, normalizePlaybookSnapshot, snapshotJsonValue, snapshotPlaybookSession, isPlaybookEffectLedgerMonotonicExtension, PlaybookSemanticCandidateStructureError, reconcilePlaybookSemanticEvidence, validateCaptainResult, validatePlayerResult, waitForPlaybookQuiescence, } from './xstate-runtime.js';
25
26
  export const BOSS_REPLY_ERRORS = {
26
27
  missingQuestion: "needsBossReply outcome missing 'question' field",
27
28
  unregisteredState: (stateId) => `state ${stateId} declared needsBossReply but is not registered as resumable`,
@@ -38,6 +39,17 @@ export const BOSS_REPLY_ERRORS = {
38
39
  const fsmResultFailures = new WeakSet();
39
40
  function markFsmResultFailure(error) {
40
41
  fsmResultFailures.add(error);
42
+ // DR-063 §2: there is no failure without a cause. A failure the runtime
43
+ // marks with nothing more specific is a runtime defect carrying its own
44
+ // message; a decision site that knows better attaches its cause afterwards.
45
+ attachPlaybookFailureCause(error, {
46
+ code: 'runtime-defect',
47
+ evidence: {
48
+ reason: typeof error.message === 'string' && error.message.length > 0
49
+ ? error.message
50
+ : 'unknown runtime defect',
51
+ },
52
+ });
41
53
  return error;
42
54
  }
43
55
  function isFsmResultFailure(error) {
@@ -46,6 +58,63 @@ function isFsmResultFailure(error) {
46
58
  fsmResultFailures.has(error));
47
59
  }
48
60
  // ---------------------------------------------------------------------------
61
+ // DR-063 §1/§2: there is no failure without a cause. Each decision site builds
62
+ // the cause its own evidence states, the runtime attaches it to the error it
63
+ // marks as the FSM failure, and anything else the runtime marks becomes
64
+ // `runtime-defect` carrying its message as `reason`.
65
+ // ---------------------------------------------------------------------------
66
+ /** Adjudication reasons this runtime decides itself, and their codes. */
67
+ const GOVERNED_JUDGE_FAILURE_REASONS = [
68
+ 'judge transport failed',
69
+ 'corrective judge failed',
70
+ 'corrective semantic candidate is invalid',
71
+ 'semantic correction budget is unavailable',
72
+ ];
73
+ const ABORTED_FAILURE_CAUSE = assertPlaybookFailureCause({
74
+ code: 'aborted',
75
+ evidence: {},
76
+ });
77
+ function runtimeDefectCause(reason) {
78
+ return assertPlaybookFailureCause({
79
+ code: 'runtime-defect',
80
+ evidence: { reason: reason.length === 0 ? 'unknown runtime defect' : reason },
81
+ });
82
+ }
83
+ function failureErrorEvidence(error) {
84
+ if (error === undefined || error === null)
85
+ return undefined;
86
+ const { name, message } = normalizeError(error);
87
+ return { name, message };
88
+ }
89
+ /** A string `code` the rejecting port reported, when it reported one. */
90
+ function failureErrorCode(error) {
91
+ if (typeof error !== 'object' || error === null)
92
+ return undefined;
93
+ try {
94
+ const code = error.code;
95
+ return typeof code === 'string' && code.length > 0 ? code : undefined;
96
+ }
97
+ catch {
98
+ return undefined;
99
+ }
100
+ }
101
+ function playerFailureCause(input) {
102
+ return assertPlaybookFailureCause({
103
+ code: 'player-failed',
104
+ evidence: {
105
+ roleId: input.roleId,
106
+ ...(input.playerId === undefined ? {} : { playerId: input.playerId }),
107
+ error: failureErrorEvidence(input.error) ?? {
108
+ name: 'Error',
109
+ message: 'Unknown error',
110
+ },
111
+ ...(failureErrorCode(input.error) === undefined
112
+ ? {}
113
+ : { errorCode: failureErrorCode(input.error) }),
114
+ },
115
+ });
116
+ }
117
+ // ---------------------------------------------------------------------------
49
118
  // DR-028: both call boundaries treat an `ok` result whose `finalText` is
50
119
  // missing, empty, or whitespace-only under one empty predicate, and that
51
120
  // shape earns exactly one corrective re-ask — the same composed call
@@ -320,7 +389,13 @@ export function normalizeErrorCompact(err) {
320
389
  if (err === undefined || err === null)
321
390
  return undefined;
322
391
  const normalized = normalizeError(err);
323
- return { name: normalized.name, message: normalized.message };
392
+ return {
393
+ name: normalized.name,
394
+ message: normalized.message,
395
+ // DR-063 §2: on a failure the cause is why the compact form exists, so it
396
+ // survives where the stack does not.
397
+ ...(normalized.cause === undefined ? {} : { cause: normalized.cause }),
398
+ };
324
399
  }
325
400
  export function normalizeErrorFull(err) {
326
401
  if (err === undefined || err === null)
@@ -417,6 +492,97 @@ function continuationBlocks(input, resuming = false) {
417
492
  export function composePlayerContinuation(input, body, resuming = false) {
418
493
  return [...continuationBlocks(input, resuming), body].join('\n\n');
419
494
  }
495
+ // DR-062 §4: the Coder is told which uncommitted changes were already in the
496
+ // worktree when its own call started. The block is composed from the call's
497
+ // own baseline observation, names paths only, and is bounded.
498
+ const PRE_EXISTING_CHANGE_GROUPS = [
499
+ 'staged',
500
+ 'modified',
501
+ 'deleted',
502
+ 'untracked',
503
+ 'renamed',
504
+ 'unmerged',
505
+ 'other',
506
+ ];
507
+ const PRE_EXISTING_CHANGE_PATH_LIMIT = 40;
508
+ /**
509
+ * The group one baseline projection entry belongs to. The shapes are the
510
+ * porcelain-v2 records the repository observer projects: an `untracked`,
511
+ * `rename-or-copy`, or `unmerged` kind, and otherwise an `ordinary` record
512
+ * whose two-character `xy` carries the index state first and the worktree
513
+ * state second.
514
+ */
515
+ function preExistingChangeGroup(entry) {
516
+ if (!isPlainObject(entry))
517
+ return 'other';
518
+ if (entry.kind === 'untracked')
519
+ return 'untracked';
520
+ if (entry.kind === 'rename-or-copy')
521
+ return 'renamed';
522
+ if (entry.kind === 'unmerged')
523
+ return 'unmerged';
524
+ const xy = entry.xy;
525
+ if (entry.kind !== 'ordinary' || typeof xy !== 'string' || xy.length !== 2) {
526
+ return 'other';
527
+ }
528
+ if (xy[1] === 'D')
529
+ return 'deleted';
530
+ if (xy[1] === '.')
531
+ return xy[0] === '.' ? 'other' : 'staged';
532
+ return 'modified';
533
+ }
534
+ /**
535
+ * The pre-existing-changes block appended to an effect-authorized player
536
+ * prompt, or `undefined` for an empty baseline projection.
537
+ */
538
+ function preExistingChangesBlock(baseline) {
539
+ const projection = baseline?.projection;
540
+ if (!isPlainObject(projection))
541
+ return undefined;
542
+ const grouped = new Map();
543
+ let total = 0;
544
+ for (const path of Object.keys(projection)) {
545
+ const group = preExistingChangeGroup(projection[path]);
546
+ const paths = grouped.get(group);
547
+ if (paths === undefined)
548
+ grouped.set(group, [path]);
549
+ else
550
+ paths.push(path);
551
+ total += 1;
552
+ }
553
+ if (total === 0)
554
+ return undefined;
555
+ const omitted = Math.max(0, total - PRE_EXISTING_CHANGE_PATH_LIMIT);
556
+ const lines = [];
557
+ let budget = PRE_EXISTING_CHANGE_PATH_LIMIT;
558
+ for (const group of PRE_EXISTING_CHANGE_GROUPS) {
559
+ const paths = grouped.get(group);
560
+ if (paths === undefined || budget <= 0)
561
+ continue;
562
+ const printed = paths.slice().sort().slice(0, budget);
563
+ budget -= printed.length;
564
+ lines.push(`> - ${group}: ${printed.join(', ')}`);
565
+ }
566
+ if (omitted > 0 && lines.length > 0) {
567
+ lines[lines.length - 1] += `, … and ${omitted} more`;
568
+ }
569
+ return [
570
+ '> Uncommitted changes present before this call, belonging to the Boss:',
571
+ ...lines,
572
+ "> 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.",
573
+ ].join('\n');
574
+ }
575
+ /**
576
+ * The block for one governed call, or `undefined` where the call is not
577
+ * effect-authorized — a call whose declared outcomes carry no
578
+ * `one-descendant-commit` disposition may commit nothing, so it is told
579
+ * nothing.
580
+ */
581
+ function effectAuthorizedPreExistingBlock(effectBoundary, baseline) {
582
+ return effectBoundary.dispositions.includes('one-descendant-commit')
583
+ ? preExistingChangesBlock(baseline)
584
+ : undefined;
585
+ }
420
586
  const PLACEHOLDER_PATTERN = /<(#|[A-Za-z_$][A-Za-z0-9_$-]*)>/g;
421
587
  function placeholderFieldName(token, fields) {
422
588
  const explicit = fields[token];
@@ -830,7 +996,8 @@ export function createPlayerBridge(spec, ports, getActiveSignal, boundary, onCon
830
996
  : false);
831
997
  }
832
998
  if (result.status !== 'ok') {
833
- throw new Error(result.error ?? `captainBridge: callPlayer status "${result.status}"`);
999
+ const failure = new Error(result.error ?? `captainBridge: callPlayer status "${result.status}"`);
1000
+ throw boundary?.markPlayerResultFailure?.(failure) ?? failure;
834
1001
  }
835
1002
  const finalText = result.finalText ?? '';
836
1003
  if (isEmptyFinalText(finalText)) {
@@ -2019,6 +2186,15 @@ export function createXStatePlaybookRuntime(machine, spec) {
2019
2186
  let deferInspectionEmissions = false;
2020
2187
  let deferredInspectionEmissions = [];
2021
2188
  let controlPlaneError;
2189
+ // DR-063 §2: the cause decided at the site the failure was decided, kept
2190
+ // for the failures whose Error the compiled artifact builds itself and the
2191
+ // runtime therefore cannot decorate. It is cleared at each Boss turn start
2192
+ // and at each successful player result, so it is never stale, and it is
2193
+ // only ever read as the cause of an error that carries none of its own.
2194
+ let lastFailureCause;
2195
+ const rememberFailureCause = (cause) => {
2196
+ lastFailureCause = cause;
2197
+ };
2022
2198
  // Previous root-machine state for the inspect-driven telemetry /
2023
2199
  // status emitter. undefined before the first inspect firing.
2024
2200
  let priorState;
@@ -2388,6 +2564,13 @@ export function createXStatePlaybookRuntime(machine, spec) {
2388
2564
  refreshRetainedEffectReconciliation(current);
2389
2565
  syncDeferredReconciliationOverlay();
2390
2566
  refreshUnresolvedSemanticReconciliation(current);
2567
+ return projectUnresolvedEffectEnvelopes(current);
2568
+ }
2569
+ /**
2570
+ * The unresolved envelopes of one already-refreshed ledger mirror. Pure:
2571
+ * `describe()` reads it through the control view without moving anything.
2572
+ */
2573
+ function projectUnresolvedEffectEnvelopes(current) {
2391
2574
  if (!hasUnresolvedReconciliation())
2392
2575
  return [];
2393
2576
  const boundaryIds = new Set(unresolvedSemanticBoundaryIds);
@@ -2919,16 +3102,44 @@ export function createXStatePlaybookRuntime(machine, spec) {
2919
3102
  },
2920
3103
  });
2921
3104
  }
2922
- function unresolvedGovernedSettlement(reason, error, signal = activeSignal) {
2923
- if (error !== undefined && signal?.aborted && Object.is(error, signal.reason)) {
2924
- return { status: 'unresolved', error };
3105
+ // DR-063 §1: the cause of an unresolved governed settlement. A reconciled
3106
+ // mismatch supplies its own receipt-read cause; the reasons this function
3107
+ // owns map to adjudication, abort, and runtime-defect codes.
3108
+ function governedSettlementCause(reason, error, aborted, supplied) {
3109
+ if (supplied !== undefined)
3110
+ return supplied;
3111
+ if (aborted || reason.includes('aborted'))
3112
+ return ABORTED_FAILURE_CAUSE;
3113
+ if (GOVERNED_JUDGE_FAILURE_REASONS.includes(reason)) {
3114
+ const transport = failureErrorEvidence(error);
3115
+ return assertPlaybookFailureCause({
3116
+ code: 'judge-failed',
3117
+ evidence: {
3118
+ reason,
3119
+ ...(transport === undefined ? {} : { error: transport }),
3120
+ },
3121
+ });
3122
+ }
3123
+ return runtimeDefectCause(reason);
3124
+ }
3125
+ function unresolvedGovernedSettlement(reason, error, signal = activeSignal, cause) {
3126
+ const aborted = error !== undefined && signal?.aborted === true
3127
+ ? Object.is(error, signal.reason)
3128
+ : false;
3129
+ const decided = governedSettlementCause(reason, error, aborted, cause);
3130
+ rememberFailureCause(decided);
3131
+ if (aborted) {
3132
+ return {
3133
+ status: 'unresolved',
3134
+ error: attachPlaybookFailureCause(error, decided),
3135
+ };
2925
3136
  }
2926
3137
  const failure = error instanceof Error
2927
3138
  ? error
2928
3139
  : new Error(`${label} governed outcome remains unresolved: ${reason}`);
2929
3140
  return {
2930
3141
  status: 'unresolved',
2931
- error: markFsmResultFailure(failure),
3142
+ error: attachPlaybookFailureCause(markFsmResultFailure(failure), decided),
2932
3143
  };
2933
3144
  }
2934
3145
  async function spendSemanticCorrectionBudget(completedBoundary, receipt, finalText, semanticCandidate) {
@@ -3085,7 +3296,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
3085
3296
  }
3086
3297
  const semanticCandidate = snapshotJsonValue(reconciliation.evidence.semanticCandidate, `${label} governed semantic candidate`);
3087
3298
  if (reconciliation.status === 'unresolved') {
3088
- governedSettlementsByBoundaryId.set(completion.boundary.boundaryId, unresolvedGovernedSettlement(reconciliation.reason));
3299
+ governedSettlementsByBoundaryId.set(completion.boundary.boundaryId, unresolvedGovernedSettlement(reconciliation.reason, undefined, undefined, reconciliation.cause));
3089
3300
  return { finalText, semanticCandidate, unresolved: true };
3090
3301
  }
3091
3302
  const output = reconciliation.output;
@@ -3462,9 +3673,16 @@ export function createXStatePlaybookRuntime(machine, spec) {
3462
3673
  }
3463
3674
  activePlayerKeys.add(playerKey);
3464
3675
  try {
3465
- const runTracedPlayerCall = async (resume = selectedResume) => {
3676
+ const runTracedPlayerCall = async (resume = selectedResume,
3677
+ // DR-062 §4: composed from this call's own baseline observation,
3678
+ // so the prompt the trace records is the prompt the player is sent.
3679
+ preExistingBlock) => {
3466
3680
  const identity = callIdentity(resume);
3467
- const callPrompt = resume === false ? freshPrompt : prompt;
3681
+ const withPreExisting = (text) => preExistingBlock === undefined
3682
+ ? text
3683
+ : `${text}\n\n${preExistingBlock}`;
3684
+ const callPrompt = withPreExisting(resume === false ? freshPrompt : prompt);
3685
+ const callFreshPrompt = withPreExisting(freshPrompt);
3468
3686
  await emitCallStarted('player.call.started', 'player.call.finished', { ...identity, prompt: callPrompt }, position, signal);
3469
3687
  let rawResult;
3470
3688
  try {
@@ -3473,7 +3691,12 @@ export function createXStatePlaybookRuntime(machine, spec) {
3473
3691
  // never start after abort, so settle the already-started pair
3474
3692
  // as `aborted` through the catch below.
3475
3693
  signal.throwIfAborted();
3476
- rawResult = await requireHostPorts().callPlayer(roleId, callPrompt, signal, { resume, ...(callPrompt === freshPrompt ? {} : { freshPrompt }) });
3694
+ rawResult = await requireHostPorts().callPlayer(roleId, callPrompt, signal, {
3695
+ resume,
3696
+ ...(callPrompt === callFreshPrompt
3697
+ ? {}
3698
+ : { freshPrompt: callFreshPrompt }),
3699
+ });
3477
3700
  // A host promise is not required to honor cancellation. Do not
3478
3701
  // let a late result mutate continuity or publish a successful
3479
3702
  // finish.
@@ -3494,7 +3717,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
3494
3717
  }
3495
3718
  // A thrown port call carries no authoritative result, so the
3496
3719
  // prior token remains available for a later explicit resume.
3497
- throw error;
3720
+ throw decidePlayerCallFailure(error, roleId, playerId, signal);
3498
3721
  }
3499
3722
  let result;
3500
3723
  try {
@@ -3509,7 +3732,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
3509
3732
  catch {
3510
3733
  // The malformed host result remains authoritative.
3511
3734
  }
3512
- throw error;
3735
+ throw decidePlayerCallFailure(error, roleId, playerId, signal);
3513
3736
  }
3514
3737
  try {
3515
3738
  updatePlayerResume(roleId, playerId, result);
@@ -3538,6 +3761,24 @@ export function createXStatePlaybookRuntime(machine, spec) {
3538
3761
  ? { resumeToken: result.resumeToken }
3539
3762
  : {}),
3540
3763
  }, position);
3764
+ // DR-063 §2: the player bridge builds the failure Error for a
3765
+ // non-`ok` result, so the cause is decided here, where the role,
3766
+ // the player, and the reported error are known, and a successful
3767
+ // result clears it so no later failure inherits it.
3768
+ if (result.status === 'ok') {
3769
+ lastFailureCause = undefined;
3770
+ }
3771
+ else if (result.status === 'aborted') {
3772
+ rememberFailureCause(ABORTED_FAILURE_CAUSE);
3773
+ }
3774
+ else {
3775
+ rememberFailureCause(playerFailureCause({
3776
+ roleId,
3777
+ ...(playerId === undefined ? {} : { playerId }),
3778
+ error: result.error ??
3779
+ `callPlayer status ${JSON.stringify(result.status)}`,
3780
+ }));
3781
+ }
3541
3782
  return result;
3542
3783
  };
3543
3784
  const effectBoundary = governedBoundarySeed(input, roleId, callId, turnId);
@@ -3560,7 +3801,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
3560
3801
  deferredContinuation.playerId = playerId;
3561
3802
  deferredContinuation.signal = signal;
3562
3803
  try {
3563
- deferredContinuation.result = await runTracedPlayerCall(selectedResume);
3804
+ deferredContinuation.result = await runTracedPlayerCall(selectedResume, effectAuthorizedPreExistingBlock(effectBoundary, deferredContinuation.baseline));
3564
3805
  }
3565
3806
  catch (error) {
3566
3807
  deferredContinuation.callError = error;
@@ -3582,7 +3823,11 @@ export function createXStatePlaybookRuntime(machine, spec) {
3582
3823
  const exclusive = await repositoryCapability.runExclusive({
3583
3824
  signal,
3584
3825
  effectBoundary,
3585
- operation: () => runTracedPlayerCall(),
3826
+ // DR-062 §4: the call's own baseline, supplied by the host that
3827
+ // captured it under this exclusive claim. A host that supplies
3828
+ // none states no pre-existing change, exactly as an empty
3829
+ // baseline projection does.
3830
+ operation: (context) => runTracedPlayerCall(selectedResume, effectAuthorizedPreExistingBlock(effectBoundary, context?.baseline)),
3586
3831
  completeEffectBoundary: completionEvidenceFor(input, roleId, playerId, signal, undefined),
3587
3832
  });
3588
3833
  return acknowledgeGovernedPlayerResult(exclusive, effectBoundary.boundaryId);
@@ -3604,6 +3849,11 @@ export function createXStatePlaybookRuntime(machine, spec) {
3604
3849
  activePlayerKeys.delete(playerKey);
3605
3850
  }
3606
3851
  },
3852
+ markPlayerResultFailure(error) {
3853
+ return lastFailureCause === undefined
3854
+ ? error
3855
+ : attachPlaybookFailureCause(error, lastFailureCause);
3856
+ },
3607
3857
  takeGovernedPlayerOutput(result) {
3608
3858
  const settlement = governedPlayerSettlements.get(result);
3609
3859
  if (settlement !== undefined)
@@ -4118,6 +4368,61 @@ export function createXStatePlaybookRuntime(machine, spec) {
4118
4368
  function tracePositionForActiveTurn() {
4119
4369
  return activeTurnId === undefined ? {} : { turnId: activeTurnId };
4120
4370
  }
4371
+ // DR-063 §2: the parked failure's error as every public surface publishes
4372
+ // it — the FSM's own `lastError`, completed with the cause decided where
4373
+ // the failure was decided. An error that already carries a valid cause
4374
+ // keeps it; an artifact-built error takes the runtime's own slot; anything
4375
+ // else is a runtime defect carrying its message. There is no failure
4376
+ // without a cause.
4377
+ function failedStateError(context) {
4378
+ const normalized = normalizeErrorFull(context.lastError);
4379
+ if (normalized === undefined || normalized.cause !== undefined) {
4380
+ return normalized;
4381
+ }
4382
+ return {
4383
+ ...normalized,
4384
+ cause: lastFailureCause ?? runtimeDefectCause(normalized.message),
4385
+ };
4386
+ }
4387
+ // DR-063 §2: a rejected player port or a result the runtime cannot read is
4388
+ // a `player-failed` failure named by role; an abort is `aborted`.
4389
+ function decidePlayerCallFailure(error, roleId, playerId, signal) {
4390
+ const cause = isAbortFailure(error, signal)
4391
+ ? ABORTED_FAILURE_CAUSE
4392
+ : playerFailureCause({
4393
+ roleId,
4394
+ ...(playerId === undefined ? {} : { playerId }),
4395
+ error,
4396
+ });
4397
+ rememberFailureCause(cause);
4398
+ return attachPlaybookFailureCause(error, cause);
4399
+ }
4400
+ function completeFailedStatuses(statuses, state, context) {
4401
+ if (state.stateId !== 'failed')
4402
+ return statuses;
4403
+ const lastError = failedStateError(context);
4404
+ if (lastError?.cause === undefined)
4405
+ return statuses;
4406
+ const cause = lastError.cause;
4407
+ return statuses.map((status) => {
4408
+ const data = status.data;
4409
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) {
4410
+ return status;
4411
+ }
4412
+ const recorded = data;
4413
+ const recordedError = recorded.lastError;
4414
+ if (recordedError === undefined ||
4415
+ recordedError === null ||
4416
+ typeof recordedError !== 'object' ||
4417
+ Array.isArray(recordedError)) {
4418
+ return status;
4419
+ }
4420
+ return {
4421
+ ...status,
4422
+ data: snapshotJsonValue({ ...recorded, lastError: { ...recordedError, cause } }, 'failed status data'),
4423
+ };
4424
+ });
4425
+ }
4121
4426
  function structuredStateTelemetryPayload(previousState, state, event, context) {
4122
4427
  const payload = {
4123
4428
  from: previousState?.value ?? null,
@@ -4132,7 +4437,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
4132
4437
  payload.pendingBossQuestion = pendingBossQuestion;
4133
4438
  }
4134
4439
  if (state.stateId === 'failed') {
4135
- const lastError = normalizeErrorFull(context.lastError);
4440
+ const lastError = failedStateError(context);
4136
4441
  if (lastError !== undefined)
4137
4442
  payload.lastError = lastError;
4138
4443
  }
@@ -4302,7 +4607,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
4302
4607
  validateBoundQuestionProjection();
4303
4608
  }
4304
4609
  const payload = structuredStateTelemetryPayload(previousState, state, inspectionEvent.event, context);
4305
- const stateStatuses = statusesForState(state, context, inspectionEvent.event);
4610
+ const stateStatuses = completeFailedStatuses(statusesForState(state, context, inspectionEvent.event), state, context);
4306
4611
  const outcomeStatuses = usesDefaultStatuses
4307
4612
  ? acceptedOutcomes.map(({ acceptedOutcome }) => ({
4308
4613
  message: `→ ${acceptedOutcome}`,
@@ -4382,10 +4687,17 @@ export function createXStatePlaybookRuntime(machine, spec) {
4382
4687
  : outcome === 'aborted'
4383
4688
  ? activeSignal?.reason
4384
4689
  : undefined);
4690
+ if (failure === undefined)
4691
+ return { outcome, state };
4692
+ // DR-063 §2: a `failed` settlement publishes the same cause-completed
4693
+ // error the failed state's status line and telemetry publish.
4694
+ const normalized = outcome === 'failed'
4695
+ ? failedStateError({ lastError: failure })
4696
+ : normalizeError(failure);
4385
4697
  return {
4386
4698
  outcome,
4387
4699
  state,
4388
- ...(failure !== undefined ? { error: normalizeError(failure) } : {}),
4700
+ ...(normalized === undefined ? {} : { error: normalized }),
4389
4701
  };
4390
4702
  }
4391
4703
  function settledOutcome(signal) {
@@ -4594,10 +4906,36 @@ export function createXStatePlaybookRuntime(machine, spec) {
4594
4906
  action: {
4595
4907
  id: `retry:${retryEvent.type}`,
4596
4908
  label: `Retry: ${description}`,
4909
+ // DR-063 §3: retrying a failed call is `ready` — the runtime cannot
4910
+ // see whether the Boss changed the outside world since it failed.
4911
+ standing: 'ready',
4597
4912
  },
4598
4913
  event: retryEvent,
4599
4914
  };
4600
4915
  }
4916
+ /**
4917
+ * DR-063 §3: reconciliation only re-reads the host's ledger, which the
4918
+ * control view has just done. Where no checkpoint restoration is eligible
4919
+ * and every unresolved envelope's boundary already holds a complete
4920
+ * receipt, re-reading can resolve nothing, so the action is a no-op.
4921
+ */
4922
+ function reconciliationStanding(deferredRestoreOperationId) {
4923
+ if (deferredRestoreOperationId !== undefined)
4924
+ return { standing: 'ready' };
4925
+ const current = effectLedgerMirror;
4926
+ const envelopes = projectUnresolvedEffectEnvelopes(current);
4927
+ if (envelopes.length === 0)
4928
+ return { standing: 'ready' };
4929
+ const boundaryIds = envelopes.flatMap((envelope) => envelope.kind === 'boundary'
4930
+ ? [envelope.boundaryId]
4931
+ : (current.logicalOperations.find(({ operationId }) => operationId === envelope.operationId)?.boundaryIds ?? []));
4932
+ if (boundaryIds.length === 0)
4933
+ return { standing: 'ready' };
4934
+ const complete = boundaryIds.every((boundaryId) => current.boundaries.find((candidate) => candidate.boundaryId === boundaryId)?.physicalReceipt !== undefined);
4935
+ return complete
4936
+ ? { standing: 'no-op', reason: 'receipt-complete' }
4937
+ : { standing: 'ready' };
4938
+ }
4601
4939
  function deriveControlActions(snapshot) {
4602
4940
  // Actions derive only at the safe point the parked snapshot also
4603
4941
  // uses — quiescent actor with status `active` and no pending nested
@@ -4629,6 +4967,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
4629
4967
  action: {
4630
4968
  id: UNRESOLVED_EFFECT_RECONCILIATION_ACTION_ID,
4631
4969
  label: 'Retry unresolved effect reconciliation',
4970
+ ...reconciliationStanding(deferredRestoreOperationId),
4632
4971
  },
4633
4972
  unresolvedEffectAction: 'reconcile',
4634
4973
  ...(deferredRestoreOperationId === undefined
@@ -4638,6 +4977,8 @@ export function createXStatePlaybookRuntime(machine, spec) {
4638
4977
  action: {
4639
4978
  id: UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID,
4640
4979
  label: 'Abandon unresolved workflow attempt',
4980
+ // DR-063 §3: abandonment always changes the session's standing.
4981
+ standing: 'ready',
4641
4982
  },
4642
4983
  unresolvedEffectAction: 'abandon',
4643
4984
  });
@@ -4664,6 +5005,8 @@ export function createXStatePlaybookRuntime(machine, spec) {
4664
5005
  action: {
4665
5006
  id: `jump:${targetId}`,
4666
5007
  label: `Resume from: ${description}`,
5008
+ // DR-063 §3: a jump always moves the machine.
5009
+ standing: 'ready',
4667
5010
  },
4668
5011
  event,
4669
5012
  });
@@ -5050,8 +5393,12 @@ export function createXStatePlaybookRuntime(machine, spec) {
5050
5393
  signal,
5051
5394
  operationId: operation.operationId,
5052
5395
  effectBoundary,
5053
- operation: async ({ playerContinuation }) => {
5396
+ operation: async (context) => {
5397
+ const { playerContinuation } = context ?? {};
5054
5398
  validateBinding(playerContinuation);
5399
+ // DR-062 §4: the continuation's own baseline, captured before the
5400
+ // FSM re-enters the bound player state that reads it.
5401
+ continuation.baseline = context?.baseline;
5055
5402
  continuation.playerContinuation = selectPlayerResume(effectBoundary.roleId, boundPlayerId);
5056
5403
  continuationStarted = true;
5057
5404
  actor.send(event);
@@ -5440,7 +5787,9 @@ export function createXStatePlaybookRuntime(machine, spec) {
5440
5787
  const pending = !hasUnresolvedReconciliation()
5441
5788
  ? pendingBossQuestionForState(state, context)
5442
5789
  : undefined;
5443
- const lastError = normalizeErrorFull(context.lastError);
5790
+ const lastError = state.stateId === 'failed'
5791
+ ? failedStateError(context)
5792
+ : normalizeErrorFull(context.lastError);
5444
5793
  const projectedContext = projectControlContext(context);
5445
5794
  const stateDescription = !hasUnresolvedReconciliation()
5446
5795
  ? stateDescriptionFor(state)
@@ -5779,6 +6128,9 @@ export function createXStatePlaybookRuntime(machine, spec) {
5779
6128
  activeAborts = abortReasonClassifier(signal);
5780
6129
  activeAbortEmission = undefined;
5781
6130
  controlPlaneError = undefined;
6131
+ // DR-063 §2: each turn decides its own failures, so the slot never
6132
+ // carries a previous turn's cause into this one.
6133
+ lastFailureCause = undefined;
5782
6134
  let result;
5783
6135
  let operationError;
5784
6136
  // The boundary sentinel releases on every exit: a settlement defect