clearotron 0.4.0-beta.3 → 0.4.1-beta.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/THIRD-PARTY-NOTICES.md +2 -2
- package/build-info.json +2 -2
- package/docs/SECURITY-OWASP.md +21 -13
- package/docs/SECURITY.md +20 -14
- package/docs/architecture/04-configuration-reference.md +3 -2
- package/docs/architecture/05-config-governance.md +1 -1
- package/docs/architecture/06-operations-runbook.md +1 -1
- package/docs/architecture/09-security-and-data.md +16 -11
- package/driver/CHANGELOG.md +99 -0
- package/driver/ask-ledger.mjs +2 -2
- package/driver/binding-layers.mjs +28 -6
- package/driver/citation-census.json +3 -3
- package/driver/common-law-coverage-status.mjs +52 -0
- package/driver/common-law-receipts.mjs +1 -1
- package/driver/connotation-search.mjs +5 -5
- package/driver/contract-dictation-registry.mjs +1 -1
- package/driver/contract-e3-backlog.mjs +1 -1
- package/driver/contract-vocabulary.mjs +18 -18
- package/driver/degraded-parts.mjs +10 -3
- package/driver/dispatch-record.mjs +19 -0
- package/driver/doubt-closure-tool.mjs +2 -2
- package/driver/driver.config.mjs +39 -6
- package/driver/engagement-receipt.mjs +1 -1
- package/driver/engine/CONTRACT.md +41 -1
- package/driver/engine/anthropic-agent.mjs +56 -4
- package/driver/engine/mcp/band-server.mjs +3 -3
- package/driver/engine/mcp/codex-config.mjs +1 -1
- package/driver/engine/mcp/coverage-server.mjs +1 -1
- package/driver/engine/mcp/gather-config.mjs +1 -1
- package/driver/engine/mcp/recording-server.mjs +31 -4
- package/driver/engine/mcp/stdio-server.mjs +45 -7
- package/driver/engine/openai-agent.mjs +100 -17
- package/driver/engine/session-record.mjs +223 -0
- package/driver/enqueue-schema.mjs +2 -2
- package/driver/findings-model.mjs +3 -3
- package/driver/form-neighbourhood.mjs +7 -7
- package/driver/frame-diff-model.mjs +3 -3
- package/driver/framework.mjs +22 -0
- package/driver/gateway.mjs +121 -15
- package/driver/named-band.mjs +2 -0
- package/driver/package.json +1 -1
- package/driver/phase0.mjs +13 -2
- package/driver/pipeline-knockout.mjs +13 -3
- package/driver/pipeline.mjs +93 -38
- package/driver/portal-families.mjs +3 -3
- package/driver/portal-service.mjs +1 -1
- package/driver/predelivery-lint.mjs +19 -19
- package/driver/provider-usage.mjs +16 -0
- package/driver/publish/index.mjs +12 -6
- package/driver/publish/knockout.mjs +5 -4
- package/driver/publish/office-record-links.mjs +17 -2
- package/driver/publish/publish-inputs.mjs +1 -1
- package/driver/publish/render-knockout.mjs +17 -5
- package/driver/publish/render.mjs +4 -2
- package/driver/record-carry.mjs +1 -1
- package/driver/record-fold.mjs +177 -0
- package/driver/register-count.mjs +18 -1
- package/driver/register-plan.mjs +71 -17
- package/driver/register-records.mjs +7 -0
- package/driver/registry-fidelity.mjs +2 -2
- package/driver/repairs.mjs +1 -1
- package/driver/report-card-record.mjs +4 -4
- package/driver/reviewer-open-points.mjs +20 -23
- package/driver/scope-facts.mjs +11 -3
- package/driver/score-redaction.mjs +32 -5
- package/driver/screen-gate.mjs +3 -3
- package/driver/search-policy.mjs +20 -1
- package/driver/skills/clearance-common-law/SKILL.md +22 -22
- package/driver/skills/clearance-register/digest.md +3 -3
- package/driver/skills/clearance-register/register-recipes.md +11 -11
- package/driver/skills/clearance-register/status-rules.md +1 -1
- package/driver/skills/clearance-register/unit.md +1 -1
- package/driver/skills/clearance-search/firm-wide-reasoning.md +3 -2
- package/driver/skills/clearance-search/phase2-execution.md +6 -5
- package/driver/skills/clearance-search/synthesis-rules.md +4 -4
- package/driver/skills/clearance-search/worked-examples.md +39 -39
- package/driver/skills/clearance-variants/SKILL.md +16 -16
- package/driver/skills/frame-diff/SKILL.md +2 -2
- package/driver/stage-context.mjs +27 -1
- package/driver/stages.mjs +12 -12
- package/driver/suite-census.json +210 -42
- package/driver/synthesis-record.mjs +5 -0
- package/driver/unit-inventory.mjs +17 -28
- package/driver/verify.mjs +6 -6
- package/mcp-server/CHANGELOG.md +8 -0
- package/mcp-server/lib/evidence.mjs +8 -8
- package/mcp-server/lib/knockout.mjs +1 -1
- package/mcp-server/package.json +2 -2
- package/package.json +2 -2
- package/portal-ui/package.json +1 -1
- package/providers/_shared/count.mjs +4 -1
- package/providers/_shared/enumerate.mjs +61 -22
- package/providers/_shared/execute-plan.mjs +2 -0
- package/providers/_shared/term-shape.mjs +1 -1
- package/providers/corsearch/src/capabilities.js +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
- package/providers/oauth-mcp-bridge/package.json +2 -2
- package/providers/signa/src/capabilities.js +17 -2
- package/providers/signa/src/core.js +307 -25
- package/scripts/band-shape-probe.mjs +2 -2
- package/scripts/deprecate-below.mjs +28 -4
- package/scripts/e2e-first-time.mjs +16 -1
- package/scripts/e2e.mjs +133 -9
- package/scripts/freeze-example-run.mjs +2 -2
- package/shared/scope.mjs +18 -2
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// ── WHAT THIS CATEGORY IS FOR, AND WHY IT IS NOT A RETRIEVAL GRANT ──────────────────────────────────
|
|
6
6
|
//
|
|
7
7
|
// The two-box model conflates "may reach the outside world" with "may hand back structure"
|
|
8
|
-
// (gather-config.mjs:246-268). A stage converted to a typed return needs the second and not the first.
|
|
8
|
+
// (gather-config.mjs:246-268 RECORDING). A stage converted to a typed return needs the second and not the first.
|
|
9
9
|
// This server carries ONLY the second: it writes the calling stage's own artifact into the calling
|
|
10
10
|
// stage's own run directory, and it dials nothing.
|
|
11
11
|
//
|
|
@@ -55,7 +55,7 @@ import { PROVIDERS, REGISTER_PROVIDER } from "../../driver.config.mjs";
|
|
|
55
55
|
// already written against them; this conversion is the consumption their headers said was still owed.
|
|
56
56
|
import { recordClosures } from "../../doubt-closure-tool.mjs";
|
|
57
57
|
import { MAX_CLOSURES_PER_CALL, CLOSURE_KINDS } from "../../doubt-closure-call.mjs";
|
|
58
|
-
import { readFileSync } from "node:fs";
|
|
58
|
+
import { readFileSync, existsSync } from "node:fs"; import { FROZEN_METHOD_FILE } from "../../framework-method.mjs";
|
|
59
59
|
import { join } from "node:path";
|
|
60
60
|
import { driverDir } from "../../../shared/driver-dir.mjs"; //
|
|
61
61
|
import { VARIANT_CATEGORIES } from "../../variant-manifest-model.mjs";
|
|
@@ -398,7 +398,7 @@ serve({
|
|
|
398
398
|
item: {
|
|
399
399
|
type: "string",
|
|
400
400
|
description:
|
|
401
|
-
"For a FIRING variant directive this must be a mark-shaped search term (
|
|
401
|
+
"For a FIRING variant directive this must be a mark-shaped search term (WAVA, CORAL " +
|
|
402
402
|
"MAGIC) or carry a remedy. A label — a parenthetical, an enumeration, more than about " +
|
|
403
403
|
"four words — dispatches as a nil search that reads CLEAN, and is refused here.",
|
|
404
404
|
},
|
|
@@ -1134,6 +1134,9 @@ serve({
|
|
|
1134
1134
|
handler: record_synthesis,
|
|
1135
1135
|
}, {
|
|
1136
1136
|
name: "record_knockout_assess",
|
|
1137
|
+
// The framework's `inputs` fields are offered only on a run whose framework states a method: a session
|
|
1138
|
+
// offered a field it may not use can fill it, and the check that refuses it runs after the whole step.
|
|
1139
|
+
schemaNow: (schema) => knockoutAssessSchemaFor(schema, process.env.CLEAROTRON_BAND_RUN_DIR),
|
|
1137
1140
|
description:
|
|
1138
1141
|
"Hand back THIS CHUNK's rated assessment as VALUES. The driver serializes knockout-assess-<n>.json, " +
|
|
1139
1142
|
"so you never format JSON and never write the file. WHICH CHUNK IS NOT YOURS TO SAY — the driver " +
|
|
@@ -1392,7 +1395,7 @@ serve({
|
|
|
1392
1395
|
},
|
|
1393
1396
|
batch: {
|
|
1394
1397
|
type: "object",
|
|
1395
|
-
required: ["productContext", "inUseAs",
|
|
1398
|
+
required: ["productContext", "inUseAs"], // places are per name (ruling 570): the batch list is optional, as in the acceptor and the manual
|
|
1396
1399
|
properties: {
|
|
1397
1400
|
productContext: { type: "string", description: "One sentence: what the batch is for. Every mark's contextFraming is read against it." },
|
|
1398
1401
|
inUseAs: { type: "string", description: "The kinds of use off the register that could conflict in this client's field, as one phrase completing \"Is this name already in use as …?\". The places the batch is searched on include the sites where these uses are listed." },
|
|
@@ -1439,3 +1442,27 @@ serve({
|
|
|
1439
1442
|
handler: record_knockout_frame,
|
|
1440
1443
|
}],
|
|
1441
1444
|
});
|
|
1445
|
+
|
|
1446
|
+
/**
|
|
1447
|
+
* The knockout save tool's schema for the run this server serves. The framework's `inputs` fields are
|
|
1448
|
+
* offered only where the run's framework states a method, which is when its frozen copy
|
|
1449
|
+
* (`_driver/framework-method.json`) exists: a run with no method has none, since the mint removes it. A
|
|
1450
|
+
* session offered a field it may not use can fill it, and the check that refuses it runs only after the
|
|
1451
|
+
* whole step. With no run to read, the schema is the one declared. Read when the tools are listed.
|
|
1452
|
+
*/
|
|
1453
|
+
export function knockoutAssessSchemaFor(schema, runDir) {
|
|
1454
|
+
if (!runDir || existsSync(driverDir(String(runDir), FROZEN_METHOD_FILE))) return schema;
|
|
1455
|
+
const strip = (s) => {
|
|
1456
|
+
if (Array.isArray(s)) return s.map(strip);
|
|
1457
|
+
if (!s || typeof s !== "object") return s;
|
|
1458
|
+
const out = {};
|
|
1459
|
+
for (const [k, v] of Object.entries(s)) {
|
|
1460
|
+
if (k === "properties" && v && typeof v === "object" && !Array.isArray(v)) {
|
|
1461
|
+
out.properties = Object.fromEntries(Object.entries(v).filter(([name]) => name !== "inputs").map(([name, sub]) => [name, strip(sub)]));
|
|
1462
|
+
} else if (k === "required" && Array.isArray(v)) out.required = v.filter((name) => name !== "inputs");
|
|
1463
|
+
else out[k] = strip(v);
|
|
1464
|
+
}
|
|
1465
|
+
return out;
|
|
1466
|
+
};
|
|
1467
|
+
return strip(schema);
|
|
1468
|
+
}
|
|
@@ -115,8 +115,11 @@ export function serve({ name, version = "0.1.0", tools = [] }) {
|
|
|
115
115
|
// it must be read-only. Nothing here needs to claim a write is a read to get called, and a
|
|
116
116
|
// `readOnlyHint: true` on a tool that writes a ledger would be a lie told to a sandbox for
|
|
117
117
|
// convenience — the exact thing annotations exist to prevent.
|
|
118
|
-
|
|
119
|
-
|
|
118
|
+
// Listed when asked, not at start: a tool may give the schema for the run it serves (`schemaNow`), read
|
|
119
|
+
// at call time like every per-run read here. A tool without one lists its declared schema, as before.
|
|
120
|
+
const list = () => tools.map((t) => ({
|
|
121
|
+
name: t.name, description: t.description,
|
|
122
|
+
inputSchema: typeof t.schemaNow === "function" ? t.schemaNow(t.inputSchema) : t.inputSchema,
|
|
120
123
|
...(t.annotations ? { annotations: t.annotations } : {}),
|
|
121
124
|
}));
|
|
122
125
|
const send = (m) => process.stdout.write(JSON.stringify(m) + "\n");
|
|
@@ -137,7 +140,7 @@ export function serve({ name, version = "0.1.0", tools = [] }) {
|
|
|
137
140
|
} else if (method === "ping") {
|
|
138
141
|
ok(id, {});
|
|
139
142
|
} else if (method === "tools/list") {
|
|
140
|
-
ok(id, { tools: list });
|
|
143
|
+
ok(id, { tools: list() });
|
|
141
144
|
} else if (method === "tools/call") {
|
|
142
145
|
const tool = byName.get(params?.name);
|
|
143
146
|
if (!tool) return ok(id, { isError: true, content: [{ type: "text", text: `unknown tool: ${params?.name}` }] });
|
|
@@ -167,7 +170,7 @@ export function serve({ name, version = "0.1.0", tools = [] }) {
|
|
|
167
170
|
// interpolate a mark, and this row's own contract is the call and never its content. A row that
|
|
168
171
|
// copied the message would leak the day one of them does; a row that copies `unmet` cannot.
|
|
169
172
|
logToolEvent({ event: "settled", seq, server: name, tool: tool.name, ...(axis ? { axis } : {}), ok: false,
|
|
170
|
-
reason: `missing_required:${unmet.join(",")}` });
|
|
173
|
+
failure: "schema", reason: `missing_required:${unmet.join(",")}` });
|
|
171
174
|
return ok(id, { isError: true, content: [{ type: "text", text:
|
|
172
175
|
`${tool.name}_missing_required:${unmet.join(",")} — the schema you were handed declares `
|
|
173
176
|
+ `${unmet.length === 1 ? "this field" : "these fields"} REQUIRED and the call did not carry `
|
|
@@ -177,13 +180,19 @@ export function serve({ name, version = "0.1.0", tools = [] }) {
|
|
|
177
180
|
try { out = await tool.handler(params?.arguments || {}); }
|
|
178
181
|
catch (e) {
|
|
179
182
|
// A THROWN tool error SETTLES. It returned an answer — a wrong one, but the model saw it and
|
|
180
|
-
// could act on it. Recording it as unsettled would attribute a live failure to a timeout.
|
|
181
|
-
|
|
183
|
+
// could act on it. Recording it as unsettled would attribute a live failure to a timeout. The
|
|
184
|
+
// row names the error's class and never its message, which can carry whatever the tool was given.
|
|
185
|
+
logToolEvent({ event: "settled", seq, server: name, tool: tool.name, ...(axis ? { axis } : {}), ok: false,
|
|
186
|
+
failure: "threw", reason: `threw:${errorClass(e)}` });
|
|
182
187
|
return ok(id, { isError: true, content: [{ type: "text", text: `${tool.name} error: ${e?.message ?? e}` }] });
|
|
183
188
|
}
|
|
184
189
|
const isError = typeof out === "object" && out?.isError === true;
|
|
185
190
|
const text = typeof out === "string" ? out : (out?.text ?? JSON.stringify(out));
|
|
186
|
-
|
|
191
|
+
// A tool that answered with an error rejected the call itself, after the schema let it past. The row
|
|
192
|
+
// says so, and carries the complaint's own code when the text opens with one, never the text.
|
|
193
|
+
const code = isError ? complaintCode(text) : null;
|
|
194
|
+
logToolEvent({ event: "settled", seq, server: name, tool: tool.name, ...(axis ? { axis } : {}), ok: !isError,
|
|
195
|
+
...(isError ? { failure: "server", ...(code ? { reason: code } : {}) } : {}) });
|
|
187
196
|
ok(id, { isError: isError || undefined, content: [{ type: "text", text }] });
|
|
188
197
|
} else if (id != null) {
|
|
189
198
|
err(id, -32601, `method not found: ${method}`);
|
|
@@ -205,3 +214,32 @@ export function serve({ name, version = "0.1.0", tools = [] }) {
|
|
|
205
214
|
// so `claude --mcp-config` reports a connected server whose tool calls return a clear isError.
|
|
206
215
|
process.on("uncaughtException", (e) => { process.stderr.write(`[mcp:${name}] ${e?.stack ?? e}\n`); });
|
|
207
216
|
}
|
|
217
|
+
|
|
218
|
+
// ── WHAT REFUSED A CALL, as the settled row names it ───────────────────────────────────────────────────
|
|
219
|
+
//
|
|
220
|
+
// (At the end of this file so that adding it moved no line another file cites.)
|
|
221
|
+
//
|
|
222
|
+
// A settled row with `ok: false` carries `failure`, one of three words for where the call stopped:
|
|
223
|
+
// schema the call did not carry a field the tool's schema requires; refused before the tool ran
|
|
224
|
+
// threw the tool ran and threw
|
|
225
|
+
// server the tool ran and answered with an error of its own
|
|
226
|
+
// So a call refused before its tool ever ran is told apart from one the tool rejected without knowing
|
|
227
|
+
// that the first leaves no call file behind. `reason` is never the message: a message can quote what the
|
|
228
|
+
// tool was given. It is the missing fields, the error's class, or the complaint's own code.
|
|
229
|
+
|
|
230
|
+
/** An error's class name, never its message. PURE. */
|
|
231
|
+
export function errorClass(e) {
|
|
232
|
+
const n = typeof e?.name === "string" && /^[A-Za-z][A-Za-z0-9]*$/.test(e.name) ? e.name : "";
|
|
233
|
+
return n || "Error";
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The code a tool's complaint opens with, as the acceptors write them (`knockoutframe_classes_plain: …`,
|
|
238
|
+
* `judgment_record_not_held:1:…`): a lower-case word with at least one underscore, read up to the first
|
|
239
|
+
* character that is not part of it. `null` when the text opens any other way, so prose never reaches the
|
|
240
|
+
* row. PURE.
|
|
241
|
+
*/
|
|
242
|
+
export function complaintCode(text) {
|
|
243
|
+
const m = /^([a-z][a-z0-9]*(?:_[a-z0-9]+)+)(?=$|[^a-z0-9_])/.exec(String(text ?? ""));
|
|
244
|
+
return m ? m[1] : null;
|
|
245
|
+
}
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
import { mkdtempSync, mkdirSync, writeFileSync, copyFileSync, existsSync, rmSync, readFileSync, readdirSync, statSync, symlinkSync, lstatSync, realpathSync } from "node:fs";
|
|
26
26
|
import { everyToolCallRefused } from "./tool-refusal.mjs";
|
|
27
|
+
import { newSessionRecord, noteCodexEvent, sessionSummary, countTool, streamSink, codexEnding } from "./session-record.mjs"; // what the session went through, beside how it ended
|
|
27
28
|
import { writeSecretFile } from "../../shared/secret-file.mjs"; // the rotated login goes back the way every credential is written
|
|
28
29
|
import { tmpdir, homedir } from "node:os";
|
|
29
30
|
import { join, dirname, sep } from "node:path";
|
|
@@ -38,11 +39,28 @@ import { resolveEngineProgram } from "../driver.config.mjs"; // — the one pl
|
|
|
38
39
|
const codexBin = () => { const r = resolveEngineProgram("openai-agent"); return r.resolved ?? r.bin; };
|
|
39
40
|
|
|
40
41
|
// tier/alias → codex `-m` model id. opus/sonnet/haiku are the driver's abstract tiers (CONTRACT §3). The
|
|
41
|
-
// GPT ids are ENV-OVERRIDABLE and default to
|
|
42
|
-
//
|
|
42
|
+
// GPT ids are ENV-OVERRIDABLE and default to the vendor's current models — `gpt-6.1-sol` for the judgment
|
|
43
|
+
// and sweep tiers, `gpt-6-luna` for the cheap tier (the GPT-6 ladder, below) — so an out-of-the-box run
|
|
43
44
|
// uses REAL, current ids rather than invented guesses, and a stage's declared tier means the same thing
|
|
44
|
-
// on both engines.
|
|
45
|
-
// `model: gpt-5.6-sol,
|
|
45
|
+
// on both engines. Until 2026-10-09 they were `gpt-5.6-sol`, `gpt-5.6-terra` and `gpt-5.6-luna`; `sol`
|
|
46
|
+
// was what codex itself reported as its own default (live-probed 2026-07-27: `model: gpt-5.6-sol,
|
|
47
|
+
// provider: openai`), which is why the judgment tier kept it.
|
|
48
|
+
//
|
|
49
|
+
// ── THE GPT-6 LADDER (ruling 2026-10-09) ────────────────────────────────────────────────────────────────
|
|
50
|
+
//
|
|
51
|
+
// The owner asked for the latest models on both engines, cheaper and faster. The vendor's price list,
|
|
52
|
+
// read 2026-10-09 (standard tier, per million tokens in / out): `gpt-6.1-sol` $2 / $10, `gpt-6-luna`
|
|
53
|
+
// $0.10 / $0.50, `gpt-6-astra` $10 / $50. The 5.6 ladder it replaces was sol $4 / $20, terra $2 / $12,
|
|
54
|
+
// luna $0.20 / $1.20. There is no GPT-6 middle model and Sol 6.1 costs less than Terra 5.6, so the sweep
|
|
55
|
+
// tier takes Sol as well; Astra is the premium model, neither cheaper nor faster, and is not a rung.
|
|
56
|
+
//
|
|
57
|
+
// THE CODEX FLOOR MOVED WITH IT. On ChatGPT-account sign-in (subscription billing) codex 0.156.1, 0.157.0
|
|
58
|
+
// and 0.158.0 refuse `gpt-6.1-sol` with a 400 — "not supported when using Codex with a ChatGPT account" —
|
|
59
|
+
// and 0.159.0 to 0.162.0 accept it; `gpt-6-luna` is accepted from 0.156.1. Measured 2026-10-09 with
|
|
60
|
+
// one-word turns on the test box. Below 0.159.0 the judgment tier stops at its first stage, so the floor
|
|
61
|
+
// in ENGINE_BINARIES (driver.config.mjs) is 0.159.0.
|
|
62
|
+
//
|
|
63
|
+
// The August measurement below still names the symptom to watch for when a tier moves to a cheaper model.
|
|
46
64
|
//
|
|
47
65
|
// ── THE THREE TIERS MAP ONTO THE CODEX LADDER (ruling 2026-09-02,) ──────────────────────────
|
|
48
66
|
//
|
|
@@ -89,9 +107,9 @@ const codexBin = () => { const r = resolveEngineProgram("openai-agent"); return
|
|
|
89
107
|
// runs SOMETHING is worse than one that refuses: the arm still produces a number and the number is a
|
|
90
108
|
// story. They fall through to the throw below, which is where they always should have been. Concrete
|
|
91
109
|
// gpt-*/o* ids still pass through.
|
|
92
|
-
const JUDGMENT = process.env.CLEAROTRON_OPENAI_MODEL_JUDGMENT || "gpt-
|
|
93
|
-
const SWEEP = process.env.CLEAROTRON_OPENAI_MODEL_SWEEP || "gpt-
|
|
94
|
-
const CHEAP = process.env.CLEAROTRON_OPENAI_MODEL_CHEAP || "gpt-
|
|
110
|
+
const JUDGMENT = process.env.CLEAROTRON_OPENAI_MODEL_JUDGMENT || "gpt-6.1-sol"; // opus — was gpt-5.6-sol
|
|
111
|
+
const SWEEP = process.env.CLEAROTRON_OPENAI_MODEL_SWEEP || "gpt-6.1-sol"; // sonnet — was gpt-5.6-terra
|
|
112
|
+
const CHEAP = process.env.CLEAROTRON_OPENAI_MODEL_CHEAP || "gpt-6-luna"; // haiku — was gpt-5.6-luna
|
|
95
113
|
const OPENAI_MODEL = { opus: JUDGMENT, sonnet: SWEEP, haiku: CHEAP };
|
|
96
114
|
export function openaiModel(model) {
|
|
97
115
|
if (!model) return undefined;
|
|
@@ -311,8 +329,10 @@ export function parseCodexEvent(line, ev) {
|
|
|
311
329
|
switch (e?.type) {
|
|
312
330
|
case "thread.started": if (e.thread_id) ev.threadId = e.thread_id; break;
|
|
313
331
|
case "turn.completed": ev.turnCompleted = true; if (e.usage) ev.usage = e.usage; break;
|
|
314
|
-
|
|
315
|
-
|
|
332
|
+
// The program's own words are kept apart from the placeholders, so the record never shows a word of
|
|
333
|
+
// ours where it said nothing (session-record.mjs, codexEnding).
|
|
334
|
+
case "turn.failed": ev.turnFailed = e.error?.message || "turn.failed"; ev.turnFailedText = typeof e.error?.message === "string" ? e.error.message : null; break;
|
|
335
|
+
case "error": ev.streamError = e.message || "stream error"; ev.streamErrorText = typeof e.message === "string" ? e.message : null; break;
|
|
316
336
|
case "item.completed":
|
|
317
337
|
if (e.item?.type === "agent_message" && typeof e.item.text === "string") ev.agentText = e.item.text;
|
|
318
338
|
// A FILE CHANGE CODEX ITSELF REPORTS AS FAILED. Measured on codex-cli 0.156.1 with its sandbox unable
|
|
@@ -330,6 +350,7 @@ export function parseCodexEvent(line, ev) {
|
|
|
330
350
|
ev.commandFailures.push({ command: String(e.item.command ?? ""), output: String(e.item.aggregated_output ?? "") });
|
|
331
351
|
}
|
|
332
352
|
noteMcpToolCall(e.item, ev);
|
|
353
|
+
noteToolItem(e.item, ev);
|
|
333
354
|
break;
|
|
334
355
|
// ── — AN MCP CALL THAT WAS REFUSED IS NOT A CALL NOBODY MADE ──────────
|
|
335
356
|
//
|
|
@@ -352,6 +373,7 @@ export function parseCodexEvent(line, ev) {
|
|
|
352
373
|
// would double every call.
|
|
353
374
|
case "item.started":
|
|
354
375
|
noteMcpToolCall(e.item, ev);
|
|
376
|
+
noteToolItem(e.item, ev);
|
|
355
377
|
break;
|
|
356
378
|
default: break;
|
|
357
379
|
}
|
|
@@ -377,6 +399,47 @@ export function noteMcpToolCall(item, ev) {
|
|
|
377
399
|
});
|
|
378
400
|
}
|
|
379
401
|
|
|
402
|
+
// The item kinds codex reports a tool call as, besides its MCP calls. Its own words, measured on
|
|
403
|
+
// codex-cli 0.150–0.158: a shell command, a file edit, a web search.
|
|
404
|
+
const TOOL_ITEM_KINDS = new Set(["command_execution", "file_change", "web_search"]);
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Remember one tool-shaped item by id, for the per-name count. Codex writes each item twice (started,
|
|
408
|
+
* then its terminal state) under one id, so the id is the unit, exactly as for the MCP gauge.
|
|
409
|
+
*/
|
|
410
|
+
export function noteToolItem(item, ev) {
|
|
411
|
+
if (!item?.id) return;
|
|
412
|
+
const name = item.type === "mcp_tool_call" ? `mcp__${item.server ?? "?"}__${item.tool ?? "?"}`
|
|
413
|
+
: TOOL_ITEM_KINDS.has(item.type) ? item.type : null;
|
|
414
|
+
if (!name) return;
|
|
415
|
+
(ev.toolItems ??= new Map()).set(item.id, name);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* The turn's tool calls by name, in the shape anthropic-agent records: an MCP call under
|
|
420
|
+
* `mcp__<server>__<tool>`, and codex's own tool items under its own word for them. Counted once per item
|
|
421
|
+
* id. An OBJECT is a measurement (`{}` = none observed).
|
|
422
|
+
*/
|
|
423
|
+
export function codexToolCallsByName(ev) {
|
|
424
|
+
const byName = Object.create(null);
|
|
425
|
+
for (const name of ev?.toolItems?.values() ?? []) countTool(byName, name);
|
|
426
|
+
return { ...byName };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* The tool-server calls of this turn that came back as an error, by tool name, in the shape
|
|
431
|
+
* anthropic-agent records: a call that reached its server and `failed` there, which codex writes with no
|
|
432
|
+
* `error.message` (a call codex refused itself carries one, and `toolCallsRefused` counts those). Counted
|
|
433
|
+
* once per item id. An OBJECT is a measurement (`{}`: none). PURE.
|
|
434
|
+
*/
|
|
435
|
+
export function codexToolServerErrors(ev) {
|
|
436
|
+
const byName = Object.create(null);
|
|
437
|
+
for (const c of ev?.mcpCalls?.values() ?? []) {
|
|
438
|
+
if (c.status === "failed" && !c.message) countTool(byName, `mcp__${c.server ?? "?"}__${c.tool ?? "?"}`);
|
|
439
|
+
}
|
|
440
|
+
return { ...byName };
|
|
441
|
+
}
|
|
442
|
+
|
|
380
443
|
/**
|
|
381
444
|
* The MCP tool-call gauge for this turn: what completed, what was REFUSED, and why.
|
|
382
445
|
*
|
|
@@ -540,11 +603,16 @@ function settleTuple({ r, ev, resumeRef }) {
|
|
|
540
603
|
const failed = overflow || killed || !ev.turnCompleted || !!ev.turnFailed;
|
|
541
604
|
const usage = mapUsage(ev.usage);
|
|
542
605
|
const text = ev.agentText || "";
|
|
543
|
-
const json =
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
606
|
+
const json = {
|
|
607
|
+
...buildEnvelope({
|
|
608
|
+
text, ok: !failed, killed, usage,
|
|
609
|
+
summary: ev.turnFailed ? "failed" : (ev.turnCompleted ? "success" : undefined),
|
|
610
|
+
runId: ev.threadId,
|
|
611
|
+
}),
|
|
612
|
+
// What ended the turn, in the program's own words when it failed (session-record.mjs, codexEnding):
|
|
613
|
+
// the same three fields the Claude adapter writes. Recorded only; `ok` above is unchanged.
|
|
614
|
+
...codexEnding(ev),
|
|
615
|
+
};
|
|
548
616
|
const stderrOut = overflow
|
|
549
617
|
? (r.stderr + `\nopenai-agent output overflow: stdout/stderr exceeded ${r.maxBuffer} chars — killed the tree and failed the turn (truncated tail never parsed)`)
|
|
550
618
|
: (stallKill ? (r.stderr + "\nrequest timed out (openai-agent stall-watchdog: 0 streamed tokens)") : r.stderr);
|
|
@@ -589,6 +657,10 @@ function settleTuple({ r, ev, resumeRef }) {
|
|
|
589
657
|
// adapter reads its commands only for failures (below), so it reports no command-tool count: null, never zero.
|
|
590
658
|
toolCallsRefused: mcpToolGauge(ev).mcpToolCallsRefused,
|
|
591
659
|
commandToolCalls: null,
|
|
660
|
+
// The per-name split of the calls codex reports (see codexToolCallsByName), what the session went
|
|
661
|
+
// through (session-record.mjs: every turn ending and stream error, in order), and the raw stream.
|
|
662
|
+
toolCallsByName: codexToolCallsByName(ev), toolCallsErroredByName: codexToolServerErrors(ev),
|
|
663
|
+
session: sessionSummary(ev.session), stream: ev.stream ?? null,
|
|
592
664
|
writesFailed: ev.writesFailed ?? 0,
|
|
593
665
|
commandsFailed: ev.commandsFailed ?? 0,
|
|
594
666
|
commandFailures: ev.commandFailures ?? [],
|
|
@@ -624,6 +696,8 @@ function errResult(t0, e, resumeRef) {
|
|
|
624
696
|
code: 1, killed: false, wall: (Date.now() - t0) / 1000, stdout: "",
|
|
625
697
|
stderr: `openai-agent error: ${e?.message ?? e}`, laneWaitMs: 0,
|
|
626
698
|
json: null, usage: null, modelWire: null, sessionRef: resumeRef ?? null,
|
|
699
|
+
// No session ran: nothing to have been interrupted, no stream.
|
|
700
|
+
toolCallsByName: null, toolCallsErroredByName: null, session: null, stream: null,
|
|
627
701
|
};
|
|
628
702
|
}
|
|
629
703
|
|
|
@@ -652,7 +726,7 @@ export const openaiAgentEngine = {
|
|
|
652
726
|
// `thread/resume failed: no rollout found for thread id ... (code -32600)`, exit 1 — with the control
|
|
653
727
|
// (same id, same binary, its own home) resuming cleanly. So every warm-patch retry and every
|
|
654
728
|
// form-repair sub-turn on the codex arm has been failing.
|
|
655
|
-
async runTurn({ message, model, thinking, timeoutSec, resumeRef, mcpConfig, allowedTools, cwd, skillsDir, skillsGrantRoots, profilesDir, resolveSkill, runDir, stallSec, codexHome: providedHome } = {}) {
|
|
729
|
+
async runTurn({ message, model, thinking, timeoutSec, resumeRef, mcpConfig, allowedTools, cwd, skillsDir, skillsGrantRoots, profilesDir, resolveSkill, runDir, stallSec, codexHome: providedHome, streamFile = null } = {}) {
|
|
656
730
|
if (!message) throw new Error("openai-agent.runTurn: message is required");
|
|
657
731
|
const t0 = Date.now();
|
|
658
732
|
let codexHome;
|
|
@@ -703,16 +777,25 @@ export const openaiAgentEngine = {
|
|
|
703
777
|
// Stamped HERE, not at function entry: everything above is file writes that can take a moment, and
|
|
704
778
|
// a floor set too early would let a previous turn's rollout back in on a shared home.
|
|
705
779
|
const spawnedAtMs = Date.now();
|
|
706
|
-
const ev = { threadId: null, usage: null, agentText: "", turnCompleted: false, turnFailed: null, streamError: null, model: null, mcpCalls: new Map()
|
|
780
|
+
const ev = { threadId: null, usage: null, agentText: "", turnCompleted: false, turnFailed: null, streamError: null, model: null, mcpCalls: new Map(),
|
|
781
|
+
session: newSessionRecord() };
|
|
782
|
+
// The raw stream, one file per session beside its dispatch; every line codex wrote on stdout.
|
|
783
|
+
const sink = streamSink(streamFile);
|
|
707
784
|
const r = await runStreamingChild({
|
|
708
785
|
bin: codexBin(), args, input, runDir,
|
|
709
786
|
// — the run dir, not a shared tmpdir. codex makes cwd a workspace root, writable under the
|
|
710
787
|
// stage's profile, so this tightens the writable surface onto the run rather than widening it.
|
|
711
788
|
cwd: resolveSpawnCwd({ cwd, runDir }),
|
|
712
789
|
env, stallSec, timeoutSec,
|
|
713
|
-
onStdoutLine: (line) =>
|
|
790
|
+
onStdoutLine: (line) => {
|
|
791
|
+
sink.write(`${line}\n`);
|
|
792
|
+
parseCodexEvent(line, ev);
|
|
793
|
+
let e; try { e = JSON.parse(line); } catch { return; }
|
|
794
|
+
noteCodexEvent(ev.session, e, Date.now() - t0);
|
|
795
|
+
},
|
|
714
796
|
stderrIsLiveness: true, // codex streams progress on STDERR → it is liveness for the stall watchdog
|
|
715
797
|
});
|
|
798
|
+
ev.stream = sink.close();
|
|
716
799
|
if (r.spawnError) return errResult(t0, r.spawnError, resumeRef);
|
|
717
800
|
// The stream said no model (it never does — see readServedModel); the rollout under THIS run's
|
|
718
801
|
// CODEX_HOME did. Read here, inside the try, because the finally below deletes that dir.
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
|
|
3
|
+
// engine/session-record.mjs — what a model session went through, kept beside what it ended with.
|
|
4
|
+
//
|
|
5
|
+
// ── why this exists ──────────────────────────────────────────────────────────────────────────────────
|
|
6
|
+
//
|
|
7
|
+
// Both engines read the program's stream in memory and kept the LAST result. A session the vendor's
|
|
8
|
+
// classifier cut off mid-answer, or one the API refused and a restart recovered, ended in an ordinary
|
|
9
|
+
// successful result and was recorded as a clean session. A cut drops the tool calls that had not
|
|
10
|
+
// finished, so a stage that lost calls mid-answer looked exactly like one that finished.
|
|
11
|
+
//
|
|
12
|
+
// Measured on the bench's saved-data replays (2026-09-28, 50 Claude sessions): 23 were cut and carried
|
|
13
|
+
// on; in 8 the API then refused; one of the 8 still answered, because a background helper restarted the
|
|
14
|
+
// session and the last result was a success. The program's own words for each event, read off a saved
|
|
15
|
+
// stream with the content left out:
|
|
16
|
+
//
|
|
17
|
+
// cut a `user` event, `isSynthetic: true`, whose text block says the response "was stopped by
|
|
18
|
+
// a safety classifier"
|
|
19
|
+
// refusal a `system` event, subtype `model_refusal_no_fallback`, carrying `api_refusal_category`
|
|
20
|
+
// and `original_model`; then an `assistant` message with `stop_reason: "refusal"`
|
|
21
|
+
// restart a second `system` / `init` event in the same stream
|
|
22
|
+
// results one `result` event per answer, each with `result_index`, `is_error`, `stop_reason`,
|
|
23
|
+
// `terminal_reason` and, for one a helper's notice started, `origin.kind`
|
|
24
|
+
//
|
|
25
|
+
// So nothing here is inferred: every row is an event the program wrote, with when it arrived. The
|
|
26
|
+
// record answers "was this session interrupted, refused or restarted, and when" from the run directory,
|
|
27
|
+
// and it never decides anything — the retry policy reads the last result exactly as before.
|
|
28
|
+
//
|
|
29
|
+
// ── what is NOT recorded ─────────────────────────────────────────────────────────────────────────────
|
|
30
|
+
//
|
|
31
|
+
// No text in the session record. The cut message, the refusal's explanation and every result's answer
|
|
32
|
+
// carry the model's or the vendor's words about client matter; a row carries the event's kind, its time
|
|
33
|
+
// and its codes. The raw stream, which does carry the words, goes to its own file in the run directory
|
|
34
|
+
// (streamSink below), where the dispatch text already sits.
|
|
35
|
+
//
|
|
36
|
+
// ONE EXCEPTION, and it sits beside the record rather than in it: when a session ENDED IN AN ERROR, the
|
|
37
|
+
// program's own words for that error go on the attempt row (`errorText`, see "how the session ended"
|
|
38
|
+
// below). An expired sign-in recorded `stop_sequence` six times and nothing else, while the program had
|
|
39
|
+
// said in plain words that the session had expired. On a healthy ending that field is the model's answer
|
|
40
|
+
// and stays off the row.
|
|
41
|
+
|
|
42
|
+
import { mkdirSync, openSync, writeSync, closeSync, statfsSync } from "node:fs";
|
|
43
|
+
import { dirname } from "node:path";
|
|
44
|
+
|
|
45
|
+
/** The program's words for a classifier cut, as it writes them into the session. Matched, never quoted. */
|
|
46
|
+
export const CLASSIFIER_CUT_RE = /stopped by a safety classifier/i;
|
|
47
|
+
|
|
48
|
+
// A row list is a record, not a log: a session that is cut fifty times has said what it needs to say by
|
|
49
|
+
// the fiftieth, and the count beside the list keeps saying how many there were.
|
|
50
|
+
const ROWS_CAP = 50;
|
|
51
|
+
|
|
52
|
+
/** An empty record. `starts` counts the program's own session starts (`system`/`init`). */
|
|
53
|
+
export function newSessionRecord() {
|
|
54
|
+
return { starts: 0, results: [], classifierCuts: [], refusals: [], refusalStops: 0, dropped: 0 };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const push = (rec, list, row) => {
|
|
58
|
+
if (list.length < ROWS_CAP) list.push(row);
|
|
59
|
+
else rec.dropped += 1;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** The text a `user` event carries outside its tool results: a string, or its `text` blocks. */
|
|
63
|
+
function userText(content) {
|
|
64
|
+
if (typeof content === "string") return content;
|
|
65
|
+
if (!Array.isArray(content)) return "";
|
|
66
|
+
return content.filter((b) => b?.type === "text" && typeof b.text === "string").map((b) => b.text).join("\n");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Fold one parsed Claude stream event into the record. `atMs` is milliseconds since the spawn. PURE
|
|
71
|
+
* over its arguments; the caller owns the clock.
|
|
72
|
+
*/
|
|
73
|
+
export function noteClaudeEvent(rec, ev, atMs) {
|
|
74
|
+
if (!rec || !ev || typeof ev !== "object") return;
|
|
75
|
+
if (ev.type === "system" && ev.subtype === "init") { rec.starts += 1; return; }
|
|
76
|
+
if (ev.type === "system" && ev.subtype === "model_refusal_no_fallback") {
|
|
77
|
+
push(rec, rec.refusals, {
|
|
78
|
+
atMs, category: typeof ev.api_refusal_category === "string" ? ev.api_refusal_category : null,
|
|
79
|
+
model: typeof ev.original_model === "string" ? ev.original_model : null,
|
|
80
|
+
});
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (ev.type === "assistant") {
|
|
84
|
+
if (ev.message?.stop_reason === "refusal") rec.refusalStops += 1;
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (ev.type === "user") {
|
|
88
|
+
if (CLASSIFIER_CUT_RE.test(userText(ev.message?.content))) push(rec, rec.classifierCuts, { atMs, synthetic: ev.isSynthetic === true });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (ev.type === "result") {
|
|
92
|
+
push(rec, rec.results, {
|
|
93
|
+
atMs,
|
|
94
|
+
index: Number.isFinite(ev.result_index) ? ev.result_index : rec.results.length + rec.dropped,
|
|
95
|
+
subtype: typeof ev.subtype === "string" ? ev.subtype : null,
|
|
96
|
+
isError: ev.is_error === true,
|
|
97
|
+
stopReason: typeof ev.stop_reason === "string" ? ev.stop_reason : null,
|
|
98
|
+
terminalReason: typeof ev.terminal_reason === "string" ? ev.terminal_reason : null,
|
|
99
|
+
origin: typeof ev.origin?.kind === "string" ? ev.origin.kind : null,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Fold one parsed Codex `exec --json` event into the record. Codex states no refusal category and no
|
|
106
|
+
* classifier cut on its stream (measured, codex-cli 0.150–0.158), so what it can say is said: how the
|
|
107
|
+
* turn ended, every time it ended, and every stream error.
|
|
108
|
+
*/
|
|
109
|
+
export function noteCodexEvent(rec, ev, atMs) {
|
|
110
|
+
if (!rec || !ev || typeof ev !== "object") return;
|
|
111
|
+
if (ev.type === "thread.started") { rec.starts += 1; return; }
|
|
112
|
+
if (ev.type === "turn.completed" || ev.type === "turn.failed" || ev.type === "error") {
|
|
113
|
+
push(rec, rec.results, {
|
|
114
|
+
atMs, index: rec.results.length + rec.dropped, subtype: ev.type,
|
|
115
|
+
isError: ev.type !== "turn.completed", stopReason: null, terminalReason: null, origin: null,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** True when any result the session reached was an error, whatever the last one said. */
|
|
121
|
+
export const anyResultErrored = (rec) => Array.isArray(rec?.results) && rec.results.some((r) => r?.isError === true);
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The record as the attempt row carries it. `interrupted` is the one-word answer a reader filters on: the
|
|
125
|
+
* session was cut, refused, restarted, or reached an error result, at least once.
|
|
126
|
+
*/
|
|
127
|
+
export function sessionSummary(rec) {
|
|
128
|
+
if (!rec) return null;
|
|
129
|
+
const interrupted = rec.classifierCuts.length > 0 || rec.refusals.length > 0 || rec.refusalStops > 0
|
|
130
|
+
|| rec.starts > 1 || anyResultErrored(rec);
|
|
131
|
+
return {
|
|
132
|
+
interrupted, starts: rec.starts, results: rec.results.map((r) => ({ ...r })),
|
|
133
|
+
classifierCuts: rec.classifierCuts.map((r) => ({ ...r })), refusals: rec.refusals.map((r) => ({ ...r })),
|
|
134
|
+
refusalStops: rec.refusalStops, ...(rec.dropped ? { rowsDropped: rec.dropped } : {}),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── how the session ended, in the program's own words ──────────────────────────────────────────────────
|
|
139
|
+
//
|
|
140
|
+
// The stop reason alone misleads on an error. Measured on an expired sign-in: the Claude program reported
|
|
141
|
+
// `subtype: "success"`, `is_error: true`, `stop_reason: "stop_sequence"` (the stop of its OWN synthetic
|
|
142
|
+
// message, whose model reads `<synthetic>`) and `terminal_reason: "api_error"`, with the reason in the
|
|
143
|
+
// result text: "Failed to authenticate: OAuth session expired and could not be refreshed". Six retries of
|
|
144
|
+
// one stage journalled six identical `stop_sequence` rows and said nothing about why. The stop reason stays
|
|
145
|
+
// verbatim, because on an error it describes the program's synthetic message and rewriting it would be
|
|
146
|
+
// inventing; these three go beside it, on both engines:
|
|
147
|
+
//
|
|
148
|
+
// terminalReason the kind of ending, as the program states it. null when it states none (Codex never does).
|
|
149
|
+
// apiErrorStatus the vendor's status when it gave one. null when it did not, which is not zero.
|
|
150
|
+
// errorText the program's words for the error, ONLY when the session ended in one, cut to a length a
|
|
151
|
+
// person reads. Never a word of ours in their place: an error that carried none reads null.
|
|
152
|
+
//
|
|
153
|
+
// The verdict does not move: `fail`, the retry ladder and the envelope's status read exactly what they
|
|
154
|
+
// read before. These record; they decide nothing.
|
|
155
|
+
export const ERROR_TEXT_CAP = 300;
|
|
156
|
+
|
|
157
|
+
const words = (t) => (typeof t === "string" && t.trim() ? t.slice(0, ERROR_TEXT_CAP) : null);
|
|
158
|
+
|
|
159
|
+
/** Claude: from the session's LAST result event (the one the verdict reads), or nulls when none arrived. */
|
|
160
|
+
export function claudeEnding(resultEvent) {
|
|
161
|
+
const r = resultEvent && typeof resultEvent === "object" ? resultEvent : null;
|
|
162
|
+
return {
|
|
163
|
+
terminalReason: typeof r?.terminal_reason === "string" ? r.terminal_reason : null,
|
|
164
|
+
apiErrorStatus: r?.api_error_status ?? null,
|
|
165
|
+
errorText: r?.is_error === true ? words(r.result) : null,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Codex: no terminal kind and no status on its stream. Its words are the failed turn's own message, or the
|
|
171
|
+
* stream error when the turn never completed; an `error` it recovered from (a reconnect, then a completed
|
|
172
|
+
* turn) is not why it ended. Reads the raw messages `parseCodexEvent` keeps, never its placeholders.
|
|
173
|
+
*/
|
|
174
|
+
export function codexEnding(ev) {
|
|
175
|
+
const failed = ev?.turnFailedText ?? null, stream = ev?.streamErrorText ?? null;
|
|
176
|
+
const said = ev?.turnFailed ? words(failed) : (ev?.turnCompleted ? null : words(stream));
|
|
177
|
+
return { terminalReason: null, apiErrorStatus: null, errorText: said };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The three as an attempt row carries them: written every time, null where the engine said nothing. */
|
|
181
|
+
export function endingFields(json) {
|
|
182
|
+
return { terminalReason: json?.terminalReason ?? null, apiErrorStatus: json?.apiErrorStatus ?? null, errorText: json?.errorText ?? null };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** A per-tool-name call count, folded one name at a time. */
|
|
186
|
+
export function countTool(byName, name) {
|
|
187
|
+
const k = String(name ?? "?") || "?";
|
|
188
|
+
byName[k] = (byName[k] ?? 0) + 1;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── the raw stream ───────────────────────────────────────────────────────────────────────────────────
|
|
192
|
+
//
|
|
193
|
+
// Every byte the program wrote on its protocol channel, one file per session, beside the dispatch that
|
|
194
|
+
// started it. Kept while the disk holding the run has room: below STREAM_FLOOR_BYTES free it is not
|
|
195
|
+
// written, and the record says so rather than leaving a file that stops halfway. A write that fails
|
|
196
|
+
// mid-session closes the file and says so too. Neither ever fails the turn.
|
|
197
|
+
export const STREAM_FLOOR_BYTES = 5 * 1024 ** 3;
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Open a sink for `file`. Returns `{ write(chunk), close() }`; `close()` returns what landed:
|
|
201
|
+
* `{ file, bytes, present: true }`, or `{ file, present: false, reason }`. A null file is a null sink.
|
|
202
|
+
*/
|
|
203
|
+
export function streamSink(file, { floorBytes = STREAM_FLOOR_BYTES, freeBytes = null } = {}) {
|
|
204
|
+
if (!file) return { write() {}, close: () => null };
|
|
205
|
+
let fd = null, bytes = 0, reason = null;
|
|
206
|
+
try {
|
|
207
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
208
|
+
const free = freeBytes ?? (() => { const s = statfsSync(dirname(file)); return Number(s.bavail) * Number(s.bsize); })();
|
|
209
|
+
if (free < floorBytes) reason = `not written: the disk holding the run had ${Math.round(free / 1024 ** 2)} MiB free, under the ${Math.round(floorBytes / 1024 ** 3)} GiB floor`;
|
|
210
|
+
else fd = openSync(file, "w", 0o600);
|
|
211
|
+
} catch (e) { reason = `not written: ${String(e?.message ?? e).slice(0, 120)}`; }
|
|
212
|
+
return {
|
|
213
|
+
write(chunk) {
|
|
214
|
+
if (fd === null) return;
|
|
215
|
+
try { bytes += writeSync(fd, typeof chunk === "string" ? chunk : Buffer.from(chunk)); }
|
|
216
|
+
catch (e) { try { closeSync(fd); } catch { /* already gone */ } fd = null; reason = `cut short after ${bytes} bytes: ${String(e?.message ?? e).slice(0, 120)}`; }
|
|
217
|
+
},
|
|
218
|
+
close() {
|
|
219
|
+
if (fd !== null) { try { closeSync(fd); } catch { /* the bytes are on disk or they are not; bytes says which */ } fd = null; }
|
|
220
|
+
return reason ? { file, present: bytes > 0, bytes, reason } : { file, present: true, bytes };
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
@@ -730,8 +730,8 @@ export function validateJob(job, { atClaim = false } = {}) {
|
|
|
730
730
|
warnings.push(...fit.warnings);
|
|
731
731
|
}
|
|
732
732
|
// platforms — marketplaces to sweep IN ADDITION to the account's own. ADDITIVE ONLY, and enforced as
|
|
733
|
-
// such where the union happens: a client's platforms are a client MANDATE (the 2026-07-18
|
|
734
|
-
//
|
|
733
|
+
// such where the union happens: a client's platforms are a client MANDATE (the 2026-07-18 Drivers
|
|
734
|
+
// Haven run searched house platforms instead of the client's), so a per-run list can widen the grid
|
|
735
735
|
// and can never shrink it. Same per-entry rules as a profile's, shared via platformEntryErrors.
|
|
736
736
|
if (job.platforms != null) {
|
|
737
737
|
if (!Array.isArray(job.platforms)) errs.push("platforms must be an array of bare store domains (e.g. [\"gnc.com\"]) — they are ADDED to the account's marketplaces, never a replacement for them");
|
|
@@ -1780,7 +1780,7 @@ function validateNet(f, ord, mode) {
|
|
|
1780
1780
|
// `findings?_` and would aim the repair turn at register-coverage-ledger.json — forbidding the model
|
|
1781
1781
|
// to rewrite the only file that could fix it.
|
|
1782
1782
|
// It reaches the wire as a validator `reason` (verify.mjs checkFindingsSibling), which is the only shape
|
|
1783
|
-
// gateway.mjs:
|
|
1783
|
+
// gateway.mjs:1200 judgeArtifacts mints as `invalid_file:<path>:<token>`; a bespoke throw would match nothing.
|
|
1784
1784
|
// The precedent is one line up the file: findings_mark_assessment_* was named plural for the same
|
|
1785
1785
|
// routing reason.
|
|
1786
1786
|
//
|
|
@@ -1942,14 +1942,14 @@ function recordUrlOrigin(value) {
|
|
|
1942
1942
|
*. `checkRecordUrlHost` asks whether a link points at the wrong register, and `recordUrlOrigin`
|
|
1943
1943
|
* returns null for anything with no host — so the host gate returns CLEAN for `#`. That is right for its
|
|
1944
1944
|
* own purpose and it means nothing in the findings path was asking whether the link is a link. A value of
|
|
1945
|
-
* `#` then composes `- Source: [EUIPO ·
|
|
1945
|
+
* `#` then composes `- Source: [EUIPO · 099999992](#)` — the delivered R5 shape is about, byte for
|
|
1946
1946
|
* byte, reached through a path nothing rejected.
|
|
1947
1947
|
*
|
|
1948
1948
|
* ABSENT IS NOT DEAD, and the distinction is the whole predicate. `null` and `""` are the sanctioned way
|
|
1949
1949
|
* to say "this provider publishes no per-record page" — validateSource documents `""` in its own message
|
|
1950
1950
|
* and renderSourceBullet composes nothing for it. Rejecting those would break the legitimate case this
|
|
1951
1951
|
* issue exists to protect. Anything else that is not an http(s) URL is a value a seat typed in place of a
|
|
1952
|
-
* link: `#`, `#details/trademarks/
|
|
1952
|
+
* link: `#`, `#details/trademarks/099999992`, a bare path, or whitespace that only looks like a value.
|
|
1953
1953
|
*
|
|
1954
1954
|
* IT ENUMERATES RATHER THAN ASKING "IS THIS AN http(s) URL", and a shipped test is why. ` an
|
|
1955
1955
|
* unparseable or non-http value is not a host claim and is left to the shape rules` deliberately ACCEPTS
|