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,531 @@
1
+ import { finite } from "../util.mjs";
2
+ /**
3
+ * Shared provider-protocol normalization and version parsing, extracted from
4
+ * `exec-jsonl.mjs` because `claude.mjs`, `codex.mjs`, `agy.mjs`, `dsh.mjs`,
5
+ * `zcode.mjs`, `replay.mjs` and `exec-jsonl.mjs` itself all depend on it.
6
+ */
7
+
8
+ /**
9
+ * Parse newline-delimited JSON without accepting provider prose.
10
+ *
11
+ * @param {string} stdout
12
+ * @param {string} harness
13
+ * @returns {Record<string, unknown>[]}
14
+ */
15
+ export function parseJsonLines(stdout, harness) {
16
+ const raw = Buffer.from(String(stdout), "utf8");
17
+ const truncated = raw.length > HARNESS_OUTPUT_LIMIT_BYTES;
18
+ const bounded = truncated ? raw.subarray(raw.length - HARNESS_OUTPUT_LIMIT_BYTES).toString("utf8") : raw.toString("utf8");
19
+ /** @type {Record<string, unknown>[]} */
20
+ const events = [];
21
+ let firstNonEmpty = true;
22
+ for (const [index, line] of bounded.split(/\r?\n/u).entries()) {
23
+ if (!line.trim()) continue;
24
+ try {
25
+ events.push(JSON.parse(line));
26
+ firstNonEmpty = false;
27
+ } catch (error) {
28
+ // A bounded tail (or a log capped by the gate wrapper) can start inside a
29
+ // provider event; only the first non-empty line may be partial.
30
+ if (firstNonEmpty) continue;
31
+ throw new Error(`${harness} emitted invalid JSON on line ${index + 1}: ${error instanceof Error ? error.message : String(error)}`);
32
+ }
33
+ }
34
+ return events;
35
+ }
36
+
37
+ export const HARNESS_OUTPUT_LIMIT_BYTES = 512 * 1024;
38
+
39
+ /**
40
+ * @param {string} stdout
41
+ * @param {string} stderr
42
+ * @returns {string|null}
43
+ */
44
+ export function parseVersion(stdout, stderr = "") {
45
+ const line = `${stdout}\n${stderr}`.split(/\r?\n/u).map((value) => value.trim()).find(Boolean);
46
+ if (!line || /^[\[{]/u.test(line)) return null;
47
+ return /(?:^|[\s/])v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?(?:$|\s)/u.test(line)
48
+ ? line
49
+ : null;
50
+ }
51
+
52
+ /**
53
+ * @param {unknown} value
54
+ * @returns {string}
55
+ */
56
+ export function toml(value) {
57
+ return JSON.stringify(value);
58
+ }
59
+
60
+ /**
61
+ * A stream that ended without its completion event says nothing about why.
62
+ * When the process also wrote to stderr — a config parse error, a missing
63
+ * binary, an auth refusal — that text is the whole diagnosis, and dropping it
64
+ * turns a contract bug into what looks like a provider outage: a codex custom
65
+ * provider declared without `name` dies at config load, and reporting only
66
+ * "Codex emitted no turn.completed event" cost two preflight rounds before
67
+ * anyone read git-less stderr by hand.
68
+ *
69
+ * @param {string} message
70
+ * @param {import("./index.mjs").NormalizeOptions} [options]
71
+ * @returns {string}
72
+ */
73
+ function withStartupReason(message, options = {}) {
74
+ const reason = typeof options.stderr === "string" ? options.stderr.trim() : "";
75
+ return reason ? `${message}: ${boundedMessage(reason, 512)}` : message;
76
+ }
77
+
78
+ /**
79
+ * @param {string} stdout
80
+ * @param {number|null} exitCode
81
+ * @param {string|null} signal
82
+ * @param {import("./index.mjs").NormalizeOptions} [options]
83
+ * @returns {import("./index.mjs").ProviderEnvelope}
84
+ */
85
+ export function normalizeClaudeResult(stdout, exitCode, signal, options = {}) {
86
+ if (signal) return failed("canceled", `provider ended after ${signal}`, "canceled");
87
+ const events = parseJsonLines(stdout, "claude");
88
+ const resultEvent = events.findLast((event) => event.type === "result");
89
+ if (!resultEvent) return failed("incomplete_stream", withStartupReason("Claude emitted no result event", options));
90
+ const result = typeof resultEvent.result === "string" ? resultEvent.result : null;
91
+ // A provider-reported quota stop is exhaustion: the declared failover edge
92
+ // must fire instead of settling the node as an ordinary provider failure.
93
+ const quotaText = claudeQuotaText(resultEvent, events);
94
+ if (quotaText) {
95
+ return failed(
96
+ "quota_exhausted",
97
+ boundedMessage(quotaText, 512),
98
+ "exhausted",
99
+ typeof resultEvent.session_id === "string" ? resultEvent.session_id : null,
100
+ canonicalUsage(resultEvent.usage),
101
+ );
102
+ }
103
+ if (resultEvent.is_error || exitCode !== 0) {
104
+ return failed("provider_error", result ?? `Claude exited with code ${exitCode}`);
105
+ }
106
+ return {
107
+ status: result?.trim() ? "done" : "no-op",
108
+ result,
109
+ continuationId: typeof resultEvent.session_id === "string" ? resultEvent.session_id : null,
110
+ usage: canonicalUsage(resultEvent.usage),
111
+ costUsd: finite(resultEvent.total_cost_usd),
112
+ error: null,
113
+ };
114
+ }
115
+
116
+ /**
117
+ * @param {string} stdout
118
+ * @param {number|null} exitCode
119
+ * @param {string|null} signal
120
+ * @param {import("./index.mjs").NormalizeOptions} options
121
+ * @returns {import("./index.mjs").ProviderEnvelope}
122
+ */
123
+ export function normalizeAgyResult(stdout, exitCode, signal, options = {}) {
124
+ if (signal) return failed("canceled", `provider ended after ${signal}`, "canceled");
125
+ const events = parseJsonLines(stdout, "agy");
126
+ const resultEvent = events.findLast((event) => event.event === "result")?.result;
127
+ if (!resultEvent || typeof resultEvent !== "object" || Array.isArray(resultEvent)) {
128
+ return failed("incomplete_stream", withStartupReason("agy emitted no result event", options));
129
+ }
130
+ const record = /** @type {Record<string, unknown>} */ (resultEvent);
131
+ const response = typeof record.response === "string" ? record.response : null;
132
+ if (record.status !== "SUCCESS" || exitCode !== 0) {
133
+ return failed("provider_error", typeof record.error === "string" ? record.error : `agy exited with code ${exitCode}`);
134
+ }
135
+ const result = options.preferStructured ? extractJson(response) ?? response : response;
136
+ return {
137
+ status: result?.trim() ? "done" : "no-op",
138
+ result,
139
+ continuationId: typeof record.conversation_id === "string" ? record.conversation_id : null,
140
+ usage: canonicalUsage(record.usage),
141
+ costUsd: null,
142
+ error: null,
143
+ };
144
+ }
145
+
146
+ /**
147
+ * @param {string} stdout
148
+ * @param {number|null} exitCode
149
+ * @param {string|null} signal
150
+ * @param {import("./index.mjs").NormalizeOptions} options
151
+ * @returns {import("./index.mjs").ProviderEnvelope}
152
+ */
153
+ export function normalizeZcodeResult(stdout, exitCode, signal, options = {}) {
154
+ if (signal) return failed("canceled", `provider ended after ${signal}`, "canceled");
155
+ // The headless result is one JSON object, not an event stream: a run that
156
+ // died before it (config rejection, auth refusal) leaves stdout empty and
157
+ // the whole diagnosis on stderr.
158
+ let record;
159
+ let parsed = false;
160
+ try {
161
+ record = JSON.parse(stdout);
162
+ parsed = record !== null && typeof record === "object" && !Array.isArray(record);
163
+ } catch {
164
+ // SyntaxError means stdout is not the result object; the !parsed branch below classifies it.
165
+ }
166
+ if (!parsed) {
167
+ const reason = withStartupReason("ZCode emitted no result object", options);
168
+ // A run that died before its result object still classifies by its own
169
+ // stderr: quota evidence is exhaustion, everything else is a plain
170
+ // incomplete stream whose diagnosis travels in the message.
171
+ if (isQuotaText(reason)) return failed("quota_exhausted", boundedMessage(reason, 512), "exhausted");
172
+ return failed("incomplete_stream", reason);
173
+ }
174
+ const response = typeof record.response === "string" ? record.response : null;
175
+ if (exitCode !== 0) {
176
+ return failed("provider_error", response?.trim() ? response : withStartupReason(`ZCode exited with code ${exitCode}`, options));
177
+ }
178
+ const usage = record.usage && typeof record.usage === "object" && !Array.isArray(record.usage)
179
+ ? /** @type {Record<string, unknown>} */ (record.usage)
180
+ : {};
181
+ const result = options.preferStructured ? extractJson(response) ?? response : response;
182
+ return {
183
+ status: result?.trim() ? "done" : "no-op",
184
+ result,
185
+ continuationId: typeof record.sessionId === "string" ? record.sessionId : null,
186
+ // ZCode inputTokens is the whole input volume: its totalTokens is
187
+ // inputTokens + outputTokens, and cacheReadTokens/cacheWriteTokens are
188
+ // details already inside it. Subtracting the cache reads leaves uncached
189
+ // input plus cache writes, the canonical convention; adding the writes on
190
+ // top would count them twice.
191
+ usage: canonicalUsage({
192
+ inputTokens: usage.inputTokens,
193
+ outputTokens: usage.outputTokens,
194
+ cache_read_tokens: usage.cacheReadTokens,
195
+ }, { inputIncludesCache: true }),
196
+ costUsd: null,
197
+ error: null,
198
+ };
199
+ }
200
+
201
+ /**
202
+ * @param {Record<string, unknown>|undefined} event
203
+ * @returns {Record<string, unknown>|null}
204
+ */
205
+ export function eventItem(event) {
206
+ const item = event?.item;
207
+ return item && typeof item === "object" && !Array.isArray(item) ? /** @type {Record<string, unknown>} */ (item) : null;
208
+ }
209
+
210
+ /** Provider-reported quota and rate-limit text: exhaustion, never an ordinary provider failure. */
211
+ const QUOTA_TEXT_PATTERN = /429|1310|rate.?limit|usage limit|limit exhausted|quota|too many requests/iu;
212
+
213
+ /**
214
+ * @param {string|null|undefined} text
215
+ * @returns {boolean}
216
+ */
217
+ export function isQuotaText(text) {
218
+ return QUOTA_TEXT_PATTERN.test(String(text ?? ""));
219
+ }
220
+
221
+ /**
222
+ * Quota evidence inside one claude-family assistant error record. The record
223
+ * shape differs across providers: the Z.ai stream carries `content` text at
224
+ * the top level, while the Anthropic stream nests content blocks under
225
+ * `message`.
226
+ *
227
+ * @param {Record<string, unknown>} record
228
+ * @returns {string|null}
229
+ */
230
+ function assistantQuotaText(record) {
231
+ const message = record.message && typeof record.message === "object" ? /** @type {Record<string, unknown>} */ (record.message) : null;
232
+ const content = record.content ?? message?.content;
233
+ if (typeof content === "string") return content;
234
+ if (Array.isArray(content)) {
235
+ return content.map((block) => {
236
+ if (block === null || typeof block !== "object") return "";
237
+ const value = /** @type {Record<string, unknown>} */ (block).text ?? /** @type {Record<string, unknown>} */ (block).content;
238
+ return typeof value === "string" ? value : "";
239
+ }).join("\n");
240
+ }
241
+ return null;
242
+ }
243
+
244
+ /**
245
+ * Provider-reported quota text in a claude-family stream: an assistant error
246
+ * record (`rate_limit` or an API error message) or a terminal `api_error`
247
+ * result whose text carries quota evidence.
248
+ *
249
+ * @param {Record<string, unknown>|undefined} resultEvent
250
+ * @param {Record<string, unknown>[]} events
251
+ * @returns {string|null}
252
+ */
253
+ function claudeQuotaText(resultEvent, events) {
254
+ for (const event of events) {
255
+ if (event?.type !== "assistant") continue;
256
+ const record = /** @type {Record<string, unknown>} */ (event);
257
+ if (record.error !== "rate_limit" && record.is_api_error_message !== true) continue;
258
+ const text = assistantQuotaText(record);
259
+ if (text !== null && isQuotaText(text)) return text;
260
+ }
261
+ if (resultEvent?.terminal_reason === "api_error" || resultEvent?.is_error === true) {
262
+ const terminal = typeof resultEvent.result === "string"
263
+ ? resultEvent.result
264
+ : resultEvent.error && typeof resultEvent.error === "object"
265
+ ? /** @type {Record<string, unknown>} */ (resultEvent.error).message
266
+ : null;
267
+ if (typeof terminal === "string" && isQuotaText(terminal)) return terminal;
268
+ }
269
+ return null;
270
+ }
271
+
272
+ /**
273
+ * @param {string} stdout
274
+ * @param {number|null} exitCode
275
+ * @param {string|null} signal
276
+ * @param {import("./index.mjs").NormalizeOptions} options
277
+ * @returns {import("./index.mjs").ProviderEnvelope}
278
+ */
279
+ export function normalizeCodexResult(stdout, exitCode, signal, options = {}) {
280
+ const events = parseJsonLines(stdout, "codex");
281
+ const thread = events.findLast((event) => event.type === "thread.started");
282
+ const continuationId = typeof thread?.thread_id === "string" ? thread.thread_id : null;
283
+ // A disabled code-mode host means the model could not run any command or
284
+ // inspect anything: whatever agent_message it streamed afterwards is a
285
+ // fabricated result, never evidence. The tool-host failure is classified
286
+ // first so it wins over a later termination signal, a completed turn, or an
287
+ // agent_message verdict: a judge grounded in no inspection is rejected, and
288
+ // a harness that dies after the host error is not a plain cancellation.
289
+ // Other item-level error records (rollout_budget warnings, missing model
290
+ // metadata, ...) are diagnostics and stay ignored.
291
+ const toolHostError = events.find((event) => {
292
+ if (event.type !== "item.completed") return false;
293
+ const item = eventItem(event);
294
+ return item?.type === "error" && typeof item.message === "string"
295
+ && (item.message.includes("code-mode host is disabled") || item.message.includes("Code Mode is unavailable"));
296
+ });
297
+ if (toolHostError) {
298
+ const item = eventItem(toolHostError);
299
+ const message = typeof item?.message === "string" ? item.message : "code-mode host is disabled";
300
+ const completed = events.findLast((event) => event.type === "turn.completed");
301
+ return failed(
302
+ "tool_host_unavailable",
303
+ boundedMessage(message, 512),
304
+ undefined,
305
+ continuationId,
306
+ canonicalUsage(completed?.usage, { inputIncludesCache: true }),
307
+ );
308
+ }
309
+ if (signal) return failed("canceled", `provider ended after ${signal}`, "canceled", continuationId);
310
+ const completed = events.findLast((event) => event.type === "turn.completed");
311
+ const messages = events.filter((event) => event.type === "item.completed" && eventItem(event)?.type === "agent_message");
312
+ // A judge round is arbitrated by exactly one verdict. Counting the
313
+ // verdict-shaped messages here, at the provider boundary, is the only way the
314
+ // controller can tell one verdict from two: the last structured message looks
315
+ // identical in both cases once the rest is discarded.
316
+ //
317
+ // Only messages after the judge's last action are arbitration. Under an
318
+ // enforced output schema every agent message is shaped like a verdict,
319
+ // including the plan the model narrates before it inspects anything, so a
320
+ // naive count read that preamble as a second verdict: measured 2026-09-13,
321
+ // 17 of 22 codex judge rounds in this repository's history (77%) spent a
322
+ // bounded re-ask on it, and every codex round of a live six-node campaign
323
+ // paid two invocations instead of one. A verdict emitted before the judge
324
+ // ran a single command was reached with no evidence and cannot be the
325
+ // arbitration; a judge that used no tools at all still has every candidate
326
+ // counted, so a genuine change of mind is caught exactly as before.
327
+ const verdictCandidates = options.preferStructured ? arbitrationMessages(events, messages).filter((event) => isVerdictCandidate(eventItem(event)?.text)) : [];
328
+ const message = options.preferStructured
329
+ ? verdictCandidates.at(-1) ?? messages.findLast((event) => extractJson(eventItem(event)?.text) !== null) ?? messages.at(-1)
330
+ : messages.at(-1);
331
+ const failure = events.findLast((event) => event.type === "turn.failed" || event.type === "error");
332
+ if (failure) {
333
+ const errorRecord = /** @type {Record<string, unknown>|undefined} */ (failure?.error);
334
+ const failureMessage = typeof errorRecord?.message === "string" ? errorRecord.message
335
+ : typeof failure?.message === "string" ? failure.message
336
+ : "Codex failed";
337
+ const usage = canonicalUsage(errorRecord?.usage ?? failure?.usage, { inputIncludesCache: true });
338
+ if (isQuotaText(failureMessage)) {
339
+ return failed("quota_exhausted", boundedMessage(failureMessage, 512), "exhausted", continuationId, usage);
340
+ }
341
+ return failed(
342
+ "provider_error",
343
+ failureMessage,
344
+ undefined,
345
+ continuationId,
346
+ usage,
347
+ );
348
+ }
349
+ if (!completed) return failed("incomplete_stream", withStartupReason("Codex emitted no turn.completed event", options), undefined, continuationId);
350
+ const text = eventItem(message)?.text;
351
+ const textResult = typeof text === "string" ? text : null;
352
+ // A finished turn with a final message is accepted work regardless of the
353
+ // harness exit code: the exit code is evidence about the harness, not about
354
+ // the result. Only a turn that ended without any final message still
355
+ // reports the non-zero exit as a provider error.
356
+ if (exitCode !== 0 && typeof text !== "string") {
357
+ return failed(
358
+ "provider_error",
359
+ `Codex exited with code ${exitCode}`,
360
+ undefined,
361
+ continuationId,
362
+ canonicalUsage(completed.usage, { inputIncludesCache: true }),
363
+ );
364
+ }
365
+ const result = options.preferStructured ? extractJson(textResult) ?? textResult : textResult;
366
+ return {
367
+ status: result?.trim() ? "done" : "no-op",
368
+ result,
369
+ continuationId,
370
+ usage: canonicalUsage(completed.usage, { inputIncludesCache: true }),
371
+ costUsd: null,
372
+ error: null,
373
+ ...(options.preferStructured ? { judgeCandidates: verdictCandidates.length } : {}),
374
+ };
375
+ }
376
+
377
+ /**
378
+ * The agent messages that can be this turn's arbitration: the ones after the
379
+ * judge's last action. An action is any completed item that is not an
380
+ * `agent_message` and not an `error` diagnostic — measured 2026-09-13 across
381
+ * every judge log in this repository, codex emits exactly four item types
382
+ * (`command_execution`, `agent_message`, `error`, `collab_tool_call`), so the
383
+ * complement is the tool surface. With no action in the turn every message
384
+ * stays a candidate.
385
+ *
386
+ * @param {Record<string, unknown>[]} events
387
+ * @param {Record<string, unknown>[]} messages
388
+ * @returns {Record<string, unknown>[]}
389
+ */
390
+ function arbitrationMessages(events, messages) {
391
+ const lastAction = events.findLast((event) => {
392
+ if (event.type !== "item.completed") return false;
393
+ const type = eventItem(event)?.type;
394
+ return typeof type === "string" && type !== "agent_message" && type !== "error";
395
+ });
396
+ if (!lastAction) return messages;
397
+ const actionIndex = events.indexOf(lastAction);
398
+ return messages.filter((event) => events.indexOf(event) > actionIndex);
399
+ }
400
+
401
+ /**
402
+ * Whether one agent message carries a verdict-shaped object: a JSON value whose
403
+ * `verdict` names the only two outcomes a gate accepts. Prose, partial JSON,
404
+ * and unrelated objects are not candidates.
405
+ *
406
+ * @param {unknown} text
407
+ * @returns {boolean}
408
+ */
409
+ export function isVerdictCandidate(text) {
410
+ const candidate = extractJson(text);
411
+ if (candidate === null) return false;
412
+ try {
413
+ const parsed = /** @type {unknown} */ (JSON.parse(candidate));
414
+ return Boolean(parsed && typeof parsed === "object" && !Array.isArray(parsed)
415
+ && ["pass", "fail"].includes(String(/** @type {Record<string, unknown>} */ (parsed).verdict)));
416
+ } catch {
417
+ return false;
418
+ }
419
+ }
420
+
421
+ /**
422
+ * Extract a JSON value from a provider response that may carry prose, taking
423
+ * the last parseable suffix or fenced JSON block.
424
+ *
425
+ * @param {unknown} value
426
+ * @returns {string|null}
427
+ */
428
+ export function extractJson(value) {
429
+ if (typeof value !== "string") return null;
430
+ const trimmed = value.trim();
431
+ try {
432
+ JSON.parse(trimmed);
433
+ return trimmed;
434
+ } catch {
435
+ // Not JSON as a whole: fall through to the suffix and fenced-block scans below.
436
+ }
437
+ const lines = trimmed.split(/\r?\n/u);
438
+ for (let index = lines.length - 1; index > 0; index -= 1) {
439
+ const candidate = lines.slice(index).join("\n").trim();
440
+ try {
441
+ JSON.parse(candidate);
442
+ return candidate;
443
+ } catch {
444
+ // This suffix is not JSON; keep trying earlier line boundaries.
445
+ }
446
+ }
447
+ const blocks = [...value.matchAll(/```json\s*([\s\S]*?)```/giu)];
448
+ for (const block of blocks.reverse()) {
449
+ const candidate = block[1].trim();
450
+ try {
451
+ JSON.parse(candidate);
452
+ return candidate;
453
+ } catch {
454
+ // This fenced block is not JSON; try the next one.
455
+ }
456
+ }
457
+ return null;
458
+ }
459
+
460
+ /**
461
+ * Truncate a diagnostic message to at most `maxBytes` UTF-8 bytes without
462
+ * splitting a multi-byte sequence.
463
+ *
464
+ * @param {string} value
465
+ * @param {number} maxBytes
466
+ * @returns {string}
467
+ */
468
+ function boundedMessage(value, maxBytes) {
469
+ const bytes = Buffer.from(String(value), "utf8");
470
+ if (bytes.length <= maxBytes) return bytes.toString("utf8");
471
+ // Cut before the character that starts at or after the limit. Backing up to
472
+ // a lead byte without dropping it would leave a dangling sequence that
473
+ // re-encodes as U+FFFD and can exceed the byte ceiling.
474
+ let end = maxBytes;
475
+ while (end > 0 && (bytes[end] & 0xc0) === 0x80) end -= 1;
476
+ return bytes.subarray(0, end).toString("utf8");
477
+ }
478
+
479
+ /**
480
+ * @param {string} code
481
+ * @param {string} message
482
+ * @param {"done"|"no-op"|"blocked"|"failed"|"exhausted"|"stalled"|"canceled"} status
483
+ * @param {string|null} [continuationId]
484
+ * @param {ReturnType<typeof canonicalUsage>} [usage]
485
+ * @returns {import("./index.mjs").ProviderEnvelope}
486
+ */
487
+ export function failed(code, message, status = classifyFailure(message), continuationId = null, usage = canonicalUsage()) {
488
+ return {
489
+ status,
490
+ result: null,
491
+ continuationId,
492
+ usage,
493
+ costUsd: null,
494
+ error: { code, message: String(message) },
495
+ };
496
+ }
497
+
498
+ /**
499
+ * @param {string} message
500
+ * @returns {"canceled"|"blocked"|"exhausted"|"failed"}
501
+ */
502
+ function classifyFailure(message) {
503
+ const text = String(message);
504
+ if (/cancel(?:ed|led)|aborted/iu.test(text)) return "canceled";
505
+ if (/permission|approval|sandbox/iu.test(text)) return "blocked";
506
+ if (isQuotaText(text) || /budget|token.*limit|context.*limit|max.*turn/iu.test(text)) return "exhausted";
507
+ return "failed";
508
+ }
509
+
510
+ /**
511
+ * @param {unknown} usage
512
+ * @param {{inputIncludesCache?: boolean}} [options]
513
+ * @returns {{inputTokens: number|null, outputTokens: number|null, cacheReadInputTokens: number|null}}
514
+ */
515
+ export function canonicalUsage(usage = {}, options = {}) {
516
+ const record = usage && typeof usage === "object" && !Array.isArray(usage) ? /** @type {Record<string, unknown>} */ (usage) : {};
517
+ const rawInput = finite(record.inputTokens ?? record.input_tokens);
518
+ const cacheReadInputTokens = finite(
519
+ record.cacheReadInputTokens ?? record.cache_read_input_tokens ?? record.cached_input_tokens ?? record.cache_read_tokens,
520
+ );
521
+ const cacheWriteInputTokens = finite(record.cacheWriteInputTokens ?? record.cache_creation_input_tokens ?? record.cache_write_tokens) ?? 0;
522
+ const inputTokens = rawInput === null
523
+ ? null
524
+ : Math.max(0, rawInput + cacheWriteInputTokens - (options.inputIncludesCache ? cacheReadInputTokens ?? 0 : 0));
525
+ return {
526
+ inputTokens,
527
+ outputTokens: finite(record.outputTokens ?? record.output_tokens),
528
+ cacheReadInputTokens,
529
+ };
530
+ }
531
+