faberun 0.29.0 → 0.30.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "faberun",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "description": "Faberun is a development orchestration system that turns intent into verified software: harness- and model-agnostic, it keeps the intent, coordinates the work, verifies the result and decides what happens next.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -135,6 +135,33 @@ export function assertTimeoutsCoverMeasured(contract, facts) {
135
135
  if (problems.size) throw new TypeError(`verification timeouts do not cover their measured durations: ${[...problems].join("; ")}`);
136
136
  }
137
137
 
138
+ /**
139
+ * Raise whichever of a command list's timeouts sit under
140
+ * `MEASURED_TIMEOUT_MARGIN` times their measured duration, recording each
141
+ * raise under `label`. Returns the list unchanged (by identity) when nothing
142
+ * needed raising, so the caller can tell whether to re-list.
143
+ *
144
+ * @param {import("../contract/index.mjs").VerificationCommand[]} commands
145
+ * @param {MeasuredFacts} facts
146
+ * @param {string} label
147
+ * @param {string[]} raised
148
+ * @returns {import("../contract/index.mjs").VerificationCommand[]}
149
+ */
150
+ function raiseCommandTimeouts(commands, facts, label, raised) {
151
+ let changed = false;
152
+ const next = commands.map((command) => {
153
+ const measuredMs = measuredMsFor(command.argv, facts);
154
+ if (measuredMs === null) return command;
155
+ const requiredSec = Math.ceil((measuredMs * MEASURED_TIMEOUT_MARGIN) / 1_000);
156
+ const timeoutSec = command.timeoutSec ?? 120;
157
+ if (timeoutSec >= requiredSec || requiredSec > VERIFICATION_LIMITS.maxTimeoutSec) return command;
158
+ raised.push(`${label}: ${command.argv.join(" ")} ${timeoutSec}s -> ${requiredSec}s`);
159
+ changed = true;
160
+ return { ...command, timeoutSec: requiredSec };
161
+ });
162
+ return changed ? next : commands;
163
+ }
164
+
138
165
  /**
139
166
  * Raise every plan node's verification timeout that sits under
140
167
  * `MEASURED_TIMEOUT_MARGIN` times its measured duration to exactly that
@@ -145,6 +172,11 @@ export function assertTimeoutsCoverMeasured(contract, facts) {
145
172
  * plan with no critical from review, contested only because three nodes gave
146
173
  * `node --test test/harnesses` 120s against a measured 84.4s (bound 127s).
147
174
  *
175
+ * The plan's own contract-level suites (RM-107) are raised here too, over the
176
+ * same measurement: `assertTimeoutsCoverMeasured` reads them, so a repair that
177
+ * skipped them would leave the check naming a repair this function does not
178
+ * make. Its label is the suite key, since a contract suite belongs to no node.
179
+ *
148
180
  * @param {import("./template.mjs").PlanOutput} plan
149
181
  * @param {MeasuredFacts} facts
150
182
  * @returns {{plan: import("./template.mjs").PlanOutput, raised: string[]}}
@@ -152,19 +184,22 @@ export function assertTimeoutsCoverMeasured(contract, facts) {
152
184
  export function raiseTimeoutsToMeasured(plan, facts) {
153
185
  /** @type {string[]} */
154
186
  const raised = [];
155
- const nodes = plan.nodes.map((node) => ({
156
- ...node,
157
- verification: (node.verification ?? []).map((command) => {
158
- const measuredMs = measuredMsFor(command.argv, facts);
159
- if (measuredMs === null) return command;
160
- const requiredSec = Math.ceil((measuredMs * MEASURED_TIMEOUT_MARGIN) / 1_000);
161
- const timeoutSec = command.timeoutSec ?? 120;
162
- if (timeoutSec >= requiredSec || requiredSec > VERIFICATION_LIMITS.maxTimeoutSec) return command;
163
- raised.push(`${node.id}: ${command.argv.join(" ")} ${timeoutSec}s -> ${requiredSec}s`);
164
- return { ...command, timeoutSec: requiredSec };
165
- }),
166
- }));
167
- return { plan: raised.length ? { ...plan, nodes } : plan, raised };
187
+ const nodes = plan.nodes.map((node) => {
188
+ const verification = raiseCommandTimeouts(node.verification ?? [], facts, node.id, raised);
189
+ return verification === node.verification ? node : { ...node, verification };
190
+ });
191
+ const sharedVerification = raiseCommandTimeouts(plan.sharedVerification ?? [], facts, "sharedVerification", raised);
192
+ const finalVerification = raiseCommandTimeouts(plan.finalVerification ?? [], facts, "finalVerification", raised);
193
+ if (raised.length === 0) return { plan, raised };
194
+ return {
195
+ plan: {
196
+ ...plan,
197
+ nodes,
198
+ ...(plan.sharedVerification === undefined ? {} : { sharedVerification }),
199
+ ...(plan.finalVerification === undefined ? {} : { finalVerification }),
200
+ },
201
+ raised,
202
+ };
168
203
  }
169
204
 
170
205
  /** @returns {string} the installed package's own version, read once per call so a freeze always names the toolchain that produced it */
@@ -57,7 +57,7 @@ export { droppedWriteFindings, unresolvedFindings } from "./rounds.mjs";
57
57
  /** @typedef {import("./template.mjs").PlanFindingOutput} PlanFindingOutput */
58
58
  /** @typedef {import("./template.mjs").PlanPhase} PlanPhase */
59
59
  /** @typedef {import("./sizing.mjs").PlanNode & {objective: string}} SizedPlanNode */
60
- /** @typedef {{sizing: import("./sizing.mjs").SizingResult, routing: import("./routing.mjs").RoutingResult, nodes: JsonObject[], phases?: PlanPhase[]}} AssembledPlan */
60
+ /** @typedef {{sizing: import("./sizing.mjs").SizingResult, routing: import("./routing.mjs").RoutingResult, nodes: JsonObject[], phases?: PlanPhase[], suites: VerificationSuites}} AssembledPlan */
61
61
  /** @typedef {"standard"|"high"|"none"} ApproveBelow */
62
62
  /** @typedef {(contractPath: string, contract: ValidatedContract) => Promise<void>|void} LaunchFn */
63
63
  /** @typedef {(runtimes: Record<string, JsonObject>, runtimeDefaults: {worker?: string, judge?: string}, cwd: string) => Promise<import("../harnesses/index.mjs").ProbeResult[]>} AskFn */
@@ -352,7 +352,7 @@ async function runPlanningStages(options) {
352
352
  * @param {PlanOutput} currentPlan
353
353
  * @returns {AssembledPlan}
354
354
  */
355
- const assembleFrozenNodes = (currentPlan) => assembleFrozenPlan(currentPlan, { repoFacts, packageMode, targetedFix, phase, cwd, runtimes, runtimeDefaults, judgeIndependence });
355
+ const assembleFrozenNodes = (currentPlan) => assembleFrozenPlan(currentPlan, { repoFacts, packageMode, targetedFix, phase, cwd, runtimes, runtimeDefaults, judgeIndependence, verification });
356
356
 
357
357
  /**
358
358
  * The raw contract exactly as `freezePlan` will assemble and validate it.
@@ -362,7 +362,7 @@ async function runPlanningStages(options) {
362
362
  * @param {AssembledPlan} assembly
363
363
  * @returns {JsonObject}
364
364
  */
365
- const frozenContractRaw = (assembly) => frozenContractRawOf(assembly, { campaignId, phase, campaignGoal: campaign.goal, cwd, plansDir, runtimes, runtimeDefaults, verification, judgeIndependence });
365
+ const frozenContractRaw = (assembly) => frozenContractRawOf(assembly, { campaignId, phase, campaignGoal: campaign.goal, cwd, plansDir, runtimes, runtimeDefaults, judgeIndependence });
366
366
 
367
367
  const roundsResult = await runReviewRounds({
368
368
  reviewRounds, plan, rejectedDraft: plan === null ? draft.output.plan : undefined, findings, cwd, plansDir, scratchDir, workingPlanPath, relativeWorkingPlanPath,
@@ -394,8 +394,10 @@ async function runPlanningStages(options) {
394
394
  // is legitimate, an unnoticed one is not. Observed 2026-09-20 on the first
395
395
  // contract this planner ever froze: it carried neither suite while the same
396
396
  // ratchets, on hand-authored contracts, were catching copied helpers
397
- // mid-run — and nothing said so.
398
- const freezeWarnings = verification.sharedVerification || verification.finalVerification
397
+ // mid-run — and nothing said so. Read off the effective suites, so a plan
398
+ // that authored its own ratchets (RM-107) is not warned about a contract
399
+ // that carries them.
400
+ const freezeWarnings = carriesSuites(effectiveVerificationSuites(verification, plan))
399
401
  ? []
400
402
  : ["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"];
401
403
  try {
@@ -604,6 +606,46 @@ function taggedStageError(planStage, error) {
604
606
  return Object.assign(wrapped, { planStage });
605
607
  }
606
608
 
609
+ /**
610
+ * Which contract-level suites the frozen contract will carry: the operator's
611
+ * `--verification`, when it was passed, otherwise the plan's own authored
612
+ * suites (RM-107). The operator wins because the flag is the one place an
613
+ * operator can still override a plan they did not trust; the plan's suites are
614
+ * the fallback because a phase whose whole point is a repository-wide ratchet
615
+ * must not freeze unratcheted just because nobody remembered the flag —
616
+ * measured 2026-09-27, `safe-to-hand-to-a-friend` phase 1 integrated a red
617
+ * tree no node had a command to catch.
618
+ *
619
+ * An empty array is dropped rather than carried: it runs nothing, and the
620
+ * contract reads its absence identically. One home, read by `assembleFrozenPlan`
621
+ * below and by both freeze-warning sites, so the warning cannot disagree with
622
+ * the contract about whether a ratchet runs.
623
+ *
624
+ * @param {VerificationSuites} operator
625
+ * @param {PlanOutput} plan
626
+ * @returns {VerificationSuites}
627
+ */
628
+ export function effectiveVerificationSuites(operator, plan) {
629
+ const suites = operator.sharedVerification || operator.finalVerification ? operator : plan;
630
+ return {
631
+ ...(suites.sharedVerification?.length ? { sharedVerification: suites.sharedVerification } : {}),
632
+ ...(suites.finalVerification?.length ? { finalVerification: suites.finalVerification } : {}),
633
+ };
634
+ }
635
+
636
+ /**
637
+ * Whether a suite set carries anything at all. Exported beside
638
+ * `effectiveVerificationSuites` because the freeze warning in two modules asks
639
+ * this of its result, and a truthiness test written twice is how the warning
640
+ * and the contract drift apart.
641
+ *
642
+ * @param {VerificationSuites} suites
643
+ * @returns {boolean}
644
+ */
645
+ export function carriesSuites(suites) {
646
+ return Boolean(suites.sharedVerification || suites.finalVerification);
647
+ }
648
+
607
649
  /**
608
650
  * The one assembly a plan freezes through: sizing reshapes the drafted
609
651
  * nodes, routing assigns worker and judge, and `toContractNode` renders the
@@ -613,12 +655,16 @@ function taggedStageError(planStage, error) {
613
655
  * plan came from — a fresh draft/revise or one read back off a contested
614
656
  * `plan.json`.
615
657
  *
658
+ * `ctx.verification` takes part here, not in `frozenContractRawOf`, because
659
+ * which suites the contract carries is a decision the assembly makes once for
660
+ * every caller; the raw contract is then only the assembly rendered.
661
+ *
616
662
  * @param {PlanOutput} currentPlan
617
- * @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}, judgeIndependence?: "same-vendor"}} ctx
663
+ * @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}, judgeIndependence?: "same-vendor", verification?: VerificationSuites}} ctx
618
664
  * @returns {AssembledPlan}
619
665
  */
620
666
  export function assembleFrozenPlan(currentPlan, ctx) {
621
- const { repoFacts, packageMode, targetedFix, phase, cwd, runtimes, runtimeDefaults, judgeIndependence } = ctx;
667
+ const { repoFacts, packageMode, targetedFix, phase, cwd, runtimes, runtimeDefaults, judgeIndependence, verification } = ctx;
622
668
  let sizing;
623
669
  try {
624
670
  sizing = applySizingRules(
@@ -649,6 +695,9 @@ export function assembleFrozenPlan(currentPlan, ctx) {
649
695
  // named it must now name the node that absorbed it or freeze would see
650
696
  // an unknown assignment.
651
697
  phases: carryPhaseDeclarations(currentPlan.phases, sizing.transformations),
698
+ // The plan's own suites survive sizing untouched: they are contract-level
699
+ // proof, so no node merge can absorb or invalidate one.
700
+ suites: effectiveVerificationSuites(verification ?? {}, currentPlan),
652
701
  };
653
702
  }
654
703
 
@@ -661,11 +710,11 @@ export function assembleFrozenPlan(currentPlan, ctx) {
661
710
  * for the same reason.
662
711
  *
663
712
  * @param {AssembledPlan} assembly
664
- * @param {{campaignId: string, phase: string, campaignGoal: string, cwd: string, plansDir: string, runtimes: Record<string, JsonObject>, runtimeDefaults: {worker?: string, judge?: string}, verification: VerificationSuites, judgeIndependence?: "same-vendor"}} ctx
713
+ * @param {{campaignId: string, phase: string, campaignGoal: string, cwd: string, plansDir: string, runtimes: Record<string, JsonObject>, runtimeDefaults: {worker?: string, judge?: string}, judgeIndependence?: "same-vendor"}} ctx
665
714
  * @returns {JsonObject}
666
715
  */
667
- export function frozenContractRawOf({ sizing, nodes }, ctx) {
668
- const { campaignId, phase, campaignGoal, cwd, plansDir, runtimes, runtimeDefaults, verification, judgeIndependence } = ctx;
716
+ export function frozenContractRawOf({ sizing, nodes, suites }, ctx) {
717
+ const { campaignId, phase, campaignGoal, cwd, plansDir, runtimes, runtimeDefaults, judgeIndependence } = ctx;
669
718
  return {
670
719
  schemaVersion: PROTOCOL_SCHEMA_VERSION,
671
720
  contractVersion: CONTRACT_VERSION,
@@ -687,12 +736,14 @@ export function frozenContractRawOf({ sizing, nodes }, ctx) {
687
736
  // The operator's opt-in, carried verbatim: the frozen contract is
688
737
  // validated under the same judge-independence rule its routing used.
689
738
  ...(judgeIndependence === undefined ? {} : { judgeIndependence }),
690
- // The operator's ratchets, carried verbatim: which suites a repository
691
- // runs on every node is the operator's policy, supplied through
692
- // `--verification`, never derived from repository facts. Validated at the
693
- // flag boundary, so freeze neither re-derives nor edits them.
694
- ...(verification.sharedVerification ? { sharedVerification: verification.sharedVerification } : {}),
695
- ...(verification.finalVerification ? { finalVerification: verification.finalVerification } : {}),
739
+ // The ratchets this phase carries, chosen once by the assembly and carried
740
+ // verbatim here: the operator's `--verification` when it was passed,
741
+ // otherwise the suites the plan authored from the repository facts
742
+ // (RM-107). Validated at both boundaries — the flag loader for one, the
743
+ // plan validator for the other — so freeze neither re-derives nor edits
744
+ // them.
745
+ ...(suites?.sharedVerification ? { sharedVerification: suites.sharedVerification } : {}),
746
+ ...(suites?.finalVerification ? { finalVerification: suites.finalVerification } : {}),
696
747
  nodes,
697
748
  };
698
749
  }
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Repo facts: a deterministic, bounded inventory of the target repository —
3
3
  * tracked paths, declared scripts, verification candidates across the Node,
4
- * Python, Go, Rust, Zig and Makefile ecosystems, and which test file covers
5
- * which source module — collected without invoking a model. Every candidate
4
+ * Python, Go, Rust, Zig and Makefile ecosystems, and which source modules each
5
+ * test file covers, by its name and by what it imports or runs — collected
6
+ * without invoking a model. Every candidate
6
7
  * records the manifest it was read from; only the Node candidates are timed,
7
8
  * and no candidate for another ecosystem is ever executed to be found.
8
9
  * A planning stage's draft is authored against exactly this JSON instead of
@@ -20,6 +21,7 @@
20
21
  import { existsSync, readFileSync } from "node:fs";
21
22
  import { join } from "node:path";
22
23
  import { timeVerificationCommands } from "../host/preflight.mjs";
24
+ import { directReferenceGraph } from "../repo/scope-closure.mjs";
23
25
  import { boundedGitSync, gitHead } from "../repo/worktree.mjs";
24
26
  import { runShellCapture } from "./proof-run.mjs";
25
27
 
@@ -45,7 +47,7 @@ import { runShellCapture } from "./proof-run.mjs";
45
47
  *
46
48
  * @typedef {{argv: string[], manifest: string, measuredMs: number|null, eligible: boolean}} DetectedVerificationCandidate
47
49
  */
48
- /** @typedef {{path: string, covers: string|null}} TestFileEntry */
50
+ /** @typedef {{path: string, covers: string[]}} TestFileEntry */
49
51
  /** @typedef {{formatVersion: number, gitHead: string|null, paths: string[], truncated: boolean, scripts: Record<string, string>, verificationCandidates: VerificationCandidate[], detectedVerificationCandidates?: DetectedVerificationCandidate[], testFiles: TestFileEntry[], requirementMeasurements: RequirementMeasurement[]}} RepoFacts */
50
52
 
51
53
  const FORMAT_VERSION = 1;
@@ -95,16 +97,36 @@ function testDirectories(paths) {
95
97
  }
96
98
 
97
99
  /**
100
+ * What each test file covers: the module it is named after, when that module
101
+ * exists, plus every tracked module it imports or runs (RM-109). The naming
102
+ * convention alone was the whole answer, so a test that exercises a module
103
+ * without being named for it — a cross-module integration test, a test that
104
+ * spawns the CLI by URL — was invisible to planning. Measured on
105
+ * `safe-to-hand-to-a-friend`: four failures in 1763 reached the integration
106
+ * branch while every node had passed its own suite.
107
+ *
108
+ * Only tracked paths count, so a stray file in the working tree cannot become
109
+ * part of a plan's verification, and only `test/**\/*.test.mjs` files are
110
+ * entries at all: a helper is covered by the tests that import it, never a
111
+ * verifier itself.
112
+ *
98
113
  * @param {string[]} paths
99
114
  * @param {Set<string>} pathSet
115
+ * @param {Map<string, string[]>} references repo-relative references per file
100
116
  * @returns {TestFileEntry[]}
101
117
  */
102
- function testFileEntries(paths, pathSet) {
118
+ function testFileEntries(paths, pathSet, references) {
103
119
  return paths
104
120
  .filter((path) => path.startsWith("test/") && path.endsWith(".test.mjs"))
105
121
  .map((path) => {
106
- const modulePath = `src/${path.slice("test/".length, -".test.mjs".length)}.mjs`;
107
- return { path, covers: pathSet.has(modulePath) ? modulePath : null };
122
+ /** @type {Set<string>} */
123
+ const covers = new Set();
124
+ const namedAfter = `src/${path.slice("test/".length, -".test.mjs".length)}.mjs`;
125
+ if (pathSet.has(namedAfter)) covers.add(namedAfter);
126
+ for (const referenced of references.get(path) ?? []) {
127
+ if (pathSet.has(referenced)) covers.add(referenced);
128
+ }
129
+ return { path, covers: [...covers].sort() };
108
130
  });
109
131
  }
110
132
 
@@ -303,7 +325,7 @@ export function collectRepoFacts(cwd, options = {}) {
303
325
  // null "no measurement" sentinel freeze.mjs reads and eligibility true.
304
326
  verificationCandidates: measured.map((candidate) => ({ ...candidate, manifest: PACKAGE_MANIFEST })),
305
327
  detectedVerificationCandidates: detected.map((candidate) => ({ ...candidate, measuredMs: null, eligible: true })),
306
- testFiles: testFileEntries(allPaths, pathSet),
328
+ testFiles: testFileEntries(allPaths, pathSet, directReferenceGraph(cwd)),
307
329
  requirementMeasurements: measureRequirements(cwd, options.requirements ?? [], options.measure ?? {}),
308
330
  };
309
331
  }
@@ -26,7 +26,7 @@ import { freezePlan } from "./freeze.mjs";
26
26
  import { collectHumanSteps } from "./human-step.mjs";
27
27
  import { highestOf, modelOf } from "./pipeline-shape.mjs";
28
28
  import { reviewerProvenanceOf } from "./reviewer.mjs";
29
- import { PLANNER_SESSION_ID, assembleFrozenPlan, frozenContractRawOf, invalidPlanFinding, writeFrozenPlan } from "./pipeline.mjs";
29
+ import { PLANNER_SESSION_ID, assembleFrozenPlan, carriesSuites, effectiveVerificationSuites, frozenContractRawOf, invalidPlanFinding, writeFrozenPlan } from "./pipeline.mjs";
30
30
  import { contestPlan } from "./contest.mjs";
31
31
  import { parseSpec } from "./spec.mjs";
32
32
  import { RISK_TIERS } from "./template.mjs";
@@ -132,17 +132,19 @@ export async function resolvePlanningPipeline({ plansDir, cwd, answers }) {
132
132
  }
133
133
  logStage("resolve", { round, answered: criticalFindings.length });
134
134
 
135
- const freezeWarnings = verification.sharedVerification || verification.finalVerification
135
+ // The plan resumed here may carry suites of its own (RM-107), so the
136
+ // warning is read off the same effective set the freeze below will carry.
137
+ const freezeWarnings = carriesSuites(effectiveVerificationSuites(verification ?? {}, plan))
136
138
  ? []
137
139
  : ["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"];
138
140
  let stage = "sizing";
139
141
  try {
140
- const assembled = assembleFrozenPlan(plan, { repoFacts, packageMode, targetedFix, phase, cwd, runtimes, runtimeDefaults, judgeIndependence });
142
+ const assembled = assembleFrozenPlan(plan, { repoFacts, packageMode, targetedFix, phase, cwd, runtimes, runtimeDefaults, judgeIndependence, verification });
141
143
  stage = "freeze";
142
144
  logStage("sizing", { transformations: assembled.sizing.transformations.length, nodeCount: assembled.sizing.plan.nodes.length, overheadMinutes: assembled.sizing.estimate.overheadMinutes });
143
145
  logStage("routing", { assignments: Object.keys(assembled.routing.assignments).length });
144
146
  const highestRiskTier = highestOf(assembled.sizing.plan.nodes.map((node) => node.riskTier ?? RISK_TIERS[0]));
145
- const frozen = freezePlan(frozenContractRawOf(assembled, { campaignId, phase, campaignGoal: campaign.goal, cwd, plansDir, runtimes, runtimeDefaults, verification, judgeIndependence }), {
147
+ const frozen = freezePlan(frozenContractRawOf(assembled, { campaignId, phase, campaignGoal: campaign.goal, cwd, plansDir, runtimes, runtimeDefaults, judgeIndependence }), {
146
148
  outDir: plansDir,
147
149
  phases: assembled.phases,
148
150
  spec: { path: specPath, digest: specDigest },
@@ -27,11 +27,12 @@ import { writeJsonAtomic } from "../run/store.mjs";
27
27
  import { stableJson } from "../util.mjs";
28
28
  import { assertTimeoutsCoverMeasured, raiseTimeoutsToMeasured } from "./freeze.mjs";
29
29
  import { assertFilteredProofsNameTheirTest, declareDirectoryGuards } from "./proof-scope.mjs";
30
- import { validateFindings, validatePlanOutput } from "./template.mjs";
30
+ import { applyPlanPatch, validateFindings, validatePlanOutput } from "./template.mjs";
31
31
 
32
32
  /** @typedef {import("../contract/index.mjs").JsonObject} JsonObject */
33
33
  /** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
34
34
  /** @typedef {import("./template.mjs").PlanOutput} PlanOutput */
35
+ /** @typedef {import("./template.mjs").PlanOutputNode} PlanOutputNode */
35
36
  /** @typedef {import("./template.mjs").PlanFindingOutput} PlanFindingOutput */
36
37
  /** @typedef {import("./template.mjs").PlanningKind} PlanningKind */
37
38
  /** @typedef {import("./contest.mjs").ContestedPipelineResult} ContestedPipelineResult */
@@ -288,21 +289,44 @@ export async function runReviewRounds(options) {
288
289
  // the findings redrafted the plan from scratch, renamed files and dropped
289
290
  // six writes, and its retry fixed one validator error while introducing
290
291
  // another.
291
- const revise = await runStage("revise", {
292
- specPath: relativeSpecPath, repoFactsPath: relativeRepoFactsPath, cataloguePath: relativeCataloguePath, findingsPath: relative(cwd, path), planPath: relativeWorkingPlanPath, packageMode,
293
- });
292
+ //
293
+ // RM-110: a revise returns a patch over the nodes it changes rather than
294
+ // the whole plan, so answering one finding costs the size of that
295
+ // finding's nodes instead of the phase's. Two calls died on the full-plan
296
+ // shape at 65,932 and 66,821 output tokens against the 65,536 ceiling.
297
+ // The patch is only asked for when there is a validated plan to apply it
298
+ // to: a first round whose draft never validated has no node list to patch,
299
+ // and that round's revise returns a whole plan.
300
+ const base = plan;
301
+ const patchMode = base !== null;
302
+ const stageInputs = { specPath: relativeSpecPath, repoFactsPath: relativeRepoFactsPath, cataloguePath: relativeCataloguePath, packageMode, ...(patchMode ? { revisePatch: true } : {}) };
303
+ const revise = await runStage("revise", { ...stageInputs, findingsPath: relative(cwd, path), planPath: relativeWorkingPlanPath });
304
+ /**
305
+ * The plan a revise's output produced, before any validation: the merged
306
+ * patch when the reviser patched a plan, its own `output.plan` when there
307
+ * was no plan to patch. Null only when the merge itself was refused, so
308
+ * the retry falls back to the plan the first attempt read.
309
+ *
310
+ * @param {unknown} onto
311
+ * @param {Record<string, unknown>} output
312
+ * @returns {unknown}
313
+ */
314
+ const revisionFrom = (onto, output) => (patchMode ? applyPlanPatch(/** @type {{nodes: PlanOutputNode[]} & Record<string, unknown>} */ (onto), output.patch) : output.plan);
294
315
  // The whole deterministic check R14 names runs on the revise's output:
295
316
  // the shape validator, and the freeze pre-flight a review round would
296
317
  // otherwise only reach a round later. Measured 2026-09-26 on the 3a gate:
297
318
  // four rounds with no critical from review each contested on a
298
319
  // scope-closure gap the same revise had just opened, because the revise
299
320
  // learned of it only after the next review had spent its round.
321
+ /** @type {{nodes: PlanOutputNode[]} & Record<string, unknown>|null} */
322
+ let revision = null;
300
323
  /** @type {PlanOutput|null} */
301
324
  let structurallyValid = null;
302
325
  /** @type {PlanFindingOutput} */
303
326
  let firstInvalid;
304
327
  try {
305
- structurallyValid = validatePlanOutput(revise.output.plan);
328
+ revision = /** @type {{nodes: PlanOutputNode[]} & Record<string, unknown>} */ (revisionFrom(base, revise.output));
329
+ structurallyValid = validatePlanOutput(revision);
306
330
  const preflightError = freezePreflightError(structurallyValid);
307
331
  if (preflightError === null) return { plan: structurallyValid, runId: revise.contract.id, firstInvalid: null };
308
332
  firstInvalid = invalidPlanFinding(`revise-r${round}-attempt1`, preflightError);
@@ -311,18 +335,18 @@ export async function runReviewRounds(options) {
311
335
  }
312
336
  const retryPath = join(scratchDir, `findings-round-${round}-retry.json`);
313
337
  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
338
+ // The retry repairs the revision the check refused, not the plan before
339
+ // it: that revision already carries this round's resolutions, and only the
316
340
  // check's message is left to answer.
317
341
  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
- });
342
+ writeJsonAtomic(retryPlanPath, revision ?? base);
343
+ const retry = await runStage("revise", { ...stageInputs, findingsPath: relative(cwd, retryPath), planPath: relative(cwd, retryPlanPath) });
322
344
  try {
323
345
  // 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 };
346
+ // still refuses it: that round's own pre-flight raises it again. Its
347
+ // patch applies to the revision the first attempt produced, which is
348
+ // the plan the retry was handed.
349
+ return { plan: validatePlanOutput(revisionFrom(revision ?? base, retry.output)), runId: retry.contract.id, firstInvalid };
326
350
  } catch (secondError) {
327
351
  // The first attempt still validated: it goes to review rather than
328
352
  // contesting a round over a retry that made things worse.
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { dirname } from "node:path";
14
+ import { VERIFICATION_LIMITS } from "../contract/verification.mjs";
14
15
 
15
16
  /** @typedef {import("../contract/definition-of-done.mjs").DefinitionOfDoneItem} DefinitionOfDoneItem */
16
17
 
@@ -18,7 +19,7 @@ import { dirname } from "node:path";
18
19
  /** @typedef {{writeFiles?: string[], writeRoots?: string[], verification: SizingVerificationCommand[], [key: string]: unknown}} SizingTaskPacket */
19
20
  /** @typedef {{id: string, dependsOn?: string[], taskKind?: string, riskTier?: string, taskPacket: SizingTaskPacket, definitionOfDone?: DefinitionOfDoneItem[], parallel?: boolean, [key: string]: unknown}} PlanNode */
20
21
  /** @typedef {{nodes: PlanNode[], justification?: string, [key: string]: unknown}} Plan */
21
- /** @typedef {{path: string, covers: string|null}} SizingTestFileEntry */
22
+ /** @typedef {{path: string, covers: string[]}} SizingTestFileEntry */
22
23
  /** @typedef {{testFiles?: SizingTestFileEntry[]}} SizingFacts */
23
24
  /** @typedef {"implementation"|"exploratory"} PackageMode */
24
25
  /** @typedef {{nodeBudgetMs: number, targetedFix?: boolean, facts?: SizingFacts, minWriteFiles?: number, maxMergedWriteFiles?: number, turnCeiling?: number, packageMode?: PackageMode, readVolume?: (path: string) => number|null}} SizingOptions */
@@ -374,6 +375,13 @@ function flagOverTurnCeiling(nodes, ceiling, transformations) {
374
375
  * place instead (a repeat pass never re-flags an already-flagged command, nor
375
376
  * re-splits a command that no longer carries a measured duration).
376
377
  *
378
+ * A covering set larger than the contract's own command budget (RM-109) is
379
+ * flagged, not split: a module almost every test references has that many
380
+ * covering files because the change is genuinely wide, and replacing one
381
+ * command with more than `maxCommands` of them freezes a contract that cannot
382
+ * validate. The flag names the count, and the answer for that shape is the
383
+ * phase-terminal suite (`finalVerification`), which runs the whole tree once.
384
+ *
377
385
  * @param {PlanNode[]} nodes
378
386
  * @param {number} nodeBudgetMs
379
387
  * @param {SizingFacts} facts
@@ -393,8 +401,9 @@ function splitOverBudgetVerification(nodes, nodeBudgetMs, facts, transformations
393
401
  verification.push(command);
394
402
  continue;
395
403
  }
396
- const candidates = dedupe(testFiles.filter((file) => file.covers !== null && writeFiles.includes(file.covers)).map((file) => file.path));
397
- if (candidates.length > 0) {
404
+ const candidates = dedupe(testFiles.filter((file) => coversSomething(file, writeFiles)).map((file) => file.path));
405
+ const overflow = candidates.length > VERIFICATION_LIMITS.maxCommands;
406
+ if (candidates.length > 0 && !overflow) {
398
407
  transformations.push({
399
408
  rule: "over-budget-verification-split",
400
409
  nodes: [node.id],
@@ -406,7 +415,9 @@ function splitOverBudgetVerification(nodes, nodeBudgetMs, facts, transformations
406
415
  transformations.push({
407
416
  rule: "over-budget-verification-flagged",
408
417
  nodes: [node.id],
409
- detail: `${command.argv.join(" ")} measured ${command.measuredMs}ms over the ${nodeBudgetMs}ms budget; no repo-facts test file covers this node's writeFiles`,
418
+ detail: overflow
419
+ ? `${command.argv.join(" ")} measured ${command.measuredMs}ms over the ${nodeBudgetMs}ms budget; ${candidates.length} test files reference this node's writeFiles, above the contract's ${VERIFICATION_LIMITS.maxCommands}-command budget: prove them once on the phase-terminal node instead of naming each here`
420
+ : `${command.argv.join(" ")} measured ${command.measuredMs}ms over the ${nodeBudgetMs}ms budget; no repo-facts test file covers this node's writeFiles`,
410
421
  });
411
422
  changed = true;
412
423
  verification.push({ ...command, flaggedOverBudget: true });
@@ -416,6 +427,11 @@ function splitOverBudgetVerification(nodes, nodeBudgetMs, facts, transformations
416
427
  });
417
428
  }
418
429
 
430
+ /** @param {SizingTestFileEntry} file @param {string[]} writeFiles @returns {boolean} */
431
+ function coversSomething(file, writeFiles) {
432
+ return file.covers.some((module) => writeFiles.includes(module));
433
+ }
434
+
419
435
  /**
420
436
  * A node with no dependsOn, that no other node depends on, and whose
421
437
  * writeFiles shares nothing with any other node's, may run in parallel with
@@ -16,18 +16,24 @@
16
16
  * refusal. A legacy declaration that names no requirements and no nodes is
17
17
  * still reported as a finding, never a silent pass. freeze.mjs imports the
18
18
  * same phase check for the frozen plan record.
19
+ *
20
+ * It also validates the two contract-level suites a plan may author of its own
21
+ * (RM-107): `sharedVerification` and `finalVerification`, the same schema the
22
+ * contract carries, so the phase's repository-wide proof is planned rather
23
+ * than left to an operator remembering `--verification`.
19
24
  */
20
25
  import { assertObject, positiveInteger, rejectUnknown, requireId, requireString, requireStringArray } from "../contract/assert.mjs";
21
26
  import { CONTRACT_VERSION, PROTOCOL_SCHEMA_VERSION } from "../contract/index.mjs";
22
27
  import { validateDefinitionOfDone } from "../contract/definition-of-done.mjs";
28
+ import { validateFinalVerification, validateSharedVerification } from "../contract/final-verification.mjs";
23
29
  import { validateVerificationCommands } from "../contract/verification.mjs";
24
30
 
25
31
  /** @typedef {import("../contract/index.mjs").JsonObject} JsonObject */
26
32
  /** @typedef {"draft"|"review"|"revise"|"spec-author"|"spec-review"} PlanningKind */
27
33
  /** @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}, reviewerId?: string, specPath?: string, repoFactsPath?: string, cataloguePath?: string, packageMode?: import("./sizing.mjs").PackageMode, planPath?: string, findingsPath?: string, notesPath?: string}} PlanningContractInputs */
34
+ /** @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, revisePatch?: boolean}} PlanningContractInputs */
29
35
  /** @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
- /** @typedef {{nodes: PlanOutputNode[], phases?: PlanPhase[], findings?: PlanFindingOutput[], justification?: string}} PlanOutput */
36
+ /** @typedef {{nodes: PlanOutputNode[], phases?: PlanPhase[], sharedVerification?: import("../contract/verification.mjs").VerificationCommand[], finalVerification?: import("../contract/verification.mjs").VerificationCommand[], findings?: PlanFindingOutput[], justification?: string}} PlanOutput */
31
37
  /** @typedef {{id: string, requirementIds: string[], nodeIds?: string[], deliverable: string}} PlanPhase */
32
38
  /** @typedef {{id: string, severity: "critical"|"major"|"minor", nodeId: string, text: string}} PlanFindingOutput */
33
39
 
@@ -115,7 +121,18 @@ const REQUIRED_INPUTS = Object.freeze({
115
121
  // run had already succeeded. The id charset is requireId's
116
122
  // (contract/assert.mjs) verbatim, because an id that is present but invalid
117
123
  // fails that same validator just as late.
118
- const PLAN_OUTPUT_SHAPE = '{nodes: [{id, objective, taskKind, riskTier, dependsOn, readFiles, writeFiles, scopeAcknowledged, definitionOfDone: [{id, text, proof?: {kind: "command"|"path"|"verification", ref}, judgment?: true, reason?: string}], verification: [{argv: [string], cwd?, timeoutSec?, repeat?, env?, mutation?: {threshold}}], expectedTurns?}], phases?: [{id, requirementIds: [string], nodeIds: [string], deliverable}], justification?}; every id in it (node, phase, node assignment, and definitionOfDone item) must match [A-Za-z0-9._-]+ and never be exactly "." or ".."';
124
+ const PLAN_NODE_SHAPE = '{id, objective, taskKind, riskTier, dependsOn, readFiles, writeFiles, scopeAcknowledged, definitionOfDone: [{id, text, proof?: {kind: "command"|"path"|"verification", ref}, judgment?: true, reason?: string}], verification: [{argv: [string], cwd?, timeoutSec?, repeat?, env?, mutation?: {threshold}}], expectedTurns?}';
125
+ const PLAN_PHASE_SHAPE = '[{id, requirementIds: [string], nodeIds: [string], deliverable}]';
126
+ const PLAN_SUITE_SHAPE = '[{argv: [string], cwd?, timeoutSec?, repeat?, env?, mutation?: {threshold}}]';
127
+ const ID_CHARSET_RULE = 'every id in it (node, phase, node assignment, and definitionOfDone item) must match [A-Za-z0-9._-]+ and never be exactly "." or ".."';
128
+ const PLAN_OUTPUT_SHAPE = `{nodes: [${PLAN_NODE_SHAPE}], phases?: ${PLAN_PHASE_SHAPE}, sharedVerification?: ${PLAN_SUITE_SHAPE}, finalVerification?: ${PLAN_SUITE_SHAPE}, justification?}; ${ID_CHARSET_RULE}`;
129
+ /**
130
+ * What a revise writes instead of the plan again (RM-110). Every node this
131
+ * names is a complete node, because the plan it patches is in the reviser's
132
+ * readFiles and the patch says only what changes: the node shape is
133
+ * `PLAN_NODE_SHAPE`, one home for both strings so they cannot drift.
134
+ */
135
+ const REVISE_PATCH_SHAPE = `{nodes?: [${PLAN_NODE_SHAPE}], removedNodeIds?: [string], phases?: ${PLAN_PHASE_SHAPE}, sharedVerification?: ${PLAN_SUITE_SHAPE}, finalVerification?: ${PLAN_SUITE_SHAPE}, justification?}; ${ID_CHARSET_RULE}`;
119
136
  /**
120
137
  * The size guidance every draft and revise carries. measured 2026-09-20 over
121
138
  * stored runs: median 49 provider requests per worker turn; cost per turn
@@ -135,6 +152,17 @@ const SIZING_INSTRUCTION = "Size nodes to 4 to 6 write files where the work allo
135
152
  const EXPLORATORY_SIZING_INSTRUCTION = "Size nodes by what each must read and by risk, never by what it writes: one write file is the normal shape for a finding, a review or an audit. Give every node an expectedTurns (the provider requests one worker needs end to end), keep the read volume of the nodes within the same order of each other so one does not cost several times its siblings, and split a node you expect past 150 requests, because a run cuts an attempt there.";
136
153
  const FINDINGS_SHAPE = "[{id, severity, nodeId, text}]";
137
154
 
155
+ /**
156
+ * The two shapes a revise can be asked for, swapped by `instructionsFor`.
157
+ * Which one is in force is decided by `inputs.revisePatch`, never by the
158
+ * worker: the plan in readFiles is a validated plan to patch only when the
159
+ * pipeline has one, and a draft that never validated is the case where it does
160
+ * not (`rounds.mjs` starts a round with `plan: null` and the draft's own
161
+ * rejected output).
162
+ */
163
+ const REVISE_PLAN_INSTRUCTION = `Return exactly one worker-result JSON object. Put the revised plan in output.plan as ${PLAN_OUTPUT_SHAPE} and nothing else in output.`;
164
+ const REVISE_PATCH_INSTRUCTION = `Return exactly one worker-result JSON object. Put the revision in output.patch as ${REVISE_PATCH_SHAPE} and nothing else in output. The patch applies to the plan JSON in readFiles: a node whose id that plan already has replaces it, a new id adds a node, removedNodeIds names each node the plan must no longer have, and a field you omit keeps the value the plan already has. Carry only the nodes and fields a finding requires — a revise that omits what a finding names has not answered it.`;
165
+
138
166
  /**
139
167
  * The instruction list is a frozen table because it is the same for every
140
168
  * campaign; only the sizing sentence depends on what kind of package this is.
@@ -144,8 +172,11 @@ const FINDINGS_SHAPE = "[{id, severity, nodeId, text}]";
144
172
  * @returns {string[]}
145
173
  */
146
174
  function instructionsFor(kind, inputs) {
147
- if (inputs.packageMode !== "exploratory") return INSTRUCTIONS[kind];
148
- return INSTRUCTIONS[kind].map((line) => (line === SIZING_INSTRUCTION ? EXPLORATORY_SIZING_INSTRUCTION : line));
175
+ const lines = kind !== "revise" || inputs.revisePatch !== true
176
+ ? INSTRUCTIONS[kind]
177
+ : INSTRUCTIONS[kind].map((line) => (line === REVISE_PLAN_INSTRUCTION ? REVISE_PATCH_INSTRUCTION : line));
178
+ if (inputs.packageMode !== "exploratory") return lines;
179
+ return lines.map((line) => (line === SIZING_INSTRUCTION ? EXPLORATORY_SIZING_INSTRUCTION : line));
149
180
  }
150
181
 
151
182
  // AP1 of safe-to-hand-to-a-friend, measured 2026-09-26: a draft copied the
@@ -164,6 +195,29 @@ const PROOF_NOT_STRICTER_THAN_REQUIREMENT_RULE = "For every node, compare each c
164
195
 
165
196
  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.';
166
197
 
198
+ // RM-109, measured 2026-09-27 on `safe-to-hand-to-a-friend`: the naming
199
+ // convention alone decided a node's test file, so a node whose module was
200
+ // exercised by a test named after something else passed its own suite and
201
+ // still left four failures for the integration branch (1763 tests, 4 failures,
202
+ // phases 3/4/5/3r8). repo-facts.json's testFiles carries what each test file
203
+ // references, so the planner names the files that can actually break.
204
+ const COVERING_TEST_RULE = 'repo-facts.json\'s testFiles lists, per test file, the repo-relative modules it is named after and the ones it imports or runs. A node\'s verification names every test file whose covers intersects its writeFiles, not only the one named after the module: node --test <file>, one per covering file, and the enclosing test directory when that set is larger than one verification array holds. A test file that references nothing the node writes is not named there; those are the phase\'s finalVerification, which runs the whole suite once when the phase closes.';
205
+
206
+ // RM-107, measured 2026-09-27 on `safe-to-hand-to-a-friend` phase 1: the phase
207
+ // integrated a red tree -- src/host/preflight.mjs at 840 lines against the 800
208
+ // ceiling, declaredEnvironment exported by seven modules, three test files
209
+ // whose first import was not scoped-home.mjs, one zcode assertion falsified --
210
+ // and no node had run test/repo/source-shape.test.mjs (0.36 s measured).
211
+ // Contract-level verification was operator-only, so a phase whose whole point
212
+ // was a repository-wide ratchet could freeze without one and look fully
213
+ // verified. The planner reads the measured candidates, so it is the one that
214
+ // proposes them here.
215
+ const CONTRACT_VERIFICATION_RULE = Object.freeze([
216
+ "A plan may carry two suites of its own, over and above the per-node verification: output.plan.sharedVerification, whose commands run on every node of the phase, and output.plan.finalVerification, whose commands run once, when the phase closes. Both take exactly a node's verification-command shape. Use them for the proof no single node owns: a repository-wide conformance or structural suite, in sharedVerification, because any node can break one, and the target's whole suite, in finalVerification, because only the last node can run it.",
217
+ "Every command in either suite must be an argv repo-facts.json measured (verificationCandidates includes each measured argv with its measuredMs), copied verbatim, with a timeoutSec of at least twice that measuredMs. A command the facts did not measure is written from memory, not measurement, and is the shape this rule exists to stop. Omit a suite the facts measured nothing for; a plan for a repository with no ratchets carries neither.",
218
+ "A command that already runs in a suite is not repeated in a node's verification, and a node's own verification is never replaced by a suite.",
219
+ ]);
220
+
167
221
  // The rule every planned packet is held to at freeze time, worded from
168
222
  // AGENTS.md's Faberun protocol and src/repo/scope-closure.mjs ("reading it
169
223
  // cannot fix it"): a first draft that ignores it produces a plan that fails
@@ -190,17 +244,21 @@ const INSTRUCTIONS = Object.freeze({
190
244
  "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.",
191
245
  ...SCOPE_CLOSURE_RULE,
192
246
  NAMED_TEST_FILE_RULE,
247
+ COVERING_TEST_RULE,
248
+ ...CONTRACT_VERIFICATION_RULE,
193
249
  `Return exactly one worker-result JSON object. Put the plan in output.plan as ${PLAN_OUTPUT_SHAPE} and nothing else in output.`,
194
250
  "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.",
195
251
  SIZING_INSTRUCTION,
196
252
  ],
197
253
  revise: [
198
- "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.",
254
+ "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, definitionOfDone item and contract-level suite that no finding asks you to change: a revise that redrafts from the findings alone loses what the plan already got right.",
199
255
  "Read the findings and resolve every one; do not leave a critical or major finding unaddressed.",
200
256
  "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.",
201
257
  ...SCOPE_CLOSURE_RULE,
202
258
  NAMED_TEST_FILE_RULE,
203
- `Return exactly one worker-result JSON object. Put the revised plan in output.plan as ${PLAN_OUTPUT_SHAPE} and nothing else in output.`,
259
+ COVERING_TEST_RULE,
260
+ ...CONTRACT_VERIFICATION_RULE,
261
+ REVISE_PLAN_INSTRUCTION,
204
262
  "Never name a runtime, harness, model, or vendor anywhere in output.plan.",
205
263
  SIZING_INSTRUCTION,
206
264
  ],
@@ -327,45 +385,42 @@ export function buildPlanningContract(kind, inputs) {
327
385
  };
328
386
  }
329
387
 
330
- const PLAN_FIELDS = new Set(["nodes", "phases", "justification"]);
388
+ const PLAN_FIELDS = new Set(["nodes", "phases", "sharedVerification", "finalVerification", "justification"]);
389
+ /** A patch's fields: the plan's own, minus `nodes`, plus the ids it removes. */
390
+ const PATCH_FIELDS = new Set(["nodes", "removedNodeIds", "phases", "sharedVerification", "finalVerification", "justification"]);
331
391
  const PLAN_NODE_FIELDS = new Set(["id", "objective", "taskKind", "riskTier", "dependsOn", "readFiles", "writeFiles", "scopeAcknowledged", "definitionOfDone", "verification", "expectedTurns"]);
332
392
 
333
393
  /**
334
- * Validate a draft or revise worker's `output.plan`. Rejects a node naming a
335
- * runtime, harness, model, or vendor (an unknown field, since a plan node's
336
- * shape never includes one) and a node missing taskKind or riskTier. A
337
- * declared phase associated with no requirement is reported as a finding on
338
- * the result, never a silent pass and never a refusal.
394
+ * The node list of a plan, or of a revise's `output.patch`, under one
395
+ * validator: a node's shape is the same in both, so a patch node is refused
396
+ * for exactly what a plan node is. `label` is the caller's own field path, so
397
+ * the message a worker reads names the field it actually wrote.
339
398
  *
340
- * @param {unknown} plan
341
- * @returns {PlanOutput}
399
+ * @param {unknown} value
400
+ * @param {string} label
401
+ * @returns {PlanOutputNode[]}
342
402
  */
343
- export function validatePlanOutput(plan) {
344
- assertObject(plan, "plan");
345
- const record = /** @type {Record<string, unknown>} */ (plan);
346
- rejectUnknown(record, PLAN_FIELDS, "plan");
347
- if (!Array.isArray(record.nodes) || record.nodes.length === 0) {
348
- throw new TypeError("plan.nodes must be a non-empty array");
349
- }
350
- const nodes = record.nodes.map((node, index) => {
351
- const label = `plan.nodes[${index}]`;
352
- assertObject(node, label);
403
+ function validatePlanNodes(value, label) {
404
+ if (!Array.isArray(value)) throw new TypeError(`${label} must be an array`);
405
+ return value.map((node, index) => {
406
+ const nodeLabel = `${label}[${index}]`;
407
+ assertObject(node, nodeLabel);
353
408
  const nodeRecord = /** @type {Record<string, unknown>} */ (node);
354
- rejectUnknown(nodeRecord, PLAN_NODE_FIELDS, label);
355
- requireId(nodeRecord.id, `${label}.id`);
356
- requireString(nodeRecord.objective, `${label}.objective`);
409
+ rejectUnknown(nodeRecord, PLAN_NODE_FIELDS, nodeLabel);
410
+ requireId(nodeRecord.id, `${nodeLabel}.id`);
411
+ requireString(nodeRecord.objective, `${nodeLabel}.objective`);
357
412
  if (typeof nodeRecord.taskKind !== "string" || !TASK_KINDS.includes(nodeRecord.taskKind)) {
358
- throw new TypeError(`${label}.taskKind must be one of ${TASK_KINDS.join(", ")}`);
413
+ throw new TypeError(`${nodeLabel}.taskKind must be one of ${TASK_KINDS.join(", ")}`);
359
414
  }
360
415
  if (typeof nodeRecord.riskTier !== "string" || !RISK_TIERS.includes(nodeRecord.riskTier)) {
361
- throw new TypeError(`${label}.riskTier must be one of ${RISK_TIERS.join(", ")}`);
416
+ throw new TypeError(`${nodeLabel}.riskTier must be one of ${RISK_TIERS.join(", ")}`);
362
417
  }
363
418
  const dependsOn = nodeRecord.dependsOn ?? [];
364
- requireStringArray(dependsOn, `${label}.dependsOn`);
419
+ requireStringArray(dependsOn, `${nodeLabel}.dependsOn`);
365
420
  const readFiles = nodeRecord.readFiles ?? [];
366
- requireStringArray(readFiles, `${label}.readFiles`);
421
+ requireStringArray(readFiles, `${nodeLabel}.readFiles`);
367
422
  const writeFiles = nodeRecord.writeFiles ?? [];
368
- requireStringArray(writeFiles, `${label}.writeFiles`);
423
+ requireStringArray(writeFiles, `${nodeLabel}.writeFiles`);
369
424
  // A node that reaches an importer it must not change says so here. Without
370
425
  // this field the drafter is told (INSTRUCTIONS, draft/revise) to answer a
371
426
  // scope-closure finding with `writeFiles or scopeAcknowledged` and has no
@@ -373,17 +428,17 @@ export function validatePlanOutput(plan) {
373
428
  // declaring a read-only importer writable. Measured 2026-09-20 -- a revise
374
429
  // round could not resolve the finding the preflight had just raised.
375
430
  const scopeAcknowledged = nodeRecord.scopeAcknowledged ?? [];
376
- requireStringArray(scopeAcknowledged, `${label}.scopeAcknowledged`);
431
+ requireStringArray(scopeAcknowledged, `${nodeLabel}.scopeAcknowledged`);
377
432
  // Verification is validated first so a DoD proof that names a verification
378
433
  // command by its exact text (R21) can be checked and normalized to that
379
434
  // command's index against this node's own commands, in hand here.
380
- const verification = validateVerificationCommands(nodeRecord.verification ?? [], `${label}.verification`);
435
+ const verification = validateVerificationCommands(nodeRecord.verification ?? [], `${nodeLabel}.verification`);
381
436
  const definitionOfDone = validateDefinitionOfDone(
382
437
  nodeRecord.definitionOfDone ?? [],
383
- `${label}.definitionOfDone`,
438
+ `${nodeLabel}.definitionOfDone`,
384
439
  { commands: verification, nodeId: /** @type {string} */ (nodeRecord.id) },
385
440
  );
386
- const expectedTurns = nodeRecord.expectedTurns === undefined ? undefined : positiveInteger(nodeRecord.expectedTurns, `${label}.expectedTurns`);
441
+ const expectedTurns = nodeRecord.expectedTurns === undefined ? undefined : positiveInteger(nodeRecord.expectedTurns, `${nodeLabel}.expectedTurns`);
387
442
  return /** @type {PlanOutputNode} */ ({
388
443
  id: /** @type {string} */ (nodeRecord.id),
389
444
  objective: /** @type {string} */ (nodeRecord.objective),
@@ -398,7 +453,33 @@ export function validatePlanOutput(plan) {
398
453
  ...(expectedTurns === undefined ? {} : { expectedTurns }),
399
454
  });
400
455
  });
456
+ }
457
+
458
+ /**
459
+ * Validate a draft's `output.plan`. Rejects a node naming a
460
+ * runtime, harness, model, or vendor (an unknown field, since a plan node's
461
+ * shape never includes one) and a node missing taskKind or riskTier. A
462
+ * declared phase associated with no requirement is reported as a finding on
463
+ * the result, never a silent pass and never a refusal.
464
+ *
465
+ * @param {unknown} plan
466
+ * @returns {PlanOutput}
467
+ */
468
+ export function validatePlanOutput(plan) {
469
+ assertObject(plan, "plan");
470
+ const record = /** @type {Record<string, unknown>} */ (plan);
471
+ rejectUnknown(record, PLAN_FIELDS, "plan");
472
+ if (!Array.isArray(record.nodes) || record.nodes.length === 0) {
473
+ throw new TypeError("plan.nodes must be a non-empty array");
474
+ }
475
+ const nodes = validatePlanNodes(record.nodes, "plan.nodes");
401
476
  if (record.justification !== undefined) requireString(record.justification, "plan.justification");
477
+ // The two contract-level suites, under the validators the contract itself
478
+ // applies, so a plan cannot author a suite the freeze would then refuse. An
479
+ // empty array stays legal here and means what absence means; the assembly
480
+ // (`effectiveVerificationSuites`) is what drops it before the contract.
481
+ const sharedVerification = validateSharedVerification(record.sharedVerification, "plan.sharedVerification");
482
+ const finalVerification = validateFinalVerification(record.finalVerification, "plan.finalVerification");
402
483
  const phases = validatePlanPhases(record.phases, nodes.map((node) => node.id));
403
484
  const findings = (phases ?? [])
404
485
  .filter((phase) => phase.requirementIds.length === 0)
@@ -414,11 +495,66 @@ export function validatePlanOutput(plan) {
414
495
  return {
415
496
  nodes,
416
497
  ...(phases === undefined ? {} : { phases }),
498
+ ...(sharedVerification === undefined ? {} : { sharedVerification }),
499
+ ...(finalVerification === undefined ? {} : { finalVerification }),
417
500
  ...(findings.length > 0 ? { findings } : {}),
418
501
  ...(record.justification === undefined ? {} : { justification: /** @type {string} */ (record.justification) }),
419
502
  };
420
503
  }
421
504
 
505
+ /**
506
+ * The plan a revise's `output.patch` produces, from the plan the reviser read
507
+ * (RM-110). Measured 2026-09-27 on `safe-to-hand-to-a-friend`: two revise
508
+ * calls died as `prompt_failed` at 65,932 and 66,821 output tokens against the
509
+ * 65,536 ceiling — a revise that must reproduce every node it is not changing
510
+ * pays the whole plan's size for a change to one of them, after the draft and
511
+ * a review round have already been paid for.
512
+ *
513
+ * The merge is deterministic and total: a patch node whose id the plan has
514
+ * replaces it in place (so the plan's node order, which its phases and its
515
+ * working file both read, survives a revise that changes nothing), a new id
516
+ * appends, `removedNodeIds` deletes by name, and a plan-level field the patch
517
+ * omits keeps the plan's own value. Every node in the result is one the
518
+ * validator has seen once already, so the caller's `validatePlanOutput` on the
519
+ * returned record is the same check a draft's output gets, not a weaker one.
520
+ *
521
+ * Three shapes are refused here rather than merged: a node id named twice in
522
+ * the patch, an id removed that the plan does not have (a typo the reviser
523
+ * would otherwise never hear about), and an id in both lists.
524
+ *
525
+ * @param {{nodes: PlanOutputNode[]} & Record<string, unknown>} previous the plan the reviser was handed
526
+ * @param {unknown} patch a revise worker's `output.patch`
527
+ * @returns {{nodes: PlanOutputNode[]} & Record<string, unknown>} the merged plan record, for `validatePlanOutput`
528
+ */
529
+ export function applyPlanPatch(previous, patch) {
530
+ assertObject(patch, "patch");
531
+ const record = /** @type {Record<string, unknown>} */ (patch);
532
+ rejectUnknown(record, PATCH_FIELDS, "patch");
533
+ const nodes = record.nodes === undefined ? [] : validatePlanNodes(record.nodes, "patch.nodes");
534
+ const removedNodeIds = /** @type {string[]} */ (record.removedNodeIds ?? []);
535
+ requireStringArray(removedNodeIds, "patch.removedNodeIds");
536
+ const previousIds = new Set(previous.nodes.map((node) => node.id));
537
+ const replacements = new Map(nodes.map((node) => [node.id, node]));
538
+ if (replacements.size !== nodes.length) throw new TypeError("patch.nodes names the same node id twice");
539
+ for (const id of removedNodeIds) {
540
+ if (!previousIds.has(id)) throw new TypeError(`patch.removedNodeIds names ${id}, which the plan it revises does not have`);
541
+ if (replacements.has(id)) throw new TypeError(`patch names ${id} in both nodes and removedNodeIds: a node is replaced or removed, never both`);
542
+ }
543
+ const removed = new Set(removedNodeIds);
544
+ /** @type {{nodes: PlanOutputNode[]} & Record<string, unknown>} */
545
+ const merged = {
546
+ nodes: [
547
+ ...previous.nodes.filter((node) => !removed.has(node.id)).map((node) => replacements.get(node.id) ?? node),
548
+ ...nodes.filter((node) => !previousIds.has(node.id)),
549
+ ],
550
+ };
551
+ for (const field of ["phases", "sharedVerification", "finalVerification", "justification"]) {
552
+ const value = record[field] ?? previous[field];
553
+ if (value !== undefined) merged[field] = value;
554
+ }
555
+ return merged;
556
+ }
557
+
422
558
  // The fields a phase declaration carries beyond its id: requirementIds|nodeIds|deliverable —
423
559
  // which spec requirements the phase satisfies, which planned nodes it assigns, and the one
424
560
  // sentence naming its result.
@@ -103,6 +103,28 @@ export function runtimeImportGraph(root) {
103
103
  return graph;
104
104
  }
105
105
 
106
+ /**
107
+ * The repo-relative paths each of the repository's own modules points at: the
108
+ * relative modules it imports and the ones it runs through
109
+ * `new URL(…, import.meta.url)`. Wider than `runtimeImportGraph`'s edges on
110
+ * purpose — that one answers "does this import cycle at load time", where a
111
+ * JSDoc reference and a spawned program are both absent; this one answers
112
+ * "which files does this file exercise", where a test that spawns a program
113
+ * counts exactly like one that imports it. RM-109: `plan/repo-facts.mjs` reads
114
+ * it to name the test files a planned write set touches.
115
+ *
116
+ * @param {string} root absolute directory to walk
117
+ * @returns {Map<string, string[]>} repo-relative path to its references, sorted
118
+ */
119
+ export function directReferenceGraph(root) {
120
+ /** @type {Map<string, string[]>} */
121
+ const graph = new Map();
122
+ for (const [path, source] of repositorySources(root)) {
123
+ graph.set(path, [...referencePaths(source, root)].sort());
124
+ }
125
+ return graph;
126
+ }
127
+
106
128
  /**
107
129
  * Files a node's declared writes drag into the same change, minus those the
108
130
  * packet already declares or dismisses.