--- issue: 569 issue_title: "Move the presentation family onto the tool-kind product" --- # Move the presentation family onto the tool-kind product ## Release Recommendation **Release:** ship now — batch "tool-kind-dispatch" tail (this issue completes the batch) This is Phase 10 Step 2 of the pi-permission-system roadmap, the **tail** of the two-step batch "tool-kind-dispatch" whose head (Step 1, [#568]) has already landed. Landing Step 2 completes the batch, so there is no reason to hold it — ship (land on `main`) now. It is a `refactor:` change (a hidden changelog type), so it does **not** cut a release on its own; it auto-batches into the next `feat:`/`fix:`/unhidden-`docs:` release regardless. The rationale here does not claim it will cut a release — the "ship now — batch tail" marker means land the coordinated pair's tail, not that a release is emitted. ## Problem Statement The extraction family migrated onto the `access-intent/tool-kind.ts` classification in Step 1 ([#568]). The **presentation** family still re-decides the tool kind per formatter by silent string comparison ([#561], Category C repeated-discriminator / OCP): - `tool-preview-formatter.ts::getToolInputPreviewForLog` — `result.toolName === "bash" || result.toolName === "mcp" || result.source === "mcp"`. - `permission-prompts.ts` — `formatUnknownToolReason`'s `toolName === "mcp"` MCP hint, `formatAskPrompt`'s `result.toolName === "bash"` bash branch, and its `(result.source === "mcp" || result.toolName === "mcp") && result.target` MCP branch. - `denial-messages.ts` — two `check.toolName === "bash" && check.command` bash guards plus a private `isMcpCheck()` helper (`(check.source === "mcp" || check.toolName === "mcp") && !!check.target`) called at three sites. - `handlers/gates/helpers.ts::deriveDecisionValue` — `toolName === "bash"` / `toolName === "mcp"`. The private `isMcpCheck()` encapsulates the `(source === "mcp" || toolName === "mcp")` MCP-ness derivation, but two sibling call sites — `permission-prompts.ts::formatAskPrompt` and `tool-preview-formatter.ts::getToolInputPreviewForLog` — re-derive the same pattern inline instead of sharing it. Adding a tool kind, or changing what "MCP-ness" means, requires finding and editing every site, and a missed site diverges silently. With Step 1 landing the classification at the boundary, the presentation formatters should ask the tool-kind product for their display projection rather than re-checking `toolName`/`source` strings. ## Goals - Migrate the four presentation consumers named in the roadmap onto `access-intent/tool-kind.ts`: `tool-preview-formatter.ts`, `permission-prompts.ts`, `denial-messages.ts`, `handlers/gates/helpers.ts`. - Consolidate the `(source === "mcp" || toolName === "mcp")` MCP-ness derivation into a single shared `isMcpCheck` in `tool-kind.ts`; delete the private copy in `denial-messages.ts` so all three denial sites plus the two sibling formatters share one predicate. - Drive every remaining `toolName`/`source` bash/mcp discriminator through `classifyToolKind` (already exported) or the new shared `isMcpCheck`, so the recompute grep drops the total family from 12 to ≤ 4, all inside `access-intent/tool-kind.ts`. - Preserve behavior exactly — this is a pure internal refactor with no observable change to prompts, denial messages, log previews, decision values, or config. Not a breaking change. ## Non-Goals - No change to `classifyToolKind` itself, nor to the extraction consumers migrated in Step 1 (`input-normalizer.ts`, `tool-input-path.ts`, `handlers/gates/tool.ts`, `handlers/gates/tool-call-gate-pipeline.ts`, `permission-manager.ts`) — they are already on the product. - No change to the message text, prompt wording, or log-preview format any formatter emits — the characterization tests must stay green unchanged. - The `PathFlavor` / win32 discriminator work ([#562], Step 3), advisory bash decomposition ([#309], Step 4), indirection wrappers ([#490], Step 5), and the docs recipe ([#521], Step 6) are separate roadmap steps. ## Background The Step 1 product (`src/access-intent/tool-kind.ts`) exports: ```typescript export type ToolKind = "bash" | "mcp" | "skill" | "path" | "extension"; export function classifyToolKind(toolName: string): ToolKind; ``` `classifyToolKind` keys purely on the tool **name**. The presentation family needs one thing it cannot express: MCP-ness of a *resolved check*, which considers `source === "mcp"` in addition to `toolName === "mcp"`. `PermissionCheckResult.source` is derived independently of `toolName` (`permission-manager.ts::deriveSource`), and the characterization suite already pins the source-only case: - `denial-messages.test.ts` "MCP source with target on non-mcp toolName" — `toolCheck("anything", { source: "mcp", target: "server:tool" })` → `"…run MCP target 'server:tool'."`. - `tool-preview-formatter.test.ts` "returns undefined for mcp source" — `makeResult("some-server:some-tool", { source: "mcp" })` → `undefined`. So the shared MCP predicate must keep the `source === "mcp"` disjunct; reducing it to `classifyToolKind(toolName) === "mcp"` would regress these two tests. Constraint from AGENTS.md and Step 1: `permission-manager.ts` carries an ESLint `no-restricted-imports` rule forbidding any import of `access-intent/access-path`, and `tool-kind.ts` must stay `AccessPath`-free so it remains a legal import there. The new `isMcpCheck` imports nothing new (it reuses `classifyToolKind` and compares `source` to a string literal), so the boundary holds. The recompute grep (`toolName === "(bash|mcp)"|source === "mcp"`) currently returns 12: one docstring inside `tool-kind.ts` plus the 11 presentation sites above. ## Design Overview ### One shared MCP-ness predicate, target-presence separated Add a single predicate to `tool-kind.ts` that answers "does this resolved check concern an MCP call?" — nothing more. Whether a target string is available to *display* is a separate concern that stays at the call sites that need it (SRP): ```typescript // src/access-intent/tool-kind.ts (added) /** The resolved-check fields that decide MCP-ness. */ interface McpKindFields { toolName: string; source: string; } /** * True when a resolved check concerns an MCP call — either the invoked tool is * `mcp`, or the winning rule matched on the `mcp` surface (`source`). The * `source` disjunct is why this cannot reduce to `classifyToolKind(toolName)`. */ export function isMcpCheck(check: McpKindFields): boolean { return check.source === "mcp" || classifyToolKind(check.toolName) === "mcp"; } ``` The param is a narrow structural type (ISP): `PermissionCheckResult` satisfies it structurally (`source` is a union assignable to `string`), so `tool-kind.ts` imports no domain types and stays `AccessPath`-free. The private `denial-messages.ts::isMcpCheck` currently bakes in `&& !!check.target`. Separating that out means every call site that needs the target hoists `&& check.target` explicitly — which also gives TypeScript the truthy-narrowing the old `!!check.target` implied. ### Consumer call sites (behavior preserved exactly) `denial-messages.ts` — three sites, each gaining an explicit `&& check.target` (was folded into the old private helper), plus the two bash guards: ```typescript // buildToolDenyBody if (isMcpCheck(check) && check.target) { parts.push(`is not permitted to run MCP target '${check.target}'`); } else { parts.push(`is not permitted to run '${check.toolName}'`); } // buildUnavailableBody / buildUserDeniedBody — MCP arms if (isMcpCheck(check) && check.target) { /* MCP-target text */ } // bash guards (was check.toolName === "bash") if (classifyToolKind(check.toolName) === "bash" && check.command) { /* … */ } ``` `permission-prompts.ts`: ```typescript // formatUnknownToolReason — MCP hint (was toolName === "mcp") const mcpHint = classifyToolKind(toolName) === "mcp" ? "" : " If this was intended…"; // formatAskPrompt — bash branch (was result.toolName === "bash") if (classifyToolKind(result.toolName) === "bash") { /* bash prompt */ } // formatAskPrompt — MCP branch (was (source === "mcp" || toolName === "mcp") && target) if (isMcpCheck(result) && result.target) { /* MCP prompt */ } ``` `tool-preview-formatter.ts::getToolInputPreviewForLog` — the "skip preview because content is surfaced elsewhere" guard is bash **or** MCP-of-a-result (no target requirement, matching the original): ```typescript if (classifyToolKind(result.toolName) === "bash" || isMcpCheck(result)) { return undefined; } ``` `handlers/gates/helpers.ts::deriveDecisionValue` — an exhaustive `switch` (a future `ToolKind` variant becomes a compile error, the OCP win): ```typescript switch (classifyToolKind(toolName)) { case "bash": return check.command ?? toolName; case "mcp": return check.target ?? toolName; case "path": case "skill": case "extension": // Preserve the original `if (path) return path; return toolName` — an empty // string falls through to toolName, so keep the truthy ternary (not `??`). return path ? path : toolName; } ``` ### Why this is decide-once, not procedure-splitting The refactor removes a genuine repeated discriminator: the `(source === "mcp" || toolName === "mcp")` derivation was re-decided at three-plus sites, and `isMcpCheck` becomes its single home alongside `classifyToolKind`. Each consumer dispatches on the shared classification instead of re-checking strings; adding a tool kind means editing `tool-kind.ts` plus the exhaustive `switch` the compiler flags. This is the design-review skill's accepted resolution for a repeated discriminator — capture the decision once at the boundary and hand consumers its product. ### Metric note The recompute grep keys literally on `toolName === "(bash|mcp)"` / `source === "mcp"`. Migrated code dispatches on `classifyToolKind(...)` (whose `=== "bash"` / `=== "mcp"` follows a `)`, not the bare `toolName`, so it does not match) or `isMcpCheck(...)`, so the presentation sites drop to 0. After migration the only matches are inside `access-intent/tool-kind.ts`: the module docstring's `toolName === "bash"` reference and the `source === "mcp"` disjunct in `isMcpCheck` — 2 lines, within the Phase 10 end-state target of ≤ 4. ## Module-Level Changes - **`src/access-intent/tool-kind.ts`** — add `isMcpCheck(check: { toolName: string; source: string })`; extend the module docstring to note the presentation consumers now dispatch on it too. Still imports only `PATH_BEARING_TOOLS`. - **`src/denial-messages.ts`** — delete the private `isMcpCheck`; import `classifyToolKind` + `isMcpCheck` from `./access-intent/tool-kind`; the three MCP arms become `isMcpCheck(check) && check.target`; the two bash guards become `classifyToolKind(check.toolName) === "bash"`. - **`src/permission-prompts.ts`** — import `classifyToolKind` + `isMcpCheck`; migrate the MCP hint (`formatUnknownToolReason`), the bash branch, and the MCP branch (`isMcpCheck(result) && result.target`) of `formatAskPrompt`. - **`src/tool-preview-formatter.ts`** — import `classifyToolKind` + `isMcpCheck`; migrate the `getToolInputPreviewForLog` skip guard. - **`src/handlers/gates/helpers.ts`** — import `classifyToolKind`; migrate `deriveDecisionValue` to an exhaustive `switch`. - **`test/access-intent/tool-kind.test.ts`** — add an `isMcpCheck` describe block (toolName-mcp → true, source-mcp on a non-mcp toolName → true, bash → false, plain tool → false). - **`docs/architecture/architecture.md`** — mark Phase 10 Step 2 `✅` (step heading + `S2` Mermaid node); add a **Landed:** bullet; update the "Tool-kind discriminator sites" metric row to note the target is met (≤ 4, all in `tool-kind.ts`); extend the `tool-kind.ts` module-tree entry (line ~749) to say the presentation consumers (`tool-preview-formatter`, `permission-prompts`, `denial-messages`, `deriveDecisionValue`) dispatch on it via `classifyToolKind`/`isMcpCheck`. No public `exports`, event channel, or `Symbol.for()` surface changes. `isMcpCheck` is not a cross-extension API; no user-facing doc, README, or `package-*` SKILL references `isMcpCheck` or the presentation formatters by name (verified by grep — the only `isMcpCheck` mentions are in `src/`, the historical `0568` plan/retro, and the Step 2 roadmap entry being updated here). ## Test Impact Analysis 1. **New tests enabled.** `isMcpCheck` becomes independently testable for the first time (it was a private helper inlining the derivation). `test/access-intent/tool-kind.test.ts` pins it directly, including the source-only disjunct (`{ toolName: "read", source: "mcp" }` → `true`) that distinguishes it from `classifyToolKind`. 2. **Redundant tests.** None become fully redundant. The per-consumer characterization tests (`denial-messages.test.ts`, `permission-prompts.test.ts`, `tool-preview-formatter.test.ts`, `helpers.test.ts`) assert each formatter's *output* (message text, prompt string, log preview, decision value), which the refactor keeps invariant, so they remain the safety net. The `isMcpCheck` unit test overlaps with them only at the classification layer, not the projection layer. 3. **Tests that must stay as-is.** All four presentation characterization suites — in particular `denial-messages.test.ts` "MCP source with target on non-mcp toolName" and `tool-preview-formatter.test.ts` "returns undefined for mcp source", which pin the `source === "mcp"` disjunct the shared predicate must keep. Before each migration, confirm the touched branch has characterization coverage (verified above — MCP-with-target, MCP-via-source, bash, and generic-tool arms are all covered across the four suites); add a red characterization test only if a gap surfaces. ## Invariants at risk The refactor touches surfaces earlier phases refactored; each invariant is pinned by an existing test: - **MCP-ness considers `source`, not just `toolName`** — Step 1's `deriveSource` can set `source: "mcp"` on a result whose `toolName` is a server-qualified string. Pinned by `denial-messages.test.ts` "MCP source with target on non-mcp toolName" and `tool-preview-formatter.test.ts` "returns undefined for mcp source". - **Formatter output text unchanged** — every prompt, denial, unavailable, user-denied, log-preview, and decision-value projection. Pinned by the four presentation characterization suites (kept green, unchanged). - **`deriveDecisionValue` empty-path fallback** — an empty `path` string falls through to `toolName` (the original `if (path)` truthiness). Pinned by `helpers.test.ts` "falls back to toolName for path-bearing tools when path is missing"; preserved by the truthy ternary (`path ? path : toolName`), not `??`. - **ADR-0002 string boundary** — `tool-kind.ts` stays `AccessPath`-free so `isMcpCheck`/`classifyToolKind` remain legal imports package-wide. Pinned by the `no-restricted-imports` ESLint rule on `permission-manager.ts` (which does not import the presentation modules, so it is unaffected either way). ## TDD Order Each migration is behavior-preserving under the existing green suite; only the new `isMcpCheck` unit tests carry a true red. Run `pnpm run check` after each step and the full package suite before committing. 1. **Add `isMcpCheck` + migrate `denial-messages.ts`.** Red: `test/access-intent/tool-kind.test.ts` `isMcpCheck` block (function absent). Green: implement `isMcpCheck` in `tool-kind.ts`, then delete the private `denial-messages.ts::isMcpCheck` and migrate its three MCP arms (`isMcpCheck(check) && check.target`) and two bash guards (`classifyToolKind(check.toolName) === "bash"`) in the **same** commit so the export lands with a consumer (avoids a `fallow dead-code` failure on an unwired export). `denial-messages.test.ts` stays green. Commit: `refactor(pi-permission-system): share MCP-check via tool-kind product in denial messages`. 2. **Migrate `permission-prompts.ts`.** `formatUnknownToolReason` MCP hint, `formatAskPrompt` bash branch, and MCP branch (`isMcpCheck(result) && result.target`) dispatch on the product. `permission-prompts.test.ts` green. Commit: `refactor(pi-permission-system): classify prompt tool kind via tool-kind product`. 3. **Migrate `tool-preview-formatter.ts`.** `getToolInputPreviewForLog` skip guard → `classifyToolKind(result.toolName) === "bash" || isMcpCheck(result)`. `tool-preview-formatter.test.ts` green. Commit: `refactor(pi-permission-system): classify preview tool kind via tool-kind product`. 4. **Migrate `handlers/gates/helpers.ts::deriveDecisionValue`.** Exhaustive `switch (classifyToolKind(toolName))`, preserving the empty-path truthy fallback. `helpers.test.ts` green. Commit: `refactor(pi-permission-system): derive decision value via tool-kind classification`. 5. **Record the roadmap step.** Update `docs/architecture/architecture.md`: mark Phase 10 Step 2 `✅` (heading + `S2` Mermaid node), add the **Landed:** bullet, update the "Tool-kind discriminator sites" metric row (target met), extend the `tool-kind.ts` module-tree entry to name the presentation consumers. Verify the recompute returns ≤ 4 (expected 2, both inside `tool-kind.ts`). Commit: `docs(pi-permission-system): record Phase 10 Step 2 presentation tool-kind migration`. ## Risks and Mitigations - **Dropping the `source === "mcp"` disjunct.** A naive `classifyToolKind(toolName) === "mcp"` replacement would regress the source-only characterization tests. Mitigation: `isMcpCheck` keeps the `source === "mcp"` disjunct; the two pinning tests stay green. - **`&& target` hoist changes a branch.** Moving the target guard from the private helper to the call sites must be applied at all three denial sites and the prompt MCP branch. Mitigation: enumerate them (three in `denial-messages.ts`, one in `permission-prompts.ts`); `tool-preview-formatter.ts` deliberately omits it (its original had no target check). The characterization suites (MCP-with-target unavailable/user-denied/deny arms, MCP-via-source) catch any slip. - **`deriveDecisionValue` empty-path fallback.** `path ?? toolName` would return `""` for an empty path; the original returns `toolName`. Mitigation: keep the truthy ternary; `helpers.test.ts` pins it. - **Unwired export → `fallow dead-code`.** Mitigation: fold the first consumer (`denial-messages.ts`) into the `isMcpCheck` introduction commit (Step 1). - **Exhaustiveness drift.** Mitigation: `deriveDecisionValue` uses an exhaustive `switch` with no `default`, so a future `ToolKind` variant is a compile error. ## Open Questions - None blocking. The one design choice (a single no-target `isMcpCheck` with target-presence hoisted to call sites, vs. two predicates) is resolved by SRP and the existing characterization coverage; it is behavior-preserving either way. [#309]: https://github.com/gotgenes/pi-packages/issues/309 [#490]: https://github.com/gotgenes/pi-packages/issues/490 [#521]: https://github.com/gotgenes/pi-packages/issues/521 [#561]: https://github.com/gotgenes/pi-packages/issues/561 [#562]: https://github.com/gotgenes/pi-packages/issues/562 [#568]: https://github.com/gotgenes/pi-packages/issues/568