clearotron 0.3.2 → 0.3.3-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/CONTRIBUTING.md +12 -0
  2. package/INSTALL.md +8 -0
  3. package/bin/onboard.mjs +109 -13
  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 +29 -11
  9. package/driver/CHANGELOG.md +49 -0
  10. package/driver/citation-census.json +3 -3
  11. package/driver/clearance-variants-record.mjs +12 -1
  12. package/driver/common-law-coverage-status.mjs +113 -0
  13. package/driver/config-inventory.mjs +1 -1
  14. package/driver/contract-audit.mjs +1 -1
  15. package/driver/contract-e3-backlog.mjs +37 -37
  16. package/driver/contract-vocabulary.mjs +8 -8
  17. package/driver/coverage-form-io.mjs +3 -1
  18. package/driver/coverage-form.mjs +38 -11
  19. package/driver/coverage-ledger.mjs +37 -7
  20. package/driver/coverage-union.mjs +2 -2
  21. package/driver/crowd-context.mjs +19 -6
  22. package/driver/dev-portal.mjs +3 -3
  23. package/driver/drainer-identity.mjs +1 -1
  24. package/driver/driver.config.mjs +80 -9
  25. package/driver/engine/CONTRACT.md +3 -2
  26. package/driver/engine/anthropic-agent.mjs +34 -7
  27. package/driver/engine/mcp/clarivate-server.mjs +4 -2
  28. package/driver/engine/mcp/corsearch-server.mjs +3 -1
  29. package/driver/engine/mcp/coverage-server.mjs +1 -1
  30. package/driver/engine/mcp/dispositions-server.mjs +47 -5
  31. package/driver/engine/mcp/euipo-server.mjs +2 -0
  32. package/driver/engine/mcp/free-tier-server.mjs +2 -0
  33. package/driver/engine/mcp/gather-config.mjs +8 -2
  34. package/driver/engine/mcp/probe-server.mjs +37 -0
  35. package/driver/engine/mcp/proposal-fields.mjs +45 -0
  36. package/driver/engine/mcp/recording-server.mjs +30 -0
  37. package/driver/engine/mcp/signa-server.mjs +2 -0
  38. package/driver/engine/mcp/supplemental.mjs +89 -12
  39. package/driver/engine/mcp/unit-note-server.mjs +50 -0
  40. package/driver/engine/mcp/uspto-local-server.mjs +2 -0
  41. package/driver/engine/openai-agent.mjs +7 -0
  42. package/driver/engine/probe.mjs +67 -14
  43. package/driver/engine/tool-refusal.mjs +16 -0
  44. package/driver/enqueue-schema.mjs +2 -2
  45. package/driver/envelope-settle.mjs +82 -13
  46. package/driver/findings-model.mjs +4 -4
  47. package/driver/gateway.mjs +18 -2
  48. package/driver/manager-groups-verdict.mjs +1 -1
  49. package/driver/matter-frame-record.mjs +24 -7
  50. package/driver/named-band.mjs +1 -1
  51. package/driver/package.json +1 -1
  52. package/driver/partial-payload-baseline.json +12 -3
  53. package/driver/pipeline-knockout.mjs +3 -3
  54. package/driver/pipeline.mjs +154 -50
  55. package/driver/plan-run-agreement-verdict.mjs +49 -0
  56. package/driver/portal-service.mjs +8 -4
  57. package/driver/progress.mjs +14 -3
  58. package/driver/publish/index.mjs +41 -26
  59. package/driver/publish/report-data.mjs +4 -3
  60. package/driver/publish/xlsx.mjs +26 -4
  61. package/driver/queue-markers.mjs +44 -0
  62. package/driver/queue-watch-verdict.mjs +2 -2
  63. package/driver/reference-score.mjs +10 -2
  64. package/driver/register-availability.mjs +2 -2
  65. package/driver/register-plan.mjs +313 -21
  66. package/driver/roster-verdict.mjs +1 -1
  67. package/driver/runner.mjs +26 -2
  68. package/driver/settle-stamp.mjs +10 -3
  69. package/driver/skills/clearance-common-law/SKILL.md +2 -0
  70. package/driver/skills/clearance-register/SKILL.md +44 -3
  71. package/driver/skills/clearance-register/digest.md +5 -5
  72. package/driver/skills/clearance-register/providers/clarivate.md +1 -1
  73. package/driver/skills/clearance-register/unit.md +39 -0
  74. package/driver/skills/clearance-variants/SKILL.md +1 -1
  75. package/driver/skills/matter-frame/SKILL.md +4 -2
  76. package/driver/stages.mjs +12 -5
  77. package/driver/status-snapshot.mjs +2 -2
  78. package/driver/suite-census.json +293 -29
  79. package/driver/synthesis-record.mjs +80 -2
  80. package/driver/unit-file-drift.mjs +3 -3
  81. package/driver/unit-inventory.mjs +2 -2
  82. package/driver/unit-state-verdict.mjs +1 -1
  83. package/driver/updater-identity.mjs +2 -3
  84. package/driver/variant-manifest-model.mjs +11 -1
  85. package/driver/verify.mjs +5 -5
  86. package/driver/withheld-families.mjs +104 -0
  87. package/mcp-server/CHANGELOG.md +8 -0
  88. package/mcp-server/lib/brief.mjs +16 -12
  89. package/mcp-server/lib/runs.mjs +1 -1
  90. package/mcp-server/package.json +1 -1
  91. package/mcp-server/server.mjs +3 -2
  92. package/package.json +2 -2
  93. package/portal-ui/dist/assets/{index-DMthc7PQ.js → index-GBbbyQxc.js} +22 -4
  94. package/portal-ui/dist/index.html +1 -1
  95. package/portal-ui/package.json +1 -1
  96. package/providers/_shared/count.mjs +2 -2
  97. package/providers/_shared/enumerate.mjs +15 -2
  98. package/providers/_shared/execute-plan.mjs +19 -1
  99. package/providers/_shared/plan-guards.mjs +40 -0
  100. package/providers/clarivate/src/capabilities.js +15 -5
  101. package/providers/clarivate/src/core.js +41 -5
  102. package/providers/corsearch/src/capabilities.js +4 -0
  103. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  104. package/providers/oauth-mcp-bridge/package.json +1 -1
  105. package/providers/signa/src/capabilities.js +22 -8
  106. package/providers/signa/src/core.js +12 -1
  107. package/scripts/demo-evidence.mjs +114 -0
  108. package/scripts/e2e.mjs +1 -1
  109. package/scripts/engine-probe.mjs +6 -5
  110. package/scripts/env-audit.mjs +1 -1
  111. package/scripts/freeze-example-run.mjs +3 -3
  112. package/scripts/live-surface-check.mjs +32 -33
  113. package/scripts/mint-suite-census.mjs +66 -0
  114. package/scripts/package-size-budget.mjs +117 -0
  115. package/scripts/register-plan-shape.mjs +259 -0
  116. package/scripts/release-note-required.mjs +38 -1
  117. package/scripts/repo-writes.mjs +1 -1
  118. package/scripts/report-sections-render-check.mjs +7 -3
  119. package/scripts/score.mjs +7 -1
  120. package/scripts/settings-render-check.mjs +36 -0
  121. package/scripts/travelling-predicates.mjs +1 -1
  122. package/shared/identifier-scan.mjs +22 -5
  123. package/shared/scroll-settle.mjs +67 -0
@@ -181,7 +181,13 @@ export function uncarriedCoverageLimits(rows, ledger) {
181
181
  if (!Array.isArray(ledger) || !ledger.length) return null;
182
182
  const limited = ledger.filter((r) => {
183
183
  const st = String(r?.status ?? "").trim();
184
- return st !== "" && st !== "confirmed-clean" && st !== "note";
184
+ // `withheld-by-judgment` sits beside `confirmed-clean` here, and the reason is the opposite one.
185
+ // A clean row has nothing to carry. A withheld row is a family the reading turn chose NOT to open,
186
+ // having read the identical question as a list and found what it needed — that is where the work
187
+ // was spent, not a slice the run could not clear, and the rule that nothing is added to the report keeps it out
188
+ // entirely. Demanding it be carried would put a "we did not search this" line in front of a lawyer
189
+ // about a decision that made the search better.
190
+ return st !== "" && st !== "confirmed-clean" && st !== "note" && st !== "withheld-by-judgment";
185
191
  });
186
192
  if (!limited.length) return null; // the run records no limit: nothing to carry
187
193
  const carried = (rows ?? []).some((r) => {
@@ -201,6 +207,66 @@ export function uncarriedCoverageLimits(rows, ledger) {
201
207
  * could assert either could waive its own contract — the same reason the reviewer's transport takes
202
208
  * `receiptPresent` from the driver rather than from the call.
203
209
  */
210
+ /**
211
+ * The coverage rows a client may see, with every withheld family's row removed. PURE.
212
+ *
213
+ * `withheld-by-judgment` is the reading turn saying it chose not to open a family — it read the
214
+ * identical question as a list, found what it needed, and spent the work there. That is a decision
215
+ * about where effort went, not a gap in the client's search, and the rule that nothing is added to the report (2026-09-18) keeps it in the run record
216
+ * and the coverage ledger alone.
217
+ *
218
+ * Applied here rather than trusted to the model, because the model is the surface that can get it
219
+ * wrong: it authors these rows, it has been told the family was withheld, and a row reading "we did
220
+ * not search this" in front of a lawyer is the sentence that rule forbids — about a decision that
221
+ * made the search better. The LEDGER is the authority on which families were withheld.
222
+ *
223
+ * A row for a family the ledger holds as anything else is untouched, and that direction matters more
224
+ * than this one: a documented limit dropped by accident is a disclosure the reader is owed and does
225
+ * not get.
226
+ */
227
+ export function withoutWithheldRows(rows, ledger) {
228
+ // A FAMILY IS ONLY WITHHELD IF THE LEDGER HOLDS NOTHING ELSE ABOUT IT. A family can be withheld at
229
+ // the axis level AND still carry a documented limit on one of its slices — measured: an axis whose
230
+ // whole-axis row is withheld while `primary-sweep (exact: <mark> [cl 25])` is a disclosed limit the
231
+ // synthesis gate then demands be carried. Dropping by family name alone deleted the carrying row and
232
+ // the run failed, which is the worse error stated twice over: a disclosure the reader is owed, gone,
233
+ // and a run that does not deliver.
234
+ //
235
+ // So a family qualifies only when EVERY ledger row for it is withheld. One row saying anything else
236
+ // keeps the whole family's rows, and that is the direction to fail in.
237
+ const byFamily = new Map();
238
+ for (const r of (Array.isArray(ledger) ? ledger : [])) {
239
+ const axis = String(r?.axis ?? "").trim().toLowerCase();
240
+ if (!axis) continue;
241
+ const isWithheld = String(r?.status ?? "").trim() === "withheld-by-judgment";
242
+ byFamily.set(axis, (byFamily.get(axis) ?? true) && isWithheld);
243
+ }
244
+ const withheld = new Set([...byFamily.entries()].filter(([, only]) => only).map(([axis]) => axis));
245
+ if (!withheld.size) return Array.isArray(rows) ? rows : [];
246
+
247
+ // ── THE AXIS IS NAMED IN A SEGMENT, NOT ALWAYS THE FIRST ONE ──────────────────────────────────
248
+ //
249
+ // This read `area.split("/")[0]` and was inert for the two shapes that matter most, failing OPEN —
250
+ // toward the client, which is the wrong direction for a gate whose whole job is to keep a row off
251
+ // the page. Measured against the real strings:
252
+ //
253
+ // `incumbent-class (entire axis)` no slash at all — the whole-axis row a client reads
254
+ // `Register / incumbent-class` the axis is the SECOND segment; the first is a layer
255
+ // `incumbent-class / extra script group` the only shape the first read caught
256
+ //
257
+ // So every `/`-separated segment is considered, with a trailing parenthetical stripped, and each is
258
+ // compared by EQUALITY against the axis names the ledger actually holds.
259
+ //
260
+ // Equality on a segment, never `includes`: a substring test over an open vocabulary is how one axis
261
+ // name once matched two unrelated areas, and here a false match DROPS a row — a documented limit the
262
+ // reader is owed, gone silently. The failure this gate prevents is a withheld row appearing; the
263
+ // failure it must not cause is a disclosure disappearing, and that one is worse.
264
+ const namesIn = (area) => String(area ?? "").toLowerCase().split("/")
265
+ .map((seg) => seg.replace(/\([^)]*\)/g, " ").trim())
266
+ .filter(Boolean);
267
+ return (Array.isArray(rows) ? rows : []).filter((r) => !namesIn(r?.area).some((n) => withheld.has(n)));
268
+ }
269
+
204
270
  export function acceptSynthesis(params, { asks = [], ledger = null, manifest = null, owed = null, declined = null } = {}) {
205
271
  const doc = params?.findings;
206
272
  if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
@@ -228,7 +294,19 @@ export function acceptSynthesis(params, { asks = [], ledger = null, manifest = n
228
294
  // Two copies that must agree is a second-authoring defect, so there are not two. The record is the
229
295
  // machine contract and the narrative's coverage list is RENDERED from it. Disagreement is not detected;
230
296
  // it is impossible.
231
- const rows = Array.isArray(doc.coverage) ? doc.coverage : [];
297
+ // ── NOTHING ADDED TO THE REPORT: A FAMILY THE READING TURN DID NOT OPEN STAYS OFF IT, WHATEVER THE MODEL WROTE ──
298
+ //
299
+ // `withheld-by-judgment` is the reading turn saying it chose not to open a family — it read the
300
+ // identical question as a list, found what it needed, and spent the work there instead. That is a
301
+ // decision about where effort went, not a gap in the client's search, and it belongs to the run
302
+ // record and the coverage ledger alone.
303
+ //
304
+ // Dropped HERE rather than trusted to the model, because the model is the one surface that can get
305
+ // it wrong: it writes the coverage rows, it has been told the family was withheld, and a row saying
306
+ // "we did not search this" in front of a lawyer is exactly the sentence that rule forbids — about a
307
+ // decision that made the search better. The ledger is the authority on which families were withheld,
308
+ // so a row whose family it holds as withheld goes, however the row was authored.
309
+ const rows = withoutWithheldRows(Array.isArray(doc.coverage) ? doc.coverage : [], ledger);
232
310
  if (n.coverage?.rows !== undefined) {
233
311
  return { ok: false, reason: "synthesis_coverage_rows_misplaced: coverage rows belong in `findings.coverage`, not on the narrative — the driver renders the narrative's coverage list from the record, so there is one authored set and no way for the client's readable statement and the machine record to disagree. Send `narrative.coverage.read` for the prose" };
234
312
  }
@@ -63,7 +63,7 @@ export function unitBody(text) {
63
63
  * driver/unit-files.mjs wherever it sits, or null when the repo tracks none
64
64
  * dropIns — array of drop-in paths (may be empty)
65
65
  * @param {{ok: boolean, why?: string}} probe — could the caller enumerate at all?
66
- * @returns {{state: "pass"|"fail"|"skip", message: string, drifted: string[], templated: string[]}}
66
+ * @returns {{state: "pass"|"fail"|"skip", blocked?: true, message: string, drifted: string[], templated: string[]}}
67
67
  *
68
68
  * THREE OUTCOMES, and the third is the one this file exists for. "Could not look" is never "nothing is
69
69
  * wrong" — the lesson, applied to the arm that has the same failure mode one layer down: a unit
@@ -72,7 +72,7 @@ export function unitBody(text) {
72
72
  */
73
73
  export function unitFileDriftVerdict({ units = [], probe = { ok: true } } = {}) {
74
74
  if (!probe.ok) {
75
- return { state: "skip", drifted: [],
75
+ return { state: "skip", blocked: true, drifted: [],
76
76
  message: `could not enumerate systemd units, so no unit file was COMPARED — ${probe.why ?? "no reason given"}. `
77
77
  + "This is a failure to look, not a finding about the deployment" };
78
78
  }
@@ -80,7 +80,7 @@ export function unitFileDriftVerdict({ units = [], probe = { ok: true } } = {})
80
80
  const unreadable = units.filter((u) => u && u.live == null);
81
81
  const untracked = units.filter((u) => u && u.live != null && u.tracked == null);
82
82
  if (!comparable.length) {
83
- return { state: "skip", drifted: [],
83
+ return { state: "skip", blocked: true, drifted: [],
84
84
  message: `systemd answered and no unit could be compared to a tracked file (${units.length} unit(s) seen, `
85
85
  + `${unreadable.length} with no readable fragment, ${untracked.length} with no tracked unit file `
86
86
  + "anywhere in the tree) — nothing was checked" };
@@ -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,13 @@
1
1
  # trademark-artifacts-mcp
2
2
 
3
+ ## 0.3.3-beta.1
4
+
5
+ No changes in this release.
6
+
7
+ ## 0.3.3-beta.0
8
+
9
+ No changes in this release.
10
+
3
11
  ## 0.3.2
4
12
 
5
13
  ### Patch Changes
@@ -44,6 +44,11 @@ function plainClause(s) {
44
44
  }
45
45
 
46
46
  const titleCase = (w) => (w ? w.charAt(0) + w.slice(1).toLowerCase() : w);
47
+ // A band arrives as its word ("High") or as the derived record publish writes for the run's verdict
48
+ // ({ label, rankFromTop, scale }). Stringifying the record printed "Overall risk: [object object]."
49
+ // on the assistant's summary; every band read here goes through this, so either shape gives the word.
50
+ const bandLabel = (b) => (b && typeof b === "object" ? (typeof b.label === "string" && b.label.trim() ? b.label : null)
51
+ : (b == null || String(b).trim() === "" ? null : String(b)));
47
52
 
48
53
  // One clearance finding → one line. NO SECOND FILTER: report-data.json already carries only the live
49
54
  // findings (a withdrawn one is not in the file), so the brief's conflicts and the report's findings are
@@ -51,7 +56,7 @@ const titleCase = (w) => (w ? w.charAt(0) + w.slice(1).toLowerCase() : w);
51
56
  // shorter list than the document the client was holding.
52
57
  function clearanceLine(f) {
53
58
  const who = [f.mark, f.owner?.name].filter(Boolean).join(" — ") || "(unnamed finding)";
54
- const band = f.band ? ` — ${titleCase(String(f.band))} risk.` : "";
59
+ const band = bandLabel(f.band) ? ` — ${titleCase(bandLabel(f.band))} risk.` : "";
55
60
  return `- **${who}**${band}${f.net ? ` ${f.net}` : ""}`.trimEnd();
56
61
  }
57
62
 
@@ -77,7 +82,8 @@ export function buildBrief(run) {
77
82
  // THE BAND, AND NEVER THE GATE'S WORD. This chain used to fall through to the delivery verdict — the
78
83
  // sidecar's `verdict`, then the run's — so a run whose report reads Medium could be briefed as BLOCKING.
79
84
  // The band is what the report shows; where no band is recorded the line is not drawn at all.
80
- const overall = clearance?.verdict?.band ?? clearance?.verdict?.tier
85
+ const rating = clearance?.rating ?? clearance?.verdict ?? null; // `verdict` on a report-data file written before the rename
86
+ const overall = bandLabel(rating?.band) ?? rating?.tier
81
87
  ?? (koDocs.length === 1 ? (koDocs[0].overall ?? null) : null)
82
88
  ?? fm.overall_label ?? run.tier ?? null;
83
89
 
@@ -92,9 +98,8 @@ export function buildBrief(run) {
92
98
  const tail = [product, dateStr ? `run ${dateStr}` : null].filter(Boolean).join(", ");
93
99
  lines.push(`**${subject}**${tail ? ` — ${tail}` : ""}.`);
94
100
 
95
- const caption = clearance
96
- ? (clearance.verdict?.statement ?? clearance.caption ?? "")
97
- : plainClause(fm.overall_caption ?? "");
101
+ // The report's own conclusion, verbatim, after the rating (ruled 2026-09-22: the report is the master).
102
+ const caption = clearance ? (clearance.caption ?? "") : plainClause(fm.overall_caption ?? "");
98
103
  if (overall) lines.push(`**Overall risk: ${titleCase(String(overall))}.** ${caption}`.trim());
99
104
  if (run.state) {
100
105
  // a park is paused, not finished — say so plainly, and name the clock only where one exists
@@ -104,9 +109,8 @@ export function buildBrief(run) {
104
109
  : run.state === "recovering" ? ` — auto-recovery backoff, resumes ${run.recoveryResumesAt ? `at ${String(run.recoveryResumesAt).replace("T", " ").slice(0, 16)} UTC` : "on its own"}`
105
110
  : run.state === "parked-for-human" ? ` — parked by a runner stop (deploy/restart), resumes on the next runner activation` : "";
106
111
  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));
112
+ // No second summary here: the rating and the report's conclusion above are the run's answer. The
113
+ // composed statement said "on hold" on runs nothing held.
110
114
  }
111
115
 
112
116
  let source = "none";
@@ -120,7 +124,7 @@ export function buildBrief(run) {
120
124
  }
121
125
  // Conditions gate a clean result; advisories never do, so only the conditions ride the briefing.
122
126
  const conditions = [
123
- ...(clearance.verdict?.conditions ?? []),
127
+ ...(rating?.conditions ?? []),
124
128
  ...(clearance.actions?.conditions ?? []).map((a) => a?.text),
125
129
  ].filter(Boolean);
126
130
  if (conditions.length) {
@@ -137,7 +141,7 @@ export function buildBrief(run) {
137
141
  lines.push("", "**Each name screened:**");
138
142
  for (const d of koDocs) {
139
143
  for (const m of (d.marks ?? [])) {
140
- const band = m.band ? `${titleCase(String(m.band))}${m.qualifier ? ` (${m.qualifier})` : ""}` : "unrated";
144
+ const band = bandLabel(m.band) ? `${titleCase(bandLabel(m.band))}${m.qualifier ? ` (${m.qualifier})` : ""}` : "unrated";
141
145
  lines.push(`- **${m.name}** — ${band}.${d.url ? ` Report: ${d.url}` : ""}`);
142
146
  for (const f of (m.findings ?? [])) {
143
147
  const who = [f.name, f.owner].filter(Boolean).join(" — ");
@@ -151,7 +155,7 @@ export function buildBrief(run) {
151
155
  // was: a typed conflict already leads with its own band on the report, and widening this to all
152
156
  // findings would change what this briefing says about runs that have no register layer at all.
153
157
  if (f.shape === "register") {
154
- const rating = f.band ? ` — ${titleCase(String(f.band))} risk.` : "";
158
+ const rating = bandLabel(f.band) ? ` — ${titleCase(bandLabel(f.band))} risk.` : "";
155
159
  const read = f.basis && f.basis !== f.net ? ` ${f.basis}` : "";
156
160
  lines.push(` - ${who}${rating}${f.net ? ` ${f.net}` : ""}${read}`.trimEnd());
157
161
  continue;
@@ -204,7 +208,7 @@ export function buildBrief(run) {
204
208
  return {
205
209
  runId: run.runId, markName: run.markName ?? clearance?.markName ?? fm.title ?? null,
206
210
  product,
207
- overall, tier: run.tier ?? null, statement: run.statement ?? null, state: run.state ?? null, date: run.date ?? null,
211
+ overall, tier: run.tier ?? null, caption: run.caption ?? null, state: run.state ?? null, date: run.date ?? null,
208
212
  source, brief: lines.join("\n"),
209
213
  };
210
214
  }
@@ -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.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.",
@@ -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.1",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
7
7
  "type": "git",
@@ -66,7 +66,7 @@
66
66
  "assert-census": "node scripts/unexecuted-asserts.mjs"
67
67
  },
68
68
  "devDependencies": {
69
- "@changesets/cli": "3.0.2",
69
+ "@changesets/cli": "3.0.3",
70
70
  "@types/node": "^26",
71
71
  "acorn": "^8.18.0",
72
72
  "eslint": "^10.8.1",
@@ -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.1",
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.