faberun 0.12.0 → 0.13.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/contract.md +10 -10
- package/src/contract/assert.mjs +5 -1
- package/src/contract/index.mjs +43 -7
- package/src/contract/runtime.mjs +4 -1
- package/src/contract/snapshot.mjs +25 -1
- package/src/contract/task-packet.mjs +5 -1
- package/src/engine/backoff.mjs +2 -1
- package/src/engine/capacity.mjs +74 -0
- package/src/engine/dispatch.mjs +12 -186
- package/src/engine/lifecycle.mjs +7 -2
- package/src/engine/phase-session.mjs +217 -0
- package/src/engine/process.mjs +26 -108
- package/src/engine/scheduler.mjs +15 -3
- package/src/engine/settle.mjs +11 -2
- package/src/engine/transcript.mjs +145 -0
- package/src/harnesses/claude/index.mjs +5 -0
- package/src/harnesses/dsh/runner.mjs +38 -3
- package/src/harnesses/exec-jsonl/index.mjs +0 -499
- package/src/harnesses/index.mjs +55 -8
- package/src/harnesses/protocol.mjs +22 -2
- package/src/harnesses/session-metrics.mjs +674 -0
- package/src/host/tool-policy-decisions.mjs +78 -21
- package/src/host/tool-policy-hook.mjs +12 -3
- package/src/plan/freeze.mjs +24 -6
- package/src/plan/pipeline.mjs +232 -67
- package/src/plan/sizing.mjs +0 -0
- package/src/plan/template.mjs +110 -8
- package/src/run/operations.mjs +1 -1
- package/src/run/usage.mjs +9 -3
- package/src/util.mjs +0 -0
package/src/plan/pipeline.mjs
CHANGED
|
@@ -4,15 +4,16 @@
|
|
|
4
4
|
* (sizing, routing, freeze), never as one long-lived process. Separate from
|
|
5
5
|
* `template.mjs` (which only builds the one-node contracts) and from
|
|
6
6
|
* `freeze.mjs` (which only turns a plan into a validated contract on disk):
|
|
7
|
-
* this module is the one place that sequences those runs,
|
|
8
|
-
* plan
|
|
9
|
-
*
|
|
7
|
+
* this module is the one place that sequences those runs, checks each round's
|
|
8
|
+
* plan against the contract it would freeze into while a revise can still
|
|
9
|
+
* act on the failure, and decides when a plan is contested instead of
|
|
10
|
+
* frozen. `launch` and `wait` are the only two seams that touch a
|
|
10
11
|
* process or the wall clock, so a test drives the whole pipeline through
|
|
11
12
|
* `runContract` in-process, deterministically.
|
|
12
13
|
*/
|
|
13
|
-
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
14
15
|
import { dirname, join, relative, resolve } from "node:path";
|
|
15
|
-
import { validateContract } from "../contract/index.mjs";
|
|
16
|
+
import { CONTRACT_VERSION, DEFAULT_MAX_TURNS, PROTOCOL_SCHEMA_VERSION, validateContract } from "../contract/index.mjs";
|
|
16
17
|
import { discoveryOutput } from "../contract/worker-result.mjs";
|
|
17
18
|
import { readWorkerResultFile } from "../engine/result-file.mjs";
|
|
18
19
|
import { classifyRunProgress } from "../campaign/chain.mjs";
|
|
@@ -24,7 +25,7 @@ import { allowanceDelta, allowanceEventFields, sampleAllowance } from "../seat/a
|
|
|
24
25
|
import { parseSpec, validateSpec } from "./spec.mjs";
|
|
25
26
|
import { collectRepoFacts } from "./repo-facts.mjs";
|
|
26
27
|
import { RISK_TIERS, buildPlanningContract, validateFindings, validatePlanOutput } from "./template.mjs";
|
|
27
|
-
import { applySizingRules } from "./sizing.mjs";
|
|
28
|
+
import { MIN_WRITE_FILES, applySizingRules } from "./sizing.mjs";
|
|
28
29
|
import { resolveRuntimes } from "./routing.mjs";
|
|
29
30
|
import { freezePlan } from "./freeze.mjs";
|
|
30
31
|
import { campaignTree, runDirectory } from "../run/paths.mjs";
|
|
@@ -155,90 +156,230 @@ export async function runPlanningPipeline(options) {
|
|
|
155
156
|
const progress = await wait(runDir);
|
|
156
157
|
const classification = classifyRunProgress(progress);
|
|
157
158
|
if (classification !== "succeeded") {
|
|
159
|
+
logStage(kind, { runId: validated.id, failed: classification });
|
|
158
160
|
throw new Error(`planning stage ${kind} did not succeed: run ${validated.id} ${classification}`);
|
|
159
161
|
}
|
|
160
162
|
const result = readWorkerResultFile(runDir, kind);
|
|
161
163
|
const output = result ? discoveryOutput(result) : null;
|
|
162
|
-
if (!output)
|
|
164
|
+
if (!output) {
|
|
165
|
+
logStage(kind, { runId: validated.id, failed: "no_discovery_output" });
|
|
166
|
+
throw new Error(`planning stage ${kind}: run ${validated.id} recorded no discovery output`);
|
|
167
|
+
}
|
|
163
168
|
return { contract: validated, output };
|
|
164
169
|
};
|
|
165
170
|
|
|
166
171
|
const draft = await runStage("draft", { specPath: relativeSpecPath, repoFactsPath: relativeRepoFactsPath });
|
|
167
|
-
|
|
168
|
-
|
|
172
|
+
/** @type {PlanOutput|null} */
|
|
173
|
+
let plan = null;
|
|
174
|
+
/** @type {PlanFindingOutput[]} */
|
|
175
|
+
let findings = [];
|
|
176
|
+
try {
|
|
177
|
+
plan = validatePlanOutput(draft.output.plan);
|
|
178
|
+
} catch (error) {
|
|
179
|
+
findings = [invalidPlanFinding("draft", error)];
|
|
180
|
+
}
|
|
181
|
+
logStage("draft", plan === null
|
|
182
|
+
? { runId: draft.contract.id, invalid: findings[0].text }
|
|
183
|
+
: { runId: draft.contract.id, nodeCount: plan.nodes.length });
|
|
169
184
|
|
|
170
185
|
const workingPlanPath = join(scratchDir, "plan.working.json");
|
|
171
|
-
writeJsonAtomic(workingPlanPath, plan);
|
|
172
186
|
const relativeWorkingPlanPath = relative(cwd, workingPlanPath);
|
|
173
187
|
|
|
174
|
-
/**
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
188
|
+
/**
|
|
189
|
+
* End the pipeline the way an unresolvable plan already ends: the contested
|
|
190
|
+
* result, the outstanding findings that forced it, and an open question on
|
|
191
|
+
* the campaign journal. Every no-valid-plan exit funnels through here.
|
|
192
|
+
*
|
|
193
|
+
* @param {number} round
|
|
194
|
+
* @returns {Promise<ContestedPipelineResult>}
|
|
195
|
+
*/
|
|
196
|
+
const contest = async (round) => {
|
|
179
197
|
const criticalFindings = findings.filter((finding) => finding.severity === "critical");
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
]);
|
|
191
|
-
return { status: "contested", plansDir, planPath, findings, round };
|
|
192
|
-
}
|
|
193
|
-
const findingsPath = join(scratchDir, `findings-round-${round}.json`);
|
|
194
|
-
writeJsonAtomic(findingsPath, findings);
|
|
195
|
-
const revise = await runStage("revise", {
|
|
196
|
-
specPath: relativeSpecPath, repoFactsPath: relativeRepoFactsPath, findingsPath: relative(cwd, findingsPath),
|
|
197
|
-
});
|
|
198
|
-
plan = validatePlanOutput(revise.output.plan);
|
|
199
|
-
writeJsonAtomic(workingPlanPath, plan);
|
|
200
|
-
logStage("revise", { round, runId: revise.contract.id });
|
|
201
|
-
}
|
|
198
|
+
const planPath = join(plansDir, "plan.json");
|
|
199
|
+
writeJsonAtomic(planPath, { formatVersion: 1, status: "contested", rounds: round, findings });
|
|
200
|
+
logStage("contested", { round, criticalCount: criticalFindings.length });
|
|
201
|
+
await campaignCli([
|
|
202
|
+
"note", campaignId, "--cwd", cwd, "--session-id", PLANNER_SESSION_ID,
|
|
203
|
+
"--kind", "open-question", "--question-id", `plan-${phase}-contested`,
|
|
204
|
+
"--text", `Plan for phase ${phase} is contested after ${round} review round(s): ${criticalFindings.map((finding) => finding.text).join("; ")}`,
|
|
205
|
+
]);
|
|
206
|
+
return { status: "contested", plansDir, planPath, findings, round };
|
|
207
|
+
};
|
|
202
208
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
209
|
+
// Which deterministic stage is running, advanced by `assembleFrozenNodes`
|
|
210
|
+
// so the freeze-wrap catch below names the stage that threw.
|
|
211
|
+
let stage = "sizing";
|
|
212
|
+
// The last review round entered, so the freeze-wrap catch contests with
|
|
213
|
+
// the count of rounds that actually ran.
|
|
214
|
+
let roundsRun = 0;
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The one assembly a plan freezes through, one home for the expression the
|
|
218
|
+
* in-round pre-flight and the final freeze must run identically: sizing
|
|
219
|
+
* reshapes the drafted nodes, routing assigns worker and judge, and
|
|
220
|
+
* `toContractNode` renders the contract shape. Local and cheap — no I/O,
|
|
221
|
+
* no model — so running it once per round costs nothing. It advances
|
|
222
|
+
* `stage` as it goes; see the declaration above.
|
|
223
|
+
*
|
|
224
|
+
* @param {PlanOutput} currentPlan
|
|
225
|
+
* @returns {{sizing: import("./sizing.mjs").SizingResult, routing: import("./routing.mjs").RoutingResult, nodes: JsonObject[]}}
|
|
226
|
+
*/
|
|
227
|
+
const assembleFrozenNodes = (currentPlan) => {
|
|
228
|
+
stage = "sizing";
|
|
229
|
+
const sizing = applySizingRules(
|
|
230
|
+
{ nodes: currentPlan.nodes.map(toSizingNode), justification: currentPlan.justification },
|
|
231
|
+
{ nodeBudgetMs: DEFAULT_NODE_BUDGET_MS, facts: repoFacts, minWriteFiles: MIN_WRITE_FILES, turnCeiling: DEFAULT_MAX_TURNS },
|
|
232
|
+
);
|
|
233
|
+
stage = "routing";
|
|
234
|
+
const routing = resolveRuntimes(sizing.plan.nodes, {
|
|
235
|
+
table: [...DEFAULT_ROUTING_TABLE],
|
|
236
|
+
runtimes: /** @type {Record<string, import("./routing.mjs").RoutingRuntime>} */ (runtimes),
|
|
237
|
+
availability: availabilityOf(runtimes),
|
|
238
|
+
runtimeDefaults,
|
|
239
|
+
});
|
|
240
|
+
stage = "freeze";
|
|
241
|
+
return {
|
|
242
|
+
sizing,
|
|
243
|
+
routing,
|
|
244
|
+
nodes: sizing.plan.nodes.map((node) => toContractNode(/** @type {SizedPlanNode} */ (node), phase, routing.assignments[node.id])),
|
|
245
|
+
};
|
|
246
|
+
};
|
|
217
247
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
248
|
+
/**
|
|
249
|
+
* The raw contract exactly as `freezePlan` will assemble and validate it,
|
|
250
|
+
* schemaVersion and contractVersion included. `validateContract` never
|
|
251
|
+
* reads the path it is given — only `dirname(resolve(contractPath))` to
|
|
252
|
+
* resolve `raw.cwd` — so the pre-flight passes the path the contract will
|
|
253
|
+
* occupy, `contract.json` inside `plansDir`, and validates in memory
|
|
254
|
+
* without writing or deleting anything.
|
|
255
|
+
*
|
|
256
|
+
* @param {JsonObject[]} nodes
|
|
257
|
+
* @returns {JsonObject}
|
|
258
|
+
*/
|
|
259
|
+
const frozenContractRaw = (nodes) => ({
|
|
260
|
+
schemaVersion: PROTOCOL_SCHEMA_VERSION,
|
|
261
|
+
contractVersion: CONTRACT_VERSION,
|
|
221
262
|
id: `${campaignId}-${phase}`,
|
|
222
263
|
campaignId,
|
|
223
264
|
goal: campaign.goal,
|
|
224
|
-
// `freezePlan` writes contract.json inside `outDir` (`plansDir`), so
|
|
225
|
-
// has to point back at the repo root from there, exactly like
|
|
226
|
-
// computes it for the nodes it writes under `plansDir/nodes/`.
|
|
265
|
+
// `freezePlan` writes contract.json inside `outDir` (`plansDir`), so
|
|
266
|
+
// `cwd` has to point back at the repo root from there, exactly like
|
|
267
|
+
// `runStage` computes it for the nodes it writes under `plansDir/nodes/`.
|
|
227
268
|
cwd: relative(plansDir, cwd) || ".",
|
|
228
269
|
runtimes,
|
|
229
270
|
runtimeDefaults,
|
|
230
271
|
nodes,
|
|
231
|
-
}, {
|
|
232
|
-
outDir: plansDir,
|
|
233
|
-
provenance: {
|
|
234
|
-
targetGitHead: repoFacts.gitHead,
|
|
235
|
-
planner: { runtimeId: runtimeDefaults.worker ?? "", model: modelOf(runtimes, runtimeDefaults.worker) },
|
|
236
|
-
reviewer: { runtimeId: runtimeDefaults.judge ?? "", model: modelOf(runtimes, runtimeDefaults.judge) },
|
|
237
|
-
sizing: sizing.transformations,
|
|
238
|
-
findings,
|
|
239
|
-
},
|
|
240
272
|
});
|
|
241
|
-
|
|
273
|
+
|
|
274
|
+
for (let round = 1; round <= reviewRounds; round += 1) {
|
|
275
|
+
roundsRun = round;
|
|
276
|
+
if (plan) {
|
|
277
|
+
// The reviewer grades a structurally valid plan; an invalid one skips
|
|
278
|
+
// review and reaches revise through the validator's finding instead.
|
|
279
|
+
writeJsonAtomic(workingPlanPath, plan);
|
|
280
|
+
const review = await runStage("review", { specPath: relativeSpecPath, repoFactsPath: relativeRepoFactsPath, planPath: relativeWorkingPlanPath });
|
|
281
|
+
/** @type {PlanFindingOutput|null} */
|
|
282
|
+
let invalidFindings = null;
|
|
283
|
+
try {
|
|
284
|
+
findings = validateFindings(review.output.findings);
|
|
285
|
+
} catch (error) {
|
|
286
|
+
// The review said nothing usable about the plan, so the plan cannot be
|
|
287
|
+
// treated as clean: the malformed-output finding is critical and
|
|
288
|
+
// drives the same revise-or-contest path a real critical finding does,
|
|
289
|
+
// with the still-outstanding findings riding along.
|
|
290
|
+
invalidFindings = invalidPlanFinding(`review-r${round}`, error);
|
|
291
|
+
findings = [...findings, invalidFindings];
|
|
292
|
+
}
|
|
293
|
+
// The contract this plan would freeze into is checked here, inside the
|
|
294
|
+
// round and after the review's findings replaced the previous round's,
|
|
295
|
+
// because freeze runs after the last one: caught here, a plan that
|
|
296
|
+
// cannot freeze still has a revise left to fix it. The failure is a
|
|
297
|
+
// critical finding, never an auto-filled acknowledgement — scope
|
|
298
|
+
// closure exists to force the per-file decision (declare a write, or
|
|
299
|
+
// acknowledge a read-only importer), and the revise worker, which can
|
|
300
|
+
// read the repository, makes it from the validator's own message.
|
|
301
|
+
/** @type {PlanFindingOutput|null} */
|
|
302
|
+
let freezeFailure = null;
|
|
303
|
+
try {
|
|
304
|
+
validateContract(frozenContractRaw(assembleFrozenNodes(plan).nodes), join(plansDir, "contract.json"));
|
|
305
|
+
} catch (error) {
|
|
306
|
+
freezeFailure = invalidPlanFinding(`freeze-r${round}`, error);
|
|
307
|
+
findings = [...findings, freezeFailure];
|
|
308
|
+
}
|
|
309
|
+
logStage("review", {
|
|
310
|
+
round,
|
|
311
|
+
runId: review.contract.id,
|
|
312
|
+
findingsCount: findings.length,
|
|
313
|
+
criticalCount: findings.filter((finding) => finding.severity === "critical").length,
|
|
314
|
+
...(freezeFailure === null ? {} : { freezeFailed: freezeFailure.text }),
|
|
315
|
+
...(invalidFindings === null ? {} : { invalid: invalidFindings.text }),
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
const criticalFindings = findings.filter((finding) => finding.severity === "critical");
|
|
319
|
+
if (criticalFindings.length === 0) break;
|
|
320
|
+
if (round === reviewRounds) return await contest(round);
|
|
321
|
+
const findingsPath = join(scratchDir, `findings-round-${round}.json`);
|
|
322
|
+
writeJsonAtomic(findingsPath, findings);
|
|
323
|
+
const revise = await runStage("revise", {
|
|
324
|
+
specPath: relativeSpecPath, repoFactsPath: relativeRepoFactsPath, findingsPath: relative(cwd, findingsPath),
|
|
325
|
+
});
|
|
326
|
+
/** @type {PlanFindingOutput|null} */
|
|
327
|
+
let invalid = null;
|
|
328
|
+
try {
|
|
329
|
+
plan = validatePlanOutput(revise.output.plan);
|
|
330
|
+
} catch (error) {
|
|
331
|
+
// The revise output was rejected wholesale, so the round's review
|
|
332
|
+
// findings are still outstanding and ride along to the next round.
|
|
333
|
+
invalid = invalidPlanFinding(`revise-r${round}`, error);
|
|
334
|
+
plan = null;
|
|
335
|
+
findings = [...findings, invalid];
|
|
336
|
+
}
|
|
337
|
+
logStage("revise", { round, runId: revise.contract.id, ...(invalid === null ? {} : { invalid: invalid.text }) });
|
|
338
|
+
}
|
|
339
|
+
// Reached only when no review round is configured (reviewRounds <= 0) and
|
|
340
|
+
// the draft never validated: no revise exists to reach, so contested is the
|
|
341
|
+
// end rather than a silent crash at sizing.
|
|
342
|
+
if (plan === null) return await contest(0);
|
|
343
|
+
|
|
344
|
+
// The pre-flight ran this exact assembly through the same validator in the
|
|
345
|
+
// round that just broke, so a failure here is nearly impossible — but
|
|
346
|
+
// `nearly` is what this whole phase is about: an unanticipated failure
|
|
347
|
+
// writes its stage line and takes the contested exit instead of leaving
|
|
348
|
+
// pipeline.jsonl stopping between stages.
|
|
349
|
+
/** @type {{sizing: import("./sizing.mjs").SizingResult, routing: import("./routing.mjs").RoutingResult, nodes: JsonObject[]}|undefined} */
|
|
350
|
+
let assembled;
|
|
351
|
+
/** @type {import("./freeze.mjs").FrozenPlan|undefined} */
|
|
352
|
+
let frozen;
|
|
353
|
+
/** @type {string|undefined} */
|
|
354
|
+
let highestRiskTier;
|
|
355
|
+
try {
|
|
356
|
+
assembled = assembleFrozenNodes(plan);
|
|
357
|
+
logStage("sizing", { transformations: assembled.sizing.transformations.length, nodeCount: assembled.sizing.plan.nodes.length, overheadMinutes: assembled.sizing.estimate.overheadMinutes });
|
|
358
|
+
logStage("routing", { assignments: Object.keys(assembled.routing.assignments).length });
|
|
359
|
+
highestRiskTier = highestOf(assembled.sizing.plan.nodes.map((node) => node.riskTier ?? RISK_TIERS[0]));
|
|
360
|
+
frozen = freezePlan(frozenContractRaw(assembled.nodes), {
|
|
361
|
+
outDir: plansDir,
|
|
362
|
+
provenance: {
|
|
363
|
+
targetGitHead: repoFacts.gitHead,
|
|
364
|
+
planner: { runtimeId: runtimeDefaults.worker ?? "", model: modelOf(runtimes, runtimeDefaults.worker) },
|
|
365
|
+
reviewer: { runtimeId: runtimeDefaults.judge ?? "", model: modelOf(runtimes, runtimeDefaults.judge) },
|
|
366
|
+
sizing: assembled.sizing.transformations,
|
|
367
|
+
findings,
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
logStage("freeze", { contractId: `${campaignId}-${phase}`, highestRiskTier });
|
|
371
|
+
} catch (error) {
|
|
372
|
+
// The stage line the pipeline would otherwise have stopped short of, the
|
|
373
|
+
// failure carried as the critical finding that names it, and the
|
|
374
|
+
// contested end every other unresolvable plan takes.
|
|
375
|
+
logStage(stage, { failed: error instanceof Error ? error.message : String(error) });
|
|
376
|
+
findings = [...findings, invalidPlanFinding(stage, error)];
|
|
377
|
+
// freezePlan removes contract.json itself when validation refuses it; a
|
|
378
|
+
// failure after that write and before its return would otherwise leave a
|
|
379
|
+
// contract.json no contested result may have.
|
|
380
|
+
rmSync(join(plansDir, "contract.json"), { force: true });
|
|
381
|
+
return await contest(roundsRun);
|
|
382
|
+
}
|
|
242
383
|
|
|
243
384
|
// A delta only means something between two samples of the same seat: freeze
|
|
244
385
|
// re-samples the exact harness `campaign init` recorded at `sample: "start"`
|
|
@@ -294,6 +435,24 @@ function repoRelativePath(cwd, path, label) {
|
|
|
294
435
|
return relativePath;
|
|
295
436
|
}
|
|
296
437
|
|
|
438
|
+
/**
|
|
439
|
+
* A validatePlanOutput, validateFindings or in-round validateContract
|
|
440
|
+
* rejection, shaped as the finding a review round already carries to revise,
|
|
441
|
+
* so a structurally invalid plan, a review whose findings are not findings —
|
|
442
|
+
* or a plan that would not survive freeze — reaches the stage that can act on
|
|
443
|
+
* it instead of killing the pipeline between the run finishing and its stage
|
|
444
|
+
* line. `nodeId` is "plan" because the validator's message names a path into
|
|
445
|
+
* the plan, not one of its nodes.
|
|
446
|
+
*
|
|
447
|
+
* @param {string} label
|
|
448
|
+
* @param {unknown} error
|
|
449
|
+
* @returns {PlanFindingOutput}
|
|
450
|
+
*/
|
|
451
|
+
function invalidPlanFinding(label, error) {
|
|
452
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
453
|
+
return { id: `plan-shape-${label}`, severity: "critical", nodeId: "plan", text };
|
|
454
|
+
}
|
|
455
|
+
|
|
297
456
|
/**
|
|
298
457
|
* Every declared runtime treated as available. Live discovery (probing a
|
|
299
458
|
* harness for real exhaustion) is a separate concern this pipeline does not
|
|
@@ -342,10 +501,12 @@ function toSizingNode(node) {
|
|
|
342
501
|
taskKind: node.taskKind,
|
|
343
502
|
riskTier: node.riskTier,
|
|
344
503
|
objective: node.objective,
|
|
504
|
+
expectedTurns: node.expectedTurns,
|
|
345
505
|
definitionOfDone: node.definitionOfDone,
|
|
346
506
|
taskPacket: {
|
|
347
507
|
readFiles: node.readFiles,
|
|
348
508
|
writeFiles: node.writeFiles,
|
|
509
|
+
scopeAcknowledged: node.scopeAcknowledged,
|
|
349
510
|
verification: node.verification,
|
|
350
511
|
},
|
|
351
512
|
});
|
|
@@ -383,6 +544,10 @@ function toContractNode(node, phase, assignment) {
|
|
|
383
544
|
instructions: [node.objective],
|
|
384
545
|
readFiles: node.taskPacket.readFiles ?? [],
|
|
385
546
|
writeFiles: node.taskPacket.writeFiles ?? [],
|
|
547
|
+
// Carried, never computed here: the drafter decided which importers it
|
|
548
|
+
// will not change, and scope closure exists to force that decision on a
|
|
549
|
+
// person rather than answer it for them (src/repo/scope-closure.mjs).
|
|
550
|
+
scopeAcknowledged: node.taskPacket.scopeAcknowledged ?? [],
|
|
386
551
|
symbols: [],
|
|
387
552
|
decisions: [],
|
|
388
553
|
nonGoals: [],
|
package/src/plan/sizing.mjs
CHANGED
|
Binary file
|
package/src/plan/template.mjs
CHANGED
|
@@ -8,8 +8,14 @@
|
|
|
8
8
|
* accepts, from exactly the inputs each role may see. The reviewer's packet
|
|
9
9
|
* is the enforced case: it carries the spec, the repository facts and the
|
|
10
10
|
* artefact under review, never the author's packet, transcript or summary.
|
|
11
|
+
*
|
|
12
|
+
* The plan output validator also owns requirement traceability: a plan
|
|
13
|
+
* declares, per phase, the requirement ids it satisfies and its one-sentence
|
|
14
|
+
* deliverable, and a phase associated with no requirement is reported as a
|
|
15
|
+
* finding — never a silent pass, never a refusal. freeze.mjs imports the same
|
|
16
|
+
* phase check for the frozen plan record.
|
|
11
17
|
*/
|
|
12
|
-
import { assertObject, rejectUnknown, requireId, requireString, requireStringArray } from "../contract/assert.mjs";
|
|
18
|
+
import { assertObject, positiveInteger, rejectUnknown, requireId, requireString, requireStringArray } from "../contract/assert.mjs";
|
|
13
19
|
import { CONTRACT_VERSION, PROTOCOL_SCHEMA_VERSION } from "../contract/index.mjs";
|
|
14
20
|
import { validateDefinitionOfDone } from "../contract/definition-of-done.mjs";
|
|
15
21
|
import { validateVerificationCommands } from "../contract/verification.mjs";
|
|
@@ -18,8 +24,9 @@ import { validateVerificationCommands } from "../contract/verification.mjs";
|
|
|
18
24
|
/** @typedef {"draft"|"review"|"revise"|"spec-author"|"spec-review"} PlanningKind */
|
|
19
25
|
/** @typedef {"low"|"standard"|"high"} RiskTier */
|
|
20
26
|
/** @typedef {{campaignId: string, phase: string, n: number, goal?: string, cwd?: string, runtimes: Record<string, JsonObject>, runtimeDefaults: {worker?: string, judge?: string}, specPath?: string, repoFactsPath?: string, planPath?: string, findingsPath?: string, notesPath?: string}} PlanningContractInputs */
|
|
21
|
-
/** @typedef {{id: string, objective: string, taskKind: string, riskTier: RiskTier, dependsOn: string[], readFiles: string[], writeFiles: string[], definitionOfDone: import("../contract/definition-of-done.mjs").DefinitionOfDoneItem[], verification: import("../contract/verification.mjs").VerificationCommand[]}} PlanOutputNode */
|
|
22
|
-
/** @typedef {{nodes: PlanOutputNode[], justification?: string}} PlanOutput */
|
|
27
|
+
/** @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 */
|
|
28
|
+
/** @typedef {{nodes: PlanOutputNode[], phases?: PlanPhase[], findings?: PlanFindingOutput[], justification?: string}} PlanOutput */
|
|
29
|
+
/** @typedef {{id: string, requirementIds: string[], deliverable: string}} PlanPhase */
|
|
23
30
|
/** @typedef {{id: string, severity: "critical"|"major"|"minor", nodeId: string, text: string}} PlanFindingOutput */
|
|
24
31
|
|
|
25
32
|
/**
|
|
@@ -62,9 +69,35 @@ const REQUIRED_INPUTS = Object.freeze({
|
|
|
62
69
|
"spec-review": ["specPath"],
|
|
63
70
|
});
|
|
64
71
|
|
|
65
|
-
|
|
72
|
+
// The nested shapes are spelled from DefinitionOfDoneItem
|
|
73
|
+
// (contract/definition-of-done.mjs) and VerificationCommand
|
|
74
|
+
// (contract/verification.mjs), never from prose: observed 2026-09-20, bare
|
|
75
|
+
// field names made a worker guess — a DoD item with no id, a `command` string
|
|
76
|
+
// where argv belongs — and the guess failed validatePlanOutput only after the
|
|
77
|
+
// run had already succeeded. The id charset is requireId's
|
|
78
|
+
// (contract/assert.mjs) verbatim, because an id that is present but invalid
|
|
79
|
+
// fails that same validator just as late.
|
|
80
|
+
const PLAN_OUTPUT_SHAPE = '{nodes: [{id, objective, taskKind, riskTier, dependsOn, readFiles, writeFiles, scopeAcknowledged, definitionOfDone: [{id, text, proof?: {kind: "command"|"path"|"verification", ref}, judgment?: true}], verification: [{argv: [string], cwd?, timeoutSec?, repeat?, env?, mutation?: {threshold}}], expectedTurns?}], phases?: [{id, requirementIds?: [string], deliverable}], justification?}; every id in it (node, phase, and definitionOfDone item) must match [A-Za-z0-9._-]+ and never be exactly "." or ".."';
|
|
81
|
+
/**
|
|
82
|
+
* The size guidance every draft and revise carries. measured 2026-09-20 over
|
|
83
|
+
* stored runs: median 49 provider requests per worker turn; cost per turn
|
|
84
|
+
* nearly independent of the write set; 14.5 minutes of verification, judge
|
|
85
|
+
* and integration per node outside its worker turn; an attempt is cut at
|
|
86
|
+
* 150 requests.
|
|
87
|
+
*/
|
|
88
|
+
const SIZING_INSTRUCTION = "Size nodes to 4 to 6 write files where the work allows, and give every node an expectedTurns: the provider requests one worker needs to finish it end to end (measured median 49 for 4 to 6 files). A smaller node pays the same orientation and about 15 minutes of verification, judge and integration for less delivered work; a node you expect past 150 requests must be split, because a run cuts an attempt there.";
|
|
66
89
|
const FINDINGS_SHAPE = "[{id, severity, nodeId, text}]";
|
|
67
90
|
|
|
91
|
+
// The rule every planned packet is held to at freeze time, worded from
|
|
92
|
+
// AGENTS.md's Faberun protocol and src/repo/scope-closure.mjs ("reading it
|
|
93
|
+
// cannot fix it"): a first draft that ignores it produces a plan that fails
|
|
94
|
+
// the pipeline's in-round contract check a round later than a draft that
|
|
95
|
+
// could have written the write set honestly.
|
|
96
|
+
const SCOPE_CLOSURE_RULE = Object.freeze([
|
|
97
|
+
"Each node's writeFiles lists what the change forces to change, not only what it intends to: the importers a written module drags along, the schema validator for a field the node adds, the registry that field is recorded in, and any reader the node's own instructions tell the worker to touch.",
|
|
98
|
+
"Scope closure refuses a task packet whose transitive imports reach an undeclared file, so an importer the change breaks belongs in writeFiles or scopeAcknowledged — readFiles only permits reading, and a broken importer can only be repaired by a write.",
|
|
99
|
+
]);
|
|
100
|
+
|
|
68
101
|
/** @type {Record<PlanningKind, string>} */
|
|
69
102
|
const OBJECTIVES = Object.freeze({
|
|
70
103
|
draft: "Draft an execution plan for this phase: classify every node's taskKind and riskTier from the spec and the repository facts, and propose the dependency graph.",
|
|
@@ -78,14 +111,19 @@ const OBJECTIVES = Object.freeze({
|
|
|
78
111
|
const INSTRUCTIONS = Object.freeze({
|
|
79
112
|
draft: [
|
|
80
113
|
`Consult ${TASK_KIND_CATALOGUE_PATH}'s exported TASK_KINDS before classifying any node; taskKind must be one of that catalogue and riskTier must be one of ${RISK_TIERS.join(", ")}.`,
|
|
114
|
+
"Declare every phase the plan serves in output.plan.phases: the requirement ids (R<n> from the spec) the phase satisfies and the deliverable it produces in one sentence. A phase associated with no requirement is reported as a finding, not refused.",
|
|
115
|
+
...SCOPE_CLOSURE_RULE,
|
|
81
116
|
`Return exactly one worker-result JSON object. Put the plan in output.plan as ${PLAN_OUTPUT_SHAPE} and nothing else in output.`,
|
|
82
117
|
"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.",
|
|
118
|
+
SIZING_INSTRUCTION,
|
|
83
119
|
],
|
|
84
120
|
revise: [
|
|
85
121
|
"Read the findings and resolve every one; do not leave a critical or major finding unaddressed.",
|
|
86
|
-
|
|
122
|
+
"Declare every phase the plan serves in output.plan.phases: the requirement ids (R<n> from the spec) the phase satisfies and the deliverable it produces in one sentence. A phase associated with no requirement is reported as a finding, not refused.",
|
|
123
|
+
...SCOPE_CLOSURE_RULE,
|
|
87
124
|
`Return exactly one worker-result JSON object. Put the revised plan in output.plan as ${PLAN_OUTPUT_SHAPE} and nothing else in output.`,
|
|
88
125
|
"Never name a runtime, harness, model, or vendor anywhere in output.plan.",
|
|
126
|
+
SIZING_INSTRUCTION,
|
|
89
127
|
],
|
|
90
128
|
review: [
|
|
91
129
|
"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.",
|
|
@@ -187,13 +225,15 @@ export function buildPlanningContract(kind, inputs) {
|
|
|
187
225
|
};
|
|
188
226
|
}
|
|
189
227
|
|
|
190
|
-
const PLAN_FIELDS = new Set(["nodes", "justification"]);
|
|
191
|
-
const PLAN_NODE_FIELDS = new Set(["id", "objective", "taskKind", "riskTier", "dependsOn", "readFiles", "writeFiles", "definitionOfDone", "verification"]);
|
|
228
|
+
const PLAN_FIELDS = new Set(["nodes", "phases", "justification"]);
|
|
229
|
+
const PLAN_NODE_FIELDS = new Set(["id", "objective", "taskKind", "riskTier", "dependsOn", "readFiles", "writeFiles", "scopeAcknowledged", "definitionOfDone", "verification", "expectedTurns"]);
|
|
192
230
|
|
|
193
231
|
/**
|
|
194
232
|
* Validate a draft or revise worker's `output.plan`. Rejects a node naming a
|
|
195
233
|
* runtime, harness, model, or vendor (an unknown field, since a plan node's
|
|
196
|
-
* shape never includes one) and a node missing taskKind or riskTier.
|
|
234
|
+
* shape never includes one) and a node missing taskKind or riskTier. A
|
|
235
|
+
* declared phase associated with no requirement is reported as a finding on
|
|
236
|
+
* the result, never a silent pass and never a refusal.
|
|
197
237
|
*
|
|
198
238
|
* @param {unknown} plan
|
|
199
239
|
* @returns {PlanOutput}
|
|
@@ -224,8 +264,17 @@ export function validatePlanOutput(plan) {
|
|
|
224
264
|
requireStringArray(readFiles, `${label}.readFiles`);
|
|
225
265
|
const writeFiles = nodeRecord.writeFiles ?? [];
|
|
226
266
|
requireStringArray(writeFiles, `${label}.writeFiles`);
|
|
267
|
+
// A node that reaches an importer it must not change says so here. Without
|
|
268
|
+
// this field the drafter is told (INSTRUCTIONS, draft/revise) to answer a
|
|
269
|
+
// scope-closure finding with `writeFiles or scopeAcknowledged` and has no
|
|
270
|
+
// way to say the second, so the only expressible answer is the wrong one:
|
|
271
|
+
// declaring a read-only importer writable. Measured 2026-09-20 -- a revise
|
|
272
|
+
// round could not resolve the finding the preflight had just raised.
|
|
273
|
+
const scopeAcknowledged = nodeRecord.scopeAcknowledged ?? [];
|
|
274
|
+
requireStringArray(scopeAcknowledged, `${label}.scopeAcknowledged`);
|
|
227
275
|
const definitionOfDone = validateDefinitionOfDone(nodeRecord.definitionOfDone ?? [], `${label}.definitionOfDone`);
|
|
228
276
|
const verification = validateVerificationCommands(nodeRecord.verification ?? [], `${label}.verification`);
|
|
277
|
+
const expectedTurns = nodeRecord.expectedTurns === undefined ? undefined : positiveInteger(nodeRecord.expectedTurns, `${label}.expectedTurns`);
|
|
229
278
|
return /** @type {PlanOutputNode} */ ({
|
|
230
279
|
id: /** @type {string} */ (nodeRecord.id),
|
|
231
280
|
objective: /** @type {string} */ (nodeRecord.objective),
|
|
@@ -234,17 +283,70 @@ export function validatePlanOutput(plan) {
|
|
|
234
283
|
dependsOn: /** @type {string[]} */ (dependsOn),
|
|
235
284
|
readFiles: /** @type {string[]} */ (readFiles),
|
|
236
285
|
writeFiles: /** @type {string[]} */ (writeFiles),
|
|
286
|
+
scopeAcknowledged: /** @type {string[]} */ (scopeAcknowledged),
|
|
237
287
|
definitionOfDone,
|
|
238
288
|
verification,
|
|
289
|
+
...(expectedTurns === undefined ? {} : { expectedTurns }),
|
|
239
290
|
});
|
|
240
291
|
});
|
|
241
292
|
if (record.justification !== undefined) requireString(record.justification, "plan.justification");
|
|
293
|
+
const phases = validatePlanPhases(record.phases);
|
|
294
|
+
const findings = (phases ?? [])
|
|
295
|
+
.filter((phase) => phase.requirementIds.length === 0)
|
|
296
|
+
.map((phase) => /** @type {PlanFindingOutput} */ ({
|
|
297
|
+
id: `no-requirement-${phase.id}`,
|
|
298
|
+
// minor, not major: a support phase with no direct requirement is a
|
|
299
|
+
// question for review, not a defect — the finding exists so the gap is
|
|
300
|
+
// never silent.
|
|
301
|
+
severity: "minor",
|
|
302
|
+
nodeId: phase.id,
|
|
303
|
+
text: `Phase ${phase.id} is associated with no requirement: fill requirementIds with the R<n> ids from the spec that it satisfies, or fold it into a phase that does.`,
|
|
304
|
+
}));
|
|
242
305
|
return {
|
|
243
306
|
nodes,
|
|
307
|
+
...(phases === undefined ? {} : { phases }),
|
|
308
|
+
...(findings.length > 0 ? { findings } : {}),
|
|
244
309
|
...(record.justification === undefined ? {} : { justification: /** @type {string} */ (record.justification) }),
|
|
245
310
|
};
|
|
246
311
|
}
|
|
247
312
|
|
|
313
|
+
// The fields a phase declaration carries beyond its id: requirementIds|deliverable —
|
|
314
|
+
// which spec requirements the phase satisfies, and the one sentence naming its result.
|
|
315
|
+
const PLAN_PHASE_FIELDS = new Set(["id", "requirementIds", "deliverable"]);
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Validate a plan's `phases` — the per-phase requirement declarations: the
|
|
319
|
+
* requirement ids (R<n> from the spec) each phase satisfies and the
|
|
320
|
+
* deliverable it produces in one sentence. Absent, empty, and requirement-less
|
|
321
|
+
* are legal here: a support phase with no requirement is the caller's finding
|
|
322
|
+
* to report (validatePlanOutput does), so the gap stays visible without a
|
|
323
|
+
* malformed-but-honest plan being refused. Shared with freeze.mjs, which holds
|
|
324
|
+
* the same declarations on the frozen plan record.
|
|
325
|
+
*
|
|
326
|
+
* @param {unknown} phases
|
|
327
|
+
* @returns {PlanPhase[]|undefined} the normalized declarations, or undefined when none were given
|
|
328
|
+
*/
|
|
329
|
+
export function validatePlanPhases(phases) {
|
|
330
|
+
if (phases === undefined) return undefined;
|
|
331
|
+
if (!Array.isArray(phases)) throw new TypeError("plan.phases must be an array of phase declarations");
|
|
332
|
+
if (phases.length === 0) return undefined;
|
|
333
|
+
return phases.map((phase, index) => {
|
|
334
|
+
const label = `plan.phases[${index}]`;
|
|
335
|
+
assertObject(phase, label);
|
|
336
|
+
const record = /** @type {Record<string, unknown>} */ (phase);
|
|
337
|
+
rejectUnknown(record, PLAN_PHASE_FIELDS, label);
|
|
338
|
+
requireId(record.id, `${label}.id`);
|
|
339
|
+
requireString(record.deliverable, `${label}.deliverable`);
|
|
340
|
+
const requirementIds = record.requirementIds ?? [];
|
|
341
|
+
requireStringArray(requirementIds, `${label}.requirementIds`);
|
|
342
|
+
return {
|
|
343
|
+
id: /** @type {string} */ (record.id),
|
|
344
|
+
requirementIds: /** @type {string[]} */ (requirementIds),
|
|
345
|
+
deliverable: /** @type {string} */ (record.deliverable),
|
|
346
|
+
};
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
248
350
|
const FINDING_FIELDS = new Set(["id", "severity", "nodeId", "text"]);
|
|
249
351
|
const FINDING_SEVERITIES = new Set(["critical", "major", "minor"]);
|
|
250
352
|
|
package/src/run/operations.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import { basename, join } from "node:path";
|
|
|
12
12
|
import { createHash } from "node:crypto";
|
|
13
13
|
import { existsSync } from "node:fs";
|
|
14
14
|
|
|
15
|
-
import { readBoundedTail } from "../engine/
|
|
15
|
+
import { readBoundedTail } from "../engine/transcript.mjs";
|
|
16
16
|
import { readJson, writeJsonAtomic } from "./store.mjs";
|
|
17
17
|
import { runtimeSnapshot } from "../engine/failover.mjs";
|
|
18
18
|
import { normalizeProviderResult } from "../harnesses/index.mjs";
|
package/src/run/usage.mjs
CHANGED
|
@@ -10,9 +10,10 @@ import { appendJsonl, writeJsonAtomic } from "./store.mjs";
|
|
|
10
10
|
import { basename, join } from "node:path";
|
|
11
11
|
import { errorMessage, stableJson } from "../util.mjs";
|
|
12
12
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
13
|
-
import { liveUsage } from "../harnesses/
|
|
13
|
+
import { liveUsage } from "../harnesses/session-metrics.mjs";
|
|
14
14
|
|
|
15
|
-
import { priceUsage
|
|
15
|
+
import { priceUsage } from "../engine/process.mjs";
|
|
16
|
+
import { readBoundedTail, sessionLedger } from "../engine/transcript.mjs";
|
|
16
17
|
import { writeNode } from "../engine/state.mjs";
|
|
17
18
|
import { normalizeProviderResult } from "../harnesses/index.mjs";
|
|
18
19
|
|
|
@@ -80,8 +81,12 @@ export function recordInvocationUsage(job, options = {}) {
|
|
|
80
81
|
// the single priced object every later copy spreads from.
|
|
81
82
|
const priced = priceUsage(job.runtime, envelope.usage, envelope.costUsd);
|
|
82
83
|
envelope = { ...envelope, costUsd: priced.costUsd, costProvenance: priced.costProvenance };
|
|
84
|
+
// The per-request ledger is read here, with the usage, for the same reason
|
|
85
|
+
// the usage is: after this point the transcript may already be capped.
|
|
86
|
+
const session = sessionLedger(job);
|
|
87
|
+
job.invocation.session = session;
|
|
83
88
|
state.invocations = (state.invocations ?? []).map((invocation) => invocation.id === job.invocation.id
|
|
84
|
-
? { ...invocation, usage: envelope.usage, costUsd: envelope.costUsd, costProvenance: envelope.costProvenance }
|
|
89
|
+
? { ...invocation, usage: envelope.usage, costUsd: envelope.costUsd, costProvenance: envelope.costProvenance, session }
|
|
85
90
|
: invocation);
|
|
86
91
|
if (options.accumulate !== false) state.usage = addUsage(state.usage, envelope.usage);
|
|
87
92
|
return envelope;
|
|
@@ -158,6 +163,7 @@ export function appendUsageRecord(runDir, invocation) {
|
|
|
158
163
|
// A persisted `priced` marker wins; otherwise the pre-Phase-4 rule applies
|
|
159
164
|
// unchanged: a reported number is `provider`, absence is `unknown`.
|
|
160
165
|
costProvenance: invocation.costProvenance ?? (typeof invocation.costUsd === "number" ? "provider" : "unknown"),
|
|
166
|
+
session: invocation.session ?? null,
|
|
161
167
|
startedAt: invocation.startedAt ?? null,
|
|
162
168
|
finishedAt: invocation.closedAt ?? null,
|
|
163
169
|
});
|
package/src/util.mjs
CHANGED
|
Binary file
|