@codedology/code-forge 0.6.2 → 0.7.1

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.
@@ -1,16 +1,43 @@
1
1
  /**
2
2
  * The block gate's review rows (plan §4.6 rows 2, 9 and 11; §4.9 `block waive`; §4.11; block B12b).
3
3
  *
4
- * `checkBlockReviews` refuses the block when:
4
+ * `checkBlockReviews` refuses the block when (the file set is the close's: the diff since base ∪
5
+ * every owned file with review rows of the block, {@link reviewedFiles}, owned through
6
+ * {@link ownsPath}):
7
+ * - `no_changes`: (B52) that set is empty — the block changed no owned file and has no reviews;
8
+ * a close that checked nothing is never a pass (`--no-require-reviews` is the owner's way out);
9
+ * - `unowned_change`: (B52) a changed or new file outside the block's `owned_files` that no other
10
+ * block of the run accounts for ({@link unownedChanges}: it is as it was when the block opened,
11
+ * another block of the run that is still OPEN owns it — the gate cannot tell which coder wrote
12
+ * it, so the close WARNs naming each such file — or a closed one owns it and its gate approved
13
+ * exactly the current content);
5
14
  * - `unreviewed`: a changed or new file has NO `review.approved` row for its CURRENT content hash;
6
15
  * - `unsigned`: the file's approvals for that hash exist but none carries a valid MAC;
16
+ * - `not_approved`: (B52) the file's signed approval for its current hash is STALE — a later row of
17
+ * the block (ledger order) for the same file and hash refuses it ({@link denialOf}: a
18
+ * `review.result` with `approved: false` whatever its status, except the outcomes that never
19
+ * reviewed the content, {@link neverReviewed}; a `review.round` with `open_after` not 0; a
20
+ * `review.cap`; a refusal row with no `content_hash` counts for every hash of its file). One
21
+ * refusal per open finding the latest such row names, each cleared only by a valid waiver for
22
+ * the severity that row RECORDS (anything but warning/nit — a critical — only by a signed
23
+ * `by: human` waiver); a row that names no finding is one `not_approved <file>` that only a
24
+ * fresh approval clears. An old ledger's `review.result` row has no `approved` field and is
25
+ * trusted neither way: the ticket's signed result file decides ({@link resultFilesFor}); a
26
+ * file that is missing or fails its MAC refuses `not_approved <file>`. Refusal rows count
27
+ * whether or not they are signed (fail closed); approvals only when signed. The check is per
28
+ * content hash: an approval of the current bytes is not made stale by a review of other bytes
29
+ * (a file edited and then put back);
7
30
  * - `rule_break`: a `rule_break` row names the block, or the coder transcript hits the forbidden
8
31
  * list (`scanTranscript`; a read of `~/.code-forge/runs/` — the signer key — is the named case);
9
32
  * - `late_unruled`: a `review.late_finding` has neither a signed `review.late_ruling` (the
10
- * block-close sweep's L3 ruling) nor a signed `review.waived` row;
33
+ * block-close sweep's L3 ruling) nor a signed `review.waived` row. B52: a ruling clears it only
34
+ * when it rules `nit` AND the late finding is recorded `warning` or `nit` — a `fix_now` ruling
35
+ * clears nothing, and a critical (or unrecorded severity) late finding needs a waiver;
11
36
  * - `review_cap`: a file stopped at `review_cap` has an open finding with no signed
12
- * `review.waived {by: human}` row — unless the file has a signed approval at its current hash
13
- * (the human fixed it by hand and it was reviewed);
37
+ * `review.waived {by: human}` row — unless a signed approval of the file's current hash comes
38
+ * AFTER the cap row (ledger order) and no later refusal row made it stale (the human fixed it
39
+ * by hand and it was reviewed); a finding already refused as `not_approved` for that file is
40
+ * not repeated;
14
41
  * - `unproven`: a changed owned file of tier `high` (B20, Ricardo 2026-09-25) has no covering
15
42
  * red→green proof (`checkBlockProof`, below). Light-tier files need no proof row.
16
43
  * Rulings and waivers only count when their MAC verifies AND name the finding's file exactly
@@ -20,7 +47,7 @@
20
47
  * B47 (autopilot): a `review.waived {by: autopilot}` row (written by `autopilot waive`) clears a
21
48
  * finding ONLY when {@link autopilotWaiverProblem} finds nothing wrong with it: its MAC verifies;
22
49
  * the finding is not `proof`; the finding's severity recorded in the block's signed review rows
23
- * (`review.triage`, `review.late_finding`; {@link recordedSeverity}) is `warning` or `nit` and
50
+ * (`review.triage`, `review.late_finding`, B52: `review.result.open`; {@link recordedSeverity}) is `warning` or `nit` and
24
51
  * equals the waiver's `severity`; a signed `autopilot.grant` row of this run with the waiver's
25
52
  * `grant_id` exists, its window (`ts` ≤ waiver `ts` < `until`) holds the waiver, and its scopes
26
53
  * include `waive:<severity>`; no `autopilot.stop` / `autopilot.expire` row for that grant is
@@ -41,12 +68,15 @@ import { mergeForbidden, scanTranscript } from '../util/forbidden.mjs';
41
68
  import { exec } from '../util/exec.mjs';
42
69
  import { readRun, writeSigned } from '../state/run.mjs';
43
70
  import { StateError } from '../state/paths.mjs';
71
+ import { findOverlap } from '../state/registry.mjs';
72
+ import { sameStamp, treeStamp } from '../state/block.mjs';
44
73
  import { verifyRow } from '../state/signer.mjs';
45
74
  import { tierFor } from '../proof/tiers.mjs';
46
75
  import { DELETED_HASH, contentHash, gitChildEnv } from '../worker/ticket.mjs';
76
+ import { verifyResult } from '../worker/queue.mjs';
47
77
 
48
78
  /**
49
- * @typedef {{code: 'unreviewed' | 'unsigned' | 'rule_break' | 'late_unruled' | 'review_cap' | 'unproven' | 'autopilot_waiver_invalid', file?: string, finding?: string, detail?: string}} Refusal
79
+ * @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
50
80
  */
51
81
 
52
82
  /** @param {Record<string, any>} row @param {Buffer} key */
@@ -57,15 +87,19 @@ const verified = (row, key) => verifyRow(row, key).ok;
57
87
  * block: string, runId: string, files: Array<{file: string, content_hash: string}>,
58
88
  * rows: Array<Record<string, any>>, key: Buffer, transcript?: string | null,
59
89
  * extraTokens?: string[], proofFiles: Array<{file: string, content_hash: string}>,
60
- * highPaths: ReadonlyArray<string>,
61
- * }} opts `proofFiles` is the set the proof check reads — `--no-require-reviews` empties `files`
90
+ * highPaths: ReadonlyArray<string>, unowned?: ReadonlyArray<string>, resultFiles?: Record<string, ResultFile>, noChanges?: boolean,
91
+ * }} opts `noChanges` (B52): the block changed no owned file and no owned file has review rows —
92
+ * refused `no_changes`. `resultFiles` (B52): the signed result files of old-ledger `review.result` rows
93
+ * ({@link resultFilesFor}; a row whose file is absent here refuses). `unowned` (B52): the
94
+ * changed files no block accounts for ({@link unownedChanges}),
95
+ * refused even when `files` is empty. `proofFiles` is the set the proof check reads — `--no-require-reviews` empties `files`
62
96
  * but never the proof check; `highPaths` is `proof.tiers.high.paths`. Both are REQUIRED: a
63
97
  * caller that leaves one out gets a TypeError, never a silent default that skips the proof.
64
98
  * `runId` is required too: the proof check matches its rows on this run only (fix round 4).
65
99
  * @returns {{ok: boolean, refusals: Refusal[]}}
66
100
  * @throws {TypeError} `runId`, `proofFiles` or `highPaths` undefined
67
101
  */
68
- export function checkBlockReviews({ block, runId, files, rows, key, transcript = null, extraTokens = [], proofFiles, highPaths }) {
102
+ export function checkBlockReviews({ block, runId, files, rows, key, transcript = null, extraTokens = [], proofFiles, highPaths, unowned = [], resultFiles = {}, noChanges = false }) {
69
103
  if (runId === undefined) throw new TypeError('checkBlockReviews: runId is required (the proof check matches rows on this run only)');
70
104
  if (proofFiles === undefined) throw new TypeError('checkBlockReviews: proofFiles is required (the changed owned files the proof check reads)');
71
105
  if (highPaths === undefined) throw new TypeError('checkBlockReviews: highPaths is required (proof.tiers.high.paths)');
@@ -74,11 +108,58 @@ export function checkBlockReviews({ block, runId, files, rows, key, transcript =
74
108
  const refusals = [];
75
109
  const approvedFor = (/** @type {string} */ file, /** @type {string} */ hash) => mine.filter((r) => r.event === 'review.approved' && r.file === file && r.content_hash === hash);
76
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
+ const staleBy = (/** @type {string} */ file, /** @type {string} */ hash) => latestOf(file, hash).denial;
140
+
141
+ 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
+
143
+ if (noChanges) refusals.push({ code: 'no_changes', detail: `block ${block} changed no owned file and has no reviews` });
77
144
 
145
+ /** @type {Array<{file: string, denial: Denial}>} */
146
+ const stale = [];
78
147
  for (const { file, content_hash } of files) {
79
148
  const approvals = approvedFor(file, content_hash);
149
+ const signedApproval = approvals.some((r) => verified(r, key));
80
150
  if (approvals.length === 0) refusals.push({ code: 'unreviewed', file, detail: `no review.approved row for ${file} at its current content` });
81
- else if (!approvals.some((r) => verified(r, key))) refusals.push({ code: 'unsigned', file, detail: `the review.approved row for ${file} has no valid MAC` });
151
+ else if (!signedApproval) refusals.push({ code: 'unsigned', file, detail: `the review.approved row for ${file} has no valid MAC` });
152
+ if (signedApproval) {
153
+ const denial = staleBy(file, content_hash);
154
+ if (denial !== null) stale.push({ file, denial });
155
+ } else {
156
+ // B52: never approved — its latest review's open findings are named too (the file stays
157
+ // refused by `unreviewed` / `unsigned` whatever is waived: only an approval clears that)
158
+ // (a file stopped at the cap is named by the `review_cap` check below instead)
159
+ const latest = latestOf(file, content_hash).latest;
160
+ const capped = mine.some((r) => r.event === 'review.cap' && r.file === file);
161
+ if (latest !== null && latest.open.length > 0 && !capped) stale.push({ file, denial: latest });
162
+ }
82
163
  }
83
164
 
84
165
  for (const r of mine.filter((row) => row.event === 'rule_break')) refusals.push({ code: 'rule_break', detail: `rule_break row: ${String(r.id ?? r.rule ?? 'unnamed')}` });
@@ -109,8 +190,33 @@ export function checkBlockReviews({ block, runId, files, rows, key, transcript =
109
190
  const orInvalid = (refusal, finding, invalid) =>
110
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}` };
111
192
 
193
+ /** B52: a ruling clears a late finding only when it rules `nit` on one recorded warning or nit. @param {Record<string, any>} late */
194
+ const ruledNit = (late) => (late.severity === 'warning' || late.severity === 'nit') && rulings.some((r) => matches(r, late.file, late.finding) && r.ruling === 'nit');
195
+
196
+ /** @type {Set<string>} `file\0finding` pairs already refused as not_approved */
197
+ const notApproved = new Set();
198
+ for (const { file, denial } of stale) {
199
+ if (denial.open.length === 0) {
200
+ refusals.push({ code: 'not_approved', file, detail: `a later review of ${file}'s current content did not approve it (${denial.event}) and names no finding; review it again` });
201
+ continue;
202
+ }
203
+ for (const { id: finding, severity } of denial.open) {
204
+ notApproved.add(`${file}\0${finding}`);
205
+ 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
+ // 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));
215
+ }
216
+ }
217
+
112
218
  for (const late of mine.filter((r) => r.event === 'review.late_finding')) {
113
- if (rulings.some((r) => matches(r, late.file, late.finding))) continue;
219
+ if (ruledNit(late)) continue;
114
220
  const w = waiverFor(late.file, late.finding);
115
221
  if (w.waived) continue;
116
222
  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));
@@ -118,8 +224,13 @@ export function checkBlockReviews({ block, runId, files, rows, key, transcript =
118
224
 
119
225
  for (const cap of mine.filter((r) => r.event === 'review.cap')) {
120
226
  const hash = hashOf.get(cap.file);
121
- if (hash !== undefined && approvedFor(cap.file, hash).some((r) => verified(r, key))) continue;
227
+ // honoured unless a signed approval of the current content comes AFTER the cap and is not stale
228
+ if (hash !== undefined) {
229
+ const latest = latestOf(cap.file, hash);
230
+ if (latest.lastApproval > mine.indexOf(cap) && latest.denial === null) continue;
231
+ }
122
232
  for (const finding of Array.isArray(cap.open) ? cap.open : []) {
233
+ if (notApproved.has(`${cap.file}\0${finding}`)) continue;
123
234
  const w = waiverFor(cap.file, finding);
124
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));
125
236
  }
@@ -131,6 +242,122 @@ export function checkBlockReviews({ block, runId, files, rows, key, transcript =
131
242
  return { ok: refusals.length === 0, refusals };
132
243
  }
133
244
 
245
+ /**
246
+ * B52: the `review.result` outcomes that never reviewed the content, and so are no verdict on it,
247
+ * whatever they carry: `stale` (the file moved before the review), `unavailable` (no session
248
+ * 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.
251
+ * @param {Record<string, any>} r @returns {boolean}
252
+ */
253
+ export const neverReviewed = (r) => r.status === 'stale' || r.status === 'unavailable' || (r.status === 'refused' && r.reason === 'other-run');
254
+
255
+ /**
256
+ * @typedef {{event: string, open: Array<{id: string, severity: string | null}>}} Denial
257
+ * a refusal row: the findings it leaves open (empty = it names none: only a fresh approval
258
+ * clears it) with the severity it records (null = it records none; the triage rows decide).
259
+ * @typedef {{status: unknown, approved: unknown, findings: unknown} | null} ResultFile
260
+ * a ticket's signed result file as the gate read it — null when it is missing or its MAC fails.
261
+ */
262
+
263
+ /**
264
+ * B52: what a row says about a review of its (file, content_hash), or null when it is no refusal:
265
+ * - `review.result` (the worker's): no verdict when {@link neverReviewed} — those outcomes never
266
+ * reviewed the content, so they neither approve nor refuse it. `approved: true` with no
267
+ * critical in its `open` is no refusal (the approval itself is the `review.approved` row);
268
+ * `approved: true` whose `open` names a critical, and `approved: false`, refuse with every
269
+ * finding of `open`. A row WITHOUT `approved` (an old ledger) cannot be trusted either way: the
270
+ * ticket's signed result file (`resultFiles[ticket]`, read and MAC-checked by
271
+ * {@link resultFilesFor}) decides by the same rule. In particular a file that says `approved:
272
+ * true` and lists no critical is no refusal: that is the fix loop's normal approval — the
273
+ * remaining warnings and nits were accepted by the reviewer and triage (a critical never is,
274
+ * `triage.mjs`). A file that is missing, fails its MAC or names another status refuses with no
275
+ * finding (fail closed);
276
+ * - `review.round` (the fix loop's): `open_after` not 0, with no finding named;
277
+ * - `review.cap`: the file stopped; its `open` ids (severity from the triage rows).
278
+ * @param {Record<string, any>} r @param {Record<string, ResultFile>} [resultFiles]
279
+ * @returns {Denial | null}
280
+ */
281
+ export function denialOf(r, resultFiles = {}) {
282
+ if (r?.event === 'review.result') {
283
+ if (neverReviewed(r)) return null;
284
+ /** @param {unknown} approved @param {Array<{id: string, severity: string}>} open @returns {Denial | null} */
285
+ const judge = (approved, open) => (approved === true && !open.some((f) => f.severity === 'critical') ? null : { event: r.event, open });
286
+ if (r.approved === true || r.approved === false) return judge(r.approved, normalizeOpen(r.open));
287
+ const file = typeof r.ticket === 'string' && Object.hasOwn(resultFiles, r.ticket) ? resultFiles[r.ticket] : null;
288
+ if (!file || file.status !== r.status) return { event: r.event, open: [] };
289
+ return judge(file.approved, openFindings(file.findings));
290
+ }
291
+ if (r?.event === 'review.round') return r.open_after === 0 ? null : { event: r.event, open: [] };
292
+ if (r?.event === 'review.cap') {
293
+ const ids = Array.isArray(r.open) ? r.open.filter((/** @type {unknown} */ id) => typeof id === 'string' && id.length > 0) : [];
294
+ return { event: r.event, open: [...new Set(/** @type {string[]} */ (ids))].map((id) => ({ id, severity: null })) };
295
+ }
296
+ return null;
297
+ }
298
+
299
+ /**
300
+ * B52: a ledger row's `open` list as `{id, severity}` — an entry without a string id is kept
301
+ * under a synthetic id by position (`X<n>`) and an unknown severity as `unknown`, so nothing
302
+ * vanishes; repeated ids keep the worst severity.
303
+ * @param {unknown} open @returns {Array<{id: string, severity: string}>}
304
+ */
305
+ function normalizeOpen(open) {
306
+ return Array.isArray(open) ? openFindings(open) : [];
307
+ }
308
+
309
+ /** The finding id form `block waive` accepts. */
310
+ const FINDING_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/;
311
+ /** B52: severity order for {@link openFindings}; an unknown severity ranks above critical (fail closed). */
312
+ const OPEN_RANK = Object.freeze({ nit: 0, warning: 1, critical: 2, unknown: 3 });
313
+
314
+ /**
315
+ * B52: the open findings of a review as `{id, severity}`, for the ledger's `review.result` row
316
+ * (the worker) and the gate (`not_approved <file> <id>`). Nothing vanishes: a finding with a
317
+ * missing or malformed id (not the form `block waive` takes) is kept as `X<n>`, n its 1-based
318
+ * position in the list; a severity outside critical/warning/nit is `unknown` (only the human
319
+ * waives it). An id given more than once keeps its WORST severity (unknown > critical > warning >
320
+ * nit), at its first position.
321
+ * @param {unknown} findings @returns {Array<{id: string, severity: string}>}
322
+ */
323
+ export function openFindings(findings) {
324
+ if (!Array.isArray(findings)) return [];
325
+ /** @type {Map<string, string>} */
326
+ const out = new Map();
327
+ /** @type {Record<string, number>} */
328
+ const rank = OPEN_RANK;
329
+ findings.forEach((f, i) => {
330
+ const id = typeof f?.id === 'string' && FINDING_ID.test(f.id) ? f.id : `X${i + 1}`;
331
+ const severity = typeof f?.severity === 'string' && Object.hasOwn(rank, f.severity) && f.severity !== 'unknown' ? f.severity : 'unknown';
332
+ const had = out.get(id);
333
+ if (had === undefined || rank[severity] > rank[had]) out.set(id, severity);
334
+ });
335
+ return [...out].map(([id, severity]) => ({ id, severity }));
336
+ }
337
+
338
+ /**
339
+ * B52: the signed result files {@link denialOf} needs — one per `review.result` row of the block
340
+ * that has no `approved` field (an old ledger) and is a verdict — keyed by ticket. A file that is
341
+ * missing, unparsable, names another run or ticket, or fails its MAC is null.
342
+ * @param {{repoRoot: string, runId: string, block: string, rows: Array<Record<string, any>>}} opts
343
+ * `repoRoot`: the repository root the worker writes `.code-forge/reviews/` under.
344
+ * @returns {Promise<Record<string, ResultFile>>}
345
+ */
346
+ export async function resultFilesFor({ repoRoot, runId, block, rows }) {
347
+ /** @type {Record<string, ResultFile>} */
348
+ const out = {};
349
+ for (const r of rows) {
350
+ if (r?.event !== 'review.result' || r.block !== block || Object.hasOwn(r, 'approved') || neverReviewed(r) || typeof r.ticket !== 'string' || Object.hasOwn(out, r.ticket)) continue;
351
+ try {
352
+ const checked = await verifyResult(repoRoot, runId, r.ticket);
353
+ out[r.ticket] = checked.ok && checked.result ? { status: checked.result.status, approved: checked.result.approved, findings: checked.result.findings } : null;
354
+ } catch {
355
+ out[r.ticket] = null; // a malformed ticket id, an unreadable file: fail closed
356
+ }
357
+ }
358
+ return out;
359
+ }
360
+
134
361
  /** The finding severities autopilot may waive (B47); anything else is never delegated. */
135
362
  export const DELEGABLE_SEVERITIES = Object.freeze(['warning', 'nit']);
136
363
 
@@ -139,7 +366,8 @@ const SEVERITY_RANK = Object.freeze({ nit: 0, warning: 1, critical: 2, blocker:
139
366
 
140
367
  /**
141
368
  * The severity the block's signed review rows record for one finding of one file (B47): every
142
- * `review.triage` and `review.late_finding` row with that `block`, `file` and `finding`; the worst
369
+ * `review.triage` and `review.late_finding` row with that `block`, `file` and `finding`, and (B52)
370
+ * every `review.result` row whose `open` list names it (with the severity recorded there); the worst
143
371
  * one wins. Fails closed: no row ⇒ `not-recorded`; any such row whose MAC does not verify ⇒
144
372
  * `unverified`; a severity outside critical/blocker/warning/nit ⇒ `unknown` — each with
145
373
  * `severity: null`.
@@ -148,15 +376,24 @@ const SEVERITY_RANK = Object.freeze({ nit: 0, warning: 1, critical: 2, blocker:
148
376
  * @returns {{severity: string, why: null} | {severity: null, why: 'not-recorded' | 'unverified' | 'unknown'}}
149
377
  */
150
378
  export function recordedSeverity(rows, { block, file, finding, key }) {
151
- const named = rows.filter((r) => (r?.event === 'review.triage' || r?.event === 'review.late_finding') && r.block === block && r.file === file && r.finding === finding);
379
+ /** @type {Array<{row: Record<string, any>, severity: unknown}>} */
380
+ const named = [];
381
+ for (const r of rows) {
382
+ if (r?.block !== block || r.file !== file) continue;
383
+ if ((r.event === 'review.triage' || r.event === 'review.late_finding') && r.finding === finding) named.push({ row: r, severity: r.severity });
384
+ // B52: the worker's review.result rows record the open findings' severities too
385
+ else if (r.event === 'review.result' && Array.isArray(r.open)) {
386
+ for (const o of r.open) if (o?.id === finding) named.push({ row: r, severity: o.severity });
387
+ }
388
+ }
152
389
  if (named.length === 0) return { severity: null, why: 'not-recorded' };
153
- if (named.some((r) => !verified(r, key))) return { severity: null, why: 'unverified' };
390
+ if (named.some((n) => !verified(n.row, key))) return { severity: null, why: 'unverified' };
154
391
  /** @type {Record<string, number>} */
155
392
  const rank = SEVERITY_RANK;
156
393
  let worst = '';
157
- for (const r of named) {
158
- if (typeof r.severity !== 'string' || !Object.hasOwn(rank, r.severity)) return { severity: null, why: 'unknown' };
159
- if (worst === '' || rank[r.severity] > rank[worst]) worst = r.severity;
394
+ for (const { severity } of named) {
395
+ if (typeof severity !== 'string' || !Object.hasOwn(rank, severity)) return { severity: null, why: 'unknown' };
396
+ if (worst === '' || rank[severity] > rank[worst]) worst = severity;
160
397
  }
161
398
  return { severity: worst, why: null };
162
399
  }
@@ -330,10 +567,24 @@ export function repoRelativePath(raw) {
330
567
  * The block's file set (§4.1): changed tracked files since `base` (deletions included, hashed
331
568
  * `DELETED_HASH`) ∪ untracked files, filtered to `owned` (exact paths; a glob owned entry matches through `matchOwned`), each with its content hash.
332
569
  * Every path goes through `repoRelativePath`.
333
- * @param {{repoRoot: string, base: string, owned: string[], matchOwned?: (file: string) => boolean}} opts
570
+ * @param {{repoRoot: string, base: string, owned: string[], matchOwned?: (file: string) => boolean, changed?: string[]}} opts
571
+ * `changed`: the tree's changed files when the caller already listed them ({@link changedFiles}).
334
572
  * @returns {Promise<Array<{file: string, content_hash: string}>>}
335
573
  */
336
- export async function blockFileSet({ repoRoot, base, owned, matchOwned }) {
574
+ export async function blockFileSet({ repoRoot, base, owned, matchOwned, changed }) {
575
+ const all = changed ?? (await changedFiles({ repoRoot, base }));
576
+ const isOwned = matchOwned ?? ((/** @type {string} */ f) => owned.includes(f));
577
+ return all.filter((f) => isOwned(f)).map((file) => ({ file, content_hash: currentHash(repoRoot, file) }));
578
+ }
579
+
580
+ /**
581
+ * Every changed tracked file since `base` (deletions included) ∪ every untracked, not ignored
582
+ * file, through `repoRelativePath`, sorted — the set {@link blockFileSet} filters to the block.
583
+ * @param {{repoRoot: string, base: string}} opts
584
+ * @returns {Promise<string[]>}
585
+ * @throws {StateError} `bad-ref`, `git-failed`
586
+ */
587
+ export async function changedFiles({ repoRoot, base }) {
337
588
  if (typeof base !== 'string' || !/^[0-9a-f]{7,64}$/.test(base)) throw new StateError('bad-ref', 'the block base must be a commit id');
338
589
  const env = gitChildEnv();
339
590
  const changed = await exec(['git', 'diff', '--name-only', '-z', '--no-renames', base, '--'], { cwd: repoRoot, env, timeoutMs: 30000 });
@@ -342,11 +593,130 @@ export async function blockFileSet({ repoRoot, base, owned, matchOwned }) {
342
593
  const all = new Set(
343
594
  [...changed.stdout.split('\0'), ...untracked.stdout.split('\0')].filter((f) => f.length > 0).map((f) => repoRelativePath(f)),
344
595
  );
345
- const isOwned = matchOwned ?? ((/** @type {string} */ f) => owned.includes(f));
346
- return [...all]
347
- .filter((f) => isOwned(f))
348
- .sort()
349
- .map((file) => ({ file, content_hash: isOnDisk(repoRoot, file) ? contentHash(repoRoot, file) : DELETED_HASH }));
596
+ return [...all].sort();
597
+ }
598
+
599
+ /**
600
+ * B52: whether `owned` (a block's `owned_files`) covers `file` at the gate: the `findOverlap`
601
+ * match `block open` uses (exact paths and globs), and — what the field run needed — an entry with
602
+ * no glob character also covers every path below it as a directory (`src/feature` owns
603
+ * `src/feature/Fan.swift`). A wider match only ever puts more files under the gate's checks.
604
+ * @param {ReadonlyArray<string>} owned @param {string} file @returns {boolean}
605
+ */
606
+ export function ownsPath(owned, file) {
607
+ if (findOverlap(owned, [file]) !== null) return true;
608
+ return owned.some((o) => typeof o === 'string' && o.length > 0 && !/[*?[\]{}]/.test(o) && file.startsWith(`${o.replace(/\/+$/, '')}/`));
609
+ }
610
+
611
+ /**
612
+ * B52: {@link ownsPath} for the close's one path base, the repository top level: `file` is
613
+ * repo-root-relative (as git lists it and the worker signs it); an owned entry matches it as
614
+ * given, or — for a workspace in a subdirectory (`wsPrefix`, the workspace relative to the top;
615
+ * `''` when they are the same) — relative to the workspace. A file outside the workspace is
616
+ * matched as given only.
617
+ * @param {string} wsPrefix @returns {(owned: ReadonlyArray<string>, file: string) => boolean}
618
+ */
619
+ export function ownsAt(wsPrefix) {
620
+ const prefix = wsPrefix.replace(/\/+$/, '');
621
+ return (owned, file) => ownsPath(owned, file) || (prefix !== '' && prefix !== '.' && file.startsWith(`${prefix}/`) && ownsPath(owned, file.slice(prefix.length + 1)));
622
+ }
623
+
624
+ /** B52: the review rows that name a file the block's worker reviewed. */
625
+ const REVIEW_EVENTS = new Set(['review.result', 'review.round', 'review.approved', 'review.triage', 'review.cap', 'review.late_finding']);
626
+
627
+ /**
628
+ * B52: every file with ANY review row (`review.result`, `review.round`, `review.approved`,
629
+ * `review.triage`, `review.cap`, `review.late_finding`) for `block` among the run's `rows`,
630
+ * through `repoRelativePath` (a path it
631
+ * refuses is left out), sorted. The close adds them to the diff's file set, so work the diff
632
+ * cannot see — committed before `block open`, done in another worktree — is still checked.
633
+ * @param {Array<Record<string, any>>} rows @param {string} block @returns {string[]}
634
+ */
635
+ export function reviewedFiles(rows, block) {
636
+ /** @type {Set<string>} */
637
+ const out = new Set();
638
+ for (const r of rows) {
639
+ if (r?.block !== block || !REVIEW_EVENTS.has(r.event) || typeof r.file !== 'string') continue;
640
+ try {
641
+ out.add(repoRelativePath(r.file));
642
+ } catch {
643
+ // not a repo path: nothing on disk to check it against
644
+ }
645
+ }
646
+ return [...out].sort();
647
+ }
648
+
649
+ /** @param {string} repoRoot @param {string} file @returns {string} the content hash, `DELETED_HASH` when gone. */
650
+ const currentHash = (repoRoot, file) => (isOnDisk(repoRoot, file) ? contentHash(repoRoot, file) : DELETED_HASH);
651
+
652
+ /**
653
+ * B52: the changed files of the tree (`changed`, from {@link changedFiles} run at the repository
654
+ * top level `top`, so top-relative) that NO block of the run accounts for — each one is an
655
+ * `unowned_change` refusal at `block close`. A file is accounted for when:
656
+ * - this block owns it (`isMine`, the close's own `owned_files` match);
657
+ * - it is exactly as it was when this block opened: its `tree_at_open` stamp (`treeStamp`) is
658
+ * the same now (`sameStamp`; an `unreadable` stamp at open or now never is) — a change made
659
+ * before the coder started (plan files, `init`'s config, a skill link) is not the coder's; any
660
+ * later edit to it is;
661
+ * - another block of the run that is still OPEN owns it (parallel coders share the tree and its
662
+ * own gate reviews it). The gate cannot tell which coder wrote it, so such files are returned
663
+ * in `siblings` and the close WARNs naming each one;
664
+ * - a CLOSED block of the run owns it AND a signed `review.approved` row of that block names the
665
+ * file at its current content hash (that block's work, exactly as its gate approved it — a
666
+ * later edit makes it unowned again);
667
+ * - it is code-forge's own state: under `.code-forge/` at the top level or under `stateDir` (the
668
+ * workspace's `.code-forge/`, top-relative). Nothing else is exempt — not the close's own
669
+ * `--transcript` or `--report` when they sit in the source tree.
670
+ * A stopped block accounts for nothing. Paths match through {@link ownsPath} (globs as at `block
671
+ * open`). A stamp that throws counts as not accounted (refused). A block opened before B52 has no
672
+ * `tree_at_open`: null — nothing can tell the coder's changes from earlier ones, so the caller
673
+ * warns that the check did not run (never a silent pass).
674
+ * @param {{
675
+ * top: string, changed: ReadonlyArray<string>, id: string, blocks: Record<string, any>,
676
+ * rows: Array<Record<string, any>>, key: Buffer, isMine: (file: string) => boolean, owns?: (owned: ReadonlyArray<string>, file: string) => boolean, stateDir?: string,
677
+ * }} opts `blocks`: the run record's `blocks`; `rows`: the run's ledger rows; `owns` (default {@link ownsPath}): how another block's `owned_files` match a path.
678
+ * @returns {Promise<{unowned: string[], siblings: Array<{file: string, block: string}>} | null>}
679
+ */
680
+ export async function unownedChanges({ top, changed, id, blocks, rows, key, isMine, owns = ownsPath, stateDir = '.code-forge' }) {
681
+ const atOpen = blocks?.[id]?.tree_at_open;
682
+ if (!atOpen || typeof atOpen !== 'object' || Array.isArray(atOpen)) return null;
683
+ const others = Object.entries(blocks ?? {}).filter(([other, b]) => other !== id && b && Array.isArray(b.owned_files));
684
+ /** @param {string} file @returns {boolean} */
685
+ const approvedClosed = (file) => {
686
+ let hash;
687
+ try {
688
+ hash = currentHash(top, file);
689
+ } catch {
690
+ return false; // a symlink or an unreadable path was never approved
691
+ }
692
+ return others.some(
693
+ ([other, b]) =>
694
+ b.status === 'closed' &&
695
+ owns(b.owned_files, file) &&
696
+ rows.some((r) => r?.event === 'review.approved' && r.block === other && r.file === file && r.content_hash === hash && verified(r, key)),
697
+ );
698
+ };
699
+ /** @param {string} file @returns {Promise<boolean>} */
700
+ const unchangedSinceOpen = async (file) => {
701
+ if (!Object.hasOwn(atOpen, file)) return false;
702
+ try {
703
+ return sameStamp(atOpen[file], await treeStamp(top, file));
704
+ } catch {
705
+ return false;
706
+ }
707
+ };
708
+ const state = (/** @type {string} */ f) => [...new Set(['.code-forge', stateDir])].some((dir) => f === dir || f.startsWith(`${dir}/`));
709
+ /** @type {string[]} */
710
+ const unowned = [];
711
+ /** @type {Array<{file: string, block: string}>} */
712
+ const siblings = [];
713
+ for (const file of changed) {
714
+ if (state(file) || isMine(file) || (await unchangedSinceOpen(file))) continue;
715
+ const sibling = others.find(([, b]) => b.status === 'open' && owns(b.owned_files, file));
716
+ if (sibling) siblings.push({ file, block: sibling[0] });
717
+ else if (!approvedClosed(file)) unowned.push(file);
718
+ }
719
+ return { unowned, siblings };
350
720
  }
351
721
 
352
722
  /**
@@ -11,7 +11,11 @@
11
11
  * to ONE L3 ruling per file (`deps.rule`). Jev unavailable or `defect` disabled ⇒ §3.5 fallback:
12
12
  * `defect` has no rule (`alwaysResidue`), so every finding is residue for the L3 ruling;
13
13
  * - fail closed: a mid-band finding with no ruling available (no `deps.rule`, or it failed or
14
- * left the id out) is `fix_now` unless its severity is `nit`.
14
+ * left the id out) is `fix_now` unless its severity is `nit`;
15
+ * - B52: a `critical` finding is ALWAYS `fix_now` — neither S1's band nor the L3 ruling makes it
16
+ * a nit (both are still asked and logged in the row's `p` / `band`). Only a fix (the recheck)
17
+ * or the human's `block waive` ends a critical; otherwise the loop would write
18
+ * `review.approved` with the reviewer's critical still standing.
15
19
  * One `review.triage` row per finding: `{file, finding, severity, source, p, band, verdict}`.
16
20
  */
17
21
 
@@ -110,6 +114,8 @@ export async function triageFindings({ file, findings, fromJudge, diffText = '',
110
114
  /** @type {Array<{finding: Finding, p: number | null}>} */
111
115
  const mid = [];
112
116
  const put = (/** @type {Finding} */ f, /** @type {'fix_now' | 'nit'} */ verdict) => (verdict === 'fix_now' ? fixNow : nits).push(f);
117
+ /** B52: a critical is never demoted. @param {Finding} f @param {'fix_now' | 'nit'} verdict @returns {'fix_now' | 'nit'} */
118
+ const keepCritical = (f, verdict) => (f.severity === 'critical' ? 'fix_now' : verdict);
113
119
 
114
120
  for (const finding of findings) {
115
121
  const p = await askNoul('defect', { file_diff_hunk: diffText, finding }, { jev, cfg });
@@ -129,8 +135,9 @@ export async function triageFindings({ file, findings, fromJudge, diffText = '',
129
135
  mid.push({ finding, p });
130
136
  continue;
131
137
  }
132
- put(finding, band);
133
- await note(writeRow, { event: 'review.triage', file, finding: finding.id, severity: finding.severity, source: 'jev', p, band, verdict: band });
138
+ const verdict = keepCritical(finding, band);
139
+ put(finding, verdict);
140
+ await note(writeRow, { event: 'review.triage', file, finding: finding.id, severity: finding.severity, source: 'jev', p, band, verdict });
134
141
  }
135
142
 
136
143
  let rulings = 0;
@@ -147,7 +154,7 @@ export async function triageFindings({ file, findings, fromJudge, diffText = '',
147
154
  }
148
155
  for (const { finding, p } of mid) {
149
156
  const given = ruled?.[finding.id];
150
- const verdict = given === 'fix_now' || given === 'nit' ? given : severityVerdict(finding);
157
+ const verdict = keepCritical(finding, given === 'fix_now' || given === 'nit' ? given : severityVerdict(finding));
151
158
  put(finding, verdict);
152
159
  await note(writeRow, {
153
160
  event: 'review.triage',
@@ -5,7 +5,7 @@
5
5
  * 1. the process exited 0 (not killed at the session timeout);
6
6
  * 2. the answer is a JSON object that validates against `finding.schema.json`;
7
7
  * 3. `reviewed_hunks` equals the packet's hunk headers EXACTLY and IN ORDER;
8
- * 4. `tokens_out ≥ review.min_tokens_out` (120).
8
+ * 4. `tokens_out ≥ review.min_tokens_out` (120), or ≥ 40 for a structured clean pass (B53).
9
9
  * Anything else is `unavailable` with one reason — `timeout`, `exit`, `schema`,
10
10
  * `hunks_mismatch`, `too_short`, or `budget` (B33: the spawner refused at `budget.usd`) — and is
11
11
  * never approval. There is no "approve on missing output":
@@ -20,13 +20,13 @@ export const UNAVAILABLE_REASONS = Object.freeze(['exit', 'schema', 'hunks_misma
20
20
  export const DEFAULT_MIN_TOKENS_OUT = 120;
21
21
 
22
22
  /**
23
- * A clean pass (`passed: true`, no findings, every hunk acknowledged) on a SMALL diff legitimately
24
- * needs few words: a 3-line placeholder reviewed by GPT-6 Astra produced 108 tokens and was refused
25
- * as `too_short`. For such answers the floor drops to this value; any finding keeps the full floor.
23
+ * A structured clean pass legitimately needs few words, whatever the diff size: GPT-6 Astra clean
24
+ * passes measured 108-121 output tokens while reviews with findings measured 300-1,400, so the full
25
+ * floor refused 11 of 19 clean files on one block (issue #6). A clean pass is `passed: true`, no
26
+ * findings, every hunk acknowledged in order (checked above) and a non-blank `summary`; for it the
27
+ * floor drops to this value. Any finding, or a blank summary, keeps the full floor (B53).
26
28
  */
27
29
  export const CLEAN_PASS_MIN_TOKENS_OUT = 40;
28
- /** A diff with at most this many added lines is "small" for the clean-pass floor. */
29
- export const SMALL_DIFF_ADDED_LINES = 20;
30
30
 
31
31
  /** The source answer schema (compiled per provider by the spawner). */
32
32
  export const FINDING_SCHEMA = Object.freeze(JSON.parse(readFileSync(new URL('./finding.schema.json', import.meta.url), 'utf8')));
@@ -55,10 +55,10 @@ export function minTokensOut(cfg) {
55
55
 
56
56
  /**
57
57
  * @param {Record<string, any> | null | undefined} session - a `spawnSession` result.
58
- * @param {{hunkHeaders: ReadonlyArray<string>, minTokensOut?: number, smallDiff?: boolean}} expect - `smallDiff` lowers the floor to `CLEAN_PASS_MIN_TOKENS_OUT` for a clean pass only
58
+ * @param {{hunkHeaders: ReadonlyArray<string>, minTokensOut?: number}} expect
59
59
  * @returns {Verdict}
60
60
  */
61
- export function validateReview(session, { hunkHeaders, minTokensOut: min = DEFAULT_MIN_TOKENS_OUT, smallDiff = false }) {
61
+ export function validateReview(session, { hunkHeaders, minTokensOut: min = DEFAULT_MIN_TOKENS_OUT }) {
62
62
  if (!session || typeof session !== 'object') return { ok: false, reason: 'exit', detail: 'no session result' };
63
63
  if (session.status === 'timeout') return { ok: false, reason: 'timeout', detail: 'killed at the session timeout' };
64
64
  // B33: the spawner refused the session because the run reached `budget.usd` — nothing ran
@@ -77,9 +77,9 @@ export function validateReview(session, { hunkHeaders, minTokensOut: min = DEFAU
77
77
  return { ok: false, reason: 'hunks_mismatch', detail: `reviewed_hunks has ${got.length} entries; the packet has ${hunkHeaders.length} hunks` };
78
78
  }
79
79
  const tokensOut = Number(session.usage?.tokens_out ?? session.row?.tokens_out ?? 0);
80
- const review = /** @type {{passed: boolean, findings: unknown[]}} */ (session.answer);
81
- const cleanPass = review.passed === true && Array.isArray(review.findings) && review.findings.length === 0;
82
- const floor = smallDiff && cleanPass ? Math.min(min, CLEAN_PASS_MIN_TOKENS_OUT) : min;
80
+ const review = /** @type {{passed: boolean, summary: string, findings: unknown[]}} */ (session.answer);
81
+ const cleanPass = review.passed === true && Array.isArray(review.findings) && review.findings.length === 0 && review.summary.trim().length > 0;
82
+ const floor = cleanPass ? Math.min(min, CLEAN_PASS_MIN_TOKENS_OUT) : min;
83
83
  if (!(tokensOut >= floor)) return { ok: false, reason: 'too_short', detail: `tokens_out ${tokensOut} < ${floor}` };
84
84
  return { ok: true, review: session.answer, tokens_out: tokensOut };
85
85
  }