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,788 @@
1
+ /**
2
+ * Generic exec-jsonl adapter protocol.
3
+ *
4
+ * The executable receives one UTF-8 JSON line on stdin:
5
+ * `{schemaVersion:1,type:"run.request",model,prompt,structuredOutput,
6
+ * outputSchema,continuationId}`. The request deliberately carries no tool
7
+ * policy: an
8
+ * arbitrary wrapper executable cannot prove enforcement, so the mechanical
9
+ * policy travels only where a hook surface can enforce it (claude). It
10
+ * writes JSONL events to stdout:
11
+ * `run.started` (optional), `message` (zero or more), then exactly one
12
+ * `run.completed` or `run.failed` event. Events must appear in that order,
13
+ * with no unknown fields. A completed event is
14
+ * `{schemaVersion:1,type:"run.completed",result,continuationId,usage,costUsd}`;
15
+ * `result` is required and may be any JSON value. A failed event is
16
+ * `{schemaVersion:1,type:"run.failed",error:{code,message}}`.
17
+ *
18
+ * Stderr is diagnostic only. Unknown or malformed output is rejected by the
19
+ * runtime normalizer; wrappers should emit this protocol rather than making
20
+ * scheduler-specific provider branches.
21
+ */
22
+
23
+ import {
24
+ canonicalUsage,
25
+ eventItem,
26
+ extractJson,
27
+ failed,
28
+ parseJsonLines,
29
+ parseVersion,
30
+ } from "../protocol.mjs";
31
+ import { rejectUnknown } from "../../contract/assert.mjs";
32
+ import { finite } from "../../util.mjs";
33
+
34
+ export { HARNESS_OUTPUT_LIMIT_BYTES } from "../protocol.mjs";
35
+
36
+ export const EXEC_JSONL_PROTOCOL = Object.freeze({
37
+ schemaVersion: 1,
38
+ requestType: "run.request",
39
+ completedType: "run.completed",
40
+ failedType: "run.failed",
41
+ });
42
+
43
+ /** Exact tool-output bound (UTF-8 bytes) carried by the toolPolicy contract. */
44
+ export const TOOL_OUTPUT_LIMIT_BYTES = 8192;
45
+
46
+ const EVENT_FIELDS = Object.freeze({
47
+ "run.started": new Set(["schemaVersion", "type", "continuationId"]),
48
+ message: new Set(["schemaVersion", "type", "text"]),
49
+ "run.completed": new Set(["schemaVersion", "type", "result", "continuationId", "usage", "costUsd"]),
50
+ "run.failed": new Set(["schemaVersion", "type", "error"]),
51
+ });
52
+
53
+ const EVENT_TYPES = new Set(Object.keys(EVENT_FIELDS));
54
+
55
+ /** @typedef {import("../index.mjs").HarnessAdapter} HarnessAdapter */
56
+
57
+ /**
58
+ * @type {HarnessAdapter}
59
+ */
60
+ export const execJsonlHarness = {
61
+ capabilities: {
62
+ structuredOutput: true,
63
+ promptTransport: "stdin",
64
+ sandbox: false,
65
+ permissions: false,
66
+ continuation: true,
67
+ tokenBudget: true,
68
+ costBudget: false,
69
+ usage: true,
70
+ cost: true,
71
+ // An arbitrary wrapper executable cannot honestly advertise mechanical
72
+ // tool-policy enforcement; the request carries none.
73
+ toolPolicy: false,
74
+ // The protocol allows zero `message` events before the terminal one, so
75
+ // an arbitrary wrapper cannot honestly advertise incremental output either.
76
+ streamsOutput: false,
77
+ },
78
+
79
+ // The wrapper protocol exposes no permission mode.
80
+ permissionExecution: null,
81
+
82
+ /** @param {import("../index.mjs").HarnessRuntime} runtime @returns {string} */
83
+ executable(runtime) {
84
+ return process.env.FABERUN_EXEC_JSONL_BIN ?? runtime.executable ?? "exec-jsonl";
85
+ },
86
+
87
+ /** @param {import("../index.mjs").HarnessRuntime} runtime @returns {string[]} */
88
+ versionArgs(runtime) {
89
+ return runtime.versionArgs ?? ["--version"];
90
+ },
91
+
92
+ parseVersion,
93
+
94
+ /** @param {import("../index.mjs").HarnessRuntime} runtime @param {string} prompt @param {import("../index.mjs").CommandOptions} options @returns {import("../index.mjs").HarnessCommand} */
95
+ command(runtime, prompt, options) {
96
+ const request = {
97
+ schemaVersion: 1,
98
+ type: "run.request",
99
+ model: runtime.model,
100
+ prompt,
101
+ structuredOutput: Boolean(options.schema || options.schemaPath),
102
+ outputSchema: options.schema ?? options.schemaPath ?? null,
103
+ continuationId: options.continuationId ?? null,
104
+ };
105
+ const args = runtime.args ?? [];
106
+ return {
107
+ executable: this.executable(runtime),
108
+ args: [...args],
109
+ promptTransport: "stdin",
110
+ input: `${JSON.stringify(request)}\n`,
111
+ };
112
+ },
113
+
114
+ normalize: normalizeExecJsonlResult,
115
+ };
116
+
117
+ export const harness = execJsonlHarness;
118
+ export default execJsonlHarness;
119
+
120
+ /**
121
+ * @param {string} stdout
122
+ * @param {number|null} exitCode
123
+ * @param {string|null} signal
124
+ * @returns {import("../index.mjs").ProviderEnvelope}
125
+ */
126
+ export function normalizeExecJsonlResult(stdout, exitCode, signal) {
127
+ if (signal) return failed("canceled", `provider ended after ${signal}`, "canceled");
128
+ let events;
129
+ try {
130
+ events = parseJsonLines(stdout, "exec-jsonl");
131
+ validateExecJsonlEvents(events);
132
+ } catch (error) {
133
+ return failed("invalid_protocol", error instanceof Error ? error.message : String(error));
134
+ }
135
+ const lastEvent = events.at(-1);
136
+ if (!lastEvent) return failed("invalid_protocol", "exec-jsonl emitted no events");
137
+ const terminal = /** @type {Record<string, unknown>} */ (lastEvent);
138
+ if (terminal.type === "run.failed") {
139
+ const error = /** @type {Record<string, unknown>|undefined} */ (terminal.error);
140
+ return failed(
141
+ typeof error?.code === "string" ? error.code : "provider_error",
142
+ typeof error?.message === "string" ? error.message : "exec-jsonl failed",
143
+ );
144
+ }
145
+ if (exitCode !== 0) return failed("provider_error", `exec-jsonl exited with code ${exitCode}`);
146
+ const rawResult = terminal.result;
147
+ const result = typeof rawResult === "string" ? rawResult : JSON.stringify(rawResult);
148
+ return {
149
+ status: result.trim() ? "done" : "no-op",
150
+ result,
151
+ continuationId: typeof terminal.continuationId === "string" ? terminal.continuationId : null,
152
+ usage: canonicalUsage(terminal.usage),
153
+ costUsd: finite(terminal.costUsd),
154
+ error: null,
155
+ };
156
+ }
157
+
158
+ /**
159
+ * @param {Record<string, unknown>[]} events
160
+ */
161
+ function validateExecJsonlEvents(events) {
162
+ if (!events.length) throw new TypeError("exec-jsonl emitted no events");
163
+ let terminalCount = 0;
164
+ let phase = "start";
165
+ for (const [index, event] of events.entries()) {
166
+ if (!event || typeof event !== "object" || Array.isArray(event)) {
167
+ throw new TypeError(`exec-jsonl event ${index + 1} must be an object`);
168
+ }
169
+ if (event.schemaVersion !== 1) {
170
+ throw new TypeError(`exec-jsonl event ${index + 1} schemaVersion must be 1`);
171
+ }
172
+ const type = /** @type {keyof typeof EVENT_FIELDS} */ (event.type);
173
+ if (!EVENT_TYPES.has(type)) {
174
+ throw new TypeError(`exec-jsonl event ${index + 1} type is unknown`);
175
+ }
176
+ rejectUnknown(event, EVENT_FIELDS[type], `exec-jsonl event ${index + 1}`);
177
+ if (type === "run.started") {
178
+ if (phase !== "start") throw new TypeError("exec-jsonl run.started must be the first event");
179
+ phase = "messages";
180
+ validateContinuationId(event.continuationId, `exec-jsonl event ${index + 1}.continuationId`);
181
+ continue;
182
+ }
183
+ if (type === "message") {
184
+ if (phase === "terminal") throw new TypeError("exec-jsonl message cannot follow a terminal event");
185
+ phase = "messages";
186
+ if (typeof event.text !== "string") throw new TypeError(`exec-jsonl event ${index + 1}.text must be a string`);
187
+ continue;
188
+ }
189
+ if (phase === "terminal") throw new TypeError("exec-jsonl emitted multiple terminal events");
190
+ phase = "terminal";
191
+ terminalCount += 1;
192
+ if (event.type === "run.completed") {
193
+ if (!Object.hasOwn(event, "result")) throw new TypeError("exec-jsonl run.completed.result is required");
194
+ validateContinuationId(event.continuationId, `exec-jsonl event ${index + 1}.continuationId`);
195
+ validateUsage(event.usage, `exec-jsonl event ${index + 1}.usage`);
196
+ validateCost(event.costUsd, `exec-jsonl event ${index + 1}.costUsd`);
197
+ } else {
198
+ validateError(event.error, `exec-jsonl event ${index + 1}.error`);
199
+ }
200
+ if (index !== events.length - 1) {
201
+ if (events.slice(index + 1).some((next) => next?.type === "run.completed" || next?.type === "run.failed")) {
202
+ throw new TypeError("exec-jsonl emitted multiple terminal events");
203
+ }
204
+ throw new TypeError("exec-jsonl terminal event must be last");
205
+ }
206
+ }
207
+ if (terminalCount !== 1) throw new TypeError("exec-jsonl requires exactly one terminal event");
208
+ }
209
+
210
+ /**
211
+ * @param {unknown} value
212
+ * @param {string} label
213
+ */
214
+ function validateContinuationId(value, label) {
215
+ if (value !== undefined && value !== null && typeof value !== "string") {
216
+ throw new TypeError(`${label} must be a string or null`);
217
+ }
218
+ }
219
+
220
+ /**
221
+ * @param {unknown} value
222
+ * @param {string} label
223
+ */
224
+ function validateUsage(value, label) {
225
+ if (value === undefined) return;
226
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new TypeError(`${label} must be an object`);
227
+ const record = /** @type {Record<string, unknown>} */ (value);
228
+ const allowed = new Set(["inputTokens", "outputTokens", "cacheReadInputTokens"]);
229
+ rejectUnknown(record, allowed, label);
230
+ for (const key of allowed) {
231
+ const raw = record[key];
232
+ if (raw !== undefined && raw !== null && (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0)) {
233
+ throw new TypeError(`${label}.${key} must be a non-negative integer or null`);
234
+ }
235
+ }
236
+ }
237
+
238
+ /**
239
+ * @param {unknown} value
240
+ * @param {string} label
241
+ */
242
+ function validateCost(value, label) {
243
+ if (value !== undefined && value !== null && (typeof value !== "number" || !Number.isFinite(value) || value < 0)) {
244
+ throw new TypeError(`${label} must be a non-negative number or null`);
245
+ }
246
+ }
247
+
248
+ /**
249
+ * @param {unknown} value
250
+ * @param {string} label
251
+ */
252
+ function validateError(value, label) {
253
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new TypeError(`${label} must be an object`);
254
+ const record = /** @type {Record<string, unknown>} */ (value);
255
+ rejectUnknown(record, new Set(["code", "message"]), label);
256
+ if (typeof record.code !== "string" || !record.code.trim()) throw new TypeError(`${label}.code must be a non-empty string`);
257
+ if (typeof record.message !== "string" || !record.message.trim()) throw new TypeError(`${label}.message must be a non-empty string`);
258
+ }
259
+
260
+ /**
261
+ * Best-effort input-token meter over a still-growing transcript. The
262
+ * controller never owns the provider stream (the gate writes stdout straight
263
+ * to the log fd), so budget enforcement polls this instead. Lenient by
264
+ * design: unparsable or partial lines count as zero, and providers that only
265
+ * report usage at completion (agy, exec-jsonl, replay) meter as 0 mid-run.
266
+ *
267
+ * @param {string} harness
268
+ * @param {string} stdout bounded transcript tail
269
+ * @returns {{inputTokens: number|null, cacheReadInputTokens: number|null}}
270
+ */
271
+ export function liveUsage(harness, stdout) {
272
+ if (harness === "exec-jsonl" || harness === "replay") {
273
+ // Completion-only harnesses: usage arrives in the terminal envelope, which
274
+ // the close path normalizes, never in a mid-run live observation.
275
+ return { inputTokens: null, cacheReadInputTokens: null };
276
+ }
277
+ const events = parsedEvents(stdout);
278
+ if (harness === "codex") {
279
+ // turn.completed usage is cumulative for the session; the last one wins.
280
+ // Codex counts input_tokens with their cached portion included, so the
281
+ // uncached total is what the ledger calls `inputTokens`.
282
+ const records = events
283
+ .filter((event) => event?.type === "turn.completed" && event.usage && typeof event.usage === "object")
284
+ .map((event) => {
285
+ const rawInput = finite(event.usage.input_tokens ?? event.usage.inputTokens);
286
+ if (rawInput === null) return null;
287
+ const cacheReadInputTokens = finite(
288
+ event.usage.cached_input_tokens ?? event.usage.cacheReadInputTokens ?? event.usage.cache_read_tokens,
289
+ ) ?? 0;
290
+ return { inputTokens: Math.max(0, rawInput - cacheReadInputTokens), cacheReadInputTokens };
291
+ })
292
+ .filter((record) => record !== null);
293
+ if (!records.length) return { inputTokens: null, cacheReadInputTokens: null };
294
+ return records.reduce((best, record) => (
295
+ record.inputTokens + record.cacheReadInputTokens > best.inputTokens + best.cacheReadInputTokens ? record : best
296
+ ));
297
+ }
298
+ if (harness === "claude") {
299
+ // The terminal result event carries the session total; before it lands,
300
+ // sum per-request assistant usage (each request re-reads full context).
301
+ // Claude's input_tokens already exclude cache reads.
302
+ const resultEvent = events.findLast((event) => event?.type === "result");
303
+ const resultUsage = resultEvent?.usage && typeof resultEvent.usage === "object"
304
+ ? finite(resultEvent.usage.input_tokens ?? resultEvent.usage.inputTokens)
305
+ : null;
306
+ if (resultUsage !== null) {
307
+ return {
308
+ inputTokens: resultUsage,
309
+ cacheReadInputTokens: finite(resultEvent.usage.cache_read_input_tokens ?? resultEvent.usage.cacheReadInputTokens) ?? null,
310
+ };
311
+ }
312
+ return {
313
+ inputTokens: events.reduce((sum, event) => {
314
+ if (event?.type !== "assistant") return sum;
315
+ const usage = event.message?.usage;
316
+ const value = usage && typeof usage === "object" ? finite(usage.input_tokens ?? usage.inputTokens) : null;
317
+ return sum + (value ?? 0);
318
+ }, 0) || null,
319
+ cacheReadInputTokens: null,
320
+ };
321
+ }
322
+ return { inputTokens: null, cacheReadInputTokens: null };
323
+ }
324
+
325
+ /**
326
+ * Budgeted live meter: one number, with cache reads weighted by the campaign
327
+ * policy so it is comparable with the persisted ledger. A provider that only
328
+ * reports usage at completion (agy, exec-jsonl, replay) meters as 0 mid-run.
329
+ *
330
+ * @param {string} harness
331
+ * @param {string} stdout bounded transcript tail
332
+ * @param {number} [cacheReadWeight] cached-to-uncached rate ratio, default 1
333
+ * @returns {number}
334
+ */
335
+ export function liveInputTokens(harness, stdout, cacheReadWeight = 1) {
336
+ const usage = liveUsage(harness, stdout);
337
+ if (usage.inputTokens === null) return 0;
338
+ const weighted = usage.inputTokens + (usage.cacheReadInputTokens ?? 0) * cacheReadWeight;
339
+ return Math.round(weighted * 1000) / 1000;
340
+ }
341
+
342
+ /**
343
+ * Parse each JSONL line independently. A bounded transcript tail can start or
344
+ * end mid-line, so unparsable lines are skipped rather than failing the live
345
+ * observation.
346
+ *
347
+ * @param {string} stdout
348
+ */
349
+ function parsedEvents(stdout) {
350
+ return String(stdout).split(/\r?\n/u).flatMap((line) => {
351
+ try {
352
+ return [JSON.parse(line)];
353
+ } catch {
354
+ return [];
355
+ }
356
+ });
357
+ }
358
+
359
+ /** Codex item types whose completion proves one tool invocation. */
360
+ const CODEX_TOOL_ITEM_TYPES = new Set(["tool_call", "command_execution", "mcp_tool_call", "web_search", "file_change"]);
361
+
362
+ /**
363
+ * Session evidence from a bounded live transcript: completed turns, cache-read
364
+ * input, tool invocations, and whether the harness's terminal record has been
365
+ * folded. Each harness exposes only what its own events prove, and anything
366
+ * unparsable or unsupported meters as zero — a live observation never throws.
367
+ *
368
+ * @param {string} harness
369
+ * @param {string} stdout bounded transcript tail
370
+ * @returns {{turns: number, cacheReadInputTokens: number, toolCalls: number, completed: boolean}}
371
+ */
372
+ export function liveSessionMetrics(harness, stdout) {
373
+ const parser = new SessionMetricsParser(harness);
374
+ parser.push(String(stdout));
375
+ parser.flush();
376
+ return parser.metrics();
377
+ }
378
+
379
+ /** Retention bound for one streamed record: records at or below it parse whole. */
380
+ const SESSION_RECORD_MAX_BYTES = 64 * 1024;
381
+
382
+ /** Fragment evidence kept for a record that outgrew the retention bound. */
383
+ const SESSION_FRAGMENT_BYTES = SESSION_RECORD_MAX_BYTES / 2;
384
+
385
+ /** Claude-family content-block needle proving one tool invocation. */
386
+ const TOOL_USE_NEEDLE = Buffer.from('"type":"tool_use"', "utf8");
387
+
388
+ /** Cache-read evidence spellings across harness streams. */
389
+ const CACHE_READ_PATTERN = /"(?:cache_read_input_tokens|cached_input_tokens|cacheReadInputTokens)":(\d+)/gu;
390
+
391
+ /**
392
+ * Bounded incremental session-metrics parser: fold fixed-size chunks into
393
+ * running rotation totals without ever holding a buffer that scales with the
394
+ * unread transcript. Records within `SESSION_RECORD_MAX_BYTES` parse whole;
395
+ * a larger record keeps head and tail fragments plus streamed needle counts,
396
+ * so its turn and usage evidence still lands in the totals instead of being
397
+ * silently skipped.
398
+ */
399
+ export class SessionMetricsParser {
400
+ /**
401
+ * @param {string} harness
402
+ * @param {{turns?: number, cacheReadInputTokens?: number, toolCalls?: number, completed?: boolean}} [previous]
403
+ */
404
+ constructor(harness, previous = {}) {
405
+ this.harness = harness;
406
+ this.totals = {
407
+ turns: previous.turns ?? 0,
408
+ cacheReadInputTokens: previous.cacheReadInputTokens ?? 0,
409
+ toolCalls: previous.toolCalls ?? 0,
410
+ completed: previous.completed === true,
411
+ };
412
+ /** @type {string|null} */
413
+ this.continuationId = null;
414
+ /** @type {string|null} Most recent folded item-completed type, for the codex completion rule. */
415
+ this.lastItemType = null;
416
+ /** @type {string|null} Text of the most recent folded agent message, for the codex completion rule. */
417
+ this.lastAgentText = null;
418
+ /** @type {Buffer} */
419
+ this.pending = Buffer.alloc(0);
420
+ /** @type {{head: Buffer, tail: Buffer, streamedToolUse: number, carry: Buffer}|null} */
421
+ this.oversized = null;
422
+ }
423
+
424
+ /**
425
+ * Fold every newline-terminated record in one chunk. A trailing partial
426
+ * record stays buffered (bounded) for the next chunk.
427
+ *
428
+ * @param {string|Buffer} chunk
429
+ */
430
+ push(chunk) {
431
+ let data = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
432
+ while (data.length > 0) {
433
+ const newline = data.indexOf(10);
434
+ if (newline < 0) {
435
+ this.absorb(data);
436
+ return;
437
+ }
438
+ this.absorb(data.subarray(0, newline));
439
+ this.completeRecord();
440
+ data = data.subarray(newline + 1);
441
+ }
442
+ }
443
+
444
+ /** Fold the buffered partial record as if a newline had ended it. */
445
+ flush() {
446
+ if (this.pending.length > 0 || this.oversized) this.completeRecord();
447
+ }
448
+
449
+ /** @returns {{turns: number, cacheReadInputTokens: number, toolCalls: number, completed: boolean}} */
450
+ metrics() {
451
+ return {
452
+ turns: this.totals.turns,
453
+ cacheReadInputTokens: this.totals.cacheReadInputTokens,
454
+ toolCalls: this.totals.toolCalls,
455
+ completed: this.totals.completed === true,
456
+ };
457
+ }
458
+
459
+ /**
460
+ * Retain one piece of a record still under assembly. Once the record
461
+ * outgrows the retention bound, only its head and a rolling tail are kept;
462
+ * the bytes leaving the tail are scanned for tool_use evidence instead of
463
+ * being buffered.
464
+ *
465
+ * @param {Buffer} piece
466
+ */
467
+ absorb(piece) {
468
+ if (this.oversized) {
469
+ const window = Buffer.concat([this.oversized.tail, piece]);
470
+ const keep = window.subarray(Math.max(0, window.length - SESSION_FRAGMENT_BYTES));
471
+ const dropped = window.subarray(0, window.length - keep.length);
472
+ if (this.harness === "claude") {
473
+ const counted = countWithCarry(dropped, this.oversized.carry, TOOL_USE_NEEDLE);
474
+ this.oversized.streamedToolUse += counted.hits;
475
+ this.oversized.carry = counted.carry;
476
+ }
477
+ this.oversized.tail = keep;
478
+ return;
479
+ }
480
+ if (this.pending.length + piece.length <= SESSION_RECORD_MAX_BYTES) {
481
+ // Copy: `piece` may be a view of a scratch buffer the caller reuses for
482
+ // the next read, which would corrupt a record buffered mid-chunk.
483
+ this.pending = this.pending.length > 0 ? Buffer.concat([this.pending, piece]) : Buffer.from(piece);
484
+ return;
485
+ }
486
+ const whole = Buffer.concat([this.pending, piece]);
487
+ this.pending = Buffer.alloc(0);
488
+ const head = whole.subarray(0, Math.min(SESSION_FRAGMENT_BYTES, whole.length));
489
+ const tail = whole.subarray(Math.max(0, whole.length - SESSION_FRAGMENT_BYTES));
490
+ /** @type {{head: Buffer, tail: Buffer, streamedToolUse: number, carry: Buffer}} */
491
+ const oversized = { head, tail, streamedToolUse: 0, carry: Buffer.alloc(0) };
492
+ if (this.harness === "claude") {
493
+ // Count from the record start up to where the rolling tail takes over,
494
+ // so a needle straddling any region boundary is counted exactly once.
495
+ const counted = countWithCarry(whole.subarray(0, Math.max(0, whole.length - tail.length)), oversized.carry, TOOL_USE_NEEDLE);
496
+ oversized.streamedToolUse = counted.hits;
497
+ oversized.carry = counted.carry;
498
+ }
499
+ this.oversized = oversized;
500
+ }
501
+
502
+ /** Fold the assembled record into the running totals. */
503
+ completeRecord() {
504
+ const oversized = this.oversized;
505
+ if (oversized) {
506
+ this.oversized = null;
507
+ /** @type {{head: string, tail: string, toolUse: number}} */
508
+ let fragments;
509
+ if (this.harness === "claude") {
510
+ const counted = countWithCarry(oversized.tail, oversized.carry, TOOL_USE_NEEDLE);
511
+ fragments = {
512
+ head: decodeFragment(oversized.head),
513
+ tail: decodeFragment(oversized.tail),
514
+ toolUse: oversized.streamedToolUse + counted.hits,
515
+ };
516
+ } else {
517
+ fragments = { head: decodeFragment(oversized.head), tail: decodeFragment(oversized.tail), toolUse: 0 };
518
+ }
519
+ foldFragmentRecord(this.harness, this.totals, fragments);
520
+ this.continuationId ??= fragmentContinuationId(this.harness, fragments);
521
+ return;
522
+ }
523
+ const line = this.pending.toString("utf8");
524
+ this.pending = Buffer.alloc(0);
525
+ let event;
526
+ try {
527
+ event = JSON.parse(line);
528
+ } catch {
529
+ return;
530
+ }
531
+ if (!event || typeof event !== "object" || Array.isArray(event)) return;
532
+ const record = /** @type {Record<string, unknown>} */ (event);
533
+ foldRecord(this.harness, this.totals, record);
534
+ this.continuationId ??= recordContinuationId(this.harness, record);
535
+ this.foldCompletionEvidence(record);
536
+ }
537
+
538
+ /**
539
+ * Fold the completion evidence one parsed record proves into the sticky
540
+ * totals. A harness is completed when its terminal record was folded; for
541
+ * codex that means a turn.completed that ends the turn with the
542
+ * result-carrying final agent message, so a live observation never treats a
543
+ * still-working or already-answered session ambiguously.
544
+ *
545
+ * @param {Record<string, unknown>} record
546
+ */
547
+ foldCompletionEvidence(record) {
548
+ const totals = this.totals;
549
+ if (this.harness === "codex") {
550
+ if (record.type === "turn.completed" && this.lastItemType === "agent_message"
551
+ && extractJson(this.lastAgentText) !== null) {
552
+ totals.completed = true;
553
+ }
554
+ } else if (this.harness === "claude" && record.type === "result") {
555
+ totals.completed = true;
556
+ } else if (this.harness === "exec-jsonl" && record.type === "run.completed") {
557
+ totals.completed = true;
558
+ } else if (this.harness === "replay" && typeof record.status === "string") {
559
+ // The replay envelope is the terminal record: the bin emits exactly one
560
+ // envelope line per invocation, so folding one proves completion.
561
+ totals.completed = true;
562
+ }
563
+ const item = eventItem(record);
564
+ if (record.type === "item.completed" && item) {
565
+ this.lastItemType = String(item.type ?? "");
566
+ this.lastAgentText = this.lastItemType === "agent_message" && typeof item.text === "string"
567
+ ? item.text
568
+ : null;
569
+ } else {
570
+ this.lastItemType = null;
571
+ this.lastAgentText = null;
572
+ }
573
+ }
574
+ }
575
+
576
+ /**
577
+ * Fold one parsed record into the running totals. Turn and tool counts are
578
+ * additive; cache-read is a running max for Codex (each turn.completed
579
+ * counter is already cumulative) and additive for claude-style streams until
580
+ * a terminal result event carries the authoritative session total.
581
+ *
582
+ * @param {string} harness
583
+ * @param {{turns: number, cacheReadInputTokens: number, toolCalls: number, completed: boolean}} totals
584
+ * @param {Record<string, unknown>} record
585
+ */
586
+ function foldRecord(harness, totals, record) {
587
+ if (harness === "codex") {
588
+ if (record.type === "turn.completed") {
589
+ totals.turns += 1;
590
+ totals.cacheReadInputTokens = Math.max(totals.cacheReadInputTokens, canonicalUsage(record.usage).cacheReadInputTokens ?? 0);
591
+ } else if (record.type === "item.completed" && CODEX_TOOL_ITEM_TYPES.has(String(eventItem(record)?.type))) {
592
+ totals.toolCalls += 1;
593
+ }
594
+ return;
595
+ }
596
+ if (harness === "claude") {
597
+ if (record.type === "assistant") {
598
+ const message = /** @type {Record<string, unknown>} */ (record.message ?? {});
599
+ totals.turns += 1;
600
+ totals.cacheReadInputTokens += canonicalUsage(message.usage).cacheReadInputTokens ?? 0;
601
+ totals.toolCalls += Array.isArray(message.content)
602
+ ? message.content.filter((/** @type {{type?: unknown}} */ block) => block?.type === "tool_use").length
603
+ : 0;
604
+ } else if (record.type === "result") {
605
+ const sessionTotal = canonicalUsage(record.usage).cacheReadInputTokens;
606
+ if (sessionTotal !== null) totals.cacheReadInputTokens = sessionTotal;
607
+ }
608
+ return;
609
+ }
610
+ if (harness === "exec-jsonl" && record.type === "run.completed") {
611
+ // The protocol carries no tool events; only a completed run proves a turn.
612
+ totals.turns += 1;
613
+ totals.cacheReadInputTokens += canonicalUsage(record.usage).cacheReadInputTokens ?? 0;
614
+ }
615
+ if (harness === "replay" && typeof record.status === "string") {
616
+ // A replayed envelope is the whole invocation: one completed turn, no
617
+ // tool events, usage only in the terminal record.
618
+ totals.turns += 1;
619
+ totals.cacheReadInputTokens += canonicalUsage(record.usage).cacheReadInputTokens ?? 0;
620
+ }
621
+ }
622
+
623
+ /**
624
+ * Fold the head-plus-tail fragments of one record that outgrew the retention
625
+ * bound: the same evidence foldRecord extracts, read as fragments so an
626
+ * oversized record is never silently skipped.
627
+ *
628
+ * @param {string} harness
629
+ * @param {{turns: number, cacheReadInputTokens: number, toolCalls: number, completed: boolean}} totals
630
+ * @param {{head: string, tail: string, toolUse: number}} fragments
631
+ */
632
+ function foldFragmentRecord(harness, totals, fragments) {
633
+ const text = `${fragments.head}\n${fragments.tail}`;
634
+ if (harness === "claude") {
635
+ if (text.includes('"type":"assistant"')) {
636
+ totals.turns += 1;
637
+ totals.toolCalls += fragments.toolUse;
638
+ const cacheRead = lastCacheRead(text);
639
+ if (cacheRead !== null) totals.cacheReadInputTokens += cacheRead;
640
+ } else if (text.includes('"type":"result"')) {
641
+ const sessionTotal = lastCacheRead(text);
642
+ if (sessionTotal !== null) totals.cacheReadInputTokens = sessionTotal;
643
+ totals.completed = true;
644
+ }
645
+ return;
646
+ }
647
+ if (harness === "codex") {
648
+ if (text.includes('"type":"turn.completed"')) {
649
+ totals.turns += 1;
650
+ const cacheRead = lastCacheRead(text);
651
+ totals.cacheReadInputTokens = Math.max(totals.cacheReadInputTokens, cacheRead ?? 0);
652
+ // Fragment approximation of the parsed-record completion rule: the
653
+ // turn ends with the final agent message when that message appears
654
+ // before the completed marker in the retained head and tail.
655
+ const agentAt = text.indexOf('"type":"agent_message"');
656
+ if (agentAt >= 0 && agentAt < text.indexOf('"type":"turn.completed"')) totals.completed = true;
657
+ } else if (text.includes('"type":"item.completed"') && [...CODEX_TOOL_ITEM_TYPES].some((type) => text.includes(`"type":"${type}"`))) {
658
+ totals.toolCalls += 1;
659
+ }
660
+ return;
661
+ }
662
+ if (harness === "exec-jsonl" && text.includes('"type":"run.completed"')) {
663
+ totals.turns += 1;
664
+ const cacheRead = lastCacheRead(text);
665
+ if (cacheRead !== null) totals.cacheReadInputTokens += cacheRead;
666
+ totals.completed = true;
667
+ }
668
+ }
669
+
670
+ /**
671
+ * The provider session identity one record proves.
672
+ *
673
+ * @param {string} harness
674
+ * @param {Record<string, unknown>} record
675
+ * @returns {string|null}
676
+ */
677
+ function recordContinuationId(harness, record) {
678
+ if (harness === "codex") {
679
+ return record.type === "thread.started" && typeof record.thread_id === "string" ? record.thread_id : null;
680
+ }
681
+ if (harness === "claude") {
682
+ return record.type === "result" && typeof record.session_id === "string" ? record.session_id : null;
683
+ }
684
+ if (harness === "exec-jsonl" && (record.type === "run.started" || record.type === "run.completed")) {
685
+ return typeof record.continuationId === "string" ? record.continuationId : null;
686
+ }
687
+ return null;
688
+ }
689
+
690
+ /**
691
+ * The provider session identity one record's fragments prove.
692
+ *
693
+ * @param {string} harness
694
+ * @param {{head: string, tail: string}} fragments
695
+ * @returns {string|null}
696
+ */
697
+ function fragmentContinuationId(harness, fragments) {
698
+ const text = `${fragments.head}\n${fragments.tail}`;
699
+ const pattern = harness === "codex"
700
+ ? /"thread_id":"([^"]+)"/u
701
+ : harness === "claude"
702
+ ? /"session_id":"([^"]+)"/u
703
+ : /"continuationId":"([^"]+)"/u;
704
+ const match = pattern.exec(text);
705
+ return match ? match[1] : null;
706
+ }
707
+
708
+ /**
709
+ * Count needle occurrences in one region, keeping the trailing bytes that
710
+ * could complete a needle in the next region so a straddling needle is
711
+ * counted exactly once.
712
+ *
713
+ * @param {Buffer} region
714
+ * @param {Buffer} carry
715
+ * @param {Buffer} needle
716
+ * @returns {{hits: number, carry: Buffer}}
717
+ */
718
+ function countWithCarry(region, carry, needle) {
719
+ const stream = carry.length > 0 ? Buffer.concat([carry, region]) : region;
720
+ return { hits: countNeedle(stream, needle), carry: stream.subarray(Math.max(0, stream.length - (needle.length - 1))) };
721
+ }
722
+
723
+ /**
724
+ * @param {Buffer} haystack
725
+ * @param {Buffer} needle
726
+ * @returns {number}
727
+ */
728
+ function countNeedle(haystack, needle) {
729
+ let hits = 0;
730
+ for (let at = haystack.indexOf(needle); at >= 0; at = haystack.indexOf(needle, at + needle.length)) hits += 1;
731
+ return hits;
732
+ }
733
+
734
+ /**
735
+ * Decode a retained fragment without splitting a UTF-8 sequence.
736
+ *
737
+ * @param {Buffer} fragment
738
+ * @returns {string}
739
+ */
740
+ function decodeFragment(fragment) {
741
+ let start = 0;
742
+ while (start < fragment.length && (fragment[start] & 0xc0) === 0x80) start += 1;
743
+ return fragment.toString("utf8", start);
744
+ }
745
+
746
+ /**
747
+ * The last cache-read number in a fragment text, or null.
748
+ *
749
+ * @param {string} text
750
+ * @returns {number|null}
751
+ */
752
+ function lastCacheRead(text) {
753
+ const matches = [...text.matchAll(CACHE_READ_PATTERN)];
754
+ return matches.length > 0 ? Number(matches.at(-1)?.[1]) : null;
755
+ }
756
+
757
+ /**
758
+ * Bound one tool result to at most `maxBytes` UTF-8 bytes, keeping the head
759
+ * and the tail around an omission marker. This is the reference head+tail
760
+ * form the toolPolicy contract names; a cut never splits a UTF-8 sequence.
761
+ *
762
+ * @param {string} value
763
+ * @param {number} [maxBytes]
764
+ * @returns {string}
765
+ */
766
+ export function truncateToolOutput(value, maxBytes = TOOL_OUTPUT_LIMIT_BYTES) {
767
+ const bytes = Buffer.from(String(value ?? ""), "utf8");
768
+ if (bytes.length <= maxBytes) return bytes.toString("utf8");
769
+ if (maxBytes < 192) {
770
+ // Too small to carry a head+tail marker: keep only a UTF-8-safe prefix.
771
+ let end = Math.max(0, maxBytes - 3);
772
+ while (end > 0 && (bytes[end] & 0xc0) === 0x80) end -= 1;
773
+ return end > 0 ? `${bytes.subarray(0, end).toString("utf8")}…` : "";
774
+ }
775
+ // Reserve headroom for the marker so the bounded result can never exceed
776
+ // the limit regardless of how many digits the omission count needs.
777
+ const markerBudget = 96;
778
+ const headBudget = Math.floor((maxBytes - markerBudget) / 2);
779
+ const tailBudget = maxBytes - markerBudget - headBudget;
780
+ let headEnd = headBudget;
781
+ while (headEnd > 0 && (bytes[headEnd] & 0xc0) === 0x80) headEnd -= 1;
782
+ let tailStart = bytes.length - tailBudget;
783
+ while (tailStart < bytes.length && (bytes[tailStart] & 0xc0) === 0x80) tailStart += 1;
784
+ const head = bytes.subarray(0, headEnd);
785
+ const tail = bytes.subarray(tailStart);
786
+ const marker = `\n…[${bytes.length - head.length - tail.length} bytes truncated; narrow with grep or tail]…\n`;
787
+ return Buffer.concat([head, Buffer.from(marker, "utf8"), tail]).toString("utf8");
788
+ }