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.
@@ -16,18 +16,24 @@
16
16
  * refusal. A legacy declaration that names no requirements and no nodes is
17
17
  * still reported as a finding, never a silent pass. freeze.mjs imports the
18
18
  * same phase check for the frozen plan record.
19
+ *
20
+ * It also validates the two contract-level suites a plan may author of its own
21
+ * (RM-107): `sharedVerification` and `finalVerification`, the same schema the
22
+ * contract carries, so the phase's repository-wide proof is planned rather
23
+ * than left to an operator remembering `--verification`.
19
24
  */
20
25
  import { assertObject, positiveInteger, rejectUnknown, requireId, requireString, requireStringArray } from "../contract/assert.mjs";
21
26
  import { CONTRACT_VERSION, PROTOCOL_SCHEMA_VERSION } from "../contract/index.mjs";
22
27
  import { validateDefinitionOfDone } from "../contract/definition-of-done.mjs";
28
+ import { validateFinalVerification, validateSharedVerification } from "../contract/final-verification.mjs";
23
29
  import { validateVerificationCommands } from "../contract/verification.mjs";
24
30
 
25
31
  /** @typedef {import("../contract/index.mjs").JsonObject} JsonObject */
26
32
  /** @typedef {"draft"|"review"|"revise"|"spec-author"|"spec-review"} PlanningKind */
27
33
  /** @typedef {"low"|"standard"|"high"} RiskTier */
28
- /** @typedef {{campaignId: string, phase: string, n: number, goal?: string, cwd?: string, runtimes: Record<string, JsonObject>, runtimeDefaults: {worker?: string, judge?: string}, reviewerId?: string, specPath?: string, repoFactsPath?: string, cataloguePath?: string, packageMode?: import("./sizing.mjs").PackageMode, planPath?: string, findingsPath?: string, notesPath?: string}} PlanningContractInputs */
34
+ /** @typedef {{campaignId: string, phase: string, n: number, goal?: string, cwd?: string, runtimes: Record<string, JsonObject>, runtimeDefaults: {worker?: string, judge?: string}, reviewerId?: string, specPath?: string, repoFactsPath?: string, cataloguePath?: string, packageMode?: import("./sizing.mjs").PackageMode, planPath?: string, findingsPath?: string, notesPath?: string, revisePatch?: boolean}} PlanningContractInputs */
29
35
  /** @typedef {{id: string, objective: string, taskKind: string, riskTier: RiskTier, dependsOn: string[], readFiles: string[], writeFiles: string[], scopeAcknowledged: string[], definitionOfDone: import("../contract/definition-of-done.mjs").DefinitionOfDoneItem[], verification: import("../contract/verification.mjs").VerificationCommand[], expectedTurns?: number}} PlanOutputNode */
30
- /** @typedef {{nodes: PlanOutputNode[], phases?: PlanPhase[], findings?: PlanFindingOutput[], justification?: string}} PlanOutput */
36
+ /** @typedef {{nodes: PlanOutputNode[], phases?: PlanPhase[], sharedVerification?: import("../contract/verification.mjs").VerificationCommand[], finalVerification?: import("../contract/verification.mjs").VerificationCommand[], findings?: PlanFindingOutput[], justification?: string}} PlanOutput */
31
37
  /** @typedef {{id: string, requirementIds: string[], nodeIds?: string[], deliverable: string}} PlanPhase */
32
38
  /** @typedef {{id: string, severity: "critical"|"major"|"minor", nodeId: string, text: string}} PlanFindingOutput */
33
39
 
@@ -115,7 +121,18 @@ const REQUIRED_INPUTS = Object.freeze({
115
121
  // run had already succeeded. The id charset is requireId's
116
122
  // (contract/assert.mjs) verbatim, because an id that is present but invalid
117
123
  // fails that same validator just as late.
118
- const PLAN_OUTPUT_SHAPE = '{nodes: [{id, objective, taskKind, riskTier, dependsOn, readFiles, writeFiles, scopeAcknowledged, definitionOfDone: [{id, text, proof?: {kind: "command"|"path"|"verification", ref}, judgment?: true, reason?: string}], verification: [{argv: [string], cwd?, timeoutSec?, repeat?, env?, mutation?: {threshold}}], expectedTurns?}], phases?: [{id, requirementIds: [string], nodeIds: [string], deliverable}], justification?}; every id in it (node, phase, node assignment, and definitionOfDone item) must match [A-Za-z0-9._-]+ and never be exactly "." or ".."';
124
+ const PLAN_NODE_SHAPE = '{id, objective, taskKind, riskTier, dependsOn, readFiles, writeFiles, scopeAcknowledged, definitionOfDone: [{id, text, proof?: {kind: "command"|"path"|"verification", ref}, judgment?: true, reason?: string}], verification: [{argv: [string], cwd?, timeoutSec?, repeat?, env?, mutation?: {threshold}}], expectedTurns?}';
125
+ const PLAN_PHASE_SHAPE = '[{id, requirementIds: [string], nodeIds: [string], deliverable}]';
126
+ const PLAN_SUITE_SHAPE = '[{argv: [string], cwd?, timeoutSec?, repeat?, env?, mutation?: {threshold}}]';
127
+ const ID_CHARSET_RULE = 'every id in it (node, phase, node assignment, and definitionOfDone item) must match [A-Za-z0-9._-]+ and never be exactly "." or ".."';
128
+ const PLAN_OUTPUT_SHAPE = `{nodes: [${PLAN_NODE_SHAPE}], phases?: ${PLAN_PHASE_SHAPE}, sharedVerification?: ${PLAN_SUITE_SHAPE}, finalVerification?: ${PLAN_SUITE_SHAPE}, justification?}; ${ID_CHARSET_RULE}`;
129
+ /**
130
+ * What a revise writes instead of the plan again (RM-110). Every node this
131
+ * names is a complete node, because the plan it patches is in the reviser's
132
+ * readFiles and the patch says only what changes: the node shape is
133
+ * `PLAN_NODE_SHAPE`, one home for both strings so they cannot drift.
134
+ */
135
+ const REVISE_PATCH_SHAPE = `{nodes?: [${PLAN_NODE_SHAPE}], removedNodeIds?: [string], phases?: ${PLAN_PHASE_SHAPE}, sharedVerification?: ${PLAN_SUITE_SHAPE}, finalVerification?: ${PLAN_SUITE_SHAPE}, justification?}; ${ID_CHARSET_RULE}`;
119
136
  /**
120
137
  * The size guidance every draft and revise carries. measured 2026-09-20 over
121
138
  * stored runs: median 49 provider requests per worker turn; cost per turn
@@ -135,6 +152,17 @@ const SIZING_INSTRUCTION = "Size nodes to 4 to 6 write files where the work allo
135
152
  const EXPLORATORY_SIZING_INSTRUCTION = "Size nodes by what each must read and by risk, never by what it writes: one write file is the normal shape for a finding, a review or an audit. Give every node an expectedTurns (the provider requests one worker needs end to end), keep the read volume of the nodes within the same order of each other so one does not cost several times its siblings, and split a node you expect past 150 requests, because a run cuts an attempt there.";
136
153
  const FINDINGS_SHAPE = "[{id, severity, nodeId, text}]";
137
154
 
155
+ /**
156
+ * The two shapes a revise can be asked for, swapped by `instructionsFor`.
157
+ * Which one is in force is decided by `inputs.revisePatch`, never by the
158
+ * worker: the plan in readFiles is a validated plan to patch only when the
159
+ * pipeline has one, and a draft that never validated is the case where it does
160
+ * not (`rounds.mjs` starts a round with `plan: null` and the draft's own
161
+ * rejected output).
162
+ */
163
+ const REVISE_PLAN_INSTRUCTION = `Return exactly one worker-result JSON object. Put the revised plan in output.plan as ${PLAN_OUTPUT_SHAPE} and nothing else in output.`;
164
+ const REVISE_PATCH_INSTRUCTION = `Return exactly one worker-result JSON object. Put the revision in output.patch as ${REVISE_PATCH_SHAPE} and nothing else in output. The patch applies to the plan JSON in readFiles: a node whose id that plan already has replaces it, a new id adds a node, removedNodeIds names each node the plan must no longer have, and a field you omit keeps the value the plan already has. Carry only the nodes and fields a finding requires — a revise that omits what a finding names has not answered it.`;
165
+
138
166
  /**
139
167
  * The instruction list is a frozen table because it is the same for every
140
168
  * campaign; only the sizing sentence depends on what kind of package this is.
@@ -144,8 +172,11 @@ const FINDINGS_SHAPE = "[{id, severity, nodeId, text}]";
144
172
  * @returns {string[]}
145
173
  */
146
174
  function instructionsFor(kind, inputs) {
147
- if (inputs.packageMode !== "exploratory") return INSTRUCTIONS[kind];
148
- return INSTRUCTIONS[kind].map((line) => (line === SIZING_INSTRUCTION ? EXPLORATORY_SIZING_INSTRUCTION : line));
175
+ const lines = kind !== "revise" || inputs.revisePatch !== true
176
+ ? INSTRUCTIONS[kind]
177
+ : INSTRUCTIONS[kind].map((line) => (line === REVISE_PLAN_INSTRUCTION ? REVISE_PATCH_INSTRUCTION : line));
178
+ if (inputs.packageMode !== "exploratory") return lines;
179
+ return lines.map((line) => (line === SIZING_INSTRUCTION ? EXPLORATORY_SIZING_INSTRUCTION : line));
149
180
  }
150
181
 
151
182
  // AP1 of safe-to-hand-to-a-friend, measured 2026-09-26: a draft copied the
@@ -154,8 +185,39 @@ function instructionsFor(kind, inputs) {
154
185
  // tree and read as having measured nothing; two nodes exhausted on it. The
155
186
  // freeze now refuses that shape (proof-scope.mjs); this is the same rule told
156
187
  // to the author, so the draft never writes it.
188
+ // AP11 of safe-to-hand-to-friend, measured 2026-09-27: the plan proved R6 with
189
+ // `! grep -q "\.runs" README.md docs/*.md` while the requirement allowed the
190
+ // mention inside a section labelled as legacy layout, and the node exhausted
191
+ // both attempts on a proof stricter than the statement it proved. The proof's
192
+ // wording is a claim the reviewer can compare with the requirement's own; no
193
+ // deterministic check does, because "stricter" is a reading of two sentences.
194
+ const PROOF_NOT_STRICTER_THAN_REQUIREMENT_RULE = "For every node, compare each command proof with the requirement statement it proves, sentence by sentence: a proof that asserts more than the statement is a finding, because the node is refused for work the requirement allows. A bare `grep` for an absence the statement does not claim, a pattern narrower than the statement's words, or a whole-file scope where the statement names a section are the three shapes this takes.";
195
+
157
196
  const NAMED_TEST_FILE_RULE = 'A command that filters node:test by name (--test-name-pattern) names the test file it selects from, as in node --test --test-name-pattern="<exact test title>" test/<area>/<file>.test.mjs: with no path node --test runs every test file in the tree and the freeze refuses it. A requirement proof the spec writes without a file gains the file of the test that carries that title.';
158
197
 
198
+ // RM-109, measured 2026-09-27 on `safe-to-hand-to-a-friend`: the naming
199
+ // convention alone decided a node's test file, so a node whose module was
200
+ // exercised by a test named after something else passed its own suite and
201
+ // still left four failures for the integration branch (1763 tests, 4 failures,
202
+ // phases 3/4/5/3r8). repo-facts.json's testFiles carries what each test file
203
+ // references, so the planner names the files that can actually break.
204
+ const COVERING_TEST_RULE = 'repo-facts.json\'s testFiles lists, per test file, the repo-relative modules it is named after and the ones it imports or runs. A node\'s verification names every test file whose covers intersects its writeFiles, not only the one named after the module: node --test <file>, one per covering file, and the enclosing test directory when that set is larger than one verification array holds. A test file that references nothing the node writes is not named there; those are the phase\'s finalVerification, which runs the whole suite once when the phase closes.';
205
+
206
+ // RM-107, measured 2026-09-27 on `safe-to-hand-to-a-friend` phase 1: the phase
207
+ // integrated a red tree -- src/host/preflight.mjs at 840 lines against the 800
208
+ // ceiling, declaredEnvironment exported by seven modules, three test files
209
+ // whose first import was not scoped-home.mjs, one zcode assertion falsified --
210
+ // and no node had run test/repo/source-shape.test.mjs (0.36 s measured).
211
+ // Contract-level verification was operator-only, so a phase whose whole point
212
+ // was a repository-wide ratchet could freeze without one and look fully
213
+ // verified. The planner reads the measured candidates, so it is the one that
214
+ // proposes them here.
215
+ const CONTRACT_VERIFICATION_RULE = Object.freeze([
216
+ "A plan may carry two suites of its own, over and above the per-node verification: output.plan.sharedVerification, whose commands run on every node of the phase, and output.plan.finalVerification, whose commands run once, when the phase closes. Both take exactly a node's verification-command shape. Use them for the proof no single node owns: a repository-wide conformance or structural suite, in sharedVerification, because any node can break one, and the target's whole suite, in finalVerification, because only the last node can run it.",
217
+ "Every command in either suite must be an argv repo-facts.json measured (verificationCandidates includes each measured argv with its measuredMs), copied verbatim, with a timeoutSec of at least twice that measuredMs. A command the facts did not measure is written from memory, not measurement, and is the shape this rule exists to stop. Omit a suite the facts measured nothing for; a plan for a repository with no ratchets carries neither.",
218
+ "A command that already runs in a suite is not repeated in a node's verification, and a node's own verification is never replaced by a suite.",
219
+ ]);
220
+
159
221
  // The rule every planned packet is held to at freeze time, worded from
160
222
  // AGENTS.md's Faberun protocol and src/repo/scope-closure.mjs ("reading it
161
223
  // cannot fix it"): a first draft that ignores it produces a plan that fails
@@ -182,22 +244,27 @@ const INSTRUCTIONS = Object.freeze({
182
244
  "Declare every phase the plan serves in output.plan.phases: the requirement ids (R<n> from the spec) the phase satisfies, the planned node ids it assigns, and the deliverable it produces in one sentence. Every planned node must appear in exactly one phase's nodeIds; a missing, duplicate, or unknown node assignment is refused.",
183
245
  ...SCOPE_CLOSURE_RULE,
184
246
  NAMED_TEST_FILE_RULE,
247
+ COVERING_TEST_RULE,
248
+ ...CONTRACT_VERIFICATION_RULE,
185
249
  `Return exactly one worker-result JSON object. Put the plan in output.plan as ${PLAN_OUTPUT_SHAPE} and nothing else in output.`,
186
250
  "Never name a runtime, harness, model, or vendor anywhere in output.plan. taskKind and riskTier are the only classification a draft makes; a routing table assigns a runtime afterward, from those two fields alone.",
187
251
  SIZING_INSTRUCTION,
188
252
  ],
189
253
  revise: [
190
- "Start from the plan JSON in readFiles, the plan the findings were raised against, and return it with only the changes the findings require. Keep every node id, write file and definitionOfDone item that no finding asks you to change: a revise that redrafts from the findings alone loses what the plan already got right.",
254
+ "Start from the plan JSON in readFiles, the plan the findings were raised against, and return it with only the changes the findings require. Keep every node id, write file, definitionOfDone item and contract-level suite that no finding asks you to change: a revise that redrafts from the findings alone loses what the plan already got right.",
191
255
  "Read the findings and resolve every one; do not leave a critical or major finding unaddressed.",
192
256
  "Declare every phase the plan serves in output.plan.phases: the requirement ids (R<n> from the spec) the phase satisfies, the planned node ids it assigns, and the deliverable it produces in one sentence. Every planned node must appear in exactly one phase's nodeIds; a missing, duplicate, or unknown node assignment is refused.",
193
257
  ...SCOPE_CLOSURE_RULE,
194
258
  NAMED_TEST_FILE_RULE,
195
- `Return exactly one worker-result JSON object. Put the revised plan in output.plan as ${PLAN_OUTPUT_SHAPE} and nothing else in output.`,
259
+ COVERING_TEST_RULE,
260
+ ...CONTRACT_VERIFICATION_RULE,
261
+ REVISE_PLAN_INSTRUCTION,
196
262
  "Never name a runtime, harness, model, or vendor anywhere in output.plan.",
197
263
  SIZING_INSTRUCTION,
198
264
  ],
199
265
  review: [
200
266
  "You are given only the spec, the repository facts, and the plan under review; you have not seen how the plan was produced or any reasoning behind it. Review the artefact alone.",
267
+ PROOF_NOT_STRICTER_THAN_REQUIREMENT_RULE,
201
268
  `Return exactly one worker-result JSON object. Put your findings in output.findings as ${FINDINGS_SHAPE} and nothing else in output.`,
202
269
  "severity must be one of critical, major, minor. Every finding's nodeId must name a node id that actually appears in the plan under review.",
203
270
  ],
@@ -318,45 +385,42 @@ export function buildPlanningContract(kind, inputs) {
318
385
  };
319
386
  }
320
387
 
321
- const PLAN_FIELDS = new Set(["nodes", "phases", "justification"]);
388
+ const PLAN_FIELDS = new Set(["nodes", "phases", "sharedVerification", "finalVerification", "justification"]);
389
+ /** A patch's fields: the plan's own, minus `nodes`, plus the ids it removes. */
390
+ const PATCH_FIELDS = new Set(["nodes", "removedNodeIds", "phases", "sharedVerification", "finalVerification", "justification"]);
322
391
  const PLAN_NODE_FIELDS = new Set(["id", "objective", "taskKind", "riskTier", "dependsOn", "readFiles", "writeFiles", "scopeAcknowledged", "definitionOfDone", "verification", "expectedTurns"]);
323
392
 
324
393
  /**
325
- * Validate a draft or revise worker's `output.plan`. Rejects a node naming a
326
- * runtime, harness, model, or vendor (an unknown field, since a plan node's
327
- * shape never includes one) and a node missing taskKind or riskTier. A
328
- * declared phase associated with no requirement is reported as a finding on
329
- * the result, never a silent pass and never a refusal.
394
+ * The node list of a plan, or of a revise's `output.patch`, under one
395
+ * validator: a node's shape is the same in both, so a patch node is refused
396
+ * for exactly what a plan node is. `label` is the caller's own field path, so
397
+ * the message a worker reads names the field it actually wrote.
330
398
  *
331
- * @param {unknown} plan
332
- * @returns {PlanOutput}
399
+ * @param {unknown} value
400
+ * @param {string} label
401
+ * @returns {PlanOutputNode[]}
333
402
  */
334
- export function validatePlanOutput(plan) {
335
- assertObject(plan, "plan");
336
- const record = /** @type {Record<string, unknown>} */ (plan);
337
- rejectUnknown(record, PLAN_FIELDS, "plan");
338
- if (!Array.isArray(record.nodes) || record.nodes.length === 0) {
339
- throw new TypeError("plan.nodes must be a non-empty array");
340
- }
341
- const nodes = record.nodes.map((node, index) => {
342
- const label = `plan.nodes[${index}]`;
343
- assertObject(node, label);
403
+ function validatePlanNodes(value, label) {
404
+ if (!Array.isArray(value)) throw new TypeError(`${label} must be an array`);
405
+ return value.map((node, index) => {
406
+ const nodeLabel = `${label}[${index}]`;
407
+ assertObject(node, nodeLabel);
344
408
  const nodeRecord = /** @type {Record<string, unknown>} */ (node);
345
- rejectUnknown(nodeRecord, PLAN_NODE_FIELDS, label);
346
- requireId(nodeRecord.id, `${label}.id`);
347
- requireString(nodeRecord.objective, `${label}.objective`);
409
+ rejectUnknown(nodeRecord, PLAN_NODE_FIELDS, nodeLabel);
410
+ requireId(nodeRecord.id, `${nodeLabel}.id`);
411
+ requireString(nodeRecord.objective, `${nodeLabel}.objective`);
348
412
  if (typeof nodeRecord.taskKind !== "string" || !TASK_KINDS.includes(nodeRecord.taskKind)) {
349
- throw new TypeError(`${label}.taskKind must be one of ${TASK_KINDS.join(", ")}`);
413
+ throw new TypeError(`${nodeLabel}.taskKind must be one of ${TASK_KINDS.join(", ")}`);
350
414
  }
351
415
  if (typeof nodeRecord.riskTier !== "string" || !RISK_TIERS.includes(nodeRecord.riskTier)) {
352
- throw new TypeError(`${label}.riskTier must be one of ${RISK_TIERS.join(", ")}`);
416
+ throw new TypeError(`${nodeLabel}.riskTier must be one of ${RISK_TIERS.join(", ")}`);
353
417
  }
354
418
  const dependsOn = nodeRecord.dependsOn ?? [];
355
- requireStringArray(dependsOn, `${label}.dependsOn`);
419
+ requireStringArray(dependsOn, `${nodeLabel}.dependsOn`);
356
420
  const readFiles = nodeRecord.readFiles ?? [];
357
- requireStringArray(readFiles, `${label}.readFiles`);
421
+ requireStringArray(readFiles, `${nodeLabel}.readFiles`);
358
422
  const writeFiles = nodeRecord.writeFiles ?? [];
359
- requireStringArray(writeFiles, `${label}.writeFiles`);
423
+ requireStringArray(writeFiles, `${nodeLabel}.writeFiles`);
360
424
  // A node that reaches an importer it must not change says so here. Without
361
425
  // this field the drafter is told (INSTRUCTIONS, draft/revise) to answer a
362
426
  // scope-closure finding with `writeFiles or scopeAcknowledged` and has no
@@ -364,17 +428,17 @@ export function validatePlanOutput(plan) {
364
428
  // declaring a read-only importer writable. Measured 2026-09-20 -- a revise
365
429
  // round could not resolve the finding the preflight had just raised.
366
430
  const scopeAcknowledged = nodeRecord.scopeAcknowledged ?? [];
367
- requireStringArray(scopeAcknowledged, `${label}.scopeAcknowledged`);
431
+ requireStringArray(scopeAcknowledged, `${nodeLabel}.scopeAcknowledged`);
368
432
  // Verification is validated first so a DoD proof that names a verification
369
433
  // command by its exact text (R21) can be checked and normalized to that
370
434
  // command's index against this node's own commands, in hand here.
371
- const verification = validateVerificationCommands(nodeRecord.verification ?? [], `${label}.verification`);
435
+ const verification = validateVerificationCommands(nodeRecord.verification ?? [], `${nodeLabel}.verification`);
372
436
  const definitionOfDone = validateDefinitionOfDone(
373
437
  nodeRecord.definitionOfDone ?? [],
374
- `${label}.definitionOfDone`,
438
+ `${nodeLabel}.definitionOfDone`,
375
439
  { commands: verification, nodeId: /** @type {string} */ (nodeRecord.id) },
376
440
  );
377
- const expectedTurns = nodeRecord.expectedTurns === undefined ? undefined : positiveInteger(nodeRecord.expectedTurns, `${label}.expectedTurns`);
441
+ const expectedTurns = nodeRecord.expectedTurns === undefined ? undefined : positiveInteger(nodeRecord.expectedTurns, `${nodeLabel}.expectedTurns`);
378
442
  return /** @type {PlanOutputNode} */ ({
379
443
  id: /** @type {string} */ (nodeRecord.id),
380
444
  objective: /** @type {string} */ (nodeRecord.objective),
@@ -389,7 +453,33 @@ export function validatePlanOutput(plan) {
389
453
  ...(expectedTurns === undefined ? {} : { expectedTurns }),
390
454
  });
391
455
  });
456
+ }
457
+
458
+ /**
459
+ * Validate a draft's `output.plan`. Rejects a node naming a
460
+ * runtime, harness, model, or vendor (an unknown field, since a plan node's
461
+ * shape never includes one) and a node missing taskKind or riskTier. A
462
+ * declared phase associated with no requirement is reported as a finding on
463
+ * the result, never a silent pass and never a refusal.
464
+ *
465
+ * @param {unknown} plan
466
+ * @returns {PlanOutput}
467
+ */
468
+ export function validatePlanOutput(plan) {
469
+ assertObject(plan, "plan");
470
+ const record = /** @type {Record<string, unknown>} */ (plan);
471
+ rejectUnknown(record, PLAN_FIELDS, "plan");
472
+ if (!Array.isArray(record.nodes) || record.nodes.length === 0) {
473
+ throw new TypeError("plan.nodes must be a non-empty array");
474
+ }
475
+ const nodes = validatePlanNodes(record.nodes, "plan.nodes");
392
476
  if (record.justification !== undefined) requireString(record.justification, "plan.justification");
477
+ // The two contract-level suites, under the validators the contract itself
478
+ // applies, so a plan cannot author a suite the freeze would then refuse. An
479
+ // empty array stays legal here and means what absence means; the assembly
480
+ // (`effectiveVerificationSuites`) is what drops it before the contract.
481
+ const sharedVerification = validateSharedVerification(record.sharedVerification, "plan.sharedVerification");
482
+ const finalVerification = validateFinalVerification(record.finalVerification, "plan.finalVerification");
393
483
  const phases = validatePlanPhases(record.phases, nodes.map((node) => node.id));
394
484
  const findings = (phases ?? [])
395
485
  .filter((phase) => phase.requirementIds.length === 0)
@@ -405,11 +495,66 @@ export function validatePlanOutput(plan) {
405
495
  return {
406
496
  nodes,
407
497
  ...(phases === undefined ? {} : { phases }),
498
+ ...(sharedVerification === undefined ? {} : { sharedVerification }),
499
+ ...(finalVerification === undefined ? {} : { finalVerification }),
408
500
  ...(findings.length > 0 ? { findings } : {}),
409
501
  ...(record.justification === undefined ? {} : { justification: /** @type {string} */ (record.justification) }),
410
502
  };
411
503
  }
412
504
 
505
+ /**
506
+ * The plan a revise's `output.patch` produces, from the plan the reviser read
507
+ * (RM-110). Measured 2026-09-27 on `safe-to-hand-to-a-friend`: two revise
508
+ * calls died as `prompt_failed` at 65,932 and 66,821 output tokens against the
509
+ * 65,536 ceiling — a revise that must reproduce every node it is not changing
510
+ * pays the whole plan's size for a change to one of them, after the draft and
511
+ * a review round have already been paid for.
512
+ *
513
+ * The merge is deterministic and total: a patch node whose id the plan has
514
+ * replaces it in place (so the plan's node order, which its phases and its
515
+ * working file both read, survives a revise that changes nothing), a new id
516
+ * appends, `removedNodeIds` deletes by name, and a plan-level field the patch
517
+ * omits keeps the plan's own value. Every node in the result is one the
518
+ * validator has seen once already, so the caller's `validatePlanOutput` on the
519
+ * returned record is the same check a draft's output gets, not a weaker one.
520
+ *
521
+ * Three shapes are refused here rather than merged: a node id named twice in
522
+ * the patch, an id removed that the plan does not have (a typo the reviser
523
+ * would otherwise never hear about), and an id in both lists.
524
+ *
525
+ * @param {{nodes: PlanOutputNode[]} & Record<string, unknown>} previous the plan the reviser was handed
526
+ * @param {unknown} patch a revise worker's `output.patch`
527
+ * @returns {{nodes: PlanOutputNode[]} & Record<string, unknown>} the merged plan record, for `validatePlanOutput`
528
+ */
529
+ export function applyPlanPatch(previous, patch) {
530
+ assertObject(patch, "patch");
531
+ const record = /** @type {Record<string, unknown>} */ (patch);
532
+ rejectUnknown(record, PATCH_FIELDS, "patch");
533
+ const nodes = record.nodes === undefined ? [] : validatePlanNodes(record.nodes, "patch.nodes");
534
+ const removedNodeIds = /** @type {string[]} */ (record.removedNodeIds ?? []);
535
+ requireStringArray(removedNodeIds, "patch.removedNodeIds");
536
+ const previousIds = new Set(previous.nodes.map((node) => node.id));
537
+ const replacements = new Map(nodes.map((node) => [node.id, node]));
538
+ if (replacements.size !== nodes.length) throw new TypeError("patch.nodes names the same node id twice");
539
+ for (const id of removedNodeIds) {
540
+ if (!previousIds.has(id)) throw new TypeError(`patch.removedNodeIds names ${id}, which the plan it revises does not have`);
541
+ if (replacements.has(id)) throw new TypeError(`patch names ${id} in both nodes and removedNodeIds: a node is replaced or removed, never both`);
542
+ }
543
+ const removed = new Set(removedNodeIds);
544
+ /** @type {{nodes: PlanOutputNode[]} & Record<string, unknown>} */
545
+ const merged = {
546
+ nodes: [
547
+ ...previous.nodes.filter((node) => !removed.has(node.id)).map((node) => replacements.get(node.id) ?? node),
548
+ ...nodes.filter((node) => !previousIds.has(node.id)),
549
+ ],
550
+ };
551
+ for (const field of ["phases", "sharedVerification", "finalVerification", "justification"]) {
552
+ const value = record[field] ?? previous[field];
553
+ if (value !== undefined) merged[field] = value;
554
+ }
555
+ return merged;
556
+ }
557
+
413
558
  // The fields a phase declaration carries beyond its id: requirementIds|nodeIds|deliverable —
414
559
  // which spec requirements the phase satisfies, which planned nodes it assigns, and the one
415
560
  // sentence naming its result.
@@ -103,6 +103,28 @@ export function runtimeImportGraph(root) {
103
103
  return graph;
104
104
  }
105
105
 
106
+ /**
107
+ * The repo-relative paths each of the repository's own modules points at: the
108
+ * relative modules it imports and the ones it runs through
109
+ * `new URL(…, import.meta.url)`. Wider than `runtimeImportGraph`'s edges on
110
+ * purpose — that one answers "does this import cycle at load time", where a
111
+ * JSDoc reference and a spawned program are both absent; this one answers
112
+ * "which files does this file exercise", where a test that spawns a program
113
+ * counts exactly like one that imports it. RM-109: `plan/repo-facts.mjs` reads
114
+ * it to name the test files a planned write set touches.
115
+ *
116
+ * @param {string} root absolute directory to walk
117
+ * @returns {Map<string, string[]>} repo-relative path to its references, sorted
118
+ */
119
+ export function directReferenceGraph(root) {
120
+ /** @type {Map<string, string[]>} */
121
+ const graph = new Map();
122
+ for (const [path, source] of repositorySources(root)) {
123
+ graph.set(path, [...referencePaths(source, root)].sort());
124
+ }
125
+ return graph;
126
+ }
127
+
106
128
  /**
107
129
  * Files a node's declared writes drag into the same change, minus those the
108
130
  * packet already declares or dismisses.
@@ -13,9 +13,9 @@ import { existsSync, readFileSync, readdirSync } from "node:fs";
13
13
  import { SETTLED } from "../engine/prompts.mjs";
14
14
  import { describeRuns } from "../run/disk-gc.mjs";
15
15
  import { worktreeRoot } from "../run/paths.mjs";
16
- import { git, gitHead, releaseRunWorktrees, runRefName } from "./worktree.mjs";
16
+ import { git, gitHead, releaseAttemptBranches, releaseRunWorktrees, runRefName } from "./worktree.mjs";
17
17
 
18
- /** @typedef {{runId: string, removed: number, archived: string[]}} PrunedRun */
18
+ /** @typedef {{runId: string, removed: number, archived: string[], branches: string[]}} PrunedRun */
19
19
 
20
20
  /**
21
21
  * Is the run's integrated head already on a branch of the operator's? An
@@ -48,7 +48,9 @@ function allNodesSettled(runDir) {
48
48
  * nodes are all terminal, and whose run ref a local branch already contains.
49
49
  * `releaseRunWorktrees` keeps each attempt's HEAD and uncommitted delta under
50
50
  * `refs/faberun-archive/`, so a released attempt can still be recovered.
51
- * `parked` also takes a run with a blocked or exhausted node: only on the
51
+ * The attempt branches of the same run go with them (AP21): a released
52
+ * worktree deletes only the branch it had checked out, so a run that tried
53
+ * three times kept three. `parked` also takes a run with a blocked or exhausted node: only on the
52
54
  * operator's word, because a resume that re-judges an attempt reads its
53
55
  * worktree, and only the operator knows the run was superseded.
54
56
  *
@@ -64,9 +66,15 @@ export function pruneIntegratedRunWorktrees(repo, runsDir, options = {}) {
64
66
  if (run.hasActiveController) continue;
65
67
  if (!run.allNodesTerminal && !(options.parked === true && allNodesSettled(run.path))) continue;
66
68
  const runId = basename(run.path);
67
- if (!existsSync(worktreeRoot(join(runsDir, runId), runId))) continue;
68
69
  if (!runRefLanded(repo, runId)) continue;
69
- pruned.push({ runId, ...releaseRunWorktrees(repo, join(runsDir, runId), runId) });
70
+ // The branch sweep is not gated on the worktree root: the run whose
71
+ // worktrees an earlier prune already released is exactly the run whose
72
+ // attempt branches are still there (AP21).
73
+ const runDir = join(runsDir, runId);
74
+ const released = existsSync(worktreeRoot(runDir, runId)) ? releaseRunWorktrees(repo, runDir, runId) : { removed: 0, archived: [] };
75
+ const branches = releaseAttemptBranches(repo, runId);
76
+ if (released.removed === 0 && branches.length === 0) continue;
77
+ pruned.push({ runId, ...released, branches });
70
78
  }
71
79
  return pruned;
72
80
  }
@@ -482,6 +482,60 @@ export function attemptWorkspace(state) {
482
482
  return path && state?.worktree?.status !== "removed" && existsSync(path) ? path : null;
483
483
  }
484
484
 
485
+ /**
486
+ * Delete the attempt branches of `runId` whose commit another ref already
487
+ * holds: the run's own `refs/faberun/<run>/…`, an archive ref written when a
488
+ * worktree was released, or any branch of the operator's. Measured 2026-09-28
489
+ * (AP21 of safe-to-hand-to-friend): `prune --parked` released 20 worktrees and
490
+ * left 133 branches `faberun/<run>/<node>/<n>` behind, every one of them
491
+ * already reachable from one of those refs; releasing a worktree deletes only
492
+ * the branch it had checked out.
493
+ *
494
+ * A branch nothing else holds is never deleted: an attempt that exists only as
495
+ * its own branch is the only copy of that work. Attempt branches are not
496
+ * keepers for each other, so the rule is about the run, not about whichever
497
+ * attempt happens to be newest.
498
+ *
499
+ * @param {string} repo
500
+ * @param {string} runId
501
+ * @returns {string[]} the branch names deleted, without `refs/heads/`
502
+ */
503
+ export function releaseAttemptBranches(repo, runId) {
504
+ const prefix = `refs/heads/faberun/${runId}/`;
505
+ /** @type {Array<{ref: string, commit: string}>} */
506
+ const attempts = git(repo, ["for-each-ref", "--format=%(refname) %(objectname)", "refs/heads/faberun/"])
507
+ .split("\n")
508
+ .map((line) => line.trim())
509
+ .filter(Boolean)
510
+ .map((line) => {
511
+ const [ref, commit] = line.split(" ");
512
+ return { ref: ref ?? "", commit: commit ?? "" };
513
+ })
514
+ .filter(({ ref }) => ref.startsWith(prefix));
515
+ if (attempts.length === 0) return [];
516
+ const keepers = git(repo, ["for-each-ref", "--format=%(refname)", "refs/heads", "refs/faberun", "refs/faberun-archive"])
517
+ .split("\n")
518
+ .map((line) => line.trim())
519
+ .filter((line) => line && !line.startsWith("refs/heads/faberun/"));
520
+ /** @type {string[]} */
521
+ const deleted = [];
522
+ for (const { ref, commit } of attempts) {
523
+ const held = keepers.some((keeper) => {
524
+ try {
525
+ git(repo, ["merge-base", "--is-ancestor", commit, keeper]);
526
+ return true;
527
+ } catch {
528
+ // Not an ancestor of this keeper; the next one may still hold it.
529
+ return false;
530
+ }
531
+ });
532
+ if (!held) continue;
533
+ deleteRef(repo, ref);
534
+ deleted.push(ref.slice("refs/heads/".length));
535
+ }
536
+ return deleted;
537
+ }
538
+
485
539
  /**
486
540
  * Release every attempt worktree a finished run left behind, keeping what it
487
541
  * held. Measured 2026-09-24: attempts that were never accepted (a failed or