@codedology/code-forge 0.8.0 → 0.10.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.
Files changed (64) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/README.md +46 -7
  3. package/docs/reference/blocks.json +618 -1
  4. package/docs/reference/config.md +36 -0
  5. package/package.json +1 -1
  6. package/schema/code-forge.schema.json +89 -0
  7. package/skill/references/adapters/claude-code.md +1 -1
  8. package/skill/references/adapters/solo.md +2 -2
  9. package/skill/references/adapters/subprocess.md +12 -2
  10. package/skill/references/code.md +21 -7
  11. package/skill/references/ledger.md +2 -2
  12. package/skill/references/proof.md +77 -2
  13. package/skill/references/review.md +29 -4
  14. package/skill/references/security.md +5 -5
  15. package/skill/scripts/forge +1 -1
  16. package/skill/templates/coder-brief.md +4 -2
  17. package/src/autopilot/binnacle.mjs +9 -3
  18. package/src/cli/block.mjs +300 -33
  19. package/src/cli/jev.mjs +22 -3
  20. package/src/cli/keys.mjs +156 -21
  21. package/src/cli/proof.mjs +35 -7
  22. package/src/cli/review-file.mjs +264 -13
  23. package/src/cli/spawn.mjs +44 -3
  24. package/src/config/validate.mjs +320 -5
  25. package/src/doctor/index.mjs +7 -5
  26. package/src/doctor/local.mjs +116 -8
  27. package/src/doctor/probes.mjs +38 -0
  28. package/src/engines/builders/codex.mjs +44 -4
  29. package/src/engines/sandbox-dirs.mjs +257 -0
  30. package/src/keys/store.mjs +128 -21
  31. package/src/ledger/write.mjs +7 -1
  32. package/src/proof/junit.mjs +530 -0
  33. package/src/proof/red-green.mjs +57 -9
  34. package/src/proof/red-parse.mjs +2 -0
  35. package/src/proof/runners.mjs +472 -0
  36. package/src/proof/xcresult.mjs +285 -0
  37. package/src/review/answers.mjs +445 -0
  38. package/src/review/deletion.mjs +106 -0
  39. package/src/review/engine.mjs +117 -31
  40. package/src/review/fixloop.mjs +70 -28
  41. package/src/review/gate-check.mjs +378 -80
  42. package/src/review/lenses/A.md +1 -0
  43. package/src/review/lenses/B.md +1 -0
  44. package/src/review/lenses/full.md +1 -0
  45. package/src/review/lenses/quick.md +1 -0
  46. package/src/review/lenses/recheck.md +1 -0
  47. package/src/review/reopen.mjs +256 -0
  48. package/src/review/route.mjs +232 -0
  49. package/src/review/schema-fallback.mjs +77 -22
  50. package/src/review/session-retry.mjs +44 -9
  51. package/src/review/stop-exits.mjs +15 -0
  52. package/src/review/test-quality.mjs +201 -0
  53. package/src/review/verb-forms.mjs +39 -0
  54. package/src/session/spawn.mjs +39 -2
  55. package/src/state/block.mjs +13 -3
  56. package/src/state/config-snapshot.mjs +39 -0
  57. package/src/state/run.mjs +4 -0
  58. package/src/util/forbidden.mjs +11 -1
  59. package/src/util/tmp.mjs +16 -4
  60. package/src/worker/engine.mjs +75 -9
  61. package/src/worker/loop.mjs +82 -15
  62. package/src/worker/queue.mjs +265 -10
  63. package/src/worker/review-state.mjs +346 -51
  64. package/src/worker/ticket.mjs +9 -1
@@ -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,15 @@ 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
+ import { checkFileForm, checkFindingId } from './verb-forms.mjs';
97
+
98
+ /**
99
+ * @typedef {{file: string, content_hash: string, status: 'reviewed_by_waiver', findings: string[], by: 'human' | 'autopilot' | null, answered?: string[]}} WaivedAll
100
+ * B70: a file that counts as reviewed (never approved) because every finding its review left open was waived.
101
+ * B71: or answered — `findings` are the waived ids, `answered` (present only when there is one)
102
+ * the ids a signed `review.answered` row clears; `by` is null when every id was answered.
103
+ */
77
104
 
78
105
  /**
79
106
  * @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 +123,53 @@ const verified = (row, key) => verifyRow(row, key).ok;
96
123
  * but never the proof check; `highPaths` is `proof.tiers.high.paths`. Both are REQUIRED: a
97
124
  * caller that leaves one out gets a TypeError, never a silent default that skips the proof.
98
125
  * `runId` is required too: the proof check matches its rows on this run only (fix round 4).
99
- * @returns {{ok: boolean, refusals: Refusal[]}}
126
+ * @returns {{ok: boolean, refusals: Refusal[], waivedAll: WaivedAll[]}} `waivedAll` (B70): the files
127
+ * that passed as `reviewed_by_waiver` — empty whenever the check refuses.
100
128
  * @throws {TypeError} `runId`, `proofFiles` or `highPaths` undefined
101
129
  */
102
130
  export function checkBlockReviews({ block, runId, files, rows, key, transcript = null, extraTokens = [], proofFiles, highPaths, unowned = [], resultFiles = {}, noChanges = false }) {
103
131
  if (runId === undefined) throw new TypeError('checkBlockReviews: runId is required (the proof check matches rows on this run only)');
104
132
  if (proofFiles === undefined) throw new TypeError('checkBlockReviews: proofFiles is required (the changed owned files the proof check reads)');
105
133
  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));
134
+ const hashOf = new Map(files.map((f) => [f.file, f.content_hash]));
135
+ // B71: the current hashes an answer is checked against — every file the close hashed (the
136
+ // proof set too, so `--no-require-reviews` does not hide the answering file's hash)
137
+ /** @type {Map<string, string>} */
138
+ const current = new Map();
139
+ for (const f of [...proofFiles, ...files]) current.set(f.file, f.content_hash);
140
+ const { mine, latestOf, signedOf, matches, waiverOf, waiverFor, waivedAllOf, recordedRefusalSeverity } = reviewRules({ block, runId, rows, key, resultFiles, current });
107
141
  /** @type {Refusal[]} */
108
142
  const refusals = [];
109
143
  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
144
  const staleBy = (/** @type {string} */ file, /** @type {string} */ hash) => latestOf(file, hash).denial;
140
145
 
141
146
  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
147
 
143
148
  if (noChanges) refusals.push({ code: 'no_changes', detail: `block ${block} changed no owned file and has no reviews` });
144
149
 
150
+ const rulings = signedOf('review.late_ruling');
151
+ /** @param {Refusal} refusal @param {string} finding @param {string | null} invalid @returns {Refusal} */
152
+ const orInvalid = (refusal, finding, invalid) =>
153
+ invalid === null ? refusal : { code: 'autopilot_waiver_invalid', file: refusal.file, finding, detail: `the autopilot waiver of ${finding} in ${refusal.file} does not count: ${invalid}` };
154
+
155
+ /** @type {WaivedAll[]} */
156
+ const waivedAll = [];
157
+
145
158
  /** @type {Array<{file: string, denial: Denial}>} */
146
159
  const stale = [];
147
160
  for (const { file, content_hash } of files) {
148
161
  const approvals = approvedFor(file, content_hash);
149
162
  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` });
163
+ if (approvals.length === 0) {
164
+ // B70: a review whose every open finding was waived counts as reviewed — never as approved
165
+ const all = waivedAllOf(file, content_hash);
166
+ if (all !== null) waivedAll.push(all);
167
+ else {
168
+ // B73: a stopped file (a cap row of the block) names its ways out
169
+ const exits = mine.some((r) => r.event === 'review.cap' && r.file === file) ? `; ${stopExitsLine}` : '';
170
+ refusals.push({ code: 'unreviewed', file, detail: `no review.approved row for ${file} at its current content${exits}` });
171
+ }
172
+ } else if (!signedApproval) refusals.push({ code: 'unsigned', file, detail: `the review.approved row for ${file} has no valid MAC` });
152
173
  if (signedApproval) {
153
174
  const denial = staleBy(file, content_hash);
154
175
  if (denial !== null) stale.push({ file, denial });
@@ -167,29 +188,6 @@ export function checkBlockReviews({ block, runId, files, rows, key, transcript =
167
188
  for (const hit of scanTranscript(transcript, mergeForbidden(extraTokens))) refusals.push({ code: 'rule_break', detail: `transcript line ${hit.line}: ${hit.id}` });
168
189
  }
169
190
 
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
191
  /** B52: a ruling clears a late finding only when it rules `nit` on one recorded warning or nit. @param {Record<string, any>} late */
194
192
  const ruledNit = (late) => (late.severity === 'warning' || late.severity === 'nit') && rulings.some((r) => matches(r, late.file, late.finding) && r.ruling === 'nit');
195
193
 
@@ -204,20 +202,15 @@ export function checkBlockReviews({ block, runId, files, rows, key, transcript =
204
202
  notApproved.add(`${file}\0${finding}`);
205
203
  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
204
  // 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));
205
+ const w = waiverOf(file, finding, severity);
206
+ if (w.by === null) refusals.push(orInvalid(refusal, finding, w.invalid));
215
207
  }
216
208
  }
217
209
 
218
210
  for (const late of mine.filter((r) => r.event === 'review.late_finding')) {
219
211
  if (ruledNit(late)) continue;
220
- const w = waiverFor(late.file, late.finding);
212
+ // the answer rule reads the severity the late row records: a missing or unknown one fails closed
213
+ const w = waiverFor(late.file, late.finding, typeof late.severity === 'string' ? late.severity : 'unknown');
221
214
  if (w.waived) continue;
222
215
  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
216
  }
@@ -231,26 +224,311 @@ export function checkBlockReviews({ block, runId, files, rows, key, transcript =
231
224
  }
232
225
  for (const finding of Array.isArray(cap.open) ? cap.open : []) {
233
226
  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));
227
+ const w = waiverFor(cap.file, finding, hash === undefined ? 'unknown' : recordedRefusalSeverity(cap.file, hash, finding));
228
+ 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
229
  }
237
230
  }
238
231
  for (const r of checkBlockProof({ runId, files: proofFiles, rows: mine, key, highPaths })) {
239
- const w = waiverFor(/** @type {string} */ (r.file), PROOF_FINDING);
232
+ const w = waiverFor(/** @type {string} */ (r.file), PROOF_FINDING, 'unknown');
240
233
  if (!w.waived) refusals.push(orInvalid(r, PROOF_FINDING, w.invalid));
241
234
  }
242
- return { ok: refusals.length === 0, refusals };
235
+ return { ok: refusals.length === 0, refusals, waivedAll: refusals.length === 0 ? waivedAll : [] };
236
+ }
237
+
238
+ /** B71: the signed row the worker writes for a finding a reviewer judged resolved by another owned file. */
239
+ export const ANSWERED_EVENT = 'review.answered';
240
+
241
+ /**
242
+ * B71: the severities a signed answer clears — a critical too (a reviewer judged the fix; it is
243
+ * not a waiver) and an explicit null (the row records none). An unknown severity, `undefined` or
244
+ * any other value is never cleared by an answer (fail closed).
245
+ * @param {unknown} severity @returns {boolean}
246
+ */
247
+ export function answerableSeverity(severity) {
248
+ return severity === null || severity === 'critical' || severity === 'warning' || severity === 'nit';
249
+ }
250
+
251
+ /**
252
+ * THE review rules of one block (B52, B70, B71), shared by the close ({@link checkBlockReviews})
253
+ * and the worker's answers check ({@link answerableFindings}) so both read a finding the same way.
254
+ * `current`: the current content hash per file — an answer counts only while BOTH its hashes are
255
+ * there and equal (a file missing from it fails closed).
256
+ * @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
257
+ */
258
+ function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verify }) {
259
+ // B71: `verify` replaces the MAC check only for `review-file`'s unverified pre-check ({@link
260
+ // namedOnHash}), which has no key and can only refuse; it then reads no autopilot waiver
261
+ const ok = verify ?? ((/** @type {Record<string, any>} */ r) => verified(r, key));
262
+ const mine = rows.filter((r) => r?.block === block && (runId === undefined || r.run === undefined || r.run === runId));
263
+ /**
264
+ * B52: the index of the last signed approval and the latest refusal AFTER it for (file, hash);
265
+ * `denial` is null when that approval stands (or there is none — `unreviewed` / `unsigned` say
266
+ * so). A refusal row with no `content_hash` counts for every hash of its file (fail closed).
267
+ * `latest` is the latest refusal for (file, hash) whether or not an approval exists, `latestRow`
268
+ * the row it came from.
269
+ * @param {string} file @param {string} hash
270
+ * @returns {{lastApproval: number, denial: Denial | null, latest: Denial | null, latestRow: Record<string, any> | null}}
271
+ */
272
+ const latestOf = (file, hash) => {
273
+ let lastApproval = -1;
274
+ /** @type {Denial | null} */
275
+ let denial = null;
276
+ let lastDenial = -1;
277
+ /** @type {Record<string, any> | null} */
278
+ let latestRow = null;
279
+ mine.forEach((r, i) => {
280
+ if (r.file !== file) return;
281
+ if (r.event === 'review.approved') {
282
+ if (r.content_hash === hash && ok(r)) lastApproval = i;
283
+ return;
284
+ }
285
+ if (r.content_hash !== hash && r.content_hash !== undefined) return;
286
+ const d = denialOf(r, resultFiles);
287
+ if (d !== null) {
288
+ denial = d;
289
+ lastDenial = i;
290
+ latestRow = r;
291
+ }
292
+ });
293
+ return { lastApproval, denial: lastApproval >= 0 && lastDenial > lastApproval ? denial : null, latest: denial, latestRow };
294
+ };
295
+
296
+ const signedOf = (/** @type {string} */ event) => mine.filter((r) => r.event === event && ok(r));
297
+ const waivers = signedOf('review.waived').filter((r) => r.by === 'human');
298
+ const autopilotWaivers = mine.filter((r) => r.event === 'review.waived' && r.by === 'autopilot');
299
+ // B71: only the worker's signed rows of THIS run (a row with no run never counts)
300
+ const answers = signedOf(ANSWERED_EVENT).filter((r) => r.run === runId);
301
+ const runRows = rows.filter((r) => r?.run === runId);
302
+ const matches = (/** @type {Record<string, any>} */ r, /** @type {string} */ file, /** @type {string} */ finding) => r.finding === finding && r.file === file;
303
+ /**
304
+ * B71: whether a signed `review.answered` row clears `finding` of `file`: never `proof`, only an
305
+ * {@link answerableSeverity}, and only while the source hash (`content_hash`) and the answering
306
+ * file's hash (`by_hash`) both equal their CURRENT hashes — a change to either makes it stale.
307
+ * `exceptTicket`: the rows of that answers ticket are left out (its own retry, {@link openAt}).
308
+ * A proof finding is cleared by its proof (`proof` rows), never by a reviewer's answer.
309
+ * @param {string} file @param {string} finding @param {unknown} severity @param {string | null} [exceptTicket]
310
+ * @returns {boolean}
311
+ */
312
+ const answered = (file, finding, severity, exceptTicket = null) => {
313
+ if (finding === PROOF_FINDING || !answerableSeverity(severity)) return false;
314
+ return answerRows(file).some((r) => r.finding === finding && (exceptTicket === null || r.ticket !== exceptTicket));
315
+ };
316
+ /**
317
+ * B71: the signed `review.answered` rows of `file` still current (both hashes), any finding.
318
+ * @param {string} file @returns {Array<Record<string, any>>}
319
+ */
320
+ const answerRows = (file) => {
321
+ const now = current.get(file);
322
+ if (now === undefined) return [];
323
+ 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));
324
+ };
325
+ /**
326
+ * THE clearing rule: who clears the finding — `human` (a signed human waiver), `answer` (B71: a
327
+ * signed answer of the worker, {@link answered}), `autopilot` (an autopilot waiver its grant
328
+ * covered, {@link autopilotWaiverProblem}) or null, in that order. `severity`: the one a refusal
329
+ * row RECORDS for it — REQUIRED: only an explicit null (none recorded there; the signed review
330
+ * rows decide, {@link autopilotWaiverProblem}) or warning/nit may be delegated; anything else
331
+ * (`undefined`, a critical, an unknown value) is the human's alone ({@link delegableSeverity}),
332
+ * except that an answer also clears a critical. `invalid`: why the autopilot waivers naming it do
333
+ * not count (null when none names it).
334
+ * `exceptTicket`: see {@link answered}. `answerSeverity`: the severity the answer rule reads
335
+ * (default `severity`); the late-finding, cap and proof loops pass the one RECORDED for the id,
336
+ * so an unknown severity is never cleared by an answer even where the autopilot rule reads null.
337
+ * @param {string} file @param {string} finding @param {unknown} severity @param {string | null} [exceptTicket] @param {unknown} [answerSeverity]
338
+ * @returns {{by: 'human' | 'answer' | 'autopilot' | null, invalid: string | null}}
339
+ */
340
+ const waiverOf = (file, finding, severity, exceptTicket = null, answerSeverity = severity) => {
341
+ if (waivers.some((r) => matches(r, file, finding))) return { by: 'human', invalid: null };
342
+ if (answered(file, finding, answerSeverity, exceptTicket)) return { by: 'answer', invalid: null };
343
+ const named = autopilotWaivers.filter((r) => matches(r, file, finding));
344
+ // the unverified pre-check (`verify` given) reads no autopilot waiver: it cannot check a grant
345
+ if (named.length === 0 || verify !== undefined) return { by: null, invalid: null };
346
+ if (!delegableSeverity(severity)) return { by: null, invalid: `the latest review records it ${severity}; only the human waives it` };
347
+ const problems = named.map((w) => autopilotWaiverProblem(w, { blockRows: mine, runRows, allRows: rows, key: /** @type {Buffer} */ (key) }));
348
+ if (problems.some((p) => p === null)) return { by: 'autopilot', invalid: null };
349
+ return { by: null, invalid: /** @type {string} */ (problems[0]) };
350
+ };
351
+ /**
352
+ * Whether the finding is cleared: a signed human waiver, a valid answer, or an autopilot waiver
353
+ * its grant covered. `answerSeverity`: the severity recorded for the id (REQUIRED: the answer
354
+ * rule fails closed on anything {@link answerableSeverity} refuses).
355
+ * @param {string} file @param {string} finding @param {unknown} answerSeverity @returns {{waived: boolean, invalid: string | null}}
356
+ */
357
+ const waiverFor = (file, finding, answerSeverity) => {
358
+ const w = waiverOf(file, finding, null, null, answerSeverity);
359
+ return { waived: w.by !== null, invalid: w.invalid };
360
+ };
361
+
362
+ /**
363
+ * B70: the worst severity any signed hash-bound refusal row of `file` at `hash` records for
364
+ * `finding` (a cap row records none), or `unknown` when none does — never delegable.
365
+ * @param {string} file @param {string} hash @param {string} finding @returns {string}
366
+ */
367
+ const recordedRefusalSeverity = (file, hash, finding) => {
368
+ let worst = '';
369
+ for (const r of mine) {
370
+ if (r.file !== file || r.content_hash !== hash || !ok(r)) continue;
371
+ for (const o of denialOf(r, resultFiles)?.open ?? []) {
372
+ if (o.id !== finding || o.severity === null) continue;
373
+ // 'unknown' is itself an OPEN_RANK key (ranked above critical), so any value outside it
374
+ // maps to 'unknown' and wins: fail closed
375
+ const sev = Object.hasOwn(OPEN_RANK, o.severity) ? o.severity : 'unknown';
376
+ if (worst === '' || /** @type {Record<string, number>} */ (OPEN_RANK)[sev] > /** @type {Record<string, number>} */ (OPEN_RANK)[worst]) worst = sev;
377
+ }
378
+ }
379
+ return worst === '' ? 'unknown' : worst;
380
+ };
381
+
382
+ /**
383
+ * B79: the ids every signed `review.cap` row for (file, hash) names. A cap row records no
384
+ * severity, so the rules read each of these ids at {@link recordedRefusalSeverity} — also when
385
+ * that cap row is the latest refusal (its own null never stands for them).
386
+ * @param {string} file @param {string} hash @returns {Set<string>}
387
+ */
388
+ const capIdsAt = (file, hash) => {
389
+ /** @type {Set<string>} */
390
+ const ids = new Set();
391
+ for (const cap of mine) {
392
+ if (cap.event !== 'review.cap' || cap.file !== file || cap.content_hash !== hash || !Array.isArray(cap.open) || !ok(cap)) continue;
393
+ for (const id of cap.open) if (typeof id === 'string' && id.length > 0) ids.add(id);
394
+ }
395
+ return ids;
396
+ };
397
+
398
+ /**
399
+ * B70: the findings a real review of (file, hash) left open, with the severity each is read at,
400
+ * or null when no such review qualifies. All of: a signed `review.round` row for (file, hash) — a
401
+ * session reviewed these bytes; the latest refusal row for (file, hash) is signed, carries
402
+ * `content_hash: hash` (a hash-less one never qualifies) and names at least one finding. The ids
403
+ * are every finding it names and every open id of the file's `review.cap` rows. Severities: an id
404
+ * a signed `review.cap` row at (file, hash) names ({@link capIdsAt}) — also when that cap row is
405
+ * the latest refusal — and any other open cap id use {@link recordedRefusalSeverity}, whose
406
+ * `unknown` (no signed refusal at `hash` records one) is the human's alone and never answered.
407
+ * Any other id the latest refusal names uses the severity THAT row records. A cap row whose
408
+ * `open` is not an array of non-empty strings (the fix loop's shape) fails closed: null.
409
+ * @param {string} file @param {string} hash
410
+ * @returns {Map<string, unknown> | null}
411
+ */
412
+ const reviewedOpen = (file, hash) => {
413
+ if (!mine.some((r) => r.event === 'review.round' && r.file === file && r.content_hash === hash && ok(r))) return null;
414
+ const { latest, latestRow } = latestOf(file, hash);
415
+ if (latest === null || latestRow === null || latestRow.content_hash !== hash || !ok(latestRow) || latest.open.length === 0) return null;
416
+ /** @type {Map<string, unknown>} */
417
+ const named = new Map(latest.open.map((f) => [f.id, f.severity]));
418
+ for (const id of capIdsAt(file, hash)) named.set(id, recordedRefusalSeverity(file, hash, id));
419
+ for (const cap of mine.filter((r) => r.event === 'review.cap' && r.file === file)) {
420
+ if (!Array.isArray(cap.open) || !cap.open.every((/** @type {unknown} */ id) => typeof id === 'string' && id.length > 0)) return null;
421
+ for (const id of cap.open) if (!named.has(id)) named.set(id, recordedRefusalSeverity(file, hash, id));
422
+ }
423
+ return named;
424
+ };
425
+
426
+ /**
427
+ * B70: a file with no approval for its current hash that counts as reviewed because everything a
428
+ * real review of these bytes left open ({@link reviewedOpen}) is cleared by {@link waiverOf} —
429
+ * waived by the human or a covered autopilot grant, or (B71) answered — or null. `findings` are
430
+ * the waived ids, `answered` the answered ones (only when there is one); `by` is `human` when
431
+ * every clearing waiver is the human's, `autopilot` when one is the autopilot's, and null when
432
+ * every id was answered.
433
+ * @param {string} file @param {string} hash
434
+ * @returns {WaivedAll | null}
435
+ */
436
+ const waivedAllOf = (file, hash) => {
437
+ const named = reviewedOpen(file, hash);
438
+ if (named === null) return null;
439
+ /** @type {string[]} */
440
+ const waived = [];
441
+ /** @type {string[]} */
442
+ const answeredIds = [];
443
+ let byHuman = true;
444
+ for (const [finding, severity] of named) {
445
+ const w = waiverOf(file, finding, severity);
446
+ if (w.by === null) return null;
447
+ if (w.by === 'answer') answeredIds.push(finding);
448
+ else {
449
+ waived.push(finding);
450
+ if (w.by !== 'human') byHuman = false;
451
+ }
452
+ }
453
+ /** @type {'human' | 'autopilot' | null} */
454
+ const by = waived.length === 0 ? null : byHuman ? 'human' : 'autopilot';
455
+ return { file, content_hash: hash, status: 'reviewed_by_waiver', findings: waived.sort(), by, ...(answeredIds.length > 0 ? { answered: answeredIds.sort() } : {}) };
456
+ };
457
+
458
+ /**
459
+ * B71: the findings still open on `file` at `hash` an answer may name: every id the latest
460
+ * signed, hash-bound refusal row for (file, hash) names, and every open id of a signed `review.cap`
461
+ * row for (file, hash), that {@link waiverOf} does not clear yet (not waived, not answered), with
462
+ * the severity it is read at (a cap id's: {@link recordedRefusalSeverity}, B79). An id the close
463
+ * could never count as answered — the proof finding, or one read at a severity outside {@link answerableSeverity} (an unknown value, or a
464
+ * cap id no signed refusal at `hash` records) — is never open here, so it is refused before any
465
+ * session. `ticket`: that answers ticket's own rows do not close an id (a retry after a partial
466
+ * write finishes instead of being refused).
467
+ * @param {string} file @param {string} hash @param {string | null} [ticket]
468
+ * @returns {Map<string, unknown>}
469
+ */
470
+ const openAt = (file, hash, ticket = null) => {
471
+ /** @type {Map<string, unknown>} */
472
+ const named = new Map();
473
+ const { latest, latestRow } = latestOf(file, hash);
474
+ if (latest !== null && latestRow !== null && latestRow.content_hash === hash && ok(latestRow)) for (const f of latest.open) named.set(f.id, f.severity);
475
+ // B79: a cap id is read at its recorded severity even when the cap row is the latest refusal
476
+ for (const id of capIdsAt(file, hash)) named.set(id, recordedRefusalSeverity(file, hash, id));
477
+ return new Map([...named].filter(([id, severity]) => id !== PROOF_FINDING && answerableSeverity(severity) && waiverOf(file, id, severity, ticket).by === null));
478
+ };
479
+
480
+ return { mine, latestOf, signedOf, matches, waiverOf, waiverFor, waivedAllOf, openAt, answerRows, recordedRefusalSeverity };
481
+ }
482
+
483
+ /**
484
+ * B71: the findings of `file` at its current hash `hash` that an answer may still clear (the
485
+ * worker's check, the one that counts): open on that hash by the signed rows and not waived or
486
+ * answered yet — read by the SAME rules as the close ({@link reviewRules}). `current`: the current
487
+ * hash per file (the source's and the answering file's). Keyed by id, with the severity read.
488
+ * `ticket`: the asking ticket's own rows do not close an id ({@link reviewRules} `openAt`).
489
+ * @param {{block: string, runId: string, rows: Array<Record<string, any>>, key: Buffer, file: string, hash: string, current: Map<string, string>, ticket?: string | null}} opts
490
+ * @returns {Map<string, unknown>}
491
+ */
492
+ export function answerableFindings({ block, runId, rows, key, file, hash, current, ticket = null }) {
493
+ return reviewRules({ block, runId, rows, key, current }).openAt(file, hash, ticket);
494
+ }
495
+
496
+ /**
497
+ * B71: the findings of `file` the answers ticket `ticket` already answered by a signed row that is
498
+ * still current (a retry writes no second row for them).
499
+ * @param {{block: string, runId: string, rows: Array<Record<string, any>>, key: Buffer, file: string, current: Map<string, string>, ticket: string}} opts
500
+ * @returns {Set<string>}
501
+ */
502
+ export function ticketAnswered({ block, runId, rows, key, file, current, ticket }) {
503
+ return new Set(reviewRules({ block, runId, rows, key, current }).answerRows(file).filter((r) => r.ticket === ticket).map((r) => r.finding));
504
+ }
505
+
506
+ /**
507
+ * B71: the coder-side pre-check of `review-file --answers` (fast refusal only): the same rules as
508
+ * {@link answerableFindings} ({@link reviewRules}) over rows `review-file` cannot verify (it has no
509
+ * run key), so no MAC is checked and no autopilot waiver is read. It can only refuse: a forged row
510
+ * here at most refuses the coder's own request, and the worker re-checks with the signed rows.
511
+ * @param {{block: string, runId: string, rows: Array<Record<string, any>>, file: string, hash: string, current: Map<string, string>, ticket?: string | null}} opts
512
+ * @returns {Set<string>} the ids still open on (`file`, `hash`).
513
+ */
514
+ export function namedOnHash({ block, runId, rows, file, hash, current, ticket = null }) {
515
+ return new Set(reviewRules({ block, runId, rows, key: null, current, verify: () => true }).openAt(file, hash, ticket).keys());
243
516
  }
244
517
 
245
518
  /**
246
519
  * B52: the `review.result` outcomes that never reviewed the content, and so are no verdict on it,
247
520
  * whatever they carry: `stale` (the file moved before the review), `unavailable` (no session
248
521
  * 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.
522
+ * run's). B71: `answers` too — an answers ticket judged findings of the file, it never reviewed
523
+ * the file (its resolved ids are `review.answered` rows; the rest stay open as they were). Every
524
+ * other status — `reviewed`, `stopped`, `refused` for any other reason, `no_change`,
525
+ * `split_required`, … — is a verdict, and anything but `approved: true` refuses.
251
526
  * @param {Record<string, any>} r @returns {boolean}
252
527
  */
253
- export const neverReviewed = (r) => r.status === 'stale' || r.status === 'unavailable' || (r.status === 'refused' && r.reason === 'other-run');
528
+ export const neverReviewed = (r) => r.status === 'stale' || r.status === 'unavailable' || r.status === ANSWERS_STATUS || (r.status === 'refused' && r.reason === 'other-run');
529
+
530
+ /** B71: the status of every answers-ticket result that is not `stale` or `unavailable`. */
531
+ export const ANSWERS_STATUS = 'answers';
254
532
 
255
533
  /**
256
534
  * @typedef {{event: string, open: Array<{id: string, severity: string | null}>}} Denial
@@ -361,6 +639,17 @@ export async function resultFilesFor({ repoRoot, runId, block, rows }) {
361
639
  /** The finding severities autopilot may waive (B47); anything else is never delegated. */
362
640
  export const DELEGABLE_SEVERITIES = Object.freeze(['warning', 'nit']);
363
641
 
642
+ /**
643
+ * Whether a finding with the severity a refusal row records may be cleared by an autopilot waiver
644
+ * at all (B70, fail closed): an explicit null (the row records none — the signed review rows then
645
+ * decide, {@link autopilotWaiverProblem}) or warning/nit. `undefined`, a critical, an unknown
646
+ * severity or any other value is the human's alone.
647
+ * @param {unknown} severity @returns {boolean}
648
+ */
649
+ export function delegableSeverity(severity) {
650
+ return severity === null || (typeof severity === 'string' && DELEGABLE_SEVERITIES.includes(severity));
651
+ }
652
+
364
653
  /** Severity order for {@link recordedSeverity}: the worst recorded one wins. */
365
654
  const SEVERITY_RANK = Object.freeze({ nit: 0, warning: 1, critical: 2, blocker: 3 });
366
655
 
@@ -382,7 +671,8 @@ export function recordedSeverity(rows, { block, file, finding, key }) {
382
671
  if (r?.block !== block || r.file !== file) continue;
383
672
  if ((r.event === 'review.triage' || r.event === 'review.late_finding') && r.finding === finding) named.push({ row: r, severity: r.severity });
384
673
  // B52: the worker's review.result rows record the open findings' severities too
385
- else if (r.event === 'review.result' && Array.isArray(r.open)) {
674
+ // (B71: an answers result records no severity: it judged findings, it never reviewed the file)
675
+ else if (r.event === 'review.result' && r.status !== ANSWERS_STATUS && Array.isArray(r.open)) {
386
676
  for (const o of r.open) if (o?.id === finding) named.push({ row: r, severity: o.severity });
387
677
  }
388
678
  }
@@ -739,14 +1029,22 @@ function isOnDisk(repoRoot, file) {
739
1029
  }
740
1030
 
741
1031
  /**
742
- * A `closeBlock` extra check (B8's `extraChecks` seam): refusals joined into one reason.
1032
+ * A `closeBlock` extra check (B8's `extraChecks` seam): refusals joined into one reason. B70: on a
1033
+ * pass, `onWaivedAll` (when given) receives the files that passed as `reviewed_by_waiver` (empty
1034
+ * when none). It only buffers that list: the close's `gate.review_waived_all` rows for it are
1035
+ * signed and written by `closeBlock` after EVERY check passed, just before `block.close` — a
1036
+ * refused close writes none.
743
1037
  * @param {() => Promise<Parameters<typeof checkBlockReviews>[0]>} load
1038
+ * @param {{onWaivedAll?: (files: WaivedAll[]) => void}} [hooks]
744
1039
  * @returns {() => Promise<{ok: boolean, reason?: string}>}
745
1040
  */
746
- export function reviewGateCheck(load) {
1041
+ export function reviewGateCheck(load, { onWaivedAll } = {}) {
747
1042
  return async () => {
748
1043
  const result = checkBlockReviews(await load());
749
- if (result.ok) return { ok: true };
1044
+ if (result.ok) {
1045
+ onWaivedAll?.(result.waivedAll);
1046
+ return { ok: true };
1047
+ }
750
1048
  return { ok: false, reason: result.refusals.map((r) => `${r.code}${r.file ? ` ${r.file}` : ''}${r.finding ? ` ${r.finding}` : ''}`).join('; ') };
751
1049
  };
752
1050
  }
@@ -758,10 +1056,10 @@ export function reviewGateCheck(load) {
758
1056
  * @returns {Promise<Record<string, any>>} the signed row.
759
1057
  */
760
1058
  export async function waiveFinding({ runId, id, finding, reason, file, writeRow }) {
761
- if (typeof finding !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/.test(finding)) throw new StateError('usage', 'a finding id is required (letters, digits, . _ : -)');
1059
+ checkFindingId(finding);
762
1060
  if (typeof reason !== 'string' || reason.trim().length === 0) throw new StateError('usage', 'block waive needs --reason');
763
1061
  if (typeof file !== 'string' || file.length === 0) throw new StateError('usage', 'block waive needs --file <path>');
764
- if (path.isAbsolute(file) || file.split('/').includes('..') || file.startsWith('./')) throw new StateError('usage', '--file must be repo-root-relative');
1062
+ checkFileForm(file, { normalized: false });
765
1063
  const record = await readRun(runId);
766
1064
  if (!record.blocks?.[id]) throw new StateError('unknown_block', `block ${id} is not in run ${runId}`);
767
1065
  const row = { event: 'review.waived', block: id, file, finding, reason: reason.trim(), by: 'human' };
@@ -20,3 +20,4 @@ You work blind: you do not see the other reviewer's answer.
20
20
  - Every finding names `file`, `line_start`, `line_end` (line numbers of the CURRENT file, as numbered in the context), `severity` (`critical` = wrong behaviour, data loss, security; `warning` = a real defect that is not critical; `nit` = style or preference), a short `category`, the `claim`, the `evidence` you read in the packet, and the `fix`. Each of `claim`, `evidence`, `fix` ≤ 300 chars.
21
21
  - `passed` is true only when you found no `critical` and no `warning`.
22
22
  - Do not invent a problem to look thorough. An empty `findings` list is a valid answer.
23
+ - A `## test-quality` section names lines of a test that reads a source file and asserts on its text. Check those lines: such a test is a `warning` with the claim "test reads source text; prove behaviour instead", unless the read file is a fixture. The section is a hint, not a finding.
@@ -21,3 +21,4 @@ You work blind: you do not see the other reviewer's answer.
21
21
  - Every finding names `file`, `line_start`, `line_end` (line numbers of the CURRENT file, as numbered in the context), `severity` (`critical` = wrong behaviour, data loss, security; `warning` = a real defect that is not critical; `nit` = style or preference), a short `category`, the `claim`, the `evidence` you read in the packet, and the `fix`. Each of `claim`, `evidence`, `fix` ≤ 300 chars.
22
22
  - `passed` is true only when you found no `critical` and no `warning`.
23
23
  - Do not invent a problem to look thorough. An empty `findings` list is a valid answer.
24
+ - A `## test-quality` section names lines of a test that reads a source file and asserts on its text. Check those lines: such a test is a `warning` with the claim "test reads source text; prove behaviour instead", unless the read file is a fixture. The section is a hint, not a finding.
@@ -21,3 +21,4 @@ Report each real defect once, at the lines where it lives.
21
21
  - Every finding names `file`, `line_start`, `line_end` (line numbers of the CURRENT file, as numbered in the context), `severity` (`critical` = wrong behaviour, data loss, security; `warning` = a real defect that is not critical; `nit` = style or preference), a short `category`, the `claim`, the `evidence` you read in the packet, and the `fix`. Each of `claim`, `evidence`, `fix` ≤ 300 chars.
22
22
  - `passed` is true only when you found no `critical` and no `warning`.
23
23
  - Do not invent a problem to look thorough. An empty `findings` list is a valid answer.
24
+ - A `## test-quality` section names lines of a test that reads a source file and asserts on its text. Check those lines: such a test is a `warning` with the claim "test reads source text; prove behaviour instead", unless the read file is a fixture. The section is a hint, not a finding.
@@ -19,3 +19,4 @@ Skip style, naming and structure unless it hides a bug. Keep `findings` short.
19
19
  - Every finding names `file`, `line_start`, `line_end` (line numbers of the CURRENT file, as numbered in the context), `severity` (`critical` = wrong behaviour, data loss, security; `warning` = a real defect that is not critical; `nit` = style or preference), a short `category`, the `claim`, the `evidence` you read in the packet, and the `fix`. Each of `claim`, `evidence`, `fix` ≤ 300 chars.
20
20
  - `passed` is true only when you found no `critical` and no `warning`.
21
21
  - Do not invent a problem to look thorough. An empty `findings` list is a valid answer.
22
+ - A `## test-quality` section names lines of a test that reads a source file and asserts on its text. Check those lines: such a test is a `warning` with the claim "test reads source text; prove behaviour instead", unless the read file is a fixture. The section is a hint, not a finding.
@@ -21,3 +21,4 @@ that changed since that round, each with a window of the current file around it.
21
21
  - You have no tools and no project files. The packet is everything you get.
22
22
  - `reviewed_hunks`: copy every `@@ … @@` hunk header listed under "hunks" in the packet, exactly and in the same order.
23
23
  - Answer with ONE JSON object that matches the answer schema.
24
+ - A `## test-quality` section names lines of a test that reads a source file and asserts on its text. Check those lines: such a test is a `warning` with the claim "test reads source text; prove behaviour instead", unless the read file is a fixture. The section is a hint, not a finding.