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
@@ -497,6 +497,19 @@ serve({
497
497
  },
498
498
  watchlist_owners: { type: "array", items: { type: "string" },
499
499
  description: "Real register owners the plan compiles owner lanes from — never sectors or descriptions." },
500
+ goods_words: { type: "array", items: { type: "string" },
501
+ description:
502
+ "The words the register search is narrowed to: the client's own goods and services wording " +
503
+ "first, then the words other filings use for the same goods that it does not already " +
504
+ "contain. Single words or short phrases as a specification would write them, no wildcards, " +
505
+ "at most 24. Each is matched against the goods and services description of registered " +
506
+ "marks, so use words a specification would contain; a word broader than the goods widens " +
507
+ "the search instead of narrowing it. The register cannot read the words and, or, not, adj " +
508
+ "or near inside an item — an item containing one is searched without it. Send an EMPTY " +
509
+ "LIST when you have considered the goods and no word is worth narrowing by; omit the " +
510
+ "field only when the matter states no goods at all. Those are different answers and the " +
511
+ "run records which one you gave.",
512
+ },
500
513
  scope_ledger: {
501
514
  type: "array",
502
515
  description:
@@ -826,6 +839,23 @@ serve({
826
839
  },
827
840
  },
828
841
  },
842
+ // THE SAME DEFECT TWICE MORE, and the same fix. The dispatch asks for both of these in its own
843
+ // imperative, the acceptor validates and records them, and the plan compile reads the classes —
844
+ // and until now neither was a property here, so a frame following its schema could send neither.
845
+ // A real run's frame carried no added class although the goods reached one. Shape only: the
846
+ // dispatch already says what to send and why, so no sentence the model reads is added here.
847
+ identified_classes: {
848
+ type: "array",
849
+ items: {
850
+ type: "object",
851
+ required: ["class", "reason"],
852
+ properties: {
853
+ class: { type: "integer", minimum: 1, maximum: 45 },
854
+ reason: { type: "string" },
855
+ },
856
+ },
857
+ },
858
+ ratified_forms: { type: "array", items: { type: "string" } },
829
859
  // OFFERED HERE, OR NEVER SENT. The acceptor took this field for a beta and the plan acted on it,
830
860
  // and no frame ever proposed one, because the schema a model is given did not offer it. Worded as
831
861
  // the owner approved it; it is model-facing prose, so its wording is his.
@@ -11,6 +11,7 @@
11
11
  import { serve } from "./stdio-server.mjs";
12
12
  import { doSearch, doRecordFetch, doEnumerate, doExecutePlan, DEFAULT_BASE } from "../../../providers/signa/src/core.js";
13
13
  import { proposeSupplemental } from "./supplemental.mjs";
14
+ import { narrowingFields } from "./proposal-fields.mjs";
14
15
  import { CAPABILITIES } from "../../../providers/signa/src/capabilities.js";
15
16
 
16
17
  const API_KEY = process.env.SIGNA_API_KEY || "";
@@ -101,6 +102,7 @@ serve({
101
102
  romanization: { type: "string", description: "The Latin-script form of a NON-LATIN term. On THIS source it is NOT used to rescue the slice — nativeScriptIndex is true, so the characters are sent as themselves and the romanisation is carried for the reader only." },
102
103
  owner: { type: "string", description: "OPTIONAL owner scope field on a MARK-TEXT proposal: the owner×term intersection, served by filters.owner_name in the same request. Not allowed on predicate:owner (there the owner name IS the term)." },
103
104
  nice_classes: { type: "array", items: {} },
105
+ ...narrowingFields(), // the narrowing fields every register serves (proposal-fields.mjs)
104
106
  rationale: { type: "string" },
105
107
  term_literal: { type: "boolean", description: "TRUE only when the term genuinely IS the mark verbatim (a multi-word slogan mark, a mark carrying an anchored star) — it bypasses the term-shape lint. Never use it to push a label through." },
106
108
  } } },
@@ -30,7 +30,7 @@
30
30
  import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from "node:fs";
31
31
  import { dirname, join } from "node:path";
32
32
  import { driverDir } from "../../../shared/driver-dir.mjs"; //
33
- import { PLAN_PREDICATES, PLAN_MAX_OR_WIDTH, PLAN_MAX_NAME_LENGTH, fingerprint, ownerIntersectionGap, resolveRegions, houseElementOf, withoutHouseElementTerms } from "../../register-plan.mjs";
33
+ import { PLAN_PREDICATES, PLAN_MAX_OR_WIDTH, PLAN_MAX_NAME_LENGTH, fingerprint, ownerIntersectionGap, goodsTextGap, resolveRegions, houseElementOf, withoutHouseElementTerms, containsFormSubstitution } from "../../register-plan.mjs";
34
34
  import { entryTermIssues } from "../../../providers/_shared/term-shape.mjs";
35
35
  import { isNonLatinTerm, romanizationRefusal, romanizationSpellings, nativeScriptIndexGap } from "../../../providers/_shared/script-form.mjs";
36
36
 
@@ -64,13 +64,37 @@ export function mintSupplementalEntries(axis, proposals, { existingQids = new Se
64
64
  // first, non-priority ones keep their relative order behind them. The cap VALUES are unchanged
65
65
  // (no count threshold moves), and with no priorityClasses the order is byte-identical to before.
66
66
  const prio = new Set((priorityClasses ?? []).map((c) => String(c).trim()).filter(Boolean));
67
- const indexed = (proposals ?? []).map((p, i) => ({ p: p ?? {}, i }));
67
+ // ONE QUESTION PER GOODS WORD where the register cannot offer alternatives in one goods clause — the
68
+ // compiler's own rule for such a register (register-plan.mjs, goodsTextListOr). Joined, the words would
69
+ // be intersected and the answer would narrow as the list grew; the connector refuses that shape
70
+ // outright. Each word keeps the proposal's index, so a refusal still names the proposal it came from.
71
+ const perWord = capabilities?.goodsTextSearch === true && capabilities?.goodsTextListOr === false;
72
+ // ONE QUESTION PER NAME where the register has no OR at all (maxOrWidth 1) — the compiler's own width
73
+ // for such a register. A batch of names went out as windows of one name each under ONE question, and
74
+ // the enumerate kernel stopped the whole batch at the first name that crowded or failed, so every name
75
+ // after it was never sent. Split, each name is its own question: its own count, its own crowd decision
76
+ // and its own receipt row. The names of one batch still spend ONE slot of the per-call and per-axis
77
+ // caps between them, as the batch did; `split_of` names the batch so the next call counts it once.
78
+ const perName = Number(capabilities?.maxOrWidth) === 1;
79
+ const indexed = (proposals ?? []).flatMap((p, i) => {
80
+ const names = perName && Array.isArray(p?.terms) ? p.terms.map((t) => String(t ?? "").trim()).filter(Boolean) : [];
81
+ const splitOf = names.length > 1 && names.length <= PLAN_MAX_OR_WIDTH
82
+ ? fingerprint({ axis, predicate: String(p?.predicate ?? "default"), names: [...names].sort(), i }) : null;
83
+ const byName = splitOf ? names.map((t) => { const { terms: _batch, ...rest } = p; return { ...rest, term: t }; }) : [p ?? {}];
84
+ return byName.flatMap((q) => {
85
+ const words = perWord && Array.isArray(q?.goods_words) ? q.goods_words.map((w) => String(w ?? "").trim()).filter(Boolean) : [];
86
+ return words.length > 1
87
+ ? words.map((w, k) => ({ p: { ...q, goods_words: [w] }, i, slot: `${i}:${k}`, splitOf }))
88
+ : [{ p: q, i, slot: `${i}:0`, splitOf }];
89
+ });
90
+ });
91
+ const slots = new Set();
68
92
  const ordered = prio.size
69
93
  ? [...indexed.filter(({ p }) => inPriority(p, prio)), ...indexed.filter(({ p }) => !inPriority(p, prio))]
70
94
  : indexed;
71
- for (const { p, i } of ordered) {
95
+ for (const { p, i, slot, splitOf } of ordered) {
72
96
  const issue = (msg) => rejected.push({ index: i, issue: msg, proposal: compactProposal(p) });
73
- if (minted.length >= perCall) { issue(`per-call cap ${perCall} reached`); continue; }
97
+ if (!slots.has(slot) && slots.size >= perCall) { issue(`per-call cap ${perCall} reached`); continue; }
74
98
  const predicate = String(p.predicate ?? "default");
75
99
  if (!PLAN_PREDICATES.includes(predicate)) { issue(`unknown predicate "${predicate.slice(0, 20)}" (one of: ${PLAN_PREDICATES.join(", ")})`); continue; }
76
100
  const terms = Array.isArray(p.terms) ? p.terms.map((t) => String(t ?? "").trim()).filter(Boolean) : null;
@@ -107,6 +131,15 @@ export function mintSupplementalEntries(axis, proposals, { existingQids = new Se
107
131
  if (!isNonLatinTerm(term)) { issue(`a romanization belongs ONLY on a non-Latin term — "${term.slice(0, 40)}" is already Latin script, so this romanization transliterates a DIFFERENT string; drop it or fix the term`); continue; }
108
132
  romanizedTerms = romanizationSpellings(romanRaw);
109
133
  }
134
+ // THE GOODS NARROWING, validated where every other field is. Whole words or short phrases as a
135
+ // specification writes them; a wildcard is a hard refusal on this field at the register, and an
136
+ // operator word inside an item is stripped downstream by the shared reader, so nothing here needs
137
+ // to invent a second rule for either.
138
+ const goodsWords = (Array.isArray(p.goods_words) ? p.goods_words : (typeof p.goods_words === "string" ? [p.goods_words] : []))
139
+ .map((w) => String(w ?? "").trim()).filter(Boolean);
140
+ if (p.goods_words != null && !goodsWords.length) { issue("goods_words, when present, must carry at least one word"); continue; }
141
+ if (goodsWords.some((w) => /[*?]/.test(w))) { issue("a goods word carries a wildcard, which this register refuses on that field"); continue; }
142
+ if (goodsWords.length && predicate === "owner") { issue("goods_words narrows a MARK-TEXT question; on predicate:owner the owner name is the term"); continue; }
110
143
  const term_literal = p.term_literal === true;
111
144
  // A1 — the same term-shape/term-predicate lint the plan freeze enforces, at the PROPOSAL seam:
112
145
  // the model gets the reason IN-TURN (rejected[]) and can re-propose the mark-shaped term — a
@@ -205,6 +238,19 @@ export function mintSupplementalEntries(axis, proposals, { existingQids = new Se
205
238
  for (const r of covered) regions.push(r);
206
239
  }
207
240
  }
241
+ // A CONTAINS-FORM PROPOSAL ON A TERM SHORTER THAN THE REGISTER'S FLOOR is asked on the exact form,
242
+ // the rule the compiler applies to its own goods-narrowed questions and saturation probes: the
243
+ // register refuses the contains form for a term that short, so the question as proposed would come
244
+ // back as an error rather than an answer. Same classes, goods words and scope, one entry for one,
245
+ // and the entry says so. Decided BEFORE the fingerprint, so the qid names the question actually
246
+ // asked. A stack is switched only when every member is below the floor; a mixed stack keeps the
247
+ // form the model chose, and its short members are refused and disclosed on their own.
248
+ let askedPredicate = predicate;
249
+ let substituted = null;
250
+ if (predicate === "default") {
251
+ const subs = (terms ?? [term]).map((t) => containsFormSubstitution(t, capabilities));
252
+ if (subs.length && subs.every(Boolean)) { askedPredicate = "exact"; substituted = subs[0]; }
253
+ }
208
254
  const anchor = terms ? terms[0] : term;
209
255
  // The fingerprint (⇒ the qid) deliberately EXCLUDES the romanization: the qid names the QUESTION
210
256
  // (which term, which predicate, which scope) and the romanisation is carriage, not a different
@@ -212,37 +258,68 @@ export function mintSupplementalEntries(axis, proposals, { existingQids = new Se
212
258
  // with the Latin form added — the exact wedge shape the regions inheritance above exists to kill.
213
259
  // Instead a re-proposal that adds a romanisation to a stored bare qid ENRICHES it (below), the
214
260
  // same field-level, never-term-changing merge extendRegisterPlan applies to the dictated plan.
215
- const fp = String(fingerprint({ predicate, term: term || null, terms: terms || null, nice_classes: nice, regions, ...(owner ? { owner } : {}) })).replace(/^fnv1a:/, "");
216
- const qid = `supp:${axis}:${predicate}:${slug(anchor)}:${fp.slice(0, 8)}`;
261
+ // …and it deliberately INCLUDES the goods narrowing, for the opposite reason. A goods-limited
262
+ // re-ask of a crowded question is the same term, predicate, classes and scope — it differs only by
263
+ // what the filings must cover. Excluded, it would mint the crowd's own qid and be read as a
264
+ // re-proposal of the question it exists to replace, so the first move against a crowd would
265
+ // silently become no move at all.
266
+ const fp = String(fingerprint({ predicate: askedPredicate, term: term || null, terms: terms || null, nice_classes: nice, regions,
267
+ ...(owner ? { owner } : {}), ...(goodsWords.length ? { goods: [...goodsWords].sort() } : {}) })).replace(/^fnv1a:/, "");
268
+ const qid = `supp:${axis}:${askedPredicate}:${slug(anchor)}:${fp.slice(0, 8)}`;
217
269
  if (existingQids.has(qid) || minted.some((e) => e.qid === qid)) {
218
270
  reused.push(qid);
219
271
  if (romanizedTerms && existingQids.has(qid)) enriched.push({ qid, term, romanizedTerms });
220
272
  continue;
221
273
  }
222
- if (budget <= 0) { issue(`per-axis cap ${axisMax} reached — assess whether an existing supplemental already covers this`); continue; }
223
- budget -= 1;
274
+ if (!slots.has(slot)) {
275
+ if (budget <= 0) { issue(`per-axis cap ${axisMax} reached — assess whether an existing supplemental already covers this`); continue; }
276
+ budget -= 1;
277
+ }
278
+ slots.add(slot);
224
279
  const entry = {
225
- qid, axis, predicate,
280
+ qid, axis, predicate: askedPredicate,
281
+ ...(substituted ? { contains_substituted: substituted } : {}),
226
282
  ...(terms ? { terms } : { term }),
227
283
  ...(romanizedTerms ? { romanizedTerms } : {}),
228
284
  ...(owner ? { owner } : {}),
285
+ // Carried as `goods_text`, the field the plan and every connector already speak — the tool calls
286
+ // it `goods_words` because that is what the manual and the manifest call it to the model.
287
+ ...(goodsWords.length ? { goods_text: goodsWords } : {}),
288
+ // What this question REPLACED. The ledger shows the crowd and its narrowing together, each with
289
+ // its own count, so a crowd never reads as a question nobody answered.
290
+ ...(typeof p.narrows === "string" && p.narrows.trim() ? { narrows: p.narrows.trim().slice(0, 200) } : {}),
229
291
  ...(term_literal ? { term_literal: true } : {}),
230
292
  nice_classes: nice, regions,
231
293
  expected_kind: "enumerate",
232
294
  origin: "supplemental",
295
+ ...(splitOf ? { split_of: splitOf } : {}),
233
296
  ...(typeof p.rationale === "string" && p.rationale.trim() ? { rationale: p.rationale.trim().slice(0, 200) } : {}),
234
297
  };
235
298
  // F1 — an owner×term slice on a provider that cannot intersect them is minted as an UNSUPPORTED
236
299
  // entry (→ the executor's deferred lane → a disclosed coverage row), exactly like a missing
237
300
  // predicate at compile time. Never rejected (the gap belongs on the record) and never silently
238
301
  // widened into an owner-less sweep.
239
- const ownerGap = ownerIntersectionGap(entry, capabilities);
240
- if (ownerGap) { entry.unsupported = true; entry.unsupported_reason = ownerGap; }
302
+ // …and a goods narrowing on a register that cannot search goods text is the same kind of gap, with
303
+ // the compiler's own reason (goodsTextGap): recorded, never run on the class alone, which would ask the
304
+ // crowd the narrowing exists to cut.
305
+ const gap = ownerIntersectionGap(entry, capabilities) ?? goodsTextGap(entry, capabilities);
306
+ if (gap) { entry.unsupported = true; entry.unsupported_reason = gap; }
241
307
  minted.push(entry);
242
308
  }
243
309
  return { minted, reused, rejected, enriched, narrowed };
244
310
  }
245
311
 
312
+ /** The per-axis cap's count of what is already on file: the names of one split batch are one slot. PURE. */
313
+ export function supplementalSlots(entries) {
314
+ const batches = new Set();
315
+ let n = 0;
316
+ for (const e of (entries ?? [])) {
317
+ if (e?.split_of) { if (!batches.has(e.split_of)) { batches.add(e.split_of); n += 1; } }
318
+ else n += 1;
319
+ }
320
+ return n;
321
+ }
322
+
246
323
  /**
247
324
  * Append mint-rejection rows to a supplemental-plan doc's `rejected[]` (append-only, beside
248
325
  * entries[] — one sidecar per axis holds BOTH what folded into the plan and what died at the seam,
@@ -326,7 +403,7 @@ export async function proposeSupplemental(params, tctx, deps) {
326
403
  return { type: "text", text: JSON.stringify({ minted: [], reused: [], rejected: [], excluded_house_element: excludedHouse, executed: false }, null, 2) };
327
404
  const existingQids = new Set(supp.entries.map((e) => e.qid));
328
405
  const { minted, reused, rejected, enriched, narrowed } = mintSupplementalEntries(axis, offered,
329
- { existingQids, perCall, axisMax, existingCount: supp.entries.length, capabilities: deps.capabilities ?? null, priorityClasses: planClasses });
406
+ { existingQids, perCall, axisMax, existingCount: supplementalSlots(supp.entries), capabilities: deps.capabilities ?? null, priorityClasses: planClasses });
330
407
 
331
408
  // Field-level romanisation enrichment of a REUSED qid (2026-07-30 review round): the natural retry —
332
409
  // a bare non-Latin proposal deferred at the wire, the model re-proposes it WITH the romanisation —
@@ -40,6 +40,7 @@
40
40
  // for it and this tool never guesses one.
41
41
  import { serve } from "./stdio-server.mjs";
42
42
  import { recordUnitNote } from "../../register-unit-record.mjs";
43
+ import { recordWithheldFamilies, WITHHELD_REASON_MAX } from "../../withheld-families.mjs";
43
44
 
44
45
  async function record_unit_note(params) {
45
46
  const runDir = String(process.env.CLEAROTRON_BAND_RUN_DIR ?? "");
@@ -66,6 +67,38 @@ async function record_unit_note(params) {
66
67
  }
67
68
  }
68
69
 
70
+ // ── THE FAMILIES THE READING TURN LEAVES UNASKED (withheld-families.mjs) ───────────────────────────
71
+ //
72
+ // Same server, same binding: the axis is the one the driver fanned this seat out for, and a payload
73
+ // naming another is refused. What it records is a judgment only this turn can make — which waiting
74
+ // families were not worth asking, and why — so it lives beside the turn's note rather than on the
75
+ // digest's coverage tool, which never saw the families.
76
+ async function record_withheld_families(params) {
77
+ const runDir = String(process.env.CLEAROTRON_BAND_RUN_DIR ?? "");
78
+ if (!runDir) return { isError: true, text: "ERROR: this server was started without a run — the driver wires it per run; there is no parameter for it and this tool never guesses one." };
79
+ const bound = String(process.env.CLEAROTRON_RECORD_AXIS ?? "").trim();
80
+ if (!bound) return { isError: true, text: "ERROR: this server was started without a bound axis — the driver binds the axis it fanned out for." };
81
+ const named = String(params?.axis ?? "").trim();
82
+ if (named && named !== bound)
83
+ return { isError: true, text: `ERROR: unit_axis_not_yours:${named} — you are the seat for axis "${bound}". Send "${bound}" or omit the field.` };
84
+ try {
85
+ const r = recordWithheldFamilies(runDir, { axis: bound, families: params?.families });
86
+ if (r.refused) return { isError: true, text: `REFUSED: ${r.refused}` };
87
+ if (r.write_failed) return { isError: true, text: `ERROR: the driver could not store this record (${r.write_failed}). This is a driver fault — do not re-type it.` };
88
+ const refused = r.rejected.map((x) => `- ${x.qid || "(no qid)"}: ${x.issue}`);
89
+ const left = r.still_to_judge;
90
+ return { isError: !r.recorded.length && refused.length > 0, text: [
91
+ `Recorded ${r.recorded.length} famil${r.recorded.length === 1 ? "y" : "ies"} as withheld-by-judgment on axis "${r.axis}".`,
92
+ ...(refused.length ? ["Refused:", ...refused] : []),
93
+ left.length
94
+ ? `Still to judge on this axis — waiting, not asked, no reason recorded (${left.length}): ${left.slice(0, 40).join(", ")}${left.length > 40 ? ", …" : ""}`
95
+ : "Every waiting family on this axis is now asked or recorded.",
96
+ ].join("\n") };
97
+ } catch (e) {
98
+ return { isError: true, text: `ERROR: the driver could not record this call (${String(e?.message ?? e).slice(0, 200)}). This is a driver fault — do not re-type it.` };
99
+ }
100
+ }
101
+
69
102
  serve({
70
103
  name: "unit-note", version: "0.1.0",
71
104
  tools: [{
@@ -84,5 +117,22 @@ serve({
84
117
  } },
85
118
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
86
119
  handler: record_unit_note,
120
+ }, {
121
+ name: "record_withheld_families",
122
+ description:
123
+ "Record the WAITING families on this axis that you decided NOT to ask, each with your reason. A family you " +
124
+ "do not ask was never searched: it is recorded withheld-by-judgment, and the reason goes into the run's " +
125
+ "record and the audit workbook, never into the report. Every waiting family must end this run either asked " +
126
+ "or recorded here — one nobody judged holds up delivery. One reason may cover several families. The answer " +
127
+ "lists the waiting families on this axis that are still neither asked nor recorded.",
128
+ inputSchema: { type: "object", required: ["families"], properties: {
129
+ families: { type: "array", items: { type: "object", required: ["qids", "reason"], properties: {
130
+ qids: { type: "array", items: { type: "string" }, description: "The qids of waiting families on this axis, exactly as the dispatch lists them." },
131
+ reason: { type: "string", description: `Why these were not asked, in a lawyer's words (at most ${WITHHELD_REASON_MAX} characters). The audit workbook prints it.` },
132
+ } } },
133
+ axis: { type: "string", description: "Optional, and checked rather than trusted: the driver binds the axis it dispatched you for." },
134
+ } },
135
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
136
+ handler: record_withheld_families,
87
137
  }],
88
138
  });
@@ -36,6 +36,7 @@ import {
36
36
  CAPABILITIES, DEFAULT_DB_ENV, doSearch, doRecordFetch, doBatchScreen, doEnumerate, doExecutePlan,
37
37
  } from "../../../providers/uspto-local/src/core.js";
38
38
  import { proposeSupplemental } from "./supplemental.mjs";
39
+ import { narrowingFields } from "./proposal-fields.mjs";
39
40
 
40
41
  const DB_PATH = process.env[DEFAULT_DB_ENV] || "";
41
42
  // The auth object IS the index path — see the core's header. Passed as an object rather than a bare
@@ -139,6 +140,7 @@ serve({
139
140
  romanization: { type: "string", description: "The Latin-script form of a NON-LATIN term — plain ASCII letters/digits, syllable-separated by single spaces, no tone marks or diacritics. On THIS source it does not rescue the slice: nativeScriptIndex is undeclared, so a non-Latin term defers and is disclosed rather than being answered by its romanisation." },
140
141
  owner: { type: "string", description: "OPTIONAL owner scope field on a MARK-TEXT proposal: the query is the owner×term intersection (the owner's filings within the term band). Not allowed on predicate:owner (there the owner name IS the term)." },
141
142
  nice_classes: { type: "array", items: {} },
143
+ ...narrowingFields(), // the narrowing fields every register serves (proposal-fields.mjs)
142
144
  rationale: { type: "string" },
143
145
  term_literal: { type: "boolean", description: "TRUE only when the term genuinely IS the mark verbatim (a multi-word slogan mark, a mark carrying an anchored star) — it bypasses the term-shape lint. Never use it to push a label through." },
144
146
  } } },
@@ -23,6 +23,7 @@
23
23
  // `--dangerously-bypass-approvals-and-sandbox` — see buildCodexArgs below for why.
24
24
 
25
25
  import { mkdtempSync, writeFileSync, copyFileSync, existsSync, rmSync, readFileSync, readdirSync, statSync, symlinkSync, lstatSync, realpathSync } from "node:fs";
26
+ import { everyToolCallRefused } from "./tool-refusal.mjs";
26
27
  import { writeSecretFile } from "../../shared/secret-file.mjs"; // the rotated login goes back the way every credential is written
27
28
  import { tmpdir, homedir } from "node:os";
28
29
  import { join } from "node:path";
@@ -531,6 +532,12 @@ function settleTuple({ r, ev, resumeRef }) {
531
532
  // The engine's sign-in could not be refreshed: what the operator runs to fix it. The gateway names the
532
533
  // stage's failure with it.
533
534
  signedOut: signedOut ? "codex sign-in expired — run `codex login`, then start the search again" : undefined,
535
+ // Every tool call refused and none completed: codex's own sandbox on this host, and what fixes it. The
536
+ // turn is still `ok` here; when the stage then fails, the gateway names its failure with this, as it
537
+ // names a sign-in, instead of the missing file the refusal left behind.
538
+ toolsRefused: everyToolCallRefused(mcpToolGauge(ev))
539
+ ? "codex refused every tool call this search needs — set CLEAROTRON_CODEX_SANDBOX_BYPASS=1 in this install's environment file, or use the Anthropic engine, then start the search again"
540
+ : undefined,
534
541
  rateLimitBasis: rateLimited ? "text-match" : undefined,
535
542
  // resetsAtBasis (2026-08-20): same honesty as rateLimitBasis one line up, for the reset
536
543
  // CLOCK rather than the classification. codex states its reset as human prose with NO timezone
@@ -15,11 +15,20 @@
15
15
  //
16
16
  // THE CHEAPEST TURN THAT PROVES THE WHOLE PATH
17
17
  //
18
- // One `haiku`-tier turn at `low` effort on a six-word prompt, with no MCP config, no allowed tools, no
19
- // skills dir and no run dir — the smallest argv either adapter can build. It exercises every link a
20
- // stage uses: binary → spawn → billing mode → credential → model access → a completed turn parsed by
21
- // the adapter's own settle path. And it is far lighter than the thing it protects: one register-sweep
22
- // stage prompt inlines 150 KB of plan and runs for minutes.
18
+ // One `haiku`-tier turn at `low` effort, asked to call one tool, with no skills dir and no run dir. The
19
+ // tool is `ping` on engine/mcp/probe-server.mjs, handed over exactly as a stage hands over its own.
20
+ //
21
+ // WHY IT CALLS A TOOL. It used to call none, and a turn with no tools proves nothing about the tools every
22
+ // search stage is given. On some hosts codex's own sandbox refuses every tool call while the turn reports
23
+ // success; the probe passed there, and every search then failed after real spend. So the probe now passes
24
+ // only when the reply carries the word `ping` returned, a random word minted for this turn and given to
25
+ // that server alone, so the model cannot supply it. Every call refused is a configuration fault, refused at
26
+ // the door; no call and no word shows nothing either way, and warns.
27
+ //
28
+ // It exercises every link a stage uses: binary → spawn → billing mode → credential → model access → a tool
29
+ // call through the stage's own tool path → a completed turn parsed by the adapter's own settle path. And
30
+ // it is far lighter than the thing it protects: one register-sweep stage prompt inlines 150 KB of plan and
31
+ // runs for minutes.
23
32
  //
24
33
  // WHAT IT DOES NOT PROVE, said out loud. It exercises the CHEAP tier. Both tiers ride one credential on
25
34
  // a subscription, so AUTH is proven for all of them; a per-tier model entitlement or a per-tier quota is
@@ -56,9 +65,28 @@
56
65
 
57
66
  import { ENGINE_BINARIES, DEFAULT_ENGINE_ID, engineAdapterSpecifier, resolveEngineProgram } from "../driver.config.mjs";
58
67
  import { resolveAuthMode, CLOUD_SETTINGS, CLOUD_CREDENTIAL_CHECK } from "./auth.mjs";
68
+ import { everyToolCallRefused } from "./tool-refusal.mjs";
69
+ import { randomBytes } from "node:crypto";
70
+ import { fileURLToPath } from "node:url";
71
+
72
+ /** One tool call and one word back: short enough to be free in practice, and it needs a working tool path. */
73
+ export const PROBE_PROMPT = "Call the ping tool once, then reply with exactly the word it returned.";
74
+ /** The server and tool the probe hands the engine, named as a stage names its own (`mcp__<server>__<tool>`). */
75
+ export const PROBE_SERVER = "probe";
76
+ export const PROBE_TOOL = "ping";
77
+ const PROBE_SERVER_PATH = fileURLToPath(new URL("./mcp/probe-server.mjs", import.meta.url));
78
+
79
+ /** The tool config for one probe turn, in the shape both adapters take from a stage. */
80
+ export function probeToolConfig(sentinel) {
81
+ return {
82
+ mcpConfig: JSON.stringify({ mcpServers: { [PROBE_SERVER]: {
83
+ command: process.execPath, args: [PROBE_SERVER_PATH, String(sentinel)], env: {} } } }),
84
+ allowedTools: `mcp__${PROBE_SERVER}__${PROBE_TOOL}`,
85
+ };
86
+ }
59
87
 
60
- /** Six words. Short enough to be free in practice, and it still requires a real completed turn. */
61
- export const PROBE_PROMPT = "Reply with the single word: ok.";
88
+ /** A word no model would produce unprompted, fresh per turn. */
89
+ export const mintProbeSentinel = () => `probe-${randomBytes(4).toString("hex")}`;
62
90
  /** The CHEAP rung of the driver's tier vocabulary on BOTH adapters (engine/CONTRACT.md §3). */
63
91
  export const PROBE_MODEL = "haiku";
64
92
  /** The floor rung of both EFFORT tables. There is nothing below `low` on anthropic. */
@@ -159,7 +187,7 @@ const capitalised = (s) => s.charAt(0).toUpperCase() + s.slice(1);
159
187
  * (`{ source, path }`); both only shape the advice, never the mode, so the run door refuses exactly what it
160
188
  * refused before. Absent, the advice is the subscription's, naming the bare program word.
161
189
  */
162
- export function classifyProbe({ engine, tuple = null, error = null, timeoutSec = PROBE_TIMEOUT_SEC, auth = null, program = null } = {}) {
190
+ export function classifyProbe({ engine, tuple = null, error = null, timeoutSec = PROBE_TIMEOUT_SEC, auth = null, program = null, expect = null } = {}) {
163
191
  const id = String(engine ?? "").trim().toLowerCase();
164
192
  const v = (mode, basis, headline, fix, extra = {}) =>
165
193
  ({ ok: false, engine: id, mode, basis, headline, fix, detail: null, ...extra });
@@ -197,6 +225,21 @@ export function classifyProbe({ engine, tuple = null, error = null, timeoutSec =
197
225
  // which pipe this happened to look at.
198
226
  const detail = tail(tuple.stderr) ?? tail(tuple.stdout);
199
227
 
228
+ // THE TOOL, ON A TURN THAT COMPLETED. `expect` is the word the probe's tool returns, and only a probe that
229
+ // handed the engine a tool passes one; without it a completed turn proves what it always proved. With
230
+ // it, three outcomes, never two. Every call refused is this machine's configuration, read off the
231
+ // adapter's own gauge, and the run door refuses on it. The word in the reply is the pass. Neither is a
232
+ // turn that shows nothing about the tools either way: not a pass, and not this box's fault to refuse on.
233
+ if (tuple.code === 0 && expect != null) {
234
+ if (everyToolCallRefused(tuple))
235
+ return v("tools-refused", "tool-gauge", `${id} refused every tool call it was given`, toolsRefusedFix(id),
236
+ { detail: tail(tuple.mcpToolCallRefusals?.[0]?.message) });
237
+ if (!String(tuple.stdout ?? "").includes(String(expect)))
238
+ return v("tools-unproven", "no-tool-answer", `${id} did not return the word its probe tool gives`,
239
+ "Nothing here shows that the tools a search needs work on this machine. Run this again; if it repeats, a search is likely to fail the same way.",
240
+ { detail: tail(tuple.stdout) });
241
+ }
242
+
200
243
  // A completed turn names what served it, the model and the provider as the program reported them, and
201
244
  // null where it named neither, so a proof says which model and whose account it proved.
202
245
  if (tuple.code === 0) return { ok: true, engine: id, mode: "ok", basis: "completed-turn", headline: `${id} completed a turn`, fix: null, detail: null,
@@ -234,7 +277,7 @@ export function classifyProbe({ engine, tuple = null, error = null, timeoutSec =
234
277
  return v("tier-unavailable", "text-match", `${id} cannot reach the model it was asked for`, tierFix(id, text), { detail });
235
278
 
236
279
  if (tuple.killed || s.stalled || s.hardWall)
237
- return v("timed-out", "watchdog", `${id} started but did not finish a six-word turn in ${timeoutSec}s`,
280
+ return v("timed-out", "watchdog", `${id} started but did not finish its probe turn in ${timeoutSec}s`,
238
281
  "The binary runs and the turn produces nothing. Run the CLI by hand once and see what it is waiting for — an unanswered login prompt and a wedged MCP server both look like this.", { detail });
239
282
 
240
283
  // The anthropic adapter's own diagnosis: the CLI exited without emitting a single stream event, i.e.
@@ -250,6 +293,14 @@ export function classifyProbe({ engine, tuple = null, error = null, timeoutSec =
250
293
  "The engine's stderr below is the whole story; a turn that starts and fails is not a configuration this check can name.", { detail });
251
294
  }
252
295
 
296
+ /** What fixes a host that refuses every tool call. On codex it is its own sandbox, and the setting is named. */
297
+ function toolsRefusedFix(engine) {
298
+ if (engine === "openai-agent")
299
+ return "Every search stage calls tools, so no search can finish here. codex's own sandbox refuses them on this machine: "
300
+ + "set CLEAROTRON_CODEX_SANDBOX_BYPASS=1 in this install's environment file, or use the Anthropic engine, then run this again.";
301
+ return "Every search stage calls tools, so no search can finish here. The engine's stderr below is the place to start.";
302
+ }
303
+
253
304
  /** The one place the tier doctrine is POINTED AT rather than re-authored. */
254
305
  function tierFix(engine, msg) {
255
306
  if (engine === "openai-agent")
@@ -290,7 +341,7 @@ export function probeFailureText(verdict) {
290
341
  // classify is by definition one the door cannot claim to understand — and the cost of being wrong runs
291
342
  // the other way here: refusing wrongly kills a run that would have worked, proceeding wrongly costs the
292
343
  // stages before a failure the engine was going to produce anyway.
293
- const CONFIGURATION_MODES = new Set(["unknown-engine", "auth-misconfigured", "signed-out", "tier-unavailable", "cannot-spawn"]);
344
+ const CONFIGURATION_MODES = new Set(["unknown-engine", "auth-misconfigured", "signed-out", "tier-unavailable", "cannot-spawn", "tools-refused"]);
294
345
 
295
346
  // …and a mode alone is not enough, because `basis` says HOW WELL the mode is known and the ladder already
296
347
  // makes that distinction for its own reasons. `startup-class` is an INFERENCE FROM SILENCE — the CLI died
@@ -299,7 +350,8 @@ const CONFIGURATION_MODES = new Set(["unknown-engine", "auth-misconfigured", "si
299
350
  // hosted runner where a hermetic PATH left `env` unable to resolve node, and the probe "correctly reported
300
351
  // a startup-class engine death" on a machine whose engine was fine. Refusing a production run on that
301
352
  // inference manufactures the outage it was written to prevent, so it warns instead.
302
- const NAMED_BASES = new Set(["config", "text-match", "spawn-error"]);
353
+ // `tool-gauge` is named, not inferred: the adapter counted each refused call off the engine's own stream.
354
+ const NAMED_BASES = new Set(["config", "text-match", "spawn-error", "tool-gauge"]);
303
355
 
304
356
  /**
305
357
  * "ok" | "configuration" | "weather" — PURE, and the whole of the door's judgment.
@@ -465,8 +517,9 @@ export async function probeEngineTurn({
465
517
  // the environment back whatever it does. A resolver that cannot answer leaves the bare word.
466
518
  if (knownProgram?.path) program = { source: knownProgram.source ?? null, path: knownProgram.path };
467
519
  else try { const r = resolveEngineProgram(id); program = r.resolved ? { source: r.source, path: r.resolved } : null; } catch { /* the bare word */ }
468
- const tuple = await turn({ message: PROBE_PROMPT, model: PROBE_MODEL, thinking: PROBE_THINKING, timeoutSec, stallSec });
469
- return classifyProbe({ engine: id, tuple, timeoutSec, auth, program });
520
+ const sentinel = mintProbeSentinel();
521
+ const tuple = await turn({ message: PROBE_PROMPT, model: PROBE_MODEL, thinking: PROBE_THINKING, timeoutSec, stallSec, ...probeToolConfig(sentinel) });
522
+ return classifyProbe({ engine: id, tuple, timeoutSec, auth, program, expect: sentinel });
470
523
  } catch (e) {
471
524
  return classifyProbe({ engine: id, error: e, timeoutSec, auth, program });
472
525
  } finally {
@@ -479,7 +532,7 @@ export async function probeEngineTurn({
479
532
  *
480
533
  * The choice, stated so nobody has to re-decide it. The register-credential precedent refuses because a
481
534
  * run whose work is impossible must say so before it costs anything, and the reasoning transfers exactly:
482
- * every one of the fourteen stages spawns the engine, so an engine that cannot complete a six-word turn
535
+ * every one of the fourteen stages spawns the engine, so an engine that cannot complete its probe turn
483
536
  * cannot complete any of them. A warning would let the run build its directory, freeze its profile and
484
537
  * write its status sidecar, then die at stage one leaving a resumable-looking husk and a failure wearing
485
538
  * the shape of a model fault — which is the precise outcome preflightEngineBinary exists to prevent, and
@@ -0,0 +1,16 @@
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
+ // engine/tool-refusal.mjs — one definition of "this turn's every tool call was refused", for the two
4
+ // places that act on it: the gateway, which stops a stage's retries and names the failure, and the engine
5
+ // probe, which refuses a host where it happens before a search is paid for. Two copies would drift.
6
+
7
+ /**
8
+ * Every tool call the turn made was refused, and none completed. Read off the adapter's own gauge
9
+ * (`mcpToolCalls` / `mcpToolCallsRefused`), which counts a refusal only when the call never reached its
10
+ * server — a tool that ran and errored is not one. One refused call beside a completed one is a model
11
+ * asking for something it may not have, not a host that refuses tools, so it does not count. An engine
12
+ * that keeps no gauge reports nothing, and nothing is not a refusal. Pure.
13
+ */
14
+ export function everyToolCallRefused(turn) {
15
+ return Number(turn?.mcpToolCallsRefused ?? 0) > 0 && Number(turn?.mcpToolCalls ?? 0) === 0;
16
+ }
@@ -28,7 +28,7 @@ import { productName, productSpec, checkProductScope, checkNativeLanguage, unkno
28
28
  import { partitionTerritories } from "./territory-tiers.mjs";
29
29
  // — the sidecar field map, from the module that OWNS it. The check below asks whether the prose a
30
30
  // manifest declared actually arrived, and a local copy of that list is how the two would drift apart.
31
- import { PROSE_PARTS } from "./queue-markers.mjs";
31
+ import { PROSE_PARTS, goodsOf } from "./queue-markers.mjs";
32
32
 
33
33
  // ── per-run scope limits ──────────────────────────────────────────────────────────────────────────────
34
34
  // Caps, not policy. They exist so one malformed request cannot mint an unbounded search: every extra
@@ -422,7 +422,7 @@ export function validateJob(job, { atClaim = false } = {}) {
422
422
  }
423
423
  // §B2: classes OR a goods description — either suffices; both absent ⇒ the subject can't be scoped.
424
424
  const hasClasses = requestNamesClasses(job);
425
- const hasGoods = Boolean(job.goods || job.use);
425
+ const hasGoods = goodsOf(job) !== null; // — the spellings live in queue-markers.mjs, with the fold that keeps every reader on one answer
426
426
  if (!hasClasses && !hasGoods) {
427
427
  // — THE GATE HAS TO ASK WHAT THE RUN WOULD ASK, not what the request typed.
428
428
  //