@sabaiway/agent-workflow-kit 13.0.0 → 13.1.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.
@@ -1,29 +1,32 @@
1
1
  // exec-producer.mjs — the GIT-side metric producer for an exec return (delegation Plan 2, Phase 1).
2
2
  // It answers the two questions a return record cannot answer for itself: which objects the delegated
3
- // run touched and how many bytes they represent, and what the canonical integration bundle is. It
4
- // never writes, never appends and never spawns anything but git reads.
3
+ // run touched and how many bytes they represent, and what the canonical integration bundle is.
4
+ // Under a checkpoint base the walks use the leaf's own temporary index and forced-add blob objects;
5
+ // the real index is never written, and head walks spawn only git reads.
5
6
  //
6
7
  // dispatch-record.mjs:74-76 named this module before it existed: "the metric's byte domains are
7
8
  // computed over STRUCTURED inputs handed in by a producer; the git-side producer is Plan 2, and it
8
9
  // owns minting a stable objectId".
9
10
  //
10
- // ONE DOMAIN, THREE LAYERS (D7). The enumeration walks HEAD → index → worktree, exactly the layers
11
+ // ONE DOMAIN, THREE LAYERS (D7) under a head base. The enumeration walks HEAD → index → worktree, exactly the layers
11
12
  // the canonical payload concatenates — never the collapsed HEAD → worktree view. The difference is
12
13
  // not academic: a staged change reverted in the worktree keeps its bytes in the payload (and so in
13
14
  // the DENOMINATOR) while HEAD→worktree shows nothing at all, so the object would vanish from the
14
15
  // NUMERATOR and the two halves of one ratio would describe different change sets. The diff bytes are
15
- // computeFingerprintPayload's own bytes, imported rather than rebuilt.
16
+ // computeFingerprintPayload's own bytes, imported rather than rebuilt. Under a checkpoint base the
17
+ // walk is one layer against the base tree, through the leaf's temporary index.
16
18
  //
17
- // ONE OBJECT, ONE ENTRY. An object touched in both layers is a single entry, and its pre-image is
19
+ // ONE OBJECT, ONE ENTRY. Under a head base an object touched in both layers is a single entry, and its pre-image is
18
20
  // always the HEAD blob — never the index blob: the numerator answers what the delegate could have
19
21
  // spared the ORCHESTRATOR, measured against the dispatch baseline, and a metric-eligible dispatch
20
- // starts from a clean tree where HEAD is that baseline.
22
+ // starts from a clean tree where HEAD is that baseline. Under a checkpoint base the pre-image is the base blob.
21
23
  //
22
24
  // RENAMES ARE NOT RECONSTRUCTED, AND THAT IS THE DESIGN — a boundary, not an omission. git's OWN
23
25
  // rename detection is consumed where git offers it (`--raw -M` sees a staged `git mv` within a layer),
24
- // but a rename a delegate actually makes — the wrapper's git-write shim blocks every git write verb,
26
+ // but under a head base a rename a delegate actually makes — the wrapper's git-write shim blocks every git write verb,
25
27
  // so every one arrives as a tracked deletion plus an UNTRACKED creation — is accounted as exactly
26
- // that: a deletion plus a creation.
28
+ // that: a deletion plus a creation. Under a checkpoint base git's own rename detection applies inside
29
+ // the single base-relative layer, and a detected rename is counted once as `renamed`.
27
30
  //
28
31
  // This module once matched those pairs by blob identity, and the machinery was SUBTRACTED after it
29
32
  // produced silent-error edges in four consecutive review rounds: hashing that read through symlinks
@@ -35,7 +38,7 @@
35
38
  // An identity heuristic buys accuracy on the ordinary refactor and pays for it in exactly that
36
39
  // currency, so the trade was refused.
37
40
  //
38
- // The cost is stated with its DIRECTION, per case, because the direction is NOT the same in all of
41
+ // The head-base cost is stated with its DIRECTION, per case, because the direction is NOT the same in all of
39
42
  // them — and a blanket "always optimistic" would be a false reassurance about an accounting number.
40
43
  // Write O for the pre-image size:
41
44
  // • A PLAIN RENAME `a→b` emits `deleted pre:a (O)` + `new new:b (O)` = 2O against a recognised O.
@@ -54,18 +57,11 @@
54
57
  // Every object here is one git can PROVE: a tracked path with a recorded blob, or an untracked path
55
58
  // that exists. Nothing is paired on a resemblance.
56
59
  //
57
- // CONFIG-HIDDEN PATHS ARE A STATED LIMIT, and the limit is the PAYLOAD's, not this module's. `git
58
- // diff` honours `diff.ignoreSubmodules` and skips index entries carrying assume-unchanged or
59
- // skip-worktree, so a path can be changed and stay invisible to both probes below. The kit already
60
- // owns a probe that sees them — computeWorkingState (core-evidence.mjs:341) forces
61
- // `--ignore-submodules=none` and folds in flaggedIndexLag — and `isTreeClean` consumes it, so a
62
- // dispatch opened over such a tree records baselineClean:false and its return is metric-INELIGIBLE by
63
- // name. What this module must NOT do is force those flags HERE: D7 binds the numerator to the byte
64
- // domain of computeFingerprintPayload, which uses the same plain `git diff` — forcing them on one
65
- // side only would let the numerator count objects the denominator cannot see, a worse failure than
66
- // the blindness. Closing it for real means changing the shared payload, which is a Plan-1-frozen
67
- // surface the review lane also consumes; that is queued as its own item, and Phase 2's `return` owns
68
- // the return-time guard.
60
+ // CONFIG-HIDDEN PATHS ARE A STATED LIMIT, and not this module's to close. Under a head base `git
61
+ // diff` skips index entries carrying assume-unchanged or skip-worktree, so a path can be changed and
62
+ // stay invisible to both probes below; the doors refuse a concealing tree rather than measure it. The
63
+ // checkpoint walks force `--ignore-submodules=none` because the checkpoint payload they are measured
64
+ // against does.
69
65
  //
70
66
  // IDENTITY CARRIES ITS DOMAIN (D6). `pre:<path>` for an object with a pre-image (keyed by the name it
71
67
  // HAD) and `new:<path>` for a created one. The prefix is load-bearing: a rename a→b beside a
@@ -79,12 +75,12 @@
79
75
  // paying it zero would drop a whole object's bytes out of the numerator on a type change. Every OTHER
80
76
  // unknown size REFUSES — the fail-closed rule is never a silent zero.
81
77
  //
82
- // A TRANSIENT object (staged, then deleted from the worktree) counts its INDEX image once: the
78
+ // Under a head base a TRANSIENT object (staged, then deleted from the worktree) counts its INDEX image once: the
83
79
  // delegate authored those bytes, the payload carries them in both layers, and dropping them would
84
80
  // credit the delegate with less than it wrote.
85
81
  //
86
- // Fail closed: outside a git work tree, on an unborn branch (no HEAD means no pre-image to attribute
87
- // bytes against), on any git read that fails, and on any size it cannot establish.
82
+ // Fail closed: outside a git work tree, on an unborn branch (the inherited no-HEAD limit
83
+ // applies to both bases), on any git read that fails, and on any size it cannot establish.
88
84
 
89
85
  import { join } from 'node:path';
90
86
  import { createHash } from 'node:crypto';
@@ -92,7 +88,8 @@ import {
92
88
  gitBuf, computeFingerprintPayload, isBinaryFile, isNeverCommittableStat, resolveBase,
93
89
  } from './core-evidence.mjs';
94
90
  import { lstatNoFollowRead } from './fs-read-nofollow.mjs';
95
- import { frameIntegrationBundle } from './dispatch-record.mjs';
91
+ import { frameIntegrationBundle, HEAD_BASELINE } from './dispatch-record.mjs';
92
+ import { runBaseDiff, computeBasePayload } from './dispatch-baseline.mjs';
96
93
 
97
94
  const refuse = (reason) => ({ ok: false, reason });
98
95
 
@@ -100,6 +97,8 @@ const GITLINK_MODE = '160000';
100
97
  const SYMLINK_MODE = '120000';
101
98
  const ABSENT_MODE = '000000';
102
99
  const SUBMODULE_BYTES = 0;
100
+ const BASE_RAW_ARGS = ['--raw', '-z', '-M', '--no-abbrev', '--no-ext-diff', '--ignore-submodules=none'];
101
+ const BASE_NUMSTAT_ARGS = ['--numstat', '-z', '-M', '--no-ext-diff', '--ignore-submodules=none'];
103
102
 
104
103
  export const PRE_IMAGE_ID_PREFIX = 'pre:';
105
104
  export const NEW_IMAGE_ID_PREFIX = 'new:';
@@ -229,7 +228,7 @@ export const parseNumstatMarkers = (buf, where) => {
229
228
  };
230
229
 
231
230
  // ── the object model ──────────────────────────────────────────────────────────────────────────────
232
- // One record per touched object: where it came from in HEAD, where it ended up, and every name it
231
+ // One record per touched object: where it came from in the base tree, where it ended up, and every name it
233
232
  // wore in between (the names the binary oracle and the type probes are asked about).
234
233
 
235
234
  const makeObject = ({ headPath, headSha, headMode }) => ({
@@ -247,7 +246,7 @@ const kindForModes = (modes) => {
247
246
 
248
247
  const buildObjects = (staged, unstaged) => {
249
248
  const objects = [];
250
- // HEAD → index. A staged record's DESTINATION name is how the unstaged layer will refer to it.
249
+ // Base tree → index. Under a head base the DESTINATION name links to the unstaged layer.
251
250
  const byIndexPath = new Map();
252
251
  for (const r of staged) {
253
252
  const hasHead = !isAbsentSha(r.srcSha) && r.srcMode !== ABSENT_MODE;
@@ -312,7 +311,7 @@ const entryForObject = (top, object, binary, lstat) => {
312
311
  if (typeKind !== null) {
313
312
  // The COUNTED image decides the size, even when the emitted kind comes from another layer. A
314
313
  // gitlink in ANY layer makes the kind `submodule` (TYPE beats STATUS, D6) — but a regular file
315
- // REPLACED by a gitlink is still counted at its HEAD blob, and paying it the submodule's
314
+ // REPLACED by a gitlink is still counted at its base blob, and paying it the submodule's
316
315
  // deliberate zero would drop a whole object's bytes out of the numerator on a type change. Zero
317
316
  // belongs to a counted image that is ITSELF a gitlink: that is the case where the move costs this
318
317
  // repository no bytes.
@@ -359,6 +358,24 @@ const entryForUntracked = (top, rel, lstat) => {
359
358
  : { ok: true, entry: { kind: 'new', path: rel, objectId, postImageBytes: stat.size } };
360
359
  };
361
360
 
361
+ const enumerateCheckpointObjects = (top, base, io) => {
362
+ const raw = runBaseDiff(top, base, BASE_RAW_ARGS, io);
363
+ if (raw === null) return refuse('git could not read the base-relative change set (fail closed)');
364
+ const parsed = parseRawRecords(raw, 'the base-relative change set');
365
+ if (!parsed.ok) return parsed;
366
+ const numstat = runBaseDiff(top, base, BASE_NUMSTAT_ARGS, io);
367
+ if (numstat === null) return refuse('git could not read the numstat binary markers (fail closed)');
368
+ const binary = parseNumstatMarkers(numstat, 'the base-relative binary markers');
369
+ if (!binary.ok) return binary;
370
+ const entries = [];
371
+ for (const object of buildObjects(parsed.records, [])) {
372
+ const built = entryForObject(top, object, binary.names, io.lstat);
373
+ if (!built.ok) return built;
374
+ entries.push(built.entry);
375
+ }
376
+ return { ok: true, entries };
377
+ };
378
+
362
379
  // enumerateReturnedObjects(cwd, io?) → { ok: true, entries } | { ok: false, reason }. The entries are
363
380
  // the STRUCTURED input computeNumerator consumes; this module never computes the numerator itself, so
364
381
  // the closed vocabulary stays the single authority on what a component is. `io.lstat` and
@@ -370,6 +387,8 @@ export const enumerateReturnedObjects = (cwd = process.cwd(), io = {}) => {
370
387
  if (resolveBase(top) == null) {
371
388
  return refuse('the branch is unborn (no HEAD) — there is no pre-image to attribute delegated bytes against (fail closed)');
372
389
  }
390
+ const base = io.base ?? HEAD_BASELINE;
391
+ if (base.kind === 'checkpoint') return enumerateCheckpointObjects(top, base, io);
373
392
  // ABSENT and UNREADABLE are different answers. ENOENT means the path vanished between `ls-files`
374
393
  // and the stat, which the payload itself records name-only — mirroring it keeps numerator and
375
394
  // denominator over ONE object set. Every OTHER errno (EACCES, EIO, ENOTDIR, ELOOP on a parent) is
@@ -442,9 +461,10 @@ export const enumerateReturnedObjects = (cwd = process.cwd(), io = {}) => {
442
461
  return refusedProbe() ?? { ok: true, entries };
443
462
  };
444
463
 
445
- // computeReturnedDiff(cwd) → the canonical uncommitted-state payload BYTES, or a refusal. This is the
446
- // diff half of the integration bundle, and it is the very payload computeTreeFingerprint digests — so
447
- // the bytes the denominator counts and the digest the records bind describe one state. It refuses
464
+ // computeReturnedDiff(cwd) → the payload BYTES the thread's own base fingerprint digests, or a refusal:
465
+ // computeTreeFingerprint's under a head base, the leaf's base-relative payload under a checkpoint.
466
+ // This is the diff half of the integration bundle, so the bytes the denominator counts and the
467
+ // digest the records bind describe one state. It refuses
448
468
  // where the enumeration refuses: one entry point may not answer for a tree the other rejects. That
449
469
  // sentence used to hold only for the two SHARED preconditions below, which let an AMBIGUOUS rename
450
470
  // refuse the enumeration while the diff still handed back bytes — so the enumeration is run here and
@@ -457,18 +477,18 @@ export const computeReturnedDiff = (cwd = process.cwd(), io = {}) => {
457
477
  }
458
478
  const enumerated = enumerateReturnedObjects(cwd, io);
459
479
  if (!enumerated.ok) return enumerated;
460
- // The payload builder guards its lstat but reads untracked file BYTES unguarded, so an unreadable
480
+ // The head payload builder guards its lstat but reads untracked file BYTES unguarded, so an unreadable
461
481
  // or vanishing untracked file throws out of it. This entry point promises a refusal object, and a
462
482
  // caller that framed a bundle around a thrown read would have no bytes and no reason either.
463
- let payload;
464
483
  try {
465
- payload = computeFingerprintPayload(cwd);
484
+ const base = io.base ?? HEAD_BASELINE;
485
+ const payload = base.kind === 'checkpoint' ? computeBasePayload(cwd, base, io) : computeFingerprintPayload(cwd);
486
+ return payload == null
487
+ ? refuse('the canonical payload could not be computed (fail closed)')
488
+ : { ok: true, diff: payload };
466
489
  } catch (err) {
467
490
  return refuse(`the canonical payload could not be read (${err?.code ?? err?.message ?? 'read failed'}) — the returned diff is refused rather than framed around a partial read (fail closed)`);
468
491
  }
469
- return payload == null
470
- ? refuse('the canonical payload could not be computed (fail closed)')
471
- : { ok: true, diff: payload };
472
492
  };
473
493
 
474
494
  // assembleIntegrationBundle(diff, report) → the framed bundle with its digest and length. The framing
@@ -1,10 +1,12 @@
1
1
  import { escapeForDisplay, isRenderableLine, shellQuoteArg } from './repo-lex.mjs';
2
2
  import { wrapperCmdFor } from './detect-backends.mjs';
3
3
  import { DISPLAY_ALIASES } from './recipes.mjs';
4
+ import { readsBaseline, TERMINAL_RETURN_OUTCOMES } from './dispatch-record.mjs';
4
5
 
5
6
  const CODE_STEP = 'code';
6
7
  const FOLD_KIND = 'fold';
7
8
  const RETURN_KIND = 'return';
9
+ const DEGRADE_KIND = 'degrade';
8
10
  const EXECUTE_BACKEND = 'codex-cli-bridge';
9
11
  const UNPOPULATED_HELD_ID = '<held id>';
10
12
 
@@ -24,6 +26,7 @@ const findsDispatch = (dispatches, nonce) => dispatches.find((dispatch) => dispa
24
26
  const findsReturn = (returns, nonce) => returns.find((returned) => returned.nonce === nonce) ?? null;
25
27
  const hasCoveredReplacement = (returned, degrades) =>
26
28
  degrades.some((degrade) => degrade.fingerprint === returned.postTreeDigest);
29
+ const expectsSession = (dispatch, baseKind) => dispatch.baselineClean === false || baseKind === 'checkpoint';
27
30
  const collectsRetryChainNonces = (dispatches, nonce, collected = new Set()) => {
28
31
  if (nonce === null || collected.has(nonce)) return collected;
29
32
  const next = new Set(collected).add(nonce);
@@ -33,26 +36,28 @@ const collectsRetryChainNonces = (dispatches, nonce, collected = new Set()) => {
33
36
  const accumulatesFacts = (degrades) => (state, record) => {
34
37
  if (record.kind === 'dispatch' && state.epochNonces.has(record.nonce)) {
35
38
  const expectedId = state.heldId;
39
+ const baseKind = readsBaseline(record).kind;
36
40
  return {
37
41
  ...state,
38
42
  dispatches: [...state.dispatches, record],
39
- threads: [...state.threads, { nonce: record.nonce, expectedId, actualId: null, status: 'OPEN' }],
40
- open: record.baselineClean === false ? [...state.open, { nonce: record.nonce, expectedId }] : state.open,
43
+ threads: [...state.threads, { nonce: record.nonce, expectedId, actualId: null, status: 'OPEN', baseKind }],
44
+ open: expectsSession(record, baseKind) ? [...state.open, { nonce: record.nonce, expectedId }] : state.open,
41
45
  };
42
46
  }
43
47
  if (record.kind === RETURN_KIND && state.epochNonces.has(record.nonce)) {
44
48
  const dispatch = findsDispatch(state.dispatches, record.nonce);
45
49
  if (dispatch === null) return state;
50
+ const baseKind = readsBaseline(dispatch).kind;
46
51
  const expectedId = state.threads.find((thread) => thread.nonce === record.nonce)?.expectedId ?? null;
47
52
  const status = record.sessionId === null ? 'FAILED'
48
53
  : expectedId === null ? 'FIRST'
49
54
  : dispatch.retryOf !== null || record.sessionId === expectedId ? 'CONTINUED'
50
55
  : 'SUBSTITUTED';
51
- const substitution = status === 'SUBSTITUTED' && dispatch.baselineClean === false
52
- && !hasCoveredReplacement(record, degrades)
56
+ const substitution = status === 'SUBSTITUTED' && expectsSession(dispatch, baseKind)
57
+ && (baseKind === 'checkpoint' ? !TERMINAL_RETURN_OUTCOMES.includes(record.outcome) : !hasCoveredReplacement(record, degrades))
53
58
  ? {
54
59
  nonce: record.nonce, expectedId, actualId: record.sessionId,
55
- postTreeDigest: record.postTreeDigest, folded: false,
60
+ postTreeDigest: record.postTreeDigest, folded: false, baseKind,
56
61
  }
57
62
  : null;
58
63
  return {
@@ -71,7 +76,7 @@ const accumulatesFacts = (degrades) => (state, record) => {
71
76
  if (dispatch === null || returned === null || returned.sessionId === null) return state;
72
77
  const establishes = state.heldId === null;
73
78
  const retries = dispatch.retryOf !== null;
74
- const replaces = returned.sessionId !== state.heldId && hasCoveredReplacement(returned, degrades);
79
+ const replaces = readsBaseline(dispatch).kind === 'head' && returned.sessionId !== state.heldId && hasCoveredReplacement(returned, degrades);
75
80
  const retryChain = retries ? collectsRetryChainNonces(state.dispatches, dispatch.retryOf) : new Set();
76
81
  const foldedSubstitutions = state.substitutions.map((substitution) =>
77
82
  (substitution.nonce === record.nonce ? { ...substitution, folded: true } : substitution));
@@ -81,6 +86,10 @@ const accumulatesFacts = (degrades) => (state, record) => {
81
86
  if (establishes || retries || replaces) return { ...state, heldId: returned.sessionId, substitutions };
82
87
  return { ...state, substitutions };
83
88
  }
89
+ if (record.kind === DEGRADE_KIND && state.epochNonces.has(record.nonce)
90
+ && state.substitutions.some((substitution) => substitution.nonce === record.nonce && substitution.baseKind === 'checkpoint')) {
91
+ return { ...state, heldId: null, substitutions: state.substitutions.filter((substitution) => substitution.nonce !== record.nonce) };
92
+ }
84
93
  return state;
85
94
  };
86
95
 
@@ -109,11 +118,15 @@ export const judgeHeldSession = (records, { head, backend, degrades = [] }) => {
109
118
  heldId: accumulated.heldId,
110
119
  folds: countsHeldFolds(records, epochNonces, accumulated.heldId),
111
120
  substitution: accumulated.substitutions[0] ?? null,
112
- threads: accumulated.threads,
121
+ threads: accumulated.threads.map((thread) => ({
122
+ ...thread, substituted: accumulated.substitutions.some((substitution) => substitution.nonce === thread.nonce),
123
+ })),
113
124
  open: accumulated.open,
114
125
  };
115
126
  };
116
127
 
128
+ export const threadVerdict = (facts, nonce) => facts.threads.find((thread) => thread.nonce === nonce) ?? null;
129
+
117
130
  export const judgeLedger = (ledger, { backend, degrades = [] }) => {
118
131
  if (ledger.state === 'absent') {
119
132
  return { state: 'absent', heldId: null, folds: 0, substitution: null, threads: [], open: [] };
@@ -137,7 +150,7 @@ export const decideHeldSession = (facts) => {
137
150
  const reason = `delegated code thread "${nonce}" substituted held session "${escapeForDisplay(expectedId)}" with "${escapeForDisplay(actualId)}"`;
138
151
  return { code: 1, reason, line: `held session: SUBSTITUTED — ${reason}` };
139
152
  }
140
- if (facts.heldId === null) return { code: 0, reason: 'no folded code thread in the commit epoch carries a session yet', line: 'held session: none' };
153
+ if (facts.heldId === null) return { code: 0, reason: 'no held session stands: no folded code thread established one in this commit epoch, or the last one was withdrawn by a checkpoint thread\'s ledger degrade', line: 'held session: none' };
141
154
  const heldId = escapeForDisplay(facts.heldId);
142
155
  return { code: 0, reason: `held session "${heldId}" is continuous`, line: `held session: ${heldId} — ${facts.folds} fold(s) rode it` };
143
156
  };
@@ -159,7 +172,7 @@ export const foldLaneLines = (facts) => {
159
172
  ` run: ${HELD_EXECUTE_WRAPPER} --resume ${heldId} --nonce <nonce> <fold-brief>`,
160
173
  ' the fold brief is a dispatch file carrying the finding and the accepted fold; dispatch open precedes the run, then dispatch return and fold follow it',
161
174
  ' the orchestrator runs the suites, verifies the returned diff, re-mints the red-proofs and owns the commit',
162
- ' a fresh session is a forbidden substitution; a retry of a failed thread or a recorded execute degrade is the exception; the wrapper sidecar is never read',
175
+ ' a fresh session is a forbidden substitution; a retry of a failed thread or, for a head base, a recorded execute degrade is the exception (a checkpoint thread\'s substitution closes by its ledger degrade); the wrapper sidecar is never read',
163
176
  ];
164
177
  return caveat === null ? lines : [...lines, ` caveat: ${caveat}`];
165
178
  };
@@ -11,9 +11,10 @@
11
11
  // Normative `--check` exit contract (the single home of this list — SKILL.md points here):
12
12
  // Before the review-recipe exits, configured plan-execution.execute=delegated with a plan and a
13
13
  // dirty, fingerprintable tree audits the delegation ledger. An absent ledger is inert; an
14
- // unavailable ledger, a HEAD error or a SUBSTITUTED held session refuses unless a codex-exec degrade at the
15
- // substituted return's tree, standing after every later fold, accepts it, or the fold of that thread's
16
- // retry lifts it (codex never grants it).
14
+ // unavailable ledger or a HEAD error refuses. A SUBSTITUTED held session refuses: for a head-base thread,
15
+ // a codex-exec degrade at the substituted return's tree, standing after every later fold, accepts it,
16
+ // or the fold of that thread's retry lifts it (codex never grants it); a checkpoint-based thread's
17
+ // substitution closes only by its own ledger degrade.
17
18
  // exit 0 when the CONFIGURED plan-execution.review recipe is solo (or the computed default is —
18
19
  // absent config with no reviewer backend ready); when no plan is in flight (docs/plans/
19
20
  // holds no top-level .md that is not queue.md and not scratch by the naming convention:
@@ -149,6 +150,7 @@ export {
149
150
  const ACTIVITY = 'plan-execution';
150
151
  const SLOT = 'review';
151
152
  const CORE_EVIDENCE_TOOL = shellQuoteArg(join(dirname(fileURLToPath(import.meta.url)), 'core-evidence.mjs'));
153
+ const DISPATCH_TOOL = shellQuoteArg(join(dirname(fileURLToPath(import.meta.url)), 'dispatch.mjs'));
152
154
  // --await (BUGFREE-3 / AD-049, item (d)) bounds + poll cadence. The default timeout is generous —
153
155
  // a real grounded bridge review can take minutes — and every value is overridable (--timeout / the
154
156
  // injectable clock) so hermetic tests never spend wall-clock.
@@ -531,6 +533,9 @@ const backendFailurePart = (b, state) => {
531
533
  const describesHeldRecovery = (state) => {
532
534
  const substitution = state.heldSession.substitution;
533
535
  if (substitution === null) return '';
536
+ if (substitution.baseKind === 'checkpoint') {
537
+ return ` — a checkpoint-based thread: no core-evidence degrade lifts it; record its ledger degrade (node ${DISPATCH_TOOL} degrade --wave <wave> --nonce ${shellQuoteArg(substitution.nonce)} --step-class code --rationale "...") then retry it (cap permitting) or open a new thread on the held session, and fold that`;
538
+ }
534
539
  const canRetry = substitution.folded === false;
535
540
  if (state.evidenceUnavailable === true) {
536
541
  const retry = canRetry ? '; or fold the retry of that thread' : '';
@@ -784,8 +789,7 @@ record (node core-evidence.mjs degrade) — and never all backends.
784
789
  below, a configured plan-execution.execute = delegated with a plan in flight and a dirty,
785
790
  fingerprintable tree audits the delegation ledger: an absent ledger is inert; an unreadable,
786
791
  malformed, foreign or audit-refused ledger, a failed HEAD read, or a SUBSTITUTED held session
787
- exits 1 (a substitution is lifted only by a codex-exec degrade at the substituted return's own
788
- tree, or the fold of that thread's retry). THEN: 0 for solo / no plan in flight /
792
+ exits 1. THEN: 0 for solo / no plan in flight /
789
793
  a clean tree (under a non-solo review obligation the PASS names every plan in flight and its arm) /
790
794
  a cwd where git ITSELF answers not-a-repository (the only passing non-work-tree state; the four others exit 1 by name) / obligations satisfied (reviewed: >=1 ship-class attestation;
791
795
  council: every backend ship-class or degrade-recorded, >=1 real ship); 1 on a veto, an