@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.
- package/dist/hooks/index.d.ts +3 -3
- package/dist/hooks/index.js +2 -2
- package/dist/hooks/session-state.d.ts +3 -0
- package/dist/hooks/session-state.js +10 -0
- package/dist/hooks/stop-review.d.ts +8 -15
- package/dist/hooks/stop-review.js +36 -2
- package/dist/index.d.ts +4 -2
- package/dist/index.js +4 -2
- package/dist/review/backtest-init.js +13 -11
- package/dist/review/backtest-init.test.js +26 -2
- package/dist/review/backtest.js +35 -3
- package/dist/review/backtest.test.js +44 -2
- package/dist/review/dead-code.test.js +13 -0
- package/dist/review/orphan-files.js +3 -2
- package/dist/review/quiet.d.ts +1 -1
- package/dist/review/quiet.js +3 -1
- package/dist/review/review-task.d.ts +2 -6
- package/dist/review/review-task.js +2 -2
- package/dist/review/reviewer/adapters.d.ts +21 -0
- package/dist/review/reviewer/adapters.js +70 -15
- package/dist/review/reviewer/adapters.test.js +22 -0
- package/dist/review/reviewer/background.test.js +1 -1
- package/dist/review/reviewer/context.d.ts +3 -0
- package/dist/review/reviewer/context.js +14 -5
- package/dist/review/reviewer/context.test.js +26 -1
- package/dist/review/reviewer/exec.d.ts +9 -0
- package/dist/review/reviewer/exec.js +28 -1
- package/dist/review/reviewer/exec.test.js +38 -2
- package/dist/review/reviewer/inputs.d.ts +6 -0
- package/dist/review/reviewer/inputs.js +26 -0
- package/dist/review/reviewer/judge-env.d.ts +3 -0
- package/dist/review/reviewer/judge-env.js +10 -0
- package/dist/review/reviewer/prompt.js +68 -12
- package/dist/review/reviewer/rule-writer.d.ts +10 -0
- package/dist/review/reviewer/rule-writer.js +30 -0
- package/dist/review/reviewer/rule-writer.test.d.ts +1 -0
- package/dist/review/reviewer/rule-writer.test.js +52 -0
- package/dist/review/reviewer/settings.d.ts +4 -0
- package/dist/review/reviewer/settings.js +2 -1
- package/dist/review/reviewer/verdict.d.ts +71 -9
- package/dist/review/reviewer/verdict.js +102 -10
- package/dist/review/reviewer.d.ts +5 -2
- package/dist/review/reviewer.js +56 -41
- package/dist/review/reviewer.test.js +170 -17
- package/dist/review/typed/constant-inputs.d.ts +35 -0
- package/dist/review/typed/constant-inputs.js +168 -0
- package/dist/review/typed/dead-null-guards.d.ts +34 -0
- package/dist/review/typed/dead-null-guards.js +147 -0
- package/dist/review/typed/redundancy.js +6 -0
- package/dist/review/typed/redundancy.test.js +136 -0
- package/dist/review/unused-exports.js +3 -2
- package/dist/review-learning/acted-on.d.ts +23 -3
- package/dist/review-learning/acted-on.js +25 -13
- package/dist/review-learning/human-edits.d.ts +7 -0
- package/dist/review-learning/human-edits.js +99 -0
- package/dist/review-learning/human-edits.test.d.ts +1 -0
- package/dist/review-learning/human-edits.test.js +47 -0
- package/dist/review-learning/learn-from-reviews.d.ts +37 -4
- package/dist/review-learning/learn-from-reviews.js +128 -12
- package/dist/review-learning/lessons.d.ts +62 -9
- package/dist/review-learning/lessons.js +132 -21
- package/dist/review-learning/outcomes.d.ts +26 -0
- package/dist/review-learning/outcomes.js +73 -0
- package/dist/review-learning/review-learning.test.js +158 -22
- package/dist/review-learning/review-points.d.ts +4 -0
- package/dist/review-learning/review-points.js +14 -0
- package/dist/review-learning/rules-from-reviews.d.ts +14 -0
- package/dist/review-learning/rules-from-reviews.js +86 -0
- package/dist/review-learning/team-lessons.d.ts +17 -1
- package/dist/review-learning/team-lessons.js +29 -5
- package/dist/templates/universal-config.js +3 -0
- package/dist/types/index.d.ts +63 -0
- package/dist/types/index.js +11 -0
- package/dist/utils/profile.d.ts +5 -1
- package/dist/utils/profile.js +7 -1
- package/dist/utils/profile.test.js +10 -0
- 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,
|
|
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
|
-
|
|
75
|
-
|
|
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',
|
|
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,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.
|
|
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,
|
|
87
|
-
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
"
|
|
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-
|
|
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 {};
|