@codedology/code-forge 0.10.0 → 0.11.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.
- package/CHANGELOG.md +35 -0
- package/README.md +21 -2
- package/docs/reference/blocks.json +203 -1
- package/package.json +2 -2
- package/skill/SKILL.md +1 -1
- package/skill/references/code.md +6 -4
- package/skill/references/review.md +28 -3
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +4 -6
- package/src/cli/block.mjs +98 -7
- package/src/cli/review-file.mjs +17 -11
- package/src/review/engine.mjs +59 -12
- package/src/review/fixloop.mjs +30 -10
- package/src/review/lenses/A.md +1 -0
- package/src/review/lenses/B.md +1 -0
- package/src/review/lenses/full.md +1 -0
- package/src/review/lenses/recheck.md +1 -0
- package/src/review/packet.mjs +48 -14
- package/src/review/paired.mjs +80 -0
- package/src/review/reviewed-lines.mjs +94 -0
- package/src/state/block-end.mjs +10 -6
- package/src/worker/engine.mjs +43 -15
- package/src/worker/loop.mjs +19 -1
package/src/cli/block.mjs
CHANGED
|
@@ -70,6 +70,21 @@
|
|
|
70
70
|
* FAILED: no review-file ticket id for <files>` (skipped under `--no-require-reviews`, the human's
|
|
71
71
|
* way out). It runs after the review rows and reuses their file set (listed once).
|
|
72
72
|
*
|
|
73
|
+
* B96 (E1): the tool builds the close report. After the review gate passed, the close prints
|
|
74
|
+
* `reviewed <path> ticket <id>` for every file the gate accepted, sorted by path, before the
|
|
75
|
+
* `reviewed_by_waiver` lines and the closed line; the id is the queue's ticket for that file at the
|
|
76
|
+
* hash the gate accepted (`resolveTicket`: the tree of a worktree block is part of it). `--json`
|
|
77
|
+
* and the done file get `reviewed: [{file, ticket, via}]` (`via`: approved, waiver or answered —
|
|
78
|
+
* the gate's own verdict, `review/reviewed-lines.mjs`), only when there is a line. So `--report`
|
|
79
|
+
* needs no ticket line: a missing hand line refuses only for a file the tool has no line for, and a
|
|
80
|
+
* hand line that names another ticket prints `WARN report ticket mismatch <path>` on stderr while
|
|
81
|
+
* the close goes on (D1). A gate-accepted file with no ticket prints `WARN no ticket for <path>`
|
|
82
|
+
* (`WARN bad ticket for <path>` when the ticket is not 24 lowercase hex) and has no line; lines that
|
|
83
|
+
* cannot be built at all give no line and `WARN reviewed lines unavailable: <message>` — then
|
|
84
|
+
* `--report` needs a hand line for every file the gate accepted, as before B96. All four warnings
|
|
85
|
+
* are held and print only when the block closes. A waived or external-review close is unchanged:
|
|
86
|
+
* no line, no key.
|
|
87
|
+
*
|
|
73
88
|
* B58 (issue #11): `review.external.required: true` in the run's config snapshot (the owner set it
|
|
74
89
|
* before `run start`; `run reload` refuses to change it) is the owner's non-interactive way out
|
|
75
90
|
* when an outside reviewer reviews the block: the per-file review checks and the `--report` ticket
|
|
@@ -200,12 +215,13 @@ import { validateConfig } from '../config/validate.mjs';
|
|
|
200
215
|
import { migrateConfig } from '../config/migrate.mjs';
|
|
201
216
|
import { exec } from '../util/exec.mjs';
|
|
202
217
|
import { contentHash, gitChildEnv } from '../worker/ticket.mjs';
|
|
203
|
-
import { clearDone } from '../worker/queue.mjs';
|
|
218
|
+
import { clearDone, resolveTicket } from '../worker/queue.mjs';
|
|
204
219
|
import { loadState } from '../worker/review-state.mjs';
|
|
205
220
|
import { openBlockEntry, reopenFile } from '../review/reopen.mjs';
|
|
206
221
|
import { checkFileForm, checkFindingId } from '../review/verb-forms.mjs';
|
|
207
222
|
import { runRootPath } from '../util/tmp.mjs';
|
|
208
223
|
import { missingReviewTickets, parseSentinel } from '../engines/sentinel.mjs';
|
|
224
|
+
import { reportMismatches, reviewedLines } from '../review/reviewed-lines.mjs';
|
|
209
225
|
import { blockEndHook, runBlockEndHook, writeDoneFile } from '../state/block-end.mjs';
|
|
210
226
|
import { blockOpenFindings } from '../review/open-findings.mjs';
|
|
211
227
|
import { hookForbidden, scanTranscript } from '../util/forbidden.mjs';
|
|
@@ -281,11 +297,14 @@ const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', '
|
|
|
281
297
|
* isTTY?: boolean, stdinIsTTY?: boolean, stdoutIsTTY?: boolean,
|
|
282
298
|
* ui?: {confirm: (o: {message: string, initialValue?: boolean}) => Promise<unknown>, isCancel: (v: unknown) => boolean},
|
|
283
299
|
* removeBlockTree?: typeof removeBlockTree,
|
|
300
|
+
* resolveTicket?: typeof resolveTicket, reviewedLines?: typeof reviewedLines,
|
|
284
301
|
* }} [deps] - `now` (B48): the clock of the autopilot expiry checks. `stdinIsTTY` / `stdoutIsTTY`
|
|
285
302
|
* (default `process.stdin.isTTY` / `process.stdout.isTTY`; `isTTY` sets both) and `ui` (default
|
|
286
303
|
* `@clack/prompts`): the human's confirmation (B52 `--no-require-reviews`, B80 `block waive` and
|
|
287
304
|
* `block reopen`), which needs both to be TTYs.
|
|
288
305
|
* `removeBlockTree` (B64b, tests only): the `--remove-worktree` step, default the real one.
|
|
306
|
+
* `resolveTicket` / `reviewedLines` (B96, tests only): the queue's ticket rule and the reviewed
|
|
307
|
+
* lines builder of the close, default the real ones.
|
|
289
308
|
* @returns {Promise<number>}
|
|
290
309
|
*/
|
|
291
310
|
export async function runBlock(args, deps = {}) {
|
|
@@ -344,7 +363,8 @@ export async function runBlock(args, deps = {}) {
|
|
|
344
363
|
// then the owner's `hooks.on_block_end` from the run-start snapshot. Without the hook key the
|
|
345
364
|
// done file is the only addition: no row, and every output stays exactly as before.
|
|
346
365
|
const hook = blockEndHook(record);
|
|
347
|
-
|
|
366
|
+
// B96: `reviewed` (the close's tool lines) joins the done file only when there is one
|
|
367
|
+
const ended = async (/** @type {'closed' | 'stopped'} */ status, /** @type {string} */ event, /** @type {string | null} */ reason, /** @type {import('../review/reviewed-lines.mjs').ReviewedLine[]} */ reviewed = []) => {
|
|
348
368
|
// ruling (e): `ended` never throws — any failure here is the done-file warning and a null
|
|
349
369
|
// `doneFile`, so the caller's try/finally still reaches the hook
|
|
350
370
|
/** @type {string | null} */
|
|
@@ -358,6 +378,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
358
378
|
reason: typeof reason === 'string' ? redact(reason) : null,
|
|
359
379
|
openFindings: (rows) => blockOpenFindings(record, rows, id),
|
|
360
380
|
err,
|
|
381
|
+
...(reviewed.length > 0 ? { reviewed } : {}),
|
|
361
382
|
});
|
|
362
383
|
} catch (thrown) {
|
|
363
384
|
const code = /** @type {any} */ (thrown)?.code;
|
|
@@ -568,6 +589,9 @@ export async function runBlock(args, deps = {}) {
|
|
|
568
589
|
// B70: a file whose review left only waived findings passes as `reviewed_by_waiver`; its signed
|
|
569
590
|
// `gate.review_waived_all` row joins the close rows (written just before `block.close`, only
|
|
570
591
|
// when the block closes — a refused close writes none)
|
|
592
|
+
// B96: whether the review gate passed — set from the gate's own result (below), so the files
|
|
593
|
+
// it checked are the ones it accepted and the tool can give their reviewed lines
|
|
594
|
+
let reviewsPassed = false;
|
|
571
595
|
const onWaivedAll = (/** @type {import('../review/gate-check.mjs').WaivedAll[]} */ files) => {
|
|
572
596
|
// idempotent: the waived rows are rebuilt from the final list, never appended per call
|
|
573
597
|
// (closeBlock reads closeRows only after every extra check passed)
|
|
@@ -575,7 +599,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
575
599
|
const others = closeRows.filter((r) => r.event !== 'gate.review_waived_all');
|
|
576
600
|
closeRows.splice(0, closeRows.length, ...others, ...files.map((f) => ({ event: 'gate.review_waived_all', block: id, file: f.file, content_hash: f.content_hash, findings: f.findings, by: f.by, ...(f.answered?.length ? { answered: f.answered } : {}) })));
|
|
577
601
|
};
|
|
578
|
-
const
|
|
602
|
+
const gate = reviewGateCheck(async () => {
|
|
579
603
|
const transcript = await findTranscript(record, id, typeof flags.transcript === 'string' ? flags.transcript : null);
|
|
580
604
|
if (transcript === null) {
|
|
581
605
|
await writeSigned(runId, writeRow, { event: 'gate.transcript_missing', block: id });
|
|
@@ -625,7 +649,63 @@ export async function runBlock(args, deps = {}) {
|
|
|
625
649
|
highPaths,
|
|
626
650
|
};
|
|
627
651
|
}, { onWaivedAll });
|
|
628
|
-
//
|
|
652
|
+
// B96: `reviewsPassed` comes from the gate's own result, not from the onWaivedAll call. Each
|
|
653
|
+
// extra check runs at most once per close (`closeBlock`'s one loop, src/state/block.mjs:562,
|
|
654
|
+
// and `closeBlock` is called once below), so the gate's one pass is its final one.
|
|
655
|
+
const reviews = async () => {
|
|
656
|
+
const result = await gate();
|
|
657
|
+
reviewsPassed = result.ok;
|
|
658
|
+
return result;
|
|
659
|
+
};
|
|
660
|
+
// B96: the stderr warnings of the reviewed lines (`WARN no ticket for <path>`, `WARN bad
|
|
661
|
+
// ticket for <path>`, `WARN reviewed lines unavailable: <message>`, `WARN report ticket
|
|
662
|
+
// mismatch <path>`): kept here and printed only when the block closes, before the reviewed
|
|
663
|
+
// lines — a refused close prints none
|
|
664
|
+
/** @type {string[]} */
|
|
665
|
+
const lineWarnings = [];
|
|
666
|
+
// B96: the tool's reviewed lines — one per file the review gate accepted, with the queue's
|
|
667
|
+
// ticket for that file at the hash the gate accepted (`resolveTicket`, the queue's own rule:
|
|
668
|
+
// the same path check, tree resolution and ticket id); a file whose content moved since the
|
|
669
|
+
// gate hashed it, or whose ticket cannot be given, has no line (fail closed, `WARN no ticket
|
|
670
|
+
// for <path>`), and so has a file whose ticket is not 24 lowercase hex (`WARN bad ticket for
|
|
671
|
+
// <path>`). None when the reviews were waived (the human flag or `review.external`) or the
|
|
672
|
+
// gate has not passed — and that empty answer is never memoised: the lines are built once,
|
|
673
|
+
// from the gate's pass (its file set and hashes, its waivedAll). It never throws: an error
|
|
674
|
+
// gives no lines and one held `WARN reviewed lines unavailable: <message>`, so the done file
|
|
675
|
+
// and the on_block_end hook of a closed block always follow.
|
|
676
|
+
/** @type {Promise<import('../review/reviewed-lines.mjs').ReviewedLine[]> | undefined} */
|
|
677
|
+
let linesOnce;
|
|
678
|
+
const toolLines = async () => {
|
|
679
|
+
if (!reviewsPassed || reviewsWaived || external !== null) return [];
|
|
680
|
+
linesOnce ??= (async () => {
|
|
681
|
+
try {
|
|
682
|
+
const { mainTop, top, inTree, fileSet } = await tree();
|
|
683
|
+
const resolve = deps.resolveTicket ?? resolveTicket;
|
|
684
|
+
const ticketOf = (/** @type {{file: string, content_hash: string}} */ f) => {
|
|
685
|
+
try {
|
|
686
|
+
const t = resolve({ repoRoot: mainTop, run: runId, block: id, file: f.file, tree: inTree ? top : null });
|
|
687
|
+
return t.content_hash === f.content_hash ? t.ticket : null;
|
|
688
|
+
} catch {
|
|
689
|
+
return null;
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
return await (deps.reviewedLines ?? reviewedLines)({
|
|
693
|
+
files: fileSet,
|
|
694
|
+
waivedAll,
|
|
695
|
+
ticketOf,
|
|
696
|
+
onNoTicket: (file) => lineWarnings.push(`WARN no ticket for ${file}\n`),
|
|
697
|
+
onBadTicket: (file) => lineWarnings.push(`WARN bad ticket for ${file}\n`),
|
|
698
|
+
});
|
|
699
|
+
} catch (thrown) {
|
|
700
|
+
lineWarnings.push(`WARN reviewed lines unavailable: ${thrown instanceof Error ? thrown.message : String(thrown)}\n`);
|
|
701
|
+
return [];
|
|
702
|
+
}
|
|
703
|
+
})();
|
|
704
|
+
return linesOnce;
|
|
705
|
+
};
|
|
706
|
+
// B36 → B96: the coder's report. The tool gives the reviewed lines, so a missing hand line is
|
|
707
|
+
// a refusal only for a file the tool has no line for; a hand line that names another ticket
|
|
708
|
+
// is a warning and the close goes on (D1: the tool lines are the record)
|
|
629
709
|
const report = async () => {
|
|
630
710
|
if (typeof flags.report !== 'string' || reviewsWaived || external !== null) return { ok: true };
|
|
631
711
|
let text;
|
|
@@ -635,8 +715,12 @@ export async function runBlock(args, deps = {}) {
|
|
|
635
715
|
return { ok: false, reason: 'coder report FAILED: --report cannot be read' };
|
|
636
716
|
}
|
|
637
717
|
const { fileSet } = await tree();
|
|
638
|
-
const
|
|
639
|
-
|
|
718
|
+
const lines = await toolLines();
|
|
719
|
+
const given = new Set(lines.map((l) => l.file));
|
|
720
|
+
const missing = missingReviewTickets(text, fileSet.map((f) => f.file)).filter((f) => !given.has(f));
|
|
721
|
+
if (missing.length > 0) return { ok: false, reason: `coder report FAILED: no review-file ticket id for ${missing.join(', ')}` };
|
|
722
|
+
for (const file of reportMismatches(text, lines)) lineWarnings.push(`WARN report ticket mismatch ${file}\n`);
|
|
723
|
+
return { ok: true };
|
|
640
724
|
};
|
|
641
725
|
/** @type {Array<() => Promise<{ok: boolean, reason?: string}>>} */
|
|
642
726
|
const extraChecks = [reviews, report];
|
|
@@ -666,14 +750,19 @@ export async function runBlock(args, deps = {}) {
|
|
|
666
750
|
const result = await closeBlock({ runId, id, rows, writeRow, livePid: intFlag(flags['worker-pid'], 'worker-pid'), probe, extraChecks, closeRows });
|
|
667
751
|
// B64b: the block ended (closed, or stopped by a worker pin event or ledger.tamper) — its rows
|
|
668
752
|
// are written and the run lock released; the done file is written before the tree is removed
|
|
669
|
-
|
|
753
|
+
// B96: the tool's reviewed lines of a block that closed (also kept in its done file)
|
|
754
|
+
const reviewedJson = result.ok ? await toolLines() : [];
|
|
755
|
+
const end = result.status === 'closed' || result.status === 'stopped' ? await ended(result.status, result.event ?? (result.status === 'stopped' ? 'block.stop' : 'block.close'), result.reason ?? null, reviewedJson) : null;
|
|
670
756
|
// B64b ruling (e): the hook runs exactly once for this end, after everything else the verb
|
|
671
757
|
// does — also when a later step (the output, `--remove-worktree`) throws; it never changes
|
|
672
758
|
// the exit code or stdout (`runHook` never throws)
|
|
673
759
|
try {
|
|
760
|
+
// B96: the reviewed lines (sorted by path) come first, then the reviewed_by_waiver lines
|
|
674
761
|
// B70: each file that passed as reviewed_by_waiver is named before the closed line
|
|
675
762
|
// B71: an answered finding is named as answered; a file with only answered findings has no `by`
|
|
676
763
|
if (result.ok) {
|
|
764
|
+
for (const w of lineWarnings) err(w);
|
|
765
|
+
for (const l of reviewedJson) say(`reviewed ${l.file} ticket ${l.ticket}\n`);
|
|
677
766
|
for (const f of waivedAll) {
|
|
678
767
|
const parts = f.findings.length > 0 ? [`${f.findings.join(', ')}; by ${f.by}`] : [];
|
|
679
768
|
if (f.answered?.length) parts.push(`answered ${f.answered.join(', ')}`);
|
|
@@ -692,6 +781,8 @@ export async function runBlock(args, deps = {}) {
|
|
|
692
781
|
...(end?.json ?? {}),
|
|
693
782
|
// B70: only when a file passed as reviewed_by_waiver (otherwise the output is unchanged)
|
|
694
783
|
...(result.ok && waivedAll.length > 0 ? { reviewed_by_waiver: waivedAll.map((f) => ({ file: f.file, content_hash: f.content_hash, status: f.status, findings: f.findings, by: f.by, ...(f.answered?.length ? { answered: f.answered } : {}) })) } : {}),
|
|
784
|
+
// B96: only when the tool gave reviewed lines (a waived or external close is unchanged)
|
|
785
|
+
...(reviewedJson.length > 0 ? { reviewed: reviewedJson } : {}),
|
|
695
786
|
};
|
|
696
787
|
if (!result.ok || !removeTree) {
|
|
697
788
|
if (json && result.ok) emit(closedJson);
|
package/src/cli/review-file.mjs
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* status, kind: answers, file: source, by_file, answers}
|
|
11
11
|
* review-file <path> --block <id> --fresh B75: re-run a cached result that never counted
|
|
12
12
|
*
|
|
13
|
-
* The same content enqueued again after an `unavailable` result is a
|
|
14
|
-
* not the old result.
|
|
13
|
+
* The same content enqueued again after an `unavailable` or round-1 `split_required` result is a
|
|
14
|
+
* fresh attempt (`retry: true`), not the old result.
|
|
15
15
|
* A done result that is not approved and carries open findings adds `fix_list` (what the coder
|
|
16
16
|
* fixes before re-running `review-file <path>`, which is then a round-2 recheck of the fix hunks,
|
|
17
17
|
* §4.11); a result the loop stopped adds `stop` with the reason (`review_cap`, …) and (B73)
|
|
@@ -28,8 +28,9 @@
|
|
|
28
28
|
* down on two polls in a row, and a worker whose pid is alive with a fresh heartbeat is never down
|
|
29
29
|
* (`liveWorker`, B30). Output is one JSON line on stdout.
|
|
30
30
|
* B75: `--fresh` drops the cached done marker of the file's current content and queues the ticket
|
|
31
|
-
* again ONLY when that result never counted as a review: `stale`, `unavailable`,
|
|
32
|
-
* `other-run`, `context-unreadable` or a `worktree-*` reason
|
|
31
|
+
* again ONLY when that result never counted as a review: `stale`, `unavailable`, round-1
|
|
32
|
+
* `split_required`, or `refused` with `other-run`, `context-unreadable` or a `worktree-*` reason
|
|
33
|
+
* (an allow-list, fail closed). Any
|
|
33
34
|
* other cached result — a round ran (findings, approved, stopped), `review-state-lost`, an answers
|
|
34
35
|
* result, an unknown status or reason, an unreadable marker — is refused `fresh-needs-reopen`
|
|
35
36
|
* (exit 1, `exits: [block reopen, block waive]`, the marker kept byte-for-byte); a
|
|
@@ -129,8 +130,9 @@ const FRESH_REFUSED = new Set(['other-run', 'context-unreadable']);
|
|
|
129
130
|
|
|
130
131
|
/**
|
|
131
132
|
* B75: may `--fresh` drop this cached result? An allow-list, fail closed: `rerun` only for a
|
|
132
|
-
* result that never counted as a review — `stale`, `unavailable`,
|
|
133
|
-
* is not a verdict (`other-run`, `context-unreadable`, `worktree-*`)
|
|
133
|
+
* result that never counted as a review — `stale`, `unavailable`, round-1 `split_required`, or
|
|
134
|
+
* `refused` with a reason that is not a verdict (`other-run`, `context-unreadable`, `worktree-*`)
|
|
135
|
+
* — whose marker names the same
|
|
134
136
|
* status. `tampered` for `refused review-state-tampered`; `counted` for everything else (a round
|
|
135
137
|
* ran, `review-state-lost`, an answers result, an unknown status or reason, an unreadable marker or
|
|
136
138
|
* result).
|
|
@@ -145,7 +147,7 @@ export function freshVerdict(text, result) {
|
|
|
145
147
|
return 'counted';
|
|
146
148
|
}
|
|
147
149
|
if (result === null || marker === null || typeof marker !== 'object' || marker.status !== result.status) return 'counted';
|
|
148
|
-
if (result.status === 'stale' || result.status === 'unavailable') return 'rerun';
|
|
150
|
+
if (result.status === 'stale' || result.status === 'unavailable' || result.status === 'split_required') return 'rerun';
|
|
149
151
|
if (result.status !== 'refused' || typeof result.reason !== 'string') return 'counted';
|
|
150
152
|
if (result.reason === 'review-state-tampered') return 'tampered';
|
|
151
153
|
return FRESH_REFUSED.has(result.reason) || result.reason.startsWith('worktree-') ? 'rerun' : 'counted';
|
|
@@ -166,10 +168,11 @@ export const FRESH_EXITS = Object.freeze(['block reopen', 'block waive']);
|
|
|
166
168
|
* crash between the drop and the enqueue leaves only a re-runnable pending ticket. Anything else is refused and the marker kept byte-for-byte:
|
|
167
169
|
* `fresh-needs-reopen` (names `block reopen` and `block waive`) or `fresh-tampered` (names only
|
|
168
170
|
* the owner's `block close --no-require-reviews`; reopen refuses a tampered state).
|
|
169
|
-
*
|
|
171
|
+
* B110: a plain cached round-1 `split_required` uses the same claim, with `retry: true`.
|
|
172
|
+
* @param {{repoRoot: string, run: string, block: string, file: string, where: {tree?: string}, say: (obj: Record<string, any>) => unknown, dropDone?: typeof dropDoneIf, retry?: boolean}} opts
|
|
170
173
|
* @returns {number} the exit code.
|
|
171
174
|
*/
|
|
172
|
-
function freshTicket({ repoRoot, run, block, file, where, say, dropDone = dropDoneIf }) {
|
|
175
|
+
function freshTicket({ repoRoot, run, block, file, where, say, dropDone = dropDoneIf, retry = false }) {
|
|
173
176
|
const { ticket } = resolveTicket({ repoRoot, run, block, file, tree: where.tree ?? null });
|
|
174
177
|
const markerPath = path.join(queueDir(repoRoot), `${ticket}.done`);
|
|
175
178
|
/** @type {'rerun' | 'tampered' | 'counted'} */
|
|
@@ -210,7 +213,7 @@ function freshTicket({ repoRoot, run, block, file, where, say, dropDone = dropDo
|
|
|
210
213
|
// dropped: queue it again now (a crash right here leaves a pending ticket with no marker, which
|
|
211
214
|
// the next review-file queues anyway); gone: another caller took the marker, a normal enqueue
|
|
212
215
|
const queued = enqueue({ repoRoot, run, block, file, ...where });
|
|
213
|
-
say(outcome === 'dropped' ? { ...queued, fresh: true } : queued);
|
|
216
|
+
say(outcome === 'dropped' ? { ...queued, ...(retry ? { retry: true } : { fresh: true }) } : queued);
|
|
214
217
|
return 0;
|
|
215
218
|
}
|
|
216
219
|
if (verdict === 'tampered') {
|
|
@@ -223,7 +226,7 @@ function freshTicket({ repoRoot, run, block, file, where, say, dropDone = dropDo
|
|
|
223
226
|
ticket,
|
|
224
227
|
file,
|
|
225
228
|
exits: [...FRESH_EXITS],
|
|
226
|
-
message: `fresh-needs-reopen: the cached result of ${file} counts as a review (or cannot be read, or kept changing under the claim); --fresh re-runs only stale, unavailable or refused other-run/worktree-*/context-unreadable results; the human's ways out are block reopen and block waive; the cached result was kept`,
|
|
229
|
+
message: `fresh-needs-reopen: the cached result of ${file} counts as a review (or cannot be read, or kept changing under the claim); --fresh re-runs only stale, unavailable, round-1 split_required or refused other-run/worktree-*/context-unreadable results; the human's ways out are block reopen and block waive; the cached result was kept`,
|
|
227
230
|
});
|
|
228
231
|
return 1;
|
|
229
232
|
}
|
|
@@ -410,6 +413,9 @@ export async function runReviewFile(args, deps = {}) {
|
|
|
410
413
|
const where = realpathSync(tree) === realpathSync(repoRoot) ? {} : { tree };
|
|
411
414
|
if (flags.fresh === true) return freshTicket({ repoRoot, run: worker.run, block, file, where, say, ...(deps.dropDone ? { dropDone: deps.dropDone } : {}) });
|
|
412
415
|
const queued = enqueue({ repoRoot, run: worker.run, block, file, ...where });
|
|
416
|
+
if (queued.status === 'done' && doneResult(repoRoot, queued.ticket)?.status === 'split_required') {
|
|
417
|
+
return freshTicket({ repoRoot, run: worker.run, block, file, where, say, retry: true, ...(deps.dropDone ? { dropDone: deps.dropDone } : {}) });
|
|
418
|
+
}
|
|
413
419
|
if (queued.status === 'done' && doneResult(repoRoot, queued.ticket)?.status === 'unavailable') {
|
|
414
420
|
// `unavailable` is never approval AND never final: drop the done marker so the worker runs
|
|
415
421
|
// the ticket again (the fix loop re-runs its pending round); the signed result is replaced.
|
package/src/review/engine.mjs
CHANGED
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
* The summary then carries `schema_fallback: {level, provider, model}` (`level`:
|
|
30
30
|
* `review.second_levels.<level>`), the second level's own provider/model, and `tokens_in` /
|
|
31
31
|
* `tokens_out` summed over both sessions;
|
|
32
|
-
* 7. a
|
|
32
|
+
* 7. a non-Markdown quick packet over budget first steps up to one full L2 session (B109).
|
|
33
|
+
* A diff alone still over the packet budget is `split_required` (with `tokens_in` and `budget`) —
|
|
33
34
|
* except a Markdown file (B54), which is reviewed SECTION BY SECTION (`sections.mjs`): every
|
|
34
35
|
* section packet runs the whole plan above (its own sessions, judge and stub guard, its own
|
|
35
36
|
* `review.plan` row with `section`), in file order, stopping at the first one that is not
|
|
@@ -64,15 +65,17 @@ import { blockKind, isDocsKind } from '../decide/escalation.mjs';
|
|
|
64
65
|
import { fallbackRisk } from '../decide/fallback-rules.mjs';
|
|
65
66
|
import { isMigrationPath, isPolicyPath } from '../decide/lane-facts.mjs';
|
|
66
67
|
import { tierFor } from '../proof/tiers.mjs';
|
|
67
|
-
import { assembleJudgePacket, assemblePacket, attachFiles, budgetFor, diffOnlyTokens, readFileDiff } from './packet.mjs';
|
|
68
|
+
import { assembleJudgePacket, assemblePacket, attachFiles, budgetFor, diffOnlyTokens, isSecretLike, readFileDiff } from './packet.mjs';
|
|
68
69
|
import { movedText, movesFor, peerDiffReader } from './moved.mjs';
|
|
69
70
|
import { deletionNote, renderDeletionNote } from './deletion.mjs';
|
|
70
71
|
import { testQualityNote } from './test-quality.mjs';
|
|
72
|
+
import { pairsFor, summarize } from './paired.mjs';
|
|
73
|
+
import { redact } from '../util/redact.mjs';
|
|
71
74
|
import { isMarkdownFile, packSections, sectionFindings, sectionGroups } from './sections.mjs';
|
|
72
75
|
import { FINDING_SCHEMA, minTokensOut, validateReview } from './validate-review.mjs';
|
|
73
76
|
import { spawnWithTimeoutRetry } from './session-retry.mjs';
|
|
74
77
|
import { afterSchemaMiss, logSecondLevelMiss, packetHash } from './schema-fallback.mjs';
|
|
75
|
-
import { assertRowPath } from '../worker/ticket.mjs';
|
|
78
|
+
import { assertRowPath, readRegularFileNoFollow } from '../worker/ticket.mjs';
|
|
76
79
|
|
|
77
80
|
/**
|
|
78
81
|
* @typedef {{lens: 'quick' | 'full' | 'A' | 'B' | 'judge', role: 'reviewer' | 'judge', level: 'L2' | 'L3', slot: 'A' | 'B' | null, cfg?: Record<string, any>}} SessionSpec -
|
|
@@ -185,6 +188,9 @@ export function rulesRisk({ file, plusCount, cfg }) {
|
|
|
185
188
|
* code moved between this file and one of them is named in every packet (`## moved code`).
|
|
186
189
|
* @property {() => Promise<import('./moved.mjs').PeerDiff[]>} [peerDiffs] - B56: a memoised reader
|
|
187
190
|
* of those files' diffs (`peerDiffReader`), shared by the caller across the round; wins over `peers`.
|
|
191
|
+
* @property {ReadonlyArray<string>} [ownedFiles] - concrete paths owned by this block.
|
|
192
|
+
* @property {ReadonlyArray<string>} [changedFiles] - concrete changed paths of the same block.
|
|
193
|
+
* @property {ReadonlyArray<string>} [testFiles] - the block's explicit test_files, regardless of stem.
|
|
188
194
|
*/
|
|
189
195
|
|
|
190
196
|
/**
|
|
@@ -224,6 +230,31 @@ function safeNeeds(needs) {
|
|
|
224
230
|
}
|
|
225
231
|
}
|
|
226
232
|
|
|
233
|
+
/**
|
|
234
|
+
* B98: names only, from owned changed peers and explicit tests in the reviewed tree.
|
|
235
|
+
* Missing, unsafe or unreadable pairs give no hint, never a failed review.
|
|
236
|
+
* @param {{repoRoot: string, file: string, peers?: ReadonlyArray<string>, ownedFiles?: ReadonlyArray<string>, changedFiles?: ReadonlyArray<string>, testFiles?: ReadonlyArray<string>}} input
|
|
237
|
+
* @returns {string}
|
|
238
|
+
*/
|
|
239
|
+
export function pairedContext({ repoRoot, file, peers = [], ownedFiles = [], changedFiles = peers, testFiles = [] }) {
|
|
240
|
+
if (ownedFiles.length === 0) return '';
|
|
241
|
+
const owned = new Set(ownedFiles);
|
|
242
|
+
const candidates = [...new Set([...pairsFor(file, changedFiles), ...testFiles])].filter((p) => p !== file && owned.has(p));
|
|
243
|
+
const lines = [];
|
|
244
|
+
for (const candidate of candidates) {
|
|
245
|
+
try {
|
|
246
|
+
const rel = assertRowPath(candidate);
|
|
247
|
+
if (isSecretLike(rel)) continue;
|
|
248
|
+
const bytes = readRegularFileNoFollow(repoRoot, rel);
|
|
249
|
+
if (bytes === null) continue;
|
|
250
|
+
lines.push(`- ${redact(rel)}`, ...summarize(bytes.toString('utf8'), rel).map((name) => ` - ${name}`));
|
|
251
|
+
} catch {
|
|
252
|
+
// The summary is optional; never follow a symlink or substitute another file.
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return lines.join('\n');
|
|
256
|
+
}
|
|
257
|
+
|
|
227
258
|
/**
|
|
228
259
|
* @param {ReviewInput} input @param {ReviewDeps} deps
|
|
229
260
|
* @returns {Promise<{status: string, approved: boolean, engine: string, sessions: Array<Record<string, any>>} & Record<string, any>>}
|
|
@@ -234,8 +265,8 @@ export async function reviewFile(input, deps) {
|
|
|
234
265
|
const risk = input.risk ?? rulesRisk({ file: diff.file, plusCount: diff.plusCount, cfg });
|
|
235
266
|
// no recorded kind ⇒ the reviewed file's own kind (a lone `.md` is a docs review), never a blind `code`
|
|
236
267
|
const kind = typeof input.kind === 'string' ? input.kind : blockKind({ owned: [diff.file] });
|
|
237
|
-
|
|
238
|
-
|
|
268
|
+
let plan = planSessions({ risk, cfg, kind });
|
|
269
|
+
let head = { engine: plan.mode, depth: plan.depth, risk, file: diff.file };
|
|
239
270
|
if (plan.refused) return { status: 'refused', reason: plan.refused, approved: false, ...head, sessions: [] };
|
|
240
271
|
if (diff.diffText.trim().length === 0) return { status: 'no_change', approved: false, ...head, sessions: [] };
|
|
241
272
|
mkdirSync(workDir, { recursive: true, mode: 0o700 });
|
|
@@ -265,6 +296,7 @@ export async function reviewFile(input, deps) {
|
|
|
265
296
|
// B92: a test that reads a source file and asserts on its text gets the `## test-quality` hint
|
|
266
297
|
// (line numbers only), read once from the current content; '' for none
|
|
267
298
|
const quality = testQualityNote(diff.file, diff.content);
|
|
299
|
+
const paired = pairedContext(input);
|
|
268
300
|
/** @param {ReadonlyArray<number> | null} keys - a Markdown section's group keys; null ⇒ the whole file. */
|
|
269
301
|
const movedOf = (keys) => [deleted, movedText(moves, diff.content, keys), quality].filter((t) => typeof t === 'string' && t.length > 0).join('\n');
|
|
270
302
|
const min = minTokensOut(cfg);
|
|
@@ -280,7 +312,7 @@ export async function reviewFile(input, deps) {
|
|
|
280
312
|
const paths = safeNeeds(needs);
|
|
281
313
|
let extra = null;
|
|
282
314
|
try {
|
|
283
|
-
extra = paths ? await attachFiles({ repoRoot, paths, budgetTokens: budgetFor(cfg, 'full_in') }) : null;
|
|
315
|
+
extra = paths ? await attachFiles({ repoRoot, paths, budgetTokens: budgetFor(cfg, 'full_in'), ownedFiles: input.ownedFiles, changedFiles: input.changedFiles ?? input.peers ?? [] }) : null;
|
|
284
316
|
} catch {
|
|
285
317
|
extra = null;
|
|
286
318
|
}
|
|
@@ -290,10 +322,10 @@ export async function reviewFile(input, deps) {
|
|
|
290
322
|
return summary;
|
|
291
323
|
};
|
|
292
324
|
|
|
293
|
-
|
|
294
|
-
|
|
325
|
+
let reviewers = plan.sessions.filter((s) => s.role === 'reviewer');
|
|
326
|
+
let judgeSpec = plan.sessions.find((s) => s.role === 'judge');
|
|
295
327
|
/** @param {import('./packet.mjs').FileDiff} part @param {ReadonlyArray<number> | null} [keys] */
|
|
296
|
-
const packetsFor = (part, keys = null) => reviewers.map((spec) => assemblePacket({ diff: part, lens: spec.lens, rulesDigest: input.rulesDigest, factsExcerpt: input.factsExcerpt, cfg, moved: movedOf(keys) }));
|
|
328
|
+
const packetsFor = (part, keys = null) => reviewers.map((spec) => assemblePacket({ diff: part, lens: spec.lens, rulesDigest: input.rulesDigest, factsExcerpt: input.factsExcerpt, cfg, moved: movedOf(keys), paired }));
|
|
297
329
|
|
|
298
330
|
/**
|
|
299
331
|
* The plan on one packet set (the whole file, or one Markdown section): reviewers, then the judge.
|
|
@@ -327,10 +359,25 @@ export async function reviewFile(input, deps) {
|
|
|
327
359
|
return { status: 'reviewed', approved, summary: final.review.summary, findings, sessions };
|
|
328
360
|
};
|
|
329
361
|
|
|
330
|
-
|
|
331
|
-
|
|
362
|
+
let packets = packetsFor(diff);
|
|
363
|
+
let split = /** @type {{status: 'split_required', tokensIn: number, budget: number} | undefined} */ (packets.find((p) => p.status === 'split_required'));
|
|
364
|
+
/** @type {Record<string, any>} */
|
|
365
|
+
let stepUp = {};
|
|
366
|
+
if (split && plan.depth === 'quick' && !isMarkdownFile(diff.file)) {
|
|
367
|
+
const full = planSessions({ risk: 1, cfg, kind });
|
|
368
|
+
// A configured consensus/refusal is not the single full session this step permits.
|
|
369
|
+
if (full.depth === 'full') {
|
|
370
|
+
stepUp = { step_up: { from: 'quick', tokens_in: split.tokensIn, budget: split.budget } };
|
|
371
|
+
plan = full;
|
|
372
|
+
head = { ...head, engine: plan.mode, depth: plan.depth };
|
|
373
|
+
reviewers = plan.sessions.filter((s) => s.role === 'reviewer');
|
|
374
|
+
judgeSpec = plan.sessions.find((s) => s.role === 'judge');
|
|
375
|
+
packets = packetsFor(diff);
|
|
376
|
+
split = /** @type {typeof split} */ (packets.find((p) => p.status === 'split_required'));
|
|
377
|
+
}
|
|
378
|
+
}
|
|
332
379
|
if (!split) {
|
|
333
|
-
const { status, reason, approved, summary, findings, sessions } = await reviewPart(diff, packets);
|
|
380
|
+
const { status, reason, approved, summary, findings, sessions } = await reviewPart(diff, packets, stepUp);
|
|
334
381
|
if (status !== 'reviewed') return { status, reason, approved: false, ...head, sessions };
|
|
335
382
|
return { status, approved, ...head, summary, findings, sessions };
|
|
336
383
|
}
|
package/src/review/fixloop.mjs
CHANGED
|
@@ -93,6 +93,7 @@ import { contentHash, gitChildEnv } from '../worker/ticket.mjs';
|
|
|
93
93
|
import { parseDiff } from './context.mjs';
|
|
94
94
|
import { assemblePacket, assertPacketPath, diffOnlyTokens, readFileDiff } from './packet.mjs';
|
|
95
95
|
import { movedText, movesFor, peerDiffReader } from './moved.mjs';
|
|
96
|
+
import { pairedContext } from './engine.mjs';
|
|
96
97
|
import { isMarkdownFile, packSections, sectionFindings, sectionGroups, sectionOf } from './sections.mjs';
|
|
97
98
|
import { cameFromJudge, resolvedBand, triageFindings } from './triage.mjs';
|
|
98
99
|
import { FINDING_SCHEMA, minTokensOut, validateReview } from './validate-review.mjs';
|
|
@@ -144,6 +145,9 @@ import { afterSchemaMiss, logSecondLevelMiss, packetHash } from './schema-fallba
|
|
|
144
145
|
* @property {(req: {file: string, level: 'L3', open: Finding[]}) => Promise<unknown>} [patch] - the L3 patch rung (≤ 80 lines, owned files only).
|
|
145
146
|
* @property {string | null} [base] - the block base (`recheck_scope: file`, and the B56 moved-code diff).
|
|
146
147
|
* @property {ReadonlyArray<string>} [peers] - B56: the block's other changed files (moved-code hints).
|
|
148
|
+
* @property {ReadonlyArray<string>} [ownedFiles] - concrete paths owned by this block.
|
|
149
|
+
* @property {ReadonlyArray<string>} [changedFiles] - concrete changed paths of the same block.
|
|
150
|
+
* @property {ReadonlyArray<string>} [testFiles] - the block's explicit test_files.
|
|
147
151
|
* @property {() => Promise<import('./moved.mjs').PeerDiff[]>} [peerDiffs] - B56: a memoised reader of their diffs (`peerDiffReader`); wins over `peers`.
|
|
148
152
|
* @property {string} [rulesDigest] @property {string} [factsExcerpt]
|
|
149
153
|
* @property {number} [extraRounds] - B47: rounds past `review.max_rounds_per_file` granted by autopilot (`extraRoundsFor`).
|
|
@@ -362,11 +366,11 @@ export function recheckTokens(diff, open, moved = '') {
|
|
|
362
366
|
/**
|
|
363
367
|
* The recheck packet: the `recheck` lens on the fix hunks (hunk form, never the whole file), then
|
|
364
368
|
* the open findings by id. Over budget ⇒ `minimal` (see the module doc).
|
|
365
|
-
* @param {{diff: import('./packet.mjs').FileDiff, open: Finding[], cfg?: Record<string, any>, contextMode?: 'auto' | 'recheck', rulesDigest?: string, factsExcerpt?: string, moved?: string}} opts -
|
|
369
|
+
* @param {{diff: import('./packet.mjs').FileDiff, open: Finding[], cfg?: Record<string, any>, contextMode?: 'auto' | 'recheck', rulesDigest?: string, factsExcerpt?: string, moved?: string, paired?: string}} opts -
|
|
366
370
|
* `moved`: the file's `## moved code` section (B56), '' for none.
|
|
367
371
|
*/
|
|
368
|
-
export function buildRecheckPacket({ diff, open, cfg, contextMode = 'recheck', rulesDigest, factsExcerpt, moved = '' }) {
|
|
369
|
-
const packet = assemblePacket({ diff, lens: 'recheck', cfg, contextMode, rulesDigest, factsExcerpt, moved });
|
|
372
|
+
export function buildRecheckPacket({ diff, open, cfg, contextMode = 'recheck', rulesDigest, factsExcerpt, moved = '', paired = '' }) {
|
|
373
|
+
const packet = assemblePacket({ diff, lens: 'recheck', cfg, contextMode, rulesDigest, factsExcerpt, moved, paired });
|
|
370
374
|
if (packet.status !== 'ok') return packet;
|
|
371
375
|
const text = `${packet.text}${openFindingsText(open)}`;
|
|
372
376
|
return { ...packet, text, tokensIn: packet.tokensIn + Math.ceil(Buffer.byteLength(text.slice(packet.text.length)) / 4) };
|
|
@@ -436,12 +440,21 @@ export function insideHunks(f, hunks) {
|
|
|
436
440
|
* (`schema-fallback.mjs`; rechecks and the patch_check alike).
|
|
437
441
|
* @param {{text: string, hunkHeaders: string[], tokensIn: number, contextMode: string}} packet
|
|
438
442
|
* @param {LoopDeps} deps
|
|
443
|
+
* @param {ReadonlyArray<Finding>} open - only the findings listed in this packet.
|
|
439
444
|
*/
|
|
440
|
-
async function recheckSession(packet, deps) {
|
|
445
|
+
async function recheckSession(packet, deps, open) {
|
|
441
446
|
mkdirSync(deps.workDir, { recursive: true, mode: 0o700 });
|
|
442
447
|
const promptPath = path.join(deps.workDir, `recheck-${randomBytes(6).toString('hex')}.md`);
|
|
443
448
|
writeFileSync(promptPath, packet.text, { mode: 0o600 });
|
|
444
449
|
const min = minTokensOut(deps.cfg);
|
|
450
|
+
/** @param {Record<string, any> | null} result @returns {import('./validate-review.mjs').Verdict} */
|
|
451
|
+
const validate = (result) => {
|
|
452
|
+
const verdict = validateReview(result, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
|
|
453
|
+
if (!verdict.ok) return verdict;
|
|
454
|
+
const ids = new Set((verdict.review?.resolved ?? []).map((/** @type {{id: string}} */ entry) => entry.id));
|
|
455
|
+
if (open.some((finding) => !ids.has(finding.id))) return { ok: false, reason: 'schema', detail: 'resolved does not cover the packet open findings' };
|
|
456
|
+
return verdict;
|
|
457
|
+
};
|
|
445
458
|
const session = { lens: 'recheck', role: 'reviewer', level: 'L2' };
|
|
446
459
|
// no ledger writer ⇒ nothing logged, nothing tried; every row goes through the same writer
|
|
447
460
|
// (each helper handles its own failed write)
|
|
@@ -485,7 +498,7 @@ async function recheckSession(packet, deps) {
|
|
|
485
498
|
} catch {
|
|
486
499
|
res = null; // the helper never throws; if it ever did, it is still an `exit` failure
|
|
487
500
|
}
|
|
488
|
-
verdict =
|
|
501
|
+
verdict = validate(res);
|
|
489
502
|
if (first?.to) {
|
|
490
503
|
// B91: a timed-out attempt was the packet's second broken result; the second level ran. The
|
|
491
504
|
// verdict is already set: whatever throws after it leaves that verdict, never an exception
|
|
@@ -510,7 +523,7 @@ async function recheckSession(packet, deps) {
|
|
|
510
523
|
const second = await tryNext(res, 'schema');
|
|
511
524
|
if (second) {
|
|
512
525
|
// compute everything first, then assign together, so a throw never leaves a mixed state
|
|
513
|
-
const secondVerdict =
|
|
526
|
+
const secondVerdict = validate(second.res);
|
|
514
527
|
const sumIn = sum(tokensIn, second.res?.usage?.tokens_in);
|
|
515
528
|
const sumOut = sum(tokensOut, second.res?.usage?.tokens_out);
|
|
516
529
|
res = second.res;
|
|
@@ -664,6 +677,11 @@ export async function runRound(state, deps, opts = {}) {
|
|
|
664
677
|
s.scope === 'file'
|
|
665
678
|
? await readFileDiff({ repoRoot: deps.repoRoot, file: current.rel, base: deps.base ?? null })
|
|
666
679
|
: await fixHunkDiff({ file: current.rel, previous: state.reviewed_content ?? '', current: current.content, workDir: deps.workDir });
|
|
680
|
+
if (kind === 'recheck' && (current.content === state.reviewed_content || diff.hunks.length === 0)) {
|
|
681
|
+
state.next = { action: 'fix', level: state.level, trigger: null, reason: 'no-fix-hunks' };
|
|
682
|
+
state.pending_kind = null;
|
|
683
|
+
return state;
|
|
684
|
+
}
|
|
667
685
|
if (diff.hunks.length > 0) {
|
|
668
686
|
/** @type {Finding[]} */
|
|
669
687
|
const pending = [];
|
|
@@ -676,7 +694,8 @@ export async function runRound(state, deps, opts = {}) {
|
|
|
676
694
|
if (pending.length > 0) {
|
|
677
695
|
const contextMode = s.scope === 'file' ? 'auto' : 'recheck';
|
|
678
696
|
const moves = await recheckMoves(deps, current.rel, s.scope === 'file' ? diff : null);
|
|
679
|
-
const
|
|
697
|
+
const paired = pairedContext({ ...deps, file: current.rel });
|
|
698
|
+
const whole = buildRecheckPacket({ diff, open: pending, cfg: deps.cfg, contextMode, rulesDigest: deps.rulesDigest, factsExcerpt: deps.factsExcerpt, moved: movedText(moves, diff.content, null), paired });
|
|
680
699
|
/** @type {Array<{index: number | null, heading: string | null, diff: import('./packet.mjs').FileDiff, open: Finding[], keys: number[] | null}>} */
|
|
681
700
|
let parts = [{ index: null, heading: null, diff, open: pending, keys: null }];
|
|
682
701
|
if (whole.status !== 'ok') {
|
|
@@ -697,7 +716,7 @@ export async function runRound(state, deps, opts = {}) {
|
|
|
697
716
|
continue;
|
|
698
717
|
}
|
|
699
718
|
const moved = movedText(moves, diff.content, part.keys);
|
|
700
|
-
const packet = buildRecheckPacket({ diff: part.diff, open: part.open, cfg: deps.cfg, contextMode, rulesDigest: deps.rulesDigest, factsExcerpt: deps.factsExcerpt, moved });
|
|
719
|
+
const packet = buildRecheckPacket({ diff: part.diff, open: part.open, cfg: deps.cfg, contextMode, rulesDigest: deps.rulesDigest, factsExcerpt: deps.factsExcerpt, moved, paired });
|
|
701
720
|
// a packet that fails to build stops with ITS status (and size, when it has one)
|
|
702
721
|
if (packet.status !== 'ok') return stopBeforeRound(state, deps, kind, current.rel, { ...packet, section: part.heading });
|
|
703
722
|
const size = recheckTokens(part.diff, part.open, moved);
|
|
@@ -713,7 +732,7 @@ export async function runRound(state, deps, opts = {}) {
|
|
|
713
732
|
for (const [i, part] of parts.entries()) {
|
|
714
733
|
const packet = /** @type {any} */ (built[i]);
|
|
715
734
|
packetInfo = mergePacketInfo(packetInfo, packet);
|
|
716
|
-
const session = await recheckSession(packet, deps);
|
|
735
|
+
const session = await recheckSession(packet, deps, part.open);
|
|
717
736
|
tokensIn = sum(tokensIn, session.tokens_in);
|
|
718
737
|
tokensOut = sum(tokensOut, session.tokens_out);
|
|
719
738
|
if (!session.verdict.ok) {
|
|
@@ -743,7 +762,7 @@ export async function runRound(state, deps, opts = {}) {
|
|
|
743
762
|
draft.open = [...stillOpen, ...triaged.fix_now, ...blocking];
|
|
744
763
|
}
|
|
745
764
|
}
|
|
746
|
-
// no fix hunks
|
|
765
|
+
// A patch_check with no fix hunks closed nothing: B73 still stops it.
|
|
747
766
|
}
|
|
748
767
|
|
|
749
768
|
if (kind !== 'patch_check') {
|
|
@@ -828,6 +847,7 @@ export async function converge(state, deps) {
|
|
|
828
847
|
}
|
|
829
848
|
await deps.fix?.({ file: state.file, round: state.round + 1, level: state.level, open: [...state.open], l3_patch: state.l3_rung_used });
|
|
830
849
|
await runRound(state, deps);
|
|
850
|
+
if (state.next?.reason === 'no-fix-hunks') return state;
|
|
831
851
|
}
|
|
832
852
|
return state;
|
|
833
853
|
}
|
package/src/review/lenses/A.md
CHANGED
|
@@ -14,6 +14,7 @@ You work blind: you do not see the other reviewer's answer.
|
|
|
14
14
|
## Rules for every answer
|
|
15
15
|
|
|
16
16
|
- Judge only what is in the packet; if you need a file you cannot see, return it in `needs_file` instead of guessing.
|
|
17
|
+
- A test named in `## paired` exists; do not report it as unseen; use `needs_file` when its body matters.
|
|
17
18
|
- You have no tools and no project files. The packet is everything you get.
|
|
18
19
|
- `reviewed_hunks`: copy every `@@ … @@` hunk header listed under "hunks" in the packet, exactly and in the same order. A review whose `reviewed_hunks` does not match is discarded.
|
|
19
20
|
- Answer with ONE JSON object that matches the answer schema: `passed`, `summary` (≤ 300 chars), `reviewed_hunks`, `findings[]`, and `needs_file[]` (empty when you need nothing).
|
package/src/review/lenses/B.md
CHANGED
|
@@ -15,6 +15,7 @@ You work blind: you do not see the other reviewer's answer.
|
|
|
15
15
|
## Rules for every answer
|
|
16
16
|
|
|
17
17
|
- Judge only what is in the packet; if you need a file you cannot see, return it in `needs_file` instead of guessing.
|
|
18
|
+
- A test named in `## paired` exists; do not report it as unseen; use `needs_file` when its body matters.
|
|
18
19
|
- You have no tools and no project files. The packet is everything you get.
|
|
19
20
|
- `reviewed_hunks`: copy every `@@ … @@` hunk header listed under "hunks" in the packet, exactly and in the same order. A review whose `reviewed_hunks` does not match is discarded.
|
|
20
21
|
- Answer with ONE JSON object that matches the answer schema: `passed`, `summary` (≤ 300 chars), `reviewed_hunks`, `findings[]`, and `needs_file[]` (empty when you need nothing).
|
|
@@ -15,6 +15,7 @@ Report each real defect once, at the lines where it lives.
|
|
|
15
15
|
## Rules for every answer
|
|
16
16
|
|
|
17
17
|
- Judge only what is in the packet; if you need a file you cannot see, return it in `needs_file` instead of guessing.
|
|
18
|
+
- A test named in `## paired` exists; do not report it as unseen; use `needs_file` when its body matters.
|
|
18
19
|
- You have no tools and no project files. The packet is everything you get.
|
|
19
20
|
- `reviewed_hunks`: copy every `@@ … @@` hunk header listed under "hunks" in the packet, exactly and in the same order. A review whose `reviewed_hunks` does not match is discarded.
|
|
20
21
|
- Answer with ONE JSON object that matches the answer schema: `passed`, `summary` (≤ 300 chars), `reviewed_hunks`, `findings[]`, and `needs_file[]` (empty when you need nothing).
|
|
@@ -18,6 +18,7 @@ that changed since that round, each with a window of the current file around it.
|
|
|
18
18
|
## Rules for every answer
|
|
19
19
|
|
|
20
20
|
- Judge only what is in the packet; if you need a file you cannot see, return it in `needs_file` instead of guessing.
|
|
21
|
+
- A test named in `## paired` exists; do not report it as unseen; use `needs_file` when its body matters.
|
|
21
22
|
- You have no tools and no project files. The packet is everything you get.
|
|
22
23
|
- `reviewed_hunks`: copy every `@@ … @@` hunk header listed under "hunks" in the packet, exactly and in the same order.
|
|
23
24
|
- Answer with ONE JSON object that matches the answer schema.
|