clearotron 0.3.2 → 0.3.3-beta.0

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 (105) hide show
  1. package/CONTRIBUTING.md +12 -0
  2. package/INSTALL.md +8 -0
  3. package/bin/onboard.mjs +29 -7
  4. package/bin/start.mjs +1 -1
  5. package/build-info.json +2 -2
  6. package/demo/MANIFEST.json +27 -0
  7. package/docs/INTAKE.md +8 -0
  8. package/docs/architecture/04-configuration-reference.md +1 -1
  9. package/driver/CHANGELOG.md +37 -0
  10. package/driver/clearance-variants-record.mjs +12 -1
  11. package/driver/common-law-coverage-status.mjs +113 -0
  12. package/driver/contract-audit.mjs +1 -1
  13. package/driver/contract-e3-backlog.mjs +37 -37
  14. package/driver/contract-vocabulary.mjs +8 -8
  15. package/driver/coverage-form-io.mjs +3 -1
  16. package/driver/coverage-form.mjs +38 -11
  17. package/driver/coverage-ledger.mjs +37 -7
  18. package/driver/coverage-union.mjs +2 -2
  19. package/driver/crowd-context.mjs +19 -6
  20. package/driver/drainer-identity.mjs +1 -1
  21. package/driver/engine/mcp/clarivate-server.mjs +4 -2
  22. package/driver/engine/mcp/corsearch-server.mjs +3 -1
  23. package/driver/engine/mcp/coverage-server.mjs +1 -1
  24. package/driver/engine/mcp/dispositions-server.mjs +47 -5
  25. package/driver/engine/mcp/euipo-server.mjs +2 -0
  26. package/driver/engine/mcp/free-tier-server.mjs +2 -0
  27. package/driver/engine/mcp/gather-config.mjs +8 -2
  28. package/driver/engine/mcp/probe-server.mjs +28 -0
  29. package/driver/engine/mcp/proposal-fields.mjs +45 -0
  30. package/driver/engine/mcp/recording-server.mjs +30 -0
  31. package/driver/engine/mcp/signa-server.mjs +2 -0
  32. package/driver/engine/mcp/supplemental.mjs +89 -12
  33. package/driver/engine/mcp/unit-note-server.mjs +50 -0
  34. package/driver/engine/mcp/uspto-local-server.mjs +2 -0
  35. package/driver/engine/openai-agent.mjs +7 -0
  36. package/driver/engine/probe.mjs +67 -14
  37. package/driver/engine/tool-refusal.mjs +16 -0
  38. package/driver/enqueue-schema.mjs +2 -2
  39. package/driver/envelope-settle.mjs +82 -13
  40. package/driver/findings-model.mjs +4 -4
  41. package/driver/gateway.mjs +18 -2
  42. package/driver/manager-groups-verdict.mjs +1 -1
  43. package/driver/matter-frame-record.mjs +24 -7
  44. package/driver/named-band.mjs +1 -1
  45. package/driver/package.json +1 -1
  46. package/driver/partial-payload-baseline.json +12 -3
  47. package/driver/pipeline.mjs +141 -37
  48. package/driver/publish/index.mjs +40 -25
  49. package/driver/publish/xlsx.mjs +26 -4
  50. package/driver/queue-markers.mjs +44 -0
  51. package/driver/queue-watch-verdict.mjs +2 -2
  52. package/driver/register-availability.mjs +2 -2
  53. package/driver/register-plan.mjs +313 -21
  54. package/driver/roster-verdict.mjs +1 -1
  55. package/driver/runner.mjs +26 -2
  56. package/driver/skills/clearance-common-law/SKILL.md +2 -0
  57. package/driver/skills/clearance-register/SKILL.md +44 -3
  58. package/driver/skills/clearance-register/digest.md +5 -5
  59. package/driver/skills/clearance-register/providers/clarivate.md +1 -1
  60. package/driver/skills/clearance-register/unit.md +39 -0
  61. package/driver/skills/clearance-variants/SKILL.md +1 -1
  62. package/driver/skills/matter-frame/SKILL.md +4 -2
  63. package/driver/stages.mjs +12 -5
  64. package/driver/status-snapshot.mjs +1 -1
  65. package/driver/suite-census.json +236 -14
  66. package/driver/synthesis-record.mjs +80 -2
  67. package/driver/unit-file-drift.mjs +3 -3
  68. package/driver/unit-inventory.mjs +2 -2
  69. package/driver/unit-state-verdict.mjs +1 -1
  70. package/driver/updater-identity.mjs +2 -3
  71. package/driver/variant-manifest-model.mjs +11 -1
  72. package/driver/verify.mjs +5 -5
  73. package/driver/withheld-families.mjs +104 -0
  74. package/mcp-server/CHANGELOG.md +4 -0
  75. package/mcp-server/lib/brief.mjs +5 -7
  76. package/mcp-server/lib/runs.mjs +1 -1
  77. package/mcp-server/package.json +1 -1
  78. package/mcp-server/server.mjs +3 -2
  79. package/package.json +1 -1
  80. package/portal-ui/dist/assets/{index-DMthc7PQ.js → index-GBbbyQxc.js} +22 -4
  81. package/portal-ui/dist/index.html +1 -1
  82. package/portal-ui/package.json +1 -1
  83. package/providers/_shared/count.mjs +2 -2
  84. package/providers/_shared/enumerate.mjs +15 -2
  85. package/providers/_shared/execute-plan.mjs +19 -1
  86. package/providers/_shared/plan-guards.mjs +40 -0
  87. package/providers/clarivate/src/capabilities.js +15 -5
  88. package/providers/clarivate/src/core.js +41 -5
  89. package/providers/corsearch/src/capabilities.js +4 -0
  90. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  91. package/providers/oauth-mcp-bridge/package.json +1 -1
  92. package/providers/signa/src/capabilities.js +22 -8
  93. package/providers/signa/src/core.js +12 -1
  94. package/scripts/demo-evidence.mjs +114 -0
  95. package/scripts/engine-probe.mjs +6 -5
  96. package/scripts/env-audit.mjs +1 -1
  97. package/scripts/freeze-example-run.mjs +3 -3
  98. package/scripts/live-surface-check.mjs +26 -19
  99. package/scripts/mint-suite-census.mjs +66 -0
  100. package/scripts/package-size-budget.mjs +117 -0
  101. package/scripts/register-plan-shape.mjs +259 -0
  102. package/scripts/release-note-required.mjs +38 -1
  103. package/scripts/settings-render-check.mjs +36 -0
  104. package/scripts/travelling-predicates.mjs +1 -1
  105. package/shared/identifier-scan.mjs +22 -5
@@ -676,7 +676,7 @@ export const CHECKED_TIMERS = Object.freeze(
676
676
  * that reports on the service keeps saying `inactive`, which is what it says when the timer is working.
677
677
  */
678
678
  export function timerVerdict(timers, { probeFailed = null } = {}) {
679
- if (probeFailed) return { state: "unknown", stopped: [], absent: [], message: `could not ask systemd about the timers: ${probeFailed}. That is this check failing to look, not a report about them.` };
679
+ if (probeFailed) return { state: "skip", blocked: true, stopped: [], absent: [], message: `could not ask systemd about the timers: ${probeFailed}. That is this check failing to look, not a report about them.` };
680
680
  const rows = timers ?? [];
681
681
  if (!rows.length) return { state: "pass", stopped: [], absent: [], message: "no timer is declared for this box" };
682
682
  const absent = rows.filter((t) => t.load === "not-found").map((t) => t.unit).sort();
@@ -726,7 +726,7 @@ export function unitInventoryVerdict({
726
726
  inventory = UNIT_INVENTORY,
727
727
  } = {}) {
728
728
  if (!probe.ok) {
729
- return { state: "skip", undeclared: [], orphaned: [], absent: [], misdeclared: [],
729
+ return { state: "skip", blocked: true, undeclared: [], orphaned: [], absent: [], misdeclared: [],
730
730
  message: `could not enumerate systemd units, so the inventory was NOT checked — ${probe.why ?? "no reason given"}. `
731
731
  + "A failure to look is not a finding about the deployment." };
732
732
  }
@@ -100,7 +100,7 @@ export function classifyActiveState(active) {
100
100
  export function unitsActiveVerdict({ units, probe }) {
101
101
  // — a failure to look is never a finding about the deployment.
102
102
  if (!probe?.ok)
103
- return { state: "skip", message: `could not enumerate systemd --user units — ${probe?.why ?? "no reason reported"}` };
103
+ return { state: "skip", blocked: true, message: `could not enumerate systemd --user units — ${probe?.why ?? "no reason reported"}` };
104
104
 
105
105
  const label = (u) => `${u.unit}=${u.active}`
106
106
  + (u.type ? ` (${u.type}${u.since ? `, since ${u.since}` : ""})` : (u.since ? ` (since ${u.since})` : ""));
@@ -111,8 +111,7 @@ export function updaterVerdict({ stamp, now = null, deployClone = null, maxAgeSe
111
111
  return { state: "fail", message:
112
112
  `no updater identity stamp at ${UPDATER_STAMP_BASENAME}: the copy of the updater that deploys this `
113
113
  + "box did NOT say what it is, so whether it is the current one was not established. A copy old "
114
- + "enough to predate the stamp writes none, which is itself the stale-updater case. This is a "
115
- + "failure to look, never a pass." };
114
+ + "enough to predate the stamp writes none, which is itself the stale-updater case." };
116
115
  }
117
116
 
118
117
  // — AN UNREADABLE SIDE IS NOT A MISMATCH. A digest that could not be taken arrives as null, and
@@ -128,7 +127,7 @@ export function updaterVerdict({ stamp, now = null, deployClone = null, maxAgeSe
128
127
  : !wsha ? "the running copy of the updater could NOT be read as a digest"
129
128
  : "the master could NOT be read as a digest";
130
129
  const why = stamp.masterCommitError ? ` The updater reported: ${stamp.masterCommitError}` : "";
131
- return { state: "fail", message:
130
+ return { state: "skip", blocked: true, message:
132
131
  `the updater stamp is present but ${which}, so the running copy was NOT `
133
132
  + `compared against its master.${why} This is a failure to look, never a pass.` };
134
133
  }
@@ -176,8 +176,18 @@ export function parseVariantManifestModel(raw) {
176
176
  // and refuses it. Each says so in its own capability contract, and the compiler declines to build an
177
177
  // entry a register cannot express. Deciding it here would freeze one register's behaviour into a
178
178
  // rule about every register.
179
- let goods_words = [];
179
+ // ── AN ABSENT KEY AND AN EMPTY LIST ARE DIFFERENT ANSWERS ───────────────────────────────────────
180
+ //
181
+ // `null` means the stage was asked and did not answer. `[]` means it answered: it considered the
182
+ // goods and there are no words worth narrowing by. They are the same value to a reader who only
183
+ // checks emptiness, and that is how a production run shipped a narrowing that never ran — the model
184
+ // could not send the key at all, the manifest carried an empty list, the compiler minted nothing,
185
+ // and the run looked exactly like a matter that genuinely had no goods words.
186
+ //
187
+ // Nothing downstream may treat the two the same. A skipped question is a fact about the run.
188
+ let goods_words = null;
180
189
  if (m.goods_words != null) {
190
+ goods_words = [];
181
191
  if (!Array.isArray(m.goods_words) || !m.goods_words.every((w) => typeof w === "string"))
182
192
  throw new Error("variantmodel_goods_words_invalid (an array of single-word strings, or omitted)");
183
193
  const seen = new Set();
package/driver/verify.mjs CHANGED
@@ -16,7 +16,7 @@ import { matterFrameWasRecorded, frameRatifiedForms } from "./matter-frame-recor
16
16
  import { findConnotationViolations, parsePrRiskResults, prRiskPopulation,
17
17
  CONNOTATION_UNMATCHED_MARK, CONNOTATION_NO_RESEMBLANCE_MARK, MEANING_ANGLES_RE,
18
18
  parseDispositionForm, CONNOTATION_UNRULED_REASONS, queryKey } from "./connotation-search.mjs";
19
- import { formSidecarName, formSidecarPath } from "./disposition-union.mjs";
19
+ import { formSidecarName, formSidecarPath } from "./disposition-union.mjs"; import { coverageStatusAsData } from "./common-law-coverage-status.mjs";
20
20
  // B — the transport's own four failure states. The audit reads the run's records; this file locates them.
21
21
  import { auditDispositionCalls, CALL_FAILURE_REASONS } from "./disposition-call-audit.mjs";
22
22
  import { callRecordPaths } from "./disposition-tool.mjs";
@@ -481,7 +481,7 @@ function commonLawMeaningSeat(p, c) {
481
481
  // — the EVIDENCE chain for `validators.commonLaw`, lifted out verbatim so the unavailability
482
482
  // veto can wrap it instead of pre-empting it. Every return token is unchanged.
483
483
  function commonLawEvidence(p, c) {
484
- const structural = commonLawStructural(c);
484
+ const structural = commonLawStructural(c, p);
485
485
  if (!structural.ok) return structural;
486
486
  // The dictated grid spec is the SINGLE source of the grid contract when present (the driver wrote it,
487
487
  // the plugin ran it, the plugin wrote the ledger from it): the receipts gate joins the ledger against
@@ -596,7 +596,7 @@ function commonLawHalfEvidence(p, c) {
596
596
  if (!half) return fail("half_path_unrecognized");
597
597
  // — the meaning seat is judged on the meaning work, because that is all it was dictated.
598
598
  if (half === MEANING_SEAT) return commonLawMeaningSeat(p, c);
599
- const structural = commonLawStructural(c);
599
+ const structural = commonLawStructural(c, p);
600
600
  if (!structural.ok) return structural;
601
601
  const gs = readGridSpecHalf(p, half);
602
602
  if (gs.invalid) return fail(`grid_spec_unreadable:_driver/grid-spec.half-${half}.json is corrupt or misshapen (driver-written — this is a bug, not a model defect)`);
@@ -664,7 +664,7 @@ function commonLawHalfEvidence(p, c) {
664
664
  // adjacent with exactly one separator between them; it stops rejecting a document that HAS the section
665
665
  // under the hyphenated compound the skill itself uses. The failure this removes is a false negative, and
666
666
  // its cost was a clearance that had already spent an hour.
667
- function commonLawStructural(c) {
667
+ function commonLawStructural(c, p = null) {
668
668
  return all(
669
669
  nonEmpty(c),
670
670
  needsSection(c, "findings", [/^#{1,4}\s+[^\n]*\bfindings\b/im], "findings-heading"),
@@ -672,7 +672,7 @@ function commonLawStructural(c) {
672
672
  needsSection(c, "coverage-ledger", [/coverage[\s-]ledger/i], "coverage-ledger"),
673
673
  needsSection(c, "audit-trail", [/audit[\s-]trail/i], "audit-trail"),
674
674
  needs(c, [/\|/], "platform matrix"),
675
- hasCoverageLedgerRow(c) ? ok() : fail("no_coverage_status_row"),
675
+ coverageStatusAsData(p) || hasCoverageLedgerRow(c) ? ok() : fail("no_coverage_status_row"), // the recorded status first, the word as fallback: coverageStatusAsData() in common-law-coverage-status.mjs
676
676
  );
677
677
  }
678
678
 
@@ -0,0 +1,104 @@
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
+ // withheld-families.mjs — the reading turn's record of the waiting families it chose not to ask.
4
+ //
5
+ // On a crowded field the wider register families wait in the plan for the reading turn, which asks the
6
+ // ones worth asking. A family it does not ask was never searched, and until this record nothing said
7
+ // so: the turn's reason, when it gave one, sat in its prose note, where nothing reads it. So the turn
8
+ // records each family it leaves unasked as `withheld-by-judgment` with its reason, the coverage form
9
+ // carries a row per waiting family pre-settled from this record, and a family nobody judged is an
10
+ // unsettled row the digest's gate refuses to pass.
11
+ //
12
+ // The reason is the run's record and the audit workbook's, never the report's: the coverage form keeps
13
+ // these rows out of the ledger the report is built from (coverage-form.mjs, `family`).
14
+ //
15
+ // One file per axis, because the reading turn fans out one seat per axis and two seats writing one file
16
+ // would lose each other's calls. The axis is bound by the server, never taken from the payload.
17
+ import { readFileSync, writeFileSync, renameSync, existsSync, readdirSync, mkdirSync } from "node:fs";
18
+ import { dirname, join } from "node:path";
19
+ import { driverDir } from "../shared/driver-dir.mjs";
20
+ import { awaitsReadingTurn } from "../providers/_shared/plan-guards.mjs";
21
+ import { entryQuestionKey } from "./register-plan.mjs";
22
+ import { seatBannedTokens } from "./coverage-form.mjs";
23
+
24
+ export const WITHHELD_REASON_MAX = 600;
25
+ const FILE_RE = /^withheld-families-(.+)\.json$/;
26
+
27
+ export const withheldFamiliesPath = (runDir, axis) => driverDir(runDir, `withheld-families-${axis}.json`);
28
+
29
+ const readJson = (p) => { try { return existsSync(p) ? JSON.parse(readFileSync(p, "utf8")) : null; } catch { return null; } };
30
+
31
+ /** Every axis's record, merged: `{ [qid]: { axis, reason } }`. An unreadable file contributes nothing. */
32
+ export function readWithheldFamilies(runDir) {
33
+ const out = {};
34
+ let names = [];
35
+ try { names = readdirSync(driverDir(runDir)); } catch { return out; }
36
+ for (const name of names) {
37
+ const m = FILE_RE.exec(name);
38
+ if (!m) continue;
39
+ const doc = readJson(driverDir(runDir, name));
40
+ for (const [qid, v] of Object.entries(doc?.families ?? {})) {
41
+ const reason = String(v?.reason ?? "").trim();
42
+ if (qid && reason) out[qid] = { axis: String(doc?.axis ?? m[1]), reason };
43
+ }
44
+ }
45
+ return out;
46
+ }
47
+
48
+ /**
49
+ * The waiting families on one axis, and which of them the reading turn has asked: a waiting row is asked
50
+ * when a question it did not wait for — a plan entry or one of this axis's supplementals — carries the
51
+ * same question key. PURE over its inputs.
52
+ */
53
+ export function waitingFamiliesOn(plan, axis, supplementals = []) {
54
+ const entries = Array.isArray(plan?.entries) ? plan.entries : [];
55
+ const askedKeys = new Set([...entries.filter((e) => !awaitsReadingTurn(e?.when)), ...supplementals]
56
+ .filter((e) => e && !e.unsupported).map((e) => entryQuestionKey(e, plan)).filter(Boolean));
57
+ const waiting = entries.filter((e) => e?.axis === axis && awaitsReadingTurn(e?.when));
58
+ return {
59
+ waiting,
60
+ unasked: waiting.filter((e) => !askedKeys.has(entryQuestionKey(e, plan))),
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Record families withheld on the bound axis. `families` is `[{ qids: [qid…] | qid, reason }]` — one
66
+ * reason may cover several families. Each qid must be a waiting family of this axis in the frozen plan;
67
+ * a reason must be the reader's words, since the audit workbook prints it. Accepts what is valid, names
68
+ * what it refused, and never throws.
69
+ */
70
+ export function recordWithheldFamilies(runDir, { axis, families } = {}) {
71
+ const plan = readJson(driverDir(runDir, "register-plan.json"));
72
+ if (!plan || !Array.isArray(plan.entries)) return { refused: "no frozen register plan in this run — there are no waiting families to record against" };
73
+ if (!Array.isArray(families) || !families.length) return { refused: "families must list at least one { qids, reason }" };
74
+ const supp = readJson(join(runDir, "register-units", `${axis}-supplemental-plan.json`));
75
+ const { waiting, unasked } = waitingFamiliesOn(plan, axis, Array.isArray(supp?.entries) ? supp.entries : []);
76
+ const waitingQids = new Set(waiting.map((e) => e.qid));
77
+ const path = withheldFamiliesPath(runDir, axis);
78
+ const doc = readJson(path) ?? { axis, families: {} };
79
+ const recorded = [], rejected = [];
80
+ for (const item of families) {
81
+ const qids = (Array.isArray(item?.qids) ? item.qids : [item?.qids ?? item?.qid]).map((q) => String(q ?? "").trim()).filter(Boolean);
82
+ const reason = String(item?.reason ?? "").replace(/\s+/g, " ").trim();
83
+ if (!qids.length) { rejected.push({ qid: "", issue: "an item names no qid" }); continue; }
84
+ if (!reason) { for (const qid of qids) rejected.push({ qid, issue: "no reason — a withheld family is recorded with why it was not asked" }); continue; }
85
+ if (reason.length > WITHHELD_REASON_MAX) { for (const qid of qids) rejected.push({ qid, issue: `the reason runs to ${reason.length} characters; at most ${WITHHELD_REASON_MAX}` }); continue; }
86
+ const banned = seatBannedTokens(reason);
87
+ if (banned.length) { for (const qid of qids) rejected.push({ qid, issue: `the reason names ${banned.join(", ")} — the audit workbook prints it; say it in a lawyer's words` }); continue; }
88
+ for (const qid of qids) {
89
+ if (!waitingQids.has(qid)) { rejected.push({ qid, issue: `not a waiting family on axis "${axis}" in the frozen plan` }); continue; }
90
+ doc.families[qid] = { reason };
91
+ recorded.push(qid);
92
+ }
93
+ }
94
+ if (recorded.length) {
95
+ try {
96
+ mkdirSync(dirname(path), { recursive: true });
97
+ const tmp = `${path}.${process.pid}.tmp`;
98
+ writeFileSync(tmp, JSON.stringify({ axis, families: doc.families }, null, 2) + "\n");
99
+ renameSync(tmp, path);
100
+ } catch (e) { return { write_failed: String(e?.message ?? e).slice(0, 200) }; }
101
+ }
102
+ const still = unasked.filter((e) => !doc.families[e.qid]).map((e) => e.qid);
103
+ return { axis, recorded, rejected, still_to_judge: still, waiting: waiting.length };
104
+ }
@@ -1,5 +1,9 @@
1
1
  # trademark-artifacts-mcp
2
2
 
3
+ ## 0.3.3-beta.0
4
+
5
+ No changes in this release.
6
+
3
7
  ## 0.3.2
4
8
 
5
9
  ### Patch Changes
@@ -92,9 +92,8 @@ export function buildBrief(run) {
92
92
  const tail = [product, dateStr ? `run ${dateStr}` : null].filter(Boolean).join(", ");
93
93
  lines.push(`**${subject}**${tail ? ` — ${tail}` : ""}.`);
94
94
 
95
- const caption = clearance
96
- ? (clearance.verdict?.statement ?? clearance.caption ?? "")
97
- : plainClause(fm.overall_caption ?? "");
95
+ // The report's own conclusion, verbatim, after the rating (ruled 2026-09-22: the report is the master).
96
+ const caption = clearance ? (clearance.caption ?? "") : plainClause(fm.overall_caption ?? "");
98
97
  if (overall) lines.push(`**Overall risk: ${titleCase(String(overall))}.** ${caption}`.trim());
99
98
  if (run.state) {
100
99
  // a park is paused, not finished — say so plainly, and name the clock only where one exists
@@ -104,9 +103,8 @@ export function buildBrief(run) {
104
103
  : run.state === "recovering" ? ` — auto-recovery backoff, resumes ${run.recoveryResumesAt ? `at ${String(run.recoveryResumesAt).replace("T", " ").slice(0, 16)} UTC` : "on its own"}`
105
104
  : run.state === "parked-for-human" ? ` — parked by a runner stop (deploy/restart), resumes on the next runner activation` : "";
106
105
  lines.push(`Status: ${run.state}${paused}.`);
107
- // The run's own sentence, composed once by the driver and rendered on every client surface. It says
108
- // what the gate word used to be reached for, in the words the report itself uses.
109
- if (run.statement) lines.push(String(run.statement));
106
+ // No second summary here: the rating and the report's conclusion above are the run's answer. The
107
+ // composed statement said "on hold" on runs nothing held.
110
108
  }
111
109
 
112
110
  let source = "none";
@@ -204,7 +202,7 @@ export function buildBrief(run) {
204
202
  return {
205
203
  runId: run.runId, markName: run.markName ?? clearance?.markName ?? fm.title ?? null,
206
204
  product,
207
- overall, tier: run.tier ?? null, statement: run.statement ?? null, state: run.state ?? null, date: run.date ?? null,
205
+ overall, tier: run.tier ?? null, caption: run.caption ?? null, state: run.state ?? null, date: run.date ?? null,
208
206
  source, brief: lines.join("\n"),
209
207
  };
210
208
  }
@@ -102,7 +102,7 @@ function runFromStatusFile(statusFile, agent) {
102
102
  // assistant saw the gate's word and nothing beside it, which is the shape this whole item is about.
103
103
  // The verdict record holds the band for those runs, so it is read from there.
104
104
  state: s.state ?? null,
105
- tier: s.tier ?? bandWord(s.verdict) ?? tierFromRecord(runDir), statement: s.statement ?? null, url: s.url ?? null,
105
+ tier: s.tier ?? bandWord(s.verdict) ?? tierFromRecord(runDir), caption: s.caption ?? null, url: s.url ?? null, // the report's own conclusion, never a second summary (ruled 2026-09-22)
106
106
  markName: s.markName ?? null, ref: s.ref ?? null, classes: s.classes ?? null,
107
107
  stepN: s.stepN ?? null, stepLabel: s.stepLabel ?? null, stepTotal: s.stepTotal ?? null,
108
108
  failedStage: s.failedStage ?? null, reason: s.reason ?? null,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-artifacts-mcp",
3
- "version": "0.3.2",
3
+ "version": "0.3.3-beta.0",
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.",
@@ -207,8 +207,9 @@ function runSummary(run) {
207
207
  // once. null where the registry cannot name it — the row says nothing rather than guessing, because
208
208
  // a hardcoded fallback is how a knockout once announced itself as a product it provably was not.
209
209
  product: productIdentityFor(run),
210
- // The band and the run's own sentence; the gate's word stays in the run record (824).
211
- state: run.state, location: run.location, tier: run.tier, statement: run.statement, url: run.url,
210
+ // The band and the report's own conclusion; the gate's word stays in the run record (824), and no
211
+ // second summary rides beside them (ruled 2026-09-22).
212
+ state: run.state, location: run.location, tier: run.tier, caption: run.caption, url: run.url,
212
213
  markName: run.markName, ref: run.ref, classes: run.classes,
213
214
  step: s.stepN ? `${s.stepN}/${s.stepTotal} ${s.stepLabel ?? ""}`.trim() : null,
214
215
  startedAt: run.startedAt, updatedAt: run.updatedAt, deliveredAt: run.deliveredAt,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clearotron",
3
3
  "type": "module",
4
- "version": "0.3.2",
4
+ "version": "0.3.3-beta.0",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
7
7
  "type": "git",
@@ -12751,7 +12751,18 @@ function Logo({ markOnly = false }) {
12751
12751
  var ACTIVE_MS = 5e3;
12752
12752
  var IDLE_MS = 3e4;
12753
12753
  var BACKOFF_MS = 6e4;
12754
- function useLoad(fetcher, deps) {
12754
+ var startedEarly = new Map();
12755
+ function startEarly(key, fetcher) {
12756
+ if (!startedEarly.has(key)) startedEarly.set(key, fetcher());
12757
+ }
12758
+ function claimEarly(key) {
12759
+ if (!key) return null;
12760
+ const p = startedEarly.get(key);
12761
+ if (!p) return null;
12762
+ startedEarly.delete(key);
12763
+ return p;
12764
+ }
12765
+ function useLoad(fetcher, deps, early) {
12755
12766
  const [result, setResult] = (0, import_react.useState)(null);
12756
12767
  const [loading, setLoading] = (0, import_react.useState)(true);
12757
12768
  const [nonce, setNonce] = (0, import_react.useState)(0);
@@ -12764,7 +12775,7 @@ function useLoad(fetcher, deps) {
12764
12775
  if (seenKey.current !== null && seenKey.current !== depsKey) setResult(null);
12765
12776
  seenKey.current = depsKey;
12766
12777
  setLoading(true);
12767
- fetcherRef.current().then((r) => {
12778
+ (claimEarly(early) ?? fetcherRef.current()).then((r) => {
12768
12779
  if (!live) return;
12769
12780
  setResult(r);
12770
12781
  setLoading(false);
@@ -15487,7 +15498,7 @@ function lastFinishedOf(row) {
15487
15498
  }
15488
15499
  var COMPACT_FROM = 5;
15489
15500
  function Home({ ctx }) {
15490
- const { result, reload } = useLoad(() => api.runsMine(), []);
15501
+ const { result, reload } = useLoad(() => api.runsMine(), [], "runs:mine");
15491
15502
  const allowanceOwner = ctx.owner ?? (ctx.me.accounts.length === 1 ? ctx.me.accounts[0] : null);
15492
15503
  const { result: usageRes } = useLoad(() => allowanceOwner ? api.usage(allowanceOwner) : Promise.resolve({ kind: "pickAccount" }), [allowanceOwner]);
15493
15504
  const allRuns = result?.kind === "ok" ? result.value : [];
@@ -16920,7 +16931,7 @@ function Clearances({ ctx }) {
16920
16931
  const [query, setQuery] = (0, import_react.useState)("");
16921
16932
  const [open, setOpen] = (0, import_react.useState)(new Set());
16922
16933
  const [page, setPage] = (0, import_react.useState)(0);
16923
- const { result, reload } = useLoad(() => api.runsMine(), []);
16934
+ const { result, reload } = useLoad(() => api.runsMine(), [], "runs:mine");
16924
16935
  const allRuns = result?.kind === "ok" ? result.value : [];
16925
16936
  const ownerFilter = ctx.owner;
16926
16937
  const runs = (0, import_react.useMemo)(() => ownerFilter ? allRuns.filter((r) => runKey(r) === ownerFilter) : allRuns, [allRuns, ownerFilter]);
@@ -26744,6 +26755,13 @@ function screen(id, ctx) {
26744
26755
  });
26745
26756
  }
26746
26757
  }
26758
+ var EARLY_RUNS_PATHS = [
26759
+ "/portal/home",
26760
+ "/portal/clearances",
26761
+ "/portal"
26762
+ ];
26763
+ var here = window.location.pathname.replace(/[?#].*$/, "").replace(/\/+$/, "") || "/portal";
26764
+ if (EARLY_RUNS_PATHS.includes(here)) startEarly("runs:mine", () => api.runsMine());
26747
26765
  var root = document.getElementById("root");
26748
26766
  if (!root) throw new Error("#root is missing from index.html");
26749
26767
  (0, import_client.createRoot)(root).render((0, import_jsx_runtime.jsx)(import_react.StrictMode, { children: (0, import_jsx_runtime.jsx)(AppShell, { render: screen }) }));
@@ -49,7 +49,7 @@
49
49
  -->
50
50
  <link rel="preconnect" href="https://api.fontshare.com" crossorigin />
51
51
  <link href="https://api.fontshare.com/v2/css?f[]=satoshi@400,500,700,900&display=swap" rel="stylesheet" />
52
- <script type="module" crossorigin src="/portal/assets/index-DMthc7PQ.js"></script>
52
+ <script type="module" crossorigin src="/portal/assets/index-GBbbyQxc.js"></script>
53
53
  <link rel="stylesheet" crossorigin href="/portal/assets/index-5CCwiJG7.css">
54
54
  </head>
55
55
  <body>
@@ -2,7 +2,7 @@
2
2
  "name": "portal-ui",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.3.2",
5
+ "version": "0.3.3-beta.0",
6
6
  "license": "AGPL-3.0-only",
7
7
  "description": "The unified trademark portal UI. One address, one login: who you are decides what you see. Built as a static bundle, served by driver/portal-service.mjs — the browser never reaches profile-service or recipe-service.",
8
8
  "engines": {
@@ -13,8 +13,8 @@
13
13
  //
14
14
  // SEAM (capabilities.countProbe) — WHERE the number comes from. Same three values enumerate.mjs
15
15
  // documents, same meanings:
16
- // "endpoint" Clarivate. A real POST /count: cheap, works at ANY magnitude (209012 returned without
17
- // complaint), fetches nothing. A true count-only call.
16
+ // "endpoint" Clarivate. A real POST /count: cheap, works at ANY magnitude, fetches nothing. A true
17
+ // count-only call.
18
18
  // "cheap" Corsearch. The count rides page 0 of a normal search (totalHitCount) — so it is one
19
19
  // BILLABLE search with `limit:1 fields:["uri"]`, the smallest response the API will give.
20
20
  // Cheap, not free: every count here is a metered call.
@@ -288,7 +288,18 @@ export function makeEnumerate(deps) {
288
288
  const tally = { "verified-zero": 0, enumerated: 0, crowd: 0, unenumerated: 0, error: 0 };
289
289
  for (const v of Object.values(term_counts)) tally[v.disposition] += 1;
290
290
  const unresolved = tally.crowd + tally.unenumerated + tally.error;
291
- if (unresolved === 0 && records.length > 0) {
291
+ // A FULLY RESOLVED STACK IS A COMPLETE BAND, INCLUDING WHEN THE ANSWER IS ZERO. The comment below
292
+ // states the rule and the code then demanded a record anyway: `records.length > 0`. So a stack in
293
+ // which EVERY term resolved to verified-zero — nobody has filed any of these names — fell through
294
+ // to `incomplete`, which says nobody answered the question. The opposite is true: every term was
295
+ // asked and every one came back empty.
296
+ //
297
+ // It matters beyond the label. An `incomplete` parent is terminal for its when-guarded children, so
298
+ // a clean zero here held back everything waiting on it — and with the wider families now waiting on
299
+ // the identical question, that is the best case a matter can have (a mark nobody has registered)
300
+ // producing a run that searches almost nothing. The ordinary search path has always returned
301
+ // `enumerated` for a zero-record answer; this rescue path was the one place that did not.
302
+ if (unresolved === 0) {
292
303
  // every term resolved to verified-zero or fully-enumerated ⇒ the union of per-term enumerations IS
293
304
  // the complete stack (every record matching ≥1 name sits in some term's enumeration) — a true band.
294
305
  return { type: "text", text: JSON.stringify({ state: "enumerated", total_hits: stackTotal, count: records.length, records, term_counts }, null, 2) };
@@ -344,7 +355,9 @@ export function makeEnumerate(deps) {
344
355
  const tally = { "verified-zero": 0, enumerated: 0, crowd: 0, unenumerated: 0, error: 0 };
345
356
  for (const v of Object.values(class_counts)) tally[v.disposition] += 1;
346
357
  const unresolved = tally.crowd + tally.unenumerated + tally.error;
347
- if (unresolved === 0 && records.length > 0) {
358
+ // Same rule, same reason, same correction as the OR-stack rescue above: a stack in which every class
359
+ // came back a verified zero is a complete band whose answer is zero, not a question nobody answered.
360
+ if (unresolved === 0) {
348
361
  // every class resolved to verified-zero or fully-enumerated ⇒ the union of per-class enumerations
349
362
  // IS the complete stack (every record carries ≥1 in-filter class) — a true band, the class rescue.
350
363
  return { type: "text", text: JSON.stringify({ state: "enumerated", total_hits: stackTotal, count: records.length, records, class_counts }, null, 2) };
@@ -18,6 +18,7 @@ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
18
18
  import { dirname } from "node:path";
19
19
  import { nativeScriptIndexGap } from "./script-form.mjs";
20
20
  import { entryTermIssues, goodsTermsList } from "./term-shape.mjs";
21
+ import { awaitsReadingTurn } from "./plan-guards.mjs";
21
22
  import { faultText, guardToolCall } from "./transport-guard.mjs";
22
23
  import { clipProviderText } from "./provider-text.mjs"; // — keep the discriminator
23
24
 
@@ -210,10 +211,13 @@ export function queryMarkTerms(e, query) {
210
211
  return Array.isArray(e?.terms) ? e.terms : e?.term != null ? [e.term] : [];
211
212
  }
212
213
 
214
+ // The goods words ride after the class tag (close-verify reads that tag): a goods-narrowed question shares
215
+ // predicate, term and classes with the identical one, and without them the two read as the same question.
213
216
  export const describePlanEntry = (e) =>
214
217
  `${e.predicate} ${e.terms ? e.terms.join(" OR ") : e.term}`
215
218
  + `${typeof e.owner === "string" && e.owner.trim() && String(e.predicate ?? "") !== "owner" ? ` owner:${e.owner.trim()}` : ""}`
216
- + ` [cl ${(e.nice_classes ?? []).join(",")}]`;
219
+ + ` [cl ${(e.nice_classes ?? []).join(",")}]`
220
+ + `${goodsTermsList(e).length ? ` goods:${goodsTermsList(e).join(" OR ")}` : ""}`;
217
221
 
218
222
  // Compile one plan entry + its predicate params into the provider's query params (corsearch shapes by
219
223
  // default: names/name/owners/owner + nice_classes + regions).
@@ -576,6 +580,20 @@ export function makeExecutePlan(deps) {
576
580
  for (const e of targeted.filter((x) => !x.when)) await runEntry(e);
577
581
  const skipped = [];
578
582
  for (const e of targeted.filter((x) => x.when)) {
583
+ // A CROWD IS WHAT HOLDS A CHILD BACK, and only a crowd. `enumerated` is the state of a question
584
+ // that was answered — with records or with none: the ordinary search path returns it for a
585
+ // zero-record answer too, so a clean zero has always released its children here.
586
+ //
587
+ // Where that stopped being true was the RESCUE paths, and it is fixed there rather than here: a
588
+ // per-term or per-class stack in which every member came back a verified zero was falling through
589
+ // to `incomplete`, which says nobody answered. See enumerate.mjs — a fully resolved stack is a
590
+ // complete band whose answer is zero. `verified-zero` is a per-term DISPOSITION and never a band
591
+ // state (named-band.mjs BAND_STATES), so a guard testing for it here could never fire.
592
+ // THE READING-TURN WAIT: a family awaiting the reading turn is never released by a RESULT, so there is no
593
+ // state to read here and no seeded prior state that could release it on a warm followup either.
594
+ // The reading turn asks for it by minting a supplemental entry, which arrives as its own
595
+ // ungated entry — this one stands in the plan as the record of a question not asked.
596
+ if (awaitsReadingTurn(e.when)) { skipped.push(e.qid); continue; }
579
597
  if (stateByQid.get(e.when.runs_if_enumerated) === "enumerated") await runEntry(e);
580
598
  else skipped.push(e.qid); // crowd/failed parent is TERMINAL for the fringe — by design, never an error
581
599
  }
@@ -0,0 +1,40 @@
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
+ // plan-guards.mjs — what a register plan entry's `when` guard means, read from one place.
5
+ //
6
+ // THE TWO WAITS A PLAN ENTRY CAN CARRY, and they are different questions.
7
+ //
8
+ // `runs_if_enumerated: <parent qid>` is a fact about a RESULT: this slice runs if its parent question
9
+ // came back as a list rather than a crowd. A crowd parent is terminal for it.
10
+ //
11
+ // `awaits_reading_turn: true` is a fact about JUDGMENT NOT YET MADE (ruled 2026-09-21): this family runs
12
+ // when the reading turn asks for it, having read the identical mark's own list and found it thin. No
13
+ // result releases it — not the identical question coming back as a comfortable list, not a clean
14
+ // zero, nothing. The ask arrives as a supplemental entry, so the guarded entry itself never runs: it
15
+ // stands in the plan as the record of a question deliberately not asked.
16
+ //
17
+ // WHY THIS IS ITS OWN MODULE, on the provider side, rather than a constant in the compiler. Both the
18
+ // COMPILER (driver/register-plan.mjs, which writes the guard and joins it back to the band) and the
19
+ // EXECUTOR (providers/_shared/execute-plan.mjs, which decides what to dispatch) must agree on what a
20
+ // guard means, and a guard the executor does not recognise is not a guard at all — the entry runs as
21
+ // though it were ungated, which is silently the opposite of what was asked for. The executor cannot
22
+ // import the compiler: nothing under providers/ imports driver/, and inverting that for one predicate
23
+ // would invert it for the whole provider tree. So the meaning lives here, where both sides can read
24
+ // it, and neither side carries a second copy to drift.
25
+
26
+ /** The guard a decision-10 family carries while it waits for the reading turn. Frozen: entries spread a copy. */
27
+ export const AWAITS_READING_TURN = Object.freeze({ awaits_reading_turn: true });
28
+
29
+ /** Is this guard the ruling-204 wait — the one no result can release? */
30
+ export const awaitsReadingTurn = (when) => when?.awaits_reading_turn === true;
31
+
32
+ /**
33
+ * The parent qid a guard waits on, or null where it waits on no question at all.
34
+ *
35
+ * Every caller that resolves a guard against a band, validates it against the plan's qids, or reports
36
+ * which question held an entry back goes through this. A ruling-204 wait names no qid, and a caller
37
+ * reading `when.runs_if_enumerated` directly would get `undefined` and then decide something about it
38
+ * — orphan it, look it up and miss, or print it.
39
+ */
40
+ export const guardParentQid = (when) => (awaitsReadingTurn(when) ? null : when?.runs_if_enumerated ?? null);
@@ -97,8 +97,7 @@ export const CAPABILITIES = Object.freeze({
97
97
  // fails loud with tooManyResults past 30000. There is no partial mode and no cursor.
98
98
  pagination: "single-shot",
99
99
  // …which is exactly why the enumerate ceiling must be tested BEFORE the search, via the cheap
100
- // POST /count — it works at ANY magnitude (209012 returned without complaint) and returns per-office
101
- // counts in one call.
100
+ // POST /count — it works at ANY magnitude and returns per-office counts in one call.
102
101
  countProbe: "endpoint",
103
102
  // A count CAN be narrowed to live filings here — queryOptions.activeOnly (buildSearchRequest's
104
103
  // `active_only`). Declared because it diverges from corsearch, which has no status clause at all;
@@ -107,7 +106,8 @@ export const CAPABILITIES = Object.freeze({
107
106
  countStatusFilter: "live",
108
107
  // JSON body, not a URI: the bound is the parser's own document-nesting cap, which the vendor names in
109
108
  // the refusal it answers a stack wider than this with. Not a URI length, so widening is not the fix.
110
- maxOrWidth: 500,
109
+ // A 498-term stack is refused at that cap and a 496-term stack is not, so 496 is the declared width.
110
+ maxOrWidth: 496,
111
111
  // ONE call: INT_CLASS_NUMBER value "9 OR 28 OR 41 OR 42" (or "9,28,41,42") = the deduplicated union.
112
112
  classFilter: "native",
113
113
  // POST /text, EXACTLY 100 ids per call — a longer list is refused — and the call is BILLED: screening an
@@ -239,6 +239,16 @@ export const CAPABILITIES = Object.freeze({
239
239
  goodsTextMultiWord: "ordered-phrase",
240
240
  // Several goods terms ride ONE clause joined by OR — this register expresses a list natively.
241
241
  goodsTextListOr: true,
242
+ // ── THE SHORTEST TERM THE CONTAINS FORM ACCEPTS: none known, so none declared ───────────────────
243
+ //
244
+ // No vendor document available to this repository states one: the vendor publishes no public
245
+ // reference for this search API, and none was found on 2026-09-22. What is measured is narrower and is
246
+ // handled in core.js: a ONE-character first or last token inside a phrase chain is refused (HTTP
247
+ // 500), and two characters are answered. A two-letter `*TERM*` on its own is not recorded either way.
248
+ //
249
+ // `null` means the compiler keeps asking the contains form at every length, which is what this
250
+ // register has always been sent. A declared number must come from the vendor, not from a guess.
251
+ containsMinLength: null,
242
252
  // The operator the goods clause rides. `EQUALS` on WHOLE WORDS: `CONTAINS` is a hard 400 here
243
253
  // exactly as it is on APPLICANT_NAME, and so is a mid-word wildcard. Several words are asked for
244
254
  // with `OR` inside the value.
@@ -285,7 +295,7 @@ export const CAPABILITIES = Object.freeze({
285
295
  // WIRED: providers/clarivate/src/core.js builds its enumerate from
286
296
  // makeEnumerate({ capabilities: {...CAPABILITIES.kernel} }) — these values are the LIVE seam settings,
287
297
  // no longer a design note. pageGuard is 1 because /search is single-shot: there is no page 2 to
288
- // fetch, so the guard can only ever be a backstop. namesChunkDefault = maxOrWidth (500): the kernel
298
+ // fetch, so the guard can only ever be a backstop. namesChunkDefault = maxOrWidth (496): the kernel
289
299
  // chunks a wide OR-stack to the parser's nesting bound before it reaches the wire.
290
300
  kernel: Object.freeze({
291
301
  countProbe: "endpoint",
@@ -293,7 +303,7 @@ export const CAPABILITIES = Object.freeze({
293
303
  pageSize: 100,
294
304
  pageGuard: 1, // single-shot: there is no page 2
295
305
  ceilingDefault: 600,
296
- namesChunkDefault: 500,
306
+ namesChunkDefault: 496,
297
307
  providerWindow: "30000-result hard ceiling (tooManyResults, fails loud)",
298
308
  // POST /search returns BARE GUIDS — the search row carries no mark text, classes, status or owner.
299
309
  // POST /text (the screen call) is therefore the SOLE content source for an enumerated band, which