@sublang/playbook 15.0.0 → 16.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 (81) hide show
  1. package/README.md +1 -1
  2. package/docs/embedding.md +9 -7
  3. package/package.json +3 -1
  4. package/reference/sdlc/branch.playbook/branch.fsm.d.ts +63 -33
  5. package/reference/sdlc/branch.playbook/branch.fsm.js +190 -108
  6. package/reference/sdlc/branch.playbook/branch.fsm.ts +269 -147
  7. package/reference/sdlc/branch.playbook/branch.gears.md +7 -17
  8. package/reference/sdlc/branch.playbook/branch.playbook.d.ts +12 -16
  9. package/reference/sdlc/branch.playbook/branch.playbook.js +112 -101
  10. package/reference/sdlc/branch.playbook/branch.playbook.ts +128 -171
  11. package/reference/sdlc/branch.playbook/branch.registry.d.ts +3 -2
  12. package/reference/sdlc/branch.playbook/branch.registry.ts +6 -2
  13. package/reference/sdlc/code.playbook/bin/repository-effects.js +54 -13
  14. package/reference/sdlc/code.playbook/code.fsm.d.ts +143 -90
  15. package/reference/sdlc/code.playbook/code.fsm.introspect.d.ts +8 -8
  16. package/reference/sdlc/code.playbook/code.fsm.introspect.ts +13 -13
  17. package/reference/sdlc/code.playbook/code.fsm.js +609 -649
  18. package/reference/sdlc/code.playbook/code.fsm.ts +890 -821
  19. package/reference/sdlc/code.playbook/code.gears.md +39 -29
  20. package/reference/sdlc/code.playbook/code.playbook.d.ts +13 -16
  21. package/reference/sdlc/code.playbook/code.playbook.js +166 -147
  22. package/reference/sdlc/code.playbook/code.playbook.ts +180 -229
  23. package/reference/sdlc/code.playbook/code.registry.d.ts +3 -2
  24. package/reference/sdlc/code.playbook/code.registry.ts +6 -2
  25. package/reference/sdlc/code.playbook/playbook-captain.d.ts +42 -0
  26. package/reference/sdlc/code.playbook/playbook-captain.js +829 -194
  27. package/reference/sdlc/code.playbook/playbook-captain.ts +1227 -220
  28. package/reference/sdlc/decide.md +1 -0
  29. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +270 -154
  30. package/reference/sdlc/decide.playbook/decide.fsm.js +765 -732
  31. package/reference/sdlc/decide.playbook/decide.fsm.ts +1167 -945
  32. package/reference/sdlc/decide.playbook/decide.gears.md +28 -21
  33. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +11 -67
  34. package/reference/sdlc/decide.playbook/decide.playbook.js +155 -3570
  35. package/reference/sdlc/decide.playbook/decide.playbook.ts +186 -5061
  36. package/reference/sdlc/decide.playbook/decide.registry.d.ts +8 -4
  37. package/reference/sdlc/decide.playbook/decide.registry.js +2 -2
  38. package/reference/sdlc/decide.playbook/decide.registry.ts +13 -6
  39. package/reference/sdlc/dev.playbook/dev.fsm.d.ts +113 -131
  40. package/reference/sdlc/dev.playbook/dev.fsm.js +517 -575
  41. package/reference/sdlc/dev.playbook/dev.fsm.ts +667 -787
  42. package/reference/sdlc/dev.playbook/dev.gears.md +52 -54
  43. package/reference/sdlc/dev.playbook/dev.playbook.d.ts +13 -10
  44. package/reference/sdlc/dev.playbook/dev.playbook.js +128 -120
  45. package/reference/sdlc/dev.playbook/dev.playbook.ts +143 -193
  46. package/reference/sdlc/dev.playbook/dev.registry.d.ts +3 -2
  47. package/reference/sdlc/dev.playbook/dev.registry.ts +6 -2
  48. package/reference/sdlc/pr.playbook/pr.fsm.d.ts +92 -85
  49. package/reference/sdlc/pr.playbook/pr.fsm.js +461 -518
  50. package/reference/sdlc/pr.playbook/pr.fsm.ts +533 -663
  51. package/reference/sdlc/pr.playbook/pr.gears.md +47 -63
  52. package/reference/sdlc/pr.playbook/pr.playbook.d.ts +13 -23
  53. package/reference/sdlc/pr.playbook/pr.playbook.js +118 -112
  54. package/reference/sdlc/pr.playbook/pr.playbook.ts +136 -190
  55. package/reference/sdlc/pr.playbook/pr.registry.d.ts +3 -2
  56. package/reference/sdlc/pr.playbook/pr.registry.ts +4 -2
  57. package/reference/sdlc/review.md +3 -3
  58. package/reference/sdlc/review.playbook/review.fsm.d.ts +150 -116
  59. package/reference/sdlc/review.playbook/review.fsm.js +479 -550
  60. package/reference/sdlc/review.playbook/review.fsm.ts +717 -662
  61. package/reference/sdlc/review.playbook/review.gears.md +32 -29
  62. package/reference/sdlc/review.playbook/review.playbook.d.ts +12 -12
  63. package/reference/sdlc/review.playbook/review.playbook.js +180 -155
  64. package/reference/sdlc/review.playbook/review.playbook.ts +190 -230
  65. package/reference/sdlc/review.playbook/review.registry.d.ts +6 -5
  66. package/reference/sdlc/review.playbook/review.registry.js +3 -3
  67. package/reference/sdlc/review.playbook/review.registry.ts +9 -5
  68. package/slc/gears2fsm.md +72 -3
  69. package/slc/link.md +50 -27
  70. package/slc/materialize-link.mjs +51 -13
  71. package/slc/optimize.md +4 -1
  72. package/slc/prefix-prompts.mjs +191 -0
  73. package/slc/prefix.md +203 -0
  74. package/slc/slc.pin-inputs.json +11 -1
  75. package/slc/text2gears.md +71 -11
  76. package/src/xstate-playbook-runtime.d.ts +82 -9
  77. package/src/xstate-playbook-runtime.js +1224 -283
  78. package/src/xstate-playbook-runtime.ts +1671 -338
  79. package/src/xstate-runtime.d.ts +3 -1
  80. package/src/xstate-runtime.js +9 -2
  81. package/src/xstate-runtime.ts +12 -2
@@ -5,7 +5,7 @@ import { isDeepStrictEqual } from 'node:util';
5
5
  import PQueue from 'p-queue';
6
6
  import { isAgentCallSettingsError, } from '@sublang/cligent/tmux-play';
7
7
  import { assertPlaybookFailureCause } from '@sublang/playbook/runtime';
8
- import { assertPlaybookRuntimeSnapshot, assertPlaybookEffectLedger, emptyPlaybookEffectLedger, hiddenControlEnvelope, isPlaybookEffectLedgerMonotonicExtension, registerPlaybookAbortCleanup, snapshotJsonValue, validatePlayerResult, } from '../../../src/xstate-runtime.js';
8
+ import { assertPlaybookRuntimeSnapshot, assertPlaybookEffectLedger, emptyPlaybookEffectLedger, hiddenControlEnvelope, isPlaybookEffectLedgerMonotonicExtension, normalizeError, registerPlaybookAbortCleanup, snapshotJsonValue, validatePlayerResult, } from '../../../src/xstate-runtime.js';
9
9
  import createDefaultCaptainRuntime from '../captain.playbook/captain.playbook.js';
10
10
  function retainedEffectLedgerCanRebase(checkpoint, current) {
11
11
  if (checkpoint.boundaries.some(({ physicalReceipt }) => physicalReceipt === undefined)) {
@@ -390,6 +390,14 @@ function proseRejection(prose, liveSessionIds = [], liveStateIds = [], suppliedI
390
390
  return 'the reply leaked hidden control syntax or internal control vocabulary';
391
391
  }
392
392
  }
393
+ return identifierRejection(prose, liveSessionIds, liveStateIds, suppliedIds);
394
+ }
395
+ /**
396
+ * CAPTAIN-9's identifier duty alone: whether `prose` repeats a live session
397
+ * identifier, a live internal state identifier, or an identifier the shell
398
+ * supplied for selection only.
399
+ */
400
+ function identifierRejection(prose, liveSessionIds, liveStateIds, suppliedIds) {
393
401
  const caseFoldedProse = prose.toLowerCase();
394
402
  for (const sessionId of liveSessionIds) {
395
403
  // UUID hexadecimal is case-insensitive. A model uppercasing A-F has not
@@ -410,6 +418,36 @@ function proseRejection(prose, liveSessionIds = [], liveStateIds = [], suppliedI
410
418
  }
411
419
  return undefined;
412
420
  }
421
+ /**
422
+ * CAPTAIN-9: a prose call answered with the routing envelope of a `respond`
423
+ * selection — exactly `{"action": "respond", "text": <string>}`, with nothing
424
+ * but whitespace around it — said its prose in the decision shape, so the
425
+ * prose is that `text`, which then passes the same validation as any reply.
426
+ * Refusing the envelope instead spends the one corrective re-ask on a shape
427
+ * whose meaning is unambiguous, and a model that answers the same way twice
428
+ * costs the Boss the whole reply. Any other JSON stays control JSON.
429
+ */
430
+ function respondEnvelopeText(reply) {
431
+ const trimmed = reply?.trim();
432
+ if (trimmed === undefined || !trimmed.startsWith('{'))
433
+ return reply;
434
+ let parsed;
435
+ try {
436
+ parsed = JSON.parse(trimmed);
437
+ }
438
+ catch {
439
+ return reply;
440
+ }
441
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
442
+ return reply;
443
+ }
444
+ const members = parsed;
445
+ return Object.keys(members).length === 2 &&
446
+ members.action === 'respond' &&
447
+ typeof members.text === 'string'
448
+ ? members.text
449
+ : reply;
450
+ }
413
451
  // DR-013 A1: adapters with no provider-enforced tool-restriction surface.
414
452
  // Cligent's Codex, Kimi, and OpenCode adapters reject any `allowedTools`
415
453
  // value — including the empty list that expresses tool-free — because their
@@ -452,28 +490,36 @@ function forwardedToolOptions(requested, captainAdapter) {
452
490
  return { allowedTools: requested };
453
491
  }
454
492
  const hiddenJudgeEnvelope = hiddenControlEnvelope;
493
+ /**
494
+ * Each unresolved-effect classification as what the step did (CAPTAIN-58), in
495
+ * the words the matching failure cause uses, so the report assembly reads a
496
+ * cause an entry already states as said (CAPTAIN-69).
497
+ */
498
+ const UNRESOLVED_EFFECT_CLAUSES = {
499
+ 'one-descendant-commit': 'the step made one new commit',
500
+ 'multiple-commits': 'the step made more than one commit',
501
+ 'rewritten-or-non-descendant': 'the repository history was rewritten',
502
+ 'worktree-only-change': 'the step left changes uncommitted',
503
+ 'concurrent-or-foreign-change': 'the repository changed outside this step',
504
+ 'observation-ambiguous': 'the repository could not be observed after the step',
505
+ incomplete: "the step's effect on the repository was not fully recorded",
506
+ };
455
507
  function unresolvedEffectReportLines(unresolvedEffects) {
456
508
  return unresolvedEffects.map((effect, index) => {
457
509
  const merelyPossible = effect.classification === 'observation-ambiguous' ||
458
510
  effect.classification === 'incomplete';
459
- return [
460
- `${index + 1}. ${merelyPossible ? 'Possible repository effect; a change could not be excluded' : 'Observed repository change'} (${effect.classification})`,
461
- `baseline HEAD ${effect.baselineHead}`,
462
- effect.afterHead === undefined
463
- ? 'after HEAD was not available'
464
- : `after HEAD ${effect.afterHead}`,
465
- ...(effect.commitOid === undefined
466
- ? []
467
- : [`proven commit OID ${effect.commitOid}`]),
468
- ].join('; ') + '.';
511
+ const heads = effect.afterHead === undefined
512
+ ? `the repository stood at ${effect.baselineHead} before it; no HEAD was available after it`
513
+ : effect.afterHead === effect.baselineHead
514
+ ? `HEAD stayed at ${effect.baselineHead}`
515
+ : `HEAD moved from ${effect.baselineHead} to ${effect.commitOid === undefined ? '' : 'the proven commit '}${effect.afterHead}`;
516
+ return `${index + 1}. ${merelyPossible ? 'Possible repository effect, which could not be excluded' : 'Observed repository change'}: ${UNRESOLVED_EFFECT_CLAUSES[effect.classification]}; ${heads}.`;
469
517
  });
470
518
  }
471
- function unresolvedEffectBossReport(unresolvedEffects) {
472
- if (unresolvedEffects.length === 0)
473
- return undefined;
519
+ function unresolvedEffectBossReport(lines) {
474
520
  return [
475
521
  'Repository-effect evidence:',
476
- ...unresolvedEffectReportLines(unresolvedEffects).map((line) => `- ${line}`),
522
+ ...lines.map((line) => `- ${line}`),
477
523
  'This evidence does not establish workflow completion or attribute any repository change or commit to this workflow.',
478
524
  ].join('\n');
479
525
  }
@@ -535,10 +581,21 @@ function carriedPreExistingReport(carried) {
535
581
  ].join('\n'))
536
582
  .join('\n\n');
537
583
  }
584
+ /**
585
+ * The carried-changes report as settlement facts, one per commit, saying all
586
+ * the report says so a reply that lists them need not repeat it.
587
+ */
538
588
  function carriedPreExistingFacts(carried) {
539
589
  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)}.`;
590
+ const lists = [
591
+ ...(absorbed.length === 0
592
+ ? []
593
+ : [`absorbed as found ${boundedPathList(absorbed)}`]),
594
+ ...(altered.length === 0
595
+ ? []
596
+ : [`altered before committing ${boundedPathList(altered)}`]),
597
+ ];
598
+ return `The commit ${commitOid} carries changes that were uncommitted before the step: ${lists.join('; ')}.`;
542
599
  });
543
600
  }
544
601
  // DR-063 §4: the Boss-visible failure report. Every host sees the same
@@ -563,59 +620,359 @@ function causePathList(paths, truncated) {
563
620
  ? `${printed.join(', ')}, … and ${omitted} more`
564
621
  : printed.join(', ');
565
622
  }
566
- /** One sentence per code, with that code's bounded evidence (DR-063 §4). */
567
- function failureCauseSentence(cause, describePlaybook) {
623
+ // CAPTAIN-71: one renderer puts every failure into words, for the Boss and for
624
+ // the turn's settlement facts alike — a failure statement names what failed
625
+ // and why — so no site composes a failure's words or quotes its message
626
+ // itself. A cause says why in the Boss phrase of its code, and a reason the
627
+ // runtime mints from a closed set in its own phrase (CAPTAIN-76); any other
628
+ // recorded reason or message is quoted where it reads as prose and is
629
+ // otherwise shown once, whole, as recorded, in a code span, never replaced by
630
+ // a generic sentence that hides it (CAPTAIN-74).
631
+ const REPOSITORY_EVIDENCE_SENTENCE = "the repository evidence for the step is missing or inconsistent, so the step's outcome was not accepted.";
632
+ const RESULT_EVIDENCE_SENTENCE = "the step's reported result is incomplete or inconsistent, so its outcome was not accepted.";
633
+ const UNDECIDED_OUTCOME_SENTENCE = "the step's outcome could not be decided.";
634
+ const CAPTAIN_INCOMPLETE_SENTENCE = 'the Captain call did not complete.';
635
+ const PLAYER_ERROR_SENTENCE = 'the player reported an error without a message.';
636
+ const PLAYER_ABORTED_SENTENCE = 'the player call was aborted.';
637
+ /**
638
+ * CAPTAIN-76: every reason the runtime records from its own literal text, as
639
+ * the Boss reads it. These are the runtime's own words for its own states — the
640
+ * semantic-reconciliation reasons (PBRT-77), the governed settlement's
641
+ * reasons, the judge's reasons, the player and Captain calls' results that
642
+ * carried no message or no text, its own invariant reasons behind its
643
+ * diagnostic label, and the stand-ins for a player result or an error that
644
+ * carried no message — so no recorded text of one is ever shown as a code the
645
+ * Boss has to decode. Anything not listed here is foreign text (CAPTAIN-74).
646
+ */
647
+ const SEMANTIC_RECONCILIATION_PHRASES = {
648
+ 'missing-repository-receipt': REPOSITORY_EVIDENCE_SENTENCE,
649
+ 'invalid-repository-receipt': REPOSITORY_EVIDENCE_SENTENCE,
650
+ 'repository-disposition-mismatch': REPOSITORY_EVIDENCE_SENTENCE,
651
+ 'missing-effect-evidence': REPOSITORY_EVIDENCE_SENTENCE,
652
+ 'missing-presentation-evidence': RESULT_EVIDENCE_SENTENCE,
653
+ 'missing-runtime-evidence': RESULT_EVIDENCE_SENTENCE,
654
+ 'inconsistent-runtime-evidence': RESULT_EVIDENCE_SENTENCE,
655
+ };
656
+ const RUNTIME_REASON_PHRASES = {
657
+ ...SEMANTIC_RECONCILIATION_PHRASES,
658
+ 'deferred player result has no semantic evidence': RESULT_EVIDENCE_SENTENCE,
659
+ 'host omitted governed semantic settlement': "the host did not settle the step's outcome.",
660
+ 'deferred question did not receive an eligible durable binding': "the step's question could not be kept for a later reply.",
661
+ 'judge transport failed': UNDECIDED_OUTCOME_SENTENCE,
662
+ 'corrective judge failed': UNDECIDED_OUTCOME_SENTENCE,
663
+ 'corrective semantic candidate is invalid': UNDECIDED_OUTCOME_SENTENCE,
664
+ 'semantic correction budget is unavailable': UNDECIDED_OUTCOME_SENTENCE,
665
+ 'callPlayer status "error"': PLAYER_ERROR_SENTENCE,
666
+ 'callPlayer status "aborted"': PLAYER_ABORTED_SENTENCE,
667
+ 'captainBridge: callPlayer status "error"': PLAYER_ERROR_SENTENCE,
668
+ 'captainBridge: callPlayer status "aborted"': PLAYER_ABORTED_SENTENCE,
669
+ 'captainBridge: callPlayer returned status=ok with no finalText': 'the player call returned nothing.',
670
+ 'captainActor: callCaptain status "error"': CAPTAIN_INCOMPLETE_SENTENCE,
671
+ 'captainActor: callCaptain status "aborted"': CAPTAIN_INCOMPLETE_SENTENCE,
672
+ 'captainActor: callCaptain returned status=ok with no finalText': 'the Captain call returned nothing.',
673
+ 'apply settled with outcome failed': 'the action did not complete.',
674
+ 'apply settled with outcome aborted': 'the action was stopped.',
675
+ 'unknown runtime defect': 'the runtime failed without saying why.',
676
+ 'Unknown error': 'an unknown error.',
677
+ };
678
+ /**
679
+ * CAPTAIN-76: the reasons the runtime mints behind its diagnostic label —
680
+ * `playbook` unless a playbook names its own — as `<label> <suffix>`, the
681
+ * parallel cohort's naming its state id as well and the unresolved governed
682
+ * outcome its own reason; each matches only the whole reason.
683
+ */
684
+ const LABELLED_RUNTIME_REASONS = [
685
+ [
686
+ /^.+ retained governed semantic envelope is no longer exact$/,
687
+ "the step's retained result no longer matches.",
688
+ ],
689
+ [/^.+ parallel state .+ cohort failed$/, 'a parallel step failed.'],
690
+ [
691
+ /^.+ governed semantic reconciliation remains unresolved$/,
692
+ "the step's outcome could not be reconciled.",
693
+ ],
694
+ [
695
+ /^.+ reconstructed governed output changed before FSM acceptance$/,
696
+ "the step's result changed before it was accepted.",
697
+ ],
698
+ [
699
+ /^.+ deferred continuation remains unresolved$/,
700
+ 'the deferred step is still unresolved.',
701
+ ],
702
+ [
703
+ /^.+ deferred player returned no result$/,
704
+ 'the deferred player returned nothing.',
705
+ ],
706
+ [
707
+ /^.+ governed outcome remains unresolved: .+$/,
708
+ "the step's outcome could not be settled.",
709
+ ],
710
+ [/^.+ actor entered error status$/, "the step's actor failed."],
711
+ ];
712
+ /**
713
+ * The runtime's refusal of an action it does not advertise, which repeats the
714
+ * selected id as JSON (PBRT-52) and so says nothing the Boss can use but that
715
+ * the action is no longer offered (CAPTAIN-76).
716
+ */
717
+ const NOT_ADVERTISED_REFUSAL = /^action "(?:[^"\\]|\\.)*" is not currently advertised$/;
718
+ /** The phrase of a reason the runtime mints from a closed set (CAPTAIN-76). */
719
+ function runtimeReasonPhrase(reason) {
720
+ if (Object.hasOwn(RUNTIME_REASON_PHRASES, reason)) {
721
+ return RUNTIME_REASON_PHRASES[reason];
722
+ }
723
+ if (NOT_ADVERTISED_REFUSAL.test(reason)) {
724
+ return 'that action is no longer offered.';
725
+ }
726
+ return LABELLED_RUNTIME_REASONS.find(([minted]) => minted.test(reason))?.[1];
727
+ }
728
+ /** A declared repository disposition, as what the step was to leave. */
729
+ const REQUIRED_DISPOSITION_PHRASES = {
730
+ unchanged: 'no change',
731
+ 'one-descendant-commit': 'one new commit',
732
+ deferred: 'a change deferred to a later step',
733
+ };
734
+ /** A receipt classification, as what the step left. */
735
+ const OBSERVED_CLASSIFICATION_PHRASES = {
736
+ unchanged: 'no change',
737
+ 'one-descendant-commit': 'one new commit',
738
+ 'multiple-commits': 'more than one new commit',
739
+ 'rewritten-or-non-descendant': 'rewritten history',
740
+ 'worktree-only-change': 'uncommitted changes only',
741
+ 'concurrent-or-foreign-change': 'a change made outside the step',
742
+ 'observation-ambiguous': 'a state that could not be read',
743
+ };
744
+ /**
745
+ * An error-class name leading a message (CAPTAIN-74): `Error`, `Exception`,
746
+ * `Failure`, or `Defect`, alone or ending a CamelCase identifier, as in
747
+ * `TypeError: …`, followed by `: `.
748
+ */
749
+ const LEADING_ERROR_CLASS = /^(?:[A-Z][A-Za-z0-9]*)?(?:Error|Exception|Failure|Defect): /;
750
+ /**
751
+ * CAPTAIN-74: prose begins with no error-class name — a message behind one is
752
+ * machine text by origin, however what follows it reads — holds a space,
753
+ * begins with a capital letter, ends in a terminal mark, and holds no word
754
+ * CAPTAIN-9's identifier grammar tells apart from English — the same tokens
755
+ * and the same test the reply validation applies, so no second notion of
756
+ * "code-like" exists to disagree with it.
757
+ */
758
+ function readsAsProse(text) {
759
+ return (!LEADING_ERROR_CLASS.test(text) &&
760
+ text.includes(' ') &&
761
+ /^\p{Lu}/u.test(text) &&
762
+ /[.!?]$/.test(text) &&
763
+ !(text.match(IDENTIFIER_TOKENS) ?? []).some(machineShapedIdentifier));
764
+ }
765
+ /**
766
+ * A statement's own end (CAPTAIN-69): a run of terminal marks at its very end
767
+ * collapses to its first, and a statement with none gains a period. A run
768
+ * inside it — `main..HEAD`, `../config.yaml` — is evidence and stays.
769
+ */
770
+ function oneTerminalMark(text) {
771
+ const statement = text.trim().replace(/([.!?])[.!?]+$/, '$1');
772
+ return /[.!?]$/.test(statement) ? statement : `${statement}.`;
773
+ }
774
+ /** `text` as one Markdown code span, unchanged whatever backticks it holds. */
775
+ function codeSpan(text) {
776
+ const longest = Math.max(0, ...[...text.matchAll(/`+/g)].map(([run]) => run.length));
777
+ const fence = '`'.repeat(longest + 1);
778
+ const pad = text.startsWith('`') || text.endsWith('`') ? ' ' : '';
779
+ return `${fence}${pad}${text}${pad}${fence}`;
780
+ }
781
+ /**
782
+ * The code spans of `text` as CommonMark reads them: a backtick run opens one
783
+ * that the next run of the same length closes, and one space padding both of
784
+ * its ends is no part of what it shows.
785
+ */
786
+ function codeSpans(text) {
787
+ const runs = [...text.matchAll(/`+/g)].map((match) => ({
788
+ at: match.index ?? 0,
789
+ length: match[0].length,
790
+ }));
791
+ const spans = [];
792
+ for (let index = 0; index < runs.length; index += 1) {
793
+ const open = runs[index];
794
+ const offset = runs
795
+ .slice(index + 1)
796
+ .findIndex(({ length }) => length === open.length);
797
+ if (offset < 0)
798
+ continue;
799
+ const close = runs[index + 1 + offset];
800
+ const inner = text.slice(open.at + open.length, close.at);
801
+ spans.push({
802
+ start: open.at,
803
+ end: close.at + close.length,
804
+ content: inner.length > 2 &&
805
+ inner.startsWith(' ') &&
806
+ inner.endsWith(' ') &&
807
+ inner.trim().length > 0
808
+ ? inner.slice(1, -1)
809
+ : inner,
810
+ });
811
+ index += 1 + offset;
812
+ }
813
+ return spans;
814
+ }
815
+ /** `text` with `map` applied to what stands outside its code spans. */
816
+ function outsideCodeSpans(text, map) {
817
+ let mapped = '';
818
+ let at = 0;
819
+ for (const span of codeSpans(text)) {
820
+ mapped += map(text.slice(at, span.start)) + text.slice(span.start, span.end);
821
+ at = span.end;
822
+ }
823
+ return mapped + map(text.slice(at));
824
+ }
825
+ function sentenceCase(text) {
826
+ return text.charAt(0).toUpperCase() + text.slice(1);
827
+ }
828
+ const SPEAKABLE_ANYWHERE = () => true;
829
+ /**
830
+ * CAPTAIN-76/74: a recorded reason or message, its control characters spaced,
831
+ * its ends trimmed, and its length bounded, is said by its phrase where the
832
+ * runtime minted it from a closed set; otherwise it is quoted where it reads
833
+ * as prose — and only where the shell may say it outside a code span — and is
834
+ * otherwise kept whole as recorded; `undefined` where nothing is left of it.
835
+ */
836
+ function recordedReason(text, speakable = SPEAKABLE_ANYWHERE) {
837
+ const reason = compactEvidence(text);
838
+ if (reason.length === 0)
839
+ return undefined;
840
+ const minted = runtimeReasonPhrase(reason);
841
+ if (minted !== undefined)
842
+ return { kind: 'phrase', text: minted };
843
+ return readsAsProse(reason) && speakable(reason)
844
+ ? { kind: 'phrase', text: oneTerminalMark(reason) }
845
+ : { kind: 'code', text: reason };
846
+ }
847
+ /**
848
+ * `<subject> <outcome>: <reason>` for words the Boss reads as they are,
849
+ * `<subject> <outcome> with the <noun> `<reason>`.` for a reason kept as
850
+ * recorded, and `<subject> <outcome>.` where there is none.
851
+ */
852
+ function reasonedClause(subject, outcome, noun, reason) {
853
+ if (reason === undefined)
854
+ return `${subject} ${outcome}.`;
855
+ return reason.kind === 'phrase'
856
+ ? `${subject} ${outcome}: ${reason.text}`
857
+ : `${subject} ${outcome} with the ${noun} ${codeSpan(reason.text)}.`;
858
+ }
859
+ function failureClause(subject, reason) {
860
+ return reasonedClause(subject, 'failed', 'error', reason);
861
+ }
862
+ /** The Boss phrase of each cause code, with its bounded evidence (DR-063 §4). */
863
+ function causeReason(cause, describePlaybook, speakable = SPEAKABLE_ANYWHERE) {
864
+ const phrase = (text) => ({ kind: 'phrase', text });
568
865
  const evidence = cause.evidence;
569
866
  const paths = evidence.paths;
570
867
  const code = cause.code;
571
868
  switch (code) {
572
869
  case 'commit-missing':
573
- return (paths?.uncommitted ?? []).length === 0
870
+ return phrase((paths?.uncommitted ?? []).length === 0
574
871
  ? '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)}.`;
872
+ : `the step had to commit its work and committed nothing; these changes are uncommitted: ${causePathList(paths?.uncommitted, paths?.truncated)}.`);
576
873
  case 'commit-residual': {
577
874
  const subject = evidence.commitOid === undefined
578
875
  ? "the step's commit"
579
876
  : `the commit ${evidence.commitOid}`;
580
877
  if ((paths?.uncommitted ?? []).length > 0) {
581
- return `${subject} left changes uncommitted: ${causePathList(paths?.uncommitted, paths?.truncated)}.`;
878
+ return phrase(`${subject} left changes uncommitted: ${causePathList(paths?.uncommitted, paths?.truncated)}.`);
582
879
  }
583
880
  if ((paths?.altered ?? []).length > 0) {
584
- return `${subject} left altered changes uncommitted: ${causePathList(paths?.altered, paths?.truncated)}.`;
881
+ return phrase(`${subject} left altered changes uncommitted: ${causePathList(paths?.altered, paths?.truncated)}.`);
585
882
  }
586
- return `${subject} left changes uncommitted beside it.`;
883
+ return phrase(`${subject} left changes uncommitted beside it.`);
587
884
  }
588
885
  case 'pre-existing-lost':
589
- return `uncommitted changes you had were lost: ${causePathList(paths?.lost, paths?.truncated)}.`;
886
+ return phrase(`uncommitted changes you had were lost: ${causePathList(paths?.lost, paths?.truncated)}.`);
590
887
  case 'commits-more-than-one':
591
- return `the step made more than one commit; HEAD moved from ${evidence.baselineHead} to ${evidence.afterHead}.`;
888
+ return phrase(`the step made more than one commit; HEAD moved from ${evidence.baselineHead} to ${evidence.afterHead}.`);
592
889
  case 'history-rewritten':
593
- return `the repository history was rewritten; HEAD moved from ${evidence.baselineHead} to ${evidence.afterHead}.`;
890
+ return phrase(`the repository history was rewritten; HEAD moved from ${evidence.baselineHead} to ${evidence.afterHead}.`);
594
891
  case 'foreign-change':
595
- return `the repository changed outside this step: ${causePathList(paths?.changed, paths?.truncated)}.`;
892
+ return phrase(`the repository changed outside this step: ${causePathList(paths?.changed, paths?.truncated)}.`);
596
893
  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}.`;
894
+ return phrase(`the repository could not be observed after the step; the repository stood at ${evidence.baselineHead} before it.`);
895
+ case 'attribution-ambiguous': {
896
+ const required = evidence.required === undefined
897
+ ? undefined
898
+ : REQUIRED_DISPOSITION_PHRASES[evidence.required];
899
+ const observed = evidence.observed === undefined
900
+ ? undefined
901
+ : OBSERVED_CLASSIFICATION_PHRASES[evidence.observed];
902
+ return phrase(required === undefined || observed === undefined
903
+ ? 'the repository change could not be attributed to this step.'
904
+ : `the repository change could not be attributed to this step: it should have left ${required} but left ${observed}.`);
905
+ }
600
906
  case 'receipt-missing':
601
- return `a step left no complete repository receipt; the repository stood at ${evidence.baselineHead} before it.`;
907
+ return phrase(`the step's effect on the repository was not fully recorded; the repository stood at ${evidence.baselineHead} before it.`);
602
908
  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)}).`;
909
+ // The runtime's own closed reasons stay in the cause's data.
910
+ return phrase(UNDECIDED_OUTCOME_SENTENCE);
606
911
  case 'player-failed':
607
- return `the ${evidence.roleId} call failed: ${compactEvidence(evidence.error?.message ?? 'no detail was reported')}.`;
912
+ // The player's message says why, as any recorded message does: by the
913
+ // runtime's phrase where it minted the message, else as prose or code.
914
+ return phrase(failureClause(`the ${evidence.roleId} call`, recordedReason(evidence.error?.message ?? '', speakable)));
608
915
  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
- }
916
+ return phrase('the turn was aborted before the work settled.');
917
+ case 'child-failed':
918
+ return phrase(failureClause(`the nested ${describePlaybook(evidence.playbookId ?? '')} run`, evidence.cause === undefined
919
+ ? undefined
920
+ : causeReason(evidence.cause, describePlaybook, speakable)));
616
921
  case 'runtime-defect':
617
- return `the runtime could not settle the step: ${compactEvidence(evidence.reason ?? 'no reason was recorded')}.`;
922
+ // No more specific code: a reason the runtime minted is said by its
923
+ // phrase, and any other reason as it was recorded.
924
+ return recordedReason(evidence.reason ?? '', speakable);
925
+ }
926
+ }
927
+ /**
928
+ * Why a recorded failure failed: the phrase of the cause it carries where the
929
+ * closed validator accepts one, and otherwise its recorded message.
930
+ */
931
+ function failureReason(failure, describePlaybook, speakable = SPEAKABLE_ANYWHERE) {
932
+ if (failure === undefined)
933
+ return undefined;
934
+ if (failure.cause !== undefined) {
935
+ let cause;
936
+ try {
937
+ cause = assertPlaybookFailureCause(failure.cause);
938
+ }
939
+ catch {
940
+ // A cause the closed validator refuses is not a cause.
941
+ }
942
+ if (cause !== undefined) {
943
+ return causeReason(cause, describePlaybook, speakable);
944
+ }
945
+ }
946
+ return failure.message === undefined
947
+ ? undefined
948
+ : recordedReason(failure.message, speakable);
949
+ }
950
+ /**
951
+ * CAPTAIN-71: the one way a failure is put into words — `<Subject> failed:
952
+ * <reason>.` — `subject` naming what failed in the Boss's words and `failure`
953
+ * the error or cause the shell recorded.
954
+ */
955
+ function failureStatement(subject, failure, describePlaybook = (playbookId) => playbookId, speakable = SPEAKABLE_ANYWHERE) {
956
+ return sentenceCase(failureClause(subject, failureReason(failure, describePlaybook, speakable)));
957
+ }
958
+ /** The runtime's refusal of `action`, its reason said as a failure's is. */
959
+ function refusalStatement(action, reason) {
960
+ return sentenceCase(reasonedClause('the runtime', `refused ${action}`, 'reason', reason));
961
+ }
962
+ /**
963
+ * The `Failure:` line of a parked failure (CAPTAIN-67): the reason's phrase,
964
+ * or, for a reason kept as recorded, the step's failure statement; and the
965
+ * reason it states, which a statement carrying the same failure holds.
966
+ */
967
+ function failureLine(reason) {
968
+ if (reason?.kind === 'phrase') {
969
+ return { sentence: reason.text, reason: reason.text };
618
970
  }
971
+ const sentence = failureClause('the step', reason);
972
+ return {
973
+ sentence,
974
+ reason: reason === undefined ? sentence : codeSpan(reason.text),
975
+ };
619
976
  }
620
977
  function failureControlLine(action) {
621
978
  // DR-063 §3: an action that publishes no standing is `ready`.
@@ -624,20 +981,163 @@ function failureControlLine(action) {
624
981
  ? `- ${action.label} (ready)`
625
982
  : `- ${action.label} (${standing}: ${controlReasonPhrase(action.reason)})`;
626
983
  }
627
- function failureReportText(sentence, controls) {
628
- return [
629
- `Failure: ${sentence}`,
630
- 'Controls:',
631
- ...(controls.length === 0 ? ['- none'] : controls),
632
- ].join('\n');
984
+ /**
985
+ * Whether `container` already states `statement`: the same words, case and
986
+ * terminal mark aside, standing as a phrase of their own rather than inside a
987
+ * longer word or command.
988
+ */
989
+ function alreadyStates(container, statement) {
990
+ const haystack = container.toLowerCase();
991
+ const needle = statement.toLowerCase().replace(/[.!?]+$/, '');
992
+ if (needle.length === 0)
993
+ return true;
994
+ for (let at = haystack.indexOf(needle); at >= 0; at = haystack.indexOf(needle, at + 1)) {
995
+ const before = haystack.charAt(at - 1);
996
+ const after = haystack.charAt(at + needle.length);
997
+ if (!/[\p{L}\p{N}_/-]/u.test(before) && !/[\p{L}\p{N}_-]/u.test(after)) {
998
+ return true;
999
+ }
1000
+ }
1001
+ return false;
633
1002
  }
634
- function appendMandatoryPresentationSuffix(turn, suffix) {
635
- const current = turn.mandatoryPresentationSuffix;
636
- if (current === undefined) {
637
- turn.mandatoryPresentationSuffix = suffix;
1003
+ /** The words a reason CAPTAIN-9 withholds is said as. */
1004
+ const WITHHELD_REASON = 'an internal error';
1005
+ /** `text` with each of its code spans read as a space. */
1006
+ function wordsOutsideCodeSpans(text) {
1007
+ let words = '';
1008
+ let at = 0;
1009
+ for (const span of codeSpans(text)) {
1010
+ words += `${text.slice(at, span.start)} `;
1011
+ at = span.end;
1012
+ }
1013
+ return words + text.slice(at);
1014
+ }
1015
+ const NO_SHELL_WORDS = { subjects: [], labels: [] };
1016
+ /**
1017
+ * CAPTAIN-69's guard on one statement the shell says, so no statement is
1018
+ * lost for its own or another's words: a code span whose content repeats an
1019
+ * identifier CAPTAIN-9 withholds reads, with the noun before it, `an internal
1020
+ * error`; a reason the renderer phrased whose own words fail CAPTAIN-9 reads
1021
+ * so too; a failure subject beginning the statement whose own words fail
1022
+ * reads `The step`; and a quoted action label whose own words fail reads `the
1023
+ * selected action`. Whatever is left, the statement is never left out.
1024
+ */
1025
+ function guardedStatement(text, withholds, refuses, phrased = [], shellWords = NO_SHELL_WORDS) {
1026
+ let guarded = '';
1027
+ let at = 0;
1028
+ for (const span of codeSpans(text)) {
1029
+ const before = text.slice(at, span.start);
1030
+ guarded += withholds(span.content)
1031
+ ? `${before.replace(/the (?:error|reason) $/, '')}${WITHHELD_REASON}`
1032
+ : before + text.slice(span.start, span.end);
1033
+ at = span.end;
638
1034
  }
639
- else if (!current.includes(suffix)) {
640
- turn.mandatoryPresentationSuffix = `${current}\n\n${suffix}`;
1035
+ guarded += text.slice(at);
1036
+ const fails = (statement) => {
1037
+ const words = wordsOutsideCodeSpans(statement);
1038
+ return words.trim().length > 0 && refuses(words);
1039
+ };
1040
+ for (const reason of phrased) {
1041
+ if (!fails(reason))
1042
+ continue;
1043
+ guarded =
1044
+ guarded === `Failure: ${reason}`
1045
+ ? `Failure: the step failed with ${WITHHELD_REASON}.`
1046
+ : guarded.split(`: ${reason}`).join(` with ${WITHHELD_REASON}.`);
1047
+ }
1048
+ for (const subject of shellWords.subjects) {
1049
+ if (guarded.startsWith(`${subject} `) && fails(subject)) {
1050
+ guarded = `The step${guarded.slice(subject.length)}`;
1051
+ }
1052
+ }
1053
+ for (const label of shellWords.labels) {
1054
+ if (fails(label)) {
1055
+ guarded = guarded.split(label).join('the selected action');
1056
+ }
1057
+ }
1058
+ return guarded;
1059
+ }
1060
+ /**
1061
+ * CAPTAIN-69: the one way the shell assembles Boss-facing text of its own.
1062
+ * Each statement ends in one terminal mark and passes `guard`. The kinds
1063
+ * decide what is dropped: an unresolved-effect entry records a boundary of
1064
+ * its own and always stays, while a fact or the `Failure:` line is dropped
1065
+ * where an entry, or a fact before it that stays, carries its reason — for
1066
+ * the `Failure:` line the reason it states, for a fact every reason the
1067
+ * renderer gave that turn (`reasons`) that it holds, else the fact itself.
1068
+ * The result is aligned with `statements`, a dropped statement `undefined`.
1069
+ */
1070
+ function assembleStatements(statements, reasons = [], guard = (text) => text) {
1071
+ const rendered = [...new Set(reasons)];
1072
+ const weighed = statements.map(({ text, kind = 'fact', reason }) => {
1073
+ const statement = oneTerminalMark(text);
1074
+ if (reason !== undefined)
1075
+ return { text: statement, kind, own: [reason] };
1076
+ const held = rendered.filter((candidate) => alreadyStates(statement, candidate));
1077
+ return { text: statement, kind, own: held.length > 0 ? held : [statement] };
1078
+ });
1079
+ const entries = weighed
1080
+ .filter(({ kind }) => kind === 'entry')
1081
+ .map(({ text }) => text);
1082
+ const facts = [];
1083
+ const carried = (own, containers) => containers.some((container) => own.every((reason) => alreadyStates(container, reason)));
1084
+ const stays = weighed.map(({ text, kind, own }) => {
1085
+ if (kind !== 'fact')
1086
+ return true;
1087
+ if (carried(own, entries) || carried(own, facts))
1088
+ return false;
1089
+ facts.push(text);
1090
+ return true;
1091
+ });
1092
+ return weighed.map(({ text, kind, own }, index) => stays[index] &&
1093
+ (kind !== 'failure' || !carried(own, [...entries, ...facts]))
1094
+ ? oneTerminalMark(guard(text))
1095
+ : undefined);
1096
+ }
1097
+ /** The statements of `statements` that stay, as CAPTAIN-69 assembles them. */
1098
+ function bossReport(statements, reasons = []) {
1099
+ return assembleStatements(statements.map((statement) => typeof statement === 'string' ? { text: statement } : statement), reasons).filter((statement) => statement !== undefined);
1100
+ }
1101
+ /**
1102
+ * The reports that accompany a reply, from the turn's assembled statements
1103
+ * (CAPTAIN-69): a report whose facts the reply lists is not said again, the
1104
+ * unresolved-effect report carries every entry as guarded, and the failure
1105
+ * report's `Controls:` list always stays.
1106
+ */
1107
+ function presentationSuffix(turn, assembled) {
1108
+ const parts = turn.presentationReports.flatMap((report) => {
1109
+ if (report.kind === 'stated') {
1110
+ return report.facts.length > 0 &&
1111
+ report.facts.every((fact) => assembled.facts.some((listed) => alreadyStates(listed, fact)))
1112
+ ? []
1113
+ : [report.text];
1114
+ }
1115
+ return assembled.effects.length === 0
1116
+ ? []
1117
+ : [unresolvedEffectBossReport(assembled.effects)];
1118
+ });
1119
+ const failure = turn.failureReport;
1120
+ if (failure !== undefined) {
1121
+ parts.push([
1122
+ ...(assembled.failure === undefined ? [] : [assembled.failure]),
1123
+ 'Controls:',
1124
+ ...(failure.controls.length === 0 ? ['- none'] : failure.controls),
1125
+ ].join('\n'));
1126
+ }
1127
+ return parts.length === 0 ? undefined : parts.join('\n\n');
1128
+ }
1129
+ function appendMandatoryPresentationSuffix(turn, suffix, facts = []) {
1130
+ if (!turn.presentationReports.some((report) => report.kind === 'stated' && report.text.includes(suffix))) {
1131
+ turn.presentationReports.push({ kind: 'stated', text: suffix, facts });
1132
+ }
1133
+ }
1134
+ function appendUnresolvedEffectReport(turn, unresolvedEffects) {
1135
+ if (unresolvedEffects.length > 0 &&
1136
+ !turn.presentationReports.some((report) => report.kind === 'effects')) {
1137
+ turn.presentationReports.push({
1138
+ kind: 'effects',
1139
+ lines: unresolvedEffectReportLines(unresolvedEffects),
1140
+ });
641
1141
  }
642
1142
  }
643
1143
  // CAPTAIN-20: the result-phase block the shell supplies inside the closing
@@ -2180,21 +2680,46 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2180
2680
  if (report === undefined)
2181
2681
  return;
2182
2682
  turn.carriedPreExistingReported = true;
2183
- appendMandatoryPresentationSuffix(turn, report);
2184
2683
  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
- };
2684
+ appendMandatoryPresentationSuffix(turn, report, facts);
2685
+ for (const fact of facts) {
2686
+ joinSettlementFact(turn, fact);
2687
+ }
2688
+ };
2689
+ /**
2690
+ * CAPTAIN-67: one fact joins the turn once. The action's own report was
2691
+ * assembled from the facts as they stood when it settled, and the
2692
+ * result-phase prompt reads that report, so a later fact joins it as well as
2693
+ * the turn's running list — which that report's `facts` often *is*, so
2694
+ * appending to both stated the fact twice. A fact that restates one already
2695
+ * listed for the same failure takes that fact's place.
2696
+ */
2697
+ const joinSettlementFact = (turn, fact, replacing, speak = (text) => text) => {
2698
+ const placed = (list, entry, replaced) => {
2699
+ if (list.includes(entry))
2700
+ return list;
2701
+ const at = replaced === undefined ? -1 : list.indexOf(replaced);
2702
+ return at < 0
2703
+ ? [...list, entry]
2704
+ : list.map((listed, index) => (index === at ? entry : listed));
2705
+ };
2706
+ const running = placed(turn.settlementFacts, fact, replacing);
2707
+ if (running !== turn.settlementFacts) {
2708
+ turn.settlementFacts.splice(0, turn.settlementFacts.length, ...running);
2197
2709
  }
2710
+ const report = turn.report;
2711
+ if (report === undefined)
2712
+ return;
2713
+ // The Boss-facing rendering holds the same fact as the Boss reads it.
2714
+ turn.report = {
2715
+ ...report,
2716
+ facts: placed(report.facts, fact, replacing),
2717
+ ...(report.bossFacts === undefined
2718
+ ? {}
2719
+ : {
2720
+ bossFacts: placed(report.bossFacts, speak(fact), replacing === undefined ? undefined : speak(replacing)),
2721
+ }),
2722
+ };
2198
2723
  };
2199
2724
  const normalizeInstalledRetainedGenerations = (value) => {
2200
2725
  const path = 'Captain retained generations';
@@ -2631,6 +3156,18 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2631
3156
  }
2632
3157
  return { name: 'Error', message: String(value) };
2633
3158
  };
3159
+ /**
3160
+ * CAPTAIN-71: what the shell recorded of a thrown value — its message, and
3161
+ * the cause the runtime decided where the value carries one (DR-063 §2).
3162
+ */
3163
+ const failureOf = (error) => {
3164
+ const normalized = normalizeErrorCompact(error) ?? {
3165
+ name: 'Error',
3166
+ message: String(error),
3167
+ };
3168
+ const { cause } = normalizeError(error);
3169
+ return cause === undefined ? normalized : { ...normalized, cause };
3170
+ };
2634
3171
  const payloadRecord = (payload) => typeof payload === 'object' && payload !== null && !Array.isArray(payload)
2635
3172
  ? payload
2636
3173
  : undefined;
@@ -3638,11 +4175,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3638
4175
  }
3639
4176
  else {
3640
4177
  if (runFailureFacts) {
3641
- const normalized = normalizeErrorCompact(error) ?? {
3642
- name: 'Error',
3643
- message: String(error),
3644
- };
3645
- runFailureFacts.push(`Cleanup while removing ${frameLabel(child)} failed: ${normalized.name}: ${compactEvidence(normalized.message)}.`);
4178
+ runFailureFacts.push(stateFailure(`cleanup after ${frameLabel(child)}`, failureOf(error)));
3646
4179
  }
3647
4180
  effectiveResult = {
3648
4181
  status: context.signal.aborted ? 'aborted' : 'error',
@@ -3727,10 +4260,14 @@ export function createPlaybookCaptainShell(options, deps = {}) {
3727
4260
  runFailureFacts.push(`${frameLabel(frame)} was aborted before its outcome could be confirmed; it was not repeated automatically.`);
3728
4261
  }
3729
4262
  if (result.outcome === 'failed' && runFailureFacts) {
3730
- runFailureFacts.push(`${frameLabel(frame)} failed` +
3731
- (result.error
3732
- ? `: ${result.error.name}: ${compactEvidence(result.error.message)}.`
3733
- : '.'));
4263
+ const subject = frameLabel(frame);
4264
+ const fact = stateFailure(subject, result.error);
4265
+ runFailureFacts.push(fact);
4266
+ activeTurn?.failureStatements.push({
4267
+ frame,
4268
+ fact,
4269
+ restate: (failure) => stateFailure(subject, failure),
4270
+ });
3734
4271
  }
3735
4272
  if (leafFrame()) {
3736
4273
  await setMode('engaged.parked', `turn:${result.outcome}`);
@@ -4309,7 +4846,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4309
4846
  // all of this prose. Preserve the exact attempt before crossing the
4310
4847
  // boundary; the uncertainty record below keeps recovery from pretending
4311
4848
  // delivery was confirmed while still understanding a Boss follow-up.
4312
- const suffix = turn?.mandatoryPresentationSuffix;
4849
+ const suffix = turn === undefined
4850
+ ? undefined
4851
+ : presentationSuffix(turn, assembleTurnReport(turn, settlement.stated ?? []));
4313
4852
  const visibleText = suffix === undefined || settlement.text.includes(suffix)
4314
4853
  ? settlement.text
4315
4854
  : `${settlement.text.trimEnd()}\n\n${suffix}`;
@@ -4400,6 +4939,53 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4400
4939
  // The one predicate every Boss-visible Captain reply passes, whether the
4401
4940
  // words came from the model or the shell's conservative failure fallback.
4402
4941
  const replyRejection = (prose) => proseRejection(prose, liveSessionIdentifiers(), liveStateIdentifiers(), suppliedIdentifiers());
4942
+ // CAPTAIN-74: whether a recorded reason may be quoted outside a code span —
4943
+ // what the renderer asks before it quotes one as prose.
4944
+ const speakableWords = (words) => replyRejection(words) === undefined;
4945
+ // CAPTAIN-69: the guard every statement the shell says passes on its own —
4946
+ // CAPTAIN-9 outside its code spans, and inside one only its identifiers —
4947
+ // with the reasons the renderer gave that turn and the shell words it may
4948
+ // say neutrally.
4949
+ const statementGuard = (text, turn) => guardedStatement(text, (content) => identifierRejection(content, liveSessionIdentifiers(), liveStateIdentifiers(), suppliedIdentifiers()) !== undefined, (words) => replyRejection(words) !== undefined, turn.statedReasons, turn.shellWords);
4950
+ /** A reason the renderer gave a statement this turn, noted for the assembly. */
4951
+ const noteReason = (reason) => {
4952
+ if (reason !== undefined) {
4953
+ activeTurn?.statedReasons.add(reason.kind === 'phrase' ? reason.text : codeSpan(reason.text));
4954
+ }
4955
+ return reason;
4956
+ };
4957
+ /**
4958
+ * CAPTAIN-69: the turn's shell-said statements — the facts a reply lists,
4959
+ * the unresolved-effect entries, and the `Failure:` line — each said once.
4960
+ */
4961
+ const assembleTurnReport = (turn, facts) => {
4962
+ const effects = turn.presentationReports.flatMap((report) => report.kind === 'effects' ? report.lines : []);
4963
+ const failure = turn.failureReport;
4964
+ const said = assembleStatements([
4965
+ ...facts.map((text) => ({ text, kind: 'fact' })),
4966
+ ...effects.map((text) => ({ text, kind: 'entry' })),
4967
+ ...(failure === undefined
4968
+ ? []
4969
+ : [
4970
+ {
4971
+ text: `Failure: ${failure.sentence}`,
4972
+ kind: 'failure',
4973
+ reason: failure.reason,
4974
+ },
4975
+ ]),
4976
+ ], turn.statedReasons, (text) => statementGuard(text, turn));
4977
+ const kept = (from, to) => said
4978
+ .slice(from, to)
4979
+ .filter((statement) => statement !== undefined);
4980
+ const listed = facts.length + effects.length;
4981
+ return {
4982
+ facts: kept(0, facts.length),
4983
+ effects: kept(facts.length, listed),
4984
+ ...(failure === undefined || said[listed] === undefined
4985
+ ? {}
4986
+ : { failure: said[listed] }),
4987
+ };
4988
+ };
4403
4989
  // -------------------------------------------------------------------------
4404
4990
  // The durable conversation (CAPTAIN-31, CAPTAIN-35).
4405
4991
  // -------------------------------------------------------------------------
@@ -4627,7 +5213,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4627
5213
  // Captain speech (DR-029): all durable calls are hidden; the shell
4628
5214
  // validates the returned prose and surfaces it through `emitReply`.
4629
5215
  const surfaceProse = async (context, outcome, compose) => {
4630
- let text = outcome.finalText;
5216
+ let text = respondEnvelopeText(outcome.finalText);
4631
5217
  const rejection = replyRejection(text);
4632
5218
  if (rejection !== undefined) {
4633
5219
  // DR-028 §26: the reseed already was this call's single corrective, so a
@@ -4637,7 +5223,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4637
5223
  }
4638
5224
  // DR-028's single corrective re-ask on the same durable conversation.
4639
5225
  const reasked = await durableCall(context, (options) => compose({ ...options, proseRejection: rejection }));
4640
- text = reasked.finalText;
5226
+ text = respondEnvelopeText(reasked.finalText);
4641
5227
  const second = replyRejection(text);
4642
5228
  if (second !== undefined) {
4643
5229
  throw markControlFailure(new CaptainProseError(second));
@@ -4720,8 +5306,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4720
5306
  // Through the one presentation seam, exactly where a model-composed
4721
5307
  // reply is presented, so this reply is journaled and single-attempt
4722
5308
  // like every other. It needs no reply validation or corrective re-ask:
4723
- // `giveUpReplyText` already validated what it composed.
4724
- await surfaceSettlement({ context, text: giveUpReplyText() });
5309
+ // every statement `giveUpReply` lists passed CAPTAIN-69's guard.
5310
+ await surfaceSettlement({ context, ...giveUpReply() });
4725
5311
  return { status: 'ok', finalText: 'ok' };
4726
5312
  }
4727
5313
  const outcome = await durableCall(context, compose);
@@ -4881,7 +5467,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4881
5467
  const summary = leafStateSummary();
4882
5468
  const settlement = {
4883
5469
  status: 'rejected',
4884
- facts: [`Rejected: ${reason}.`],
5470
+ facts: [`Rejected: ${oneTerminalMark(reason)}`],
4885
5471
  reason,
4886
5472
  ...(summary === undefined ? {} : { leafStateSummary: summary }),
4887
5473
  };
@@ -4945,11 +5531,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4945
5531
  }
4946
5532
  catch (error) {
4947
5533
  // A failing dispose never resurrects the engagement (DR-029).
4948
- const normalized = normalizeErrorCompact(error) ?? {
4949
- name: 'Error',
4950
- message: String(error),
4951
- };
4952
- facts.push(`Dismissed the ${label} engagement; its disposal failed: ${normalized.name}: ${compactEvidence(normalized.message)}.`);
5534
+ facts.push(stateFailure(`dismissing ${label}`, failureOf(error)));
4953
5535
  return true;
4954
5536
  }
4955
5537
  };
@@ -4959,11 +5541,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4959
5541
  frame = await runEffect(() => engage(entry));
4960
5542
  }
4961
5543
  catch (error) {
4962
- const normalized = normalizeErrorCompact(error) ?? {
4963
- name: 'Error',
4964
- message: String(error),
4965
- };
4966
- facts.push(`Starting /${enablementById.get(entry.id).command} failed: ${normalized.name}: ${compactEvidence(normalized.message)}.`);
5544
+ facts.push(stateFailure(`starting /${enablementById.get(entry.id).command}`, failureOf(error)));
4967
5545
  return { report: emptyReport(), failed: true };
4968
5546
  }
4969
5547
  facts.push(`Started ${frameLabel(frame)} with the selected request.`);
@@ -4975,11 +5553,17 @@ export function createPlaybookCaptainShell(options, deps = {}) {
4975
5553
  // composition error, never a settled Boss outcome.
4976
5554
  if (outcome.error instanceof VisibilityControlError)
4977
5555
  throw outcome.error;
4978
- const normalized = normalizeErrorCompact(outcome.error) ?? {
4979
- name: 'Error',
4980
- message: String(outcome.error),
4981
- };
4982
- facts.push(`The first turn of ${frameLabel(frame)} failed: ${normalized.name}: ${compactEvidence(normalized.message)}.`);
5556
+ const failure = failureOf(outcome.error);
5557
+ const subject = `the first turn of ${frameLabel(frame)}`;
5558
+ const restate = (recorded) => stateFailure(subject, recorded);
5559
+ const fact = restate(failure);
5560
+ facts.push(fact);
5561
+ activeTurn?.failureStatements.push({
5562
+ frame,
5563
+ fact,
5564
+ message: failure.message,
5565
+ restate,
5566
+ });
4983
5567
  return { frame, report: outcome.report, failed: true };
4984
5568
  }
4985
5569
  return { frame, report: outcome.report, failed: false };
@@ -5055,18 +5639,21 @@ export function createPlaybookCaptainShell(options, deps = {}) {
5055
5639
  await requestVisibility(adoptedFrames.at(-1));
5056
5640
  }
5057
5641
  await requireSession().emitStatus(`◇ /${enablementById.get(rootPlaybookId).command} resumed`);
5642
+ // The warning is both a settlement fact and a report the reply carries;
5643
+ // a reply that lists the fact need not say it twice (CAPTAIN-69).
5644
+ const warning = requiresEffectReconciliation
5645
+ ? 'The retained work remains parked until its repository-effect evidence is reconciled; no ordinary action was resumed.'
5646
+ : RESUMPTION_DUPLICATE_EFFECT_WARNING;
5058
5647
  if (activeTurn) {
5059
5648
  appendMandatoryPresentationSuffix(activeTurn, requiresEffectReconciliation
5060
5649
  ? 'The retained work remains parked until its repository-effect evidence is reconciled.'
5061
- : RESUMPTION_DUPLICATE_EFFECT_WARNING);
5650
+ : RESUMPTION_DUPLICATE_EFFECT_WARNING, [warning]);
5062
5651
  }
5063
5652
  return [
5064
5653
  generation.rootStateDescription === undefined
5065
5654
  ? `Resumed /${enablementById.get(rootPlaybookId).command} from its retained state; no published root-state description was retained.`
5066
5655
  : `Resumed /${enablementById.get(rootPlaybookId).command} from the retained state described as ${quoteEvidence(compactEvidence(generation.rootStateDescription))}.`,
5067
- requiresEffectReconciliation
5068
- ? 'The retained work remains parked until its repository-effect evidence is reconciled; no ordinary action was resumed.'
5069
- : RESUMPTION_DUPLICATE_EFFECT_WARNING,
5656
+ warning,
5070
5657
  ];
5071
5658
  }
5072
5659
  catch (error) {
@@ -5202,6 +5789,14 @@ export function createPlaybookCaptainShell(options, deps = {}) {
5202
5789
  const enablement = enablementById.get(playbookId);
5203
5790
  return enablement === undefined ? playbookId : `/${enablement.command}`;
5204
5791
  };
5792
+ /**
5793
+ * CAPTAIN-71: a failure statement naming nested playbooks by command, its
5794
+ * subject said as `the step` where CAPTAIN-9 refuses it (CAPTAIN-69).
5795
+ */
5796
+ const stateFailure = (subject, failure) => {
5797
+ activeTurn?.shellWords.subjects.add(sentenceCase(subject));
5798
+ return sentenceCase(failureClause(subject, noteReason(failureReason(failure, describeNestedPlaybook, speakableWords))));
5799
+ };
5205
5800
  // DR-063 §4: appended once, beside the unresolved-effect and pre-existing
5206
5801
  // change reports, whenever the turn settles with the leaf parked in its
5207
5802
  // failure state or behind the retained-effect fence.
@@ -5227,21 +5822,26 @@ export function createPlaybookCaptainShell(options, deps = {}) {
5227
5822
  if (cause === undefined)
5228
5823
  return;
5229
5824
  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
- }
5825
+ const failure = { cause };
5826
+ // One failure, one fact: where a fact this turn already stated the leaf's
5827
+ // parked failure — its failed run result, or a runtime action's failed
5828
+ // receipt or a thrown first turn, delivery, or runtime action whose error
5829
+ // is the one the leaf records — the cause restates that fact in its place
5830
+ // rather than joining beside it.
5831
+ const recorded = (view?.lastError ?? lastError)?.message;
5832
+ const statement = turn.failureStatements.find(({ frame, message }) => frame === leaf &&
5833
+ (message === undefined ||
5834
+ (recorded !== undefined &&
5835
+ compactEvidence(message) === compactEvidence(recorded))));
5836
+ const fact = statement === undefined
5837
+ ? stateFailure(frameLabel(leaf), failure)
5838
+ : statement.restate(failure);
5839
+ const speak = statement?.speak ?? ((text) => text);
5840
+ turn.failureReport = {
5841
+ ...failureLine(noteReason(failureReason(failure, describeNestedPlaybook, speakableWords))),
5842
+ controls: failureControlLines(view, fenced),
5843
+ };
5844
+ joinSettlementFact(turn, fact, statement?.fact, speak);
5245
5845
  };
5246
5846
  const settleSelection = async (selection, signal) => {
5247
5847
  const turn = activeTurn;
@@ -5249,9 +5849,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
5249
5849
  let frozenUnresolvedEffects;
5250
5850
  const freezeControllerEvidence = () => {
5251
5851
  frozenUnresolvedEffects ??= freezeTurnUnresolvedEffects();
5252
- const report = unresolvedEffectBossReport(frozenUnresolvedEffects);
5253
- if (turn !== undefined && report !== undefined) {
5254
- appendMandatoryPresentationSuffix(turn, report);
5852
+ if (turn !== undefined) {
5853
+ appendUnresolvedEffectReport(turn, frozenUnresolvedEffects);
5255
5854
  }
5256
5855
  if (turn !== undefined)
5257
5856
  reportCarriedPreExistingChanges(turn);
@@ -5277,10 +5876,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
5277
5876
  if (turn?.presentationError === error)
5278
5877
  throw error;
5279
5878
  const aborted = signal.aborted || activeContext?.signal.aborted === true;
5280
- const normalized = normalizeErrorCompact(error) ?? {
5281
- name: 'Error',
5282
- message: String(error),
5283
- };
5879
+ const failure = failureOf(error);
5284
5880
  if (aborted) {
5285
5881
  markConversationUnsynchronized();
5286
5882
  if (turn?.outcomePending) {
@@ -5296,9 +5892,24 @@ export function createPlaybookCaptainShell(options, deps = {}) {
5296
5892
  }
5297
5893
  const mayHaveApplied = selection.action !== 'respond' &&
5298
5894
  containsEffectThrow(turn, error);
5299
- turn.settlementFacts.push(mayHaveApplied
5300
- ? `The ${selection.action} action failed before its complete outcome could be confirmed and may have changed the session: ${normalized.name}: ${compactEvidence(normalized.message)}. It was not repeated automatically.`
5301
- : `The ${selection.action} action failed before its complete outcome could be confirmed: ${normalized.name}: ${compactEvidence(normalized.message)}. It was not repeated automatically.`);
5895
+ const subject = `the ${selection.action} action`;
5896
+ const unconfirmed = mayHaveApplied
5897
+ ? 'Its complete outcome could not be confirmed, and it may have changed the session. It was not repeated automatically.'
5898
+ : 'Its complete outcome could not be confirmed. It was not repeated automatically.';
5899
+ const restate = (recorded) => `${stateFailure(subject, recorded)} ${unconfirmed}`;
5900
+ const fact = restate(failure);
5901
+ turn.settlementFacts.push(fact);
5902
+ // A delivery or runtime action acts on the leaf, so the leaf's parked
5903
+ // failure may be this very error (CAPTAIN-67).
5904
+ const failedLeaf = leafFrame();
5905
+ if (failedLeaf !== undefined) {
5906
+ turn.failureStatements.push({
5907
+ frame: failedLeaf,
5908
+ fact,
5909
+ message: failure.message,
5910
+ restate,
5911
+ });
5912
+ }
5302
5913
  if (!turn.outcomePending && !turn.outcomeRecorded) {
5303
5914
  journalAction({
5304
5915
  action: selection.action,
@@ -5375,11 +5986,12 @@ export function createPlaybookCaptainShell(options, deps = {}) {
5375
5986
  journalAction({ action: 'respond' });
5376
5987
  const reask = decisionCall;
5377
5988
  if (reask === undefined) {
5378
- const rejection = replyRejection(selection.text);
5989
+ const text = respondEnvelopeText(selection.text);
5990
+ const rejection = replyRejection(text);
5379
5991
  if (rejection !== undefined) {
5380
5992
  throw markControlFailure(new CaptainProseError(rejection));
5381
5993
  }
5382
- await surfaceSettlement({ context, text: selection.text });
5994
+ await surfaceSettlement({ context, text: text });
5383
5995
  }
5384
5996
  else {
5385
5997
  await surfaceProse(context, { ...reask.outcome, finalText: selection.text }, reask.compose);
@@ -5630,11 +6242,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
5630
6242
  .actions.find((action) => action.id === actionId);
5631
6243
  }
5632
6244
  catch (error) {
5633
- const normalized = normalizeErrorCompact(error) ?? {
5634
- name: 'Error',
5635
- message: String(error),
5636
- };
5637
- return rejectSelection(selection, `${frameLabel(leaf)} could not be asked which actions it offers: ${normalized.name}: ${compactEvidence(normalized.message)}`);
6245
+ return rejectSelection(selection, stateFailure(`asking ${frameLabel(leaf)} which actions it offers`, failureOf(error)));
5638
6246
  }
5639
6247
  if (advertised === undefined) {
5640
6248
  // CAPPLAY-5: the chosen id is control data whether or not the leaf
@@ -5644,6 +6252,16 @@ export function createPlaybookCaptainShell(options, deps = {}) {
5644
6252
  return rejectSelection(selection, `${frameLabel(leaf)} does not advertise that action`);
5645
6253
  }
5646
6254
  const actionLabel = advertised.label;
6255
+ // The settlement facts are shell-internal strings composed for the hidden
6256
+ // result-phase prompt: they name the action by its id, which is control
6257
+ // data. The Boss-facing rendering of the same settlement names it by the
6258
+ // runtime's own Boss-appropriate label (PBRT-52), for the one place these
6259
+ // facts are spoken rather than prompted — the CAPTAIN-34 fallback.
6260
+ // A code span is the recorded text as recorded (CAPTAIN-74), so only the
6261
+ // words outside one are renamed.
6262
+ const spokenLabel = `"${compactEvidence(actionLabel)}"`;
6263
+ turn.shellWords.labels.add(spokenLabel);
6264
+ const speak = (fact) => outsideCodeSpans(fact, (words) => words.split(`"${actionId}"`).join(spokenLabel));
5647
6265
  // The id the digest advertised and the reply selected by is now also the
5648
6266
  // id this turn's outcome-report facts carry (CAPTAIN-9): recording it here
5649
6267
  // keeps the closing-reply prompt's copy inside the same supplied set the
@@ -5708,9 +6326,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
5708
6326
  turn.report = {
5709
6327
  ...outcome.report,
5710
6328
  facts: [...facts],
5711
- bossFacts: facts.map((fact) => fact
5712
- .split(`"${actionId}"`)
5713
- .join(`"${compactEvidence(actionLabel)}"`)),
6329
+ bossFacts: facts.map(speak),
5714
6330
  status: 'failed',
5715
6331
  receipt: {
5716
6332
  disposition: 'failed',
@@ -5730,9 +6346,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
5730
6346
  turn.report = {
5731
6347
  ...outcome.report,
5732
6348
  facts: [...facts],
5733
- bossFacts: facts.map((fact) => fact
5734
- .split(`"${actionId}"`)
5735
- .join(`"${compactEvidence(actionLabel)}"`)),
6349
+ bossFacts: facts.map(speak),
5736
6350
  status: 'ok',
5737
6351
  receipt: receiptEvidence,
5738
6352
  ...(establishedSummary === undefined
@@ -5752,20 +6366,27 @@ export function createPlaybookCaptainShell(options, deps = {}) {
5752
6366
  }
5753
6367
  }
5754
6368
  else if (receipt.disposition === 'rejected') {
5755
- facts.push(`The runtime refused "${actionId}": ${compactEvidence(receipt.reason)}.`);
6369
+ facts.push(refusalStatement(`"${actionId}"`, noteReason(recordedReason(receipt.reason, speakableWords))));
5756
6370
  }
5757
6371
  else {
5758
- facts.push(`Applying "${actionId}" failed: ${receipt.error.name}: ${compactEvidence(receipt.error.message)}.`);
6372
+ // A failed receipt acts on the leaf, so the leaf's parked failure may
6373
+ // be this very error — a Retry whose re-run failed again (CAPTAIN-67).
6374
+ const subject = `applying "${actionId}"`;
6375
+ const restate = (failure) => stateFailure(subject, failure);
6376
+ const fact = restate(receipt.error);
6377
+ facts.push(fact);
6378
+ turn.failureStatements.push({
6379
+ frame: leaf,
6380
+ fact,
6381
+ message: receipt.error.message,
6382
+ restate,
6383
+ speak,
6384
+ });
5759
6385
  }
5760
6386
  const runFailed = drainRunFailureFacts(facts);
5761
6387
  if (runFailed)
5762
6388
  status = 'failed';
5763
- // The settlement facts above are shell-internal strings composed for the
5764
- // hidden result-phase prompt: they name the action by its id, which is
5765
- // control data. The Boss-facing rendering of the same settlement names it
5766
- // by the runtime's own Boss-appropriate label (PBRT-52), for the one place
5767
- // these facts are spoken rather than prompted — the CAPTAIN-34 fallback.
5768
- const bossFacts = facts.map((fact) => fact.split(`"${actionId}"`).join(`"${compactEvidence(actionLabel)}"`));
6389
+ const bossFacts = facts.map(speak);
5769
6390
  const summary = leafStateSummary();
5770
6391
  turn.report = {
5771
6392
  ...outcome.report,
@@ -5802,14 +6423,15 @@ export function createPlaybookCaptainShell(options, deps = {}) {
5802
6423
  // `settled` guard. That guard closes duplicate submissions before an effect
5803
6424
  // starts; it is not evidence that anything ran.
5804
6425
  // CAPTAIN-63: the give-up's closing reply, composed from the same settlement
5805
- // facts the result-phase prompt would have carried. Shell-authored Boss
5806
- // prose passes the validation every model reply passes (CAPTAIN-34); what it
5807
- // interpolates is not host-authored all the way down, so a fact set that
5808
- // fails validation is dropped rather than spoken and the reply still states
5809
- // the settlement truthfully.
5810
- const giveUpReplyText = () => {
5811
- const report = activeTurn?.report;
5812
- const facts = report?.bossFacts ?? report?.facts ?? [];
6426
+ // facts the result-phase prompt would have carried. What it lists is not
6427
+ // host-authored all the way down, so each statement passes CAPTAIN-69's
6428
+ // guard on its own and the reply still states the settlement truthfully;
6429
+ // no statement is ever left out.
6430
+ const giveUpReply = () => {
6431
+ const turn = activeTurn;
6432
+ const report = turn?.report;
6433
+ const stated = report?.bossFacts ?? report?.facts ?? [];
6434
+ const facts = turn === undefined ? [] : assembleTurnReport(turn, stated).facts;
5813
6435
  const stopped = report?.status === 'ok';
5814
6436
  const opening = stopped
5815
6437
  ? 'Stopped that workflow. I will not resume it.'
@@ -5817,27 +6439,29 @@ export function createPlaybookCaptainShell(options, deps = {}) {
5817
6439
  const closing = stopped
5818
6440
  ? 'Send the command again to start fresh work.'
5819
6441
  : 'The stop did not settle successfully; recover the session before continuing.';
5820
- const composed = [
5821
- opening,
5822
- ...(facts.length === 0
5823
- ? []
5824
- : ['Here is what happened:', ...facts.map((fact) => `- ${fact}`)]),
5825
- closing,
5826
- ].join('\n');
5827
- return replyRejection(composed) === undefined
5828
- ? composed
5829
- : [opening, closing].join('\n');
5830
- };
5831
- const failureReplyText = () => {
6442
+ return {
6443
+ text: [
6444
+ opening,
6445
+ ...(facts.length === 0
6446
+ ? []
6447
+ : ['Here is what happened:', ...facts.map((fact) => `- ${fact}`)]),
6448
+ closing,
6449
+ ].join('\n'),
6450
+ stated,
6451
+ };
6452
+ };
6453
+ const failureReply = () => {
5832
6454
  const commands = [...enablementById.values()]
5833
6455
  .map((enablement) => `/${enablement.command} <task>`)
5834
6456
  .join(' or ');
5835
6457
  const turn = activeTurn;
5836
6458
  const report = turn?.report;
5837
6459
  if (report === undefined) {
5838
- return ('I could not finish deciding that turn. No action was selected or run — please send the request again' +
5839
- (commands ? `, or start a playbook directly with ${commands}` : '') +
5840
- '.');
6460
+ return {
6461
+ text: 'I could not finish deciding that turn. No action was selected or run — please send the request again' +
6462
+ (commands ? `, or start a playbook directly with ${commands}` : '') +
6463
+ '.',
6464
+ };
5841
6465
  }
5842
6466
  const settledPreamble = report.status === 'ok'
5843
6467
  ? 'I could not finish reporting that turn, but the reported action completed — please do not send it again.'
@@ -5849,25 +6473,26 @@ export function createPlaybookCaptainShell(options, deps = {}) {
5849
6473
  const closing = 'Ask me where things stand and I will report the current state.';
5850
6474
  // The Boss-facing rendering of the settlement, never the prompt-side one:
5851
6475
  // `facts` name actions by id and quote runtime-authored text nobody
5852
- // validated.
5853
- const facts = report.bossFacts ?? report.facts;
5854
- const composed = [
5855
- settledPreamble,
5856
- ...(facts.length === 0
5857
- ? []
5858
- : ['Here is what happened:', ...facts.map((fact) => `- ${fact}`)]),
5859
- closing,
5860
- ].join('\n');
5861
- // CAPTAIN-34: this reply is host-authored Boss prose and passes the same
5862
- // validation every model reply passes. What it interpolates is not
6476
+ // validated. Each is said once, in Boss's words, beside the reports the
6477
+ // reply carries (CAPTAIN-69).
6478
+ const stated = report.bossFacts ?? report.facts;
6479
+ const facts = assembleTurnReport(turn, stated).facts;
6480
+ // CAPTAIN-34: this reply is host-authored Boss prose. What it lists is not
5863
6481
  // host-authored all the way down — a refusal reason and a normalized error
5864
- // message are foreign text — so a fact set that fails validation is
5865
- // dropped rather than spoken, and the reply still states the settlement
5866
- // truthfully and names the next step. It is never withheld: it is the
5867
- // turn's only remaining settlement.
5868
- return replyRejection(composed) === undefined
5869
- ? composed
5870
- : [settledPreamble, closing].join('\n');
6482
+ // message are foreign text — so each statement passes CAPTAIN-69's guard
6483
+ // on its own, the reply states the settlement truthfully and names the
6484
+ // next step, and no statement is left out for its own or another's words.
6485
+ // It is never withheld: it is the turn's only remaining settlement.
6486
+ return {
6487
+ text: [
6488
+ settledPreamble,
6489
+ ...(facts.length === 0
6490
+ ? []
6491
+ : ['Here is what happened:', ...facts.map((fact) => `- ${fact}`)]),
6492
+ closing,
6493
+ ].join('\n'),
6494
+ stated,
6495
+ };
5871
6496
  };
5872
6497
  const settleTurnFailure = async (context, error) => {
5873
6498
  if (context.signal.aborted)
@@ -5888,10 +6513,7 @@ export function createPlaybookCaptainShell(options, deps = {}) {
5888
6513
  // Through the one presentation seam, so this reply is journaled like
5889
6514
  // every other Boss-visible Captain reply. A rejected emission propagates
5890
6515
  // unchanged: it is never retried and never disguised as an action failure.
5891
- await surfaceSettlement({
5892
- context,
5893
- text: failureReplyText(),
5894
- });
6516
+ await surfaceSettlement({ context, ...failureReply() });
5895
6517
  };
5896
6518
  const enabledCatalog = () => Object.freeze(entries.map((entry) => Object.freeze({
5897
6519
  id: entry.id,
@@ -6758,7 +7380,11 @@ export function createPlaybookCaptainShell(options, deps = {}) {
6758
7380
  ...(hostGiveUp === undefined ? {} : { hostGiveUp }),
6759
7381
  settled: false,
6760
7382
  presentationAttempted: false,
7383
+ presentationReports: [],
6761
7384
  settlementFacts: [],
7385
+ failureStatements: [],
7386
+ statedReasons: new Set(),
7387
+ shellWords: { subjects: new Set(), labels: new Set() },
6762
7388
  effectThrows: new Set(),
6763
7389
  controlFailures: new Set(),
6764
7390
  settingsPreflightFailures: new Set(),
@@ -6901,4 +7527,13 @@ export function createPlaybookCaptainShell(options, deps = {}) {
6901
7527
  throw failure;
6902
7528
  }
6903
7529
  }
7530
+ // The Boss-facing failure renderer, report assembly, and minted-reason tables,
7531
+ // for the shell suite's direct cases; the leading underscore keeps them out of
7532
+ // the stable surface.
7533
+ export const _internal = {
7534
+ failureStatement,
7535
+ bossReport,
7536
+ RUNTIME_REASON_PHRASES,
7537
+ LABELLED_RUNTIME_REASONS,
7538
+ };
6904
7539
  export default createPlaybookCaptainShell;