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.
@@ -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 = {}) => appendJsonl(pipelineLog, {
147
- type: "plan.stage", at: new Date().toISOString(), campaignId, phase, stage, ...extra,
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
- const repoFacts = collectRepoFacts(cwd, { requirements: parsedSpec.requirements });
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, verification, judgeIndependence });
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
- const freezeWarnings = verification.sharedVerification || verification.finalVerification
397
+ // mid-run — and nothing said so. Read off the effective suites, so a plan
398
+ // that authored its own ratchets (RM-107) is not warned about a contract
399
+ // that carries them.
400
+ const freezeWarnings = carriesSuites(effectiveVerificationSuites(verification, plan))
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}, verification: VerificationSuites, judgeIndependence?: "same-vendor"}} ctx
713
+ * @param {{campaignId: string, phase: string, campaignGoal: string, cwd: string, plansDir: string, runtimes: Record<string, JsonObject>, runtimeDefaults: {worker?: string, judge?: string}, judgeIndependence?: "same-vendor"}} ctx
613
714
  * @returns {JsonObject}
614
715
  */
615
- export function frozenContractRawOf({ sizing, nodes }, ctx) {
616
- const { campaignId, phase, campaignGoal, cwd, plansDir, runtimes, runtimeDefaults, verification, judgeIndependence } = ctx;
716
+ export function frozenContractRawOf({ sizing, nodes, suites }, ctx) {
717
+ const { campaignId, phase, campaignGoal, cwd, plansDir, runtimes, runtimeDefaults, judgeIndependence } = ctx;
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 operator's ratchets, carried verbatim: which suites a repository
639
- // runs on every node is the operator's policy, supplied through
640
- // `--verification`, never derived from repository facts. Validated at the
641
- // flag boundary, so freeze neither re-derives nor edits them.
642
- ...(verification.sharedVerification ? { sharedVerification: verification.sharedVerification } : {}),
643
- ...(verification.finalVerification ? { finalVerification: verification.finalVerification } : {}),
739
+ // The ratchets this phase carries, chosen once by the assembly and carried
740
+ // verbatim here: the operator's `--verification` when it was passed,
741
+ // otherwise the suites the plan authored from the repository facts
742
+ // (RM-107). Validated at both boundaries — the flag loader for one, the
743
+ // plan validator for the other — so freeze neither re-derives nor edits
744
+ // them.
745
+ ...(suites?.sharedVerification ? { sharedVerification: suites.sharedVerification } : {}),
746
+ ...(suites?.finalVerification ? { finalVerification: suites.finalVerification } : {}),
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
+ }
@@ -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];
@@ -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 = [];
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Repo facts: a deterministic, bounded inventory of the target repository —
3
3
  * tracked paths, declared scripts, verification candidates across the Node,
4
- * Python, Go, Rust, Zig and Makefile ecosystems, and which test file covers
5
- * which source module — collected without invoking a model. Every candidate
4
+ * Python, Go, Rust, Zig and Makefile ecosystems, and which source modules each
5
+ * test file covers, by its name and by what it imports or runs — collected
6
+ * without invoking a model. Every candidate
6
7
  * records the manifest it was read from; only the Node candidates are timed,
7
8
  * and no candidate for another ecosystem is ever executed to be found.
8
9
  * A planning stage's draft is authored against exactly this JSON instead of
@@ -20,6 +21,7 @@
20
21
  import { existsSync, readFileSync } from "node:fs";
21
22
  import { join } from "node:path";
22
23
  import { timeVerificationCommands } from "../host/preflight.mjs";
24
+ import { directReferenceGraph } from "../repo/scope-closure.mjs";
23
25
  import { boundedGitSync, gitHead } from "../repo/worktree.mjs";
24
26
  import { runShellCapture } from "./proof-run.mjs";
25
27
 
@@ -45,7 +47,7 @@ import { runShellCapture } from "./proof-run.mjs";
45
47
  *
46
48
  * @typedef {{argv: string[], manifest: string, measuredMs: number|null, eligible: boolean}} DetectedVerificationCandidate
47
49
  */
48
- /** @typedef {{path: string, covers: string|null}} TestFileEntry */
50
+ /** @typedef {{path: string, covers: string[]}} TestFileEntry */
49
51
  /** @typedef {{formatVersion: number, gitHead: string|null, paths: string[], truncated: boolean, scripts: Record<string, string>, verificationCandidates: VerificationCandidate[], detectedVerificationCandidates?: DetectedVerificationCandidate[], testFiles: TestFileEntry[], requirementMeasurements: RequirementMeasurement[]}} RepoFacts */
50
52
 
51
53
  const FORMAT_VERSION = 1;
@@ -95,16 +97,36 @@ function testDirectories(paths) {
95
97
  }
96
98
 
97
99
  /**
100
+ * What each test file covers: the module it is named after, when that module
101
+ * exists, plus every tracked module it imports or runs (RM-109). The naming
102
+ * convention alone was the whole answer, so a test that exercises a module
103
+ * without being named for it — a cross-module integration test, a test that
104
+ * spawns the CLI by URL — was invisible to planning. Measured on
105
+ * `safe-to-hand-to-a-friend`: four failures in 1763 reached the integration
106
+ * branch while every node had passed its own suite.
107
+ *
108
+ * Only tracked paths count, so a stray file in the working tree cannot become
109
+ * part of a plan's verification, and only `test/**\/*.test.mjs` files are
110
+ * entries at all: a helper is covered by the tests that import it, never a
111
+ * verifier itself.
112
+ *
98
113
  * @param {string[]} paths
99
114
  * @param {Set<string>} pathSet
115
+ * @param {Map<string, string[]>} references repo-relative references per file
100
116
  * @returns {TestFileEntry[]}
101
117
  */
102
- function testFileEntries(paths, pathSet) {
118
+ function testFileEntries(paths, pathSet, references) {
103
119
  return paths
104
120
  .filter((path) => path.startsWith("test/") && path.endsWith(".test.mjs"))
105
121
  .map((path) => {
106
- const modulePath = `src/${path.slice("test/".length, -".test.mjs".length)}.mjs`;
107
- return { path, covers: pathSet.has(modulePath) ? modulePath : null };
122
+ /** @type {Set<string>} */
123
+ const covers = new Set();
124
+ const namedAfter = `src/${path.slice("test/".length, -".test.mjs".length)}.mjs`;
125
+ if (pathSet.has(namedAfter)) covers.add(namedAfter);
126
+ for (const referenced of references.get(path) ?? []) {
127
+ if (pathSet.has(referenced)) covers.add(referenced);
128
+ }
129
+ return { path, covers: [...covers].sort() };
108
130
  });
109
131
  }
110
132
 
@@ -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, { ...probes, now: () => { const mark = now(); marks.push(mark); return mark; } });
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 measured = measureCandidates(cwd, nodeCandidateCommands(scripts, allPaths), options.measure ?? {});
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
  }
@@ -26,7 +26,7 @@ import { freezePlan } from "./freeze.mjs";
26
26
  import { collectHumanSteps } from "./human-step.mjs";
27
27
  import { highestOf, modelOf } from "./pipeline-shape.mjs";
28
28
  import { reviewerProvenanceOf } from "./reviewer.mjs";
29
- import { PLANNER_SESSION_ID, assembleFrozenPlan, frozenContractRawOf, invalidPlanFinding, writeFrozenPlan } from "./pipeline.mjs";
29
+ import { PLANNER_SESSION_ID, assembleFrozenPlan, carriesSuites, effectiveVerificationSuites, frozenContractRawOf, invalidPlanFinding, writeFrozenPlan } from "./pipeline.mjs";
30
30
  import { contestPlan } from "./contest.mjs";
31
31
  import { parseSpec } from "./spec.mjs";
32
32
  import { RISK_TIERS } from "./template.mjs";
@@ -132,17 +132,19 @@ export async function resolvePlanningPipeline({ plansDir, cwd, answers }) {
132
132
  }
133
133
  logStage("resolve", { round, answered: criticalFindings.length });
134
134
 
135
- const freezeWarnings = verification.sharedVerification || verification.finalVerification
135
+ // The plan resumed here may carry suites of its own (RM-107), so the
136
+ // warning is read off the same effective set the freeze below will carry.
137
+ const freezeWarnings = carriesSuites(effectiveVerificationSuites(verification ?? {}, plan))
136
138
  ? []
137
139
  : ["the frozen contract carries neither sharedVerification nor finalVerification, so no repository ratchet runs on its nodes and no final check closes the phase; pass --verification <file> if the target repository has ratchets every node must run"];
138
140
  let stage = "sizing";
139
141
  try {
140
- const assembled = assembleFrozenPlan(plan, { repoFacts, packageMode, targetedFix, phase, cwd, runtimes, runtimeDefaults, judgeIndependence });
142
+ const assembled = assembleFrozenPlan(plan, { repoFacts, packageMode, targetedFix, phase, cwd, runtimes, runtimeDefaults, judgeIndependence, verification });
141
143
  stage = "freeze";
142
144
  logStage("sizing", { transformations: assembled.sizing.transformations.length, nodeCount: assembled.sizing.plan.nodes.length, overheadMinutes: assembled.sizing.estimate.overheadMinutes });
143
145
  logStage("routing", { assignments: Object.keys(assembled.routing.assignments).length });
144
146
  const highestRiskTier = highestOf(assembled.sizing.plan.nodes.map((node) => node.riskTier ?? RISK_TIERS[0]));
145
- const frozen = freezePlan(frozenContractRawOf(assembled, { campaignId, phase, campaignGoal: campaign.goal, cwd, plansDir, runtimes, runtimeDefaults, verification, judgeIndependence }), {
147
+ const frozen = freezePlan(frozenContractRawOf(assembled, { campaignId, phase, campaignGoal: campaign.goal, cwd, plansDir, runtimes, runtimeDefaults, judgeIndependence }), {
146
148
  outDir: plansDir,
147
149
  phases: assembled.phases,
148
150
  spec: { path: specPath, digest: specDigest },