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,446 @@
1
+ /**
2
+ * The planning pipeline's review/revise rounds: draft a plan once, then
3
+ * alternate review and revise up to `reviewRounds` times until no critical
4
+ * finding remains, the round budget runs out, or a round's own revise did not
5
+ * lower the critical count review found the round before it while leaving one
6
+ * of that round's criticals standing (R14) — every one
7
+ * of those ends contested, through the round that reached it. Separate from
8
+ * `pipeline.mjs` (measured 2026-09-24:
9
+ * pipeline.mjs was 742 of 800 lines) because this is the one place a round's
10
+ * finding bookkeeping — merge what is still open, drop what a revise
11
+ * silently shrank, resolve what a revise touched — happens together with the
12
+ * in-round freeze pre-flight. `droppedWriteFindings` and `unresolvedFindings`
13
+ * live here rather than in `pipeline.mjs` because nothing outside this loop
14
+ * ever calls them. `runStage`, `assembleFrozenNodes`, `frozenContractRaw`,
15
+ * `contest`, `invalidPlanFinding` and `logStage` are supplied by the caller
16
+ * rather than imported, because `invalidPlanFinding` is also raised outside
17
+ * any round (the draft validation and the freeze catch in `pipeline.mjs`)
18
+ * and the rest close over pipeline state (`plansDir`, `runtimes`, the
19
+ * campaign) this module has no reason to hold; injecting them keeps this
20
+ * module free of a runtime dependency on `pipeline.mjs`, so a test can drive
21
+ * it with fakes.
22
+ */
23
+ import { existsSync } from "node:fs";
24
+ import { join, relative } from "node:path";
25
+ import { validateContract } from "../contract/index.mjs";
26
+ import { writeJsonAtomic } from "../run/store.mjs";
27
+ import { stableJson } from "../util.mjs";
28
+ import { assertTimeoutsCoverMeasured, raiseTimeoutsToMeasured } from "./freeze.mjs";
29
+ import { assertFilteredProofsNameTheirTest, declareDirectoryGuards } from "./proof-scope.mjs";
30
+ import { validateFindings, validatePlanOutput } from "./template.mjs";
31
+
32
+ /** @typedef {import("../contract/index.mjs").JsonObject} JsonObject */
33
+ /** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
34
+ /** @typedef {import("./template.mjs").PlanOutput} PlanOutput */
35
+ /** @typedef {import("./template.mjs").PlanFindingOutput} PlanFindingOutput */
36
+ /** @typedef {import("./template.mjs").PlanningKind} PlanningKind */
37
+ /** @typedef {import("./contest.mjs").ContestedPipelineResult} ContestedPipelineResult */
38
+ /** @typedef {import("./pipeline.mjs").AssembledPlan} AssembledPlan */
39
+ /** @typedef {(kind: PlanningKind, inputs: Record<string, unknown>) => Promise<{contract: ValidatedContract, output: Record<string, unknown>}>} RunStageFn */
40
+ /** @typedef {(round: number, findings: PlanFindingOutput[], plan: PlanOutput|null) => Promise<ContestedPipelineResult>} ContestFn */
41
+ /** @typedef {(label: string, error: unknown) => PlanFindingOutput} InvalidPlanFindingFn */
42
+ /** @typedef {{resolved: true, plan: PlanOutput|null, findings: PlanFindingOutput[], roundsRun: number} | {resolved: false, result: ContestedPipelineResult}} RoundsResult */
43
+
44
+ /**
45
+ * `incoming` merged over `carried`, keyed by finding id. A finding both
46
+ * rounds raise is the newer reviewer's own re-judgement of the same
47
+ * objection, severity included, so it replaces the carried copy instead of
48
+ * duplicating it — this merge never rewrites a severity of its own. Carried
49
+ * findings keep their order and come first, so the oldest outstanding
50
+ * objection is at the top of the file the reviser reads.
51
+ *
52
+ * @param {PlanFindingOutput[]} carried
53
+ * @param {PlanFindingOutput[]} incoming
54
+ * @returns {PlanFindingOutput[]}
55
+ */
56
+ function mergeFindings(carried, incoming) {
57
+ const merged = new Map(carried.map((finding) => [finding.id, finding]));
58
+ for (const finding of incoming) merged.set(finding.id, finding);
59
+ return [...merged.values()];
60
+ }
61
+
62
+ /**
63
+ * The findings still open against the plan a revise produced. A review round
64
+ * is under no obligation to repeat what the last one found, so replacing the
65
+ * finding set each round silently drops any objection the new reviewer is
66
+ * quiet about: a spike run froze a plan carrying a defect round 1 had named
67
+ * and round 2 did not repeat, and the worker refused the packet with
68
+ * context_missing.
69
+ *
70
+ * What counts as resolved is read off the two plans, never off the reviewer's
71
+ * silence. A finding whose node the revise removed is moot. A finding whose
72
+ * node the revise changed at all was acted on — the next review grades the
73
+ * changed node and can object again in its own words. A finding against a
74
+ * node the revise left identical was not addressed, and stays open. That rule
75
+ * is also what keeps a carried finding from making convergence impossible:
76
+ * every one of them clears the moment the reviser touches the node it names,
77
+ * so a plan that answers its objections still freezes inside the round
78
+ * budget, and a plan that does not still ends contested at it. A finding
79
+ * naming no node of the plan — the pipeline's own `nodeId: "plan"` shape and
80
+ * freeze failures — is re-derived from scratch by the next round's pre-flight,
81
+ * so carrying it would double it.
82
+ *
83
+ * @param {PlanFindingOutput[]} findings everything open at the end of the round
84
+ * @param {PlanOutput|null} previousPlan the plan the revise revised
85
+ * @param {PlanOutput|null} revisedPlan the plan the revise produced, null when its output was refused
86
+ * @returns {PlanFindingOutput[]}
87
+ */
88
+ export function unresolvedFindings(findings, previousPlan, revisedPlan) {
89
+ // No revised plan to measure against: the refused-output finding drives the
90
+ // next round and everything raised so far is still outstanding.
91
+ if (!revisedPlan) return findings;
92
+ const before = new Map((previousPlan?.nodes ?? []).map((node) => [node.id, stableJson(node)]));
93
+ const after = new Map(revisedPlan.nodes.map((node) => [node.id, stableJson(node)]));
94
+ return findings.filter((finding) => after.has(finding.nodeId) && before.get(finding.nodeId) === after.get(finding.nodeId));
95
+ }
96
+
97
+ /**
98
+ * The write files the plan going into a revise declared that the revised plan
99
+ * no longer declares, one finding per (node id, path). This is the check the
100
+ * scope-closure validator cannot make: a shrink satisfies closure without a
101
+ * judgement about any importer, so the cheap move needs a comparator of its
102
+ * own. Severity is critical — a drop is not automatically wrong, but it is
103
+ * always worth a second look, and only a critical finding reaches the round
104
+ * loop's revise-or-contest decision; major would ride along in the findings
105
+ * file while the plan froze. Nodes are matched by id alone: a node the
106
+ * revision renamed or removed entirely is out of scope, because tracking
107
+ * identity across a rename is a judgement about the graph this check does
108
+ * not make — a removed node's writes were reviewed as a removal, not as a
109
+ * silent shrink.
110
+ *
111
+ * Membership is judged against the whole revised plan, not against the node
112
+ * that used to hold the path. A revise that splits one node in two and hands
113
+ * a file to the new sibling has not dropped that file: it is still declared,
114
+ * still reviewable, and the graph change is visible in the plan. Measured
115
+ * 2026-09-21 on durable-state-integrity phase 1, where a per-node test made
116
+ * exactly that move a critical and contested a sound plan — the draft's only
117
+ * node wrote src/repo/worktree.mjs and src/engine/cancel.mjs, and the revise
118
+ * layered them into worktree-preserve-ref-verb and
119
+ * cancel-preserves-integrated-heads, which is the decomposition this
120
+ * repository's own layering asks for.
121
+ *
122
+ * Only a path that exists in the repository can be dropped. A file the plan
123
+ * itself invented and the revise then renamed is a rename, not a lost write:
124
+ * no importer, test or reader depends on a name nothing has created yet.
125
+ * Measured 2026-09-25 on the 3a gate rerun: two of six drops were exactly
126
+ * that (env-guard.mjs renamed env-declaration.mjs, a probe-results note moved
127
+ * to the ledger), and each counted as a critical against a revise that had
128
+ * resolved every critical its review raised.
129
+ *
130
+ * @param {PlanOutput|null} previousPlan the plan the revise revised, null when the draft never validated
131
+ * @param {PlanOutput|null} revisedPlan the plan the revise produced, null when its output was refused
132
+ * @param {string} cwd the repository the plan writes into
133
+ * @returns {PlanFindingOutput[]}
134
+ */
135
+ export function droppedWriteFindings(previousPlan, revisedPlan, cwd) {
136
+ if (!previousPlan || !revisedPlan) return [];
137
+ const before = new Map(previousPlan.nodes.map((node) => [node.id, new Set(node.writeFiles)]));
138
+ const stillDeclared = new Set(revisedPlan.nodes.flatMap((node) => node.writeFiles));
139
+ /** @type {PlanFindingOutput[]} */
140
+ const findings = [];
141
+ for (const node of revisedPlan.nodes) {
142
+ const previousWrites = before.get(node.id);
143
+ if (!previousWrites) continue;
144
+ let dropped = 0;
145
+ for (const path of previousWrites) {
146
+ if (stillDeclared.has(path) || !existsSync(join(cwd, path))) continue;
147
+ dropped += 1;
148
+ findings.push({
149
+ id: `dropped-write-${node.id}-${dropped}`,
150
+ severity: "critical",
151
+ nodeId: node.id,
152
+ 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.`,
153
+ });
154
+ }
155
+ }
156
+ return findings;
157
+ }
158
+
159
+ /**
160
+ * The finding a round's break condition raises when a revise did not lower
161
+ * the critical count the reviewer found: `round`'s own count sits at or above
162
+ * `history[history.length - 2]`'s, the round right before it. Carries the
163
+ * whole per-round history rather than just the two counts being compared, so
164
+ * the contested record and the campaign journal note it drives both show the
165
+ * shape of the whole attempt, not just the round that finally gave up on it.
166
+ *
167
+ * @param {number} round
168
+ * @param {number[]} history critical counts, one per round measured so far, this round's last
169
+ * @returns {PlanFindingOutput}
170
+ */
171
+ function revisionNotConvergingFinding(round, history) {
172
+ const current = history[history.length - 1];
173
+ const previous = history[history.length - 2];
174
+ return {
175
+ id: `revision-not-converging-r${round}`,
176
+ severity: "critical",
177
+ nodeId: "plan",
178
+ text: `Round ${round} review still finds ${current} critical finding(s), no fewer than round ${round - 1}'s ${previous}. Critical count by round: ${history.join(", ")}. The revise is not resolving what review keeps objecting to, so the pipeline stops here instead of spending the round(s) still in budget.`,
179
+ };
180
+ }
181
+
182
+ /**
183
+ * Run the review/revise rounds against a draft plan, in place of the pipeline
184
+ * running them itself. Returns `resolved: true` with the plan and findings a
185
+ * round's break (no critical finding left) or an exhausted `reviewRounds`
186
+ * without a review ever running (the draft never validated) leaves behind, or
187
+ * `resolved: false` with the `contest` result the round that hit the budget,
188
+ * failed to converge (R14), or lost both of a round's revise attempts to
189
+ * `validatePlanOutput` produced — `pipeline.mjs` returns that result as its
190
+ * own.
191
+ *
192
+ * @param {{
193
+ * reviewRounds: number,
194
+ * plan: PlanOutput|null,
195
+ * rejectedDraft?: unknown,
196
+ * findings: PlanFindingOutput[],
197
+ * cwd: string,
198
+ * plansDir: string,
199
+ * scratchDir: string,
200
+ * workingPlanPath: string,
201
+ * relativeWorkingPlanPath: string,
202
+ * relativeSpecPath: string,
203
+ * relativeRepoFactsPath: string,
204
+ * relativeCataloguePath: string,
205
+ * packageMode: import("./sizing.mjs").PackageMode,
206
+ * repoFacts: import("./repo-facts.mjs").RepoFacts,
207
+ * runStage: RunStageFn,
208
+ * assembleFrozenNodes: (plan: PlanOutput) => AssembledPlan,
209
+ * frozenContractRaw: (assembly: AssembledPlan) => JsonObject,
210
+ * contest: ContestFn,
211
+ * invalidPlanFinding: InvalidPlanFindingFn,
212
+ * logStage: (stage: string, extra?: Record<string, unknown>) => void,
213
+ * }} options
214
+ * @returns {Promise<RoundsResult>}
215
+ */
216
+ export async function runReviewRounds(options) {
217
+ const {
218
+ reviewRounds, cwd, plansDir, scratchDir, workingPlanPath, relativeWorkingPlanPath,
219
+ relativeSpecPath, relativeRepoFactsPath, relativeCataloguePath, packageMode, repoFacts,
220
+ runStage, assembleFrozenNodes, frozenContractRaw, contest,
221
+ invalidPlanFinding, logStage,
222
+ } = options;
223
+ let plan = options.plan;
224
+ let findings = options.findings;
225
+ // A draft that never validated is still the plan its first revise repairs.
226
+ if (plan === null) writeJsonAtomic(workingPlanPath, options.rejectedDraft ?? null);
227
+ // The revise's write-drops, merged into `findings` below each round; see
228
+ // that merge for why.
229
+ /** @type {PlanFindingOutput[]} */
230
+ let droppedWrites = [];
231
+ let roundsRun = 0;
232
+ // The critical count review found each round it actually ran, oldest
233
+ // first: what R14's stop condition compares one round against the one
234
+ // before it. A revise retry (`reviseOnce` below) never appends here — only
235
+ // a round whose own review ran counts toward this history, because a retry
236
+ // spends no round.
237
+ /** @type {number[]} */
238
+ const criticalHistory = [];
239
+ // How many of the last round's criticals the revise left standing: each
240
+ // one names a node the revise did not change (`unresolvedFindings`). R14
241
+ // stops only when this is non-zero, because a count alone cannot tell a
242
+ // revise that ignored an objection from one that answered every objection
243
+ // and a fresh review found new ones. Measured 2026-09-25 on the 3a gate
244
+ // rerun: round 1 raised 2 criticals, the revise answered both (0 carried),
245
+ // round 2's review raised 2 different ones, and a count-only R14 stopped a
246
+ // plan with two rounds still in budget.
247
+ let carriedCritical = 0;
248
+
249
+ /**
250
+ * The freeze pre-flight: the contract this plan would freeze into, checked
251
+ * the way `freeze.mjs` checks it. Null when it would freeze.
252
+ *
253
+ * @param {PlanOutput} candidate
254
+ * @returns {unknown}
255
+ */
256
+ const freezePreflightError = (candidate) => {
257
+ try {
258
+ const contract = validateContract(frozenContractRaw(assembleFrozenNodes(declareDirectoryGuards(raiseTimeoutsToMeasured(candidate, repoFacts).plan, repoFacts, cwd).plan)), join(plansDir, "contract.json"));
259
+ assertFilteredProofsNameTheirTest(contract);
260
+ assertTimeoutsCoverMeasured(contract, repoFacts);
261
+ return null;
262
+ } catch (error) {
263
+ return error;
264
+ }
265
+ };
266
+
267
+ /**
268
+ * Run the revise stage once, validate its output and run the freeze
269
+ * pre-flight on it, and on a rejection run it a second time with the
270
+ * check's message appended to the same round's findings — the one retry
271
+ * R14 grants before a round contests. Every shape rejection this reaches is
272
+ * one `validatePlanOutput` has no deterministic fix for: R21 already normalizes the one shape variant that used to need
273
+ * one (a text `proof.ref`) inside the validator itself, so nothing here has
274
+ * a mechanical repair to apply instead of asking the worker again. Neither
275
+ * attempt is charged against `reviewRounds`; the caller's own round counter
276
+ * is untouched either way.
277
+ *
278
+ * @param {number} round
279
+ * @param {PlanFindingOutput[]} findingsForRevise
280
+ * @returns {Promise<{plan: PlanOutput, runId: string, firstInvalid: PlanFindingOutput|null} | {plan: null, finding: PlanFindingOutput, firstInvalid: PlanFindingOutput}>}
281
+ */
282
+ const reviseOnce = async (round, findingsForRevise) => {
283
+ const path = join(scratchDir, `findings-round-${round}.json`);
284
+ writeJsonAtomic(path, findingsForRevise);
285
+ // The revise edits the plan the findings were raised against, which the
286
+ // round wrote to the working plan before review (or the draft's rejected
287
+ // output, below). Measured 2026-09-25 on the 3a gate: a revise handed only
288
+ // the findings redrafted the plan from scratch, renamed files and dropped
289
+ // six writes, and its retry fixed one validator error while introducing
290
+ // another.
291
+ const revise = await runStage("revise", {
292
+ specPath: relativeSpecPath, repoFactsPath: relativeRepoFactsPath, cataloguePath: relativeCataloguePath, findingsPath: relative(cwd, path), planPath: relativeWorkingPlanPath, packageMode,
293
+ });
294
+ // The whole deterministic check R14 names runs on the revise's output:
295
+ // the shape validator, and the freeze pre-flight a review round would
296
+ // otherwise only reach a round later. Measured 2026-09-26 on the 3a gate:
297
+ // four rounds with no critical from review each contested on a
298
+ // scope-closure gap the same revise had just opened, because the revise
299
+ // learned of it only after the next review had spent its round.
300
+ /** @type {PlanOutput|null} */
301
+ let structurallyValid = null;
302
+ /** @type {PlanFindingOutput} */
303
+ let firstInvalid;
304
+ try {
305
+ structurallyValid = validatePlanOutput(revise.output.plan);
306
+ const preflightError = freezePreflightError(structurallyValid);
307
+ if (preflightError === null) return { plan: structurallyValid, runId: revise.contract.id, firstInvalid: null };
308
+ firstInvalid = invalidPlanFinding(`revise-r${round}-attempt1`, preflightError);
309
+ } catch (error) {
310
+ firstInvalid = invalidPlanFinding(`revise-r${round}-attempt1`, error);
311
+ }
312
+ const retryPath = join(scratchDir, `findings-round-${round}-retry.json`);
313
+ writeJsonAtomic(retryPath, [...findingsForRevise, firstInvalid]);
314
+ // The retry repairs the output the check refused, not the plan before
315
+ // it: that output already carries this round's resolutions, and only the
316
+ // check's message is left to answer.
317
+ const retryPlanPath = join(scratchDir, `plan-round-${round}-rejected.json`);
318
+ writeJsonAtomic(retryPlanPath, revise.output.plan ?? null);
319
+ const retry = await runStage("revise", {
320
+ specPath: relativeSpecPath, repoFactsPath: relativeRepoFactsPath, cataloguePath: relativeCataloguePath, findingsPath: relative(cwd, retryPath), planPath: relative(cwd, retryPlanPath), packageMode,
321
+ });
322
+ try {
323
+ // A retry that validates goes to review even if the freeze pre-flight
324
+ // still refuses it: that round's own pre-flight raises it again.
325
+ return { plan: validatePlanOutput(retry.output.plan), runId: retry.contract.id, firstInvalid };
326
+ } catch (secondError) {
327
+ // The first attempt still validated: it goes to review rather than
328
+ // contesting a round over a retry that made things worse.
329
+ if (structurallyValid !== null) return { plan: structurallyValid, runId: revise.contract.id, firstInvalid };
330
+ return { plan: null, finding: invalidPlanFinding(`revise-r${round}-attempt2`, secondError), firstInvalid };
331
+ }
332
+ };
333
+
334
+ for (let round = 1; round <= reviewRounds; round += 1) {
335
+ roundsRun = round;
336
+ // Only a round whose plan a review actually graded has a critical count
337
+ // R14 can compare: an invalid plan skips review, and its one critical says
338
+ // the plan did not validate. Measured 2026-09-25 on the 3a gate: an invalid
339
+ // draft counted 1, the first real review counted 2 (one of them the
340
+ // scope-closure pre-flight), and R14 stopped the plan after 2 of 4 rounds.
341
+ const reviewed = plan !== null;
342
+ if (plan) {
343
+ // R14's mechanical repair with a single answer, applied before review so
344
+ // the reviewer grades, and freeze checks, the plan that would ship.
345
+ const timeouts = raiseTimeoutsToMeasured(plan, repoFacts);
346
+ if (timeouts.raised.length) logStage("timeouts-raised", { round, raised: timeouts.raised });
347
+ const guards = declareDirectoryGuards(timeouts.plan, repoFacts, cwd);
348
+ if (guards.declared.length) logStage("guards-declared", { round, declared: guards.declared });
349
+ plan = guards.plan;
350
+ // The reviewer grades a structurally valid plan; an invalid one skips
351
+ // review and reaches revise through the validator's finding instead.
352
+ writeJsonAtomic(workingPlanPath, plan);
353
+ const review = await runStage("review", { specPath: relativeSpecPath, repoFactsPath: relativeRepoFactsPath, planPath: relativeWorkingPlanPath });
354
+ /** @type {PlanFindingOutput|null} */
355
+ let invalidFindings = null;
356
+ try {
357
+ // Merged over what is still open, never substituted for it: a round's
358
+ // reviewer grades the plan in front of it and may simply not mention
359
+ // an objection the last round raised, which a plain assignment here
360
+ // threw away.
361
+ findings = mergeFindings(findings, validateFindings(review.output.findings));
362
+ } catch (error) {
363
+ // The review said nothing usable about the plan, so the plan cannot be
364
+ // treated as clean: the malformed-output finding is critical and
365
+ // drives the same revise-or-contest path a real critical finding does,
366
+ // with the still-outstanding findings riding along.
367
+ invalidFindings = invalidPlanFinding(`review-r${round}`, error);
368
+ findings = [...findings, invalidFindings];
369
+ }
370
+ // The contract this plan would freeze into is checked here, inside the
371
+ // round and after the review's findings merged into the open ones,
372
+ // because freeze runs after the last one: caught here, a plan that
373
+ // cannot freeze still has a revise left to fix it. The failure is a
374
+ // critical finding, never an auto-filled acknowledgement — scope
375
+ // closure exists to force the per-file decision (declare a write, or
376
+ // acknowledge a read-only importer), and the revise worker, which can
377
+ // read the repository, makes it from the validator's own message.
378
+ /** @type {PlanFindingOutput|null} */
379
+ let freezeFailure = null;
380
+ const preflightError = freezePreflightError(plan);
381
+ if (preflightError !== null) {
382
+ freezeFailure = invalidPlanFinding(`freeze-r${round}`, preflightError);
383
+ findings = [...findings, freezeFailure];
384
+ }
385
+ logStage("review", {
386
+ round,
387
+ runId: review.contract.id,
388
+ findingsCount: findings.length,
389
+ criticalCount: findings.filter((finding) => finding.severity === "critical").length,
390
+ ...(freezeFailure === null ? {} : { freezeFailed: freezeFailure.text }),
391
+ ...(invalidFindings === null ? {} : { invalid: invalidFindings.text }),
392
+ });
393
+ }
394
+ // The revise's write-drops from the previous round land here — after the
395
+ // review above, before the critical check below — so a drop survives into
396
+ // the same revise-or-contest decision a review finding reaches. Merged by
397
+ // id: a drop the last round already carried and this revise made again is
398
+ // one finding, not two.
399
+ findings = mergeFindings(findings, droppedWrites);
400
+ const criticalFindings = findings.filter((finding) => finding.severity === "critical");
401
+ if (criticalFindings.length === 0) break;
402
+ // R14: a round that ends with as many or more criticals than the round
403
+ // before it, while the revise left one of that round's criticals
404
+ // standing, is not converging — the revise already had it in front of
405
+ // it, and spending the rounds still in budget would not change that.
406
+ // Checked before the round-budget exit below, so the more informative
407
+ // finding wins when a round is both the last one and a non-improvement
408
+ // over the one before it.
409
+ const previousCritical = reviewed ? criticalHistory[criticalHistory.length - 1] : undefined;
410
+ if (reviewed) criticalHistory.push(criticalFindings.length);
411
+ if (previousCritical !== undefined && criticalFindings.length >= previousCritical && carriedCritical > 0) {
412
+ const notConverging = revisionNotConvergingFinding(round, criticalHistory);
413
+ findings = [...findings, notConverging];
414
+ logStage("revision-not-converging", { round, criticalHistory });
415
+ return { resolved: false, result: await contest(round, findings, plan) };
416
+ }
417
+ if (round === reviewRounds) return { resolved: false, result: await contest(round, findings, plan) };
418
+ // Kept for the write-drop and unresolved-finding comparisons below: the
419
+ // plan the revise revises, against whichever attempt's output validates.
420
+ const planBeforeRevise = plan;
421
+ const revised = await reviseOnce(round, findings);
422
+ if (revised.plan === null) {
423
+ // Both this round's revise attempts were rejected: the round contests
424
+ // rather than carrying the still-unrevised plan into another round.
425
+ findings = [...findings, revised.finding];
426
+ logStage("revise", { round, retried: true, invalid: revised.finding.text, firstInvalid: revised.firstInvalid.text });
427
+ return { resolved: false, result: await contest(round, findings, plan) };
428
+ }
429
+ plan = revised.plan;
430
+ droppedWrites = droppedWriteFindings(planBeforeRevise, plan, cwd);
431
+ // What the next round starts from: the findings this revise did not move
432
+ // the plan under. Everything else — a node it changed, a node it removed,
433
+ // and the pipeline's own shape findings, which the next round re-derives
434
+ // — is dropped here rather than carried forever.
435
+ findings = unresolvedFindings(findings, planBeforeRevise, plan);
436
+ carriedCritical = findings.filter((finding) => finding.severity === "critical").length;
437
+ logStage("revise", {
438
+ round,
439
+ runId: revised.runId,
440
+ droppedWrites: droppedWrites.length,
441
+ carriedFindings: findings.length,
442
+ ...(revised.firstInvalid === null ? {} : { retried: true, firstInvalid: revised.firstInvalid.text }),
443
+ });
444
+ }
445
+ return { resolved: true, plan, findings, roundsRun };
446
+ }
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { ROUTING_STRATEGIES } from "../contract/runtime.mjs";
15
15
  import { cheapest, isRuntimeAvailable, strongest } from "../engine/runtime-discovery.mjs";
16
+ import { effectiveProvider } from "../contract/provider.mjs";
16
17
 
17
18
  /** @typedef {import("../contract/runtime.mjs").RoutingStrategy} RoutingStrategy */
18
19
  /** @typedef {RoutingStrategy|"declared"} AppliedStrategy */
@@ -227,7 +228,8 @@ function chooseByStrategy(strategy, prefer, context) {
227
228
  function blockedWhy(id, runtimes, availability, forbiddenVendors) {
228
229
  const runtime = runtimes[id];
229
230
  if (!runtime) return "is not declared in runtimes";
230
- if (forbiddenVendors.has(runtime.vendor)) return `carries forbidden vendor ${runtime.vendor}`;
231
+ const provider = effectiveProvider(runtime);
232
+ if (provider !== undefined && forbiddenVendors.has(provider)) return `carries forbidden vendor ${provider}`;
231
233
  return "is unavailable";
232
234
  }
233
235
 
@@ -241,7 +243,8 @@ function blockedWhy(id, runtimes, availability, forbiddenVendors) {
241
243
  function admits(id, runtimes, availability, forbiddenVendors) {
242
244
  const runtime = runtimes[id];
243
245
  if (!runtime) return false;
244
- if (forbiddenVendors.has(runtime.vendor)) return false;
246
+ const provider = effectiveProvider(runtime);
247
+ if (provider !== undefined && forbiddenVendors.has(provider)) return false;
245
248
  return isRuntimeAvailable(availability[id]);
246
249
  }
247
250
 
@@ -262,7 +265,8 @@ function forbiddenJudgeVendors(workerId, runtimes) {
262
265
  let id = workerId;
263
266
  while (id !== undefined && runtimes[id] && !seen.has(id)) {
264
267
  seen.add(id);
265
- vendors.add(runtimes[id].vendor);
268
+ const provider = effectiveProvider(runtimes[id]);
269
+ if (provider !== undefined) vendors.add(provider);
266
270
  id = runtimes[id].fallback;
267
271
  }
268
272
  return vendors;
@@ -283,7 +287,10 @@ function ruleLabel(row) {
283
287
  function candidateEntries(runtimes, availability, forbiddenVendors) {
284
288
  return Object.entries(runtimes)
285
289
  .map(([id, runtime], order) => ({ id, runtime, order }))
286
- .filter(({ id, runtime }) => !forbiddenVendors.has(runtime.vendor) && isRuntimeAvailable(availability[id]));
290
+ .filter(({ id, runtime }) => {
291
+ const provider = effectiveProvider(runtime);
292
+ return (provider === undefined || !forbiddenVendors.has(provider)) && isRuntimeAvailable(availability[id]);
293
+ });
287
294
  }
288
295
 
289
296
  /**
@@ -25,7 +25,7 @@ import { validateVerificationCommands } from "../contract/verification.mjs";
25
25
  /** @typedef {import("../contract/index.mjs").JsonObject} JsonObject */
26
26
  /** @typedef {"draft"|"review"|"revise"|"spec-author"|"spec-review"} PlanningKind */
27
27
  /** @typedef {"low"|"standard"|"high"} RiskTier */
28
- /** @typedef {{campaignId: string, phase: string, n: number, goal?: string, cwd?: string, runtimes: Record<string, JsonObject>, runtimeDefaults: {worker?: string, judge?: string}, specPath?: string, repoFactsPath?: string, cataloguePath?: string, packageMode?: import("./sizing.mjs").PackageMode, planPath?: string, findingsPath?: string, notesPath?: string}} PlanningContractInputs */
28
+ /** @typedef {{campaignId: string, phase: string, n: number, goal?: string, cwd?: string, runtimes: Record<string, JsonObject>, runtimeDefaults: {worker?: string, judge?: string}, reviewerId?: string, specPath?: string, repoFactsPath?: string, cataloguePath?: string, packageMode?: import("./sizing.mjs").PackageMode, planPath?: string, findingsPath?: string, notesPath?: string}} PlanningContractInputs */
29
29
  /** @typedef {{id: string, objective: string, taskKind: string, riskTier: RiskTier, dependsOn: string[], readFiles: string[], writeFiles: string[], scopeAcknowledged: string[], definitionOfDone: import("../contract/definition-of-done.mjs").DefinitionOfDoneItem[], verification: import("../contract/verification.mjs").VerificationCommand[], expectedTurns?: number}} PlanOutputNode */
30
30
  /** @typedef {{nodes: PlanOutputNode[], phases?: PlanPhase[], findings?: PlanFindingOutput[], justification?: string}} PlanOutput */
31
31
  /** @typedef {{id: string, requirementIds: string[], nodeIds?: string[], deliverable: string}} PlanPhase */
@@ -78,26 +78,30 @@ export function renderTaskKindCatalogue() {
78
78
  }
79
79
 
80
80
  /**
81
- * Which of the caller's `runtimeDefaults` roles resolves this contract's
82
- * single node. A draft or revise is authored by the worker role; a review or
83
- * spec-review is graded by the judge role — there is no gate on this
84
- * single-node contract, so the role only decides which runtime id the node
85
- * itself carries.
81
+ * Which of the caller's runtime inputs resolves this contract's single node.
82
+ * A draft or revise is authored by the worker role, read off
83
+ * `runtimeDefaults.worker`; a review or spec-review is graded by the
84
+ * reviewer role, read off `inputs.reviewerId` instead (R19) -- the planner's
85
+ * own ordered reviewer list (`reviewer.mjs`), never `runtimeDefaults.judge`,
86
+ * which is R18's judge-list default for the frozen contract's nodes and
87
+ * shares nothing with this one. There is no gate on this single-node
88
+ * contract, so the role only decides which runtime id the node itself
89
+ * carries.
86
90
  *
87
- * @type {Record<PlanningKind, "worker"|"judge">}
91
+ * @type {Record<PlanningKind, "worker"|"reviewer">}
88
92
  */
89
93
  const KIND_ROLE = Object.freeze({
90
94
  draft: "worker",
91
95
  revise: "worker",
92
96
  "spec-author": "worker",
93
- review: "judge",
94
- "spec-review": "judge",
97
+ review: "reviewer",
98
+ "spec-review": "reviewer",
95
99
  });
96
100
 
97
101
  /** @type {Record<PlanningKind, string[]>} */
98
102
  const REQUIRED_INPUTS = Object.freeze({
99
103
  draft: ["specPath", "repoFactsPath", "cataloguePath"],
100
- revise: ["specPath", "repoFactsPath", "cataloguePath", "findingsPath"],
104
+ revise: ["specPath", "repoFactsPath", "cataloguePath", "findingsPath", "planPath"],
101
105
  review: ["specPath", "repoFactsPath", "planPath"],
102
106
  "spec-author": ["notesPath"],
103
107
  "spec-review": ["specPath"],
@@ -144,6 +148,14 @@ function instructionsFor(kind, inputs) {
144
148
  return INSTRUCTIONS[kind].map((line) => (line === SIZING_INSTRUCTION ? EXPLORATORY_SIZING_INSTRUCTION : line));
145
149
  }
146
150
 
151
+ // AP1 of safe-to-hand-to-a-friend, measured 2026-09-26: a draft copied the
152
+ // spec's requirement proof `node --test --test-name-pattern="<title>"` into a
153
+ // Definition of Done item with no file, which runs every test file in the
154
+ // tree and read as having measured nothing; two nodes exhausted on it. The
155
+ // freeze now refuses that shape (proof-scope.mjs); this is the same rule told
156
+ // to the author, so the draft never writes it.
157
+ const NAMED_TEST_FILE_RULE = 'A command that filters node:test by name (--test-name-pattern) names the test file it selects from, as in node --test --test-name-pattern="<exact test title>" test/<area>/<file>.test.mjs: with no path node --test runs every test file in the tree and the freeze refuses it. A requirement proof the spec writes without a file gains the file of the test that carries that title.';
158
+
147
159
  // The rule every planned packet is held to at freeze time, worded from
148
160
  // AGENTS.md's Faberun protocol and src/repo/scope-closure.mjs ("reading it
149
161
  // cannot fix it"): a first draft that ignores it produces a plan that fails
@@ -157,7 +169,7 @@ const SCOPE_CLOSURE_RULE = Object.freeze([
157
169
  /** @type {Record<PlanningKind, string>} */
158
170
  const OBJECTIVES = Object.freeze({
159
171
  draft: "Draft an execution plan for this phase: classify every node's taskKind and riskTier from the spec and the repository facts, and propose the dependency graph.",
160
- revise: "Revise the plan to resolve every one of the reviewer's findings, keeping the same classification and graph shape as a fresh draft.",
172
+ revise: "Revise the plan in readFiles to resolve every one of the reviewer's findings, changing only what a finding requires.",
161
173
  review: "Review this plan against the spec and the repository facts, and report only findings.",
162
174
  "spec-author": "Turn free notes into a structured spec document following the spec format.",
163
175
  "spec-review": "Review this spec for traceability and completeness, and report only findings.",
@@ -169,14 +181,17 @@ const INSTRUCTIONS = Object.freeze({
169
181
  `Consult the ${TASK_KIND_CATALOGUE_FILE} in readFiles before classifying any node; taskKind must be one of that catalogue and riskTier must be one of ${RISK_TIERS.join(", ")}.`,
170
182
  "Declare every phase the plan serves in output.plan.phases: the requirement ids (R<n> from the spec) the phase satisfies, the planned node ids it assigns, and the deliverable it produces in one sentence. Every planned node must appear in exactly one phase's nodeIds; a missing, duplicate, or unknown node assignment is refused.",
171
183
  ...SCOPE_CLOSURE_RULE,
184
+ NAMED_TEST_FILE_RULE,
172
185
  `Return exactly one worker-result JSON object. Put the plan in output.plan as ${PLAN_OUTPUT_SHAPE} and nothing else in output.`,
173
186
  "Never name a runtime, harness, model, or vendor anywhere in output.plan. taskKind and riskTier are the only classification a draft makes; a routing table assigns a runtime afterward, from those two fields alone.",
174
187
  SIZING_INSTRUCTION,
175
188
  ],
176
189
  revise: [
190
+ "Start from the plan JSON in readFiles, the plan the findings were raised against, and return it with only the changes the findings require. Keep every node id, write file and definitionOfDone item that no finding asks you to change: a revise that redrafts from the findings alone loses what the plan already got right.",
177
191
  "Read the findings and resolve every one; do not leave a critical or major finding unaddressed.",
178
192
  "Declare every phase the plan serves in output.plan.phases: the requirement ids (R<n> from the spec) the phase satisfies, the planned node ids it assigns, and the deliverable it produces in one sentence. Every planned node must appear in exactly one phase's nodeIds; a missing, duplicate, or unknown node assignment is refused.",
179
193
  ...SCOPE_CLOSURE_RULE,
194
+ NAMED_TEST_FILE_RULE,
180
195
  `Return exactly one worker-result JSON object. Put the revised plan in output.plan as ${PLAN_OUTPUT_SHAPE} and nothing else in output.`,
181
196
  "Never name a runtime, harness, model, or vendor anywhere in output.plan.",
182
197
  SIZING_INSTRUCTION,
@@ -214,7 +229,7 @@ const NON_GOALS = Object.freeze({
214
229
  function readFilesForKind(kind, inputs) {
215
230
  if (kind === "draft") return [/** @type {string} */ (inputs.specPath), /** @type {string} */ (inputs.repoFactsPath), /** @type {string} */ (inputs.cataloguePath)];
216
231
  if (kind === "revise") {
217
- return [/** @type {string} */ (inputs.specPath), /** @type {string} */ (inputs.repoFactsPath), /** @type {string} */ (inputs.cataloguePath), /** @type {string} */ (inputs.findingsPath)];
232
+ return [/** @type {string} */ (inputs.specPath), /** @type {string} */ (inputs.repoFactsPath), /** @type {string} */ (inputs.cataloguePath), /** @type {string} */ (inputs.findingsPath), /** @type {string} */ (inputs.planPath)];
218
233
  }
219
234
  if (kind === "review") return [/** @type {string} */ (inputs.specPath), /** @type {string} */ (inputs.repoFactsPath), /** @type {string} */ (inputs.planPath)];
220
235
  if (kind === "spec-author") return [/** @type {string} */ (inputs.notesPath)];
@@ -243,7 +258,7 @@ export function buildPlanningContract(kind, inputs) {
243
258
 
244
259
  const readFiles = readFilesForKind(kind, inputs);
245
260
  const role = KIND_ROLE[kind];
246
- const runtimeId = (inputs.runtimeDefaults ?? {})[role];
261
+ const runtimeId = role === "reviewer" ? inputs.reviewerId : (inputs.runtimeDefaults ?? {})[role];
247
262
 
248
263
  /** @type {JsonObject} */
249
264
  const taskPacket = {
@@ -328,8 +343,15 @@ export function validatePlanOutput(plan) {
328
343
  // round could not resolve the finding the preflight had just raised.
329
344
  const scopeAcknowledged = nodeRecord.scopeAcknowledged ?? [];
330
345
  requireStringArray(scopeAcknowledged, `${label}.scopeAcknowledged`);
331
- const definitionOfDone = validateDefinitionOfDone(nodeRecord.definitionOfDone ?? [], `${label}.definitionOfDone`);
346
+ // Verification is validated first so a DoD proof that names a verification
347
+ // command by its exact text (R21) can be checked and normalized to that
348
+ // command's index against this node's own commands, in hand here.
332
349
  const verification = validateVerificationCommands(nodeRecord.verification ?? [], `${label}.verification`);
350
+ const definitionOfDone = validateDefinitionOfDone(
351
+ nodeRecord.definitionOfDone ?? [],
352
+ `${label}.definitionOfDone`,
353
+ { commands: verification, nodeId: /** @type {string} */ (nodeRecord.id) },
354
+ );
333
355
  const expectedTurns = nodeRecord.expectedTurns === undefined ? undefined : positiveInteger(nodeRecord.expectedTurns, `${label}.expectedTurns`);
334
356
  return /** @type {PlanOutputNode} */ ({
335
357
  id: /** @type {string} */ (nodeRecord.id),
@@ -2,8 +2,13 @@ export const SIGNAL_START = "<!-- faberun-active:start (managed by faberun — r
2
2
  export const SIGNAL_END = "<!-- faberun-active:end -->";
3
3
 
4
4
  /**
5
- * Replace only a complete runner-managed block with a stable marker. Guidance
6
- * outside the block remains part of source identity.
5
+ * Remove a complete runner-managed block, markers included, and join the text
6
+ * around it with the blank line the runner writes before it. A file whose
7
+ * commit has no block and whose working copy gained one therefore normalizes
8
+ * to the same text: measured 2026-09-25, keeping the markers made the block
9
+ * the runner appends to a block-less AGENTS.md read as a human edit, and the
10
+ * planner's review stage refused the tree its own draft stage had just
11
+ * written. Guidance outside the block remains part of source identity.
7
12
  *
8
13
  * @param {string} text
9
14
  * @returns {string}
@@ -12,5 +17,7 @@ export function normalizeManagedSignalBlock(text) {
12
17
  const start = text.indexOf(SIGNAL_START);
13
18
  const end = text.indexOf(SIGNAL_END, start + SIGNAL_START.length);
14
19
  if (start < 0 || end < start) return text;
15
- return `${text.slice(0, start)}${SIGNAL_START}\n${SIGNAL_END}${text.slice(end + SIGNAL_END.length)}`;
20
+ const before = text.slice(0, start).trimEnd();
21
+ const after = text.slice(end + SIGNAL_END.length).replace(/^\s+/u, "");
22
+ return after ? `${before}\n\n${after}` : `${before}\n`;
16
23
  }