faberun 0.14.0 → 0.16.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.
@@ -6,10 +6,11 @@
6
6
  * `freeze.mjs` (which only turns a plan into a validated contract on disk):
7
7
  * this module is the one place that sequences those runs, checks each round's
8
8
  * plan against the contract it would freeze into while a revise can still
9
- * act on the failure, and decides when a plan is contested instead of
10
- * frozen. `launch` and `wait` are the only two seams that touch a
11
- * process or the wall clock, so a test drives the whole pipeline through
12
- * `runContract` in-process, deterministically.
9
+ * act on the failure, compares a revision's write set against the plan it
10
+ * revised so scope closure cannot be satisfied by shrinking the work, and
11
+ * decides when a plan is contested instead of frozen. `launch` and `wait`
12
+ * are the only two seams that touch a process or the wall clock, so a test
13
+ * drives the whole pipeline through `runContract` in-process, deterministically.
13
14
  */
14
15
  import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
15
16
  import { dirname, join, relative, resolve } from "node:path";
@@ -21,24 +22,28 @@ import { appendSeatAllowanceEvent, readJournal } from "../campaign/journal.mjs";
21
22
  import { readCampaign } from "../campaign/record.mjs";
22
23
  import { campaignCli } from "../cli/campaign.mjs";
23
24
  import { appendJsonl, writeJsonAtomic } from "../run/store.mjs";
25
+ import { stableJson } from "../util.mjs";
24
26
  import { allowanceDelta, allowanceEventFields, sampleAllowance } from "../seat/allowance.mjs";
25
27
  import { parseSpec, validateSpec } from "./spec.mjs";
26
28
  import { collectRepoFacts } from "./repo-facts.mjs";
27
29
  import { RISK_TIERS, buildPlanningContract, validateFindings, validatePlanOutput } from "./template.mjs";
28
- import { MIN_WRITE_FILES, applySizingRules } from "./sizing.mjs";
30
+ import { MIN_WRITE_FILES, applySizingRules, provenParallelism } from "./sizing.mjs";
29
31
  import { resolveRuntimes } from "./routing.mjs";
30
32
  import { freezePlan } from "./freeze.mjs";
31
33
  import { campaignTree, runDirectory } from "../run/paths.mjs";
32
34
 
33
35
  /** @typedef {import("../contract/index.mjs").JsonObject} JsonObject */
34
36
  /** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
37
+ /** @typedef {import("../contract/index.mjs").VerificationCommand} VerificationCommand */
38
+ /** @typedef {{sharedVerification?: VerificationCommand[], finalVerification?: VerificationCommand[]}} VerificationSuites */
35
39
  /** @typedef {import("./template.mjs").PlanOutput} PlanOutput */
36
40
  /** @typedef {import("./template.mjs").PlanFindingOutput} PlanFindingOutput */
37
41
  /** @typedef {import("./sizing.mjs").PlanNode & {objective: string}} SizedPlanNode */
42
+ /** @typedef {{sizing: import("./sizing.mjs").SizingResult, routing: import("./routing.mjs").RoutingResult, nodes: JsonObject[]}} AssembledPlan */
38
43
  /** @typedef {"standard"|"high"|"none"} ApproveBelow */
39
44
  /** @typedef {(contractPath: string, contract: ValidatedContract) => Promise<void>|void} LaunchFn */
40
45
  /** @typedef {(runDir: string) => Promise<import("../engine/supervise.mjs").RunProgress>|import("../engine/supervise.mjs").RunProgress} WaitFn */
41
- /** @typedef {{status: "frozen", plansDir: string, planPath: string, contractPath: string, approved: boolean, findings: PlanFindingOutput[]}} FrozenPipelineResult */
46
+ /** @typedef {{status: "frozen", plansDir: string, planPath: string, contractPath: string, approved: boolean, findings: PlanFindingOutput[], warnings: string[]}} FrozenPipelineResult */
42
47
  /** @typedef {{status: "contested", plansDir: string, planPath: string, findings: PlanFindingOutput[], round: number}} ContestedPipelineResult */
43
48
 
44
49
  /**
@@ -75,13 +80,13 @@ export const DEFAULT_NODE_BUDGET_MS = 600_000;
75
80
  const APPROVE_BELOW_VALUES = new Set(["standard", "high", "none"]);
76
81
 
77
82
  /**
78
- * @param {{specPath: string, campaignId: string, phase: string, cwd?: string, reviewRounds?: number, approveBelow?: ApproveBelow, runtimeDefaults?: {worker?: string, judge?: string}, runtimes: Record<string, JsonObject>, launch: LaunchFn, wait: WaitFn}} options
83
+ * @param {{specPath: string, campaignId: string, phase: string, cwd?: string, reviewRounds?: number, approveBelow?: ApproveBelow, runtimeDefaults?: {worker?: string, judge?: string}, runtimes: Record<string, JsonObject>, verification?: VerificationSuites, launch: LaunchFn, wait: WaitFn}} options
79
84
  * @returns {Promise<FrozenPipelineResult|ContestedPipelineResult>}
80
85
  */
81
86
  export async function runPlanningPipeline(options) {
82
87
  const {
83
88
  specPath, campaignId, phase, runtimes, launch, wait,
84
- reviewRounds = 2, runtimeDefaults = {},
89
+ reviewRounds = 2, runtimeDefaults = {}, verification = {},
85
90
  } = options;
86
91
  const approveBelow = /** @type {ApproveBelow} */ (options.approveBelow ?? "standard");
87
92
  if (!APPROVE_BELOW_VALUES.has(approveBelow)) throw new TypeError(`approveBelow must be one of ${[...APPROVE_BELOW_VALUES].join(", ")}`);
@@ -171,8 +176,18 @@ export async function runPlanningPipeline(options) {
171
176
  const draft = await runStage("draft", { specPath: relativeSpecPath, repoFactsPath: relativeRepoFactsPath });
172
177
  /** @type {PlanOutput|null} */
173
178
  let plan = null;
179
+ // Everything still open against the plan in hand, accumulated across rounds
180
+ // rather than replaced by each one: a reviewer who does not repeat the last
181
+ // round's objection has not answered it. `unresolvedFindings` is what takes
182
+ // a finding back out once the plan moved under it.
174
183
  /** @type {PlanFindingOutput[]} */
175
184
  let findings = [];
185
+ // The writes the most recent revise dropped, computed where the revise's
186
+ // output is validated and held for the next round: merged in there, after
187
+ // the review's own findings, so a drop is never cleaned away by a fresh
188
+ // review passing over a plan that no longer declares the write.
189
+ /** @type {PlanFindingOutput[]} */
190
+ let droppedWrites = [];
176
191
  try {
177
192
  plan = validatePlanOutput(draft.output.plan);
178
193
  } catch (error) {
@@ -222,7 +237,7 @@ export async function runPlanningPipeline(options) {
222
237
  * `stage` as it goes; see the declaration above.
223
238
  *
224
239
  * @param {PlanOutput} currentPlan
225
- * @returns {{sizing: import("./sizing.mjs").SizingResult, routing: import("./routing.mjs").RoutingResult, nodes: JsonObject[]}}
240
+ * @returns {AssembledPlan}
226
241
  */
227
242
  const assembleFrozenNodes = (currentPlan) => {
228
243
  stage = "sizing";
@@ -247,16 +262,19 @@ export async function runPlanningPipeline(options) {
247
262
 
248
263
  /**
249
264
  * The raw contract exactly as `freezePlan` will assemble and validate it,
250
- * schemaVersion and contractVersion included. `validateContract` never
265
+ * schemaVersion and contractVersion included. It takes the whole assembly
266
+ * rather than its nodes because the contract carries a run-level conclusion
267
+ * of sizing's too (`maxParallel`), and the in-round pre-flight must validate
268
+ * the same bytes the freeze writes. `validateContract` never
251
269
  * reads the path it is given — only `dirname(resolve(contractPath))` to
252
270
  * resolve `raw.cwd` — so the pre-flight passes the path the contract will
253
271
  * occupy, `contract.json` inside `plansDir`, and validates in memory
254
272
  * without writing or deleting anything.
255
273
  *
256
- * @param {JsonObject[]} nodes
274
+ * @param {AssembledPlan} assembly
257
275
  * @returns {JsonObject}
258
276
  */
259
- const frozenContractRaw = (nodes) => ({
277
+ const frozenContractRaw = ({ sizing, nodes }) => ({
260
278
  schemaVersion: PROTOCOL_SCHEMA_VERSION,
261
279
  contractVersion: CONTRACT_VERSION,
262
280
  id: `${campaignId}-${phase}`,
@@ -266,8 +284,20 @@ export async function runPlanningPipeline(options) {
266
284
  // `cwd` has to point back at the repo root from there, exactly like
267
285
  // `runStage` computes it for the nodes it writes under `plansDir/nodes/`.
268
286
  cwd: relative(plansDir, cwd) || ".",
287
+ // Sizing's `parallelisable` conclusion, which nothing else carries: a
288
+ // contract node has no `parallel` field, so a plan that says nothing here
289
+ // freezes at the validator's default of 1 and every independent node it
290
+ // proved waits for its turn. What the engine does with the number is the
291
+ // scheduler's business; declaring it is this stage's.
292
+ maxParallel: provenParallelism(sizing.plan),
269
293
  runtimes,
270
294
  runtimeDefaults,
295
+ // The operator's ratchets, carried verbatim: which suites a repository
296
+ // runs on every node is the operator's policy, supplied through
297
+ // `--verification`, never derived from repository facts. Validated at the
298
+ // flag boundary, so freeze neither re-derives nor edits them.
299
+ ...(verification.sharedVerification ? { sharedVerification: verification.sharedVerification } : {}),
300
+ ...(verification.finalVerification ? { finalVerification: verification.finalVerification } : {}),
271
301
  nodes,
272
302
  });
273
303
 
@@ -281,7 +311,11 @@ export async function runPlanningPipeline(options) {
281
311
  /** @type {PlanFindingOutput|null} */
282
312
  let invalidFindings = null;
283
313
  try {
284
- findings = validateFindings(review.output.findings);
314
+ // Merged over what is still open, never substituted for it: a round's
315
+ // reviewer grades the plan in front of it and may simply not mention
316
+ // an objection the last round raised, which a plain assignment here
317
+ // threw away.
318
+ findings = mergeFindings(findings, validateFindings(review.output.findings));
285
319
  } catch (error) {
286
320
  // The review said nothing usable about the plan, so the plan cannot be
287
321
  // treated as clean: the malformed-output finding is critical and
@@ -291,7 +325,7 @@ export async function runPlanningPipeline(options) {
291
325
  findings = [...findings, invalidFindings];
292
326
  }
293
327
  // The contract this plan would freeze into is checked here, inside the
294
- // round and after the review's findings replaced the previous round's,
328
+ // round and after the review's findings merged into the open ones,
295
329
  // because freeze runs after the last one: caught here, a plan that
296
330
  // cannot freeze still has a revise left to fix it. The failure is a
297
331
  // critical finding, never an auto-filled acknowledgement — scope
@@ -301,7 +335,7 @@ export async function runPlanningPipeline(options) {
301
335
  /** @type {PlanFindingOutput|null} */
302
336
  let freezeFailure = null;
303
337
  try {
304
- validateContract(frozenContractRaw(assembleFrozenNodes(plan).nodes), join(plansDir, "contract.json"));
338
+ validateContract(frozenContractRaw(assembleFrozenNodes(plan)), join(plansDir, "contract.json"));
305
339
  } catch (error) {
306
340
  freezeFailure = invalidPlanFinding(`freeze-r${round}`, error);
307
341
  findings = [...findings, freezeFailure];
@@ -315,6 +349,12 @@ export async function runPlanningPipeline(options) {
315
349
  ...(invalidFindings === null ? {} : { invalid: invalidFindings.text }),
316
350
  });
317
351
  }
352
+ // The revise's write-drops land here — after the review above, before the
353
+ // critical check below — so a drop survives into the same
354
+ // revise-or-contest decision a review finding reaches. Merged by id: a
355
+ // drop the last round already carried and this revise made again is one
356
+ // finding, not two.
357
+ findings = mergeFindings(findings, droppedWrites);
318
358
  const criticalFindings = findings.filter((finding) => finding.severity === "critical");
319
359
  if (criticalFindings.length === 0) break;
320
360
  if (round === reviewRounds) return await contest(round);
@@ -323,6 +363,9 @@ export async function runPlanningPipeline(options) {
323
363
  const revise = await runStage("revise", {
324
364
  specPath: relativeSpecPath, repoFactsPath: relativeRepoFactsPath, findingsPath: relative(cwd, findingsPath),
325
365
  });
366
+ // Kept for the write-drop comparison: the plan the revise revised, against
367
+ // the plan it produced.
368
+ const planBeforeRevise = plan;
326
369
  /** @type {PlanFindingOutput|null} */
327
370
  let invalid = null;
328
371
  try {
@@ -334,7 +377,21 @@ export async function runPlanningPipeline(options) {
334
377
  plan = null;
335
378
  findings = [...findings, invalid];
336
379
  }
337
- logStage("revise", { round, runId: revise.contract.id, ...(invalid === null ? {} : { invalid: invalid.text }) });
380
+ // Null when the revise output was refused: there is no revised write set
381
+ // to compare, and the refused-output finding already drives the round.
382
+ droppedWrites = droppedWriteFindings(planBeforeRevise, plan);
383
+ // What the next round starts from: the findings this revise did not move
384
+ // the plan under. Everything else — a node it changed, a node it removed,
385
+ // and the pipeline's own shape findings, which the next round re-derives
386
+ // — is dropped here rather than carried forever.
387
+ findings = unresolvedFindings(findings, planBeforeRevise, plan);
388
+ logStage("revise", {
389
+ round,
390
+ runId: revise.contract.id,
391
+ droppedWrites: droppedWrites.length,
392
+ carriedFindings: findings.length,
393
+ ...(invalid === null ? {} : { invalid: invalid.text }),
394
+ });
338
395
  }
339
396
  // Reached only when no review round is configured (reviewRounds <= 0) and
340
397
  // the draft never validated: no revise exists to reach, so contested is the
@@ -346,18 +403,26 @@ export async function runPlanningPipeline(options) {
346
403
  // `nearly` is what this whole phase is about: an unanticipated failure
347
404
  // writes its stage line and takes the contested exit instead of leaving
348
405
  // pipeline.jsonl stopping between stages.
349
- /** @type {{sizing: import("./sizing.mjs").SizingResult, routing: import("./routing.mjs").RoutingResult, nodes: JsonObject[]}|undefined} */
406
+ /** @type {AssembledPlan|undefined} */
350
407
  let assembled;
351
408
  /** @type {import("./freeze.mjs").FrozenPlan|undefined} */
352
409
  let frozen;
353
410
  /** @type {string|undefined} */
354
411
  let highestRiskTier;
412
+ // Warned at freeze, never refused: a plan for a repository with no ratchets
413
+ // is legitimate, an unnoticed one is not. Observed 2026-09-20 on the first
414
+ // contract this planner ever froze: it carried neither suite while the same
415
+ // ratchets, on hand-authored contracts, were catching copied helpers
416
+ // mid-run — and nothing said so.
417
+ const freezeWarnings = verification.sharedVerification || verification.finalVerification
418
+ ? []
419
+ : ["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"];
355
420
  try {
356
421
  assembled = assembleFrozenNodes(plan);
357
422
  logStage("sizing", { transformations: assembled.sizing.transformations.length, nodeCount: assembled.sizing.plan.nodes.length, overheadMinutes: assembled.sizing.estimate.overheadMinutes });
358
423
  logStage("routing", { assignments: Object.keys(assembled.routing.assignments).length });
359
424
  highestRiskTier = highestOf(assembled.sizing.plan.nodes.map((node) => node.riskTier ?? RISK_TIERS[0]));
360
- frozen = freezePlan(frozenContractRaw(assembled.nodes), {
425
+ frozen = freezePlan(frozenContractRaw(assembled), {
361
426
  outDir: plansDir,
362
427
  provenance: {
363
428
  targetGitHead: repoFacts.gitHead,
@@ -367,7 +432,7 @@ export async function runPlanningPipeline(options) {
367
432
  findings,
368
433
  },
369
434
  });
370
- logStage("freeze", { contractId: `${campaignId}-${phase}`, highestRiskTier });
435
+ logStage("freeze", { contractId: `${campaignId}-${phase}`, highestRiskTier, ...(freezeWarnings.length ? { warnings: freezeWarnings } : {}) });
371
436
  } catch (error) {
372
437
  // The stage line the pipeline would otherwise have stopped short of, the
373
438
  // failure carried as the critical finding that names it, and the
@@ -416,7 +481,7 @@ export async function runPlanningPipeline(options) {
416
481
  }
417
482
  logStage("approval", { approved, approveBelow, highestRiskTier });
418
483
 
419
- return { status: "frozen", plansDir, planPath, contractPath: join(plansDir, "contract.json"), approved, findings };
484
+ return { status: "frozen", plansDir, planPath, contractPath: join(plansDir, "contract.json"), approved, findings, warnings: freezeWarnings };
420
485
  }
421
486
 
422
487
  /**
@@ -435,6 +500,25 @@ function repoRelativePath(cwd, path, label) {
435
500
  return relativePath;
436
501
  }
437
502
 
503
+ /**
504
+ * The prefix `validateContract` throws its scope-closure refusal with
505
+ * (src/contract/index.mjs owns the wording). Matched here because the only
506
+ * channel the validator has is its message text, and that text is carried
507
+ * verbatim — the finding below appends to it, never rewrites it.
508
+ */
509
+ const SCOPE_CLOSURE_MESSAGE_PREFIX = "task packet scope does not close";
510
+
511
+ /**
512
+ * The one sentence about scope closure the validator cannot know: the raw
513
+ * message reads as "declare or acknowledge", and removing a write clears it
514
+ * just as well — more cheaply, in fact, since fewer writes drag in fewer
515
+ * importers and no judgement about which importer breaks. Measured
516
+ * 2026-09-20 across three plans for one node: a revise took exactly that
517
+ * cheap move, twice, and two workers then refused their packets with
518
+ * context_missing for a file the shrink had taken away.
519
+ */
520
+ const SCOPE_CLOSURE_RESOLUTION = "Resolve it by declaring each named file in writeFiles — or acknowledging it in scopeAcknowledged when the node must not change it — never by dropping a write the node needs: removing a file from writeFiles clears this failure too, and leaves the worker without a file the work requires.";
521
+
438
522
  /**
439
523
  * A validatePlanOutput, validateFindings or in-round validateContract
440
524
  * rejection, shaped as the finding a review round already carries to revise,
@@ -442,7 +526,9 @@ function repoRelativePath(cwd, path, label) {
442
526
  * or a plan that would not survive freeze — reaches the stage that can act on
443
527
  * it instead of killing the pipeline between the run finishing and its stage
444
528
  * line. `nodeId` is "plan" because the validator's message names a path into
445
- * the plan, not one of its nodes.
529
+ * the plan, not one of its nodes. A scope-closure failure carries
530
+ * `SCOPE_CLOSURE_RESOLUTION` after the validator's verbatim message, which
531
+ * stays intact because it names the exact paths the reviser must act on.
446
532
  *
447
533
  * @param {string} label
448
534
  * @param {unknown} error
@@ -450,7 +536,114 @@ function repoRelativePath(cwd, path, label) {
450
536
  */
451
537
  function invalidPlanFinding(label, error) {
452
538
  const text = error instanceof Error ? error.message : String(error);
453
- return { id: `plan-shape-${label}`, severity: "critical", nodeId: "plan", text };
539
+ const guided = text.startsWith(SCOPE_CLOSURE_MESSAGE_PREFIX) ? `${text} ${SCOPE_CLOSURE_RESOLUTION}` : text;
540
+ return { id: `plan-shape-${label}`, severity: "critical", nodeId: "plan", text: guided };
541
+ }
542
+
543
+ /**
544
+ * `incoming` merged over `carried`, keyed by finding id. A finding both
545
+ * rounds raise is the newer reviewer's own re-judgement of the same
546
+ * objection, severity included, so it replaces the carried copy instead of
547
+ * duplicating it — this merge never rewrites a severity of its own. Carried
548
+ * findings keep their order and come first, so the oldest outstanding
549
+ * objection is at the top of the file the reviser reads.
550
+ *
551
+ * @param {PlanFindingOutput[]} carried
552
+ * @param {PlanFindingOutput[]} incoming
553
+ * @returns {PlanFindingOutput[]}
554
+ */
555
+ function mergeFindings(carried, incoming) {
556
+ const merged = new Map(carried.map((finding) => [finding.id, finding]));
557
+ for (const finding of incoming) merged.set(finding.id, finding);
558
+ return [...merged.values()];
559
+ }
560
+
561
+ /**
562
+ * The findings still open against the plan a revise produced. A review round
563
+ * is under no obligation to repeat what the last one found, so replacing the
564
+ * finding set each round silently drops any objection the new reviewer is
565
+ * quiet about: a spike run froze a plan carrying a defect round 1 had named
566
+ * and round 2 did not repeat, and the worker refused the packet with
567
+ * context_missing.
568
+ *
569
+ * What counts as resolved is read off the two plans, never off the reviewer's
570
+ * silence. A finding whose node the revise removed is moot. A finding whose
571
+ * node the revise changed at all was acted on — the next review grades the
572
+ * changed node and can object again in its own words. A finding against a
573
+ * node the revise left identical was not addressed, and stays open. That rule
574
+ * is also what keeps a carried finding from making convergence impossible:
575
+ * every one of them clears the moment the reviser touches the node it names,
576
+ * so a plan that answers its objections still freezes inside the round
577
+ * budget, and a plan that does not still ends contested at it. A finding
578
+ * naming no node of the plan — the pipeline's own `nodeId: "plan"` shape and
579
+ * freeze failures — is re-derived from scratch by the next round's pre-flight,
580
+ * so carrying it would double it.
581
+ *
582
+ * @param {PlanFindingOutput[]} findings everything open at the end of the round
583
+ * @param {PlanOutput|null} previousPlan the plan the revise revised
584
+ * @param {PlanOutput|null} revisedPlan the plan the revise produced, null when its output was refused
585
+ * @returns {PlanFindingOutput[]}
586
+ */
587
+ export function unresolvedFindings(findings, previousPlan, revisedPlan) {
588
+ // No revised plan to measure against: the refused-output finding drives the
589
+ // next round and everything raised so far is still outstanding.
590
+ if (!revisedPlan) return findings;
591
+ const before = new Map((previousPlan?.nodes ?? []).map((node) => [node.id, stableJson(node)]));
592
+ const after = new Map(revisedPlan.nodes.map((node) => [node.id, stableJson(node)]));
593
+ return findings.filter((finding) => after.has(finding.nodeId) && before.get(finding.nodeId) === after.get(finding.nodeId));
594
+ }
595
+
596
+ /**
597
+ * The write files the plan going into a revise declared that the revised plan
598
+ * no longer declares, one finding per (node id, path). This is the check the
599
+ * scope-closure validator cannot make: a shrink satisfies closure without a
600
+ * judgement about any importer, so the cheap move needs a comparator of its
601
+ * own. Severity is critical — a drop is not automatically wrong, but it is
602
+ * always worth a second look, and only a critical finding reaches the round
603
+ * loop's revise-or-contest decision; major would ride along in the findings
604
+ * file while the plan froze. Nodes are matched by id alone: a node the
605
+ * revision renamed or removed entirely is out of scope, because tracking
606
+ * identity across a rename is a judgement about the graph this check does
607
+ * not make — a removed node's writes were reviewed as a removal, not as a
608
+ * silent shrink.
609
+ *
610
+ * Membership is judged against the whole revised plan, not against the node
611
+ * that used to hold the path. A revise that splits one node in two and hands
612
+ * a file to the new sibling has not dropped that file: it is still declared,
613
+ * still reviewable, and the graph change is visible in the plan. Measured
614
+ * 2026-09-21 on durable-state-integrity phase 1, where a per-node test made
615
+ * exactly that move a critical and contested a sound plan — the draft's only
616
+ * node wrote src/repo/worktree.mjs and src/engine/cancel.mjs, and the revise
617
+ * layered them into worktree-preserve-ref-verb and
618
+ * cancel-preserves-integrated-heads, which is the decomposition this
619
+ * repository's own layering asks for.
620
+ *
621
+ * @param {PlanOutput|null} previousPlan the plan the revise revised, null when the draft never validated
622
+ * @param {PlanOutput|null} revisedPlan the plan the revise produced, null when its output was refused
623
+ * @returns {PlanFindingOutput[]}
624
+ */
625
+ export function droppedWriteFindings(previousPlan, revisedPlan) {
626
+ if (!previousPlan || !revisedPlan) return [];
627
+ const before = new Map(previousPlan.nodes.map((node) => [node.id, new Set(node.writeFiles)]));
628
+ const stillDeclared = new Set(revisedPlan.nodes.flatMap((node) => node.writeFiles));
629
+ /** @type {PlanFindingOutput[]} */
630
+ const findings = [];
631
+ for (const node of revisedPlan.nodes) {
632
+ const previousWrites = before.get(node.id);
633
+ if (!previousWrites) continue;
634
+ let dropped = 0;
635
+ for (const path of previousWrites) {
636
+ if (stillDeclared.has(path)) continue;
637
+ dropped += 1;
638
+ findings.push({
639
+ id: `dropped-write-${node.id}-${dropped}`,
640
+ severity: "critical",
641
+ nodeId: node.id,
642
+ text: `Node ${node.id} no longer declares ${path} in writeFiles, which the plan this revise revised did declare, and no other node in the revised plan declares it either. Declare it again on whichever node owns the work: the resolution to a scope-closure finding is to declare or acknowledge the dragged-along file, never to drop a write the node needs — a smaller write set clears the same finding while leaving the worker unable to do the work. Moving the file to another node is a resolution; removing it from the plan is not.`,
643
+ });
644
+ }
645
+ }
646
+ return findings;
454
647
  }
455
648
 
456
649
  /**
@@ -19,6 +19,7 @@ import { existsSync, readFileSync } from "node:fs";
19
19
  import { join } from "node:path";
20
20
  import { timeVerificationCommands } from "../host/preflight.mjs";
21
21
  import { NOTIFY_BIN_ENV } from "../notify/index.mjs";
22
+ import { NOTIFY_SESSION_ENV } from "../notify/session.mjs";
22
23
  import { boundedGitSync, gitHead } from "../repo/worktree.mjs";
23
24
 
24
25
  /** @typedef {import("./spec.mjs").SpecRequirement} SpecRequirement */
@@ -45,6 +46,7 @@ const MEASURE_OUTPUT_CAP_BYTES = 4096;
45
46
  */
46
47
  const MEASURE_SIDE_EFFECT_ENV_KEYS = [
47
48
  NOTIFY_BIN_ENV,
49
+ NOTIFY_SESSION_ENV,
48
50
  "FABERUN_CODEX_BIN",
49
51
  "FABERUN_CLAUDE_BIN",
50
52
  "FABERUN_AGY_BIN",