---
issue: 326
issue_title: "Unify handleInput's skill-input gate with the GateRunner pipeline"
---

# Unify `handleInput`'s skill-input gate with the `GateRunner` pipeline

## Problem Statement

`PermissionGateHandler.handleInput` gates `/skill:<name>` invocations by hand-rolling the same `check → log → emit → approve` cycle that `GateRunner.runDescriptor` already owns.
It calls `checkPermission` directly, builds its own `applyPermissionGate(...)`, emits its own `permissions:decision` event, and computes the decision `resolution` with a nested, eslint-disabled ternary that re-implements `deriveResolution()`.
This is the worst-CRAP function in the file (79.4, after `handleToolCall` was decomposed in #285) and it reaches straight into `emitDecision` / `writeReviewLog` / `prompt` / `canPrompt` — the very channels that #322 (`DecisionReporter`) and #323 (`GatePrompter`) gave owners.

The duplication is also a maintenance trap: a change to the gate cycle (as in #319's `resolve()` routing) must be made in two places, and the second is easy to miss.

This is preparatory work for #325: collapsing `handleInput` onto the shared runner removes a large part of the handler's dependency on the concrete `PermissionSession`, so the eventual role-interface retyping becomes a small change ("make the change easy, then make the easy change").

## Goals

- Express the skill-input gate as a `GateDescriptor` produced by a pure `describeSkillInputGate(...)` factory and run it through the existing `GateRunner`.
- Delete the inline `applyPermissionGate` block, the nested resolution ternary, and the direct `emitDecision` / `writeReviewLog` / `prompt` / `canPrompt` calls in `handleInput`.
- Preserve the observable decision-event behavior (surface/value/result/resolution) and the deny-time UI warning exactly.
- Make the skill-input gate logic unit-testable in isolation (it is currently reachable only through `handleInput`).

## Non-Goals

- Retyping the `PermissionGateHandler` constructor against role interfaces or dropping the `as unknown as PermissionSession` casts — that is #325.
  This plan keeps the concrete-class constructor and the existing mocks.
- Extracting the tool-call gate pipeline or tightening the `PermissionSession` API (`getToolPreviewLimits`, `getInfrastructureReadDirs`) — that is #327.
- Changing whether skill-input honors session rules.
  `handleInput` resolves via `checkPermission` (no session ruleset); this plan preserves that exactly via `preCheck` (see Open Questions).
- Touching `handleToolCall`.

## Background

Relevant existing modules:

- `src/handlers/permission-gate-handler.ts` — `handleInput` (the code being refactored) and `handleToolCall` (already runs its gates through `this.runner`).
  The constructor already builds `this.runner = new GateRunner(session, session, session, this.reporter)` and `this.reporter = new GateDecisionReporter(session.logger, events)`, so `handleInput` can reuse both with no constructor change.
- `src/handlers/gates/runner.ts` — `GateRunner.run(gate, agentName, toolCallId)` → `runDescriptor`.
  For a `GateDescriptor` with `preCheck` set, it uses that check directly (no `resolver.resolve`), runs `applyPermissionGate`, emits the decision via the reporter, and records session approval only when the descriptor carries a `sessionApproval`.
- `src/handlers/gates/descriptor.ts` — `GateDescriptor` shape (`surface`, `input`, `denialContext`, `promptDetails` (runner adds `requestId`), `logContext`, `decision`, optional `preCheck` / `preResolved` / `sessionApproval`).
- `src/handlers/gates/skill-read.ts` — `describeSkillReadGate`, the sibling factory this one mirrors (it uses `preResolved`; this one uses `preCheck`).
- `src/handlers/gates/helpers.ts` — `deriveResolution(state, action, hasSession, canConfirm, autoApproved)` produces exactly the resolutions `handleInput`'s ternary computes for the no-session case.
- `src/denial-messages.ts` — `DenialContext` discriminated union + three exhaustive body builders (`buildDenyBody` / `buildUnavailableBody` / `buildUserDeniedBody`).
  Adding a `skill_input` variant forces a case in each (TypeScript exhaustiveness — a feature, not a chore).
- `src/permission-prompter.ts` — `PromptPermissionDetails`; `toolCallId` / `toolName` / `skillName` are optional, so the skill-input descriptor's `promptDetails` (`source` / `agentName` / `message` / `skillName`) typechecks.

AGENTS / skill constraints that apply:

- The skill-input deny/unavailable/user-denied **messages will change** (see Design Overview) — a deliberate, behavior-affecting decision, documented in the issue.
  Per the testing skill's TDD rules, any test asserting the old strings must update in the same step as the change.
- `@typescript-eslint/require-await`: `handleInput` keeps an `await this.runner.run(...)`, so it stays `async`.
- There is no `src/handlers/gates/index.ts` barrel; sibling gate factories are imported directly, so the new factory is imported directly too (no speculative re-export).

## Design Overview

### `describeSkillInputGate` factory

A pure factory mirroring `describeSkillReadGate`, but keyed off a caller-supplied raw `preCheck` rather than a skill-entry match:

```typescript
export function describeSkillInputGate(
  skillName: string,
  agentName: string | null,
  preCheck: PermissionCheckResult,
): GateDescriptor {
  const message = formatSkillAskPrompt(skillName, agentName ?? undefined);
  return {
    surface: "skill",
    input: { name: skillName },
    preCheck,
    denialContext: { kind: "skill_input", skillName, agentName: agentName ?? undefined },
    promptDetails: { source: "skill_input", agentName, message, skillName },
    logContext: { source: "skill_input", skillName, agentName, message },
    decision: { surface: "skill", value: skillName },
  };
}
```

It takes only the three values it reads (ISP — no `tcc`, since skill input is not a tool call).

### `handleInput` after the change

```typescript
async handleInput(event: InputPayload, ctx: ExtensionContext): Promise<InputEventResult> {
  this.session.activate(ctx);
  const skillName = extractSkillNameFromInput(event.text);
  if (!skillName) return { action: "continue" };

  const agentName = this.session.resolveAgentName(ctx);
  const check = this.session.checkPermission("skill", { name: skillName }, agentName ?? undefined);

  if (check.state === "deny" && ctx.hasUI) {
    ctx.ui.notify(/* unchanged deny-warning text */, "warning");
  }

  const outcome = await this.runner.run(
    describeSkillInputGate(skillName, agentName, check),
    agentName,
    this.session.createPermissionRequestId("skill-input"),
  );
  return outcome.action === "block" ? { action: "handled" } : { action: "continue" };
}
```

The runner now owns the prompt (`prompter.promptPermission`), the review-log writes, the decision-event emission, and the resolution derivation.

### Why the decision events are preserved exactly

The runner builds the event via `buildDecisionEvent({ surface: "skill", value: skillName }, check, agentName, result, deriveResolution(...))`.
For skill input, `preCheck.source` is never `"session"` (the raw `checkPermission` is called without a session ruleset), so the runner's session-hit fast path is unreachable and `descriptor.sessionApproval` is absent (`hasSession` is always `false`).
`deriveResolution` then yields the identical mapping to `handleInput`'s current ternary:

| `check.state` | gate action | extra      | resolution                |
| ------------- | ----------- | ---------- | ------------------------- |
| `allow`       | allow       | —          | `policy_allow`            |
| `deny`        | block       | —          | `policy_deny`             |
| `ask`         | allow       | autoApprove| `auto_approved`           |
| `ask`         | allow       | —          | `user_approved`           |
| `ask`         | block       | canConfirm | `user_denied`             |
| `ask`         | block       | no UI      | `confirmation_unavailable`|

`origin`, `agentName`, and `matchedPattern` flow from the same `check`, so the full event matches.

### Deliberate change: block-reason messages

Today `handleInput` passes ad-hoc, **tag-less** strings to `applyPermissionGate` (`denyReason` = the ask-prompt text; `unavailableReason` = "Skill requires approval, but no interactive UI is available."; `userDeniedReason` = "User denied skill.").
The runner instead formats messages from `descriptor.denialContext` via `formatDenyReason` / `formatUnavailableReason` / `formatUserDeniedReason`, which prepend the `[pi-permission-system]` tag.
A new `skill_input` `DenialContext` kind supplies bodies consistent with the `skill_read` sibling:

```text
deny:        Current agent is not permitted to access skill '<name>'.
unavailable: Accessing skill '<name>' requires approval, but no interactive UI is available.
userDenied:  User denied access to skill '<name>'.[ Reason: …]
```

These block reasons are not surfaced to the user for input handling (`handleInput` returns `{ action: "handled" }` and discards the reason); they appear only in the review log.
No existing input test asserts them.
The change gives skill input the same `[pi-permission-system]` attribution every other surface already carries.

## Module-Level Changes

- `src/denial-messages.ts` — add a `skill_input` variant to `DenialContext` (`{ kind: "skill_input"; skillName: string; agentName?: string }`) and a matching `case "skill_input":` to `buildDenyBody`, `buildUnavailableBody`, and `buildUserDeniedBody`.
  (Grepped: these three switches are the only ones over `DenialContext.kind`.)
- `src/handlers/gates/skill-input.ts` — **new**: `describeSkillInputGate`.
- `src/handlers/permission-gate-handler.ts` — rewrite `handleInput` to build the descriptor and call `this.runner.run(...)`; delete the inline `applyPermissionGate` block, the nested resolution ternary, and the manual `this.reporter.emitDecision(...)`; remove the now-unused `applyPermissionGate` import (used only here) and drop `formatSkillAskPrompt` from the `#src/permission-prompts` import (moves to the factory).
- `test/handlers/gates/skill-input.test.ts` — **new**: unit tests for the factory (descriptor shape, `preCheck` passthrough, message wiring).
- `test/denial-messages.test.ts` — add `skill_input` cases for the three formatters.
- `test/handlers/input.test.ts` — update the "passes agentName in the prompt permission request" test: prompting now flows through `session.promptPermission(details)` (the runner's `GatePrompter`), so assert on `session.promptPermission` (or the captured details) rather than `session.prompt(ctx, …)`; `expect.anything()` no longer matches the (now context-bound) first argument.
- `test/handlers/input-events.test.ts` — expected to pass **unchanged** (resolutions reproduced by the runner); verify, do not edit.

`docs/architecture/architecture.md` already records this as Phase 3 Step 9 (#326); no further doc edit is needed in this issue.

## Test Impact Analysis

1. **New lower-level tests enabled.**
   The skill-input gate logic was reachable only through `handleInput`; extracting `describeSkillInputGate` makes the descriptor independently unit-testable (`skill-input.test.ts`), and the new `skill_input` `DenialContext` gains direct formatter coverage (`denial-messages.test.ts`).
2. **Redundant tests.**
   None are removed.
   `input-events.test.ts` continues to pin the end-to-end resolutions; with the runner now producing them, those assertions also document that the unified path is equivalent.
   The bespoke-ternary branches are no longer separately reachable, but the event-level tests still cover every resolution.
3. **Tests that must stay as-is.**
   `input.test.ts` (activation, skill-name parsing, allow/deny/ask outcomes, deny-warning notify) and `input-events.test.ts` (decision events) genuinely exercise `handleInput`'s contract and remain the behavioral guard for this refactor. `runner.test.ts` is untouched.

## TDD Order

1. **Add the `skill_input` denial context.** (red→green)
   - Test surface: `test/denial-messages.test.ts`.
   - Covered: `formatDenyReason` / `formatUnavailableReason` / `formatUserDeniedReason` for a `skill_input` context produce the three tagged bodies above.
   - Implementation: add the union variant + three switch cases.
   - Commit: `feat: add skill_input denial context (#326)`.
2. **Extract `describeSkillInputGate` and route `handleInput` through the runner.** (red→green→refactor)
   - Test surface: `test/handlers/gates/skill-input.test.ts` (new, factory unit tests) + the existing `test/handlers/input.test.ts` / `input-events.test.ts`.
   - Covered: the factory returns the descriptor shape (surface `skill`, `input.name`, `preCheck` passthrough, `skill_input` denial context, `skill_input` prompt/log source, decision value = skill name); `handleInput` produces the same outcomes and decision events through `this.runner.run(...)`.
   - Implementation: add `src/handlers/gates/skill-input.ts`; rewrite `handleInput`; remove the inline gate, ternary, manual emit, and the `applyPermissionGate` / `formatSkillAskPrompt` handler imports; update the one `input.test.ts` prompt assertion to target `promptPermission`.
   - Run `pnpm --filter @gotgenes/pi-permission-system exec vitest run` (full package suite) and `pnpm run check` before committing — the factory must have a `src` consumer in this same commit (no dead-code window).
   - Commit: `refactor: route handleInput skill-input gate through GateRunner (#326)`.

## Risks and Mitigations

- **Decision-event drift.**
  Mitigated by the resolution table above and by `input-events.test.ts` passing unchanged; if any resolution differs, that suite fails immediately.
- **`preCheck` taking the session-hit path.**
  Cannot occur: skill input calls `checkPermission` without a session ruleset, so `source` is never `"session"`.
  Documented so a future change that adds session rules here is flagged.
- **Block-reason message change.**
  Deliberate (gains the `[pi-permission-system]` tag); not asserted by any input test; surfaced only in the review log; called out in the issue and `denial-messages.test.ts`.
- **`expect.anything()` assertion break.**
  Anticipated; the single affected `input.test.ts` case is updated in Step 2.
- **Unused-import lint after the rewrite.** `applyPermissionGate` and `formatSkillAskPrompt` become unused in the handler; both are removed in Step 2 (eslint would otherwise fail).

## Open Questions

- **Should skill input honor session rules?**
  `handleInput` uses raw `checkPermission` (no session ruleset) while every `handleToolCall` gate uses `resolve()` (session-rule-aware).
  This plan preserves the raw behavior via `preCheck`.
  Switching to `resolve()` is a separate, deliberate behavior change and is out of scope here; left as a tracked question on #326.
