Files
my-pi/pi-permission-system/docs/plans/0316-fold-build-forwarding-deps.md

199 lines
15 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: 316
issue_title: "Fold PermissionPrompter.buildForwardingDeps() into the injected forwarder"
---
# Fold `PermissionPrompter.buildForwardingDeps()` into the injected forwarder
## Problem Statement
`PermissionPrompter` re-synthesizes its own `PermissionForwardingDeps` bag (`buildForwardingDeps()`) solely to call `confirmPermission()`.
This is the second independent construction of the same dependency set — `index.ts` already assembles one for the `PermissionForwarder` introduced in #315.
The prompter's copy diverges subtly (`shouldAutoApprove: () => false`, a no-op `writeDebugLog`) and drags along a cluster of `eslint-disable @typescript-eslint/unbound-method` lines.
It is a relay bag the prompter builds only to hand to a free function — anemic design with no owner.
This is step 2 of 3 in the forwarding lift-and-shift (#315#316#317).
## 315 has landed: `PermissionForwarder` exists in `src/forwarded-permissions/permission-forwarder.ts` with `requestApproval()` (currently unused by production) and `processInbox()`, plus a narrow `InboxProcessor` seam consumed by `ForwardingManager`
### Goals
- Inject the single `PermissionForwarder` into `PermissionPrompter` through a narrow `ApprovalRequester` interface exposing only `requestApproval`.
- Replace the `confirmPermission(ctx, …, this.buildForwardingDeps(), …)` call with `this.deps.forwarder.requestApproval(ctx, …)`.
- Delete `buildForwardingDeps()`, the second `PermissionForwardingDeps` synthesis, and its `eslint-disable unbound-method` cluster.
- Narrow `PermissionPrompterDeps` by removing the four fields that existed only to feed `buildForwardingDeps()` (`subagentSessionsDir`, `forwardingDir`, `registry`, `requestPermissionDecisionFromUi`).
- Wire `index.ts` to inject the existing single forwarder into the prompter (no second forwarder, no second bag).
- Behavior-preserving: this is a `refactor:`, not a `feat:`.
### Non-Goals
- Inlining the `polling.ts` free-function bodies as methods on `PermissionForwarder` or deleting the `PermissionForwardingDeps` interface — that is #317 (step 3 of 3).
- Changing the forwarding wire protocol, request/response file shapes, or the UI dialog flow.
- Touching `ForwardingManager` or its `InboxProcessor` seam (settled in #315).
- Altering yolo-mode handling — it stays at the prompter level, evaluated before `requestApproval` is reached.
### Background
Relevant modules:
- `src/permission-prompter.ts``PermissionPrompter` class, `PermissionPrompterDeps`, and the private `buildForwardingDeps()` being deleted.
- `src/forwarded-permissions/permission-forwarder.ts``PermissionForwarder` (already implements `InboxProcessor`); `requestApproval(ctx, message, options?, forwarded?)` already exists and delegates to `confirmPermission`.
This is where the new `ApprovalRequester` seam belongs, mirroring the `InboxProcessor` convention established in #315.
- `src/index.ts` — composition root; constructs the prompter (line ~52), then the `forwardingDeps` bag and `forwarder` (lines ~6476), then `PermissionSession`/`ForwardingManager`.
- `src/forwarded-permissions/polling.ts``confirmPermission()` and the `PermissionForwardingDeps` interface (untouched this issue).
- `test/permission-prompter.test.ts` — currently `vi.mock`s `polling` and asserts against `mockConfirmPermission`; must migrate to an injected forwarder mock.
Constraints from AGENTS.md / package skill:
- The package is the sole authority for tool policy; this refactor must not alter any allow/deny/ask decision.
- `@typescript-eslint/require-await` is enabled for `src/`; `requestApproval` already returns the delegated promise, so no `async` churn.
- Markdown is enforced by `rumdl` (`pnpm run lint:md`), not `markdownlint` — the `MDxxx` IDs in conventions are for reference only (per #315 retro).
- The seam type must be a **narrow interface**, never the concrete `PermissionForwarder` — concrete class types leak private fields into the structural checker and force test casts (code-design + design-review guidance, confirmed by #315's `InboxProcessor` win).
### Design Overview
#### The `ApprovalRequester` seam
Define a one-method interface alongside `InboxProcessor` in `permission-forwarder.ts` and add it to the class's `implements` clause:
```typescript
/**
* Narrow seam describing what `PermissionPrompter` needs from the forwarder:
* resolve a permission decision for the current context (prompt directly when
* the session has UI, otherwise forward to the parent).
*/
export interface ApprovalRequester {
requestApproval(
ctx: ExtensionContext,
message: string,
options?: RequestPermissionOptions,
forwarded?: ForwardedPromptDisplay,
): Promise<PermissionPromptDecision>;
}
export class PermissionForwarder implements InboxProcessor, ApprovalRequester {
// unchanged
}
```
`requestApproval` already has exactly this signature, so the class body is unchanged — only the `implements` clause and the new interface declaration are added.
#### Prompter consumption (Tell-Don't-Ask call site)
`PermissionPrompter` depends on the seam, not the concrete forwarder:
```typescript
export interface PermissionPrompterDeps {
getConfig(): PermissionSystemExtensionConfig; // yolo-mode check
writeReviewLog(event: string, details: Record<string, unknown>): void;
events: PermissionEventBus; // permissions:ui_prompt broadcast
forwarder: ApprovalRequester; // resolve the decision (UI or forwarded)
}
```
Inside `prompt()`, the `confirmPermission(...)` call becomes a tell:
```typescript
const decision = await this.deps.forwarder.requestApproval(
ctx,
details.message,
details.sessionLabel ? { sessionLabel: details.sessionLabel } : undefined,
{ source: uiPrompt.source, surface: uiPrompt.surface, value: uiPrompt.value },
);
```
The prompter no longer reaches into a bag — it tells the forwarder.
`PermissionPrompterDeps` drops from 7 fields to 4, and every remaining field is read by `prompt()` directly (passes design-review check 1: every consumer uses every field).
#### Behavioral nuance: debug logging on the prompter's forwarding path
The deleted `buildForwardingDeps()` supplied a **no-op** `writeDebugLog` and `shouldAutoApprove: () => false`.
The shared forwarder (built in `index.ts`) supplies the **real** `runtime.writeDebugLog` and the real yolo policy.
- `shouldAutoApprove``confirmPermission` never calls it (only `processForwardedPermissionRequests` does, on its own deps), so sharing the real policy is inert on this path.
Decision is unchanged.
- `writeDebugLog``confirmPermission`'s helpers (`writeJsonFileAtomic`, `safeDeleteFile`, etc.) pass `deps.logger` through, so the subagent forwarding path will now emit real debug-level log lines instead of swallowing them.
This is the intended convergence: the #315/#316 plan deferred "trace-level forwarding debug" as an open question, and consolidating onto one forwarder resolves it.
The effect is strictly additive debug output on a path that previously logged nothing; no allow/deny/ask decision, review-log entry, or wire message changes.
Flagged in Risks below.
#### Edge cases
- Yolo-mode short-circuit stays ahead of `requestApproval`; the forwarder is never consulted when `yoloMode` is on (existing test coverage preserved).
- `sessionLabel` and the display fields (`source`/`surface`/`value`) are relayed unchanged through the new call — the four positional arguments map 1:1 to the old `confirmPermission` call.
### Module-Level Changes
- `src/forwarded-permissions/permission-forwarder.ts`
- Add `export interface ApprovalRequester { requestApproval(...) }` next to `InboxProcessor`.
- Add `ApprovalRequester` to the `PermissionForwarder` `implements` clause.
- No method-body changes.
- `src/permission-prompter.ts`
- Add `forwarder: ApprovalRequester` to `PermissionPrompterDeps`; remove `subagentSessionsDir`, `forwardingDir`, `registry`, `requestPermissionDecisionFromUi`.
- Replace the `confirmPermission(...)` call in `prompt()` with `this.deps.forwarder.requestApproval(...)`.
- Delete the private `buildForwardingDeps()` method.
- Remove now-unused imports: `confirmPermission` and `PermissionForwardingDeps` from `./forwarded-permissions/polling`, `ForwardedPermissionLogger` from `./forwarded-permissions/io`, `SubagentSessionRegistry`, and `RequestPermissionOptions` if no longer referenced (lint will confirm).
- Add the `ApprovalRequester` type import from `./forwarded-permissions/permission-forwarder`.
- Update the `PermissionPrompterDeps` doc comment ("synthesises the PermissionForwardingDeps it needs internally" is no longer true).
- `src/index.ts`
- Construct `forwardingDeps` + `forwarder` **before** the prompter, then pass `forwarder` into `new PermissionPrompter({ … })`.
- Remove the four dropped fields (`subagentSessionsDir`, `forwardingDir`, `registry`, `requestPermissionDecisionFromUi`) from the prompter's deps literal — TypeScript excess-property checking rejects them once the interface narrows, so this must land in the same commit.
- `forwardingDeps`/`forwarder` remain (still consumed by `ForwardingManager`); no second forwarder.
- `test/permission-prompter.test.ts`
- Remove `vi.mock("../src/forwarded-permissions/polling")` and the hoisted `mockConfirmPermission`.
- Add a hoisted `mockRequestApproval` and inject `forwarder: { requestApproval: mockRequestApproval }` via `makeDeps`.
- Drop `subagentSessionsDir`/`forwardingDir`/`requestPermissionDecisionFromUi` from the `makeDeps` defaults.
- Re-point every `mockConfirmPermission` assertion to `mockRequestApproval`; the argument matchers shift by one position (the deps bag argument is gone, so the matchers become `(ctx, message, options, forwarded)`).
- Reset/seed `mockRequestApproval` in `beforeEach`.
- `packages/pi-permission-system/docs/architecture/permission-prompter.md`
- Update the `PermissionPrompterDeps` interface block (4 fields, add `forwarder: ApprovalRequester`).
- Replace the "Relationship to PermissionForwardingDeps" section: the prompter no longer constructs a bag; it depends on the injected `ApprovalRequester`.
- Refresh the "Wiring" note to show the forwarder injection.
- `packages/pi-permission-system/docs/architecture/architecture.md`
- Mark Phase 3 Step 3 (#316) `✅` with a past-tense outcome and forward reference to #317 (following the #315 status-convention precedent).
- Update the Track-B roadmap row / Mermaid status node if it tracks per-step completion.
### Test Impact Analysis
This is a seam swap, not a new extraction, so the test surface shifts rather than expands.
1. **New tests enabled** — the prompter can now be tested against a trivially injected `{ requestApproval: vi.fn() }` with no module mock.
This removes the `vi.mock("…/polling")` indirection and makes the prompter's collaboration with the forwarder explicit and assertable (design-review check 6: mock depth drops, no casts).
2. **Tests simplified** — all assertions migrate from `mockConfirmPermission` (module mock) to `mockRequestApproval` (injected mock); the deps-bag positional argument disappears, so matchers get simpler.
No test is deleted — each still exercises a distinct prompter behavior (yolo short-circuit, waiting/approved/denied logging, UI-prompt emission, sessionLabel/display-field relay, forwarding path).
3. **Tests that stay as-is**`test/permission-forwarder.test.ts` already covers `requestApproval`'s delegation to `confirmPermission` (the layer being depended upon); it is untouched.
`test/composition-root.test.ts` exercises real wiring and should stay green without edits (verify the forwarder-before-prompter reorder does not perturb it).
### TDD Order
1. **Swap the prompter onto the injected `ApprovalRequester` seam** (`refactor:`)
- Test surface: `test/permission-prompter.test.ts`.
- Red: migrate the suite to inject `forwarder: { requestApproval: mockRequestApproval }`, drop the polling module mock and the four removed deps, and re-point assertions to `mockRequestApproval` with the shifted argument positions.
The suite fails to compile/run until production changes land.
- Green: add `ApprovalRequester` to `permission-forwarder.ts` (+ `implements`), narrow `PermissionPrompterDeps`, replace the `confirmPermission` call with `this.deps.forwarder.requestApproval`, delete `buildForwardingDeps()` and its `eslint-disable` lines and now-unused imports, and update `index.ts` to construct the forwarder before the prompter and inject it (removing the four stale fields from the deps literal).
- This is one atomic commit: narrowing the interface and removing `buildForwardingDeps` break `index.ts` (excess properties) and the test (missing `forwarder`) at the type level simultaneously, so production, wiring, and test migration cannot be separated.
- Suggested message: `refactor: inject forwarder into PermissionPrompter, delete buildForwardingDeps (#316)`
2. **Update architecture docs** (`docs:`)
- Refresh `docs/architecture/permission-prompter.md` (deps interface, forwarder relationship, wiring) and mark Phase 3 Step 3 `✅` in `docs/architecture/architecture.md`.
- Suggested message: `docs: record forwarder injection into PermissionPrompter (#316)`
Run after each step: `pnpm --filter @gotgenes/pi-permission-system run check`, `run lint`, `run test`, then `pnpm fallow dead-code` before handoff.
### Risks and Mitigations
- **Debug-log behavior change on the forwarding path** — the prompter's forwarding path gains real `writeDebugLog` output (was no-op).
Mitigation: intended convergence (resolves the deferred debug open question); strictly additive debug-level output, no decision/log/wire change.
Documented in Design Overview.
- **Argument-position drift in test assertions** — removing the deps-bag positional argument shifts every `toHaveBeenCalledWith` matcher by one.
Mitigation: migrate matchers mechanically and rely on `check`/`test` to catch any stale matcher; assert the exact 4-argument shape (`ctx, message, options, forwarded`).
- **`index.ts` ordering regression** — the forwarder must exist before the prompter literal references it.
Mitigation: reorder construction in the same commit; `composition-root.test.ts` verifies real wiring stays green.
- **Unused-import lint churn** — removing `buildForwardingDeps` orphans several imports.
Mitigation: `run lint` (eslint auto-detects) catches and the implementer prunes them in the same commit.
### Open Questions
- Whether to keep `RequestPermissionOptions` imported in `permission-prompter.ts` depends on whether the inline `{ sessionLabel }` literal still references the type after the swap — defer to the type checker during implementation; prune if unused.
- #317 will dismantle `PermissionForwardingDeps` and inline the `polling.ts` bodies as forwarder methods; nothing in this plan should pre-empt that (keep the delegation intact).