clearotron 0.3.1-beta.2 → 0.3.1-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -32,7 +32,6 @@ import { registrationSystem } from '../jurisdiction-systems.mjs';
32
32
  import { READ_LEAD_RE } from '../report-card-record.mjs'; // D3 — the dedupe gate and the card's acceptance are ONE predicate
33
33
  import { REPORT_ROOT, REPORT_ROOT_DARK_EXPLICIT, THEME_INIT_EXPLICIT, FAVICON_LINK, logoLockup, BRAND, confPosture } from '../../shared/brand.mjs';
34
34
  import { NAV_CSS } from '../../shared/site-nav.mjs';
35
- import { mintToken } from '../../shared/scope.mjs'; // mint a scoped read-only USER token for "Ask your AI" (dep-free; no-op fallback when no secret)
36
35
  import { isEntrypoint } from "../../shared/is-entrypoint.mjs"; // — realpath both sides, or a symlinked invocation exits 0 silently
37
36
 
38
37
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -1745,32 +1744,6 @@ function method(_text) {
1745
1744
  // lawyer signs. The slim internal toolbar that survived it existed ONLY to host the Flag/Etch
1746
1745
  // controls and rendered nothing without them, so took it with them.
1747
1746
 
1748
- // "Ask your AI" launcher (§2.7) — its own collapsible banner under the verdict (a native <details>, default
1749
- // collapsed, no-print). COPY-FIRST, bring-your-own-AI. Same gating as before: rendered only when a mcpUrl is
1750
- // present (internal = shared read-only MCP; client export = OMITTED unless a scoped mcpUrl is given).
1751
- function askAi(fm, { mcpUrl, runId }) {
1752
- const mark = String(fm.title || fm.matter || 'this mark').trim();
1753
- const prompt = runId ? `Brief me on trademark clearance run ${runId}.` : `Brief me on the ${mark} trademark clearance.`;
1754
- const pa = escAttr(prompt);
1755
- const steps = (label, items) => `<details class="askai-steps"><summary>${label}</summary><ol>${items.map(i => `<li>${i}</li>`).join('')}</ol></details>`;
1756
- return `<details class="askband no-print">
1757
- <summary>
1758
- <div class="askband-ic" aria-hidden="true">💬</div>
1759
- <div class="askband-main">
1760
- <div class="askband-title">Ask your AI about this run <span class="askband-exp" aria-hidden="true">▾</span></div>
1761
- <div class="askband-sub">Connect once and interrogate the findings in the Claude or ChatGPT you already use — read-only.</div>
1762
- </div>
1763
- </summary>
1764
- <div class="askband-body">
1765
- <button type="button" class="util primary askai-copy" data-copy="${pa}">📋 Copy question</button>
1766
- <p class="askai-hint">Paste into the Claude or ChatGPT you already use — read-only. New chat: <a href="https://claude.ai/new" target="_blank" rel="noopener">Claude →</a> · <a href="https://chatgpt.com/" target="_blank" rel="noopener">ChatGPT →</a></p>
1767
- <div class="askai-field"><code class="askai-url">${esc(mcpUrl)}</code><button type="button" class="util askai-copy" data-copy="${escAttr(mcpUrl)}">Copy</button></div>
1768
- ${steps('Set up Claude <span class="askai-checked">· ✓ Checked 4 September 2026</span>', ['Settings → Connectors → Add custom connector', 'Paste the address above — it already carries your key', 'Set Authentication to None', 'Add. If it warns that authentication is required, that is its own guess — None is correct here'])}
1769
- ${steps('Set up ChatGPT', ['Settings → Connectors → Advanced → Developer mode', 'Add MCP server, paste the address above', `Sign in when the browser opens (${BRAND.name} email)`])}
1770
- <p class="askai-hint askai-note">These steps name no button we have not opened ourselves. Your app may word them differently.</p>
1771
- </div>
1772
- </details>`;
1773
- }
1774
1747
 
1775
1748
  // Brand :root tokens are sourced from shared/brand.mjs (REPORT_ROOT) and prepended to the
1776
1749
  // report stylesheet, so report.css carries only rules — the palette lives in ONE place across surfaces.
@@ -1808,7 +1781,6 @@ window.addEventListener('hashchange',_cardHashGo);
1808
1781
  window.addEventListener('load',_cardHashGo);
1809
1782
  window.addEventListener('beforeprint',function(){document.querySelectorAll('details').forEach(function(d){d.dataset.o=d.open?'1':'';d.open=true;});});
1810
1783
  window.addEventListener('afterprint',function(){document.querySelectorAll('details').forEach(function(d){d.open=d.dataset.o==='1';});_hidden.forEach(function(c){c.classList.remove('print-hidden');});_hidden=[];});
1811
- document.addEventListener('click',function(e){var cp=e.target.closest('.askai-copy');if(cp){navigator.clipboard&&navigator.clipboard.writeText(cp.getAttribute('data-copy'));}});
1812
1784
  document.addEventListener('click',function(e){var t=e.target.closest('.tb-exp-toggle'),pop=document.querySelector('.tb-exp-pop');if(t){if(pop){pop.hidden=!pop.hidden;t.setAttribute('aria-expanded',String(!pop.hidden));}return;}if(pop&&!pop.hidden&&!e.target.closest('.tb-exp-pop')){pop.hidden=true;var b=document.querySelector('.tb-exp-toggle');if(b)b.setAttribute('aria-expanded','false');}});
1813
1785
  document.addEventListener('keydown',function(e){if(e.key==='Escape'){var pop=document.querySelector('.tb-exp-pop');if(pop&&!pop.hidden){pop.hidden=true;var b=document.querySelector('.tb-exp-toggle');if(b)b.setAttribute('aria-expanded','false');}}});`;
1814
1786
 
@@ -2154,21 +2126,6 @@ export function renderHtml(parsed, findings = [], coverage = [], opts = {}) {
2154
2126
 
2155
2127
  // Excel/audit download — lives inside the topbar Export popover (portal-report strips the link at serve time for non-staff).
2156
2128
  const excelBtn2 = !opts.auditFile ? '' : `<a class="util" href="${esc(opts.auditFile)}" download>⬇ Download full audit (Excel)</a>`;
2157
- // "Ask your AI" target. Explicit opts.mcpUrl wins. Otherwise the staff surface (CLEAROTRON_MCP_URL), with
2158
- // a run-scoped read-only token embedded when a secret is configured. ONE report: the document always
2159
- // carries the staff connector; portal-report.mjs strips the whole block (and redacts any surviving MCP
2160
- // host) for non-staff readers at serve time — the client's connector path is the portal's own
2161
- // /portal/api/mcp-access, which needs no baked credential. FAIL CLOSED on a missing env: NO placeholder
2162
- // host — a forgotten env used to render a mcp.example.com connector that looked configured and resolved
2163
- // nowhere.
2164
- let mcpUrl = opts.mcpUrl || '';
2165
- if (!mcpUrl) {
2166
- const base = process.env.CLEAROTRON_MCP_URL || '';
2167
- let tok = '';
2168
- if (base && opts.runId) { try { tok = mintToken({ scope: 'user', runId: opts.runId }); } catch { tok = ''; } }
2169
- mcpUrl = base ? (tok ? `${base}?token=${tok}` : base) : '';
2170
- }
2171
- const askAiHtml = mcpUrl ? askAi(fm, { mcpUrl, runId: opts.runId }) : '';
2172
2129
 
2173
2130
  const cardFor = f => matchCard(f, cards);
2174
2131
  const recordsByUri = opts.recordsByUri || new Map(); // Instance #6 — the run's _records/ set (publishReport loads it)
@@ -2372,8 +2329,6 @@ ${opts.nav || ''}
2372
2329
  findings.some((f) => (f?.owner?.registrations ?? []).some((r) => r?.uri
2373
2330
  && (recordsByUri.size > 0 || !(r.status || r.filed || r.expiry || (r.classes && r.classes.length))))))}
2374
2331
 
2375
- ${askAiHtml}
2376
-
2377
2332
  <footer>
2378
2333
  <span>${productName ? `${esc(productName)}. ` : ''}${FRAMEWORK
2379
2334
  // TWO SENTENCES, and that count is the ruled shape rather than a consequence of trimming.
@@ -214,22 +214,6 @@
214
214
  details.method[open]>summary::after{content:"▴"}
215
215
  .method .mbody{padding:0 22px 20px;font-size:13px;color:var(--slate);line-height:1.62}
216
216
 
217
- /* Ask-your-AI launcher */
218
- .askai{position:relative;display:inline-block}
219
- .askai-pop{position:absolute;z-index:30;top:calc(100% + 8px);left:0;width:340px;max-width:88vw;background:var(--card);
220
- border:1px solid var(--line2);border-radius:14px;box-shadow:var(--shadow);padding:14px}
221
- .askai-hint{font-size:11.5px;color:var(--slate);margin:9px 0 8px;line-height:1.45}
222
- .askai-field{display:flex;gap:6px;align-items:center;background:var(--bg2);border-radius:9px;padding:6px 8px;margin-bottom:8px}
223
- .askai-url{font-family:var(--mono);font-size:11px;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
224
- .askai-steps>summary{cursor:pointer;font-size:11.5px;font-weight:700;color:var(--crimson);padding:4px 0;list-style:none}
225
- .askai-steps>summary::-webkit-details-marker{display:none}
226
- .askai-steps ol{margin:4px 0 8px;padding-left:18px;font-size:11.5px;color:var(--slate)}
227
- /* The dated stamp on a set-up recipe we have actually driven. Muted on purpose: the recipe's own
228
- summary is crimson and bold, and a stamp that shouted as loudly would read as part of the step. */
229
- .askai-steps>summary .askai-checked{font-weight:600;color:var(--slate)}
230
- /* The note under BOTH recipes. `.askband-body .askai-hint` zeroes the top margin for the hint that
231
- opens the body; this one closes it and needs the gap back. */
232
- details.askband .askband-body .askai-note{margin-top:10px}
233
217
 
234
218
  footer{margin-top:54px;padding-top:20px;border-top:2px solid var(--ink);font-size:12px;color:var(--faint);line-height:1.6;display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap}
235
219
  footer .wm{font-size:13px}
@@ -282,19 +266,6 @@
282
266
  .rv-tag{font-size:9px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:#a06aa3}
283
267
  .rv-spacer{flex:1;min-width:16px}
284
268
 
285
- /* Ask-AI banner (collapsible) */
286
- details.askband{display:block;background:var(--card);border:1px solid var(--line);border-left:4px solid var(--crimson);border-radius:14px;padding:16px 18px;margin:20px 0 0;box-shadow:var(--shadow)}
287
- details.askband>summary{cursor:pointer;list-style:none;display:flex;gap:14px;align-items:flex-start}
288
- details.askband>summary::-webkit-details-marker{display:none}
289
- .askband-ic{flex:none;width:38px;height:38px;border-radius:11px;background:var(--high-soft);color:var(--high-tx);display:flex;align-items:center;justify-content:center;font-size:18px}
290
- .askband-main{flex:1;min-width:0}
291
- .askband-title{font-weight:900;font-size:16px;letter-spacing:-.01em;margin:0 0 3px}
292
- .askband-sub{font-size:13px;color:var(--slate);line-height:1.5;margin:0}
293
- .askband-exp{display:inline-block;transition:.15s;color:var(--faint);font-size:12px;margin-left:4px}
294
- details.askband[open] .askband-exp{transform:rotate(180deg)}
295
- details.askband .askband-body{margin-top:12px;padding-left:52px;max-width:560px}
296
- details.askband .askband-body .askai-hint{margin-top:0}
297
- @media(max-width:560px){details.askband .askband-body{padding-left:0}}
298
269
 
299
270
  /* cross-region rights-holder rows (.rrow) + secondary region groups (.rgroup) */
300
271
  .keypanel{overflow-y:auto}
@@ -334,7 +305,7 @@
334
305
  @media (min-width:760px){.rn-why{flex:1 1 46%;min-width:280px}}
335
306
 
336
307
  @media print{
337
- .topbar,.no-print,.review,.askband,.int-note{display:none!important}
308
+ .topbar,.no-print,.review,.int-note{display:none!important}
338
309
  body{background:#fff}
339
310
  .panel,.card,.land,.firstUp,.method{box-shadow:none}
340
311
  /* Only PURE TOGGLES hide their summary line in print — the drill toggles ("The read" / "Full detail
@@ -16,7 +16,8 @@ there is no other value: a band that needs sharpening is the band above it, stat
16
16
  ## Per mark — the mandatory sequence
17
17
 
18
18
  1. **Context framing FIRST** (from the plan; correct it only if plainly wrong): coined vs common
19
- phrase vs cultural echo — the rating hangs off this.
19
+ phrase vs cultural echo. It frames what the name READS as, and the evidence bullets draw on it.
20
+ The band does not: no framing raises or lowers a rating (calibration rule 3, retired).
20
21
  2. **Parody / evocation check**: does the name echo a famous mark or property even without identical
21
22
  ownership ("Free Range 1s" echoes "Air Force 1s")? Flag it even when nobody owns the echoed form.
22
23
  3. **Band per the framework ladder**, applying the calibration rules below.
@@ -58,11 +59,21 @@ there is no other value: a band that needs sharpening is the band above it, stat
58
59
  read." It was the only instruction in this seat's context licensing that move, and the readiest
59
60
  explanation for a Disney hit rating Very High and an EA hit rating High against a lawyer's Medium and
60
61
  Manageable. The rest of the rule stands and is doctrine.)*
61
- 3. **Never rate the lowest band for common English phrases — DO rate it for coined/fanciful terms.**
62
- Common word or phrase → second-lowest band minimum even on a clean sweep, plus the purple
63
- register-pending note. Coined/invented word with a clean sweep → the lowest band is correct.
64
- The test: could a reasonable person use this word in everyday speech without reference to the
65
- proposed mark? No → coined. Yes → common phrase → floor applies.
62
+ 3. **RETIRED — how ordinary the words are is a note for the reviewing lawyer, never a band.**
63
+ This forbade the lowest band for a common English phrase and put a floor a band above it. On a
64
+ scale that ends in Low it kept an everyday name off "no issues found"; on a client scale with no
65
+ Low band the floor landed one band higher still, and three names went out rated above every
66
+ conflict card on their own pages — twelve cards, all at the bottom band, under three names a band
67
+ above them, with nothing on the page to explain it. The reviewing lawyer rated the cards right and
68
+ the names wrong.
69
+ **Removed for every client, not only where it misfired** (owner, 2026-09-15). Keeping it on scales
70
+ that have a Low band was considered and rejected; so was explaining the gap on the report, because
71
+ a report that explains a wrong rating is still wrong.
72
+ Say what the words are in the reading — coined, compound, everyday phrase, cultural echo — and let
73
+ it inform the evidence bullets. It does not set, floor or lift a band. A risk that belongs to the
74
+ NAME rather than to one user is written as its own `findings[]` record and rated there; Famous
75
+ Brand and Cultural Reference are already card types for exactly that.
76
+ *The number is kept and not reused*, for the reason rule 4 gives below.
66
77
  4. **RETIRED — the caveat is conditional now, and it is not this file's to state.**
67
78
  This rule ordered the pending-register caveat on *every* summary, unconditionally. When the register
68
79
  ran and surfaced live filings, that sentence tells a client its ratings are common-law only while the
@@ -77,6 +88,24 @@ there is no other value: a band that needs sharpening is the band above it, stat
77
88
  at its "(low)" qualifier.
78
89
  7. **Client's prior use mitigates, doesn't eliminate.** Rate the full external landscape first; note
79
90
  the mitigation separately in a purple bullet.
91
+ 8. **A NAME IS NEVER RATED ABOVE THE WORST CARD ON ITS PAGE.** The page is one judgment: the name's
92
+ band, its basis line, its Why and Why-not boxes and its opening read all have to agree with each
93
+ other and with the cards under them. A reader who sees a band on the name and a milder band on
94
+ every card beneath it has been given two answers and no way to choose.
95
+ **Cards means every rated item on this name's page** — each `findings[]` record, and each
96
+ `registerReads[]` row that carries a band. A row with no band is not a card and does not count.
97
+ **A name with no rated cards takes the bottom band of the client's own ladder.** Nothing was found
98
+ against it; that is what the bottom band is for. It is not a reason to reach up a band.
99
+ **Rating BELOW the worst card is allowed and needs a sentence**, because it is a real judgment:
100
+ the worst card's holder may touch only the edge of the request. Say which card and why it does not
101
+ carry the name.
102
+ **Anything that should lift a name is a card of its own** — a famous-mark echo, a cultural
103
+ reading, a risk that belongs to the NAME rather than to one user. Write it as a `findings[]`
104
+ record and rate it there, where a reader can see what it is and disagree with it. A band lifted
105
+ without a card is a conclusion with its evidence left out.
106
+ The driver checks this and REFUSES, naming the card it read: it never rewrites your band. A
107
+ corrected band you reasoned is the product; a band a script lowered under prose that still argues
108
+ for the old one is worse than the defect it replaced.
80
109
 
81
110
  ## Degraded marks (research unavailable or null — never inflate)
82
111
 
@@ -35,7 +35,8 @@ mark) and the assessment turn; nothing you write here searches anything.
35
35
  integers only. Scope expansion is NEVER silent.
36
36
  4. **Context framing** per mark (MANDATORY): what does this name read as in THIS context — coined,
37
37
  compound, common phrase, cultural echo? ("Coop" = chicken coop for a poultry brand, not a
38
- cooperative.) The assessor's rating hangs off this framing.
38
+ cooperative.) It tells the assessor what the name reads as. It does not set the band — a framing
39
+ never raises or lowers a rating, and a risk belonging to the name is a finding of its own.
39
40
  5. **Prior knowledge** from the request/instructions: anything the forwarder said about a mark
40
41
  ("client loves this one", known history) — folded in verbatim, never invented.
41
42
  6. **Execution order**: common-word / known-problem marks first (they surface the batch's hard calls
@@ -23,17 +23,17 @@
23
23
 
24
24
  ### Manageable
25
25
 
26
- - **Triage read.** Minor findings, no strong claims. Flag major competitors/brand owners if relevant. The floor for any common English phrase.
26
+ - **Triage read.** Minor findings, no strong claims. Flag major competitors/brand owners if relevant.
27
27
 
28
28
  ### Low
29
29
 
30
- - **Triage read.** No issues found. Available only for coined/fanciful terms (calibration rule 3).
30
+ - **Triage read.** No issues found.
31
31
 
32
32
  ## Calibration rules (they override gut instinct — reviewer-derived)
33
33
 
34
34
  1. **Class-specific ratings are mandatory at Medium and above** — "Medium (Classes 9, 25, 28, 41)", never a bare band.
35
35
  2. **Crowded field = diluted risk, not amplified** — many small non-enforcing players typically caps risk at Medium.
36
- 3. **Common English phrases never rate Low** (Manageable floor + a purple register-pending note); **coined/fanciful terms CAN be Low**. The test: could a reasonable person use this word in everyday speech without reference to the proposed mark? No → coined → Low available. Yes → common phrase → Manageable floor.
36
+ 3. **RETIRED — an everyday name with nothing serious found can reach the bottom band** (owner, 2026-09-15). This read "common English phrases never rate Low"; it decided a band from how ordinary the words were, which no evidence supports and which the clearance lane's own instruction forbids in terms — the applicant's own-mark read is advisory and never moves a band. How ordinary or ownable the words are is a note for the reviewing lawyer. Anything that should lift a name is a finding of its own, rated as one.
37
37
  4. **Every rating is pending register overlay** — the standing caveat rides every summary.
38
38
  5. **Enforcer profiling matters** at Medium+ — portfolio brand? opposition history? institutional filer? "No assertive enforcers" can cap a Medium at "Medium (low)".
39
39
  6. **Client's prior use mitigates, never overrides** — rate the full external landscape first; note the mitigation in a purple bullet.
package/driver/stages.mjs CHANGED
@@ -3042,7 +3042,7 @@ export const STAGES = {
3042
3042
  // therapeutic-goods discipline, no-use ≠ safety (pipelines run 5-10y), practitioner/pharmacist
3043
3043
  // confusion. Conditional read (frameworkFor pattern); doctrine TABLES live elsewhere, untouched.
3044
3044
  reads(synthesisSkillReads({ profile, job })),
3045
- `Produce the joint synthesis narrative (dominant-element spine, cross-pollination Option-D cap N=10, actual-use check, owner workup on high-risk findings, rate each conflict net of merits defences UNDER THE FRAMEWORK YOU JUST READ — state the likelihood of confusion in plain words, then give the band those words require under the framework's own definitions (the prose and the band are one judgment and may not disagree), coverage honesty).`,
3045
+ `Produce the joint synthesis narrative (dominant-element spine, cross-pollination Option-D cap N=10, actual-use check, owner workup on high-risk findings, rate each conflict net of merits defences UNDER THE FRAMEWORK YOU JUST READ — state the likelihood of confusion in plain words, then give the band those words require under the framework's own definitions (the prose and the band are one judgment and may not disagree), coverage honesty). WHERE THE MATTER RATIFIED MORE THAN ONE FORM OF THE NAME — a second spelling the instruction told you to read as a variant of the same candidate — REASON EACH FORM THROUGH THE FRAMEWORK ON ITS OWN, then say in the narrative which conflicts move between them and why, naming the form each read belongs to. A conflict that turns on one form exactly is not the same exposure for the other, and a reader choosing between the forms cannot act on a single undifferentiated read. Where every conflict lands the same on both, say so in one line — "the reads are the same for both forms" — as an asserted finding rather than a silence: a reader must be able to tell a matter where the forms do not differ from one where nobody looked. This changes the NARRATIVE and nothing about the report's shape: one overall band as today, and never a second one.`,
3046
3046
  // — the prose rung. Empty on a one-country run, and `lines` drops it.
3047
3047
  proseRungDirective(depth, framework?.bands ?? null),
3048
3048
  // doc 50 — THE RATING AUTHORITY: the framework read above (the customer's own, or the Generic default)
@@ -3702,7 +3702,7 @@ export const STAGES = {
3702
3702
  `Hand the shell back by calling the \`record_report_overview\` tool. There is no file to write and no front-matter to type: the driver renders the shell — every front-matter key it already holds, the \`# Actions\` section and the optional \`# Methodology\` note — from the values you send, and assembles the cards around it. Do NOT author any \`## <owner> — <MARK>\` card and do not think about a \`# Marks\` heading; each card is rendered separately. Risk scoring is unchanged.`,
3703
3703
  // TRANSFORM, NOT FREE-GENERATION (Fix B1 — carried from main's report-synthesis): the shell RESHAPES the
3704
3704
  // settled synthesis, it never re-derives. Applied here to overall_caption + # Actions.
3705
- `TRANSFORM, NOT FREE-GENERATION: overall_caption and # Actions draw ONLY on the settled synthesis narrative ${P.narrative} (and findings.json ${P.findings}) — introduce NO finding, owner, or claim that is not already there, NEVER re-compute a band, and never let a claim outrun its evidence (a verified-from-record fact stays fact; an inferred-from-signal claim reads AS an inference — "on the available signals…" — never flattened to bald fact).`,
3705
+ `TRANSFORM, NOT FREE-GENERATION: overall_caption and # Actions draw ONLY on the settled synthesis narrative ${P.narrative} (and findings.json ${P.findings}) — introduce NO finding, owner, or claim that is not already there, NEVER re-compute a band, and never let a claim outrun its evidence (a verified-from-record fact stays fact; an inferred-from-signal claim reads AS an inference — "on the available signals…" — never flattened to bald fact). WHERE THE SETTLED NARRATIVE REASONED MORE THAN ONE RATIFIED FORM OF THE NAME, CARRY ITS PER-FORM READ THROUGH — the overall_caption says which form the decisive conflicts turn on, or that the reads are the same for both, in the narrative's own words. Do not flatten two reads into one undifferentiated statement: that loses the distinction the client ratified both forms to get. This is a transform of what the narrative already settled, not a second rating, and the band is unchanged — one overall band as today.`,
3706
3706
  // — the D2 case-law line is RETIRED HERE (it stays on `report-card`, which is where
3707
3707
  // enforcement-history and opposition-likelihood are actually written). Two reasons, and the second
3708
3708
  // is the general one:
@@ -471,6 +471,12 @@
471
471
  "skips": 1,
472
472
  "todos": 0
473
473
  },
474
+ "a-name-is-never-rated-above-its-worst-card.test.mjs": {
475
+ "tests": 4,
476
+ "asserts": 15,
477
+ "skips": 0,
478
+ "todos": 0
479
+ },
474
480
  "a-name-the-wizard-returns-is-a-setup-name.test.mjs": {
475
481
  "tests": 5,
476
482
  "asserts": 9,
@@ -2439,9 +2445,15 @@
2439
2445
  "skips": 0,
2440
2446
  "todos": 0
2441
2447
  },
2448
+ "giving-access-to-everything-is-a-switch-not-a-point.test.mjs": {
2449
+ "tests": 1,
2450
+ "asserts": 4,
2451
+ "skips": 0,
2452
+ "todos": 0
2453
+ },
2442
2454
  "grant-command.test.mjs": {
2443
2455
  "tests": 19,
2444
- "asserts": 75,
2456
+ "asserts": 76,
2445
2457
  "skips": 0,
2446
2458
  "todos": 0
2447
2459
  },
@@ -3142,8 +3154,8 @@
3142
3154
  "todos": 0
3143
3155
  },
3144
3156
  "portal-service.test.mjs": {
3145
- "tests": 124,
3146
- "asserts": 673,
3157
+ "tests": 127,
3158
+ "asserts": 683,
3147
3159
  "skips": 1,
3148
3160
  "todos": 0
3149
3161
  },
@@ -3665,7 +3677,7 @@
3665
3677
  },
3666
3678
  "render.test.mjs": {
3667
3679
  "tests": 154,
3668
- "asserts": 630,
3680
+ "asserts": 632,
3669
3681
  "skips": 0,
3670
3682
  "todos": 0
3671
3683
  },
@@ -4377,6 +4389,12 @@
4377
4389
  "skips": 0,
4378
4390
  "todos": 0
4379
4391
  },
4392
+ "taking-access-away-is-the-editor-that-gave-it.test.mjs": {
4393
+ "tests": 9,
4394
+ "asserts": 30,
4395
+ "skips": 0,
4396
+ "todos": 0
4397
+ },
4380
4398
  "teardown-leaves-nothing-claimable.test.mjs": {
4381
4399
  "tests": 4,
4382
4400
  "asserts": 19,
@@ -4815,6 +4833,12 @@
4815
4833
  "skips": 0,
4816
4834
  "todos": 0
4817
4835
  },
4836
+ "the-people-page-can-change-and-remove-a-person.test.mjs": {
4837
+ "tests": 16,
4838
+ "asserts": 80,
4839
+ "skips": 0,
4840
+ "todos": 0
4841
+ },
4818
4842
  "the-person-who-installs-is-a-person-not-a-domain.test.mjs": {
4819
4843
  "tests": 18,
4820
4844
  "asserts": 86,
@@ -5151,6 +5175,12 @@
5151
5175
  "skips": 0,
5152
5176
  "todos": 0
5153
5177
  },
5178
+ "two-ratified-forms-get-reasoned-apart.test.mjs": {
5179
+ "tests": 7,
5180
+ "asserts": 17,
5181
+ "skips": 0,
5182
+ "todos": 0
5183
+ },
5154
5184
  "two-word-annotated-variant-is-refused.test.mjs": {
5155
5185
  "tests": 6,
5156
5186
  "asserts": 21,
@@ -5450,6 +5480,18 @@
5450
5480
  "skips": 0,
5451
5481
  "todos": 0
5452
5482
  },
5483
+ "a-removed-person-loses-their-assistant-too.test.mjs": {
5484
+ "tests": 4,
5485
+ "asserts": 12,
5486
+ "skips": 0,
5487
+ "todos": 0
5488
+ },
5489
+ "a-run-list-names-its-client.test.mjs": {
5490
+ "tests": 4,
5491
+ "asserts": 29,
5492
+ "skips": 0,
5493
+ "todos": 0
5494
+ },
5453
5495
  "account-audit-chain.test.mjs": {
5454
5496
  "tests": 10,
5455
5497
  "asserts": 46,
@@ -5720,6 +5762,12 @@
5720
5762
  "skips": 0,
5721
5763
  "todos": 0
5722
5764
  },
5765
+ "the-portal-can-tell-who-has-connected-an-assistant.test.mjs": {
5766
+ "tests": 10,
5767
+ "asserts": 28,
5768
+ "skips": 0,
5769
+ "todos": 0
5770
+ },
5723
5771
  "token-door-boot.test.mjs": {
5724
5772
  "tests": 7,
5725
5773
  "asserts": 8,
@@ -5744,8 +5792,8 @@
5744
5792
  "todos": 0
5745
5793
  },
5746
5794
  "askAi.test.ts": {
5747
- "tests": 5,
5748
- "asserts": 12,
5795
+ "tests": 11,
5796
+ "asserts": 34,
5749
5797
  "skips": 0,
5750
5798
  "todos": 0
5751
5799
  },
@@ -5815,6 +5863,12 @@
5815
5863
  "skips": 0,
5816
5864
  "todos": 0
5817
5865
  },
5866
+ "decodePersonCarriesEveryField.test.ts": {
5867
+ "tests": 4,
5868
+ "asserts": 17,
5869
+ "skips": 0,
5870
+ "todos": 0
5871
+ },
5818
5872
  "effortModelParity.test.ts": {
5819
5873
  "tests": 7,
5820
5874
  "asserts": 31,
@@ -5865,7 +5919,7 @@
5865
5919
  },
5866
5920
  "home.test.ts": {
5867
5921
  "tests": 51,
5868
- "asserts": 169,
5922
+ "asserts": 177,
5869
5923
  "skips": 0,
5870
5924
  "todos": 0
5871
5925
  },
@@ -5941,6 +5995,12 @@
5941
5995
  "skips": 0,
5942
5996
  "todos": 0
5943
5997
  },
5998
+ "oneHeaderPerPage.test.ts": {
5999
+ "tests": 4,
6000
+ "asserts": 15,
6001
+ "skips": 0,
6002
+ "todos": 0
6003
+ },
5944
6004
  "ownerNames.test.ts": {
5945
6005
  "tests": 9,
5946
6006
  "asserts": 23,
@@ -5977,6 +6037,12 @@
5977
6037
  "skips": 0,
5978
6038
  "todos": 0
5979
6039
  },
6040
+ "proseIsStrippedOnce.test.ts": {
6041
+ "tests": 3,
6042
+ "asserts": 12,
6043
+ "skips": 0,
6044
+ "todos": 0
6045
+ },
5980
6046
  "readiness.test.ts": {
5981
6047
  "tests": 6,
5982
6048
  "asserts": 19,
@@ -6062,8 +6128,8 @@
6062
6128
  "todos": 0
6063
6129
  },
6064
6130
  "useYourAI.test.ts": {
6065
- "tests": 13,
6066
- "asserts": 41,
6131
+ "tests": 12,
6132
+ "asserts": 38,
6067
6133
  "skips": 0,
6068
6134
  "todos": 0
6069
6135
  },
@@ -668,12 +668,70 @@ export function validateMergedFindings(runDir, merged, plan) {
668
668
  // would put a field in the artifact the stage never emitted.
669
669
  try { const ranked = validateKnockoutFindings(m.findings, { manifest }); if (Array.isArray(m.findings)) m.findings = ranked; }
670
670
  catch (e) { failures.push(`mark "${m.name}": ${e.message}`); }
671
+ const above = markRatedAboveItsCards(manifest, m);
672
+ if (above) failures.push(above);
671
673
  }
672
674
  const receipts = knockoutReceipts(runDir, marks);
673
675
  failures.push(...receipts.failures);
674
676
  return { ok: failures.length === 0, failures, receipts: receipts.checked };
675
677
  }
676
678
 
679
+ // ── A NAME IS NEVER RATED ABOVE THE WORST CARD ON ITS PAGE ───────────────────────────────────────────
680
+ //
681
+ // Three names went out rated a band above every conflict card on their own pages. Twelve cards across
682
+ // the three, all at the client's bottom band; three names a band above them; nothing on any page
683
+ // explaining the gap. The reviewing lawyer read the cards as right and the names as wrong.
684
+ //
685
+ // Nothing tied the two together. The name's band is the model's own `rating` and no check compared it
686
+ // with the bands underneath it — a gap the full clearance does not have, where the headline is computed
687
+ // from the worst live finding. The doctrine that produced the lift (the everyday-words floor) is retired
688
+ // in this change; this is the floor under it, so the same shape cannot arrive by another route.
689
+ //
690
+ // IT REFUSES AND ASKS AGAIN. It never lowers the band itself, and that restraint is the point: a page
691
+ // whose badge a script moved while its basis line still argues for the old band gives a reader a
692
+ // Manageable chip over a Medium argument, which is worse than the defect. The corrective ladder this
693
+ // failure list feeds re-dispatches the mark with the reason, and the model reasons the band again.
694
+ //
695
+ // A CARD IS A RATED ITEM ON THIS NAME'S PAGE — a `findings[]` record, or a `registerReads[]` row that
696
+ // carries a band. A register row's band is optional by design (a read with no band prints no chip), so
697
+ // a row without one is not a card and does not count; including it would invent a floor out of silence.
698
+ // There is no withdrawn state on a knockout finding — that is the clearance lane's concept and it does
699
+ // not exist here, so nothing is filtered for it. Whoever brings it over adds the filter with it.
700
+ //
701
+ // RATING BELOW THE WORST CARD STAYS ALLOWED. It has happened, and the one that was checked was reasoned
702
+ // — the worst card's holder touched only the edge of the request. This refuses one direction only.
703
+ //
704
+ // INDEX 0 IS THE WORST BAND, as `worstBand` below says in its own comment, so "above" is a SMALLER
705
+ // index. Written the other way round this check would pass on the exact production run it was written
706
+ // for and fire on correct ones; the arms assert the direction against a real four-band ladder rather
707
+ // than trusting this sentence.
708
+ const cardBands = (m) => [
709
+ ...(Array.isArray(m?.findings) ? m.findings : []).map((f) => f?.band),
710
+ ...(Array.isArray(m?.registerReads) ? m.registerReads : []).map((r) => r?.band),
711
+ ].filter((b) => b != null && String(b).trim());
712
+
713
+ export function markRatedAboveItsCards(framework, m) {
714
+ const ladder = ladderOf(framework);
715
+ if (!ladder.length) return null; // no frozen ladder — the band checks above own that
716
+ const mine = bandIdx(ladder, m?.rating);
717
+ if (mine < 0) return null; // an unknown band is knockout_band_unknown's to name
718
+ const idxs = cardBands(m).map((b) => bandIdx(ladder, b)).filter((i) => i >= 0);
719
+ // NO CARDS, NOTHING TO BE ABOVE — and the code stops here deliberately.
720
+ //
721
+ // The doctrine says a name with nothing found against it takes the bottom band, and that belongs in
722
+ // the reasoning where it is written (calibration rule 8). Enforcing it HERE would be a second rule in
723
+ // code: a floor that forces a band down, which is what the ruling forbids in terms — "do not replace
724
+ // the removed rule with another floor, cap or word list. The two rulings are the whole requirement."
725
+ //
726
+ // The first draft of this function did enforce it, and the e2e batch is what said so: a mark with no
727
+ // cards, correctly rated Manageable on a five-band ladder, was refused because the bottom of that
728
+ // ladder is Low. That is the retired rule's own shape — a band decided by something other than the
729
+ // evidence on the page — arriving from the opposite direction.
730
+ if (!idxs.length) return null;
731
+ const worst = Math.min(...idxs);
732
+ return mine < worst ? `knockout_rating_above_cards:${m?.name}: rated "${m?.rating}", above every card on its page — the worst is "${ladder[worst]}". Rate the name from its cards, or write the risk that lifts it as a finding of its own and rate it there. Rating BELOW the worst card is allowed and wants a sentence saying which card does not carry the name.` : null;
733
+ }
734
+
677
735
  // worst band across the batch, in the frozen ladder's own order (index 0 = worst)
678
736
  export function worstBand(framework, marks) {
679
737
  const ladder = ladderOf(framework);
@@ -1,5 +1,12 @@
1
1
  # trademark-artifacts-mcp
2
2
 
3
+ ## 0.3.1-beta.3
4
+
5
+ ### Patch Changes
6
+
7
+ - 56a760f: Fixed: Removing somebody's access now ends their assistant's connection on its next request. Until now an assistant that had already connected kept the access it started with for up to half an hour.
8
+ - c05e0a7: Fixed: Ask your assistant for a client's recent searches by the client's name and it finds them. Every search in the list now says which client it was for, and which project. A search whose own record cannot be read says so, instead of appearing to belong to nobody.
9
+
3
10
  ## 0.3.1-beta.2
4
11
 
5
12
  ### Patch Changes
@@ -6,7 +6,7 @@
6
6
  // name + runId/uri), never the full request body or any artifact content. Best-effort: a write failure must
7
7
  // never break a request.
8
8
 
9
- import { appendFileSync, mkdirSync, existsSync } from "node:fs";
9
+ import { appendFileSync, mkdirSync, existsSync, statSync, openSync, readSync, closeSync } from "node:fs";
10
10
  import { dirname, join } from "node:path";
11
11
  import { homedir } from "node:os";
12
12
 
@@ -40,9 +40,102 @@ export function summarize(body) {
40
40
  return out;
41
41
  }
42
42
 
43
- export function appendAudit({ email, sub, body, status, path = DEFAULT_AUDIT_PATH }) {
43
+ export function appendAudit({ email, sub, body, status, transport, path = DEFAULT_AUDIT_PATH }) {
44
44
  // `sub` = the inner-token PRINCIPAL (ops-token issuance, INSTALL.md §8) — distinguishes
45
45
  // two automations sharing a transport identity. null for internal/user sessions without a sub claim.
46
- const line = JSON.stringify({ ts: new Date().toISOString(), email: email ?? null, sub: sub ?? null, ...summarize(body), status: status ?? null }) + "\n";
46
+ //
47
+ // `transport` is written only where it changes an answer: "stdio" marks a call that arrived over the
48
+ // local route, which has no signed-in identity to record and therefore no email. Omitted on the HTTP
49
+ // doors, whose records are identified by the email they already carry.
50
+ const line = JSON.stringify({ ts: new Date().toISOString(), email: email ?? null, sub: sub ?? null, ...summarize(body), status: status ?? null, ...(transport ? { transport } : {}) }) + "\n";
47
51
  try { mkdirSync(dirname(path), { recursive: true }); appendFileSync(path, line); } catch { /* best-effort */ }
48
52
  }
53
+
54
+ // ── READING THE LOG BACK ────────────────────────────────────────────────────────────────────────────
55
+ //
56
+ // The portal asks one question of this log: has this reader's assistant ever called a connector on this
57
+ // installation? It is the only signal either process has — enrolment says a person MAY connect, not that
58
+ // they DID — and the Ask-AI control on a report is drawn from the answer.
59
+ //
60
+ // THE PORTAL MUST NOT COMPUTE THIS PATH ITSELF. `DEFAULT_AUDIT_PATH` resolves at import, in the writing
61
+ // process, from that process's `TRADEMARK_MCP_AUDIT_LOG` and its `homedir()`. A reader that re-ran that
62
+ // branch would be the portal-reads-the-connector's-environment defect in a new place. It agrees today —
63
+ // on every shipped shape the portal and the connector are units of the SAME login reading the SAME
64
+ // `%h/.env`, and the override name is set nowhere — but `existsSync(LEGACY_AUDIT_PATH)` is evaluated at
65
+ // import in two processes that start at different times, so a legacy file appearing between those two
66
+ // imports would give them different answers with nothing in either log to say so.
67
+ //
68
+ // So: read BOTH candidates and take the union. A reader that has connected appears in one of them; which
69
+ // one is not a question worth asking, and asking it is the part that can be wrong.
70
+
71
+ /**
72
+ * Every file a connector on this installation could be appending to.
73
+ *
74
+ * AN OVERRIDE IS AN ANSWER, NOT A PREFERENCE. `TRADEMARK_MCP_AUDIT_LOG` exists to relocate this log, and
75
+ * `appendAudit` writes there and nowhere else — so a reader that went on to consult the default homes
76
+ * would report readers from a log the connector on this box is not writing to. It is only where the name
77
+ * is UNSET that there are two candidates to union, because that is the only case where the two processes
78
+ * resolve it themselves.
79
+ */
80
+ export function auditPaths() {
81
+ const override = String(process.env.TRADEMARK_MCP_AUDIT_LOG ?? "").trim();
82
+ if (override) return [override];
83
+ const seen = new Set();
84
+ const out = [];
85
+ for (const p of [LEGACY_AUDIT_PATH, join(homedir(), "trademark", "telemetry", "trademark-mcp-access.jsonl")]) {
86
+ if (!seen.has(p)) { seen.add(p); out.push(p); }
87
+ }
88
+ return out;
89
+ }
90
+
91
+ const TAIL_BYTES = 256 * 1024;
92
+
93
+ /**
94
+ * Who has called a connector on this installation, from the tail of the access log.
95
+ *
96
+ * BOUNDED AND FALLIBLE BY DESIGN. The log is append-only and unrotated, so it is read from the end and
97
+ * never whole; `truncated` says the window did not reach the start of the file. A reader further back
98
+ * than the window reads as absent, which is the same answer as a reader who has genuinely never called,
99
+ * and the control's own escape ("Already connected? Ask anyway") is what makes that survivable.
100
+ *
101
+ * `available: false` IS NOT "NOBODY HAS CONNECTED". No log, an unreadable one, a directory where a file
102
+ * should be — every one of those is a could-not-look, and a caller that renders it as "never connected"
103
+ * is asserting something it did not measure. It is returned separately so a caller can say so.
104
+ */
105
+ export function readConnections({ paths = auditPaths(), maxBytes = TAIL_BYTES } = {}) {
106
+ const emails = new Set();
107
+ let local = false;
108
+ let available = false;
109
+ let truncated = false;
110
+ const notes = [];
111
+
112
+ for (const path of paths) {
113
+ let fd = null;
114
+ try {
115
+ const size = statSync(path).size;
116
+ fd = openSync(path, "r");
117
+ const from = size > maxBytes ? size - maxBytes : 0;
118
+ if (from > 0) truncated = true;
119
+ const buf = Buffer.allocUnsafe(size - from);
120
+ readSync(fd, buf, 0, buf.length, from);
121
+ available = true;
122
+ const lines = buf.toString("utf8").split("\n");
123
+ // A window that starts mid-file starts mid-line: that first fragment is not a record.
124
+ if (from > 0) lines.shift();
125
+ for (const line of lines) {
126
+ if (!line.trim()) continue;
127
+ let rec = null;
128
+ try { rec = JSON.parse(line); } catch { continue; }
129
+ if (!rec || typeof rec !== "object") continue;
130
+ if (typeof rec.email === "string" && rec.email.trim()) emails.add(rec.email.trim().toLowerCase());
131
+ if (rec.transport === "stdio") local = true;
132
+ }
133
+ } catch (e) {
134
+ if (e?.code !== "ENOENT") notes.push(`${path}: ${e?.code ?? e?.message ?? e}`);
135
+ } finally {
136
+ if (fd !== null) { try { closeSync(fd); } catch { /* best-effort */ } }
137
+ }
138
+ }
139
+
140
+ return { available, emails, local, truncated, note: notes.length ? notes.join("; ") : null };
141
+ }