11 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 323 | 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
GatePrompterandSessionApprovalRecorderin their own SDK-free files (src/gate-prompter.ts,src/session-approval-recorder.ts) to mirror thepermission-resolver.ts/decision-reporter.tsprecedent; co-locatingSessionApprovalRecorderinsidesession-approval.tswas considered and rejected for consistency. Verified neitherpermission-prompter.tsnorsession-approval.tsimports fromhandlers/gates, so the role interfaces import cleanly with no cycle. - The prompter is the crux:
GatePrompter(canConfirm()+promptPermission(details)) carries noctx, soPermissionSessionimplements it with stored-context adapters overthis.context(set byactivate(ctx)at the top ofhandleToolCall).canConfirm()returnsfalsewhen inactive, making thepromptPermissionnull-guard unreachable in correct use — a defensive invariant only. - Transition via lift-and-shift:
GateRunnerDepsalready structurally satisfies all four roles, sorunGateCheckbecomes a one-line wrapper (new GateRunner(deps, deps, deps, deps.reporter).run(...)) in step 2, letting the handler (step 3) and the largerunner.test.ts(step 4) migrate independently before the wrapper, interface, andmakeRunnerDepsare deleted together. - Applied the #319-retro
missing-contextlesson proactively: grepped all session mocks up front. Three (handler-fixtures.tsmakeSession,external-directory-integration.test.ts,external-directory-session-dedup.test.ts) areas unknown as PermissionSession, so the runtime runner callingsession.canConfirm()/session.promptPermission()would fail at runtime, not typecheck. Step 3 adds delegatingcanConfirm→canPrompt/promptPermission→promptadapters (guarded withObject.hasOwnlike the existingresolvedelegation) so theprompt-override andsession.promptcall-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 ascasts drop. - Scope held: behavior-preserving, no public npm export change (all
#srcinternal),handleInputuntouched,as unknown as PermissionSessiondeferred 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 usedthrow new Error(...)initially, which is synchronous and not a rejected promise;expect(...).rejects.toThrow(...)requires a rejected promise. Fixed by changing toreturn Promise.reject(new Error(...))— clean and avoids the@typescript-eslint/require-awaitlint rule that would fire on anasyncfunction with noawait. - Step 2 deviation: marking
runGateCheckwith@deprecatedJSDoc triggered@typescript-eslint/no-deprecatedon 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-retromissing-contextlesson applied cleanly: all threeas unknown as PermissionSessionsession mocks were identified at plan time and received delegatingcanConfirm/promptPermissionadapters 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):
toolDescriptor.preCheck = toolCheckpatch-after-construction in the last gate producer — pre-dates this issue, out of scope.const resolver = this.sessionalias types asPermissionSessionrather thanPermissionResolver— explicitly deferred to #325 in the plan’s 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-rolledas unknown as PermissionSessionsession 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 delegatingcanConfirm/promptPermissionadapters 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
GateRunnerDepsalready structurally satisfied all four role interfaces,runGateCheckcollapsed to a one-line wrapper (new GateRunner(deps, deps, deps, deps.reporter).run(...)), letting the handler (step 3) and the 440-linerunner.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 checkran 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)
other(TDD step 1) — thepromptPermissionnull guard was written as a synchronousthrowinside a non-asyncmethod declaredPromise<…>;expect(...).rejects.toThrow(...)cannot catch a synchronous throw. Switched toreturn Promise.reject(new Error(...)), which also sidesteps the@typescript-eslint/require-awaitrule that anasync-with-no-awaitworkaround would trip. Impact: self-caught on the first test run, ~2 tool calls, no rework of committed code.other(TDD step 2) — marking the transitionalrunGateCheckwrapper with@deprecatedJSDoc triggered@typescript-eslint/no-deprecatedon all 19 surviving call sites inrunner.test.tsat 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 noask_userdecision gate was needed and no redirection occurred.
Diagnostic details
- Model-performance correlation — interleaving
model_changewithmessageentries gives the accurate attribution: planning ran onanthropic/claude-opus-4-8, the entire TDD execution (all ~90 turns) onanthropic/claude-sonnet-4-6, and this retro onanthropic/claude-opus-4-8. Theopencode-go/deepseek-v4-flashentry 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 defaultanthropic/claude-sonnet-4-6and 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: readingmodel_changeentries in isolation over-counts models — a change event does not imply a turn ran under that model; attribution requires interleaving withmessageentries (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 checkafter each of the three interface-touching steps; the proactive handler-suite run after the step-3 mock change is the concrete payoff.
Changes made
- Added a
Promise.reject-vs-throwrule to theTest assertionssection of.pi/skills/testing/SKILL.md(a synchronousthrowescapesexpect(...).rejects.toThrow(...); switching toasynctripsrequire-await). - Added a transitional-wrapper
@deprecatedrule to theTDD planning rulessection of.pi/skills/testing/SKILL.md(@typescript-eslint/no-deprecatedfires on every surviving call site). - Clarified the
Model-performance correlationlens in.pi/prompts/retro.mdto require interleavingmodel_changewithmessageentries — amodel_changewith no assistant turn under it never ran. - Corrected this retro's
Model-performance correlationdiagnostic: theopencode-go/deepseek-v4-flashmodel-change event ran zero turns (transient selection overridden by opus); TDD ran entirely onanthropic/claude-sonnet-4-6, planning and this retro onanthropic/claude-opus-4-8.