clearotron 0.4.1-beta.3 → 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.
package/README.md CHANGED
@@ -8,6 +8,7 @@
8
8
  <p align="center">
9
9
  <a href="LICENSE"><img src="https://img.shields.io/badge/licence-AGPL--3.0--only-860F09?style=flat-square" alt="Licence: AGPL-3.0-only"></a>
10
10
  <a href=".nvmrc"><img src="https://img.shields.io/badge/node-%E2%89%A5%2022.13-250902?style=flat-square" alt="Node 22.13+"></a>
11
+ <a href="https://scorecard.dev/viewer/?uri=github.com/CordilleraSarl/clearotron"><img src="https://img.shields.io/ossf-scorecard/github.com/CordilleraSarl/clearotron?label=openssf%20scorecard&style=flat-square" alt="OpenSSF Scorecard"></a>
11
12
  </p>
12
13
 
13
14
  Before your company commits to a name, find out what stands in its way. Give Clearotron the name, the
package/build-info.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "38d43f7a215c87092fd2cc233812b3aff17e1e82",
3
- "version": "0.4.1-beta.3"
2
+ "commit": "103da45d8abcbbe5af51cacd213954a3cc59c841",
3
+ "version": "0.4.1-beta.4"
4
4
  }
@@ -1,5 +1,11 @@
1
1
  # clearotron-driver
2
2
 
3
+ ## 0.4.1-beta.4
4
+
5
+ ### Patch Changes
6
+
7
+ - Fixed: Every clearance uses the same web search whichever engine runs it; the engines' own web search is switched off.
8
+
3
9
  ## 0.4.1-beta.3
4
10
 
5
11
  ### Patch Changes
@@ -499,30 +499,22 @@ export function caseLawInventory(env = process.env) {
499
499
  remedy: null,
500
500
  });
501
501
 
502
- // ── THE TWO THE REPORT NAMED AND THIS BUILD DOES NOT SHIP ────────────────────────────────────────
502
+ // ── THE ONE THE REPORT NAMED AND THIS BUILD DOES NOT SHIP ────────────────────────────────────────
503
503
  //
504
- // "no EUIPO Boards-of-Appeal source" and "WebSearch unavailable this session" were the other two gaps
505
- // in the same report, and the issue asks for the same treatment. They get it, and the treatment is to
506
- // say what is TRUE of each rather than to invent a switch:
504
+ // "no EUIPO Boards-of-Appeal source" was another gap in the same report, and the treatment is to say
505
+ // what is TRUE of it rather than to invent a switch: the Boards of Appeal have no adapter in this build.
506
+ // That is a release fact, not a setting, and an operator can do nothing about it — which is precisely
507
+ // what they need to be told before they spend two hours discovering it from a report.
507
508
  //
508
- // · the Boards of Appeal have no adapter in this build. That is a release fact, not a setting, and
509
- // an operator can do nothing about it — which is precisely what they need to be told before they
510
- // spend two hours discovering it from a report.
511
- // · WebSearch is the engine's own tool. Whether it answers is decided per session by the engine, so
512
- // no deployment check can promise it. Recorded as a capability with a stated limit rather than as
513
- // a green tick this file cannot honestly give.
509
+ // The engine's own web search had a row here too. It is off on both engines now (OWN_SEARCH_TOOLS in
510
+ // engine/anthropic-agent.mjs, `web_search` in engine/mcp/codex-config.mjs), so it has none: the open-web
511
+ // row a run actually uses is the research provider's, above.
514
512
  rows.push({
515
513
  key: "caselaw", label: "Case law and oppositions", provider: "euipo-boards-of-appeal",
516
514
  providerLabel: "EUIPO Boards of Appeal", known: false, enrolment: "absent", configured: false, missing: [],
517
515
  remedy: "Not part of this build. There is no adapter for it, so no setting turns it on and a report "
518
516
  + "covering the EU discloses it as a gap. Nothing to fix on this box.",
519
517
  });
520
- rows.push({
521
- key: "web", label: "Open-web search", provider: "engine-websearch",
522
- providerLabel: "The engine's own web search", known: true, enrolment: "built-in", configured: true, missing: [],
523
- remedy: "Provided by the engine, not by this deployment, and it can be unavailable for a single "
524
- + "session. A run that loses it discloses the gap rather than reporting a clean sweep.",
525
- });
526
518
  return rows;
527
519
  }
528
520
 
@@ -16,8 +16,8 @@
16
16
  // extractor never sees:
17
17
  //
18
18
  // D1 verify.mjs connotationDispositionFail fail(`connotation_${reason}:…`) — reason is a member of CALL_REASON_SET
19
- // D2 verify.mjs fail(String(e.message)) — parseFindingsJson throws token-first
20
- // D3 verify.mjs:1237 checkJson: fail(String(e.message)) — FIVE parsers reach this one site
19
+ // D2 verify.mjs checkFindingsSibling fail(String(e.message)…) — parseFindingsJson throws token-first
20
+ // D3 verify.mjs:1240 checkJson: fail(String(e.message)…) — FIVE parsers reach this one site
21
21
  // D4 verify.mjs parseCoverageLedgerJson, same shape
22
22
  // D5 verify.mjs validators.registerFindings fail(`${unaccounted[0].token}:…`) — token minted in a DATA ROW
23
23
  // D6 verify.mjs validators.registerFindings fail(`${violations[0].token}…`) — findUnexecutedCleanClaims() in register-plan.mjs
@@ -370,6 +370,14 @@ export function runDirGrant({ runDir, dispatch, seatWrites = null } = {}) {
370
370
  * shell the program offered them anyway.
371
371
  */
372
372
  export const COMMAND_TOOLS = Object.freeze(["Bash", "PowerShell", "Monitor"]);
373
+ /**
374
+ * The program's own web search, removed from every stage by the same name-removal. The engine's web search
375
+ * is its own: the common-law sweep, which keeps a record of every result. The program's was never granted,
376
+ * and every attempt a stage made at it was refused, a turn spent for nothing (observed in testing,
377
+ * 2026-10-02 and 2026-10-03: 48 attempts in five runs, all refused). Removed, the model is never offered it.
378
+ * The codex engine turns its own off the same way (`web_search`, codex-config.mjs).
379
+ */
380
+ export const OWN_SEARCH_TOOLS = Object.freeze(["WebSearch"]);
373
381
  /** The program's tools that write a file. A result that comes back an error is a write that failed. */
374
382
  const FILE_WRITE_TOOLS = Object.freeze(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
375
383
 
@@ -399,7 +407,7 @@ export function buildClaudeArgs({ message, model, thinking, resumeRef, mcpConfig
399
407
  // through Bash on a login with no rule allowing it. A bare name here "removes the matching tools from
400
408
  // Claude's context" (the vendor's CLI reference, `--disallowedTools`), on every call, the resumed one
401
409
  // included, so the model is never offered them. See COMMAND_TOOLS for the three names.
402
- args.push("--disallowedTools", COMMAND_TOOLS.join(" "));
410
+ args.push("--disallowedTools", [...COMMAND_TOOLS, ...OWN_SEARCH_TOOLS].join(" "));
403
411
  const cap = maxBudgetUsd ?? (process.env.CLEAROTRON_MAX_BUDGET_USD ? Number(process.env.CLEAROTRON_MAX_BUDGET_USD) : null);
404
412
  if (cap != null && Number.isFinite(cap)) args.push("--max-budget-usd", String(cap));
405
413
  // FILE ACCESS: claude's Read/Write/Edit tools are confined to cwd + --add-dir roots even under
@@ -593,6 +601,8 @@ export const anthropicAgentEngine = {
593
601
  let lastMove = Date.now();
594
602
  let lastProgress = Date.now(); // honest-progress clock: token movement / agent-loop steps / artifact writes ONLY
595
603
  let firstOutputAt = null; // #1692/#1813 — first byte of the PROTOCOL (stdout only); until then every clock below is timing STARTUP
604
+ let atKill = null; // what the watchdog had seen when it decided to kill — recording only, see killDecision
605
+ let silentIdleMs = null; // the no-progress clock at the watchdog's last look before the child spoke — recording only
596
606
  const GRACE = spawnGraceMs(); // one source of the grace: common.mjs, where #1703 set it
597
607
  let sawAnyEvent = false; // ANY parseable stream event (incl. system:init) — false on close = the CLI died before streaming (startup-class failure)
598
608
  let rateLimitEvent = null; // a 429 session-cap rejection rides a `rate_limit_event` (status:"rejected" + resetsAt)
@@ -957,6 +967,7 @@ export const anthropicAgentEngine = {
957
967
  ? (sig) => { const n = killWindowsTreeNow({ pid: child.pid, since: spawnedAt, until: exitedAt }); if (!n && sig === "SIGTERM") process.stderr.write(`[engine] the Windows stop found nothing of this turn left to end (program ${child.pid})\n`); }
958
968
  : (sig) => { try { process.kill(-child.pid, sig); } catch { try { child.kill(sig); } catch { /* already gone */ } } };
959
969
  const killTree = () => {
970
+ atKill ??= { ms: Date.now() - t0, spoken: firstOutputAt !== null, toolCalls };
960
971
  if (escalation) return; // the watchdog polls — arm the escalation exactly once
961
972
  groupKill("SIGTERM");
962
973
  escalation = setTimeout(() => groupKill("SIGKILL"), killEscalateMs());
@@ -1009,6 +1020,7 @@ export const anthropicAgentEngine = {
1009
1020
  // was recorded as a no-progress stall against a turn that had not yet been given a chance to
1010
1021
  // progress. A child that never speaks AT ALL is still bounded: the byte-stall fires at max(STALL, GRACE).
1011
1022
  const started = firstOutputAt !== null;
1023
+ if (!started) silentIdleMs = progIdle;
1012
1024
  if (idle >= (started ? STALL : Math.max(STALL, GRACE))) { stallKill = true; killed = true; killTree(); }
1013
1025
  // No observable progress (tokens/agent-loop/artifact) for NOPROG while bytes kept the pipe warm:
1014
1026
  // kill it NOW, well below the wall, and record it as a STALL — never let the ceiling raise turn
@@ -1220,6 +1232,16 @@ export const anthropicAgentEngine = {
1220
1232
  // ONLY: nothing branches on it. A recording field is protected by a test or by nothing, so
1221
1233
  // engine.anthropic.test.mjs pins it — deleting it must red something.
1222
1234
  firstByteMs: firstOutputAt === null ? null : Math.round(firstOutputAt - t0),
1235
+ // WHICH SIDE OF THE KILL a byte or a tool ask fell on. RECORDING ONLY, like firstByteMs. The
1236
+ // totals above keep counting after the decision, because what a dying child already wrote is
1237
+ // still read (streamed usage depends on it). On Windows the kill blocks this process while it
1238
+ // lists and ends the tree, measured at one to three seconds on the hosted runner, which is long
1239
+ // enough for a booting child to speak and open a tool ask that is read only afterwards.
1240
+ // `killDecision` is what the watchdog had seen when it decided: ms from spawn, whether the child
1241
+ // had spoken, and the tool asks read by then; null when nothing was killed. `silentIdleMs` is how
1242
+ // long the no-progress clock had run at the watchdog's last look before the child spoke; null
1243
+ // when it never looked at a silent child.
1244
+ killDecision: atKill, silentIdleMs,
1223
1245
  // WHICH CLOCK RAN OUT, and WHAT the wait was made of.
1224
1246
  //
1225
1247
  // The hard wall measures ELAPSED, and elapsed includes time the model spent waiting on tools
@@ -153,6 +153,14 @@ export function renderCodexConfigToml({ mcpConfig, allowedTools, developerInstru
153
153
  if (developerInstructions) lines.push(`developer_instructions = ${tomlString(developerInstructions)}`, "");
154
154
  // Top-level, so above the first table: TOML reads a bare key after a table header as that table's.
155
155
  if (fence) lines.push(`default_permissions = ${tomlString(FENCE_PROFILE)}`, "");
156
+ // CODEX'S OWN WEB SEARCH IS OFF, ON EVERY STAGE AND IN EVERY SAFETY SETTING. The engine's web search is its
157
+ // own: the common-law sweep, which keeps a record of every result. Codex brings a search of its own that
158
+ // is on unless the configuration says otherwise, "cached" by default and "live" once the sandbox is
159
+ // bypassed, and `"disabled"` removes the tool (the vendor's configuration reference, `web_search`). Left
160
+ // on, it fired in some runs and not others and brought web addresses into the placement notes that no
161
+ // recorded search returned (observed in testing, 2026-10). The Claude engine removes its own the same way
162
+ // (OWN_SEARCH_TOOLS, anthropic-agent.mjs). The case-law step's page fetcher is a tool server, not this.
163
+ lines.push(`web_search = "disabled"`, "");
156
164
  lines.push(...commandEnvToml(withheldFromCommands));
157
165
 
158
166
  const emitServer = (name, s, { forwardCreds } = {}) => {
@@ -2,7 +2,7 @@
2
2
  "name": "clearotron-driver",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.4.1-beta.3",
5
+ "version": "0.4.1-beta.4",
6
6
  "license": "AGPL-3.0-only",
7
7
  "description": "Deterministic driver for the trademark clearance workflow: orchestration in code (fan-out, fan-in barrier, gating, retries); the model does judgment leaves only, through a reasoning CLI spawned per stage.",
8
8
  "engines": {
@@ -981,6 +981,12 @@
981
981
  "skips": 0,
982
982
  "todos": 0
983
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
+ },
984
990
  "a-placement-pass-that-lost-its-answers-does-not-succeed.test.mjs": {
985
991
  "tests": 7,
986
992
  "asserts": 17,
@@ -3101,8 +3107,8 @@
3101
3107
  },
3102
3108
  "engine.anthropic.test.mjs": {
3103
3109
  "tests": 70,
3104
- "asserts": 260,
3105
- "skips": 1,
3110
+ "asserts": 262,
3111
+ "skips": 2,
3106
3112
  "todos": 0
3107
3113
  },
3108
3114
  "engine.codex-config.test.mjs": {
@@ -3867,6 +3873,12 @@
3867
3873
  "skips": 0,
3868
3874
  "todos": 0
3869
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
+ },
3870
3882
  "no-test-account-reaches-the-package.test.mjs": {
3871
3883
  "tests": 17,
3872
3884
  "asserts": 31,
@@ -5817,6 +5829,12 @@
5817
5829
  "skips": 0,
5818
5830
  "todos": 0
5819
5831
  },
5832
+ "the-engine-pins-come-from-the-tree.test.mjs": {
5833
+ "tests": 6,
5834
+ "asserts": 24,
5835
+ "skips": 0,
5836
+ "todos": 0
5837
+ },
5820
5838
  "the-engine-sets-no-sampling-and-forces-no-tool.test.mjs": {
5821
5839
  "tests": 6,
5822
5840
  "asserts": 9,
@@ -1,5 +1,9 @@
1
1
  # trademark-artifacts-mcp
2
2
 
3
+ ## 0.4.1-beta.4
4
+
5
+ No changes in this release.
6
+
3
7
  ## 0.4.1-beta.3
4
8
 
5
9
  No changes in this release.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-artifacts-mcp",
3
- "version": "0.4.1-beta.3",
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.3",
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.3",
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,9 @@
1
1
  # trademark-oauth-mcp-bridge
2
2
 
3
+ ## 0.4.1-beta.4
4
+
5
+ No changes in this release.
6
+
3
7
  ## 0.4.1-beta.3
4
8
 
5
9
  No changes in this release.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-oauth-mcp-bridge",
3
- "version": "0.4.1-beta.3",
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
+ }
@@ -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)})`)