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.
- package/CONTRIBUTING.md +30 -1
- package/README.md +1 -0
- package/build-info.json +2 -2
- package/docs/writing-rules.md +208 -0
- package/docs/writing-standard.md +85 -0
- package/driver/CHANGELOG.md +13 -0
- package/driver/contract-e3-backlog.mjs +4 -4
- package/driver/contract-vocabulary.mjs +15 -14
- package/driver/gateway.mjs +33 -17
- package/driver/package.json +1 -1
- package/driver/partial-payload-baseline.json +1 -1
- package/driver/pipeline.mjs +3 -3
- package/driver/portal-families.mjs +1 -1
- package/driver/predelivery-lint.mjs +23 -1
- package/driver/publish/index.mjs +24 -1
- package/driver/publish/render-knockout.mjs +6 -26
- package/driver/publish/render.mjs +25 -5
- package/driver/publish/report-data.mjs +2 -1
- package/driver/publish/search-depth.mjs +178 -0
- package/driver/suite-census.json +38 -2
- package/driver/terminal-clamp.mjs +41 -0
- package/driver/verify.mjs +22 -3
- package/mcp-server/CHANGELOG.md +8 -0
- package/mcp-server/package.json +1 -1
- package/package.json +1 -1
- package/portal-ui/dist/assets/{index-ChIQsMYp.js → index-BsbasHjM.js} +3350 -3288
- package/portal-ui/dist/assets/{index-DBIs21e4.css → index-DNQpLYZF.css} +17 -1
- package/portal-ui/dist/index.html +2 -2
- package/portal-ui/package.json +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/scripts/freeze-example-run.mjs +27 -27
- package/scripts/mint-writing-standard-backlog.mjs +82 -0
- package/scripts/writing-standard-check.mjs +122 -0
- package/shared/says-something-new.mjs +62 -0
- package/shared/writing-standard-caveats.json +14 -0
- 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:
|
|
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 }));
|
|
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
|
package/driver/publish/index.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
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
|
-
//
|
|
1693
|
-
//
|
|
1694
|
-
//
|
|
1695
|
-
//
|
|
1696
|
-
//
|
|
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: (
|
|
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
|
+
}
|
package/driver/suite-census.json
CHANGED
|
@@ -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":
|
|
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":
|
|
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
|
|
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)} [
|
|
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
|
}
|
package/mcp-server/CHANGELOG.md
CHANGED
package/mcp-server/package.json
CHANGED
|
@@ -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.",
|