8.2 KiB
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 9–11; downstream steps renumbered, diagram + tracks updated).
This planning session then produced the numbered plan for #326, the first pivot target.
Observations
handleInputfully reduces to the runner. The bespokeapplyPermissionGateblock, the eslint-disabled nested resolution ternary, and the manualemitDecisionall map ontoGateRunner.runDescriptor+deriveResolution. Confirmed the six resolution values (policy_allow,policy_deny,auto_approved,user_approved,user_denied,confirmation_unavailable) are reproduced exactly, soinput-events.test.tsshould pass unchanged.preCheckpreserves raw semantics.handleInputresolves viacheckPermission(no session ruleset), sopreCheck.sourceis 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_inputDenialContextkind), 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 PermissionSessioncasts (that is #325), and does not tighten thePermissionSessionAPI or touchhandleToolCall(that is #327). The concrete-session mocks stay. - TDD shape.
Two commits: (1) additive
skill_inputdenial context + formatter tests; (2) factory +handleInputrewrite + consumer-test updates folded together so the newdescribeSkillInputGatehas asrcconsumer immediately (no dead-code window for fallow). - Known test edit.
input.test.ts's "passes agentName…" assertion usesexpect.anything()for the prompt's first arg; prompting now flows through the context-boundpromptPermission(details), so that one assertion must retargetsession.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
ToolCallGatePipelineshape in #327, and theindex.tscomposition 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.tspassed 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.tswas exactly as anticipated: theexpect.anything()first argument was replaced bysession.promptPermission(details)with no second argument. - No dead-code window:
describeSkillInputGatewas introduced in the same commit as thehandleInputrewrite, satisfying the fallow constraint. applyPermissionGateandformatSkillAskPromptcleanly removed frompermission-gate-handler.ts; lint passed on first run.- Pre-completion reviewer: PASS — one WARN note that
architecture.mdstep 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'sexpect.anything()assertion tosession.promptPermission) materialized exactly as written, andinput-events.test.tspassed 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 afterEdit/Writetool 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 activepi-autoformatextension injects a[pi-permission-system]-style[pi-autoformat] Formatted N file(s)user-role message after eachEdit/Write; the agent (runninganthropic/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 diagnosticI 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 withwhy are you stopping after edits?after the first stall would have surfaced thepi-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); thepre-completion-reviewersubagent ran judgment-heavy review (323.6s, 45 tool uses) under its own frontmatter model; this retro runs onanthropic/claude-opus-4-8(appropriate for synthesis). A transientmodel_change → opencode-go/deepseek-v4-flashafter 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-codegate 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 11–15 before editing).
Changes made
AGENTS.md— added a### Tool-injected messagessubsection under## Workflow: thepi-autoformatFormatted N file(s)message is informational, not a turn boundary, so the agent continues the current step instead of yielding.packages/pi-permission-system/docs/retro/0326-unify-handleinput-skill-input-gate.md— this Final Retrospective stage entry.