@rigour-labs/core 6.7.9 → 6.8.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.
Files changed (40) hide show
  1. package/dist/brief/briefing.d.ts +59 -0
  2. package/dist/brief/briefing.js +126 -0
  3. package/dist/brief/briefing.test.d.ts +1 -0
  4. package/dist/brief/briefing.test.js +107 -0
  5. package/dist/index.d.ts +2 -0
  6. package/dist/index.js +2 -0
  7. package/dist/review/backtest-init.d.ts +8 -5
  8. package/dist/review/backtest-init.js +28 -10
  9. package/dist/review/backtest-init.test.js +39 -1
  10. package/dist/review/backtest-last.js +3 -1
  11. package/dist/review/backtest.d.ts +10 -0
  12. package/dist/review/backtest.js +11 -2
  13. package/dist/review/backtest.test.js +12 -0
  14. package/dist/review/reviewer/adapters.d.ts +4 -0
  15. package/dist/review/reviewer/adapters.js +23 -0
  16. package/dist/review/reviewer/adapters.test.js +40 -0
  17. package/dist/review/reviewer/inputs.d.ts +6 -0
  18. package/dist/review/reviewer/inputs.js +46 -6
  19. package/dist/review/reviewer/inputs.test.d.ts +1 -0
  20. package/dist/review/reviewer/inputs.test.js +52 -0
  21. package/dist/review/reviewer/prompt.js +8 -2
  22. package/dist/review/reviewer/record.d.ts +5 -0
  23. package/dist/review/reviewer/record.js +3 -3
  24. package/dist/review/reviewer/record.test.js +8 -0
  25. package/dist/review/reviewer/verdict.d.ts +40 -1
  26. package/dist/review/reviewer/verdict.js +150 -9
  27. package/dist/review/reviewer.d.ts +2 -0
  28. package/dist/review/reviewer.js +29 -9
  29. package/dist/review/reviewer.test.js +173 -8
  30. package/dist/review-learning/repo-rules.d.ts +14 -2
  31. package/dist/review-learning/repo-rules.js +59 -9
  32. package/dist/review-learning/repo-rules.test.js +39 -1
  33. package/dist/task/thread.d.ts +47 -0
  34. package/dist/task/thread.js +234 -0
  35. package/dist/task/thread.test.d.ts +1 -0
  36. package/dist/task/thread.test.js +133 -0
  37. package/dist/templates/universal-config.js +4 -0
  38. package/dist/types/index.d.ts +21 -0
  39. package/dist/types/index.js +7 -0
  40. package/package.json +6 -6
@@ -11,6 +11,8 @@ import os from 'os';
11
11
  import path from 'path';
12
12
  import { defaultExec } from './exec.js';
13
13
  const n = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
14
+ /** The Claude Code version the memory switches were verified in: below it, a judge may still load a person's CLAUDE.md. */
15
+ const CLAUDE_MEMORY_ISOLATION = '2.1.285';
14
16
  const READ_ONLY_TOOLS = ['Read', 'Grep', 'Glob', 'Bash(git diff:*)', 'Bash(git show:*)', 'Bash(git log:*)', 'Bash(git grep:*)'];
15
17
  export const ADAPTERS = {
16
18
  claude: {
@@ -28,12 +30,26 @@ export const ADAPTERS = {
28
30
  '--allowedTools', ...READ_ONLY_TOOLS,
29
31
  '--disallowedTools', 'Edit', 'Write', 'NotebookEdit', 'Bash(git push:*)', 'Bash(git commit:*)',
30
32
  ],
33
+ // No memory file loads itself: not ~/.claude/CLAUDE.md, not one in a folder above the repository, not the repository's
34
+ // own (the judge reads the repository's rules as files, as Rigour's prompt tells every judge to), and no auto-memory.
35
+ // Claude Code's own safe mode uses the same switch. What the judge knows is the repository and what Rigour gives it.
36
+ env: { CLAUDE_CODE_DISABLE_CLAUDE_MDS: '1', CLAUDE_CODE_DISABLE_AUTO_MEMORY: '1' },
37
+ // An older Claude Code ignores those switches without a word and loads the memory files anyway: below the version
38
+ // they were verified in, or when the version cannot be read, the record says the isolation is unverified.
39
+ outsideRepo: (_home, version) => {
40
+ const found = /(\d+)\.(\d+)\.(\d+)/.exec(version ?? '');
41
+ const at = found ? found.slice(1).map(Number) : undefined;
42
+ const floor = CLAUDE_MEMORY_ISOLATION.split('.').map(Number);
43
+ const below = !at || at[0] !== floor[0] ? !at || at[0] < floor[0] : at[1] !== floor[1] ? at[1] < floor[1] : at[2] < floor[2];
44
+ return below ? `claude ${found?.[0] ?? '(version unknown)'}: memory isolation unverified (needs ${CLAUDE_MEMORY_ISOLATION} or later)` : undefined;
45
+ },
31
46
  answer: stdout => claudeAnswer(stdout),
32
47
  },
33
48
  cursor: {
34
49
  vendor: 'cursor',
35
50
  binary: 'cursor-agent',
36
51
  // Ask mode is read-only and cannot run git: every input the prompt names is a file.
52
+ outsideRepo: () => 'cursor also reads your own Cursor rules and settings',
37
53
  args: (prompt, model) => ['-p', '--print', '--output-format', 'json', '--trust', '--mode', 'ask', '--model', model ?? 'auto', prompt],
38
54
  answer: stdout => {
39
55
  try {
@@ -48,6 +64,13 @@ export const ADAPTERS = {
48
64
  codex: {
49
65
  vendor: 'openai',
50
66
  binary: 'codex',
67
+ // Codex has no switch that skips a person's ~/.codex/config.toml and ~/.codex/AGENTS.md and keeps their login, so the
68
+ // record says when those exist rather than claiming a judge that only knows the repository.
69
+ outsideRepo: home => {
70
+ const dir = process.env.CODEX_HOME?.trim() || path.join(home, '.codex');
71
+ const read = ['config.toml', 'AGENTS.md'].filter(name => fs.existsSync(path.join(dir, name)));
72
+ return read.length ? `codex also reads ${read.map(name => path.join(dir, name)).join(' and ')}` : undefined;
73
+ },
51
74
  args: (prompt, model, options) => ['exec', '--sandbox', 'read-only', '--json', ...(model ? ['--model', model] : []), '-c', `model_reasoning_effort=${options?.reasoning ?? 'high'}`, prompt],
52
75
  // `codex exec --json` streams events; the last text-bearing one carries the answer.
53
76
  // Warnings arrive as `error` items with a `message`, not `text`, so they are never taken for the answer.
@@ -1,3 +1,6 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
1
4
  import { describe, expect, it } from 'vitest';
2
5
  import { ADAPTERS, apiVendor, selectReviewers } from './adapters.js';
3
6
  /** A real `codex exec --json` run (codex-cli 0.160.1), the warning's path shortened: the shape the adapter must read. */
@@ -58,3 +61,40 @@ describe('a judge reached through an API', () => {
58
61
  expect(ADAPTERS.codex.args('p', undefined)).toContain('model_reasoning_effort=high');
59
62
  });
60
63
  });
64
+ describe('what a judge knows besides the repository', () => {
65
+ it('launches claude with no memory file, auto-memory, user settings, hooks or MCP servers of its own', () => {
66
+ expect(ADAPTERS.claude.env).toEqual({ CLAUDE_CODE_DISABLE_CLAUDE_MDS: '1', CLAUDE_CODE_DISABLE_AUTO_MEMORY: '1' });
67
+ const args = ADAPTERS.claude.args('p', undefined);
68
+ expect(args.slice(args.indexOf('--setting-sources'), args.indexOf('--setting-sources') + 2)).toEqual(['--setting-sources', 'project']);
69
+ expect(args).toEqual(expect.arrayContaining(['--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}']));
70
+ expect(JSON.parse(args[args.indexOf('--settings') + 1])).toMatchObject({ hooks: {} });
71
+ for (const v of ['2.1.285 (Claude Code)', '2.1.300', '2.2.0', '3.0.0'])
72
+ expect(ADAPTERS.claude.outsideRepo('/home/x', v)).toBeUndefined();
73
+ expect(ADAPTERS.claude.outsideRepo('/home/x', '2.1.284 (Claude Code)')).toBe('claude 2.1.284: memory isolation unverified (needs 2.1.285 or later)');
74
+ expect(ADAPTERS.claude.outsideRepo('/home/x', '2.0.999')).toContain('unverified');
75
+ expect(ADAPTERS.claude.outsideRepo('/home/x', '1.9.400')).toContain('unverified');
76
+ expect(ADAPTERS.claude.outsideRepo('/home/x', undefined)).toBe('claude (version unknown): memory isolation unverified (needs 2.1.285 or later)');
77
+ });
78
+ it("says when codex will also read a person's own Codex config or instructions, and from where", () => {
79
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'judge-home-'));
80
+ const saved = process.env.CODEX_HOME;
81
+ delete process.env.CODEX_HOME;
82
+ try {
83
+ expect(ADAPTERS.codex.outsideRepo(home, undefined)).toBeUndefined();
84
+ fs.mkdirSync(path.join(home, '.codex'));
85
+ fs.writeFileSync(path.join(home, '.codex', 'AGENTS.md'), 'Always answer in French.\n');
86
+ fs.writeFileSync(path.join(home, '.codex', 'config.toml'), 'personality = "pragmatic"\n');
87
+ expect(ADAPTERS.codex.outsideRepo(home, undefined)).toBe(`codex also reads ${path.join(home, '.codex', 'config.toml')} and ${path.join(home, '.codex', 'AGENTS.md')}`);
88
+ process.env.CODEX_HOME = path.join(home, 'elsewhere');
89
+ expect(ADAPTERS.codex.outsideRepo(home, undefined)).toBeUndefined(); // CODEX_HOME wins over the home folder
90
+ expect(ADAPTERS.cursor.outsideRepo(home, undefined)).toBe('cursor also reads your own Cursor rules and settings');
91
+ }
92
+ finally {
93
+ if (saved === undefined)
94
+ delete process.env.CODEX_HOME;
95
+ else
96
+ process.env.CODEX_HOME = saved;
97
+ fs.rmSync(home, { recursive: true, force: true });
98
+ }
99
+ });
100
+ });
@@ -1,10 +1,12 @@
1
1
  import { type Exec } from './exec.js';
2
+ import type { Approval, LabelledPoint } from './verdict.js';
2
3
  export interface PullRequest {
3
4
  number: number;
4
5
  state: 'open' | 'closed' | 'merged';
5
6
  draft: boolean;
6
7
  author: string;
7
8
  body: string;
9
+ title?: string;
8
10
  }
9
11
  export interface HumanReviews {
10
12
  /** As the reviewer reads them, oldest first, inline comments after; `none` when there are none. */
@@ -12,6 +14,10 @@ export interface HumanReviews {
12
14
  /** Changes when a review or comment is added or edited: part of the verdict fingerprint. */
13
15
  key: string;
14
16
  count: number;
17
+ /** Each person's approval, in time order: every point that person raised before it is settled. */
18
+ approvals: Approval[];
19
+ /** The points each review put under a severity heading of its own ("Blocking", "Should fix", "Nits"). */
20
+ labels: LabelledPoint[];
15
21
  /** `<login>, <date> (<n> reviews)` of the latest, for the report. */
16
22
  label?: string;
17
23
  }
@@ -13,7 +13,7 @@ import { GH_TIMEOUT_MS, parseJsonArrays } from './exec.js';
13
13
  export function ghFor(cwd, exec, env) {
14
14
  return args => exec('gh', args, { cwd, timeoutMs: GH_TIMEOUT_MS, env });
15
15
  }
16
- const PR_FIELDS = 'number,state,isDraft,author,body';
16
+ const PR_FIELDS = 'number,state,isDraft,author,body,title';
17
17
  /**
18
18
  * The description as it read at `at` (a backtest's review time): GitHub keeps every version in
19
19
  * `userContentEdits`, the first being the text at creation. Undefined when it cannot be read, so a
@@ -67,7 +67,7 @@ async function viewPullRequest(gh, selector) {
67
67
  const parsed = JSON.parse(result.stdout);
68
68
  if (!Number.isInteger(parsed.number))
69
69
  return { error: `gh returned no pull request number for ${selector}` };
70
- return { pr: { number: parsed.number, state: String(parsed.state ?? '').toLowerCase(), draft: !!parsed.isDraft, author: String(parsed.author?.login ?? ''), body: String(parsed.body ?? '') } };
70
+ return { pr: { number: parsed.number, state: String(parsed.state ?? '').toLowerCase(), draft: !!parsed.isDraft, author: String(parsed.author?.login ?? ''), body: String(parsed.body ?? ''), ...(parsed.title ? { title: String(parsed.title) } : {}) } };
71
71
  }
72
72
  catch {
73
73
  return { error: `gh returned something other than a pull request for ${selector}: ${result.stdout.slice(0, 120)}` };
@@ -87,21 +87,61 @@ 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
+ labels: rounds.filter(worded).flatMap((r) => severityLabels(String(r.body), String(r.user.login), String(r.submitted_at))),
108
+ ...(latest ? { label: `${latest.user.login}, ${latest.submitted_at} (${count} review${count === 1 ? '' : 's'})` } : {}),
102
109
  },
103
110
  };
104
111
  }
112
+ /** The severity a heading names: "Blocking", "Should fix", "Nits" and their usual spellings, alone on the line. */
113
+ function headingSeverity(line) {
114
+ const bare = line.replace(/[#*_:`>]/g, ' ').replace(/\(\d+\)/g, ' ').replace(/\s+/g, ' ').trim().toLowerCase();
115
+ if (!bare || bare.split(' ').length > 3)
116
+ return undefined;
117
+ if (/^(blocking|blockers?|must fix|must-fix)$/.test(bare))
118
+ return 'blocking';
119
+ if (/^(should fix|should-fix|should)$/.test(bare))
120
+ return 'should-fix';
121
+ if (/^(nits?|non blocking|non-blocking|minor|optional)$/.test(bare))
122
+ return 'non-blocking';
123
+ return undefined;
124
+ }
125
+ /** Each bullet or numbered line under a severity heading of the review, with that heading's severity. */
126
+ function severityLabels(body, login, at) {
127
+ const out = [];
128
+ let severity;
129
+ for (const line of body.split('\n')) {
130
+ const heading = headingSeverity(line);
131
+ if (heading) {
132
+ severity = heading;
133
+ continue;
134
+ }
135
+ if (/^\s{0,3}#{1,6}\s/.test(line))
136
+ severity = undefined; // another heading ends the section
137
+ // A bullet, a numbered line, or a numbered line set in bold or underline ("**1. The column is NOT NULL.**"), which is
138
+ // how many reviewers title a point before its evidence.
139
+ const item = /^\s*(?:\*\*|__)?\s*(?:[-*+]|\d+[.)])\s+(.+?)\s*(?:\*\*|__)?\s*$/.exec(line);
140
+ if (severity && item)
141
+ out.push({ login, at, severity, text: item[1].replace(/\*\*|__/g, '').trim() });
142
+ }
143
+ return out;
144
+ }
105
145
  /** The repository's rules as the reviewer must read them. */
106
146
  export function rulesText(cwd) {
107
147
  return ['AGENTS.md', 'CLAUDE.md'].map(name => {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,52 @@
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
+ it('reads the bullets and numbered lines under Blocking / Should fix / Nits headings as that reviewer labelled them, and nothing under another heading', async () => {
27
+ const body = ['Thanks, a few things.', '', '## Blocking', '- The kill switch is read after every query: check it first.', '',
28
+ '**Should fix**', '1. The comment on the window still says daily.', '', 'Nits:', '* Rename tmp to rows.', '', '## Context', '- not a label'].join('\n');
29
+ const labelled = async (args) => ({ exitCode: 0, stdout: JSON.stringify(args[1].endsWith('/reviews') ? [{ ...reviews[0], body }] : []), stderr: '' });
30
+ const { reviews: read } = await humanReviews(labelled, pr, undefined);
31
+ expect(read.labels.map(l => [l.login, l.severity, l.text])).toEqual([
32
+ ['senior', 'blocking', 'The kill switch is read after every query: check it first.'],
33
+ ['senior', 'should-fix', 'The comment on the window still says daily.'],
34
+ ['senior', 'non-blocking', 'Rename tmp to rows.'],
35
+ ]);
36
+ expect(read.labels.every(l => l.at === '2026-09-25T18:09:11Z')).toBe(true);
37
+ });
38
+ it('reads a numbered point set in bold, with its evidence sub-bullets, under each heading', async () => {
39
+ const body = ['## Blocking', '', '**1. The status column is typed nullable but the table declares it NOT NULL.**', '- the reader falls back to an empty string', '- two callers check for null again', '',
40
+ '**2. The retry posts the event twice when the first post times out.**', '', '## Should fix', '', '__1. The window comment says hourly; the job runs daily.__', '', '## Nits', '', '- Rename tmp to rows.'].join('\n');
41
+ const shaped = async (args) => ({ exitCode: 0, stdout: JSON.stringify(args[1].endsWith('/reviews') ? [{ ...reviews[0], body }] : []), stderr: '' });
42
+ const { reviews: read } = await humanReviews(shaped, pr, undefined);
43
+ expect(read.labels.map(l => [l.severity, l.text])).toEqual([
44
+ ['blocking', 'The status column is typed nullable but the table declares it NOT NULL.'],
45
+ ['blocking', 'the reader falls back to an empty string'],
46
+ ['blocking', 'two callers check for null again'],
47
+ ['blocking', 'The retry posts the event twice when the first post times out.'],
48
+ ['should-fix', 'The window comment says hourly; the job runs daily.'],
49
+ ['non-blocking', 'Rename tmp to rows.'],
50
+ ]);
51
+ });
52
+ });
@@ -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":"..."}],
@@ -5,12 +5,14 @@ export interface ReviewRecord {
5
5
  base: string;
6
6
  scope: 'full' | 'delta';
7
7
  at: string;
8
+ /** `outside_repo`: what the judge also read from the machine's own config (a person's instructions); absent when it read only the repository and Rigour's inputs. */
8
9
  judges: Array<{
9
10
  reviewer: string;
10
11
  version?: string;
11
12
  model?: string;
12
13
  cost_usd?: number;
13
14
  turns?: number;
15
+ outside_repo?: string;
14
16
  }>;
15
17
  /** Checked by Rigour against the checkout. */
16
18
  verified: {
@@ -28,10 +30,13 @@ export interface ReviewRecord {
28
30
  served: number;
29
31
  applied: number;
30
32
  };
33
+ /** `labelled`: points that took the review's own severity heading; `relabelled`: of those, the ones the judge had read otherwise. */
31
34
  prior_points: {
32
35
  open: number;
33
36
  resolved: number;
34
37
  answer_in_reply: number;
38
+ labelled?: number;
39
+ relabelled?: number;
35
40
  };
36
41
  unverified: number;
37
42
  notes: number;
@@ -21,7 +21,7 @@ export function buildRecord(input) {
21
21
  should_fix: input.accounted.advisory,
22
22
  rules: { served: rules.length, followed: rules.filter(r => r.status === 'followed').length, broken: rules.filter(r => r.status === 'broken').length, not_applicable: rules.filter(r => r.status === 'not-applicable').length },
23
23
  lessons: { served: input.lessonsServed, applied: lessons.filter(l => l.applies === true).length },
24
- prior_points: { open: input.accounted.open.filter(i => i.kind === 'prior').length, resolved: input.accounted.resolved.length, answer_in_reply: input.accounted.answerInReply.length },
24
+ prior_points: { open: input.accounted.open.filter(i => i.kind === 'prior').length, resolved: input.accounted.resolved.length, answer_in_reply: input.accounted.answerInReply.length, ...(input.accounted.labels?.served ? { labelled: input.accounted.labels.taken, relabelled: input.accounted.labels.disagreed } : {}) },
25
25
  unverified: input.accounted.unverified.length,
26
26
  notes: input.accounted.notes.length,
27
27
  disputed: input.accounted.disputed.length,
@@ -54,7 +54,7 @@ function canonical(value) {
54
54
  export function recordLines(r, shouldFixShown = 5) {
55
55
  const where = (i) => `${i.file ? `\`${i.file}${i.line ? `:${i.line}` : ''}\` ` : ''}${i.issue}${i.locations?.length ? ` (also ${i.locations.map(l => `\`${l.file}${l.line ? `:${l.line}` : ''}\``).join(', ')})` : ''}`;
56
56
  const v = r.verified;
57
- const lines = [`**Review record** · ${v.blocking.length} blocking · ${v.should_fix.length} should-fix · rules ${v.rules.followed} followed, ${v.rules.broken} broken, ${v.rules.not_applicable} not applicable of ${v.rules.served} · lessons ${v.lessons.applied} of ${v.lessons.served} apply · prior points ${v.prior_points.open} open, ${v.prior_points.resolved} resolved`];
57
+ const lines = [`**Review record** · ${v.blocking.length} blocking · ${v.should_fix.length} should-fix · rules ${v.rules.followed} followed, ${v.rules.broken} broken, ${v.rules.not_applicable} not applicable of ${v.rules.served} · lessons ${v.lessons.applied} of ${v.lessons.served} apply · prior points ${v.prior_points.open} open, ${v.prior_points.resolved} resolved${v.prior_points.labelled !== undefined ? `, ${v.prior_points.labelled} by the review's own label (${v.prior_points.relabelled} relabelled)` : ''}`];
58
58
  for (const i of v.blocking)
59
59
  lines.push(`- **Blocking** ${where(i)}`);
60
60
  for (const i of v.should_fix.slice(0, shouldFixShown))
@@ -64,6 +64,6 @@ export function recordLines(r, shouldFixShown = 5) {
64
64
  const folded = [[v.notes, 'working note'], [v.disputed, 'disputed'], [v.unverified, 'unverified'], [r.people.dismissed, 'dismissed']].filter(([n]) => n > 0);
65
65
  if (folded.length)
66
66
  lines.push(`Also seen, never blocking: ${folded.map(([n, w]) => `${n} ${w}${n === 1 || w === 'disputed' || w === 'unverified' || w === 'dismissed' ? '' : 's'}`).join(', ')}.`);
67
- lines.push(`Judged by ${r.judges.map(j => `${j.reviewer}${j.version ? ` ${j.version}` : ''}${j.model ? ` (${j.model})` : ''}${typeof j.cost_usd === 'number' ? ` $${j.cost_usd.toFixed(2)}` : ''}`).join(', ') || 'no judge'} on \`${r.head.slice(0, 9)}\` against \`${r.base.slice(0, 9)}\` (${r.scope}); ${r.reported.human_reviews} human review(s) seen. Integrity \`${r.integrity.slice(0, 16)}\`.`);
67
+ lines.push(`Judged by ${r.judges.map(j => `${j.reviewer}${j.version ? ` ${j.version}` : ''}${j.model ? ` (${j.model})` : ''}${typeof j.cost_usd === 'number' ? ` $${j.cost_usd.toFixed(2)}` : ''}${j.outside_repo ? ` [${j.outside_repo}]` : ''}`).join(', ') || 'no judge'} on \`${r.head.slice(0, 9)}\` against \`${r.base.slice(0, 9)}\` (${r.scope}); ${r.reported.human_reviews} human review(s) seen. Integrity \`${r.integrity.slice(0, 16)}\`.`);
68
68
  return lines;
69
69
  }
@@ -29,4 +29,12 @@ describe('the review record', () => {
29
29
  expect(lines.at(-2)).toBe('Also seen, never blocking: 2 working notes, 1 unverified, 1 dismissed.');
30
30
  expect(lines.at(-1)).toMatch(/^Judged by claude 2\.1\.0 \(opus\) \$1\.25 on `abcdef012` against `012345678` \(full\); 2 human review\(s\) seen\. Integrity `[0-9a-f]{16}`\.$/);
31
31
  });
32
+ it("counts the prior points that took the review's own severity label, and only when the reviews carry labels", () => {
33
+ const labelled = input();
34
+ labelled.accounted = { ...labelled.accounted, labels: { served: 4, taken: 2, disagreed: 1 } };
35
+ const record = buildRecord(labelled);
36
+ expect(record.verified.prior_points).toMatchObject({ labelled: 2, relabelled: 1 });
37
+ expect(recordLines(record).join('\n')).toContain("2 by the review's own label (1 relabelled)");
38
+ expect(buildRecord(input()).verified.prior_points).not.toHaveProperty('labelled');
39
+ });
32
40
  });
@@ -11,7 +11,35 @@ 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;
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
+ labels?: LabelledPoint[];
14
31
  }
32
+ /** A point under a severity heading the reviewer wrote ("Blocking", "Should fix", "Nits"): the reviewer's own label. */
33
+ export interface LabelledPoint {
34
+ login: string;
35
+ at: string;
36
+ severity: NonNullable<PriorPoint['severity']>;
37
+ text: string;
38
+ }
39
+ /** The lines a change touched, per file: every line added, and the place of every deletion, in the new file's numbering. */
40
+ export type ChangedLines = Map<string, Set<number>>;
41
+ /** The touched lines of a unified diff (`git diff base...head`). */
42
+ export declare function changedLinesOf(diff: string): ChangedLines;
15
43
  interface Redundant {
16
44
  file: string;
17
45
  line?: number;
@@ -195,6 +223,11 @@ export type Verify = (file: string, line: number | undefined, quote?: string) =>
195
223
  * that is not where the judge says it is never blocks, whichever model made it.
196
224
  */
197
225
  export declare function checkoutVerifier(cwd: string): Verify;
226
+ /**
227
+ * Searches the whole checkout for a line of text (the first non-empty line of `text`, whitespace aside): `file:line` of
228
+ * the first place it is, or undefined. A `git grep` so the tracked tree is searched and nothing else.
229
+ */
230
+ export declare function checkoutSearch(cwd: string): (text: string) => string | undefined;
198
231
  /** 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
232
  export declare function parseVerdict(text: string, needsPriorPoints: boolean, reviewer: string, spend: Spend): {
200
233
  verdict: Verdict;
@@ -227,9 +260,15 @@ export interface Accounting {
227
260
  notes: OpenItem[];
228
261
  /** Should-fixes the judge could show (a verified quote): worth a person's time, never a block. */
229
262
  advisory: OpenItem[];
263
+ /** The reviews' own severity labels: how many there were, how many prior points took one, and how many the judge read otherwise. */
264
+ labels?: {
265
+ served: number;
266
+ taken: number;
267
+ disagreed: number;
268
+ };
230
269
  }
231
270
  /** 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;
271
+ export declare function account(verdict: Verdict, previousOpen: OpenItem[] | undefined, verify: Verify, prior?: PriorChecks): Accounting;
233
272
  export declare function itemLine(item: OpenItem): string;
234
273
  /** The rules Rigour served, onto the judge's answers by id: an answer naming no served rule is dropped. */
235
274
  export declare function attachServedRules(verdict: Verdict, served: ServedRule[]): void;