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.
- package/bin/grant.mjs +21 -22
- package/build-info.json +2 -2
- package/driver/CHANGELOG.md +25 -0
- package/driver/package.json +1 -1
- package/driver/portal-config-view.mjs +26 -1
- package/driver/portal-report.mjs +16 -2
- package/driver/portal-service.mjs +286 -6
- package/driver/publish/render.mjs +0 -45
- package/driver/publish/templates/report.css +1 -30
- package/driver/skills/knockout-assess/SKILL.md +35 -6
- package/driver/skills/knockout-frame/SKILL.md +2 -1
- package/driver/skills/prelim-search/risk-framework-triage.md +3 -3
- package/driver/stages.mjs +2 -2
- package/driver/suite-census.json +75 -9
- package/driver/verify-knockout.mjs +58 -0
- package/mcp-server/CHANGELOG.md +7 -0
- package/mcp-server/lib/audit.mjs +96 -3
- package/mcp-server/lib/http-handler.mjs +76 -3
- package/mcp-server/lib/runs.mjs +42 -2
- package/mcp-server/package.json +1 -1
- package/mcp-server/packs/client/CONNECT.md +10 -6
- package/mcp-server/server.mjs +37 -3
- package/package.json +1 -1
- package/portal-ui/dist/assets/{index-D8ITW-aD.js → index-C-QysgZM.js} +1082 -630
- package/portal-ui/dist/assets/{index-D5WAoLZI.css → index-CaSZbEMb.css} +15 -0
- package/portal-ui/dist/index.html +2 -2
- package/portal-ui/package.json +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/scripts/ask-ai-render-check.mjs +359 -0
- package/shared/grants-edit.mjs +99 -2
|
@@ -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,.
|
|
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
|
|
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. **
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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.)
|
|
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.
|
|
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.
|
|
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. **
|
|
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:
|
package/driver/suite-census.json
CHANGED
|
@@ -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":
|
|
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":
|
|
3146
|
-
"asserts":
|
|
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":
|
|
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":
|
|
5748
|
-
"asserts":
|
|
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":
|
|
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":
|
|
6066
|
-
"asserts":
|
|
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);
|
package/mcp-server/CHANGELOG.md
CHANGED
|
@@ -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
|
package/mcp-server/lib/audit.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|