clearotron 0.5.0-beta.0 → 0.5.0-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 (36) hide show
  1. package/build-info.json +2 -2
  2. package/driver/CHANGELOG.md +9 -0
  3. package/driver/ask-ledger.mjs +4 -1
  4. package/driver/band-shape.mjs +5 -1
  5. package/driver/contract-vocabulary.mjs +3 -3
  6. package/driver/coverage-form-io.mjs +2 -1
  7. package/driver/coverage-form.mjs +14 -2
  8. package/driver/effective-scope.mjs +12 -7
  9. package/driver/engine/mcp/held-answers.mjs +128 -0
  10. package/driver/engine/mcp/supplemental.mjs +74 -12
  11. package/driver/package.json +1 -1
  12. package/driver/pipeline.mjs +53 -13
  13. package/driver/register-plan.mjs +132 -6
  14. package/driver/scope-facts.mjs +6 -2
  15. package/driver/skills/clearance-register/SKILL.md +24 -16
  16. package/driver/skills/clearance-register/digest.md +8 -3
  17. package/driver/skills/clearance-register/providers/clarivate.md +4 -4
  18. package/driver/skills/clearance-register/providers/corsearch.md +1 -1
  19. package/driver/skills/clearance-register/providers/euipo.md +4 -4
  20. package/driver/skills/clearance-register/providers/free-tier.md +4 -4
  21. package/driver/skills/clearance-register/providers/uspto-local.md +3 -3
  22. package/driver/skills/clearance-register/register-recipes.md +6 -6
  23. package/driver/skills/clearance-register/unit.md +26 -19
  24. package/driver/stages.mjs +1 -1
  25. package/driver/suite-census.json +49 -7
  26. package/mcp-server/CHANGELOG.md +4 -0
  27. package/mcp-server/package.json +1 -1
  28. package/package.json +1 -1
  29. package/portal-ui/package.json +1 -1
  30. package/providers/_shared/enumerate.mjs +12 -6
  31. package/providers/_shared/execute-plan.mjs +25 -1
  32. package/providers/_shared/follow-up-searches.mjs +139 -0
  33. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  34. package/providers/oauth-mcp-bridge/package.json +1 -1
  35. package/scripts/e2e-first-time.mjs +1 -1
  36. package/scripts/release-await-cut.mjs +4 -2
package/build-info.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "e2212d67aeaa269f95682ab906b16c03d37ffab4",
3
- "version": "0.5.0-beta.0"
2
+ "commit": "39f3bdc47543630a4f206ac667aa0fd923a2b32b",
3
+ "version": "0.5.0-beta.1"
4
4
  }
@@ -1,5 +1,14 @@
1
1
  # clearotron-driver
2
2
 
3
+ ## 0.5.0-beta.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Fixed: A search with more similar marks than can be read now narrows step by step, and stops once the lists it has read answer it. Before, it could keep searching far past what the advice needed.
8
+ - Fixed: A crowded search that the lists already read have answered no longer appears among the report's coverage limits. Its count is still recorded.
9
+ - For operators: A clearance now stops asking further follow-up searches after 120, and delivers its report with what it has read.
10
+ - For operators: On the Signa register, follow-up searches now send spellings as lists, so a clearance makes fewer requests.
11
+
3
12
  ## 0.5.0-beta.0
4
13
 
5
14
  ### Minor Changes
@@ -54,6 +54,7 @@
54
54
  // DRIVER's instructed values appear in the model's frame, where this checks the MODEL's quote
55
55
  // appears in a driver-held file. Same expression, different question; it stays separate.
56
56
  import { squash } from "./doubt-ledger.mjs";
57
+ import { OUTSTANDING_REASON } from "../providers/_shared/follow-up-searches.mjs";
57
58
  //
58
59
  // Ask record shape (frozen — _driver/asks.json and the audit's Ask Ledger both render it):
59
60
  // {
@@ -469,7 +470,9 @@ export function deriveAsks({
469
470
  // must show it working. So EVERY rejection attempts the join, and one that did not mint later
470
471
  // ends OPEN — a real unanswered question the closure stage settles with a citation or that ships
471
472
  // visibly. "judged-immaterial" is reserved for a materiality judgment somebody actually made.
472
- const supersededBy = mintedByIdentity.get(proposalIdentity(r?.proposal)) ?? null;
473
+ // A follow-up the run's safety net did not send is never superseded: the identity join ignores the regions, so a
474
+ // question minted for one market would otherwise bury the same question refused for another.
475
+ const supersededBy = issue === OUTSTANDING_REASON ? null : (mintedByIdentity.get(proposalIdentity(r?.proposal)) ?? null);
473
476
  push({
474
477
  ask_id: `ask:supplemental-rejected:${slug(axis)}:${i + 1}`,
475
478
  born: { place: "supplemental-proposal", artifact: `register-units/${axis}-supplemental-plan.json`, ref: `rejected[${i}]`, ts: r?.ts ?? null },
@@ -830,7 +830,7 @@ export function bandGroups(records, { classify, inScope, goods = null, dominantT
830
830
  * (charter P2d); the band itself stays per-registration
831
831
  * @returns { shape, md } deterministic — same band + inputs ⇒ same bytes (no timestamps)
832
832
  */
833
- export function buildBandShape(band, { targets = [], inScopeClasses = [], crowdContext = null, caps = {}, positions = null, goods = null, goodsTest = null, goodsWordCount = 0, dominantTerm = "", manifestDominant = "", keysOf = null, markets = [], territories = [], evidenceOf = null } = {}) {
833
+ export function buildBandShape(band, { crowdNarrowing = [], targets = [], inScopeClasses = [], crowdContext = null, caps = {}, positions = null, goods = null, goodsTest = null, goodsWordCount = 0, dominantTerm = "", manifestDominant = "", keysOf = null, markets = [], territories = [], evidenceOf = null } = {}) {
834
834
  const prepared = prepareTargets(Array.isArray(targets) && targets.every(isPrepared) ? targets.map((t) => t.text) : targets);
835
835
  const records = Array.isArray(band?.enumerated) ? band.enumerated : [];
836
836
  const crowds = Array.isArray(band?.crowds) ? band.crowds : [];
@@ -914,8 +914,11 @@ export function buildBandShape(band, { targets = [], inScopeClasses = [], crowdC
914
914
  // context slice joins a crowd when one of its terms appears in the crowd's query (case-folded).
915
915
  const ccSlices = Array.isArray(crowdContext?.slices) ? crowdContext.slices : [];
916
916
  const fold = (s) => foldDiacritics(String(s ?? "")).toLowerCase();
917
+ // what was retrieved under each crowd (register-plan.mjs, crowdNarrowing): data on the descriptor, by its qid
918
+ const endingByQid = new Map((Array.isArray(crowdNarrowing) ? crowdNarrowing : []).filter((r) => r?.qid && r?.narrowing).map((r) => [String(r.qid), r]));
917
919
  const shapeCrowds = crowds.map((c) => {
918
920
  const q = fold(c.query);
921
+ const end = c.qid ? endingByQid.get(String(c.qid)) : null;
919
922
  const joined = ccSlices
920
923
  .filter((s) => (Array.isArray(s.terms) ? s.terms : []).some((t) => fold(t) && q.includes(fold(t))))
921
924
  .map((s) => ({ unit: s.unit ?? null, axis: s.axis ?? null, terms: s.terms ?? [],
@@ -929,6 +932,7 @@ export function buildBandShape(band, { targets = [], inScopeClasses = [], crowdC
929
932
  // executor and judgment, and dropping them here would undo the first carry silently.
930
933
  ...(c.error === true ? { error: true } : {}),
931
934
  ...(c.deferred === true ? { deferred: true } : {}),
935
+ ...(end ? { narrowing: end.narrowing, ...(end.lists?.length ? { lists: end.lists } : {}), ...(end.not_covered?.length ? { not_covered: end.not_covered } : {}) } : {}),
932
936
  crowd_context_slice: joined.length ? joined : null,
933
937
  };
934
938
  });
@@ -530,9 +530,9 @@ export const INNER_CODES = Object.freeze([
530
530
  // ONE CODE, TWO TOKENS, and the split is by `cause` rather than by code: verify.mjs peels the
531
531
  // `axis_invalid` cause into its own family before counting the rest. A ruling naming only
532
532
  // `coverage_no_status` would be true of most `no_status` records and false of the ones that matter most.
533
- { code: "no_status", mints: ["driver/coverage-form.mjs:897"], rollsUpTo: ["coverage_no_status", "coverage_form_axis_invalid"],
533
+ { code: "no_status", mints: ["driver/coverage-form.mjs:907"], rollsUpTo: ["coverage_no_status", "coverage_form_axis_invalid"],
534
534
  why: "Two composites, split on the record's `cause`: coverageFormFail() in verify.mjs for cause `axis_invalid`, and the same function for the rest. NOT the `@returns` annotation of findCoverageFormViolations() in coverage-form.mjs — that is the annotation, not the mint." },
535
- { code: "engine_vocabulary", mints: ["driver/coverage-form.mjs:887"], rollsUpTo: ["coverage_form_engine_vocabulary"],
535
+ { code: "engine_vocabulary", mints: ["driver/coverage-form.mjs:897"], rollsUpTo: ["coverage_form_engine_vocabulary"],
536
536
  why: "#669 — the seat wrote an engine token into the `reason` sentence that reaches the reader's page. Checked on settled rows too, because a row the seat considers finished is exactly the one whose sentence gets printed. Namespaced by coverageFormFail() in verify.mjs; the bare code names a row, never a stage." },
537
537
 
538
538
  // ── ONE CODE, TWO MODULES ────────────────────────────────────────────────────────────────────────
@@ -544,7 +544,7 @@ export const INNER_CODES = Object.freeze([
544
544
  // failure tokens" already states the rule this row records: `coverage_form_damaged`, never a bare
545
545
  // `form_damaged`.
546
546
  { code: "form_damaged",
547
- mints: ["driver/connotation-search.mjs:2002", "driver/connotation-search.mjs:2078", "driver/coverage-form.mjs:879"],
547
+ mints: ["driver/connotation-search.mjs:2002", "driver/connotation-search.mjs:2078", "driver/coverage-form.mjs:889"],
548
548
  rollsUpTo: ["connotation_form_damaged", "coverage_form_damaged"],
549
549
  why: "Minted in two lanes and namespaced per lane: connotationDispositionFail() in verify.mjs for the meaning sweep, coverageFormFail() in verify.mjs for the register digest. The namespacing is what keeps them apart — see verify.mjs's block headed \"the register coverage-form failure tokens\"." },
550
550
  ]);
@@ -149,7 +149,8 @@ export function coverageFormInput(runDir) {
149
149
  const orderedTerritories = Array.isArray(plan.ordered_jurisdictions) ? plan.ordered_jurisdictions : [];
150
150
  return { input: { skeleton: exec.skeleton, plan, bandBlocksByAxis, deferredReasons, activeAxes,
151
151
  bandsUnreadable, orderedTerritories, capabilities, unknownAxisUnits,
152
- awaiting: Array.isArray(exec.awaiting) ? exec.awaiting : [], withheld: readWithheldFamilies(runDir) }, absent: null };
152
+ awaiting: Array.isArray(exec.awaiting) ? exec.awaiting : [], withheld: readWithheldFamilies(runDir),
153
+ mainMarkets: Array.isArray(exec.main_markets) ? exec.main_markets : [] }, absent: null };
153
154
  }
154
155
 
155
156
  /**
@@ -31,7 +31,7 @@
31
31
  import { REGISTER_AXES, COVERAGE_STATUSES, normalizeAxis, CROWD_RULING_TOKEN,
32
32
  CROWD_RULING_UNIT_GRAMMAR } from "./coverage-ledger.mjs";
33
33
  import { shortId } from "./connotation-search.mjs";
34
- import { openBlocksByAxis } from "./register-plan.mjs";
34
+ import { openBlocksByAxis, crowdNarrowing, blocksByQidOf } from "./register-plan.mjs";
35
35
  import { territoryLayerReport, unsearchedLayerReason } from "./binding-layers.mjs"; import { goodsTermsList } from "../providers/_shared/term-shape.mjs";
36
36
 
37
37
  const STATUS_SET = new Set(COVERAGE_STATUSES);
@@ -344,8 +344,17 @@ export function blockOpenBecause(b, narrowings = []) {
344
344
  */
345
345
  export function coverageFormRows({ skeleton = [], activeAxes = null, plan = null,
346
346
  bandBlocksByAxis = {}, deferredReasons = {}, bandsUnreadable = [],
347
- orderedTerritories = [], capabilities = null, awaiting = [], withheld = {} } = {}) {
347
+ orderedTerritories = [], capabilities = null, awaiting = [], withheld = {}, mainMarkets = [] } = {}) {
348
348
  const skel = Array.isArray(skeleton) ? skeleton.filter((s) => s && typeof s === "object") : [];
349
+ // WHAT WAS RETRIEVED UNDER EACH CROWD (register-plan.mjs, crowdNarrowing), worked out from the same blocks these rows
350
+ // are built from and carried on its row as data: a narrowed list retrieved in full (and which), none, or for the
351
+ // identical mark whether retrieved lists cover every class in every main market (and the cells they do not). The
352
+ // row stays open either way: reading the lists and deciding the crowd are the reading step's.
353
+ const narrowingByQid = new Map(crowdNarrowing(plan, blocksByQidOf(bandBlocksByAxis), { mainMarkets }).rows.map((r) => [String(r.qid), r]));
354
+ const endingOf = (qid) => {
355
+ const r = narrowingByQid.get(String(qid));
356
+ return r ? { narrowing: r.narrowing, ...(r.lists?.length ? { lists: r.lists } : {}), ...(r.not_covered?.length ? { not_covered: r.not_covered } : {}) } : {};
357
+ };
349
358
  const entriesByQid = new Map((plan?.entries ?? []).map((e) => [e.qid, e]));
350
359
  const open = openBlocksByAxis(skel, bandBlocksByAxis, plan);
351
360
  // Axis order is the run's, not a set's iteration order: the plan-execution skeleton first (the order
@@ -393,6 +402,7 @@ export function coverageFormRows({ skeleton = [], activeAxes = null, plan = null
393
402
  ...(Number.isInteger(b.total_hits) ? { total_hits: b.total_hits } : {}),
394
403
  ...(b.unaccounted?.length ? { unaccounted_terms: b.unaccounted } : {}),
395
404
  ...(b.unaccounted_classes?.length ? { unaccounted_classes: b.unaccounted_classes } : {}),
405
+ ...endingOf(b.qid),
396
406
  // The block's OWN row is the naming the deleted join required. A clean claim HERE is the
397
407
  // silent swallow; a clean claim on a sibling slice never discharged this block and does not now.
398
408
  open: true,
@@ -1034,6 +1044,8 @@ function briefRow(r) {
1034
1044
  r.unaccounted_classes?.length ? `classes unaccounted: ${r.unaccounted_classes.join(", ")}` : "",
1035
1045
  r.unaccounted_terms?.length ? `terms unaccounted: ${r.unaccounted_terms.join(", ")}` : "",
1036
1046
  r.receipt_reason ? `receipt: ${String(r.receipt_reason).slice(0, 160)}` : "",
1047
+ // what was retrieved under the crowd (register-plan.mjs, crowdNarrowing), as data beside the counts it rests on
1048
+ r.narrowing ? `narrowing: ${r.narrowing}${r.lists?.length ? ` (${r.lists.slice(0, 6).join(", ")})` : ""}${r.not_covered?.length ? `; not covered: ${r.not_covered.slice(0, 12).join(", ")}` : ""}` : "",
1037
1049
  ].filter(Boolean).join("; ");
1038
1050
  const settled = String(r.status ?? "").trim() ? ` [settled: ${String(r.status).trim()}]` : "";
1039
1051
  return ` ${r.row_id} (${r.kind}) ${r.unit}${facts ? ` — ${facts}` : ""}${r.open === true ? " — OPEN: never confirmed-clean" : ""}${settled}`;
@@ -192,19 +192,24 @@ export function orderTerritories(job, profile) {
192
192
  /**
193
193
  * THE NARROWING ORDER, as the register reading step is told it (ruled 2026-09-26): goods and class
194
194
  * first, then the markets in turn, with the markets the driver knows filled in. One line. PURE.
195
+ *
196
+ * ONE MARKET LIST, the one that applies: the order's countries; else the customer's priority markets; else the
197
+ * markets the customer's field and brand point to, with the major markets as their fallback. The line used to
198
+ * print all three as a chain of "else", and the reading step was left to pick among them. Each step keeps the
199
+ * limits before it, and a crowd still a crowd after the markets is recorded with its count: the narrowing ends.
195
200
  */
196
201
  export function narrowingOrderLine(job, profile, inScopeClasses = null) {
197
202
  const { scope, priorities, major } = narrowingMarkets(job, profile);
198
203
  const markets = scope.length
199
204
  ? `one question per market of the order's scope (${scope.join(", ")}); nothing outside it was ordered`
200
- : `one question per market: ${[
201
- priorities.length ? `the customer's priority markets (${priorities.join(", ")})` : "",
202
- "the markets the customer's field and brand point to (judge them from the matter frame)",
203
- `the major markets (${major.join(", ")})`,
204
- ].filter(Boolean).join(", else ")}`;
205
+ : priorities.length
206
+ ? `one question per market: the customer's priority markets (${priorities.join(", ")})`
207
+ : `one question per market: the markets the customer's field and brand point to (judge them from the matter frame), else the major markets (${major.join(", ")})`;
205
208
  return `NARROWING A CROWD, in this order: first the goods and the classes, the client's goods words in `
206
- + `${inScopeClasses ? `the order's classes [${inScopeClasses}]` : "the order's classes"}, then one of those classes at a time; `
207
- + `still a crowd, then the markets, ${markets}. From the narrowed lists, read what a lawyer would raise. `
209
+ + `${inScopeClasses ? `the order's classes [${inScopeClasses}]` : "the order's classes"}, then one of those classes at a time, `
210
+ + `each step keeping the limits before it and stopping at the first list you can read; `
211
+ + `still a crowd, then the markets, ${markets}. Still a crowd after the markets: record it with its count. `
212
+ + `From the narrowed lists, read what a lawyer would raise. `
208
213
  + `What was counted and not read stays on its coverage row with its count: set aside, never clean.`;
209
214
  }
210
215
 
@@ -0,0 +1,128 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
3
+ //
4
+ // held-answers.mjs — ONE ANSWER PER QUESTION PER RUN, ACROSS EVERY WORKER AND PASS.
5
+ //
6
+ // The register workers run in parallel and do not see each other's questions, so the same question was put to the
7
+ // register again by another worker: on a test run of a crowded matter, 178 follow-up proposals held 167 distinct
8
+ // questions, and the 11 copies cost 49 calls. A question the run has already answered now gets the held answer, and
9
+ // one the run has already decided not to ask (a family withheld by judgment, or a follow-up the run's safety net
10
+ // refused) gets that decision. Nothing is asked again and no call is made.
11
+ //
12
+ // WHAT MAKES TWO QUESTIONS THE SAME: every field that changes what the register is asked — the predicate, the term
13
+ // or terms, their romanised forms, the classes, the markets, the goods words, the owner and the kind of answer —
14
+ // and none that only records where a question came from. A question with no markets is asked to the plan's, so it
15
+ // is the same as one naming them. Owner names are compared as written, never merged. A different scope is a
16
+ // different question, and an earlier answer that failed is no answer: both are asked again.
17
+
18
+ import { readFileSync, readdirSync, statSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { driverDir } from "../../../shared/driver-dir.mjs";
21
+ import { OUTSTANDING_REASON } from "../../../providers/_shared/follow-up-searches.mjs";
22
+ import { awaitsReadingTurn } from "../../../providers/_shared/plan-guards.mjs";
23
+ import { releasedFamilyQids } from "../../withheld-families.mjs";
24
+
25
+ const QUESTION_FIELDS = ["predicate", "term", "terms", "romanizedTerms", "term_literal", "nice_classes", "regions", "goods_text", "owner", "expected_kind"];
26
+ const sorted = (v) => [...v].map((x) => String(x)).sort();
27
+
28
+ /** The question an entry asks, as a key. `planRegions` fills an entry that names no markets. PURE. */
29
+ export function questionKey(e, planRegions = []) {
30
+ const o = {};
31
+ for (const k of QUESTION_FIELDS) {
32
+ let v = e?.[k];
33
+ if (k === "predicate") v = String(v ?? "default");
34
+ if (k === "expected_kind") v = String(v ?? "enumerate");
35
+ if (k === "regions" && !(Array.isArray(v) && v.length)) v = planRegions;
36
+ if (v === undefined || v === null || v === false || (Array.isArray(v) && !v.length) || v === "") continue;
37
+ o[k] = Array.isArray(v) ? (k === "terms" || k === "romanizedTerms" ? v.map(String) : sorted(v)) : v;
38
+ }
39
+ return JSON.stringify(o);
40
+ }
41
+
42
+ // A band file is read once per change: the propose tool is called many times in a turn, and a primary sweep's band
43
+ // runs to tens of megabytes. Keyed on the path, its size and its modification time. Only what a held answer hands
44
+ // back is kept, the first records among it, so a worker's process does not hold every band whole.
45
+ const BAND_CACHE = new Map();
46
+ const HELD_RECORDS = 25;
47
+ function readBlocks(path) {
48
+ let st;
49
+ try { st = statSync(path); } catch { return []; }
50
+ const stamp = `${st.size}:${st.mtimeMs}`;
51
+ const hit = BAND_CACHE.get(path);
52
+ if (hit && hit.stamp === stamp) return hit.blocks;
53
+ let blocks = [];
54
+ try {
55
+ const j = JSON.parse(readFileSync(path, "utf8"));
56
+ blocks = (Array.isArray(j) ? j : []).filter((b) => b?.qid).map((b) => ({
57
+ qid: b.qid, state: b.state ?? null, error: b.error === true, total_hits: b.total_hits ?? null,
58
+ count: b.count ?? (Array.isArray(b.records) ? b.records.length : null),
59
+ records: Array.isArray(b.records) ? b.records.slice(0, HELD_RECORDS) : [],
60
+ }));
61
+ } catch { blocks = []; }
62
+ BAND_CACHE.set(path, { stamp, blocks });
63
+ return blocks;
64
+ }
65
+
66
+ /**
67
+ * Refuse the minted entries whose question the run has already answered on another row, the way the fold refuses a
68
+ * question the plan already holds: `duplicate-question`, with the row that answered it as the twin. Second framing's
69
+ * re-sweep calls this before its fold, because the fold sees only the plan's rows on the re-sweep's own axis, and a
70
+ * question another worker answered is not asked again. Only an answer settles: a withheld family has its own refusal,
71
+ * and a follow-up the safety net did not send would not be sent again. PURE.
72
+ * @returns {{ kept: object[], refused: {qid: string, term: string, kind: "duplicate-question", twin: string}[] }}
73
+ */
74
+ export function refuseHeldQuestions(entries, held, planRegions = []) {
75
+ const kept = [], refused = [];
76
+ for (const e of Array.isArray(entries) ? entries : []) {
77
+ const h = held?.get?.(questionKey(e, planRegions));
78
+ if (e?.qid && h?.decision === "answered" && h.qid && String(h.qid) !== String(e.qid))
79
+ refused.push({ qid: e.qid, term: String(e.term ?? e.terms?.[0] ?? ""), kind: "duplicate-question", twin: String(h.qid) });
80
+ else kept.push(e);
81
+ }
82
+ return { kept, refused };
83
+ }
84
+
85
+ /**
86
+ * The run's held answers and decisions, by question key. IMPURE (reads the run's register files).
87
+ * @returns {Map<string, { qid: string, axis: string, decision: "answered"|"withheld"|"outstanding", block?: object }>}
88
+ */
89
+ export function heldAnswers(runDir, { planRegions = [], withheld = {}, released = null } = {}) {
90
+ // A FAMILY WAITING FOR THE READING TURN IS ANSWERED ONLY ONCE IT IS RELEASED AND RUN, the plan join's own rule: until
91
+ // then it was not asked, whatever block its qid may carry, and a question repeating it is asked as the join expects.
92
+ let releasedQids = released;
93
+ if (!(releasedQids instanceof Set)) { try { releasedQids = releasedFamilyQids(runDir); } catch { releasedQids = new Set(); } }
94
+ const held = new Map();
95
+ const units = join(runDir, "register-units");
96
+ const blocksByQid = new Map();
97
+ const plans = [];
98
+ try { plans.push({ axis: null, plan: JSON.parse(readFileSync(driverDir(runDir, "register-plan.json"), "utf8")) }); } catch { /* no frozen plan */ }
99
+ let files = [];
100
+ try { files = readdirSync(units); } catch { files = []; }
101
+ for (const f of files) {
102
+ if (f.endsWith("-band.json")) for (const b of readBlocks(join(units, f))) if (b?.qid) blocksByQid.set(String(b.qid), b);
103
+ if (f.endsWith("-supplemental-plan.json")) {
104
+ try { plans.push({ axis: f.slice(0, -"-supplemental-plan.json".length), plan: JSON.parse(readFileSync(join(units, f), "utf8")) }); } catch { /* torn: skipped */ }
105
+ }
106
+ }
107
+ for (const { plan } of plans) {
108
+ for (const e of Array.isArray(plan?.entries) ? plan.entries : []) {
109
+ if (!e?.qid) continue;
110
+ const key = questionKey(e, planRegions);
111
+ if (held.has(key)) continue;
112
+ if (awaitsReadingTurn(e.when) && !releasedQids.has(String(e.qid))) {
113
+ if (withheld?.[e.qid]) held.set(key, { qid: e.qid, axis: e.axis, decision: "withheld" });
114
+ continue;
115
+ }
116
+ const b = blocksByQid.get(String(e.qid));
117
+ if (b && b.error !== true) held.set(key, { qid: e.qid, axis: e.axis, decision: "answered", block: b });
118
+ else if (withheld?.[e.qid]) held.set(key, { qid: e.qid, axis: e.axis, decision: "withheld" });
119
+ }
120
+ // A follow-up the run's safety net did not send is a decision too: proposed again, it would not be sent again.
121
+ for (const r of Array.isArray(plan?.rejected) ? plan.rejected : []) {
122
+ if (r?.issue !== OUTSTANDING_REASON || !r?.proposal) continue;
123
+ const key = questionKey(r.proposal, planRegions);
124
+ if (!held.has(key)) held.set(key, { qid: null, axis: null, decision: "outstanding" });
125
+ }
126
+ }
127
+ return held;
128
+ }
@@ -32,10 +32,18 @@
32
32
  import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from "node:fs";
33
33
  import { dirname, join } from "node:path";
34
34
  import { driverDir } from "../../../shared/driver-dir.mjs"; //
35
- import { PLAN_PREDICATES, PLAN_MAX_OR_WIDTH, PLAN_MAX_NAME_LENGTH, fingerprint, ownerIntersectionGap, goodsTextGap, resolveRegions, houseElementOf, withoutHouseElementTerms, containsFormSubstitution } from "../../register-plan.mjs";
35
+ import { PLAN_PREDICATES, PLAN_MAX_OR_WIDTH, PLAN_MAX_NAME_LENGTH, planExactOrWidth, planPhoneticOrWidth, fingerprint, ownerIntersectionGap, goodsTextGap, resolveRegions, houseElementOf, withoutHouseElementTerms, containsFormSubstitution } from "../../register-plan.mjs";
36
36
  import { entryTermIssues } from "../../../providers/_shared/term-shape.mjs";
37
+ import { OUTSTANDING_REASON } from "../../../providers/_shared/follow-up-searches.mjs";
38
+ import { heldAnswers, questionKey } from "./held-answers.mjs";
39
+ import { readWithheldFamilies } from "../../withheld-families.mjs";
37
40
  import { isNonLatinTerm, romanizationRefusal, romanizationSpellings, nativeScriptIndexGap } from "../../../providers/_shared/script-form.mjs";
38
41
 
42
+ // THE ONE SENTENCE THE MODEL READS WHEN THE RUN'S SAFETY NET HOLDS A FOLLOW-UP BACK (follow-up-searches.mjs). It names
43
+ // no limit and no cost: the model is never told of the net. It rides the reply whenever a question was not sent, now
44
+ // or by an earlier decision held for the run.
45
+ export const OUTSTANDING_NOTE = "Not asked in this run. Keep it as an open question with your reason, and continue with what you have read.";
46
+
39
47
  const slug = (s) => String(s ?? "").toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32) || "q";
40
48
 
41
49
  const PREVIEW_FIELDS = ["record_id", "mark_text", "classes", "status", "owner_name", "owner_country", "screen_verdict"];
@@ -57,17 +65,20 @@ const compactProposal = (p) => ({
57
65
  const inPriority = (p, prio) =>
58
66
  (Array.isArray(p?.nice_classes) ? p.nice_classes : []).some((c) => prio.has(String(c).trim()));
59
67
 
60
- // A SOUND-ALIKE BATCH GOES AS LISTS where the register takes one (`phoneticOrWidth`, ruled 2026-10-09): up to
61
- // that many spellings per question, within its word budget, each long enough for the ranked search. A
62
- // spelling below that floor is its own question, as every name of a batch was before. Each list is one
63
- // question with one receipt row, so a list that crowds or fails takes no other list down with it. PURE.
68
+ // A BATCH OF SPELLINGS GOES AS THE PLANNER'S LISTS where the register takes one: exact spellings up to
69
+ // `exactOrWidth`, sound-alike ones up to `phoneticOrWidth` within the register's word budget (ruled 2026-10-09
70
+ // for sound-alikes; the exact width is the one the plan's own spelling band already uses), each spelling long
71
+ // enough for the ranked search. A spelling below that floor is its own question, and so is every name of a batch
72
+ // with an owner or on any other predicate, as before. Each list is one question with one receipt row, so a list
73
+ // that crowds or fails takes no other list down with it. PURE.
64
74
  function splitBatch(p, names, capabilities) {
65
75
  const { terms: _batch, ...rest } = p;
66
76
  const single = (t) => ({ ...rest, term: t });
67
- const width = Math.floor(Number(capabilities?.phoneticOrWidth));
68
- if (String(p?.predicate ?? "") !== "phonetic" || !(width >= 2)) return names.map(single);
77
+ const predicate = String(p?.predicate ?? "");
78
+ const width = predicate === "exact" ? planExactOrWidth(capabilities) : predicate === "phonetic" ? planPhoneticOrWidth(capabilities) : 1;
79
+ if (!(width >= 2) || (typeof p?.owner === "string" && p.owner.trim())) return names.map(single);
69
80
  const floor = Number(capabilities?.rankedMinLength) || 0;
70
- const budget = Number(capabilities?.listWordBudget) || Infinity;
81
+ const budget = predicate === "phonetic" ? (Number(capabilities?.listWordBudget) || Infinity) : Infinity;
71
82
  const words = (t) => t.split(/\s+/).filter(Boolean).length;
72
83
  const folded = (t) => [...t.normalize("NFKD").replace(/\p{M}/gu, "").toLowerCase().replace(/\s+/g, "")].length;
73
84
  const out = [];
@@ -427,9 +438,29 @@ export async function proposeSupplemental(params, tctx, deps) {
427
438
  if (!offered.length)
428
439
  return { type: "text", text: JSON.stringify({ minted: [], reused: [], rejected: [], excluded_house_element: excludedHouse, executed: false }, null, 2) };
429
440
  const existingQids = new Set(supp.entries.map((e) => e.qid));
430
- const { minted, reused, rejected, enriched, narrowed } = mintSupplementalEntries(axis, offered,
441
+ let { minted, reused, rejected, enriched, narrowed } = mintSupplementalEntries(axis, offered,
431
442
  { existingQids, capabilities: deps.capabilities ?? null, priorityClasses: planClasses });
432
443
 
444
+ // ── ONE ANSWER PER QUESTION PER RUN (held-answers.mjs) ───────────────────────────────────────────
445
+ // A question another worker or pass has already answered gets that answer, and one the run has decided not to ask
446
+ // gets that decision: it is not minted, not run, and costs no call. A failed answer is no answer, so it is asked again.
447
+ const held = [];
448
+ try {
449
+ const runDir = dirname(dirname(outPath));
450
+ const index = heldAnswers(runDir, { planRegions, withheld: readWithheldFamilies(runDir) });
451
+ if (index.size) {
452
+ const fresh = [];
453
+ for (const e of minted) {
454
+ const h = index.get(questionKey(e, planRegions));
455
+ if (!h || h.qid === e.qid) { fresh.push(e); continue; }
456
+ held.push({ question: compactProposal(e), decision: h.decision, ...(h.qid ? { answered_by: h.qid, axis: h.axis } : {}),
457
+ ...(h.block ? { state: h.block.state ?? null, total_hits: h.block.total_hits ?? null,
458
+ count: h.block.count ?? (Array.isArray(h.block.records) ? h.block.records.length : null), records_preview: preview(h.block.records) } : {}) });
459
+ }
460
+ minted = fresh;
461
+ }
462
+ } catch { /* the held index is an economy: without it every question is asked as before */ }
463
+
433
464
  // Field-level romanisation enrichment of a REUSED qid (2026-07-30 review round): the natural retry —
434
465
  // a bare non-Latin proposal deferred at the wire, the model re-proposes it WITH the romanisation —
435
466
  // reuses the same deterministic qid, and without this the stored bare entry re-executed verbatim and
@@ -486,7 +517,15 @@ export async function proposeSupplemental(params, tctx, deps) {
486
517
  renameSync(`${suppPath}.tmp`, suppPath);
487
518
  }
488
519
 
489
- const qids = [...minted.map((e) => e.qid), ...reused];
520
+ // A question this worker re-proposes that already holds an answer is not run again: the answer stands. One with no
521
+ // answer, a failed one, or one this call enriched or gave its markets runs as before.
522
+ let reusedHeld = [];
523
+ try {
524
+ const own = new Map();
525
+ for (const b of JSON.parse(readFileSync(outPath, "utf8"))) if (b?.qid) own.set(String(b.qid), b);
526
+ reusedHeld = reused.filter((q) => { const b = own.get(String(q)); return b && b.error !== true && !enrichedQids.includes(q) && !staleOnDisk; });
527
+ } catch { reusedHeld = []; }
528
+ const qids = [...minted.map((e) => e.qid), ...reused.filter((q) => !reusedHeld.includes(q))];
490
529
  let summary = null;
491
530
  if (qids.length) {
492
531
  const r = await executePlan({ plan_path: suppPath, axis, output_path: outPath, qids }, tctx);
@@ -497,13 +536,36 @@ export async function proposeSupplemental(params, tctx, deps) {
497
536
  try { summary = JSON.parse(text); } catch { summary = { raw: text.slice(0, 300) }; }
498
537
  }
499
538
 
539
+ // A FOLLOW-UP THE RUN'S SAFETY NET DID NOT SEND never ran and has no answer (follow-up-searches.mjs). One minted by this
540
+ // call leaves the plan the fold reads, so it is never taken for a planned question that went missing, and is
541
+ // kept as a question not asked: a rejected[] row, which the ask ledger keeps open. One that ran before keeps its
542
+ // place and its answer.
543
+ const outstanding = Array.isArray(summary?.outstanding) ? summary.outstanding.map(String) : [];
544
+ const mintedHere = new Map(minted.map((e) => [e.qid, e]));
545
+ const refusedHere = outstanding.filter((q) => mintedHere.has(q));
546
+ if (refusedHere.length) {
547
+ let cur = supp;
548
+ try { cur = JSON.parse(readFileSync(suppPath, "utf8")); } catch { /* the in-memory plan is what was written */ }
549
+ const gone = new Set(refusedHere);
550
+ cur = withRejected({ ...cur, entries: (Array.isArray(cur?.entries) ? cur.entries : []).filter((e) => !gone.has(e?.qid)) },
551
+ // The markets and goods ride on the row: the ask ledger keeps one ask per (issue, proposal), and the compact
552
+ // proposal alone would fold the same question refused for several markets into one ask naming none.
553
+ refusedHere.map((q) => { const e = mintedHere.get(q); return { issue: OUTSTANDING_REASON, proposal: { ...compactProposal(e),
554
+ ...(Array.isArray(e?.regions) && e.regions.length ? { regions: e.regions.map(String).slice(0, 200) } : {}),
555
+ ...(Array.isArray(e?.goods_text) && e.goods_text.length ? { goods_text: e.goods_text.map((g) => String(g).slice(0, 80)).slice(0, 20) } : {}) } }; }),
556
+ { ts: new Date().toISOString(), origin: "propose-tool" });
557
+ mkdirSync(dirname(suppPath), { recursive: true });
558
+ writeFileSync(`${suppPath}.tmp`, JSON.stringify(cur, null, 2) + "\n");
559
+ renameSync(`${suppPath}.tmp`, suppPath);
560
+ }
561
+
500
562
  // read the results BACK FROM THE BAND — the reasoning payload is never model-transcribed
501
563
  const results = {};
502
564
  try {
503
565
  const band = JSON.parse(readFileSync(outPath, "utf8"));
504
566
  const blocks = Array.isArray(band) ? band : [];
505
567
  for (const b of blocks) {
506
- if (!b || typeof b !== "object" || !b.qid || !qids.includes(b.qid)) continue;
568
+ if (!b || typeof b !== "object" || !b.qid || !(qids.includes(b.qid) || reusedHeld.includes(b.qid))) continue;
507
569
  results[b.qid] = {
508
570
  state: b.state ?? null,
509
571
  total_hits: b.total_hits ?? null,
@@ -517,5 +579,5 @@ export async function proposeSupplemental(params, tctx, deps) {
517
579
  }
518
580
  } catch { /* band unreadable — the executor summary still crosses */ }
519
581
 
520
- return { type: "text", text: JSON.stringify({ minted: minted.map((e) => e.qid), reused, ...(enrichedQids.length ? { enriched: enrichedQids } : {}), ...(conflicts.length ? { conflict: conflicts } : {}), rejected, ...(excludedHouse.length ? { excluded_house_element: excludedHouse } : {}), ...(narrowed.length ? { narrowed } : {}), executed: qids.length > 0, summary, results }, null, 2) };
582
+ return { type: "text", text: JSON.stringify({ minted: minted.map((e) => e.qid).filter((q) => !refusedHere.includes(q)), reused, ...(enrichedQids.length ? { enriched: enrichedQids } : {}), ...(conflicts.length ? { conflict: conflicts } : {}), rejected, ...(outstanding.length ? { outstanding } : {}), ...(held.length ? { held } : {}), ...(outstanding.length || held.some((h) => h.decision === "outstanding") ? { outstanding_note: OUTSTANDING_NOTE } : {}), ...(reusedHeld.length ? { held_answer_kept: reusedHeld } : {}), ...(excludedHouse.length ? { excluded_house_element: excludedHouse } : {}), ...(narrowed.length ? { narrowed } : {}), executed: qids.length > 0, summary, results }, null, 2) };
521
583
  }
@@ -2,7 +2,7 @@
2
2
  "name": "clearotron-driver",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.5.0-beta.0",
5
+ "version": "0.5.0-beta.1",
6
6
  "license": "AGPL-3.0-only",
7
7
  "description": "Deterministic driver for the trademark clearance workflow: orchestration in code (fan-out, fan-in barrier, gating, retries); the model does judgment leaves only, through a reasoning CLI spawned per stage.",
8
8
  "engines": {