@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
@@ -0,0 +1,59 @@
1
+ import { type LessonMode } from '../review-learning/team-lessons.js';
2
+ /** The most items a briefing gives: past about ten, a briefing is a wall nobody reads. */
3
+ export declare const BRIEFING_MAX_ITEMS = 10;
4
+ /** The most items a briefing for one file gives, the first time an agent edits it: a word in passing, not a wall. */
5
+ export declare const FILE_BRIEFING_MAX_ITEMS = 3;
6
+ export interface BriefingItem {
7
+ kind: 'rule' | 'lesson' | 'settled';
8
+ /** What to do, in the team's words (a rule's text, a lesson's rule). */
9
+ text: string;
10
+ /** Where it came from: the rules file, or the pull requests the lesson was learned and proven on. */
11
+ cite: string;
12
+ /** A rule the team worded as a requirement: a break blocks at review. */
13
+ requirement?: boolean;
14
+ id: string;
15
+ }
16
+ export interface Briefing {
17
+ task?: string;
18
+ goal: string;
19
+ files: string[];
20
+ items: BriefingItem[];
21
+ }
22
+ export interface BriefingInput {
23
+ /** What the task is for: the agent's first prompt, a ticket's summary, the pull request's title. The branch name when absent. */
24
+ goal?: string;
25
+ /** The files the task will touch, when known; otherwise the branch's own changes and the files the goal names. */
26
+ files?: string[];
27
+ lessons?: LessonMode;
28
+ limit?: number;
29
+ }
30
+ /**
31
+ * The briefing for a task in this checkout: requirement rules first (a break of one blocks later), then the team's
32
+ * verified lessons, then the points the team settled against, then guidance rules; `limit` items at most.
33
+ */
34
+ export declare function buildBriefing(cwd: string, input?: BriefingInput): Briefing;
35
+ /** The briefing as an agent reads it: short, numbered, each item with where it came from. Empty when there is nothing to say. */
36
+ export declare function briefingText(briefing: Briefing): string;
37
+ /**
38
+ * The briefing for one file, the first time an agent edits it: the requirement rules that name it (or its folder), the
39
+ * lessons the team learned on it, and the points the team settled against on it; at most three. At session start the
40
+ * task's files are often unknown; the first edit of a file is when they are, and when a briefing can be specific.
41
+ */
42
+ export declare function buildFileBriefing(cwd: string, file: string, input?: {
43
+ lessons?: LessonMode;
44
+ limit?: number;
45
+ }): Briefing;
46
+ /** A file's briefing as an agent reads it, just before it edits that file. Empty when there is nothing to say. */
47
+ export declare function fileBriefingText(briefing: Briefing): string;
48
+ /** Builds a file's briefing and records it on the task's thread, with the file. */
49
+ export declare function briefFile(cwd: string, file: string, input?: {
50
+ lessons?: LessonMode;
51
+ limit?: number;
52
+ session?: string;
53
+ agent?: string;
54
+ }): Briefing;
55
+ /** Builds the briefing and records it on the task's thread (what was briefed, by id, so a later review can be read against it). */
56
+ export declare function briefTask(cwd: string, input: BriefingInput & {
57
+ session?: string;
58
+ agent?: string;
59
+ }): Briefing;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The briefing: before an agent writes, what a senior on this team would tell it about the task. The repository's own
3
+ * rules and the team's verified lessons for the files the task will likely touch, and the points the team settled
4
+ * against (so the agent does not re-raise or re-do them), at most `limit` items, each cited to where it came from.
5
+ *
6
+ * Nothing here is new knowledge: the same rules and lessons the reviewer checks a change against, selected by the same
7
+ * matchers, before the code exists instead of after. It is deterministic (no model call), local, and it never blocks.
8
+ */
9
+ import { spawnSync } from 'child_process';
10
+ import { rulesForDiff } from '../review-learning/repo-rules.js';
11
+ import { describeLesson, lessonsForDiff, lessonView, rejectedForDiff } from '../review-learning/team-lessons.js';
12
+ import { appendTaskEvent, taskOf } from '../task/thread.js';
13
+ /** The most items a briefing gives: past about ten, a briefing is a wall nobody reads. */
14
+ export const BRIEFING_MAX_ITEMS = 10;
15
+ /** The most items a briefing for one file gives, the first time an agent edits it: a word in passing, not a wall. */
16
+ export const FILE_BRIEFING_MAX_ITEMS = 3;
17
+ /** The most files a briefing reads the task's likely reach from. */
18
+ const LIKELY_FILES = 20;
19
+ /**
20
+ * The briefing for a task in this checkout: requirement rules first (a break of one blocks later), then the team's
21
+ * verified lessons, then the points the team settled against, then guidance rules; `limit` items at most.
22
+ */
23
+ export function buildBriefing(cwd, input = {}) {
24
+ const task = taskOf(cwd);
25
+ const goal = (input.goal?.trim() || (task ? task.branch.replace(/[/_-]+/g, ' ') : '')).slice(0, 2000);
26
+ const files = (input.files?.length ? input.files : likelyFiles(cwd, goal)).slice(0, LIKELY_FILES);
27
+ const limit = Math.max(0, Math.min(input.limit ?? BRIEFING_MAX_ITEMS, BRIEFING_MAX_ITEMS));
28
+ // The rule and lesson matchers read a change: the task's files and the goal's words stand in for the code to come.
29
+ const shape = shapeOf(files, goal);
30
+ // Only rules that name a path or identifier of the task: the reviewer can judge a rule against code; a briefing cannot.
31
+ const rules = rulesForDiff(cwd, shape, true, limit, true);
32
+ const lessons = lessonsForDiff(cwd, shape, input.lessons ?? 'verified', 5, limit, 3).filter(l => l.state === 'verified' || input.lessons === 'all');
33
+ const settled = rejectedForDiff(cwd, shape);
34
+ const cited = (prs) => (prs.length ? `learned in PR ${prs.map(p => `#${p}`).join(', ')}` : 'the team\'s decision');
35
+ const items = [
36
+ ...rules.filter(r => r.requirement).map(r => ({ kind: 'rule', text: r.text, cite: ruleCite(r.source, r.scope), requirement: true, id: `rule:${r.id}` })),
37
+ ...lessons.map(l => {
38
+ const view = lessonView(l);
39
+ return { kind: 'lesson', text: describeLesson({ ...view, prs: [] }), cite: cited(view.prs), id: `lesson:${l.id}` };
40
+ }),
41
+ ...settled.map(l => ({ kind: 'settled', text: `settled against, do not do or raise it: ${lessonView(l).text}`, cite: cited(lessonView(l).prs), id: `settled:${l.id}` })),
42
+ ...rules.filter(r => !r.requirement).map(r => ({ kind: 'rule', text: r.text, cite: ruleCite(r.source, r.scope), id: `rule:${r.id}` })),
43
+ ];
44
+ return { ...(task ? { task: task.key } : {}), goal, files, items: items.slice(0, limit) };
45
+ }
46
+ /** The briefing as an agent reads it: short, numbered, each item with where it came from. Empty when there is nothing to say. */
47
+ export function briefingText(briefing) {
48
+ if (briefing.items.length === 0)
49
+ return '';
50
+ const lines = briefing.items.map((item, i) => `${i + 1}. ${item.requirement ? '[must] ' : item.kind === 'settled' ? '[settled] ' : ''}${item.text} (${item.cite})`);
51
+ return [`Rigour briefing${briefing.task ? ` for ${briefing.task}` : ''}: how this team builds the code this task will likely touch. Follow these; a [must] broken in your change blocks at review.`, ...lines].join('\n');
52
+ }
53
+ /**
54
+ * The briefing for one file, the first time an agent edits it: the requirement rules that name it (or its folder), the
55
+ * lessons the team learned on it, and the points the team settled against on it; at most three. At session start the
56
+ * task's files are often unknown; the first edit of a file is when they are, and when a briefing can be specific.
57
+ */
58
+ export function buildFileBriefing(cwd, file, input = {}) {
59
+ const task = taskOf(cwd);
60
+ const limit = Math.max(0, Math.min(input.limit ?? FILE_BRIEFING_MAX_ITEMS, FILE_BRIEFING_MAX_ITEMS));
61
+ const shape = shapeOf([file], '');
62
+ const mode = input.lessons ?? 'verified';
63
+ const rules = rulesForDiff(cwd, shape, true, BRIEFING_MAX_ITEMS, true).filter(r => r.requirement);
64
+ const lessons = lessonsForDiff(cwd, shape, mode, 0, BRIEFING_MAX_ITEMS, BRIEFING_MAX_ITEMS).filter(l => l.file === file && (l.state === 'verified' || mode === 'all'));
65
+ const settled = rejectedForDiff(cwd, shape).filter(l => l.file === file);
66
+ const cited = (prs) => (prs.length ? `learned in PR ${prs.map(p => `#${p}`).join(', ')}` : 'the team\'s decision');
67
+ const items = [
68
+ ...rules.map(r => ({ kind: 'rule', text: r.text, cite: ruleCite(r.source, r.scope), requirement: true, id: `rule:${r.id}` })),
69
+ ...lessons.map(l => ({ kind: 'lesson', text: lessonView(l).text, cite: cited(lessonView(l).prs), id: `lesson:${l.id}` })),
70
+ ...settled.map(l => ({ kind: 'settled', text: `settled against, do not do or raise it: ${lessonView(l).text}`, cite: cited(lessonView(l).prs), id: `settled:${l.id}` })),
71
+ ];
72
+ return { ...(task ? { task: task.key } : {}), goal: '', files: [file], items: items.slice(0, limit) };
73
+ }
74
+ /** A file's briefing as an agent reads it, just before it edits that file. Empty when there is nothing to say. */
75
+ export function fileBriefingText(briefing) {
76
+ if (briefing.items.length === 0)
77
+ return '';
78
+ const lines = briefing.items.map((item, i) => `${i + 1}. ${item.requirement ? '[must] ' : item.kind === 'settled' ? '[settled] ' : ''}${item.text} (${item.cite})`);
79
+ return [`Rigour, before you edit ${briefing.files[0]}: what this team asks of this file.`, ...lines].join('\n');
80
+ }
81
+ /** Builds a file's briefing and records it on the task's thread, with the file. */
82
+ export function briefFile(cwd, file, input = {}) {
83
+ const briefing = buildFileBriefing(cwd, file, input);
84
+ appendTaskEvent(cwd, { kind: 'brief', file, ...(input.session ? { session: input.session } : {}), ...(input.agent ? { agent: input.agent } : {}), items: briefing.items.length, ids: briefing.items.map(i => i.id), files: [file] });
85
+ return briefing;
86
+ }
87
+ /** Builds the briefing and records it on the task's thread (what was briefed, by id, so a later review can be read against it). */
88
+ export function briefTask(cwd, input) {
89
+ const briefing = buildBriefing(cwd, input);
90
+ appendTaskEvent(cwd, { kind: 'brief', ...(input.session ? { session: input.session } : {}), ...(input.agent ? { agent: input.agent } : {}), items: briefing.items.length, ids: briefing.items.map(i => i.id), files: briefing.files });
91
+ return briefing;
92
+ }
93
+ function ruleCite(source, scope) {
94
+ return scope ? `${source}, for ${scope}` : source;
95
+ }
96
+ /** A stand-in change for the matchers: each file as touched, the goal's words as the added line. */
97
+ function shapeOf(files, goal) {
98
+ const words = goal.replace(/\s+/g, ' ');
99
+ const touched = files.length ? files : ['.'];
100
+ return touched.map(f => `diff --git a/${f} b/${f}\n--- a/${f}\n+++ b/${f}\n@@ -0,0 +1,1 @@\n+${words}\n`).join('');
101
+ }
102
+ /** The files a task will likely touch: what the branch already changed, then tracked files whose path names a word of the goal. */
103
+ function likelyFiles(cwd, goal) {
104
+ const files = [];
105
+ const main = ['origin/main', 'main', 'origin/master', 'master'].find(ref => git(cwd, ['rev-parse', '--verify', '-q', ref]) !== undefined);
106
+ const base = main ? git(cwd, ['merge-base', 'HEAD', main]) : undefined;
107
+ if (base)
108
+ files.push(...(git(cwd, ['diff', '--name-only', `${base}...HEAD`]) ?? '').split('\n').filter(Boolean));
109
+ const words = [...new Set(goal.toLowerCase().match(/[a-z][a-z0-9]{3,}/g) ?? [])].filter(w => !STOP.has(w));
110
+ if (words.length) {
111
+ const tracked = (git(cwd, ['ls-files']) ?? '').split('\n').filter(Boolean);
112
+ for (const file of tracked) {
113
+ if (files.length >= LIKELY_FILES)
114
+ break;
115
+ const name = file.toLowerCase();
116
+ if (!files.includes(file) && words.some(w => name.includes(w)))
117
+ files.push(file);
118
+ }
119
+ }
120
+ return files;
121
+ }
122
+ const STOP = new Set(['this', 'that', 'with', 'from', 'into', 'when', 'then', 'than', 'have', 'make', 'should', 'would', 'could', 'please', 'there', 'their', 'what', 'which', 'about', 'feat', 'test', 'tests', 'code', 'file', 'files', 'change', 'update']);
123
+ function git(cwd, args) {
124
+ const result = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 5000, maxBuffer: 16 * 1024 * 1024 });
125
+ return result.status === 0 ? result.stdout.trim() : undefined;
126
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,107 @@
1
+ import { execFileSync } from 'child_process';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6
+ import { writeLessons } from '../review-learning/lessons.js';
7
+ import { readThread } from '../task/thread.js';
8
+ import { BRIEFING_MAX_ITEMS, briefFile, briefingText, briefTask, buildBriefing, buildFileBriefing, fileBriefingText } from './briefing.js';
9
+ let repo;
10
+ const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' }).trim();
11
+ const write = (file, text) => {
12
+ fs.mkdirSync(path.dirname(path.join(repo, file)), { recursive: true });
13
+ fs.writeFileSync(path.join(repo, file), text);
14
+ };
15
+ const lesson = (id, state, file, text, symbols, pr) => ({
16
+ id, text, file, symbols, state, evidence: [{ kind: 'outcome', pr, comment: 'c', author: 'senior', source: 'person' }],
17
+ });
18
+ beforeEach(() => {
19
+ repo = fs.mkdtempSync(path.join(os.tmpdir(), 'brief-'));
20
+ git('init', '-q', '-b', 'main');
21
+ git('config', 'user.email', 't@example.com');
22
+ git('config', 'user.name', 't');
23
+ git('config', 'commit.gpgsign', 'false');
24
+ write('AGENTS.md', [
25
+ '- Every job in `src/jobs/` must take `withLock()` before its first read; a job that reads first double-sends.',
26
+ '',
27
+ 'Prefer `fetchWithTimeout()` over a bare call in `src/jobs/` when talking to partner APIs; a hung request blocks the queue.',
28
+ '',
29
+ ].join('\n'));
30
+ write('services/billing/AGENTS.md', '- Every amount in `services/billing/` must be integer cents via `toCents()`; never a float.\n');
31
+ write('src/jobs/retry.ts', 'export async function retryJob() {}\n');
32
+ write('services/billing/charge.ts', 'export const charge = 1;\n');
33
+ git('add', '-A');
34
+ git('commit', '-qm', 'init');
35
+ writeLessons(repo, [
36
+ lesson('v1', 'verified', 'src/jobs/retry.ts', 'Bound the retry window at both ends: `updated_at` between since and until.', ['retryJob'], 12),
37
+ lesson('c1', 'candidate', 'src/jobs/retry.ts', 'Log every retry at debug level in `retryJob`.', ['retryJob'], 13),
38
+ lesson('r1', 'rejected', 'src/jobs/retry.ts', 'Wrap `retryJob` in a second try/catch.', ['retryJob'], 14),
39
+ ]);
40
+ });
41
+ afterEach(() => fs.rmSync(repo, { recursive: true, force: true }));
42
+ describe('the briefing', () => {
43
+ it("gives the files' requirement rules first, then verified lessons, settled points, then guidance, each cited; never a candidate", () => {
44
+ const briefing = buildBriefing(repo, { goal: 'retry the partner job with backoff', files: ['src/jobs/retry.ts'] });
45
+ expect(briefing.items.map(i => [i.kind, i.requirement ?? false, i.cite])).toEqual([
46
+ ['rule', true, 'AGENTS.md'],
47
+ ['lesson', false, 'learned in PR #12'],
48
+ ['settled', false, 'learned in PR #14'],
49
+ ['rule', false, 'AGENTS.md'],
50
+ ]);
51
+ expect(briefing.items[1].text).toContain('Bound the retry window at both ends');
52
+ expect(briefing.items.some(i => i.text.includes('debug level'))).toBe(false); // a candidate is not the team's yet
53
+ // A team whose reviewer is shown candidates (review_lessons: all) is briefed with them too.
54
+ expect(buildBriefing(repo, { goal: 'retry the partner job with backoff', files: ['src/jobs/retry.ts'], lessons: 'all' }).items.some(i => i.text.includes('debug level'))).toBe(true);
55
+ expect(briefing.items.some(i => i.text.includes('integer cents'))).toBe(false); // billing's rule, not this task's folder
56
+ // A rule that only shares words with the task, naming none of its paths or identifiers, is not briefed.
57
+ write('AGENTS.md', fs.readFileSync(path.join(repo, 'AGENTS.md'), 'utf8') + '\nEvery partner retry job must log its backoff and the partner it retried for, always.\n');
58
+ expect(buildBriefing(repo, { goal: 'retry the partner job with backoff', files: ['src/jobs/retry.ts'] }).items.some(i => i.text.includes('log its backoff'))).toBe(false);
59
+ const text = briefingText(briefing);
60
+ expect(text.split('\n')[1]).toMatch(/^1\. \[must\] Every job in `src\/jobs\/` must take `withLock\(\)`.* \(AGENTS\.md\)$/);
61
+ expect(text).toContain('[settled] settled against, do not do or raise it: Wrap `retryJob` in a second try/catch. (learned in PR #14)');
62
+ });
63
+ it("serves a folder's own rules to a task in that folder, cited with the folder", () => {
64
+ const briefing = buildBriefing(repo, { goal: 'charge in cents', files: ['services/billing/charge.ts'] });
65
+ expect(briefing.items.map(i => i.cite)).toContain('services/billing/AGENTS.md, for services/billing/');
66
+ });
67
+ it('never gives more than ten items, and says nothing when nothing applies', () => {
68
+ write('AGENTS.md', Array.from({ length: 30 }, (_, i) => `- Every job in \`src/jobs/\` must call \`step${i}()\` before \`retryJob\` reads.\n`).join('\n'));
69
+ expect(buildBriefing(repo, { goal: 'retry job', files: ['src/jobs/retry.ts'], limit: 50 }).items).toHaveLength(BRIEFING_MAX_ITEMS);
70
+ expect(buildBriefing(repo, { goal: 'retry job', files: ['src/jobs/retry.ts'], limit: 3 }).items).toHaveLength(3);
71
+ const nothing = buildBriefing(repo, { goal: 'update the readme wording', files: ['README.md'] });
72
+ expect(nothing.items).toEqual([]);
73
+ expect(briefingText(nothing)).toBe('');
74
+ });
75
+ it("reads the task's likely files from the branch's own changes and the goal's words, and the goal from the branch when none is given", () => {
76
+ git('checkout', '-qb', 'feat/retry-backoff');
77
+ write('src/jobs/retry.ts', 'export async function retryJob() { return 1; }\n');
78
+ git('commit', '-qam', 'work');
79
+ const briefing = buildBriefing(repo);
80
+ expect(briefing.goal).toBe('feat retry backoff');
81
+ expect(briefing.files).toEqual(['src/jobs/retry.ts']);
82
+ expect(buildBriefing(repo, { goal: 'charge the customer' }).files).toEqual(['src/jobs/retry.ts', 'services/billing/charge.ts']);
83
+ });
84
+ it("records what was briefed on the task's thread, by id", () => {
85
+ git('checkout', '-qb', 'feat/retry');
86
+ const briefing = briefTask(repo, { goal: 'retry', files: ['src/jobs/retry.ts'], session: 's1', agent: 'claude' });
87
+ const [event] = readThread(repo).events;
88
+ expect(event).toMatchObject({ kind: 'brief', session: 's1', agent: 'claude', items: briefing.items.length, ids: briefing.items.map(i => i.id), files: ['src/jobs/retry.ts'] });
89
+ });
90
+ });
91
+ describe("a file's briefing, on the agent's first edit of it", () => {
92
+ it("gives at most three items: the file's requirement rules, its lessons and settled points; never guidance or another file's", () => {
93
+ const briefing = buildFileBriefing(repo, 'src/jobs/retry.ts');
94
+ expect(briefing.items.map(i => [i.kind, i.cite])).toEqual([['rule', 'AGENTS.md'], ['lesson', 'learned in PR #12'], ['settled', 'learned in PR #14']]);
95
+ expect(briefing.items.some(i => i.text.includes('fetchWithTimeout'))).toBe(false); // guidance waits for review
96
+ expect(fileBriefingText(briefing).split('\n')[0]).toBe('Rigour, before you edit src/jobs/retry.ts: what this team asks of this file.');
97
+ expect(buildFileBriefing(repo, 'services/billing/charge.ts').items.map(i => i.cite)).toEqual(['services/billing/AGENTS.md, for services/billing/']);
98
+ expect(buildFileBriefing(repo, 'README.md').items).toEqual([]);
99
+ expect(fileBriefingText(buildFileBriefing(repo, 'README.md'))).toBe('');
100
+ expect(buildFileBriefing(repo, 'src/jobs/retry.ts', { lessons: 'all' }).items.length).toBe(3); // still three, with candidates in play
101
+ });
102
+ it('records the file it briefed on the thread', () => {
103
+ git('checkout', '-qb', 'feat/retry');
104
+ briefFile(repo, 'src/jobs/retry.ts', { session: 's9', agent: 'claude' });
105
+ expect(readThread(repo).events[0]).toMatchObject({ kind: 'brief', file: 'src/jobs/retry.ts', session: 's9', items: 3, files: ['src/jobs/retry.ts'] });
106
+ });
107
+ });
package/dist/index.d.ts CHANGED
@@ -119,3 +119,5 @@ export type { SemanticFinding } from './semantic/types.js';
119
119
  export type { ModelUsage } from './storage/index.js';
120
120
  export type { CheckpointMetric } from './storage/index.js';
121
121
  export type { CursorUsageSyncOptions } from './services/cursor-usage-client.js';
122
+ export { appendTaskEvent, readThread, taskOf, threadText, threadsDir, THREADS_DIR, type TaskEvent, type TaskEventKind, type ThreadEvent } from './task/thread.js';
123
+ export { briefFile, briefingText, briefTask, buildBriefing, buildFileBriefing, fileBriefingText, BRIEFING_MAX_ITEMS, FILE_BRIEFING_MAX_ITEMS, type Briefing, type BriefingInput, type BriefingItem } from './brief/briefing.js';
package/dist/index.js CHANGED
@@ -101,3 +101,5 @@ export { learnFromFix, learnFromFileChange } from './semantic/learn/learn.js';
101
101
  export { extractFixTrees } from './semantic/learn/git-trees.js';
102
102
  export { saveLearnedRule, loadLearnedRules, LEARNED_RULES_DIR } from './semantic/learn/store.js';
103
103
  export { recordContextEvent, recordModelUsage, recordCheckpointMetric, getContextEvents, getModelUsages, getCheckpointMetrics, } from './storage/index.js';
104
+ export { appendTaskEvent, readThread, taskOf, threadText, threadsDir, THREADS_DIR } from './task/thread.js';
105
+ export { briefFile, briefingText, briefTask, buildBriefing, buildFileBriefing, fileBriefingText, BRIEFING_MAX_ITEMS, FILE_BRIEFING_MAX_ITEMS } from './brief/briefing.js';
@@ -11,11 +11,14 @@ export interface PrRounds {
11
11
  export declare function roundsForPr(cwd: string, pr: number, config: Config, exec?: Exec, options?: {
12
12
  approvedHead?: boolean;
13
13
  }): Promise<PrRounds>;
14
- /** The last `n` merged pull requests, newest merge first. */
15
- export declare function mergedPrs(cwd: string, n: number, config: Config, exec?: Exec): Promise<Array<{
16
- number: number;
17
- mergedAt: string;
18
- }>>;
14
+ /** The last `n` merged pull requests, newest merge first; `incomplete` when the listing could not prove it has them all. */
15
+ export declare function mergedPrs(cwd: string, n: number, config: Config, exec?: Exec): Promise<{
16
+ prs: Array<{
17
+ number: number;
18
+ mergedAt: string;
19
+ }>;
20
+ incomplete: boolean;
21
+ }>;
19
22
  export declare function scaffoldLedger(cwd: string, pr: number, config: Config, exec?: Exec): Promise<{
20
23
  file: string;
21
24
  rounds: LedgerRound[];
@@ -40,23 +40,41 @@ export async function roundsForPr(cwd, pr, config, exec = defaultExec, options =
40
40
  }
41
41
  const approval = options.approvedHead ? byPerson.find((r) => r.state === 'APPROVED' && r.commit_id) : undefined;
42
42
  const approved = approval
43
- ? { id: `pr${pr}-approved`, commit: String(approval.commit_id), base: await baseAt(cwd, String(approval.commit_id), approval.submitted_at, mainRef, exec), reviewed_at: String(approval.submitted_at), pr, points: [], must_not_flag: [] }
43
+ ? { id: `pr${pr}-approved`, commit: String(approval.commit_id), base: await baseAt(cwd, String(approval.commit_id), approval.submitted_at, mainRef, exec), reviewed_at: String(approval.submitted_at), approved: true, pr, points: [], must_not_flag: [] }
44
44
  : undefined;
45
45
  if (rounds.length === 0 && !approved)
46
46
  throw new Error(`pull request ${pr} has no review by a person yet`);
47
47
  return { rounds, ...(approved ? { approved } : {}) };
48
48
  }
49
- /** The last `n` merged pull requests, newest merge first. */
49
+ /** How many merged pull requests to list per one wanted, first: gh applies its limit before any sort of ours. */
50
+ const MERGED_OVERFETCH = 4;
51
+ /** The most merged pull requests listed per one wanted before Rigour stops and says the list may be incomplete. */
52
+ const MERGED_OVERFETCH_MAX = 32;
53
+ /** The last `n` merged pull requests, newest merge first; `incomplete` when the listing could not prove it has them all. */
50
54
  export async function mergedPrs(cwd, n, config, exec = defaultExec) {
51
55
  const env = await githubEnv(cwd, config.review?.github_account ?? process.env.RIGOUR_GITHUB_ACCOUNT, exec);
52
- const list = await exec('gh', ['pr', 'list', '--state', 'merged', '--limit', String(n), '--json', 'number,mergedAt'], { cwd, timeoutMs: GH_TIMEOUT_MS, env });
53
- if (list.exitCode !== 0)
54
- throw new Error(`could not list merged pull requests: ${list.stderr.trim() || 'is gh signed in?'}`);
55
- try {
56
- return JSON.parse(list.stdout).sort((a, b) => (a.mergedAt < b.mergedAt ? 1 : -1)).slice(0, n);
57
- }
58
- catch {
59
- throw new Error('could not read the list of merged pull requests');
56
+ // Listed by last update and sorted by merge date here. A merged pull request is updated at or after its merge, so once the
57
+ // oldest update listed is no later than the Nth merge kept, nothing unlisted can be among the last N (its merge is no later
58
+ // than its update, which is no later than that). Until then the listing doubles: bots that touch old pull requests after
59
+ // merge (backports, labels, stale comments) can crowd a window.
60
+ for (let limit = n * MERGED_OVERFETCH;; limit *= 2) {
61
+ const list = await exec('gh', ['pr', 'list', '--state', 'merged', '--limit', String(limit), '--search', 'sort:updated-desc', '--json', 'number,mergedAt,updatedAt'], { cwd, timeoutMs: GH_TIMEOUT_MS, env });
62
+ if (list.exitCode !== 0)
63
+ throw new Error(`could not list merged pull requests: ${list.stderr.trim() || 'is gh signed in?'}`);
64
+ let listed;
65
+ try {
66
+ listed = JSON.parse(list.stdout);
67
+ }
68
+ catch {
69
+ throw new Error('could not read the list of merged pull requests');
70
+ }
71
+ const prs = [...listed].sort((a, b) => (a.mergedAt < b.mergedAt ? 1 : -1)).slice(0, n).map(({ number, mergedAt }) => ({ number, mergedAt }));
72
+ const oldestUpdate = listed.reduce((min, p) => (p.updatedAt < min ? p.updatedAt : min), listed[0]?.updatedAt ?? '');
73
+ const complete = listed.length < limit || (prs.length === n && oldestUpdate <= prs[n - 1].mergedAt);
74
+ if (complete)
75
+ return { prs, incomplete: false };
76
+ if (limit >= n * MERGED_OVERFETCH_MAX)
77
+ return { prs, incomplete: true };
60
78
  }
61
79
  }
62
80
  /** Lines either side of an inline comment that a finding for the same point may land on. */
@@ -5,7 +5,7 @@ import path from 'path';
5
5
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6
6
  import { ConfigSchema } from '../types/index.js';
7
7
  import { ledgerProblems, loadLedger, LEDGER_PATH, LedgerSchema } from './backtest.js';
8
- import { scaffoldLedger } from './backtest-init.js';
8
+ import { mergedPrs, scaffoldLedger } from './backtest-init.js';
9
9
  let repo;
10
10
  const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' }).trim();
11
11
  const config = ConfigSchema.parse({ version: 1 });
@@ -96,3 +96,41 @@ describe('rigour backtest init', () => {
96
96
  expect(written.rounds.map(r => [r.id, r.commit])).toEqual([['pr7-r1', 'other00'], ['pr212-r1', 'c2c2c2c2c2']]);
97
97
  });
98
98
  });
99
+ describe('the last N merged pull requests', () => {
100
+ const run = async (pages) => {
101
+ const limits = [];
102
+ const exec = async (command, args, options) => {
103
+ if (command === 'gh' && args[0] === 'auth')
104
+ return { exitCode: 0, stdout: 'token\n', stderr: '' };
105
+ if (command === 'gh' && args[0] === 'pr') {
106
+ expect(args).toEqual(expect.arrayContaining(['--search', 'sort:updated-desc', '--json', 'number,mergedAt,updatedAt']));
107
+ const limit = Number(args[args.indexOf('--limit') + 1]);
108
+ limits.push(limit);
109
+ return { exitCode: 0, stdout: JSON.stringify(pages(limit).slice(0, limit)), stderr: '' };
110
+ }
111
+ return fakeExec([])(command, args, options);
112
+ };
113
+ return { ...(await mergedPrs(repo, 2, config, exec)), limits };
114
+ };
115
+ const pr = (number, mergedAt, updatedAt = mergedAt) => ({ number, mergedAt: `2026-04-${mergedAt}T00:00:00Z`, updatedAt: `2026-04-${updatedAt}T00:00:00Z` });
116
+ it('keeps the N most recently merged: a long-lived pull request merged last is in, one merged first is out', async () => {
117
+ const { prs, incomplete, limits } = await run(() => [pr(3, '12'), pr(6, '10'), pr(5, '05'), pr(7, '01')]);
118
+ expect(prs.map(p => p.number)).toEqual([3, 6]);
119
+ expect(incomplete).toBe(false);
120
+ expect(limits).toEqual([8]); // fewer listed than asked for: that is everything
121
+ });
122
+ it('lists more when old pull requests touched after merge crowd the window, until the result is provably complete', async () => {
123
+ // Eight old pull requests a bot touched on the 20th, then the two real latest merges.
124
+ const all = [...Array.from({ length: 8 }, (_, i) => pr(100 + i, '02', '20')), pr(3, '12'), pr(6, '10'), pr(5, '05')];
125
+ const { prs, incomplete, limits } = await run(() => all);
126
+ expect(limits).toEqual([8, 16]); // the first page held only crowding bots' touches: its oldest update (the 20th) is after the merges kept
127
+ expect(prs.map(p => p.number)).toEqual([3, 6]);
128
+ expect(incomplete).toBe(false);
129
+ });
130
+ it('stops at the cap and says the result may be incomplete', async () => {
131
+ const crowd = Array.from({ length: 200 }, (_, i) => pr(1000 + i, '01', '28'));
132
+ const { incomplete, limits } = await run(() => crowd);
133
+ expect(limits).toEqual([8, 16, 32, 64]);
134
+ expect(incomplete).toBe(true);
135
+ });
136
+ });
@@ -17,7 +17,9 @@ const GIT_TIMEOUT_MS = 5 * 60_000;
17
17
  export async function backtestLast(cwd, config, options) {
18
18
  const exec = options.exec ?? defaultExec;
19
19
  const progress = options.progress ?? (() => undefined);
20
- const prs = await mergedPrs(cwd, options.last, config, exec);
20
+ const { prs, incomplete } = await mergedPrs(cwd, options.last, config, exec);
21
+ if (incomplete)
22
+ progress(`backtest: warning: the listing could not prove these are the last ${options.last} merged pull requests (many old pull requests were updated after merge); the result may miss recent ones`);
21
23
  const rounds = [];
22
24
  const skipped = [];
23
25
  for (const pr of prs) {
@@ -55,6 +55,8 @@ declare const Round: z.ZodObject<{
55
55
  base: z.ZodString;
56
56
  /** When the human review was posted: the reviewer sees nothing from then on. */
57
57
  reviewed_at: z.ZodOptional<z.ZodString>;
58
+ /** The head a person approved: `reviewed_at` is the approval, and the reviewer sees it (it closes the points before it). */
59
+ approved: z.ZodOptional<z.ZodBoolean>;
58
60
  pr: z.ZodOptional<z.ZodNumber>;
59
61
  points: z.ZodArray<z.ZodObject<{
60
62
  /** A regular expression over the finding's file path. */
@@ -123,6 +125,7 @@ declare const Round: z.ZodObject<{
123
125
  }[];
124
126
  pr?: number | undefined;
125
127
  reviewed_at?: string | undefined;
128
+ approved?: boolean | undefined;
126
129
  }, {
127
130
  id: string;
128
131
  commit: string;
@@ -137,6 +140,7 @@ declare const Round: z.ZodObject<{
137
140
  }[];
138
141
  pr?: number | undefined;
139
142
  reviewed_at?: string | undefined;
143
+ approved?: boolean | undefined;
140
144
  must_not_flag?: {
141
145
  file: string;
142
146
  text?: string | undefined;
@@ -151,6 +155,8 @@ export declare const LedgerSchema: z.ZodObject<{
151
155
  base: z.ZodString;
152
156
  /** When the human review was posted: the reviewer sees nothing from then on. */
153
157
  reviewed_at: z.ZodOptional<z.ZodString>;
158
+ /** The head a person approved: `reviewed_at` is the approval, and the reviewer sees it (it closes the points before it). */
159
+ approved: z.ZodOptional<z.ZodBoolean>;
154
160
  pr: z.ZodOptional<z.ZodNumber>;
155
161
  points: z.ZodArray<z.ZodObject<{
156
162
  /** A regular expression over the finding's file path. */
@@ -219,6 +225,7 @@ export declare const LedgerSchema: z.ZodObject<{
219
225
  }[];
220
226
  pr?: number | undefined;
221
227
  reviewed_at?: string | undefined;
228
+ approved?: boolean | undefined;
222
229
  }, {
223
230
  id: string;
224
231
  commit: string;
@@ -233,6 +240,7 @@ export declare const LedgerSchema: z.ZodObject<{
233
240
  }[];
234
241
  pr?: number | undefined;
235
242
  reviewed_at?: string | undefined;
243
+ approved?: boolean | undefined;
236
244
  must_not_flag?: {
237
245
  file: string;
238
246
  text?: string | undefined;
@@ -261,6 +269,7 @@ export declare const LedgerSchema: z.ZodObject<{
261
269
  }[];
262
270
  pr?: number | undefined;
263
271
  reviewed_at?: string | undefined;
272
+ approved?: boolean | undefined;
264
273
  }[];
265
274
  }, {
266
275
  rounds: {
@@ -277,6 +286,7 @@ export declare const LedgerSchema: z.ZodObject<{
277
286
  }[];
278
287
  pr?: number | undefined;
279
288
  reviewed_at?: string | undefined;
289
+ approved?: boolean | undefined;
280
290
  must_not_flag?: {
281
291
  file: string;
282
292
  text?: string | undefined;
@@ -35,6 +35,8 @@ const Round = z.object({
35
35
  base: z.string().min(1),
36
36
  /** When the human review was posted: the reviewer sees nothing from then on. */
37
37
  reviewed_at: z.string().optional(),
38
+ /** The head a person approved: `reviewed_at` is the approval, and the reviewer sees it (it closes the points before it). */
39
+ approved: z.boolean().optional(),
38
40
  pr: z.number().int().positive().optional(),
39
41
  points: z.array(Point),
40
42
  must_not_flag: z.array(Match).default([]),
@@ -91,7 +93,7 @@ export async function runBacktest(cwd, config, ledger, options = {}) {
91
93
  const started = Date.now();
92
94
  const worktree = await worktreeFor(cwd, round.commit, exec);
93
95
  const head = (await exec('git', ['rev-parse', 'HEAD'], { cwd: worktree, timeoutMs: GIT_TIMEOUT_MS })).stdout.trim();
94
- progress(`backtest ${round.id}: ${head.slice(0, 9)} against ${round.base}${round.reviewed_at ? `, reviews hidden from ${round.reviewed_at}` : ''}`);
96
+ progress(`backtest ${round.id}: ${head.slice(0, 9)} against ${round.base}${round.reviewed_at ? `, reviews hidden from ${reviewsHiddenFrom(round)}` : ''}`);
95
97
  const stale = await staleBase(worktree, head, round.base, exec);
96
98
  if (stale)
97
99
  progress(`backtest ${round.id}: warning: ${stale}`);
@@ -192,7 +194,7 @@ async function collectItems(worktree, round, config, reviewer, exec, progress) {
192
194
  ];
193
195
  if (!reviewer)
194
196
  return { items };
195
- const result = await runReviewer(worktree, round.base, config, exec, progress, { pr: round.pr, reviewsBefore: round.reviewed_at, blind: round.pr === undefined, trigger: 'backtest', force: true, ...reviewerInputs(review) });
197
+ const result = await runReviewer(worktree, round.base, config, exec, progress, { pr: round.pr, reviewsBefore: reviewsHiddenFrom(round), blind: round.pr === undefined, trigger: 'backtest', force: true, ...reviewerInputs(review) });
196
198
  if (result.outcome === 'unavailable' || result.outcome === 'skipped')
197
199
  return { items, reviewerError: result.reason ?? result.outcome };
198
200
  // The reviewer behind each catch is part of the score, so the gate is `reviewer:<name>`.
@@ -237,3 +239,10 @@ function byGate(result) {
237
239
  counts.set(p.by.split(' ')[0], (counts.get(p.by.split(' ')[0]) ?? 0) + 1);
238
240
  return counts.size ? `; caught by ${[...counts].map(([gate, n]) => `${gate} ${n}`).join(', ')}` : '';
239
241
  }
242
+ /** The moment the reviewer sees nothing from: the review itself for a round, and just after the approval for an approved head. */
243
+ function reviewsHiddenFrom(round) {
244
+ if (!round.reviewed_at || !round.approved)
245
+ return round.reviewed_at;
246
+ const at = Date.parse(round.reviewed_at);
247
+ return Number.isNaN(at) ? round.reviewed_at : new Date(at + 1000).toISOString();
248
+ }
@@ -92,6 +92,18 @@ describe('rigour backtest on a repository', () => {
92
92
  catch { /* the repository may be gone */ }
93
93
  fs.rmSync(repo, { recursive: true, force: true });
94
94
  });
95
+ it('hides the review itself for a round, and shows the approval for an approved head', async () => {
96
+ const commit = git('rev-parse', 'HEAD');
97
+ const base = git('rev-parse', 'main');
98
+ const lines = [];
99
+ const ledger = { rounds: [
100
+ { id: 'r1', commit, base, reviewed_at: '2026-09-28T15:12:53Z', points: [], must_not_flag: [] },
101
+ { id: 'pr1-approved', commit, base, reviewed_at: '2026-09-28T15:12:53Z', approved: true, points: [], must_not_flag: [] },
102
+ { id: 'odd', commit, base, reviewed_at: 'not a date', approved: true, points: [], must_not_flag: [] },
103
+ ] };
104
+ await runBacktest(repo, ConfigSchema.parse({ version: 1 }), ledger, { progress: line => lines.push(line), collect: async () => ({ items: [] }) });
105
+ expect(lines.filter(l => l.includes('reviews hidden from')).map(l => l.replace(/^.*reviews hidden from /, ''))).toEqual(['2026-09-28T15:12:53Z', '2026-09-28T15:12:54.000Z', 'not a date']);
106
+ }, 60_000);
95
107
  it('reviews the reviewed commit in a worktree with the review hidden, scores it, and leaves the checkout alone', async () => {
96
108
  const reviewed = git('rev-parse', 'HEAD');
97
109
  const base = git('rev-parse', 'main');
@@ -13,6 +13,10 @@ export interface Adapter {
13
13
  answer(stdout: string): {
14
14
  text: string;
15
15
  } & Spend;
16
+ /** Variables the judge runs with, on top of what it inherits: what keeps a person's own instructions out of it. */
17
+ env?: Record<string, string>;
18
+ /** What this judge still reads from outside the repository on this machine (a person's own config), or undefined: said on the record, never hidden. */
19
+ outsideRepo?(home: string, version: string | undefined): string | undefined;
16
20
  }
17
21
  /** What a run used: dollars when the CLI reports them (Claude Code), tokens otherwise (Codex reports only tokens). */
18
22
  export interface Spend {