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.
- package/package.json +1 -1
- package/skills/faberun/references/operations.md +22 -11
- package/src/cli/plan.mjs +40 -1
- package/src/cli.mjs +1 -0
- package/src/contract/runtime.mjs +50 -2
- package/src/contract/verification.mjs +26 -10
- package/src/engine/assignment.mjs +33 -6
- package/src/engine/backoff.mjs +11 -5
- package/src/engine/cancel.mjs +20 -6
- package/src/engine/failover.mjs +62 -1
- package/src/engine/judge-gate.mjs +63 -5
- package/src/engine/lifecycle.mjs +17 -4
- package/src/engine/mutation.mjs +47 -7
- package/src/engine/resume.mjs +1 -1
- package/src/engine/runtime-discovery.mjs +44 -7
- package/src/engine/scheduler.mjs +19 -23
- package/src/engine/scope.mjs +108 -6
- package/src/engine/settle.mjs +19 -8
- package/src/host/preflight.mjs +5 -3
- package/src/notify/index.mjs +66 -32
- package/src/notify/session.mjs +240 -0
- package/src/plan/pipeline.mjs +215 -22
- package/src/plan/repo-facts.mjs +2 -0
- package/src/plan/routing.mjs +151 -33
- package/src/plan/sizing.mjs +33 -0
- package/src/repo/worktree.mjs +28 -0
- package/src/report/final.mjs +2 -1
- package/src/report/locale.mjs +164 -0
- package/src/report/message.mjs +602 -0
- package/src/report/packet-repetition.mjs +121 -0
- package/src/report/progress.mjs +33 -153
- package/src/report/render.mjs +3 -3
- package/src/seat/tmux.mjs +12 -3
package/src/plan/pipeline.mjs
CHANGED
|
@@ -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,
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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 {
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
/**
|
package/src/plan/repo-facts.mjs
CHANGED
|
@@ -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",
|