@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/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
- const ended = async (/** @type {'closed' | 'stopped'} */ status, /** @type {string} */ event, /** @type {string | null} */ reason) => {
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 reviews = reviewGateCheck(async () => {
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
- // B36: the coder's report names a review-file ticket for every changed file, or it is FAILED
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 missing = missingReviewTickets(text, fileSet.map((f) => f.file));
639
- return missing.length === 0 ? { ok: true } : { ok: false, reason: `coder report FAILED: no review-file ticket id for ${missing.join(', ')}` };
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
- const end = result.status === 'closed' || result.status === 'stopped' ? await ended(result.status, result.event ?? (result.status === 'stopped' ? 'block.stop' : 'block.close'), result.reason ?? null) : null;
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);
@@ -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 fresh attempt (`retry: true`),
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`, or `refused` with
32
- * `other-run`, `context-unreadable` or a `worktree-*` reason (an allow-list, fail closed). Any
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`, or `refused` with a reason that
133
- * is not a verdict (`other-run`, `context-unreadable`, `worktree-*`) — whose marker names the same
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
- * @param {{repoRoot: string, run: string, block: string, file: string, where: {tree?: string}, say: (obj: Record<string, any>) => unknown, dropDone?: typeof dropDoneIf}} opts
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.
@@ -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 diff alone over the packet budget is `split_required` (with `tokens_in` and `budget`) —
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
- const plan = planSessions({ risk, cfg, kind });
238
- const head = { engine: plan.mode, depth: plan.depth, risk, file: diff.file };
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
- const reviewers = plan.sessions.filter((s) => s.role === 'reviewer');
294
- const judgeSpec = plan.sessions.find((s) => s.role === 'judge');
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
- const packets = packetsFor(diff);
331
- const split = /** @type {{status: 'split_required', tokensIn: number, budget: number} | undefined} */ (packets.find((p) => p.status === 'split_required'));
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
  }
@@ -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 = validateReview(res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
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 = validateReview(second.res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
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 whole = buildRecheckPacket({ diff, open: pending, cfg: deps.cfg, contextMode, rulesDigest: deps.rulesDigest, factsExcerpt: deps.factsExcerpt, moved: movedText(moves, diff.content, null) });
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 (the content did not change): nothing closes — the round stalls
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
  }
@@ -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).
@@ -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.