@sublang/playbook 13.3.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 (56) hide show
  1. package/README.md +5 -5
  2. package/docs/cli.md +14 -14
  3. package/docs/configuration.md +10 -2
  4. package/docs/embedding.md +20 -1
  5. package/package.json +6 -1
  6. package/reference/sdlc/branch.playbook/branch.fsm.d.ts +1 -1
  7. package/reference/sdlc/code.playbook/bin/launch-config.js +88 -5
  8. package/reference/sdlc/code.playbook/bin/playbook.js +4 -0
  9. package/reference/sdlc/code.playbook/bin/repository-effects.js +198 -17
  10. package/reference/sdlc/code.playbook/bin/run.js +4 -0
  11. package/reference/sdlc/code.playbook/bin/session-store.js +2 -2
  12. package/reference/sdlc/code.playbook/code.fsm.d.ts +1 -1
  13. package/reference/sdlc/code.playbook/host-capabilities.d.ts +6 -0
  14. package/reference/sdlc/code.playbook/playbook-captain.d.ts +3 -1
  15. package/reference/sdlc/code.playbook/playbook-captain.js +374 -20
  16. package/reference/sdlc/code.playbook/playbook-captain.ts +478 -38
  17. package/reference/sdlc/code.playbook/session-host.d.ts +6 -3
  18. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +1 -1
  19. package/reference/sdlc/decide.playbook/decide.playbook.js +25 -1
  20. package/reference/sdlc/decide.playbook/decide.playbook.ts +35 -2
  21. package/reference/sdlc/dev.md +15 -8
  22. package/reference/sdlc/dev.playbook/dev.fsm.d.ts +1 -1
  23. package/reference/sdlc/dev.playbook/dev.fsm.js +18 -12
  24. package/reference/sdlc/dev.playbook/dev.fsm.ts +18 -13
  25. package/reference/sdlc/dev.playbook/dev.gears.md +19 -12
  26. package/reference/sdlc/dev.playbook/dev.playbook.d.ts +3 -10
  27. package/reference/sdlc/dev.playbook/dev.playbook.js +6 -6
  28. package/reference/sdlc/dev.playbook/dev.playbook.ts +8 -9
  29. package/reference/sdlc/dev.playbook/dev.registry.js +1 -1
  30. package/reference/sdlc/dev.playbook/dev.registry.ts +1 -1
  31. package/reference/sdlc/pr.md +17 -4
  32. package/reference/sdlc/pr.playbook/pr.fsm.d.ts +18 -4
  33. package/reference/sdlc/pr.playbook/pr.fsm.js +49 -18
  34. package/reference/sdlc/pr.playbook/pr.fsm.ts +63 -20
  35. package/reference/sdlc/pr.playbook/pr.gears.md +20 -7
  36. package/reference/sdlc/pr.playbook/pr.playbook.js +2 -1
  37. package/reference/sdlc/pr.playbook/pr.playbook.ts +2 -1
  38. package/reference/sdlc/review.playbook/review.fsm.d.ts +1 -1
  39. package/slc/experiments/fsm-scaffold-guidance.md +31 -0
  40. package/slc/gears2fsm.md +145 -58
  41. package/slc/link.md +190 -7
  42. package/slc/materialize-link.mjs +460 -0
  43. package/slc/optimize.md +46 -6
  44. package/slc/scaffold-fsm.mjs +225 -0
  45. package/slc/slc.pin-inputs.json +13 -0
  46. package/slc/text2gears.md +38 -3
  47. package/slc/workflow-contracts.json +371 -0
  48. package/src/runtime.d.ts +70 -0
  49. package/src/runtime.js +232 -1
  50. package/src/runtime.ts +358 -1
  51. package/src/xstate-playbook-runtime.d.ts +9 -10
  52. package/src/xstate-playbook-runtime.js +407 -21
  53. package/src/xstate-playbook-runtime.ts +514 -20
  54. package/src/xstate-runtime.d.ts +9 -1
  55. package/src/xstate-runtime.js +299 -19
  56. 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);
@@ -2455,6 +2638,39 @@ export function createXStatePlaybookRuntime(machine, spec) {
2455
2638
  throw new Error(`createPlaybookRuntime.${method}: deferred settlement recovery is required`, { cause: deferredSettlementClosure });
2456
2639
  }
2457
2640
  }
2641
+ function pendingHasExactUnchangedOrigin(pending) {
2642
+ // The outcome menu is not the selected outcome: a source-authored
2643
+ // unchanged question may share its state with needsBossReply: deferred.
2644
+ // An open operation nevertheless always retains its checkpoint and
2645
+ // cumulative receipt authority, even if a later boundary looks alike.
2646
+ if (runtimeLogicalOperations().some((operation) => operation.logicalReceipt === undefined)) {
2647
+ return false;
2648
+ }
2649
+ const latest = effectLedgerMirror.boundaries
2650
+ .filter(runtimeBoundaryIsOwned)
2651
+ .at(-1);
2652
+ if (latest === undefined ||
2653
+ latest.sourceStateId !== pending.resumeStateId ||
2654
+ pending.asker.kind !== 'role' ||
2655
+ latest.roleId !== pending.asker.roleId ||
2656
+ latest.logicalOperationId !== undefined ||
2657
+ latest.physicalReceipt?.classification !== 'unchanged') {
2658
+ return false;
2659
+ }
2660
+ // Reuse the persisted authority/receipt check rather than infer an arm
2661
+ // from question prose or search older evidence for a convenient match.
2662
+ const persisted = persistedBoundaryReconciliation(latest, effectLedgerMirror);
2663
+ if (persisted === undefined ||
2664
+ persisted.historicalDeferred ||
2665
+ persisted.reconciliation.status !== 'resolved') {
2666
+ return false;
2667
+ }
2668
+ const output = persisted.reconciliation.output;
2669
+ const declaration = outcomeAuthority.governedPlayerStates[latest.sourceStateId]?.[output.guard];
2670
+ return (declaration?.repositoryDisposition === 'unchanged' &&
2671
+ typeof output.question === 'string' &&
2672
+ output.question === pending.question);
2673
+ }
2458
2674
  function currentBoundDeferredOperation(pending) {
2459
2675
  const projected = {
2460
2676
  questionId: pending.questionId,
@@ -2886,16 +3102,44 @@ export function createXStatePlaybookRuntime(machine, spec) {
2886
3102
  },
2887
3103
  });
2888
3104
  }
2889
- function unresolvedGovernedSettlement(reason, error, signal = activeSignal) {
2890
- if (error !== undefined && signal?.aborted && Object.is(error, signal.reason)) {
2891
- 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
+ };
2892
3136
  }
2893
3137
  const failure = error instanceof Error
2894
3138
  ? error
2895
3139
  : new Error(`${label} governed outcome remains unresolved: ${reason}`);
2896
3140
  return {
2897
3141
  status: 'unresolved',
2898
- error: markFsmResultFailure(failure),
3142
+ error: attachPlaybookFailureCause(markFsmResultFailure(failure), decided),
2899
3143
  };
2900
3144
  }
2901
3145
  async function spendSemanticCorrectionBudget(completedBoundary, receipt, finalText, semanticCandidate) {
@@ -3052,7 +3296,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
3052
3296
  }
3053
3297
  const semanticCandidate = snapshotJsonValue(reconciliation.evidence.semanticCandidate, `${label} governed semantic candidate`);
3054
3298
  if (reconciliation.status === 'unresolved') {
3055
- governedSettlementsByBoundaryId.set(completion.boundary.boundaryId, unresolvedGovernedSettlement(reconciliation.reason));
3299
+ governedSettlementsByBoundaryId.set(completion.boundary.boundaryId, unresolvedGovernedSettlement(reconciliation.reason, undefined, undefined, reconciliation.cause));
3056
3300
  return { finalText, semanticCandidate, unresolved: true };
3057
3301
  }
3058
3302
  const output = reconciliation.output;
@@ -3429,9 +3673,16 @@ export function createXStatePlaybookRuntime(machine, spec) {
3429
3673
  }
3430
3674
  activePlayerKeys.add(playerKey);
3431
3675
  try {
3432
- 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) => {
3433
3680
  const identity = callIdentity(resume);
3434
- 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);
3435
3686
  await emitCallStarted('player.call.started', 'player.call.finished', { ...identity, prompt: callPrompt }, position, signal);
3436
3687
  let rawResult;
3437
3688
  try {
@@ -3440,7 +3691,12 @@ export function createXStatePlaybookRuntime(machine, spec) {
3440
3691
  // never start after abort, so settle the already-started pair
3441
3692
  // as `aborted` through the catch below.
3442
3693
  signal.throwIfAborted();
3443
- 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
+ });
3444
3700
  // A host promise is not required to honor cancellation. Do not
3445
3701
  // let a late result mutate continuity or publish a successful
3446
3702
  // finish.
@@ -3461,7 +3717,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
3461
3717
  }
3462
3718
  // A thrown port call carries no authoritative result, so the
3463
3719
  // prior token remains available for a later explicit resume.
3464
- throw error;
3720
+ throw decidePlayerCallFailure(error, roleId, playerId, signal);
3465
3721
  }
3466
3722
  let result;
3467
3723
  try {
@@ -3476,7 +3732,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
3476
3732
  catch {
3477
3733
  // The malformed host result remains authoritative.
3478
3734
  }
3479
- throw error;
3735
+ throw decidePlayerCallFailure(error, roleId, playerId, signal);
3480
3736
  }
3481
3737
  try {
3482
3738
  updatePlayerResume(roleId, playerId, result);
@@ -3505,6 +3761,24 @@ export function createXStatePlaybookRuntime(machine, spec) {
3505
3761
  ? { resumeToken: result.resumeToken }
3506
3762
  : {}),
3507
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
+ }
3508
3782
  return result;
3509
3783
  };
3510
3784
  const effectBoundary = governedBoundarySeed(input, roleId, callId, turnId);
@@ -3527,7 +3801,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
3527
3801
  deferredContinuation.playerId = playerId;
3528
3802
  deferredContinuation.signal = signal;
3529
3803
  try {
3530
- deferredContinuation.result = await runTracedPlayerCall(selectedResume);
3804
+ deferredContinuation.result = await runTracedPlayerCall(selectedResume, effectAuthorizedPreExistingBlock(effectBoundary, deferredContinuation.baseline));
3531
3805
  }
3532
3806
  catch (error) {
3533
3807
  deferredContinuation.callError = error;
@@ -3549,7 +3823,11 @@ export function createXStatePlaybookRuntime(machine, spec) {
3549
3823
  const exclusive = await repositoryCapability.runExclusive({
3550
3824
  signal,
3551
3825
  effectBoundary,
3552
- 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)),
3553
3831
  completeEffectBoundary: completionEvidenceFor(input, roleId, playerId, signal, undefined),
3554
3832
  });
3555
3833
  return acknowledgeGovernedPlayerResult(exclusive, effectBoundary.boundaryId);
@@ -3571,6 +3849,11 @@ export function createXStatePlaybookRuntime(machine, spec) {
3571
3849
  activePlayerKeys.delete(playerKey);
3572
3850
  }
3573
3851
  },
3852
+ markPlayerResultFailure(error) {
3853
+ return lastFailureCause === undefined
3854
+ ? error
3855
+ : attachPlaybookFailureCause(error, lastFailureCause);
3856
+ },
3574
3857
  takeGovernedPlayerOutput(result) {
3575
3858
  const settlement = governedPlayerSettlements.get(result);
3576
3859
  if (settlement !== undefined)
@@ -4085,6 +4368,61 @@ export function createXStatePlaybookRuntime(machine, spec) {
4085
4368
  function tracePositionForActiveTurn() {
4086
4369
  return activeTurnId === undefined ? {} : { turnId: activeTurnId };
4087
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
+ }
4088
4426
  function structuredStateTelemetryPayload(previousState, state, event, context) {
4089
4427
  const payload = {
4090
4428
  from: previousState?.value ?? null,
@@ -4099,7 +4437,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
4099
4437
  payload.pendingBossQuestion = pendingBossQuestion;
4100
4438
  }
4101
4439
  if (state.stateId === 'failed') {
4102
- const lastError = normalizeErrorFull(context.lastError);
4440
+ const lastError = failedStateError(context);
4103
4441
  if (lastError !== undefined)
4104
4442
  payload.lastError = lastError;
4105
4443
  }
@@ -4269,7 +4607,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
4269
4607
  validateBoundQuestionProjection();
4270
4608
  }
4271
4609
  const payload = structuredStateTelemetryPayload(previousState, state, inspectionEvent.event, context);
4272
- const stateStatuses = statusesForState(state, context, inspectionEvent.event);
4610
+ const stateStatuses = completeFailedStatuses(statusesForState(state, context, inspectionEvent.event), state, context);
4273
4611
  const outcomeStatuses = usesDefaultStatuses
4274
4612
  ? acceptedOutcomes.map(({ acceptedOutcome }) => ({
4275
4613
  message: `→ ${acceptedOutcome}`,
@@ -4349,10 +4687,17 @@ export function createXStatePlaybookRuntime(machine, spec) {
4349
4687
  : outcome === 'aborted'
4350
4688
  ? activeSignal?.reason
4351
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);
4352
4697
  return {
4353
4698
  outcome,
4354
4699
  state,
4355
- ...(failure !== undefined ? { error: normalizeError(failure) } : {}),
4700
+ ...(normalized === undefined ? {} : { error: normalized }),
4356
4701
  };
4357
4702
  }
4358
4703
  function settledOutcome(signal) {
@@ -4561,10 +4906,36 @@ export function createXStatePlaybookRuntime(machine, spec) {
4561
4906
  action: {
4562
4907
  id: `retry:${retryEvent.type}`,
4563
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',
4564
4912
  },
4565
4913
  event: retryEvent,
4566
4914
  };
4567
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
+ }
4568
4939
  function deriveControlActions(snapshot) {
4569
4940
  // Actions derive only at the safe point the parked snapshot also
4570
4941
  // uses — quiescent actor with status `active` and no pending nested
@@ -4596,6 +4967,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
4596
4967
  action: {
4597
4968
  id: UNRESOLVED_EFFECT_RECONCILIATION_ACTION_ID,
4598
4969
  label: 'Retry unresolved effect reconciliation',
4970
+ ...reconciliationStanding(deferredRestoreOperationId),
4599
4971
  },
4600
4972
  unresolvedEffectAction: 'reconcile',
4601
4973
  ...(deferredRestoreOperationId === undefined
@@ -4605,6 +4977,8 @@ export function createXStatePlaybookRuntime(machine, spec) {
4605
4977
  action: {
4606
4978
  id: UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID,
4607
4979
  label: 'Abandon unresolved workflow attempt',
4980
+ // DR-063 §3: abandonment always changes the session's standing.
4981
+ standing: 'ready',
4608
4982
  },
4609
4983
  unresolvedEffectAction: 'abandon',
4610
4984
  });
@@ -4631,6 +5005,8 @@ export function createXStatePlaybookRuntime(machine, spec) {
4631
5005
  action: {
4632
5006
  id: `jump:${targetId}`,
4633
5007
  label: `Resume from: ${description}`,
5008
+ // DR-063 §3: a jump always moves the machine.
5009
+ standing: 'ready',
4634
5010
  },
4635
5011
  event,
4636
5012
  });
@@ -5017,8 +5393,12 @@ export function createXStatePlaybookRuntime(machine, spec) {
5017
5393
  signal,
5018
5394
  operationId: operation.operationId,
5019
5395
  effectBoundary,
5020
- operation: async ({ playerContinuation }) => {
5396
+ operation: async (context) => {
5397
+ const { playerContinuation } = context ?? {};
5021
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;
5022
5402
  continuation.playerContinuation = selectPlayerResume(effectBoundary.roleId, boundPlayerId);
5023
5403
  continuationStarted = true;
5024
5404
  actor.send(event);
@@ -5407,7 +5787,9 @@ export function createXStatePlaybookRuntime(machine, spec) {
5407
5787
  const pending = !hasUnresolvedReconciliation()
5408
5788
  ? pendingBossQuestionForState(state, context)
5409
5789
  : undefined;
5410
- const lastError = normalizeErrorFull(context.lastError);
5790
+ const lastError = state.stateId === 'failed'
5791
+ ? failedStateError(context)
5792
+ : normalizeErrorFull(context.lastError);
5411
5793
  const projectedContext = projectControlContext(context);
5412
5794
  const stateDescription = !hasUnresolvedReconciliation()
5413
5795
  ? stateDescriptionFor(state)
@@ -5746,6 +6128,9 @@ export function createXStatePlaybookRuntime(machine, spec) {
5746
6128
  activeAborts = abortReasonClassifier(signal);
5747
6129
  activeAbortEmission = undefined;
5748
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;
5749
6134
  let result;
5750
6135
  let operationError;
5751
6136
  // The boundary sentinel releases on every exit: a settlement defect
@@ -5779,7 +6164,8 @@ export function createXStatePlaybookRuntime(machine, spec) {
5779
6164
  deferredPending = pendingBossQuestionForState(normalizePlaybookSnapshot(snapshot), snapshotContext);
5780
6165
  pendingRequiresDeferredBinding =
5781
6166
  deferredPending !== undefined &&
5782
- outcomeAuthority?.governedPlayerStates[deferredPending.resumeStateId]?.needsBossReply?.repositoryDisposition === 'deferred';
6167
+ outcomeAuthority?.governedPlayerStates[deferredPending.resumeStateId]?.needsBossReply?.repositoryDisposition === 'deferred' &&
6168
+ !pendingHasExactUnchangedOrigin(deferredPending);
5783
6169
  deferredOperation =
5784
6170
  !pendingRequiresDeferredBinding
5785
6171
  ? undefined