Files
my-pi/pi-permission-system/docs/retro/0326-unify-handleinput-skill-input-gate.md

98 lines
8.2 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
issue: 326
issue_title: "Unify handleInput's skill-input gate with the GateRunner pipeline"
---
# Retro: #326 — Unify `handleInput`'s skill-input gate with the `GateRunner` pipeline
## Stage: Planning (2026-06-02T00:00:00Z)
### Session summary
This session began as planning for #325 but pivoted.
Investigating #325's "residual cluster" decomposition (with the user steering toward Tell-Don't-Ask and "make the change that makes the change easy") surfaced that #325 is awkward only because `PermissionGateHandler` carries a wide, anemic dependency on the concrete `PermissionSession`.
Two preparatory refactors were identified and filed — #326 (unify `handleInput` with `GateRunner`) and #327 (extract a `ToolCallGatePipeline`) — and sequenced ahead of #325 in `docs/architecture/architecture.md` (Phase 3 Steps 911; downstream steps renumbered, diagram + tracks updated).
This planning session then produced the numbered plan for #326, the first pivot target.
### Observations
- **`handleInput` fully reduces to the runner.**
The bespoke `applyPermissionGate` block, the eslint-disabled nested resolution ternary, and the manual `emitDecision` all map onto `GateRunner.runDescriptor` + `deriveResolution`.
Confirmed the six resolution values (`policy_allow`, `policy_deny`, `auto_approved`, `user_approved`, `user_denied`, `confirmation_unavailable`) are reproduced exactly, so `input-events.test.ts` should pass unchanged.
- **`preCheck` preserves raw semantics.**
`handleInput` resolves via `checkPermission` (no session ruleset), so `preCheck.source` is never `"session"` and the runner's session-hit branch is unreachable — the unification stays behavior-preserving on resolution.
Whether skill input *should* honor session rules is left as a tracked open question, not changed here.
- **One deliberate behavior change.**
Block-reason messages move from ad-hoc tag-less strings to runner-formatted ones (a new `skill_input` `DenialContext` kind), gaining the `[pi-permission-system]` tag like every other surface.
Not asserted by any input test; surfaced only in the review log.
Flagged in the issue and the plan.
- **Scope boundaries held.**
#326 does **not** change the handler constructor or drop the `as unknown as PermissionSession` casts (that is #325), and does **not** tighten the `PermissionSession` API or touch `handleToolCall` (that is #327).
The concrete-session mocks stay.
- **TDD shape.**
Two commits: (1) additive `skill_input` denial context + formatter tests; (2) factory + `handleInput` rewrite + consumer-test updates folded together so the new `describeSkillInputGate` has a `src` consumer immediately (no dead-code window for fallow).
- **Known test edit.** `input.test.ts`'s "passes agentName…" assertion uses `expect.anything()` for the prompt's first arg; prompting now flows through the context-bound `promptPermission(details)`, so that one assertion must retarget `session.promptPermission`.
- **Process note.**
Per the user's direction, this is a recursive "discover → note in architecture.md → file issues → backtrack" loop; expect further smells (e.g. the `ToolCallGatePipeline` shape in #327, and the `index.ts` composition root in #320) to be refined as those issues are planned.
## Stage: Implementation — TDD (2026-06-02T23:20:00Z)
### Session summary
Completed two TDD cycles in order.
Step 1 added the `skill_input` variant to `DenialContext` and its three switch cases in `buildDenyBody`, `buildUnavailableBody`, and `buildUserDeniedBody`, with 5 new tests in `test/denial-messages.test.ts`.
Step 2 created `src/handlers/gates/skill-input.ts` (`describeSkillInputGate` pure factory, 10 unit tests), rewrote `handleInput` to delegate to `this.runner.run(...)`, removed the inline `applyPermissionGate` block and the nested resolution ternary, and updated the one `input.test.ts` prompt assertion to target `session.promptPermission`.
Test count went from 1781 to 1796 (+15).
### Observations
- **`input-events.test.ts` passed unchanged**, confirming the runner reproduces all six resolutions (`policy_allow`, `policy_deny`, `user_approved`, `user_denied`, `auto_approved`, `confirmation_unavailable`) identically.
- **Single prompt-assertion fix in `input.test.ts`** was exactly as anticipated: the `expect.anything()` first argument was replaced by `session.promptPermission(details)` with no second argument.
- **No dead-code window**: `describeSkillInputGate` was introduced in the same commit as the `handleInput` rewrite, satisfying the fallow constraint.
- **`applyPermissionGate` and `formatSkillAskPrompt` cleanly removed** from `permission-gate-handler.ts`; lint passed on first run.
- **Pre-completion reviewer: PASS** — one WARN note that `architecture.md` step 9 lacks the ✅ prefix; reviewer confirmed this is intentional (the project pattern defers ✅ updates to post-ship).
## Stage: Final Retrospective (2026-06-02T23:45:00Z)
### Session summary
The TDD implementation landed both planned cycles cleanly — three commits (`feat: add skill_input denial context`, `refactor: route handleInput skill-input gate through GateRunner`, `docs(retro): add TDD stage notes`), +15 tests (1781 → 1796), pre-completion reviewer PASS, zero deviations from the plan.
The only friction was behavioral, not technical: the agent repeatedly ended its turn after `Edit`/`Write` calls, requiring three user nudges to keep the cycle moving.
### Observations
#### What went well
- **Plan-prediction discipline paid off end to end.**
The plan's single "Known test edit" note (retarget `input.test.ts`'s `expect.anything()` assertion to `session.promptPermission`) materialized exactly as written, and `input-events.test.ts` passed unchanged — confirming behavior preservation with no surprises across either cycle.
A notably clean plan→execution match: every red→green→commit step worked first try.
#### What caused friction (agent side)
- `other` — premature turn termination after `Edit`/`Write` tool calls.
Turns 19, 23, and 32 were empty assistant turns where the agent stopped instead of continuing the Red→Green→Commit cycle.
Root cause: the active `pi-autoformat` extension injects a `[pi-permission-system]`-style `[pi-autoformat] Formatted N file(s)` user-role message after each `Edit`/`Write`; the agent (running `anthropic/claude-sonnet-4-6`) interpreted that injected message as a turn boundary and yielded.
Impact: the user intervened three times — `Continue.` (turn 20), `Continue.` (turn 24), and the diagnostic `I would like you to continue until we've met the expectations of the plan. I'm not sure why we keep ending work at edits or writes.` (turn 33).
Added friction, no rework — the work itself was clean.
User-caught, not self-identified.
#### What caused friction (user side)
- The first two nudges (`Continue.`) were minimal; the third (turn 33) added the diagnostic framing that surfaced the real question.
Opportunity, not criticism: leading with `why are you stopping after edits?` after the first stall would have surfaced the `pi-autoformat`-injection root cause two turns earlier.
### Diagnostic details
- **Model-performance correlation** — TDD turns ran on `anthropic/claude-sonnet-4-6` (appropriate for mechanical TDD); the `pre-completion-reviewer` subagent ran judgment-heavy review (323.6s, 45 tool uses) under its own frontmatter model; this retro runs on `anthropic/claude-opus-4-8` (appropriate for synthesis).
A transient `model_change → opencode-go/deepseek-v4-flash` after the TDD summary had no assistant turn under it — it never ran and is not counted.
No mismatches.
- **Feedback-loop gap analysis** — exemplary, no gap.
Tests ran after every Red and Green phase; the full `check` / `lint` / `test` / `fallow dead-code` gate ran after the last step.
Verification was incremental, not end-loaded.
- Escalation-delay and unused-tool lenses found nothing notable (no rabbit-holes; all file reads front-loaded at turns 1115 before editing).
### Changes made
1. `AGENTS.md` — added a `### Tool-injected messages` subsection under `## Workflow`: the `pi-autoformat` `Formatted N file(s)` message is informational, not a turn boundary, so the agent continues the current step instead of yielding.
2. `packages/pi-permission-system/docs/retro/0326-unify-handleinput-skill-input-gate.md` — this Final Retrospective stage entry.