19 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 569 | 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'stoolName === "mcp"MCP hint,formatAskPrompt'sresult.toolName === "bash"bash branch, and its(result.source === "mcp" || result.toolName === "mcp") && result.targetMCP branch.denial-messages.ts— twocheck.toolName === "bash" && check.commandbash guards plus a privateisMcpCheck()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 sharedisMcpCheckintool-kind.ts; delete the private copy indenial-messages.tsso all three denial sites plus the two sibling formatters share one predicate. - Drive every remaining
toolName/sourcebash/mcp discriminator throughclassifyToolKind(already exported) or the new sharedisMcpCheck, so the recompute grep drops the total family from 12 to ≤ 4, all insideaccess-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
classifyToolKinditself, 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:
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):
// 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:
// 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:
// 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):
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):
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— addisMcpCheck(check: { toolName: string; source: string }); extend the module docstring to note the presentation consumers now dispatch on it too. Still imports onlyPATH_BEARING_TOOLS.src/denial-messages.ts— delete the privateisMcpCheck; importclassifyToolKind+isMcpCheckfrom./access-intent/tool-kind; the three MCP arms becomeisMcpCheck(check) && check.target; the two bash guards becomeclassifyToolKind(check.toolName) === "bash".src/permission-prompts.ts— importclassifyToolKind+isMcpCheck; migrate the MCP hint (formatUnknownToolReason), the bash branch, and the MCP branch (isMcpCheck(result) && result.target) offormatAskPrompt.src/tool-preview-formatter.ts— importclassifyToolKind+isMcpCheck; migrate thegetToolInputPreviewForLogskip guard.src/handlers/gates/helpers.ts— importclassifyToolKind; migratederiveDecisionValueto an exhaustiveswitch.test/access-intent/tool-kind.test.ts— add anisMcpCheckdescribe 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 +S2Mermaid node); add a Landed: bullet; update the "Tool-kind discriminator sites" metric row to note the target is met (≤ 4, all intool-kind.ts); extend thetool-kind.tsmodule-tree entry (line ~749) to say the presentation consumers (tool-preview-formatter,permission-prompts,denial-messages,deriveDecisionValue) dispatch on it viaclassifyToolKind/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
- New tests enabled.
isMcpCheckbecomes independently testable for the first time (it was a private helper inlining the derivation).test/access-intent/tool-kind.test.tspins it directly, including the source-only disjunct ({ toolName: "read", source: "mcp" }→true) that distinguishes it fromclassifyToolKind. - 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. TheisMcpCheckunit test overlaps with them only at the classification layer, not the projection layer. - 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" andtool-preview-formatter.test.ts"returns undefined for mcp source", which pin thesource === "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 justtoolName— Step 1'sderiveSourcecan setsource: "mcp"on a result whosetoolNameis a server-qualified string. Pinned bydenial-messages.test.ts"MCP source with target on non-mcp toolName" andtool-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).
deriveDecisionValueempty-path fallback — an emptypathstring falls through totoolName(the originalif (path)truthiness). Pinned byhelpers.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.tsstaysAccessPath-free soisMcpCheck/classifyToolKindremain legal imports package-wide. Pinned by theno-restricted-importsESLint rule onpermission-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.
- Add
isMcpCheck+ migratedenial-messages.ts. Red:test/access-intent/tool-kind.test.tsisMcpCheckblock (function absent). Green: implementisMcpCheckintool-kind.ts, then delete the privatedenial-messages.ts::isMcpCheckand 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 afallow dead-codefailure on an unwired export).denial-messages.test.tsstays green. Commit:refactor(pi-permission-system): share MCP-check via tool-kind product in denial messages. - Migrate
permission-prompts.ts.formatUnknownToolReasonMCP hint,formatAskPromptbash branch, and MCP branch (isMcpCheck(result) && result.target) dispatch on the product.permission-prompts.test.tsgreen. Commit:refactor(pi-permission-system): classify prompt tool kind via tool-kind product. - Migrate
tool-preview-formatter.ts.getToolInputPreviewForLogskip guard →classifyToolKind(result.toolName) === "bash" || isMcpCheck(result).tool-preview-formatter.test.tsgreen. Commit:refactor(pi-permission-system): classify preview tool kind via tool-kind product. - Migrate
handlers/gates/helpers.ts::deriveDecisionValue. Exhaustiveswitch (classifyToolKind(toolName)), preserving the empty-path truthy fallback.helpers.test.tsgreen. Commit:refactor(pi-permission-system): derive decision value via tool-kind classification. - Record the roadmap step.
Update
docs/architecture/architecture.md: mark Phase 10 Step 2✅(heading +S2Mermaid node), add the Landed: bullet, update the "Tool-kind discriminator sites" metric row (target met), extend thetool-kind.tsmodule-tree entry to name the presentation consumers. Verify the recompute returns ≤ 4 (expected 2, both insidetool-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 naiveclassifyToolKind(toolName) === "mcp"replacement would regress the source-only characterization tests. Mitigation:isMcpCheckkeeps thesource === "mcp"disjunct; the two pinning tests stay green. && targethoist 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 indenial-messages.ts, one inpermission-prompts.ts);tool-preview-formatter.tsdeliberately 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.deriveDecisionValueempty-path fallback.path ?? toolNamewould return""for an empty path; the original returnstoolName. Mitigation: keep the truthy ternary;helpers.test.tspins it.- Unwired export →
fallow dead-code. Mitigation: fold the first consumer (denial-messages.ts) into theisMcpCheckintroduction commit (Step 1). - Exhaustiveness drift.
Mitigation:
deriveDecisionValueuses an exhaustiveswitchwith nodefault, so a futureToolKindvariant is a compile error.
Open Questions
- None blocking.
The one design choice (a single no-target
isMcpCheckwith target-presence hoisted to call sites, vs. two predicates) is resolved by SRP and the existing characterization coverage; it is behavior-preserving either way.