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.
@@ -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
- const runtime = node.runtime ? `${node.runtime.harness}/${node.runtime.model}` : "-";
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
- if (!attention.length && !identityWarnings.length) lines.push("Nothing needs you right now.");
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", "RUNTIME", "IN", "OUT", "CACHE", "COST", "NOTE"]),
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
- const runtime = node.runtime ? `${node.runtime.harness}/${node.runtime.model}` : "-";
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
- compactCost(node.costUsd),
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
@@ -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 { compareVersions, faberunHome, readUpdateCheck } from "../host/home.mjs";
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 the cached update check names a release newer than the one
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 check;
288
+ let latest;
289
289
  try {
290
- check = readUpdateCheck(faberunHome());
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 (!check || compareVersions(check.latest, packageVersion()) <= 0) return [];
296
- return [`⬆️ faberun ${check.latest} ${label("available")} · ${label("runUpdate")} faberun update`];
295
+ if (!latest) return [];
296
+ return [`⬆️ faberun ${latest} ${label("available")} · ${label("runUpdate")} faberun update`];
297
297
  }
298
298
 
299
299
  /**
@@ -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, finite, truncateChars } from "../util.mjs";
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 {"priced"|"partial"|"unpriced"|"none"} CostProvenance */
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 {{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, 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 */
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
- if (!attention.length && !orphans.length && !identityWarnings.length) lines.push("Nothing needs you right now.");
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.gateOutcome ?? "-",
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 (node.status === "exhausted" && gate?.findings?.length) {
536
+ if (gate?.findings?.length) {
476
537
  const listed = gate.findings.map((finding) => `- [${finding.severity}] ${finding.description}\n Evidence: ${finding.evidence}`).join("\n");
477
- sections.push(`## ${node.id}\n\nGate verdict: ${gate.verdict} (${gate.maxSeverity}). ${gate.summary}\n\n${listed}`);
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
+ }