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.
Files changed (37) hide show
  1. package/bin/onboard.mjs +80 -6
  2. package/build-info.json +2 -2
  3. package/docs/architecture/04-configuration-reference.md +28 -10
  4. package/docs/releases/0.3.3.md +52 -0
  5. package/driver/CHANGELOG.md +56 -0
  6. package/driver/citation-census.json +3 -3
  7. package/driver/config-inventory.mjs +1 -1
  8. package/driver/dev-portal.mjs +3 -3
  9. package/driver/driver.config.mjs +80 -9
  10. package/driver/engine/CONTRACT.md +3 -2
  11. package/driver/engine/anthropic-agent.mjs +34 -7
  12. package/driver/engine/mcp/probe-server.mjs +12 -3
  13. package/driver/package.json +1 -1
  14. package/driver/pipeline-knockout.mjs +3 -3
  15. package/driver/pipeline.mjs +14 -14
  16. package/driver/plan-run-agreement-verdict.mjs +49 -0
  17. package/driver/portal-service.mjs +8 -4
  18. package/driver/progress.mjs +14 -3
  19. package/driver/publish/index.mjs +1 -1
  20. package/driver/publish/report-data.mjs +4 -3
  21. package/driver/reference-score.mjs +10 -2
  22. package/driver/settle-stamp.mjs +10 -3
  23. package/driver/status-snapshot.mjs +2 -2
  24. package/driver/suite-census.json +61 -19
  25. package/mcp-server/CHANGELOG.md +8 -0
  26. package/mcp-server/lib/brief.mjs +11 -5
  27. package/mcp-server/package.json +1 -1
  28. package/package.json +2 -2
  29. package/portal-ui/package.json +1 -1
  30. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  31. package/providers/oauth-mcp-bridge/package.json +1 -1
  32. package/scripts/e2e.mjs +1 -1
  33. package/scripts/live-surface-check.mjs +6 -14
  34. package/scripts/repo-writes.mjs +1 -1
  35. package/scripts/report-sections-render-check.mjs +7 -3
  36. package/scripts/score.mjs +7 -1
  37. package/shared/scroll-settle.mjs +67 -0
@@ -1,5 +1,13 @@
1
1
  # trademark-artifacts-mcp
2
2
 
3
+ ## 0.3.3
4
+
5
+ No changes in this release.
6
+
7
+ ## 0.3.3-beta.1
8
+
9
+ No changes in this release.
10
+
3
11
  ## 0.3.3-beta.0
4
12
 
5
13
  No changes in this release.
@@ -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(String(f.band))} risk.` : "";
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 overall = clearance?.verdict?.band ?? clearance?.verdict?.tier
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
- ...(clearance.verdict?.conditions ?? []),
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(String(m.band))}${m.qualifier ? ` (${m.qualifier})` : ""}` : "unrated";
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(String(f.band))} risk.` : "";
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;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-artifacts-mcp",
3
- "version": "0.3.3-beta.0",
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-beta.0",
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.2",
69
+ "@changesets/cli": "3.0.3",
70
70
  "@types/node": "^26",
71
71
  "acorn": "^8.18.0",
72
72
  "eslint": "^10.8.1",
@@ -2,7 +2,7 @@
2
2
  "name": "portal-ui",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.3.3-beta.0",
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": {
@@ -1,5 +1,13 @@
1
1
  # trademark-oauth-mcp-bridge
2
2
 
3
+ ## 0.3.3
4
+
5
+ No changes in this release.
6
+
7
+ ## 0.3.3-beta.1
8
+
9
+ No changes in this release.
10
+
3
11
  ## 0.3.3-beta.0
4
12
 
5
13
  No changes in this release.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-oauth-mcp-bridge",
3
- "version": "0.3.3-beta.0",
3
+ "version": "0.3.3",
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).",
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 ?? "?"} verdict=${st.verdict ?? "-"} sendPending=${st.sendPending}`);
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
- const planDisagreements = [];
739
- for (const key of (probeProfileKey ? seen : [])) {
740
- try {
741
- const plan = await mcpToolCall({ url: MCP_URL, token: OPS_TOKEN, tool: "plan_run",
742
- args: { markName: "SURFACE CHECK", classes: [9], product: key, profileKey: probeProfileKey }, timeoutMs: 20000 });
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
@@ -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:70` already states the rule in prose: moving the real
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
- let settled = before, last = -1
229
- for (let i = 0; i < 40 && settled !== last; i++) { last = settled; await wait(150); settled = await value('window.scrollY') }
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 (!(settled > before)) fail.push(`${at}: pressing "${SECTIONS[target]}" did not move the page`)
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) : (ref.scope?.territories ?? []).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
+ }