@rigour-labs/core 6.7.2 → 6.7.5

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 (77) hide show
  1. package/dist/hooks/index.d.ts +3 -3
  2. package/dist/hooks/index.js +2 -2
  3. package/dist/hooks/session-state.d.ts +3 -0
  4. package/dist/hooks/session-state.js +10 -0
  5. package/dist/hooks/stop-review.d.ts +8 -15
  6. package/dist/hooks/stop-review.js +36 -2
  7. package/dist/index.d.ts +4 -2
  8. package/dist/index.js +4 -2
  9. package/dist/review/backtest-init.js +13 -11
  10. package/dist/review/backtest-init.test.js +26 -2
  11. package/dist/review/backtest.js +35 -3
  12. package/dist/review/backtest.test.js +44 -2
  13. package/dist/review/dead-code.test.js +13 -0
  14. package/dist/review/orphan-files.js +3 -2
  15. package/dist/review/quiet.d.ts +1 -1
  16. package/dist/review/quiet.js +3 -1
  17. package/dist/review/review-task.d.ts +2 -6
  18. package/dist/review/review-task.js +2 -2
  19. package/dist/review/reviewer/adapters.d.ts +21 -0
  20. package/dist/review/reviewer/adapters.js +70 -15
  21. package/dist/review/reviewer/adapters.test.js +22 -0
  22. package/dist/review/reviewer/background.test.js +1 -1
  23. package/dist/review/reviewer/context.d.ts +3 -0
  24. package/dist/review/reviewer/context.js +14 -5
  25. package/dist/review/reviewer/context.test.js +26 -1
  26. package/dist/review/reviewer/exec.d.ts +9 -0
  27. package/dist/review/reviewer/exec.js +28 -1
  28. package/dist/review/reviewer/exec.test.js +38 -2
  29. package/dist/review/reviewer/inputs.d.ts +6 -0
  30. package/dist/review/reviewer/inputs.js +26 -0
  31. package/dist/review/reviewer/judge-env.d.ts +3 -0
  32. package/dist/review/reviewer/judge-env.js +10 -0
  33. package/dist/review/reviewer/prompt.js +68 -12
  34. package/dist/review/reviewer/rule-writer.d.ts +10 -0
  35. package/dist/review/reviewer/rule-writer.js +30 -0
  36. package/dist/review/reviewer/rule-writer.test.d.ts +1 -0
  37. package/dist/review/reviewer/rule-writer.test.js +52 -0
  38. package/dist/review/reviewer/settings.d.ts +4 -0
  39. package/dist/review/reviewer/settings.js +2 -1
  40. package/dist/review/reviewer/verdict.d.ts +71 -9
  41. package/dist/review/reviewer/verdict.js +102 -10
  42. package/dist/review/reviewer.d.ts +5 -2
  43. package/dist/review/reviewer.js +56 -41
  44. package/dist/review/reviewer.test.js +170 -17
  45. package/dist/review/typed/constant-inputs.d.ts +35 -0
  46. package/dist/review/typed/constant-inputs.js +168 -0
  47. package/dist/review/typed/dead-null-guards.d.ts +34 -0
  48. package/dist/review/typed/dead-null-guards.js +147 -0
  49. package/dist/review/typed/redundancy.js +6 -0
  50. package/dist/review/typed/redundancy.test.js +136 -0
  51. package/dist/review/unused-exports.js +3 -2
  52. package/dist/review-learning/acted-on.d.ts +23 -3
  53. package/dist/review-learning/acted-on.js +25 -13
  54. package/dist/review-learning/human-edits.d.ts +7 -0
  55. package/dist/review-learning/human-edits.js +99 -0
  56. package/dist/review-learning/human-edits.test.d.ts +1 -0
  57. package/dist/review-learning/human-edits.test.js +47 -0
  58. package/dist/review-learning/learn-from-reviews.d.ts +37 -4
  59. package/dist/review-learning/learn-from-reviews.js +128 -12
  60. package/dist/review-learning/lessons.d.ts +62 -9
  61. package/dist/review-learning/lessons.js +132 -21
  62. package/dist/review-learning/outcomes.d.ts +26 -0
  63. package/dist/review-learning/outcomes.js +73 -0
  64. package/dist/review-learning/review-learning.test.js +158 -22
  65. package/dist/review-learning/review-points.d.ts +4 -0
  66. package/dist/review-learning/review-points.js +14 -0
  67. package/dist/review-learning/rules-from-reviews.d.ts +14 -0
  68. package/dist/review-learning/rules-from-reviews.js +86 -0
  69. package/dist/review-learning/team-lessons.d.ts +17 -1
  70. package/dist/review-learning/team-lessons.js +29 -5
  71. package/dist/templates/universal-config.js +3 -0
  72. package/dist/types/index.d.ts +63 -0
  73. package/dist/types/index.js +11 -0
  74. package/dist/utils/profile.d.ts +5 -1
  75. package/dist/utils/profile.js +7 -1
  76. package/dist/utils/profile.test.js +10 -0
  77. package/package.json +6 -6
@@ -21,27 +21,14 @@ export const ADAPTERS = {
21
21
  args: (prompt, model) => [
22
22
  '-p', prompt,
23
23
  ...(model ? ['--model', model] : []),
24
- '--output-format', 'json',
24
+ '--output-format', 'stream-json', '--verbose', // every turn's usage and tool calls, for the run's trace
25
25
  '--max-turns', '80',
26
26
  '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}',
27
27
  '--setting-sources', 'project', '--settings', '{"hooks":{},"outputStyle":"default"}',
28
28
  '--allowedTools', ...READ_ONLY_TOOLS,
29
29
  '--disallowedTools', 'Edit', 'Write', 'NotebookEdit', 'Bash(git push:*)', 'Bash(git commit:*)',
30
30
  ],
31
- answer: stdout => {
32
- try {
33
- const parsed = JSON.parse(stdout);
34
- const usage = parsed.usage;
35
- return {
36
- text: String(parsed.result ?? ''),
37
- ...(typeof parsed.total_cost_usd === 'number' ? { costUsd: parsed.total_cost_usd } : {}),
38
- ...(usage ? { tokens: { input: n(usage.input_tokens) + n(usage.cache_read_input_tokens) + n(usage.cache_creation_input_tokens), output: n(usage.output_tokens) } } : {}),
39
- };
40
- }
41
- catch {
42
- return { text: stdout };
43
- }
44
- },
31
+ answer: stdout => claudeAnswer(stdout),
45
32
  },
46
33
  cursor: {
47
34
  vendor: 'cursor',
@@ -179,3 +166,71 @@ export async function reviewerAvailability(cwd, exec = defaultExec) {
179
166
  return { name, vendor: ADAPTERS[name].vendor, binary: ADAPTERS[name].binary, installed: !!found, ...(found ? { version: found.version } : {}) };
180
167
  }));
181
168
  }
169
+ /** Claude Code's answer: a stream of JSON events (the final `result` one carries the text and cost), or one JSON object from older runs. */
170
+ function claudeAnswer(stdout) {
171
+ const events = stdout.split('\n').map(line => line.trim()).filter(line => line.startsWith('{')).flatMap(line => {
172
+ try {
173
+ return [JSON.parse(line)];
174
+ }
175
+ catch {
176
+ return [];
177
+ }
178
+ });
179
+ const result = events.filter(e => e?.type === 'result').at(-1) ?? (events.length === 1 ? events[0] : undefined);
180
+ if (!result)
181
+ return { text: stdout };
182
+ const usage = result.usage;
183
+ const trace = traceOf(events);
184
+ return {
185
+ text: String(result.result ?? ''),
186
+ ...(typeof result.total_cost_usd === 'number' ? { costUsd: result.total_cost_usd } : {}),
187
+ ...(usage ? { tokens: { input: n(usage.input_tokens) + n(usage.cache_read_input_tokens) + n(usage.cache_creation_input_tokens), output: n(usage.output_tokens) } } : {}),
188
+ ...(trace ? { trace } : {}),
189
+ };
190
+ }
191
+ /** The turns, their usage and the tool calls of a stream, or undefined for a single-object answer. */
192
+ function traceOf(events) {
193
+ const assistant = events.filter(e => e?.type === 'assistant' && e.message);
194
+ if (assistant.length === 0)
195
+ return undefined;
196
+ const usage = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 };
197
+ const calls = [];
198
+ const byId = new Map();
199
+ let turn = 0;
200
+ const seen = new Set();
201
+ for (const e of events) {
202
+ if (e?.type === 'assistant' && e.message) {
203
+ // One model call can arrive as several events (one per content block) sharing its id: count its usage once.
204
+ const id = String(e.message.id ?? `turn-${turn}`);
205
+ if (!seen.has(id)) {
206
+ seen.add(id);
207
+ turn++;
208
+ const u = e.message.usage ?? {};
209
+ usage.input += n(u.input_tokens);
210
+ usage.cacheRead += n(u.cache_read_input_tokens);
211
+ usage.cacheWrite += n(u.cache_creation_input_tokens);
212
+ usage.output += n(u.output_tokens);
213
+ }
214
+ for (const block of e.message.content ?? []) {
215
+ if (block?.type !== 'tool_use')
216
+ continue;
217
+ const input = block.input ?? {};
218
+ const call = { turn, tool: String(block.name ?? ''), target: String(input.file_path ?? input.path ?? input.pattern ?? input.command ?? '').slice(0, 300), resultChars: 0 };
219
+ calls.push(call);
220
+ byId.set(String(block.id), call);
221
+ }
222
+ }
223
+ else if (e?.type === 'user' && e.message) {
224
+ for (const block of e.message.content ?? []) {
225
+ if (block?.type !== 'tool_result')
226
+ continue;
227
+ const call = byId.get(String(block.tool_use_id));
228
+ if (!call)
229
+ continue;
230
+ const content = block.content;
231
+ call.resultChars = typeof content === 'string' ? content.length : Array.isArray(content) ? content.reduce((sum, c) => sum + String(c?.text ?? '').length, 0) : 0;
232
+ }
233
+ }
234
+ }
235
+ return { turns: turn, usage, calls };
236
+ }
@@ -20,4 +20,26 @@ describe('reading an agent CLI\'s answer', () => {
20
20
  expect(ADAPTERS.claude.answer('not json')).toEqual({ text: 'not json' });
21
21
  expect(ADAPTERS.codex.answer('plain text')).toEqual({ text: 'plain text' });
22
22
  });
23
+ it("reads Claude Code's event stream: the answer and cost from its result, each turn's usage once, and what every tool call read", () => {
24
+ const events = [
25
+ { type: 'system', subtype: 'init' },
26
+ { type: 'assistant', message: { id: 'm1', usage: { input_tokens: 10, cache_read_input_tokens: 0, cache_creation_input_tokens: 4000, output_tokens: 50 }, content: [{ type: 'tool_use', id: 't1', name: 'Read', input: { file_path: '/work/full.diff' } }] } },
27
+ { type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 't1', content: 'x'.repeat(1200) }] } },
28
+ { type: 'assistant', message: { id: 'm2', usage: { input_tokens: 5, cache_read_input_tokens: 4000, cache_creation_input_tokens: 300, output_tokens: 20 }, content: [{ type: 'text', text: 'checking' }] } },
29
+ { type: 'assistant', message: { id: 'm2', usage: { input_tokens: 5, cache_read_input_tokens: 4000, cache_creation_input_tokens: 300, output_tokens: 20 }, content: [{ type: 'tool_use', id: 't2', name: 'Grep', input: { pattern: 'preloadAll' } }] } },
30
+ { type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 't2', content: [{ type: 'text', text: 'src/a.ts:3' }] }] } },
31
+ { type: 'result', result: '{"prior_points":[]}', total_cost_usd: 0.31, usage: { input_tokens: 15, cache_read_input_tokens: 4000, cache_creation_input_tokens: 4300, output_tokens: 70 } },
32
+ ];
33
+ const answer = ADAPTERS.claude.answer(events.map(e => JSON.stringify(e)).join('\n'));
34
+ expect(answer).toMatchObject({ text: '{"prior_points":[]}', costUsd: 0.31, tokens: { input: 8315, output: 70 } });
35
+ expect(answer.trace).toEqual({
36
+ turns: 2, // m2 arrived as two events: one turn, counted once
37
+ usage: { input: 15, cacheRead: 4000, cacheWrite: 4300, output: 70 },
38
+ calls: [
39
+ { turn: 1, tool: 'Read', target: '/work/full.diff', resultChars: 1200 },
40
+ { turn: 2, tool: 'Grep', target: 'preloadAll', resultChars: 10 },
41
+ ],
42
+ });
43
+ expect(ADAPTERS.claude.args('p', undefined)).toEqual(expect.arrayContaining(['--output-format', 'stream-json', '--verbose']));
44
+ });
23
45
  });
@@ -12,7 +12,7 @@ const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding:
12
12
  const config = ConfigSchema.parse({ version: 1, review: { reviewer: { enabled: true } } });
13
13
  const originalPath = process.env.PATH;
14
14
  let PR = { number: 7, state: 'OPEN', isDraft: false, author: { login: 'author' }, body: 'desc' };
15
- const VERDICT = JSON.stringify({ prior_points: [], redundant: [], reads: [], scans: [], merge_impact: [], findings: [{ class: 'correctness', file: 'src/job.ts', line: 1, issue: 'never locks', consequence: 'a second run reads stale rows' }], carried: [], resolved_previous: [] });
15
+ const VERDICT = JSON.stringify({ prior_points: [], redundant: [], reads: [], scans: [], merge_impact: [], findings: [{ class: 'correctness', file: 'src/job.ts', line: 1, issue: 'never locks', quote: 'export const job = 1;', consequence: 'a second run reads stale rows' }], carried: [], resolved_previous: [] });
16
16
  /** Real git; a pull request on the branch; a claude that answers one finding. */
17
17
  const exec = async (command, args, options) => {
18
18
  if (command === 'git') {
@@ -1,3 +1,4 @@
1
+ import { type LessonMode } from '../../review-learning/team-lessons.js';
1
2
  import type { RouterPolicy } from '../../deep/router.js';
2
3
  import type { PanelItem } from './panel.js';
3
4
  import { type Exec } from './exec.js';
@@ -28,6 +29,8 @@ export interface ContextInput {
28
29
  dismissals: ReviewDismissal[];
29
30
  diff: string;
30
31
  router: RouterPolicy | undefined;
32
+ /** Which of the team's review lessons the judges see (gates.deep.review_lessons): verified by default, all, or off. */
33
+ lessons?: LessonMode;
31
34
  /** The previous verdict's panel decisions, and the files changed since it. */
32
35
  previousPanel: PanelItem[] | undefined;
33
36
  touched: Set<string>;
@@ -14,12 +14,15 @@ import fs from 'fs';
14
14
  import path from 'path';
15
15
  import { createHash } from 'crypto';
16
16
  import { buildReviewTask } from '../review-task.js';
17
+ import { describeLesson, lessonsForDiff, lessonView, rejectedForDiff } from '../../review-learning/team-lessons.js';
17
18
  import { reviewedKeys } from '../ledger.js';
18
19
  import { textSimilarity } from './consensus.js';
19
20
  import { defaultExec, GH_TIMEOUT_MS } from './exec.js';
20
21
  import { VerdictStore } from './store.js';
21
22
  export const REVIEW_DISMISSALS = path.join('.rigour', 'dismissed-review-items.json');
22
23
  const MAX_DOCS = 10;
24
+ /** Team standards a judge is shown with the lessons about the changed files. */
25
+ const JUDGE_STANDARDS = 15;
23
26
  const MAX_SETTLED = 40;
24
27
  export function readReviewDismissals(cwd) {
25
28
  try {
@@ -66,22 +69,28 @@ export function buildContext(input) {
66
69
  const sections = [];
67
70
  let task;
68
71
  try {
69
- task = buildReviewTask(input.cwd, input.diff, input.router, undefined, undefined, reviewedKeys(input.stateRoot));
72
+ task = buildReviewTask(input.cwd, input.diff, input.router, input.lessons, undefined, reviewedKeys(input.stateRoot));
70
73
  }
71
74
  catch {
72
75
  task = undefined;
73
76
  }
74
- if (task?.lessons.length)
75
- sections.push(`## Lessons this team verified on earlier reviews, for the files this change touches\n${task.lessons.map(l => `- ${l.file}: ${l.text}${l.prs.length ? ` (PR ${l.prs.join(', ')})` : ''}`).join('\n')}`);
77
+ // A judge reads the whole pull request: more of what the team taught fits than an agent's one question at the stop.
78
+ const lessons = input.lessons === 'off' ? [] : lessonsForDiff(input.cwd, input.diff, input.lessons, JUDGE_STANDARDS).map(lessonView);
79
+ if (lessons.length)
80
+ sections.push(`## Lessons this team taught on earlier reviews, for what this change touches (context: a lesson never blocks on its own; a finding still needs its quote)\n${lessons.map(l => `- ${describeLesson(l)}`).join('\n')}`);
76
81
  if (task?.rules.length)
77
82
  sections.push(`## Repository rules that name what this change touches\n${task.rules.map(r => `- ${r.source}: ${r.text}`).join('\n')}`);
78
83
  if (input.checks.length)
79
84
  sections.push(`## Already found by Rigour's checks: they block on their own, so do not report them again\n${input.checks.slice(0, MAX_SETTLED).map(c => `- ${c}`).join('\n')}`);
85
+ const rejected = input.lessons === 'off' ? [] : rejectedForDiff(input.cwd, input.diff).map(l => {
86
+ const no = l.evidence.filter(e => e.kind === 'rejected').at(-1);
87
+ return `- this team decided against: ${l.text}${no?.author ? ` (rejected by ${no.author}${no.detail ? `: ${no.detail}` : ''})` : ''}`;
88
+ });
80
89
  const dismissed = input.dismissals.slice(-MAX_SETTLED).map(d => `- dismissed as not a bug${d.by ? ` by ${d.by}` : ''}: ${where(d)} [${d.class}] ${d.issue} (reason: ${d.reason})`);
81
90
  const refuted = (input.previousPanel ?? []).filter(p => p.status === 'dropped' && !!p.item.file && !input.touched.has(p.item.file)).slice(0, MAX_SETTLED)
82
91
  .map(p => `- refuted with evidence in the last round: ${where(p.item)} [${p.item.class}] ${p.item.issue} (${(p.cross ?? []).find(c => c.call === 'refute')?.evidence ?? 'evidence in the previous verdict'})`);
83
- if (dismissed.length || refuted.length)
84
- sections.push(`## Settled: do not raise these again unless the code now shows something new\n${[...dismissed, ...refuted].join('\n')}`);
92
+ if (dismissed.length || refuted.length || rejected.length)
93
+ sections.push(`## Settled: do not raise these again unless the code now shows something new\n${[...rejected, ...dismissed, ...refuted].join('\n')}`);
85
94
  const docs = input.docs;
86
95
  if (docs.length)
87
96
  sections.push(`## Docs that describe the changed code (read one when its claim matters to a finding)\n${docs.map(d => `- ${d.doc} (names ${d.names.join(', ')})`).join('\n')}`);
@@ -1,5 +1,8 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
1
4
  import { describe, expect, it } from 'vitest';
2
- import { dismissedAs } from './context.js';
5
+ import { buildContext, dismissedAs } from './context.js';
3
6
  const dismissal = { id: 'abcdef0123', file: 'src/x.ts', line: 40, class: 'correctness', issue: 'cache key built from user id may collide', reason: 'ids are unique per tenant', at: '2026-10-06' };
4
7
  const finding = (over) => ({ id: 'ffff000011', kind: 'finding', class: 'correctness', file: 'src/x.ts', line: 40, issue: 'cache key built from the user id may collide', consequence: 'two users share an entry', ...over });
5
8
  describe('a dismissal', () => {
@@ -16,3 +19,25 @@ describe('a dismissal', () => {
16
19
  expect(dismissedAs({ ...finding({}), kind: 'prior' }, [dismissal])).toBeUndefined(); // a human's point is never dismissed this way
17
20
  });
18
21
  });
22
+ describe('what the judges are told the team knows', () => {
23
+ it('shows verified lessons by default, candidates too with all, none when off, each with the words it came from', () => {
24
+ const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'context-'));
25
+ try {
26
+ const lesson = { file: 'src/a.ts', symbols: ['save'], createdAt: '', updatedAt: '' };
27
+ fs.mkdirSync(path.join(repo, '.rigour'));
28
+ fs.writeFileSync(path.join(repo, '.rigour', 'review-lessons.json'), JSON.stringify({ version: 1, lessons: [
29
+ { ...lesson, id: 'v', text: 'Make every save idempotent on retry.', state: 'verified', evidence: [{ pr: 3, comment: 'a', author: 'p', said: 'A retry here saves twice.' }, { pr: 4, comment: 'b', author: 'p' }] },
30
+ { ...lesson, id: 'c', text: 'Log the save id on failure.', state: 'candidate', evidence: [{ pr: 5, comment: 'c', author: 'p' }] },
31
+ ] }));
32
+ const diff = 'diff --git a/src/a.ts b/src/a.ts\n--- a/src/a.ts\n+++ b/src/a.ts\n@@ -0,0 +1,1 @@\n+export const save = 1;\n';
33
+ const told = (lessons) => buildContext({ cwd: repo, stateRoot: repo, dismissals: [], diff, router: undefined, lessons, previousPanel: undefined, touched: new Set(), docs: [], checks: [] }).text;
34
+ expect(told()).toContain('src/a.ts: Make every save idempotent on retry. (in their words: "A retry here saves twice.") (acted on in PR #3, #4)');
35
+ expect(told()).not.toContain('Log the save id');
36
+ expect(told('all')).toContain('Log the save id on failure.');
37
+ expect(told('off')).not.toContain('idempotent');
38
+ }
39
+ finally {
40
+ fs.rmSync(repo, { recursive: true, force: true });
41
+ }
42
+ });
43
+ });
@@ -1,7 +1,9 @@
1
+ /** `env` adds variables to the inherited environment; `unset` removes inherited ones the command must not see. */
1
2
  export type Exec = (command: string, args: string[], options: {
2
3
  cwd: string;
3
4
  timeoutMs: number;
4
5
  env?: Record<string, string>;
6
+ unset?: string[];
5
7
  }) => Promise<{
6
8
  exitCode: number;
7
9
  stdout: string;
@@ -13,5 +15,12 @@ export type Progress = (message: string) => void;
13
15
  export declare const GH_TIMEOUT_MS = 60000;
14
16
  /** The named GitHub account's token for `gh`, when the person keeps several; otherwise gh's own. */
15
17
  export declare function githubEnv(cwd: string, account: string | undefined, exec: Exec): Promise<Record<string, string> | undefined>;
18
+ /**
19
+ * The token to read GitHub with: an explicit one first (GH_TOKEN, GITHUB_TOKEN: CI, or a person's
20
+ * own choice), then the named account's (review.github_account / RIGOUR_GITHUB_ACCOUNT), and the
21
+ * gh CLI's active account only when no account is named, so a machine signed in to several
22
+ * accounts never reads as the wrong one.
23
+ */
24
+ export declare function githubToken(cwd: string, account: string | undefined, exec: Exec): Promise<string>;
16
25
  /** `gh --paginate` prints one JSON array per page. */
17
26
  export declare function parseJsonArrays(text: string): any[];
@@ -3,11 +3,20 @@ import { execa } from 'execa';
3
3
  export const defaultExec = async (command, args, options) => {
4
4
  // stdin is /dev/null, not an empty pipe: an agent CLI that reads stdin sees its end at once, and a command that exits
5
5
  // before an empty write lands (git rev-parse) cannot fail the run with EPIPE, as it did on Linux.
6
- const result = await execa(command, args, { cwd: options.cwd, reject: false, timeout: options.timeoutMs, stdin: 'ignore', env: options.env ? { ...process.env, ...options.env } : undefined, maxBuffer: 64 * 1024 * 1024 });
6
+ const result = await execa(command, args, { cwd: options.cwd, reject: false, timeout: options.timeoutMs, stdin: 'ignore', ...childEnv(options), maxBuffer: 64 * 1024 * 1024 });
7
7
  const stderr = String(result.stderr ?? '');
8
8
  // A command that could not start, timed out or was killed writes nothing itself: say which, never an empty failure.
9
9
  return { exitCode: result.exitCode ?? 1, stdout: String(result.stdout ?? ''), stderr: stderr || (result.failed && 'shortMessage' in result ? String(result.shortMessage) : '') };
10
10
  };
11
+ /** The environment a command runs with: the inherited one plus `env`, minus `unset` (then nothing is inherited implicitly). */
12
+ function childEnv(options) {
13
+ if (!options.unset?.length)
14
+ return options.env ? { env: { ...process.env, ...options.env } } : {};
15
+ const env = { ...process.env, ...options.env };
16
+ for (const name of options.unset)
17
+ delete env[name];
18
+ return { env, extendEnv: false };
19
+ }
11
20
  export const GH_TIMEOUT_MS = 60_000;
12
21
  /** The named GitHub account's token for `gh`, when the person keeps several; otherwise gh's own. */
13
22
  export async function githubEnv(cwd, account, exec) {
@@ -16,6 +25,24 @@ export async function githubEnv(cwd, account, exec) {
16
25
  const token = await exec('gh', ['auth', 'token', '--user', account], { cwd, timeoutMs: GH_TIMEOUT_MS });
17
26
  return token.exitCode === 0 && token.stdout.trim() ? { GH_TOKEN: token.stdout.trim() } : undefined;
18
27
  }
28
+ /**
29
+ * The token to read GitHub with: an explicit one first (GH_TOKEN, GITHUB_TOKEN: CI, or a person's
30
+ * own choice), then the named account's (review.github_account / RIGOUR_GITHUB_ACCOUNT), and the
31
+ * gh CLI's active account only when no account is named, so a machine signed in to several
32
+ * accounts never reads as the wrong one.
33
+ */
34
+ export async function githubToken(cwd, account, exec) {
35
+ const explicit = process.env.GH_TOKEN?.trim() || process.env.GITHUB_TOKEN?.trim();
36
+ if (explicit)
37
+ return explicit;
38
+ const named = account?.trim();
39
+ const read = await exec('gh', named ? ['auth', 'token', '--user', named] : ['auth', 'token'], { cwd, timeoutMs: GH_TIMEOUT_MS });
40
+ if (read.exitCode === 0 && read.stdout.trim())
41
+ return read.stdout.trim();
42
+ throw new Error(named
43
+ ? `GitHub account ${named} is named for reading, but \`gh auth token --user ${named}\` gave no token: sign in to it with \`gh auth login\`, or set GITHUB_TOKEN.`
44
+ : 'Set GITHUB_TOKEN or sign in with `gh auth login` (read access to the repository is enough).');
45
+ }
19
46
  /** `gh --paginate` prints one JSON array per page. */
20
47
  export function parseJsonArrays(text) {
21
48
  try {
@@ -1,6 +1,6 @@
1
1
  import os from 'os';
2
- import { describe, expect, it } from 'vitest';
3
- import { defaultExec } from './exec.js';
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { defaultExec, githubToken } from './exec.js';
4
4
  describe('running a command', () => {
5
5
  it('passes the command\'s own output through', async () => {
6
6
  const result = await defaultExec(process.execPath, ['-e', 'process.stdout.write("out"); process.stderr.write("err"); process.exit(3)'], { cwd: os.tmpdir(), timeoutMs: 30_000 });
@@ -12,6 +12,21 @@ describe('running a command', () => {
12
12
  const runs = await Promise.all(Array.from({ length: 50 }, () => defaultExec(process.execPath, ['-e', 'process.stdout.write("ok")'], { cwd: os.tmpdir(), timeoutMs: 30_000 })));
13
13
  expect(runs.every(r => r.exitCode === 0 && r.stdout === 'ok')).toBe(true);
14
14
  });
15
+ it('keeps a variable the command must not see out of its environment, and passes the rest through', async () => {
16
+ process.env.RIGOUR_TEST_SECRET = 'gateway-key';
17
+ process.env.RIGOUR_TEST_KEPT = 'kept';
18
+ try {
19
+ const print = ['-e', 'process.stdout.write(JSON.stringify({ secret: process.env.RIGOUR_TEST_SECRET ?? null, kept: process.env.RIGOUR_TEST_KEPT ?? null, added: process.env.RIGOUR_TEST_ADDED ?? null }))'];
20
+ const hidden = await defaultExec(process.execPath, print, { cwd: os.tmpdir(), timeoutMs: 30_000, env: { RIGOUR_TEST_ADDED: 'added' }, unset: ['RIGOUR_TEST_SECRET'] });
21
+ expect(JSON.parse(hidden.stdout)).toEqual({ secret: null, kept: 'kept', added: 'added' });
22
+ const inherited = await defaultExec(process.execPath, print, { cwd: os.tmpdir(), timeoutMs: 30_000 });
23
+ expect(JSON.parse(inherited.stdout)).toEqual({ secret: 'gateway-key', kept: 'kept', added: null });
24
+ }
25
+ finally {
26
+ delete process.env.RIGOUR_TEST_SECRET;
27
+ delete process.env.RIGOUR_TEST_KEPT;
28
+ }
29
+ });
15
30
  it('says why a command that never answered failed: not started, or timed out', async () => {
16
31
  const missing = await defaultExec('rigour-no-such-command', [], { cwd: os.tmpdir(), timeoutMs: 30_000 });
17
32
  expect(missing.exitCode).not.toBe(0);
@@ -21,3 +36,24 @@ describe('running a command', () => {
21
36
  expect(slow.stderr).toMatch(/timed out/i);
22
37
  });
23
38
  });
39
+ describe('the token GitHub is read with', () => {
40
+ afterEach(() => { vi.unstubAllEnvs(); });
41
+ const gh = (tokens, asked) => async (_command, args) => {
42
+ asked.push(args);
43
+ const user = args[args.indexOf('--user') + 1];
44
+ const token = args.includes('--user') ? tokens[user] : tokens.active;
45
+ return token ? { exitCode: 0, stdout: `${token}\n`, stderr: '' } : { exitCode: 1, stdout: '', stderr: 'no account' };
46
+ };
47
+ it('is an explicit token first, then the named account, and the active account only when none is named', async () => {
48
+ vi.stubEnv('GH_TOKEN', '');
49
+ vi.stubEnv('GITHUB_TOKEN', '');
50
+ const asked = [];
51
+ const tokens = { active: 'other-employer', work: 'work-token' };
52
+ expect(await githubToken(os.tmpdir(), 'work', gh(tokens, asked))).toBe('work-token');
53
+ expect(await githubToken(os.tmpdir(), undefined, gh(tokens, asked))).toBe('other-employer');
54
+ await expect(githubToken(os.tmpdir(), 'missing', gh(tokens, asked))).rejects.toThrow('GitHub account missing is named for reading');
55
+ expect(asked).toEqual([['auth', 'token', '--user', 'work'], ['auth', 'token'], ['auth', 'token', '--user', 'missing']]); // a named account never falls back to the active one
56
+ vi.stubEnv('GITHUB_TOKEN', 'ci-token');
57
+ expect(await githubToken(os.tmpdir(), 'work', gh(tokens, asked))).toBe('ci-token');
58
+ });
59
+ });
@@ -17,6 +17,12 @@ export interface HumanReviews {
17
17
  }
18
18
  type Gh = (args: string[]) => ReturnType<Exec>;
19
19
  export declare function ghFor(cwd: string, exec: Exec, env: Record<string, string> | undefined): Gh;
20
+ /**
21
+ * The description as it read at `at` (a backtest's review time): GitHub keeps every version in
22
+ * `userContentEdits`, the first being the text at creation. Undefined when it cannot be read, so a
23
+ * caller never falls back to today's text, which may describe code written after the review.
24
+ */
25
+ export declare function bodyAsOf(gh: Gh, pr: number, at: string): Promise<string | undefined>;
20
26
  /** The pull request for the branch (or the one named, or the one the commit is on), or none, or why it could not be read. */
21
27
  export declare function findPullRequest(gh: Gh, branch: string, head: string, named: number | undefined): Promise<{
22
28
  pr?: PullRequest;
@@ -14,6 +14,32 @@ export function ghFor(cwd, exec, env) {
14
14
  return args => exec('gh', args, { cwd, timeoutMs: GH_TIMEOUT_MS, env });
15
15
  }
16
16
  const PR_FIELDS = 'number,state,isDraft,author,body';
17
+ /**
18
+ * The description as it read at `at` (a backtest's review time): GitHub keeps every version in
19
+ * `userContentEdits`, the first being the text at creation. Undefined when it cannot be read, so a
20
+ * caller never falls back to today's text, which may describe code written after the review.
21
+ */
22
+ export async function bodyAsOf(gh, pr, at) {
23
+ const query = 'query($owner:String!,$repo:String!,$pr:Int!){repository(owner:$owner,name:$repo){pullRequest(number:$pr){body lastEditedAt userContentEdits(first:100){totalCount nodes{editedAt diff}}}}}';
24
+ const read = await gh(['api', 'graphql', '-f', `query=${query}`, '-F', 'owner={owner}', '-F', 'repo={repo}', '-F', `pr=${pr}`]);
25
+ if (read.exitCode !== 0)
26
+ return undefined;
27
+ try {
28
+ const node = JSON.parse(read.stdout)?.data?.repository?.pullRequest;
29
+ if (!node)
30
+ return undefined;
31
+ if (!node.lastEditedAt || node.lastEditedAt < at)
32
+ return String(node.body ?? '');
33
+ const edits = node.userContentEdits?.nodes ?? [];
34
+ if ((node.userContentEdits?.totalCount ?? 0) > edits.length)
35
+ return undefined; // older versions beyond the page: unknown
36
+ const before = edits.filter(e => e.editedAt < at && typeof e.diff === 'string').sort((a, b) => (a.editedAt < b.editedAt ? 1 : -1))[0];
37
+ return before?.diff ?? undefined;
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ }
17
43
  /** The pull request for the branch (or the one named, or the one the commit is on), or none, or why it could not be read. */
18
44
  export async function findPullRequest(gh, branch, head, named) {
19
45
  if (named !== undefined)
@@ -0,0 +1,3 @@
1
+ export declare function judgeUnset(name: string, judgeEnv: Record<string, {
2
+ unset: string[];
3
+ }>): string[];
@@ -0,0 +1,10 @@
1
+ /**
2
+ * What a judge's CLI must not see. A judge runs with the person's environment, because that is how
3
+ * an agent CLI finds its login or its API key; but a key meant for something else must not reach
4
+ * it. RIGOUR_API_KEY (Rigour's own model-review key) never does, and a team names any other
5
+ * variable per judge in review.reviewer.judge_env (e.g. OPENAI_API_KEY holding a gateway key).
6
+ */
7
+ const NEVER_TO_JUDGES = ['RIGOUR_API_KEY'];
8
+ export function judgeUnset(name, judgeEnv) {
9
+ return [...new Set([...NEVER_TO_JUDGES, ...(judgeEnv[name]?.unset ?? [])])];
10
+ }
@@ -3,7 +3,7 @@
3
3
  * is sandboxed can still read all of it. The structure is what turned noted issues into caught
4
4
  * blocking ones on a real multi-round review: every prior point first (siblings included), then
5
5
  * what a fix made redundant, then a trace of every read, then nested scans, then merge impact,
6
- * then the diff as a person reads it. Blocking is decided by the kind of finding, never by how
6
+ * then the journey past the request, sibling parity and every claim, then the diff as a person reads it. Blocking is decided by the kind of finding, never by how
7
7
  * severe it feels.
8
8
  */
9
9
  import { createHash } from 'crypto';
@@ -36,7 +36,10 @@ Do these steps in order. Report only what you verified in the code, with file:li
36
36
  site, check the others that mount or call the same thing and list them in checked_siblings. A
37
37
  point an earlier review raised and a later review called fixed must still be fixed: look for the
38
38
  same class coming back in new code. Judge the code, not commit messages, replies or the
39
- description. Give each point the severity its reviewer gave it.
39
+ description. Give each point the severity its reviewer gave it. For a point you say is NOT
40
+ resolved, give file, line and quote: the code at this commit that shows it is still open, copied
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.
40
43
 
41
44
  2. Redundancy. A fix often leaves behind what it made unnecessary. For every hunk in the reviewed
42
45
  range that moves a condition into a query (.not, .gte, .lte, .gt, .lt, .in, .like, .eq added),
@@ -81,32 +84,85 @@ Do these steps in order. Report only what you verified in the code, with file:li
81
84
  against the new definition and the new tests. A call site that no longer holds is a
82
85
  correctness finding.
83
86
 
84
- 6. Review the diff the way the human reviewers do: correctness, production cost, dead code and
87
+ 6. Journey. Follow what the change does to a user, a job or a record past this one request. For
88
+ every write the reviewed range adds or changes, record in journey:
89
+ - state that outlives the request (a row, a cache entry, a queued job, a sent message, a
90
+ stamped flag): what clears or expires it (cleared_by, null when nothing does), and whether a
91
+ retry of the same request (retry_safe) and two runs overlapping (overlap_safe) leave it right.
92
+ - every status or stage it sets: can_move_back is true when the write can move a record to an
93
+ earlier status, or overwrite a terminal one, with no guard in the query or before it.
94
+ - every event id, dedupe key or idempotency key it writes, with its inputs and
95
+ stable_under_edit, as in step 3.
96
+ A false retry_safe, overlap_safe or stable_under_edit, a true can_move_back, or a null
97
+ cleared_by on state that should end is a correctness finding.
98
+
99
+ 7. Sibling parity. For every route, runner, handler, job or page the range changes, find the
100
+ siblings that do the same job (the same folder, the same shape, the same caller) and say whether
101
+ each needs the same change and has it. One that needs it and lacks it is a correctness finding.
102
+
103
+ 8. Claims. Read every comment in every file the range touches and every sentence of the pull
104
+ request description that states what the code does ("every link goes through X", "at worst one
105
+ email", "runs once a day"). Report each claim the code at this commit no longer makes true,
106
+ with the code that contradicts it as file:line. A false claim is a stale-claim finding.
107
+
108
+ 9. Team lessons. For EVERY lesson listed under "Lessons this team taught" in the team knowledge
109
+ file, decide whether this change repeats the mistake it names or skips what it asks for, and say
110
+ so in lessons, with the file:line that shows it either way. A lesson is what this team's
111
+ reviewers asked for before; it is not a finding by itself. When the change does repeat it, write
112
+ the finding as for any other miss (input, consequence, quote), naming the lesson in why.
113
+
114
+ 10. Review the diff the way the human reviewers do: correctness, production cost, dead code and
85
115
  unreferenced exports (a test is not a consumer), code duplicated across sibling routes or
86
- runners, links or ids built outside the helper that owns them, every comment and every claim
87
- in the description still true of the code, and the repository's rules.
116
+ runners, links or ids built outside the helper that owns them, and the repository's rules.
117
+
118
+ The lists from steps 2-9 are your working notes: people see them, and they never block on their
119
+ own. A miss blocks only when you also put it in findings, with all three of:
120
+ - input: the concrete input, state or sequence that goes wrong (a user edits, a retry, two runs at once);
121
+ - consequence: what goes wrong for that input, or the cost (reads, calls or memory per what);
122
+ - quote: the code at file:line that does it, copied exactly from the file (one to three lines).
123
+ The quote is checked against the checkout: a finding whose quote is not at the line it names never
124
+ blocks. A miss you cannot show that way stays in your notes. When a finding is that something is
125
+ MISSING (a call, a check, a guard, a cleanup), also put the exact text that is missing in absent
126
+ ("conn.close("): Rigour searches the file for it, and a finding about something that is there
127
+ never blocks.
128
+
129
+ Give every finding a severity, by how the wrong outcome is reached. blocking: it happens on the
130
+ feature's normal path (every run, every user of the feature, growing with the data), even when the
131
+ feature sits behind a flag or a rollout: wrong data, a lost or duplicated write or event, a crash, a
132
+ security hole, or a material cost. should: it needs an unusual combination of inputs or failures to
133
+ happen, or it is brief or cosmetic, or it is a comment or description that drifted. For a scheduled
134
+ or background job, the flag-off, kill-switch or lock-held path is a normal path: work done before
135
+ checking it is paid on every run. A point a human reviewer already raised and called non-blocking, or approved with it
136
+ present, is answered in prior_points and is never a blocking finding unless you show a wrong outcome
137
+ they did not know about.
88
138
 
89
139
  Classes: correctness, production-cost, dead-code, duplication, stale-claim, helper-bypass,
90
140
  repo-rule. Every finding needs a consequence: the wrong outcome it causes (an input and what
91
- goes wrong) or the cost it adds (reads, calls or memory per what). A finding with no wrong outcome
92
- and no cost (faster, simpler or cleaner code) is an opinion: leave consequence empty and it is
93
- shown, never blocking. Do not report style preferences or trade-offs you would not request
94
- changes for.
141
+ goes wrong) or a material cost: one that grows with the data or the traffic (an extra query or
142
+ round trip, rows read that scale with users or time, a missing index, an unbounded window, memory
143
+ per item). A cost that does not grow (one more column on rows already read, a second copy of a
144
+ small check, code that could be shorter or shared) is not material. A finding with no wrong outcome
145
+ and no material cost is an opinion: leave consequence empty and it is shown, never blocking. Do not
146
+ report style preferences or trade-offs you would not request changes for.
95
147
 
96
148
  Your final message must be ONLY this JSON, starting with { and ending with }, nothing before or after it:
97
- {"prior_points":[{"point":"...","review":"<login> <submitted_at>","severity":"blocking"|"should-fix"|"non-blocking","resolved":true|false,"evidence":"file:line ...","checked_siblings":["file:line"]}],
149
+ {"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"]}],
98
150
  "redundant":[{"file":"...","line":0,"what":"...","made_redundant_by":"file:line","removed":true|false}],
99
151
  "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":"..."}],
100
152
  "scans":[{"file":"...","line":0,"function":"...","outer":"...","inner":"...","fix":"..."}],
101
153
  "merge_impact":[{"symbol":"...","main_file":"...","call_site":"file:line","holds":true|false,"why":"..."}],
102
- "findings":[{"class":"...","file":"...","line":0,"issue":"...","why":"...","consequence":"<wrong outcome for an input, or the cost; empty for an opinion>"}],
154
+ "journey":[{"file":"...","line":0,"what":"...","cleared_by":null|"...","retry_safe":true|false|null,"overlap_safe":true|false|null,"can_move_back":true|false|null,"keys":[{"name":"...","inputs":"...","stable_under_edit":true|false}]}],
155
+ "siblings":[{"changed":"file:line","sibling":"file:line","needs_same_change":true|false,"has_it":true|false,"why":"..."}],
156
+ "claims":[{"source":"comment"|"description","claim":"...","file":"<code that contradicts it>","line":0,"holds":true|false,"evidence":"..."}],
157
+ "lessons":[{"lesson":"<the lesson as listed>","applies":true|false,"file":"...","line":0,"evidence":"..."}],
158
+ "findings":[{"class":"...","severity":"blocking"|"should","file":"...","line":0,"issue":"...","why":"...","input":"...","consequence":"<wrong outcome for that input, or the cost; empty for an opinion>","quote":"<the code at file:line, copied exactly>","absent":"<for a missing call or check: the exact text that is missing>"}],
103
159
  "carried":["<delta mode: ids of previous open items that still stand>"],
104
160
  "resolved_previous":[{"id":"<delta mode: id of a previous open item now fixed>","evidence":"file:line and the fix"}]}`;
105
161
  }
106
162
  export function deltaBlock(previousHead, previousVerdict, previousOpenFile, commitsFile, deltaDiffFile, settledFile) {
107
163
  return `- DELTA MODE. The previous verdict on ${previousHead.slice(0, 9)} is at ${previousVerdict}; its open
108
164
  items, each with an id, are in ${previousOpenFile}. Only the commits in ${commitsFile} are new;
109
- their diff is ${deltaDiffFile}. Do steps 1-6 on that diff and on every file an open item names.
165
+ their diff is ${deltaDiffFile}. Do steps 1-10 on that diff and on every file an open item names.
110
166
  Then, for EVERY previous open item: put its id in "carried" if it still stands, or in
111
167
  "resolved_previous" with the fix quoted at file:line. An id you leave out is treated as still open.
112
168
  Human points the previous verdict resolved, whose files these commits do not touch, are in
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The model call behind turning reviews into rules (review-learning/rules-from-reviews.ts): the
3
+ * team's first installed reviewer CLI, isolated and read-only exactly as a judge is, its spend
4
+ * counted with the reviewer's and stopped at review.reviewer.max_usd_per_day. No CLI, or the cap
5
+ * reached: undefined, and the points stay as the person wrote them.
6
+ */
7
+ import type { Config } from '../../types/index.js';
8
+ import type { RuleWriter } from '../../review-learning/rules-from-reviews.js';
9
+ import type { Exec } from './exec.js';
10
+ export declare function ruleWriterFor(cwd: string, config: Config, exec: Exec, progress?: (line: string) => void): Promise<RuleWriter | undefined>;
@@ -0,0 +1,30 @@
1
+ import { ADAPTERS, isReviewerName, resolveAdapter } from './adapters.js';
2
+ import { judgeUnset } from './judge-env.js';
3
+ import { resolveReviewer } from './settings.js';
4
+ import { VerdictStore } from './store.js';
5
+ export async function ruleWriterFor(cwd, config, exec, progress = () => undefined) {
6
+ const settings = resolveReviewer(config);
7
+ let chosen;
8
+ for (const name of settings.reviewers.filter(isReviewerName)) {
9
+ const found = await resolveAdapter(ADAPTERS[name], cwd, exec);
10
+ if (found) {
11
+ chosen = { name, binary: found.binary };
12
+ break;
13
+ }
14
+ }
15
+ const store = await VerdictStore.open(cwd, exec);
16
+ if (!chosen || !store)
17
+ return undefined;
18
+ const { name, binary } = chosen;
19
+ const model = settings.models[name] ?? (name === 'claude' ? settings.model : undefined);
20
+ return async (prompt) => {
21
+ if (settings.max_usd_per_day !== undefined && store.spend().usd >= settings.max_usd_per_day) {
22
+ progress(`rules from reviews: the daily cost cap ($${settings.max_usd_per_day}) is reached; the rest stay as people wrote them`);
23
+ return undefined;
24
+ }
25
+ const run = await exec(binary, ADAPTERS[name].args(prompt, model), { cwd, timeoutMs: settings.timeout_ms, unset: judgeUnset(name, settings.judge_env) });
26
+ const answer = ADAPTERS[name].answer(run.stdout);
27
+ store.addSpend(1, answer.costUsd);
28
+ return run.exitCode === 0 ? answer.text : undefined;
29
+ };
30
+ }
@@ -0,0 +1 @@
1
+ export {};