@sema-agent/core 7.7.0 → 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 (135) hide show
  1. package/CHANGELOG.md +37 -0
  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/send-message-tool.js +2 -1
  6. package/dist/agents/subagent-steps.js +5 -4
  7. package/dist/agents/subagent.js +2 -3
  8. package/dist/core/ask-question.js +2 -2
  9. package/dist/core/checkpoint-store.d.ts +38 -4
  10. package/dist/core/context-edit.d.ts +0 -22
  11. package/dist/core/context-edit.js +2 -11
  12. package/dist/core/engine-notice.d.ts +6 -0
  13. package/dist/core/fs-write-gate-policy.d.ts +7 -1
  14. package/dist/core/fs-write-gate-policy.js +7 -7
  15. package/dist/core/gate-fold.js +2 -2
  16. package/dist/core/gate-lanes.js +4 -3
  17. package/dist/core/governance-codes.d.ts +1 -1
  18. package/dist/core/governance-codes.js +6 -0
  19. package/dist/core/hooks.d.ts +8 -2
  20. package/dist/core/hooks.js +1 -1
  21. package/dist/core/lsp.js +2 -2
  22. package/dist/core/mcp-server-spec.d.ts +17 -0
  23. package/dist/core/mcp.js +3 -1
  24. package/dist/core/memory-engine/tools.js +4 -12
  25. package/dist/core/permission-rules.d.ts +32 -2
  26. package/dist/core/permission-rules.js +43 -37
  27. package/dist/core/present-plan-tool.js +3 -4
  28. package/dist/core/runner/active-skill-scope.js +7 -4
  29. package/dist/core/runner/compaction-call-options.d.ts +1 -1
  30. package/dist/core/runner/contracts.d.ts +27 -3
  31. package/dist/core/runner/prepare-ask-lane.d.ts +1 -1
  32. package/dist/core/runner/prepare-caps-and-workflow.d.ts +5 -4
  33. package/dist/core/runner/prepare-caps-and-workflow.js +16 -11
  34. package/dist/core/runner/prepare-defer-classify.d.ts +5 -26
  35. package/dist/core/runner/prepare-defer-classify.js +10 -10
  36. package/dist/core/runner/prepare-delegation-surface.d.ts +5 -4
  37. package/dist/core/runner/prepare-delegation-surface.js +15 -17
  38. package/dist/core/runner/prepare-gate-stations.d.ts +4 -1
  39. package/dist/core/runner/prepare-gate-stations.js +3 -1
  40. package/dist/core/runner/prepare-hands-readface.d.ts +3 -2
  41. package/dist/core/runner/prepare-hands-readface.js +3 -2
  42. package/dist/core/runner/prepare-inherited-gate.d.ts +1 -1
  43. package/dist/core/runner/prepare-inherited-gate.js +1 -0
  44. package/dist/core/runner/prepare-lsp.d.ts +4 -3
  45. package/dist/core/runner/prepare-lsp.js +3 -2
  46. package/dist/core/runner/prepare-memory-engine-session.d.ts +4 -4
  47. package/dist/core/runner/prepare-memory-engine-session.js +4 -2
  48. package/dist/core/runner/prepare-offload-wrappers.js +7 -5
  49. package/dist/core/runner/prepare-park-ask.d.ts +4 -0
  50. package/dist/core/runner/prepare-park-ask.js +4 -1
  51. package/dist/core/runner/prepare-project-context.d.ts +5 -23
  52. package/dist/core/runner/prepare-project-context.js +7 -5
  53. package/dist/core/runner/prepare-prompt-assembly.d.ts +1 -1
  54. package/dist/core/runner/prepare-protocol-tools.d.ts +5 -1
  55. package/dist/core/runner/prepare-protocol-tools.js +38 -21
  56. package/dist/core/runner/prepare-question-face.d.ts +4 -3
  57. package/dist/core/runner/prepare-question-face.js +5 -3
  58. package/dist/core/runner/prepare-run-refs.d.ts +16 -0
  59. package/dist/core/runner/prepare-run-refs.js +6 -0
  60. package/dist/core/runner/prepare-safety-scan.js +42 -2
  61. package/dist/core/runner/prepare-task.js +37 -19
  62. package/dist/core/runner/prepare-tool-disclosure-mount.d.ts +5 -2
  63. package/dist/core/runner/prepare-tool-disclosure-mount.js +3 -1
  64. package/dist/core/runner/prepare-wiring-manifest.d.ts +4 -0
  65. package/dist/core/runner/prepare-wiring-manifest.js +3 -8
  66. package/dist/core/runner/run-harness-handlers.d.ts +46 -0
  67. package/dist/core/runner/run-harness-handlers.js +345 -0
  68. package/dist/core/runner/run-turn-boundary.d.ts +69 -0
  69. package/dist/core/runner/run-turn-boundary.js +694 -0
  70. package/dist/core/runner/runtask.js +47 -1043
  71. package/dist/core/runner/session-rule-policy.js +9 -4
  72. package/dist/core/runner/synthetic-tools.js +5 -8
  73. package/dist/core/runner/tool-disclosure.d.ts +4 -0
  74. package/dist/core/runner/tool-disclosure.js +9 -5
  75. package/dist/core/runner/tool-end-body.js +3 -2
  76. package/dist/core/runner/tool-face-overlay.d.ts +40 -0
  77. package/dist/core/runner/tool-face-overlay.js +125 -0
  78. package/dist/core/runner/tool-output-projection.d.ts +10 -0
  79. package/dist/core/runner/tool-output-projection.js +33 -10
  80. package/dist/core/sensitive-path-policy.d.ts +3 -0
  81. package/dist/core/sensitive-path-policy.js +6 -5
  82. package/dist/core/shared-memory/tools.js +3 -6
  83. package/dist/core/store-contracts/checkpoint-store-contract.js +23 -0
  84. package/dist/core/task-event.d.ts +10 -0
  85. package/dist/core/task-registry.js +4 -5
  86. package/dist/core/task-spec.d.ts +8 -0
  87. package/dist/core/tool-catalog-entries.d.ts +48 -0
  88. package/dist/core/tool-catalog-entries.js +174 -0
  89. package/dist/core/tool-conformance.d.ts +22 -0
  90. package/dist/core/tool-conformance.js +37 -0
  91. package/dist/core/tool-face.d.ts +171 -0
  92. package/dist/core/tool-face.js +25 -0
  93. package/dist/core/tool-policy.d.ts +24 -0
  94. package/dist/core/tool-policy.js +4 -2
  95. package/dist/core/tool-registry.d.ts +114 -0
  96. package/dist/core/tool-registry.js +114 -0
  97. package/dist/core/tool-result-budget.d.ts +0 -3
  98. package/dist/core/tool-result-budget.js +4 -3
  99. package/dist/core/tool-result-store.d.ts +0 -18
  100. package/dist/core/tool-result-store.js +2 -18
  101. package/dist/core/tool-roster.d.ts +450 -0
  102. package/dist/core/tool-roster.js +497 -0
  103. package/dist/core/tool-spec.d.ts +54 -3
  104. package/dist/core/tools.js +7 -0
  105. package/dist/core/wiring-manifest.d.ts +13 -0
  106. package/dist/core/wiring-manifest.js +2 -1
  107. package/dist/core/write-protect.d.ts +2 -1
  108. package/dist/core/write-protect.js +5 -4
  109. package/dist/engine/compaction/utils.d.ts +6 -0
  110. package/dist/engine/compaction/utils.js +19 -19
  111. package/dist/engine/loop/types.d.ts +6 -0
  112. package/dist/index.d.ts +15 -4
  113. package/dist/index.js +15 -4
  114. package/dist/orchestration/goal.js +2 -1
  115. package/dist/orchestration/run-spec.js +3 -5
  116. package/dist/orchestration/run-workflow-tool.js +2 -2
  117. package/dist/prompt-assembly/event-registry.js +3 -3
  118. package/dist/prompt-assembly/tool-catalog.d.ts +2 -2
  119. package/dist/tools/fs/fs-bash.d.ts +1 -1
  120. package/dist/tools/fs/fs-bash.js +7 -11
  121. package/dist/tools/fs/fs-read.js +2 -2
  122. package/dist/tools/fs/fs-search-tools.d.ts +0 -7
  123. package/dist/tools/fs/fs-search-tools.js +3 -16
  124. package/dist/tools/fs/fs-write.js +4 -6
  125. package/dist/tools/fs/repo-map.js +2 -2
  126. package/dist/tools/fs/safety.d.ts +2 -1
  127. package/dist/tools/fs/safety.js +6 -5
  128. package/dist/tools/monitor.js +2 -2
  129. package/dist/tools/scheduler-tools.js +5 -8
  130. package/dist/tools/task-list.js +5 -8
  131. package/dist/tools/todo.js +2 -2
  132. package/dist/tools/web.js +3 -6
  133. package/dist/tools/worktree.js +3 -4
  134. package/package.json +4 -1
  135. package/test/export-surface.snapshot.json +147 -7
package/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## 7.8.0 — 2026-09-08
4
+
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
+
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).
21
+
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).
31
+
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() }))`.
36
+
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
+
3
40
  ## 7.7.0 — 2026-09-07
4
41
 
5
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.
@@ -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,
@@ -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` : "") +
@@ -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;
@@ -822,6 +822,36 @@ export type PendingAction = {
822
822
  * pre-1a.2 checkpoint (deserialized without it), in which case the resume skips the hash check.
823
823
  */
824
824
  boundInputHash: string;
825
+ /**
826
+ * design/388 §2.5 boundary ② (B18; additive — no checkpoint-version bump, the `previewWithheld`
827
+ * precedent: an optional group an older reader ignores): the IDENTITY of the row the park minted
828
+ * this action against, read off the leg's tool roster at the suspend station — `capabilityId`
829
+ * (engine-minted over {source, peer, name, contractId}), `contractId`, `shapeDigest`,
830
+ * `implementationRevision` — plus the roster `digest` of the leg that parked. The resume finds the
831
+ * same-name row on ITS leg and compares the FOUR identity members (`judgeParkedToolIdentity`): any
832
+ * mismatch is a replacement wearing the name and the resume refuses it
833
+ * (`resume.tool_contract_mismatch`, the `tool_unavailable` reopen family). All four ABSENT ⇒ a row
834
+ * minted before the members existed ⇒ the name-only path, never widened by their absence.
835
+ */
836
+ capabilityId?: string;
837
+ contractId?: string;
838
+ shapeDigest?: string;
839
+ implementationRevision?: string;
840
+ rosterDigest?: string;
841
+ /**
842
+ * design/388 B6 × B18 (additive; the identity group's precedent): the FACE the parked call was judged under —
843
+ * display `family` + declared `pathTarget` — off the same roster row at the same mint. The pre-CAS resume-edit
844
+ * recheck has no roster yet; it rebuilds the call face from these (`parkedCallFaceOf`) and judges the edit as
845
+ * the live gate judged the original (merge-seam H1: without them a caller slot fell to the catalog's slot for
846
+ * the NAME). Any member absent ⇒ pre-388 row ⇒ the recheck falls to the catalog, never a widened grant.
847
+ */
848
+ family?: import("./tool-face.js").ToolFamily;
849
+ pathTarget?: {
850
+ param: string;
851
+ aliases: string[];
852
+ access: "read" | "create" | "edit";
853
+ skillScopeEligible: boolean;
854
+ };
825
855
  /**
826
856
  * #438 (additive; no checkpoint-version bump — the `reminderMark` precedent: an optional field an
827
857
  * older reader ignores. Unlike that precedent seat — which resume DOES read, through its
@@ -2119,11 +2149,13 @@ export declare class CheckpointError extends Error {
2119
2149
  * additive and optional like `field` — `code` remains the only REQUIRED discriminant.
2120
2150
  * · `unsupported_version` arms: `"version_newer"` (retryable on a newer worker),
2121
2151
  * `"env_factory_missing"` (retryable on a factory-wired worker), `"governed_unwired"`
2122
- * (retryable on an org-wired worker).
2152
+ * (retryable on an org-wired worker), `"origin_missing"` (a pending approval row minted
2153
+ * before the ask's origin was persisted on the park — terminal for the row's bytes; the host
2154
+ * decides the orphaned card itself, no worker version redeems it).
2123
2155
  * · `invalid_outcome` pre-CAS row-integrity arms: `"real_approval_damaged"`,
2124
2156
  * `"real_approval_forged"`, `"constraint_chain_missing"` (all terminal for the row's
2125
2157
  * current bytes — no worker version redeems a damaged row). */
2126
- reason?: "version_newer" | "env_factory_missing" | "governed_unwired" | "real_approval_damaged" | "real_approval_forged" | "read_face_damaged" | "read_face_forged" | "constraint_chain_missing";
2158
+ reason?: "version_newer" | "env_factory_missing" | "governed_unwired" | "origin_missing" | "real_approval_damaged" | "real_approval_forged" | "read_face_damaged" | "read_face_forged" | "constraint_chain_missing";
2127
2159
  } | undefined;
2128
2160
  constructor(code: "checkpoint.already_exists" | "checkpoint.already_resolved" | "checkpoint.not_found"
2129
2161
  /** `runner.resume` was handed an {@link ResumeOutcome} whose `gate` arm does not match the
@@ -2305,11 +2337,13 @@ export declare class CheckpointError extends Error {
2305
2337
  * additive and optional like `field` — `code` remains the only REQUIRED discriminant.
2306
2338
  * · `unsupported_version` arms: `"version_newer"` (retryable on a newer worker),
2307
2339
  * `"env_factory_missing"` (retryable on a factory-wired worker), `"governed_unwired"`
2308
- * (retryable on an org-wired worker).
2340
+ * (retryable on an org-wired worker), `"origin_missing"` (a pending approval row minted
2341
+ * before the ask's origin was persisted on the park — terminal for the row's bytes; the host
2342
+ * decides the orphaned card itself, no worker version redeems it).
2309
2343
  * · `invalid_outcome` pre-CAS row-integrity arms: `"real_approval_damaged"`,
2310
2344
  * `"real_approval_forged"`, `"constraint_chain_missing"` (all terminal for the row's
2311
2345
  * current bytes — no worker version redeems a damaged row). */
2312
- reason?: "version_newer" | "env_factory_missing" | "governed_unwired" | "real_approval_damaged" | "real_approval_forged" | "read_face_damaged" | "read_face_forged" | "constraint_chain_missing";
2346
+ reason?: "version_newer" | "env_factory_missing" | "governed_unwired" | "origin_missing" | "real_approval_damaged" | "real_approval_forged" | "read_face_damaged" | "read_face_forged" | "constraint_chain_missing";
2313
2347
  } | undefined);
2314
2348
  }
2315
2349
  /**
@@ -130,28 +130,6 @@ export declare const MIN_CLEAR_SAVINGS_TOKENS = 20000;
130
130
  /** Minimum kept results — CC `EUs` :397710 `Math.max(1, keepRecent)`. See
131
131
  * {@link ContextEditOptions.keepRecentToolResults} for why both degenerate ends are unusable. */
132
132
  export declare const MIN_KEEP_RECENT_TOOL_RESULTS = 1;
133
- /**
134
- * checklist #54 (CC microCompact.ts:41-50 `COMPACTABLE_TOOLS` parity): the INCLUSION whitelist of
135
- * tools whose results may be content-cleared by the stale-result pass. CC's set is exactly
136
- * { Read, Bash, PowerShell (SHELL_TOOL_NAMES), Grep, Glob, WebSearch, WebFetch, Edit, Write } —
137
- * cheap-to-regenerate I/O.
138
- *
139
- * RE-VERIFIED against the 2.1.250 corpus (design/374 slice 4, member-by-member — a set is exactly
140
- * the shape where "the count still matches" is not evidence): `eQt = new Set([wt, ...VD, Yo, Xo,
141
- * yO, Yr, tn, er])` at cli250.js:41714, whose aliases resolve to `wt`="Read", `VD`=[`Xe`="Bash",
142
- * `Nt`="PowerShell"], `Yo`="Grep", `Xo`="Glob", `yO`="WebSearch", `Yr`="WebFetch", `tn`="Edit",
143
- * `er`="Write" — nine members, byte-identical to the set below, zero drift from the 223 reading.
144
- * The exclusion side is the load-bearing half and is unchanged too: everything NOT here is
145
- * PRESERVED (Agent/Task/Skill/ToolSearch/TodoWrite/Monitor/SendMessage/NotebookEdit and ALL MCP
146
- * tools are never content-cleared by this pass, upstream or here).
147
- *
148
- * Everything else is PRESERVED: expensive/decision-bearing results
149
- * (Agent subagent reports, Task* registry state, TodoWrite acks, Monitor, MCP tools, custom
150
- * ToolSpecs) survive to the harder defenses (compaction summarizes them instead of blanking them).
151
- * PowerShell is kept for CC fidelity even though sema does not mount it. Overridable per call via
152
- * {@link ContextEditOptions.compactableTools} for custom-tool-heavy embedders.
153
- */
154
- export declare const COMPACTABLE_TOOLS: ReadonlySet<string>;
155
133
  export interface ContextEditOptions {
156
134
  /** Start clearing once estimated context tokens exceed this. */
157
135
  budgetTokens: number;
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { DEFAULT_CHARS_PER_TOKEN, estimateContextTokens, estimateTokens } from "../internal/harness.js";
3
3
  import { isToolResult } from "./message-utils.js";
4
4
  import { PERSISTED_OUTPUT_PREFIX, offloadPagebackHint } from "./tool-result-store.js";
5
+ import { compactableToolNames } from "./tool-registry.js";
5
6
  const CLEARED_MARKER = "[tool result cleared to save context]";
6
7
  const CC_CLEARED_MARKER = "[Old tool result content cleared]";
7
8
  const LEGACY_CLEARED_EXTENDED_PREFIX = "[tool result cleared to save context —";
@@ -46,17 +47,7 @@ function resolveKeepRecentToolResults(value, defaultValue) {
46
47
  }
47
48
  return Math.max(MIN_KEEP_RECENT_TOOL_RESULTS, value);
48
49
  }
49
- export const COMPACTABLE_TOOLS = new Set([
50
- "Read",
51
- "Bash",
52
- "PowerShell",
53
- "Grep",
54
- "Glob",
55
- "WebSearch",
56
- "WebFetch",
57
- "Edit",
58
- "Write",
59
- ]);
50
+ const COMPACTABLE_TOOLS = compactableToolNames();
60
51
  function soleMarkerText(m) {
61
52
  const content = m.content;
62
53
  if (!Array.isArray(content) || content.length !== 1)
@@ -17,6 +17,12 @@ export interface EngineNotice {
17
17
  * the value in force; `detail: { knob, raw, usedMs }`.
18
18
  * - `"config.materialize_env_discarded"` — `SEMA_TOOL_MATERIALIZE_STRATEGY` held a value outside the
19
19
  * closed set in a seat where it is not in force; `detail: { raw, specStrategy? }`.
20
+ * - `"config.tool_card_undeclared"` (design/388 L4) — a tool minted a `details.type` card its definition
21
+ * (or its caller declaration's `renderHints.resultCards`) never declared; the card was dropped from
22
+ * `tool_end.structured`; once per leg per type; `detail: { type, sessionId, runId }`.
23
+ * - `"config.tool_face_undeclared"` / `"config.tool_face_invalid"` (design/388 §2.7) — an MCP server's
24
+ * `toolFaces` overlay names a tool the listing does not carry / does not fit the tool's materialized
25
+ * schema (the tool mounts bare); once per leg per tool; `detail: { server, tool, problem?, sessionId, runId }`.
20
26
  * - `"tool_result.offload_put_failed"` (#167) — a clear-with-offload persist's fire-and-forget put
21
27
  * failed; THIS attempt stored nothing (the failure arm reports, it never re-inserts under the
22
28
  * ref) — an earlier attempt of the same idempotent re-put may already have stored the row, so
@@ -1,5 +1,11 @@
1
1
  import type { ExecutionEnv } from "../internal/harness-types.js";
2
2
  import type { ToolPolicy } from "./tool-policy.js";
3
+ /** design/388 B22 — the identity triple the exemption probe hands the store beside the name. */
4
+ export interface ToolCallIdentity {
5
+ capabilityId: string;
6
+ contractId: string;
7
+ shapeDigest: string;
8
+ }
3
9
  /** Options for {@link createFsWriteGatePolicy}. */
4
10
  export interface FsWriteGatePolicyOptions {
5
11
  /** The task's real execution env (the one the hands run against) — canonicalization must see the
@@ -37,7 +43,7 @@ export interface FsWriteGatePolicyOptions {
37
43
  * `combinePolicies` outranks this `allow` under the deny > ask > allow fold, and the two fail-closed
38
44
  * asks for an UNRESOLVABLE target fire before any canonical key exists, so they are never exempted.
39
45
  */
40
- isExempt?: (toolName: string, canonicalPath: string) => boolean | Promise<boolean>;
46
+ isExempt?: (toolName: string, canonicalPath: string, identity?: ToolCallIdentity) => boolean | Promise<boolean>;
41
47
  /**
42
48
  * design/380 O13 — the deployment HARD-CLOSE knob for guarded writes. Armed (`true`), a covered
43
49
  * write whose env does NOT provide `writeFileGuarded` is REFUSED (deny, typed message) — never the
@@ -1,9 +1,9 @@
1
1
  import { canonicalizeTarget, writeTargetPath } from "../tools/fs/safety.js";
2
- import { PATH_CONFINABLE_WRITE_TOOLS, isWithin } from "./runner/session-rule-policy.js";
2
+ import { isWithin } from "./runner/session-rule-policy.js";
3
+ import { declaredPathTargetOf, isProtectedWrite } from "./tool-registry.js";
3
4
  const ask = (message) => ({ action: "ask", message, decisionReason: "rule" });
4
5
  export function createFsWriteGatePolicy(opts) {
5
6
  const { env, rootPath, defaultWrite } = opts;
6
- const gated = PATH_CONFINABLE_WRITE_TOOLS;
7
7
  const acceptDirs = opts.acceptDirs && opts.acceptDirs.length > 0 ? opts.acceptDirs : undefined;
8
8
  const exemptDirs = opts.exemptDirs && opts.exemptDirs.length > 0 ? opts.exemptDirs : undefined;
9
9
  const guardedCapable = env.writeFileGuarded !== undefined;
@@ -12,8 +12,7 @@ export function createFsWriteGatePolicy(opts) {
12
12
  : " (advisory adjudication: this environment provides no guarded write, so approval is judged before a separate, non-atomic write step)";
13
13
  return {
14
14
  async check(req, signal) {
15
- const canonical = req.toolName;
16
- if (!gated.has(canonical))
15
+ if (!isProtectedWrite(req))
17
16
  return { action: "allow" };
18
17
  if (opts.requireGuardedWrite === true && !guardedCapable) {
19
18
  return {
@@ -22,9 +21,10 @@ export function createFsWriteGatePolicy(opts) {
22
21
  decisionReason: "rule",
23
22
  };
24
23
  }
25
- const path = writeTargetPath(canonical, req.args);
24
+ const declared = declaredPathTargetOf(req);
25
+ const path = declared === undefined ? undefined : writeTargetPath(req, declared);
26
26
  if (path === undefined) {
27
- return ask(`write tool "${req.toolName}" requires approval: the call has no resolvable path target to confine${advisory}`);
27
+ return ask(`write tool "${req.toolName}" requires approval: the call has no resolvable path target to confine${declared === undefined ? " (the tool declares no path target)" : ""}${advisory}`);
28
28
  }
29
29
  const canon = await canonicalizeTarget(env, path, signal, req.cwd ?? rootPath);
30
30
  if (!canon.ok) {
@@ -42,7 +42,7 @@ export function createFsWriteGatePolicy(opts) {
42
42
  if (opts.isExempt) {
43
43
  let exempt = false;
44
44
  try {
45
- exempt = (await opts.isExempt(canonical, canon.key)) === true;
45
+ exempt = (await opts.isExempt(req.toolName, canon.key, req.face !== undefined ? { capabilityId: req.face.capabilityId, contractId: req.face.contractId, shapeDigest: req.face.shapeDigest } : undefined)) === true;
46
46
  }
47
47
  catch {
48
48
  exempt = false;
@@ -30,7 +30,7 @@ function withProbeTimeout(p, ms, signal) {
30
30
  const DEFAULT_PROBE_TIMEOUT_MS = 30_000;
31
31
  export async function runGateFold(pass) {
32
32
  const { input, toolName, toolCallId, callSignal, ledger, notifier, adjudicate } = pass;
33
- pass.req = { toolName, args: pass.currentInput, toolCallId };
33
+ pass.req = { toolName, args: pass.currentInput, toolCallId, ...(pass.req.face !== undefined ? { face: pass.req.face } : {}) };
34
34
  pass.decision = adjudicate ? await (callSignal !== undefined ? adjudicate(pass.req, callSignal) : adjudicate(pass.req)) : { action: "allow" };
35
35
  {
36
36
  const refusedBy = ledger.fromDecision(pass.decision);
@@ -144,7 +144,7 @@ export async function runGateFold(pass) {
144
144
  pass.tightenedBy = "safety";
145
145
  }
146
146
  if (pass.decision.action === "allow" && input.writeProtectionCheck !== undefined) {
147
- const writeProtectedHit = input.writeProtectionCheck(toolName, pass.policyRewrite !== undefined ? pass.policyRewrite : pass.currentInput);
147
+ const writeProtectedHit = input.writeProtectionCheck(toolName, pass.policyRewrite !== undefined ? pass.policyRewrite : pass.currentInput, pass.req.face);
148
148
  if (writeProtectedHit !== null) {
149
149
  pass.decision = {
150
150
  action: "ask",
@@ -548,7 +548,8 @@ export async function runGateLanes(pass) {
548
548
  let recheck = { action: "allow" };
549
549
  if (adjudicate) {
550
550
  try {
551
- recheck = await (callSignal !== undefined ? adjudicate({ toolName, args: editArgs, toolCallId }, callSignal) : adjudicate({ toolName, args: editArgs, toolCallId }));
551
+ const editReq = { toolName, args: editArgs, toolCallId, ...(pass.req.face !== undefined ? { face: pass.req.face } : {}) };
552
+ recheck = await (callSignal !== undefined ? adjudicate(editReq, callSignal) : adjudicate(editReq));
552
553
  }
553
554
  catch (err) {
554
555
  recheck = {
@@ -588,7 +589,7 @@ export async function runGateLanes(pass) {
588
589
  }
589
590
  const editAskSnapshot = { ...recheck, ruleEvidence: mintRuleEvidence({ dotsAbsent: "not_adjudicated" }) };
590
591
  const editAskDecision = { ...editAskSnapshot, origin: askOriginOf(editAskSnapshot, askOriginFacts(editOrg.org)) };
591
- const editAskReq = { toolName, args: editArgs, toolCallId };
592
+ const editAskReq = { toolName, args: editArgs, toolCallId, ...(pass.req.face !== undefined ? { face: pass.req.face } : {}) };
592
593
  const rr = await (callSignal !== undefined ? resolveAsk(editAskDecision, editAskReq, callSignal) : resolveAsk(editAskDecision, editAskReq));
593
594
  ledger.fromResolved(rr, editAskDecision, toolName);
594
595
  if (rr.action !== "allow") {
@@ -606,7 +607,7 @@ export async function runGateLanes(pass) {
606
607
  editRewrittenSinceHuman = false;
607
608
  }
608
609
  if (editDenied === undefined && editRewrittenSinceHuman && input.writeProtectionCheck !== undefined) {
609
- const editHit = input.writeProtectionCheck(toolName, editArgs);
610
+ const editHit = input.writeProtectionCheck(toolName, editArgs, pass.req.face);
610
611
  if (editHit !== null) {
611
612
  editDenied = {
612
613
  action: "deny",
@@ -103,7 +103,7 @@ export type NoticeAudience = "user" | "operator";
103
103
  * src/ for notice mint shapes and names any code that is minted but unregistered, or registered but
104
104
  * no longer minted.
105
105
  */
106
- export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.read_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "config.durable_gate_unavailable", "config.peer_admission_out_of_range", "config.peer_lane_unmounted", "peer.inbound_disposition", "peer.held_settled", "peer.idle_subscription", "classifier.denial_limit", "checkpoint.execution_outcome_unrecorded", "delegation.transcript_integrity", "mcp.revocation_probe_failed", "workflow.governance_key_stripped", "workflow.agent_option_ignored", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "memory.consolidation_withheld", "route.fallback_to_primary", "route.base_url_changed_key_unchanged", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "task.halt_unconsumed", "task.late_approval", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "tool_result.offload_put_failed"];
106
+ export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.read_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "config.tool_card_undeclared", "config.tool_face_undeclared", "config.tool_face_invalid", "config.durable_gate_unavailable", "config.peer_admission_out_of_range", "config.peer_lane_unmounted", "peer.inbound_disposition", "peer.held_settled", "peer.idle_subscription", "classifier.denial_limit", "checkpoint.execution_outcome_unrecorded", "delegation.transcript_integrity", "mcp.revocation_probe_failed", "workflow.governance_key_stripped", "workflow.agent_option_ignored", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "memory.consolidation_withheld", "route.fallback_to_primary", "route.base_url_changed_key_unchanged", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "task.halt_unconsumed", "task.late_approval", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "tool_result.offload_put_failed"];
107
107
  /** A code this engine mints (see {@link ENGINE_NOTICE_CODES}). NOT the type of
108
108
  * `EngineNotice.code`, which stays `string` — a host forwarding its own notices through the same
109
109
  * sink is a supported shape, and narrowing that field would break it. */
@@ -101,6 +101,9 @@ export const ENGINE_NOTICE_CODES = [
101
101
  "config.tool_model_gate_removed",
102
102
  "config.tool_model_gate_unknown_class",
103
103
  "config.tool_model_gate_env_invalid",
104
+ "config.tool_card_undeclared",
105
+ "config.tool_face_undeclared",
106
+ "config.tool_face_invalid",
104
107
  "config.durable_gate_unavailable",
105
108
  "config.peer_admission_out_of_range",
106
109
  "config.peer_lane_unmounted",
@@ -164,6 +167,9 @@ const NOTICE_AUDIENCE_TABLE = {
164
167
  "config.tool_model_gate_removed": "operator",
165
168
  "config.tool_model_gate_unknown_class": "operator",
166
169
  "config.tool_model_gate_env_invalid": "operator",
170
+ "config.tool_card_undeclared": "operator",
171
+ "config.tool_face_undeclared": "operator",
172
+ "config.tool_face_invalid": "operator",
167
173
  "config.peer_lane_unmounted": "operator",
168
174
  "config.peer_admission_out_of_range": "operator",
169
175
  "peer.inbound_disposition": "user",
@@ -1,6 +1,6 @@
1
1
  import type { ActorAssertion, DocumentContent, ImageContent, TextContent } from "../internal/llm.js";
2
2
  import type { ExecutionEnv, FileError, Result, SessionTreeEntry } from "../internal/harness-types.js";
3
- import type { DecisionReason, PermissionResult, ResolvedAsk, ToolCallRequest, ToolPolicy } from "./tool-policy.js";
3
+ import type { DecisionReason, PermissionResult, ResolvedAsk, ToolCallFace, ToolCallRequest, ToolPolicy } from "./tool-policy.js";
4
4
  import type { GateOutcome } from "./gate-outcome.js";
5
5
  export { normalizeOrgGateVerdict, normalizePersistedRuleHit, persistedRuleMandateOf } from "./gate-lanes.js";
6
6
  export { cloneObserverInput } from "./runner/gate-exit.js";
@@ -1115,6 +1115,12 @@ export interface ToolGateInput {
1115
1115
  * builds ({@link HookEnvCapabilities}). Built ONCE per task by the runner (after the env is minted) and
1116
1116
  * passed through unchanged; omitted when the deployment wired no execution environment. */
1117
1117
  hookEnv?: HookEnvCapabilities;
1118
+ /**
1119
+ * design/388 B6 — the leg's live roster lookup: the face of the mounted tool a call dispatches to, stamped
1120
+ * on the {@link ToolCallRequest} every policy sees (read at the gate entry, so a run-time roster change is
1121
+ * honoured on the next call). Absent ⇒ requests carry no face (a bare producer).
1122
+ */
1123
+ toolFaceOf?: (toolName: string) => ToolCallFace | undefined;
1118
1124
  /**
1119
1125
  * design/384 slice 1 — the PER-CALL abort signal, carried in from the `tool_call` hook event's
1120
1126
  * own `signal` seat (the loop's turn-scoped controller; the run abort is composed into it). The
@@ -1332,7 +1338,7 @@ export interface ToolGateInput {
1332
1338
  * (the write-protection tighten below). Absent ⇒ the deployment replaced the table with `[]` (or
1333
1339
  * the caller runs the gate without one) and the decision path is byte-identical.
1334
1340
  */
1335
- writeProtectionCheck?: (toolName: string, args: unknown) => import("./write-protect.js").WriteProtectedHit | null;
1341
+ writeProtectionCheck?: (toolName: string, args: unknown, face?: import("./tool-registry.js").ToolCallLike["face"]) => import("./write-protect.js").WriteProtectedHit | null;
1336
1342
  /**
1337
1343
  * design/143 批2 (A, CC 2.1.207 auto mode): when present, a surviving `ask` is routed to the
1338
1344
  * small-model policy CLASSIFIER before any human/durable resolution:
@@ -301,7 +301,7 @@ export async function runToolGate(input) {
301
301
  screening,
302
302
  afterFold: runGateLanes,
303
303
  currentInput: modelInput,
304
- req: { toolName, args: modelInput, toolCallId },
304
+ req: { toolName, args: modelInput, toolCallId, ...(() => { const f = input.toolFaceOf?.(toolName); return f !== undefined ? { face: f } : {}; })() },
305
305
  preToolContext: [],
306
306
  hookAsk: undefined,
307
307
  parkFailed: undefined,
package/dist/core/lsp.js CHANGED
@@ -2,6 +2,7 @@ import { uriToPath } from "./lsp-protocol.js";
2
2
  import { foldRedundantPathSegments, isAbsolutePathForm } from "../tools/fs/safety.js";
3
3
  import { Type } from "typebox";
4
4
  import { defineTool, errorResult } from "./tools.js";
5
+ import { toolFace } from "./tool-catalog-entries.js";
5
6
  export const LSP_OPERATIONS = [
6
7
  "goToDefinition", "findReferences", "hover", "documentSymbol", "workspaceSymbol",
7
8
  "goToImplementation", "prepareCallHierarchy", "incomingCalls", "outgoingCalls",
@@ -145,7 +146,7 @@ const POSITION_OPS = new Set([
145
146
  export function createLspTool(manager, opts = {}) {
146
147
  return defineTool({
147
148
  name: "LSP",
148
- contract: { contractId: "core.lsp@1", implementationRevision: "1" },
149
+ ...toolFace("lsp"),
149
150
  description: "Interact with Language Server Protocol (LSP) servers to get code intelligence features.\n" +
150
151
  "\n" +
151
152
  "Supported operations:\n" +
@@ -172,7 +173,6 @@ export function createLspTool(manager, opts = {}) {
172
173
  character: Type.Optional(Type.Number({ description: "The character offset (1-based, as shown in editors)." })),
173
174
  query: Type.Optional(Type.String({ description: "Symbol query for workspaceSymbol (may be empty to browse broadly)." })),
174
175
  }),
175
- effect: "read",
176
176
  execute: async (args, ctx) => {
177
177
  const { operation, filePath, line, character, query } = args;
178
178
  if (POSITION_OPS.has(operation) && (line === undefined || character === undefined)) {
@@ -6,6 +6,7 @@
6
6
  * a longer name. Layer 0 vocabulary; `types.ts` re-exports every name below.
7
7
  */
8
8
  import type { ToolContentOrigin, ToolEffect } from "./tool-spec.js";
9
+ import type { ToolFamily, ToolPathTarget, ToolRenderHints, ToolRuleFace } from "./tool-face.js";
9
10
  /** Definition of an MCP server to connect to for the duration of one task, then discard. */
10
11
  export interface McpServerSpec {
11
12
  /** Stable name; tool names are namespaced as `<name>__<tool>` to avoid collisions. */
@@ -71,6 +72,22 @@ export interface McpServerSpec {
71
72
  egress?: boolean;
72
73
  irreversibility?: "always" | "never";
73
74
  }>;
75
+ /**
76
+ * design/388 §1.1 / §2.7 — the OPTIONAL per-tool FACE overlay for this server's tools (keyed by the server's
77
+ * RAW tool name, like {@link allowTools} / {@link toolAxes}): a deployment lets an MCP tool enter a specialised
78
+ * approval card, be confirmed by path, carry a display name / summary, or narrow its rule vocabulary. TRUSTED
79
+ * (the deployment writes it), but validated against the MATERIALIZED schema at every listing (first mount and
80
+ * every refresh): a `pathTarget.param` that is not a top-level property, an `approvalCard` whose keys the schema
81
+ * lacks, a `ruleFace` naming a non-scalar key ⇒ the tool mounts WITHOUT the overlay and the leg announces
82
+ * `config.tool_face_invalid` once; a key naming a tool the listing does not carry announces
83
+ * `config.tool_face_undeclared` once. Applied whole or not at all.
84
+ */
85
+ toolFaces?: Record<string, {
86
+ family?: ToolFamily;
87
+ pathTarget?: ToolPathTarget;
88
+ renderHints?: ToolRenderHints;
89
+ ruleFace?: ToolRuleFace;
90
+ }>;
74
91
  /**
75
92
  * design/378 — declare {@link ToolSpec.contentOrigin} on behalf of THIS SERVER'S ENTIRE TOOL SET
76
93
  * (tools a mid-task refresh adds included), with the same authority and the same responsibility a