@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.
- package/CHANGELOG.md +34 -0
- package/README.md +16 -0
- package/docs/reference/blocks.json +31 -1
- package/docs/reference/config.md +1 -1
- package/package.json +1 -1
- package/schema/code-forge.schema.json +1 -1
- package/skill/SKILL.md +1 -1
- package/skill/references/adapters/claude-code.md +1 -1
- package/skill/references/adapters/solo.md +1 -1
- package/skill/references/code.md +4 -4
- package/skill/references/decisions.md +1 -1
- package/skill/references/review.md +3 -3
- package/skill/scripts/forge +1 -1
- package/src/cli/block.mjs +123 -18
- package/src/review/engine.mjs +7 -8
- package/src/review/gate-check.mjs +395 -25
- package/src/review/triage.mjs +11 -4
- package/src/review/validate-review.mjs +11 -11
- package/src/state/block.mjs +139 -2
- package/src/worker/loop.mjs +24 -2
|
@@ -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
|
|
13
|
-
*
|
|
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 `
|
|
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 (!
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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((
|
|
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
|
|
158
|
-
if (typeof
|
|
159
|
-
if (worst === '' || rank[
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
/**
|
package/src/review/triage.mjs
CHANGED
|
@@ -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
|
-
|
|
133
|
-
|
|
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
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
|
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
|
|
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 =
|
|
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
|
}
|