clearotron 0.3.3 → 0.4.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 (142) hide show
  1. package/.env.example +9 -0
  2. package/INSTALL.md +1 -14
  3. package/bin/brandowner.mjs +5 -5
  4. package/bin/connect.mjs +4 -4
  5. package/bin/onboard.mjs +5 -7
  6. package/bin/start.mjs +20 -5
  7. package/bin/update.mjs +6 -1
  8. package/build-info.json +2 -2
  9. package/docs/architecture/04-configuration-reference.md +2 -6
  10. package/driver/CHANGELOG.md +28 -0
  11. package/driver/ask-ledger.mjs +2 -2
  12. package/driver/band-shape.mjs +8 -8
  13. package/driver/blind-frame-model.mjs +1 -1
  14. package/driver/common-law-receipts.mjs +2 -2
  15. package/driver/commonlaw-carry.mjs +2 -2
  16. package/driver/company-bundle.mjs +11 -18
  17. package/driver/connotation-search.mjs +4 -4
  18. package/driver/contract-e3-backlog.mjs +3 -3
  19. package/driver/declination-call.mjs +1 -1
  20. package/driver/declination-tool.mjs +1 -1
  21. package/driver/dev-portal.mjs +1 -1
  22. package/driver/door-call-verdict.mjs +27 -0
  23. package/driver/driver.config.mjs +17 -11
  24. package/driver/e2e/README.md +1 -1
  25. package/driver/engine/mcp/clarivate-server.mjs +2 -1
  26. package/driver/engine/mcp/corsearch-server.mjs +1 -0
  27. package/driver/engine/mcp/euipo-server.mjs +1 -0
  28. package/driver/engine/mcp/free-tier-server.mjs +1 -1
  29. package/driver/engine/mcp/gather-config.mjs +1 -1
  30. package/driver/engine/mcp/perplexity-server.mjs +1 -1
  31. package/driver/engine/mcp/recording-server.mjs +1 -1
  32. package/driver/engine/mcp/signa-server.mjs +1 -0
  33. package/driver/engine/mcp/supplemental.mjs +1 -1
  34. package/driver/engine/mcp/uspto-local-server.mjs +1 -0
  35. package/driver/enqueue-schema.mjs +2 -2
  36. package/driver/feedback-issues.mjs +1 -1
  37. package/driver/feedback-store.mjs +1 -1
  38. package/driver/findings-model.mjs +3 -3
  39. package/driver/flag-snapshot.mjs +1 -1
  40. package/driver/floor-duty.mjs +2 -2
  41. package/driver/form-neighbourhood.mjs +47 -15
  42. package/driver/frame-diff-model.mjs +3 -3
  43. package/driver/gateway.mjs +5 -5
  44. package/driver/jx-lanes.mjs +1 -1
  45. package/driver/known-conflicts.mjs +18 -0
  46. package/driver/log.mjs +2 -2
  47. package/driver/package.json +1 -1
  48. package/driver/pipeline-knockout.mjs +129 -95
  49. package/driver/pipeline.mjs +69 -32
  50. package/driver/placement-carry.mjs +2 -2
  51. package/driver/placement-form.mjs +1 -1
  52. package/driver/portal-mcp-client.mjs +1 -1
  53. package/driver/portal-request-origin.mjs +79 -0
  54. package/driver/portal-service.mjs +45 -17
  55. package/driver/predelivery-lint.mjs +10 -10
  56. package/driver/profile-page.html +9 -13
  57. package/driver/profile-service.mjs +25 -13
  58. package/driver/profiles.mjs +17 -4
  59. package/driver/progress.mjs +1 -1
  60. package/driver/provider-usage.mjs +24 -1
  61. package/driver/publish/index.mjs +17 -5
  62. package/driver/publish/knockout.mjs +3 -2
  63. package/driver/publish/render-knockout.mjs +1 -1
  64. package/driver/publish/render.mjs +14 -3
  65. package/driver/publish/search-depth.mjs +4 -2
  66. package/driver/recall-reconciliation.mjs +1 -1
  67. package/driver/record-carry.mjs +6 -6
  68. package/driver/recording-agreement.mjs +2 -2
  69. package/driver/reference-score.mjs +27 -27
  70. package/driver/register-count.mjs +56 -1
  71. package/driver/register-digest-record.mjs +1 -1
  72. package/driver/register-plan.mjs +5 -5
  73. package/driver/register-records.mjs +10 -1
  74. package/driver/registry-fidelity.mjs +4 -4
  75. package/driver/repair-composers.mjs +6 -6
  76. package/driver/run-economics.mjs +8 -19
  77. package/driver/screen-gate.mjs +1 -1
  78. package/driver/skills/blind-frame/SKILL.md +2 -2
  79. package/driver/skills/clearance-common-law/SKILL.md +2 -2
  80. package/driver/skills/clearance-common-law/perplexity-prompts.md +4 -4
  81. package/driver/skills/clearance-register/digest.md +1 -1
  82. package/driver/skills/clearance-register/unit.md +1 -1
  83. package/driver/skills/clearance-search/report-prose.md +5 -5
  84. package/driver/skills/clearance-search/synthesis-rules.md +4 -4
  85. package/driver/skills/clearance-variants/SKILL.md +2 -2
  86. package/driver/skills/clearance-variants/transliteration-scripts.md +1 -1
  87. package/driver/skills/frame-diff/SKILL.md +2 -2
  88. package/driver/skills/knockout-assess/SKILL.md +9 -9
  89. package/driver/skills/matter-frame/watchlist-reference.md +1 -1
  90. package/driver/skills/narrative-refutation/SKILL.md +2 -2
  91. package/driver/skills/placement-inquiry/SKILL.md +1 -1
  92. package/driver/stage-context.mjs +4 -4
  93. package/driver/stages.mjs +23 -15
  94. package/driver/suite-census.json +138 -42
  95. package/driver/systemd/clearotron-client-mcp.service +24 -0
  96. package/driver/systemd/clearotron-mcp-face.service +24 -0
  97. package/driver/systemd/clearotron-portal.service +24 -0
  98. package/driver/systemd/clearotron-worker.service +24 -0
  99. package/driver/tokens.mjs +26 -17
  100. package/driver/turnaround-bands.mjs +1 -1
  101. package/driver/unit-inventory.mjs +3 -3
  102. package/driver/variant-manifest-model.mjs +1 -1
  103. package/driver/verify.mjs +2 -2
  104. package/driver/whatif-memo-run.mjs +1 -1
  105. package/mcp-server/CHANGELOG.md +8 -0
  106. package/mcp-server/lib/audit.mjs +9 -2
  107. package/mcp-server/lib/http-handler.mjs +7 -3
  108. package/mcp-server/mint-token.mjs +8 -6
  109. package/mcp-server/package.json +1 -1
  110. package/mcp-server/server.mjs +11 -1
  111. package/package.json +1 -1
  112. package/portal-ui/dist/assets/{index-GBbbyQxc.js → index-D_O_55vK.js} +59 -9
  113. package/portal-ui/dist/index.html +1 -1
  114. package/portal-ui/package.json +1 -1
  115. package/providers/_shared/README.md +1 -1
  116. package/providers/_shared/answer-memory.mjs +199 -0
  117. package/providers/_shared/ledger-path.mjs +1 -1
  118. package/providers/_shared/ledger.mjs +47 -5
  119. package/providers/_shared/script-form.mjs +24 -5
  120. package/providers/_shared/term-shape.mjs +5 -5
  121. package/providers/clarivate/src/capabilities.js +11 -0
  122. package/providers/clarivate/src/core.js +140 -13
  123. package/providers/jx-subclass/lookup.mjs +1 -1
  124. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  125. package/providers/oauth-mcp-bridge/package.json +1 -1
  126. package/providers/signa/src/capabilities.js +24 -0
  127. package/providers/signa/src/core.js +66 -0
  128. package/scripts/deprecate-below.mjs +114 -2
  129. package/scripts/freeze-example-run.mjs +1 -1
  130. package/scripts/live-surface-check.mjs +11 -2
  131. package/scripts/release-entry-catch-up.mjs +211 -0
  132. package/scripts/release-note-required.mjs +102 -6
  133. package/scripts/release-rehearsal-version.mjs +60 -0
  134. package/scripts/release-sbom.mjs +104 -0
  135. package/scripts/release-visible-check.mjs +7 -5
  136. package/scripts/score.mjs +3 -3
  137. package/shared/brand.mjs +1 -1
  138. package/shared/client-door.mjs +15 -8
  139. package/shared/driver-dir.mjs +20 -9
  140. package/shared/names-in-force.mjs +2 -0
  141. package/shared/scope.mjs +25 -10
  142. package/shared/store-in-repo.mjs +38 -17
@@ -0,0 +1,199 @@
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
+ // answer-memory.mjs — a run remembers the register's answer to a question it has already asked.
4
+ //
5
+ // WHY. A register that bills per request charges again for every repeat, and a run repeats itself: a
6
+ // repair re-sends a whole axis, a re-attempt re-asks questions that already answered, a proposal is
7
+ // asked again later in the run. The memory sits at the provider's one HTTP chokepoint, so every caller
8
+ // is covered without each of them learning to share.
9
+ //
10
+ // WHERE IT LIVES. In the run's own `_driver/` folder, because the requests come from two kinds of
11
+ // process — the long-lived driver and the tool servers a stage spawns — and the repeats cross between
12
+ // them. Each already knows the run's record log: the driver passes it on `tctx.recordLog`, and a
13
+ // spawned server has it in CLEAROTRON_REGISTER_RECORD_LOG. The memory is the folder beside that file,
14
+ // so nothing new is handed to either.
15
+ //
16
+ // ONE ATTEMPT. The driver clears the folder when an attempt starts and removes it when the attempt
17
+ // ends, so a resume asks the register again. A folder whose attempt began more than a day ago is
18
+ // ignored, for an attempt that died without cleaning up. A process that finds no folder — a bare
19
+ // probe, a test, a register with no run — asks the register exactly as it always has.
20
+ //
21
+ // EXCEPT A RECORD, WHICH IS HELD FOR THE WHOLE RUN. Clarivate's record reuse (`heldRecords` in its core)
22
+ // reads the run's record log, not this folder, and that log keeps every attempt's records. So a run
23
+ // resumed days later screens a record from the copy an earlier attempt fetched, and its row says what the
24
+ // register said then. That is the ruling on this memory, that a run never fetches the same record twice;
25
+ // the questions above are asked again on a resume, the records are not.
26
+ //
27
+ // THREE MODES, fixed when the attempt starts from the register's own switch (ANSWER_MEMORY_PROVIDERS):
28
+ // off nothing is remembered and nothing is written.
29
+ // watch every request still goes to the register. Per request, the memory records whether it held
30
+ // an answer to the identical question, and whether the fresh answer has the same total, the
31
+ // same record ids and the same order as the one it held. This is the evidence the switch to
32
+ // `on` waits for.
33
+ // on a held answer is returned and the register is not asked.
34
+ //
35
+ // WHAT IS KEPT is the provider's decision, because only the provider knows which bodies are complete
36
+ // answers (see `rememberableAnswer` in the Signa core). An answer that points at a next page is held
37
+ // only for NEXT_PAGE_FRESH_MS: the cursor it carries is the register's, and nothing says how long the
38
+ // register honours it.
39
+ //
40
+ // NEVER THROWS. Every failure here reads as "nothing held" or "not stored", which is the behaviour
41
+ // the run had before the memory existed.
42
+
43
+ import { appendFileSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
44
+ import { basename, dirname, join } from "node:path";
45
+ import { createHash } from "node:crypto";
46
+ import { gunzipSync, gzipSync } from "node:zlib";
47
+ import { driverDir } from "../../shared/driver-dir.mjs";
48
+ import { RUN_RECORD_LOG_FILE } from "./ledger-path.mjs";
49
+
50
+ export const ANSWER_MEMORY_MODES = Object.freeze(["off", "watch", "on"]);
51
+
52
+ /**
53
+ * The registers whose core consults this memory, the environment switch that sets it for each (`env`, the
54
+ * table shape the configuration audit reads), and the mode a run takes when that switch is unset. Every other register is `off`, whatever any switch says.
55
+ *
56
+ * SIGNA starts `off`: it goes `on` only after runs in watch mode show no mismatch, by the owner's ruling.
57
+ * CLARIVATE starts `on`, with no watch period, also by his ruling: the repeated answers of its past runs
58
+ * were already compared and matched.
59
+ */
60
+ export const ANSWER_MEMORY_PROVIDERS = Object.freeze({
61
+ signa: Object.freeze({ env: "CLEAROTRON_SIGNA_ANSWER_MEMORY", defaultMode: "off" }),
62
+ clarivate: Object.freeze({ env: "CLEAROTRON_CLARIVATE_ANSWER_MEMORY", defaultMode: "on" }),
63
+ });
64
+ export const ANSWER_MEMORY_SWITCH = ANSWER_MEMORY_PROVIDERS.signa.env;
65
+ export const ANSWER_MEMORY_DIR = "register-answers";
66
+ export const ANSWER_WATCH_LOG = "register-answer-watch.jsonl";
67
+ const ATTEMPT_FILE = "attempt.json";
68
+ export const ATTEMPT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
69
+ // One hour: a next-page link has been seen to work an hour after the register gave it, and nothing says it
70
+ // works for longer. A held answer older than this is simply asked again. A park ends the attempt anyway,
71
+ // and a resumed run starts with an empty memory, so no link survives a park.
72
+ export const NEXT_PAGE_FRESH_MS = 60 * 60 * 1000;
73
+
74
+ /**
75
+ * The mode a register's switch asks for. Unset, or naming none of the three, is that register's default,
76
+ * and a value that named none of them is returned as `unknown` so the run can say so.
77
+ */
78
+ export function answerMemoryMode(env = process.env, provider = "signa") {
79
+ const spec = ANSWER_MEMORY_PROVIDERS[String(provider ?? "")];
80
+ if (!spec) return { mode: "off", unknown: null };
81
+ const raw = String(env?.[spec.env] ?? "").trim().toLowerCase();
82
+ if (!raw) return { mode: spec.defaultMode, unknown: null };
83
+ return ANSWER_MEMORY_MODES.includes(raw) ? { mode: raw, unknown: null } : { mode: spec.defaultMode, unknown: raw };
84
+ }
85
+
86
+ /**
87
+ * Begin an attempt: whatever an earlier attempt left is removed, and a folder is made only for
88
+ * `watch` or `on`. Returns the folder, or null when the memory is off. Called by the driver alone.
89
+ */
90
+ export function startAnswerMemory(runDir, mode, { now = Date.now } = {}) {
91
+ const dir = driverDir(runDir, ANSWER_MEMORY_DIR);
92
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* nothing to clear */ }
93
+ if (mode !== "watch" && mode !== "on") return null;
94
+ try {
95
+ mkdirSync(dir, { recursive: true });
96
+ writeFileSync(join(dir, ATTEMPT_FILE), JSON.stringify({ mode, started_ms: now() }) + "\n");
97
+ return dir;
98
+ } catch { return null; }
99
+ }
100
+
101
+ /**
102
+ * The driver's half of an attempt: the mode this run uses, with the folder begun for it. `applies` is
103
+ * false for a register that does not use the memory, and then nothing is written at all, so that
104
+ * register's run folder is exactly what it was. A folder that cannot be made reads as `off`.
105
+ */
106
+ export function beginAnswerMemory(runDir, provider, { env = process.env, now = Date.now } = {}) {
107
+ const spec = ANSWER_MEMORY_PROVIDERS[String(provider ?? "")];
108
+ if (!spec) return { mode: "off", unknown: null, applies: false, switch: null };
109
+ const { mode, unknown } = answerMemoryMode(env, provider);
110
+ const dir = startAnswerMemory(runDir, mode, { now });
111
+ return { mode: dir ? mode : "off", unknown, applies: true, switch: spec.env };
112
+ }
113
+
114
+ /** End an attempt. The watch log stays; the held answers go. */
115
+ export function endAnswerMemory(runDir) {
116
+ try { rmSync(driverDir(runDir, ANSWER_MEMORY_DIR), { recursive: true, force: true }); } catch { /* already gone */ }
117
+ }
118
+
119
+ /**
120
+ * The run's memory for this request, or null. Found from the run's record log, which is the only
121
+ * address both kinds of process already hold. The box-global ledger's file has a different name, so a
122
+ * process pointed at it finds no run and no memory.
123
+ */
124
+ export function openAnswerMemory(recordLog = null, { env = process.env, now = Date.now } = {}) {
125
+ try {
126
+ const log = typeof recordLog === "string" && recordLog.trim()
127
+ ? recordLog.trim() : String(env?.CLEAROTRON_REGISTER_RECORD_LOG ?? "").trim();
128
+ if (!log || basename(log) !== RUN_RECORD_LOG_FILE) return null;
129
+ const dir = join(dirname(log), ANSWER_MEMORY_DIR);
130
+ const attempt = JSON.parse(readFileSync(join(dir, ATTEMPT_FILE), "utf8"));
131
+ if (attempt?.mode !== "watch" && attempt?.mode !== "on") return null;
132
+ if (!Number.isFinite(attempt.started_ms) || now() - attempt.started_ms > ATTEMPT_MAX_AGE_MS) return null;
133
+ return { mode: attempt.mode, dir, watchLog: join(dirname(log), ANSWER_WATCH_LOG) };
134
+ } catch { return null; }
135
+ }
136
+
137
+ /** The identity of a question: everything that decides the answer, and nothing that does not. */
138
+ export function answerKey(question) {
139
+ return createHash("sha256").update(JSON.stringify(question)).digest("hex");
140
+ }
141
+
142
+ /**
143
+ * The held answer to a question, or null. An answer that points at a next page is returned `stale`
144
+ * once NEXT_PAGE_FRESH_MS has passed: it is not served, and the watch log records that it was held, so
145
+ * the log shows what the rule costs.
146
+ */
147
+ export function recallAnswer(mem, key, { now = Date.now } = {}) {
148
+ try {
149
+ const held = JSON.parse(gunzipSync(readFileSync(join(mem.dir, `${key}.json.gz`))).toString("utf8"));
150
+ const age = now() - held.stored_ms;
151
+ return { ...held, age_ms: age, stale: held?.summary?.next_page === true && age > NEXT_PAGE_FRESH_MS };
152
+ } catch { return null; }
153
+ }
154
+
155
+ /**
156
+ * Keep an answer. Written whole and renamed into place, so a process reading at the same moment sees
157
+ * the old state or the new one and never half of either. Never makes the folder: an attempt that has
158
+ * ended has no memory to write into.
159
+ */
160
+ export function rememberAnswer(mem, key, entry, { now = Date.now } = {}) {
161
+ const dest = join(mem.dir, `${key}.json.gz`);
162
+ const tmp = `${dest}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
163
+ try {
164
+ writeFileSync(tmp, gzipSync(JSON.stringify({ ...entry, stored_ms: now() })));
165
+ renameSync(tmp, dest);
166
+ return true;
167
+ } catch {
168
+ try { rmSync(tmp, { force: true }); } catch { /* nothing written */ }
169
+ return false;
170
+ }
171
+ }
172
+
173
+ /** Drop a held answer, so the next identical question goes to the register. Never throws. */
174
+ export function forgetAnswer(mem, key) {
175
+ try { rmSync(join(mem.dir, `${key}.json.gz`), { force: true }); return true; } catch { return false; }
176
+ }
177
+
178
+ /** One line per request in the watch log. Small, so concurrent appends from two processes stay whole. */
179
+ export function noteAnswer(mem, row) {
180
+ try { appendFileSync(mem.watchLog, JSON.stringify(row) + "\n"); } catch { /* the log is evidence, never a gate */ }
181
+ }
182
+
183
+ /**
184
+ * Three separate answers, because they mean different things. A different total or a different set
185
+ * of ids is a different answer, and the memory must not go live while either happens. A different
186
+ * order of the same ids is a register that breaks ties differently from one request to the next: the
187
+ * memory returns one of the orders the register itself gives.
188
+ */
189
+ export function compareAnswers(held, fresh) {
190
+ const a = Array.isArray(held?.ids) ? held.ids : [];
191
+ const b = Array.isArray(fresh?.ids) ? fresh.ids : [];
192
+ const sa = [...a].sort(), sb = [...b].sort();
193
+ const same_ids = sa.length === sb.length && sa.every((v, i) => v === sb[i]);
194
+ return {
195
+ same_total: (held?.total ?? null) === (fresh?.total ?? null),
196
+ same_ids,
197
+ same_order: same_ids && a.every((v, i) => v === b[i]),
198
+ };
199
+ }
@@ -305,7 +305,7 @@ export function retiredGlobalRecordLogNotice(env = process.env) {
305
305
  if (announced.has(key)) return null;
306
306
  announced.add(key);
307
307
  const bytes = sizeOf(r.path);
308
- return `[ledger] the box-global record log ${r.path}${bytes === null ? "" : ` (${bytes} bytes)`} is RETIRED (#743) — `
308
+ return `[ledger] the box-global record log ${r.path}${bytes === null ? "" : ` (${bytes} bytes)`} is RETIRED — `
309
309
  + `register response bodies now live in each run's _driver/${RUN_RECORD_LOG_FILE} and are archived and purged with the run. `
310
310
  + `Nothing writes to or reads this file any more; archive it once (mv it aside) at your convenience.`;
311
311
  }
@@ -135,15 +135,21 @@ export function makeLedger(provider) {
135
135
  //
136
136
  // Run-scoped ledgers only. The box-wide fallback file holds many runs, and a record one run fetched is not
137
137
  // a record another run holds.
138
- const RUN_INDEXES = new Map(); // dest → { offset, byTarget: Map<key, { hash, refreshed, ts }> }
138
+ const RUN_INDEXES = new Map(); // dest → { offset, byTarget: Map<key, { hash, refreshed, ts, start, len }>, byGuid: Map<guid, key> }
139
139
  const targetKey = (t) => String(t ?? "").toLowerCase(); // the key every reader of this file matches on
140
140
  const bodyHash = (b) => createHash("sha1").update(JSON.stringify(b ?? null)).digest("hex");
141
+ // The record's own id: the last segment of `/mark/<office>/<id>`. The office segment is a hint that can
142
+ // differ between two answers for the same record, so a lookup by record keys on this and never on it.
143
+ const guidOf = (t) => { const k = targetKey(t); const i = k.lastIndexOf("/"); return i >= 0 ? k.slice(i + 1) : k; };
141
144
 
142
- function indexRow(ix, line) {
145
+ // `start` and `len` are the line's byte range, so one record can be read back without reading the file.
146
+ function indexRow(ix, line, start = null, len = null) {
143
147
  if (!line.trim()) return;
144
148
  try {
145
149
  const r = JSON.parse(line);
146
- ix.byTarget.set(targetKey(r?.target), { hash: bodyHash(r?.body), refreshed: r?.refreshed === true, ts: r?.ts ?? null });
150
+ const key = targetKey(r?.target);
151
+ ix.byTarget.set(key, { hash: bodyHash(r?.body), refreshed: r?.refreshed === true, ts: r?.ts ?? null, start, len });
152
+ if (key) ix.byGuid.set(guidOf(key), key);
147
153
  } catch { /* a torn or foreign line indexes nothing, and is left where it is */ }
148
154
  }
149
155
 
@@ -154,7 +160,7 @@ function indexOf(dest) {
154
160
  let ix = RUN_INDEXES.get(dest);
155
161
  // A replacement elsewhere renames a NEW file over this one: same path, different inode, and an offset
156
162
  // into the old file means nothing in the new one. Re-read from the start.
157
- if (!ix || size < ix.offset || ix.ino !== ino) { ix = { offset: 0, ino, byTarget: new Map() }; RUN_INDEXES.set(dest, ix); }
163
+ if (!ix || size < ix.offset || ix.ino !== ino) { ix = { offset: 0, ino, byTarget: new Map(), byGuid: new Map() }; RUN_INDEXES.set(dest, ix); }
158
164
  if (size <= ix.offset) return ix;
159
165
  const fd = openSync(dest, "r");
160
166
  try {
@@ -167,9 +173,14 @@ function indexOf(dest) {
167
173
  if (n <= 0) break;
168
174
  pos += n;
169
175
  const data = carry.length ? Buffer.concat([carry, buf.subarray(0, n)]) : buf.subarray(0, n);
176
+ const base = pos - data.length; // the file offset of data[0]
170
177
  const last = data.lastIndexOf(0x0a);
171
178
  if (last < 0) { carry = data; continue; }
172
- for (const line of data.subarray(0, last).toString("utf8").split("\n")) indexRow(ix, line);
179
+ for (let at = 0; at <= last;) {
180
+ const nl = data.indexOf(0x0a, at);
181
+ indexRow(ix, data.subarray(at, nl).toString("utf8"), base + at, nl - at);
182
+ at = nl + 1;
183
+ }
173
184
  carry = data.subarray(last + 1); // a line still being written waits for the next look
174
185
  ix.offset = pos - carry.length;
175
186
  }
@@ -177,6 +188,37 @@ function indexOf(dest) {
177
188
  return ix;
178
189
  }
179
190
 
191
+ /**
192
+ * The record bodies this run already holds, for the given record ids: Map<id, body>, lowercased ids.
193
+ *
194
+ * Read off the run's own record log, which every process that fetches a record for the run writes to,
195
+ * so a record the driver fetched is held for a tool server and the other way round. A run-scoped log only:
196
+ * the box-wide fallback file holds many runs, and one run's record is not another's. Anything that cannot
197
+ * be read reads as not held, which costs a fetch and never an answer.
198
+ */
199
+ export function heldRecordBodies(dest, ids) {
200
+ const out = new Map();
201
+ if (typeof dest !== "string" || basename(dest) !== RUN_RECORD_LOG_FILE) return out;
202
+ let ix;
203
+ try { ix = indexOf(dest); } catch { return out; }
204
+ const wanted = [...new Set((ids ?? []).map((g) => String(g ?? "").toLowerCase()).filter(Boolean))]
205
+ .map((g) => [g, ix.byTarget.get(ix.byGuid.get(g) ?? "")]).filter(([, e]) => e && Number.isInteger(e.start) && Number.isInteger(e.len));
206
+ if (!wanted.length) return out;
207
+ let fd;
208
+ try {
209
+ fd = openSync(dest, "r");
210
+ for (const [g, e] of wanted) {
211
+ try {
212
+ const buf = Buffer.alloc(e.len);
213
+ if (readSync(fd, buf, 0, e.len, e.start) !== e.len) continue;
214
+ const row = JSON.parse(buf.toString("utf8"));
215
+ if (row?.body && typeof row.body === "object") out.set(g, row.body);
216
+ } catch { /* a line that moved under us is simply not held */ }
217
+ }
218
+ } catch { /* unreadable: nothing held */ } finally { if (fd !== undefined) try { closeSync(fd); } catch { /* closed */ } }
219
+ return out;
220
+ }
221
+
180
222
  const sleepMs = (ms) => { try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* no sleep available */ } };
181
223
 
182
224
  /** Run `fn` holding the ledger's lock; false (and `fn` not run) when the lock could not be had in time. */
@@ -44,6 +44,25 @@ export const NON_LATIN_RE = /[^\p{Script=Latin}\p{Script=Common}\p{Script=Inheri
44
44
 
45
45
  export function isNonLatinTerm(term) { return NON_LATIN_RE.test(String(term ?? "")); }
46
46
 
47
+ /**
48
+ * Does the term MIX Latin letters with Greek or Cyrillic ones? `τιmbεr` does; `timber`, `τιμβερ` and
49
+ * `тимбер` do not, and neither does a term whose only other characters are digits or punctuation, which
50
+ * belong to no script of their own.
51
+ *
52
+ * The look-alike generator in driver/form-neighbourhood.mjs writes such terms: it swaps every letter
53
+ * that has a Greek or Cyrillic twin and leaves the rest Latin. A register may answer that spelling as
54
+ * if the non-Latin letters were not there, returning marks that share only the Latin remainder, or may
55
+ * not take it at all. `capabilities.mixedScriptQuery: false` declares that the register cannot search
56
+ * such a spelling as written, and the form band leaves these spellings out there and lists them as not
57
+ * searched.
58
+ */
59
+ const LATIN_LETTER_RE = /\p{Script=Latin}/u;
60
+ const GREEK_OR_CYRILLIC_RE = /[\p{Script=Greek}\p{Script=Cyrillic}]/u;
61
+ export function mixesLatinWithGreekOrCyrillic(term) {
62
+ const s = String(term ?? "");
63
+ return LATIN_LETTER_RE.test(s) && GREEK_OR_CYRILLIC_RE.test(s);
64
+ }
65
+
47
66
  /**
48
67
  * The declaration-driven policy. Given a provider's capability contract and the MARK TERMS a query is
49
68
  * about to carry, return the plain-English gap reason — or null when the slice may be dispatched.
@@ -121,15 +140,15 @@ export function romanizationSpellings(value) {
121
140
  *
122
141
  * Two folds fused, and the seam between them is the whole point:
123
142
  * - LATIN text is accent-folded: lowercase, NFKD, then combining marks stripped — but ONLY the
124
- * marks sitting on a Latin base letter — then punctuation/whitespace dropped. "Tikí-Slush" and
125
- * "CORAL FREEZE" collide, exactly as the owner-formative dedup has always wanted.
143
+ * marks sitting on a Latin base letter — then punctuation/whitespace dropped. "Wavó-Slush" and
144
+ * "WAVO SLUSH" collide, exactly as the owner-formative dedup has always wanted.
126
145
  * - NON-LATIN text keeps every combining mark. In most non-Latin scripts a combining mark is not
127
146
  * an accent, it is part of WHICH LETTER this is: Japanese dakuten/handakuten (タ=ta vs ダ=da),
128
147
  * Thai vowel signs, Devanagari matras, Arabic diacritics. A fold that strips them (the previous
129
148
  * shape of this function: bare NFKD + strip all \p{M}) collapses MARK-DISTINGUISHED SIBLINGS
130
- * into one key: ティキスラッシュ
131
- * (TIKI SURASSHU) and ディキスラッシュ (DIKI SURASSHU) keyed identically, so the romanisation
132
- * lookup handed one sibling the OTHER's romanisation and the dictated DIKI form was never
149
+ * into one key: ワボスラッシュ
150
+ * (WABO SURASSHU) and ワホスラッシュ (WAHO SURASSHU) keyed identically, so the romanisation
151
+ * lookup handed one sibling the OTHER's romanisation and the dictated WAHO form was never
133
152
  * searched anywhere — a silent wrong-query false clean, the exact class the carriage fix exists
134
153
  * to kill. NFKD is kept for its width folding (half-width ガ and full-width ガ are the same
135
154
  * letter); only the mark-stripping is script-scoped.
@@ -6,12 +6,12 @@
6
6
  // Two defect classes shipped as SILENT CLEANS, and both were shape-vs-predicate
7
7
  // disagreements nobody checked:
8
8
  //
9
- // * WILDCARD-UNDER-LITERAL: the frozen plan carried {predicate:"exact", term:"TIKI*"} ×4. Dispatch
9
+ // * WILDCARD-UNDER-LITERAL: the frozen plan carried {predicate:"exact", term:"WAVO*"} ×4. Dispatch
10
10
  // never inspects term characters on a literal predicate, so the provider searched the star as a
11
11
  // character, found nothing, and the band recorded state:"enumerated", total_hits:0 — a
12
12
  // schema-level confident clean over a slice that was never really searched.
13
- // * LABEL-AS-TERM: a frame-diff directive's display label ("Reverse-order TIKI composites
14
- // (TROPICAL TIKI, ISLAND TIKI)") was dispatched verbatim as a mark term. Structured transport,
13
+ // * LABEL-AS-TERM: a frame-diff directive's display label ("Reverse-order WAVO composites
14
+ // (TROPICAL WAVO, ISLAND WAVO)") was dispatched verbatim as a mark term. Structured transport,
15
15
  // prose value — same nil search, same false clean.
16
16
  //
17
17
  // This module is the shared detector all four seams call: the plan freeze-lint
@@ -88,7 +88,7 @@ export function termPredicateIssue(term, predicate) {
88
88
  // state:"enumerated", total_hits:0 — a false clean, quieter than the failure.
89
89
  //
90
90
  // The three arms are the ones the issue names, and NOT the bracket: `predicate:"owner"` rows carry
91
- // parenthesised company names ("Delphi Technologies (BorgWarner Inc.)") and a bracket rule breaks
91
+ // parenthesised company names ("Korphi Technologies (BorgWarner Inc.)") and a bracket rule breaks
92
92
  // owner search, which is the very lane this screen protects. Every arm fires at ANY word count.
93
93
  //
94
94
  // `**` / `__` markdown emphasis. No register indexes it; a mark cannot contain it.
@@ -178,7 +178,7 @@ export function termShapeIssue(term) {
178
178
  * TWO EXEMPTIONS, AND THEY ARE NOT THE SAME EXEMPTION:
179
179
  *
180
180
  * `predicate: "owner"` is exempt from EVERYTHING, first, before any other test. Owner names are
181
- * long, prose-shaped and parenthesised by nature ("Delphi Technologies (BorgWarner Inc.)"), they
181
+ * long, prose-shaped and parenthesised by nature ("Korphi Technologies (BorgWarner Inc.)"), they
182
182
  * ride their own field, and the cross-check lane mints them — so this is what makes it safe to
183
183
  * screen that lane at all.
184
184
  *
@@ -280,6 +280,17 @@ export const CAPABILITIES = Object.freeze({
280
280
  // transliteration is itself a silent zero, where the same term under contains answers). A slice
281
281
  // rescued that way is answerable and is never refused; only a native term with no romanisation defers.
282
282
  nativeScriptIndex: false,
283
+ // A term mixing Latin letters with Greek or Cyrillic ones is not searched here. The index holds
284
+ // non-Latin filings by transliteration only (`nativeScriptIndex` above), so such a term used to be
285
+ // compiled and then deferred, and a deferred look-alike was counted as a search not completed. `false`
286
+ // leaves those spellings out of the form band and lists them there as not searched, as on Signa.
287
+ mixedScriptQuery: false,
288
+ // A knockout's listing already holds its count. The listing's `/search` asks the count lane's exact
289
+ // question, with the same body `/count` takes, and returns every matching id, so its length is the
290
+ // register's total. A knockout therefore lists first and takes the identical and close counts from the
291
+ // listing (driver/register-count.mjs listingAnswers). A term the listing did not answer, including one
292
+ // `/search` refuses for exceeding the result ceiling, is counted with `/count` as before.
293
+ listingAnswersCount: true,
283
294
  // No phoneme expansion knob: PHONETIC_WORD_MARK_SPECIFICATION is the whole surface; the client cannot
284
295
  // hand it a variant list. (/similarity/word/* — which would be the expansion surface — is genuinely
285
296
  // not available on this provider; the endpoint answers 403. Do NOT wire it.)