@sema-agent/core 7.6.3 → 7.8.0

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 (164) hide show
  1. package/CHANGELOG.md +70 -10
  2. package/dist/agents/agent-transcript-tool.js +2 -2
  3. package/dist/agents/list-agents-tool.js +2 -3
  4. package/dist/agents/observer.js +2 -2
  5. package/dist/agents/peer-admission.d.ts +1 -1
  6. package/dist/agents/send-message-tool.js +2 -1
  7. package/dist/agents/subagent-steps.js +5 -4
  8. package/dist/agents/subagent.js +2 -3
  9. package/dist/brain/anthropic.js +8 -2
  10. package/dist/brain/open-responses.js +5 -3
  11. package/dist/brain/openai.js +31 -8
  12. package/dist/brain/reasoning.d.ts +32 -0
  13. package/dist/brain/reasoning.js +18 -0
  14. package/dist/core/ask-question.js +2 -2
  15. package/dist/core/auto-mode-defaults.d.ts +16 -0
  16. package/dist/core/auto-mode-defaults.js +1 -0
  17. package/dist/core/auto-mode.d.ts +19 -0
  18. package/dist/core/auto-mode.js +74 -56
  19. package/dist/core/checkpoint-execution-record.d.ts +110 -0
  20. package/dist/core/checkpoint-execution-record.js +49 -0
  21. package/dist/core/checkpoint-store.d.ts +114 -2
  22. package/dist/core/checkpoint-store.js +35 -2
  23. package/dist/core/context-edit.d.ts +0 -22
  24. package/dist/core/context-edit.js +2 -11
  25. package/dist/core/engine-notice.d.ts +17 -0
  26. package/dist/core/fs-write-gate-policy.d.ts +7 -1
  27. package/dist/core/fs-write-gate-policy.js +7 -7
  28. package/dist/core/gate-fold.js +2 -2
  29. package/dist/core/gate-lanes.js +4 -3
  30. package/dist/core/governance-codes.d.ts +1 -1
  31. package/dist/core/governance-codes.js +8 -0
  32. package/dist/core/hooks.d.ts +8 -2
  33. package/dist/core/hooks.js +1 -1
  34. package/dist/core/lsp.js +2 -2
  35. package/dist/core/mcp-server-spec.d.ts +17 -0
  36. package/dist/core/mcp.js +3 -1
  37. package/dist/core/memory-engine/tools.js +4 -12
  38. package/dist/core/permission-rules.d.ts +32 -2
  39. package/dist/core/permission-rules.js +43 -37
  40. package/dist/core/present-plan-tool.js +3 -4
  41. package/dist/core/runner/active-skill-scope.js +7 -4
  42. package/dist/core/runner/clock-and-limits.d.ts +117 -0
  43. package/dist/core/runner/clock-and-limits.js +118 -0
  44. package/dist/core/runner/compaction-call-options.d.ts +1 -1
  45. package/dist/core/runner/contracts.d.ts +37 -3
  46. package/dist/core/runner/decide-continuation.d.ts +98 -0
  47. package/dist/core/runner/decide-continuation.js +133 -0
  48. package/dist/core/runner/execution-record.d.ts +26 -0
  49. package/dist/core/runner/execution-record.js +19 -0
  50. package/dist/core/runner/git-leg-delivery.d.ts +28 -0
  51. package/dist/core/runner/git-leg-delivery.js +94 -0
  52. package/dist/core/runner/initial-run-state.d.ts +14 -0
  53. package/dist/core/runner/initial-run-state.js +11 -0
  54. package/dist/core/runner/prepare-ask-lane.d.ts +1 -1
  55. package/dist/core/runner/prepare-caps-and-workflow.d.ts +5 -4
  56. package/dist/core/runner/prepare-caps-and-workflow.js +33 -11
  57. package/dist/core/runner/prepare-defer-classify.d.ts +5 -26
  58. package/dist/core/runner/prepare-defer-classify.js +10 -10
  59. package/dist/core/runner/prepare-delegation-surface.d.ts +5 -4
  60. package/dist/core/runner/prepare-delegation-surface.js +15 -17
  61. package/dist/core/runner/prepare-gate-stations.d.ts +4 -1
  62. package/dist/core/runner/prepare-gate-stations.js +3 -1
  63. package/dist/core/runner/prepare-hands-readface.d.ts +3 -2
  64. package/dist/core/runner/prepare-hands-readface.js +3 -2
  65. package/dist/core/runner/prepare-inherited-gate.d.ts +1 -1
  66. package/dist/core/runner/prepare-inherited-gate.js +1 -0
  67. package/dist/core/runner/prepare-lsp.d.ts +4 -3
  68. package/dist/core/runner/prepare-lsp.js +3 -2
  69. package/dist/core/runner/prepare-memory-engine-session.d.ts +4 -4
  70. package/dist/core/runner/prepare-memory-engine-session.js +4 -2
  71. package/dist/core/runner/prepare-offload-wrappers.js +7 -5
  72. package/dist/core/runner/prepare-park-ask.d.ts +4 -0
  73. package/dist/core/runner/prepare-park-ask.js +4 -1
  74. package/dist/core/runner/prepare-project-context.d.ts +5 -23
  75. package/dist/core/runner/prepare-project-context.js +7 -5
  76. package/dist/core/runner/prepare-prompt-assembly.d.ts +1 -1
  77. package/dist/core/runner/prepare-protocol-tools.d.ts +5 -1
  78. package/dist/core/runner/prepare-protocol-tools.js +38 -21
  79. package/dist/core/runner/prepare-question-face.d.ts +4 -3
  80. package/dist/core/runner/prepare-question-face.js +5 -3
  81. package/dist/core/runner/prepare-safety-scan.js +42 -2
  82. package/dist/core/runner/prepare-task.js +37 -19
  83. package/dist/core/runner/prepare-tool-disclosure-mount.d.ts +5 -2
  84. package/dist/core/runner/prepare-tool-disclosure-mount.js +3 -1
  85. package/dist/core/runner/prepare-wiring-manifest.d.ts +4 -0
  86. package/dist/core/runner/prepare-wiring-manifest.js +3 -8
  87. package/dist/core/runner/run-harness-handlers.d.ts +46 -0
  88. package/dist/core/runner/run-harness-handlers.js +345 -0
  89. package/dist/core/runner/run-turn-boundary.d.ts +69 -0
  90. package/dist/core/runner/run-turn-boundary.js +694 -0
  91. package/dist/core/runner/runtask.d.ts +0 -68
  92. package/dist/core/runner/runtask.js +66 -1486
  93. package/dist/core/runner/session-rule-policy.js +9 -4
  94. package/dist/core/runner/steer-admission.d.ts +17 -0
  95. package/dist/core/runner/steer-admission.js +17 -0
  96. package/dist/core/runner/synthetic-tools.js +5 -8
  97. package/dist/core/runner/tool-disclosure.d.ts +4 -0
  98. package/dist/core/runner/tool-disclosure.js +9 -5
  99. package/dist/core/runner/tool-end-body.d.ts +71 -0
  100. package/dist/core/runner/tool-end-body.js +75 -0
  101. package/dist/core/runner/tool-face-overlay.d.ts +40 -0
  102. package/dist/core/runner/tool-face-overlay.js +125 -0
  103. package/dist/core/runner/tool-output-projection.d.ts +10 -0
  104. package/dist/core/runner/tool-output-projection.js +33 -10
  105. package/dist/core/sensitive-path-policy.d.ts +3 -0
  106. package/dist/core/sensitive-path-policy.js +6 -5
  107. package/dist/core/shared-memory/tools.js +3 -6
  108. package/dist/core/store-contracts/checkpoint-store-contract.d.ts +4 -0
  109. package/dist/core/store-contracts/checkpoint-store-contract.js +108 -0
  110. package/dist/core/task-event.d.ts +10 -0
  111. package/dist/core/task-registry.js +4 -5
  112. package/dist/core/task-spec.d.ts +8 -0
  113. package/dist/core/tool-catalog-entries.d.ts +48 -0
  114. package/dist/core/tool-catalog-entries.js +174 -0
  115. package/dist/core/tool-conformance.d.ts +22 -0
  116. package/dist/core/tool-conformance.js +37 -0
  117. package/dist/core/tool-face.d.ts +171 -0
  118. package/dist/core/tool-face.js +25 -0
  119. package/dist/core/tool-policy.d.ts +24 -0
  120. package/dist/core/tool-policy.js +4 -2
  121. package/dist/core/tool-registry.d.ts +114 -0
  122. package/dist/core/tool-registry.js +114 -0
  123. package/dist/core/tool-result-budget.d.ts +0 -3
  124. package/dist/core/tool-result-budget.js +4 -3
  125. package/dist/core/tool-result-store.d.ts +0 -18
  126. package/dist/core/tool-result-store.js +2 -18
  127. package/dist/core/tool-roster.d.ts +450 -0
  128. package/dist/core/tool-roster.js +497 -0
  129. package/dist/core/tool-spec.d.ts +54 -3
  130. package/dist/core/tools.js +7 -0
  131. package/dist/core/trace.d.ts +24 -0
  132. package/dist/core/wiring-manifest.d.ts +13 -0
  133. package/dist/core/wiring-manifest.js +2 -1
  134. package/dist/core/write-protect.d.ts +2 -1
  135. package/dist/core/write-protect.js +5 -4
  136. package/dist/engine/compaction/utils.d.ts +6 -0
  137. package/dist/engine/compaction/utils.js +19 -19
  138. package/dist/engine/loop/types.d.ts +6 -0
  139. package/dist/index.d.ts +20 -8
  140. package/dist/index.js +19 -7
  141. package/dist/orchestration/goal.js +2 -1
  142. package/dist/orchestration/run-spec.js +3 -5
  143. package/dist/orchestration/run-workflow-tool.js +2 -2
  144. package/dist/prompt-assembly/event-registry.js +3 -3
  145. package/dist/prompt-assembly/tool-catalog.d.ts +2 -2
  146. package/dist/stores/file/checkpoint-store.d.ts +7 -0
  147. package/dist/stores/file/checkpoint-store.js +20 -1
  148. package/dist/tools/fs/fs-bash.d.ts +1 -1
  149. package/dist/tools/fs/fs-bash.js +7 -11
  150. package/dist/tools/fs/fs-read.js +2 -2
  151. package/dist/tools/fs/fs-search-tools.d.ts +0 -7
  152. package/dist/tools/fs/fs-search-tools.js +3 -16
  153. package/dist/tools/fs/fs-write.js +4 -6
  154. package/dist/tools/fs/repo-map.js +2 -2
  155. package/dist/tools/fs/safety.d.ts +2 -1
  156. package/dist/tools/fs/safety.js +6 -5
  157. package/dist/tools/monitor.js +2 -2
  158. package/dist/tools/scheduler-tools.js +5 -8
  159. package/dist/tools/task-list.js +5 -8
  160. package/dist/tools/todo.js +2 -2
  161. package/dist/tools/web.js +3 -6
  162. package/dist/tools/worktree.js +3 -4
  163. package/package.json +4 -1
  164. package/test/export-surface.snapshot.json +183 -7
package/CHANGELOG.md CHANGED
@@ -1,22 +1,82 @@
1
1
  # Changelog
2
2
 
3
- ## 7.6.3 — 2026-09-08
3
+ ## 7.8.0 — 2026-09-08
4
4
 
5
- Maintenance patch on the 7.6.x line: one correction to 7.6.2's forwarding drain.
5
+ One roster instead of 29 name tables. `ToolSpec` already described a tool completely; the engine, the server and the shells still re-asserted each tool's identity and axes by NAME in hand-kept tables (a retired name lived on as a key in five of them). This version mints ONE effect roster per prepared leg from the mounted instances, derives every remaining table from a static catalog, and closes the mount array behind a builder.
6
6
 
7
- ### Fixed
8
- - **forwardSubagentEvents drain gate** 7.6.2 gated the drain inside an `async` helper and awaited it unconditionally, so a run that did NOT opt in still yielded one extra microtask before its terminal push (the black-box round measured parent `done` at hop 356 vs 355 on 7.6.1). The gate is now a synchronous predicate at both terminal pushes and the helper is a plain Promise factory; the non-forwarding terminal depth is pinned (ratchet 58, the unconditional-await form reds it at 59). No change for opted-in runs.
7
+ ### BREAKING
8
+ - `HAND_TOOL_EFFECTS`, `DEFAULT_BUDGET_EXEMPT_TOOLS`, `COMPACTABLE_TOOLS` leave `src/index.ts` (B1). Replacement: `describeToolCatalog()` the hand band is `.filter((e) => e.mountedBy.includes("hands"))` (or `handBandEffects()`), the two policy sets `budgetExemptToolNames()` / `compactableToolNames()`. A host deriving its hand band (server `HANDS_BAND_TOOL_NAMES`) derives it over `HANDS_BAND_TAGS`; the derived band equals today's server list plus `ListAgents` (the peer-lane definition the hand-kept list predates) the server's list is the stale side.
9
+ - `Prepared.tools` is `readonly AgentTool[]` (the builder's read face); a prepare phase that wrote the array takes `roster: RosterBuilder`.
10
+
11
+ ### Narrowings (each a two-arm pin)
12
+ - **B6/B17** — the policy faces read the call's OBJECT face (`ToolCallRequest.face`, stamped at the gate entry off the live roster and carried through the fold's re-mint over the final args). The active-skill `allowPaths` face judges `pathTarget.skillScopeEligible` and confines the DECLARED target's value (`pathTargetValue`: the face's `param` + aliases — an in-fence `file_path` beside an out-of-fence declared param is a decoy, not the write; a declared param absent from the args is denied as unresolvable): a bare request (a policy invoked outside a Runner) or a caller tool that merely took a built-in's name (`Write`) is an unconfirmable write and is denied where it used to pass by name. The transcript-directory gate reads the face and falls back to the catalog's declaration for the name (never wider than 7.7.0).
13
+ - **B15** — the permission-rule lint's diagnostic set is closed: `unsupported.param_unknown` (renamed from `unsupported.unknown_param`), `unsupported.param_non_scalar` (`Edit(edits:…)` and every carried object/array key), `unsupported.tool_unknown` (only under a leg-supplied `ruleFaces` resolver — `ruleFacesFromRoster(prepared.toolRoster)`; without one the catalog alone judges and an unknown tool stays permissive). `RuleFaceView` / `catalogRuleFaceView` / `PermissionRulePolicyOptions.ruleFaces` are the seats.
14
+ - **B14** — the micro-compaction whitelist is derived from the catalog's `compactable` declarations: eight members, no `PowerShell` row (no sema definition ever carried the name).
15
+ - **B20** — a caller tool name / alias over 128 characters, or a contract member over 64, is refused typed at prepare (`config.tool_name_too_long` / `config.tool_contract_too_long`); an MCP assembled name can never exceed the bound — the namespacing helper already budgets it (peer segment absorbs the clamp, digest-suffixed; provider bound 64), so the design's "drop that tool" arm is satisfied by construction and no drop arm exists (design/388 S49's "core has no bound on the synthetic name" premise is stale as of RB-83/RB-107).
16
+ - The compaction file-op summary reads the live names' path access off the catalog and the three pre-rename names off the frozen `DURABLE_HISTORY_NAMES`; a persisted `MultiEdit` block contributes no file-op metadata any more (the retired name is a key nowhere — G3).
17
+ - **Every write fence judges the call's DECLARED slot** — two named readers for the two kinds of fence, shared by all of them (`writeTargetPath(call, target)`): a DENY-LIST (sensitive paths, frozen paths, write protection, the transcript gate, edit tracking) reads `protectivePathTargetOf` — the object face's declaration first, the catalog's declaration for the NAME as the floor (a bare request, or a face that declares none): reading the catalog for an undeclared same-name tool can only tighten a deny-list (the pre-388 posture, never wider). A GRANT that admits a write BY its path (session `allowDirs`, the fs-write gate's accept/exempt dirs, the active-skill allowPaths eligibility) reads `declaredPathTargetOf` — the face's OWN declaration only: a same-name tool that declares no path target is an unconfirmable write and the fence fails closed (allowDirs denies "declares no path target to confine"; the fs-write gate asks; the catalog's slot never buys an admission); a bare request (no face at all — a policy invoked outside a Runner) reads the catalog for the name. `isProtectedWrite` / `isDeclaredWrite` are the two membership predicates. The sensitive-path guard's DEFAULT membership is the same predicate (a caller tool under any name whose face declares a write target is guarded); an explicit `tools` list stays a name list. A decoy `file_path` beside an out-of-fence declared param is not the target; a declared param absent from the args is "no resolvable path" (containment fences deny/ask as they always did for an unresolvable write; the additive deny-lists — sensitive paths, frozen paths — see no target and pass, as their unresolvable arm always did). The cross-slot fallback that once read `notebook_path` for a Write, or `file_path` for a NotebookEdit, is gone (the tool's own schema refuses such a call before any write).
18
+ - **Merge-seam adversarial rounds (codex r1/r2 over the merged 388 × 393 S1/S2 × #602 tree)** — (H1) the durable resume-edit rechecks judge under the call's face: the park row carries `family` + `pathTarget` beside its B18 identity members (additive optional; `parkedToolFace` mints them at the same station, `parkedCallFaceOf` rebuilds the face — present iff face + all four identity members are on the row), the pre-CAS recheck judges the edited args under the ROW face and the two post-CAS rechecks under the live roster face; a decoy in-fence `file_path` beside an out-of-fence declared `destination` is refused `resume.constraint_rejected` pre-CAS (the checkpoint stays pending). **@server**: two additive optional keys on the checkpoint row (JSON rows: zero DDL); the store-contract kit gained a seven-member round-trip run your pg store must pass. (H2) MCP intake keys the overlay's results by tool NAME — a tool the overlay refused is not mounted at all (previously an index pairing mounted it bare and shifted later tools onto a neighbour's face). (F2) the `resume` identity arm is wired (see the disclosure below): a same-name tool whose contract / shape / revision differ from the parked row's is refused `resume.tool_contract_mismatch` through the non-executing reopen path (`tool_unavailable` family), never executed under the new face. (F3) the park-time projection re-adjudication (a JSON-fidelity store moving the args) carries the call face too. Two review proposals were REJECTED on the documented contract (#549 ①, HRD-PRM-1, #286, CLS-C-4): protective fences judge the tool's declared/catalog slot only — no "legacy spelling floor" when the slot is empty and no "judge every candidate" union; a caller whose implementation writes another slot declares its face.
19
+ - **B20 (keys)** — a schema property name is a SEMANTIC key every fence reads by and is never truncated on a roster row: a caller declaration over `TOOL_KEY_MAX_CHARS` (256) or `TOOL_KEYS_MAX` (1024 keys) is refused typed at prepare (`config.tool_schema_bound`); a caller face that does not fit its schema (a `pathTarget.param` outside the top-level keys, a `ruleFace` param outside the derived vocabulary — scalar keys ∪ the declared path target's param + aliases —, an approval card whose required keys are missing, a declared key or display hint over its bound) is refused `config.tool_face_invalid`; no display hint is truncated on a row either — the same validator the MCP `toolFaces` overlay passes; an MCP tool whose schema breaks the key bound is dropped with a disclosed reason (its siblings mount). `writeProtectionCheck(toolName, args, face?)` gains the call's face (additive).
20
+ - The sensitive-path guard / frozen-path deny / edited-file tracking / `allowDirs` all derive their covered set from the catalog's path targets: `MultiEdit` is no longer a member of any of them (it mounts nowhere).
9
21
 
10
- ## 7.6.2 — 2026-09-08
22
+ ### Added
23
+ - `ToolSpec.family` / `pathTarget` / `ruleFace` / `renderHints` / `compactable` / `budgetExempt` (additive; `defineTool` forwards them + `modelGate` onto the materialized face); `TaskSpec.excludeAllTools: true` (the whole face unmounts; `"*"` stays a legal caller name); `McpServerSpec.toolFaces` (a per-tool face overlay for MCP tools, validated against the materialized schema at every listing).
24
+ - The tool FACE vocabulary (`TOOL_FAMILIES`, `TOOL_MOUNT_TAGS`, `TOOL_APPROVAL_CARDS`, `TOOL_PATH_ACCESSES`, the render-hint bounds, `TOOL_WIRE_NAME_MAX_CHARS` / `TOOL_CONTRACT_MAX_CHARS`, `ToolPathTarget` / `ToolRuleFace` / `ToolRenderHints` / `ToolFaceDeclaration` / `ToolCatalogEntry`), the static catalog (`describeToolCatalog`, `catalogEntriesNamed`, `catalogToolNames`, `catalogRuleFaceOf`, `pathTargetOf`, `pathTargetValue`, the derived predicates, `engineCardTypes`, `predictMountedToolNames`, `HANDS_BAND_TAGS` / `MOUNT_TAG_IN_HANDS_BAND`), the roster (`ToolRosterEntry` / `ToolRoster` / `ToolRosterDelta` / `ToolRosterRenderHints` as typebox schemas AND `Static` types, `ROSTER_SOURCES`, `mintCapabilityId`, `toolIdentityOf`, `diffToolRoster`, `judgeParkedToolIdentity`, `parkedToolIdentity`, `ruleFacesFromRoster`, `toolCallFaceOf`, `inputKeysOf`, `ruleFaceOfInstance`, `ToolRosterDeltaSeat`), `TOOL_CONFORMANCE_VECTORS` / `describeToolConformanceVectors`, `applyMcpToolFaces` / `toolFaceProblem` / `APPROVAL_CARD_REQUIRED_KEYS`, `createStructuredProjector`, `ASK_USER_QUESTION_TOOL_NAME` (B5), `DURABLE_HISTORY_NAMES`.
25
+ - **Wire (@server @cli @client-core @web-admin @desktop)**: `WiringManifest.tools?: ToolRoster` on every effective manifest (OUTSIDE `configFingerprint`; `schemaVersion` unchanged); `TaskEvent` arm `tool_roster_delta { delta: ToolRosterDelta }` (a run-time roster change: the whole post-change roster + a summary usable when the consumer holds `fromDigest`; a mismatch means adopt the snapshot, never reject); `ToolCallRequest.face?: ToolCallFace`; `FsWriteGateWiring.isExempt(toolName, canonicalPath, identity?)` (B22 — the identity triple beside the name for a store keyed on `capabilityId`); `PendingAction{kind:"tool_approval"}` gains `capabilityId? / contractId? / shapeDigest? / implementationRevision? / rosterDigest?` (B18; additive, no checkpoint-version bump; a row without them follows the name-only path); three `engine_notice` codes (B2, audience operator): `config.tool_card_undeclared`, `config.tool_face_undeclared`, `config.tool_face_invalid`.
26
+ - Gates (all in `gate:all`): `gate:tool-catalog` (every core-owned definition spreads its catalog face; a face under another name, a ghost id, a row without family, a card minted by a file that does not list it — red, named), `gate:tool-names` (L1: every retained tool-name table registered in `docs/TOOL-NAME-TABLES.json`, disjoint from the retired names, inside the catalog), `gate:tool-docs` (the `ToolSpec` field reference in docs/sdk/04 is generated from the declaration — `scripts/gen-tool-spec-fields.mjs --check`). `ToolMountTag` registered in `docs/CLOSED-SETS.md`.
27
+ - The catalog is data: every row is deep-frozen at module init and `toolFace(id)` hands out a deep COPY — two factory products never share a `pathTarget`, and a host adapting one instance's face cannot move another run's fences (or the catalog).
28
+ - An MCP `toolFaces` overlay that does not fit the tool's materialized schema (at intake or after a refresh) means the tool is NOT mounted on that leg — announced once (`config.tool_face_invalid`); it never mounts bare (a bare mount would silently drop the write protection the operator declared, and an MCP name has no catalog floor). The approval-edit re-check and the edit ask carry the stamped face (a decoy in an approved edit is confined by the declared slot).
29
+ - A deferred tool's placeholder carries the real tool's `aliases` (dispatch by alias resolves to the same slot before and after activation, the roster's name→row resolution agrees with the dispatcher's — wire name first, then alias, in mount order — and a transcript call under an alias activates the canonical entry on replay). The MCP `toolFaces` overlay binds to the RAW accepted tool name (`Tool.mcpRemoteName`, a typed carry the materializer stamps): two raw spellings folding onto one minted name cannot make a face land on the other tool — the unaccepted spelling is announced undeclared. `TOOL_KEY_MAX_CHARS` / `TOOL_KEYS_MAX`; `ToolCallLike` / `callPathTargetOf` / `isPathConfinableWrite` / `fileOpAccessOf` (the compaction summary records file ops for the FILE families only — a search tool's `path` is a root, not a file read).
30
+ - The roster carries every name the model can call: the ToolSearch instance the deferred lane appends to the harness list rides the roster's ENGINE LANE (`RosterBuilder.mountEngineLane`) as the trailing row, minted after the disclosure lane (which reads the mount array and never writes it) — the prompt manifest's tool rows are the mount array; the roster is that plus the engine lane. The effect map follows the instance the harness dispatches to (a later same-name mount's declared effect is the name's truth). `gate:tool-catalog` rule ②b: a resolvable `defineTool({ name })` with no catalog row is red (per-file `HOST_FACTORY_DEFINITIONS` exemption list, empty today).
11
31
 
12
- Backport patch on the 7.6.x line (two field bugs the server 7.64.0 chain found; no store-contract change that is 7.7.0).
32
+ ### Not wired in 7.8.0 three runtask.ts seams deferred to design/393 S3 (the seats are minted; the driver does not yet consume them)
33
+ - **Disclosure**: the three items below are the driver-side half of this slice. In 7.8.0 the roster, its delta seat and the structured projector all exist and are pinned at the seat/function level, but runtask.ts (design/393's domain, being decomposed in parallel) does not yet call them: no `tool_roster_delta` frame is pushed and `tool_end` still projects through the module-level card set. A consumer must not rely on `tool_roster_delta` frames until the version that lands 393 S3. (The `resume` identity arm — `judgeParkedToolIdentity` minting `resume.tool_contract_mismatch` — IS wired in 7.8.0: the merge-seam adversarial round showed a same-name replacement executing under a different face once the rechecks read faces, so it did not wait for S3.)
34
+ - `tool_end`: the body projection reads `prepared.structuredProjector` (the per-leg card set + the once-per-leg announce) in place of the module-level `structuredFrom` (tool-end-body.ts:77 via runtask).
35
+ - `wiring_manifest`: beside the manifest push (runtask.ts ≈:4149) subscribe `prepared.toolRosterDeltas.subscribe((delta) => queue.push({ type: "tool_roster_delta", delta, ...ident() }))`.
13
36
 
14
- ### Fixed
15
- - **R12 (`forwardSubagentEvents`)** a run that opted into forwarding did not wait for the frames its BACKGROUND children had already handed to the forward channel before saying `done`; the gap is one microtask ladder deep and moved when #594 split the tool gate into three async stations (a child's gated call went from 24 to 27 microtask hops between `tool_start` and `tool_end`), so a consumer that read the parent to `done` and then inspected the sink lost the child's `tool_end`. The parent now takes one macrotask turn right before its terminal push, only on opted-in runs, on both the success and the failure path. Pin: `test/forward-subagent-events-drain.test.ts`.
16
- - **R13 (checkpoint)** — the pre-CAS refusal of a pending approval row that carries no `origin` word (a row minted before the ask's origin was persisted on the park) now carries `reason: "origin_missing"` like its three `unsupported_version` siblings, so a host upgrading with parked cards can classify the orphaned row. Pin: `test/backlog-r13-origin-missing-reason.test.ts`.
37
+ ### Pins
38
+ - `test/tool-catalog.test.ts` (L5 snapshots + the gate's discriminance), `test/tool-roster.test.ts` (the builder, the row mint, the delta seat, G1 / G5 / G6 / G6c / G6e), `test/tool-conformance.test.ts` (L3 over four vectors on a real NodeExecutionEnv, L6 lockstep + every param matches, L4's once-per-leg announce, G6d), the retired-table tests re-pointed at the catalog.
39
+
40
+ ## 7.7.0 — 2026-09-07
41
+
42
+ One version for the first refactor wave and its bug fixes (the 7.6.2 patch folded in: main already carries the store-contract change, so the fix ships with it). #618 / B-040: The classifier request shape changes on the wire; the explicit "off" tier changes on both brain lanes.
43
+
44
+ **BREAKING (store contract).** #602 — the durable half of a post-CAS veto. A person approved a parked call, the deployment's own re-check refused the approver's edit on the resumed leg, `tool_end.gate` said `denied/policy` beside the `human_allowed` settlement — and the row still said `resolvedOutcome.gateOutcome.disposition = allowed`, because the store contract had no verb for "how was the decided action finally disposed". A consumer rebuilding the run from the row after the stream broke read the pre-execution allow and nothing else.
45
+
46
+ ### BREAKING — `CheckpointStore` gains two REQUIRED members
47
+ - `readonly execution: { readonly outcome: true }` — a declaration, read at the first read of the store seat (`resolveCheckpointStore`): a store without it is refused with `config.invalid_checkpoint_store` (the same door as the retired `null` spelling), naming the seat (`TaskSpec.checkpointStore` / `RunnerDeps.checkpointStore`) and the missing declaration. A store that satisfies the TypeScript type carries it; a JavaScript caller is told at prepare what the type would have told it.
48
+ - `recordExecutionOutcome(token, scope, gate: GateOutcome): Promise<"recorded" | "already_recorded" | "not_resolved" | "absent">` — the CAS `status='resolved' AND execution_outcome IS NULL`; the same record again is idempotent (`already_recorded`, structural equality over the record's JSON); a DIFFERENT record throws `checkpoint.execution_outcome_conflict` (`ExecutionOutcomeConflictError`, carrying `recorded` + `attempted`); pending/expired rows answer `not_resolved`, missing and wrong-scope rows `absent`. Never touches `status` / `rev` / `resolvedOutcome` / `reopenReason`.
49
+ - `reopen` gains the predicate `AND execution_outcome IS NULL` — a row whose decided action is already disposed is settled, never reopenable (the reopen answers `false`; the engine's compensation reads it as `checkpoint.reopen_failed`, definitive). Behaviour narrowing: an `env_failed` retry is offered only for a row whose action never reached its disposition.
50
+ - Deployment stores (server's two) need two columns, `execution_outcome` (json) + `execution_at_ms`, the record CAS, and the reopen predicate; the store-contract kit (`checkpointStoreContract`) carries the six cases. The in-tree file store writes a new ledger event kind (`execution`); a binary older than 7.7.0 refuses to replay a directory whose live ledger carries one (loud) — but once compaction has folded the event into a snapshot row, that older binary reads the row with a field it does not know and its `reopen` ignores the record (the same rollback shape the parked-steer queue event disclosed in its release; a rollback across a recorded row is not supported).
51
+
52
+ ### Added
53
+ - `CheckpointRow.executionOutcome?: GateOutcome` + `executionAtMs?: number` (additive row keys, present together) — the record the resumed call's `tool_end.gate` carried, written by the engine on EVERY resolution (an executed allow too); absence has one meaning: unknown, never "allowed". `resolvedOutcome.gateOutcome` stays the immutable record of the decision, so a vetoed row reads `allowed` there and `denied/policy` here.
54
+ - `executionVerdict(cp)` → `{ kind: "executed", gate } | { kind: "unknown" }` — the one read face of the column (`type ExecutionVerdict`).
55
+ - `EXECUTION_OUTCOME_RECORD_WORDS` / `type ExecutionOutcomeRecordWord` / `isExecutionOutcomeRecordWord`, the disposition table `EXECUTION_RECORD_LEAVES_ROW_UNRECORDED` (+ fence `ExecutionRecordTableCoversEveryWord`; registered in `docs/CLOSED-SETS.md`), `ExecutionOutcomeConflictError`, and the shared backend halves `executionRecordDisposition` / `checkpointExecutionRecorded` / `sameExecutionRecord` (every in-tree backend routes through them — `gate:single-mint` rows); `checkpointStoreExecutionUndeclared` (the refusal's one mint).
56
+ - `engine_notice` code `checkpoint.execution_outcome_unrecorded` (audience operator) — the resolver disposed the decided action and the store answered `not_resolved` / `absent`: the execution result stands as delivered, the row reads unknown, and this names it; `detail: { sessionId, runId, scope, checkpointId?, word }`. A conflict is not a notice: the throw propagates and the resumed leg fails with `checkpoint.execution_outcome_conflict` (the action, if it ran, ran; the defect is reported, not hidden).
57
+ - `@contract checkpoint.execution_outcome` (the row field's JSDoc is the one home).
17
58
 
18
59
  ### Changed
19
- - `CheckpointError.detail.reason` closed set gains `"origin_missing"` (terminal for the row's bytes; the host decides the orphaned card).
60
+ - The pending-call resolver (`resolvePendingCall`) files the frame's record on every arm through one `settleEnd` helper, between the `tool_end` frame and the transcript append; a policy_ask decide reaching the resolver without its settlement record is refused (`checkpoint.invalid_outcome`) rather than settled unrecorded. `ResumeRun.recordExecutionOutcome` is the bound verb (required seat). The notice half lives in `runner/execution-record.ts` (layer 1).
61
+
62
+ ### Changed (refactor — byte-invariant on the runtime)
63
+ - **design/393 S1** — six module-level helpers leave runtask.ts for layer-1 modules (initial-run-state / steer-admission / tool-end-body / decide-continuation / clock-and-limits / git-leg-delivery; runtask 11 963 → 11 059); dist runtask.js changes by import lines and moved bodies only, the await-boundary table is all-zero, the export snapshot is unchanged by this slice.
64
+
65
+ ### Pins
66
+ - `test/backlog602-execution-outcome.test.ts` (17): store-level ③④⑤⑥ over both in-process backends + file-ledger replay; engine-level ① (person's edit vetoed post-CAS ⇒ frame and row both `denied/policy`, decision cell still `allowed`, `executionVerdict` executed, reopen refused), ② (executed allow recorded), a delivered refusal, the unrecorded notice, the conflict propagation, ⑦ (undeclared store refused on both seats with the declared control). Store-contract kit +6 cases (InMemory / File / Pg via pg-mem).
67
+
68
+ ### Fixed
69
+ - **#618 (P1, B-040)** — the auto-mode classifier's request said nothing about thinking and carried no output cap, so a default-on Messages-compatible endpoint (deepseek's anthropic port in the field) spent the whole cap on reasoning and answered with zero text: every classification was a contract failure, three of them opened the session breaker, and every later ask in the session became a card for a person (3–7 s per classification on the way). The classify call now carries `reasoning:"off"` and `maxTokens: AUTO_MODE_CLASSIFIER_MAX_TOKENS` (256, derived from the engine's own `<block>…` output contract — the longest legal block verdict is ≈150 tokens; not upstream's 64 nor its 4096 critique cap), on every model whose wire can SAY off (`thinkingOffExpressible`); a model that thinks regardless (anthropic adaptive form; stock openai / Responses with no declared off spelling) keeps its own output budget — a cap sized for the answer alone would only cut its thinking. Explicit "off" is now spelled on every wire that has an off form, one rule with no absence-means-off exception: anthropic budget form `thinking:{type:"disabled"}` (adaptive form: field omitted — those models reject `disabled`); openai lane — deepseek `thinking:{type:"disabled"}`, qwen/zai `enable_thinking:false`, qwen-chat-template `chat_template_kwargs.enable_thinking:false`, openrouter/together `reasoning:{enabled:false}`; stock openai completions and the Responses lane the DEPLOYMENT-declared off spelling (`thinkingLevelMap.off`, e.g. a provider's `"none"` tier — `declaredOffSpelling`), else absence (an enabled tier is never inferred as off: it turns reasoning on). "Unset" still writes nothing on every format. A new trace frame `auto_mode.classified {toolCallId, model, ms, verdict, cause?}` reads one line per decision (breaker-open short-circuits included); `AutoModeDeciderOptions.onClassified` is the hook it rides. Repro `scripts/repro/B-040.mjs` (RED on 7.6.1, GREEN here) runs under `gate:repro`.
70
+
71
+ ### Changed (wire — consumers named, #618)
72
+ - Classifier request body on the wire: `thinking:{type:"disabled"}` + `max_tokens: 256` (anthropic budget form); `reasoning:"off"` reaches every brain lane. A deployment that reads the classifier's request through a proxy sees the two new keys.
73
+ - Explicit `"off"` on the main model's finalize turn (and any caller passing `reasoning:"off"`): anthropic budget models now receive `thinking:{type:"disabled"}` (was: no key); deepseek-format models receive `thinking:{type:"disabled"}` (was: no key); openrouter/together-format models receive `reasoning:{enabled:false}` (was: no key); stock-openai-format and Responses-lane models with a declared `thinkingLevelMap.off` receive that spelling as `reasoning_effort` / `reasoning.effort` (was: no key; without the mapping still no key). Unset requests are byte-identical to 7.6.1.
74
+
75
+ ### Added (#618)
76
+ - `AUTO_MODE_CLASSIFIER_MAX_TOKENS`, `thinkingOffExpressible(model)`, `declaredOffSpelling(model)`, `type OffCapabilityModel`, `type AutoModeClassified`, `AutoModeDeciderOptions.onClassified`, trace frame kind `auto_mode.classified`.
77
+
78
+ ### Known limit (ticketed, not fixed here)
79
+ - The off-capability table reads the WIRE's ability to say off; a thinking-only model served through a format that has a disable key (a Qwen thinking-only build, a mandatory-reasoning model behind openrouter/together) ignores that key, so its classifier still gets the 256-token cap and fails the verdict contract. The per-model fact the compat declaration would need (`mandatoryReasoning` / a classifier-specific budget knob) is a follow-up.
20
80
 
21
81
  ## 7.6.1 — 2026-09-07
22
82
 
@@ -6,14 +6,14 @@ import { inlineUntrusted } from "../core/untrusted-text.js";
6
6
  import { stepsFromMessages } from "./subagent-steps.js";
7
7
  import { REPORT_FIELD_MAX } from "./subagent.js";
8
8
  import { getSessionRetainLedger } from "./retain-ledger.js";
9
+ import { toolFace } from "../core/tool-catalog-entries.js";
9
10
  export const AGENT_TRANSCRIPT_TOOL_NAME = "AgentTranscript";
10
11
  const AGENT_TRANSCRIPT_DEFAULT_N = 20;
11
12
  const AGENT_TRANSCRIPT_MAX_N = 50;
12
13
  export function createAgentTranscriptTool(opts) {
13
14
  return defineTool({
14
15
  name: AGENT_TRANSCRIPT_TOOL_NAME,
15
- contract: { contractId: "core.agent_transcript@1", implementationRevision: "1" },
16
- effect: "read",
16
+ ...toolFace("agent-transcript"),
17
17
  description: `Read the recent tool steps of a background agent you launched (task_id 'a…' from the Agent tool with ` +
18
18
  `run_in_background), so you can see precisely where it got to — useful before SendMessage-ing a follow-up, ` +
19
19
  `or to inspect a stopped agent's tail. Returns the last N steps (tool, target, outcome); it does not re-run ` +
@@ -4,15 +4,14 @@ import { inlineUntrusted } from "../core/untrusted-text.js";
4
4
  import { formatPeerNameRef } from "./cross-session-ref.js";
5
5
  import { entryAccessible } from "./roster-store.js";
6
6
  import { mintPeerSessionCandidates, peerSessionBoxHandle } from "./peer-directory.js";
7
+ import { toolFace } from "../core/tool-catalog-entries.js";
7
8
  export const LIST_AGENTS_TOOL_NAME = "ListAgents";
8
9
  export const LIST_AGENTS_TOOL_ALIAS = "ListPeers";
9
10
  export const LIST_AGENTS_MAX_RESULT_CHARS = 10_000;
10
11
  export function createListAgentsTool(opts) {
11
12
  return defineTool({
12
13
  name: LIST_AGENTS_TOOL_NAME,
13
- aliases: [LIST_AGENTS_TOOL_ALIAS],
14
- contract: { contractId: "core.list_agents@1", implementationRevision: "1" },
15
- effect: "read",
14
+ ...toolFace("list-agents"),
16
15
  isConcurrencySafe: () => true,
17
16
  description: `List the agents and peer sessions you can message with SendMessage. Peer sessions are other sessions of this ` +
18
17
  `engine running for the same user on this machine, shown as \`name [ref]\`; send to one with its name (or \`name [ref]\` ` +
@@ -2,6 +2,7 @@ import { Type } from "typebox";
2
2
  import { defineTool } from "../core/tools.js";
3
3
  import { createSafeNotifier } from "../core/safe-notify.js";
4
4
  import { escapeEnvelopeTag } from "../core/untrusted-text.js";
5
+ import { toolFace } from "../core/tool-catalog-entries.js";
5
6
  export const OBSERVER_EVENT_TAGS = ["tool-call", "user-message", "tool-result", "turn-ended"];
6
7
  const TAG_ESCAPE_RE = new RegExp(`<(?=/?(?:${OBSERVER_EVENT_TAGS.join("|")})(?:[>\\s/]|$))`, "gi");
7
8
  export function escapeObserverTags(text) {
@@ -377,9 +378,8 @@ export function frameObserverReport(observerAgentName, report) {
377
378
  export function createObserverReportToolSpec(opts) {
378
379
  return {
379
380
  name: OBSERVER_REPORT_TOOL_NAME,
380
- contract: { contractId: "core.observer_report@1", implementationRevision: "1" },
381
+ ...toolFace("observer-report"),
381
382
  description: OBSERVER_REPORT_DESCRIPTION,
382
- effect: "write",
383
383
  parameters: Type.Object({
384
384
  report: Type.String({
385
385
  minLength: 1,
@@ -78,7 +78,7 @@ export interface PeerIdentity {
78
78
  ownTokens: string[];
79
79
  }
80
80
  /**
81
- * design/176 §4.1 — the LATE-BOUND self-identity carrier ({@link import("../core/runner/prepare-task.js").RunInternals}`.peerSelfRef`).
81
+ * design/176 §4.1 — the LATE-BOUND self-identity carrier ({@link import("../core/runner/contracts.js").RunInternals}`.peerSelfRef`).
82
82
  * A ref (same family as `ownOrgAdmissionRef`): revival replays a spread COPY of spawn-time
83
83
  * internals, so a plain field would freeze at its spawn value; and a root run's session axis only
84
84
  * exists once `prepareTask` acquires the session — no single assembly point can synthesize the full
@@ -14,6 +14,7 @@ import { neutralizePeerBody } from "./cross-session-envelope.js";
14
14
  import { SUBAGENT_RESUME_CAP, SubagentRetainLedger, getSessionRetainLedger } from "./retain-ledger.js";
15
15
  import { createSubagentResume } from "./subagent.js";
16
16
  import { appendHopToken, judgePeerAdmission, peerAxisToken, resolvePeerAdmissionConfig, PEER_MESSAGE_NOTICE, } from "./peer-admission.js";
17
+ import { toolFace } from "../core/tool-catalog-entries.js";
17
18
  export const SEND_MESSAGE_TOOL_NAME = "SendMessage";
18
19
  let uplinkSeqGlobal = Date.now();
19
20
  const UPLINK_RESULT_MAX = 8000;
@@ -134,7 +135,7 @@ export function createSendMessageTool(opts) {
134
135
  };
135
136
  return defineTool({
136
137
  name: SEND_MESSAGE_TOOL_NAME,
137
- contract: { contractId: "core.send_message@1", implementationRevision: "1" },
138
+ ...toolFace("send-message"),
138
139
  executionMode: "parallel",
139
140
  description: `Send a follow-up message to a previously spawned background agent. ` +
140
141
  `Your plain text output is NOT visible to other agents — to communicate, you MUST call this tool. Messages addressed to you are delivered automatically; you don't check an inbox. ` +
@@ -1,8 +1,9 @@
1
1
  import { redactSecrets } from "../core/untrusted-egress.js";
2
+ import { isProtectedWrite } from "../core/tool-registry.js";
3
+ import { writeTargetPath } from "../tools/fs/safety.js";
2
4
  export const STEP_CAP = 10;
3
5
  const FIELD_MAX = 80;
4
6
  const EDITED_FILES_CAP = 32;
5
- const EDITING_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
6
7
  function firstLine(s) {
7
8
  const nl = s.indexOf("\n");
8
9
  return nl === -1 ? s : s.slice(0, nl);
@@ -57,10 +58,10 @@ export function extractTarget(args) {
57
58
  }
58
59
  }
59
60
  function editTargetPath(toolName, args) {
60
- if (!EDITING_TOOLS.has(toolName) || args === null || typeof args !== "object")
61
+ const call = { toolName, args };
62
+ if (!isProtectedWrite(call) || args === null || typeof args !== "object")
61
63
  return undefined;
62
- const a = args;
63
- const p = a.file_path ?? a.path ?? a.notebook_path;
64
+ const p = writeTargetPath(call);
64
65
  return typeof p === "string" && p.length > 0 ? p : undefined;
65
66
  }
66
67
  function outputFirstLine(output) {
@@ -60,6 +60,7 @@ import { recordRosterSpawn } from "./roster-store.js";
60
60
  import { LAUNCH_RECEIPT_OWN_WORDS_CLAUSE, launchReceiptNoQuoteClause } from "./launch-receipt-contract.js";
61
61
  import { ObserverDigestTap, ObserverPairing, createObserverReportToolSpec, markObserverTaskId, unmarkObserverTaskId, isObserverTaskId, ObserverResumeStateError, ObserverStoppedByUserError, observerFramingPrompt, observerSlug, resolveObserverDeclaration, } from "./observer.js";
62
62
  import { SubagentStepRecorder } from "./subagent-steps.js";
63
+ import { toolFace } from "../core/tool-catalog-entries.js";
63
64
  const BG_AGENT_RESULT_MAX = 4_000;
64
65
  const BG_AGENT_RESULT_FULL_MAX = 200_000;
65
66
  function resultSettleFields(result) {
@@ -243,7 +244,6 @@ function createToolStatsCounter(delegationToolName) {
243
244
  t.bashCount++;
244
245
  break;
245
246
  case "Edit":
246
- case "MultiEdit":
247
247
  t.editFileCount++;
248
248
  recordEditLines(args);
249
249
  break;
@@ -1392,13 +1392,12 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1392
1392
  }));
1393
1393
  return {
1394
1394
  name: opts.name ?? DEFAULT_SUBAGENT_TOOL_NAME,
1395
+ ...toolFace("agent"),
1395
1396
  agentListing,
1396
1397
  agentToolFaces,
1397
1398
  ...(agentToolPool !== undefined ? { agentToolPool } : {}),
1398
- contentOrigin: "local",
1399
1399
  ...(rosterNames !== undefined ? { agentModels: rosterNames } : {}),
1400
1400
  executionMode: "parallel",
1401
- contract: { contractId: "core.agent@1", implementationRevision: "1" },
1402
1401
  description: `Launch a new agent to handle complex, multi-step tasks. Each agent type has specific capabilities and tools available to it.\n` +
1403
1402
  (opts.purpose ? `\nThis sub-agent is for: ${opts.purpose}.\n` : "") +
1404
1403
  (hasAgents ? `\nAvailable agent types are listed in <system-reminder> messages in the conversation.\n` : "") +
@@ -276,9 +276,11 @@ export function createAnthropicBrain(config = {}) {
276
276
  body.temperature = options.temperature;
277
277
  }
278
278
  let builtReasoningFacts;
279
+ let thinkingOn = false;
279
280
  if (reasoningRequestCarried(model, options?.reasoning)) {
280
281
  if (anthCompat.thinkingMode === "adaptive") {
281
282
  body.thinking = { type: "adaptive" };
283
+ thinkingOn = true;
282
284
  }
283
285
  else {
284
286
  const hardCap = overrides?.maxOutputTokens !== undefined || options?.maxTokens !== undefined;
@@ -292,11 +294,15 @@ export function createAnthropicBrain(config = {}) {
292
294
  const { max, budget } = thinkingBudget(body.max_tokens, share, config.thinkingBudgetTokens, hardCap);
293
295
  body.max_tokens = max;
294
296
  body.thinking = { type: "enabled", budget_tokens: budget };
297
+ thinkingOn = true;
295
298
  }
296
299
  }
297
- if (body.thinking !== undefined)
300
+ if (thinkingOn)
298
301
  delete body.temperature;
299
302
  }
303
+ else if (model.reasoning && options?.reasoning === "off" && anthCompat.thinkingMode !== "adaptive") {
304
+ body.thinking = { type: "disabled" };
305
+ }
300
306
  const betas = [];
301
307
  let sendEffortBeta = false;
302
308
  const declaredEffort = declaredEffortLevels(anthCompat.effortLevels);
@@ -308,7 +314,7 @@ export function createAnthropicBrain(config = {}) {
308
314
  }
309
315
  if (sendEffortBeta)
310
316
  betas.push("effort-2025-11-24");
311
- if (anthCompat.contextManagement && body.thinking !== undefined) {
317
+ if (anthCompat.contextManagement && thinkingOn) {
312
318
  body.context_management = { edits: [{ type: "clear_thinking_20251015", keep: "all" }] };
313
319
  betas.push("context-management-2025-06-27");
314
320
  }
@@ -7,7 +7,7 @@ import { emitBrainTelemetry } from "./status-sink.js";
7
7
  import { errorResultMediaNote, IMAGE_OMITTED_NO_VISION, imagesOmittedNoVisionNote, modelSupportsVision, sendableImages } from "./media-degrade.js";
8
8
  import { OUTPUT_CAP_KEYS, RESPONSES_RESERVED, applyExtraBody, effectiveOutputCap, lockHeader, mergeHeaders } from "./request-params.js";
9
9
  import { adjudicateModelRoute, applyRouteCredentialHeaders, createBrainRouteJudge, resolveRouteCredential, routeRefusalText } from "./route-adjudicator.js";
10
- import { mintEffortWireValue, reasoningRequestCarried } from "./reasoning.js";
10
+ import { declaredOffSpelling, mintEffortWireValue, reasoningRequestCarried } from "./reasoning.js";
11
11
  import { runStreamingBrain } from "./stream-engine.js";
12
12
  const DEGENERATE_POLL_CHARS = 64;
13
13
  const MALFORMED_SAMPLE_CHARS = 160;
@@ -152,11 +152,13 @@ function toResponsesTools(ctx) {
152
152
  }));
153
153
  }
154
154
  function resolveWireEffort(model, reasoning) {
155
- if (!reasoningRequestCarried(model, reasoning))
156
- return undefined;
155
+ if (model.reasoning && reasoning === "off")
156
+ return declaredOffSpelling(model);
157
157
  const compat = responsesCompat(model);
158
158
  if (compat.supportsReasoningEffort === false)
159
159
  return undefined;
160
+ if (!reasoningRequestCarried(model, reasoning))
161
+ return undefined;
160
162
  return mintEffortWireValue(reasoning, model, compat.reasoningEffortLevels).wireValue;
161
163
  }
162
164
  function computeUsage(model, raw) {
@@ -8,7 +8,7 @@ import { errorResultMediaNote, IMAGE_OMITTED_NO_VISION, imagesOmittedNoVisionNot
8
8
  import { OPENAI_RESERVED, OUTPUT_CAP_KEYS, applyExtraBody, effectiveOutputCap, lockHeader, mergeHeaders } from "./request-params.js";
9
9
  import { BrainError } from "./errors.js";
10
10
  import { adjudicateModelRoute, applyRouteCredentialHeaders, createBrainRouteJudge, resolveRouteCredential, routeRefusalText } from "./route-adjudicator.js";
11
- import { mintEffortWireValue, reasoningRequestCarried } from "./reasoning.js";
11
+ import { declaredOffSpelling, mintEffortWireValue, reasoningRequestCarried } from "./reasoning.js";
12
12
  import { runStreamingBrain } from "./stream-engine.js";
13
13
  function closeToolCallAccum(acc) {
14
14
  if (acc.closedTc)
@@ -78,13 +78,36 @@ function thinkingCompat(model) {
78
78
  }
79
79
  function applyThinking(body, model, reasoning) {
80
80
  if (model.reasoning && reasoning === "off") {
81
- const offFormat = thinkingCompat(model).thinkingFormat ?? "openai";
82
- if (offFormat === "qwen" || offFormat === "zai") {
83
- body.enable_thinking = false;
84
- }
85
- else if (offFormat === "qwen-chat-template") {
86
- const k = (typeof body.chat_template_kwargs === "object" && body.chat_template_kwargs) || {};
87
- body.chat_template_kwargs = { ...k, enable_thinking: false };
81
+ const offCompat = thinkingCompat(model);
82
+ const offFormat = offCompat.thinkingFormat ?? "openai";
83
+ switch (offFormat) {
84
+ case "qwen":
85
+ case "zai":
86
+ body.enable_thinking = false;
87
+ break;
88
+ case "qwen-chat-template": {
89
+ const k = (typeof body.chat_template_kwargs === "object" && body.chat_template_kwargs) || {};
90
+ body.chat_template_kwargs = { ...k, enable_thinking: false };
91
+ break;
92
+ }
93
+ case "deepseek":
94
+ body.thinking = { type: "disabled" };
95
+ break;
96
+ case "openrouter":
97
+ case "together":
98
+ body.reasoning = { enabled: false };
99
+ break;
100
+ case "openai": {
101
+ const offSpelling = declaredOffSpelling(model);
102
+ if (offSpelling !== undefined)
103
+ body.reasoning_effort = offSpelling;
104
+ break;
105
+ }
106
+ default: {
107
+ const _exhaustive = offFormat;
108
+ void _exhaustive;
109
+ break;
110
+ }
88
111
  }
89
112
  return;
90
113
  }
@@ -104,6 +104,38 @@ export declare const MIN_THINKING_TOKENS = 1024;
104
104
  * host the budget instead (`hardCap === false`).
105
105
  */
106
106
  export declare function budgetCapSkipsThinking(outputCapTokens: number, hardCap: boolean): boolean;
107
+ /** The model slice the off-capability reads: the API family, the reasoning bit, the compat declaration and the levelmap. */
108
+ export interface OffCapabilityModel {
109
+ api?: string;
110
+ reasoning?: boolean;
111
+ compat?: unknown;
112
+ thinkingLevelMap?: Readonly<Partial<Record<ThinkingLevel, string | null>>>;
113
+ }
114
+ /**
115
+ * The DEPLOYMENT-declared spelling of the explicit "off" tier on an effort-only wire (stock openai
116
+ * completions, the Responses lane): `thinkingLevelMap.off` as a non-empty string (a provider's own
117
+ * "none" tier), on an endpoint that takes an effort value at all (`supportsReasoningEffort` not
118
+ * false) — else `undefined`: there is nothing to send. The effort formats have no disable key, and an
119
+ * effort VALUE is never off (it enables reasoning at that tier), so off on those wires is spelled only
120
+ * when the deployment says how. The two appliers and {@link thinkingOffExpressible} read this ONE
121
+ * function, so what the wire carries and what the capability table claims cannot disagree.
122
+ */
123
+ export declare function declaredOffSpelling(model: OffCapabilityModel): string | undefined;
124
+ /**
125
+ * Whether this model's wire can SAY "off". A model that does not reason at all (`reasoning` falsy)
126
+ * never thinks, so off is trivially expressible. Otherwise, per wire: the anthropic budget form spells
127
+ * `{type:"disabled"}` (the ADAPTIVE form rejects it — those models think on every request); deepseek
128
+ * `{type:"disabled"}`, the binary enable keys at `false`, openrouter/together `reasoning:{enabled:false}`
129
+ * all say it; the effort-only wires (stock openai completions, the Responses lane) say it only through
130
+ * a deployment-declared off spelling ({@link declaredOffSpelling}).
131
+ *
132
+ * A caller that sizes an output cap for an ANSWER alone (the auto-mode classifier's block-verdict
133
+ * contract) applies the cap only when this is true: a model that will think regardless cannot have its
134
+ * cap derived from the answer's contract, and keeps its own budget. What this table cannot see is a
135
+ * model that IGNORES its wire's disable key (a thinking-only build served through a format that has
136
+ * one) — that is a per-model fact the compat declaration does not yet carry.
137
+ */
138
+ export declare function thinkingOffExpressible(model: OffCapabilityModel): boolean;
107
139
  /**
108
140
  * OPTIONAL per-request facts for {@link resolveReasoning} — what the wire's budget arm knows at
109
141
  * request build that a per-leg eager resolution cannot: the resolved output cap and whether it is a
@@ -23,6 +23,24 @@ export const MIN_THINKING_TOKENS = 1024;
23
23
  export function budgetCapSkipsThinking(outputCapTokens, hardCap) {
24
24
  return hardCap && outputCapTokens < MIN_THINKING_TOKENS * 2;
25
25
  }
26
+ export function declaredOffSpelling(model) {
27
+ const compat = (model.compat ?? {});
28
+ if (compat.supportsReasoningEffort === false)
29
+ return undefined;
30
+ const mapped = model.thinkingLevelMap?.off;
31
+ return typeof mapped === "string" && mapped !== "" ? mapped : undefined;
32
+ }
33
+ export function thinkingOffExpressible(model) {
34
+ if (!model.reasoning)
35
+ return true;
36
+ const compat = (model.compat ?? {});
37
+ if (model.api === "anthropic-messages")
38
+ return compat.thinkingMode !== "adaptive";
39
+ if (model.api !== undefined && RESPONSES_APIS.has(model.api))
40
+ return declaredOffSpelling(model) !== undefined;
41
+ const format = compat.thinkingFormat ?? "openai";
42
+ return format === "openai" ? declaredOffSpelling(model) !== undefined : true;
43
+ }
26
44
  export function declaredEffortLevels(v) {
27
45
  return Array.isArray(v) && v.length > 0 ? v : undefined;
28
46
  }
@@ -3,6 +3,7 @@ import { uuidv7 } from "../engine/session/uuid.js";
3
3
  import { boundInputHashOf } from "./canonical-json.js";
4
4
  import { defineTool, errorResult } from "./tools.js";
5
5
  import { delimitUntrusted, inlineUntrusted } from "./untrusted-text.js";
6
+ import { toolFace } from "./tool-catalog-entries.js";
6
7
  export const ASK_USER_QUESTION_TOOL_NAME = "AskUserQuestion";
7
8
  export function classifyQuestionOutcome(outcome) {
8
9
  let unavailable;
@@ -170,7 +171,7 @@ export function createAskUserQuestionTool(onQuestion, source, opts) {
170
171
  const continuationCard = askQuestionContinuationCard;
171
172
  return defineTool({
172
173
  name: ASK_USER_QUESTION_TOOL_NAME,
173
- contract: { contractId: "core.ask_user_question@1", implementationRevision: "1" },
174
+ ...toolFace("ask-user-question"),
174
175
  description: "Ask the user a structured multiple-choice question when you are genuinely stuck or a decision is " +
175
176
  "the user's to make (which approach, which of these). Provide 1-4 questions, each with 2-4 options " +
176
177
  "(label + description). Use sparingly — not for anything you can determine yourself from the code or " +
@@ -226,7 +227,6 @@ export function createAskUserQuestionTool(onQuestion, source, opts) {
226
227
  })),
227
228
  }), { minItems: 1, maxItems: 4, description: "Questions to ask the user (1-4 questions)" }),
228
229
  }),
229
- effect: "read",
230
230
  executionMode: "sequential",
231
231
  execute: async (args, ctx) => {
232
232
  const { questions } = args;
@@ -6,6 +6,22 @@ export declare const AUTO_MODE_DEFAULT_FAILURE_THRESHOLD = 3;
6
6
  export declare const AUTO_MODE_DEFAULT_WINDOW_MAX_ENTRIES = 40;
7
7
  /** Per-entry excerpt cap when `AutoModeWindowOptions.maxCharsPerEntry` is omitted. */
8
8
  export declare const AUTO_MODE_DEFAULT_WINDOW_MAX_CHARS = 2000;
9
+ /**
10
+ * The classifier call's output cap (`maxTokens`), sized from the engine's OWN output contract rather
11
+ * than copied from upstream: the parser (`parseAutoModeResponse`) accepts exactly two shapes,
12
+ * `<block>no</block>` and `<block>yes</block><category>NAME</category><reason>[NAME] one short
13
+ * sentence</reason>`. The longest rule name in the shipped rule sets is 46 characters and the reason
14
+ * is one short sentence naming that rule (≈200 characters at the outside, more when several rules
15
+ * match and the others are named in the reason), so the longest LEGAL reply is ≈350 characters —
16
+ * ≈120 tokens at the conservative 3 chars/token that tag-heavy text tokenizes at, ≈150 with a
17
+ * second and third rule name in the reason. Doubled for margin: a truncated block verdict still
18
+ * parses as a block (the parser tolerates a missing tail), a truncated allow does not exist (it is
19
+ * seven tokens), and the cap's job is to make a model that ignores the "no preamble" instruction —
20
+ * or that reasons in-text — cost a bounded, quickly-detected contract failure instead of a slow one.
21
+ * Paired with `reasoning:"off"` at the call: the cap hosts the ANSWER, so it applies only where the
22
+ * wire can say off (`thinkingOffExpressible`); a model that thinks regardless keeps its own budget.
23
+ */
24
+ export declare const AUTO_MODE_CLASSIFIER_MAX_TOKENS = 256;
9
25
  /** The sentinel a deployment puts INSIDE a paired rule list to splice the CC default rules back in at
10
26
  * that position (CC `XYt = "$defaults"`). It lives here, beside the other defaults, because the #503
11
27
  * recipe canonicalizer needs its VALUE and must not load the assembly face (and its SHA-locked assets)
@@ -2,6 +2,7 @@ export const AUTO_MODE_DEFAULT_TIMEOUT_MS = 15_000;
2
2
  export const AUTO_MODE_DEFAULT_FAILURE_THRESHOLD = 3;
3
3
  export const AUTO_MODE_DEFAULT_WINDOW_MAX_ENTRIES = 40;
4
4
  export const AUTO_MODE_DEFAULT_WINDOW_MAX_CHARS = 2_000;
5
+ export const AUTO_MODE_CLASSIFIER_MAX_TOKENS = 256;
5
6
  export const AUTO_MODE_DEFAULTS_SENTINEL = "$defaults";
6
7
  export const AUTO_MODE_DENIAL_LIMIT_DEFAULTS = Object.freeze({ maxConsecutive: 3, maxTotal: 20 });
7
8
  export const AUTO_MODE_DENIAL_AUTO_DENY_DEFAULT_MS = 120_000;
@@ -70,6 +70,25 @@ export interface AutoModeDeciderOptions {
70
70
  consecutiveFailures: number;
71
71
  lastCause: string;
72
72
  }) => void;
73
+ /**
74
+ * Fired once per `decide` call with the verdict the gate is about to act on and the wall time it
75
+ * waited (`ms`, integer ≥ 0; a timed-out round reads the deadline; a breaker-open short-circuit
76
+ * reads ~0 with `cause:"breaker_open"`). `cause` is present iff the verdict is `unavailable`. The
77
+ * engine's own wiring turns this into the `auto_mode.classified` trace frame; a hand-built decider
78
+ * need not implement it. Like the breaker alarm, a throwing hook never breaks the gate.
79
+ */
80
+ onClassified?: (info: AutoModeClassified) => void;
81
+ }
82
+ /** What {@link AutoModeDeciderOptions.onClassified} receives — one record per decision. */
83
+ export interface AutoModeClassified {
84
+ /** The gated call the decision was about (`AutoModeClassifyInput.req.toolCallId`). */
85
+ toolCallId: string;
86
+ /** Wall-clock milliseconds between the decide call and its verdict. */
87
+ ms: number;
88
+ verdict: AutoModeVerdict["kind"];
89
+ cause?: Extract<AutoModeVerdict, {
90
+ kind: "unavailable";
91
+ }>["cause"];
73
92
  }
74
93
  export interface AutoModeDecider {
75
94
  /** Never rejects. Any internal failure surfaces as `unavailable`/`parse_error` (fail-closed). */