faberun 0.6.0 → 0.7.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "faberun",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Faberun is a development orchestration system that turns intent into verified software: harness- and model-agnostic, it keeps the intent, coordinates the work, verifies the result and decides what happens next.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,6 +27,8 @@
27
27
  "check": "for f in bin/*.mjs .claude/hooks/*.mjs src/*.mjs src/*/*.mjs src/*/*/*.mjs evals/*.mjs test/*.mjs test/*/*.mjs; do node --check \"$f\" || exit 1; done",
28
28
  "typecheck": "tsc",
29
29
  "test": "node --test test/*.test.mjs test/*/*.test.mjs",
30
+ "docs": "node src/cli/manual.mjs --write",
31
+ "docs:check": "node src/cli/manual.mjs --check",
30
32
  "prepare": "husky"
31
33
  },
32
34
  "devDependencies": {
@@ -728,3 +728,5 @@ function usage() {
728
728
  );
729
729
  process.exitCode = 2;
730
730
  }
731
+
732
+ export default OPERATION_OPTIONS;
@@ -65,3 +65,5 @@ function usage() {
65
65
  process.stderr.write("usage: faberun contract validate <contract.json>\n");
66
66
  process.exitCode = 2;
67
67
  }
68
+
69
+ export default OPERATION_OPTIONS;
@@ -0,0 +1,341 @@
1
+ /**
2
+ * Regenerates the derivable parts of docs/COMMANDS.md — verb and operation
3
+ * headings, synopsis lines and flag-table rows — from the option tables the
4
+ * CLI itself dispatches on. Every other line (description paragraphs,
5
+ * reads/writes prose, examples, Related lines, and the four fixed sections)
6
+ * is copied through unchanged, so the manual's prose stays hand-authored
7
+ * while its command surface cannot drift from the code silently.
8
+ */
9
+ import { readFileSync, writeFileSync } from "node:fs";
10
+ import { resolve } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { COMMAND_OPTIONS } from "../cli.mjs";
13
+ import CAMPAIGN_OPERATIONS from "./campaign.mjs";
14
+ import SEAT_OPERATIONS from "./seat.mjs";
15
+ import CONTRACT_OPERATIONS from "./contract.mjs";
16
+ import SKILLS_OPERATIONS from "./skills.mjs";
17
+
18
+ /** @typedef {{type: "string"|"boolean", multiple?: boolean}} FlagSpec */
19
+ /** @typedef {{flags?: Record<string, FlagSpec>, operations?: Record<string, Record<string, FlagSpec>>}} VerbSurface */
20
+ /** @typedef {{verbs: Record<string, VerbSurface>}} Surface */
21
+
22
+ const MANUAL_PATH = fileURLToPath(new URL("../../docs/COMMANDS.md", import.meta.url));
23
+
24
+ /**
25
+ * `campaign`, `seat`, `contract` and `skills` are dispatched before
26
+ * `COMMAND_OPTIONS` is ever consulted (`cli.mjs` routes them by `argv[0]`), so
27
+ * they carry no flags of their own — only the operations their own module
28
+ * declares. Their top-level `## faberun <verb>` section is therefore never
29
+ * regenerated; it is hand-authored overview prose, preserved verbatim.
30
+ *
31
+ * @type {Record<string, Record<string, Record<string, FlagSpec>>>}
32
+ */
33
+ const CONTAINER_OPERATIONS = {
34
+ campaign: CAMPAIGN_OPERATIONS,
35
+ seat: SEAT_OPERATIONS,
36
+ contract: CONTRACT_OPERATIONS,
37
+ skills: SKILLS_OPERATIONS,
38
+ };
39
+
40
+ /**
41
+ * The real command surface, read from the same option tables `cli.mjs`
42
+ * parses argv against. `supervise campaign` is `campaign.mjs`'s `supervise`
43
+ * operation reached through a second spelling (`cli.mjs` routes
44
+ * `argv = ["supervise", "campaign", …]` into `campaignCli`), so it shares that
45
+ * operation's flags rather than declaring its own.
46
+ *
47
+ * @returns {Surface}
48
+ */
49
+ export function collectSurface() {
50
+ /** @type {Record<string, VerbSurface>} */
51
+ const verbs = {};
52
+ for (const [verb, flags] of Object.entries(COMMAND_OPTIONS)) verbs[verb] = { flags };
53
+ if (verbs.supervise) verbs.supervise.operations = { campaign: CAMPAIGN_OPERATIONS.supervise };
54
+ for (const [verb, operations] of Object.entries(CONTAINER_OPERATIONS)) verbs[verb] = { operations };
55
+ return { verbs };
56
+ }
57
+
58
+ const VERB_HEADING = /^## faberun ([a-z][a-z-]*)$/u;
59
+ const TABLE_HEADER = "| Flag | Value | Effect | Default |";
60
+ const TABLE_SEPARATOR = "| --- | --- | --- | --- |";
61
+ /** The start of a flag token in a synopsis line: `--flag` or `[--flag`. */
62
+ const FLAG_TOKEN = /\[?--/u;
63
+
64
+ /**
65
+ * Regenerate the derivable parts of a command manual. A verb absent from
66
+ * `surface` is dropped; one present in `surface` but absent from `current` is
67
+ * appended as a skeleton section.
68
+ *
69
+ * @param {string} current
70
+ * @param {Surface} surface
71
+ * @returns {string}
72
+ */
73
+ export function renderManual(current, surface) {
74
+ const lines = current.split("\n");
75
+ /** @type {string[]} */
76
+ const output = [];
77
+ const seenVerbs = new Set();
78
+ let i = 0;
79
+ while (i < lines.length) {
80
+ const match = VERB_HEADING.exec(lines[i]);
81
+ if (!match) {
82
+ output.push(lines[i]);
83
+ i += 1;
84
+ continue;
85
+ }
86
+ const verb = match[1];
87
+ let end = i + 1;
88
+ while (end < lines.length && !/^## /u.test(lines[end])) end += 1;
89
+ const entry = surface.verbs[verb];
90
+ if (entry) {
91
+ output.push(...renderVerbBlock(verb, lines.slice(i, end), entry));
92
+ seenVerbs.add(verb);
93
+ }
94
+ i = end;
95
+ }
96
+ for (const [verb, entry] of Object.entries(surface.verbs)) {
97
+ if (!seenVerbs.has(verb)) output.push(...renderVerbBlock(verb, [`## faberun ${verb}`], entry));
98
+ }
99
+ return output.join("\n");
100
+ }
101
+
102
+ /**
103
+ * @param {string} verb
104
+ * @param {string[]} block
105
+ * @param {VerbSurface} entry
106
+ * @returns {string[]}
107
+ */
108
+ function renderVerbBlock(verb, block, entry) {
109
+ const heading = block[0] ?? `## faberun ${verb}`;
110
+ const { body, operationBlocks } = splitOperations(verb, block.slice(1));
111
+ const renderedBody = entry.flags
112
+ ? renderFlaggedBody(`faberun ${verb}`, body, entry.flags)
113
+ : body.length
114
+ ? body
115
+ : renderFlaggedBody(`faberun ${verb}`, [], {});
116
+ const renderedOperations = renderOperations(verb, operationBlocks, entry.operations ?? {});
117
+ return [heading, ...renderedBody, ...renderedOperations];
118
+ }
119
+
120
+ /**
121
+ * Splits a verb's body into the part before its first `### faberun <verb>
122
+ * <op>` heading and the operation sub-blocks that follow, each running to the
123
+ * next `### ` heading.
124
+ *
125
+ * @param {string} verb
126
+ * @param {string[]} lines
127
+ * @returns {{body: string[], operationBlocks: {op: string, block: string[]}[]}}
128
+ */
129
+ function splitOperations(verb, lines) {
130
+ const opHeading = new RegExp(`^### faberun ${verb} ([a-z][a-z-]*)$`, "u");
131
+ const firstOpIndex = lines.findIndex((line) => opHeading.test(line));
132
+ if (firstOpIndex === -1) return { body: lines, operationBlocks: [] };
133
+ const body = lines.slice(0, firstOpIndex);
134
+ /** @type {{op: string, block: string[]}[]} */
135
+ const operationBlocks = [];
136
+ let i = firstOpIndex;
137
+ while (i < lines.length) {
138
+ const match = opHeading.exec(lines[i]);
139
+ if (!match) break;
140
+ let end = i + 1;
141
+ while (end < lines.length && !/^###? /u.test(lines[end])) end += 1;
142
+ operationBlocks.push({ op: match[1], block: lines.slice(i, end) });
143
+ i = end;
144
+ }
145
+ return { body, operationBlocks };
146
+ }
147
+
148
+ /**
149
+ * @param {string} verb
150
+ * @param {{op: string, block: string[]}[]} operationBlocks
151
+ * @param {Record<string, Record<string, FlagSpec>>} operations
152
+ * @returns {string[]}
153
+ */
154
+ function renderOperations(verb, operationBlocks, operations) {
155
+ const output = [];
156
+ const seen = new Set();
157
+ for (const { op, block } of operationBlocks) {
158
+ if (!Object.hasOwn(operations, op)) continue;
159
+ output.push(block[0] ?? `### faberun ${verb} ${op}`, ...renderFlaggedBody(`faberun ${verb} ${op}`, block.slice(1), operations[op]));
160
+ seen.add(op);
161
+ }
162
+ for (const [op, flags] of Object.entries(operations)) {
163
+ if (seen.has(op)) continue;
164
+ output.push(`### faberun ${verb} ${op}`, ...renderFlaggedBody(`faberun ${verb} ${op}`, [], flags));
165
+ }
166
+ return output;
167
+ }
168
+
169
+ /**
170
+ * Regenerates a section's synopsis fence and flag table in place; every
171
+ * other line is untouched.
172
+ *
173
+ * @param {string} prefix
174
+ * @param {string[]} body
175
+ * @param {Record<string, FlagSpec>} flags
176
+ * @returns {string[]}
177
+ */
178
+ function renderFlaggedBody(prefix, body, flags) {
179
+ const positional = extractPositional(prefix, body);
180
+ const synopsis = renderSynopsis(prefix, positional, flags);
181
+ const fence = findFence(body, "```text");
182
+ const withSynopsis = fence
183
+ ? [...body.slice(0, fence.start), "```text", synopsis, "```", ...body.slice(fence.end + 1)]
184
+ : ["```text", synopsis, "```", ...body];
185
+ return replaceFlagTable(withSynopsis, flags);
186
+ }
187
+
188
+ /**
189
+ * The positional placeholder a synopsis names, kept verbatim from the
190
+ * current text (including its own brackets, when optional) — everything
191
+ * before the first flag token.
192
+ *
193
+ * @param {string} prefix
194
+ * @param {string[]} body
195
+ * @returns {string}
196
+ */
197
+ function extractPositional(prefix, body) {
198
+ const fence = findFence(body, "```text");
199
+ if (!fence) return "";
200
+ const inner = body[fence.start + 1] ?? "";
201
+ if (!inner.startsWith(prefix)) return "";
202
+ const remainder = inner.slice(prefix.length).trim();
203
+ const flagToken = FLAG_TOKEN.exec(remainder);
204
+ return flagToken ? remainder.slice(0, flagToken.index).trim() : remainder;
205
+ }
206
+
207
+ /**
208
+ * @param {string} prefix
209
+ * @param {string} positional
210
+ * @param {Record<string, FlagSpec>} flags
211
+ * @returns {string}
212
+ */
213
+ function renderSynopsis(prefix, positional, flags) {
214
+ const parts = [prefix];
215
+ if (positional) parts.push(positional);
216
+ for (const [name, spec] of Object.entries(flags)) {
217
+ if (spec.type === "boolean") parts.push(`[--${name}]`);
218
+ else if (spec.multiple) parts.push(`[--${name} <a>...]`);
219
+ else parts.push(`[--${name} <value>]`);
220
+ }
221
+ return parts.join(" ");
222
+ }
223
+
224
+ /**
225
+ * @param {string[]} lines
226
+ * @param {string} opener
227
+ * @returns {{start: number, end: number}|null}
228
+ */
229
+ function findFence(lines, opener) {
230
+ const start = lines.indexOf(opener);
231
+ if (start === -1) return null;
232
+ let end = start + 1;
233
+ while (end < lines.length && lines[end] !== "```") end += 1;
234
+ return { start, end };
235
+ }
236
+
237
+ /**
238
+ * @param {string[]} lines
239
+ * @param {Record<string, FlagSpec>} flags
240
+ * @returns {string[]}
241
+ */
242
+ function replaceFlagTable(lines, flags) {
243
+ const headerIndex = lines.indexOf(TABLE_HEADER);
244
+ const existingRows = headerIndex === -1 ? new Map() : parseRows(lines, headerIndex + 2);
245
+ const newRows = buildRows(flags, existingRows);
246
+ if (headerIndex === -1) {
247
+ const fenceEnd = lines.indexOf("```");
248
+ const insertAt = fenceEnd === -1 ? lines.length : fenceEnd + 1;
249
+ return [...lines.slice(0, insertAt), TABLE_HEADER, TABLE_SEPARATOR, ...newRows, ...lines.slice(insertAt)];
250
+ }
251
+ let rowsEnd = headerIndex + 2;
252
+ while (rowsEnd < lines.length && lines[rowsEnd].startsWith("|")) rowsEnd += 1;
253
+ return [...lines.slice(0, headerIndex), TABLE_HEADER, TABLE_SEPARATOR, ...newRows, ...lines.slice(rowsEnd)];
254
+ }
255
+
256
+ /**
257
+ * @param {string[]} lines
258
+ * @param {number} start
259
+ * @returns {Map<string, {value: string, effect: string, default: string}>}
260
+ */
261
+ function parseRows(lines, start) {
262
+ /** @type {Map<string, {value: string, effect: string, default: string}>} */
263
+ const map = new Map();
264
+ let i = start;
265
+ while (i < lines.length && lines[i].startsWith("|")) {
266
+ const cells = lines[i].trim().replace(/^\|/u, "").replace(/\|$/u, "").split("|").map((cell) => cell.trim());
267
+ if (cells.length === 4) map.set(flagKeyOf(cells[0]), { value: cells[1], effect: cells[2], default: cells[3] });
268
+ i += 1;
269
+ }
270
+ return map;
271
+ }
272
+
273
+ /** @param {string} cell @returns {string} */
274
+ function flagKeyOf(cell) {
275
+ const match = /`--([a-z-]+)`/u.exec(cell);
276
+ return match ? match[1] : "—";
277
+ }
278
+
279
+ /**
280
+ * @param {Record<string, FlagSpec>} flags
281
+ * @param {Map<string, {value: string, effect: string, default: string}>} existingRows
282
+ * @returns {string[]}
283
+ */
284
+ function buildRows(flags, existingRows) {
285
+ const names = Object.keys(flags);
286
+ if (names.length === 0) {
287
+ const existing = existingRows.get("—");
288
+ return [existing ? `| — | ${existing.value} | ${existing.effect} | ${existing.default} |` : "| — | — | No flags. | — |"];
289
+ }
290
+ return names.map((name) => {
291
+ const existing = existingRows.get(name);
292
+ const value = existing ? existing.value : "<value>";
293
+ const effect = existing ? existing.effect : "";
294
+ const fallback = existing ? existing.default : "—";
295
+ return `| \`--${name}\` | ${value} | ${effect} | ${fallback} |`;
296
+ });
297
+ }
298
+
299
+ /**
300
+ * A minimal, dependency-free diff summary: every line index where the two
301
+ * texts disagree, capped so a large rewrite does not flood the console.
302
+ *
303
+ * @param {string} current
304
+ * @param {string} next
305
+ * @returns {string}
306
+ */
307
+ function diffSummary(current, next) {
308
+ const a = current.split("\n");
309
+ const b = next.split("\n");
310
+ const max = Math.max(a.length, b.length);
311
+ /** @type {string[]} */
312
+ const lines = [];
313
+ for (let i = 0; i < max && lines.length < 40; i += 1) {
314
+ if (a[i] !== b[i]) lines.push(`line ${i + 1}:\n- ${a[i] ?? "<eof>"}\n+ ${b[i] ?? "<eof>"}`);
315
+ }
316
+ return lines.join("\n");
317
+ }
318
+
319
+ /**
320
+ * @param {string[]} argv
321
+ * @returns {void}
322
+ */
323
+ function main(argv) {
324
+ const mode = argv[0];
325
+ if (mode !== "--write" && mode !== "--check") {
326
+ process.stderr.write("usage: manual.mjs --write|--check\n");
327
+ process.exitCode = 2;
328
+ return;
329
+ }
330
+ const current = readFileSync(MANUAL_PATH, "utf8");
331
+ const next = renderManual(current, collectSurface());
332
+ if (next === current) return;
333
+ if (mode === "--write") {
334
+ writeFileSync(MANUAL_PATH, next);
335
+ return;
336
+ }
337
+ process.stderr.write(`docs/COMMANDS.md is out of date; run \`npm run docs\`.\n${diffSummary(current, next)}\n`);
338
+ process.exitCode = 1;
339
+ }
340
+
341
+ if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) main(process.argv.slice(2));
package/src/cli/seat.mjs CHANGED
@@ -137,3 +137,5 @@ function usage() {
137
137
  process.stderr.write("usage: faberun seat <start|attach|status|stop|switch> [<campaign-id>] [--cwd <dir>] ...\n");
138
138
  process.exitCode = 2;
139
139
  }
140
+
141
+ export default OPERATION_OPTIONS;
@@ -403,3 +403,5 @@ function usage() {
403
403
  process.stderr.write("usage: faberun skills <list|install|register> [<name>...] [--target <dir>] [--global] [--force] [--copy] [--harness <a,b>] [--json]\n");
404
404
  process.exitCode = 2;
405
405
  }
406
+
407
+ export default OPERATION_OPTIONS;
package/src/cli.mjs CHANGED
@@ -87,7 +87,7 @@ export function hasDetachedBootstrapNonce() {
87
87
  }
88
88
 
89
89
  /** @type {Record<string, import("node:util").ParseArgsOptionsConfig>} */
90
- const COMMAND_OPTIONS = {
90
+ export const COMMAND_OPTIONS = {
91
91
  run: { detach: { type: "boolean" }, "base-ref": { type: "string" } },
92
92
  resume: { detach: { type: "boolean" }, node: { type: "string" }, reconcile: { type: "string" }, answer: { type: "string" } },
93
93
  supervise: { detach: { type: "boolean" }, interval: { type: "string" } },
@@ -79,7 +79,7 @@ const GATE_REVIEWS = new Set(["none", "advisory", "blocking"]);
79
79
  /** @typedef {{history: RoutingHistoryEntry[], currentOverride: RoutingOverride|null, assignments?: RuntimeAssignments, availability?: Record<string, RuntimeAvailability>, tierExhaustion?: TierExhaustion, tierExhaustionCycle?: number}} RoutingState */
80
80
  /** @typedef {{revision?: number, heartbeatCount: number, dryHeartbeatCount: number, progressSignature?: string|null, lastHeartbeatAt: string|null, lastProgressAt: string|null, nextCheckAt?: string|null}} ProgressState */
81
81
  /** @typedef {{status: "unassigned"|"provisioning"|"ready"|"failed"|"removed", path: string|null, branch: string|null, commit: string|null, baseSha?: string|null, sealedSha?: string|null, sealError?: string|null, previousAttempt?: number|null}} WorktreeState */
82
- /** @typedef {{schemaVersion: number, contractVersion: string, id: string, type: string, sourceIdentity: SourceIdentity, packetHash: string, status: NodeStatus, phase: NodePhase, attempt: number, revisions: number, judgeFailures?: number, review?: ("none"|"advisory"|"blocking"), runtime: RuntimeSnapshot|null, blockedBy: string[], startedAt: string|null, updatedAt: string, result: unknown, gate: GateResult|null, error: SnapshotError|null, usage?: Usage, costUsd?: number, routing?: RoutingState|null, progress?: ProgressState|null, worktree?: WorktreeState|null, integratedHead?: string|null, invocations?: Invocation[], executionOverrides?: ExecutionOverride[], verification?: VerificationState|null, scope?: BoundedScope|null, scopeFindings?: ScopeFindings|null, previousAttempt?: string, sessionPolicy?: {forceFresh?: boolean}|null}} NodeSnapshot */
82
+ /** @typedef {{schemaVersion: number, contractVersion: string, id: string, type: string, sourceIdentity: SourceIdentity, packetHash: string, status: NodeStatus, phase: NodePhase, attempt: number, revisions: number, judgeFailures?: number, review?: ("none"|"advisory"|"blocking"), runtime: RuntimeSnapshot|null, blockedBy: string[], startedAt: string|null, updatedAt: string, result: unknown, gate: GateResult|null, error: SnapshotError|null, usage?: Usage, costUsd?: number, routing?: RoutingState|null, progress?: ProgressState|null, worktree?: WorktreeState|null, integratedHead?: string|null, invocations?: Invocation[], executionOverrides?: ExecutionOverride[], verification?: VerificationState|null, scope?: BoundedScope|null, scopeFindings?: ScopeFindings|null, previousAttempt?: string, sessionPolicy?: {forceFresh?: boolean}|null, declaredReadBytes?: number|null}} NodeSnapshot */
83
83
  /** @typedef {{path: string, sha: string}} ControllerIdentity */
84
84
  /** @typedef {{schemaVersion: number, contractVersion: string, pid: number, processStartToken: string|null, startedAt: string, sourceIdentity: SourceIdentity, controllerIdentity?: ControllerIdentity, integrationRef?: string, identityWarnings?: string[], relaunchCount?: number, lastRelaunchProgressAt?: string|null, attention?: {code: string, message: string, at: string}|null, contractDigest?: string, scopeDecision?: ScopeDecision, autoRetries?: Record<string, {code: string, at: string}>}} RunMetadata */
85
85
  /** @typedef {{at: string, base: string|null, dirtyTreeFingerprint: string|null}} ScopeDecision */
@@ -129,7 +129,7 @@ export function validateNodeSnapshot(value, expectedNode = null) {
129
129
  "schemaVersion", "contractVersion", "id", "type", "sourceIdentity", "packetHash", "status", "phase",
130
130
  "attempt", "revisions", "judgeFailures", "runtime", "blockedBy", "startedAt", "updatedAt", "result", "gate", "error", "usage",
131
131
  "costUsd", "routing", "progress", "worktree", "invocations", "executionOverrides", "verification", "scope",
132
- "scopeFindings", "review", "previousAttempt", "sessionPolicy", "integratedHead",
132
+ "scopeFindings", "review", "previousAttempt", "sessionPolicy", "integratedHead", "declaredReadBytes",
133
133
  ]), "node snapshot");
134
134
  validateMetadata(value, "node snapshot");
135
135
  requireId(value.id, "node snapshot.id");
@@ -166,6 +166,12 @@ export function validateNodeSnapshot(value, expectedNode = null) {
166
166
  validateSnapshotError(value.error, "node snapshot.error");
167
167
  if (value.usage !== undefined) validateUsage(value.usage, "node snapshot.usage");
168
168
  if (value.costUsd !== undefined) nonNegativeNumber(value.costUsd, "node snapshot.costUsd");
169
+ // The summed byte size of the node's declared readFiles in the attempt
170
+ // worktree at dispatch time -- the one quantity the controller can measure
171
+ // about a packet's reference load, since the worker reads the files itself.
172
+ if (value.declaredReadBytes !== undefined && value.declaredReadBytes !== null) {
173
+ nonNegativeInteger(value.declaredReadBytes, "node snapshot.declaredReadBytes");
174
+ }
169
175
  if (value.routing !== undefined && value.routing !== null) validateRoutingState(value.routing, "node snapshot.routing");
170
176
  if (value.progress !== undefined && value.progress !== null) validateProgressState(value.progress, "node snapshot.progress");
171
177
  if (value.worktree !== undefined && value.worktree !== null) validateWorktreeState(value.worktree, "node snapshot.worktree");
@@ -32,7 +32,7 @@ import { emptyScope, persistedScopeBoundary, workerScope } from "./scope.mjs";
32
32
  import { hasOperationIntent, hasOperationSettlement, operationNeedsRecovery, operationNextState, persistInvocationIntent, providerReceipts, settleInvocation } from "../run/operations.mjs";
33
33
  import { invocationCost, invocationUsage } from "../run/usage.mjs";
34
34
  import { logPaths, readBoundedTail, startProcess } from "./process.mjs";
35
- import { mkdirSync } from "node:fs";
35
+ import { mkdirSync, statSync } from "node:fs";
36
36
  import { READ_LINE_LIMIT, normalizeProviderResult, providerCommand } from "../harnesses/index.mjs";
37
37
  import { readJson, writeJsonAtomic } from "../run/store.mjs";
38
38
  import { judgeReaskInstruction, reviewMode } from "../contract/review-modes.mjs";
@@ -267,6 +267,30 @@ function phaseHandoffPrompt(contract, node, state, runDir, role) {
267
267
  ].join("\n\n");
268
268
  return boundedUtf8(handoff, 60 * 1024);
269
269
  }
270
+ /**
271
+ * The declared weight of a node's readFiles at dispatch time: the sum of the
272
+ * byte sizes of the files that exist in the attempt workspace. This is the
273
+ * one quantity the controller can measure about a packet's reference load --
274
+ * the worker prompt lists readFiles and the worker reads them itself, so what
275
+ * it actually reads is the harness's business. A missing file counts 0 rather
276
+ * than throwing: a declared path can be produced by a dependency that has not
277
+ * run yet or removed by the tree since the packet was authored.
278
+ *
279
+ * @param {string[]} readFiles
280
+ * @param {string} workspace
281
+ * @returns {number}
282
+ */
283
+ export function declaredReadBytes(readFiles, workspace) {
284
+ let total = 0;
285
+ for (const path of readFiles) {
286
+ try {
287
+ total += statSync(join(workspace, path)).size;
288
+ } catch {
289
+ // Missing or unreadable file: contributes no weight.
290
+ }
291
+ }
292
+ return total;
293
+ }
270
294
  /**
271
295
  * The mechanical worker tool policy for the provider boundary: hook settings
272
296
  * on Claude-compatible commands. Only an adapter whose surface can prove
@@ -431,6 +455,7 @@ export function startWorker(contract, node, state, runDir, running, prompt, lock
431
455
  writeJsonAtomic(snapshotPath, baseline);
432
456
  state.phase = "worker";
433
457
  state.runtime = runtime;
458
+ state.declaredReadBytes = declaredReadBytes(node.taskPacket.readFiles ?? [], workspace);
434
459
  // A new worker attempt has no accepted result yet. The canonical result
435
460
  // file is cleared when the previous attempt was explicitly rejected (failed
436
461
  // gate verdict), when no valid canonical file exists, or when the stale file
@@ -90,7 +90,7 @@ export function renderFinalReport(runDir, contract, states) {
90
90
  const widths = [3, 24, 9, 7, 7, 28, 10, 10, 10, 12, 64];
91
91
  /** @param {unknown[]} cells */
92
92
  const row = (cells) => cells.map((cell, index) => fit(String(cell ?? ""), widths[index])).join(" ");
93
- const totals = { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0 };
93
+ const totals = { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, declaredReadBytes: 0 };
94
94
  let totalCostUsd = null;
95
95
  const lines = [
96
96
  `# run ${basename(runDir)}`,
@@ -106,6 +106,7 @@ export function renderFinalReport(runDir, contract, states) {
106
106
  totals.inputTokens += usage.inputTokens ?? 0;
107
107
  totals.outputTokens += usage.outputTokens ?? 0;
108
108
  totals.cacheReadInputTokens += usage.cacheReadInputTokens ?? 0;
109
+ if (typeof node.declaredReadBytes === "number") totals.declaredReadBytes += node.declaredReadBytes;
109
110
  if (typeof node.costUsd === "number" && Number.isFinite(node.costUsd)) totalCostUsd = (totalCostUsd ?? 0) + node.costUsd;
110
111
  const runtime = node.runtime ? `${node.runtime.harness}/${node.runtime.model}` : "-";
111
112
  const planNode = contract.nodes.find((candidate) => candidate.id === node.id);
@@ -129,7 +130,7 @@ export function renderFinalReport(runDir, contract, states) {
129
130
  ]));
130
131
  }
131
132
  const roles = roleCosts(nodes);
132
- lines.push("```", "", `totals · in ${compactTokens(totals.inputTokens)} · out ${compactTokens(totals.outputTokens)} · cache ${compactTokens(totals.cacheReadInputTokens)} · worker ${compactCost(roles.worker)} · judge ${compactCost(roles.judge)} · cost ${compactCost(totalCostUsd)}`);
133
+ lines.push("```", "", `totals · in ${compactTokens(totals.inputTokens)} · out ${compactTokens(totals.outputTokens)} · cache ${compactTokens(totals.cacheReadInputTokens)} · worker ${compactCost(roles.worker)} · judge ${compactCost(roles.judge)} · cost ${compactCost(totalCostUsd)} · read ${compactTokens(totals.declaredReadBytes)}`);
133
134
  return `${lines.join("\n")}\n`;
134
135
  }
135
136
  /**
@@ -26,7 +26,7 @@ const POINTER_ATTENTION_CHARS = 80;
26
26
  /** @typedef {{costUsd: number|null, costProvenance: CostProvenance, inputTokens: number, outputTokens: number, cacheReadInputTokens: number, pricedInvocations: number, unpricedInvocations: number}} RoleUsage */
27
27
  /** @typedef {{inputTokens: number|null, outputTokens: number|null, cacheReadInputTokens: number|null}} StatusPayloadUsage */
28
28
  /** @typedef {{index: number, total: number, argv: string}} VerificationProgress */
29
- /** @typedef {{id: string, status: NodeStatus, phase: string|null, executionPhase: string|null, runtime: string|null, workerRuntime: string|null, continuation: string, attempt: number, revisions: number, startedAt: string|null, updatedAt: string|null, usage: StatusPayloadUsage|null, costUsd: number|null, verdict: string|null, pendingHandoff: {runtime: string, reason: string}|null, note: string|null, scopeFindings: string[]|null, errorCode: string|null, blockedBy: string[], verificationProgress: VerificationProgress|null}} StatusPayloadNode */
29
+ /** @typedef {{id: string, status: NodeStatus, phase: string|null, executionPhase: string|null, runtime: string|null, workerRuntime: string|null, continuation: string, attempt: number, revisions: number, startedAt: string|null, updatedAt: string|null, usage: StatusPayloadUsage|null, costUsd: number|null, verdict: string|null, pendingHandoff: {runtime: string, reason: string}|null, note: string|null, scopeFindings: string[]|null, errorCode: string|null, blockedBy: string[], verificationProgress: VerificationProgress|null, declaredReadBytes: number|null}} StatusPayloadNode */
30
30
  /** @typedef {{schemaVersion: 1, run: string, contractId: string, campaignId: string, goal: string, usage: {inputTokens: number, outputTokens: number, cacheReadInputTokens: number, costUsd: number|null}, roles: {worker: RoleUsage, judge: RoleUsage}, controller: JsonObject, identityWarnings: string[], summary: string, nodes: StatusPayloadNode[]}} StatusPayload */
31
31
 
32
32
  /** The glyph each terminal state prints in a status table. */
@@ -92,7 +92,8 @@ export function renderStatus(runDir) {
92
92
  node.note ?? "-",
93
93
  ]));
94
94
  }
95
- lines.push("```", "", "## Cost", "", `in ${compactTokens(usage.inputTokens)} · out ${compactTokens(usage.outputTokens)} · cache ${compactTokens(usage.cacheReadInputTokens)} · worker ${formatRole(payload.roles.worker)} · judge ${formatRole(payload.roles.judge)} · cost ${compactCost(usage.costUsd)}`);
95
+ const readBytes = payload.nodes.reduce((total, node) => total + (node.declaredReadBytes ?? 0), 0);
96
+ lines.push("```", "", "## Cost", "", `in ${compactTokens(usage.inputTokens)} · out ${compactTokens(usage.outputTokens)} · cache ${compactTokens(usage.cacheReadInputTokens)} · worker ${formatRole(payload.roles.worker)} · judge ${formatRole(payload.roles.judge)} · cost ${compactCost(usage.costUsd)} · read ${compactTokens(readBytes)}`);
96
97
  return `${lines.join("\n")}\n`;
97
98
  }
98
99
 
@@ -232,6 +233,7 @@ function buildStatusPayload(runDir, contract, nodes, identityWarnings, usage) {
232
233
  errorCode: node.error?.code ?? null,
233
234
  blockedBy: node.blockedBy ?? [],
234
235
  verificationProgress: progress,
236
+ declaredReadBytes: typeof node.declaredReadBytes === "number" ? node.declaredReadBytes : null,
235
237
  };
236
238
  }),
237
239
  };
@@ -407,13 +409,14 @@ export function renderReportJson(runDir) {
407
409
  const { contract, nodes } = loadRun(runDir);
408
410
  const counts = new Map();
409
411
  for (const node of nodes) counts.set(node.status, (counts.get(node.status) ?? 0) + 1);
410
- /** @type {{inputTokens: number, outputTokens: number, cacheReadInputTokens: number, costUsd: number|null, costStatus: string, workerCostUsd: number|null, judgeCostUsd: number|null}} */
411
- const totals = { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, costUsd: null, costStatus: "ambiguous", workerCostUsd: null, judgeCostUsd: null };
412
+ /** @type {{inputTokens: number, outputTokens: number, cacheReadInputTokens: number, costUsd: number|null, costStatus: string, workerCostUsd: number|null, judgeCostUsd: number|null, declaredReadBytes: number}} */
413
+ const totals = { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, costUsd: null, costStatus: "ambiguous", workerCostUsd: null, judgeCostUsd: null, declaredReadBytes: 0 };
412
414
  const costs = nodes.map(costProjection);
413
415
  const listed = nodes.map((node, index) => {
414
416
  const usage = node.usage ?? { inputTokens: null, outputTokens: null, cacheReadInputTokens: null };
415
417
  for (const key of /** @type {("inputTokens"|"outputTokens"|"cacheReadInputTokens")[]} */ (["inputTokens", "outputTokens", "cacheReadInputTokens"])) totals[key] = (totals[key] ?? 0) + (usage[key] ?? 0);
416
418
  const cost = costs[index];
419
+ totals.declaredReadBytes += typeof node.declaredReadBytes === "number" ? node.declaredReadBytes : 0;
417
420
  return {
418
421
  id: node.id,
419
422
  status: node.status,
@@ -427,6 +430,7 @@ export function renderReportJson(runDir) {
427
430
  costStatus: cost.status,
428
431
  continuation: continuationMode(node),
429
432
  note: nodeNote(node),
433
+ declaredReadBytes: typeof node.declaredReadBytes === "number" ? node.declaredReadBytes : null,
430
434
  };
431
435
  });
432
436
  const aggregateCost = aggregateCostProjection(costs);