@rigour-labs/core 6.6.5 → 6.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) 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.d.ts +1 -1
  12. package/dist/gates/frontend-secret-exposure.js +9 -3
  13. package/dist/gates/frontend-secret-exposure.test.js +6 -3
  14. package/dist/gates/hallucinated-imports/index.d.ts +0 -2
  15. package/dist/gates/hallucinated-imports/index.js +2 -2
  16. package/dist/gates/hallucinated-imports/js-resolver.js +1 -0
  17. package/dist/gates/hallucinated-imports.test.js +10 -0
  18. package/dist/gates/runner.js +3 -4
  19. package/dist/gates/test-quality-matchers.d.ts +0 -4
  20. package/dist/gates/test-quality-matchers.js +0 -7
  21. package/dist/gates/test-quality.d.ts +0 -2
  22. package/dist/gates/test-quality.js +1 -10
  23. package/dist/hooks/checker.js +1 -1
  24. package/dist/hooks/index.d.ts +2 -3
  25. package/dist/hooks/index.js +1 -2
  26. package/dist/hooks/stop-review.d.ts +6 -9
  27. package/dist/hooks/stop-review.js +5 -10
  28. package/dist/hooks/types.d.ts +0 -14
  29. package/dist/hooks/types.js +1 -15
  30. package/dist/index.d.ts +11 -3
  31. package/dist/index.js +11 -3
  32. package/dist/inference/cloud-provider.d.ts +2 -0
  33. package/dist/inference/cloud-provider.js +16 -6
  34. package/dist/inference/cloud-provider.test.js +21 -0
  35. package/dist/pattern-index/embeddings.d.ts +0 -5
  36. package/dist/pattern-index/embeddings.js +11 -4
  37. package/dist/pattern-index/semantic-runtime.d.ts +10 -0
  38. package/dist/pattern-index/semantic-runtime.js +46 -0
  39. package/dist/pattern-index/semantic-runtime.test.d.ts +1 -0
  40. package/dist/pattern-index/semantic-runtime.test.js +31 -0
  41. package/dist/review/backtest-init.d.ts +8 -0
  42. package/dist/review/backtest-init.js +87 -0
  43. package/dist/review/backtest-init.test.d.ts +1 -0
  44. package/dist/review/backtest-init.test.js +74 -0
  45. package/dist/review/backtest-judges.d.ts +38 -0
  46. package/dist/review/backtest-judges.js +63 -0
  47. package/dist/review/backtest-judges.test.d.ts +1 -0
  48. package/dist/review/backtest-judges.test.js +32 -0
  49. package/dist/review/backtest.d.ts +320 -0
  50. package/dist/review/backtest.js +207 -0
  51. package/dist/review/backtest.test.d.ts +1 -0
  52. package/dist/review/backtest.test.js +129 -0
  53. package/dist/review/baseline.js +2 -1
  54. package/dist/review/dead-code.test.js +15 -0
  55. package/dist/review/fix-scope.d.ts +21 -0
  56. package/dist/review/fix-scope.js +94 -0
  57. package/dist/review/fix-scope.test.d.ts +1 -0
  58. package/dist/review/fix-scope.test.js +104 -0
  59. package/dist/review/lint-overlay.d.ts +21 -0
  60. package/dist/review/lint-overlay.js +89 -0
  61. package/dist/review/orphan-files.js +3 -2
  62. package/dist/review/partial-wiring.js +5 -3
  63. package/dist/review/query-patterns.js +3 -2
  64. package/dist/review/quiet.d.ts +6 -0
  65. package/dist/review/quiet.js +12 -2
  66. package/dist/review/quiet.test.js +11 -0
  67. package/dist/review/review.d.ts +6 -0
  68. package/dist/review/review.js +10 -2
  69. package/dist/review/reviewer/adapters.d.ts +51 -0
  70. package/dist/review/reviewer/adapters.js +181 -0
  71. package/dist/review/reviewer/adapters.test.d.ts +1 -0
  72. package/dist/review/reviewer/adapters.test.js +23 -0
  73. package/dist/review/reviewer/background.d.ts +41 -0
  74. package/dist/review/reviewer/background.js +153 -0
  75. package/dist/review/reviewer/background.test.d.ts +1 -0
  76. package/dist/review/reviewer/background.test.js +118 -0
  77. package/dist/review/reviewer/consensus.d.ts +36 -0
  78. package/dist/review/reviewer/consensus.js +144 -0
  79. package/dist/review/reviewer/context.d.ts +75 -0
  80. package/dist/review/reviewer/context.js +140 -0
  81. package/dist/review/reviewer/context.test.d.ts +1 -0
  82. package/dist/review/reviewer/context.test.js +18 -0
  83. package/dist/review/reviewer/exec.d.ts +17 -0
  84. package/dist/review/reviewer/exec.js +27 -0
  85. package/dist/review/reviewer/exec.test.d.ts +1 -0
  86. package/dist/review/reviewer/exec.test.js +23 -0
  87. package/dist/review/reviewer/inputs.d.ts +39 -0
  88. package/dist/review/reviewer/inputs.js +107 -0
  89. package/dist/review/reviewer/merge-impact.d.ts +2 -0
  90. package/dist/review/reviewer/merge-impact.js +64 -0
  91. package/dist/review/reviewer/panel.d.ts +67 -0
  92. package/dist/review/reviewer/panel.js +101 -0
  93. package/dist/review/reviewer/panel.test.d.ts +1 -0
  94. package/dist/review/reviewer/panel.test.js +110 -0
  95. package/dist/review/reviewer/prompt.d.ts +32 -0
  96. package/dist/review/reviewer/prompt.js +145 -0
  97. package/dist/review/reviewer/settings.d.ts +58 -0
  98. package/dist/review/reviewer/settings.js +152 -0
  99. package/dist/review/reviewer/settings.test.d.ts +1 -0
  100. package/dist/review/reviewer/settings.test.js +57 -0
  101. package/dist/review/reviewer/store.d.ts +49 -0
  102. package/dist/review/reviewer/store.js +99 -0
  103. package/dist/review/reviewer/usage.d.ts +2 -0
  104. package/dist/review/reviewer/usage.js +28 -0
  105. package/dist/review/reviewer/usage.test.d.ts +1 -0
  106. package/dist/review/reviewer/usage.test.js +14 -0
  107. package/dist/review/reviewer/verdict.d.ts +141 -0
  108. package/dist/review/reviewer/verdict.js +166 -0
  109. package/dist/review/reviewer.d.ts +84 -49
  110. package/dist/review/reviewer.js +364 -195
  111. package/dist/review/reviewer.test.js +316 -50
  112. package/dist/review/test-files.d.ts +5 -1
  113. package/dist/review/test-files.js +6 -2
  114. package/dist/review/toolchain.d.ts +5 -2
  115. package/dist/review/toolchain.js +89 -7
  116. package/dist/review/toolchain.test.js +45 -4
  117. package/dist/review/typed/program.d.ts +26 -0
  118. package/dist/review/typed/program.js +90 -0
  119. package/dist/review/typed/redundancy.d.ts +10 -0
  120. package/dist/review/typed/redundancy.js +378 -0
  121. package/dist/review/typed/redundancy.test.d.ts +1 -0
  122. package/dist/review/typed/redundancy.test.js +179 -0
  123. package/dist/review/typed/schema-nullability.d.ts +6 -0
  124. package/dist/review/typed/schema-nullability.js +218 -0
  125. package/dist/review/typed/schema-nullability.test.d.ts +1 -0
  126. package/dist/review/typed/schema-nullability.test.js +63 -0
  127. package/dist/review/unused-exports.js +10 -7
  128. package/dist/semantic/learn/types.d.ts +2 -2
  129. package/dist/services/adaptive-thresholds.d.ts +2 -52
  130. package/dist/services/adaptive-thresholds.js +2 -147
  131. package/dist/services/adaptive-thresholds.test.js +1 -81
  132. package/dist/settings.d.ts +14 -6
  133. package/dist/settings.js +0 -12
  134. package/dist/storage/team-diagnostics.js +1 -1
  135. package/dist/storage/team-schema.d.ts +1 -1
  136. package/dist/storage/team-schema.js +2 -0
  137. package/dist/storage/team-store.js +26 -3
  138. package/dist/storage/team-sync-scope.test.js +35 -2
  139. package/dist/telemetry/telemetry.d.ts +5 -0
  140. package/dist/telemetry/telemetry.js +11 -1
  141. package/dist/templates/paradigms.js +0 -8
  142. package/dist/templates/presets.d.ts +0 -1
  143. package/dist/templates/presets.js +0 -23
  144. package/dist/templates/universal-config.js +16 -46
  145. package/dist/types/index.d.ts +202 -574
  146. package/dist/types/index.js +64 -67
  147. package/dist/types/index.test.d.ts +1 -0
  148. package/dist/types/index.test.js +33 -0
  149. package/dist/utils/command-line.d.ts +8 -0
  150. package/dist/utils/command-line.js +8 -0
  151. package/dist/utils/command-line.test.d.ts +1 -0
  152. package/dist/utils/command-line.test.js +8 -0
  153. package/dist/utils/profile.js +1 -1
  154. package/package.json +6 -7
@@ -15,6 +15,17 @@ describe('quiet by default', () => {
15
15
  expect(quiet.advisory.map(f => f.id)).toEqual(['promise-safety', 'file-size']);
16
16
  expect(quietSplit(dir, all, true).speaking).toHaveLength(5);
17
17
  });
18
+ it('speaks on what must be fixed, the one rule the stop hook and the push gate share: critical, or high and verified or from a security gate', () => {
19
+ const all = [
20
+ { ...finding('frontend-secret-exposure'), severity: 'high', provenance: 'security' },
21
+ { ...finding('promise-safety'), severity: 'high' },
22
+ { ...finding('promise-safety', 'verified'), severity: 'high', verified: true },
23
+ { ...finding('file-size'), severity: 'critical' },
24
+ finding('migration-order'),
25
+ ];
26
+ expect(quietSplit(dir, all).speaking.map(f => f.id)).toEqual(['frontend-secret-exposure', 'promise-safety', 'file-size', 'migration-order']);
27
+ expect(quietSplit(dir, all).advisory.map(f => f.details)).toEqual(['d']);
28
+ });
18
29
  it('never reports a dismissed finding again, even after its line moves', () => {
19
30
  const leak = finding('security-patterns', 'Hardcoded token in `client`');
20
31
  expect(dismissFinding(dir, findingKey(leak), 'test fixture token, never deployed')).toBe(true);
@@ -14,6 +14,8 @@ export interface ReviewInput {
14
14
  deep?: Omit<DeepOptions, 'focusLines' | 'removedLines' | 'diff'>;
15
15
  /** Read dismissals and check outcomes at this commit (the base), not as the change left them. */
16
16
  trustedRef?: string;
17
+ /** Run the typed checks (review/typed): the project's TypeScript program takes seconds, so at push, in `rigour review` and in a backtest, not at every stop. */
18
+ typed?: boolean;
17
19
  }
18
20
  export interface ReviewResult {
19
21
  status: 'PASS' | 'FAIL' | 'ERROR';
@@ -39,6 +41,10 @@ export interface ReviewResult {
39
41
  gateErrors: string[];
40
42
  /** rigour.yml or .rigour/ files this change edits: they steer the review, so they are called out. */
41
43
  controlFilesChanged: string[];
44
+ /** Candidates for the reviewer to confirm (a nested scan, a property that only leaves through serialisation), from the typed checks. */
45
+ hints: string[];
46
+ /** Why the typed checks could not run on a TypeScript project (`gateErrors` then names `typed-checks-unavailable`). */
47
+ typedError?: string;
42
48
  }
43
49
  export interface ReviewFinding {
44
50
  id: string;
@@ -24,6 +24,7 @@ import { unusedExportFailures } from './unused-exports.js';
24
24
  import { queryPatternFailures } from './query-patterns.js';
25
25
  import { optionalParamFailures } from './optional-params.js';
26
26
  import { duplicateFunctionFailures } from './duplicate-functions.js';
27
+ import { typedChecks, TYPED_CHECKS } from './typed/redundancy.js';
27
28
  import { loopCopyFailures } from './loop-copies.js';
28
29
  import { partialFixFailures } from './partial-fixes.js';
29
30
  import { partialWiringFailures } from './partial-wiring.js';
@@ -34,16 +35,21 @@ export async function reviewChange(input) {
34
35
  const changedLines = withoutGenerated(input.cwd, parseDiff(diff));
35
36
  const targets = input.files?.length ? input.files : Object.keys(changedLines);
36
37
  if (targets.length === 0) {
37
- 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) };
38
+ 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: [] };
38
39
  }
39
40
  const deep = input.deep ? { ...input.deep, focusLines: changedLinesByFile(changedLines), removedLines: removedByFile(diff), diff } : undefined;
40
- const report = await new GateRunner(input.config).run(input.cwd, await normalizeScopePatterns(input.cwd, targets), deep);
41
+ // The team's `commands:` run at push (toolchain.ts), where a failure blocks; here they would only cost time.
42
+ const report = await new GateRunner({ ...input.config, commands: {} }).run(input.cwd, await normalizeScopePatterns(input.cwd, targets), deep);
41
43
  const preexisting = await dropPreexisting(input, report, targets);
42
44
  if (input.diffTests && deep)
43
45
  report.failures.push(...await diffTestFailures(input.cwd, input.source, deep));
44
46
  report.failures.push(...migrationOrderFailures(input.cwd, diff, input.source, input.config));
45
47
  report.failures.push(...unusedExportFailures(input.cwd, diff, input.config), ...orphanFileFailures(input.cwd, diff, input.config));
46
48
  report.failures.push(...queryPatternFailures(input.cwd, changedLines, input.config), ...optionalParamFailures(input.cwd, changedLines, input.config), ...duplicateFunctionFailures(input.cwd, changedLines, input.config), ...loopCopyFailures(input.cwd, changedLines, input.config), ...partialFixFailures(input.cwd, changedLines, input.config), ...partialWiringFailures(input.cwd, changedLines, input.config));
49
+ const typed = input.typed ? typedChecks(input.cwd, changedLines, input.config) : { failures: [], hints: [] };
50
+ report.failures.push(...typed.failures);
51
+ if (typed.error)
52
+ report.summary[TYPED_CHECKS] = 'ERROR'; // a check that could not run is a crashed gate, never a pass
47
53
  const split = splitByChangedLines(report.failures, changedLines, deep ? changedFunctionSpans(input.cwd, changedLines) : {}, removedByFile(diff));
48
54
  const deepError = deepAnalysisError(report);
49
55
  const quiet = quietSplit(input.cwd, split.findings, input.config.review?.include_heuristics, input.trustedRef);
@@ -66,7 +72,9 @@ export async function reviewChange(input) {
66
72
  report,
67
73
  gateErrors,
68
74
  controlFilesChanged: controlFiles(diff),
75
+ hints: typed.hints,
69
76
  ...(deepError ? { deepError } : {}),
77
+ ...(typed.error ? { typedError: typed.error } : {}),
70
78
  };
71
79
  }
72
80
  /** Drop the rules' findings the base already had; returns how many. Model findings stay: the model reviews only the change. */
@@ -0,0 +1,51 @@
1
+ import { type Exec } from './exec.js';
2
+ export type ReviewerName = 'claude' | 'cursor' | 'codex';
3
+ export type Vendor = 'anthropic' | 'cursor' | 'openai';
4
+ export type ReviewMode = 'single' | 'cross' | 'full';
5
+ export interface Adapter {
6
+ vendor: Vendor;
7
+ binary: string;
8
+ /** The command line for one review: the prompt is passed as text, never through a shell. */
9
+ args(prompt: string, model: string | undefined): string[];
10
+ /** The reviewer's final message and, when the CLI reports them, what the run cost and the tokens it used. */
11
+ answer(stdout: string): {
12
+ text: string;
13
+ } & Spend;
14
+ }
15
+ /** What a run used: dollars when the CLI reports them (Claude Code), tokens otherwise (Codex reports only tokens). */
16
+ export interface Spend {
17
+ costUsd?: number;
18
+ tokens?: Tokens;
19
+ }
20
+ export interface Tokens {
21
+ input: number;
22
+ output: number;
23
+ }
24
+ export declare const ADAPTERS: Record<ReviewerName, Adapter>;
25
+ export declare function isReviewerName(name: string): name is ReviewerName;
26
+ export interface Installed {
27
+ binary: string;
28
+ version: string;
29
+ }
30
+ /**
31
+ * The newest installed copy of the adapter's CLI, or undefined when none is. Several copies are
32
+ * common (an old global npm install first on PATH, a current one from Homebrew), and the old one
33
+ * refuses current models, so the version decides, not PATH order.
34
+ */
35
+ export declare function resolveAdapter(adapter: Adapter, cwd: string, exec: Exec): Promise<Installed | undefined>;
36
+ /** Which vendors wrote the commits, from their Co-Authored-By trailers. No trailer means a person. */
37
+ export declare function vendorsOf(trailers: string): Set<Vendor>;
38
+ /**
39
+ * The reviewers a run uses. single: the first available of the list. cross: the first available
40
+ * whose vendor is not on the trailers, else the first available. full: that one plus the next
41
+ * available of each other vendor, up to `judges`. Empty when none is installed.
42
+ */
43
+ export declare function selectReviewers(candidates: ReviewerName[], mode: ReviewMode, authors: Set<Vendor>, available: Set<ReviewerName>, judges?: number): ReviewerName[];
44
+ /** Every reviewer Rigour can run, and whether this machine has it: what bounds the judges of a panel. */
45
+ export declare function reviewerAvailability(cwd: string, exec?: Exec): Promise<Array<{
46
+ name: ReviewerName;
47
+ vendor: Vendor;
48
+ binary: string;
49
+ installed: boolean;
50
+ version?: string;
51
+ }>>;
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Reviewer adapters: the person's own coding-agent CLIs, each run headless and read-only on the
3
+ * same prompt. `review.reviewer.reviewers` is the contract (an explicit list, any vendors); the
4
+ * mode picks among it: `single` runs the first available (the default, and the fastest), `cross`
5
+ * prefers a vendor not on the commits' Co-Authored-By trailers, `full` runs two vendors and the
6
+ * verdicts are merged. Which mode earns its minutes is decided by the backtest ledger, which
7
+ * records the reviewer behind each catch, not by preference.
8
+ */
9
+ import fs from 'fs';
10
+ import os from 'os';
11
+ import path from 'path';
12
+ import { defaultExec } from './exec.js';
13
+ const n = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
14
+ const READ_ONLY_TOOLS = ['Read', 'Grep', 'Glob', 'Bash(git diff:*)', 'Bash(git show:*)', 'Bash(git log:*)', 'Bash(git grep:*)'];
15
+ export const ADAPTERS = {
16
+ claude: {
17
+ vendor: 'anthropic',
18
+ binary: 'claude',
19
+ // Isolated: no MCP servers, no hooks, no user-level settings (an output style or permission
20
+ // of the author's session must not shape the reviewer), read-only tools.
21
+ args: (prompt, model) => [
22
+ '-p', prompt,
23
+ ...(model ? ['--model', model] : []),
24
+ '--output-format', 'json',
25
+ '--max-turns', '80',
26
+ '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}',
27
+ '--setting-sources', 'project', '--settings', '{"hooks":{},"outputStyle":"default"}',
28
+ '--allowedTools', ...READ_ONLY_TOOLS,
29
+ '--disallowedTools', 'Edit', 'Write', 'NotebookEdit', 'Bash(git push:*)', 'Bash(git commit:*)',
30
+ ],
31
+ answer: stdout => {
32
+ try {
33
+ const parsed = JSON.parse(stdout);
34
+ const usage = parsed.usage;
35
+ return {
36
+ text: String(parsed.result ?? ''),
37
+ ...(typeof parsed.total_cost_usd === 'number' ? { costUsd: parsed.total_cost_usd } : {}),
38
+ ...(usage ? { tokens: { input: n(usage.input_tokens) + n(usage.cache_read_input_tokens) + n(usage.cache_creation_input_tokens), output: n(usage.output_tokens) } } : {}),
39
+ };
40
+ }
41
+ catch {
42
+ return { text: stdout };
43
+ }
44
+ },
45
+ },
46
+ cursor: {
47
+ vendor: 'cursor',
48
+ binary: 'cursor-agent',
49
+ // Ask mode is read-only and cannot run git: every input the prompt names is a file.
50
+ args: (prompt, model) => ['-p', '--print', '--output-format', 'json', '--trust', '--mode', 'ask', '--model', model ?? 'auto', prompt],
51
+ answer: stdout => {
52
+ try {
53
+ const pick = (o) => typeof o === 'string' ? o : o && typeof o === 'object' ? (Array.isArray(o) ? pick(o[o.length - 1]) : String(o.result ?? o.text ?? o.content ?? o.message ?? '')) : '';
54
+ return { text: pick(JSON.parse(stdout)) };
55
+ }
56
+ catch {
57
+ return { text: stdout };
58
+ }
59
+ },
60
+ },
61
+ codex: {
62
+ vendor: 'openai',
63
+ binary: 'codex',
64
+ args: (prompt, model) => ['exec', '--sandbox', 'read-only', '--json', ...(model ? ['--model', model] : []), '-c', 'model_reasoning_effort=high', prompt],
65
+ // `codex exec --json` streams events; the last text-bearing one carries the answer.
66
+ // Warnings arrive as `error` items with a `message`, not `text`, so they are never taken for the answer.
67
+ // `turn.completed` carries the tokens (Codex reports no dollars).
68
+ answer: stdout => {
69
+ let text = '';
70
+ let tokens;
71
+ for (const line of stdout.split('\n').filter(Boolean)) {
72
+ try {
73
+ const event = JSON.parse(line);
74
+ const candidate = event?.item?.text ?? event?.msg?.message ?? event?.text;
75
+ if (typeof candidate === 'string')
76
+ text = candidate;
77
+ if (event?.type === 'turn.completed' && event.usage)
78
+ tokens = { input: n(event.usage.input_tokens), output: n(event.usage.output_tokens) + n(event.usage.reasoning_output_tokens) };
79
+ }
80
+ catch {
81
+ // not an event line
82
+ }
83
+ }
84
+ return { text: text || stdout, ...(tokens ? { tokens } : {}) };
85
+ },
86
+ },
87
+ };
88
+ export function isReviewerName(name) {
89
+ return name in ADAPTERS;
90
+ }
91
+ /** Where a CLI tends to be installed besides PATH: a global npm prefix, Homebrew, a user-local bin, the agent's own installer. */
92
+ const EXTRA_BIN_DIRS = ['/opt/homebrew/bin', '/usr/local/bin', path.join(os.homedir(), '.local', 'bin'), path.join(os.homedir(), '.npm-global', 'bin'), path.join(os.homedir(), '.claude', 'local')];
93
+ /**
94
+ * The newest installed copy of the adapter's CLI, or undefined when none is. Several copies are
95
+ * common (an old global npm install first on PATH, a current one from Homebrew), and the old one
96
+ * refuses current models, so the version decides, not PATH order.
97
+ */
98
+ export async function resolveAdapter(adapter, cwd, exec) {
99
+ const names = process.platform === 'win32' ? [`${adapter.binary}.cmd`, `${adapter.binary}.exe`, adapter.binary] : [adapter.binary];
100
+ const dirs = [...(process.env.PATH ?? '').split(path.delimiter), ...EXTRA_BIN_DIRS].filter(Boolean);
101
+ const candidates = new Set();
102
+ for (const dir of dirs) {
103
+ for (const name of names) {
104
+ const candidate = path.join(dir, name);
105
+ if (executable(candidate))
106
+ candidates.add(candidate);
107
+ }
108
+ }
109
+ let best;
110
+ for (const binary of candidates) {
111
+ const result = await exec(binary, ['--version'], { cwd, timeoutMs: 20_000 });
112
+ if (result.exitCode !== 0)
113
+ continue;
114
+ const version = result.stdout.trim().split('\n')[0];
115
+ if (!best || newer(version, best.version))
116
+ best = { binary, version };
117
+ }
118
+ return best;
119
+ }
120
+ function executable(file) {
121
+ try {
122
+ fs.accessSync(file, process.platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK);
123
+ return fs.statSync(file).isFile();
124
+ }
125
+ catch {
126
+ return false;
127
+ }
128
+ }
129
+ /** `a` carries a higher dotted version than `b` (the first such run of digits in each line). */
130
+ function newer(a, b) {
131
+ const parse = (line) => (line.match(/\d+(?:\.\d+)+/)?.[0] ?? '0').split('.').map(Number);
132
+ const [x, y] = [parse(a), parse(b)];
133
+ for (let i = 0; i < Math.max(x.length, y.length); i++) {
134
+ if ((x[i] ?? 0) !== (y[i] ?? 0))
135
+ return (x[i] ?? 0) > (y[i] ?? 0);
136
+ }
137
+ return false;
138
+ }
139
+ /** Which vendors wrote the commits, from their Co-Authored-By trailers. No trailer means a person. */
140
+ export function vendorsOf(trailers) {
141
+ const vendors = new Set();
142
+ for (const line of trailers.toLowerCase().split('\n')) {
143
+ if (/claude/.test(line))
144
+ vendors.add('anthropic');
145
+ if (/cursor/.test(line))
146
+ vendors.add('cursor');
147
+ if (/codex|openai/.test(line))
148
+ vendors.add('openai');
149
+ }
150
+ return vendors;
151
+ }
152
+ /**
153
+ * The reviewers a run uses. single: the first available of the list. cross: the first available
154
+ * whose vendor is not on the trailers, else the first available. full: that one plus the next
155
+ * available of each other vendor, up to `judges`. Empty when none is installed.
156
+ */
157
+ export function selectReviewers(candidates, mode, authors, available, judges = 2) {
158
+ const installed = candidates.filter(name => available.has(name));
159
+ if (installed.length === 0)
160
+ return [];
161
+ let first = installed[0];
162
+ if (mode !== 'single')
163
+ first = installed.find(name => !authors.has(ADAPTERS[name].vendor)) ?? first;
164
+ if (mode !== 'full')
165
+ return [first];
166
+ const chosen = [first];
167
+ for (const name of installed) {
168
+ if (chosen.length >= judges)
169
+ break;
170
+ if (!chosen.some(c => ADAPTERS[c].vendor === ADAPTERS[name].vendor))
171
+ chosen.push(name);
172
+ }
173
+ return chosen;
174
+ }
175
+ /** Every reviewer Rigour can run, and whether this machine has it: what bounds the judges of a panel. */
176
+ export async function reviewerAvailability(cwd, exec = defaultExec) {
177
+ return Promise.all(Object.keys(ADAPTERS).map(async (name) => {
178
+ const found = await resolveAdapter(ADAPTERS[name], cwd, exec);
179
+ return { name, vendor: ADAPTERS[name].vendor, binary: ADAPTERS[name].binary, installed: !!found, ...(found ? { version: found.version } : {}) };
180
+ }));
181
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ADAPTERS } from './adapters.js';
3
+ /** A real `codex exec --json` run (codex-cli 0.160.1), the warning's path shortened: the shape the adapter must read. */
4
+ const CODEX = [
5
+ '{"type":"thread.started","thread_id":"01a11477-f9fd-7472-8d31-5863de4cbbdb"}',
6
+ '{"type":"item.completed","item":{"id":"item_0","type":"error","message":"Codex is ignoring 1 unrecognized configuration setting. Check for typos or deprecated settings.\\n user (~/.codex/config.toml)"}}',
7
+ '{"type":"turn.started"}',
8
+ '{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"{\\"answers\\":[{\\"id\\":\\"a1\\",\\"call\\":\\"unsure\\",\\"evidence\\":\\"none\\"}]}"}}',
9
+ '{"type":"turn.completed","usage":{"input_tokens":26565,"cached_input_tokens":13184,"cache_write_input_tokens":0,"output_tokens":24,"reasoning_output_tokens":0}}',
10
+ ].join('\n');
11
+ describe('reading an agent CLI\'s answer', () => {
12
+ it('takes Codex\'s last agent message, never a warning, and its tokens', () => {
13
+ expect(ADAPTERS.codex.answer(CODEX)).toEqual({ text: '{"answers":[{"id":"a1","call":"unsure","evidence":"none"}]}', tokens: { input: 26565, output: 24 } });
14
+ });
15
+ it('takes Claude Code\'s result, its cost and its tokens', () => {
16
+ const out = JSON.stringify({ result: '{"prior_points":[]}', total_cost_usd: 0.42, usage: { input_tokens: 10, cache_read_input_tokens: 900, cache_creation_input_tokens: 90, output_tokens: 300 } });
17
+ expect(ADAPTERS.claude.answer(out)).toEqual({ text: '{"prior_points":[]}', costUsd: 0.42, tokens: { input: 1000, output: 300 } });
18
+ });
19
+ it('falls back to the raw output when it is not what the CLI usually prints', () => {
20
+ expect(ADAPTERS.claude.answer('not json')).toEqual({ text: 'not json' });
21
+ expect(ADAPTERS.codex.answer('plain text')).toEqual({ text: 'plain text' });
22
+ });
23
+ });
@@ -0,0 +1,41 @@
1
+ import type { Config } from '../../types/index.js';
2
+ import { type ModeRecord, type ReviewerResult } from '../reviewer.js';
3
+ import { type Exec } from './exec.js';
4
+ import { type DaySpend, type ReviewAttempt } from './store.js';
5
+ import { type OpenItem } from './verdict.js';
6
+ export interface BackgroundJob {
7
+ head: string;
8
+ branch: string;
9
+ base: string;
10
+ }
11
+ /**
12
+ * Starts `command` detached (its own process group, output to the branch's log) and records its
13
+ * pid; a job still running for the same branch is ended first. Returns the log path.
14
+ */
15
+ export declare function startBackgroundReview(cwd: string, job: BackgroundJob, command: string[], exec?: Exec): Promise<string | undefined>;
16
+ /** The detached process: review the commit in a worktree of its own, write the verdict, tell the person. */
17
+ export declare function backgroundReview(cwd: string, job: BackgroundJob, config: Config, exec?: Exec, log?: (line: string) => void): Promise<ReviewerResult>;
18
+ export interface ReviewStatus {
19
+ branch: string;
20
+ /** A review still running, and for which commit. */
21
+ running?: {
22
+ pid: number;
23
+ head: string;
24
+ };
25
+ /** What the reviewer spent today in this repository: the count the daily caps are checked against. */
26
+ today: DaySpend;
27
+ /** The last review that ended without a verdict, when it is newer than the last verdict. */
28
+ attempt?: ReviewAttempt;
29
+ /** The last verdict recorded for the branch. */
30
+ last?: {
31
+ head: string;
32
+ mode: 'full' | 'delta';
33
+ at: string;
34
+ open: OpenItem[];
35
+ ran?: ModeRecord;
36
+ disputed: OpenItem[];
37
+ };
38
+ log?: string;
39
+ }
40
+ /** What the background reviewer has done for a branch: running, the last verdict and its open items. */
41
+ export declare function reviewStatus(cwd: string, branch: string, exec?: Exec): Promise<ReviewStatus | undefined>;
@@ -0,0 +1,153 @@
1
+ /**
2
+ * The model review that does not hold the push. Once the deterministic gates pass, the pushed
3
+ * commit is reviewed in a detached worktree of that exact commit (later commits or edits cannot
4
+ * change what it reads), in its own process group, so the push returns in seconds. A newer push
5
+ * of the same branch ends a review still running for an older commit. The verdict goes to the
6
+ * store (shared by the worktree, the checkout and the status command), a desktop notification
7
+ * and `rigour review --status`. The hard stop stays explicit: `rigour review --reviewer --full`
8
+ * before asking a person to look.
9
+ */
10
+ import { spawn, spawnSync } from 'child_process';
11
+ import fs from 'fs';
12
+ import path from 'path';
13
+ import { reviewChange } from '../review.js';
14
+ import { pushReviewSkip, runReviewer } from '../reviewer.js';
15
+ import { defaultExec, GH_TIMEOUT_MS } from './exec.js';
16
+ import { dismissedAs, readReviewDismissals, reviewerInputs } from './context.js';
17
+ import { VerdictStore } from './store.js';
18
+ import { itemLine } from './verdict.js';
19
+ /**
20
+ * Starts `command` detached (its own process group, output to the branch's log) and records its
21
+ * pid; a job still running for the same branch is ended first. Returns the log path.
22
+ */
23
+ export async function startBackgroundReview(cwd, job, command, exec = defaultExec) {
24
+ const store = await VerdictStore.open(cwd, exec);
25
+ if (!store)
26
+ return undefined;
27
+ endRunning(store, job.branch);
28
+ const log = store.branchFile(job.branch, 'log');
29
+ const out = fs.openSync(log, 'w');
30
+ const child = spawn(command[0], command.slice(1), { cwd, detached: true, stdio: ['ignore', out, out], env: process.env });
31
+ fs.closeSync(out);
32
+ if (child.pid)
33
+ fs.writeFileSync(store.branchFile(job.branch, 'pid'), `${child.pid} ${job.head}\n`);
34
+ child.unref();
35
+ return log;
36
+ }
37
+ /** The detached process: review the commit in a worktree of its own, write the verdict, tell the person. */
38
+ export async function backgroundReview(cwd, job, config, exec = defaultExec, log = line => process.stdout.write(`${line}\n`)) {
39
+ const store = await VerdictStore.open(cwd, exec);
40
+ if (!store)
41
+ throw new Error(`${cwd} is not a repository`);
42
+ const skip = await pushReviewSkip(cwd, job.branch, job.head, config, exec);
43
+ if (skip) {
44
+ store.recordAttempt(job.branch, { head: job.head, outcome: 'skipped', reason: skip, at: new Date().toISOString() });
45
+ log(`review of ${job.head.slice(0, 9)}: skipped (${skip})`);
46
+ clearPid(store, job.branch);
47
+ return { outcome: 'skipped', items: [], unverified: [], resolved: [], answerInReply: [], notes: [], disputed: [], dropped: [], dismissed: [], reason: skip, reviewers: [], cached: false };
48
+ }
49
+ const worktree = store.worktreeDir(job.head);
50
+ const added = await exec('git', ['worktree', 'add', '--detach', worktree, job.head], { cwd, timeoutMs: 5 * GH_TIMEOUT_MS });
51
+ if (added.exitCode !== 0 && !fs.existsSync(path.join(worktree, '.git')))
52
+ throw new Error(`could not check out ${job.head.slice(0, 9)} for the review: ${added.stderr.trim()}`);
53
+ try {
54
+ shareDependencies(cwd, worktree);
55
+ // The typed checks' hints (a nested scan, a value that only leaves through serialisation) are the reviewer's candidates to confirm.
56
+ const review = await reviewChange({ cwd: worktree, config, source: { mode: 'base', base: job.base }, typed: true });
57
+ // The worktree has the commit, not the team's untracked state: dismissals and reviewed functions come from the checkout.
58
+ const result = await runReviewer(worktree, job.base, config, exec, log, { trigger: 'push', branch: job.branch, stateRoot: cwd, ...reviewerInputs(review) });
59
+ log(`review of ${job.head.slice(0, 9)}: ${result.outcome}${result.reason ? ` (${result.reason})` : ''}${result.items.length ? `, ${result.items.length} open item(s)` : ''}`);
60
+ for (const item of result.items)
61
+ log(` ${itemLine(item)}`);
62
+ notify(`Rigour review of ${job.branch} @ ${job.head.slice(0, 9)}: ${result.outcome}${result.items.length ? `, ${result.items.length} open item(s)` : ''}`);
63
+ return result;
64
+ }
65
+ finally {
66
+ await exec('git', ['worktree', 'remove', '--force', worktree], { cwd, timeoutMs: 5 * GH_TIMEOUT_MS });
67
+ clearPid(store, job.branch);
68
+ }
69
+ }
70
+ /** What the background reviewer has done for a branch: running, the last verdict and its open items. */
71
+ export async function reviewStatus(cwd, branch, exec = defaultExec) {
72
+ const store = await VerdictStore.open(cwd, exec);
73
+ if (!store)
74
+ return undefined;
75
+ const status = { branch, today: store.spend() };
76
+ const running = runningJob(store, branch);
77
+ if (running)
78
+ status.running = running;
79
+ const state = store.branchState(branch);
80
+ const attempt = store.attempt(branch);
81
+ if (attempt && (!state || attempt.at > state.at))
82
+ status.attempt = attempt;
83
+ if (state) {
84
+ const verdict = store.readJson(state.verdict);
85
+ const dismissals = readReviewDismissals(cwd);
86
+ status.last = {
87
+ head: state.head, mode: state.mode, at: state.at,
88
+ // A finding dismissed since this verdict is not work any more: it leaves the list now, not at the next review.
89
+ open: (store.readJson(store.openPath(state.verdict)) ?? []).filter(item => !dismissedAs(item, dismissals)),
90
+ ...(verdict?.inputs?.mode ? { ran: verdict.inputs.mode } : {}),
91
+ disputed: (verdict?.panel?.items ?? []).filter(d => d.status === 'disputed').map(d => ({ ...d.item, reviewer: d.judges.join('+') })),
92
+ };
93
+ }
94
+ const log = store.branchFile(branch, 'log');
95
+ if (fs.existsSync(log))
96
+ status.log = log;
97
+ return status;
98
+ }
99
+ function runningJob(store, branch) {
100
+ const pidFile = store.branchFile(branch, 'pid');
101
+ if (!fs.existsSync(pidFile))
102
+ return undefined;
103
+ const [pid, head] = fs.readFileSync(pidFile, 'utf8').trim().split(' ');
104
+ if (!alive(Number(pid))) {
105
+ fs.unlinkSync(pidFile);
106
+ return undefined;
107
+ }
108
+ return { pid: Number(pid), head: head ?? '' };
109
+ }
110
+ /** Ends a review still running for the branch (the whole process group: the CLI and the agent it runs). */
111
+ function endRunning(store, branch) {
112
+ const running = runningJob(store, branch);
113
+ if (!running)
114
+ return;
115
+ try {
116
+ process.kill(process.platform === 'win32' ? running.pid : -running.pid, 'SIGTERM');
117
+ }
118
+ catch {
119
+ // already gone
120
+ }
121
+ fs.unlinkSync(store.branchFile(branch, 'pid'));
122
+ }
123
+ /** The checkout's installed dependencies serve the worktree too (the typed checks need the project's compiler), never a download. */
124
+ function shareDependencies(cwd, worktree) {
125
+ const root = path.join(cwd, 'node_modules');
126
+ if (fs.existsSync(root) && !fs.existsSync(path.join(worktree, 'node_modules')))
127
+ fs.symlinkSync(root, path.join(worktree, 'node_modules'), 'junction');
128
+ }
129
+ function alive(pid) {
130
+ if (!pid)
131
+ return false;
132
+ try {
133
+ process.kill(pid, 0);
134
+ return true;
135
+ }
136
+ catch {
137
+ return false;
138
+ }
139
+ }
140
+ /** A desktop notification where the platform has one; silence otherwise. */
141
+ function notify(message) {
142
+ const safe = message.replace(/["\\]/g, '');
143
+ if (process.platform === 'darwin')
144
+ spawnSync('osascript', ['-e', `display notification "${safe}" with title "Rigour"`], { stdio: 'ignore', timeout: 5000 });
145
+ else if (process.platform === 'linux')
146
+ spawnSync('notify-send', ['Rigour', safe], { stdio: 'ignore', timeout: 5000 });
147
+ }
148
+ /** This process's pid record for the branch, removed when it ends (a newer job's record is left alone). */
149
+ function clearPid(store, branch) {
150
+ const pidFile = store.branchFile(branch, 'pid');
151
+ if (fs.existsSync(pidFile) && fs.readFileSync(pidFile, 'utf8').startsWith(`${process.pid} `))
152
+ fs.unlinkSync(pidFile);
153
+ }
@@ -0,0 +1 @@
1
+ export {};