clearotron 0.3.2-beta.0 → 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.
package/build-info.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "0bc01b5d6cda940a40dc2fb7da2ae5f83caf3f5b",
3
- "version": "0.3.2-beta.0"
2
+ "commit": "4a4575724492736d2333d3bf2faf8027578427f2",
3
+ "version": "0.3.2-beta.1"
4
4
  }
@@ -1,5 +1,12 @@
1
1
  # clearotron-driver
2
2
 
3
+ ## 0.3.2-beta.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 2a81abc: New: A report can now show how much was searched to reach its answer. It records the names read and cleared, the records read in each country, and the checks made. Countries where nothing was found are included.
8
+ - 2a81abc: Fixed: The conditions listed on a report are now written in plain legal English, matching the summary line above them. One condition could previously appear as an internal engine note with counts and identifiers in it.
9
+
3
10
  ## 0.3.2-beta.0
4
11
 
5
12
  ### Patch Changes
@@ -638,7 +638,7 @@ export const E3_BACKLOG = [
638
638
  where: "driver/stages.mjs:3470",
639
639
  surface: "stage-message",
640
640
  evidence: "EVERY \"Grounded profile\" section MUST start its body with the line \"- ord: <N>\" naming which finding it grounds (use the ordinal from this list; a profile that grounds no listed finding omits the line)",
641
- reparsedBy: "driver/publish/parse.mjs:339 parseCaseLawProfiles (\"the optional '- ord: <N>' first body line … gives an EXACT join\"); driver/findings-model.mjs:273 /^-\\s*ord:\\s*(\\d+)\\s*$/m; driver/publish/index.mjs:776",
641
+ reparsedBy: "driver/publish/parse.mjs:339 parseCaseLawProfiles (\"the optional '- ord: <N>' first body line … gives an EXACT join\"); driver/findings-model.mjs:273 /^-\\s*ord:\\s*(\\d+)\\s*$/m; driver/publish/index.mjs:778 runOrigins",
642
642
  removedByMove: "NOTHING ON THE #850 PLAN REMOVES THIS",
643
643
  },
644
644
  {
@@ -2,7 +2,7 @@
2
2
  "name": "clearotron-driver",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.3.2-beta.0",
5
+ "version": "0.3.2-beta.1",
6
6
  "license": "AGPL-3.0-only",
7
7
  "description": "Deterministic driver for the trademark clearance workflow: orchestration in code (fan-out, fan-in barrier, gating, retries); the model does judgment leaves only, through a reasoning CLI spawned per stage.",
8
8
  "engines": {
@@ -56,7 +56,7 @@
56
56
  "methodology",
57
57
  "handling_note"
58
58
  ],
59
- "why": "a caption-only call renders 149B, clears the 120-char floor, and ships a SECTION OF THE CLIENT'S ONE REPORT without the 'Checks we ran' bullets, the methodology or the handling note. Traced: report-overview.md is the head of report.md (assembleReportMd() in pipeline.mjs) which renders to the HTML (publish/index.mjs:648). The 'Only you can close these' register is NOT lost — it is code-built from findings.json under spec 64's 'code wins' ruling."
59
+ "why": "a caption-only call renders 149B, clears the 120-char floor, and ships a SECTION OF THE CLIENT'S ONE REPORT without the 'Checks we ran' bullets, the methodology or the handling note. Traced: report-overview.md is the head of report.md (assembleReportMd() in pipeline.mjs) which renders to the HTML (publish/index.mjs:649 publishReport). The 'Only you can close these' register is NOT lost — it is code-built from findings.json under spec 64's 'code wins' ruling."
60
60
  },
61
61
  {
62
62
  "tool": "record_prelim_variants",
@@ -10,7 +10,7 @@ import { readFileSync, existsSync, mkdirSync, writeFileSync, renameSync, copyFil
10
10
  import { createHash } from "node:crypto";
11
11
  import { join, dirname, basename, resolve } from "node:path"; // resolve: the resume line must work from any cwd
12
12
  import { driverDir, driverRel, ensureDriverDir } from "../shared/driver-dir.mjs"; // — one definition of where `_driver/` is
13
- import { terminalClampDecision, orderClausesForLede } from "./terminal-clamp.mjs"; // — deliver and clamp, never withhold
13
+ import { terminalClampDecision, orderClausesForLede, clientConditions } from "./terminal-clamp.mjs"; // — deliver and clamp, never withhold
14
14
  import { recordSpan } from "./attributed-span.mjs"; // — driver work the decomposition can attribute
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { runStage, correctionHint, gridLedgerNameFor, draftCarryEligible, toolWrittenArtifact, selectEngine } from "./gateway.mjs";
@@ -13006,7 +13006,7 @@ async function pipelineInner(job, opts = {}) {
13006
13006
  const statement = riskStatement({ tier: derived.tier, verdict, reasons: reasonsOut, clauses: orderedClauses,
13007
13007
  basis: isRegisterOnly(ctx.searchPolicy) ? "register-only" : null });
13008
13008
  const tmp = driverDir(run.runDir, "verdict.json.tmp");
13009
- writeFileSync(tmp, JSON.stringify({ ts: new Date().toISOString(), verdict, reasons: reasonsOut, kinds: kindsOut,
13009
+ writeFileSync(tmp, JSON.stringify({ ts: new Date().toISOString(), verdict, reasons: reasonsOut, clauses: orderedClauses, kinds: kindsOut,
13010
13010
  tier: derived.tier, badge: derived.badge, gaugeIndex: derived.gaugeIndex, maxComposite: derived.maxComposite,
13011
13011
  band: derived.band ?? null, statement, stance: verdictStance(verdict) }, null, 2));
13012
13012
  renameSync(tmp, driverDir(run.runDir, "verdict.json"));
@@ -14859,7 +14859,7 @@ async function pipelineInner(job, opts = {}) {
14859
14859
  let emailVerdictOpts = { productName: emailProductName ?? undefined };
14860
14860
  // SPREAD, never reassign: this used to replace the whole object, which would now drop productName
14861
14861
  // above on every run that has a verdict sidecar — i.e. on every healthy run, and on no test.
14862
- try { const v = JSON.parse(readFileSync(driverDir(run.runDir, "verdict.json"), "utf8")); emailVerdictOpts = { ...emailVerdictOpts, verdict: v.verdict, conditions: v.reasons, tier: v.tier, statement: v.statement ?? null }; } catch { /* legacy path — no row */ }
14862
+ try { const v = JSON.parse(readFileSync(driverDir(run.runDir, "verdict.json"), "utf8")); emailVerdictOpts = { ...emailVerdictOpts, verdict: v.verdict, conditions: clientConditions(v), tier: v.tier, statement: v.statement ?? null }; } catch { /* legacy path — no row */ }
14863
14863
  // wp50 — thread the findings too: the table overlay's rating cells are code-bound to the canonical
14864
14864
  // ratings (joinFindingToBlock), never the summary's own words.
14865
14865
  try { emailVerdictOpts.findings = parseFindingsJsonLenient(readFileSync(P.findings, "utf8"))?.findings ?? undefined; } catch { /* no findings — table falls back to the summary words */ }
@@ -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
@@ -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,
@@ -1643,7 +1649,7 @@
1643
1649
  },
1644
1650
  "coverage-is-disclosed-never-refused.test.mjs": {
1645
1651
  "tests": 10,
1646
- "asserts": 38,
1652
+ "asserts": 40,
1647
1653
  "skips": 0,
1648
1654
  "todos": 0
1649
1655
  },
@@ -5001,6 +5007,12 @@
5001
5007
  "skips": 1,
5002
5008
  "todos": 0
5003
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
+ },
5004
5016
  "the-report-carries-what-the-assessment-wrote.test.mjs": {
5005
5017
  "tests": 10,
5006
5018
  "asserts": 41,
@@ -5979,7 +5991,7 @@
5979
5991
  },
5980
5992
  "home.test.ts": {
5981
5993
  "tests": 56,
5982
- "asserts": 196,
5994
+ "asserts": 197,
5983
5995
  "skips": 0,
5984
5996
  "todos": 0
5985
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
+ }
@@ -1,5 +1,9 @@
1
1
  # trademark-artifacts-mcp
2
2
 
3
+ ## 0.3.2-beta.1
4
+
5
+ No changes in this release.
6
+
3
7
  ## 0.3.2-beta.0
4
8
 
5
9
  No changes in this release.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-artifacts-mcp",
3
- "version": "0.3.2-beta.0",
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.2-beta.0",
4
+ "version": "0.3.2-beta.1",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
7
7
  "type": "git",