clearotron 0.3.1 → 0.3.2-beta.1

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 (37) hide show
  1. package/CONTRIBUTING.md +30 -1
  2. package/README.md +1 -0
  3. package/build-info.json +2 -2
  4. package/docs/writing-rules.md +208 -0
  5. package/docs/writing-standard.md +85 -0
  6. package/driver/CHANGELOG.md +13 -0
  7. package/driver/contract-e3-backlog.mjs +4 -4
  8. package/driver/contract-vocabulary.mjs +15 -14
  9. package/driver/gateway.mjs +33 -17
  10. package/driver/package.json +1 -1
  11. package/driver/partial-payload-baseline.json +1 -1
  12. package/driver/pipeline.mjs +3 -3
  13. package/driver/portal-families.mjs +1 -1
  14. package/driver/predelivery-lint.mjs +23 -1
  15. package/driver/publish/index.mjs +24 -1
  16. package/driver/publish/render-knockout.mjs +6 -26
  17. package/driver/publish/render.mjs +25 -5
  18. package/driver/publish/report-data.mjs +2 -1
  19. package/driver/publish/search-depth.mjs +178 -0
  20. package/driver/suite-census.json +38 -2
  21. package/driver/terminal-clamp.mjs +41 -0
  22. package/driver/verify.mjs +22 -3
  23. package/mcp-server/CHANGELOG.md +8 -0
  24. package/mcp-server/package.json +1 -1
  25. package/package.json +1 -1
  26. package/portal-ui/dist/assets/{index-ChIQsMYp.js → index-BsbasHjM.js} +3350 -3288
  27. package/portal-ui/dist/assets/{index-DBIs21e4.css → index-DNQpLYZF.css} +17 -1
  28. package/portal-ui/dist/index.html +2 -2
  29. package/portal-ui/package.json +1 -1
  30. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  31. package/providers/oauth-mcp-bridge/package.json +1 -1
  32. package/scripts/freeze-example-run.mjs +27 -27
  33. package/scripts/mint-writing-standard-backlog.mjs +82 -0
  34. package/scripts/writing-standard-check.mjs +122 -0
  35. package/shared/says-something-new.mjs +62 -0
  36. package/shared/writing-standard-caveats.json +14 -0
  37. package/shared/writing-standard-classes.mjs +526 -0
@@ -9,7 +9,7 @@
9
9
  //
10
10
  // ── why this is a sidecar and not a field in meta.json ────────────────────────────────────────────────
11
11
  //
12
- // The same reason `archive-tags.json` is one, recorded at publish/index.mjs:338: "meta.json is rewritten
12
+ // The same reason `archive-tags.json` is one, recorded at publish/index.mjs:377 ARCHIVE_TAGS_FILE: "meta.json is rewritten
13
13
  // on every republish so a flag there would be lost." That is not hypothetical here — a `rerender-all`
14
14
  // pass over the pool is a live plan, and a family written into meta would be erased by the very operation
15
15
  // meant to bring old reports up to date. Curation state that a person entered by hand must outlive a
@@ -32,6 +32,7 @@ import { writeUpViolations, writeUpMessage } from "./narrative-write-ups.mjs";
32
32
  import { findRegistryArithmeticIssues, findRegistryViolations, splitBlocks } from "./registry-fidelity.mjs";
33
33
  import { CLIENT_TIER_BY_COMPOSITE, joinFindingToBlock, parseBlockOrd, worstLiveBand, NO_RATED_CONFLICTS, deriveActionConditions, isUnconditionalProceed, verdictStance, joinAskToAnswer, projectAssessmentField, POSITION_REQUIRED_DISPOSITIONS, OFF_FIELD_GROUNDS, FINDINGS_SCHEMA_VERSION, netChainMarkers, STATEMENT_CLAUSE_MAX } from "./findings-model.mjs";
34
34
  import { normalizeBand } from "./framework.mjs";
35
+ import { clientConditions, ENGINE_TOKEN_RE } from "./terminal-clamp.mjs"; // the reader's clause per condition, and the token shape it may never carry
35
36
  import { knockoutNoteView, REQUEST_NOTE_WORDS, REQUEST_SUBJECT_WORDS } from "./findings-model.mjs"; // one reader for where a note prints
36
37
  import { isEngineAppendedCaveat } from "./verify-knockout.mjs"; // ONE derivation for "the engine appended this caveat, not a seat"
37
38
 
@@ -1961,6 +1962,26 @@ export function verdictActionsCoherenceChecks({ actionsRegister, findings, verdi
1961
1962
  // the conditional FORM riskStatement composes ("<Tier> — conditional on: <facts>"), never a wording
1962
1963
  // regex. Legacy sidecars (no stance) get the tier + unconditional-proceed checks only — judged
1963
1964
  // against the wording THEIR era composed (the retired-phrase exemption below), never the new form.
1965
+ // ── THE CONDITIONS A CLIENT READS SPEAK A LAWYER'S NOUNS ────────────────────────────────────────────
1966
+ // The sidecar carries two texts per condition — a run-record `reason` that may name tokens, counts and
1967
+ // record ids, and the reader's `clause`. `clientConditions` is the one reader of that pair, so this
1968
+ // check asks the question over what a surface actually renders rather than over either array.
1969
+ //
1970
+ // IT FIRES ON A LEGACY SIDECAR, DELIBERATELY. A run recorded before clauses were persisted has none, so
1971
+ // every condition falls back to its run-record reason and a token-bearing one flags here. That is the
1972
+ // honest reading: the delivered page for that run does carry the token, and re-generating the run is
1973
+ // what clears it. A flag, never a withholding — the report ships and the operator sees where the gap is.
1974
+ export function clientConditionVoiceChecks({ verdictDoc }) {
1975
+ if (!verdictDoc) return [];
1976
+ const carrying = clientConditions(verdictDoc)
1977
+ .map((c) => ({ c, m: String(c).match(ENGINE_TOKEN_RE) }))
1978
+ .filter((x) => x.m);
1979
+ return [check("client-condition-voice", "verdict", "report", carrying.length === 0,
1980
+ carrying.length
1981
+ ? `${carrying.length} delivered condition${carrying.length === 1 ? "" : "s"} carr${carrying.length === 1 ? "ies" : "y"} an engine identifier, so the client's "conditional on:" list reads the run record rather than the reader's sentence: ${carrying.slice(0, 3).map((x) => `"${x.m[0]}"`).join(", ")}${carrying.length > 3 ? ` (+${carrying.length - 3} more)` : ""}. The clause exists at the clamp site; persist it and this list takes it. A sidecar written before clauses were persisted flags until the run is re-generated.`
1982
+ : "")];
1983
+ }
1984
+
1964
1985
  export function statementCoherenceChecks({ verdictDoc }) {
1965
1986
  if (!verdictDoc?.statement) return [];
1966
1987
  const st = String(verdictDoc.statement), tier = String(verdictDoc.tier ?? "");
@@ -2292,7 +2313,8 @@ export function runLint({ depth, commonLawGrid, matterContext, clientPartyName,
2292
2313
  if (findings && clientSummaryMd) checks.push(...clientTierChecks({ clientSummaryMd, findings, manifest })); // A2 (doc 50: band words via the frozen manifest)
2293
2314
  if (verdictDoc?.tier && reportMd) checks.push(...overallTierChecks({ reportMd, verdictDoc })); // wp50/wi2
2294
2315
  checks.push(...verdictActionsCoherenceChecks({ actionsRegister, findings, verdictDoc })); // spec 64
2295
- checks.push(...statementCoherenceChecks({ verdictDoc })); // spec 64
2316
+ checks.push(...statementCoherenceChecks({ verdictDoc }));
2317
+ checks.push(...clientConditionVoiceChecks({ verdictDoc })); // — the reader's clause, not the run record // spec 64
2296
2318
  checks.push(...conditionalTextCoherenceChecks({ reportMd, clientSummaryMd, verdictDoc })); // qw/verdict-text-coherence — CONDITIONAL badge vs clean-outcome prose
2297
2319
  checks.push(...prescriptionProseChecks({ reportMd, clientSummaryMd, findings, fourAnswers })); // PR-3 report voice — facts that condition, never advice; flag-only (P5: the findings-derived prose is a delivered surface too)
2298
2320
  checks.push(...onlyYouRegisterChecks({ actionsRegister, findings, reportMd })); // spec 64
@@ -16,6 +16,7 @@ import { buildAudit } from './xlsx.mjs';
16
16
  import { parseFindingsJson, parseFindingsJsonLenient, deriveDisplayVerdict, joinFindingToBlock, CLIENT_TIER_BY_COMPOSITE, projectCoverageJudgment } from '../findings-model.mjs';
17
17
  import { readStore, requiredAbsent, nonClosingAbsences } from './publish-inputs.mjs'; // — and why an absence did not close
18
18
  import { clearanceReportData } from './report-data.mjs';
19
+ import { searchDepthRecord } from './search-depth.mjs'; // how much was read to reach the answer, as counts and tokens
19
20
  import { parseFrameworkManifest } from '../framework.mjs';
20
21
  import { rollupTokens } from '../tokens.mjs';
21
22
  import { reportIdentityFor, productCoverageNote, isRegisterOnly } from '../search-policy.mjs';
@@ -1053,7 +1054,29 @@ export async function publishReport({ runId, codename, reportMd, auditMd, findin
1053
1054
  // worse demo than no toggle.
1054
1055
  const reportNav = siteNav(poolRoot, 'report', null, '../', { anon: false });
1055
1056
  // `demoData` is resolved above the report.md write — one answer, every surface.
1056
- writeRO('report.html', renderHtml(parsed, findings, coverage, { demoData, productName, depthNote, scopeBasis, auditFile: auditFile || undefined, runId, delivery: deliv, recordsByUri, contextNotes, coverageJudgment: coverageJudgmentDisplay, markAssessment, fourAnswers, homeHref: '../index.html', nav: reportNav, chromeHref: '../assets/chrome.css', issued, asOf, verdictInfo, framework, searchedJurisdictions, caseLawByOrdinal, caseLawNotice, enforcerSignals, recordOrigin, recordOrigins: runOrigins, recordCitation: runProviderConf?.recordCitation ?? null, recordLinks: officeLinks?.byUri ?? null, providerLabel, seniorRights, findingsSchemaVersion }));
1057
+ // ── HOW MUCH WAS READ TO REACH THE ANSWER ──────────────────────────────────────────────────────
1058
+ // Derived here rather than in a stage so a REPUBLISH of an archived run picks the fields up with no
1059
+ // re-run: every source below is something the run already wrote. Best-effort per source, in the house
1060
+ // pattern — a run with no grid or no case-law layer still gets its register counts, and the absent
1061
+ // ones report themselves as zero or `not-in-scope` rather than as a gap nobody can see.
1062
+ let searchDepth = null;
1063
+ try {
1064
+ const runBase = runDir ?? dirname(reportMd);
1065
+ const recDir = join(runBase, '_records');
1066
+ const rdText = (p2) => { try { return readFileSync(p2, 'utf8'); } catch { return ''; } };
1067
+ const rdJson = (p2) => { try { return JSON.parse(readFileSync(p2, 'utf8')); } catch { return null; } };
1068
+ searchDepth = searchDepthRecord({
1069
+ auditMd: (auditMd && existsSync(auditMd)) ? rdText(auditMd) : '',
1070
+ recordIndex: recordsByUri ?? {},
1071
+ recordFileNames: existsSync(recDir) ? readdirSync(recDir) : [],
1072
+ commonLawGrid: rdJson(join(runBase, 'common-law-grid.json')),
1073
+ caseLawText: rdText(join(dirname(reportMd), 'case-law-findings.md')),
1074
+ registerPlan: rdJson(driverDir(runBase, 'register-plan.json')),
1075
+ });
1076
+ writeRO('search-depth.json', JSON.stringify(searchDepth, null, 2));
1077
+ } catch { /* the depth record is additive — a publish never fails for want of it */ }
1078
+
1079
+ writeRO('report.html', renderHtml(parsed, findings, coverage, { demoData, productName, depthNote, scopeBasis, auditFile: auditFile || undefined, runId, delivery: deliv, recordsByUri, contextNotes, coverageJudgment: coverageJudgmentDisplay, markAssessment, fourAnswers, homeHref: '../index.html', nav: reportNav, chromeHref: '../assets/chrome.css', issued, asOf, verdictInfo, framework, searchedJurisdictions, caseLawByOrdinal, caseLawNotice, enforcerSignals, recordOrigin, recordOrigins: runOrigins, recordCitation: runProviderConf?.recordCitation ?? null, recordLinks: officeLinks?.byUri ?? null, providerLabel, seniorRights, findingsSchemaVersion, searchDepth }));
1057
1080
  // ONE report (spec 2026-07-30 §5): report.client.html is no longer written. The knockout lane's own
1058
1081
  // collapse note is the precedent: "two renderings of one run is how the wrong link gets sent". The
1059
1082
  // client host serves the same report.html through the portal's readReport() (cleaning built in) — its
@@ -53,6 +53,7 @@ import { demoBannerHtml } from './render.mjs'; // — the SAME banner the clea
53
53
  // fail-open; re-deriving either in a renderer would be this file starting a second status vocabulary,
54
54
  // which is the thing providers/_shared/screen.mjs exists to prevent.
55
55
  import { makeClassifyStatus, isAllClass } from '../../providers/_shared/screen.mjs';
56
+ import { saysSomethingNew } from '../../shared/says-something-new.mjs';
56
57
 
57
58
  const HERE = dirname(fileURLToPath(import.meta.url));
58
59
 
@@ -1336,32 +1337,11 @@ const SCOPE_BLOCK_TEXT = 'What this is. A fast screen for obvious blockers to us
1336
1337
  + 'goes on to clearance. Every conflict above links to the material we found. The audit workbook holds '
1337
1338
  + 'every search run, every empty result and the working notes. Register data.';
1338
1339
 
1339
- /** Words that carry no claim, so their presence or absence says nothing about what a sentence asserts. */
1340
- const STOPWORDS = new Set(['a', 'an', 'and', 'are', 'as', 'at', 'be', 'been', 'but', 'by', 'can', 'do',
1341
- 'does', 'each', 'for', 'from', 'has', 'have', 'here', 'in', 'is', 'it', 'its', 'no', 'not', 'of', 'on',
1342
- 'or', 'that', 'the', 'their', 'them', 'there', 'these', 'they', 'this', 'to', 'up', 'was', 'we', 'were',
1343
- 'what', 'when', 'which', 'will', 'with', 'you', 'your']);
1344
-
1345
- const contentWords = (s) => String(s ?? '').toLowerCase().replace(/[^a-z0-9\s-]/g, ' ')
1346
- .split(/\s+/).filter((w) => w.length > 2 && !STOPWORDS.has(w));
1347
-
1348
- /**
1349
- * Does this line assert anything the reference text does not already assert?
1350
- *
1351
- * TRUE unless every content word in the line is already in the reference. An empty line has nothing to
1352
- * say and returns false; a line with one unfamiliar word is kept. Singular/plural is folded so that
1353
- * "conclusion" does not read as new beside "conclusions".
1354
- */
1355
- function saysSomethingNew(line, reference) {
1356
- // The stem must be IDEMPOTENT on the singular, or the fold does nothing: an earlier form stripped
1357
- // "es" and turned "gives" into "giv" while leaving "give" alone, so the two never matched and every
1358
- // caveat looked new. Strip one trailing "s" and nothing else.
1359
- const stem = (w) => w.replace(/ies$/, 'y').replace(/s$/, '');
1360
- const known = new Set(contentWords(reference).map(stem));
1361
- const words = contentWords(line);
1362
- if (!words.length) return false;
1363
- return words.some((w) => !known.has(stem(w)));
1364
- }
1340
+ // THE CAVEAT FILTER IS SHARED, NOT LOCAL. `saysSomethingNew` was defined here and is now in
1341
+ // `shared/says-something-new.mjs`, because the writing-standard check asks the identical question of a
1342
+ // page's lede against its title. Two definitions of one rule is one definition and one imitation of it.
1343
+ // The stopword set and the idempotent stem travel with it; the reference text below stays here, because
1344
+ // it is this page's own words and nothing else's.
1365
1345
 
1366
1346
  function readBlock(m, framework) {
1367
1347
  const factors = (m.factors ?? []).filter((s) => typeof s === 'string' && s.trim());
@@ -1661,6 +1661,8 @@ const COV_STATE = {
1661
1661
  * carries EVERY significant word of the directive it names — a near-match keeps both.
1662
1662
  */
1663
1663
  const FOLLOW_UP_PREFIX = 'Follow-up / ';
1664
+ // `<axis> / <what was swept>` — the shape unitLabel composes for a plan-derived coverage unit.
1665
+ const AXIS_LABELLED = /\s\/\s/;
1664
1666
  const COV_STOPWORDS = new Set(['the', 'a', 'an', 'as', 'for', 'of', 'in', 'on', 'and', 'or', 'to', 'is',
1665
1667
  'was', 'it', 'its', 'this', 'that', 'with', 'by', 'at', 'be', 'been', 'run', 'search', 'searched']);
1666
1668
  const covWords = (t) => new Set(String(t || '').toLowerCase().match(/[a-z0-9]+/g)?.filter((w) => !COV_STOPWORDS.has(w)) ?? []);
@@ -1689,13 +1691,31 @@ function dedupeFollowUps(coverage) {
1689
1691
  // it. That is exactly the failure this function's header calls the one worth avoiding, and the
1690
1692
  // header was right while the code was not.
1691
1693
  //
1692
- // WHAT THIS STILL CANNOT DO, said rather than implied: it is word containment, so two genuinely
1693
- // different OPEN searches sharing every word of a short directive would still collapse to one. The
1694
- // discriminator a reader would want is which search a row names, and the rows carry no identity to
1695
- // join on — that is why the issue's own wording is "name the same search" rather than a rule. The
1696
- // narrowing here is the strongest one the data supports, and it errs toward keeping both.
1694
+ // THE LIMITATION ABOVE STOPPED BEING HYPOTHETICAL, so it is a rule now rather than a paragraph.
1695
+ //
1696
+ // It read: "it is word containment, so two genuinely different OPEN searches sharing every word of a
1697
+ // short directive would still collapse to one." Measured on a delivered report — 33 coverage entries,
1698
+ // 31 rendered cells. Two open park rows with one- and two-word directives were erased by unrelated
1699
+ // rows that merely mentioned those words. The client read a coverage section that never named two
1700
+ // slices the run had deliberately disclosed, which is the failure this header calls the one worth
1701
+ // avoiding, reached by the route the header predicted.
1702
+ //
1703
+ // THE DISCRIMINATOR IS THE AXIS LABEL, and it is the identity the paragraph above said was missing.
1704
+ // A row whose area carries one — `<axis> / <what was swept>`, the shape `unitLabel` composes — is a
1705
+ // PLAN-DERIVED COVERAGE UNIT. It is not the model restating a deferred slice; it is a different unit
1706
+ // that happens to contain the same word. Only the model's own free-text row can BE a restatement,
1707
+ // and that is exactly the dolphin row this function was built for: "the English word DOLPHIN as a
1708
+ // dedicated exact search", no axis, no separator. So an axis-labelled row may no longer stand in for
1709
+ // a composed follow-up, however many words it shares.
1710
+ //
1711
+ // EVIDENCE, STATED ONE-SIDED BECAUSE IT IS. Every row containing either erased directive was
1712
+ // axis-labelled `register`, so the measured run supports the half that stops suppression. It cannot
1713
+ // support the other half: that run has no open row WITHOUT an axis label, so nothing in it exercises
1714
+ // "a free-text row still suppresses". The witness for that half is the dolphin incident alone, which
1715
+ // is a real delivered page but a single one — and the arm below is what keeps it honest.
1697
1716
  return !written.some((w) => {
1698
1717
  if (COV_STATE[w?.state]?.cls === 'ok') return false; // a searched-and-clean row reports the opposite
1718
+ if (AXIS_LABELLED.test(String(w?.area || ''))) return false; // a plan unit is not a restatement
1699
1719
  const theirs = covWords(w.area);
1700
1720
  return [...directive].every((word) => theirs.has(word));
1701
1721
  });
@@ -32,6 +32,7 @@
32
32
  // testable offline and a republished archived run reproduces its file deterministically.
33
33
  import { stripInternal, dropLabelledInternals, stripEngineInternals } from './parse.mjs';
34
34
  import { DISPOSITION_GROUP, deriveActionConditions, projectAssessmentField } from '../findings-model.mjs';
35
+ import { clientConditions } from '../terminal-clamp.mjs'; // the reader's clause per condition — one definition, shared with the email
35
36
 
36
37
  // The client scrub choke point: the three existing rules, in the order the client HTML applies them.
37
38
  // Structural values (uris, enum tokens, dates, numbers) do not route through here — they carry no prose.
@@ -101,7 +102,7 @@ export function clearanceReportData({
101
102
  badge: verdictInfo.badge ?? null,
102
103
  band: verdictInfo.band ?? null,
103
104
  statement: clientText(verdictInfo.statement),
104
- conditions: (Array.isArray(verdictInfo.reasons) ? verdictInfo.reasons : []).map(clientText).filter(Boolean),
105
+ conditions: clientConditions(verdictInfo).map(clientText).filter(Boolean),
105
106
  } : null,
106
107
  caption: clientText(caption),
107
108
  jurisdiction: clientText(jurisdiction),
@@ -0,0 +1,178 @@
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
+ //
4
+ // ── HOW MUCH WAS READ TO REACH THE ANSWER, AS MACHINE FIELDS ────────────────────────────────────────
5
+ //
6
+ // A deeper search reads more and finds no more, and until now the report could not say so: a run that
7
+ // read 1,455 register records and cleared 432 near-names delivered a page showing 13 findings and
8
+ // nothing of the rest. The negative evidence — the work that came back clean — existed only in the
9
+ // audit workbook, in the engine's own working voice.
10
+ //
11
+ // This module derives that body of negative evidence from artifacts the run has ALREADY written. It
12
+ // starts no search, asks no model and composes no sentence: every field here is a count, a token, or a
13
+ // fact copied from a record. That is the whole design constraint, and it is why this lives at publish
14
+ // time rather than in a stage — a republish of an archived run picks the fields up with no re-run.
15
+ //
16
+ // THE GROUP KEY IS THE PART THAT COULD HAVE GONE WRONG. Each cleared near-name is grouped by WHY it was
17
+ // cleared, and the honest source is the register providers' closed screening vocabulary, which every
18
+ // provider computes identically: `drop:dead` (a confidently dead status), `drop:out-of-class` (live but
19
+ // no in-scope-class overlap), `surface:in-scope-live` / `surface:all-class` (a real in-scope candidate),
20
+ // `deepfetch:ambiguous` (status unrecognised). A verdict and the record's own status are facts. The
21
+ // engine's `result` paragraph is not a fact about the record, it is prose about the reasoning, and
22
+ // classifying on it is how a report ends up asserting a reason the record does not support.
23
+ //
24
+ // SO THIS EMITS THREE GROUPS, NOT FOUR, AND THAT IS DELIBERATE. `dead-filing` and `different-goods` fall
25
+ // straight out of the vocabulary. Everything else is `other` — read, in scope, and cleared on judgment.
26
+ // A fourth group, "different word", cannot be derived from a screening verdict or a status: a name
27
+ // cleared because it reads as a different word was `surface:in-scope-live` like any other real
28
+ // candidate, and only the reasoning says otherwise. Splitting it would mean either reading that prose
29
+ // or inventing a similarity rule here, and a wrong group on a client page is worse than a coarse
30
+ // honest one. Recorded on the issue rather than guessed at.
31
+ //
32
+ // BREAK MATRIX:
33
+ // · a dead status groups as dead-filing → break: drop the status arm, arm 1 red
34
+ // · an out-of-class verdict groups as goods → break: map it to other, arm 2 red
35
+ // · an in-scope live name groups as other → break: classify on the result prose, arm 3 red
36
+ // · a per-country count names every country read → break: count only countries with a finding, arm 5 red
37
+ // · court decisions distinguishes four states → break: collapse none-found into not-checked, arm 6 red
38
+
39
+ /** The closed set a group key may take. The renderer's headings are keyed on these, never on prose. */
40
+ export const CLEARED_GROUPS = Object.freeze(["dead-filing", "different-goods", "other"]);
41
+
42
+ /** Statuses a register reports for a filing that is no longer live. Surfaced by the provider, never date-cut. */
43
+ const DEAD_STATUS_RE = /^(?:CANCELLED|CANCELED|EXPIRED|ABANDONED|WITHDRAWN|REFUSED|DEAD|LAPSED|INVALID|SURRENDERED)\b/i;
44
+
45
+ /**
46
+ * Why this near-name was cleared, from the two fields the engine records per name. PURE.
47
+ *
48
+ * @param {{screenVerdict?: string, status?: string}} a
49
+ * @returns {"dead-filing"|"different-goods"|"other"}
50
+ */
51
+ export function groupForCleared({ screenVerdict, status } = {}) {
52
+ const v = String(screenVerdict ?? "").trim().toLowerCase();
53
+ const s = String(status ?? "").trim();
54
+ if (v.startsWith("drop:dead") || DEAD_STATUS_RE.test(s)) return "dead-filing";
55
+ if (v.startsWith("drop:out-of-class")) return "different-goods";
56
+ return "other";
57
+ }
58
+
59
+ /** One `## NRn` block's `- key: value` lines. The audit is written by a deterministic builder, so this is a contract. */
60
+ const field = (block, key) => (block.match(new RegExp(`^- ${key}:\\s*(.*)$`, "m")) || [])[1]?.trim() ?? "";
61
+ const noteField = (notes, key) => (notes.match(new RegExp(`${key}=([^;]+)`)) || [])[1]?.trim() ?? "";
62
+
63
+ /**
64
+ * Every near-name the run read and cleared, register and web, as facts. PURE.
65
+ *
66
+ * The register rows carry no sentence: mark, owner, country, class, status, group and the record URI.
67
+ * The reasoning stays in the audit workbook, where the engine already wrote it — the owner ruled against
68
+ * a second client-facing sentence per name (2026-09-16), so this deliberately does not carry `result`.
69
+ *
70
+ * @param {string} auditMd the run's `audit.md`
71
+ * @param {Record<string, object>} recordIndex fetched records by URI, for the fuller mark and owner
72
+ */
73
+ export function clearedNames(auditMd, recordIndex = {}) {
74
+ const out = { register: [], web: [] };
75
+ for (const block of String(auditMd ?? "").split(/^## /m).slice(1)) {
76
+ const title = block.split("\n")[0].trim();
77
+ const layer = field(block, "source_layer");
78
+ if (/^NR\d+/.test(title) && /register/i.test(layer)) {
79
+ const notes = field(block, "notes");
80
+ const uri = (notes.match(/URI (\S+?);/) || [])[1] ?? "";
81
+ const screenVerdict = noteField(notes, "screen_verdict");
82
+ const status = noteField(notes, "status");
83
+ const classes = noteField(notes, "class");
84
+ const country = ((uri.match(/^\/mark\/([a-z]{2})\//) || [])[1] ?? "").toUpperCase();
85
+ const rec = recordIndex[uri] ?? {};
86
+ out.register.push({
87
+ term: field(block, "search_term"),
88
+ mark: rec.markText || rec.mark || field(block, "search_term"),
89
+ owner: rec.owner ?? "", country, classes: rec.classes || classes,
90
+ status: rec.statusText || status,
91
+ group: groupForCleared({ screenVerdict, status: rec.statusText || status }),
92
+ uri,
93
+ });
94
+ } else if (!/^NR\d+/.test(title) && /common-law/i.test(layer) && !/^\(none/i.test(title)) {
95
+ out.web.push({ title, url: field(block, "url"), type: field(block, "type") });
96
+ }
97
+ }
98
+ return out;
99
+ }
100
+
101
+ /**
102
+ * Register records read, per country, from the run's own `_records/` listing. PURE.
103
+ *
104
+ * EVERY COUNTRY THE RUN READ, including the ones that came back clean — those are the whole point. A
105
+ * count keyed off the findings would list only countries with a conflict, which is the gap this closes.
106
+ *
107
+ * @param {string[]} recordFileNames the `_records/` directory listing, named `<cc>-<id>.json`
108
+ */
109
+ export function recordsByCountry(recordFileNames = []) {
110
+ const out = {};
111
+ for (const name of recordFileNames) {
112
+ const cc = (String(name).match(/^([a-z]{2})-/i) || [])[1];
113
+ if (cc) out[cc.toUpperCase()] = (out[cc.toUpperCase()] ?? 0) + 1;
114
+ }
115
+ return out;
116
+ }
117
+
118
+ /** Marketplace, web, reputation and meaning checks, from the deterministic grid the tools wrote. PURE. */
119
+ export function sweepCounts(commonLawGrid, auditMd = "") {
120
+ const cells = Array.isArray(commonLawGrid?.cells) ? commonLawGrid.cells : [];
121
+ const counts = {
122
+ checks: cells.length,
123
+ platforms: new Set(cells.map((c) => c?.platform).filter(Boolean)).size,
124
+ spellings: new Set(cells.map((c) => c?.term).filter(Boolean)).size,
125
+ reputation: Array.isArray(commonLawGrid?.extras?.pr_risk) ? commonLawGrid.extras.pr_risk.length : 0,
126
+ };
127
+ // A run old enough to predate the grid has no machine record; its per-term log is the audit's own
128
+ // common-law rows. Not a fallback masking a defect — those runs have nothing else to read.
129
+ if (!counts.checks && auditMd) counts.checks = (String(auditMd).match(/^- source_layer: Common-law/gm) || []).length;
130
+ return counts;
131
+ }
132
+
133
+ /**
134
+ * What the court-decisions pass came back with, as one token. PURE.
135
+ *
136
+ * FOUR STATES, BECAUSE THREE OF THEM MEAN DIFFERENT THINGS TO A READER. "None found" is a result and
137
+ * "could not be checked" is a gap; collapsing them would let an unreachable source read as a clean
138
+ * negative, which is the one thing a clearance may never do. "Not in scope" is neither — the product
139
+ * offers the pass on a full country search only.
140
+ *
141
+ * @returns {"found"|"none-found"|"not-checked"|"not-in-scope"}
142
+ */
143
+ export function courtDecisionsState(caseLawText) {
144
+ const t = String(caseLawText ?? "");
145
+ if (!t.trim()) return "not-in-scope";
146
+ if (/source unreachable|could not be reached|CONNECTION_CLOSED|not reachable|quota/i.test(t)) return "not-checked";
147
+ if (/No on-point precedent found|none found|no decisions found/i.test(t)) return "none-found";
148
+ return "found";
149
+ }
150
+
151
+ /** Was the name searched in a non-Latin script? Read off the plan's own terms, never asserted. PURE. */
152
+ export function localScriptSearched(registerPlan) {
153
+ const entries = Array.isArray(registerPlan?.entries) ? registerPlan.entries : [];
154
+ return entries.some((e) => /[^\x00-\x7F]/.test(String(e?.term ?? "")));
155
+ }
156
+
157
+ /**
158
+ * The whole record, composed from what the run holds. PURE — every argument is already-written material.
159
+ *
160
+ * @returns {{schemaVersion: number, cleared: object, counts: object}}
161
+ */
162
+ export function searchDepthRecord({ auditMd = "", recordIndex = {}, recordFileNames = [], commonLawGrid = null, caseLawText = "", registerPlan = null } = {}) {
163
+ const cleared = clearedNames(auditMd, recordIndex);
164
+ const groups = {};
165
+ for (const key of CLEARED_GROUPS) groups[key] = 0;
166
+ for (const c of cleared.register) groups[c.group] += 1;
167
+ return {
168
+ schemaVersion: 1,
169
+ cleared: { register: cleared.register, web: cleared.web, groups },
170
+ counts: {
171
+ recordsByCountry: recordsByCountry(recordFileNames),
172
+ recordsRead: recordFileNames.length,
173
+ sweep: sweepCounts(commonLawGrid, auditMd),
174
+ localScriptSearched: localScriptSearched(registerPlan),
175
+ courtDecisions: courtDecisionsState(caseLawText),
176
+ },
177
+ };
178
+ }
@@ -141,6 +141,12 @@
141
141
  "skips": 0,
142
142
  "todos": 0
143
143
  },
144
+ "a-client-condition-never-carries-an-engine-token.test.mjs": {
145
+ "tests": 6,
146
+ "asserts": 10,
147
+ "skips": 0,
148
+ "todos": 0
149
+ },
144
150
  "a-codex-login-survives-its-own-refresh.test.mjs": {
145
151
  "tests": 4,
146
152
  "asserts": 18,
@@ -651,6 +657,12 @@
651
657
  "skips": 0,
652
658
  "todos": 0
653
659
  },
660
+ "a-refusal-with-no-near-neighbour-does-not-claim-the-search-never-ran.test.mjs": {
661
+ "tests": 4,
662
+ "asserts": 15,
663
+ "skips": 0,
664
+ "todos": 0
665
+ },
654
666
  "a-refused-mcp-call-is-not-a-call-nobody-made.test.mjs": {
655
667
  "tests": 7,
656
668
  "asserts": 19,
@@ -765,6 +777,12 @@
765
777
  "skips": 0,
766
778
  "todos": 0
767
779
  },
780
+ "a-short-named-open-slice-still-reaches-the-page.test.mjs": {
781
+ "tests": 5,
782
+ "asserts": 6,
783
+ "skips": 0,
784
+ "todos": 0
785
+ },
768
786
  "a-signal-immune-fixture-is-reaped-by-its-owner.test.mjs": {
769
787
  "tests": 10,
770
788
  "asserts": 20,
@@ -1631,7 +1649,7 @@
1631
1649
  },
1632
1650
  "coverage-is-disclosed-never-refused.test.mjs": {
1633
1651
  "tests": 10,
1634
- "asserts": 38,
1652
+ "asserts": 40,
1635
1653
  "skips": 0,
1636
1654
  "todos": 0
1637
1655
  },
@@ -4989,6 +5007,12 @@
4989
5007
  "skips": 1,
4990
5008
  "todos": 0
4991
5009
  },
5010
+ "the-report-can-say-how-much-was-read.test.mjs": {
5011
+ "tests": 8,
5012
+ "asserts": 23,
5013
+ "skips": 0,
5014
+ "todos": 0
5015
+ },
4992
5016
  "the-report-carries-what-the-assessment-wrote.test.mjs": {
4993
5017
  "tests": 10,
4994
5018
  "asserts": 41,
@@ -5139,6 +5163,18 @@
5139
5163
  "skips": 0,
5140
5164
  "todos": 0
5141
5165
  },
5166
+ "the-writing-standard-backlog-is-a-floor.test.mjs": {
5167
+ "tests": 3,
5168
+ "asserts": 8,
5169
+ "skips": 2,
5170
+ "todos": 0
5171
+ },
5172
+ "the-writing-standard-check-refuses-five-classes.test.mjs": {
5173
+ "tests": 14,
5174
+ "asserts": 53,
5175
+ "skips": 0,
5176
+ "todos": 0
5177
+ },
5142
5178
  "the-xcheck-cap-counts-queries.test.mjs": {
5143
5179
  "tests": 9,
5144
5180
  "asserts": 25,
@@ -5955,7 +5991,7 @@
5955
5991
  },
5956
5992
  "home.test.ts": {
5957
5993
  "tests": 56,
5958
- "asserts": 196,
5994
+ "asserts": 197,
5959
5995
  "skips": 0,
5960
5996
  "todos": 0
5961
5997
  },
@@ -106,3 +106,44 @@ export function orderClausesForLede(clauses, reasons, guardSet) {
106
106
  const ordered = [...aligned.filter((x) => !guardSet?.has?.(x.c)), ...aligned.filter((x) => guardSet?.has?.(x.c))];
107
107
  return { clauses: ordered.map((x) => x.c), reasons: ordered.map((x) => x.r) };
108
108
  }
109
+
110
+ /**
111
+ * THE CLIENT'S CONDITION LIST, from a verdict sidecar. PURE.
112
+ *
113
+ * TWO TEXTS, NEVER ONE — stated at the top of this module, and until now honoured at only one of the
114
+ * two ends. Every clamp site composes a run-record `reason` (token, counts, record ids) and a reader
115
+ * `clause` (the same fact in a lawyer's nouns), and `terminalClampDecision` refuses a clause that
116
+ * carries an engine identifier. The sidecar then persisted `reasons` alone and threw the clauses away,
117
+ * so every client surface had only the run-record text to render. A delivered report's conditions
118
+ * opened with `floor_duty_undischarged:4 of 430 floor row(s)…` while the SAME run's risk statement, one
119
+ * line above, read the clean clause: `riskStatement` already prefers `clauses`, so the page contradicted
120
+ * its own headline. This function is the other end of that rule.
121
+ *
122
+ * THE CLAUSE WINS WHERE THERE IS ONE, and the fallback is not politeness. Three machinery sites push
123
+ * the reason AS the clause ("machinery reasons ARE factual open-states"), so for those entries the two
124
+ * texts are one string and this returns it unchanged — correctly, because no second text exists to
125
+ * prefer. A legacy sidecar written before this change carries no `clauses` key at all and falls back
126
+ * entirely, which is what keeps archived runs republishable.
127
+ *
128
+ * ALIGNMENT IS BY INDEX AND THE REASONS ARE THE COUNT AUTHORITY. `orderClausesForLede` pairs the two
129
+ * arrays and reorders them together; a BLOCKING verdict then appends its grounds to the reasons alone,
130
+ * so `clauses` is legitimately SHORTER. Mapping over reasons and reaching for `clauses[i]` is what
131
+ * makes that safe — never `clauses.map`, which would silently drop the appended grounds.
132
+ *
133
+ * BREAK MATRIX:
134
+ * · a clause replaces its token-bearing reason → break: return reasons unchanged, arm 1 red
135
+ * · a clean reason with no clause survives → break: return "" for a missing clause, arm 2 red
136
+ * · a legacy sidecar still yields its conditions → break: require the clauses key, arm 3 red
137
+ * · clauses shorter than reasons loses nothing → break: map over clauses, arm 4 red
138
+ *
139
+ * @param {{reasons?: string[], clauses?: string[]}} sidecar the parsed `_driver/verdict.json`
140
+ * @returns {string[]} one condition per reason, in the sidecar's own order
141
+ */
142
+ export function clientConditions({ reasons, clauses } = {}) {
143
+ const rs = Array.isArray(reasons) ? reasons : [];
144
+ const cs = Array.isArray(clauses) ? clauses : [];
145
+ return rs.map((r, i) => {
146
+ const clause = typeof cs[i] === "string" ? cs[i].trim() : "";
147
+ return clause || String(r ?? "").trim();
148
+ }).filter(Boolean);
149
+ }
package/driver/verify.mjs CHANGED
@@ -368,14 +368,21 @@ function commonLawMeaningSeat(p, c) {
368
368
  const recordedQ = new Set(recordedRaw.map(queryKey));
369
369
  const dropped = dictated.filter((q) => !recordedQ.has(queryKey(q)));
370
370
  if (dropped.length) {
371
- // ── THE REFUSAL SAYS WHICH OF TWO FAULTS THIS IS, because they need opposite remedies ──────────
371
+ // ── THE REFUSAL SAYS WHAT IT CAN SEE, AND STOPS SHORT OF WHAT IT CANNOT ───────────────────────
372
372
  //
373
- // ABSENT: no recorded query resembles it, so the search was not run and the seat must run it.
374
373
  // UNMATCHED: something close IS recorded, so the search ran and the two spellings disagree beyond
375
374
  // what the key folds — a re-ordering, a translation, a truncation, a query the provider chose for
376
375
  // itself. Telling the seat to "re-run the missing query" in that case asks for the one thing that
377
376
  // cannot help, and that is what turned one attempt into four on a production clearance.
378
377
  //
378
+ // NO RESEMBLANCE: nothing recorded looks like it. This used to be reported as ABSENT — "the search
379
+ // was not run and the seat must run it" — and that is a claim the gate has no way to make. A query
380
+ // recorded under a translation, a transliteration, or the seat's own rewording resembles nothing and
381
+ // is not absent; the seat was then told to re-run a search that had already happened, which is the
382
+ // same loop, one wording-distance further out. The two states are genuinely indistinguishable from
383
+ // here and always will be: there is no identity to join on, which is why this comparison exists at
384
+ // all. So the label names the observation and the remedy carries BOTH repairs, cheap either way.
385
+ //
379
386
  // NO THRESHOLD DECIDES ANYTHING (owner's ruling). The nearest recorded query is shown so a person or
380
387
  // a seat can SEE the difference in one attempt; it never makes the gate pass. A similarity score
381
388
  // that could pass this gate would be a score that can hide a skipped query, which is what the gate
@@ -394,11 +401,23 @@ function commonLawMeaningSeat(p, c) {
394
401
  // every query. Below half the words in common, say nothing rather than point at a red herring.
395
402
  return bestScore >= 0.5 ? best : null;
396
403
  };
404
+ // THE SECOND LABEL SAYS WHAT THIS GATE KNOWS, WHICH IS LESS THAN IT USED TO CLAIM.
405
+ //
406
+ // It read `[absent from the ledger]`, and that is an assertion the gate cannot make. No near
407
+ // neighbour means no RECORDED query resembles this one — not that the search never ran. A query that
408
+ // ran and was recorded under a translation, a transliteration, a re-ordering, or the seat's own
409
+ // rewording clears no overlap threshold, and was then told to re-run a search that had already
410
+ // happened. That is the loop this whole gate was filed to break, narrowed but not closed: it needs a
411
+ // large wording difference now rather than a single apostrophe, and it is still reachable.
412
+ //
413
+ // The gate cannot tell the two apart and is not being asked to. There is no identity to join on —
414
+ // that is the entire reason the dictated-versus-recorded comparison exists. So the label states the
415
+ // observation, the remedy carries both cases, and no threshold decides which one a seat is told.
397
416
  const parts = dropped.slice(0, 3).map((q) => {
398
417
  const n = nearest(q);
399
418
  return n
400
419
  ? `${abbrev(q, 40)} [unmatched; nearest recorded: ${abbrev(n, 40)}]`
401
- : `${abbrev(q, 40)} [absent from the ledger]`;
420
+ : `${abbrev(q, 40)} [no recorded query resembles this one]`;
402
421
  });
403
422
  return fail(`connotation_query_unrecorded:${parts.join(",")}${dropped.length > 3 ? ` (+${dropped.length - 3} more)` : ""}`);
404
423
  }
@@ -1,5 +1,13 @@
1
1
  # trademark-artifacts-mcp
2
2
 
3
+ ## 0.3.2-beta.1
4
+
5
+ No changes in this release.
6
+
7
+ ## 0.3.2-beta.0
8
+
9
+ No changes in this release.
10
+
3
11
  ## 0.3.1
4
12
 
5
13
  ### Patch Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-artifacts-mcp",
3
- "version": "0.3.1",
3
+ "version": "0.3.2-beta.1",
4
4
  "license": "AGPL-3.0-only",
5
5
  "private": true,
6
6
  "description": "MCP server to interrogate clearotron trademark-clearance runs — list/read artifacts, trace the full decision flow, telemetry/cost, coverage, single-run search, and a gated single-step what-if. Imports the clearotron-driver read-only; touches no driver/template/deploy files.",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clearotron",
3
3
  "type": "module",
4
- "version": "0.3.1",
4
+ "version": "0.3.2-beta.1",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
7
7
  "type": "git",