faberun 0.28.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli/launch.mjs +20 -3
- package/src/cli/plan.mjs +56 -5
- package/src/cli.mjs +5 -1
- package/src/contract/definition-of-done.mjs +55 -0
- package/src/contract/index.mjs +1 -1
- package/src/contract/runtime.mjs +15 -5
- package/src/contract/worker-result.mjs +27 -11
- package/src/engine/live-preflight.mjs +4 -1
- package/src/engine/prompts.mjs +17 -1
- package/src/engine/scheduler.mjs +8 -1
- package/src/plan/freeze.mjs +50 -13
- package/src/plan/pipeline.mjs +123 -20
- package/src/plan/progress.mjs +101 -0
- package/src/plan/proof-check.mjs +13 -1
- package/src/plan/proof-scope.mjs +1 -1
- package/src/plan/repo-facts.mjs +64 -11
- package/src/plan/resolve.mjs +6 -4
- package/src/plan/rounds.mjs +37 -13
- package/src/plan/sizing.mjs +20 -4
- package/src/plan/spec.mjs +82 -6
- package/src/plan/template.mjs +182 -37
- package/src/repo/scope-closure.mjs +22 -0
- package/src/repo/worktree-prune.mjs +13 -5
- package/src/repo/worktree.mjs +54 -0
package/src/plan/template.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
148
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
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}
|
|
332
|
-
* @
|
|
399
|
+
* @param {unknown} value
|
|
400
|
+
* @param {string} label
|
|
401
|
+
* @returns {PlanOutputNode[]}
|
|
333
402
|
*/
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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,
|
|
346
|
-
requireId(nodeRecord.id, `${
|
|
347
|
-
requireString(nodeRecord.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(`${
|
|
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(`${
|
|
416
|
+
throw new TypeError(`${nodeLabel}.riskTier must be one of ${RISK_TIERS.join(", ")}`);
|
|
353
417
|
}
|
|
354
418
|
const dependsOn = nodeRecord.dependsOn ?? [];
|
|
355
|
-
requireStringArray(dependsOn, `${
|
|
419
|
+
requireStringArray(dependsOn, `${nodeLabel}.dependsOn`);
|
|
356
420
|
const readFiles = nodeRecord.readFiles ?? [];
|
|
357
|
-
requireStringArray(readFiles, `${
|
|
421
|
+
requireStringArray(readFiles, `${nodeLabel}.readFiles`);
|
|
358
422
|
const writeFiles = nodeRecord.writeFiles ?? [];
|
|
359
|
-
requireStringArray(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, `${
|
|
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 ?? [], `${
|
|
435
|
+
const verification = validateVerificationCommands(nodeRecord.verification ?? [], `${nodeLabel}.verification`);
|
|
372
436
|
const definitionOfDone = validateDefinitionOfDone(
|
|
373
437
|
nodeRecord.definitionOfDone ?? [],
|
|
374
|
-
`${
|
|
438
|
+
`${nodeLabel}.definitionOfDone`,
|
|
375
439
|
{ commands: verification, nodeId: /** @type {string} */ (nodeRecord.id) },
|
|
376
440
|
);
|
|
377
|
-
const expectedTurns = nodeRecord.expectedTurns === undefined ? undefined : positiveInteger(nodeRecord.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
|
-
*
|
|
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
|
-
|
|
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
|
}
|
package/src/repo/worktree.mjs
CHANGED
|
@@ -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
|