faberun 0.25.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/package.json +1 -1
  2. package/skills/faberun/references/contract.md +34 -28
  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/plan.mjs +75 -3
  8. package/src/cli/setup.mjs +22 -5
  9. package/src/cli.mjs +35 -1
  10. package/src/contract/definition-of-done.mjs +48 -8
  11. package/src/contract/human-step.mjs +54 -0
  12. package/src/contract/index.mjs +25 -38
  13. package/src/contract/judge-independence.mjs +170 -0
  14. package/src/contract/judges.mjs +27 -0
  15. package/src/contract/provider.mjs +103 -0
  16. package/src/contract/runtime.mjs +19 -2
  17. package/src/contract/snapshot.mjs +31 -1
  18. package/src/contract/task-packet.mjs +4 -1
  19. package/src/contract/worker-result.mjs +35 -4
  20. package/src/engine/assignment.mjs +41 -5
  21. package/src/engine/backoff.mjs +30 -9
  22. package/src/engine/dispatch.mjs +19 -2
  23. package/src/engine/failover.mjs +10 -2
  24. package/src/engine/judge-gate.mjs +24 -4
  25. package/src/engine/judge-list.mjs +206 -0
  26. package/src/engine/lifecycle.mjs +29 -24
  27. package/src/engine/resume.mjs +49 -12
  28. package/src/engine/runtime-discovery.mjs +72 -11
  29. package/src/engine/scheduler.mjs +27 -6
  30. package/src/harnesses/catalogue.mjs +9 -2
  31. package/src/harnesses/index.mjs +2 -2
  32. package/src/harnesses/model-tiers.mjs +39 -0
  33. package/src/harnesses/protocol.mjs +4 -1
  34. package/src/host/config.mjs +7 -2
  35. package/src/plan/contest.mjs +78 -0
  36. package/src/plan/freeze.mjs +118 -7
  37. package/src/plan/human-step.mjs +38 -0
  38. package/src/plan/pipeline.mjs +224 -313
  39. package/src/plan/preflight.mjs +5 -3
  40. package/src/plan/proof-check.mjs +161 -0
  41. package/src/plan/proof-run.mjs +6 -2
  42. package/src/plan/proof-scope.mjs +175 -0
  43. package/src/plan/resolve.mjs +182 -0
  44. package/src/plan/reviewer.mjs +82 -0
  45. package/src/plan/rounds.mjs +446 -0
  46. package/src/plan/routing.mjs +11 -4
  47. package/src/plan/template.mjs +36 -14
  48. package/src/repo/signal-block.mjs +10 -3
  49. package/src/repo/source-identity.mjs +45 -5
  50. package/src/repo/worktree-prune.mjs +72 -0
  51. package/src/report/campaign-brief-estimate.mjs +1 -1
  52. package/src/report/campaign-brief.mjs +4 -0
  53. package/src/report/metrics-report.mjs +34 -1
  54. 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`);
@@ -426,8 +426,11 @@ function pathInside(path, root) {
426
426
  * @returns {string}
427
427
  */
428
428
  function renderDiscoveryPrompt(packet, nodeId) {
429
+ // The closed variant says what settlement accepts (engine/lifecycle.mjs):
430
+ // only a discovery packet with no read files owes an execution packet in
431
+ // artifacts[0]; one closed to its read files delivers through `output`.
429
432
  const closedContext = packet.readFiles.length
430
- ? "This discovery context is read-only and closed to the listed read files. Inspect only those files and do not perform repository-wide exploration. Return the worker-result JSON object below; put exactly one execution task packet JSON string in artifacts[0]."
433
+ ? "This discovery context is read-only and closed to the listed read files. Inspect only those files and do not perform repository-wide exploration. Return the worker-result JSON object below; deliver your result in `output` and send `artifacts` as []."
431
434
  : "This discovery context is read-only and is the one exception to closed inspection: no read files were pre-supplied, so you may inspect the repository read-only only as needed to produce the packet. Do not edit the repository. Return the worker-result JSON object below; put exactly one execution task packet JSON string in artifacts[0].";
432
435
  const lines = [
433
436
  `# Node ${nodeId} (discovery)`,
@@ -25,6 +25,23 @@ const RESULT_LIMITS = Object.freeze({
25
25
  */
26
26
  export const DERIVED_WORKER_RESULT_FIELDS = Object.freeze(["changedFiles"]);
27
27
 
28
+ /**
29
+ * A worker result that is well-formed JSON but breaks one of the byte
30
+ * ceilings above. Typed, not a plain TypeError, so the repair prompt can name
31
+ * the ceiling that was broken: observed 2026-09-25, a planning draft that
32
+ * copied its 38 KiB plan into `artifacts[0]` was told twice to drop markdown
33
+ * fences it never wrote, and repeated the copy.
34
+ */
35
+ export class WorkerResultSizeError extends TypeError {
36
+ /** @param {string} field @param {number} limit */
37
+ constructor(field, limit) {
38
+ super(`${field} exceeds ${limit} bytes`);
39
+ this.name = "WorkerResultSizeError";
40
+ this.field = field;
41
+ this.limit = limit;
42
+ }
43
+ }
44
+
28
45
  /** @typedef {"done"|"blocked_context"} WorkerResultStatus */
29
46
 
30
47
  /**
@@ -37,6 +54,20 @@ export const DERIVED_WORKER_RESULT_FIELDS = Object.freeze(["changedFiles"]);
37
54
  * @typedef {{status: WorkerResultStatus, summary: string, verification: string[], artifacts: string[], missingContext: string[], output?: Record<string, unknown>}} WorkerResult
38
55
  */
39
56
 
57
+ /**
58
+ * The repair instruction for an invalid worker result: a broken byte ceiling
59
+ * is named, since re-emitting the same object without fences cannot fix it.
60
+ *
61
+ * @param {unknown} cause
62
+ * @returns {string}
63
+ */
64
+ export function invalidResultRepair(cause) {
65
+ if (cause instanceof WorkerResultSizeError) {
66
+ return `the result breaks a size ceiling: ${cause.field} exceeds ${cause.limit} bytes. Keep every field within its ceiling; a node that delivers through \`output\` sends \`artifacts\` as [] and never copies \`output\` into \`artifacts\`.`;
67
+ }
68
+ return "the entire final message must be exactly the required JSON object: no markdown fences, no prose before or after it. Return it as the only content of the final message.";
69
+ }
70
+
40
71
  /**
41
72
  * @param {string} value
42
73
  * @returns {WorkerResult}
@@ -45,7 +76,7 @@ export function parseWorkerResult(value) {
45
76
  if (typeof value !== "string") throw new TypeError("worker result must be JSON text");
46
77
  const maxRawBytes = RESULT_LIMITS.bytes + RESULT_LIMITS.outputBytes;
47
78
  if (Buffer.byteLength(value, "utf8") > maxRawBytes) {
48
- throw new TypeError(`worker result exceeds ${maxRawBytes} bytes`);
79
+ throw new WorkerResultSizeError("worker result", maxRawBytes);
49
80
  }
50
81
  let parsed;
51
82
  try {
@@ -100,7 +131,7 @@ export function validateWorkerResult(value) {
100
131
  if (Object.hasOwn(record, "output") && record.output !== undefined) {
101
132
  assertObject(record.output, "worker result.output");
102
133
  if (Buffer.byteLength(JSON.stringify(record.output), "utf8") > RESULT_LIMITS.outputBytes) {
103
- throw new TypeError(`worker result.output exceeds ${RESULT_LIMITS.outputBytes} bytes`);
134
+ throw new WorkerResultSizeError("worker result.output", RESULT_LIMITS.outputBytes);
104
135
  }
105
136
  output = /** @type {Record<string, unknown>} */ (record.output);
106
137
  }
@@ -115,7 +146,7 @@ export function validateWorkerResult(value) {
115
146
  // it is a discovery node's deliverable, not incidental prose competing with
116
147
  // summary/verification for the same 32 KiB budget.
117
148
  if (Buffer.byteLength(JSON.stringify(envelope), "utf8") > RESULT_LIMITS.bytes) {
118
- throw new TypeError(`worker result exceeds ${RESULT_LIMITS.bytes} bytes`);
149
+ throw new WorkerResultSizeError("worker result", RESULT_LIMITS.bytes);
119
150
  }
120
151
  return output === undefined ? envelope : { ...envelope, output };
121
152
  }
@@ -165,6 +196,6 @@ function requireList(value, label, maxItems, itemBytes) {
165
196
  if (!Array.isArray(value) || value.length > maxItems) throw new TypeError(`${label} must be an array with at most ${maxItems} items`);
166
197
  for (const [index, item] of value.entries()) {
167
198
  if (typeof item !== "string") throw new TypeError(`${label}[${index}] must be a string`);
168
- if (Buffer.byteLength(item, "utf8") > itemBytes) throw new TypeError(`${label}[${index}] exceeds ${itemBytes} bytes`);
199
+ if (Buffer.byteLength(item, "utf8") > itemBytes) throw new WorkerResultSizeError(`${label}[${index}]`, itemBytes);
169
200
  }
170
201
  }
@@ -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
  }