clearotron 0.4.1-beta.2 → 0.4.1-beta.4

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.
@@ -766,8 +766,8 @@
766
766
  "todos": 0
767
767
  },
768
768
  "a-large-grid-is-served-in-parts-under-the-tool-cap.test.mjs": {
769
- "tests": 4,
770
- "asserts": 19,
769
+ "tests": 5,
770
+ "asserts": 23,
771
771
  "skips": 0,
772
772
  "todos": 0
773
773
  },
@@ -795,6 +795,18 @@
795
795
  "skips": 0,
796
796
  "todos": 0
797
797
  },
798
+ "a-long-answer-reaches-a-gpt-model-in-parts.test.mjs": {
799
+ "tests": 5,
800
+ "asserts": 39,
801
+ "skips": 0,
802
+ "todos": 0
803
+ },
804
+ "a-long-case-law-answer-reaches-a-gpt-model-in-parts.test.mjs": {
805
+ "tests": 2,
806
+ "asserts": 7,
807
+ "skips": 0,
808
+ "todos": 0
809
+ },
798
810
  "a-mark-name-cannot-lie-about-how-it-reads.test.mjs": {
799
811
  "tests": 9,
800
812
  "asserts": 38,
@@ -969,6 +981,12 @@
969
981
  "skips": 0,
970
982
  "todos": 0
971
983
  },
984
+ "a-pin-change-asks-for-a-bench-replay.test.mjs": {
985
+ "tests": 16,
986
+ "asserts": 37,
987
+ "skips": 0,
988
+ "todos": 0
989
+ },
972
990
  "a-placement-pass-that-lost-its-answers-does-not-succeed.test.mjs": {
973
991
  "tests": 7,
974
992
  "asserts": 17,
@@ -1365,6 +1383,12 @@
1365
1383
  "skips": 0,
1366
1384
  "todos": 0
1367
1385
  },
1386
+ "a-seat-reads-the-band-page-by-page.test.mjs": {
1387
+ "tests": 4,
1388
+ "asserts": 25,
1389
+ "skips": 0,
1390
+ "todos": 0
1391
+ },
1368
1392
  "a-secret-file-is-compared-not-printed.test.mjs": {
1369
1393
  "tests": 5,
1370
1394
  "asserts": 16,
@@ -2020,8 +2044,8 @@
2020
2044
  "todos": 0
2021
2045
  },
2022
2046
  "band-server.test.mjs": {
2023
- "tests": 24,
2024
- "asserts": 126,
2047
+ "tests": 26,
2048
+ "asserts": 136,
2025
2049
  "skips": 0,
2026
2050
  "todos": 0
2027
2051
  },
@@ -3083,13 +3107,13 @@
3083
3107
  },
3084
3108
  "engine.anthropic.test.mjs": {
3085
3109
  "tests": 70,
3086
- "asserts": 260,
3087
- "skips": 1,
3110
+ "asserts": 262,
3111
+ "skips": 2,
3088
3112
  "todos": 0
3089
3113
  },
3090
3114
  "engine.codex-config.test.mjs": {
3091
- "tests": 20,
3092
- "asserts": 70,
3115
+ "tests": 23,
3116
+ "asserts": 89,
3093
3117
  "skips": 0,
3094
3118
  "todos": 0
3095
3119
  },
@@ -3849,6 +3873,12 @@
3849
3873
  "skips": 0,
3850
3874
  "todos": 0
3851
3875
  },
3876
+ "no-stage-is-offered-the-engines-own-web-search.test.mjs": {
3877
+ "tests": 4,
3878
+ "asserts": 10,
3879
+ "skips": 0,
3880
+ "todos": 0
3881
+ },
3852
3882
  "no-test-account-reaches-the-package.test.mjs": {
3853
3883
  "tests": 17,
3854
3884
  "asserts": 31,
@@ -5799,6 +5829,12 @@
5799
5829
  "skips": 0,
5800
5830
  "todos": 0
5801
5831
  },
5832
+ "the-engine-pins-come-from-the-tree.test.mjs": {
5833
+ "tests": 6,
5834
+ "asserts": 24,
5835
+ "skips": 0,
5836
+ "todos": 0
5837
+ },
5802
5838
  "the-engine-sets-no-sampling-and-forces-no-tool.test.mjs": {
5803
5839
  "tests": 6,
5804
5840
  "asserts": 9,
@@ -1,5 +1,13 @@
1
1
  # trademark-artifacts-mcp
2
2
 
3
+ ## 0.4.1-beta.4
4
+
5
+ No changes in this release.
6
+
7
+ ## 0.4.1-beta.3
8
+
9
+ No changes in this release.
10
+
3
11
  ## 0.4.1-beta.2
4
12
 
5
13
  No changes in this release.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-artifacts-mcp",
3
- "version": "0.4.1-beta.2",
3
+ "version": "0.4.1-beta.4",
4
4
  "license": "AGPL-3.0-only",
5
5
  "private": true,
6
6
  "description": "MCP server to interrogate clearotron trademark-clearance runs — list/read artifacts, trace the full decision flow, telemetry/cost, coverage, single-run search, and a gated single-step what-if. Imports the clearotron-driver read-only; touches no driver/template/deploy files.",
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "clearotron",
3
3
  "mcpName": "io.github.CordilleraSarl/clearotron",
4
4
  "type": "module",
5
- "version": "0.4.1-beta.2",
5
+ "version": "0.4.1-beta.4",
6
6
  "license": "AGPL-3.0-only",
7
7
  "repository": {
8
8
  "type": "git",
@@ -25046,8 +25046,7 @@ function engineCapabilityRows(providers) {
25046
25046
  ok: p.configured,
25047
25047
  name: splitSourceLabel(p.providerLabel ?? p.provider ?? "").name.replace(/'/g, "’"),
25048
25048
  mono: "provided by the engine",
25049
- state: p.configured ? "Available" : "Not available",
25050
- note: "The model searches the web itself during a run."
25049
+ state: p.configured ? "Available" : "Not available"
25051
25050
  }));
25052
25051
  }
25053
25052
  function providerRow(p) {
@@ -36,7 +36,7 @@
36
36
  chosen anywhere in the product holds everywhere in it.
37
37
  -->
38
38
  <script>(function(){try{var t=localStorage.getItem('cordillera-theme');if(t==='dark'||t==='light')document.documentElement.setAttribute('data-theme',t);}catch(e){}})();</script>
39
- <script type="module" crossorigin src="/portal/assets/index-BPAUjcI0.js"></script>
39
+ <script type="module" crossorigin src="/portal/assets/index-DcLbvFmQ.js"></script>
40
40
  <link rel="stylesheet" crossorigin href="/portal/assets/index-D2wrw9cH.css">
41
41
  </head>
42
42
  <body>
@@ -2,7 +2,7 @@
2
2
  "name": "portal-ui",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.4.1-beta.2",
5
+ "version": "0.4.1-beta.4",
6
6
  "license": "AGPL-3.0-only",
7
7
  "description": "The unified trademark portal UI. One address, one login: who you are decides what you see. Built as a static bundle, served by driver/portal-service.mjs — the browser never reaches profile-service or recipe-service.",
8
8
  "engines": {
@@ -24,6 +24,6 @@
24
24
  "@types/react-dom": "^19.3.0",
25
25
  "@vitejs/plugin-react": "^6.1.2",
26
26
  "typescript": "^7.0.2",
27
- "vite": "^8.3.3"
27
+ "vite": "^8.3.4"
28
28
  }
29
29
  }
@@ -1,5 +1,13 @@
1
1
  # trademark-oauth-mcp-bridge
2
2
 
3
+ ## 0.4.1-beta.4
4
+
5
+ No changes in this release.
6
+
7
+ ## 0.4.1-beta.3
8
+
9
+ No changes in this release.
10
+
3
11
  ## 0.4.1-beta.2
4
12
 
5
13
  No changes in this release.
@@ -30,6 +30,7 @@ import { appendFileSync, mkdirSync } from "node:fs";
30
30
  import { homedir } from "node:os";
31
31
  import path from "node:path";
32
32
  import { driverDir } from "../../shared/driver-dir.mjs"; //
33
+ import { answerBudget, answerPieces, servedPart, partNotHeld, takePart, heldAnswers, withAnswerPart } from "../../shared/answer-parts.mjs";
33
34
  import { RefreshLock } from "./refresh-lock.mjs";
34
35
  import { exitOnStdinClose } from "./stdin-guard.mjs";
35
36
 
@@ -327,6 +328,19 @@ async function main() {
327
328
  { capabilities: { tools: {} } },
328
329
  );
329
330
 
331
+ // A LONG ANSWER IS SERVED IN PARTS when the bridge was given an answer size (shared/answer-parts.mjs). An
332
+ // answer made of text alone is split; one carrying anything else is passed on as it came.
333
+ const budget = answerBudget();
334
+ const held = heldAnswers();
335
+ const partedResult = (tool, args, res) => {
336
+ const blocks = Array.isArray(res?.content) ? res.content : [];
337
+ if (!blocks.length || !blocks.every((c) => c?.type === "text")) return res;
338
+ const pieces = answerPieces(blocks.map((c) => String(c.text ?? "")).join("\n"), budget, tool);
339
+ if (pieces.length === 1) return res;
340
+ held.hold(tool, args, pieces);
341
+ return { content: [{ type: "text", text: servedPart(tool, pieces, 1) }] };
342
+ };
343
+
330
344
  downstream.setRequestHandler(ListToolsRequestSchema, async () => {
331
345
  const upstreamResp = await upstream.listTools();
332
346
  const upstreamTools = upstreamResp?.tools ?? [];
@@ -339,7 +353,7 @@ async function main() {
339
353
  `[oauth-bridge:${serverName}] filtered ${dropped.length} unlisted upstream tool(s) from ListTools: ${dropped.join(", ")}\n`,
340
354
  );
341
355
  }
342
- return { ...upstreamResp, tools };
356
+ return { ...upstreamResp, tools: budget ? tools.map((t) => ({ ...t, inputSchema: withAnswerPart(t.inputSchema) })) : tools };
343
357
  });
344
358
 
345
359
  downstream.setRequestHandler(CallToolRequestSchema, async (req) => {
@@ -353,12 +367,20 @@ async function main() {
353
367
  `Tool "${name}" is not in the bridge allowlist for ${serverName}. To enable it, add the unprefixed upstream name to ALLOWED_TOOLS in bridge.mjs and restart.`,
354
368
  );
355
369
  }
370
+ // A later part of a held answer is read from it; the upstream server is not asked again.
371
+ const { args, part } = budget ? takePart(req.params.arguments) : { args: req.params.arguments, part: null };
372
+ const kept = part ? held.get(name, args) : null;
373
+ if (part && (kept || part > 1)) {
374
+ logCall(serverName, name, args, kept ? { ok: true, answer_part: part, of: kept.length } : { ok: false, answer_part: part, held: false });
375
+ return kept ? { content: [{ type: "text", text: servedPart(name, kept, part) }] }
376
+ : { isError: true, content: [{ type: "text", text: partNotHeld(name, part) }] };
377
+ }
356
378
  try {
357
- const res = await upstream.callTool({ name, arguments: req.params.arguments });
379
+ const res = await upstream.callTool({ name, arguments: args });
358
380
  const refused = toolError(res);
359
381
  if (refused === null) logCall(serverName, name, req.params.arguments, { ok: true, bytes: resultBytes(res) });
360
382
  else logCall(serverName, name, req.params.arguments, { ok: false, error: refused });
361
- return res;
383
+ return budget && refused === null ? partedResult(name, args, res) : res;
362
384
  } catch (err) {
363
385
  // A FAILED CALL IS LOGGED TOO, and it is the half that matters: without it, "the search ran and
364
386
  // found nothing" and "the search errored" leave the same trace — the ambiguity is about.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-oauth-mcp-bridge",
3
- "version": "0.4.1-beta.2",
3
+ "version": "0.4.1-beta.4",
4
4
  "license": "AGPL-3.0-only",
5
5
  "private": true,
6
6
  "description": "OAuth 2.1 MCP stdio bridge used by the engine's case-law gather stage (courtlistener / legaldatahunter).",
@@ -0,0 +1,111 @@
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
+ //
4
+ // bench-replay-request.mjs — ask for a replay of saved cases when a push moved the engine pins.
5
+ //
6
+ // node scripts/bench-replay-request.mjs # in the push workflow; reads SHA, BEFORE, REPO, GH_TOKEN
7
+ //
8
+ // A change to a model or an engine program's floor changes what a clearance reads and writes while every
9
+ // test around it stays green, so it is replayed on saved cases before the beta that carries it is cut.
10
+ // This is the request: a `bench replay` status on the pushed commit, `pending` with the description
11
+ // `waiting`. The machine that holds the saved cases picks it up, replays, and sets pass or flag. Nothing
12
+ // waits for it here and nothing is blocked; the cut reads it (release-bench-replay-check.mjs).
13
+ //
14
+ // WHAT IS COMPARED. The pushed commit against the branch's previous tip (BEFORE), both judged by THIS
15
+ // commit's rule (engine-pins.mjs), so a tree from before that script existed still gets a real answer.
16
+ // A new branch has no previous tip and is compared with where it leaves main. Equal pins post nothing.
17
+ // A parent this rule cannot read posts `waiting` too: "could not tell" is never "unchanged".
18
+ //
19
+ // A SQUASH ON MAIN ASKS ONCE. When the pushed commit is the squash of a pull request whose head already
20
+ // carries a final answer for the same pins, that answer is copied rather than asked for again.
21
+ //
22
+ // The status carries the context and one word, never a link, a count or a case: it sits on a public commit.
23
+ //
24
+ // Exit 0: done (posted, copied, or nothing to ask). Exit 2: could not look.
25
+
26
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
27
+ import { execFileSync } from "node:child_process";
28
+ import { tmpdir } from "node:os";
29
+ import { join, dirname } from "node:path";
30
+ import { pinState } from "./engine-pins.mjs";
31
+ import { isEntrypoint } from "../shared/is-entrypoint.mjs";
32
+
33
+ export const CONTEXT = "bench replay";
34
+ export const PIN_FILES = ["driver/engine/openai-agent.mjs", "driver/driver.config.mjs"];
35
+ const NO_COMMIT = /^0+$/;
36
+
37
+ const git = (args, cwd) => execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
38
+
39
+ /** The pins of `commit`, judged by the rule in this checkout: its two pin files, laid into a scratch tree. */
40
+ export function pinsAt(commit, { cwd = process.cwd() } = {}) {
41
+ const dir = mkdtempSync(join(tmpdir(), "engine-pins-"));
42
+ try {
43
+ for (const rel of PIN_FILES) {
44
+ let text;
45
+ try { text = execFileSync("git", ["show", `${commit}:${rel}`], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 64 * 1024 * 1024 }); }
46
+ catch { continue; } // absent at that commit: the rule below says could-not-look, by name
47
+ mkdirSync(dirname(join(dir, rel)), { recursive: true });
48
+ writeFileSync(join(dir, rel), text);
49
+ }
50
+ return pinState(dir);
51
+ } finally { rmSync(dir, { recursive: true, force: true }); }
52
+ }
53
+
54
+ /** The latest `bench replay` state on `sha`, or null. `api(path)` answers a GitHub REST GET. */
55
+ export async function latestReplay(api, sha) {
56
+ const statuses = await api(`commits/${sha}/statuses?per_page=100`);
57
+ const mine = statuses.filter((s) => s.context === CONTEXT); // newest first, as the API lists them
58
+ return mine.length ? { state: mine[0].state, description: mine[0].description ?? "" } : null;
59
+ }
60
+
61
+ /**
62
+ * What to post on `sha`, or null for nothing. PURE apart from the injected readers, so each path is tested
63
+ * without a repository or a network: `pins(commit)` returns a pin state or throws; `squashHead()` names the
64
+ * merged pull request's head when `sha` is a squash on main, else null; `replay(sha)` is latestReplay.
65
+ */
66
+ export async function decide({ sha, parent, pins, squashHead, replay }) {
67
+ const head = pins(sha);
68
+ let before = null;
69
+ try { before = pins(parent); } catch { /* could not tell: ask */ }
70
+ if (before && before.digest === head.digest) return null;
71
+ const prHead = await squashHead();
72
+ if (prHead) {
73
+ let prPins = null;
74
+ try { prPins = pins(prHead); } catch { /* could not tell: ask */ }
75
+ const answer = prPins && prPins.digest === head.digest ? await replay(prHead) : null;
76
+ if (answer && (answer.state === "success" || answer.state === "failure")) return answer;
77
+ }
78
+ return { state: "pending", description: "waiting" };
79
+ }
80
+
81
+ if (isEntrypoint(import.meta.url)) {
82
+ const { SHA, BEFORE = "", REPO, GH_TOKEN } = process.env;
83
+ if (!SHA || !REPO || !GH_TOKEN) { process.stderr.write("bench-replay-request: SHA, REPO and GH_TOKEN are required\n"); process.exit(2); }
84
+ const api = async (path, init = {}) => {
85
+ const r = await fetch(`https://api.github.com/repos/${REPO}/${path}`, {
86
+ ...init, headers: { authorization: `Bearer ${GH_TOKEN}`, accept: "application/vnd.github+json", ...(init.headers ?? {}) } });
87
+ if (!r.ok) throw new Error(`${init.method ?? "GET"} ${path}: ${r.status}`);
88
+ return r.json();
89
+ };
90
+ try {
91
+ const parent = BEFORE && !NO_COMMIT.test(BEFORE) ? BEFORE : git(["merge-base", "origin/main", SHA]);
92
+ const decision = await decide({
93
+ sha: SHA, parent,
94
+ pins: (c) => pinsAt(c),
95
+ squashHead: async () => {
96
+ const pulls = await api(`commits/${SHA}/pulls`);
97
+ const pr = pulls.find((p) => p.merge_commit_sha === SHA && p.merged_at);
98
+ if (!pr) return null;
99
+ git(["fetch", "--quiet", "origin", `refs/pull/${pr.number}/head`]);
100
+ return pr.head.sha;
101
+ },
102
+ replay: (s) => latestReplay(api, s),
103
+ });
104
+ if (!decision) { console.log(`bench-replay-request: the engine pins did not move against ${parent.slice(0, 12)}; nothing to ask`); process.exit(0); }
105
+ await api(`statuses/${SHA}`, { method: "POST", body: JSON.stringify({ state: decision.state, context: CONTEXT, description: decision.description }) });
106
+ console.log(`bench-replay-request: ${CONTEXT} set to ${decision.state} (${decision.description}) on ${SHA.slice(0, 12)}`);
107
+ } catch (e) {
108
+ process.stderr.write(`bench-replay-request: could not look — ${e.message}\n`);
109
+ process.exit(2);
110
+ }
111
+ }
@@ -222,7 +222,7 @@ export const NOT_COUNTED_EVENTS = {
222
222
  "stale-repair-entry", "taint-disclose", "unsettled-inputs", "verdict-2", "verdict-3",
223
223
  ],
224
224
  "read from its own record: the stage records, the dispatch records and the tool-call log": [
225
- "attempt", "stage", "settled", "started",
225
+ "answer-part", "attempt", "stage", "settled", "started",
226
226
  ],
227
227
  "the portal's, the profile store's or the config store's audit log, never a run log": [
228
228
  "compose-read", "demo-order", "family-group", "family-ungroup", "other", "person-add", "person-change",
@@ -0,0 +1,117 @@
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
+ //
4
+ // engine-pins.mjs — the engine pins a tree carries: the models the Codex engine asks for, and the program
5
+ // each engine runs, with the oldest version it accepts.
6
+ //
7
+ // node scripts/engine-pins.mjs # the tree this script sits in
8
+ // node scripts/engine-pins.mjs --root <dir> # another tree, judged by this script's rule
9
+ //
10
+ // WHY IT EXISTS. A change to a model or to an engine program's floor changes what a clearance reads and
11
+ // writes, while every test of the code around it stays green. Such a change asks for a replay of saved
12
+ // cases before the beta that carries it is cut. This is the one definition of "such a change": the push
13
+ // workflow compares a commit with its parent by it, the cut compares the last published version with
14
+ // main by it, and the replay reads it to see which engine moved. A second copy of the rule would drift.
15
+ //
16
+ // WHAT COUNTS. The three Codex model defaults in driver/engine/openai-agent.mjs (JUDGMENT, SWEEP and
17
+ // CHEAP), and the `package` and `floor` of both entries in ENGINE_BINARIES in driver/driver.config.mjs.
18
+ // The Claude engine asks for a tier by name and its program resolves it, so for Claude the program's
19
+ // floor IS the model choice. Values are parsed, not lines: an edited comment or a moved line is not a
20
+ // change. Left out on purpose: the tier each stage asks for, and anything a box's settings override.
21
+ //
22
+ // IT NEVER READS THE ENVIRONMENT. Each model default sits behind a setting that overrides it on a box;
23
+ // this reports the source's default, so two machines with different settings print the same pins for one
24
+ // tree. Judging an older tree with --root applies THIS script's rule to it, which is how a tree from
25
+ // before this script existed still gets an answer.
26
+ //
27
+ // OUTPUT: one line of JSON, keys sorted at every level, no spaces:
28
+ // {"digest":"sha256:<hex>","pins":{"anthropic-agent":{"floor","package"},"openai-agent":{"floor","models":{"cheap","judgment","sweep"},"package"}},"schema":1}
29
+ // `digest` is the sha256 of `pins` alone in that same form, so equal digests are equal pins; `schema`
30
+ // sits outside it, so a change of format never reads as a change of pins.
31
+ //
32
+ // Exit 0: printed. Exit 2: could not look — a file missing, or a value not found exactly once. The reason
33
+ // goes to stderr and nothing to stdout, because a partial answer compared as a whole reads as a change or,
34
+ // worse, as none.
35
+
36
+ import { readFileSync } from "node:fs";
37
+ import { createHash } from "node:crypto";
38
+ import { join, dirname, resolve } from "node:path";
39
+ import { fileURLToPath } from "node:url";
40
+ import { isEntrypoint } from "../shared/is-entrypoint.mjs";
41
+
42
+ export const SCHEMA = 1;
43
+ const MODELS_FILE = "driver/engine/openai-agent.mjs";
44
+ const BINARIES_FILE = "driver/driver.config.mjs";
45
+ const ENGINES = ["anthropic-agent", "openai-agent"];
46
+ const CODEX_MODELS = { judgment: "JUDGMENT", sweep: "SWEEP", cheap: "CHEAP" };
47
+
48
+ class CouldNotLook extends Error {}
49
+
50
+ /** Exactly one match of `re` in `text`, or could-not-look naming `what`. */
51
+ function one(text, re, what) {
52
+ const found = [...text.matchAll(re)];
53
+ if (found.length !== 1) throw new CouldNotLook(`${what}: found ${found.length} time(s), expected exactly 1`);
54
+ return found[0];
55
+ }
56
+
57
+ /** The source with whole-line comments removed, so a commented-out value is not a value. */
58
+ const code = (text) => text.split("\n").filter((l) => !/^\s*\/\//.test(l)).join("\n");
59
+
60
+ function read(root, rel) {
61
+ try { return readFileSync(join(root, rel), "utf8"); }
62
+ catch (e) { throw new CouldNotLook(`${rel}: ${e.code ?? e.message}`); }
63
+ }
64
+
65
+ /** The pins of the tree at `root`. Throws CouldNotLook when any value cannot be read exactly once. */
66
+ export function readPins(root) {
67
+ const models = code(read(root, MODELS_FILE));
68
+ const codex = {};
69
+ for (const [key, name] of Object.entries(CODEX_MODELS)) {
70
+ const re = new RegExp(`^const ${name}\\s*=\\s*process\\.env\\.CLEAROTRON_OPENAI_MODEL_${name}\\s*\\|\\|\\s*"([^"]+)"\\s*;`, "gm");
71
+ codex[key] = one(models, re, `${MODELS_FILE} ${name}`)[1];
72
+ }
73
+ const binaries = code(read(root, BINARIES_FILE));
74
+ const table = one(binaries, /^export const ENGINE_BINARIES = \{\n([\s\S]*?)^\};/gm, `${BINARIES_FILE} ENGINE_BINARIES`)[1];
75
+ const pins = {};
76
+ for (const engine of ENGINES) {
77
+ const start = one(table, new RegExp(`^ {2}"${engine}":\\s*\\{`, "gm"), `${BINARIES_FILE} ${engine}`).index;
78
+ const rest = table.slice(start + 1);
79
+ const next = rest.search(/^ {2}"[a-z-]+":\s*\{/m);
80
+ const entry = next === -1 ? rest : rest.slice(0, next);
81
+ pins[engine] = {
82
+ package: one(entry, /\bpackage:\s*"([^"]+)"/g, `${BINARIES_FILE} ${engine} package`)[1],
83
+ floor: one(entry, /\bfloor:\s*"([^"]+)"/g, `${BINARIES_FILE} ${engine} floor`)[1],
84
+ };
85
+ }
86
+ pins["openai-agent"].models = codex;
87
+ return pins;
88
+ }
89
+
90
+ /** JSON with keys sorted at every level and no whitespace: one value, one string. */
91
+ export function canonical(v) {
92
+ if (Array.isArray(v)) return `[${v.map(canonical).join(",")}]`;
93
+ if (v && typeof v === "object") return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`).join(",")}}`;
94
+ return JSON.stringify(v);
95
+ }
96
+
97
+ export function pinState(root) {
98
+ const pins = readPins(root);
99
+ return { digest: `sha256:${createHash("sha256").update(canonical(pins)).digest("hex")}`, pins, schema: SCHEMA };
100
+ }
101
+
102
+ if (isEntrypoint(import.meta.url)) {
103
+ const args = process.argv.slice(2);
104
+ let root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
105
+ if (args.length === 2 && args[0] === "--root") root = resolve(args[1]);
106
+ else if (args.length) {
107
+ process.stderr.write("usage: node scripts/engine-pins.mjs [--root <dir>]\n");
108
+ process.exit(2);
109
+ }
110
+ try {
111
+ process.stdout.write(`${canonical(pinState(root))}\n`);
112
+ } catch (e) {
113
+ if (!(e instanceof CouldNotLook)) throw e;
114
+ process.stderr.write(`engine-pins: could not look — ${e.message}\n`);
115
+ process.exit(2);
116
+ }
117
+ }
@@ -0,0 +1,87 @@
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
+ //
4
+ // release-bench-replay-check.mjs — a cut waits while a moved engine pin has no passing bench replay.
5
+ //
6
+ // node scripts/release-bench-replay-check.mjs [--since <tag>] [--head <ref>] # reads REPO and GH_TOKEN
7
+ //
8
+ // The push workflow asks for a replay of saved cases whenever a push moves the engine pins
9
+ // (bench-replay-request.mjs); the machine holding the cases answers with a `bench replay` status, pass or
10
+ // flag. This is where that answer is read: before a beta or a stable is cut from HEAD, or from `--head`
11
+ // when the checkout is not on the commit being cut (the dispatcher reads origin/main from its own checkout).
12
+ //
13
+ // WHAT IT COMPARES. The pins at HEAD against the pins at the last version cut (`--since`, by default the
14
+ // newest `v*` tag HEAD contains), both judged by THIS checkout's rule. Equal pins: nothing to read. Moved
15
+ // pins: the commits since that tag, on main's own line, whose pins equal HEAD's are the ones that carry
16
+ // today's pins, and one of them must carry a passing `bench replay`.
17
+ //
18
+ // WHAT HOLDS THE CUT, and only one thing lets it through:
19
+ // pass on any of those commits ............ cut
20
+ // flag, and no pass ....................... held — fix it, or explain it on the change's issue and set pass by hand
21
+ // waiting, or no status at all ............ held — "not reported"; an absent answer is never a pass
22
+ // The output names the commit and the state, never a count or a case: it is printed in a public log.
23
+ //
24
+ // Exit 0: nothing moved, or a replay passed. Exit 1: held. Exit 2: could not look.
25
+
26
+ import { execFileSync } from "node:child_process";
27
+ import { pinsAt, latestReplay, CONTEXT } from "./bench-replay-request.mjs";
28
+ import { isEntrypoint } from "../shared/is-entrypoint.mjs";
29
+
30
+ const git = (args) => execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
31
+
32
+ /**
33
+ * The verdict for a cut of `head` since `since`. PURE apart from the injected readers: `pins(commit)`
34
+ * returns a pin state or throws, `line()` lists the commits since `since` on main's own line, newest first,
35
+ * and `replay(sha)` returns the latest `bench replay` state or null.
36
+ */
37
+ export async function verdict({ head, since, pins, line, replay }) {
38
+ const now = pins(head);
39
+ let then = null;
40
+ try { then = pins(since); } catch { /* could not tell: the replay must answer */ }
41
+ if (then && then.digest === now.digest) return { hold: false, why: `no engine pin moved since ${since}` };
42
+ const carrying = [];
43
+ for (const c of line()) {
44
+ let p = null;
45
+ try { p = pins(c); } catch { /* a commit the rule cannot read carries no answer */ }
46
+ if (!p || p.digest !== now.digest) break; // older than the last move: its answer was for other pins
47
+ carrying.push(c);
48
+ }
49
+ const answers = [];
50
+ for (const c of carrying) answers.push({ sha: c, answer: await replay(c) });
51
+ const pass = answers.find((a) => a.answer?.state === "success");
52
+ if (pass) return { hold: false, why: `${CONTEXT}: ${pass.answer.description || "pass"} on ${pass.sha.slice(0, 12)}` };
53
+ const flag = answers.find((a) => a.answer?.state === "failure");
54
+ if (flag) return { hold: true, why: `${CONTEXT}: flagged on ${flag.sha.slice(0, 12)} — fix it, or explain it on the change's issue and set pass by hand` };
55
+ return { hold: true, why: `${CONTEXT}: not reported for the engine pins at ${String(head).slice(0, 12)}${answers.some((a) => a.answer) ? " (still waiting)" : ""}` };
56
+ }
57
+
58
+ if (isEntrypoint(import.meta.url)) {
59
+ const { REPO, GH_TOKEN } = process.env;
60
+ const args = process.argv.slice(2);
61
+ try {
62
+ if (!REPO || !GH_TOKEN) throw new Error("REPO and GH_TOKEN are required");
63
+ const opt = {};
64
+ for (let i = 0; i < args.length; i += 2) {
65
+ if (!["--since", "--head"].includes(args[i]) || args[i + 1] === undefined) throw new Error("usage: [--since <tag>] [--head <ref>]");
66
+ opt[args[i].slice(2)] = args[i + 1];
67
+ }
68
+ const head = git(["rev-parse", `${opt.head ?? "HEAD"}^{commit}`]);
69
+ const since = opt.since ?? git(["describe", "--tags", "--abbrev=0", "--match", "v*", head]);
70
+ const api = async (path) => {
71
+ const r = await fetch(`https://api.github.com/repos/${REPO}/${path}`,
72
+ { headers: { authorization: `Bearer ${GH_TOKEN}`, accept: "application/vnd.github+json" } });
73
+ if (!r.ok) throw new Error(`GET ${path}: ${r.status}`);
74
+ return r.json();
75
+ };
76
+ const v = await verdict({
77
+ head, since, pins: (c) => pinsAt(c),
78
+ line: () => git(["rev-list", "--first-parent", `${since}..${head}`]).split("\n").filter(Boolean),
79
+ replay: (sha) => latestReplay(api, sha),
80
+ });
81
+ console.log(`release-bench-replay-check: ${v.why}`);
82
+ process.exit(v.hold ? 1 : 0);
83
+ } catch (e) {
84
+ process.stderr.write(`release-bench-replay-check: could not look — ${e.message}\n`);
85
+ process.exit(2);
86
+ }
87
+ }
@@ -380,7 +380,6 @@ const signInProbe = `(() => {
380
380
  const out = {}
381
381
  const SPEC = {
382
382
  signInName: "Through your organisation's sign-in service",
383
- webSearch: ['The engine’s own web search', 'provided by the engine', 'Available', 'The model searches the web itself during a run.'],
384
383
  categories: ['Trademark register', 'Common-law and marketplace research', 'Open-web search', 'Case law and oppositions'],
385
384
  caseLawGap: 'Until these are set up, a Full country search still runs and its report discloses the case-law gap instead of reporting no adverse case law.',
386
385
  adminLine: 'To change the address, the permissions or the companies on it, contact your Clearotron administrator.',
@@ -398,7 +397,9 @@ for (const theme of ['light', 'dark']) {
398
397
  // ── Installation settings
399
398
  console.log('\nInstallation settings:')
400
399
  loads.length = 0
401
- if (await open('/portal/admin/config', "document.querySelectorAll('.cfg-row').length >= 10", 'Installation settings drew its rows')) {
400
+ // Nine rows: sign-in, the engine, and seven providers. The engine's own web search had a tenth; it is off on
401
+ // both engines and has no row.
402
+ if (await open('/portal/admin/config', "document.querySelectorAll('.cfg-row').length >= 9", 'Installation settings drew its rows')) {
402
403
  await setTheme(theme)
403
404
  const s = (await evalIn(settingsProbe)) ?? {}
404
405
  out[`settings-${theme}`] = s
@@ -411,12 +412,10 @@ for (const theme of ['light', 'dark']) {
411
412
  `the sign-in row reads "${SPEC.signInName} · Configured" (saw ${JSON.stringify(si)})`)
412
413
  ok(si && same(si.mono, [`auth-proxy · issuer ${ISSUER}`]), `the mode and issuer are the row's quiet mono line (saw ${JSON.stringify(si?.mono)})`)
413
414
  const rights = [...s.signIn, ...s.engine, ...s.categories.flatMap((c) => c.rows)].map((r) => r.stateRight)
414
- ok(rights.length >= 10 && new Set(rights).size === 1, `every state lines up in one column (right edges ${JSON.stringify([...new Set(rights)])})`)
415
- ok(s.engine.length === 2 && s.engine[0].name === 'Anthropic' && s.engine[0].state === 'Subscription',
416
- `Engine carries the engine row first (saw ${JSON.stringify(s.engine[0])})`)
417
- const web = s.engine[1]
418
- ok(web && same([web.name, web.val, web.state, web.notes[0]], SPEC.webSearch),
419
- `the engine's own web search sits under Engine, in the page's words (saw ${JSON.stringify(web)})`)
415
+ ok(rights.length >= 9 && new Set(rights).size === 1, `every state lines up in one column (right edges ${JSON.stringify([...new Set(rights)])})`)
416
+ ok(s.engine.length === 1 && s.engine[0].name === 'Anthropic' && s.engine[0].state === 'Subscription',
417
+ `Engine carries the engine row alone (saw ${JSON.stringify(s.engine)})`)
418
+ ok(!s.engine.some((r) => /web search/i.test(r.name)), "the engine's own web search, off on both engines, has no row under Engine")
420
419
  ok(same(s.categories.map((c) => c.label), SPEC.categories), `four provider categories, in order (saw ${JSON.stringify(s.categories.map((c) => c.label))})`)
421
420
  const repeated = s.categories.flatMap((c) => c.rows.filter((r) => [r.name, r.val ?? '', ...r.notes].some((t) => t.includes(c.label))).map((r) => `${c.label} on ${r.name}`))
422
421
  ok(repeated.length === 0, `no category name is repeated on a row (saw ${JSON.stringify(repeated)})`)