@sublang/playbook 17.8.1 → 17.9.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sublang/playbook",
3
- "version": "17.8.1",
3
+ "version": "17.9.0",
4
4
  "type": "module",
5
5
  "description": "Composable XState v5 playbook runtime with compiled Captain, CODE, REVIEW, DECIDE, DEV, BRANCH, PR, and INSPECT workflows driven by GEARS specs.",
6
6
  "license": "Apache-2.0",
@@ -96,7 +96,7 @@ When an acting turn's selection — parse-resolved or model-decided — settles
96
96
  > Do not finish with a bare acknowledgement, a promise to act, or an announcement that the round is complete.
97
97
  > When mentioning progress detail, use only the aggregate counts the report supplies.
98
98
  > Append the supplied saved-counts line verbatim only when one is supplied; when none is supplied, append no saved-counts line.
99
- > Use familiar words and usually one to three short sentences, or a short list of choices. When a step's ending is supplied, give every fact that step reported — paths, record ids, commit hashes, names — as it gave them, before anything you say about them; leave none out and add none. Omit routine acknowledgements, unchanged-file reports, and other commit identifiers unless they explain a failure or a requested result. Never repeat other jargon, even in quotes: explain its meaning instead. Preserve every choice and material constraint without repeating background. Boss must understand and answer from your reply alone, without reading a player pane.
99
+ > Use familiar words and usually one to three short sentences, or a short list of choices. When a step's ending is supplied, give every fact that step reported — paths, record ids, commit hashes, names — as it gave them, before anything you say about them; leave none out and add none. Where the report states a step's commits, name each commit by its hash and the branch it landed on, exactly as the report gives them. Omit routine acknowledgements, unchanged-file reports, and other commit identifiers unless they explain a failure or a requested result. Never repeat other jargon, even in quotes: explain its meaning instead. Preserve every choice and material constraint without repeating background. Boss must understand and answer from your reply alone, without reading a player pane.
100
100
  > Keep the reply to 60 words unless extra words are essential to preserve choices, constraints, or result evidence.
101
101
  > Omit routine status and routing updates. Do not add guarantees or conditions beyond the supplied facts.
102
102
  > Write concise human chat prose with no guard names, result property names, control JSON, hidden control data, internal state ids, session ids, call ids, stack data, or private reasoning.
@@ -53,7 +53,7 @@ const CLOSING_REPLY_PROMPT = [
53
53
  'Do not finish with a bare acknowledgement, a promise to act, or an announcement that the round is complete.',
54
54
  'When mentioning progress detail, use only the aggregate counts the report supplies.',
55
55
  'Append the supplied saved-counts line verbatim only when one is supplied; when none is supplied, append no saved-counts line.',
56
- "Use familiar words and usually one to three short sentences, or a short list of choices. When a step's ending is supplied, give every fact that step reported — paths, record ids, commit hashes, names — as it gave them, before anything you say about them; leave none out and add none. Omit routine acknowledgements, unchanged-file reports, and other commit identifiers unless they explain a failure or a requested result. Never repeat other jargon, even in quotes: explain its meaning instead. Preserve every choice and material constraint without repeating background. Boss must understand and answer from your reply alone, without reading a player pane.",
56
+ "Use familiar words and usually one to three short sentences, or a short list of choices. When a step's ending is supplied, give every fact that step reported — paths, record ids, commit hashes, names — as it gave them, before anything you say about them; leave none out and add none. Where the report states a step's commits, name each commit by its hash and the branch it landed on, exactly as the report gives them. Omit routine acknowledgements, unchanged-file reports, and other commit identifiers unless they explain a failure or a requested result. Never repeat other jargon, even in quotes: explain its meaning instead. Preserve every choice and material constraint without repeating background. Boss must understand and answer from your reply alone, without reading a player pane.",
57
57
  'Keep the reply to 60 words unless extra words are essential to preserve choices, constraints, or result evidence.',
58
58
  'Omit routine status and routing updates. Do not add guarantees or conditions beyond the supplied facts.',
59
59
  'Write concise human chat prose with no guard names, result property names, control JSON, hidden control data, internal state ids, session ids, call ids, stack data, or private reasoning.',
@@ -267,7 +267,7 @@ const CLOSING_REPLY_PROMPT = [
267
267
  'Do not finish with a bare acknowledgement, a promise to act, or an announcement that the round is complete.',
268
268
  'When mentioning progress detail, use only the aggregate counts the report supplies.',
269
269
  'Append the supplied saved-counts line verbatim only when one is supplied; when none is supplied, append no saved-counts line.',
270
- "Use familiar words and usually one to three short sentences, or a short list of choices. When a step's ending is supplied, give every fact that step reported — paths, record ids, commit hashes, names — as it gave them, before anything you say about them; leave none out and add none. Omit routine acknowledgements, unchanged-file reports, and other commit identifiers unless they explain a failure or a requested result. Never repeat other jargon, even in quotes: explain its meaning instead. Preserve every choice and material constraint without repeating background. Boss must understand and answer from your reply alone, without reading a player pane.",
270
+ "Use familiar words and usually one to three short sentences, or a short list of choices. When a step's ending is supplied, give every fact that step reported — paths, record ids, commit hashes, names — as it gave them, before anything you say about them; leave none out and add none. Where the report states a step's commits, name each commit by its hash and the branch it landed on, exactly as the report gives them. Omit routine acknowledgements, unchanged-file reports, and other commit identifiers unless they explain a failure or a requested result. Never repeat other jargon, even in quotes: explain its meaning instead. Preserve every choice and material constraint without repeating background. Boss must understand and answer from your reply alone, without reading a player pane.",
271
271
  'Keep the reply to 60 words unless extra words are essential to preserve choices, constraints, or result evidence.',
272
272
  'Omit routine status and routing updates. Do not add guarantees or conditions beyond the supplied facts.',
273
273
  'Write concise human chat prose with no guard names, result property names, control JSON, hidden control data, internal state ids, session ids, call ids, stack data, or private reasoning.',
@@ -65,7 +65,7 @@ Where this is the default generic session Captain playbook with no players beyon
65
65
  > Do not finish with a bare acknowledgement, a promise to act, or an announcement that the round is complete.
66
66
  > When mentioning progress detail, use only the aggregate counts the report supplies.
67
67
  > Append the supplied saved-counts line verbatim only when one is supplied; when none is supplied, append no saved-counts line.
68
- > Use familiar words and usually one to three short sentences, or a short list of choices. When a step's ending is supplied, give every fact that step reported — paths, record ids, commit hashes, names — as it gave them, before anything you say about them; leave none out and add none. Omit routine acknowledgements, unchanged-file reports, and other commit identifiers unless they explain a failure or a requested result. Never repeat other jargon, even in quotes: explain its meaning instead. Preserve every choice and material constraint without repeating background. Boss must understand and answer from your reply alone, without reading a player pane.
68
+ > Use familiar words and usually one to three short sentences, or a short list of choices. When a step's ending is supplied, give every fact that step reported — paths, record ids, commit hashes, names — as it gave them, before anything you say about them; leave none out and add none. Where the report states a step's commits, name each commit by its hash and the branch it landed on, exactly as the report gives them. Omit routine acknowledgements, unchanged-file reports, and other commit identifiers unless they explain a failure or a requested result. Never repeat other jargon, even in quotes: explain its meaning instead. Preserve every choice and material constraint without repeating background. Boss must understand and answer from your reply alone, without reading a player pane.
69
69
  > Keep the reply to 60 words unless extra words are essential to preserve choices, constraints, or result evidence.
70
70
  > Omit routine status and routing updates. Do not add guarantees or conditions beyond the supplied facts.
71
71
  > Write concise human chat prose with no guard names, result property names, control JSON, hidden control data, internal state ids, session ids, call ids, stack data, or private reasoning.
@@ -779,6 +779,23 @@ async function resolveHead(worktree, env) {
779
779
  }
780
780
  }
781
781
 
782
+ // DR-090 §4: the branch HEAD names, or undefined when HEAD is detached:
783
+ // `symbolic-ref --quiet` exits 1 without output for a detached HEAD, and
784
+ // every other failure stays a failure.
785
+ async function resolveBranch(worktree, env) {
786
+ try {
787
+ const branch = await runGitText(
788
+ worktree,
789
+ ['symbolic-ref', '--quiet', '--short', 'HEAD'],
790
+ { env },
791
+ );
792
+ return branch.length === 0 ? undefined : branch;
793
+ } catch (error) {
794
+ if (error?.code === 1 && error.stdout?.length === 0) return undefined;
795
+ throw error;
796
+ }
797
+ }
798
+
782
799
  function gitFailureSays(error, exitCode, text) {
783
800
  return (
784
801
  typeof error === 'object' &&
@@ -862,20 +879,25 @@ export async function resolveCanonicalGitWorktree(cwd) {
862
879
  }
863
880
 
864
881
  async function sampleWorktree(worktree, env) {
865
- const [headResult, visibilityResult, statusResult] = await Promise.allSettled([
882
+ // A directory that is not a repository yet names no branch: its scratch
883
+ // repository's HEAD is not the worktree's.
884
+ const [headResult, branchResult, visibilityResult, statusResult] = await Promise.allSettled([
866
885
  resolveHead(worktree, env),
886
+ env === undefined ? resolveBranch(worktree, env) : Promise.resolve(undefined),
867
887
  rawIndexVisibility(worktree, env),
868
888
  rawRepositoryStatus(worktree, env),
869
889
  ]);
870
890
  if (headResult.status === 'rejected') throw headResult.reason;
891
+ if (branchResult.status === 'rejected') throw branchResult.reason;
871
892
  if (visibilityResult.status === 'rejected') throw visibilityResult.reason;
872
893
  const head = headResult.value;
894
+ const branch = branchResult.value;
873
895
  const indexVisibility = visibilityResult.value;
874
896
  assertIndexVisibility(indexVisibility);
875
897
  if (statusResult.status === 'rejected') throw statusResult.reason;
876
898
  const status = statusResult.value;
877
899
  const projection = await projectionFromStatus(worktree, status);
878
- return { head, indexVisibility, status, projection };
900
+ return { head, branch, indexVisibility, status, projection };
879
901
  }
880
902
 
881
903
  async function observeWorktreeSamples(identity, env, options) {
@@ -885,6 +907,7 @@ async function observeWorktreeSamples(identity, env, options) {
885
907
  const beforeText = projectionText(before.projection);
886
908
  if (
887
909
  before.head !== after.head ||
910
+ before.branch !== after.branch ||
888
911
  !before.indexVisibility.equals(after.indexVisibility) ||
889
912
  !before.status.equals(after.status) ||
890
913
  beforeText !== projectionText(after.projection)
@@ -895,6 +918,7 @@ async function observeWorktreeSamples(identity, env, options) {
895
918
  worktree: identity.worktree,
896
919
  gitDir: identity.gitDir,
897
920
  head: before.head,
921
+ ...(before.branch === undefined ? {} : { branch: before.branch }),
898
922
  projection: before.projection,
899
923
  projectionDigest: `sha256:${sha256(beforeText)}`,
900
924
  });
@@ -29,6 +29,7 @@ export interface RepositoryIdentity {
29
29
  }
30
30
  export interface PlaybookRepositoryObservation extends RepositoryIdentity {
31
31
  readonly head: string;
32
+ readonly branch?: string;
32
33
  readonly projection: Readonly<Record<string, JsonValue>>;
33
34
  readonly projectionDigest: string;
34
35
  }
@@ -556,6 +556,29 @@ function boundedPathList(paths) {
556
556
  ? `${printed.join(', ')}, … and ${omitted} more`
557
557
  : printed.join(', ');
558
558
  }
559
+ // DR-090 §5: the report lines the shell appends to a reply carry marks and
560
+ // identifiers, never an English sentence, so a reply in any language reads
561
+ // them as structure. The settlement facts a model reads keep their words.
562
+ const MARK_DONE = '✓';
563
+ const MARK_FAILED = '✕';
564
+ const MARK_TO = '⟶';
565
+ const MARK_BRANCH = '⎇';
566
+ const MARK_SEP = '·';
567
+ const MARK_READY = '▸';
568
+ const MARK_NOT_READY = '▹';
569
+ /** At most 24 items, then `+<n>` for the rest (DR-090 §5). */
570
+ function markedList(items, separator = ', ', more = 0) {
571
+ const printed = items.slice(0, CARRIED_CHANGE_PATH_LIMIT);
572
+ const omitted = items.length - printed.length + more;
573
+ if (printed.length === 0)
574
+ return omitted > 0 ? `+${omitted}` : '';
575
+ return omitted > 0
576
+ ? `${printed.join(separator)}${separator}+${omitted}`
577
+ : printed.join(separator);
578
+ }
579
+ function markedBranch(branch) {
580
+ return branch === undefined ? '' : ` ${MARK_BRANCH} ${branch}`;
581
+ }
559
582
  /**
560
583
  * The carried pre-existing changes of the boundaries completed after
561
584
  * `boundaryPrefixCount`, one entry per commit OID in boundary sequence order.
@@ -621,6 +644,11 @@ function carriedPreExistingFacts(carried) {
621
644
  return `The commit ${commitOid} carries changes that were uncommitted before the step: ${lists.join('; ')}.`;
622
645
  });
623
646
  }
647
+ /** DR-090 §4: where a step's commits landed, as its receipt names it. */
648
+ function onBranch(receipt) {
649
+ const branch = receipt.after?.branch;
650
+ return branch === undefined ? '' : ` on branch ${branch}`;
651
+ }
624
652
  function commitsBeforeFailure(ledger, boundaryPrefixCount, unresolvedEffects) {
625
653
  // An entry names a commit by its proven OID, or — for a receipt listing
626
654
  // several commits, which projects as `multiple-commits` (DR-088 §3) — by
@@ -645,28 +673,32 @@ function commitsBeforeFailure(ledger, boundaryPrefixCount, unresolvedEffects) {
645
673
  baselineHead: receipt.baseline.head,
646
674
  commitOid: receipt.commitOid,
647
675
  ...(receipt.commits === undefined ? {} : { commits: receipt.commits }),
676
+ ...(receipt.after?.branch === undefined
677
+ ? {}
678
+ : { branch: receipt.after.branch }),
648
679
  });
649
680
  }
650
681
  return commits;
651
682
  }
652
683
  /** What one step committed, with every commit where it made several (DR-088). */
653
- function commitBeforeFailureClause({ baselineHead, commitOid, commits, }) {
684
+ function commitBeforeFailureClause({ baselineHead, commitOid, commits, branch, }) {
654
685
  const made = commits === undefined
655
686
  ? 'made one new commit'
656
687
  : `made ${commits.length} new commits, oldest first: ${boundedPathList(commits)}`;
657
- return `${made}; HEAD moved from ${baselineHead} to the proven commit ${commitOid}.`;
688
+ const landed = branch === undefined ? '' : ` on branch ${branch}`;
689
+ return `${made}; HEAD moved from ${baselineHead} to the proven commit ${commitOid}${landed}.`;
658
690
  }
659
691
  function commitBeforeFailureFact(commit) {
660
692
  return `Before the failure, a step ${commitBeforeFailureClause(commit)}`;
661
693
  }
694
+ // DR-090 §5: one marked line per receipt, `✓ <baselineHead> ⟶ <commits>`
695
+ // and the branch they landed on.
662
696
  function commitsBeforeFailureReport(commits) {
663
697
  if (commits.length === 0)
664
698
  return undefined;
665
- return [
666
- 'Repository changes made before the failure:',
667
- ...commits.map((commit) => `- A step ${commitBeforeFailureClause(commit)}`),
668
- 'The failure does not undo these commits.',
669
- ].join('\n');
699
+ return commits
700
+ .map(({ baselineHead, commitOid, commits: listed, branch }) => `${MARK_DONE} ${baselineHead} ${MARK_TO} ${markedList(listed ?? [commitOid], ` ${MARK_SEP} `)}${markedBranch(branch)}`)
701
+ .join('\n');
670
702
  }
671
703
  /**
672
704
  * DR-088 §2: whether a final text names one commit as a word, by its full OID
@@ -681,14 +713,16 @@ function finalTextNamesCommit(finalText, oid) {
681
713
  return false;
682
714
  }
683
715
  /**
684
- * DR-088 §3: what the steps completed after `boundaryPrefixCount` committed,
685
- * one statement each, in boundary sequence order: every new commit where a
686
- * receipt proves more than one, and the commit already at HEAD where a step
687
- * whose outcomes all commit or ask named it over an `unchanged` receipt. A
688
- * step in a deferred chain reads the chain's cumulative receipt at its last
689
- * boundary.
716
+ * DR-088 §3, DR-090 §4: what the steps completed after `boundaryPrefixCount`
717
+ * committed, one statement each, in boundary sequence order, naming the
718
+ * branch where the receipt names one: every new commit where a receipt
719
+ * proves more than one, the commit already at HEAD where a step whose
720
+ * outcomes all commit or ask named it over an `unchanged` receipt, and —
721
+ * as a fact for the closing reply only, and only where `oneCommit` asks —
722
+ * the one new commit of a step that made one. A step in a deferred chain
723
+ * reads the chain's cumulative receipt at its last boundary.
690
724
  */
691
- function stepCommitStatements(ledger, boundaryPrefixCount) {
725
+ function stepCommitStatements(ledger, boundaryPrefixCount, oneCommit = true) {
692
726
  const statements = [];
693
727
  for (const boundary of ledger.boundaries) {
694
728
  if (boundary.sequence <= boundaryPrefixCount)
@@ -704,7 +738,19 @@ function stepCommitStatements(ledger, boundaryPrefixCount) {
704
738
  continue;
705
739
  if (receipt.classification === 'one-descendant-commit' &&
706
740
  receipt.commits !== undefined) {
707
- statements.push(`The step made ${receipt.commits.length} new commits, oldest first: ${boundedPathList(receipt.commits)}.`);
741
+ statements.push({
742
+ text: `The step made ${receipt.commits.length} new commits${onBranch(receipt)}, oldest first: ${boundedPathList(receipt.commits)}.`,
743
+ shown: `${MARK_DONE} ${markedList(receipt.commits, ` ${MARK_SEP} `)}${markedBranch(receipt.after?.branch)}`,
744
+ });
745
+ continue;
746
+ }
747
+ if (receipt.classification === 'one-descendant-commit' &&
748
+ receipt.commitOid !== undefined) {
749
+ if (oneCommit) {
750
+ statements.push({
751
+ text: `The step made one new commit, ${receipt.commitOid}${receipt.after?.branch === undefined ? '' : `,${onBranch(receipt)}`}.`,
752
+ });
753
+ }
708
754
  continue;
709
755
  }
710
756
  const candidate = boundary.semanticCandidate;
@@ -722,7 +768,10 @@ function stepCommitStatements(ledger, boundaryPrefixCount) {
722
768
  typeof guard === 'string' &&
723
769
  guard !== BOSS_QUESTION_OUTCOME &&
724
770
  finalTextNamesCommit(boundary.finalText?.trim() ?? '', head)) {
725
- statements.push(`The step made no new commit; it named ${head}, already at HEAD, as its work.`);
771
+ statements.push({
772
+ text: `The step made no new commit; it named ${head}, already at HEAD${onBranch(receipt)}, as its work.`,
773
+ shown: `${MARK_DONE} = ${head}${markedBranch(receipt.after?.branch)}`,
774
+ });
726
775
  }
727
776
  }
728
777
  return statements;
@@ -732,15 +781,6 @@ function stepCommitStatements(ledger, boundaryPrefixCount) {
732
781
  // of asking a model to describe facts it may drop. The report exposes no file
733
782
  // content, player prose, or internal identity: paths, revisions, dispositions,
734
783
  // and the reported error message are what a cause carries.
735
- /** The Boss-facing phrase for each closed control-action reason (DR-063 §3). */
736
- const CONTROL_REASON_PHRASES = {
737
- 'receipt-complete': 'nothing has changed since it failed',
738
- };
739
- function controlReasonPhrase(reason) {
740
- if (reason === undefined)
741
- return 'no reason was published';
742
- return CONTROL_REASON_PHRASES[reason] ?? reason;
743
- }
744
784
  function causePathList(paths, truncated) {
745
785
  const list = paths ?? [];
746
786
  const printed = list.slice(0, CARRIED_CHANGE_PATH_LIMIT);
@@ -1105,11 +1145,56 @@ function failureLine(reason) {
1105
1145
  };
1106
1146
  }
1107
1147
  function failureControlLine(action) {
1108
- // DR-063 §3: an action that publishes no standing is `ready`.
1148
+ // DR-063 §3: an action that publishes no standing is `ready`. DR-090 §5:
1149
+ // the standing and its reason read as their closed identifiers.
1109
1150
  const standing = action.standing ?? 'ready';
1110
1151
  return standing === 'ready'
1111
- ? `- ${action.label} (ready)`
1112
- : `- ${action.label} (${standing}: ${controlReasonPhrase(action.reason)})`;
1152
+ ? `${MARK_READY} ${action.label}`
1153
+ : `${MARK_NOT_READY} ${action.label} ${MARK_SEP} ${standing}${action.reason === undefined ? '' : ` ${MARK_SEP} ${action.reason}`}`;
1154
+ }
1155
+ /**
1156
+ * DR-090 §5: the failure line's marked record of a cause — its code and the
1157
+ * identities its evidence carries, a nested cause after its playbook. No
1158
+ * recorded reason or message reaches it: the settlement facts the closing
1159
+ * reply reads state the failure in words (CAPTAIN-71).
1160
+ */
1161
+ function markedFailure(cause, describeNestedPlaybook) {
1162
+ const { evidence } = cause;
1163
+ const parts = [cause.code];
1164
+ if (evidence.roleId !== undefined)
1165
+ parts.push(evidence.roleId);
1166
+ if (evidence.playbookId !== undefined) {
1167
+ parts.push(describeNestedPlaybook(evidence.playbookId));
1168
+ }
1169
+ if (cause.code === 'attribution-ambiguous' &&
1170
+ evidence.required !== undefined &&
1171
+ evidence.observed !== undefined) {
1172
+ parts.push(`${evidence.required} ${MARK_TO} ${evidence.observed}`);
1173
+ }
1174
+ if (evidence.baselineHead !== undefined) {
1175
+ parts.push(evidence.afterHead === undefined
1176
+ ? evidence.baselineHead
1177
+ : `${evidence.baselineHead} ${MARK_TO} ${evidence.afterHead}`);
1178
+ }
1179
+ if (evidence.commitOid !== undefined &&
1180
+ evidence.commitOid !== evidence.afterHead) {
1181
+ parts.push(evidence.commitOid);
1182
+ }
1183
+ const paths = evidence.paths;
1184
+ if (paths !== undefined) {
1185
+ const listed = markedList([
1186
+ ...(paths.uncommitted ?? []),
1187
+ ...(paths.altered ?? []),
1188
+ ...(paths.lost ?? []),
1189
+ ...(paths.changed ?? []),
1190
+ ], ', ', paths.truncated ?? 0);
1191
+ if (listed !== '')
1192
+ parts.push(listed);
1193
+ }
1194
+ if (evidence.cause !== undefined) {
1195
+ parts.push(markedFailure(evidence.cause, describeNestedPlaybook));
1196
+ }
1197
+ return parts.join(` ${MARK_SEP} `);
1113
1198
  }
1114
1199
  /**
1115
1200
  * Whether `container` already states `statement`: the same words, case and
@@ -1170,10 +1255,7 @@ function guardedStatement(text, withholds, refuses, phrased = [], shellWords = N
1170
1255
  for (const reason of phrased) {
1171
1256
  if (!fails(reason))
1172
1257
  continue;
1173
- guarded =
1174
- guarded === `Failure: ${reason}`
1175
- ? `Failure: the step failed with ${WITHHELD_REASON}.`
1176
- : guarded.split(`: ${reason}`).join(` with ${WITHHELD_REASON}.`);
1258
+ guarded = guarded.split(`: ${reason}`).join(` with ${WITHHELD_REASON}.`);
1177
1259
  }
1178
1260
  for (const subject of shellWords.subjects) {
1179
1261
  if (guarded.startsWith(`${subject} `) && fails(subject)) {
@@ -1199,8 +1281,10 @@ function guardedStatement(text, withholds, refuses, phrased = [], shellWords = N
1199
1281
  */
1200
1282
  function assembleStatements(statements, reasons = [], guard = (text) => text) {
1201
1283
  const rendered = [...new Set(reasons)];
1284
+ // DR-090 §5: the marked failure line is no sentence and gains no mark.
1285
+ const finish = (kind, text) => kind === 'failure' ? text.trim() : oneTerminalMark(text);
1202
1286
  const weighed = statements.map(({ text, kind = 'fact', reason }) => {
1203
- const statement = oneTerminalMark(text);
1287
+ const statement = finish(kind, text);
1204
1288
  if (reason !== undefined)
1205
1289
  return { text: statement, kind, own: [reason] };
1206
1290
  const held = rendered.filter((candidate) => alreadyStates(statement, candidate));
@@ -1221,7 +1305,7 @@ function assembleStatements(statements, reasons = [], guard = (text) => text) {
1221
1305
  });
1222
1306
  return weighed.map(({ text, kind, own }, index) => stays[index] &&
1223
1307
  (kind !== 'failure' || !carried(own, [...entries, ...facts]))
1224
- ? oneTerminalMark(guard(text))
1308
+ ? finish(kind, guard(text))
1225
1309
  : undefined);
1226
1310
  }
1227
1311
  /** The statements of `statements` that stay, as CAPTAIN-69 assembles them. */
@@ -1248,11 +1332,12 @@ function presentationSuffix(turn, assembled) {
1248
1332
  });
1249
1333
  const failure = turn.failureReport;
1250
1334
  if (failure !== undefined) {
1251
- parts.push([
1335
+ const lines = [
1252
1336
  ...(assembled.failure === undefined ? [] : [assembled.failure]),
1253
- 'Controls:',
1254
- ...(failure.controls.length === 0 ? ['- none'] : failure.controls),
1255
- ].join('\n'));
1337
+ ...failure.controls,
1338
+ ];
1339
+ if (lines.length > 0)
1340
+ parts.push(lines.join('\n'));
1256
1341
  }
1257
1342
  return parts.length === 0 ? undefined : parts.join('\n\n');
1258
1343
  }
@@ -2917,7 +3002,9 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2917
3002
  try {
2918
3003
  const ledger = assertPlaybookEffectLedger(currentEffectLedger());
2919
3004
  carried = carriedPreExistingChanges(ledger, presentedEffectPrefix);
2920
- commitStatements = stepCommitStatements(ledger, presentedEffectPrefix);
3005
+ // DR-090 §4: a failed turn states its one-commit steps in the
3006
+ // commits-before-failure report instead (DR-089 §3), never twice.
3007
+ commitStatements = stepCommitStatements(ledger, presentedEffectPrefix, !leafParkedInFailure());
2921
3008
  turn.carriedEffectPrefix = ledger.boundaries.length;
2922
3009
  }
2923
3010
  catch {
@@ -2931,12 +3018,15 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2931
3018
  joinSettlementFact(turn, fact);
2932
3019
  }
2933
3020
  }
2934
- // DR-088 §3: the commits each step took, read from the same boundaries.
2935
- if (commitStatements.length > 0) {
2936
- appendMandatoryPresentationSuffix(turn, commitStatements.join('\n'), commitStatements);
2937
- for (const fact of commitStatements) {
2938
- joinSettlementFact(turn, fact);
2939
- }
3021
+ // DR-088 §3, DR-090 §4: the commits each step took, read from the same
3022
+ // boundaries; a step's one new commit reaches the closing reply's facts
3023
+ // only.
3024
+ const shownStatements = commitStatements.filter((statement) => statement.shown !== undefined);
3025
+ if (shownStatements.length > 0) {
3026
+ appendMandatoryPresentationSuffix(turn, shownStatements.map(({ shown }) => shown).join('\n'), shownStatements.map(({ text }) => text));
3027
+ }
3028
+ for (const { text } of commitStatements) {
3029
+ joinSettlementFact(turn, text);
2940
3030
  }
2941
3031
  };
2942
3032
  /**
@@ -5455,7 +5545,11 @@ export function createPlaybookCaptainShell(options, deps = {}) {
5455
5545
  ? []
5456
5546
  : [
5457
5547
  {
5458
- text: `Failure: ${failure.sentence}`,
5548
+ // CAPTAIN-69: a mark whose words CAPTAIN-9 refuses — a path
5549
+ // naming control vocabulary, say — reads its code alone.
5550
+ text: `${MARK_FAILED} ${replyRejection(failure.mark) === undefined
5551
+ ? failure.mark
5552
+ : failure.code}`,
5459
5553
  kind: 'failure',
5460
5554
  reason: failure.reason,
5461
5555
  },
@@ -6291,6 +6385,21 @@ export function createPlaybookCaptainShell(options, deps = {}) {
6291
6385
  // DR-063 §4: appended once, beside the unresolved-effect and pre-existing
6292
6386
  // change reports, whenever the turn settles with the leaf parked in its
6293
6387
  // failure state or behind the retained-effect fence.
6388
+ // The condition under which reportLeafFailure states a failure: the leaf
6389
+ // parked in its failure state, or behind the retained-effect fence.
6390
+ const leafParkedInFailure = () => {
6391
+ if (retainedEffectReconciliation !== undefined)
6392
+ return true;
6393
+ const leaf = leafFrame();
6394
+ if (!leaf || typeof leaf.runtime.describe !== 'function')
6395
+ return false;
6396
+ try {
6397
+ return leaf.runtime.describe().state.stateId === 'failed';
6398
+ }
6399
+ catch {
6400
+ return false;
6401
+ }
6402
+ };
6294
6403
  const reportLeafFailure = (turn, unresolvedEffects) => {
6295
6404
  if (turn.failureReported)
6296
6405
  return;
@@ -6329,6 +6438,8 @@ export function createPlaybookCaptainShell(options, deps = {}) {
6329
6438
  : statement.restate(failure);
6330
6439
  const speak = statement?.speak ?? ((text) => text);
6331
6440
  turn.failureReport = {
6441
+ mark: markedFailure(cause, describeNestedPlaybook),
6442
+ code: cause.code,
6332
6443
  ...failureLine(noteReason(failureReason(failure, describeNestedPlaybook, speakableWords))),
6333
6444
  controls: failureControlLines(view, fenced),
6334
6445
  };