clearotron 0.3.2-beta.11 → 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 (46) hide show
  1. package/.env.example +1 -1
  2. package/bin/clearotron.mjs +14 -0
  3. package/bin/connect.mjs +68 -3
  4. package/bin/key.mjs +6 -1
  5. package/bin/onboard.mjs +7 -1
  6. package/bin/passphrase.mjs +4 -2
  7. package/bin/start.mjs +12 -3
  8. package/build-info.json +2 -2
  9. package/driver/CHANGELOG.md +16 -0
  10. package/driver/contract-vocabulary.mjs +5 -5
  11. package/driver/engine/mcp/recording-server.mjs +1 -1
  12. package/driver/engine/mcp/supplemental.mjs +22 -5
  13. package/driver/knockout-next-step.mjs +72 -0
  14. package/driver/named-band.mjs +1 -1
  15. package/driver/package.json +1 -1
  16. package/driver/pipeline-knockout.mjs +26 -0
  17. package/driver/portal-report.mjs +21 -2
  18. package/driver/portal-service.mjs +8 -8
  19. package/driver/publish/render-knockout.mjs +14 -9
  20. package/driver/register-availability.mjs +1 -1
  21. package/driver/register-plan.mjs +81 -11
  22. package/driver/result-noun-fields.mjs +3 -1
  23. package/driver/stages-knockout.mjs +1 -1
  24. package/driver/suite-census.json +47 -17
  25. package/driver/verify-knockout.mjs +0 -27
  26. package/mcp-server/CHANGELOG.md +4 -0
  27. package/mcp-server/package.json +1 -1
  28. package/package.json +1 -1
  29. package/portal-ui/dist/assets/{index-CtvwLCti.css → index-7Lq-dXDV.css} +12 -9
  30. package/portal-ui/dist/assets/{index-DXSRxPV_.js → index-w8GFZftk.js} +110 -69
  31. package/portal-ui/dist/index.html +2 -2
  32. package/portal-ui/package.json +1 -1
  33. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  34. package/providers/oauth-mcp-bridge/package.json +1 -1
  35. package/scripts/ask-ai-render-check.mjs +26 -1
  36. package/scripts/dead-names.mjs +17 -16
  37. package/scripts/env-audit.mjs +6 -0
  38. package/scripts/repo-writes.mjs +42 -0
  39. package/scripts/report-sections-render-check.mjs +245 -0
  40. package/scripts/report-theme-render-check.mjs +31 -3
  41. package/scripts/settings-render-check.mjs +10 -7
  42. package/scripts/test-run.mjs +63 -4
  43. package/shared/client-door.mjs +20 -0
  44. package/shared/parent-watch.mjs +33 -0
  45. package/shared/running-start.mjs +14 -3
  46. package/shared/scope.mjs +22 -3
@@ -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.11",
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": {
@@ -1,5 +1,9 @@
1
1
  # trademark-oauth-mcp-bridge
2
2
 
3
+ ## 0.3.2-beta.12
4
+
5
+ No changes in this release.
6
+
3
7
  ## 0.3.2-beta.11
4
8
 
5
9
  No changes in this release.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-oauth-mcp-bridge",
3
- "version": "0.3.2-beta.11",
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).",
@@ -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)
@@ -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;
@@ -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
 
@@ -119,3 +119,45 @@ export function explainRepoWrites(rows) {
119
119
  " touching the tree to tell the two apart.)",
120
120
  ];
121
121
  }
122
+
123
+ // ── the home the run executes as ────────────────────────────────────────────────────────────────────
124
+ //
125
+ // THE SAME RULE, ONE FOLDER OUT. The suite is documented as offline with no side effects, and a fresh
126
+ // clone's run left `~/trademark/telemetry/trademark-mcp-access.jsonl` behind (measured 2026-09-19, one
127
+ // line per run): a test spawned the stdio server with the real home, and `doctor` on that machine then
128
+ // reported the client door's access log as being written. These are the product's own folders under a
129
+ // home: the pool, workspace, queue and telemetry, and the settings, records and revocation list. A run
130
+ // leaves them as it found them. The rest of a home — npm's cache, a browser's profile — belongs to the
131
+ // tools a run uses, and is theirs to write.
132
+
133
+ /** The product's own folders under a home, relative to it. */
134
+ export const HOME_DATA = Object.freeze(["trademark", join(".config", "clearotron")]);
135
+
136
+ /** Every path under the home's product folders, stamped. A folder that does not exist is not walked. */
137
+ export function snapshotHome(home) {
138
+ const out = new Map();
139
+ for (const rel of HOME_DATA) {
140
+ const p = join(resolve(home), rel);
141
+ let isDir = false;
142
+ try { isDir = lstatSync(p).isDirectory(); } catch { continue; }
143
+ if (!isDir) { out.set(p, "not-a-directory"); continue; }
144
+ out.set(p, "dir");
145
+ for (const [k, v] of snapshotRepo(p)) out.set(k, v);
146
+ }
147
+ return out;
148
+ }
149
+
150
+ /** What a reader is told when a run wrote under the home's product folders. */
151
+ export function explainHomeWrites(rows, home) {
152
+ return [
153
+ "",
154
+ `[test-run] THIS RUN WROTE UNDER THE HOME IT RAN AS (${home}), in the product's own folders:`,
155
+ ...rows,
156
+ "",
157
+ " A test that starts a product command hands it a HOME under its own temp directory. Without one, the",
158
+ " command reads and writes the pool, queue, telemetry and settings of whoever runs the suite.",
159
+ "",
160
+ " (A service of this account writing there while the run was in flight prints this too. Run again",
161
+ " with HOME set to an empty temp directory to tell the two apart.)",
162
+ ];
163
+ }