@rigour-labs/core 6.10.1-rc.1 → 6.11.0-rc.1

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.
@@ -1,6 +1,6 @@
1
1
  import type { DeepFinding, InferenceOptions, InferenceProvider } from '../inference/types.js';
2
2
  import type { CodeContext } from './code-context.js';
3
- import { type SettledCheck } from '../review/settled-checks.js';
3
+ import { type CoveredLesson, type SettledCheck } from '../review/settled-checks.js';
4
4
  import type { RelatedChange } from './related-changes.js';
5
5
  export interface FocusItem {
6
6
  file: string;
@@ -22,6 +22,8 @@ export interface PrReviewInput {
22
22
  prBody?: string;
23
23
  /** What Rigour's checks already found on the change: listed as settled; a finding of the same kind at one of their lines is dropped. */
24
24
  settled?: SettledCheck[];
25
+ /** Lessons the team's compiled checks covered on this change, left out of `lessons`: the prompt says so. */
26
+ covered?: CoveredLesson[];
25
27
  }
26
28
  export interface PrReviewResult {
27
29
  findings: DeepFinding[];
@@ -14,7 +14,7 @@ import { REVIEW_CATEGORIES } from './code-review-prompt.js';
14
14
  import { parseFindings } from './parse-findings.js';
15
15
  import { diffSections } from './pr-diff.js';
16
16
  import { runToolLoop } from './tool-loop.js';
17
- import { againstSettled, settledLine, settledSection } from '../review/settled-checks.js';
17
+ import { againstSettled, coveredSection, settledLine, settledSection } from '../review/settled-checks.js';
18
18
  const BUDGET = { maxToolCalls: 24, maxTurns: 14 };
19
19
  const MAX_DIFF_CHARS = 80_000;
20
20
  const MAX_FINDINGS = 5;
@@ -67,6 +67,7 @@ export function buildPrPrompt(input) {
67
67
  input.prBody ? `PR DESCRIPTION (what the author intended):\n${input.prBody.slice(0, PR_BODY_CHARS)}` : '',
68
68
  input.rules ?? '',
69
69
  input.lessons ?? '',
70
+ coveredSection(input.covered ?? []),
70
71
  settledSection((input.settled ?? []).map(settledLine)),
71
72
  contracts ? `BOTH SIDES OF A CALL CHANGED (check these contracts first):\n${contracts}` : '',
72
73
  focus ? `LOOK FIRST (riskiest changed functions, and what to check):\n${focus}` : '',
@@ -31,6 +31,7 @@ import { checkLocalPatterns } from '../storage/local-memory.js';
31
31
  import { isScoped } from '../utils/scope.js';
32
32
  import { Logger } from '../utils/logger.js';
33
33
  import path from 'path';
34
+ import { withoutCovered } from '../review/settled-checks.js';
34
35
  /** Cloud setup (API connection) must not hang a check. Local setup is bounded by download stall timeouts instead. */
35
36
  const CLOUD_SETUP_TIMEOUT_MS = 120_000;
36
37
  /**
@@ -158,7 +159,7 @@ export class DeepAnalysisGate extends Gate {
158
159
  const related = relatedChanges(cwd, changedFiles, rankChangedFunctions(cwd, options.focusLines ?? {}, options.removedLines));
159
160
  this.config.onProgress?.(` Reviewing the PR as a whole (${focus.length} risky function(s) first)...`);
160
161
  try {
161
- const result = await reviewPullRequest(this.provider, { cwd, diff: options.diff, focus, related, lessons: lessonsSection(lessonsForDiff(cwd, options.diff, this.config.reviewLessons)),
162
+ const result = await reviewPullRequest(this.provider, { cwd, diff: options.diff, focus, related, lessons: lessonsSection(withoutCovered(lessonsForDiff(cwd, options.diff, this.config.reviewLessons), options.covered ?? [])), covered: options.covered,
162
163
  rules: rulesSection(rulesForDiff(cwd, options.diff, this.config.repoRules)), prBody: options.prBody, settled: options.settled }, inferenceOptions(this.config));
163
164
  this.recordPass({ findings: [], chunksTotal: 1, chunksFailed: 0 });
164
165
  this.outcome.findingsProposed = result.findings.length;
@@ -215,7 +215,7 @@ export class GateRunner {
215
215
  let deepStats = undefined;
216
216
  if (deepOptions?.enabled) {
217
217
  // What the checks found on the change's lines is settled: the model is told so and never reports it again.
218
- const deep = await runDeepAnalysis(this.config, { cwd, ignore, patterns }, { ...deepOptions, settled: settledChecks(onChangedLines(failures, deepOptions.focusLines)) });
218
+ const deep = await runDeepAnalysis(this.config, { cwd, ignore, patterns }, { ...deepOptions, settled: [...(deepOptions.settled ?? []), ...settledChecks(onChangedLines(failures, deepOptions.focusLines))] });
219
219
  failures.push(...deep.failures);
220
220
  summary['deep-analysis'] = deep.summary;
221
221
  deepStats = deep.stats;
package/dist/index.d.ts CHANGED
@@ -21,6 +21,7 @@ export { loadLedger, ledgerProblems, runBacktest, backtestPassed, formatBacktest
21
21
  export { scaffoldLedger } from './review/backtest-init.js';
22
22
  export type { CiResult, FollowUp, PrOutcome } from './outcomes/outcome.js';
23
23
  export { localOutcomeMetrics, runOutcomes, type OutcomesRun } from './outcomes/run.js';
24
+ export { learningUsage } from './telemetry/learning-usage.js';
24
25
  export type { OutcomeMetrics, Share } from './outcomes/metrics.js';
25
26
  export { fixScope, type FixScope } from './review/fix-scope.js';
26
27
  export { isGeneratedFile } from './review/generated-files.js';
@@ -44,6 +45,7 @@ export { rankChangedFunctions, scoreRisk, functionHash, findFunction, type Funct
44
45
  export { routeFiles, type RouterPolicy, type RouterStats } from './deep/router.js';
45
46
  export { appendDeepRun, readDeepRuns, summarizeDeepRuns, type DeepRun, type DeepRunSummary } from './review/deep-runs.js';
46
47
  export { learnFromReviews, type LearnFromReviewsOptions, type LearnFromReviewsResult } from './review-learning/learn-from-reviews.js';
48
+ export { decideCompiledCheck, proposeCompiledChecks, readCompiledChecks, suspension, type CompiledCheck } from './review-learning/compiled-lessons.js';
47
49
  export { ruleWriterFor } from './review/reviewer/rule-writer.js';
48
50
  export { backtestLast, formatLast, scoreLast, LAST_LEDGER_PATH, type LastReport } from './review/backtest-last.js';
49
51
  export { buildRecord, recordLines, recordIntact, type ReviewRecord } from './review/reviewer/record.js';
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ export { dismissFinding, dismissedKeys, findingKey, isProven, mustFix, quietSpli
20
20
  export { loadLedger, ledgerProblems, runBacktest, backtestPassed, formatBacktest, LEDGER_PATH } from './review/backtest.js';
21
21
  export { scaffoldLedger } from './review/backtest-init.js';
22
22
  export { localOutcomeMetrics, runOutcomes } from './outcomes/run.js';
23
+ export { learningUsage } from './telemetry/learning-usage.js';
23
24
  export { fixScope } from './review/fix-scope.js';
24
25
  export { isGeneratedFile } from './review/generated-files.js';
25
26
  export { costBucket, countUsage, doNotTrack, durationBucket, flushDailyUsage, isTelemetryEnabled, readTelemetryState, setTelemetryEnabled, shouldAskTelemetry, telemetryToken, trackUsage } from './telemetry/telemetry.js';
@@ -42,6 +43,7 @@ export { rankChangedFunctions, scoreRisk, functionHash, findFunction } from './d
42
43
  export { routeFiles } from './deep/router.js';
43
44
  export { appendDeepRun, readDeepRuns, summarizeDeepRuns } from './review/deep-runs.js';
44
45
  export { learnFromReviews } from './review-learning/learn-from-reviews.js';
46
+ export { decideCompiledCheck, proposeCompiledChecks, readCompiledChecks, suspension } from './review-learning/compiled-lessons.js';
45
47
  export { ruleWriterFor } from './review/reviewer/rule-writer.js';
46
48
  export { backtestLast, formatLast, scoreLast, LAST_LEDGER_PATH } from './review/backtest-last.js';
47
49
  export { buildRecord, recordLines, recordIntact } from './review/reviewer/record.js';
@@ -8,7 +8,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
10
  import { type OutcomeEvidenceResult } from '../review-learning/outcome-evidence.js';
11
- import { type OutcomeMetrics } from './metrics.js';
11
+ import { type OutcomeMetrics, type PrReviews } from './metrics.js';
12
12
  export interface OutcomesRun {
13
13
  switch: ResolvedSwitch;
14
14
  outcomes: PrOutcome[];
@@ -27,5 +27,10 @@ export declare function runOutcomes(cwd: string, config: Config, options: {
27
27
  last?: number;
28
28
  exec?: Exec;
29
29
  }): Promise<OutcomesRun>;
30
+ /** Per pull request, the lessons a review of it recorded as applying, and every pull request a review by Rigour ran on with its rounds' dollars and its first review's findings: from the threads. */
31
+ export declare function threadReviews(cwd: string): {
32
+ applied: Map<number, Set<string>>;
33
+ reviewed: Map<number, PrReviews>;
34
+ };
30
35
  /** The outcome numbers for this checkout (metrics.ts), over every record it keeps; undefined when it keeps none. Read-only. */
31
36
  export declare function localOutcomeMetrics(cwd: string): OutcomeMetrics | undefined;
@@ -39,13 +39,13 @@ function lessonEvidence(cwd, mainRef, demoteAfter) {
39
39
  const lessons = readLessons(cwd);
40
40
  if (lessons.length === 0)
41
41
  return undefined;
42
- const result = applyOutcomeEvidence(lessons, Object.values(readPrOutcomes(cwd).outcomes), reviews(cwd).applied, { demoteAfter, git: gitIn(cwd), mainRef, deadline: Date.now() + LESSON_DEADLINE_MS });
42
+ const result = applyOutcomeEvidence(lessons, Object.values(readPrOutcomes(cwd).outcomes), threadReviews(cwd).applied, { demoteAfter, git: gitIn(cwd), mainRef, deadline: Date.now() + LESSON_DEADLINE_MS });
43
43
  if (result.added)
44
44
  writeLessons(cwd, lessons);
45
45
  return result;
46
46
  }
47
47
  /** Per pull request, the lessons a review of it recorded as applying, and every pull request a review by Rigour ran on with its rounds' dollars and its first review's findings: from the threads. */
48
- function reviews(cwd) {
48
+ export function threadReviews(cwd) {
49
49
  const applied = new Map();
50
50
  const reviewed = new Map();
51
51
  const count = (v) => typeof v === 'number' ? v : 0;
@@ -72,7 +72,7 @@ function reviews(cwd) {
72
72
  /** The outcome numbers for this checkout (metrics.ts), over every record it keeps; undefined when it keeps none. Read-only. */
73
73
  export function localOutcomeMetrics(cwd) {
74
74
  const records = Object.values(readPrOutcomes(cwd).outcomes);
75
- return records.length ? outcomeMetrics(records, readLessons(cwd), reviews(cwd).reviewed) : undefined;
75
+ return records.length ? outcomeMetrics(records, readLessons(cwd), threadReviews(cwd).reviewed) : undefined;
76
76
  }
77
77
  async function onePr(cwd, pr, exec, env) {
78
78
  const view = await exec('gh', ['pr', 'view', String(pr), '--json', 'number,mergedAt,mergeCommit,headRefName,author,state'], { cwd, timeoutMs: GH_TIMEOUT_MS, ...(env ? { env } : {}) });
@@ -13,6 +13,8 @@
13
13
  */
14
14
  import fs from 'fs';
15
15
  import path from 'path';
16
+ import { telemetryCheckId } from '../telemetry/check-ids.js';
17
+ import { countUsage } from '../telemetry/telemetry.js';
16
18
  import { readStateFile } from './trusted-state.js';
17
19
  const OUTCOMES_FILE = path.join('.rigour', 'check-outcomes.json');
18
20
  const REPORTED_FILE = path.join('.rigour', 'reported-findings.json');
@@ -49,6 +51,12 @@ export function recordOutcome(cwd, check, kind) {
49
51
  const current = outcomes[check] ?? { fixed: 0, dismissed: 0 };
50
52
  outcomes[check] = { ...current, [kind]: current[kind] + 1 };
51
53
  writeJson(path.join(cwd, OUTCOMES_FILE), outcomes);
54
+ // The same outcome, counted for opt-in telemetry by the check's gate id only, and only if it is one of Rigour's own.
55
+ countUsage(`finding_${kind}:${telemetryCheckId(gateOf(check))}`);
56
+ }
57
+ /** A check id's gate: `semantic-bugs` of `semantic-bugs: Credential header follows redirects`. */
58
+ function gateOf(check) {
59
+ return check.split(':')[0].trim();
52
60
  }
53
61
  /** Every check with an outcome, most dismissed first. */
54
62
  export function checkPrecisions(cwd) {
@@ -1,4 +1,6 @@
1
1
  import { appendAgentEvent } from './effectiveness.js';
2
+ import { countUsage } from '../telemetry/telemetry.js';
3
+ import { telemetryCheckId } from '../telemetry/check-ids.js';
2
4
  const MAX_LISTED = 20;
3
5
  /** Lessons an agent was given before or while writing code. Nothing is written when none were. */
4
6
  export function recordLessonsServed(cwd, via, subjects) {
@@ -10,6 +12,9 @@ export function recordLessonsServed(cwd, via, subjects) {
10
12
  export function recordPrCatches(cwd, findings) {
11
13
  if (findings.length === 0)
12
14
  return;
15
+ // A finding that reached a pull request's review: counted for opt-in telemetry by its gate id only, if one of Rigour's own.
16
+ for (const finding of findings)
17
+ countUsage(`finding_pushed:${telemetryCheckId(finding.id)}`);
13
18
  appendAgentEvent(cwd, {
14
19
  type: 'pr_catches',
15
20
  findings: findings.slice(0, MAX_LISTED).map(f => ({ rule: f.id, title: f.title, file: f.files?.[0] ?? '' })),
@@ -20,7 +20,7 @@ import fs from 'fs';
20
20
  import path from 'path';
21
21
  import { checkId, isMuted, readOutcomes, recordOutcome, reportedCheck } from './check-outcomes.js';
22
22
  import { readStateFile } from './trusted-state.js';
23
- const PROVEN_GATES = new Set(['semantic-bugs', 'hallucinated-imports', 'security-patterns', 'deep-analysis', 'diff-tests', 'unused-export', 'orphan-file', 'offset-paging', 'unbounded-window', 'duplicate-function', 'partial-fix', 'partial-wiring', 'migration-order',
23
+ const PROVEN_GATES = new Set(['compiled-lesson', 'semantic-bugs', 'hallucinated-imports', 'security-patterns', 'deep-analysis', 'diff-tests', 'unused-export', 'orphan-file', 'offset-paging', 'unbounded-window', 'duplicate-function', 'partial-fix', 'partial-wiring', 'migration-order',
24
24
  'duplicate-null-filter', 'nullable-filtered-column', 'optional-always-supplied', 'write-only-property', 'typed-checks-unavailable',
25
25
  'goal-scope', 'goal-done-when']);
26
26
  export const DISMISSED_FILE = path.join('.rigour', 'dismissed.json');
@@ -1,5 +1,6 @@
1
1
  import type { Config, DeepOptions, Failure, Report } from '../types/index.js';
2
2
  import { type DiffSource } from './git-diff.js';
3
+ import { type CoveredLesson } from './settled-checks.js';
3
4
  import { type Goal } from '../goal/goal.js';
4
5
  export interface ReviewInput {
5
6
  cwd: string;
@@ -52,6 +53,8 @@ export interface ReviewResult {
52
53
  typedError?: string;
53
54
  /** The goal the description declared, when the goal check ran on one with something to check. */
54
55
  goal?: Goal;
56
+ /** The lessons the team's compiled checks covered on this change: a model reviewer is told so instead of the lesson. */
57
+ covered: CoveredLesson[];
55
58
  }
56
59
  export interface ReviewFinding {
57
60
  id: string;
@@ -20,6 +20,8 @@ import { checkId, rememberReported } from './check-outcomes.js';
20
20
  import { diffFromGit } from './git-diff.js';
21
21
  import { diffTestFailures } from './diff-test-findings.js';
22
22
  import { migrationOrderFailures } from './migration-order.js';
23
+ import { compiledChecksOn } from '../review-learning/compiled-lessons.js';
24
+ import { settledChecks } from './settled-checks.js';
23
25
  import { orphanFileFailures } from './orphan-files.js';
24
26
  import { unusedExportFailures } from './unused-exports.js';
25
27
  import { queryPatternFailures } from './query-patterns.js';
@@ -37,9 +39,11 @@ export async function reviewChange(input) {
37
39
  const changedLines = withoutGenerated(input.cwd, parseDiff(diff));
38
40
  const targets = input.files?.length ? input.files : Object.keys(changedLines);
39
41
  if (targets.length === 0) {
40
- return { status: 'PASS', findings: [], fileFindings: [], contextFindings: [], advisory: [], muted: 0, dismissed: 0, dismissedByGate: {}, unlocated: 0, excludedOutsideChangedLines: 0, preexisting: 0, changedLines, report: null, gateErrors: [], controlFilesChanged: controlFiles(diff), hints: [] };
42
+ return { status: 'PASS', findings: [], fileFindings: [], contextFindings: [], advisory: [], muted: 0, dismissed: 0, dismissedByGate: {}, unlocated: 0, excludedOutsideChangedLines: 0, preexisting: 0, changedLines, report: null, gateErrors: [], controlFilesChanged: controlFiles(diff), hints: [], covered: [] };
41
43
  }
42
- const deep = input.deep ? { ...input.deep, focusLines: changedLinesByFile(changedLines), removedLines: removedByFile(diff), diff } : undefined;
44
+ // The team's compiled checks run before the deep review, so it is told what they found and which lessons they covered.
45
+ const compiled = compiledChecksOn(input.cwd, changedLines, input.config);
46
+ const deep = input.deep ? { ...input.deep, focusLines: changedLinesByFile(changedLines), removedLines: removedByFile(diff), diff, settled: settledChecks(compiled.failures), covered: compiled.covered } : undefined;
43
47
  // The team's `commands:` run at push (toolchain.ts), where a failure blocks; here they would only cost time.
44
48
  const report = await new GateRunner({ ...input.config, commands: {} }).run(input.cwd, await normalizeScopePatterns(input.cwd, targets), deep);
45
49
  const { preexisting, baseUnknown } = await dropPreexisting(input, report, targets);
@@ -54,6 +58,7 @@ export async function reviewChange(input) {
54
58
  reviewCheck('migration-order', 'migration_order', migrationOrderFailures(input.cwd, diff, input.source, input.config));
55
59
  reviewCheck('unused-exports', 'unused_exports', unusedExportFailures(input.cwd, diff, input.config));
56
60
  reviewCheck('orphan-files', 'orphan_files', orphanFileFailures(input.cwd, diff, input.config));
61
+ reviewCheck('compiled-lessons', 'compiled_lessons', compiled.failures);
57
62
  reviewCheck('query-patterns', 'query_patterns', queryPatternFailures(input.cwd, changedLines, input.config));
58
63
  reviewCheck('optional-params', 'optional_params', optionalParamFailures(input.cwd, changedLines, input.config));
59
64
  reviewCheck('duplicate-functions', 'duplicate_functions', duplicateFunctionFailures(input.cwd, changedLines, input.config));
@@ -97,6 +102,7 @@ export async function reviewChange(input) {
97
102
  gateErrors,
98
103
  controlFilesChanged: controlFiles(diff),
99
104
  hints: typed.hints,
105
+ covered: compiled.covered,
100
106
  ...(deepError ? { deepError } : {}),
101
107
  ...(typed.error ? { typedError: typed.error } : {}),
102
108
  ...(checkedGoal ? { goal: checkedGoal } : {}),
@@ -3,6 +3,7 @@ import type { RouterPolicy } from '../../deep/router.js';
3
3
  import type { PanelItem } from './panel.js';
4
4
  import { type Exec } from './exec.js';
5
5
  import type { OpenItem, ServedRule } from './verdict.js';
6
+ import { type CoveredLesson } from '../settled-checks.js';
6
7
  export declare const REVIEW_DISMISSALS: string;
7
8
  export interface ReviewDismissal {
8
9
  id: string;
@@ -43,18 +44,29 @@ export interface ContextInput {
43
44
  }>;
44
45
  /** Findings Rigour's checks already report on this change, one line each. */
45
46
  checks: string[];
47
+ /** Lessons the team's compiled checks covered on this change: left out of the lessons, and said so. */
48
+ covered?: CoveredLesson[];
46
49
  }
47
50
  /** A review's result as the reviewer's inputs: its hints, and what its checks found, as settled. */
48
51
  export declare function reviewerInputs(review: {
49
52
  hints: string[];
50
53
  findings: Array<{
54
+ id?: string;
51
55
  files?: string[];
52
56
  line?: number;
53
57
  title: string;
54
58
  }>;
59
+ advisory?: Array<{
60
+ id?: string;
61
+ files?: string[];
62
+ line?: number;
63
+ title: string;
64
+ }>;
65
+ covered?: CoveredLesson[];
55
66
  }): {
56
67
  hints: string;
57
68
  checks: string[];
69
+ covered: CoveredLesson[];
58
70
  };
59
71
  /** A lesson as the judge was shown it: its id, and the line it was listed as (the judge answers by that line). */
60
72
  export interface ServedLesson {
@@ -20,7 +20,7 @@ import { reviewedKeys } from '../ledger.js';
20
20
  import { textSimilarity } from './consensus.js';
21
21
  import { defaultExec, GH_TIMEOUT_MS } from './exec.js';
22
22
  import { VerdictStore } from './store.js';
23
- import { MAX_SETTLED, settledChecks, settledLine, settledSection } from '../settled-checks.js';
23
+ import { coveredSection, MAX_SETTLED, settledChecks, settledLine, settledSection, withoutCovered } from '../settled-checks.js';
24
24
  export const REVIEW_DISMISSALS = path.join('.rigour', 'dismissed-review-items.json');
25
25
  const MAX_DOCS = 10;
26
26
  /** Team standards a judge is shown with the lessons about the changed files. */
@@ -68,7 +68,9 @@ export function dismissedAs(item, dismissals) {
68
68
  }
69
69
  /** A review's result as the reviewer's inputs: its hints, and what its checks found, as settled. */
70
70
  export function reviewerInputs(review) {
71
- return { hints: review.hints.join('\n'), checks: settledChecks(review.findings).map(settledLine) };
71
+ // A compiled check's findings are notes, and settled too: the judge no longer has the lesson, so it must have them.
72
+ const compiled = (review.advisory ?? []).filter(f => f.id === 'compiled-lesson');
73
+ return { hints: review.hints.join('\n'), checks: settledChecks([...review.findings, ...compiled]).map(settledLine), covered: review.covered ?? [] };
72
74
  }
73
75
  /** The ids of the served lessons the judge said this change repeats; an answer that names no served lesson says nothing. */
74
76
  export function lessonsApplied(answers, served) {
@@ -87,13 +89,15 @@ export function buildContext(input) {
87
89
  task = undefined;
88
90
  }
89
91
  // A judge reads the whole pull request: more of what the team taught fits than an agent's one question at the stop.
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)) }));
92
+ const servedLessons = input.lessons === 'off' ? [] : withoutCovered(lessonsForDiff(input.cwd, input.diff, input.lessons, JUDGE_STANDARDS, JUDGE_FILE_LESSONS, JUDGE_LESSONS_PER_FILE, input.pr), input.covered ?? []).map(l => ({ id: l.id, listed: describeLesson(lessonView(l)) }));
91
93
  if (servedLessons.length)
92
94
  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')}`);
93
95
  // The repository's own rules, always: the reviewer is the boundary, and what the team wrote is the standard it checks.
94
96
  const rules = rulesForDiff(input.cwd, input.diff, true, JUDGE_RULES).map((r) => ({ id: r.id, source: r.source, text: r.text, requirement: r.requirement }));
95
97
  if (rules.length)
96
98
  sections.push(`## Rules this repository wrote for itself that apply to this change (answer every one in rules, by id)\n${rules.map(r => `- [${r.id}] (${r.source}, ${r.requirement ? 'requirement' : 'guidance'}) ${r.text}`).join('\n')}`);
99
+ if (input.covered?.length)
100
+ sections.push(coveredSection(input.covered));
97
101
  if (input.checks.length)
98
102
  sections.push(settledSection(input.checks));
99
103
  const rejected = input.lessons === 'off' ? [] : rejectedForDiff(input.cwd, input.diff).map(l => {
@@ -5,6 +5,7 @@ import { type PanelItem } from './reviewer/panel.js';
5
5
  import { type RunChoice, type Source } from './reviewer/settings.js';
6
6
  import { type ReviewRecord } from './reviewer/record.js';
7
7
  import { type Accounting, type OpenItem, type PriorPoint } from './reviewer/verdict.js';
8
+ import type { CoveredLesson } from './settled-checks.js';
8
9
  export { defaultExec, githubEnv, githubToken, parseJsonArrays, type Exec, type Progress } from './reviewer/exec.js';
9
10
  export { itemLine, type OpenItem } from './reviewer/verdict.js';
10
11
  export type ReviewerOutcome = 'passed' | 'findings' | 'unavailable' | 'skipped';
@@ -29,6 +30,8 @@ export interface ReviewerOptions {
29
30
  hints?: string;
30
31
  /** What Rigour's checks already found on this change: settled, so no judge spends a turn finding it again. */
31
32
  checks?: string[];
33
+ /** Lessons the team's compiled checks covered on this change (review.ts): left out of the judge's lessons, and said so. */
34
+ covered?: CoveredLesson[];
32
35
  /** Where the team's state lives (.rigour: dismissals, reviewed functions) when cwd is a worktree that lacks it. */
33
36
  stateRoot?: string;
34
37
  /** The branch the commit was pushed from, when reviewing it in a detached worktree (background.ts). */
@@ -177,7 +177,7 @@ async function review(cwd, base, config, exec, progress, options) {
177
177
  const sincePrevious = previousIsAncestor ? new Set((await git(['diff', '--name-only', `${previous.head}..HEAD`])).split('\n').filter(Boolean)) : new Set();
178
178
  const changedFiles = [...fullDiff.matchAll(/^diff --git a\/.* b\/(.*)$/gm)].map(m => m[1]);
179
179
  const context = buildContext({
180
- cwd, stateRoot, dismissals, diff: fullDiff, router: config.gates.deep?.router, lessons: config.gates.deep?.review_lessons, ...(pr ? { pr: pr.number } : {}), touched: sincePrevious, checks: options.checks ?? [],
180
+ cwd, stateRoot, dismissals, diff: fullDiff, router: config.gates.deep?.router, lessons: config.gates.deep?.review_lessons, ...(pr ? { pr: pr.number } : {}), touched: sincePrevious, checks: options.checks ?? [], covered: options.covered ?? [],
181
181
  previousPanel: previousIsAncestor ? store.readJson(previous.verdict)?.panel?.items : undefined,
182
182
  docs: await relatedDocs(cwd, changedFiles, exec),
183
183
  });
@@ -1,6 +1,5 @@
1
1
  /**
2
- * What Rigour's checks already found on a change, as a model reviewer is told it: settled, blocking on their own, never
3
- * to be reported again. The judge (reviewer/context.ts) and the deep PR review (deep/pr-review.ts) both say it this way,
2
+ * What Rigour's checks already found on a change, as a model reviewer is told it: settled, never to be reported again. The judge (reviewer/context.ts) and the deep PR review (deep/pr-review.ts) both say it this way,
4
3
  * so neither spends a model's turn on what a free check proves.
5
4
  */
6
5
  /** A check's finding where it sits; `kind` is the check's id. */
@@ -37,3 +36,15 @@ export declare function settledChecks(findings: Array<{
37
36
  export declare function settledLine(check: SettledCheck): string;
38
37
  /** The prompt section; empty when there is nothing settled. */
39
38
  export declare function settledSection(lines: string[]): string;
39
+ /** A lesson a compiled check covered on this change: the check ran on its files, and its findings are settled. */
40
+ export interface CoveredLesson {
41
+ checkId: string;
42
+ lessonId: string;
43
+ message: string;
44
+ }
45
+ /** A model reviewer's lessons without those a compiled check covered on this change: the check said them for free. */
46
+ export declare function withoutCovered<L extends {
47
+ id: string;
48
+ }>(lessons: L[], covered: CoveredLesson[]): L[];
49
+ /** The prompt section saying which lessons a compiled check covered instead; empty when none did. */
50
+ export declare function coveredSection(covered: CoveredLesson[]): string;
@@ -1,6 +1,5 @@
1
1
  /**
2
- * What Rigour's checks already found on a change, as a model reviewer is told it: settled, blocking on their own, never
3
- * to be reported again. The judge (reviewer/context.ts) and the deep PR review (deep/pr-review.ts) both say it this way,
2
+ * What Rigour's checks already found on a change, as a model reviewer is told it: settled, never to be reported again. The judge (reviewer/context.ts) and the deep PR review (deep/pr-review.ts) both say it this way,
4
3
  * so neither spends a model's turn on what a free check proves.
5
4
  */
6
5
  /**
@@ -43,5 +42,14 @@ export function settledLine(check) {
43
42
  }
44
43
  /** The prompt section; empty when there is nothing settled. */
45
44
  export function settledSection(lines) {
46
- return lines.length ? `## Already found by Rigour's checks: they block on their own, so do not report them again\n${lines.slice(0, MAX_SETTLED).map(c => `- ${c}`).join('\n')}` : '';
45
+ return lines.length ? `## Already found by Rigour's checks on this change: do not report them again\n${lines.slice(0, MAX_SETTLED).map(c => `- ${c}`).join('\n')}` : '';
46
+ }
47
+ /** A model reviewer's lessons without those a compiled check covered on this change: the check said them for free. */
48
+ export function withoutCovered(lessons, covered) {
49
+ const ids = new Set(covered.map(c => c.lessonId));
50
+ return ids.size ? lessons.filter(l => !ids.has(l.id)) : lessons;
51
+ }
52
+ /** The prompt section saying which lessons a compiled check covered instead; empty when none did. */
53
+ export function coveredSection(covered) {
54
+ return covered.length ? `## Lessons the team's compiled checks covered on this change (their findings are listed as already found)\n${covered.map(c => `- covered by compiled check ${c.checkId} for lesson ${c.lessonId}: ${c.message}`).join('\n')}` : '';
47
55
  }
@@ -0,0 +1,62 @@
1
+ import type { Config, Failure } from '../types/index.js';
2
+ import { type ReviewLesson } from './lessons.js';
3
+ import type { CoveredLesson } from '../review/settled-checks.js';
4
+ export interface CompiledCheck {
5
+ id: string;
6
+ lessonId: string;
7
+ /** The files it applies to: the lesson's file, or a glob a person widened it to. */
8
+ files: string;
9
+ kind: 'forbid' | 'require';
10
+ /** The symbol that triggers it. */
11
+ symbol: string;
12
+ /** For `require`: the symbol that must be near the trigger. */
13
+ with?: string;
14
+ message: string;
15
+ /** Proposed by `compileLesson`; runs only once `active` (a person approved it); `withdrawn` when taken back. */
16
+ state: 'proposed' | 'active' | 'withdrawn';
17
+ /** Who made the latest decision, and when (the full trail is `history`). */
18
+ by?: string;
19
+ at: string;
20
+ /** Every decision on it, oldest first: nothing a later decision overwrites is lost. */
21
+ history?: Array<{
22
+ state: 'active' | 'withdrawn';
23
+ by: string;
24
+ at: string;
25
+ }>;
26
+ /**
27
+ * The check run over the main branch's history, for the person who approves it: on merged pull requests a review
28
+ * found the lesson repeating in (it should fire), and on every other merged change to its files (each fire is a
29
+ * false one, or a catch the review missed). Counts, never a rate below RATE_MIN.
30
+ */
31
+ backtest?: {
32
+ repeating: BacktestShare;
33
+ other: BacktestShare;
34
+ commits: number;
35
+ at: string;
36
+ };
37
+ }
38
+ /** Fires out of n, with the rate computed here, the one place RATE_MIN applies: null below it. */
39
+ export interface BacktestShare {
40
+ fired: number;
41
+ n: number;
42
+ rate: number | null;
43
+ }
44
+ /**
45
+ * Proposes a check for every compilable lesson that has none yet, each backtested against the main branch's history
46
+ * (new proposals only); returns them. A decided check is never re-proposed.
47
+ */
48
+ export declare function proposeCompiledChecks(cwd: string): CompiledCheck[];
49
+ /** A person's decision on a compiled check: approve it (it runs) or take it back (it stops). Undefined when there is no such check. */
50
+ export declare function decideCompiledCheck(cwd: string, id: string, state: 'active' | 'withdrawn', by: string): CompiledCheck | undefined;
51
+ /** Whether an approved check is suspended, and why: its lesson no longer qualifies. */
52
+ export declare function suspension(check: CompiledCheck, lessons: Map<string, ReviewLesson>): string | undefined;
53
+ export declare function readCompiledChecks(cwd: string): CompiledCheck[];
54
+ /**
55
+ * The running compiled checks on a change: their findings on its changed lines (notes unless the team blocks), and the
56
+ * lessons they covered (each check whose files the change touched, whether or not it found anything). A model reviewer
57
+ * may leave a covered lesson out of its prompt; nothing else.
58
+ */
59
+ export declare function compiledChecksOn(cwd: string, changedLines: Record<string, Set<number>>, config: Config): {
60
+ failures: Failure[];
61
+ covered: CoveredLesson[];
62
+ };
@@ -0,0 +1,249 @@
1
+ /**
2
+ * A verified lesson turned into a check that runs without a model. Only lessons a person confirmed (accepted, a
3
+ * correction) or that recurred across pull requests qualify, never one an outcome alone promoted. Compilation is a
4
+ * template, not a model: a lesson compiles only when it names its file and its symbols in backticks and says, in so many
5
+ * words, what is wrong:
6
+ *
7
+ * forbid "never `a`", "avoid `a`", "do not use `a`", "use `b` instead of `a`" → `a` on a changed line is reported
8
+ * require "always …", "must …", "every …" naming `a` then `b` → `a` on a changed line with no `b`
9
+ * within REQUIRE_REACH lines is reported
10
+ *
11
+ * A compiled check is stored in .rigour/compiled-checks.json (committed and reviewed with the code) and runs only once a
12
+ * person approved it (`active`). It keeps its lesson's id, never blocks unless the team turns `block` on, and a person
13
+ * can take it back (`withdrawn`), which hands the lesson back to the model reviewer. Every decision is kept, on the
14
+ * check and as evidence on its lesson. An approved check whose lesson no longer qualifies (rejected, taken back) is
15
+ * suspended: it does not run, and the lesson goes back to the model reviewer.
16
+ */
17
+ import fs from 'fs';
18
+ import path from 'path';
19
+ import micromatch from 'micromatch';
20
+ import { execFileSync } from 'child_process';
21
+ import { lessonState, readLessons, writeLessons } from './lessons.js';
22
+ import { branchBase } from '../gates/logic-drift-git-base.js';
23
+ import { threadReviews } from '../outcomes/run.js';
24
+ import { parseDiff } from '../utils/diff.js';
25
+ const STORE = path.join('.rigour', 'compiled-checks.json');
26
+ /** How far from the trigger line the required symbol may sit for a `require` check. */
27
+ const REQUIRE_REACH = 3;
28
+ /** Fewer than this: a count, never a rate. */
29
+ const RATE_MIN = 10;
30
+ /** Merged changes to a check's files read back, newest first. */
31
+ const BACKTEST_COMMITS = 100;
32
+ /** Each backtest stops here and keeps what it counted. */
33
+ const BACKTEST_DEADLINE_MS = 20_000;
34
+ /** All of one proposal round's backtests stop here; a check not reached is proposed without its counts. */
35
+ const PROPOSE_DEADLINE_MS = 30_000;
36
+ const FORBID = [
37
+ /\b(?:use|prefer)\s+`([^`]+)`\s+(?:instead of|over|rather than)\s+`([^`]+)`/i, // the second is forbidden
38
+ // "never forget to call `x`" asks for `x`: a negation followed by one of these is not a ban.
39
+ /\b(?:never|avoid|don't|do not|must not|should not|no longer)\b(?!\s+(?:forget|remove|skip|miss|omit|drop|leave out|lose|stop)\b)[^.`]*`([^`]+)`/i,
40
+ ];
41
+ const REQUIRE = /\b(?:always|must|every|needs? to|has to)\b/i;
42
+ /** Whether a lesson may be compiled: verified by a person, a correction or recurrence, never by an outcome alone. */
43
+ function compilable(lesson) {
44
+ if (!lesson)
45
+ return false;
46
+ const { state, promotedBy } = lessonState(lesson);
47
+ return state === 'verified' && (promotedBy === 'person' || promotedBy === 'correction' || promotedBy === 'recurrence') && !!lesson.file && lesson.symbols.length > 0;
48
+ }
49
+ /** The check a lesson compiles to, proposed for a person to approve; undefined when no template fits it. */
50
+ function compileLesson(lesson, at = new Date().toISOString()) {
51
+ if (!compilable(lesson))
52
+ return undefined;
53
+ const named = (s) => (s && lesson.symbols.includes(s.replace(/\(\)$/, '')) ? s.replace(/\(\)$/, '') : undefined);
54
+ const base = { id: `c-${lesson.id}`, lessonId: lesson.id, files: lesson.file, message: lesson.text, state: 'proposed', at };
55
+ const instead = FORBID[0].exec(lesson.text);
56
+ const forbidden = instead ? named(instead[2]) : named(FORBID[1].exec(lesson.text)?.[1]);
57
+ if (forbidden)
58
+ return { ...base, kind: 'forbid', symbol: forbidden };
59
+ if (REQUIRE.test(lesson.text)) {
60
+ const ticked = [...lesson.text.matchAll(/`([^`]+)`/g)].map(m => named(m[1])).filter((s) => !!s);
61
+ if (ticked.length >= 2 && ticked[0] !== ticked[1])
62
+ return { ...base, kind: 'require', symbol: ticked[0], with: ticked[1] };
63
+ }
64
+ return undefined;
65
+ }
66
+ /**
67
+ * Proposes a check for every compilable lesson that has none yet, each backtested against the main branch's history
68
+ * (new proposals only); returns them. A decided check is never re-proposed.
69
+ */
70
+ export function proposeCompiledChecks(cwd) {
71
+ const checks = readCompiledChecks(cwd);
72
+ const known = new Set(checks.map(c => c.lessonId));
73
+ const proposed = readLessons(cwd).filter(l => !known.has(l.id)).map(l => compileLesson(l)).filter((c) => !!c);
74
+ if (proposed.length === 0)
75
+ return [];
76
+ withBacktests(cwd, proposed); // only the new ones: a decided or earlier proposal keeps the counts it was shown with
77
+ writeCompiledChecks(cwd, [...checks, ...proposed]);
78
+ return proposed;
79
+ }
80
+ /** A person's decision on a compiled check: approve it (it runs) or take it back (it stops). Undefined when there is no such check. */
81
+ export function decideCompiledCheck(cwd, id, state, by) {
82
+ if (!by.trim())
83
+ throw new Error('a compiled check is decided by a named person: their git email is committed with it');
84
+ const checks = readCompiledChecks(cwd);
85
+ const check = checks.find(c => c.id === id);
86
+ if (!check)
87
+ return undefined;
88
+ const lessons = readLessons(cwd);
89
+ const lesson = lessons.find(l => l.id === check.lessonId);
90
+ if (state === 'active' && !(lesson && compilable(lesson)))
91
+ throw new Error(`lesson ${check.lessonId} no longer qualifies (rejected, taken back, or gone): its check cannot run`);
92
+ const at = new Date().toISOString();
93
+ check.history = [...(check.history ?? []), { state, by, at }];
94
+ Object.assign(check, { state, by, at });
95
+ writeCompiledChecks(cwd, checks);
96
+ // The lesson keeps the decision too: what its check did is part of its trail.
97
+ if (lesson) {
98
+ lesson.evidence.push({ kind: 'compiled', pr: lesson.evidence[0]?.pr ?? 0, comment: `compiled-${check.id}-${at}`, author: by, detail: `${state === 'active' ? 'approved' : 'took back'} compiled check ${check.id}`, at });
99
+ writeLessons(cwd, lessons);
100
+ }
101
+ return check;
102
+ }
103
+ /**
104
+ * The approved checks that run: an approved check whose lesson no longer qualifies (a person rejected it, evidence
105
+ * took it back, it is gone) is suspended, and its lesson goes back to the model reviewer.
106
+ */
107
+ function runningChecks(cwd) {
108
+ const lessons = new Map(readLessons(cwd).map(l => [l.id, l]));
109
+ return readCompiledChecks(cwd).filter(c => c.state === 'active' && compilable(lessons.get(c.lessonId)));
110
+ }
111
+ /** Whether an approved check is suspended, and why: its lesson no longer qualifies. */
112
+ export function suspension(check, lessons) {
113
+ if (check.state !== 'active')
114
+ return undefined;
115
+ const lesson = lessons.get(check.lessonId);
116
+ if (!lesson)
117
+ return `lesson ${check.lessonId} is gone`;
118
+ return compilable(lesson) ? undefined : `lesson ${check.lessonId} no longer qualifies (${lessonState(lesson).state}): suspended, the lesson is back with the model reviewer`;
119
+ }
120
+ export function readCompiledChecks(cwd) {
121
+ try {
122
+ const data = JSON.parse(fs.readFileSync(path.join(cwd, STORE), 'utf8'));
123
+ return Array.isArray(data?.checks) ? data.checks : [];
124
+ }
125
+ catch {
126
+ return [];
127
+ }
128
+ }
129
+ function writeCompiledChecks(cwd, checks) {
130
+ fs.mkdirSync(path.join(cwd, '.rigour'), { recursive: true });
131
+ fs.writeFileSync(path.join(cwd, STORE), `${JSON.stringify({ version: 1, checks }, null, 2)}\n`);
132
+ }
133
+ const word = (symbol) => new RegExp(`(^|[^\\w$])${symbol.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^\\w$]|$)`);
134
+ /** Whether a check applies to a file: the file itself, or a glob it was widened to. */
135
+ function covers(check, file) {
136
+ return /[*?[\]{}]/.test(check.files) ? micromatch.isMatch(file, check.files, { dot: true }) : check.files === file;
137
+ }
138
+ /**
139
+ * The running compiled checks on a change: their findings on its changed lines (notes unless the team blocks), and the
140
+ * lessons they covered (each check whose files the change touched, whether or not it found anything). A model reviewer
141
+ * may leave a covered lesson out of its prompt; nothing else.
142
+ */
143
+ export function compiledChecksOn(cwd, changedLines, config) {
144
+ const settings = config.gates.compiled_lessons;
145
+ if (!settings?.enabled)
146
+ return { failures: [], covered: [] };
147
+ const checks = runningChecks(cwd);
148
+ if (checks.length === 0)
149
+ return { failures: [], covered: [] };
150
+ const failures = [];
151
+ const ran = new Set();
152
+ for (const [file, lines] of Object.entries(changedLines)) {
153
+ const applying = checks.filter(c => covers(c, file));
154
+ if (applying.length === 0)
155
+ continue;
156
+ applying.forEach(c => ran.add(c));
157
+ let text;
158
+ try {
159
+ text = fs.readFileSync(path.join(cwd, file), 'utf8').split('\n');
160
+ }
161
+ catch {
162
+ continue;
163
+ }
164
+ for (const check of applying) {
165
+ for (const line of firesOn(check, text, lines)) {
166
+ failures.push({
167
+ id: 'compiled-lesson',
168
+ ...(settings.block ? {} : { advisory: true }),
169
+ title: check.kind === 'forbid' ? `\`${check.symbol}\`: the team's lesson says not to` : `\`${check.symbol}\` without \`${check.with}\`: the team's lesson says to pair them`,
170
+ details: `${check.message} (compiled from lesson ${check.lessonId}; a person approved it)`,
171
+ severity: 'medium',
172
+ provenance: 'traditional',
173
+ files: [file],
174
+ line,
175
+ hint: `Follow the lesson, or take the compiled check back in Studio if it no longer holds (lesson ${check.lessonId}).`,
176
+ });
177
+ }
178
+ }
179
+ }
180
+ return { failures, covered: [...ran].map(c => ({ checkId: c.id, lessonId: c.lessonId, message: c.message })) };
181
+ }
182
+ /** The changed lines a check fires on, in a file's text. */
183
+ function firesOn(check, text, lines) {
184
+ const trigger = word(check.symbol);
185
+ return [...lines].sort((a, b) => a - b).filter(line => {
186
+ if (!trigger.test(text[line - 1] ?? ''))
187
+ return false;
188
+ if (check.kind === 'forbid')
189
+ return true;
190
+ return !word(check.with).test(text.slice(Math.max(0, line - 1 - REQUIRE_REACH), line + REQUIRE_REACH).join('\n'));
191
+ });
192
+ }
193
+ /**
194
+ * Runs a check over the main branch's last BACKTEST_COMMITS changes to its files: per change, whether it fires on the
195
+ * lines that change added, split by whether a review found the lesson repeating in that pull request. Read-only, no model.
196
+ */
197
+ function backtestCheck(cwd, check, mainRef, applied) {
198
+ const git = (args) => execFileSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, timeout: 10_000 });
199
+ const spec = /[*?[\]{}]/.test(check.files) ? `:(glob)${check.files}` : check.files;
200
+ const result = { repeating: { fired: 0, n: 0, rate: null }, other: { fired: 0, n: 0, rate: null }, commits: 0, at: new Date().toISOString() };
201
+ const deadline = Date.now() + BACKTEST_DEADLINE_MS;
202
+ let log = '';
203
+ try {
204
+ log = git(['log', '--first-parent', '-n', String(BACKTEST_COMMITS), '--format=%H %s', mainRef, '--', spec]);
205
+ }
206
+ catch {
207
+ return withRates(result);
208
+ }
209
+ for (const entry of log.split('\n').filter(Boolean)) {
210
+ if (Date.now() > deadline)
211
+ break;
212
+ const [sha, ...subject] = entry.split(' ');
213
+ const pr = Number(/\(#(\d+)\)\s*$|Merge pull request #(\d+)/.exec(subject.join(' '))?.slice(1).find(Boolean));
214
+ let fired = false;
215
+ try {
216
+ for (const [file, lines] of Object.entries(parseDiff(git(['diff', '-U0', `${sha}^1`, sha, '--', spec])))) {
217
+ if (firesOn(check, git(['show', `${sha}:${file}`]).split('\n'), lines).length)
218
+ fired = true;
219
+ }
220
+ }
221
+ catch {
222
+ continue; // a root commit or a file the commit removed: nothing to count
223
+ }
224
+ const bucket = pr && applied.get(pr)?.has(check.lessonId) ? result.repeating : result.other;
225
+ bucket.n++;
226
+ if (fired)
227
+ bucket.fired++;
228
+ result.commits++;
229
+ }
230
+ return withRates(result);
231
+ }
232
+ function withRates(result) {
233
+ for (const share of [result.repeating, result.other])
234
+ share.rate = share.n >= RATE_MIN ? Math.round((share.fired / share.n) * 100) / 100 : null;
235
+ return result;
236
+ }
237
+ /** Backtests new proposals, all within PROPOSE_DEADLINE_MS: what the person approving each one reads. */
238
+ function withBacktests(cwd, checks) {
239
+ const mainRef = branchBase(cwd)?.mainRef;
240
+ if (!mainRef)
241
+ return;
242
+ const { applied } = threadReviews(cwd);
243
+ const deadline = Date.now() + PROPOSE_DEADLINE_MS;
244
+ for (const check of checks) {
245
+ if (Date.now() > deadline)
246
+ break;
247
+ check.backtest = backtestCheck(cwd, check, mainRef, applied);
248
+ }
249
+ }
@@ -18,7 +18,7 @@ import type { Git, ReviewBody, ReviewComment } from './acted-on.js';
18
18
  * until a person promotes it.
19
19
  * A record from before evidence kinds has none: it is a `point`.
20
20
  */
21
- export type EvidenceKind = 'point' | 'outcome' | 'counter' | 'correction' | 'accepted' | 'rejected' | 'norule' | 'followup' | 'lines' | 'dismissed' | 'against' | 'demoted' | 'reclassified';
21
+ export type EvidenceKind = 'point' | 'outcome' | 'counter' | 'correction' | 'accepted' | 'rejected' | 'norule' | 'followup' | 'lines' | 'dismissed' | 'against' | 'demoted' | 'reclassified' | 'compiled';
22
22
  export interface LessonEvidence {
23
23
  kind?: EvidenceKind;
24
24
  pr: number;
@@ -227,6 +227,10 @@ export declare const SWITCHES: {
227
227
  allow: string[];
228
228
  block: boolean;
229
229
  };
230
+ compiled_lessons: {
231
+ enabled: boolean;
232
+ block: boolean;
233
+ };
230
234
  migration_order: {
231
235
  enabled: boolean;
232
236
  dirs: string[];
@@ -536,6 +540,10 @@ export declare const SWITCHES: {
536
540
  allow: string[];
537
541
  block: boolean;
538
542
  };
543
+ compiled_lessons: {
544
+ enabled: boolean;
545
+ block: boolean;
546
+ };
539
547
  migration_order: {
540
548
  enabled: boolean;
541
549
  dirs: string[];
@@ -845,6 +853,10 @@ export declare const SWITCHES: {
845
853
  allow: string[];
846
854
  block: boolean;
847
855
  };
856
+ compiled_lessons: {
857
+ enabled: boolean;
858
+ block: boolean;
859
+ };
848
860
  migration_order: {
849
861
  enabled: boolean;
850
862
  dirs: string[];
@@ -0,0 +1,2 @@
1
+ /** A check id as telemetry may send it: one of Rigour's own, else `custom`. */
2
+ export declare function telemetryCheckId(id: string): string;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The check ids opt-in telemetry may send: Rigour's own, and nothing else. A team's own check (a `commands:` entry, a
3
+ * plugin, anything named in config) can carry a product or customer name, so it is sent only as `custom`. The list is
4
+ * the gate registry's ids, the review's own checks and the hook's: check-ids.test.ts builds the registry and fails
5
+ * when a gate is missing here.
6
+ */
7
+ const BUILT_IN = new Set([
8
+ // Gates (gates/runner.ts registers them)
9
+ 'agent-team', 'ast-analysis', 'checkpoint', 'content-check', 'context-drift', 'context-window-artifacts', 'coverage-guard', 'deep-analysis',
10
+ 'dependency-guardian', 'deprecated-apis', 'deprecated-dependencies', 'duplication-drift', 'environment-alignment', 'file-guard', 'file-size',
11
+ 'frontend-secret-exposure', 'hallucinated-imports', 'inconsistent-error-handling', 'logic-drift', 'phantom-apis', 'promise-safety',
12
+ 'retry_loop_breaker', 'security-patterns', 'semantic-bugs', 'side-effect-analysis', 'structure-check', 'style-drift', 'test-quality', 'unindexed-reads',
13
+ // The review's own checks (review/review.ts) and their typed checks
14
+ 'unused-export', 'orphan-file', 'offset-paging', 'unbounded-window', 'duplicate-function', 'partial-fix', 'partial-wiring', 'quadratic-copy',
15
+ 'migration-order', 'diff-tests', 'compiled-lesson', 'goal-scope', 'goal-done-when', 'duplicate-null-filter', 'nullable-filtered-column',
16
+ 'optional-always-supplied', 'write-only-property', 'typed-checks-unavailable',
17
+ // The per-edit hook's (hooks/checker.ts)
18
+ 'agent-scope', 'governance', 'governance-dlp', 'governance-skills',
19
+ ]);
20
+ /** A check id as telemetry may send it: one of Rigour's own, else `custom`. */
21
+ export function telemetryCheckId(id) {
22
+ return BUILT_IN.has(id) ? id : 'custom';
23
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * What the learning loop did in a repository, for opt-in telemetry: read from the two places that already say it, the
3
+ * switches' resolver and the outcome numbers (outcomes/metrics.ts), never counted again here. Enums and counts only; a
4
+ * rate only where outcomeMetrics gives one (ten or more records), else none.
5
+ */
6
+ import type { Config } from '../types/index.js';
7
+ export declare function learningUsage(cwd: string, config: Config): Record<string, unknown>;
@@ -0,0 +1,33 @@
1
+ import { resolveSwitch, SWITCHES } from '../switches.js';
2
+ import { localOutcomeMetrics } from '../outcomes/run.js';
3
+ export function learningUsage(cwd, config) {
4
+ const usage = {};
5
+ for (const name of Object.keys(SWITCHES)) {
6
+ const resolved = resolveSwitch(name, config);
7
+ usage[`switch_${name}`] = resolved.required ? 'required' : resolved.enabled ? 'on' : 'off';
8
+ usage[`switch_${name}_set_by`] = resolved.source; // team (rigour.yml), user (personal settings), env or flag
9
+ }
10
+ const metrics = localOutcomeMetrics(cwd);
11
+ if (!metrics)
12
+ return usage;
13
+ const share = (prefix, s) => {
14
+ usage[`${prefix}_count`] = s.count;
15
+ usage[`${prefix}_of`] = s.of;
16
+ if (s.rate !== null)
17
+ usage[`${prefix}_rate`] = s.rate;
18
+ };
19
+ usage.outcomes_merged = metrics.records.merged;
20
+ usage.outcomes_settled = metrics.records.settled;
21
+ share('outcomes_ci_regressed', metrics.settled.ciRegressed);
22
+ share('outcomes_reverted', metrics.settled.reverted);
23
+ share('outcomes_fixed_later', metrics.settled.fixedLater);
24
+ usage.outcomes_reviewed_prs = metrics.settled.reviewed.prs;
25
+ share('outcomes_reviewed_fixed_later', metrics.settled.reviewed.fixedLater);
26
+ usage.outcomes_not_reviewed_prs = metrics.settled.notReviewed.prs;
27
+ share('outcomes_not_reviewed_fixed_later', metrics.settled.notReviewed.fixedLater);
28
+ usage.lessons_awaiting_decision = metrics.lessons.awaitingDecision;
29
+ usage.lessons_promoted_from_evidence = metrics.lessons.promotedFromEvidence;
30
+ usage.lessons_dismissed = metrics.lessons.dismissed;
31
+ usage.lessons_taken_back = metrics.lessons.takenBack;
32
+ return usage;
33
+ }
@@ -4,6 +4,8 @@ export interface TelemetryState {
4
4
  /** undefined until the person has been asked. */
5
5
  enabled?: boolean;
6
6
  installId: string;
7
+ /** When the person was first asked (ms): how old the install is, bucketed. Absent in files written before it was kept. */
8
+ firstAt?: number;
7
9
  }
8
10
  export interface TelemetryDeps {
9
11
  env?: Env;
@@ -11,7 +13,13 @@ export interface TelemetryDeps {
11
13
  fetch?: Fetch;
12
14
  version?: string;
13
15
  now?: number;
16
+ /** The agent a hook ran for, when the hook knows (its own tool name): wins over the environment. */
17
+ agent?: string;
18
+ /** More of the daily event, read only when it is sent (learning-usage.ts): the learning loop of the repository the command ran in. */
19
+ daily?: () => Record<string, unknown>;
14
20
  }
21
+ /** The agent hosts telemetry names, and nothing else: anything unknown is `other`, no agent is `none`. */
22
+ export type AgentHost = 'claude-code' | 'cursor' | 'cline' | 'windsurf' | 'codex' | 'other' | 'none';
15
23
  export declare function telemetryToken(env?: Env): string;
16
24
  export declare function readTelemetryState(home?: string): TelemetryState;
17
25
  export declare function setTelemetryEnabled(enabled: boolean, home?: string): TelemetryState;
@@ -22,6 +22,44 @@ import { MIXPANEL_TOKEN } from './token.js';
22
22
  const ENDPOINT = 'https://api.mixpanel.com/track?ip=0';
23
23
  const TIMEOUT_MS = 2000;
24
24
  const DAY_MS = 24 * 60 * 60 * 1000;
25
+ const HOOK_TOOLS = { claude: 'claude-code', 'claude-code': 'claude-code', cursor: 'cursor', cline: 'cline', windsurf: 'windsurf', codex: 'codex' };
26
+ /**
27
+ * Which agent Rigour ran under: the hook's own tool name when it is one of the known; else the variable each sets in
28
+ * the processes it starts (CLAUDECODE=1 for Claude Code, CURSOR_TRACE_ID for Cursor's terminal, CODEX_SANDBOX for
29
+ * Codex); else `other` for a hint naming an agent not on the list, `none` without one. Cline and Windsurf are known
30
+ * only from their hooks.
31
+ */
32
+ function agentHost(env, hint) {
33
+ const known = hint ? HOOK_TOOLS[hint.toLowerCase()] : undefined;
34
+ if (known)
35
+ return known;
36
+ if (env.CLAUDECODE === '1')
37
+ return 'claude-code';
38
+ if (env.CURSOR_TRACE_ID)
39
+ return 'cursor';
40
+ if (env.CODEX_SANDBOX)
41
+ return 'codex';
42
+ return hint ? 'other' : 'none';
43
+ }
44
+ const WEEK_MS = 7 * DAY_MS;
45
+ /** When telemetry.json was first written, for a file from before `firstAt` was kept: its creation time, else its last write. */
46
+ function firstWritten(home) {
47
+ try {
48
+ const stat = fs.statSync(file(home, 'telemetry.json'));
49
+ return stat.birthtimeMs > 0 ? stat.birthtimeMs : stat.mtimeMs;
50
+ }
51
+ catch {
52
+ return undefined;
53
+ }
54
+ }
55
+ /** How long ago the person was first asked, bucketed: 0, 1, 2-4, 5-12 or 13+ weeks; undefined before they were. */
56
+ function installAgeWeeks(home, now) {
57
+ const first = readTelemetryState(home).firstAt ?? firstWritten(home);
58
+ if (first === undefined)
59
+ return undefined;
60
+ const weeks = Math.floor(Math.max(0, now - first) / WEEK_MS);
61
+ return weeks === 0 ? '0' : weeks === 1 ? '1' : weeks <= 4 ? '2-4' : weeks <= 12 ? '5-12' : '13+';
62
+ }
25
63
  export function telemetryToken(env = process.env) {
26
64
  return env.RIGOUR_MIXPANEL_TOKEN?.trim() || MIXPANEL_TOKEN;
27
65
  }
@@ -32,7 +70,7 @@ export function readTelemetryState(home = rigourHome()) {
32
70
  try {
33
71
  const parsed = JSON.parse(fs.readFileSync(file(home, 'telemetry.json'), 'utf8'));
34
72
  if (typeof parsed?.installId === 'string')
35
- return { installId: parsed.installId, enabled: typeof parsed.enabled === 'boolean' ? parsed.enabled : undefined };
73
+ return { installId: parsed.installId, enabled: typeof parsed.enabled === 'boolean' ? parsed.enabled : undefined, ...(typeof parsed.firstAt === 'number' ? { firstAt: parsed.firstAt } : {}) };
36
74
  }
37
75
  catch {
38
76
  // Not asked yet.
@@ -40,7 +78,8 @@ export function readTelemetryState(home = rigourHome()) {
40
78
  return { installId: crypto.randomUUID() };
41
79
  }
42
80
  export function setTelemetryEnabled(enabled, home = rigourHome()) {
43
- const state = { ...readTelemetryState(home), enabled };
81
+ const current = readTelemetryState(home);
82
+ const state = { ...current, enabled, firstAt: current.firstAt ?? firstWritten(home) ?? Date.now() };
44
83
  writeJson(file(home, 'telemetry.json'), state);
45
84
  return state;
46
85
  }
@@ -87,6 +126,8 @@ export async function trackUsage(event, properties, deps = {}) {
87
126
  os: process.platform,
88
127
  node_major: Number(process.versions.node.split('.')[0]),
89
128
  ci: isCi(env),
129
+ agent_host: agentHost(env, deps.agent),
130
+ install_age_weeks: installAgeWeeks(deps.home ?? rigourHome(), deps.now ?? Date.now()),
90
131
  ...properties,
91
132
  },
92
133
  }];
@@ -110,6 +151,9 @@ export function countUsage(name, by = 1, deps = {}) {
110
151
  const home = deps.home ?? rigourHome();
111
152
  const counters = readCounters(home, deps.now ?? Date.now());
112
153
  counters.counts[name] = (counters.counts[name] ?? 0) + by;
154
+ // What each agent drives: one count per agent host, beside the thing counted.
155
+ const host = `agent_host:${agentHost(deps.env ?? process.env, deps.agent)}`;
156
+ counters.counts[host] = (counters.counts[host] ?? 0) + by;
113
157
  writeJson(file(home, 'telemetry-counters.json'), counters);
114
158
  }
115
159
  catch {
@@ -123,10 +167,20 @@ export async function flushDailyUsage(deps = {}) {
123
167
  const home = deps.home ?? rigourHome();
124
168
  const now = deps.now ?? Date.now();
125
169
  const counters = readCounters(home, now);
126
- if (now - counters.since < DAY_MS || Object.keys(counters.counts).length === 0)
170
+ // The day starts the first time anything could be sent, so a day with no counts still ends.
171
+ if (deps.daily && !fs.existsSync(file(home, 'telemetry-counters.json')))
172
+ writeJson(file(home, 'telemetry-counters.json'), counters);
173
+ if (now - counters.since < DAY_MS || (Object.keys(counters.counts).length === 0 && !deps.daily))
127
174
  return false;
128
175
  writeJson(file(home, 'telemetry-counters.json'), { since: now, counts: {} });
129
- await trackUsage('daily_usage', { ...counters.counts }, deps);
176
+ let daily = {};
177
+ try {
178
+ daily = deps.daily?.() ?? {};
179
+ }
180
+ catch {
181
+ // What the learning loop did is extra: the day's counts go out without it.
182
+ }
183
+ await trackUsage('daily_usage', { ...counters.counts, ...daily }, deps);
130
184
  return true;
131
185
  }
132
186
  function readCounters(home, now) {
@@ -218,6 +218,10 @@ export const UNIVERSAL_CONFIG = {
218
218
  allow: [],
219
219
  block: false,
220
220
  },
221
+ compiled_lessons: {
222
+ enabled: true,
223
+ block: false,
224
+ },
221
225
  query_patterns: {
222
226
  enabled: true,
223
227
  },
@@ -503,6 +503,17 @@ export declare const GatesSchema: z.ZodObject<{
503
503
  allow?: string[] | undefined;
504
504
  block?: boolean | undefined;
505
505
  }>>>;
506
+ /** Verified lessons a person compiled into checks (review-learning/compiled-lessons.ts): notes unless `block`. */
507
+ compiled_lessons: z.ZodDefault<z.ZodOptional<z.ZodObject<{
508
+ enabled: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
509
+ block: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
510
+ }, "strip", z.ZodTypeAny, {
511
+ enabled: boolean;
512
+ block: boolean;
513
+ }, {
514
+ enabled?: boolean | undefined;
515
+ block?: boolean | undefined;
516
+ }>>>;
506
517
  migration_order: z.ZodDefault<z.ZodOptional<z.ZodObject<{
507
518
  enabled: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
508
519
  dirs: z.ZodDefault<z.ZodOptional<z.ZodArray<z.ZodString, "many">>>;
@@ -897,6 +908,10 @@ export declare const GatesSchema: z.ZodObject<{
897
908
  allow: string[];
898
909
  block: boolean;
899
910
  };
911
+ compiled_lessons: {
912
+ enabled: boolean;
913
+ block: boolean;
914
+ };
900
915
  migration_order: {
901
916
  enabled: boolean;
902
917
  dirs: string[];
@@ -1136,6 +1151,10 @@ export declare const GatesSchema: z.ZodObject<{
1136
1151
  allow?: string[] | undefined;
1137
1152
  block?: boolean | undefined;
1138
1153
  } | undefined;
1154
+ compiled_lessons?: {
1155
+ enabled?: boolean | undefined;
1156
+ block?: boolean | undefined;
1157
+ } | undefined;
1139
1158
  migration_order?: {
1140
1159
  enabled?: boolean | undefined;
1141
1160
  dirs?: string[] | undefined;
@@ -1717,6 +1736,17 @@ export declare const ConfigSchema: z.ZodObject<{
1717
1736
  allow?: string[] | undefined;
1718
1737
  block?: boolean | undefined;
1719
1738
  }>>>;
1739
+ /** Verified lessons a person compiled into checks (review-learning/compiled-lessons.ts): notes unless `block`. */
1740
+ compiled_lessons: z.ZodDefault<z.ZodOptional<z.ZodObject<{
1741
+ enabled: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
1742
+ block: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
1743
+ }, "strip", z.ZodTypeAny, {
1744
+ enabled: boolean;
1745
+ block: boolean;
1746
+ }, {
1747
+ enabled?: boolean | undefined;
1748
+ block?: boolean | undefined;
1749
+ }>>>;
1720
1750
  migration_order: z.ZodDefault<z.ZodOptional<z.ZodObject<{
1721
1751
  enabled: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
1722
1752
  dirs: z.ZodDefault<z.ZodOptional<z.ZodArray<z.ZodString, "many">>>;
@@ -2111,6 +2141,10 @@ export declare const ConfigSchema: z.ZodObject<{
2111
2141
  allow: string[];
2112
2142
  block: boolean;
2113
2143
  };
2144
+ compiled_lessons: {
2145
+ enabled: boolean;
2146
+ block: boolean;
2147
+ };
2114
2148
  migration_order: {
2115
2149
  enabled: boolean;
2116
2150
  dirs: string[];
@@ -2350,6 +2384,10 @@ export declare const ConfigSchema: z.ZodObject<{
2350
2384
  allow?: string[] | undefined;
2351
2385
  block?: boolean | undefined;
2352
2386
  } | undefined;
2387
+ compiled_lessons?: {
2388
+ enabled?: boolean | undefined;
2389
+ block?: boolean | undefined;
2390
+ } | undefined;
2353
2391
  migration_order?: {
2354
2392
  enabled?: boolean | undefined;
2355
2393
  dirs?: string[] | undefined;
@@ -2895,6 +2933,10 @@ export declare const ConfigSchema: z.ZodObject<{
2895
2933
  allow: string[];
2896
2934
  block: boolean;
2897
2935
  };
2936
+ compiled_lessons: {
2937
+ enabled: boolean;
2938
+ block: boolean;
2939
+ };
2898
2940
  migration_order: {
2899
2941
  enabled: boolean;
2900
2942
  dirs: string[];
@@ -3200,6 +3242,10 @@ export declare const ConfigSchema: z.ZodObject<{
3200
3242
  allow?: string[] | undefined;
3201
3243
  block?: boolean | undefined;
3202
3244
  } | undefined;
3245
+ compiled_lessons?: {
3246
+ enabled?: boolean | undefined;
3247
+ block?: boolean | undefined;
3248
+ } | undefined;
3203
3249
  migration_order?: {
3204
3250
  enabled?: boolean | undefined;
3205
3251
  dirs?: string[] | undefined;
@@ -3784,10 +3830,17 @@ export interface DeepOptions {
3784
3830
  independent?: boolean;
3785
3831
  /** The change's unified diff (from reviewChange): a cloud agentic review reads the PR as a whole. */
3786
3832
  diff?: string;
3787
- /** What Rigour's checks already found on the change's lines (the runner fills it): settled, never reported again. */
3833
+ /** What Rigour's checks already found on the change's lines (the review and the runner fill it): settled, never reported again. */
3788
3834
  settled?: Array<{
3789
3835
  file: string;
3790
3836
  line?: number;
3791
3837
  title: string;
3838
+ kind?: string;
3839
+ }>;
3840
+ /** The lessons the team's compiled checks covered on this change: the deep review is told so instead of the lesson. */
3841
+ covered?: Array<{
3842
+ checkId: string;
3843
+ lessonId: string;
3844
+ message: string;
3792
3845
  }>;
3793
3846
  }
@@ -282,6 +282,11 @@ export const GatesSchema = z.object({
282
282
  // Block on them. Off by default, like unused_exports.block: a team whose reviewers block on dead files turns it on.
283
283
  block: z.boolean().optional().default(false),
284
284
  }).optional().default({}),
285
+ /** Verified lessons a person compiled into checks (review-learning/compiled-lessons.ts): notes unless `block`. */
286
+ compiled_lessons: z.object({
287
+ enabled: z.boolean().optional().default(true),
288
+ block: z.boolean().optional().default(false),
289
+ }).optional().default({}),
285
290
  migration_order: z.object({
286
291
  enabled: z.boolean().optional().default(false),
287
292
  dirs: z.array(z.string()).optional().default(['**/supabase/migrations']),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rigour-labs/core",
3
- "version": "6.10.1-rc.1",
3
+ "version": "6.11.0-rc.1",
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.10.1-rc.1",
79
- "@rigour-labs/brain-darwin-x64": "6.10.1-rc.1",
80
- "@rigour-labs/brain-linux-arm64": "6.10.1-rc.1",
81
- "@rigour-labs/brain-linux-x64": "6.10.1-rc.1",
82
- "@rigour-labs/brain-win-x64": "6.10.1-rc.1"
78
+ "@rigour-labs/brain-darwin-x64": "6.11.0-rc.1",
79
+ "@rigour-labs/brain-linux-arm64": "6.11.0-rc.1",
80
+ "@rigour-labs/brain-linux-x64": "6.11.0-rc.1",
81
+ "@rigour-labs/brain-darwin-arm64": "6.11.0-rc.1",
82
+ "@rigour-labs/brain-win-x64": "6.11.0-rc.1"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@types/fs-extra": "^11.0.4",