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 +1 -1
- package/src/plan/freeze.mjs +48 -13
- package/src/plan/pipeline.mjs +67 -16
- package/src/plan/repo-facts.mjs +29 -7
- package/src/plan/resolve.mjs +6 -4
- package/src/plan/rounds.mjs +37 -13
- package/src/plan/sizing.mjs +20 -4
- package/src/plan/template.mjs +173 -37
- package/src/repo/scope-closure.mjs +22 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "faberun",
|
|
3
|
-
"version": "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": {
|
package/src/plan/freeze.mjs
CHANGED
|
@@ -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
|
-
|
|
157
|
-
verification
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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 */
|
package/src/plan/pipeline.mjs
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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},
|
|
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,
|
|
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
|
|
691
|
-
//
|
|
692
|
-
//
|
|
693
|
-
//
|
|
694
|
-
|
|
695
|
-
|
|
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
|
}
|
package/src/plan/repo-facts.mjs
CHANGED
|
@@ -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
|
|
5
|
-
*
|
|
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
|
|
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
|
-
|
|
107
|
-
|
|
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
|
}
|
package/src/plan/resolve.mjs
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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 },
|
package/src/plan/rounds.mjs
CHANGED
|
@@ -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
|
-
|
|
292
|
-
|
|
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
|
-
|
|
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
|
|
315
|
-
// it: that
|
|
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,
|
|
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
|
-
|
|
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.
|
package/src/plan/sizing.mjs
CHANGED
|
@@ -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
|
|
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
|
|
397
|
-
|
|
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:
|
|
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
|
package/src/plan/template.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
148
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
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}
|
|
341
|
-
* @
|
|
399
|
+
* @param {unknown} value
|
|
400
|
+
* @param {string} label
|
|
401
|
+
* @returns {PlanOutputNode[]}
|
|
342
402
|
*/
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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,
|
|
355
|
-
requireId(nodeRecord.id, `${
|
|
356
|
-
requireString(nodeRecord.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(`${
|
|
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(`${
|
|
416
|
+
throw new TypeError(`${nodeLabel}.riskTier must be one of ${RISK_TIERS.join(", ")}`);
|
|
362
417
|
}
|
|
363
418
|
const dependsOn = nodeRecord.dependsOn ?? [];
|
|
364
|
-
requireStringArray(dependsOn, `${
|
|
419
|
+
requireStringArray(dependsOn, `${nodeLabel}.dependsOn`);
|
|
365
420
|
const readFiles = nodeRecord.readFiles ?? [];
|
|
366
|
-
requireStringArray(readFiles, `${
|
|
421
|
+
requireStringArray(readFiles, `${nodeLabel}.readFiles`);
|
|
367
422
|
const writeFiles = nodeRecord.writeFiles ?? [];
|
|
368
|
-
requireStringArray(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, `${
|
|
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 ?? [], `${
|
|
435
|
+
const verification = validateVerificationCommands(nodeRecord.verification ?? [], `${nodeLabel}.verification`);
|
|
381
436
|
const definitionOfDone = validateDefinitionOfDone(
|
|
382
437
|
nodeRecord.definitionOfDone ?? [],
|
|
383
|
-
`${
|
|
438
|
+
`${nodeLabel}.definitionOfDone`,
|
|
384
439
|
{ commands: verification, nodeId: /** @type {string} */ (nodeRecord.id) },
|
|
385
440
|
);
|
|
386
|
-
const expectedTurns = nodeRecord.expectedTurns === undefined ? undefined : positiveInteger(nodeRecord.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.
|