@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
@@ -1,6 +1,6 @@
1
1
  import { type Captain, type CaptainSession, type TuningSelection } from '@sublang/cligent/tmux-play';
2
2
  import type { Effort, PermissionPolicy } from '@sublang/cligent';
3
- import type { JsonValue, PlaybookEffectLedger, PlaybookEffectLedgerCommandBatch, PlaybookControlAction, PlaybookRuntime, PlaybookRuntimeSnapshot } from '@sublang/playbook/runtime';
3
+ import type { JsonValue, PlaybookEffectLedger, PlaybookEffectLedgerCommandBatch, PlaybookControlAction, PlaybookFailureCause, PlaybookRuntime, PlaybookRuntimeSnapshot } from '@sublang/playbook/runtime';
4
4
  import { type CaptainControllerPort } from '../captain.playbook/captain.playbook.js';
5
5
  import type { PlaybookSummaryPolicy } from './code.registry.js';
6
6
  interface SessionAgent {
@@ -176,9 +176,11 @@ type PlaybookCaptainShellSnapshotValue = PlaybookCaptainShellSnapshotFields & ({
176
176
  readonly frames: readonly PlaybookCaptainFrameSnapshot[];
177
177
  readonly retainedEffectReconciliation?: PlaybookCaptainRetainedEffectReconciliation;
178
178
  readonly pendingBossQuestions?: JsonValue;
179
+ /** DR-063 §2: the parked failure's cause travels with its error. */
179
180
  readonly lastError?: {
180
181
  readonly name: string;
181
182
  readonly message: string;
183
+ readonly cause?: PlaybookFailureCause;
182
184
  };
183
185
  });
184
186
  export type PlaybookCaptainShellSnapshot = DeepReadonly<PlaybookCaptainShellSnapshotValue>;
@@ -4,6 +4,7 @@ import { randomUUID } from 'node:crypto';
4
4
  import { isDeepStrictEqual } from 'node:util';
5
5
  import PQueue from 'p-queue';
6
6
  import { isAgentCallSettingsError, } from '@sublang/cligent/tmux-play';
7
+ import { assertPlaybookFailureCause } from '@sublang/playbook/runtime';
7
8
  import { assertPlaybookRuntimeSnapshot, assertPlaybookEffectLedger, emptyPlaybookEffectLedger, hiddenControlEnvelope, isPlaybookEffectLedgerMonotonicExtension, registerPlaybookAbortCleanup, snapshotJsonValue, validatePlayerResult, } from '../../../src/xstate-runtime.js';
8
9
  import createDefaultCaptainRuntime from '../captain.playbook/captain.playbook.js';
9
10
  function retainedEffectLedgerCanRebase(checkpoint, current) {
@@ -476,6 +477,160 @@ function unresolvedEffectBossReport(unresolvedEffects) {
476
477
  'This evidence does not establish workflow completion or attribute any repository change or commit to this workflow.',
477
478
  ].join('\n');
478
479
  }
480
+ // DR-062 §5: a settled turn whose accepted commit absorbed or altered the
481
+ // Boss's own uncommitted changes says so, bounded to paths.
482
+ const CARRIED_CHANGE_PATH_LIMIT = 24;
483
+ function boundedPathList(paths) {
484
+ const printed = paths.slice(0, CARRIED_CHANGE_PATH_LIMIT);
485
+ const omitted = paths.length - printed.length;
486
+ return omitted > 0
487
+ ? `${printed.join(', ')}, … and ${omitted} more`
488
+ : printed.join(', ');
489
+ }
490
+ /**
491
+ * The carried pre-existing changes of the boundaries completed after
492
+ * `boundaryPrefixCount`, one entry per commit OID in boundary sequence order.
493
+ */
494
+ function carriedPreExistingChanges(ledger, boundaryPrefixCount) {
495
+ const byCommit = new Map();
496
+ for (const boundary of ledger.boundaries) {
497
+ if (boundary.sequence <= boundaryPrefixCount)
498
+ continue;
499
+ const receipt = boundary.physicalReceipt;
500
+ if (receipt?.classification !== 'one-descendant-commit' ||
501
+ receipt.commitOid === undefined ||
502
+ receipt.preExisting === undefined) {
503
+ continue;
504
+ }
505
+ const { absorbed, altered } = receipt.preExisting;
506
+ if (absorbed.length === 0 && altered.length === 0)
507
+ continue;
508
+ const carried = byCommit.get(receipt.commitOid) ?? {
509
+ absorbed: [],
510
+ altered: [],
511
+ };
512
+ carried.absorbed.push(...absorbed);
513
+ carried.altered.push(...altered);
514
+ byCommit.set(receipt.commitOid, carried);
515
+ }
516
+ return [...byCommit].map(([commitOid, { absorbed, altered }]) => ({
517
+ commitOid,
518
+ absorbed: [...new Set(absorbed)].sort(),
519
+ altered: [...new Set(altered)].sort(),
520
+ }));
521
+ }
522
+ function carriedPreExistingReport(carried) {
523
+ if (carried.length === 0)
524
+ return undefined;
525
+ return carried
526
+ .map(({ commitOid, absorbed, altered }) => [
527
+ `Pre-existing changes carried by commit ${commitOid}:`,
528
+ ...(absorbed.length === 0
529
+ ? []
530
+ : [`- absorbed as found: ${boundedPathList(absorbed)}`]),
531
+ ...(altered.length === 0
532
+ ? []
533
+ : [`- altered before committing: ${boundedPathList(altered)}`]),
534
+ 'These changes were uncommitted before the step.',
535
+ ].join('\n'))
536
+ .join('\n\n');
537
+ }
538
+ function carriedPreExistingFacts(carried) {
539
+ return carried.map(({ commitOid, absorbed, altered }) => {
540
+ const paths = [...new Set([...absorbed, ...altered])].sort();
541
+ return `The commit ${commitOid} carries ${paths.length} changes that were uncommitted before the step: ${boundedPathList(paths)}.`;
542
+ });
543
+ }
544
+ // DR-063 §4: the Boss-visible failure report. Every host sees the same
545
+ // sentences because the shell composes them from the structured cause instead
546
+ // of asking a model to describe facts it may drop. The report exposes no file
547
+ // content, player prose, or internal identity: paths, revisions, dispositions,
548
+ // and the reported error message are what a cause carries.
549
+ /** The Boss-facing phrase for each closed control-action reason (DR-063 §3). */
550
+ const CONTROL_REASON_PHRASES = {
551
+ 'receipt-complete': 'nothing has changed since it failed',
552
+ };
553
+ function controlReasonPhrase(reason) {
554
+ if (reason === undefined)
555
+ return 'no reason was published';
556
+ return CONTROL_REASON_PHRASES[reason] ?? reason;
557
+ }
558
+ function causePathList(paths, truncated) {
559
+ const list = paths ?? [];
560
+ const printed = list.slice(0, CARRIED_CHANGE_PATH_LIMIT);
561
+ const omitted = list.length - printed.length + (truncated ?? 0);
562
+ return omitted > 0
563
+ ? `${printed.join(', ')}, … and ${omitted} more`
564
+ : printed.join(', ');
565
+ }
566
+ /** One sentence per code, with that code's bounded evidence (DR-063 §4). */
567
+ function failureCauseSentence(cause, describePlaybook) {
568
+ const evidence = cause.evidence;
569
+ const paths = evidence.paths;
570
+ const code = cause.code;
571
+ switch (code) {
572
+ case 'commit-missing':
573
+ return (paths?.uncommitted ?? []).length === 0
574
+ ? 'the step had to commit its work and committed nothing.'
575
+ : `the step had to commit its work and committed nothing; these changes are uncommitted: ${causePathList(paths?.uncommitted, paths?.truncated)}.`;
576
+ case 'commit-residual': {
577
+ const subject = evidence.commitOid === undefined
578
+ ? "the step's commit"
579
+ : `the commit ${evidence.commitOid}`;
580
+ if ((paths?.uncommitted ?? []).length > 0) {
581
+ return `${subject} left changes uncommitted: ${causePathList(paths?.uncommitted, paths?.truncated)}.`;
582
+ }
583
+ if ((paths?.altered ?? []).length > 0) {
584
+ return `${subject} left altered changes uncommitted: ${causePathList(paths?.altered, paths?.truncated)}.`;
585
+ }
586
+ return `${subject} left changes uncommitted beside it.`;
587
+ }
588
+ case 'pre-existing-lost':
589
+ return `uncommitted changes you had were lost: ${causePathList(paths?.lost, paths?.truncated)}.`;
590
+ case 'commits-more-than-one':
591
+ return `the step made more than one commit; HEAD moved from ${evidence.baselineHead} to ${evidence.afterHead}.`;
592
+ case 'history-rewritten':
593
+ return `the repository history was rewritten; HEAD moved from ${evidence.baselineHead} to ${evidence.afterHead}.`;
594
+ case 'foreign-change':
595
+ return `the repository changed outside this step: ${causePathList(paths?.changed, paths?.truncated)}.`;
596
+ case 'observation-unstable':
597
+ return `the repository could not be observed after the step; it stood at ${evidence.baselineHead} before it.`;
598
+ case 'attribution-ambiguous':
599
+ return `the repository change could not be attributed to this step; it required ${evidence.required} and observed ${evidence.observed}.`;
600
+ case 'receipt-missing':
601
+ return `a step left no complete repository receipt; the repository stood at ${evidence.baselineHead} before it.`;
602
+ case 'judge-failed':
603
+ return evidence.error === undefined
604
+ ? `the hidden adjudication failed: ${evidence.reason}.`
605
+ : `the hidden adjudication failed: ${evidence.reason} (${evidence.error.name}: ${compactEvidence(evidence.error.message)}).`;
606
+ case 'player-failed':
607
+ return `the ${evidence.roleId} call failed: ${compactEvidence(evidence.error?.message ?? 'no detail was reported')}.`;
608
+ case 'aborted':
609
+ return 'the turn was aborted before the work settled.';
610
+ case 'child-failed': {
611
+ const nested = describePlaybook(evidence.playbookId ?? 'nested playbook');
612
+ return evidence.cause === undefined
613
+ ? `the nested ${nested} run failed.`
614
+ : `the nested ${nested} run failed: ${failureCauseSentence(evidence.cause, describePlaybook)}`;
615
+ }
616
+ case 'runtime-defect':
617
+ return `the runtime could not settle the step: ${compactEvidence(evidence.reason ?? 'no reason was recorded')}.`;
618
+ }
619
+ }
620
+ function failureControlLine(action) {
621
+ // DR-063 §3: an action that publishes no standing is `ready`.
622
+ const standing = action.standing ?? 'ready';
623
+ return standing === 'ready'
624
+ ? `- ${action.label} (ready)`
625
+ : `- ${action.label} (${standing}: ${controlReasonPhrase(action.reason)})`;
626
+ }
627
+ function failureReportText(sentence, controls) {
628
+ return [
629
+ `Failure: ${sentence}`,
630
+ 'Controls:',
631
+ ...(controls.length === 0 ? ['- none'] : controls),
632
+ ].join('\n');
633
+ }
479
634
  function appendMandatoryPresentationSuffix(turn, suffix) {
480
635
  const current = turn.mandatoryPresentationSuffix;
481
636
  if (current === undefined) {
@@ -1247,10 +1402,16 @@ export function assertPlaybookCaptainShellSnapshot(value) {
1247
1402
  let normalizedLastError;
1248
1403
  if (snapshot.lastError !== undefined) {
1249
1404
  const error = snapshotRecord(snapshot.lastError, 'Captain shell snapshot.lastError');
1250
- rejectSnapshotKeys(error, ['name', 'message'], 'Captain shell snapshot.lastError');
1405
+ // DR-063 §2: the cause enters the stored record inside `lastError`, so a
1406
+ // parked failure explains itself after a restart. An unrecognized shape is
1407
+ // not a cause and the closed validator refuses the snapshot.
1408
+ rejectSnapshotKeys(error, ['name', 'message', 'cause'], 'Captain shell snapshot.lastError');
1251
1409
  normalizedLastError = {
1252
1410
  name: snapshotString(error.name, 'Captain shell snapshot.lastError.name', true),
1253
1411
  message: snapshotString(error.message, 'Captain shell snapshot.lastError.message', true),
1412
+ ...(error.cause === undefined
1413
+ ? {}
1414
+ : { cause: assertPlaybookFailureCause(error.cause) }),
1254
1415
  };
1255
1416
  }
1256
1417
  const activePlaybooks = new Set();
@@ -1854,6 +2015,12 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1854
2015
  return unresolvedEffectFromReceipt(receipt, original.head);
1855
2016
  }
1856
2017
  const sameProjection = isDeepStrictEqual(original.projection, after.projection);
2018
+ // DR-062 §3: a pure projection never downgrades a classification the
2019
+ // physical receipt proved. The latest receipt already accounted for every
2020
+ // pre-existing entry of its own baseline, so when that baseline is the
2021
+ // original one its accounting is the operation's accounting, and the
2022
+ // projection may not demand a byte-equal after projection on top of it.
2023
+ const receiptAccountsFromOriginal = isDeepStrictEqual(receipt.baseline.projection, original.projection);
1857
2024
  if (after.head === original.head) {
1858
2025
  if (receipt.classification !== 'unchanged' &&
1859
2026
  receipt.classification !== 'worktree-only-change') {
@@ -1868,7 +2035,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1868
2035
  const preservesOriginal = Object.entries(original.projection).every(([path, entry]) => Object.hasOwn(after.projection, path) &&
1869
2036
  isDeepStrictEqual(entry, after.projection[path]));
1870
2037
  return {
1871
- classification: preservesOriginal
2038
+ classification: preservesOriginal ||
2039
+ (receipt.classification === 'worktree-only-change' &&
2040
+ receiptAccountsFromOriginal)
1872
2041
  ? 'worktree-only-change'
1873
2042
  : 'observation-ambiguous',
1874
2043
  baselineHead: original.head,
@@ -1876,7 +2045,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1876
2045
  };
1877
2046
  }
1878
2047
  if (receipt.classification === 'one-descendant-commit' &&
1879
- sameProjection) {
2048
+ (sameProjection || receiptAccountsFromOriginal)) {
1880
2049
  return {
1881
2050
  classification: 'one-descendant-commit',
1882
2051
  baselineHead: original.head,
@@ -1989,6 +2158,44 @@ export function createPlaybookCaptainShell(options, deps = {}) {
1989
2158
  settledTurnUnresolvedEffects = frozen;
1990
2159
  return frozen;
1991
2160
  };
2161
+ /**
2162
+ * DR-062 §5: one deterministic Boss-visible report, once per turn, for the
2163
+ * pre-existing changes the commits completed during this turn carried, and
2164
+ * the same information as settlement facts the closing-reply prompt reads.
2165
+ * It supplements the unresolved-effect report and enters no run result.
2166
+ */
2167
+ const reportCarriedPreExistingChanges = (turn) => {
2168
+ if (turn.carriedPreExistingReported)
2169
+ return;
2170
+ if (turn.effectBoundaryPrefixCount === undefined)
2171
+ return;
2172
+ let carried;
2173
+ try {
2174
+ carried = carriedPreExistingChanges(assertPlaybookEffectLedger(currentEffectLedger()), turn.effectBoundaryPrefixCount);
2175
+ }
2176
+ catch {
2177
+ return;
2178
+ }
2179
+ const report = carriedPreExistingReport(carried);
2180
+ if (report === undefined)
2181
+ return;
2182
+ turn.carriedPreExistingReported = true;
2183
+ appendMandatoryPresentationSuffix(turn, report);
2184
+ const facts = carriedPreExistingFacts(carried);
2185
+ turn.settlementFacts.push(...facts);
2186
+ // The action's own report was assembled from the facts as they stood when
2187
+ // it settled; the result-phase prompt reads this report, so the same facts
2188
+ // join it here rather than only the turn's running list.
2189
+ if (turn.report !== undefined) {
2190
+ turn.report = {
2191
+ ...turn.report,
2192
+ facts: [...turn.report.facts, ...facts],
2193
+ ...(turn.report.bossFacts === undefined
2194
+ ? {}
2195
+ : { bossFacts: [...turn.report.bossFacts, ...facts] }),
2196
+ };
2197
+ }
2198
+ };
1992
2199
  const normalizeInstalledRetainedGenerations = (value) => {
1993
2200
  const path = 'Captain retained generations';
1994
2201
  const detached = snapshotJsonValue(value, path);
@@ -3758,6 +3965,22 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3758
3965
  ...entries.map(([key, value]) => digestLine `- ${key}: ${JSON.stringify(value)}`),
3759
3966
  ];
3760
3967
  };
3968
+ // DR-063 §3: every advertised action names its standing in the digest, so a
3969
+ // model is never invited to select an action that changes nothing.
3970
+ const advertisedActionDigest = (actions) => {
3971
+ if (actions.length === 0)
3972
+ return 'Advertised actions: none.';
3973
+ const lines = [
3974
+ 'Advertised actions:',
3975
+ ...actions.map((action) => (action.standing ?? 'ready') === 'ready'
3976
+ ? digestLine `- ${action.id}: ${action.label}`
3977
+ : digestLine `- ${action.id}: ${action.label} (${action.standing}: ${action.reason ?? 'no reason was published'})`),
3978
+ ];
3979
+ if (actions.some(({ standing }) => standing !== undefined && standing !== 'ready')) {
3980
+ lines.push('An action marked no-op runs and changes nothing; a blocked action cannot run at all.');
3981
+ }
3982
+ return lines.join('\n');
3983
+ };
3761
3984
  const retainedResumptionDigest = () => {
3762
3985
  if (rootFrame() !== undefined) {
3763
3986
  return 'Retained resumptions: unavailable while a playbook is engaged.';
@@ -3802,12 +4025,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3802
4025
  }
3803
4026
  lines.push(`Leaf ${frameLabel(leaf)} is parked for repository-effect reconciliation.`);
3804
4027
  lines.push('Pending Boss questions: withheld until reconciliation.');
3805
- lines.push(reconciliationActions.length === 0
3806
- ? 'Advertised actions: none.'
3807
- : [
3808
- 'Advertised actions:',
3809
- ...reconciliationActions.map((action) => digestLine `- ${action.id}: ${action.label}`),
3810
- ].join('\n'));
4028
+ lines.push(advertisedActionDigest(reconciliationActions));
3811
4029
  lines.push('Ordinary delivery, switching, dismissal, and runtime actions are unavailable while retained effect evidence is unresolved. Only the advertised unresolved-effect controls may run. Conversation is unaffected: `respond` stays valid for any turn.');
3812
4030
  lines.push(retainedResumptionDigest());
3813
4031
  return lines.join('\n');
@@ -3891,12 +4109,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3891
4109
  message: view.lastError.message,
3892
4110
  })}`);
3893
4111
  }
3894
- lines.push(view.actions.length === 0
3895
- ? 'Advertised actions: none.'
3896
- : [
3897
- 'Advertised actions:',
3898
- ...view.actions.map((action) => digestLine `- ${action.id}: ${action.label}`),
3899
- ].join('\n'));
4112
+ lines.push(advertisedActionDigest(view.actions));
3900
4113
  lines.push(retainedResumptionDigest());
3901
4114
  return lines.join('\n');
3902
4115
  };
@@ -3936,7 +4149,15 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3936
4149
  .filter((action) => !fenced ||
3937
4150
  action.id === UNRESOLVED_EFFECT_RECONCILIATION_ACTION_ID ||
3938
4151
  action.id === UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID)
3939
- .map((action) => Object.freeze({ id: action.id, label: action.label })));
4152
+ // DR-063 §3: the leaf's own standing is published with the pair, so a
4153
+ // host drawing the control says what running it would do. A leaf that
4154
+ // publishes none advertises `ready`, which is what it always meant.
4155
+ .map((action) => Object.freeze({
4156
+ id: action.id,
4157
+ label: action.label,
4158
+ standing: action.standing ?? 'ready',
4159
+ ...(action.reason === undefined ? {} : { reason: action.reason }),
4160
+ })));
3940
4161
  };
3941
4162
  // CAPTAIN-62: the controls the shell effects itself, published beside the
3942
4163
  // leaf's advertised actions and never mixed with them — these are the
@@ -3963,6 +4184,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3963
4184
  // way the Boss started it rather than the frame that happens to be
3964
4185
  // parked inside it.
3965
4186
  label: `Stop ${frameLabel(root)}`,
4187
+ // DR-063 §3: giving up always ends the engagement, whatever the leaf
4188
+ // is parked on.
4189
+ standing: 'ready',
3966
4190
  }),
3967
4191
  ]);
3968
4192
  };
@@ -4919,6 +5143,106 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4919
5143
  return (error instanceof AggregateError &&
4920
5144
  error.errors.some((nested) => containsEffectThrow(turn, nested)));
4921
5145
  };
5146
+ // DR-063 §4: the leaf's failure as the shell reads it — the cause the runtime
5147
+ // decided, or, behind the retained-effect fence where the leaf itself holds
5148
+ // no error, the incomplete receipt the frozen evidence names.
5149
+ const leafFailureCause = (view, fenced, unresolvedEffects) => {
5150
+ const recorded = view?.lastError ?? lastError;
5151
+ if (recorded?.cause !== undefined) {
5152
+ try {
5153
+ return assertPlaybookFailureCause(recorded.cause);
5154
+ }
5155
+ catch {
5156
+ // A cause the closed validator refuses is not a cause.
5157
+ }
5158
+ }
5159
+ if (fenced) {
5160
+ const incomplete = unresolvedEffects.find(({ classification }) => classification === 'incomplete');
5161
+ if (incomplete !== undefined) {
5162
+ return assertPlaybookFailureCause({
5163
+ code: 'receipt-missing',
5164
+ evidence: { baselineHead: incomplete.baselineHead },
5165
+ });
5166
+ }
5167
+ }
5168
+ // There is no failure without a cause: a parked failure whose error
5169
+ // carries none is a runtime defect named by that error.
5170
+ if (view?.state.stateId === 'failed' && recorded !== undefined) {
5171
+ return assertPlaybookFailureCause({
5172
+ code: 'runtime-defect',
5173
+ evidence: {
5174
+ reason: recorded.message.trim().length > 0
5175
+ ? recorded.message
5176
+ : recorded.name,
5177
+ },
5178
+ });
5179
+ }
5180
+ return undefined;
5181
+ };
5182
+ const failureControlLines = (view, fenced) => {
5183
+ const runtimeActions = (view?.actions ?? []).filter((action) => !fenced ||
5184
+ action.id === UNRESOLVED_EFFECT_RECONCILIATION_ACTION_ID ||
5185
+ action.id === UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID);
5186
+ const root = rootFrame();
5187
+ return [
5188
+ ...runtimeActions.map((action) => failureControlLine(action)),
5189
+ // The shell's own control is published beside the leaf's, so the report
5190
+ // names every door out of this failure (CAPTAIN-62).
5191
+ ...(root === undefined
5192
+ ? []
5193
+ : [
5194
+ failureControlLine({
5195
+ label: `Stop ${frameLabel(root)}`,
5196
+ standing: 'ready',
5197
+ }),
5198
+ ]),
5199
+ ];
5200
+ };
5201
+ const describeNestedPlaybook = (playbookId) => {
5202
+ const enablement = enablementById.get(playbookId);
5203
+ return enablement === undefined ? playbookId : `/${enablement.command}`;
5204
+ };
5205
+ // DR-063 §4: appended once, beside the unresolved-effect and pre-existing
5206
+ // change reports, whenever the turn settles with the leaf parked in its
5207
+ // failure state or behind the retained-effect fence.
5208
+ const reportLeafFailure = (turn, unresolvedEffects) => {
5209
+ if (turn.failureReported)
5210
+ return;
5211
+ const leaf = leafFrame();
5212
+ if (!leaf)
5213
+ return;
5214
+ const fenced = retainedEffectReconciliation !== undefined;
5215
+ let view;
5216
+ if (typeof leaf.runtime.describe === 'function') {
5217
+ try {
5218
+ view = leaf.runtime.describe();
5219
+ }
5220
+ catch {
5221
+ // An unreadable control view leaves the mirrored error as evidence.
5222
+ }
5223
+ }
5224
+ if (!fenced && view?.state.stateId !== 'failed')
5225
+ return;
5226
+ const cause = leafFailureCause(view, fenced, unresolvedEffects);
5227
+ if (cause === undefined)
5228
+ return;
5229
+ turn.failureReported = true;
5230
+ const sentence = failureCauseSentence(cause, describeNestedPlaybook);
5231
+ appendMandatoryPresentationSuffix(turn, failureReportText(sentence, failureControlLines(view, fenced)));
5232
+ // The result-phase prompt reads the turn's report, so the same fact joins
5233
+ // it here rather than only the turn's running list.
5234
+ const fact = `The workflow failed: ${sentence}`;
5235
+ turn.settlementFacts.push(fact);
5236
+ if (turn.report !== undefined) {
5237
+ turn.report = {
5238
+ ...turn.report,
5239
+ facts: [...turn.report.facts, fact],
5240
+ ...(turn.report.bossFacts === undefined
5241
+ ? {}
5242
+ : { bossFacts: [...turn.report.bossFacts, fact] }),
5243
+ };
5244
+ }
5245
+ };
4922
5246
  const settleSelection = async (selection, signal) => {
4923
5247
  const turn = activeTurn;
4924
5248
  runFailureFacts = [];
@@ -4929,6 +5253,11 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4929
5253
  if (turn !== undefined && report !== undefined) {
4930
5254
  appendMandatoryPresentationSuffix(turn, report);
4931
5255
  }
5256
+ if (turn !== undefined)
5257
+ reportCarriedPreExistingChanges(turn);
5258
+ if (turn !== undefined) {
5259
+ reportLeafFailure(turn, frozenUnresolvedEffects);
5260
+ }
4932
5261
  return frozenUnresolvedEffects;
4933
5262
  };
4934
5263
  const finalizeSettlement = (settlement) => Object.freeze({
@@ -6407,12 +6736,24 @@ export function createPlaybookCaptainShell(options, deps = {}) {
6407
6736
  const hostRuntimeActionId = decided?.kind === 'runtime' ? decided.actionId : undefined;
6408
6737
  const hostGiveUp = decided?.kind === 'give-up' ? true : undefined;
6409
6738
  const parsed = decided === undefined ? resolveCommandTurn(turn.prompt) : undefined;
6739
+ // DR-062 §5: the boundary count this turn starts from. Every boundary
6740
+ // beyond it completed during this turn.
6741
+ let effectBoundaryPrefixCount;
6742
+ try {
6743
+ effectBoundaryPrefixCount = assertPlaybookEffectLedger(currentEffectLedger()).boundaries.length;
6744
+ }
6745
+ catch {
6746
+ effectBoundaryPrefixCount = undefined;
6747
+ }
6410
6748
  activeTurn = {
6411
6749
  id: ++turnSequence,
6412
6750
  captainSyncedJournalSeq: journalSeq,
6413
6751
  bossText: turn.prompt,
6414
6752
  authoritativeText: parsed?.authoritativeText ?? turn.prompt,
6415
6753
  ...(parsed ? { resolution: parsed.resolution } : {}),
6754
+ ...(effectBoundaryPrefixCount === undefined
6755
+ ? {}
6756
+ : { effectBoundaryPrefixCount }),
6416
6757
  ...(hostRuntimeActionId === undefined ? {} : { hostRuntimeActionId }),
6417
6758
  ...(hostGiveUp === undefined ? {} : { hostGiveUp }),
6418
6759
  settled: false,