@sublang/playbook 15.1.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 (76) hide show
  1. package/docs/embedding.md +2 -2
  2. package/package.json +1 -1
  3. package/reference/sdlc/branch.playbook/branch.fsm.d.ts +63 -33
  4. package/reference/sdlc/branch.playbook/branch.fsm.js +190 -108
  5. package/reference/sdlc/branch.playbook/branch.fsm.ts +269 -147
  6. package/reference/sdlc/branch.playbook/branch.gears.md +7 -17
  7. package/reference/sdlc/branch.playbook/branch.playbook.d.ts +12 -16
  8. package/reference/sdlc/branch.playbook/branch.playbook.js +112 -101
  9. package/reference/sdlc/branch.playbook/branch.playbook.ts +128 -171
  10. package/reference/sdlc/branch.playbook/branch.registry.d.ts +3 -2
  11. package/reference/sdlc/branch.playbook/branch.registry.ts +6 -2
  12. package/reference/sdlc/code.playbook/code.fsm.d.ts +143 -90
  13. package/reference/sdlc/code.playbook/code.fsm.introspect.d.ts +8 -8
  14. package/reference/sdlc/code.playbook/code.fsm.introspect.ts +13 -13
  15. package/reference/sdlc/code.playbook/code.fsm.js +609 -649
  16. package/reference/sdlc/code.playbook/code.fsm.ts +890 -821
  17. package/reference/sdlc/code.playbook/code.gears.md +39 -29
  18. package/reference/sdlc/code.playbook/code.playbook.d.ts +13 -16
  19. package/reference/sdlc/code.playbook/code.playbook.js +166 -147
  20. package/reference/sdlc/code.playbook/code.playbook.ts +180 -229
  21. package/reference/sdlc/code.playbook/code.registry.d.ts +3 -2
  22. package/reference/sdlc/code.playbook/code.registry.ts +6 -2
  23. package/reference/sdlc/code.playbook/playbook-captain.d.ts +42 -0
  24. package/reference/sdlc/code.playbook/playbook-captain.js +829 -194
  25. package/reference/sdlc/code.playbook/playbook-captain.ts +1227 -220
  26. package/reference/sdlc/decide.md +1 -0
  27. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +270 -154
  28. package/reference/sdlc/decide.playbook/decide.fsm.js +765 -732
  29. package/reference/sdlc/decide.playbook/decide.fsm.ts +1167 -945
  30. package/reference/sdlc/decide.playbook/decide.gears.md +28 -21
  31. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +11 -59
  32. package/reference/sdlc/decide.playbook/decide.playbook.js +155 -3696
  33. package/reference/sdlc/decide.playbook/decide.playbook.ts +186 -5244
  34. package/reference/sdlc/decide.playbook/decide.registry.d.ts +8 -4
  35. package/reference/sdlc/decide.playbook/decide.registry.js +2 -2
  36. package/reference/sdlc/decide.playbook/decide.registry.ts +13 -6
  37. package/reference/sdlc/dev.playbook/dev.fsm.d.ts +113 -131
  38. package/reference/sdlc/dev.playbook/dev.fsm.js +517 -575
  39. package/reference/sdlc/dev.playbook/dev.fsm.ts +667 -787
  40. package/reference/sdlc/dev.playbook/dev.gears.md +52 -54
  41. package/reference/sdlc/dev.playbook/dev.playbook.d.ts +13 -10
  42. package/reference/sdlc/dev.playbook/dev.playbook.js +128 -120
  43. package/reference/sdlc/dev.playbook/dev.playbook.ts +143 -193
  44. package/reference/sdlc/dev.playbook/dev.registry.d.ts +3 -2
  45. package/reference/sdlc/dev.playbook/dev.registry.ts +6 -2
  46. package/reference/sdlc/pr.playbook/pr.fsm.d.ts +92 -85
  47. package/reference/sdlc/pr.playbook/pr.fsm.js +461 -518
  48. package/reference/sdlc/pr.playbook/pr.fsm.ts +533 -663
  49. package/reference/sdlc/pr.playbook/pr.gears.md +47 -63
  50. package/reference/sdlc/pr.playbook/pr.playbook.d.ts +13 -23
  51. package/reference/sdlc/pr.playbook/pr.playbook.js +118 -112
  52. package/reference/sdlc/pr.playbook/pr.playbook.ts +136 -190
  53. package/reference/sdlc/pr.playbook/pr.registry.d.ts +3 -2
  54. package/reference/sdlc/pr.playbook/pr.registry.ts +4 -2
  55. package/reference/sdlc/review.md +3 -3
  56. package/reference/sdlc/review.playbook/review.fsm.d.ts +150 -116
  57. package/reference/sdlc/review.playbook/review.fsm.js +479 -550
  58. package/reference/sdlc/review.playbook/review.fsm.ts +717 -662
  59. package/reference/sdlc/review.playbook/review.gears.md +32 -29
  60. package/reference/sdlc/review.playbook/review.playbook.d.ts +12 -12
  61. package/reference/sdlc/review.playbook/review.playbook.js +180 -155
  62. package/reference/sdlc/review.playbook/review.playbook.ts +190 -230
  63. package/reference/sdlc/review.playbook/review.registry.d.ts +6 -5
  64. package/reference/sdlc/review.playbook/review.registry.js +3 -3
  65. package/reference/sdlc/review.playbook/review.registry.ts +9 -5
  66. package/slc/gears2fsm.md +72 -3
  67. package/slc/link.md +50 -27
  68. package/slc/materialize-link.mjs +51 -13
  69. package/slc/optimize.md +4 -1
  70. package/slc/prefix-prompts.mjs +29 -28
  71. package/slc/prefix.md +27 -24
  72. package/slc/slc.pin-inputs.json +11 -2
  73. package/slc/text2gears.md +71 -11
  74. package/src/xstate-playbook-runtime.d.ts +54 -15
  75. package/src/xstate-playbook-runtime.js +1154 -248
  76. package/src/xstate-playbook-runtime.ts +1603 -317
@@ -30,6 +30,8 @@ import type {
30
30
  PlaybookFailureCause,
31
31
  PlaybookFailureCode,
32
32
  PlaybookPorts,
33
+ PlaybookRepositoryDisposition,
34
+ PlaybookRepositoryReceipt,
33
35
  PlaybookRunResult,
34
36
  PlaybookRuntime,
35
37
  PlaybookRuntimeSnapshot,
@@ -44,9 +46,11 @@ import {
44
46
  emptyPlaybookEffectLedger,
45
47
  hiddenControlEnvelope,
46
48
  isPlaybookEffectLedgerMonotonicExtension,
49
+ normalizeError,
47
50
  registerPlaybookAbortCleanup,
48
51
  snapshotJsonValue,
49
52
  validatePlayerResult,
53
+ type PlaybookSemanticReconciliationReason,
50
54
  } from '../../../src/xstate-runtime.js';
51
55
  import createDefaultCaptainRuntime, {
52
56
  type CaptainControllerPort,
@@ -669,10 +673,37 @@ interface ActiveTurn {
669
673
  presentationAttempted: boolean;
670
674
  /** The exact rejected presentation boundary, propagated without retry. */
671
675
  presentationError?: unknown;
672
- /** Shell-authored safety text that must accompany the one visible reply. */
673
- mandatoryPresentationSuffix?: string;
676
+ /**
677
+ * Shell-authored safety text that must accompany the one visible reply, one
678
+ * report per entry in the order appended, each with the settlement facts
679
+ * that state the same thing (CAPTAIN-69).
680
+ */
681
+ readonly presentationReports: PresentationReport[];
682
+ /** DR-063 §4: the parked failure's report, rendered last at presentation. */
683
+ failureReport?: FailureReport;
674
684
  /** Facts accumulated while the selected action runs, including partial work. */
675
685
  readonly settlementFacts: string[];
686
+ /**
687
+ * The facts that stated a frame's failure this turn, in the order stated, so
688
+ * the leaf's failure cause restates the one that stated its parked failure
689
+ * instead of stating that failure again (CAPTAIN-67).
690
+ */
691
+ readonly failureStatements: FailureStatement[];
692
+ /**
693
+ * The reasons the renderer gave this turn's failure statements — a phrase,
694
+ * or a recorded reason in its code span — which the report assembly reads
695
+ * as what each statement holding one says (CAPTAIN-69).
696
+ */
697
+ readonly statedReasons: Set<string>;
698
+ /**
699
+ * The shell's own words in this turn's statements that name something
700
+ * foreign — a failure's subject, an action's quoted label — which a
701
+ * statement says neutrally where CAPTAIN-9 refuses them (CAPTAIN-69).
702
+ */
703
+ readonly shellWords: {
704
+ readonly subjects: Set<string>;
705
+ readonly labels: Set<string>;
706
+ };
676
707
  report?: OutcomeReport;
677
708
  /**
678
709
  * A shell-owned control-plane failure — an unusable durable reply or a
@@ -1088,6 +1119,20 @@ function proseRejection(
1088
1119
  return 'the reply leaked hidden control syntax or internal control vocabulary';
1089
1120
  }
1090
1121
  }
1122
+ return identifierRejection(prose, liveSessionIds, liveStateIds, suppliedIds);
1123
+ }
1124
+
1125
+ /**
1126
+ * CAPTAIN-9's identifier duty alone: whether `prose` repeats a live session
1127
+ * identifier, a live internal state identifier, or an identifier the shell
1128
+ * supplied for selection only.
1129
+ */
1130
+ function identifierRejection(
1131
+ prose: string,
1132
+ liveSessionIds: readonly string[],
1133
+ liveStateIds: readonly string[],
1134
+ suppliedIds: readonly string[],
1135
+ ): string | undefined {
1091
1136
  const caseFoldedProse = prose.toLowerCase();
1092
1137
  for (const sessionId of liveSessionIds) {
1093
1138
  // UUID hexadecimal is case-insensitive. A model uppercasing A-F has not
@@ -1109,6 +1154,35 @@ function proseRejection(
1109
1154
  return undefined;
1110
1155
  }
1111
1156
 
1157
+ /**
1158
+ * CAPTAIN-9: a prose call answered with the routing envelope of a `respond`
1159
+ * selection — exactly `{"action": "respond", "text": <string>}`, with nothing
1160
+ * but whitespace around it — said its prose in the decision shape, so the
1161
+ * prose is that `text`, which then passes the same validation as any reply.
1162
+ * Refusing the envelope instead spends the one corrective re-ask on a shape
1163
+ * whose meaning is unambiguous, and a model that answers the same way twice
1164
+ * costs the Boss the whole reply. Any other JSON stays control JSON.
1165
+ */
1166
+ function respondEnvelopeText(reply: string | undefined): string | undefined {
1167
+ const trimmed = reply?.trim();
1168
+ if (trimmed === undefined || !trimmed.startsWith('{')) return reply;
1169
+ let parsed: unknown;
1170
+ try {
1171
+ parsed = JSON.parse(trimmed);
1172
+ } catch {
1173
+ return reply;
1174
+ }
1175
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
1176
+ return reply;
1177
+ }
1178
+ const members = parsed as Record<string, unknown>;
1179
+ return Object.keys(members).length === 2 &&
1180
+ members.action === 'respond' &&
1181
+ typeof members.text === 'string'
1182
+ ? members.text
1183
+ : reply;
1184
+ }
1185
+
1112
1186
  type CaptainToolIsolation = 'provider-enforced' | 'prompt-only';
1113
1187
 
1114
1188
  // DR-013 A1: adapters with no provider-enforced tool-restriction surface.
@@ -1192,6 +1266,24 @@ type ControllerSettlementDraft = Omit<
1192
1266
  'unresolvedEffects'
1193
1267
  >;
1194
1268
 
1269
+ /**
1270
+ * Each unresolved-effect classification as what the step did (CAPTAIN-58), in
1271
+ * the words the matching failure cause uses, so the report assembly reads a
1272
+ * cause an entry already states as said (CAPTAIN-69).
1273
+ */
1274
+ const UNRESOLVED_EFFECT_CLAUSES: Readonly<
1275
+ Record<PlaybookCaptainUnresolvedEffect['classification'], string>
1276
+ > = {
1277
+ 'one-descendant-commit': 'the step made one new commit',
1278
+ 'multiple-commits': 'the step made more than one commit',
1279
+ 'rewritten-or-non-descendant': 'the repository history was rewritten',
1280
+ 'worktree-only-change': 'the step left changes uncommitted',
1281
+ 'concurrent-or-foreign-change': 'the repository changed outside this step',
1282
+ 'observation-ambiguous':
1283
+ 'the repository could not be observed after the step',
1284
+ incomplete: "the step's effect on the repository was not fully recorded",
1285
+ };
1286
+
1195
1287
  function unresolvedEffectReportLines(
1196
1288
  unresolvedEffects: readonly PlaybookCaptainUnresolvedEffect[],
1197
1289
  ): readonly string[] {
@@ -1199,26 +1291,20 @@ function unresolvedEffectReportLines(
1199
1291
  const merelyPossible =
1200
1292
  effect.classification === 'observation-ambiguous' ||
1201
1293
  effect.classification === 'incomplete';
1202
- return [
1203
- `${index + 1}. ${merelyPossible ? 'Possible repository effect; a change could not be excluded' : 'Observed repository change'} (${effect.classification})`,
1204
- `baseline HEAD ${effect.baselineHead}`,
1294
+ const heads =
1205
1295
  effect.afterHead === undefined
1206
- ? 'after HEAD was not available'
1207
- : `after HEAD ${effect.afterHead}`,
1208
- ...(effect.commitOid === undefined
1209
- ? []
1210
- : [`proven commit OID ${effect.commitOid}`]),
1211
- ].join('; ') + '.';
1296
+ ? `the repository stood at ${effect.baselineHead} before it; no HEAD was available after it`
1297
+ : effect.afterHead === effect.baselineHead
1298
+ ? `HEAD stayed at ${effect.baselineHead}`
1299
+ : `HEAD moved from ${effect.baselineHead} to ${effect.commitOid === undefined ? '' : 'the proven commit '}${effect.afterHead}`;
1300
+ return `${index + 1}. ${merelyPossible ? 'Possible repository effect, which could not be excluded' : 'Observed repository change'}: ${UNRESOLVED_EFFECT_CLAUSES[effect.classification]}; ${heads}.`;
1212
1301
  });
1213
1302
  }
1214
1303
 
1215
- function unresolvedEffectBossReport(
1216
- unresolvedEffects: readonly PlaybookCaptainUnresolvedEffect[],
1217
- ): string | undefined {
1218
- if (unresolvedEffects.length === 0) return undefined;
1304
+ function unresolvedEffectBossReport(lines: readonly string[]): string {
1219
1305
  return [
1220
1306
  'Repository-effect evidence:',
1221
- ...unresolvedEffectReportLines(unresolvedEffects).map((line) => `- ${line}`),
1307
+ ...lines.map((line) => `- ${line}`),
1222
1308
  'This evidence does not establish workflow completion or attribute any repository change or commit to this workflow.',
1223
1309
  ].join('\n');
1224
1310
  }
@@ -1297,12 +1383,23 @@ function carriedPreExistingReport(
1297
1383
  .join('\n\n');
1298
1384
  }
1299
1385
 
1386
+ /**
1387
+ * The carried-changes report as settlement facts, one per commit, saying all
1388
+ * the report says so a reply that lists them need not repeat it.
1389
+ */
1300
1390
  function carriedPreExistingFacts(
1301
1391
  carried: readonly CarriedPreExistingChanges[],
1302
1392
  ): readonly string[] {
1303
1393
  return carried.map(({ commitOid, absorbed, altered }) => {
1304
- const paths = [...new Set([...absorbed, ...altered])].sort();
1305
- return `The commit ${commitOid} carries ${paths.length} changes that were uncommitted before the step: ${boundedPathList(paths)}.`;
1394
+ const lists = [
1395
+ ...(absorbed.length === 0
1396
+ ? []
1397
+ : [`absorbed as found ${boundedPathList(absorbed)}`]),
1398
+ ...(altered.length === 0
1399
+ ? []
1400
+ : [`altered before committing ${boundedPathList(altered)}`]),
1401
+ ];
1402
+ return `The commit ${commitOid} carries changes that were uncommitted before the step: ${lists.join('; ')}.`;
1306
1403
  });
1307
1404
  }
1308
1405
 
@@ -1334,63 +1431,498 @@ function causePathList(
1334
1431
  : printed.join(', ');
1335
1432
  }
1336
1433
 
1337
- /** One sentence per code, with that code's bounded evidence (DR-063 §4). */
1338
- function failureCauseSentence(
1339
- cause: PlaybookFailureCause,
1340
- describePlaybook: (playbookId: string) => string,
1434
+ // CAPTAIN-71: one renderer puts every failure into words, for the Boss and for
1435
+ // the turn's settlement facts alike — a failure statement names what failed
1436
+ // and why — so no site composes a failure's words or quotes its message
1437
+ // itself. A cause says why in the Boss phrase of its code, and a reason the
1438
+ // runtime mints from a closed set in its own phrase (CAPTAIN-76); any other
1439
+ // recorded reason or message is quoted where it reads as prose and is
1440
+ // otherwise shown once, whole, as recorded, in a code span, never replaced by
1441
+ // a generic sentence that hides it (CAPTAIN-74).
1442
+
1443
+ const REPOSITORY_EVIDENCE_SENTENCE =
1444
+ "the repository evidence for the step is missing or inconsistent, so the step's outcome was not accepted.";
1445
+ const RESULT_EVIDENCE_SENTENCE =
1446
+ "the step's reported result is incomplete or inconsistent, so its outcome was not accepted.";
1447
+ const UNDECIDED_OUTCOME_SENTENCE = "the step's outcome could not be decided.";
1448
+ const CAPTAIN_INCOMPLETE_SENTENCE = 'the Captain call did not complete.';
1449
+ const PLAYER_ERROR_SENTENCE = 'the player reported an error without a message.';
1450
+ const PLAYER_ABORTED_SENTENCE = 'the player call was aborted.';
1451
+
1452
+ /**
1453
+ * CAPTAIN-76: every reason the runtime records from its own literal text, as
1454
+ * the Boss reads it. These are the runtime's own words for its own states — the
1455
+ * semantic-reconciliation reasons (PBRT-77), the governed settlement's
1456
+ * reasons, the judge's reasons, the player and Captain calls' results that
1457
+ * carried no message or no text, its own invariant reasons behind its
1458
+ * diagnostic label, and the stand-ins for a player result or an error that
1459
+ * carried no message — so no recorded text of one is ever shown as a code the
1460
+ * Boss has to decode. Anything not listed here is foreign text (CAPTAIN-74).
1461
+ */
1462
+ const SEMANTIC_RECONCILIATION_PHRASES: Readonly<
1463
+ Record<PlaybookSemanticReconciliationReason, string>
1464
+ > = {
1465
+ 'missing-repository-receipt': REPOSITORY_EVIDENCE_SENTENCE,
1466
+ 'invalid-repository-receipt': REPOSITORY_EVIDENCE_SENTENCE,
1467
+ 'repository-disposition-mismatch': REPOSITORY_EVIDENCE_SENTENCE,
1468
+ 'missing-effect-evidence': REPOSITORY_EVIDENCE_SENTENCE,
1469
+ 'missing-presentation-evidence': RESULT_EVIDENCE_SENTENCE,
1470
+ 'missing-runtime-evidence': RESULT_EVIDENCE_SENTENCE,
1471
+ 'inconsistent-runtime-evidence': RESULT_EVIDENCE_SENTENCE,
1472
+ };
1473
+ const RUNTIME_REASON_PHRASES: Readonly<Record<string, string>> = {
1474
+ ...SEMANTIC_RECONCILIATION_PHRASES,
1475
+ 'deferred player result has no semantic evidence': RESULT_EVIDENCE_SENTENCE,
1476
+ 'host omitted governed semantic settlement':
1477
+ "the host did not settle the step's outcome.",
1478
+ 'deferred question did not receive an eligible durable binding':
1479
+ "the step's question could not be kept for a later reply.",
1480
+ 'judge transport failed': UNDECIDED_OUTCOME_SENTENCE,
1481
+ 'corrective judge failed': UNDECIDED_OUTCOME_SENTENCE,
1482
+ 'corrective semantic candidate is invalid': UNDECIDED_OUTCOME_SENTENCE,
1483
+ 'semantic correction budget is unavailable': UNDECIDED_OUTCOME_SENTENCE,
1484
+ 'callPlayer status "error"': PLAYER_ERROR_SENTENCE,
1485
+ 'callPlayer status "aborted"': PLAYER_ABORTED_SENTENCE,
1486
+ 'captainBridge: callPlayer status "error"': PLAYER_ERROR_SENTENCE,
1487
+ 'captainBridge: callPlayer status "aborted"': PLAYER_ABORTED_SENTENCE,
1488
+ 'captainBridge: callPlayer returned status=ok with no finalText':
1489
+ 'the player call returned nothing.',
1490
+ 'captainActor: callCaptain status "error"': CAPTAIN_INCOMPLETE_SENTENCE,
1491
+ 'captainActor: callCaptain status "aborted"': CAPTAIN_INCOMPLETE_SENTENCE,
1492
+ 'captainActor: callCaptain returned status=ok with no finalText':
1493
+ 'the Captain call returned nothing.',
1494
+ 'apply settled with outcome failed': 'the action did not complete.',
1495
+ 'apply settled with outcome aborted': 'the action was stopped.',
1496
+ 'unknown runtime defect': 'the runtime failed without saying why.',
1497
+ 'Unknown error': 'an unknown error.',
1498
+ };
1499
+
1500
+ /**
1501
+ * CAPTAIN-76: the reasons the runtime mints behind its diagnostic label —
1502
+ * `playbook` unless a playbook names its own — as `<label> <suffix>`, the
1503
+ * parallel cohort's naming its state id as well and the unresolved governed
1504
+ * outcome its own reason; each matches only the whole reason.
1505
+ */
1506
+ const LABELLED_RUNTIME_REASONS: readonly (readonly [RegExp, string])[] = [
1507
+ [
1508
+ /^.+ retained governed semantic envelope is no longer exact$/,
1509
+ "the step's retained result no longer matches.",
1510
+ ],
1511
+ [/^.+ parallel state .+ cohort failed$/, 'a parallel step failed.'],
1512
+ [
1513
+ /^.+ governed semantic reconciliation remains unresolved$/,
1514
+ "the step's outcome could not be reconciled.",
1515
+ ],
1516
+ [
1517
+ /^.+ reconstructed governed output changed before FSM acceptance$/,
1518
+ "the step's result changed before it was accepted.",
1519
+ ],
1520
+ [
1521
+ /^.+ deferred continuation remains unresolved$/,
1522
+ 'the deferred step is still unresolved.',
1523
+ ],
1524
+ [
1525
+ /^.+ deferred player returned no result$/,
1526
+ 'the deferred player returned nothing.',
1527
+ ],
1528
+ [
1529
+ /^.+ governed outcome remains unresolved: .+$/,
1530
+ "the step's outcome could not be settled.",
1531
+ ],
1532
+ [/^.+ actor entered error status$/, "the step's actor failed."],
1533
+ ];
1534
+
1535
+ /**
1536
+ * The runtime's refusal of an action it does not advertise, which repeats the
1537
+ * selected id as JSON (PBRT-52) and so says nothing the Boss can use but that
1538
+ * the action is no longer offered (CAPTAIN-76).
1539
+ */
1540
+ const NOT_ADVERTISED_REFUSAL =
1541
+ /^action "(?:[^"\\]|\\.)*" is not currently advertised$/;
1542
+
1543
+ /** The phrase of a reason the runtime mints from a closed set (CAPTAIN-76). */
1544
+ function runtimeReasonPhrase(reason: string): string | undefined {
1545
+ if (Object.hasOwn(RUNTIME_REASON_PHRASES, reason)) {
1546
+ return RUNTIME_REASON_PHRASES[reason];
1547
+ }
1548
+ if (NOT_ADVERTISED_REFUSAL.test(reason)) {
1549
+ return 'that action is no longer offered.';
1550
+ }
1551
+ return LABELLED_RUNTIME_REASONS.find(([minted]) => minted.test(reason))?.[1];
1552
+ }
1553
+
1554
+ /** A declared repository disposition, as what the step was to leave. */
1555
+ const REQUIRED_DISPOSITION_PHRASES: Readonly<
1556
+ Record<PlaybookRepositoryDisposition, string>
1557
+ > = {
1558
+ unchanged: 'no change',
1559
+ 'one-descendant-commit': 'one new commit',
1560
+ deferred: 'a change deferred to a later step',
1561
+ };
1562
+
1563
+ /** A receipt classification, as what the step left. */
1564
+ const OBSERVED_CLASSIFICATION_PHRASES: Readonly<
1565
+ Record<PlaybookRepositoryReceipt['classification'], string>
1566
+ > = {
1567
+ unchanged: 'no change',
1568
+ 'one-descendant-commit': 'one new commit',
1569
+ 'multiple-commits': 'more than one new commit',
1570
+ 'rewritten-or-non-descendant': 'rewritten history',
1571
+ 'worktree-only-change': 'uncommitted changes only',
1572
+ 'concurrent-or-foreign-change': 'a change made outside the step',
1573
+ 'observation-ambiguous': 'a state that could not be read',
1574
+ };
1575
+
1576
+ /**
1577
+ * An error-class name leading a message (CAPTAIN-74): `Error`, `Exception`,
1578
+ * `Failure`, or `Defect`, alone or ending a CamelCase identifier, as in
1579
+ * `TypeError: …`, followed by `: `.
1580
+ */
1581
+ const LEADING_ERROR_CLASS =
1582
+ /^(?:[A-Z][A-Za-z0-9]*)?(?:Error|Exception|Failure|Defect): /;
1583
+
1584
+ /**
1585
+ * CAPTAIN-74: prose begins with no error-class name — a message behind one is
1586
+ * machine text by origin, however what follows it reads — holds a space,
1587
+ * begins with a capital letter, ends in a terminal mark, and holds no word
1588
+ * CAPTAIN-9's identifier grammar tells apart from English — the same tokens
1589
+ * and the same test the reply validation applies, so no second notion of
1590
+ * "code-like" exists to disagree with it.
1591
+ */
1592
+ function readsAsProse(text: string): boolean {
1593
+ return (
1594
+ !LEADING_ERROR_CLASS.test(text) &&
1595
+ text.includes(' ') &&
1596
+ /^\p{Lu}/u.test(text) &&
1597
+ /[.!?]$/.test(text) &&
1598
+ !(text.match(IDENTIFIER_TOKENS) ?? []).some(machineShapedIdentifier)
1599
+ );
1600
+ }
1601
+
1602
+ /**
1603
+ * A statement's own end (CAPTAIN-69): a run of terminal marks at its very end
1604
+ * collapses to its first, and a statement with none gains a period. A run
1605
+ * inside it — `main..HEAD`, `../config.yaml` — is evidence and stays.
1606
+ */
1607
+ function oneTerminalMark(text: string): string {
1608
+ const statement = text.trim().replace(/([.!?])[.!?]+$/, '$1');
1609
+ return /[.!?]$/.test(statement) ? statement : `${statement}.`;
1610
+ }
1611
+
1612
+ /** `text` as one Markdown code span, unchanged whatever backticks it holds. */
1613
+ function codeSpan(text: string): string {
1614
+ const longest = Math.max(
1615
+ 0,
1616
+ ...[...text.matchAll(/`+/g)].map(([run]) => run.length),
1617
+ );
1618
+ const fence = '`'.repeat(longest + 1);
1619
+ const pad = text.startsWith('`') || text.endsWith('`') ? ' ' : '';
1620
+ return `${fence}${pad}${text}${pad}${fence}`;
1621
+ }
1622
+
1623
+ /** One Markdown code span of a text: where it stands, and what it shows. */
1624
+ interface CodeSpanAt {
1625
+ readonly start: number;
1626
+ readonly end: number;
1627
+ readonly content: string;
1628
+ }
1629
+
1630
+ /**
1631
+ * The code spans of `text` as CommonMark reads them: a backtick run opens one
1632
+ * that the next run of the same length closes, and one space padding both of
1633
+ * its ends is no part of what it shows.
1634
+ */
1635
+ function codeSpans(text: string): readonly CodeSpanAt[] {
1636
+ const runs = [...text.matchAll(/`+/g)].map((match) => ({
1637
+ at: match.index ?? 0,
1638
+ length: match[0].length,
1639
+ }));
1640
+ const spans: CodeSpanAt[] = [];
1641
+ for (let index = 0; index < runs.length; index += 1) {
1642
+ const open = runs[index]!;
1643
+ const offset = runs
1644
+ .slice(index + 1)
1645
+ .findIndex(({ length }) => length === open.length);
1646
+ if (offset < 0) continue;
1647
+ const close = runs[index + 1 + offset]!;
1648
+ const inner = text.slice(open.at + open.length, close.at);
1649
+ spans.push({
1650
+ start: open.at,
1651
+ end: close.at + close.length,
1652
+ content:
1653
+ inner.length > 2 &&
1654
+ inner.startsWith(' ') &&
1655
+ inner.endsWith(' ') &&
1656
+ inner.trim().length > 0
1657
+ ? inner.slice(1, -1)
1658
+ : inner,
1659
+ });
1660
+ index += 1 + offset;
1661
+ }
1662
+ return spans;
1663
+ }
1664
+
1665
+ /** `text` with `map` applied to what stands outside its code spans. */
1666
+ function outsideCodeSpans(
1667
+ text: string,
1668
+ map: (segment: string) => string,
1341
1669
  ): string {
1670
+ let mapped = '';
1671
+ let at = 0;
1672
+ for (const span of codeSpans(text)) {
1673
+ mapped += map(text.slice(at, span.start)) + text.slice(span.start, span.end);
1674
+ at = span.end;
1675
+ }
1676
+ return mapped + map(text.slice(at));
1677
+ }
1678
+
1679
+ function sentenceCase(text: string): string {
1680
+ return text.charAt(0).toUpperCase() + text.slice(1);
1681
+ }
1682
+
1683
+ /** Why something failed, as a statement says it. */
1684
+ type FailureReason =
1685
+ /** Words the Boss reads as they are, ending in one terminal mark. */
1686
+ | { readonly kind: 'phrase'; readonly text: string }
1687
+ /** A recorded reason that reads as no prose, shown once as recorded. */
1688
+ | { readonly kind: 'code'; readonly text: string };
1689
+
1690
+ /**
1691
+ * Whether words may stand outside a code span in a statement the shell says
1692
+ * (CAPTAIN-9); the pure renderer accepts everything, and the shell passes its
1693
+ * guard.
1694
+ */
1695
+ type Speakable = (text: string) => boolean;
1696
+
1697
+ const SPEAKABLE_ANYWHERE: Speakable = () => true;
1698
+
1699
+ /**
1700
+ * CAPTAIN-76/74: a recorded reason or message, its control characters spaced,
1701
+ * its ends trimmed, and its length bounded, is said by its phrase where the
1702
+ * runtime minted it from a closed set; otherwise it is quoted where it reads
1703
+ * as prose — and only where the shell may say it outside a code span — and is
1704
+ * otherwise kept whole as recorded; `undefined` where nothing is left of it.
1705
+ */
1706
+ function recordedReason(
1707
+ text: string,
1708
+ speakable: Speakable = SPEAKABLE_ANYWHERE,
1709
+ ): FailureReason | undefined {
1710
+ const reason = compactEvidence(text);
1711
+ if (reason.length === 0) return undefined;
1712
+ const minted = runtimeReasonPhrase(reason);
1713
+ if (minted !== undefined) return { kind: 'phrase', text: minted };
1714
+ return readsAsProse(reason) && speakable(reason)
1715
+ ? { kind: 'phrase', text: oneTerminalMark(reason) }
1716
+ : { kind: 'code', text: reason };
1717
+ }
1718
+
1719
+ /**
1720
+ * `<subject> <outcome>: <reason>` for words the Boss reads as they are,
1721
+ * `<subject> <outcome> with the <noun> `<reason>`.` for a reason kept as
1722
+ * recorded, and `<subject> <outcome>.` where there is none.
1723
+ */
1724
+ function reasonedClause(
1725
+ subject: string,
1726
+ outcome: string,
1727
+ noun: string,
1728
+ reason: FailureReason | undefined,
1729
+ ): string {
1730
+ if (reason === undefined) return `${subject} ${outcome}.`;
1731
+ return reason.kind === 'phrase'
1732
+ ? `${subject} ${outcome}: ${reason.text}`
1733
+ : `${subject} ${outcome} with the ${noun} ${codeSpan(reason.text)}.`;
1734
+ }
1735
+
1736
+ function failureClause(
1737
+ subject: string,
1738
+ reason: FailureReason | undefined,
1739
+ ): string {
1740
+ return reasonedClause(subject, 'failed', 'error', reason);
1741
+ }
1742
+
1743
+ /**
1744
+ * What the shell recorded of a failure: an error, which may carry the cause
1745
+ * the runtime decided (DR-063 §2), or that cause alone.
1746
+ */
1747
+ interface RecordedFailure {
1748
+ readonly message?: string;
1749
+ readonly cause?: unknown;
1750
+ }
1751
+
1752
+ type DescribePlaybook = (playbookId: string) => string;
1753
+
1754
+ /** The Boss phrase of each cause code, with its bounded evidence (DR-063 §4). */
1755
+ function causeReason(
1756
+ cause: PlaybookFailureCause,
1757
+ describePlaybook: DescribePlaybook,
1758
+ speakable: Speakable = SPEAKABLE_ANYWHERE,
1759
+ ): FailureReason | undefined {
1760
+ const phrase = (text: string): FailureReason => ({ kind: 'phrase', text });
1342
1761
  const evidence = cause.evidence;
1343
1762
  const paths = evidence.paths;
1344
1763
  const code: PlaybookFailureCode = cause.code;
1345
1764
  switch (code) {
1346
1765
  case 'commit-missing':
1347
- return (paths?.uncommitted ?? []).length === 0
1348
- ? 'the step had to commit its work and committed nothing.'
1349
- : `the step had to commit its work and committed nothing; these changes are uncommitted: ${causePathList(paths?.uncommitted, paths?.truncated)}.`;
1766
+ return phrase(
1767
+ (paths?.uncommitted ?? []).length === 0
1768
+ ? 'the step had to commit its work and committed nothing.'
1769
+ : `the step had to commit its work and committed nothing; these changes are uncommitted: ${causePathList(paths?.uncommitted, paths?.truncated)}.`,
1770
+ );
1350
1771
  case 'commit-residual': {
1351
1772
  const subject =
1352
1773
  evidence.commitOid === undefined
1353
1774
  ? "the step's commit"
1354
1775
  : `the commit ${evidence.commitOid}`;
1355
1776
  if ((paths?.uncommitted ?? []).length > 0) {
1356
- return `${subject} left changes uncommitted: ${causePathList(paths?.uncommitted, paths?.truncated)}.`;
1777
+ return phrase(
1778
+ `${subject} left changes uncommitted: ${causePathList(paths?.uncommitted, paths?.truncated)}.`,
1779
+ );
1357
1780
  }
1358
1781
  if ((paths?.altered ?? []).length > 0) {
1359
- return `${subject} left altered changes uncommitted: ${causePathList(paths?.altered, paths?.truncated)}.`;
1782
+ return phrase(
1783
+ `${subject} left altered changes uncommitted: ${causePathList(paths?.altered, paths?.truncated)}.`,
1784
+ );
1360
1785
  }
1361
- return `${subject} left changes uncommitted beside it.`;
1786
+ return phrase(`${subject} left changes uncommitted beside it.`);
1362
1787
  }
1363
1788
  case 'pre-existing-lost':
1364
- return `uncommitted changes you had were lost: ${causePathList(paths?.lost, paths?.truncated)}.`;
1789
+ return phrase(
1790
+ `uncommitted changes you had were lost: ${causePathList(paths?.lost, paths?.truncated)}.`,
1791
+ );
1365
1792
  case 'commits-more-than-one':
1366
- return `the step made more than one commit; HEAD moved from ${evidence.baselineHead} to ${evidence.afterHead}.`;
1793
+ return phrase(
1794
+ `the step made more than one commit; HEAD moved from ${evidence.baselineHead} to ${evidence.afterHead}.`,
1795
+ );
1367
1796
  case 'history-rewritten':
1368
- return `the repository history was rewritten; HEAD moved from ${evidence.baselineHead} to ${evidence.afterHead}.`;
1797
+ return phrase(
1798
+ `the repository history was rewritten; HEAD moved from ${evidence.baselineHead} to ${evidence.afterHead}.`,
1799
+ );
1369
1800
  case 'foreign-change':
1370
- return `the repository changed outside this step: ${causePathList(paths?.changed, paths?.truncated)}.`;
1801
+ return phrase(
1802
+ `the repository changed outside this step: ${causePathList(paths?.changed, paths?.truncated)}.`,
1803
+ );
1371
1804
  case 'observation-unstable':
1372
- return `the repository could not be observed after the step; it stood at ${evidence.baselineHead} before it.`;
1373
- case 'attribution-ambiguous':
1374
- return `the repository change could not be attributed to this step; it required ${evidence.required} and observed ${evidence.observed}.`;
1805
+ return phrase(
1806
+ `the repository could not be observed after the step; the repository stood at ${evidence.baselineHead} before it.`,
1807
+ );
1808
+ case 'attribution-ambiguous': {
1809
+ const required =
1810
+ evidence.required === undefined
1811
+ ? undefined
1812
+ : REQUIRED_DISPOSITION_PHRASES[evidence.required];
1813
+ const observed =
1814
+ evidence.observed === undefined
1815
+ ? undefined
1816
+ : OBSERVED_CLASSIFICATION_PHRASES[evidence.observed];
1817
+ return phrase(
1818
+ required === undefined || observed === undefined
1819
+ ? 'the repository change could not be attributed to this step.'
1820
+ : `the repository change could not be attributed to this step: it should have left ${required} but left ${observed}.`,
1821
+ );
1822
+ }
1375
1823
  case 'receipt-missing':
1376
- return `a step left no complete repository receipt; the repository stood at ${evidence.baselineHead} before it.`;
1824
+ return phrase(
1825
+ `the step's effect on the repository was not fully recorded; the repository stood at ${evidence.baselineHead} before it.`,
1826
+ );
1377
1827
  case 'judge-failed':
1378
- return evidence.error === undefined
1379
- ? `the hidden adjudication failed: ${evidence.reason}.`
1380
- : `the hidden adjudication failed: ${evidence.reason} (${evidence.error.name}: ${compactEvidence(evidence.error.message)}).`;
1828
+ // The runtime's own closed reasons stay in the cause's data.
1829
+ return phrase(UNDECIDED_OUTCOME_SENTENCE);
1381
1830
  case 'player-failed':
1382
- return `the ${evidence.roleId} call failed: ${compactEvidence(evidence.error?.message ?? 'no detail was reported')}.`;
1831
+ // The player's message says why, as any recorded message does: by the
1832
+ // runtime's phrase where it minted the message, else as prose or code.
1833
+ return phrase(
1834
+ failureClause(
1835
+ `the ${evidence.roleId} call`,
1836
+ recordedReason(evidence.error?.message ?? '', speakable),
1837
+ ),
1838
+ );
1383
1839
  case 'aborted':
1384
- return 'the turn was aborted before the work settled.';
1385
- case 'child-failed': {
1386
- const nested = describePlaybook(evidence.playbookId ?? 'nested playbook');
1387
- return evidence.cause === undefined
1388
- ? `the nested ${nested} run failed.`
1389
- : `the nested ${nested} run failed: ${failureCauseSentence(evidence.cause, describePlaybook)}`;
1390
- }
1840
+ return phrase('the turn was aborted before the work settled.');
1841
+ case 'child-failed':
1842
+ return phrase(
1843
+ failureClause(
1844
+ `the nested ${describePlaybook(evidence.playbookId ?? '')} run`,
1845
+ evidence.cause === undefined
1846
+ ? undefined
1847
+ : causeReason(evidence.cause, describePlaybook, speakable),
1848
+ ),
1849
+ );
1391
1850
  case 'runtime-defect':
1392
- return `the runtime could not settle the step: ${compactEvidence(evidence.reason ?? 'no reason was recorded')}.`;
1851
+ // No more specific code: a reason the runtime minted is said by its
1852
+ // phrase, and any other reason as it was recorded.
1853
+ return recordedReason(evidence.reason ?? '', speakable);
1854
+ }
1855
+ }
1856
+
1857
+ /**
1858
+ * Why a recorded failure failed: the phrase of the cause it carries where the
1859
+ * closed validator accepts one, and otherwise its recorded message.
1860
+ */
1861
+ function failureReason(
1862
+ failure: RecordedFailure | undefined,
1863
+ describePlaybook: DescribePlaybook,
1864
+ speakable: Speakable = SPEAKABLE_ANYWHERE,
1865
+ ): FailureReason | undefined {
1866
+ if (failure === undefined) return undefined;
1867
+ if (failure.cause !== undefined) {
1868
+ let cause: PlaybookFailureCause | undefined;
1869
+ try {
1870
+ cause = assertPlaybookFailureCause(failure.cause);
1871
+ } catch {
1872
+ // A cause the closed validator refuses is not a cause.
1873
+ }
1874
+ if (cause !== undefined) {
1875
+ return causeReason(cause, describePlaybook, speakable);
1876
+ }
1877
+ }
1878
+ return failure.message === undefined
1879
+ ? undefined
1880
+ : recordedReason(failure.message, speakable);
1881
+ }
1882
+
1883
+ /**
1884
+ * CAPTAIN-71: the one way a failure is put into words — `<Subject> failed:
1885
+ * <reason>.` — `subject` naming what failed in the Boss's words and `failure`
1886
+ * the error or cause the shell recorded.
1887
+ */
1888
+ function failureStatement(
1889
+ subject: string,
1890
+ failure: RecordedFailure | undefined,
1891
+ describePlaybook: DescribePlaybook = (playbookId) => playbookId,
1892
+ speakable: Speakable = SPEAKABLE_ANYWHERE,
1893
+ ): string {
1894
+ return sentenceCase(
1895
+ failureClause(subject, failureReason(failure, describePlaybook, speakable)),
1896
+ );
1897
+ }
1898
+
1899
+ /** The runtime's refusal of `action`, its reason said as a failure's is. */
1900
+ function refusalStatement(
1901
+ action: string,
1902
+ reason: FailureReason | undefined,
1903
+ ): string {
1904
+ return sentenceCase(
1905
+ reasonedClause('the runtime', `refused ${action}`, 'reason', reason),
1906
+ );
1907
+ }
1908
+
1909
+ /**
1910
+ * The `Failure:` line of a parked failure (CAPTAIN-67): the reason's phrase,
1911
+ * or, for a reason kept as recorded, the step's failure statement; and the
1912
+ * reason it states, which a statement carrying the same failure holds.
1913
+ */
1914
+ function failureLine(reason: FailureReason | undefined): {
1915
+ readonly sentence: string;
1916
+ readonly reason: string;
1917
+ } {
1918
+ if (reason?.kind === 'phrase') {
1919
+ return { sentence: reason.text, reason: reason.text };
1393
1920
  }
1921
+ const sentence = failureClause('the step', reason);
1922
+ return {
1923
+ sentence,
1924
+ reason: reason === undefined ? sentence : codeSpan(reason.text),
1925
+ };
1394
1926
  }
1395
1927
 
1396
1928
  function failureControlLine(action: {
@@ -1405,26 +1937,297 @@ function failureControlLine(action: {
1405
1937
  : `- ${action.label} (${standing}: ${controlReasonPhrase(action.reason)})`;
1406
1938
  }
1407
1939
 
1408
- function failureReportText(
1409
- sentence: string,
1410
- controls: readonly string[],
1940
+ /** A report appended to the visible reply (CAPTAIN-69). */
1941
+ type PresentationReport =
1942
+ /**
1943
+ * A report the settlement facts also state — the resumption warning, the
1944
+ * carried-changes report — which a reply listing them need not repeat.
1945
+ */
1946
+ | {
1947
+ readonly kind: 'stated';
1948
+ readonly text: string;
1949
+ readonly facts: readonly string[];
1950
+ }
1951
+ /** The unresolved-effect report, whose lines are statements of their own. */
1952
+ | { readonly kind: 'effects'; readonly lines: readonly string[] };
1953
+
1954
+ interface FailureReport {
1955
+ /** The `Failure:` line's sentence. */
1956
+ readonly sentence: string;
1957
+ /** The reason the line states, which weighs it (CAPTAIN-69). */
1958
+ readonly reason: string;
1959
+ readonly controls: readonly string[];
1960
+ }
1961
+
1962
+ /** A fact that stated a frame's failure, and how it reads by the leaf's cause. */
1963
+ interface FailureStatement {
1964
+ readonly frame: EngagementFrame;
1965
+ readonly fact: string;
1966
+ /**
1967
+ * The error message the fact stated, which the leaf's recorded error must
1968
+ * carry for the fact to have stated the parked failure; absent where the
1969
+ * fact is that failure by construction — the leaf's failed run result.
1970
+ */
1971
+ readonly message?: string;
1972
+ /** The same fact with `failure` as its reason (CAPTAIN-71). */
1973
+ readonly restate: (failure: RecordedFailure) => string;
1974
+ /**
1975
+ * The fact as the Boss reads it, where that differs from the prompt's: a
1976
+ * runtime action named by its label rather than its id (PBRT-52).
1977
+ */
1978
+ readonly speak?: (fact: string) => string;
1979
+ }
1980
+
1981
+ /** A reply the shell composes itself, with the facts it lists. */
1982
+ interface ShellReply {
1983
+ readonly text: string;
1984
+ /** The settlement facts the reply was assembled from (CAPTAIN-69). */
1985
+ readonly stated?: readonly string[];
1986
+ }
1987
+
1988
+ /**
1989
+ * Whether `container` already states `statement`: the same words, case and
1990
+ * terminal mark aside, standing as a phrase of their own rather than inside a
1991
+ * longer word or command.
1992
+ */
1993
+ function alreadyStates(container: string, statement: string): boolean {
1994
+ const haystack = container.toLowerCase();
1995
+ const needle = statement.toLowerCase().replace(/[.!?]+$/, '');
1996
+ if (needle.length === 0) return true;
1997
+ for (
1998
+ let at = haystack.indexOf(needle);
1999
+ at >= 0;
2000
+ at = haystack.indexOf(needle, at + 1)
2001
+ ) {
2002
+ const before = haystack.charAt(at - 1);
2003
+ const after = haystack.charAt(at + needle.length);
2004
+ if (!/[\p{L}\p{N}_/-]/u.test(before) && !/[\p{L}\p{N}_-]/u.test(after)) {
2005
+ return true;
2006
+ }
2007
+ }
2008
+ return false;
2009
+ }
2010
+
2011
+ /**
2012
+ * The three kinds of statement a report the shell says holds (CAPTAIN-69):
2013
+ * a listed fact, a numbered unresolved-effect entry, and the `Failure:` line.
2014
+ */
2015
+ type StatementKind = 'fact' | 'entry' | 'failure';
2016
+
2017
+ /** A statement the report assembly weighs. */
2018
+ interface BossStatement {
2019
+ readonly text: string;
2020
+ /** Its kind, which decides what may drop it; a fact where absent. */
2021
+ readonly kind?: StatementKind;
2022
+ /** The reason a `Failure:` line states (CAPTAIN-67). */
2023
+ readonly reason?: string;
2024
+ }
2025
+
2026
+ /** The words a reason CAPTAIN-9 withholds is said as. */
2027
+ const WITHHELD_REASON = 'an internal error';
2028
+
2029
+ /** `text` with each of its code spans read as a space. */
2030
+ function wordsOutsideCodeSpans(text: string): string {
2031
+ let words = '';
2032
+ let at = 0;
2033
+ for (const span of codeSpans(text)) {
2034
+ words += `${text.slice(at, span.start)} `;
2035
+ at = span.end;
2036
+ }
2037
+ return words + text.slice(at);
2038
+ }
2039
+
2040
+ /** The shell's words for something foreign in a statement (CAPTAIN-69). */
2041
+ interface ShellWords {
2042
+ /** Failure subjects, as a statement standing alone begins with one. */
2043
+ readonly subjects: Iterable<string>;
2044
+ /** Runtime actions' labels, quoted as a statement names one. */
2045
+ readonly labels: Iterable<string>;
2046
+ }
2047
+
2048
+ const NO_SHELL_WORDS: ShellWords = { subjects: [], labels: [] };
2049
+
2050
+ /**
2051
+ * CAPTAIN-69's guard on one statement the shell says, so no statement is
2052
+ * lost for its own or another's words: a code span whose content repeats an
2053
+ * identifier CAPTAIN-9 withholds reads, with the noun before it, `an internal
2054
+ * error`; a reason the renderer phrased whose own words fail CAPTAIN-9 reads
2055
+ * so too; a failure subject beginning the statement whose own words fail
2056
+ * reads `The step`; and a quoted action label whose own words fail reads `the
2057
+ * selected action`. Whatever is left, the statement is never left out.
2058
+ */
2059
+ function guardedStatement(
2060
+ text: string,
2061
+ withholds: (content: string) => boolean,
2062
+ refuses: (words: string) => boolean,
2063
+ phrased: Iterable<string> = [],
2064
+ shellWords: ShellWords = NO_SHELL_WORDS,
1411
2065
  ): string {
1412
- return [
1413
- `Failure: ${sentence}`,
1414
- 'Controls:',
1415
- ...(controls.length === 0 ? ['- none'] : controls),
1416
- ].join('\n');
2066
+ let guarded = '';
2067
+ let at = 0;
2068
+ for (const span of codeSpans(text)) {
2069
+ const before = text.slice(at, span.start);
2070
+ guarded += withholds(span.content)
2071
+ ? `${before.replace(/the (?:error|reason) $/, '')}${WITHHELD_REASON}`
2072
+ : before + text.slice(span.start, span.end);
2073
+ at = span.end;
2074
+ }
2075
+ guarded += text.slice(at);
2076
+ const fails = (statement: string): boolean => {
2077
+ const words = wordsOutsideCodeSpans(statement);
2078
+ return words.trim().length > 0 && refuses(words);
2079
+ };
2080
+ for (const reason of phrased) {
2081
+ if (!fails(reason)) continue;
2082
+ guarded =
2083
+ guarded === `Failure: ${reason}`
2084
+ ? `Failure: the step failed with ${WITHHELD_REASON}.`
2085
+ : guarded.split(`: ${reason}`).join(` with ${WITHHELD_REASON}.`);
2086
+ }
2087
+ for (const subject of shellWords.subjects) {
2088
+ if (guarded.startsWith(`${subject} `) && fails(subject)) {
2089
+ guarded = `The step${guarded.slice(subject.length)}`;
2090
+ }
2091
+ }
2092
+ for (const label of shellWords.labels) {
2093
+ if (fails(label)) {
2094
+ guarded = guarded.split(label).join('the selected action');
2095
+ }
2096
+ }
2097
+ return guarded;
2098
+ }
2099
+
2100
+ /**
2101
+ * CAPTAIN-69: the one way the shell assembles Boss-facing text of its own.
2102
+ * Each statement ends in one terminal mark and passes `guard`. The kinds
2103
+ * decide what is dropped: an unresolved-effect entry records a boundary of
2104
+ * its own and always stays, while a fact or the `Failure:` line is dropped
2105
+ * where an entry, or a fact before it that stays, carries its reason — for
2106
+ * the `Failure:` line the reason it states, for a fact every reason the
2107
+ * renderer gave that turn (`reasons`) that it holds, else the fact itself.
2108
+ * The result is aligned with `statements`, a dropped statement `undefined`.
2109
+ */
2110
+ function assembleStatements(
2111
+ statements: readonly BossStatement[],
2112
+ reasons: Iterable<string> = [],
2113
+ guard: (text: string) => string = (text) => text,
2114
+ ): readonly (string | undefined)[] {
2115
+ const rendered = [...new Set(reasons)];
2116
+ const weighed = statements.map(({ text, kind = 'fact', reason }) => {
2117
+ const statement = oneTerminalMark(text);
2118
+ if (reason !== undefined) return { text: statement, kind, own: [reason] };
2119
+ const held = rendered.filter((candidate) =>
2120
+ alreadyStates(statement, candidate),
2121
+ );
2122
+ return { text: statement, kind, own: held.length > 0 ? held : [statement] };
2123
+ });
2124
+ const entries = weighed
2125
+ .filter(({ kind }) => kind === 'entry')
2126
+ .map(({ text }) => text);
2127
+ const facts: string[] = [];
2128
+ const carried = (own: readonly string[], containers: readonly string[]) =>
2129
+ containers.some((container) =>
2130
+ own.every((reason) => alreadyStates(container, reason)),
2131
+ );
2132
+ const stays = weighed.map(({ text, kind, own }) => {
2133
+ if (kind !== 'fact') return true;
2134
+ if (carried(own, entries) || carried(own, facts)) return false;
2135
+ facts.push(text);
2136
+ return true;
2137
+ });
2138
+ return weighed.map(({ text, kind, own }, index) =>
2139
+ stays[index] &&
2140
+ (kind !== 'failure' || !carried(own, [...entries, ...facts]))
2141
+ ? oneTerminalMark(guard(text))
2142
+ : undefined,
2143
+ );
2144
+ }
2145
+
2146
+ /** The statements of `statements` that stay, as CAPTAIN-69 assembles them. */
2147
+ function bossReport(
2148
+ statements: readonly (string | BossStatement)[],
2149
+ reasons: Iterable<string> = [],
2150
+ ): string[] {
2151
+ return assembleStatements(
2152
+ statements.map((statement) =>
2153
+ typeof statement === 'string' ? { text: statement } : statement,
2154
+ ),
2155
+ reasons,
2156
+ ).filter((statement): statement is string => statement !== undefined);
2157
+ }
2158
+
2159
+ /** A turn's shell-said statements, as they stay (CAPTAIN-69). */
2160
+ interface AssembledReport {
2161
+ /** The listed facts that stay. */
2162
+ readonly facts: readonly string[];
2163
+ /** The unresolved-effect entries that stay. */
2164
+ readonly effects: readonly string[];
2165
+ /** The `Failure:` line, where it stays. */
2166
+ readonly failure?: string;
2167
+ }
2168
+
2169
+ /**
2170
+ * The reports that accompany a reply, from the turn's assembled statements
2171
+ * (CAPTAIN-69): a report whose facts the reply lists is not said again, the
2172
+ * unresolved-effect report carries every entry as guarded, and the failure
2173
+ * report's `Controls:` list always stays.
2174
+ */
2175
+ function presentationSuffix(
2176
+ turn: ActiveTurn,
2177
+ assembled: AssembledReport,
2178
+ ): string | undefined {
2179
+ const parts = turn.presentationReports.flatMap((report) => {
2180
+ if (report.kind === 'stated') {
2181
+ return report.facts.length > 0 &&
2182
+ report.facts.every((fact) =>
2183
+ assembled.facts.some((listed) => alreadyStates(listed, fact)),
2184
+ )
2185
+ ? []
2186
+ : [report.text];
2187
+ }
2188
+ return assembled.effects.length === 0
2189
+ ? []
2190
+ : [unresolvedEffectBossReport(assembled.effects)];
2191
+ });
2192
+ const failure = turn.failureReport;
2193
+ if (failure !== undefined) {
2194
+ parts.push(
2195
+ [
2196
+ ...(assembled.failure === undefined ? [] : [assembled.failure]),
2197
+ 'Controls:',
2198
+ ...(failure.controls.length === 0 ? ['- none'] : failure.controls),
2199
+ ].join('\n'),
2200
+ );
2201
+ }
2202
+ return parts.length === 0 ? undefined : parts.join('\n\n');
1417
2203
  }
1418
2204
 
1419
2205
  function appendMandatoryPresentationSuffix(
1420
2206
  turn: ActiveTurn,
1421
2207
  suffix: string,
2208
+ facts: readonly string[] = [],
1422
2209
  ): void {
1423
- const current = turn.mandatoryPresentationSuffix;
1424
- if (current === undefined) {
1425
- turn.mandatoryPresentationSuffix = suffix;
1426
- } else if (!current.includes(suffix)) {
1427
- turn.mandatoryPresentationSuffix = `${current}\n\n${suffix}`;
2210
+ if (
2211
+ !turn.presentationReports.some(
2212
+ (report) => report.kind === 'stated' && report.text.includes(suffix),
2213
+ )
2214
+ ) {
2215
+ turn.presentationReports.push({ kind: 'stated', text: suffix, facts });
2216
+ }
2217
+ }
2218
+
2219
+ function appendUnresolvedEffectReport(
2220
+ turn: ActiveTurn,
2221
+ unresolvedEffects: readonly PlaybookCaptainUnresolvedEffect[],
2222
+ ): void {
2223
+ if (
2224
+ unresolvedEffects.length > 0 &&
2225
+ !turn.presentationReports.some((report) => report.kind === 'effects')
2226
+ ) {
2227
+ turn.presentationReports.push({
2228
+ kind: 'effects',
2229
+ lines: unresolvedEffectReportLines(unresolvedEffects),
2230
+ });
1428
2231
  }
1429
2232
  }
1430
2233
 
@@ -3781,23 +4584,60 @@ export function createPlaybookCaptainShell(
3781
4584
  const report = carriedPreExistingReport(carried);
3782
4585
  if (report === undefined) return;
3783
4586
  turn.carriedPreExistingReported = true;
3784
- appendMandatoryPresentationSuffix(turn, report);
3785
4587
  const facts = carriedPreExistingFacts(carried);
3786
- turn.settlementFacts.push(...facts);
3787
- // The action's own report was assembled from the facts as they stood when
3788
- // it settled; the result-phase prompt reads this report, so the same facts
3789
- // join it here rather than only the turn's running list.
3790
- if (turn.report !== undefined) {
3791
- turn.report = {
3792
- ...turn.report,
3793
- facts: [...turn.report.facts, ...facts],
3794
- ...(turn.report.bossFacts === undefined
3795
- ? {}
3796
- : { bossFacts: [...turn.report.bossFacts, ...facts] }),
3797
- };
4588
+ appendMandatoryPresentationSuffix(turn, report, facts);
4589
+ for (const fact of facts) {
4590
+ joinSettlementFact(turn, fact);
3798
4591
  }
3799
4592
  };
3800
4593
 
4594
+ /**
4595
+ * CAPTAIN-67: one fact joins the turn once. The action's own report was
4596
+ * assembled from the facts as they stood when it settled, and the
4597
+ * result-phase prompt reads that report, so a later fact joins it as well as
4598
+ * the turn's running list — which that report's `facts` often *is*, so
4599
+ * appending to both stated the fact twice. A fact that restates one already
4600
+ * listed for the same failure takes that fact's place.
4601
+ */
4602
+ const joinSettlementFact = (
4603
+ turn: ActiveTurn,
4604
+ fact: string,
4605
+ replacing?: string,
4606
+ speak: (fact: string) => string = (text) => text,
4607
+ ): void => {
4608
+ const placed = (
4609
+ list: readonly string[],
4610
+ entry: string,
4611
+ replaced: string | undefined,
4612
+ ): readonly string[] => {
4613
+ if (list.includes(entry)) return list;
4614
+ const at = replaced === undefined ? -1 : list.indexOf(replaced);
4615
+ return at < 0
4616
+ ? [...list, entry]
4617
+ : list.map((listed, index) => (index === at ? entry : listed));
4618
+ };
4619
+ const running = placed(turn.settlementFacts, fact, replacing);
4620
+ if (running !== turn.settlementFacts) {
4621
+ turn.settlementFacts.splice(0, turn.settlementFacts.length, ...running);
4622
+ }
4623
+ const report = turn.report;
4624
+ if (report === undefined) return;
4625
+ // The Boss-facing rendering holds the same fact as the Boss reads it.
4626
+ turn.report = {
4627
+ ...report,
4628
+ facts: placed(report.facts, fact, replacing),
4629
+ ...(report.bossFacts === undefined
4630
+ ? {}
4631
+ : {
4632
+ bossFacts: placed(
4633
+ report.bossFacts,
4634
+ speak(fact),
4635
+ replacing === undefined ? undefined : speak(replacing),
4636
+ ),
4637
+ }),
4638
+ };
4639
+ };
4640
+
3801
4641
  const normalizeInstalledRetainedGenerations = (
3802
4642
  value: Readonly<Record<string, PlaybookCaptainRetainedGeneration>>,
3803
4643
  ): Map<string, PlaybookCaptainRetainedGeneration> => {
@@ -4450,6 +5290,19 @@ export function createPlaybookCaptainShell(
4450
5290
  return { name: 'Error', message: String(value) };
4451
5291
  };
4452
5292
 
5293
+ /**
5294
+ * CAPTAIN-71: what the shell recorded of a thrown value — its message, and
5295
+ * the cause the runtime decided where the value carries one (DR-063 §2).
5296
+ */
5297
+ const failureOf = (error: unknown): RecordedFailure => {
5298
+ const normalized = normalizeErrorCompact(error) ?? {
5299
+ name: 'Error',
5300
+ message: String(error),
5301
+ };
5302
+ const { cause } = normalizeError(error);
5303
+ return cause === undefined ? normalized : { ...normalized, cause };
5304
+ };
5305
+
4453
5306
  const payloadRecord = (
4454
5307
  payload: unknown,
4455
5308
  ): Record<string, unknown> | undefined =>
@@ -5652,12 +6505,8 @@ export function createPlaybookCaptainShell(
5652
6505
  visibilityControlError = error;
5653
6506
  } else {
5654
6507
  if (runFailureFacts) {
5655
- const normalized = normalizeErrorCompact(error) ?? {
5656
- name: 'Error',
5657
- message: String(error),
5658
- };
5659
6508
  runFailureFacts.push(
5660
- `Cleanup while removing ${frameLabel(child)} failed: ${normalized.name}: ${compactEvidence(normalized.message)}.`,
6509
+ stateFailure(`cleanup after ${frameLabel(child)}`, failureOf(error)),
5661
6510
  );
5662
6511
  }
5663
6512
  effectiveResult = {
@@ -5760,12 +6609,14 @@ export function createPlaybookCaptainShell(
5760
6609
  );
5761
6610
  }
5762
6611
  if (result.outcome === 'failed' && runFailureFacts) {
5763
- runFailureFacts.push(
5764
- `${frameLabel(frame)} failed` +
5765
- (result.error
5766
- ? `: ${result.error.name}: ${compactEvidence(result.error.message)}.`
5767
- : '.'),
5768
- );
6612
+ const subject = frameLabel(frame);
6613
+ const fact = stateFailure(subject, result.error);
6614
+ runFailureFacts.push(fact);
6615
+ activeTurn?.failureStatements.push({
6616
+ frame,
6617
+ fact,
6618
+ restate: (failure) => stateFailure(subject, failure),
6619
+ });
5769
6620
  }
5770
6621
  if (leafFrame()) {
5771
6622
  await setMode('engaged.parked', `turn:${result.outcome}`);
@@ -6445,9 +7296,12 @@ export function createPlaybookCaptainShell(
6445
7296
  * followed by another attempt: the Promise cannot prove whether rendering
6446
7297
  * began, so retrying could duplicate a reply the Boss already saw.
6447
7298
  */
6448
- const surfaceSettlement = async (
6449
- settlement: { context: CaptainContext; text: string },
6450
- ): Promise<void> => {
7299
+ const surfaceSettlement = async (settlement: {
7300
+ context: CaptainContext;
7301
+ text: string;
7302
+ /** The facts a shell-composed reply lists, which its reports need not repeat. */
7303
+ stated?: readonly string[];
7304
+ }): Promise<void> => {
6451
7305
  const turn = activeTurn;
6452
7306
  if (turn?.presentationAttempted) {
6453
7307
  const error = new Error(
@@ -6461,7 +7315,13 @@ export function createPlaybookCaptainShell(
6461
7315
  // all of this prose. Preserve the exact attempt before crossing the
6462
7316
  // boundary; the uncertainty record below keeps recovery from pretending
6463
7317
  // delivery was confirmed while still understanding a Boss follow-up.
6464
- const suffix = turn?.mandatoryPresentationSuffix;
7318
+ const suffix =
7319
+ turn === undefined
7320
+ ? undefined
7321
+ : presentationSuffix(
7322
+ turn,
7323
+ assembleTurnReport(turn, settlement.stated ?? []),
7324
+ );
6465
7325
  const visibleText =
6466
7326
  suffix === undefined || settlement.text.includes(suffix)
6467
7327
  ? settlement.text
@@ -6561,6 +7421,85 @@ export function createPlaybookCaptainShell(
6561
7421
  suppliedIdentifiers(),
6562
7422
  );
6563
7423
 
7424
+ // CAPTAIN-74: whether a recorded reason may be quoted outside a code span —
7425
+ // what the renderer asks before it quotes one as prose.
7426
+ const speakableWords = (words: string): boolean =>
7427
+ replyRejection(words) === undefined;
7428
+
7429
+ // CAPTAIN-69: the guard every statement the shell says passes on its own —
7430
+ // CAPTAIN-9 outside its code spans, and inside one only its identifiers —
7431
+ // with the reasons the renderer gave that turn and the shell words it may
7432
+ // say neutrally.
7433
+ const statementGuard = (text: string, turn: ActiveTurn): string =>
7434
+ guardedStatement(
7435
+ text,
7436
+ (content) =>
7437
+ identifierRejection(
7438
+ content,
7439
+ liveSessionIdentifiers(),
7440
+ liveStateIdentifiers(),
7441
+ suppliedIdentifiers(),
7442
+ ) !== undefined,
7443
+ (words) => replyRejection(words) !== undefined,
7444
+ turn.statedReasons,
7445
+ turn.shellWords,
7446
+ );
7447
+
7448
+ /** A reason the renderer gave a statement this turn, noted for the assembly. */
7449
+ const noteReason = (
7450
+ reason: FailureReason | undefined,
7451
+ ): FailureReason | undefined => {
7452
+ if (reason !== undefined) {
7453
+ activeTurn?.statedReasons.add(
7454
+ reason.kind === 'phrase' ? reason.text : codeSpan(reason.text),
7455
+ );
7456
+ }
7457
+ return reason;
7458
+ };
7459
+
7460
+ /**
7461
+ * CAPTAIN-69: the turn's shell-said statements — the facts a reply lists,
7462
+ * the unresolved-effect entries, and the `Failure:` line — each said once.
7463
+ */
7464
+ const assembleTurnReport = (
7465
+ turn: ActiveTurn,
7466
+ facts: readonly string[],
7467
+ ): AssembledReport => {
7468
+ const effects = turn.presentationReports.flatMap((report) =>
7469
+ report.kind === 'effects' ? report.lines : [],
7470
+ );
7471
+ const failure = turn.failureReport;
7472
+ const said = assembleStatements(
7473
+ [
7474
+ ...facts.map((text) => ({ text, kind: 'fact' as const })),
7475
+ ...effects.map((text) => ({ text, kind: 'entry' as const })),
7476
+ ...(failure === undefined
7477
+ ? []
7478
+ : [
7479
+ {
7480
+ text: `Failure: ${failure.sentence}`,
7481
+ kind: 'failure' as const,
7482
+ reason: failure.reason,
7483
+ },
7484
+ ]),
7485
+ ],
7486
+ turn.statedReasons,
7487
+ (text) => statementGuard(text, turn),
7488
+ );
7489
+ const kept = (from: number, to: number): string[] =>
7490
+ said
7491
+ .slice(from, to)
7492
+ .filter((statement): statement is string => statement !== undefined);
7493
+ const listed = facts.length + effects.length;
7494
+ return {
7495
+ facts: kept(0, facts.length),
7496
+ effects: kept(facts.length, listed),
7497
+ ...(failure === undefined || said[listed] === undefined
7498
+ ? {}
7499
+ : { failure: said[listed] }),
7500
+ };
7501
+ };
7502
+
6564
7503
  // -------------------------------------------------------------------------
6565
7504
  // The durable conversation (CAPTAIN-31, CAPTAIN-35).
6566
7505
  // -------------------------------------------------------------------------
@@ -6864,7 +7803,7 @@ export function createPlaybookCaptainShell(
6864
7803
  outcome: DurableCallOutcome,
6865
7804
  compose: (options: { reseedDigest?: string; proseRejection?: string }) => string,
6866
7805
  ): Promise<void> => {
6867
- let text = outcome.finalText;
7806
+ let text = respondEnvelopeText(outcome.finalText);
6868
7807
  const rejection = replyRejection(text);
6869
7808
  if (rejection !== undefined) {
6870
7809
  // DR-028 §26: the reseed already was this call's single corrective, so a
@@ -6876,7 +7815,7 @@ export function createPlaybookCaptainShell(
6876
7815
  const reasked = await durableCall(context, (options) =>
6877
7816
  compose({ ...options, proseRejection: rejection }),
6878
7817
  );
6879
- text = reasked.finalText;
7818
+ text = respondEnvelopeText(reasked.finalText);
6880
7819
  const second = replyRejection(text);
6881
7820
  if (second !== undefined) {
6882
7821
  throw markControlFailure(new CaptainProseError(second));
@@ -6973,8 +7912,8 @@ export function createPlaybookCaptainShell(
6973
7912
  // Through the one presentation seam, exactly where a model-composed
6974
7913
  // reply is presented, so this reply is journaled and single-attempt
6975
7914
  // like every other. It needs no reply validation or corrective re-ask:
6976
- // `giveUpReplyText` already validated what it composed.
6977
- await surfaceSettlement({ context, text: giveUpReplyText() });
7915
+ // every statement `giveUpReply` lists passed CAPTAIN-69's guard.
7916
+ await surfaceSettlement({ context, ...giveUpReply() });
6978
7917
  return { status: 'ok' as const, finalText: 'ok' };
6979
7918
  }
6980
7919
  const outcome = await durableCall(context, compose);
@@ -7162,7 +8101,7 @@ export function createPlaybookCaptainShell(
7162
8101
  const summary = leafStateSummary();
7163
8102
  const settlement: ControllerSettlementDraft = {
7164
8103
  status: 'rejected',
7165
- facts: [`Rejected: ${reason}.`],
8104
+ facts: [`Rejected: ${oneTerminalMark(reason)}`],
7166
8105
  reason,
7167
8106
  ...(summary === undefined ? {} : { leafStateSummary: summary }),
7168
8107
  };
@@ -7232,13 +8171,7 @@ export function createPlaybookCaptainShell(
7232
8171
  return false;
7233
8172
  } catch (error) {
7234
8173
  // A failing dispose never resurrects the engagement (DR-029).
7235
- const normalized = normalizeErrorCompact(error) ?? {
7236
- name: 'Error',
7237
- message: String(error),
7238
- };
7239
- facts.push(
7240
- `Dismissed the ${label} engagement; its disposal failed: ${normalized.name}: ${compactEvidence(normalized.message)}.`,
7241
- );
8174
+ facts.push(stateFailure(`dismissing ${label}`, failureOf(error)));
7242
8175
  return true;
7243
8176
  }
7244
8177
  };
@@ -7253,12 +8186,11 @@ export function createPlaybookCaptainShell(
7253
8186
  try {
7254
8187
  frame = await runEffect(() => engage(entry));
7255
8188
  } catch (error) {
7256
- const normalized = normalizeErrorCompact(error) ?? {
7257
- name: 'Error',
7258
- message: String(error),
7259
- };
7260
8189
  facts.push(
7261
- `Starting /${enablementById.get(entry.id)!.command} failed: ${normalized.name}: ${compactEvidence(normalized.message)}.`,
8190
+ stateFailure(
8191
+ `starting /${enablementById.get(entry.id)!.command}`,
8192
+ failureOf(error),
8193
+ ),
7262
8194
  );
7263
8195
  return { report: emptyReport(), failed: true };
7264
8196
  }
@@ -7270,13 +8202,18 @@ export function createPlaybookCaptainShell(
7270
8202
  // CAPTAIN-22/23: a visibility rejection is an internal shell or
7271
8203
  // composition error, never a settled Boss outcome.
7272
8204
  if (outcome.error instanceof VisibilityControlError) throw outcome.error;
7273
- const normalized = normalizeErrorCompact(outcome.error) ?? {
7274
- name: 'Error',
7275
- message: String(outcome.error),
7276
- };
7277
- facts.push(
7278
- `The first turn of ${frameLabel(frame)} failed: ${normalized.name}: ${compactEvidence(normalized.message)}.`,
7279
- );
8205
+ const failure = failureOf(outcome.error);
8206
+ const subject = `the first turn of ${frameLabel(frame)}`;
8207
+ const restate = (recorded: RecordedFailure): string =>
8208
+ stateFailure(subject, recorded);
8209
+ const fact = restate(failure);
8210
+ facts.push(fact);
8211
+ activeTurn?.failureStatements.push({
8212
+ frame,
8213
+ fact,
8214
+ message: failure.message,
8215
+ restate,
8216
+ });
7280
8217
  return { frame, report: outcome.report, failed: true };
7281
8218
  }
7282
8219
  return { frame, report: outcome.report, failed: false };
@@ -7380,21 +8317,25 @@ export function createPlaybookCaptainShell(
7380
8317
  await requireSession().emitStatus(
7381
8318
  `◇ /${enablementById.get(rootPlaybookId)!.command} resumed`,
7382
8319
  );
8320
+ // The warning is both a settlement fact and a report the reply carries;
8321
+ // a reply that lists the fact need not say it twice (CAPTAIN-69).
8322
+ const warning = requiresEffectReconciliation
8323
+ ? 'The retained work remains parked until its repository-effect evidence is reconciled; no ordinary action was resumed.'
8324
+ : RESUMPTION_DUPLICATE_EFFECT_WARNING;
7383
8325
  if (activeTurn) {
7384
8326
  appendMandatoryPresentationSuffix(
7385
8327
  activeTurn,
7386
8328
  requiresEffectReconciliation
7387
8329
  ? 'The retained work remains parked until its repository-effect evidence is reconciled.'
7388
8330
  : RESUMPTION_DUPLICATE_EFFECT_WARNING,
8331
+ [warning],
7389
8332
  );
7390
8333
  }
7391
8334
  return [
7392
8335
  generation.rootStateDescription === undefined
7393
8336
  ? `Resumed /${enablementById.get(rootPlaybookId)!.command} from its retained state; no published root-state description was retained.`
7394
8337
  : `Resumed /${enablementById.get(rootPlaybookId)!.command} from the retained state described as ${quoteEvidence(compactEvidence(generation.rootStateDescription))}.`,
7395
- requiresEffectReconciliation
7396
- ? 'The retained work remains parked until its repository-effect evidence is reconciled; no ordinary action was resumed.'
7397
- : RESUMPTION_DUPLICATE_EFFECT_WARNING,
8338
+ warning,
7398
8339
  ];
7399
8340
  } catch (error) {
7400
8341
  if (installed) {
@@ -7551,6 +8492,25 @@ export function createPlaybookCaptainShell(
7551
8492
  return enablement === undefined ? playbookId : `/${enablement.command}`;
7552
8493
  };
7553
8494
 
8495
+ /**
8496
+ * CAPTAIN-71: a failure statement naming nested playbooks by command, its
8497
+ * subject said as `the step` where CAPTAIN-9 refuses it (CAPTAIN-69).
8498
+ */
8499
+ const stateFailure = (
8500
+ subject: string,
8501
+ failure: RecordedFailure | undefined,
8502
+ ): string => {
8503
+ activeTurn?.shellWords.subjects.add(sentenceCase(subject));
8504
+ return sentenceCase(
8505
+ failureClause(
8506
+ subject,
8507
+ noteReason(
8508
+ failureReason(failure, describeNestedPlaybook, speakableWords),
8509
+ ),
8510
+ ),
8511
+ );
8512
+ };
8513
+
7554
8514
  // DR-063 §4: appended once, beside the unresolved-effect and pre-existing
7555
8515
  // change reports, whenever the turn settles with the leaf parked in its
7556
8516
  // failure state or behind the retained-effect fence.
@@ -7574,24 +8534,34 @@ export function createPlaybookCaptainShell(
7574
8534
  const cause = leafFailureCause(view, fenced, unresolvedEffects);
7575
8535
  if (cause === undefined) return;
7576
8536
  turn.failureReported = true;
7577
- const sentence = failureCauseSentence(cause, describeNestedPlaybook);
7578
- appendMandatoryPresentationSuffix(
7579
- turn,
7580
- failureReportText(sentence, failureControlLines(view, fenced)),
8537
+ const failure: RecordedFailure = { cause };
8538
+ // One failure, one fact: where a fact this turn already stated the leaf's
8539
+ // parked failure — its failed run result, or a runtime action's failed
8540
+ // receipt or a thrown first turn, delivery, or runtime action whose error
8541
+ // is the one the leaf records — the cause restates that fact in its place
8542
+ // rather than joining beside it.
8543
+ const recorded = (view?.lastError ?? lastError)?.message;
8544
+ const statement = turn.failureStatements.find(
8545
+ ({ frame, message }) =>
8546
+ frame === leaf &&
8547
+ (message === undefined ||
8548
+ (recorded !== undefined &&
8549
+ compactEvidence(message) === compactEvidence(recorded))),
7581
8550
  );
7582
- // The result-phase prompt reads the turn's report, so the same fact joins
7583
- // it here rather than only the turn's running list.
7584
- const fact = `The workflow failed: ${sentence}`;
7585
- turn.settlementFacts.push(fact);
7586
- if (turn.report !== undefined) {
7587
- turn.report = {
7588
- ...turn.report,
7589
- facts: [...turn.report.facts, fact],
7590
- ...(turn.report.bossFacts === undefined
7591
- ? {}
7592
- : { bossFacts: [...turn.report.bossFacts, fact] }),
7593
- };
7594
- }
8551
+ const fact =
8552
+ statement === undefined
8553
+ ? stateFailure(frameLabel(leaf), failure)
8554
+ : statement.restate(failure);
8555
+ const speak = statement?.speak ?? ((text: string) => text);
8556
+ turn.failureReport = {
8557
+ ...failureLine(
8558
+ noteReason(
8559
+ failureReason(failure, describeNestedPlaybook, speakableWords),
8560
+ ),
8561
+ ),
8562
+ controls: failureControlLines(view, fenced),
8563
+ };
8564
+ joinSettlementFact(turn, fact, statement?.fact, speak);
7595
8565
  };
7596
8566
 
7597
8567
  const settleSelection = async (
@@ -7606,9 +8576,8 @@ export function createPlaybookCaptainShell(
7606
8576
  const freezeControllerEvidence =
7607
8577
  (): readonly PlaybookCaptainUnresolvedEffect[] => {
7608
8578
  frozenUnresolvedEffects ??= freezeTurnUnresolvedEffects();
7609
- const report = unresolvedEffectBossReport(frozenUnresolvedEffects);
7610
- if (turn !== undefined && report !== undefined) {
7611
- appendMandatoryPresentationSuffix(turn, report);
8579
+ if (turn !== undefined) {
8580
+ appendUnresolvedEffectReport(turn, frozenUnresolvedEffects);
7612
8581
  }
7613
8582
  if (turn !== undefined) reportCarriedPreExistingChanges(turn);
7614
8583
  if (turn !== undefined) {
@@ -7633,10 +8602,7 @@ export function createPlaybookCaptainShell(
7633
8602
  } catch (error) {
7634
8603
  if (turn?.presentationError === error) throw error;
7635
8604
  const aborted = signal.aborted || activeContext?.signal.aborted === true;
7636
- const normalized = normalizeErrorCompact(error) ?? {
7637
- name: 'Error',
7638
- message: String(error),
7639
- };
8605
+ const failure = failureOf(error);
7640
8606
  if (aborted) {
7641
8607
  markConversationUnsynchronized();
7642
8608
  if (turn?.outcomePending) {
@@ -7661,11 +8627,25 @@ export function createPlaybookCaptainShell(
7661
8627
  const mayHaveApplied =
7662
8628
  selection.action !== 'respond' &&
7663
8629
  containsEffectThrow(turn, error);
7664
- turn.settlementFacts.push(
7665
- mayHaveApplied
7666
- ? `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.`
7667
- : `The ${selection.action} action failed before its complete outcome could be confirmed: ${normalized.name}: ${compactEvidence(normalized.message)}. It was not repeated automatically.`,
7668
- );
8630
+ const subject = `the ${selection.action} action`;
8631
+ const unconfirmed = mayHaveApplied
8632
+ ? 'Its complete outcome could not be confirmed, and it may have changed the session. It was not repeated automatically.'
8633
+ : 'Its complete outcome could not be confirmed. It was not repeated automatically.';
8634
+ const restate = (recorded: RecordedFailure): string =>
8635
+ `${stateFailure(subject, recorded)} ${unconfirmed}`;
8636
+ const fact = restate(failure);
8637
+ turn.settlementFacts.push(fact);
8638
+ // A delivery or runtime action acts on the leaf, so the leaf's parked
8639
+ // failure may be this very error (CAPTAIN-67).
8640
+ const failedLeaf = leafFrame();
8641
+ if (failedLeaf !== undefined) {
8642
+ turn.failureStatements.push({
8643
+ frame: failedLeaf,
8644
+ fact,
8645
+ message: failure.message,
8646
+ restate,
8647
+ });
8648
+ }
7669
8649
  if (!turn.outcomePending && !turn.outcomeRecorded) {
7670
8650
  journalAction({
7671
8651
  action: selection.action,
@@ -7756,11 +8736,12 @@ export function createPlaybookCaptainShell(
7756
8736
  journalAction({ action: 'respond' });
7757
8737
  const reask = decisionCall;
7758
8738
  if (reask === undefined) {
7759
- const rejection = replyRejection(selection.text);
8739
+ const text = respondEnvelopeText(selection.text);
8740
+ const rejection = replyRejection(text);
7760
8741
  if (rejection !== undefined) {
7761
8742
  throw markControlFailure(new CaptainProseError(rejection));
7762
8743
  }
7763
- await surfaceSettlement({ context, text: selection.text });
8744
+ await surfaceSettlement({ context, text: text! });
7764
8745
  } else {
7765
8746
  await surfaceProse(
7766
8747
  context,
@@ -8066,13 +9047,12 @@ export function createPlaybookCaptainShell(
8066
9047
  .describe()
8067
9048
  .actions.find((action) => action.id === actionId);
8068
9049
  } catch (error) {
8069
- const normalized = normalizeErrorCompact(error) ?? {
8070
- name: 'Error',
8071
- message: String(error),
8072
- };
8073
9050
  return rejectSelection(
8074
9051
  selection,
8075
- `${frameLabel(leaf)} could not be asked which actions it offers: ${normalized.name}: ${compactEvidence(normalized.message)}`,
9052
+ stateFailure(
9053
+ `asking ${frameLabel(leaf)} which actions it offers`,
9054
+ failureOf(error),
9055
+ ),
8076
9056
  );
8077
9057
  }
8078
9058
  if (advertised === undefined) {
@@ -8086,6 +9066,19 @@ export function createPlaybookCaptainShell(
8086
9066
  );
8087
9067
  }
8088
9068
  const actionLabel = advertised.label;
9069
+ // The settlement facts are shell-internal strings composed for the hidden
9070
+ // result-phase prompt: they name the action by its id, which is control
9071
+ // data. The Boss-facing rendering of the same settlement names it by the
9072
+ // runtime's own Boss-appropriate label (PBRT-52), for the one place these
9073
+ // facts are spoken rather than prompted — the CAPTAIN-34 fallback.
9074
+ // A code span is the recorded text as recorded (CAPTAIN-74), so only the
9075
+ // words outside one are renamed.
9076
+ const spokenLabel = `"${compactEvidence(actionLabel)}"`;
9077
+ turn.shellWords.labels.add(spokenLabel);
9078
+ const speak = (fact: string): string =>
9079
+ outsideCodeSpans(fact, (words) =>
9080
+ words.split(`"${actionId}"`).join(spokenLabel),
9081
+ );
8089
9082
  // The id the digest advertised and the reply selected by is now also the
8090
9083
  // id this turn's outcome-report facts carry (CAPTAIN-9): recording it here
8091
9084
  // keeps the closing-reply prompt's copy inside the same supplied set the
@@ -8160,11 +9153,7 @@ export function createPlaybookCaptainShell(
8160
9153
  turn.report = {
8161
9154
  ...outcome.report,
8162
9155
  facts: [...facts],
8163
- bossFacts: facts.map((fact) =>
8164
- fact
8165
- .split(`"${actionId}"`)
8166
- .join(`"${compactEvidence(actionLabel)}"`),
8167
- ),
9156
+ bossFacts: facts.map(speak),
8168
9157
  status: 'failed',
8169
9158
  receipt: {
8170
9159
  disposition: 'failed',
@@ -8184,11 +9173,7 @@ export function createPlaybookCaptainShell(
8184
9173
  turn.report = {
8185
9174
  ...outcome.report,
8186
9175
  facts: [...facts],
8187
- bossFacts: facts.map((fact) =>
8188
- fact
8189
- .split(`"${actionId}"`)
8190
- .join(`"${compactEvidence(actionLabel)}"`),
8191
- ),
9176
+ bossFacts: facts.map(speak),
8192
9177
  status: 'ok',
8193
9178
  receipt: receiptEvidence,
8194
9179
  ...(establishedSummary === undefined
@@ -8210,23 +9195,30 @@ export function createPlaybookCaptainShell(
8210
9195
  }
8211
9196
  } else if (receipt.disposition === 'rejected') {
8212
9197
  facts.push(
8213
- `The runtime refused "${actionId}": ${compactEvidence(receipt.reason)}.`,
9198
+ refusalStatement(
9199
+ `"${actionId}"`,
9200
+ noteReason(recordedReason(receipt.reason, speakableWords)),
9201
+ ),
8214
9202
  );
8215
9203
  } else {
8216
- facts.push(
8217
- `Applying "${actionId}" failed: ${receipt.error.name}: ${compactEvidence(receipt.error.message)}.`,
8218
- );
9204
+ // A failed receipt acts on the leaf, so the leaf's parked failure may
9205
+ // be this very error — a Retry whose re-run failed again (CAPTAIN-67).
9206
+ const subject = `applying "${actionId}"`;
9207
+ const restate = (failure: RecordedFailure): string =>
9208
+ stateFailure(subject, failure);
9209
+ const fact = restate(receipt.error);
9210
+ facts.push(fact);
9211
+ turn.failureStatements.push({
9212
+ frame: leaf,
9213
+ fact,
9214
+ message: receipt.error.message,
9215
+ restate,
9216
+ speak,
9217
+ });
8219
9218
  }
8220
9219
  const runFailed = drainRunFailureFacts(facts);
8221
9220
  if (runFailed) status = 'failed';
8222
- // The settlement facts above are shell-internal strings composed for the
8223
- // hidden result-phase prompt: they name the action by its id, which is
8224
- // control data. The Boss-facing rendering of the same settlement names it
8225
- // by the runtime's own Boss-appropriate label (PBRT-52), for the one place
8226
- // these facts are spoken rather than prompted — the CAPTAIN-34 fallback.
8227
- const bossFacts = facts.map((fact) =>
8228
- fact.split(`"${actionId}"`).join(`"${compactEvidence(actionLabel)}"`),
8229
- );
9221
+ const bossFacts = facts.map(speak);
8230
9222
  const summary = leafStateSummary();
8231
9223
  turn.report = {
8232
9224
  ...outcome.report,
@@ -8266,14 +9258,16 @@ export function createPlaybookCaptainShell(
8266
9258
  // `settled` guard. That guard closes duplicate submissions before an effect
8267
9259
  // starts; it is not evidence that anything ran.
8268
9260
  // CAPTAIN-63: the give-up's closing reply, composed from the same settlement
8269
- // facts the result-phase prompt would have carried. Shell-authored Boss
8270
- // prose passes the validation every model reply passes (CAPTAIN-34); what it
8271
- // interpolates is not host-authored all the way down, so a fact set that
8272
- // fails validation is dropped rather than spoken and the reply still states
8273
- // the settlement truthfully.
8274
- const giveUpReplyText = (): string => {
8275
- const report = activeTurn?.report;
8276
- const facts = report?.bossFacts ?? report?.facts ?? [];
9261
+ // facts the result-phase prompt would have carried. What it lists is not
9262
+ // host-authored all the way down, so each statement passes CAPTAIN-69's
9263
+ // guard on its own and the reply still states the settlement truthfully;
9264
+ // no statement is ever left out.
9265
+ const giveUpReply = (): ShellReply => {
9266
+ const turn = activeTurn;
9267
+ const report = turn?.report;
9268
+ const stated = report?.bossFacts ?? report?.facts ?? [];
9269
+ const facts =
9270
+ turn === undefined ? [] : assembleTurnReport(turn, stated).facts;
8277
9271
  const stopped = report?.status === 'ok';
8278
9272
  const opening = stopped
8279
9273
  ? 'Stopped that workflow. I will not resume it.'
@@ -8281,30 +9275,31 @@ export function createPlaybookCaptainShell(
8281
9275
  const closing = stopped
8282
9276
  ? 'Send the command again to start fresh work.'
8283
9277
  : 'The stop did not settle successfully; recover the session before continuing.';
8284
- const composed = [
8285
- opening,
8286
- ...(facts.length === 0
8287
- ? []
8288
- : ['Here is what happened:', ...facts.map((fact) => `- ${fact}`)]),
8289
- closing,
8290
- ].join('\n');
8291
- return replyRejection(composed) === undefined
8292
- ? composed
8293
- : [opening, closing].join('\n');
9278
+ return {
9279
+ text: [
9280
+ opening,
9281
+ ...(facts.length === 0
9282
+ ? []
9283
+ : ['Here is what happened:', ...facts.map((fact) => `- ${fact}`)]),
9284
+ closing,
9285
+ ].join('\n'),
9286
+ stated,
9287
+ };
8294
9288
  };
8295
9289
 
8296
- const failureReplyText = (): string => {
9290
+ const failureReply = (): ShellReply => {
8297
9291
  const commands = [...enablementById.values()]
8298
9292
  .map((enablement) => `/${enablement.command} <task>`)
8299
9293
  .join(' or ');
8300
9294
  const turn = activeTurn;
8301
9295
  const report = turn?.report;
8302
9296
  if (report === undefined) {
8303
- return (
8304
- 'I could not finish deciding that turn. No action was selected or run — please send the request again' +
8305
- (commands ? `, or start a playbook directly with ${commands}` : '') +
8306
- '.'
8307
- );
9297
+ return {
9298
+ text:
9299
+ 'I could not finish deciding that turn. No action was selected or run — please send the request again' +
9300
+ (commands ? `, or start a playbook directly with ${commands}` : '') +
9301
+ '.',
9302
+ };
8308
9303
  }
8309
9304
  const settledPreamble =
8310
9305
  report.status === 'ok'
@@ -8318,25 +9313,26 @@ export function createPlaybookCaptainShell(
8318
9313
  'Ask me where things stand and I will report the current state.';
8319
9314
  // The Boss-facing rendering of the settlement, never the prompt-side one:
8320
9315
  // `facts` name actions by id and quote runtime-authored text nobody
8321
- // validated.
8322
- const facts = report.bossFacts ?? report.facts;
8323
- const composed = [
8324
- settledPreamble,
8325
- ...(facts.length === 0
8326
- ? []
8327
- : ['Here is what happened:', ...facts.map((fact) => `- ${fact}`)]),
8328
- closing,
8329
- ].join('\n');
8330
- // CAPTAIN-34: this reply is host-authored Boss prose and passes the same
8331
- // validation every model reply passes. What it interpolates is not
9316
+ // validated. Each is said once, in Boss's words, beside the reports the
9317
+ // reply carries (CAPTAIN-69).
9318
+ const stated = report.bossFacts ?? report.facts;
9319
+ const facts = assembleTurnReport(turn!, stated).facts;
9320
+ // CAPTAIN-34: this reply is host-authored Boss prose. What it lists is not
8332
9321
  // host-authored all the way down — a refusal reason and a normalized error
8333
- // message are foreign text — so a fact set that fails validation is
8334
- // dropped rather than spoken, and the reply still states the settlement
8335
- // truthfully and names the next step. It is never withheld: it is the
8336
- // turn's only remaining settlement.
8337
- return replyRejection(composed) === undefined
8338
- ? composed
8339
- : [settledPreamble, closing].join('\n');
9322
+ // message are foreign text — so each statement passes CAPTAIN-69's guard
9323
+ // on its own, the reply states the settlement truthfully and names the
9324
+ // next step, and no statement is left out for its own or another's words.
9325
+ // It is never withheld: it is the turn's only remaining settlement.
9326
+ return {
9327
+ text: [
9328
+ settledPreamble,
9329
+ ...(facts.length === 0
9330
+ ? []
9331
+ : ['Here is what happened:', ...facts.map((fact) => `- ${fact}`)]),
9332
+ closing,
9333
+ ].join('\n'),
9334
+ stated,
9335
+ };
8340
9336
  };
8341
9337
 
8342
9338
  const settleTurnFailure = async (
@@ -8358,10 +9354,7 @@ export function createPlaybookCaptainShell(
8358
9354
  // Through the one presentation seam, so this reply is journaled like
8359
9355
  // every other Boss-visible Captain reply. A rejected emission propagates
8360
9356
  // unchanged: it is never retried and never disguised as an action failure.
8361
- await surfaceSettlement({
8362
- context,
8363
- text: failureReplyText(),
8364
- });
9357
+ await surfaceSettlement({ context, ...failureReply() });
8365
9358
  };
8366
9359
 
8367
9360
  const enabledCatalog = () =>
@@ -9447,7 +10440,11 @@ export function createPlaybookCaptainShell(
9447
10440
  ...(hostGiveUp === undefined ? {} : { hostGiveUp }),
9448
10441
  settled: false,
9449
10442
  presentationAttempted: false,
10443
+ presentationReports: [],
9450
10444
  settlementFacts: [],
10445
+ failureStatements: [],
10446
+ statedReasons: new Set<string>(),
10447
+ shellWords: { subjects: new Set<string>(), labels: new Set<string>() },
9451
10448
  effectThrows: new Set<unknown>(),
9452
10449
  controlFailures: new Set<unknown>(),
9453
10450
  settingsPreflightFailures: new Set<unknown>(),
@@ -9606,4 +10603,14 @@ export function createPlaybookCaptainShell(
9606
10603
  }
9607
10604
  }
9608
10605
 
10606
+ // The Boss-facing failure renderer, report assembly, and minted-reason tables,
10607
+ // for the shell suite's direct cases; the leading underscore keeps them out of
10608
+ // the stable surface.
10609
+ export const _internal = {
10610
+ failureStatement,
10611
+ bossReport,
10612
+ RUNTIME_REASON_PHRASES,
10613
+ LABELLED_RUNTIME_REASONS,
10614
+ };
10615
+
9609
10616
  export default createPlaybookCaptainShell;