@rigour-labs/core 6.7.8 → 6.7.10

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.
@@ -40,7 +40,7 @@ export async function roundsForPr(cwd, pr, config, exec = defaultExec, options =
40
40
  }
41
41
  const approval = options.approvedHead ? byPerson.find((r) => r.state === 'APPROVED' && r.commit_id) : undefined;
42
42
  const approved = approval
43
- ? { id: `pr${pr}-approved`, commit: String(approval.commit_id), base: await baseAt(cwd, String(approval.commit_id), approval.submitted_at, mainRef, exec), reviewed_at: String(approval.submitted_at), pr, points: [], must_not_flag: [] }
43
+ ? { id: `pr${pr}-approved`, commit: String(approval.commit_id), base: await baseAt(cwd, String(approval.commit_id), approval.submitted_at, mainRef, exec), reviewed_at: String(approval.submitted_at), approved: true, pr, points: [], must_not_flag: [] }
44
44
  : undefined;
45
45
  if (rounds.length === 0 && !approved)
46
46
  throw new Error(`pull request ${pr} has no review by a person yet`);
@@ -55,6 +55,8 @@ declare const Round: z.ZodObject<{
55
55
  base: z.ZodString;
56
56
  /** When the human review was posted: the reviewer sees nothing from then on. */
57
57
  reviewed_at: z.ZodOptional<z.ZodString>;
58
+ /** The head a person approved: `reviewed_at` is the approval, and the reviewer sees it (it closes the points before it). */
59
+ approved: z.ZodOptional<z.ZodBoolean>;
58
60
  pr: z.ZodOptional<z.ZodNumber>;
59
61
  points: z.ZodArray<z.ZodObject<{
60
62
  /** A regular expression over the finding's file path. */
@@ -123,6 +125,7 @@ declare const Round: z.ZodObject<{
123
125
  }[];
124
126
  pr?: number | undefined;
125
127
  reviewed_at?: string | undefined;
128
+ approved?: boolean | undefined;
126
129
  }, {
127
130
  id: string;
128
131
  commit: string;
@@ -137,6 +140,7 @@ declare const Round: z.ZodObject<{
137
140
  }[];
138
141
  pr?: number | undefined;
139
142
  reviewed_at?: string | undefined;
143
+ approved?: boolean | undefined;
140
144
  must_not_flag?: {
141
145
  file: string;
142
146
  text?: string | undefined;
@@ -151,6 +155,8 @@ export declare const LedgerSchema: z.ZodObject<{
151
155
  base: z.ZodString;
152
156
  /** When the human review was posted: the reviewer sees nothing from then on. */
153
157
  reviewed_at: z.ZodOptional<z.ZodString>;
158
+ /** The head a person approved: `reviewed_at` is the approval, and the reviewer sees it (it closes the points before it). */
159
+ approved: z.ZodOptional<z.ZodBoolean>;
154
160
  pr: z.ZodOptional<z.ZodNumber>;
155
161
  points: z.ZodArray<z.ZodObject<{
156
162
  /** A regular expression over the finding's file path. */
@@ -219,6 +225,7 @@ export declare const LedgerSchema: z.ZodObject<{
219
225
  }[];
220
226
  pr?: number | undefined;
221
227
  reviewed_at?: string | undefined;
228
+ approved?: boolean | undefined;
222
229
  }, {
223
230
  id: string;
224
231
  commit: string;
@@ -233,6 +240,7 @@ export declare const LedgerSchema: z.ZodObject<{
233
240
  }[];
234
241
  pr?: number | undefined;
235
242
  reviewed_at?: string | undefined;
243
+ approved?: boolean | undefined;
236
244
  must_not_flag?: {
237
245
  file: string;
238
246
  text?: string | undefined;
@@ -261,6 +269,7 @@ export declare const LedgerSchema: z.ZodObject<{
261
269
  }[];
262
270
  pr?: number | undefined;
263
271
  reviewed_at?: string | undefined;
272
+ approved?: boolean | undefined;
264
273
  }[];
265
274
  }, {
266
275
  rounds: {
@@ -277,6 +286,7 @@ export declare const LedgerSchema: z.ZodObject<{
277
286
  }[];
278
287
  pr?: number | undefined;
279
288
  reviewed_at?: string | undefined;
289
+ approved?: boolean | undefined;
280
290
  must_not_flag?: {
281
291
  file: string;
282
292
  text?: string | undefined;
@@ -35,6 +35,8 @@ const Round = z.object({
35
35
  base: z.string().min(1),
36
36
  /** When the human review was posted: the reviewer sees nothing from then on. */
37
37
  reviewed_at: z.string().optional(),
38
+ /** The head a person approved: `reviewed_at` is the approval, and the reviewer sees it (it closes the points before it). */
39
+ approved: z.boolean().optional(),
38
40
  pr: z.number().int().positive().optional(),
39
41
  points: z.array(Point),
40
42
  must_not_flag: z.array(Match).default([]),
@@ -91,7 +93,7 @@ export async function runBacktest(cwd, config, ledger, options = {}) {
91
93
  const started = Date.now();
92
94
  const worktree = await worktreeFor(cwd, round.commit, exec);
93
95
  const head = (await exec('git', ['rev-parse', 'HEAD'], { cwd: worktree, timeoutMs: GIT_TIMEOUT_MS })).stdout.trim();
94
- progress(`backtest ${round.id}: ${head.slice(0, 9)} against ${round.base}${round.reviewed_at ? `, reviews hidden from ${round.reviewed_at}` : ''}`);
96
+ progress(`backtest ${round.id}: ${head.slice(0, 9)} against ${round.base}${round.reviewed_at ? `, reviews hidden from ${reviewsHiddenFrom(round)}` : ''}`);
95
97
  const stale = await staleBase(worktree, head, round.base, exec);
96
98
  if (stale)
97
99
  progress(`backtest ${round.id}: warning: ${stale}`);
@@ -192,7 +194,7 @@ async function collectItems(worktree, round, config, reviewer, exec, progress) {
192
194
  ];
193
195
  if (!reviewer)
194
196
  return { items };
195
- const result = await runReviewer(worktree, round.base, config, exec, progress, { pr: round.pr, reviewsBefore: round.reviewed_at, blind: round.pr === undefined, trigger: 'backtest', force: true, ...reviewerInputs(review) });
197
+ const result = await runReviewer(worktree, round.base, config, exec, progress, { pr: round.pr, reviewsBefore: reviewsHiddenFrom(round), blind: round.pr === undefined, trigger: 'backtest', force: true, ...reviewerInputs(review) });
196
198
  if (result.outcome === 'unavailable' || result.outcome === 'skipped')
197
199
  return { items, reviewerError: result.reason ?? result.outcome };
198
200
  // The reviewer behind each catch is part of the score, so the gate is `reviewer:<name>`.
@@ -237,3 +239,10 @@ function byGate(result) {
237
239
  counts.set(p.by.split(' ')[0], (counts.get(p.by.split(' ')[0]) ?? 0) + 1);
238
240
  return counts.size ? `; caught by ${[...counts].map(([gate, n]) => `${gate} ${n}`).join(', ')}` : '';
239
241
  }
242
+ /** The moment the reviewer sees nothing from: the review itself for a round, and just after the approval for an approved head. */
243
+ function reviewsHiddenFrom(round) {
244
+ if (!round.reviewed_at || !round.approved)
245
+ return round.reviewed_at;
246
+ const at = Date.parse(round.reviewed_at);
247
+ return Number.isNaN(at) ? round.reviewed_at : new Date(at + 1000).toISOString();
248
+ }
@@ -92,6 +92,18 @@ describe('rigour backtest on a repository', () => {
92
92
  catch { /* the repository may be gone */ }
93
93
  fs.rmSync(repo, { recursive: true, force: true });
94
94
  });
95
+ it('hides the review itself for a round, and shows the approval for an approved head', async () => {
96
+ const commit = git('rev-parse', 'HEAD');
97
+ const base = git('rev-parse', 'main');
98
+ const lines = [];
99
+ const ledger = { rounds: [
100
+ { id: 'r1', commit, base, reviewed_at: '2026-09-28T15:12:53Z', points: [], must_not_flag: [] },
101
+ { id: 'pr1-approved', commit, base, reviewed_at: '2026-09-28T15:12:53Z', approved: true, points: [], must_not_flag: [] },
102
+ { id: 'odd', commit, base, reviewed_at: 'not a date', approved: true, points: [], must_not_flag: [] },
103
+ ] };
104
+ await runBacktest(repo, ConfigSchema.parse({ version: 1 }), ledger, { progress: line => lines.push(line), collect: async () => ({ items: [] }) });
105
+ expect(lines.filter(l => l.includes('reviews hidden from')).map(l => l.replace(/^.*reviews hidden from /, ''))).toEqual(['2026-09-28T15:12:53Z', '2026-09-28T15:12:54.000Z', 'not a date']);
106
+ }, 60_000);
95
107
  it('reviews the reviewed commit in a worktree with the review hidden, scores it, and leaves the checkout alone', async () => {
96
108
  const reviewed = git('rev-parse', 'HEAD');
97
109
  const base = git('rev-parse', 'main');
@@ -12,6 +12,11 @@ export interface ApiJudgeOptions {
12
12
  roots: string[];
13
13
  reasoning?: Reasoning;
14
14
  fetchImpl?: typeof fetch;
15
+ /** The review's input files, given inline in the first message: a model that never calls a tool still has what it needs. */
16
+ inputs?: Array<{
17
+ path: string;
18
+ text: string;
19
+ }>;
15
20
  }
16
21
  export interface ApiJudgeRun {
17
22
  exitCode: number;
@@ -11,6 +11,10 @@ import path from 'path';
11
11
  import { execa } from 'execa';
12
12
  const SYSTEM = 'You review code with read-only tools. Read the files the task names with read_file (the review inputs are named by absolute path), search the repository with search, read history with git. When you are done, reply with the final answer the task asks for and nothing else.';
13
13
  const MAX_RESULT_CHARS = 60_000;
14
+ /** Output tokens a turn may use, reasoning included: a review verdict is long, and a reasoning model's thinking counts against the default budget. */
15
+ const MAX_OUTPUT_TOKENS = 32_000;
16
+ /** Characters of the inputs given inline in the first message; the rest stays in the files, for the tools. */
17
+ const MAX_INLINE_CHARS = 240_000;
14
18
  const GIT_ALLOWED = new Set(['log', 'show', 'diff', 'blame', 'grep', 'ls-files', 'rev-parse', 'merge-base']);
15
19
  /** Git options that write, or point git at another repository. */
16
20
  const GIT_REFUSED = /^(--output|-o$|--git-dir|--work-tree|-C$|--exec-path|-c$|--config-env)/;
@@ -24,7 +28,7 @@ const n = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
24
28
  export async function runApiJudge(prompt, o) {
25
29
  const fetchImpl = o.fetchImpl ?? fetch;
26
30
  const deadline = Date.now() + o.timeoutMs;
27
- const messages = [{ role: 'system', content: SYSTEM }, { role: 'user', content: prompt }];
31
+ const messages = [{ role: 'system', content: SYSTEM }, { role: 'user', content: withInputs(prompt, o.inputs ?? []) }];
28
32
  const usage = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 };
29
33
  const calls = [];
30
34
  let cost;
@@ -40,7 +44,7 @@ export async function runApiJudge(prompt, o) {
40
44
  response = await fetchImpl(`${o.url.replace(/\/$/, '')}/chat/completions`, {
41
45
  method: 'POST',
42
46
  headers: { 'content-type': 'application/json', authorization: `Bearer ${o.key}` },
43
- body: JSON.stringify({ model: o.model, messages, tools: TOOLS, tool_choice: 'auto', ...(o.reasoning ? { reasoning_effort: o.reasoning } : {}) }),
47
+ body: JSON.stringify({ model: o.model, messages, tools: TOOLS, tool_choice: 'auto', max_tokens: MAX_OUTPUT_TOKENS, ...(o.reasoning ? { reasoning_effort: o.reasoning } : {}) }),
44
48
  signal: controller.signal,
45
49
  });
46
50
  }
@@ -66,13 +70,22 @@ export async function runApiJudge(prompt, o) {
66
70
  usage.output += n(u.completion_tokens);
67
71
  if (typeof u.cost === 'number')
68
72
  cost = (cost ?? 0) + u.cost;
69
- const message = body.choices?.[0]?.message;
73
+ const choice = body.choices?.[0];
74
+ // A gateway reports a provider's failure inside the choice, with the choice's finish_reason "error": say what it said.
75
+ if (choice?.error || body.error)
76
+ return fail(`the API reported an error: ${JSON.stringify(choice?.error ?? body.error).slice(0, 300)}`);
77
+ const message = choice?.message;
70
78
  if (!message)
71
79
  return fail('no choices in the answer');
72
- messages.push(message);
80
+ // Echo back what the API needs to continue (reasoning_details carries a reasoning model's chain), not the reasoning prose.
81
+ messages.push({ role: 'assistant', content: message.content ?? null, ...(message.tool_calls ? { tool_calls: message.tool_calls } : {}), ...(message.reasoning_details ? { reasoning_details: message.reasoning_details } : {}) });
73
82
  const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
74
83
  if (toolCalls.length === 0) {
75
- const answer = { result: String(message.content ?? ''), usage, ...(cost !== undefined ? { cost_usd: cost } : {}), trace: { turns: turn, usage, calls } };
84
+ const text = String(message.content ?? '').trim();
85
+ // An empty final message is not an answer: say why (the output budget ran out, a refusal), never report it as one.
86
+ if (!text)
87
+ return fail(`an empty answer on turn ${turn} (finish_reason ${String(body.choices?.[0]?.finish_reason ?? 'unknown')}${message.refusal ? `, refusal: ${String(message.refusal).slice(0, 120)}` : ''})`);
88
+ const answer = { result: text, usage, ...(cost !== undefined ? { cost_usd: cost } : {}), trace: { turns: turn, usage, calls } };
76
89
  return { exitCode: 0, stdout: JSON.stringify(answer), stderr: '' };
77
90
  }
78
91
  for (const call of toolCalls) {
@@ -83,6 +96,22 @@ export async function runApiJudge(prompt, o) {
83
96
  }
84
97
  return fail(`no answer within ${o.maxTurns} turns`);
85
98
  }
99
+ /**
100
+ * The prompt with the inputs it names appended inline, smallest first so the diff takes what budget is left: a judge
101
+ * is fed, not left to decide whether to read. What does not fit is cut with a note naming the file to read.
102
+ */
103
+ function withInputs(prompt, inputs) {
104
+ if (inputs.length === 0)
105
+ return prompt;
106
+ const ordered = [...inputs].sort((a, b) => a.text.length - b.text.length);
107
+ let left = MAX_INLINE_CHARS;
108
+ const parts = ordered.map(input => {
109
+ const text = input.text.length > left ? `${input.text.slice(0, Math.max(0, left))}\n…[cut here: read ${input.path} with read_file for the rest]` : input.text;
110
+ left = Math.max(0, left - input.text.length);
111
+ return `### ${input.path}\n${text}`;
112
+ });
113
+ return `${prompt}\n\nThe inputs named above, inline (the files are also there for your tools):\n\n${parts.join('\n\n')}`;
114
+ }
86
115
  /** One tool call, inside the allowed roots only; the trace names tools as the CLI judges do (Read, Grep, Glob, Bash). */
87
116
  async function runTool(call, o) {
88
117
  const name = String(call?.function?.name ?? '');
@@ -78,6 +78,27 @@ describe('the API judge', () => {
78
78
  const slow = (async (_u, init) => new Promise((_r, reject) => init.signal.addEventListener('abort', () => reject(new Error('aborted')))));
79
79
  expect(await runApiJudge('p', options(slow, { timeoutMs: 50 }))).toMatchObject({ exitCode: 1, stderr: expect.stringContaining('request failed') });
80
80
  });
81
+ it('fails closed on an empty final message, saying why, and echoes back only what the API needs to continue', async () => {
82
+ const empty = (async () => new Response(JSON.stringify({ choices: [{ message: { role: 'assistant', content: '', reasoning: 'thinking…' }, finish_reason: 'length' }], usage: {} }), { status: 200 }));
83
+ expect(await runApiJudge('p', options(empty))).toMatchObject({ exitCode: 1, stderr: expect.stringContaining('an empty answer on turn 1 (finish_reason length)') });
84
+ const providerError = (async () => new Response(JSON.stringify({ choices: [{ message: { role: 'assistant', content: '' }, finish_reason: 'error', error: { message: 'Provider returned error', code: 502 } }], usage: {} }), { status: 200 }));
85
+ expect(await runApiJudge('p', options(providerError))).toMatchObject({ exitCode: 1, stderr: expect.stringContaining('the API reported an error: {"message":"Provider returned error","code":502}') });
86
+ const { fetchImpl, seen } = model([{ tools: [{ name: 'list_dir', args: { path: '.' } }] }, { text: 'ok' }]);
87
+ await runApiJudge('p', options(fetchImpl));
88
+ const echoed = seen[1].messages.find((m) => m.role === 'assistant');
89
+ expect(Object.keys(echoed).sort()).toEqual(['content', 'role', 'tool_calls']); // no reasoning prose sent back
90
+ expect(seen[0].max_tokens).toBe(32000);
91
+ });
92
+ it('gives the judge its inputs inline, smallest first, the diff cut with a note when the budget runs out', async () => {
93
+ const { fetchImpl, seen } = model([{ text: 'ok' }]);
94
+ const inputs = [{ path: '/w/full.diff', text: 'x'.repeat(300_000) }, { path: '/w/pr-description.md', text: 'the description' }, { path: '/w/previous-reviews.md', text: 'the reviews' }];
95
+ await runApiJudge('review this', options(fetchImpl, { inputs }));
96
+ const first = seen[0].messages[1].content;
97
+ expect(first.startsWith('review this\n\nThe inputs named above, inline')).toBe(true);
98
+ expect(first.indexOf('### /w/previous-reviews.md')).toBeLessThan(first.indexOf('### /w/full.diff')); // smallest first
99
+ expect(first).toContain('…[cut here: read /w/full.diff with read_file for the rest]');
100
+ expect(first.length).toBeLessThan(241_000);
101
+ });
81
102
  it('passes the reasoning effort when asked', async () => {
82
103
  const { fetchImpl, seen } = model([{ text: 'ok' }]);
83
104
  await runApiJudge('p', options(fetchImpl, { reasoning: 'low' }));
@@ -1,4 +1,5 @@
1
1
  import { type Exec } from './exec.js';
2
+ import type { Approval } from './verdict.js';
2
3
  export interface PullRequest {
3
4
  number: number;
4
5
  state: 'open' | 'closed' | 'merged';
@@ -12,6 +13,8 @@ export interface HumanReviews {
12
13
  /** Changes when a review or comment is added or edited: part of the verdict fingerprint. */
13
14
  key: string;
14
15
  count: number;
16
+ /** Each person's approval, in time order: every point that person raised before it is settled. */
17
+ approvals: Approval[];
15
18
  /** `<login>, <date> (<n> reviews)` of the latest, for the report. */
16
19
  label?: string;
17
20
  }
@@ -87,18 +87,24 @@ export async function humanReviews(gh, pr, reviewsBefore) {
87
87
  return { error: `could not read the comments of pull request ${pr.number}: ${comments.stderr.trim().slice(0, 200)}` };
88
88
  const human = (x) => isHuman(x, pr.author);
89
89
  const before = (at) => !reviewsBefore || (typeof at === 'string' && at < reviewsBefore);
90
- const rounds = parseJsonArrays(reviews.stdout).filter((r) => human(r) && String(r.body ?? '').trim() && before(r.submitted_at));
90
+ const worded = (r) => !!String(r.body ?? '').trim();
91
+ // A review with words carries points. An approval without any carries none, and still settles every point its
92
+ // author raised before it: left out, the judge would see the points and never the approval that closed them.
93
+ const rounds = parseJsonArrays(reviews.stdout).filter((r) => human(r) && (worded(r) || r.state === 'APPROVED') && before(r.submitted_at));
91
94
  const inline = parseJsonArrays(comments.stdout).filter((c) => human(c) && before(c.created_at));
92
- let markdown = rounds.map((r) => `## Review by ${r.user.login}, ${r.submitted_at}, ${r.state} (on ${String(r.commit_id ?? '').slice(0, 9)})\n\n${String(r.body).trim()}\n`).join('\n');
95
+ const approvals = rounds.filter((r) => r.state === 'APPROVED').map((r) => ({ login: String(r.user.login), at: String(r.submitted_at), commit: String(r.commit_id ?? '') }));
96
+ let markdown = rounds.map((r) => `## Review by ${r.user.login}, ${r.submitted_at}, ${r.state} (on ${String(r.commit_id ?? '').slice(0, 9)})\n\n${worded(r) ? String(r.body).trim() : `(approved without comment: every point ${r.user.login} raised before this is settled)`}\n`).join('\n');
93
97
  if (inline.length)
94
98
  markdown += `\n## Inline comments\n${inline.map((c) => `- ${c.created_at} ${c.path}:${c.line ?? c.original_line ?? '?'}: ${String(c.body ?? '').trim()}`).join('\n')}\n`;
95
99
  const latest = rounds.at(-1);
100
+ const count = rounds.filter(worded).length;
96
101
  return {
97
102
  reviews: {
98
103
  markdown: markdown || 'none\n',
99
104
  key: [...rounds.map((r) => `${r.id},${r.submitted_at},${r.commit_id},${r.state}`), ...inline.map((c) => `${c.id},${c.updated_at}`)].join('|'),
100
- count: rounds.length,
101
- ...(latest ? { label: `${latest.user.login}, ${latest.submitted_at} (${rounds.length} review${rounds.length === 1 ? '' : 's'})` } : {}),
105
+ count,
106
+ approvals,
107
+ ...(latest ? { label: `${latest.user.login}, ${latest.submitted_at} (${count} review${count === 1 ? '' : 's'})` } : {}),
102
108
  },
103
109
  };
104
110
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { humanReviews } from './inputs.js';
3
+ const pr = { number: 7, state: 'open', draft: false, author: 'author', body: '' };
4
+ const reviews = [
5
+ { id: 1, user: { login: 'senior', type: 'User' }, body: 'Keep a separate case for a visitor with no account.', state: 'CHANGES_REQUESTED', submitted_at: '2026-09-25T18:09:11Z', commit_id: 'a0a0a0a0a0aa' },
6
+ { id: 2, user: { login: 'senior', type: 'User' }, body: '', state: 'APPROVED', submitted_at: '2026-09-28T15:12:53Z', commit_id: 'b1b1b1b1b1bb' },
7
+ { id: 3, user: { login: 'author', type: 'User' }, body: '', state: 'APPROVED', submitted_at: '2026-09-28T16:00:00Z', commit_id: 'b1b1b1b1b1bb' },
8
+ ];
9
+ const gh = async (args) => ({ exitCode: 0, stdout: JSON.stringify(args[1].endsWith('/reviews') ? reviews : []), stderr: '' });
10
+ describe('the human reviews a judge reads', () => {
11
+ it('keeps an approval without words: it settles the points its author raised before it', async () => {
12
+ const { reviews: read } = await humanReviews(gh, pr, undefined);
13
+ expect(read.markdown).toContain('## Review by senior, 2026-09-25T18:09:11Z, CHANGES_REQUESTED (on a0a0a0a0a)');
14
+ expect(read.markdown).toContain('## Review by senior, 2026-09-28T15:12:53Z, APPROVED (on b1b1b1b1b)\n\n(approved without comment: every point senior raised before this is settled)');
15
+ expect(read.approvals).toEqual([{ login: 'senior', at: '2026-09-28T15:12:53Z', commit: 'b1b1b1b1b1bb' }]); // the author's own approval is not a review
16
+ expect(read.count).toBe(1); // reviews with points: the judge must answer those, and an approval has none
17
+ expect(read.label).toBe('senior, 2026-09-28T15:12:53Z (1 review)');
18
+ });
19
+ it('hides the approval like any review from the reviewed moment on', async () => {
20
+ const { reviews: read } = await humanReviews(gh, pr, '2026-09-28T15:12:53Z');
21
+ expect(read.approvals).toEqual([]);
22
+ expect(read.markdown).not.toContain('APPROVED');
23
+ const later = await humanReviews(gh, pr, '2026-09-28T15:12:54.000Z');
24
+ expect(later.reviews.approvals).toHaveLength(1);
25
+ });
26
+ });
@@ -39,7 +39,13 @@ Do these steps in order. Report only what you verified in the code, with file:li
39
39
  description. Give each point the severity its reviewer gave it. For a point you say is NOT
40
40
  resolved, give file, line and quote: the code at this commit that shows it is still open, copied
41
41
  exactly. Rigour checks the quote; a point you cannot show still open that way is not open. Check the
42
- code at THIS commit: a later commit may already have done what the point asked.
42
+ code at THIS commit: a later commit may already have done what the point asked. A reviewer's
43
+ APPROVED review settles every point that reviewer raised before it, with or without words: report
44
+ such a point resolved, citing the approval; the same class coming back in code written after the
45
+ approved commit is a finding of your own (step 11), with its own input and consequence. When a point
46
+ asks for something to exist (a test case, a guard, a call) and you say it is still missing, search
47
+ the WHOLE checkout for it, not only the file the point names: a reply or a later commit may have put
48
+ it elsewhere. Give absent: the exact code or test text you searched for. Rigour searches too.
43
49
 
44
50
  2. Redundancy. A fix often leaves behind what it made unnecessary. For every hunk in the reviewed
45
51
  range that moves a condition into a query (.not, .gte, .lte, .gt, .lt, .in, .like, .eq added),
@@ -152,7 +158,7 @@ and no material cost is an opinion: leave consequence empty and it is shown, nev
152
158
  report style preferences or trade-offs you would not request changes for.
153
159
 
154
160
  Your final message must be ONLY this JSON, starting with { and ending with }, nothing before or after it:
155
- {"prior_points":[{"point":"...","review":"<login> <submitted_at>","severity":"blocking"|"should-fix"|"non-blocking","resolved":true|false,"evidence":"file:line ...","file":"<when not resolved>","line":0,"quote":"<when not resolved: the code that shows it still open>","checked_siblings":["file:line"]}],
161
+ {"prior_points":[{"point":"...","review":"<login> <submitted_at>","severity":"blocking"|"should-fix"|"non-blocking","resolved":true|false,"evidence":"file:line ...","file":"<when not resolved>","line":0,"quote":"<when not resolved: the code that shows it still open>","absent":"<when not resolved because something is missing: the exact text you searched the checkout for>","checked_siblings":["file:line"]}],
156
162
  "redundant":[{"file":"...","line":0,"what":"...","made_redundant_by":"file:line","removed":true|false}],
157
163
  "reads":[{"file":"...","line":0,"read":"...","rules":[{"rule":"...","known_before_read":true|false,"applied_before_read":true|false}],"consumer":{"file":"...","line":0,"uses":"ids-only"|"rows"|"aggregate"},"narrower_source":null|"...","keys":[{"name":"...","inputs":"...","stable_under_edit":true|false}],"window_bounded":true|false|null,"keyset":true|false|null,"index":"..."}],
158
164
  "scans":[{"file":"...","line":0,"function":"...","outer":"...","inner":"...","fix":"..."}],
@@ -11,7 +11,27 @@ export interface PriorPoint {
11
11
  file?: string;
12
12
  line?: number;
13
13
  quote?: string;
14
+ absent?: string;
15
+ }
16
+ /** A person's approval of the pull request: every point they raised before `at` is settled. */
17
+ export interface Approval {
18
+ login: string;
19
+ at: string;
20
+ commit?: string;
14
21
  }
22
+ /**
23
+ * What an item is checked against besides its quote: who approved (a prior point), whether the checkout has what a
24
+ * point calls missing, and which lines the change touched (a block must sit on one; unknown when absent).
25
+ */
26
+ export interface PriorChecks {
27
+ approvals: Approval[];
28
+ inCheckout: (text: string) => string | undefined;
29
+ changed?: ChangedLines;
30
+ }
31
+ /** The lines a change touched, per file: every line added, and the place of every deletion, in the new file's numbering. */
32
+ export type ChangedLines = Map<string, Set<number>>;
33
+ /** The touched lines of a unified diff (`git diff base...head`). */
34
+ export declare function changedLinesOf(diff: string): ChangedLines;
15
35
  interface Redundant {
16
36
  file: string;
17
37
  line?: number;
@@ -195,6 +215,11 @@ export type Verify = (file: string, line: number | undefined, quote?: string) =>
195
215
  * that is not where the judge says it is never blocks, whichever model made it.
196
216
  */
197
217
  export declare function checkoutVerifier(cwd: string): Verify;
218
+ /**
219
+ * Searches the whole checkout for a line of text (the first non-empty line of `text`, whitespace aside): `file:line` of
220
+ * the first place it is, or undefined. A `git grep` so the tracked tree is searched and nothing else.
221
+ */
222
+ export declare function checkoutSearch(cwd: string): (text: string) => string | undefined;
198
223
  /** The verdict in a reviewer's answer, or why it is not one. `needsPriorPoints`: a human review exists and none of its points is carried. */
199
224
  export declare function parseVerdict(text: string, needsPriorPoints: boolean, reviewer: string, spend: Spend): {
200
225
  verdict: Verdict;
@@ -229,7 +254,7 @@ export interface Accounting {
229
254
  advisory: OpenItem[];
230
255
  }
231
256
  /** Everything the reviewers reported blocks; in delta mode, previous open items carry unless resolved with evidence. */
232
- export declare function account(verdict: Verdict, previousOpen: OpenItem[] | undefined, verify: Verify): Accounting;
257
+ export declare function account(verdict: Verdict, previousOpen: OpenItem[] | undefined, verify: Verify, prior?: PriorChecks): Accounting;
233
258
  export declare function itemLine(item: OpenItem): string;
234
259
  /** The rules Rigour served, onto the judge's answers by id: an answer naming no served rule is dropped. */
235
260
  export declare function attachServedRules(verdict: Verdict, served: ServedRule[]): void;
@@ -7,10 +7,55 @@
7
7
  * checked against the checkout before it can block: a file not in the repository, or a line past
8
8
  * its end, is a reviewer's slip, reported apart and never a block.
9
9
  */
10
+ import { execFileSync } from 'child_process';
10
11
  import { createHash } from 'crypto';
11
12
  import fs from 'fs';
12
13
  import path from 'path';
13
14
  import { textSimilarity } from './consensus.js';
15
+ const NO_PRIOR_CHECKS = { approvals: [], inCheckout: () => undefined };
16
+ /** The touched lines of a unified diff (`git diff base...head`). */
17
+ export function changedLinesOf(diff) {
18
+ const changed = new Map();
19
+ let file;
20
+ let line = 0;
21
+ for (const raw of diff.split('\n')) {
22
+ if (raw.startsWith('diff --git ') || /^--- (a\/|\/dev\/null)/.test(raw)) {
23
+ file = undefined; // headers, until the new file's name
24
+ }
25
+ else if (/^\+\+\+ (b\/|\/dev\/null)/.test(raw)) {
26
+ const name = raw.slice(4).replace(/^b\//, '').replace(/\t.*$/, '');
27
+ file = name === '/dev/null' ? undefined : (changed.get(name) ?? new Set());
28
+ if (file)
29
+ changed.set(name, file);
30
+ }
31
+ else if (raw.startsWith('@@ ')) {
32
+ line = Number(/\+(\d+)/.exec(raw)?.[1] ?? 0);
33
+ }
34
+ else if (raw.startsWith('+')) {
35
+ file?.add(line);
36
+ line++;
37
+ }
38
+ else if (raw.startsWith('-')) {
39
+ file?.add(line);
40
+ }
41
+ else if (raw.startsWith(' ') || raw === '') {
42
+ line++;
43
+ }
44
+ }
45
+ return changed;
46
+ }
47
+ /** Whether a line of a file is within CHANGED_WINDOW lines of one the change touched. */
48
+ function nearChanged(changed, file, line) {
49
+ const lines = changed.get(file);
50
+ if (!lines)
51
+ return false;
52
+ for (let l = line - CHANGED_WINDOW; l <= line + CHANGED_WINDOW; l++)
53
+ if (lines.has(l))
54
+ return true;
55
+ return false;
56
+ }
57
+ /** How far from a touched line a block may sit: a judge names the statement, the diff names the line. */
58
+ const CHANGED_WINDOW = 3;
14
59
  /**
15
60
  * A file in the checkout, and a line it has: an item naming anything else is a reviewer's slip. With a quote, the
16
61
  * quoted code must also be there, within QUOTE_WINDOW lines of the line named (whitespace aside): a claim about code
@@ -44,6 +89,34 @@ export function checkoutVerifier(cwd) {
44
89
  }
45
90
  /** How far from the line it names a finding's quote may sit: judges count lines loosely. */
46
91
  const QUOTE_WINDOW = 3;
92
+ /** The approval, if any, the point's own reviewer gave after raising it (`review` reads `<login> <submitted_at>`). */
93
+ function settledBy(point, approvals) {
94
+ const [login, ...rest] = (point.review ?? '').trim().split(/\s+/);
95
+ if (!login)
96
+ return undefined;
97
+ const at = rest.join(' ');
98
+ return approvals.find(a => a.login === login && (!at || a.at > at));
99
+ }
100
+ /**
101
+ * Searches the whole checkout for a line of text (the first non-empty line of `text`, whitespace aside): `file:line` of
102
+ * the first place it is, or undefined. A `git grep` so the tracked tree is searched and nothing else.
103
+ */
104
+ export function checkoutSearch(cwd) {
105
+ return text => {
106
+ const line = text.split('\n').map(l => l.trim()).find(Boolean);
107
+ if (!line)
108
+ return undefined;
109
+ try {
110
+ const out = execFileSync('git', ['grep', '-n', '-I', '-F', '-e', line, '--', '.'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000 });
111
+ const hit = out.split('\n').find(Boolean);
112
+ const m = hit?.match(/^(.*?):(\d+):/);
113
+ return m ? `${m[1]}:${m[2]}` : undefined;
114
+ }
115
+ catch {
116
+ return undefined;
117
+ }
118
+ };
119
+ }
47
120
  /** How alike a finding and a point a human accepted as non-blocking must read to be that point again. */
48
121
  const ACCEPTED_SIMILARITY = 0.4;
49
122
  /** The reviewer's working steps: shown so a person can follow the reasoning, never a block on their own. */
@@ -135,7 +208,7 @@ export function evidenceTouched(evidence, touched) {
135
208
  }
136
209
  const id = (...parts) => createHash('sha1').update(parts.map(p => String(p ?? '')).join('|').toLowerCase().replace(/\s+/g, ' ')).digest('hex').slice(0, 10);
137
210
  /** Everything the reviewers reported blocks; in delta mode, previous open items carry unless resolved with evidence. */
138
- export function account(verdict, previousOpen, verify) {
211
+ export function account(verdict, previousOpen, verify, prior = NO_PRIOR_CHECKS) {
139
212
  const open = [];
140
213
  const unverified = [];
141
214
  const notes = [];
@@ -159,17 +232,45 @@ export function account(verdict, previousOpen, verify) {
159
232
  if (WORKING_NOTES.has(item.kind))
160
233
  return void notes.push(item);
161
234
  const placed = !!item.file && !!item.quote?.trim() && verify(item.file, item.line, item.quote);
162
- (placed ? open : unverified).push(item);
235
+ if (!placed)
236
+ return void unverified.push(item);
237
+ // A block is about this change. A finding or rule break in a touched file but on lines the change did not touch is
238
+ // what the code already had: shown as a note, never a block on this change. A human's point is about the change by
239
+ // definition. Without the diff (a judge's own items for a panel, a test) nothing is known and nothing is moved.
240
+ if (item.kind !== 'prior' && prior.changed && (item.line === undefined || !nearChanged(prior.changed, item.file, item.line))) {
241
+ return void notes.push({ ...item, evidence: `${item.evidence ? `${item.evidence}; ` : ''}${item.line === undefined ? 'names no line' : 'on a line this change did not touch'}: what the code already had, never a block on this change` });
242
+ }
243
+ open.push(item);
163
244
  };
164
245
  const answerInReply = [];
165
246
  for (const p of verdict.prior_points) {
166
247
  if (p.resolved)
167
248
  continue;
168
- if (p.severity === 'non-blocking')
249
+ if (p.severity === 'non-blocking') {
169
250
  answerInReply.push(p);
251
+ continue;
252
+ }
253
+ const item = { id: id('prior', p.review, p.point), kind: 'prior', class: 'prior point', issue: p.point, evidence: p.evidence, reviewer: p.reviewer, ...(p.file ? { file: p.file } : {}), ...(p.line ? { line: p.line } : {}), ...(p.quote ? { quote: p.quote } : {}) };
254
+ // The person who raised it approved afterwards: settled, whatever the judge makes of the code now. The same class
255
+ // coming back in later code is a finding of the judge's own, with its own input and consequence.
256
+ const approval = settledBy(p, prior.approvals);
257
+ if (approval) {
258
+ if (!seen.has(item.id))
259
+ notes.push({ ...item, evidence: `${approval.login} approved on ${approval.at}, after raising it: settled` });
260
+ seen.add(item.id);
261
+ continue;
262
+ }
263
+ // A point that says something is still missing names what it searched for, and the checkout is searched for it: the
264
+ // reply or a later commit may have put it in another file than the one the point named.
265
+ const found = p.absent?.trim() ? prior.inCheckout(p.absent) : undefined;
266
+ if (found) {
267
+ if (!seen.has(item.id))
268
+ unverified.push({ ...item, evidence: `says "${p.absent.trim()}" is missing, and the checkout has it at ${found}` });
269
+ seen.add(item.id);
270
+ continue;
271
+ }
170
272
  // Still open only where the judge quotes the code that keeps it open: a point a later commit already fixed never blocks.
171
- else
172
- add({ id: id('prior', p.review, p.point), kind: 'prior', class: 'prior point', issue: p.point, evidence: p.evidence, reviewer: p.reviewer, ...(p.file ? { file: p.file } : {}), ...(p.line ? { line: p.line } : {}), ...(p.quote ? { quote: p.quote } : {}) });
273
+ add(item);
173
274
  }
174
275
  for (const r of verdict.redundant) {
175
276
  if (r.removed === false)
@@ -34,7 +34,7 @@ import { trackUsage } from '../telemetry/telemetry.js';
34
34
  import { reviewerUsage } from './reviewer/usage.js';
35
35
  import { buildContext, dismissedAs, readReviewDismissals, relatedDocs } from './reviewer/context.js';
36
36
  import { buildRecord } from './reviewer/record.js';
37
- import { account, attachServedRules, checkoutVerifier, carryResolved, evidenceTouched, mergeVerdicts, parseVerdict } from './reviewer/verdict.js';
37
+ import { account, attachServedRules, changedLinesOf, checkoutSearch, checkoutVerifier, carryResolved, evidenceTouched, mergeVerdicts, parseVerdict } from './reviewer/verdict.js';
38
38
  import { judgeUnset } from './reviewer/judge-env.js';
39
39
  export { defaultExec, githubEnv, githubToken, parseJsonArrays } from './reviewer/exec.js';
40
40
  export { itemLine } from './reviewer/verdict.js';
@@ -107,7 +107,7 @@ async function review(cwd, base, config, exec, progress, options) {
107
107
  if (skip)
108
108
  return none('skipped', skip, { reviewers, pr: pr?.number });
109
109
  }
110
- let reviews = { markdown: 'none\n', key: '', count: 0 };
110
+ let reviews = { markdown: 'none\n', key: '', count: 0, approvals: [] };
111
111
  if (gh && pr) {
112
112
  const read = await humanReviews(gh, pr, options.reviewsBefore);
113
113
  if (read.error)
@@ -187,6 +187,7 @@ async function review(cwd, base, config, exec, progress, options) {
187
187
  const openFile = store.openPath(verdictFile);
188
188
  const previousOpen = scope === 'delta' ? store.readJson(store.openPath(previous.verdict)) ?? [] : undefined;
189
189
  const verify = checkoutVerifier(cwd);
190
+ const prior = { approvals: reviews.approvals, inCheckout: checkoutSearch(cwd), changed: changedLinesOf(fullDiff) };
190
191
  const modelFor = (name) => settings.models[name] ?? (name === 'claude' ? settings.model : undefined);
191
192
  // The record of the review, written beside the verdict once and rebuilt from the same verdict on a cached read.
192
193
  const withRecord = (accounted, verdict, cached) => {
@@ -200,7 +201,7 @@ async function review(cwd, base, config, exec, progress, options) {
200
201
  };
201
202
  if (!options.force && fs.existsSync(verdictFile) && fs.existsSync(openFile)) {
202
203
  const verdict = store.readJson(verdictFile);
203
- return withRecord(decide(verdict, previousOpen, verify, dismissals), verdict, true);
204
+ return withRecord(decide(verdict, previousOpen, verify, prior, dismissals), verdict, true);
204
205
  }
205
206
  // The daily caps, before any judge starts: a cached or reused verdict above cost nothing and never reaches here.
206
207
  const over = overBudget(store.spend(), settings, reviewers.length);
@@ -208,8 +209,9 @@ async function review(cwd, base, config, exec, progress, options) {
208
209
  return none(settings.required.panel || settings.required.mode ? 'unavailable' : 'skipped', over, { reviewers, scope, why, pr: pr?.number });
209
210
  const work = fs.mkdtempSync(path.join(os.tmpdir(), 'rigour-reviewer-'));
210
211
  // One judge run, by CLI or by API: the same prompt, the same cost accounting, the same trace.
212
+ let inlineInputs = [];
211
213
  const runJudge = (name, prompt, model) => name === 'api'
212
- ? runApiJudge(prompt, { url: settings.api.url, model: settings.api.model, key: process.env[settings.api.key_env] ?? '', maxTurns: settings.api.max_turns, timeoutMs: settings.timeout_ms, cwd, roots: [cwd, work], ...(settings.reasoning[name] ? { reasoning: settings.reasoning[name] } : {}), ...(options.fetch ? { fetchImpl: options.fetch } : {}) })
214
+ ? runApiJudge(prompt, { url: settings.api.url, model: settings.api.model, key: process.env[settings.api.key_env] ?? '', maxTurns: settings.api.max_turns, timeoutMs: settings.timeout_ms, cwd, roots: [cwd, work], inputs: inlineInputs, ...(settings.reasoning[name] ? { reasoning: settings.reasoning[name] } : {}), ...(options.fetch ? { fetchImpl: options.fetch } : {}) })
213
215
  : exec(installed.get(name).binary, ADAPTERS[name].args(prompt, model, { reasoning: settings.reasoning[name] }), { cwd, timeoutMs: settings.timeout_ms, unset: judgeUnset(name, settings.judge_env) });
214
216
  try {
215
217
  const file = (name, text) => {
@@ -223,6 +225,7 @@ async function review(cwd, base, config, exec, progress, options) {
223
225
  const diffFile = file('full.diff', fullDiff);
224
226
  const contextFile = file('team-knowledge.md', context.text);
225
227
  const hintsFile = file('hints.txt', options.hints?.trim() || 'none\n');
228
+ inlineInputs = [[reviewsFile, reviews.markdown], [prBodyFile, body], [diffstatFile, await git(['diff', '--stat', `${baseSha}...HEAD`])], [diffFile, fullDiff], [contextFile, context.text], [hintsFile, options.hints?.trim() || 'none\n']].map(([p, text]) => ({ path: p, text }));
226
229
  let delta = '';
227
230
  // A reviewer must report on the human reviews, unless every point was settled by the previous verdict and is carried.
228
231
  let needsPriorPoints = reviews.count > 0;
@@ -258,13 +261,29 @@ async function review(cwd, base, config, exec, progress, options) {
258
261
  return { run, answer, verdict: run.exitCode === 0 || answer.text.trim() ? parseVerdict(answer.text, needsPriorPoints, name, answer) : undefined };
259
262
  };
260
263
  let first = await ask();
261
- // An answer that is not a verdict is a slip, not a decision: asked once more, inside the caps, before the review is unavailable.
262
- if (first.verdict && 'error' in first.verdict && !overBudget(store.spend(), settings, 1)) {
263
- progress(`Rigour reviewer: ${name} gave no valid verdict; asking once more`);
264
+ // No verdict, whether a malformed answer or a run that died, is a slip, not a decision: asked once more, inside the caps.
265
+ if ((!first.verdict || 'error' in first.verdict) && !overBudget(store.spend(), settings, 1)) {
266
+ progress(`Rigour reviewer: ${name} gave no ${first.verdict ? 'valid verdict' : 'answer'}; asking once more`);
264
267
  first = await ask();
265
268
  }
266
269
  return first.verdict ?? { error: `${name}: no answer (exit ${first.run.exitCode}): ${first.run.stderr.trim().slice(-200)}` };
267
270
  }));
271
+ // A judge that gives nothing is replaced by the next one installed, so the boundary stays up: a review ends unavailable only when every judge failed.
272
+ const spare = candidates.filter(c => installed.has(c) && !reviewers.includes(c));
273
+ for (let i = 0; i < answers.length; i++) {
274
+ let answer = answers[i];
275
+ while ('error' in answer && spare.length && !overBudget(store.spend(), settings, 1)) {
276
+ const next = spare.shift();
277
+ progress(`Rigour reviewer: ${reviewers[i]} gave no verdict (${answer.error}); ${next} judges instead`);
278
+ modeRecord = { ...modeRecord, degraded: `${modeRecord.degraded ? `${modeRecord.degraded}; ` : ''}${reviewers[i]} gave no verdict, ${next} judged instead` };
279
+ reviewers[i] = next;
280
+ const run = await runJudge(next, prompt, modelFor(next));
281
+ const got = ADAPTERS[next].answer(run.stdout);
282
+ store.addSpend(1, got.costUsd);
283
+ answer = run.exitCode === 0 || got.text.trim() ? parseVerdict(got.text, needsPriorPoints, next, got) : { error: `${next}: no answer (exit ${run.exitCode}): ${run.stderr.trim().slice(-200)}` };
284
+ }
285
+ answers[i] = answer;
286
+ }
268
287
  const failed = answers.find(a => 'error' in a);
269
288
  if (failed && 'error' in failed)
270
289
  return none('unavailable', failed.error, { reviewers, scope, why, pr: pr?.number });
@@ -316,7 +335,7 @@ async function review(cwd, base, config, exec, progress, options) {
316
335
  });
317
336
  verdict = { ...verdict, panel: { judgeItemIds: judgeItems.flat().map(item => item.id), items }, reviewers: [...(verdict.reviewers ?? []), ...cross] };
318
337
  }
319
- const accounted = decide(verdict, previousOpen, verify, dismissals);
338
+ const accounted = decide(verdict, previousOpen, verify, prior, dismissals);
320
339
  store.writeJson(verdictFile, { ...verdict, inputs: { head, base: baseSha, scope, why, mode: modeRecord, reviewers, versions: reviewerVersions, authors: [...authors], fingerprint, human_reviews: reviews.count, reviews_before: options.reviewsBefore ?? null, since: previous?.head ?? null, at: new Date().toISOString() } });
321
340
  store.writeJson(openFile, accounted.open);
322
341
  store.writeJson(store.decidedPath(verdictFile), accounted);
@@ -329,8 +348,8 @@ async function review(cwd, base, config, exec, progress, options) {
329
348
  }
330
349
  }
331
350
  /** The accounting a verdict leads to: with a panel, only what it confirmed blocks; a finding the team dismissed never blocks. */
332
- function decide(verdict, previousOpen, verify, dismissals) {
333
- const accounted = account(verdict, previousOpen, verify);
351
+ function decide(verdict, previousOpen, verify, prior, dismissals) {
352
+ const accounted = account(verdict, previousOpen, verify, prior);
334
353
  // An item an earlier round confirmed stays open until it is resolved with evidence, whatever this panel says of it.
335
354
  const earlier = new Set((previousOpen ?? []).map(item => item.id));
336
355
  const decided = verdict.panel
@@ -8,7 +8,7 @@ import { reviewerBlocks, runReviewer } from './reviewer.js';
8
8
  import { dismissReviewerFinding } from './reviewer/context.js';
9
9
  import { reviewStatus } from './reviewer/background.js';
10
10
  import { selectReviewers, vendorsOf } from './reviewer/adapters.js';
11
- import { account, attachServedRules, carryResolved, checkoutVerifier, mergeVerdicts, parseVerdict } from './reviewer/verdict.js';
11
+ import { account, attachServedRules, carryResolved, changedLinesOf, checkoutSearch, checkoutVerifier, mergeVerdicts, parseVerdict } from './reviewer/verdict.js';
12
12
  import { recordIntact } from './reviewer/record.js';
13
13
  let repo;
14
14
  const config = ConfigSchema.parse({ version: 1, review: { github_account: 'reviewer-account', reviewer: { enabled: true, reviewers: ['claude', 'cursor'] } } });
@@ -170,7 +170,7 @@ describe('the reviewer', () => {
170
170
  expect(hidden.outcome).toBe('passed');
171
171
  const shown = await runReviewer(repo, 'main', config, fakes(() => JSON.stringify({ ...EMPTY, prior_points: [] }), seen), () => undefined, { pr: 42, reviewsBefore: '2026-10-04', force: true });
172
172
  expect(seen.files['previous-reviews.md']).toContain('Review by senior');
173
- expect(shown).toMatchObject({ outcome: 'unavailable', reason: 'claude did not report on the human reviews' });
173
+ expect(shown).toMatchObject({ outcome: 'unavailable', reason: expect.stringContaining('did not report on the human reviews') });
174
174
  });
175
175
  it('for a backtest, gives the description as it read at the review, never a later edit', async () => {
176
176
  const versions = { lastEditedAt: '2026-10-06T00:00:00Z', body: 'today: refunds are issued by the nightly job', userContentEdits: { totalCount: 3, nodes: [
@@ -200,7 +200,7 @@ describe('the reviewer', () => {
200
200
  });
201
201
  it('never passes without a verdict: a crash, a malformed answer, an unreadable pull request or no installed reviewer', async () => {
202
202
  const crashed = await runReviewer(repo, 'main', config, fakes(() => ({ exitCode: 1, stdout: '', stderr: 'API error' }), seenNow()), () => undefined);
203
- expect(crashed).toMatchObject({ outcome: 'unavailable', reason: expect.stringContaining('claude: no answer (exit 1)') });
203
+ expect(crashed).toMatchObject({ outcome: 'unavailable', reason: expect.stringContaining('cursor: no answer (exit 1)'), mode: { degraded: expect.stringContaining('claude gave no verdict, cursor judged instead') } }); // asked twice, then the spare judge, which failed too
204
204
  const prose = await runReviewer(repo, 'main', config, fakes(() => 'Looks good to me!', seenNow()), () => undefined);
205
205
  expect(prose).toMatchObject({ outcome: 'unavailable', reason: expect.stringContaining('no valid verdict') });
206
206
  const broken = async (command, args, options) => command === 'gh' && args[0] === 'pr' ? { exitCode: 1, stdout: '', stderr: 'HTTP 500' } : fakes(() => '', seenNow())(command, args, options);
@@ -283,16 +283,34 @@ describe('the reviewer', () => {
283
283
  delete process.env.TEST_JUDGE_KEY;
284
284
  }
285
285
  });
286
+ it('replaces a judge that gives nothing with the next one installed, and says so', async () => {
287
+ const seen = seenNow();
288
+ const silent = (async () => new Response(JSON.stringify({ choices: [{ message: { role: 'assistant', content: '' }, finish_reason: 'stop' }], usage: { prompt_tokens: 5, completion_tokens: 0 } }), { status: 200 }));
289
+ const twoJudges = ConfigSchema.parse({ version: 1, review: { reviewer: { enabled: true, reviewers: ['api', 'claude'], api: { url: 'https://example.test/v1', model: 'silent-model', key_env: 'TEST_JUDGE_KEY' } } } });
290
+ process.env.TEST_JUDGE_KEY = 'secret';
291
+ try {
292
+ const result = await runReviewer(repo, 'main', twoJudges, fakes(() => JSON.stringify(EMPTY), seen), () => undefined, { fetch: silent, force: true });
293
+ expect(result).toMatchObject({ outcome: 'passed', reviewers: ['claude'], mode: { degraded: expect.stringContaining('api gave no verdict, claude judged instead') } });
294
+ expect(seen.prompts).toHaveLength(1); // claude ran once, after the api judge's two empty answers
295
+ }
296
+ finally {
297
+ delete process.env.TEST_JUDGE_KEY;
298
+ }
299
+ });
286
300
  it('asks a judge once more after an answer that is not a verdict, and is unavailable only when the second is not one either', async () => {
287
301
  const seen = seenNow();
288
302
  let calls = 0;
289
303
  const slipOnce = await runReviewer(repo, 'main', config, fakes(() => (++calls === 1 ? '{"prior_points":[], "findings":[{"class"' : JSON.stringify(EMPTY)), seen), () => undefined, { force: true });
290
304
  expect(slipOnce.outcome).toBe('passed');
291
305
  expect(seen.prompts).toHaveLength(2);
306
+ let crashes = 0;
307
+ const crashOnce = seenNow();
308
+ const recovered = await runReviewer(repo, 'main', config, fakes(() => (++crashes === 1 ? { exitCode: 1, stdout: '', stderr: 'API error' } : JSON.stringify(EMPTY)), crashOnce), () => undefined, { force: true });
309
+ expect(recovered.outcome).toBe('passed'); // a run that died is asked once more too
292
310
  const twice = seenNow();
293
311
  const slipTwice = await runReviewer(repo, 'main', config, fakes(() => 'not json', twice), () => undefined, { force: true });
294
312
  expect(slipTwice).toMatchObject({ outcome: 'unavailable', reason: expect.stringContaining('no valid verdict') });
295
- expect(twice.prompts).toHaveLength(2); // once more, never a loop
313
+ expect(twice.prompts).toHaveLength(3); // once more, then the spare judge once: never a loop
296
314
  });
297
315
  it('says what was asked and that nothing ran when a review ends early, with why a judge is missing', async () => {
298
316
  const seen = { ...seenNow(), installed: ['claude'] }; // cursor is listed but not installed
@@ -308,7 +326,8 @@ describe('the reviewer', () => {
308
326
  const by = (name) => JSON.stringify({ ...EMPTY, prior_points: [{ point: 'lock before read', severity: 'blocking', resolved: name === 'claude', evidence: 'src/job.ts:2', file: 'src/job.ts', line: 2, quote: 'export function job() {' }], findings: name === 'cursor' ? [{ class: 'dead-code', file: 'a.ts', line: 1, issue: 'a is unused', consequence: 'a second run reads stale rows', quote: 'export const a = 1;' }] : [] });
309
327
  const result = await runReviewer(repo, 'main', config, fakes(by, seen), () => undefined, { full: true });
310
328
  expect(result.reviewers).toEqual(['claude', 'cursor']);
311
- expect(result.items.map(i => [i.kind, i.reviewer])).toEqual([['prior', 'cursor'], ['finding', 'cursor']]);
329
+ expect(result.items.map(i => [i.kind, i.reviewer])).toEqual([['prior', 'cursor']]);
330
+ expect(result.notes.map(i => [i.kind, i.file, i.reviewer])).toEqual([['finding', 'a.ts', 'cursor']]); // a.ts is not in the change: what the code already had
312
331
  expect(seen.prompts).toHaveLength(2);
313
332
  });
314
333
  });
@@ -607,3 +626,80 @@ describe('what the team already knows', () => {
607
626
  expect(result.notes).toEqual([]);
608
627
  });
609
628
  });
629
+ describe("a human's prior point", () => {
630
+ const point = (over) => ({ point: 'keep a separate case for a visitor with no account', review: 'senior 2026-09-25T18:09:11Z', severity: 'blocking', resolved: false, evidence: 'tests/e2e/gate.ts:137', file: 'tests/e2e/gate.ts', line: 137, quote: 'expect(href).toMatch(/account_id=/)', ...over });
631
+ const verdict = (p) => ({ ...EMPTY, prior_points: [p] });
632
+ const approvals = [{ login: 'senior', at: '2026-09-28T15:12:53Z' }];
633
+ it('blocks where the judge quotes the code that keeps it open and no one approved since', () => {
634
+ const { open } = account(verdict(point({})), undefined, () => true, { approvals: [], inCheckout: () => undefined });
635
+ expect(open.map(i => i.issue)).toEqual(['keep a separate case for a visitor with no account']);
636
+ });
637
+ it('is settled by its own reviewer approving after raising it: a note, never a block', () => {
638
+ const { open, notes } = account(verdict(point({})), undefined, () => true, { approvals, inCheckout: () => undefined });
639
+ expect(open).toEqual([]);
640
+ expect(notes).toMatchObject([{ kind: 'prior', issue: 'keep a separate case for a visitor with no account', evidence: 'senior approved on 2026-09-28T15:12:53Z, after raising it: settled' }]);
641
+ });
642
+ it('is not settled by an approval before it, by another person, or when the judge names no reviewer', () => {
643
+ const before = account(verdict(point({})), undefined, () => true, { approvals: [{ login: 'senior', at: '2026-09-20T00:00:00Z' }], inCheckout: () => undefined });
644
+ const other = account(verdict(point({})), undefined, () => true, { approvals: [{ login: 'peer', at: '2026-09-28T15:12:53Z' }], inCheckout: () => undefined });
645
+ const unnamed = account(verdict(point({ review: undefined })), undefined, () => true, { approvals, inCheckout: () => undefined });
646
+ for (const result of [before, other, unnamed])
647
+ expect(result.open).toHaveLength(1);
648
+ const undated = account(verdict(point({ review: 'senior' })), undefined, () => true, { approvals, inCheckout: () => undefined });
649
+ expect(undated.open).toEqual([]); // the reviewer named and approved: settled
650
+ });
651
+ it('that calls something missing is unverified when the checkout has it elsewhere', () => {
652
+ const searched = [];
653
+ const found = account(verdict(point({ absent: 'origin=native&returnTo=' })), undefined, () => true, { approvals: [], inCheckout: text => (searched.push(text), 'src/lib/Upsell.test.ts:128') });
654
+ expect(searched).toEqual(['origin=native&returnTo=']);
655
+ expect(found.open).toEqual([]);
656
+ expect(found.unverified).toMatchObject([{ kind: 'prior', evidence: 'says "origin=native&returnTo=" is missing, and the checkout has it at src/lib/Upsell.test.ts:128' }]);
657
+ const missing = account(verdict(point({ absent: 'origin=native&returnTo=' })), undefined, () => true, { approvals: [], inCheckout: () => undefined });
658
+ expect(missing.open).toHaveLength(1); // searched, not there: the point stands on its quote
659
+ });
660
+ });
661
+ describe('searching the checkout for what a point calls missing', () => {
662
+ it('finds the first line of the text anywhere in the tracked tree, and nothing untracked', () => {
663
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rigour-search-'));
664
+ execFileSync('git', ['-C', dir, 'init', '-q']);
665
+ fs.mkdirSync(path.join(dir, 'src'));
666
+ fs.writeFileSync(path.join(dir, 'src', 'a.test.ts'), 'it("no account", () => {\n expect(href).toBe("/checkout?origin=native");\n});\n');
667
+ fs.writeFileSync(path.join(dir, 'untracked.ts'), 'const ghost = 1;\n');
668
+ execFileSync('git', ['-C', dir, 'add', 'src']);
669
+ const search = checkoutSearch(dir);
670
+ expect(search(' expect(href).toBe("/checkout?origin=native");\n more')).toBe('src/a.test.ts:2');
671
+ expect(search('const ghost = 1;')).toBeUndefined();
672
+ expect(search(' \n')).toBeUndefined();
673
+ });
674
+ });
675
+ describe('a block sits on a line the change touched', () => {
676
+ const diff = [
677
+ 'diff --git a/src/player.ts b/src/player.ts', '--- a/src/player.ts', '+++ b/src/player.ts',
678
+ '@@ -10,4 +10,5 @@ function resume() {', ' const a = 1;', '- old();', '+ report(a);', '+ report(b);', ' return a;', ' }',
679
+ 'diff --git a/src/gone.ts b/src/gone.ts', '--- a/src/gone.ts', '+++ /dev/null', '@@ -1,2 +0,0 @@', '-export const x = 1;', '-export const y = 2;',
680
+ 'diff --git a/src/new.ts b/src/new.ts', '--- /dev/null', '+++ b/src/new.ts', '@@ -0,0 +1,2 @@', '+export const z = 1;', '+export const w = 2;', '',
681
+ ].join('\n');
682
+ const changed = changedLinesOf(diff);
683
+ const rule = (file, line) => ({ ...EMPTY, prior_points: [], rules: [{ id: 'r1', status: 'broken', rule: 'wrap every navigation target in resolve()', source: 'AGENTS.md', requirement: true, file, line, quote: 'preloadCode(target)' }] });
684
+ const checks = { approvals: [], inCheckout: () => undefined, changed };
685
+ it('reads the touched lines of a diff: added lines, the place of a deletion, nothing for a deleted file', () => {
686
+ expect([...changed.get('src/player.ts')].sort((a, b) => a - b)).toEqual([11, 12]); // the deletion's place, then the two added lines (11 is both)
687
+ expect([...changed.get('src/new.ts')]).toEqual([1, 2]);
688
+ expect(changed.has('src/gone.ts')).toBe(false);
689
+ });
690
+ it('blocks a verified rule break near a touched line, and notes one on lines the change did not touch, or with no line', () => {
691
+ expect(account(rule('src/player.ts', 14), undefined, () => true, checks).open).toHaveLength(1); // within the window of line 12
692
+ const far = account(rule('src/player.ts', 1819), undefined, () => true, checks);
693
+ expect(far.open).toEqual([]);
694
+ expect(far.notes).toMatchObject([{ kind: 'rule', line: 1819, evidence: expect.stringContaining('on a line this change did not touch: what the code already had, never a block on this change') }]);
695
+ const unplaced = account(rule('src/player.ts', undefined), undefined, () => true, checks);
696
+ expect(unplaced.open).toEqual([]);
697
+ expect(unplaced.notes[0].evidence).toContain('names no line');
698
+ expect(account(rule('src/other.ts', 3), undefined, () => true, checks).open).toEqual([]); // a file the change did not touch at all
699
+ });
700
+ it("leaves a human's point, and every item when the diff is unknown, as before", () => {
701
+ const point = { ...EMPTY, prior_points: [{ point: 'wrap the target', review: 'senior 2026-10-01', severity: 'blocking', resolved: false, file: 'src/player.ts', line: 1819, quote: 'preloadCode(target)' }] };
702
+ expect(account(point, undefined, () => true, checks).open).toHaveLength(1);
703
+ expect(account(rule('src/player.ts', 1819), undefined, () => true, { approvals: [], inCheckout: () => undefined }).open).toHaveLength(1);
704
+ });
705
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rigour-labs/core",
3
- "version": "6.7.8",
3
+ "version": "6.7.10",
4
4
  "description": "Rigour's review engine: deterministic gates on changed lines, rules and lessons learned from your team's fixes, and per-check precision from what you fix versus dismiss, across TypeScript, JavaScript, Python, Go, Ruby and C#.",
5
5
  "engines": {
6
6
  "node": ">=22.13"
@@ -72,11 +72,11 @@
72
72
  "@anthropic-ai/sdk": "^0.30.1",
73
73
  "pg": "^8.16.3",
74
74
  "openai": "^5.23.2",
75
- "@rigour-labs/brain-darwin-arm64": "6.7.8",
76
- "@rigour-labs/brain-darwin-x64": "6.7.8",
77
- "@rigour-labs/brain-linux-x64": "6.7.8",
78
- "@rigour-labs/brain-linux-arm64": "6.7.8",
79
- "@rigour-labs/brain-win-x64": "6.7.8"
75
+ "@rigour-labs/brain-darwin-arm64": "6.7.10",
76
+ "@rigour-labs/brain-darwin-x64": "6.7.10",
77
+ "@rigour-labs/brain-linux-x64": "6.7.10",
78
+ "@rigour-labs/brain-win-x64": "6.7.10",
79
+ "@rigour-labs/brain-linux-arm64": "6.7.10"
80
80
  },
81
81
  "devDependencies": {
82
82
  "@types/fs-extra": "^11.0.4",