faberun 0.26.0 → 0.26.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "faberun",
3
- "version": "0.26.0",
3
+ "version": "0.26.2",
4
4
  "description": "Faberun is a development orchestration system that turns intent into verified software: harness- and model-agnostic, it keeps the intent, coordinates the work, verifies the result and decides what happens next.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/brand.mjs CHANGED
@@ -199,7 +199,7 @@ export function renderUsage() {
199
199
  "contract validate <contract.json>",
200
200
  "spec validate <file> [--strict-traceability] [--json]",
201
201
  "spec scaffold <path> [--id <id>]",
202
- "plan <spec.md> --campaign <id> [--phase <phase>] [--review-rounds <n>] [--approve-below standard|high|none] [--runtime-defaults worker=<id>,judge=<id>] [--detach] [--json]",
202
+ "plan <spec.md> --campaign <id> [--phase <phase>] [--review-rounds <n>] [--approve-below standard|high|none] [--runtime-defaults worker=<id>,judge=<id>] [--judge-independence same-vendor] [--detach] [--json]",
203
203
  "metrics <campaign-id> [--cwd <dir>] [--json]",
204
204
  "campaign <init|watch|attach|note|resolve|close|supervise|show|list|sync|ack> ...",
205
205
  "seat <start|attach|status|stop> [<campaign-id>] [--cwd <dir>] ...",
@@ -24,6 +24,7 @@ import { readRunNodes } from "../run/node-store.mjs";
24
24
  import { spawn } from "node:child_process";
25
25
  import { validateContract } from "../contract/index.mjs";
26
26
  import { runDirectory } from "../run/paths.mjs";
27
+ import { livePreflightCeilingSec } from "../engine/live-preflight.mjs";
27
28
 
28
29
  /**
29
30
  * The file a detached child is spawned as. It must be the CLI and not this
@@ -94,7 +95,7 @@ export function detachArgv(argv, options = {}) {
94
95
  * @param {number} [timeoutMs]
95
96
  * @returns {Promise<BootstrapRecord>}
96
97
  */
97
- export async function waitForBootstrap(runDir, pid, child = null, timeoutMs = 30_000) {
98
+ export async function waitForBootstrap(runDir, pid, child = null, timeoutMs = bootstrapReadyTimeoutMs()) {
98
99
  const deadline = Date.now() + timeoutMs;
99
100
  const nonce = child?.bootstrapNonce;
100
101
  if (!nonce) throw new Error(`detached bootstrap has no start nonce for pid ${pid}`);
@@ -157,6 +158,18 @@ export async function waitForBootstrap(runDir, pid, child = null, timeoutMs = 30
157
158
  cleanupBootstrapAttempts(runDir);
158
159
  throw new Error(`detached bootstrap did not become ready for pid ${pid}`);
159
160
  }
161
+ /**
162
+ * The controller answers ready only after the dispatch gate's live preflight,
163
+ * so the launcher waits for that ask on top of its own 30s start-up margin.
164
+ * Measured 2026-09-27: with a fixed 30s wait, a gate asking Opus at `xhigh`
165
+ * made `run --detach` and `plan --detach` report "did not become ready" for
166
+ * controllers that then came up and ran.
167
+ *
168
+ * @returns {number}
169
+ */
170
+ export function bootstrapReadyTimeoutMs() {
171
+ return 30_000 + livePreflightCeilingSec() * 1000;
172
+ }
160
173
  /**
161
174
  * @param {string} runDir
162
175
  * @param {BootstrapRecord} bootstrap
package/src/cli/plan.mjs CHANGED
@@ -4,6 +4,7 @@
4
4
  * contested. This file only owns the wire — `src/plan/pipeline.mjs` owns the
5
5
  * sequencing and every decision the pipeline makes.
6
6
  */
7
+ import { validateJudgeIndependence } from "../contract/judge-independence.mjs";
7
8
  import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
8
9
  import { dirname, join, resolve } from "node:path";
9
10
  import { detachArgv, detachSelf, waitForBootstrap } from "./launch.mjs";
@@ -144,7 +145,7 @@ export function loadVerificationSuites(path) {
144
145
 
145
146
  /**
146
147
  * @param {string} target
147
- * @param {{campaign?: string, phase?: string, "review-rounds"?: string, "approve-below"?: string, "runtime-defaults"?: string, reviewers?: string, runtimes?: string, verification?: string, package?: string, "targeted-fix"?: boolean, detach?: boolean, resolve?: string, answer?: string[], json?: boolean}} values
148
+ * @param {{campaign?: string, phase?: string, "review-rounds"?: string, "approve-below"?: string, "runtime-defaults"?: string, reviewers?: string, runtimes?: string, verification?: string, package?: string, "targeted-fix"?: boolean, "judge-independence"?: string, detach?: boolean, resolve?: string, answer?: string[], json?: boolean}} values
148
149
  * @returns {Promise<void>}
149
150
  */
150
151
  export async function planCli(target, values) {
@@ -166,6 +167,9 @@ export async function planCli(target, values) {
166
167
  ? loadVerificationSuites(values.verification)
167
168
  : {};
168
169
  const packageMode = packageModeOf(values.package);
170
+ const judgeIndependence = values["judge-independence"] === undefined
171
+ ? undefined
172
+ : validateJudgeIndependence(values["judge-independence"], "--judge-independence");
169
173
 
170
174
  if (values.detach === true) {
171
175
  const argv = ["plan", specPath, "--campaign", campaignId, "--phase", phase, "--review-rounds", String(reviewRounds)];
@@ -176,6 +180,7 @@ export async function planCli(target, values) {
176
180
  if (typeof values.verification === "string" && values.verification) argv.push("--verification", resolve(values.verification));
177
181
  if (packageMode !== "implementation") argv.push("--package", packageMode);
178
182
  if (values["targeted-fix"] === true) argv.push("--targeted-fix");
183
+ if (judgeIndependence !== undefined) argv.push("--judge-independence", judgeIndependence);
179
184
  const failurePath = planBootstrapFailurePath(process.cwd(), campaignId, phase);
180
185
  // Read the campaign before creating anything: the failure record lives
181
186
  // inside the campaign tree, so a typo in --campaign would otherwise leave
@@ -222,6 +227,7 @@ export async function planCli(target, values) {
222
227
  verification,
223
228
  targetedFix: values["targeted-fix"] === true,
224
229
  packageMode,
230
+ ...(judgeIndependence === undefined ? {} : { judgeIndependence }),
225
231
  launch: async (contractPath, contract) => {
226
232
  const child = detachSelf("run", contractPath);
227
233
  if (child.pid === undefined) throw new Error("detached planning run has no pid");
package/src/cli.mjs CHANGED
@@ -128,6 +128,7 @@ export const COMMAND_OPTIONS = {
128
128
  verification: { type: "string" },
129
129
  package: { type: "string" },
130
130
  "targeted-fix": { type: "boolean" },
131
+ "judge-independence": { type: "string" },
131
132
  detach: { type: "boolean" },
132
133
  resolve: { type: "string" },
133
134
  answer: { type: "string", multiple: true },
@@ -46,7 +46,7 @@ const LIVE_PREFLIGHT_OUTPUT_LIMIT_BYTES = 512 * 1024;
46
46
  * whole is part of the answer.
47
47
  *
48
48
  * @param {string} contractPath
49
- * @param {{static?: boolean, liveTimeoutSec?: number, persisted?: boolean}} [options]
49
+ * @param {{static?: boolean, liveTimeoutSec?: number, minimumTimeoutSec?: number, persisted?: boolean}} [options]
50
50
  * @returns {Promise<ProbeResult[]>}
51
51
  */
52
52
  export async function preflightContract(contractPath, options = {}) {
@@ -70,7 +70,7 @@ export async function preflightContract(contractPath, options = {}) {
70
70
  * once, so a caller cannot accidentally ask a different question.
71
71
  *
72
72
  * @param {{runtime: RuntimeSnapshot, requiredCapabilitySets?: import("../harnesses/index.mjs").CapabilityRequirements[]}[]} entries
73
- * @param {{static?: boolean, liveTimeoutSec?: number, cwd?: string}} [options]
73
+ * @param {{static?: boolean, liveTimeoutSec?: number, minimumTimeoutSec?: number, cwd?: string}} [options]
74
74
  * @returns {Promise<ProbeResult[]>}
75
75
  */
76
76
  export async function preflightRuntimes(entries, options = {}) {
@@ -80,7 +80,7 @@ export async function preflightRuntimes(entries, options = {}) {
80
80
  ));
81
81
  if (options.static === true) return staticChecks;
82
82
 
83
- const timeoutSec = livePreflightTimeout(options.liveTimeoutSec);
83
+ const configuredTimeoutSec = configuredLivePreflightTimeout(options.liveTimeoutSec);
84
84
  let liveRepo;
85
85
  try {
86
86
  liveRepo = createLivePreflightRepo();
@@ -95,7 +95,7 @@ export async function preflightRuntimes(entries, options = {}) {
95
95
  }
96
96
  try {
97
97
  return await Promise.all(staticChecks.map(async (check, index) => {
98
- const live = await livePreflight(runtimes[index], liveRepo, timeoutSec);
98
+ const live = await livePreflight(runtimes[index], liveRepo, configuredTimeoutSec ?? Math.max(options.minimumTimeoutSec ?? 0, defaultLivePreflightTimeout(runtimes[index])));
99
99
  const liveDetail = live.status === "done"
100
100
  ? `live done · usage ${formatUsage(live.usage)} · cost ${formatCost(live.costUsd)}`
101
101
  : `live ${live.status} · ${live.error?.code ?? "provider_error"}: ${redactProviderText(live.error?.message ?? "generation failed")} · usage ${formatUsage(live.usage)} · cost ${formatCost(live.costUsd)}`;
@@ -116,16 +116,54 @@ export async function preflightRuntimes(entries, options = {}) {
116
116
  rmSync(liveRepo, { recursive: true, force: true });
117
117
  }
118
118
  }
119
- /** @param {number|undefined} configured */
120
- function livePreflightTimeout(configured) {
119
+ /**
120
+ * An explicit budget (option, then environment) applies to every runtime.
121
+ *
122
+ * @param {number|undefined} configured
123
+ * @returns {number|null}
124
+ */
125
+ function configuredLivePreflightTimeout(configured) {
121
126
  const raw = configured ?? (process.env.FABERUN_PREFLIGHT_TIMEOUT_SEC === undefined
122
- ? 15
127
+ ? null
123
128
  : Number(process.env.FABERUN_PREFLIGHT_TIMEOUT_SEC));
129
+ if (raw === null) return null;
124
130
  if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) {
125
131
  throw new TypeError("preflight live timeout must be a positive number of seconds");
126
132
  }
127
133
  return raw;
128
134
  }
135
+ /**
136
+ * Without an explicit budget, the wait follows the runtime's reasoning effort:
137
+ * a model at high effort thinks before answering even a one-word prompt.
138
+ * Measured 2026-09-26: Opus 5.5 at `xhigh` failed every `faberun plan`
139
+ * preflight at 15 s and passed once the budget was raised, so the planning
140
+ * pipeline was refusing a runtime that was healthy.
141
+ *
142
+ * @param {RuntimeSnapshot} runtime
143
+ * @returns {number}
144
+ */
145
+ export function defaultLivePreflightTimeout(runtime) {
146
+ // Keep LIVE_PREFLIGHT_CEILING_SEC equal to the largest value returned here.
147
+ const reasoning = /** @type {{reasoning?: unknown}} */ (runtime).reasoning;
148
+ if (reasoning === "xhigh" || reasoning === "max") return 180;
149
+ if (reasoning === "high") return 60;
150
+ return 15;
151
+ }
152
+ /**
153
+ * The longest a live preflight can take without an explicit budget: what a
154
+ * detached launcher must be willing to wait before the controller it spawned
155
+ * can report ready, because the dispatch gate asks before it does.
156
+ */
157
+ export const LIVE_PREFLIGHT_CEILING_SEC = 180;
158
+ /**
159
+ * The longest one live preflight ask may take under the current environment.
160
+ *
161
+ * @returns {number}
162
+ */
163
+ export function livePreflightCeilingSec() {
164
+ const override = Number(process.env.FABERUN_PREFLIGHT_TIMEOUT_SEC);
165
+ return process.env.FABERUN_PREFLIGHT_TIMEOUT_SEC !== undefined && Number.isFinite(override) && override > 0 ? override : LIVE_PREFLIGHT_CEILING_SEC;
166
+ }
129
167
  /** @returns {string} */
130
168
  function createLivePreflightRepo() {
131
169
  const directory = mkdtempSync(join(tmpdir(), "faberun-preflight-"));
@@ -507,11 +507,12 @@ export async function assertEnvironmentReady(contract, runDir, sourceIdentity) {
507
507
  // worker and no judge, and can spend no availability.
508
508
  if (blocking === null && launchMayDispatch(runDir)) {
509
509
  // measured 2026-09-22: asking four routed runtimes in parallel took about
510
- // 18s, so the default budget is 60s. FABERUN_PREFLIGHT_TIMEOUT_SEC stays
511
- // the operator override; preflightContract validates it, so only a valid
512
- // number is lifted here.
510
+ // 18s, so no runtime is given less than 60s here; a runtime at high
511
+ // reasoning effort gets its longer default (defaultLivePreflightTimeout).
512
+ // FABERUN_PREFLIGHT_TIMEOUT_SEC stays the operator override for every
513
+ // runtime; preflightContract validates it, so only a valid number is lifted.
513
514
  const override = Number(process.env.FABERUN_PREFLIGHT_TIMEOUT_SEC);
514
- const timeoutSec = process.env.FABERUN_PREFLIGHT_TIMEOUT_SEC !== undefined && Number.isFinite(override) && override > 0 ? override : 60;
515
+ const timeoutSec = process.env.FABERUN_PREFLIGHT_TIMEOUT_SEC !== undefined && Number.isFinite(override) && override > 0 ? override : undefined;
515
516
  const probes = await livePreflightProbes(contract, runDir, sourceIdentity, timeoutSec);
516
517
  const silent = probes.filter((probe) => liveSilenceCause(probe) !== null);
517
518
  writeJsonAtomic(join(runDir, "env-preflight.json"), evidence(silent.length === 0, probes));
@@ -536,6 +537,8 @@ export async function assertEnvironmentReady(contract, runDir, sourceIdentity) {
536
537
  throw Object.assign(new Error(`env_preflight_failed: ${blocking} · the run stays resumable: fix the environment and resume ${runDir}`), { code: "env_preflight_failed" });
537
538
  }
538
539
 
540
+ /** No routed runtime is given less than this by the dispatch gate. */
541
+ const GATE_MINIMUM_PREFLIGHT_SEC = 60;
539
542
  /**
540
543
  * The live half of the gate for one launch. Every routed runtime either holds
541
544
  * a verdict this machine recorded inside its freshness window -- reused, the
@@ -552,7 +555,7 @@ export async function assertEnvironmentReady(contract, runDir, sourceIdentity) {
552
555
  * @param {ValidatedContract} contract
553
556
  * @param {string} runDir
554
557
  * @param {SourceIdentity|undefined} sourceIdentity
555
- * @param {number} timeoutSec
558
+ * @param {number|undefined} timeoutSec
556
559
  * @returns {Promise<ProbeResult[]>}
557
560
  */
558
561
  async function livePreflightProbes(contract, runDir, sourceIdentity, timeoutSec) {
@@ -628,7 +631,7 @@ async function livePreflightProbes(contract, runDir, sourceIdentity, timeoutSec)
628
631
  };
629
632
  });
630
633
  }
631
- const probes = await preflightContract(join(runDir, "contract.json"), { liveTimeoutSec: timeoutSec, persisted: true });
634
+ const probes = await preflightContract(join(runDir, "contract.json"), { ...(timeoutSec === undefined ? {} : { liveTimeoutSec: timeoutSec }), minimumTimeoutSec: GATE_MINIMUM_PREFLIGHT_SEC, persisted: true });
632
635
  // What this launch bought is durable from here on: every ask that reached a
633
636
  // provider -- a refusal included, an answer being an answer -- is recorded
634
637
  // under the provider's own identity. Silence and a command that never
@@ -44,6 +44,7 @@ import { writeJsonAtomic } from "../run/store.mjs";
44
44
  * targetedFix: boolean,
45
45
  * approveBelow: "standard"|"high"|"none",
46
46
  * repoFacts: import("./repo-facts.mjs").RepoFacts,
47
+ * judgeIndependence?: "same-vendor",
47
48
  * }} ContestResumeContext
48
49
  */
49
50
  /** @typedef {{formatVersion: 1, status: "contested", rounds: number, findings: PlanFindingOutput[], plan: PlanOutput|null, resume: ContestResumeContext}} ContestedPlanRecord */
@@ -26,6 +26,7 @@ import { campaignCli } from "../cli/campaign.mjs";
26
26
  import { appendJsonl } from "../run/store.mjs";
27
27
  import { allowanceDelta, allowanceEventFields, sampleAllowance } from "../seat/allowance.mjs";
28
28
  import { askPlanningRuntimes, refusePlanningSilence, refuseUnplannableRuntimes } from "./preflight.mjs";
29
+ import { validateJudgeIndependence } from "../contract/judge-independence.mjs";
29
30
  import { firstEligibleReviewer, reviewerProvenanceOf } from "./reviewer.mjs";
30
31
  import { parseSpec, validateSpec } from "./spec.mjs";
31
32
  import { collectRepoFacts } from "./repo-facts.mjs";
@@ -96,7 +97,7 @@ export const DEFAULT_NODE_BUDGET_MS = 600_000;
96
97
  const APPROVE_BELOW_VALUES = new Set(["standard", "high", "none"]);
97
98
 
98
99
  /**
99
- * @param {{specPath: string, campaignId: string, phase: string, cwd?: string, reviewRounds?: number, approveBelow?: ApproveBelow, runtimeDefaults?: {worker?: string, judge?: string}, reviewers?: string[], runtimes: Record<string, JsonObject>, verification?: VerificationSuites, packageMode?: import("./sizing.mjs").PackageMode, targetedFix?: boolean, launch: LaunchFn, wait: WaitFn, ask?: AskFn}} options
100
+ * @param {{specPath: string, campaignId: string, phase: string, cwd?: string, reviewRounds?: number, approveBelow?: ApproveBelow, runtimeDefaults?: {worker?: string, judge?: string}, reviewers?: string[], runtimes: Record<string, JsonObject>, verification?: VerificationSuites, packageMode?: import("./sizing.mjs").PackageMode, targetedFix?: boolean, judgeIndependence?: "same-vendor", launch: LaunchFn, wait: WaitFn, ask?: AskFn}} options
100
101
  * `targetedFix` allows a plan with a single node. Sizing refuses one by
101
102
  * default because a phase that decomposes into one node is usually a plan
102
103
  * that was never decomposed; a targeted fix is the case where one node is
@@ -106,8 +107,9 @@ const APPROVE_BELOW_VALUES = new Set(["standard", "high", "none"]);
106
107
  export async function runPlanningPipeline(options) {
107
108
  const {
108
109
  specPath, campaignId, phase, runtimes, launch, wait,
109
- reviewRounds = 2, runtimeDefaults = {}, reviewers = [], verification = {}, targetedFix = false,
110
+ reviewRounds = 2, runtimeDefaults = {}, reviewers = [], verification = {}, targetedFix = false, judgeIndependence,
110
111
  } = options;
112
+ if (judgeIndependence !== undefined) validateJudgeIndependence(judgeIndependence, "judgeIndependence");
111
113
  const ask = options.ask ?? askPlanningRuntimes;
112
114
  // Implementation work is sized by what it writes; exploratory work -- an
113
115
  // audit, a review, a survey -- by what it reads, because it writes one
@@ -123,7 +125,7 @@ export async function runPlanningPipeline(options) {
123
125
  const campaignPath = campaignTree(cwd, campaignId);
124
126
  const campaign = readCampaign(campaignPath);
125
127
  if (campaign.status !== "active") throw new Error(`campaign is closed: ${campaignId}`);
126
- refuseUnplannableRuntimes(runtimes, runtimeDefaults, packageMode);
128
+ refuseUnplannableRuntimes(runtimes, runtimeDefaults, packageMode, judgeIndependence);
127
129
  refusePlanningSilence(await ask(runtimes, runtimeDefaults, cwd), cwd);
128
130
 
129
131
  const relativeSpecPath = repoRelativePath(cwd, specPath, "specPath");
@@ -275,6 +277,7 @@ export async function runPlanningPipeline(options) {
275
277
  resume: {
276
278
  campaignId, phase, specPath: relativeSpecPath, specDigest, reviewRounds,
277
279
  runtimeDefaults, reviewers, runtimes, verification, packageMode, targetedFix, approveBelow, repoFacts,
280
+ ...(judgeIndependence === undefined ? {} : { judgeIndependence }),
278
281
  },
279
282
  });
280
283
 
@@ -297,7 +300,7 @@ export async function runPlanningPipeline(options) {
297
300
  * @param {PlanOutput} currentPlan
298
301
  * @returns {AssembledPlan}
299
302
  */
300
- const assembleFrozenNodes = (currentPlan) => assembleFrozenPlan(currentPlan, { repoFacts, packageMode, targetedFix, phase, cwd, runtimes, runtimeDefaults });
303
+ const assembleFrozenNodes = (currentPlan) => assembleFrozenPlan(currentPlan, { repoFacts, packageMode, targetedFix, phase, cwd, runtimes, runtimeDefaults, judgeIndependence });
301
304
 
302
305
  /**
303
306
  * The raw contract exactly as `freezePlan` will assemble and validate it.
@@ -307,7 +310,7 @@ export async function runPlanningPipeline(options) {
307
310
  * @param {AssembledPlan} assembly
308
311
  * @returns {JsonObject}
309
312
  */
310
- const frozenContractRaw = (assembly) => frozenContractRawOf(assembly, { campaignId, phase, campaignGoal: campaign.goal, cwd, plansDir, runtimes, runtimeDefaults, verification });
313
+ const frozenContractRaw = (assembly) => frozenContractRawOf(assembly, { campaignId, phase, campaignGoal: campaign.goal, cwd, plansDir, runtimes, runtimeDefaults, verification, judgeIndependence });
311
314
 
312
315
  const roundsResult = await runReviewRounds({
313
316
  reviewRounds, plan, rejectedDraft: plan === null ? draft.output.plan : undefined, findings, cwd, plansDir, scratchDir, workingPlanPath, relativeWorkingPlanPath,
@@ -559,11 +562,11 @@ function taggedStageError(planStage, error) {
559
562
  * `plan.json`.
560
563
  *
561
564
  * @param {PlanOutput} currentPlan
562
- * @param {{repoFacts: import("./repo-facts.mjs").RepoFacts, packageMode: import("./sizing.mjs").PackageMode, targetedFix: boolean, phase: string, cwd: string, runtimes: Record<string, JsonObject>, runtimeDefaults: {worker?: string, judge?: string}}} ctx
565
+ * @param {{repoFacts: import("./repo-facts.mjs").RepoFacts, packageMode: import("./sizing.mjs").PackageMode, targetedFix: boolean, phase: string, cwd: string, runtimes: Record<string, JsonObject>, runtimeDefaults: {worker?: string, judge?: string}, judgeIndependence?: "same-vendor"}} ctx
563
566
  * @returns {AssembledPlan}
564
567
  */
565
568
  export function assembleFrozenPlan(currentPlan, ctx) {
566
- const { repoFacts, packageMode, targetedFix, phase, cwd, runtimes, runtimeDefaults } = ctx;
569
+ const { repoFacts, packageMode, targetedFix, phase, cwd, runtimes, runtimeDefaults, judgeIndependence } = ctx;
567
570
  let sizing;
568
571
  try {
569
572
  sizing = applySizingRules(
@@ -580,6 +583,7 @@ export function assembleFrozenPlan(currentPlan, ctx) {
580
583
  runtimes: /** @type {Record<string, import("./routing.mjs").RoutingRuntime>} */ (runtimes),
581
584
  availability: availabilityOf(runtimes),
582
585
  runtimeDefaults,
586
+ ...(judgeIndependence === undefined ? {} : { judgeIndependence }),
583
587
  });
584
588
  } catch (error) {
585
589
  throw taggedStageError("routing", error);
@@ -605,11 +609,11 @@ export function assembleFrozenPlan(currentPlan, ctx) {
605
609
  * for the same reason.
606
610
  *
607
611
  * @param {AssembledPlan} assembly
608
- * @param {{campaignId: string, phase: string, campaignGoal: string, cwd: string, plansDir: string, runtimes: Record<string, JsonObject>, runtimeDefaults: {worker?: string, judge?: string}, verification: VerificationSuites}} ctx
612
+ * @param {{campaignId: string, phase: string, campaignGoal: string, cwd: string, plansDir: string, runtimes: Record<string, JsonObject>, runtimeDefaults: {worker?: string, judge?: string}, verification: VerificationSuites, judgeIndependence?: "same-vendor"}} ctx
609
613
  * @returns {JsonObject}
610
614
  */
611
615
  export function frozenContractRawOf({ sizing, nodes }, ctx) {
612
- const { campaignId, phase, campaignGoal, cwd, plansDir, runtimes, runtimeDefaults, verification } = ctx;
616
+ const { campaignId, phase, campaignGoal, cwd, plansDir, runtimes, runtimeDefaults, verification, judgeIndependence } = ctx;
613
617
  return {
614
618
  schemaVersion: PROTOCOL_SCHEMA_VERSION,
615
619
  contractVersion: CONTRACT_VERSION,
@@ -628,6 +632,9 @@ export function frozenContractRawOf({ sizing, nodes }, ctx) {
628
632
  maxParallel: provenParallelism(sizing.plan),
629
633
  runtimes,
630
634
  runtimeDefaults,
635
+ // The operator's opt-in, carried verbatim: the frozen contract is
636
+ // validated under the same judge-independence rule its routing used.
637
+ ...(judgeIndependence === undefined ? {} : { judgeIndependence }),
631
638
  // The operator's ratchets, carried verbatim: which suites a repository
632
639
  // runs on every node is the operator's policy, supplied through
633
640
  // `--verification`, never derived from repository facts. Validated at the
@@ -11,6 +11,7 @@ import { liveSilenceCause } from "../engine/live-silence.mjs";
11
11
  import { harnessCapabilities } from "../harnesses/index.mjs";
12
12
  import { assertRuntimeExecutesCommands, validateRuntime } from "../contract/runtime.mjs";
13
13
  import { effectiveProvider } from "../contract/provider.mjs";
14
+ import { SAME_VENDOR_REVIEW_MODE, sameVendorTierRefusal } from "../contract/judge-independence.mjs";
14
15
 
15
16
  /**
16
17
  * The runtimes a planning run will spend, asked once before its first stage.
@@ -90,10 +91,18 @@ export function refusePlanningSilence(checks, cwd) {
90
91
  *
91
92
  * @param {Record<string, Record<string, unknown>>} runtimes
92
93
  * @param {{worker?: string, judge?: string}} runtimeDefaults
94
+ * In same-vendor mode (`--judge-independence same-vendor`) a shared vendor is
95
+ * admitted exactly as the contract validator admits it: the judge's tier is
96
+ * declared and at or above that of the worker and of every fallback runtime of
97
+ * the same vendor. Measured 2026-09-27: without the mode on `plan`, a
98
+ * single-provider catalogue (Sonnet worker, Opus planner and judge) could not
99
+ * plan at all, though its frozen contract would have validated in the mode.
100
+ *
93
101
  * @param {"implementation"|"exploratory"} packageMode
102
+ * @param {"same-vendor"} [judgeIndependence]
94
103
  * @returns {void}
95
104
  */
96
- export function refuseUnplannableRuntimes(runtimes, runtimeDefaults, packageMode) {
105
+ export function refuseUnplannableRuntimes(runtimes, runtimeDefaults, packageMode, judgeIndependence) {
97
106
  const worker = runtimeDefaults.worker;
98
107
  if (!worker || !runtimes[worker]) return;
99
108
  if (packageMode === "implementation") {
@@ -105,16 +114,19 @@ export function refuseUnplannableRuntimes(runtimes, runtimeDefaults, packageMode
105
114
  }
106
115
  const judge = runtimeDefaults.judge;
107
116
  if (!judge || !runtimes[judge]) return;
108
- /** @type {Set<string>} */
109
- const vendors = new Set();
117
+ const judgeVendor = effectiveProvider(/** @type {import("../contract/provider.mjs").ProviderInput & {vendor?: string}} */ (runtimes[judge]));
118
+ if (judgeVendor === undefined) return;
110
119
  const seen = new Set();
111
120
  for (let id = /** @type {string|undefined} */ (worker); id && runtimes[id] && !seen.has(id); id = /** @type {string|undefined} */ (runtimes[id].fallback)) {
112
121
  seen.add(id);
113
122
  const provider = effectiveProvider(/** @type {import("../contract/provider.mjs").ProviderInput & {vendor?: string}} */ (runtimes[id]));
114
- if (provider !== undefined) vendors.add(provider);
115
- }
116
- const judgeVendor = effectiveProvider(/** @type {import("../contract/provider.mjs").ProviderInput & {vendor?: string}} */ (runtimes[judge]));
117
- if (judgeVendor !== undefined && vendors.has(judgeVendor)) {
118
- throw new Error(`--runtime-defaults judge=${judge} shares vendor ${judgeVendor} with worker ${worker} or its fallback, so no frozen node could route its judge; name a judge of another vendor`);
123
+ if (provider !== judgeVendor) continue;
124
+ if (judgeIndependence !== SAME_VENDOR_REVIEW_MODE) {
125
+ throw new Error(`--runtime-defaults judge=${judge} shares vendor ${judgeVendor} with worker ${worker} or its fallback, so no frozen node could route its judge; name a judge of another vendor, or pass --judge-independence same-vendor`);
126
+ }
127
+ const refusal = sameVendorTierRefusal(/** @type {{harness: string, model: string}} */ (runtimes[id]), /** @type {{harness: string, model: string}} */ (runtimes[judge]));
128
+ if (refusal) {
129
+ throw new Error(`--runtime-defaults judge=${judge} shares vendor ${judgeVendor} with ${id === worker ? "worker" : "worker fallback"} ${id} under --judge-independence same-vendor, but ${refusal.message}`);
130
+ }
119
131
  }
120
132
  }
@@ -98,7 +98,7 @@ export async function resolvePlanningPipeline({ plansDir, cwd, answers }) {
98
98
  if (record.status !== "contested") throw new Error(`plan at ${planPath} is not contested (status: ${record.status})`);
99
99
  if (!record.plan) throw new Error(`plan at ${planPath} has no plan to resume: the draft or revise that produced it never validated; run faberun plan again instead of --resolve`);
100
100
  if (!record.resume) throw new Error(`plan at ${planPath} predates faberun plan --resolve and carries no resume context; run faberun plan again`);
101
- const { campaignId, phase, specPath, specDigest, runtimeDefaults, reviewers, runtimes, verification, packageMode, targetedFix, approveBelow, repoFacts } = record.resume;
101
+ const { campaignId, phase, specPath, specDigest, runtimeDefaults, reviewers, runtimes, verification, packageMode, targetedFix, approveBelow, repoFacts, judgeIndependence } = record.resume;
102
102
  // R16: recomputed from the same spec bytes the contest recorded, so a plan
103
103
  // resumed through --resolve carries the same declared operator steps a
104
104
  // fresh freeze would.
@@ -137,12 +137,12 @@ export async function resolvePlanningPipeline({ plansDir, cwd, answers }) {
137
137
  : ["the frozen contract carries neither sharedVerification nor finalVerification, so no repository ratchet runs on its nodes and no final check closes the phase; pass --verification <file> if the target repository has ratchets every node must run"];
138
138
  let stage = "sizing";
139
139
  try {
140
- const assembled = assembleFrozenPlan(plan, { repoFacts, packageMode, targetedFix, phase, cwd, runtimes, runtimeDefaults });
140
+ const assembled = assembleFrozenPlan(plan, { repoFacts, packageMode, targetedFix, phase, cwd, runtimes, runtimeDefaults, judgeIndependence });
141
141
  stage = "freeze";
142
142
  logStage("sizing", { transformations: assembled.sizing.transformations.length, nodeCount: assembled.sizing.plan.nodes.length, overheadMinutes: assembled.sizing.estimate.overheadMinutes });
143
143
  logStage("routing", { assignments: Object.keys(assembled.routing.assignments).length });
144
144
  const highestRiskTier = highestOf(assembled.sizing.plan.nodes.map((node) => node.riskTier ?? RISK_TIERS[0]));
145
- const frozen = freezePlan(frozenContractRawOf(assembled, { campaignId, phase, campaignGoal: campaign.goal, cwd, plansDir, runtimes, runtimeDefaults, verification }), {
145
+ const frozen = freezePlan(frozenContractRawOf(assembled, { campaignId, phase, campaignGoal: campaign.goal, cwd, plansDir, runtimes, runtimeDefaults, verification, judgeIndependence }), {
146
146
  outDir: plansDir,
147
147
  phases: assembled.phases,
148
148
  spec: { path: specPath, digest: specDigest },
@@ -14,6 +14,7 @@
14
14
  import { ROUTING_STRATEGIES } from "../contract/runtime.mjs";
15
15
  import { cheapest, isRuntimeAvailable, strongest } from "../engine/runtime-discovery.mjs";
16
16
  import { effectiveProvider } from "../contract/provider.mjs";
17
+ import { SAME_VENDOR_REVIEW_MODE, sameVendorTierRefusal } from "../contract/judge-independence.mjs";
17
18
 
18
19
  /** @typedef {import("../contract/runtime.mjs").RoutingStrategy} RoutingStrategy */
19
20
  /** @typedef {RoutingStrategy|"declared"} AppliedStrategy */
@@ -27,12 +28,18 @@ import { effectiveProvider } from "../contract/provider.mjs";
27
28
  * reader, `isRuntimeAvailable` in runtime-discovery.mjs.
28
29
  * @typedef {{available: boolean, exhaustedUntil: string|null, observedAt?: string|null, window?: string|null, remaining?: number|null, [key: string]: unknown}} RoutingAvailability
29
30
  */
30
- /** @typedef {{vendor: string, tier?: number|string, costRank?: number, fallback?: string, [key: string]: unknown}} RoutingRuntime */
31
+ /** @typedef {{vendor: string, harness?: string, model?: string, tier?: number|string, costRank?: number, fallback?: string, [key: string]: unknown}} RoutingRuntime */
31
32
  /** @typedef {{id: string, taskKind?: string, riskTier?: string}} RoutingNode */
32
33
  /** @typedef {{taskKind?: string, riskTier?: string}} RoutingWhen */
33
34
  /** @typedef {{name?: string, when: RoutingWhen, prefer: string[], role: "worker"|"judge", strategy?: RoutingStrategy}} RoutingRule */
34
35
  /** @typedef {{worker?: string, judge?: string}} RoutingRoleMap */
35
- /** @typedef {{table?: RoutingRule[], runtimes: Record<string, RoutingRuntime>, availability?: Record<string, RoutingAvailability>, runtimeDefaults?: RoutingRoleMap, overrides?: Record<string, RoutingRoleMap>}} RoutingConfig */
36
+ /**
37
+ * Why a runtime may not take a role on a node, or `null` when it may: the
38
+ * judge's bar follows the worker actually chosen (its vendor, its fallback
39
+ * chain, and in same-vendor mode the tier rule); the worker has none.
40
+ * @typedef {(id: string, runtime: RoutingRuntime) => string|null} JudgeBar
41
+ */
42
+ /** @typedef {{table?: RoutingRule[], runtimes: Record<string, RoutingRuntime>, availability?: Record<string, RoutingAvailability>, runtimeDefaults?: RoutingRoleMap, overrides?: Record<string, RoutingRoleMap>, judgeIndependence?: "same-vendor"}} RoutingConfig */
36
43
  /** @typedef {{worker: string|null, judge: string|null, rule: {worker: string, judge: string}, strategy: {worker: AppliedStrategy|null, judge: AppliedStrategy|null}, reason: {worker: string, judge: string}}} RoutingAssignment */
37
44
  /** @typedef {{nodeId: string, role: "worker"|"judge", rule: string}} RoutingUnmet */
38
45
  /** @typedef {{assignments: Record<string, RoutingAssignment>, unmet: RoutingUnmet[]}} RoutingResult */
@@ -53,6 +60,7 @@ export function resolveRuntimes(nodes, config, options = {}) {
53
60
  const runtimeDefaults = config.runtimeDefaults ?? {};
54
61
  const overrides = config.overrides ?? {};
55
62
  const previous = options.previous ?? {};
63
+ const judgeIndependence = config.judgeIndependence;
56
64
 
57
65
  // A strategy name outside the vocabulary is an authored-table defect, not an
58
66
  // unobservable datum: it fails fast, before any node is resolved.
@@ -69,13 +77,13 @@ export function resolveRuntimes(nodes, config, options = {}) {
69
77
 
70
78
  for (const node of nodes) {
71
79
  const override = overrides[node.id] ?? {};
72
- const worker = resolveRole(node, "worker", { runtimes, availability, runtimeDefaults, table, override, forbiddenVendors: EMPTY_VENDORS, previousId: previous[node.id]?.worker });
80
+ const worker = resolveRole(node, "worker", { runtimes, availability, runtimeDefaults, table, override, judgeBar: NO_BAR, previousId: previous[node.id]?.worker });
73
81
  if (worker.runtimeId === null) unmet.push({ nodeId: node.id, role: "worker", rule: worker.rule });
74
- // The judge's forbidden vendors follow the worker runtime that was
75
- // actually chosen, never the row that named it — a worker unmet leaves
76
- // nothing to conflict with, so the judge resolves without restriction.
77
- const forbiddenVendors = worker.runtimeId ? forbiddenJudgeVendors(worker.runtimeId, runtimes) : EMPTY_VENDORS;
78
- const judge = resolveRole(node, "judge", { runtimes, availability, runtimeDefaults, table, override, forbiddenVendors, previousId: previous[node.id]?.judge });
82
+ // The judge's bar follows the worker runtime that was actually chosen,
83
+ // never the row that named it — a worker unmet leaves nothing to
84
+ // conflict with, so the judge resolves without restriction.
85
+ const judgeBar = worker.runtimeId ? judgeBarFor(worker.runtimeId, runtimes, judgeIndependence) : NO_BAR;
86
+ const judge = resolveRole(node, "judge", { runtimes, availability, runtimeDefaults, table, override, judgeBar, previousId: previous[node.id]?.judge });
79
87
  if (judge.runtimeId === null) unmet.push({ nodeId: node.id, role: "judge", rule: judge.rule });
80
88
  assignments[node.id] = {
81
89
  worker: worker.runtimeId,
@@ -94,8 +102,8 @@ export function resolveRuntimes(nodes, config, options = {}) {
94
102
  return { assignments, unmet };
95
103
  }
96
104
 
97
- /** @type {ReadonlySet<string>} */
98
- const EMPTY_VENDORS = Object.freeze(new Set());
105
+ /** @type {JudgeBar} */
106
+ const NO_BAR = () => null;
99
107
 
100
108
  /**
101
109
  * Precedence for one role on one node: an explicit node override, then the
@@ -110,16 +118,16 @@ const EMPTY_VENDORS = Object.freeze(new Set());
110
118
  *
111
119
  * @param {RoutingNode} node
112
120
  * @param {"worker"|"judge"} role
113
- * @param {{runtimes: Record<string, RoutingRuntime>, availability: Record<string, RoutingAvailability>, runtimeDefaults: RoutingRoleMap, table: RoutingRule[], override: RoutingRoleMap, forbiddenVendors: ReadonlySet<string>, previousId: string|undefined}} context
121
+ * @param {{runtimes: Record<string, RoutingRuntime>, availability: Record<string, RoutingAvailability>, runtimeDefaults: RoutingRoleMap, table: RoutingRule[], override: RoutingRoleMap, judgeBar: JudgeBar, previousId: string|undefined}} context
114
122
  * @returns {{runtimeId: string|null, rule: string, strategy: AppliedStrategy|null, reason: string}}
115
123
  */
116
124
  function resolveRole(node, role, context) {
117
- const { runtimes, availability, runtimeDefaults, table, override, forbiddenVendors, previousId } = context;
125
+ const { runtimes, availability, runtimeDefaults, table, override, judgeBar, previousId } = context;
118
126
 
119
127
  if (override[role] !== undefined) {
120
128
  const id = override[role];
121
129
  return {
122
- runtimeId: admits(id, runtimes, availability, forbiddenVendors) ? id : null,
130
+ runtimeId: admits(id, runtimes, availability, judgeBar) ? id : null,
123
131
  rule: "override",
124
132
  strategy: "declared",
125
133
  reason: `operator override on node ${node.id}`,
@@ -129,7 +137,7 @@ function resolveRole(node, role, context) {
129
137
  if (runtimeDefaults[role] !== undefined) {
130
138
  const id = runtimeDefaults[role];
131
139
  return {
132
- runtimeId: admits(id, runtimes, availability, forbiddenVendors) ? id : null,
140
+ runtimeId: admits(id, runtimes, availability, judgeBar) ? id : null,
133
141
  rule: "runtimeDefaults",
134
142
  strategy: "declared",
135
143
  reason: "operator runtimeDefaults",
@@ -141,13 +149,13 @@ function resolveRole(node, role, context) {
141
149
  && (candidate.when.riskTier === undefined || candidate.when.riskTier === node.riskTier));
142
150
  if (row) {
143
151
  const strategy = row.strategy ?? "priority";
144
- const decision = chooseByStrategy(strategy, row.prefer, { runtimes, availability, forbiddenVendors, previousId });
152
+ const decision = chooseByStrategy(strategy, row.prefer, { runtimes, availability, judgeBar, previousId });
145
153
  return { runtimeId: decision.runtimeId, rule: ruleLabel(row), strategy, reason: decision.reason };
146
154
  }
147
155
 
148
156
  const discovered = role === "worker"
149
- ? cheapestAvailable(runtimes, availability, forbiddenVendors)
150
- : strongestAvailable(runtimes, availability, forbiddenVendors);
157
+ ? cheapestAvailable(runtimes, availability, judgeBar)
158
+ : strongestAvailable(runtimes, availability, judgeBar);
151
159
  return {
152
160
  runtimeId: discovered,
153
161
  rule: "discovery",
@@ -168,12 +176,12 @@ function resolveRole(node, role, context) {
168
176
  *
169
177
  * @param {RoutingStrategy} strategy
170
178
  * @param {string[]} prefer
171
- * @param {{runtimes: Record<string, RoutingRuntime>, availability: Record<string, RoutingAvailability>, forbiddenVendors: ReadonlySet<string>, previousId: string|undefined}} context
179
+ * @param {{runtimes: Record<string, RoutingRuntime>, availability: Record<string, RoutingAvailability>, judgeBar: JudgeBar, previousId: string|undefined}} context
172
180
  * @returns {{runtimeId: string|null, reason: string}}
173
181
  */
174
182
  function chooseByStrategy(strategy, prefer, context) {
175
- const { runtimes, availability, forbiddenVendors, previousId } = context;
176
- const candidates = prefer.map((id) => ({ id, admissible: admits(id, runtimes, availability, forbiddenVendors) }));
183
+ const { runtimes, availability, judgeBar, previousId } = context;
184
+ const candidates = prefer.map((id) => ({ id, admissible: admits(id, runtimes, availability, judgeBar) }));
177
185
  const admissible = candidates.filter((candidate) => candidate.admissible);
178
186
  if (admissible.length === 0) return { runtimeId: null, reason: "no admissible candidate in the prefer list" };
179
187
 
@@ -185,10 +193,10 @@ function chooseByStrategy(strategy, prefer, context) {
185
193
  // where the prefer list does not name it -- reusing it is the strategy's
186
194
  // whole point. It yields by name whenever it would violate availability
187
195
  // or vendor distinction, the yields `admits` already speaks.
188
- if (admits(previousId, runtimes, availability, forbiddenVendors)) {
196
+ if (admits(previousId, runtimes, availability, judgeBar)) {
189
197
  return { runtimeId: previousId, reason: `attempt-affinity: previous attempt's runtime ${previousId} still admissible` };
190
198
  }
191
- return { runtimeId: admissible[0].id, reason: `attempt-affinity yielded: ${previousId} ${blockedWhy(previousId, runtimes, availability, forbiddenVendors)}; prefer order decided` };
199
+ return { runtimeId: admissible[0].id, reason: `attempt-affinity yielded: ${previousId} ${blockedWhy(previousId, runtimes, availability, judgeBar)}; prefer order decided` };
192
200
  }
193
201
 
194
202
  if (strategy === "cost") {
@@ -222,54 +230,68 @@ function chooseByStrategy(strategy, prefer, context) {
222
230
  * @param {string} id
223
231
  * @param {Record<string, RoutingRuntime>} runtimes
224
232
  * @param {Record<string, RoutingAvailability>} availability
225
- * @param {ReadonlySet<string>} forbiddenVendors
233
+ * @param {JudgeBar} judgeBar
226
234
  * @returns {string}
227
235
  */
228
- function blockedWhy(id, runtimes, availability, forbiddenVendors) {
236
+ function blockedWhy(id, runtimes, availability, judgeBar) {
229
237
  const runtime = runtimes[id];
230
238
  if (!runtime) return "is not declared in runtimes";
231
- const provider = effectiveProvider(runtime);
232
- if (provider !== undefined && forbiddenVendors.has(provider)) return `carries forbidden vendor ${provider}`;
233
- return "is unavailable";
239
+ return judgeBar(id, runtime) ?? "is unavailable";
234
240
  }
235
241
 
236
242
  /**
237
243
  * @param {string} id
238
244
  * @param {Record<string, RoutingRuntime>} runtimes
239
245
  * @param {Record<string, RoutingAvailability>} availability
240
- * @param {ReadonlySet<string>} forbiddenVendors
246
+ * @param {JudgeBar} judgeBar
241
247
  * @returns {boolean}
242
248
  */
243
- function admits(id, runtimes, availability, forbiddenVendors) {
249
+ function admits(id, runtimes, availability, judgeBar) {
244
250
  const runtime = runtimes[id];
245
251
  if (!runtime) return false;
246
- const provider = effectiveProvider(runtime);
247
- if (provider !== undefined && forbiddenVendors.has(provider)) return false;
252
+ if (judgeBar(id, runtime) !== null) return false;
248
253
  return isRuntimeAvailable(availability[id]);
249
254
  }
250
255
 
251
256
  /**
252
- * Every vendor a judge may not carry: the worker's own vendor, plus the
253
- * vendor of each runtime reachable through the worker's declared `fallback`
254
- * chain — the same independence the contract validator enforces statically
255
- * once a worker is actually chosen dynamically here.
257
+ * The judge's bar for one chosen worker: the worker's own vendor, plus the
258
+ * vendor of each runtime reachable through its declared `fallback` chain —
259
+ * the same independence the contract validator enforces statically
260
+ * (`markSameProviderReviewNodes`), applied here once a worker is chosen.
261
+ * Outside same-vendor mode any of those vendors bars the judge. In
262
+ * same-vendor mode a shared vendor admits the judge only when the tier rule
263
+ * does against every chain runtime of that vendor, and the worker's own
264
+ * runtime never judges itself (`runtime-discovery.mjs` excludes it too).
256
265
  *
257
266
  * @param {string} workerId
258
267
  * @param {Record<string, RoutingRuntime>} runtimes
259
- * @returns {Set<string>}
268
+ * @param {"same-vendor"|undefined} judgeIndependence
269
+ * @returns {JudgeBar}
260
270
  */
261
- function forbiddenJudgeVendors(workerId, runtimes) {
262
- const vendors = new Set();
271
+ function judgeBarFor(workerId, runtimes, judgeIndependence) {
272
+ /** @type {RoutingRuntime[]} */
273
+ const chain = [];
263
274
  const seen = new Set();
264
275
  /** @type {string|undefined} */
265
276
  let id = workerId;
266
277
  while (id !== undefined && runtimes[id] && !seen.has(id)) {
267
278
  seen.add(id);
268
- const provider = effectiveProvider(runtimes[id]);
269
- if (provider !== undefined) vendors.add(provider);
279
+ chain.push(runtimes[id]);
270
280
  id = runtimes[id].fallback;
271
281
  }
272
- return vendors;
282
+ const vendors = new Set(chain.map((runtime) => effectiveProvider(runtime)).filter((provider) => provider !== undefined));
283
+ return (candidateId, candidate) => {
284
+ const provider = effectiveProvider(candidate);
285
+ if (provider === undefined || !vendors.has(provider)) return null;
286
+ if (judgeIndependence !== SAME_VENDOR_REVIEW_MODE) return `carries forbidden vendor ${provider}`;
287
+ if (candidateId === workerId) return "is the worker runtime itself";
288
+ for (const member of chain) {
289
+ if (effectiveProvider(member) !== provider) continue;
290
+ const refusal = sameVendorTierRefusal(/** @type {{harness: string, model: string}} */ (member), /** @type {{harness: string, model: string}} */ (candidate));
291
+ if (refusal) return `shares vendor ${provider} and ${refusal.message}`;
292
+ }
293
+ return null;
294
+ };
273
295
  }
274
296
 
275
297
  /** @param {RoutingRule} row @returns {string} */
@@ -281,16 +303,13 @@ function ruleLabel(row) {
281
303
  /**
282
304
  * @param {Record<string, RoutingRuntime>} runtimes
283
305
  * @param {Record<string, RoutingAvailability>} availability
284
- * @param {ReadonlySet<string>} forbiddenVendors
306
+ * @param {JudgeBar} judgeBar
285
307
  * @returns {{id: string, runtime: RoutingRuntime, order: number}[]}
286
308
  */
287
- function candidateEntries(runtimes, availability, forbiddenVendors) {
309
+ function candidateEntries(runtimes, availability, judgeBar) {
288
310
  return Object.entries(runtimes)
289
311
  .map(([id, runtime], order) => ({ id, runtime, order }))
290
- .filter(({ id, runtime }) => {
291
- const provider = effectiveProvider(runtime);
292
- return (provider === undefined || !forbiddenVendors.has(provider)) && isRuntimeAvailable(availability[id]);
293
- });
312
+ .filter(({ id, runtime }) => judgeBar(id, runtime) === null && isRuntimeAvailable(availability[id]));
294
313
  }
295
314
 
296
315
  /**
@@ -301,11 +320,11 @@ function candidateEntries(runtimes, availability, forbiddenVendors) {
301
320
  *
302
321
  * @param {Record<string, RoutingRuntime>} runtimes
303
322
  * @param {Record<string, RoutingAvailability>} availability
304
- * @param {ReadonlySet<string>} forbiddenVendors
323
+ * @param {JudgeBar} judgeBar
305
324
  * @returns {string|null}
306
325
  */
307
- function cheapestAvailable(runtimes, availability, forbiddenVendors) {
308
- return cheapest(candidateEntries(runtimes, availability, forbiddenVendors))?.id ?? null;
326
+ function cheapestAvailable(runtimes, availability, judgeBar) {
327
+ return cheapest(candidateEntries(runtimes, availability, judgeBar))?.id ?? null;
309
328
  }
310
329
 
311
330
  /**
@@ -317,9 +336,9 @@ function cheapestAvailable(runtimes, availability, forbiddenVendors) {
317
336
  *
318
337
  * @param {Record<string, RoutingRuntime>} runtimes
319
338
  * @param {Record<string, RoutingAvailability>} availability
320
- * @param {ReadonlySet<string>} forbiddenVendors
339
+ * @param {JudgeBar} judgeBar
321
340
  * @returns {string|null}
322
341
  */
323
- function strongestAvailable(runtimes, availability, forbiddenVendors) {
324
- return strongest(candidateEntries(runtimes, availability, forbiddenVendors), "")?.id ?? null;
342
+ function strongestAvailable(runtimes, availability, judgeBar) {
343
+ return strongest(candidateEntries(runtimes, availability, judgeBar), "")?.id ?? null;
325
344
  }