clearotron 0.5.0-beta.3 → 0.5.0-beta.5

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 (50) hide show
  1. package/THIRD-PARTY-NOTICES.md +1 -1
  2. package/build-info.json +2 -2
  3. package/docs/architecture/03-run-lifecycle.md +6 -0
  4. package/docs/architecture/04-configuration-reference.md +1 -1
  5. package/docs/architecture/05-config-governance.md +1 -1
  6. package/driver/CHANGELOG.md +22 -0
  7. package/driver/band-shape.mjs +15 -7
  8. package/driver/contract-e3-backlog.mjs +6 -6
  9. package/driver/coverage-form.mjs +1 -1
  10. package/driver/engine/mcp/band-server.mjs +12 -12
  11. package/driver/engine/mcp/codex-config.mjs +12 -7
  12. package/driver/engine/mcp/dispositions-server.mjs +7 -0
  13. package/driver/engine/mcp/perplexity-server.mjs +44 -3
  14. package/driver/engine/mcp/recording-server.mjs +16 -1
  15. package/driver/engine/mcp/supplemental.mjs +6 -1
  16. package/driver/engine/openai-agent.mjs +62 -20
  17. package/driver/findings-model.mjs +31 -1
  18. package/driver/gateway.mjs +18 -2
  19. package/driver/owner-use-check.mjs +15 -6
  20. package/driver/package.json +1 -1
  21. package/driver/pipeline.mjs +91 -26
  22. package/driver/placement-decisions.mjs +20 -5
  23. package/driver/predelivery-lint.mjs +54 -12
  24. package/driver/publish/render-knockout.mjs +23 -4
  25. package/driver/record-replay.mjs +40 -0
  26. package/driver/register-digest-record.mjs +27 -8
  27. package/driver/register-plan.mjs +6 -2
  28. package/driver/repair-targets.mjs +81 -6
  29. package/driver/research-answers.mjs +149 -8
  30. package/driver/research-checks.mjs +84 -0
  31. package/driver/saved-findings-block.mjs +36 -0
  32. package/driver/skills/clearance-register/digest.md +1 -1
  33. package/driver/skills/clearance-search/phase2-execution.md +13 -8
  34. package/driver/skills/clearance-search/synthesis-rules.md +2 -2
  35. package/driver/skills/narrative-refutation/SKILL.md +1 -1
  36. package/driver/stage-context.mjs +31 -1
  37. package/driver/stages.mjs +42 -10
  38. package/driver/suite-census.json +100 -4
  39. package/driver/synthesis-record.mjs +278 -3
  40. package/mcp-server/CHANGELOG.md +8 -0
  41. package/mcp-server/package.json +1 -1
  42. package/package.json +1 -1
  43. package/portal-ui/package.json +1 -1
  44. package/providers/_shared/execute-plan.mjs +9 -0
  45. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  46. package/providers/oauth-mcp-bridge/package.json +1 -1
  47. package/scripts/deprecate-below.mjs +14 -3
  48. package/scripts/e2e-first-time.mjs +2 -1
  49. package/scripts/release-notes-lint.mjs +1 -1
  50. package/shared/inside-run.mjs +54 -0
@@ -80,7 +80,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
80
80
  THE SOFTWARE.
81
81
  ```
82
82
 
83
- ## @hono/node-server@1.19.17
83
+ ## @hono/node-server@2.1.5
84
84
 
85
85
  - **Licence declared:** `MIT`
86
86
  - **Repository:** https://github.com/honojs/node-server
package/build-info.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "9a5ba90756f37ddefaee2a21ab79e9f7a374667a",
3
- "version": "0.5.0-beta.3"
2
+ "commit": "a475b2f2632ee5df27bc7248bd73d27d6fc6f5c3",
3
+ "version": "0.5.0-beta.5"
4
4
  }
@@ -438,6 +438,7 @@ node pipeline.mjs --job <file.json> [--agent <id>] # cold run (still take
438
438
  node pipeline.mjs --resume <codename> # idempotent resume: valid outputs skip
439
439
  node pipeline.mjs --resume <codename> --from <stage> # force re-run from a stage ordinal
440
440
  node pipeline.mjs --resume <codename> --experiment <stage> [--label <t>] # shadow dir
441
+ node pipeline.mjs --resume <codename> --experiment synthesis --research replay # research from the run's own answers
441
442
  ```
442
443
 
443
444
  - **Resume** refuses a delivered run (any of the three delivered markers above — re-handing off a
@@ -448,6 +449,11 @@ node pipeline.mjs --resume <codename> --experiment <stage> [--label <t>]
448
449
  - **`--experiment`** runs one stage in a shadow dir (`_experiments/<ts>-<tag>/`) on copies of its
449
450
  inputs, under a `clearance-exp-…` session key that is excluded from the run's provider-usage
450
451
  attribution. The canonical run is untouched.
452
+ - **`--research replay`** answers the arm's research from the answers the run saved
453
+ (`_driver/research-answers.jsonl`, copied into the shadow dir). A question the run asked gets the
454
+ run's latest answer to it; a question it did not ask, and any grid, is refused, and nothing is sent
455
+ to the research service. It is refused for a stage that holds no research tool, or a run that saved
456
+ no answers. The arm's `experiment-context.json` records `research: {mode, answers}`.
451
457
  - **Orphan self-resume**: a manually resumed run that parks has no queue sidecars; the runner scans
452
458
  run dirs for due, payload-complete `.postponed` sentinels not owned by any queue and resumes them
453
459
  (claim = atomic rename to `.resuming`).
@@ -191,7 +191,7 @@ timeout shot, warm patch, backoff), the lane-wedge re-dispatch, and the rate-lim
191
191
  | `CLEAROTRON_AI_BILLING` | `subscription` (default) \| `api-key` \| `cloud` | Billing mode for the selected engine. ONE variable for both, and only the LIVE engine's setting is read — it fills each engine's billing knob, and the engine that is not selected is never consulted. **`anthropic-agent`:** subscription **deletes `ANTHROPIC_API_KEY` from the child env** so `claude -p` uses OAuth subscription credentials (a present key would override them); `api-key` keeps the key — the scale setting and standing fallback; **`cloud`** bills Claude per use through the reader's own cloud account, named by the Claude program's own switch — `CLAUDE_CODE_USE_VERTEX`, `CLAUDE_CODE_USE_FOUNDRY` or `CLAUDE_CODE_USE_BEDROCK`, or `ANTHROPIC_BASE_URL` alone for a gateway (see the credentials table below) — and deletes `ANTHROPIC_API_KEY` as subscription does. **`openai-agent`:** subscription seeds `auth.json` into the per-run `CODEX_HOME` from `CLEAROTRON_OPENAI_AUTH_FILE` (default `~/.codex/auth.json`) and strips API keys; `api-key` keeps `CODEX_API_KEY`; `cloud` is refused. **Fail-loud, never a guess, never the subscription in its place:** `api-key` with no `ANTHROPIC_API_KEY` / `CODEX_API_KEY` throws; on `anthropic-agent`, `cloud` with two switches on, or with none and no `ANTHROPIC_BASE_URL`, throws, and so does a switch left on under `subscription` or `api-key`, because the program bills that cloud whatever this says; a value that is none of the three throws on both engines. `clearotron start` and `clearotron doctor` report each refusal against the settings the services read, naming a value that is not a billing mode by its setting and never quoting it, and the runner refuses the search when it is ordered, before anything is spent. The resolved mode is stamped on every stage telemetry row (`engine`, `authMode`, `apiBilled`, and `cloud`, null when no cloud bills), and the attempt row also carries `providerReported`, the program's own word for who served the turn. |
192
192
  | `CLEAROTRON_CLAUDE_PATH` · `CLEAROTRON_CODEX_PATH` | `claude` / `codex` on `PATH`, then the copy Clearotron installed | The engine binary to spawn, ONE PER ENGINE: `CLEAROTRON_CLAUDE_PATH` under `anthropic-agent`, `CLEAROTRON_CODEX_PATH` under `openai-agent`. Only the live engine's is read, and a machine that runs both sets both — they were one variable until it met a machine needing two different paths. A path set here must be **absolute**: stage subprocesses run with cwd set to the run directory, so a relative path does not resolve there, and `clearotron doctor` refuses one. **Unset**, or set to the bare word `claude` / `codex` (which means the same), the engine uses the program on `PATH` when the machine has one, and otherwise the copy Clearotron installed in `~/.local/share/clearotron/engines`: `clearotron install` offers to put the chosen engine's program there (`@anthropic-ai/claude-code` or `@openai/codex`, this platform's build only), and `clearotron update` refreshes it. Nothing is bundled into the package. A value naming anything else is used as given and never falls back. `clearotron doctor` says which copy it found, and its version when npm installed that copy. |
193
193
  | `CLEAROTRON_OPENAI_AUTH_FILE` | `~/.codex/auth.json` | The credentials file seeded into the per-run `CODEX_HOME` under subscription billing. Was exempted from the August 2026 rename as an `openai-agent` internal rather than an install-surface name; a decision of 2026-09-04 **reversed that exemption** and renamed the whole namespace, so it carries the house prefix like everything else. One spelling, no exceptions. |
194
- | `CLEAROTRON_OPENAI_MODEL_JUDGMENT` / `CLEAROTRON_OPENAI_MODEL_SWEEP` / `CLEAROTRON_OPENAI_MODEL_CHEAP` | `gpt-6.1-sol` / `gpt-6.1-sol` / `gpt-6-luna` (from 2026-10-09; before that `gpt-5.6-sol` / `gpt-5.6-terra` / `gpt-5.6-luna`). Codex `0.159.0` or newer is required on a ChatGPT sign-in: older releases refuse `gpt-6.1-sol` with a 400. | Maps the driver's abstract `opus`/`sonnet`/`haiku` tiers onto the codex ladder, the way the anthropic engine maps onto its own (ruling 2026-09-02, superseding the single-model mapping). A tier that cannot hold its stage contract announces itself at that stage — `missing:negative-results`, `no_coverage_status_row`, `named_band_missing` in `_driver/<stage>.jsonl` — not at delivery. |
194
+ | `CLEAROTRON_OPENAI_MODEL_JUDGMENT` / `CLEAROTRON_OPENAI_MODEL_SWEEP` / `CLEAROTRON_OPENAI_MODEL_CHEAP` | `gpt-6.1-sol` / `gpt-6.1-terra` / `gpt-6-luna` (from 2026-10-28; from 2026-10-09 the sweep tier shared `gpt-6.1-sol`, and before that they were `gpt-5.6-sol` / `gpt-5.6-terra` / `gpt-5.6-luna`). Codex `0.159.0` or newer is required on a ChatGPT sign-in: older releases refuse `gpt-6.1-sol` with a 400. | Maps the driver's abstract `opus`/`sonnet`/`haiku` tiers onto the codex ladder, the way the anthropic engine maps onto its own (ruling 2026-09-02, superseding the single-model mapping). A tier that cannot hold its stage contract announces itself at that stage — `missing:negative-results`, `no_coverage_status_row`, `named_band_missing` in `_driver/<stage>.jsonl` — not at delivery. |
195
195
  | ↳ why they are three, and what the old measurement still says | superseded 2026-09-02, not erased | The three defaulted to `sol` alone on a MEASUREMENT, never a placeholder: on 2026-08-11/12, with `SWEEP=gpt-5.6-terra` / `CHEAP=gpt-5.6-luna`, every codex clearance died on structural-output gates — `missing:negative-results`, `no_coverage_status_row`, `named_band_missing` — over 2 scenarios × 3 stage families, **byte-identically on retry**, so no retry budget rescued it. That measurement stands for the code and the codex CLI **of that date**, and it is why the judgment tier keeps `sol`. Three weeks of stage-contract work and several codex minor versions sit between it and the 2026-09-02 ruling that split the three. **The symptom to match against this cause is unchanged**: those same three gates, as a fail row in`_driver/<stage>.jsonl`, inside the first few dispatches of a clearance and identical on every retry — a model incapable of a contract is not flaky at it. It reads as an engine bug when the only fault is this configuration. A cheap-tier experiment still belongs in the three constants in `driver/engine/openai-agent.mjs` where a reviewer sees it, not in an untraceable env line. |
196
196
  | `CLEAROTRON_DATABASE` | **REQUIRED — no default.** `corsearch` \| `clarivate` \| `signa` \| `euipo` \| `uspto-local` \| `free-tier` | Active register provider — one per run, set in the environment the deploy carries, in every environment including production. There is **no default**: unset resolves to `null` and every use of it throws, so a run refuses at start rather than calling a vendor nobody chose (the removed `corsearch` fallback named a vendor the deployment did not choose). Three are paid global sweeps; `euipo` (EU) and `uspto-local` (US) are free single-office sources, and `free-tier` composes those two as one register. Choosing a free value makes every territory outside its coverage a disclosed *deferred* row. Unknown ids throw loudly, and the gather layer throws at stage time for any provider without a built MCP server. |
197
197
  | `CLEAROTRON_CODEX_SANDBOX_BYPASS` | unset | `1` runs `codex exec` with its own sandbox bypassed, for hosts where that sandbox cannot run (setup and `doctor --probe-engine` report it). It removes a defence: with it set, a stage's commands run with every permission of the account Clearotron runs as, not only in the run folder. Codex has no guard against a stage writing into the run's `_driver/` folder, with the sandbox or without it. Set it because the host forces it, never for convenience. |
@@ -161,7 +161,7 @@ structural, or dev seam); [dev] = dev/test seam, never set in prod.
161
161
  `CLEAROTRON_AI` (anthropic-agent | openai-agent; default anthropic-agent), `CLEAROTRON_AI_BILLING`
162
162
  (subscription|api-key|cloud; cloud is Claude only), `CLEAROTRON_CODEX_PATH`,
163
163
  `CLEAROTRON_OPENAI_AUTH_FILE`, `CLEAROTRON_OPENAI_MODEL_JUDGMENT` / `CLEAROTRON_OPENAI_MODEL_SWEEP` /
164
- `CLEAROTRON_OPENAI_MODEL_CHEAP` (gpt-6.1-sol / gpt-6.1-sol / gpt-6-luna),
164
+ `CLEAROTRON_OPENAI_MODEL_CHEAP` (gpt-6.1-sol / gpt-6.1-terra / gpt-6-luna),
165
165
  `CLEAROTRON_CLAUDE_PATH` (claude on PATH, then the copy Clearotron installed),
166
166
  `CLEAROTRON_SYNTHESIS_MODEL` (opus), `CLEAROTRON_KNOCKOUT_MODEL` (opus),
167
167
  `CLEAROTRON_MAX_BUDGET_USD` (unset).
@@ -1,5 +1,27 @@
1
1
  # clearotron-driver
2
2
 
3
+ ## 0.5.0-beta.5
4
+
5
+ ### Patch Changes
6
+
7
+ - Fixed: When a register search fails, the report's coverage still accounts for the unread searches beside it, instead of leaving them out.
8
+ - Fixed: When the search for an owner's use of a mark could not run, the report no longer says that search found nothing.
9
+ - Fixed: A register entry the engine placed on the watchlist now appears in the audit trail with its reason, instead of being left out.
10
+ - Fixed: A citation of a finding the review withdrew, in the report's summary panel or methodology, is now sent back for correction before delivery.
11
+ - Fixed: A clearance that searched common law or the United Kingdom is no longer flagged as claiming a search it did not run.
12
+ - 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, GPT-6.1 Terra and GPT-6 Luna.
13
+ - Fixed: When two parties use the same name without a registration, the report keeps a finding for each, instead of keeping only the last one saved.
14
+
15
+ ## 0.5.0-beta.4
16
+
17
+ ### Patch Changes
18
+
19
+ - Fixed: On the Codex engine, a clearance no longer sees temporary files that another clearance left on the same machine.
20
+ - Fixed: A report's copy for the client is no longer held back when a live finding shares its mark and owner with a finding the review withdrew.
21
+ - Fixed: A register entry the engine decided to keep as background now appears in the report's context table, instead of being left out of it.
22
+ - Fixed: When the report's writing step is interrupted, the next attempt continues from the findings it had already finished instead of starting again.
23
+ - Fixed: On a crowded field, the report is written from the work the clearance has already done, without reading every register result again.
24
+
3
25
  ## 0.5.0-beta.3
4
26
 
5
27
  ### Patch Changes
@@ -1135,18 +1135,26 @@ export const AN_UNREAD_LISTS_FLOOR = "Place each floor here, or set it aside by
1135
1135
  // alone, as its other orders are: every other stage keeps the markers as evidence and is not told of a fate.
1136
1136
  export const AN_UNREAD_LINE_IS_CHECKED = 'A line marked "on file, not readable", "cut by the register, no goods word found", "goods held; completeness not established" or "no goods text held; completeness not established" must be checked with band_record before its fate. Read the evidence held for it, keeping its source and goods completeness clear. A line marked "no goods on file", "fetch failed, no goods on file" or "the register\'s record lists no goods" has no goods text on file. Missing goods do not establish that the goods are unrelated.';
1137
1137
  export const PLACEMENT_LINE_ORDERS = Object.freeze([FLOORS_BY_NAME_HERE, A_CROWDS_ONE_ROW, A_CROWDS_ROW_COVERS_A_READ_FLOOR, AN_UNREAD_CROWDS_ONE_ROW, AN_UNREAD_CROWDS_FLOOR, AN_UNREAD_LISTS_FLOOR, AN_UNREAD_LINE_IS_CHECKED]);
1138
+ // AND THE SIZE RULE'S ORDERS TO READ (ruled 2026-10-27). A list's size sentence tells placement how much of it to read,
1139
+ // and only placement owes the lines that read. Every other stage is served the shape without these orders too: the
1140
+ // writer is told the groups are a way to find evidence, never a list it must read to completion. Each list's count,
1141
+ // what it holds and the fact that a crowded one prints its first page stay.
1142
+ export const READ_THEM_ALL = "Fifty? Read them all.";
1143
+ export const READ_EVERY_LINE = ": read every line.";
1144
+ export const READ_THE_LIVE_ONES_FIRST = "A few hundred? Read the live ones in the ordered classes first.";
1138
1145
  const SHAPE_LISTS = ["a", "b", "c", "d", "rest", "no_goods"];
1139
1146
  const pageEveryOtherLine = (g) => `; page every other line with ${groupPageCall(g)}`;
1140
1147
  const pageWhereALineNeedsIt = (g) => `; page with ${groupPageCall(g)} only where a line needs reading`;
1141
1148
  const pageTheFloorsLeft = (g) => `Where more floors are counted than this page shows, page the rest with ${groupPageCall(g)}; they come first.`;
1142
1149
  /**
1143
- * The shape as every stage but placement is served it: placement's orders and its group-page commands removed,
1150
+ * The shape as every stage but placement is served it: placement's orders, its size rule's orders to read and its group-page commands removed,
1144
1151
  * each with the space before it, inside its line, so no line moves and the line each listed mark is printed on
1145
1152
  * still holds. A crowded group's "The first N lines are below" stays, as a fact about what is printed. PURE.
1146
1153
  */
1147
1154
  export function withoutPlacementOrders(text) {
1148
1155
  let t = String(text ?? "");
1149
- for (const order of [...PLACEMENT_GROUP_ORDERS, ...PLACEMENT_LINE_ORDERS, ...SHAPE_LISTS.map(pageTheFloorsLeft)]) t = t.split(` ${order}`).join("").split(order).join("");
1156
+ for (const order of [...PLACEMENT_GROUP_ORDERS, ...PLACEMENT_LINE_ORDERS, ...SHAPE_LISTS.map(pageTheFloorsLeft), READ_THEM_ALL, READ_THE_LIVE_ONES_FIRST]) t = t.split(` ${order}`).join("").split(order).join("");
1157
+ t = t.split(READ_EVERY_LINE).join(".");
1150
1158
  for (const g of SHAPE_LISTS) t = t.split(pageEveryOtherLine(g)).join("").split(pageWhereALineNeedsIt(g)).join("");
1151
1159
  return t;
1152
1160
  }
@@ -1200,16 +1208,16 @@ function renderGroupsMd(md, groups, rows = []) {
1200
1208
  md.push(`### ${heading(g)}`, "");
1201
1209
  if (!grp.lines) { md.push("(none)", ""); continue; }
1202
1210
  const crowded = grp.records >= groups.crowded_at;
1203
- const readA = "These are the identical and near-identical marks live in the ordered classes for the client's goods: read every line.";
1211
+ const readA = `These are the identical and near-identical marks live in the ordered classes for the client's goods${READ_EVERY_LINE}`;
1204
1212
  md.push(g === "a"
1205
1213
  ? (crowded
1206
1214
  ? `${count(grp)}. ${readA} ${firstPage}${pageEveryOtherLine(g)}. ${FLOORS_BY_NAME_HERE}`
1207
- : grp.lines <= READ_ALL_LINES ? `${count(grp)}. Fifty? Read them all.` : `${count(grp)}. ${readA}`)
1215
+ : grp.lines <= READ_ALL_LINES ? `${count(grp)}. ${READ_THEM_ALL}` : `${count(grp)}. ${readA}`)
1208
1216
  : crowded
1209
1217
  ? `${count(grp)}. A thousand or more? The field is crowded. These are ${crowd[g]}. ${A_CROWDS_ONE_ROW} ${firstPage}${pageWhereALineNeedsIt(g)}.${
1210
1218
  g === "b" ? ` ${A_CROWDS_ROW_COVERS_A_READ_FLOOR} ${pageTheFloorsLeft(g)}` : ""}`
1211
1219
  : grp.lines <= READ_ALL_LINES
1212
- ? `${count(grp)}. Fifty? Read them all.`
1220
+ ? `${count(grp)}. ${READ_THEM_ALL}`
1213
1221
  : `${count(grp)}. ${SORT_THE_NEAR_ONES}`, "");
1214
1222
  md.push(...head);
1215
1223
  for (const l of crowded ? grp.list.slice(0, groups.first_page) : grp.list) { rows.push([md.length, l.key]); md.push(row(l)); }
@@ -1223,7 +1231,7 @@ function renderGroupsMd(md, groups, rows = []) {
1223
1231
  md.push(`**The rest: ${count(rest)}${outside}** — every record in none of (a) to (d) above. ${!rest.lines ? ONE_SET_ASIDE_ROW
1224
1232
  : restCrowded
1225
1233
  ? `A thousand or more? The field is crowded. ${A_CROWDS_ONE_ROW} ${firstPage}${pageWhereALineNeedsIt("rest")}.`
1226
- : `${rest.lines <= READ_ALL_LINES ? "Fifty? Read them all." : SORT_THE_NEAR_ONES} ${ONE_SET_ASIDE_ROW}`}`);
1234
+ : `${rest.lines <= READ_ALL_LINES ? READ_THEM_ALL : SORT_THE_NEAR_ONES} ${ONE_SET_ASIDE_ROW}`}`);
1227
1235
  if (rest.lines) {
1228
1236
  md.push("", ...head);
1229
1237
  for (const l of restCrowded ? rest.list.slice(0, groups.first_page) : rest.list) { rows.push([md.length, l.key]); md.push(row(l)); }
@@ -1240,7 +1248,7 @@ function renderGroupsMd(md, groups, rows = []) {
1240
1248
  : `**Goods the test could not read: ${count(ng)}.**`;
1241
1249
  md.push("", crowded
1242
1250
  ? `${lead} A thousand or more? ${firstPage}, the floors first, then the live ones in the ordered classes. ${AN_UNREAD_CROWDS_ONE_ROW} ${AN_UNREAD_CROWDS_FLOOR} ${pageTheFloorsLeft("no_goods")}`
1243
- : `${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" ? "" : ` ${AN_UNREAD_LINE_IS_CHECKED}`} ${AN_UNREAD_LISTS_FLOOR}`, "");
1251
+ : `${lead} ${ng.lines <= READ_ALL_LINES ? READ_THEM_ALL : READ_THE_LIVE_ONES_FIRST} ${A_FATE_BY_NAME}${tested.source === "none" ? "" : ` ${AN_UNREAD_LINE_IS_CHECKED}`} ${AN_UNREAD_LISTS_FLOOR}`, "");
1244
1252
  md.push(...head);
1245
1253
  for (const l of crowded ? ng.list.slice(0, groups.first_page) : ng.list) { rows.push([md.length, l.key]); md.push(row(l)); }
1246
1254
  }
@@ -171,7 +171,7 @@ export const E3_BACKLOG = [
171
171
  {
172
172
  stage: "common-law",
173
173
  kind: "dictated-line-shape",
174
- where: "driver/stages.mjs:1103 (emitted at 1891, 1920, 2100, 2320)",
174
+ where: "driver/stages.mjs:1104 (emitted at 1891, 1920, 2100, 2320)",
175
175
  surface: "stage-message",
176
176
  evidence: "CROSS-CHECK HAND-OFF: … record it on its OWN line in EXACTLY the form \"CROSS-CHECK REQUIRED: <what> — <why>\" (that exact prefix; an em-dash between what and why; name the mark in CAPS in <what>). The driver parses ONLY this exact line shape…",
177
177
  reparsedBy: "driver/doubt-ledger.mjs:183 CROSS_CHECK_RE = /^(?:[-*]\\s+)?CROSS-CHECK REQUIRED:\\s*(.+?)\\s+—\\s+(.+?)\\s*$/ → mintCrossCheckDoubts",
@@ -265,7 +265,7 @@ export const E3_BACKLOG = [
265
265
  {
266
266
  stage: "register-unit",
267
267
  kind: "exactly-these-keys",
268
- where: "driver/stages.mjs:2271",
268
+ where: "driver/stages.mjs:2272",
269
269
  surface: "stage-message",
270
270
  evidence: "Every block you append MUST carry \"state\":\"enumerated\" (ONLY if you paged it to has_more:false) or \"state\":\"incomplete\" — EXACTLY those two strings; there is no \"verified\"/\"checked\"/\"complete\"/\"clean\" state, and any other value fails the stage.",
271
271
  reparsedBy: "driver/named-band.mjs parseNamedBand (off-enum state fails validators.registerUnit)",
@@ -283,7 +283,7 @@ export const E3_BACKLOG = [
283
283
  {
284
284
  stage: "register-unit",
285
285
  kind: "exactly-these-keys",
286
- where: "driver/stages.mjs:4277 (the frame-reopen / scoped-retry message builder). A second number stood here and had been stale for some time: it pointed at a contract-element description rather than a builder, at its old line and at every mechanical shift of it. Two candidate builders sit beside 4164 and picking one would be a guess, so the wrong pointer is removed rather than moved a third time — one accurate citation beats one accurate and one invented.",
286
+ where: "driver/stages.mjs:4280 (the frame-reopen / scoped-retry message builder). A second number stood here and had been stale for some time: it pointed at a contract-element description rather than a builder, at its old line and at every mechanical shift of it. Two candidate builders sit beside 4164 and picking one would be a guess, so the wrong pointer is removed rather than moved a third time — one accurate citation beats one accurate and one invented.",
287
287
  surface: "stage-message",
288
288
  evidence: "Every block you append MUST carry \"state\":\"enumerated\" (ONLY if paged to has_more:false) or \"state\":\"incomplete\" — EXACTLY those two strings … (re-dispatch builders, which REPLACE def.message)",
289
289
  reparsedBy: "driver/named-band.mjs parseNamedBand. Scope warning: these builders replace def.message on every escalation / envelope-close / frame-reopen dispatch, so an E3 lint that walks STAGES[*].message only never sees them",
@@ -524,7 +524,7 @@ export const E3_BACKLOG = [
524
524
  kind: "literal-json-skeleton",
525
525
  where: "driver/stages.mjs:3294",
526
526
  surface: "stage-message",
527
- evidence: "use_check = {\"source\",\"quality\"}: … quality: OPTIONAL, EXACTLY one of owner-site / independent / register-mirror … own_rights = {\"source\"}",
527
+ evidence: "use_check = {\"source\",\"quality\",\"question\"}: … quality: OPTIONAL, EXACTLY one of owner-site / independent / register-mirror … own_rights = {\"source\"}",
528
528
  reparsedBy: "driver/verify.mjs checkFindingsSibling (finding_use_check_missing); driver/own-rights.mjs:19-22",
529
529
  removedByMove: "NOTHING ON THE #850 PLAN REMOVES THIS",
530
530
  },
@@ -670,7 +670,7 @@ export const E3_BACKLOG = [
670
670
  {
671
671
  stage: "narrative-refutation",
672
672
  kind: "dictated-line-shape",
673
- where: "driver/stages.mjs:4832",
673
+ where: "driver/stages.mjs:4835",
674
674
  surface: "stage-message",
675
675
  // RE-QUOTED BY CONVERSION 9. The LINE-TOKEN half is gone — no "anywhere on the line", no "[kind: …]
676
676
  // token", no "a line with no token is treated as fact", because a kind is a typed field now and an
@@ -769,7 +769,7 @@ export const E3_BACKLOG = [
769
769
  {
770
770
  stage: "report-card",
771
771
  kind: "literal-json-skeleton",
772
- where: "driver/stages.mjs:3966",
772
+ where: "driver/stages.mjs:3968",
773
773
  surface: "stage-message",
774
774
  evidence: "The finding's OWN record — the ONLY source for this card …:\\n```json\\n<JSON.stringify(finding, null, 2)>\\n```",
775
775
  reparsedBy: "none — this is the INPUT side, and that is why it belongs in the survey: a full JSON object rendered into the prompt is exactly the mechanism #850 proves produced R-RECEIPT (the model pattern-matches a shown shape). E3's clause 1 as written (\"a code fence or inline example showing the exact object shape the model must emit\") does not reach an injected record, so the lint needs an explicit rule for shown-but-not-owed structure",
@@ -373,7 +373,7 @@ export function coverageFormRows({ skeleton = [], activeAxes = null, plan = null
373
373
  return r ? { narrowing: r.narrowing, ...(r.lists?.length ? { lists: r.lists } : {}), ...(r.not_covered?.length ? { not_covered: r.not_covered } : {}) } : {};
374
374
  };
375
375
  const entriesByQid = new Map((plan?.entries ?? []).map((e) => [e.qid, e]));
376
- const open = openBlocksByAxis(skel, bandBlocksByAxis, plan);
376
+ const open = openBlocksByAxis(skel, bandBlocksByAxis, plan, { withDeferred: true });
377
377
  // Axis order is the run's, not a set's iteration order: the plan-execution skeleton first (the order
378
378
  // the plan dictated), then any active axis the skeleton does not name, then the canonical axis list as
379
379
  // the final tie-break. Deterministic ⇒ the same run regenerates a byte-identical form every pass.
@@ -280,9 +280,9 @@ function bandShape(params) {
280
280
  ...(read ? { served_lines: read.keys[part - 1], group_parts: read.groupParts } : {}) });
281
281
  const head = format === "json"
282
282
  ? `[band_shape part ${part}/${total} of the json; concatenate the parts before parsing it.]`
283
- : `[band_shape part ${part}/${total}. Read each group by the size sentence at its head; the next part continues the groups.]`;
283
+ : `[band_shape part ${part}/${total}. ${GROUP_PAGES ? "Read each group by the size sentence at its head; the next part continues the groups." : "The next part continues the groups."}]`;
284
284
  const tail = part < total
285
- ? `\n[end of part ${part}/${total} — call band_shape again with {"part": ${part + 1}} for the next part]`
285
+ ? `\n[end of part ${part}/${total} — part ${part + 1} is available with {"part": ${part + 1}}]`
286
286
  : `\n[end of the shape]`;
287
287
  return head + "\n" + parts[part - 1] + tail;
288
288
  }
@@ -351,7 +351,7 @@ function groupLookup(params) {
351
351
  const body = () => JSON.stringify({
352
352
  group: g, lines_total: lines.length, returned: page.length, ...(offset ? { offset } : {}),
353
353
  ...(lines.length > offset + page.length ? { next_offset: offset + page.length,
354
- note: `call again with offset: ${offset + page.length} for the next lines of group ${g}` } : {}),
354
+ note: `the next lines of group ${g} are available with offset: ${offset + page.length}` } : {}),
355
355
  lines: page.map(({ _key, ...l }) => l),
356
356
  }, null, 2);
357
357
  let out = body();
@@ -440,8 +440,8 @@ function bandLookup(params = {}) {
440
440
  const body = () => JSON.stringify({
441
441
  matched, returned: hits.length, ...(offset ? { offset } : {}),
442
442
  ...(matched > offset + hits.length ? { next_offset: offset + hits.length } : {}),
443
- ...(matched > offset + hits.length ? { note: `capped at ${hits.length} — call again with offset: ${offset + hits.length} for the next page; the count above is the full match` } : {}),
444
- ...(byteCapped ? { byte_cap_note: `${byteCapped} matched record(s) withheld to fit one tool response — call again with offset: ${offset + hits.length} for the rest` } : {}),
443
+ ...(matched > offset + hits.length ? { note: `capped at ${hits.length} — the next page is available with offset: ${offset + hits.length}; the count above is the full match` } : {}),
444
+ ...(byteCapped ? { byte_cap_note: `${byteCapped} matched record(s) withheld to fit one tool response — the rest are available with offset: ${offset + hits.length}` } : {}),
445
445
  records: hits,
446
446
  ...(crowds.length ? { matching_crowds: crowds, crowd_note: "these matching slices were NOT enumerated — records under them may exist that no lookup can return; weigh the descriptor, never treat this result as their clean"
447
447
  + (refusedCrowds ? ` — and ${refusedCrowds} of them was/were NEVER ANSWERED at all (see "refusal"): no count exists for those, so they are unsearched rather than empty, and nothing about them can be read off a zero` : "")
@@ -515,7 +515,7 @@ function bandRecord(params = {}) {
515
515
  logRead("band_record", { record_id: uri, part, parts: total }, { ok: true, bytes: parts[part - 1].length, total_bytes: text.length, ...resolution, ...served.audit });
516
516
  const head = `[band_record part ${part}/${total}. This record is served in ${total} parts; the next part continues it.]`;
517
517
  const tail = part < total
518
- ? `\n[end of part ${part}/${total} — call band_record again with {"record_id": "${uri}", "part": ${part + 1}} for the next part]`
518
+ ? `\n[end of part ${part}/${total} — part ${part + 1} is available with {"record_id": "${uri}", "part": ${part + 1}}]`
519
519
  : `\n[end of the record]`;
520
520
  return head + "\n" + parts[part - 1] + tail;
521
521
  }
@@ -537,7 +537,7 @@ function bandRecord(params = {}) {
537
537
  const part = Number.isInteger(wanted) && wanted >= 1 && wanted <= parts.length ? wanted : 1;
538
538
  logRead("band_record", { record_id: uri }, { ok: true, bytes: text.length, via: "ledger", part, parts: parts.length, ...served.audit });
539
539
  return { type: "text", text: `[record ${uri} — part ${part}/${parts.length}]\n` + parts[part - 1]
540
- + (part < parts.length ? `\n[end of part ${part}/${parts.length} — call band_record again with {"record_id": "${uri}", "part": ${part + 1}}]` : "") };
540
+ + (part < parts.length ? `\n[end of part ${part}/${parts.length} — part ${part + 1} is available with {"record_id": "${uri}", "part": ${part + 1}}]` : "") };
541
541
  }
542
542
 
543
543
  // A GENUINE miss — neither materialised NOR in this run's ledger. Now the sentence below is true.
@@ -581,7 +581,7 @@ function bandRecord(params = {}) {
581
581
  ...(parts.length > 1 ? { part, parts: parts.length } : {}), ...(failed ? { fetch_failed: true } : {}) });
582
582
  if (parts.length === 1) return { isError: true, text };
583
583
  return { isError: true, text: `[record ${uri} — part ${part}/${parts.length}]\n` + parts[part - 1]
584
- + (part < parts.length ? `\n[end of part ${part}/${parts.length} — call band_record again with {"record_id": "${uri}", "part": ${part + 1}}]` : "") };
584
+ + (part < parts.length ? `\n[end of part ${part}/${parts.length} — part ${part + 1} is available with {"record_id": "${uri}", "part": ${part + 1}}]` : "") };
585
585
  }
586
586
 
587
587
  // The band row a record id names, from the band file, read once per server. null where the band holds none.
@@ -610,10 +610,10 @@ const GROUP_PAGES = process.env.CLEAROTRON_BAND_GROUPS === "1";
610
610
  // served the shape without them (the owner's ruling, 2026-10-14). Each is removed with the space before it, inside
611
611
  // its line, so no line moves and the line each listed mark is printed on, which `linesByPart` reads, still holds.
612
612
  // (band-shape.mjs `withoutPlacementOrders` removes them, with the shape's other placement-only orders and its group-page commands.)
613
- 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; `goods_from` says what those goods rest on: official record; official record, cut by the register; official record, completeness not established; official record, no goods listed; search answer; search answer, cut by the register; search answer, completeness not established; fetch failed; or nothing held. 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.";
613
+ 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 offset where the next page starts). 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; `goods_from` says what those goods rest on: official record; official record, cut by the register; official record, completeness not established; official record, no goods listed; search answer; search answer, cut by the register; search answer, completeness not established; fetch failed; or nothing held. 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.";
614
614
  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), ";
615
- 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)";
616
- 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)";
615
+ const LOOKUP_OFFSET_GROUPS = "offset (default 0: where in the matching records or lines to start — a capped answer names the offset where the next page starts)";
616
+ const LOOKUP_OFFSET_BAND = "offset (default 0: where in the matching records to start — a capped answer names the offset where the next page starts)";
617
617
  /** The lookup's description as a stage is offered it: whole where it reads the groups, without them elsewhere. PURE. */
618
618
  const lookupDescription = (groupPages) => (groupPages ? LOOKUP_DESCRIPTION
619
619
  : LOOKUP_DESCRIPTION.replace(LOOKUP_GROUP_CLAUSE, "").replace(LOOKUP_OFFSET_GROUPS, LOOKUP_OFFSET_BAND));
@@ -623,7 +623,7 @@ serve({
623
623
  tools: [
624
624
  {
625
625
  name: "band_shape",
626
- 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.",
626
+ 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. What it names can be looked up with band_lookup and band_record. A large shape is served in parts, and the end of each part names the next one. Every call is logged to the run's reading audit.",
627
627
  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)" } } },
628
628
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
629
629
  handler: bandShape,
@@ -275,10 +275,15 @@ export function featuresToml(withheld = []) {
275
275
  // codex's `workspace-write` sandbox confines where a command WRITES and lets it read the whole disk, the
276
276
  // install's settings files and the program's own sign-in included. A permission profile confines reading
277
277
  // too (the vendor's Permissions guide, a beta feature since 0.138.0; the floor was 0.154.0 when this was written and is 0.159.0 now, for the judgment tier's model — driver.config.mjs): a stage's
278
- // commands read the platform's own paths, the instruction trees and the temp folders, and write the run
279
- // folder, their working folder and the temp folders. Nothing else is readable, so a page a stage fetched
280
- // cannot get a command to print the account's files. The temp folders stay writable because
281
- // `workspace-write` let commands use them and stages write their output through the shell.
278
+ // commands read the platform's own paths and the instruction trees, and write the run folder, their
279
+ // working folder and their Codex home's working folder. Nothing else is readable, so a page a stage
280
+ // fetched cannot get a command to print the account's files.
281
+ //
282
+ // NO SHARED TEMP FOLDER. The profile granted `:tmpdir` and `:slash_tmp`, which every stage of every run on
283
+ // the machine shares, and a later run's commands read what an earlier run left in `/tmp` (measured on codex
284
+ // 0.162.0 with that profile: a command read a file planted in `/tmp` and wrote another beside it). The
285
+ // turn's TMPDIR, TMP and TEMP now name its home's working folder (openai-agent.mjs, `withWorkFolder`), and
286
+ // this grants that folder, named, in their place.
282
287
  //
283
288
  // ONLY WITH THE SANDBOX ON. Under the bypass codex builds no sandbox, so a profile would enforce nothing;
284
289
  // the caller passes no `fence` then, and the file stays what it was. With the sandbox on the command line
@@ -297,10 +302,10 @@ export function featuresToml(withheld = []) {
297
302
  // wins over the run folder's: measured on codex 0.162.0 with this profile, a write into the run folder
298
303
  // succeeded, a read of `_driver/` succeeded, and a write or an append there was refused.
299
304
  export const FENCE_PROFILE = "clearotron-stage";
300
- export function fenceToml({ runDir = null, readRoots = [] } = {}) {
305
+ export function fenceToml({ runDir = null, workDir = null, readRoots = [] } = {}) {
301
306
  const key = (p) => tomlString(p);
302
- const out = [`[permissions.${FENCE_PROFILE}.filesystem]`,
303
- `":minimal" = "read"`, `":tmpdir" = "write"`, `":slash_tmp" = "write"`];
307
+ const out = [`[permissions.${FENCE_PROFILE}.filesystem]`, `":minimal" = "read"`];
308
+ if (workDir) out.push(`${key(workDir)} = "write"`);
304
309
  for (const r of [...new Set(readRoots.filter(Boolean))]) out.push(`${key(r)} = "read"`);
305
310
  if (runDir) out.push(`${key(runDir)} = "write"`, `${key(driverDir(runDir))} = "read"`);
306
311
  out.push("", `[permissions.${FENCE_PROFILE}.filesystem.":workspace_roots"]`, `"." = "write"`, "");
@@ -60,6 +60,7 @@ import { MAX_ROWS_PER_CALL } from "../../disposition-call.mjs";
60
60
  // The lane's second statement, and a different one: which coverage units were searched to what end. Its
61
61
  // own module and its own file, never a row in the disposition form — see record_coverage_status below.
62
62
  import { recordCoverageStatus, COMMON_LAW_COVERAGE_STATUSES } from "../../common-law-coverage-status.mjs";
63
+ import { insideRun } from "../../../shared/inside-run.mjs"; // a path a tool is handed is served only inside this run
63
64
 
64
65
  // ── B — THE TYPED DISPOSITION TRANSPORT ─────────────────────────────────────────────────────────────
65
66
  //
@@ -76,11 +77,17 @@ function specFrom(params) {
76
77
  const { grid_spec_path } = params ?? {};
77
78
  if (!grid_spec_path)
78
79
  return { error: "ERROR: grid_spec_path is required — it is the same driver-written spec path the grid tool was given. Do not compose a path." };
80
+ // THIS RUN'S SPEC AND LEDGER ONLY (shared/inside-run.mjs): the run folder is the one the driver wired, never the call's.
81
+ const runDir = process.env.CLEAROTRON_BAND_RUN_DIR || "";
82
+ if (runDir && !insideRun(runDir, grid_spec_path))
83
+ return { error: "ERROR: grid_spec_path is outside this run's folder. The driver writes the spec inside the run; use the path it gave you." };
79
84
  let spec;
80
85
  try { spec = validateGridSpec(JSON.parse(readFileSync(grid_spec_path, "utf8"))); }
81
86
  catch (err) { return { error: `ERROR: grid_spec_path unreadable/invalid (${err.message}). The driver writes this file; do not hand-author it.` }; }
82
87
  if (!underStudioSegment(spec.output_path)) // either spelling: an install keeps the studio segment it has
83
88
  return { error: `ERROR: grid spec.output_path must be within a studio/clearance-search run dir; got ${spec.output_path}` };
89
+ if (runDir && !insideRun(runDir, spec.output_path))
90
+ return { error: "ERROR: the grid spec's output_path is outside this run's folder. The driver writes the spec inside the run; use the path it gave you." };
84
91
  return { spec };
85
92
  }
86
93
 
@@ -29,7 +29,8 @@ import { parsePrRiskResults, connotationObligations, renderConnotationObligation
29
29
  // What stays is the GRID path's own need — the obligations sidecar written beside the grid's ledger.
30
30
  import { obligationsSidecarPath } from "../../disposition-tool.mjs";
31
31
  import { writeGridProvenance } from "./grid-provenance.mjs"; // — what served the grid, beside the ledger
32
- import { keepResearchAnswer } from "../../research-answers.mjs"; // each answer's text, kept for a later attempt of its stage
32
+ import { insideRun } from "../../../shared/inside-run.mjs"; // a path a tool is handed is served only inside this run
33
+ import { keepResearchAnswer, isResearchAnswer, replayAnswersFrom, researchReplayOn, savedResearchAnswer } from "../../research-answers.mjs"; // each answer's text, kept for a later attempt of its stage; a replay answers from them
33
34
 
34
35
  const API_KEY = process.env.PERPLEXITY_API_KEY || "";
35
36
 
@@ -219,14 +220,28 @@ function judgeBlock(cands, part, gridSpecPath, reserve = 0) {
219
220
  }
220
221
 
221
222
  async function research(params) {
222
- if (!API_KEY) return "ERROR: PERPLEXITY_API_KEY not set — marketplace research unavailable.";
223
+ // A REPLAY SENDS NOTHING OUT, so it is answered before the key is asked for: an offline replay needs none.
224
+ if (researchReplayOn(RUN_DIR)) return replayResearch(params);
225
+ // A REFUSED QUESTION IS LOGGED as a failed one is. It left no line, so the run's record could not tell a question
226
+ // refused for want of a credential from one never asked, and a writer whose every question was refused wrote
227
+ // "no result" with nothing in the log to say otherwise. The reason names the refusal, never the key.
228
+ if (!API_KEY) {
229
+ logCall("perplexity_research", { task: params?.task ?? "", ...(params?.grid_spec_path ? { grid: true } : {}) },
230
+ { ok: false, error: "refused: no research credential" });
231
+ return "ERROR: PERPLEXITY_API_KEY not set — marketplace research unavailable.";
232
+ }
223
233
  const { task, depth, model: modelOverride, allow_fetch = false, domain_filter, enable_sandbox = false, response_schema, schema_name, grid_spec_path, part } = params;
224
234
 
225
235
  // ── Deterministic grid mode: the caller dictates the cells; the tool runs + captures them. ──
226
236
  if (grid_spec_path) {
237
+ // THIS RUN'S SPEC, WRITING THIS RUN'S LEDGER: a spec, or a ledger it names, in another run's folder is never served.
238
+ if (RUN_DIR && !insideRun(RUN_DIR, grid_spec_path))
239
+ return "ERROR: grid_spec_path is outside this run's folder. The driver writes the spec inside the run; use the path it gave you.";
227
240
  let spec;
228
241
  try { spec = validateGridSpec(JSON.parse(readFileSync(grid_spec_path, "utf8"))); }
229
242
  catch (err) { return `ERROR: grid_spec_path unreadable/invalid (${err.message}). The driver writes this file; do not hand-author it.`; }
243
+ if (RUN_DIR && !insideRun(RUN_DIR, spec.output_path))
244
+ return requiredLedgerRefusal("ERROR: the grid spec's output_path is outside this run's folder. The driver writes the spec inside the run; use the path it gave you.", { spec, gridSpecPath: grid_spec_path });
230
245
  if (!underStudioSegment(spec.output_path)) // either spelling: an install keeps the studio segment it has
231
246
  return requiredLedgerRefusal(`ERROR: grid spec.output_path must be within a studio/clearance-search run dir; got ${spec.output_path}`, { spec, gridSpecPath: grid_spec_path });
232
247
  // — already recorded and complete? Answer from the ledger; do not buy the grid twice. This is also
@@ -297,7 +312,11 @@ async function research(params) {
297
312
  const data = await callAgentAPI(API_KEY, body);
298
313
  keepRaw("question", { task, preset }, data);
299
314
  const text = enable_sandbox ? formatSandboxResponse(data, preset) : formatResponse(data, preset);
300
- logCall("perplexity_research", asked, { ok: true, bytes: String(text ?? "").length, citations: citationCount(data) });
315
+ // A REPLY THE FORMATTER COULD NOT READ IS NO ANSWER, and is logged as a failure: it was logged as answered, beside
316
+ // completed searches, though it is not kept and is asked again (research-answers.mjs).
317
+ logCall("perplexity_research", asked, isResearchAnswer(text)
318
+ ? { ok: true, bytes: String(text ?? "").length, citations: citationCount(data) }
319
+ : { ok: false, error: String(text ?? "").slice(0, 200) });
301
320
  keepResearchAnswer(RUN_DIR, { session: SESSION, agent: AGENT, task, preset, text });
302
321
  return text;
303
322
  } catch (err) {
@@ -309,6 +328,28 @@ async function research(params) {
309
328
  }
310
329
  }
311
330
 
331
+ // ── A REPLAY: THE RUN'S OWN ANSWER, OR NOTHING ─────────────────────────────────────────────────────────
332
+ //
333
+ // In the experiment sandbox under `--research replay` (research-answers.mjs), a question the run already asked
334
+ // gets the answer the run got, and any other question, a grid among them, is refused: nothing goes out to the
335
+ // research service. Both are logged as a lookup is, marked as a replay, so the arm's reading log says which. The answers
336
+ // are read from the copy the marker names, outside the arm's folder, where the stage's own commands cannot reach them.
337
+ function replayResearch(params) {
338
+ const { task, grid_spec_path } = params ?? {};
339
+ if (grid_spec_path) {
340
+ logCall("perplexity_research", { task: task ?? "", grid: true, replay: true }, { ok: false, error: "replay: grid not replayed" });
341
+ return "ERROR: replay — this replays the run's saved research answers and runs no grid; nothing was sent to the research service.";
342
+ }
343
+ const from = replayAnswersFrom(RUN_DIR);
344
+ const saved = savedResearchAnswer(from, task);
345
+ if (!saved) {
346
+ logCall("perplexity_research", { task: task ?? "", replay: true }, { ok: false, error: from ? "replay: no saved answer" : "replay: the marker names no answers file" });
347
+ return "ERROR: replay — the run asked no question worded exactly like this one, so there is no saved answer to give; nothing was sent to the research service.";
348
+ }
349
+ logCall("perplexity_research", { task, replay: true }, { ok: true, bytes: saved.text.length, replayed: true });
350
+ return saved.text;
351
+ }
352
+
312
353
  serve({
313
354
  name: "perplexity", version: "0.1.0",
314
355
  tools: [{
@@ -1046,7 +1046,10 @@ serve({
1046
1046
  "are. The driver renders narrative.md and findings.json. Your coverage rows are joined to this " +
1047
1047
  "run's plan-execution receipt as you send them, so a clean claim over a slice that did not run is " +
1048
1048
  "refused here — in this turn, where restating it is free — rather than at a gate that can only " +
1049
- "re-ask the whole stage. The answer tells you what was stored, or names the exact defect token.",
1049
+ "re-ask the whole stage. The answer tells you what was stored, or names the exact defect token. " +
1050
+ "Save each finding as you complete it, alone or a few at a time, with `save_findings`: each is checked as " +
1051
+ "the record checks a finding and kept the moment it passes, so a stopped attempt loses none of them. The " +
1052
+ "call that sends the whole record takes every saved finding for a position it does not carry itself.",
1050
1053
  inputSchema: {
1051
1054
  type: "object",
1052
1055
  // ── NO BLANKET `required`, AND THAT IS THE FIX FOR ─────────────────────────
@@ -1079,6 +1082,18 @@ serve({
1079
1082
  // reads the run's own accepted-call record rather than trusting the shape. That check is older
1080
1083
  // than this change and is the reason relaxing `required` costs nothing.
1081
1084
  properties: {
1085
+ // A COMPLETED FINDING, KEPT BEFORE THE WHOLE RECORD EXISTS (synthesis-record.mjs, `saveFindings`). Its items
1086
+ // are findings as the record carries them, declared from the same key ladder as a patch row.
1087
+ save_findings: {
1088
+ type: "array",
1089
+ description:
1090
+ "COMPLETED FINDINGS, saved before the whole record exists. Send one or a few complete finding objects as the findings record carries them, and nothing else on the call. Each is checked as the record checks a finding and kept as soon as it passes; one that is refused leaves the others saved. A finding saved again for the same position replaces the earlier one. Saving writes no report: the record is complete only when you send `narrative` with `findings`, and that call takes every saved finding for a position it does not carry.",
1091
+ items: {
1092
+ type: "object",
1093
+ description: "One complete finding object, as the findings record carries it.",
1094
+ properties: Object.fromEntries(FINDING_KEYS_CURRENT.map((k) => [k, { description: `Part of the finding record; see findings-model.mjs for what \`${k}\` carries.` }])),
1095
+ },
1096
+ },
1082
1097
  findings_patch: {
1083
1098
  type: "array",
1084
1099
  description:
@@ -34,7 +34,8 @@ import { dirname, join } from "node:path";
34
34
  import { driverDir } from "../../../shared/driver-dir.mjs"; //
35
35
  import { PLAN_PREDICATES, PLAN_MAX_OR_WIDTH, PLAN_MAX_NAME_LENGTH, planExactOrWidth, planPhoneticOrWidth, fingerprint, ownerIntersectionGap, goodsTextGap, resolveRegions, houseElementOf, withoutHouseElementTerms, containsFormSubstitution } from "../../register-plan.mjs";
36
36
  import { entryTermIssues } from "../../../providers/_shared/term-shape.mjs";
37
- import { OUTSTANDING_REASON } from "../../../providers/_shared/follow-up-searches.mjs";
37
+ import { OUTSTANDING_REASON, runDriverDirOf } from "../../../providers/_shared/follow-up-searches.mjs";
38
+ import { pathInRun } from "../../../shared/inside-run.mjs"; // the band path is served only inside this run
38
39
  import { heldAnswers, questionKey } from "./held-answers.mjs";
39
40
  import { bandWithSlicesOut, slicesTakenOutByAxis } from "../../../shared/slices-taken-out.mjs";
40
41
  import { readWithheldFamilies } from "../../withheld-families.mjs";
@@ -391,6 +392,10 @@ export async function proposeSupplemental(params, tctx, deps) {
391
392
  const proposals = Array.isArray(params?.proposals) ? params.proposals : null;
392
393
  if (!axis || !outPath || !proposals || !proposals.length)
393
394
  return { type: "text", text: "ERROR: axis, output_path and a non-empty proposals[] are all required." };
395
+ // The supplemental plan is written beside the band, so the band path must be this run's before anything is written.
396
+ const runOf = runDriverDirOf(tctx?.recordLog);
397
+ if (runOf && pathInRun(dirname(runOf), outPath) === null)
398
+ return { type: "text", text: "ERROR: output_path is outside this run's folder. Use the band path the driver gave you." };
394
399
 
395
400
  const suppPath = join(dirname(outPath), `${axis}-supplemental-plan.json`);
396
401
  // ── the supplemental plan INHERITS the frozen plan's regions (review findings 4/12) ───────────────