clearotron 0.3.3-beta.0 → 0.3.3
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/bin/onboard.mjs +80 -6
- package/build-info.json +2 -2
- package/docs/architecture/04-configuration-reference.md +28 -10
- package/docs/releases/0.3.3.md +52 -0
- package/driver/CHANGELOG.md +56 -0
- package/driver/citation-census.json +3 -3
- package/driver/config-inventory.mjs +1 -1
- package/driver/dev-portal.mjs +3 -3
- package/driver/driver.config.mjs +80 -9
- package/driver/engine/CONTRACT.md +3 -2
- package/driver/engine/anthropic-agent.mjs +34 -7
- package/driver/engine/mcp/probe-server.mjs +12 -3
- package/driver/package.json +1 -1
- package/driver/pipeline-knockout.mjs +3 -3
- package/driver/pipeline.mjs +14 -14
- package/driver/plan-run-agreement-verdict.mjs +49 -0
- package/driver/portal-service.mjs +8 -4
- package/driver/progress.mjs +14 -3
- package/driver/publish/index.mjs +1 -1
- package/driver/publish/report-data.mjs +4 -3
- package/driver/reference-score.mjs +10 -2
- package/driver/settle-stamp.mjs +10 -3
- package/driver/status-snapshot.mjs +2 -2
- package/driver/suite-census.json +61 -19
- package/mcp-server/CHANGELOG.md +8 -0
- package/mcp-server/lib/brief.mjs +11 -5
- package/mcp-server/package.json +1 -1
- package/package.json +2 -2
- package/portal-ui/package.json +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/scripts/e2e.mjs +1 -1
- package/scripts/live-surface-check.mjs +6 -14
- package/scripts/repo-writes.mjs +1 -1
- package/scripts/report-sections-render-check.mjs +7 -3
- package/scripts/score.mjs +7 -1
- package/shared/scroll-settle.mjs +67 -0
package/mcp-server/CHANGELOG.md
CHANGED
package/mcp-server/lib/brief.mjs
CHANGED
|
@@ -44,6 +44,11 @@ function plainClause(s) {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
const titleCase = (w) => (w ? w.charAt(0) + w.slice(1).toLowerCase() : w);
|
|
47
|
+
// A band arrives as its word ("High") or as the derived record publish writes for the run's verdict
|
|
48
|
+
// ({ label, rankFromTop, scale }). Stringifying the record printed "Overall risk: [object object]."
|
|
49
|
+
// on the assistant's summary; every band read here goes through this, so either shape gives the word.
|
|
50
|
+
const bandLabel = (b) => (b && typeof b === "object" ? (typeof b.label === "string" && b.label.trim() ? b.label : null)
|
|
51
|
+
: (b == null || String(b).trim() === "" ? null : String(b)));
|
|
47
52
|
|
|
48
53
|
// One clearance finding → one line. NO SECOND FILTER: report-data.json already carries only the live
|
|
49
54
|
// findings (a withdrawn one is not in the file), so the brief's conflicts and the report's findings are
|
|
@@ -51,7 +56,7 @@ const titleCase = (w) => (w ? w.charAt(0) + w.slice(1).toLowerCase() : w);
|
|
|
51
56
|
// shorter list than the document the client was holding.
|
|
52
57
|
function clearanceLine(f) {
|
|
53
58
|
const who = [f.mark, f.owner?.name].filter(Boolean).join(" — ") || "(unnamed finding)";
|
|
54
|
-
const band = f.band ? ` — ${titleCase(
|
|
59
|
+
const band = bandLabel(f.band) ? ` — ${titleCase(bandLabel(f.band))} risk.` : "";
|
|
55
60
|
return `- **${who}**${band}${f.net ? ` ${f.net}` : ""}`.trimEnd();
|
|
56
61
|
}
|
|
57
62
|
|
|
@@ -77,7 +82,8 @@ export function buildBrief(run) {
|
|
|
77
82
|
// THE BAND, AND NEVER THE GATE'S WORD. This chain used to fall through to the delivery verdict — the
|
|
78
83
|
// sidecar's `verdict`, then the run's — so a run whose report reads Medium could be briefed as BLOCKING.
|
|
79
84
|
// The band is what the report shows; where no band is recorded the line is not drawn at all.
|
|
80
|
-
const
|
|
85
|
+
const rating = clearance?.rating ?? clearance?.verdict ?? null; // `verdict` on a report-data file written before the rename
|
|
86
|
+
const overall = bandLabel(rating?.band) ?? rating?.tier
|
|
81
87
|
?? (koDocs.length === 1 ? (koDocs[0].overall ?? null) : null)
|
|
82
88
|
?? fm.overall_label ?? run.tier ?? null;
|
|
83
89
|
|
|
@@ -118,7 +124,7 @@ export function buildBrief(run) {
|
|
|
118
124
|
}
|
|
119
125
|
// Conditions gate a clean result; advisories never do, so only the conditions ride the briefing.
|
|
120
126
|
const conditions = [
|
|
121
|
-
...(
|
|
127
|
+
...(rating?.conditions ?? []),
|
|
122
128
|
...(clearance.actions?.conditions ?? []).map((a) => a?.text),
|
|
123
129
|
].filter(Boolean);
|
|
124
130
|
if (conditions.length) {
|
|
@@ -135,7 +141,7 @@ export function buildBrief(run) {
|
|
|
135
141
|
lines.push("", "**Each name screened:**");
|
|
136
142
|
for (const d of koDocs) {
|
|
137
143
|
for (const m of (d.marks ?? [])) {
|
|
138
|
-
const band = m.band ? `${titleCase(
|
|
144
|
+
const band = bandLabel(m.band) ? `${titleCase(bandLabel(m.band))}${m.qualifier ? ` (${m.qualifier})` : ""}` : "unrated";
|
|
139
145
|
lines.push(`- **${m.name}** — ${band}.${d.url ? ` Report: ${d.url}` : ""}`);
|
|
140
146
|
for (const f of (m.findings ?? [])) {
|
|
141
147
|
const who = [f.name, f.owner].filter(Boolean).join(" — ");
|
|
@@ -149,7 +155,7 @@ export function buildBrief(run) {
|
|
|
149
155
|
// was: a typed conflict already leads with its own band on the report, and widening this to all
|
|
150
156
|
// findings would change what this briefing says about runs that have no register layer at all.
|
|
151
157
|
if (f.shape === "register") {
|
|
152
|
-
const rating = f.band ? ` — ${titleCase(
|
|
158
|
+
const rating = bandLabel(f.band) ? ` — ${titleCase(bandLabel(f.band))} risk.` : "";
|
|
153
159
|
const read = f.basis && f.basis !== f.net ? ` ${f.basis}` : "";
|
|
154
160
|
lines.push(` - ${who}${rating}${f.net ? ` ${f.net}` : ""}${read}`.trimEnd());
|
|
155
161
|
continue;
|
package/mcp-server/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trademark-artifacts-mcp",
|
|
3
|
-
"version": "0.3.3
|
|
3
|
+
"version": "0.3.3",
|
|
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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clearotron",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.3.3
|
|
4
|
+
"version": "0.3.3",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
"assert-census": "node scripts/unexecuted-asserts.mjs"
|
|
67
67
|
},
|
|
68
68
|
"devDependencies": {
|
|
69
|
-
"@changesets/cli": "3.0.
|
|
69
|
+
"@changesets/cli": "3.0.3",
|
|
70
70
|
"@types/node": "^26",
|
|
71
71
|
"acorn": "^8.18.0",
|
|
72
72
|
"eslint": "^10.8.1",
|
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.3.3
|
|
5
|
+
"version": "0.3.3",
|
|
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": {
|
package/scripts/e2e.mjs
CHANGED
|
@@ -3207,7 +3207,7 @@ async function cmdReport(id, { round: requestedToken = null } = {}) {
|
|
|
3207
3207
|
// Five test runs did exactly this in the seven days to 2026-08-25. The answer to "was this
|
|
3208
3208
|
// delivered" is therefore its own line, above, in the words used for a failed order.
|
|
3209
3209
|
console.log(` ${deliveryLine(st)}`);
|
|
3210
|
-
console.log(` state=${st.state ?? "?"}
|
|
3210
|
+
console.log(` state=${st.state ?? "?"} tier=${st.tier ?? "-"} signoff=${st.review?.signoff ?? st.verdict ?? "-"} sendPending=${st.sendPending}`);
|
|
3211
3211
|
|
|
3212
3212
|
// Which skills changed since this run started — the resume question, stated as fact not as a rule.
|
|
3213
3213
|
// — THE THREE ANSWERS THIS CHECK CAN GIVE, and it used to give one of them silently.
|
|
@@ -106,6 +106,7 @@ import { processTable } from "../shared/process-table.mjs";
|
|
|
106
106
|
import { envFrom } from "../shared/env-aliases.mjs";
|
|
107
107
|
import { gitTry, treeOf } from "../shared/tree-commit.mjs"; // — a packaged install has no git, and says its commit in build-info.json
|
|
108
108
|
import { exitFor } from "../driver/surface-exit-verdict.mjs"; // — a check that could not look is not a drift, and they want different things done // — the name a reader is told to set is the one in force
|
|
109
|
+
import { planRunAgreementVerdict } from "../driver/plan-run-agreement-verdict.mjs";
|
|
109
110
|
|
|
110
111
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
111
112
|
const asJson = process.argv.includes("--json");
|
|
@@ -735,20 +736,11 @@ if (mcpOptions && built) {
|
|
|
735
736
|
//
|
|
736
737
|
// plan_run needs an explicit profileKey: an accounts-scoped session refuses without one. The key
|
|
737
738
|
// comes from what the door ITSELF resolved (below), so no customer is ever hardcoded here.
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
const unavailable = (plan?.blockers ?? []).some((b) => /not part of the current release|not switched on|unavailable/i.test(String(b)));
|
|
744
|
-
if (unavailable !== !doorSays.get(key)) {
|
|
745
|
-
planDisagreements.push(`${key}: describe_options=${doorSays.get(key) ? "available" : "unavailable"} plan_run=${unavailable ? "unavailable" : "available"}`);
|
|
746
|
-
}
|
|
747
|
-
} catch (e) { planDisagreements.push(`${key}: plan_run errored — ${e.message.slice(0, 120)}`); }
|
|
748
|
-
}
|
|
749
|
-
if (!probeProfileKey) skip("describe_options and plan_run agree", "no customer resolved to plan against — see the roster check");
|
|
750
|
-
else if (planDisagreements.length) fail("describe_options and plan_run agree", planDisagreements.join(" · "));
|
|
751
|
-
else pass("describe_options and plan_run agree", `${seen.length} products checked through both code paths`);
|
|
739
|
+
// A plan_run that throws (a 429, a timeout) compared nothing: a marked skip, exit 3, never a drift.
|
|
740
|
+
const pv = await planRunAgreementVerdict({ keys: seen, doorSays, probeProfileKey,
|
|
741
|
+
ask: (key) => mcpToolCall({ url: MCP_URL, token: OPS_TOKEN, tool: "plan_run",
|
|
742
|
+
args: { markName: "SURFACE CHECK", classes: [9], product: key, profileKey: probeProfileKey }, timeoutMs: 20000 }) });
|
|
743
|
+
record("describe_options and plan_run agree", pv.state, pv.message, pv.blocked === true);
|
|
752
744
|
}
|
|
753
745
|
|
|
754
746
|
// 5. Leak scan on what a door actually returned. An env name or a path in a live response is a defect
|
package/scripts/repo-writes.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// attribute the red that produces: it surfaces in a file whose diff is empty, on another branch, in
|
|
12
12
|
// another agent's session, and it is intermittent — the three properties that make a defect expensive.
|
|
13
13
|
//
|
|
14
|
-
// `doctor-refuses-what-cannot-run.test.mjs
|
|
14
|
+
// `doctor-refuses-what-cannot-run.test.mjs` already states the rule in prose: moving the real
|
|
15
15
|
// `portal-ui/dist` aside "would have been shorter and is wrong". Prose is not an instrument. This is.
|
|
16
16
|
//
|
|
17
17
|
// THE RULE IS ABSOLUTE, AND IT IS NOT A QUESTION ABOUT `git`. Tracked, untracked and ignored are the
|
|
@@ -27,6 +27,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
27
27
|
import { reapOnExit } from '../shared/reap-on-exit.mjs'
|
|
28
28
|
import { browserRun } from '../shared/browser-temp-root.mjs'
|
|
29
29
|
import { prepareReportForEmbed } from '../driver/portal-report.mjs'
|
|
30
|
+
import { scrollAfterPress } from '../shared/scroll-settle.mjs'
|
|
30
31
|
|
|
31
32
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
32
33
|
const ROOT = join(HERE, '..')
|
|
@@ -225,11 +226,14 @@ for (const width of [1440, 400]) {
|
|
|
225
226
|
const target = 2
|
|
226
227
|
const before = await value('window.scrollY')
|
|
227
228
|
await value(`document.querySelectorAll('nav.report-sections .report-section')[${target}].click()`)
|
|
228
|
-
|
|
229
|
-
|
|
229
|
+
// The jump is animated and starts on the browser's own schedule, so the page is given a deadline to
|
|
230
|
+
// START moving before it is read as still. Waiting only for the position to settle answers "it never
|
|
231
|
+
// moved" for a scroll that had not yet begun — see scroll-settle.mjs.
|
|
232
|
+
const { moved, y: settled, waitedMs } = await scrollAfterPress({ read: () => value('window.scrollY'), wait, from: before })
|
|
230
233
|
const s = await strip()
|
|
231
234
|
const foot = await atFoot()
|
|
232
|
-
if (!
|
|
235
|
+
if (!moved) fail.push(`${at}: pressing "${SECTIONS[target]}" did not move the page in ${waitedMs}ms`)
|
|
236
|
+
else if (!(settled > before)) fail.push(`${at}: pressing "${SECTIONS[target]}" left the page at ${settled}, not below ${before}`)
|
|
233
237
|
else if (filled(s) !== (foot ? SECTIONS.length : target + 1)) fail.push(`${at}: after the jump to "${SECTIONS[target]}" the strip reads "${s}"`)
|
|
234
238
|
else ok.push(`${at}: a press jumps to "${SECTIONS[target]}" and marks it — ${s}`)
|
|
235
239
|
}
|
package/scripts/score.mjs
CHANGED
|
@@ -245,9 +245,15 @@ function scopeOf(runDir, ref) {
|
|
|
245
245
|
const instructed = readJson(driverDir(runDir, "instructed-scope.json"));
|
|
246
246
|
const cls = instructed?.classes ?? instructed?.nice_classes ?? null;
|
|
247
247
|
const terr = instructed?.jurisdictions ?? instructed?.territories ?? null;
|
|
248
|
+
// A worldwide request is recorded as a MODE, not a list entry: intake takes the word off the territory
|
|
249
|
+
// list and stamps `geography.mode` (enqueue-schema.mjs, GEOGRAPHY_MODES). So a worldwide run carries no
|
|
250
|
+
// territories, and falling through to the reference's own scope would score the run against what the
|
|
251
|
+
// lawyer wrote there instead of what the run was asked. The run's record wins, stated as the mode.
|
|
252
|
+
const worldwide = instructed?.geography?.mode === "worldwide";
|
|
248
253
|
return {
|
|
249
254
|
classes: Array.isArray(cls) && cls.length ? cls.map(String) : (ref.scope?.classes ?? []).map(String),
|
|
250
|
-
territories: Array.isArray(terr) && terr.length ? terr.map(String)
|
|
255
|
+
territories: Array.isArray(terr) && terr.length ? terr.map(String)
|
|
256
|
+
: worldwide ? ["worldwide"] : (ref.scope?.territories ?? []).map(String),
|
|
251
257
|
};
|
|
252
258
|
}
|
|
253
259
|
|
|
@@ -0,0 +1,67 @@
|
|
|
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
|
+
// scroll-settle.mjs — after a press, wait for the page to START moving, then for it to STOP.
|
|
4
|
+
//
|
|
5
|
+
// ── WHY A SETTLE WAIT ALONE IS NOT ENOUGH, WHICH IS THE WHOLE POINT ──────────────────────────────
|
|
6
|
+
//
|
|
7
|
+
// A check that presses something and reads the scroll position wants the position the page came to
|
|
8
|
+
// rest at. The obvious shape reads twice and stops when two reads agree:
|
|
9
|
+
//
|
|
10
|
+
// let settled = before, last = -1
|
|
11
|
+
// for (let i = 0; i < 40 && settled !== last; i++) { last = settled; await wait(150); settled = await read() }
|
|
12
|
+
//
|
|
13
|
+
// That is a settle detector, and it cannot tell a page that has STOPPED from one that has not STARTED.
|
|
14
|
+
// A smooth scroll begins on the browser's own schedule; when it has not begun by the first read, the
|
|
15
|
+
// first two reads are both the position before the press, they agree, the loop ends, and the caller is
|
|
16
|
+
// told the page never moved. The page then moves, a moment after nobody is looking.
|
|
17
|
+
//
|
|
18
|
+
// This failed once in continuous integration on a branch whose range touched no part of that page, and
|
|
19
|
+
// passed on re-run with nothing changed — the signature of a measurement that races the thing it
|
|
20
|
+
// measures rather than a defect in the page.
|
|
21
|
+
//
|
|
22
|
+
// So the wait is in two parts, and only the first is new: hold until the position CHANGES, giving up at
|
|
23
|
+
// a deadline; then hold until it stops changing. A page that does not move is now told apart from one
|
|
24
|
+
// that has not moved yet by how long it was given — which is why the deadline is returned, for the
|
|
25
|
+
// caller to name in its failure. A caller that says only "it did not move" leaves the next reader
|
|
26
|
+
// unable to tell a real defect from this race.
|
|
27
|
+
//
|
|
28
|
+
// PURE, with the read and the clock injected, so a test drives every path without a browser: a wait
|
|
29
|
+
// that can only be exercised against a real renderer cannot be shown to fail.
|
|
30
|
+
|
|
31
|
+
// Long enough that a scroll which has not begun by then is not merely late. The press is animated, so
|
|
32
|
+
// this is a deadline for the FIRST movement, never for the whole journey — the settle below carries that.
|
|
33
|
+
export const MOVE_DEADLINE_MS = 4000;
|
|
34
|
+
const STEP_MS = 150;
|
|
35
|
+
const QUIET_STEPS = 40;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Wait for a press to move the page, then for the movement to stop.
|
|
39
|
+
*
|
|
40
|
+
* @param {object} o
|
|
41
|
+
* @param {() => Promise<number|null>} o.read reads the scroll position now
|
|
42
|
+
* @param {(ms: number) => Promise<void>} o.wait sleeps
|
|
43
|
+
* @param {number} o.from the position before the press
|
|
44
|
+
* @param {() => number} [o.now] the clock the deadline is measured on
|
|
45
|
+
* @returns {Promise<{moved: boolean, y: number|null, waitedMs: number}>}
|
|
46
|
+
* `moved` false means the position never changed within `waitedMs` — the page was given that long.
|
|
47
|
+
*/
|
|
48
|
+
export async function scrollAfterPress({ read, wait, from, now = Date.now,
|
|
49
|
+
deadlineMs = MOVE_DEADLINE_MS, stepMs = STEP_MS, quietSteps = QUIET_STEPS }) {
|
|
50
|
+
const started = now();
|
|
51
|
+
let y = from;
|
|
52
|
+
// FIRST, THAT IT MOVED AT ALL. The deadline is what makes the answer below a finding rather than a race.
|
|
53
|
+
while (y === from) {
|
|
54
|
+
if (now() - started >= deadlineMs) return { moved: false, y, waitedMs: now() - started };
|
|
55
|
+
await wait(stepMs);
|
|
56
|
+
y = await read();
|
|
57
|
+
}
|
|
58
|
+
// THEN, WHERE IT CAME TO REST. `last` starts at a value no read returns, so the position is always
|
|
59
|
+
// read at least once more: the first changed position is mid-animation, not the answer.
|
|
60
|
+
let last = null;
|
|
61
|
+
for (let i = 0; i < quietSteps && y !== last; i++) {
|
|
62
|
+
last = y;
|
|
63
|
+
await wait(stepMs);
|
|
64
|
+
y = await read();
|
|
65
|
+
}
|
|
66
|
+
return { moved: true, y, waitedMs: now() - started };
|
|
67
|
+
}
|