clearotron 0.4.0 → 0.4.1-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/THIRD-PARTY-NOTICES.md +2 -2
  2. package/bin/README.md +1 -1
  3. package/bin/clearotron.mjs +2 -0
  4. package/bin/mcp.mjs +80 -0
  5. package/bin/start.mjs +14 -1
  6. package/build-info.json +2 -2
  7. package/demo/README.md +1 -1
  8. package/docs/SECURITY-OWASP.md +21 -13
  9. package/docs/SECURITY.md +20 -14
  10. package/docs/architecture/04-configuration-reference.md +3 -3
  11. package/docs/architecture/05-config-governance.md +1 -1
  12. package/docs/architecture/06-operations-runbook.md +5 -4
  13. package/docs/architecture/08-development-guide.md +3 -1
  14. package/docs/architecture/09-security-and-data.md +16 -11
  15. package/driver/CHANGELOG.md +29 -0
  16. package/driver/binding-layers.mjs +28 -6
  17. package/driver/citation-census.json +3 -3
  18. package/driver/common-law-coverage-status.mjs +52 -0
  19. package/driver/common-law-receipts.mjs +1 -1
  20. package/driver/compare.mjs +1 -1
  21. package/driver/connotation-search.mjs +5 -5
  22. package/driver/contract-audit.mjs +6 -6
  23. package/driver/contract-dictation-registry.mjs +1 -1
  24. package/driver/contract-e3-backlog.mjs +18 -17
  25. package/driver/contract-vocabulary.mjs +145 -132
  26. package/driver/declination-call.mjs +5 -1
  27. package/driver/degraded-parts.mjs +10 -3
  28. package/driver/dev-portal.mjs +2 -1
  29. package/driver/dispatch-record.mjs +19 -0
  30. package/driver/doubt-closure-tool.mjs +2 -2
  31. package/driver/driver.config.mjs +39 -6
  32. package/driver/engagement-receipt.mjs +1 -1
  33. package/driver/engine/CONTRACT.md +41 -1
  34. package/driver/engine/anthropic-agent.mjs +56 -4
  35. package/driver/engine/mcp/codex-config.mjs +8 -3
  36. package/driver/engine/mcp/coverage-server.mjs +1 -1
  37. package/driver/engine/mcp/gather-config.mjs +1 -1
  38. package/driver/engine/mcp/recording-server.mjs +32 -5
  39. package/driver/engine/mcp/stdio-server.mjs +45 -7
  40. package/driver/engine/mcp/supplemental.mjs +26 -1
  41. package/driver/engine/openai-agent.mjs +113 -20
  42. package/driver/engine/session-record.mjs +223 -0
  43. package/driver/enqueue-schema.mjs +2 -2
  44. package/driver/findings-model.mjs +4 -4
  45. package/driver/flag-snapshot.mjs +2 -1
  46. package/driver/form-neighbourhood.mjs +7 -7
  47. package/driver/frame-diff-model.mjs +3 -3
  48. package/driver/framework.mjs +22 -0
  49. package/driver/gateway.mjs +96 -14
  50. package/driver/jx.mjs +1 -1
  51. package/driver/knockout-review-record.mjs +55 -1
  52. package/driver/named-band.mjs +4 -0
  53. package/driver/owner-screen.mjs +7 -4
  54. package/driver/package.json +1 -1
  55. package/driver/phase0.mjs +13 -2
  56. package/driver/pipeline-knockout.mjs +15 -13
  57. package/driver/pipeline.mjs +132 -48
  58. package/driver/portal-families.mjs +3 -3
  59. package/driver/portal-service.mjs +6 -5
  60. package/driver/predelivery-lint.mjs +19 -19
  61. package/driver/products.mjs +1 -1
  62. package/driver/profiles.mjs +4 -3
  63. package/driver/publish/index.mjs +1 -1
  64. package/driver/publish/knockout.mjs +5 -4
  65. package/driver/publish/office-record-links.mjs +17 -2
  66. package/driver/publish/publish-inputs.mjs +3 -3
  67. package/driver/publish/render-knockout.mjs +17 -5
  68. package/driver/publish/render.mjs +4 -2
  69. package/driver/publish/xlsx.mjs +1 -1
  70. package/driver/record-fold.mjs +177 -0
  71. package/driver/register-count.mjs +20 -3
  72. package/driver/register-coverage.mjs +1 -1
  73. package/driver/register-plan.mjs +83 -18
  74. package/driver/register-records.mjs +7 -0
  75. package/driver/registry-fidelity.mjs +2 -2
  76. package/driver/repairs.mjs +1 -1
  77. package/driver/report-card-record.mjs +4 -4
  78. package/driver/result-noun-fields.mjs +3 -3
  79. package/driver/scope-facts.mjs +11 -3
  80. package/driver/scope-rules.mjs +2 -2
  81. package/driver/search-policy.mjs +20 -1
  82. package/driver/skills/clearance-common-law/SKILL.md +22 -22
  83. package/driver/skills/clearance-register/digest.md +3 -3
  84. package/driver/skills/clearance-register/register-recipes.md +11 -11
  85. package/driver/skills/clearance-register/status-rules.md +1 -1
  86. package/driver/skills/clearance-register/unit.md +1 -1
  87. package/driver/skills/clearance-search/firm-wide-reasoning.md +3 -2
  88. package/driver/skills/clearance-search/phase2-execution.md +6 -5
  89. package/driver/skills/clearance-search/synthesis-rules.md +4 -4
  90. package/driver/skills/clearance-search/worked-examples.md +39 -39
  91. package/driver/skills/clearance-variants/SKILL.md +16 -16
  92. package/driver/skills/frame-diff/SKILL.md +2 -2
  93. package/driver/stage-context.mjs +36 -1
  94. package/driver/stages-knockout.mjs +6 -5
  95. package/driver/stages.mjs +30 -30
  96. package/driver/suite-census.json +259 -55
  97. package/driver/synthesis-record.mjs +5 -0
  98. package/driver/unit-inventory.mjs +17 -28
  99. package/driver/verify.mjs +7 -7
  100. package/mcp-server/CHANGELOG.md +8 -0
  101. package/mcp-server/CONNECT.md +9 -3
  102. package/mcp-server/demo-mode.mjs +10 -0
  103. package/mcp-server/lib/evidence.mjs +8 -8
  104. package/mcp-server/lib/knockout.mjs +1 -1
  105. package/mcp-server/lib/runs.mjs +7 -1
  106. package/mcp-server/lib/scrub.mjs +1 -1
  107. package/mcp-server/package.json +2 -2
  108. package/mcp-server/packs/README.md +1 -1
  109. package/mcp-server/server.mjs +16 -2
  110. package/package.json +4 -3
  111. package/portal-ui/package.json +3 -3
  112. package/providers/_shared/count.mjs +9 -2
  113. package/providers/_shared/enumerate.mjs +125 -41
  114. package/providers/_shared/execute-plan.mjs +15 -4
  115. package/providers/corsearch/src/capabilities.js +1 -1
  116. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  117. package/providers/oauth-mcp-bridge/package.json +2 -2
  118. package/providers/signa/src/capabilities.js +32 -2
  119. package/providers/signa/src/core.js +262 -36
  120. package/scripts/band-shape-probe.mjs +2 -2
  121. package/scripts/deprecate-below.mjs +28 -4
  122. package/scripts/e2e-first-time.mjs +17 -2
  123. package/scripts/env-audit.mjs +3 -2
  124. package/scripts/freeze-example-run.mjs +10 -10
  125. package/scripts/mcp-registry-listing.mjs +199 -0
  126. package/scripts/mint-plugin-folder.mjs +65 -0
  127. package/scripts/release-version.mjs +44 -0
  128. package/scripts/score.mjs +1 -1
  129. package/shared/register-floor.mjs +19 -0
  130. package/shared/scope.mjs +18 -2
  131. package/shared/trigger-lane.mjs +1 -1
  132. package/skills/clearotron-ops/SKILL.md +81 -24
@@ -139,6 +139,28 @@ export function aboveLowestBand(manifest, label) {
139
139
  return i !== -1 && i < manifest.bands.length - 1;
140
140
  }
141
141
 
142
+ /** The tones of the rungs whose findings are "material": medium risk or higher. A rung's tone is its own
143
+ * statement of its risk, so the line holds on any client's ladder. A line drawn by POSITION ("above the
144
+ * lowest band") did not: the bottom rung is "low" on a four-rung ladder and "minimal" on a five-rung one,
145
+ * so one client's low-risk rung was checked and another's was not (owner's ruling, 2026-10-09). */
146
+ export const MATERIAL_TONES = Object.freeze(["severe", "high", "medium"]);
147
+
148
+ /** True when the label is a band whose tone is medium risk or higher — the "material finding" predicate. */
149
+ export function materialBand(manifest, label) {
150
+ return MATERIAL_TONES.includes(bandTone(manifest, label));
151
+ }
152
+
153
+ /** The material bands' labels, highest first: what a dispatch names when it says which findings a check covers. */
154
+ export const materialBandLabels = (manifest) => (manifest?.bands ?? [])
155
+ .filter((b) => MATERIAL_TONES.includes(b?.tone)).map((b) => b.label);
156
+
157
+ /** The material bands as a reader's list — "Very High, High or Moderate" — or null when the ladder names none. */
158
+ export function materialBandPhrase(manifest) {
159
+ const xs = materialBandLabels(manifest);
160
+ if (!xs.length) return null;
161
+ return xs.length === 1 ? xs[0] : `${xs.slice(0, -1).join(", ")} or ${xs[xs.length - 1]}`;
162
+ }
163
+
142
164
  /** The most severe band label among the given labels (unknown labels ignored); null when none are bands. */
143
165
  export function worstBand(manifest, labels) {
144
166
  let best = -1;
@@ -246,7 +246,8 @@ import { REGISTER_ENUMERATE_TOOL, SUPPLEMENTAL_LANE_STEERING } from "./stages.mj
246
246
  // same thing to the exit-1 rescue below as it does to the register taint chain that reads the very
247
247
  // same rows off disk. Acyclic: register-taint.mjs imports node builtins only.
248
248
  import { isTaintRow } from "./register-taint.mjs";
249
- import { recordDispatch } from "./dispatch-record.mjs";
249
+ import { recordDispatch, streamFilePath } from "./dispatch-record.mjs";
250
+ import { anyResultErrored, endingFields } from "./engine/session-record.mjs"; // a session that reached an error result is not an ok attempt, whatever its last result said
250
251
  import { recordBestDraft } from "./best-draft.mjs";
251
252
  //: the allowlisted progress-quantity extractor. Acyclic — repairs.mjs imports node builtins only
252
253
  // (fs/crypto/path). Used only when the validator did not stamp its own count; never a digit hunt.
@@ -309,6 +310,37 @@ export function isTimeout({ killed, code, wall, stderr = "", timeoutSec }) {
309
310
  // on a parsed 0-usage object. A slow-but-working model that times out returns a status_timeout envelope WITH
310
311
  // usage (admitted) → NOT a wedge. Kept pure + exported so the classifier is unit-tested (it must not misfire on
311
312
  // admitted turns, and it MUST fire on the null-usage hard-kill that the production incident actually produced).
313
+ /**
314
+ * Did this attempt spend its whole derived budget, exit cleanly, and still fail? PURE.
315
+ *
316
+ * THE CASE THIS NAMES, measured on one round: an attempt ran 958.6 seconds against a derived limit of 900,
317
+ * exited 0, wrote nothing, and was recorded as `missing_file` naming its own output. It was working the whole
318
+ * time — 35,341 output tokens, 119 tool calls, 947.7 seconds of active time. The hard kill is the limit plus
319
+ * 60, so it finished 1.4 seconds under it.
320
+ *
321
+ * WHY THE CLASSIFICATION ABOVE IS RIGHT AND THE RECORD WAS STILL WRONG. `isTimeout`'s third signal needs a
322
+ * non-zero exit as well as a wall past the budget, and the program exited 0 — so it is not a timeout, and
323
+ * nothing here argues it should be. The defect is that the row then read `missing_file`, which names an
324
+ * artifact and sends the next reader to look for a path bug or a race. The two numbers that say otherwise
325
+ * were both on the row — the wall beside the derived limit — and nobody was pointed at them.
326
+ *
327
+ * SO THIS RECORDS A FACT AND MOVES NO POLICY. It does not feed `isTimeout`, it does not change which retry
328
+ * the attempt earns, and the fail class is untouched. Whether a clean exit at the budget should also earn the
329
+ * longer retry a timeout earns is a question for the owner, and the issue that asked for this says so
330
+ * explicitly: it may be right that a clean exit is not a timeout. This is the record it would be decided on.
331
+ *
332
+ * Three-valued, like the model fields on the same row: null when the budget is unknown, so "nobody derived a
333
+ * limit for this stage" stays visibly different from "it did not spend it".
334
+ */
335
+ export function spentBudgetCleanly({ fail, killed, code, wall, derivedLimitSec }) {
336
+ if (!fail) return false; // a passing attempt is not this shape
337
+ if (!Number.isFinite(derivedLimitSec) || derivedLimitSec <= 0) return null;
338
+ if (!Number.isFinite(wall)) return null;
339
+ if (killed) return false; // a kill is the timeout path, not this one
340
+ if (code !== 0) return false; // a dirty exit at the budget IS a timeout already
341
+ return wall >= derivedLimitSec;
342
+ }
343
+
312
344
  export function isLaneWedge(fail, usage) {
313
345
  if (fail !== "timeout") return false;
314
346
  const moved = (usage?.input || 0) + (usage?.output || 0) + (usage?.cacheRead || 0) + (usage?.cacheWrite || 0);
@@ -389,9 +421,35 @@ export function toolGauge(turn) {
389
421
  // engine cannot report", the same distinction toolWaitByTool draws one line up. RECORDING ONLY:
390
422
  // toolWaitMs and the per-tool split are exactly what they were, because the kill clock reads them.
391
423
  toolWaitUnmeasurable: Array.isArray(turn?.toolWaitUnmeasurable) ? [...turn.toolWaitUnmeasurable] : null, ...toolCallCounts(turn),
424
+ // The calls by tool name, beside the total, so a tool server's own log can be set against what the
425
+ // model asked for. An OBJECT is a measurement (`{}`: none); null is "this engine cannot report".
426
+ toolCallsByName: turn?.toolCallsByName && typeof turn.toolCallsByName === "object" && !Array.isArray(turn.toolCallsByName)
427
+ ? { ...turn.toolCallsByName } : null,
392
428
  };
393
429
  }
394
430
 
431
+ /**
432
+ * Whether the attempt is OK: the driver's verdict (`fail`) AND no result event of the session carried
433
+ * `is_error`. A session the vendor refused and a restart recovered ends in a successful LAST result, so
434
+ * the retry ladder (which reads that last result, unchanged) accepts it; the record says what happened.
435
+ * Readers that ask "did the stage fail" read `fail`, which this does not touch. PURE.
436
+ */
437
+ export function attemptOk(fail, turn) {
438
+ return !fail && !anyResultErrored(turn?.session);
439
+ }
440
+
441
+ /**
442
+ * The stream record as a row carries it: the file relative to the run, as the dispatch record names its
443
+ * file, and the size — or why it was not kept. null when the engine kept none or ran no session. PURE.
444
+ */
445
+ export function streamMeta(runDir, stream) {
446
+ if (!stream || typeof stream !== "object" || !stream.file) return null;
447
+ const dir = String(runDir ?? "");
448
+ const file = dir && String(stream.file).startsWith(dir) ? String(stream.file).slice(dir.length).replace(/^[\\/]+/, "").replace(/\\/g, "/") : String(stream.file); // forward slashes, as every record path is, on Windows too
449
+ return { file, present: stream.present === true, bytes: Number.isFinite(stream.bytes) ? stream.bytes : null,
450
+ ...(stream.reason ? { reason: stream.reason } : {}) };
451
+ }
452
+
395
453
  export function tokensPerSec(usage, wall) {
396
454
  const w = Number(wall);
397
455
  if (!usage || !Number.isFinite(w) || w <= 0) return null;
@@ -1073,7 +1131,10 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1073
1131
  // run-integrity.mjs for why it is not a manifest file and why the append-only journals are excluded.
1074
1132
  const integrityBefore = frozenSnapshot(runDir);
1075
1133
  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,
1076
- progressFiles: files }); // the no-progress watchdog's artifact-advance signal (anthropic-agent; other adapters ignore it)
1134
+ progressFiles: files, // the no-progress watchdog's artifact-advance signal (anthropic-agent; other adapters ignore it)
1135
+ // The raw stream of this session, beside its dispatch record (engine/session-record.mjs). The engines
1136
+ // keep it while the disk has room and say on the tuple's `stream` what landed.
1137
+ streamFile: streamFilePath(runDir, name, attempt) });
1077
1138
  const settledAt = Date.now(); //: zero point of the wall-rescue quiescence clock, read before anything else
1078
1139
  // LOG-ONLY. Nothing here can fail a turn: the claim that the frozen set does not change across a seat
1079
1140
  // turn is READ from the writers' call sites and not yet MEASURED on a running system, and arming an
@@ -1294,7 +1355,7 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1294
1355
  const rt = await engine.runTurn({ agent, sessionKey: key, message: repairMessage, model, thinking,
1295
1356
  timeoutSec: effTimeout, resumeRef: repairRef, codexHome: stageCodexHome, mcpConfig: gatherMcpConfig, allowedTools: gatherAllowedTools,
1296
1357
  skillsDir: engineSkillsDir, skillsGrantRoots: engineSkillsGrantRoots, profilesDir: profilesStoreDir, resolveSkill: engineResolveSkill, runDir, stallSec,
1297
- progressFiles: files });
1358
+ progressFiles: files, streamFile: streamFilePath(runDir, name, attempt, formRepairsUsed + 1) });
1298
1359
  repairRef = rt.sessionRef ?? repairRef;
1299
1360
  // A KILLED REPAIR TURN'S BYTES ARE NOT STAGE TRUTH. Same doctrine as the exit-1 rescue below: a
1300
1361
  // stage validator is a SHAPE check, not a completeness proof, and a torn draft can pass its own
@@ -1338,8 +1399,9 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1338
1399
  repair: formRepairsUsed, repairOf: MAX_FORM_REPAIRS, repairOutcome: outcome, fail: triggeredBy,
1339
1400
  repairTarget: rel(target), // the file the repair was AIMED at (often a sibling, not the expectFile)
1340
1401
  dispatch: repairDispatch, // — the verbatim message this repair turn was given
1341
- stopReason: rt.json?.stopReason ?? undefined,
1402
+ stopReason: rt.json?.stopReason ?? undefined, ...endingFields(rt.json), // — and what ended it, in the program's words on an error
1342
1403
  killed: rt.killed || undefined, signals: rt.signals ?? undefined,
1404
+ session: rt.session ?? null, stream: streamMeta(runDir, rt.stream), // — the repair session's own record, as on the attempt row
1343
1405
  warm: true, // a repair turn always RESUMES the session that wrote the defect
1344
1406
  runId: rt.json?.runId, engineSummary: rt.json?.summary, usage: rt.usage,
1345
1407
  selfReportContradicted: (outcome !== "repaired" && rt.json?.status === "ok") || undefined, //
@@ -1707,7 +1769,13 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1707
1769
  // attempt produce what the stage exists to produce". Sixty report-card attempts on round 892dd88e
1708
1770
  // journalled status:"ok", summary:"success", code:0, wrote:true and output.present:true beside a
1709
1771
  // populated `fail`, and a watcher keyed on the headline saw sixty successes.
1710
- ok: !fail,
1772
+ // — AND NOT WHEN THE SESSION REACHED AN ERROR RESULT, whatever its last result said: a refusal a
1773
+ // restart recovered is a session that was refused (engine/session-record.mjs). `fail` is unchanged,
1774
+ // so the ladder and every reader asking "did the stage fail" read exactly what they read before.
1775
+ ok: attemptOk(fail, turn),
1776
+ // What the session went through — every result, classifier cut, refusal and restart, with when —
1777
+ // and where its raw stream is, or why it was not kept. null where the engine cannot report.
1778
+ session: turn.session ?? null, stream: streamMeta(runDir, turn.stream),
1711
1779
  // The engine's claim about itself, KEPT and renamed to say whose claim it is. A self-report is
1712
1780
  // evidence — deleting it would destroy the only record of what the seat believed it had done — and
1713
1781
  // it is not a verdict. Still three-valued (ok | timeout | error). Nothing in the driver reads either
@@ -1743,7 +1811,7 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1743
1811
  laneWaitMs, // WS-C turn-cap queue time — the live overlap validation reads this
1744
1812
  // A6: the provider's stop reason, verbatim — the max_tokens fault line is countable from the
1745
1813
  // rows alone (fail carries the named fault; this field carries the raw discriminator).
1746
- stopReason: json?.stopReason ?? undefined,
1814
+ stopReason: json?.stopReason ?? undefined, ...endingFields(json), // — and beside it what ended the session: its kind, the vendor's status, and on an error the program's own words (session-record.mjs)
1747
1815
  // taint durability (copper-lattice): the kill discriminators survive to disk — register-taint.mjs
1748
1816
  // reads these rows across process restarts (--resume), where the in-memory turn object is gone.
1749
1817
  // `followup` matters too: a followup success PATCHES the prior session's output, it never
@@ -1785,6 +1853,11 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1785
1853
  // measure" stays visibly different from "this turn called no tools" — see toolGauge.
1786
1854
  ...toolGauge(turn), band: bandSize ?? undefined,
1787
1855
  inputBytes: derivedLimit?.inputBytes ?? undefined, derivedLimitSec: derivedLimit?.sec ?? undefined,
1856
+ // …and whether this attempt spent that limit and exited cleanly anyway. Written UNCONDITIONALLY and
1857
+ // three-valued, beside the two numbers it is about, so a failure that had used its whole budget is
1858
+ // not left looking like an artifact problem. It names a fact; it moves no retry policy. See
1859
+ // `spentBudgetCleanly`.
1860
+ budgetSpentCleanly: spentBudgetCleanly({ fail, killed, code, wall, derivedLimitSec: derivedLimit?.sec }),
1788
1861
  // AD-4 emitted-vs-landed, UNCONDITIONAL (was success-only, which made a failed attempt's mid-write
1789
1862
  // artifact invisible): `output` = what LANDED on disk after this attempt (null when the stage has no
1790
1863
  // expected file); `wrote` = whether THIS attempt emitted it (see the computation above the runDir
@@ -1815,7 +1888,9 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1815
1888
  // from "retries not recorded here"). Best-effort like every telemetry write.
1816
1889
  try {
1817
1890
  runLog(runDir, {
1818
- event: "attempt", stage: name, attempt, of: maxRetries + 1, ok: !fail, fail: fail ?? null,
1891
+ event: "attempt", stage: name, attempt, of: maxRetries + 1, ok: attemptOk(fail, turn), fail: fail ?? null,
1892
+ // The spine carries the one-word answer and the pointer; the per-stage row carries the record.
1893
+ interrupted: turn.session?.interrupted ?? null, stream: streamMeta(runDir, turn.stream)?.file ?? null,
1819
1894
  //: the spine carries the same pair as the per-stage log, or the two disagree about what
1820
1895
  // ran. `model` stays the requested resolution (its existing readers); `modelActual` is the wire.
1821
1896
  model: modelRequested, modelActual, modelBasis, modelSnapshot, modelMismatch, providerReported,
@@ -1850,6 +1925,9 @@ async function runStageLadder(name, opts, stageCodexHome = null) {
1850
1925
  // — the spine carries them too, or a round has to join two files to ask why a stage was slow.
1851
1926
  ...toolGauge(turn), band: bandSize ?? undefined,
1852
1927
  inputBytes: derivedLimit?.inputBytes ?? undefined, derivedLimitSec: derivedLimit?.sec ?? undefined,
1928
+ // …and the same fact on this row, because one of the two carrying it records half the runs and
1929
+ // reads as done. Three-valued and beside the numbers it is about. See `spentBudgetCleanly`.
1930
+ budgetSpentCleanly: spentBudgetCleanly({ fail, killed, code, wall, derivedLimitSec: derivedLimit?.sec }),
1853
1931
  formRepairs: formRepairsThisAttempt || undefined, //, see the stage row above
1854
1932
  });
1855
1933
  } catch { /* telemetry best-effort — never fail a turn over a journal line */ }
@@ -3037,7 +3115,7 @@ export function correctionHint(lastFail, { gridLedgerName = "common-law-grid.jso
3037
3115
  // run the driver stamped as form-required, and the stamp is conditional. On an unstamped run
3038
3116
  // validators.registerFindings demands the table exactly as it did before and emits this label,
3039
3117
  // so dropping the arm left the one lane that can still fire it with a generic hint.
3040
- hint = "the file has a findings heading plus a Coverage ledger with a status row (confirmed-clean / coverage-limited / deferred)" + (/common-law-findings/.test(lastFail) ? ", or each ledger row's status is recorded by calling `record_coverage_status` with `grid_spec_path`, the same spec path the grid tool was given" : "");
3118
+ hint = "the file has a findings heading plus a Coverage ledger with a status row (confirmed-clean / coverage-limited / deferred)"; // the common-law clause went with that lane's status gate (ruling 2026-10-01)
3041
3119
  } else if (/negative-results|coverage-ledger|audit-trail|findings-heading/.test(lastFail)) {
3042
3120
  hint = "the findings file carries ALL required sections: a findings heading, the Negative results matrix " +
3043
3121
  "(every variant × platform row), the Coverage ledger with a status row, and the Audit trail call log";
@@ -3114,8 +3192,8 @@ export function isFormClassFail(fail) {
3114
3192
  return FORM_CLASS_RE.test(fail ?? "");
3115
3193
  }
3116
3194
  // How many in-dispatch repairs one stage run may buy. TWO, and the number has a cause: parseFrameDiff
3117
- // is FAIL-FAST — it throws on the first bad severity (frame-diff-model.mjs:66) before it ever collects
3118
- // the undispatchable directives (:100) — so ONE artifact carrying both defects surfaces them
3195
+ // is FAIL-FAST — it throws on the first bad severity (parseFrameDiff() in frame-diff-model.mjs) before it ever collects
3196
+ // the undispatchable directives — so ONE artifact carrying both defects surfaces them
3119
3197
  // SEQUENTIALLY. That is exactly what the 08-02 frame-diff ladder did (severity on a1, undispatchable
3120
3198
  // on a2), and a cap of 1 would have handed the second one straight back to the ladder. The codebase
3121
3199
  // already learned this lesson once at directive granularity: undispatchableThrow batches ALL offenders
@@ -3201,7 +3279,7 @@ const MAX_FORM_REPAIRS = 2;
3201
3279
  // issue. The coverage-judgment family was COLD-ONLY — WARM_ELIGIBLE_RE carried seven coverage STRUCTURE
3202
3280
  // tokens and not one `coverage_clean_*` — so every retry re-dispatched a fresh session that re-read a
3203
3281
  // 1.9 MB band and re-derived a 160 KB document instead of editing it. The stage's own measured profile
3204
- // (repair-contract.mjs:10-18) is 105,747 out FAIL → 137,519 out FAIL → 36,362 out PASS, and the attempt
3282
+ // (repair-contract.mjs, 'WHY THIS EXISTS') is 105,747 out FAIL → 137,519 out FAIL → 36,362 out PASS, and the attempt
3205
3283
  // that passed is the one that PATCHED. A cold ladder never patches.
3206
3284
  // WHY THEY ARE SAFE TO WARM, on the same argument made one gate over: findCoverageFormViolations
3207
3285
  // emits both tokens only over rows the DRIVER wrote from the frozen plan and the plan-execution
@@ -3604,12 +3682,16 @@ export function warmPatchMessage(lastFail, expectFile, { supplementalLane = fals
3604
3682
  * THE COUNTS A TEST ROUND READS PER STAGE, on both engines, spread into `toolGauge`. `toolCallsRefused`:
3605
3683
  * calls the program refused (Claude's own denials; Codex's tool-server calls refused before reaching their
3606
3684
  * server). `commandToolCalls`: calls to a tool that runs a command, which no Claude stage is offered, so
3607
- * zero is the expected reading; Codex keeps its shell and reports null. `mcpToolCalls`: the tool-server
3608
- * calls Codex completed, its only count of calls made, since its `toolCalls` stays null. Null, as in
3685
+ * zero is the expected reading there; Codex keeps its shell and counts the commands it ran. `mcpToolCalls`: the tool-server
3686
+ * calls Codex completed, its only count of calls made, since its `toolCalls` stays null.
3687
+ * `toolCallsErroredByName`: the tool-server calls that came back as an error, by tool name, so a stage that
3688
+ * had a call refused and made it again says so where its row otherwise reads ok on one attempt. Null, as in
3609
3689
  * `toolGauge`, is "this engine does not report", never zero. RECORDING ONLY. It sits at the end of this
3610
3690
  * file so that adding it moved no line another file cites.
3611
3691
  */
3612
3692
  function toolCallCounts(turn) {
3613
3693
  const n = (v) => (Number.isFinite(v) && v >= 0 ? v : null);
3614
- return { toolCallsRefused: n(turn?.toolCallsRefused), commandToolCalls: n(turn?.commandToolCalls), mcpToolCalls: n(turn?.mcpToolCalls) };
3694
+ const errored = turn?.toolCallsErroredByName;
3695
+ return { toolCallsRefused: n(turn?.toolCallsRefused), commandToolCalls: n(turn?.commandToolCalls), mcpToolCalls: n(turn?.mcpToolCalls),
3696
+ toolCallsErroredByName: errored && typeof errored === "object" && !Array.isArray(errored) ? { ...errored } : null };
3615
3697
  }
package/driver/jx.mjs CHANGED
@@ -59,7 +59,7 @@ export const jxFoldLaneRecord = ({ cause = null, ...rest } = {}) => ({
59
59
  // silent in the fold. One record spoke and the other could not.
60
60
  //
61
61
  // TWO FIELDS, BECAUSE ONE CANNOT CARRY BOTH JOBS.
62
- // `executes` is a SCALAR STRING, because reference-score.mjs:1103 routes it into the same slot as the
62
+ // `executes` is a SCALAR STRING, because readJxLanes() in reference-score.mjs routes it into the same slot as the
63
63
  // per-lane `executes` string and scripts/score.mjs interpolates it bare — an object prints
64
64
  // [object Object] there. It names the slices that RAN, "+"-joined, and is NEVER "" (the e2e `exists`
65
65
  // op passes on an empty string, so an all-failed run would have gone green on a statement of nothing).
@@ -272,7 +272,7 @@ export function reviewEvidence(merged, about = {}) {
272
272
  if (at?.engineOwned || !Object.hasOwn(ADDRESSABLE, at?.field)) continue;
273
273
  const flags = plainRegisterFlags(text, about);
274
274
  if (!flags.length) continue;
275
- rows.push({ at, where, says: flags.map((f) => f.say) });
275
+ rows.push({ at: offeredAddress(at), where, says: flags.map((f) => f.say) });
276
276
  }
277
277
  const marks = (about.marks ?? []).filter(Boolean), owners = (about.owners ?? []).filter(Boolean);
278
278
  const ownerNote = owners.length ? ` The ${owners.length} owner name(s) from the record were also removed.` : "";
@@ -287,6 +287,60 @@ export function reviewEvidenceLines({ rows }) {
287
287
  return rows.map(({ at, where, says }) => `- ${where} — address ${JSON.stringify(at)} — ${says.join(" ")}`);
288
288
  }
289
289
 
290
+ /**
291
+ * The lines a reader meets first, each one the pass may write, with the address that names it.
292
+ *
293
+ * Handed to the reviewer on a record the driver's read flagged nowhere (owner ruling 2026-10-10). Its
294
+ * first question is the client's, and it can rewrite toward that answer, or decline, only at an address
295
+ * it was given: the tool refuses a composed one. The set is the one the flagged table is drawn from,
296
+ * without the flag, so the engine's own caveats and any field the applier cannot write stay out here too.
297
+ */
298
+ export function reviewFirstReadRows(merged) {
299
+ return knockoutVisibleProse(merged)
300
+ .filter(({ at }) => !at?.engineOwned && Object.hasOwn(ADDRESSABLE, at?.field))
301
+ .map(({ at, where }) => ({ at: offeredAddress(at), where }));
302
+ }
303
+
304
+ /**
305
+ * An address as the seat is told to copy it: the field and the keys that field takes, nothing else.
306
+ *
307
+ * The walk marks a standing caveat with `engineOwned`, true or false, to decide what is offered at all.
308
+ * The grammar refuses any key a field does not take, so an offered address that carried the mark would
309
+ * be refused when copied verbatim, as the tool tells the seat to copy it.
310
+ */
311
+ export function offeredAddress(at) {
312
+ const out = { field: at.field };
313
+ for (const k of ADDRESSABLE[at.field] ?? []) if (at[k] !== undefined) out[k] = at[k];
314
+ return out;
315
+ }
316
+
317
+ /** One line each, the address quoted for the seat to copy back. */
318
+ export function reviewFirstReadLines(rows) {
319
+ return rows.map(({ at, where }) => `- ${where} — address ${JSON.stringify(at)}`);
320
+ }
321
+
322
+ /**
323
+ * What the reviewing pass hands the reviewer, decided before any turn is spent. PURE.
324
+ *
325
+ * EVERY KNOCKOUT IS REVIEWED. Flagged lines go as the measured table. A record flagged nowhere hands
326
+ * over its first-read lines instead, so the client's question is put on every record. Only a record with
327
+ * no such line at all is not dispatched, because there would be nothing to read and no address to answer.
328
+ */
329
+ export function reviewDispatch(merged, plan) {
330
+ const about = reviewAbout(merged, plan);
331
+ const evidence = reviewEvidence(merged, about);
332
+ const firstRead = evidence.rows.length ? [] : reviewFirstReadRows(merged);
333
+ return {
334
+ about, evidence, flagged: evidence.rows.length, listed: firstRead.length,
335
+ dispatch: evidence.rows.length > 0 || firstRead.length > 0,
336
+ msgCtx: {
337
+ evidenceLines: reviewEvidenceLines(evidence),
338
+ firstReadLines: reviewFirstReadLines(firstRead),
339
+ exclusionNote: evidence.exclusionNote,
340
+ },
341
+ };
342
+ }
343
+
290
344
  /**
291
345
  * The `about` a knockout run is blind to: every name in the batch, and every owner on the record.
292
346
  *
@@ -129,6 +129,10 @@ export function parseNamedBand(raw) {
129
129
  // truncate the covered_by pointers with the reason's 400-char cap. Old bands carry neither key.
130
130
  ...(b.class_counts && typeof b.class_counts === "object" && !Array.isArray(b.class_counts) ? { class_counts: b.class_counts } : {}),
131
131
  ...(Array.isArray(b.covered_by) && b.covered_by.length ? { covered_by: b.covered_by } : {}),
132
+ // what the total counts, when the register said — carried like the counts it qualifies
133
+ ...(typeof b.total_counts === "string" ? { total_counts: b.total_counts } : {}),
134
+ // the register's floor when it answered "at least N": the total above stays null beside it
135
+ ...(Number.isFinite(b.total_floor) ? { total_floor: b.total_floor } : {}),
132
136
  // — THE REFUSAL STAMP SURVIVES THE PROJECTION. `execute-plan.mjs` writes an errored slice
133
137
  // as `{state:"incomplete", total_hits:0, fetched:0, error:true}` and says why in its own header:
134
138
  // "the error:true stamp (a provider error is never confusable with a sanctioned crowd)". The
@@ -31,7 +31,7 @@
31
31
  //
32
32
  // PURE (no node imports) so it tests offline, like coverage-ledger.mjs / named-band.mjs.
33
33
 
34
- import { recordQids } from "./named-band.mjs";
34
+ import { recordQids } from "./named-band.mjs"; import { moreThan } from "../shared/register-floor.mjs";
35
35
 
36
36
  /** Owner-slice states. `enumerated` is the only one that can carry a negative. */
37
37
  export const OWNER_SLICE_STATES = ["enumerated", "crowd", "not-run", "missing"];
@@ -95,7 +95,9 @@ export function deriveOwnerScreen(planEntries, blocks, bandRecords, {
95
95
  terms: Array.isArray(e.terms) ? e.terms.map(str) : (e.term != null ? [str(e.term)] : []),
96
96
  nice_classes: (e.nice_classes ?? []).map(str),
97
97
  state,
98
- total_hits: Number(block?.total_hits) || 0,
98
+ // a register's floor ("at least N") is no count: the total stays null and the floor rides beside it
99
+ total_hits: Number.isFinite(block?.total_floor) ? null : Number(block?.total_hits) || 0,
100
+ ...(Number.isFinite(block?.total_floor) ? { total_floor: block.total_floor } : {}),
99
101
  records: recs.length,
100
102
  record_ids: recs.slice(0, recordCap).map((r) => str(r.record_id)).filter(Boolean),
101
103
  reason: sliceReason(e, block, deferred.get(qid) ?? null, capabilities),
@@ -103,7 +105,8 @@ export function deriveOwnerScreen(planEntries, blocks, bandRecords, {
103
105
  // executor's own descriptor says so, and the screen keeps them apart on the same row.
104
106
  portfolio: countEntry ? {
105
107
  qid: str(countEntry.qid),
106
- total_hits: Number(countBlock?.total_hits) || 0,
108
+ total_hits: Number.isFinite(countBlock?.total_floor) ? null : Number(countBlock?.total_hits) || 0,
109
+ ...(Number.isFinite(countBlock?.total_floor) ? { total_floor: countBlock.total_floor } : {}),
107
110
  counted: !!countBlock && countBlock.error !== true,
108
111
  } : null,
109
112
  });
@@ -173,7 +176,7 @@ export function ownerScreenNegative(screen) {
173
176
  ];
174
177
  if (hit.length) parts.push(`Enumerated WITH records: ${hit.map((o) => `${name(o)} — ${o.records} record(s)`).join("; ")}.`);
175
178
  if (zero.length) parts.push(`Enumerated with zero records — a negative may be stated for these owners and ONLY these: ${zero.map(name).join("; ")}.`);
176
- if (crowd.length) parts.push(`Count-only (NOT a clean — the slice was never enumerated): ${crowd.map((o) => `${name(o)} — ${o.total_hits} hit(s)`).join("; ")}.`);
179
+ if (crowd.length) parts.push(`Count-only (NOT a clean — the slice was never enumerated): ${crowd.map((o) => `${name(o)} — ${Number.isFinite(o.total_floor) ? moreThan(o.total_floor) : o.total_hits} hit(s)`).join("; ")}.`);
177
180
  if (notRun.length) parts.push(`NOT RUN — no negative may be stated for these owners; the gap is disclosed, never a clean: ${notRun.map((o) => `${name(o)}: ${o.reason ?? "capability absent"}`).join("; ")}.`);
178
181
  if (missing.length) parts.push(`Unaccounted for: ${missing.map(name).join("; ")}.`);
179
182
  return parts.join(" ");
@@ -2,7 +2,7 @@
2
2
  "name": "clearotron-driver",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.4.0",
5
+ "version": "0.4.1-beta.1",
6
6
  "license": "AGPL-3.0-only",
7
7
  "description": "Deterministic driver for the trademark clearance workflow: orchestration in code (fan-out, fan-in barrier, gating, retries); the model does judgment leaves only, through a reasoning CLI spawned per stage.",
8
8
  "engines": {
package/driver/phase0.mjs CHANGED
@@ -76,15 +76,26 @@ const boundName = (s) => String(s).slice(0, PLAN_MAX_NAME_LENGTH);
76
76
  // Refless jobs (ref/tmp absent — allowed since the intake relaxation) get `noref<6-hex>-<mark>`: the hash is
77
77
  // of job.id, the sanitized email message-id and the queue's dedup key, so the slug stays unique across
78
78
  // same-name marks AND stable across webhook re-delivery + resume. Refed jobs are byte-identical to before.
79
+ //
80
+ // THE SLUG IS THE FIRST PART OF THE RUN ID, AND THE RUN ID IS A URL PATH SEGMENT IN ASCII. Every route that
81
+ // serves a run, and every link a delivery carries, spells the run id as `[A-Za-z0-9._-]`
82
+ // (publish/index.mjs RUN_SEGMENT_RE, the portal's own gate), and refuses anything else: a link the portal
83
+ // would refuse is not built at all. `kebab` keeps every Unicode letter, so a mark written in Greek,
84
+ // Cyrillic, Han or with a letter such as Ł gave a run id no route accepts, and its run was delivered with
85
+ // no report link on any surface. So the mark's part of the slug keeps only what the routes accept. A name
86
+ // whose kebab is already ASCII is untouched, byte for byte, which is every run id minted before this; a
87
+ // name with nothing ASCII left reads `mark`, the knockout slug's own word for that case. The reference
88
+ // prefix and the codename keep the run id unique, as they always have: the mark only makes it readable.
89
+ const asciiMark = (slug) => slug.replace(/[^a-z0-9-]+/g, "").replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "") || "mark";
79
90
  export function deriveSlug(job) {
80
91
  const raw = boundName(String(job.ref ?? job.tmp ?? "").toLowerCase().replace(/[^a-z0-9]/g, ""));
81
92
  const mark = kebab(boundName(job.markName ?? job.name ?? job.marks?.[0]?.name ?? "mark"));
82
93
  if (!raw) {
83
94
  const h = createHash("sha256").update(String(job.id ?? mark)).digest("hex").slice(0, 6);
84
- return `noref${h}-${mark}`;
95
+ return `noref${h}-${asciiMark(mark)}`;
85
96
  }
86
97
  const tmp = raw.startsWith("tmp") ? raw : `tmp${raw}`;
87
- return `${tmp}-${mark}`;
98
+ return `${tmp}-${asciiMark(mark)}`;
88
99
  }
89
100
 
90
101
  // Calendar date in Europe/Zurich (the firm's timezone), YYYY-MM-DD.
@@ -57,7 +57,7 @@ import { registerUnavailableOffices } from "./register-unreachable.mjs";
57
57
  import { runRecordLogPath } from "../providers/_shared/ledger-path.mjs"; // — this run's record log
58
58
  import { beginAnswerMemory, endAnswerMemory } from "../providers/_shared/answer-memory.mjs";
59
59
  import { validators as koValidators, validateMergedFindings, worstBand, registerSurfacedFilings, raterCaveats, SURVIVOR_BOUNDARY_RE } from "./verify-knockout.mjs";
60
- import { reviewAbout, reviewEvidence, reviewEvidenceLines, applyKnockoutReview, knockoutReviewFile } from "./knockout-review-record.mjs";
60
+ import { reviewDispatch, applyKnockoutReview, knockoutReviewFile } from "./knockout-review-record.mjs";
61
61
  import { stripNextStepSections } from "./knockout-next-step.mjs";
62
62
  import { publishKnockout, composeKnockoutEmail } from "./publish/knockout.mjs";
63
63
  import { writeRunStatus, readRunStatus, rollupStatus, atomicWrite, identitySeed } from "./progress.mjs"; // — the identity seed is shared; the stepper is not
@@ -188,22 +188,24 @@ export function readBackLadder(ctx, sidecarPath, { minted }) { // exported for
188
188
  * pass is presentation. A refusal is never a pass, and "no report" is a product failure — so the last
189
189
  * thing added to the delivery path is the last thing that should be able to stop it.
190
190
  *
191
- * THE EMPTY CASE IS NOT A FAILURE AND IS NOT SILENT. A batch whose default-visible lines are all already
192
- * plain has nothing to dispatch, and paying for a turn to be told so is waste. It is logged with the
193
- * count, so "nothing was flagged" and "the pass never ran" are different rows in the journal rather than
194
- * one absent one.
191
+ * EVERY KNOCKOUT IS REVIEWED, by the owner's ruling of 2026-10-10. The reviewer's first question is the
192
+ * client's, and a record whose wording the driver's read flagged nowhere still gets it: the reviewer is
193
+ * handed the lines a reader meets first, with their addresses, and rewrites toward the client's question
194
+ * or declines at one of them. Only a record with no such line at all is not dispatched. That is logged
195
+ * with its counts, so "there was nothing to read" and "the pass never ran" are different rows in the
196
+ * journal rather than one absent one.
195
197
  */
196
198
  async function knockoutReviewingPass({ ctx, run, K, merged, plan }) {
197
- const about = reviewAbout(merged, plan);
198
- const evidence = reviewEvidence(merged, about);
199
- if (!evidence.rows.length) {
200
- runLog(run.runDir, { event: "knockout-review", outcome: "nothing-flagged", flagged: 0,
199
+ const d = reviewDispatch(merged, plan);
200
+ const { about } = d;
201
+ if (!d.dispatch) {
202
+ runLog(run.runDir, { event: "knockout-review", outcome: "nothing-flagged", flagged: 0, listed: 0,
201
203
  marks: merged.marks.length, excludedMarks: about.marks.length, excludedOwners: about.owners.length });
202
204
  return merged;
203
205
  }
204
206
 
205
207
  const keep = (outcome, detail) => {
206
- runLog(run.runDir, { event: "knockout-review", outcome, flagged: evidence.rows.length, applied: 0,
208
+ runLog(run.runDir, { event: "knockout-review", outcome, flagged: d.flagged, listed: d.listed, applied: 0,
207
209
  delivered: "the rated record, unrewritten", ...detail });
208
210
  note(`reviewing pass: ${outcome} — the rated record ships unrewritten`);
209
211
  return merged;
@@ -211,7 +213,7 @@ async function knockoutReviewingPass({ ctx, run, K, merged, plan }) {
211
213
 
212
214
  try {
213
215
  await koStage("knockout-review", ctx, {
214
- msgCtx: { evidenceLines: reviewEvidenceLines(evidence), exclusionNote: evidence.exclusionNote },
216
+ msgCtx: d.msgCtx,
215
217
  });
216
218
  } catch (e) {
217
219
  return keep("stage-failed", { reason: String(e?.message ?? e).slice(0, 200) });
@@ -228,7 +230,7 @@ async function knockoutReviewingPass({ ctx, run, K, merged, plan }) {
228
230
  if (!mv.ok) return keep("rewrite-refused-by-the-merged-gate", { failures: mv.failures.slice(0, 5) });
229
231
 
230
232
  atomicWrite(K.findings, JSON.stringify(doc, null, 2) + "\n");
231
- runLog(run.runDir, { event: "knockout-review", outcome: "applied", flagged: evidence.rows.length,
233
+ runLog(run.runDir, { event: "knockout-review", outcome: "applied", flagged: d.flagged, listed: d.listed,
232
234
  applied: receipt.applied, declined: receipt.declined,
233
235
  unresolved: receipt.unresolved.length, refused: receipt.refused.length,
234
236
  excludedMarks: about.marks.length, excludedOwners: about.owners.length });
@@ -1209,7 +1211,7 @@ export async function knockoutInner(ctx, job, opts = {}) {
1209
1211
  // ONE LINE, N LINKS. This said "Report: <one url>" and on a batch that url is now null, which would
1210
1212
  // have read "Report: null" — the fail-visible shape doing its job, and still not a line to send. A
1211
1213
  // batch names every report it produced, in the order the names were ordered.
1212
- whatsappText: `✅ Knockout screen${refTag} of ${nMarks} mark(s) is done — worst band ${overall}. `
1214
+ whatsappText: `✅ Knockout screen${refTag} of ${nMarks} mark(s) is done — ${nMarks === 1 ? "rating" : "highest rating"} ${overall}. `
1213
1215
  + (published.reports.length === 1
1214
1216
  ? `Report: ${published.reports[0].url}`
1215
1217
  : `Reports: ${published.reports.map((r) => `${r.mark} ${r.url}`).join(" · ")}`),