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.
- package/.env.example +9 -0
- package/README.md +1 -0
- package/build-info.json +2 -2
- package/docs/architecture/04-configuration-reference.md +2 -1
- package/driver/CHANGELOG.md +12 -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/band-server.mjs +29 -9
- package/driver/engine/mcp/codex-config.mjs +39 -3
- package/driver/engine/mcp/gather-config.mjs +17 -2
- package/driver/engine/mcp/perplexity-server.mjs +5 -1
- package/driver/engine/mcp/stdio-server.mjs +24 -4
- package/driver/package.json +1 -1
- package/driver/suite-census.json +44 -8
- package/mcp-server/CHANGELOG.md +8 -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 +8 -0
- package/providers/oauth-mcp-bridge/bridge.mjs +25 -3
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/scripts/bench-replay-request.mjs +111 -0
- package/scripts/e2e-first-time.mjs +1 -1
- 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/shared/answer-parts.mjs +127 -0
- package/shared/names-in-force.mjs +1 -0
package/driver/suite-census.json
CHANGED
|
@@ -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":
|
|
770
|
-
"asserts":
|
|
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":
|
|
2024
|
-
"asserts":
|
|
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":
|
|
3087
|
-
"skips":
|
|
3110
|
+
"asserts": 262,
|
|
3111
|
+
"skips": 2,
|
|
3088
3112
|
"todos": 0
|
|
3089
3113
|
},
|
|
3090
3114
|
"engine.codex-config.test.mjs": {
|
|
3091
|
-
"tests":
|
|
3092
|
-
"asserts":
|
|
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,
|
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
|
}
|
|
@@ -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:
|
|
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.
|
|
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
|
-
|
|
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)})`)
|