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.
- package/.env.example +1 -1
- package/CONTRIBUTING.md +1 -0
- package/INSTALL.md +49 -46
- package/README.md +8 -7
- package/bin/clearotron.mjs +14 -0
- package/bin/connect.mjs +68 -3
- package/bin/key.mjs +6 -1
- package/bin/onboard.mjs +51 -14
- package/bin/passphrase.mjs +4 -2
- package/bin/start.mjs +43 -9
- package/build-info.json +2 -2
- package/docs/architecture/05-config-governance.md +1 -1
- package/driver/CHANGELOG.md +416 -388
- package/driver/contract-vocabulary.mjs +5 -5
- package/driver/engine/mcp/recording-server.mjs +21 -1
- package/driver/engine/mcp/supplemental.mjs +22 -5
- package/driver/knockout-next-step.mjs +72 -0
- package/driver/named-band.mjs +1 -1
- package/driver/package.json +1 -1
- package/driver/phase0.mjs +16 -7
- package/driver/pipeline-knockout.mjs +26 -0
- package/driver/pipeline.mjs +48 -1
- package/driver/portal-local-auth.mjs +14 -4
- package/driver/portal-report.mjs +21 -2
- package/driver/portal-service.mjs +20 -11
- package/driver/publish/attr.mjs +36 -0
- package/driver/publish/index.mjs +10 -10
- package/driver/publish/parse.mjs +1 -1
- package/driver/publish/render-knockout.mjs +14 -9
- package/driver/publish/render.mjs +9 -9
- package/driver/publish/xlsx.mjs +7 -1
- package/driver/queue-watch-verdict.mjs +1 -1
- package/driver/register-availability.mjs +1 -1
- package/driver/register-plan.mjs +81 -11
- package/driver/result-noun-fields.mjs +3 -1
- package/driver/stages-knockout.mjs +1 -1
- package/driver/suite-census.json +104 -38
- package/driver/unit-inventory.mjs +7 -7
- package/driver/verify-knockout.mjs +0 -27
- package/mcp-server/CHANGELOG.md +27 -19
- package/mcp-server/lib/audit-view.mjs +4 -4
- package/mcp-server/lib/trace.mjs +1 -1
- package/mcp-server/package.json +1 -1
- package/package.json +2 -2
- package/portal-ui/dist/assets/{index-CtvwLCti.css → index-7Lq-dXDV.css} +12 -9
- package/portal-ui/dist/assets/{index-DXSRxPV_.js → index-w8GFZftk.js} +110 -69
- package/portal-ui/dist/index.html +2 -2
- package/portal-ui/package.json +1 -1
- package/providers/jx/README.md +2 -2
- package/providers/jx-subclass/README.md +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/scripts/README.md +5 -10
- package/scripts/ask-ai-render-check.mjs +26 -1
- package/scripts/citation-line-check.mjs +2 -2
- package/scripts/dead-names.mjs +17 -16
- package/scripts/e2e.mjs +8 -8
- package/scripts/env-audit.mjs +6 -0
- package/scripts/generated-files-are-current.mjs +35 -4
- package/scripts/live-surface-check.mjs +17 -17
- package/scripts/release-code-scanning-check.mjs +114 -0
- package/scripts/release-visible-check.mjs +117 -0
- package/scripts/repo-writes.mjs +42 -0
- package/scripts/report-sections-render-check.mjs +245 -0
- package/scripts/report-theme-render-check.mjs +31 -3
- package/scripts/settings-render-check.mjs +10 -7
- package/scripts/test-run.mjs +63 -4
- package/shared/client-door.mjs +20 -0
- package/shared/invocation.mjs +2 -2
- package/shared/parent-watch.mjs +33 -0
- package/shared/register-selection.mjs +4 -1
- package/shared/root-doc-commands.mjs +10 -4
- package/shared/running-start.mjs +14 -3
- package/shared/scope.mjs +24 -5
package/driver/publish/index.mjs
CHANGED
|
@@ -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="${
|
|
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}${
|
|
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="${
|
|
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-${
|
|
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}${
|
|
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="${
|
|
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="${
|
|
1670
|
-
+ (auditUrl ? ` · <a href="${
|
|
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) ? ` · <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
|
package/driver/publish/parse.mjs
CHANGED
|
@@ -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
|
|
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
|
|
1518
|
-
//
|
|
1519
|
-
//
|
|
1520
|
-
//
|
|
1521
|
-
//
|
|
1522
|
-
//
|
|
1523
|
-
//
|
|
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 || !
|
|
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="${
|
|
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="${
|
|
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="${
|
|
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="${
|
|
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="${
|
|
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="${
|
|
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="${
|
|
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
|
|
package/driver/publish/xlsx.mjs
CHANGED
|
@@ -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
|
-
|
|
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 ?? "").
|
|
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:
|
|
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
|
//
|
package/driver/register-plan.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
1534
|
-
if (!
|
|
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
|
-
|
|
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
|
|
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.`,
|