clearotron 0.3.0-beta.2 → 0.3.0-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.
Files changed (40) hide show
  1. package/INSTALL.md +32 -6
  2. package/bin/connect.mjs +68 -25
  3. package/bin/disconnect.mjs +16 -11
  4. package/bin/onboard.mjs +65 -6
  5. package/bin/start.mjs +7 -1
  6. package/build-info.json +2 -2
  7. package/driver/CHANGELOG.md +38 -0
  8. package/driver/driver.config.mjs +5 -0
  9. package/driver/engine/anthropic-agent.mjs +34 -17
  10. package/driver/gateway.mjs +76 -15
  11. package/driver/package.json +1 -1
  12. package/driver/portal-service.mjs +1 -1
  13. package/driver/profile-service.mjs +31 -3
  14. package/driver/publish/index.mjs +12 -9
  15. package/driver/publish/knockout.mjs +4 -2
  16. package/driver/publish/office-record-links.mjs +56 -21
  17. package/driver/recipe-service.mjs +14 -5
  18. package/driver/record-origins.mjs +14 -0
  19. package/driver/suite-census.json +34 -28
  20. package/mcp-server/CHANGELOG.md +8 -0
  21. package/mcp-server/lib/driver.mjs +2 -0
  22. package/mcp-server/lib/knockout.mjs +2 -2
  23. package/mcp-server/lib/options.mjs +9 -2
  24. package/mcp-server/package.json +1 -1
  25. package/package.json +1 -1
  26. package/portal-ui/dist/assets/{index-CsCuPshD.css → index-Cv-E_agg.css} +199 -86
  27. package/portal-ui/dist/assets/{index-CcFjgM78.js → index-DWYCsOCJ.js} +514 -380
  28. package/portal-ui/dist/index.html +2 -2
  29. package/portal-ui/package.json +1 -1
  30. package/providers/clarivate/src/core.js +5 -0
  31. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  32. package/providers/oauth-mcp-bridge/package.json +1 -1
  33. package/scripts/e2e-unread-terminals.mjs +1 -1
  34. package/scripts/e2e.mjs +114 -9
  35. package/scripts/revisit-render-check.mjs +22 -4
  36. package/scripts/travelling-predicates.mjs +1 -1
  37. package/shared/connect-clients.mjs +282 -321
  38. package/shared/names-in-force.mjs +1 -0
  39. package/shared/stdio-connect.mjs +63 -0
  40. package/shared/store-in-repo.mjs +50 -2
@@ -49,8 +49,8 @@
49
49
  -->
50
50
  <link rel="preconnect" href="https://api.fontshare.com" crossorigin />
51
51
  <link href="https://api.fontshare.com/v2/css?f[]=satoshi@400,500,700,900&display=swap" rel="stylesheet" />
52
- <script type="module" crossorigin src="/portal/assets/index-CcFjgM78.js"></script>
53
- <link rel="stylesheet" crossorigin href="/portal/assets/index-CsCuPshD.css">
52
+ <script type="module" crossorigin src="/portal/assets/index-DWYCsOCJ.js"></script>
53
+ <link rel="stylesheet" crossorigin href="/portal/assets/index-Cv-E_agg.css">
54
54
  </head>
55
55
  <body>
56
56
  <div id="root"></div>
@@ -2,7 +2,7 @@
2
2
  "name": "portal-ui",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.3.0-beta.2",
5
+ "version": "0.3.0-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": {
@@ -1214,6 +1214,11 @@ export async function doBatchScreen(apiKey, base, params, tctx) {
1214
1214
  application_date: nr.applicationDate,
1215
1215
  registration_date: nr.registrationDate,
1216
1216
  expiry_date: nr.expiryDate,
1217
+ // The office's own numbers, which the record carries and a reader looks the record up by. A
1218
+ // knockout's filings list is built from these rows, and without them it could name no record a
1219
+ // reader can open.
1220
+ application_number: nr.applicationNumber,
1221
+ registration_number: nr.registrationNumber,
1217
1222
  live_status: liveStatusOf(rec),
1218
1223
  all_class: isAllClass(nr.niceClasses),
1219
1224
  };
@@ -1,5 +1,13 @@
1
1
  # trademark-oauth-mcp-bridge
2
2
 
3
+ ## 0.3.0-beta.4
4
+
5
+ No changes in this release.
6
+
7
+ ## 0.3.0-beta.3
8
+
9
+ No changes in this release.
10
+
3
11
  ## 0.3.0-beta.2
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.0-beta.2",
3
+ "version": "0.3.0-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).",
@@ -240,7 +240,7 @@ export function classify(doors, names, now, probe = runStateProbe) {
240
240
  // dropped off this list while remaining exactly as unclosed as the ones that were never opened —
241
241
  // so reading a round was enough to make it stop being counted, whatever the read found.
242
242
  //
243
- // The harness already knows this. previousRoundNotice in e2e.mjs:2256 says the round closes on
243
+ // The harness already knows this. previousRoundNotice in e2e.mjs says the round closes on
244
244
  // `reportedState === "settled"`, NEVER on `reportedAt != null`, and gives the incident that
245
245
  // taught it. This lister was the last reader keying on the weaker field.
246
246
  if (String(r?.reportedState ?? "") === "settled") continue;
package/scripts/e2e.mjs CHANGED
@@ -1002,11 +1002,59 @@ export function doorUnavailableLabel(a, { max = 90 } = {}) {
1002
1002
  return "no status and no reason recorded";
1003
1003
  }
1004
1004
 
1005
- export function doorAsymmetry(answers) {
1005
+ /**
1006
+ * ── THE ASYMMETRY RULE IS WRITTEN FOR A CASE BOTH DOORS SHOULD REFUSE ───────────────────────────
1007
+ *
1008
+ * "The one that ACCEPTED is the defect" rests on a premise nobody wrote down: that the case EXPECTS a
1009
+ * refusal. Eight of R0's nine cases do, so the premise held by accident for as long as the ninth could
1010
+ * not be measured.
1011
+ *
1012
+ * R0e is the one admit case. Its own `expect.terminal` is `"delivered"` and it asserts the run resolves
1013
+ * to `generic`. On 2026-09-10, the first round in which both doors actually ANSWERED it, the cli door
1014
+ * accepted and delivered `profileKey: "generic"` — the case's whole contract, met — and the ops-mcp door
1015
+ * refused at `assertScopedProfileKey`, a deliberate cross-customer bypass gate doing its job for an
1016
+ * accounts-scoped session. Both doors were right. The rule named the one that behaved.
1017
+ *
1018
+ * The three earlier rounds hid it: the ops-mcp door was lost to the transport every time, so the case
1019
+ * never reached this comparison as two real answers.
1020
+ *
1021
+ * SO THE POLARITY IS NOT INVERTED HERE, and that matters. "On an admit case the door that REFUSED is the
1022
+ * defect" would blame a security gate for holding. What is wrong is the VERDICT, not its direction: on an
1023
+ * admit case this comparison does not know which door is at fault, and it says so instead of guessing.
1024
+ * A case that declares which doors can admit it (`doors`) stops the guess entirely.
1025
+ */
1026
+ export function doorAsymmetry(answers, { expectTerminal = null } = {}) {
1006
1027
  const compared = answers.filter((a) => doorAnswerClass(a) === DOOR_ANSWER.ANSWERED);
1007
1028
  const unavailable = answers.filter((a) => doorAnswerClass(a) === DOOR_ANSWER.INFRA_UNAVAILABLE);
1008
1029
  const agreed = compared.length < 2 ? true : compared.every((a) => a.ok === compared[0].ok);
1009
- return { agreed, compared, unavailable, reducedCoverage: unavailable.length > 0 };
1030
+ return { agreed, compared, unavailable, reducedCoverage: unavailable.length > 0, admit: isAdmitCase(expectTerminal) };
1031
+ }
1032
+
1033
+ /** A case whose own contract is that a door ACCEPTS. `delivered` and `duplicate` both admit; the rest refuse. */
1034
+ export function isAdmitCase(expectTerminal) {
1035
+ const t = String(expectTerminal ?? "").trim().toLowerCase();
1036
+ return t === "delivered" || t === "duplicate";
1037
+ }
1038
+
1039
+ /**
1040
+ * The sentence a disagreement gets — ONE author, because the run printed it and the report pushed it into
1041
+ * `toInvestigate` from two separate literals, and a fix to either would have left the other saying the
1042
+ * old thing.
1043
+ *
1044
+ * `accepted`/`refused` read `ok` at run time and `accepted` on the receipt; both spellings are the same
1045
+ * fact and one formatter has to serve both surfaces or they drift.
1046
+ */
1047
+ export function doorDisagreementLine(caseId, answers, { expectTerminal = null } = {}) {
1048
+ const took = (a) => (a?.ok ?? a?.accepted) === true;
1049
+ const accepted = answers.filter(took).map((a) => a.door);
1050
+ const refused = answers.filter((a) => !took(a)).map((a) => a.door);
1051
+ const both = `accepted by ${accepted.join(", ") || "(none)"}, refused by ${refused.join(", ") || "(none)"}`;
1052
+ if (!isAdmitCase(expectTerminal))
1053
+ return `${caseId}: THE DOORS DISAGREE — ${both}; the door that ACCEPTED is the defect`;
1054
+ return `${caseId}: THE DOORS DISAGREE on a case whose own expect.terminal ADMITS — ${both}. `
1055
+ + `The accepting door met this case's stated contract; the refusing door answered a question the case did not ask. `
1056
+ + `WHICH IS AT FAULT IS NOT DECIDED HERE — a door may refuse for a reason this case cannot satisfy at that door, `
1057
+ + `and naming the accepter would blame the door that behaved. Declare \`doors\` on the case to say which doors can admit it.`;
1010
1058
  }
1011
1059
 
1012
1060
  const enqueue = (job, door) =>
@@ -1115,6 +1163,46 @@ const OPS = {
1115
1163
  length: (v, want) => ({ ok: Array.isArray(v) && v.length === want, saw: Array.isArray(v) ? `${v.length}` : JSON.stringify(v) }),
1116
1164
  };
1117
1165
 
1166
+ /**
1167
+ * The file `delivery-settled` reads, and the ONLY one it reads.
1168
+ *
1169
+ * Named rather than inlined because two places have to agree about it: the op itself, and the check that
1170
+ * asks every scenario whether the path it declared for an op is one that op can read. A literal in both
1171
+ * is the shape that produced the defect this constant exists to prevent.
1172
+ */
1173
+ export const DELIVERY_STATUS_FILE = "status.json";
1174
+
1175
+ /**
1176
+ * Which ops read a FIXED file rather than the path the scenario declares, and what that file is.
1177
+ *
1178
+ * DERIVED CHECKS NEED THIS TABLE, not a hand-written list of known-bad scenarios: a scenario nobody
1179
+ * thought to add is exactly the one that goes unnoticed, and R13 went unnoticed for four rounds.
1180
+ */
1181
+ export const FIXED_FILE_OPS = Object.freeze({ "delivery-settled": DELIVERY_STATUS_FILE });
1182
+
1183
+ /**
1184
+ * Every scenario case whose declared `path` names a file its `op` does not read.
1185
+ *
1186
+ * Not a pass/fail — the op reads the right file either way. It is a REPORT, so a scenario stating a wrong
1187
+ * belief about an op is visible to whoever runs it. Built from the scenario set, never from a list of
1188
+ * names: the point is to find the next one.
1189
+ */
1190
+ export function pathsAnOpDoesNotRead(scenarios) {
1191
+ const out = [];
1192
+ for (const s of scenarios ?? []) {
1193
+ const cases = s?.cases ?? (s?.expect ? [{ id: s.id, expect: s.expect }] : []);
1194
+ for (const c of cases) {
1195
+ for (const a of c?.expect?.assert ?? []) {
1196
+ const fixed = FIXED_FILE_OPS[a?.op];
1197
+ if (!fixed) continue;
1198
+ const declared = String(a?.path ?? "").split(":")[0];
1199
+ if (declared && declared !== fixed) out.push({ scenario: s.id, case: c.id ?? s.id, op: a.op, declared, reads: fixed });
1200
+ }
1201
+ }
1202
+ }
1203
+ return out;
1204
+ }
1205
+
1118
1206
  function evalAssertion(a, runDir) {
1119
1207
  const [file, field] = String(a.path ?? "").split(":");
1120
1208
  const full = join(runDir, file || "");
@@ -1335,8 +1423,21 @@ function evalAssertion(a, runDir) {
1335
1423
  // exist. A run that flipped sendPending without writing one would have passed the old assertion and
1336
1424
  // fails this one.
1337
1425
  if (a.op === "delivery-settled") {
1338
- const st = readJson(full);
1339
- if (!st) return { ok: false, saw: "status.json absent or unparseable" };
1426
+ // — THIS OP READS `status.json`, WHATEVER PATH THE SCENARIO DECLARED, and the error text three
1427
+ // lines below always said so while the read above it took `full`. R13 declared `_driver/delivery.json`,
1428
+ // which parses cleanly and carries no `sendPending`, so the assertion read `undefined`, failed a run
1429
+ // that had delivered correctly, and pushed a product defect that did not exist into `INVESTIGATE`.
1430
+ // It went unseen for four rounds because R13 had been REPORTED once.
1431
+ //
1432
+ // Reading the run's own status is not a correction of the scenario — it is what the op has always
1433
+ // meant. The scenario's `path` is still ANSWERED FOR rather than ignored: `declaredPathNote` says so
1434
+ // in the line, so a scenario stating a belief about this op that is wrong is visible to the reader
1435
+ // who runs it, not only to whoever next reads this function.
1436
+ const st = readJson(join(runDir, DELIVERY_STATUS_FILE));
1437
+ const declaredPathNote = (file && file !== DELIVERY_STATUS_FILE)
1438
+ ? ` — NOTE: this scenario declares \`path: "${file}"\` for \`delivery-settled\`, which reads \`${DELIVERY_STATUS_FILE}\` and nothing else. The declared path was not read.`
1439
+ : "";
1440
+ if (!st) return { ok: false, saw: `${DELIVERY_STATUS_FILE} absent or unparseable${declaredPathNote}` };
1340
1441
  // — THERE IS NO MODE TO READ ANY MORE, and this is the one place where that mattered rather
1341
1442
  // than being a comment fix. This used to open `process.env.CLEAROTRON_DELIVERY || "email"` and take a
1342
1443
  // `sendPending === false` branch for anything that was not `handoff`. With the variable deleted that
@@ -1392,7 +1493,7 @@ function evalAssertion(a, runDir) {
1392
1493
  const { packets, unreadable } = outboxPackets({ runIds: runIdForms(st, runDir) });
1393
1494
  return { ok: st.sendPending === true && packets.length > 0,
1394
1495
  saw: `mode=handoff sendPending=${JSON.stringify(st.sendPending)} packet=${
1395
- packets.length ? packets.join(", ") : unreadable ? `NOT LOOKED FOR — ${unreadable}` : "NONE WRITTEN"}` };
1496
+ packets.length ? packets.join(", ") : unreadable ? `NOT LOOKED FOR — ${unreadable}` : "NONE WRITTEN"}${declaredPathNote}` };
1396
1497
  }
1397
1498
 
1398
1499
  // `absent` — the file must NOT exist, and its absence is the asserted state, not a silent pass.
@@ -1811,7 +1912,7 @@ async function cmdRun(id) {
1811
1912
  // ever wants the launch to stop.
1812
1913
  printPreviousRoundNotice(s);
1813
1914
 
1814
- const jobs = s.job ? [{ id: s.id, job: s.job }] : (s.cases ?? []).map((c) => ({ id: c.id, job: c.job, what: c.what, oneMatterAcrossDoors: c.oneMatterAcrossDoors === true }));
1915
+ const jobs = s.job ? [{ id: s.id, job: s.job }] : (s.cases ?? []).map((c) => ({ id: c.id, job: c.job, what: c.what, oneMatterAcrossDoors: c.oneMatterAcrossDoors === true, expectTerminal: (c.expect ?? {}).terminal ?? null }));
1815
1916
 
1816
1917
  // door: "all" is the point of R0 — a rule enforced in one door and not another is exactly the #98
1817
1918
  // asymmetry. Every case goes through EVERY drivable door and the answers are compared. runner.mjs's
@@ -1827,7 +1928,7 @@ async function cmdRun(id) {
1827
1928
  // over whatever was there, so the second run of a pair destroyed the first round's token and the
1828
1929
  // first half became unreportable with nothing saying so.
1829
1930
  const round = { token: RUN_TOKEN, startedAt: new Date().toISOString(), startedAtSource: "run", doors, cases: [] };
1830
- for (const { id: caseId, job, what, oneMatterAcrossDoors } of jobs) {
1931
+ for (const { id: caseId, job, what, oneMatterAcrossDoors, expectTerminal } of jobs) {
1831
1932
  // The round token goes on the BASE ref, so the door suffix (and R0d's opt-out from it) still decides
1832
1933
  // whether the doors are one matter or two. See refForRun and refForDoor.
1833
1934
  const roundRef = refForRun(job.ref);
@@ -1857,7 +1958,7 @@ async function cmdRun(id) {
1857
1958
  console.log(` [${a.door}] ${a.ok ? "accepted" : "refused"}: ${a.out.split("\n").slice(0, 2).join(" ").slice(0, 200)}`);
1858
1959
  }
1859
1960
  }
1860
- if (!agreed) console.log(` ⚠ THE DOORS DISAGREE on this case — the one that ACCEPTED is the defect`);
1961
+ if (!agreed) console.log(` ⚠ ${doorDisagreementLine(caseId, answers, { expectTerminal })}`);
1861
1962
  // — named, never silent. A door lost to the transport costs this case a share of its door
1862
1963
  // coverage, and that is a fact about the ROUND, not about the product. Saying nothing here is how
1863
1964
  // the R0e 429 cost a third of a scenario's coverage without appearing anywhere.
@@ -3083,9 +3184,13 @@ async function cmdReport(id, { round: requestedToken = null } = {}) {
3083
3184
  .filter((x) => x.out.length);
3084
3185
  console.log(`\ndoors: ${rec.doors.join(", ") || "(not recorded)"} — ${rec.cases.length} case(s), ${dis.length} disagreement(s), ${unavailable.length} case(s) with reduced door coverage`);
3085
3186
  for (const c of dis) {
3187
+ // — the RECEIPT does not carry the case's contract, so it is joined back to the scenario by
3188
+ // id. A receipt written before this field existed reads null and gets the old sentence, which is
3189
+ // the right reading for it rather than a retrospective one.
3190
+ const expectTerminal = ((s.cases ?? []).find((x) => x.id === c.id)?.expect ?? {}).terminal ?? null;
3086
3191
  const accepted = c.answers.filter((a) => a.accepted).map((a) => a.door);
3087
3192
  console.log(` ⚠ ${c.id}: accepted by ${accepted.join(", ") || "(none)"}, refused by ${c.answers.filter((a) => !a.accepted).map((a) => a.door).join(", ") || "(none)"}`);
3088
- toInvestigate.push(`${c.id}: THE DOORS DISAGREE — accepted by ${accepted.join(", ")}; the door that ACCEPTED is the defect`);
3193
+ toInvestigate.push(doorDisagreementLine(c.id, c.answers, { expectTerminal }));
3089
3194
  }
3090
3195
  for (const { c, out } of unavailable) {
3091
3196
  const who = out.map((a) => `${a.door} (${doorUnavailableLabel(a)})`).join(", ");
@@ -221,14 +221,31 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
221
221
  // the truncation as a lower request count — the failure mode that would make this whole check lie in the
222
222
  // safe-looking direction. Wait until the server has been silent for `quiet` ms, capped so a screen that
223
223
  // requests forever fails loudly instead of hanging.
224
- const settle = async ({ quiet = 900, cap = 12000 } = {}) => {
224
+ //
225
+ // SILENCE BEFORE THE FIRST REQUEST IS NOT QUIET, WHERE A REQUEST IS OWED. `Page.navigate` returns as soon
226
+ // as the navigation is committed, long before the app's bundle has run, so the quiet clock above used to
227
+ // start while the server had heard nothing at all — and a first fetch that arrived later than `quiet`
228
+ // closed the window empty. Every request that followed was then counted into the NEXT window, so the
229
+ // whole drive read one bucket late: visit 1 empty, the revisit holding what boot had asked for. On a warm
230
+ // machine the bundle runs in well under a second and it never showed; on a slow runner it failed nine
231
+ // assertions about a product that was behaving. Reproduced by delaying the bundle 1.5s at this fixture.
232
+ //
233
+ // `expectTraffic` is for a window that follows a FULL NAVIGATION — the shell's boot and the Result
234
+ // screen's first visit, the two places this drive calls `Page.navigate`. Each reloads the bundle, so each
235
+ // has the same gap between the navigation committing and the app's first fetch. A client-side visit runs
236
+ // on the bundle already loaded and has no such gap, which is why it is not the default: a revisit is
237
+ // allowed to ask for nothing, and that is the very thing this file measures, so a window there must still
238
+ // be able to close on silence.
239
+ const settle = async ({ quiet = 900, cap = 12000, expectTraffic = false } = {}) => {
225
240
  const started = Date.now()
241
+ const before = hits.length
226
242
  let last = hits.length
227
243
  let lastChange = Date.now()
228
244
  for (;;) {
229
245
  await sleep(100)
230
246
  if (hits.length !== last) { last = hits.length; lastChange = Date.now() }
231
- if (Date.now() - lastChange >= quiet) return true
247
+ const heard = hits.length > before
248
+ if ((heard || !expectTraffic) && Date.now() - lastChange >= quiet) return true
232
249
  if (Date.now() - started >= cap) return false
233
250
  }
234
251
  }
@@ -290,7 +307,8 @@ const record = {}
290
307
  // The shell, once. Everything after this is client-side.
291
308
  epoch = 'boot'
292
309
  await navigateOrRefuse(cmd, `${origin}/portal/home`, { what: 'revisit-render-check' })
293
- if (!(await settle())) say(false, 'the shell never went quiet within 12s — it is still requesting')
310
+ if (!(await settle({ expectTraffic: true })))
311
+ say(false, 'the shell did not finish loading within 12s — it never reached the fixture server, or never stopped requesting')
294
312
  const bootPath = await where()
295
313
  say(bootPath === '/portal/home', `the shell loaded on /portal/home (got ${bootPath})`)
296
314
 
@@ -384,7 +402,7 @@ await twice('clearances', () => clickNav('Clearances'), '/portal/clearances')
384
402
  // runs AppShell's popstate listener rather than its click funnel.
385
403
  epoch = 'result:1'
386
404
  await navigateOrRefuse(cmd, `${origin}/portal/result/${RUN_ID}`, { what: 'revisit-render-check' })
387
- const rq1 = await settle()
405
+ const rq1 = await settle({ expectTraffic: true }) // a full navigation, like boot
388
406
  const rp1 = await where()
389
407
  const rt1 = await screenText()
390
408
  say(rp1 === `/portal/result/${RUN_ID}`, `result: visit 1 landed on the run (got ${rp1})`)
@@ -101,7 +101,7 @@ function isPlumbing(node, child) {
101
101
  // A ternary BRANCH is plumbing; a ternary TEST is a decision, and classify() handles that separately.
102
102
  // This was the whole of the unresolved bucket — three sites, all one shape:
103
103
  //
104
- // const wrote = files.length ? files.some(…) : null; gateway.mjs:1407
104
+ // const wrote = files.length ? files.some(…) : null; gateway.mjs (by name)
105
105
  // const inScope = scope.size ? tokens.some(…) : (…); reasoning-tripwires.mjs:82
106
106
  // const reached = b.layer === "national" ? (…) : regions.some(…); register-plan.mjs:318
107
107
  //