clearotron 0.4.0 → 0.4.1-beta.1
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/bin/README.md +1 -1
- package/bin/clearotron.mjs +2 -0
- package/bin/mcp.mjs +80 -0
- package/bin/start.mjs +14 -1
- package/build-info.json +2 -2
- package/demo/README.md +1 -1
- package/docs/SECURITY-OWASP.md +21 -13
- package/docs/SECURITY.md +20 -14
- package/docs/architecture/04-configuration-reference.md +3 -3
- package/docs/architecture/05-config-governance.md +1 -1
- package/docs/architecture/06-operations-runbook.md +5 -4
- package/docs/architecture/08-development-guide.md +3 -1
- package/docs/architecture/09-security-and-data.md +16 -11
- package/driver/CHANGELOG.md +29 -0
- 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/compare.mjs +1 -1
- package/driver/connotation-search.mjs +5 -5
- package/driver/contract-audit.mjs +6 -6
- package/driver/contract-dictation-registry.mjs +1 -1
- package/driver/contract-e3-backlog.mjs +18 -17
- package/driver/contract-vocabulary.mjs +145 -132
- package/driver/declination-call.mjs +5 -1
- package/driver/degraded-parts.mjs +10 -3
- package/driver/dev-portal.mjs +2 -1
- 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/codex-config.mjs +8 -3
- 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 +32 -5
- package/driver/engine/mcp/stdio-server.mjs +45 -7
- package/driver/engine/mcp/supplemental.mjs +26 -1
- package/driver/engine/openai-agent.mjs +113 -20
- package/driver/engine/session-record.mjs +223 -0
- package/driver/enqueue-schema.mjs +2 -2
- package/driver/findings-model.mjs +4 -4
- package/driver/flag-snapshot.mjs +2 -1
- 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 +96 -14
- package/driver/jx.mjs +1 -1
- package/driver/knockout-review-record.mjs +55 -1
- package/driver/named-band.mjs +4 -0
- package/driver/owner-screen.mjs +7 -4
- package/driver/package.json +1 -1
- package/driver/phase0.mjs +13 -2
- package/driver/pipeline-knockout.mjs +15 -13
- package/driver/pipeline.mjs +132 -48
- package/driver/portal-families.mjs +3 -3
- package/driver/portal-service.mjs +6 -5
- package/driver/predelivery-lint.mjs +19 -19
- package/driver/products.mjs +1 -1
- package/driver/profiles.mjs +4 -3
- package/driver/publish/index.mjs +1 -1
- package/driver/publish/knockout.mjs +5 -4
- package/driver/publish/office-record-links.mjs +17 -2
- package/driver/publish/publish-inputs.mjs +3 -3
- package/driver/publish/render-knockout.mjs +17 -5
- package/driver/publish/render.mjs +4 -2
- package/driver/publish/xlsx.mjs +1 -1
- package/driver/record-fold.mjs +177 -0
- package/driver/register-count.mjs +20 -3
- package/driver/register-coverage.mjs +1 -1
- package/driver/register-plan.mjs +83 -18
- 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/result-noun-fields.mjs +3 -3
- package/driver/scope-facts.mjs +11 -3
- package/driver/scope-rules.mjs +2 -2
- 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 +36 -1
- package/driver/stages-knockout.mjs +6 -5
- package/driver/stages.mjs +30 -30
- package/driver/suite-census.json +259 -55
- package/driver/synthesis-record.mjs +5 -0
- package/driver/unit-inventory.mjs +17 -28
- package/driver/verify.mjs +7 -7
- package/mcp-server/CHANGELOG.md +8 -0
- package/mcp-server/CONNECT.md +9 -3
- package/mcp-server/demo-mode.mjs +10 -0
- package/mcp-server/lib/evidence.mjs +8 -8
- package/mcp-server/lib/knockout.mjs +1 -1
- package/mcp-server/lib/runs.mjs +7 -1
- package/mcp-server/lib/scrub.mjs +1 -1
- package/mcp-server/package.json +2 -2
- package/mcp-server/packs/README.md +1 -1
- package/mcp-server/server.mjs +16 -2
- package/package.json +4 -3
- package/portal-ui/package.json +3 -3
- package/providers/_shared/count.mjs +9 -2
- package/providers/_shared/enumerate.mjs +125 -41
- package/providers/_shared/execute-plan.mjs +15 -4
- 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 +32 -2
- package/providers/signa/src/core.js +262 -36
- package/scripts/band-shape-probe.mjs +2 -2
- package/scripts/deprecate-below.mjs +28 -4
- package/scripts/e2e-first-time.mjs +17 -2
- package/scripts/env-audit.mjs +3 -2
- package/scripts/freeze-example-run.mjs +10 -10
- package/scripts/mcp-registry-listing.mjs +199 -0
- package/scripts/mint-plugin-folder.mjs +65 -0
- package/scripts/release-version.mjs +44 -0
- package/scripts/score.mjs +1 -1
- package/shared/register-floor.mjs +19 -0
- package/shared/scope.mjs +18 -2
- package/shared/trigger-lane.mjs +1 -1
- package/skills/clearotron-ops/SKILL.md +81 -24
|
@@ -57,6 +57,31 @@ const compactProposal = (p) => ({
|
|
|
57
57
|
const inPriority = (p, prio) =>
|
|
58
58
|
(Array.isArray(p?.nice_classes) ? p.nice_classes : []).some((c) => prio.has(String(c).trim()));
|
|
59
59
|
|
|
60
|
+
// A SOUND-ALIKE BATCH GOES AS LISTS where the register takes one (`phoneticOrWidth`, ruled 2026-10-09): up to
|
|
61
|
+
// that many spellings per question, within its word budget, each long enough for the ranked search. A
|
|
62
|
+
// spelling below that floor is its own question, as every name of a batch was before. Each list is one
|
|
63
|
+
// question with one receipt row, so a list that crowds or fails takes no other list down with it. PURE.
|
|
64
|
+
function splitBatch(p, names, capabilities) {
|
|
65
|
+
const { terms: _batch, ...rest } = p;
|
|
66
|
+
const single = (t) => ({ ...rest, term: t });
|
|
67
|
+
const width = Math.floor(Number(capabilities?.phoneticOrWidth));
|
|
68
|
+
if (String(p?.predicate ?? "") !== "phonetic" || !(width >= 2)) return names.map(single);
|
|
69
|
+
const floor = Number(capabilities?.rankedMinLength) || 0;
|
|
70
|
+
const budget = Number(capabilities?.listWordBudget) || Infinity;
|
|
71
|
+
const words = (t) => t.split(/\s+/).filter(Boolean).length;
|
|
72
|
+
const folded = (t) => [...t.normalize("NFKD").replace(/\p{M}/gu, "").toLowerCase().replace(/\s+/g, "")].length;
|
|
73
|
+
const out = [];
|
|
74
|
+
let list = [];
|
|
75
|
+
const close = () => { if (list.length) out.push(list.length === 1 ? single(list[0]) : { ...rest, terms: list }); list = []; };
|
|
76
|
+
for (const t of names) {
|
|
77
|
+
if (folded(t) < floor) { out.push(single(t)); continue; }
|
|
78
|
+
if (list.length >= width || list.reduce((n, x) => n + words(x), 0) + words(t) > budget) close();
|
|
79
|
+
list.push(t);
|
|
80
|
+
}
|
|
81
|
+
close();
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
60
85
|
export function mintSupplementalEntries(axis, proposals, { existingQids = new Set(), perCall = Infinity, axisMax = Infinity, existingCount = 0, capabilities = null, priorityClasses = [] } = {}) {
|
|
61
86
|
const minted = [], reused = [], rejected = [], enriched = [], narrowed = [];
|
|
62
87
|
let budget = Math.max(0, axisMax - existingCount);
|
|
@@ -82,7 +107,7 @@ export function mintSupplementalEntries(axis, proposals, { existingQids = new Se
|
|
|
82
107
|
const names = perName && Array.isArray(p?.terms) ? p.terms.map((t) => String(t ?? "").trim()).filter(Boolean) : [];
|
|
83
108
|
const splitOf = names.length > 1 && names.length <= PLAN_MAX_OR_WIDTH
|
|
84
109
|
? fingerprint({ axis, predicate: String(p?.predicate ?? "default"), names: [...names].sort(), i }) : null;
|
|
85
|
-
const byName = splitOf ?
|
|
110
|
+
const byName = splitOf ? splitBatch(p, names, capabilities) : [p ?? {}];
|
|
86
111
|
return byName.flatMap((q) => {
|
|
87
112
|
const words = perWord && Array.isArray(q?.goods_words) ? q.goods_words.map((w) => String(w ?? "").trim()).filter(Boolean) : [];
|
|
88
113
|
return words.length > 1
|
|
@@ -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,57 @@ 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 commands codex ran this turn: its `command_execution` items, counted once per item id, a failed one
|
|
431
|
+
* included, since a command that exited non-zero still ran. A NUMBER is a measurement (0: none ran). PURE.
|
|
432
|
+
*/
|
|
433
|
+
export function codexCommandCalls(ev) {
|
|
434
|
+
let n = 0;
|
|
435
|
+
for (const name of ev?.toolItems?.values() ?? []) if (name === "command_execution") n += 1;
|
|
436
|
+
return n;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* The tool-server calls of this turn that came back as an error, by tool name, in the shape
|
|
441
|
+
* anthropic-agent records: a call that reached its server and `failed` there, which codex writes with no
|
|
442
|
+
* `error.message` (a call codex refused itself carries one, and `toolCallsRefused` counts those). Counted
|
|
443
|
+
* once per item id. An OBJECT is a measurement (`{}`: none). PURE.
|
|
444
|
+
*/
|
|
445
|
+
export function codexToolServerErrors(ev) {
|
|
446
|
+
const byName = Object.create(null);
|
|
447
|
+
for (const c of ev?.mcpCalls?.values() ?? []) {
|
|
448
|
+
if (c.status === "failed" && !c.message) countTool(byName, `mcp__${c.server ?? "?"}__${c.tool ?? "?"}`);
|
|
449
|
+
}
|
|
450
|
+
return { ...byName };
|
|
451
|
+
}
|
|
452
|
+
|
|
380
453
|
/**
|
|
381
454
|
* The MCP tool-call gauge for this turn: what completed, what was REFUSED, and why.
|
|
382
455
|
*
|
|
@@ -540,11 +613,16 @@ function settleTuple({ r, ev, resumeRef }) {
|
|
|
540
613
|
const failed = overflow || killed || !ev.turnCompleted || !!ev.turnFailed;
|
|
541
614
|
const usage = mapUsage(ev.usage);
|
|
542
615
|
const text = ev.agentText || "";
|
|
543
|
-
const json =
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
616
|
+
const json = {
|
|
617
|
+
...buildEnvelope({
|
|
618
|
+
text, ok: !failed, killed, usage,
|
|
619
|
+
summary: ev.turnFailed ? "failed" : (ev.turnCompleted ? "success" : undefined),
|
|
620
|
+
runId: ev.threadId,
|
|
621
|
+
}),
|
|
622
|
+
// What ended the turn, in the program's own words when it failed (session-record.mjs, codexEnding):
|
|
623
|
+
// the same three fields the Claude adapter writes. Recorded only; `ok` above is unchanged.
|
|
624
|
+
...codexEnding(ev),
|
|
625
|
+
};
|
|
548
626
|
const stderrOut = overflow
|
|
549
627
|
? (r.stderr + `\nopenai-agent output overflow: stdout/stderr exceeded ${r.maxBuffer} chars — killed the tree and failed the turn (truncated tail never parsed)`)
|
|
550
628
|
: (stallKill ? (r.stderr + "\nrequest timed out (openai-agent stall-watchdog: 0 streamed tokens)") : r.stderr);
|
|
@@ -585,10 +663,14 @@ function settleTuple({ r, ev, resumeRef }) {
|
|
|
585
663
|
// still cannot report a whole-turn tool count, so `toolCalls` stays null, which is the house rule
|
|
586
664
|
// for "this engine does not report" rather than "it called nothing".
|
|
587
665
|
...mcpToolGauge(ev),
|
|
588
|
-
// The refused count under the name the attempt row reads on both engines. Codex keeps its shell, and
|
|
589
|
-
//
|
|
666
|
+
// The refused count under the name the attempt row reads on both engines. Codex keeps its shell, and every
|
|
667
|
+
// command it runs is a `command_execution` item: counted once per item id, failed ones included.
|
|
590
668
|
toolCallsRefused: mcpToolGauge(ev).mcpToolCallsRefused,
|
|
591
|
-
commandToolCalls:
|
|
669
|
+
commandToolCalls: codexCommandCalls(ev),
|
|
670
|
+
// The per-name split of the calls codex reports (see codexToolCallsByName), what the session went
|
|
671
|
+
// through (session-record.mjs: every turn ending and stream error, in order), and the raw stream.
|
|
672
|
+
toolCallsByName: codexToolCallsByName(ev), toolCallsErroredByName: codexToolServerErrors(ev),
|
|
673
|
+
session: sessionSummary(ev.session), stream: ev.stream ?? null,
|
|
592
674
|
writesFailed: ev.writesFailed ?? 0,
|
|
593
675
|
commandsFailed: ev.commandsFailed ?? 0,
|
|
594
676
|
commandFailures: ev.commandFailures ?? [],
|
|
@@ -624,6 +706,8 @@ function errResult(t0, e, resumeRef) {
|
|
|
624
706
|
code: 1, killed: false, wall: (Date.now() - t0) / 1000, stdout: "",
|
|
625
707
|
stderr: `openai-agent error: ${e?.message ?? e}`, laneWaitMs: 0,
|
|
626
708
|
json: null, usage: null, modelWire: null, sessionRef: resumeRef ?? null,
|
|
709
|
+
// No session ran: nothing to have been interrupted, no stream.
|
|
710
|
+
toolCallsByName: null, toolCallsErroredByName: null, session: null, stream: null,
|
|
627
711
|
};
|
|
628
712
|
}
|
|
629
713
|
|
|
@@ -652,7 +736,7 @@ export const openaiAgentEngine = {
|
|
|
652
736
|
// `thread/resume failed: no rollout found for thread id ... (code -32600)`, exit 1 — with the control
|
|
653
737
|
// (same id, same binary, its own home) resuming cleanly. So every warm-patch retry and every
|
|
654
738
|
// 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 } = {}) {
|
|
739
|
+
async runTurn({ message, model, thinking, timeoutSec, resumeRef, mcpConfig, allowedTools, cwd, skillsDir, skillsGrantRoots, profilesDir, resolveSkill, runDir, stallSec, codexHome: providedHome, streamFile = null } = {}) {
|
|
656
740
|
if (!message) throw new Error("openai-agent.runTurn: message is required");
|
|
657
741
|
const t0 = Date.now();
|
|
658
742
|
let codexHome;
|
|
@@ -703,16 +787,25 @@ export const openaiAgentEngine = {
|
|
|
703
787
|
// Stamped HERE, not at function entry: everything above is file writes that can take a moment, and
|
|
704
788
|
// a floor set too early would let a previous turn's rollout back in on a shared home.
|
|
705
789
|
const spawnedAtMs = Date.now();
|
|
706
|
-
const ev = { threadId: null, usage: null, agentText: "", turnCompleted: false, turnFailed: null, streamError: null, model: null, mcpCalls: new Map()
|
|
790
|
+
const ev = { threadId: null, usage: null, agentText: "", turnCompleted: false, turnFailed: null, streamError: null, model: null, mcpCalls: new Map(),
|
|
791
|
+
session: newSessionRecord() };
|
|
792
|
+
// The raw stream, one file per session beside its dispatch; every line codex wrote on stdout.
|
|
793
|
+
const sink = streamSink(streamFile);
|
|
707
794
|
const r = await runStreamingChild({
|
|
708
795
|
bin: codexBin(), args, input, runDir,
|
|
709
796
|
// — the run dir, not a shared tmpdir. codex makes cwd a workspace root, writable under the
|
|
710
797
|
// stage's profile, so this tightens the writable surface onto the run rather than widening it.
|
|
711
798
|
cwd: resolveSpawnCwd({ cwd, runDir }),
|
|
712
799
|
env, stallSec, timeoutSec,
|
|
713
|
-
onStdoutLine: (line) =>
|
|
800
|
+
onStdoutLine: (line) => {
|
|
801
|
+
sink.write(`${line}\n`);
|
|
802
|
+
parseCodexEvent(line, ev);
|
|
803
|
+
let e; try { e = JSON.parse(line); } catch { return; }
|
|
804
|
+
noteCodexEvent(ev.session, e, Date.now() - t0);
|
|
805
|
+
},
|
|
714
806
|
stderrIsLiveness: true, // codex streams progress on STDERR → it is liveness for the stall watchdog
|
|
715
807
|
});
|
|
808
|
+
ev.stream = sink.close();
|
|
716
809
|
if (r.spawnError) return errResult(t0, r.spawnError, resumeRef);
|
|
717
810
|
// The stream said no model (it never does — see readServedModel); the rollout under THIS run's
|
|
718
811
|
// 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
|
|
@@ -2179,7 +2179,7 @@ export function projectCoverageJudgment(cj) {
|
|
|
2179
2179
|
const rows = Array.isArray(cj.rows) ? cj.rows : [];
|
|
2180
2180
|
if (!rows.length) return cj;
|
|
2181
2181
|
// — the READER's name leads, and falls back to the identifier when the driver had nothing to
|
|
2182
|
-
// add.
|
|
2182
|
+
// add. renderHtml receives this string and leaves it off report.html, so no page prints it; the label leads anyway.
|
|
2183
2183
|
const shown = rows.slice(0, COVERAGE_JUDGMENT_ROW_CAP);
|
|
2184
2184
|
const hidden = rows.length - shown.length;
|
|
2185
2185
|
const rowText = shown.map((r) => `${trimDot(r.areaLabel || r.area)}: ${trimDot(r.note)}`).join("; ");
|
package/driver/flag-snapshot.mjs
CHANGED
|
@@ -425,7 +425,8 @@ export function registerCanCountFor(snapshot) {
|
|
|
425
425
|
*
|
|
426
426
|
* The trap is `covered ?? []`, which turns both "unrestricted" and "unknown" into "nothing" and offers
|
|
427
427
|
* a client zero territories on a production deployment. It is written down at
|
|
428
|
-
* providers/corsearch/src/capabilities.js
|
|
428
|
+
* the `covered` entry of CAPABILITIES in providers/corsearch/src/capabilities.js as well: "Never read as
|
|
429
|
+
* 'covers nothing'".
|
|
429
430
|
*/
|
|
430
431
|
export function registerTerritoriesFor(snapshot) {
|
|
431
432
|
if (!snapshot?.register || !("territories" in snapshot.register)) return undefined;
|
|
@@ -288,8 +288,8 @@ export function formNeighbourhood(element, { markets = [], scripts = SUPPORTED_S
|
|
|
288
288
|
|
|
289
289
|
// exactQueries = the strings dispatched as OR-stacked exact/near name searches (deduped, original excluded).
|
|
290
290
|
//
|
|
291
|
-
// Deduped ON THE DIACRITIC FOLD, not the raw string. The registers fold diacritics, so `
|
|
292
|
-
// `
|
|
291
|
+
// Deduped ON THE DIACRITIC FOLD, not the raw string. The registers fold diacritics, so `havensidè`,
|
|
292
|
+
// `havensidé`, `havensidê`, `havensidë` and `havenšide` are ONE query, not five — and one already covered by the
|
|
293
293
|
// element itself. Drivers Haven 2026-07-17 dispatched exactly those five as distinct terms; each returned
|
|
294
294
|
// the same 424 hits, together they exhausted the run's record budget, all five stayed `unenumerated`, and
|
|
295
295
|
// `coverage_clean_unverified_incomplete:primary-sweep` then blocked delivery six times on a gate that could
|
|
@@ -399,8 +399,8 @@ export function coverageGaps(band, { dispatched = [], explained = [] } = {}) {
|
|
|
399
399
|
// `model` is the VALIDATED variant-manifest.json (variant-manifest-model.mjs). When supplied it is
|
|
400
400
|
// AUTHORITATIVE for the dominant element and the prose is used only for the formative root — because the prose
|
|
401
401
|
// parse is unsafe. `dominantElementFromManifest` captures to the first `.`/`;`/`(`/newline, so a manifest that
|
|
402
|
-
// writes "Dominant element:
|
|
403
|
-
// normalizeElement then compacts to `
|
|
402
|
+
// writes "Dominant element: AQUA — the stem a family of marks shares" yields the whole clause, which
|
|
403
|
+
// normalizeElement then compacts to `aquathestemafamilyofmarksshares`. That pseudo-element got a full
|
|
404
404
|
// exhaustive edit-1 neighbourhood: 1,736 junk exact queries on AquaPlus 2026-07-17, 4,524 on the 07-16 run.
|
|
405
405
|
// Measured 2026-07-18: 10 of 20 recent runs carried one of these. The JSON field is a validated scalar and
|
|
406
406
|
// cannot swallow a sentence.
|
|
@@ -478,7 +478,7 @@ export function renderFormNeighbourhoodJson(manifestMd, { markets = [], scripts
|
|
|
478
478
|
// line variantCompletenessGaps' per-script floor draws when it says it VALIDATES, NEVER GENERATES.
|
|
479
479
|
|
|
480
480
|
// A seed longer than this is a prose-parse artifact, not an element. The measured shape is the
|
|
481
|
-
// swallowed clause — "
|
|
481
|
+
// swallowed clause — "aquathestemafamilyofmarksshares", 31-35 characters against a 4-char element —
|
|
482
482
|
// which took a full exhaustive edit-1 neighbourhood and put 1,736 junk exact queries on the wire
|
|
483
483
|
// (AquaPlus 2026-07-17). isStemOfNamedElement already rejects that shape for the formative root by
|
|
484
484
|
// measuring it against the named elements; this is the same measure for a seed with nothing to
|
|
@@ -691,7 +691,7 @@ export function variantFloorFamilies(elements, { mark = "", droppedAxes = [], mi
|
|
|
691
691
|
// MEASURED, not assumed (2026-08-03): the register compiler's `norm` and the shared `formKey`
|
|
692
692
|
// both strip separators, so every member of this family keys IDENTICALLY to the mark and is
|
|
693
693
|
// already searched as the mark's own exact entry — which is why compileRegisterPlan does not
|
|
694
|
-
// re-dispatch them, and why doing so would be the
|
|
694
|
+
// re-dispatch them, and why doing so would be the Drivers Haven shape (five spellings, one result
|
|
695
695
|
// set, the run's record budget spent). Enumerated here because the family is genuinely
|
|
696
696
|
// mechanical and the surfaces that do NOT fold spacing — the common-law grid, the connotation
|
|
697
697
|
// sweep — are real; those consumers are NOT wired to it by this change.
|
|
@@ -794,7 +794,7 @@ export function mergeVariantFloor(floorFamilies, modelVariants, { rejectedSeeds
|
|
|
794
794
|
/**
|
|
795
795
|
* Is `root` a plausible formative root, or a prose-parse artifact?
|
|
796
796
|
*
|
|
797
|
-
* A stem is never LONGER than the element it stems from — VOLEM(5) for ZOLEMA(6),
|
|
797
|
+
* A stem is never LONGER than the element it stems from — VOLEM(5) for ZOLEMA(6), AQU(3) for AQUA(4),
|
|
798
798
|
* VELTRI(6) for VELTRIN(7). Substring containment is NOT the test: a real root can differ in its leading
|
|
799
799
|
* character (ZOLEMA → VOLEM reaches the VOLEMA family), which is exactly the widening the root exists for.
|
|
800
800
|
* The swallowed-sentence artifacts are always the element PLUS a clause — 32-36 characters against a 5-char
|
|
@@ -70,7 +70,7 @@ export function parseFrameDiff(raw) {
|
|
|
70
70
|
if (d.remedy != null) out.remedy = parseRemedy(d.remedy);
|
|
71
71
|
// ── THE ASK CONTRACT AT ASK-RAISE (charter P2e) ───────────────────────────────────────────────
|
|
72
72
|
// A firing directive is a REQUEST TO SEARCH SOMETHING. Until now the parser accepted one whose
|
|
73
|
-
// item was a display label ("
|
|
73
|
+
// item was a display label ("WAVA (famous cpg snack, …)") with no remedy, and the refusal came
|
|
74
74
|
// hours later at reopen — by which time the session that could have restated it had exited. Four
|
|
75
75
|
// of the five directives on the 2026-07-29 run died exactly there: loud (coverage rows, the clamp,
|
|
76
76
|
// the audit trail) but terminal, with the head noun of a perfect one-query term sitting in the
|
|
@@ -143,7 +143,7 @@ export function undispatchableThrow(offenders) {
|
|
|
143
143
|
* Every FIRING directive in a frame-diff document that cannot be dispatched — read LENIENTLY, from
|
|
144
144
|
* raw text or an already-parsed object. This is the enumeration the gateway hint and the pipeline's
|
|
145
145
|
* exhausted-ladder disclosure both run against the artifact ON DISK, because the fail string they
|
|
146
|
-
* would otherwise scrape is cut at 160 characters (verify.mjs
|
|
146
|
+
* would otherwise scrape is cut at 160 characters (checkJson() in verify.mjs) and cannot carry more than the
|
|
147
147
|
* first offender. Lenient on purpose: the artifact reaching these two callers has ALREADY failed
|
|
148
148
|
* validation, so it may also carry an unknown key or a bad gap flag — refusing to enumerate then
|
|
149
149
|
* would lose exactly the disclosures this exists to keep. Returns [{item, layer, severity, why}].
|
|
@@ -190,7 +190,7 @@ export function firingDirectivesLenient(rawOrDoc) {
|
|
|
190
190
|
*
|
|
191
191
|
* SCOPE IS DELIBERATELY THE VARIANT LAYER (plus any layer's own remedy):
|
|
192
192
|
* * VARIANT — the evidenced class. The directive says "search this near-form", so its item stands
|
|
193
|
-
* where a mark term stands, and a label there ("
|
|
193
|
+
* where a mark term stands, and a label there ("WAVA (famous cpg snack, …)") is the whole defect:
|
|
194
194
|
* it dispatches as a nil search that reads CLEAN, or it dies unasked at reopen.
|
|
195
195
|
* * FIELD — untouched. A class-gap directive whose item names no parseable class already has a
|
|
196
196
|
* principled ending: deriveDirectiveRemedy returns null and the directive is DISCLOSED rather
|