@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
@@ -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);
@@ -3987,6 +4200,58 @@ export function createXStatePlaybookRuntime<
3987
4200
  }
3988
4201
  }
3989
4202
 
4203
+ function pendingHasExactUnchangedOrigin(
4204
+ pending: PlaybookPendingBossQuestionContext,
4205
+ ): boolean {
4206
+ // The outcome menu is not the selected outcome: a source-authored
4207
+ // unchanged question may share its state with needsBossReply: deferred.
4208
+ // An open operation nevertheless always retains its checkpoint and
4209
+ // cumulative receipt authority, even if a later boundary looks alike.
4210
+ if (
4211
+ runtimeLogicalOperations().some(
4212
+ (operation) => operation.logicalReceipt === undefined,
4213
+ )
4214
+ ) {
4215
+ return false;
4216
+ }
4217
+ const latest = effectLedgerMirror.boundaries
4218
+ .filter(runtimeBoundaryIsOwned)
4219
+ .at(-1);
4220
+ if (
4221
+ latest === undefined ||
4222
+ latest.sourceStateId !== pending.resumeStateId ||
4223
+ pending.asker.kind !== 'role' ||
4224
+ latest.roleId !== pending.asker.roleId ||
4225
+ latest.logicalOperationId !== undefined ||
4226
+ latest.physicalReceipt?.classification !== 'unchanged'
4227
+ ) {
4228
+ return false;
4229
+ }
4230
+ // Reuse the persisted authority/receipt check rather than infer an arm
4231
+ // from question prose or search older evidence for a convenient match.
4232
+ const persisted = persistedBoundaryReconciliation(
4233
+ latest,
4234
+ effectLedgerMirror,
4235
+ );
4236
+ if (
4237
+ persisted === undefined ||
4238
+ persisted.historicalDeferred ||
4239
+ persisted.reconciliation.status !== 'resolved'
4240
+ ) {
4241
+ return false;
4242
+ }
4243
+ const output = persisted.reconciliation.output;
4244
+ const declaration =
4245
+ outcomeAuthority.governedPlayerStates[latest.sourceStateId]?.[
4246
+ output.guard
4247
+ ];
4248
+ return (
4249
+ declaration?.repositoryDisposition === 'unchanged' &&
4250
+ typeof output.question === 'string' &&
4251
+ output.question === pending.question
4252
+ );
4253
+ }
4254
+
3990
4255
  function currentBoundDeferredOperation(
3991
4256
  pending: PlaybookPendingBossQuestionContext,
3992
4257
  ): PlaybookEffectLogicalOperation | undefined {
@@ -4600,13 +4865,47 @@ export function createXStatePlaybookRuntime<
4600
4865
  });
4601
4866
  }
4602
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
+
4603
4892
  function unresolvedGovernedSettlement(
4604
4893
  reason: string,
4605
4894
  error?: unknown,
4606
4895
  signal: AbortSignal | undefined = activeSignal,
4896
+ cause?: PlaybookFailureCause,
4607
4897
  ): GovernedPlayerSettlement {
4608
- if (error !== undefined && signal?.aborted && Object.is(error, signal.reason)) {
4609
- 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
+ };
4610
4909
  }
4611
4910
  const failure =
4612
4911
  error instanceof Error
@@ -4614,7 +4913,10 @@ export function createXStatePlaybookRuntime<
4614
4913
  : new Error(`${label} governed outcome remains unresolved: ${reason}`);
4615
4914
  return {
4616
4915
  status: 'unresolved',
4617
- error: markFsmResultFailure(failure),
4916
+ error: attachPlaybookFailureCause(
4917
+ markFsmResultFailure(failure),
4918
+ decided,
4919
+ ),
4618
4920
  };
4619
4921
  }
4620
4922
 
@@ -4862,7 +5164,12 @@ export function createXStatePlaybookRuntime<
4862
5164
  if (reconciliation.status === 'unresolved') {
4863
5165
  governedSettlementsByBoundaryId.set(
4864
5166
  completion.boundary.boundaryId,
4865
- unresolvedGovernedSettlement(reconciliation.reason),
5167
+ unresolvedGovernedSettlement(
5168
+ reconciliation.reason,
5169
+ undefined,
5170
+ undefined,
5171
+ reconciliation.cause,
5172
+ ),
4866
5173
  );
4867
5174
  return { finalText, semanticCandidate, unresolved: true };
4868
5175
  }
@@ -5410,9 +5717,19 @@ export function createXStatePlaybookRuntime<
5410
5717
  try {
5411
5718
  const runTracedPlayerCall = async (
5412
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,
5413
5723
  ): Promise<PlayerResult> => {
5414
5724
  const identity = callIdentity(resume);
5415
- 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);
5416
5733
  await emitCallStarted(
5417
5734
  'player.call.started',
5418
5735
  'player.call.finished',
@@ -5432,7 +5749,12 @@ export function createXStatePlaybookRuntime<
5432
5749
  roleId,
5433
5750
  callPrompt,
5434
5751
  signal,
5435
- { resume, ...(callPrompt === freshPrompt ? {} : { freshPrompt }) },
5752
+ {
5753
+ resume,
5754
+ ...(callPrompt === callFreshPrompt
5755
+ ? {}
5756
+ : { freshPrompt: callFreshPrompt }),
5757
+ },
5436
5758
  );
5437
5759
  // A host promise is not required to honor cancellation. Do not
5438
5760
  // let a late result mutate continuity or publish a successful
@@ -5455,7 +5777,7 @@ export function createXStatePlaybookRuntime<
5455
5777
  }
5456
5778
  // A thrown port call carries no authoritative result, so the
5457
5779
  // prior token remains available for a later explicit resume.
5458
- throw error;
5780
+ throw decidePlayerCallFailure(error, roleId, playerId, signal);
5459
5781
  }
5460
5782
 
5461
5783
  let result: PlayerResult;
@@ -5472,7 +5794,7 @@ export function createXStatePlaybookRuntime<
5472
5794
  } catch {
5473
5795
  // The malformed host result remains authoritative.
5474
5796
  }
5475
- throw error;
5797
+ throw decidePlayerCallFailure(error, roleId, playerId, signal);
5476
5798
  }
5477
5799
 
5478
5800
  try {
@@ -5508,6 +5830,25 @@ export function createXStatePlaybookRuntime<
5508
5830
  },
5509
5831
  position,
5510
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
+ }
5511
5852
  return result;
5512
5853
  };
5513
5854
 
@@ -5548,6 +5889,10 @@ export function createXStatePlaybookRuntime<
5548
5889
  try {
5549
5890
  deferredContinuation.result = await runTracedPlayerCall(
5550
5891
  selectedResume,
5892
+ effectAuthorizedPreExistingBlock(
5893
+ effectBoundary,
5894
+ deferredContinuation.baseline,
5895
+ ),
5551
5896
  );
5552
5897
  } catch (error) {
5553
5898
  deferredContinuation.callError = error;
@@ -5569,7 +5914,18 @@ export function createXStatePlaybookRuntime<
5569
5914
  const exclusive = await repositoryCapability.runExclusive({
5570
5915
  signal,
5571
5916
  effectBoundary,
5572
- 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
+ ),
5573
5929
  completeEffectBoundary: completionEvidenceFor(
5574
5930
  input,
5575
5931
  roleId,
@@ -5600,6 +5956,12 @@ export function createXStatePlaybookRuntime<
5600
5956
  }
5601
5957
  },
5602
5958
 
5959
+ markPlayerResultFailure(error): Error {
5960
+ return lastFailureCause === undefined
5961
+ ? error
5962
+ : attachPlaybookFailureCause(error, lastFailureCause);
5963
+ },
5964
+
5603
5965
  takeGovernedPlayerOutput(result): GovernedPlayerSettlement | undefined {
5604
5966
  const settlement = governedPlayerSettlements.get(result);
5605
5967
  if (settlement !== undefined) governedPlayerSettlements.delete(result);
@@ -6240,6 +6602,80 @@ export function createXStatePlaybookRuntime<
6240
6602
  return activeTurnId === undefined ? {} : { turnId: activeTurnId };
6241
6603
  }
6242
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
+
6243
6679
  function structuredStateTelemetryPayload(
6244
6680
  previousState: PlaybookState | undefined,
6245
6681
  state: PlaybookState,
@@ -6261,7 +6697,7 @@ export function createXStatePlaybookRuntime<
6261
6697
  payload.pendingBossQuestion = pendingBossQuestion;
6262
6698
  }
6263
6699
  if (state.stateId === 'failed') {
6264
- const lastError = normalizeErrorFull(context.lastError);
6700
+ const lastError = failedStateError(context);
6265
6701
  if (lastError !== undefined) payload.lastError = lastError;
6266
6702
  }
6267
6703
  return snapshotJsonValue(payload, 'FSM telemetry payload');
@@ -6466,10 +6902,10 @@ export function createXStatePlaybookRuntime<
6466
6902
  inspectionEvent.event,
6467
6903
  context,
6468
6904
  );
6469
- const stateStatuses = statusesForState(
6905
+ const stateStatuses = completeFailedStatuses(
6906
+ statusesForState(state, context, inspectionEvent.event),
6470
6907
  state,
6471
6908
  context,
6472
- inspectionEvent.event,
6473
6909
  );
6474
6910
  const outcomeStatuses = usesDefaultStatuses
6475
6911
  ? acceptedOutcomes.map(({ acceptedOutcome }) => ({
@@ -6569,10 +7005,17 @@ export function createXStatePlaybookRuntime<
6569
7005
  : outcome === 'aborted'
6570
7006
  ? activeSignal?.reason
6571
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);
6572
7015
  return {
6573
7016
  outcome,
6574
7017
  state,
6575
- ...(failure !== undefined ? { error: normalizeError(failure) } : {}),
7018
+ ...(normalized === undefined ? {} : { error: normalized }),
6576
7019
  };
6577
7020
  }
6578
7021
 
@@ -6801,11 +7244,46 @@ export function createXStatePlaybookRuntime<
6801
7244
  action: {
6802
7245
  id: `retry:${retryEvent.type}`,
6803
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',
6804
7250
  },
6805
7251
  event: retryEvent,
6806
7252
  };
6807
7253
  }
6808
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
+
6809
7287
  function deriveControlActions(snapshot: unknown): DerivedControlAction[] {
6810
7288
  // Actions derive only at the safe point the parked snapshot also
6811
7289
  // uses — quiescent actor with status `active` and no pending nested
@@ -6844,6 +7322,7 @@ export function createXStatePlaybookRuntime<
6844
7322
  action: {
6845
7323
  id: UNRESOLVED_EFFECT_RECONCILIATION_ACTION_ID,
6846
7324
  label: 'Retry unresolved effect reconciliation',
7325
+ ...reconciliationStanding(deferredRestoreOperationId),
6847
7326
  },
6848
7327
  unresolvedEffectAction: 'reconcile',
6849
7328
  ...(deferredRestoreOperationId === undefined
@@ -6854,6 +7333,8 @@ export function createXStatePlaybookRuntime<
6854
7333
  action: {
6855
7334
  id: UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID,
6856
7335
  label: 'Abandon unresolved workflow attempt',
7336
+ // DR-063 §3: abandonment always changes the session's standing.
7337
+ standing: 'ready',
6857
7338
  },
6858
7339
  unresolvedEffectAction: 'abandon',
6859
7340
  },
@@ -6878,6 +7359,8 @@ export function createXStatePlaybookRuntime<
6878
7359
  action: {
6879
7360
  id: `jump:${targetId}`,
6880
7361
  label: `Resume from: ${description}`,
7362
+ // DR-063 §3: a jump always moves the machine.
7363
+ standing: 'ready',
6881
7364
  },
6882
7365
  event,
6883
7366
  });
@@ -7369,8 +7852,12 @@ export function createXStatePlaybookRuntime<
7369
7852
  signal,
7370
7853
  operationId: operation.operationId,
7371
7854
  effectBoundary,
7372
- operation: async ({ playerContinuation }) => {
7855
+ operation: async (context) => {
7856
+ const { playerContinuation } = context ?? {};
7373
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;
7374
7861
  continuation.playerContinuation = selectPlayerResume(
7375
7862
  effectBoundary.roleId, boundPlayerId,
7376
7863
  );
@@ -7874,7 +8361,10 @@ export function createXStatePlaybookRuntime<
7874
8361
  !hasUnresolvedReconciliation()
7875
8362
  ? pendingBossQuestionForState(state, context)
7876
8363
  : undefined;
7877
- const lastError = normalizeErrorFull(context.lastError);
8364
+ const lastError =
8365
+ state.stateId === 'failed'
8366
+ ? failedStateError(context)
8367
+ : normalizeErrorFull(context.lastError);
7878
8368
  const projectedContext = projectControlContext(context);
7879
8369
  const stateDescription =
7880
8370
  !hasUnresolvedReconciliation()
@@ -8273,6 +8763,9 @@ export function createXStatePlaybookRuntime<
8273
8763
  activeAborts = abortReasonClassifier(signal);
8274
8764
  activeAbortEmission = undefined;
8275
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;
8276
8769
  let result: PlaybookRunResult | undefined;
8277
8770
  let operationError: unknown;
8278
8771
  // The boundary sentinel releases on every exit: a settlement defect
@@ -8313,7 +8806,8 @@ export function createXStatePlaybookRuntime<
8313
8806
  deferredPending !== undefined &&
8314
8807
  outcomeAuthority?.governedPlayerStates[
8315
8808
  deferredPending.resumeStateId
8316
- ]?.needsBossReply?.repositoryDisposition === 'deferred';
8809
+ ]?.needsBossReply?.repositoryDisposition === 'deferred' &&
8810
+ !pendingHasExactUnchangedOrigin(deferredPending);
8317
8811
  deferredOperation =
8318
8812
  !pendingRequiresDeferredBinding
8319
8813
  ? undefined