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 +3 -1
- package/src/cli/campaign.mjs +2 -0
- package/src/cli/contract.mjs +2 -0
- package/src/cli/manual.mjs +341 -0
- package/src/cli/seat.mjs +2 -0
- package/src/cli/skills.mjs +2 -0
- package/src/cli.mjs +1 -1
- package/src/contract/index.mjs +1 -1
- package/src/contract/snapshot.mjs +7 -1
- package/src/engine/dispatch.mjs +26 -1
- package/src/report/final.mjs +3 -2
- package/src/report/render.mjs +8 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "faberun",
|
|
3
|
-
"version": "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": {
|
package/src/cli/campaign.mjs
CHANGED
package/src/cli/contract.mjs
CHANGED
|
@@ -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
package/src/cli/skills.mjs
CHANGED
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" } },
|
package/src/contract/index.mjs
CHANGED
|
@@ -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");
|
package/src/engine/dispatch.mjs
CHANGED
|
@@ -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
|
package/src/report/final.mjs
CHANGED
|
@@ -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
|
/**
|
package/src/report/render.mjs
CHANGED
|
@@ -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
|
-
|
|
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);
|