@codedology/code-forge 0.8.0 → 0.9.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.
@@ -11,7 +11,14 @@
11
11
  * another block of the run that is still OPEN owns it — the gate cannot tell which coder wrote
12
12
  * it, so the close WARNs naming each such file — or a closed one owns it and its gate approved
13
13
  * exactly the current content);
14
- * - `unreviewed`: a changed or new file has NO `review.approved` row for its CURRENT content hash;
14
+ * - `unreviewed`: a changed or new file has NO `review.approved` row for its CURRENT content hash —
15
+ * unless (B70) it is `reviewed_by_waiver`: a signed `review.round` row for that hash exists (a
16
+ * session really reviewed these bytes; a repeated stop or an `unavailable` result never counts),
17
+ * the latest refusal row for the hash is signed, carries `content_hash` (a hash-less one does
18
+ * not qualify, though it still refuses) and names at least one finding, and every finding it
19
+ * names and every open id of the file's `review.cap` rows is cleared by a waiver the gate
20
+ * accepts (the same rule as below: a critical only by `by: human`). Such a file is never
21
+ * approved; the close writes one signed `gate.review_waived_all` row for it ({@link reviewGateCheck});
15
22
  * - `unsigned`: the file's approvals for that hash exist but none carries a valid MAC;
16
23
  * - `not_approved`: (B52) the file's signed approval for its current hash is STALE — a later row of
17
24
  * the block (ledger order) for the same file and hash refuses it ({@link denialOf}: a
@@ -37,9 +44,20 @@
37
44
  * `review.waived {by: human}` row — unless a signed approval of the file's current hash comes
38
45
  * AFTER the cap row (ledger order) and no later refusal row made it stale (the human fixed it
39
46
  * by hand and it was reviewed); a finding already refused as `not_approved` for that file is
40
- * not repeated;
47
+ * not repeated. B73: this detail, and an `unreviewed` detail for a file with a cap row, ends
48
+ * with the stop's ways out (`exits: block reopen, block waive, review-file --answers,
49
+ * re-decompose`, `stop-exits.mjs`);
41
50
  * - `unproven`: a changed owned file of tier `high` (B20, Ricardo 2026-09-25) has no covering
42
51
  * red→green proof (`checkBlockProof`, below). Light-tier files need no proof row.
52
+ * B71 (answers): a finding is also cleared — wherever a waiver clears it, by the one rule set
53
+ * ({@link reviewRules}: `waiverOf`, so `reviewed_by_waiver` reads "waived or answered") — by a
54
+ * `review.answered {file, content_hash, finding, by_file, by_hash, ticket}` row the worker wrote
55
+ * after a reviewer judged a fix in another owned file: only when its MAC verifies, it is of this
56
+ * run, the finding is not `proof`, the severity read is critical, warning, nit or none (an unknown
57
+ * one stays the human's), and BOTH `content_hash` (the source) and `by_hash` (the answering file
58
+ * `by_file`) equal those files' current hashes — a change to either makes the answer stale. An
59
+ * answer can clear a critical (a reviewer judged the fix; it is not a waiver). It never approves
60
+ * the answering file, which needs its own review.
43
61
  * Rulings and waivers only count when their MAC verifies AND name the finding's file exactly
44
62
  * (`file` + `finding`; a finding id is scoped to one file, so a waiver of `F1` in `a.mjs` never
45
63
  * clears `F1` in `b.mjs`); a late finding counts whether or not it is signed (fail closed).
@@ -74,6 +92,14 @@ import { verifyRow } from '../state/signer.mjs';
74
92
  import { tierFor } from '../proof/tiers.mjs';
75
93
  import { DELETED_HASH, contentHash, gitChildEnv } from '../worker/ticket.mjs';
76
94
  import { verifyResult } from '../worker/queue.mjs';
95
+ import { stopExitsLine } from './stop-exits.mjs';
96
+
97
+ /**
98
+ * @typedef {{file: string, content_hash: string, status: 'reviewed_by_waiver', findings: string[], by: 'human' | 'autopilot' | null, answered?: string[]}} WaivedAll
99
+ * B70: a file that counts as reviewed (never approved) because every finding its review left open was waived.
100
+ * B71: or answered — `findings` are the waived ids, `answered` (present only when there is one)
101
+ * the ids a signed `review.answered` row clears; `by` is null when every id was answered.
102
+ */
77
103
 
78
104
  /**
79
105
  * @typedef {{code: 'no_changes' | 'unowned_change' | 'not_approved' | 'unreviewed' | 'unsigned' | 'rule_break' | 'late_unruled' | 'review_cap' | 'unproven' | 'autopilot_waiver_invalid', file?: string, finding?: string, detail?: string}} Refusal
@@ -96,59 +122,53 @@ const verified = (row, key) => verifyRow(row, key).ok;
96
122
  * but never the proof check; `highPaths` is `proof.tiers.high.paths`. Both are REQUIRED: a
97
123
  * caller that leaves one out gets a TypeError, never a silent default that skips the proof.
98
124
  * `runId` is required too: the proof check matches its rows on this run only (fix round 4).
99
- * @returns {{ok: boolean, refusals: Refusal[]}}
125
+ * @returns {{ok: boolean, refusals: Refusal[], waivedAll: WaivedAll[]}} `waivedAll` (B70): the files
126
+ * that passed as `reviewed_by_waiver` — empty whenever the check refuses.
100
127
  * @throws {TypeError} `runId`, `proofFiles` or `highPaths` undefined
101
128
  */
102
129
  export function checkBlockReviews({ block, runId, files, rows, key, transcript = null, extraTokens = [], proofFiles, highPaths, unowned = [], resultFiles = {}, noChanges = false }) {
103
130
  if (runId === undefined) throw new TypeError('checkBlockReviews: runId is required (the proof check matches rows on this run only)');
104
131
  if (proofFiles === undefined) throw new TypeError('checkBlockReviews: proofFiles is required (the changed owned files the proof check reads)');
105
132
  if (highPaths === undefined) throw new TypeError('checkBlockReviews: highPaths is required (proof.tiers.high.paths)');
106
- const mine = rows.filter((r) => r?.block === block && (runId === undefined || r.run === undefined || r.run === runId));
133
+ const hashOf = new Map(files.map((f) => [f.file, f.content_hash]));
134
+ // B71: the current hashes an answer is checked against — every file the close hashed (the
135
+ // proof set too, so `--no-require-reviews` does not hide the answering file's hash)
136
+ /** @type {Map<string, string>} */
137
+ const current = new Map();
138
+ for (const f of [...proofFiles, ...files]) current.set(f.file, f.content_hash);
139
+ const { mine, latestOf, signedOf, matches, waiverOf, waiverFor, waivedAllOf, recordedRefusalSeverity } = reviewRules({ block, runId, rows, key, resultFiles, current });
107
140
  /** @type {Refusal[]} */
108
141
  const refusals = [];
109
142
  const approvedFor = (/** @type {string} */ file, /** @type {string} */ hash) => mine.filter((r) => r.event === 'review.approved' && r.file === file && r.content_hash === hash);
110
- const hashOf = new Map(files.map((f) => [f.file, f.content_hash]));
111
- /**
112
- * B52: the index of the last signed approval and the latest refusal AFTER it for (file, hash);
113
- * `denial` is null when that approval stands (or there is none — `unreviewed` / `unsigned` say
114
- * so). A refusal row with no `content_hash` counts for every hash of its file (fail closed).
115
- * `latest` is the latest refusal for (file, hash) whether or not an approval exists.
116
- * @param {string} file @param {string} hash
117
- * @returns {{lastApproval: number, denial: Denial | null, latest: Denial | null}}
118
- */
119
- const latestOf = (file, hash) => {
120
- let lastApproval = -1;
121
- /** @type {Denial | null} */
122
- let denial = null;
123
- let lastDenial = -1;
124
- mine.forEach((r, i) => {
125
- if (r.file !== file) return;
126
- if (r.event === 'review.approved') {
127
- if (r.content_hash === hash && verified(r, key)) lastApproval = i;
128
- return;
129
- }
130
- if (r.content_hash !== hash && r.content_hash !== undefined) return;
131
- const d = denialOf(r, resultFiles);
132
- if (d !== null) {
133
- denial = d;
134
- lastDenial = i;
135
- }
136
- });
137
- return { lastApproval, denial: lastApproval >= 0 && lastDenial > lastApproval ? denial : null, latest: denial };
138
- };
139
143
  const staleBy = (/** @type {string} */ file, /** @type {string} */ hash) => latestOf(file, hash).denial;
140
144
 
141
145
  for (const file of unowned) refusals.push({ code: 'unowned_change', file, detail: `${file} changed but no block of the run owns it — block claim it, or put it back` });
142
146
 
143
147
  if (noChanges) refusals.push({ code: 'no_changes', detail: `block ${block} changed no owned file and has no reviews` });
144
148
 
149
+ const rulings = signedOf('review.late_ruling');
150
+ /** @param {Refusal} refusal @param {string} finding @param {string | null} invalid @returns {Refusal} */
151
+ const orInvalid = (refusal, finding, invalid) =>
152
+ invalid === null ? refusal : { code: 'autopilot_waiver_invalid', file: refusal.file, finding, detail: `the autopilot waiver of ${finding} in ${refusal.file} does not count: ${invalid}` };
153
+
154
+ /** @type {WaivedAll[]} */
155
+ const waivedAll = [];
156
+
145
157
  /** @type {Array<{file: string, denial: Denial}>} */
146
158
  const stale = [];
147
159
  for (const { file, content_hash } of files) {
148
160
  const approvals = approvedFor(file, content_hash);
149
161
  const signedApproval = approvals.some((r) => verified(r, key));
150
- if (approvals.length === 0) refusals.push({ code: 'unreviewed', file, detail: `no review.approved row for ${file} at its current content` });
151
- else if (!signedApproval) refusals.push({ code: 'unsigned', file, detail: `the review.approved row for ${file} has no valid MAC` });
162
+ if (approvals.length === 0) {
163
+ // B70: a review whose every open finding was waived counts as reviewed — never as approved
164
+ const all = waivedAllOf(file, content_hash);
165
+ if (all !== null) waivedAll.push(all);
166
+ else {
167
+ // B73: a stopped file (a cap row of the block) names its ways out
168
+ const exits = mine.some((r) => r.event === 'review.cap' && r.file === file) ? `; ${stopExitsLine}` : '';
169
+ refusals.push({ code: 'unreviewed', file, detail: `no review.approved row for ${file} at its current content${exits}` });
170
+ }
171
+ } else if (!signedApproval) refusals.push({ code: 'unsigned', file, detail: `the review.approved row for ${file} has no valid MAC` });
152
172
  if (signedApproval) {
153
173
  const denial = staleBy(file, content_hash);
154
174
  if (denial !== null) stale.push({ file, denial });
@@ -167,29 +187,6 @@ export function checkBlockReviews({ block, runId, files, rows, key, transcript =
167
187
  for (const hit of scanTranscript(transcript, mergeForbidden(extraTokens))) refusals.push({ code: 'rule_break', detail: `transcript line ${hit.line}: ${hit.id}` });
168
188
  }
169
189
 
170
- const signedOf = (/** @type {string} */ event) => mine.filter((r) => r.event === event && verified(r, key));
171
- const waivers = signedOf('review.waived').filter((r) => r.by === 'human');
172
- const autopilotWaivers = mine.filter((r) => r.event === 'review.waived' && r.by === 'autopilot');
173
- const runRows = rows.filter((r) => r?.run === runId);
174
- const rulings = signedOf('review.late_ruling');
175
- const matches = (/** @type {Record<string, any>} */ r, /** @type {string} */ file, /** @type {string} */ finding) => r.finding === finding && r.file === file;
176
- /**
177
- * Whether the finding is waived: a signed human waiver, or an autopilot waiver its grant covered.
178
- * `invalid`: why the autopilot waivers naming it do not count (null when none names it).
179
- * @param {string} file @param {string} finding @returns {{waived: boolean, invalid: string | null}}
180
- */
181
- const waiverFor = (file, finding) => {
182
- if (waivers.some((r) => matches(r, file, finding))) return { waived: true, invalid: null };
183
- const named = autopilotWaivers.filter((r) => matches(r, file, finding));
184
- if (named.length === 0) return { waived: false, invalid: null };
185
- const problems = named.map((w) => autopilotWaiverProblem(w, { blockRows: mine, runRows, allRows: rows, key }));
186
- if (problems.some((p) => p === null)) return { waived: true, invalid: null };
187
- return { waived: false, invalid: /** @type {string} */ (problems[0]) };
188
- };
189
- /** @param {Refusal} refusal @param {string} finding @param {string | null} invalid @returns {Refusal} */
190
- const orInvalid = (refusal, finding, invalid) =>
191
- invalid === null ? refusal : { code: 'autopilot_waiver_invalid', file: refusal.file, finding, detail: `the autopilot waiver of ${finding} in ${refusal.file} does not count: ${invalid}` };
192
-
193
190
  /** B52: a ruling clears a late finding only when it rules `nit` on one recorded warning or nit. @param {Record<string, any>} late */
194
191
  const ruledNit = (late) => (late.severity === 'warning' || late.severity === 'nit') && rulings.some((r) => matches(r, late.file, late.finding) && r.ruling === 'nit');
195
192
 
@@ -204,20 +201,15 @@ export function checkBlockReviews({ block, runId, files, rows, key, transcript =
204
201
  notApproved.add(`${file}\0${finding}`);
205
202
  const refusal = /** @type {Refusal} */ ({ code: 'not_approved', file, finding, detail: `the latest review of ${file}'s current content left ${finding} open and it is not waived` });
206
203
  // the severity the refusal row records wins: anything but warning/nit is the human's alone
207
- if (severity !== null && !DELEGABLE_SEVERITIES.includes(severity)) {
208
- if (waivers.some((r) => matches(r, file, finding))) continue;
209
- const delegated = autopilotWaivers.some((r) => matches(r, file, finding));
210
- refusals.push(delegated ? { code: 'autopilot_waiver_invalid', file, finding, detail: `the autopilot waiver of ${finding} in ${file} does not count: the latest review records it ${severity}; only the human waives it` } : refusal);
211
- continue;
212
- }
213
- const w = waiverFor(file, finding);
214
- if (!w.waived) refusals.push(orInvalid(refusal, finding, w.invalid));
204
+ const w = waiverOf(file, finding, severity);
205
+ if (w.by === null) refusals.push(orInvalid(refusal, finding, w.invalid));
215
206
  }
216
207
  }
217
208
 
218
209
  for (const late of mine.filter((r) => r.event === 'review.late_finding')) {
219
210
  if (ruledNit(late)) continue;
220
- const w = waiverFor(late.file, late.finding);
211
+ // the answer rule reads the severity the late row records: a missing or unknown one fails closed
212
+ const w = waiverFor(late.file, late.finding, typeof late.severity === 'string' ? late.severity : 'unknown');
221
213
  if (w.waived) continue;
222
214
  refusals.push(orInvalid({ code: 'late_unruled', file: late.file, finding: late.finding, detail: `late finding ${late.finding} in ${late.file} has no ruling` }, late.finding, w.invalid));
223
215
  }
@@ -231,26 +223,311 @@ export function checkBlockReviews({ block, runId, files, rows, key, transcript =
231
223
  }
232
224
  for (const finding of Array.isArray(cap.open) ? cap.open : []) {
233
225
  if (notApproved.has(`${cap.file}\0${finding}`)) continue;
234
- const w = waiverFor(cap.file, finding);
235
- if (!w.waived) refusals.push(orInvalid({ code: 'review_cap', file: cap.file, finding, detail: `${cap.file} stopped at review_cap; finding ${finding} is open and not waived` }, finding, w.invalid));
226
+ const w = waiverFor(cap.file, finding, hash === undefined ? 'unknown' : recordedRefusalSeverity(cap.file, hash, finding));
227
+ if (!w.waived) refusals.push(orInvalid({ code: 'review_cap', file: cap.file, finding, detail: `${cap.file} stopped at review_cap; finding ${finding} is open and not waived; ${stopExitsLine}` }, finding, w.invalid));
236
228
  }
237
229
  }
238
230
  for (const r of checkBlockProof({ runId, files: proofFiles, rows: mine, key, highPaths })) {
239
- const w = waiverFor(/** @type {string} */ (r.file), PROOF_FINDING);
231
+ const w = waiverFor(/** @type {string} */ (r.file), PROOF_FINDING, 'unknown');
240
232
  if (!w.waived) refusals.push(orInvalid(r, PROOF_FINDING, w.invalid));
241
233
  }
242
- return { ok: refusals.length === 0, refusals };
234
+ return { ok: refusals.length === 0, refusals, waivedAll: refusals.length === 0 ? waivedAll : [] };
235
+ }
236
+
237
+ /** B71: the signed row the worker writes for a finding a reviewer judged resolved by another owned file. */
238
+ export const ANSWERED_EVENT = 'review.answered';
239
+
240
+ /**
241
+ * B71: the severities a signed answer clears — a critical too (a reviewer judged the fix; it is
242
+ * not a waiver) and an explicit null (the row records none). An unknown severity, `undefined` or
243
+ * any other value is never cleared by an answer (fail closed).
244
+ * @param {unknown} severity @returns {boolean}
245
+ */
246
+ export function answerableSeverity(severity) {
247
+ return severity === null || severity === 'critical' || severity === 'warning' || severity === 'nit';
248
+ }
249
+
250
+ /**
251
+ * THE review rules of one block (B52, B70, B71), shared by the close ({@link checkBlockReviews})
252
+ * and the worker's answers check ({@link answerableFindings}) so both read a finding the same way.
253
+ * `current`: the current content hash per file — an answer counts only while BOTH its hashes are
254
+ * there and equal (a file missing from it fails closed).
255
+ * @param {{block: string, runId: string, rows: Array<Record<string, any>>, key: Buffer | null, resultFiles?: Record<string, ResultFile>, current: Map<string, string>, verify?: (row: Record<string, any>) => boolean}} opts
256
+ */
257
+ function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verify }) {
258
+ // B71: `verify` replaces the MAC check only for `review-file`'s unverified pre-check ({@link
259
+ // namedOnHash}), which has no key and can only refuse; it then reads no autopilot waiver
260
+ const ok = verify ?? ((/** @type {Record<string, any>} */ r) => verified(r, key));
261
+ const mine = rows.filter((r) => r?.block === block && (runId === undefined || r.run === undefined || r.run === runId));
262
+ /**
263
+ * B52: the index of the last signed approval and the latest refusal AFTER it for (file, hash);
264
+ * `denial` is null when that approval stands (or there is none — `unreviewed` / `unsigned` say
265
+ * so). A refusal row with no `content_hash` counts for every hash of its file (fail closed).
266
+ * `latest` is the latest refusal for (file, hash) whether or not an approval exists, `latestRow`
267
+ * the row it came from.
268
+ * @param {string} file @param {string} hash
269
+ * @returns {{lastApproval: number, denial: Denial | null, latest: Denial | null, latestRow: Record<string, any> | null}}
270
+ */
271
+ const latestOf = (file, hash) => {
272
+ let lastApproval = -1;
273
+ /** @type {Denial | null} */
274
+ let denial = null;
275
+ let lastDenial = -1;
276
+ /** @type {Record<string, any> | null} */
277
+ let latestRow = null;
278
+ mine.forEach((r, i) => {
279
+ if (r.file !== file) return;
280
+ if (r.event === 'review.approved') {
281
+ if (r.content_hash === hash && ok(r)) lastApproval = i;
282
+ return;
283
+ }
284
+ if (r.content_hash !== hash && r.content_hash !== undefined) return;
285
+ const d = denialOf(r, resultFiles);
286
+ if (d !== null) {
287
+ denial = d;
288
+ lastDenial = i;
289
+ latestRow = r;
290
+ }
291
+ });
292
+ return { lastApproval, denial: lastApproval >= 0 && lastDenial > lastApproval ? denial : null, latest: denial, latestRow };
293
+ };
294
+
295
+ const signedOf = (/** @type {string} */ event) => mine.filter((r) => r.event === event && ok(r));
296
+ const waivers = signedOf('review.waived').filter((r) => r.by === 'human');
297
+ const autopilotWaivers = mine.filter((r) => r.event === 'review.waived' && r.by === 'autopilot');
298
+ // B71: only the worker's signed rows of THIS run (a row with no run never counts)
299
+ const answers = signedOf(ANSWERED_EVENT).filter((r) => r.run === runId);
300
+ const runRows = rows.filter((r) => r?.run === runId);
301
+ const matches = (/** @type {Record<string, any>} */ r, /** @type {string} */ file, /** @type {string} */ finding) => r.finding === finding && r.file === file;
302
+ /**
303
+ * B71: whether a signed `review.answered` row clears `finding` of `file`: never `proof`, only an
304
+ * {@link answerableSeverity}, and only while the source hash (`content_hash`) and the answering
305
+ * file's hash (`by_hash`) both equal their CURRENT hashes — a change to either makes it stale.
306
+ * `exceptTicket`: the rows of that answers ticket are left out (its own retry, {@link openAt}).
307
+ * A proof finding is cleared by its proof (`proof` rows), never by a reviewer's answer.
308
+ * @param {string} file @param {string} finding @param {unknown} severity @param {string | null} [exceptTicket]
309
+ * @returns {boolean}
310
+ */
311
+ const answered = (file, finding, severity, exceptTicket = null) => {
312
+ if (finding === PROOF_FINDING || !answerableSeverity(severity)) return false;
313
+ return answerRows(file).some((r) => r.finding === finding && (exceptTicket === null || r.ticket !== exceptTicket));
314
+ };
315
+ /**
316
+ * B71: the signed `review.answered` rows of `file` still current (both hashes), any finding.
317
+ * @param {string} file @returns {Array<Record<string, any>>}
318
+ */
319
+ const answerRows = (file) => {
320
+ const now = current.get(file);
321
+ if (now === undefined) return [];
322
+ return answers.filter((r) => r.file === file && r.content_hash === now && typeof r.by_file === 'string' && r.by_file !== file && current.has(r.by_file) && r.by_hash === current.get(r.by_file));
323
+ };
324
+ /**
325
+ * THE clearing rule: who clears the finding — `human` (a signed human waiver), `answer` (B71: a
326
+ * signed answer of the worker, {@link answered}), `autopilot` (an autopilot waiver its grant
327
+ * covered, {@link autopilotWaiverProblem}) or null, in that order. `severity`: the one a refusal
328
+ * row RECORDS for it — REQUIRED: only an explicit null (none recorded there; the signed review
329
+ * rows decide, {@link autopilotWaiverProblem}) or warning/nit may be delegated; anything else
330
+ * (`undefined`, a critical, an unknown value) is the human's alone ({@link delegableSeverity}),
331
+ * except that an answer also clears a critical. `invalid`: why the autopilot waivers naming it do
332
+ * not count (null when none names it).
333
+ * `exceptTicket`: see {@link answered}. `answerSeverity`: the severity the answer rule reads
334
+ * (default `severity`); the late-finding, cap and proof loops pass the one RECORDED for the id,
335
+ * so an unknown severity is never cleared by an answer even where the autopilot rule reads null.
336
+ * @param {string} file @param {string} finding @param {unknown} severity @param {string | null} [exceptTicket] @param {unknown} [answerSeverity]
337
+ * @returns {{by: 'human' | 'answer' | 'autopilot' | null, invalid: string | null}}
338
+ */
339
+ const waiverOf = (file, finding, severity, exceptTicket = null, answerSeverity = severity) => {
340
+ if (waivers.some((r) => matches(r, file, finding))) return { by: 'human', invalid: null };
341
+ if (answered(file, finding, answerSeverity, exceptTicket)) return { by: 'answer', invalid: null };
342
+ const named = autopilotWaivers.filter((r) => matches(r, file, finding));
343
+ // the unverified pre-check (`verify` given) reads no autopilot waiver: it cannot check a grant
344
+ if (named.length === 0 || verify !== undefined) return { by: null, invalid: null };
345
+ if (!delegableSeverity(severity)) return { by: null, invalid: `the latest review records it ${severity}; only the human waives it` };
346
+ const problems = named.map((w) => autopilotWaiverProblem(w, { blockRows: mine, runRows, allRows: rows, key: /** @type {Buffer} */ (key) }));
347
+ if (problems.some((p) => p === null)) return { by: 'autopilot', invalid: null };
348
+ return { by: null, invalid: /** @type {string} */ (problems[0]) };
349
+ };
350
+ /**
351
+ * Whether the finding is cleared: a signed human waiver, a valid answer, or an autopilot waiver
352
+ * its grant covered. `answerSeverity`: the severity recorded for the id (REQUIRED: the answer
353
+ * rule fails closed on anything {@link answerableSeverity} refuses).
354
+ * @param {string} file @param {string} finding @param {unknown} answerSeverity @returns {{waived: boolean, invalid: string | null}}
355
+ */
356
+ const waiverFor = (file, finding, answerSeverity) => {
357
+ const w = waiverOf(file, finding, null, null, answerSeverity);
358
+ return { waived: w.by !== null, invalid: w.invalid };
359
+ };
360
+
361
+ /**
362
+ * B70: the worst severity any signed hash-bound refusal row of `file` at `hash` records for
363
+ * `finding` (a cap row records none), or `unknown` when none does — never delegable.
364
+ * @param {string} file @param {string} hash @param {string} finding @returns {string}
365
+ */
366
+ const recordedRefusalSeverity = (file, hash, finding) => {
367
+ let worst = '';
368
+ for (const r of mine) {
369
+ if (r.file !== file || r.content_hash !== hash || !ok(r)) continue;
370
+ for (const o of denialOf(r, resultFiles)?.open ?? []) {
371
+ if (o.id !== finding || o.severity === null) continue;
372
+ // 'unknown' is itself an OPEN_RANK key (ranked above critical), so any value outside it
373
+ // maps to 'unknown' and wins: fail closed
374
+ const sev = Object.hasOwn(OPEN_RANK, o.severity) ? o.severity : 'unknown';
375
+ if (worst === '' || /** @type {Record<string, number>} */ (OPEN_RANK)[sev] > /** @type {Record<string, number>} */ (OPEN_RANK)[worst]) worst = sev;
376
+ }
377
+ }
378
+ return worst === '' ? 'unknown' : worst;
379
+ };
380
+
381
+ /**
382
+ * B79: the ids every signed `review.cap` row for (file, hash) names. A cap row records no
383
+ * severity, so the rules read each of these ids at {@link recordedRefusalSeverity} — also when
384
+ * that cap row is the latest refusal (its own null never stands for them).
385
+ * @param {string} file @param {string} hash @returns {Set<string>}
386
+ */
387
+ const capIdsAt = (file, hash) => {
388
+ /** @type {Set<string>} */
389
+ const ids = new Set();
390
+ for (const cap of mine) {
391
+ if (cap.event !== 'review.cap' || cap.file !== file || cap.content_hash !== hash || !Array.isArray(cap.open) || !ok(cap)) continue;
392
+ for (const id of cap.open) if (typeof id === 'string' && id.length > 0) ids.add(id);
393
+ }
394
+ return ids;
395
+ };
396
+
397
+ /**
398
+ * B70: the findings a real review of (file, hash) left open, with the severity each is read at,
399
+ * or null when no such review qualifies. All of: a signed `review.round` row for (file, hash) — a
400
+ * session reviewed these bytes; the latest refusal row for (file, hash) is signed, carries
401
+ * `content_hash: hash` (a hash-less one never qualifies) and names at least one finding. The ids
402
+ * are every finding it names and every open id of the file's `review.cap` rows. Severities: an id
403
+ * a signed `review.cap` row at (file, hash) names ({@link capIdsAt}) — also when that cap row is
404
+ * the latest refusal — and any other open cap id use {@link recordedRefusalSeverity}, whose
405
+ * `unknown` (no signed refusal at `hash` records one) is the human's alone and never answered.
406
+ * Any other id the latest refusal names uses the severity THAT row records. A cap row whose
407
+ * `open` is not an array of non-empty strings (the fix loop's shape) fails closed: null.
408
+ * @param {string} file @param {string} hash
409
+ * @returns {Map<string, unknown> | null}
410
+ */
411
+ const reviewedOpen = (file, hash) => {
412
+ if (!mine.some((r) => r.event === 'review.round' && r.file === file && r.content_hash === hash && ok(r))) return null;
413
+ const { latest, latestRow } = latestOf(file, hash);
414
+ if (latest === null || latestRow === null || latestRow.content_hash !== hash || !ok(latestRow) || latest.open.length === 0) return null;
415
+ /** @type {Map<string, unknown>} */
416
+ const named = new Map(latest.open.map((f) => [f.id, f.severity]));
417
+ for (const id of capIdsAt(file, hash)) named.set(id, recordedRefusalSeverity(file, hash, id));
418
+ for (const cap of mine.filter((r) => r.event === 'review.cap' && r.file === file)) {
419
+ if (!Array.isArray(cap.open) || !cap.open.every((/** @type {unknown} */ id) => typeof id === 'string' && id.length > 0)) return null;
420
+ for (const id of cap.open) if (!named.has(id)) named.set(id, recordedRefusalSeverity(file, hash, id));
421
+ }
422
+ return named;
423
+ };
424
+
425
+ /**
426
+ * B70: a file with no approval for its current hash that counts as reviewed because everything a
427
+ * real review of these bytes left open ({@link reviewedOpen}) is cleared by {@link waiverOf} —
428
+ * waived by the human or a covered autopilot grant, or (B71) answered — or null. `findings` are
429
+ * the waived ids, `answered` the answered ones (only when there is one); `by` is `human` when
430
+ * every clearing waiver is the human's, `autopilot` when one is the autopilot's, and null when
431
+ * every id was answered.
432
+ * @param {string} file @param {string} hash
433
+ * @returns {WaivedAll | null}
434
+ */
435
+ const waivedAllOf = (file, hash) => {
436
+ const named = reviewedOpen(file, hash);
437
+ if (named === null) return null;
438
+ /** @type {string[]} */
439
+ const waived = [];
440
+ /** @type {string[]} */
441
+ const answeredIds = [];
442
+ let byHuman = true;
443
+ for (const [finding, severity] of named) {
444
+ const w = waiverOf(file, finding, severity);
445
+ if (w.by === null) return null;
446
+ if (w.by === 'answer') answeredIds.push(finding);
447
+ else {
448
+ waived.push(finding);
449
+ if (w.by !== 'human') byHuman = false;
450
+ }
451
+ }
452
+ /** @type {'human' | 'autopilot' | null} */
453
+ const by = waived.length === 0 ? null : byHuman ? 'human' : 'autopilot';
454
+ return { file, content_hash: hash, status: 'reviewed_by_waiver', findings: waived.sort(), by, ...(answeredIds.length > 0 ? { answered: answeredIds.sort() } : {}) };
455
+ };
456
+
457
+ /**
458
+ * B71: the findings still open on `file` at `hash` an answer may name: every id the latest
459
+ * signed, hash-bound refusal row for (file, hash) names, and every open id of a signed `review.cap`
460
+ * row for (file, hash), that {@link waiverOf} does not clear yet (not waived, not answered), with
461
+ * the severity it is read at (a cap id's: {@link recordedRefusalSeverity}, B79). An id the close
462
+ * could never count as answered — the proof finding, or one read at a severity outside {@link answerableSeverity} (an unknown value, or a
463
+ * cap id no signed refusal at `hash` records) — is never open here, so it is refused before any
464
+ * session. `ticket`: that answers ticket's own rows do not close an id (a retry after a partial
465
+ * write finishes instead of being refused).
466
+ * @param {string} file @param {string} hash @param {string | null} [ticket]
467
+ * @returns {Map<string, unknown>}
468
+ */
469
+ const openAt = (file, hash, ticket = null) => {
470
+ /** @type {Map<string, unknown>} */
471
+ const named = new Map();
472
+ const { latest, latestRow } = latestOf(file, hash);
473
+ if (latest !== null && latestRow !== null && latestRow.content_hash === hash && ok(latestRow)) for (const f of latest.open) named.set(f.id, f.severity);
474
+ // B79: a cap id is read at its recorded severity even when the cap row is the latest refusal
475
+ for (const id of capIdsAt(file, hash)) named.set(id, recordedRefusalSeverity(file, hash, id));
476
+ return new Map([...named].filter(([id, severity]) => id !== PROOF_FINDING && answerableSeverity(severity) && waiverOf(file, id, severity, ticket).by === null));
477
+ };
478
+
479
+ return { mine, latestOf, signedOf, matches, waiverOf, waiverFor, waivedAllOf, openAt, answerRows, recordedRefusalSeverity };
480
+ }
481
+
482
+ /**
483
+ * B71: the findings of `file` at its current hash `hash` that an answer may still clear (the
484
+ * worker's check, the one that counts): open on that hash by the signed rows and not waived or
485
+ * answered yet — read by the SAME rules as the close ({@link reviewRules}). `current`: the current
486
+ * hash per file (the source's and the answering file's). Keyed by id, with the severity read.
487
+ * `ticket`: the asking ticket's own rows do not close an id ({@link reviewRules} `openAt`).
488
+ * @param {{block: string, runId: string, rows: Array<Record<string, any>>, key: Buffer, file: string, hash: string, current: Map<string, string>, ticket?: string | null}} opts
489
+ * @returns {Map<string, unknown>}
490
+ */
491
+ export function answerableFindings({ block, runId, rows, key, file, hash, current, ticket = null }) {
492
+ return reviewRules({ block, runId, rows, key, current }).openAt(file, hash, ticket);
493
+ }
494
+
495
+ /**
496
+ * B71: the findings of `file` the answers ticket `ticket` already answered by a signed row that is
497
+ * still current (a retry writes no second row for them).
498
+ * @param {{block: string, runId: string, rows: Array<Record<string, any>>, key: Buffer, file: string, current: Map<string, string>, ticket: string}} opts
499
+ * @returns {Set<string>}
500
+ */
501
+ export function ticketAnswered({ block, runId, rows, key, file, current, ticket }) {
502
+ return new Set(reviewRules({ block, runId, rows, key, current }).answerRows(file).filter((r) => r.ticket === ticket).map((r) => r.finding));
503
+ }
504
+
505
+ /**
506
+ * B71: the coder-side pre-check of `review-file --answers` (fast refusal only): the same rules as
507
+ * {@link answerableFindings} ({@link reviewRules}) over rows `review-file` cannot verify (it has no
508
+ * run key), so no MAC is checked and no autopilot waiver is read. It can only refuse: a forged row
509
+ * here at most refuses the coder's own request, and the worker re-checks with the signed rows.
510
+ * @param {{block: string, runId: string, rows: Array<Record<string, any>>, file: string, hash: string, current: Map<string, string>, ticket?: string | null}} opts
511
+ * @returns {Set<string>} the ids still open on (`file`, `hash`).
512
+ */
513
+ export function namedOnHash({ block, runId, rows, file, hash, current, ticket = null }) {
514
+ return new Set(reviewRules({ block, runId, rows, key: null, current, verify: () => true }).openAt(file, hash, ticket).keys());
243
515
  }
244
516
 
245
517
  /**
246
518
  * B52: the `review.result` outcomes that never reviewed the content, and so are no verdict on it,
247
519
  * whatever they carry: `stale` (the file moved before the review), `unavailable` (no session
248
520
  * answered, or the worker failed) and `refused` with reason `other-run` (the ticket was another
249
- * run's). Every other status — `reviewed`, `stopped`, `refused` for any other reason,
250
- * `no_change`, `split_required`, … — is a verdict, and anything but `approved: true` refuses.
521
+ * run's). B71: `answers` too — an answers ticket judged findings of the file, it never reviewed
522
+ * the file (its resolved ids are `review.answered` rows; the rest stay open as they were). Every
523
+ * other status — `reviewed`, `stopped`, `refused` for any other reason, `no_change`,
524
+ * `split_required`, … — is a verdict, and anything but `approved: true` refuses.
251
525
  * @param {Record<string, any>} r @returns {boolean}
252
526
  */
253
- export const neverReviewed = (r) => r.status === 'stale' || r.status === 'unavailable' || (r.status === 'refused' && r.reason === 'other-run');
527
+ export const neverReviewed = (r) => r.status === 'stale' || r.status === 'unavailable' || r.status === ANSWERS_STATUS || (r.status === 'refused' && r.reason === 'other-run');
528
+
529
+ /** B71: the status of every answers-ticket result that is not `stale` or `unavailable`. */
530
+ export const ANSWERS_STATUS = 'answers';
254
531
 
255
532
  /**
256
533
  * @typedef {{event: string, open: Array<{id: string, severity: string | null}>}} Denial
@@ -361,6 +638,17 @@ export async function resultFilesFor({ repoRoot, runId, block, rows }) {
361
638
  /** The finding severities autopilot may waive (B47); anything else is never delegated. */
362
639
  export const DELEGABLE_SEVERITIES = Object.freeze(['warning', 'nit']);
363
640
 
641
+ /**
642
+ * Whether a finding with the severity a refusal row records may be cleared by an autopilot waiver
643
+ * at all (B70, fail closed): an explicit null (the row records none — the signed review rows then
644
+ * decide, {@link autopilotWaiverProblem}) or warning/nit. `undefined`, a critical, an unknown
645
+ * severity or any other value is the human's alone.
646
+ * @param {unknown} severity @returns {boolean}
647
+ */
648
+ export function delegableSeverity(severity) {
649
+ return severity === null || (typeof severity === 'string' && DELEGABLE_SEVERITIES.includes(severity));
650
+ }
651
+
364
652
  /** Severity order for {@link recordedSeverity}: the worst recorded one wins. */
365
653
  const SEVERITY_RANK = Object.freeze({ nit: 0, warning: 1, critical: 2, blocker: 3 });
366
654
 
@@ -382,7 +670,8 @@ export function recordedSeverity(rows, { block, file, finding, key }) {
382
670
  if (r?.block !== block || r.file !== file) continue;
383
671
  if ((r.event === 'review.triage' || r.event === 'review.late_finding') && r.finding === finding) named.push({ row: r, severity: r.severity });
384
672
  // B52: the worker's review.result rows record the open findings' severities too
385
- else if (r.event === 'review.result' && Array.isArray(r.open)) {
673
+ // (B71: an answers result records no severity: it judged findings, it never reviewed the file)
674
+ else if (r.event === 'review.result' && r.status !== ANSWERS_STATUS && Array.isArray(r.open)) {
386
675
  for (const o of r.open) if (o?.id === finding) named.push({ row: r, severity: o.severity });
387
676
  }
388
677
  }
@@ -739,14 +1028,22 @@ function isOnDisk(repoRoot, file) {
739
1028
  }
740
1029
 
741
1030
  /**
742
- * A `closeBlock` extra check (B8's `extraChecks` seam): refusals joined into one reason.
1031
+ * A `closeBlock` extra check (B8's `extraChecks` seam): refusals joined into one reason. B70: on a
1032
+ * pass, `onWaivedAll` (when given) receives the files that passed as `reviewed_by_waiver` (empty
1033
+ * when none). It only buffers that list: the close's `gate.review_waived_all` rows for it are
1034
+ * signed and written by `closeBlock` after EVERY check passed, just before `block.close` — a
1035
+ * refused close writes none.
743
1036
  * @param {() => Promise<Parameters<typeof checkBlockReviews>[0]>} load
1037
+ * @param {{onWaivedAll?: (files: WaivedAll[]) => void}} [hooks]
744
1038
  * @returns {() => Promise<{ok: boolean, reason?: string}>}
745
1039
  */
746
- export function reviewGateCheck(load) {
1040
+ export function reviewGateCheck(load, { onWaivedAll } = {}) {
747
1041
  return async () => {
748
1042
  const result = checkBlockReviews(await load());
749
- if (result.ok) return { ok: true };
1043
+ if (result.ok) {
1044
+ onWaivedAll?.(result.waivedAll);
1045
+ return { ok: true };
1046
+ }
750
1047
  return { ok: false, reason: result.refusals.map((r) => `${r.code}${r.file ? ` ${r.file}` : ''}${r.finding ? ` ${r.finding}` : ''}`).join('; ') };
751
1048
  };
752
1049
  }