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

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.
@@ -35,6 +35,8 @@ export interface ResolvedReviewer {
35
35
  judges: 2 | 3;
36
36
  escalate: 'always' | 'risk';
37
37
  cross_models: Record<string, string>;
38
+ /** The cheap model per reviewer name for cheap-model-first tiering; empty when it is off (team only). */
39
+ tiers: Record<string, string>;
38
40
  /** Reasoning effort per reviewer name (codex, api). */
39
41
  reasoning: Record<string, 'low' | 'medium' | 'high'>;
40
42
  /** The API judge, when the team configured one (review.reviewer.api). */
@@ -13,7 +13,7 @@ const RANK = { single: 0, cross: 1, full: 2 };
13
13
  /** How near a layer is to this run: the nearer wins. */
14
14
  const NEAR = { flag: 0, env: 1, user: 2, team: 3 };
15
15
  export function resolveReviewer(config, choice = {}, user = loadSettings().reviewer, env = process.env) {
16
- const team = config.review?.reviewer ?? { enabled: false, on_push: 'background', reviewers: ['claude'], mode: 'single', models: {}, timeout_ms: 15 * 60_000, panel: 'off', mode_required: false, panel_max_items: 20, dismissals: false, orchestrator: 'off', judges: 2, escalate: 'always', cross_models: {}, judge_env: {}, reasoning: {} };
16
+ const team = config.review?.reviewer ?? { enabled: false, on_push: 'background', reviewers: ['claude'], mode: 'single', models: {}, timeout_ms: 15 * 60_000, panel: 'off', mode_required: false, panel_max_items: 20, dismissals: false, orchestrator: 'off', judges: 2, escalate: 'always', cross_models: {}, tiers: { cheap: {} }, judge_env: {}, reasoning: {} };
17
17
  const refused = [];
18
18
  const envMode = parseMode(env.RIGOUR_REVIEWER_MODE);
19
19
  const envPanel = parseSwitch(env.RIGOUR_REVIEWER_PANEL);
@@ -78,6 +78,7 @@ export function resolveReviewer(config, choice = {}, user = loadSettings().revie
78
78
  judges: floor ? Math.max(team.judges, user?.judges ?? team.judges) : user?.judges ?? team.judges,
79
79
  escalate: requiredEscalation(team, user, refused),
80
80
  cross_models: team.cross_models,
81
+ tiers: team.tiers?.cheap ?? {},
81
82
  judge_env: team.judge_env ?? {},
82
83
  reasoning: team.reasoning ?? {},
83
84
  ...(team.api ? { api: team.api } : {}),
@@ -43,6 +43,8 @@ export interface ReviewCost {
43
43
  runs: number;
44
44
  /** A verdict reused instead of run: for the same content on another commit (`content`). Runs and actual cost are 0. */
45
45
  cache?: 'content';
46
+ /** The tier the review ran at, when cheap-model-first tiering is on (reviewer/tiering.ts). */
47
+ tier?: 'cheap' | 'strong';
46
48
  }
47
49
  /**
48
50
  * A verdict, findable by the content it reviewed rather than its commit: a rebase, an amend, a cherry-pick or the same
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Cheap-model-first tiering (off by default; review.reviewer.tiers.cheap names a cheaper model per judge). Which model
3
+ * reviews a change is decided BEFORE any run, from facts about the change only: never by a model, and never from a
4
+ * model's findings. A change with something a cheaper model may miss gets the team's model:
5
+ *
6
+ * a required floor, human reviews to check, open items carried from the last verdict, a migration, a security
7
+ * finding from the checks, a declared goal, or a risky changed function (the router's score)
8
+ *
9
+ * Anything else gets the cheap model. After the run, the only escalation is an answer that is not a valid verdict: the
10
+ * one retry the reviewer already makes uses the team's model. What blocks is unchanged: the same evidence contract.
11
+ *
12
+ * The rule a tier must keep: on average, a review costs no more than one judge would have. Over the last
13
+ * TIER_WINDOW reviews tiering ran on, actual cost against what one judge would have been given (the frozen baseline's
14
+ * dollars per character, else characters); above it, tiering turns itself off and the review record says so.
15
+ */
16
+ import type { CostBaseline, ReviewCost } from './store.js';
17
+ export interface TierDecision {
18
+ tier: 'cheap' | 'strong';
19
+ /** The cheap model, when the tier is cheap. */
20
+ model?: string;
21
+ why: string;
22
+ /** Why tiering turned itself off, when it did. */
23
+ disabled?: string;
24
+ /** Why the cheap model's run was retried on the team's model: its answer was not a valid verdict. */
25
+ escalated?: string;
26
+ }
27
+ export interface TierFacts {
28
+ required: boolean;
29
+ humanReviews: number;
30
+ carried: number;
31
+ migration: boolean;
32
+ securityChecks: number;
33
+ goal: boolean;
34
+ /** Risky changed functions (the router's score); undefined when it could not score the change. */
35
+ risky: number | undefined;
36
+ }
37
+ /** The tier for one judge's review; undefined when no cheap model is set for it (tiering is off). */
38
+ export declare function chooseTier(cheap: Record<string, string>, judge: string, facts: TierFacts, costs: ReviewCost[], baseline: CostBaseline | undefined): TierDecision | undefined;
@@ -0,0 +1,43 @@
1
+ /** Recent tiered reviews the self-disable rule reads. */
2
+ const TIER_WINDOW = 20;
3
+ /** Tiered reviews needed before the rule can turn tiering off. */
4
+ const TIER_MIN = 5;
5
+ /** The tier for one judge's review; undefined when no cheap model is set for it (tiering is off). */
6
+ export function chooseTier(cheap, judge, facts, costs, baseline) {
7
+ const model = cheap[judge];
8
+ if (!model)
9
+ return undefined;
10
+ const disabled = overBudget(costs, baseline);
11
+ if (disabled)
12
+ return { tier: 'strong', why: 'tiering turned itself off', disabled };
13
+ const strong = (why) => ({ tier: 'strong', why });
14
+ if (facts.required)
15
+ return strong('a required floor');
16
+ if (facts.humanReviews > 0)
17
+ return strong(`${facts.humanReviews} human review(s) to check`);
18
+ if (facts.carried > 0)
19
+ return strong(`${facts.carried} open item(s) carried from the last verdict`);
20
+ if (facts.migration)
21
+ return strong('the change adds or edits a migration');
22
+ if (facts.securityChecks > 0)
23
+ return strong(`${facts.securityChecks} security finding(s) from the checks`);
24
+ if (facts.goal)
25
+ return strong('the description declares a goal to check');
26
+ if (facts.risky === undefined)
27
+ return strong('the risk router could not score the change');
28
+ if (facts.risky > 0)
29
+ return strong(`${facts.risky} risky changed function(s)`);
30
+ return { tier: 'cheap', model, why: 'no risk signal: the cheap model' };
31
+ }
32
+ /** Why tiering must turn itself off: its recent reviews cost more, on average, than one judge would have. */
33
+ function overBudget(costs, baseline) {
34
+ const tiered = costs.filter(c => c.tier).slice(-TIER_WINDOW);
35
+ if (tiered.length < TIER_MIN)
36
+ return undefined;
37
+ const rate = baseline?.usdPerChar ?? null;
38
+ const single = tiered.reduce((sum, c) => sum + c.projectedSingleChars * (rate ?? 1), 0);
39
+ const actual = tiered.reduce((sum, c) => sum + (rate === null ? c.actualChars : c.actualUsd > 0 ? c.actualUsd : c.actualChars * rate), 0);
40
+ if (single <= 0 || actual <= single)
41
+ return undefined;
42
+ return `the last ${tiered.length} tiered reviews cost ${(actual / single).toFixed(2)}x what one judge would have (${rate === null ? 'characters' : 'dollars'})`;
43
+ }
@@ -31,6 +31,8 @@ export interface TriageContext {
31
31
  }
32
32
  /** Per specialist, the hunks it is for (by index); a specialist absent from the map is not needed. Prior points take the whole change. */
33
33
  export declare function triage(hunks: Hunk[], context: TriageContext): Map<string, number[]>;
34
+ /** Whether a file is a migration or a schema (a change a cheaper model may miss the cost of). */
35
+ export declare function isMigration(file: string): boolean;
34
36
  /** Whether no model reviews this file (SKIP). */
35
37
  export declare function skipped(file: string): boolean;
36
38
  export interface Pass {
@@ -79,6 +79,10 @@ export function triage(hunks, context) {
79
79
  picked.set('prior-points', hunks.map((_, i) => i).filter(i => !skipped(hunks[i].file)));
80
80
  return picked;
81
81
  }
82
+ /** Whether a file is a migration or a schema (a change a cheaper model may miss the cost of). */
83
+ export function isMigration(file) {
84
+ return MIGRATION.test(file);
85
+ }
82
86
  /** Whether no model reviews this file (SKIP). */
83
87
  export function skipped(file) {
84
88
  return SKIP.some(pattern => pattern.test(file));
@@ -18,6 +18,7 @@ export function reviewerUsage(result, trigger) {
18
18
  judges: result.reviewers.length,
19
19
  cached: result.cached,
20
20
  ...(result.cache ? { cache: result.cache } : {}),
21
+ ...(mode?.tier ? { tier: mode.tier.tier, tier_disabled: !!mode.tier.disabled, tier_escalated: !!mode.tier.escalated } : {}),
21
22
  confirmed: result.items.length,
22
23
  disputed: result.disputed.length,
23
24
  dropped: result.dropped.length,
@@ -2,6 +2,7 @@ import type { Config } from '../types/index.js';
2
2
  import { type ReviewerName, type Tokens } from './reviewer/adapters.js';
3
3
  import { type Exec, type Progress } from './reviewer/exec.js';
4
4
  import { type PanelItem } from './reviewer/panel.js';
5
+ import { type TierDecision } from './reviewer/tiering.js';
5
6
  import { type RunChoice, type Source } from './reviewer/settings.js';
6
7
  import { type ReviewRecord } from './reviewer/record.js';
7
8
  import { type Accounting, type OpenItem, type PriorPoint } from './reviewer/verdict.js';
@@ -136,6 +137,8 @@ export interface ModeRecord {
136
137
  refused?: string[];
137
138
  /** With escalate: risk, why this review got one judge or all of them. */
138
139
  escalation?: string;
140
+ /** With cheap-model-first tiering on: the tier, why, and whether it turned itself off or escalated (reviewer/tiering.ts). */
141
+ tier?: TierDecision;
139
142
  }
140
143
  /** Findings and no verdict stop a push; a skipped review does not, and is reported as owed. */
141
144
  export declare function reviewerBlocks(result: ReviewerResult): boolean;
@@ -29,7 +29,8 @@ import { mergeImpact } from './reviewer/merge-impact.js';
29
29
  import { applyPanel, parseAnswers, runPanel } from './reviewer/panel.js';
30
30
  import { crossExamPrompt, deltaBlock, goalStep, mergeBlock, PROMPT_VERSION, renderPrompt } from './reviewer/prompt.js';
31
31
  import { BASELINE_MIN_SINGLES, focusBlock, formatLedger, ledger, passLimit, runPasses, SPECIALISTS, SPECIALISTS_KEY, splitNeeds } from './reviewer/orchestrator.js';
32
- import { MAX_PARTS, parseHunks, planPasses, reviewable, skipped, triage } from './reviewer/triage.js';
32
+ import { isMigration, MAX_PARTS, parseHunks, planPasses, reviewable, skipped, triage } from './reviewer/triage.js';
33
+ import { chooseTier } from './reviewer/tiering.js';
33
34
  import { modelGoalItems, parseGoal } from '../goal/goal.js';
34
35
  import { resolveSwitch } from '../switches.js';
35
36
  import { resolveReviewer } from './reviewer/settings.js';
@@ -224,7 +225,17 @@ async function review(cwd, base, config, exec, progress, options) {
224
225
  const previousOpen = scope === 'delta' ? store.readJson(store.openPath(previous.verdict)) ?? [] : undefined;
225
226
  const verify = checkoutVerifier(cwd);
226
227
  const prior = { approvals: reviews.approvals, inCheckout: checkoutSearch(cwd), changed: changedLinesOf(fullDiff), labels: reviews.labels };
227
- const modelFor = (name) => settings.models[name] ?? (name === 'claude' ? settings.model : undefined);
228
+ // Cheap-model-first (reviewer/tiering.ts): decided before any run, from facts about the change only, for one judge.
229
+ let tier = reviewers.length === 1 && (modeRecord.ran === 'single' || modeRecord.ran === 'orchestrator')
230
+ ? chooseTier(settings.tiers, reviewers[0], {
231
+ required: settings.required.mode || settings.required.panel || orchestrator.required, humanReviews: reviews.count, carried: previousOpen?.length ?? 0,
232
+ migration: changedFiles.some(isMigration), securityChecks: (options.checks ?? []).filter(c => /\bsecurity\b/i.test(c)).length, goal: goalItems.length > 0, risky: context.risky,
233
+ }, store.costs(), store.baseline())
234
+ : undefined;
235
+ if (tier)
236
+ modeRecord = { ...modeRecord, tier };
237
+ const cheapModel = () => (tier?.tier === 'cheap' && !tier.escalated ? tier.model : undefined);
238
+ const modelFor = (name) => (name === reviewers[0] ? cheapModel() : undefined) ?? settings.models[name] ?? (name === 'claude' ? settings.model : undefined);
228
239
  // The record of the review, written beside the verdict once and rebuilt from the same verdict on a cached read.
229
240
  // What a judge reads besides the repository and Rigour's inputs, on this machine: on the record, so "the same judge" is a claim it can check.
230
241
  const outsideOf = (reviewer) => {
@@ -323,7 +334,7 @@ async function review(cwd, base, config, exec, progress, options) {
323
334
  store.recordCost({
324
335
  at: new Date().toISOString(), mode: orchestrated ? 'orchestrator' : 'single', lines: size.lines, projectedSingleChars: projectedSingle,
325
336
  ...(orchestrated ? { projectedChars: passes.reduce((sum, pass) => sum + shared + pass.diff.length, 0) } : {}),
326
- actualChars: tally.chars, actualUsd: spentUsd(), runs: tally.runs,
337
+ actualChars: tally.chars, actualUsd: spentUsd(), runs: tally.runs, ...(tier ? { tier: tier.tier } : {}),
327
338
  });
328
339
  };
329
340
  // One judge run, by CLI or by API: the same prompt, the same cost accounting, the same trace.
@@ -438,6 +449,11 @@ async function review(cwd, base, config, exec, progress, options) {
438
449
  // No verdict, whether a malformed answer or a run that died, is a slip, not a decision: asked once more, inside the caps.
439
450
  if (!once && (!first.verdict || 'error' in first.verdict) && !overBudget(store.spend(), settings, 1)) {
440
451
  progress(`Rigour reviewer: ${name} gave no ${first.verdict ? 'valid verdict' : 'answer'}; asking once more`);
452
+ // The cheap model's slip is the one escalation: the retry runs on the team's model.
453
+ if (cheapModel()) {
454
+ tier = { ...tier, escalated: 'no valid verdict from the cheap model: retried on the team\'s model' };
455
+ modeRecord = { ...modeRecord, tier };
456
+ }
441
457
  first = await ask();
442
458
  }
443
459
  return first.verdict ?? { error: `${name}: no answer (exit ${first.run.exitCode}): ${first.run.stderr.trim().slice(-200)}` };
@@ -301,6 +301,9 @@ export declare const SWITCHES: {
301
301
  judges: 2 | 3;
302
302
  escalate: "always" | "risk";
303
303
  cross_models: Record<string, string>;
304
+ tiers: {
305
+ cheap: Record<string, string>;
306
+ };
304
307
  reasoning: Record<string, "high" | "medium" | "low">;
305
308
  judge_env: Record<string, {
306
309
  unset: string[];
@@ -614,6 +617,9 @@ export declare const SWITCHES: {
614
617
  judges: 2 | 3;
615
618
  escalate: "always" | "risk";
616
619
  cross_models: Record<string, string>;
620
+ tiers: {
621
+ cheap: Record<string, string>;
622
+ };
617
623
  reasoning: Record<string, "high" | "medium" | "low">;
618
624
  judge_env: Record<string, {
619
625
  unset: string[];
@@ -927,6 +933,9 @@ export declare const SWITCHES: {
927
933
  judges: 2 | 3;
928
934
  escalate: "always" | "risk";
929
935
  cross_models: Record<string, string>;
936
+ tiers: {
937
+ cheap: Record<string, string>;
938
+ };
930
939
  reasoning: Record<string, "high" | "medium" | "low">;
931
940
  judge_env: Record<string, {
932
941
  unset: string[];
@@ -305,6 +305,7 @@ export const UNIVERSAL_CONFIG = {
305
305
  judges: 2,
306
306
  escalate: 'always',
307
307
  cross_models: {},
308
+ tiers: { cheap: {} },
308
309
  judge_env: {},
309
310
  reasoning: {},
310
311
  },
@@ -2551,6 +2551,17 @@ export declare const ConfigSchema: z.ZodObject<{
2551
2551
  escalate: z.ZodDefault<z.ZodOptional<z.ZodEnum<["always", "risk"]>>>;
2552
2552
  /** A model per reviewer name for cross-examination (a narrow verification task), e.g. { claude: "haiku" }. */
2553
2553
  cross_models: z.ZodDefault<z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>>;
2554
+ /**
2555
+ * Cheap-model-first (reviewer/tiering.ts), off unless set: a cheaper model per reviewer name for a change with no
2556
+ * risk signal. Experimental: not to be recommended until a backtest shows what the cheap model misses.
2557
+ */
2558
+ tiers: z.ZodDefault<z.ZodOptional<z.ZodObject<{
2559
+ cheap: z.ZodDefault<z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>>;
2560
+ }, "strip", z.ZodTypeAny, {
2561
+ cheap: Record<string, string>;
2562
+ }, {
2563
+ cheap?: Record<string, string> | undefined;
2564
+ }>>>;
2554
2565
  /** Reasoning effort per reviewer name where the CLI or API takes one (codex, api): low, medium or high. */
2555
2566
  reasoning: z.ZodDefault<z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodEnum<["low", "medium", "high"]>>>>;
2556
2567
  /**
@@ -2603,6 +2614,9 @@ export declare const ConfigSchema: z.ZodObject<{
2603
2614
  judges: 2 | 3;
2604
2615
  escalate: "always" | "risk";
2605
2616
  cross_models: Record<string, string>;
2617
+ tiers: {
2618
+ cheap: Record<string, string>;
2619
+ };
2606
2620
  reasoning: Record<string, "high" | "medium" | "low">;
2607
2621
  judge_env: Record<string, {
2608
2622
  unset: string[];
@@ -2635,6 +2649,9 @@ export declare const ConfigSchema: z.ZodObject<{
2635
2649
  judges?: 2 | 3 | undefined;
2636
2650
  escalate?: "always" | "risk" | undefined;
2637
2651
  cross_models?: Record<string, string> | undefined;
2652
+ tiers?: {
2653
+ cheap?: Record<string, string> | undefined;
2654
+ } | undefined;
2638
2655
  reasoning?: Record<string, "high" | "medium" | "low"> | undefined;
2639
2656
  api?: {
2640
2657
  model: string;
@@ -2666,6 +2683,9 @@ export declare const ConfigSchema: z.ZodObject<{
2666
2683
  judges: 2 | 3;
2667
2684
  escalate: "always" | "risk";
2668
2685
  cross_models: Record<string, string>;
2686
+ tiers: {
2687
+ cheap: Record<string, string>;
2688
+ };
2669
2689
  reasoning: Record<string, "high" | "medium" | "low">;
2670
2690
  judge_env: Record<string, {
2671
2691
  unset: string[];
@@ -2705,6 +2725,9 @@ export declare const ConfigSchema: z.ZodObject<{
2705
2725
  judges?: 2 | 3 | undefined;
2706
2726
  escalate?: "always" | "risk" | undefined;
2707
2727
  cross_models?: Record<string, string> | undefined;
2728
+ tiers?: {
2729
+ cheap?: Record<string, string> | undefined;
2730
+ } | undefined;
2708
2731
  reasoning?: Record<string, "high" | "medium" | "low"> | undefined;
2709
2732
  api?: {
2710
2733
  model: string;
@@ -3007,6 +3030,9 @@ export declare const ConfigSchema: z.ZodObject<{
3007
3030
  judges: 2 | 3;
3008
3031
  escalate: "always" | "risk";
3009
3032
  cross_models: Record<string, string>;
3033
+ tiers: {
3034
+ cheap: Record<string, string>;
3035
+ };
3010
3036
  reasoning: Record<string, "high" | "medium" | "low">;
3011
3037
  judge_env: Record<string, {
3012
3038
  unset: string[];
@@ -3320,6 +3346,9 @@ export declare const ConfigSchema: z.ZodObject<{
3320
3346
  judges?: 2 | 3 | undefined;
3321
3347
  escalate?: "always" | "risk" | undefined;
3322
3348
  cross_models?: Record<string, string> | undefined;
3349
+ tiers?: {
3350
+ cheap?: Record<string, string> | undefined;
3351
+ } | undefined;
3323
3352
  reasoning?: Record<string, "high" | "medium" | "low"> | undefined;
3324
3353
  api?: {
3325
3354
  model: string;
@@ -458,6 +458,11 @@ export const ConfigSchema = z.object({
458
458
  escalate: z.enum(['always', 'risk']).optional().default('always'),
459
459
  /** A model per reviewer name for cross-examination (a narrow verification task), e.g. { claude: "haiku" }. */
460
460
  cross_models: z.record(ModelName).optional().default({}),
461
+ /**
462
+ * Cheap-model-first (reviewer/tiering.ts), off unless set: a cheaper model per reviewer name for a change with no
463
+ * risk signal. Experimental: not to be recommended until a backtest shows what the cheap model misses.
464
+ */
465
+ tiers: z.object({ cheap: z.record(ModelName).optional().default({}) }).optional().default({}),
461
466
  /** Reasoning effort per reviewer name where the CLI or API takes one (codex, api): low, medium or high. */
462
467
  reasoning: z.record(z.enum(['low', 'medium', 'high'])).optional().default({}),
463
468
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rigour-labs/core",
3
- "version": "6.11.0-rc.1",
3
+ "version": "6.11.0-rc.2",
4
4
  "description": "Rigour's review engine: deterministic gates on changed lines, rules and lessons learned from your team's fixes, and per-check precision from what you fix versus dismiss, across TypeScript, JavaScript, Python, Go, Ruby and C#.",
5
5
  "engines": {
6
6
  "node": ">=22.13"
@@ -75,11 +75,11 @@
75
75
  "@anthropic-ai/sdk": "^0.132.1",
76
76
  "pg": "^8.16.3",
77
77
  "openai": "^5.23.2",
78
- "@rigour-labs/brain-darwin-x64": "6.11.0-rc.1",
79
- "@rigour-labs/brain-linux-arm64": "6.11.0-rc.1",
80
- "@rigour-labs/brain-linux-x64": "6.11.0-rc.1",
81
- "@rigour-labs/brain-darwin-arm64": "6.11.0-rc.1",
82
- "@rigour-labs/brain-win-x64": "6.11.0-rc.1"
78
+ "@rigour-labs/brain-darwin-arm64": "6.11.0-rc.2",
79
+ "@rigour-labs/brain-linux-x64": "6.11.0-rc.2",
80
+ "@rigour-labs/brain-linux-arm64": "6.11.0-rc.2",
81
+ "@rigour-labs/brain-win-x64": "6.11.0-rc.2",
82
+ "@rigour-labs/brain-darwin-x64": "6.11.0-rc.2"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@types/fs-extra": "^11.0.4",