@rigour-labs/core 6.7.1 → 6.7.4

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 (85) hide show
  1. package/README.md +1 -1
  2. package/dist/hooks/index.d.ts +3 -3
  3. package/dist/hooks/index.js +2 -2
  4. package/dist/hooks/session-state.d.ts +3 -0
  5. package/dist/hooks/session-state.js +10 -0
  6. package/dist/hooks/stop-review.d.ts +8 -15
  7. package/dist/hooks/stop-review.js +36 -2
  8. package/dist/index.d.ts +4 -2
  9. package/dist/index.js +4 -2
  10. package/dist/review/backtest-init.js +13 -11
  11. package/dist/review/backtest-init.test.js +26 -2
  12. package/dist/review/backtest.js +35 -3
  13. package/dist/review/backtest.test.js +44 -2
  14. package/dist/review/dead-code.test.js +13 -0
  15. package/dist/review/orphan-files.js +3 -2
  16. package/dist/review/quiet.d.ts +1 -1
  17. package/dist/review/quiet.js +3 -1
  18. package/dist/review/review-task.d.ts +2 -6
  19. package/dist/review/review-task.js +2 -2
  20. package/dist/review/review.js +19 -4
  21. package/dist/review/review.test.js +11 -0
  22. package/dist/review/reviewer/adapters.d.ts +21 -0
  23. package/dist/review/reviewer/adapters.js +70 -15
  24. package/dist/review/reviewer/adapters.test.js +22 -0
  25. package/dist/review/reviewer/background.test.js +1 -1
  26. package/dist/review/reviewer/context.d.ts +3 -0
  27. package/dist/review/reviewer/context.js +14 -5
  28. package/dist/review/reviewer/context.test.js +26 -1
  29. package/dist/review/reviewer/exec.d.ts +9 -0
  30. package/dist/review/reviewer/exec.js +28 -1
  31. package/dist/review/reviewer/exec.test.js +38 -2
  32. package/dist/review/reviewer/inputs.d.ts +6 -0
  33. package/dist/review/reviewer/inputs.js +26 -0
  34. package/dist/review/reviewer/judge-env.d.ts +3 -0
  35. package/dist/review/reviewer/judge-env.js +10 -0
  36. package/dist/review/reviewer/prompt.js +68 -12
  37. package/dist/review/reviewer/rule-writer.d.ts +10 -0
  38. package/dist/review/reviewer/rule-writer.js +30 -0
  39. package/dist/review/reviewer/rule-writer.test.d.ts +1 -0
  40. package/dist/review/reviewer/rule-writer.test.js +52 -0
  41. package/dist/review/reviewer/settings.d.ts +4 -0
  42. package/dist/review/reviewer/settings.js +2 -1
  43. package/dist/review/reviewer/verdict.d.ts +71 -9
  44. package/dist/review/reviewer/verdict.js +102 -10
  45. package/dist/review/reviewer.d.ts +5 -2
  46. package/dist/review/reviewer.js +56 -41
  47. package/dist/review/reviewer.test.js +170 -17
  48. package/dist/review/typed/constant-inputs.d.ts +35 -0
  49. package/dist/review/typed/constant-inputs.js +168 -0
  50. package/dist/review/typed/dead-null-guards.d.ts +34 -0
  51. package/dist/review/typed/dead-null-guards.js +147 -0
  52. package/dist/review/typed/redundancy.js +6 -0
  53. package/dist/review/typed/redundancy.test.js +136 -0
  54. package/dist/review/unused-exports.js +3 -2
  55. package/dist/review-learning/acted-on.d.ts +23 -3
  56. package/dist/review-learning/acted-on.js +25 -13
  57. package/dist/review-learning/human-edits.d.ts +7 -0
  58. package/dist/review-learning/human-edits.js +99 -0
  59. package/dist/review-learning/human-edits.test.d.ts +1 -0
  60. package/dist/review-learning/human-edits.test.js +47 -0
  61. package/dist/review-learning/learn-from-reviews.d.ts +37 -4
  62. package/dist/review-learning/learn-from-reviews.js +128 -12
  63. package/dist/review-learning/lessons.d.ts +62 -9
  64. package/dist/review-learning/lessons.js +122 -20
  65. package/dist/review-learning/outcomes.d.ts +26 -0
  66. package/dist/review-learning/outcomes.js +73 -0
  67. package/dist/review-learning/review-learning.test.js +149 -22
  68. package/dist/review-learning/review-points.d.ts +4 -0
  69. package/dist/review-learning/review-points.js +14 -0
  70. package/dist/review-learning/rules-from-reviews.d.ts +14 -0
  71. package/dist/review-learning/rules-from-reviews.js +86 -0
  72. package/dist/review-learning/team-lessons.d.ts +17 -1
  73. package/dist/review-learning/team-lessons.js +29 -5
  74. package/dist/services/temporal-drift.js +1 -1
  75. package/dist/storage/db.d.ts +4 -6
  76. package/dist/storage/db.js +63 -90
  77. package/dist/storage/db.test.d.ts +1 -0
  78. package/dist/storage/db.test.js +46 -0
  79. package/dist/templates/universal-config.js +3 -0
  80. package/dist/types/index.d.ts +63 -0
  81. package/dist/types/index.js +11 -0
  82. package/dist/utils/profile.d.ts +5 -1
  83. package/dist/utils/profile.js +7 -1
  84. package/dist/utils/profile.test.js +10 -0
  85. package/package.json +8 -9
@@ -0,0 +1,7 @@
1
+ /** Each file as the agent just left it (repository-relative paths). */
2
+ export declare function recordAgentWrites(cwd: string, files: string[]): void;
3
+ /**
4
+ * The files a person changed since the agent last wrote them, stored as candidate lessons with correction
5
+ * evidence (the rule writer turns each into the rule behind it, or finds none). Returns how many were taken.
6
+ */
7
+ export declare function captureHumanEdits(cwd: string, by?: string): number;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * A person editing what an agent wrote is the strongest lesson there is: they corrected the work
3
+ * toward what the team wants. The after-edit hook records each file as the agent left it
4
+ * (recordAgentWrites); at the stop and the push, a recorded file that now reads differently was
5
+ * changed by someone else (captureHumanEdits), and that change becomes a candidate lesson with
6
+ * correction evidence. A change that came from git (a checkout, a pull) or only moved whitespace
7
+ * is not a correction. Each edit is taken once. Everything stays in the repository's .rigour/.
8
+ */
9
+ import crypto from 'crypto';
10
+ import { execFileSync } from 'child_process';
11
+ import fs from 'fs';
12
+ import path from 'path';
13
+ import { compactDiff } from '../review/stories.js';
14
+ import { mergeLessons, readLessons, writeLessons } from './lessons.js';
15
+ const DIR = path.join('.rigour', 'agent-writes');
16
+ const MAX_FILE_BYTES = 200_000;
17
+ /** Lines of the change kept as the lesson's words. */
18
+ const MAX_DIFF_LINES = 12;
19
+ const recordPath = (cwd, file) => path.join(cwd, DIR, `${crypto.createHash('sha256').update(file).digest('hex').slice(0, 16)}.json`);
20
+ const squash = (text) => text.replace(/\s+/g, '');
21
+ function headOf(cwd) {
22
+ try {
23
+ return execFileSync('git', ['rev-parse', 'HEAD'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
24
+ }
25
+ catch {
26
+ return undefined;
27
+ }
28
+ }
29
+ function atHead(cwd, file) {
30
+ try {
31
+ return execFileSync('git', ['show', `HEAD:${file}`], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 4 * MAX_FILE_BYTES });
32
+ }
33
+ catch {
34
+ return undefined;
35
+ }
36
+ }
37
+ /** Each file as the agent just left it (repository-relative paths). */
38
+ export function recordAgentWrites(cwd, files) {
39
+ const head = headOf(cwd);
40
+ for (const file of files) {
41
+ try {
42
+ const content = fs.readFileSync(path.join(cwd, file), 'utf8');
43
+ if (content.length > MAX_FILE_BYTES)
44
+ continue;
45
+ fs.mkdirSync(path.join(cwd, DIR), { recursive: true });
46
+ fs.writeFileSync(recordPath(cwd, file), JSON.stringify({ file, content, ...(head ? { head } : {}), at: new Date().toISOString() }));
47
+ }
48
+ catch {
49
+ // a file the agent deleted or cannot be read: nothing to compare later
50
+ }
51
+ }
52
+ }
53
+ /**
54
+ * The files a person changed since the agent last wrote them, stored as candidate lessons with correction
55
+ * evidence (the rule writer turns each into the rule behind it, or finds none). Returns how many were taken.
56
+ */
57
+ export function captureHumanEdits(cwd, by = 'a person') {
58
+ let entries = [];
59
+ try {
60
+ entries = fs.readdirSync(path.join(cwd, DIR));
61
+ }
62
+ catch {
63
+ return 0;
64
+ }
65
+ const at = new Date().toISOString();
66
+ const lessons = [];
67
+ const head = headOf(cwd);
68
+ for (const entry of entries) {
69
+ const record = path.join(cwd, DIR, entry);
70
+ let write;
71
+ let now;
72
+ try {
73
+ write = JSON.parse(fs.readFileSync(record, 'utf8'));
74
+ now = fs.readFileSync(path.join(cwd, write.file), 'utf8');
75
+ }
76
+ catch {
77
+ fs.rmSync(record, { force: true });
78
+ continue;
79
+ }
80
+ if (now === write.content)
81
+ continue;
82
+ fs.rmSync(record, { force: true }); // taken once, whatever it was
83
+ if (squash(now) === squash(write.content))
84
+ continue; // whitespace only
85
+ if (head && head !== write.head && atHead(cwd, write.file) === now)
86
+ continue; // came from git: a checkout, a pull, a merge
87
+ const diff = compactDiff(write.content, now).slice(0, MAX_DIFF_LINES).join('\n');
88
+ const text = `In ${write.file}, a person changed what the agent wrote:\n${diff}`;
89
+ lessons.push({
90
+ id: crypto.createHash('sha256').update(`${write.file}\u0000${diff}`).digest('hex').slice(0, 12),
91
+ text, file: write.file, symbols: [], state: 'candidate',
92
+ evidence: [{ kind: 'correction', pr: 0, comment: `edit-${crypto.createHash('sha256').update(`${write.file}${write.at}`).digest('hex').slice(0, 12)}`, author: by, text, detail: `edited after the agent's write of ${write.at}`, at }],
93
+ createdAt: at, updatedAt: at,
94
+ });
95
+ }
96
+ if (lessons.length)
97
+ writeLessons(cwd, mergeLessons(readLessons(cwd), lessons).lessons);
98
+ return lessons.length;
99
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,47 @@
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 { captureHumanEdits, recordAgentWrites } from './human-edits.js';
7
+ import { readLessons } from './lessons.js';
8
+ let repo;
9
+ const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' }).trim();
10
+ const write = (body) => fs.writeFileSync(path.join(repo, 'src/job.ts'), body);
11
+ beforeEach(() => {
12
+ repo = fs.mkdtempSync(path.join(os.tmpdir(), 'human-edits-'));
13
+ git('init', '-q', '-b', 'main');
14
+ git('config', 'user.email', 't@example.com');
15
+ git('config', 'user.name', 't');
16
+ git('config', 'commit.gpgsign', 'false');
17
+ fs.mkdirSync(path.join(repo, 'src'));
18
+ write('export function job(rows) {\n return rows.map(r => r.id);\n}\n');
19
+ git('add', '-A');
20
+ git('commit', '-qm', 'base');
21
+ });
22
+ afterEach(() => { fs.rmSync(repo, { recursive: true, force: true }); });
23
+ describe('a person editing what the agent wrote', () => {
24
+ it('becomes a lesson with correction evidence, taken once', () => {
25
+ write('export function job(rows) {\n return rows.slice(0, 10000).map(r => r.id);\n}\n'); // the agent's write
26
+ recordAgentWrites(repo, ['src/job.ts']);
27
+ write('export function job(rows, after) {\n return rows.filter(r => r.id > after).slice(0, 500).map(r => r.id);\n}\n'); // the person's fix
28
+ expect(captureHumanEdits(repo, 'lead@acme')).toBe(1);
29
+ const [lesson] = readLessons(repo);
30
+ expect(lesson).toMatchObject({ file: 'src/job.ts', state: 'verified', promotedBy: 'correction' });
31
+ expect(lesson.text).toContain('a person changed what the agent wrote');
32
+ expect(lesson.text).toContain('+ return rows.filter(r => r.id > after).slice(0, 500).map(r => r.id);');
33
+ expect(lesson.evidence[0]).toMatchObject({ kind: 'correction', author: 'lead@acme' });
34
+ expect(captureHumanEdits(repo, 'lead@acme')).toBe(0); // taken once
35
+ });
36
+ it('is not a correction when only whitespace moved, or the change came from git', () => {
37
+ recordAgentWrites(repo, ['src/job.ts']);
38
+ write('export function job(rows) {\n return rows.map(r => r.id);\n}\n'); // a formatter
39
+ expect(captureHumanEdits(repo)).toBe(0);
40
+ recordAgentWrites(repo, ['src/job.ts']);
41
+ git('checkout', '-q', '-b', 'other');
42
+ write('export const job = (rows) => rows.map(r => r.id);\n');
43
+ git('commit', '-qam', 'someone else\'s branch');
44
+ expect(captureHumanEdits(repo)).toBe(0); // a checkout brought it, not a person correcting the agent
45
+ expect(readLessons(repo)).toEqual([]);
46
+ });
47
+ });
@@ -1,9 +1,17 @@
1
1
  /**
2
- * `rigour learn --from-reviews`: turn a repository's acted-on review comments
3
- * into review lessons. Reads GitHub (merged PRs and their review comments) and
4
- * the local clone; writes only .rigour/review-lessons.json.
2
+ * `rigour learn-reviews`: turn a repository's review points into lessons, on evidence. Reads GitHub
3
+ * (merged PRs, their review comments and review bodies) and the local clone; writes only
4
+ * .rigour/review-lessons.json (and, with rules, the audit log beside it).
5
+ *
6
+ * Every point is a candidate, whoever wrote it: a person, an AI posting under a person's login, a
7
+ * review bot. Who wrote it, and whether the pull request changed those lines, are recorded and never
8
+ * decide anything: agents apply review comments on their own, and people paste AI text. Evidence
9
+ * decides (lessons.ts lessonState): what later happened to the lines (outcomes.ts), a person's
10
+ * decision, or the same point recurring across pull requests by different authors. The pull
11
+ * request's author commenting on their own pull request is not a review point.
5
12
  */
6
13
  import { type Git } from './acted-on.js';
14
+ import { type RuleWriter } from './rules-from-reviews.js';
7
15
  type Fetch = (url: string, init?: any) => Promise<{
8
16
  ok: boolean;
9
17
  status: number;
@@ -15,9 +23,20 @@ export interface LearnFromReviewsOptions {
15
23
  repo: string;
16
24
  /** Only PRs merged on or after this ISO date. */
17
25
  since?: string;
18
- /** Only PRs merged before this ISO date (a time split for measuring). */
26
+ /** Only PRs merged before this ISO date (a time split for measuring); with `pr`, only reviews posted before it. */
19
27
  until?: string;
28
+ /**
29
+ * Learn from this one pull request, open or merged: its reviews up to `until`, acted on when files
30
+ * changed after a review by its head as of `until`. A long-running pull request teaches as it goes.
31
+ */
32
+ pr?: number;
20
33
  limit?: number;
34
+ /** The main branch, for outcome evidence (what later happened to the lines); none is gathered without it. */
35
+ mainRef?: string;
36
+ /** How long a point's unchanged lines must ship before that counts against it. */
37
+ windowDays?: number;
38
+ /** Rewrite each newly promoted lesson as the rule behind it, or drop it as no rule (rules-from-reviews.ts); off: the words as they are. */
39
+ writeRules?: RuleWriter;
21
40
  apiUrl?: string;
22
41
  fetch?: Fetch;
23
42
  git?: Git;
@@ -26,6 +45,20 @@ export interface LearnFromReviewsResult {
26
45
  prs: number;
27
46
  comments: number;
28
47
  actedOn: number;
48
+ /** Review bodies read (each point in one is a candidate). */
49
+ reviewBodies: number;
50
+ /** Candidate points read this run, by who wrote them. */
51
+ candidates: {
52
+ person: number;
53
+ bot: number;
54
+ };
55
+ /** Lessons now promoted, by the evidence that promoted them; anti-lessons; candidates held back by counter-evidence. */
56
+ promoted: Record<'outcome' | 'correction' | 'person' | 'recurrence' | 'legacy', number>;
57
+ rejected: number;
58
+ heldBack: number;
59
+ /** With writeRules: points rewritten as rules, and points the model called no rule (dropped). */
60
+ rules?: number;
61
+ notRules?: number;
29
62
  added: number;
30
63
  verified: number;
31
64
  total: number;
@@ -1,28 +1,89 @@
1
1
  /**
2
- * `rigour learn --from-reviews`: turn a repository's acted-on review comments
3
- * into review lessons. Reads GitHub (merged PRs and their review comments) and
4
- * the local clone; writes only .rigour/review-lessons.json.
2
+ * `rigour learn-reviews`: turn a repository's review points into lessons, on evidence. Reads GitHub
3
+ * (merged PRs, their review comments and review bodies) and the local clone; writes only
4
+ * .rigour/review-lessons.json (and, with rules, the audit log beside it).
5
+ *
6
+ * Every point is a candidate, whoever wrote it: a person, an AI posting under a person's login, a
7
+ * review bot. Who wrote it, and whether the pull request changed those lines, are recorded and never
8
+ * decide anything: agents apply review comments on their own, and people paste AI text. Evidence
9
+ * decides (lessons.ts lessonState): what later happened to the lines (outcomes.ts), a person's
10
+ * decision, or the same point recurring across pull requests by different authors. The pull
11
+ * request's author commenting on their own pull request is not a review point.
5
12
  */
6
- import { actedOn, gitIn } from './acted-on.js';
7
- import { lessonFromComment, mergeLessons, readLessons, writeLessons } from './lessons.js';
13
+ import { changedSince, gitIn, withActedOn } from './acted-on.js';
14
+ import { outcomeFor, revertOf } from './outcomes.js';
15
+ import fs from 'fs';
16
+ import path from 'path';
17
+ import { lessonFromComment, lessonsFromReview, lessonsPath, lessonState, mergeLessons, readLessons, writeLessons } from './lessons.js';
18
+ import { rulesFromReviews } from './rules-from-reviews.js';
8
19
  export async function learnFromReviews(cwd, options) {
9
20
  const git = options.git ?? gitIn(cwd);
10
21
  const prs = await mergedPrs(options);
11
22
  const lessons = [];
12
23
  let comments = 0;
13
24
  let acted = 0;
25
+ let bodies = 0;
14
26
  for (const pr of prs) {
15
27
  comments += pr.comments.length;
16
- for (const comment of actedOn(git, pr)) {
17
- acted++;
28
+ for (const comment of withActedOn(git, pr)) {
29
+ if (comment.actedOn)
30
+ acted++;
18
31
  const lesson = lessonFromComment(git, comment);
19
32
  if (lesson)
20
33
  lessons.push(lesson);
21
34
  }
35
+ for (const review of pr.reviews) {
36
+ bodies++;
37
+ lessons.push(...lessonsFromReview(review, changedSince(git, review.commit, pr.mergeSha)));
38
+ }
39
+ }
40
+ const known = new Set(readLessons(cwd).flatMap(l => l.evidence.map(e => e.comment)));
41
+ const points = lessons.flatMap(l => l.evidence).filter(e => e.kind === 'point' && !known.has(e.comment));
42
+ const fresh = lessons.filter(l => !l.evidence.every(e => known.has(e.comment)));
43
+ const merged = mergeLessons(readLessons(cwd), fresh);
44
+ // Outcomes accrue after the merge: every candidate whose pull request was read this run is checked again.
45
+ if (options.mainRef) {
46
+ const byNumber = new Map(prs.filter(pr => pr.mergedAt).map(pr => [pr.number, pr]));
47
+ const reverts = new Map([...byNumber.values()].map(pr => [pr.number, revertOf(git, pr, { mainRef: options.mainRef, until: options.until })]));
48
+ for (const lesson of merged.lessons) {
49
+ if (lesson.state !== 'candidate' || lesson.evidence.some(e => e.kind === 'outcome' || e.kind === 'counter'))
50
+ continue;
51
+ for (const point of lesson.evidence.filter(e => (e.kind ?? 'point') === 'point')) {
52
+ const pr = byNumber.get(point.pr);
53
+ if (!pr)
54
+ continue;
55
+ const found = (point.actedOn === false ? reverts.get(pr.number) : undefined) ?? outcomeFor(git, lesson, pr, { mainRef: options.mainRef, until: options.until, windowDays: options.windowDays });
56
+ if (found) {
57
+ lesson.evidence.push(found);
58
+ break;
59
+ }
60
+ }
61
+ }
62
+ for (const lesson of merged.lessons)
63
+ Object.assign(lesson, lessonState(lesson));
64
+ }
65
+ // Rules are written for what evidence promoted, once: the model is paid only for lessons that will be served.
66
+ let written;
67
+ const unwritten = merged.lessons.filter(l => l.state === 'verified' && !l.evidence.some(e => e.said));
68
+ if (options.writeRules && unwritten.length) {
69
+ written = await rulesFromReviews(unwritten, options.writeRules);
70
+ auditRules(cwd, unwritten, written.lessons);
71
+ const replaced = new Set(unwritten.map(l => l.id));
72
+ merged.lessons = [...merged.lessons.filter(l => !replaced.has(l.id)), ...written.lessons.map(l => ({ ...l, ...lessonState(l) }))];
22
73
  }
23
- const merged = mergeLessons(readLessons(cwd), lessons);
24
74
  writeLessons(cwd, merged.lessons);
25
- return { prs: prs.length, comments, actedOn: acted, added: merged.added, verified: merged.verified, total: merged.lessons.length };
75
+ const promoted = { outcome: 0, correction: 0, person: 0, recurrence: 0, legacy: 0 };
76
+ for (const l of merged.lessons)
77
+ if (l.state === 'verified' && l.promotedBy)
78
+ promoted[l.promotedBy]++;
79
+ return {
80
+ prs: prs.length, comments, actedOn: acted, reviewBodies: bodies,
81
+ candidates: { person: points.filter(e => e.source !== 'bot').length, bot: points.filter(e => e.source === 'bot').length },
82
+ promoted, rejected: merged.lessons.filter(l => l.state === 'rejected').length,
83
+ heldBack: merged.lessons.filter(l => l.state === 'candidate' && l.evidence.some(e => e.kind === 'counter')).length,
84
+ ...(written ? { rules: written.rules, notRules: written.dropped } : {}),
85
+ added: merged.added, verified: merged.lessons.filter(l => l.state === 'verified').length, total: merged.lessons.length,
86
+ };
26
87
  }
27
88
  async function mergedPrs(options) {
28
89
  const fetchImpl = options.fetch ?? fetch;
@@ -33,6 +94,8 @@ async function mergedPrs(options) {
33
94
  throw new Error(`GitHub ${url.replace(base, '')}: HTTP ${response.status}`);
34
95
  return response.json();
35
96
  };
97
+ if (options.pr !== undefined)
98
+ return [await onePr(options, base, get)];
36
99
  const limit = options.limit ?? 100;
37
100
  const prs = [];
38
101
  for (let page = 1; prs.length < limit && page <= 20; page++) {
@@ -46,20 +109,73 @@ async function mergedPrs(options) {
46
109
  continue;
47
110
  if (options.until && pr.merged_at >= options.until)
48
111
  continue;
112
+ const author = String(pr.user?.login ?? '');
113
+ const reviewer = (user) => !!user?.login && user.login !== author;
49
114
  const raw = await get(`${base}/pulls/${pr.number}/comments?per_page=100`);
50
- prs.push({ number: pr.number, mergeSha: pr.merge_commit_sha, mergedAt: pr.merged_at, comments: raw.flatMap(c => toComment(pr.number, c)) });
115
+ const reviews = await get(`${base}/pulls/${pr.number}/reviews?per_page=100`);
116
+ prs.push({
117
+ number: pr.number, mergeSha: pr.merge_commit_sha, mergedAt: pr.merged_at,
118
+ comments: raw.filter(c => reviewer(c.user)).flatMap(c => toComment(pr.number, c, author)),
119
+ reviews: reviews.filter(r => reviewer(r.user) && r.commit_id && String(r.body ?? '').trim()).map((r) => ({ id: String(r.id), prNumber: pr.number, commit: r.commit_id, body: String(r.body), author: String(r.user.login), source: sourceOf(r.user), prAuthor: author })),
120
+ });
51
121
  if (prs.length >= limit)
52
122
  break;
53
123
  }
54
124
  }
55
125
  return prs;
56
126
  }
127
+ /** One pull request as of `until`: its head then, and the reviews by people posted before it. */
128
+ async function onePr(options, base, get) {
129
+ const pr = await get(`${base}/pulls/${options.pr}`);
130
+ const before = (at) => !options.until || (typeof at === 'string' && at < options.until);
131
+ // Every page: a long-running pull request has hundreds of commits and comments.
132
+ const all = async (url) => {
133
+ const items = [];
134
+ for (let page = 1; page <= 30; page++) {
135
+ const batch = await get(`${url}?per_page=100&page=${page}`);
136
+ items.push(...batch);
137
+ if (batch.length < 100)
138
+ break;
139
+ }
140
+ return items;
141
+ };
142
+ const commits = await all(`${base}/pulls/${options.pr}/commits`);
143
+ const atUntil = commits.filter(c => before(c.commit?.committer?.date)).at(-1)?.sha;
144
+ const head = atUntil ?? pr.head?.sha;
145
+ const author = String(pr.user?.login ?? '');
146
+ const reviewer = (user) => !!user?.login && user.login !== author;
147
+ const raw = await all(`${base}/pulls/${options.pr}/comments`);
148
+ const reviews = await all(`${base}/pulls/${options.pr}/reviews`);
149
+ return {
150
+ number: Number(options.pr), mergeSha: head, mergedAt: pr.merged_at ?? '',
151
+ comments: raw.filter(c => reviewer(c.user) && before(c.created_at)).flatMap(c => toComment(Number(options.pr), c, author)),
152
+ reviews: reviews.filter(r => reviewer(r.user) && before(r.submitted_at) && r.commit_id && String(r.body ?? '').trim())
153
+ .map((r) => ({ id: String(r.id), prNumber: Number(options.pr), commit: r.commit_id, body: String(r.body), author: String(r.user.login), source: sourceOf(r.user), prAuthor: author })),
154
+ };
155
+ }
57
156
  /** A review comment where it was written: its original commit and lines. */
58
- function toComment(prNumber, c) {
157
+ function toComment(prNumber, c, prAuthor) {
59
158
  const end = c.original_line ?? c.line;
60
159
  const commit = c.original_commit_id ?? c.commit_id;
61
160
  if (!c.path || !end || !commit || c.in_reply_to_id)
62
161
  return [];
63
162
  const start = c.original_start_line ?? c.start_line ?? end;
64
- return [{ id: String(c.id), prNumber, path: c.path, start: Math.min(start, end), end, commit, body: String(c.body ?? ''), author: String(c.user?.login ?? '') }];
163
+ return [{ id: String(c.id), prNumber, path: c.path, start: Math.min(start, end), end, commit, body: String(c.body ?? ''), author: String(c.user?.login ?? ''), source: sourceOf(c.user), prAuthor }];
164
+ }
165
+ /** A GitHub App or a bot account (`type: Bot`, or a login like `name[bot]`), else a person's login, whose text may itself be an AI's. */
166
+ function sourceOf(user) {
167
+ return user?.type === 'Bot' || /\[bot\]$|bot$/i.test(String(user?.login ?? '')) ? 'bot' : 'person';
168
+ }
169
+ /** Every point the model judged, with the person's words and what it made of them (a rule, or none), beside the lessons for audit. */
170
+ function auditRules(cwd, points, kept) {
171
+ const at = new Date().toISOString();
172
+ const byComment = new Map(kept.flatMap(l => l.evidence.map(e => [e.comment, l])));
173
+ const lines = points.map(p => {
174
+ const e = p.evidence[0];
175
+ const rule = byComment.get(e.comment);
176
+ return JSON.stringify({ at, pr: e.pr, author: e.author, said: p.text, rule: rule && rule.text !== p.text ? rule.text : rule ? '(kept as said)' : null });
177
+ });
178
+ const file = path.join(path.dirname(lessonsPath(cwd)), 'review-rules-log.jsonl');
179
+ fs.mkdirSync(path.dirname(file), { recursive: true });
180
+ fs.appendFileSync(file, `${lines.join('\n')}\n`);
65
181
  }
@@ -1,23 +1,73 @@
1
- import type { Git, ReviewComment } from './acted-on.js';
1
+ import type { Git, ReviewBody, ReviewComment } from './acted-on.js';
2
+ /**
3
+ * One piece of a lesson's evidence trail:
4
+ * point a review point making it (the candidate's own source; weak, but it can recur);
5
+ * outcome a later commit fixed the lines the point named, or the pull request was reverted;
6
+ * counter the lines shipped unchanged and nothing needed fixing within the window;
7
+ * correction a person changed what an agent wrote (human-edits.ts): heavily weighted, it makes a lesson;
8
+ * accepted / rejected a person decided (`rigour learn-reviews --promote / --reject`);
9
+ * norule the rule writer found no rule in it (a report, a template, a one-off): never promoted again.
10
+ * A record from before evidence kinds has none: it is a `point`.
11
+ */
12
+ export type EvidenceKind = 'point' | 'outcome' | 'counter' | 'correction' | 'accepted' | 'rejected' | 'norule';
13
+ export interface LessonEvidence {
14
+ kind?: EvidenceKind;
15
+ pr: number;
16
+ /** The review comment or review-body point (or, for a decision, what decided it). */
17
+ comment: string;
18
+ /** Who wrote the point, or who decided. */
19
+ author: string;
20
+ /** A review bot's login (a GitHub App), or a person's (whose text may itself be an AI's): metadata, never a gate. */
21
+ source?: 'person' | 'bot';
22
+ /** Who wrote the pull request: recurrence counts only across different authors. */
23
+ prAuthor?: string;
24
+ /** Whether the pull request changed the lines before merging: not evidence (agents apply comments on their own), recorded. */
25
+ actedOn?: boolean;
26
+ /** The person's own words, kept when a rule was written from them (rules-from-reviews.ts). */
27
+ said?: string;
28
+ /** A point's own words, as it was made (each point merged into a lesson keeps its own). */
29
+ text?: string;
30
+ /** What the evidence was: the fixing commit, the window, who decided and why. */
31
+ detail?: string;
32
+ at?: string;
33
+ }
2
34
  export interface ReviewLesson {
3
35
  id: string;
4
36
  text: string;
5
37
  file: string;
6
38
  symbols: string[];
7
- state: 'candidate' | 'verified';
8
- evidence: Array<{
9
- pr: number;
10
- comment: string;
11
- author: string;
12
- }>;
39
+ /** `rejected`: an anti-lesson, a point the team decided against. */
40
+ state: 'candidate' | 'verified' | 'rejected';
41
+ /** Which evidence made it a lesson. */
42
+ promotedBy?: 'outcome' | 'correction' | 'person' | 'recurrence' | 'legacy';
43
+ evidence: LessonEvidence[];
44
+ /** Where the point sits in the commit it was made on, for outcome evidence (inline comments only). */
45
+ at?: {
46
+ commit: string;
47
+ start: number;
48
+ end: number;
49
+ };
13
50
  createdAt: string;
14
51
  updatedAt: string;
15
52
  }
53
+ /**
54
+ * A lesson's state from its evidence trail. A person's decision wins; then an outcome; counter-evidence
55
+ * holds a candidate back; recurrence across pull requests and authors is enough only without it. A record
56
+ * from before evidence kinds keeps the state it had.
57
+ */
58
+ export declare function lessonState(lesson: ReviewLesson): Pick<ReviewLesson, 'state' | 'promotedBy'>;
16
59
  /** The point of a review comment: its bold title, else its first sentence, without tool output or markup. */
17
60
  export declare function lessonText(body: string): string;
18
61
  /** Identifiers a lesson is about: those the comment names, then those on the lines it pointed at. */
19
62
  export declare function lessonSymbols(body: string, codeLines: string[]): string[];
20
63
  export declare function lessonFromComment(git: Git, comment: ReviewComment, at?: string): ReviewLesson | undefined;
64
+ /**
65
+ * Candidate lessons from a review body's points. A point naming a path is about that file; one naming
66
+ * only a file the PR changed after the review (by its name) is about that file; a point naming none is
67
+ * a team standard (file ''), served with any change it is about once evidence makes it a lesson.
68
+ * Whether files changed after it is recorded, not required.
69
+ */
70
+ export declare function lessonsFromReview(review: ReviewBody, changedAfter: string[], at?: string): ReviewLesson[];
21
71
  /**
22
72
  * Add lessons to the store. The same lesson again (same text, or the same
23
73
  * file with two shared symbols) adds evidence; acted on in enough PRs, it is
@@ -37,16 +87,19 @@ export interface ChangeShape {
37
87
  * Lessons that apply to a change, best first. A lesson applies when the change
38
88
  * touches its file, or shares at least two specific identifiers with it; a
39
89
  * shared directory only breaks ties. Lessons about docs, tests or config never
40
- * apply to other files.
90
+ * apply to other files. A team standard (a lesson with no file) applies to every
91
+ * change; the best-evidenced few follow the file lessons.
41
92
  */
42
93
  export declare function matchLessons(lessons: ReviewLesson[], change: ChangeShape, options?: {
43
94
  includeCandidates?: boolean;
44
95
  limit?: number;
96
+ standards?: number;
45
97
  }): ReviewLesson[];
46
98
  /** RIGOUR_REVIEW_LESSONS points at a lessons file outside the clone (CI, or a team's shared copy). */
47
99
  export declare function lessonsPath(cwd: string): string;
48
100
  export declare function readLessons(cwd: string): ReviewLesson[];
49
101
  export declare function writeLessons(cwd: string, lessons: ReviewLesson[]): void;
50
- export declare function promoteLesson(cwd: string, id: string): ReviewLesson | undefined;
102
+ /** A person's decision on a lesson, kept as evidence: accepted makes it a lesson, rejected an anti-lesson. Undefined for an unknown id. */
103
+ export declare function decideLesson(cwd: string, id: string, decision: 'accepted' | 'rejected', by: string, why?: string): ReviewLesson | undefined;
51
104
  /** An identifier specific enough to link two pieces of code: camelCase, snake_case, or long. */
52
105
  export declare function isSpecific(symbol: string): boolean;