--- issue: 568 issue_title: "Tool-kind classification decided once at the normalize boundary (extraction family)" --- # Tool-kind classification decided once at the normalize boundary (extraction family) ## Release Recommendation **Release:** mid-batch — defer (batch "tool-kind-dispatch"); confirm at ship time This is Phase 10 Step 1 of the pi-permission-system roadmap, the head of the two-step batch "tool-kind-dispatch" whose tail is Step 2 ([#569]). The batch ships together, so this step lands on `main` but does not cut a release on its own. It is a `refactor:` change (a hidden changelog type), so it auto-batches into the next `feat:`/`fix:`/unhidden-`docs:` release regardless — the rationale here does not claim it will cut a release. ## Problem Statement The extraction question — "what does this invocation access: a bash command, an MCP target, or a path?" — is re-derived by silent string comparison (`toolName === "bash"`, `toolName === "mcp"`, `source === "mcp"`) at 21 production sites across 8 modules ([#561]). This is a Category C repeated-discriminator / OCP flaw: the same domain decision is re-decided at every consumer, so adding a tool kind or changing what "MCP-ness" means requires finding and editing every site, and a missed site diverges silently — there is no compiler-enforced exhaustive dispatch, just scattered `===` comparisons. `fallow dupes` is structurally blind to it (one-line comparisons never form a token-run clone); the repeated-discriminator grep sweep is the only detector. This step captures the classification once in a new `access-intent/tool-kind.ts` and migrates the **extraction-family** consumers onto it. The **presentation-family** consumers (prompts, previews, denial messages, decision values) follow in Step 2 ([#569]). ## Goals - Introduce `src/access-intent/tool-kind.ts` owning a `ToolKind` discriminated classification and its single dispatch point, `classifyToolKind(toolName)`. - Migrate the five extraction consumers named in the roadmap to ask the classification instead of re-checking the raw string: `input-normalizer.ts`, `tool-input-path.ts`, `handlers/gates/tool.ts`, `handlers/gates/tool-call-gate-pipeline.ts`, `permission-manager.ts`. - Keep `permission-manager.ts` string-based: `tool-kind.ts` returns plain data (a string union), imports no `AccessPath`, and stays safe to consume there per `docs/decisions/0002-path-values-string-boundary.md`. - Preserve behavior exactly — this is a pure internal refactor with no observable change to decisions, output, or config. Not a breaking change. ## Non-Goals - The presentation family (`tool-preview-formatter.ts`, `permission-prompts.ts`, `denial-messages.ts`, `handlers/gates/helpers.ts::deriveDecisionValue`, and `denial-messages.ts::isMcpCheck`) — deferred to Step 2 ([#569]). - `input-normalizer.ts::buildInputForSurface` (the `surface === "bash"` / `"skill"` / `"external_directory"` inverse reconstruction) is **not** migrated: it dispatches on path/service **surface** names, not tool names, and `external_directory` has no `ToolKind` variant, so `classifyToolKind` would drop that branch. It is outside the extraction-discriminator family (it is not a grep-counted site) and stays as-is. - `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 extraction consumers and their exact discriminator sites (the official recompute grep, `toolName === "(bash|mcp)"|source === "mcp"`, run 2026-07-10): | Module | Sites | Role | | ------------------------------------------- | -------- | ----------------------------------------------------------------------------- | | `input-normalizer.ts` | 122, 138 | `normalizeInput` — builds the `(surface, values, resultExtras)` triple | | `tool-input-path.ts` | 34, 44 | `getToolInputPath` — extracts the filesystem path for the cross-cutting gates | | `handlers/gates/tool.ts` | 26, 27 | `deriveSuggestionValue` — the session-approval suggestion value | | `handlers/gates/tool-call-gate-pipeline.ts` | 80, 162 | bash-parse guard + `resolvePerToolCheck` bash branch | | `permission-manager.ts` | 448, 455 | `deriveSource` — maps a matched rule + tool to `PermissionCheckResult.source` | That is 10 extraction sites; the remaining 11 (in `denial-messages.ts`, `tool-preview-formatter.ts`, `permission-prompts.ts`, `handlers/gates/helpers.ts`) are the presentation family Step 2 clears. The classification set is fixed by the existing surfaces: - `bash` — its own token-based path gates; extraction product is the command string. - `mcp` — extraction product is the qualified target string(s). - `skill` — a distinct surface `normalizeInput` and `deriveSource` treat specially. - path-bearing built-ins (`read`/`write`/`edit`/`grep`/`find`/`ls`, the `PATH_BEARING_TOOLS` set in `path-surfaces.ts`) — extraction product is `input.path`. - everything else (extension tools, and the `external_directory`/`path` special surfaces that reach `deriveSource` as normalized names). Two AGENTS.md constraints apply: - `permission-manager.ts` carries an ESLint `no-restricted-imports` rule (`eslint.config.js`) forbidding any import of `access-intent/access-path`. `tool-kind.ts` imports only `PATH_BEARING_TOOLS` (from `path-surfaces.ts`, a pure module), so importing `classifyToolKind` there does not breach the boundary. `tool-kind.ts` must stay `AccessPath`-free to keep this true. - When the roadmap step completes, the implementation doc-update commit marks Phase 10 Step 1 `✅` on both its heading and its Mermaid node in `docs/architecture/architecture.md`, per the package skill — not a deferred ship commit. ## Design Overview ### The classification `tool-kind.ts` owns a string-union `ToolKind` and one dispatch function. A plain string union (not a rich product object carrying the extracted command/target/path) is the right granularity: - A rich product would have to be built with the MCP server-name list (`createMcpPermissionTargets`), the tool-access extractor registry, and the `PathNormalizer`/`AccessPath` — but `deriveSource` and the pipeline's bash guard have none of those in hand, and pulling `AccessPath` into `tool-kind.ts` would breach the ADR-0002 boundary permission-manager depends on. - The string union keeps the classifier a pure, plain-data function every consumer can call, while each consumer builds its own product by dispatching on the shared kind. The classification is the single missing dispatch point ([#561]); the per-kind products stay where their dependencies live. ```typescript // src/access-intent/tool-kind.ts import { PATH_BEARING_TOOLS } from "#src/path-surfaces"; /** What a tool invocation accesses, decided once from the tool name. */ export type ToolKind = "bash" | "mcp" | "skill" | "path" | "extension"; export function classifyToolKind(toolName: string): ToolKind { const name = toolName.trim(); if (name === "bash") return "bash"; if (name === "mcp") return "mcp"; if (name === "skill") return "skill"; if (PATH_BEARING_TOOLS.has(name)) return "path"; return "extension"; } ``` `tool-kind.ts` is `AccessPath`-free (its only import is the pure `PATH_BEARING_TOOLS` set), so it is plain data safe for `permission-manager.ts` to consume — the ADR-0002 constraint holds. ### Why this is decide-once, not procedure-splitting The refactor introduces a genuine new collaborator (`classifyToolKind`, the dispatch point that did not exist) and gives behavior to data (a `ToolKind` union the consumers dispatch on via exhaustive `switch`). Adding a new tool kind then means editing `classifyToolKind` plus the exhaustive switches the compiler flags — an OCP win over silent `===` comparisons that a new variant sails past. 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. ### Consumer call sites (Tell-Don't-Ask preserved) Each consumer replaces its inline `toolName === "bash"` chain with a `switch` (or single comparison) on the shared classification. Representative sketches: ```typescript // input-normalizer.ts — normalizeInput switch (classifyToolKind(toolName)) { case "skill": return { surface: "skill", values: [skillName ?? "*"], resultExtras: {} }; case "bash": return { surface: "bash", values: [matchValue], resultExtras: { command } }; case "mcp": return { surface: "mcp", values: mcpTargets, resultExtras: { target } }; case "path": case "extension": return { surface: toolName, values: ["*"], resultExtras: {} }; } ``` ```typescript // tool-input-path.ts — getToolInputPath switch (classifyToolKind(toolName)) { case "bash": return null; // own token gates case "path": return getNonEmptyString(record.path); case "mcp": return getNonEmptyString(toRecord(record.arguments).path); case "skill": // no path; default case "extension": return getNonEmptyString((extractors?.get(toolName) ?? ((r) => r.path))(record)); } ``` ```typescript // handlers/gates/tool.ts — deriveSuggestionValue (accessPath stays here; tool-kind.ts stays AccessPath-free) switch (classifyToolKind(tcc.toolName)) { case "bash": return check.command ?? ""; case "mcp": return check.target ?? "mcp"; default: return accessPath ? accessPath.value() : "*"; } ``` `deriveSource` keeps its `SPECIAL_PERMISSION_KEYS` check (the `external_directory`/`path` surfaces classify as `extension`, then the special-key check maps them to `"special"`), and dispatches the remaining arms on `classifyToolKind`. `getPathBearingToolPath` becomes `classifyToolKind(toolName) === "path"`. The pipeline's two bash guards become `classifyToolKind(tcc.toolName) === "bash"` (compute the kind once at the top of `evaluate`). `buildCheckResult`'s `surface === "mcp"` target-shaping becomes `classifyToolKind(surface) === "mcp"` for consistency. ### Observed redundancy: `getToolPermission` `getToolPermission` (permission-manager) branches on `normalizedToolName === "bash"` / `"mcp"` / `"skill"` (not grep-counted — the variable is `normalizedToolName`, not `toolName`), but every branch — including the `SPECIAL` and default arms — evaluates the identical expression `evaluate(normalizedToolName, "*", composedRules, this.platform).action`. The branching is provably dead. Collapsing the whole body to that single return is a behavior-identical simplification folded into the `permission-manager` migration step, covered by the existing `getToolPermission` tests. It removes a discriminator without needing `classifyToolKind`. ### Metric note The roadmap's recompute grep keys literally on `toolName ===` / `source ===`. Migrated consumers dispatch on a `classifyToolKind(...)`/`kind`-named value, and `classifyToolKind` itself compares a `name`-named local, so none of the migrated code matches the grep — extraction-family sites drop to 0 and the total falls from 21 to 11 (presentation only), within the Step 1 target of ≤ 12. The Phase 10 end-state target (≤ 4, all in `tool-kind.ts`) is reached only after Step 2. ## Module-Level Changes - **`src/access-intent/tool-kind.ts`** (new) — `ToolKind` union + `classifyToolKind`; imports only `PATH_BEARING_TOOLS`. - **`src/input-normalizer.ts`** — `normalizeInput` dispatches on `classifyToolKind`; `buildInputForSurface` unchanged (see Non-Goals). - **`src/tool-input-path.ts`** — `getToolInputPath` and `getPathBearingToolPath` dispatch on `classifyToolKind`. - **`src/handlers/gates/tool.ts`** — `deriveSuggestionValue` dispatches on `classifyToolKind` (AccessPath handling stays local). - **`src/handlers/gates/tool-call-gate-pipeline.ts`** — the bash-parse guard (`evaluate`) and `resolvePerToolCheck` bash branch use `classifyToolKind(tcc.toolName) === "bash"`, computed once. - **`src/permission-manager.ts`** — `deriveSource` and `buildCheckResult` (`surface === "mcp"`) dispatch on `classifyToolKind`; `getToolPermission` collapses its dead branches. Imports `classifyToolKind` (allowed — it is not `access-path`). - **`test/access-intent/tool-kind.test.ts`** (new) — unit tests for `classifyToolKind`. - **`docs/architecture/architecture.md`** — add a `tool-kind.ts` entry under `access-intent/` in the module-layout tree; mark Phase 10 Step 1 `✅` on its heading and Mermaid node; annotate the "Tool-kind discriminator sites" metric row that the extraction family is cleared (presentation remains until Step 2). No public `exports`, event channel, or `Symbol.for()` surface changes — so no `docs/`-tree or README grep beyond the architecture doc is needed. `classifyToolKind` is not a cross-extension API; no user-facing doc references it. ## Test Impact Analysis 1. **New tests enabled.** The classification was inlined at 10 sites and untestable in isolation. `test/access-intent/tool-kind.test.ts` pins it directly: `bash`/`mcp`/`skill`, each member of `PATH_BEARING_TOOLS` → `path`, an arbitrary extension tool → `extension`, and whitespace-trim (`" bash "` → `bash`). 2. **Redundant tests.** None become fully redundant. Per-consumer "is this bash/mcp?" behavior is now also covered indirectly by the classifier test, but the consumer tests assert the consumer's *product* (surface/values/extras, path, suggestion value, source) — the refactor must preserve those, so they stay. 3. **Tests that must stay as-is.** `input-normalizer.test.ts`, `tool-input-path.test.ts`, `handlers/gates/tool.test.ts`, `handlers/gates/tool-call-gate-pipeline.test.ts`, and `permission-manager-unified.test.ts` exercise the extraction products the refactor keeps invariant — they are the characterization safety net. Before each migration, confirm the touched branch has characterization coverage; add a red characterization test only where a branch is uncovered (candidates: `getToolInputPath("skill", …)` → default path; `deriveSource` for the `external_directory`/`path` special surfaces and for an extension tool at the default layer). ## Invariants at risk The refactor touches surfaces earlier phases refactored; each invariant is pinned by an existing test: - **Extraction products unchanged** — `normalizeInput`'s `(surface, values, resultExtras)` ([#478]), `getToolInputPath`'s per-kind path ([#502]), `deriveSource`'s source mapping, and the pipeline's single-parse bash guard. Pinned by the five test files above. - **ADR-0002 string boundary** — `permission-manager.ts` must not import `AccessPath`. Pinned by the `no-restricted-imports` ESLint rule (`eslint.config.js`); `tool-kind.ts` staying `AccessPath`-free keeps `classifyToolKind` a legal import. - **Special-surface source** — `external_directory`/`path` still resolve to `source: "special"`. Ensure a characterization test covers it before migrating `deriveSource`. ## TDD Order Each migration is behavior-preserving under the existing green suite; only the new module carries a true red. Run `pnpm run check` after each step (shared-type / interface touch) and the full package suite before committing (shared helpers). 1. **Introduce `tool-kind.ts` + migrate the first consumer.** Red: `test/access-intent/tool-kind.test.ts` (module absent). Green: implement `classifyToolKind`, then migrate `tool-input-path.ts` (`getToolInputPath`, `getPathBearingToolPath`) so the export has a production consumer immediately (avoids a `fallow dead-code` failure on an unwired export). `tool-input-path.test.ts` stays green. Commit: `refactor(pi-permission-system): add tool-kind classification and migrate tool-input-path`. 2. **Migrate `normalizeInput`.** Refactor `input-normalizer.ts::normalizeInput` to the `classifyToolKind` switch; `input-normalizer.test.ts` green. Commit: `refactor(pi-permission-system): normalize input via tool-kind classification`. 3. **Migrate `permission-manager.ts`.** `deriveSource` + `buildCheckResult` (`surface === "mcp"`) dispatch on `classifyToolKind`; collapse `getToolPermission`'s dead branches. Add the special-surface / extension-default source characterization test first if uncovered. `permission-manager-unified.test.ts` and `permission-manager-yolo.test.ts` green; run `pnpm run check` (shared-type consumer). Commit: `refactor(pi-permission-system): derive check-result source via tool-kind`. 4. **Migrate the gate consumers.** `handlers/gates/tool.ts::deriveSuggestionValue` and `tool-call-gate-pipeline.ts` (compute the kind once, replace both bash guards). `tool.test.ts` and `tool-call-gate-pipeline.test.ts` green. Commit: `refactor(pi-permission-system): classify tool kind in the tool-call gate pipeline`. 5. **Record the roadmap step.** Update `docs/architecture/architecture.md`: add `tool-kind.ts` to the `access-intent/` module tree, mark Phase 10 Step 1 `✅` (heading + Mermaid node), annotate the metric row. Verify the extraction-family recompute returns 0. Commit: `docs(pi-permission-system): record Phase 10 Step 1 tool-kind classification`. ## Risks and Mitigations - **Exhaustiveness drift.** A future `ToolKind` variant must reach every switch. Mitigation: use exhaustive `switch` statements (or a `never`-typed default) in `normalizeInput`, `getToolInputPath`, and `deriveSource` so a new variant is a compile error, not a silent fall-through. - **`getToolInputPath` skill branch.** `skill` currently falls to the default `record.path` extraction; the migration must fold `skill` into the same arm as `extension`, not into `bash`/`path`/`mcp`. Mitigation: characterization test for `getToolInputPath("skill", …)` → `null`. - **`deriveSource` special surfaces.** `external_directory`/`path` classify as `extension`; the `SPECIAL_PERMISSION_KEYS` check must run and map them to `"special"`. Mitigation: keep the special-key check ahead of the default arm; characterization test. - **Unwired export → `fallow dead-code`.** CI gates on it. Mitigation: fold the first consumer into the introduction commit (Step 1) so `classifyToolKind` never lands unused. - **ADR-0002 breach.** Mitigation: `tool-kind.ts` imports only `PATH_BEARING_TOOLS`; the existing ESLint rule guards `permission-manager.ts`. Confirm `pnpm run lint` passes after Step 3. ## Open Questions - None blocking. `getToolPermission`'s dead-branch collapse is included as a behavior-identical simplification; if review prefers to keep it out of a classification-focused refactor, it can drop to a one-line follow-up without affecting the rest. [#309]: https://github.com/gotgenes/pi-packages/issues/309 [#478]: https://github.com/gotgenes/pi-packages/issues/478 [#490]: https://github.com/gotgenes/pi-packages/issues/490 [#502]: https://github.com/gotgenes/pi-packages/issues/502 [#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 [#569]: https://github.com/gotgenes/pi-packages/issues/569