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
package/src/cli/plan.mjs CHANGED
@@ -15,8 +15,11 @@ import { validateFinalVerification, validateSharedVerification } from "../contra
15
15
  import { colorLevel, statusToken } from "./brand.mjs";
16
16
  import { delay } from "../util.mjs";
17
17
  import { runPlanningPipeline } from "../plan/pipeline.mjs";
18
+ import { parseAnswerFlags, resolvePlanningPipeline } from "../plan/resolve.mjs";
19
+ import { resolveReviewerList } from "../plan/reviewer.mjs";
18
20
  import { campaignTree, runDirectory } from "../run/paths.mjs";
19
21
  import { readCampaign } from "../campaign/record.mjs";
22
+ import { readUserConfig } from "../host/config.mjs";
20
23
 
21
24
  /** How often a foreground `plan` polls a launched stage's run directory. */
22
25
  const DEFAULT_POLL_MS = 1_000;
@@ -56,6 +59,21 @@ export function parseRuntimeDefaults(value) {
56
59
  return result;
57
60
  }
58
61
 
62
+ /**
63
+ * `--reviewers <a,b>`: the planner's own ordered reviewer list (R19),
64
+ * separate from `--runtime-defaults judge=` and from the frozen contract's
65
+ * R18 judge list even when an operator names the same runtime id in both.
66
+ *
67
+ * @param {string|undefined} value
68
+ * @returns {string[]|undefined}
69
+ */
70
+ export function parseReviewerList(value) {
71
+ if (value === undefined) return undefined;
72
+ const ids = value.split(",").map((id) => id.trim()).filter(Boolean);
73
+ if (ids.length === 0) throw new Error("--reviewers needs at least one runtime id");
74
+ return ids;
75
+ }
76
+
59
77
  /**
60
78
  * @param {unknown} value
61
79
  * @returns {number}
@@ -126,10 +144,11 @@ export function loadVerificationSuites(path) {
126
144
 
127
145
  /**
128
146
  * @param {string} target
129
- * @param {{campaign?: string, phase?: string, "review-rounds"?: string, "approve-below"?: string, "runtime-defaults"?: string, runtimes?: string, verification?: string, package?: string, "targeted-fix"?: boolean, detach?: boolean, json?: boolean}} values
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
130
148
  * @returns {Promise<void>}
131
149
  */
132
150
  export async function planCli(target, values) {
151
+ if (typeof values.resolve === "string" && values.resolve) return await resolvePlanCli(values);
133
152
  const specPath = resolve(target);
134
153
  if (typeof values.campaign !== "string" || !values.campaign) throw new Error("plan requires --campaign <id>");
135
154
  const campaignId = values.campaign;
@@ -137,6 +156,9 @@ export async function planCli(target, values) {
137
156
  const reviewRounds = reviewRoundsOf(values["review-rounds"]);
138
157
  const approveBelow = /** @type {"standard"|"high"|"none"|undefined} */ (values["approve-below"]);
139
158
  const runtimeDefaults = parseRuntimeDefaults(values["runtime-defaults"]);
159
+ // R19: the planner's own reviewer list, never `runtimeDefaults.judge` --
160
+ // `--reviewers` wins over the machine's own `config.reviewers` default.
161
+ const reviewers = resolveReviewerList({ reviewers: parseReviewerList(values.reviewers) }, readUserConfig(process.env));
140
162
  const runtimes = typeof values.runtimes === "string" && values.runtimes
141
163
  ? loadRuntimesCatalogue(values.runtimes)
142
164
  : DISCOVERY_RUNTIME_DEFINITIONS;
@@ -149,6 +171,7 @@ export async function planCli(target, values) {
149
171
  const argv = ["plan", specPath, "--campaign", campaignId, "--phase", phase, "--review-rounds", String(reviewRounds)];
150
172
  if (approveBelow !== undefined) argv.push("--approve-below", approveBelow);
151
173
  if (values["runtime-defaults"] !== undefined) argv.push("--runtime-defaults", values["runtime-defaults"]);
174
+ if (values.reviewers !== undefined) argv.push("--reviewers", values.reviewers);
152
175
  if (typeof values.runtimes === "string" && values.runtimes) argv.push("--runtimes", resolve(values.runtimes));
153
176
  if (typeof values.verification === "string" && values.verification) argv.push("--verification", resolve(values.verification));
154
177
  if (packageMode !== "implementation") argv.push("--package", packageMode);
@@ -194,6 +217,7 @@ export async function planCli(target, values) {
194
217
  reviewRounds,
195
218
  approveBelow,
196
219
  runtimeDefaults,
220
+ reviewers,
197
221
  runtimes,
198
222
  verification,
199
223
  targetedFix: values["targeted-fix"] === true,
@@ -222,14 +246,62 @@ export async function planCli(target, values) {
222
246
  return;
223
247
  }
224
248
  if (result.status === "contested") {
225
- process.stdout.write(`[plan] ${campaignId} phase ${phase} contested after ${result.round} round(s) · ${result.findings.length} finding(s) · ${result.planPath}\n`);
226
- process.exitCode = 1;
249
+ renderContested(campaignId, phase, result);
227
250
  return;
228
251
  }
229
252
  for (const warning of result.warnings) process.stdout.write(`${statusToken("warn", colorLevel(process.env, process.stdout.isTTY))} ${warning}\n`);
230
253
  process.stdout.write(`[plan] ${campaignId} phase ${phase} frozen · approved ${result.approved} · ${result.contractPath}\n`);
231
254
  }
232
255
 
256
+ /**
257
+ * `faberun plan --resolve <plan-dir> --answer <finding-id>=accept|reject:<reason>`
258
+ * (R9): apply the operator's decisions to a contested plan's open critical
259
+ * findings and resume straight to sizing/routing/freeze, without redrafting
260
+ * or asking a provider anything. `--campaign`/`--phase`/the spec positional
261
+ * are not needed here — everything this needs is the resume context the
262
+ * contest already wrote to `plan.json` (`src/plan/contest.mjs`).
263
+ *
264
+ * @param {{resolve?: string, answer?: string[], json?: boolean}} values
265
+ * @returns {Promise<void>}
266
+ */
267
+ async function resolvePlanCli(values) {
268
+ const plansDir = resolve(/** @type {string} */ (values.resolve));
269
+ const rawAnswers = values.answer ?? [];
270
+ if (rawAnswers.length === 0) throw new Error("--resolve requires at least one --answer <finding-id>=accept or --answer <finding-id>=reject:<reason>");
271
+ const answers = parseAnswerFlags(rawAnswers);
272
+ const result = await resolvePlanningPipeline({ plansDir, cwd: process.cwd(), answers });
273
+ if (values.json === true) {
274
+ process.stdout.write(`${JSON.stringify(result)}\n`);
275
+ return;
276
+ }
277
+ if (result.status === "contested") {
278
+ renderContested(undefined, undefined, result);
279
+ return;
280
+ }
281
+ for (const warning of result.warnings) process.stdout.write(`${statusToken("warn", colorLevel(process.env, process.stdout.isTTY))} ${warning}\n`);
282
+ process.stdout.write(`[plan] resolved · frozen · approved ${result.approved} · ${result.contractPath}\n`);
283
+ }
284
+
285
+ /**
286
+ * A contested result's console rendering: the summary line, then every
287
+ * critical finding with its id, the node or requirement it concerns, and
288
+ * what would resolve it (R9's own words for what the output must list), and
289
+ * the exact command to answer it.
290
+ *
291
+ * @param {string|undefined} campaignId
292
+ * @param {string|undefined} phase
293
+ * @param {import("../plan/contest.mjs").ContestedPipelineResult} result
294
+ * @returns {void}
295
+ */
296
+ function renderContested(campaignId, phase, result) {
297
+ const criticalFindings = result.findings.filter((finding) => finding.severity === "critical");
298
+ const label = campaignId ? `${campaignId} phase ${phase} ` : "";
299
+ process.stdout.write(`[plan] ${label}contested after ${result.round} round(s) · ${criticalFindings.length} critical finding(s) · ${result.planPath}\n`);
300
+ for (const finding of criticalFindings) process.stdout.write(` - ${finding.id} (${finding.nodeId}): ${finding.text}\n`);
301
+ process.stdout.write(`[plan] resolve with: faberun plan --resolve ${result.plansDir} --answer <finding-id>=accept or --answer <finding-id>=reject:<reason>\n`);
302
+ process.exitCode = 1;
303
+ }
304
+
233
305
  /**
234
306
  * Where a detached planning run records why it never came up. It sits beside
235
307
  * the phase's durable plan artifacts rather than in the disposable scratch
package/src/cli/setup.mjs CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  discoverRuntimes,
27
27
  strongest,
28
28
  } from "../engine/runtime-discovery.mjs";
29
+ import { effectiveProvider } from "../contract/provider.mjs";
29
30
  import { boundedGitSync } from "../repo/worktree.mjs";
30
31
  import { colorLevel, renderBanner, statusToken } from "./brand.mjs";
31
32
  import { packageVersion } from "../host/package.mjs";
@@ -46,6 +47,8 @@ import { discoverSkillTargets, registerSkills } from "./skills.mjs";
46
47
  * @property {string} [harnesses]
47
48
  * @property {string} [worker]
48
49
  * @property {string} [judge]
50
+ * @property {string} [judges] comma-separated ordered runtime ids for the machine's R18 judge-list default; unset keeps whatever the existing config already declared
51
+ * @property {string} [reviewers] comma-separated ordered runtime ids for the machine's R19 reviewer-list default (`faberun plan --reviewers`); unset keeps whatever the existing config already declared
49
52
  * @property {boolean} [skill] whether to register the faberun skill (default true)
50
53
  * @property {boolean} [json]
51
54
  * @property {NodeJS.ProcessEnv} [env]
@@ -166,11 +169,15 @@ export async function setupCommand(options = {}) {
166
169
  }
167
170
  }
168
171
 
172
+ const selectedJudges = splitHarnesses(options.judges ?? "").length ? splitHarnesses(options.judges ?? "") : kept.judges;
173
+ const selectedReviewers = splitHarnesses(options.reviewers ?? "").length ? splitHarnesses(options.reviewers ?? "") : kept.reviewers;
169
174
  const config = {
170
175
  schemaVersion: /** @type {1} */ (1),
171
176
  harnesses: selectedHarnesses,
172
177
  worker: selectedWorker,
173
178
  judge: selectedJudge,
179
+ ...(selectedJudges.length ? { judges: selectedJudges } : {}),
180
+ ...(selectedReviewers.length ? { reviewers: selectedReviewers } : {}),
174
181
  updatedAt: new Date().toISOString(),
175
182
  };
176
183
  writeUserConfig(env, config);
@@ -270,10 +277,10 @@ function missingEnvKeys(runtime, env) {
270
277
  *
271
278
  * @param {UserConfig|null} existing
272
279
  * @param {Record<string, RuntimeAvailability>} availability
273
- * @returns {{harnesses: string[], worker: string, judge: string}}
280
+ * @returns {{harnesses: string[], worker: string, judge: string, judges: string[], reviewers: string[]}}
274
281
  */
275
282
  export function mergeExistingConfig(existing, availability) {
276
- if (!existing) return { harnesses: [], worker: "", judge: "" };
283
+ if (!existing) return { harnesses: [], worker: "", judge: "", judges: [], reviewers: [] };
277
284
  const availableHarnesses = new Set(
278
285
  Object.entries(DISCOVERY_RUNTIME_DEFINITIONS)
279
286
  .filter(([id]) => availability[id]?.available === true)
@@ -286,6 +293,12 @@ export function mergeExistingConfig(existing, availability) {
286
293
  harnesses: existing.harnesses.filter((harness) => availableHarnesses.has(harness)),
287
294
  worker: existing.worker && candidateIds.has(existing.worker) ? existing.worker : "",
288
295
  judge: existing.judge && candidateIds.has(existing.judge) ? existing.judge : "",
296
+ // The judge and reviewer lists are static, operator-declared orderings
297
+ // (D9, D11): unlike the single worker/judge default, an entry discovery
298
+ // cannot currently reach is not dropped, since each list's own selection
299
+ // already skips a refused entry at every read.
300
+ judges: Array.isArray(existing.judges) ? existing.judges : [],
301
+ reviewers: Array.isArray(existing.reviewers) ? existing.reviewers : [],
289
302
  };
290
303
  }
291
304
 
@@ -314,7 +327,9 @@ function keptJudgeDefault(kept, workerId, candidates) {
314
327
  * @returns {string}
315
328
  */
316
329
  function defaultJudge(workerId, candidates) {
317
- const vendor = DISCOVERY_RUNTIME_DEFINITIONS[workerId]?.vendor;
330
+ const worker = DISCOVERY_RUNTIME_DEFINITIONS[workerId];
331
+ if (worker === undefined) return "";
332
+ const vendor = effectiveProvider(worker);
318
333
  if (vendor === undefined) return "";
319
334
  return strongest(candidates, vendor)?.id ?? "";
320
335
  }
@@ -328,8 +343,10 @@ function defaultJudge(workerId, candidates) {
328
343
  * @returns {boolean}
329
344
  */
330
345
  function crossVendor(judgeId, workerId) {
331
- const judgeVendor = DISCOVERY_RUNTIME_DEFINITIONS[judgeId]?.vendor;
332
- const workerVendor = DISCOVERY_RUNTIME_DEFINITIONS[workerId]?.vendor;
346
+ const judge = DISCOVERY_RUNTIME_DEFINITIONS[judgeId];
347
+ const worker = DISCOVERY_RUNTIME_DEFINITIONS[workerId];
348
+ const judgeVendor = judge && effectiveProvider(judge);
349
+ const workerVendor = worker && effectiveProvider(worker);
333
350
  return Boolean(judgeVendor && workerVendor && judgeVendor !== workerVendor);
334
351
  }
335
352
 
package/src/cli.mjs CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  writeTextAtomic,
24
24
  } from "./run/store.mjs";
25
25
  import { runDirectory, runsRoot } from "./run/paths.mjs";
26
+ import { pruneIntegratedRunWorktrees } from "./repo/worktree-prune.mjs";
26
27
  import { migrateRunState } from "./run/migrate.mjs";
27
28
  import {
28
29
  acquire as acquireLock,
@@ -112,6 +113,7 @@ export const COMMAND_OPTIONS = {
112
113
  update: { check: { type: "boolean" }, json: { type: "boolean" } },
113
114
  project: { from: { type: "string" } },
114
115
  migrate: { cwd: { type: "string" } },
116
+ prune: { cwd: { type: "string" }, parked: { type: "boolean" }, json: { type: "boolean" } },
115
117
  setup: { yes: { type: "boolean" }, harnesses: { type: "string" }, worker: { type: "string" }, judge: { type: "string" }, "no-skill": { type: "boolean" }, json: { type: "boolean" } },
116
118
  init: { cwd: { type: "string" }, yes: { type: "boolean" }, "no-skill": { type: "boolean" }, agentkit: { type: "boolean" }, greenfield: { type: "boolean" }, stable: { type: "boolean" }, json: { type: "boolean" } },
117
119
  metrics: METRICS_OPTIONS,
@@ -121,11 +123,14 @@ export const COMMAND_OPTIONS = {
121
123
  "review-rounds": { type: "string" },
122
124
  "approve-below": { type: "string" },
123
125
  "runtime-defaults": { type: "string" },
126
+ reviewers: { type: "string" },
124
127
  runtimes: { type: "string" },
125
128
  verification: { type: "string" },
126
129
  package: { type: "string" },
127
130
  "targeted-fix": { type: "boolean" },
128
131
  detach: { type: "boolean" },
132
+ resolve: { type: "string" },
133
+ answer: { type: "string", multiple: true },
129
134
  json: { type: "boolean" },
130
135
  },
131
136
  };
@@ -156,7 +161,13 @@ function parseCli(argv, quiet = false) {
156
161
  if (command === "setup" && parsed.positionals.length !== 0) return null;
157
162
  if (command === "init" && parsed.positionals.length !== 0) return null;
158
163
  if (command === "migrate" && parsed.positionals.length !== 0) return null;
159
- if (command !== "doctor" && command !== "models" && command !== "bulk-read" && command !== "next" && command !== "update" && command !== "setup" && command !== "init" && command !== "migrate" && parsed.positionals.length !== 1) return null;
164
+ if (command === "prune" && parsed.positionals.length !== 0) return null;
165
+ // `plan --resolve <plan-dir>` (R9) takes no spec positional: everything a
166
+ // fresh `plan` reads from it and its flags is already on the contested
167
+ // plan.json the run it resumes wrote.
168
+ if (command === "plan" && typeof parsed.values.resolve === "string" && parsed.values.resolve !== "") {
169
+ if (parsed.positionals.length !== 0) return null;
170
+ } else if (command !== "doctor" && command !== "models" && command !== "bulk-read" && command !== "next" && command !== "update" && command !== "setup" && command !== "init" && command !== "migrate" && command !== "prune" && parsed.positionals.length !== 1) return null;
160
171
  return {
161
172
  command,
162
173
  target: parsed.positionals[0],
@@ -310,6 +321,13 @@ async function main(argv) {
310
321
  process.stdout.write(`[migrate] ${result.legacy} -> ${result.target} · ${runs}, ${campaigns}\n`);
311
322
  return;
312
323
  }
324
+ if (command === "prune") {
325
+ const cwd = resolve(typeof values.cwd === "string" ? values.cwd : ".");
326
+ const pruned = pruneIntegratedRunWorktrees(cwd, runsRoot(cwd), { parked: values.parked === true });
327
+ if (values.json === true) process.stdout.write(`${JSON.stringify({ pruned }, null, 2)}\n`);
328
+ else process.stdout.write(`${prunedLine(pruned)}\n`);
329
+ return;
330
+ }
313
331
  if (!target) { usage(); return; }
314
332
  if (command === "project") {
315
333
  const record = reassociateProject(faberunHome(process.env), target, {
@@ -334,6 +352,12 @@ async function main(argv) {
334
352
  const contractFromCwd = relative(contract.cwd, absolute);
335
353
  const contractIgnore = contractFromCwd && !contractFromCwd.startsWith("..") && !isAbsolute(contractFromCwd) ? [contractFromCwd] : [];
336
354
  assertLaunchBaseClean(contract.cwd, baseRef, { ignorePaths: contractIgnore });
355
+ // Every launch releases the worktrees of runs that have already landed,
356
+ // so a long campaign does not carry them until `campaign close`.
357
+ if (!hasDetachedBootstrapNonce()) {
358
+ const pruned = pruneIntegratedRunWorktrees(contract.cwd, runsRoot(contract.cwd));
359
+ if (pruned.length) process.stdout.write(`${prunedLine(pruned)}\n`);
360
+ }
337
361
  if (values.detach === true) {
338
362
  if (existsSync(runDir)) throw new Error(`run already exists: ${runDir}`);
339
363
  for (const warning of [...contract.warnings, ...reusedDoneWarnings(contract)]) process.stdout.write(`${advisoryToken()} ${warning}\n`);
@@ -487,6 +511,16 @@ function advisoryToken() {
487
511
  return statusToken("warn", colorLevel(process.env, process.stdout.isTTY));
488
512
  }
489
513
 
514
+ /**
515
+ * @param {import("./repo/worktree-prune.mjs").PrunedRun[]} pruned
516
+ * @returns {string}
517
+ */
518
+ function prunedLine(pruned) {
519
+ if (!pruned.length) return "[prune] no finished, integrated run holds a worktree";
520
+ const removed = pruned.reduce((sum, run) => sum + run.removed, 0);
521
+ return `[prune] released ${removed} worktree${removed === 1 ? "" : "s"} of ${pruned.length} integrated run${pruned.length === 1 ? "" : "s"} · each kept under refs/faberun-archive/`;
522
+ }
523
+
490
524
  /**
491
525
  * The foreground launch command is the only moment an operator is present, so
492
526
  * it is where the no-transport warning belongs. A detached controller's stdio
@@ -14,7 +14,7 @@ import { rejectUnknown, requireId, requireString } from "./assert.mjs";
14
14
  /**
15
15
  * @param {unknown} value
16
16
  * @param {string} label
17
- * @param {{verificationCount?: number, recordableCount?: number}} [options]
17
+ * @param {{verificationCount?: number, recordableCount?: number, commands?: {argv: string[]}[], nodeId?: string}} [options]
18
18
  * @returns {DefinitionOfDoneItem[]}
19
19
  */
20
20
  export function validateDefinitionOfDone(value, label, options = {}) {
@@ -80,7 +80,7 @@ export function judgmentReasonWarnings(items, index) {
80
80
  /**
81
81
  * @param {unknown} value
82
82
  * @param {string} label
83
- * @param {{verificationCount?: number, recordableCount?: number}} options
83
+ * @param {{verificationCount?: number, recordableCount?: number, commands?: {argv: string[]}[], nodeId?: string}} options
84
84
  * @returns {DefinitionOfDoneProof}
85
85
  */
86
86
  function validateProof(value, label, options) {
@@ -100,20 +100,60 @@ function validateProof(value, label, options) {
100
100
 
101
101
  /**
102
102
  * A `verification` proof reuses a recorded controller verification result by
103
- * position, never by comparing command strings: a joined argv loses argument
104
- * boundaries and shell semantics, so the reference is the only sound name.
103
+ * position. It is authored as either the zero-based index or, when the
104
+ * node's verification commands are known to the caller (`options.commands`),
105
+ * the exact text of one of them -- the shape a planning model actually wrote
106
+ * (measured in `choose-the-judges` R5). A text ref is resolved to its index
107
+ * and normalized the same way a numeric ref is, so nothing downstream ever
108
+ * compares command strings again.
105
109
  *
106
110
  * @param {unknown} value
107
111
  * @param {string} label
108
- * @param {{verificationCount?: number, recordableCount?: number}} options
112
+ * @param {{verificationCount?: number, recordableCount?: number, commands?: {argv: string[]}[], nodeId?: string}} options
109
113
  * @returns {string}
110
114
  */
111
115
  function validateVerificationRef(value, label, options) {
116
+ if (typeof value === "string" && options.commands !== undefined && !isIndexLike(value)) {
117
+ return resolveTextRef(value, label, options);
118
+ }
112
119
  const text = typeof value === "number" ? String(value) : value;
113
- if (typeof text !== "string" || !/^\d+$/u.test(text.trim())) {
114
- throw new TypeError(`${label}.ref must be the zero-based index of a verification command`);
120
+ if (!isIndexLike(text)) {
121
+ throw new TypeError(`${label}.ref must be the zero-based index of a verification command, or the exact text of one`);
122
+ }
123
+ return checkedIndex(Number.parseInt(/** @type {string} */ (text).trim(), 10), label, options);
124
+ }
125
+
126
+ /** @param {unknown} text @returns {boolean} */
127
+ function isIndexLike(text) {
128
+ return typeof text === "string" && /^\d+$/u.test(text.trim());
129
+ }
130
+
131
+ /**
132
+ * @param {string} value
133
+ * @param {string} label
134
+ * @param {{verificationCount?: number, recordableCount?: number, commands?: {argv: string[]}[], nodeId?: string}} options
135
+ * @returns {string}
136
+ */
137
+ function resolveTextRef(value, label, options) {
138
+ const commands = /** @type {{argv: string[]}[]} */ (options.commands);
139
+ const index = commands.findIndex((command) => command.argv.join(" ") === value);
140
+ if (index === -1) {
141
+ const node = options.nodeId ?? "this node";
142
+ const existing = commands.length === 0
143
+ ? "(no verification commands)"
144
+ : commands.map((command, position) => `${position}: ${command.argv.join(" ")}`).join("\n");
145
+ throw new TypeError(`${label}.ref "${value}" names no verification command of ${node}; its verification commands are:\n${existing}`);
115
146
  }
116
- const index = Number.parseInt(text, 10);
147
+ return checkedIndex(index, label, options);
148
+ }
149
+
150
+ /**
151
+ * @param {number} index
152
+ * @param {string} label
153
+ * @param {{verificationCount?: number, recordableCount?: number}} options
154
+ * @returns {string}
155
+ */
156
+ function checkedIndex(index, label, options) {
117
157
  if (typeof options.verificationCount === "number" && index >= options.verificationCount) {
118
158
  throw new TypeError(`${label}.ref ${index} names no verification command: the packet declares ${options.verificationCount}`);
119
159
  }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Validation for a node's `humanStep` field (R16): the operator-only step and
3
+ * its command a requirement's `constraints` declared (`plan/human-step.mjs`
4
+ * detects it; `plan/freeze.mjs` stamps it onto the node that carries the
5
+ * requirement). Split from `contract/index.mjs`, mirroring
6
+ * `judge-independence.mjs`, because the shape that turns a node into a stop
7
+ * belongs with the field that declares it, not the general node-parsing loop.
8
+ */
9
+ import { assertObject, boundedString, rejectUnknown } from "./assert.mjs";
10
+
11
+ /** The fields a node's `humanStep` declaration carries. */
12
+ const HUMAN_STEP_FIELDS = new Set(["step", "command"]);
13
+
14
+ /** Bound on each of `step` and `command`, matching the operator-answer ceiling this stop is paired with (`engine/retry.mjs`'s `Previous attempt` section). */
15
+ const HUMAN_STEP_MAX_BYTES = 4096;
16
+
17
+ /** The error code a `humanStep` node stops with once its dependencies are done, never dispatched to a provider. */
18
+ export const HUMAN_STEP_ERROR_CODE = "human_step_pending";
19
+
20
+ /**
21
+ * @param {unknown} value
22
+ * @param {string} label
23
+ * @returns {{step: string, command: string}|undefined}
24
+ */
25
+ export function validateHumanStep(value, label) {
26
+ if (value === undefined) return undefined;
27
+ assertObject(value, label);
28
+ const record = /** @type {Record<string, unknown>} */ (value);
29
+ rejectUnknown(record, HUMAN_STEP_FIELDS, label);
30
+ boundedString(record.step, `${label}.step`, HUMAN_STEP_MAX_BYTES);
31
+ boundedString(record.command, `${label}.command`, HUMAN_STEP_MAX_BYTES);
32
+ return { step: /** @type {string} */ (record.step), command: /** @type {string} */ (record.command) };
33
+ }
34
+
35
+ /**
36
+ * The attention message naming the step and its command: `state.error.message`
37
+ * carries this verbatim, so every existing attention surface (the final
38
+ * report, the run's stdout line, a resume's attention print) names both
39
+ * without reading the contract a second time.
40
+ *
41
+ * @param {{step: string, command: string}} humanStep
42
+ * @returns {string}
43
+ */
44
+ export function humanStepAttentionMessage(humanStep) {
45
+ return `${humanStep.step} — run \`${humanStep.command}\``;
46
+ }
47
+
48
+ /**
49
+ * @param {{status?: string, error?: {code?: string}|null}} state
50
+ * @returns {boolean}
51
+ */
52
+ export function isHumanStepPending(state) {
53
+ return state.status === "blocked" && state.error?.code === HUMAN_STEP_ERROR_CODE;
54
+ }
@@ -14,6 +14,9 @@ import { stableJson } from "../util.mjs";
14
14
  import { assertObject, boundedString, nonNegativeInteger, nonNegativeNumber, positiveInteger, positiveNumber, rejectUnknown, requireId, requireString } from "./assert.mjs";
15
15
  import { validateMetadata } from "./schema-version.mjs";
16
16
  import { assertRuntimeExecutesCommands, judgeWriteWarnings, requireRuntime, validateRuntime } from "./runtime.mjs";
17
+ import { validateJudgeList } from "./judges.mjs";
18
+ import { markSameProviderReviewNodes, validateJudgeIndependence } from "./judge-independence.mjs";
19
+ import { validateHumanStep } from "./human-step.mjs";
17
20
  import { validateSourceIdentity } from "../repo/source-identity.mjs";
18
21
  import { commandCoverageWarnings, ignoreSourceWriteWarnings, mirrorCoverageWarnings, unsnapshottedWriteWarnings } from "../repo/declared-paths.mjs";
19
22
  import { crossNodeScopeFindings, scopeClosureFindings } from "../repo/scope-closure.mjs";
@@ -33,13 +36,14 @@ const WRITE_FILE_LINE_WARN_MARGIN = 100;
33
36
  const CONTRACT_FIELDS = new Set([
34
37
  "schemaVersion", "contractVersion", "id", "campaignId", "goal", "cwd", "sourceIdentity",
35
38
  "maxParallel", "pollIntervalMs", "stallTimeoutSec", "timeoutSec", "maxTurns", "phaseSessionReuse",
36
- "runtimeDefaults", "runtimes", "nodes", "warnings", "finalVerification", "sharedVerification", "nodeAdvisory",
39
+ "runtimeDefaults", "runtimes", "nodes", "warnings", "finalVerification", "sharedVerification", "nodeAdvisory", "judges",
40
+ "judgeIndependence",
37
41
  ]);
38
42
  const DEFAULTS_FIELDS = new Set(["worker", "judge"]);
39
43
  const NODE_FIELDS = new Set([
40
44
  "id", "type", "phase", "requirementIds", "runtime", "dependsOn", "taskPacket", "taskPacketFile", "prompt", "promptFile",
41
45
  "definitionOfDone", "gate", "timeoutSec", "maxTurns",
42
- "requiredCapabilities", "packetHash", "sourceIdentity", "replayPolicy",
46
+ "requiredCapabilities", "packetHash", "sourceIdentity", "replayPolicy", "sameProviderReview", "humanStep",
43
47
  ]);
44
48
  const REPLAY_POLICIES = new Set(["safe", "reconcile", "never"]);
45
49
  /**
@@ -68,9 +72,9 @@ const GATE_REVIEWS = new Set(["none", "advisory", "blocking"]);
68
72
 
69
73
  /** @typedef {{enabled: boolean, review?: ("none"|"advisory"|"blocking"), runtime?: string, failOn?: ("minor"|"major"|"critical")[], maxRevisions?: number, requiredCapabilities?: CapabilityRequirements, skipWhen?: {verificationGreen: true, maxChangedPaths: number}}} ValidatedGate */
70
74
 
71
- /** @typedef {{id: string, type: string, phase: string, requirementIds?: string[], runtime?: string, dependsOn: string[], taskPacket: TaskPacket, taskPacketFile?: string, prompt: string, definitionOfDone: import("./definition-of-done.mjs").DefinitionOfDoneItem[], gate: ValidatedGate, timeoutSec?: number, maxTurns?: number, requiredCapabilities: CapabilityRequirements, packetHash: string, sourceIdentity: SourceIdentity, replayPolicy: "safe"|"reconcile"|"never"}} ValidatedNode */
75
+ /** @typedef {{id: string, type: string, phase: string, requirementIds?: string[], runtime?: string, dependsOn: string[], taskPacket: TaskPacket, taskPacketFile?: string, prompt: string, definitionOfDone: import("./definition-of-done.mjs").DefinitionOfDoneItem[], gate: ValidatedGate, timeoutSec?: number, maxTurns?: number, requiredCapabilities: CapabilityRequirements, packetHash: string, sourceIdentity: SourceIdentity, replayPolicy: "safe"|"reconcile"|"never", sameProviderReview: boolean, humanStep?: {step: string, command: string}}} ValidatedNode */
72
76
 
73
- /** @typedef {{schemaVersion: number, contractVersion: string, id: string, campaignId: string, goal: string, cwd: string, sourceIdentity: SourceIdentity, runtimes: Record<string, ValidatedRuntime>, runtimeDefaults: {worker?: string, judge?: string}, nodes: ValidatedNode[], maxParallel: number, pollIntervalMs: number, stallTimeoutSec: number, timeoutSec: number, maxTurns: number, phaseSessionReuse: boolean, finalVerification?: VerificationCommand[], sharedVerification?: VerificationCommand[], nodeAdvisory?: NodeAdvisoryPolicy, warnings: string[]}} ValidatedContract */
77
+ /** @typedef {{schemaVersion: number, contractVersion: string, id: string, campaignId: string, goal: string, cwd: string, sourceIdentity: SourceIdentity, runtimes: Record<string, ValidatedRuntime>, runtimeDefaults: {worker?: string, judge?: string}, judges?: string[], judgeIndependence?: "same-vendor", nodes: ValidatedNode[], maxParallel: number, pollIntervalMs: number, stallTimeoutSec: number, timeoutSec: number, maxTurns: number, phaseSessionReuse: boolean, finalVerification?: VerificationCommand[], sharedVerification?: VerificationCommand[], nodeAdvisory?: NodeAdvisoryPolicy, warnings: string[]}} ValidatedContract */
74
78
  /** @typedef {{costUsd?: number, durationSec?: number}} NodeAdvisoryPolicy */
75
79
 
76
80
  /** @typedef {"pending"|"running"|"done"|"no-op"|"blocked"|"failed"|"exhausted"|"stalled"|"canceled"} NodeStatus */
@@ -95,10 +99,12 @@ const GATE_REVIEWS = new Set(["none", "advisory", "blocking"]);
95
99
  /** @typedef {{available: boolean, exhaustedUntil: string|null, reason: string}} RuntimeAvailability */
96
100
  /** @typedef {{runtimeId: string, exhaustedUntil: string|null}} TierExhaustionCandidate */
97
101
  /** @typedef {{role: "worker"|"judge", candidates: TierExhaustionCandidate[]}} TierExhaustion */
98
- /** @typedef {{history: RoutingHistoryEntry[], currentOverride: RoutingOverride|null, assignments?: RuntimeAssignments, availability?: Record<string, RuntimeAvailability>, tierExhaustion?: TierExhaustion, tierExhaustionCycle?: number}} RoutingState */
102
+ /** @typedef {{id: string, reason: string}} JudgeSkip */
103
+ /** @typedef {{list: string[], chosen: string|null, skipped: JudgeSkip[]}} JudgeListState */
104
+ /** @typedef {{history: RoutingHistoryEntry[], currentOverride: RoutingOverride|null, assignments?: RuntimeAssignments, availability?: Record<string, RuntimeAvailability>, tierExhaustion?: TierExhaustion, tierExhaustionCycle?: number, judgeList?: JudgeListState}} RoutingState */
99
105
  /** @typedef {{revision?: number, heartbeatCount: number, dryHeartbeatCount: number, progressSignature?: string|null, lastHeartbeatAt: string|null, lastProgressAt: string|null, nextCheckAt?: string|null}} ProgressState */
100
106
  /** @typedef {{status: "unassigned"|"provisioning"|"ready"|"failed"|"removed", path: string|null, branch: string|null, commit: string|null, baseSha?: string|null, sealedSha?: string|null, sealError?: string|null, previousAttempt?: number|null}} WorktreeState */
101
- /** @typedef {{schemaVersion: number, contractVersion: string, id: string, type: string, sourceIdentity: SourceIdentity, packetHash: string, requirementIds?: string[], status: NodeStatus, phase: NodePhase, attempt: number, revisions: number, judgeFailures?: number, review?: ("none"|"advisory"|"blocking"), runtime: RuntimeSnapshot|null, blockedBy: string[], startedAt: string|null, updatedAt: string, result: unknown, gate: GateResult|null, error: SnapshotError|null, usage?: Usage, costUsd?: number, routing?: RoutingState|null, progress?: ProgressState|null, worktree?: WorktreeState|null, integratedHead?: string|null, invocations?: Invocation[], executionOverrides?: ExecutionOverride[], verification?: VerificationState|null, scope?: BoundedScope|null, scopeFindings?: ScopeFindings|null, verificationArtifacts?: string[], previousAttempt?: string, sessionPolicy?: {forceFresh?: boolean}|null, declaredReadBytes?: number|null}} NodeSnapshot */
107
+ /** @typedef {{schemaVersion: number, contractVersion: string, id: string, type: string, sourceIdentity: SourceIdentity, packetHash: string, requirementIds?: string[], status: NodeStatus, phase: NodePhase, attempt: number, revisions: number, judgeFailures?: number, review?: ("none"|"advisory"|"blocking"), runtime: RuntimeSnapshot|null, blockedBy: string[], startedAt: string|null, updatedAt: string, result: unknown, gate: GateResult|null, error: SnapshotError|null, usage?: Usage, costUsd?: number, routing?: RoutingState|null, progress?: ProgressState|null, worktree?: WorktreeState|null, integratedHead?: string|null, invocations?: Invocation[], executionOverrides?: ExecutionOverride[], verification?: VerificationState|null, scope?: BoundedScope|null, scopeFindings?: ScopeFindings|null, verificationArtifacts?: string[], previousAttempt?: string, sessionPolicy?: {forceFresh?: boolean}|null, declaredReadBytes?: number|null, sameProviderReview?: boolean}} NodeSnapshot */
102
108
  /** @typedef {{path: string, sha: string}} ControllerIdentity */
103
109
  /** @typedef {{schemaVersion: number, contractVersion: string, pid: number, processStartToken: string|null, startedAt: string, sourceIdentity: SourceIdentity, controllerIdentity?: ControllerIdentity, integrationRef?: string, identityWarnings?: string[], relaunchCount?: number, lastRelaunchProgressAt?: string|null, attention?: {code: string, message: string, at: string}|null, contractDigest?: string, scopeDecision?: ScopeDecision, autoRetries?: Record<string, {code: string, at: string}>}} RunMetadata */
104
110
  /** @typedef {{at: string, base: string|null, dirtyTreeFingerprint: string|null}} ScopeDecision */
@@ -160,6 +166,8 @@ export function validateContract(raw, contractPath, options = {}) {
160
166
  rejectUnknown(defaults, DEFAULTS_FIELDS, "contract.runtimeDefaults");
161
167
  if (defaults.worker !== undefined) requireRuntime(runtimes, defaults.worker, "runtimeDefaults.worker");
162
168
  if (defaults.judge !== undefined) requireRuntime(runtimes, defaults.judge, "runtimeDefaults.judge");
169
+ const judges = validateJudgeList(raw.judges, runtimes, "contract.judges");
170
+ const judgeIndependence = validateJudgeIndependence(raw.judgeIndependence, "contract.judgeIndependence");
163
171
 
164
172
  if (!Array.isArray(raw.nodes) || raw.nodes.length === 0) {
165
173
  throw new TypeError("contract.nodes must be a non-empty array");
@@ -244,6 +252,7 @@ export function validateContract(raw, contractPath, options = {}) {
244
252
  ? undefined
245
253
  : positiveInteger(node.maxTurns, `nodes[${index}].maxTurns`);
246
254
  const replayPolicy = validateReplayPolicy(node.replayPolicy, `nodes[${index}]`);
255
+ const humanStep = validateHumanStep(node.humanStep, `nodes[${index}].humanStep`);
247
256
  return /** @type {ValidatedNode} */ ({
248
257
  ...node,
249
258
  dependsOn,
@@ -257,6 +266,11 @@ export function validateContract(raw, contractPath, options = {}) {
257
266
  timeoutSec,
258
267
  maxTurns,
259
268
  replayPolicy,
269
+ // Flipped true below, once the worker/judge vendor check has run for
270
+ // every node: same-vendor mode (R20) is only known admissible after
271
+ // that pass reads the pair's declared tiers.
272
+ sameProviderReview: false,
273
+ ...(humanStep === undefined ? {} : { humanStep }),
260
274
  });
261
275
  });
262
276
 
@@ -290,38 +304,9 @@ export function validateContract(raw, contractPath, options = {}) {
290
304
  }
291
305
  }
292
306
 
293
- // A gated node whose worker and judge share a vendor cannot produce an
294
- // independent review — the same vendor grading its own output is not a
295
- // gate, so this is rejected outright rather than left to reach dispatch.
296
- // The worker's declared fallback chain is checked the same way, since it is
297
- // statically known which runtime a worker failover lands on; the symmetric
298
- // case — the judge's own fallback landing on the worker's vendor — depends
299
- // on which worker runtime actually ran and is refused at execution instead
300
- // (node.mjs, `judge_fallback_vendor_conflict`).
301
- for (const [index, node] of nodes.entries()) {
302
- if (!node.gate.enabled) continue;
303
- const workerRuntimeId = node.runtime ?? defaults.worker;
304
- const judgeRuntimeId = node.gate.runtime ?? defaults.judge;
305
- if (!workerRuntimeId || !judgeRuntimeId) continue;
306
- const workerVendor = runtimes[/** @type {string} */ (workerRuntimeId)].vendor;
307
- const judgeVendor = runtimes[/** @type {string} */ (judgeRuntimeId)].vendor;
308
- if (workerVendor === judgeVendor) {
309
- throw new TypeError(`nodes[${index}] worker runtime ${workerRuntimeId} and judge runtime ${judgeRuntimeId} share vendor ${workerVendor}`);
310
- }
311
- const seenFallbacks = new Set([/** @type {string} */ (workerRuntimeId)]);
312
- let fallbackId = runtimes[/** @type {string} */ (workerRuntimeId)].fallback;
313
- while (fallbackId !== undefined) {
314
- if (seenFallbacks.has(fallbackId)) {
315
- throw new TypeError(`nodes[${index}] worker runtime ${workerRuntimeId} fallback chain cycles back to ${fallbackId}`);
316
- }
317
- seenFallbacks.add(fallbackId);
318
- const fallbackVendor = runtimes[fallbackId].vendor;
319
- if (fallbackVendor === judgeVendor) {
320
- throw new TypeError(`nodes[${index}] worker runtime ${workerRuntimeId} fallback runtime ${fallbackId} and judge runtime ${judgeRuntimeId} share vendor ${fallbackVendor}`);
321
- }
322
- fallbackId = runtimes[fallbackId].fallback;
323
- }
324
- }
307
+ // See `markSameProviderReviewNodes` for the vendor-conflict refusal and
308
+ // R20's same-vendor opt-in that relaxes it.
309
+ markSameProviderReviewNodes(nodes, runtimes, defaults, judgeIndependence);
325
310
 
326
311
  // Every worker prompt tells the worker to run its packet verification.
327
312
  // Refuse a statically known permission mode that makes that instruction
@@ -403,6 +388,8 @@ export function validateContract(raw, contractPath, options = {}) {
403
388
  cwd,
404
389
  runtimes,
405
390
  runtimeDefaults: /** @type {{worker?: string, judge?: string}} */ (defaults),
391
+ judges,
392
+ judgeIndependence,
406
393
  nodes,
407
394
  maxParallel: validateMaxParallel(raw.maxParallel ?? 1),
408
395
  pollIntervalMs: positiveInteger(raw.pollIntervalMs ?? 1_000, "contract.pollIntervalMs"),