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
@@ -4,15 +4,17 @@
4
4
  * (sizing, routing, freeze), never as one long-lived process. Separate from
5
5
  * `template.mjs` (which only builds the one-node contracts) and from
6
6
  * `freeze.mjs` (which only turns a plan into a validated contract on disk):
7
- * this module is the one place that sequences those runs, checks each round's
8
- * plan against the contract it would freeze into while a revise can still
9
- * act on the failure, compares a revision's write set against the plan it
10
- * revised so scope closure cannot be satisfied by shrinking the work, and
11
- * decides when a plan is contested instead of frozen. `launch` and `wait`
12
- * are the only two seams that touch a process or the wall clock, so a test
13
- * drives the whole pipeline through `runContract` in-process, deterministically.
7
+ * this module sequences the draft and stages the sizing/routing/freeze
8
+ * assembly both the round loop and the final freeze run through. The round
9
+ * loop itself — checking each round's plan against the contract it would
10
+ * freeze into, comparing a revision's write set against the plan it revised,
11
+ * and the finding bookkeeping that decides when a round is done — lives in
12
+ * `rounds.mjs`; the contested-plan exit every unresolvable path returns
13
+ * through lives in `contest.mjs`. `launch` and `wait` are the only two seams
14
+ * that touch a process or the wall clock, so a test drives the whole pipeline
15
+ * through `runContract` in-process, deterministically.
14
16
  */
15
- import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
17
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
16
18
  import { dirname, join, relative, resolve } from "node:path";
17
19
  import { CONTRACT_VERSION, DEFAULT_MAX_TURNS, PROTOCOL_SCHEMA_VERSION, validateContract } from "../contract/index.mjs";
18
20
  import { discoveryOutput } from "../contract/worker-result.mjs";
@@ -21,18 +23,28 @@ import { classifyRunProgress } from "../campaign/chain.mjs";
21
23
  import { appendSeatAllowanceEvent, readJournal } from "../campaign/journal.mjs";
22
24
  import { readCampaign } from "../campaign/record.mjs";
23
25
  import { campaignCli } from "../cli/campaign.mjs";
24
- import { appendJsonl, writeJsonAtomic } from "../run/store.mjs";
25
- import { stableJson } from "../util.mjs";
26
+ import { appendJsonl } from "../run/store.mjs";
26
27
  import { allowanceDelta, allowanceEventFields, sampleAllowance } from "../seat/allowance.mjs";
27
28
  import { askPlanningRuntimes, refusePlanningSilence, refuseUnplannableRuntimes } from "./preflight.mjs";
29
+ import { firstEligibleReviewer, reviewerProvenanceOf } from "./reviewer.mjs";
28
30
  import { parseSpec, validateSpec } from "./spec.mjs";
29
31
  import { collectRepoFacts } from "./repo-facts.mjs";
30
- import { RISK_TIERS, TASK_KIND_CATALOGUE_FILE, buildPlanningContract, renderTaskKindCatalogue, validateFindings, validatePlanOutput } from "./template.mjs";
32
+ import { checkPlanProofs } from "./proof-check.mjs";
33
+ import { collectHumanSteps } from "./human-step.mjs";
34
+ import { RISK_TIERS, TASK_KIND_CATALOGUE_FILE, buildPlanningContract, renderTaskKindCatalogue, validatePlanOutput } from "./template.mjs";
31
35
  import { MIN_WRITE_FILES, applySizingRules, provenParallelism } from "./sizing.mjs";
32
36
  import { resolveRuntimes } from "./routing.mjs";
33
- import { assertTimeoutsCoverMeasured, contentDigest, freezePlan, writeFrozenPlanRecord } from "./freeze.mjs";
37
+ import { contentDigest, freezePlan, writeFrozenPlanRecord } from "./freeze.mjs";
34
38
  import { availabilityOf, fileLineCount, highestOf, modelOf, toContractNode, toSizingNode } from "./pipeline-shape.mjs";
35
39
  import { campaignTree, runDirectory } from "../run/paths.mjs";
40
+ import { contestPlan } from "./contest.mjs";
41
+ import { runReviewRounds } from "./rounds.mjs";
42
+
43
+ // `droppedWriteFindings` and `unresolvedFindings` are defined in
44
+ // `rounds.mjs`, the only module that calls them: this is a compatibility
45
+ // re-export, not a second home, kept because `test/plan/template.test.mjs`
46
+ // already names this path.
47
+ export { droppedWriteFindings, unresolvedFindings } from "./rounds.mjs";
36
48
 
37
49
  /** @typedef {import("../contract/index.mjs").JsonObject} JsonObject */
38
50
  /** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
@@ -48,7 +60,7 @@ import { campaignTree, runDirectory } from "../run/paths.mjs";
48
60
  /** @typedef {(runtimes: Record<string, JsonObject>, runtimeDefaults: {worker?: string, judge?: string}, cwd: string) => Promise<import("../harnesses/index.mjs").ProbeResult[]>} AskFn */
49
61
  /** @typedef {(runDir: string) => Promise<import("../engine/supervise.mjs").RunProgress>|import("../engine/supervise.mjs").RunProgress} WaitFn */
50
62
  /** @typedef {{status: "frozen", plansDir: string, planPath: string, contractPath: string, approved: boolean, findings: PlanFindingOutput[], warnings: string[]}} FrozenPipelineResult */
51
- /** @typedef {{status: "contested", plansDir: string, planPath: string, findings: PlanFindingOutput[], round: number}} ContestedPipelineResult */
63
+ /** @typedef {import("./contest.mjs").ContestedPipelineResult} ContestedPipelineResult */
52
64
 
53
65
  /**
54
66
  * The session id every automated journal entry this pipeline writes carries.
@@ -84,7 +96,7 @@ export const DEFAULT_NODE_BUDGET_MS = 600_000;
84
96
  const APPROVE_BELOW_VALUES = new Set(["standard", "high", "none"]);
85
97
 
86
98
  /**
87
- * @param {{specPath: string, campaignId: string, phase: string, cwd?: string, reviewRounds?: number, approveBelow?: ApproveBelow, runtimeDefaults?: {worker?: string, judge?: string}, runtimes: Record<string, JsonObject>, verification?: VerificationSuites, packageMode?: import("./sizing.mjs").PackageMode, targetedFix?: boolean, launch: LaunchFn, wait: WaitFn, ask?: AskFn}} options
99
+ * @param {{specPath: string, campaignId: string, phase: string, cwd?: string, reviewRounds?: number, approveBelow?: ApproveBelow, runtimeDefaults?: {worker?: string, judge?: string}, reviewers?: string[], runtimes: Record<string, JsonObject>, verification?: VerificationSuites, packageMode?: import("./sizing.mjs").PackageMode, targetedFix?: boolean, launch: LaunchFn, wait: WaitFn, ask?: AskFn}} options
88
100
  * `targetedFix` allows a plan with a single node. Sizing refuses one by
89
101
  * default because a phase that decomposes into one node is usually a plan
90
102
  * that was never decomposed; a targeted fix is the case where one node is
@@ -94,7 +106,7 @@ const APPROVE_BELOW_VALUES = new Set(["standard", "high", "none"]);
94
106
  export async function runPlanningPipeline(options) {
95
107
  const {
96
108
  specPath, campaignId, phase, runtimes, launch, wait,
97
- reviewRounds = 2, runtimeDefaults = {}, verification = {}, targetedFix = false,
109
+ reviewRounds = 2, runtimeDefaults = {}, reviewers = [], verification = {}, targetedFix = false,
98
110
  } = options;
99
111
  const ask = options.ask ?? askPlanningRuntimes;
100
112
  // Implementation work is sized by what it writes; exploratory work -- an
@@ -147,6 +159,10 @@ export async function runPlanningPipeline(options) {
147
159
  // from the spec's prose.
148
160
  const parsedSpec = parseSpec(specText);
149
161
  const repoFacts = collectRepoFacts(cwd, { requirements: parsedSpec.requirements });
162
+ // R16: an operator step a requirement's constraints declare, detected once
163
+ // here so both the freeze below and a caller inspecting the pipeline agree
164
+ // on the same list.
165
+ const humanSteps = collectHumanSteps(parsedSpec.requirements);
150
166
  const repoFactsPath = join(scratchDir, "repo-facts.json");
151
167
  writeFileSync(repoFactsPath, `${JSON.stringify(repoFacts, null, 2)}\n`);
152
168
  const relativeRepoFactsPath = relative(cwd, repoFactsPath);
@@ -172,14 +188,24 @@ export async function runPlanningPipeline(options) {
172
188
  * @returns {Promise<{contract: ValidatedContract, output: Record<string, unknown>}>}
173
189
  */
174
190
  const runStage = async (kind, inputs) => {
175
- const stageN = nextN();
176
- const contractPath = join(plansDir, "nodes", `${kind}-${stageN}.contract.json`);
191
+ // R19: a review or spec-review stage's runtime comes from the planner's
192
+ // own reviewer list, resolved fresh at every stage so a refusal recorded
193
+ // between rounds is not repeated.
194
+ const reviewerId = kind === "review" || kind === "spec-review" ? firstEligibleReviewer(reviewers, runtimes) ?? undefined : undefined;
195
+ // A stage number whose run already exists belongs to an earlier `plan` of
196
+ // this phase and is skipped: measured 2026-09-25, re-planning a phase
197
+ // reused `draft-1`, whose run the first attempt had left, and the launch
198
+ // died at bootstrap on "run already exists".
199
+ let contractPath, raw, validated;
200
+ do {
201
+ const stageN = nextN();
202
+ contractPath = join(plansDir, "nodes", `${kind}-${stageN}.contract.json`);
203
+ raw = buildPlanningContract(kind, {
204
+ campaignId, phase, n: stageN, runtimes, runtimeDefaults, ...(reviewerId === undefined ? {} : { reviewerId }), cwd: relative(join(plansDir, "nodes"), cwd) || ".", ...inputs,
205
+ });
206
+ validated = validateContract(raw, contractPath);
207
+ } while (existsSync(runDirectory(validated.cwd, validated.id)));
177
208
  mkdirSync(dirname(contractPath), { recursive: true });
178
- const relativeCwd = relative(dirname(contractPath), cwd) || ".";
179
- const raw = buildPlanningContract(kind, {
180
- campaignId, phase, n: stageN, runtimes, runtimeDefaults, cwd: relativeCwd, ...inputs,
181
- });
182
- const validated = validateContract(raw, contractPath);
183
209
  writeFileSync(contractPath, `${JSON.stringify(raw, null, 2)}\n`);
184
210
  await launch(contractPath, validated);
185
211
  const runDir = runDirectory(validated.cwd, validated.id);
@@ -207,12 +233,6 @@ export async function runPlanningPipeline(options) {
207
233
  // a finding back out once the plan moved under it.
208
234
  /** @type {PlanFindingOutput[]} */
209
235
  let findings = [];
210
- // The writes the most recent revise dropped, computed where the revise's
211
- // output is validated and held for the next round: merged in there, after
212
- // the review's own findings, so a drop is never cleaned away by a fresh
213
- // review passing over a plan that no longer declares the write.
214
- /** @type {PlanFindingOutput[]} */
215
- let droppedWrites = [];
216
236
  try {
217
237
  plan = validatePlanOutput(draft.output.plan);
218
238
  } catch (error) {
@@ -222,6 +242,17 @@ export async function runPlanningPipeline(options) {
222
242
  ? { runId: draft.contract.id, invalid: findings[0].text }
223
243
  : { runId: draft.contract.id, nodeCount: plan.nodes.length });
224
244
 
245
+ // R15: a deterministic check for a DoD proof no node can satisfy, run once
246
+ // against the drafted plan before the first review round grades it — a
247
+ // finding this raises is exactly as actionable to a revise as a reviewer's
248
+ // own, and raising it before review means review never spends a round
249
+ // re-discovering what a mechanical check already knows for certain.
250
+ if (plan !== null) {
251
+ const proofFindings = checkPlanProofs(plan, repoFacts, cwd);
252
+ if (proofFindings.length > 0) findings = [...findings, ...proofFindings];
253
+ logStage("proof-check", { findingsCount: proofFindings.length });
254
+ }
255
+
225
256
  const workingPlanPath = join(scratchDir, "plan.working.json");
226
257
  const relativeWorkingPlanPath = relative(cwd, workingPlanPath);
227
258
 
@@ -229,25 +260,28 @@ export async function runPlanningPipeline(options) {
229
260
  * End the pipeline the way an unresolvable plan already ends: the contested
230
261
  * result, the outstanding findings that forced it, and an open question on
231
262
  * the campaign journal. Every no-valid-plan exit funnels through here.
263
+ * `currentPlan` and the `resume` context ride along on the written record
264
+ * so `faberun plan --resolve` (R9) can continue from it without redrafting
265
+ * or re-collecting repo facts.
232
266
  *
233
267
  * @param {number} round
268
+ * @param {PlanFindingOutput[]} currentFindings
269
+ * @param {PlanOutput|null} currentPlan
234
270
  * @returns {Promise<ContestedPipelineResult>}
235
271
  */
236
- const contest = async (round) => {
237
- const criticalFindings = findings.filter((finding) => finding.severity === "critical");
238
- const planPath = join(plansDir, "plan.json");
239
- writeJsonAtomic(planPath, { formatVersion: 1, status: "contested", rounds: round, findings });
240
- logStage("contested", { round, criticalCount: criticalFindings.length });
241
- await campaignCli([
242
- "note", campaignId, "--cwd", cwd, "--session-id", PLANNER_SESSION_ID,
243
- "--kind", "open-question", "--question-id", `plan-${phase}-contested`,
244
- "--text", `Plan for phase ${phase} is contested after ${round} review round(s): ${criticalFindings.map((finding) => finding.text).join("; ")}`,
245
- ]);
246
- return { status: "contested", plansDir, planPath, findings, round };
247
- };
272
+ const contest = (round, currentFindings, currentPlan) => contestPlan({
273
+ campaignId, phase, cwd, plansDir, sessionId: PLANNER_SESSION_ID, findings: currentFindings, round, logStage,
274
+ plan: currentPlan ?? null,
275
+ resume: {
276
+ campaignId, phase, specPath: relativeSpecPath, specDigest, reviewRounds,
277
+ runtimeDefaults, reviewers, runtimes, verification, packageMode, targetedFix, approveBelow, repoFacts,
278
+ },
279
+ });
248
280
 
249
- // Which deterministic stage is running, advanced by `assembleFrozenNodes`
250
- // so the freeze-wrap catch below names the stage that threw.
281
+ // Which deterministic stage is running, so the freeze-wrap catch below
282
+ // names the stage that threw when the thrown error carries no `planStage`
283
+ // of its own (`assembleFrozenPlan` tags sizing and routing failures; a
284
+ // plain error reaching the catch after that call is `freezePlan`'s).
251
285
  let stage = "sizing";
252
286
  // The last review round entered, so the freeze-wrap catch contests with
253
287
  // the count of rounds that actually ran.
@@ -255,178 +289,40 @@ export async function runPlanningPipeline(options) {
255
289
 
256
290
  /**
257
291
  * The one assembly a plan freezes through, one home for the expression the
258
- * in-round pre-flight and the final freeze must run identically: sizing
259
- * reshapes the drafted nodes, routing assigns worker and judge, and
260
- * `toContractNode` renders the contract shape. Local and cheap — no I/O,
261
- * no model — so running it once per round costs nothing. It advances
262
- * `stage` as it goes; see the declaration above.
292
+ * in-round pre-flight and the final freeze must run identically. Delegates
293
+ * to `assembleFrozenPlan`, exported below, so `resolve.mjs` can run the
294
+ * exact same reshape over a plan it read back off disk instead of holding
295
+ * it in this closure.
263
296
  *
264
297
  * @param {PlanOutput} currentPlan
265
298
  * @returns {AssembledPlan}
266
299
  */
267
- const assembleFrozenNodes = (currentPlan) => {
268
- stage = "sizing";
269
- const sizing = applySizingRules(
270
- { nodes: currentPlan.nodes.map(toSizingNode), justification: currentPlan.justification },
271
- { nodeBudgetMs: DEFAULT_NODE_BUDGET_MS, facts: repoFacts, minWriteFiles: MIN_WRITE_FILES, turnCeiling: DEFAULT_MAX_TURNS, packageMode, readVolume: (path) => fileLineCount(join(cwd, path)), targetedFix },
272
- );
273
- stage = "routing";
274
- const routing = resolveRuntimes(sizing.plan.nodes, {
275
- table: [...DEFAULT_ROUTING_TABLE],
276
- runtimes: /** @type {Record<string, import("./routing.mjs").RoutingRuntime>} */ (runtimes),
277
- availability: availabilityOf(runtimes),
278
- runtimeDefaults,
279
- });
280
- stage = "freeze";
281
- return {
282
- sizing,
283
- routing,
284
- nodes: sizing.plan.nodes.map((node) => toContractNode(/** @type {SizedPlanNode} */ (node), phase, routing.assignments[node.id])),
285
- // The declarations a reviewer saw, remapped onto the nodes sizing
286
- // actually produced: a merge removes a node id, and a declaration that
287
- // named it must now name the node that absorbed it or freeze would see
288
- // an unknown assignment.
289
- phases: carryPhaseDeclarations(currentPlan.phases, sizing.transformations),
290
- };
291
- };
300
+ const assembleFrozenNodes = (currentPlan) => assembleFrozenPlan(currentPlan, { repoFacts, packageMode, targetedFix, phase, cwd, runtimes, runtimeDefaults });
292
301
 
293
302
  /**
294
- * The raw contract exactly as `freezePlan` will assemble and validate it,
295
- * schemaVersion and contractVersion included. It takes the whole assembly
296
- * rather than its nodes because the contract carries a run-level conclusion
297
- * of sizing's too (`maxParallel`), and the in-round pre-flight must validate
298
- * the same bytes the freeze writes. `validateContract` never
299
- * reads the path it is given — only `dirname(resolve(contractPath))` to
300
- * resolve `raw.cwd` — so the pre-flight passes the path the contract will
301
- * occupy, `contract.json` inside `plansDir`, and validates in memory
302
- * without writing or deleting anything.
303
+ * The raw contract exactly as `freezePlan` will assemble and validate it.
304
+ * Delegates to `frozenContractRawOf`, exported below for the same reason
305
+ * as `assembleFrozenPlan`.
303
306
  *
304
307
  * @param {AssembledPlan} assembly
305
308
  * @returns {JsonObject}
306
309
  */
307
- const frozenContractRaw = ({ sizing, nodes }) => ({
308
- schemaVersion: PROTOCOL_SCHEMA_VERSION,
309
- contractVersion: CONTRACT_VERSION,
310
- id: `${campaignId}-${phase}`,
311
- campaignId,
312
- goal: campaign.goal,
313
- // `freezePlan` writes contract.json inside `outDir` (`plansDir`), so
314
- // `cwd` has to point back at the repo root from there, exactly like
315
- // `runStage` computes it for the nodes it writes under `plansDir/nodes/`.
316
- cwd: relative(plansDir, cwd) || ".",
317
- // Sizing's `parallelisable` conclusion, which nothing else carries: a
318
- // contract node has no `parallel` field, so a plan that says nothing here
319
- // freezes at the validator's default of 1 and every independent node it
320
- // proved waits for its turn. What the engine does with the number is the
321
- // scheduler's business; declaring it is this stage's.
322
- maxParallel: provenParallelism(sizing.plan),
323
- runtimes,
324
- runtimeDefaults,
325
- // The operator's ratchets, carried verbatim: which suites a repository
326
- // runs on every node is the operator's policy, supplied through
327
- // `--verification`, never derived from repository facts. Validated at the
328
- // flag boundary, so freeze neither re-derives nor edits them.
329
- ...(verification.sharedVerification ? { sharedVerification: verification.sharedVerification } : {}),
330
- ...(verification.finalVerification ? { finalVerification: verification.finalVerification } : {}),
331
- nodes,
332
- });
310
+ const frozenContractRaw = (assembly) => frozenContractRawOf(assembly, { campaignId, phase, campaignGoal: campaign.goal, cwd, plansDir, runtimes, runtimeDefaults, verification });
333
311
 
334
- for (let round = 1; round <= reviewRounds; round += 1) {
335
- roundsRun = round;
336
- if (plan) {
337
- // The reviewer grades a structurally valid plan; an invalid one skips
338
- // review and reaches revise through the validator's finding instead.
339
- writeJsonAtomic(workingPlanPath, plan);
340
- const review = await runStage("review", { specPath: relativeSpecPath, repoFactsPath: relativeRepoFactsPath, planPath: relativeWorkingPlanPath });
341
- /** @type {PlanFindingOutput|null} */
342
- let invalidFindings = null;
343
- try {
344
- // Merged over what is still open, never substituted for it: a round's
345
- // reviewer grades the plan in front of it and may simply not mention
346
- // an objection the last round raised, which a plain assignment here
347
- // threw away.
348
- findings = mergeFindings(findings, validateFindings(review.output.findings));
349
- } catch (error) {
350
- // The review said nothing usable about the plan, so the plan cannot be
351
- // treated as clean: the malformed-output finding is critical and
352
- // drives the same revise-or-contest path a real critical finding does,
353
- // with the still-outstanding findings riding along.
354
- invalidFindings = invalidPlanFinding(`review-r${round}`, error);
355
- findings = [...findings, invalidFindings];
356
- }
357
- // The contract this plan would freeze into is checked here, inside the
358
- // round and after the review's findings merged into the open ones,
359
- // because freeze runs after the last one: caught here, a plan that
360
- // cannot freeze still has a revise left to fix it. The failure is a
361
- // critical finding, never an auto-filled acknowledgement — scope
362
- // closure exists to force the per-file decision (declare a write, or
363
- // acknowledge a read-only importer), and the revise worker, which can
364
- // read the repository, makes it from the validator's own message.
365
- /** @type {PlanFindingOutput|null} */
366
- let freezeFailure = null;
367
- try {
368
- assertTimeoutsCoverMeasured(validateContract(frozenContractRaw(assembleFrozenNodes(plan)), join(plansDir, "contract.json")), repoFacts);
369
- } catch (error) {
370
- freezeFailure = invalidPlanFinding(`freeze-r${round}`, error);
371
- findings = [...findings, freezeFailure];
372
- }
373
- logStage("review", {
374
- round,
375
- runId: review.contract.id,
376
- findingsCount: findings.length,
377
- criticalCount: findings.filter((finding) => finding.severity === "critical").length,
378
- ...(freezeFailure === null ? {} : { freezeFailed: freezeFailure.text }),
379
- ...(invalidFindings === null ? {} : { invalid: invalidFindings.text }),
380
- });
381
- }
382
- // The revise's write-drops land here — after the review above, before the
383
- // critical check below — so a drop survives into the same
384
- // revise-or-contest decision a review finding reaches. Merged by id: a
385
- // drop the last round already carried and this revise made again is one
386
- // finding, not two.
387
- findings = mergeFindings(findings, droppedWrites);
388
- const criticalFindings = findings.filter((finding) => finding.severity === "critical");
389
- if (criticalFindings.length === 0) break;
390
- if (round === reviewRounds) return await contest(round);
391
- const findingsPath = join(scratchDir, `findings-round-${round}.json`);
392
- writeJsonAtomic(findingsPath, findings);
393
- const revise = await runStage("revise", {
394
- specPath: relativeSpecPath, repoFactsPath: relativeRepoFactsPath, cataloguePath: relativeCataloguePath, findingsPath: relative(cwd, findingsPath), packageMode,
395
- });
396
- // Kept for the write-drop comparison: the plan the revise revised, against
397
- // the plan it produced.
398
- const planBeforeRevise = plan;
399
- /** @type {PlanFindingOutput|null} */
400
- let invalid = null;
401
- try {
402
- plan = validatePlanOutput(revise.output.plan);
403
- } catch (error) {
404
- // The revise output was rejected wholesale, so the round's review
405
- // findings are still outstanding and ride along to the next round.
406
- invalid = invalidPlanFinding(`revise-r${round}`, error);
407
- plan = null;
408
- findings = [...findings, invalid];
409
- }
410
- // Null when the revise output was refused: there is no revised write set
411
- // to compare, and the refused-output finding already drives the round.
412
- droppedWrites = droppedWriteFindings(planBeforeRevise, plan);
413
- // What the next round starts from: the findings this revise did not move
414
- // the plan under. Everything else — a node it changed, a node it removed,
415
- // and the pipeline's own shape findings, which the next round re-derives
416
- // — is dropped here rather than carried forever.
417
- findings = unresolvedFindings(findings, planBeforeRevise, plan);
418
- logStage("revise", {
419
- round,
420
- runId: revise.contract.id,
421
- droppedWrites: droppedWrites.length,
422
- carriedFindings: findings.length,
423
- ...(invalid === null ? {} : { invalid: invalid.text }),
424
- });
425
- }
312
+ const roundsResult = await runReviewRounds({
313
+ reviewRounds, plan, rejectedDraft: plan === null ? draft.output.plan : undefined, findings, cwd, plansDir, scratchDir, workingPlanPath, relativeWorkingPlanPath,
314
+ relativeSpecPath, relativeRepoFactsPath, relativeCataloguePath, packageMode, repoFacts,
315
+ runStage, assembleFrozenNodes, frozenContractRaw, contest,
316
+ invalidPlanFinding, logStage,
317
+ });
318
+ if (!roundsResult.resolved) return roundsResult.result;
319
+ plan = roundsResult.plan;
320
+ findings = roundsResult.findings;
321
+ roundsRun = roundsResult.roundsRun;
426
322
  // Reached only when no review round is configured (reviewRounds <= 0) and
427
323
  // the draft never validated: no revise exists to reach, so contested is the
428
324
  // end rather than a silent crash at sizing.
429
- if (plan === null) return await contest(0);
325
+ if (plan === null) return await contest(0, findings, null);
430
326
 
431
327
  // The pre-flight ran this exact assembly through the same validator in the
432
328
  // round that just broke, so a failure here is nearly impossible — but
@@ -449,6 +345,7 @@ export async function runPlanningPipeline(options) {
449
345
  : ["the frozen contract carries neither sharedVerification nor finalVerification, so no repository ratchet runs on its nodes and no final check closes the phase; pass --verification <file> if the target repository has ratchets every node must run"];
450
346
  try {
451
347
  assembled = assembleFrozenNodes(plan);
348
+ stage = "freeze";
452
349
  logStage("sizing", { transformations: assembled.sizing.transformations.length, nodeCount: assembled.sizing.plan.nodes.length, overheadMinutes: assembled.sizing.estimate.overheadMinutes });
453
350
  logStage("routing", { assignments: Object.keys(assembled.routing.assignments).length });
454
351
  highestRiskTier = highestOf(assembled.sizing.plan.nodes.map((node) => node.riskTier ?? RISK_TIERS[0]));
@@ -458,27 +355,37 @@ export async function runPlanningPipeline(options) {
458
355
  // The pipeline's own pinned spec bytes: a wrong or missing digest is the
459
356
  // first thing the Campaign Brief refuses on, never a summary.
460
357
  spec: { path: relativeSpecPath, digest: specDigest },
358
+ humanSteps,
461
359
  facts: repoFacts,
462
360
  provenance: {
463
361
  targetGitHead: repoFacts.gitHead,
464
362
  planner: { runtimeId: runtimeDefaults.worker ?? "", model: modelOf(runtimes, runtimeDefaults.worker) },
465
- reviewer: { runtimeId: runtimeDefaults.judge ?? "", model: modelOf(runtimes, runtimeDefaults.judge) },
363
+ reviewer: reviewerProvenanceOf(reviewers, runtimes),
466
364
  sizing: assembled.sizing.transformations,
467
365
  findings,
468
366
  },
469
367
  });
470
- logStage("freeze", { contractId: `${campaignId}-${phase}`, highestRiskTier, ...(freezeWarnings.length ? { warnings: freezeWarnings } : {}) });
368
+ logStage("freeze", {
369
+ contractId: `${campaignId}-${phase}`,
370
+ highestRiskTier,
371
+ ...(humanSteps.length ? { humanSteps: humanSteps.length } : {}),
372
+ ...(freezeWarnings.length ? { warnings: freezeWarnings } : {}),
373
+ });
471
374
  } catch (error) {
472
375
  // The stage line the pipeline would otherwise have stopped short of, the
473
376
  // failure carried as the critical finding that names it, and the
474
- // contested end every other unresolvable plan takes.
475
- logStage(stage, { failed: error instanceof Error ? error.message : String(error) });
476
- findings = [...findings, invalidPlanFinding(stage, error)];
377
+ // contested end every other unresolvable plan takes. `planStage` names a
378
+ // sizing or routing failure precisely; anything else reaching here is
379
+ // `freezePlan`'s, so the outer `stage` (bumped to "freeze" once assembly
380
+ // itself succeeded) is the fallback.
381
+ const failedStage = /** @type {{planStage?: string}} */ (error)?.planStage ?? stage;
382
+ logStage(failedStage, { failed: error instanceof Error ? error.message : String(error) });
383
+ findings = [...findings, invalidPlanFinding(failedStage, error)];
477
384
  // freezePlan removes contract.json itself when validation refuses it; a
478
385
  // failure after that write and before its return would otherwise leave a
479
386
  // contract.json no contested result may have.
480
387
  rmSync(join(plansDir, "contract.json"), { force: true });
481
- return await contest(roundsRun);
388
+ return await contest(roundsRun, findings, plan);
482
389
  }
483
390
 
484
391
  // A delta only means something between two samples of the same seat: freeze
@@ -568,122 +475,23 @@ const SCOPE_CLOSURE_RESOLUTION = "Resolve it by declaring each named file in wri
568
475
  * `SCOPE_CLOSURE_RESOLUTION` after the validator's verbatim message, which
569
476
  * stays intact because it names the exact paths the reviser must act on.
570
477
  *
478
+ * Used both outside any round (the draft validation above, and the freeze
479
+ * catch inside `runPlanningPipeline`) and inside one, so `rounds.mjs` takes
480
+ * it as an injected function rather than importing it, which would cycle
481
+ * back into this module. Exported for `resolve.mjs`, which reaches the same
482
+ * freeze catch from a plan read back off disk instead of one this module
483
+ * just assembled.
484
+ *
571
485
  * @param {string} label
572
486
  * @param {unknown} error
573
487
  * @returns {PlanFindingOutput}
574
488
  */
575
- function invalidPlanFinding(label, error) {
489
+ export function invalidPlanFinding(label, error) {
576
490
  const text = error instanceof Error ? error.message : String(error);
577
491
  const guided = text.startsWith(SCOPE_CLOSURE_MESSAGE_PREFIX) ? `${text} ${SCOPE_CLOSURE_RESOLUTION}` : text;
578
492
  return { id: `plan-shape-${label}`, severity: "critical", nodeId: "plan", text: guided };
579
493
  }
580
494
 
581
- /**
582
- * `incoming` merged over `carried`, keyed by finding id. A finding both
583
- * rounds raise is the newer reviewer's own re-judgement of the same
584
- * objection, severity included, so it replaces the carried copy instead of
585
- * duplicating it — this merge never rewrites a severity of its own. Carried
586
- * findings keep their order and come first, so the oldest outstanding
587
- * objection is at the top of the file the reviser reads.
588
- *
589
- * @param {PlanFindingOutput[]} carried
590
- * @param {PlanFindingOutput[]} incoming
591
- * @returns {PlanFindingOutput[]}
592
- */
593
- function mergeFindings(carried, incoming) {
594
- const merged = new Map(carried.map((finding) => [finding.id, finding]));
595
- for (const finding of incoming) merged.set(finding.id, finding);
596
- return [...merged.values()];
597
- }
598
-
599
- /**
600
- * The findings still open against the plan a revise produced. A review round
601
- * is under no obligation to repeat what the last one found, so replacing the
602
- * finding set each round silently drops any objection the new reviewer is
603
- * quiet about: a spike run froze a plan carrying a defect round 1 had named
604
- * and round 2 did not repeat, and the worker refused the packet with
605
- * context_missing.
606
- *
607
- * What counts as resolved is read off the two plans, never off the reviewer's
608
- * silence. A finding whose node the revise removed is moot. A finding whose
609
- * node the revise changed at all was acted on — the next review grades the
610
- * changed node and can object again in its own words. A finding against a
611
- * node the revise left identical was not addressed, and stays open. That rule
612
- * is also what keeps a carried finding from making convergence impossible:
613
- * every one of them clears the moment the reviser touches the node it names,
614
- * so a plan that answers its objections still freezes inside the round
615
- * budget, and a plan that does not still ends contested at it. A finding
616
- * naming no node of the plan — the pipeline's own `nodeId: "plan"` shape and
617
- * freeze failures — is re-derived from scratch by the next round's pre-flight,
618
- * so carrying it would double it.
619
- *
620
- * @param {PlanFindingOutput[]} findings everything open at the end of the round
621
- * @param {PlanOutput|null} previousPlan the plan the revise revised
622
- * @param {PlanOutput|null} revisedPlan the plan the revise produced, null when its output was refused
623
- * @returns {PlanFindingOutput[]}
624
- */
625
- export function unresolvedFindings(findings, previousPlan, revisedPlan) {
626
- // No revised plan to measure against: the refused-output finding drives the
627
- // next round and everything raised so far is still outstanding.
628
- if (!revisedPlan) return findings;
629
- const before = new Map((previousPlan?.nodes ?? []).map((node) => [node.id, stableJson(node)]));
630
- const after = new Map(revisedPlan.nodes.map((node) => [node.id, stableJson(node)]));
631
- return findings.filter((finding) => after.has(finding.nodeId) && before.get(finding.nodeId) === after.get(finding.nodeId));
632
- }
633
-
634
- /**
635
- * The write files the plan going into a revise declared that the revised plan
636
- * no longer declares, one finding per (node id, path). This is the check the
637
- * scope-closure validator cannot make: a shrink satisfies closure without a
638
- * judgement about any importer, so the cheap move needs a comparator of its
639
- * own. Severity is critical — a drop is not automatically wrong, but it is
640
- * always worth a second look, and only a critical finding reaches the round
641
- * loop's revise-or-contest decision; major would ride along in the findings
642
- * file while the plan froze. Nodes are matched by id alone: a node the
643
- * revision renamed or removed entirely is out of scope, because tracking
644
- * identity across a rename is a judgement about the graph this check does
645
- * not make — a removed node's writes were reviewed as a removal, not as a
646
- * silent shrink.
647
- *
648
- * Membership is judged against the whole revised plan, not against the node
649
- * that used to hold the path. A revise that splits one node in two and hands
650
- * a file to the new sibling has not dropped that file: it is still declared,
651
- * still reviewable, and the graph change is visible in the plan. Measured
652
- * 2026-09-21 on durable-state-integrity phase 1, where a per-node test made
653
- * exactly that move a critical and contested a sound plan — the draft's only
654
- * node wrote src/repo/worktree.mjs and src/engine/cancel.mjs, and the revise
655
- * layered them into worktree-preserve-ref-verb and
656
- * cancel-preserves-integrated-heads, which is the decomposition this
657
- * repository's own layering asks for.
658
- *
659
- * @param {PlanOutput|null} previousPlan the plan the revise revised, null when the draft never validated
660
- * @param {PlanOutput|null} revisedPlan the plan the revise produced, null when its output was refused
661
- * @returns {PlanFindingOutput[]}
662
- */
663
- export function droppedWriteFindings(previousPlan, revisedPlan) {
664
- if (!previousPlan || !revisedPlan) return [];
665
- const before = new Map(previousPlan.nodes.map((node) => [node.id, new Set(node.writeFiles)]));
666
- const stillDeclared = new Set(revisedPlan.nodes.flatMap((node) => node.writeFiles));
667
- /** @type {PlanFindingOutput[]} */
668
- const findings = [];
669
- for (const node of revisedPlan.nodes) {
670
- const previousWrites = before.get(node.id);
671
- if (!previousWrites) continue;
672
- let dropped = 0;
673
- for (const path of previousWrites) {
674
- if (stillDeclared.has(path)) continue;
675
- dropped += 1;
676
- findings.push({
677
- id: `dropped-write-${node.id}-${dropped}`,
678
- severity: "critical",
679
- nodeId: node.id,
680
- text: `Node ${node.id} no longer declares ${path} in writeFiles, which the plan this revise revised did declare, and no other node in the revised plan declares it either. Declare it again on whichever node owns the work: the resolution to a scope-closure finding is to declare or acknowledge the dragged-along file, never to drop a write the node needs — a smaller write set clears the same finding while leaving the worker unable to do the work. Moving the file to another node is a resolution; removing it from the plan is not.`,
681
- });
682
- }
683
- }
684
- return findings;
685
- }
686
-
687
495
  /**
688
496
  * The phase declarations a reviewer saw, remapped onto the nodes sizing
689
497
  * actually produced. `applySizingRules` is the only stage that changes the
@@ -727,6 +535,109 @@ export function carryPhaseDeclarations(phases, transformations) {
727
535
  });
728
536
  }
729
537
 
538
+ /**
539
+ * Tag an error with the sizing/routing sub-stage that threw it, so a catch
540
+ * far from here (this module's own freeze-wrap, or `resolve.mjs`'s) can name
541
+ * it precisely instead of defaulting to whichever stage it last saw start.
542
+ *
543
+ * @param {string} planStage
544
+ * @param {unknown} error
545
+ * @returns {Error}
546
+ */
547
+ function taggedStageError(planStage, error) {
548
+ const wrapped = error instanceof Error ? error : new Error(String(error));
549
+ return Object.assign(wrapped, { planStage });
550
+ }
551
+
552
+ /**
553
+ * The one assembly a plan freezes through: sizing reshapes the drafted
554
+ * nodes, routing assigns worker and judge, and `toContractNode` renders the
555
+ * contract shape. Exported (rather than kept a `runPlanningPipeline`
556
+ * closure) so both the in-round pre-flight there and `resolve.mjs`'s
557
+ * freeze-only resume run the identical reshape over a plan, wherever that
558
+ * plan came from — a fresh draft/revise or one read back off a contested
559
+ * `plan.json`.
560
+ *
561
+ * @param {PlanOutput} currentPlan
562
+ * @param {{repoFacts: import("./repo-facts.mjs").RepoFacts, packageMode: import("./sizing.mjs").PackageMode, targetedFix: boolean, phase: string, cwd: string, runtimes: Record<string, JsonObject>, runtimeDefaults: {worker?: string, judge?: string}}} ctx
563
+ * @returns {AssembledPlan}
564
+ */
565
+ export function assembleFrozenPlan(currentPlan, ctx) {
566
+ const { repoFacts, packageMode, targetedFix, phase, cwd, runtimes, runtimeDefaults } = ctx;
567
+ let sizing;
568
+ try {
569
+ sizing = applySizingRules(
570
+ { nodes: currentPlan.nodes.map(toSizingNode), justification: currentPlan.justification },
571
+ { nodeBudgetMs: DEFAULT_NODE_BUDGET_MS, facts: repoFacts, minWriteFiles: MIN_WRITE_FILES, turnCeiling: DEFAULT_MAX_TURNS, packageMode, readVolume: (path) => fileLineCount(join(cwd, path)), targetedFix },
572
+ );
573
+ } catch (error) {
574
+ throw taggedStageError("sizing", error);
575
+ }
576
+ let routing;
577
+ try {
578
+ routing = resolveRuntimes(sizing.plan.nodes, {
579
+ table: [...DEFAULT_ROUTING_TABLE],
580
+ runtimes: /** @type {Record<string, import("./routing.mjs").RoutingRuntime>} */ (runtimes),
581
+ availability: availabilityOf(runtimes),
582
+ runtimeDefaults,
583
+ });
584
+ } catch (error) {
585
+ throw taggedStageError("routing", error);
586
+ }
587
+ return {
588
+ sizing,
589
+ routing,
590
+ nodes: sizing.plan.nodes.map((node) => toContractNode(/** @type {SizedPlanNode} */ (node), phase, routing.assignments[node.id])),
591
+ // The declarations a reviewer saw, remapped onto the nodes sizing
592
+ // actually produced: a merge removes a node id, and a declaration that
593
+ // named it must now name the node that absorbed it or freeze would see
594
+ // an unknown assignment.
595
+ phases: carryPhaseDeclarations(currentPlan.phases, sizing.transformations),
596
+ };
597
+ }
598
+
599
+ /**
600
+ * The raw contract exactly as `freezePlan` will assemble and validate it,
601
+ * schemaVersion and contractVersion included. It takes the whole assembly
602
+ * rather than its nodes because the contract carries a run-level conclusion
603
+ * of sizing's too (`maxParallel`), and the in-round pre-flight must validate
604
+ * the same bytes the freeze writes. Exported alongside `assembleFrozenPlan`
605
+ * for the same reason.
606
+ *
607
+ * @param {AssembledPlan} assembly
608
+ * @param {{campaignId: string, phase: string, campaignGoal: string, cwd: string, plansDir: string, runtimes: Record<string, JsonObject>, runtimeDefaults: {worker?: string, judge?: string}, verification: VerificationSuites}} ctx
609
+ * @returns {JsonObject}
610
+ */
611
+ export function frozenContractRawOf({ sizing, nodes }, ctx) {
612
+ const { campaignId, phase, campaignGoal, cwd, plansDir, runtimes, runtimeDefaults, verification } = ctx;
613
+ return {
614
+ schemaVersion: PROTOCOL_SCHEMA_VERSION,
615
+ contractVersion: CONTRACT_VERSION,
616
+ id: `${campaignId}-${phase}`,
617
+ campaignId,
618
+ goal: campaignGoal,
619
+ // `freezePlan` writes contract.json inside `outDir` (`plansDir`), so
620
+ // `cwd` has to point back at the repo root from there, exactly like
621
+ // `runStage` computes it for the nodes it writes under `plansDir/nodes/`.
622
+ cwd: relative(plansDir, cwd) || ".",
623
+ // Sizing's `parallelisable` conclusion, which nothing else carries: a
624
+ // contract node has no `parallel` field, so a plan that says nothing here
625
+ // freezes at the validator's default of 1 and every independent node it
626
+ // proved waits for its turn. What the engine does with the number is the
627
+ // scheduler's business; declaring it is this stage's.
628
+ maxParallel: provenParallelism(sizing.plan),
629
+ runtimes,
630
+ runtimeDefaults,
631
+ // The operator's ratchets, carried verbatim: which suites a repository
632
+ // runs on every node is the operator's policy, supplied through
633
+ // `--verification`, never derived from repository facts. Validated at the
634
+ // flag boundary, so freeze neither re-derives nor edits them.
635
+ ...(verification.sharedVerification ? { sharedVerification: verification.sharedVerification } : {}),
636
+ ...(verification.finalVerification ? { finalVerification: verification.finalVerification } : {}),
637
+ nodes,
638
+ };
639
+ }
640
+
730
641
  /**
731
642
  * Write the pipeline's final frozen plan record and the `plan.json.sha256`
732
643
  * sidecar over those exact bytes. This is the last write to plan.json: after