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.
- package/LICENSE +21 -0
- package/README.md +131 -0
- package/bin/faberun.mjs +25 -0
- package/integrations/claude-code/statusline-bench.sh +42 -0
- package/integrations/claude-code/statusline.sh +80 -0
- package/package.json +33 -0
- package/skills/faberun/SKILL.md +24 -0
- package/skills/faberun/references/contract.md +380 -0
- package/skills/faberun/references/engineering.md +29 -0
- package/skills/faberun/references/handoffs.md +26 -0
- package/skills/faberun/references/operations.md +184 -0
- package/skills/faberun/references/rules.md +35 -0
- package/skills/faberun/references/workflow.md +23 -0
- package/skills/init-agentkit/SKILL.md +108 -0
- package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
- package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
- package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
- package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
- package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
- package/skills/init-agentkit/templates/AGENTS.md +110 -0
- package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
- package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
- package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
- package/skills/init-agentkit/templates/docs/VISION.md +33 -0
- package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
- package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
- package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
- package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
- package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
- package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
- package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
- package/src/campaign/brief.mjs +394 -0
- package/src/campaign/chain.mjs +555 -0
- package/src/campaign/handoff.mjs +516 -0
- package/src/campaign/index.mjs +300 -0
- package/src/campaign/journal.mjs +347 -0
- package/src/campaign/layout.mjs +51 -0
- package/src/campaign/metrics-evals.mjs +25 -0
- package/src/campaign/metrics.mjs +517 -0
- package/src/campaign/projection.mjs +250 -0
- package/src/campaign/record.mjs +102 -0
- package/src/campaign/unpark.mjs +56 -0
- package/src/cli/brand.mjs +205 -0
- package/src/cli/campaign.mjs +730 -0
- package/src/cli/contract.mjs +67 -0
- package/src/cli/init.mjs +170 -0
- package/src/cli/launch.mjs +239 -0
- package/src/cli/seat.mjs +139 -0
- package/src/cli/setup.mjs +294 -0
- package/src/cli/skills.mjs +105 -0
- package/src/cli/update.mjs +216 -0
- package/src/cli.mjs +525 -0
- package/src/contract/articles.mjs +12 -0
- package/src/contract/assert.mjs +162 -0
- package/src/contract/definition-of-done.mjs +97 -0
- package/src/contract/final-verification.mjs +96 -0
- package/src/contract/index.mjs +641 -0
- package/src/contract/judge-envelope.mjs +25 -0
- package/src/contract/review-modes.mjs +151 -0
- package/src/contract/runtime.mjs +204 -0
- package/src/contract/schema-version.mjs +25 -0
- package/src/contract/scope-findings.mjs +77 -0
- package/src/contract/snapshot.mjs +639 -0
- package/src/contract/task-packet.mjs +495 -0
- package/src/contract/untrusted.mjs +75 -0
- package/src/contract/verification.mjs +185 -0
- package/src/contract/worker-result.mjs +138 -0
- package/src/engine/assignment.mjs +63 -0
- package/src/engine/backoff.mjs +492 -0
- package/src/engine/bulk-read.mjs +361 -0
- package/src/engine/cancel.mjs +177 -0
- package/src/engine/detach.mjs +101 -0
- package/src/engine/dispatch.mjs +752 -0
- package/src/engine/failover.mjs +192 -0
- package/src/engine/gate.mjs +183 -0
- package/src/engine/judge-gate.mjs +517 -0
- package/src/engine/lifecycle.mjs +772 -0
- package/src/engine/live-preflight.mjs +299 -0
- package/src/engine/mutation.mjs +146 -0
- package/src/engine/notify-queue.mjs +327 -0
- package/src/engine/process-identity.mjs +72 -0
- package/src/engine/process.mjs +774 -0
- package/src/engine/prompts.mjs +289 -0
- package/src/engine/recover.mjs +300 -0
- package/src/engine/result-file.mjs +222 -0
- package/src/engine/resume.mjs +635 -0
- package/src/engine/retry.mjs +334 -0
- package/src/engine/review.mjs +228 -0
- package/src/engine/run-command.mjs +287 -0
- package/src/engine/run-identity.mjs +411 -0
- package/src/engine/runtime-discovery.mjs +235 -0
- package/src/engine/scheduler.mjs +526 -0
- package/src/engine/scope.mjs +378 -0
- package/src/engine/settle.mjs +207 -0
- package/src/engine/state.mjs +148 -0
- package/src/engine/supervise.mjs +713 -0
- package/src/engine/verify.mjs +167 -0
- package/src/harnesses/agy/index.mjs +62 -0
- package/src/harnesses/catalogue.mjs +509 -0
- package/src/harnesses/claude/index.mjs +90 -0
- package/src/harnesses/codex/index.mjs +87 -0
- package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
- package/src/harnesses/dsh/index.mjs +210 -0
- package/src/harnesses/dsh/runner.mjs +259 -0
- package/src/harnesses/exec-jsonl/index.mjs +788 -0
- package/src/harnesses/index.mjs +508 -0
- package/src/harnesses/protocol.mjs +531 -0
- package/src/harnesses/replay/bin.mjs +386 -0
- package/src/harnesses/replay/index.mjs +238 -0
- package/src/harnesses/zcode/index.mjs +276 -0
- package/src/host/config.mjs +87 -0
- package/src/host/home.mjs +149 -0
- package/src/host/package.mjs +23 -0
- package/src/host/preflight.mjs +520 -0
- package/src/host/tool-policy-decisions.mjs +341 -0
- package/src/host/tool-policy-hook.mjs +270 -0
- package/src/notify/index.mjs +359 -0
- package/src/notify/os-macos.mjs +81 -0
- package/src/repo/declared-paths.mjs +220 -0
- package/src/repo/integrate.mjs +546 -0
- package/src/repo/scope-closure.mjs +665 -0
- package/src/repo/signal-block.mjs +16 -0
- package/src/repo/signal.mjs +222 -0
- package/src/repo/source-identity.mjs +295 -0
- package/src/repo/workspace.mjs +557 -0
- package/src/repo/worktree.mjs +352 -0
- package/src/report/final.mjs +200 -0
- package/src/report/metrics-report.mjs +99 -0
- package/src/report/next.mjs +383 -0
- package/src/report/render.mjs +716 -0
- package/src/run/disk-gc.mjs +251 -0
- package/src/run/lock.mjs +329 -0
- package/src/run/node-store.mjs +62 -0
- package/src/run/operations.mjs +286 -0
- package/src/run/store.mjs +187 -0
- package/src/run/usage.mjs +337 -0
- package/src/seat/harnesses.mjs +83 -0
- package/src/seat/index.mjs +239 -0
- package/src/seat/tmux.mjs +208 -0
- package/src/util.mjs +0 -0
- package/src/web/api.mjs +371 -0
- package/src/web/boundary.mjs +88 -0
- package/src/web/index.html +299 -0
- 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
|
+
|