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 +1 -0
- package/build-info.json +2 -2
- package/driver/CHANGELOG.md +6 -0
- package/driver/config-inventory.mjs +8 -16
- package/driver/contract-vocabulary.mjs +2 -2
- package/driver/engine/anthropic-agent.mjs +23 -1
- package/driver/engine/mcp/codex-config.mjs +8 -0
- package/driver/package.json +1 -1
- package/driver/suite-census.json +20 -2
- package/mcp-server/CHANGELOG.md +4 -0
- package/mcp-server/package.json +1 -1
- package/package.json +1 -1
- package/portal-ui/dist/assets/{index-BPAUjcI0.js → index-DcLbvFmQ.js} +1 -2
- package/portal-ui/dist/index.html +1 -1
- package/portal-ui/package.json +2 -2
- package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/scripts/bench-replay-request.mjs +111 -0
- package/scripts/engine-pins.mjs +117 -0
- package/scripts/release-bench-replay-check.mjs +87 -0
- package/scripts/settings-render-check.mjs +7 -8
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
package/driver/CHANGELOG.md
CHANGED
|
@@ -499,30 +499,22 @@ export function caseLawInventory(env = process.env) {
|
|
|
499
499
|
remedy: null,
|
|
500
500
|
});
|
|
501
501
|
|
|
502
|
-
// ── THE
|
|
502
|
+
// ── THE ONE THE REPORT NAMED AND THIS BUILD DOES NOT SHIP ────────────────────────────────────────
|
|
503
503
|
//
|
|
504
|
-
// "no EUIPO Boards-of-Appeal source"
|
|
505
|
-
//
|
|
506
|
-
//
|
|
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
|
-
//
|
|
509
|
-
//
|
|
510
|
-
//
|
|
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
|
|
20
|
-
// D3 verify.mjs:
|
|
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 } = {}) => {
|
package/driver/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "clearotron-driver",
|
|
3
3
|
"private": true,
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.4.1-beta.
|
|
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": {
|
package/driver/suite-census.json
CHANGED
|
@@ -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":
|
|
3105
|
-
"skips":
|
|
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,
|
package/mcp-server/CHANGELOG.md
CHANGED
package/mcp-server/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trademark-artifacts-mcp",
|
|
3
|
-
"version": "0.4.1-beta.
|
|
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
|
@@ -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-
|
|
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>
|
package/portal-ui/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "portal-ui",
|
|
3
3
|
"private": true,
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.4.1-beta.
|
|
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.
|
|
27
|
+
"vite": "^8.3.4"
|
|
28
28
|
}
|
|
29
29
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trademark-oauth-mcp-bridge",
|
|
3
|
-
"version": "0.4.1-beta.
|
|
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
|
-
|
|
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 >=
|
|
415
|
-
ok(s.engine.length ===
|
|
416
|
-
`Engine carries the engine row
|
|
417
|
-
|
|
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)})`)
|