faberun 0.10.0 → 0.12.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.
Files changed (60) hide show
  1. package/README.md +6 -0
  2. package/integrations/claude-code/statusline.sh +31 -2
  3. package/package.json +2 -2
  4. package/skills/faberun/references/spec-format.md +9 -0
  5. package/src/campaign/chain.mjs +2 -1
  6. package/src/campaign/index.mjs +102 -1
  7. package/src/campaign/metrics.mjs +2 -2
  8. package/src/cli/brand.mjs +2 -0
  9. package/src/cli/campaign-contract.mjs +55 -0
  10. package/src/cli/campaign.mjs +48 -13
  11. package/src/cli/init.mjs +8 -2
  12. package/src/cli/launch.mjs +2 -1
  13. package/src/cli/plan.mjs +3 -2
  14. package/src/cli/project.mjs +150 -0
  15. package/src/cli/skills.mjs +2 -3
  16. package/src/cli/update.mjs +7 -10
  17. package/src/cli.mjs +30 -4
  18. package/src/contract/index.mjs +44 -0
  19. package/src/contract/task-packet.mjs +12 -3
  20. package/src/engine/cancel.mjs +13 -0
  21. package/src/engine/dispatch.mjs +20 -2
  22. package/src/engine/lifecycle.mjs +38 -61
  23. package/src/engine/live-preflight.mjs +2 -1
  24. package/src/engine/notify-queue.mjs +9 -1
  25. package/src/engine/pricing-seed.json +543 -0
  26. package/src/engine/pricing-seed.mjs +54 -0
  27. package/src/engine/process.mjs +8 -1
  28. package/src/engine/result-file.mjs +2 -1
  29. package/src/engine/resume.mjs +6 -2
  30. package/src/engine/run-identity.mjs +9 -2
  31. package/src/engine/scheduler.mjs +40 -6
  32. package/src/engine/settle-judge.mjs +141 -0
  33. package/src/engine/settle.mjs +1 -1
  34. package/src/harnesses/protocol.mjs +46 -10
  35. package/src/harnesses/replay/bin.mjs +10 -1
  36. package/src/host/home.mjs +10 -3
  37. package/src/host/platform.mjs +111 -0
  38. package/src/host/preflight.mjs +43 -21
  39. package/src/host/projects.mjs +155 -0
  40. package/src/notify/index.mjs +88 -10
  41. package/src/plan/pipeline.mjs +32 -9
  42. package/src/plan/repo-facts.mjs +80 -2
  43. package/src/plan/spec.mjs +2 -1
  44. package/src/repo/declared-paths.mjs +7 -1
  45. package/src/repo/integrate.mjs +1 -1
  46. package/src/repo/scope-closure.mjs +2 -1
  47. package/src/repo/signal.mjs +24 -7
  48. package/src/repo/source-identity.mjs +7 -0
  49. package/src/repo/workspace.mjs +12 -2
  50. package/src/repo/worktree.mjs +89 -22
  51. package/src/report/next.mjs +66 -6
  52. package/src/report/progress.mjs +724 -0
  53. package/src/report/render.mjs +3 -3
  54. package/src/run/migrate.mjs +278 -0
  55. package/src/run/paths.mjs +189 -0
  56. package/src/seat/index.mjs +2 -1
  57. package/src/web/app.css +170 -0
  58. package/src/web/app.mjs +690 -0
  59. package/src/web/index.html +38 -283
  60. package/src/web/server.mjs +197 -182
@@ -0,0 +1,724 @@
1
+ /**
2
+ * Two renderers sharing one module, so a notification and a campaign page can
3
+ * never disagree about progress:
4
+ *
5
+ * `renderRunProgress` -- one message for a human, from a run's persisted
6
+ * state plus one terminal event: campaign, phase and node with a glyph for
7
+ * the node's outcome; percent and count done and left; this node's own span;
8
+ * the phase's estimated remaining time, from settled spans alone; the next
9
+ * node in the phase, or that the phase is complete; what the node delivered,
10
+ * in the worker's own words; and cost by role plus the campaign's cumulative
11
+ * cost.
12
+ *
13
+ * `renderCampaignProgress` -- the campaign-wide roll-up, answering the
14
+ * owner's four questions from persisted state alone: what the campaign
15
+ * delivered (per phase, its newest settled node's own words), what progress
16
+ * it has made (done, settled and total across every phase, kept two
17
+ * different numbers), what it cost (the campaign total plus per-role cost
18
+ * with the three token counts carried beside it -- a role only partly
19
+ * priced reports the dollars on record and its provenance still says
20
+ * `partial`), and how long it ran -- to the record's own `closedAt` once it
21
+ * has one -- with a labelled estimate of what is left. The phase list is the
22
+ * campaign record's linked runs -- the history -- followed by every manifest
23
+ * contract with no run yet -- the queue: the manifest alone is not the
24
+ * history, because the orchestrator replaces its entry each phase and
25
+ * forgets (measured 2026-09-18: manifest 1 contract, linkedRunIds 22, 20 run
26
+ * directories on disk), while the record does not. A manifest contract whose
27
+ * authored file is gone is left out rather than allowed to abort the render:
28
+ * one missing file must not cost the page all four answers.
29
+ *
30
+ * Every number here comes from a reader that already owns it -- the status
31
+ * payload (`renderStatusJson`, which already carries per-node phase, spans,
32
+ * cost and per-role usage with provenance), the raw node snapshot (for the
33
+ * worker's own `result.summary`, which the status payload does not carry),
34
+ * and, for the campaign's money and tokens, the campaign record's own
35
+ * `linkedRunIds`: each linked run's `usage.jsonl`, summed through the same
36
+ * per-role arithmetic the status payload uses for this run alone. Nothing
37
+ * here recomputes what one of those already answers.
38
+ */
39
+ import { MARK, formatDuration, formatRole, readRunUsage, renderStatusJson, roleUsage } from "./render.mjs";
40
+ import { nodeSnapshotPath, readNodeSnapshot } from "../run/node-store.mjs";
41
+ import { boundedUtf8, compactCost, finite } from "../util.mjs";
42
+ import { SETTLED, SUCCESS } from "../engine/prompts.mjs";
43
+ import { campaignDir } from "../campaign/layout.mjs";
44
+ import { readCampaign } from "../campaign/record.mjs";
45
+ import { computeNextItems } from "./next.mjs";
46
+ import { existsSync, readFileSync } from "node:fs";
47
+ import { dirname, join, resolve } from "node:path";
48
+ import { runDirectory } from "../run/paths.mjs";
49
+
50
+ /** @typedef {import("./render.mjs").StatusPayloadNode} StatusPayloadNode */
51
+ /** @typedef {import("../notify/index.mjs").NotifyEvent} NotifyEvent */
52
+
53
+ /**
54
+ * The message's byte ceiling. A three-node run, both roles priced and a
55
+ * two-sentence worker summary, measures ~430 bytes; this leaves headroom for
56
+ * a long campaign/phase/node id triple while still bounding an adversarial
57
+ * worker summary (the protocol's own 4 KiB ceiling), which is the only
58
+ * unbounded block and the one truncated first.
59
+ */
60
+ export const PROGRESS_MESSAGE_MAX_BYTES = 2048;
61
+
62
+ const NO_SUMMARY = "(no summary recorded)";
63
+
64
+ /**
65
+ * @param {string} runDir
66
+ * @param {NotifyEvent} event
67
+ * @returns {string}
68
+ */
69
+ export function renderRunProgress(runDir, event) {
70
+ const payload = /** @type {{campaignId: string, usage: {costUsd: number|null}, roles: {worker: import("./render.mjs").RoleUsage, judge: import("./render.mjs").RoleUsage}, nodes: StatusPayloadNode[]}} */
71
+ (JSON.parse(renderStatusJson(runDir)));
72
+ const subject = subjectNode(payload.nodes, event.nodeId ?? null);
73
+ const raw = readNodeSnapshot(runDir, subject.id);
74
+ const phaseNodes = payload.nodes.filter((node) => node.phase === subject.phase);
75
+ const total = payload.nodes.length;
76
+ const done = payload.nodes.filter((node) => SETTLED.has(node.status)).length;
77
+ const donePct = total ? Math.round((done / total) * 100) : 0;
78
+
79
+ const lines = [
80
+ `${MARK[subject.status] ?? "[?]"} campaign ${payload.campaignId} · phase ${subject.phase ?? "-"} · node ${subject.id}`,
81
+ `${done}/${total} nodes done · ${donePct}% done · ${100 - donePct}% left`,
82
+ `this node: ${spanOf(subject) ?? "-"}`,
83
+ phaseEstimateLine(phaseNodes),
84
+ nextNodeLine(phaseNodes),
85
+ ];
86
+ if (event.type === "attention") lines.push(attentionLine(runDir, subject, raw));
87
+ const valueLineIndex = lines.push(valueLine(raw)) - 1;
88
+ lines.push(costLine(runDir, payload));
89
+
90
+ return boundToCeiling(lines, valueLineIndex);
91
+ }
92
+
93
+ /**
94
+ * The event's own node when it names one and the run still has it, else the
95
+ * most recently settled node (a `run.terminal` event names the run, not a
96
+ * node), else the run's first node.
97
+ *
98
+ * @param {StatusPayloadNode[]} nodes
99
+ * @param {string|null} nodeId
100
+ * @returns {StatusPayloadNode}
101
+ */
102
+ function subjectNode(nodes, nodeId) {
103
+ const named = nodeId ? nodes.find((node) => node.id === nodeId) : undefined;
104
+ if (named) return named;
105
+ const settled = nodes.filter((node) => SETTLED.has(node.status) && node.updatedAt);
106
+ const latest = settled.sort((left, right) => String(left.updatedAt).localeCompare(String(right.updatedAt))).at(-1);
107
+ return latest ?? nodes[0];
108
+ }
109
+
110
+ /** @param {{startedAt: string|null, updatedAt: string|null}} node @returns {number|null} */
111
+ function spanMs(node) {
112
+ if (!node.startedAt || !node.updatedAt) return null;
113
+ const start = Date.parse(node.startedAt);
114
+ const end = Date.parse(node.updatedAt);
115
+ return Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, end - start) : null;
116
+ }
117
+
118
+ /** @param {{startedAt: string|null, updatedAt: string|null}} node @returns {string|null} */
119
+ function spanOf(node) {
120
+ const ms = spanMs(node);
121
+ return ms === null ? null : formatDuration(ms);
122
+ }
123
+
124
+ /**
125
+ * The remaining-time estimate, shared by the single-run message and the
126
+ * campaign roll-up so neither invents a second one: the mean span of the
127
+ * settled nodes alone -- a running node's partial elapsed is never in
128
+ * `SETTLED`, so it never drags the mean down and the estimate forward --
129
+ * times how many nodes have not settled. `"complete"` when nothing is left;
130
+ * `null` when nothing has settled to base the estimate on -- absent, never
131
+ * zero, the same refusal this page applies to requirement coverage.
132
+ *
133
+ * @param {{status: string}[]} nodes every node the estimate covers
134
+ * @param {{status: string, startedAt: string|null, updatedAt: string|null}[]} spans the nodes that carry the timestamps to average
135
+ * @returns {string|null}
136
+ */
137
+ function remainingEstimate(nodes, spans) {
138
+ const unsettled = nodes.filter((node) => !SETTLED.has(node.status)).length;
139
+ if (!unsettled) return "complete";
140
+ const settledSpans = spans.filter((node) => SETTLED.has(node.status)).map(spanMs).filter((ms) => ms !== null);
141
+ if (!settledSpans.length) return null;
142
+ const meanMs = settledSpans.reduce((total, ms) => total + /** @type {number} */ (ms), 0) / settledSpans.length;
143
+ return `~${formatDuration(meanMs * unsettled)} remaining (from ${settledSpans.length} settled node${settledSpans.length === 1 ? "" : "s"})`;
144
+ }
145
+
146
+ /** @param {StatusPayloadNode[]} phaseNodes @returns {string} */
147
+ function phaseEstimateLine(phaseNodes) {
148
+ return `phase estimate: ${remainingEstimate(phaseNodes, phaseNodes) ?? "unknown (no settled node yet)"}`;
149
+ }
150
+
151
+ /** @param {StatusPayloadNode[]} phaseNodes @returns {string} */
152
+ function nextNodeLine(phaseNodes) {
153
+ const next = phaseNodes.find((node) => !SETTLED.has(node.status));
154
+ return next ? `next: ${next.id}` : "next: phase complete";
155
+ }
156
+
157
+ /**
158
+ * The worker's own words, never the renderer's: the raw snapshot's own
159
+ * `result.summary`, labelled as such so a reader never mistakes it for the
160
+ * renderer's judgment.
161
+ *
162
+ * @param {Record<string, unknown>} raw
163
+ * @returns {string}
164
+ */
165
+ function valueLine(raw) {
166
+ const result = /** @type {{summary?: unknown}|null} */ (raw.result && typeof raw.result === "object" ? raw.result : null);
167
+ const summary = typeof result?.summary === "string" && result.summary.trim() ? result.summary.trim() : null;
168
+ return `worker says: ${summary ?? NO_SUMMARY}`;
169
+ }
170
+
171
+ /**
172
+ * The attention variant names what must be decided and the exact command
173
+ * that unblocks it, the way `src/report/next.mjs` does for its own items.
174
+ *
175
+ * @param {string} runDir
176
+ * @param {StatusPayloadNode} subject
177
+ * @param {Record<string, unknown>} raw
178
+ * @returns {string}
179
+ */
180
+ function attentionLine(runDir, subject, raw) {
181
+ const result = /** @type {{status?: unknown, summary?: unknown, missingContext?: unknown}|null} */ (raw.result && typeof raw.result === "object" ? raw.result : null);
182
+ if (result?.status === "blocked_context") {
183
+ const missing = Array.isArray(result.missingContext) ? result.missingContext.filter((entry) => typeof entry === "string") : [];
184
+ const asked = missing.length ? ` (needs ${missing.join(", ")})` : "";
185
+ return `decide: answer node ${subject.id}'s missing context${asked} → resume ${runDir} --answer ${subject.id}=<answer-file>`;
186
+ }
187
+ const gate = /** @type {{findings?: unknown}|null} */ (raw.gate && typeof raw.gate === "object" ? raw.gate : null);
188
+ if (subject.status === "exhausted" && Array.isArray(gate?.findings) && gate.findings.length) {
189
+ return `decide: review node ${subject.id}'s gate findings → findings ${runDir}`;
190
+ }
191
+ return `decide: resolve node ${subject.id} (${subject.status}) → status ${runDir}`;
192
+ }
193
+
194
+ /**
195
+ * The campaign's cumulative cost: every linked run's own usage.jsonl total
196
+ * (a phase-run per plan phase), summed through the same reader the status
197
+ * payload uses for this run alone -- never this run's own total standing in
198
+ * for the campaign's, which is the phase's spend, not the campaign's.
199
+ *
200
+ * `null` when the campaign record cannot be read at all (never once a
201
+ * linked run's own usage is merely unpriced or missing, which `readRunUsage`
202
+ * already folds into the sum as a no-op).
203
+ *
204
+ * @param {string} runsDir
205
+ * @param {string} campaignId
206
+ * @returns {number|null}
207
+ */
208
+ function campaignCumulativeCostUsd(runsDir, campaignId) {
209
+ let campaign;
210
+ try {
211
+ campaign = readCampaign(campaignDir(runsDir, campaignId));
212
+ } catch {
213
+ return null;
214
+ }
215
+ let total = null;
216
+ for (const runId of campaign.linkedRunIds) {
217
+ const cost = readRunUsage(join(runsDir, runId)).costUsd;
218
+ if (typeof cost === "number") total = (total ?? 0) + cost;
219
+ }
220
+ return total;
221
+ }
222
+
223
+ /**
224
+ * @param {string} runDir
225
+ * @param {{campaignId: string, roles: {worker: import("./render.mjs").RoleUsage, judge: import("./render.mjs").RoleUsage}}} payload
226
+ * @returns {string}
227
+ */
228
+ function costLine(runDir, payload) {
229
+ const cumulative = campaignCumulativeCostUsd(dirname(runDir), payload.campaignId);
230
+ return `cost — worker ${formatRole(payload.roles.worker)} · judge ${formatRole(payload.roles.judge)} · campaign total ${compactCost(cumulative)}`;
231
+ }
232
+
233
+ /**
234
+ * The value block is the only unbounded part of the message, so it is the
235
+ * only one truncated when the whole message would exceed the ceiling.
236
+ *
237
+ * @param {string[]} lines
238
+ * @param {number} valueLineIndex
239
+ * @returns {string}
240
+ */
241
+ function boundToCeiling(lines, valueLineIndex) {
242
+ const overflow = Buffer.byteLength(lines.join("\n"), "utf8") - PROGRESS_MESSAGE_MAX_BYTES;
243
+ if (overflow <= 0) return lines.join("\n");
244
+ const valueLineBytes = Buffer.byteLength(lines[valueLineIndex], "utf8");
245
+ const trimmed = [...lines];
246
+ trimmed[valueLineIndex] = boundedUtf8(lines[valueLineIndex], Math.max(0, valueLineBytes - overflow));
247
+ return trimmed.join("\n");
248
+ }
249
+
250
+ /**
251
+ * @typedef {{id: string, dependsOn: string[], status: string, attempt: number, workerRuntime: string|null, judgeRuntime: string|null, elapsedSpan: string|null, costUsd: number|null, runDir: string|null, workerLogPath: string|null, judgeLogPaths: string[], verificationRecordPath: string|null, attemptBranch: string|null, sealCommit: string|null}} RollupNode
252
+ * `status` carries every `NodeStatus` plus two the manifest alone can
253
+ * explain: `not_started` (the contract has no run yet) and `unreadable` (a
254
+ * run exists but this node's own snapshot does not).
255
+ * `workerRuntime` and `judgeRuntime` are two different facts, not one field
256
+ * whichever role happened to run last: a gated node's `state.runtime` is
257
+ * overwritten by the judge dispatch (`src/engine/dispatch.mjs` writes it at
258
+ * three points), so a single `runtime` field reads as though the judge did
259
+ * the worker's job. `judgeRuntime` is `null` for a node with no gate, or one
260
+ * whose gate never ran -- never the worker's own runtime repeated.
261
+ */
262
+ /**
263
+ * `counts.done` and `counts.settled` are two different numbers: `done` is
264
+ * `SUCCESS` alone (a reader's "finished"), `settled` is `SETTLED` (the
265
+ * scheduler's "will not move again on its own", which also holds blocked,
266
+ * failed and exhausted). A phase holding a blocked node settles without
267
+ * finishing; a page that draws it from `settled` alone draws it as done,
268
+ * which is the lie this split exists to prevent.
269
+ */
270
+ /** @typedef {{contractId: string|null, runId: string|null, phase: string|null, name: string|null, goal: string|null, declaredRequirementIds: string[], nodes: RollupNode[], counts: {done: number, settled: number, total: number}, newestSettledNode: RollupNewestNode|null, recordGone?: boolean}} RollupPhase */
271
+ /** @typedef {{contractId: string, nodeId: string, summary: string}} RollupNewestNode */
272
+ /** @typedef {{reason: string, command: string, runnable: boolean}} RollupNextAction */
273
+
274
+ /**
275
+ * The campaign roll-up: `renderRunProgress`'s campaign-wide sibling. Every
276
+ * field comes from persisted state -- the campaign record, each linked run's
277
+ * own directory, and the runs' usage ledgers -- never from a live model.
278
+ *
279
+ * @param {string} runsDir
280
+ * @param {string} campaignId
281
+ * @param {number} [now] epoch ms the roll-up is rendered at; defaults to the
282
+ * wall clock, and injected by tests so the clock can be held still
283
+ * @returns {string}
284
+ */
285
+ export function renderCampaignProgress(runsDir, campaignId, now = Date.now()) {
286
+ return `${JSON.stringify(buildCampaignProgress(runsDir, campaignId, now), null, 2)}\n`;
287
+ }
288
+
289
+ /**
290
+ * @param {string} runsDir
291
+ * @param {string} campaignId
292
+ * @param {number} now
293
+ * @returns {Record<string, unknown>}
294
+ */
295
+ function buildCampaignProgress(runsDir, campaignId, now) {
296
+ const campaignPath = campaignDir(runsDir, campaignId);
297
+ const campaign = readCampaign(campaignPath);
298
+ // The manifest is a queue of what is declared to run next, and the
299
+ // orchestrator replaces its entry each phase, so a roll-up that walks it
300
+ // alone reports a campaign of one phase after thirteen have landed
301
+ // (measured 2026-09-18 on this campaign: manifest 1 contract,
302
+ // linkedRunIds 22, 20 run directories on disk). The record does not
303
+ // forget: every linked run is a phase that happened, in the order the
304
+ // record registered it, and every manifest contract with no run is a
305
+ // phase that has not started. Happened first, then next -- the order a
306
+ // reader expects. A manifest contract whose id is already linked is the
307
+ // same phase arriving by both roads (a launched run takes its contract's
308
+ // id as its own), so it reports once, from the run's own directory --
309
+ // decided before any snapshot is read, and without ever letting an
310
+ // unreadable authored contract abort the render (`buildManifestPhase`).
311
+ const linkedRunIds = new Set(campaign.linkedRunIds);
312
+ const built = campaign.linkedRunIds.map((runId) => buildLinkedRunPhase(runsDir, runId));
313
+ for (const entry of campaign.contracts) {
314
+ const phase = buildManifestPhase(entry, linkedRunIds);
315
+ if (phase) built.push(phase);
316
+ }
317
+ const phases = built.map((entry) => entry.output);
318
+ const settledNodes = built.flatMap((entry) => entry.settledCandidates);
319
+ const done = phases.reduce((total, phase) => total + phase.counts.done, 0);
320
+ const settled = phases.reduce((total, phase) => total + phase.counts.settled, 0);
321
+ const total = phases.reduce((total2, phase) => total2 + phase.counts.total, 0);
322
+ return {
323
+ schemaVersion: 1,
324
+ campaignId: campaign.id,
325
+ goal: campaign.goal,
326
+ landBranch: campaign.landBranch,
327
+ phases,
328
+ counts: { done, settled, total },
329
+ // The denominator is every node the campaign has ever declared: each
330
+ // linked run's own contract copy plus each never-launched manifest
331
+ // contract, so a campaign can never read as 100% while a declared phase
332
+ // is unauthored work. A pruned run's nodes are gone with its directory
333
+ // and count as nothing rather than as a guess. The percentage counts
334
+ // `done` (`SUCCESS` alone), never `settled`: a running or blocked node is
335
+ // not done either, but only `settled` also folds in blocked, failed and
336
+ // exhausted, which would read as progress.
337
+ percentDone: total ? Math.round((done / total) * 100) : 0,
338
+ costByRole: campaignRoleUsage(runsDir, campaign.linkedRunIds),
339
+ // The same sum the single-run message prints as "campaign total", from
340
+ // the same ledgers -- the roll-up and the message cannot disagree about
341
+ // what the campaign spent.
342
+ costTotalUsd: campaignCumulativeCostUsd(runsDir, campaignId),
343
+ newestSettledNode: newestSettledNode(settledNodes),
344
+ time: campaignTime(campaign, phases, settledNodes, now),
345
+ // The chain calls this contract's own run directory the campaign's
346
+ // repo root three levels above `campaignPath` (`record.mjs`'s
347
+ // `preserveCampaignLedger` derives it the same way); reused here so the
348
+ // next action's command matches what `next` itself would print.
349
+ nextAction: campaignNextAction(runsDir, campaignId, resolve(campaignPath, "..", "..", "..")),
350
+ };
351
+ }
352
+
353
+ /**
354
+ * One manifest entry's roll-up: the contract's own declared nodes and graph
355
+ * edges when it has never run, or those same nodes filled in from the run's
356
+ * persisted snapshots once it has. `null` when the entry contributes no
357
+ * phase: its authored contract is unreadable, or its contract id is already
358
+ * linked and has reported from the run's own directory -- decided before any
359
+ * snapshot is read, so the dedupe never pays for the reads it discards.
360
+ *
361
+ * @param {{path: string, digest: string}} entry
362
+ * @param {Set<string>} linkedRunIds
363
+ * @returns {{output: RollupPhase, settledCandidates: {contractId: string, nodeId: string, snapshot: Record<string, unknown>}[]}|null}
364
+ */
365
+ function buildManifestPhase(entry, linkedRunIds) {
366
+ let raw;
367
+ try {
368
+ raw = /** @type {Record<string, unknown>} */ (JSON.parse(readFileSync(entry.path, "utf8")));
369
+ } catch {
370
+ // The authored contract is gone or unreadable, so the entry cannot name
371
+ // its phase. If its run launched, the run is linked and the phase has
372
+ // already reported from its own directory -- an anonymous placeholder
373
+ // here would count one phase twice. If it never launched, the chain
374
+ // parks the campaign on the missing file at its next supervise
375
+ // (`validateManifestEntryAtLaunch` cannot even hash it), where the
376
+ // operator decides; the page omits the declaration rather than showing
377
+ // a phase nothing can read or run. Measured 2026-09-19: seven real
378
+ // campaigns hit the first case, and this read -- unguarded in the
379
+ // previous attempt -- aborted all four answers for each of them.
380
+ return null;
381
+ }
382
+ const contractId = String(raw.id);
383
+ if (linkedRunIds.has(contractId)) return null;
384
+ const cwd = resolve(dirname(entry.path), typeof raw.cwd === "string" ? raw.cwd : ".");
385
+ const runDir = runDirectory(cwd, contractId);
386
+ // The same signal the chain itself uses to decide a contract has started
387
+ // (`chain.mjs`'s own launch loop checks this file before it trusts a run
388
+ // directory's contents).
389
+ const hasRun = existsSync(join(runDir, "run.json"));
390
+ return buildPhaseFromContract(raw, runDir, hasRun ? contractId : null);
391
+ }
392
+
393
+ /**
394
+ * One phase's roll-up from its contract object, shared by the two roads a
395
+ * phase arrives by: a manifest entry (whose run directory is derived from
396
+ * the contract id) and a linked run (whose directory the campaign record
397
+ * names directly). `runId` doubles as the has-run signal -- null means the
398
+ * contract has never launched, so its nodes report `not_started` from the
399
+ * declaration alone. `settledCandidates` carries the raw snapshot beside its
400
+ * contract and node id, for the campaign-wide newest-summary and
401
+ * remaining-time readers, which need the snapshot's own `result`, `status`
402
+ * and spans -- content this function's own output never repeats.
403
+ *
404
+ * @param {Record<string, unknown>} raw
405
+ * @param {string} runDir
406
+ * @param {string|null} runId
407
+ * @returns {{output: RollupPhase, settledCandidates: {contractId: string, nodeId: string, snapshot: Record<string, unknown>}[]}}
408
+ */
409
+ function buildPhaseFromContract(raw, runDir, runId) {
410
+ const hasRun = runId !== null;
411
+ const contractId = String(raw.id);
412
+ const rawNodes = Array.isArray(raw.nodes) ? raw.nodes : [];
413
+ const phaseId = typeof rawNodes[0]?.phase === "string" ? rawNodes[0].phase : null;
414
+ const workerRuntimeById = hasRun ? workerRuntimeMap(runDir) : new Map();
415
+
416
+ /** @type {{contractId: string, nodeId: string, snapshot: Record<string, unknown>}[]} */
417
+ const settledCandidates = [];
418
+ const nodes = rawNodes.map((node) => rollupNode(node, contractId, runDir, hasRun, settledCandidates, workerRuntimeById));
419
+ const done = nodes.filter((node) => SUCCESS.has(node.status)).length;
420
+ const settled = nodes.filter((node) => SETTLED.has(node.status)).length;
421
+
422
+ return {
423
+ output: {
424
+ contractId,
425
+ runId,
426
+ phase: phaseId,
427
+ name: phaseName(phaseId),
428
+ goal: typeof raw.goal === "string" ? raw.goal : null,
429
+ // Declared-not-measured, and empty until a frozen plan carries
430
+ // requirement ids on the phase itself (R9/R10): there used to be a
431
+ // scrape of requirement ids out of the campaign journal's decision
432
+ // entries, keyed by whether a decision's own text happened to name
433
+ // this phase or contract. Journal prose is not a traceability source —
434
+ // a phase-decomposition decision that names every requirement id in
435
+ // one sentence attributed all of them to whichever phase the decision
436
+ // text mentioned, which is a wrong mapping, and a wrong mapping is
437
+ // worse than a missing one.
438
+ declaredRequirementIds: [],
439
+ newestSettledNode: newestSettledNode(settledCandidates),
440
+ nodes,
441
+ counts: { done, settled, total: nodes.length },
442
+ },
443
+ settledCandidates,
444
+ };
445
+ }
446
+
447
+ /**
448
+ * A phase from a run the campaign record links, read from the run's own
449
+ * directory: its contract copy supplies the declared nodes, goal and phase
450
+ * id, its snapshots supply what happened. A run id the record remembers but
451
+ * whose directory is gone (pruned, or never materialised) is still a phase
452
+ * that happened -- it is flagged `recordGone` and everything else about it
453
+ * is left unknown rather than guessed; the run id itself is the readable
454
+ * residue, being the name the operator ran.
455
+ *
456
+ * @param {string} runsDir
457
+ * @param {string} runId
458
+ * @returns {{output: RollupPhase, settledCandidates: {contractId: string, nodeId: string, snapshot: Record<string, unknown>}[]}}
459
+ */
460
+ function buildLinkedRunPhase(runsDir, runId) {
461
+ try {
462
+ const raw = /** @type {Record<string, unknown>} */ (JSON.parse(readFileSync(join(runsDir, runId, "contract.json"), "utf8")));
463
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new TypeError("contract copy is not an object");
464
+ return buildPhaseFromContract(raw, join(runsDir, runId), runId);
465
+ } catch {
466
+ return {
467
+ output: {
468
+ contractId: null,
469
+ runId,
470
+ phase: null,
471
+ name: phaseName(runId),
472
+ goal: null,
473
+ declaredRequirementIds: [],
474
+ newestSettledNode: null,
475
+ nodes: [],
476
+ counts: { done: 0, settled: 0, total: 0 },
477
+ recordGone: true,
478
+ },
479
+ settledCandidates: [],
480
+ };
481
+ }
482
+ }
483
+
484
+ /**
485
+ * The honest per-node worker runtime, read from the same status payload
486
+ * `buildStatusPayload` already computes (`workerRuntimeLabel`'s scan of the
487
+ * invocation ledger for the worker that actually ran) -- never recomputed
488
+ * here, so this reader and `status --json`/`report` can never disagree about
489
+ * who worked a node. Empty when the run's own payload cannot be built at all
490
+ * (e.g. a snapshot elsewhere in the run is corrupt), so one broken sibling
491
+ * never hides this phase's readable nodes; `rollupNode` falls back to `null`
492
+ * per node in that case, same as an unreadable snapshot would.
493
+ *
494
+ * @param {string} runDir
495
+ * @returns {Map<string, string|null>}
496
+ */
497
+ function workerRuntimeMap(runDir) {
498
+ try {
499
+ const payload = /** @type {{nodes: {id: string, workerRuntime: string|null}[]}} */ (JSON.parse(renderStatusJson(runDir)));
500
+ return new Map(payload.nodes.map((entry) => [entry.id, entry.workerRuntime]));
501
+ } catch {
502
+ return new Map();
503
+ }
504
+ }
505
+
506
+ /**
507
+ * The judge that actually reviewed this node, read the same way
508
+ * `workerRuntimeLabel` reads the worker: the invocation ledger's own last
509
+ * `role: "judge"` entry. Unlike the worker there is no fallback to
510
+ * `snapshot.runtime` -- a node with no gate, or one whose gate never ran, has
511
+ * no judge invocation at all, and must report no judge runtime rather than
512
+ * the worker's runtime standing in for it.
513
+ *
514
+ * @param {Record<string, unknown>[]} invocations
515
+ * @returns {string|null}
516
+ */
517
+ function judgeRuntimeLabel(invocations) {
518
+ const judge = /** @type {{harness?: unknown, model?: unknown}|undefined} */ ([...invocations].reverse().find((invocation) => invocation?.role === "judge"));
519
+ return judge?.harness && judge?.model ? `${judge.harness}/${judge.model}` : null;
520
+ }
521
+
522
+ /**
523
+ * @param {Record<string, unknown>} node the manifest's own declared node
524
+ * @param {string} contractId
525
+ * @param {string} runDir
526
+ * @param {boolean} hasRun
527
+ * @param {{contractId: string, nodeId: string, snapshot: Record<string, unknown>}[]} settledCandidates appended to when this node's own snapshot is readable
528
+ * @param {Map<string, string|null>} workerRuntimeById
529
+ * @returns {RollupNode}
530
+ */
531
+ function rollupNode(node, contractId, runDir, hasRun, settledCandidates, workerRuntimeById) {
532
+ const id = String(node.id);
533
+ const dependsOn = Array.isArray(node.dependsOn) ? node.dependsOn.map(String) : [];
534
+ const empty = { id, dependsOn, attempt: 0, workerRuntime: null, judgeRuntime: null, elapsedSpan: null, costUsd: null, workerLogPath: null, judgeLogPaths: /** @type {string[]} */ ([]), attemptBranch: null, sealCommit: null };
535
+ if (!hasRun) return { ...empty, status: "not_started", runDir: null, verificationRecordPath: null };
536
+
537
+ let snapshot;
538
+ try {
539
+ snapshot = readNodeSnapshot(runDir, id);
540
+ } catch {
541
+ return { ...empty, status: "unreadable", runDir, verificationRecordPath: nodeSnapshotPath(runDir, id) };
542
+ }
543
+ settledCandidates.push({ contractId, nodeId: id, snapshot });
544
+
545
+ const invocations = Array.isArray(snapshot.invocations) ? snapshot.invocations : [];
546
+ const worker = [...invocations].reverse().find((invocation) => invocation?.role === "worker");
547
+ const judgeLogPaths = invocations
548
+ .filter((invocation) => invocation?.role === "judge")
549
+ .map((invocation) => invocation.stdoutPath)
550
+ .filter((path) => typeof path === "string");
551
+ const worktree = snapshot.worktree && typeof snapshot.worktree === "object" ? /** @type {Record<string, unknown>} */ (snapshot.worktree) : null;
552
+
553
+ return {
554
+ id,
555
+ dependsOn,
556
+ status: typeof snapshot.status === "string" ? snapshot.status : "unreadable",
557
+ attempt: typeof snapshot.attempt === "number" ? snapshot.attempt : 0,
558
+ workerRuntime: workerRuntimeById.get(id) ?? null,
559
+ judgeRuntime: judgeRuntimeLabel(invocations),
560
+ elapsedSpan: spanOf({ startedAt: typeof snapshot.startedAt === "string" ? snapshot.startedAt : null, updatedAt: typeof snapshot.updatedAt === "string" ? snapshot.updatedAt : null }),
561
+ costUsd: typeof snapshot.costUsd === "number" ? snapshot.costUsd : null,
562
+ runDir,
563
+ workerLogPath: typeof worker?.stdoutPath === "string" ? worker.stdoutPath : null,
564
+ judgeLogPaths,
565
+ verificationRecordPath: nodeSnapshotPath(runDir, id),
566
+ attemptBranch: typeof worktree?.branch === "string" ? worktree.branch : null,
567
+ sealCommit: typeof worktree?.sealedSha === "string" ? worktree.sealedSha : null,
568
+ };
569
+ }
570
+
571
+ /**
572
+ * A phase id is not a name (the owner's own words on seeing `0b3` unlabelled):
573
+ * hyphens become spaces and the first letter is capitalized, so
574
+ * `verdict-and-write-check` reads as `Verdict and write check`.
575
+ *
576
+ * @param {string|null} phaseId
577
+ * @returns {string|null}
578
+ */
579
+ function phaseName(phaseId) {
580
+ if (!phaseId) return null;
581
+ const words = phaseId.replaceAll("-", " ");
582
+ return words.charAt(0).toUpperCase() + words.slice(1);
583
+ }
584
+
585
+ /**
586
+ * The most recently settled node across every linked run, campaign-wide --
587
+ * `renderRunProgress`'s `subjectNode` narrowed to one run; here there is no
588
+ * event naming a node, only whichever settled last.
589
+ *
590
+ * @param {{contractId: string, nodeId: string, snapshot: Record<string, unknown>}[]} candidates
591
+ * @returns {RollupNewestNode|null}
592
+ */
593
+ function newestSettledNode(candidates) {
594
+ const settled = candidates.filter((candidate) => SETTLED.has(/** @type {import("../contract/index.mjs").NodeStatus} */ (String(candidate.snapshot.status)))
595
+ && typeof candidate.snapshot.updatedAt === "string");
596
+ const latest = settled.sort((left, right) => String(left.snapshot.updatedAt).localeCompare(String(right.snapshot.updatedAt))).at(-1);
597
+ if (!latest) return null;
598
+ const result = /** @type {{summary?: unknown}|null} */ (latest.snapshot.result && typeof latest.snapshot.result === "object" ? latest.snapshot.result : null);
599
+ const summary = typeof result?.summary === "string" && result.summary.trim() ? result.summary.trim() : null;
600
+ return { contractId: latest.contractId, nodeId: latest.nodeId, summary: summary ?? NO_SUMMARY };
601
+ }
602
+
603
+ /**
604
+ * The campaign's per-role cost and token counts, summed from every linked
605
+ * run's own `usage.jsonl` -- the same records `campaignCumulativeCostUsd`
606
+ * sums the money from, so the per-role view and the campaign total cannot
607
+ * disagree. Node snapshots cannot promise this: a pruned or torn run keeps
608
+ * its usage ledger after its snapshots are gone. The three token kinds stay
609
+ * three numbers -- they differ by two orders of magnitude on a real campaign
610
+ * (measured 2026-09-18: 6.9M input against 333.5M cache-read) -- and a role
611
+ * with no price on record reports its tokens as unpriced, never a zero. A
612
+ * role only partly priced reports the dollars it does have on record, with
613
+ * `costProvenance` still saying `partial` (`withRecordedCost`).
614
+ *
615
+ * @param {string} runsDir
616
+ * @param {string[]} linkedRunIds
617
+ * @returns {{worker: import("./render.mjs").RoleUsage, judge: import("./render.mjs").RoleUsage}}
618
+ */
619
+ function campaignRoleUsage(runsDir, linkedRunIds) {
620
+ /** @type {Record<string, unknown>[]} */
621
+ const invocations = [];
622
+ /** @type {Record<"worker"|"judge", number>} */
623
+ const pricedUsd = { worker: 0, judge: 0 };
624
+ for (const runId of linkedRunIds) {
625
+ const path = join(runsDir, runId, "usage.jsonl");
626
+ if (!existsSync(path)) continue;
627
+ for (const line of readFileSync(path, "utf8").split("\n")) {
628
+ if (!line.trim()) continue;
629
+ let record;
630
+ try { record = JSON.parse(line); } catch { continue; }
631
+ if (!record || typeof record !== "object" || Array.isArray(record)) continue;
632
+ const value = /** @type {Record<string, unknown>} */ (record);
633
+ invocations.push({
634
+ role: value.role,
635
+ costUsd: value.costUsd,
636
+ usage: { inputTokens: value.inputTokens, outputTokens: value.outputTokens, cacheReadInputTokens: value.cacheReadInputTokens },
637
+ });
638
+ const cost = finite(value.costUsd);
639
+ if ((value.role === "worker" || value.role === "judge") && cost !== null) pricedUsd[value.role] += cost;
640
+ }
641
+ }
642
+ const roles = roleUsage(/** @type {import("../contract/index.mjs").NodeSnapshot[]} */ (/** @type {unknown} */ ([{ invocations }])));
643
+ return {
644
+ worker: withRecordedCost(roles.worker, pricedUsd.worker),
645
+ judge: withRecordedCost(roles.judge, pricedUsd.judge),
646
+ };
647
+ }
648
+
649
+ /**
650
+ * The dollars a role has on record, even when only some of its invocations
651
+ * carry a price. `summarizeRole` keeps `costUsd` null for a partly priced
652
+ * role so no surface can read a partial sum as the whole cost; the campaign
653
+ * page makes the other half of that trade -- it prints the recorded dollars
654
+ * while `costProvenance` still says `partial` -- because "what did it cost?"
655
+ * answered with null while real dollars sit in the ledger hides the answer
656
+ * the page exists to give (measured 2026-09-18: $3.69 and $6.80 of recorded
657
+ * spend rendering as no money at all). A fully unpriced role keeps null:
658
+ * no dollars are on record, and none are invented.
659
+ *
660
+ * @param {import("./render.mjs").RoleUsage} role
661
+ * @param {number} pricedUsd
662
+ * @returns {import("./render.mjs").RoleUsage}
663
+ */
664
+ function withRecordedCost(role, pricedUsd) {
665
+ if (role.costUsd === null && pricedUsd > 0) return { ...role, costUsd: pricedUsd };
666
+ return role;
667
+ }
668
+
669
+ /**
670
+ * Answer four: how long the campaign has been running and what is left.
671
+ * Elapsed runs from the campaign record's own `createdAt` -- the earliest
672
+ * clock the record durably carries -- to its own `closedAt` once the record
673
+ * carries one: a closed campaign's clock stopped when it closed, and an
674
+ * elapsed that keeps counting to now reports how long ago the campaign was
675
+ * born, not how long it ran (measured 2026-09-19: a campaign closed after
676
+ * ~14h52m rendering 140h21m of running time). A first linked run's start
677
+ * lives in a run directory pruning can take, and an elapsed that moves when
678
+ * history is pruned is not a record. Remaining reuses `remainingEstimate`,
679
+ * the single-run renderer's own rule, over the whole campaign's nodes.
680
+ *
681
+ * @param {import("../campaign/index.mjs").Campaign} campaign
682
+ * @param {RollupPhase[]} phases
683
+ * @param {{contractId: string, nodeId: string, snapshot: Record<string, unknown>}[]} settledCandidates
684
+ * @param {number} now epoch ms
685
+ * @returns {{startedAt: string|null, elapsed: string|null, remaining: string|null}}
686
+ */
687
+ function campaignTime(campaign, phases, settledCandidates, now) {
688
+ const startedAt = typeof campaign.createdAt === "string" ? campaign.createdAt : null;
689
+ const start = startedAt === null ? NaN : Date.parse(startedAt);
690
+ const closedAt = typeof campaign.closedAt === "string" ? campaign.closedAt : null;
691
+ const closed = closedAt === null ? NaN : Date.parse(closedAt);
692
+ const end = Number.isFinite(closed) ? closed : now;
693
+ const nodes = phases.flatMap((phase) => phase.nodes);
694
+ const spans = settledCandidates.map((candidate) => ({
695
+ status: String(candidate.snapshot.status),
696
+ startedAt: typeof candidate.snapshot.startedAt === "string" ? candidate.snapshot.startedAt : null,
697
+ updatedAt: typeof candidate.snapshot.updatedAt === "string" ? candidate.snapshot.updatedAt : null,
698
+ }));
699
+ return {
700
+ startedAt,
701
+ elapsed: Number.isFinite(start) ? formatDuration(Math.max(0, end - start)) : null,
702
+ // A campaign with no readable node at all has nothing to estimate from
703
+ // and nothing to be complete about: absent, like the estimate itself.
704
+ remaining: nodes.length ? remainingEstimate(nodes, spans) : null,
705
+ };
706
+ }
707
+
708
+ /**
709
+ * The operator's next action for this one campaign, reusing `next`'s own
710
+ * ranked predicates rather than re-deriving them: a second implementation of
711
+ * "what needs the operator" is exactly how a page and `faberun next` could
712
+ * disagree. `null` when `next` has nothing runnable or worth naming for this
713
+ * campaign (an empty command, e.g. "run live; nothing to do").
714
+ *
715
+ * @param {string} runsDir
716
+ * @param {string} campaignId
717
+ * @param {string} cwd
718
+ * @returns {RollupNextAction|null}
719
+ */
720
+ function campaignNextAction(runsDir, campaignId, cwd) {
721
+ const item = computeNextItems(runsDir, cwd).find((candidate) => candidate.campaign === campaignId);
722
+ if (!item || !item.command) return null;
723
+ return { reason: item.reason, command: item.command, runnable: item.runnable };
724
+ }