@sabaiway/agent-workflow-kit 1.37.0 → 1.38.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.
@@ -14,20 +14,35 @@
14
14
  // here). The gate is plan-EXECUTION-scoped, filtered to the in-flight plan's filename stem:
15
15
  // exit 0 when the resolved plan-execution.review recipe is solo (configured, or degraded there);
16
16
  // when no plan is in flight; when the tree is clean; when the cwd is not a git work tree;
17
- // and when the in-flight plan-execution loop has a CURRENT run record whose BOTH bindings
18
- // match the tree fingerprint AND the sorted fixable-bug testId set recorded in the run —
19
- // with every bound testId resolvable + baseline-green, 0 uncovered changed lines, 0 changed
20
- // unsupported-source files, and the reserved EMPTY mutation shape (v1 ships no mutation).
17
+ // and when the in-flight plan-execution loop has a CURRENT run record (kind-aware: the
18
+ // latest RUN, never a later red-probe) whose BOTH bindings match the tree fingerprint AND
19
+ // the sorted fixable-bug testId set recorded in the run with, per bound testId: an
20
+ // N/N-green probe (D4), an observed-red receipt in this loop (Approach-1.i), that receipt
21
+ // PRECEDING the latest run in ledger order (anti-post-hoc), and content CUSTODY — the run's
22
+ // recorded test-file hash equals the latest custody-eligible red-probe hash on that file
23
+ // (eligible = the receipt's own testId is bound AND it precedes the run, D5); plus 0
24
+ // uncovered changed lines, 0 changed unsupported-source files, a recorded tamper surface
25
+ // with every tampered test-surface file covered by a recorded oracle-change override
26
+ // (review ledger v3), and the reserved EMPTY mutation shape (no mutation ships). A
27
+ // recorded red-proof override waives the receipt + custody proof for exactly the testId
28
+ // it names (D7) — never the N/N-green requirement, never QUARANTINE.
21
29
  // exit 1 for any DIRTY in-flight plan-execution loop lacking such a current run record — including
22
- // the stale-fingerprint case (a tree edit moves the fingerprint) and the same-fingerprint/
23
- // new-testId case (a triage recorded after the run moves the bound-testId set, Decision 9)
24
- // or a run naming an unresolvable/red-baseline bound test, an uncovered changed line, a
25
- // changed unsupported-source file, or ANY mutation data (v1 ships no mutation such a record
26
- // was not produced by this runner version; fail closed); when MORE THAN ONE plan is in
27
- // flight (ambiguous loop id). Fail-CLOSED (unknown state, never a fail-open pass) on a
28
- // detector failure, an unreadable/malformed result or review ledger, or a corrupt run set —
29
- // the only detector-independent green is an EXPLICIT configured solo. Changed OUT-OF-DOMAIN
30
- // files (docs/config the suite does not execute) are LOUDLY listed but never gate-blocking
30
+ // the stale-fingerprint case (a tree edit moves the fingerprint), the same-fingerprint/
31
+ // new-testId case (a triage recorded after the run moves the bound-testId set, Decision 9),
32
+ // and a schema-1 (or tamper-less) record as the loop's latest run (an older runner; D2)
33
+ // or a tampered test-surface file (a removed/modified line in a pre-existing test file,
34
+ // or a deleted one the union of test-classified paths and bound-testId file halves)
35
+ // not covered by a recorded oracle-change override, a run naming an unresolvable bound
36
+ // test, a QUARANTINED bound
37
+ // test (mixed or timed-out probe runs — never an N/N verdict, never converted, no override
38
+ // lane; D4), a red-baseline bound test, a green bound test with NO observed-red receipt /
39
+ // a post-hoc receipt / broken custody (D5), an uncovered changed line, a changed
40
+ // unsupported-source file, or ANY mutation data (no mutation ships — such a record was not
41
+ // produced by this runner; fail closed); when MORE THAN ONE plan is in flight (ambiguous
42
+ // loop id). Fail-CLOSED (unknown state, never a fail-open pass) on a detector failure, an
43
+ // unreadable/malformed result or review ledger, or a corrupt run set — the only
44
+ // detector-independent green is an EXPLICIT configured solo. Changed OUT-OF-DOMAIN files
45
+ // (docs/config the suite does not execute) are LOUDLY listed but never gate-blocking
31
46
  // (Decision 5): guarding what the tool cannot assess with a red gate is a pretend-mechanism.
32
47
  //
33
48
  // HONEST residuals (accepted, documented — exactly like review-state's / review-ledger's): coverage
@@ -50,10 +65,17 @@ import { detectBackends } from './detect-backends.mjs';
50
65
  import { resolveActivityRecipe, planRecipe, DISPLAY_ALIASES } from './recipes.mjs';
51
66
  import { CONFIG_REL, fail, loadConfig } from './orchestration-config.mjs';
52
67
  import { computeTreeFingerprint, isTreeClean, plansInFlight } from './review-state.mjs';
53
- import { resolveLedgerPath, readLedger, filterLoopRecords } from './review-ledger.mjs';
68
+ import { resolveLedgerPath, readLedger, filterLoopRecords, collectOverrides, isWellFormedTestId, splitTestId } from './review-ledger.mjs';
54
69
 
55
70
  export const RESULTS_BASENAME = 'agent-workflow-fold-completeness.jsonl';
56
- export const RESULT_SCHEMA_VERSION = 1;
71
+ // SCHEMA v2 (BUGFREE-1 / AD-047): records gain a kind discriminator — `run` (the fold-completeness
72
+ // run, now with per-testId rerun counts + the test file's content hash) | `red-probe` (the
73
+ // observed-red receipt --red mints). RESULT_SCHEMA_VERSION is what the WRITER emits; the reader
74
+ // tolerates every SUPPORTED version under its own per-version rules (the review-ledger v1→v2
75
+ // precedent), so v1 ledgers never retroactively become malformed — but a v1 record as the loop's
76
+ // LATEST run fails the gate with a named re-run reason (D2).
77
+ export const RESULT_SCHEMA_VERSION = 2;
78
+ export const SUPPORTED_RESULT_SCHEMAS = new Set([1, 2]);
57
79
  const ACTIVITY = 'plan-execution';
58
80
  const SLOT = 'review';
59
81
 
@@ -96,37 +118,109 @@ const isNonEmptyString = (v) => typeof v === 'string' && v.length > 0;
96
118
  const isNonNegInt = (v) => Number.isInteger(v) && v >= 0;
97
119
  const isStringArray = (v) => Array.isArray(v) && v.every((x) => typeof x === 'string');
98
120
 
99
- // validateRunRecord(obj) { ok, reason }. The `reason` names the exact failed check so the
100
- // malformed-line surface and the per-check named tests can assert it. Mutation is reserved and must
101
- // stay the empty shape in v1 (the mutation half is shelved); the schema carries the fields so a
102
- // record validates uniformly and decideCheck enforces the exact reserved shape.
103
- export const validateRunRecord = (obj) => {
104
- if (!isPlainObject(obj)) return { ok: false, reason: 'not an object' };
105
- if (obj.schema !== RESULT_SCHEMA_VERSION) return { ok: false, reason: `schema must be ${RESULT_SCHEMA_VERSION}` };
106
- if (!isNonEmptyString(obj.loop)) return { ok: false, reason: 'missing loop' };
107
- if (!(obj.fingerprint === null || isNonEmptyString(obj.fingerprint))) return { ok: false, reason: 'fingerprint must be null or a non-empty string' };
108
- if (!isStringArray(obj.boundTestIds)) return { ok: false, reason: 'boundTestIds must be an array of strings' };
109
- if (!Array.isArray(obj.testIds)) return { ok: false, reason: 'testIds must be an array' };
121
+ const HASH_RE = /^[0-9a-f]{64}$/; // sha-256 hex the content-custody hash shape
122
+
123
+ // The shared record frame (both versions, both kinds).
124
+ const validateFrame = (obj) => {
125
+ if (!isNonEmptyString(obj.loop)) return 'missing loop';
126
+ if (!(obj.fingerprint === null || isNonEmptyString(obj.fingerprint))) return 'fingerprint must be null or a non-empty string';
127
+ if (!isNonEmptyString(obj.timestamp)) return 'missing timestamp';
128
+ return null;
129
+ };
130
+
131
+ // A v1 per-testId probe entry (single-run booleans the AD-046 shape, tolerated read-only).
132
+ const validateV1Entry = (t) => {
133
+ if (!isPlainObject(t) || !isNonEmptyString(t.id)) return 'each testId entry needs an id';
134
+ if (typeof t.resolvable !== 'boolean' || typeof t.baselineGreen !== 'boolean') return `testId ${t.id} needs boolean resolvable + baselineGreen`;
135
+ if (!isNonNegInt(t.executed)) return `testId ${t.id} executed must be a non-negative integer`;
136
+ return null;
137
+ };
138
+
139
+ // A v2 per-testId probe entry: rerun counts (the D4 evidence) + the test file's content hash (the
140
+ // D5 custody anchor) + the derived booleans, VALIDATED consistent with the counts so a forged
141
+ // verdict cannot ride beside honest-looking evidence.
142
+ const validateV2Entry = (t) => {
143
+ if (!isPlainObject(t) || !isNonEmptyString(t.id)) return 'each testId entry needs an id';
144
+ if (!isNonNegInt(t.executed)) return `testId ${t.id} executed must be a non-negative integer`;
145
+ if (!(Number.isInteger(t.runs) && t.runs >= 1)) return `testId ${t.id} runs must be a positive integer`;
146
+ if (!isNonNegInt(t.greens) || !isNonNegInt(t.reds) || !isNonNegInt(t.timeouts)) return `testId ${t.id} rerun counts (greens/reds/timeouts) must be non-negative integers`;
147
+ if (t.greens + t.reds + t.timeouts > t.runs) return `testId ${t.id} rerun counts exceed runs`;
148
+ // A resolved run means at least one matched (executed) test result — greens/reds with executed=0
149
+ // is a forged N/N verdict carrying zero-match evidence (codex R1, BUGFREE-1 live loop).
150
+ if (t.greens + t.reds > 0 && t.executed < 1) return `testId ${t.id} executed must be positive when a run resolved (greens+reds > 0)`;
151
+ if (!(t.fileHash === null || (typeof t.fileHash === 'string' && HASH_RE.test(t.fileHash)))) return `testId ${t.id} fileHash must be null or a 64-hex content hash`;
152
+ if (t.resolvable !== (t.greens + t.reds === t.runs)) return `testId ${t.id} resolvable must equal (greens + reds === runs)`;
153
+ if (t.baselineGreen !== (t.greens === t.runs)) return `testId ${t.id} baselineGreen must equal (greens === runs)`;
154
+ return null;
155
+ };
156
+
157
+ // The run-record body shared by v1 and v2 (surface classes, coverage, reserved mutation, budgets);
158
+ // the per-testId entry rule is the per-version part.
159
+ const validateRunBody = (obj, validateEntry) => {
160
+ if (!isStringArray(obj.boundTestIds)) return 'boundTestIds must be an array of strings';
161
+ if (!Array.isArray(obj.testIds)) return 'testIds must be an array';
110
162
  for (const t of obj.testIds) {
111
- if (!isPlainObject(t) || !isNonEmptyString(t.id)) return { ok: false, reason: 'each testId entry needs an id' };
112
- if (typeof t.resolvable !== 'boolean' || typeof t.baselineGreen !== 'boolean') return { ok: false, reason: `testId ${t.id} needs boolean resolvable + baselineGreen` };
113
- if (!isNonNegInt(t.executed)) return { ok: false, reason: `testId ${t.id} executed must be a non-negative integer` };
163
+ const r = validateEntry(t);
164
+ if (r) return r;
114
165
  }
115
- if (!isStringArray(obj.unsupported)) return { ok: false, reason: 'unsupported must be an array of strings' };
116
- if (!isStringArray(obj.outOfDomain)) return { ok: false, reason: 'outOfDomain must be an array of strings' };
117
- if (!isPlainObject(obj.coverage) || !Array.isArray(obj.coverage.uncoveredChanged)) return { ok: false, reason: 'coverage.uncoveredChanged must be an array' };
166
+ if (!isStringArray(obj.unsupported)) return 'unsupported must be an array of strings';
167
+ if (!isStringArray(obj.outOfDomain)) return 'outOfDomain must be an array of strings';
168
+ if (!isPlainObject(obj.coverage) || !Array.isArray(obj.coverage.uncoveredChanged)) return 'coverage.uncoveredChanged must be an array';
118
169
  for (const u of obj.coverage.uncoveredChanged) {
119
- if (!isPlainObject(u) || !isNonEmptyString(u.file)) return { ok: false, reason: 'each uncoveredChanged entry needs a file' };
120
- if (!(u.line === null || (Number.isInteger(u.line) && u.line >= 1))) return { ok: false, reason: `uncoveredChanged ${u.file} line must be null or an integer >= 1` };
170
+ if (!isPlainObject(u) || !isNonEmptyString(u.file)) return 'each uncoveredChanged entry needs a file';
171
+ if (!(u.line === null || (Number.isInteger(u.line) && u.line >= 1))) return `uncoveredChanged ${u.file} line must be null or an integer >= 1`;
121
172
  }
122
173
  const m = obj.mutation;
123
174
  if (!isPlainObject(m) || !isNonNegInt(m.total) || !isNonNegInt(m.killed) || !Array.isArray(m.survived) || !isNonNegInt(m.skipped)) {
124
- return { ok: false, reason: 'mutation must be { total, killed, survived[], skipped, killSetBasis }' };
175
+ return 'mutation must be { total, killed, survived[], skipped, killSetBasis }';
125
176
  }
126
- if (!(m.killSetBasis === null || isNonEmptyString(m.killSetBasis))) return { ok: false, reason: 'mutation.killSetBasis must be null or a non-empty string' };
127
- if (!isPlainObject(obj.budgets)) return { ok: false, reason: 'budgets must be an object' };
128
- if (!isNonEmptyString(obj.timestamp)) return { ok: false, reason: 'missing timestamp' };
129
- return { ok: true };
177
+ if (!(m.killSetBasis === null || isNonEmptyString(m.killSetBasis))) return 'mutation.killSetBasis must be null or a non-empty string';
178
+ if (!isPlainObject(obj.budgets)) return 'budgets must be an object';
179
+ return null;
180
+ };
181
+
182
+ // The red-probe receipt (v2 only): the machine attestation that testId FAILED on N/N runs at the
183
+ // recorded content hash. Minted only by the runner's --red verb; reds must equal runs — a receipt
184
+ // never records anything but an honest N/N red (refusals write nothing).
185
+ const validateRedProbe = (obj) => {
186
+ if (!isWellFormedTestId(obj.testId)) return 'red-probe testId must be "<test-file>#<test-name-pattern>" (a "#" separator, both halves non-empty)';
187
+ if (!(typeof obj.fileHash === 'string' && HASH_RE.test(obj.fileHash))) return 'red-probe fileHash must be a 64-hex content hash';
188
+ if (!(Number.isInteger(obj.runs) && obj.runs >= 1)) return 'red-probe runs must be a positive integer';
189
+ if (obj.reds !== obj.runs) return 'red-probe reds must equal runs (a receipt attests N/N observed red)';
190
+ return null;
191
+ };
192
+
193
+ // validateRunRecord(obj) → { ok, reason }. Per-version, per-kind (D2): v1 records (no kind) keep the
194
+ // AD-046 single-run rules; v2 records carry the kind discriminator. The `reason` names the exact
195
+ // failed check so the malformed-line surface and the per-check named tests can assert it. Mutation
196
+ // stays the reserved empty shape (the mutation half is shelved); the schema carries the fields so a
197
+ // record validates uniformly and decideCheck enforces the exact reserved shape.
198
+ export const validateRunRecord = (obj) => {
199
+ if (!isPlainObject(obj)) return { ok: false, reason: 'not an object' };
200
+ if (!SUPPORTED_RESULT_SCHEMAS.has(obj.schema)) return { ok: false, reason: `schema must be one of ${[...SUPPORTED_RESULT_SCHEMAS].join(', ')}` };
201
+ const frame = validateFrame(obj);
202
+ if (frame) return { ok: false, reason: frame };
203
+ if (obj.schema === 1) {
204
+ if (obj.kind !== undefined) return { ok: false, reason: 'a v1 record must not carry kind (kind is a v2 discriminator)' };
205
+ const r = validateRunBody(obj, validateV1Entry);
206
+ return r ? { ok: false, reason: r } : { ok: true };
207
+ }
208
+ if (obj.kind === 'run') {
209
+ const r = validateRunBody(obj, validateV2Entry);
210
+ if (r) return { ok: false, reason: r };
211
+ // The tamper surface (Phase 2.2) is OPTIONAL on read — a record written by the pre-tamper v2
212
+ // runner stays readable (never retroactively malformed); the GATE handles the staleness. When
213
+ // present, the shape is exact.
214
+ if (obj.tamper !== undefined && (!isPlainObject(obj.tamper) || !isStringArray(obj.tamper.tampered))) {
215
+ return { ok: false, reason: 'tamper.tampered must be an array of strings (when the tamper surface is recorded)' };
216
+ }
217
+ return { ok: true };
218
+ }
219
+ if (obj.kind === 'red-probe') {
220
+ const r = validateRedProbe(obj);
221
+ return r ? { ok: false, reason: r } : { ok: true };
222
+ }
223
+ return { ok: false, reason: `kind must be "run" or "red-probe" (got ${JSON.stringify(obj.kind)})` };
130
224
  };
131
225
 
132
226
  // readResults(path) → { records, malformed, malformedReasons, readError }. Absent file → empty (no run
@@ -157,9 +251,38 @@ export const readResults = (path, readFile = readFileSync) => {
157
251
  return { records, malformed: malformedReasons.length, malformedReasons };
158
252
  };
159
253
 
160
- // filterLoopResults(records, loop) → the run records of ONE loop, order preserved (latest is last).
254
+ // filterLoopResults(records, loop) → the result records of ONE loop (both kinds), order preserved
255
+ // (latest is last).
161
256
  export const filterLoopResults = (records, loop) => records.filter((r) => r.loop === loop);
162
257
 
258
+ // ── kind-aware selectors (shared: the runner and the checker read the ledger through THESE) ──────
259
+
260
+ // A v1 record IS a run (the kindless AD-046 shape); a v2 record is a run only under kind:"run".
261
+ export const isRunRecord = (r) => r.schema === 1 || r.kind === 'run';
262
+ export const isRedProbeRecord = (r) => r.schema >= 2 && r.kind === 'red-probe';
263
+
264
+ // latestRunRecord(loopRecords) → { record, index } | null over ONE loop's ordered records. Kind-aware
265
+ // (codex R2): a red-probe appended after a run must never be read as the loop's "latest run" — the
266
+ // index anchors the D5/order checks (a custody-eligible receipt PRECEDES the latest run).
267
+ export const latestRunRecord = (loopRecords) => {
268
+ for (let i = loopRecords.length - 1; i >= 0; i -= 1) {
269
+ if (isRunRecord(loopRecords[i])) return { record: loopRecords[i], index: i };
270
+ }
271
+ return null;
272
+ };
273
+
274
+ // probeVerdict(entry) → 'green' | 'red' | 'quarantine' | 'unresolvable' — the D4 verdict algebra,
275
+ // the SINGLE home shared by the runner (--red mints only on 'red') and the checker (the gate passes
276
+ // only on 'green'). RED/GREEN are strict N/N verdicts; any timeout, mixed outcome, or partial
277
+ // resolution is QUARANTINE — it never converts and has no override lane (a flaky pin proves
278
+ // nothing — replace the test). Zero resolved runs (or a defensive runs=0) reads unresolvable.
279
+ export const probeVerdict = (t) => {
280
+ const unresolved = t.runs - t.greens - t.reds - t.timeouts;
281
+ if (unresolved >= t.runs) return 'unresolvable';
282
+ if (t.timeouts > 0 || unresolved > 0 || (t.greens > 0 && t.reds > 0)) return 'quarantine';
283
+ return t.greens === t.runs ? 'green' : 'red';
284
+ };
285
+
163
286
  // ── the check + report core ─────────────────────────────────────────────────────────────────────
164
287
 
165
288
  // buildFoldState({ cwd, env, detect }) → everything both renders need. Pure I/O at the edges; every
@@ -230,9 +353,15 @@ export const decideCheck = (state) => {
230
353
  if (state.reviewMalformed > 0) return { code: 1, reason: `the review ledger has ${state.reviewMalformed} malformed line(s) — failing closed; inspect ${state.reviewPath}` };
231
354
 
232
355
  const loop = state.plans[0].replace(/\.md$/, '');
233
- const runs = filterLoopResults(state.resultRecords, loop);
234
- if (runs.length === 0) return { code: 1, reason: `dirty plan-execution loop "${loop}" but no fold-completeness run recorded — run fold-completeness-run.mjs` };
235
- const latest = runs[runs.length - 1];
356
+ const loopResults = filterLoopResults(state.resultRecords, loop);
357
+ const sel = latestRunRecord(loopResults);
358
+ if (sel == null) return { code: 1, reason: `dirty plan-execution loop "${loop}" but no fold-completeness run recorded — run fold-completeness-run.mjs` };
359
+ const latest = sel.record;
360
+ // D2 — a v1 record as the loop's latest run: the gate now needs the v2 evidence (rerun counts +
361
+ // custody hashes), which an older runner never recorded. Fail with a named re-run reason. The same
362
+ // rule covers a v2 record with NO recorded tamper surface (the pre-2.2 runner).
363
+ if (latest.schema === 1) return { code: 1, reason: `the loop's latest run is a schema-1 record (an older runner — no rerun counts, no custody hashes) — re-run fold-completeness-run.mjs` };
364
+ if (latest.tamper === undefined) return { code: 1, reason: `the loop's latest run has no recorded tamper surface (an older runner) — re-run fold-completeness-run.mjs` };
236
365
 
237
366
  // Decision 9 — the double binding. A tree edit moves the fingerprint; a new fixable-bug triage moves
238
367
  // the bound-testId set. Either mismatch is STALE (the run no longer describes the committable tree).
@@ -249,10 +378,54 @@ export const decideCheck = (state) => {
249
378
  // A changed unsupported-source file → fail closed: the signal never vouches for JS-family source it
250
379
  // cannot assess (TS/JSX out of scope v1, Decision 5).
251
380
  if (latest.unsupported.length > 0) return { code: 1, reason: `changed unsupported-source file(s) the signal cannot assess (TS/JSX out of scope v1): ${latest.unsupported.join(', ')}` };
252
- const unresolvable = latest.testIds.filter((t) => !t.resolvable).map((t) => t.id);
253
- if (unresolvable.length > 0) return { code: 1, reason: `unresolvable bound testId(s) — the pattern selects no test: ${unresolvable.join(', ')}` };
254
- const redBaseline = latest.testIds.filter((t) => t.resolvable && !t.baselineGreen).map((t) => t.id);
255
- if (redBaseline.length > 0) return { code: 1, reason: `bound test(s) with a red baseline (the fold is not complete): ${redBaseline.join(', ')}` };
381
+
382
+ // The recorded overrides of this loop (review ledger, schema v3): oracle-change lifts named
383
+ // tampered files; red-proof waives the receipt + custody proof for exactly the testId it names.
384
+ const overrides = collectOverrides(state.reviewRecords, { activity: ACTIVITY, loop });
385
+
386
+ // Approach-3 — the oracle-tamper guard: any tampered test-surface file (a removed/modified line in
387
+ // a pre-existing test file, or a deleted one) must be covered by a recorded oracle-change
388
+ // override. Never silent: the override is a durable, auditable ledger entry.
389
+ const tamperedUncovered = latest.tamper.tampered.filter((f) => !overrides.oracleChangeFiles.has(f));
390
+ if (tamperedUncovered.length > 0) {
391
+ return {
392
+ code: 1,
393
+ reason: `tampered test-surface file(s) — a fix diff modified/deleted pre-existing test expectations — without a recorded oracle-change override: ${tamperedUncovered.join(', ')}. If the oracle change is deliberate, record it: node review-ledger-write.mjs override --json '{"loop":"${loop}","round":<n>,"scope":"oracle-change","files":${JSON.stringify(tamperedUncovered)},"reason":"<why the expectation legitimately changed>"}'`,
394
+ };
395
+ }
396
+
397
+ // Per-testId enforcement, in bound (sorted) order: the D4 verdict algebra first — QUARANTINE and
398
+ // red/unresolvable are probe-outcome failures regardless of receipts — then, for a green test, the
399
+ // red-proof chain: receipt exists (Approach-1.i) → the receipt PRECEDES the loop's latest run
400
+ // (anti-post-hoc, codex R2) → content custody (D5). The checker stays PURE: the current file hash
401
+ // IS the latest run's recorded hash (the run is fingerprint-bound to the current tree).
402
+ const probes = new Map(latest.testIds.map((t) => [t.id, t]));
403
+ const boundSet = new Set(latest.boundTestIds);
404
+ const receipts = loopResults.map((r, i) => ({ r, i })).filter(({ r }) => isRedProbeRecord(r));
405
+ for (const id of latest.boundTestIds) {
406
+ const t = probes.get(id);
407
+ const verdict = probeVerdict(t);
408
+ if (verdict === 'unresolvable') return { code: 1, reason: `unresolvable bound testId(s) — the pattern selects no test: ${id}` };
409
+ if (verdict === 'quarantine') return { code: 1, reason: `bound test in QUARANTINE — not an N/N verdict (${t.greens} green / ${t.reds} red / ${t.timeouts} timed out of ${t.runs} runs): ${id}. A flaky/timed-out probe proves nothing and has no override lane — replace or speed up the test, then re-run` };
410
+ if (verdict === 'red') return { code: 1, reason: `bound test(s) with a red baseline (the fold is not complete): ${id}` };
411
+ // D7 — a recorded red-proof override replaces the receipt + custody proof for EXACTLY this
412
+ // testId (green N/N was already required above; QUARANTINE was already refused — no override
413
+ // lane converts a flaky probe).
414
+ if (overrides.redProofTestIds.has(id)) continue;
415
+ // verdict green — the red-proof chain.
416
+ const own = receipts.filter(({ r }) => r.testId === id);
417
+ if (own.length === 0) return { code: 1, reason: `no observed-red receipt for ${id} — a test never seen failing proves nothing about the fix. BEFORE folding a fix, run: node fold-completeness-run.mjs --red "${id}"` };
418
+ if (!own.some(({ i }) => i < sel.index)) return { code: 1, reason: `the observed-red receipt for ${id} was minted AFTER the loop's latest run — a post-hoc red proves nothing; run fold-completeness-run.mjs once more (a fresh run after the receipt)` };
419
+ const { file } = splitTestId(id);
420
+ // Custody eligibility (D5, codex R3): the anchor is the LATEST receipt on that FILE whose own
421
+ // testId is in the bound set AND which precedes the latest run — an unbound throwaway receipt,
422
+ // or one minted post-run, never re-attests a file.
423
+ const eligible = receipts.filter(({ r, i }) => i < sel.index && boundSet.has(r.testId) && splitTestId(r.testId).file === file);
424
+ const anchor = eligible[eligible.length - 1];
425
+ if (!anchor || t.fileHash == null || anchor.r.fileHash !== t.fileHash) {
426
+ return { code: 1, reason: `custody broken for ${id}: the test file ${file} no longer matches its last observed-red content — re-observe red after the edit (node fold-completeness-run.mjs --red "<the file's newest testId>"), or record a red-proof override if the red is genuinely unestablishable` };
427
+ }
428
+ }
256
429
  if (latest.coverage.uncoveredChanged.length > 0) return { code: 1, reason: `uncovered changed line(s) — changed code no test executed: ${latest.coverage.uncoveredChanged.map(renderUncovered).join(', ')}` };
257
430
  // v1 ships NO mutation (the mutation half was shelved): the shipped runner only ever writes the
258
431
  // reserved empty shape, so a record carrying ANY mutation data was not produced by this runner
@@ -275,12 +448,19 @@ export const decideCheck = (state) => {
275
448
 
276
449
  // ── rendering ─────────────────────────────────────────────────────────────────────────────────
277
450
 
278
- const runLine = (r) => {
279
- const tests = r.testIds.length ? r.testIds.map((t) => `${t.id}${t.resolvable ? (t.baselineGreen ? '✓' : ' red-baseline') : ' unresolvable'}`).join(', ') : '(no bound tests)';
451
+ const testLine = (r, t) => {
452
+ if (r.schema === 1) return `${t.id}${t.resolvable ? (t.baselineGreen ? '✓' : ' red-baseline') : ' unresolvable'}`;
453
+ const v = probeVerdict(t);
454
+ return v === 'green' ? `${t.id} ✓ ${t.greens}/${t.runs}` : `${t.id} ${v} (${t.greens}g/${t.reds}r/${t.timeouts}t of ${t.runs})`;
455
+ };
456
+
457
+ const runLine = (r, receipts) => {
458
+ const tests = r.testIds.length ? r.testIds.map((t) => testLine(r, t)).join(', ') : '(no bound tests)';
280
459
  const uncov = r.coverage.uncoveredChanged.length ? r.coverage.uncoveredChanged.map(renderUncovered).join(', ') : 'none';
281
460
  return [
282
461
  ` latest run — fingerprint ${r.fingerprint}`,
283
462
  ` bound testIds: ${tests}`,
463
+ ` red-probe receipts: ${receipts.length ? receipts.map((p) => `${p.testId} (${p.reds}/${p.runs} red @${p.fileHash.slice(0, 8)})`).join(', ') : 'none'}`,
284
464
  ` uncovered changed: ${uncov}`,
285
465
  ` unsupported: ${r.unsupported.length ? r.unsupported.join(', ') : 'none'} · out-of-domain: ${r.outOfDomain.length ? r.outOfDomain.join(', ') : 'none'}`,
286
466
  ` mutation: ${r.mutation.total} total / ${r.mutation.killed} killed / ${r.mutation.survived.length} survived / ${r.mutation.skipped} skipped`,
@@ -300,8 +480,9 @@ const formatHuman = (state, check) => {
300
480
  lines.push(` result ledger: ${state.resultsPath ?? '(unresolvable — no git dir)'} (${state.resultRecords.length} record(s)${state.resultMalformed ? `, ${state.resultMalformed} malformed — inspect the file` : ''})`);
301
481
  if (state.plans.length === 1) {
302
482
  const loop = state.plans[0].replace(/\.md$/, '');
303
- const runs = filterLoopResults(state.resultRecords, loop);
304
- if (runs.length > 0) lines.push(runLine(runs[runs.length - 1]));
483
+ const loopResults = filterLoopResults(state.resultRecords, loop);
484
+ const sel = latestRunRecord(loopResults); // kind-aware: never render a red-probe as "the run"
485
+ if (sel) lines.push(runLine(sel.record, loopResults.filter(isRedProbeRecord)));
305
486
  }
306
487
  lines.push(` check: ${check.code === 0 ? 'PASS' : 'FAIL'} — ${check.reason}`);
307
488
  return lines.join('\n');
@@ -316,14 +497,23 @@ Reads the result ledger the runner writes (<git dir>/${RESULTS_BASENAME}; AW_FOL
316
497
  resolves the effective ${ACTIVITY}.${SLOT} recipe, recomputes the canonical uncommitted-state
317
498
  fingerprint, and decides whether the in-flight plan-execution loop's changed code is pinned by tests.
318
499
 
319
- --status (default) → the human report: resolved recipe, plan-in-flight, the latest run summary, verdict.
500
+ --status (default) → the human report: resolved recipe, plan-in-flight, the latest run summary
501
+ (per-testId D4 verdicts + rerun counts), the loop's red-probe receipts, verdict.
320
502
  --check → the gate exit code. The normative exit contract lives in the tool header (the single home):
321
503
  exit 0 for solo / no plan in flight / a clean tree / not-a-git-tree / a CURRENT run whose fingerprint
322
- AND bound-testId set both match, with resolvable+green bound tests, 0 uncovered changed lines, 0
323
- changed unsupported source, and the reserved empty mutation shape; exit 1 otherwise (stale/missing
324
- run, an unresolvable/red bound test, an uncovered line, changed TS/JSX, any mutation data v1 ships
325
- no mutation, >1 plan, an unreadable/malformed ledger, or a detector failure). Out-of-domain changes
326
- are listed, never blocking.
504
+ AND bound-testId set both match, with per bound testId an N/N-green probe, an observed-red
505
+ receipt that PRECEDES the run, and content custody (run hash == the latest custody-eligible receipt
506
+ hash on that file); plus 0 uncovered changed lines, 0 changed unsupported source, every tampered
507
+ test-surface file covered by a recorded oracle-change override, and the reserved empty mutation
508
+ shape. A recorded red-proof override (review-ledger-write override) waives receipt + custody for
509
+ exactly its testId — never green-N/N, never QUARANTINE. exit 1 otherwise (stale/missing/older-runner
510
+ run, an unresolvable bound test, a QUARANTINED bound test — mixed/timeout, never converted, no
511
+ override lane — a red baseline, a missing/post-hoc receipt, broken custody, an uncovered tampered
512
+ file, an uncovered line, changed TS/JSX, any mutation data, >1 plan, an unreadable/malformed
513
+ ledger, or a detector failure). Out-of-domain changes are listed, never blocking. The honest
514
+ fold-time order: classify the fixable-bug with its testId → write the test →
515
+ fold-completeness-run.mjs --red "<testId>" observes red BEFORE the fix → fold the fix → the
516
+ normal run observes green → --check.
327
517
  --json → the structured state + decision.
328
518
 
329
519
  The runner is a SEPARATE tool (fold-completeness-run.mjs) — this read-only checker never imports it.
@@ -29,8 +29,9 @@
29
29
  import { readFileSync } from 'node:fs';
30
30
  import { dirname } from 'node:path';
31
31
  import { pathToFileURL } from 'node:url';
32
+ import { spawnSync } from 'node:child_process';
32
33
  import { writeContainedFileAtomic } from './atomic-write.mjs';
33
- import { computeTreeFingerprint, readReceipts, resolveReceiptsPath } from './review-state.mjs';
34
+ import { computeTreeFingerprint, plansInFlight, readReceipts, resolveReceiptsPath } from './review-state.mjs';
34
35
  import {
35
36
  REVIEW_CAP,
36
37
  SCHEMA_VERSION,
@@ -145,6 +146,50 @@ export const recordRound = (params, deps = {}) => {
145
146
  return appendRecord(ledgerPath, record, deps);
146
147
  };
147
148
 
149
+ // ── recordOverride (BUGFREE-1 / AD-047, D3/D7 — the loud, recorded waiver) ──────────────────────
150
+
151
+ // A read-only git-root probe for the in-flight tooth (the writer's only other git query is inside
152
+ // computeTreeFingerprint).
153
+ const gitRoot = (cwd) => {
154
+ const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, windowsHide: true });
155
+ if (r.error || r.status !== 0) return null;
156
+ return r.stdout.toString('utf8').replace(/\r?\n$/, '');
157
+ };
158
+
159
+ // recordOverride({ cwd, env, loop, activity, round, scope, files, testId, reason, timestamp }, deps)
160
+ // → { writtenPath, record }. Standard teeth: field validation via the schema (exact per-scope
161
+ // payloads), fail-closed ledger read, and the IN-FLIGHT tooth — an override is a waiver, minted only
162
+ // inside its live loop, never retro-recorded for a finished or foreign one. The fold-completeness
163
+ // gate matches on loop + payload, never on the (audit-only) fingerprint.
164
+ export const recordOverride = (params, deps = {}) => {
165
+ const { cwd = process.cwd(), env = process.env, loop, activity = DEFAULT_ACTIVITY, round, scope, files, testId, reason, timestamp } = params;
166
+ const ledgerPath = deps.ledgerPath ?? resolveLedgerPath(cwd, env);
167
+ if (ledgerPath == null) throw stop('cannot resolve the ledger path — not a git work tree and AW_REVIEW_LEDGER is unset');
168
+ if (!(Number.isInteger(round) && round >= 1)) throw stop(`round must be an integer >= 1 (got ${round})`);
169
+ // Exactly ONE in-flight plan, and it must be the named loop (codex+agy R5): a waiver minted while
170
+ // multiple plans are active could later cover a now-single loop — the ambiguity the rest of the
171
+ // family refuses everywhere (the single-plan rule).
172
+ const plans = deps.plansInFlight ? deps.plansInFlight() : plansInFlight(gitRoot(cwd) ?? cwd);
173
+ if (plans.length !== 1 || plans[0] !== `${loop}.md`) {
174
+ throw stop(`refusing to record an override for loop "${loop}": an override is minted only inside its SINGLE in-flight loop (in flight: ${plans.length ? plans.join(', ') : 'none'})`);
175
+ }
176
+ const fingerprint = deps.computeFingerprint ? deps.computeFingerprint(cwd) : computeTreeFingerprint(cwd);
177
+ const record = {
178
+ schema: SCHEMA_VERSION, loop, activity, kind: 'override', round, fingerprint, scope,
179
+ ...(files !== undefined ? { files } : {}), ...(testId !== undefined ? { testId } : {}),
180
+ reason, timestamp: timestamp ?? isoNow(),
181
+ };
182
+ const v = validateRecord(record);
183
+ if (!v.ok) throw stop(`refusing to record a malformed override: ${v.reason}`);
184
+ const { records, malformed, malformedReasons, readError } = readLedger(ledgerPath, deps.readFile);
185
+ if (readError) throw stop(`cannot read the existing ledger (${readError}) — refusing to append (fail closed)`);
186
+ if (malformed > 0) throw stop(`the existing ledger has ${malformed} malformed line(s) — refusing to append until they are fixed (fail closed): ${malformedReasons.join('; ')}`);
187
+ if (!roundSequenceIntact(filterLoopRecords(records, { activity, loop }))) {
188
+ throw stop(`refusing to record an override for loop "${loop}": its recorded round sequence is corrupt (not 1..n) — fix the ledger by hand first`);
189
+ }
190
+ return appendRecord(ledgerPath, record, deps);
191
+ };
192
+
148
193
  // ── recordTriage (the deadlock-breaker — no teeth, no receipt binding) ──────────────────────────
149
194
 
150
195
  // recordTriage({ cwd, env, loop, activity, round, classifications, timestamp }, deps) →
@@ -186,11 +231,12 @@ export const recordTriage = (params, deps = {}) => {
186
231
 
187
232
  // ── CLI (record / classify) ──────────────────────────────────────────────────────────────────────
188
233
 
189
- const HELP = `review-ledger-write — the review-round ledger WRITER (agent-workflow family, AD-045).
234
+ const HELP = `review-ledger-write — the review-round ledger WRITER (agent-workflow family, AD-045 + AD-047).
190
235
 
191
236
  Usage:
192
- node review-ledger-write.mjs record --json '<round-payload>' [--cwd <dir>]
193
- node review-ledger-write.mjs classify --json '<triage-payload>' [--cwd <dir>]
237
+ node review-ledger-write.mjs record --json '<round-payload>' [--cwd <dir>]
238
+ node review-ledger-write.mjs classify --json '<triage-payload>' [--cwd <dir>]
239
+ node review-ledger-write.mjs override --json '<override-payload>' [--cwd <dir>]
194
240
 
195
241
  record appends one review round. The JSON payload carries { loop, round, origins, backends,
196
242
  findings } (activity defaults to plan-execution; timestamp defaults to now). REFUSES while
@@ -200,6 +246,12 @@ classify appends one triage record. The JSON payload carries { loop, round, clas
200
246
  { findingKey, class, accepted, testId, note }). A fixable-bug REQUIRES a testId — the
201
247
  red→green test that pins the fold, formatted "<test-file>#<test-name-pattern>" (write it
202
248
  first); inherent-layer-residual / escalate may omit it. This is what permits the next round.
249
+ override appends one override record (schema 3) — the LOUD, durable waiver the fold-completeness
250
+ gate consumes. The JSON payload carries { loop, round, scope, reason } plus, per scope:
251
+ scope "oracle-change" → files[] (non-empty repo-relative paths whose tamper flag it lifts);
252
+ scope "red-proof" → testId (the exact bound testId whose observed-red receipt + custody it
253
+ waives — for a red that is GENUINELY unestablishable pre-fold, D7). REFUSES for a loop that
254
+ is not the in-flight plan. QUARANTINE (a flaky/timed-out probe) has NO override lane.
203
255
 
204
256
  The read-only checker is a SEPARATE tool: node review-ledger.mjs --check / --status / --json.
205
257
  Exit codes: 0 written; 1 a typed STOP (teeth / malformed / missing receipt / fs error); 2 usage.`;
@@ -238,14 +290,16 @@ export const main = (argv, ctx = {}) => {
238
290
  try {
239
291
  if (argv.includes('--help') || argv.includes('-h') || argv.length === 0) return { code: argv.length === 0 ? 2 : 0, stdout: HELP, stderr: '' };
240
292
  const sub = argv[0];
241
- if (sub !== 'record' && sub !== 'classify') throw usageFail(`unknown subcommand "${sub}" (expected: record | classify)`);
293
+ if (sub !== 'record' && sub !== 'classify' && sub !== 'override') throw usageFail(`unknown subcommand "${sub}" (expected: record | classify | override)`);
242
294
  const opts = parseArgs(argv.slice(1));
243
295
  const payload = parsePayload(opts.json);
244
296
  const cwd = opts.cwd ?? cwd0;
245
297
  const result =
246
298
  sub === 'record'
247
299
  ? recordRound({ cwd, env, ...payload })
248
- : recordTriage({ cwd, env, ...payload });
300
+ : sub === 'classify'
301
+ ? recordTriage({ cwd, env, ...payload })
302
+ : recordOverride({ cwd, env, ...payload });
249
303
  return { code: 0, stdout: `review-ledger-write: recorded a ${result.record.kind} for loop "${result.record.loop}" round ${result.record.round} → ${result.writtenPath}`, stderr: '' };
250
304
  } catch (err) {
251
305
  return { code: err.exitCode ?? 1, stdout: '', stderr: `review-ledger-write: ${err.message}` };