clearotron 0.5.0-beta.2 → 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.
Files changed (73) hide show
  1. package/.env.example +7 -0
  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 -0
  5. package/docs/architecture/05-config-governance.md +1 -1
  6. package/driver/CHANGELOG.md +21 -0
  7. package/driver/band-shape.mjs +19 -11
  8. package/driver/contract-vocabulary.mjs +3 -3
  9. package/driver/coverage-form-io.mjs +3 -1
  10. package/driver/coverage-form.mjs +21 -2
  11. package/driver/coverage-ledger.mjs +7 -1
  12. package/driver/declination-call.mjs +33 -16
  13. package/driver/degraded-parts.mjs +6 -0
  14. package/driver/engine/mcp/band-server.mjs +12 -12
  15. package/driver/engine/mcp/clarivate-server.mjs +2 -0
  16. package/driver/engine/mcp/codex-config.mjs +12 -7
  17. package/driver/engine/mcp/corsearch-server.mjs +2 -0
  18. package/driver/engine/mcp/declination-server.mjs +4 -5
  19. package/driver/engine/mcp/dispositions-server.mjs +7 -0
  20. package/driver/engine/mcp/euipo-server.mjs +2 -0
  21. package/driver/engine/mcp/free-tier-server.mjs +2 -0
  22. package/driver/engine/mcp/gather-config.mjs +15 -0
  23. package/driver/engine/mcp/held-answers.mjs +11 -3
  24. package/driver/engine/mcp/perplexity-server.mjs +29 -1
  25. package/driver/engine/mcp/recording-server.mjs +16 -1
  26. package/driver/engine/mcp/signa-server.mjs +2 -0
  27. package/driver/engine/mcp/supplemental.mjs +11 -3
  28. package/driver/engine/mcp/uspto-local-server.mjs +2 -0
  29. package/driver/engine/openai-agent.mjs +48 -9
  30. package/driver/envelope-settle.mjs +13 -3
  31. package/driver/findings-model.mjs +17 -0
  32. package/driver/gateway.mjs +31 -10
  33. package/driver/package.json +1 -1
  34. package/driver/parts-not-completed.mjs +52 -11
  35. package/driver/pipeline-knockout.mjs +5 -4
  36. package/driver/pipeline.mjs +474 -81
  37. package/driver/placement-decisions.mjs +24 -9
  38. package/driver/placement-form-io.mjs +21 -1
  39. package/driver/placement-form.mjs +25 -15
  40. package/driver/placement-union.mjs +53 -8
  41. package/driver/predelivery-lint.mjs +8 -1
  42. package/driver/publish/index.mjs +7 -3
  43. package/driver/register-digest-record.mjs +6 -3
  44. package/driver/research-answers.mjs +54 -7
  45. package/driver/runner.mjs +4 -4
  46. package/driver/saved-findings-block.mjs +36 -0
  47. package/driver/skills/clearance-register/digest.md +1 -1
  48. package/driver/skills/clearance-search/phase2-execution.md +7 -5
  49. package/driver/skills/clearance-search/synthesis-rules.md +3 -3
  50. package/driver/skills/placement-inquiry/SKILL.md +3 -3
  51. package/driver/stage-context.mjs +18 -0
  52. package/driver/stages.mjs +41 -13
  53. package/driver/suite-census.json +140 -14
  54. package/driver/synthesis-record.mjs +167 -1
  55. package/driver/verify.mjs +6 -6
  56. package/mcp-server/CHANGELOG.md +8 -0
  57. package/mcp-server/package.json +1 -1
  58. package/package.json +1 -1
  59. package/portal-ui/package.json +1 -1
  60. package/providers/_shared/README.md +1 -0
  61. package/providers/_shared/enumerate.mjs +4 -3
  62. package/providers/_shared/execute-plan.mjs +33 -7
  63. package/providers/_shared/send-until.mjs +25 -0
  64. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  65. package/providers/oauth-mcp-bridge/package.json +1 -1
  66. package/scripts/band-shape-probe.mjs +1 -1
  67. package/scripts/e2e-first-time.mjs +2 -1
  68. package/scripts/release-notes-lint.mjs +1 -1
  69. package/shared/inside-run.mjs +54 -0
  70. package/shared/names-in-force.mjs +1 -0
  71. package/shared/slices-taken-out.mjs +65 -0
  72. package/skills/clearotron-account/SKILL.md +1 -1
  73. package/skills/clearotron-ops/SKILL.md +1 -1
@@ -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
 
@@ -28,6 +28,7 @@
28
28
  // `GET /trademarks/{n}/image` answers 200 with real image bytes. The tool returns metadata + the
29
29
  // Vienna figurative-element codes rather than the bytes; see the core for why.
30
30
  import { serve } from "./stdio-server.mjs";
31
+ import { sendUntilFromEnv } from "../../../providers/_shared/send-until.mjs";
31
32
  import { crowdNoteFor } from "./crowd-note.mjs"; // how the unit decides on a crowd, beside an answer that carries one
32
33
  import {
33
34
  CAPABILITIES, CRED_ENV, doSearch, doRecordFetch, doBatchScreen, doImageFetch,
@@ -51,6 +52,7 @@ const tctx = (kind) => ({
51
52
  agentId: process.env.CLEAROTRON_GATHER_AGENT || "localagent",
52
53
  sessionKey: process.env.CLEAROTRON_GATHER_SESSION_KEY || "",
53
54
  sessionId: process.env.CLEAROTRON_GATHER_SESSION_ID || "",
55
+ sendUntilMs: sendUntilFromEnv(), // this turn's time to stop sending (providers/_shared/send-until.mjs)
54
56
  });
55
57
  const passthrough = (r) => (r && typeof r === "object" ? (r.text ?? JSON.stringify(r)) : String(r));
56
58
  const guard = (fn) => async (args) => {
@@ -30,6 +30,7 @@
30
30
  // Telemetry: the core writes CLEAROTRON_REGISTER_CALL_LOG / CLEAROTRON_REGISTER_RECORD_LOG (the SHARED register
31
31
  // ledger) under the provider id `free-tier`, keyed by the gateway session key.
32
32
  import { serve } from "./stdio-server.mjs";
33
+ import { sendUntilFromEnv } from "../../../providers/_shared/send-until.mjs";
33
34
  import { crowdNoteFor } from "./crowd-note.mjs"; // how the unit decides on a crowd, beside an answer that carries one
34
35
  import {
35
36
  CAPABILITIES, doSearch, doRecordFetch, doBatchScreen, doImageFetch, doEnumerate, doExecutePlan,
@@ -47,6 +48,7 @@ const tctx = (kind) => ({
47
48
  agentId: process.env.CLEAROTRON_GATHER_AGENT || "localagent",
48
49
  sessionKey: process.env.CLEAROTRON_GATHER_SESSION_KEY || "",
49
50
  sessionId: process.env.CLEAROTRON_GATHER_SESSION_ID || "",
51
+ sendUntilMs: sendUntilFromEnv(), // this turn's time to stop sending (providers/_shared/send-until.mjs)
50
52
  });
51
53
  const passthrough = (r) => (r && typeof r === "object" ? (r.text ?? JSON.stringify(r)) : String(r));
52
54
  const CAPABILITY_OFFICES = CAPABILITIES.offices.covered.join("+");
@@ -900,6 +900,21 @@ function serverEnv({ sessionKey, agent, runDir, recordAxis, bandGroups = false }
900
900
 
901
901
  // Build the mcp-config object for a set of groups. Returns { config: <object>, servers: [...] } or null if
902
902
  // the groups need no MCP server (e.g. caselaw-only EUR-Lex via WebFetch needs no server but the bridges do).
903
+ /**
904
+ * The stage's tool config for ONE turn, with that turn's time to stop sending handed to every server
905
+ * (providers/_shared/send-until.mjs). Built per turn, because each attempt has its own deadline. The config
906
+ * is the JSON string the engines take; anything that is not one is returned as it came. PURE.
907
+ */
908
+ export function withSendUntil(mcpConfigJson, sendUntilMs) {
909
+ if (typeof mcpConfigJson !== "string" || !Number.isFinite(Number(sendUntilMs))) return mcpConfigJson;
910
+ let cfg;
911
+ try { cfg = JSON.parse(mcpConfigJson); } catch { return mcpConfigJson; }
912
+ if (!cfg?.mcpServers || typeof cfg.mcpServers !== "object") return mcpConfigJson;
913
+ for (const server of Object.values(cfg.mcpServers))
914
+ if (server && typeof server === "object") server.env = { ...(server.env ?? {}), CLEAROTRON_GATHER_SEND_UNTIL_MS: String(Math.floor(Number(sendUntilMs))) };
915
+ return JSON.stringify(cfg);
916
+ }
917
+
903
918
  export function buildGatherMcpConfig(groups = [], { sessionKey, agent, runDir, recordAxis, bandGroups = false } = {}) {
904
919
  const localKeys = new Set(), bridgeKeys = new Set();
905
920
  for (const g of groups) { const r = resolveGroup(g); r.local.forEach((k) => localKeys.add(k)); r.bridges.forEach((k) => bridgeKeys.add(k)); }
@@ -15,7 +15,8 @@
15
15
  // is the same as one naming them. Owner names are compared as written, never merged. A different scope is a
16
16
  // different question, and an earlier answer that failed is no answer: both are asked again. A question that timed out
17
17
  // at the gateway is the one failure held, by the executor rather than here: its timeout stands until the envelope
18
- // close asks it once more (execute-plan.mjs, timedOutBefore).
18
+ // close asks it once more (execute-plan.mjs, timedOutBefore). An answer in a slice the run took out of its band
19
+ // because a check could not prove it ran is asked again too, whatever wrote the band since.
19
20
 
20
21
  import { readFileSync, readdirSync, statSync } from "node:fs";
21
22
  import { join } from "node:path";
@@ -23,6 +24,7 @@ import { driverDir } from "../../../shared/driver-dir.mjs";
23
24
  import { OUTSTANDING_REASON } from "../../../providers/_shared/follow-up-searches.mjs";
24
25
  import { awaitsReadingTurn } from "../../../providers/_shared/plan-guards.mjs";
25
26
  import { releasedFamilyQids } from "../../withheld-families.mjs";
27
+ import { bandWithSlicesOut, slicesTakenOutByAxis } from "../../../shared/slices-taken-out.mjs";
26
28
 
27
29
  const QUESTION_FIELDS = ["predicate", "term", "terms", "romanizedTerms", "term_literal", "nice_classes", "regions", "goods_text", "owner", "expected_kind"];
28
30
  const sorted = (v) => [...v].map((x) => String(x)).sort();
@@ -88,7 +90,9 @@ export function refuseHeldQuestions(entries, held, planRegions = []) {
88
90
  }
89
91
 
90
92
  // The run's questions and answers as its files hold them: the frozen plan, each worker's supplemental plan, and every
91
- // band's blocks by qid. IMPURE.
93
+ // band's blocks by qid, with the slices the searches record took out read as the open slices they were delivered as
94
+ // (shared/slices-taken-out.mjs). They are read out after the cache, which knows a band's size and time and not the
95
+ // record's. IMPURE.
92
96
  function readRunQuestions(runDir) {
93
97
  const units = join(runDir, "register-units");
94
98
  const blocksByQid = new Map();
@@ -96,8 +100,12 @@ function readRunQuestions(runDir) {
96
100
  try { plans.push({ axis: null, plan: JSON.parse(readFileSync(driverDir(runDir, "register-plan.json"), "utf8")) }); } catch { /* no frozen plan */ }
97
101
  let files = [];
98
102
  try { files = readdirSync(units); } catch { files = []; }
103
+ const takenOut = slicesTakenOutByAxis(runDir);
99
104
  for (const f of files) {
100
- if (f.endsWith("-band.json")) for (const b of readBlocks(join(units, f))) if (b?.qid) blocksByQid.set(String(b.qid), b);
105
+ if (f.endsWith("-band.json")) {
106
+ const { blocks } = bandWithSlicesOut(readBlocks(join(units, f)), takenOut.get(f.slice(0, -"-band.json".length)));
107
+ for (const b of blocks) if (b?.qid) blocksByQid.set(String(b.qid), b);
108
+ }
101
109
  if (f.endsWith("-supplemental-plan.json")) {
102
110
  try { plans.push({ axis: f.slice(0, -"-supplemental-plan.json".length), plan: JSON.parse(readFileSync(join(units, f), "utf8")) }); } catch { /* torn: skipped */ }
103
111
  }
@@ -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:
@@ -9,6 +9,7 @@
9
9
  // keyed by the GATEWAY session key (tctx.sessionKey) — we thread the run's session key via
10
10
  // CLEAROTRON_GATHER_SESSION_KEY so the $0 provider-usage diff still attributes calls to the run.
11
11
  import { serve } from "./stdio-server.mjs";
12
+ import { sendUntilFromEnv } from "../../../providers/_shared/send-until.mjs";
12
13
  import { crowdNoteFor } from "./crowd-note.mjs"; // how the unit decides on a crowd, beside an answer that carries one
13
14
  import { doSearch, doRecordFetch, doEnumerate, doExecutePlan, DEFAULT_BASE } from "../../../providers/signa/src/core.js";
14
15
  import { proposeSupplemental } from "./supplemental.mjs";
@@ -22,6 +23,7 @@ const tctx = (kind) => ({
22
23
  agentId: process.env.CLEAROTRON_GATHER_AGENT || "localagent",
23
24
  sessionKey: process.env.CLEAROTRON_GATHER_SESSION_KEY || "", // run session key — telemetry attribution
24
25
  sessionId: process.env.CLEAROTRON_GATHER_SESSION_ID || "",
26
+ sendUntilMs: sendUntilFromEnv(), // this turn's time to stop sending (providers/_shared/send-until.mjs)
25
27
  });
26
28
  const passthrough = (r) => (r && typeof r === "object" ? (r.text ?? JSON.stringify(r)) : String(r));
27
29
  const guard = (fn) => async (args) => {
@@ -34,8 +34,10 @@ 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";
40
+ import { bandWithSlicesOut, slicesTakenOutByAxis } from "../../../shared/slices-taken-out.mjs";
39
41
  import { readWithheldFamilies } from "../../withheld-families.mjs";
40
42
  import { addedClassBound, boundAddedClasses } from "../../frame-diff-model.mjs"; // an added class takes one question, the identical mark
41
43
  import { scriptQuestionsByMarket } from "../../registration-scripts.mjs"; // one script question per market on the plan's list
@@ -390,6 +392,10 @@ export async function proposeSupplemental(params, tctx, deps) {
390
392
  const proposals = Array.isArray(params?.proposals) ? params.proposals : null;
391
393
  if (!axis || !outPath || !proposals || !proposals.length)
392
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." };
393
399
 
394
400
  const suppPath = join(dirname(outPath), `${axis}-supplemental-plan.json`);
395
401
  // ── the supplemental plan INHERITS the frozen plan's regions (review findings 4/12) ───────────────
@@ -573,11 +579,13 @@ export async function proposeSupplemental(params, tctx, deps) {
573
579
  }
574
580
 
575
581
  // A question this worker re-proposes that already holds an answer is not run again: the answer stands. One with no
576
- // answer, a failed one, or one this call enriched or gave its markets runs as before.
582
+ // answer, a failed one, one whose slice the run took out of the band because a check could not prove it ran
583
+ // (shared/slices-taken-out.mjs), or one this call enriched or gave its markets runs as before.
577
584
  let reusedHeld = [];
578
585
  try {
579
586
  const own = new Map();
580
- for (const b of JSON.parse(readFileSync(outPath, "utf8"))) if (b?.qid) own.set(String(b.qid), b);
587
+ const { blocks } = bandWithSlicesOut(JSON.parse(readFileSync(outPath, "utf8")), slicesTakenOutByAxis(dirname(dirname(outPath))).get(axis));
588
+ for (const b of blocks) if (b?.qid) own.set(String(b.qid), b);
581
589
  reusedHeld = reused.filter((q) => { const b = own.get(String(q)); return b && b.error !== true && !enrichedQids.includes(q) && !staleOnDisk; });
582
590
  } catch { reusedHeld = []; }
583
591
  const qids = [...minted.map((e) => e.qid), ...reused.filter((q) => !reusedHeld.includes(q))];
@@ -32,6 +32,7 @@
32
32
  // visible where a spawn and a reviewer each actually look — the tool list here, the REGISTER_SERVERS
33
33
  // filter in gather-config.mjs, and the exact-six pin in driver/test/engine.gather.test.mjs.
34
34
  import { serve } from "./stdio-server.mjs";
35
+ import { sendUntilFromEnv } from "../../../providers/_shared/send-until.mjs";
35
36
  import { crowdNoteFor } from "./crowd-note.mjs"; // how the unit decides on a crowd, beside an answer that carries one
36
37
  import {
37
38
  CAPABILITIES, DEFAULT_DB_ENV, doSearch, doRecordFetch, doBatchScreen, doEnumerate, doExecutePlan,
@@ -48,6 +49,7 @@ const tctx = (kind) => ({
48
49
  agentId: process.env.CLEAROTRON_GATHER_AGENT || "localagent",
49
50
  sessionKey: process.env.CLEAROTRON_GATHER_SESSION_KEY || "", // run session key — telemetry attribution
50
51
  sessionId: process.env.CLEAROTRON_GATHER_SESSION_ID || "",
52
+ sendUntilMs: sendUntilFromEnv(), // this turn's time to stop sending (providers/_shared/send-until.mjs)
51
53
  });
52
54
  const passthrough = (r) => (r && typeof r === "object" ? (r.text ?? JSON.stringify(r)) : String(r));
53
55
  const guard = (fn) => async (args) => {
@@ -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);
@@ -37,6 +37,7 @@
37
37
  import { existsSync, readFileSync, writeFileSync, renameSync } from "node:fs";
38
38
  import { isCapabilityGapReason } from "./coverage-ledger.mjs";
39
39
  import { fullyDeferredAxes } from "./register-plan.mjs";
40
+ import { readPartNotCompleted } from "./parts-not-completed.mjs";
40
41
 
41
42
  export const SETTLE_SCHEMA_VERSION = 1;
42
43
 
@@ -194,9 +195,18 @@ export function settleTriedAfter(prior, dispatched, planVersion) {
194
195
  return [...out.values()];
195
196
  }
196
197
 
197
- /** The run's sticky capability gaps, by qid → row. Absent or unreadable decision ⇒ none. */
198
+ /**
199
+ * The run's sticky gaps, by qid → row: the capability gaps it accepted, and the slices a check could not prove ran,
200
+ * which the searches' record took out of the band once their recovery was spent (parts-not-completed.mjs). Neither is
201
+ * sent to the provider again, settled again or re-run to close. Absent or unreadable decision and record ⇒ none.
202
+ */
198
203
  export function readStickyGaps(P) {
199
- return new Map((readEnvelopeDecision(P)?.sticky_gaps ?? []).map((g) => [String(g?.qid ?? ""), g]).filter(([q]) => q));
204
+ const out = new Map((readEnvelopeDecision(P)?.sticky_gaps ?? []).map((g) => [String(g?.qid ?? ""), g]).filter(([q]) => q));
205
+ for (const s of (P?.runDir ? readPartNotCompleted(P.runDir, "searches")?.slices : null) ?? []) {
206
+ const qid = String(s?.qid ?? "");
207
+ if (qid && !out.has(qid)) out.set(qid, { qid, axis: String(s.axis ?? ""), reason: String(s.reason ?? "").slice(0, 400) });
208
+ }
209
+ return out;
200
210
  }
201
211
 
202
212
  /** One history entry per superseded decision. Returns the prior history with the old decision appended. */
@@ -225,7 +235,7 @@ export function supersede(prior, source) {
225
235
  export async function settleReceipt({ P, plan, receipt, dispatch = null, rejoin = null, now = null }) {
226
236
  const deferred = receipt?.deferred ?? [];
227
237
  const prior = readEnvelopeDecision(P);
228
- const sticky = new Set((prior?.sticky_gaps ?? []).map((g) => String(g?.qid ?? "")));
238
+ const sticky = new Set(readStickyGaps(P).keys());
229
239
  let { accepted, suspect } = partitionReceiptDeferrals(plan, receipt, { sticky });
230
240
  const closed = [], closeFailed = [];
231
241
  const tried = new Map((prior?.settle_tried ?? []).map((t) => [String(t?.qid ?? ""), t]));
@@ -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
+ }
@@ -224,7 +224,8 @@ export function toolWrittenArtifact(p) {
224
224
  ?? TOOL_WRITTEN_PATTERNS.find((r) => r.re.test(base))
225
225
  ?? null;
226
226
  }
227
- import { buildGatherMcpConfig, allowedToolsFor, toolGroupsForStage, recordAxisFor, seatWritesForGroups } from "./engine/mcp/gather-config.mjs";
227
+ import { buildGatherMcpConfig, allowedToolsFor, toolGroupsForStage, recordAxisFor, seatWritesForGroups, withSendUntil } from "./engine/mcp/gather-config.mjs";
228
+ import { sendReserveMs } from "../providers/_shared/send-until.mjs"; // each turn's plan calls stop sending ahead of its deadline
228
229
  import { everyToolCallRefused } from "./engine/tool-refusal.mjs"; // one definition, shared with the engine probe
229
230
  export { everyToolCallRefused };
230
231
  // The profiles STORE root, for the write boundary only — read from the module that owns it, never
@@ -297,11 +298,11 @@ export function isEmbeddedFallback(json, stderr = "") {
297
298
  // nonzero exit (the v14 delivery failure, previously mislabelled `nonzero_exit_1`); (3) any nonzero
298
299
  // exit whose wall already reached the configured budget. Distinguished from transient errors so the
299
300
  // retry policy can give a slow stage ONE longer shot instead of 3 identical doomed attempts.
300
- export function isTimeout({ killed, code, wall, stderr = "", timeoutSec }) {
301
- if (killed) return true;
302
- if (/EmbeddedAttemptSessionTakeoverError|request timed out|\bETIMEDOUT\b/i.test(stderr)) return true;
303
- if (code !== 0 && wall >= timeoutSec) return true;
304
- return false;
301
+ export const isTimeout = (facts) => timeoutCause(facts) !== null;
302
+ export function timeoutCause({ killed, code, wall, stderr = "", timeoutSec }) { // which signal fired, or null
303
+ if (killed) return "killed";
304
+ if (/EmbeddedAttemptSessionTakeoverError|request timed out|\bETIMEDOUT\b/i.test(stderr)) return "stderr";
305
+ return code !== 0 && wall >= timeoutSec ? "wall" : null;
305
306
  }
306
307
 
307
308
  // A LANE WEDGE: a timeout whose turn moved ZERO tokens — the gateway never admitted it to a command lane (the
@@ -942,6 +943,9 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
942
943
  }
943
944
  }
944
945
  }
946
+ // EACH TURN'S OWN COPY of the stage's tool config, carrying that turn's time to stop sending: ahead of the turn's
947
+ // deadline by the reserve, so a plan call returns what it holds while the turn can still write it (send-until.mjs).
948
+ const turnConfig = (sec) => withSendUntil(gatherMcpConfig, Date.now() + sec * 1000 - sendReserveMs(sec));
945
949
  let lastFail;
946
950
  // — WHAT ACTUALLY RAN, carried out to the caller. The attempt rows have held the truth all along
947
951
  // (`modelActual` from the wire, `modelUsed` from the engine's own resolver); the ladder simply never
@@ -1125,13 +1129,14 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1125
1129
  // the snapshot, which only widens the window by their own time: an earlier attempt's refusals all
1126
1130
  // precede its own settle, so they stay outside it.
1127
1131
  const dispatchedAt = Date.now();
1132
+ const mcpConfig = turnConfig(effTimeout); // this turn's copy, with its time to stop sending
1128
1133
  for (const line of describeMethodologyDrift(witnessStageMethodology(runDir, name, effMessage, engineResolveSkill)))
1129
1134
  note(`[${name}] ${line}`);
1130
1135
  // — the frozen judged-by set, hashed into THIS PROCESS'S MEMORY before the seat runs. Never
1131
1136
  // written to disk before the comparison, so the thing being watched cannot reach it. See
1132
1137
  // run-integrity.mjs for why it is not a manifest file and why the append-only journals are excluded.
1133
1138
  const integrityBefore = frozenSnapshot(runDir);
1134
- const turn = await engine.runTurn({ agent, sessionKey: key, message: effMessage, grantDispatch: base, model, thinking, timeoutSec: effTimeout, resumeRef: warm ? lastSessionRef : undefined, codexHome: stageCodexHome, mcpConfig: gatherMcpConfig, allowedTools: gatherAllowedTools, seatWrites: gatherSeatWrites, skillsDir: engineSkillsDir, skillsGrantRoots: engineSkillsGrantRoots, profilesDir: profilesStoreDir, resolveSkill: engineResolveSkill, runDir, stallSec,
1139
+ const turn = await engine.runTurn({ agent, sessionKey: key, message: effMessage, grantDispatch: base, model, thinking, timeoutSec: effTimeout, resumeRef: warm ? lastSessionRef : undefined, codexHome: stageCodexHome, mcpConfig, allowedTools: gatherAllowedTools, seatWrites: gatherSeatWrites, skillsDir: engineSkillsDir, skillsGrantRoots: engineSkillsGrantRoots, profilesDir: profilesStoreDir, resolveSkill: engineResolveSkill, runDir, stallSec,
1135
1140
  progressFiles: files, // the no-progress watchdog's artifact-advance signal (anthropic-agent; other adapters ignore it)
1136
1141
  // The raw stream of this session, beside its dispatch record (engine/session-record.mjs). The engines
1137
1142
  // keep it while the disk has room and say on the tuple's `stream` what landed.
@@ -1282,7 +1287,10 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1282
1287
  let fail = null;
1283
1288
  let stampedQuantity = null; //: a validator's own exact count, when it stamped one (see below)
1284
1289
  let failingFile = null; //: the artifact the validator rejected — the repair's only target
1285
- if (isTimeout({ killed, code, wall, stderr, timeoutSec: effTimeout })) fail = "timeout";
1290
+ // A TURN THAT COMPLETED IS JUDGED ON WHAT IT WROTE, whatever its stderr says about a request inside it
1291
+ // (attemptTimedOut). Which signal fired is kept on the row either way.
1292
+ const timedOutBy = timeoutCause({ killed, code, wall, stderr, timeoutSec: effTimeout });
1293
+ if (attemptTimedOut({ cause: timedOutBy, code, json })) fail = "timeout";
1286
1294
  else if (isEmbeddedFallback(json, stderr)) fail = "embedded_fallback";
1287
1295
  else if (code !== 0) fail = `nonzero_exit_${code}`;
1288
1296
  else if (!json) fail = "unparseable_json";
@@ -1354,7 +1362,7 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1354
1362
  // under the run dir to win the grant back, and warmPatchMessage renders the target through rel();
1355
1363
  // a relative path there would silently strip the write root off the one turn that needs it most.
1356
1364
  const rt = await engine.runTurn({ agent, sessionKey: key, message: repairMessage, model, thinking,
1357
- timeoutSec: effTimeout, resumeRef: repairRef, codexHome: stageCodexHome, mcpConfig: gatherMcpConfig, allowedTools: gatherAllowedTools,
1365
+ timeoutSec: effTimeout, resumeRef: repairRef, codexHome: stageCodexHome, mcpConfig: turnConfig(effTimeout), allowedTools: gatherAllowedTools,
1358
1366
  skillsDir: engineSkillsDir, skillsGrantRoots: engineSkillsGrantRoots, profilesDir: profilesStoreDir, resolveSkill: engineResolveSkill, runDir, stallSec,
1359
1367
  progressFiles: files, streamFile: streamFilePath(runDir, name, attempt, formRepairsUsed + 1) });
1360
1368
  repairRef = rt.sessionRef ?? repairRef;
@@ -1819,6 +1827,7 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1819
1827
  // `followup` matters too: a followup success PATCHES the prior session's output, it never
1820
1828
  // supersedes a taint the way a fresh full pass does.
1821
1829
  killed: killed || undefined,
1830
+ timeoutCause: timedOutBy ?? undefined, // — which of the three timeout signals fired: killed, stderr or wall
1822
1831
  signals: turn.signals ?? undefined,
1823
1832
  //: how long every declared artifact had been untouched when the turn settled. Present on any
1824
1833
  // hard-wall kill the wall rescue looked at — INCLUDING the ones it refused, so "considered and
@@ -1899,7 +1908,7 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1899
1908
  cliVersion: cli.version, cliVersionProbe: cli.probe, ...(cli.why ? { cliVersionWhy: cli.why } : {}), cliSource: cli.source,
1900
1909
  cliFloor: cli.floor ?? null, cliBelowFloor: cli.belowFloor ?? null,
1901
1910
  wrote, warm: warm || undefined, warmEscalated: attempt === warmEscalatedAt || undefined,
1902
- rescued: rescued ?? undefined, killed: killed || undefined,
1911
+ rescued: rescued ?? undefined, killed: killed || undefined, timeoutCause: timedOutBy ?? undefined,
1903
1912
  quiescentMs: Number.isFinite(quiescentMs) ? Math.round(quiescentMs) : undefined, // — see the per-stage row
1904
1913
  rescueRefused: rescueRefused ?? undefined, // — the cause, when the rescue looked and refused
1905
1914
  rescueRefusedFile: rescueRefusedFile ?? undefined, rescueRefusedReason: rescueRefusedReason ?? undefined,
@@ -3735,3 +3744,15 @@ function toolCallCounts(turn) {
3735
3744
  return { toolCallsRefused: n(turn?.toolCallsRefused), commandToolCalls: n(turn?.commandToolCalls), mcpToolCalls: n(turn?.mcpToolCalls),
3736
3745
  toolCallsErroredByName: errored && typeof errored === "object" && !Array.isArray(errored) ? { ...errored } : null };
3737
3746
  }
3747
+
3748
+ /**
3749
+ * IS THIS ATTEMPT A TIMEOUT? A kill and a run past the limit with a non-zero exit always are. A timed-out request named
3750
+ * in stderr is one only when the turn did not complete: a turn that exited 0 with its engine's status ok outlived the
3751
+ * request, which was a call inside it (a web lookup, a tool), and is judged on what it wrote like any finished turn.
3752
+ * Recorded as a timeout, it was run again whole: on one test run a writer finished in 14.7 minutes of its 43, exited 0
3753
+ * with its output written, and paid a second attempt of 20.3 minutes. `cause` is `timeoutCause`'s answer. PURE.
3754
+ */
3755
+ export function attemptTimedOut({ cause, code, json }) {
3756
+ if (!cause) return false;
3757
+ return !(cause === "stderr" && code === 0 && json?.status === "ok");
3758
+ }
@@ -2,7 +2,7 @@
2
2
  "name": "clearotron-driver",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.5.0-beta.2",
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": {