# Phase 5: Tell-Don't-Ask and decoupling sweep Goal: clear the residual state-encapsulation and decoupling smells that Phase 4 left behind — factory closures over mutable state, a composition-root forward-reference cycle, anemic getter/setter pairs a handler orchestrates by hand, Law-of-Demeter reach-throughs, and concrete-class dependencies that force test casts. Phase 4 converted essentially every mutable-state-and-closures bag into a state-owning class, so Phase 5 is deliberately narrow. A targeted sweep for Tell-Don't-Ask violations turned up seven findings, and they were the only genuine state-encapsulation and decoupling work left. The phase does not touch `bash-program.ts` (pure AST parsing — splitting it produces free-function modules, not state-owning behavior) or reframe `Ruleset` (that would be a value object, and it would fight the intentional pure-evaluation design principle). `fallow` reports a clean syntactic surface (health 76, 0% dead files, 0% reported dead exports, avg cyclomatic 1.4, no refactoring targets), which is exactly why these findings matter: they are structural smells `fallow` cannot see — a mutable Set hidden in a closure, a `null`-init cast papering over a construction cycle, an anemic accessor quartet a handler drives via ask-then-tell, a relay-only field reached through, and concrete-class constructor types that force `as unknown as` casts in tests. ## Findings summary | Metric | Phase 5 baseline | Phase 5 target | | ------------------------------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- | | Health score | 76 (B) | ≥ 76 (structural, not score-driven) | | Production `as unknown as` casts | 3 (`index.ts` ×1, `config-store.ts` ×2 serialization) | 2 (serialization only) | | Factory closures over mutable state | 1 (`createSessionLogger`) | 0 | | Forward-reference `null`-init holders in `index.ts` | 2 (`configStore`, `sessionNotify`) | 0 | | Anemic cache accessors on `PermissionSession` | 4 methods over 2 fields | 0 (2 owned `CacheKeyGate` sub-objects) | | Ask-then-tell pairs in `AgentPrepHandler` | 2 | 0 | | Test-only-alive exports | 1 (`shouldApplyCachedAgentStartState`) | 0 | | `PermissionSession` constructor arity | 7 positional args | 6 (relay-only `logger` dropped) | | `session.logger` / `session.getRuntimeContext()?.ui` reach-throughs | 5 (1 notify sink, 3 lifecycle logger, 1 reporter wiring) | 0 | | `config-modal` controller reach-throughs | 1 (`permissionManager` + `session.lastKnownActiveAgentName`) | 0 | | `LocalPermissionsService` concrete-class deps | 3 | 0 (narrow interfaces) | | Test `as unknown as` casts removed | — | −8 (3 service + 5 forwarder ctx) → −8 more (8 `ExtensionContext` ctx; #367) = −16 total; 4 remain | Unchanged guardrails: 0% dead code, avg cyclomatic 1.4, maintainability 91.1, no new public surface. ## Steps The seven steps are filed as [#362]–[#368]. Each is a behavior-preserving refactor that leaves the suite green; the success metric is the table above moving toward zero, observed as fewer production casts, dropped forward-reference holders, and fewer forced test casts. ### Track A — logger state + PermissionSession/composition-root coupling (serial) The composition-root forward-reference cycle existed *because* the logger needed late-bound config-reading and UI-notify capability, and the `logger` field on `PermissionSession` was relayed straight back out — so these three landed in order: make the logger a state-owning class, dissolve the cycle, then drop the relay-only field. 1. **Convert `createSessionLogger` into a `SessionLogger` class** ([#362]) ✓ complete - Target: `src/session-logger.ts` — the `createSessionLogger` factory that returned an object literal closing over a mutable `reported: Set` (IO-failure-warning dedup) and the writer. - Smell: Category C (mutable closure state) — a bag of state + closures masquerading as a factory. - Outcome: a `SessionLogger` class that privately owns `reported` and the writer and exposes `debug` / `review` / `warn`; constructed as `new SessionLogger(deps)`; no factory-closure mutable state remains. 2. **Add `PermissionSession.notify()` and dissolve the `index.ts` forward-reference cycle** ([#363]) ✓ complete - Target: `src/permission-session.ts` (new `notify(message)` Tell-Don't-Ask method over the owned context); `src/index.ts` (removed `let configStore = null as unknown as ConfigStore` and the `let sessionNotify` holder, wiring the logger's notify sink as `(m) => session.notify(m)`). - Smell: Category C (forward references + the only production `as unknown as` cast + the `getRuntimeContext()?.ui.notify` Law-of-Demeter reach-through). - Outcome: production `as unknown as` casts 3 → 2; `index.ts` has no `null`-init holders; the UI-notify reach-through became a single tell to the context-owning session. - Depended on Step 1 (the logger reshape that lets construction order resolve without the cast). 3. **Inject `logger` directly into the lifecycle handler and reporter; drop the relay-only field** ([#364]) ✓ complete - Target: `src/permission-session.ts` (removed the `readonly logger` constructor parameter — never read internally, only relayed — taking the constructor from 7 args to 6); `src/handlers/lifecycle.ts` (accept a `SessionLogger` and call `this.logger.warn/debug` instead of `this.session.logger`); `src/index.ts` (pass the composition-root `logger` to `new GateDecisionReporter(logger, …)` and `new SessionLifecycleHandler(session, resolver, serviceLifecycle, logger)`). - Smell: Category C (relay-only dependency / Law-of-Demeter reach-through — the handler talked to `session.logger`, a stranger reached through the session). - Outcome: `PermissionSession` no longer exposes `logger`; the three lifecycle reach-throughs and the one reporter-wiring reach-through are gone; the constructor narrowed to 6 args. - Depended on Step 2 (shares edits to `permission-session.ts` and `index.ts`; serialized to avoid conflicts). ### Track B — anemic cache-key state (independent) 4. **Encapsulate agent-start cache keys in a `CacheKeyGate` class** ([#365]) ✓ complete - Target: `src/permission-session.ts` (replaced the four anemic methods — `shouldUpdateActiveTools` / `commitActiveToolsCacheKey` / `shouldUpdatePromptState` / `commitPromptStateCacheKey` — and their two `string | null` fields with two `CacheKeyGate` instances); `src/handlers/before-agent-start.ts` (collapsed the two ask-then-tell pairs into `gate.runIfChanged(key, effect)`); `src/before-agent-start-cache.ts` (removed the dead-in-production `shouldApplyCachedAgentStartState` and folded its comparison into `CacheKeyGate`). - Smell: Category C (anemic domain / ask-then-tell — the handler asked "should I update?" then told "commit") plus Category A (a redundant export kept alive only by its own test, which is why `fallow`'s 0%-dead-exports missed it). - Outcome: a `CacheKeyGate` class owning a previous key and exposing `runIfChanged(nextKey, effect)`; `PermissionSession`'s four cache methods became two owned sub-objects; the handler's ask-then-tell pairs became single tells; one source of truth for the key comparison; the test-only-alive free function is gone. ### Track C — narrow-interface decoupling for testability (independent) 5. **Narrow `LocalPermissionsService` collaborators to interfaces** ([#366]) ✓ complete - Target: `src/permissions-service.ts` — the constructor typed the concrete `PermissionManager`, `SessionRules`, and `ToolInputFormatterRegistry` but only called `checkPermission` / `getToolPermission`, `getRuleset`, and `register`. - Smell: Category C (DIP — depending on concrete classes) / Category D (testability — concrete-class types expose private members, so `permissions-service.test.ts` was forced into `as unknown as` casts). - Outcome: depends on the existing `ScopedPermissionManager`, `Pick`, and a `{ register }` formatter interface; the three `as unknown as` casts in `permissions-service.test.ts` disappeared and mocks became plain objects. 6. **Narrow `PermissionForwarder`'s context dependency to a local interface** ([#367]) ✓ complete - Target: `src/forwarded-permissions/permission-forwarder.ts` — methods took the full SDK `ExtensionContext` rather than a narrow local interface of the fields actually read. - Smell: Category C (platform-type threading) / Category D (testability). - Outcome: the five `as unknown as ExtensionContext` casts in `permission-forwarder.test.ts` (the single biggest cluster of the 12 such casts across 7 test files) disappeared; a bounded down-payment on the systemic ctx-threading pattern. ### Track D — slash-command reach-through (independent) 7. **Remove the `config-modal` controller reach-through** ([#368]) ✓ complete - Target: `src/config-modal.ts` — the `show` handler chained `controller.permissionManager.getComposedConfigRules(controller.session.lastKnownActiveAgentName ?? undefined)`, reaching through the controller bag to two strangers. - Smell: Category C (Law-of-Demeter reach-through). - Outcome: collapsed the controller's `permissionManager` + `session` fields into a single `getActiveAgentConfigRules()` accessor wired in the composition root, so the command tells one collaborator; the `PermissionSession.lastKnownActiveAgentName` getter is no longer consumed via object-literal wiring (retiring the `fallow` false-positive suppression). ## Step dependency diagram ```mermaid flowchart TD S1["Step 1: SessionLogger class (#362)"] S2["Step 2: PermissionSession.notify + dissolve index.ts cycle (#363)"] S3["Step 3: inject logger; drop relay-only field (#364)"] S4["Step 4: CacheKeyGate for agent-start cache keys (#365)"] S5["Step 5: narrow LocalPermissionsService collaborators (#366)"] S6["Step 6: narrow PermissionForwarder context (#367)"] S7["Step 7: remove config-modal reach-through (#368)"] S1 --> S2 --> S3 subgraph TrackA["Track A — logger state + composition-root coupling (serial)"] S1 S2 S3 end subgraph TrackB["Track B — anemic cache-key state"] S4 end subgraph TrackC["Track C — narrow-interface decoupling"] S5 S6 end subgraph TrackD["Track D — slash-command reach-through"] S7 end ``` ## Tracks | Track | Steps | Description | | ---------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | A: logger state + composition-root coupling (serial) | 1 → 2 → 3 | Make the logger a state-owning class, dissolve the `index.ts` forward-reference cycle, then drop the relay-only `logger` field from `PermissionSession` | | B: anemic cache-key state | 4 | Replace the four anemic cache accessors and two fields with two owned `CacheKeyGate` sub-objects, collapsing the handler's ask-then-tell pairs into single tells | | C: narrow-interface decoupling | 5, 6 | Narrow `LocalPermissionsService` and `PermissionForwarder` to local interfaces so forced `as unknown as` test casts disappear (independent of each other and the others) | | D: slash-command reach-through | 7 | Collapse the `config-modal` controller's two reached-through fields into a single `getActiveAgentConfigRules()` accessor | [#362]: https://github.com/gotgenes/pi-packages/issues/362 [#363]: https://github.com/gotgenes/pi-packages/issues/363 [#364]: https://github.com/gotgenes/pi-packages/issues/364 [#365]: https://github.com/gotgenes/pi-packages/issues/365 [#366]: https://github.com/gotgenes/pi-packages/issues/366 [#367]: https://github.com/gotgenes/pi-packages/issues/367 [#368]: https://github.com/gotgenes/pi-packages/issues/368