--- issue: 365 issue_title: "Encapsulate agent-start cache keys in a `CacheKeyGate` class" --- # Encapsulate agent-start cache keys in a `CacheKeyGate` class ## Problem Statement `PermissionSession` exposes four anemic methods over two private `string | null` fields (`shouldUpdateActiveTools` / `commitActiveToolsCacheKey` / `shouldUpdatePromptState` / `commitPromptStateCacheKey`). `AgentPrepHandler` drives each pair via ask-then-tell: it asks "should I update?", performs the effect, then tells "commit the key". The same `prev !== next` comparison lives in three places — the session's inline `!==`, the handler's orchestration, and the free function `shouldApplyCachedAgentStartState` in `before-agent-start-cache.ts`, which has no production caller and is kept alive only by its own test (so `fallow`'s 0%-dead-exports check misses it). This is Phase 5, Track B, Step 4 of the `pi-permission-system` improvement roadmap (`docs/architecture/architecture.md`). The goal is to fold the comparison into a single cohesive `CacheKeyGate` class that owns a previous key and exposes one Tell — `runIfChanged(nextKey, effect)`. ## Goals - Introduce a `CacheKeyGate` class owning a previous key and exposing `runIfChanged(nextKey, effect)` plus `reset()`. - Replace `PermissionSession`'s four cache methods and two `string | null` fields with two `CacheKeyGate` sub-objects exposed as `readonly` properties. - Collapse `AgentPrepHandler`'s two ask-then-tell pairs into single `gate.runIfChanged(key, effect)` tells. - Remove the dead-in-production `shouldApplyCachedAgentStartState` (and the test assertions that keep it alive), folding its comparison into `CacheKeyGate`. - Keep `fallow`'s dead-export and dead-file metrics at 0%. This change is not breaking: it is an internal encapsulation refactor with no change to config, schema, the `/permission-system` command, or observable agent-facing behavior in normal operation. ## Non-Goals - Track A (`#362`–`#364`, logger state + composition-root coupling) — closed and shipped; no merge coordination is needed. - Track C (`#366`, `#367`) and Track D (`#368`) — independent steps, out of scope. - Changing the cache-key *content* — `createActiveToolsCacheKey` and `createBeforeAgentStartPromptStateKey` (the key builders in `before-agent-start-cache.ts`) are unchanged; only the comparison helper is removed. - Changing `before-agent-start.ts`'s tool-filtering loop or skill-prompt sanitization logic. ## Background Relevant modules: - `src/permission-session.ts` — `PermissionSession` owns mutable session state. Lines 41–42 declare `toolsCacheKey` / `promptCacheKey`; lines 122–135 declare the four anemic methods; `resetForNewSession`, `shutdown`, and `reload` each set both fields to `null`. - `src/handlers/before-agent-start.ts` — `AgentPrepHandler.handle` builds the two cache keys, then runs the two ask-then-tell pairs (lines 76–95). - `src/before-agent-start-cache.ts` — the key builders plus the dead `shouldApplyCachedAgentStartState`. Current consumers of the four session methods (verified by grep): only `src/handlers/before-agent-start.ts`, `test/permission-session.test.ts`, and `test/handlers/before-agent-start.test.ts`. Current consumers of `shouldApplyCachedAgentStartState`: only `test/before-agent-start-cache.test.ts`. No `.pi/skills/package-*/SKILL.md` references any of these symbols. Constraints from AGENTS.md / package skill: - A new exported class warrants its own unit test (`code-design`: extract helpers into a module with its own public API once they warrant tests). - `@typescript-eslint/require-await` is enabled for `src/`; `handle` keeps its existing `// eslint-disable-next-line @typescript-eslint/require-await` since it stays `async` with no `await`. - When a roadmap step ships, mark it complete in `docs/architecture/architecture.md` as part of the shipping change. ## Design Overview ### `CacheKeyGate` A standalone, dependency-free class in a new module `src/cache-key-gate.ts`: ```typescript export class CacheKeyGate { private previousKey: string | null = null; runIfChanged(nextKey: string, effect: () => T): T | undefined { if (this.previousKey === nextKey) { return undefined; } const result = effect(); this.previousKey = nextKey; return result; } reset(): void { this.previousKey = null; } } ``` Semantics: - On a changed key: runs `effect`, commits `nextKey`, returns the effect's value. - On an unchanged key: skips `effect`, returns `undefined`. - `reset()` re-arms the gate so the next key is treated as changed (used by the session lifecycle). Commit ordering is run-then-commit: the key is committed only after the effect returns. This unifies the two paths (the tools path already committed after `setActive`; the prompt path previously committed before the sanitization work). The only observable difference is on the pathological path where the effect throws — the key is then left uncommitted and the next `before_agent_start` event retries, which is strictly safer (no poisoned cache). In normal (non-throwing) operation the behavior is identical. ### `PermissionSession` Replace the two fields and four methods with two exposed gates: ```typescript readonly activeToolsGate = new CacheKeyGate(); readonly promptStateGate = new CacheKeyGate(); ``` The three lifecycle methods (`resetForNewSession`, `shutdown`, `reload`) replace `this.toolsCacheKey = null; this.promptCacheKey = null;` with `this.activeToolsGate.reset(); this.promptStateGate.reset();`. The gates are exposed as `readonly` properties (per the resolved design decision) rather than wrapped in delegating methods. The gate is a cohesive behavior object the session owns; the handler sends it a single Tell (`runIfChanged`), so this is Tell-Don't-Ask at the gate boundary, not a reach-through to a stranger. This hits the roadmap's "0 anemic cache accessors / 2 owned `CacheKeyGate` sub-objects" target. ### `AgentPrepHandler` call sites The two ask-then-tell pairs become single tells: ```typescript this.session.activeToolsGate.runIfChanged(activeToolsCacheKey, () => { this.toolRegistry.setActive(allowedTools); }); const promptResult = this.session.promptStateGate.runIfChanged( promptStateCacheKey, () => { const toolPromptResult = sanitizeAvailableToolsSection( event.systemPrompt, allowedTools, ); const skillPromptResult = resolveSkillPromptEntries( toolPromptResult.prompt, this.resolver, agentName, ctx.cwd, ); this.session.setActiveSkillEntries(skillPromptResult.entries); return skillPromptResult.prompt !== event.systemPrompt ? { systemPrompt: skillPromptResult.prompt } : {}; }, ); return promptResult ?? {}; ``` The effect's return type `T` is `BeforeAgentStartEventResult`; `runIfChanged` returns `BeforeAgentStartEventResult | undefined`, and `?? {}` reproduces the old early-return-`{}` behavior when the prompt cache is unchanged. ### Edge cases preserved - Unchanged prompt key: effect skipped, skill entries untouched, returns `{}` — same as the old early return. - Changed prompt key whose sanitized prompt equals the original: effect runs (commits key, sets skill entries), returns `{}` — same as before. - `setActive` is still invoked at most once per distinct allowed-tools set across repeated events. ## Module-Level Changes - `src/cache-key-gate.ts` — new file; exports `CacheKeyGate`. - `src/permission-session.ts` — remove `toolsCacheKey` / `promptCacheKey` fields and the four methods (`shouldUpdateActiveTools`, `commitActiveToolsCacheKey`, `shouldUpdatePromptState`, `commitPromptStateCacheKey`); add `readonly activeToolsGate` / `readonly promptStateGate`; update the three reset sites; import `CacheKeyGate` from `#src/cache-key-gate`. - `src/handlers/before-agent-start.ts` — replace the two ask-then-tell blocks with `runIfChanged` tells. - `src/before-agent-start-cache.ts` — remove `shouldApplyCachedAgentStartState`; keep both key builders. - `test/cache-key-gate.test.ts` — new unit test for `CacheKeyGate`. - `test/permission-session.test.ts` — remove the `cache key methods` describe block (now covered by `CacheKeyGate`'s test); rewrite the `resetForNewSession` / `shutdown` / `reload` "clears cache keys" assertions to drive the gates via `runIfChanged`. - `test/handlers/before-agent-start.test.ts` — rewrite the four tests that spy on `commit*` / mock `shouldUpdate*` to drive real gate behavior (e.g. call `handle` twice with identical inputs and assert `setActive` runs once / the second result is `{}`). - `test/before-agent-start-cache.test.ts` — remove the `shouldApplyCachedAgentStartState` import and the dedupe test; in the permission-change test, replace the `shouldApplyCachedAgentStartState(baselineKey, invalidatedKey)` assertion with a direct key comparison (`expect(invalidatedKey).not.toBe(baselineKey)`). - `docs/architecture/architecture.md` — append `✓ complete` to the Step 4 line (Phase 5, Track B) as part of shipping. No `docs/architecture/` complexity tables, layout listings, or domain diagrams reference the removed symbols beyond the roadmap step line itself. ## Test Impact Analysis 1. New tests enabled by the extraction. `CacheKeyGate` gets a focused unit test for behavior that was previously only reachable through the session's anemic methods or the free function: `runIfChanged` runs and returns on a first/changed key, skips and returns `undefined` on an unchanged key, and `reset()` re-arms the gate. 2. Tests that become redundant. The `cache key methods` describe block in `permission-session.test.ts` (five tests exercising `shouldUpdate*` / `commit*` directly) is superseded by the `CacheKeyGate` unit test — remove it. The `dedupes unchanged active-tool exposure and prompt state` test in `before-agent-start-cache.test.ts` exercises only `shouldApplyCachedAgentStartState` — remove it. 3. Tests that must stay (rewritten, not deleted). The `resetForNewSession` / `shutdown` / `reload` "clears cache keys" tests genuinely exercise session-lifecycle re-arming of the gates — keep them, asserting via the exposed gates. The handler behavior tests (`setActive` called/skipped, returns `{}` vs `{ systemPrompt }`) genuinely exercise the handler's dedupe orchestration — keep them, driving real gate state instead of mocking the removed methods. The permission-change test in `before-agent-start-cache.test.ts` genuinely exercises key invalidation on a policy-stamp change — keep it, asserting key inequality directly. ## TDD Order 1. Add `CacheKeyGate` (new module + unit test). Surface: `test/cache-key-gate.test.ts` against `src/cache-key-gate.ts`. Covers: runs `effect` and returns its value on a new/changed key; skips `effect` and returns `undefined` on an unchanged key; `reset()` re-arms so the same key runs again. Commit: `feat: add CacheKeyGate for agent-start cache keys (#365)`. 2. Migrate `PermissionSession` + `AgentPrepHandler` + their tests to `CacheKeyGate`. Surface: `src/permission-session.ts`, `src/handlers/before-agent-start.ts`, `test/permission-session.test.ts`, `test/handlers/before-agent-start.test.ts`. This is one step: removing the four methods breaks the handler and both test files at the type/behavior level simultaneously, so the extraction, all consumer updates, and all consumer-test updates land together. Remove the two fields + four methods; add the two `readonly` gates; update the three reset sites; rewrite the handler call sites; rewrite the affected tests per the Module-Level Changes list. Run `pnpm run check` immediately after this commit (shared-surface change). Commit: `refactor: encapsulate agent-start cache keys in CacheKeyGate (#365)`. 3. Remove the dead-in-production `shouldApplyCachedAgentStartState`. Surface: `src/before-agent-start-cache.ts`, `test/before-agent-start-cache.test.ts`. Remove the function and its test references (drop the dedupe test; convert the permission-change assertion to a direct key comparison). Verify with `pnpm fallow dead-code` that the export count stays at 0% dead. Commit: `refactor: remove test-only shouldApplyCachedAgentStartState (#365)`. 4. Mark the roadmap step complete. Surface: `docs/architecture/architecture.md`. Append `✓ complete` to the Phase 5 Track B Step 4 line. Commit: `docs: mark Phase 5 Step 4 complete (#365)`. ## Risks and Mitigations - Risk: the prompt path's commit ordering shifts from commit-then-run to run-then-commit. Mitigation: observable only when the effect throws (then the key is retried — strictly safer); normal operation is identical. No existing test asserts the throw-path ordering. - Risk: handler tests currently mock the removed methods (`vi.spyOn(session, "shouldUpdatePromptState")`). Mitigation: rewrite them to drive real gate state via repeated `handle` calls; the handler tests already use a real session (`makeRealSession`), so the real gates are present. - Risk: removing an export could leave a dangling reference. Mitigation: grep confirmed the four methods and `shouldApplyCachedAgentStartState` are referenced only in the files listed above (no SKILL.md, no composition-root test); step 3 runs `fallow` to confirm 0% dead code. ## Open Questions - Track A steps (`#362`–`#364`) shipped but were not individually marked `✓ complete` in `docs/architecture/architecture.md`. Step 4 of this plan marks Step 4 complete per the package-skill convention; whether to back-fill the Track A markers is out of scope here.