clearotron 0.4.1-beta.4 → 0.4.1-beta.6

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 (70) 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 +16 -0
  5. package/driver/ask-ledger.mjs +6 -1
  6. package/driver/band-shape.mjs +398 -21
  7. package/driver/clearance-variants-record.mjs +5 -1
  8. package/driver/close-verify.mjs +27 -8
  9. package/driver/contract-vocabulary.mjs +2 -0
  10. package/driver/degraded-parts.mjs +7 -0
  11. package/driver/effective-scope.mjs +27 -1
  12. package/driver/engine/anthropic-agent.mjs +7 -3
  13. package/driver/engine/common.mjs +10 -3
  14. package/driver/engine/mcp/band-server.mjs +179 -36
  15. package/driver/engine/mcp/codex-config.mjs +24 -4
  16. package/driver/engine/mcp/gather-config.mjs +4 -3
  17. package/driver/engine/mcp/recording-server.mjs +8 -0
  18. package/driver/engine/openai-agent.mjs +87 -2
  19. package/driver/findings-model.mjs +7 -3
  20. package/driver/gateway.mjs +36 -10
  21. package/driver/matter-frame-record.mjs +12 -2
  22. package/driver/package.json +1 -1
  23. package/driver/partial-payload-baseline.json +2 -1
  24. package/driver/pipeline.mjs +129 -37
  25. package/driver/placement-diff.mjs +8 -3
  26. package/driver/placement-form-io.mjs +31 -0
  27. package/driver/placement-form.mjs +231 -31
  28. package/driver/placement-model.mjs +6 -0
  29. package/driver/placement-union.mjs +134 -10
  30. package/driver/predelivery-lint.mjs +3 -3
  31. package/driver/publish/index.mjs +4 -1
  32. package/driver/record-goods.mjs +291 -0
  33. package/driver/remedy-accounting.mjs +2 -6
  34. package/driver/skills/clearance-variants/SKILL.md +2 -2
  35. package/driver/skills/knockout-assess/SKILL.md +2 -2
  36. package/driver/skills/matter-frame/SKILL.md +2 -2
  37. package/driver/skills/placement-inquiry/SKILL.md +17 -17
  38. package/driver/stage-context.mjs +6 -2
  39. package/driver/stages-knockout.mjs +0 -13
  40. package/driver/stages.mjs +19 -10
  41. package/driver/suite-census.json +196 -16
  42. package/driver/terminal-clamp.mjs +39 -5
  43. package/driver/unit-inventory.mjs +7 -0
  44. package/driver/variant-manifest-model.mjs +26 -1
  45. package/driver/verify-knockout.mjs +27 -10
  46. package/driver/verify.mjs +79 -7
  47. package/mcp-server/CHANGELOG.md +8 -0
  48. package/mcp-server/http-smoke.mjs +2 -0
  49. package/mcp-server/lib/audit-view.mjs +10 -3
  50. package/mcp-server/lib/events.mjs +7 -0
  51. package/mcp-server/lib/knockout.mjs +7 -5
  52. package/mcp-server/package.json +1 -1
  53. package/package.json +1 -1
  54. package/portal-ui/package.json +1 -1
  55. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  56. package/providers/oauth-mcp-bridge/package.json +1 -1
  57. package/scripts/ask-ai-render-check.mjs +1 -0
  58. package/scripts/authority-boundary-probe.mjs +2 -0
  59. package/scripts/band-shape-probe.mjs +2 -1
  60. package/scripts/freeze-example-run.mjs +2 -2
  61. package/scripts/report-header-render-check.mjs +1 -0
  62. package/scripts/report-offline-render-check.mjs +1 -0
  63. package/scripts/report-screenshot.mjs +1 -0
  64. package/scripts/report-sections-render-check.mjs +1 -0
  65. package/scripts/report-theme-render-check.mjs +1 -0
  66. package/scripts/revisit-render-check.mjs +1 -0
  67. package/scripts/settings-render-check.mjs +1 -0
  68. package/scripts/undeclared-key-census.mjs +1 -0
  69. package/scripts/verify-publishable.mjs +2 -0
  70. package/shared/names-in-force.mjs +1 -0
@@ -151,6 +151,7 @@ export function renderClearanceVariants(model, scopeRows) {
151
151
  // The prose copy restates exactly what the structured sibling holds — a reader of the manifest must
152
152
  // be able to see which words the register search was narrowed to without opening the JSON.
153
153
  if (model.goods_words?.length) out.push("### Goods words the register search is narrowed to", "", ...model.goods_words.map((w) => `- ${w}`), "");
154
+ if (model.product_words?.length) out.push("### The client's product words", "", ...model.product_words.map((w) => `- ${w}`), "");
154
155
  // — the search floor, on the human surface because a reader auditing the run has to see what was
155
156
  // obliged as well as what was done. Rendered ONLY when designated: an empty section would read as a
156
157
  // floor of nothing rather than as no floor, and those are the two states this mechanism must keep apart.
@@ -178,7 +179,7 @@ export function renderClearanceVariants(model, scopeRows) {
178
179
  */
179
180
  /** The shape this tool declares, at every depth — what the ACCEPTOR enforces. */
180
181
  const DECLARED = Object.freeze({
181
- "": ["mark", "dominant_element", "elements", "variants", "incumbent_classes", "search_floor", "watchlist_owners", "goods_words", "scope_ledger"],
182
+ "": ["mark", "dominant_element", "elements", "variants", "incumbent_classes", "search_floor", "watchlist_owners", "goods_words", "product_words", "scope_ledger"],
182
183
  elements: ["value", "kind", "famous_mark_flag"],
183
184
  variants: ["value", "category", "rationale", "romanization"],
184
185
  scope_ledger: ["layer", "item", "status", "reason", "reopen_trigger"],
@@ -218,6 +219,7 @@ export function mergeClearanceVariantsCall(stored, received) {
218
219
  // correct one part, and a seat that sends only that part, would otherwise delete the goods words
219
220
  // — and the loss reads as "this matter has none" rather than as a partial call.
220
221
  goods_words: keepIfAbsent(received?.goods_words, base.goods_words),
222
+ product_words: keepIfAbsent(received?.product_words, base.product_words),
221
223
  };
222
224
  }
223
225
 
@@ -234,6 +236,8 @@ export function acceptClearanceVariants(params) {
234
236
  // back a key nothing in this chain declares: the tool's schema offers the slot, and this is what
235
237
  // moves the answer from the call into the manifest the compiler reads.
236
238
  goods_words: params?.goods_words,
239
+ // — the client's product words, the particular goods the band shape groups on (ruled 2026-10-12).
240
+ product_words: params?.product_words,
237
241
  // — the search-floor axes. Validated by the model parser (closed against REGISTER_AXES), not
238
242
  // here, so there is ONE definition of what a floor may name.
239
243
  search_floor: params?.search_floor,
@@ -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
  }
@@ -213,6 +213,8 @@ export const VOCABULARY = [
213
213
  { token: "placement_unjudged", stages: ["placement-inquiry"], site: "driver/verify.mjs placementAccountVerdict" },
214
214
  { token: "placement_register_unrendered", stages: ["placement-inquiry"], site: "driver/verify.mjs placementAccountVerdict" },
215
215
  { token: "placement_form_unreadable", stages: ["placement-inquiry"], site: "driver/verify.mjs placementAccountVerdict" },
216
+ { token: "placement_floors_without_fate", stages: ["placement-inquiry"], site: "driver/verify.mjs placementSaidBackVerdict" },
217
+ { token: "placement_refused", stages: ["placement-inquiry"], site: "driver/verify.mjs placementSaidBackVerdict" },
216
218
  { token: "placement", stages: ["placement-inquiry"], site: "driver/verify.mjs validators.placement → checkSiblingJson → checkJson", family: "driver/placement-model.mjs (`placement_*` and `placements_*`)", dynamic: "D3" },
217
219
 
218
220
  // ── case-law / narrative-refutation ────────────────────────────────────────────────────────────────
@@ -131,6 +131,9 @@ const stageEnded = (runDir, stage) => {
131
131
 
132
132
  // The register's own steps, each of which the run survives without: the band's shape (the floors and the
133
133
  // full cards read it), the mechanical form floor, the owner screen, and the digest's folding in of re-runs.
134
+ // And the dominant-element crowd the blind frame comparison flagged and the reopen pass did not close: the
135
+ // clamp keeps that reason on the run record alone (ruled 2026-10-13), so the audit records the part here,
136
+ // read from the reopen's receipt as delivery found it.
134
137
  function register(runDir, log) {
135
138
  const part = { part: "register", name: PART_NAMES.register, reason: NOT_COMPLETED };
136
139
  const steps = [
@@ -140,6 +143,10 @@ function register(runDir, log) {
140
143
  ["digest-flush", "digest-flush-failed", "the digest flush"],
141
144
  ];
142
145
  const failed = steps.map(([ok, bad, what]) => [failedStep(log, ok, bad), what]).filter(([e]) => e);
146
+ let reopen = null;
147
+ try { reopen = JSON.parse(readFileSync(driverDir(runDir, "frame-reopen.json"), "utf8")); } catch { reopen = null; }
148
+ if (reopen?.dominant_element_gap === true && reopen?.domClosed !== true)
149
+ failed.push([{ fail: "the omission the blind frame comparison flagged was not closed by the reopen pass" }, "the dominant-element crowd"]);
143
150
  if (!failed.length) return null;
144
151
  return { ...part, cause: failed.map(([e, what]) => `${what}: ${e.fail ?? e.reason ?? e.event}`).join("; ") };
145
152
  }
@@ -34,7 +34,7 @@
34
34
  // those origins rather than re-deriving them, so it cannot disagree about which layer supplied a value.
35
35
 
36
36
  import { withRunPlatforms, derivedFloor } from "./profiles.mjs";
37
- import { recognizedTerritories } from "./territory-tiers.mjs"; // — a staff free-text field must not reach a prompt unchecked
37
+ import { recognizedTerritories, territoryKey } from "./territory-tiers.mjs"; // — a staff free-text field must not reach a prompt unchecked
38
38
 
39
39
  /**
40
40
  * Human-readable provenance, shown to clients.
@@ -163,6 +163,32 @@ export function narrowingMarkets(job, profile) {
163
163
  return { scope: [], priorities: defaultTerritoryState(profile).kept, major: [...MAJOR_MARKETS] };
164
164
  }
165
165
 
166
+ /**
167
+ * THE CLIENT'S MARKETS, as the band shape sorts by them (840 decision 7): the account's default markets,
168
+ * else the major markets, plus every market the order names. Codes as the territory vocabulary writes
169
+ * them, compared with a record's own jurisdictions. PURE.
170
+ */
171
+ export function clientMarkets(job, profile) {
172
+ // A sort key, not a scope: an order the ladder cannot read leaves the sort without markets rather than
173
+ // the band without a shape.
174
+ try {
175
+ const { scope } = narrowingMarkets(job, profile);
176
+ const defaults = defaultTerritoryState(profile).kept;
177
+ return [...new Set([...scope, ...(defaults.length ? defaults : MAJOR_MARKETS)].map((t) => territoryKey(t)).filter(Boolean))];
178
+ } catch { return []; }
179
+ }
180
+
181
+ /**
182
+ * THE ORDER'S TERRITORIES, as the band shape scopes its lists (ruled 2026-10-12): the countries a
183
+ * territorial order names, or the account's defaults filling an absent one; empty for a worldwide order.
184
+ * A ladder that cannot read the order leaves the lists unscoped rather than the band without a shape. PURE.
185
+ */
186
+ export function orderTerritories(job, profile) {
187
+ // Folded as the territory vocabulary folds them: an order that names "Switzerland" is CH, never a name
188
+ // that matches no register.
189
+ try { return [...new Set(narrowingMarkets(job, profile).scope.map((t) => territoryKey(t)).filter(Boolean))]; } catch { return []; }
190
+ }
191
+
166
192
  /**
167
193
  * THE NARROWING ORDER, as the register reading step is told it (ruled 2026-09-26): goods and class
168
194
  * first, then the markets in turn, with the markets the driver knows filled in. One line. PURE.
@@ -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,
@@ -20,10 +20,11 @@ import { readFileSync, readdirSync, appendFileSync, existsSync, mkdirSync } from
20
20
  import { join, dirname } from "node:path";
21
21
  import { driverDir } from "../../../shared/driver-dir.mjs"; //
22
22
  import { serve } from "./stdio-server.mjs";
23
- import { answerBudget } from "../../../shared/answer-parts.mjs";
24
- import { classifyRecord, prepareTargets, SHAPE_TIERS, isLiveRecord } from "../../band-shape.mjs";
23
+ import { answerBudget, quotedBytes } from "../../../shared/answer-parts.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, rawGoodsCarried, untaggedIsEnglish } from "../../record-goods.mjs";
27
28
 
28
29
  const RUN_DIR = process.env.CLEAROTRON_BAND_RUN_DIR || "";
29
30
  const SESSION = process.env.CLEAROTRON_GATHER_SESSION_KEY || "";
@@ -107,7 +108,8 @@ const SHAPE_PART_CHARS = 70000; // step 3 — was a knob; no environment ever
107
108
  // band_lookup of 100 fat records or a pathological official record could exceed the engine's
108
109
  // per-tool-output token cap and error the call — killing the reading path exactly where the record
109
110
  // volume is highest. band_lookup TRIMS records (the full match count always survives, with an
110
- // 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`).
111
113
  // Given an answer size and no band size of its own, the band answers in five sixths of it: the rest is room
112
114
  // for the quotes and line breaks a printout adds (up to 13% on band answers) and for a banner, so a band
113
115
  // answer is never split a second time (shared/answer-parts.mjs). 37,500 on Claude, 30,000 on codex.
@@ -122,15 +124,27 @@ const RESPONSE_CHARS = Math.max(4096, Number(process.env.CLEAROTRON_BAND_RESPONS
122
124
  // so a larger answer size cannot push a part past Claude's limit; with no size set, nothing changes.
123
125
  const SHAPE_PART_SERVED = Math.min(SHAPE_PART_CHARS, RESPONSE_CHARS);
124
126
 
125
- /** Split text into parts of ≤ budget chars, cutting only at line boundaries (an over-budget single
126
- * line still ships whole — never cut mid-line). Concatenating the parts with "\n" restores the
127
- * input byte-for-byte. */
127
+ // The answer size is in bytes, counted as a quoted string (shared/answer-parts.mjs), and characters are not
128
+ // bytes: a page of marks and owners in a script of three bytes a character, trimmed to the character size,
129
+ // came to about three times the answer size and was split a second time. So every answer and every part is
130
+ // held to the bytes as well, with room in a part for its banner. Latin text meets the character size first,
131
+ // so it is served as before.
132
+ const RESPONSE_BYTES = answerBudget();
133
+ const PART_ROOM = 512;
134
+ const fits = (text) => text.length <= RESPONSE_CHARS && (!RESPONSE_BYTES || quotedBytes(text) <= RESPONSE_BYTES);
135
+
136
+ /** Split text into parts of ≤ budget chars and, given an answer size, ≤ its bytes less a banner's room,
137
+ * cutting only at line boundaries (an over-budget single line still ships whole — never cut mid-line).
138
+ * Concatenating the parts with "\n" restores the input byte-for-byte. */
128
139
  function splitAtLines(text, budget) {
140
+ const bytes = RESPONSE_BYTES ? RESPONSE_BYTES - PART_ROOM : Infinity;
129
141
  const parts = [];
130
- let cur = null;
142
+ let cur = null, curBytes = 0;
131
143
  for (const line of String(text).split("\n")) {
132
- if (cur !== null && cur.length + 1 + line.length > budget) { parts.push(cur); cur = line; }
133
- else cur = cur === null ? line : cur + "\n" + line;
144
+ const lineBytes = RESPONSE_BYTES ? quotedBytes(line) : 0;
145
+ if (cur !== null && (cur.length + 1 + line.length > budget || curBytes + 2 + lineBytes > bytes)) { parts.push(cur); cur = line; curBytes = lineBytes; }
146
+ else if (cur === null) { cur = line; curBytes = lineBytes; }
147
+ else { cur += "\n" + line; curBytes += 2 + lineBytes; }
134
148
  }
135
149
  parts.push(cur ?? "");
136
150
  return parts;
@@ -224,35 +238,119 @@ function resolveRecordFile(dir, uri) {
224
238
  return { miss: "not-fetched", files };
225
239
  }
226
240
 
241
+ // WHAT A PART OF THE SHAPE HOLDS. A group's row is checked against the lines the step READ, so each md part
242
+ // served logs the listed lines printed in it, found through the md line the shape's data records for each
243
+ // (`md_lines`), and which parts hold each group, so a refusal can name the parts left unread. The json form
244
+ // is the machine shape: reading it serves no line. A shape with no recorded lines logs none.
245
+ function linesByPart(parts) {
246
+ let shape = null;
247
+ try { shape = loadJson(shapeJsonPath()); } catch { shape = null; }
248
+ const at = shape?.md_lines && typeof shape.md_lines === "object" ? shape.md_lines : {};
249
+ const ends = [];
250
+ let line = 0;
251
+ for (const p of parts) { line += p.split("\n").length; ends.push(line - 1); }
252
+ const partOf = (n) => ends.findIndex((end) => n <= end);
253
+ const keys = parts.map(() => []);
254
+ for (const [key, n] of Object.entries(at)) { const i = partOf(n); if (i >= 0) keys[i].push(key); }
255
+ const groupParts = {};
256
+ for (const g of ["a", "b", "c", "d", "rest", "no_goods"]) {
257
+ const ps = new Set((shape?.groups?.[g]?.list ?? []).map((l) => at[l.key]).filter((n) => n != null).map((n) => partOf(n) + 1));
258
+ if (ps.size) groupParts[g] = [...ps].sort((x, y) => x - y);
259
+ }
260
+ return { keys, groupParts };
261
+ }
262
+
227
263
  function bandShape(params) {
228
264
  const format = params?.format === "json" ? "json" : "md";
229
265
  const p = format === "json" ? shapeJsonPath() : shapeMdPath();
230
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." }; }
231
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).` }; }
232
- const text = readFileSync(p, "utf8");
268
+ const text = format === "md" && !GROUP_PAGES ? withoutPlacementOrders(readFileSync(p, "utf8")) : readFileSync(p, "utf8");
233
269
  const parts = splitAtLines(text, SHAPE_PART_SERVED);
234
270
  const total = parts.length;
271
+ const read = format === "md" ? linesByPart(parts) : null;
235
272
  if (total === 1) { // the common case — served verbatim, no banner
236
- logRead("band_shape", { format }, { ok: true, bytes: text.length });
273
+ logRead("band_shape", { format }, { ok: true, bytes: text.length, ...(read ? { part: 1, parts: 1, served_lines: read.keys[0], group_parts: read.groupParts } : {}) });
237
274
  return text;
238
275
  }
239
276
  const part = Math.min(Math.max(1, Math.floor(Number(params?.part)) || 1), total);
240
- logRead("band_shape", { format, part, parts: total }, { ok: true, bytes: parts[part - 1].length, total_bytes: text.length });
241
- const head = `[band_shape part ${part}/${total} — this shape exceeds one tool response, so it is served in ${total} parts` +
242
- ` split at line boundaries (every floor row is intact). The shape is COMPLETE only once ALL ${total} parts are read` +
243
- `${format === "json" ? "; concatenate the parts before parsing the JSON" : ""} — never reason from a partial shape.]`;
277
+ logRead("band_shape", { format, part, parts: total }, { ok: true, bytes: parts[part - 1].length, total_bytes: text.length,
278
+ ...(read ? { served_lines: read.keys[part - 1], group_parts: read.groupParts } : {}) });
279
+ const head = format === "json"
280
+ ? `[band_shape part ${part}/${total} of the json; concatenate the parts before parsing it.]`
281
+ : `[band_shape part ${part}/${total}. Read each group by the size sentence at its head; the next part continues the groups.]`;
244
282
  const tail = part < total
245
283
  ? `\n[end of part ${part}/${total} — call band_shape again with {"part": ${part + 1}} for the next part]`
246
- : `\n[end of part ${total}/${total} — the shape is complete once parts 1..${total} are all read]`;
284
+ : `\n[end of the shape]`;
247
285
  return head + "\n" + parts[part - 1] + tail;
248
286
  }
249
287
 
288
+ // THE GOODS ON THE ROW, AS THE SHAPE TESTS THEM. Each record served carries the goods words its goods
289
+ // hold, "k of N: the words", tested on the same words and stems the shape grouped on (its `goods_test`),
290
+ // from the records the register already logged to this run (record-goods.mjs). The goods text is one
291
+ // `band_record` away; a row never carries it. A record whose goods cannot be read says so with null.
292
+ let goodsCache = null;
293
+ const goodsIndex = () => (goodsCache ??= bandGoodsIndex(RUN_DIR));
294
+ let testCache = null;
295
+ const shapeGoodsTest = () => {
296
+ if (testCache) return testCache;
297
+ let words = [];
298
+ try { words = loadJson(shapeJsonPath()).groups?.goods_test?.words ?? []; } catch { words = []; }
299
+ testCache = { words, matched: goodsWordsMatched(words, { stems: true }) };
300
+ return testCache;
301
+ };
302
+ // The record's body first; a row that carries goods of its own stands in where the run holds no body.
303
+ const goodsOfRow = (r) => goodsFor(goodsIndex(), r)
304
+ ?? (typeof r?.goods === "string" && r.goods.trim() ? [{ classes: [], text: r.goods.trim(), ...(untaggedIsEnglish(r?.record_id) ? {} : { en: false }) }] : null);
305
+ const withGoods = (r) => {
306
+ const { words, matched } = shapeGoodsTest();
307
+ const m = words.length ? matched(goodsOfRow(r)) : null;
308
+ const { goods: _text, ...row } = project(r); // a row's own goods text stands in for the test, and is not served
309
+ return { ...row, goods_words: m == null ? null : `${m.length} of ${words.length}${m.length ? `: ${m.join(", ")}` : ""}` };
310
+ };
311
+
312
+ // A GROUP, PAGED AS THE SHAPE LISTS IT. The shape lists a group of a thousand records or more by its first
313
+ // page; the rest of its lines are served here, one compact line per mark of an owner, in the group's own
314
+ // order. A group's row is refused until every line was read (placement-union.mjs), so the step can always
315
+ // reach every line it is asked to answer for. Each answer logs the lines it served.
316
+ const GROUP_LINE_LIMIT = 200;
317
+ function groupLookup(params) {
318
+ const g = String(params.group ?? "").trim().toLowerCase();
319
+ let groups;
320
+ try { groups = loadJson(shapeJsonPath()).groups; } catch { groups = null; }
321
+ if (!groups?.[g]?.list) { logRead("band_lookup", params, { ok: false, reason: "no-group" }); return { isError: true, text: `ERROR: the band shape lists no group "${g}" — one of a, b, c, d, rest, no_goods.` }; }
322
+ const lines = groups[g].list;
323
+ const total = groups.goods_words_total || 0;
324
+ const limit = Math.max(1, Math.min(Number(params.limit) || GROUP_LINE_LIMIT, GROUP_LINE_LIMIT));
325
+ const offset = Math.max(0, Math.floor(Number(params.offset) || 0));
326
+ const page = lines.slice(offset, offset + limit).map((l) => ({
327
+ mark: l.mark, owner: l.owner_name, classes: l.classes, live: `${l.live} of ${l.records}`, territories: l.territories,
328
+ goods_words: l.goods_words == null ? null : `${l.goods_words.length} of ${total}${l.goods_words.length ? `: ${l.goods_words.join(", ")}` : ""}`,
329
+ ...(l.goods_words == null && l.goods_on_file != null ? { goods_on_file: l.goods_on_file } : {}),
330
+ floor: l.floors > 0, record: l.record_id, _key: l.key,
331
+ }));
332
+ const body = () => JSON.stringify({
333
+ group: g, lines_total: lines.length, returned: page.length, ...(offset ? { offset } : {}),
334
+ ...(lines.length > offset + page.length ? { next_offset: offset + page.length,
335
+ note: `call again with offset: ${offset + page.length} for the next lines of group ${g}` } : {}),
336
+ lines: page.map(({ _key, ...l }) => l),
337
+ }, null, 2);
338
+ let out = body();
339
+ while (!fits(out) && page.length > 1) { page.pop(); out = body(); }
340
+ logRead("band_lookup", params, { ok: true, group: g, returned: page.length, served_lines: page.map((l) => l._key) });
341
+ return out;
342
+ }
343
+
250
344
  function bandLookup(params = {}) {
251
345
  if (!RUN_DIR) return { isError: true, text: "ERROR: CLEAROTRON_BAND_RUN_DIR not set — band tools are wired per run by the driver." };
346
+ if (params.group != null && String(params.group).trim()) return groupLookup(params);
252
347
  if (!existsSync(bandPath())) { logRead("band_lookup", params, { ok: false }); return { isError: true, text: "ERROR: register-named-band.json not found in this run — the band has not been merged yet." }; }
253
348
  let band;
254
349
  try { band = loadJson(bandPath()); } catch (e) { logRead("band_lookup", params, { ok: false }); return { isError: true, text: `ERROR: band unreadable (${e.message}) — a driver-side defect; report it, never hand-repair the band.` }; }
255
- const { record_id, owner, nice_class, tier, qid, query, text, status, live_only = false } = params;
350
+ const { record_id, owner, nice_class, tier, qid, query, text, status, live_only = false, goods } = params;
351
+ // Words, as a list or one string. A record whose goods the run does not hold passes: unknown is not a mismatch.
352
+ const goodsWords = (Array.isArray(goods) ? goods : goods == null ? [] : [goods]).map((w) => String(w ?? "").trim()).filter(Boolean);
353
+ const goodsCount = goodsWords.length ? goodsWordCounter(goodsWords, { stems: true }) : null; // the shape's own matcher
256
354
  const limit = Math.max(1, Math.min(Number(params.limit) || 20, 100));
257
355
  // PAGING. A large band is read page by page from `offset`, in the band's own order, and a capped answer
258
356
  // names the next offset. Without it the only way to see every record was to slice the band by owner or
@@ -275,8 +373,8 @@ function bandLookup(params = {}) {
275
373
  if (record_id && norm(r?.record_id) !== norm(record_id)) continue;
276
374
  if (owner && !norm(r?.owner_name).includes(norm(owner))) continue;
277
375
  if (nice_class != null) {
278
- const cls = (Array.isArray(r?.classes) ? r.classes : [r?.classes]).map((c) => String(c ?? "").trim());
279
- if (!cls.includes(String(nice_class).trim())) continue;
376
+ const cls = (Array.isArray(r?.classes) ? r.classes : [r?.classes]).map(niceClassKey); // "09" is class 9
377
+ if (!cls.includes(niceClassKey(nice_class))) continue;
280
378
  }
281
379
  if (status && !norm(r?.status ?? r?.screen?.status).includes(norm(status))) continue;
282
380
  if (live_only === true && !isLiveRecord(r)) continue;
@@ -288,8 +386,9 @@ function bandLookup(params = {}) {
288
386
  if (query && ![...(Array.isArray(r?._queries) ? r._queries : []), r?._query].some((q) => norm(q).includes(norm(query)))) continue;
289
387
  if (text && !norm(r?.mark_text).includes(norm(text))) continue;
290
388
  if (prepared && classifyRecord(r?.mark_text, prepared).tier !== tier) continue;
389
+ if (goodsCount && goodsCount(goodsOfRow(r)) === 0) continue;
291
390
  matched++;
292
- if (matched > offset && hits.length < limit) hits.push(project(r));
391
+ if (matched > offset && hits.length < limit) hits.push(withGoods(r));
293
392
  }
294
393
  // matching crowd descriptors ride along (a lookup that lands in an un-enumerated zone must SEE that).
295
394
  const crowds = (Array.isArray(band?.crowds) ? band.crowds : []).filter((c) => {
@@ -330,11 +429,32 @@ function bandLookup(params = {}) {
330
429
  + (unsizedCrowds ? ` — and ${unsizedCrowds} of them has/have an UNKNOWN SIZE (see "size"): the register stated no total, so how much lies under those slices is unmeasured` : "") } : {}),
331
430
  }, null, 2);
332
431
  let out = body();
333
- while (out.length > RESPONSE_CHARS && hits.length > 1) { hits.pop(); byteCapped++; out = body(); }
334
- logRead("band_lookup", params, { ok: true, matched, returned: hits.length, crowds: crowds.length, ...(byteCapped ? { byte_capped: byteCapped } : {}) });
432
+ while (!fits(out) && hits.length > 1) { hits.pop(); byteCapped++; out = body(); }
433
+ logRead("band_lookup", params, { ok: true, matched, returned: hits.length, crowds: crowds.length, ...(byteCapped ? { byte_capped: byteCapped } : {}),
434
+ served: hits.map((h) => h.record_id).filter(Boolean) });
335
435
  return out;
336
436
  }
337
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
+
338
458
  function bandRecord(params = {}) {
339
459
  if (!RUN_DIR) return { isError: true, text: "ERROR: CLEAROTRON_BAND_RUN_DIR not set — band tools are wired per run by the driver." };
340
460
  const uri = String(params.record_id ?? "").trim();
@@ -362,21 +482,22 @@ function bandRecord(params = {}) {
362
482
  // How the cite resolved rides the log: an `exact` hit is the model quoting the store's own key; any
363
483
  // other value is this ladder doing work, and the reading audit says which and to what.
364
484
  const resolution = hit.via === "exact" ? {} : { via: hit.via, ...(hit.resolved ? { resolved: hit.resolved } : {}) };
485
+ const served = recordAsServed(text);
486
+ text = served.text;
365
487
  const parts = splitAtLines(text, RESPONSE_CHARS);
366
488
  const total = parts.length;
367
- if (total === 1) { // the common case — served verbatim, no banner
368
- 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 });
369
491
  return text;
370
492
  }
371
- // An oversize official record serves in PARTS, same contract as band_shape: line-boundary splits,
372
- // byte-identical concatenation, and an explicit "complete only once all parts are read" banner.
493
+ // An oversize official record serves in PARTS, as band_shape does: line-boundary splits, byte-identical
494
+ // concatenation, and a banner that says the next part continues it.
373
495
  const part = Math.min(Math.max(1, Math.floor(Number(params.part)) || 1), total);
374
- logRead("band_record", { record_id: uri, part, parts: total }, { ok: true, bytes: parts[part - 1].length, total_bytes: text.length, ...resolution });
375
- const head = `[band_record part ${part}/${total} — this record exceeds one tool response, so it is served in ${total} parts` +
376
- ` split at line boundaries. The record is COMPLETE only once ALL ${total} parts are read; concatenate the parts before parsing.]`;
496
+ logRead("band_record", { record_id: uri, part, parts: total }, { ok: true, bytes: parts[part - 1].length, total_bytes: text.length, ...resolution, ...served.audit });
497
+ const head = `[band_record part ${part}/${total}. This record is served in ${total} parts; the next part continues it.]`;
377
498
  const tail = part < total
378
499
  ? `\n[end of part ${part}/${total} — call band_record again with {"record_id": "${uri}", "part": ${part + 1}} for the next part]`
379
- : `\n[end of part ${total}/${total} — the record is complete once parts 1..${total} are all read]`;
500
+ : `\n[end of the record]`;
380
501
  return head + "\n" + parts[part - 1] + tail;
381
502
  }
382
503
  // ── BEFORE CALLING IT A MISS, ASK THE LEDGER — ─────────────────────────────────
@@ -386,15 +507,16 @@ function bandRecord(params = {}) {
386
507
  // fetched this run", which is a different claim and was false four times on a delivered round.
387
508
  const fromLedger = recordFromLedger(uri);
388
509
  if (fromLedger) {
389
- const text = JSON.stringify(fromLedger, null, 2);
510
+ const served = recordAsServed(JSON.stringify(fromLedger, null, 2));
511
+ const text = served.text;
390
512
  const parts = splitAtLines(text, RESPONSE_CHARS);
391
513
  if (parts.length === 1) {
392
- 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 });
393
515
  return { type: "text", text };
394
516
  }
395
517
  const wanted = Number(params.part ?? 1);
396
518
  const part = Number.isInteger(wanted) && wanted >= 1 && wanted <= parts.length ? wanted : 1;
397
- 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 });
398
520
  return { type: "text", text: `[record ${uri} — part ${part}/${parts.length}]\n` + parts[part - 1]
399
521
  + (part < parts.length ? `\n[end of part ${part}/${parts.length} — call band_record again with {"record_id": "${uri}", "part": ${part + 1}}]` : "") };
400
522
  }
@@ -413,23 +535,44 @@ function bandRecord(params = {}) {
413
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.` };
414
536
  }
415
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
+
416
557
  serve({
417
558
  name: "band", version: "0.1.0",
418
559
  tools: [
419
560
  {
420
561
  name: "band_shape",
421
- description: "The deterministic shape of this run's merged register band: totals, mechanical tiers, the complete floors list (every live in-class identical/near-identical record), class/status/registry/recency census, owner concentrations, crowd descriptors (with crowd-context joins) and blind spots. Read this FIRST, then pull what it names via band_lookup/band_record. A floors-heavy shape is served in PARTS (the response labels itself part N/M and names the next call — read ALL parts; the shape is complete only then, and a partial read is never the shape). Every call is logged to the run's reading audit.",
562
+ description: "The deterministic shape of this run's merged register band: totals, mechanical tiers, the floors (every live in-class identical/near-identical record), each marked on its line in the band in the lawyer's order, class/status/registry/recency census, owner concentrations, crowd descriptors (with crowd-context joins) and blind spots. Read this FIRST, then pull what it names via band_lookup/band_record. A large shape is served in parts, each naming the next call. Every call is logged to the run's reading audit.",
422
563
  inputSchema: { type: "object", properties: { format: { type: "string", enum: ["md", "json"], description: "md (default, readable) or json (machine shape)" }, part: { type: "number", description: "1-based part number when the shape is served in parts (default 1)" } } },
423
564
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
424
565
  handler: bandShape,
425
566
  },
426
567
  {
427
568
  name: "band_lookup",
428
- 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, limit (default 20, max 100), 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). 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),
429
570
  inputSchema: { type: "object", properties: {
430
571
  record_id: { type: "string" }, owner: { type: "string" }, nice_class: { type: "string" },
431
572
  tier: { type: "string", enum: SHAPE_TIERS }, qid: { type: "string" }, query: { type: "string" }, text: { type: "string" },
432
- status: { type: "string" }, live_only: { type: "boolean" }, limit: { type: "number" }, offset: { type: "number" },
573
+ status: { type: "string" }, live_only: { type: "boolean" }, goods: { type: "array", items: { type: "string" } },
574
+ ...(GROUP_PAGES ? { group: { type: "string", enum: ["a", "b", "c", "d", "rest", "no_goods"] } } : {}),
575
+ limit: { type: "number" }, offset: { type: "number" },
433
576
  } },
434
577
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
435
578
  handler: bandLookup,