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.
@@ -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, decides when a
8
- * plan is contested instead of frozen, and records the operator-approval
9
- * open-question. `launch` and `wait` are the only two seams that touch a
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) throw new Error(`planning stage ${kind}: run ${validated.id} recorded no discovery 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
- let plan = validatePlanOutput(draft.output.plan);
168
- logStage("draft", { runId: draft.contract.id, nodeCount: plan.nodes.length });
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
- /** @type {PlanFindingOutput[]} */
175
- let findings = [];
176
- for (let round = 1; round <= reviewRounds; round += 1) {
177
- const review = await runStage("review", { specPath: relativeSpecPath, repoFactsPath: relativeRepoFactsPath, planPath: relativeWorkingPlanPath });
178
- findings = validateFindings(review.output.findings);
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
- logStage("review", { round, runId: review.contract.id, findingsCount: findings.length, criticalCount: criticalFindings.length });
181
- if (criticalFindings.length === 0) break;
182
- if (round === reviewRounds) {
183
- const planPath = join(plansDir, "plan.json");
184
- writeJsonAtomic(planPath, { formatVersion: 1, status: "contested", rounds: round, findings });
185
- logStage("contested", { round, criticalCount: criticalFindings.length });
186
- await campaignCli([
187
- "note", campaignId, "--cwd", cwd, "--session-id", PLANNER_SESSION_ID,
188
- "--kind", "open-question", "--question-id", `plan-${phase}-contested`,
189
- "--text", `Plan for phase ${phase} is contested after ${round} review round(s): ${criticalFindings.map((finding) => finding.text).join("; ")}`,
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
- const sizing = applySizingRules(
204
- { nodes: plan.nodes.map(toSizingNode), justification: plan.justification },
205
- { nodeBudgetMs: DEFAULT_NODE_BUDGET_MS, facts: repoFacts },
206
- );
207
- logStage("sizing", { transformations: sizing.transformations.length, nodeCount: sizing.plan.nodes.length });
208
-
209
- const routingRuntimes = /** @type {Record<string, import("./routing.mjs").RoutingRuntime>} */ (runtimes);
210
- const routing = resolveRuntimes(sizing.plan.nodes, {
211
- table: [...DEFAULT_ROUTING_TABLE],
212
- runtimes: routingRuntimes,
213
- availability: availabilityOf(runtimes),
214
- runtimeDefaults,
215
- });
216
- logStage("routing", { assignments: Object.keys(routing.assignments).length });
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
- const highestRiskTier = highestOf(sizing.plan.nodes.map((node) => node.riskTier ?? RISK_TIERS[0]));
219
- const nodes = sizing.plan.nodes.map((node) => toContractNode(/** @type {SizedPlanNode} */ (node), phase, routing.assignments[node.id]));
220
- const frozen = freezePlan({
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 `cwd`
225
- // has to point back at the repo root from there, exactly like `runStage`
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
- logStage("freeze", { contractId: `${campaignId}-${phase}`, highestRiskTier });
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: [],
Binary file
@@ -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
- const PLAN_OUTPUT_SHAPE = "{nodes: [{id, objective, taskKind, riskTier, dependsOn, readFiles, writeFiles, definitionOfDone, verification}], justification?}";
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
- `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(", ")}.`,
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
 
@@ -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/process.mjs";
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/exec-jsonl/index.mjs";
13
+ import { liveUsage } from "../harnesses/session-metrics.mjs";
14
14
 
15
- import { priceUsage, readBoundedTail } from "../engine/process.mjs";
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