@sublang/playbook 17.7.0 → 17.8.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.7.0",
3
+ "version": "17.8.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",
@@ -976,27 +976,43 @@ async function isAncestor(worktree, baselineHead, afterHead) {
976
976
  }
977
977
  }
978
978
 
979
- async function descendantCount(worktree, baselineHead, afterHead) {
980
- const value = await runGitText(worktree, [
979
+ // DR-088 §1: the commits reachable from the after HEAD and not from the
980
+ // baseline HEAD — every reachable commit for the null HEAD — oldest first, so
981
+ // the last is the after HEAD itself.
982
+ async function newCommits(worktree, baselineHead, afterHead) {
983
+ const raw = await runGitText(worktree, [
981
984
  'rev-list',
982
- '--count',
985
+ '--topo-order',
986
+ '--reverse',
983
987
  baselineHead === NULL_GIT_OID ? afterHead : `${baselineHead}..${afterHead}`,
984
988
  ]);
985
- const count = Number(value);
986
- if (!Number.isSafeInteger(count) || count < 0) {
989
+ const commits = raw.length === 0 ? [] : raw.split('\n');
990
+ if (
991
+ commits.some((oid) => !/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(oid)) ||
992
+ new Set(commits).size !== commits.length ||
993
+ (commits.length > 0 && commits.at(-1) !== afterHead)
994
+ ) {
987
995
  throw new RepositoryObservationAmbiguousError(
988
- 'Git returned an invalid descendant count',
996
+ 'Git returned an invalid list of new commits',
989
997
  );
990
998
  }
991
- return count;
999
+ return commits;
992
1000
  }
993
1001
 
994
- function receipt(classification, baseline, after, commitOid, preExisting) {
1002
+ function receipt(
1003
+ classification,
1004
+ baseline,
1005
+ after,
1006
+ commitOid,
1007
+ preExisting,
1008
+ commits,
1009
+ ) {
995
1010
  return deepFreeze({
996
1011
  classification,
997
1012
  baseline,
998
1013
  ...(after === undefined ? {} : { after }),
999
1014
  ...(commitOid === undefined ? {} : { commitOid }),
1015
+ ...(commits === undefined ? {} : { commits }),
1000
1016
  ...(preExisting === undefined ? {} : { preExisting }),
1001
1017
  });
1002
1018
  }
@@ -1049,12 +1065,13 @@ export async function classifyRepositoryReceipt(
1049
1065
  ) {
1050
1066
  return receipt('rewritten-or-non-descendant', baseline, after);
1051
1067
  }
1052
- const count = await descendantCount(after.worktree, baseline.head, after.head);
1053
- if (count > 1) return receipt('multiple-commits', baseline, after);
1054
- if (count !== 1) {
1068
+ // DR-088 §1: HEAD advanced by one or more new commits; a receipt of more
1069
+ // than one lists every one, oldest first.
1070
+ const commits = await newCommits(after.worktree, baseline.head, after.head);
1071
+ if (commits.length === 0) {
1055
1072
  return receipt('rewritten-or-non-descendant', baseline, after);
1056
1073
  }
1057
- // DR-062 §2: the one commit proves itself when every baseline entry is
1074
+ // DR-062 §2: the advance proves itself when every baseline entry is
1058
1075
  // preserved, absorbed, or altered and carried by it, and the after
1059
1076
  // projection holds nothing else.
1060
1077
  const fates = await baselineEntryFates(baseline, after);
@@ -1071,6 +1088,7 @@ export async function classifyRepositoryReceipt(
1071
1088
  after,
1072
1089
  unattributable ? undefined : after.head,
1073
1090
  preExistingFromFates(fates),
1091
+ unattributable || commits.length === 1 ? undefined : commits,
1074
1092
  );
1075
1093
  }
1076
1094
 
@@ -42,6 +42,8 @@ export interface PlaybookRepositoryReceipt {
42
42
  readonly baseline: PlaybookRepositoryObservation;
43
43
  readonly after?: PlaybookRepositoryObservation;
44
44
  readonly commitOid?: string;
45
+ /** Every new commit, oldest first, where HEAD advanced by more than one (DR-088). */
46
+ readonly commits?: readonly string[];
45
47
  readonly preExisting?: PlaybookRepositoryPreExistingChanges;
46
48
  }
47
49
  export interface RepositoryReceiptOptions {
@@ -621,6 +621,112 @@ function carriedPreExistingFacts(carried) {
621
621
  return `The commit ${commitOid} carries changes that were uncommitted before the step: ${lists.join('; ')}.`;
622
622
  });
623
623
  }
624
+ function commitsBeforeFailure(ledger, boundaryPrefixCount, unresolvedEffects) {
625
+ // An entry names a commit by its proven OID, or — for a receipt listing
626
+ // several commits, which projects as `multiple-commits` (DR-088 §3) — by
627
+ // its after HEAD, the last of them.
628
+ const named = new Set(unresolvedEffects.flatMap(({ classification, afterHead, commitOid }) => commitOid !== undefined
629
+ ? [commitOid]
630
+ : classification === 'multiple-commits' && afterHead !== undefined
631
+ ? [afterHead]
632
+ : []));
633
+ const commits = [];
634
+ for (const boundary of ledger.boundaries) {
635
+ if (boundary.sequence <= boundaryPrefixCount)
636
+ continue;
637
+ const receipt = boundary.physicalReceipt;
638
+ if (receipt?.classification !== 'one-descendant-commit' ||
639
+ receipt.commitOid === undefined ||
640
+ named.has(receipt.commitOid)) {
641
+ continue;
642
+ }
643
+ named.add(receipt.commitOid);
644
+ commits.push({
645
+ baselineHead: receipt.baseline.head,
646
+ commitOid: receipt.commitOid,
647
+ ...(receipt.commits === undefined ? {} : { commits: receipt.commits }),
648
+ });
649
+ }
650
+ return commits;
651
+ }
652
+ /** What one step committed, with every commit where it made several (DR-088). */
653
+ function commitBeforeFailureClause({ baselineHead, commitOid, commits, }) {
654
+ const made = commits === undefined
655
+ ? 'made one new commit'
656
+ : `made ${commits.length} new commits, oldest first: ${boundedPathList(commits)}`;
657
+ return `${made}; HEAD moved from ${baselineHead} to the proven commit ${commitOid}.`;
658
+ }
659
+ function commitBeforeFailureFact(commit) {
660
+ return `Before the failure, a step ${commitBeforeFailureClause(commit)}`;
661
+ }
662
+ function commitsBeforeFailureReport(commits) {
663
+ if (commits.length === 0)
664
+ 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');
670
+ }
671
+ /**
672
+ * DR-088 §2: whether a final text names one commit as a word, by its full OID
673
+ * or by an abbreviation of at least seven leading hex digits — the
674
+ * reconciler's own designation rule, read here to say what it accepted.
675
+ */
676
+ function finalTextNamesCommit(finalText, oid) {
677
+ for (const [word] of finalText.matchAll(/(?<![0-9A-Za-z_])[0-9A-Fa-f]{7,64}(?![0-9A-Za-z_])/g)) {
678
+ if (oid.startsWith(word.toLowerCase()))
679
+ return true;
680
+ }
681
+ return false;
682
+ }
683
+ /**
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.
690
+ */
691
+ function stepCommitStatements(ledger, boundaryPrefixCount) {
692
+ const statements = [];
693
+ for (const boundary of ledger.boundaries) {
694
+ if (boundary.sequence <= boundaryPrefixCount)
695
+ continue;
696
+ let receipt = boundary.physicalReceipt;
697
+ if (boundary.logicalOperationId !== undefined) {
698
+ const operation = ledger.logicalOperations.find(({ operationId }) => operationId === boundary.logicalOperationId);
699
+ if (operation?.boundaryIds.at(-1) !== boundary.boundaryId)
700
+ continue;
701
+ receipt = operation.logicalReceipt;
702
+ }
703
+ if (receipt === undefined)
704
+ continue;
705
+ if (receipt.classification === 'one-descendant-commit' &&
706
+ receipt.commits !== undefined) {
707
+ statements.push(`The step made ${receipt.commits.length} new commits, oldest first: ${boundedPathList(receipt.commits)}.`);
708
+ continue;
709
+ }
710
+ const candidate = boundary.semanticCandidate;
711
+ const guard = candidate !== null &&
712
+ typeof candidate === 'object' &&
713
+ !Array.isArray(candidate)
714
+ ? candidate.guard
715
+ : undefined;
716
+ const head = receipt.after?.head;
717
+ if (receipt.classification === 'unchanged' &&
718
+ head !== undefined &&
719
+ !/^0+$/.test(head) &&
720
+ boundary.dispositions.includes('one-descendant-commit') &&
721
+ !boundary.dispositions.includes('unchanged') &&
722
+ typeof guard === 'string' &&
723
+ guard !== BOSS_QUESTION_OUTCOME &&
724
+ finalTextNamesCommit(boundary.finalText?.trim() ?? '', head)) {
725
+ statements.push(`The step made no new commit; it named ${head}, already at HEAD, as its work.`);
726
+ }
727
+ }
728
+ return statements;
729
+ }
624
730
  // DR-063 §4: the Boss-visible failure report. Every host sees the same
625
731
  // sentences because the shell composes them from the structured cause instead
626
732
  // of asking a model to describe facts it may drop. The report exposes no file
@@ -2402,6 +2508,15 @@ async function buildEnablements(options, loadModule, hostCapabilities) {
2402
2508
  const unresolvedEffectFromReceipt = (receipt, baselineHead = receipt.baseline.head) => {
2403
2509
  if (receipt.classification === 'unchanged')
2404
2510
  return undefined;
2511
+ // DR-088 §3: a receipt listing more than one new commit reads as
2512
+ // `multiple-commits`, whose entry names more than one commit and no OID.
2513
+ if (receipt.commits !== undefined) {
2514
+ return {
2515
+ classification: 'multiple-commits',
2516
+ baselineHead,
2517
+ ...(receipt.after === undefined ? {} : { afterHead: receipt.after.head }),
2518
+ };
2519
+ }
2405
2520
  return {
2406
2521
  classification: receipt.classification,
2407
2522
  baselineHead,
@@ -2478,12 +2593,18 @@ const cumulativeOpenLogicalEffect = (operation, boundaries) => {
2478
2593
  }
2479
2594
  if (receipt.classification === 'one-descendant-commit' &&
2480
2595
  (sameProjection || receiptAccountsFromOriginal)) {
2481
- return {
2482
- classification: 'one-descendant-commit',
2483
- baselineHead: original.head,
2484
- afterHead: after.head,
2485
- commitOid: after.head,
2486
- };
2596
+ return receipt.commits === undefined
2597
+ ? {
2598
+ classification: 'one-descendant-commit',
2599
+ baselineHead: original.head,
2600
+ afterHead: after.head,
2601
+ commitOid: after.head,
2602
+ }
2603
+ : {
2604
+ classification: 'multiple-commits',
2605
+ baselineHead: original.head,
2606
+ afterHead: after.head,
2607
+ };
2487
2608
  }
2488
2609
  return {
2489
2610
  classification: 'observation-ambiguous',
@@ -2792,21 +2913,30 @@ export function createPlaybookCaptainShell(options, deps = {}) {
2792
2913
  if (turn.carriedEffectPrefix !== undefined)
2793
2914
  return;
2794
2915
  let carried;
2916
+ let commitStatements;
2795
2917
  try {
2796
2918
  const ledger = assertPlaybookEffectLedger(currentEffectLedger());
2797
2919
  carried = carriedPreExistingChanges(ledger, presentedEffectPrefix);
2920
+ commitStatements = stepCommitStatements(ledger, presentedEffectPrefix);
2798
2921
  turn.carriedEffectPrefix = ledger.boundaries.length;
2799
2922
  }
2800
2923
  catch {
2801
2924
  return;
2802
2925
  }
2803
2926
  const report = carriedPreExistingReport(carried);
2804
- if (report === undefined)
2805
- return;
2806
- const facts = carriedPreExistingFacts(carried);
2807
- appendMandatoryPresentationSuffix(turn, report, facts);
2808
- for (const fact of facts) {
2809
- joinSettlementFact(turn, fact);
2927
+ if (report !== undefined) {
2928
+ const facts = carriedPreExistingFacts(carried);
2929
+ appendMandatoryPresentationSuffix(turn, report, facts);
2930
+ for (const fact of facts) {
2931
+ joinSettlementFact(turn, fact);
2932
+ }
2933
+ }
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
+ }
2810
2940
  }
2811
2941
  };
2812
2942
  /**
@@ -6203,6 +6333,26 @@ export function createPlaybookCaptainShell(options, deps = {}) {
6203
6333
  controls: failureControlLines(view, fenced),
6204
6334
  };
6205
6335
  joinSettlementFact(turn, fact, statement?.fact, speak);
6336
+ reportCommitsBeforeFailure(turn, unresolvedEffects);
6337
+ };
6338
+ // DR-089: the commits the turn's receipts proved before its leaf parked,
6339
+ // stated beside the failure report and joined to the settlement facts the
6340
+ // closing-reply prompt reads.
6341
+ const reportCommitsBeforeFailure = (turn, unresolvedEffects) => {
6342
+ let commits;
6343
+ try {
6344
+ commits = commitsBeforeFailure(assertPlaybookEffectLedger(currentEffectLedger()), presentedEffectPrefix, unresolvedEffects);
6345
+ }
6346
+ catch {
6347
+ return;
6348
+ }
6349
+ const report = commitsBeforeFailureReport(commits);
6350
+ if (report === undefined)
6351
+ return;
6352
+ const facts = commits.map(commitBeforeFailureFact);
6353
+ appendMandatoryPresentationSuffix(turn, report, facts);
6354
+ for (const fact of facts)
6355
+ joinSettlementFact(turn, fact);
6206
6356
  };
6207
6357
  // The runtimes own each frame's position. The shell only joins their stack.
6208
6358
  const saveProgress = async (starting, step, required = true) => {
@@ -1490,6 +1490,158 @@ function carriedPreExistingFacts(
1490
1490
  });
1491
1491
  }
1492
1492
 
1493
+ // DR-089: a turn whose leaf parks in failure still states, as fact, every
1494
+ // commit its steps proved before the failure, so a closing reply never tells
1495
+ // the Boss that nothing was written when a receipt shows otherwise. A commit
1496
+ // the unresolved-effect report already names is not said twice.
1497
+ interface CommitBeforeFailure {
1498
+ readonly baselineHead: string;
1499
+ readonly commitOid: string;
1500
+ /** Every new commit, oldest first, where the step made more than one (DR-088). */
1501
+ readonly commits?: readonly string[];
1502
+ }
1503
+
1504
+ function commitsBeforeFailure(
1505
+ ledger: PlaybookEffectLedger,
1506
+ boundaryPrefixCount: number,
1507
+ unresolvedEffects: readonly PlaybookCaptainUnresolvedEffect[],
1508
+ ): readonly CommitBeforeFailure[] {
1509
+ // An entry names a commit by its proven OID, or — for a receipt listing
1510
+ // several commits, which projects as `multiple-commits` (DR-088 §3) — by
1511
+ // its after HEAD, the last of them.
1512
+ const named = new Set(
1513
+ unresolvedEffects.flatMap(({ classification, afterHead, commitOid }) =>
1514
+ commitOid !== undefined
1515
+ ? [commitOid]
1516
+ : classification === 'multiple-commits' && afterHead !== undefined
1517
+ ? [afterHead]
1518
+ : [],
1519
+ ),
1520
+ );
1521
+ const commits: CommitBeforeFailure[] = [];
1522
+ for (const boundary of ledger.boundaries) {
1523
+ if (boundary.sequence <= boundaryPrefixCount) continue;
1524
+ const receipt = boundary.physicalReceipt;
1525
+ if (
1526
+ receipt?.classification !== 'one-descendant-commit' ||
1527
+ receipt.commitOid === undefined ||
1528
+ named.has(receipt.commitOid)
1529
+ ) {
1530
+ continue;
1531
+ }
1532
+ named.add(receipt.commitOid);
1533
+ commits.push({
1534
+ baselineHead: receipt.baseline.head,
1535
+ commitOid: receipt.commitOid,
1536
+ ...(receipt.commits === undefined ? {} : { commits: receipt.commits }),
1537
+ });
1538
+ }
1539
+ return commits;
1540
+ }
1541
+
1542
+ /** What one step committed, with every commit where it made several (DR-088). */
1543
+ function commitBeforeFailureClause({
1544
+ baselineHead,
1545
+ commitOid,
1546
+ commits,
1547
+ }: CommitBeforeFailure): string {
1548
+ const made =
1549
+ commits === undefined
1550
+ ? 'made one new commit'
1551
+ : `made ${commits.length} new commits, oldest first: ${boundedPathList(commits)}`;
1552
+ return `${made}; HEAD moved from ${baselineHead} to the proven commit ${commitOid}.`;
1553
+ }
1554
+
1555
+ function commitBeforeFailureFact(commit: CommitBeforeFailure): string {
1556
+ return `Before the failure, a step ${commitBeforeFailureClause(commit)}`;
1557
+ }
1558
+
1559
+ function commitsBeforeFailureReport(
1560
+ commits: readonly CommitBeforeFailure[],
1561
+ ): string | undefined {
1562
+ if (commits.length === 0) return undefined;
1563
+ return [
1564
+ 'Repository changes made before the failure:',
1565
+ ...commits.map(
1566
+ (commit) => `- A step ${commitBeforeFailureClause(commit)}`,
1567
+ ),
1568
+ 'The failure does not undo these commits.',
1569
+ ].join('\n');
1570
+ }
1571
+
1572
+ /**
1573
+ * DR-088 §2: whether a final text names one commit as a word, by its full OID
1574
+ * or by an abbreviation of at least seven leading hex digits — the
1575
+ * reconciler's own designation rule, read here to say what it accepted.
1576
+ */
1577
+ function finalTextNamesCommit(finalText: string, oid: string): boolean {
1578
+ for (const [word] of finalText.matchAll(
1579
+ /(?<![0-9A-Za-z_])[0-9A-Fa-f]{7,64}(?![0-9A-Za-z_])/g,
1580
+ )) {
1581
+ if (oid.startsWith(word.toLowerCase())) return true;
1582
+ }
1583
+ return false;
1584
+ }
1585
+
1586
+ /**
1587
+ * DR-088 §3: what the steps completed after `boundaryPrefixCount` committed,
1588
+ * one statement each, in boundary sequence order: every new commit where a
1589
+ * receipt proves more than one, and the commit already at HEAD where a step
1590
+ * whose outcomes all commit or ask named it over an `unchanged` receipt. A
1591
+ * step in a deferred chain reads the chain's cumulative receipt at its last
1592
+ * boundary.
1593
+ */
1594
+ function stepCommitStatements(
1595
+ ledger: PlaybookEffectLedger,
1596
+ boundaryPrefixCount: number,
1597
+ ): readonly string[] {
1598
+ const statements: string[] = [];
1599
+ for (const boundary of ledger.boundaries) {
1600
+ if (boundary.sequence <= boundaryPrefixCount) continue;
1601
+ let receipt = boundary.physicalReceipt;
1602
+ if (boundary.logicalOperationId !== undefined) {
1603
+ const operation = ledger.logicalOperations.find(
1604
+ ({ operationId }) => operationId === boundary.logicalOperationId,
1605
+ );
1606
+ if (operation?.boundaryIds.at(-1) !== boundary.boundaryId) continue;
1607
+ receipt = operation.logicalReceipt;
1608
+ }
1609
+ if (receipt === undefined) continue;
1610
+ if (
1611
+ receipt.classification === 'one-descendant-commit' &&
1612
+ receipt.commits !== undefined
1613
+ ) {
1614
+ statements.push(
1615
+ `The step made ${receipt.commits.length} new commits, oldest first: ${boundedPathList(receipt.commits)}.`,
1616
+ );
1617
+ continue;
1618
+ }
1619
+ const candidate = boundary.semanticCandidate;
1620
+ const guard =
1621
+ candidate !== null &&
1622
+ typeof candidate === 'object' &&
1623
+ !Array.isArray(candidate)
1624
+ ? (candidate as { readonly [key: string]: JsonValue }).guard
1625
+ : undefined;
1626
+ const head = receipt.after?.head;
1627
+ if (
1628
+ receipt.classification === 'unchanged' &&
1629
+ head !== undefined &&
1630
+ !/^0+$/.test(head) &&
1631
+ boundary.dispositions.includes('one-descendant-commit') &&
1632
+ !boundary.dispositions.includes('unchanged') &&
1633
+ typeof guard === 'string' &&
1634
+ guard !== BOSS_QUESTION_OUTCOME &&
1635
+ finalTextNamesCommit(boundary.finalText?.trim() ?? '', head)
1636
+ ) {
1637
+ statements.push(
1638
+ `The step made no new commit; it named ${head}, already at HEAD, as its work.`,
1639
+ );
1640
+ }
1641
+ }
1642
+ return statements;
1643
+ }
1644
+
1493
1645
  // DR-063 §4: the Boss-visible failure report. Every host sees the same
1494
1646
  // sentences because the shell composes them from the structured cause instead
1495
1647
  // of asking a model to describe facts it may drop. The report exposes no file
@@ -4250,6 +4402,15 @@ const unresolvedEffectFromReceipt = (
4250
4402
  baselineHead = receipt.baseline.head,
4251
4403
  ): PlaybookCaptainUnresolvedEffect | undefined => {
4252
4404
  if (receipt.classification === 'unchanged') return undefined;
4405
+ // DR-088 §3: a receipt listing more than one new commit reads as
4406
+ // `multiple-commits`, whose entry names more than one commit and no OID.
4407
+ if (receipt.commits !== undefined) {
4408
+ return {
4409
+ classification: 'multiple-commits',
4410
+ baselineHead,
4411
+ ...(receipt.after === undefined ? {} : { afterHead: receipt.after.head }),
4412
+ };
4413
+ }
4253
4414
  return {
4254
4415
  classification: receipt.classification,
4255
4416
  baselineHead,
@@ -4352,12 +4513,18 @@ const cumulativeOpenLogicalEffect = (
4352
4513
  receipt.classification === 'one-descendant-commit' &&
4353
4514
  (sameProjection || receiptAccountsFromOriginal)
4354
4515
  ) {
4355
- return {
4356
- classification: 'one-descendant-commit',
4357
- baselineHead: original.head,
4358
- afterHead: after.head,
4359
- commitOid: after.head,
4360
- };
4516
+ return receipt.commits === undefined
4517
+ ? {
4518
+ classification: 'one-descendant-commit',
4519
+ baselineHead: original.head,
4520
+ afterHead: after.head,
4521
+ commitOid: after.head,
4522
+ }
4523
+ : {
4524
+ classification: 'multiple-commits',
4525
+ baselineHead: original.head,
4526
+ afterHead: after.head,
4527
+ };
4361
4528
  }
4362
4529
  return {
4363
4530
  classification: 'observation-ambiguous',
@@ -4772,19 +4939,33 @@ export function createPlaybookCaptainShell(
4772
4939
  const reportCarriedPreExistingChanges = (turn: ActiveTurn): void => {
4773
4940
  if (turn.carriedEffectPrefix !== undefined) return;
4774
4941
  let carried: readonly CarriedPreExistingChanges[];
4942
+ let commitStatements: readonly string[];
4775
4943
  try {
4776
4944
  const ledger = assertPlaybookEffectLedger(currentEffectLedger());
4777
4945
  carried = carriedPreExistingChanges(ledger, presentedEffectPrefix);
4946
+ commitStatements = stepCommitStatements(ledger, presentedEffectPrefix);
4778
4947
  turn.carriedEffectPrefix = ledger.boundaries.length;
4779
4948
  } catch {
4780
4949
  return;
4781
4950
  }
4782
4951
  const report = carriedPreExistingReport(carried);
4783
- if (report === undefined) return;
4784
- const facts = carriedPreExistingFacts(carried);
4785
- appendMandatoryPresentationSuffix(turn, report, facts);
4786
- for (const fact of facts) {
4787
- joinSettlementFact(turn, fact);
4952
+ if (report !== undefined) {
4953
+ const facts = carriedPreExistingFacts(carried);
4954
+ appendMandatoryPresentationSuffix(turn, report, facts);
4955
+ for (const fact of facts) {
4956
+ joinSettlementFact(turn, fact);
4957
+ }
4958
+ }
4959
+ // DR-088 §3: the commits each step took, read from the same boundaries.
4960
+ if (commitStatements.length > 0) {
4961
+ appendMandatoryPresentationSuffix(
4962
+ turn,
4963
+ commitStatements.join('\n'),
4964
+ commitStatements,
4965
+ );
4966
+ for (const fact of commitStatements) {
4967
+ joinSettlementFact(turn, fact);
4968
+ }
4788
4969
  }
4789
4970
  };
4790
4971
 
@@ -8965,6 +9146,31 @@ export function createPlaybookCaptainShell(
8965
9146
  controls: failureControlLines(view, fenced),
8966
9147
  };
8967
9148
  joinSettlementFact(turn, fact, statement?.fact, speak);
9149
+ reportCommitsBeforeFailure(turn, unresolvedEffects);
9150
+ };
9151
+
9152
+ // DR-089: the commits the turn's receipts proved before its leaf parked,
9153
+ // stated beside the failure report and joined to the settlement facts the
9154
+ // closing-reply prompt reads.
9155
+ const reportCommitsBeforeFailure = (
9156
+ turn: ActiveTurn,
9157
+ unresolvedEffects: readonly PlaybookCaptainUnresolvedEffect[],
9158
+ ): void => {
9159
+ let commits: readonly CommitBeforeFailure[];
9160
+ try {
9161
+ commits = commitsBeforeFailure(
9162
+ assertPlaybookEffectLedger(currentEffectLedger()),
9163
+ presentedEffectPrefix,
9164
+ unresolvedEffects,
9165
+ );
9166
+ } catch {
9167
+ return;
9168
+ }
9169
+ const report = commitsBeforeFailureReport(commits);
9170
+ if (report === undefined) return;
9171
+ const facts = commits.map(commitBeforeFailureFact);
9172
+ appendMandatoryPresentationSuffix(turn, report, facts);
9173
+ for (const fact of facts) joinSettlementFact(turn, fact);
8968
9174
  };
8969
9175
 
8970
9176
  // The runtimes own each frame's position. The shell only joins their stack.
package/slc/link.md CHANGED
@@ -341,6 +341,7 @@ interface PlaybookRepositoryReceipt {
341
341
  readonly baseline: PlaybookRepositoryObservation;
342
342
  readonly after?: PlaybookRepositoryObservation;
343
343
  readonly commitOid?: string;
344
+ readonly commits?: readonly string[];
344
345
  }
345
346
 
346
347
  type PlaybookRepositoryDisposition =
@@ -1286,6 +1287,12 @@ runtime-owned payload field; `guard` shall name exactly one outcome declared
1286
1287
  by both the live result map and `outcomeAuthority`; and every semantic-owned
1287
1288
  field shall satisfy the result map's required-field type before any actor
1288
1289
  output is delivered.
1290
+ That type is the value shape the compiled FSM's own validator admits for the
1291
+ field: the shared engine reads it once at construction by presenting each
1292
+ governed outcome's accepting arm with sample values of a closed set of shapes,
1293
+ a field whose shape the FSM admits as a string or does not reveal stays a
1294
+ string, and the judge prompt and validation error name every non-string shape
1295
+ ([DR-089](../specs/decisions/089-judge-replies-take-the-machines-shapes.md)).
1289
1296
  The judge prompt shall render each governed outcome's description with its
1290
1297
  meaning verbatim and its `Output shall include` clause replaced by that reply
1291
1298
  contract — exactly `guard` plus the semantic-owned fields, each keeping its
@@ -1320,10 +1327,16 @@ physical receipt, or the complete cumulative logical receipt of a deferred
1320
1327
  operation, whose classification is exactly the outcome's declared
1321
1328
  `unchanged` or `one-descendant-commit` disposition; a `one-descendant-commit`
1322
1329
  receipt shall carry exactly the after-HEAD OID used for the arm's effect-owned
1323
- fields, while an `unchanged` receipt's complete validated observation supplies
1324
- its observed HEAD OID for them, and a receipt that cannot prove that observed
1325
- HEAD shall leave the envelope unresolved rather than inject a fabricated
1326
- value.
1330
+ fields, and lists every new commit in `commits` where HEAD advanced by more
1331
+ than one, while an `unchanged` receipt's complete validated observation
1332
+ supplies its observed HEAD OID for them, and a receipt that cannot prove that
1333
+ observed HEAD shall leave the envelope unresolved rather than inject a
1334
+ fabricated value.
1335
+ A `one-descendant-commit` outcome shall also resolve over an `unchanged`
1336
+ receipt whose HEAD names a commit when no outcome of that call declares
1337
+ `unchanged` and the trimmed `finalText` names that commit as a word, by its
1338
+ full OID or an abbreviation of at least seven leading hex digits; its
1339
+ effect-owned fields then take that HEAD OID.
1327
1340
  A `deferred` candidate shall be admissible only for its already-validated
1328
1341
  effect-authorized `needsBossReply` outcome and only from a complete after
1329
1342
  observation whose HEAD equals the logical operation's original baseline HEAD
@@ -1373,10 +1386,16 @@ player or judge call.
1373
1386
  The adjudicator shall use the same document-order tolerant JSON recovery as
1374
1387
  the Boss classifier. Unlike invalid classification, a reply from which no
1375
1388
  object can be recovered, an undeclared guard, or a missing required field is a
1376
- control-plane error for nongoverned delegated-player and direct-Captain adjudication and
1389
+ control-plane error for nongoverned delegated-player adjudication and
1377
1390
  shall throw after the invocation reaches its FSM error path and ordered
1378
1391
  emissions drain; schema-3 governed adjudication follows the bounded
1379
1392
  reconciliation contract above instead.
1393
+ Direct-Captain adjudication treats such a reply, or one carrying an undeclared
1394
+ field, as a structural error: it makes exactly one corrective adjudication over
1395
+ the same visible output that restates the reply and its validation error, and
1396
+ a second structural error routes the invocation to its FSM failure state as a
1397
+ recoverable `judge-failed` failure rather than a control-plane error
1398
+ ([DR-089](../specs/decisions/089-judge-replies-take-the-machines-shapes.md)).
1380
1399
 
1381
1400
  Two default adjudication strategies, in selection order:
1382
1401
 
@@ -1389,7 +1408,11 @@ Two default adjudication strategies, in selection order:
1389
1408
  `{ guard, …semanticOwnedPayloadFields }` and explicitly forbids every other
1390
1409
  payload field. The governed form also permits the blocked report above when no outcome matches.
1391
1410
  Both forms exclude the runtime-injected direct-Captain
1392
- `question` and `response` fields above. The prompt shall identify hidden control work,
1411
+ `question` and `response` fields above. A direct-Captain prompt keeps each
1412
+ description and states under it the exact reply that key takes — `guard`
1413
+ plus the structural fields its `Output shall include` clause names, each with
1414
+ its authored placeholder — naming `question` and `response` as
1415
+ runtime-supplied. The prompt shall identify hidden control work,
1393
1416
  prohibit tool use, file inspection, and external evidence, direct the judge
1394
1417
  to decide only from the supplied actor output and declared outcomes, and
1395
1418
  require exactly one JSON object with no prose. The judge prompt shall not