@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
@@ -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);
@@ -3321,6 +3528,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3321
3528
  * may return an executed receipt to its result phase.
3322
3529
  */
3323
3530
  const settleUnresolvedEffectAbandonment = async (unresolvedLeaf) => {
3531
+ abandonmentSettlementUnsafe = true;
3324
3532
  if (leafFrame() !== unresolvedLeaf) {
3325
3533
  throw new Error('unresolved-effect abandonment requires the active leaf');
3326
3534
  }
@@ -3347,6 +3555,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3347
3555
  rootPlaybookId,
3348
3556
  });
3349
3557
  await runEffect(() => unresolvedEffectSettlement.complete(settlement));
3558
+ abandonmentSettlementUnsafe = false;
3350
3559
  };
3351
3560
  const callResultFor = (frame, result) => {
3352
3561
  if (result.outcome === 'unresolved-effect') {
@@ -3756,6 +3965,22 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3756
3965
  ...entries.map(([key, value]) => digestLine `- ${key}: ${JSON.stringify(value)}`),
3757
3966
  ];
3758
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
+ };
3759
3984
  const retainedResumptionDigest = () => {
3760
3985
  if (rootFrame() !== undefined) {
3761
3986
  return 'Retained resumptions: unavailable while a playbook is engaged.';
@@ -3800,12 +4025,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3800
4025
  }
3801
4026
  lines.push(`Leaf ${frameLabel(leaf)} is parked for repository-effect reconciliation.`);
3802
4027
  lines.push('Pending Boss questions: withheld until reconciliation.');
3803
- lines.push(reconciliationActions.length === 0
3804
- ? 'Advertised actions: none.'
3805
- : [
3806
- 'Advertised actions:',
3807
- ...reconciliationActions.map((action) => digestLine `- ${action.id}: ${action.label}`),
3808
- ].join('\n'));
4028
+ lines.push(advertisedActionDigest(reconciliationActions));
3809
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.');
3810
4030
  lines.push(retainedResumptionDigest());
3811
4031
  return lines.join('\n');
@@ -3889,12 +4109,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3889
4109
  message: view.lastError.message,
3890
4110
  })}`);
3891
4111
  }
3892
- lines.push(view.actions.length === 0
3893
- ? 'Advertised actions: none.'
3894
- : [
3895
- 'Advertised actions:',
3896
- ...view.actions.map((action) => digestLine `- ${action.id}: ${action.label}`),
3897
- ].join('\n'));
4112
+ lines.push(advertisedActionDigest(view.actions));
3898
4113
  lines.push(retainedResumptionDigest());
3899
4114
  return lines.join('\n');
3900
4115
  };
@@ -3934,7 +4149,15 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3934
4149
  .filter((action) => !fenced ||
3935
4150
  action.id === UNRESOLVED_EFFECT_RECONCILIATION_ACTION_ID ||
3936
4151
  action.id === UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID)
3937
- .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
+ })));
3938
4161
  };
3939
4162
  // CAPTAIN-62: the controls the shell effects itself, published beside the
3940
4163
  // leaf's advertised actions and never mixed with them — these are the
@@ -3961,6 +4184,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3961
4184
  // way the Boss started it rather than the frame that happens to be
3962
4185
  // parked inside it.
3963
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',
3964
4190
  }),
3965
4191
  ]);
3966
4192
  };
@@ -4693,6 +4919,12 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4693
4919
  if (!root)
4694
4920
  return false;
4695
4921
  const label = frameLabel(root);
4922
+ if (activeTurn?.hostGiveUp === true &&
4923
+ freezeTurnUnresolvedEffects().length > 0) {
4924
+ await settleUnresolvedEffectAbandonment(leafFrame());
4925
+ facts.push(`Dismissed the ${label} engagement with unresolved repository effects reported.`);
4926
+ return false;
4927
+ }
4696
4928
  // Dismissal leaves the procedure unfinished. Persist the latest safe
4697
4929
  // generation captured for this turn before disposal erases the frames —
4698
4930
  // unless the Boss gave up on it (CAPTAIN-44), in which case retaining it
@@ -4911,6 +5143,106 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4911
5143
  return (error instanceof AggregateError &&
4912
5144
  error.errors.some((nested) => containsEffectThrow(turn, nested)));
4913
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
+ };
4914
5246
  const settleSelection = async (selection, signal) => {
4915
5247
  const turn = activeTurn;
4916
5248
  runFailureFacts = [];
@@ -4921,6 +5253,11 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4921
5253
  if (turn !== undefined && report !== undefined) {
4922
5254
  appendMandatoryPresentationSuffix(turn, report);
4923
5255
  }
5256
+ if (turn !== undefined)
5257
+ reportCarriedPreExistingChanges(turn);
5258
+ if (turn !== undefined) {
5259
+ reportLeafFailure(turn, frozenUnresolvedEffects);
5260
+ }
4924
5261
  return frozenUnresolvedEffects;
4925
5262
  };
4926
5263
  const finalizeSettlement = (settlement) => Object.freeze({
@@ -5066,7 +5403,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
5066
5403
  selection.actionId === UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID) &&
5067
5404
  fencedLeaf !== undefined;
5068
5405
  if (retainedEffectReconciliation !== undefined &&
5069
- !routesRetainedReconciliation) {
5406
+ !routesRetainedReconciliation &&
5407
+ !(selection.action === 'dismiss' && turn.hostGiveUp === true)) {
5070
5408
  return rejectSelection(selection, 'retained work must reconcile its repository-effect evidence before an ordinary action can run');
5071
5409
  }
5072
5410
  if (selection.action === 'resume') {
@@ -5359,7 +5697,6 @@ export function createPlaybookCaptainShell(options, deps = {}) {
5359
5697
  await settleUnresolvedEffectAbandonment(leaf);
5360
5698
  }
5361
5699
  catch (error) {
5362
- abandonmentSettlementUnsafe = true;
5363
5700
  const normalized = normalizeErrorCompact(error) ?? {
5364
5701
  name: 'Error',
5365
5702
  message: String(error),
@@ -5473,8 +5810,13 @@ export function createPlaybookCaptainShell(options, deps = {}) {
5473
5810
  const giveUpReplyText = () => {
5474
5811
  const report = activeTurn?.report;
5475
5812
  const facts = report?.bossFacts ?? report?.facts ?? [];
5476
- const opening = 'Stopped that workflow. I will not resume it.';
5477
- const closing = 'Send the command again to start fresh work.';
5813
+ const stopped = report?.status === 'ok';
5814
+ const opening = stopped
5815
+ ? 'Stopped that workflow. I will not resume it.'
5816
+ : 'I could not confirm that the workflow was stopped.';
5817
+ const closing = stopped
5818
+ ? 'Send the command again to start fresh work.'
5819
+ : 'The stop did not settle successfully; recover the session before continuing.';
5478
5820
  const composed = [
5479
5821
  opening,
5480
5822
  ...(facts.length === 0
@@ -6394,12 +6736,24 @@ export function createPlaybookCaptainShell(options, deps = {}) {
6394
6736
  const hostRuntimeActionId = decided?.kind === 'runtime' ? decided.actionId : undefined;
6395
6737
  const hostGiveUp = decided?.kind === 'give-up' ? true : undefined;
6396
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
+ }
6397
6748
  activeTurn = {
6398
6749
  id: ++turnSequence,
6399
6750
  captainSyncedJournalSeq: journalSeq,
6400
6751
  bossText: turn.prompt,
6401
6752
  authoritativeText: parsed?.authoritativeText ?? turn.prompt,
6402
6753
  ...(parsed ? { resolution: parsed.resolution } : {}),
6754
+ ...(effectBoundaryPrefixCount === undefined
6755
+ ? {}
6756
+ : { effectBoundaryPrefixCount }),
6403
6757
  ...(hostRuntimeActionId === undefined ? {} : { hostRuntimeActionId }),
6404
6758
  ...(hostGiveUp === undefined ? {} : { hostGiveUp }),
6405
6759
  settled: false,