@sabaiway/agent-workflow-kit 1.38.0 → 1.40.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/tools/lcov.mjs ADDED
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env node
2
+ // lcov.mjs — a dependency-free LCOV parser for the fold-completeness runner's coverage.kind:"lcov"
3
+ // branch (BUGFREE-3, AD-049 — the language-independence contract). When a verification profile
4
+ // declares coverage.kind:"lcov", the consumer's OWN suite leaves an LCOV file at the declared path
5
+ // (the diff-cover / c8 --reporter=lcov pattern) and this module reads it into a per-file
6
+ // line→hits map, keyed by the SAME canonical absolute path the V8 coverage map uses — so the
7
+ // runner's ONE uncovered-changed loop consumes either source unchanged (computeUncoveredLines /
8
+ // effectiveCount stay the V8-only path, D10).
9
+ //
10
+ // FAIL-CLOSED posture (the whole reason the fold gate exists): a malformed record can never mark a
11
+ // line COVERED — a DA with a valid line but a non-integer HIT count reads the line UNCOVERED (hits 0,
12
+ // the gate fails), and a DA with no valid line number is skipped (unattributable — there is no line to
13
+ // flag), never a false green. An SF path that resolves OUTSIDE the repo — or does not resolve at all —
14
+ // is never trusted as coverage (a wrong file could otherwise read covered). (STATED residual, inherent
15
+ // to the LCOV data model: a CONFORMANT producer emits DA:N,0 for every uncovered executable line, so a
16
+ // changed executable line with NO DA entry reads non-executable — LCOV carries no separate
17
+ // executability signal; the V8 path, which reads an absent line as uncovered, differs by format.)
18
+ //
19
+ // Dependency-free, Node >= 18. No side effects on import.
20
+
21
+ import { join, resolve, isAbsolute, sep } from 'node:path';
22
+ import { realpathSync } from 'node:fs';
23
+
24
+ // Only these three LCOV records are consulted; every other record (FN/FNDA/BRDA/LF/LH/BRF/…) is
25
+ // ignored. LCOV groups per-file sections between an `SF:<path>` line and `end_of_record`.
26
+ const SF_RE = /^SF:(.*)$/;
27
+ const DA_RE = /^DA:(\d+),(.*)$/; // DA:<line>,<hits-field>… — the LINE must be an int; the hits field is
28
+ // parsed leniently below so a truncated/malformed hit count reads UNCOVERED (fail-closed), not omitted.
29
+ const END_RE = /^end_of_record\s*$/;
30
+
31
+ // parseLcov(text) → Map<sfString, Map<lineNumber, hits>>. Raw SF strings (NOT yet canonicalized).
32
+ // A line's hits is the MAX across duplicate DA entries within a section (some tracers emit per-test
33
+ // sections that also repeat lines). A DA whose line or hit count is not a base-10 integer is skipped
34
+ // (fail-closed: it can never mark a line covered). Lines with no DA entry never appear (a
35
+ // non-executable line — blank/comment — is simply absent, mirroring the V8 blank-line skip).
36
+ export const parseLcov = (text) => {
37
+ const out = new Map();
38
+ let current = null; // the Map<line,hits> for the SF section in progress
39
+ for (const rawLine of String(text).split('\n')) {
40
+ const line = rawLine.replace(/\r$/, '');
41
+ const sf = SF_RE.exec(line);
42
+ if (sf) {
43
+ const path = sf[1].trim();
44
+ if (path.length === 0) {
45
+ current = null;
46
+ continue;
47
+ }
48
+ current = out.get(path);
49
+ if (!current) {
50
+ current = new Map();
51
+ out.set(path, current);
52
+ }
53
+ continue;
54
+ }
55
+ if (END_RE.test(line)) {
56
+ current = null;
57
+ continue;
58
+ }
59
+ const da = DA_RE.exec(line);
60
+ if (da && current) {
61
+ const n = Number.parseInt(da[1], 10);
62
+ if (!Number.isInteger(n) || n < 1) continue; // no valid line number → unattributable, skip
63
+ const hitsField = da[2].split(',')[0]; // the hit count (a trailing ,checksum is ignored)
64
+ // FAIL-CLOSED, STRICT: the hit count must be ALL digits — a truncated `DA:42,`, a
65
+ // `DA:2,xyz`, a partial `DA:5,1abc`, or a fractional `DA:6,2.5` all read UNCOVERED (hits 0), never
66
+ // parseInt-coerced to a positive "covered" value. A covered sibling still wins (max).
67
+ const effHits = /^\d+$/.test(hitsField) ? Number.parseInt(hitsField, 10) : 0;
68
+ current.set(n, Math.max(current.get(n) ?? 0, effHits));
69
+ }
70
+ }
71
+ return out;
72
+ };
73
+
74
+ const defaultCanon = (p) => {
75
+ try {
76
+ return realpathSync(p);
77
+ } catch {
78
+ return p;
79
+ }
80
+ };
81
+
82
+ // Segment-safe containment ('/a' never contains '/ab'); correct at the filesystem root (mirrors
83
+ // fold-completeness-run.mjs containsPath). Defined locally so lcov.mjs never imports the runner (the
84
+ // runner imports THIS module).
85
+ const containsPath = (realRoot, realAbs) =>
86
+ realAbs === realRoot || realAbs.startsWith(realRoot.endsWith(sep) ? realRoot : realRoot + sep);
87
+
88
+ // lcovCoveredMap(text, rootTop, { canon }?) → Map<canonAbsKey, Map<line, hits>>. Each SF is resolved
89
+ // against rootTop (a relative / ./-prefixed / absolute-in-repo SF all normalize to the same key the
90
+ // V8 map uses), canon()-normalized, and KEPT only when it resolves strictly INSIDE the repo root; an
91
+ // outside-tree or unresolvable SF is DROPPED (never trusted as coverage). Duplicate SF sections are
92
+ // merged taking the max hits per line. canon is injectable for hermetic tests (default realpathSync).
93
+ export const lcovCoveredMap = (text, rootTop, deps = {}) => {
94
+ const canon = deps.canon ?? defaultCanon;
95
+ const raw = parseLcov(text);
96
+ const rootCanon = canon(rootTop);
97
+ const out = new Map();
98
+ for (const [sf, lineHits] of raw) {
99
+ const abs = isAbsolute(sf) ? sf : resolve(rootTop, sf);
100
+ const key = canon(abs);
101
+ if (!containsPath(rootCanon, key)) continue; // outside the repo (or unresolved elsewhere) → drop
102
+ let m = out.get(key);
103
+ if (!m) {
104
+ m = new Map();
105
+ out.set(key, m);
106
+ }
107
+ for (const [line, hits] of lineHits) m.set(line, Math.max(m.get(line) ?? 0, hits));
108
+ }
109
+ return out;
110
+ };
111
+
112
+ // uncoveredChangedFromLcov(coveredMap, key, changedLines) → the sorted changed lines that the LCOV
113
+ // records as EXECUTABLE-but-unexecuted (a DA entry with 0 hits). A file ABSENT from the map (no SF)
114
+ // is signalled by a null return → the caller records a file-level RED (line:null), exactly like the
115
+ // V8 "absent from coverage" case. A changed line with NO DA entry in a PRESENT file is non-executable
116
+ // (blank/comment) and never flagged — mirroring the V8 blank-line skip.
117
+ export const uncoveredChangedFromLcov = (coveredMap, key, changedLines) => {
118
+ const lineHits = coveredMap.get(key);
119
+ if (!lineHits) return null; // file absent from coverage → file-level RED (caller decides)
120
+ const uncovered = [];
121
+ for (const n of changedLines) {
122
+ const hits = lineHits.get(n);
123
+ if (hits === 0) uncovered.push(n); // executable, 0 hits → uncovered
124
+ // hits > 0 → covered; hits === undefined → non-executable line, skip
125
+ }
126
+ return [...new Set(uncovered)].sort((a, b) => a - b);
127
+ };
@@ -212,7 +212,7 @@ const reviewLoopAdvice = (slots, activity) =>
212
212
  ' • At the cap, classify every surviving blocking finding: fixable-bug (fold ONCE as a red→green test, re-review) / inherent-layer-residual (document + raise to an acceptance criterion) / escalate (the maintainer decides); a minor never forces triage.',
213
213
  ...(activity === 'plan-execution'
214
214
  ? [
215
- ' • The computed instrument for THIS loop: record each round + triage via review-ledger (record / classify); read the stop with review-ledger --status (its render replaces the hand-composed tally); gate the commit with review-ledger --check.',
215
+ ' • The computed instrument for THIS loop: run the FULL gate matrix with run-gates --record BEFORE recording a round (the green-baseline receipt the writer demands), then record each round + triage via review-ledger (record / classify); rounds, caps, and teeth are per SEGMENT (base = HEAD — the counter resets only at a gated commit); read the stop with review-ledger --status (its render replaces the hand-composed tally); gate the commit with review-ledger --check.',
216
216
  ]
217
217
  : []),
218
218
  ]
@@ -7,17 +7,23 @@
7
7
  // shared hardened atomic-write core (tools/atomic-write.mjs — exclusive-create tmp + rename, TOCTOU
8
8
  // re-check, symlink STOPs). The ledger lives in the git dir (uncommittable by construction).
9
9
  //
10
- // Two record kinds, one JSONL ledger:
10
+ // Record kinds, one JSONL ledger. Every verb evaluates the current SEGMENT — (activity, loop,
11
+ // base = `git rev-parse HEAD`), BUGFREE-2 / AD-048 D1: round numbering, the caps, and every tooth
12
+ // are per segment; a segment closes only through a gated commit, so a counter reset is earned:
11
13
  // recordRound — one review round: per-backend counts + verdict + degraded, finding-origin tally,
12
- // findings[]. Binds to the canonical tree fingerprint. THE TEETH (Decision 5):
13
- // REFUSES (typed STOP) while decideStop on the existing records is `triage-required`
14
- // (an unclassified surviving blocking finding at/after the cap), and refuses ANY
15
- // round beyond the hard-max ceiling unconditionally. Integrity binding (Decision 7):
14
+ // findings[]. Binds to the canonical tree fingerprint + base. THE TEETH (Decision 5
15
+ // + AD-048): REFUSES (typed STOP) while decideStop on the segment's records is
16
+ // `triage-required`; refuses ANY round beyond the per-segment hard-max ceiling
17
+ // unconditionally; refuses while a blocking finding of the segment's previous
18
+ // round VANISHED unclassified (D6 — no-repro-no-fold; `refuted` is the honest
19
+ // phantom lane); refuses while the changed source surface exceeds the diff cap
20
+ // without a recorded segment size-cap override (D4). Integrity binding (Decision 7):
16
21
  // each NON-degraded backend needs a grounded code receipt for the current tree, so a
17
22
  // round cannot be recorded for a tree no bridge reviewed.
18
- // recordTriage — the classification that BREAKS the deadlock: each surviving blocking finding
19
- // classified fixable-bug / inherent-layer-residual / escalate. No teeth (a triage is
20
- // exactly what lets the next round proceed), no receipt binding (it reviews nothing).
23
+ // recordTriage — the classification that BREAKS the deadlock: each surviving blocking finding of a
24
+ // SEGMENT round classified fixable-bug / inherent-layer-residual / escalate /
25
+ // refuted (v4). No teeth (a triage is exactly what lets the next round proceed),
26
+ // no receipt binding (it reviews nothing).
21
27
  //
22
28
  // HONEST residual (stated, accepted — like review-state's): the ledger attests a review occurred; it
23
29
  // does NOT prove the recorded COUNTS are truthful nor that a self-reported `degraded:true` is real.
@@ -31,21 +37,37 @@ import { dirname } from 'node:path';
31
37
  import { pathToFileURL } from 'node:url';
32
38
  import { spawnSync } from 'node:child_process';
33
39
  import { writeContainedFileAtomic } from './atomic-write.mjs';
34
- import { computeTreeFingerprint, plansInFlight, readReceipts, resolveReceiptsPath } from './review-state.mjs';
40
+ import { buildState, computeTreeFingerprint, plansInFlight, readReceipts, resolveReceiptsPath } from './review-state.mjs';
35
41
  import {
36
42
  REVIEW_CAP,
37
43
  SCHEMA_VERSION,
38
44
  resolveLedgerPath,
45
+ resolveBase,
39
46
  readLedger,
40
- filterLoopRecords,
47
+ filterSegmentRecords,
48
+ collectSizeCapLimit,
49
+ isQualityGreenGateRun,
41
50
  roundSequenceIntact,
42
51
  decideStop,
43
52
  validateRecord,
44
53
  } from './review-ledger.mjs';
54
+ // The NEUTRAL shared changed-surface computation (BUGFREE-2 / D4): the D4 diff-cap and the
55
+ // fold-completeness coverage gate consume ONE computation, so they can never drift. The writer
56
+ // imports the NEUTRAL module, never the runner (the sole-tree-toucher boundary, codex R2 — an
57
+ // import-split test pins that this file never imports fold-completeness-run.mjs).
58
+ import { computeChangedSurface, countCapLines, parsePositiveIntKnob } from './changed-surface.mjs';
45
59
 
46
60
  // The absolute WRITER ceiling (Decision 5): hard-max lives ONLY here — it is NOT a decideStop input.
47
- // Even a fully-classified resolved-residual loop cannot reach a round beyond this.
61
+ // Even a fully-classified resolved-residual loop cannot reach a round beyond this. Since AD-048 it
62
+ // is scoped per SEGMENT (D3 — value unchanged, scope corrected): round numbers restart when a gated
63
+ // commit moves base, so a multiphase plan records fully while round 4 within ONE segment stays
64
+ // refused — that ceiling is the point (the 2026-06-30 six-round incident class).
48
65
  export const HARD_MAX = 3;
66
+ // The D4 diff-size review cap (default 400 changed source lines; AW_REVIEW_DIFF_CAP overrides
67
+ // through the shared fail-closed positive-integer parser). Counted classes are pinned in
68
+ // changed-surface.mjs: assessable + unsupported SOURCE lines count; tests and out-of-domain never
69
+ // do; pure deletions are free.
70
+ export const DEFAULT_DIFF_CAP = 400;
49
71
  const DEFAULT_ACTIVITY = 'plan-execution';
50
72
 
51
73
  // A typed STOP — a deliberate refusal we surface (the teeth / a malformed record / a missing
@@ -79,6 +101,52 @@ const appendRecord = (ledgerPath, record, deps = {}) => {
79
101
  return { writtenPath: ledgerPath, record };
80
102
  };
81
103
 
104
+ // ── (g) record --from-receipts: draft the backends[] from the current-fingerprint receipts ────────
105
+ // (BUGFREE-3 / AD-049). The orchestrator hand-maintained each backend's { backend, verdict } beside
106
+ // the counts every round — drift-prone busywork. --from-receipts DRAFTS the backends[] instead: for
107
+ // each recipe-named backend it reads the review-state receipt status (verdict from the fresh grounded
108
+ // code receipt) and computes the counts from the orchestrator's OWN supplied findings; `origins` and
109
+ // `findings` stay explicit input. It NEVER invents a backend — a recipe-named backend with no fresh
110
+ // grounded receipt is a LOUD STOP (run its review, or supply it explicitly as a degraded backend).
111
+ // The assembled backends[] then rides the normal recordRound teeth (validateRound re-checks that
112
+ // findings-by-severity equals the drafted counts — true by construction).
113
+ export const draftBackendsFromReceipts = ({ state, findings = [], explicitBackends = [] }, deps = {}) => {
114
+ const stopFn = deps.stop ?? stop;
115
+ const explicitByName = new Map((Array.isArray(explicitBackends) ? explicitBackends : []).filter((b) => b && typeof b.backend === 'string').map((b) => [b.backend, b]));
116
+ const findingsArr = Array.isArray(findings) ? findings : [];
117
+ const countFor = (name) => {
118
+ const own = findingsArr.filter((f) => f && f.backend === name);
119
+ return {
120
+ blockers: own.filter((f) => f.severity === 'blocker').length,
121
+ majors: own.filter((f) => f.severity === 'major').length,
122
+ minors: own.filter((f) => f.severity === 'minor').length,
123
+ };
124
+ };
125
+ return state.requiredBackends.map((name) => {
126
+ const explicit = explicitByName.get(name);
127
+ if (explicit) {
128
+ // An explicit row is honored verbatim ONLY for a DEGRADED backend (a bridge the operator knows
129
+ // is down, which minted no receipt). A NON-degraded explicit row would BYPASS the
130
+ // receipt-derived verdict --from-receipts exists to compute (a stale hand-composed row silently
131
+ // winning) — a loud STOP, fail-closed (codex council R1). Drop --from-receipts to compose a
132
+ // non-degraded row by hand, or mark the backend degraded.
133
+ if (explicit.degraded !== true) {
134
+ throw stopFn(
135
+ `refusing --from-receipts: an explicit non-degraded backends[] row for ${name} would bypass the receipt-derived verdict this flag computes — mark it degraded (a bridge that is down), or drop --from-receipts and compose the round by hand`,
136
+ );
137
+ }
138
+ return explicit;
139
+ }
140
+ const status = state.backends.find((b) => b.backend === name);
141
+ if (!status || status.state !== 'current') {
142
+ throw stopFn(
143
+ `refusing --from-receipts: no fresh grounded code receipt for ${name} (state: ${status?.state ?? 'missing'}) — run its review wrapper (codex-review code / agy-review code --facts @f) first, or supply it explicitly as a degraded backend in the payload's backends[]`,
144
+ );
145
+ }
146
+ return { backend: name, degraded: false, ...countFor(name), verdict: status.verdict };
147
+ });
148
+ };
149
+
82
150
  // ── recordRound (the teeth + the integrity binding) ─────────────────────────────────────────────
83
151
 
84
152
  // recordRound({ cwd, env, loop, activity, round, origins, backends, findings, timestamp }, deps) →
@@ -90,11 +158,13 @@ export const recordRound = (params, deps = {}) => {
90
158
  if (ledgerPath == null) throw stop('cannot resolve the ledger path — not a git work tree and AW_REVIEW_LEDGER is unset');
91
159
  if (!(Number.isInteger(round) && round >= 1)) throw stop(`round must be an integer >= 1 (got ${round})`);
92
160
  // The hard-max ceiling: refuse ANY round beyond it, independent of triage state (Decision 5).
161
+ // Per SEGMENT since AD-048 (D3): the round number is the segment round number.
93
162
  if (round > HARD_MAX) {
94
- throw stop(`refusing to record round ${round}: beyond the hard-max ceiling of ${HARD_MAX} rounds — the loop must converge or the surviving finding must escalate, never another round`);
163
+ throw stop(`refusing to record round ${round}: beyond the hard-max ceiling of ${HARD_MAX} rounds within one segment — the segment must converge (or its surviving finding escalate) and ship through a gated commit; the counter resets only at the commit boundary, never by declaration`);
95
164
  }
96
165
 
97
166
  const fingerprint = deps.computeFingerprint ? deps.computeFingerprint(cwd) : computeTreeFingerprint(cwd);
167
+ const base = deps.resolveBase ? deps.resolveBase(cwd) : resolveBase(cwd);
98
168
 
99
169
  // The teeth: refuse a new round WHILE decideStop on the existing records is triage-required. Once
100
170
  // the surviving blocking finding is classified (recordTriage), decideStop is no longer
@@ -107,25 +177,79 @@ export const recordRound = (params, deps = {}) => {
107
177
  // the teeth OPEN (codex R1). Refuse to append until the ledger is sound.
108
178
  if (readError) throw stop(`cannot read the existing ledger (${readError}) — refusing to append (fail closed)`);
109
179
  if (malformed > 0) throw stop(`the existing ledger has ${malformed} malformed line(s) — refusing to append until they are fixed (fail closed): ${malformedReasons.join('; ')}`);
110
- const existingLoop = filterLoopRecords(records, { activity, loop });
111
- // Sequence integrity + sequentiality (codex R2+R3): the EXISTING rounds must already be exactly
112
- // 1..n (never trust a hand-corrupted [2]/[1,1]/[2,1] to compute "latest"), AND the incoming round
113
- // must be exactly the next (n+1). A duplicate, decreasing, or gapped round would let a fabricated
114
- // "later" round become the latest that decideStop reads, bypassing the "latest round" teeth.
115
- const priorRounds = existingLoop.filter((r) => r.kind === 'round').map((r) => r.round);
116
- if (!roundSequenceIntact(existingLoop)) {
117
- throw stop(`refusing to append to loop "${loop}": its recorded round sequence is corrupt (${priorRounds.join(',') || 'empty'}, not 1..n) — fix the ledger by hand before recording another round`);
180
+ // EVERY tooth below evaluates the current SEGMENT (activity, loop, base) — D1: records of earlier
181
+ // segments (other bases, or pre-v4 records with no base) are closed history; the field-proven
182
+ // 11-round / 4-base loop records completely while round 4 within ONE segment stays refused.
183
+ const segment = filterSegmentRecords(records, { activity, loop, base });
184
+ // Sequence integrity + sequentiality (codex R2+R3): the EXISTING segment rounds must already be
185
+ // exactly 1..n (never trust a hand-corrupted [2]/[1,1]/[2,1] to compute "latest"), AND the
186
+ // incoming round must be exactly the next (n+1). A duplicate, decreasing, or gapped round would
187
+ // let a fabricated "later" round become the latest that decideStop reads, bypassing the teeth.
188
+ const priorRounds = segment.filter((r) => r.kind === 'round').map((r) => r.round);
189
+ if (!roundSequenceIntact(segment)) {
190
+ throw stop(`refusing to append to loop "${loop}": its recorded round sequence for the current segment is corrupt (${priorRounds.join(',') || 'empty'}, not 1..n) — fix the ledger by hand before recording another round`);
118
191
  }
119
192
  const nextRound = priorRounds.length + 1;
120
193
  if (round !== nextRound) {
121
- throw stop(`refusing to record round ${round}: rounds must be sequential — the next round for loop "${loop}" is ${nextRound} (a duplicate, out-of-order, or gapped round would corrupt the crossover computation)`);
194
+ throw stop(`refusing to record round ${round}: rounds must be sequential within the segment — the next round for loop "${loop}" at the current base is ${nextRound} (a duplicate, out-of-order, or gapped round would corrupt the crossover computation)`);
122
195
  }
123
- const pre = decideStop(existingLoop, { cap: REVIEW_CAP });
196
+ const pre = decideStop(segment, { cap: REVIEW_CAP });
124
197
  if (pre.state === 'triage-required') {
125
198
  throw stop(`refusing to record a new round while triage is required — ${pre.reason}. Classify the surviving blocking finding(s) with the "classify" command first (a fixable-bug classification permits the fix round).`);
126
199
  }
127
200
 
128
- const record = { schema: SCHEMA_VERSION, loop, activity, kind: 'round', round, fingerprint, origins, backends, findings, timestamp: timestamp ?? isoNow() };
201
+ // D6 no-repro-no-fold: no blocking finding of the segment's previous round may VANISH
202
+ // unclassified. Present-again is fine (still live); classified is fine (fixable-bug folded with
203
+ // its red→green testId at the round it was folded — late binding restored; inherent-layer-residual
204
+ // documented; escalate handed over; refuted — the honest phantom lane, grounds mandatory). A
205
+ // silent disappearance is exactly the sycophancy hole this pillar closes. Minors stay exempt.
206
+ const previous = segment.find((r) => r.kind === 'round' && r.round === nextRound - 1);
207
+ if (previous) {
208
+ // "Present" means present AS BLOCKING: a blocker/major re-reported as a minor did not survive —
209
+ // it was softened, and the silent-soften lane is exactly the bypass D6 closes (codex R1).
210
+ const incoming = new Set(
211
+ Array.isArray(findings)
212
+ ? findings.filter((f) => f && (f.severity === 'blocker' || f.severity === 'major')).map((f) => f.findingKey)
213
+ : [],
214
+ );
215
+ // A classification CLEARS the vanish only when it actually resolves the finding's fate:
216
+ // fixable-bug (folded, testId bound), inherent-layer-residual (documented), refuted (grounds
217
+ // cited), or an ACCEPTED escalate — a pending escalate (accepted:false) is still undecided and
218
+ // must not disappear into a clean round (codex R1).
219
+ const clearsVanish = (c) =>
220
+ c.class === 'fixable-bug' || c.class === 'inherent-layer-residual' || c.class === 'refuted' || (c.class === 'escalate' && c.accepted === true);
221
+ const classified = new Set();
222
+ for (const t of segment) {
223
+ if (t.kind === 'triage' && t.round === previous.round) for (const c of t.classifications) if (clearsVanish(c)) classified.add(c.findingKey);
224
+ }
225
+ const vanished = [...new Set(
226
+ previous.findings
227
+ .filter((f) => f.severity === 'blocker' || f.severity === 'major')
228
+ .map((f) => f.findingKey)
229
+ .filter((k) => !incoming.has(k) && !classified.has(k)),
230
+ )];
231
+ if (vanished.length > 0) {
232
+ throw stop(`refusing to record round ${round}: blocking finding(s) of round ${previous.round} vanished without a classification: ${vanished.join(', ')} (D6 — no blocking finding disappears silently). Classify each with the "classify" command first: fixable-bug (bind the red→green testId), inherent-layer-residual, escalate, or refuted (a phantom finding — cite the refuting grounds in note).`);
233
+ }
234
+ }
235
+
236
+ // D4 — the diff-size cap over the ONE shared changed-surface computation (changed-surface.mjs):
237
+ // a round is refused while the changed source surface exceeds the cap, unless the SEGMENT carries
238
+ // a recorded size-cap override sanctioning at least the counted magnitude. Subtractive folds are
239
+ // free (new-side lines only); tests and out-of-domain files never count.
240
+ const cap = parsePositiveIntKnob(env, 'AW_REVIEW_DIFF_CAP', DEFAULT_DIFF_CAP, stop);
241
+ const counted = deps.countChangedLines ? deps.countChangedLines(cwd) : countCapLines(computeChangedSurface(gitRoot(cwd) ?? cwd));
242
+ if (counted > cap) {
243
+ const sanctioned = collectSizeCapLimit(records, { activity, loop, base });
244
+ if (sanctioned === null || counted > sanctioned) {
245
+ throw stop(
246
+ `refusing to record round ${round}: the changed source surface is ${counted} lines — over the ${cap}-line review cap (AW_REVIEW_DIFF_CAP)${sanctioned !== null ? ` and over the segment's recorded size-cap sanction of ${sanctioned}` : ''}. ` +
247
+ `Split the change into reviewable units (commit a converged part first), or record the LOUD waiver: node review-ledger-write.mjs override --json '{"loop":"${loop}","round":${round},"scope":"size-cap","sanctionedLines":${counted},"reason":"<why this surface must review as one unit>"}'`,
248
+ );
249
+ }
250
+ }
251
+
252
+ const record = { schema: SCHEMA_VERSION, loop, activity, kind: 'round', round, base, fingerprint, origins, backends, findings, timestamp: timestamp ?? isoNow() };
129
253
  const v = validateRecord(record);
130
254
  if (!v.ok) throw stop(`refusing to record a malformed round: ${v.reason}`);
131
255
 
@@ -143,6 +267,53 @@ export const recordRound = (params, deps = {}) => {
143
267
  }
144
268
  }
145
269
 
270
+ // D5 — the green-baseline tooth (armed at Step 2.3, AFTER run-gates --record exists — the
271
+ // bootstrap order): a round records only over a tree whose FULL declared non-process gate set
272
+ // was proven green by a recorded gate-run at the CURRENT fingerprint. "Gates ran before review"
273
+ // is now computed, never remembered. A `--only` subset is recorded honestly but never satisfies
274
+ // this (the R1 converged subset-bypass hole); a run whose tree changed under it attests no
275
+ // particular tree (codex R2); process-gate failures never block (the closed carve-out).
276
+ const gateRuns = segment.filter((r) => r.kind === 'gate-run');
277
+ if (!gateRuns.some((g) => g.fingerprint === fingerprint && isQualityGreenGateRun(g))) {
278
+ throw stop(`refusing to record round ${round}: no quality-green gate-run for the current tree in this segment (D5 — gates run before review is computed, not remembered). Run the FULL declared matrix with a recorded receipt first: node agent-workflow-kit/tools/run-gates.mjs --record (a --only subset never satisfies this; a run whose tree changed under it never counts).`);
279
+ }
280
+
281
+ return appendRecord(ledgerPath, record, deps);
282
+ };
283
+
284
+ // ── recordGateRun (BUGFREE-2 / AD-048, D5 — the green-baseline receipt run-gates --record mints) ──
285
+
286
+ // recordGateRun({ cwd, env, activity, declared, results, summary, fingerprintBefore,
287
+ // fingerprintAfter, timestamp }, deps) → { writtenPath, record }. The SOLE ledger entry point for
288
+ // run-gates (`--record` DELEGATES here — the runner never opens the ledger file itself; an
289
+ // import/structure pin holds the boundary). The loop is DERIVED from the single in-flight plan
290
+ // (the recordOverride precedent — a gate-run is minted only inside its live loop, never
291
+ // retro-recorded); the record carries the segment frame and NO round number (per-kind frame, D5).
292
+ // A red run records honestly (telemetry fuel — consecutive red gate-runs are the revert-first
293
+ // visibility); quality-green is judged at read time, never stored.
294
+ export const recordGateRun = (params, deps = {}) => {
295
+ const { cwd = process.cwd(), env = process.env, activity = DEFAULT_ACTIVITY, declared, results, summary, fingerprintBefore, fingerprintAfter, timestamp } = params;
296
+ const ledgerPath = deps.ledgerPath ?? resolveLedgerPath(cwd, env);
297
+ if (ledgerPath == null) throw stop('cannot resolve the ledger path — not a git work tree and AW_REVIEW_LEDGER is unset');
298
+ const plans = deps.plansInFlight ? deps.plansInFlight() : plansInFlight(gitRoot(cwd) ?? cwd);
299
+ if (plans.length !== 1) {
300
+ throw stop(`refusing to record a gate-run: it is minted only inside the SINGLE in-flight loop (in flight: ${plans.length ? plans.join(', ') : 'none'})`);
301
+ }
302
+ const loop = plans[0].replace(/\.md$/, '');
303
+ const base = deps.resolveBase ? deps.resolveBase(cwd) : resolveBase(cwd);
304
+ const record = {
305
+ schema: SCHEMA_VERSION, loop, activity, kind: 'gate-run', base,
306
+ fingerprint: fingerprintBefore, fingerprintAfter, declared, results, summary,
307
+ timestamp: timestamp ?? isoNow(),
308
+ };
309
+ const v = validateRecord(record);
310
+ if (!v.ok) throw stop(`refusing to record a malformed gate-run: ${v.reason}`);
311
+ const { records, malformed, malformedReasons, readError } = readLedger(ledgerPath, deps.readFile);
312
+ if (readError) throw stop(`cannot read the existing ledger (${readError}) — refusing to append (fail closed)`);
313
+ if (malformed > 0) throw stop(`the existing ledger has ${malformed} malformed line(s) — refusing to append until they are fixed (fail closed): ${malformedReasons.join('; ')}`);
314
+ if (!roundSequenceIntact(filterSegmentRecords(records, { activity, loop, base }))) {
315
+ throw stop(`refusing to record a gate-run for loop "${loop}": its recorded round sequence for the current segment is corrupt (not 1..n) — fix the ledger by hand first`);
316
+ }
146
317
  return appendRecord(ledgerPath, record, deps);
147
318
  };
148
319
 
@@ -162,7 +333,7 @@ const gitRoot = (cwd) => {
162
333
  // inside its live loop, never retro-recorded for a finished or foreign one. The fold-completeness
163
334
  // gate matches on loop + payload, never on the (audit-only) fingerprint.
164
335
  export const recordOverride = (params, deps = {}) => {
165
- const { cwd = process.cwd(), env = process.env, loop, activity = DEFAULT_ACTIVITY, round, scope, files, testId, reason, timestamp } = params;
336
+ const { cwd = process.cwd(), env = process.env, loop, activity = DEFAULT_ACTIVITY, round, scope, files, testId, sanctionedLines, reason, timestamp } = params;
166
337
  const ledgerPath = deps.ledgerPath ?? resolveLedgerPath(cwd, env);
167
338
  if (ledgerPath == null) throw stop('cannot resolve the ledger path — not a git work tree and AW_REVIEW_LEDGER is unset');
168
339
  if (!(Number.isInteger(round) && round >= 1)) throw stop(`round must be an integer >= 1 (got ${round})`);
@@ -174,9 +345,11 @@ export const recordOverride = (params, deps = {}) => {
174
345
  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
346
  }
176
347
  const fingerprint = deps.computeFingerprint ? deps.computeFingerprint(cwd) : computeTreeFingerprint(cwd);
348
+ const base = deps.resolveBase ? deps.resolveBase(cwd) : resolveBase(cwd);
177
349
  const record = {
178
- schema: SCHEMA_VERSION, loop, activity, kind: 'override', round, fingerprint, scope,
350
+ schema: SCHEMA_VERSION, loop, activity, kind: 'override', round, base, fingerprint, scope,
179
351
  ...(files !== undefined ? { files } : {}), ...(testId !== undefined ? { testId } : {}),
352
+ ...(sanctionedLines !== undefined ? { sanctionedLines } : {}),
180
353
  reason, timestamp: timestamp ?? isoNow(),
181
354
  };
182
355
  const v = validateRecord(record);
@@ -184,8 +357,8 @@ export const recordOverride = (params, deps = {}) => {
184
357
  const { records, malformed, malformedReasons, readError } = readLedger(ledgerPath, deps.readFile);
185
358
  if (readError) throw stop(`cannot read the existing ledger (${readError}) — refusing to append (fail closed)`);
186
359
  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`);
360
+ if (!roundSequenceIntact(filterSegmentRecords(records, { activity, loop, base }))) {
361
+ throw stop(`refusing to record an override for loop "${loop}": its recorded round sequence for the current segment is corrupt (not 1..n) — fix the ledger by hand first`);
189
362
  }
190
363
  return appendRecord(ledgerPath, record, deps);
191
364
  };
@@ -200,27 +373,31 @@ export const recordTriage = (params, deps = {}) => {
200
373
  if (ledgerPath == null) throw stop('cannot resolve the ledger path — not a git work tree and AW_REVIEW_LEDGER is unset');
201
374
  if (!(Number.isInteger(round) && round >= 1)) throw stop(`round must be an integer >= 1 (got ${round})`);
202
375
  const fingerprint = deps.computeFingerprint ? deps.computeFingerprint(cwd) : computeTreeFingerprint(cwd);
376
+ const base = deps.resolveBase ? deps.resolveBase(cwd) : resolveBase(cwd);
203
377
  // Normalize each classification: an absent testId → null, an absent note → '' — an absent optional
204
378
  // field is FILLED, never rejected as malformed (agy R3). Under schema v2 (M2/AD-046) a fixable-bug
205
379
  // normalized to a null testId then FAILS validateRecord below (a typed STOP naming the rule + the
206
- // red-test-first fix) — the test-per-fold binding rides the existing validate path. A non-array is
207
- // left as-is for validateRecord to reject with a typed STOP (never a raw .map TypeError).
380
+ // red-test-first fix) — the test-per-fold binding rides the existing validate path; a `refuted`
381
+ // classification normalized to an empty note fails the same way (D6 the grounds are mandatory).
382
+ // A non-array is left as-is for validateRecord to reject with a typed STOP (never a raw .map TypeError).
208
383
  const normalized = Array.isArray(classifications) ? classifications.map((c) => ({ ...c, testId: c?.testId ?? null, note: c?.note ?? '' })) : classifications;
209
- const record = { schema: SCHEMA_VERSION, loop, activity, kind: 'triage', round, fingerprint, classifications: normalized, timestamp: timestamp ?? isoNow() };
384
+ const record = { schema: SCHEMA_VERSION, loop, activity, kind: 'triage', round, base, fingerprint, classifications: normalized, timestamp: timestamp ?? isoNow() };
210
385
  const v = validateRecord(record);
211
386
  if (!v.ok) throw stop(`refusing to record a malformed triage: ${v.reason}`);
212
387
 
213
- // Bind the triage to a REAL round: the referenced round must exist and every classified findingKey
214
- // must be a surviving blocking finding (blocker or major) of THAT round — a classification for a
215
- // nonexistent/future round, or for a key the round never raised, must not satisfy resolved-residual
216
- // downstream (codex R1). Fail CLOSED on an unreadable / malformed ledger.
388
+ // Bind the triage to a REAL round of the current SEGMENT: the referenced round must exist there
389
+ // and every classified findingKey must be a surviving blocking finding (blocker or major) of THAT
390
+ // round — a classification for a nonexistent/future/other-segment round, or for a key the round
391
+ // never raised, must not satisfy resolved-residual downstream (codex R1; D1 a committed
392
+ // segment's rounds are closed, the cross-segment lane is the D7 red-proof override). Fail CLOSED
393
+ // on an unreadable / malformed ledger.
217
394
  const { records, malformed, malformedReasons, readError } = readLedger(ledgerPath, deps.readFile);
218
395
  if (readError) throw stop(`cannot read the existing ledger (${readError}) — refusing to append (fail closed)`);
219
396
  if (malformed > 0) throw stop(`the existing ledger has ${malformed} malformed line(s) — refusing to append until they are fixed (fail closed): ${malformedReasons.join('; ')}`);
220
- const loopRecords = filterLoopRecords(records, { activity, loop });
221
- if (!roundSequenceIntact(loopRecords)) throw stop(`refusing to classify loop "${loop}": its recorded round sequence is corrupt (not 1..n) — fix the ledger by hand first`);
222
- const targetRound = loopRecords.find((r) => r.kind === 'round' && r.round === round);
223
- if (!targetRound) throw stop(`refusing to classify round ${round} of loop "${loop}": no such recorded round — classify a round that exists`);
397
+ const segment = filterSegmentRecords(records, { activity, loop, base });
398
+ if (!roundSequenceIntact(segment)) throw stop(`refusing to classify loop "${loop}": its recorded round sequence for the current segment is corrupt (not 1..n) — fix the ledger by hand first`);
399
+ const targetRound = segment.find((r) => r.kind === 'round' && r.round === round);
400
+ if (!targetRound) throw stop(`refusing to classify round ${round} of loop "${loop}": no such recorded round in the current segment — classify a round that exists at the current base`);
224
401
  const survivingKeys = new Set(targetRound.findings.filter((f) => f.severity === 'blocker' || f.severity === 'major').map((f) => f.findingKey));
225
402
  for (const c of classifications) {
226
403
  if (!survivingKeys.has(c.findingKey)) throw stop(`refusing to classify "${c.findingKey}": it is not a surviving blocking finding of round ${round} (classify only that round's blockers/majors)`);
@@ -231,33 +408,50 @@ export const recordTriage = (params, deps = {}) => {
231
408
 
232
409
  // ── CLI (record / classify) ──────────────────────────────────────────────────────────────────────
233
410
 
234
- const HELP = `review-ledger-write — the review-round ledger WRITER (agent-workflow family, AD-045 + AD-047).
411
+ const HELP = `review-ledger-write — the review-round ledger WRITER (agent-workflow family, AD-045 + AD-047 + AD-048).
235
412
 
236
413
  Usage:
237
- node review-ledger-write.mjs record --json '<round-payload>' [--cwd <dir>]
414
+ node review-ledger-write.mjs record --json '<round-payload>' [--from-receipts] [--cwd <dir>]
238
415
  node review-ledger-write.mjs classify --json '<triage-payload>' [--cwd <dir>]
239
416
  node review-ledger-write.mjs override --json '<override-payload>' [--cwd <dir>]
240
417
 
418
+ Every verb operates on the current SEGMENT — (loop, base = git rev-parse HEAD): round numbers,
419
+ caps, and teeth reset only when a gated commit moves base (schema 4; records carry base).
420
+
241
421
  record appends one review round. The JSON payload carries { loop, round, origins, backends,
242
- findings } (activity defaults to plan-execution; timestamp defaults to now). REFUSES while
243
- triage is required, beyond the hard-max ceiling of ${HARD_MAX} rounds, or when a non-degraded
244
- backend lacks a grounded code receipt for the current tree.
422
+ findings } (activity defaults to plan-execution; timestamp defaults to now). With
423
+ --from-receipts the backends[] is DRAFTED from the current-fingerprint grounded code
424
+ receipts (verdict per backend) with counts computed from the supplied findings — origins /
425
+ findings stay explicit; a recipe-named backend with no receipt is a LOUD stop (run its
426
+ review, or supply it explicitly as a degraded backend). REFUSES while
427
+ triage is required; beyond the hard-max ceiling of ${HARD_MAX} rounds within one segment;
428
+ while a blocking finding of the segment's previous round VANISHED unclassified (D6 —
429
+ classify it first, "refuted" is the honest phantom lane); while the changed source surface
430
+ exceeds the ${DEFAULT_DIFF_CAP}-line diff cap (AW_REVIEW_DIFF_CAP) without a recorded
431
+ segment size-cap override (D4); while the segment lacks a quality-green gate-run at the
432
+ current fingerprint (D5 — run the FULL matrix first: run-gates.mjs --record; a --only
433
+ subset or a tree-changed run never satisfies; red PROCESS gates never block); or when a
434
+ non-degraded backend lacks a grounded code receipt for the current tree.
245
435
  classify appends one triage record. The JSON payload carries { loop, round, classifications } (each
246
436
  { findingKey, class, accepted, testId, note }). A fixable-bug REQUIRES a testId — the
247
437
  red→green test that pins the fold, formatted "<test-file>#<test-name-pattern>" (write it
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:
438
+ first); refuted REQUIRES a non-empty note citing the refuting grounds;
439
+ inherent-layer-residual / escalate may omit the testId. This is what permits the next round.
440
+ override appends one override record the LOUD, durable waiver the gates consume. The JSON payload
441
+ carries { loop, round, scope, reason } plus, per scope:
251
442
  scope "oracle-change" → files[] (non-empty repo-relative paths whose tamper flag it lifts);
252
443
  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.
444
+ waives — for a red that is GENUINELY unestablishable pre-fold, D7);
445
+ scope "size-cap" sanctionedLines (the exact changed-surface magnitude the waiver
446
+ sanctions, SEGMENT-scoped — it dies at the next commit, D4).
447
+ REFUSES for a loop that is not the in-flight plan. QUARANTINE (a flaky/timed-out probe)
448
+ has NO override lane.
255
449
 
256
- The read-only checker is a SEPARATE tool: node review-ledger.mjs --check / --status / --json.
450
+ The read-only checker is a SEPARATE tool: node review-ledger.mjs --check / --status / --json / --telemetry.
257
451
  Exit codes: 0 written; 1 a typed STOP (teeth / malformed / missing receipt / fs error); 2 usage.`;
258
452
 
259
453
  const parseArgs = (argv) => {
260
- const opts = { cwd: undefined, json: undefined };
454
+ const opts = { cwd: undefined, json: undefined, fromReceipts: false };
261
455
  for (let i = 0; i < argv.length; i += 1) {
262
456
  const a = argv[i];
263
457
  if (a === '--cwd') {
@@ -268,6 +462,8 @@ const parseArgs = (argv) => {
268
462
  opts.json = argv[i + 1];
269
463
  if (opts.json === undefined) throw usageFail('--json needs a JSON payload');
270
464
  i += 1;
465
+ } else if (a === '--from-receipts') {
466
+ opts.fromReceipts = true;
271
467
  } else {
272
468
  throw usageFail(`unknown argument: ${a}`);
273
469
  }
@@ -292,8 +488,15 @@ export const main = (argv, ctx = {}) => {
292
488
  const sub = argv[0];
293
489
  if (sub !== 'record' && sub !== 'classify' && sub !== 'override') throw usageFail(`unknown subcommand "${sub}" (expected: record | classify | override)`);
294
490
  const opts = parseArgs(argv.slice(1));
491
+ if (opts.fromReceipts && sub !== 'record') throw usageFail('--from-receipts applies only to `record`');
295
492
  const payload = parsePayload(opts.json);
296
493
  const cwd = opts.cwd ?? cwd0;
494
+ if (opts.fromReceipts) {
495
+ // Draft backends[] from the current-fingerprint receipts + the supplied findings; origins /
496
+ // findings stay explicit. The drafted array then rides the normal recordRound teeth.
497
+ const state = buildState({ cwd, env });
498
+ payload.backends = draftBackendsFromReceipts({ state, findings: payload.findings, explicitBackends: payload.backends ?? [] });
499
+ }
297
500
  const result =
298
501
  sub === 'record'
299
502
  ? recordRound({ cwd, env, ...payload })