faberun 0.28.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/cli/launch.mjs +20 -3
- package/src/cli/plan.mjs +56 -5
- package/src/cli.mjs +5 -1
- package/src/contract/definition-of-done.mjs +55 -0
- package/src/contract/index.mjs +1 -1
- package/src/contract/runtime.mjs +15 -5
- package/src/contract/worker-result.mjs +27 -11
- package/src/engine/live-preflight.mjs +4 -1
- package/src/engine/prompts.mjs +17 -1
- package/src/engine/scheduler.mjs +8 -1
- package/src/plan/freeze.mjs +50 -13
- package/src/plan/pipeline.mjs +123 -20
- package/src/plan/progress.mjs +101 -0
- package/src/plan/proof-check.mjs +13 -1
- package/src/plan/proof-scope.mjs +1 -1
- package/src/plan/repo-facts.mjs +64 -11
- package/src/plan/resolve.mjs +6 -4
- package/src/plan/rounds.mjs +37 -13
- package/src/plan/sizing.mjs +20 -4
- package/src/plan/spec.mjs +82 -6
- package/src/plan/template.mjs +182 -37
- package/src/repo/scope-closure.mjs +22 -0
- package/src/repo/worktree-prune.mjs +13 -5
- package/src/repo/worktree.mjs +54 -0
package/src/plan/pipeline.mjs
CHANGED
|
@@ -30,6 +30,7 @@ import { validateJudgeIndependence } from "../contract/judge-independence.mjs";
|
|
|
30
30
|
import { firstEligibleReviewer, reviewerProvenanceOf } from "./reviewer.mjs";
|
|
31
31
|
import { parseSpec, validateSpec } from "./spec.mjs";
|
|
32
32
|
import { collectRepoFacts } from "./repo-facts.mjs";
|
|
33
|
+
import { clearPlanProgress, writePlanProgress } from "./progress.mjs";
|
|
33
34
|
import { checkPlanProofs } from "./proof-check.mjs";
|
|
34
35
|
import { collectHumanSteps } from "./human-step.mjs";
|
|
35
36
|
import { RISK_TIERS, TASK_KIND_CATALOGUE_FILE, buildPlanningContract, renderTaskKindCatalogue, validatePlanOutput } from "./template.mjs";
|
|
@@ -38,6 +39,7 @@ import { resolveRuntimes } from "./routing.mjs";
|
|
|
38
39
|
import { contentDigest, freezePlan, writeFrozenPlanRecord } from "./freeze.mjs";
|
|
39
40
|
import { availabilityOf, fileLineCount, highestOf, modelOf, toContractNode, toSizingNode } from "./pipeline-shape.mjs";
|
|
40
41
|
import { campaignTree, runDirectory } from "../run/paths.mjs";
|
|
42
|
+
import { assertLaunchBaseClean } from "../repo/source-identity.mjs";
|
|
41
43
|
import { contestPlan } from "./contest.mjs";
|
|
42
44
|
import { runReviewRounds } from "./rounds.mjs";
|
|
43
45
|
|
|
@@ -55,7 +57,7 @@ export { droppedWriteFindings, unresolvedFindings } from "./rounds.mjs";
|
|
|
55
57
|
/** @typedef {import("./template.mjs").PlanFindingOutput} PlanFindingOutput */
|
|
56
58
|
/** @typedef {import("./template.mjs").PlanPhase} PlanPhase */
|
|
57
59
|
/** @typedef {import("./sizing.mjs").PlanNode & {objective: string}} SizedPlanNode */
|
|
58
|
-
/** @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 */
|
|
59
61
|
/** @typedef {"standard"|"high"|"none"} ApproveBelow */
|
|
60
62
|
/** @typedef {(contractPath: string, contract: ValidatedContract) => Promise<void>|void} LaunchFn */
|
|
61
63
|
/** @typedef {(runtimes: Record<string, JsonObject>, runtimeDefaults: {worker?: string, judge?: string}, cwd: string) => Promise<import("../harnesses/index.mjs").ProbeResult[]>} AskFn */
|
|
@@ -105,6 +107,27 @@ const APPROVE_BELOW_VALUES = new Set(["standard", "high", "none"]);
|
|
|
105
107
|
* @returns {Promise<FrozenPipelineResult|ContestedPipelineResult>}
|
|
106
108
|
*/
|
|
107
109
|
export async function runPlanningPipeline(options) {
|
|
110
|
+
try {
|
|
111
|
+
return await runPlanningStages(options);
|
|
112
|
+
} finally {
|
|
113
|
+
// The record of a process that is working must not outlive the work. It is
|
|
114
|
+
// cleared here, in the one place every return and every throw passes
|
|
115
|
+
// through, rather than by a caller: `plan` already removed it, but a
|
|
116
|
+
// caller that forgot left a record that makes the next launch report a
|
|
117
|
+
// death that never happened, and the record is worth nothing if it can be
|
|
118
|
+
// wrong in that direction.
|
|
119
|
+
if (typeof options.campaignId === "string" && typeof options.phase === "string") clearPlanProgress(resolve(options.cwd ?? "."), options.campaignId, options.phase);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The stages themselves, separate from the exported entry point only so that
|
|
125
|
+
* the liveness record is removed on every path out of them.
|
|
126
|
+
*
|
|
127
|
+
* @param {Parameters<typeof runPlanningPipeline>[0]} options
|
|
128
|
+
* @returns {Promise<FrozenPipelineResult|ContestedPipelineResult>}
|
|
129
|
+
*/
|
|
130
|
+
async function runPlanningStages(options) {
|
|
108
131
|
const {
|
|
109
132
|
specPath, campaignId, phase, runtimes, launch, wait,
|
|
110
133
|
reviewRounds = 2, runtimeDefaults = {}, reviewers = [], verification = {}, targetedFix = false, judgeIndependence,
|
|
@@ -125,6 +148,17 @@ export async function runPlanningPipeline(options) {
|
|
|
125
148
|
const campaignPath = campaignTree(cwd, campaignId);
|
|
126
149
|
const campaign = readCampaign(campaignPath);
|
|
127
150
|
if (campaign.status !== "active") throw new Error(`campaign is closed: ${campaignId}`);
|
|
151
|
+
// The run this pipeline launches is the last thing it does, and the run
|
|
152
|
+
// refuses a tree whose HEAD is dirty. Asking the same question here, before
|
|
153
|
+
// the first stage, is the difference between a refusal and sixteen minutes
|
|
154
|
+
// spent on repo facts first. Measured 2026-09-27 (AP10): a `plan` stage that
|
|
155
|
+
// died exactly that way reported "refusing to launch against HEAD: the
|
|
156
|
+
// working tree has 2 uncommitted paths" only when the operator re-ran it in
|
|
157
|
+
// the foreground. Nothing this pipeline writes touches the tree
|
|
158
|
+
// `dirtyTreePaths` reads: the plan artifacts live under the campaign tree in
|
|
159
|
+
// the home, and the scratch relay inside the repository is excluded by the
|
|
160
|
+
// same pathspec the launch uses.
|
|
161
|
+
assertLaunchBaseClean(cwd, undefined);
|
|
128
162
|
refuseUnplannableRuntimes(runtimes, runtimeDefaults, packageMode, judgeIndependence);
|
|
129
163
|
refusePlanningSilence(await ask(runtimes, runtimeDefaults, cwd), cwd);
|
|
130
164
|
|
|
@@ -142,10 +176,18 @@ export async function runPlanningPipeline(options) {
|
|
|
142
176
|
const plansDir = join(campaignTree(cwd, campaignId), "plans", phase);
|
|
143
177
|
mkdirSync(plansDir, { recursive: true });
|
|
144
178
|
const pipelineLog = join(plansDir, "pipeline.jsonl");
|
|
179
|
+
// Every stage line is also the liveness record: the file is the answer to
|
|
180
|
+
// "is this plan still working", and a plan that dies mid-stage leaves the
|
|
181
|
+
// stage it died in as the last thing it ever said. Nothing removes it here;
|
|
182
|
+
// the caller clears it when the pipeline returns, so a record that survives
|
|
183
|
+
// a returned pipeline cannot happen and a record that survives anything
|
|
184
|
+
// else means exactly one thing.
|
|
145
185
|
/** @param {string} stage @param {Record<string, unknown>} [extra] */
|
|
146
|
-
const logStage = (stage, extra = {}) =>
|
|
147
|
-
|
|
148
|
-
|
|
186
|
+
const logStage = (stage, extra = {}) => {
|
|
187
|
+
const at = new Date().toISOString();
|
|
188
|
+
appendJsonl(pipelineLog, { type: "plan.stage", at, campaignId, phase, stage, ...extra });
|
|
189
|
+
writePlanProgress(cwd, campaignId, phase, { campaignId, phase, at, stage, ...extra });
|
|
190
|
+
};
|
|
149
191
|
|
|
150
192
|
// A discovery node's `readFiles` must resolve inside `cwd` (the task packet's
|
|
151
193
|
// own containment rule), but `plansDir` lives under the home since R2 and no
|
|
@@ -160,7 +202,17 @@ export async function runPlanningPipeline(options) {
|
|
|
160
202
|
// the draft stage reads what the planner measured instead of inferring it
|
|
161
203
|
// from the spec's prose.
|
|
162
204
|
const parsedSpec = parseSpec(specText);
|
|
163
|
-
|
|
205
|
+
// This stage holds the terminal for minutes, which is why it says what it is
|
|
206
|
+
// about to do before it does it and one line per command as each finishes.
|
|
207
|
+
// Measured 2026-09-25/26 (AP3): five to eight minutes with nothing written
|
|
208
|
+
// anywhere, on six relaunches.
|
|
209
|
+
const repoFacts = collectRepoFacts(cwd, {
|
|
210
|
+
requirements: parsedSpec.requirements,
|
|
211
|
+
onProgress: (event) => {
|
|
212
|
+
if (event.kind === "commands") logStage("repo-facts:start", { commands: event.commands.map((argv) => argv.join(" ")) });
|
|
213
|
+
else logStage("repo-facts:measured", { argv: event.argv, measuredMs: event.measuredMs, index: event.index, total: event.total });
|
|
214
|
+
},
|
|
215
|
+
});
|
|
164
216
|
// R16: an operator step a requirement's constraints declare, detected once
|
|
165
217
|
// here so both the freeze below and a caller inspecting the pipeline agree
|
|
166
218
|
// on the same list.
|
|
@@ -300,7 +352,7 @@ export async function runPlanningPipeline(options) {
|
|
|
300
352
|
* @param {PlanOutput} currentPlan
|
|
301
353
|
* @returns {AssembledPlan}
|
|
302
354
|
*/
|
|
303
|
-
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 });
|
|
304
356
|
|
|
305
357
|
/**
|
|
306
358
|
* The raw contract exactly as `freezePlan` will assemble and validate it.
|
|
@@ -310,7 +362,7 @@ export async function runPlanningPipeline(options) {
|
|
|
310
362
|
* @param {AssembledPlan} assembly
|
|
311
363
|
* @returns {JsonObject}
|
|
312
364
|
*/
|
|
313
|
-
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 });
|
|
314
366
|
|
|
315
367
|
const roundsResult = await runReviewRounds({
|
|
316
368
|
reviewRounds, plan, rejectedDraft: plan === null ? draft.output.plan : undefined, findings, cwd, plansDir, scratchDir, workingPlanPath, relativeWorkingPlanPath,
|
|
@@ -342,8 +394,10 @@ export async function runPlanningPipeline(options) {
|
|
|
342
394
|
// is legitimate, an unnoticed one is not. Observed 2026-09-20 on the first
|
|
343
395
|
// contract this planner ever froze: it carried neither suite while the same
|
|
344
396
|
// ratchets, on hand-authored contracts, were catching copied helpers
|
|
345
|
-
// mid-run — and nothing said so.
|
|
346
|
-
|
|
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))
|
|
347
401
|
? []
|
|
348
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"];
|
|
349
403
|
try {
|
|
@@ -552,6 +606,46 @@ function taggedStageError(planStage, error) {
|
|
|
552
606
|
return Object.assign(wrapped, { planStage });
|
|
553
607
|
}
|
|
554
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
|
+
|
|
555
649
|
/**
|
|
556
650
|
* The one assembly a plan freezes through: sizing reshapes the drafted
|
|
557
651
|
* nodes, routing assigns worker and judge, and `toContractNode` renders the
|
|
@@ -561,12 +655,16 @@ function taggedStageError(planStage, error) {
|
|
|
561
655
|
* plan came from — a fresh draft/revise or one read back off a contested
|
|
562
656
|
* `plan.json`.
|
|
563
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
|
+
*
|
|
564
662
|
* @param {PlanOutput} currentPlan
|
|
565
|
-
* @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
|
|
566
664
|
* @returns {AssembledPlan}
|
|
567
665
|
*/
|
|
568
666
|
export function assembleFrozenPlan(currentPlan, ctx) {
|
|
569
|
-
const { repoFacts, packageMode, targetedFix, phase, cwd, runtimes, runtimeDefaults, judgeIndependence } = ctx;
|
|
667
|
+
const { repoFacts, packageMode, targetedFix, phase, cwd, runtimes, runtimeDefaults, judgeIndependence, verification } = ctx;
|
|
570
668
|
let sizing;
|
|
571
669
|
try {
|
|
572
670
|
sizing = applySizingRules(
|
|
@@ -597,6 +695,9 @@ export function assembleFrozenPlan(currentPlan, ctx) {
|
|
|
597
695
|
// named it must now name the node that absorbed it or freeze would see
|
|
598
696
|
// an unknown assignment.
|
|
599
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),
|
|
600
701
|
};
|
|
601
702
|
}
|
|
602
703
|
|
|
@@ -609,11 +710,11 @@ export function assembleFrozenPlan(currentPlan, ctx) {
|
|
|
609
710
|
* for the same reason.
|
|
610
711
|
*
|
|
611
712
|
* @param {AssembledPlan} assembly
|
|
612
|
-
* @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
|
|
613
714
|
* @returns {JsonObject}
|
|
614
715
|
*/
|
|
615
|
-
export function frozenContractRawOf({ sizing, nodes }, ctx) {
|
|
616
|
-
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;
|
|
617
718
|
return {
|
|
618
719
|
schemaVersion: PROTOCOL_SCHEMA_VERSION,
|
|
619
720
|
contractVersion: CONTRACT_VERSION,
|
|
@@ -635,12 +736,14 @@ export function frozenContractRawOf({ sizing, nodes }, ctx) {
|
|
|
635
736
|
// The operator's opt-in, carried verbatim: the frozen contract is
|
|
636
737
|
// validated under the same judge-independence rule its routing used.
|
|
637
738
|
...(judgeIndependence === undefined ? {} : { judgeIndependence }),
|
|
638
|
-
// The
|
|
639
|
-
//
|
|
640
|
-
//
|
|
641
|
-
//
|
|
642
|
-
|
|
643
|
-
|
|
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 } : {}),
|
|
644
747
|
nodes,
|
|
645
748
|
};
|
|
646
749
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The liveness record of a planning process: where a `faberun plan` says which
|
|
3
|
+
* stage it is in, and how a later reader tells a process that is still working
|
|
4
|
+
* from one that died without a word.
|
|
5
|
+
*
|
|
6
|
+
* It is separate from `pipeline.mjs` because two layers need it and neither
|
|
7
|
+
* owns it: the pipeline writes it as it advances through stages, and
|
|
8
|
+
* `cli/plan.mjs` reads it before starting and clears it when the pipeline
|
|
9
|
+
* returns. `cli/plan.mjs` already owns the sibling record for a plan that dies
|
|
10
|
+
* before the pipeline is reached (`writePlanBootstrapFailure`); this one
|
|
11
|
+
* covers the death that record cannot, the one that happens minutes later.
|
|
12
|
+
*
|
|
13
|
+
* Measured 2026-09-26 (AP4): a detached `plan` (pid 64270) vanished during the
|
|
14
|
+
* repo-facts stage, leaving `plans/phase-1` empty, with no `pipeline.jsonl` and
|
|
15
|
+
* no bootstrap failure. `watchPlanBootstrap` covers a five-second window, so a
|
|
16
|
+
* death after it recorded nothing at all.
|
|
17
|
+
*
|
|
18
|
+
* A record exists because a detached process's stdio is discarded, which is
|
|
19
|
+
* also why `plan --resolve` writes none: it is never detached, so a death
|
|
20
|
+
* there is visible where it happens and a record would only add a second
|
|
21
|
+
* account of something the operator already watched.
|
|
22
|
+
*/
|
|
23
|
+
import { rmSync } from "node:fs";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { pidAlive, processStartToken, sameProcessStartToken } from "../run/lock.mjs";
|
|
26
|
+
import { campaignTree } from "../run/paths.mjs";
|
|
27
|
+
import { readJson, writeJsonAtomic } from "../run/store.mjs";
|
|
28
|
+
|
|
29
|
+
/** @typedef {{pid?: number, processStartToken?: string|null, at?: string, stage?: string, [key: string]: unknown}} PlanProgressRecord */
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Beside the phase's durable plan artifacts rather than in the disposable
|
|
33
|
+
* scratch tree, for the same reason `bootstrap-failure.json` is: a record a
|
|
34
|
+
* later launch has to find cannot live where the next `plan` may wipe.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} cwd @param {string} campaignId @param {string} phase
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
export function planProgressPath(cwd, campaignId, phase) {
|
|
40
|
+
return join(campaignTree(cwd, campaignId), "plans", phase, "progress.json");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Write the record of the process that is doing the work right now. It carries
|
|
45
|
+
* the pid together with the process start token, so a reader an hour later is
|
|
46
|
+
* not fooled by a pid the operating system handed to somebody else.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} cwd @param {string} campaignId @param {string} phase
|
|
49
|
+
* @param {PlanProgressRecord} record
|
|
50
|
+
* @returns {void}
|
|
51
|
+
*/
|
|
52
|
+
export function writePlanProgress(cwd, campaignId, phase, record) {
|
|
53
|
+
writeJsonAtomic(planProgressPath(cwd, campaignId, phase), {
|
|
54
|
+
pid: process.pid,
|
|
55
|
+
processStartToken: processStartToken(process.pid),
|
|
56
|
+
at: new Date().toISOString(),
|
|
57
|
+
...record,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {string} cwd @param {string} campaignId @param {string} phase
|
|
63
|
+
* @returns {PlanProgressRecord|null}
|
|
64
|
+
*/
|
|
65
|
+
export function readPlanProgress(cwd, campaignId, phase) {
|
|
66
|
+
try {
|
|
67
|
+
const record = /** @type {PlanProgressRecord} */ (readJson(planProgressPath(cwd, campaignId, phase)));
|
|
68
|
+
return record && typeof record === "object" && !Array.isArray(record) ? record : null;
|
|
69
|
+
} catch {
|
|
70
|
+
// An absent record is the ordinary case: a plan that finished clears it.
|
|
71
|
+
// A record that will not parse is one a dying process left half-written,
|
|
72
|
+
// and there is nothing in it a reader could act on either way.
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The record of a process that stopped where it stood, or null when no plan is
|
|
79
|
+
* running and nothing died. A live pid with the recorded start token is work
|
|
80
|
+
* in progress, however long it has been in the same stage; anything else means
|
|
81
|
+
* the pipeline never reached its own end.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} cwd @param {string} campaignId @param {string} phase
|
|
84
|
+
* @returns {PlanProgressRecord|null}
|
|
85
|
+
*/
|
|
86
|
+
export function abandonedPlanProgress(cwd, campaignId, phase) {
|
|
87
|
+
const record = readPlanProgress(cwd, campaignId, phase);
|
|
88
|
+
if (!record) return null;
|
|
89
|
+
const pid = typeof record.pid === "number" ? record.pid : null;
|
|
90
|
+
const token = typeof record.processStartToken === "string" ? record.processStartToken : null;
|
|
91
|
+
if (pid !== null && pidAlive(pid) && sameProcessStartToken(processStartToken(pid), token)) return null;
|
|
92
|
+
return record;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @param {string} cwd @param {string} campaignId @param {string} phase
|
|
97
|
+
* @returns {void}
|
|
98
|
+
*/
|
|
99
|
+
export function clearPlanProgress(cwd, campaignId, phase) {
|
|
100
|
+
rmSync(planProgressPath(cwd, campaignId, phase), { force: true });
|
|
101
|
+
}
|
package/src/plan/proof-check.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { readFileSync } from "node:fs";
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import { shellWords } from "../util.mjs";
|
|
16
|
-
import { inlineScriptParseError } from "../contract/definition-of-done.mjs";
|
|
16
|
+
import { inlineScriptParseError, unclosedQuote } from "../contract/definition-of-done.mjs";
|
|
17
17
|
import { NAME_PATTERN_FLAG } from "./proof-run.mjs";
|
|
18
18
|
|
|
19
19
|
/** @typedef {import("./template.mjs").PlanOutput} PlanOutput */
|
|
@@ -150,6 +150,18 @@ export function checkPlanProofs(plan, repoFacts, cwd) {
|
|
|
150
150
|
});
|
|
151
151
|
continue;
|
|
152
152
|
}
|
|
153
|
+
// AP13: a command the shell cannot parse never runs, so no delivery
|
|
154
|
+
// makes the item pass; the proof text has to change before the freeze.
|
|
155
|
+
const openQuote = unclosedQuote(commandText);
|
|
156
|
+
if (openQuote !== null) {
|
|
157
|
+
findings.push({
|
|
158
|
+
id: `proof-unparseable-${node.id}-${item.id}`,
|
|
159
|
+
severity: "critical",
|
|
160
|
+
nodeId: node.id,
|
|
161
|
+
text: `Node ${node.id}'s Definition of Done item "${item.id}" proves itself with a command that leaves a ${openQuote} open ("${commandText}"), which /bin/sh refuses before it runs, so no delivery can make this proof exit 0. Quote the value, as in --test-name-pattern="<title>".`,
|
|
162
|
+
});
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
153
165
|
const nameFlag = NAME_PATTERN_FLAG.exec(commandText);
|
|
154
166
|
if (nameFlag) {
|
|
155
167
|
const pattern = nameFlag[1] ?? nameFlag[2] ?? nameFlag[3];
|
package/src/plan/proof-scope.mjs
CHANGED
|
@@ -30,7 +30,7 @@ const TEST_TITLE = /\b(?:test|it|describe)(?:\.\w+)?\(\s*(["'`])((?:\\.|(?!\1).)
|
|
|
30
30
|
* @param {string[]} argv
|
|
31
31
|
* @returns {{nameFilters: Array<{flag: string, value: string}>, paths: string[]}|null}
|
|
32
32
|
*/
|
|
33
|
-
function nodeTestShape(argv) {
|
|
33
|
+
export function nodeTestShape(argv) {
|
|
34
34
|
if (argv[0] !== "node" || !argv.includes("--test")) return null;
|
|
35
35
|
/** @type {Array<{flag: string, value: string}>} */
|
|
36
36
|
const nameFilters = [];
|
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
|
|
|
@@ -176,6 +198,19 @@ function manifestCandidateCommands(cwd) {
|
|
|
176
198
|
return commands;
|
|
177
199
|
}
|
|
178
200
|
|
|
201
|
+
/**
|
|
202
|
+
* How a caller watches this stage work. `commands` arrives once, naming every
|
|
203
|
+
* command about to be timed; `measured` arrives once per command as it
|
|
204
|
+
* finishes. Neither reaches the returned facts: two calls at the same HEAD
|
|
205
|
+
* stay byte-identical because no event is written into them.
|
|
206
|
+
*
|
|
207
|
+
* Measured 2026-09-25/26 (AP3): this stage took five to eight minutes and said
|
|
208
|
+
* nothing while it did, so `faberun plan` was indistinguishable from a hang
|
|
209
|
+
* and the operator checked the process by hand on six relaunches.
|
|
210
|
+
*
|
|
211
|
+
* @typedef {(event: {kind: "commands", commands: string[][]} | {kind: "measured", argv: string[], measuredMs: number, index: number, total: number}) => void} RepoFactsProgress
|
|
212
|
+
*/
|
|
213
|
+
|
|
179
214
|
/**
|
|
180
215
|
* Time every candidate through `timeVerificationCommands`'s own probe —
|
|
181
216
|
* real `spawnSync` and `Date.now` by default, or the caller's fake — instead
|
|
@@ -187,9 +222,10 @@ function manifestCandidateCommands(cwd) {
|
|
|
187
222
|
* @param {string} cwd
|
|
188
223
|
* @param {{argv: string[]}[]} commands
|
|
189
224
|
* @param {MeasureProbes} probes
|
|
225
|
+
* @param {RepoFactsProgress} [onProgress]
|
|
190
226
|
* @returns {{argv: string[], measuredMs: number, eligible: boolean}[]}
|
|
191
227
|
*/
|
|
192
|
-
function measureCandidates(cwd, commands, probes) {
|
|
228
|
+
function measureCandidates(cwd, commands, probes, onProgress) {
|
|
193
229
|
if (commands.length === 0) return [];
|
|
194
230
|
const now = probes.now ?? (() => Date.now());
|
|
195
231
|
/** @type {number[]} */
|
|
@@ -201,7 +237,22 @@ function measureCandidates(cwd, commands, probes) {
|
|
|
201
237
|
taskPacket: { verification: [{ argv: command.argv, timeoutSec: CANDIDATE_TIMEOUT_SEC }] },
|
|
202
238
|
})),
|
|
203
239
|
}));
|
|
204
|
-
timeVerificationCommands(contract, {
|
|
240
|
+
timeVerificationCommands(contract, {
|
|
241
|
+
...probes,
|
|
242
|
+
now: () => {
|
|
243
|
+
const mark = now();
|
|
244
|
+
marks.push(mark);
|
|
245
|
+
// Every command contributes exactly one start mark and one stop mark, in
|
|
246
|
+
// order, so an even mark count means the command at half that count just
|
|
247
|
+
// finished. That is the only progress signal this stage has while it
|
|
248
|
+
// holds the terminal for minutes.
|
|
249
|
+
const stopped = marks.length;
|
|
250
|
+
if (stopped % 2 === 0 && onProgress) {
|
|
251
|
+
onProgress({ kind: "measured", argv: commands[stopped / 2 - 1].argv, measuredMs: mark - marks[stopped - 2], index: stopped / 2, total: commands.length });
|
|
252
|
+
}
|
|
253
|
+
return mark;
|
|
254
|
+
},
|
|
255
|
+
});
|
|
205
256
|
return commands.map((command, index) => {
|
|
206
257
|
const measuredMs = marks[index * 2 + 1] - marks[index * 2];
|
|
207
258
|
return { argv: command.argv, measuredMs, eligible: measuredMs <= ELIGIBLE_MS_CEILING };
|
|
@@ -247,7 +298,7 @@ export function measureRequirements(cwd, requirements, probes = {}) {
|
|
|
247
298
|
|
|
248
299
|
/**
|
|
249
300
|
* @param {string} cwd
|
|
250
|
-
* @param {{measure?: MeasureProbes, maxPaths?: number, requirements?: SpecRequirement[]}} [options]
|
|
301
|
+
* @param {{measure?: MeasureProbes, maxPaths?: number, requirements?: SpecRequirement[], onProgress?: RepoFactsProgress}} [options]
|
|
251
302
|
* @returns {RepoFacts}
|
|
252
303
|
*/
|
|
253
304
|
export function collectRepoFacts(cwd, options = {}) {
|
|
@@ -255,7 +306,9 @@ export function collectRepoFacts(cwd, options = {}) {
|
|
|
255
306
|
const allPaths = listTrackedPaths(cwd);
|
|
256
307
|
const pathSet = new Set(allPaths);
|
|
257
308
|
const scripts = readScripts(cwd);
|
|
258
|
-
const
|
|
309
|
+
const candidates = nodeCandidateCommands(scripts, allPaths);
|
|
310
|
+
options.onProgress?.({ kind: "commands", commands: candidates.map((command) => command.argv) });
|
|
311
|
+
const measured = measureCandidates(cwd, candidates, options.measure ?? {}, options.onProgress);
|
|
259
312
|
const detected = manifestCandidateCommands(cwd);
|
|
260
313
|
const truncated = allPaths.length > maxPaths;
|
|
261
314
|
return {
|
|
@@ -272,7 +325,7 @@ export function collectRepoFacts(cwd, options = {}) {
|
|
|
272
325
|
// null "no measurement" sentinel freeze.mjs reads and eligibility true.
|
|
273
326
|
verificationCandidates: measured.map((candidate) => ({ ...candidate, manifest: PACKAGE_MANIFEST })),
|
|
274
327
|
detectedVerificationCandidates: detected.map((candidate) => ({ ...candidate, measuredMs: null, eligible: true })),
|
|
275
|
-
testFiles: testFileEntries(allPaths, pathSet),
|
|
328
|
+
testFiles: testFileEntries(allPaths, pathSet, directReferenceGraph(cwd)),
|
|
276
329
|
requirementMeasurements: measureRequirements(cwd, options.requirements ?? [], options.measure ?? {}),
|
|
277
330
|
};
|
|
278
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 },
|