faberun 0.3.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 (144) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -0
  3. package/bin/faberun.mjs +25 -0
  4. package/integrations/claude-code/statusline-bench.sh +42 -0
  5. package/integrations/claude-code/statusline.sh +80 -0
  6. package/package.json +33 -0
  7. package/skills/faberun/SKILL.md +24 -0
  8. package/skills/faberun/references/contract.md +380 -0
  9. package/skills/faberun/references/engineering.md +29 -0
  10. package/skills/faberun/references/handoffs.md +26 -0
  11. package/skills/faberun/references/operations.md +184 -0
  12. package/skills/faberun/references/rules.md +35 -0
  13. package/skills/faberun/references/workflow.md +23 -0
  14. package/skills/init-agentkit/SKILL.md +108 -0
  15. package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
  16. package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
  17. package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
  18. package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
  19. package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
  20. package/skills/init-agentkit/templates/AGENTS.md +110 -0
  21. package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
  22. package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
  23. package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
  24. package/skills/init-agentkit/templates/docs/VISION.md +33 -0
  25. package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
  26. package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
  27. package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
  28. package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
  29. package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
  30. package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
  31. package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
  32. package/src/campaign/brief.mjs +394 -0
  33. package/src/campaign/chain.mjs +555 -0
  34. package/src/campaign/handoff.mjs +516 -0
  35. package/src/campaign/index.mjs +300 -0
  36. package/src/campaign/journal.mjs +347 -0
  37. package/src/campaign/layout.mjs +51 -0
  38. package/src/campaign/metrics-evals.mjs +25 -0
  39. package/src/campaign/metrics.mjs +517 -0
  40. package/src/campaign/projection.mjs +250 -0
  41. package/src/campaign/record.mjs +102 -0
  42. package/src/campaign/unpark.mjs +56 -0
  43. package/src/cli/brand.mjs +205 -0
  44. package/src/cli/campaign.mjs +730 -0
  45. package/src/cli/contract.mjs +67 -0
  46. package/src/cli/init.mjs +170 -0
  47. package/src/cli/launch.mjs +239 -0
  48. package/src/cli/seat.mjs +139 -0
  49. package/src/cli/setup.mjs +294 -0
  50. package/src/cli/skills.mjs +105 -0
  51. package/src/cli/update.mjs +216 -0
  52. package/src/cli.mjs +525 -0
  53. package/src/contract/articles.mjs +12 -0
  54. package/src/contract/assert.mjs +162 -0
  55. package/src/contract/definition-of-done.mjs +97 -0
  56. package/src/contract/final-verification.mjs +96 -0
  57. package/src/contract/index.mjs +641 -0
  58. package/src/contract/judge-envelope.mjs +25 -0
  59. package/src/contract/review-modes.mjs +151 -0
  60. package/src/contract/runtime.mjs +204 -0
  61. package/src/contract/schema-version.mjs +25 -0
  62. package/src/contract/scope-findings.mjs +77 -0
  63. package/src/contract/snapshot.mjs +639 -0
  64. package/src/contract/task-packet.mjs +495 -0
  65. package/src/contract/untrusted.mjs +75 -0
  66. package/src/contract/verification.mjs +185 -0
  67. package/src/contract/worker-result.mjs +138 -0
  68. package/src/engine/assignment.mjs +63 -0
  69. package/src/engine/backoff.mjs +492 -0
  70. package/src/engine/bulk-read.mjs +361 -0
  71. package/src/engine/cancel.mjs +177 -0
  72. package/src/engine/detach.mjs +101 -0
  73. package/src/engine/dispatch.mjs +752 -0
  74. package/src/engine/failover.mjs +192 -0
  75. package/src/engine/gate.mjs +183 -0
  76. package/src/engine/judge-gate.mjs +517 -0
  77. package/src/engine/lifecycle.mjs +772 -0
  78. package/src/engine/live-preflight.mjs +299 -0
  79. package/src/engine/mutation.mjs +146 -0
  80. package/src/engine/notify-queue.mjs +327 -0
  81. package/src/engine/process-identity.mjs +72 -0
  82. package/src/engine/process.mjs +774 -0
  83. package/src/engine/prompts.mjs +289 -0
  84. package/src/engine/recover.mjs +300 -0
  85. package/src/engine/result-file.mjs +222 -0
  86. package/src/engine/resume.mjs +635 -0
  87. package/src/engine/retry.mjs +334 -0
  88. package/src/engine/review.mjs +228 -0
  89. package/src/engine/run-command.mjs +287 -0
  90. package/src/engine/run-identity.mjs +411 -0
  91. package/src/engine/runtime-discovery.mjs +235 -0
  92. package/src/engine/scheduler.mjs +526 -0
  93. package/src/engine/scope.mjs +378 -0
  94. package/src/engine/settle.mjs +207 -0
  95. package/src/engine/state.mjs +148 -0
  96. package/src/engine/supervise.mjs +713 -0
  97. package/src/engine/verify.mjs +167 -0
  98. package/src/harnesses/agy/index.mjs +62 -0
  99. package/src/harnesses/catalogue.mjs +509 -0
  100. package/src/harnesses/claude/index.mjs +90 -0
  101. package/src/harnesses/codex/index.mjs +87 -0
  102. package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
  103. package/src/harnesses/dsh/index.mjs +210 -0
  104. package/src/harnesses/dsh/runner.mjs +259 -0
  105. package/src/harnesses/exec-jsonl/index.mjs +788 -0
  106. package/src/harnesses/index.mjs +508 -0
  107. package/src/harnesses/protocol.mjs +531 -0
  108. package/src/harnesses/replay/bin.mjs +386 -0
  109. package/src/harnesses/replay/index.mjs +238 -0
  110. package/src/harnesses/zcode/index.mjs +276 -0
  111. package/src/host/config.mjs +87 -0
  112. package/src/host/home.mjs +149 -0
  113. package/src/host/package.mjs +23 -0
  114. package/src/host/preflight.mjs +520 -0
  115. package/src/host/tool-policy-decisions.mjs +341 -0
  116. package/src/host/tool-policy-hook.mjs +270 -0
  117. package/src/notify/index.mjs +359 -0
  118. package/src/notify/os-macos.mjs +81 -0
  119. package/src/repo/declared-paths.mjs +220 -0
  120. package/src/repo/integrate.mjs +546 -0
  121. package/src/repo/scope-closure.mjs +665 -0
  122. package/src/repo/signal-block.mjs +16 -0
  123. package/src/repo/signal.mjs +222 -0
  124. package/src/repo/source-identity.mjs +295 -0
  125. package/src/repo/workspace.mjs +557 -0
  126. package/src/repo/worktree.mjs +352 -0
  127. package/src/report/final.mjs +200 -0
  128. package/src/report/metrics-report.mjs +99 -0
  129. package/src/report/next.mjs +383 -0
  130. package/src/report/render.mjs +716 -0
  131. package/src/run/disk-gc.mjs +251 -0
  132. package/src/run/lock.mjs +329 -0
  133. package/src/run/node-store.mjs +62 -0
  134. package/src/run/operations.mjs +286 -0
  135. package/src/run/store.mjs +187 -0
  136. package/src/run/usage.mjs +337 -0
  137. package/src/seat/harnesses.mjs +83 -0
  138. package/src/seat/index.mjs +239 -0
  139. package/src/seat/tmux.mjs +208 -0
  140. package/src/util.mjs +0 -0
  141. package/src/web/api.mjs +371 -0
  142. package/src/web/boundary.mjs +88 -0
  143. package/src/web/index.html +299 -0
  144. package/src/web/server.mjs +552 -0
@@ -0,0 +1,716 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import { validateContract } from "../contract/index.mjs";
4
+ import { readJson, writeJsonAtomic } from "../run/store.mjs";
5
+ import { lockStale, pidAlive, readLock } from "../run/lock.mjs";
6
+ import { scopeFindingsNote } from "../contract/scope-findings.mjs";
7
+ import { reviewNote } from "../contract/review-modes.mjs";
8
+ import { validateNodeSnapshot, validateRunMetadata } from "../contract/snapshot.mjs";
9
+ import { compactCost, compactTokens, finite, truncateChars } from "../util.mjs";
10
+ import { listNodeSnapshots, nodeSnapshotPath } from "../run/node-store.mjs";
11
+ import { readHeartbeat } from "../engine/supervise.mjs";
12
+ import { SETTLED, SUCCESS } from "../engine/prompts.mjs";
13
+
14
+ /** Advisory ceiling for status.json (TECH-SPEC lean, rule 5); never enforced destructively. */
15
+ const STATUS_JSON_MAX_BYTES = 200 * 1024;
16
+ const STATUS_POINTER_FILE = "status.json";
17
+ const STATUS_POINTER_MAX_BYTES = 1024;
18
+ const POINTER_STRING_CHARS = 64;
19
+ const POINTER_ATTENTION_CHARS = 80;
20
+
21
+ /** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
22
+ /** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
23
+ /** @typedef {import("../contract/index.mjs").NodeStatus} NodeStatus */
24
+ /** @typedef {Record<string, unknown>} JsonObject */
25
+ /** @typedef {{inputTokens: number|null, outputTokens: number|null, cacheReadInputTokens: number|null}} StatusPayloadUsage */
26
+ /** @typedef {{id: string, status: NodeStatus, phase: string|null, executionPhase: string|null, runtime: string|null, workerRuntime: string|null, continuation: string, attempt: number, revisions: number, startedAt: string|null, updatedAt: string|null, usage: StatusPayloadUsage|null, costUsd: number|null, verdict: string|null, pendingHandoff: {runtime: string, reason: string}|null, note: string|null, scopeFindings: string[]|null, errorCode: string|null, blockedBy: string[]}} StatusPayloadNode */
27
+ /** @typedef {{schemaVersion: 1, run: string, contractId: string, campaignId: string, goal: string, usage: {inputTokens: number, outputTokens: number, cacheReadInputTokens: number, costUsd: number|null}, controller: JsonObject, identityWarnings: string[], summary: string, nodes: StatusPayloadNode[]}} StatusPayload */
28
+
29
+ /** The glyph each terminal state prints in a status table. */
30
+ export const MARK = {
31
+ pending: "[ ]",
32
+ running: "[>]",
33
+ done: "[+]",
34
+ "no-op": "[.]",
35
+ blocked: "[!]",
36
+ failed: "[x]",
37
+ exhausted: "[$]",
38
+ stalled: "[~]",
39
+ canceled: "[/]",
40
+ };
41
+
42
+ /**
43
+ * `status <run-dir>`: everything the operator needs, in the same order as
44
+ * the dashboard page (TECH-SPEC lean, section 4's last paragraph) —
45
+ * needs-you, now, nodes, cost. Every cell comes from the same payload
46
+ * `status --json` and the per-run `status.json` file emit
47
+ * (`buildStatusPayload`); `nodes` and `identityWarnings` from `loadRun` are
48
+ * used only for the two facts the payload does not carry: throwing on an
49
+ * invalid persisted snapshot, and `controllerStatus`'s lock read.
50
+ *
51
+ * @param {string} runDir
52
+ * @returns {string}
53
+ */
54
+ export function renderStatus(runDir) {
55
+ const { contract, nodes, identityWarnings } = loadRun(runDir);
56
+ const usage = readRunUsage(runDir);
57
+ const payload = buildStatusPayload(runDir, contract, nodes, identityWarnings, usage);
58
+ const controller = controllerStatus(runDir, nodes);
59
+ const now = Date.now();
60
+
61
+ const lines = [`# run ${payload.run}`, "", payload.goal, "", `controller: ${controller.line}`, ""];
62
+
63
+ lines.push("## Needs you", "");
64
+ const attention = payload.nodes.filter((node) => !["pending", "running", "done", "no-op"].includes(node.status));
65
+ const orphans = controller.status.state !== "active" ? payload.nodes.filter((node) => node.status === "running").map((node) => node.id) : [];
66
+ if (!attention.length && !orphans.length && !identityWarnings.length) lines.push("Nothing needs you right now.");
67
+ 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.`);
68
+ for (const warning of identityWarnings) lines.push(`- [~] ${warning}`);
69
+ for (const node of attention) lines.push(`- ${MARK[node.status] ?? "[?]"} ${node.id}: ${node.note ?? node.status}`);
70
+ lines.push("", "## Now", "", nowLine(payload, now), "", "## Nodes", "");
71
+
72
+ const widths = [3, 24, 9, 3, 28, 8, 6, 6, 6, 10, 9, MAX_NOTE_LENGTH];
73
+ /** @type {(cells: unknown[]) => string} */
74
+ const row = (cells) => cells.map((cell, i) => fit(String(cell ?? ""), widths[i])).join(" ");
75
+ lines.push("```", row(["", "NODE", "STATE", "TRY", "RUNTIME", "ELAPSED", "IN", "CACHE", "OUT", "USD", "VERDICT", "NOTE"]), row(widths.map((width) => "-".repeat(width))));
76
+ for (const node of payload.nodes) {
77
+ lines.push(row([
78
+ MARK[node.status] ?? "[?]",
79
+ node.id,
80
+ node.status,
81
+ node.attempt ?? 0,
82
+ node.workerRuntime ?? node.runtime ?? "-",
83
+ formatElapsed(node, now),
84
+ compactTokens(node.usage?.inputTokens),
85
+ compactTokens(node.usage?.cacheReadInputTokens),
86
+ compactTokens(node.usage?.outputTokens),
87
+ compactCost(node.costUsd),
88
+ node.verdict ?? "-",
89
+ node.note ?? "-",
90
+ ]));
91
+ }
92
+ lines.push("```", "", "## Cost", "", `in ${compactTokens(usage.inputTokens)} · out ${compactTokens(usage.outputTokens)} · cache ${compactTokens(usage.cacheReadInputTokens)} · cost ${compactCost(usage.costUsd)}`);
93
+ return `${lines.join("\n")}\n`;
94
+ }
95
+
96
+ /**
97
+ * The node the operator should look at right now, formatted the same way
98
+ * the dashboard's now strip is (TECH-SPEC lean, section 4, item 1): the
99
+ * active node's elapsed time and cost so far, or an idle line once every
100
+ * node has settled.
101
+ *
102
+ * @param {StatusPayload} payload
103
+ * @param {number} now epoch ms
104
+ * @returns {string}
105
+ */
106
+ function nowLine(payload, now) {
107
+ const active = activeStatusNode(payload.nodes);
108
+ if (active) return `now: ${active.id} ${active.status} (${formatElapsed(active, now)}) · ${active.runtime ?? "-"} · ${compactCost(active.costUsd)}`;
109
+ const allTerminal = payload.nodes.every((node) => SUCCESS.has(node.status));
110
+ return allTerminal ? `now: idle · run done · ${compactCost(payload.usage.costUsd)}` : "now: idle";
111
+ }
112
+
113
+ /**
114
+ * A node's wall-clock elapsed time: `startedAt` to `updatedAt` once it has
115
+ * settled into a terminal state, `startedAt` to `now` while it is still
116
+ * live (running, blocked or stalled), `-` before it ever started.
117
+ *
118
+ * @param {{status: string, startedAt: string|null, updatedAt: string|null}} node
119
+ * @param {number} now epoch ms
120
+ * @returns {string}
121
+ */
122
+ function formatElapsed(node, now) {
123
+ if (!node.startedAt) return "-";
124
+ const start = Date.parse(node.startedAt);
125
+ if (!Number.isFinite(start)) return "-";
126
+ const terminal = SETTLED.has(node.status);
127
+ const end = terminal && node.updatedAt ? Date.parse(node.updatedAt) : now;
128
+ return formatDuration(Math.max(0, (Number.isFinite(end) ? end : now) - start));
129
+ }
130
+
131
+ /** @param {number} ms @returns {string} */
132
+ function formatDuration(ms) {
133
+ const totalSeconds = Math.floor(ms / 1000);
134
+ const hours = Math.floor(totalSeconds / 3600);
135
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
136
+ const seconds = totalSeconds % 60;
137
+ if (hours > 0) return `${hours}h${String(minutes).padStart(2, "0")}m`;
138
+ if (minutes > 0) return `${minutes}m${String(seconds).padStart(2, "0")}s`;
139
+ return `${seconds}s`;
140
+ }
141
+
142
+ /**
143
+ * JSON status for `status --json`: stable, machine-readable, no rendering.
144
+ *
145
+ * @param {string} runDir
146
+ * @returns {string}
147
+ */
148
+ export function renderStatusJson(runDir) {
149
+ const { contract, nodes, identityWarnings } = loadRun(runDir);
150
+ const usage = readRunUsage(runDir);
151
+ const payload = buildStatusPayload(runDir, contract, nodes, identityWarnings, usage);
152
+ return `${JSON.stringify(payload, null, 2)}\n`;
153
+ }
154
+
155
+ /**
156
+ * The status.json payload shared by the CLI (`status --json`, reading from
157
+ * disk) and the controller's per-tick writer (in-memory node states): every
158
+ * field is a durable fact, never a rendering choice.
159
+ *
160
+ * @param {string} runDir
161
+ * @param {ValidatedContract} contract
162
+ * @param {NodeSnapshot[]} nodes
163
+ * @param {string[]} identityWarnings
164
+ * @param {{inputTokens: number, outputTokens: number, cacheReadInputTokens: number, costUsd: number|null}} usage
165
+ * @returns {StatusPayload}
166
+ */
167
+ function buildStatusPayload(runDir, contract, nodes, identityWarnings, usage) {
168
+ const counts = new Map();
169
+ for (const node of nodes) counts.set(node.status, (counts.get(node.status) ?? 0) + 1);
170
+ return {
171
+ schemaVersion: 1,
172
+ run: basename(runDir),
173
+ contractId: contract.id,
174
+ campaignId: contract.campaignId,
175
+ goal: contract.goal,
176
+ usage: {
177
+ inputTokens: usage.inputTokens,
178
+ outputTokens: usage.outputTokens,
179
+ cacheReadInputTokens: usage.cacheReadInputTokens,
180
+ costUsd: usage.costUsd,
181
+ },
182
+ controller: controllerStatus(runDir, nodes).status,
183
+ identityWarnings,
184
+ summary: [...counts].map(([status, count]) => `${count} ${status}`).join(" · "),
185
+ nodes: nodes.map((node) => ({
186
+ id: node.id,
187
+ status: node.status,
188
+ phase: contract.nodes.find((candidate) => candidate.id === node.id)?.phase ?? null,
189
+ executionPhase: node.phase,
190
+ runtime: node.runtime ? `${node.runtime.harness}/${node.runtime.model}` : null,
191
+ workerRuntime: workerRuntimeLabel(node),
192
+ continuation: continuationMode(node),
193
+ attempt: node.attempt,
194
+ revisions: node.revisions,
195
+ startedAt: node.startedAt ?? null,
196
+ updatedAt: node.updatedAt ?? null,
197
+ usage: node.usage ? { inputTokens: node.usage.inputTokens ?? null, outputTokens: node.usage.outputTokens ?? null, cacheReadInputTokens: node.usage.cacheReadInputTokens ?? null } : null,
198
+ costUsd: typeof node.costUsd === "number" ? node.costUsd : null,
199
+ verdict: node.gate?.verdict ?? null,
200
+ pendingHandoff: pendingHandoff(node),
201
+ note: statusNote(node),
202
+ scopeFindings: node.scopeFindings?.unexpectedPaths ?? null,
203
+ errorCode: node.error?.code ?? null,
204
+ blockedBy: node.blockedBy ?? [],
205
+ })),
206
+ };
207
+ }
208
+
209
+ /**
210
+ * The node the operator should look at right now: the first node that is
211
+ * running, else the first in an attention state, else null. Generic over the
212
+ * node shape so both the raw `NodeSnapshot[]` (`derivePointer`) and the
213
+ * `status.json` payload's nodes (`nowLine`) share this one rule.
214
+ *
215
+ * @template {{status: NodeStatus}} T
216
+ * @param {T[]} nodes
217
+ * @returns {T|null}
218
+ */
219
+ function activeStatusNode(nodes) {
220
+ return nodes.find((node) => node.status === "running")
221
+ ?? nodes.find((node) => !["pending", "running", "done", "no-op"].includes(node.status))
222
+ ?? null;
223
+ }
224
+
225
+ /**
226
+ * The bounded pointer record written to `.runs/status.json`: enough for a
227
+ * quick ambient read (statusline, a stale watcher) without opening the
228
+ * per-run status.json. Bounded the same way heartbeat.json used to be, so a
229
+ * cheap bounded read stays valid for any reader still built that way.
230
+ * `generatedAt` is unix seconds, not ISO: the statusline's no-jq fallback has
231
+ * no clock and only jq's builtin `now` can compute an age from a live clock,
232
+ * and neither path needs a `date` process to read an integer. `elapsedSec`
233
+ * is likewise precomputed here (as of `generatedAt`, not live) so the
234
+ * statusline never has to subtract two timestamps to show it — it just
235
+ * prints the integer, whichever reader it is.
236
+ *
237
+ * @param {JsonObject} payload the per-run status.json payload
238
+ * @param {NodeSnapshot[]} nodes
239
+ * @param {number} generatedAt unix seconds
240
+ * @returns {JsonObject}
241
+ */
242
+ function derivePointer(payload, nodes, generatedAt) {
243
+ const active = activeStatusNode(nodes);
244
+ const done = nodes.filter((node) => node.status === "done" || node.status === "no-op").length;
245
+ const attentionNodes = nodes.filter((node) => !["pending", "running", "done", "no-op"].includes(node.status));
246
+ const attentionNode = attentionNodes[0] ?? null;
247
+ const state = attentionNode ? "attention" : nodes.every((node) => SUCCESS.has(node.status)) ? "done" : "active";
248
+ const activeStartedAt = active?.startedAt ? Math.floor(Date.parse(active.startedAt) / 1000) : null;
249
+ const usage = /** @type {{costUsd: number|null}} */ (payload.usage);
250
+ const pointer = {
251
+ schemaVersion: 1,
252
+ runId: payload.run,
253
+ campaignId: payload.campaignId,
254
+ state,
255
+ checkpoints: { done, total: nodes.length },
256
+ activeNode: active ? truncateChars(active.id, POINTER_STRING_CHARS) : null,
257
+ runtime: active?.runtime ? truncateChars(`${active.runtime.harness}/${active.runtime.model}`, POINTER_STRING_CHARS) : null,
258
+ elapsedSec: activeStartedAt !== null && Number.isFinite(activeStartedAt) ? Math.max(0, generatedAt - activeStartedAt) : null,
259
+ costUsd: typeof usage.costUsd === "number" ? Math.round(usage.costUsd * 100) / 100 : null,
260
+ needsYou: attentionNodes.length,
261
+ attention: attentionNode ? truncateChars(statusNote(attentionNode) ?? attentionNode.status, POINTER_ATTENTION_CHARS) : null,
262
+ generatedAt,
263
+ };
264
+ return pointer;
265
+ }
266
+
267
+ /**
268
+ * Write status.json for one run (bounded advisory ceiling) and the
269
+ * `.runs/status.json` pointer to it (bounded to 1 KiB, mirroring the old
270
+ * heartbeat.json contract), atomically. Called each controller tick and at
271
+ * run terminal (TECH-SPEC lean, rule 5).
272
+ *
273
+ * @param {string} runDir
274
+ * @param {string} runsDir
275
+ * @param {ValidatedContract} contract
276
+ * @param {Map<string, NodeSnapshot>} states
277
+ */
278
+ export function writeStatusArtifacts(runDir, runsDir, contract, states) {
279
+ const nodes = /** @type {NodeSnapshot[]} */ (contract.nodes.map((node) => states.get(node.id)).filter((node) => node !== undefined));
280
+ const runMetadata = /** @type {{identityWarnings?: string[]}} */ (readJson(join(runDir, "run.json")) ?? {});
281
+ const usage = readRunUsage(runDir);
282
+ const payload = buildStatusPayload(runDir, contract, nodes, runMetadata.identityWarnings ?? [], usage);
283
+ const serialized = JSON.stringify(payload);
284
+ if (Buffer.byteLength(serialized, "utf8") > STATUS_JSON_MAX_BYTES) {
285
+ process.stderr.write(`[warn] status.json for ${basename(runDir)} exceeds the ${STATUS_JSON_MAX_BYTES}-byte advisory ceiling\n`);
286
+ }
287
+ writeJsonAtomic(join(runDir, STATUS_POINTER_FILE), payload);
288
+ const pointer = derivePointer(payload, nodes, Math.floor(Date.now() / 1000));
289
+ const pointerSerialized = JSON.stringify(pointer);
290
+ if (Buffer.byteLength(pointerSerialized, "utf8") <= STATUS_POINTER_MAX_BYTES) {
291
+ writeJsonAtomic(join(runsDir, STATUS_POINTER_FILE), pointer);
292
+ } else {
293
+ process.stderr.write(`[warn] .runs/status.json pointer for ${basename(runDir)} exceeds ${STATUS_POINTER_MAX_BYTES} bytes; left unwritten\n`);
294
+ }
295
+ }
296
+
297
+ /**
298
+ * The controller line: `active pid N since T` for a live lock, or
299
+ * `stale pid N (dead|restarted) last tick T` once its holder is proven dead
300
+ * or the pid was recycled — `T` is then the newest node update, since the
301
+ * dead controller's own lock carries no useful clock. No lock at all (a run
302
+ * that never started, or one that shut down cleanly) reports `none`.
303
+ *
304
+ * @param {string} runDir
305
+ * @param {NodeSnapshot[]} nodes
306
+ * @returns {{line: string, status: {state: "active"|"stale"|"none", pid: number|null, since: string|null, lastTick: string|null}}}
307
+ */
308
+ export function controllerStatus(runDir, nodes) {
309
+ const lock = readLock(runDir);
310
+ // The heartbeat's `at` is the live tick, so a working controller and a dead
311
+ // one are distinguishable on disk. Before this it was hard-coded null here
312
+ // and computed from node updates only once the lock was already stale, which
313
+ // is why all 47 recorded status.json files reported a null lastTick.
314
+ const heartbeat = readHeartbeat(runDir);
315
+ const lastTick = typeof heartbeat?.at === "string" ? heartbeat.at : null;
316
+ if (!lock || /** @type {{invalid?: true}} */ (lock).invalid) {
317
+ return { line: "none", status: { state: "none", pid: null, since: null, lastTick } };
318
+ }
319
+ const record = /** @type {import("../run/lock.mjs").LockRecord} */ (lock);
320
+ if (!lockStale(record)) {
321
+ return {
322
+ line: `active pid ${record.pid} since ${record.startedAt}`,
323
+ status: { state: "active", pid: record.pid, since: record.startedAt, lastTick },
324
+ };
325
+ }
326
+ const staleTick = lastTick ?? (nodes.reduce((latest, node) => (node.updatedAt && node.updatedAt > latest ? node.updatedAt : latest), "") || null);
327
+ const reason = pidAlive(record.pid) ? "restarted" : "dead";
328
+ return {
329
+ line: `stale pid ${record.pid} (${reason}) last tick ${staleTick ?? "-"}`,
330
+ status: { state: "stale", pid: record.pid, since: record.startedAt, lastTick: staleTick },
331
+ };
332
+ }
333
+
334
+ /**
335
+ * @param {string} runDir
336
+ * @returns {string}
337
+ */
338
+ export function renderReport(runDir) {
339
+ const { contract, nodes } = loadRun(runDir);
340
+ const usage = readRunUsage(runDir);
341
+ const counts = new Map();
342
+ for (const node of nodes) counts.set(node.status, (counts.get(node.status) ?? 0) + 1);
343
+ const summary = [...counts].map(([status, count]) => `${count} ${status}`).join(" · ");
344
+ const widths = [3, 24, 9, 7, 7, 28, 10, 10, 10, 20, 64];
345
+ /** @type {(cells: unknown[]) => string} */
346
+ const row = (cells) => cells.map((cell, i) => fit(String(cell ?? ""), widths[i])).join(" ");
347
+ /** @type {import("../contract/index.mjs").Usage & {costUsd: number|null}} */
348
+ const totals = { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, costUsd: null };
349
+ const costs = nodes.map(costProjection);
350
+ const aggregateCost = aggregateCostProjection(costs);
351
+ 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)))];
352
+ for (const [index, node] of nodes.entries()) {
353
+ const usage = node.usage ?? { inputTokens: null, outputTokens: null, cacheReadInputTokens: null };
354
+ for (const key of /** @type {("inputTokens"|"outputTokens"|"cacheReadInputTokens")[]} */ (Object.keys(totals).filter((key) => key !== "costUsd"))) totals[key] = (totals[key] ?? 0) + (usage[key] ?? 0);
355
+ const cost = costs[index];
356
+ const runtime = workerRuntimeLabel(node) ?? "-";
357
+ const planNode = contract.nodes.find((candidate) => candidate.id === node.id);
358
+ const note = scopeFindingsNote(node.scopeFindings)
359
+ ? nodeNote(node)
360
+ : `phase ${planNode?.phase ?? "-"} · ${continuationMode(node)} · ${nodeNote(node)}`;
361
+ lines.push(row([MARK[node.status] ?? "[?]", node.id, node.status, node.attempt ?? 0, node.revisions ?? 0, runtime, compactTokens(usage.inputTokens), compactTokens(usage.outputTokens), compactTokens(usage.cacheReadInputTokens), formatCost(cost), note]));
362
+ }
363
+ totals.costUsd = aggregateCost.costUsd;
364
+ const roles = roleCosts(nodes);
365
+ lines.push("```", "", `totals · in ${compactTokens(totals.inputTokens)} · out ${compactTokens(totals.outputTokens)} · cache ${compactTokens(totals.cacheReadInputTokens)} · worker ${compactCost(roles.worker)} · judge ${compactCost(roles.judge)} · cost ${formatCost(aggregateCost)}`);
366
+ return `${lines.join("\n")}\n`;
367
+ }
368
+
369
+ /**
370
+ * JSON report for `report --json`: totals and per-node usage, no rendering.
371
+ *
372
+ * @param {string} runDir
373
+ * @returns {string}
374
+ */
375
+ export function renderReportJson(runDir) {
376
+ const { contract, nodes } = loadRun(runDir);
377
+ const counts = new Map();
378
+ for (const node of nodes) counts.set(node.status, (counts.get(node.status) ?? 0) + 1);
379
+ /** @type {{inputTokens: number, outputTokens: number, cacheReadInputTokens: number, costUsd: number|null, costStatus: string, workerCostUsd: number|null, judgeCostUsd: number|null}} */
380
+ const totals = { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, costUsd: null, costStatus: "ambiguous", workerCostUsd: null, judgeCostUsd: null };
381
+ const costs = nodes.map(costProjection);
382
+ const listed = nodes.map((node, index) => {
383
+ const usage = node.usage ?? { inputTokens: null, outputTokens: null, cacheReadInputTokens: null };
384
+ for (const key of /** @type {("inputTokens"|"outputTokens"|"cacheReadInputTokens")[]} */ (["inputTokens", "outputTokens", "cacheReadInputTokens"])) totals[key] = (totals[key] ?? 0) + (usage[key] ?? 0);
385
+ const cost = costs[index];
386
+ return {
387
+ id: node.id,
388
+ status: node.status,
389
+ phase: contract.nodes.find((candidate) => candidate.id === node.id)?.phase ?? null,
390
+ executionPhase: node.phase,
391
+ runtime: node.runtime ? `${node.runtime.harness}/${node.runtime.model}` : null,
392
+ attempt: node.attempt,
393
+ revisions: node.revisions,
394
+ usage,
395
+ costUsd: cost.costUsd,
396
+ costStatus: cost.status,
397
+ continuation: continuationMode(node),
398
+ note: nodeNote(node),
399
+ };
400
+ });
401
+ const aggregateCost = aggregateCostProjection(costs);
402
+ totals.costUsd = aggregateCost.costUsd;
403
+ totals.costStatus = aggregateCost.status;
404
+ const roles = roleCosts(nodes);
405
+ totals.workerCostUsd = roles.worker;
406
+ totals.judgeCostUsd = roles.judge;
407
+ const payload = {
408
+ schemaVersion: 1,
409
+ run: basename(runDir),
410
+ contractId: contract.id,
411
+ campaignId: contract.campaignId,
412
+ summary: [...counts].map(([status, count]) => `${count} ${status}`).join(" · "),
413
+ totals,
414
+ nodes: listed,
415
+ };
416
+ return `${JSON.stringify(payload, null, 2)}\n`;
417
+ }
418
+
419
+ /**
420
+ * @param {string} runDir
421
+ * @returns {string}
422
+ */
423
+ export function renderFindings(runDir) {
424
+ const { nodes } = loadRun(runDir);
425
+ const sections = [];
426
+ for (const node of nodes) {
427
+ const gate = node.gate;
428
+ if (node.status === "exhausted" && gate?.findings?.length) {
429
+ const listed = gate.findings.map((finding) => `- [${finding.severity}] ${finding.description}\n Evidence: ${finding.evidence}`).join("\n");
430
+ sections.push(`## ${node.id}\n\nGate verdict: ${gate.verdict} (${gate.maxSeverity}). ${gate.summary}\n\n${listed}`);
431
+ continue;
432
+ }
433
+ const question = blockedContextQuestion(node);
434
+ if (question) sections.push(question);
435
+ }
436
+ return sections.length ? `${sections.join("\n\n")}\n` : "no findings or blocking questions to act on\n";
437
+ }
438
+
439
+ /**
440
+ * A node the worker itself stopped on, rendered as the repair it asks for.
441
+ *
442
+ * `findings` used to answer only gate exhaustion, and a run whose nodes all
443
+ * stopped on `blocked_context` reported nothing to act on while the workers
444
+ * had each named exactly what they needed — measured on a four-node campaign
445
+ * where three nodes were blocked and the command printed one line saying so.
446
+ * The question is already structured, so the repair is mechanical: put the
447
+ * named paths in the packet's `readFiles` and take a new run id.
448
+ *
449
+ * @param {NodeSnapshot} node
450
+ * @returns {string|null}
451
+ */
452
+ function blockedContextQuestion(node) {
453
+ if (node.status !== "blocked") return null;
454
+ const result = node.result && typeof node.result === "object" ? /** @type {{status?: unknown, summary?: unknown, missingContext?: unknown}} */ (node.result) : null;
455
+ if (result?.status !== "blocked_context") return null;
456
+ const missing = Array.isArray(result.missingContext) ? result.missingContext.filter((entry) => typeof entry === "string") : [];
457
+ const asked = missing.length ? missing.map((entry) => `- ${entry}`).join("\n") : "- (the worker named nothing specific)";
458
+ const summary = typeof result.summary === "string" ? result.summary : "the worker stopped on missing context";
459
+ return `## ${node.id}\n\nBlocked on context, attempt ${node.attempt ?? 0}. ${summary}\n\nThe worker asked for:\n${asked}`;
460
+ }
461
+
462
+ /**
463
+ * @param {string} runDir
464
+ * @returns {{contract: ValidatedContract, nodes: NodeSnapshot[], identityWarnings: string[]}}
465
+ */
466
+ function loadRun(runDir) {
467
+ const contractPath = join(runDir, "contract.json");
468
+ const contract = validateContract(/** @type {import("../contract/index.mjs").JsonObject} */ (JSON.parse(readFileSync(contractPath, "utf8"))), contractPath, { persisted: true });
469
+ const metadata = validateRunMetadata(readJson(join(runDir, "run.json")));
470
+ return { contract, nodes: readNodes(runDir, contract), identityWarnings: metadata.identityWarnings ?? [] };
471
+ }
472
+
473
+ /**
474
+ * @param {string} runDir
475
+ * @param {ValidatedContract} contract
476
+ * @returns {NodeSnapshot[]}
477
+ */
478
+ function readNodes(runDir, contract) {
479
+ const names = listNodeSnapshots(runDir);
480
+ const expected = new Map(contract.nodes.map((node) => [`${node.id}.json`, node]));
481
+ for (const name of names) if (!expected.has(name)) throw new TypeError(`unexpected persisted node snapshot ${name}`);
482
+ return contract.nodes.map((node) => {
483
+ const name = `${node.id}.json`;
484
+ if (!names.includes(name)) throw new TypeError(`missing persisted node snapshot ${name}`);
485
+ return validateNodeSnapshot(/** @type {import("../contract/index.mjs").JsonObject} */ (JSON.parse(readFileSync(nodeSnapshotPath(runDir, node.id), "utf8"))), node);
486
+ });
487
+ }
488
+
489
+ /**
490
+ * Tokens by kind and cost across the run's usage.jsonl records. Missing or
491
+ * unparsable lines are skipped; a missing file yields zero totals.
492
+ *
493
+ * @param {string} runDir
494
+ * @returns {{inputTokens: number, outputTokens: number, cacheReadInputTokens: number, costUsd: number|null}}
495
+ */
496
+ function readRunUsage(runDir) {
497
+ const totals = { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, costUsd: /** @type {number|null} */ (null) };
498
+ const path = join(runDir, "usage.jsonl");
499
+ if (!existsSync(path)) return totals;
500
+ for (const line of readFileSync(path, "utf8").split("\n")) {
501
+ if (!line.trim()) continue;
502
+ let record;
503
+ try { record = JSON.parse(line); } catch { continue; }
504
+ if (!record || typeof record !== "object" || Array.isArray(record)) continue;
505
+ const value = /** @type {Record<string, unknown>} */ (record);
506
+ if (typeof value.inputTokens === "number") totals.inputTokens += value.inputTokens;
507
+ if (typeof value.outputTokens === "number") totals.outputTokens += value.outputTokens;
508
+ if (typeof value.cacheReadInputTokens === "number") totals.cacheReadInputTokens += value.cacheReadInputTokens;
509
+ if (typeof value.costUsd === "number") totals.costUsd = (totals.costUsd ?? 0) + value.costUsd;
510
+ }
511
+ return totals;
512
+ }
513
+
514
+ /** @param {NodeSnapshot} node @returns {string} */
515
+ function continuationMode(node) {
516
+ return node.invocations?.at(-1)?.continuationMode ?? "fresh";
517
+ }
518
+
519
+ /**
520
+ * Who produced this node's work, for the RUNTIME column of both tables.
521
+ *
522
+ * `state.runtime` is the last runtime *dispatched*, and the judge dispatch
523
+ * overwrites the worker's: every gated node that reached its gate therefore
524
+ * reported its judge as the runtime, and a six-node campaign whose workers
525
+ * were five different harnesses rendered as though three of them had never
526
+ * run (measured 2026-09-13). The invocation ledger keeps both roles, so the
527
+ * label is derived from it: the worker that ran, falling back to the live
528
+ * runtime for a node that has not dispatched one yet. The judge is not lost —
529
+ * it owns the VERDICT column, and `nowLine` still names whatever is running.
530
+ *
531
+ * @param {NodeSnapshot} node
532
+ * @returns {string|null}
533
+ */
534
+ function workerRuntimeLabel(node) {
535
+ const worker = [...(node.invocations ?? [])].reverse().find((invocation) => invocation.role === "worker");
536
+ if (worker?.harness && worker.model) return `${worker.harness}/${worker.model}`;
537
+ return node.runtime ? `${node.runtime.harness}/${node.runtime.model}` : null;
538
+ }
539
+
540
+ /**
541
+ * A worker routing override waiting to be consumed by the node's next attempt —
542
+ * set by `handoff` (manual) or provider failover.
543
+ *
544
+ * @param {NodeSnapshot} node
545
+ * @returns {{runtime: string, reason: string}|null}
546
+ */
547
+ function pendingHandoff(node) {
548
+ const override = node.routing?.currentOverride;
549
+ if (override?.role !== "worker") return null;
550
+ return { runtime: override.runtime, reason: override.reason };
551
+ }
552
+
553
+ /** Segments of a node note are joined by this separator. */
554
+ const NOTE_SEPARATOR = " · ";
555
+
556
+ /**
557
+ * The longest note a status surface shows. It is the width the status tables
558
+ * render, so a bounded note never has to be cut again on its way into a cell
559
+ * and the JSON carries exactly the string the tables do.
560
+ */
561
+ export const MAX_NOTE_LENGTH = 64;
562
+
563
+ /**
564
+ * Joins the note segments into one note bounded to `maxLength`, cutting the
565
+ * trailing segment first: the leading scope and review markers are what the
566
+ * operator and the campaign match on, so a long gate summary or error is the
567
+ * part that yields, and every surface shows the same bounded string.
568
+ *
569
+ * @param {(string|null|undefined)[]} segments
570
+ * @param {number} [maxLength]
571
+ * @returns {string|null}
572
+ */
573
+ function boundedNote(segments, maxLength = MAX_NOTE_LENGTH) {
574
+ const parts = segments.filter(Boolean);
575
+ if (!parts.length) return null;
576
+ const note = parts.join(NOTE_SEPARATOR);
577
+ if (note.length <= maxLength) return note;
578
+ const head = parts.slice(0, -1).join(NOTE_SEPARATOR);
579
+ const tail = /** @type {string} */ (parts.at(-1));
580
+ const room = maxLength - (head ? head.length + NOTE_SEPARATOR.length : 0);
581
+ const cut = `${tail.slice(0, Math.max(0, room - 1))}…`;
582
+ if (head && cut.length > 1) return `${head}${NOTE_SEPARATOR}${cut}`;
583
+ return `${note.slice(0, maxLength - 1)}…`;
584
+ }
585
+
586
+ /**
587
+ * The note a status surface shows for a node: gate summary or error, led by
588
+ * any advisory scope finding and by the review outcome, so a gated done node
589
+ * cannot hide an advisory finding or an invalid verdict behind its gate
590
+ * summary (TECH-SPEC lean, rules 1 and 2). The order is the stable format
591
+ * every surface shares: `scope: N unexpected paths · <review note> · <gate
592
+ * summary or error>`, bounded so the tables and the JSON cannot disagree.
593
+ *
594
+ * @param {NodeSnapshot} node
595
+ * @returns {string|null}
596
+ */
597
+ export function statusNote(node) {
598
+ const scope = scopeFindingsNote(node.scopeFindings);
599
+ const review = reviewNote(node);
600
+ const detail = node.gate?.summary ?? node.error?.message ?? node.blockedBy?.join(", ") ?? node.phase;
601
+ const note = boundedNote([review, detail]);
602
+ if (!scope) return note;
603
+ return boundedNote([scope, note]);
604
+ }
605
+
606
+ /**
607
+ * A scope finding is advisory, so the node keeps its own note; the finding
608
+ * still has to stay visible on a gated node, where the gate summary would
609
+ * otherwise be the whole note (TECH-SPEC lean, rule 1).
610
+ *
611
+ * @param {NodeSnapshot} node
612
+ * @returns {string}
613
+ */
614
+ function nodeNote(node) {
615
+ const note = statusNote(node);
616
+ if (note) return note;
617
+ if (typeof node.result === "string" && node.result.trim()) return node.result.trim();
618
+ return node.phase ?? "-";
619
+ }
620
+
621
+ /** @typedef {{costUsd: number|null, status: "known"|"estimated"|"ambiguous"}} CostProjection */
622
+
623
+ /**
624
+ * Per-role cost, summed from the invocation ledger alone. A role that appears
625
+ * only on invocations that all carry a provider cost is `known`; a role with no
626
+ * invocation at all, or with any invocation whose cost is missing, is `null`.
627
+ * That is deliberately not zero: an unavailable or partial role cost must not
628
+ * fabricate `$0`, and summing only the known invocations would understate a
629
+ * partial one. The invocation ledger is the single source, so the node's own
630
+ * `costUsd` (itself the sum of these invocations) is never added on top and
631
+ * cannot double-count.
632
+ *
633
+ * @param {NodeSnapshot[]} nodes
634
+ * @returns {{worker: number|null, judge: number|null}}
635
+ */
636
+ export function roleCosts(nodes) {
637
+ /** @type {Record<"worker"|"judge", {total: number, present: number, unknown: number}>} */
638
+ const roles = {
639
+ worker: { total: 0, present: 0, unknown: 0 },
640
+ judge: { total: 0, present: 0, unknown: 0 },
641
+ };
642
+ for (const node of nodes) {
643
+ for (const invocation of node.invocations ?? []) {
644
+ if (invocation.role !== "worker" && invocation.role !== "judge") continue;
645
+ const bucket = roles[invocation.role];
646
+ const cost = finite(invocation.costUsd);
647
+ if (cost === null) bucket.unknown += 1;
648
+ else {
649
+ bucket.present += 1;
650
+ bucket.total += cost;
651
+ }
652
+ }
653
+ }
654
+ const complete = (/** @type {{present: number, unknown: number, total: number}} */ bucket) =>
655
+ bucket.present > 0 && bucket.unknown === 0 ? bucket.total : null;
656
+ return { worker: complete(roles.worker), judge: complete(roles.judge) };
657
+ }
658
+
659
+ /**
660
+ * Project cost only from durable snapshot evidence. Invocation costs are
661
+ * provider-reported; a standalone node total has no provider attribution and
662
+ * remains an estimate. Missing or mismatched evidence is ambiguous.
663
+ *
664
+ * @param {NodeSnapshot} node
665
+ * @returns {CostProjection}
666
+ */
667
+ function costProjection(node) {
668
+ const nodeCost = finite(node.costUsd);
669
+ const invocations = Array.isArray(node.invocations) ? node.invocations : [];
670
+ const invocationCosts = invocations.map((invocation) => finite(invocation.costUsd));
671
+
672
+ if (invocations.length > 0) {
673
+ if (!invocationCosts.every((cost) => cost !== null)) return { costUsd: null, status: "ambiguous" };
674
+ const reportedCost = invocationCosts.reduce((total, cost) => total + /** @type {number} */ (cost), 0);
675
+ if (nodeCost !== null && !sameCost(nodeCost, reportedCost)) return { costUsd: null, status: "ambiguous" };
676
+ return { costUsd: nodeCost ?? reportedCost, status: "known" };
677
+ }
678
+
679
+ if (nodeCost !== null) return { costUsd: nodeCost, status: "estimated" };
680
+ return { costUsd: null, status: "ambiguous" };
681
+ }
682
+
683
+ /** @param {CostProjection[]} costs @returns {CostProjection} */
684
+ function aggregateCostProjection(costs) {
685
+ if (costs.length === 0 || costs.some((cost) => cost.status === "ambiguous")) {
686
+ return { costUsd: null, status: "ambiguous" };
687
+ }
688
+ const costUsd = costs.every((cost) => typeof cost.costUsd === "number")
689
+ ? costs.reduce((total, cost) => total + /** @type {number} */ (cost.costUsd), 0)
690
+ : null;
691
+ return {
692
+ costUsd,
693
+ status: costs.some((cost) => cost.status === "estimated") ? "estimated" : "known",
694
+ };
695
+ }
696
+
697
+ /** @param {number} left @param {number} right @returns {boolean} */
698
+ function sameCost(left, right) {
699
+ return Math.abs(left - right) <= 1e-9;
700
+ }
701
+
702
+ /** @param {CostProjection} projection @returns {string} */
703
+ function formatCost(projection) {
704
+ return `${compactCost(projection.costUsd)} (${projection.status})`;
705
+ }
706
+
707
+ /**
708
+ * @param {string} value
709
+ * @param {number} width
710
+ * @returns {string}
711
+ */
712
+ export function fit(value, width) {
713
+ const clean = value.replace(/[\u0000-\u001f\u007f]+/gu, " ").replace(/\s+/gu, " ").trim();
714
+ if (clean.length <= width) return clean + " ".repeat(width - clean.length);
715
+ return `${clean.slice(0, Math.max(0, width - 2))}..`.padEnd(width, " ");
716
+ }