clearotron 0.3.2-beta.10 → 0.3.2-beta.12

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 (74) hide show
  1. package/.env.example +1 -1
  2. package/CONTRIBUTING.md +1 -0
  3. package/INSTALL.md +49 -46
  4. package/README.md +8 -7
  5. package/bin/clearotron.mjs +14 -0
  6. package/bin/connect.mjs +68 -3
  7. package/bin/key.mjs +6 -1
  8. package/bin/onboard.mjs +51 -14
  9. package/bin/passphrase.mjs +4 -2
  10. package/bin/start.mjs +43 -9
  11. package/build-info.json +2 -2
  12. package/docs/architecture/05-config-governance.md +1 -1
  13. package/driver/CHANGELOG.md +416 -388
  14. package/driver/contract-vocabulary.mjs +5 -5
  15. package/driver/engine/mcp/recording-server.mjs +21 -1
  16. package/driver/engine/mcp/supplemental.mjs +22 -5
  17. package/driver/knockout-next-step.mjs +72 -0
  18. package/driver/named-band.mjs +1 -1
  19. package/driver/package.json +1 -1
  20. package/driver/phase0.mjs +16 -7
  21. package/driver/pipeline-knockout.mjs +26 -0
  22. package/driver/pipeline.mjs +48 -1
  23. package/driver/portal-local-auth.mjs +14 -4
  24. package/driver/portal-report.mjs +21 -2
  25. package/driver/portal-service.mjs +20 -11
  26. package/driver/publish/attr.mjs +36 -0
  27. package/driver/publish/index.mjs +10 -10
  28. package/driver/publish/parse.mjs +1 -1
  29. package/driver/publish/render-knockout.mjs +14 -9
  30. package/driver/publish/render.mjs +9 -9
  31. package/driver/publish/xlsx.mjs +7 -1
  32. package/driver/queue-watch-verdict.mjs +1 -1
  33. package/driver/register-availability.mjs +1 -1
  34. package/driver/register-plan.mjs +81 -11
  35. package/driver/result-noun-fields.mjs +3 -1
  36. package/driver/stages-knockout.mjs +1 -1
  37. package/driver/suite-census.json +104 -38
  38. package/driver/unit-inventory.mjs +7 -7
  39. package/driver/verify-knockout.mjs +0 -27
  40. package/mcp-server/CHANGELOG.md +27 -19
  41. package/mcp-server/lib/audit-view.mjs +4 -4
  42. package/mcp-server/lib/trace.mjs +1 -1
  43. package/mcp-server/package.json +1 -1
  44. package/package.json +2 -2
  45. package/portal-ui/dist/assets/{index-CtvwLCti.css → index-7Lq-dXDV.css} +12 -9
  46. package/portal-ui/dist/assets/{index-DXSRxPV_.js → index-w8GFZftk.js} +110 -69
  47. package/portal-ui/dist/index.html +2 -2
  48. package/portal-ui/package.json +1 -1
  49. package/providers/jx/README.md +2 -2
  50. package/providers/jx-subclass/README.md +1 -1
  51. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  52. package/providers/oauth-mcp-bridge/package.json +1 -1
  53. package/scripts/README.md +5 -10
  54. package/scripts/ask-ai-render-check.mjs +26 -1
  55. package/scripts/citation-line-check.mjs +2 -2
  56. package/scripts/dead-names.mjs +17 -16
  57. package/scripts/e2e.mjs +8 -8
  58. package/scripts/env-audit.mjs +6 -0
  59. package/scripts/generated-files-are-current.mjs +35 -4
  60. package/scripts/live-surface-check.mjs +17 -17
  61. package/scripts/release-code-scanning-check.mjs +114 -0
  62. package/scripts/release-visible-check.mjs +117 -0
  63. package/scripts/repo-writes.mjs +42 -0
  64. package/scripts/report-sections-render-check.mjs +245 -0
  65. package/scripts/report-theme-render-check.mjs +31 -3
  66. package/scripts/settings-render-check.mjs +10 -7
  67. package/scripts/test-run.mjs +63 -4
  68. package/shared/client-door.mjs +20 -0
  69. package/shared/invocation.mjs +2 -2
  70. package/shared/parent-watch.mjs +33 -0
  71. package/shared/register-selection.mjs +4 -1
  72. package/shared/root-doc-commands.mjs +10 -4
  73. package/shared/running-start.mjs +14 -3
  74. package/shared/scope.mjs +24 -5
@@ -254,19 +254,19 @@ html.anon-on [data-anon='mark']:not(.anon-clear) {
254
254
  border-bottom: 1px solid var(--border-hairline);
255
255
  }
256
256
 
257
- /* THE HEADER'S SECOND ROW: which read you are in, then where you are inside it.
257
+ /* THE HEADER'S LOWER ROWS: which read you are in, then where you are inside it.
258
258
  The Reads strip used to sit BELOW this box and the report's own section breadcrumb inside the frame,
259
259
  so neither stayed on screen — the frame is sized to its content, which leaves a `position:sticky` bar
260
260
  in there pinned to nothing. Both are rows of the pinned header now (owner, 2026-09-18).
261
- It scrolls sideways rather than wrapping to a third line: a header that grows taller with the number of
262
- sections is a header that eats the document it is heading. */
263
- .report-nav {
264
- display: flex; align-items: center; gap: 10px; margin-top: 9px;
261
+ THE SECTIONS HAVE A ROW OF THEIR OWN, UNDER THE READS (owner, 2026-09-19). Side by side, sixteen reads
262
+ wrapped into two ragged columns while the strip floated beside them at mid-height. The reads wrap; the
263
+ sections scroll sideways rather than wrapping, so the strip stays one line however narrow the screen. */
264
+ .report-nav { display: flex; flex-direction: column; align-items: stretch; gap: 8px; margin-top: 9px; }
265
+ .report-sections {
266
+ display: flex; align-items: center; gap: 2px;
265
267
  overflow-x: auto; scrollbar-width: none;
266
268
  }
267
- .report-nav::-webkit-scrollbar { display: none; }
268
- .report-nav-sep { flex: none; width: 1px; align-self: stretch; background: var(--border-hairline); }
269
- .report-sections { display: flex; align-items: center; gap: 2px; }
269
+ .report-sections::-webkit-scrollbar { display: none; }
270
270
  .report-section {
271
271
  flex: none; display: inline-flex; align-items: center; gap: 7px; white-space: nowrap;
272
272
  font: inherit; font-size: 12.5px; font-weight: 600; letter-spacing: .01em;
@@ -277,8 +277,11 @@ html.anon-on [data-anon='mark']:not(.anon-clear) {
277
277
  .report-section-dot {
278
278
  width: 8px; height: 8px; border-radius: 50%; border: 1.5px solid currentColor; box-sizing: border-box;
279
279
  }
280
+ /* PROGRESS: every section reached has a filled dot, the rest stay hollow; the one in view also sits on
281
+ the sunken pill, so it can be told from the ones behind it. */
282
+ .report-section[data-reached='true'] { color: var(--text-strong); }
283
+ .report-section[data-reached='true'] .report-section-dot { background: var(--accent); border-color: var(--accent); }
280
284
  .report-section[aria-current='true'] { color: var(--text-strong); background: var(--surface-sunken); }
281
- .report-section[aria-current='true'] .report-section-dot { border-color: var(--accent); border-width: 3px; }
282
285
 
283
286
  /* The breadcrumb above it: one step back as a quiet button, then where the reader is. Compound selectors
284
287
  throughout this block, because the button and row rules they adjust are declared later in the file. */
@@ -18201,11 +18201,15 @@ function readFrameSections(data, sameSource) {
18201
18201
  const out = [];
18202
18202
  for (const raw of msg.sections) {
18203
18203
  if (typeof raw !== "object" || raw === null) continue;
18204
- const { id, label } = raw;
18204
+ const { id, label, top } = raw;
18205
18205
  if (typeof id !== "string" || typeof label !== "string") continue;
18206
18206
  const trimmed = label.trim().slice(0, 40);
18207
18207
  if (!id.trim() || !trimmed) continue;
18208
- out.push({
18208
+ out.push(typeof top === "number" && Number.isFinite(top) && top >= 0 ? {
18209
+ id,
18210
+ label: trimmed,
18211
+ top: Math.round(top)
18212
+ } : {
18209
18213
  id,
18210
18214
  label: trimmed
18211
18215
  });
@@ -18213,6 +18217,16 @@ function readFrameSections(data, sameSource) {
18213
18217
  }
18214
18218
  return out;
18215
18219
  }
18220
+ function sectionsReached(sections, line, where) {
18221
+ if (where.atTop) return 0;
18222
+ if (where.atFoot) return sections.length;
18223
+ let n = 0;
18224
+ for (const s of sections) {
18225
+ if (typeof s.top !== "number" || s.top > line) break;
18226
+ n += 1;
18227
+ }
18228
+ return n;
18229
+ }
18216
18230
  function readFrameTheme(data, sameSource) {
18217
18231
  if (!sameSource) return null;
18218
18232
  if (typeof data !== "object" || data === null) return null;
@@ -18243,6 +18257,8 @@ function usePortalTheme() {
18243
18257
  }, []);
18244
18258
  return theme;
18245
18259
  }
18260
+ var TOPBAR_PX = 56;
18261
+ var LANDING_PX = 10;
18246
18262
  function useReportFrame() {
18247
18263
  const ref = (0, import_react.useRef)(null);
18248
18264
  const [height, setHeight] = (0, import_react.useState)(FIRST_PAINT);
@@ -18276,7 +18292,7 @@ function useReportFrame() {
18276
18292
  if (jump !== null) {
18277
18293
  if (frame) {
18278
18294
  const head = document.querySelector(".report-head");
18279
- const chrome = 56 + (head instanceof HTMLElement ? head.offsetHeight : 48) + 10;
18295
+ const chrome = TOPBAR_PX + (head instanceof HTMLElement ? head.offsetHeight : 48) + LANDING_PX;
18280
18296
  const y = frame.getBoundingClientRect().top + window.scrollY + jump - chrome;
18281
18297
  window.scrollTo({
18282
18298
  top: Math.max(0, y),
@@ -18478,10 +18494,45 @@ function Result({ ctx, runId, markSlug }) {
18478
18494
  const run = (0, import_react.useMemo)(() => runs.find((r) => r.runId === runId) ?? null, [runs, runId]);
18479
18495
  const reads = (0, import_react.useMemo)(() => run ? readsFor(runs, run) : [], [runs, run]);
18480
18496
  const frame = useReportFrame();
18481
- const [atSection, setAtSection] = (0, import_react.useState)(null);
18497
+ const [reached, setReached] = (0, import_react.useState)(0);
18498
+ const sectionList = frame.sections;
18499
+ const frameRef = frame.ref;
18482
18500
  (0, import_react.useEffect)(() => {
18483
- setAtSection(null);
18484
- }, [runId, markSlug]);
18501
+ if (!sectionList || sectionList.length < 2) {
18502
+ setReached(0);
18503
+ return;
18504
+ }
18505
+ let queued = false;
18506
+ const measure = () => {
18507
+ queued = false;
18508
+ const el = frameRef.current;
18509
+ if (!el) return;
18510
+ const head = document.querySelector(".report-head");
18511
+ const lineOnScreen = (head instanceof HTMLElement ? head.getBoundingClientRect().bottom : TOPBAR_PX) + LANDING_PX + 2;
18512
+ const where = {
18513
+ atTop: document.documentElement.scrollHeight > window.innerHeight + 2 && window.scrollY <= 0,
18514
+ atFoot: window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2
18515
+ };
18516
+ setReached(sectionsReached(sectionList, lineOnScreen - el.getBoundingClientRect().top, where));
18517
+ };
18518
+ const later = () => {
18519
+ if (!queued) {
18520
+ queued = true;
18521
+ requestAnimationFrame(measure);
18522
+ }
18523
+ };
18524
+ measure();
18525
+ window.addEventListener("scroll", later, { passive: true });
18526
+ window.addEventListener("resize", later);
18527
+ return () => {
18528
+ window.removeEventListener("scroll", later);
18529
+ window.removeEventListener("resize", later);
18530
+ };
18531
+ }, [
18532
+ sectionList,
18533
+ frame.height,
18534
+ frameRef
18535
+ ]);
18485
18536
  const grouped = run !== null && showsAssessment(run, markSlug);
18486
18537
  const { result: summaryResult } = useLoad(() => grouped ? api.runSummary(runId) : Promise.resolve({ kind: "notFound" }), [runId, grouped]);
18487
18538
  const assessment = summaryResult?.kind === "ok" ? summaryResult.value : [];
@@ -18612,70 +18663,60 @@ function Result({ ctx, runId, markSlug }) {
18612
18663
  ]
18613
18664
  }), hasThread(reads) || (frame.sections?.length ?? 0) > 1 ? (0, import_jsx_runtime.jsxs)("div", {
18614
18665
  className: "report-nav",
18615
- children: [
18616
- hasThread(reads) ? (0, import_jsx_runtime.jsxs)("div", {
18617
- style: {
18618
- display: "flex",
18619
- gap: 8,
18620
- flexWrap: "wrap",
18621
- alignItems: "center"
18622
- },
18623
- children: [(0, import_jsx_runtime.jsx)("span", {
18624
- className: "eyebrow",
18625
- style: { alignSelf: "center" },
18626
- children: "Reads"
18627
- }), reads.map((r) => {
18628
- const active = r.runId === run.runId;
18629
- return (0, import_jsx_runtime.jsxs)("button", {
18630
- type: "button",
18631
- className: "pill",
18632
- "aria-current": active ? "true" : void 0,
18633
- onClick: () => ctx.go(resultPath(r.runId)),
18666
+ children: [hasThread(reads) ? (0, import_jsx_runtime.jsxs)("div", {
18667
+ style: {
18668
+ display: "flex",
18669
+ gap: 8,
18670
+ flexWrap: "wrap",
18671
+ alignItems: "center"
18672
+ },
18673
+ children: [(0, import_jsx_runtime.jsx)("span", {
18674
+ className: "eyebrow",
18675
+ style: { alignSelf: "center" },
18676
+ children: "Reads"
18677
+ }), reads.map((r) => {
18678
+ const active = r.runId === run.runId;
18679
+ return (0, import_jsx_runtime.jsxs)("button", {
18680
+ type: "button",
18681
+ className: "pill",
18682
+ "aria-current": active ? "true" : void 0,
18683
+ onClick: () => ctx.go(resultPath(r.runId)),
18684
+ style: {
18685
+ cursor: "pointer",
18686
+ display: "inline-flex",
18687
+ alignItems: "center",
18688
+ gap: 7,
18689
+ background: active ? "var(--accent-wash)" : "var(--surface-sunken)",
18690
+ borderColor: active ? "var(--accent)" : "var(--border-hairline)",
18691
+ color: active ? "var(--text-accent)" : "var(--text-muted)"
18692
+ },
18693
+ children: [r.tone ? (0, import_jsx_runtime.jsx)("span", {
18694
+ className: "dot",
18634
18695
  style: {
18635
- cursor: "pointer",
18636
- display: "inline-flex",
18637
- alignItems: "center",
18638
- gap: 7,
18639
- background: active ? "var(--accent-wash)" : "var(--surface-sunken)",
18640
- borderColor: active ? "var(--accent)" : "var(--border-hairline)",
18641
- color: active ? "var(--text-accent)" : "var(--text-muted)"
18642
- },
18643
- children: [r.tone ? (0, import_jsx_runtime.jsx)("span", {
18644
- className: "dot",
18645
- style: {
18646
- background: `var(--tone-${r.tone})`,
18647
- width: 7,
18648
- height: 7
18649
- }
18650
- }) : null, readLabel(r)]
18651
- }, r.runId);
18652
- })]
18653
- }) : null,
18654
- hasThread(reads) && (frame.sections?.length ?? 0) > 1 ? (0, import_jsx_runtime.jsx)("span", {
18655
- className: "report-nav-sep",
18656
- "aria-hidden": "true"
18657
- }) : null,
18658
- (frame.sections?.length ?? 0) > 1 ? (0, import_jsx_runtime.jsx)("nav", {
18659
- className: "report-sections",
18660
- "aria-label": "Sections of this report",
18661
- children: (frame.sections ?? []).map((sec, i) => {
18662
- const now = atSection === null ? i === 0 : atSection === sec.id;
18663
- return (0, import_jsx_runtime.jsxs)("button", {
18664
- type: "button",
18665
- className: "report-section",
18666
- "aria-current": now ? "true" : void 0,
18667
- onClick: () => {
18668
- setAtSection(sec.id);
18669
- frame.send("section", sec.id);
18670
- },
18671
- children: [(0, import_jsx_runtime.jsx)("span", {
18672
- className: "report-section-dot",
18673
- "aria-hidden": "true"
18674
- }), sec.label]
18675
- }, sec.id);
18676
- })
18677
- }) : null
18678
- ]
18696
+ background: `var(--tone-${r.tone})`,
18697
+ width: 7,
18698
+ height: 7
18699
+ }
18700
+ }) : null, readLabel(r)]
18701
+ }, r.runId);
18702
+ })]
18703
+ }) : null, (frame.sections?.length ?? 0) > 1 ? (0, import_jsx_runtime.jsx)("nav", {
18704
+ className: "report-sections",
18705
+ "aria-label": "Sections of this report",
18706
+ children: (frame.sections ?? []).map((sec, i) => {
18707
+ return (0, import_jsx_runtime.jsxs)("button", {
18708
+ type: "button",
18709
+ className: "report-section",
18710
+ "data-reached": i < reached ? "true" : void 0,
18711
+ "aria-current": i === reached - 1 ? "true" : void 0,
18712
+ onClick: () => frame.send("section", sec.id),
18713
+ children: [(0, import_jsx_runtime.jsx)("span", {
18714
+ className: "report-section-dot",
18715
+ "aria-hidden": "true"
18716
+ }), sec.label]
18717
+ }, sec.id);
18718
+ })
18719
+ }) : null]
18679
18720
  }) : null]
18680
18721
  }),
18681
18722
  frame.failed ? (0, import_jsx_runtime.jsxs)("div", {
@@ -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-DXSRxPV_.js"></script>
53
- <link rel="stylesheet" crossorigin href="/portal/assets/index-CtvwLCti.css">
52
+ <script type="module" crossorigin src="/portal/assets/index-w8GFZftk.js"></script>
53
+ <link rel="stylesheet" crossorigin href="/portal/assets/index-7Lq-dXDV.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.2-beta.10",
5
+ "version": "0.3.2-beta.12",
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": {
@@ -4,14 +4,14 @@ Three pure modules, **one non-agentic structured turn each, per mark × lane**,
4
4
  configured for. Not an agent: no tools, no MCP, no skills, no resume — one prompt in, one JSON object out,
5
5
  validated at parse.
6
6
 
7
- ## What these lanes ARE, and what runs them ( /)
7
+ ## What these lanes are, and what runs them
8
8
 
9
9
  **One vendor, one billing mode, decided by the run and not by this directory.** The lanes go through
10
10
  `engine.runTurn()` — the same door all fourteen agentic stages use — via
11
11
  [`../../driver/engine/jx-turn.mjs`](../../driver/engine/jx-turn.mjs). Whatever program the customer configured
12
12
  (`CLEAROTRON_AI`) and whatever billing mode the run is on (subscription, API key or, for Claude, a cloud account)
13
13
  carries these calls too.
14
- That is the owner's standing rule — *one LLM provider only ever, API or auth, no mix* — and until 2026-08-20
14
+ That is the product's rule — *one reasoning provider per run, API or subscription, never a mix* — and until 2026-08-20
15
15
  these three lanes were the one place in the product that broke it: they POSTed to the Anthropic Messages API on
16
16
  `ANTHROPIC_API_KEY` at a hardcoded cheap tier no matter what the rest of the run was doing.
17
17
 
@@ -72,7 +72,7 @@ class 44) and `351918` (cosmetics retail, class 35). A class-3 search finds neit
72
72
  goods that have a Nice basic number (609), so validating a relation's target group against it would drop
73
73
  real relations without a word. The authority is `tw/tw-group-codes.json`, taken from TIPO's group headers.
74
74
 
75
- ## Korea — done, and round-2 research had the mechanism wrong
75
+ ## Korea
76
76
 
77
77
  KIPO publishes 「유사상품 심사기준(니스 제13판)」 as a direct download: **21.8 MB, 1,011 pages, full text
78
78
  layer** (`kpoContFileDown.do?seq=30&fileNum=25`, Referer header required).
@@ -1,5 +1,13 @@
1
1
  # trademark-oauth-mcp-bridge
2
2
 
3
+ ## 0.3.2-beta.12
4
+
5
+ No changes in this release.
6
+
7
+ ## 0.3.2-beta.11
8
+
9
+ No changes in this release.
10
+
3
11
  ## 0.3.2-beta.10
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.2-beta.10",
3
+ "version": "0.3.2-beta.12",
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/README.md CHANGED
@@ -13,7 +13,7 @@ The axis is *what the script assumes exists*, not where it happens to be run.
13
13
  | Script | What it does |
14
14
  |---|---|
15
15
  | `test-run.mjs` | Gives a test run its own `TMPDIR` and deletes it at exit. **Load-bearing** — every workspace's `npm test` shells out to it, so `scripts/` can never be dropped wholesale. |
16
- | `render-check.mjs` | Renders a published report **inside the portal's iframe** in a real browser — the height bridge, the scrollbar loop, the sticky topbar. Needs a published run: point it at a pool with `--pool`, or pass `--fixture-pool` to replay `demo/` into a throwaway one. **Runs nowhere yet** — but its three interior assertions now MEASURE, deterministically: real time plus an explicit readiness signal, and `--plant-overflow` proves it still catches a broken report. What is left is a membership decision, not a defect.. |
16
+ | `render-check.mjs` | Renders a published report **inside the portal's iframe** in a real browser — the height bridge, the scrollbar loop, the sticky topbar. Needs a published run: point it at a pool with `--pool`, or pass `--fixture-pool` to replay `demo/` into a throwaway one. Run by CI on a fixture pool. Its interior assertions measure deterministically: real time plus an explicit readiness signal, and `--plant-overflow` proves it still catches a broken report. |
17
17
  | `home-render-check.mjs` | Draws the portal Home in a real browser, every state, both themes. Run by CI. |
18
18
  | `composer-render-check.mjs` | Lays out the New Clearance composer and checks the levers reach the wire. Run by CI. |
19
19
  | `clearances-render-check.mjs` | Checks the `/portal/clearances` columns hold together. Run by CI. |
@@ -80,15 +80,10 @@ settling on the first try every time, and the slack figures that looked like a l
80
80
  147px against a 15-24 band) turned out to be the stale probe too. The sandbox is byte-identical to
81
81
  `Result.tsx`'s and `allow-same-origin` was never added.
82
82
 
83
- **It is still not in CI, and that is now a decision rather than a defect** — nobody has run it on a
84
- runner since the rewrite, and wiring a blocking browser step on the strength of one developer box is the
85
- mistake the membership guard exists to stop. That call is 's.
86
-
87
- Read the other six with that in mind: they are outer-document measurements — does the page scroll, is
88
- the header pinned, is Export reachable — and every one would pass against **an iframe that loaded
89
- nothing**, because the frame is styled to a fixed height. A green from this check would have meant
90
- almost nothing. The step is backed out and the script is declared, with the measurement, rather than
91
- quietly dropped.
83
+ **CI runs it**, on a pool replayed from `demo/` into a throwaway directory:
84
+ `node scripts/render-check.mjs --fixture-pool`. Its outer-document assertions — does the page scroll, is
85
+ the header pinned, is Export reachable — would pass against an iframe that loaded nothing, because the
86
+ frame is styled to a fixed height; the interior ones above are what make a green mean something.
92
87
 
93
88
  **Which check runs where is now checked, not just written here.** A test in the driver suite
94
89
  reads `.github/workflows/ci.yml` and requires every `scripts/*-check.mjs` to be either invoked by a job
@@ -133,6 +133,10 @@ let deck = 'connected'
133
133
  // travels the real injected script to the real listener. Two cards at fixed places: a numbered finding,
134
134
  // and an "Also considered" card, which carries the button and no number.
135
135
  let reportDoc = 'stub'
136
+ // A SLOW RUNNER, ON DEMAND. The finding's document is held back this long before it is served, which is
137
+ // how a CI runner that loaded it late is reproduced: the press below has to wait for the document, not
138
+ // for a fixed time. Zero, the default, serves it at once.
139
+ const FRAME_DELAY_MS = Number(process.env.ASK_AI_CHECK_FRAME_DELAY_MS ?? 0) || 0
136
140
  const FINDING_DOC = () => prepareReportForEmbed(`<!doctype html><html><head><meta charset="utf-8"><title>report</title></head>
137
141
  <body style="margin:0;font-family:sans-serif;background:#fff;height:400px">
138
142
  <div class="card" id="c3" style="position:absolute;top:40px;left:20px"><div class="cardhead"><span class="fnum">3</span>
@@ -159,7 +163,8 @@ const server = createServer((req, res) => {
159
163
  // the height a published report's bridge announces, so the header draws the Export menu a reader meets.
160
164
  if (p.startsWith('/portal/report/') && reportDoc === 'finding') {
161
165
  res.writeHead(200, { 'content-type': 'text/html' })
162
- return res.end(FINDING_DOC())
166
+ const doc = FINDING_DOC()
167
+ return FRAME_DELAY_MS ? setTimeout(() => res.end(doc), FRAME_DELAY_MS) : res.end(doc)
163
168
  }
164
169
  if (p.startsWith('/portal/report/')) {
165
170
  res.writeHead(200, { 'content-type': 'text/html' })
@@ -433,9 +438,29 @@ const arrived = await value(PROBE)
433
438
  // null origin and the point is the whole path — the injected script hears the press, posts it, and the
434
439
  // report screen opens its control. The cards sit at fixed places in the document, so the frame's own
435
440
  // position on the page is all that has to be measured.
441
+ // READY, NOT A FIXED WAIT. The press lands on a finding's button, and it does something only once the
442
+ // document in the frame has loaded and its bridge is listening. A fixed pause after navigating was not
443
+ // always that on a CI runner: the press arrived first and opened nothing (measured on beta-12, 2026-09-19,
444
+ // with no change to the portal or the bridge since a green run). The bridge posts the document's height
445
+ // from the same script that listens for the press, so a frame that has left its first-paint height has a
446
+ // listener. The Ask AI control must be on the header too, since the press opens it. Both are waited for,
447
+ // and the frame's place must hold still across two reads, so the press is aimed where the button is.
448
+ const FIRST_PAINT = Number(/export const FIRST_PAINT = (\d+)/.exec(readFileSync(join(DIST, '..', 'src', 'contract', 'reportFrame.ts'), 'utf8'))?.[1] ?? NaN)
449
+ const frameReady = async () => {
450
+ let last = null
451
+ for (let i = 0; i < 200; i++) {
452
+ const now = await value(`(() => { const f = document.querySelector('iframe'); if (!f || !document.querySelector('[data-ask-ai] > button')) return null;
453
+ const r = f.getBoundingClientRect(); return Math.round(r.top) + ':' + Math.round(r.height) })()`)
454
+ if (now && now === last && !now.endsWith(`:${FIRST_PAINT}`)) return true
455
+ last = now
456
+ await new Promise((r) => setTimeout(r, 100))
457
+ }
458
+ return false
459
+ }
436
460
  const pressInFrame = async (top) => {
437
461
  await value('(() => { window.scrollTo(0, 0); return true })()')
438
462
  await new Promise((r) => setTimeout(r, 200))
463
+ if (!(await frameReady())) return false
439
464
  const at = await value(`(() => { const f = document.querySelector('iframe'); if (!f) return null; const r = f.getBoundingClientRect(); return { x: r.left, y: r.top } })()`)
440
465
  if (!at) return false
441
466
  const x = Math.round(at.x + 140), y = Math.round(at.y + top + 16)
@@ -79,10 +79,10 @@ export const EXEMPT_TARGETS = [
79
79
  { target: /^driver\/(x|a|f|nope)\.mjs$/, why: "planted names in contract-audit.test.mjs — the test feeds the auditor a citation to a file that does not exist ON PURPOSE" },
80
80
  { target: /^driver\/skills\/[ab]\/(rules|SKILL)\.md$/, why: "planted skill paths in contract-audit.test.mjs, same shape" },
81
81
  { target: /^planted\//, why: "THE NAMESPACE for a fixture path that must not exist. publication-scrub.test.mjs plants an undeclared path to prove the scrubber sees it, and cut-vendor-keys.test.mjs plants one to prove the record-key detector reports a location. Unlike the namespaced entries below, this one is deliberately shared: `planted/` names the class rather than one test's fixtures, so a new test planting a non-existent path uses it instead of inventing a second convention — and a bare invented name is what reports as a dangling citation to a file nobody meant to create" },
82
- { target: /^diffcase-[a-z]+\.mjs$/, why: "SYNTHETIC DIFF FIXTURE FILENAMES in pushed-content-guard.test.mjs (tracker issue 1941). That test feeds `addedLines` a hand-written unified diff, so `keep.mjs:4` is a line of DIFF DATA the guard must parse, not a citation to anything. Same class as the contract-audit planted names directly above: a test that must contain a file reference to a file that does not exist, on purpose. NAMESPACED `diffcase-` deliberately: a first cut exempted the bare names `keep|dropped|gone|zz` and SWALLOWED two of this file's own controls, which plant `gone.mjs:12` — an exemption wide enough to cover somebody else's known-bad turns their arm green while it proves nothing" },
82
+ { target: /^diffcase-[a-z]+\.mjs$/, why: "SYNTHETIC DIFF FIXTURE FILENAMES in pushed-content-guard.test.mjs. That test feeds `addedLines` a hand-written unified diff, so line 4 of `keep.mjs` is DIFF DATA the guard must parse, not a citation to anything. Same class as the contract-audit planted names directly above: a test that must contain a file reference to a file that does not exist, on purpose. NAMESPACED `diffcase-` deliberately: a first cut exempted the bare names `keep|dropped|gone|zz` and SWALLOWED two of this file's own controls, which plant line 12 of `gone.mjs` — an exemption wide enough to cover somebody else's known-bad turns their arm green while it proves nothing" },
83
83
  { target: /^driver\/engine\/mcp\/planted-server\.mjs$/, why: "a planted server module name in contract-audit.test.mjs" },
84
84
  { target: /^srv\/app\//, why: "SYNTHETIC DEPLOYMENT STACK TRACES. The house rule for fixtures is to use a `/srv/…` path and never a real `/home/<name>/` one (deployment-hostnames.test.mjs polices the other half), so a hand-written trace like \"TypeError: x is not a function at /srv/app/driver/pipeline.mjs:2411:9\" is test DATA the parser under test must read — the line number is the datum, not a claim about our tree. It passed for as long as it did by luck: this resolver strips the prefix and lands on the real driver/pipeline.mjs, so the fixture went green while whatever line it happened to name was real code, and reported BLANK the first time an unrelated edit made that line a brace. NAMESPACED to `srv/app/` for the reason the diffcase- entry gives — nothing in this repo ships under srv/, and a wider prefix would swallow a genuine citation someone writes tomorrow" },
85
- { target: /^(?:driver\/)?driftcase-[a-z-]+\.mjs$/, why: "SYNTHETIC DRIFT FIXTURE FILENAMES in citation-drift-at-the-diff.test.mjs (tracker issue 1950). That test feeds the drift report a hand-built corpus in which `driftcase-target.mjs:40` is the DATUM being classified — the thing whose movement is measured — not a citation to anything. Same class as the planted names above, and NAMESPACED under `driver/` for the reason the `diffcase-` entry gives: an exemption wide enough to cover another test's known-bad turns their arm green while it proves nothing. The directory prefix is OPTIONAL because one arm cites the BARE BASENAME on purpose: that is the exact shape that made `resolveCited` short-circuit on an always-true `exists` and print a clean tick, so the arm cannot be written any other way. `driftcase-` carries the namespacing on its own — nothing else in this tree uses the prefix" },
85
+ { target: /^(?:driver\/)?driftcase-[a-z-]+\.mjs$/, why: "SYNTHETIC DRIFT FIXTURE FILENAMES in citation-drift-at-the-diff.test.mjs. That test feeds the drift report a hand-built corpus in which line 40 of `driftcase-target.mjs` is the DATUM being classified — the thing whose movement is measured — not a citation to anything. Same class as the planted names above, and NAMESPACED under `driver/` for the reason the `diffcase-` entry gives: an exemption wide enough to cover another test's known-bad turns their arm green while it proves nothing. The directory prefix is OPTIONAL because one arm cites the BARE BASENAME on purpose: that is the exact shape that made `resolveCited` short-circuit on an always-true `exists` and print a clean tick, so the arm cannot be written any other way. `driftcase-` carries the namespacing on its own — nothing else in this tree uses the prefix" },
86
86
  { target: /^common-law-findings\.md$/, why: "a RUN ARTIFACT, written into a run directory at execution time. It is cited by name in doubt-ledger and audit-from-spine tests because that is what the artifact is called; it is not and never will be a tracked file" },
87
87
  // ✕ REMOVED: `codex/dist/(helper|cli).js` — a captured CODEX stack trace in
88
88
  // exit-cause-survives-the-digest.test.mjs. Both of its citations carry a COLUMN, so `citationsIn`
@@ -6,8 +6,8 @@
6
6
  // node scripts/dead-names.mjs report; exit 1 if a dead name is back
7
7
  // node scripts/dead-names.mjs --json the same, as JSON
8
8
  //
9
- // ruled Lore and Aughra dead names that may appear nowhere, and the purge is done: the issue's
10
- // own pattern returned 58 files when it was written and returns ONE now, which is a dictionary snippet
9
+ // The owner ruled Lore and Aughra dead names that may appear nowhere, and the purge is done: the issue's
10
+ // own pattern returned 58 files when it was written and ONE when this guard landed, a dictionary snippet
11
11
  // about hunting inside captured search results. What does not exist is anything to keep it that way. A
12
12
  // name published once stays published, so the cost of a re-introduction is not a revert — it is a name
13
13
  // in the history of a repository that is about to be cut in public.
@@ -42,37 +42,38 @@ export const GUARD = "dead-names (#853)";
42
42
  // the issue names as deployed surfaces, and `_` is a word character, so a trailing `\b` would miss the
43
43
  // environment variable while catching the prose. No English word begins with those six letters, so the
44
44
  // leading boundary is all the protection the arm needs — measured: zero hits in the tree either way.
45
- // `clawdi-lisa` and `clawdi-lidia` are 's, not 's, and they are here rather than in a second
46
- // scanner because the mechanism is identical: a name that must never reappear. They were per-person
47
- // agent identities for individuals at this firm, shipped in an operator script's default roster, in a
48
- // systemd unit watching their queues, and in two test fixtures. That out-of-scope note ruled `alex`
49
- // and `sam` INVENTED, which is why those stay; these two were never covered by that ruling.
50
- export const DEAD_NAME_RE = /lorestar|lore_(pool|url|flags)|clearance_lore|lorectl|loreurl|lorecontrols|trademark-lore|\blore\b|\baughra|clawdi-(lisa|lidia)/i;
45
+ // A PER-PERSON IDENTITY IS THE SAME KIND OF NAME, AND IT IS NOT WRITTEN HERE. An agent identity named
46
+ // after a real person must never reappear either, and the mechanism is identical. But a public guard
47
+ // that spells out a person's name publishes the name it exists to keep out, so no such arm is written
48
+ // into this file: `deadNameHits` takes its pattern as an argument, and the test drives the mechanism
49
+ // with an invented identity. The names the tests give agents and requesters are invented too.
50
+ export const DEAD_NAME_RE = /lorestar|lore_(pool|url|flags)|clearance_lore|lorectl|loreurl|lorecontrols|trademark-lore|\blore\b|\baughra/i;
51
51
 
52
52
  /**
53
53
  * Where a true match is somebody else's word rather than our dead name. NAMED, with the reason, and
54
54
  * scoped to the file — never a blanket pattern, because a blanket would re-admit the name everywhere.
55
+ * The first exemption was a dictionary snippet in the demo's captured common-law results; the demo was
56
+ * re-captured without it, so that exemption was deleted rather than carried.
55
57
  */
56
58
  export const EXEMPTIONS = [
57
59
  {
58
- file: "demo/multi-country-focus-search/run/common-law-grid.json",
59
- why: "captured third-party search results. The hit is a Collins English Dictionary definition of "
60
- + "hunting — \"the art, sport, lore, or practice of hunting\" — inside a snippet the common-law "
61
- + "sweep fetched and the driver saved VERBATIM. Editing it would corrupt a machine receipt to "
62
- + "satisfy a lint, which is the one thing a receipt may never be.",
60
+ file: "driver/wordlists/en.txt",
61
+ why: "the engine's list of ordinary English words, the dictionary of the one-letter neighbourhood "
62
+ + "filter. `lore` is an ordinary English word and sits on it. The list only ever removes queries, "
63
+ + "so taking the word out would change which searches a run makes, to satisfy a lint.",
63
64
  },
64
65
  ];
65
66
 
66
67
  const exemptionFor = (file) => EXEMPTIONS.find((e) => e.file === file) ?? null;
67
68
 
68
- /** Every dead-name hit in a corpus of {file, text}. PURE. */
69
- export function deadNameHits(corpus) {
69
+ /** Every dead-name hit in a corpus of {file, text}, against DEAD_NAME_RE unless a pattern is given. PURE. */
70
+ export function deadNameHits(corpus, pattern = DEAD_NAME_RE) {
70
71
  const out = [];
71
72
  for (const { file, text } of corpus ?? []) {
72
73
  if (exemptionFor(file)) continue;
73
74
  const lines = String(text ?? "").split("\n");
74
75
  for (let i = 0; i < lines.length; i++) {
75
- if (DEAD_NAME_RE.test(lines[i])) out.push({ file, line: i + 1, text: lines[i].trim().slice(0, 160) });
76
+ if (pattern.test(lines[i])) out.push({ file, line: i + 1, text: lines[i].trim().slice(0, 160) });
76
77
  }
77
78
  }
78
79
  return out;
package/scripts/e2e.mjs CHANGED
@@ -922,7 +922,7 @@ export async function enqueueViaMcp(job, { clientPrincipal = false, forwarder =
922
922
  // harness named the door that BEHAVED as the defect. Two hours later the codex arm hit no 429 and the
923
923
  // same case was clean — so the finding also reads as a flake, which is the worst of both.
924
924
  //
925
- // The rule the asymmetry check encodes (#98) is sound: for a case both doors should refuse, the one
925
+ // The rule the door-asymmetry check encodes is sound: for a case both doors should refuse, the one
926
926
  // that accepted is the bug. It rests on an assumption nobody wrote down — that a refusal is a
927
927
  // PRODUCT-SCOPE JUDGMENT. A 429 or a 5xx is not a judgment. The request died in the transport, before
928
928
  // `start_run` ran a single scope check, so that door holds no opinion about this case at all.
@@ -945,7 +945,7 @@ export function doorAnswerClass(answer) {
945
945
  }
946
946
 
947
947
  /**
948
- * The #98 asymmetry verdict, over the doors that actually answered.
948
+ * The door-asymmetry verdict, over the doors that actually answered.
949
949
  * Returns { agreed, compared, unavailable, reducedCoverage } — `agreed` is TRUE when fewer than two
950
950
  * doors answered, because one opinion is not a disagreement and must never be reported as one.
951
951
  */
@@ -2141,7 +2141,7 @@ async function cmdRun(id) {
2141
2141
 
2142
2142
  const jobs = s.job ? [{ id: s.id, job: s.job, kase: {} }] : (s.cases ?? []).map((c) => ({ id: c.id, job: c.job, what: c.what, oneMatterAcrossDoors: c.oneMatterAcrossDoors === true, expectTerminal: (c.expect ?? {}).terminal ?? null, kase: c }));
2143
2143
 
2144
- // door: "all" is the point of R0 — a rule enforced in one door and not another is exactly the #98
2144
+ // door: "all" is the point of R0 — a rule enforced in one door and not another is exactly the door
2145
2145
  // asymmetry. Every case goes through EVERY drivable door and the answers are compared. runner.mjs's
2146
2146
  // claimAndPrep is the wall they all land on, so they must agree; if they ever do not, the door that
2147
2147
  // admits is the bug, not the door that refuses. A case that declares `doors` goes through those alone
@@ -2662,7 +2662,7 @@ function printPreviousRoundNotice(s) {
2662
2662
  }
2663
2663
 
2664
2664
  // `run` records what each door answered, because `report` runs later in a different process and cannot
2665
- // otherwise know. A rule enforced in one door and waved through by another is exactly the #98 asymmetry,
2665
+ // otherwise know. A rule enforced in one door and waved through by another is exactly the door asymmetry,
2666
2666
  // and it is invisible unless the answers are compared.
2667
2667
  //
2668
2668
  // — the receipt's PATH and FORMAT moved to driver/e2e-rounds.mjs (`receiptPath`, `readReceipt`),
@@ -2686,7 +2686,7 @@ function printPreviousRoundNotice(s) {
2686
2686
  export function doorRefusal(receipt, ref, expect = null) {
2687
2687
  const rc = (receipt?.cases ?? []).find((c) => c.ref === ref) ?? null;
2688
2688
  const recorded = rc?.answers ?? [];
2689
- // — ONLY THE DOORS THAT ANSWERED. `doorAsymmetry` has excluded unavailable doors from the #98
2689
+ // — ONLY THE DOORS THAT ANSWERED. `doorAsymmetry` has excluded unavailable doors from the door
2690
2690
  // comparison since, but this function did not, and it is the one that prints
2691
2691
  // "refused at the door by cli, ops-mcp". A door that was never asked was listed there as a refuser,
2692
2692
  // so a case where one door refused correctly and the other did not exist read EXACTLY like a case
@@ -3175,7 +3175,7 @@ async function cmdReport(id, { round: requestedToken = null } = {}) {
3175
3175
  // notice is OWED depends on the terminal, and the terminal is what could not be established.
3176
3176
  const owesNotice = q.terminal === "clarify" || q.terminal === "duplicate" || q.terminal === "failed" || q.undetermined;
3177
3177
  if (owesNotice) {
3178
- // The contract from #92: a refusal must PARK *and NOTIFY*, never fail silently.
3178
+ // The contract: a refusal must PARK *and NOTIFY*, never fail silently.
3179
3179
  console.log(` requester notified: ${
3180
3180
  q.packets.length ? q.packets.join(", ")
3181
3181
  : q.packetsUnreadable ? `CANNOT TELL — ${q.packetsUnreadable}`
@@ -3342,7 +3342,7 @@ async function cmdReport(id, { round: requestedToken = null } = {}) {
3342
3342
  // CASE-level asserts are evaluated too. They were declared and never reached: this loop read only
3343
3343
  // `s.expect.assert`, and `kase` was consulted solely for `terminal` and `reasonMatches`. R0 has no
3344
3344
  // scenario-level asserts at all, so R0 ran ZERO assertions — and R0e's `profileKey == "generic"`,
3345
- // the only substantive check in the whole scenario and the entire point of the #83 roster-blindness
3345
+ // the only substantive check in the whole scenario and the entire point of the roster-blindness
3346
3346
  // case, was silently dead. Same class of defect as the artifacts key above: declared, never asked.
3347
3347
  for (const a of [...(s.expect?.assert ?? []), ...(kase?.expect?.assert ?? [])]) {
3348
3348
  const r = evalAssertion(a, runDir);
@@ -3405,7 +3405,7 @@ async function cmdReport(id, { round: requestedToken = null } = {}) {
3405
3405
  }
3406
3406
  // ── did every door give the same answer? ────────────────────────────────────────────────────────────
3407
3407
  // Only R0 drives more than one door. A rule enforced at one door and waved through by another is the
3408
- // #98 asymmetry, and it is invisible unless the answers are compared — so `run` records them and this
3408
+ // door asymmetry, and it is invisible unless the answers are compared — so `run` records them and this
3409
3409
  // reads the receipt. No receipt is reported as "cannot tell", never as agreement.
3410
3410
  if (s.door === "all") {
3411
3411
  // — read THIS ROUND's answers, and distinguish the three ways they can be missing: no receipt
@@ -310,6 +310,12 @@ export function isProductFile(rel) {
310
310
  if (/\.test\.mjs$/.test(rel)) return false;
311
311
  if (/^e2e\//.test(rel)) return false;
312
312
  if (/^scripts\/(e2e|test-run|ci-)/.test(rel)) return false;
313
+ // THE BROWSER CHECKS. CI and a developer run them against a fixture portal; no install runs one. A switch
314
+ // one of them reads, such as the delay that reproduces a slow runner, belongs to the check, and a row
315
+ // for it in `.env.example` would hand an operator a setting that changes nothing they run. Measured
316
+ // when this was added: HOME, PATH, TMPDIR and that delay were all the checks read, and the first three
317
+ // stay product, because shipping code reads them too.
318
+ if (/^scripts\/[a-z0-9-]+-render-check\.mjs$/.test(rel)) return false;
313
319
  return true;
314
320
  }
315
321