@rigour-labs/core 6.9.0-rc.1 → 6.9.0-rc.3

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/goal/goal.js CHANGED
@@ -44,9 +44,9 @@ const FILE_EXTENSIONS = new Set(['ts', 'tsx', 'mts', 'cts', 'js', 'mjs', 'cjs',
44
44
  'ini', 'cfg', 'conf', 'c', 'h', 'cc', 'cpp', 'hpp', 'cs', 'php', 'scala', 'ex', 'exs', 'dart', 'lua', 'tf', 'hcl', 'snap']);
45
45
  /**
46
46
  * A token that names a file or path: has a folder separator or a glob, or ends in a known file extension. Member
47
- * access (`JSON.parse`, `session.leadId`, `res.status`) is a symbol, not a file. A bare `name.ext` is a file only when
47
+ * access (`JSON.parse`, `order.customerId`, `res.status`) is a symbol, not a file. A bare `name.ext` is a file only when
48
48
  * `exists` (the repository's file names) knows it, so `package.json` is a file and `res.json` is a member access. A URL
49
- * or an app route (`https://host/checkout?x=1`, `/learner/dashboard`) is never a file of the repository.
49
+ * or an app route (`https://host/checkout?x=1`, `/app/dashboard`) is never a file of the repository.
50
50
  */
51
51
  function isPath(token, exists) {
52
52
  if (/:\/\//.test(token) || token.startsWith('/') || !/^[\w.@/*?[\]{}!,+()$~-]+$/.test(token))
@@ -1,5 +1,9 @@
1
1
  import { type Exec } from '../review/reviewer/exec.js';
2
- /** success and failure from the check runs; pending while one runs; none when there are none; unavailable when GitHub could not say. */
2
+ /**
3
+ * failure: a check that passed on the commit before the merge failed on the merge commit (the merge broke it; a check
4
+ * already failing on main says nothing about this pull request); success: none did; pending while one runs; none when
5
+ * there are no runs; unavailable when GitHub could not say.
6
+ */
3
7
  export type CiResult = 'success' | 'failure' | 'pending' | 'none' | 'unavailable';
4
8
  export interface FollowUp {
5
9
  sha: string;
@@ -15,6 +19,8 @@ export interface PrOutcome {
15
19
  mergeSha: string;
16
20
  mergedAt: string;
17
21
  branch: string;
22
+ /** Who wrote the pull request: independence between pull requests is judged by it. */
23
+ author: string;
18
24
  /** What the merge changed on main: its first-parent diff. */
19
25
  files: string[];
20
26
  ci: CiResult;
@@ -35,28 +41,40 @@ export interface OutcomeOptions {
35
41
  windowDays: number;
36
42
  /** Only history before this time (a backtest); now when unset. */
37
43
  until?: string;
38
- /** The CI result for a commit (gh); `unavailable` when it cannot be read. */
39
- ci: (sha: string) => Promise<CiResult>;
44
+ /** The CI result for the merge commit against the commit before it on main (gh); `unavailable` when it cannot be read. */
45
+ ci: (sha: string, parent: string) => Promise<CiResult>;
46
+ /** How git is run (tests count the calls); `spawnSync` by default. */
47
+ git?: (args: string[]) => string;
40
48
  }
49
+ type MergedRef = {
50
+ number: number;
51
+ mergeSha: string;
52
+ mergedAt: string;
53
+ branch: string;
54
+ author: string;
55
+ };
41
56
  /**
42
- * The CI result for a commit from all its check runs, every page (a big matrix runs more than 100: a failure on page two
43
- * must not read as success); any error is `unavailable`, never a throw.
57
+ * The CI result for a merge commit from all its check runs, every page (a big matrix runs more than 100: a failure on
58
+ * page two must not read as success). A run that failed is counted only when the same check did not fail on the commit
59
+ * before the merge, read only then: on a main branch where a check is often red, "any run failed" says nothing about the
60
+ * pull request. Any error is `unavailable`, never a throw.
44
61
  */
45
- export declare function checkRunsCi(cwd: string, exec: Exec, env?: Record<string, string>): (sha: string) => Promise<CiResult>;
62
+ export declare function checkRunsCi(cwd: string, exec: Exec, env?: Record<string, string>): (sha: string, parent: string) => Promise<CiResult>;
63
+ interface OutcomeStore {
64
+ version: 1;
65
+ outcomes: Record<string, PrOutcome>;
66
+ }
67
+ export declare function readPrOutcomes(cwd: string): OutcomeStore;
46
68
  /**
47
69
  * Brings the store up to date for these merged pull requests: a settled record is kept as it is, anything else is read
48
70
  * again. The branch's thread (when this machine has one) gets a `merge` event the first time and an `outcome` event when
49
71
  * the record settles. Stops at `deadline` (ms since epoch) and says so: what was read is kept.
50
72
  */
51
- export declare function updatePrOutcomes(cwd: string, prs: Array<{
52
- number: number;
53
- mergeSha: string;
54
- mergedAt: string;
55
- branch: string;
56
- }>, options: OutcomeOptions & {
73
+ export declare function updatePrOutcomes(cwd: string, prs: MergedRef[], options: OutcomeOptions & {
57
74
  deadline?: number;
58
75
  }): Promise<{
59
76
  outcomes: PrOutcome[];
60
77
  read: number;
61
78
  stopped?: string;
62
79
  }>;
80
+ export {};
@@ -14,73 +14,95 @@ import { FIX, revertsPr } from '../review-learning/outcomes.js';
14
14
  import { GH_TIMEOUT_MS } from '../review/reviewer/exec.js';
15
15
  import { appendBranchEvent } from '../task/thread.js';
16
16
  const DAY_MS = 24 * 60 * 60 * 1000;
17
+ /** An unsettled record read this recently is kept as it is: its window is open, and reading it again every run starves the older ones. */
18
+ const RECHECK_MS = 12 * 60 * 60 * 1000;
17
19
  const OUTCOMES_FILE = path.join('.rigour', 'outcomes.json');
18
- /** The outcome of one merged pull request as of now (or `until`). */
19
- async function prOutcome(cwd, pr, options) {
20
+ /** The outcome of one merged pull request as of now (or `until`), its follow-ups taken from main's log read once for the run. */
21
+ async function prOutcome(pr, options, run, log) {
20
22
  const now = options.until ? Date.parse(options.until) : Date.now();
21
23
  const windowEnd = new Date(Math.min(Date.parse(pr.mergedAt) + options.windowDays * DAY_MS, now)).toISOString();
22
24
  const closed = Date.parse(pr.mergedAt) + options.windowDays * DAY_MS <= now;
23
- const files = lines(git(cwd, ['diff', '--name-only', `${pr.mergeSha}^1`, pr.mergeSha]));
24
- const followUps = files.length ? followUpsOf(cwd, pr.mergeSha, options.mainRef, files, pr.mergedAt, windowEnd) : [];
25
+ const files = lines(run(['diff', '--name-only', `${pr.mergeSha}^1`, pr.mergeSha]));
26
+ const followUps = followUpsOf(log, pr, files, windowEnd);
25
27
  const revert = followUps.find(f => revertsPr(f.subject, pr));
26
- const ci = await options.ci(pr.mergeSha);
28
+ const ci = await options.ci(pr.mergeSha, `${pr.mergeSha}^1`);
27
29
  return {
28
- pr: pr.number, mergeSha: pr.mergeSha, mergedAt: pr.mergedAt, branch: pr.branch, files, ci, followUps,
30
+ pr: pr.number, mergeSha: pr.mergeSha, mergedAt: pr.mergedAt, branch: pr.branch, author: pr.author, files, ci, followUps,
29
31
  ...(revert ? { reverted: { sha: revert.sha, subject: revert.subject } } : {}),
30
32
  windowEnd,
31
33
  settled: closed && (ci === 'success' || ci === 'failure' || ci === 'none'),
32
34
  checkedAt: new Date(now).toISOString(),
33
35
  };
34
36
  }
37
+ /** The commits after the merge on main's first-parent history, up to the window's end, that touched any of the pull request's files. */
38
+ function followUpsOf(log, pr, files, windowEnd) {
39
+ const wanted = new Set(files);
40
+ const merged = log.findIndex(c => c.sha === pr.mergeSha);
41
+ // The merge itself is in the log (it is read from a day before the earliest merge); without it, time decides.
42
+ const after = merged >= 0 ? log.slice(merged + 1) : log.filter(c => c.at > pr.mergedAt);
43
+ return after.filter(c => c.at <= windowEnd).flatMap(c => {
44
+ const touched = c.files.filter(file => wanted.has(file));
45
+ return touched.length ? [{ sha: c.sha, at: c.at, subject: c.subject, files: touched, fix: FIX.test(c.subject) }] : [];
46
+ });
47
+ }
35
48
  /**
36
- * First-parent commits on main after the merge, inside the window, that touched any of the pull request's files. Both
37
- * ends bounded. The files are matched here, not passed to git: a large pull request's paths would pass the command-line
38
- * limit (about 32 KB on Windows), and the window already bounds what git lists.
49
+ * Main's first-parent history between two times, oldest first, with each commit's files: read once for every pull
50
+ * request a run brings up to date (their windows overlap). The files are matched in code, not passed to git: a large
51
+ * pull request's paths would pass the command-line limit (about 32 KB on Windows).
39
52
  */
40
- function followUpsOf(cwd, mergeSha, mainRef, files, from, to) {
41
- const wanted = new Set(files);
42
- const out = git(cwd, ['log', '--first-parent', '--name-only', '--format=%x00%H%x09%cI%x09%s', `--since=${from}`, `--until=${to}`, `${mergeSha}..${mainRef}`]);
53
+ function mainLog(run, mainRef, since, until) {
54
+ const out = run(['log', '--first-parent', '--name-only', '--format=%x00%H%x09%cI%x09%s', `--since=${since}`, `--until=${until}`, mainRef]);
43
55
  return out.split('\0').filter(Boolean).flatMap(block => {
44
56
  const [header, ...rest] = block.split('\n');
45
57
  const [sha, at, ...subject] = header.split('\t');
46
- if (!sha || !at)
47
- return [];
48
- const touched = rest.map(line => line.trim()).filter(file => wanted.has(file));
49
- if (touched.length === 0)
50
- return [];
51
- const text = subject.join('\t');
52
- return [{ sha, at, subject: text, files: touched, fix: FIX.test(text) }];
53
- }).reverse(); // oldest first
58
+ return sha && at ? [{ sha, at, subject: subject.join('\t'), files: rest.map(line => line.trim()).filter(Boolean) }] : [];
59
+ }).reverse();
54
60
  }
55
61
  /**
56
- * The CI result for a commit from all its check runs, every page (a big matrix runs more than 100: a failure on page two
57
- * must not read as success); any error is `unavailable`, never a throw.
62
+ * The CI result for a merge commit from all its check runs, every page (a big matrix runs more than 100: a failure on
63
+ * page two must not read as success). A run that failed is counted only when the same check did not fail on the commit
64
+ * before the merge, read only then: on a main branch where a check is often red, "any run failed" says nothing about the
65
+ * pull request. Any error is `unavailable`, never a throw.
58
66
  */
59
67
  export function checkRunsCi(cwd, exec, env) {
60
- return async (sha) => {
68
+ const runs = async (sha) => {
61
69
  try {
62
- const read = await exec('gh', ['api', '--paginate', `repos/{owner}/{repo}/commits/${sha}/check-runs?per_page=100`, '--jq', '[.check_runs[] | {status, conclusion}]'], { cwd, timeoutMs: GH_TIMEOUT_MS, ...(env ? { env } : {}) });
70
+ const read = await exec('gh', ['api', '--paginate', `repos/{owner}/{repo}/commits/${sha}/check-runs?per_page=100`, '--jq', '[.check_runs[] | {name, status, conclusion}]'], { cwd, timeoutMs: GH_TIMEOUT_MS, ...(env ? { env } : {}) });
63
71
  if (read.exitCode !== 0)
64
- return 'unavailable';
72
+ return undefined;
65
73
  // One array per page; unlike parseJsonArrays, output that does not parse is unavailable here, never an empty "none" that would settle.
66
- return ciFrom(JSON.parse(`[${read.stdout.trim().replace(/\]\s*\[/g, '],[')}]`).flat());
74
+ return JSON.parse(`[${read.stdout.trim().replace(/\]\s*\[/g, '],[')}]`).flat();
67
75
  }
68
76
  catch {
69
- return 'unavailable';
77
+ return undefined;
70
78
  }
71
79
  };
80
+ return async (sha, parent) => {
81
+ const merge = await runs(sha);
82
+ if (!merge)
83
+ return 'unavailable';
84
+ const failed = merge.filter(failedRun);
85
+ if (failed.length === 0)
86
+ return ciFrom(merge);
87
+ const before = await runs(parent);
88
+ if (!before)
89
+ return 'unavailable';
90
+ const redBefore = new Set(before.filter(failedRun).map(r => r.name));
91
+ return failed.some(r => !redBefore.has(r.name)) ? 'failure' : ciFrom(merge.filter(r => !failedRun(r)));
92
+ };
93
+ }
94
+ function failedRun(run) {
95
+ return run.conclusion === 'failure' || run.conclusion === 'timed_out';
72
96
  }
73
- /** A failure anywhere fails it; a run still going keeps it pending; cancelled and skipped runs say nothing. */
97
+ /** With no new failure: a run still going keeps it pending; cancelled and skipped runs say nothing. */
74
98
  function ciFrom(runs) {
75
99
  if (!Array.isArray(runs) || runs.length === 0)
76
100
  return 'none';
77
- if (runs.some(r => r.conclusion === 'failure' || r.conclusion === 'timed_out'))
78
- return 'failure';
79
101
  if (runs.some(r => r.status !== 'completed'))
80
102
  return 'pending';
81
103
  return runs.some(r => r.conclusion === 'success') ? 'success' : 'none';
82
104
  }
83
- function readPrOutcomes(cwd) {
105
+ export function readPrOutcomes(cwd) {
84
106
  try {
85
107
  const parsed = JSON.parse(fs.readFileSync(path.join(cwd, OUTCOMES_FILE), 'utf8'));
86
108
  return parsed?.version === 1 && parsed.outcomes && typeof parsed.outcomes === 'object' ? parsed : { version: 1, outcomes: {} };
@@ -97,29 +119,35 @@ function readPrOutcomes(cwd) {
97
119
  export async function updatePrOutcomes(cwd, prs, options) {
98
120
  const store = readPrOutcomes(cwd);
99
121
  const result = [];
122
+ const run = options.git ?? ((args) => git(cwd, args));
100
123
  let read = 0;
101
124
  let stopped;
102
- for (const pr of prs) {
103
- if (!pr.mergeSha)
104
- continue;
105
- const known = store.outcomes[pr.mergeSha];
106
- if (known?.settled) {
107
- result.push(known);
108
- continue;
109
- }
125
+ const now = options.until ? Date.parse(options.until) : Date.now();
126
+ const fresh = (outcome) => !!outcome && (outcome.settled || now - Date.parse(outcome.checkedAt) < RECHECK_MS);
127
+ // Oldest merge first: those are the ones whose windows close, so a run cut short by the deadline still moves records
128
+ // towards settled instead of reading the newest, still-open ones again and again.
129
+ const pending = prs.filter(pr => pr.mergeSha && !fresh(store.outcomes[pr.mergeSha])).sort((a, b) => (a.mergedAt < b.mergedAt ? -1 : 1));
130
+ // Main's log once, across every pull request to read: from a day before the earliest merge (so each merge commit is in
131
+ // it) to the latest window's end, bounded at both ends.
132
+ const log = pending.length ? mainLog(run, options.mainRef, new Date(Math.min(...pending.map(pr => Date.parse(pr.mergedAt))) - DAY_MS).toISOString(), new Date(Math.min(now, Math.max(...pending.map(pr => Date.parse(pr.mergedAt))) + options.windowDays * DAY_MS)).toISOString()) : [];
133
+ for (const pr of pending) {
110
134
  if (options.deadline !== undefined && Date.now() > options.deadline) {
111
135
  stopped = `read deadline reached after ${read} pull request(s); run again to read the rest`;
112
136
  break;
113
137
  }
114
- const outcome = await prOutcome(cwd, pr, options);
138
+ const known = store.outcomes[pr.mergeSha];
139
+ const outcome = await prOutcome(pr, options, run, log);
115
140
  read++;
116
141
  if (!known)
117
142
  appendBranchEvent(cwd, pr.branch, { kind: 'merge', pr: pr.number, merge_sha: pr.mergeSha, merged_at: pr.mergedAt });
118
143
  if (outcome.settled)
119
144
  appendBranchEvent(cwd, pr.branch, { kind: 'outcome', pr: pr.number, ci: outcome.ci, follow_ups: outcome.followUps.length, fixes: outcome.followUps.filter(f => f.fix).length, reverted: !!outcome.reverted });
120
145
  store.outcomes[pr.mergeSha] = outcome;
121
- result.push(outcome);
122
146
  }
147
+ // In the order asked for: every record known for these pull requests, read now or kept.
148
+ for (const pr of prs)
149
+ if (pr.mergeSha && store.outcomes[pr.mergeSha])
150
+ result.push(store.outcomes[pr.mergeSha]);
123
151
  fs.mkdirSync(path.join(cwd, '.rigour'), { recursive: true });
124
152
  fs.writeFileSync(path.join(cwd, OUTCOMES_FILE), JSON.stringify(store, null, 2) + '\n');
125
153
  return { outcomes: result, read, ...(stopped ? { stopped } : {}) };
@@ -7,6 +7,7 @@ import type { Config } from '../types/index.js';
7
7
  import { type Exec } from '../review/reviewer/exec.js';
8
8
  import { type ResolvedSwitch } from '../switches.js';
9
9
  import { type PrOutcome } from './outcome.js';
10
+ import { type OutcomeEvidenceResult } from '../review-learning/outcome-evidence.js';
10
11
  export interface OutcomesRun {
11
12
  switch: ResolvedSwitch;
12
13
  outcomes: PrOutcome[];
@@ -14,6 +15,8 @@ export interface OutcomesRun {
14
15
  read: number;
15
16
  /** Why the run read less than it was asked to, when it did. */
16
17
  stopped?: string;
18
+ /** What the records did to the team's review lessons (review-learning/outcome-evidence.ts). */
19
+ lessons?: OutcomeEvidenceResult;
17
20
  }
18
21
  export declare function runOutcomes(cwd: string, config: Config, options: {
19
22
  flag?: boolean;
@@ -2,9 +2,15 @@ import { branchBase } from '../gates/logic-drift-git-base.js';
2
2
  import { mergedPrs } from '../review/backtest-init.js';
3
3
  import { defaultExec, githubEnv, GH_TIMEOUT_MS } from '../review/reviewer/exec.js';
4
4
  import { resolveSwitch } from '../switches.js';
5
- import { checkRunsCi, updatePrOutcomes } from './outcome.js';
5
+ import { checkRunsCi, readPrOutcomes, updatePrOutcomes } from './outcome.js';
6
+ import { applyOutcomeEvidence } from '../review-learning/outcome-evidence.js';
7
+ import { readLessons, writeLessons } from '../review-learning/lessons.js';
8
+ import { gitIn } from '../review-learning/acted-on.js';
9
+ import { eventsOfKind } from '../task/thread.js';
6
10
  /** How long one run may read before it stops and keeps what it has. */
7
11
  const READ_DEADLINE_MS = 2 * 60_000;
12
+ /** How long one run may follow points' lines through history; the next run carries on. */
13
+ const LESSON_DEADLINE_MS = 60_000;
8
14
  export async function runOutcomes(cwd, config, options) {
9
15
  const exec = options.exec ?? defaultExec;
10
16
  const resolved = resolveSwitch('outcomes', config, options.flag);
@@ -23,14 +29,35 @@ export async function runOutcomes(cwd, config, options) {
23
29
  });
24
30
  const incomplete = listed.incomplete ? 'the list of merged pull requests may be incomplete (gh listed too many updates to prove it)' : undefined;
25
31
  const stopped = [run.stopped, incomplete].filter(Boolean).join('; ');
26
- return { switch: resolved, outcomes: run.outcomes, read: run.read, ...(stopped ? { stopped } : {}) };
32
+ const lessons = lessonEvidence(cwd, mainRef, config.learning?.outcomes?.demote_after ?? 2);
33
+ return { switch: resolved, outcomes: run.outcomes, read: run.read, ...(lessons ? { lessons } : {}), ...(stopped ? { stopped } : {}) };
34
+ }
35
+ /** Every record kept (not only this run's) against the team's lessons, and the reviews that found a lesson repeated; written only when something changed. */
36
+ function lessonEvidence(cwd, mainRef, demoteAfter) {
37
+ const lessons = readLessons(cwd);
38
+ if (lessons.length === 0)
39
+ return undefined;
40
+ const applied = new Map();
41
+ for (const e of eventsOfKind(cwd, 'review')) {
42
+ if (typeof e.pr !== 'number' || !Array.isArray(e.lessons_applied))
43
+ continue;
44
+ const ids = applied.get(e.pr) ?? new Set();
45
+ for (const id of e.lessons_applied)
46
+ if (typeof id === 'string')
47
+ ids.add(id);
48
+ applied.set(e.pr, ids);
49
+ }
50
+ const result = applyOutcomeEvidence(lessons, Object.values(readPrOutcomes(cwd).outcomes), applied, { demoteAfter, git: gitIn(cwd), mainRef, deadline: Date.now() + LESSON_DEADLINE_MS });
51
+ if (result.added)
52
+ writeLessons(cwd, lessons);
53
+ return result;
27
54
  }
28
55
  async function onePr(cwd, pr, exec, env) {
29
- const view = await exec('gh', ['pr', 'view', String(pr), '--json', 'number,mergedAt,mergeCommit,headRefName,state'], { cwd, timeoutMs: GH_TIMEOUT_MS, ...(env ? { env } : {}) });
56
+ const view = await exec('gh', ['pr', 'view', String(pr), '--json', 'number,mergedAt,mergeCommit,headRefName,author,state'], { cwd, timeoutMs: GH_TIMEOUT_MS, ...(env ? { env } : {}) });
30
57
  if (view.exitCode !== 0)
31
58
  throw new Error(`could not read pull request #${pr}: ${view.stderr.trim() || 'is gh signed in?'}`);
32
59
  const p = JSON.parse(view.stdout);
33
60
  if (p.state !== 'MERGED' || !p.mergeCommit?.oid)
34
61
  throw new Error(`pull request #${pr} is not merged`);
35
- return { prs: [{ number: p.number, mergedAt: p.mergedAt, mergeSha: p.mergeCommit.oid, branch: p.headRefName ?? '' }], incomplete: false };
62
+ return { prs: [{ number: p.number, mergedAt: p.mergedAt, mergeSha: p.mergeCommit.oid, branch: p.headRefName ?? '', author: p.author?.login ?? '' }], incomplete: false };
36
63
  }
@@ -17,6 +17,7 @@ export interface MergedPr {
17
17
  mergedAt: string;
18
18
  mergeSha: string;
19
19
  branch: string;
20
+ author: string;
20
21
  }
21
22
  /** The last `n` merged pull requests, newest merge first; `incomplete` when the listing could not prove it has them all. */
22
23
  export declare function mergedPrs(cwd: string, n: number, config: Config, exec?: Exec): Promise<{
@@ -58,7 +58,7 @@ export async function mergedPrs(cwd, n, config, exec = defaultExec) {
58
58
  // than its update, which is no later than that). Until then the listing doubles: bots that touch old pull requests after
59
59
  // merge (backports, labels, stale comments) can crowd a window.
60
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,mergeCommit,headRefName'], { cwd, timeoutMs: GH_TIMEOUT_MS, env });
61
+ const list = await exec('gh', ['pr', 'list', '--state', 'merged', '--limit', String(limit), '--search', 'sort:updated-desc', '--json', 'number,mergedAt,updatedAt,mergeCommit,headRefName,author'], { cwd, timeoutMs: GH_TIMEOUT_MS, env });
62
62
  if (list.exitCode !== 0)
63
63
  throw new Error(`could not list merged pull requests: ${list.stderr.trim() || 'is gh signed in?'}`);
64
64
  let listed;
@@ -68,7 +68,7 @@ export async function mergedPrs(cwd, n, config, exec = defaultExec) {
68
68
  catch {
69
69
  throw new Error('could not read the list of merged pull requests');
70
70
  }
71
- const prs = [...listed].sort((a, b) => (a.mergedAt < b.mergedAt ? 1 : -1)).slice(0, n).map(({ number, mergedAt, mergeCommit, headRefName }) => ({ number, mergedAt, mergeSha: mergeCommit?.oid ?? '', branch: headRefName ?? '' }));
71
+ const prs = [...listed].sort((a, b) => (a.mergedAt < b.mergedAt ? 1 : -1)).slice(0, n).map(({ number, mergedAt, mergeCommit, headRefName, author }) => ({ number, mergedAt, mergeSha: mergeCommit?.oid ?? '', branch: headRefName ?? '', author: author?.login ?? '' }));
72
72
  const oldestUpdate = listed.reduce((min, p) => (p.updatedAt < min ? p.updatedAt : min), listed[0]?.updatedAt ?? '');
73
73
  const complete = listed.length < limit || (prs.length === n && oldestUpdate <= prs[n - 1].mergedAt);
74
74
  if (complete)
@@ -56,6 +56,16 @@ export declare function reviewerInputs(review: {
56
56
  hints: string;
57
57
  checks: string[];
58
58
  };
59
+ /** A lesson as the judge was shown it: its id, and the line it was listed as (the judge answers by that line). */
60
+ export interface ServedLesson {
61
+ id: string;
62
+ listed: string;
63
+ }
64
+ /** The ids of the served lessons the judge said this change repeats; an answer that names no served lesson says nothing. */
65
+ export declare function lessonsApplied(answers: Array<{
66
+ lesson: string;
67
+ applies: boolean;
68
+ }>, served: ServedLesson[]): string[];
59
69
  /** The context pack as Markdown, its hash for the fingerprint, and the router's count of risky changed functions (undefined when it could not score). */
60
70
  export declare function buildContext(input: ContextInput): {
61
71
  text: string;
@@ -63,6 +73,7 @@ export declare function buildContext(input: ContextInput): {
63
73
  risky: number | undefined;
64
74
  rules: ServedRule[];
65
75
  lessons: number;
76
+ servedLessons: ServedLesson[];
66
77
  };
67
78
  /**
68
79
  * Tracked Markdown docs that name a changed file by path or by a distinctive file stem: one
@@ -70,6 +70,12 @@ export function dismissedAs(item, dismissals) {
70
70
  export function reviewerInputs(review) {
71
71
  return { hints: review.hints.join('\n'), checks: review.findings.map(f => `${f.files?.[0] ?? '?'}${f.line ? `:${f.line}` : ''} ${f.title}`) };
72
72
  }
73
+ /** The ids of the served lessons the judge said this change repeats; an answer that names no served lesson says nothing. */
74
+ export function lessonsApplied(answers, served) {
75
+ const norm = (text) => text.toLowerCase().replace(/\s+/g, ' ').trim();
76
+ const ids = answers.filter(a => a.applies === true && typeof a.lesson === 'string').map(a => served.find(s => norm(s.listed) === norm(a.lesson) || (norm(a.lesson).length >= 20 && norm(s.listed).startsWith(norm(a.lesson))))?.id);
77
+ return [...new Set(ids.filter((id) => !!id))];
78
+ }
73
79
  /** The context pack as Markdown, its hash for the fingerprint, and the router's count of risky changed functions (undefined when it could not score). */
74
80
  export function buildContext(input) {
75
81
  const sections = [];
@@ -81,9 +87,9 @@ export function buildContext(input) {
81
87
  task = undefined;
82
88
  }
83
89
  // A judge reads the whole pull request: more of what the team taught fits than an agent's one question at the stop.
84
- const lessons = input.lessons === 'off' ? [] : lessonsForDiff(input.cwd, input.diff, input.lessons, JUDGE_STANDARDS, JUDGE_FILE_LESSONS, JUDGE_LESSONS_PER_FILE, input.pr).map(lessonView);
85
- if (lessons.length)
86
- 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')}`);
90
+ const servedLessons = input.lessons === 'off' ? [] : lessonsForDiff(input.cwd, input.diff, input.lessons, JUDGE_STANDARDS, JUDGE_FILE_LESSONS, JUDGE_LESSONS_PER_FILE, input.pr).map(l => ({ id: l.id, listed: describeLesson(lessonView(l)) }));
91
+ if (servedLessons.length)
92
+ 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${servedLessons.map(l => `- ${l.listed}`).join('\n')}`);
87
93
  // The repository's own rules, always: the reviewer is the boundary, and what the team wrote is the standard it checks.
88
94
  const rules = rulesForDiff(input.cwd, input.diff, true, JUDGE_RULES).map((r) => ({ id: r.id, source: r.source, text: r.text, requirement: r.requirement }));
89
95
  if (rules.length)
@@ -103,7 +109,7 @@ export function buildContext(input) {
103
109
  if (docs.length)
104
110
  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')}`);
105
111
  const text = sections.length ? `# What this team already knows\n\n${sections.join('\n\n')}\n` : 'none\n';
106
- return { text, key: createHash('sha256').update(text).digest('hex').slice(0, 16), risky: task ? task.items.length + task.alreadyReviewed : undefined, rules, lessons: lessons.length };
112
+ return { text, key: createHash('sha256').update(text).digest('hex').slice(0, 16), risky: task ? task.items.length + task.alreadyReviewed : undefined, rules, lessons: servedLessons.length, servedLessons };
107
113
  }
108
114
  function where(x) {
109
115
  return x.file ? `${x.file}${x.line ? `:${x.line}` : ''}` : '(no file)';
@@ -73,6 +73,8 @@ export interface ReviewerResult {
73
73
  };
74
74
  /** The record of this review (record.ts) and where it is kept, beside the verdict. */
75
75
  record?: ReviewRecord;
76
+ /** The team's lessons the judge said this change repeats, by id: what the outcome loop reads against a lesson (review-learning/outcome-evidence.ts). */
77
+ lessonsApplied?: string[];
76
78
  recordPath?: string;
77
79
  /** Tokens every run reported, summed: the only measure of a CLI that reports no dollars (Codex). */
78
80
  tokens?: Tokens;
@@ -34,7 +34,7 @@ import { resolveReviewer } from './reviewer/settings.js';
34
34
  import { VerdictStore } from './reviewer/store.js';
35
35
  import { trackUsage } from '../telemetry/telemetry.js';
36
36
  import { reviewerUsage } from './reviewer/usage.js';
37
- import { buildContext, dismissedAs, readReviewDismissals, relatedDocs } from './reviewer/context.js';
37
+ import { buildContext, dismissedAs, lessonsApplied, readReviewDismissals, relatedDocs } from './reviewer/context.js';
38
38
  import { buildRecord } from './reviewer/record.js';
39
39
  import { account, attachServedRules, changedLinesOf, checkoutSearch, checkoutVerifier, carryResolved, evidenceTouched, mergeVerdicts, parseVerdict } from './reviewer/verdict.js';
40
40
  import { judgeUnset } from './reviewer/judge-env.js';
@@ -59,6 +59,7 @@ export async function runReviewer(cwd, base, config, exec = defaultExec, progres
59
59
  kind: 'review', trigger, outcome: result.outcome, blocking: result.items.length, should_fix: result.advisory.length,
60
60
  ...(result.pr ? { pr: result.pr } : {}),
61
61
  ...(result.prTitle ? { pr_title: result.prTitle } : {}),
62
+ ...(result.lessonsApplied ? { lessons_applied: result.lessonsApplied } : {}),
62
63
  ...(result.record ? { integrity: result.record.integrity, cost_usd: result.record.judges.reduce((sum, j) => sum + (j.cost_usd ?? 0), 0), judges: result.record.judges.map(j => j.reviewer) } : {}),
63
64
  });
64
65
  return result;
@@ -217,7 +218,8 @@ async function review(cwd, base, config, exec, progress, options) {
217
218
  const record = (cached && store.readJson(recordPath)) || buildRecord({ head, base: baseSha, scope, verdict, accounted, judges, lessonsServed: context.lessons, humanReviews: reviews.count });
218
219
  if (!cached || !fs.existsSync(recordPath))
219
220
  store.writeJson(recordPath, record);
220
- return { ...res, record, recordPath };
221
+ const applied = lessonsApplied(verdict.lessons ?? [], context.servedLessons);
222
+ return { ...res, record, recordPath, ...(applied.length ? { lessonsApplied: applied } : {}) };
221
223
  };
222
224
  if (!options.force && fs.existsSync(verdictFile) && fs.existsSync(openFile)) {
223
225
  const verdict = store.readJson(verdictFile);
@@ -46,7 +46,7 @@ export async function learnFromReviews(cwd, options) {
46
46
  const byNumber = new Map(prs.filter(pr => pr.mergedAt).map(pr => [pr.number, pr]));
47
47
  const reverts = new Map([...byNumber.values()].map(pr => [pr.number, revertOf(git, pr, { mainRef: options.mainRef, until: options.until })]));
48
48
  for (const lesson of merged.lessons) {
49
- if (lesson.state !== 'candidate' || lesson.evidence.some(e => e.kind === 'outcome' || e.kind === 'counter'))
49
+ if (lesson.state !== 'candidate' || lesson.evidence.some(e => e.kind === 'outcome' || e.kind === 'lines' || e.kind === 'counter'))
50
50
  continue;
51
51
  for (const point of lesson.evidence.filter(e => (e.kind ?? 'point') === 'point')) {
52
52
  const pr = byNumber.get(point.pr);
@@ -54,7 +54,8 @@ export async function learnFromReviews(cwd, options) {
54
54
  continue;
55
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
56
  if (found) {
57
- lesson.evidence.push(found);
57
+ // A fix on the point's lines, or a revert, is evidence for a person to promote in Studio, never a promotion (lessonState).
58
+ lesson.evidence.push(found.kind === 'outcome' ? { ...found, kind: 'lines' } : found);
58
59
  break;
59
60
  }
60
61
  }
@@ -6,10 +6,19 @@ import type { Git, ReviewBody, ReviewComment } from './acted-on.js';
6
6
  * counter the lines shipped unchanged and nothing needed fixing within the window;
7
7
  * correction a person changed what an agent wrote (human-edits.ts): heavily weighted, it makes a lesson;
8
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.
9
+ * norule the rule writer found no rule in it (a report, a template, a one-off): never promoted again;
10
+ * followup a later fix touched the point's file, with nothing else to show it was this point (outcome-evidence.ts):
11
+ * recorded, never enough on its own;
12
+ * lines a later fix changed the point's own lines (outcome-evidence.ts): shown for a person to promote or dismiss,
13
+ * never a promotion on its own (a fix on the same lines is often unrelated work);
14
+ * dismissed a person looked at that evidence and set it aside: the lesson stays as it was;
15
+ * reclassified a lesson an outcome alone had promoted, back to a candidate when outcomes stopped promoting (once);
16
+ * against a later pull request a review found repeating the lesson merged anyway and settled clean;
17
+ * demoted enough independent `against` pull requests took back a lesson evidence had promoted: a candidate again,
18
+ * until a person promotes it.
10
19
  * A record from before evidence kinds has none: it is a `point`.
11
20
  */
12
- export type EvidenceKind = 'point' | 'outcome' | 'counter' | 'correction' | 'accepted' | 'rejected' | 'norule';
21
+ export type EvidenceKind = 'point' | 'outcome' | 'counter' | 'correction' | 'accepted' | 'rejected' | 'norule' | 'followup' | 'lines' | 'dismissed' | 'against' | 'demoted' | 'reclassified';
13
22
  export interface LessonEvidence {
14
23
  kind?: EvidenceKind;
15
24
  pr: number;
@@ -51,9 +60,9 @@ export interface ReviewLesson {
51
60
  updatedAt: string;
52
61
  }
53
62
  /**
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.
63
+ * A lesson's state from its evidence trail. A person's decision wins; then a person's correction; counter-evidence
64
+ * holds a candidate back; recurrence across pull requests and authors is enough only without it. An outcome is
65
+ * evidence, never a promotion. A record from before evidence kinds keeps the state it had.
57
66
  */
58
67
  export declare function lessonState(lesson: ReviewLesson): Pick<ReviewLesson, 'state' | 'promotedBy'>;
59
68
  /** The point of a review comment: its bold title, else its first sentence, without tool output or markup. */
@@ -102,7 +111,7 @@ export declare function lessonsPath(cwd: string): string;
102
111
  export declare function readLessons(cwd: string): ReviewLesson[];
103
112
  export declare function writeLessons(cwd: string, lessons: ReviewLesson[]): void;
104
113
  /** A person's decision on a lesson, kept as evidence: accepted makes it a lesson, rejected an anti-lesson. Undefined for an unknown id. */
105
- export declare function decideLesson(cwd: string, id: string, decision: 'accepted' | 'rejected', by: string, why?: string): ReviewLesson | undefined;
114
+ export declare function decideLesson(cwd: string, id: string, decision: 'accepted' | 'rejected' | 'dismissed', by: string, why?: string): ReviewLesson | undefined;
106
115
  /** An identifier specific enough to link two pieces of code: camelCase, snake_case, or long. */
107
116
  export declare function isSpecific(symbol: string): boolean;
108
117
  /** The meaningful words of a text or an identifier: `hasLaterAttempt` and "a later attempt" share later and attempt. */
@@ -35,9 +35,9 @@ const PLAIN_WORDS = new Set(['when', 'that', 'this', 'with', 'from', 'before', '
35
35
  const NOT_CODE = /(\.(md|mdx|txt|ya?ml|json|lock|snap)$)|(\.(test|spec)\.[cm]?[jt]sx?$)|(^|\/)(__tests__|docs?|migrations|\.github)\//i;
36
36
  const NOT_SYMBOLS = new Set(['this', 'that', 'with', 'from', 'return', 'const', 'await', 'async', 'function', 'true', 'false', 'null', 'undefined', 'string', 'number', 'boolean', 'export', 'import', 'type', 'interface', 'else', 'when', 'then', 'should', 'line', 'lines']);
37
37
  /**
38
- * A lesson's state from its evidence trail. A person's decision wins; then an outcome; counter-evidence
39
- * holds a candidate back; recurrence across pull requests and authors is enough only without it. A record
40
- * from before evidence kinds keeps the state it had.
38
+ * A lesson's state from its evidence trail. A person's decision wins; then a person's correction; counter-evidence
39
+ * holds a candidate back; recurrence across pull requests and authors is enough only without it. An outcome is
40
+ * evidence, never a promotion. A record from before evidence kinds keeps the state it had.
41
41
  */
42
42
  export function lessonState(lesson) {
43
43
  const kinds = new Set(lesson.evidence.map(e => e.kind));
@@ -51,10 +51,13 @@ export function lessonState(lesson) {
51
51
  return { state: 'verified', promotedBy: 'person' };
52
52
  if (kinds.has('norule'))
53
53
  return { state: 'candidate' };
54
+ // Taken back by evidence (outcome-evidence.ts): a person's decision above is the only way back.
55
+ if (kinds.has('demoted'))
56
+ return { state: 'candidate' };
54
57
  if (kinds.has('correction'))
55
58
  return { state: 'verified', promotedBy: 'correction' };
56
- if (kinds.has('outcome'))
57
- return { state: 'verified', promotedBy: 'outcome' };
59
+ // An outcome (a later fix on the point's lines, a revert) no longer promotes on its own: judged by a person against
60
+ // real history, it was most often unrelated work. It is shown in Studio for a person to promote.
58
61
  if (kinds.has('counter'))
59
62
  return { state: 'candidate' };
60
63
  const points = lesson.evidence.filter(e => (e.kind ?? 'point') === 'point');
@@ -224,12 +227,29 @@ export function lessonsPath(cwd) {
224
227
  export function readLessons(cwd) {
225
228
  try {
226
229
  const parsed = JSON.parse(fs.readFileSync(lessonsPath(cwd), 'utf8'));
227
- return Array.isArray(parsed?.lessons) ? parsed.lessons : [];
230
+ return Array.isArray(parsed?.lessons) ? parsed.lessons.map(reclassified) : [];
228
231
  }
229
232
  catch {
230
233
  return [];
231
234
  }
232
235
  }
236
+ /** Why a lesson an outcome alone had promoted is a candidate again. */
237
+ const RECLASSIFIED = 'promoted by the exact-line rule, which no longer promotes on its own';
238
+ /**
239
+ * A lesson stored as verified by an outcome (a later fix on its lines, or a revert), as every reader sees it now that
240
+ * outcomes no longer promote: its state worked out again from its evidence, and, when that leaves it a candidate, one
241
+ * `reclassified` record saying why. Never deleted, nothing else changed; the next write keeps it, and a lesson that
242
+ * already has the record is left as it is. Recurrence, a correction or a person's decision keeps a lesson verified.
243
+ */
244
+ function reclassified(lesson) {
245
+ if (lesson.state !== 'verified' || lesson.promotedBy !== 'outcome' || lesson.evidence.some(e => e.kind === 'reclassified'))
246
+ return lesson;
247
+ const next = lessonState(lesson);
248
+ if (next.state === 'verified')
249
+ return { ...lesson, ...next };
250
+ const at = new Date().toISOString();
251
+ return { ...lesson, state: 'candidate', promotedBy: undefined, evidence: [...lesson.evidence, { kind: 'reclassified', pr: lesson.evidence[0]?.pr ?? 0, comment: `reclassified-${lesson.id}`, author: '', detail: RECLASSIFIED, at }] };
252
+ }
233
253
  export function writeLessons(cwd, lessons) {
234
254
  const file = lessonsPath(cwd);
235
255
  fs.mkdirSync(path.dirname(file), { recursive: true });
@@ -0,0 +1,41 @@
1
+ /**
2
+ * What the outcome records (outcomes/outcome.ts) say about the team's review lessons. Deterministic, and a person's
3
+ * decision always wins (lessonState).
4
+ *
5
+ * For a lesson: a point its pull request did not act on, whose own lines (within three either side, followed through
6
+ * every later commit as code moves: outcomes.ts outcomeFor) a later commit inside the record's window changed, where
7
+ * that commit says it fixed something and touches at most fifteen files: `lines` evidence, with CI regressing on the
8
+ * merge commit or a revert recorded as context. It never promotes on its own: judged by a person on real history, a fix
9
+ * on the same lines was most often unrelated work. Studio shows it on the candidate for a person to promote or dismiss.
10
+ * A fix that touched only the point's file is `followup` evidence.
11
+ *
12
+ * Against a lesson: only when a review of a later pull request recorded the lesson as APPLYING (the change does what the
13
+ * lesson warns against; the reviewer's lessons step, review event `lessons_applied`) and the pull request merged anyway
14
+ * and settled clean: CI passed on the merge commit, no fix touched the lesson's file in the window, and no revert. A
15
+ * pull request that followed the lesson, or that no review checked against it, never counts. `demote_after` such pull
16
+ * requests, independent (more than one author, or merged at least a week apart), take back a lesson recurrence
17
+ * promoted: it is `demoted` to a candidate. A lesson a person promoted, or corrected into being,
18
+ * is not.
19
+ */
20
+ import type { PrOutcome } from '../outcomes/outcome.js';
21
+ import type { Git } from './acted-on.js';
22
+ import { type ReviewLesson } from './lessons.js';
23
+ export interface OutcomeEvidenceOptions {
24
+ demoteAfter: number;
25
+ /** git in the repository, and its main branch: without them, no lines can be followed. */
26
+ git?: Git;
27
+ mainRef?: string;
28
+ /** Stop following lines at this time (ms since epoch); what was found is kept. */
29
+ deadline?: number;
30
+ }
31
+ /** `suggested`: lessons that got new `lines` evidence, for a person to promote or dismiss; `demoted`: lessons taken back. */
32
+ export interface OutcomeEvidenceResult {
33
+ added: number;
34
+ suggested: string[];
35
+ demoted: string[];
36
+ }
37
+ /**
38
+ * Adds the evidence the settled and unsettled records give, to `lessons` in place, once each (by its comment key), and
39
+ * recomputes their states. `applied` is, per pull request, the lessons a review of it recorded as applying.
40
+ */
41
+ export declare function applyOutcomeEvidence(lessons: ReviewLesson[], records: PrOutcome[], applied: Map<number, Set<string>>, options: OutcomeEvidenceOptions): OutcomeEvidenceResult;
@@ -0,0 +1,72 @@
1
+ import { lessonState } from './lessons.js';
2
+ import { outcomeFor } from './outcomes.js';
3
+ /** Lines either side of a point's own that count as its lines, and the most files a fixing commit may touch to say anything about one point. */
4
+ const POINT_SLACK = 3;
5
+ const MAX_FIX_FILES = 15;
6
+ const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
7
+ /**
8
+ * Adds the evidence the settled and unsettled records give, to `lessons` in place, once each (by its comment key), and
9
+ * recomputes their states. `applied` is, per pull request, the lessons a review of it recorded as applying.
10
+ */
11
+ export function applyOutcomeEvidence(lessons, records, applied, options) {
12
+ const byPr = new Map(records.map(r => [r.pr, r]));
13
+ const result = { added: 0, suggested: [], demoted: [] };
14
+ for (const lesson of lessons) {
15
+ const before = lesson.state;
16
+ const add = (evidence) => {
17
+ if (lesson.evidence.some(e => e.comment === evidence.comment))
18
+ return false;
19
+ lesson.evidence.push(evidence);
20
+ result.added++;
21
+ return true;
22
+ };
23
+ const own = new Set(lesson.evidence.filter(e => (e.kind ?? 'point') === 'point').map(e => e.pr));
24
+ // For it: a point its pull request left alone, whose own lines a later fix inside the window changed.
25
+ for (const point of lesson.evidence.filter(e => (e.kind ?? 'point') === 'point' && e.actedOn === false)) {
26
+ const record = byPr.get(point.pr);
27
+ // Only where the record shows a fix on the file at all: following lines costs a git walk, done only then.
28
+ const fileFix = lesson.file ? record?.followUps.find(f => f.fix && f.files.includes(lesson.file)) : undefined;
29
+ if (!record || !fileFix)
30
+ continue;
31
+ const context = [record.ci === 'failure' ? 'CI regressed on the merge commit' : '', record.reverted ? `the pull request was reverted by ${record.reverted.sha.slice(0, 9)}` : ''].filter(Boolean).join('; ');
32
+ const inTime = options.deadline === undefined || Date.now() <= options.deadline;
33
+ const lines = options.git && options.mainRef && lesson.at && inTime
34
+ ? outcomeFor(options.git, lesson, { number: record.pr, mergeSha: record.mergeSha, mergedAt: record.mergedAt }, { mainRef: options.mainRef, until: record.windowEnd, slack: POINT_SLACK, maxFiles: MAX_FIX_FILES })
35
+ : undefined;
36
+ // Evidence for a person, never a promotion: a fix on the same lines is often unrelated work.
37
+ if (lines?.kind === 'outcome') {
38
+ const evidence = { ...lines, kind: 'lines', comment: `lines-${record.pr}-${lines.comment.replace(/^outcome-/, '')}`, detail: `${lines.detail}${context ? `; ${context}` : ''}` };
39
+ if (add(evidence) && !result.suggested.includes(lesson.id))
40
+ result.suggested.push(lesson.id);
41
+ }
42
+ else {
43
+ add({ kind: 'followup', pr: record.pr, comment: `followup-${record.pr}-${fileFix.sha.slice(0, 12)}`, author: '', detail: `${lesson.file} fixed later by ${fileFix.sha.slice(0, 9)} "${fileFix.subject}", not on the point's lines${context ? `; ${context}` : ''}`, at: fileFix.at });
44
+ }
45
+ }
46
+ // Against it: later pull requests a review found repeating it, merged anyway, settled clean.
47
+ if (lesson.state === 'verified' && lesson.promotedBy === 'recurrence') {
48
+ for (const [pr, ids] of applied) {
49
+ const record = byPr.get(pr);
50
+ if (own.has(pr) || !ids.has(lesson.id) || !record || !settledClean(record, lesson.file))
51
+ continue;
52
+ add({ kind: 'against', pr, comment: `against-${pr}`, author: '', prAuthor: record.author, detail: `a review found #${pr} repeating it; merged ${record.mergedAt.slice(0, 10)}, CI passed, no fix on ${lesson.file || 'its files'} within the window, no revert`, at: record.mergedAt });
53
+ }
54
+ const against = lesson.evidence.filter(e => e.kind === 'against');
55
+ const authors = new Set(against.map(e => e.prAuthor).filter(Boolean)).size;
56
+ // Independent: more than one author, or merged at least a week apart (a span, never calendar buckets: two merges a day apart across a week boundary are not).
57
+ const times = against.map(e => Date.parse(e.at ?? '')).filter(Number.isFinite);
58
+ const apart = times.length > 1 && Math.max(...times) - Math.min(...times) >= WEEK_MS;
59
+ if (new Set(against.map(e => e.pr)).size >= options.demoteAfter && (authors > 1 || apart)) {
60
+ add({ kind: 'demoted', pr: against.at(-1).pr, comment: `demoted-${against.map(e => e.pr).sort((a, b) => a - b).join('-')}`, author: '', detail: `taken back: ${against.map(e => `#${e.pr}`).join(', ')} repeated it and settled clean`, at: new Date().toISOString() });
61
+ }
62
+ }
63
+ Object.assign(lesson, lessonState(lesson));
64
+ if (before === 'verified' && lesson.state === 'candidate')
65
+ result.demoted.push(lesson.id);
66
+ }
67
+ return result;
68
+ }
69
+ /** CI passed on the merge commit, no fix touched the file (any file, for a team standard) within the window, no revert. Settled only. */
70
+ function settledClean(record, file) {
71
+ return record.settled && record.ci === 'success' && !record.reverted && !record.followUps.some(f => f.fix && (!file || f.files.includes(file)));
72
+ }
@@ -16,6 +16,10 @@ export interface OutcomeOptions {
16
16
  until?: string;
17
17
  /** How long unchanged lines must ship before that counts against the point. */
18
18
  windowDays?: number;
19
+ /** Lines either side of the point's own that count as its lines (outcome-evidence.ts: 3). */
20
+ slack?: number;
21
+ /** A fixing commit that touches more files than this is a sweep, and says nothing about one point: no verdict. */
22
+ maxFiles?: number;
19
23
  }
20
24
  export interface MergedAt {
21
25
  number: number;
@@ -7,7 +7,8 @@ export function outcomeFor(git, lesson, pr, options) {
7
7
  const point = lesson.evidence.find(e => (e.kind ?? 'point') === 'point' && e.pr === pr.number);
8
8
  if (!point || point.actedOn || !lesson.at || !lesson.file || !pr.mergedAt)
9
9
  return undefined;
10
- let range = shift(changedHunks(git, lesson.at.commit, pr.mergeSha, lesson.file), [lesson.at.start, lesson.at.end]);
10
+ const slack = options.slack ?? 0;
11
+ let range = shift(changedHunks(git, lesson.at.commit, pr.mergeSha, lesson.file), [Math.max(1, lesson.at.start - slack), lesson.at.end + slack]);
11
12
  if (!range)
12
13
  return undefined; // the pull request changed them after all
13
14
  const before = options.until ? [`--before=${options.until}`] : [];
@@ -26,8 +27,9 @@ export function outcomeFor(git, lesson, pr, options) {
26
27
  continue;
27
28
  }
28
29
  const [subject, date] = git(['log', '-1', '--format=%s%x09%cI', sha]).trim().split('\t');
29
- // Changed by a commit that says it fixed something: the point was right. Changed otherwise: no verdict.
30
- return FIX.test(subject) ? { kind: 'outcome', pr: pr.number, comment: `outcome-${sha.slice(0, 12)}`, author: '', detail: `fixed later by ${sha.slice(0, 9)} "${subject}"`, at: date } : undefined;
30
+ // Changed by a commit that says it fixed something, and is not a sweep across the codebase: the point was right. Changed otherwise: no verdict.
31
+ const sweep = options.maxFiles !== undefined && git(['show', '--name-only', '--format=', sha]).split('\n').filter(Boolean).length > options.maxFiles;
32
+ return FIX.test(subject) && !sweep ? { kind: 'outcome', pr: pr.number, comment: `outcome-${sha.slice(0, 12)}`, author: '', detail: `fixed later by ${sha.slice(0, 9)} "${subject}"`, at: date } : undefined;
31
33
  }
32
34
  const now = options.until ? Date.parse(options.until) : Date.now();
33
35
  const days = Math.floor((now - Date.parse(pr.mergedAt)) / DAY_MS);
@@ -275,6 +275,7 @@ export declare const SWITCHES: {
275
275
  outcomes: {
276
276
  mode: "off" | "on" | "required";
277
277
  window_days: number;
278
+ demote_after: number;
278
279
  };
279
280
  };
280
281
  review: {
@@ -582,6 +583,7 @@ export declare const SWITCHES: {
582
583
  outcomes: {
583
584
  mode: "off" | "on" | "required";
584
585
  window_days: number;
586
+ demote_after: number;
585
587
  };
586
588
  };
587
589
  review: {
@@ -29,6 +29,8 @@ export declare function taskOf(cwd: string): {
29
29
  } | undefined;
30
30
  /** Appends one event to the checkout's task thread. Best effort: a thread never breaks the hook or command it runs in. */
31
31
  export declare function appendTaskEvent(cwd: string, event: TaskEvent): ThreadEvent | undefined;
32
+ /** Every thread's events of one kind, oldest first: what the outcome loop reads across tasks (the reviews that found a lesson repeated). */
33
+ export declare function eventsOfKind(cwd: string, kind: TaskEventKind): ThreadEvent[];
32
34
  /**
33
35
  * Appends one event to a named branch's thread, under the task its own events carry: what happened to a pull request
34
36
  * after it merged, recorded from wherever the outcome was read. Only a thread that already exists is written: a branch
@@ -99,6 +99,13 @@ export function appendTaskEvent(cwd, event) {
99
99
  return undefined;
100
100
  }
101
101
  }
102
+ /** Every thread's events of one kind, oldest first: what the outcome loop reads across tasks (the reviews that found a lesson repeated). */
103
+ export function eventsOfKind(cwd, kind) {
104
+ const dir = threadsDir(cwd);
105
+ if (!dir)
106
+ return [];
107
+ return listThreads(dir).flatMap(file => readEvents(path.join(dir, file))).filter(e => e.kind === kind).sort(byTime);
108
+ }
102
109
  /**
103
110
  * Appends one event to a named branch's thread, under the task its own events carry: what happened to a pull request
104
111
  * after it merged, recorded from wherever the outcome was read. Only a thread that already exists is written: a branch
@@ -280,7 +280,7 @@ export const UNIVERSAL_CONFIG = {
280
280
  max_items: 10,
281
281
  },
282
282
  learning: {
283
- outcomes: { mode: 'off', window_days: 30 },
283
+ outcomes: { mode: 'off', window_days: 30, demote_after: 2 },
284
284
  },
285
285
  review: {
286
286
  include_heuristics: false,
@@ -2420,22 +2420,28 @@ export declare const ConfigSchema: z.ZodObject<{
2420
2420
  mode: z.ZodDefault<z.ZodOptional<z.ZodEnum<["off", "on", "required"]>>>;
2421
2421
  /** How long after a merge later commits count, in days. */
2422
2422
  window_days: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
2423
+ /** How many later pull requests, independent and settled clean after a review found them repeating a lesson, demote it (review-learning/outcome-evidence.ts). */
2424
+ demote_after: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
2423
2425
  }, "strip", z.ZodTypeAny, {
2424
2426
  mode: "off" | "on" | "required";
2425
2427
  window_days: number;
2428
+ demote_after: number;
2426
2429
  }, {
2427
2430
  mode?: "off" | "on" | "required" | undefined;
2428
2431
  window_days?: number | undefined;
2432
+ demote_after?: number | undefined;
2429
2433
  }>>>;
2430
2434
  }, "strip", z.ZodTypeAny, {
2431
2435
  outcomes: {
2432
2436
  mode: "off" | "on" | "required";
2433
2437
  window_days: number;
2438
+ demote_after: number;
2434
2439
  };
2435
2440
  }, {
2436
2441
  outcomes?: {
2437
2442
  mode?: "off" | "on" | "required" | undefined;
2438
2443
  window_days?: number | undefined;
2444
+ demote_after?: number | undefined;
2439
2445
  } | undefined;
2440
2446
  }>>>;
2441
2447
  /** rigour review / rigour_review / the PR bot / the stop hook. */
@@ -2928,6 +2934,7 @@ export declare const ConfigSchema: z.ZodObject<{
2928
2934
  outcomes: {
2929
2935
  mode: "off" | "on" | "required";
2930
2936
  window_days: number;
2937
+ demote_after: number;
2931
2938
  };
2932
2939
  };
2933
2940
  review: {
@@ -3231,6 +3238,7 @@ export declare const ConfigSchema: z.ZodObject<{
3231
3238
  outcomes?: {
3232
3239
  mode?: "off" | "on" | "required" | undefined;
3233
3240
  window_days?: number | undefined;
3241
+ demote_after?: number | undefined;
3234
3242
  } | undefined;
3235
3243
  } | undefined;
3236
3244
  review?: {
@@ -380,6 +380,8 @@ export const ConfigSchema = z.object({
380
380
  mode: z.enum(['off', 'on', 'required']).optional().default('off'),
381
381
  /** How long after a merge later commits count, in days. */
382
382
  window_days: z.number().int().min(7).max(90).optional().default(30),
383
+ /** How many later pull requests, independent and settled clean after a review found them repeating a lesson, demote it (review-learning/outcome-evidence.ts). */
384
+ demote_after: z.number().int().min(2).optional().default(2),
383
385
  }).optional().default({}),
384
386
  }).optional().default({}),
385
387
  /** rigour review / rigour_review / the PR bot / the stop hook. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rigour-labs/core",
3
- "version": "6.9.0-rc.1",
3
+ "version": "6.9.0-rc.3",
4
4
  "description": "Rigour's review engine: deterministic gates on changed lines, rules and lessons learned from your team's fixes, and per-check precision from what you fix versus dismiss, across TypeScript, JavaScript, Python, Go, Ruby and C#.",
5
5
  "engines": {
6
6
  "node": ">=22.13"
@@ -75,11 +75,11 @@
75
75
  "@anthropic-ai/sdk": "^0.132.1",
76
76
  "pg": "^8.16.3",
77
77
  "openai": "^5.23.2",
78
- "@rigour-labs/brain-darwin-arm64": "6.9.0-rc.1",
79
- "@rigour-labs/brain-darwin-x64": "6.9.0-rc.1",
80
- "@rigour-labs/brain-linux-x64": "6.9.0-rc.1",
81
- "@rigour-labs/brain-linux-arm64": "6.9.0-rc.1",
82
- "@rigour-labs/brain-win-x64": "6.9.0-rc.1"
78
+ "@rigour-labs/brain-darwin-arm64": "6.9.0-rc.3",
79
+ "@rigour-labs/brain-darwin-x64": "6.9.0-rc.3",
80
+ "@rigour-labs/brain-linux-arm64": "6.9.0-rc.3",
81
+ "@rigour-labs/brain-win-x64": "6.9.0-rc.3",
82
+ "@rigour-labs/brain-linux-x64": "6.9.0-rc.3"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@types/fs-extra": "^11.0.4",