clearotron 0.4.1-beta.5 → 0.4.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 (45) hide show
  1. package/.env.example +7 -0
  2. package/build-info.json +2 -2
  3. package/docs/architecture/04-configuration-reference.md +1 -0
  4. package/driver/CHANGELOG.md +42 -0
  5. package/driver/ask-ledger.mjs +6 -1
  6. package/driver/band-shape.mjs +12 -4
  7. package/driver/close-verify.mjs +27 -8
  8. package/driver/engine/anthropic-agent.mjs +7 -3
  9. package/driver/engine/common.mjs +10 -3
  10. package/driver/engine/mcp/band-server.mjs +55 -12
  11. package/driver/engine/mcp/codex-config.mjs +24 -4
  12. package/driver/engine/mcp/gather-config.mjs +4 -3
  13. package/driver/gateway.mjs +13 -5
  14. package/driver/package.json +1 -1
  15. package/driver/pipeline.mjs +87 -31
  16. package/driver/predelivery-lint.mjs +1 -1
  17. package/driver/record-goods.mjs +30 -0
  18. package/driver/remedy-accounting.mjs +2 -6
  19. package/driver/skills/knockout-assess/SKILL.md +2 -2
  20. package/driver/stages-knockout.mjs +0 -13
  21. package/driver/stages.mjs +11 -2
  22. package/driver/suite-census.json +81 -3
  23. package/driver/verify-knockout.mjs +27 -10
  24. package/mcp-server/CHANGELOG.md +8 -0
  25. package/mcp-server/http-smoke.mjs +2 -0
  26. package/mcp-server/lib/audit-view.mjs +10 -3
  27. package/mcp-server/lib/events.mjs +7 -0
  28. package/mcp-server/lib/knockout.mjs +7 -5
  29. package/mcp-server/package.json +1 -1
  30. package/package.json +1 -1
  31. package/portal-ui/package.json +1 -1
  32. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  33. package/providers/oauth-mcp-bridge/package.json +1 -1
  34. package/scripts/ask-ai-render-check.mjs +1 -0
  35. package/scripts/authority-boundary-probe.mjs +2 -0
  36. package/scripts/report-header-render-check.mjs +1 -0
  37. package/scripts/report-offline-render-check.mjs +1 -0
  38. package/scripts/report-screenshot.mjs +1 -0
  39. package/scripts/report-sections-render-check.mjs +1 -0
  40. package/scripts/report-theme-render-check.mjs +1 -0
  41. package/scripts/revisit-render-check.mjs +1 -0
  42. package/scripts/settings-render-check.mjs +1 -0
  43. package/scripts/undeclared-key-census.mjs +1 -0
  44. package/scripts/verify-publishable.mjs +2 -0
  45. package/shared/names-in-force.mjs +1 -0
package/.env.example CHANGED
@@ -106,6 +106,13 @@ CLEAROTRON_CODEX_PATH=codex
106
106
  # effect: tuning
107
107
  CLEAROTRON_TOOL_ANSWER_BYTES=
108
108
 
109
+ # Runtime-injected — do NOT set this by hand. The driver sets it to 1 in the band tool server's env for the
110
+ # placement step alone (driver/stages.mjs GROUP_READING_STAGES): that step is offered band_lookup's group
111
+ # filter and reads the band shape with the orders written for it. Every other step is served both without
112
+ # them. Set by hand, it would offer every step the group pages, and nothing in a run records that it did.
113
+ # effect: silent-output-change
114
+ # CLEAROTRON_BAND_GROUPS=
115
+
109
116
  CLEAROTRON_WORK_DIR=
110
117
  # Customer/project config store (loadProfiles). Unset ⇒ driver's built-in profiles dir.
111
118
  CLEAROTRON_CUSTOMERS_DIR=
package/build-info.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "d407cad37cded0141194ed22bbcaf698232202c8",
3
- "version": "0.4.1-beta.5"
2
+ "commit": "e482d42d8937293290be77fc35633fd956d4bf10",
3
+ "version": "0.4.1"
4
4
  }
@@ -221,6 +221,7 @@ deployment may override (verify live values per deployment).
221
221
  | `CLEAROTRON_REGISTER_RECORD_LOG` | **runtime-injected per run**: `<runDir>/_driver/register-record-bodies.jsonl` | Citation-fidelity log: the BODY of every fetched official record. ** moved it INTO the run** — created with the run, unioned into the run's`_records/`, archived and purged with it. There is no retention setting and no cleanup job, because it no longer grows on the box: held globally it reached 432 MB in 61 days on production and needed a rotation timer on every install. **Do not set this by hand** — a fixed value pins every run's bodies to one file and restores the problem. A box upgraded across still holds its old global file; nothing writes or reads it, the driver names it once per process on stderr, and archiving it is one`mv`. An empty log cannot read as verified: the run's successful `record_fetch` rows in the (still global) call ledger are compared against the assembled record set, and a gap is reported as a failure. |
222
222
  | `CORSEARCH_CALL_LOG` / `CORSEARCH_RECORD_LOG` | — | **Deprecated, honoured for one release.** These are the names these two variables carried before the rename. Unset on every deployed box (all three ran the homedir default), so what actually protects an upgrade is the filename fallback: a`corsearch-calls.jsonl` / `corsearch-records.jsonl` already on disk keeps being read where it sits. Resolution order is in `providers/_shared/ledger-path.mjs`. |
223
223
  | `CLEAROTRON_BAND_RUN_DIR` | set per dispatch | The run dir the band MCP server writes into, injected per stage — unset means the server has no run to write to and says so rather than guessing one. |
224
+ | `CLEAROTRON_BAND_GROUPS` | set per dispatch: `1` for the placement step alone, unset for every other | Whether the band MCP server offers `band_lookup`'s group filter and serves the band shape with the placement step's orders under its groups. Driver-set from `GROUP_READING_STAGES` in `driver/stages.mjs`; not operator configuration. |
224
225
  | `CLEAROTRON_FEEDBACK_DIR` | `<poolRoot>/_feedback` | Where report feedback flags are stored. Beside the pool by default so a deployment that moves the pool moves the flags with it. |
225
226
  | `USPTO_LOCAL_DB` | **none — set it to use `uspto-local`** | The local USPTO index (`node:sqlite` + FTS5) that `bin/uspto-sync.mjs` builds and the free US register reads. Named in `.env.example`; this is the reference row. |
226
227
  | `CLEAROTRON_JX_SUBCLASS_DB` | unset ⇒ the lane refuses by name | Path to the built similar-group database the JX subclass lane reads, produced by `providers/jx-subclass/load-public.mjs` from the committed public tables. It names WHERE the table lives; what the table says is the build's. Absence is a refusal rather than an empty answer — a clearance that silently found no similar groups is indistinguishable from one where the file was missing. |
@@ -1,5 +1,47 @@
1
1
  # clearotron-driver
2
2
 
3
+ ## 0.4.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Fixed: On a crowded register search, the step that writes the findings no longer runs out of time and stops the search without a report.
8
+ - Fixed: A connection library is updated to close a reported weakness in how sign-in details could be sent.
9
+ - Fixed: A clearance started from a connected assistant under the Generic default now appears on Clearances when you choose Generic. Before, it showed only under All companies.
10
+ - Fixed: A crowded clearance on the Codex engine no longer stops before its report.
11
+ - Fixed: A register count or record that could not be fetched no longer shows the program's error message to a client. This covers the knockout workbook's count notes, the knockout report and a clearance finding card. Each shows the line the report already uses for something left open.
12
+ - Fixed: A knockout's completion message and email now give its rating, or the highest rating when several names were screened. They no longer use the term 'worst band'.
13
+ - Fixed: A client whose mark is written in another script, such as Greek, Cyrillic or Chinese, now receives the link to the report. Before, the email arrived without one.
14
+ - Fixed: In rare cases, a report repaired just before delivery listed one conflict as two findings. Its last finding also lost its full write-up. The conflict now stays one finding, and every finding keeps its write-up.
15
+ - Fixed: A clearance no longer stops before its report when findings link to an office's own page instead of the register record.
16
+ - Fixed: A report repaired just before delivery keeps its coverage notes, such as a script that was not searched, in the report and the audit workbook.
17
+ - Fixed: A report's conditions no longer include an internal note about the search's own checking steps.
18
+ - Fixed: A clearance no longer reports a search as missing, or lowers a clear result, when the same search had already run under another entry.
19
+ - Fixed: A search too broad for the register to count is no longer reported as one that could not be run. The report shows it as too large to read, with the register's own figure.
20
+ - Fixed: Reports no longer say a search "did not finish and must be re-run" when a name's non-Latin form was in fact searched. They no longer make a conclusion depend on reviewing records the search had already read in full. Knockout reports now list every territory you ordered.
21
+ - Fixed: The audit workbook no longer warns that the depth rules were applied to nothing on a clearance whose narrative is written in sections. Those rules govern per-finding write-ups, and such a narrative has none.
22
+ - For operators: A stopped run no longer leaves behind a working folder linked to the Codex sign-in; leftovers are removed the next time Codex runs.
23
+ - Fixed: An order for the European Union now also searches each member state's own trade mark register, at no extra search cost. A national registration — in France, for example — is now found by an EU search.
24
+ - Before you upgrade: if you installed Codex yourself, update it to 0.159.0 or newer first. Older copies cannot use GPT-6.1 Sol, and searches on the OpenAI engine would stop at their first step.
25
+ - Fixed: On Signa, a crowded search over several spellings now counts each spelling alone, so a rare spelling is no longer reported as crowded.
26
+ - Fixed: Every clearance uses the same web search whichever engine runs it; the engines' own web search is switched off.
27
+ - Fixed: Every live identical or near-identical mark a register search finds is now placed in the report or set aside with its reason.
28
+ - New: `npx clearotron mcp` starts Clearotron's MCP server for an assistant; with no install it answers from the demo's sample runs, and says so.
29
+ - New: Searches now use the latest models: Claude Haiku 5.5, Sonnet 5.5 and Opus 5.5, and OpenAI's GPT-6.1 Sol and GPT-6 Luna. Installing or updating Clearotron brings the Claude and Codex programs up to versions that can use them.
30
+ - Fixed: The account connector's decision timeline no longer lists the engine's internal bookkeeping events.
31
+ - Fixed: The address `clearotron start` and `clearotron demo` print for a client's assistant now answers as soon as it is printed.
32
+ - Fixed: The account connector now lists a knockout search's review step, and no longer says the search makes two research calls per name.
33
+ - Fixed: The audit workbook's coverage summary names each area in words, not the engine's identifiers.
34
+ - Fixed: Two different names written in Greek, Cyrillic or Chinese characters, or differing only in an accented letter, can now be screened in one knockout. Before, they were refused as duplicates.
35
+ - Fixed: Findings rated medium risk or higher are now checked for the owner's actual use in the same way on every company's risk ladder. That same search looks for the owner's record of oppositions and disputes, so "Likely to enforce" carries a rating instead of "not checked".
36
+
37
+ ## 0.4.1-beta.6
38
+
39
+ ### Patch Changes
40
+
41
+ - Fixed: A clearance no longer reports a search as missing, or lowers a clear result, when the same search had already run under another entry.
42
+ - Fixed: The account connector's decision timeline no longer lists the engine's internal bookkeeping events.
43
+ - Fixed: The account connector now lists a knockout search's review step, and no longer says the search makes two research calls per name.
44
+
3
45
  ## 0.4.1-beta.5
4
46
 
5
47
  ### Patch Changes
@@ -341,6 +341,11 @@ export function deriveAsks({
341
341
  const deferrals = new Map((fr.deferrals ?? []).filter((d) => d?.directive).map((d) => [d.directive, d]));
342
342
  const qidsByKey = fr.directive_qids && typeof fr.directive_qids === "object" ? fr.directive_qids : {};
343
343
  const bornByKey = fr.born && typeof fr.born === "object" ? fr.born : {};
344
+ // A slice the fold refused as already-asked never ran: the plan row that asked its question did. The receipt
345
+ // names that row where the directive closed through it (`answered_elsewhere`), and its execution ends the ask.
346
+ const twinOf = new Map((Array.isArray(fr.answered_elsewhere) ? fr.answered_elsewhere : [])
347
+ .filter((x) => typeof x?.directive === "string")
348
+ .map((x) => [x.directive, new Map((Array.isArray(x.slices) ? x.slices : []).filter((t) => t?.qid && t?.twin).map((t) => [t.qid, t.twin]))]));
344
349
  for (const key of requested) {
345
350
  const qids = (Array.isArray(qidsByKey[key]) ? qidsByKey[key] : []).filter(Boolean);
346
351
  const place = bornByKey[key] === "form-neighbourhood" ? "form-neighbourhood" : "frame-diff";
@@ -351,7 +356,7 @@ export function deriveAsks({
351
356
  handoff = "open coverage row (frame-gap) + the CLEAR→CONDITIONAL clamp — the report states the unswept omission";
352
357
  } else if (swept.has(key)) {
353
358
  ending = qids.length
354
- ? endingForQids(qids, join, ts) // computed — null (open) when the execution record disagrees
359
+ ? endingForQids(qids.map((q) => twinOf.get(key)?.get(q) ?? q), join, ts) // computed — null (open) when the execution record disagrees
355
360
  : mkEnding("executed", "frame-reopen-receipt", { evidence: "swept per the frame-reopen receipt (warm-resume arm — axis-level verification)", ts });
356
361
  }
357
362
  push({
@@ -1082,6 +1082,14 @@ const floorLineCount = (groups) => ["a", "b", "c", "d", "no_goods"].reduce((n, g
1082
1082
  /** The lookup that pages a group, as the shape and the form's refusal both name it, so the two cannot drift. */
1083
1083
  export const groupPageCall = (g) => `band_lookup {"group":"${g}"}`;
1084
1084
 
1085
+ // PLACEMENT'S ORDERS IN THE SHAPE. Three sentences under the groups tell the reader to give every line a fate,
1086
+ // and only placement owes the groups that. The band server serves the shape without them to every other
1087
+ // stage (Krzys's ruling, 2026-10-14), so they are declared once here and removed there by these same strings.
1088
+ export const SORT_THE_NEAR_ONES = "A few hundred? Sort by the client's goods and markets, read the near ones first, list the rest.";
1089
+ export const ONE_SET_ASIDE_ROW = "Write down what you set aside and why: one row, with this count.";
1090
+ export const A_FATE_BY_NAME = "Each line takes its own fate by name; no group row covers these lines.";
1091
+ export const PLACEMENT_GROUP_ORDERS = Object.freeze([SORT_THE_NEAR_ONES, ONE_SET_ASIDE_ROW, A_FATE_BY_NAME]);
1092
+
1085
1093
  function renderGroupsMd(md, groups, rows = []) {
1086
1094
  md.push("", "## The band in the lawyer's order — for the client's goods first", "");
1087
1095
  if (groups.dominant_term && groups.manifest_dominant && normKey(groups.dominant_term) !== normKey(groups.manifest_dominant))
@@ -1127,7 +1135,7 @@ function renderGroupsMd(md, groups, rows = []) {
1127
1135
  ? `${count(grp)}. A thousand or more? The field is crowded. These are ${crowd[g]}. Read the first page, carry forward any line a lawyer would raise, and set the rest aside in this group's one row with the count: the client is told the field is crowded. ${firstPage}; page with ${groupPageCall(g)} only where a line needs reading.`
1128
1136
  : grp.lines <= READ_ALL_LINES
1129
1137
  ? `${count(grp)}. Fifty? Read them all.`
1130
- : `${count(grp)}. A few hundred? Sort by the client's goods and markets, read the near ones first, list the rest.`, "");
1138
+ : `${count(grp)}. ${SORT_THE_NEAR_ONES}`, "");
1131
1139
  md.push(...head);
1132
1140
  for (const l of crowded ? grp.list.slice(0, groups.first_page) : grp.list) { rows.push([md.length, l.key]); md.push(row(l)); }
1133
1141
  md.push("");
@@ -1137,10 +1145,10 @@ function renderGroupsMd(md, groups, rows = []) {
1137
1145
  // more, where its one row stands on that page read and the count.
1138
1146
  const rest = groups.rest;
1139
1147
  const restCrowded = rest.records >= groups.crowded_at;
1140
- md.push(`**The rest: ${count(rest)}${outside}** — every record in none of (a) to (d) above. ${!rest.lines ? "Write down what you set aside and why: one row, with this count."
1148
+ md.push(`**The rest: ${count(rest)}${outside}** — every record in none of (a) to (d) above. ${!rest.lines ? ONE_SET_ASIDE_ROW
1141
1149
  : restCrowded
1142
1150
  ? `A thousand or more? The field is crowded. Read the first page, carry forward any line a lawyer would raise, and set the rest aside in this group's one row with the count: the client is told the field is crowded. ${firstPage}; page with ${groupPageCall("rest")} only where a line needs reading.`
1143
- : `${rest.lines <= READ_ALL_LINES ? "Fifty? Read them all." : "A few hundred? Sort by the client's goods and markets, read the near ones first, list the rest."} Write down what you set aside and why: one row, with this count.`}`);
1151
+ : `${rest.lines <= READ_ALL_LINES ? "Fifty? Read them all." : SORT_THE_NEAR_ONES} ${ONE_SET_ASIDE_ROW}`}`);
1144
1152
  if (rest.lines) {
1145
1153
  md.push("", ...head);
1146
1154
  for (const l of restCrowded ? rest.list.slice(0, groups.first_page) : rest.list) { rows.push([md.length, l.key]); md.push(row(l)); }
@@ -1157,7 +1165,7 @@ function renderGroupsMd(md, groups, rows = []) {
1157
1165
  : `**Goods the test could not read: ${count(ng)}.**`;
1158
1166
  md.push("", crowded
1159
1167
  ? `${lead} A thousand or more? ${firstPage}, the floors first, then the live ones in the ordered classes. Carry forward any line a lawyer would raise, and set the rest aside in this list's one row with the count, on the ground that their goods are unreadable and the field is crowded. A floor here is placed or set aside by name on its status or its territory; this row never covers it. Where more floors are counted than this page shows, page the rest with ${groupPageCall("no_goods")}; they come first.`
1160
- : `${lead} ${ng.lines <= READ_ALL_LINES ? "Fifty? Read them all." : "A few hundred? Read the live ones in the ordered classes first."} Each line takes its own fate by name; no group row covers these lines.${tested.source === "none" ? "" : ' A line marked "on file, not readable" has goods on file: open its record with band_record before its fate. A line marked "no goods on file" has nothing to open.'} A floor here is placed, or set aside by name on its status or its territory; any other line may be set aside on the ground that its goods are unknown.`, "");
1168
+ : `${lead} ${ng.lines <= READ_ALL_LINES ? "Fifty? Read them all." : "A few hundred? Read the live ones in the ordered classes first."} ${A_FATE_BY_NAME}${tested.source === "none" ? "" : ' A line marked "on file, not readable" has goods on file: open its record with band_record before its fate. A line marked "no goods on file" has nothing to open.'} A floor here is placed, or set aside by name on its status or its territory; any other line may be set aside on the ground that its goods are unknown.`, "");
1161
1169
  md.push(...head);
1162
1170
  for (const l of crowded ? ng.list.slice(0, groups.first_page) : ng.list) { rows.push([md.length, l.key]); md.push(row(l)); }
1163
1171
  }
@@ -59,6 +59,18 @@ export function blockCollapsed(block) {
59
59
  return state === "enumerated" && Number.isFinite(total) && total > 0 && recs === 0;
60
60
  }
61
61
 
62
+ /**
63
+ * The plan row that answers a minted slice: the slice itself, or, where the fold refused it as already-asked, the
64
+ * row it duplicates. Such a slice is never frozen into the plan and never runs; its question was asked by the twin.
65
+ * ONLY THE DUPLICATE KIND RESOLVES: an identity collision or a malformed term asked a question nothing else asked.
66
+ * `foldRefusals` maps a refused qid to `{kind, twin}`, as `foldSupplementalEntries` records it. One rule for the
67
+ * directive below and the remedy term ledger (remedy-accounting.mjs), so the two cannot read a slice apart. PURE.
68
+ */
69
+ export function answeredAs(qid, foldRefusals) {
70
+ const r = foldRefusals?.get?.(qid);
71
+ return r && r.kind === "duplicate-question" && r.twin ? String(r.twin) : qid;
72
+ }
73
+
62
74
  /**
63
75
  * Verify a register frame-reopen directive was actually SEARCHED as intended (Part B, arm #1). A directive
64
76
  * is closed ONLY if EVERY minted qid clears three checks:
@@ -70,22 +82,29 @@ export function blockCollapsed(block) {
70
82
  * wrong-scope 0/0). Indeterminate pins fall through to 1+2 — a strict tightening over byte-diff, never
71
83
  * a new false deferral.
72
84
  * PURE. `executedQids` is a Set of joinPlanToBands.executed qids; `blocksByQid` maps qid → band block.
73
- * @returns {{closed: boolean, reason: string|null}}
85
+ *
86
+ * A slice the fold refused as already-asked is checked on the row that answered it (`answeredAs`), on the same
87
+ * three checks, and a directive closed that way names each such row in `answered_by`.
88
+ * @returns {{closed: boolean, reason: string|null, answered_by?: {qid: string, twin: string}[]}}
74
89
  */
75
- export function verifyRegisterDirectiveClose({ qids, intendedClasses = [], executedQids, blocksByQid } = {}) {
90
+ export function verifyRegisterDirectiveClose({ qids, intendedClasses = [], executedQids, blocksByQid, foldRefusals = new Map() } = {}) {
76
91
  const list = qids ?? [];
77
92
  if (!list.length) return { closed: false, reason: "no-slice-dispatched" };
78
93
  const want = [...new Set((intendedClasses ?? []).map(String))];
79
94
  const executed = executedQids ?? new Set();
80
95
  const byQid = blocksByQid ?? new Map();
96
+ const answeredBy = [];
81
97
  for (const qid of list) {
82
- if (!executed.has(qid)) return { closed: false, reason: `slice-not-landed:${qid}` };
83
- const b = byQid.get(qid);
84
- if (!b) return { closed: false, reason: `no-band-block:${qid}` };
85
- if (blockCollapsed(b)) return { closed: false, reason: `collapsed-slice:${qid}` };
98
+ const q = answeredAs(qid, foldRefusals);
99
+ const as = q === qid ? qid : `${qid} (answered by ${q})`;
100
+ if (!executed.has(q)) return { closed: false, reason: `slice-not-landed:${as}` };
101
+ const b = byQid.get(q);
102
+ if (!b) return { closed: false, reason: `no-band-block:${as}` };
103
+ if (blockCollapsed(b)) return { closed: false, reason: `collapsed-slice:${as}` };
86
104
  const searched = blockSearchedClasses(b);
87
105
  if (want.length && searched.length && !want.every((c) => searched.includes(c)))
88
- return { closed: false, reason: `wrong-scope:${qid} searched[${searched.join(",")}]⊉intended[${want.join(",")}]` };
106
+ return { closed: false, reason: `wrong-scope:${as} searched[${searched.join(",")}]⊉intended[${want.join(",")}]` };
107
+ if (q !== qid) answeredBy.push({ qid, twin: q });
89
108
  }
90
- return { closed: true, reason: null };
109
+ return { closed: true, reason: null, ...(answeredBy.length ? { answered_by: answeredBy } : {}) };
91
110
  }
@@ -16,6 +16,7 @@
16
16
  // watchdog heartbeat (--include-partial-messages).
17
17
 
18
18
  import { spawn } from "node:child_process";
19
+ import { StringDecoder } from "node:string_decoder";
19
20
  import { statSync } from "node:fs";
20
21
  import { tmpdir } from "node:os";
21
22
  import { join, dirname } from "node:path";
@@ -594,6 +595,9 @@ export const anthropicAgentEngine = {
594
595
  child.stdin.on("error", () => {});
595
596
  try { child.stdin.write(input); child.stdin.end(); } catch { /* child gone — handled by close/error */ }
596
597
  let buf = "", stderr = "", resultEvent = null, killed = false, stallKill = false, settled = false;
598
+ // A CHUNK IS NOT A CHARACTER: a character split across two chunks of a pipe is held by its stream's decoder
599
+ // until the rest arrives (common.mjs `runStreamingChild` says why). The raw sink still takes the bytes.
600
+ const outText = new StringDecoder("utf8"), errText = new StringDecoder("utf8");
597
601
  let noProgressKill = false; // the no-progress ceiling fired (a stall discriminator, see noProgressMs)
598
602
  let trickleKill = false; // the token-rate floor fired (a stall discriminator, see minTokensPerSec)
599
603
  let overflow = false; // A3: stdout/stderr exceeded maxBuffer — force a nonzero fail, never parse the truncated tail
@@ -1091,7 +1095,7 @@ export const anthropicAgentEngine = {
1091
1095
  prevChunkAt = Date.now();
1092
1096
  lastMove = Date.now(); // ANY streamed byte = liveness → resets the stall clock
1093
1097
  sink.write(d); // the raw stream, byte for byte, before anything parses it
1094
- buf += d.toString();
1098
+ buf += outText.write(d);
1095
1099
  let nl;
1096
1100
  while ((nl = buf.indexOf("\n")) >= 0) { parseLine(buf.slice(0, nl)); buf = buf.slice(nl + 1); }
1097
1101
  if (buf.length > maxBuffer) { buf = buf.slice(0, maxBuffer); overflowKill(); }
@@ -1109,7 +1113,7 @@ export const anthropicAgentEngine = {
1109
1113
  // The diagnostic follows the same rule deliberately. `firstByteMs=NEVER` now means "never spoke
1110
1114
  // its protocol", which is the question the no-progress specimen is actually asking — a number
1111
1115
  // that came from a stderr line would have answered a different one.
1112
- stderr += d.toString();
1116
+ stderr += errText.write(d);
1113
1117
  if (stderr.length > maxBuffer) { stderr = stderr.slice(0, maxBuffer); overflowKill(); }
1114
1118
  });
1115
1119
  // error + close can BOTH fire for a single failure (Node) → settle EXACTLY once (no double clearInterval/resolve).
@@ -1125,7 +1129,7 @@ export const anthropicAgentEngine = {
1125
1129
  // settlement must never wait on a pipe some straggler keeps open (the group SIGKILL reaps the tree).
1126
1130
  try { child.stdout.destroy(); } catch { /* stream gone */ }
1127
1131
  try { child.stderr.destroy(); } catch { /* stream gone */ }
1128
- if (!overflow) { parseLine(buf); } // B1: flush the final, un-terminated line (the result event) — but NEVER the truncated overflow tail
1132
+ if (!overflow) { buf += outText.end(); stderr += errText.end(); parseLine(buf); } // B1: flush the final, un-terminated line (the result event) — but NEVER the truncated overflow tail
1129
1133
  const stream = sink.close(); // after the flush: every byte the program wrote is in it, or the record says why not
1130
1134
  buf = "";
1131
1135
  // ONE SAMPLE OF THE CLOCK, and both quantities below are derived from it. Two `Date.now()` calls
@@ -19,6 +19,7 @@
19
19
  // the engines it serves.
20
20
 
21
21
  import { spawn } from "node:child_process";
22
+ import { StringDecoder } from "node:string_decoder";
22
23
  import { tmpdir } from "node:os";
23
24
  import { dirname, join } from "node:path";
24
25
  import { recordEngineChild, clearEngineChild } from "./child-record.mjs"; //
@@ -190,6 +191,10 @@ export function runStreamingChild({
190
191
  try { child.stdin.write(input ?? ""); child.stdin.end(); } catch { /* child gone — handled by close/error */ }
191
192
 
192
193
  let buf = "", stdoutAll = "", stderr = "";
194
+ // A CHUNK IS NOT A CHARACTER. A pipe hands the output over in chunks, and a character of two or more bytes can
195
+ // straddle two of them: decoded one chunk at a time, its halves read as replacement characters. One decoder per
196
+ // stream holds a split character's first bytes until the rest arrive.
197
+ const outText = new StringDecoder("utf8"), errText = new StringDecoder("utf8");
193
198
  let killed = false, stallKill = false, overflow = false, settled = false;
194
199
  let lastMove = Date.now();
195
200
  // — false until the child's FIRST byte on EITHER stream. Not "first liveness byte": a
@@ -248,7 +253,7 @@ export function runStreamingChild({
248
253
  if (settled || overflow) return;
249
254
  sawOutput = true; firstOutputAt ||= Date.now(); // — the child has spoken; startup is over
250
255
  lastMove = Date.now(); // ANY streamed byte = liveness → resets the stall clock
251
- const s = d.toString();
256
+ const s = outText.write(d);
252
257
  buf += s; stdoutAll += s;
253
258
  let nl;
254
259
  while ((nl = buf.indexOf("\n")) >= 0) { emitLine(buf.slice(0, nl)); buf = buf.slice(nl + 1); }
@@ -258,7 +263,7 @@ export function runStreamingChild({
258
263
  if (settled || overflow) return;
259
264
  sawOutput = true; firstOutputAt ||= Date.now(); // — spoken is spoken, whether or not this stream counts as liveness
260
265
  if (stderrIsLiveness) lastMove = Date.now(); // codex progress lives on stderr → it is liveness too
261
- stderr += d.toString();
266
+ stderr += errText.write(d);
262
267
  if (stderr.length > maxBuffer) { stderr = stderr.slice(0, maxBuffer); overflowKill(); }
263
268
  });
264
269
 
@@ -282,7 +287,9 @@ export function runStreamingChild({
282
287
  // settlement must never wait on a pipe some straggler keeps open (the group SIGKILL reaps the tree).
283
288
  try { child.stdout.destroy(); } catch { /* stream gone */ }
284
289
  try { child.stderr.destroy(); } catch { /* stream gone */ }
285
- if (!overflow) emitLine(buf); // B1: flush the final, un-terminated line — but NEVER the truncated overflow tail
290
+ // B1: flush the final, un-terminated line — but NEVER the truncated overflow tail. The decoders give up
291
+ // what they held: a character the program never finished reads as a replacement character, once.
292
+ if (!overflow) { const tail = outText.end(); buf += tail; stdoutAll += tail; stderr += errText.end(); emitLine(buf); }
286
293
  buf = "";
287
294
  resolve({
288
295
  rawCode: typeof code === "number" ? code : null,
@@ -21,10 +21,10 @@ import { join, dirname } from "node:path";
21
21
  import { driverDir } from "../../../shared/driver-dir.mjs"; //
22
22
  import { serve } from "./stdio-server.mjs";
23
23
  import { answerBudget, quotedBytes } from "../../../shared/answer-parts.mjs";
24
- import { classifyRecord, prepareTargets, SHAPE_TIERS, isLiveRecord, niceClassKey } from "../../band-shape.mjs";
24
+ import { classifyRecord, prepareTargets, SHAPE_TIERS, isLiveRecord, niceClassKey, PLACEMENT_GROUP_ORDERS } from "../../band-shape.mjs";
25
25
  import { recordQids } from "../../named-band.mjs";
26
26
  import { normalizeRecordUri } from "../../registry-fidelity.mjs";
27
- import { bandGoodsIndex, goodsFor, goodsWordCounter, goodsWordsMatched, untaggedIsEnglish } from "../../record-goods.mjs";
27
+ import { bandGoodsIndex, goodsFor, goodsWordCounter, goodsWordsMatched, rawGoodsCarried, untaggedIsEnglish } from "../../record-goods.mjs";
28
28
 
29
29
  const RUN_DIR = process.env.CLEAROTRON_BAND_RUN_DIR || "";
30
30
  const SESSION = process.env.CLEAROTRON_GATHER_SESSION_KEY || "";
@@ -108,7 +108,8 @@ const SHAPE_PART_CHARS = 70000; // step 3 — was a knob; no environment ever
108
108
  // band_lookup of 100 fat records or a pathological official record could exceed the engine's
109
109
  // per-tool-output token cap and error the call — killing the reading path exactly where the record
110
110
  // volume is highest. band_lookup TRIMS records (the full match count always survives, with an
111
- // honest note); band_record serves PARTS like band_shape (a record is a document — never trimmed).
111
+ // honest note); band_record serves PARTS like band_shape (a record is a document — never trimmed of anything it
112
+ // says; a goods list it says twice is served once, at `recordAsServed`).
112
113
  // Given an answer size and no band size of its own, the band answers in five sixths of it: the rest is room
113
114
  // for the quotes and line breaks a printout adds (up to 13% on band answers) and for a banner, so a band
114
115
  // answer is never split a second time (shared/answer-parts.mjs). 37,500 on Claude, 30,000 on codex.
@@ -264,7 +265,7 @@ function bandShape(params) {
264
265
  const p = format === "json" ? shapeJsonPath() : shapeMdPath();
265
266
  if (!RUN_DIR) { logRead("band_shape", { format }, { ok: false }); return { isError: true, text: "ERROR: CLEAROTRON_BAND_RUN_DIR not set — band tools are wired per run by the driver." }; }
266
267
  if (!existsSync(p)) { logRead("band_shape", { format }, { ok: false }); return { isError: true, text: `ERROR: ${p} not found — the driver derives it after the register fan-in; if the band exists but the shape does not, report this as a driver defect (do NOT slice the raw band instead).` }; }
267
- const text = readFileSync(p, "utf8");
268
+ const text = format === "md" && !GROUP_PAGES ? withoutPlacementOrders(readFileSync(p, "utf8")) : readFileSync(p, "utf8");
268
269
  const parts = splitAtLines(text, SHAPE_PART_SERVED);
269
270
  const total = parts.length;
270
271
  const read = format === "md" ? linesByPart(parts) : null;
@@ -434,6 +435,26 @@ function bandLookup(params = {}) {
434
435
  return out;
435
436
  }
436
437
 
438
+ // A RECORD'S GOODS ARE SERVED ONCE. A Clarivate record carries the register's raw goods list beside the
439
+ // normalised goods the provider maps from it item for item (record-goods.mjs `rawGoodsCarried`), so every goods
440
+ // statement was read twice. On a crowded test run, 2026-10-13, one synthesis attempt read 10.79 MB of record
441
+ // text in 549 calls; served this way, the same records come to 6.04 MB in 398. Where the normalised goods carry
442
+ // the raw list, the record is served without that list, in the file's own layout and field order, and anything
443
+ // else in the raw goods block stays; where they do not, or the text is not a record, it is served as it
444
+ // stands. The file on disk is never touched, and the reading audit says how much was left out.
445
+ function recordAsServed(text) {
446
+ let body;
447
+ try { body = JSON.parse(text); } catch { return { text, audit: {} }; }
448
+ if (!rawGoodsCarried(body)) return { text, audit: {} };
449
+ const goods = { ...body._raw.goodsServices };
450
+ delete goods.intClassDescriptions;
451
+ const raw = { ...body._raw, goodsServices: goods };
452
+ if (!Object.keys(goods).length) delete raw.goodsServices;
453
+ const indent = /^\{\n( +)"/.exec(text)?.[1]?.length ?? 0;
454
+ const served = JSON.stringify({ ...body, _raw: raw }, null, indent) + (text.endsWith("\n") ? "\n" : "");
455
+ return { text: served, audit: { raw_goods_left_out: text.length - served.length } };
456
+ }
457
+
437
458
  function bandRecord(params = {}) {
438
459
  if (!RUN_DIR) return { isError: true, text: "ERROR: CLEAROTRON_BAND_RUN_DIR not set — band tools are wired per run by the driver." };
439
460
  const uri = String(params.record_id ?? "").trim();
@@ -461,16 +482,18 @@ function bandRecord(params = {}) {
461
482
  // How the cite resolved rides the log: an `exact` hit is the model quoting the store's own key; any
462
483
  // other value is this ladder doing work, and the reading audit says which and to what.
463
484
  const resolution = hit.via === "exact" ? {} : { via: hit.via, ...(hit.resolved ? { resolved: hit.resolved } : {}) };
485
+ const served = recordAsServed(text);
486
+ text = served.text;
464
487
  const parts = splitAtLines(text, RESPONSE_CHARS);
465
488
  const total = parts.length;
466
- if (total === 1) { // the common case — served verbatim, no banner
467
- logRead("band_record", { record_id: uri }, { ok: true, bytes: text.length, ...resolution });
489
+ if (total === 1) { // the common case — one part, no banner
490
+ logRead("band_record", { record_id: uri }, { ok: true, bytes: text.length, ...resolution, ...served.audit });
468
491
  return text;
469
492
  }
470
493
  // An oversize official record serves in PARTS, as band_shape does: line-boundary splits, byte-identical
471
494
  // concatenation, and a banner that says the next part continues it.
472
495
  const part = Math.min(Math.max(1, Math.floor(Number(params.part)) || 1), total);
473
- logRead("band_record", { record_id: uri, part, parts: total }, { ok: true, bytes: parts[part - 1].length, total_bytes: text.length, ...resolution });
496
+ logRead("band_record", { record_id: uri, part, parts: total }, { ok: true, bytes: parts[part - 1].length, total_bytes: text.length, ...resolution, ...served.audit });
474
497
  const head = `[band_record part ${part}/${total}. This record is served in ${total} parts; the next part continues it.]`;
475
498
  const tail = part < total
476
499
  ? `\n[end of part ${part}/${total} — call band_record again with {"record_id": "${uri}", "part": ${part + 1}} for the next part]`
@@ -484,15 +507,16 @@ function bandRecord(params = {}) {
484
507
  // fetched this run", which is a different claim and was false four times on a delivered round.
485
508
  const fromLedger = recordFromLedger(uri);
486
509
  if (fromLedger) {
487
- const text = JSON.stringify(fromLedger, null, 2);
510
+ const served = recordAsServed(JSON.stringify(fromLedger, null, 2));
511
+ const text = served.text;
488
512
  const parts = splitAtLines(text, RESPONSE_CHARS);
489
513
  if (parts.length === 1) {
490
- logRead("band_record", { record_id: uri }, { ok: true, bytes: text.length, via: "ledger" });
514
+ logRead("band_record", { record_id: uri }, { ok: true, bytes: text.length, via: "ledger", ...served.audit });
491
515
  return { type: "text", text };
492
516
  }
493
517
  const wanted = Number(params.part ?? 1);
494
518
  const part = Number.isInteger(wanted) && wanted >= 1 && wanted <= parts.length ? wanted : 1;
495
- logRead("band_record", { record_id: uri }, { ok: true, bytes: text.length, via: "ledger", part, parts: parts.length });
519
+ logRead("band_record", { record_id: uri }, { ok: true, bytes: text.length, via: "ledger", part, parts: parts.length, ...served.audit });
496
520
  return { type: "text", text: `[record ${uri} — part ${part}/${parts.length}]\n` + parts[part - 1]
497
521
  + (part < parts.length ? `\n[end of part ${part}/${parts.length} — call band_record again with {"record_id": "${uri}", "part": ${part + 1}}]` : "") };
498
522
  }
@@ -511,6 +535,25 @@ function bandRecord(params = {}) {
511
535
  return { isError: true, text: `${why} ${available.length ? `Records on file (${available.length}): ${available.slice(0, 40).join(", ")}${available.length > 40 ? ", …" : ""}.` : "No records fetched yet."} This tool is read-only — it never fetches; a record you need but do not have is an honest gap to state, not a reason to guess.` };
512
536
  }
513
537
 
538
+ // THE SHAPE'S GROUP PAGES ARE PLACEMENT'S. Placement owes every line a group lists a fate, so it is offered the
539
+ // group filter and told that a group is read page by page. No other stage owes the band a read, and a synthesis
540
+ // offered the same paged an exhausted group 2,498 times (measured on a test run, 2026-10-13). The driver sets
541
+ // CLEAROTRON_BAND_GROUPS for placement alone (stages.mjs GROUP_READING_STAGES); every other stage is offered
542
+ // the lookup without `group`, in the words every stage had before group pages existed. A group argument that
543
+ // arrives anyway is served as before: the offer changes, not what the tool answers.
544
+ const GROUP_PAGES = process.env.CLEAROTRON_BAND_GROUPS === "1";
545
+ // The same holds for the shape's own orders under the groups: placement reads them, and every other stage is
546
+ // served the shape without them (Krzys's ruling, 2026-10-14). Each is removed with the space before it, inside
547
+ // its line, so no line moves and the line each listed mark is printed on, which `linesByPart` reads, still holds.
548
+ const withoutPlacementOrders = (text) => PLACEMENT_GROUP_ORDERS.reduce((t, order) => t.split(` ${order}`).join("").split(order).join(""), text);
549
+ const LOOKUP_DESCRIPTION = "Look up enumerated records in this run's frozen register band (read-only; logged to the reading audit). Filters AND together: record_id (exact uri), owner (substring), nice_class, tier (identical|near-identical|same-family|other|unclassifiable — classified against the shape's frozen targets), qid (exact plan-entry id — the plan-execution ledger join; a record surfaced by several slices matches under EVERY one of them), query (the _query slice that surfaced the record), text (substring of mark_text), status (substring), live_only, goods (words: records whose goods contain any of them, as whole words, singular or plural; a record with no goods on file matches too), group (a, b, c, d, rest or no_goods: that list's lines in the shape's order, one per mark of an owner), limit (default 20, max 100), offset (default 0: where in the matching records or lines to start — a capped answer names the next offset, so a large band or group is read page by page). On each record served, `goods_words` gives the client's goods words its goods hold, as the shape tests them (k of N: the words), or null where its goods cannot be read; the goods text is in band_record. Matching un-enumerated crowd descriptors ride along so a lookup can never mistake a counted-only zone for a clean.";
550
+ const LOOKUP_GROUP_CLAUSE = "group (a, b, c, d, rest or no_goods: that list's lines in the shape's order, one per mark of an owner), ";
551
+ const LOOKUP_OFFSET_GROUPS = "offset (default 0: where in the matching records or lines to start — a capped answer names the next offset, so a large band or group is read page by page)";
552
+ const LOOKUP_OFFSET_BAND = "offset (default 0: where in the matching records to start — a capped answer names the next offset, so a large band is read page by page)";
553
+ /** The lookup's description as a stage is offered it: whole where it reads the groups, without them elsewhere. PURE. */
554
+ const lookupDescription = (groupPages) => (groupPages ? LOOKUP_DESCRIPTION
555
+ : LOOKUP_DESCRIPTION.replace(LOOKUP_GROUP_CLAUSE, "").replace(LOOKUP_OFFSET_GROUPS, LOOKUP_OFFSET_BAND));
556
+
514
557
  serve({
515
558
  name: "band", version: "0.1.0",
516
559
  tools: [
@@ -523,12 +566,12 @@ serve({
523
566
  },
524
567
  {
525
568
  name: "band_lookup",
526
- description: "Look up enumerated records in this run's frozen register band (read-only; logged to the reading audit). Filters AND together: record_id (exact uri), owner (substring), nice_class, tier (identical|near-identical|same-family|other|unclassifiable — classified against the shape's frozen targets), qid (exact plan-entry id — the plan-execution ledger join; a record surfaced by several slices matches under EVERY one of them), query (the _query slice that surfaced the record), text (substring of mark_text), status (substring), live_only, goods (words: records whose goods contain any of them, as whole words, singular or plural; a record with no goods on file matches too), group (a, b, c, d, rest or no_goods: that list's lines in the shape's order, one per mark of an owner), limit (default 20, max 100), offset (default 0: where in the matching records or lines to start — a capped answer names the next offset, so a large band or group is read page by page). On each record served, `goods_words` gives the client's goods words its goods hold, as the shape tests them (k of N: the words), or null where its goods cannot be read; the goods text is in band_record. Matching un-enumerated crowd descriptors ride along so a lookup can never mistake a counted-only zone for a clean.",
569
+ description: lookupDescription(GROUP_PAGES),
527
570
  inputSchema: { type: "object", properties: {
528
571
  record_id: { type: "string" }, owner: { type: "string" }, nice_class: { type: "string" },
529
572
  tier: { type: "string", enum: SHAPE_TIERS }, qid: { type: "string" }, query: { type: "string" }, text: { type: "string" },
530
573
  status: { type: "string" }, live_only: { type: "boolean" }, goods: { type: "array", items: { type: "string" } },
531
- group: { type: "string", enum: ["a", "b", "c", "d", "rest", "no_goods"] },
574
+ ...(GROUP_PAGES ? { group: { type: "string", enum: ["a", "b", "c", "d", "rest", "no_goods"] } } : {}),
532
575
  limit: { type: "number" }, offset: { type: "number" },
533
576
  } },
534
577
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
@@ -161,6 +161,7 @@ export function renderCodexConfigToml({ mcpConfig, allowedTools, developerInstru
161
161
  // recorded search returned (observed in testing, 2026-10). The Claude engine removes its own the same way
162
162
  // (OWN_SEARCH_TOOLS, anthropic-agent.mjs). The case-law step's page fetcher is a tool server, not this.
163
163
  lines.push(`web_search = "disabled"`, "");
164
+ lines.push(...featuresToml(withheldFromCommands));
164
165
  lines.push(...commandEnvToml(withheldFromCommands));
165
166
 
166
167
  const emitServer = (name, s, { forwardCreds } = {}) => {
@@ -244,11 +245,30 @@ export function renderCodexConfigToml({ mcpConfig, allowedTools, developerInstru
244
245
  // Filter names are matched without regard to case, and codex refuses the whole file if two of them are
245
246
  // the same name in different case, so each name is written once whatever its case.
246
247
  export function commandEnvToml(withheld = []) {
247
- const seen = new Set();
248
- const names = (withheld ?? []).filter((n) => n && !seen.has(n.toUpperCase()) && seen.add(n.toUpperCase()));
248
+ const names = withheldNames(withheld);
249
249
  if (!names.length) return [];
250
- return ["[features]", "shell_snapshot = false", "",
251
- "[shell_environment_policy.filters]", ...names.map((n) => `${tomlString(n)} = "exclude"`), ""];
250
+ return ["[shell_environment_policy.filters]", ...names.map((n) => `${tomlString(n)} = "exclude"`), ""];
251
+ }
252
+ const withheldNames = (withheld) => {
253
+ const seen = new Set();
254
+ return (withheld ?? []).filter((n) => n && !seen.has(n.toUpperCase()) && seen.add(n.toUpperCase()));
255
+ };
256
+
257
+ // ── the program's own features: what is off on every stage ──────────────────────────────────────
258
+ // ONE TABLE, WRITTEN ON EVERY STAGE: TOML refuses a table defined twice, so everything under `[features]`
259
+ // is written here.
260
+ //
261
+ // THE SIGN-IN'S APPS ARE OFF. A ChatGPT sign-in brings the program's apps (connectors) into every session
262
+ // as a tool server of its own, `codex_apps`, beside the servers a stage is given. Its search service opened
263
+ // live web pages on a knockout stage once the program's own web search was off (measured on 0.162.0,
264
+ // 2026-10-13). `apps = false` switches the integration off (the vendor's configuration reference,
265
+ // `features.apps`: "Enable app (connector) integrations (stable; on by default)"), in every safety setting.
266
+ // The web-search setting above never reached it: its filters "do not restrict connectors or MCP servers"
267
+ // (`tools.web_search`, the same reference).
268
+ //
269
+ // The shell snapshot is off wherever names are withheld from commands (`commandEnvToml` says why).
270
+ export function featuresToml(withheld = []) {
271
+ return ["[features]", "apps = false", ...(withheldNames(withheld).length ? ["shell_snapshot = false"] : []), ""];
252
272
  }
253
273
 
254
274
  // ── the stage's permission profile: what its shell commands may read and write ──────────────────────
@@ -849,13 +849,14 @@ export const claudeToolAnswerBytes = (env = process.env) =>
849
849
  // The mcp-config server env carries ONLY the per-run, non-secret values (session key + ledger paths +
850
850
  // agent + the run dir the band tools serve, and the answer size above). Creds (CORSEARCH_SESSION_KEY /
851
851
  // PERPLEXITY_API_KEY / EUIPO_*) are inherited from the engine env.
852
- function serverEnv({ sessionKey, agent, runDir, recordAxis }) {
852
+ function serverEnv({ sessionKey, agent, runDir, recordAxis, bandGroups = false }) {
853
853
  const e = {};
854
854
  // — see buildGatherMcpConfig: a register server without a run is refused there, so by the time
855
855
  // the record-log line below runs, `runDir` is present for every config that could use it.
856
856
  if (sessionKey) e.CLEAROTRON_GATHER_SESSION_KEY = sessionKey;
857
857
  if (agent) e.CLEAROTRON_GATHER_AGENT = agent;
858
858
  if (runDir) e.CLEAROTRON_BAND_RUN_DIR = runDir; // band-server: which run's band/shape/_records to serve + where the reading log lands
859
+ if (bandGroups) e.CLEAROTRON_BAND_GROUPS = "1"; // band-server: offer the shape's group pages (stages.mjs GROUP_READING_STAGES)
859
860
  // conversion 5 — THE BOUND CARD INDEX. A fan-out recording stage writes ONE member of a
860
861
  // per-ordinal artifact, and which one is the DRIVER's answer: it knows what it fanned out. Handed to
861
862
  // the server per turn so the acceptance boundary can refuse a payload naming any other index, which
@@ -899,7 +900,7 @@ function serverEnv({ sessionKey, agent, runDir, recordAxis }) {
899
900
 
900
901
  // Build the mcp-config object for a set of groups. Returns { config: <object>, servers: [...] } or null if
901
902
  // the groups need no MCP server (e.g. caselaw-only EUR-Lex via WebFetch needs no server but the bridges do).
902
- export function buildGatherMcpConfig(groups = [], { sessionKey, agent, runDir, recordAxis } = {}) {
903
+ export function buildGatherMcpConfig(groups = [], { sessionKey, agent, runDir, recordAxis, bandGroups = false } = {}) {
903
904
  const localKeys = new Set(), bridgeKeys = new Set();
904
905
  for (const g of groups) { const r = resolveGroup(g); r.local.forEach((k) => localKeys.add(k)); r.bridges.forEach((k) => bridgeKeys.add(k)); }
905
906
  const mcpServers = {};
@@ -929,7 +930,7 @@ export function buildGatherMcpConfig(groups = [], { sessionKey, agent, runDir, r
929
930
  + "belong to their run, and the box-global ledger is retired. Pass runDir.");
930
931
  }
931
932
  }
932
- const env = serverEnv({ sessionKey, agent, runDir, recordAxis });
933
+ const env = serverEnv({ sessionKey, agent, runDir, recordAxis, bandGroups });
933
934
  for (const k of localKeys) mcpServers[k] = { command: NODE, args: [join(MCP_DIR, localEntry(k).script)], env };
934
935
  // — THE BRIDGES GET THE AUDIT ENV, AND ONLY THAT. Until now they were spawned with no env at
935
936
  // all, so a case-law tool call had nowhere to log: `tool-calls.jsonl` recorded server/tool/ok and the
@@ -240,7 +240,7 @@ import { knockoutReviewRefusalsFor } from "./knockout-review-record.mjs";
240
240
  import { profilesStoreDir } from "./profiles.mjs";
241
241
  // The supplemental lane's single source (stages.mjs). The import is acyclic — stages.mjs reads
242
242
  // verify/coverage-ledger/config/framework and never reaches back here.
243
- import { REGISTER_ENUMERATE_TOOL, SUPPLEMENTAL_LANE_STEERING } from "./stages.mjs";
243
+ import { REGISTER_ENUMERATE_TOOL, SUPPLEMENTAL_LANE_STEERING, GROUP_READING_STAGES } from "./stages.mjs";
244
244
  // The ONE kill-class discriminator (timeout/status_timeout fails, exit 137, killed, hard-wall/stall
245
245
  // signals; lane_wedge and rate_limited deliberately excluded). Reused verbatim so "a kill" means the
246
246
  // same thing to the exit-1 rescue below as it does to the register taint chain that reads the very
@@ -927,7 +927,8 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
927
927
  if (groups.length) {
928
928
  // The bound card index rides the same label the grant was resolved from — derived by the SAME
929
929
  // declared rule (`perAxis`), never by a second string-sniff that could disagree with it.
930
- const cfg = buildGatherMcpConfig(groups, { sessionKey, agent, runDir, recordAxis: recordAxisFor(name)?.axis });
930
+ const cfg = buildGatherMcpConfig(groups, { sessionKey, agent, runDir, recordAxis: recordAxisFor(name)?.axis,
931
+ bandGroups: GROUP_READING_STAGES.has(name) });
931
932
  gatherMcpConfig = cfg ? JSON.stringify(cfg) : undefined;
932
933
  gatherAllowedTools = allowedToolsFor(groups);
933
934
  gatherSeatWrites = seatWritesForGroups(groups); // — the SAME derivation allowedToolsFor uses
@@ -2354,6 +2355,13 @@ export function frameDiffUndispatchable(expectFile, lastFail = "framediff_direct
2354
2355
  * — the routing was already correct, the prose was not. So the closing clause is the one part of a hint
2355
2356
  * that has to know, and it is passed rather than guessed.
2356
2357
  */
2358
+ // THE CLEARANCE WRITER'S FINDINGS CONTRACT, AND ONLY IT. A knockout finding's refusals carry `knockout_finding_*`
2359
+ // and `knockoutassess_finding_*` tokens (findings-model.mjs), prefixed so that no clearance branch would take
2360
+ // them; a bare `findings?_` matched the `finding_` inside them anyway, named findings.json, a file a knockout
2361
+ // run never has, and handed the model the clearance contract's keys. `synthesis_findings_*` stays the
2362
+ // clearance writer's.
2363
+ const CLEARANCE_FINDINGS_TOKEN_RE = /(?<!knockout_|knockoutassess_)findings?_/;
2364
+
2357
2365
  export function correctionHint(lastFail, { gridLedgerName = "common-law-grid.json", supplementalLane = false, undispatchable = [], toolWritten = null } = {}) {
2358
2366
  // The repair act, for the hints that name one. Kept to a single sentence and a single decision so a
2359
2367
  // new hint has one thing to append rather than a branch to reproduce.
@@ -2361,11 +2369,11 @@ export function correctionHint(lastFail, { gridLedgerName = "common-law-grid.jso
2361
2369
  ? `Fix exactly that and send the corrected values — the driver re-derives this file and the prose table together from your \`${toolWritten}\` call, so there is nothing here for you to save`
2362
2370
  : `Fix exactly that, re-save the COMPLETE JSON file, and leave the prose table unchanged`;
2363
2371
  let hint = "the required output file is actually written and contains every required section IN FULL";
2364
- if (/findings?_/.test(lastFail)) {
2372
+ if (CLEARANCE_FINDINGS_TOKEN_RE.test(lastFail)) {
2365
2373
  // The per-finding machine contract (findings-model.mjs). MUST sit ABOVE the coverage_ branch — a
2366
2374
  // findings_coverage_* token would otherwise route to the (wrong) coverage-ledger hint. The structural
2367
2375
  // "findings-heading" token has no underscore after "findings", so it never matches this branch.
2368
- const tok = (lastFail.match(/findings?_[a-z_]+(?::[^\s)]*)?/) || ["findings contract defect"])[0];
2376
+ const tok = (lastFail.match(new RegExp(`${CLEARANCE_FINDINGS_TOKEN_RE.source}[a-z_]+(?::[^\\s)]*)?`)) || ["findings contract defect"])[0];
2369
2377
  // A2 (teal-bastion) — the specific repair the blind generic hint was missing: the famous-neighbour
2370
2378
  // empty-uri crash. Without naming the fix (registrations:[] / route to context_notes), three retries each
2371
2379
  // re-emitted a different malformed shape and never converged. These addenda close that loop.
@@ -3480,7 +3488,7 @@ function repairSiblingName(lastFail, expectFile) {
3480
3488
  // era: the seat writes no dispositions file, so there is no sibling a repair could aim at. The
3481
3489
  // remedy is a `record_dispositions` call, which warmPatchMessage's own connotation branch orders;
3482
3490
  // the graded artifact falls back to the stage's own.md exactly as it did before.
3483
- : /findings?_/.test(f) ? "findings.json" // Phase-0 contract sibling (dormant until Phase 1)
3491
+ : CLEARANCE_FINDINGS_TOKEN_RE.test(f) ? "findings.json" // Phase-0 contract sibling (dormant until Phase 1)
3484
3492
  // blindframe_ is deliberately ABSENT: blind-frame-model.json is no longer a sibling, it is the
3485
3493
  // stage's expectFile. Routing it here would hand the model a message that forbids rewriting the very
3486
3494
  // file it then orders re-saved. The generic invalid_file branch aims the patch correctly.
@@ -2,7 +2,7 @@
2
2
  "name": "clearotron-driver",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.4.1-beta.5",
5
+ "version": "0.4.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": {