@rigour-labs/core 6.6.6 → 6.7.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.
Files changed (133) hide show
  1. package/README.md +16 -92
  2. package/dist/environment.test.js +0 -4
  3. package/dist/gates/agent-team.d.ts +0 -2
  4. package/dist/gates/agent-team.js +0 -8
  5. package/dist/gates/checkpoint.d.ts +0 -1
  6. package/dist/gates/checkpoint.js +0 -1
  7. package/dist/gates/context.d.ts +0 -4
  8. package/dist/gates/context.js +7 -17
  9. package/dist/gates/deep-analysis.d.ts +0 -1
  10. package/dist/gates/deep-runner.js +0 -1
  11. package/dist/gates/frontend-secret-exposure.test.js +2 -2
  12. package/dist/gates/hallucinated-imports/index.d.ts +0 -2
  13. package/dist/gates/hallucinated-imports/index.js +2 -2
  14. package/dist/gates/hallucinated-imports/js-resolver.js +1 -0
  15. package/dist/gates/hallucinated-imports.test.js +10 -0
  16. package/dist/gates/runner.js +3 -4
  17. package/dist/gates/test-quality-matchers.d.ts +0 -4
  18. package/dist/gates/test-quality-matchers.js +0 -7
  19. package/dist/gates/test-quality.d.ts +0 -2
  20. package/dist/gates/test-quality.js +1 -10
  21. package/dist/hooks/checker.js +1 -1
  22. package/dist/hooks/index.d.ts +1 -2
  23. package/dist/hooks/index.js +0 -1
  24. package/dist/hooks/types.d.ts +0 -14
  25. package/dist/hooks/types.js +1 -15
  26. package/dist/index.d.ts +6 -1
  27. package/dist/index.js +6 -1
  28. package/dist/inference/cloud-provider.d.ts +2 -0
  29. package/dist/inference/cloud-provider.js +16 -6
  30. package/dist/inference/cloud-provider.test.js +21 -0
  31. package/dist/pattern-index/embeddings.d.ts +0 -5
  32. package/dist/pattern-index/embeddings.js +11 -4
  33. package/dist/pattern-index/semantic-runtime.d.ts +10 -0
  34. package/dist/pattern-index/semantic-runtime.js +46 -0
  35. package/dist/pattern-index/semantic-runtime.test.d.ts +1 -0
  36. package/dist/pattern-index/semantic-runtime.test.js +31 -0
  37. package/dist/review/backtest-judges.d.ts +38 -0
  38. package/dist/review/backtest-judges.js +63 -0
  39. package/dist/review/backtest-judges.test.d.ts +1 -0
  40. package/dist/review/backtest-judges.test.js +32 -0
  41. package/dist/review/backtest.d.ts +9 -4
  42. package/dist/review/backtest.js +19 -9
  43. package/dist/review/backtest.test.js +2 -2
  44. package/dist/review/baseline.js +2 -1
  45. package/dist/review/dead-code.test.js +7 -0
  46. package/dist/review/fix-scope.d.ts +21 -0
  47. package/dist/review/fix-scope.js +94 -0
  48. package/dist/review/fix-scope.test.d.ts +1 -0
  49. package/dist/review/fix-scope.test.js +104 -0
  50. package/dist/review/lint-overlay.js +1 -1
  51. package/dist/review/orphan-files.js +3 -2
  52. package/dist/review/partial-wiring.js +5 -3
  53. package/dist/review/query-patterns.js +3 -2
  54. package/dist/review/review.js +2 -1
  55. package/dist/review/reviewer/adapters.d.ts +23 -7
  56. package/dist/review/reviewer/adapters.js +32 -7
  57. package/dist/review/reviewer/adapters.test.d.ts +1 -0
  58. package/dist/review/reviewer/adapters.test.js +23 -0
  59. package/dist/review/reviewer/background.d.ts +8 -1
  60. package/dist/review/reviewer/background.js +34 -9
  61. package/dist/review/reviewer/background.test.js +16 -2
  62. package/dist/review/reviewer/consensus.d.ts +36 -0
  63. package/dist/review/reviewer/consensus.js +144 -0
  64. package/dist/review/reviewer/context.d.ts +75 -0
  65. package/dist/review/reviewer/context.js +140 -0
  66. package/dist/review/reviewer/context.test.d.ts +1 -0
  67. package/dist/review/reviewer/context.test.js +18 -0
  68. package/dist/review/reviewer/exec.js +6 -2
  69. package/dist/review/reviewer/exec.test.d.ts +1 -0
  70. package/dist/review/reviewer/exec.test.js +23 -0
  71. package/dist/review/reviewer/inputs.d.ts +2 -0
  72. package/dist/review/reviewer/inputs.js +5 -1
  73. package/dist/review/reviewer/panel.d.ts +67 -0
  74. package/dist/review/reviewer/panel.js +101 -0
  75. package/dist/review/reviewer/panel.test.d.ts +1 -0
  76. package/dist/review/reviewer/panel.test.js +110 -0
  77. package/dist/review/reviewer/prompt.d.ts +11 -0
  78. package/dist/review/reviewer/prompt.js +26 -4
  79. package/dist/review/reviewer/settings.d.ts +58 -0
  80. package/dist/review/reviewer/settings.js +152 -0
  81. package/dist/review/reviewer/settings.test.d.ts +1 -0
  82. package/dist/review/reviewer/settings.test.js +57 -0
  83. package/dist/review/reviewer/store.d.ts +25 -2
  84. package/dist/review/reviewer/store.js +43 -1
  85. package/dist/review/reviewer/usage.d.ts +2 -0
  86. package/dist/review/reviewer/usage.js +28 -0
  87. package/dist/review/reviewer/usage.test.d.ts +1 -0
  88. package/dist/review/reviewer/usage.test.js +14 -0
  89. package/dist/review/reviewer/verdict.d.ts +15 -1
  90. package/dist/review/reviewer/verdict.js +17 -7
  91. package/dist/review/reviewer.d.ts +45 -2
  92. package/dist/review/reviewer.js +186 -25
  93. package/dist/review/reviewer.test.js +127 -12
  94. package/dist/review/test-files.d.ts +5 -1
  95. package/dist/review/test-files.js +6 -2
  96. package/dist/review/toolchain.js +10 -3
  97. package/dist/review/toolchain.test.js +6 -3
  98. package/dist/review/typed/program.d.ts +0 -1
  99. package/dist/review/typed/program.js +3 -3
  100. package/dist/review/typed/redundancy.js +43 -4
  101. package/dist/review/typed/redundancy.test.js +58 -0
  102. package/dist/review/typed/schema-nullability.d.ts +6 -0
  103. package/dist/review/typed/schema-nullability.js +218 -0
  104. package/dist/review/typed/schema-nullability.test.d.ts +1 -0
  105. package/dist/review/typed/schema-nullability.test.js +63 -0
  106. package/dist/review/unused-exports.js +5 -6
  107. package/dist/semantic/learn/types.d.ts +2 -2
  108. package/dist/services/adaptive-thresholds.d.ts +2 -52
  109. package/dist/services/adaptive-thresholds.js +2 -147
  110. package/dist/services/adaptive-thresholds.test.js +1 -81
  111. package/dist/settings.d.ts +14 -6
  112. package/dist/settings.js +0 -12
  113. package/dist/storage/team-diagnostics.js +1 -1
  114. package/dist/storage/team-schema.d.ts +1 -1
  115. package/dist/storage/team-schema.js +2 -0
  116. package/dist/storage/team-store.js +26 -3
  117. package/dist/storage/team-sync-scope.test.js +35 -2
  118. package/dist/telemetry/telemetry.d.ts +5 -0
  119. package/dist/telemetry/telemetry.js +11 -1
  120. package/dist/templates/paradigms.js +0 -8
  121. package/dist/templates/presets.d.ts +0 -1
  122. package/dist/templates/presets.js +0 -23
  123. package/dist/templates/universal-config.js +8 -45
  124. package/dist/types/index.d.ts +108 -566
  125. package/dist/types/index.js +41 -66
  126. package/dist/types/index.test.d.ts +1 -0
  127. package/dist/types/index.test.js +33 -0
  128. package/dist/utils/command-line.d.ts +8 -0
  129. package/dist/utils/command-line.js +8 -0
  130. package/dist/utils/command-line.test.d.ts +1 -0
  131. package/dist/utils/command-line.test.js +8 -0
  132. package/dist/utils/profile.js +1 -1
  133. package/package.json +6 -7
@@ -25,8 +25,13 @@ import { ADAPTERS, isReviewerName, resolveAdapter, selectReviewers, vendorsOf }
25
25
  import { defaultExec, GH_TIMEOUT_MS, githubEnv } from './reviewer/exec.js';
26
26
  import { findPullRequest, ghFor, humanReviews, linesChanged, mergesBaseIn, rulesText, sha } from './reviewer/inputs.js';
27
27
  import { mergeImpact } from './reviewer/merge-impact.js';
28
- import { deltaBlock, mergeBlock, PROMPT_VERSION, renderPrompt } from './reviewer/prompt.js';
28
+ import { applyPanel, parseAnswers, runPanel } from './reviewer/panel.js';
29
+ import { crossExamPrompt, deltaBlock, mergeBlock, PROMPT_VERSION, renderPrompt } from './reviewer/prompt.js';
30
+ import { resolveReviewer } from './reviewer/settings.js';
29
31
  import { VerdictStore } from './reviewer/store.js';
32
+ import { trackUsage } from '../telemetry/telemetry.js';
33
+ import { reviewerUsage } from './reviewer/usage.js';
34
+ import { buildContext, dismissedAs, readReviewDismissals, relatedDocs } from './reviewer/context.js';
30
35
  import { account, carryResolved, evidenceTouched, mergeVerdicts, parseVerdict } from './reviewer/verdict.js';
31
36
  export { defaultExec, githubEnv, parseJsonArrays } from './reviewer/exec.js';
32
37
  export { itemLine } from './reviewer/verdict.js';
@@ -37,20 +42,33 @@ export function reviewerBlocks(result) {
37
42
  const PROGRESS_EVERY_MS = 60_000;
38
43
  const MAX_DELTAS = 4;
39
44
  const MAX_DELTA_LINES = 400;
45
+ /** The reviewer, and one anonymous usage event for what it did (only when the person opted in to telemetry). */
40
46
  export async function runReviewer(cwd, base, config, exec = defaultExec, progress = message => process.stderr.write(`${message}\n`), options = {}) {
41
- const settings = config.review?.reviewer ?? { enabled: false, on_push: 'background', reviewers: ['claude'], mode: 'single', models: {}, timeout_ms: 15 * 60_000 };
47
+ const result = await review(cwd, base, config, exec, progress, options);
48
+ await trackUsage('reviewer_completed', reviewerUsage(result, options.trigger ?? (options.reviewsBefore ? 'backtest' : 'review')));
49
+ return result;
50
+ }
51
+ async function review(cwd, base, config, exec, progress, options) {
52
+ const settings = resolveReviewer(config, { ...options.choice, ...(options.full ? { mode: 'full' } : {}) });
42
53
  const trigger = options.trigger ?? (options.reviewsBefore ? 'backtest' : 'review');
43
54
  const git = async (args) => (await exec('git', args, { cwd, timeoutMs: GH_TIMEOUT_MS })).stdout.trim();
44
55
  const head = await git(['rev-parse', 'HEAD']);
45
56
  const baseSha = await git(['rev-parse', `${base}^{commit}`]);
46
57
  const branch = options.branch ?? await git(['rev-parse', '--abbrev-ref', 'HEAD']);
47
58
  const repoRoot = (await git(['rev-parse', '--show-toplevel'])) || cwd;
48
- const none = (outcome, reason, extra = {}) => ({ outcome, items: [], unverified: [], resolved: [], answerInReply: [], reason, reviewers: [], cached: false, ...extra });
59
+ let attempts;
60
+ // A review that ends without a verdict says why where the person looks (status, Studio, MCP), not only in a log.
61
+ const none = (outcome, reason, extra = {}) => {
62
+ if (attempts && branch !== 'HEAD')
63
+ attempts.recordAttempt(branch, { head, outcome, reason, at: new Date().toISOString() });
64
+ return { outcome, items: [], unverified: [], resolved: [], answerInReply: [], notes: [], disputed: [], dropped: [], dismissed: [], reason, reviewers: [], cached: false, ...extra };
65
+ };
49
66
  if (!head || !baseSha)
50
67
  return none('unavailable', `not a repository, or ${base} is unknown`);
51
68
  const store = await VerdictStore.open(cwd, exec);
52
69
  if (!store)
53
70
  return none('unavailable', 'no git directory to keep verdicts in');
71
+ attempts = store;
54
72
  const candidates = settings.reviewers.filter(isReviewerName);
55
73
  const installed = new Map();
56
74
  for (const name of candidates) {
@@ -59,11 +77,14 @@ export async function runReviewer(cwd, base, config, exec = defaultExec, progres
59
77
  installed.set(name, found);
60
78
  }
61
79
  const authors = vendorsOf(await git(['log', '--format=%(trailers:key=Co-Authored-By,valueonly)%(trailers:key=Co-authored-by,valueonly)', `${baseSha}..HEAD`]));
62
- const mode = options.full ? 'full' : settings.mode;
63
- const reviewers = selectReviewers(candidates, mode, authors, new Set(installed.keys()));
80
+ const mode = settings.mode;
81
+ let reviewers = selectReviewers(candidates, mode, authors, new Set(installed.keys()), settings.judges);
64
82
  if (reviewers.length === 0)
65
83
  return none('unavailable', `no reviewer installed: ${candidates.map(n => ADAPTERS[n].binary).join(', ') || 'review.reviewer.reviewers is empty'}`);
66
- const reviewerVersions = reviewers.map(name => `${name} ${installed.get(name).version}`).join(';');
84
+ let modeRecord = modeRan(settings, reviewers, candidates, installed);
85
+ if (reviewers.length < 2 && mode === 'full' && (settings.required.panel || settings.required.mode)) {
86
+ return none('unavailable', `rigour.yml requires two reviewers from different vendors, and ${modeRecord.degraded}`, { reviewers, mode: modeRecord });
87
+ }
67
88
  const gh = ghFor(cwd, exec, await githubEnv(cwd, config.review?.github_account ?? process.env.RIGOUR_GITHUB_ACCOUNT, exec));
68
89
  const found = await findPullRequest(gh, branch, head, options.pr);
69
90
  if (found.error)
@@ -81,11 +102,41 @@ export async function runReviewer(cwd, base, config, exec = defaultExec, progres
81
102
  return none('unavailable', read.error, { reviewers, pr: pr.number });
82
103
  reviews = read.reviews;
83
104
  }
105
+ const stateRoot = options.stateRoot ?? cwd;
106
+ // Dismissals count only where the team allows them; otherwise the file, if any, is ignored.
107
+ const dismissals = settings.dismissals ? readReviewDismissals(stateRoot) : [];
84
108
  const body = pr?.body || '(no pull request description)\n';
85
109
  const rules = rulesText(cwd);
110
+ const previous = branch !== 'HEAD' ? store.branchState(branch) : undefined;
111
+ // The same commit, asked again with the same settings and reviews (the background run, then the person): the verdict it already has.
112
+ const inputsKey = sha([PROMPT_VERSION, rules, body, reviews.key, JSON.stringify([settings.mode, settings.panel, settings.judges, settings.escalate, settings.panel_max_items, settings.cross_models, settings.models, candidates]), [...installed].map(([n, i]) => `${n} ${i.version}`).join(';')]);
113
+ if (!options.force && previous?.head === head && previous.inputsKey === inputsKey && fs.existsSync(store.decidedPath(previous.verdict))) {
114
+ const verdict = store.readJson(previous.verdict);
115
+ const decided = store.readJson(store.decidedPath(previous.verdict));
116
+ if (verdict && decided)
117
+ return result(redismiss(decided, dismissals), verdict, verdict.inputs?.reviewers ?? reviewers, previous.mode, 'same commit and inputs as the last verdict', true, reviews, pr, verdict.inputs?.mode ?? modeRecord, settings.dismissals);
118
+ }
119
+ // What the team already knows, for every judge; built once, and its risk count decides escalation.
120
+ const fullDiff = (await exec('git', ['diff', `${baseSha}...HEAD`], { cwd, timeoutMs: GH_TIMEOUT_MS })).stdout;
121
+ const previousIsAncestor = !!previous && previous.head !== head && fs.existsSync(previous.verdict) && (await exec('git', ['merge-base', '--is-ancestor', previous.head, 'HEAD'], { cwd, timeoutMs: GH_TIMEOUT_MS })).exitCode === 0;
122
+ const sincePrevious = previousIsAncestor ? new Set((await git(['diff', '--name-only', `${previous.head}..HEAD`])).split('\n').filter(Boolean)) : new Set();
123
+ const changedFiles = [...fullDiff.matchAll(/^diff --git a\/.* b\/(.*)$/gm)].map(m => m[1]);
124
+ const context = buildContext({
125
+ cwd, stateRoot, dismissals, diff: fullDiff, router: config.gates.deep?.router, touched: sincePrevious, checks: options.checks ?? [],
126
+ previousPanel: previousIsAncestor ? store.readJson(previous.verdict)?.panel?.items : undefined,
127
+ docs: await relatedDocs(cwd, changedFiles, exec),
128
+ });
129
+ // escalate: risk. More judges only where a second opinion can change the outcome; the --full hard stop always gets them.
130
+ if (settings.escalate === 'risk' && reviewers.length > 1 && !options.full) {
131
+ const escalation = escalationFor(reviews.count, context.risky);
132
+ if (!escalation.escalate)
133
+ reviewers = reviewers.slice(0, 1);
134
+ modeRecord = { ...modeRecord, ...(escalation.escalate ? {} : { ran: 'single' }), escalation: escalation.why };
135
+ }
136
+ const reviewerVersions = reviewers.map(name => `${name} ${installed.get(name).version}`).join(';');
137
+ // What changes the instructions themselves; the context pack changes with every commit, so it is in the fingerprint, not here.
86
138
  const rulesHash = sha([reviewerVersions, PROMPT_VERSION, rules, body, reviews.key]);
87
139
  // Full or delta.
88
- const previous = branch !== 'HEAD' ? store.branchState(branch) : undefined;
89
140
  const mergedBase = await mergesBaseIn(cwd, baseSha, exec);
90
141
  let scope = 'full';
91
142
  let why;
@@ -93,7 +144,7 @@ export async function runReviewer(cwd, base, config, exec = defaultExec, progres
93
144
  why = 'full review requested';
94
145
  else if (!previous || !fs.existsSync(previous.verdict))
95
146
  why = `no earlier verdict on ${branch}`;
96
- else if ((await exec('git', ['merge-base', '--is-ancestor', previous.head, 'HEAD'], { cwd, timeoutMs: GH_TIMEOUT_MS })).exitCode !== 0)
147
+ else if (previous.head !== head && !previousIsAncestor)
97
148
  why = `last reviewed commit ${previous.head.slice(0, 9)} is not an ancestor (rebase or amend)`;
98
149
  else if (mergedBase)
99
150
  why = `HEAD merges ${base}`;
@@ -112,16 +163,19 @@ export async function runReviewer(cwd, base, config, exec = defaultExec, progres
112
163
  why = `since ${previous.head.slice(0, 9)}`;
113
164
  }
114
165
  const previousVerdictText = scope === 'delta' ? fs.readFileSync(previous.verdict, 'utf8') : '';
115
- const fingerprint = sha([head, baseSha, scope, reviewerVersions, PROMPT_VERSION, rules, body, reviews.key, previousVerdictText]);
166
+ const fingerprint = sha([head, baseSha, scope, modeRecord.ran, context.key, inputsKey, reviewerVersions, previousVerdictText]);
116
167
  const verdictFile = store.verdictPath(head, fingerprint);
117
168
  const openFile = store.openPath(verdictFile);
118
169
  const previousOpen = scope === 'delta' ? store.readJson(store.openPath(previous.verdict)) ?? [] : undefined;
119
170
  const verify = verifier(cwd);
120
171
  if (!options.force && fs.existsSync(verdictFile) && fs.existsSync(openFile)) {
121
172
  const verdict = store.readJson(verdictFile);
122
- const accounted = account(verdict, previousOpen, verify);
123
- return result(accounted, verdict, reviewers, scope, why, true, reviews, pr);
173
+ return result(decide(verdict, previousOpen, verify, dismissals), verdict, reviewers, scope, why, true, reviews, pr, modeRecord, settings.dismissals);
124
174
  }
175
+ // The daily caps, before any judge starts: a cached or reused verdict above cost nothing and never reaches here.
176
+ const over = overBudget(store.spend(), settings, reviewers.length);
177
+ if (over)
178
+ return none(settings.required.panel || settings.required.mode ? 'unavailable' : 'skipped', over, { reviewers, scope, why, pr: pr?.number });
125
179
  const work = fs.mkdtempSync(path.join(os.tmpdir(), 'rigour-reviewer-'));
126
180
  try {
127
181
  const file = (name, text) => {
@@ -132,7 +186,8 @@ export async function runReviewer(cwd, base, config, exec = defaultExec, progres
132
186
  const reviewsFile = file('previous-reviews.md', reviews.markdown);
133
187
  const prBodyFile = file('pr-description.md', body);
134
188
  const diffstatFile = file('diffstat.txt', await git(['diff', '--stat', `${baseSha}...HEAD`]));
135
- const diffFile = file('full.diff', (await exec('git', ['diff', `${baseSha}...HEAD`], { cwd, timeoutMs: GH_TIMEOUT_MS })).stdout);
189
+ const diffFile = file('full.diff', fullDiff);
190
+ const contextFile = file('team-knowledge.md', context.text);
136
191
  const hintsFile = file('hints.txt', options.hints?.trim() || 'none\n');
137
192
  let delta = '';
138
193
  // A reviewer must report on the human reviews, unless every point was settled by the previous verdict and is carried.
@@ -142,8 +197,7 @@ export async function runReviewer(cwd, base, config, exec = defaultExec, progres
142
197
  const commitsFile = file('delta-commits.txt', await git(['log', '--format=%h %s', `${previous.head}..HEAD`]));
143
198
  const deltaDiffFile = file('delta.diff', (await exec('git', ['diff', `${previous.head}..HEAD`], { cwd, timeoutMs: GH_TIMEOUT_MS })).stdout);
144
199
  // Human points the previous verdict resolved, whose files the new commits leave alone, are not judged again.
145
- const touchedFiles = new Set((await git(['diff', '--name-only', `${previous.head}..HEAD`])).split('\n').filter(Boolean));
146
- const settled = (store.readJson(previous.verdict)?.prior_points ?? []).filter(p => p.resolved && !evidenceTouched(p.evidence, touchedFiles));
200
+ const settled = (store.readJson(previous.verdict)?.prior_points ?? []).filter(p => p.resolved && !evidenceTouched(p.evidence, sincePrevious));
147
201
  const settledFile = file('previous-resolved.json', JSON.stringify(settled, null, 2));
148
202
  delta = deltaBlock(previous.head, previous.verdict, previousOpenFile, commitsFile, deltaDiffFile, settledFile);
149
203
  if (settled.length)
@@ -154,20 +208,21 @@ export async function runReviewer(cwd, base, config, exec = defaultExec, progres
154
208
  const impact = await mergeImpact(cwd, await git(['merge-base', 'HEAD^1', 'HEAD^2']), 'HEAD^2', 'HEAD', exec);
155
209
  merge = mergeBlock(base, impact ? file('merge-impact.md', impact) : undefined);
156
210
  }
157
- const prompt = renderPrompt({ repoRoot, branch, head: head.slice(0, 9), base, baseSha, mode: scope, reviewsFile, humanCount: reviews.count, prBodyFile, diffstatFile, diffFile, hintsFile, deltaBlock: delta, mergeBlock: merge });
211
+ const prompt = renderPrompt({ repoRoot, branch, head: head.slice(0, 9), base, baseSha, mode: scope, reviewsFile, humanCount: reviews.count, prBodyFile, diffstatFile, diffFile, hintsFile, contextFile, deltaBlock: delta, mergeBlock: merge });
158
212
  progress(`Rigour reviewer: reviewing ${head.slice(0, 9)} against ${base} (${scope}: ${why}; ${reviews.count} human review(s), written by ${[...authors].join(', ') || 'a person'}) with ${reviewers.join(', ')}`);
213
+ const modelFor = (name) => settings.models[name] ?? (name === 'claude' ? settings.model : undefined);
159
214
  const started = Date.now();
160
215
  const ticker = setInterval(() => progress(`Rigour reviewer: still working (${Math.round((Date.now() - started) / 60_000)} min)`), PROGRESS_EVERY_MS);
161
216
  let parts;
162
217
  try {
163
218
  const answers = await Promise.all(reviewers.map(async (name) => {
164
219
  const adapter = ADAPTERS[name];
165
- const model = settings.models[name] ?? (name === 'claude' ? settings.model : undefined);
166
- const run = await exec(installed.get(name).binary, adapter.args(prompt, model), { cwd, timeoutMs: settings.timeout_ms });
220
+ const run = await exec(installed.get(name).binary, adapter.args(prompt, modelFor(name)), { cwd, timeoutMs: settings.timeout_ms });
167
221
  progress(`Rigour reviewer: ${name} finished in ${Math.round((Date.now() - started) / 1000)}s (exit ${run.exitCode})`);
168
222
  const answer = adapter.answer(run.stdout);
223
+ store.addSpend(1, answer.costUsd); // every run counts against the caps, an answer or not
169
224
  return run.exitCode === 0 || answer.text.trim()
170
- ? parseVerdict(answer.text, needsPriorPoints, name, answer.costUsd)
225
+ ? parseVerdict(answer.text, needsPriorPoints, name, answer)
171
226
  : { error: `${name}: no answer (exit ${run.exitCode}): ${run.stderr.trim().slice(-200)}` };
172
227
  }));
173
228
  const failed = answers.find(a => 'error' in a);
@@ -179,19 +234,115 @@ export async function runReviewer(cwd, base, config, exec = defaultExec, progres
179
234
  clearInterval(ticker);
180
235
  }
181
236
  const merged = mergeVerdicts(parts); // one part too: every item is tagged with who found it
182
- const touched = scope === 'delta' ? new Set((await git(['diff', '--name-only', `${previous.head}..HEAD`])).split('\n').filter(Boolean)) : new Set();
183
- const verdict = scope === 'delta' ? carryResolved(merged, store.readJson(previous.verdict), touched) : merged;
184
- const accounted = account(verdict, previousOpen, verify);
185
- store.writeJson(verdictFile, { ...verdict, inputs: { head, base: baseSha, scope, why, reviewers, versions: reviewerVersions, authors: [...authors], fingerprint, human_reviews: reviews.count, reviews_before: options.reviewsBefore ?? null, since: previous?.head ?? null, at: new Date().toISOString() } });
237
+ const touched = scope === 'delta' ? sincePrevious : new Set();
238
+ let verdict = scope === 'delta' ? carryResolved(merged, store.readJson(previous.verdict), touched) : merged;
239
+ if (modeRecord.ran === 'panel' && parts.length > 1) {
240
+ // Each judge's own blocking items, unmerged: the panel groups them into findings and decides each one.
241
+ const judgeItems = parts.map(part => account({ ...part, prior_points: [], carried: [], resolved_previous: [] }, undefined, verify).open);
242
+ const previousPanel = scope === 'delta' ? store.readJson(previous.verdict)?.panel : undefined;
243
+ const cross = [];
244
+ let reserved = 0;
245
+ const items = await runPanel({
246
+ judges: parts.map(part => part.reviewer),
247
+ items: judgeItems,
248
+ // Only what judges actually disagreed on: a finding parked by the item cap was never asked.
249
+ previousDisputed: (previousPanel?.items ?? []).filter(d => d.status === 'disputed' && d.cross?.length).map(d => d.item),
250
+ evidenced: text => evidenceNames(text).some(([file, line]) => verify(file, line)),
251
+ touched,
252
+ maxItems: settings.panel_max_items,
253
+ // Judges cross-examine at the same time: a run is reserved when allowed, so together they never pass the cap.
254
+ capped: () => {
255
+ const spent = store.spend();
256
+ const reason = overBudget({ ...spent, runs: spent.runs + reserved }, settings, 1);
257
+ if (!reason)
258
+ reserved++;
259
+ return reason;
260
+ },
261
+ ask: async (judge, asked) => {
262
+ const name = judge;
263
+ const run = await exec(installed.get(name).binary, ADAPTERS[name].args(crossExamPrompt(repoRoot, head.slice(0, 9), diffFile, asked), settings.cross_models[name] ?? modelFor(name)), { cwd, timeoutMs: settings.timeout_ms });
264
+ const answer = ADAPTERS[name].answer(run.stdout);
265
+ store.addSpend(1, answer.costUsd);
266
+ reserved--;
267
+ cross.push({ reviewer: `${name} cross-exam`, ...(answer.costUsd !== undefined ? { cost_usd: answer.costUsd } : {}), ...(answer.tokens ? { tokens: answer.tokens } : {}) });
268
+ progress(`Rigour reviewer: ${name} cross-examined ${asked.length} finding(s) (exit ${run.exitCode})`);
269
+ return parseAnswers(answer.text);
270
+ },
271
+ });
272
+ verdict = { ...verdict, panel: { judgeItemIds: judgeItems.flat().map(item => item.id), items }, reviewers: [...(verdict.reviewers ?? []), ...cross] };
273
+ }
274
+ const accounted = decide(verdict, previousOpen, verify, dismissals);
275
+ store.writeJson(verdictFile, { ...verdict, inputs: { head, base: baseSha, scope, why, mode: modeRecord, reviewers, versions: reviewerVersions, authors: [...authors], fingerprint, human_reviews: reviews.count, reviews_before: options.reviewsBefore ?? null, since: previous?.head ?? null, at: new Date().toISOString() } });
186
276
  store.writeJson(openFile, accounted.open);
277
+ store.writeJson(store.decidedPath(verdictFile), accounted);
187
278
  if (branch !== 'HEAD')
188
- store.recordBranch(branch, { head, verdict: verdictFile, mode: scope, rulesHash, reviewsKey: reviews.key });
189
- return result(accounted, verdict, reviewers, scope, why, false, reviews, pr);
279
+ store.recordBranch(branch, { head, verdict: verdictFile, mode: scope, rulesHash, reviewsKey: reviews.key, inputsKey });
280
+ return result(accounted, verdict, reviewers, scope, why, false, reviews, pr, modeRecord, settings.dismissals);
190
281
  }
191
282
  finally {
192
283
  fs.rmSync(work, { recursive: true, force: true });
193
284
  }
194
285
  }
286
+ /** The accounting a verdict leads to: with a panel, only what it confirmed blocks; a finding the team dismissed never blocks. */
287
+ function decide(verdict, previousOpen, verify, dismissals) {
288
+ const accounted = account(verdict, previousOpen, verify);
289
+ // An item an earlier round confirmed stays open until it is resolved with evidence, whatever this panel says of it.
290
+ const earlier = new Set((previousOpen ?? []).map(item => item.id));
291
+ const decided = verdict.panel
292
+ ? applyPanel(accounted, new Set(verdict.panel.judgeItemIds.filter(id => !earlier.has(id))), verdict.panel.items.filter(d => !earlier.has(d.item.id)))
293
+ : { ...accounted, disputed: [], dropped: [] };
294
+ return redismiss({ ...decided, dismissed: [] }, dismissals);
295
+ }
296
+ /** A finding the team dismissed leaves what blocks, now: at decision time and when a stored decision is read again. */
297
+ function redismiss(decided, dismissals) {
298
+ const dismissed = decided.open.filter(item => dismissedAs(item, dismissals));
299
+ return dismissed.length ? { ...decided, open: decided.open.filter(item => !dismissed.includes(item)), dismissed: [...decided.dismissed, ...dismissed] } : decided;
300
+ }
301
+ /** Every `file:line` an answer quotes. */
302
+ function evidenceNames(text) {
303
+ return [...text.matchAll(/([\w./-]+\.[A-Za-z0-9]+):(\d+)/g)].map(m => [m[1], Number(m[2])]);
304
+ }
305
+ /** Why the caps leave no room for `planned` more runs today, or nothing when they do. */
306
+ function overBudget(spent, caps, planned) {
307
+ if (caps.max_runs_per_day !== undefined && spent.runs + planned > caps.max_runs_per_day)
308
+ return `the daily run cap is reached: ${spent.runs} of ${caps.max_runs_per_day} agent runs used today in this repository, and this needs ${planned} more (review.reviewer.max_runs_per_day)`;
309
+ if (caps.max_usd_per_day !== undefined && spent.usd >= caps.max_usd_per_day)
310
+ return `the daily cost cap is reached: $${spent.usd.toFixed(2)} of $${caps.max_usd_per_day.toFixed(2)} reported today in this repository (review.reviewer.max_usd_per_day)`;
311
+ return undefined;
312
+ }
313
+ /** Whether a risk-escalated review adds judges: a human review exists, or the router finds a risky changed function. */
314
+ function escalationFor(humanReviews, risky) {
315
+ if (humanReviews > 0)
316
+ return { escalate: true, why: `${humanReviews} human review(s) to check` };
317
+ if (risky === undefined)
318
+ return { escalate: true, why: 'the risk router could not score this change, so every judge reviews it' };
319
+ return risky > 0
320
+ ? { escalate: true, why: `${risky} risky changed function(s)` }
321
+ : { escalate: false, why: 'no risky changed function and no human review: one judge (review.reviewer.escalate: risk)' };
322
+ }
323
+ /** The mode asked for and the one the installed reviewers allow: a panel or full review needs two vendors. */
324
+ function modeRan(settings, reviewers, candidates, installed) {
325
+ const asked = settings.panel ? 'panel' : settings.mode;
326
+ const refused = settings.refused.length ? { refused: settings.refused } : {};
327
+ const source = settings.panel ? settings.source.panel : settings.source.mode;
328
+ if (settings.mode !== 'full' || reviewers.length >= settings.judges)
329
+ return { asked, ran: asked, source, ...refused };
330
+ const missing = candidates.filter(name => !installed.has(name)).map(name => ADAPTERS[name].binary);
331
+ const degraded = `${settings.judges} judges asked, ${reviewers.join(' and ')} could run (${missing.length ? `not installed: ${missing.join(', ')}` : 'no other vendor in review.reviewer.reviewers'})`;
332
+ return { asked, ran: reviewers.length > 1 ? asked : 'single', source, degraded, ...refused };
333
+ }
334
+ /**
335
+ * Before any checkout or typed review: whether this push gets a model review at all (an open, ready
336
+ * pull request, `on_push`). The background job asks first, so a push nobody will read costs one lookup.
337
+ */
338
+ export async function pushReviewSkip(cwd, branch, head, config, exec = defaultExec) {
339
+ const settings = resolveReviewer(config);
340
+ const gh = ghFor(cwd, exec, await githubEnv(cwd, config.review?.github_account ?? process.env.RIGOUR_GITHUB_ACCOUNT, exec));
341
+ const found = await findPullRequest(gh, branch, head, undefined);
342
+ if (found.error)
343
+ return undefined; // the review itself reports a lookup that failed, as unavailable
344
+ return skipReason(settings.on_push, branch, found.pr);
345
+ }
195
346
  /** Why no model is asked at this push: `review.reviewer.on_push` and whether someone will read the push (an open, non-draft pull request). */
196
347
  function skipReason(onPush, branch, pr) {
197
348
  if (onPush === 'off')
@@ -221,18 +372,28 @@ function verifier(cwd) {
221
372
  return length >= 0 && (line === undefined || line <= length);
222
373
  };
223
374
  }
224
- function result(accounted, verdict, reviewers, scope, why, cached, reviews, pr) {
375
+ function result(accounted, verdict, reviewers, scope, why, cached, reviews, pr, mode, dismissable) {
225
376
  const cost = (verdict.reviewers ?? []).map(r => r.cost_usd).filter((c) => typeof c === 'number');
377
+ const used = (verdict.reviewers ?? []).map(r => r.tokens).filter((t) => !!t);
226
378
  return {
227
379
  outcome: accounted.open.length ? 'findings' : 'passed',
228
380
  items: accounted.open,
229
381
  unverified: accounted.unverified,
230
382
  resolved: accounted.resolved,
231
383
  answerInReply: accounted.answerInReply,
384
+ notes: accounted.notes,
385
+ disputed: accounted.disputed,
386
+ dropped: accounted.dropped,
387
+ dismissed: accounted.dismissed,
388
+ ...(verdict.panel ? { panel: verdict.panel.items } : {}),
232
389
  reviewers,
233
390
  scope,
234
391
  why,
235
392
  ...(cost.length ? { costUsd: cost.reduce((a, b) => a + b, 0) } : {}),
393
+ ...(used.length ? { tokens: used.reduce((a, b) => ({ input: a.input + b.input, output: a.output + b.output }), { input: 0, output: 0 }) } : {}),
394
+ runs: (verdict.reviewers ?? []).length,
395
+ mode,
396
+ dismissable,
236
397
  cached,
237
398
  ...(reviews.label ? { previousReview: reviews.label } : {}),
238
399
  ...(pr ? { pr: pr.number } : {}),
@@ -5,6 +5,8 @@ import path from 'path';
5
5
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6
6
  import { ConfigSchema } from '../types/index.js';
7
7
  import { reviewerBlocks, runReviewer } from './reviewer.js';
8
+ import { dismissReviewerFinding } from './reviewer/context.js';
9
+ import { reviewStatus } from './reviewer/background.js';
8
10
  import { selectReviewers, vendorsOf } from './reviewer/adapters.js';
9
11
  import { account, carryResolved, mergeVerdicts, parseVerdict } from './reviewer/verdict.js';
10
12
  let repo;
@@ -46,17 +48,19 @@ function fakes(answer, seen, pr = PR) {
46
48
  if (args[0] === '--version')
47
49
  return (seen.installed ?? ['claude', 'cursor-agent']).includes(binary) ? { exitCode: 0, stdout: `${seen.versions?.[command] ?? '1.0.0'}\n`, stderr: '' } : { exitCode: 127, stdout: '', stderr: 'not found' };
48
50
  seen.ran.push(command);
51
+ (seen.args ??= []).push(args);
49
52
  const name = binary === 'claude' ? 'claude' : binary === 'cursor-agent' ? 'cursor' : 'codex';
50
- const prompt = binary === 'cursor-agent' ? args[args.length - 1] : args[args.indexOf('-p') + 1];
53
+ const prompt = binary === 'claude' ? args[args.indexOf('-p') + 1] : args[args.length - 1];
51
54
  seen.prompts.push(prompt);
52
55
  // Paths as the prompt names them, on either separator (Windows writes `D:\...`).
53
- for (const match of prompt.matchAll(/(\S+(?:previous-reviews\.md|pr-description\.md|full\.diff|hints\.txt|previous-open\.json|delta\.diff|previous-resolved\.json))/g)) {
56
+ for (const match of prompt.matchAll(/(\S+(?:previous-reviews\.md|pr-description\.md|full\.diff|hints\.txt|previous-open\.json|delta\.diff|previous-resolved\.json|team-knowledge\.md))/g)) {
54
57
  seen.files[path.basename(match[1])] = fs.readFileSync(match[1], 'utf8');
55
58
  }
56
59
  const reply = answer(name);
57
60
  if (typeof reply !== 'string')
58
61
  return reply;
59
- return { exitCode: 0, stdout: binary === 'claude' ? JSON.stringify({ result: reply, total_cost_usd: 1.5 }) : JSON.stringify({ result: reply }), stderr: '' };
62
+ const stdout = binary === 'claude' ? JSON.stringify({ result: reply, total_cost_usd: 1.5 }) : binary === 'codex' ? JSON.stringify({ type: 'item.completed', item: { text: reply } }) : JSON.stringify({ result: reply });
63
+ return { exitCode: 0, stdout, stderr: '' };
60
64
  };
61
65
  }
62
66
  const seenNow = () => ({ prompts: [], files: {}, ghArgs: [], ran: [] });
@@ -111,16 +115,17 @@ describe('the reviewer', () => {
111
115
  });
112
116
  it('caches the verdict per commit and inputs, so the same push is free, and the next commit gets a delta review that carries what is not accounted for', async () => {
113
117
  const seen = seenNow();
114
- const open = JSON.stringify({ ...EMPTY, findings: [{ class: 'correctness', file: 'src/job.ts', line: 2, issue: 'returns before the lock', why: 'x' }] });
115
- const first = await runReviewer(repo, 'main', config, fakes(() => open, seen), () => undefined);
118
+ const open = JSON.stringify({ ...EMPTY, findings: [{ class: 'correctness', file: 'src/job.ts', line: 2, issue: 'returns before the lock', consequence: 'a second run reads stale rows', why: 'x' }] });
119
+ const first = await runReviewer(repo, 'main', config, fakes(() => open, seen), () => undefined, { checks: ['src/job.ts:1 Unused export `job`'] });
116
120
  expect(first.items).toHaveLength(1);
117
- const again = await runReviewer(repo, 'main', config, fakes(() => open, seen), () => undefined);
121
+ const again = await runReviewer(repo, 'main', config, fakes(() => open, seen), () => undefined, { checks: ['src/job.ts:1 Unused export `job`'] });
118
122
  expect(again.cached).toBe(true);
119
123
  expect(seen.prompts).toHaveLength(1);
120
124
  expect(fs.readdirSync(repo).sort()).toEqual(['.git', 'a.ts', 'src']); // nothing written to the working tree
121
125
  fs.writeFileSync(path.join(repo, 'src/job.ts'), 'export function job() {\n return 2;\n}\n');
122
126
  git('commit', '-qam', 'tweak');
123
- const delta = await runReviewer(repo, 'main', config, fakes(() => JSON.stringify({ ...EMPTY, prior_points: [], carried: [], resolved_previous: [] }), seen), () => undefined);
127
+ // What the checks found changed with the commit: the context changes, the instructions do not, so it is still a delta.
128
+ const delta = await runReviewer(repo, 'main', config, fakes(() => JSON.stringify({ ...EMPTY, prior_points: [], carried: [], resolved_previous: [] }), seen), () => undefined, { checks: ['src/job.ts:2 Unused export `other`'] });
124
129
  expect(delta.scope).toBe('delta');
125
130
  expect(seen.prompts[1]).toContain('DELTA MODE');
126
131
  expect(seen.files['delta.diff']).toContain('- return 1;');
@@ -133,7 +138,7 @@ describe('the reviewer', () => {
133
138
  });
134
139
  it('resolves a previous item only with evidence, and never blocks on an item that names code the checkout does not have', async () => {
135
140
  const seen = seenNow();
136
- const first = await runReviewer(repo, 'main', config, fakes(() => JSON.stringify({ ...EMPTY, findings: [{ class: 'correctness', file: 'src/job.ts', line: 2, issue: 'returns before the lock' }, { class: 'dead-code', file: 'src/ghost.ts', line: 1, issue: 'unused' }, { class: 'dead-code', file: '', issue: 'somewhere, no file named' }] }), seen), () => undefined);
141
+ const first = await runReviewer(repo, 'main', config, fakes(() => JSON.stringify({ ...EMPTY, findings: [{ class: 'correctness', file: 'src/job.ts', line: 2, issue: 'returns before the lock', consequence: 'a second run reads stale rows' }, { class: 'dead-code', file: 'src/ghost.ts', line: 1, issue: 'unused', consequence: 'a second run reads stale rows' }, { class: 'dead-code', file: '', issue: 'somewhere, no file named', consequence: 'a second run reads stale rows' }] }), seen), () => undefined);
137
142
  expect(first.items.map(i => i.file)).toEqual(['src/job.ts']);
138
143
  expect(first.unverified.map(i => i.file)).toEqual(['src/ghost.ts', '']); // a slip and a finding with no place to check: shown, never a block
139
144
  const id = first.items[0].id;
@@ -179,7 +184,7 @@ describe('the reviewer', () => {
179
184
  });
180
185
  it('in full mode runs two vendors and resolves a human point only when both say so', async () => {
181
186
  const seen = seenNow();
182
- const by = (name) => JSON.stringify({ ...EMPTY, prior_points: [{ point: 'lock before read', severity: 'blocking', resolved: name === 'claude', evidence: 'src/job.ts:2' }], findings: name === 'cursor' ? [{ class: 'dead-code', file: 'a.ts', line: 1, issue: 'a is unused' }] : [] });
187
+ const by = (name) => JSON.stringify({ ...EMPTY, prior_points: [{ point: 'lock before read', severity: 'blocking', resolved: name === 'claude', evidence: 'src/job.ts:2' }], findings: name === 'cursor' ? [{ class: 'dead-code', file: 'a.ts', line: 1, issue: 'a is unused', consequence: 'a second run reads stale rows' }] : [] });
183
188
  const result = await runReviewer(repo, 'main', config, fakes(by, seen), () => undefined, { full: true });
184
189
  expect(result.reviewers).toEqual(['claude', 'cursor']);
185
190
  expect(result.items.map(i => [i.kind, i.reviewer])).toEqual([['prior', 'cursor'], ['finding', 'cursor']]);
@@ -211,10 +216,10 @@ describe('verdicts', () => {
211
216
  it('finds the verdict when the reviewer wraps it in a summary or a code fence, and refuses prose', () => {
212
217
  const verdict = JSON.stringify(EMPTY);
213
218
  for (const text of [`## Summary\nAll checked.\n\n\`\`\`json\n${verdict}\n\`\`\`\nDone.`, `Notes first.\n${verdict}\nThat is all {see above}.`]) {
214
- expect(parseVerdict(text, true, 'claude', undefined)).toMatchObject({ verdict: { prior_points: [{ point: 'lock before read' }], reviewer: 'claude' } });
219
+ expect(parseVerdict(text, true, 'claude', {})).toMatchObject({ verdict: { prior_points: [{ point: 'lock before read' }], reviewer: 'claude' } });
215
220
  }
216
- expect(parseVerdict('Looks good to me!', false, 'claude', undefined)).toMatchObject({ error: expect.stringContaining('no valid verdict') });
217
- expect(parseVerdict(JSON.stringify({ ...EMPTY, prior_points: [] }), true, 'claude', undefined)).toEqual({ error: 'claude did not report on the human reviews' });
221
+ expect(parseVerdict('Looks good to me!', false, 'claude', {})).toMatchObject({ error: expect.stringContaining('no valid verdict') });
222
+ expect(parseVerdict(JSON.stringify({ ...EMPTY, prior_points: [] }), true, 'claude', {})).toEqual({ error: 'claude did not report on the human reviews' });
218
223
  });
219
224
  it('turns reads, scans, redundancy and merge impact into open items with stable ids, and answers non-blocking points in the reply', () => {
220
225
  const verdict = {
@@ -248,3 +253,113 @@ describe('verdicts', () => {
248
253
  expect(carryResolved({ ...EMPTY, prior_points: [{ point: 'Lock before read', resolved: false }] }, previous, new Set()).prior_points).toHaveLength(2);
249
254
  });
250
255
  });
256
+ describe('a panel of judges', () => {
257
+ const panelConfig = (reviewer) => ConfigSchema.parse({ version: 1, review: { github_account: 'reviewer-account', reviewer: { enabled: true, reviewers: ['claude', 'cursor', 'codex'], mode: 'full', panel: 'on', ...reviewer } } });
258
+ const LOCK = { class: 'correctness', file: 'src/job.ts', line: 2, issue: 'returns before the lock is taken', consequence: 'two runs send the same email' };
259
+ const LONE = { class: 'dead-code', file: 'src/job.ts', line: 1, issue: 'job is exported and never called', consequence: 'a reader treats it as the contract' };
260
+ const OPINION = { class: 'duplication', file: 'src/job.ts', line: 2, issue: 'could be one line shorter', consequence: '' };
261
+ it('confirms what a majority raised, drops what the others refute with evidence, and never blocks on an opinion', async () => {
262
+ installFake(bins[0], 'codex');
263
+ const seen = { ...seenNow(), installed: ['claude', 'cursor-agent', 'codex'] };
264
+ const reply = (name) => {
265
+ const prompt = seen.prompts.at(-1) ?? '';
266
+ if (prompt.includes('The other\nreviewer raised')) {
267
+ const ids = [...prompt.matchAll(/"id": "([0-9a-f]+)"/g)].map(m => m[1]);
268
+ return JSON.stringify({ answers: ids.map(id => ({ id, call: 'refute', evidence: `src/job.ts:1 ${name}: job is imported by the runner` })) });
269
+ }
270
+ if (name === 'codex')
271
+ return JSON.stringify({ ...EMPTY, findings: [LONE] });
272
+ return JSON.stringify({ ...EMPTY, findings: name === 'claude' ? [LOCK, OPINION] : [{ ...LOCK, line: 3, issue: 'the lock is taken only after it returns' }] });
273
+ };
274
+ const result = await runReviewer(repo, 'main', panelConfig({ judges: 3, cross_models: { claude: 'claude-haiku-4-5' } }), fakes(reply, seen), () => undefined);
275
+ expect(result.reviewers).toEqual(['claude', 'cursor', 'codex']);
276
+ expect(result.mode).toMatchObject({ asked: 'panel', ran: 'panel', source: 'team' });
277
+ expect(result.items.map(i => [i.issue, i.reviewer])).toEqual([[LOCK.issue, 'claude+cursor']]);
278
+ expect(result.dropped.map(i => i.issue)).toEqual([LONE.issue]);
279
+ expect(result.notes.map(i => i.issue)).toEqual([OPINION.issue]);
280
+ expect(seen.prompts).toHaveLength(5); // three blind reviews, then claude and cursor each cross-examine codex's lone finding once
281
+ expect(result.panel?.find(d => d.item.issue === LONE.issue)).toMatchObject({ judges: ['codex'], calls: { codex: 'raised', claude: 'refute', cursor: 'refute' }, status: 'dropped' });
282
+ expect(result.costUsd).toBe(3); // claude's review and claude's cross-examination
283
+ const claudeCalls = (seen.args ?? []).filter((_, i) => path.basename(seen.ran[i]).startsWith('claude'));
284
+ expect(claudeCalls.map(a => a.includes('claude-haiku-4-5'))).toEqual([false, true]); // the cheaper model for the cross-examination only
285
+ });
286
+ it('runs one judge and says why when only one vendor is installed, and is unavailable when the team requires the panel', async () => {
287
+ const seen = seenNow();
288
+ const reply = () => JSON.stringify({ ...EMPTY, findings: [LOCK] });
289
+ const fallback = await runReviewer(repo, 'main', panelConfig({ reviewers: ['claude', 'codex'] }), fakes(reply, seen), () => undefined);
290
+ expect(fallback.mode).toMatchObject({ asked: 'panel', ran: 'single', degraded: expect.stringContaining('not installed: codex') });
291
+ expect(fallback.items.map(i => i.issue)).toEqual([LOCK.issue]);
292
+ const required = await runReviewer(repo, 'main', panelConfig({ reviewers: ['claude', 'codex'], panel: 'required' }), fakes(reply, seenNow()), () => undefined);
293
+ expect(required.outcome).toBe('unavailable');
294
+ expect(required.reason).toContain('rigour.yml requires two reviewers');
295
+ });
296
+ it('keeps to the daily caps: a review past the run cap is skipped, or unavailable when the team requires the reviewer', async () => {
297
+ const reply = () => JSON.stringify({ ...EMPTY, findings: [LOCK] });
298
+ const skipped = await runReviewer(repo, 'main', panelConfig({ max_runs_per_day: 1 }), fakes(reply, seenNow()), () => undefined);
299
+ expect(skipped.outcome).toBe('skipped');
300
+ expect(skipped.reason).toContain('the daily run cap is reached: 0 of 1 agent runs used today in this repository, and this needs 2 more');
301
+ const required = await runReviewer(repo, 'main', panelConfig({ max_runs_per_day: 1, panel: 'required' }), fakes(reply, seenNow()), () => undefined);
302
+ expect(required.outcome).toBe('unavailable');
303
+ });
304
+ it('counts every run, stops new reviews at the cost cap, and leaves a cross-examination past the run cap disputed', async () => {
305
+ const seen = seenNow();
306
+ const lone = { class: 'dead-code', file: 'src/job.ts', line: 1, issue: 'job is exported and never called', consequence: 'a reader treats it as the contract' };
307
+ const reply = (name) => JSON.stringify({ ...EMPTY, findings: name === 'claude' ? [LOCK] : [{ ...LOCK, line: 3, issue: 'the lock is taken only after it returns' }, lone] });
308
+ // Two judges fit in a cap of 2; the cross-examination of cursor's lone finding would be a third run.
309
+ const result = await runReviewer(repo, 'main', panelConfig({ max_runs_per_day: 2 }), fakes(reply, seen), () => undefined);
310
+ expect(seen.prompts).toHaveLength(2);
311
+ expect(result.items.map(i => i.issue)).toEqual([LOCK.issue]);
312
+ expect(result.panel?.find(d => d.item.issue === lone.issue)).toMatchObject({ status: 'disputed', note: expect.stringContaining('the daily run cap is reached') });
313
+ // claude reported $1.50: a cost cap of $1 lets no new review start today.
314
+ const capped = await runReviewer(repo, 'main', panelConfig({ max_usd_per_day: 1 }), fakes(reply, seenNow()), () => undefined, { force: true });
315
+ expect(capped).toMatchObject({ outcome: 'skipped', reason: expect.stringContaining('the daily cost cap is reached: $1.50 of $1.00') });
316
+ });
317
+ it('escalates on risk: one judge for a change with no risky function and no human review', async () => {
318
+ const seen = seenNow();
319
+ const result = await runReviewer(repo, 'main', panelConfig({ escalate: 'risk' }), fakes(() => JSON.stringify(EMPTY), seen, null), () => undefined);
320
+ expect(result.mode).toMatchObject({ asked: 'panel', ran: 'single', escalation: expect.stringContaining('no risky changed function') });
321
+ expect(seen.prompts).toHaveLength(1);
322
+ const full = await runReviewer(repo, 'main', panelConfig({ escalate: 'risk' }), fakes(() => JSON.stringify(EMPTY), seenNow(), null), () => undefined, { full: true, force: true });
323
+ expect(full.mode?.ran).toBe('panel'); // the --full hard stop always gets every judge
324
+ });
325
+ });
326
+ describe('what the team already knows', () => {
327
+ const allowing = ConfigSchema.parse({ version: 1, review: { github_account: 'reviewer-account', reviewer: { enabled: true, reviewers: ['claude', 'cursor'], dismissals: true } } });
328
+ it('refuses a dismissal unless the team allows them: fix the code, or the reviewer', async () => {
329
+ const first = await runReviewer(repo, 'main', allowing, fakes(() => JSON.stringify({ ...EMPTY, findings: [{ class: 'correctness', file: 'src/job.ts', line: 2, issue: 'returns before the lock is taken', consequence: 'two runs send the same email' }] }), seenNow()), () => undefined);
330
+ expect((await dismissReviewerFinding(repo, first.items[0].id, 'the runner holds a lock', false)).error).toContain('this team does not dismiss reviewer findings');
331
+ expect(fs.existsSync(path.join(repo, '.rigour/dismissed-review-items.json'))).toBe(false);
332
+ });
333
+ it('a dismissed finding reaches the next judge as settled, and a re-worded repeat never blocks', async () => {
334
+ fs.mkdirSync(path.join(repo, 'docs'));
335
+ fs.writeFileSync(path.join(repo, 'docs/jobs.md'), 'The job runner (src/job.ts) takes the lock first.\n');
336
+ git('add', '-A');
337
+ git('commit', '-qm', 'docs');
338
+ const finding = { class: 'correctness', file: 'src/job.ts', line: 2, issue: 'returns before the lock is taken', consequence: 'two runs send the same email' };
339
+ const first = await runReviewer(repo, 'main', allowing, fakes(() => JSON.stringify({ ...EMPTY, findings: [finding] }), seenNow()), () => undefined);
340
+ expect(first.outcome).toBe('findings');
341
+ expect(await dismissReviewerFinding(repo, 'abcdef0123', 'not one of ours', true)).toEqual({ error: 'no open reviewer finding abcdef0123 on feature: run `rigour review --reviewer` and copy the id it shows' });
342
+ expect((await dismissReviewerFinding(repo, first.items[0].id, 'the runner holds a lock one level up', true)).item?.issue).toBe('returns before the lock is taken');
343
+ expect((await reviewStatus(repo, 'feature'))?.last?.open).toEqual([]); // not work any more, right away
344
+ const seen = seenNow();
345
+ // The same commit again: no new run; the stored decision is reused, with the dismissal applied.
346
+ const reused = await runReviewer(repo, 'main', allowing, fakes(() => JSON.stringify(EMPTY), seenNow()), () => undefined);
347
+ expect(reused).toMatchObject({ cached: true, outcome: 'passed' });
348
+ expect(reused.dismissed.map(i => i.issue)).toEqual(['returns before the lock is taken']);
349
+ // A fresh review: the judge is told it is settled, and a re-worded repeat does not block either.
350
+ const again = await runReviewer(repo, 'main', allowing, fakes(() => JSON.stringify({ ...EMPTY, findings: [{ ...finding, issue: 'returns before the lock is taken, so it races' }] }), seen), () => undefined, { force: true });
351
+ expect(again.cached).toBe(false);
352
+ expect(again.outcome).toBe('passed');
353
+ expect(again.dismissed.map(i => i.issue)).toEqual(['returns before the lock is taken, so it races']);
354
+ expect(seen.files['team-knowledge.md']).toContain('dismissed as not a bug by t@example.com: src/job.ts:2 [correctness] returns before the lock is taken (reason: the runner holds a lock one level up)');
355
+ expect(seen.files['team-knowledge.md']).toContain('docs/jobs.md (names src/job.ts');
356
+ const told = seenNow();
357
+ await runReviewer(repo, 'main', allowing, fakes(() => JSON.stringify(EMPTY), told), () => undefined, { force: true, checks: ['src/job.ts:1 Unused export `job`'] });
358
+ expect(told.files['team-knowledge.md']).toContain("## Already found by Rigour's checks: they block on their own, so do not report them again\n- src/job.ts:1 Unused export `job`");
359
+ });
360
+ it('fails closed: a finding whose judge left out the consequence still blocks', async () => {
361
+ const result = await runReviewer(repo, 'main', config, fakes(() => JSON.stringify({ ...EMPTY, findings: [{ class: 'correctness', file: 'src/job.ts', line: 2, issue: 'returns before the lock' }] }), seenNow()), () => undefined);
362
+ expect(result.items.map(i => i.issue)).toEqual(['returns before the lock']);
363
+ expect(result.notes).toEqual([]);
364
+ });
365
+ });
@@ -1,2 +1,6 @@
1
- /** A test file, by the conventions test runners use. */
1
+ /**
2
+ * A test file, by the conventions test runners use: by name (`x.test.ts`, `x.spec.ts`, `x.e2e.ts`,
3
+ * tsd's `x.test-d.ts`) or by folder (`test/`, `tests/`, `__tests__/`, `__mocks__/`, `e2e/`, tsd's
4
+ * `test-d/`). The one definition every review check uses, so they never disagree about a file.
5
+ */
2
6
  export declare function isTestFile(file: string): boolean;
@@ -1,4 +1,8 @@
1
- /** A test file, by the conventions test runners use. */
1
+ /**
2
+ * A test file, by the conventions test runners use: by name (`x.test.ts`, `x.spec.ts`, `x.e2e.ts`,
3
+ * tsd's `x.test-d.ts`) or by folder (`test/`, `tests/`, `__tests__/`, `__mocks__/`, `e2e/`, tsd's
4
+ * `test-d/`). The one definition every review check uses, so they never disagree about a file.
5
+ */
2
6
  export function isTestFile(file) {
3
- return /\.(test|spec|e2e)\.[cm]?[jt]sx?$/.test(file) || /(^|\/)(__tests__|tests?|e2e)\//.test(file);
7
+ return /\.(test|spec|e2e|test-d)\.[cm]?[jt]sx?$/.test(file) || /(^|\/)(__tests__|__mocks__|tests?|test-d|e2e)\//.test(file);
4
8
  }
@@ -4,12 +4,13 @@
4
4
  * the moment that matters (before a push) so nobody has to remember. Only tools the project
5
5
  * installed are run (node_modules/.bin, never a download). A tool the project never declared is
6
6
  * skipped and said so; one declared in package.json but not installed FAILS, since a checkout
7
- * without its dependencies cannot prove anything about the change. A tool configured under
8
- * `commands:` is left to that command, which every review already runs.
7
+ * without its dependencies cannot prove anything about the change. A tool the team configured
8
+ * under `commands:` is run as that command instead, as written (whole project, not per file).
9
9
  */
10
10
  import { execa } from 'execa';
11
11
  import fs from 'fs';
12
12
  import path from 'path';
13
+ import { splitCommand } from '../utils/command-line.js';
13
14
  import { installedBin } from '../utils/installed-bin.js';
14
15
  import { onChangedLines, overlayUnavailable, writeOverlayConfig } from './lint-overlay.js';
15
16
  import { ownOutputs } from './unused-exports.js';
@@ -27,7 +28,13 @@ export async function runToolchain(cwd, changedFiles, config, changedLines = {})
27
28
  const configured = config.commands ?? {};
28
29
  const results = [];
29
30
  const run = async (tool, step) => {
30
- results.push(configured[tool] ? { tool, status: 'skipped', command: `commands.${tool} runs it` } : await step());
31
+ const command = configured[tool];
32
+ if (!command)
33
+ results.push(await step());
34
+ else {
35
+ const { bin, args } = splitCommand(command);
36
+ results.push(await once(tool, cwd, bin, args));
37
+ }
31
38
  };
32
39
  await run('format', () => formatCheck(cwd, files.filter(file => FORMATTED.test(file))));
33
40
  await run('lint', () => lint(cwd, code));