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
@@ -69,7 +69,14 @@ const readJsonl = (p) => {
69
69
  } catch { return []; }
70
70
  };
71
71
 
72
- /** The stage's own last attempt row, or null when the stage wrote none. */
72
+ /**
73
+ * The stage's own last attempt row, or null when the stage wrote none.
74
+ *
75
+ * A PART IS DEGRADED WHEN ITS STAGE FAILED, and that is the row's `fail`, never its `ok`. The two said the
76
+ * same thing until the attempt row learned to record a session that reached an error result and was
77
+ * recovered (engine/session-record.mjs): such an attempt is not ok, and its stage still delivered. Whether
78
+ * that should reach a client's report is a ruling nobody has made, so this reads what it always meant.
79
+ */
73
80
  export function lastAttempt(runDir, stage) {
74
81
  const rows = readJsonl(driverDir(runDir, `${stage}.jsonl`))
75
82
  .filter((r) => Number.isFinite(Number(r?.attempt)) && typeof r.ok === "boolean");
@@ -93,7 +100,7 @@ function courtDecisions(runDir, log) {
93
100
  if (lastEvent(log, ["case-law-decision"])?.run !== true) return null;
94
101
  const last = lastAttempt(runDir, "case-law");
95
102
  const part = { part: "court-decisions", name: PART_NAMES.courtDecisions };
96
- if (last?.ok === false) return { ...part, reason: reasonFor(last.fail), cause: `the case-law stage ended failed: ${last.fail ?? "no cause recorded"}` };
103
+ if (last?.fail) return { ...part, reason: reasonFor(last.fail), cause: `the case-law stage ended failed: ${last.fail}` };
97
104
  let text;
98
105
  try { text = readFileSync(join(runDir, "case-law-findings.md"), "utf8"); } catch { text = null; }
99
106
  if (text === null) return { ...part, reason: NOT_COMPLETED, cause: "case-law-findings.md is absent" };
@@ -119,7 +126,7 @@ const failedStep = (log, ok, failed) => {
119
126
  };
120
127
  const stageEnded = (runDir, stage) => {
121
128
  const last = lastAttempt(runDir, stage);
122
- return last?.ok === false ? last : null;
129
+ return last?.fail ? last : null;
123
130
  };
124
131
 
125
132
  // The register's own steps, each of which the run survives without: the band's shape (the floors and the
@@ -380,7 +380,8 @@ export function startPortal({ poolRoot = null, port = 18899, host = "127.0.0.1",
380
380
  // "knockout" enqueued a job naming no product at all and ran whatever the account default or the
381
381
  // request's own territories named — a silent product substitution at a live door, from a control
382
382
  // that said otherwise. The menu is gone; refusing the field as well is what stops a stale tab, a
383
- // bookmarked script or a copied curl from doing it quietly. products.mjs:293 states the rule.
383
+ // bookmarked script or a copied curl from doing it quietly.
384
+ // SEARCH_LEVEL_NOT_A_REQUEST declared in products.mjs states the rule.
384
385
  //
385
386
  // THE SENTENCE IS NO LONGER WRITTEN HERE. This door had the only searchLevel refusal in the
386
387
  // system, in its own words — so the same request was refused here and accepted at the other
@@ -46,6 +46,25 @@ export function dispatchFileName(stage, attempt, repair = 0) {
46
46
  return `${stage}.attempt${attempt}${repair ? `.repair${repair}` : ""}.${DISPATCH_SUFFIX}`;
47
47
  }
48
48
 
49
+ export const STREAMS_DIR = "streams";
50
+
51
+ /**
52
+ * `_driver/streams/<label>.attempt<N>.jsonl` (or `…repair<M>…`): the raw stream of the session that
53
+ * dispatch started, named as its dispatch record is. The engines write it (engine/session-record.mjs,
54
+ * `streamSink`); this is the one place the name is made. Absolute, through `driverDir`, so a label's
55
+ * colon is written the way the platform can hold it.
56
+ *
57
+ * IN ITS OWN FOLDER, NEVER BESIDE THE JOURNALS. Every `*.jsonl` directly under `_driver/` is read as a
58
+ * stage's attempt journal by name: the token rollup sums its usage rows, the degraded-parts check reads
59
+ * its label off the file name, the integrity check leaves it out as append-only. A raw stream there
60
+ * would be counted as a stage and its usage summed twice. One folder down, no reader of `_driver/`
61
+ * lists it, and the integrity snapshot reads files only.
62
+ */
63
+ export function streamFilePath(runDir, stage, attempt, repair = 0) {
64
+ if (!runDir) return null;
65
+ return join(driverDir(runDir), STREAMS_DIR, driverFileName(`${stage}.attempt${attempt}${repair ? `.repair${repair}` : ""}.jsonl`));
66
+ }
67
+
49
68
  const shaOf = (buf) => createHash("sha256").update(buf).digest("hex").slice(0, 12);
50
69
 
51
70
  // ── — WHAT THE STAGE WAS GRANTED, BESIDE THE SHA THAT PROVES WHAT IT WAS ASKED ────────────────
@@ -119,8 +119,8 @@ export function captureCall(runDir, seq, received, { now = () => new Date().toIS
119
119
  //
120
120
  // THAT PREMISE IS WHY THIS IS SAFE, SO CHECK IT BEFORE MOVING THE SPEC WRITE. Five of the six
121
121
  // doubt-minting sites build an id from an ordinal over a FILTERED list
122
- // (`presence-reconciliation.mjs:182` mints `doubt:presence:<sheet>:<n>` after three `continue` filters,
123
- // with `status: "open"` hardcoded beside it); `doubt-ledger.mjs:232` is the exception and the template,
122
+ // (`presence-reconciliation.mjs:182 mintPresenceDoubts` mints `doubt:presence:<sheet>:<n>` after three `continue` filters,
123
+ // with `status: "open"` hardcoded beside it); `doubt-ledger.mjs:232 mintContradictionDoubts` is the exception and the template,
124
124
  // putting the candidate name in the id and using the ordinal only as a tiebreaker. If the spec is ever
125
125
  // minted per attempt instead of per stage, those ordinals can shift between attempts and this serve
126
126
  // starts handing back a verdict about a different subject. Nothing else in this file would notice.
@@ -1337,6 +1337,10 @@ export const PROVIDERS = {
1337
1337
  return { ok: true,
1338
1338
  total: Number.isFinite(p.total_hits) ? p.total_hits : null,
1339
1339
  ...(p.total_approximate === true ? { approximate: true, floor: Number.isFinite(p.total_floor) ? p.total_floor : null } : {}),
1340
+ // The register's own notes on this answer, carried whole as the clearance sweep carries them —
1341
+ // `expanded_fallback` among them, which says the total counts records rather than marks. The
1342
+ // knockout listing dropped them, so a knockout run could not show what a clearance run shows.
1343
+ ...(Array.isArray(p.warnings) && p.warnings.length ? { warnings: p.warnings } : {}),
1340
1344
  records: (Array.isArray(p.results) ? p.results : []).map((row) => {
1341
1345
  const rec = row?.raw && typeof row.raw === "object" ? core.normalizeRecord(row.raw, row.office || null) : null;
1342
1346
  return {
@@ -1355,6 +1359,9 @@ export const PROVIDERS = {
1355
1359
  filing_route: rec?.filingRoute ?? null,
1356
1360
  // No page per record at Signa (hasPublicRecordUrl: false above), and none is made up here.
1357
1361
  record_url: null,
1362
+ // The office's own page as the register gives it, for publish to fall back on where it cannot
1363
+ // address the office's page from the numbers above (office-record-links.mjs).
1364
+ office_url: rec?.officeUrl ?? null,
1358
1365
  };
1359
1366
  }) };
1360
1367
  } catch (e) { return { ok: false, records: null, reason: `listRecords threw: ${e.message}` }; }
@@ -2079,12 +2086,31 @@ export const ENGINE_BINARIES = {
2079
2086
  // and a pinned id was refused outright ("version 2.1.280 or newer is required"), measured 2026-09-22.
2080
2087
  // A floor that only asks for a program that starts is a floor that passes a machine this engine
2081
2088
  // cannot run the current generation on.
2082
- package: "@anthropic-ai/claude-code", floor: "2.1.284",
2089
+ //
2090
+ // THE HAIKU TIER CROSSED LATER, when Claude Haiku 5.5 joined the generation. Measured 2026-10-09 by a
2091
+ // one-word `-p` turn per version on the test box's own sign-in, reading the model the turn reports in
2092
+ // `modelUsage`:
2093
+ //
2094
+ // version --model haiku
2095
+ // 2.1.285 claude-haiku-4-5-20251001
2096
+ // 2.1.287 claude-haiku-4-5-20251001
2097
+ // 2.1.288 claude-haiku-4-5-20251001
2098
+ // 2.1.289 claude-haiku-4-5-20251001
2099
+ // 2.1.290 claude-haiku-4-5-20251001
2100
+ // 2.1.292 claude-haiku-4-5-20251001
2101
+ // 2.1.293 claude-haiku-5-5
2102
+ // 2.1.294 claude-haiku-5-5
2103
+ // 2.1.295 claude-haiku-5-5
2104
+ //
2105
+ // 2.1.295 still serves claude-opus-5-5 and claude-sonnet-5-5. The floor moves to 2.1.293 for the reason
2106
+ // it moved to 2.1.284: a stage that asks for haiku is asking for the current haiku.
2107
+ package: "@anthropic-ai/claude-code", floor: "2.1.293",
2083
2108
  // WHAT THE INSTALL TAKES ON DISK, in MB, which setup states before it asks to install. MEASURED, not
2084
2109
  // declared by the vendor: the engines folder after a fresh install of this package into an empty
2085
- // folder, on npm 10.9.8 and on 11.19.1, 2026-09-14. A later release can be larger or smaller, so setup
2086
- // says "about". Re-measure when the floor moves.
2087
- installMB: 233,
2110
+ // folder. Re-measured when the floor moved to 2.1.293: 242 MB for a fresh install of 2.1.293 itself,
2111
+ // on npm 10.9.9, 2026-10-09. A later release can be larger or smaller, so setup says "about".
2112
+ // Re-measure when the floor moves.
2113
+ installMB: 242,
2088
2114
  // The licence setup states wherever it tells a reader what they are about to install or use, as the
2089
2115
  // vendor's package declares it: "SEE LICENSE IN README.md", Anthropic's own terms.
2090
2116
  licence: "proprietary third-party software",
@@ -2110,8 +2136,15 @@ export const ENGINE_BINARIES = {
2110
2136
  vendor: "OpenAI",
2111
2137
  product: "Codex",
2112
2138
  env: "CLEAROTRON_CODEX_PATH", fallback: "codex",
2113
- package: "@openai/codex", floor: "0.154.0",
2114
- installMB: 324, // measured the same way and on the same day as Claude's, above
2139
+ // THE FLOOR IS THE OLDEST RELEASE THAT ACCEPTS THE JUDGMENT TIER'S MODEL on a ChatGPT sign-in, which
2140
+ // is how a subscription bills. Measured 2026-10-09 with a one-word `codex exec` turn per version on the
2141
+ // test box: `gpt-6.1-sol` is refused with a 400 on 0.156.1, 0.157.0 and 0.158.0 ("not supported when
2142
+ // using Codex with a ChatGPT account") and accepted on 0.159.0, 0.160.0, 0.161.0 and 0.162.0;
2143
+ // `gpt-6-luna` is accepted from 0.156.1. Below the floor every clearance stops at its first
2144
+ // judgment-tier stage (the tiers are in engine/openai-agent.mjs). The old floor, 0.154.0, was the
2145
+ // permissions feature's (engine/mcp/codex-config.mjs).
2146
+ package: "@openai/codex", floor: "0.159.0",
2147
+ installMB: 424, // re-measured the same way as Claude's, above: a fresh install of 0.159.0 itself, 2026-10-09
2115
2148
  licence: "third-party software under the Apache-2.0 licence", // the package's own "license" field
2116
2149
  label: "OpenAI — each stage runs as a headless `codex exec` turn",
2117
2150
  module: "engine/openai-agent.mjs", adapter: "openaiAgentEngine",
@@ -37,7 +37,7 @@ export function isFinding(heading, block) {
37
37
 
38
38
  // Distinctive word anchors (length ≥4, non-stopword) from the owner / element / goods / sector /
39
39
  // jurisdiction / mark vocabulary; multi-word anchors contribute their significant words (so a finding
40
- // mentioning "Plesner" anchors against owner "Plesner Advokatpartnerselskab").
40
+ // mentioning "Varnholt" anchors against owner "Varnholt Advokatpartnerselskab").
41
41
  function anchorTerms(anchors = {}) {
42
42
  const raw = [
43
43
  ...(anchors.owners ?? []), ...(anchors.dominantElements ?? []), ...(anchors.goodsServices ?? []),
@@ -36,7 +36,9 @@ ladder consumes it without knowing which engine produced it ([gateway.mjs](../ga
36
36
  laneWaitMs: number,
37
37
  json: object | null, // synthesized envelope in the classifier's shape; every downstream
38
38
  // classifier (payloadText, status, embedded-fallback, timeout,
39
- // lane-wedge) reads this and works unchanged across engines
39
+ // lane-wedge) reads this and works unchanged across engines.
40
+ // It also carries how the session ended: terminalReason,
41
+ // apiErrorStatus and errorText (see `session` below)
40
42
  usage: Usage | null, // canonical shape below; null = no tokens accounted (e.g. stall)
41
43
  sessionRef: string | null, // opaque resume handle (claude session_id | codex thread_id)
42
44
  modelWire: string | null, // MODEL GAUGE — the served model id this turn observed (§3);
@@ -57,9 +59,47 @@ ladder consumes it without knowing which engine produced it ([gateway.mjs](../ga
57
59
  // opened; [] = recorded "read nothing"; key absent = engine cannot
58
60
  // observe reads (openai-agent) → gateway journals `reads: null`
59
61
  readsTruncated: boolean | absent, // true = the path cap dropped at least one further distinct file
62
+ toolCallsByName: object | null, // the turn's tool calls by tool name; an MCP call is
63
+ // `mcp__<server>__<tool>` on both engines; null = no session ran
64
+ session: Session | null, // what the session went through (below); null = no session ran
65
+ stream: { file, present, bytes, reason? } | null,
66
+ // the raw stream, written to the file the caller named
67
+ // (the gateway names _driver/streams/<label>.attempt<N>.jsonl)
60
68
  }
61
69
  ```
62
70
 
71
+ ### `session` — what the session went through
72
+
73
+ Both engines used to keep only the LAST result of a session, so a session the vendor's classifier cut
74
+ off mid-answer, or refused and a restart recovered, ended in a successful result and was recorded as
75
+ clean. `session` keeps every event of that kind, as the program wrote it, with milliseconds since the
76
+ spawn ([engine/session-record.mjs](session-record.mjs)):
77
+
78
+ ```
79
+ Session { interrupted, starts, results: [{atMs, index, subtype, isError, stopReason, terminalReason, origin}],
80
+ classifierCuts: [{atMs, synthetic}], refusals: [{atMs, category, model}], refusalStops }
81
+ ```
82
+
83
+ It carries kinds, codes and times, never the program's words. The retry ladder still reads the last
84
+ result alone; the gateway journals the attempt as `ok: false` when any result carried `is_error`, while
85
+ `fail` — what every reader of "did the stage fail" reads — is unchanged. Codex states no cut or refusal
86
+ on its stream, so on that engine `results` lists every `turn.completed`, `turn.failed` and `error`.
87
+
88
+ How the session ended rides the envelope, and the gateway writes it on every attempt and repair row,
89
+ null where the engine said nothing:
90
+
91
+ ```
92
+ terminalReason the kind of ending as the program states it (claude `terminal_reason`); null on codex
93
+ apiErrorStatus the vendor's status when it gave one (claude `api_error_status`); null on codex
94
+ errorText the program's own words for the error, only when the session ended in one, cut to
95
+ 300 characters: claude's result text when that result has `is_error`; codex's
96
+ `turn.failed` message, or its stream error when the turn never completed
97
+ ```
98
+
99
+ These are the one place the record holds the program's words: an expired sign-in otherwise reads as
100
+ `stop_sequence` and nothing else. `stopReason` stays verbatim beside them, and none of the three moves a
101
+ verdict.
102
+
63
103
  `runStage()` folds that into what the pipeline consumes — `{ok, json, attempts, text, sessionKey, …}` —
64
104
  and journals usage/wall/status on the way past.
65
105
 
@@ -26,6 +26,7 @@ import { authorityTrees } from "../authority-trees.mjs";
26
26
  import { recordEngineChild, clearEngineChild } from "./child-record.mjs"; //
27
27
  import { engineEnv } from "./engine-env.mjs"; // — the program's environment, by list (see spawnEnv)
28
28
  import { engineSpawn, spawnsDetached, killWindowsTreeNow } from "./engine-spawn.mjs"; // one answer to how a turn starts and ends, on every platform
29
+ import { newSessionRecord, noteClaudeEvent, sessionSummary, countTool, streamSink, claudeEnding } from "./session-record.mjs"; // what the session went through, beside how it ended
29
30
 
30
31
  // Read per-call (not module-level) so tests can drive a short stall timeout / a mock binary.
31
32
  // ONE place knows how to find the program (driver.config.mjs resolveEngineProgram): the explicit setting,
@@ -514,6 +515,9 @@ export function synthesizeEnvelope({ resultEvent, usage, killed }) {
514
515
  status: ok ? "ok" : (killed ? "timeout" : "error"),
515
516
  result: { meta: { agentMeta: { usage } }, payloads: [{ text }] },
516
517
  summary: r?.subtype, runId: r?.session_id, stopReason: r?.stop_reason,
518
+ // …and beside the stop reason, what ended the session and, on an error, the program's own words for it
519
+ // (session-record.mjs, claudeEnding). Recorded only: `ok` above reads exactly what it read before.
520
+ ...claudeEnding(r),
517
521
  };
518
522
  }
519
523
 
@@ -524,7 +528,7 @@ export const anthropicAgentEngine = {
524
528
  // doctrine tree, the profile store and `<runDir>/_driver/` at the moment of the write. That is a real
525
529
  // boundary and this engine has it.
526
530
  writeBoundary: "enforced",
527
- async runTurn({ message, model, thinking, timeoutSec, resumeRef, mcpConfig, allowedTools, cwd, skillsDir, skillsGrantRoots, profilesDir, resolveSkill, runDir, seatWrites = null, grantDispatch = null, stallSec, progressFiles } = {}) {
531
+ async runTurn({ message, model, thinking, timeoutSec, resumeRef, mcpConfig, allowedTools, cwd, skillsDir, skillsGrantRoots, profilesDir, resolveSkill, runDir, seatWrites = null, grantDispatch = null, stallSec, progressFiles, streamFile = null } = {}) {
528
532
  if (!message) throw new Error("anthropic-agent.runTurn: message is required");
529
533
  const { args, input, grantNote } = buildClaudeArgs({ message, model, thinking, resumeRef, mcpConfig, allowedTools, cwd, skillsDir, skillsGrantRoots, profilesDir, resolveSkill, runDir, seatWrites, grantDispatch });
530
534
  // the cross-check speaks. Driver stderr is what an operator reads, and it is where the other
@@ -690,6 +694,24 @@ export const anthropicAgentEngine = {
690
694
  // ("a completed tool result = the agent loop advanced", below). The gap between them is the turn
691
695
  // waiting on tools rather than generating.
692
696
  let toolCalls = 0;
697
+ // …AND THE SAME CALLS BY TOOL NAME, so a tool server's own log can be set against what the model
698
+ // asked for. A name is the program's identifier for a tool (`Read`, `mcp__band__band_lookup`), never
699
+ // an input, so it carries no client text. The values sum to `toolCalls` by construction: one fold
700
+ // per tool_use block, at the one site that counts them.
701
+ const toolCallsByName = Object.create(null);
702
+ // …AND WHICH OF THEM A TOOL SERVER ANSWERED WITH AN ERROR. A refused call the model then re-made used to
703
+ // leave the stage row reading ok on one attempt, as though nothing had happened. Each ask's id names its
704
+ // tool, and its result says whether it came back as an error; nothing else is kept.
705
+ const toolNameById = new Map();
706
+ const toolErrorIds = new Set();
707
+ // WHAT THE SESSION WENT THROUGH, beside how it ended: every result event, every classifier cut,
708
+ // every refusal and every restart, each with when it arrived. See session-record.mjs; the retry
709
+ // policy below still reads the LAST result alone, so this records and decides nothing.
710
+ const session = newSessionRecord();
711
+ // The raw stream, one file per session beside its dispatch (the caller names it). Opened before the
712
+ // first byte can arrive; closed in settle(). A sink that could not open is a null writer whose
713
+ // close() says why.
714
+ const sink = streamSink(streamFile);
693
715
  // HOW MANY OF THOSE ASKED FOR A COMMAND TOOL. The command tools are removed from every stage
694
716
  // (COMMAND_TOOLS), so the number a test round expects here is zero, and a count is what proves it: a
695
717
  // flag in the argv shows what was asked of the program, this shows what the model did. Still a count
@@ -814,6 +836,7 @@ export const anthropicAgentEngine = {
814
836
  let ev;
815
837
  try { ev = JSON.parse(line); } catch { return; /* partial/non-json line */ }
816
838
  sawAnyEvent = true;
839
+ noteClaudeEvent(session, ev, Date.now() - t0);
817
840
  if (ev.type === "result") resultEvent = ev;
818
841
  else if (ev.type === "rate_limit_event") rateLimitEvent = ev;
819
842
  else if (ev.type === "system" && ev.subtype === "init") {
@@ -839,7 +862,8 @@ export const anthropicAgentEngine = {
839
862
  // classification and no recovery park, or the turn never settles with its watchdog already gone.
840
863
  // Same belt-and-braces as the thinking gauge's `?.some?.()` on the line above.
841
864
  for (const b of Array.isArray(ev.message?.content) ? ev.message.content : []) {
842
- if (b?.type === "tool_use") toolCalls++; // #1111 — every tool, not only Read; a COUNT, never a name
865
+ if (b?.type === "tool_use") { toolCalls++; countTool(toolCallsByName, b?.name); } // #1111 — every tool, not only Read; the count and its split by name
866
+ if (b?.type === "tool_use" && b?.id != null) toolNameById.set(String(b.id), String(b?.name ?? "?"));
843
867
  if (b?.type === "tool_use" && COMMAND_TOOLS.includes(b?.name)) commandToolCalls++;
844
868
  if (b?.type === "tool_use" && FILE_WRITE_TOOLS.includes(b?.name) && b?.id != null) writeAsks.add(String(b.id));
845
869
  if (b?.type === "tool_use" && b?.name === "Read" && typeof b?.input?.file_path === "string") {
@@ -873,8 +897,10 @@ export const anthropicAgentEngine = {
873
897
  progress();
874
898
  }
875
899
  else if (ev.type === "user") {
876
- for (const b of Array.isArray(ev.message?.content) ? ev.message.content : [])
900
+ for (const b of Array.isArray(ev.message?.content) ? ev.message.content : []) {
877
901
  if (b?.type === "tool_result" && b?.is_error === true && writeAsks.has(String(b?.tool_use_id))) writesFailed++;
902
+ if (b?.type === "tool_result" && b?.is_error === true && b?.tool_use_id != null) toolErrorIds.add(String(b.tool_use_id));
903
+ }
878
904
  // the result of the ask above. Only counted when an ask is outstanding, so a `user`
879
905
  // event with no preceding tool_use adds nothing rather than charging the turn for a gap it
880
906
  // never spent waiting.
@@ -1052,6 +1078,7 @@ export const anthropicAgentEngine = {
1052
1078
  chunkGapMs = prevChunkAt === null ? 0 : Date.now() - prevChunkAt;
1053
1079
  prevChunkAt = Date.now();
1054
1080
  lastMove = Date.now(); // ANY streamed byte = liveness → resets the stall clock
1081
+ sink.write(d); // the raw stream, byte for byte, before anything parses it
1055
1082
  buf += d.toString();
1056
1083
  let nl;
1057
1084
  while ((nl = buf.indexOf("\n")) >= 0) { parseLine(buf.slice(0, nl)); buf = buf.slice(nl + 1); }
@@ -1074,7 +1101,7 @@ export const anthropicAgentEngine = {
1074
1101
  if (stderr.length > maxBuffer) { stderr = stderr.slice(0, maxBuffer); overflowKill(); }
1075
1102
  });
1076
1103
  // error + close can BOTH fire for a single failure (Node) → settle EXACTLY once (no double clearInterval/resolve).
1077
- child.on("error", (e) => { if (settled) return; settled = true; clearInterval(watchdog); resolve(errResult(t0, e, resumeRef)); });
1104
+ child.on("error", (e) => { if (settled) return; settled = true; clearInterval(watchdog); sink.close(); resolve(errResult(t0, e, resumeRef)); });
1078
1105
  child.on("close", (code) => settle(code));
1079
1106
  function settle(code) {
1080
1107
  // the turn is over; the record must not outlive it, and is cleared only if
@@ -1087,6 +1114,7 @@ export const anthropicAgentEngine = {
1087
1114
  try { child.stdout.destroy(); } catch { /* stream gone */ }
1088
1115
  try { child.stderr.destroy(); } catch { /* stream gone */ }
1089
1116
  if (!overflow) { parseLine(buf); } // B1: flush the final, un-terminated line (the result event) — but NEVER the truncated overflow tail
1117
+ const stream = sink.close(); // after the flush: every byte the program wrote is in it, or the record says why not
1090
1118
  buf = "";
1091
1119
  // ONE SAMPLE OF THE CLOCK, and both quantities below are derived from it. Two `Date.now()` calls
1092
1120
  // a few milliseconds apart are enough to break the identity on the very turn it matters for: on a
@@ -1176,11 +1204,18 @@ export const anthropicAgentEngine = {
1176
1204
  // from this list is unreliable and the consumer must downgrade it to "not observed").
1177
1205
  reads: [...reads], readsTruncated,
1178
1206
  toolCalls, toolWaitMs: settledToolWaitMs, // #1111 — two integers, no content
1207
+ // The same calls by tool name (values sum to toolCalls), and what the session went through: every
1208
+ // result, cut, refusal and restart (session-record.mjs). `stream` is the raw stream's file and size,
1209
+ // or why it was not kept. Recording only — `failed` above still reads the last result alone.
1210
+ toolCallsByName: { ...toolCallsByName }, session: sessionSummary(session), stream,
1179
1211
  // The two counts a test round reads per stage: calls to a command tool, and calls the program
1180
1212
  // refused. The refusals are the result event's own `permission_denials`, counted; null when the
1181
1213
  // turn settled with no result event, so "not reported" never reads as "none refused".
1182
1214
  commandToolCalls, writesFailed,
1183
1215
  toolCallsRefused: Array.isArray(resultEvent?.permission_denials) ? resultEvent.permission_denials.length : null,
1216
+ // The tool-server calls that came back as an error, by tool name: the program sent them and the
1217
+ // server refused or failed them. A call the program itself refused is counted above and not here.
1218
+ toolCallsErroredByName: toolServerErrors(toolErrorIds, toolNameById, resultEvent?.permission_denials),
1184
1219
  // ms from spawn to the child's FIRST byte, or null when it never spoke. RECORDING
1185
1220
  // ONLY: nothing branches on it. A recording field is protected by a test or by nothing, so
1186
1221
  // engine.anthropic.test.mjs pins it — deleting it must red something.
@@ -1253,9 +1288,26 @@ function errResult(t0, e, resumeRef) {
1253
1288
  // modelWire: null for the opposite reason — no turn ran, so the wire said nothing about a model, and
1254
1289
  // the record must say UNKNOWN rather than inherit the alias that was asked for.
1255
1290
  json: null, usage: null, reads: [], readsTruncated: false, modelWire: null, providerWire: null, sessionRef: resumeRef ?? null,
1291
+ // No session ran, so there is nothing to have been interrupted and no stream: null, never an empty record.
1292
+ toolCallsByName: null, toolCallsErroredByName: null, session: null, stream: null,
1256
1293
  };
1257
1294
  }
1258
1295
 
1296
+ /**
1297
+ * The tool-server calls of one turn that came back as an error, by tool name: every `mcp__` ask whose
1298
+ * result carried `is_error`, less the asks the program refused itself (`permission_denials`, which
1299
+ * `toolCallsRefused` counts). An OBJECT is a measurement (`{}`: none). PURE.
1300
+ */
1301
+ export function toolServerErrors(errorIds, nameById, denials) {
1302
+ const denied = new Set((Array.isArray(denials) ? denials : []).map((d) => String(d?.tool_use_id ?? "")).filter(Boolean));
1303
+ const byName = Object.create(null);
1304
+ for (const id of errorIds ?? []) {
1305
+ const name = nameById?.get(id);
1306
+ if (typeof name === "string" && name.startsWith("mcp__") && !denied.has(id)) countTool(byName, name);
1307
+ }
1308
+ return { ...byName };
1309
+ }
1310
+
1259
1311
  /**
1260
1312
  * The program's own word for which provider served a turn, from the result event's per-model usage:
1261
1313
  * `modelUsage[<model>].provider`, "firstParty" on Anthropic's own API and "foundry" on Azure Foundry
@@ -5,7 +5,7 @@
5
5
  // exact server/tool wiring. Two inputs, both produced today by the gateway gather block:
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { dirname, join } from "node:path";
8
- import { tomlString } from "../../../shared/toml-string.mjs";
8
+ import { tomlString } from "../../../shared/toml-string.mjs"; import { driverDir } from "../../../shared/driver-dir.mjs";
9
9
 
10
10
  // • mcpConfig — the claude-shaped JSON string `{mcpServers:{name:{command,args,env,connectionTimeoutMs?}}}`
11
11
  // (buildGatherMcpConfig → JSON.stringify)
@@ -218,7 +218,7 @@ export function commandEnvToml(withheld = []) {
218
218
  // ── the stage's permission profile: what its shell commands may read and write ──────────────────────
219
219
  // codex's `workspace-write` sandbox confines where a command WRITES and lets it read the whole disk, the
220
220
  // install's settings files and the program's own sign-in included. A permission profile confines reading
221
- // too (the vendor's Permissions guide, a beta feature since 0.138.0; the floor is 0.154.0): a stage's
221
+ // too (the vendor's Permissions guide, a beta feature since 0.138.0; the floor was 0.154.0 when this was written and is 0.159.0 now, for the judgment tier's model — driver.config.mjs): a stage's
222
222
  // commands read the platform's own paths, the instruction trees and the temp folders, and write the run
223
223
  // folder, their working folder and the temp folders. Nothing else is readable, so a page a stage fetched
224
224
  // cannot get a command to print the account's files. The temp folders stay writable because
@@ -235,13 +235,18 @@ export function commandEnvToml(withheld = []) {
235
235
  // this profile never names. Not a refusal of their names inside the temp folder: codex scans a folder
236
236
  // before it applies a wildcard refusal there, and in a shared temp folder one entry the account cannot
237
237
  // read fails the whole turn (measured, 0.156.1).
238
+ //
239
+ // THE DRIVER'S OWN RECORDS INSIDE THE RUN FOLDER ARE READ-ONLY. `_driver/` holds the run's trace and the
240
+ // files the gates judge from, so a stage's command may read them and never write them. The narrower line
241
+ // wins over the run folder's: measured on codex 0.162.0 with this profile, a write into the run folder
242
+ // succeeded, a read of `_driver/` succeeded, and a write or an append there was refused.
238
243
  export const FENCE_PROFILE = "clearotron-stage";
239
244
  export function fenceToml({ runDir = null, readRoots = [] } = {}) {
240
245
  const key = (p) => tomlString(p);
241
246
  const out = [`[permissions.${FENCE_PROFILE}.filesystem]`,
242
247
  `":minimal" = "read"`, `":tmpdir" = "write"`, `":slash_tmp" = "write"`];
243
248
  for (const r of [...new Set(readRoots.filter(Boolean))]) out.push(`${key(r)} = "read"`);
244
- if (runDir) out.push(`${key(runDir)} = "write"`);
249
+ if (runDir) out.push(`${key(runDir)} = "write"`, `${key(driverDir(runDir))} = "read"`);
245
250
  out.push("", `[permissions.${FENCE_PROFILE}.filesystem.":workspace_roots"]`, `"." = "write"`, "");
246
251
  return out;
247
252
  }
@@ -19,7 +19,7 @@
19
19
  // wired per run by the driver; there is no parameter for it and this tool never guesses one
20
20
  // (recording-server.mjs's record_doubt_closure records why the parameter must not exist). The form name comes off the run's own
21
21
  // era stamp, the row ids are the driver's, and every other identifier is computed. Read at CALL TIME,
22
- // never captured at module load (stdio-server.mjs:29-33 states why for exactly this variable).
22
+ // never captured at module load (stdio-server.mjs:29-33 toolLogPath states why for exactly this variable).
23
23
  import { serve } from "./stdio-server.mjs";
24
24
  import { recordCoverage } from "../../coverage-tool.mjs";
25
25
  import { MAX_ROWS_PER_CALL } from "../../coverage-call.mjs";
@@ -107,7 +107,7 @@ export const REGISTER_SERVERS = {
107
107
  // public page, and this provider publishes neither. Read 2026-08-22 across providers/signa/src: no
108
108
  // vienna or figurative field anywhere in the core, the normaliser carries `imageAvailable`
109
109
  // (`rec.has_media`) and nothing else about the mark's figure, `hasPublicRecordUrl: false` and
110
- // `resolved_link: null` with "Signa exposes no per-record public URL". Serving it would return a
110
+ // `resolved_link: null` (no page per record of its own). Serving it would return a
111
111
  // bare boolean under a name that promises comparable figurative data. NOT A PROBE: this is a read of
112
112
  // the code and the vendor doc in this repo, and whether the live API has an image endpoint nobody
113
113
  // wired is UNTESTED from here — euipo's `markImage` looked equally absent until `GET
@@ -5,7 +5,7 @@
5
5
  // ── WHAT THIS CATEGORY IS FOR, AND WHY IT IS NOT A RETRIEVAL GRANT ──────────────────────────────────
6
6
  //
7
7
  // The two-box model conflates "may reach the outside world" with "may hand back structure"
8
- // (gather-config.mjs:246-268). A stage converted to a typed return needs the second and not the first.
8
+ // (gather-config.mjs:246-268 RECORDING). A stage converted to a typed return needs the second and not the first.
9
9
  // This server carries ONLY the second: it writes the calling stage's own artifact into the calling
10
10
  // stage's own run directory, and it dials nothing.
11
11
  //
@@ -22,7 +22,7 @@
22
22
  //
23
23
  // ── THE RUN IS NOT THE SEAT'S TO NAME ───────────────────────────────────────────────────────────────
24
24
  //
25
- // `CLEAROTRON_BAND_RUN_DIR`, read at CALL TIME. `serverEnv()` (gather-config.mjs:162) sets it for every local
25
+ // `CLEAROTRON_BAND_RUN_DIR`, read at CALL TIME. serverEnv() in gather-config.mjs sets it for every local
26
26
  // server, not only band. There is no `run_dir` parameter and there must not be one — the lesson is
27
27
  // the earlier one, where an invented `CLEAROTRON_RUN_DIR` fallback sat two lines under a sentence promising the tool
28
28
  // never guesses a run. Call time rather than module load: stdio-server.mjs:29-33 states why for this
@@ -55,7 +55,7 @@ import { PROVIDERS, REGISTER_PROVIDER } from "../../driver.config.mjs";
55
55
  // already written against them; this conversion is the consumption their headers said was still owed.
56
56
  import { recordClosures } from "../../doubt-closure-tool.mjs";
57
57
  import { MAX_CLOSURES_PER_CALL, CLOSURE_KINDS } from "../../doubt-closure-call.mjs";
58
- import { readFileSync } from "node:fs";
58
+ import { readFileSync, existsSync } from "node:fs"; import { FROZEN_METHOD_FILE } from "../../framework-method.mjs";
59
59
  import { join } from "node:path";
60
60
  import { driverDir } from "../../../shared/driver-dir.mjs"; //
61
61
  import { VARIANT_CATEGORIES } from "../../variant-manifest-model.mjs";
@@ -398,7 +398,7 @@ serve({
398
398
  item: {
399
399
  type: "string",
400
400
  description:
401
- "For a FIRING variant directive this must be a mark-shaped search term (TAKIS, CORAL " +
401
+ "For a FIRING variant directive this must be a mark-shaped search term (WAVA, CORAL " +
402
402
  "MAGIC) or carry a remedy. A label — a parenthetical, an enumeration, more than about " +
403
403
  "four words — dispatches as a nil search that reads CLEAN, and is refused here.",
404
404
  },
@@ -1134,6 +1134,9 @@ serve({
1134
1134
  handler: record_synthesis,
1135
1135
  }, {
1136
1136
  name: "record_knockout_assess",
1137
+ // The framework's `inputs` fields are offered only on a run whose framework states a method: a session
1138
+ // offered a field it may not use can fill it, and the check that refuses it runs after the whole step.
1139
+ schemaNow: (schema) => knockoutAssessSchemaFor(schema, process.env.CLEAROTRON_BAND_RUN_DIR),
1137
1140
  description:
1138
1141
  "Hand back THIS CHUNK's rated assessment as VALUES. The driver serializes knockout-assess-<n>.json, " +
1139
1142
  "so you never format JSON and never write the file. WHICH CHUNK IS NOT YOURS TO SAY — the driver " +
@@ -1392,7 +1395,7 @@ serve({
1392
1395
  },
1393
1396
  batch: {
1394
1397
  type: "object",
1395
- required: ["productContext", "inUseAs", "places"],
1398
+ required: ["productContext", "inUseAs"], // places are per name (ruling 570): the batch list is optional, as in the acceptor and the manual
1396
1399
  properties: {
1397
1400
  productContext: { type: "string", description: "One sentence: what the batch is for. Every mark's contextFraming is read against it." },
1398
1401
  inUseAs: { type: "string", description: "The kinds of use off the register that could conflict in this client's field, as one phrase completing \"Is this name already in use as …?\". The places the batch is searched on include the sites where these uses are listed." },
@@ -1439,3 +1442,27 @@ serve({
1439
1442
  handler: record_knockout_frame,
1440
1443
  }],
1441
1444
  });
1445
+
1446
+ /**
1447
+ * The knockout save tool's schema for the run this server serves. The framework's `inputs` fields are
1448
+ * offered only where the run's framework states a method, which is when its frozen copy
1449
+ * (`_driver/framework-method.json`) exists: a run with no method has none, since the mint removes it. A
1450
+ * session offered a field it may not use can fill it, and the check that refuses it runs only after the
1451
+ * whole step. With no run to read, the schema is the one declared. Read when the tools are listed.
1452
+ */
1453
+ export function knockoutAssessSchemaFor(schema, runDir) {
1454
+ if (!runDir || existsSync(driverDir(String(runDir), FROZEN_METHOD_FILE))) return schema;
1455
+ const strip = (s) => {
1456
+ if (Array.isArray(s)) return s.map(strip);
1457
+ if (!s || typeof s !== "object") return s;
1458
+ const out = {};
1459
+ for (const [k, v] of Object.entries(s)) {
1460
+ if (k === "properties" && v && typeof v === "object" && !Array.isArray(v)) {
1461
+ out.properties = Object.fromEntries(Object.entries(v).filter(([name]) => name !== "inputs").map(([name, sub]) => [name, strip(sub)]));
1462
+ } else if (k === "required" && Array.isArray(v)) out.required = v.filter((name) => name !== "inputs");
1463
+ else out[k] = strip(v);
1464
+ }
1465
+ return out;
1466
+ };
1467
+ return strip(schema);
1468
+ }
@@ -115,8 +115,11 @@ export function serve({ name, version = "0.1.0", tools = [] }) {
115
115
  // it must be read-only. Nothing here needs to claim a write is a read to get called, and a
116
116
  // `readOnlyHint: true` on a tool that writes a ledger would be a lie told to a sandbox for
117
117
  // convenience — the exact thing annotations exist to prevent.
118
- const list = tools.map((t) => ({
119
- name: t.name, description: t.description, inputSchema: t.inputSchema,
118
+ // Listed when asked, not at start: a tool may give the schema for the run it serves (`schemaNow`), read
119
+ // at call time like every per-run read here. A tool without one lists its declared schema, as before.
120
+ const list = () => tools.map((t) => ({
121
+ name: t.name, description: t.description,
122
+ inputSchema: typeof t.schemaNow === "function" ? t.schemaNow(t.inputSchema) : t.inputSchema,
120
123
  ...(t.annotations ? { annotations: t.annotations } : {}),
121
124
  }));
122
125
  const send = (m) => process.stdout.write(JSON.stringify(m) + "\n");
@@ -137,7 +140,7 @@ export function serve({ name, version = "0.1.0", tools = [] }) {
137
140
  } else if (method === "ping") {
138
141
  ok(id, {});
139
142
  } else if (method === "tools/list") {
140
- ok(id, { tools: list });
143
+ ok(id, { tools: list() });
141
144
  } else if (method === "tools/call") {
142
145
  const tool = byName.get(params?.name);
143
146
  if (!tool) return ok(id, { isError: true, content: [{ type: "text", text: `unknown tool: ${params?.name}` }] });
@@ -167,7 +170,7 @@ export function serve({ name, version = "0.1.0", tools = [] }) {
167
170
  // interpolate a mark, and this row's own contract is the call and never its content. A row that
168
171
  // copied the message would leak the day one of them does; a row that copies `unmet` cannot.
169
172
  logToolEvent({ event: "settled", seq, server: name, tool: tool.name, ...(axis ? { axis } : {}), ok: false,
170
- reason: `missing_required:${unmet.join(",")}` });
173
+ failure: "schema", reason: `missing_required:${unmet.join(",")}` });
171
174
  return ok(id, { isError: true, content: [{ type: "text", text:
172
175
  `${tool.name}_missing_required:${unmet.join(",")} — the schema you were handed declares `
173
176
  + `${unmet.length === 1 ? "this field" : "these fields"} REQUIRED and the call did not carry `
@@ -177,13 +180,19 @@ export function serve({ name, version = "0.1.0", tools = [] }) {
177
180
  try { out = await tool.handler(params?.arguments || {}); }
178
181
  catch (e) {
179
182
  // A THROWN tool error SETTLES. It returned an answer — a wrong one, but the model saw it and
180
- // could act on it. Recording it as unsettled would attribute a live failure to a timeout.
181
- logToolEvent({ event: "settled", seq, server: name, tool: tool.name, ...(axis ? { axis } : {}), ok: false });
183
+ // could act on it. Recording it as unsettled would attribute a live failure to a timeout. The
184
+ // row names the error's class and never its message, which can carry whatever the tool was given.
185
+ logToolEvent({ event: "settled", seq, server: name, tool: tool.name, ...(axis ? { axis } : {}), ok: false,
186
+ failure: "threw", reason: `threw:${errorClass(e)}` });
182
187
  return ok(id, { isError: true, content: [{ type: "text", text: `${tool.name} error: ${e?.message ?? e}` }] });
183
188
  }
184
189
  const isError = typeof out === "object" && out?.isError === true;
185
190
  const text = typeof out === "string" ? out : (out?.text ?? JSON.stringify(out));
186
- logToolEvent({ event: "settled", seq, server: name, tool: tool.name, ...(axis ? { axis } : {}), ok: !isError });
191
+ // A tool that answered with an error rejected the call itself, after the schema let it past. The row
192
+ // says so, and carries the complaint's own code when the text opens with one, never the text.
193
+ const code = isError ? complaintCode(text) : null;
194
+ logToolEvent({ event: "settled", seq, server: name, tool: tool.name, ...(axis ? { axis } : {}), ok: !isError,
195
+ ...(isError ? { failure: "server", ...(code ? { reason: code } : {}) } : {}) });
187
196
  ok(id, { isError: isError || undefined, content: [{ type: "text", text }] });
188
197
  } else if (id != null) {
189
198
  err(id, -32601, `method not found: ${method}`);
@@ -205,3 +214,32 @@ export function serve({ name, version = "0.1.0", tools = [] }) {
205
214
  // so `claude --mcp-config` reports a connected server whose tool calls return a clear isError.
206
215
  process.on("uncaughtException", (e) => { process.stderr.write(`[mcp:${name}] ${e?.stack ?? e}\n`); });
207
216
  }
217
+
218
+ // ── WHAT REFUSED A CALL, as the settled row names it ───────────────────────────────────────────────────
219
+ //
220
+ // (At the end of this file so that adding it moved no line another file cites.)
221
+ //
222
+ // A settled row with `ok: false` carries `failure`, one of three words for where the call stopped:
223
+ // schema the call did not carry a field the tool's schema requires; refused before the tool ran
224
+ // threw the tool ran and threw
225
+ // server the tool ran and answered with an error of its own
226
+ // So a call refused before its tool ever ran is told apart from one the tool rejected without knowing
227
+ // that the first leaves no call file behind. `reason` is never the message: a message can quote what the
228
+ // tool was given. It is the missing fields, the error's class, or the complaint's own code.
229
+
230
+ /** An error's class name, never its message. PURE. */
231
+ export function errorClass(e) {
232
+ const n = typeof e?.name === "string" && /^[A-Za-z][A-Za-z0-9]*$/.test(e.name) ? e.name : "";
233
+ return n || "Error";
234
+ }
235
+
236
+ /**
237
+ * The code a tool's complaint opens with, as the acceptors write them (`knockoutframe_classes_plain: …`,
238
+ * `judgment_record_not_held:1:…`): a lower-case word with at least one underscore, read up to the first
239
+ * character that is not part of it. `null` when the text opens any other way, so prose never reaches the
240
+ * row. PURE.
241
+ */
242
+ export function complaintCode(text) {
243
+ const m = /^([a-z][a-z0-9]*(?:_[a-z0-9]+)+)(?=$|[^a-z0-9_])/.exec(String(text ?? ""));
244
+ return m ? m[1] : null;
245
+ }