faberun 0.25.1 → 0.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/package.json +1 -1
  2. package/skills/faberun/references/contract.md +30 -26
  3. package/src/campaign/campaign-brief-graph.mjs +218 -0
  4. package/src/campaign/campaign-brief.mjs +15 -167
  5. package/src/campaign/metrics-command.mjs +3 -1
  6. package/src/cli/brand.mjs +1 -0
  7. package/src/cli/launch.mjs +14 -1
  8. package/src/cli/plan.mjs +75 -3
  9. package/src/cli/setup.mjs +22 -5
  10. package/src/cli.mjs +35 -1
  11. package/src/contract/definition-of-done.mjs +48 -8
  12. package/src/contract/human-step.mjs +54 -0
  13. package/src/contract/index.mjs +25 -38
  14. package/src/contract/judge-independence.mjs +170 -0
  15. package/src/contract/judges.mjs +27 -0
  16. package/src/contract/provider.mjs +103 -0
  17. package/src/contract/runtime.mjs +19 -2
  18. package/src/contract/snapshot.mjs +31 -1
  19. package/src/engine/assignment.mjs +41 -5
  20. package/src/engine/backoff.mjs +30 -9
  21. package/src/engine/dispatch.mjs +19 -2
  22. package/src/engine/failover.mjs +10 -2
  23. package/src/engine/judge-gate.mjs +24 -4
  24. package/src/engine/judge-list.mjs +206 -0
  25. package/src/engine/lifecycle.mjs +19 -16
  26. package/src/engine/live-preflight.mjs +45 -7
  27. package/src/engine/resume.mjs +46 -9
  28. package/src/engine/run-identity.mjs +9 -6
  29. package/src/engine/runtime-discovery.mjs +72 -11
  30. package/src/engine/scheduler.mjs +27 -6
  31. package/src/harnesses/catalogue.mjs +9 -2
  32. package/src/harnesses/index.mjs +2 -2
  33. package/src/harnesses/model-tiers.mjs +39 -0
  34. package/src/harnesses/protocol.mjs +4 -1
  35. package/src/host/config.mjs +7 -2
  36. package/src/plan/contest.mjs +78 -0
  37. package/src/plan/freeze.mjs +118 -7
  38. package/src/plan/human-step.mjs +38 -0
  39. package/src/plan/pipeline.mjs +224 -313
  40. package/src/plan/preflight.mjs +5 -3
  41. package/src/plan/proof-check.mjs +161 -0
  42. package/src/plan/proof-run.mjs +6 -2
  43. package/src/plan/proof-scope.mjs +175 -0
  44. package/src/plan/resolve.mjs +182 -0
  45. package/src/plan/reviewer.mjs +82 -0
  46. package/src/plan/rounds.mjs +446 -0
  47. package/src/plan/routing.mjs +11 -4
  48. package/src/plan/template.mjs +36 -14
  49. package/src/repo/signal-block.mjs +10 -3
  50. package/src/repo/source-identity.mjs +45 -5
  51. package/src/repo/worktree-prune.mjs +72 -0
  52. package/src/report/campaign-brief-estimate.mjs +1 -1
  53. package/src/report/campaign-brief.mjs +4 -0
  54. package/src/report/metrics-report.mjs +34 -1
  55. package/src/report/render.mjs +31 -10
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Validation for the `judgeIndependence` contract field (R20): the opt-in
3
+ * that lets a same-vendor judge review a node an operator with a single
4
+ * provider could otherwise never gate. Split from `contract/index.mjs`,
5
+ * mirroring `judges.mjs` (R18), because the tier comparison and its refusal
6
+ * belong with the field that turns them on, not scattered through the
7
+ * vendor-conflict check that calls them.
8
+ */
9
+ import { declaredModelTier } from "../harnesses/model-tiers.mjs";
10
+
11
+ /** The only value `judgeIndependence` accepts, on the contract or the machine config. */
12
+ export const SAME_VENDOR_REVIEW_MODE = "same-vendor";
13
+
14
+ /** The label every human-facing surface uses to mark a node reviewed under the mode. */
15
+ export const SAME_PROVIDER_REVIEW_LABEL = "same-provider review";
16
+
17
+ /**
18
+ * @param {unknown} value
19
+ * @param {string} label
20
+ * @returns {"same-vendor"|undefined}
21
+ */
22
+ export function validateJudgeIndependence(value, label) {
23
+ if (value === undefined) return undefined;
24
+ if (value !== SAME_VENDOR_REVIEW_MODE) throw new TypeError(`${label} must be "${SAME_VENDOR_REVIEW_MODE}"`);
25
+ return value;
26
+ }
27
+
28
+ /**
29
+ * Same-vendor mode's own admissibility rule for a worker/judge pair that
30
+ * share a vendor: a judge model absent from the declared tier table can never
31
+ * judge in this mode, and a judge tier below the worker's is refused, naming
32
+ * both. `null` means the pair is admissible.
33
+ *
34
+ * @param {{harness: string, model: string}} workerRuntime
35
+ * @param {{harness: string, model: string}} judgeRuntime
36
+ * @returns {{code: string, message: string}|null}
37
+ */
38
+ export function sameVendorTierRefusal(workerRuntime, judgeRuntime) {
39
+ const judgeTier = declaredModelTier(judgeRuntime.harness, judgeRuntime.model);
40
+ if (judgeTier === null) {
41
+ return {
42
+ code: "same_vendor_judge_no_tier",
43
+ message: `judge model ${judgeRuntime.model} declares no tier and cannot judge in same-vendor mode`,
44
+ };
45
+ }
46
+ const workerTier = declaredModelTier(workerRuntime.harness, workerRuntime.model);
47
+ if (workerTier !== null && judgeTier < workerTier) {
48
+ return {
49
+ code: "same_vendor_judge_tier_too_low",
50
+ message: `judge tier ${judgeTier} (${judgeRuntime.model}) is below worker tier ${workerTier} (${workerRuntime.model})`,
51
+ };
52
+ }
53
+ return null;
54
+ }
55
+
56
+ /**
57
+ * Whether a worker runtime that actually ran and a judge runtime that
58
+ * actually ran are a same-provider pairing: the mode is opted in and the two
59
+ * share a vendor. Contract validation already refused every reachable
60
+ * worker/judge pairing (primary or fallback) that fails the tier rule, so
61
+ * this is a plain vendor comparison at dispatch time, not a second
62
+ * enforcement of it.
63
+ *
64
+ * @param {{judgeIndependence?: string}} contract
65
+ * @param {{vendor?: string}|null|undefined} workerRuntime
66
+ * @param {{vendor?: string}|null|undefined} judgeRuntime
67
+ * @returns {boolean}
68
+ */
69
+ export function isSameProviderReviewPair(contract, workerRuntime, judgeRuntime) {
70
+ if (contract.judgeIndependence !== SAME_VENDOR_REVIEW_MODE) return false;
71
+ const workerVendor = workerRuntime?.vendor;
72
+ const judgeVendor = judgeRuntime?.vendor;
73
+ return Boolean(workerVendor) && workerVendor === judgeVendor;
74
+ }
75
+
76
+ /**
77
+ * Whether `workerRuntimeId` or its one declared fallback hop could share
78
+ * `judgeVendor`: the structural "this pairing might land same-provider"
79
+ * fact a plan-time surface (the Campaign Brief) can read before any node has
80
+ * run, mirroring the one-hop rule `engine/failover.mjs` enforces at dispatch.
81
+ * A run-time surface reading a completed node's snapshot should prefer the
82
+ * dynamic fact instead (`isSameProviderReviewPair` against the runtime that
83
+ * actually ran).
84
+ *
85
+ * @param {Record<string, {vendor?: string, fallback?: string}>} runtimes
86
+ * @param {string} workerRuntimeId
87
+ * @param {string|null|undefined} judgeVendor
88
+ * @returns {boolean}
89
+ */
90
+ export function sharesVendorThroughFallback(runtimes, workerRuntimeId, judgeVendor) {
91
+ if (!judgeVendor) return false;
92
+ const seen = new Set([workerRuntimeId]);
93
+ let currentId = workerRuntimeId;
94
+ while (currentId !== undefined) {
95
+ if (runtimes[currentId]?.vendor === judgeVendor) return true;
96
+ const nextId = runtimes[currentId]?.fallback;
97
+ if (nextId === undefined || seen.has(nextId)) break;
98
+ seen.add(nextId);
99
+ currentId = nextId;
100
+ }
101
+ return false;
102
+ }
103
+
104
+ /**
105
+ * A gated node whose worker and judge share a vendor cannot produce an
106
+ * independent review -- the same vendor grading its own output is not a
107
+ * gate, so this is rejected outright unless `judgeIndependence` opts the
108
+ * contract into same-vendor mode, in which case a judge tier at or above the
109
+ * worker's admits the pair and marks the node `sameProviderReview` for every
110
+ * human-facing surface to read back. The worker's declared fallback chain is
111
+ * checked the same way, since it is statically known which runtime a worker
112
+ * failover lands on; the symmetric case -- the judge's own fallback landing
113
+ * on the worker's vendor -- depends on which worker runtime actually ran and
114
+ * is refused at execution instead (node.mjs, `judge_fallback_vendor_conflict`).
115
+ *
116
+ * Mutates and returns `nodes` in place; called once per `validateContract`.
117
+ *
118
+ * @param {{gate: {enabled: boolean, runtime?: string}, runtime?: string, sameProviderReview: boolean}[]} nodes
119
+ * @param {Record<string, {vendor?: string, fallback?: string, harness: string, model: string}>} runtimes
120
+ * @param {{worker?: string, judge?: string}} defaults
121
+ * @param {"same-vendor"|undefined} judgeIndependence
122
+ * @returns {{gate: {enabled: boolean, runtime?: string}, runtime?: string, sameProviderReview: boolean}[]}
123
+ */
124
+ export function markSameProviderReviewNodes(nodes, runtimes, defaults, judgeIndependence) {
125
+ for (const [index, node] of nodes.entries()) {
126
+ if (!node.gate.enabled) continue;
127
+ const workerRuntimeId = node.runtime ?? defaults.worker;
128
+ const judgeRuntimeId = node.gate.runtime ?? defaults.judge;
129
+ if (!workerRuntimeId || !judgeRuntimeId) continue;
130
+ const workerRuntime = runtimes[workerRuntimeId];
131
+ const judgeRuntime = runtimes[judgeRuntimeId];
132
+ const workerVendor = workerRuntime.vendor;
133
+ const judgeVendor = judgeRuntime.vendor;
134
+ if (workerVendor === judgeVendor) {
135
+ if (judgeIndependence !== SAME_VENDOR_REVIEW_MODE) {
136
+ throw new TypeError(`nodes[${index}] worker runtime ${workerRuntimeId} and judge runtime ${judgeRuntimeId} share vendor ${workerVendor}`);
137
+ }
138
+ const refusal = sameVendorTierRefusal(workerRuntime, judgeRuntime);
139
+ if (refusal) {
140
+ throw new TypeError(`nodes[${index}] worker runtime ${workerRuntimeId} and judge runtime ${judgeRuntimeId} share vendor ${workerVendor}: ${refusal.message}`);
141
+ }
142
+ nodes[index] = { ...node, sameProviderReview: true };
143
+ }
144
+ const seenFallbacks = new Set([workerRuntimeId]);
145
+ let fallbackId = runtimes[workerRuntimeId].fallback;
146
+ while (fallbackId !== undefined) {
147
+ if (seenFallbacks.has(fallbackId)) {
148
+ throw new TypeError(`nodes[${index}] worker runtime ${workerRuntimeId} fallback chain cycles back to ${fallbackId}`);
149
+ }
150
+ seenFallbacks.add(fallbackId);
151
+ const fallbackVendor = runtimes[fallbackId].vendor;
152
+ if (fallbackVendor === judgeVendor) {
153
+ if (judgeIndependence !== SAME_VENDOR_REVIEW_MODE) {
154
+ throw new TypeError(`nodes[${index}] worker runtime ${workerRuntimeId} fallback runtime ${fallbackId} and judge runtime ${judgeRuntimeId} share vendor ${fallbackVendor}`);
155
+ }
156
+ const fallbackRefusal = sameVendorTierRefusal(runtimes[fallbackId], judgeRuntime);
157
+ if (fallbackRefusal) {
158
+ throw new TypeError(`nodes[${index}] worker runtime ${workerRuntimeId} fallback runtime ${fallbackId} and judge runtime ${judgeRuntimeId} share vendor ${fallbackVendor}: ${fallbackRefusal.message}`);
159
+ }
160
+ // The fallback is only reachable if it actually runs, but the tier
161
+ // rule above already admitted it -- so the node is marked here too,
162
+ // not only when the primary matches (the plan-time "could this land
163
+ // same-provider" fact every human-facing surface reads back).
164
+ nodes[index] = { ...nodes[index], sameProviderReview: true };
165
+ }
166
+ fallbackId = runtimes[fallbackId].fallback;
167
+ }
168
+ }
169
+ return nodes;
170
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Validation for the `judges` contract field (R18): a static, ordered list of
3
+ * runtime ids the engine reads before falling back to the machine default or
4
+ * the single strongest cross-vendor candidate. Split from `contract/index.mjs`,
5
+ * which is already near this repository's 800-line ceiling.
6
+ */
7
+ import { requireId } from "./assert.mjs";
8
+ import { requireRuntime } from "./runtime.mjs";
9
+
10
+ /** @typedef {import("./runtime.mjs").ValidatedRuntime} ValidatedRuntime */
11
+
12
+ /**
13
+ * @param {unknown} value
14
+ * @param {Record<string, ValidatedRuntime>} runtimes
15
+ * @param {string} label
16
+ * @returns {string[]|undefined}
17
+ */
18
+ export function validateJudgeList(value, runtimes, label) {
19
+ if (value === undefined) return undefined;
20
+ if (!Array.isArray(value) || value.length === 0) throw new TypeError(`${label} must be a non-empty array of runtime ids`);
21
+ value.forEach((id, index) => {
22
+ requireId(id, `${label}[${index}]`);
23
+ requireRuntime(runtimes, id, `${label}[${index}]`);
24
+ });
25
+ if (new Set(value).size !== value.length) throw new TypeError(`${label} must not repeat a runtime id`);
26
+ return /** @type {string[]} */ (value);
27
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * The canonical provider a runtime actually talks to, as opposed to its
3
+ * free-text `vendor` label. Split from runtime.mjs because both the contract
4
+ * validator and every worker-against-judge vendor comparison across `plan/`,
5
+ * `engine/`, `harnesses/` and `cli/` need it, and none of those layers should
6
+ * import the runtime schema validator just to reach it.
7
+ */
8
+ import { DEFAULT_HARNESS_VENDORS } from "../harnesses/index.mjs";
9
+
10
+ /** @typedef {{harness?: string, model?: string, config?: Record<string, unknown>}} ProviderInput */
11
+
12
+ /**
13
+ * The five canonical providers a runtime can derive to. A runtime whose
14
+ * harness names no default and whose model matches no family below derives
15
+ * none of these — `replay` and `exec-jsonl` never do, since either stands in
16
+ * for whatever the recording or the exec'd binary actually is.
17
+ *
18
+ * @type {ReadonlyArray<"openai"|"anthropic"|"zhipu"|"deepseek"|"google">}
19
+ */
20
+ const CANONICAL_PROVIDERS = Object.freeze(["openai", "anthropic", "zhipu", "deepseek", "google"]);
21
+
22
+ /**
23
+ * Model-id family prefixes, checked before the harness default so an
24
+ * `agy` runtime reselling `claude-sonnet-4-6` derives `anthropic`, not `agy`'s
25
+ * own default of `google`.
26
+ *
27
+ * @type {ReadonlyArray<{pattern: RegExp, provider: string}>}
28
+ */
29
+ const MODEL_FAMILY_PROVIDERS = Object.freeze([
30
+ { pattern: /^claude-|^opus\b|^sonnet\b|^fable\b|^haiku\b/iu, provider: "anthropic" },
31
+ { pattern: /^gpt-|^o\d/iu, provider: "openai" },
32
+ { pattern: /^gemini-/iu, provider: "google" },
33
+ { pattern: /^glm-/iu, provider: "zhipu" },
34
+ { pattern: /^deepseek-/iu, provider: "deepseek" },
35
+ ]);
36
+
37
+ /**
38
+ * The harnesses whose runtime always stands in for an arbitrary provider: the
39
+ * recording (`replay`) or the exec'd binary (`exec-jsonl`) decide, never this
40
+ * module.
41
+ */
42
+ const NO_CANONICAL_PROVIDER_HARNESSES = new Set(["replay", "exec-jsonl"]);
43
+
44
+ /**
45
+ * The provider a runtime declares a route to, read from the config key its
46
+ * own harness uses for that: codex's `config.model_provider` (for example
47
+ * `deepseek`) and dsh's `config.provider` (`deepseek-official` names
48
+ * `deepseek`). A route naming none of the five canonical providers is not a
49
+ * route this function recognizes, so derivation falls through to the model's
50
+ * family and then the harness default.
51
+ *
52
+ * @param {ProviderInput} runtime
53
+ * @returns {string|null}
54
+ */
55
+ function routedProvider(runtime) {
56
+ const config = runtime.config;
57
+ if (!config || typeof config !== "object") return null;
58
+ const route = runtime.harness === "codex"
59
+ ? config.model_provider
60
+ : runtime.harness === "dsh"
61
+ ? config.provider
62
+ : undefined;
63
+ if (typeof route !== "string" || route.length === 0) return null;
64
+ const lowered = route.toLowerCase();
65
+ return CANONICAL_PROVIDERS.find((provider) => lowered.includes(provider)) ?? null;
66
+ }
67
+
68
+ /**
69
+ * The provider a runtime actually talks to, derived in order from (1) the
70
+ * route it declares to another provider, (2) its model id's family, and (3)
71
+ * its harness's own default. `null` means none of the three named one — the
72
+ * two test harnesses always land here, and any other harness does too when
73
+ * its model matches no known family and its harness names no default.
74
+ *
75
+ * @param {ProviderInput} runtime
76
+ * @returns {string|null}
77
+ */
78
+ export function canonicalProvider(runtime) {
79
+ if (!runtime || typeof runtime.harness !== "string") return null;
80
+ if (NO_CANONICAL_PROVIDER_HARNESSES.has(runtime.harness)) return null;
81
+ const routed = routedProvider(runtime);
82
+ if (routed) return routed;
83
+ const model = typeof runtime.model === "string" ? runtime.model : "";
84
+ const family = MODEL_FAMILY_PROVIDERS.find((entry) => entry.pattern.test(model));
85
+ if (family) return family.provider;
86
+ return /** @type {Record<string, string>} */ (DEFAULT_HARNESS_VENDORS)[runtime.harness] ?? null;
87
+ }
88
+
89
+ /**
90
+ * The provider identity a worker-against-judge comparison actually compares:
91
+ * the canonical provider when one is derivable, the declared `vendor`
92
+ * otherwise. Every routing and failover site that used to compare `.vendor`
93
+ * directly compares this instead, so a contradicting label (R18) cannot
94
+ * survive `validateRuntime` and reach one of them, and a runtime that
95
+ * bypasses validation (a raw fixture, a discovery-catalogue definition) still
96
+ * compares consistently rather than by accident.
97
+ *
98
+ * @param {ProviderInput & {vendor?: string}} runtime
99
+ * @returns {string|undefined}
100
+ */
101
+ export function effectiveProvider(runtime) {
102
+ return canonicalProvider(runtime) ?? runtime.vendor;
103
+ }
@@ -12,6 +12,7 @@
12
12
  import { assertObject, nonNegativeNumber, positiveInteger, positiveNumber, rejectUnknown, requireId, requireString, requireStringArray, requireTimestamp } from "./assert.mjs";
13
13
  import { composeAssignments } from "../engine/runtime-discovery.mjs";
14
14
  import { harnessCapabilities, resolvePermissionExecution, resolveVendor, validateCapabilityRequirements, writesWorkspace } from "../harnesses/index.mjs";
15
+ import { canonicalProvider } from "./provider.mjs";
15
16
  import { stableJson } from "../util.mjs";
16
17
  /** @typedef {import("./index.mjs").NodeStatus} NodeStatus */
17
18
  /** @typedef {import("../engine/runtime-discovery.mjs").RuntimeAvailability} RuntimeAvailability */
@@ -98,8 +99,24 @@ export function validateRuntime(id, runtime) {
98
99
  assertObject(runtime, `runtime ${id}`);
99
100
  rejectUnknown(runtime, RUNTIME_FIELDS, `runtime ${id}`);
100
101
  validateRuntimeValues(runtime, `runtime ${id}`, runtime.harness === "exec-jsonl");
101
- const vendor = resolveVendor(/** @type {{harness: string, vendor?: string, config?: Record<string, unknown>}} */ (runtime));
102
- if (!vendor) throw new TypeError(`runtime ${id} has no resolvable vendor`);
102
+ const typedRuntime = /** @type {{harness: string, model: string, vendor?: string, config?: Record<string, unknown>}} */ (runtime);
103
+ // The canonical provider derived from the route, the model's family, or the
104
+ // harness default outranks a declared `vendor`: a label that contradicts it
105
+ // is refused (R18), and the runtime's vendor becomes the derived provider
106
+ // rather than whatever text the contract declared. `replay` and `exec-jsonl`
107
+ // derive none — for those the declared vendor stands, as it always has.
108
+ const derived = canonicalProvider(typedRuntime);
109
+ const declared = typeof typedRuntime.vendor === "string" && typedRuntime.vendor.length ? typedRuntime.vendor : undefined;
110
+ let vendor;
111
+ if (derived) {
112
+ if (declared !== undefined && declared !== derived) {
113
+ throw new TypeError(`runtime ${id} declares vendor ${declared} but ${typedRuntime.harness} ${typedRuntime.model} is provider ${derived}`);
114
+ }
115
+ vendor = derived;
116
+ } else {
117
+ vendor = resolveVendor(typedRuntime);
118
+ if (!vendor) throw new TypeError(`runtime ${id} has no resolvable vendor`);
119
+ }
103
120
  const stallTimeoutSec = runtime.stallTimeoutSec ?? HARNESS_STALL_TIMEOUT_SEC[/** @type {string} */ (runtime.harness)];
104
121
  return /** @type {ValidatedRuntime} */ ({
105
122
  ...runtime,
@@ -130,6 +130,7 @@ export function validateNodeSnapshot(value, expectedNode = null) {
130
130
  "attempt", "revisions", "judgeFailures", "requirementIds", "runtime", "blockedBy", "startedAt", "updatedAt", "result", "gate", "error", "usage",
131
131
  "costUsd", "routing", "progress", "worktree", "invocations", "executionOverrides", "verification", "scope",
132
132
  "scopeFindings", "verificationArtifacts", "review", "previousAttempt", "sessionPolicy", "integratedHead", "declaredReadBytes",
133
+ "sameProviderReview",
133
134
  ]), "node snapshot");
134
135
  validateMetadata(value, "node snapshot");
135
136
  requireId(value.id, "node snapshot.id");
@@ -148,6 +149,11 @@ export function validateNodeSnapshot(value, expectedNode = null) {
148
149
  if (value.review !== undefined && !REVIEW_MODES.has(/** @type {string} */ (value.review))) {
149
150
  throw new TypeError("node snapshot.review is invalid");
150
151
  }
152
+ // R20: whether this attempt's judge was admitted under same-vendor mode,
153
+ // stamped by `startJudge` from the contract's own `sameProviderReview`.
154
+ if (value.sameProviderReview !== undefined && typeof value.sameProviderReview !== "boolean") {
155
+ throw new TypeError("node snapshot.sameProviderReview must be a boolean");
156
+ }
151
157
  requirePacketHash(value.packetHash, "node snapshot.packetHash");
152
158
  validateSourceIdentity(value.sourceIdentity, "node snapshot.sourceIdentity", { kind: "node" });
153
159
  if (value.integratedHead !== undefined && value.integratedHead !== null) boundedString(value.integratedHead, "node snapshot.integratedHead", 256);
@@ -460,13 +466,36 @@ function validateTierExhaustion(value, label) {
460
466
  if (candidate.exhaustedUntil !== null) requireTimestamp(candidate.exhaustedUntil, `${label}.candidates[${index}].exhaustedUntil`);
461
467
  }
462
468
  }
469
+ /**
470
+ * The durable evidence of one node's ordered judge-list selection (R18): the
471
+ * list it read, the entry it chose (null once every entry is skipped), and
472
+ * why each earlier one was -- appended to, never replaced, across a run's
473
+ * hop-by-hop fallback, so an entry already skipped for cause stays skipped.
474
+ *
475
+ * @param {unknown} value
476
+ * @param {string} label
477
+ */
478
+ function validateJudgeListState(value, label) {
479
+ assertObject(value, label);
480
+ rejectUnknown(value, new Set(["list", "chosen", "skipped"]), label);
481
+ if (!Array.isArray(value.list) || value.list.length === 0) throw new TypeError(`${label}.list must be a non-empty array`);
482
+ value.list.forEach((id, index) => requireId(id, `${label}.list[${index}]`));
483
+ if (value.chosen !== null) requireId(value.chosen, `${label}.chosen`);
484
+ if (!Array.isArray(value.skipped)) throw new TypeError(`${label}.skipped must be an array`);
485
+ for (const [index, entry] of value.skipped.entries()) {
486
+ assertObject(entry, `${label}.skipped[${index}]`);
487
+ rejectUnknown(entry, new Set(["id", "reason"]), `${label}.skipped[${index}]`);
488
+ requireId(entry.id, `${label}.skipped[${index}].id`);
489
+ boundedString(entry.reason, `${label}.skipped[${index}].reason`, 512);
490
+ }
491
+ }
463
492
  /**
464
493
  * @param {unknown} value
465
494
  * @param {string} label
466
495
  */
467
496
  function validateRoutingState(value, label) {
468
497
  assertObject(value, label);
469
- rejectUnknown(value, new Set(["history", "currentOverride", "assignments", "availability", "tierExhaustion", "tierExhaustionCycle"]), label);
498
+ rejectUnknown(value, new Set(["history", "currentOverride", "assignments", "availability", "tierExhaustion", "tierExhaustionCycle", "judgeList"]), label);
470
499
  if (!Array.isArray(value.history) || value.history.length > MAX_ROUTING_HISTORY) {
471
500
  throw new TypeError(`${label}.history must be an array with at most ${MAX_ROUTING_HISTORY} items`);
472
501
  }
@@ -476,6 +505,7 @@ function validateRoutingState(value, label) {
476
505
  if (value.currentOverride !== null) validateRoutingEntry(value.currentOverride, `${label}.currentOverride`, true);
477
506
  if (value.tierExhaustion !== undefined) validateTierExhaustion(value.tierExhaustion, `${label}.tierExhaustion`);
478
507
  if (value.tierExhaustionCycle !== undefined) nonNegativeInteger(value.tierExhaustionCycle, `${label}.tierExhaustionCycle`);
508
+ if (value.judgeList !== undefined) validateJudgeListState(value.judgeList, `${label}.judgeList`);
479
509
  if (value.assignments !== undefined) {
480
510
  assertObject(value.assignments, `${label}.assignments`);
481
511
  rejectUnknown(value.assignments, new Set(["worker", "judge", "composedWorker", "composedJudge"]), `${label}.assignments`);
@@ -11,11 +11,14 @@ import { PARKED } from "./prompts.mjs";
11
11
  import { composeAssignments, discoverRuntimes } from "./runtime-discovery.mjs";
12
12
  import { readUserConfig } from "../host/config.mjs";
13
13
  import { transition } from "./state.mjs";
14
+ import { isHumanStepPending } from "../contract/human-step.mjs";
15
+ import { initialJudgeListState, resolveJudgeList } from "./judge-list.mjs";
14
16
 
15
17
  /** @typedef {import("../cli.mjs").LockHandle} LockHandle */
16
18
  /** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
17
19
  /** @typedef {import("./runtime-discovery.mjs").RuntimeAvailability} RuntimeAvailability */
18
20
  /** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
21
+ /** @typedef {import("../contract/index.mjs").JudgeListState} JudgeListState */
19
22
 
20
23
  /**
21
24
  * Resolve role assignments once at run creation. Discovery is used only for
@@ -30,7 +33,7 @@ import { transition } from "./state.mjs";
30
33
  * observables); it widens when a reader needs the record durably.
31
34
  *
32
35
  * @param {ValidatedContract} contract
33
- * @returns {Promise<{assignments: Record<string, {worker: string, judge: string, composedWorker: boolean, composedJudge: boolean}>, decisions: Record<string, {worker: {strategy: string|null, reason: string}, judge: {strategy: string|null, reason: string}}>, availability: Record<string, import("./runtime-discovery.mjs").RuntimeAvailability>}>}
36
+ * @returns {Promise<{assignments: Record<string, {worker: string, judge: string, composedWorker: boolean, composedJudge: boolean}>, decisions: Record<string, {worker: {strategy: string|null, reason: string}, judge: {strategy: string|null, reason: string}}>, availability: Record<string, import("./runtime-discovery.mjs").RuntimeAvailability>, judgeListStates: Record<string, JudgeListState|undefined>}>}
34
37
  */
35
38
  export async function runtimeAssignments(contract) {
36
39
  const needsComposition = contract.nodes.some((node) =>
@@ -38,7 +41,27 @@ export async function runtimeAssignments(contract) {
38
41
  || (node.gate.enabled && node.gate.runtime === undefined && contract.runtimeDefaults?.judge === undefined));
39
42
  const availability = needsComposition ? await discoverRuntimes(contract.runtimes, { cwd: contract.cwd }) : {};
40
43
  const config = readUserConfig(process.env);
41
- const assignments = composeAssignments(contract, availability, { config });
44
+ const list = resolveJudgeList(contract, config);
45
+ /** @type {Record<string, JudgeListState|undefined>} */
46
+ const judgeListStates = {};
47
+ // R18: an omitted judge reads the ordered list, contract over machine
48
+ // config, before `composeAssignments`' own single `config.judge` preference
49
+ // and strongest-candidate default. The callback records the evidence
50
+ // (`judgeListStates`) as a side effect and returns `undefined` only for a
51
+ // node the list does not govern (its gate is disabled), which falls through
52
+ // to those candidates unchanged; once the list governs, its pick is final --
53
+ // the whole state, so an exhausted list's thrown error can still name every
54
+ // skip, not just `chosen` (`null` when every entry was skipped), which
55
+ // `composeAssignments` must not fall through either.
56
+ const listJudge = list
57
+ ? (/** @type {{id: string, gate: {enabled: boolean}}} */ node, /** @type {string} */ workerId) => {
58
+ if (!node.gate.enabled) return undefined;
59
+ const judgeListState = initialJudgeListState(contract, list, workerId);
60
+ judgeListStates[node.id] = judgeListState;
61
+ return judgeListState;
62
+ }
63
+ : undefined;
64
+ const assignments = composeAssignments(contract, availability, { config, listJudge });
42
65
  /** @type {Record<string, {worker: {strategy: string|null, reason: string}, judge: {strategy: string|null, reason: string}}>} */
43
66
  const decisions = {};
44
67
  return {
@@ -54,20 +77,28 @@ export async function runtimeAssignments(contract) {
54
77
  const workerSource = node?.runtime !== undefined
55
78
  ? "node runtime"
56
79
  : contract.runtimeDefaults?.worker !== undefined ? "runtimeDefaults.worker" : null;
80
+ // A list state exists only when the callback actually ran for this node
81
+ // (composedJudge). An exhausted list (chosen null) never reaches this
82
+ // point at all: composeAssignments throws before this map runs rather
83
+ // than falling through to the discovery ranking.
84
+ const fromList = judgeListStates[nodeId]?.chosen === assignment.judge;
57
85
  decisions[nodeId] = {
58
86
  worker: {
59
87
  strategy: composedWorker ? "cost" : workerSource !== null ? "declared" : null,
60
88
  reason: composedWorker ? "discovery: cheapest available runtime" : /** @type {string} */ (workerSource),
61
89
  },
62
90
  judge: {
63
- strategy: composedJudge ? "priority" : judgeSource !== null ? "declared" : null,
64
- reason: composedJudge ? "discovery: strongest available runtime" : /** @type {string} */ (judgeSource ?? "no judge required"),
91
+ strategy: composedJudge ? (fromList ? "judge-list" : "priority") : judgeSource !== null ? "declared" : null,
92
+ reason: composedJudge
93
+ ? (fromList ? `judge list: chose ${assignment.judge}` : "discovery: strongest available runtime")
94
+ : /** @type {string} */ (judgeSource ?? "no judge required"),
65
95
  },
66
96
  };
67
97
  return [nodeId, { ...assignment, composedWorker, composedJudge }];
68
98
  })),
69
99
  decisions,
70
100
  availability,
101
+ judgeListStates,
71
102
  };
72
103
  }
73
104
  /**
@@ -85,6 +116,11 @@ export function blockDependents(contract, runDir, states, lock) {
85
116
  // automatic retry is still unspent is re-opened to `pending` before this
86
117
  // runs, so the dependants stay `pending`/`phase: "waiting"` until it parks.
87
118
  const blockedBy = node.dependsOn.filter((id) => PARKED.has(states.get(id)?.status ?? ""));
88
- if (blockedBy.length) transition(runDir, state, "blocked", { phase: "dependency", blockedBy, error: { code: "dependency_failed", message: `blocked by ${blockedBy.join(", ")}` } }, lock);
119
+ // A dependant of a pending human step (R16) is waiting, not failed: the
120
+ // code stays `dependency_failed` so `resume --answer` re-opens it, and the
121
+ // message says what it waits for.
122
+ const waiting = blockedBy.every((id) => isHumanStepPending(/** @type {NodeSnapshot} */ (states.get(id))));
123
+ const message = waiting ? `waiting for human step ${blockedBy.join(", ")}` : `blocked by ${blockedBy.join(", ")}`;
124
+ if (blockedBy.length) transition(runDir, state, "blocked", { phase: "dependency", blockedBy, error: { code: "dependency_failed", message } }, lock);
89
125
  }
90
126
  }
@@ -362,9 +362,10 @@ export function upsertTierExhaustionCandidate(existing, role, runtimeId, exhaust
362
362
  * @param {Transition} schedule
363
363
  * @param {number} [now] epoch ms the backoff window is measured from
364
364
  * @param {string|null} [exhaustedUntil] the reset instant the exhausted candidate announced, if any
365
- * @returns {{blocked: RouteError|null, nextRuntime: string, ruleIndex: number|undefined, revision: number, hop: number, backoffSec: number, backoffUntil: string, composed: boolean, tierExhaustion: TierExhaustion|null}}
365
+ * @param {{judgeListState?: import("../contract/index.mjs").JudgeListState}} [options] R18: a judge-list hop already resolved by the impure caller (`engine/judge-list.mjs`'s `nextListJudge` reads the durable refusal and usage-window stores, which this decision module never touches); its `chosen` -- possibly null, once the list is exhausted -- replaces both the declared `runtime.fallback` edge and the dynamic-tier candidate a list-driven judge would otherwise get from `nextSynthesizedRuntime`/`nextSameTierRuntime`
366
+ * @returns {{blocked: RouteError|null, nextRuntime: string, ruleIndex: number|undefined, revision: number, hop: number, backoffSec: number, backoffUntil: string, composed: boolean, tierExhaustion: TierExhaustion|null, judgeList?: import("../contract/index.mjs").JudgeListState}}
366
367
  */
367
- export function planRoute(contract, node, state, role, error, current, schedule, now = Date.now(), exhaustedUntil = null) {
368
+ export function planRoute(contract, node, state, role, error, current, schedule, now = Date.now(), exhaustedUntil = null, options = {}) {
368
369
  const revision = state.revisions ?? 0;
369
370
  // Tier routing is scoped by generation, not revision: an invocation counts
370
371
  // as attempted here only when it ran in the current tier-exhaustion
@@ -394,17 +395,33 @@ export function planRoute(contract, node, state, role, error, current, schedule,
394
395
  // declared" — nextSynthesizedRuntime's own attempted-filter would otherwise
395
396
  // make that distinction unreachable.
396
397
  const declaredCandidate = synthesizedChain(contract, role, current)[0] ?? null;
397
- const fallback = nextSynthesizedRuntime(contract, role, current, attempted)
398
- ?? (dynamicComposed ? nextSameTierRuntime(contract, routing, role, current, attempted) : null);
398
+ // R18: once a list governs this hop (`options.judgeListState` is supplied),
399
+ // its precomputed pick *replaces* the declared `runtime.fallback` edge
400
+ // rather than following it -- the list is itself the ordering, and a list
401
+ // entry that happens to declare its own `fallback` must not hop there
402
+ // instead of the list's next eligible entry. Only when no list governs does
403
+ // the declared edge, then the dynamic-tier candidate, apply.
404
+ const judgeListState = options.judgeListState;
405
+ const listGoverned = judgeListState !== undefined;
406
+ const fallback = judgeListState !== undefined
407
+ ? judgeListState.chosen
408
+ : nextSynthesizedRuntime(contract, role, current, attempted) ?? (dynamicComposed ? nextSameTierRuntime(contract, routing, role, current, attempted) : null);
399
409
  const hop = nextHop(state, role, revision, schedule);
400
410
  const nextRuntime = schedule.kind === "reset" ? current : fallback ?? current;
411
+ // A list-governed hop is bounded by the list's own length (instructions
412
+ // R18 §4), never the one-hop cap that applies to a declared or dynamic-tier
413
+ // fallback, so it is exempted from the hop-cap check below exactly as a
414
+ // dynamic-tier fallback already is.
415
+ const noHopCap = dynamicComposed || listGoverned;
401
416
  const blocked = schedule.kind === "reset"
402
417
  ? null
403
418
  : fallback === null
404
- ? declaredCandidate !== null && attempted.has(declaredCandidate)
405
- ? { code: "provider_failover_cycle", message: `runtime ${declaredCandidate} was already attempted in ${role} revision ${revision}` }
406
- : { code: dynamicComposed ? "runtime_tier_exhausted" : error.code, message: dynamicComposed ? `no available runtime remains in tier ${String(contract.runtimes[current]?.tier ?? "unknown")} for ${role}` : error.message }
407
- : hop > 1 && !dynamicComposed
419
+ ? listGoverned
420
+ ? { code: "judge_list_exhausted", message: `no eligible entry remains in the judge list for ${role}` }
421
+ : declaredCandidate !== null && attempted.has(declaredCandidate)
422
+ ? { code: "provider_failover_cycle", message: `runtime ${declaredCandidate} was already attempted in ${role} revision ${revision}` }
423
+ : { code: dynamicComposed ? "runtime_tier_exhausted" : error.code, message: dynamicComposed ? `no available runtime remains in tier ${String(contract.runtimes[current]?.tier ?? "unknown")} for ${role}` : error.message }
424
+ : hop > 1 && !noHopCap
408
425
  ? { code: "provider_failover_hop_cap", message: "provider failover exceeded the one-hop cap" }
409
426
  : null;
410
427
  const backoffSec = schedule.kind === "reset"
@@ -417,7 +434,7 @@ export function planRoute(contract, node, state, role, error, current, schedule,
417
434
  const tierExhaustion = dynamicComposed
418
435
  ? upsertTierExhaustionCandidate(state.routing?.tierExhaustion, role, current, exhaustedUntil)
419
436
  : null;
420
- return { blocked, nextRuntime, ruleIndex: undefined, revision, hop, backoffSec, backoffUntil, composed: composedAssignment, tierExhaustion };
437
+ return { blocked, nextRuntime, ruleIndex: undefined, revision, hop, backoffSec, backoffUntil, composed: composedAssignment, tierExhaustion, judgeList: options.judgeListState };
421
438
  }
422
439
 
423
440
  /**
@@ -464,6 +481,10 @@ export function buildRouting(state, { role, error, current, plan, schedule, usag
464
481
  history: [...(state.routing?.history ?? []), { ...shared, runtime: current, status, errorCode }].slice(-MAX_ROUTING_HISTORY),
465
482
  currentOverride: override,
466
483
  ...(plan.tierExhaustion ? { tierExhaustion: plan.tierExhaustion } : {}),
484
+ // Unlike tierExhaustion, an untouched judgeList is not deleted above: a
485
+ // worker exhaustion round in the same call never resolved one, and the
486
+ // node's own list evidence must survive it unchanged.
487
+ ...(plan.judgeList !== undefined ? { judgeList: plan.judgeList } : {}),
467
488
  },
468
489
  override,
469
490
  errorCode,
@@ -37,7 +37,8 @@ import { mkdirSync, statSync } from "node:fs";
37
37
  import { READ_BYTE_LIMIT, READ_LINE_LIMIT, harnessCapabilities, normalizeProviderResult, writesWorkspace } from "../harnesses/index.mjs";
38
38
  import { writeJsonAtomic } from "../run/store.mjs";
39
39
  import { judgeReaskInstruction, reviewMode } from "../contract/review-modes.mjs";
40
- import { routeRuntimeForState, runtimeSnapshot } from "./failover.mjs";
40
+ import { isSameProviderReviewPair } from "../contract/judge-independence.mjs";
41
+ import { previousAttemptRuntimeId, routeRuntimeForState, runtimeSnapshot } from "./failover.mjs";
41
42
  import { fingerprintRuntime, forceFreshSession, phaseInvocationPlan } from "./phase-session.mjs";
42
43
 
43
44
  /** @typedef {import("./phase-session.mjs").SessionPolicy} SessionPolicy */
@@ -495,6 +496,22 @@ export async function startJudge(contract, node, state, runDir, running, workerR
495
496
  /** @type {import("../contract/index.mjs").VerificationState|null} */ (state.verification),
496
497
  );
497
498
  state.review = reviewMode(node.gate);
499
+ // The judge candidate this attempt would actually use, and the worker
500
+ // runtime read off the durable invocation record rather than
501
+ // `state.runtime`: on the first judge dispatch of an attempt `state.runtime`
502
+ // still holds the worker, but `startJudge` overwrites it with the judge's
503
+ // own runtime below, and a bounded re-ask (review.mjs) or a judge-failure
504
+ // retry (settle-judge.mjs) then calls `startJudge` again with the judge
505
+ // already in `state.runtime` -- comparing against that would compare the
506
+ // judge to itself and always mark the node. The invocation record survives
507
+ // every such re-dispatch, so the mark reflects the pairing that ran,
508
+ // fallback included -- not `node.sameProviderReview`, which only says the
509
+ // pairing was admitted under same-vendor mode at contract validation,
510
+ // primary or fallback alike.
511
+ const judgeCandidate = routeRuntimeForState(contract, node, state, "judge");
512
+ const workerRuntimeId = previousAttemptRuntimeId(state, "worker");
513
+ const workerRuntime = workerRuntimeId ? contract.runtimes[workerRuntimeId] : state.runtime;
514
+ state.sameProviderReview = isSameProviderReviewPair(contract, workerRuntime, judgeCandidate);
498
515
  if (verdict.verdict === "fail") return { kind: "rejected", verdict };
499
516
  // `skipWhen` is checked before the ordinary judgment rule so a green-and-small
500
517
  // change settles mechanically even when a Definition of Done item carries
@@ -502,7 +519,7 @@ export async function startJudge(contract, node, state, runDir, running, workerR
502
519
  // and a gate whose review mode is `none` is skipped there exactly as before.
503
520
  if (judgeSkippedByScope(node, state)) return { kind: "settle", gate: verdict };
504
521
  if (!judgeRequired(node)) return { kind: "settle", gate: verdict };
505
- const runtime = routeRuntimeForState(contract, node, state, "judge");
522
+ const runtime = judgeCandidate;
506
523
  const paths = logPaths(runDir, node.id, "judge", state.attempt);
507
524
  state.phase = "judge";
508
525
  state.runtime = runtime;