faberun 0.30.0 → 0.32.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/package.json +1 -1
  2. package/src/campaign/campaign-brief.mjs +17 -6
  3. package/src/campaign/chain.mjs +124 -8
  4. package/src/campaign/ledger.mjs +19 -7
  5. package/src/campaign/metrics-command.mjs +68 -9
  6. package/src/campaign/metrics.mjs +75 -45
  7. package/src/campaign/record.mjs +8 -1
  8. package/src/campaign/reserve.mjs +340 -0
  9. package/src/campaign/spend.mjs +229 -0
  10. package/src/campaign/unpark.mjs +54 -1
  11. package/src/campaign/watch.mjs +314 -37
  12. package/src/cli/brand.mjs +2 -1
  13. package/src/cli.mjs +52 -2
  14. package/src/contract/judge-envelope.mjs +17 -2
  15. package/src/contract/scope-findings.mjs +3 -1
  16. package/src/contract/task-packet.mjs +40 -5
  17. package/src/contract/verification.mjs +67 -1
  18. package/src/engine/aux-admission.mjs +124 -0
  19. package/src/engine/capacity.mjs +78 -0
  20. package/src/engine/dispatch.mjs +116 -23
  21. package/src/engine/failure-cause.mjs +59 -0
  22. package/src/engine/lifecycle.mjs +3 -2
  23. package/src/engine/phase-session.mjs +56 -10
  24. package/src/engine/prompts.mjs +57 -2
  25. package/src/engine/reserve-admission.mjs +99 -0
  26. package/src/engine/resume.mjs +3 -1
  27. package/src/engine/review.mjs +21 -0
  28. package/src/engine/run-command.mjs +166 -11
  29. package/src/engine/scheduler.mjs +92 -88
  30. package/src/engine/settle.mjs +15 -8
  31. package/src/engine/settlement-overlap.mjs +143 -0
  32. package/src/engine/verify.mjs +131 -7
  33. package/src/harnesses/session-metrics.mjs +112 -20
  34. package/src/notify/index.mjs +86 -10
  35. package/src/plan/freeze.mjs +7 -2
  36. package/src/plan/human-step.mjs +36 -0
  37. package/src/plan/pipeline.mjs +53 -22
  38. package/src/plan/preflight.mjs +81 -5
  39. package/src/plan/progress.mjs +101 -1
  40. package/src/plan/repo-facts.mjs +403 -49
  41. package/src/plan/resolve.mjs +7 -0
  42. package/src/plan/revise-context.mjs +108 -0
  43. package/src/plan/rounds.mjs +180 -45
  44. package/src/plan/sizing.mjs +33 -1
  45. package/src/plan/template.mjs +134 -22
  46. package/src/repo/integrate.mjs +47 -5
  47. package/src/report/campaign-brief-estimate.mjs +47 -1
  48. package/src/report/campaign-brief.mjs +31 -2
  49. package/src/report/locale.mjs +20 -0
  50. package/src/report/message.mjs +13 -1
  51. package/src/report/metrics-report.mjs +38 -0
  52. package/src/report/progress.mjs +194 -10
  53. package/src/report/render.mjs +1 -1
  54. package/src/report/report-html.mjs +235 -0
  55. package/src/report/report-video.mjs +323 -0
  56. package/src/run/usage-windows.mjs +99 -4
  57. package/src/run/usage.mjs +69 -7
  58. package/src/web/api.mjs +179 -24
  59. package/src/web/app.mjs +27 -2
  60. package/src/web/server.mjs +32 -0
@@ -21,6 +21,11 @@
21
21
  * authored file is gone is left out rather than allowed to abort the render:
22
22
  * one missing file must not cost the page all four answers.
23
23
  *
24
+ * The same roll-up is the one projection for where the campaign stands now:
25
+ * current phase, node in flight, time since useful progress, wait reason,
26
+ * unpriced invocations, and the next action's state -- applied, pending or
27
+ * none -- with the owner of a human decision, all from persisted state.
28
+ *
24
29
  * Every number here comes from a reader that already owns it -- the status
25
30
  * payload (`renderStatusJson`, which already carries per-node phase, spans,
26
31
  * cost and per-role usage with provenance), the raw node snapshot (for the
@@ -37,6 +42,7 @@ import { finite } from "../util.mjs";
37
42
  import { SETTLED, SUCCESS } from "../engine/prompts.mjs";
38
43
  import { campaignDir } from "../campaign/layout.mjs";
39
44
  import { readCampaign } from "../campaign/record.mjs";
45
+ import { readJournal } from "../campaign/journal.mjs";
40
46
  import { computeNextItems } from "./next.mjs";
41
47
  import { existsSync, readFileSync } from "node:fs";
42
48
  import { dirname, join, resolve } from "node:path";
@@ -154,7 +160,12 @@ function campaignCumulativeCostUsd(runsDir, campaignId) {
154
160
  */
155
161
  /** @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 */
156
162
  /** @typedef {{contractId: string, nodeId: string, summary: string}} RollupNewestNode */
157
- /** @typedef {{reason: string, command: string, runnable: boolean}} RollupNextAction */
163
+ /**
164
+ * @typedef {{state: "applied"|"pending"|"none", reason: string|null, command: string|null, runnable: boolean, decisionOwner: "operator"|null}} RollupNextAction
165
+ * `applied` means the journal records an operator command for the action
166
+ * written after the state that demanded it; `decisionOwner` names the
167
+ * owner of the campaign's human decision, always the operator, else null.
168
+ */
158
169
 
159
170
  /**
160
171
  * The campaign roll-up: `renderRunProgress`'s campaign-wide sibling. Every
@@ -204,6 +215,15 @@ export function buildCampaignProgress(runsDir, campaignId, now) {
204
215
  const done = phases.reduce((total, phase) => total + phase.counts.done, 0);
205
216
  const settled = phases.reduce((total, phase) => total + phase.counts.settled, 0);
206
217
  const total = phases.reduce((total2, phase) => total2 + phase.counts.total, 0);
218
+ // The waiting clocks read against the record's own `closedAt` once
219
+ // closed -- `campaignTime`'s elapsed rule -- else the render's now.
220
+ const closedAt = typeof campaign.closedAt === "string" && Number.isFinite(Date.parse(campaign.closedAt)) ? campaign.closedAt : null;
221
+ const end = closedAt === null ? now : Date.parse(closedAt);
222
+ const progressAt = lastProgressAtOf(settledNodes);
223
+ const activity = currentActivityOf(phases, settledNodes);
224
+ const current = currentPhaseOf(phases);
225
+ const decisionNeeded = phases.some((phase) => phase.nodes.some((node) => node.status === "blocked" || node.status === "exhausted"));
226
+ const costByRole = campaignRoleUsage(runsDir, campaign.linkedRunIds);
207
227
  return {
208
228
  schemaVersion: 1,
209
229
  campaignId: campaign.id,
@@ -220,18 +240,29 @@ export function buildCampaignProgress(runsDir, campaignId, now) {
220
240
  // not done either, but only `settled` also folds in blocked, failed and
221
241
  // exhausted, which would read as progress.
222
242
  percentDone: total ? Math.round((done / total) * 100) : 0,
223
- costByRole: campaignRoleUsage(runsDir, campaign.linkedRunIds),
243
+ // Where the campaign stands: the first phase, in the record's own order,
244
+ // with a node that has not settled, and the node in flight, if any.
245
+ currentPhase: current === null ? null : { contractId: current.contractId, runId: current.runId, phase: current.phase, name: current.name },
246
+ activity,
247
+ requirements: campaignRequirements(phases),
248
+ costByRole,
224
249
  // The same sum the single-run message prints as "campaign total", from
225
250
  // the same ledgers -- the roll-up and the message cannot disagree about
226
251
  // what the campaign spent.
227
252
  costTotalUsd: campaignCumulativeCostUsd(runsDir, campaignId),
253
+ unpricedInvocations: costByRole.worker.unpricedInvocations + costByRole.judge.unpricedInvocations,
228
254
  newestSettledNode: newestSettledNode(settledNodes),
229
- time: campaignTime(campaign, phases, settledNodes, now),
255
+ time: {
256
+ ...campaignTime(campaign, phases, settledNodes, now),
257
+ lastProgressAt: progressAt,
258
+ sinceProgress: progressAt === null ? null : formatDuration(Math.max(0, end - Date.parse(progressAt))),
259
+ },
260
+ waitReason: campaignWaitReason(phases, activity, closedAt, total),
230
261
  // The chain calls this contract's own run directory the campaign's
231
262
  // repo root three levels above `campaignPath` (`record.mjs`'s
232
263
  // `preserveCampaignLedger` derives it the same way); reused here so the
233
264
  // next action's command matches what `next` itself would print.
234
- nextAction: campaignNextAction(runsDir, campaignId, resolve(campaignPath, "..", "..", "..")),
265
+ nextAction: campaignNextAction(runsDir, campaignId, resolve(campaignPath, "..", "..", ".."), campaignPath, progressAt, closedAt, decisionNeeded),
235
266
  };
236
267
  }
237
268
 
@@ -596,20 +627,173 @@ function campaignTime(campaign, phases, settledCandidates, now) {
596
627
  };
597
628
  }
598
629
 
630
+ /**
631
+ * When the campaign last usefully moved: the newest `updatedAt` among its
632
+ * settled nodes, read from the same snapshots `newestSettledNode` reads. A
633
+ * running node's own timestamp climbs while it works, so it is never
634
+ * progress -- only a node that has stopped moving (done, blocked, failed,
635
+ * exhausted) is. `null` when nothing has settled: no progress is on record,
636
+ * and none is invented.
637
+ *
638
+ * @param {{contractId: string, nodeId: string, snapshot: Record<string, unknown>}[]} candidates
639
+ * @returns {string|null}
640
+ */
641
+ function lastProgressAtOf(candidates) {
642
+ let latest = null;
643
+ for (const candidate of candidates) {
644
+ if (!SETTLED.has(/** @type {import("../contract/index.mjs").NodeStatus} */ (String(candidate.snapshot.status)))) continue;
645
+ const at = candidate.snapshot.updatedAt;
646
+ if (typeof at === "string" && (latest === null || at > latest)) latest = at;
647
+ }
648
+ return latest;
649
+ }
650
+
651
+ /**
652
+ * What the campaign is doing right now: the not-yet-settled node that
653
+ * started most recently, from persisted snapshots -- never a live probe.
654
+ * `null` when nothing is in flight.
655
+ *
656
+ * @param {RollupPhase[]} phases
657
+ * @param {{contractId: string, nodeId: string, snapshot: Record<string, unknown>}[]} candidates
658
+ * @returns {{runId: string|null, phase: string|null, nodeId: string, startedAt: string}|null}
659
+ */
660
+ function currentActivityOf(phases, candidates) {
661
+ /** @type {{runId: string|null, phase: string|null, nodeId: string, startedAt: string}|null} */
662
+ let latest = null;
663
+ for (const candidate of candidates) {
664
+ if (SETTLED.has(/** @type {import("../contract/index.mjs").NodeStatus} */ (String(candidate.snapshot.status)))) continue;
665
+ const startedAt = typeof candidate.snapshot.startedAt === "string" ? candidate.snapshot.startedAt : null;
666
+ if (startedAt === null || (latest !== null && startedAt <= latest.startedAt)) continue;
667
+ const phase = phases.find((entry) => entry.contractId === candidate.contractId && entry.nodes.some((node) => node.id === candidate.nodeId));
668
+ latest = { runId: phase?.runId ?? null, phase: phase?.phase ?? null, nodeId: candidate.nodeId, startedAt };
669
+ }
670
+ return latest;
671
+ }
672
+
673
+ /**
674
+ * Where the campaign stands: the first phase, in the record's own order,
675
+ * with a node that has not settled -- the one a running node belongs to, or
676
+ * the blocked one, or the next queued one. `null` when every declared node
677
+ * has settled.
678
+ *
679
+ * @param {RollupPhase[]} phases
680
+ * @returns {RollupPhase|null}
681
+ */
682
+ function currentPhaseOf(phases) {
683
+ return phases.find((phase) => phase.counts.settled < phase.counts.total) ?? null;
684
+ }
685
+
686
+ /**
687
+ * Why nothing is moving, from the same nodes the counts come from: a
688
+ * blocked or exhausted node waits on a human decision, a never-launched
689
+ * phase waits on its launch, a run stopped mid-phase waits on supervision,
690
+ * everything settled with nothing queued waits on closure. A campaign with
691
+ * a node in flight is not waiting; a closed one waits for nothing.
692
+ *
693
+ * @param {RollupPhase[]} phases
694
+ * @param {{runId: string|null, phase: string|null, nodeId: string, startedAt: string}|null} activity
695
+ * @param {string|null} closedAt
696
+ * @param {number} total
697
+ * @returns {{code: "decision"|"launch"|"supervise"|"complete", detail: string|null}|null}
698
+ */
699
+ function campaignWaitReason(phases, activity, closedAt, total) {
700
+ if (closedAt !== null || activity !== null) return null;
701
+ for (const phase of phases) {
702
+ const node = phase.nodes.find((entry) => entry.status === "blocked" || entry.status === "exhausted");
703
+ if (node) return { code: "decision", detail: node.id };
704
+ }
705
+ const current = currentPhaseOf(phases);
706
+ if (current === null) return total > 0 ? { code: "complete", detail: null } : { code: "launch", detail: null };
707
+ if (current.runId === null) return { code: "launch", detail: current.name };
708
+ return { code: "supervise", detail: current.name };
709
+ }
710
+
711
+ /**
712
+ * Requirements proven over total, from the requirement ids the phases
713
+ * declare: a phase's ids are proven when every node it declared finished.
714
+ * No phase carries declared ids yet (the journal scrape was removed --
715
+ * journal prose is not a traceability source), so both are `null`: absent,
716
+ * never zero, the page's standing refusal on requirement coverage.
717
+ *
718
+ * @param {RollupPhase[]} phases
719
+ * @returns {{proven: number|null, total: number|null}}
720
+ */
721
+ function campaignRequirements(phases) {
722
+ const total = new Set();
723
+ const proven = new Set();
724
+ for (const phase of phases) {
725
+ for (const id of phase.declaredRequirementIds) {
726
+ total.add(id);
727
+ if (phase.counts.total > 0 && phase.counts.done === phase.counts.total) proven.add(id);
728
+ }
729
+ }
730
+ return total.size > 0 ? { proven: proven.size, total: total.size } : { proven: null, total: null };
731
+ }
732
+
599
733
  /**
600
734
  * The operator's next action for this one campaign, reusing `next`'s own
601
735
  * ranked predicates rather than re-deriving them: a second implementation of
602
736
  * "what needs the operator" is exactly how a page and `faberun next` could
603
- * disagree. `null` when `next` has nothing runnable or worth naming for this
604
- * campaign (an empty command, e.g. "run live; nothing to do").
737
+ * disagree. The action carries its state -- `pending` while it stands,
738
+ * `applied` once the journal records an operator command for it, `none`
739
+ * when nothing needs doing: a campaign the record shows closed, or one
740
+ * `next` ranks with nothing to run. `decisionOwner` names who owns the
741
+ * human decision when the campaign holds a blocked or exhausted node --
742
+ * that answer is the operator's, never the projection's.
605
743
  *
606
744
  * @param {string} runsDir
607
745
  * @param {string} campaignId
608
746
  * @param {string} cwd
609
- * @returns {RollupNextAction|null}
747
+ * @param {string} campaignPath
748
+ * @param {string|null} progressAt the timestamp of the state the action answers
749
+ * @param {string|null} closedAt
750
+ * @param {boolean} decisionNeeded
751
+ * @returns {RollupNextAction}
610
752
  */
611
- function campaignNextAction(runsDir, campaignId, cwd) {
753
+ function campaignNextAction(runsDir, campaignId, cwd, campaignPath, progressAt, closedAt, decisionNeeded) {
754
+ /** @type {RollupNextAction} */
755
+ const none = { state: "none", reason: null, command: null, runnable: false, decisionOwner: null };
756
+ if (closedAt !== null) return none;
612
757
  const item = computeNextItems(runsDir, cwd).find((candidate) => candidate.campaign === campaignId);
613
- if (!item || !item.command) return null;
614
- return { reason: item.reason, command: item.command, runnable: item.runnable };
758
+ if (!item || !item.command) {
759
+ return decisionNeeded ? { ...none, decisionOwner: "operator" } : none;
760
+ }
761
+ return {
762
+ state: actionAppliedInJournal(campaignPath, item, progressAt) ? "applied" : "pending",
763
+ reason: item.reason,
764
+ command: item.command,
765
+ runnable: item.runnable,
766
+ decisionOwner: decisionNeeded ? "operator" : null,
767
+ };
768
+ }
769
+
770
+ /**
771
+ * Whether the journal records the operator applying this action: an
772
+ * `operator.command` entry with the action's verb naming its target (the
773
+ * first argument), written no earlier than the state that demanded it --
774
+ * never the whole line, since the operator's real answer file is not the
775
+ * placeholder the projection prints. An unreadable journal leaves it pending.
776
+ *
777
+ * @param {string} campaignPath
778
+ * @param {{command: string}} item
779
+ * @param {string|null} progressAt
780
+ * @returns {boolean}
781
+ */
782
+ function actionAppliedInJournal(campaignPath, item, progressAt) {
783
+ const tokens = item.command.trim().split(/\s+/u);
784
+ /** @type {import("../campaign/index.mjs").JournalEntry[]} */
785
+ let entries;
786
+ try {
787
+ entries = readJournal(campaignPath);
788
+ } catch {
789
+ return false;
790
+ }
791
+ return entries.some((entry) => {
792
+ const command = entry.type === "operator.command" ? /** @type {Record<string, unknown>} */ (entry).command : null;
793
+ if (typeof command !== "string") return false;
794
+ const applied = command.trim().split(/\s+/u);
795
+ return applied[0] === tokens[0]
796
+ && (tokens[1] === undefined || command.includes(tokens[1]))
797
+ && (progressAt === null || entry.at >= progressAt);
798
+ });
615
799
  }
@@ -448,7 +448,7 @@ export function renderReport(runDir) {
448
448
  const totals = { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, costUsd: null };
449
449
  const costs = nodes.map(costProjection);
450
450
  const aggregateCost = aggregateCostProjection(costs);
451
- const lines = [`# run ${basename(runDir)}`, "", `${nodes.length} nodes · ${summary} · in ${compactTokens(usage.inputTokens)} · out ${compactTokens(usage.outputTokens)} · cache ${compactTokens(usage.cacheReadInputTokens)} · cost ${compactCost(usage.costUsd)}`, "", "```", row(["", "NODE", "STATE", "TRY", "REV", "RUNTIME", "IN", "OUT", "CACHE", "COST", "NOTE"]), row(widths.map((width) => "-".repeat(width)))];
451
+ const lines = [`# run ${basename(runDir)}`, "", `${nodes.length} nodes · ${summary} · in ${compactTokens(usage.inputTokens)} · out ${compactTokens(usage.outputTokens)} · cache ${compactTokens(usage.cacheReadInputTokens)} · cost ${compactCost(usage.costUsd)}`, "", "```", row(["", "NODE", "STATE", "TRY", "REV", "RUNTIME", "IN", "OUT", "CACHE", "USD", "NOTE"]), row(widths.map((width) => "-".repeat(width)))];
452
452
  for (const [index, node] of nodes.entries()) {
453
453
  const usage = node.usage ?? { inputTokens: null, outputTokens: null, cacheReadInputTokens: null };
454
454
  for (const key of /** @type {("inputTokens"|"outputTokens"|"cacheReadInputTokens")[]} */ (Object.keys(totals).filter((key) => key !== "costUsd"))) totals[key] = (totals[key] ?? 0) + (usage[key] ?? 0);
@@ -0,0 +1,235 @@
1
+ /**
2
+ * The run report's HTML boundary: the same contract `campaign-brief-html.mjs`
3
+ * owns for the campaign brief — Markdown is the durable source, the portable
4
+ * `.md.html` is a copy that only survives a build, check and round-trip — but
5
+ * for `report`, the surface the operator reads a run's attempts, tokens and
6
+ * cost from. It renders the ASCII table as a Markdown table and, when the
7
+ * phase has a dependency topology, an inline SVG of the work graph instead of
8
+ * a list, then hands the Markdown to that one renderer rather than opening a
9
+ * second markdown→HTML path.
10
+ *
11
+ * It is a module apart from `render.mjs` because that file owns the terminal
12
+ * and JSON surfaces and is already near the 800-line ceiling; the HTML copy is
13
+ * a different audience (a browser, not a terminal) and a different lifetime
14
+ * (a file beside the Markdown, not stdout).
15
+ */
16
+ import { readFileSync, rmSync } from "node:fs";
17
+ import { basename, join } from "node:path";
18
+ import { renderReportJson } from "./render.mjs";
19
+ import { cell } from "./campaign-brief.mjs";
20
+ import { CampaignBriefRenderError, renderCampaignBriefHtml } from "./campaign-brief-html.mjs";
21
+ import { formatCost, formatRole } from "./role-usage.mjs";
22
+ import { compactTokens } from "../util.mjs";
23
+ import { writeTextAtomic } from "../run/store.mjs";
24
+
25
+ /** The durable source and its portable copy, beside `STATUS.md` in the run directory. */
26
+ const REPORT_MD_FILE = "report.md";
27
+ const REPORT_HTML_FILE = "report.md.html";
28
+
29
+ /** @typedef {{id: string, status: string, dependsOn: string[]}} GraphNode */
30
+ /** @typedef {import("./role-usage.mjs").RoleUsage} RoleUsage */
31
+ /** @typedef {import("./role-usage.mjs").CostProjection} CostProjection */
32
+ /** @typedef {{id: string, status: string, attempt: number, revisions: number, runtime: string|null, usage: {inputTokens: number|null, outputTokens: number|null, cacheReadInputTokens: number|null}|null, costUsd: number|null, costStatus: CostProjection["status"], note: string|null}} ReportNode */
33
+ /** @typedef {{run: string, summary: string, totals: {inputTokens: number, outputTokens: number, cacheReadInputTokens: number, costUsd: number|null, costStatus: CostProjection["status"]}, roles: {worker: RoleUsage, judge: RoleUsage}, nodes: ReportNode[]}} ReportPayload */
34
+
35
+ /**
36
+ * The work graph as inline SVG, or null when every node is
37
+ * dependency-independent and a list reads better than a diagram. The layout is
38
+ * the same depth/row scheme the dashboard uses (`src/web/app.mjs`): a node's
39
+ * column is its dependency depth, its row is its position among the siblings
40
+ * at that depth. Self-contained: inline colours, no stylesheet, no external
41
+ * reference, so the rendered copy stays offline.
42
+ *
43
+ * @param {GraphNode[]} nodes
44
+ * @returns {string|null}
45
+ */
46
+ export function renderReportGraphSvg(nodes) {
47
+ const hasEdges = nodes.some((node) => (node.dependsOn ?? []).length > 0);
48
+ if (!hasEdges) return null;
49
+ const laidOut = layoutGraphNodes(nodes);
50
+ const byId = new Map(laidOut.map((node) => [node.id, node]));
51
+ const maxDepth = laidOut.reduce((max, node) => Math.max(max, node.depth), 0);
52
+ const maxRow = laidOut.reduce((max, node) => Math.max(max, node.row), 0);
53
+ const width = NODE_MARGIN * 2 + (maxDepth + 1) * NODE_COL_WIDTH;
54
+ const height = NODE_MARGIN * 2 + (maxRow + 1) * NODE_ROW_HEIGHT;
55
+ const posOf = (/** @type {{depth: number, row: number}} */ node) => ({ x: NODE_MARGIN + node.depth * NODE_COL_WIDTH, y: NODE_MARGIN + node.row * NODE_ROW_HEIGHT });
56
+ const edges = laidOut.flatMap((node) => (node.dependsOn ?? [])
57
+ .filter((/** @type {string} */ dep) => byId.has(dep))
58
+ .map((/** @type {string} */ dep) => {
59
+ const from = posOf(/** @type {{depth: number, row: number}} */ (byId.get(dep)));
60
+ const to = posOf(node);
61
+ const x1 = from.x + NODE_BOX_W;
62
+ const y1 = from.y + NODE_BOX_H / 2;
63
+ const x2 = to.x;
64
+ const y2 = to.y + NODE_BOX_H / 2;
65
+ const midX = (x1 + x2) / 2;
66
+ return `<path d="M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}" fill="none" stroke="#D97B4F" stroke-width="2"/>`;
67
+ })).join("");
68
+ const boxes = laidOut.map((node, index) => {
69
+ const { x, y } = posOf(node);
70
+ const clipId = `nodeclip${index}`;
71
+ const colour = STATUS_COLOURS[node.status] ?? "#1F1F1F";
72
+ return `<g><rect x="${x}" y="${y}" width="${NODE_BOX_W}" height="${NODE_BOX_H}" rx="8" fill="#F4E9D8" stroke="${colour}" stroke-width="2"/><clipPath id="${clipId}"><rect x="${x}" y="${y}" width="${NODE_BOX_W}" height="${NODE_BOX_H}"/></clipPath><g clip-path="url(#${clipId})"><text x="${x + 10}" y="${y + 22}" font-family="ui-monospace, monospace" font-size="12" fill="#1F1F1F">${escapeSvgText(truncateOneLine(node.id, NODE_ID_MAX_CHARS))}</text><text x="${x + 10}" y="${y + 40}" font-family="ui-sans-serif, sans-serif" font-size="12" fill="${colour}">${escapeSvgText(node.status)}</text></g></g>`;
73
+ }).join("");
74
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" role="img" aria-label="work graph">${edges}${boxes}</svg>`;
75
+ }
76
+
77
+ /**
78
+ * `report`'s Markdown: the same facts `report --json` carries, laid out for a
79
+ * browser. The node table mirrors the ASCII table's columns (with `USD`, the
80
+ * one term both surfaces now share) and the work graph renders as a diagram
81
+ * when there are dependencies, else as a list.
82
+ *
83
+ * @param {string} runDir
84
+ * @returns {string}
85
+ */
86
+ export function renderReportMarkdown(runDir) {
87
+ const payload = /** @type {ReportPayload} */ (JSON.parse(renderReportJson(runDir)));
88
+ const dependsOn = readContractDependsOn(runDir);
89
+ const graph = renderReportGraphSvg(payload.nodes.map((node) => ({ id: node.id, status: node.status, dependsOn: dependsOn.get(node.id) ?? [] })));
90
+ const totals = payload.totals;
91
+ const summary = `${payload.summary} · in ${compactTokens(totals.inputTokens)} · out ${compactTokens(totals.outputTokens)} · cache ${compactTokens(totals.cacheReadInputTokens)} · cost ${formatCost({ costUsd: totals.costUsd, status: totals.costStatus })}`;
92
+
93
+ const header = "| NODE | STATE | TRY | REV | RUNTIME | IN | OUT | CACHE | USD | NOTE |";
94
+ const separator = "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |";
95
+ const rows = payload.nodes.map((node) => {
96
+ const usage = node.usage ?? { inputTokens: null, outputTokens: null, cacheReadInputTokens: null };
97
+ return `| \`${cell(node.id)}\` | ${cell(node.status)} | ${node.attempt} | ${node.revisions} | ${cell(node.runtime ?? "-")} | ${compactTokens(usage.inputTokens)} | ${compactTokens(usage.outputTokens)} | ${compactTokens(usage.cacheReadInputTokens)} | ${cell(formatCost({ costUsd: node.costUsd, status: node.costStatus }))} | ${cell(node.note ?? "-")} |`;
98
+ });
99
+
100
+ const graphSection = graph
101
+ ? `## Work graph\n\n${graph}`
102
+ : `## Work graph\n\nDependency-independent nodes: ${payload.nodes.map((node) => `\`${node.id}\``).join(", ")}.`;
103
+
104
+ const totalsLine = `totals · in ${compactTokens(totals.inputTokens)} · out ${compactTokens(totals.outputTokens)} · cache ${compactTokens(totals.cacheReadInputTokens)} · worker ${formatRole(payload.roles.worker)} · judge ${formatRole(payload.roles.judge)} · cost ${formatCost({ costUsd: totals.costUsd, status: totals.costStatus })}`;
105
+
106
+ return [
107
+ `# Report — ${payload.run}`,
108
+ "",
109
+ summary,
110
+ "",
111
+ graphSection,
112
+ "",
113
+ "## Nodes",
114
+ "",
115
+ header,
116
+ separator,
117
+ ...rows,
118
+ "",
119
+ "## Totals",
120
+ "",
121
+ totalsLine,
122
+ "",
123
+ ].join("\n");
124
+ }
125
+
126
+ /**
127
+ * Write `report.md` (the durable source) and render its portable copy, exactly
128
+ * as the campaign brief does: the Markdown is always written first, the HTML
129
+ * only survives a successful build, check and round-trip, and a renderer
130
+ * failure removes any prior HTML and reports the renderer's named code. The
131
+ * text path — `renderReport` — is untouched and works without mdhtml.
132
+ *
133
+ * @param {string} runDir
134
+ * @param {{mdhtmlBin?: string, cwd?: string, renderHtml?: (markdown: string, options: import("./campaign-brief-html.mjs").RenderCampaignBriefHtmlOptions) => import("./campaign-brief-html.mjs").RenderCampaignBriefHtmlResult}} [options]
135
+ * @returns {{markdownPath: string, htmlPath: string|null, code: string|null}}
136
+ */
137
+ export function generateReportHtml(runDir, options = {}) {
138
+ const markdown = renderReportMarkdown(runDir);
139
+ const markdownPath = join(runDir, REPORT_MD_FILE);
140
+ const htmlPath = join(runDir, REPORT_HTML_FILE);
141
+ writeTextAtomic(markdownPath, markdown);
142
+ const renderHtml = options.renderHtml ?? renderCampaignBriefHtml;
143
+ try {
144
+ renderHtml(markdown, { outputPath: htmlPath, title: `Report — ${basename(runDir)}`, mdhtmlBin: options.mdhtmlBin, cwd: options.cwd });
145
+ } catch (error) {
146
+ rmSync(htmlPath, { force: true });
147
+ const code = error instanceof CampaignBriefRenderError ? error.code : "REPORT_HTML_FAILED";
148
+ return { markdownPath, htmlPath: null, code };
149
+ }
150
+ return { markdownPath, htmlPath, code: null };
151
+ }
152
+
153
+ /**
154
+ * The contract's per-node `dependsOn`, read tolerantly: `renderReportJson` has
155
+ * already validated the contract, so this fallback only guards a race where
156
+ * the file changes between the two reads.
157
+ *
158
+ * @param {string} runDir
159
+ * @returns {Map<string, string[]>}
160
+ */
161
+ export function readContractDependsOn(runDir) {
162
+ try {
163
+ const contract = /** @type {{nodes?: {id?: unknown, dependsOn?: unknown}[]}} */ (JSON.parse(readFileSync(join(runDir, "contract.json"), "utf8")));
164
+ return new Map((contract.nodes ?? []).map((node) => [
165
+ String(node.id),
166
+ Array.isArray(node.dependsOn) ? node.dependsOn.filter((dep) => typeof dep === "string") : [],
167
+ ]));
168
+ } catch {
169
+ return new Map();
170
+ }
171
+ }
172
+
173
+ const NODE_COL_WIDTH = 260;
174
+ const NODE_ROW_HEIGHT = 84;
175
+ const NODE_BOX_W = 220;
176
+ const NODE_BOX_H = 56;
177
+ const NODE_MARGIN = 16;
178
+ const NODE_ID_MAX_CHARS = 28;
179
+
180
+ /** One fill per terminal state, the same earthy palette the brief theme declares. */
181
+ /** @type {Record<string, string | undefined>} */
182
+ export const STATUS_COLOURS = {
183
+ done: "#556B3F",
184
+ "no-op": "#556B3F",
185
+ running: "#D97B4F",
186
+ pending: "#1F1F1F",
187
+ blocked: "#B5522A",
188
+ failed: "#B5522A",
189
+ exhausted: "#B5522A",
190
+ stalled: "#B5522A",
191
+ canceled: "#B5522A",
192
+ };
193
+
194
+ /**
195
+ * A node's column is its dependency depth; its row is its position among the
196
+ * siblings at that depth (the dashboard's own scheme). Exported so the video
197
+ * scene (`report-video.mjs`) reuses this one layout instead of carrying a
198
+ * copy.
199
+ *
200
+ * @param {GraphNode[]} nodes
201
+ * @returns {(GraphNode & {depth: number, row: number})[]}
202
+ */
203
+ export function layoutGraphNodes(nodes) {
204
+ const byId = new Map(nodes.map((node) => [node.id, node]));
205
+ const depthCache = new Map();
206
+ /** @param {string} id @param {Set<string>} seen @returns {number} */
207
+ const depthOf = (id, seen) => {
208
+ if (depthCache.has(id)) return /** @type {number} */ (depthCache.get(id));
209
+ if (seen.has(id)) return 0;
210
+ seen.add(id);
211
+ const node = byId.get(id);
212
+ const deps = (node?.dependsOn ?? []).filter((dep) => byId.has(dep));
213
+ const depth = deps.length === 0 ? 0 : 1 + Math.max(...deps.map((dep) => depthOf(dep, seen)));
214
+ depthCache.set(id, depth);
215
+ return depth;
216
+ };
217
+ const rowCounters = new Map();
218
+ return nodes.map((node) => {
219
+ const depth = depthOf(node.id, new Set());
220
+ const row = rowCounters.get(depth) ?? 0;
221
+ rowCounters.set(depth, row + 1);
222
+ return { ...node, depth, row };
223
+ });
224
+ }
225
+
226
+ /** @param {string} value @returns {string} */
227
+ export function escapeSvgText(value) {
228
+ return value.replace(/[&<>"']/gu, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[ch] ?? ch));
229
+ }
230
+
231
+ /** @param {string} value @param {number} max @returns {string} */
232
+ function truncateOneLine(value, max) {
233
+ const flat = value.replace(/\s+/gu, " ").trim();
234
+ return flat.length <= max ? flat : `${flat.slice(0, Math.max(0, max - 1))}…`;
235
+ }