Files

100 lines
11 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: 323
issue_title: "Replace GateRunnerDeps with a GateRunner class injected with role collaborators"
---
# Retro: #323 — Replace `GateRunnerDeps` with a `GateRunner` class injected with role collaborators
## Stage: Planning (2026-06-03T02:02:27Z)
### Session summary
Planned the final step of the gate-runner collaborator rework: convert the free `runGateCheck` function and its `GateRunnerDeps` bag into a `GateRunner` class constructed with four role collaborators, adding the two missing roles (`GatePrompter`, `SessionApprovalRecorder`).
Confirmed #319 (`PermissionResolver`) and #322 (`DecisionReporter`) have landed in `src/`, so both prerequisites are satisfied.
Produced a five-step lift-and-shift plan (roles + session adapters, `GateRunner` alongside a temporary `runGateCheck` wrapper, handler migration, deletion, architecture doc) and committed it.
### Observations
- Module placement: put `GatePrompter` and `SessionApprovalRecorder` in their own SDK-free files (`src/gate-prompter.ts`, `src/session-approval-recorder.ts`) to mirror the `permission-resolver.ts` / `decision-reporter.ts` precedent; co-locating `SessionApprovalRecorder` inside `session-approval.ts` was considered and rejected for consistency.
Verified neither `permission-prompter.ts` nor `session-approval.ts` imports from `handlers/gates`, so the role interfaces import cleanly with no cycle.
- The prompter is the crux: `GatePrompter` (`canConfirm()` + `promptPermission(details)`) carries no `ctx`, so `PermissionSession` implements it with stored-context adapters over `this.context` (set by `activate(ctx)` at the top of `handleToolCall`).
`canConfirm()` returns `false` when inactive, making the `promptPermission` null-guard unreachable in correct use — a defensive invariant only.
- Transition via lift-and-shift: `GateRunnerDeps` already structurally satisfies all four roles, so `runGateCheck` becomes a one-line wrapper (`new GateRunner(deps, deps, deps, deps.reporter).run(...)`) in step 2, letting the handler (step 3) and the large `runner.test.ts` (step 4) migrate independently before the wrapper, interface, and `makeRunnerDeps` are deleted together.
- Applied the #319-retro `missing-context` lesson proactively: grepped all session mocks up front.
Three (`handler-fixtures.ts` `makeSession`, `external-directory-integration.test.ts`, `external-directory-session-dedup.test.ts`) are `as unknown as PermissionSession`, so the runtime runner calling `session.canConfirm()` / `session.promptPermission()` would fail at runtime, not typecheck.
Step 3 adds delegating `canConfirm``canPrompt` / `promptPermission``prompt` adapters (guarded with `Object.hasOwn` like the existing `resolve` delegation) so the `prompt`-override and `session.prompt` call-count assertions in the dedup and tool-call suites keep passing.
- The delegating-mock tactic is a known transitional smell (#319 retro); flagged as removed by #325 when the handler is retyped against the role interfaces and the `as unknown as` casts drop.
- Scope held: behavior-preserving, no public npm export change (all `#src` internal), `handleInput` untouched, `as unknown as PermissionSession` deferred to #325.
## Stage: Implementation — TDD (2026-06-03T22:27:00Z)
### Session summary
Executed all five TDD cycles: added `GatePrompter` and `SessionApprovalRecorder` role interfaces with `PermissionSession` stored-context adapters (+5 new tests), introduced the `GateRunner` class alongside a transitional `runGateCheck` wrapper (+6 null/bypass dispatch tests), migrated `PermissionGateHandler` to the injected runner with delegating session mocks in all three integration-test harnesses, migrated `runner.test.ts` off `makeRunnerDeps`/`runGateCheck` to `makeGateRunner`/`runner.run` and deleted the wrapper + `GateRunnerDeps` + `makeRunnerDeps`, and updated the architecture doc.
Test count: 1770 → 1781 (+11).
Pre-completion reviewer verdict: PASS.
### Observations
- Step 1 deviation: `promptPermission`s null guard used `throw new Error(...)` initially, which is synchronous and not a rejected promise; `expect(...).rejects.toThrow(...)` requires a rejected promise.
Fixed by changing to `return Promise.reject(new Error(...))` — clean and avoids the `@typescript-eslint/require-await` lint rule that would fire on an `async` function with no `await`.
- Step 2 deviation: marking `runGateCheck` with `@deprecated` JSDoc triggered `@typescript-eslint/no-deprecated` on all 19 call sites in the test file at commit time.
Removed the JSDoc tag and kept only a prose comment explaining the transitional nature.
- The `#319`-retro `missing-context` lesson applied cleanly: all three `as unknown as PermissionSession` session mocks were identified at plan time and received delegating `canConfirm`/`promptPermission` adapters in step 3 before the handler was migrated.
The full handler integration suite (359 tests) stayed green throughout.
- Reviewer WARNs (both pre-existing, no action needed):
1. `toolDescriptor.preCheck = toolCheck` patch-after-construction in the last gate producer — pre-dates this issue, out of scope.
2. `const resolver = this.session` alias types as `PermissionSession` rather than `PermissionResolver` — explicitly deferred to #325 in the plans Non-Goals.
## Stage: Final Retrospective (2026-06-03T02:31:35Z)
### Session summary
One continuous session carried #323 from planning through five TDD cycles to a PASS pre-completion review: the capstone-minus-one of the gate-runner collaborator rework, dissolving the `GateRunnerDeps` bag and the free `runGateCheck` function into an injected `GateRunner` class with four narrow role collaborators.
Execution was unusually clean — 7 commits, +11 tests (1770 → 1781), zero rework of committed code, two self-caught TypeScript/lint deviations each resolved in one or two tool calls.
The dominant theme was a planning investment (proactive mock-grep, structural lift-and-shift design) that pre-empted exactly the friction that bit the earlier #319 step.
### Observations
#### What went well
- The `#319`-retro lesson chain closed the loop: #319 was bitten at TDD time by hand-rolled `as unknown as PermissionSession` session mocks breaking at runtime (not typecheck) when a new session method was routed through the runner.
For #323, planning grepped all three session mocks up front, named them in the plan's Module-Level Changes, and step 3 added delegating `canConfirm`/`promptPermission` adapters before migrating the handler — the 359-test handler suite stayed green with no surprise.
A retro observation prevented its own recurrence one issue later.
- The lift-and-shift wrapper exploited a structural coincidence cleanly: because `GateRunnerDeps` already structurally satisfied all four role interfaces, `runGateCheck` collapsed to a one-line wrapper (`new GateRunner(deps, deps, deps, deps.reporter).run(...)`), letting the handler (step 3) and the 440-line `runner.test.ts` (step 4) migrate in independent green commits before the wrapper and interface were deleted together.
- Verification was incremental and load-bearing: the affected test file ran red→green each cycle, `pnpm run check` ran after every interface-touching step (1, 2, 3), and the full suite + `check` + `lint` + `fallow dead-code` + lockfile check ran after the last step.
No end-only-verification gap.
#### What caused friction (agent side)
1. `other` (TDD step 1) — the `promptPermission` null guard was written as a synchronous `throw` inside a non-`async` method declared `Promise<…>`; `expect(...).rejects.toThrow(...)` cannot catch a synchronous throw.
Switched to `return Promise.reject(new Error(...))`, which also sidesteps the `@typescript-eslint/require-await` rule that an `async`-with-no-`await` workaround would trip.
Impact: self-caught on the first test run, ~2 tool calls, no rework of committed code.
2. `other` (TDD step 2) — marking the transitional `runGateCheck` wrapper with `@deprecated` JSDoc triggered `@typescript-eslint/no-deprecated` on all 19 surviving call sites in `runner.test.ts` at commit time.
Removed the tag, kept a prose comment.
Impact: self-caught by the pre-commit eslint hook, one edit, no rework.
#### What caused friction (user side)
- None material.
The user issued the three workflow prompts (`/plan-issue`, `/tdd-plan`, `/retro`) and let the agent run end-to-end; the plan was prescriptive enough that no `ask_user` decision gate was needed and no redirection occurred.
### Diagnostic details
- **Model-performance correlation** — interleaving `model_change` with `message` entries gives the accurate attribution: planning ran on `anthropic/claude-opus-4-8`, the entire TDD execution (all ~90 turns) on `anthropic/claude-sonnet-4-6`, and this retro on `anthropic/claude-opus-4-8`.
The `opencode-go/deepseek-v4-flash` entry in the model-change log was a transient selection immediately overridden by a switch to opus before the next turn — **zero assistant turns ran under it**.
The one subagent dispatch (`pre-completion-reviewer`) ran on its default `anthropic/claude-sonnet-4-6` and did judgment-heavy work (217s, 36 tool uses, accurate PASS with two correct pre-existing WARNs) — appropriately capable.
TDD on sonnet was clean and planning/review on opus/sonnet was sound, so no model-quality mismatch.
Lens caveat: reading `model_change` entries in isolation over-counts models — a change event does not imply a turn ran under that model; attribution requires interleaving with `message` entries (this mistake produced an initial “bounced across three models” misstatement, corrected here).
- **Escalation-delay tracking** — no rabbit-holes; both deviations resolved in ≤2 consecutive tool calls.
No sequence approached the 5-call threshold.
- **Unused-tool detection** — none needed; planning's proactive mock-grep removed the one place a missing-context gap could have formed, and no subagent beyond the reviewer was warranted.
- **Feedback-loop gap analysis** — verification ran incrementally after every change, including `pnpm run check` after each of the three interface-touching steps; the proactive handler-suite run after the step-3 mock change is the concrete payoff.
### Changes made
1. Added a `Promise.reject`-vs-`throw` rule to the `Test assertions` section of `.pi/skills/testing/SKILL.md` (a synchronous `throw` escapes `expect(...).rejects.toThrow(...)`; switching to `async` trips `require-await`).
2. Added a transitional-wrapper `@deprecated` rule to the `TDD planning rules` section of `.pi/skills/testing/SKILL.md` (`@typescript-eslint/no-deprecated` fires on every surviving call site).
3. Clarified the `Model-performance correlation` lens in `.pi/prompts/retro.md` to require interleaving `model_change` with `message` entries — a `model_change` with no assistant turn under it never ran.
4. Corrected this retro's `Model-performance correlation` diagnostic: the `opencode-go/deepseek-v4-flash` model-change event ran zero turns (transient selection overridden by opus); TDD ran entirely on `anthropic/claude-sonnet-4-6`, planning and this retro on `anthropic/claude-opus-4-8`.