clearotron 0.3.2-beta.10 → 0.3.2-beta.11

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 (43) hide show
  1. package/CONTRIBUTING.md +1 -0
  2. package/INSTALL.md +49 -46
  3. package/README.md +8 -7
  4. package/bin/onboard.mjs +44 -13
  5. package/bin/start.mjs +31 -6
  6. package/build-info.json +2 -2
  7. package/docs/architecture/05-config-governance.md +1 -1
  8. package/driver/CHANGELOG.md +400 -388
  9. package/driver/engine/mcp/recording-server.mjs +20 -0
  10. package/driver/package.json +1 -1
  11. package/driver/phase0.mjs +16 -7
  12. package/driver/pipeline.mjs +48 -1
  13. package/driver/portal-local-auth.mjs +14 -4
  14. package/driver/portal-service.mjs +12 -3
  15. package/driver/publish/attr.mjs +36 -0
  16. package/driver/publish/index.mjs +10 -10
  17. package/driver/publish/parse.mjs +1 -1
  18. package/driver/publish/render.mjs +9 -9
  19. package/driver/publish/xlsx.mjs +7 -1
  20. package/driver/queue-watch-verdict.mjs +1 -1
  21. package/driver/suite-census.json +59 -23
  22. package/driver/unit-inventory.mjs +7 -7
  23. package/mcp-server/CHANGELOG.md +23 -19
  24. package/mcp-server/lib/audit-view.mjs +4 -4
  25. package/mcp-server/lib/trace.mjs +1 -1
  26. package/mcp-server/package.json +1 -1
  27. package/package.json +2 -2
  28. package/portal-ui/package.json +1 -1
  29. package/providers/jx/README.md +2 -2
  30. package/providers/jx-subclass/README.md +1 -1
  31. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  32. package/providers/oauth-mcp-bridge/package.json +1 -1
  33. package/scripts/README.md +5 -10
  34. package/scripts/citation-line-check.mjs +2 -2
  35. package/scripts/e2e.mjs +8 -8
  36. package/scripts/generated-files-are-current.mjs +35 -4
  37. package/scripts/live-surface-check.mjs +17 -17
  38. package/scripts/release-code-scanning-check.mjs +114 -0
  39. package/scripts/release-visible-check.mjs +117 -0
  40. package/shared/invocation.mjs +2 -2
  41. package/shared/register-selection.mjs +4 -1
  42. package/shared/root-doc-commands.mjs +10 -4
  43. package/shared/scope.mjs +2 -2
@@ -826,6 +826,26 @@ serve({
826
826
  },
827
827
  },
828
828
  },
829
+ // OFFERED HERE, OR NEVER SENT. The acceptor took this field for a beta and the plan acted on it,
830
+ // and no frame ever proposed one, because the schema a model is given did not offer it. Worded as
831
+ // the owner approved it; it is model-facing prose, so its wording is his.
832
+ house_element_candidate: {
833
+ type: "object",
834
+ description:
835
+ "Only when an element of the mark is one the CLIENT already owns as a registered mark in the " +
836
+ "instructed classes (a house mark before a tagline, for instance): name that element, the remainder " +
837
+ "the analysis should be limited to, and why you read it as the client's own. Ownership is checked " +
838
+ "on the register, by owner, before anything is excluded; if it cannot be verified, nothing is. Omit " +
839
+ "the field when no element is the client's own.",
840
+ required: ["element", "remainder", "owner_basis"],
841
+ properties: {
842
+ element: { type: "string", description: "The client's own element, exactly as it appears in the mark." },
843
+ remainder: { type: "string",
844
+ description: "The rest of the mark: the part the analysis is limited to. Never empty, never the element itself." },
845
+ owner_basis: { type: "string",
846
+ description: "Why you read the element as the client's own — what in the matter says so." },
847
+ },
848
+ },
829
849
  },
830
850
  },
831
851
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
@@ -2,7 +2,7 @@
2
2
  "name": "clearotron-driver",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.3.2-beta.10",
5
+ "version": "0.3.2-beta.11",
6
6
  "license": "AGPL-3.0-only",
7
7
  "description": "Deterministic driver for the trademark clearance workflow: orchestration in code (fan-out, fan-in barrier, gating, retries); the model does judgment leaves only, through a reasoning CLI spawned per stage.",
8
8
  "engines": {
package/driver/phase0.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { existsSync, appendFileSync, readFileSync, mkdirSync } from "node:fs";
7
7
  import { join, dirname } from "node:path";
8
- import { createHash, randomUUID } from "node:crypto";
8
+ import { createHash, randomUUID, randomInt } from "node:crypto";
9
9
  import { ledgerPath } from "../providers/_shared/ledger-path.mjs";
10
10
  import { config } from "./driver.config.mjs";
11
11
  import { resolveProfile } from "./profiles.mjs";
@@ -26,7 +26,15 @@ const NOUN = [
26
26
  "warren", "beacon", "quarry", "willow", "kestrel", "monolith", "estuary", "bramble", "compass", "drift",
27
27
  ];
28
28
 
29
- export function genCodename(rand = Math.random) {
29
+ // THE CODENAME IS DRAWN FROM THE OPERATING SYSTEM'S RANDOM SOURCE, not Math.random. It is a label, not a
30
+ // secret: it names a run's directory and appears in its reports, and the keys built from it name a run's
31
+ // attempts and ledgers — nothing is authorised by knowing one. But it is the first thing the run identifier
32
+ // is made of, and a label that is also unpredictable costs nothing here, while a predictable one invites
33
+ // every later reader to wonder what else leans on it. Same [0, 1) shape as Math.random, so the injected
34
+ // `rand` the tests pass for determinism works unchanged.
35
+ const cryptoRand = () => randomInt(0, 2 ** 32) / 2 ** 32;
36
+
37
+ export function genCodename(rand = cryptoRand) {
30
38
  return `${ADJ[Math.floor(rand() * ADJ.length)]}-${NOUN[Math.floor(rand() * NOUN.length)]}`;
31
39
  }
32
40
 
@@ -186,7 +194,7 @@ export function claimRunCodename({ slug, date, codename, registryPath = codename
186
194
  // a pre-mint through raw genCodename would silently lose this collision protection.
187
195
  // 20 straight collisions ⇒ the 400-name space is exhausted for this slug+date — suffix for freshness.
188
196
  export function mintFreshCodename({ slug, date, studioRoot = config.studioRoot, archiveRoot = config.archiveRoot,
189
- rand = Math.random, claim = claimRunCodename }) {
197
+ rand = cryptoRand, claim = claimRunCodename }) {
190
198
  for (let i = 0; i < 20; i++) {
191
199
  const c = genCodename(rand);
192
200
  if (!existsSync(runDirFor({ slug, date, codename: c, studioRoot }))
@@ -198,22 +206,23 @@ export function mintFreshCodename({ slug, date, studioRoot = config.studioRoot,
198
206
  return `${genCodename(rand)}-${Date.now().toString(36)}`;
199
207
  }
200
208
 
201
- // Assemble the immutable run identity for a job. rand is injectable for tests. studioRoot/archiveRoot are the
209
+ // Assemble the immutable run identity for a job. rand and claim are injectable for tests — claim so a test's
210
+ // mints go to a registry of its own rather than the box-wide one every run on this account shares. studioRoot/archiveRoot are the
202
211
  // FORWARDING agent's (derived from its queue dir by the runner); they default to clawdi's for back-compat.
203
212
  // `codename`/`date` overrides exist for RESUME: re-driving a failed run must rebuild the SAME run identity
204
213
  // (slug/date/codename → the same run-dir) so the idempotency skip reuses the prior stages instead of minting
205
214
  // a fresh codename and re-spending everything (the "pearl-keystone" trap). A bare new run leaves both unset.
206
215
  export function buildRunContext(
207
216
  job,
208
- { rand = Math.random, now = new Date(), studioRoot = config.studioRoot, archiveRoot = config.archiveRoot,
209
- codename: codenameOverride, date: dateOverride } = {},
217
+ { rand = cryptoRand, now = new Date(), studioRoot = config.studioRoot, archiveRoot = config.archiveRoot,
218
+ codename: codenameOverride, date: dateOverride, claim = claimRunCodename } = {},
210
219
  ) {
211
220
  const slug = deriveSlug(job);
212
221
  const date = dateOverride ?? todayISO(now);
213
222
  // Fresh mints go through mintFreshCodename (above) so they never land in a dir another run already owns.
214
223
  // Overrides skip the check: RESUME (and the runner's dispatch pre-mint, which already minted freshly)
215
224
  // wants the identity verbatim.
216
- const codename = codenameOverride ?? mintFreshCodename({ slug, date, studioRoot, archiveRoot, rand });
225
+ const codename = codenameOverride ?? mintFreshCodename({ slug, date, studioRoot, archiveRoot, rand, claim });
217
226
  return {
218
227
  slug,
219
228
  codename,
@@ -13518,7 +13518,7 @@ async function pipelineInner(job, opts = {}) {
13518
13518
  // structured-only). Each stage is file-gated/resumable; per-card sessions feed the lint repair below.
13519
13519
  // C2 — fold same-owner+same-mark duplicate filings into one finding BEFORE the overview + cards read
13520
13520
  // findings.json, so the whole delivery phase (and the published copy) sees the single consolidated set.
13521
- injectDeferralCoverage(P, run.runDir, note); // A3: unclosed reopen directives become reader-visible coverage rows first
13521
+ injectDeferralCoverage(P, run.runDir, note); injectMeaningGapCoverage(P, run.runDir, note); // A3: unclosed reopen directives, and meaning searches that did not complete, become reader-visible coverage rows first
13522
13522
  // qw/cn-scope-honesty — the sibling injection: a CN-family-scope run whose zh lane did not run
13523
13523
  // discloses what the native-language investigation would have searched, and where it is offered
13524
13524
  // (coverage-limited: never clamps, never gates).
@@ -16831,3 +16831,50 @@ export function registerGapConditions(regGap) { // @internal
16831
16831
  });
16832
16832
  return out;
16833
16833
  }
16834
+
16835
+ // ── A MEANING SEARCH THAT DID NOT COMPLETE REACHES THE CLIENT'S OWN PAGE, NOT ONLY THE AUDIT ────────────
16836
+ //
16837
+ // The connotation gate delivers a run whose dictated meaning searches did not all complete, and records
16838
+ // each unfinished one as a gap carrying its term — a gap the engine wrote for a search that never came
16839
+ // back, or the provider's own row for one it refused. The audit read those gaps; the report was never
16840
+ // handed the grid, so whether a client learned a meaning search was missing was up to the synthesis
16841
+ // model. This puts each on the coverage the report renders, the way an unclosed follow-up already is:
16842
+ // the same row, `deferralCoverageRow`, and an approved reader sentence that says it is left open — true
16843
+ // of a drop and of a refusal alike. Telling the two apart on the page
16844
+ // would need a sentence nobody has approved, and is not attempted here.
16845
+ //
16846
+ // The same guard as `injectDeferralCoverage`: a gap synthesis already weighed in is not added twice, and
16847
+ // a findings.json that fails its own schema after the push is left as it was.
16848
+ // THE WORDS ARE CHOSEN, NOT THE CAUSE: of the approved reasons, "nothing in the run's own record confirms it
16849
+ // was searched" is true of a search that never came back AND of one the provider refused, and does not
16850
+ // repeat the row's own "not completed this run" the way the generic fallback does. It borrows that arm's
16851
+ // sentence only; if the arm is ever reworded for its own case, re-read this row against it.
16852
+ const MEANING_SEARCH_UNFINISHED = "not-verified-closed";
16853
+ export function injectMeaningGapCoverage(P, runDir, note) { // @internal — exported so the refusal shape is testable without a run
16854
+ try {
16855
+ if (!existsSync(P.commonLawGrid) || !existsSync(P.findings)) return;
16856
+ const grid = JSON.parse(readFileSync(P.commonLawGrid, "utf8"));
16857
+ const terms = [...new Set((Array.isArray(grid?.gaps) ? grid.gaps : [])
16858
+ .filter((g) => String(g?.platform ?? "").toLowerCase() === "connotation")
16859
+ .map((g) => String(g?.term ?? "").trim()).filter(Boolean))];
16860
+ if (!terms.length) return;
16861
+ const doc = JSON.parse(readFileSync(P.findings, "utf8"));
16862
+ if (!Array.isArray(doc.coverage)) doc.coverage = [];
16863
+ const normTxt = (s) => String(s ?? "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
16864
+ const covText = normTxt(doc.coverage.map((c) => `${c.area} ${c.note ?? ""}`).join(" "));
16865
+ let added = 0;
16866
+ for (const term of terms) {
16867
+ const slice = normTxt(term).split(" ").filter((w) => w.length >= 4).slice(0, 4).join(" ");
16868
+ if (slice && covText.includes(slice)) continue; // synthesis already weighed it in
16869
+ doc.coverage.push(deferralCoverageRow(term, MEANING_SEARCH_UNFINISHED));
16870
+ added++;
16871
+ }
16872
+ if (!added) return;
16873
+ parseFindingsJson(JSON.stringify(doc)); // re-validate the shape (throws → catch keeps original)
16874
+ atomicWrite(P.findings, `${JSON.stringify(doc, null, 2)}\n`);
16875
+ note(`[common-law] ${added} meaning search(es) that did not complete now reach the report's coverage`);
16876
+ runLog(runDir, { event: "meaning-gap-coverage", added });
16877
+ } catch (e) {
16878
+ note(`[common-law] meaning-gap coverage skipped: ${String(e?.message || e).replace(/\s+/g, " ").slice(0, 100)}`);
16879
+ }
16880
+ }
@@ -422,10 +422,20 @@ export function firstRunCredentialLines({ handoff, credentialPath, email, passph
422
422
  return [head, ` PASSPHRASE: ${passphrase}`,
423
423
  " Write it down now. It is not stored anywhere in a form that can be read back, and this line will not be printed again."];
424
424
  }
425
- return [head,
426
- " The passphrase is NOT printed here: stderr is not a terminal, so this line would outlive the moment \u2014 a journal, a CI log, or a test's captured output.",
427
- " Nothing holds it now, this process included: what is on disk is a digest. To get one you can sign in with, run:",
428
- ` ${resetCommand}`];
425
+ return [head, ...passphraseWithheldLines({ stream: "stderr", resetCommand }).map((line) => ` ${line}`)];
426
+ }
427
+
428
+ /**
429
+ * What stands where the passphrase would, on an output that is not a terminal. ONE composer for the two
430
+ * places a first run can hand the passphrase over \u2014 the portal's standard error and the launcher's closing
431
+ * box on standard output \u2014 so they give one reason and one way back in. It is never handed the
432
+ * passphrase, so no caller can wire the value through it.
433
+ */
434
+ export function passphraseWithheldLines({ stream, resetCommand = "" }) {
435
+ return [
436
+ `The passphrase is NOT printed here: ${stream} is not a terminal, so this line would outlive the moment \u2014 a journal, a CI log, or a test's captured output.`,
437
+ "Nothing holds it now, this process included: what is on disk is a digest. To get one you can sign in with, run:",
438
+ ` ${resetCommand}`];
429
439
  }
430
440
 
431
441
  export const SESSION_DOMAIN = "portal-session.v1|";
@@ -968,6 +968,15 @@ const DENIAL_REASON = Object.freeze({
968
968
  429: "rate limited",
969
969
  });
970
970
 
971
+ /**
972
+ * WHAT A REFUSED CLIENT IS TOLD — a closed set, keyed on the status, never the error's message. The message
973
+ * can come out of a third-party token check with a claim value, an address or a fragment of the rejected
974
+ * token in it, and some of our own refusals put the caller's address in theirs. A 401 keeps the words the
975
+ * browser contract already decodes ("not signed in"); every other status says the journal's code-owned
976
+ * reason. The message itself is still journalled nowhere a client reads. PURE.
977
+ */
978
+ export const refusalWords = (status) => (status === 401 ? "not signed in" : DENIAL_REASON[status] ?? "refused at the door");
979
+
971
980
  /**
972
981
  * — THE ROW A REFUSAL FILES. One shape, one sink, whichever side of `route` decided the answer.
973
982
  *
@@ -4399,7 +4408,7 @@ export function makeHttpHandler({ verify, limiter, service, log = () => {}, devI
4399
4408
  // row must not become a place a caller can write into by sending a body that fails to parse.
4400
4409
  let body;
4401
4410
  try { body = await readJsonBody(req); }
4402
- catch (e) { journal(400, "unreadable body", identity?.email); return send(res, 400, { error: String(e.message) }); }
4411
+ catch { journal(400, "unreadable body", identity?.email); return send(res, 400, { error: "unreadable body" }); } // the parser's own message describes the bytes it was sent, and a response is not where a parse error is read back
4403
4412
  const query = Object.fromEntries(url.searchParams.entries());
4404
4413
  const r = await service.route(req.method, url.pathname, identity, body, query);
4405
4414
  // A PLAIN DOCUMENT this server renders itself. Escaped into a `<pre>`, so nothing in the file can
@@ -4466,11 +4475,11 @@ export function makeHttpHandler({ verify, limiter, service, log = () => {}, devI
4466
4475
  // person who typed the address gets a door.
4467
4476
  const wantsHtml = String(req.headers.accept ?? "").includes("text/html");
4468
4477
  if (wantsHtml) {
4469
- const html = denialPage(e.status, e.message);
4478
+ const html = denialPage(e.status, refusalWords(e.status));
4470
4479
  res.writeHead(e.status, { "content-type": "text/html; charset=utf-8", "content-length": Buffer.byteLength(html), "x-content-type-options": "nosniff" });
4471
4480
  return res.end(html);
4472
4481
  }
4473
- return send(res, e.status, { error: e.message });
4482
+ return send(res, e.status, { error: refusalWords(e.status) });
4474
4483
  }
4475
4484
  log(`500 ${String(e?.message || e)}`);
4476
4485
  if (!alreadyFiled(e)) journal(500, e?.message ?? String(e), identity?.email);
@@ -0,0 +1,36 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
3
+ // attr.mjs — a value placed inside a quoted HTML attribute, and a URL placed inside an href.
4
+ //
5
+ // Every renderer here has its own `esc`, and each escapes `&`, `<` and `>` — which is right for text and
6
+ // wrong inside an attribute: a `"` in the value closes the attribute and whatever follows is markup. So a
7
+ // value that sits between quotes goes through `attrValue`, which also encodes both quote characters.
8
+ //
9
+ // An href needs one more guard, because a correctly escaped `javascript:` URL is still a link that runs
10
+ // code when clicked, and the same for `data:` and `vbscript:`. So an href is built by `hrefAttr` or not at
11
+ // all: http(s) only — or, when the caller says so, a reference with no scheme (the audit download is a file
12
+ // beside the report). A browser drops tabs, newlines and other control characters from a URL before it
13
+ // reads the scheme, so the scheme is read with those removed: `java` + newline + `script:` is `javascript:`.
14
+
15
+ // Every C0 control character, space and DEL — what a URL parser strips or ignores before the scheme.
16
+ const IGNORED_BEFORE_SCHEME = /[\x00-\x20\x7f]/g;
17
+
18
+ /** A value safe between the quotes of an HTML attribute: & < > " and ' all encoded. PURE. */
19
+ export const attrValue = (s) => String(s ?? "")
20
+ .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
21
+ .replace(/"/g, "&quot;").replace(/'/g, "&#39;");
22
+
23
+ /**
24
+ * An href value, attribute-safe, or null when it must not be a link — the caller then renders its text
25
+ * with no anchor. `relative: true` also admits a reference with no scheme, never a protocol-relative one
26
+ * (`//host/…` is an external address by another spelling). PURE.
27
+ */
28
+ export function hrefAttr(u, { relative = false } = {}) {
29
+ const s = String(u ?? "").trim();
30
+ if (!s) return null;
31
+ const probe = s.replace(IGNORED_BEFORE_SCHEME, "");
32
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(probe)?.[1];
33
+ if (scheme) { if (!/^https?$/i.test(scheme)) return null; }
34
+ else if (!relative || probe.startsWith("//") || probe.startsWith("\\\\")) return null;
35
+ return attrValue(s);
36
+ }
@@ -239,25 +239,25 @@ function indexRows(runs, { reportFile, linkPrefix = '', showAudit = true, client
239
239
  // anchors are neutralised; the client column → aliased. Keyed by customerKey + runId so a demo run is
240
240
  // exempt and shows real.
241
241
  const ck = r.customerKey || '';
242
- const runAttrs = `data-anon-href data-anon-key="${esc(ck)}" data-anon-run="${esc(r.runId)}"`;
242
+ const runAttrs = `data-anon-href data-anon-key="${attrValue(ck)}" data-anon-run="${attrValue(r.runId)}"`;
243
243
  const mark = anonMark(r.matter, { key: ck, run: r.runId });
244
- const matterCell = `<a ${runAttrs} href="${linkPrefix}${esc(r.runId)}/${reportFile}">${mark}</a>`;
244
+ const matterCell = `<a ${runAttrs} href="${linkPrefix}${attrValue(r.runId)}/${reportFile}">${mark}</a>`;
245
245
  // Run cell: staff surfaces show date · HH:mm (Zurich, same-day issuedAt only) · codename; the
246
246
  // customer-facing per-customer index shows the date ONLY — codenames and timestamps are internal.
247
247
  const t = client ? '' : issuedTime(r);
248
248
  const runCell = client ? esc(r.date) : `${esc(r.date)}${t ? ` · ${esc(t)}` : ''} · <code>${esc(r.codename || '')}</code>`;
249
- return ` <tr data-client="${esc(ck)}">
249
+ return ` <tr data-client="${attrValue(ck)}">
250
250
  <td>${matterCell}</td>
251
251
  <td>${anonMark(r.title, { key: ck, run: r.runId })}</td>
252
252
  <td>${anonClient(r.client, ck)}</td>
253
- <td><span class="b b-${esc(r.badge)}">${esc(r.overall)}</span>${qcFailed ? ' <span class="hold" title="machine QC checks failed — see the audit workbook">⚠ QC</span>' : ''}${!client && !qcFailed && r.clientGate?.inputsAbsent?.length ? ` <span class="disc" title="${escAttr(`published without ${r.clientGate.inputsAbsent.join(', ')} — each declared optional, so the release stands; the report was built without it`)}">◦ built without an input</span>` : ''}${
253
+ <td><span class="b b-${attrValue(r.badge)}">${esc(r.overall)}</span>${qcFailed ? ' <span class="hold" title="machine QC checks failed — see the audit workbook">⚠ QC</span>' : ''}${!client && !qcFailed && r.clientGate?.inputsAbsent?.length ? ` <span class="disc" title="${escAttr(`published without ${r.clientGate.inputsAbsent.join(', ')} — each declared optional, so the release stands; the report was built without it`)}">◦ built without an input</span>` : ''}${
254
254
  // spec 64 — the stance clause of THE one risk statement beside the (labelled) band pill, so the
255
255
  // index can never show a bare severity word that reads as the whole answer. The tier word leads
256
256
  // the statement; the pill already shows it, so the cell carries the clause after the first " — ".
257
257
  // Legacy meta.json (no statement) renders this cell byte-identically.
258
258
  r.statement ? `<span class="stmt" title="${escAttr(r.statement)}">${esc(String(r.statement).split(' — ').slice(1).join(' — ') || r.statement)}</span>` : ''}</td>
259
259
  <td>${runCell}</td>${showAudit ? `
260
- <td>${r.auditFile ? `<a ${runAttrs} href="${linkPrefix}${esc(r.runId)}/${esc(r.auditFile)}">audit.xlsx</a>` : '—'}</td>` : ''}
260
+ <td>${r.auditFile ? `<a ${runAttrs} href="${linkPrefix}${attrValue(r.runId)}/${attrValue(r.auditFile)}">audit.xlsx</a>` : '—'}</td>` : ''}
261
261
  </tr>`;
262
262
  }).join('\n');
263
263
  }
@@ -275,7 +275,7 @@ function clientFilterBar(clients) {
275
275
  if (!clients || clients.length < 2) return '';
276
276
  // data-anon="client" goes on the <option> itself (a span inside <option> is invalid) so the demo overlay
277
277
  // aliases the visible label in privacy mode; the value stays the raw customerKey that data-client matches.
278
- const opts = clients.map(c => `<option value="${esc(c.key)}" data-anon="client" data-anon-key="${esc(c.key)}">${esc(c.label)}</option>`).join('');
278
+ const opts = clients.map(c => `<option value="${attrValue(c.key)}" data-anon="client" data-anon-key="${attrValue(c.key)}">${esc(c.label)}</option>`).join('');
279
279
  return `<div class="filterbar"><label class="flbl" for="clientFilter">Client</label>` +
280
280
  `<select id="clientFilter"><option value="">All clients</option>${opts}</select></div>`;
281
281
  }
@@ -377,7 +377,7 @@ ${rows}
377
377
  // sidecar without importing this renderer. Imported as well as re-exported — `export ... from` re-exports
378
378
  // without binding the name in this module's scope, and regenIndex below calls readArchivedSet directly.
379
379
  export { ARCHIVE_TAGS_FILE, readArchivedSet, updateArchived } from './archive-tags.mjs';
380
- import { readArchivedSet } from './archive-tags.mjs';
380
+ import { readArchivedSet } from './archive-tags.mjs'; import { attrValue, hrefAttr } from './attr.mjs'; // a quoted attribute, and an href
381
381
 
382
382
  // Collapsible "Older / retired runs" fold for the STAFF index: a count in the summary (so viewers see how many
383
383
  // runs we've done) with the names hidden until expanded. Named distinctly from the "Clearance reports" tab so
@@ -1665,9 +1665,9 @@ export function composeEmailHtml(reportMdPath, url, auditFile, names = [], deliv
1665
1665
  // Heading-neutral match: accept the new "Reviewer's open questions" and the legacy "Open questions for the reviewer".
1666
1666
  const oq = (secs['Summary'] || '').match(/\*\*(?:Reviewer's open questions|Open questions for the reviewer)[\s\S]*?(?=\n\n[^*\d])/i);
1667
1667
  // The report link rides HIGH — right under the bottom line in the headline, not buried below the table.
1668
- const reportLink = url
1669
- ? `<p style="margin:0 0 10px"><a href="${esc(url)}" style="color:#1a4fd6;font-weight:bold;font-size:12pt;text-decoration:none">▶ Open the full report</a>`
1670
- + (auditUrl ? ` &nbsp;·&nbsp; <a href="${esc(auditUrl)}" style="color:#3b4fd6;text-decoration:none">Download audit (Excel)</a>` : '')
1668
+ const reportLink = hrefAttr(url)
1669
+ ? `<p style="margin:0 0 10px"><a href="${hrefAttr(url)}" style="color:#1a4fd6;font-weight:bold;font-size:12pt;text-decoration:none">▶ Open the full report</a>`
1670
+ + (hrefAttr(auditUrl) ? ` &nbsp;·&nbsp; <a href="${hrefAttr(auditUrl)}" style="color:#3b4fd6;text-decoration:none">Download audit (Excel)</a>` : '')
1671
1671
  + `</p>`
1672
1672
  : '';
1673
1673
  // The search-scope signal leads the email. The 2026-06-12 "lint flags do not render on the email" rule
@@ -99,7 +99,7 @@ export function stripInternal(s, { client = false } = {}) {
99
99
  const out = [];
100
100
  for (let line of lines) {
101
101
  // normalize wrapped markers ("**::p:: x**", "*::p:: x*") so the marker is match-stable
102
- line = line.replace(/(\*{1,2})\s*::p::\s*([\s\S]*?)\1/g, '::p:: $2');
102
+ line = line.replace(/(\*{1,2})\s*::p::([\s\S]*?)\1/g, (_, _star, x) => `::p:: ${x.replace(/^\s+/, '')}`); // no `\s*` before the capture: overlapping it with the lazy body backtracked quadratically on an unclosed marker
103
103
  if (!line.includes('::p::')) { out.push(line); continue; }
104
104
  if (client) {
105
105
  const head = line.slice(0, line.indexOf('::p::')).replace(/[\s*_]+$/, '').trim();
@@ -25,7 +25,7 @@ import { fileURLToPath } from 'node:url';
25
25
  // its own split, which is how the two rules diverged. It calls stripTelemetry now, so the renderer holds
26
26
  // no copy of the RULE either, only a call to it.
27
27
  import { parseReport, stripInternal, stripTelemetry } from './parse.mjs';
28
- import { clientConditions } from '../terminal-clamp.mjs';
28
+ import { clientConditions } from '../terminal-clamp.mjs'; import { hrefAttr } from './attr.mjs'; // an href is attribute-safe and http(s), or not a link
29
29
  import { EXPORT_TOGGLE, exportPopover, EXPORT_MENU_JS } from './report-topbar.mjs'; // the export menu's shell and behaviour, shared with the knockout template // the reader's clause per condition, shared with the cover note
30
30
  import { COMMON_LAW, normRegion, regionName, REGION_NAMES } from './regions.mjs';
31
31
  import { parseFindingsJson, bindRecommendation, sentenceCaseLead, CLIENT_TIER_BY_COMPOSITE, bandOf, compareBlockingPower, inDispositionMode, reasonedNegativeGroups } from '../findings-model.mjs';
@@ -995,7 +995,7 @@ const NAMES_CAP = 8;
995
995
  function clearedGroupsHtml(searchDepth, auditFile) {
996
996
  const reg = (searchDepth && searchDepth.cleared && searchDepth.cleared.register) || [];
997
997
  if (!reg.length) return '';
998
- const link = (n) => auditFile ? `<div class="cmore"><a class="wb" href="${escAttr(auditFile)}">All ${n.toLocaleString('en-GB')} in the audit workbook</a></div>` : '';
998
+ const link = (n) => hrefAttr(auditFile, { relative: true }) ? `<div class="cmore"><a class="wb" href="${hrefAttr(auditFile, { relative: true })}">All ${n.toLocaleString('en-GB')} in the audit workbook</a></div>` : '';
999
999
  return CLEARED_GROUP_ORDER.map((g) => {
1000
1000
  const items = reg.filter((c) => c.group === g);
1001
1001
  if (!items.length) return '';
@@ -1229,8 +1229,8 @@ function whatWasSearchedSection(opts, coverage = [], findings = [], recordsByUri
1229
1229
  : '';
1230
1230
  // THE BOARDS END THE SECTION WITH THE WORKBOOK: every search and its result are in the audit workbook,
1231
1231
  // which is where a reader who wants the record goes.
1232
- const more = opts?.auditFile
1233
- ? `<div class="cmore"><a class="wb" href="${escAttr(opts.auditFile)}">Every search and result, in the audit workbook</a></div>` : '';
1232
+ const more = hrefAttr(opts?.auditFile, { relative: true })
1233
+ ? `<div class="cmore"><a class="wb" href="${hrefAttr(opts.auditFile, { relative: true })}">Every search and result, in the audit workbook</a></div>` : '';
1234
1234
  if (!rows.length && !openHtml && !prov) return '';
1235
1235
  return `<div class="sec" id="searched"><span class="num"></span><h2>What was searched</h2></div>
1236
1236
  <details class="searched"><summary><span class="gname">Counts for this search</span></summary><div class="gbody">${
@@ -1631,7 +1631,7 @@ function fullDetail(f, card, recordsByUri = new Map()) {
1631
1631
  const regUri = (u, fb) => {
1632
1632
  const h = regHref(u);
1633
1633
  const label = esc(u || fb || 'registration');
1634
- if (h) return `<a href="${esc(h)}" target="_blank" rel="noopener noreferrer">${label}</a>`;
1634
+ if (hrefAttr(h)) return `<a href="${hrefAttr(h)}" target="_blank" rel="noopener noreferrer">${label}</a>`;
1635
1635
  const note = RECORD_CITATION ? NO_LINK_NOTE[RECORD_CITATION] : null;
1636
1636
  return note ? `${label}<span class="reg-nolink">${note}</span>` : label;
1637
1637
  };
@@ -1744,7 +1744,7 @@ function fullDetail(f, card, recordsByUri = new Map()) {
1744
1744
  const stat = status ? ` <i class="evstat">Evidence: ${esc(status)}</i>` : '';
1745
1745
  const urls = (String(src).match(/https?:\/\/[^\s,|]+/g) || []).map(u => u.replace(/[).,;:]+$/, ''));
1746
1746
  if (!urls.length) return `<li><b>${label}</b> ${esc(src)}${stat}</li>`;
1747
- const links = urls.map(u => `<a href="${esc(u)}" target="_blank" rel="noopener noreferrer">${esc(u.replace(/^https?:\/\//, '').slice(0, 48))}</a>`).join(' · ');
1747
+ const links = urls.map(u => (hrefAttr(u) ? `<a href="${hrefAttr(u)}" target="_blank" rel="noopener noreferrer">${esc(u.replace(/^https?:\/\//, '').slice(0, 48))}</a>` : esc(u))).join(' · ');
1748
1748
  const rest = String(src).replace(/https?:\/\/[^\s,|]+/g, ' ').replace(/[\s,|]+/g, ' ').trim().replace(/^[—–-]\s*/, '');
1749
1749
  return `<li><b>${label}</b> ${links}${rest ? ` — ${esc(rest)}` : ''}${stat}</li>`;
1750
1750
  };
@@ -1796,7 +1796,7 @@ function fullDetail(f, card, recordsByUri = new Map()) {
1796
1796
  // such line. The SOURCE LINK stays: it is the only address a reader has for the record on this card
1797
1797
  // until the workbook row lands beside it, and dropping both would take a fact away rather than a
1798
1798
  // label. With no link there is nothing left to say, so the row does not render at all.
1799
- const prov = link ? `<div class="prov"><a href="${esc(link)}" target="_blank" rel="noopener noreferrer">${esc(link.replace(/^https?:\/\//, '').slice(0, 48))}</a></div>` : '';
1799
+ const prov = hrefAttr(link) ? `<div class="prov"><a href="${hrefAttr(link)}" target="_blank" rel="noopener noreferrer">${esc(link.replace(/^https?:\/\//, '').slice(0, 48))}</a></div>` : '';
1800
1800
  // WP-receipts W4 — the code-owned senior-right line (Owner decision 2026-07-05: VERY SIMPLE CLEAR ENGLISH,
1801
1801
  // stated qualification, verdict untouched). Verified senior → nothing extra (the W2 receipt line on
1802
1802
  // the fetched leg is the proof). Unverified → the open item, plainly, where the finding lives.
@@ -2551,7 +2551,7 @@ export function renderHtml(parsed, findings = [], coverage = [], opts = {}) {
2551
2551
  const riskLabel = VERDICT_INFO ? (VERDICT_INFO.tier || TIER_STOP_PILL[i]) : RISK_STOPS[i].toUpperCase();
2552
2552
 
2553
2553
  // Excel/audit download — lives inside the topbar Export popover (portal-report strips the link at serve time for non-staff).
2554
- const excelBtn2 = !opts.auditFile ? '' : `<a class="util" href="${esc(opts.auditFile)}" download>⬇ Download full audit (Excel)</a>`;
2554
+ const excelBtn2 = !hrefAttr(opts.auditFile, { relative: true }) ? '' : `<a class="util" href="${hrefAttr(opts.auditFile, { relative: true })}" download>⬇ Download full audit (Excel)</a>`;
2555
2555
 
2556
2556
  const cardFor = f => matchCard(f, cards);
2557
2557
  const recordsByUri = opts.recordsByUri || new Map(); // Instance #6 — the run's _records/ set (publishReport loads it)
@@ -2854,7 +2854,7 @@ import { officeReasonSentences } from './office-record-links.mjs';
2854
2854
  function officeRecordCell(uri, regUri, note) {
2855
2855
  const l = RECORD_LINKS?.get(String(uri || '').toLowerCase());
2856
2856
  if (!l?.label) return regUri(uri);
2857
- if (l.href) return `<a href="${esc(l.href)}" target="_blank" rel="noopener noreferrer">${esc(l.label)}</a>`;
2857
+ if (hrefAttr(l.href)) return `<a href="${hrefAttr(l.href)}" target="_blank" rel="noopener noreferrer">${esc(l.label)}</a>`;
2858
2858
  return note ? `${esc(l.label)}<span class="reg-nolink">${note}</span>` : esc(l.label);
2859
2859
  }
2860
2860
 
@@ -706,7 +706,13 @@ export function validateAudit(wb, { findings = [], coverage = [], coverageJudgme
706
706
  for (const name of EXPECT) if (!sheets.includes(name)) v.push(`missing tab "${name}"`);
707
707
 
708
708
  // 3 — banned internal vocabulary anywhere a lawyer can see (tab name, header, or any cell).
709
- const scan = (txt, where) => { if (txt != null && BANNED.test(String(txt))) v.push(`banned vocab "${String(txt).match(BANNED)[0]}" in ${where}`); };
709
+ // A LINK IS A CITATION, NOT VOCABULARY. The sources a finding cites are URLs a lawyer clicks, and `https`
710
+ // inside one matched the list, so every published example workbook printed an advisory about its own
711
+ // links. The address is taken out before the scan; "HTTP 404" written as prose is still found.
712
+ const scan = (txt, where) => {
713
+ const words = txt == null ? null : String(txt).replace(/\bhttps?:\/\/[^\s<>"')\]]+/gi, " ");
714
+ if (words != null && BANNED.test(words)) v.push(`banned vocab "${words.match(BANNED)[0]}" in ${where}`);
715
+ };
710
716
  for (const name of sheets) scan(name, `tab name "${name}"`);
711
717
  for (const ws of wb.worksheets) {
712
718
  ws.eachRow((row, rn) => row.eachCell(cell => {
@@ -104,7 +104,7 @@ export function queueWatchVerdict({ queueDirs, watched, unitPath, unitError = nu
104
104
  message: `this deployment resolves NO queue directory at all, so nothing enqueued to it can ever be drained (${unitPath} watches ${w.length})` };
105
105
  }
106
106
 
107
- const norm = (p) => String(p ?? "").replace(/\/+$/, "");
107
+ const norm = (p) => { const s = String(p ?? ""); let e = s.length; while (e > 0 && s.charCodeAt(e - 1) === 47) e--; return s.slice(0, e); }; // trailing slashes, in one pass: `/\/+$/` retried every run of slashes that was not at the end
108
108
  const watchedSet = new Set(w.map(norm));
109
109
  const unwatched = q.map(norm).filter((d) => !watchedSet.has(d));
110
110
  const where = `${q.length} queue dir(s) resolved; ${unitPath} watches ${w.length}`;