@sublang/playbook 17.6.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.6.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",
@@ -112,6 +112,24 @@ export function resolveMachineIdentity(options = {}) {
112
112
  return pending;
113
113
  }
114
114
 
115
+ /** One store's or coordinator's lazy resolution (playbook-cli-97): the
116
+ * first identity it resolves is kept for its lifetime, and a refusal is
117
+ * not, so its next lease or claim resolves again once the file is
118
+ * repaired. */
119
+ export function lazyMachineIdentity(resolve) {
120
+ let pending;
121
+ return () => {
122
+ if (pending === undefined) {
123
+ const attempt = Promise.resolve(resolve());
124
+ pending = attempt;
125
+ attempt.catch(() => {
126
+ if (pending === attempt) pending = undefined;
127
+ });
128
+ }
129
+ return pending;
130
+ };
131
+ }
132
+
115
133
  async function readOrPublish(path, fs, createIdentity) {
116
134
  const directory = dirname(path);
117
135
  await prepareDirectory(directory, path, fs);
@@ -30,6 +30,7 @@ import {
30
30
  classifyOwnerMachine,
31
31
  describeOwnerMachine,
32
32
  isMachineIdentity,
33
+ lazyMachineIdentity,
33
34
  resolveMachineIdentity,
34
35
  } from './machine-identity.js';
35
36
 
@@ -975,27 +976,43 @@ async function isAncestor(worktree, baselineHead, afterHead) {
975
976
  }
976
977
  }
977
978
 
978
- async function descendantCount(worktree, baselineHead, afterHead) {
979
- 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, [
980
984
  'rev-list',
981
- '--count',
985
+ '--topo-order',
986
+ '--reverse',
982
987
  baselineHead === NULL_GIT_OID ? afterHead : `${baselineHead}..${afterHead}`,
983
988
  ]);
984
- const count = Number(value);
985
- 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
+ ) {
986
995
  throw new RepositoryObservationAmbiguousError(
987
- 'Git returned an invalid descendant count',
996
+ 'Git returned an invalid list of new commits',
988
997
  );
989
998
  }
990
- return count;
999
+ return commits;
991
1000
  }
992
1001
 
993
- function receipt(classification, baseline, after, commitOid, preExisting) {
1002
+ function receipt(
1003
+ classification,
1004
+ baseline,
1005
+ after,
1006
+ commitOid,
1007
+ preExisting,
1008
+ commits,
1009
+ ) {
994
1010
  return deepFreeze({
995
1011
  classification,
996
1012
  baseline,
997
1013
  ...(after === undefined ? {} : { after }),
998
1014
  ...(commitOid === undefined ? {} : { commitOid }),
1015
+ ...(commits === undefined ? {} : { commits }),
999
1016
  ...(preExisting === undefined ? {} : { preExisting }),
1000
1017
  });
1001
1018
  }
@@ -1048,12 +1065,13 @@ export async function classifyRepositoryReceipt(
1048
1065
  ) {
1049
1066
  return receipt('rewritten-or-non-descendant', baseline, after);
1050
1067
  }
1051
- const count = await descendantCount(after.worktree, baseline.head, after.head);
1052
- if (count > 1) return receipt('multiple-commits', baseline, after);
1053
- 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) {
1054
1072
  return receipt('rewritten-or-non-descendant', baseline, after);
1055
1073
  }
1056
- // 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
1057
1075
  // preserved, absorbed, or altered and carried by it, and the after
1058
1076
  // projection holds nothing else.
1059
1077
  const fates = await baselineEntryFates(baseline, after);
@@ -1070,6 +1088,7 @@ export async function classifyRepositoryReceipt(
1070
1088
  after,
1071
1089
  unattributable ? undefined : after.head,
1072
1090
  preExistingFromFates(fates),
1091
+ unattributable || commits.length === 1 ? undefined : commits,
1073
1092
  );
1074
1093
  }
1075
1094
 
@@ -1328,8 +1347,9 @@ function validateCohort(options) {
1328
1347
 
1329
1348
  export function createRepositoryEffectCoordinator(options = {}) {
1330
1349
  // DR-087: the claim owner's `hostname` carries this machine's identity,
1331
- // read once at the first claim; an explicit value stands in for it
1332
- // (tests). An untagged owner is a legacy host name compared with the
1350
+ // read at the first claim and kept once read, while a refusal is read
1351
+ // again at the next (playbook-cli-97); an explicit value stands in for
1352
+ // it (tests). An untagged owner is a legacy host name compared with the
1333
1353
  // current one — or with an explicit untagged value, which then names
1334
1354
  // this machine both ways.
1335
1355
  const explicitHostname = options.hostname;
@@ -1338,15 +1358,13 @@ export function createRepositoryEffectCoordinator(options = {}) {
1338
1358
  (typeof explicitHostname === 'string' && !isMachineIdentity(explicitHostname)
1339
1359
  ? explicitHostname
1340
1360
  : systemHostname());
1341
- let currentIdentityPending;
1342
- const currentIdentity = () =>
1343
- (currentIdentityPending ??=
1344
- explicitHostname !== undefined
1345
- ? Promise.resolve(explicitHostname)
1346
- : resolveMachineIdentity({
1347
- ...(options.env !== undefined ? { env: options.env } : {}),
1348
- ...(options.homeDir !== undefined ? { homeDir: options.homeDir } : {}),
1349
- }));
1361
+ const currentIdentity = lazyMachineIdentity(() =>
1362
+ explicitHostname !== undefined
1363
+ ? explicitHostname
1364
+ : resolveMachineIdentity({
1365
+ ...(options.env !== undefined ? { env: options.env } : {}),
1366
+ ...(options.homeDir !== undefined ? { homeDir: options.homeDir } : {}),
1367
+ }));
1350
1368
  const currentPid = options.pid ?? process.pid;
1351
1369
  const pollIntervalMs = options.pollIntervalMs ?? 10;
1352
1370
  const probeProcess = options.probeProcess ?? defaultProbeProcess;
@@ -28,7 +28,7 @@ import {
28
28
  unlink,
29
29
  } from 'node:fs/promises';
30
30
  import { homedir, hostname as systemHostname } from 'node:os';
31
- import { dirname, isAbsolute, join, resolve } from 'node:path';
31
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
32
32
  import { fileURLToPath, pathToFileURL } from 'node:url';
33
33
  import { isDeepStrictEqual } from 'node:util';
34
34
  import { assertSupportedEffort } from '@sublang/cligent';
@@ -48,6 +48,7 @@ import {
48
48
  classifyOwnerMachine,
49
49
  describeOwnerMachine,
50
50
  isMachineIdentity,
51
+ lazyMachineIdentity,
51
52
  resolveMachineIdentity,
52
53
  } from './machine-identity.js';
53
54
  import { sameFileIdentity, syncDirectory, tightenPrivateEntry } from './private-paths.js';
@@ -638,41 +639,50 @@ export function createCaptainSessionStore(options = {}) {
638
639
  const createTempId = options.createTempId ?? randomUUID;
639
640
  const createLeaseToken = options.createLeaseToken ?? randomUUID;
640
641
  // DR-087: the owner's `hostname` carries this machine's identity, read
641
- // once at the first lease boundary; an explicit value stands in for it
642
- // (tests, migration sub-stores). An untagged owner is a legacy host
643
- // name, compared with the current one — or with an explicit untagged
644
- // value, which then names this machine both ways.
642
+ // at the first lease boundary and kept once read, while a refusal is
643
+ // read again at the next (playbook-cli-97); an explicit value stands in
644
+ // for it (tests, migration sub-stores). An untagged owner is a legacy
645
+ // host name, compared with the current one — or with an explicit
646
+ // untagged value, which then names this machine both ways.
645
647
  const explicitHostname = options.hostname;
646
648
  const legacyHostname =
647
649
  options.legacyHostname ??
648
650
  (typeof explicitHostname === 'string' && !isMachineIdentity(explicitHostname)
649
651
  ? explicitHostname
650
652
  : systemHostname());
651
- let localIdentityPending;
652
- const localIdentity = () =>
653
- (localIdentityPending ??=
654
- explicitHostname !== undefined
655
- ? Promise.resolve(explicitHostname)
656
- : options.resolveHostname !== undefined
657
- ? options.resolveHostname()
658
- : resolveMachineIdentity({ env, homeDir: home }));
653
+ const localIdentity = lazyMachineIdentity(() =>
654
+ explicitHostname !== undefined
655
+ ? explicitHostname
656
+ : options.resolveHostname !== undefined
657
+ ? options.resolveHostname()
658
+ : resolveMachineIdentity({ env, homeDir: home }));
659
659
  const localPid = options.pid ?? process.pid;
660
660
  const probeProcess =
661
661
  options.probeProcess ?? ((pid) => process.kill(pid, 0));
662
662
  const fs = { ...DEFAULT_FS_OPERATIONS, ...(options.fsOps ?? {}) };
663
+ // session-storage-11: a host that prepared the directory may forbid
664
+ // creating it or an ancestor, so a directory that moves or vanishes
665
+ // mid-operation is refused rather than recreated at its former path.
666
+ const createSessionsDir =
667
+ options.createSessionsDir === undefined ? true : options.createSessionsDir;
668
+ const mayCreateDirectory = (directory) =>
669
+ createSessionsDir || !isPathOrAncestor(directory, sessionsDir);
663
670
  const replayReadCursors = new Map();
664
671
  const replayReadQueues = new Map();
665
672
  const portableWriters = new Map();
666
673
  const assetStores = new Map();
667
674
  const assetStoreFor = (sessionId) => {
668
675
  assertSessionId(sessionId);
669
- if (!assetStores.has(sessionId)) assetStores.set(sessionId, createAssetStore({ directory: join(sessionsDir, `${sessionId}.assets`), ...(options.maxAssetBytes === undefined ? {} : { maxAssetBytes: options.maxAssetBytes }) }));
676
+ if (!assetStores.has(sessionId)) assetStores.set(sessionId, createAssetStore({ directory: join(sessionsDir, `${sessionId}.assets`), createAncestors: createSessionsDir, ...(options.maxAssetBytes === undefined ? {} : { maxAssetBytes: options.maxAssetBytes }) }));
670
677
  return assetStores.get(sessionId);
671
678
  };
672
679
 
673
680
  if (!isAbsolute(sessionsDir)) {
674
681
  throw new Error('Captain session store path must be absolute');
675
682
  }
683
+ if (typeof createSessionsDir !== 'boolean') {
684
+ throw new TypeError('Captain session store createSessionsDir must be a boolean');
685
+ }
676
686
  if (
677
687
  explicitHostname !== undefined &&
678
688
  (typeof explicitHostname !== 'string' ||
@@ -1101,7 +1111,7 @@ export function createCaptainSessionStore(options = {}) {
1101
1111
  : await portableWriters.get(recordValue.sessionId)?.checkpoint(recordValue);
1102
1112
  if (record === undefined) throw new Error('session persistence requires its owning lifecycle');
1103
1113
  const destination = recordPathFor(record.sessionId);
1104
- await ensurePrivateDirectory(sessionsDir, fs);
1114
+ await ensurePrivateDirectory(sessionsDir, fs, mayCreateDirectory);
1105
1115
 
1106
1116
  if (noReplace) {
1107
1117
  await assertPathMissing(
@@ -1358,7 +1368,7 @@ export function createCaptainSessionStore(options = {}) {
1358
1368
  };
1359
1369
 
1360
1370
  const makeLeaseStage = async (sessionId) => {
1361
- await ensurePrivateDirectory(sessionsDir, fs);
1371
+ await ensurePrivateDirectory(sessionsDir, fs, mayCreateDirectory);
1362
1372
  const ownerToken = createLeaseToken();
1363
1373
  assertUuid(ownerToken, 'Captain session lease owner token');
1364
1374
  const retiredPath = retiredPathFor(sessionId, ownerToken);
@@ -1625,10 +1635,10 @@ export function createCaptainSessionStore(options = {}) {
1625
1635
  // the lease below; a preflight read never authorizes migration writes.
1626
1636
  try { decodeSource(await readPrivateRegularFile(sourcePath, 0o600, fs, 'migration source')); }
1627
1637
  catch (cause) { if (cause?.code !== 'ENOENT' || !sidecar && !external) throw cause; }
1628
- let sourceLease, lease;
1638
+ let sourceLease, lease, failure;
1629
1639
  try {
1630
1640
  if (external) {
1631
- const sourceStore = createCaptainSessionStore({ sessionsDir: sourceDir, env, homeDir: home, fsOps: fs, resolveHostname: localIdentity, legacyHostname, pid: localPid, probeProcess });
1641
+ const sourceStore = createCaptainSessionStore({ sessionsDir: sourceDir, createSessionsDir, env, homeDir: home, fsOps: fs, resolveHostname: localIdentity, legacyHostname, pid: localPid, probeProcess });
1632
1642
  sourceLease = await sourceStore.acquireManagement(sessionId);
1633
1643
  }
1634
1644
  lease = await acquire(sessionId, true);
@@ -1719,7 +1729,7 @@ export function createCaptainSessionStore(options = {}) {
1719
1729
  if (existing !== `${JSON.stringify(manifest)}\n`) throw new Error('migration destination manifest diverged');
1720
1730
  } catch (cause) { if (cause?.code !== 'ENOENT') throw cause; }
1721
1731
  }
1722
- await ensurePrivateDirectory(inputsDir, fs);
1732
+ await ensurePrivateDirectory(inputsDir, fs, mayCreateDirectory);
1723
1733
  const inputs = [{ path: sourcePath, bytes: Buffer.from(sourceBytes, 'utf8') }, ...(originalReplay === undefined ? [] : [{ path: sourceReplayPath, bytes: originalReplay }])];
1724
1734
  for (const [index, input] of inputs.entries()) {
1725
1735
  const path = join(inputsDir, String(index));
@@ -1743,8 +1753,11 @@ export function createCaptainSessionStore(options = {}) {
1743
1753
  if (sidecar || external) { await fs.unlink(sourcePath); await syncDirectory(sourceDir, fs); }
1744
1754
  await writePrivateJson(receiptPath, { ...receipt, complete: true }, fs);
1745
1755
  return { manifest, migrated: true, reasons: [] };
1756
+ } catch (cause) {
1757
+ failure = { cause };
1758
+ throw cause;
1746
1759
  } finally {
1747
- try { await lease?.release(); } finally { await sourceLease?.release(); }
1760
+ await releaseInOrder([lease, sourceLease], failure, `cannot migrate Captain session ${JSON.stringify(sessionId)} without leaving ownership uncertain`);
1748
1761
  }
1749
1762
  };
1750
1763
 
@@ -1754,7 +1767,7 @@ export function createCaptainSessionStore(options = {}) {
1754
1767
  const sourceDir = join(sourceEnv.XDG_STATE_HOME || join(sourceHome, '.local', 'state'), 'playbook', 'sessions');
1755
1768
  const result = { sourceDir, migrated: [], skipped: [] };
1756
1769
  if (sourceDir === sessionsDir) return result;
1757
- const sourceStore = createCaptainSessionStore({ sessionsDir: sourceDir, env: sourceEnv, homeDir: sourceHome, fsOps: fs, resolveHostname: localIdentity, legacyHostname, pid: localPid, probeProcess });
1770
+ const sourceStore = createCaptainSessionStore({ sessionsDir: sourceDir, createSessionsDir, env: sourceEnv, homeDir: sourceHome, fsOps: fs, resolveHostname: localIdentity, legacyHostname, pid: localPid, probeProcess });
1758
1771
  await sourceStore.prepare();
1759
1772
  try { await assertPrivateDirectory(sourceDir, fs); }
1760
1773
  catch (cause) { if (cause?.code === 'ENOENT') return result; throw cause; }
@@ -1778,8 +1791,10 @@ export function createCaptainSessionStore(options = {}) {
1778
1791
 
1779
1792
  const remove = async (sessionId) => {
1780
1793
  const lease = await acquire(sessionId, true);
1794
+ let failure;
1781
1795
  try { await lease.assertOwner(); await deleteRecord(sessionId); }
1782
- finally { await lease.release(); }
1796
+ catch (cause) { failure = { cause }; throw cause; }
1797
+ finally { await releaseInOrder([lease], failure, `cannot delete Captain session ${JSON.stringify(sessionId)} without leaving ownership uncertain`); }
1783
1798
  };
1784
1799
 
1785
1800
  const exportBundle = async (sessionId) => {
@@ -6706,7 +6721,7 @@ async function assertPathMissing(path, fs, message) {
6706
6721
  }
6707
6722
 
6708
6723
 
6709
- async function ensurePrivateDirectory(path, fs) {
6724
+ async function ensurePrivateDirectory(path, fs, mayCreate = () => true) {
6710
6725
  try {
6711
6726
  await assertPrivateDirectory(path, fs);
6712
6727
  return;
@@ -6728,6 +6743,15 @@ async function ensurePrivateDirectory(path, fs) {
6728
6743
  cursor = parent;
6729
6744
  }
6730
6745
  }
6746
+ // Refuse before creating anything, so a forbidden ancestor is never
6747
+ // recreated beneath a permitted descendant (session-storage-11).
6748
+ const forbidden = missing.find((directory) => !mayCreate(directory));
6749
+ if (forbidden !== undefined) {
6750
+ throw Object.assign(
6751
+ new Error(`Captain session store directory ${JSON.stringify(forbidden)} is missing and this store may not create it`),
6752
+ { code: 'ENOENT' },
6753
+ );
6754
+ }
6731
6755
  for (const directory of missing.reverse()) {
6732
6756
  let created = false;
6733
6757
  try {
@@ -6750,6 +6774,32 @@ async function ensurePrivateDirectory(path, fs) {
6750
6774
  await assertPrivateDirectory(path, fs);
6751
6775
  }
6752
6776
 
6777
+ // Releases every acquired lease in order. Without an earlier failure the
6778
+ // last release error propagates, as nested try/finally releases would; a
6779
+ // release error never hides the failure that preceded it.
6780
+ async function releaseInOrder(leases, failure, message) {
6781
+ const errors = [];
6782
+ for (const lease of leases) {
6783
+ try {
6784
+ await lease?.release();
6785
+ } catch (error) {
6786
+ errors.push(error);
6787
+ }
6788
+ }
6789
+ if (errors.length === 0) return;
6790
+ if (failure === undefined) throw errors.at(-1);
6791
+ throw new AggregateError([failure.cause, ...errors], message);
6792
+ }
6793
+
6794
+ function isPathOrAncestor(directory, path) {
6795
+ const fromDirectory = relative(directory, path);
6796
+ return fromDirectory === '' || (
6797
+ fromDirectory !== '..' &&
6798
+ !fromDirectory.startsWith(`..${sep}`) &&
6799
+ !isAbsolute(fromDirectory)
6800
+ );
6801
+ }
6802
+
6753
6803
  async function assertPrivateDirectory(path, fs) {
6754
6804
  const stat = await assertDirectoryNotLink(path, fs);
6755
6805
  if ((stat.mode & 0o7777) !== 0o700) {
@@ -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) => {