faberun 0.19.0 → 0.19.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/faberun/references/contract.md +17 -5
- package/src/campaign/index.mjs +56 -1
- package/src/cli/brand.mjs +4 -5
- package/src/cli/launch.mjs +1 -1
- package/src/cli/plan.mjs +164 -27
- package/src/cli/spec.mjs +17 -7
- package/src/cli.mjs +1 -0
- package/src/contract/definition-of-done.mjs +50 -0
- package/src/contract/final-verification.mjs +21 -0
- package/src/contract/index.mjs +20 -8
- package/src/contract/verification.mjs +63 -2
- package/src/engine/cancel.mjs +3 -3
- package/src/engine/dispatch.mjs +2 -1
- package/src/engine/failover.mjs +11 -4
- package/src/engine/judge-gate.mjs +7 -11
- package/src/engine/process.mjs +40 -2
- package/src/engine/resume.mjs +2 -1
- package/src/engine/runtime-discovery.mjs +11 -0
- package/src/engine/scheduler.mjs +15 -24
- package/src/engine/scope.mjs +13 -6
- package/src/host/home.mjs +38 -0
- package/src/host/preflight.mjs +46 -14
- package/src/plan/pipeline.mjs +34 -5
- package/src/plan/proof-run.mjs +121 -0
- package/src/plan/repo-facts.mjs +5 -39
- package/src/plan/sizing.mjs +72 -4
- package/src/plan/spec.mjs +25 -1
- package/src/plan/template.mjs +69 -15
- package/src/report/final.mjs +44 -7
- package/src/report/message.mjs +7 -7
- package/src/report/render.mjs +98 -139
- package/src/report/role-usage.mjs +145 -0
- package/src/run/node-store.mjs +33 -0
- package/src/util.mjs +46 -0
package/src/report/final.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { compactCost, compactTokens, errorCode } from "../util.mjs";
|
|
|
10
10
|
import { basename, join } from "node:path";
|
|
11
11
|
import { readJson, writeJsonAtomic, writeTextAtomic } from "../run/store.mjs";
|
|
12
12
|
import { scopeFindingsNote } from "../contract/scope-findings.mjs";
|
|
13
|
-
import { MARK, fit, roleCosts, statusNote, writeStatusArtifacts } from "./render.mjs";
|
|
13
|
+
import { MARK, advisoryFindingCount, fit, judgeRuntimeLabel, roleCosts, roleUsage, statusNote, workerRuntimeLabel, writeStatusArtifacts } from "./render.mjs";
|
|
14
14
|
import { packetRepetitionByNode, packetRepetitionNote } from "./packet-repetition.mjs";
|
|
15
15
|
import { unlinkSync } from "node:fs";
|
|
16
16
|
|
|
@@ -60,7 +60,11 @@ function renderFinalStatus(runDir, contract, states) {
|
|
|
60
60
|
row(widths.map((width) => "-".repeat(width))),
|
|
61
61
|
];
|
|
62
62
|
for (const node of nodes) {
|
|
63
|
-
|
|
63
|
+
// The runtime that did this node's work, not the one dispatched last: a
|
|
64
|
+
// judge dispatch overwrites `state.runtime`, so reading it here credited
|
|
65
|
+
// every node to the judge. `render.mjs` already read it correctly and
|
|
66
|
+
// these two surfaces disagreed; now there is one reader.
|
|
67
|
+
const runtime = workerRuntimeLabel(node) ?? "-";
|
|
64
68
|
const planNode = contract.nodes.find((candidate) => candidate.id === node.id);
|
|
65
69
|
const detail = statusNote(node) ?? "-";
|
|
66
70
|
// A scope finding leads the note and drops the phase boilerplate: the
|
|
@@ -72,9 +76,17 @@ function renderFinalStatus(runDir, contract, states) {
|
|
|
72
76
|
}
|
|
73
77
|
lines.push("```", "", "## Needs you", "");
|
|
74
78
|
const attention = nodes.filter((node) => !["pending", "running", "done"].includes(node.status));
|
|
75
|
-
|
|
79
|
+
// A node the gate accepted in spite of judge findings: the decision stands,
|
|
80
|
+
// the finding is still the operator's to read. Without this the closing
|
|
81
|
+
// artifact said nothing needed anyone while a real finding sat in the node.
|
|
82
|
+
const advised = nodes.filter((node) => advisoryFindingCount(node) > 0 && !attention.includes(node));
|
|
83
|
+
if (!attention.length && !identityWarnings.length && !advised.length) lines.push("Nothing needs you right now.");
|
|
76
84
|
for (const warning of identityWarnings) lines.push(`- [~] ${warning}`);
|
|
77
85
|
for (const node of attention) lines.push(`- ${MARK[node.status] ?? "[?]"} ${node.id}: ${node.gate?.summary ?? node.error?.message ?? node.status}`);
|
|
86
|
+
for (const node of advised) {
|
|
87
|
+
const count = advisoryFindingCount(node);
|
|
88
|
+
lines.push(`- [~] ${node.id}: ${node.status} with ${count} judge ${count === 1 ? "finding" : "findings"} below the gate's threshold (${node.gate?.maxSeverity ?? "unknown"}). Read them with \`faberun findings\`.`);
|
|
89
|
+
}
|
|
78
90
|
return `${lines.join("\n")}\n`;
|
|
79
91
|
}
|
|
80
92
|
/**
|
|
@@ -88,7 +100,7 @@ export function renderFinalReport(runDir, contract, states) {
|
|
|
88
100
|
const counts = new Map();
|
|
89
101
|
for (const node of nodes) counts.set(node.status, (counts.get(node.status) ?? 0) + 1);
|
|
90
102
|
const summary = [...counts].map(([status, count]) => `${count} ${status}`).join(" · ");
|
|
91
|
-
const widths = [3, 24, 9, 7, 7, 28, 10, 10, 10, 12, 64];
|
|
103
|
+
const widths = [3, 24, 9, 7, 7, 28, 28, 10, 10, 10, 12, 12, 64];
|
|
92
104
|
/** @param {unknown[]} cells */
|
|
93
105
|
const row = (cells) => cells.map((cell, index) => fit(String(cell ?? ""), widths[index])).join(" ");
|
|
94
106
|
const totals = { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, declaredReadBytes: 0 };
|
|
@@ -99,7 +111,7 @@ export function renderFinalReport(runDir, contract, states) {
|
|
|
99
111
|
`${nodes.length} nodes · ${summary}`,
|
|
100
112
|
"",
|
|
101
113
|
"```",
|
|
102
|
-
row(["", "NODE", "STATE", "TRY", "REV", "
|
|
114
|
+
row(["", "NODE", "STATE", "TRY", "REV", "WORKER", "JUDGE", "IN", "OUT", "CACHE", "W COST", "J COST", "NOTE"]),
|
|
103
115
|
row(widths.map((width) => "-".repeat(width))),
|
|
104
116
|
];
|
|
105
117
|
for (const node of nodes) {
|
|
@@ -109,7 +121,14 @@ export function renderFinalReport(runDir, contract, states) {
|
|
|
109
121
|
totals.cacheReadInputTokens += usage.cacheReadInputTokens ?? 0;
|
|
110
122
|
if (typeof node.declaredReadBytes === "number") totals.declaredReadBytes += node.declaredReadBytes;
|
|
111
123
|
if (typeof node.costUsd === "number" && Number.isFinite(node.costUsd)) totalCostUsd = (totalCostUsd ?? 0) + node.costUsd;
|
|
112
|
-
|
|
124
|
+
// Worker and judge in columns of their own. One runtime label over a cost
|
|
125
|
+
// that bought both reads as the whole node having run there: measured
|
|
126
|
+
// 2026-09-21, seven nodes each reported a single runtime carrying
|
|
127
|
+
// aggregated worker+judge cost, and a per-runtime cost read off that
|
|
128
|
+
// table is wrong in both directions.
|
|
129
|
+
const nodeRoles = roleUsage([node]);
|
|
130
|
+
const runtime = workerRuntimeLabel(node) ?? "-";
|
|
131
|
+
const judge = judgeRuntimeLabel(node) ?? "-";
|
|
113
132
|
const planNode = contract.nodes.find((candidate) => candidate.id === node.id);
|
|
114
133
|
const detail = node.gate?.summary ?? node.error?.message ?? (node.blockedBy?.length ? node.blockedBy.join(", ") : null) ?? (typeof node.result === "string" && node.result.trim() ? node.result.trim() : node.phase ?? "-");
|
|
115
134
|
// The advisory scope finding leads the note, as it does in STATUS.md.
|
|
@@ -123,10 +142,12 @@ export function renderFinalReport(runDir, contract, states) {
|
|
|
123
142
|
node.attempt ?? 0,
|
|
124
143
|
node.revisions ?? 0,
|
|
125
144
|
runtime,
|
|
145
|
+
judge,
|
|
126
146
|
compactTokens(usage.inputTokens),
|
|
127
147
|
compactTokens(usage.outputTokens),
|
|
128
148
|
compactTokens(usage.cacheReadInputTokens),
|
|
129
|
-
|
|
149
|
+
roleCostCell(nodeRoles.worker),
|
|
150
|
+
roleCostCell(nodeRoles.judge),
|
|
130
151
|
note,
|
|
131
152
|
]));
|
|
132
153
|
}
|
|
@@ -134,6 +155,22 @@ export function renderFinalReport(runDir, contract, states) {
|
|
|
134
155
|
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)}${packetRepetitionNote(packetRepetitionByNode(runDir, nodes))}`);
|
|
135
156
|
return `${lines.join("\n")}\n`;
|
|
136
157
|
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* One role's cost cell. An unpriced invocation reads as `est` rather than as
|
|
161
|
+
* a dollar figure or a dash: the number a provider never reported is not the
|
|
162
|
+
* same fact as the number it did, and a cell that hides the difference is how
|
|
163
|
+
* an estimate gets read as a receipt.
|
|
164
|
+
*
|
|
165
|
+
* @param {import("./render.mjs").RoleUsage} role
|
|
166
|
+
* @returns {string}
|
|
167
|
+
*/
|
|
168
|
+
function roleCostCell(role) {
|
|
169
|
+
if (role.costProvenance === "none") return "-";
|
|
170
|
+
if (role.costProvenance === "priced") return compactCost(role.costUsd);
|
|
171
|
+
return "est";
|
|
172
|
+
}
|
|
173
|
+
|
|
137
174
|
/**
|
|
138
175
|
* Consolidated terminal-state handoff: one bounded JSON snapshot in the run
|
|
139
176
|
* dir so a triage session never loads full run state. Nodes stay the source
|
package/src/report/message.mjs
CHANGED
|
@@ -28,7 +28,7 @@ import { chooseLanguage, labelsFor } from "./locale.mjs";
|
|
|
28
28
|
import { readNodeSnapshot } from "../run/node-store.mjs";
|
|
29
29
|
import { campaignDir } from "../campaign/layout.mjs";
|
|
30
30
|
import { readJournal } from "../campaign/journal.mjs";
|
|
31
|
-
import {
|
|
31
|
+
import { availableUpdate, faberunHome } from "../host/home.mjs";
|
|
32
32
|
import { packageVersion } from "../host/package.mjs";
|
|
33
33
|
import { boundedUtf8, compactTokens } from "../util.mjs";
|
|
34
34
|
import { SETTLED, SUCCESS } from "../engine/prompts.mjs";
|
|
@@ -277,23 +277,23 @@ function footer(view, eventType) {
|
|
|
277
277
|
}
|
|
278
278
|
|
|
279
279
|
/**
|
|
280
|
-
* One line when
|
|
281
|
-
* running. The cache only, never the network: `faberun update --check`
|
|
280
|
+
* One line when a still-fresh cached update check names a release newer than
|
|
281
|
+
* the one running. The cache only, never the network: `faberun update --check`
|
|
282
282
|
* refreshes it, and a notification must never wait on a request.
|
|
283
283
|
*
|
|
284
284
|
* @param {(key: string) => string} label
|
|
285
285
|
* @returns {string[]}
|
|
286
286
|
*/
|
|
287
287
|
function updateLine(label) {
|
|
288
|
-
let
|
|
288
|
+
let latest;
|
|
289
289
|
try {
|
|
290
|
-
|
|
290
|
+
latest = availableUpdate(faberunHome(), packageVersion());
|
|
291
291
|
} catch {
|
|
292
292
|
// An unreadable install root is the banner's problem to report, not the message's.
|
|
293
293
|
return [];
|
|
294
294
|
}
|
|
295
|
-
if (!
|
|
296
|
-
return [`⬆️ faberun ${
|
|
295
|
+
if (!latest) return [];
|
|
296
|
+
return [`⬆️ faberun ${latest} ${label("available")} · ${label("runUpdate")} faberun update`];
|
|
297
297
|
}
|
|
298
298
|
|
|
299
299
|
/**
|
package/src/report/render.mjs
CHANGED
|
@@ -6,11 +6,16 @@ import { lockStale, pidAlive, readLock } from "../run/lock.mjs";
|
|
|
6
6
|
import { scopeFindingsNote } from "../contract/scope-findings.mjs";
|
|
7
7
|
import { reviewNote } from "../contract/review-modes.mjs";
|
|
8
8
|
import { validateNodeSnapshot, validateRunMetadata } from "../contract/snapshot.mjs";
|
|
9
|
-
import { compactCost, compactTokens,
|
|
9
|
+
import { compactCost, compactTokens, truncateChars } from "../util.mjs";
|
|
10
10
|
import { listNodeSnapshots, nodeSnapshotPath } from "../run/node-store.mjs";
|
|
11
11
|
import { readHeartbeat } from "../engine/supervise.mjs";
|
|
12
12
|
import { SETTLED, SUCCESS } from "../engine/prompts.mjs";
|
|
13
13
|
import { packetRepetitionByNode, packetRepetitionNote } from "./packet-repetition.mjs";
|
|
14
|
+
import { aggregateCostProjection, costProjection, formatCost, formatRole, roleCosts, roleUsage } from "./role-usage.mjs";
|
|
15
|
+
|
|
16
|
+
// Re-exported so every caller keeps one import site for a run's numbers while
|
|
17
|
+
// the accounting itself lives in one module of its own.
|
|
18
|
+
export { formatRole, roleCosts, roleUsage };
|
|
14
19
|
|
|
15
20
|
/** Advisory ceiling for status.json (TECH-SPEC lean, rule 5); never enforced destructively. */
|
|
16
21
|
const STATUS_JSON_MAX_BYTES = 200 * 1024;
|
|
@@ -23,11 +28,11 @@ const POINTER_ATTENTION_CHARS = 80;
|
|
|
23
28
|
/** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
|
|
24
29
|
/** @typedef {import("../contract/index.mjs").NodeStatus} NodeStatus */
|
|
25
30
|
/** @typedef {Record<string, unknown>} JsonObject */
|
|
26
|
-
/** @typedef {"
|
|
27
|
-
/** @typedef {{costUsd: number|null, costProvenance: CostProvenance, inputTokens: number, outputTokens: number, cacheReadInputTokens: number, pricedInvocations: number, unpricedInvocations: number}} RoleUsage */
|
|
31
|
+
/** @typedef {import("./role-usage.mjs").RoleUsage} RoleUsage */
|
|
28
32
|
/** @typedef {{inputTokens: number|null, outputTokens: number|null, cacheReadInputTokens: number|null}} StatusPayloadUsage */
|
|
29
33
|
/** @typedef {{index: number, total: number, argv: string}} VerificationProgress */
|
|
30
|
-
/** @typedef {{
|
|
34
|
+
/** @typedef {{verdict: string, maxSeverity: string, findingCount: number, summary: string|null}} StatusPayloadGate */
|
|
35
|
+
/** @typedef {{id: string, status: NodeStatus, phase: string|null, executionPhase: string|null, runtime: string|null, workerRuntime: string|null, judgeRuntime: string|null, continuation: string, attempt: number, revisions: number, startedAt: string|null, updatedAt: string|null, usage: StatusPayloadUsage|null, costUsd: number|null, roleCostUsd: {worker: number|null, judge: number|null}, costProvenance: {worker: string, judge: string}, verdict: string|null, gate: StatusPayloadGate|null, gateOutcome: "passed"|"rejected"|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 */
|
|
31
36
|
/** @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 */
|
|
32
37
|
|
|
33
38
|
/** The glyph each terminal state prints in a status table. */
|
|
@@ -65,10 +70,19 @@ export function renderStatus(runDir) {
|
|
|
65
70
|
lines.push("## Needs you", "");
|
|
66
71
|
const attention = payload.nodes.filter((node) => !["pending", "running", "done", "no-op"].includes(node.status));
|
|
67
72
|
const orphans = controller.status.state !== "active" ? payload.nodes.filter((node) => node.status === "running").map((node) => node.id) : [];
|
|
68
|
-
|
|
73
|
+
// An accepted node whose judge left findings is not in an attention state —
|
|
74
|
+
// the gate was right to accept it — but the finding is still something the
|
|
75
|
+
// operator has to have seen. It is named here and nowhere else in this
|
|
76
|
+
// section, so "Nothing needs you right now" cannot be printed over it.
|
|
77
|
+
const advised = payload.nodes.filter((node) => (node.gate?.findingCount ?? 0) > 0 && node.gateOutcome !== "rejected");
|
|
78
|
+
if (!attention.length && !orphans.length && !identityWarnings.length && !advised.length) lines.push("Nothing needs you right now.");
|
|
69
79
|
if (orphans.length) lines.push(`- [>] the run process is gone while ${orphans.join(", ")} still claims to be running. Those nodes are orphans, not live work. Resume the run directory to adopt whatever their workers finished.`);
|
|
70
80
|
for (const warning of identityWarnings) lines.push(`- [~] ${warning}`);
|
|
71
81
|
for (const node of attention) lines.push(`- ${MARK[node.status] ?? "[?]"} ${node.id}: ${node.note ?? node.status}`);
|
|
82
|
+
for (const node of advised) {
|
|
83
|
+
const count = node.gate?.findingCount ?? 0;
|
|
84
|
+
lines.push(`- [~] ${node.id}: ${node.status} with ${count} judge ${count === 1 ? "finding" : "findings"} below the gate's threshold (${node.gate?.maxSeverity ?? "unknown"}). Read them with \`faberun findings\`.`);
|
|
85
|
+
}
|
|
72
86
|
lines.push("", "## Now", "", nowLine(payload, now), "", "## Nodes", "");
|
|
73
87
|
|
|
74
88
|
const widths = [3, 24, 9, 3, 28, 8, 6, 6, 6, 10, 9, MAX_NOTE_LENGTH];
|
|
@@ -87,7 +101,7 @@ export function renderStatus(runDir) {
|
|
|
87
101
|
compactTokens(node.usage?.cacheReadInputTokens),
|
|
88
102
|
compactTokens(node.usage?.outputTokens),
|
|
89
103
|
compactCost(node.costUsd),
|
|
90
|
-
node
|
|
104
|
+
gateCell(node),
|
|
91
105
|
node.note ?? "-",
|
|
92
106
|
]));
|
|
93
107
|
}
|
|
@@ -145,6 +159,22 @@ function executionPhaseOf(node) {
|
|
|
145
159
|
return node.phase;
|
|
146
160
|
}
|
|
147
161
|
|
|
162
|
+
/**
|
|
163
|
+
* The GATE cell: what the gate decided, plus how many findings it accepted
|
|
164
|
+
* the node in spite of. `passed` alone read as "the judge found nothing",
|
|
165
|
+
* which is a different claim and was the false one. Bounded to the column's
|
|
166
|
+
* nine characters by construction — `passed ~99` is the widest it gets.
|
|
167
|
+
*
|
|
168
|
+
* @param {{gateOutcome: "passed"|"rejected"|null, gate: {findingCount: number}|null}} node
|
|
169
|
+
* @returns {string}
|
|
170
|
+
*/
|
|
171
|
+
function gateCell(node) {
|
|
172
|
+
if (!node.gateOutcome) return "-";
|
|
173
|
+
const count = node.gate?.findingCount ?? 0;
|
|
174
|
+
if (node.gateOutcome !== "passed" || count === 0) return node.gateOutcome;
|
|
175
|
+
return `passed ~${count > 99 ? 99 : count}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
148
178
|
/** What the gate decided (independent of `verdict`: an advisory review or a below-threshold fail verdict still accepts the node, engine/review.mjs `applyJudgeResult`). @param {NodeSnapshot} node @returns {"passed"|"rejected"|null} */
|
|
149
179
|
export function gateOutcome(node) {
|
|
150
180
|
if (!node.gate) return null;
|
|
@@ -228,6 +258,7 @@ function buildStatusPayload(runDir, contract, nodes, identityWarnings, usage) {
|
|
|
228
258
|
summary: [...counts].map(([status, count]) => `${count} ${status}`).join(" · "),
|
|
229
259
|
nodes: nodes.map((node) => {
|
|
230
260
|
const progress = verificationProgress(node);
|
|
261
|
+
const nodeRoles = roleUsage([node]);
|
|
231
262
|
return {
|
|
232
263
|
id: node.id,
|
|
233
264
|
status: node.status,
|
|
@@ -238,6 +269,7 @@ function buildStatusPayload(runDir, contract, nodes, identityWarnings, usage) {
|
|
|
238
269
|
executionPhase: executionPhaseOf(node),
|
|
239
270
|
runtime: node.runtime ? `${node.runtime.harness}/${node.runtime.model}` : null,
|
|
240
271
|
workerRuntime: workerRuntimeLabel(node),
|
|
272
|
+
judgeRuntime: judgeRuntimeLabel(node),
|
|
241
273
|
continuation: continuationMode(node),
|
|
242
274
|
attempt: node.attempt,
|
|
243
275
|
revisions: node.revisions,
|
|
@@ -245,7 +277,21 @@ function buildStatusPayload(runDir, contract, nodes, identityWarnings, usage) {
|
|
|
245
277
|
updatedAt: node.updatedAt ?? null,
|
|
246
278
|
usage: node.usage ? { inputTokens: node.usage.inputTokens ?? null, outputTokens: node.usage.outputTokens ?? null, cacheReadInputTokens: node.usage.cacheReadInputTokens ?? null } : null,
|
|
247
279
|
costUsd: typeof node.costUsd === "number" ? node.costUsd : null,
|
|
280
|
+
// `costUsd` is the node's total, and it buys two different runtimes'
|
|
281
|
+
// work. Read against a single `runtime` label it credits the judge
|
|
282
|
+
// with the worker's spend, or the reverse. The split is the same
|
|
283
|
+
// `roleUsage` the run totals already use, applied to one node.
|
|
284
|
+
roleCostUsd: { worker: nodeRoles.worker.costUsd, judge: nodeRoles.judge.costUsd },
|
|
285
|
+
costProvenance: { worker: nodeRoles.worker.costProvenance, judge: nodeRoles.judge.costProvenance },
|
|
248
286
|
verdict: node.gate?.verdict ?? null,
|
|
287
|
+
// The structured gate result, beside the bounded `note` rather than
|
|
288
|
+
// inside it: a note is cut to MAX_NOTE_LENGTH for the table cell it
|
|
289
|
+
// shares, and a gate summary whose second clause is the finding
|
|
290
|
+
// ("... However, ...") loses exactly the half that matters. These
|
|
291
|
+
// three fields are never cut.
|
|
292
|
+
gate: node.gate
|
|
293
|
+
? { verdict: node.gate.verdict, maxSeverity: node.gate.maxSeverity, findingCount: node.gate.findings?.length ?? 0, summary: node.gate.summary ?? null }
|
|
294
|
+
: null,
|
|
249
295
|
gateOutcome: gateOutcome(node),
|
|
250
296
|
pendingHandoff: pendingHandoff(node),
|
|
251
297
|
note: statusNote(node),
|
|
@@ -464,6 +510,21 @@ export function renderReportJson(runDir) {
|
|
|
464
510
|
}
|
|
465
511
|
|
|
466
512
|
/**
|
|
513
|
+
* What the judge found, whether or not it was enough to stop the node.
|
|
514
|
+
*
|
|
515
|
+
* A gate that accepts a node whose judge left findings below `failOn` is
|
|
516
|
+
* behaving exactly as declared (`applyJudgeResult`, and the rule stated at the
|
|
517
|
+
* head of this file). The defect was that the finding then became unreadable:
|
|
518
|
+
* `findings` answered only exhaustion, so a real, correct judge finding on a
|
|
519
|
+
* node that passed reported "no findings or blocking questions to act on".
|
|
520
|
+
* Measured 2026-09-21 on a synthesis node whose gate verdict was `fail` with a
|
|
521
|
+
* severity under the threshold: the run's own retrospective claimed it had
|
|
522
|
+
* passed every gate first time, against the tool's own recorded
|
|
523
|
+
* `blockingJudgeFirstPassRate`.
|
|
524
|
+
*
|
|
525
|
+
* A finding on an accepted node is listed as an advisory: named, not
|
|
526
|
+
* escalated. The decision is unchanged; only its legibility is.
|
|
527
|
+
*
|
|
467
528
|
* @param {string} runDir
|
|
468
529
|
* @returns {string}
|
|
469
530
|
*/
|
|
@@ -472,9 +533,11 @@ export function renderFindings(runDir) {
|
|
|
472
533
|
const sections = [];
|
|
473
534
|
for (const node of nodes) {
|
|
474
535
|
const gate = node.gate;
|
|
475
|
-
if (
|
|
536
|
+
if (gate?.findings?.length) {
|
|
476
537
|
const listed = gate.findings.map((finding) => `- [${finding.severity}] ${finding.description}\n Evidence: ${finding.evidence}`).join("\n");
|
|
477
|
-
|
|
538
|
+
const advisory = node.status !== "exhausted";
|
|
539
|
+
const heading = advisory ? `## ${node.id} (advisory · the node ${node.status})` : `## ${node.id}`;
|
|
540
|
+
sections.push(`${heading}\n\nGate verdict: ${gate.verdict} (${gate.maxSeverity}). ${gate.summary}\n\n${listed}`);
|
|
478
541
|
continue;
|
|
479
542
|
}
|
|
480
543
|
const question = blockedContextQuestion(node);
|
|
@@ -483,6 +546,18 @@ export function renderFindings(runDir) {
|
|
|
483
546
|
return sections.length ? `${sections.join("\n\n")}\n` : "no findings or blocking questions to act on\n";
|
|
484
547
|
}
|
|
485
548
|
|
|
549
|
+
/**
|
|
550
|
+
* How many judge findings a node carries that did not stop it. Zero for a node
|
|
551
|
+
* the gate rejected — there the findings are the rejection, not an aside.
|
|
552
|
+
*
|
|
553
|
+
* @param {NodeSnapshot} node
|
|
554
|
+
* @returns {number}
|
|
555
|
+
*/
|
|
556
|
+
export function advisoryFindingCount(node) {
|
|
557
|
+
if (node.status === "exhausted" || node.status === "failed") return 0;
|
|
558
|
+
return node.gate?.findings?.length ?? 0;
|
|
559
|
+
}
|
|
560
|
+
|
|
486
561
|
/**
|
|
487
562
|
* A node the worker itself stopped on, rendered as the repair it asks for:
|
|
488
563
|
* `findings` used to answer only gate exhaustion, so a run whose nodes all
|
|
@@ -570,12 +645,26 @@ function continuationMode(node) {
|
|
|
570
645
|
* @param {NodeSnapshot} node
|
|
571
646
|
* @returns {string|null}
|
|
572
647
|
*/
|
|
573
|
-
function workerRuntimeLabel(node) {
|
|
648
|
+
export function workerRuntimeLabel(node) {
|
|
574
649
|
const worker = [...(node.invocations ?? [])].reverse().find((invocation) => invocation.role === "worker");
|
|
575
650
|
if (worker?.harness && worker.model) return `${worker.harness}/${worker.model}`;
|
|
576
651
|
return node.runtime ? `${node.runtime.harness}/${node.runtime.model}` : null;
|
|
577
652
|
}
|
|
578
653
|
|
|
654
|
+
/**
|
|
655
|
+
* Who judged this node. Unlike the worker's, this has no fallback to
|
|
656
|
+
* `state.runtime`: that field is whatever was dispatched last, so reading it
|
|
657
|
+
* as the judge is the very mistake this pair exists to stop. A node with no
|
|
658
|
+
* judge invocation was not judged, and says so with null.
|
|
659
|
+
*
|
|
660
|
+
* @param {NodeSnapshot} node
|
|
661
|
+
* @returns {string|null}
|
|
662
|
+
*/
|
|
663
|
+
export function judgeRuntimeLabel(node) {
|
|
664
|
+
const judge = [...(node.invocations ?? [])].reverse().find((invocation) => invocation.role === "judge");
|
|
665
|
+
return judge?.harness && judge.model ? `${judge.harness}/${judge.model}` : null;
|
|
666
|
+
}
|
|
667
|
+
|
|
579
668
|
/**
|
|
580
669
|
* A worker routing override waiting to be consumed by the node's next attempt —
|
|
581
670
|
* set by `handoff` (manual) or provider failover.
|
|
@@ -657,136 +746,6 @@ function nodeNote(node) {
|
|
|
657
746
|
return node.phase ?? "-";
|
|
658
747
|
}
|
|
659
748
|
|
|
660
|
-
/** @typedef {{costUsd: number|null, status: "known"|"estimated"|"ambiguous"}} CostProjection */
|
|
661
|
-
|
|
662
|
-
/**
|
|
663
|
-
* Per-role usage from the invocation ledger, with provenance for whether the
|
|
664
|
-
* cost total is honest: `priced` (all invocations costed), `partial`/`unpriced`
|
|
665
|
-
* (some/none costed, so `costUsd` stays null rather than understating), or
|
|
666
|
-
* `none` (no invocation). Token totals are always carried.
|
|
667
|
-
*
|
|
668
|
-
* @param {NodeSnapshot[]} nodes
|
|
669
|
-
* @returns {{worker: RoleUsage, judge: RoleUsage}}
|
|
670
|
-
*/
|
|
671
|
-
export function roleUsage(nodes) {
|
|
672
|
-
/** @type {Record<"worker"|"judge", {total: number, priced: number, unpriced: number, inputTokens: number, outputTokens: number, cacheReadInputTokens: number}>} */
|
|
673
|
-
const roles = {
|
|
674
|
-
worker: { total: 0, priced: 0, unpriced: 0, inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0 },
|
|
675
|
-
judge: { total: 0, priced: 0, unpriced: 0, inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0 },
|
|
676
|
-
};
|
|
677
|
-
for (const node of nodes) {
|
|
678
|
-
for (const invocation of node.invocations ?? []) {
|
|
679
|
-
if (invocation.role !== "worker" && invocation.role !== "judge") continue;
|
|
680
|
-
const bucket = roles[invocation.role];
|
|
681
|
-
const cost = finite(invocation.costUsd);
|
|
682
|
-
if (cost === null) bucket.unpriced += 1;
|
|
683
|
-
else {
|
|
684
|
-
bucket.priced += 1;
|
|
685
|
-
bucket.total += cost;
|
|
686
|
-
}
|
|
687
|
-
bucket.inputTokens += finite(invocation.usage?.inputTokens) ?? 0;
|
|
688
|
-
bucket.outputTokens += finite(invocation.usage?.outputTokens) ?? 0;
|
|
689
|
-
bucket.cacheReadInputTokens += finite(invocation.usage?.cacheReadInputTokens) ?? 0;
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
return { worker: summarizeRole(roles.worker), judge: summarizeRole(roles.judge) };
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
/**
|
|
696
|
-
* @param {{total: number, priced: number, unpriced: number, inputTokens: number, outputTokens: number, cacheReadInputTokens: number}} bucket
|
|
697
|
-
* @returns {RoleUsage}
|
|
698
|
-
*/
|
|
699
|
-
function summarizeRole(bucket) {
|
|
700
|
-
const costProvenance = bucket.priced > 0
|
|
701
|
-
? (bucket.unpriced > 0 ? "partial" : "priced")
|
|
702
|
-
: (bucket.unpriced > 0 ? "unpriced" : "none");
|
|
703
|
-
return {
|
|
704
|
-
costUsd: costProvenance === "priced" ? bucket.total : null,
|
|
705
|
-
costProvenance,
|
|
706
|
-
inputTokens: bucket.inputTokens,
|
|
707
|
-
outputTokens: bucket.outputTokens,
|
|
708
|
-
cacheReadInputTokens: bucket.cacheReadInputTokens,
|
|
709
|
-
pricedInvocations: bucket.priced,
|
|
710
|
-
unpricedInvocations: bucket.unpriced,
|
|
711
|
-
};
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
/**
|
|
715
|
-
* Per-role cost, summed from the invocation ledger alone. Kept for the closing
|
|
716
|
-
* artifacts, which render only the dollar cell; `roleUsage` carries the
|
|
717
|
-
* provenance that cell cannot express.
|
|
718
|
-
*
|
|
719
|
-
* @param {NodeSnapshot[]} nodes
|
|
720
|
-
* @returns {{worker: number|null, judge: number|null}}
|
|
721
|
-
*/
|
|
722
|
-
export function roleCosts(nodes) {
|
|
723
|
-
const roles = roleUsage(nodes);
|
|
724
|
-
return { worker: roles.worker.costUsd, judge: roles.judge.costUsd };
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
/**
|
|
728
|
-
* A role's cell in a totals line: the dollar total when every invocation is
|
|
729
|
-
* priced, `unpriced` with the token totals it did record when any invocation
|
|
730
|
-
* has no cost, and `-` only when the role has no invocation at all. An
|
|
731
|
-
* unpriced role reads as real work instead of vanishing into a dash.
|
|
732
|
-
*
|
|
733
|
-
* @param {RoleUsage} role
|
|
734
|
-
* @returns {string}
|
|
735
|
-
*/
|
|
736
|
-
export function formatRole(role) {
|
|
737
|
-
if (role.costUsd !== null) return compactCost(role.costUsd);
|
|
738
|
-
if (role.costProvenance === "none") return "-";
|
|
739
|
-
return `unpriced (in ${compactTokens(role.inputTokens)} · out ${compactTokens(role.outputTokens)} · cache ${compactTokens(role.cacheReadInputTokens)})`;
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
/**
|
|
743
|
-
* Project cost only from durable snapshot evidence. Invocation costs are
|
|
744
|
-
* provider-reported; a standalone node total has no provider attribution and
|
|
745
|
-
* remains an estimate. Missing or mismatched evidence is ambiguous.
|
|
746
|
-
*
|
|
747
|
-
* @param {NodeSnapshot} node
|
|
748
|
-
* @returns {CostProjection}
|
|
749
|
-
*/
|
|
750
|
-
function costProjection(node) {
|
|
751
|
-
const nodeCost = finite(node.costUsd);
|
|
752
|
-
const invocations = Array.isArray(node.invocations) ? node.invocations : [];
|
|
753
|
-
const invocationCosts = invocations.map((invocation) => finite(invocation.costUsd));
|
|
754
|
-
|
|
755
|
-
if (invocations.length > 0) {
|
|
756
|
-
if (!invocationCosts.every((cost) => cost !== null)) return { costUsd: null, status: "ambiguous" };
|
|
757
|
-
const reportedCost = invocationCosts.reduce((total, cost) => total + /** @type {number} */ (cost), 0);
|
|
758
|
-
if (nodeCost !== null && !sameCost(nodeCost, reportedCost)) return { costUsd: null, status: "ambiguous" };
|
|
759
|
-
return { costUsd: nodeCost ?? reportedCost, status: "known" };
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
if (nodeCost !== null) return { costUsd: nodeCost, status: "estimated" };
|
|
763
|
-
return { costUsd: null, status: "ambiguous" };
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
/** @param {CostProjection[]} costs @returns {CostProjection} */
|
|
767
|
-
function aggregateCostProjection(costs) {
|
|
768
|
-
if (costs.length === 0 || costs.some((cost) => cost.status === "ambiguous")) {
|
|
769
|
-
return { costUsd: null, status: "ambiguous" };
|
|
770
|
-
}
|
|
771
|
-
const costUsd = costs.every((cost) => typeof cost.costUsd === "number")
|
|
772
|
-
? costs.reduce((total, cost) => total + /** @type {number} */ (cost.costUsd), 0)
|
|
773
|
-
: null;
|
|
774
|
-
return {
|
|
775
|
-
costUsd,
|
|
776
|
-
status: costs.some((cost) => cost.status === "estimated") ? "estimated" : "known",
|
|
777
|
-
};
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
/** @param {number} left @param {number} right @returns {boolean} */
|
|
781
|
-
function sameCost(left, right) {
|
|
782
|
-
return Math.abs(left - right) <= 1e-9;
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
/** @param {CostProjection} projection @returns {string} */
|
|
786
|
-
function formatCost(projection) {
|
|
787
|
-
return `${compactCost(projection.costUsd)} (${projection.status})`;
|
|
788
|
-
}
|
|
789
|
-
|
|
790
749
|
/**
|
|
791
750
|
* @param {string} value
|
|
792
751
|
* @param {number} width
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What each role spent, and how much of that is a receipt rather than an
|
|
3
|
+
* estimate.
|
|
4
|
+
*
|
|
5
|
+
* It sits apart from the renderers because both of them need it and neither
|
|
6
|
+
* owns it: a per-node reading of the same ledger is how the closing artifacts
|
|
7
|
+
* stopped crediting a node's whole cost to whichever runtime was dispatched
|
|
8
|
+
* last (measured 2026-09-21 over seven nodes), and one home is what keeps the
|
|
9
|
+
* two surfaces from answering the question differently again.
|
|
10
|
+
*/
|
|
11
|
+
import { compactCost, compactTokens, finite } from "../util.mjs";
|
|
12
|
+
|
|
13
|
+
/** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
|
|
14
|
+
/** @typedef {"priced"|"partial"|"unpriced"|"none"} CostProvenance */
|
|
15
|
+
/** @typedef {{costUsd: number|null, costProvenance: CostProvenance, inputTokens: number, outputTokens: number, cacheReadInputTokens: number, pricedInvocations: number, unpricedInvocations: number}} RoleUsage */
|
|
16
|
+
/** @typedef {{costUsd: number|null, status: "known"|"estimated"|"ambiguous"}} CostProjection */
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Per-role usage from the invocation ledger, with provenance for whether the
|
|
20
|
+
* cost total is honest: `priced` (all invocations costed), `partial`/`unpriced`
|
|
21
|
+
* (some/none costed, so `costUsd` stays null rather than understating), or
|
|
22
|
+
* `none` (no invocation). Token totals are always carried.
|
|
23
|
+
*
|
|
24
|
+
* @param {NodeSnapshot[]} nodes
|
|
25
|
+
* @returns {{worker: RoleUsage, judge: RoleUsage}}
|
|
26
|
+
*/
|
|
27
|
+
export function roleUsage(nodes) {
|
|
28
|
+
/** @type {Record<"worker"|"judge", {total: number, priced: number, unpriced: number, inputTokens: number, outputTokens: number, cacheReadInputTokens: number}>} */
|
|
29
|
+
const roles = {
|
|
30
|
+
worker: { total: 0, priced: 0, unpriced: 0, inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0 },
|
|
31
|
+
judge: { total: 0, priced: 0, unpriced: 0, inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0 },
|
|
32
|
+
};
|
|
33
|
+
for (const node of nodes) {
|
|
34
|
+
for (const invocation of node.invocations ?? []) {
|
|
35
|
+
if (invocation.role !== "worker" && invocation.role !== "judge") continue;
|
|
36
|
+
const bucket = roles[invocation.role];
|
|
37
|
+
const cost = finite(invocation.costUsd);
|
|
38
|
+
if (cost === null) bucket.unpriced += 1;
|
|
39
|
+
else {
|
|
40
|
+
bucket.priced += 1;
|
|
41
|
+
bucket.total += cost;
|
|
42
|
+
}
|
|
43
|
+
bucket.inputTokens += finite(invocation.usage?.inputTokens) ?? 0;
|
|
44
|
+
bucket.outputTokens += finite(invocation.usage?.outputTokens) ?? 0;
|
|
45
|
+
bucket.cacheReadInputTokens += finite(invocation.usage?.cacheReadInputTokens) ?? 0;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return { worker: summarizeRole(roles.worker), judge: summarizeRole(roles.judge) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {{total: number, priced: number, unpriced: number, inputTokens: number, outputTokens: number, cacheReadInputTokens: number}} bucket
|
|
53
|
+
* @returns {RoleUsage}
|
|
54
|
+
*/
|
|
55
|
+
function summarizeRole(bucket) {
|
|
56
|
+
const costProvenance = bucket.priced > 0
|
|
57
|
+
? (bucket.unpriced > 0 ? "partial" : "priced")
|
|
58
|
+
: (bucket.unpriced > 0 ? "unpriced" : "none");
|
|
59
|
+
return {
|
|
60
|
+
costUsd: costProvenance === "priced" ? bucket.total : null,
|
|
61
|
+
costProvenance,
|
|
62
|
+
inputTokens: bucket.inputTokens,
|
|
63
|
+
outputTokens: bucket.outputTokens,
|
|
64
|
+
cacheReadInputTokens: bucket.cacheReadInputTokens,
|
|
65
|
+
pricedInvocations: bucket.priced,
|
|
66
|
+
unpricedInvocations: bucket.unpriced,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Per-role cost, summed from the invocation ledger alone. Kept for the closing
|
|
72
|
+
* artifacts, which render only the dollar cell; `roleUsage` carries the
|
|
73
|
+
* provenance that cell cannot express.
|
|
74
|
+
*
|
|
75
|
+
* @param {NodeSnapshot[]} nodes
|
|
76
|
+
* @returns {{worker: number|null, judge: number|null}}
|
|
77
|
+
*/
|
|
78
|
+
export function roleCosts(nodes) {
|
|
79
|
+
const roles = roleUsage(nodes);
|
|
80
|
+
return { worker: roles.worker.costUsd, judge: roles.judge.costUsd };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A role's cell in a totals line: the dollar total when every invocation is
|
|
85
|
+
* priced, `unpriced` with the token totals it did record when any invocation
|
|
86
|
+
* has no cost, and `-` only when the role has no invocation at all. An
|
|
87
|
+
* unpriced role reads as real work instead of vanishing into a dash.
|
|
88
|
+
*
|
|
89
|
+
* @param {RoleUsage} role
|
|
90
|
+
* @returns {string}
|
|
91
|
+
*/
|
|
92
|
+
export function formatRole(role) {
|
|
93
|
+
if (role.costUsd !== null) return compactCost(role.costUsd);
|
|
94
|
+
if (role.costProvenance === "none") return "-";
|
|
95
|
+
return `unpriced (in ${compactTokens(role.inputTokens)} · out ${compactTokens(role.outputTokens)} · cache ${compactTokens(role.cacheReadInputTokens)})`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Project cost only from durable snapshot evidence. Invocation costs are
|
|
100
|
+
* provider-reported; a standalone node total has no provider attribution and
|
|
101
|
+
* remains an estimate. Missing or mismatched evidence is ambiguous.
|
|
102
|
+
*
|
|
103
|
+
* @param {NodeSnapshot} node
|
|
104
|
+
* @returns {CostProjection}
|
|
105
|
+
*/
|
|
106
|
+
export function costProjection(node) {
|
|
107
|
+
const nodeCost = finite(node.costUsd);
|
|
108
|
+
const invocations = Array.isArray(node.invocations) ? node.invocations : [];
|
|
109
|
+
const invocationCosts = invocations.map((invocation) => finite(invocation.costUsd));
|
|
110
|
+
|
|
111
|
+
if (invocations.length > 0) {
|
|
112
|
+
if (!invocationCosts.every((cost) => cost !== null)) return { costUsd: null, status: "ambiguous" };
|
|
113
|
+
const reportedCost = invocationCosts.reduce((total, cost) => total + /** @type {number} */ (cost), 0);
|
|
114
|
+
if (nodeCost !== null && !sameCost(nodeCost, reportedCost)) return { costUsd: null, status: "ambiguous" };
|
|
115
|
+
return { costUsd: nodeCost ?? reportedCost, status: "known" };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (nodeCost !== null) return { costUsd: nodeCost, status: "estimated" };
|
|
119
|
+
return { costUsd: null, status: "ambiguous" };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** @param {CostProjection[]} costs @returns {CostProjection} */
|
|
123
|
+
export function aggregateCostProjection(costs) {
|
|
124
|
+
if (costs.length === 0 || costs.some((cost) => cost.status === "ambiguous")) {
|
|
125
|
+
return { costUsd: null, status: "ambiguous" };
|
|
126
|
+
}
|
|
127
|
+
const costUsd = costs.every((cost) => typeof cost.costUsd === "number")
|
|
128
|
+
? costs.reduce((total, cost) => total + /** @type {number} */ (cost.costUsd), 0)
|
|
129
|
+
: null;
|
|
130
|
+
return {
|
|
131
|
+
costUsd,
|
|
132
|
+
status: costs.some((cost) => cost.status === "estimated") ? "estimated" : "known",
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** @param {number} left @param {number} right @returns {boolean} */
|
|
137
|
+
function sameCost(left, right) {
|
|
138
|
+
return Math.abs(left - right) <= 1e-9;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
/** @param {CostProjection} projection @returns {string} */
|
|
143
|
+
export function formatCost(projection) {
|
|
144
|
+
return `${compactCost(projection.costUsd)} (${projection.status})`;
|
|
145
|
+
}
|