@rigour-labs/core 6.7.5 → 6.7.7

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 (46) hide show
  1. package/dist/index.d.ts +2 -0
  2. package/dist/index.js +2 -0
  3. package/dist/review/backtest-init.d.ts +15 -0
  4. package/dist/review/backtest-init.js +30 -16
  5. package/dist/review/backtest-judges.test.js +1 -1
  6. package/dist/review/backtest-last.d.ts +40 -0
  7. package/dist/review/backtest-last.js +116 -0
  8. package/dist/review/backtest-last.test.d.ts +1 -0
  9. package/dist/review/backtest-last.test.js +109 -0
  10. package/dist/review/backtest.d.ts +26 -0
  11. package/dist/review/backtest.js +5 -5
  12. package/dist/review/reviewer/adapters.d.ts +11 -4
  13. package/dist/review/reviewer/adapters.js +44 -5
  14. package/dist/review/reviewer/adapters.test.js +16 -1
  15. package/dist/review/reviewer/api-judge.d.ts +28 -0
  16. package/dist/review/reviewer/api-judge.js +161 -0
  17. package/dist/review/reviewer/api-judge.test.d.ts +1 -0
  18. package/dist/review/reviewer/api-judge.test.js +87 -0
  19. package/dist/review/reviewer/background.js +1 -1
  20. package/dist/review/reviewer/context.d.ts +3 -1
  21. package/dist/review/reviewer/context.js +8 -3
  22. package/dist/review/reviewer/context.test.js +2 -0
  23. package/dist/review/reviewer/prompt.js +11 -4
  24. package/dist/review/reviewer/record.d.ts +71 -0
  25. package/dist/review/reviewer/record.js +69 -0
  26. package/dist/review/reviewer/record.test.d.ts +1 -0
  27. package/dist/review/reviewer/record.test.js +32 -0
  28. package/dist/review/reviewer/settings.d.ts +10 -0
  29. package/dist/review/reviewer/settings.js +3 -1
  30. package/dist/review/reviewer/store.d.ts +2 -0
  31. package/dist/review/reviewer/store.js +4 -0
  32. package/dist/review/reviewer/usage.test.js +1 -1
  33. package/dist/review/reviewer/verdict.d.ts +34 -1
  34. package/dist/review/reviewer/verdict.js +58 -6
  35. package/dist/review/reviewer.d.ts +15 -0
  36. package/dist/review/reviewer.js +43 -12
  37. package/dist/review/reviewer.test.js +89 -3
  38. package/dist/review-learning/lessons.d.ts +2 -0
  39. package/dist/review-learning/lessons.js +3 -3
  40. package/dist/review-learning/repo-rules.d.ts +6 -4
  41. package/dist/review-learning/repo-rules.js +32 -9
  42. package/dist/review-learning/repo-rules.test.js +13 -0
  43. package/dist/templates/universal-config.js +1 -0
  44. package/dist/types/index.d.ts +74 -0
  45. package/dist/types/index.js +15 -1
  46. package/package.json +6 -6
@@ -10,11 +10,16 @@
10
10
  */
11
11
  import fs from 'fs';
12
12
  import path from 'path';
13
- import { isSpecific } from './lessons.js';
13
+ import crypto from 'crypto';
14
+ import { isSpecific, meaningfulWords } from './lessons.js';
14
15
  const RULE_FILES = ['AGENTS.md', 'CLAUDE.md', '.github/copilot-instructions.md'];
15
16
  const RULE_DIRS = ['.cursor/rules'];
16
17
  const MAX_RULE_CHARS = 600;
17
18
  const MAX_RULES = 5;
19
+ /** A paragraph that continues the rule before it (its reason, how to apply it, an example) rather than a rule of its own. */
20
+ const CONTINUES = /^\**\s*(why|how to apply|example|examples|evidence|exception|exceptions|fix|note)\b\s*:?\**\s*:?/i;
21
+ /** Worded as a requirement: the team said must, never, always, only, every, do not. A rule without these is guidance. */
22
+ const REQUIREMENT = /\b(must|never|always|only|every|do not|don't|forbidden|required|non-negotiable)\b/i;
18
23
  export function readRepoRules(cwd) {
19
24
  const files = [
20
25
  ...RULE_FILES.filter(f => fs.existsSync(path.join(cwd, f))),
@@ -45,21 +50,39 @@ export function splitRules(source, text) {
45
50
  }
46
51
  }
47
52
  flush();
48
- return blocks.map(block => ({
53
+ // A "Why:" or "How to apply:" paragraph belongs to the rule above it: alone it is not checkable.
54
+ const merged = [];
55
+ for (const block of blocks) {
56
+ if (CONTINUES.test(block) && merged.length)
57
+ merged[merged.length - 1] = `${merged[merged.length - 1]} ${block}`;
58
+ else
59
+ merged.push(block);
60
+ }
61
+ return merged.map(block => ({
62
+ id: crypto.createHash('sha256').update(`${source}\u0000${block.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()}`).digest('hex').slice(0, 10),
49
63
  source,
50
64
  text: block.length > MAX_RULE_CHARS ? `${block.slice(0, MAX_RULE_CHARS)}…` : block,
65
+ requirement: REQUIREMENT.test(block),
51
66
  paths: [...new Set([...block.matchAll(/`([\w@.~-]+\/[\w./@*-]*)`/g)].map(m => m[1].replace(/\*.*$/, '').replace(/^\.\//, '')))].filter(Boolean),
52
67
  symbols: [...new Set([...block.matchAll(/`([A-Za-z_$][\w$]*)(?:\(\))?`/g)].map(m => m[1]))].filter(isSpecific),
53
68
  }));
54
69
  }
55
- /** Rules that name a path the change touches, or a specific identifier in it; most specific first. */
56
- export function rulesForChange(rules, files, symbols) {
70
+ /**
71
+ * The rules most likely to apply to a change, most specific first: a rule naming a path the change
72
+ * touches or an identifier in it ranks above one that only shares words with it (the change's paths
73
+ * and added names, split into words), and a rule sharing fewer than two words is left out. Ranking,
74
+ * not filtering: on a large change most rules share some words, so the judge decides applicability
75
+ * rule by rule from the top `limit`.
76
+ */
77
+ function rulesForChange(rules, files, symbols, limit = MAX_RULES) {
78
+ const changeWords = new Set([...files.flatMap(f => f.split(/[/._-]+/)), ...symbols].flatMap(meaningfulWords));
57
79
  const scored = rules.map(rule => {
58
80
  const pathHits = rule.paths.filter(p => files.some(f => f === p || f.startsWith(p.endsWith('/') ? p : `${p}/`) || f.endsWith(`/${p}`))).length;
59
81
  const symbolHits = rule.symbols.filter(s => symbols.has(s)).length;
60
- return { rule, score: 3 * pathHits + 2 * symbolHits };
82
+ const shared = new Set(meaningfulWords(rule.text).filter(w => changeWords.has(w))).size;
83
+ return { rule, named: 3 * pathHits + 2 * symbolHits, shared };
61
84
  });
62
- return scored.filter(s => s.score > 0).sort((a, b) => b.score - a.score).slice(0, MAX_RULES).map(s => s.rule);
85
+ return scored.filter(s => s.named > 0 || s.shared >= 2).sort((a, b) => b.named - a.named || b.shared - a.shared).slice(0, limit).map(s => s.rule);
63
86
  }
64
87
  export function rulesSection(rules) {
65
88
  if (rules.length === 0)
@@ -74,11 +97,11 @@ function listRuleFiles(cwd, dir) {
74
97
  return [];
75
98
  }
76
99
  }
77
- /** The rules that apply to a diff's changed files and added identifiers. */
78
- export function rulesForDiff(cwd, diff, enabled = false) {
100
+ /** The rules that apply to a diff's changed files and added identifiers, the top `limit`. */
101
+ export function rulesForDiff(cwd, diff, enabled = false, limit = MAX_RULES) {
79
102
  if (!enabled)
80
103
  return [];
81
104
  const files = [...diff.matchAll(/^\+\+\+ b\/(.+)$/gm)].map(m => m[1].trim());
82
105
  const added = diff.split('\n').filter(line => line.startsWith('+') && !line.startsWith('+++')).join('\n');
83
- return rulesForChange(readRepoRules(cwd), files, new Set(added.match(/[A-Za-z_$][\w$]*/g) ?? []));
106
+ return rulesForChange(readRepoRules(cwd), files, new Set(added.match(/[A-Za-z_$][\w$]*/g) ?? []), limit);
84
107
  }
@@ -28,6 +28,19 @@ describe('repository rules', () => {
28
28
  expect(rules.map(r => r.text.slice(0, 30))).toEqual(['**Migrations are append-only.*', 'Every outbound send goes throu', 'Prefer `fetchWithTimeout` over']);
29
29
  expect(rules[1]).toMatchObject({ paths: ['src/lib/delivery.ts'], symbols: ['deliverOrder'] });
30
30
  expect(rules[0].paths).toEqual(['migrations/']);
31
+ expect(rules.map(r => r.requirement)).toEqual([true, true, false]); // "never", "every"; "prefer" is guidance
32
+ expect(rules[0].id).toMatch(/^[0-9a-f]{10}$/);
33
+ expect(splitRules('AGENTS.md', AGENTS)[0].id).toBe(rules[0].id); // stable across runs
34
+ });
35
+ it('keeps a rule\'s "Why" and "How to apply" paragraphs with it, and ranks a rule naming the change above one that only shares its words', () => {
36
+ const text = '- **Use the design system.** Every control comes from `src/lib/ui`.\n\n**Why:** one source of styling.\n\n**How to apply:** import from `$lib/ui`, never a raw `<button>`.\n\n- Bound both ends of every time window a scheduled job reads.\n\n- Name the index a new query relies on in the migrations.\n';
37
+ const rules = splitRules('AGENTS.md', text);
38
+ expect(rules.map(r => r.text.slice(0, 24))).toEqual(['**Use the design system.', 'Bound both ends of every', 'Name the index a new que']);
39
+ expect(rules[0].text).toContain('**How to apply:**');
40
+ fs.writeFileSync(path.join(repo, 'AGENTS.md'), text);
41
+ const change = diff('src/lib/ui/Button.svelte', 'const timeWindow = scheduledJob.readsRows();');
42
+ expect(rulesForDiff(repo, change, true, 10).map(r => r.text.slice(0, 12))).toEqual(['**Use the de', 'Bound both e']); // the path hit first, then shared words; the index rule shares nothing
43
+ expect(rulesForDiff(repo, change, true, 1)).toHaveLength(1);
31
44
  });
32
45
  it('shows only the rules that name what the change touches, and nothing when disabled', () => {
33
46
  expect(rulesForDiff(repo, diff('migrations/2026_add.sql', 'alter table x;'), true).map(r => r.text.slice(0, 20))).toEqual(['**Migrations are app']);
@@ -293,6 +293,7 @@ export const UNIVERSAL_CONFIG = {
293
293
  escalate: 'always',
294
294
  cross_models: {},
295
295
  judge_env: {},
296
+ reasoning: {},
296
297
  },
297
298
  },
298
299
  ignore: [],
@@ -2457,6 +2457,32 @@ export declare const ConfigSchema: z.ZodObject<{
2457
2457
  escalate: z.ZodDefault<z.ZodOptional<z.ZodEnum<["always", "risk"]>>>;
2458
2458
  /** A model per reviewer name for cross-examination (a narrow verification task), e.g. { claude: "haiku" }. */
2459
2459
  cross_models: z.ZodDefault<z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>>;
2460
+ /** Reasoning effort per reviewer name where the CLI or API takes one (codex, api): low, medium or high. */
2461
+ reasoning: z.ZodDefault<z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodEnum<["low", "medium", "high"]>>>>;
2462
+ /**
2463
+ * A judge reached through a model API (OpenAI-compatible chat completions with tools), named `api` in
2464
+ * `reviewers`: any model the team can call. The key is read from the environment variable `key_env`, never
2465
+ * from this file. `vendor` is the model's maker, for cross and full modes (inferred from the model name when unset).
2466
+ */
2467
+ api: z.ZodOptional<z.ZodObject<{
2468
+ url: z.ZodString;
2469
+ model: z.ZodString;
2470
+ key_env: z.ZodDefault<z.ZodOptional<z.ZodString>>;
2471
+ vendor: z.ZodOptional<z.ZodEnum<["anthropic", "openai", "google", "other"]>>;
2472
+ max_turns: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
2473
+ }, "strip", z.ZodTypeAny, {
2474
+ model: string;
2475
+ url: string;
2476
+ key_env: string;
2477
+ max_turns: number;
2478
+ vendor?: "anthropic" | "openai" | "google" | "other" | undefined;
2479
+ }, {
2480
+ model: string;
2481
+ url: string;
2482
+ key_env?: string | undefined;
2483
+ vendor?: "anthropic" | "openai" | "google" | "other" | undefined;
2484
+ max_turns?: number | undefined;
2485
+ }>>;
2460
2486
  /**
2461
2487
  * Environment variables a judge's CLI must not see, per reviewer name, e.g. { codex: { unset: [OPENAI_API_KEY] } }
2462
2488
  * when that variable holds a key meant for another service. RIGOUR_API_KEY is never passed to a judge.
@@ -2482,12 +2508,20 @@ export declare const ConfigSchema: z.ZodObject<{
2482
2508
  judges: 2 | 3;
2483
2509
  escalate: "always" | "risk";
2484
2510
  cross_models: Record<string, string>;
2511
+ reasoning: Record<string, "high" | "medium" | "low">;
2485
2512
  judge_env: Record<string, {
2486
2513
  unset: string[];
2487
2514
  }>;
2488
2515
  model?: string | undefined;
2489
2516
  max_runs_per_day?: number | undefined;
2490
2517
  max_usd_per_day?: number | undefined;
2518
+ api?: {
2519
+ model: string;
2520
+ url: string;
2521
+ key_env: string;
2522
+ max_turns: number;
2523
+ vendor?: "anthropic" | "openai" | "google" | "other" | undefined;
2524
+ } | undefined;
2491
2525
  }, {
2492
2526
  enabled?: boolean | undefined;
2493
2527
  mode?: "single" | "cross" | "full" | undefined;
@@ -2505,6 +2539,14 @@ export declare const ConfigSchema: z.ZodObject<{
2505
2539
  judges?: 2 | 3 | undefined;
2506
2540
  escalate?: "always" | "risk" | undefined;
2507
2541
  cross_models?: Record<string, string> | undefined;
2542
+ reasoning?: Record<string, "high" | "medium" | "low"> | undefined;
2543
+ api?: {
2544
+ model: string;
2545
+ url: string;
2546
+ key_env?: string | undefined;
2547
+ vendor?: "anthropic" | "openai" | "google" | "other" | undefined;
2548
+ max_turns?: number | undefined;
2549
+ } | undefined;
2508
2550
  judge_env?: Record<string, {
2509
2551
  unset?: string[] | undefined;
2510
2552
  }> | undefined;
@@ -2526,12 +2568,20 @@ export declare const ConfigSchema: z.ZodObject<{
2526
2568
  judges: 2 | 3;
2527
2569
  escalate: "always" | "risk";
2528
2570
  cross_models: Record<string, string>;
2571
+ reasoning: Record<string, "high" | "medium" | "low">;
2529
2572
  judge_env: Record<string, {
2530
2573
  unset: string[];
2531
2574
  }>;
2532
2575
  model?: string | undefined;
2533
2576
  max_runs_per_day?: number | undefined;
2534
2577
  max_usd_per_day?: number | undefined;
2578
+ api?: {
2579
+ model: string;
2580
+ url: string;
2581
+ key_env: string;
2582
+ max_turns: number;
2583
+ vendor?: "anthropic" | "openai" | "google" | "other" | undefined;
2584
+ } | undefined;
2535
2585
  };
2536
2586
  github_account?: string | undefined;
2537
2587
  }, {
@@ -2555,6 +2605,14 @@ export declare const ConfigSchema: z.ZodObject<{
2555
2605
  judges?: 2 | 3 | undefined;
2556
2606
  escalate?: "always" | "risk" | undefined;
2557
2607
  cross_models?: Record<string, string> | undefined;
2608
+ reasoning?: Record<string, "high" | "medium" | "low"> | undefined;
2609
+ api?: {
2610
+ model: string;
2611
+ url: string;
2612
+ key_env?: string | undefined;
2613
+ vendor?: "anthropic" | "openai" | "google" | "other" | undefined;
2614
+ max_turns?: number | undefined;
2615
+ } | undefined;
2558
2616
  judge_env?: Record<string, {
2559
2617
  unset?: string[] | undefined;
2560
2618
  }> | undefined;
@@ -2832,12 +2890,20 @@ export declare const ConfigSchema: z.ZodObject<{
2832
2890
  judges: 2 | 3;
2833
2891
  escalate: "always" | "risk";
2834
2892
  cross_models: Record<string, string>;
2893
+ reasoning: Record<string, "high" | "medium" | "low">;
2835
2894
  judge_env: Record<string, {
2836
2895
  unset: string[];
2837
2896
  }>;
2838
2897
  model?: string | undefined;
2839
2898
  max_runs_per_day?: number | undefined;
2840
2899
  max_usd_per_day?: number | undefined;
2900
+ api?: {
2901
+ model: string;
2902
+ url: string;
2903
+ key_env: string;
2904
+ max_turns: number;
2905
+ vendor?: "anthropic" | "openai" | "google" | "other" | undefined;
2906
+ } | undefined;
2841
2907
  };
2842
2908
  github_account?: string | undefined;
2843
2909
  };
@@ -3120,6 +3186,14 @@ export declare const ConfigSchema: z.ZodObject<{
3120
3186
  judges?: 2 | 3 | undefined;
3121
3187
  escalate?: "always" | "risk" | undefined;
3122
3188
  cross_models?: Record<string, string> | undefined;
3189
+ reasoning?: Record<string, "high" | "medium" | "low"> | undefined;
3190
+ api?: {
3191
+ model: string;
3192
+ url: string;
3193
+ key_env?: string | undefined;
3194
+ vendor?: "anthropic" | "openai" | "google" | "other" | undefined;
3195
+ max_turns?: number | undefined;
3196
+ } | undefined;
3123
3197
  judge_env?: Record<string, {
3124
3198
  unset?: string[] | undefined;
3125
3199
  }> | undefined;
@@ -324,7 +324,7 @@ export const GatesSchema = z.object({
324
324
  timeout_ms: z.number().optional(), // per model call; default: cloud 120s, local 60s, local --max 240s
325
325
  budget_ms: z.number().optional(), // whole deep run; files not started in time are reported as skipped
326
326
  agentic: z.boolean().optional(), // cloud tier: the model may read the repository while it reviews (default true)
327
- repo_rules: z.boolean().optional(), // show the reviewer the rules in AGENTS.md / CLAUDE.md / Cursor rules that name what the change touches (default false)
327
+ repo_rules: z.boolean().optional(), // show the model review, the agent review list and the stop hook the rules in AGENTS.md / CLAUDE.md / Cursor rules that name what the change touches (default false); the reviewer always checks them
328
328
  review_lessons: z.enum(['verified', 'all', 'off']).optional(), // the team's past review lessons: they raise a function's risk and are shown to the reviewer. verified (default), all, or off
329
329
  router: z.object({
330
330
  enabled: z.boolean().optional(), // default true
@@ -423,6 +423,20 @@ export const ConfigSchema = z.object({
423
423
  escalate: z.enum(['always', 'risk']).optional().default('always'),
424
424
  /** A model per reviewer name for cross-examination (a narrow verification task), e.g. { claude: "haiku" }. */
425
425
  cross_models: z.record(ModelName).optional().default({}),
426
+ /** Reasoning effort per reviewer name where the CLI or API takes one (codex, api): low, medium or high. */
427
+ reasoning: z.record(z.enum(['low', 'medium', 'high'])).optional().default({}),
428
+ /**
429
+ * A judge reached through a model API (OpenAI-compatible chat completions with tools), named `api` in
430
+ * `reviewers`: any model the team can call. The key is read from the environment variable `key_env`, never
431
+ * from this file. `vendor` is the model's maker, for cross and full modes (inferred from the model name when unset).
432
+ */
433
+ api: z.object({
434
+ url: z.string().url(),
435
+ model: z.string().min(1),
436
+ key_env: z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/, 'an environment variable name').optional().default('RIGOUR_JUDGE_API_KEY'),
437
+ vendor: z.enum(['anthropic', 'openai', 'google', 'other']).optional(),
438
+ max_turns: z.number().int().positive().optional().default(60),
439
+ }).optional(),
426
440
  /**
427
441
  * Environment variables a judge's CLI must not see, per reviewer name, e.g. { codex: { unset: [OPENAI_API_KEY] } }
428
442
  * when that variable holds a key meant for another service. RIGOUR_API_KEY is never passed to a judge.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rigour-labs/core",
3
- "version": "6.7.5",
3
+ "version": "6.7.7",
4
4
  "description": "Rigour's review engine: deterministic gates on changed lines, rules and lessons learned from your team's fixes, and per-check precision from what you fix versus dismiss, across TypeScript, JavaScript, Python, Go, Ruby and C#.",
5
5
  "engines": {
6
6
  "node": ">=22.13"
@@ -72,11 +72,11 @@
72
72
  "@anthropic-ai/sdk": "^0.30.1",
73
73
  "pg": "^8.16.3",
74
74
  "openai": "^5.23.2",
75
- "@rigour-labs/brain-darwin-x64": "6.7.5",
76
- "@rigour-labs/brain-linux-arm64": "6.7.5",
77
- "@rigour-labs/brain-win-x64": "6.7.5",
78
- "@rigour-labs/brain-darwin-arm64": "6.7.5",
79
- "@rigour-labs/brain-linux-x64": "6.7.5"
75
+ "@rigour-labs/brain-darwin-arm64": "6.7.7",
76
+ "@rigour-labs/brain-darwin-x64": "6.7.7",
77
+ "@rigour-labs/brain-linux-arm64": "6.7.7",
78
+ "@rigour-labs/brain-win-x64": "6.7.7",
79
+ "@rigour-labs/brain-linux-x64": "6.7.7"
80
80
  },
81
81
  "devDependencies": {
82
82
  "@types/fs-extra": "^11.0.4",