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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/.env.example +1 -1
  2. package/CONTRIBUTING.md +1 -0
  3. package/INSTALL.md +49 -46
  4. package/README.md +8 -7
  5. package/bin/clearotron.mjs +14 -0
  6. package/bin/connect.mjs +68 -3
  7. package/bin/key.mjs +6 -1
  8. package/bin/onboard.mjs +51 -14
  9. package/bin/passphrase.mjs +4 -2
  10. package/bin/start.mjs +43 -9
  11. package/build-info.json +2 -2
  12. package/docs/architecture/05-config-governance.md +1 -1
  13. package/driver/CHANGELOG.md +416 -388
  14. package/driver/contract-vocabulary.mjs +5 -5
  15. package/driver/engine/mcp/recording-server.mjs +21 -1
  16. package/driver/engine/mcp/supplemental.mjs +22 -5
  17. package/driver/knockout-next-step.mjs +72 -0
  18. package/driver/named-band.mjs +1 -1
  19. package/driver/package.json +1 -1
  20. package/driver/phase0.mjs +16 -7
  21. package/driver/pipeline-knockout.mjs +26 -0
  22. package/driver/pipeline.mjs +48 -1
  23. package/driver/portal-local-auth.mjs +14 -4
  24. package/driver/portal-report.mjs +21 -2
  25. package/driver/portal-service.mjs +20 -11
  26. package/driver/publish/attr.mjs +36 -0
  27. package/driver/publish/index.mjs +10 -10
  28. package/driver/publish/parse.mjs +1 -1
  29. package/driver/publish/render-knockout.mjs +14 -9
  30. package/driver/publish/render.mjs +9 -9
  31. package/driver/publish/xlsx.mjs +7 -1
  32. package/driver/queue-watch-verdict.mjs +1 -1
  33. package/driver/register-availability.mjs +1 -1
  34. package/driver/register-plan.mjs +81 -11
  35. package/driver/result-noun-fields.mjs +3 -1
  36. package/driver/stages-knockout.mjs +1 -1
  37. package/driver/suite-census.json +104 -38
  38. package/driver/unit-inventory.mjs +7 -7
  39. package/driver/verify-knockout.mjs +0 -27
  40. package/mcp-server/CHANGELOG.md +27 -19
  41. package/mcp-server/lib/audit-view.mjs +4 -4
  42. package/mcp-server/lib/trace.mjs +1 -1
  43. package/mcp-server/package.json +1 -1
  44. package/package.json +2 -2
  45. package/portal-ui/dist/assets/{index-CtvwLCti.css → index-7Lq-dXDV.css} +12 -9
  46. package/portal-ui/dist/assets/{index-DXSRxPV_.js → index-w8GFZftk.js} +110 -69
  47. package/portal-ui/dist/index.html +2 -2
  48. package/portal-ui/package.json +1 -1
  49. package/providers/jx/README.md +2 -2
  50. package/providers/jx-subclass/README.md +1 -1
  51. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  52. package/providers/oauth-mcp-bridge/package.json +1 -1
  53. package/scripts/README.md +5 -10
  54. package/scripts/ask-ai-render-check.mjs +26 -1
  55. package/scripts/citation-line-check.mjs +2 -2
  56. package/scripts/dead-names.mjs +17 -16
  57. package/scripts/e2e.mjs +8 -8
  58. package/scripts/env-audit.mjs +6 -0
  59. package/scripts/generated-files-are-current.mjs +35 -4
  60. package/scripts/live-surface-check.mjs +17 -17
  61. package/scripts/release-code-scanning-check.mjs +114 -0
  62. package/scripts/release-visible-check.mjs +117 -0
  63. package/scripts/repo-writes.mjs +42 -0
  64. package/scripts/report-sections-render-check.mjs +245 -0
  65. package/scripts/report-theme-render-check.mjs +31 -3
  66. package/scripts/settings-render-check.mjs +10 -7
  67. package/scripts/test-run.mjs +63 -4
  68. package/shared/client-door.mjs +20 -0
  69. package/shared/invocation.mjs +2 -2
  70. package/shared/parent-watch.mjs +33 -0
  71. package/shared/register-selection.mjs +4 -1
  72. package/shared/root-doc-commands.mjs +10 -4
  73. package/shared/running-start.mjs +14 -3
  74. package/shared/scope.mjs +24 -5
@@ -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();
@@ -62,6 +62,7 @@ import { EXPORT_TOGGLE, exportPopover, EXPORT_MENU_JS } from './report-topbar.mj
62
62
  // which is the thing providers/_shared/screen.mjs exists to prevent.
63
63
  import { makeClassifyStatus, isAllClass } from '../../providers/_shared/screen.mjs';
64
64
  import { saysSomethingNew } from '../../shared/says-something-new.mjs';
65
+ import { isNextStepHeading } from '../knockout-next-step.mjs';
65
66
 
66
67
  const HERE = dirname(fileURLToPath(import.meta.url));
67
68
 
@@ -1513,21 +1514,25 @@ function koScale(framework, band) {
1513
1514
  // It MOVES, it is not copied: `splitOutcome` returns the assessment without it, and the fold renders
1514
1515
  // that remainder, so the words appear once.
1515
1516
  //
1517
+ // A RECORD WRITTEN SINCE 2026-09-19 NEVER REACHES THIS WITH ONE: the pipeline removes a next-step section
1518
+ // before it writes the record (`knockout-next-step.mjs`), and the heading test below is that module's, so
1519
+ // the two cannot come to disagree about what a next step is. What still draws the section is a record
1520
+ // written before then, re-rendered.
1521
+ //
1516
1522
  // THE HEADING IS THE MODEL'S OWN, so this hoists only the LAST block of the assessment. The assessing
1517
- // skill fixes no heading vocabulary — it asks for sub-headers "where the content divides" and for the
1518
- // read to close on what to do with the name, and the wording is written fresh each run. Matching a list
1519
- // of phrases anywhere in the document would let a mark whose read happens to argue under "Recommendation"
1520
- // in the MIDDLE have that middle lifted to the bottom, handing the client the engine's argument in an
1521
- // order the engine did not write, with nothing going red. A trailing block is the one position where a
1522
- // move cannot reorder what is left. So the phrase list only decides whether the last block is the
1523
- // outcome; anything earlier stays where it was written, and the page renders the assessment whole.
1524
- const OUTCOME_HEADING_RE = /^#{1,6}\s*(?:what to do with it|what happens next|next steps?|recommendations?)\b\s*$/i;
1523
+ // skill fixes no heading vocabulary — it asks for sub-headers "where the content divides", and the
1524
+ // wording is written fresh each run. Matching a list of phrases anywhere in the document would let a
1525
+ // mark whose read happens to argue under "Recommendation" in the MIDDLE have that middle lifted to the
1526
+ // bottom, handing the client the engine's argument in an order the engine did not write, with nothing
1527
+ // going red. A trailing block is the one position where a move cannot reorder what is left. So the
1528
+ // phrase list only decides whether the last block is the outcome; anything earlier stays where it was
1529
+ // written, and the page renders the assessment whole.
1525
1530
  export function splitOutcome(assessment) {
1526
1531
  const whole = String(assessment ?? '').trim();
1527
1532
  const lines = whole.split('\n');
1528
1533
  const heads = lines.map((l, i) => [l.trim(), i]).filter(([l]) => /^#{1,6}\s/.test(l));
1529
1534
  const last = heads[heads.length - 1];
1530
- if (!last || !OUTCOME_HEADING_RE.test(last[0])) return { body: whole, outcome: '' };
1535
+ if (!last || !isNextStepHeading(last[0])) return { body: whole, outcome: '' };
1531
1536
  const outcome = lines.slice(last[1] + 1).join('\n').trim();
1532
1537
  if (!outcome) return { body: whole, outcome: '' };
1533
1538
  return { body: lines.slice(0, last[1]).join('\n').trim(), outcome };
@@ -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}`;
@@ -41,7 +41,7 @@
41
41
  // CORRECTED 2026-08-11 — THIS PARAGRAPH USED TO SAY "one qid per office … which is the shape
42
42
  // joinPlanToBands already has", AND THAT WAS FALSE ABOUT THE COMPILER IT DOCUMENTS. There is no
43
43
  // per-office qid. compileRegisterPlan narrows ONE shared `regions` array and hands it to every entry
44
- // (register-plan.mjs:779 compileRegisterPlan); the unreachable office produces no entry, so no qid, so no band block, so
44
+ // (register-plan.mjs:805 compileRegisterPlan); the unreachable office produces no entry, so no qid, so no band block, so
45
45
  // nothing ever reaches joinPlanToBands' deferred bucket — whose only source is a block stamped
46
46
  // `error:true && deferred:true` (register-plan.mjs:1317 extendRegisterPlan).
47
47
  //
@@ -705,12 +705,7 @@ export function excludeHouseElement(manifest, house) {
705
705
 
706
706
  const lcEl = element.toLowerCase();
707
707
  const lcWords = words.map((w) => w.toLowerCase());
708
- // A word of the remainder, by word boundary rather than by containment: `includes` would count the
709
- // remainder word "on" inside an unrelated mutation and keep a query this exists to drop.
710
- const namesRemainder = (v) => {
711
- const t = String(v ?? "").toLowerCase();
712
- return lcWords.some((w) => new RegExp(`(^|[^\\p{L}\\p{N}])${w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([^\\p{L}\\p{N}]|$)`, "u").test(t));
713
- };
708
+ const namesRemainder = (v) => namesAnyWord(v, lcWords);
714
709
 
715
710
  // THE DOMINANT WORD OF THE REMAINDER: longest, ties broken by the LAST of them. The lawyer's own
716
711
  // phrasing was "the last word alone", and on the matter that produced this the two agree; longest is
@@ -738,11 +733,75 @@ export function excludeHouseElement(manifest, house) {
738
733
  // REQUIREMENT 3: the element is still checked ONCE, as a confirmation of the client's own live
739
734
  // registrations rather than as a conflict search — so the exclusion is evidenced on the report by a
740
735
  // query that ran, not by a sentence saying one would have.
736
+ // The remainder rides on it so a writer AFTER the compile can apply the same rule (`houseElementOf`).
741
737
  const confirmation = { axis: "incumbent-class", predicate: "owner", term: element,
742
- expected_kind: "enumerate", provenance: "mark", house_element_confirmation: true };
738
+ expected_kind: "enumerate", provenance: "mark", house_element_confirmation: true,
739
+ house_element_remainder: remainder };
743
740
  return { manifest: next, confirmation, refused: null };
744
741
  }
745
742
 
743
+ /**
744
+ * Does `text` carry any of `words` (lower-case) as a word of its own? By word boundary rather than by
745
+ * containment: `includes` would count the remainder word "on" inside an unrelated mutation.
746
+ */
747
+ function namesAnyWord(text, words) {
748
+ const t = String(text ?? "").toLowerCase();
749
+ return words.some((w) => new RegExp(`(^|[^\\p{L}\\p{N}])${w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([^\\p{L}\\p{N}]|$)`, "u").test(t));
750
+ }
751
+
752
+ /**
753
+ * The house element a plan was compiled without, read off the plan itself, or null.
754
+ *
755
+ * WHY THE PLAN CARRIES IT. The compile is not the only writer. After it, the common-law cross-check,
756
+ * the recall lane, the frame reopen and the jurisdiction candidates fold entries in, and the register
757
+ * session proposes its own. On the first live run that proposed an exclusion (2026-09-19) the compile
758
+ * dropped the element and two later writers put it straight back: a contains query from the cross-check
759
+ * and an exact query the register session proposed, both on the bare element. Those writers hold the
760
+ * plan and nothing else, so the plan is where the exclusion has to be readable. Its confirmation row
761
+ * names the element, and the remainder rides beside it because the rule needs both.
762
+ */
763
+ export function houseElementOf(plan) {
764
+ const c = (plan?.entries ?? []).find((e) => e?.house_element_confirmation === true);
765
+ const element = String(c?.term ?? "").trim();
766
+ if (!element) return null;
767
+ const remainder = String(c?.house_element_remainder ?? "").trim().toLowerCase();
768
+ return { element, remainderWords: remainder.split(/\s+/).filter(Boolean) };
769
+ }
770
+
771
+ /**
772
+ * Is this term a search on the excluded house element? The compile's own rule (`excludeHouseElement`
773
+ * keeps a variant only when it names a word of the remainder), applied to a term that arrives after
774
+ * the compile: a term carrying the element and no word of the remainder is a search on the element,
775
+ * whether it is the bare element, the element with a suffix, or the element beside other words. A term
776
+ * carrying a word of the remainder, like the whole mark, is kept, as the compile keeps it.
777
+ *
778
+ * A plan frozen before the remainder was recorded knows the element only, and then only the bare
779
+ * element counts: "carries no word of the remainder" cannot be decided without the remainder.
780
+ */
781
+ export function isHouseElementTerm(term, house) {
782
+ if (!house) return false;
783
+ const t = String(term ?? "").trim().toLowerCase();
784
+ const el = house.element.toLowerCase();
785
+ if (t === el || (norm(el) !== "" && norm(t) === norm(el) && formKey(t) === formKey(el))) return true;
786
+ if (!house.remainderWords.length) return false;
787
+ const carries = t.includes(el) || (norm(el) !== "" && norm(t).includes(norm(el)));
788
+ return carries && !namesAnyWord(t, house.remainderWords);
789
+ }
790
+
791
+ /**
792
+ * The entry without its house-element terms, or null when none is left. An owner sweep is not mark
793
+ * text and comes back untouched: the one owner query on the element is the confirmation itself. PURE.
794
+ */
795
+ export function withoutHouseElementTerms(entry, house) {
796
+ if (!house || !entry || entry.predicate === "owner") return entry;
797
+ if (Array.isArray(entry.terms)) {
798
+ const kept = entry.terms.filter((t) => !isHouseElementTerm(t, house));
799
+ if (!kept.length) return null;
800
+ return kept.length === entry.terms.length ? entry : { ...entry, terms: kept };
801
+ }
802
+ return isHouseElementTerm(entry.term, house) ? null : entry;
803
+ }
804
+
746
805
  export function compileRegisterPlan({ manifest, job, form = null, skillVersion = "", capabilities = null, unavailableOffices = [], houseElement = null }) {
747
806
  // ── AN EXCLUDED ELEMENT'S FORM BAND MUST BE UNREACHABLE, NOT MERELY UNASKED-FOR ─────────────────
748
807
  //
@@ -1495,10 +1554,13 @@ export function entryQuestionKey(entry, plan) {
1495
1554
  * an absence, and an absence reported as success is the failure this whole issue is about. Every
1496
1555
  * caller writes them somewhere a reader will meet them.
1497
1556
  *
1498
- * Returns { plan, added: [qid], refused: [{qid, term, issue}] }.
1557
+ * Returns { plan, added: [qid], refused: [{qid, term, issue}], excluded: [qid] } — `excluded` are rows on
1558
+ * the house element the compile set aside, left out quietly (see houseElementOf).
1499
1559
  */
1500
1560
  export function foldSupplementalEntries(plan, entries) {
1501
1561
  const have = new Set(plan.entries.map((e) => e.qid));
1562
+ const house = houseElementOf(plan);
1563
+ const excluded = [];
1502
1564
  // — first qid wins, so the refusal names the row already IN the plan rather than a later
1503
1565
  // arrival. The compiler's own rows are in here too: a supplemental re-proposal of a compiler slice
1504
1566
  // is the case that actually fired on the round in evidence.
@@ -1530,8 +1592,9 @@ export function foldSupplementalEntries(plan, entries) {
1530
1592
  // one identity, which is the fault. Keyed on the qid alone this refused the harmless case too, and
1531
1593
  // the suite's own arm said so.
1532
1594
  const inBatch = new Map(); // qid -> the term that claimed it
1533
- for (const e of entries ?? []) {
1534
- if (!e || typeof e.qid !== "string" || !e.qid) continue;
1595
+ for (const offered of entries ?? []) {
1596
+ if (!offered || typeof offered.qid !== "string" || !offered.qid) continue;
1597
+ let e = offered;
1535
1598
  const eTerm = String(e.term ?? e.terms?.[0] ?? "");
1536
1599
  if (inBatch.has(e.qid) && inBatch.get(e.qid) === eTerm) continue; // the same row twice — one refusal
1537
1600
  if (inBatch.has(e.qid)) {
@@ -1546,6 +1609,12 @@ export function foldSupplementalEntries(plan, entries) {
1546
1609
  inBatch.set(e.qid, eTerm);
1547
1610
  if (have.has(e.qid)) continue; // already in the PLAN — a re-proposal, expected and quiet
1548
1611
  have.add(e.qid); // a refused qid is claimed too — the same row twice is one refusal
1612
+ // THE CLIENT'S OWN ELEMENT, SET ASIDE BY THE COMPILE, IS NOT FOLDED BACK IN (see houseElementOf).
1613
+ // QUIET, NOT REFUSED: a refused row renders to the client as an OPEN ask, and this is no unanswered
1614
+ // question. The plan's confirmation row answers it and the report states the exclusion.
1615
+ const kept = withoutHouseElementTerms(e, house);
1616
+ if (!kept) { excluded.push(e.qid); continue; }
1617
+ e = kept;
1549
1618
  const issues = entryTermIssues(e);
1550
1619
  if (issues.length) {
1551
1620
  refused.push({ qid: e.qid, term: String(e.term ?? e.terms?.[0] ?? ""),
@@ -1570,11 +1639,12 @@ export function foldSupplementalEntries(plan, entries) {
1570
1639
  if (key && e.unsupported !== true) asked.set(key, e.qid); // dedupes the batch too, on the question
1571
1640
  added.push(e);
1572
1641
  }
1573
- if (!added.length) return { plan, added: [], refused };
1642
+ if (!added.length) return { plan, added: [], refused, excluded };
1574
1643
  return {
1575
1644
  plan: { ...plan, plan_version: (plan.plan_version ?? 1) + 1, entries: [...plan.entries, ...added] },
1576
1645
  added: added.map((e) => e.qid),
1577
1646
  refused,
1647
+ excluded,
1578
1648
  };
1579
1649
  }
1580
1650
 
@@ -95,7 +95,9 @@ export const RESULT_NOUN_FIELDS = Object.freeze([
95
95
  // writing site like the other two — it is a returned CLASSIFICATION of a checkout, not a field named
96
96
  // for a result whose value is what a call returned, so the verdict is unchanged.
97
97
  { file: "driver/engine-build.mjs", noun: "outcome", sites: 3, atWriteSite: 0, verdict: "out-of-scope" },
98
- { file: "driver/engine/mcp/supplemental.mjs", noun: "executed", sites: 2, atWriteSite: 0, verdict: "out-of-scope" },
98
+ // Read at the writing site: the third is the reply when every proposal was the house element the
99
+ // compile set aside — `executed: false` because nothing ran, the same statement as the error reply.
100
+ { file: "driver/engine/mcp/supplemental.mjs", noun: "executed", sites: 3, atWriteSite: 0, verdict: "out-of-scope" },
99
101
  { file: "driver/outbox-backoff.mjs", noun: "outcome", sites: 5, atWriteSite: 0, verdict: "out-of-scope" },
100
102
  { file: "driver/portal-service.mjs", noun: "outcome", sites: 2, atWriteSite: 0, verdict: "out-of-scope" },
101
103
  { file: "driver/remedy-accounting.mjs", noun: "executed", sites: 1, atWriteSite: 0, verdict: "out-of-scope" },
@@ -418,7 +418,7 @@ export const KO_STAGES = {
418
418
  // The depth rule is not stylistic. `# ` opens a SECTION of the delivered report — parseSections
419
419
  // splits on /^# /m and batchSummaryOf terminates on it — so a single hash written here ends the
420
420
  // client's summary at that line and drops everything after it, silently, on the entry point.
421
- `EVERY mark ALSO carries "assessment": the opening read a reader of THIS MARK'S OWN report meets first, before any table. Write it for a client who ordered this one name and nothing else: what the name is, what the landscape around it looks like, what drives the rating, and what a reader should do with that.`,
421
+ `EVERY mark ALSO carries "assessment": the opening read a reader of THIS MARK'S OWN report meets first, before any table. Write it for a client who ordered this one name and nothing else: what the name is, what the landscape around it looks like, what drives the rating.`,
422
422
  `GIVE IT STRUCTURE A READER CAN SCAN — that is what changed. Sub-headers where the content divides, "- " bullets for the load-bearing points, a blank line between blocks. A sub-header is "## " or "### ". NEVER "# ": one hash opens a section of the report itself, and a summary that opens a section ends there and takes the rest of itself off the client's page. A single sub-header over one unbroken block is not structure — the blocks are what make the length readable, and unstructured length is the complaint this replaces.`,
423
423
  `KEEP THE LENGTH. It is still the most useful part of a triage and it is still not rationed. What it now has to be is CONSISTENT: this mark's opening read is the scale of a single-mark report's opening read, and the marks of one batch sit within reach of each other. One assessment several times the size of its siblings is out of family. No word count and none is coming — judge it against the other marks in front of you.`,
424
424
  `SYNTHESISE THE SOURCES, DO NOT SHELVE THEM SEPARATELY. Where filings were handed to you for this mark, weigh them in the SAME passage as the marketplace and common-law reading — what the screened field shows, what those filings do to that reading, and what the two together mean for the band. A reader must not be handed one block per source and left to reconcile them.`,