clearotron 0.5.0-beta.3 → 0.5.0-beta.4

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.
package/build-info.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "9a5ba90756f37ddefaee2a21ab79e9f7a374667a",
3
- "version": "0.5.0-beta.3"
2
+ "commit": "7c6882de2e3c147992a6667bbce24de9fffb5206",
3
+ "version": "0.5.0-beta.4"
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`).
@@ -1,5 +1,15 @@
1
1
  # clearotron-driver
2
2
 
3
+ ## 0.5.0-beta.4
4
+
5
+ ### Patch Changes
6
+
7
+ - Fixed: On the Codex engine, a clearance no longer sees temporary files that another clearance left on the same machine.
8
+ - 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.
9
+ - 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.
10
+ - Fixed: When the report's writing step is interrupted, the next attempt continues from the findings it had already finished instead of starting again.
11
+ - Fixed: On a crowded field, the report is written from the work the clearance has already done, without reading every register result again.
12
+
3
13
  ## 0.5.0-beta.3
4
14
 
5
15
  ### 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
  }
@@ -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, 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,21 @@ function judgeBlock(cands, part, gridSpecPath, reserve = 0) {
219
220
  }
220
221
 
221
222
  async function research(params) {
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);
222
225
  if (!API_KEY) return "ERROR: PERPLEXITY_API_KEY not set — marketplace research unavailable.";
223
226
  const { task, depth, model: modelOverride, allow_fetch = false, domain_filter, enable_sandbox = false, response_schema, schema_name, grid_spec_path, part } = params;
224
227
 
225
228
  // ── Deterministic grid mode: the caller dictates the cells; the tool runs + captures them. ──
226
229
  if (grid_spec_path) {
230
+ // THIS RUN'S SPEC, WRITING THIS RUN'S LEDGER: a spec, or a ledger it names, in another run's folder is never served.
231
+ if (RUN_DIR && !insideRun(RUN_DIR, grid_spec_path))
232
+ 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
233
  let spec;
228
234
  try { spec = validateGridSpec(JSON.parse(readFileSync(grid_spec_path, "utf8"))); }
229
235
  catch (err) { return `ERROR: grid_spec_path unreadable/invalid (${err.message}). The driver writes this file; do not hand-author it.`; }
236
+ if (RUN_DIR && !insideRun(RUN_DIR, spec.output_path))
237
+ 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
238
  if (!underStudioSegment(spec.output_path)) // either spelling: an install keeps the studio segment it has
231
239
  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
240
  // — already recorded and complete? Answer from the ledger; do not buy the grid twice. This is also
@@ -309,6 +317,26 @@ async function research(params) {
309
317
  }
310
318
  }
311
319
 
320
+ // ── A REPLAY: THE RUN'S OWN ANSWER, OR NOTHING ─────────────────────────────────────────────────────────
321
+ //
322
+ // In the experiment sandbox under `--research replay` (research-answers.mjs), a question the run already asked
323
+ // gets the answer the run got, and any other question, a grid among them, is refused: nothing goes out to the
324
+ // research service. Both are logged as a lookup is, marked as a replay, so the arm's reading log says which.
325
+ function replayResearch(params) {
326
+ const { task, grid_spec_path } = params ?? {};
327
+ if (grid_spec_path) {
328
+ logCall("perplexity_research", { task: task ?? "", grid: true, replay: true }, { ok: false, error: "replay: grid not replayed" });
329
+ return "ERROR: replay — this replays the run's saved research answers and runs no grid; nothing was sent to the research service.";
330
+ }
331
+ const saved = savedResearchAnswer(RUN_DIR, task);
332
+ if (!saved) {
333
+ logCall("perplexity_research", { task: task ?? "", replay: true }, { ok: false, error: "replay: no saved answer" });
334
+ 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.";
335
+ }
336
+ logCall("perplexity_research", { task, replay: true }, { ok: true, bytes: saved.text.length, replayed: true });
337
+ return saved.text;
338
+ }
339
+
312
340
  serve({
313
341
  name: "perplexity", version: "0.1.0",
314
342
  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) ───────────────
@@ -317,8 +317,10 @@ export function sweepStaleCodexHomes(root, { now = Date.now(), alive = processAl
317
317
  for (const e of entries) {
318
318
  if (!e.isDirectory() || !/^(?:stage|turn)-/.test(e.name)) continue;
319
319
  const dir = join(root, e.name);
320
- if (OWNED.has(dir)) continue;
321
- const maker = e.name.match(MAKER_RE);
320
+ // A home's working folder is judged by its home's name, and goes when its home's maker has ended.
321
+ const home = e.name.endsWith(WORK_SUFFIX) ? e.name.slice(0, -WORK_SUFFIX.length) : e.name;
322
+ if (OWNED.has(join(root, home))) continue;
323
+ const maker = home.match(MAKER_RE);
322
324
  let stale;
323
325
  if (maker) stale = Number(maker[1]) !== process.pid && !alive(Number(maker[1]));
324
326
  else { try { stale = now - statSync(dir).mtimeMs > STALE_UNMARKED_MS; } catch { stale = false; } }
@@ -329,7 +331,7 @@ export function sweepStaleCodexHomes(root, { now = Date.now(), alive = processAl
329
331
  }
330
332
 
331
333
  const removeOwned = () => {
332
- for (const dir of OWNED) { try { rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ } }
334
+ for (const dir of OWNED) for (const d of [dir, workFolderOf(dir)]) { try { rmSync(d, { recursive: true, force: true }); } catch { /* best-effort */ } }
333
335
  OWNED.clear();
334
336
  };
335
337
 
@@ -363,13 +365,46 @@ export function makeCodexHome(prefix) {
363
365
  return dir;
364
366
  }
365
367
 
366
- /** Remove a home this process made. Best-effort: never throws. */
368
+ /** Remove a home this process made, and its working folder. Best-effort: never throws. */
367
369
  export function releaseCodexHome(dir) {
368
370
  if (!dir) return;
369
371
  OWNED.delete(dir);
370
- try { rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ }
372
+ for (const d of [dir, workFolderOf(dir)]) { try { rmSync(d, { recursive: true, force: true }); } catch { /* best-effort */ } }
371
373
  }
372
374
 
375
+ // ── A STAGE'S TEMPORARY FILES ARE ITS OWN ─────────────────────────────────────────────────────────────
376
+ //
377
+ // The stage's profile granted the account's temp folder and `/tmp`, both shared by every stage of every run on
378
+ // the machine, and nothing removed what a stage left there. In a test run of a crowded matter, 2026-10-27, the
379
+ // writer's commands found and read the working notes, a state file with ratings, and a families file that an
380
+ // earlier run's writer had left in `/tmp` the day before. A run must read nothing from another run.
381
+ //
382
+ // So each home has one working folder of its own, beside it and never inside it (the home holds the sign-in,
383
+ // which the profile never names). The turn's TMPDIR, TMP and TEMP name it, and the profile grants it in place
384
+ // of the shared folders. It lives as long as its home: a stage's ladder shares one home across its attempts,
385
+ // its warm resumes and its repair turns, so they keep the notes they made, and the folder goes when the ladder
386
+ // settles. A home whose maker has ended takes its working folder with it in the sweep.
387
+ const WORK_SUFFIX = ".work";
388
+ /** The working folder of a Codex home. PURE. */
389
+ export const workFolderOf = (home) => `${home}${WORK_SUFFIX}`;
390
+ /** Make a home's working folder if it is not there yet, private to the account. Returns its path. */
391
+ export function ensureWorkFolder(home) {
392
+ const dir = workFolderOf(home);
393
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
394
+ return dir;
395
+ }
396
+ /** The turn's environment with every temp-folder name pointing at the home's working folder. PURE. */
397
+ export function withWorkFolder(env, dir) {
398
+ return { ...env, TMPDIR: dir, TMP: dir, TEMP: dir };
399
+ }
400
+
401
+ // WHAT THE MODEL IS TOLD, as the design ruling of 2026-10-27 approved it (its section on isolation). It describes the
402
+ // mechanism and replaces nothing: the profile is what keeps another run's files out of reach. Codex alone carries it,
403
+ // since only a Codex stage runs commands and only a Codex turn has a working folder of its own.
404
+ export const TEMP_FOLDER_RULE = "Keep temporary working files in the directory given by TMPDIR. Use only this run's supplied evidence and accepted work.";
405
+ /** The instructions every Codex turn carries: the output rule, then the temp-folder rule. */
406
+ export const CODEX_DEVELOPER_INSTRUCTIONS = `${WRITE_DISCIPLINE} ${TEMP_FOLDER_RULE}`;
407
+
373
408
  /**
374
409
  * The folder holding codex's own programs, which the stage's profile must let it read. codex runs every
375
410
  * shell command through a helper of its own inside the sandbox, so a profile that cannot read the helper
@@ -884,7 +919,10 @@ export const openaiAgentEngine = {
884
919
  let authMaster = null, seeded = null;
885
920
  try {
886
921
  // Auth toggle (may throw loud on api-key-without-key — a config error, not a retryable failure).
887
- const { env, mode } = spawnEnv(process.env, codexHome);
922
+ const { env: baseEnv, mode } = spawnEnv(process.env, codexHome);
923
+ // The home's own working folder, as the turn's temp folder, with the sandbox on or off.
924
+ const workDir = ensureWorkFolder(codexHome);
925
+ const env = withWorkFolder(baseEnv, workDir);
888
926
  if (mode === "subscription") {
889
927
  const af = authFilePath(process.env);
890
928
  if (!existsSync(af))
@@ -906,16 +944,17 @@ export const openaiAgentEngine = {
906
944
  // backstop if a tool consumes the lot.
907
945
  //
908
946
  // — the fence: with the sandbox on, the stage's commands read only the instruction trees, the run
909
- // folder and the temp folders (codex-config.mjs, `fenceToml`). The same roots claude's file tools get.
947
+ // folder and the home's working folder (codex-config.mjs, `fenceToml`). The same roots claude's file
948
+ // tools get, and a working folder claude needs none of, since no claude stage runs a command.
910
949
  //
911
950
  // — what those commands inherit: none of the register and research keys the program holds for its
912
951
  // tool servers, and not the Codex key (engine-env.mjs, `codexCommandWithheld`), with codex's shell
913
952
  // snapshot off because it replays them (codex-config.mjs, `commandEnvToml`). With the sandbox on or
914
953
  // off, since the bypass builds no fence and a command could otherwise print them.
915
954
  const fence = codexSandboxBypassed() ? null
916
- : { runDir, readRoots: [...(skillsGrantRoots?.length ? skillsGrantRoots : [skillsDir]), codexProgramRoot(codexBin())].filter(Boolean) };
955
+ : { runDir, workDir, readRoots: [...(skillsGrantRoots?.length ? skillsGrantRoots : [skillsDir]), codexProgramRoot(codexBin())].filter(Boolean) };
917
956
  writeFileSync(join(codexHome, "config.toml"),
918
- renderCodexConfigToml({ mcpConfig, allowedTools, developerInstructions: WRITE_DISCIPLINE, toolTimeoutSec: timeoutSec, fence,
957
+ renderCodexConfigToml({ mcpConfig, allowedTools, developerInstructions: CODEX_DEVELOPER_INSTRUCTIONS, toolTimeoutSec: timeoutSec, fence,
919
958
  withheldFromCommands: codexCommandWithheld() }));
920
959
 
921
960
  const input = absolutizeSkillRefs(message, skillsDir, resolveSkill);
@@ -2670,3 +2670,20 @@ export function topKeysForVersion(schemaVersion) {
2670
2670
  const v = typeof schemaVersion === "number" ? schemaVersion : 1;
2671
2671
  return [...topKeysFor({ v4: v >= 4, v5: v >= 5, v6: v >= 6, v7: v >= 7 })];
2672
2672
  }
2673
+
2674
+ /**
2675
+ * ONE FINDING, CHECKED AS THE WHOLE RECORD CHECKS IT, before the record exists. The writer saves a completed finding as
2676
+ * it completes it, and a saved finding must pass exactly what it would pass inside the record, under the schema the
2677
+ * record is written at and the run's frozen framework and method. The record-wide rules (an empty record, ordinals
2678
+ * unique across the record, the actions and the coverage) belong to the record and are checked when it is complete.
2679
+ * Returns the finding as validated; throws the record's own token-first refusal. PURE.
2680
+ */
2681
+ export function validateFindingForSave(f, opts = {}) {
2682
+ const schemaVersion = FINDINGS_SCHEMA_VERSION;
2683
+ const mode = { v4: schemaVersion >= 4, v5: schemaVersion >= 5, v6: schemaVersion >= 6, v7: schemaVersion >= 7, manifest: opts.manifest ?? null,
2684
+ method: "method" in opts ? (opts.method ?? null) : undefined,
2685
+ recordOrigins: Array.isArray(opts.recordOrigins) ? opts.recordOrigins : null, foreignLinks: Array.isArray(opts.recordOrigins) ? [] : null };
2686
+ const out = validateFinding(f, 0, new Set(), mode);
2687
+ throwForeignLinks(mode);
2688
+ return out;
2689
+ }
@@ -2,7 +2,7 @@
2
2
  "name": "clearotron-driver",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.5.0-beta.3",
5
+ "version": "0.5.0-beta.4",
6
6
  "license": "AGPL-3.0-only",
7
7
  "description": "Deterministic driver for the trademark clearance workflow: orchestration in code (fan-out, fan-in barrier, gating, retries); the model does judgment leaves only, through a reasoning CLI spawned per stage.",
8
8
  "engines": {