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/.env.example
CHANGED
|
@@ -96,6 +96,15 @@ CLEAROTRON_CLAUDE_PATH=claude
|
|
|
96
96
|
# the copied `.env`, not a choice).
|
|
97
97
|
# effect: setup
|
|
98
98
|
CLEAROTRON_CODEX_PATH=codex
|
|
99
|
+
#
|
|
100
|
+
# How long one tool answer may be before it is handed over in parts, in bytes, counted as a quoted string
|
|
101
|
+
# (shared/answer-parts.mjs). Each engine gives every tool server its own size: 45,000 on Claude, because
|
|
102
|
+
# Claude Code saves an answer longer than 50,000 characters to a file the model must read for itself, and
|
|
103
|
+
# 36,000 on openai-agent, because Codex removes the middle of a tool read longer than about 39,000 bytes.
|
|
104
|
+
# A smaller value here still applies on both; empty leaves the engine's size. The answer itself is
|
|
105
|
+
# unchanged either way; the tool runs once, and a later part is read from the answer the server holds.
|
|
106
|
+
# effect: tuning
|
|
107
|
+
CLEAROTRON_TOOL_ANSWER_BYTES=
|
|
99
108
|
|
|
100
109
|
CLEAROTRON_WORK_DIR=
|
|
101
110
|
# Customer/project config store (loadProfiles). Unset ⇒ driver's built-in profiles dir.
|
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
|
@@ -292,7 +292,8 @@ of truncations.
|
|
|
292
292
|
|
|
293
293
|
| Var | Default | Meaning |
|
|
294
294
|
|---|---|---|
|
|
295
|
-
| `CLEAROTRON_BAND_RESPONSE_CHARS` | the
|
|
295
|
+
| `CLEAROTRON_BAND_RESPONSE_CHARS` | five sixths of `CLEAROTRON_TOOL_ANSWER_BYTES`: 37,500 on the Claude engine, 30,000 on the OpenAI engine; 70,000 with no answer size | Characters per band-server response, the band summary's parts included; a summary part is never longer than 70,000 whatever this says. Floor 4096. The sixth left over is room for the quotes and line breaks a printout adds and for a banner, so a band answer is never split again into answer parts. On the OpenAI engine it is at most 30,000; a smaller value set here still applies. |
|
|
296
|
+
| `CLEAROTRON_TOOL_ANSWER_BYTES` | 45,000 on the Claude engine, 36,000 on the OpenAI engine | Bytes per tool answer from every local tool server and the case-law bridge, counted as a quoted string (a quote or line break counts two). A longer answer is served in parts, each naming the next, and the tool runs once: a later part is read from the answer the server holds. Each engine writes its size into every server's entry: Claude Code saves an answer longer than 50,000 characters to a file the model must read for itself, and Codex removes the middle of a tool read longer than about 39,000 bytes. A smaller value set here still applies on both. A server started outside a stage, with no size, serves every answer whole. Floor 8192. |
|
|
296
297
|
|
|
297
298
|
### Surfaces beside the driver — portal, demo, e2e harness
|
|
298
299
|
|
package/driver/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
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
|
+
|
|
9
|
+
## 0.4.1-beta.3
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- Fixed: A crowded clearance on the Codex engine no longer stops before its report.
|
|
14
|
+
|
|
3
15
|
## 0.4.1-beta.2
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
|
@@ -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
|
|
@@ -20,6 +20,7 @@ import { readFileSync, readdirSync, appendFileSync, existsSync, mkdirSync } from
|
|
|
20
20
|
import { join, dirname } from "node:path";
|
|
21
21
|
import { driverDir } from "../../../shared/driver-dir.mjs"; //
|
|
22
22
|
import { serve } from "./stdio-server.mjs";
|
|
23
|
+
import { answerBudget } from "../../../shared/answer-parts.mjs";
|
|
23
24
|
import { classifyRecord, prepareTargets, SHAPE_TIERS, isLiveRecord } from "../../band-shape.mjs";
|
|
24
25
|
import { recordQids } from "../../named-band.mjs";
|
|
25
26
|
import { normalizeRecordUri } from "../../registry-fidelity.mjs";
|
|
@@ -98,7 +99,8 @@ const loadJson = (p) => JSON.parse(readFileSync(p, "utf8"));
|
|
|
98
99
|
// contract is honest and explicit: an over-size shape announces "part N/M" and names the next call;
|
|
99
100
|
// the common single-part case is served verbatim with no banner. The part size was a test seam with a
|
|
100
101
|
// floor under it so a misconfiguration could never shred the shape into confetti; step 3 deleted
|
|
101
|
-
// the knob, and a constant cannot be misconfigured.
|
|
102
|
+
// the knob, and a constant cannot be misconfigured. A smaller answer size still lowers it
|
|
103
|
+
// (SHAPE_PART_SERVED, below), never under the answer size's own floor.
|
|
102
104
|
const SHAPE_PART_CHARS = 70000; // step 3 — was a knob; no environment ever set it
|
|
103
105
|
|
|
104
106
|
// PR-11 — the SAME transport cap bounds the other two tools (they were shipped unbounded): a
|
|
@@ -106,7 +108,19 @@ const SHAPE_PART_CHARS = 70000; // step 3 — was a knob; no environment ever
|
|
|
106
108
|
// per-tool-output token cap and error the call — killing the reading path exactly where the record
|
|
107
109
|
// volume is highest. band_lookup TRIMS records (the full match count always survives, with an
|
|
108
110
|
// honest note); band_record serves PARTS like band_shape (a record is a document — never trimmed).
|
|
109
|
-
|
|
111
|
+
// Given an answer size and no band size of its own, the band answers in five sixths of it: the rest is room
|
|
112
|
+
// for the quotes and line breaks a printout adds (up to 13% on band answers) and for a banner, so a band
|
|
113
|
+
// answer is never split a second time (shared/answer-parts.mjs). 37,500 on Claude, 30,000 on codex.
|
|
114
|
+
const RESPONSE_CHARS = Math.max(4096, Number(process.env.CLEAROTRON_BAND_RESPONSE_CHARS)
|
|
115
|
+
|| (answerBudget() ? Math.floor(answerBudget() / 1.2) : SHAPE_PART_CHARS));
|
|
116
|
+
|
|
117
|
+
// The shape is split at the smaller of its own part size and the answer size, because codex cuts the
|
|
118
|
+
// middle out of a read over about 39,000 bytes and is given a smaller answer size for that
|
|
119
|
+
// (engine/mcp/codex-config.mjs, CODEX_BAND_ANSWER_CHARS). Measured in testing, 2026-10-11: a GPT model that
|
|
120
|
+
// printed a 70,269-character shape part to read it named 11 of its 24 marker rows, and in one attempt of a
|
|
121
|
+
// real run four of five shape reads were that long. Never larger than the constant,
|
|
122
|
+
// so a larger answer size cannot push a part past Claude's limit; with no size set, nothing changes.
|
|
123
|
+
const SHAPE_PART_SERVED = Math.min(SHAPE_PART_CHARS, RESPONSE_CHARS);
|
|
110
124
|
|
|
111
125
|
/** Split text into parts of ≤ budget chars, cutting only at line boundaries (an over-budget single
|
|
112
126
|
* line still ships whole — never cut mid-line). Concatenating the parts with "\n" restores the
|
|
@@ -216,7 +230,7 @@ function bandShape(params) {
|
|
|
216
230
|
if (!RUN_DIR) { logRead("band_shape", { format }, { ok: false }); return { isError: true, text: "ERROR: CLEAROTRON_BAND_RUN_DIR not set — band tools are wired per run by the driver." }; }
|
|
217
231
|
if (!existsSync(p)) { logRead("band_shape", { format }, { ok: false }); return { isError: true, text: `ERROR: ${p} not found — the driver derives it after the register fan-in; if the band exists but the shape does not, report this as a driver defect (do NOT slice the raw band instead).` }; }
|
|
218
232
|
const text = readFileSync(p, "utf8");
|
|
219
|
-
const parts = splitAtLines(text,
|
|
233
|
+
const parts = splitAtLines(text, SHAPE_PART_SERVED);
|
|
220
234
|
const total = parts.length;
|
|
221
235
|
if (total === 1) { // the common case — served verbatim, no banner
|
|
222
236
|
logRead("band_shape", { format }, { ok: true, bytes: text.length });
|
|
@@ -240,6 +254,11 @@ function bandLookup(params = {}) {
|
|
|
240
254
|
try { band = loadJson(bandPath()); } catch (e) { logRead("band_lookup", params, { ok: false }); return { isError: true, text: `ERROR: band unreadable (${e.message}) — a driver-side defect; report it, never hand-repair the band.` }; }
|
|
241
255
|
const { record_id, owner, nice_class, tier, qid, query, text, status, live_only = false } = params;
|
|
242
256
|
const limit = Math.max(1, Math.min(Number(params.limit) || 20, 100));
|
|
257
|
+
// PAGING. A large band is read page by page from `offset`, in the band's own order, and a capped answer
|
|
258
|
+
// names the next offset. Without it the only way to see every record was to slice the band by owner or
|
|
259
|
+
// text fragments, and a seat told to judge the complete band read it many times over (1,423 records,
|
|
260
|
+
// 2,287 lookups, 65.8 MB, until the engine's output cap stopped the turn).
|
|
261
|
+
const offset = Math.max(0, Math.floor(Number(params.offset) || 0));
|
|
243
262
|
if (tier && !SHAPE_TIERS.includes(tier)) return { isError: true, text: `ERROR: tier must be one of ${SHAPE_TIERS.join(" | ")}.` };
|
|
244
263
|
|
|
245
264
|
// tier filtering classifies against the SHAPE's own frozen targets — same classifier, same result.
|
|
@@ -270,7 +289,7 @@ function bandLookup(params = {}) {
|
|
|
270
289
|
if (text && !norm(r?.mark_text).includes(norm(text))) continue;
|
|
271
290
|
if (prepared && classifyRecord(r?.mark_text, prepared).tier !== tier) continue;
|
|
272
291
|
matched++;
|
|
273
|
-
if (hits.length < limit) hits.push(project(r));
|
|
292
|
+
if (matched > offset && hits.length < limit) hits.push(project(r));
|
|
274
293
|
}
|
|
275
294
|
// matching crowd descriptors ride along (a lookup that lands in an un-enumerated zone must SEE that).
|
|
276
295
|
const crowds = (Array.isArray(band?.crowds) ? band.crowds : []).filter((c) => {
|
|
@@ -301,9 +320,10 @@ function bandLookup(params = {}) {
|
|
|
301
320
|
// The full `matched` count always ships, and the note says exactly how many records were withheld.
|
|
302
321
|
let byteCapped = 0;
|
|
303
322
|
const body = () => JSON.stringify({
|
|
304
|
-
matched, returned: hits.length,
|
|
305
|
-
...(matched > hits.length ? {
|
|
306
|
-
...(
|
|
323
|
+
matched, returned: hits.length, ...(offset ? { offset } : {}),
|
|
324
|
+
...(matched > offset + hits.length ? { next_offset: offset + hits.length } : {}),
|
|
325
|
+
...(matched > offset + hits.length ? { note: `capped at ${hits.length} — call again with offset: ${offset + hits.length} for the next page; the count above is the full match` } : {}),
|
|
326
|
+
...(byteCapped ? { byte_cap_note: `${byteCapped} matched record(s) withheld to fit one tool response — call again with offset: ${offset + hits.length} for the rest` } : {}),
|
|
307
327
|
records: hits,
|
|
308
328
|
...(crowds.length ? { matching_crowds: crowds, crowd_note: "these matching slices were NOT enumerated — records under them may exist that no lookup can return; weigh the descriptor, never treat this result as their clean"
|
|
309
329
|
+ (refusedCrowds ? ` — and ${refusedCrowds} of them was/were NEVER ANSWERED at all (see "refusal"): no count exists for those, so they are unsearched rather than empty, and nothing about them can be read off a zero` : "")
|
|
@@ -405,11 +425,11 @@ serve({
|
|
|
405
425
|
},
|
|
406
426
|
{
|
|
407
427
|
name: "band_lookup",
|
|
408
|
-
description: "Look up enumerated records in this run's frozen register band (read-only; logged to the reading audit). Filters AND together: record_id (exact uri), owner (substring), nice_class, tier (identical|near-identical|same-family|other|unclassifiable — classified against the shape's frozen targets), qid (exact plan-entry id — the plan-execution ledger join; a record surfaced by several slices matches under EVERY one of them), query (the _query slice that surfaced the record), text (substring of mark_text), status (substring), live_only, limit (default 20, max 100). Matching un-enumerated crowd descriptors ride along so a lookup can never mistake a counted-only zone for a clean.",
|
|
428
|
+
description: "Look up enumerated records in this run's frozen register band (read-only; logged to the reading audit). Filters AND together: record_id (exact uri), owner (substring), nice_class, tier (identical|near-identical|same-family|other|unclassifiable — classified against the shape's frozen targets), qid (exact plan-entry id — the plan-execution ledger join; a record surfaced by several slices matches under EVERY one of them), query (the _query slice that surfaced the record), text (substring of mark_text), status (substring), live_only, limit (default 20, max 100), offset (default 0: where in the matching records to start — a capped answer names the next offset, so a large band is read page by page). Matching un-enumerated crowd descriptors ride along so a lookup can never mistake a counted-only zone for a clean.",
|
|
409
429
|
inputSchema: { type: "object", properties: {
|
|
410
430
|
record_id: { type: "string" }, owner: { type: "string" }, nice_class: { type: "string" },
|
|
411
431
|
tier: { type: "string", enum: SHAPE_TIERS }, qid: { type: "string" }, query: { type: "string" }, text: { type: "string" },
|
|
412
|
-
status: { type: "string" }, live_only: { type: "boolean" }, limit: { type: "number" },
|
|
432
|
+
status: { type: "string" }, live_only: { type: "boolean" }, limit: { type: "number" }, offset: { type: "number" },
|
|
413
433
|
} },
|
|
414
434
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
415
435
|
handler: bandLookup,
|
|
@@ -50,6 +50,30 @@ export const TOOL_SERVER_SETTINGS = Object.freeze([
|
|
|
50
50
|
"OAUTH_BRIDGE_CREDS_DIR", "OAUTH_BRIDGE_CLIENT_NAME",
|
|
51
51
|
]);
|
|
52
52
|
|
|
53
|
+
// CODEX CUTS THE MIDDLE OUT OF A LONG READ. It shows a model at most 10,000 tokens of the text a tool call
|
|
54
|
+
// puts in front of it, from its start and its end, counting 4 bytes a token, with "…N tokens truncated…"
|
|
55
|
+
// between them. For the GPT models, which call tools only from code (5.6 and 6.x), that text is what the
|
|
56
|
+
// code prints, and a quoted print adds a byte for each quote and line break: up to 13% on real band answers.
|
|
57
|
+
// Measured in testing, 2026-10-11, on codex 0.162.0 with gpt-6.1-sol: a model that printed a 68,752-character
|
|
58
|
+
// band page to read it named 14 of its 30 records, and at 32,000 characters it named every one. The band
|
|
59
|
+
// server sizes its answers for Claude's limit (70,000 characters, under 25,000 tokens). The vendor's settings
|
|
60
|
+
// for a larger budget (`tool_output_token_limit`, a tool's `output_token_limit`) changed nothing on that
|
|
61
|
+
// version, written in this file or given on the command line. So here the band server answers in 30,000
|
|
62
|
+
// characters: printed with its escapes and banner it stays under the general answer size below, and so under
|
|
63
|
+
// the cut. An install's own smaller size still wins.
|
|
64
|
+
export const CODEX_BAND_ANSWER_CHARS = 30000;
|
|
65
|
+
export const codexBandAnswerChars = (env = process.env) =>
|
|
66
|
+
Math.min(CODEX_BAND_ANSWER_CHARS, Number(env.CLEAROTRON_BAND_RESPONSE_CHARS) || CODEX_BAND_ANSWER_CHARS);
|
|
67
|
+
|
|
68
|
+
// AND EVERY OTHER ANSWER: each server here, the case-law bridges and the page fetcher included, is given an
|
|
69
|
+
// answer size, and an answer longer than that, counted as a quoted string, is served in parts, the tool
|
|
70
|
+
// running once (shared/answer-parts.mjs). 36,000 bytes leaves room under the cut for the engine's own
|
|
71
|
+
// wrapping (about 840 bytes); the band server's own answers stay under it and are never split again. An
|
|
72
|
+
// install's own smaller size still wins.
|
|
73
|
+
export const CODEX_TOOL_ANSWER_BYTES = 36000;
|
|
74
|
+
export const codexToolAnswerBytes = (env = process.env) =>
|
|
75
|
+
Math.min(CODEX_TOOL_ANSWER_BYTES, Number(env.CLEAROTRON_TOOL_ANSWER_BYTES) || CODEX_TOOL_ANSWER_BYTES);
|
|
76
|
+
|
|
53
77
|
// ── TOML value escaping (basic strings) ──────────────────────────────────────────────────────────────
|
|
54
78
|
// One encoder for every TOML block the product writes; re-exported so this module's callers keep their import.
|
|
55
79
|
export { tomlString };
|
|
@@ -121,7 +145,7 @@ export function webFetchRequested(allowedTools) {
|
|
|
121
145
|
// stays the backstop. Absent/0/negative ⇒ emit nothing and leave codex's default alone, so this is inert
|
|
122
146
|
// for any caller that does not thread a budget.
|
|
123
147
|
export function renderCodexConfigToml({ mcpConfig, allowedTools, developerInstructions, nodeBin = process.execPath, credEnvForward = [...CRED_ENV_FORWARD, ...TOOL_SERVER_SETTINGS], toolTimeoutSec, fence = null, withheldFromCommands = [],
|
|
124
|
-
platform = process.platform } = {}) {
|
|
148
|
+
platform = process.platform, bandAnswerChars = codexBandAnswerChars(), toolAnswerBytes = codexToolAnswerBytes() } = {}) {
|
|
125
149
|
const servers = parseClaudeMcpServers(mcpConfig);
|
|
126
150
|
const enabled = enabledToolsByServer(allowedTools);
|
|
127
151
|
const toolTimeout = Number(toolTimeoutSec) > 0 ? Math.floor(Number(toolTimeoutSec)) : undefined;
|
|
@@ -129,6 +153,14 @@ export function renderCodexConfigToml({ mcpConfig, allowedTools, developerInstru
|
|
|
129
153
|
if (developerInstructions) lines.push(`developer_instructions = ${tomlString(developerInstructions)}`, "");
|
|
130
154
|
// Top-level, so above the first table: TOML reads a bare key after a table header as that table's.
|
|
131
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"`, "");
|
|
132
164
|
lines.push(...commandEnvToml(withheldFromCommands));
|
|
133
165
|
|
|
134
166
|
const emitServer = (name, s, { forwardCreds } = {}) => {
|
|
@@ -167,11 +199,15 @@ export function renderCodexConfigToml({ mcpConfig, allowedTools, developerInstru
|
|
|
167
199
|
lines.push("");
|
|
168
200
|
};
|
|
169
201
|
|
|
170
|
-
|
|
202
|
+
// Every server answers under codex's cut, the band server in its own way too (CODEX_TOOL_ANSWER_BYTES,
|
|
203
|
+
// CODEX_BAND_ANSWER_CHARS, above); `env` wins over a forwarded name.
|
|
204
|
+
const answers = { CLEAROTRON_TOOL_ANSWER_BYTES: String(toolAnswerBytes) };
|
|
205
|
+
for (const [name, s] of Object.entries(servers)) emitServer(name, { ...s, enabledTools: enabled[name],
|
|
206
|
+
env: { ...s.env, ...answers, ...(name === "band" ? { CLEAROTRON_BAND_RESPONSE_CHARS: String(bandAnswerChars) } : {}) } }, { forwardCreds: true });
|
|
171
207
|
|
|
172
208
|
// WebFetch → the engine-local fetch server (codex has no built-in). Needs no creds (no env_vars).
|
|
173
209
|
if (webFetchRequested(allowedTools))
|
|
174
|
-
emitServer("fetch", { command: nodeBin, args: [FETCH_SERVER], env: {}, enabledTools: ["fetch_url"] }, { forwardCreds: false });
|
|
210
|
+
emitServer("fetch", { command: nodeBin, args: [FETCH_SERVER], env: { ...answers }, enabledTools: ["fetch_url"] }, { forwardCreds: false });
|
|
175
211
|
|
|
176
212
|
if (fence) lines.push(...fenceToml(fence));
|
|
177
213
|
// ON WINDOWS, CODEX'S SANDBOX IS ON ONLY WHEN THIS NAMES IT. With no `[windows] sandbox` and no feature
|
|
@@ -834,9 +834,21 @@ function resolveGroup(group) {
|
|
|
834
834
|
}
|
|
835
835
|
}
|
|
836
836
|
|
|
837
|
+
// CLAUDE'S ANSWER SIZE. Claude Code replaces a tool answer longer than 50,000 characters, or 25,000 tokens,
|
|
838
|
+
// with a message naming a file it saved the answer to, and the model must read that file itself, the
|
|
839
|
+
// first read refused when the file is past 25,000 tokens (the vendor's MCP page; measured in testing,
|
|
840
|
+
// 2026-10-11, on 2.1.286 and 2.1.295: 49,159 characters arrived whole, 51,189 were replaced). So every
|
|
841
|
+
// server is given this size, and a longer answer comes in the parts shared/answer-parts.mjs serves, the
|
|
842
|
+
// banners the same on both engines. 45,000 bytes counted as a quoted string is under 50,000 characters, and
|
|
843
|
+
// under 25,000 tokens for any text a register or a search returns. The codex config writes its own, smaller
|
|
844
|
+
// size over this one (codex-config.mjs). An install's own smaller size still wins.
|
|
845
|
+
export const CLAUDE_TOOL_ANSWER_BYTES = 45000;
|
|
846
|
+
export const claudeToolAnswerBytes = (env = process.env) =>
|
|
847
|
+
Math.min(CLAUDE_TOOL_ANSWER_BYTES, Number(env.CLEAROTRON_TOOL_ANSWER_BYTES) || CLAUDE_TOOL_ANSWER_BYTES);
|
|
848
|
+
|
|
837
849
|
// The mcp-config server env carries ONLY the per-run, non-secret values (session key + ledger paths +
|
|
838
|
-
// agent + the run dir the band tools serve). Creds (CORSEARCH_SESSION_KEY /
|
|
839
|
-
// EUIPO_*) are inherited from the engine env.
|
|
850
|
+
// agent + the run dir the band tools serve, and the answer size above). Creds (CORSEARCH_SESSION_KEY /
|
|
851
|
+
// PERPLEXITY_API_KEY / EUIPO_*) are inherited from the engine env.
|
|
840
852
|
function serverEnv({ sessionKey, agent, runDir, recordAxis }) {
|
|
841
853
|
const e = {};
|
|
842
854
|
// — see buildGatherMcpConfig: a register server without a run is refused there, so by the time
|
|
@@ -881,6 +893,7 @@ function serverEnv({ sessionKey, agent, runDir, recordAxis }) {
|
|
|
881
893
|
if (runDir) e.CLEAROTRON_REGISTER_RECORD_LOG = runRecordLogPath(runDir);
|
|
882
894
|
for (const v of ["CLEAROTRON_GATHER_SESSION_ID", "EUIPO_ENVIRONMENT"])
|
|
883
895
|
if (process.env[v]) e[v] = process.env[v];
|
|
896
|
+
e.CLEAROTRON_TOOL_ANSWER_BYTES = String(claudeToolAnswerBytes()); // CLAUDE_TOOL_ANSWER_BYTES, above
|
|
884
897
|
return e;
|
|
885
898
|
}
|
|
886
899
|
|
|
@@ -935,6 +948,8 @@ export function buildGatherMcpConfig(groups = [], { sessionKey, agent, runDir, r
|
|
|
935
948
|
if (runDir) auditEnv.CLEAROTRON_BAND_RUN_DIR = runDir;
|
|
936
949
|
if (sessionKey) auditEnv.CLEAROTRON_GATHER_SESSION_KEY = sessionKey;
|
|
937
950
|
if (agent) auditEnv.CLEAROTRON_GATHER_AGENT = agent;
|
|
951
|
+
// In a run, a fourth: the answer size, because a case-law answer is served in parts like any other.
|
|
952
|
+
if (Object.keys(auditEnv).length) auditEnv.CLEAROTRON_TOOL_ANSWER_BYTES = String(claudeToolAnswerBytes());
|
|
938
953
|
for (const k of bridgeKeys) mcpServers[k] = { command: NODE, args: [BRIDGE, "--server", k], connectionTimeoutMs: 60000, ...(Object.keys(auditEnv).length ? { env: auditEnv } : {}) };
|
|
939
954
|
return Object.keys(mcpServers).length ? { mcpServers } : null;
|
|
940
955
|
}
|
|
@@ -10,6 +10,7 @@ import { dirname, basename } from "node:path";
|
|
|
10
10
|
import { driverDir } from "../../../shared/driver-dir.mjs"; //
|
|
11
11
|
import { underStudioSegment } from "../../../shared/path-seps.mjs"; // Windows builds the path with "\"
|
|
12
12
|
import { serve } from "./stdio-server.mjs";
|
|
13
|
+
import { answerBudget } from "../../../shared/answer-parts.mjs";
|
|
13
14
|
import {
|
|
14
15
|
isKnownPreset, questionPresetFor, buildRequestBody, callAgentAPI, formatResponse, formatSandboxResponse,
|
|
15
16
|
validateGridSpec, buildGridProgramTask, captureGridFromResponse, requiredLedgerRefusal,
|
|
@@ -194,7 +195,10 @@ function tellObligations(spec, ledgerJson) {
|
|
|
194
195
|
// and a grid's candidates grow with its results per cell. So they are served in parts, split between
|
|
195
196
|
// cells, as band_shape serves an oversize shape, with the same part size. A later part is read from the
|
|
196
197
|
// saved ledger: nothing is searched again.
|
|
197
|
-
|
|
198
|
+
// Smaller when this server was given an answer size (codex cuts the middle out of a longer read;
|
|
199
|
+
// shared/answer-parts.mjs): three quarters of it, which leaves room for the rest of the reply and for the
|
|
200
|
+
// quotes and line breaks a printout adds, so a part of the grid is never split again on its way to the model.
|
|
201
|
+
const GRID_PART_CHARS = answerBudget() ? Math.min(70000, Math.floor(answerBudget() * 0.75)) : 70000;
|
|
198
202
|
|
|
199
203
|
// `reserve` is what part 1's reply also carries (the meaning obligations): part 1 is that much smaller,
|
|
200
204
|
// and every call passes the same reserve for the same ledger, so every call splits it the same way.
|
|
@@ -12,6 +12,7 @@ import { createInterface } from "node:readline";
|
|
|
12
12
|
import { appendFileSync, mkdirSync } from "node:fs";
|
|
13
13
|
import { dirname } from "node:path";
|
|
14
14
|
import { driverDir } from "../../../shared/driver-dir.mjs"; //
|
|
15
|
+
import { answerBudget, answerPieces, servedPart, partNotHeld, takePart, heldAnswers, withAnswerPart } from "../../../shared/answer-parts.mjs";
|
|
15
16
|
|
|
16
17
|
// ── half 2 — THE TOOL CALL LOG, so a killed call is a FACT and not an inference ────────────────
|
|
17
18
|
//
|
|
@@ -88,6 +89,10 @@ export function serve({ name, version = "0.1.0", tools = [] }) {
|
|
|
88
89
|
// reader must key on (server, seq) and never on seq alone.
|
|
89
90
|
let callSeq = 0;
|
|
90
91
|
const byName = new Map(tools.map((t) => [t.name, t]));
|
|
92
|
+
// A LONG ANSWER IS SERVED IN PARTS when this server was given an answer size (shared/answer-parts.mjs):
|
|
93
|
+
// the handler runs once, the answer is held here, and a later part is read from it. No size, no change.
|
|
94
|
+
const budget = answerBudget();
|
|
95
|
+
const held = heldAnswers();
|
|
91
96
|
// ── — THE ANNOTATIONS TRAVEL, BECAUSE A TOOL MUST BE ABLE TO SAY WHAT IT DOES ──
|
|
92
97
|
//
|
|
93
98
|
// This line used to pick three fields and drop the rest, so a tool that declared `annotations` had
|
|
@@ -117,9 +122,10 @@ export function serve({ name, version = "0.1.0", tools = [] }) {
|
|
|
117
122
|
// convenience — the exact thing annotations exist to prevent.
|
|
118
123
|
// Listed when asked, not at start: a tool may give the schema for the run it serves (`schemaNow`), read
|
|
119
124
|
// at call time like every per-run read here. A tool without one lists its declared schema, as before.
|
|
125
|
+
const schemaOf = (t) => (typeof t.schemaNow === "function" ? t.schemaNow(t.inputSchema) : t.inputSchema);
|
|
120
126
|
const list = () => tools.map((t) => ({
|
|
121
127
|
name: t.name, description: t.description,
|
|
122
|
-
inputSchema:
|
|
128
|
+
inputSchema: budget ? withAnswerPart(schemaOf(t)) : schemaOf(t),
|
|
123
129
|
...(t.annotations ? { annotations: t.annotations } : {}),
|
|
124
130
|
}));
|
|
125
131
|
const send = (m) => process.stdout.write(JSON.stringify(m) + "\n");
|
|
@@ -144,11 +150,20 @@ export function serve({ name, version = "0.1.0", tools = [] }) {
|
|
|
144
150
|
} else if (method === "tools/call") {
|
|
145
151
|
const tool = byName.get(params?.name);
|
|
146
152
|
if (!tool) return ok(id, { isError: true, content: [{ type: "text", text: `unknown tool: ${params?.name}` }] });
|
|
153
|
+
// A later part of a held answer is read from it, and the tool does not run: no started/settled pair,
|
|
154
|
+
// one row of its own, so a count of the tool's runs stays a count of its runs.
|
|
155
|
+
const { args, part } = budget ? takePart(params?.arguments) : { args: params?.arguments, part: null };
|
|
156
|
+
const kept = part ? held.get(tool.name, args) : null;
|
|
157
|
+
if (part && (kept || part > 1)) {
|
|
158
|
+
logToolEvent({ event: "answer-part", server: name, tool: tool.name, part, ...(kept ? { of: kept.length } : { held: false }) });
|
|
159
|
+
return ok(id, kept ? { content: [{ type: "text", text: servedPart(tool.name, kept, part) }] }
|
|
160
|
+
: { isError: true, content: [{ type: "text", text: partNotHeld(tool.name, part) }] });
|
|
161
|
+
}
|
|
147
162
|
// — STAMPED BEFORE THE WORK, settled after it, and the pair is the whole mechanism. A
|
|
148
163
|
// process killed mid-call cannot write anything, so "started with no settled" is the only shape
|
|
149
164
|
// that can record a call that never returned.
|
|
150
165
|
const seq = ++callSeq;
|
|
151
|
-
const axis = typeof
|
|
166
|
+
const axis = typeof args?.axis === "string" ? args.axis : null;
|
|
152
167
|
logToolEvent({ event: "started", seq, server: name, tool: tool.name, ...(axis ? { axis } : {}) });
|
|
153
168
|
// ── — THE ONE SEAM EVERY TYPED CALL PASSES THROUGH ───────────────────────
|
|
154
169
|
//
|
|
@@ -157,7 +172,7 @@ export function serve({ name, version = "0.1.0", tools = [] }) {
|
|
|
157
172
|
// re-check by hand, which made "this field is required" a property of one author's diligence
|
|
158
173
|
// rather than of the contract. The refusal names the fields in the acceptors' own token shape so
|
|
159
174
|
// a seat can act on it in the turn it happens.
|
|
160
|
-
const unmet = requiredFieldViolations(tool.inputSchema,
|
|
175
|
+
const unmet = requiredFieldViolations(tool.inputSchema, args);
|
|
161
176
|
if (unmet.length) {
|
|
162
177
|
// THE REASON THE ROW HAD IN SCOPE AND THREW AWAY. A settled row with
|
|
163
178
|
// `ok: false` and nothing else asserts a failure and withholds everything that would let a
|
|
@@ -177,7 +192,7 @@ export function serve({ name, version = "0.1.0", tools = [] }) {
|
|
|
177
192
|
+ `${unmet.length === 1 ? "it" : "them"}. Send the field itself, not null and not a placeholder.` }] });
|
|
178
193
|
}
|
|
179
194
|
let out;
|
|
180
|
-
try { out = await tool.handler(
|
|
195
|
+
try { out = await tool.handler(args || {}); }
|
|
181
196
|
catch (e) {
|
|
182
197
|
// A THROWN tool error SETTLES. It returned an answer — a wrong one, but the model saw it and
|
|
183
198
|
// could act on it. Recording it as unsettled would attribute a live failure to a timeout. The
|
|
@@ -193,6 +208,11 @@ export function serve({ name, version = "0.1.0", tools = [] }) {
|
|
|
193
208
|
const code = isError ? complaintCode(text) : null;
|
|
194
209
|
logToolEvent({ event: "settled", seq, server: name, tool: tool.name, ...(axis ? { axis } : {}), ok: !isError,
|
|
195
210
|
...(isError ? { failure: "server", ...(code ? { reason: code } : {}) } : {}) });
|
|
211
|
+
const pieces = budget && !isError ? answerPieces(text, budget, tool.name) : null;
|
|
212
|
+
if (pieces && pieces.length > 1) {
|
|
213
|
+
held.hold(tool.name, args, pieces);
|
|
214
|
+
return ok(id, { content: [{ type: "text", text: servedPart(tool.name, pieces, 1) }] });
|
|
215
|
+
}
|
|
196
216
|
ok(id, { isError: isError || undefined, content: [{ type: "text", text }] });
|
|
197
217
|
} else if (id != null) {
|
|
198
218
|
err(id, -32601, `method not found: ${method}`);
|
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": {
|