@sabaiway/agent-workflow-kit 1.37.1 → 1.39.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.
@@ -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.
@@ -29,22 +35,39 @@
29
35
  import { readFileSync } from 'node:fs';
30
36
  import { dirname } from 'node:path';
31
37
  import { pathToFileURL } from 'node:url';
38
+ import { spawnSync } from 'node:child_process';
32
39
  import { writeContainedFileAtomic } from './atomic-write.mjs';
33
- import { computeTreeFingerprint, readReceipts, resolveReceiptsPath } from './review-state.mjs';
40
+ import { computeTreeFingerprint, plansInFlight, readReceipts, resolveReceiptsPath } from './review-state.mjs';
34
41
  import {
35
42
  REVIEW_CAP,
36
43
  SCHEMA_VERSION,
37
44
  resolveLedgerPath,
45
+ resolveBase,
38
46
  readLedger,
39
- filterLoopRecords,
47
+ filterSegmentRecords,
48
+ collectSizeCapLimit,
49
+ isQualityGreenGateRun,
40
50
  roundSequenceIntact,
41
51
  decideStop,
42
52
  validateRecord,
43
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';
44
59
 
45
60
  // The absolute WRITER ceiling (Decision 5): hard-max lives ONLY here — it is NOT a decideStop input.
46
- // 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).
47
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;
48
71
  const DEFAULT_ACTIVITY = 'plan-execution';
49
72
 
50
73
  // A typed STOP — a deliberate refusal we surface (the teeth / a malformed record / a missing
@@ -89,11 +112,13 @@ export const recordRound = (params, deps = {}) => {
89
112
  if (ledgerPath == null) throw stop('cannot resolve the ledger path — not a git work tree and AW_REVIEW_LEDGER is unset');
90
113
  if (!(Number.isInteger(round) && round >= 1)) throw stop(`round must be an integer >= 1 (got ${round})`);
91
114
  // The hard-max ceiling: refuse ANY round beyond it, independent of triage state (Decision 5).
115
+ // Per SEGMENT since AD-048 (D3): the round number is the segment round number.
92
116
  if (round > HARD_MAX) {
93
- 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`);
117
+ 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`);
94
118
  }
95
119
 
96
120
  const fingerprint = deps.computeFingerprint ? deps.computeFingerprint(cwd) : computeTreeFingerprint(cwd);
121
+ const base = deps.resolveBase ? deps.resolveBase(cwd) : resolveBase(cwd);
97
122
 
98
123
  // The teeth: refuse a new round WHILE decideStop on the existing records is triage-required. Once
99
124
  // the surviving blocking finding is classified (recordTriage), decideStop is no longer
@@ -106,25 +131,79 @@ export const recordRound = (params, deps = {}) => {
106
131
  // the teeth OPEN (codex R1). Refuse to append until the ledger is sound.
107
132
  if (readError) throw stop(`cannot read the existing ledger (${readError}) — refusing to append (fail closed)`);
108
133
  if (malformed > 0) throw stop(`the existing ledger has ${malformed} malformed line(s) — refusing to append until they are fixed (fail closed): ${malformedReasons.join('; ')}`);
109
- const existingLoop = filterLoopRecords(records, { activity, loop });
110
- // Sequence integrity + sequentiality (codex R2+R3): the EXISTING rounds must already be exactly
111
- // 1..n (never trust a hand-corrupted [2]/[1,1]/[2,1] to compute "latest"), AND the incoming round
112
- // must be exactly the next (n+1). A duplicate, decreasing, or gapped round would let a fabricated
113
- // "later" round become the latest that decideStop reads, bypassing the "latest round" teeth.
114
- const priorRounds = existingLoop.filter((r) => r.kind === 'round').map((r) => r.round);
115
- if (!roundSequenceIntact(existingLoop)) {
116
- 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`);
134
+ // EVERY tooth below evaluates the current SEGMENT (activity, loop, base) — D1: records of earlier
135
+ // segments (other bases, or pre-v4 records with no base) are closed history; the field-proven
136
+ // 11-round / 4-base loop records completely while round 4 within ONE segment stays refused.
137
+ const segment = filterSegmentRecords(records, { activity, loop, base });
138
+ // Sequence integrity + sequentiality (codex R2+R3): the EXISTING segment rounds must already be
139
+ // exactly 1..n (never trust a hand-corrupted [2]/[1,1]/[2,1] to compute "latest"), AND the
140
+ // incoming round must be exactly the next (n+1). A duplicate, decreasing, or gapped round would
141
+ // let a fabricated "later" round become the latest that decideStop reads, bypassing the teeth.
142
+ const priorRounds = segment.filter((r) => r.kind === 'round').map((r) => r.round);
143
+ if (!roundSequenceIntact(segment)) {
144
+ 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`);
117
145
  }
118
146
  const nextRound = priorRounds.length + 1;
119
147
  if (round !== nextRound) {
120
- 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)`);
148
+ 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)`);
121
149
  }
122
- const pre = decideStop(existingLoop, { cap: REVIEW_CAP });
150
+ const pre = decideStop(segment, { cap: REVIEW_CAP });
123
151
  if (pre.state === 'triage-required') {
124
152
  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).`);
125
153
  }
126
154
 
127
- const record = { schema: SCHEMA_VERSION, loop, activity, kind: 'round', round, fingerprint, origins, backends, findings, timestamp: timestamp ?? isoNow() };
155
+ // D6 no-repro-no-fold: no blocking finding of the segment's previous round may VANISH
156
+ // unclassified. Present-again is fine (still live); classified is fine (fixable-bug folded with
157
+ // its red→green testId at the round it was folded — late binding restored; inherent-layer-residual
158
+ // documented; escalate handed over; refuted — the honest phantom lane, grounds mandatory). A
159
+ // silent disappearance is exactly the sycophancy hole this pillar closes. Minors stay exempt.
160
+ const previous = segment.find((r) => r.kind === 'round' && r.round === nextRound - 1);
161
+ if (previous) {
162
+ // "Present" means present AS BLOCKING: a blocker/major re-reported as a minor did not survive —
163
+ // it was softened, and the silent-soften lane is exactly the bypass D6 closes (codex R1).
164
+ const incoming = new Set(
165
+ Array.isArray(findings)
166
+ ? findings.filter((f) => f && (f.severity === 'blocker' || f.severity === 'major')).map((f) => f.findingKey)
167
+ : [],
168
+ );
169
+ // A classification CLEARS the vanish only when it actually resolves the finding's fate:
170
+ // fixable-bug (folded, testId bound), inherent-layer-residual (documented), refuted (grounds
171
+ // cited), or an ACCEPTED escalate — a pending escalate (accepted:false) is still undecided and
172
+ // must not disappear into a clean round (codex R1).
173
+ const clearsVanish = (c) =>
174
+ c.class === 'fixable-bug' || c.class === 'inherent-layer-residual' || c.class === 'refuted' || (c.class === 'escalate' && c.accepted === true);
175
+ const classified = new Set();
176
+ for (const t of segment) {
177
+ if (t.kind === 'triage' && t.round === previous.round) for (const c of t.classifications) if (clearsVanish(c)) classified.add(c.findingKey);
178
+ }
179
+ const vanished = [...new Set(
180
+ previous.findings
181
+ .filter((f) => f.severity === 'blocker' || f.severity === 'major')
182
+ .map((f) => f.findingKey)
183
+ .filter((k) => !incoming.has(k) && !classified.has(k)),
184
+ )];
185
+ if (vanished.length > 0) {
186
+ 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).`);
187
+ }
188
+ }
189
+
190
+ // D4 — the diff-size cap over the ONE shared changed-surface computation (changed-surface.mjs):
191
+ // a round is refused while the changed source surface exceeds the cap, unless the SEGMENT carries
192
+ // a recorded size-cap override sanctioning at least the counted magnitude. Subtractive folds are
193
+ // free (new-side lines only); tests and out-of-domain files never count.
194
+ const cap = parsePositiveIntKnob(env, 'AW_REVIEW_DIFF_CAP', DEFAULT_DIFF_CAP, stop);
195
+ const counted = deps.countChangedLines ? deps.countChangedLines(cwd) : countCapLines(computeChangedSurface(gitRoot(cwd) ?? cwd));
196
+ if (counted > cap) {
197
+ const sanctioned = collectSizeCapLimit(records, { activity, loop, base });
198
+ if (sanctioned === null || counted > sanctioned) {
199
+ throw stop(
200
+ `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}` : ''}. ` +
201
+ `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>"}'`,
202
+ );
203
+ }
204
+ }
205
+
206
+ const record = { schema: SCHEMA_VERSION, loop, activity, kind: 'round', round, base, fingerprint, origins, backends, findings, timestamp: timestamp ?? isoNow() };
128
207
  const v = validateRecord(record);
129
208
  if (!v.ok) throw stop(`refusing to record a malformed round: ${v.reason}`);
130
209
 
@@ -142,6 +221,99 @@ export const recordRound = (params, deps = {}) => {
142
221
  }
143
222
  }
144
223
 
224
+ // D5 — the green-baseline tooth (armed at Step 2.3, AFTER run-gates --record exists — the
225
+ // bootstrap order): a round records only over a tree whose FULL declared non-process gate set
226
+ // was proven green by a recorded gate-run at the CURRENT fingerprint. "Gates ran before review"
227
+ // is now computed, never remembered. A `--only` subset is recorded honestly but never satisfies
228
+ // this (the R1 converged subset-bypass hole); a run whose tree changed under it attests no
229
+ // particular tree (codex R2); process-gate failures never block (the closed carve-out).
230
+ const gateRuns = segment.filter((r) => r.kind === 'gate-run');
231
+ if (!gateRuns.some((g) => g.fingerprint === fingerprint && isQualityGreenGateRun(g))) {
232
+ 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).`);
233
+ }
234
+
235
+ return appendRecord(ledgerPath, record, deps);
236
+ };
237
+
238
+ // ── recordGateRun (BUGFREE-2 / AD-048, D5 — the green-baseline receipt run-gates --record mints) ──
239
+
240
+ // recordGateRun({ cwd, env, activity, declared, results, summary, fingerprintBefore,
241
+ // fingerprintAfter, timestamp }, deps) → { writtenPath, record }. The SOLE ledger entry point for
242
+ // run-gates (`--record` DELEGATES here — the runner never opens the ledger file itself; an
243
+ // import/structure pin holds the boundary). The loop is DERIVED from the single in-flight plan
244
+ // (the recordOverride precedent — a gate-run is minted only inside its live loop, never
245
+ // retro-recorded); the record carries the segment frame and NO round number (per-kind frame, D5).
246
+ // A red run records honestly (telemetry fuel — consecutive red gate-runs are the revert-first
247
+ // visibility); quality-green is judged at read time, never stored.
248
+ export const recordGateRun = (params, deps = {}) => {
249
+ const { cwd = process.cwd(), env = process.env, activity = DEFAULT_ACTIVITY, declared, results, summary, fingerprintBefore, fingerprintAfter, timestamp } = params;
250
+ const ledgerPath = deps.ledgerPath ?? resolveLedgerPath(cwd, env);
251
+ if (ledgerPath == null) throw stop('cannot resolve the ledger path — not a git work tree and AW_REVIEW_LEDGER is unset');
252
+ const plans = deps.plansInFlight ? deps.plansInFlight() : plansInFlight(gitRoot(cwd) ?? cwd);
253
+ if (plans.length !== 1) {
254
+ 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'})`);
255
+ }
256
+ const loop = plans[0].replace(/\.md$/, '');
257
+ const base = deps.resolveBase ? deps.resolveBase(cwd) : resolveBase(cwd);
258
+ const record = {
259
+ schema: SCHEMA_VERSION, loop, activity, kind: 'gate-run', base,
260
+ fingerprint: fingerprintBefore, fingerprintAfter, declared, results, summary,
261
+ timestamp: timestamp ?? isoNow(),
262
+ };
263
+ const v = validateRecord(record);
264
+ if (!v.ok) throw stop(`refusing to record a malformed gate-run: ${v.reason}`);
265
+ const { records, malformed, malformedReasons, readError } = readLedger(ledgerPath, deps.readFile);
266
+ if (readError) throw stop(`cannot read the existing ledger (${readError}) — refusing to append (fail closed)`);
267
+ if (malformed > 0) throw stop(`the existing ledger has ${malformed} malformed line(s) — refusing to append until they are fixed (fail closed): ${malformedReasons.join('; ')}`);
268
+ if (!roundSequenceIntact(filterSegmentRecords(records, { activity, loop, base }))) {
269
+ 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`);
270
+ }
271
+ return appendRecord(ledgerPath, record, deps);
272
+ };
273
+
274
+ // ── recordOverride (BUGFREE-1 / AD-047, D3/D7 — the loud, recorded waiver) ──────────────────────
275
+
276
+ // A read-only git-root probe for the in-flight tooth (the writer's only other git query is inside
277
+ // computeTreeFingerprint).
278
+ const gitRoot = (cwd) => {
279
+ const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, windowsHide: true });
280
+ if (r.error || r.status !== 0) return null;
281
+ return r.stdout.toString('utf8').replace(/\r?\n$/, '');
282
+ };
283
+
284
+ // recordOverride({ cwd, env, loop, activity, round, scope, files, testId, reason, timestamp }, deps)
285
+ // → { writtenPath, record }. Standard teeth: field validation via the schema (exact per-scope
286
+ // payloads), fail-closed ledger read, and the IN-FLIGHT tooth — an override is a waiver, minted only
287
+ // inside its live loop, never retro-recorded for a finished or foreign one. The fold-completeness
288
+ // gate matches on loop + payload, never on the (audit-only) fingerprint.
289
+ export const recordOverride = (params, deps = {}) => {
290
+ const { cwd = process.cwd(), env = process.env, loop, activity = DEFAULT_ACTIVITY, round, scope, files, testId, sanctionedLines, reason, timestamp } = params;
291
+ const ledgerPath = deps.ledgerPath ?? resolveLedgerPath(cwd, env);
292
+ if (ledgerPath == null) throw stop('cannot resolve the ledger path — not a git work tree and AW_REVIEW_LEDGER is unset');
293
+ if (!(Number.isInteger(round) && round >= 1)) throw stop(`round must be an integer >= 1 (got ${round})`);
294
+ // Exactly ONE in-flight plan, and it must be the named loop (codex+agy R5): a waiver minted while
295
+ // multiple plans are active could later cover a now-single loop — the ambiguity the rest of the
296
+ // family refuses everywhere (the single-plan rule).
297
+ const plans = deps.plansInFlight ? deps.plansInFlight() : plansInFlight(gitRoot(cwd) ?? cwd);
298
+ if (plans.length !== 1 || plans[0] !== `${loop}.md`) {
299
+ 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'})`);
300
+ }
301
+ const fingerprint = deps.computeFingerprint ? deps.computeFingerprint(cwd) : computeTreeFingerprint(cwd);
302
+ const base = deps.resolveBase ? deps.resolveBase(cwd) : resolveBase(cwd);
303
+ const record = {
304
+ schema: SCHEMA_VERSION, loop, activity, kind: 'override', round, base, fingerprint, scope,
305
+ ...(files !== undefined ? { files } : {}), ...(testId !== undefined ? { testId } : {}),
306
+ ...(sanctionedLines !== undefined ? { sanctionedLines } : {}),
307
+ reason, timestamp: timestamp ?? isoNow(),
308
+ };
309
+ const v = validateRecord(record);
310
+ if (!v.ok) throw stop(`refusing to record a malformed override: ${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 an override for loop "${loop}": its recorded round sequence for the current segment is corrupt (not 1..n) — fix the ledger by hand first`);
316
+ }
145
317
  return appendRecord(ledgerPath, record, deps);
146
318
  };
147
319
 
@@ -155,27 +327,31 @@ export const recordTriage = (params, deps = {}) => {
155
327
  if (ledgerPath == null) throw stop('cannot resolve the ledger path — not a git work tree and AW_REVIEW_LEDGER is unset');
156
328
  if (!(Number.isInteger(round) && round >= 1)) throw stop(`round must be an integer >= 1 (got ${round})`);
157
329
  const fingerprint = deps.computeFingerprint ? deps.computeFingerprint(cwd) : computeTreeFingerprint(cwd);
330
+ const base = deps.resolveBase ? deps.resolveBase(cwd) : resolveBase(cwd);
158
331
  // Normalize each classification: an absent testId → null, an absent note → '' — an absent optional
159
332
  // field is FILLED, never rejected as malformed (agy R3). Under schema v2 (M2/AD-046) a fixable-bug
160
333
  // normalized to a null testId then FAILS validateRecord below (a typed STOP naming the rule + the
161
- // red-test-first fix) — the test-per-fold binding rides the existing validate path. A non-array is
162
- // left as-is for validateRecord to reject with a typed STOP (never a raw .map TypeError).
334
+ // red-test-first fix) — the test-per-fold binding rides the existing validate path; a `refuted`
335
+ // classification normalized to an empty note fails the same way (D6 the grounds are mandatory).
336
+ // A non-array is left as-is for validateRecord to reject with a typed STOP (never a raw .map TypeError).
163
337
  const normalized = Array.isArray(classifications) ? classifications.map((c) => ({ ...c, testId: c?.testId ?? null, note: c?.note ?? '' })) : classifications;
164
- const record = { schema: SCHEMA_VERSION, loop, activity, kind: 'triage', round, fingerprint, classifications: normalized, timestamp: timestamp ?? isoNow() };
338
+ const record = { schema: SCHEMA_VERSION, loop, activity, kind: 'triage', round, base, fingerprint, classifications: normalized, timestamp: timestamp ?? isoNow() };
165
339
  const v = validateRecord(record);
166
340
  if (!v.ok) throw stop(`refusing to record a malformed triage: ${v.reason}`);
167
341
 
168
- // Bind the triage to a REAL round: the referenced round must exist and every classified findingKey
169
- // must be a surviving blocking finding (blocker or major) of THAT round — a classification for a
170
- // nonexistent/future round, or for a key the round never raised, must not satisfy resolved-residual
171
- // downstream (codex R1). Fail CLOSED on an unreadable / malformed ledger.
342
+ // Bind the triage to a REAL round of the current SEGMENT: the referenced round must exist there
343
+ // and every classified findingKey must be a surviving blocking finding (blocker or major) of THAT
344
+ // round — a classification for a nonexistent/future/other-segment round, or for a key the round
345
+ // never raised, must not satisfy resolved-residual downstream (codex R1; D1 a committed
346
+ // segment's rounds are closed, the cross-segment lane is the D7 red-proof override). Fail CLOSED
347
+ // on an unreadable / malformed ledger.
172
348
  const { records, malformed, malformedReasons, readError } = readLedger(ledgerPath, deps.readFile);
173
349
  if (readError) throw stop(`cannot read the existing ledger (${readError}) — refusing to append (fail closed)`);
174
350
  if (malformed > 0) throw stop(`the existing ledger has ${malformed} malformed line(s) — refusing to append until they are fixed (fail closed): ${malformedReasons.join('; ')}`);
175
- const loopRecords = filterLoopRecords(records, { activity, loop });
176
- 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`);
177
- const targetRound = loopRecords.find((r) => r.kind === 'round' && r.round === round);
178
- if (!targetRound) throw stop(`refusing to classify round ${round} of loop "${loop}": no such recorded round — classify a round that exists`);
351
+ const segment = filterSegmentRecords(records, { activity, loop, base });
352
+ 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`);
353
+ const targetRound = segment.find((r) => r.kind === 'round' && r.round === round);
354
+ 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`);
179
355
  const survivingKeys = new Set(targetRound.findings.filter((f) => f.severity === 'blocker' || f.severity === 'major').map((f) => f.findingKey));
180
356
  for (const c of classifications) {
181
357
  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)`);
@@ -186,22 +362,42 @@ export const recordTriage = (params, deps = {}) => {
186
362
 
187
363
  // ── CLI (record / classify) ──────────────────────────────────────────────────────────────────────
188
364
 
189
- const HELP = `review-ledger-write — the review-round ledger WRITER (agent-workflow family, AD-045).
365
+ const HELP = `review-ledger-write — the review-round ledger WRITER (agent-workflow family, AD-045 + AD-047 + AD-048).
190
366
 
191
367
  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>]
368
+ node review-ledger-write.mjs record --json '<round-payload>' [--cwd <dir>]
369
+ node review-ledger-write.mjs classify --json '<triage-payload>' [--cwd <dir>]
370
+ node review-ledger-write.mjs override --json '<override-payload>' [--cwd <dir>]
371
+
372
+ Every verb operates on the current SEGMENT — (loop, base = git rev-parse HEAD): round numbers,
373
+ caps, and teeth reset only when a gated commit moves base (schema 4; records carry base).
194
374
 
195
375
  record appends one review round. The JSON payload carries { loop, round, origins, backends,
196
376
  findings } (activity defaults to plan-execution; timestamp defaults to now). REFUSES while
197
- triage is required, beyond the hard-max ceiling of ${HARD_MAX} rounds, or when a non-degraded
198
- backend lacks a grounded code receipt for the current tree.
377
+ triage is required; beyond the hard-max ceiling of ${HARD_MAX} rounds within one segment;
378
+ while a blocking finding of the segment's previous round VANISHED unclassified (D6 —
379
+ classify it first, "refuted" is the honest phantom lane); while the changed source surface
380
+ exceeds the ${DEFAULT_DIFF_CAP}-line diff cap (AW_REVIEW_DIFF_CAP) without a recorded
381
+ segment size-cap override (D4); while the segment lacks a quality-green gate-run at the
382
+ current fingerprint (D5 — run the FULL matrix first: run-gates.mjs --record; a --only
383
+ subset or a tree-changed run never satisfies; red PROCESS gates never block); or when a
384
+ non-degraded backend lacks a grounded code receipt for the current tree.
199
385
  classify appends one triage record. The JSON payload carries { loop, round, classifications } (each
200
386
  { findingKey, class, accepted, testId, note }). A fixable-bug REQUIRES a testId — the
201
387
  red→green test that pins the fold, formatted "<test-file>#<test-name-pattern>" (write it
202
- first); inherent-layer-residual / escalate may omit it. This is what permits the next round.
388
+ first); refuted REQUIRES a non-empty note citing the refuting grounds;
389
+ inherent-layer-residual / escalate may omit the testId. This is what permits the next round.
390
+ override appends one override record — the LOUD, durable waiver the gates consume. The JSON payload
391
+ carries { loop, round, scope, reason } plus, per scope:
392
+ scope "oracle-change" → files[] (non-empty repo-relative paths whose tamper flag it lifts);
393
+ scope "red-proof" → testId (the exact bound testId whose observed-red receipt + custody it
394
+ waives — for a red that is GENUINELY unestablishable pre-fold, D7);
395
+ scope "size-cap" → sanctionedLines (the exact changed-surface magnitude the waiver
396
+ sanctions, SEGMENT-scoped — it dies at the next commit, D4).
397
+ REFUSES for a loop that is not the in-flight plan. QUARANTINE (a flaky/timed-out probe)
398
+ has NO override lane.
203
399
 
204
- The read-only checker is a SEPARATE tool: node review-ledger.mjs --check / --status / --json.
400
+ The read-only checker is a SEPARATE tool: node review-ledger.mjs --check / --status / --json / --telemetry.
205
401
  Exit codes: 0 written; 1 a typed STOP (teeth / malformed / missing receipt / fs error); 2 usage.`;
206
402
 
207
403
  const parseArgs = (argv) => {
@@ -238,14 +434,16 @@ export const main = (argv, ctx = {}) => {
238
434
  try {
239
435
  if (argv.includes('--help') || argv.includes('-h') || argv.length === 0) return { code: argv.length === 0 ? 2 : 0, stdout: HELP, stderr: '' };
240
436
  const sub = argv[0];
241
- if (sub !== 'record' && sub !== 'classify') throw usageFail(`unknown subcommand "${sub}" (expected: record | classify)`);
437
+ if (sub !== 'record' && sub !== 'classify' && sub !== 'override') throw usageFail(`unknown subcommand "${sub}" (expected: record | classify | override)`);
242
438
  const opts = parseArgs(argv.slice(1));
243
439
  const payload = parsePayload(opts.json);
244
440
  const cwd = opts.cwd ?? cwd0;
245
441
  const result =
246
442
  sub === 'record'
247
443
  ? recordRound({ cwd, env, ...payload })
248
- : recordTriage({ cwd, env, ...payload });
444
+ : sub === 'classify'
445
+ ? recordTriage({ cwd, env, ...payload })
446
+ : recordOverride({ cwd, env, ...payload });
249
447
  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
448
  } catch (err) {
251
449
  return { code: err.exitCode ?? 1, stdout: '', stderr: `review-ledger-write: ${err.message}` };