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

8.2 KiB
Raw Blame History

issue, issue_title
issue issue_title
326 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.