--- issue: 529 issue_title: "pi-permission-system: extract a SubagentDetection collaborator; seed src/authority/" --- # Extract a SubagentDetection collaborator; seed src/authority/ ## Release Recommendation **Release:** ship independently Phase 8 Step 5's roadmap annotation is `Release: independent` ("refactors; auto-batch into the next release"). The work is all `refactor:`/`docs:` commits — hidden changelog types — so it does not cut a release on its own; it lands on `main` and batches into the next `feat:`/`fix:` release. ## Problem Statement The dep triple (`subagentSessionsDir`, `platform`, `registry`) is threaded into three constructors — `PromptingGateway`, `ForwardingManager`, and `PermissionForwarder` — solely so each can call `isSubagentExecutionContext`, which is re-evaluated up to three times per ask. The architecture doc's authority-model target selects one Authorizer per session from three context predicates (`hasUI`, `isSubagent`, yolo); a single owner for the `isSubagent` predicate is a precondition for that selection (Phase 9). This is Phase 8 Step 5: extract a `SubagentDetection` collaborator constructed once in `index.ts`, and seed the declared-but-unseeded `src/authority/` domain directory with it plus the moved `subagent-context.ts`. ## Goals - One construction site for subagent detection: a `SubagentDetection` class built once in `index.ts` with (`subagentSessionsDir`, `platform`, `registry`). - `PromptingGateway`, `ForwardingManager`, and `PermissionForwarder` drop the threaded dep triple and take a narrow `SubagentDetector` seam. - `PermissionServiceLifecycle` drops its raw `SubagentSessionRegistry` field and takes a narrow `RegisteredChildDetector` seam, so **all** subagent-detection predicates have one owner (operator-confirmed scope widening beyond the issue's three constructors). - Move `src/subagent-context.ts` → `src/authority/subagent-context.ts`, seeding `src/authority/`. - Non-breaking: no config, command, or observable-behavior change; all commits are `refactor:`/`docs:`. ## Non-Goals - **The Phase 9 spine.** No `Authorizer` interface, no `canConfirm()` dissolution — this step only produces the single-owner predicate the spine's selection will consume. - **Step 6 ([#530]).** `PermissionForwarder` keeps both roles and its `forwarded-permissions/` location; only its detection deps change here. - **Absorbing the pure functions.** `isSubagentExecutionContext` and `isRegisteredSubagentChild` stay exported pure functions; the class holds the deps and delegates (operator-confirmed). Their 372-LOC test file moves with the module but is not rewritten. - **Moving `subagent-registry.ts` or `permission-forwarding.ts`.** The directory sketch defers those to Phase 9. - **Migrating `forwarding-manager.test.ts` onto the forwarding harness.** Step 4 ([#528]) deliberately left it off; this plan only removes its `vi.mock("../src/subagent-context")` module mock, which the rewire obsoletes. No follow-up issues need filing — Step 6 ([#530]) already exists. ## Background - `src/subagent-context.ts` exports `SubagentDetectionContext` (narrow `{ sessionManager: { getSessionId; getSessionDir } }` context), `normalizeFilesystemPath`, `isRegisteredSubagentChild(ctx, registry)`, and `isSubagentExecutionContext(ctx, subagentSessionsDir, platform, registry?)` (registry → env hints → filesystem fallback, in priority order — the [#296]/[#298] regression class is pinned by `test/subagent-context.test.ts`). - Four `src/` consumers today: - `src/prompting-gateway.ts` — `canConfirm()` = `hasUI ∨ isSubagentExecutionContext(...)` (the Step 3 / [#527] outcome). - `src/forwarding-manager.ts` — `start(ctx)` refuses to poll when the context is a subagent. - `src/forwarded-permissions/permission-forwarder.ts` — `requestApproval` (deny when not a subagent and no UI) and `waitForForwardedApproval` (the `isSubagent` field of target resolution); it **also** uses `registry` directly for `resolvePermissionForwardingTargetSessionId`, so `registry` stays a forwarder dep. - `src/service-lifecycle.ts` — `activate()` calls `isRegisteredSubagentChild(ctx, registry)` (the [#302] child-gated publish); its only use of its `registry` field. - `index.ts` currently threads `paths.subagentSessionsDir` + `hostPlatform` + `subagentRegistry` into the three ask-path constructors, and `subagentRegistry` into `PermissionServiceLifecycle` and `subscribeSubagentLifecycle` (the latter keeps needing the raw registry). - Sibling-module convention: files inside `src/` subdirectories import siblings via `#src/` aliases (eslint-enforced), e.g. `src/forwarded-permissions/permission-forwarder.ts`. - Test tree mirrors `src/` subdirectories (`test/access-intent/`, `test/handlers/`, `test/forwarded-permissions/`), so the moved module's test goes to `test/authority/`. - AGENTS.md / skill constraint: mark the roadmap step complete (heading ✅, Mermaid node ✅, stale metric rows) in this implementation's doc-update commit, not at ship time. ## Design Overview New module `src/authority/subagent-detection.ts`: ```typescript import { isRegisteredSubagentChild, isSubagentExecutionContext, type SubagentDetectionContext, } from "#src/authority/subagent-context"; import type { SubagentSessionRegistry } from "#src/subagent-registry"; /** Narrow seam for the ask-path consumers (ISP: one method). */ export interface SubagentDetector { isSubagent(ctx: SubagentDetectionContext): boolean; } /** Narrow seam for the service-publication guard (#302). */ export interface RegisteredChildDetector { isRegisteredChild(ctx: SubagentDetectionContext): boolean; } export interface SubagentDetectionDeps { subagentSessionsDir: string; platform: NodeJS.Platform; registry?: SubagentSessionRegistry; } export class SubagentDetection implements SubagentDetector, RegisteredChildDetector { constructor(private readonly deps: SubagentDetectionDeps) {} isSubagent(ctx: SubagentDetectionContext): boolean { return isSubagentExecutionContext( ctx, this.deps.subagentSessionsDir, this.deps.platform, this.deps.registry, ); } isRegisteredChild(ctx: SubagentDetectionContext): boolean { return this.deps.registry ? isRegisteredSubagentChild(ctx, this.deps.registry) : false; } } ``` Decision model: - **Delegate, don't absorb** — the pure functions keep the detection logic and their tests; the class owns the deps. This preserves the "pure functions, IO/deps at the edges" convention and avoids rewriting the 372-LOC `subagent-context.test.ts`. - **Two ISP seams** — the ask-path consumers read only `isSubagent`; `PermissionServiceLifecycle` reads only `isRegisteredChild`. Neither seam carries the other's method, so a one-field fake satisfies each consumer's tests without casts. - **`registry` stays optional** on the deps, mirroring the current consumer signatures; `isRegisteredChild` with no registry is `false` (not a registered child) — consistent with `isSubagentExecutionContext`'s registry-optional behavior. `index.ts` always passes the real registry from `getSubagentSessionRegistry()`. - **`SubagentDetectionContext` stays the parameter type** — both `ExtensionContext` and `ForwarderContext` already satisfy it structurally; `isSubagent` reads `getSessionDir` + `getSessionId`, `isRegisteredChild` reads `getSessionId`, so the type carries no unused surface worth splitting. Consumer call sites (Tell-Don't-Ask / LoD verified — one hop, no reach-through): ```typescript // PromptingGateway.canConfirm() if (this.context === null) return false; return this.context.hasUI || this.deps.detection.isSubagent(this.context); // ForwardingManager.start(ctx) if (!ctx.hasUI || this.detection.isSubagent(ctx)) { this.stop(); return; } // PermissionServiceLifecycle.activate(ctx) if (!this.detection.isRegisteredChild(ctx)) { publishPermissionsService(this.service); } ``` Composition root (`index.ts`), constructed once alongside the other collaborators: ```typescript const subagentDetection = new SubagentDetection({ subagentSessionsDir: paths.subagentSessionsDir, platform: hostPlatform, registry: subagentRegistry, }); ``` The raw `subagentRegistry` remains in `index.ts` for `subscribeSubagentLifecycle` and for `PermissionForwarderDeps.registry` (forwarding-target resolution) — those are registry-as-data uses, not detection. Edge cases: - `PermissionForwarder` calls `isSubagent` at two sites (`requestApproval`, `waitForForwardedApproval`); both become `this.detection.isSubagent(ctx)`. Re-evaluation within one ask still happens (twice inside the forwarder) — collapsing that to a per-session selection is exactly Phase 9's job, not this step's. - The moved `src/authority/subagent-context.ts` switches its internal imports to `#src/` aliases (`#src/permission-forwarding`, `#src/subagent-registry`) per the subdirectory convention. ## Module-Level Changes | File | Change | | --------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `src/subagent-context.ts` → `src/authority/subagent-context.ts` | `git mv`; internal imports become `#src/` aliases; exports unchanged | | `src/authority/subagent-detection.ts` | **New** — `SubagentDetector`, `RegisteredChildDetector`, `SubagentDetectionDeps`, `SubagentDetection` | | `src/prompting-gateway.ts` | `PromptingGatewayDeps` drops `subagentSessionsDir`/`platform`/`registry`, gains `detection: SubagentDetector`; `canConfirm()` delegates | | `src/forwarding-manager.ts` | Constructor `(detection: SubagentDetector, forwarder: InboxProcessor)`; drops the triple; `start()` delegates | | `src/forwarded-permissions/permission-forwarder.ts` | `PermissionForwarderDeps` drops `subagentSessionsDir`/`platform`, gains `detection: SubagentDetector`; keeps `registry` (target resolution); two call sites delegate | | `src/service-lifecycle.ts` | Constructor takes `RegisteredChildDetector` instead of `SubagentSessionRegistry`; import of `isRegisteredSubagentChild` removed | | `src/index.ts` | Constructs `SubagentDetection` once; passes it to the four consumers; stops threading the triple | | `test/subagent-context.test.ts` → `test/authority/subagent-context.test.ts` | `git mv`; import path `#src/authority/subagent-context`; content otherwise intact | | `test/authority/subagent-detection.test.ts` | **New** — class-level tests (see TDD Order) | | `test/prompting-gateway.test.ts` | `makeDeps` injects a fake `detection`; env-stub subagent case becomes a fake-detector case | | `test/forwarding-manager.test.ts` | `vi.mock("../src/subagent-context")` module mock **removed**; fake `{ isSubagent: vi.fn() }` injected; constructor-threading test replaced by a delegation assertion | | `test/service-lifecycle.test.ts` | Injects a fake `RegisteredChildDetector` instead of a real/fake registry | | `test/helpers/forwarding-fixtures.ts` | `makeForwarderDeps` drops `subagentSessionsDir`/`platform` defaults, gains `detection` default (`isSubagent` → `false`) | | `test/permission-forwarder.test.ts` | Call sites needing the forwarded path pass `detection` returning `true` instead of relying on env/dir heuristics | | `docs/architecture/architecture.md` | Line-424 path → `src/authority/subagent-context.ts`; module-layout tree gains an `authority/` subtree (`subagent-detection.ts`, `subagent-context.ts`) replacing the old `subagent-context.ts` line; Step 5 heading + Mermaid node `S5` marked ✅ with a `Landed:` bullet (documenting the `isRegisteredChild`/service-lifecycle scope widening); metrics row "Subagent-detection dep-triple constructors" marked ✅ | Checked and unchanged: - `.pi/skills/package-pi-permission-system/SKILL.md` — references `isSubagentExecutionContext()` (still exported) and the `subagent-context.ts` leaf by module name, not path; both stay accurate. - `docs/subagent-integration.md` — names `isSubagentExecutionContext()` only; still accurate. - `src/subagent-registry.ts` — its doc comment names `isSubagentExecutionContext()`; still accurate. - `README.md` — no references to the moved/changed symbols. - `test/composition-root.test.ts` — exercises the real factory end-to-end; no direct `subagent-context` import. ## Test Impact Analysis 1. **New unit tests enabled:** `SubagentDetection` is directly testable — one construction, both predicates, the no-registry fallback — without going through a consumer. The consumers' detection-permutation tests collapse to "delegates to the detector" cases with a one-field fake, removing the last `vi.mock` module mock in `forwarding-manager.test.ts`. 2. **Tests that become redundant:** `prompting-gateway.test.ts`'s env-hint `canConfirm` case duplicates coverage owned by `subagent-context.test.ts`; it is replaced by a fake-detector case (the disjunction itself stays covered). `forwarding-manager.test.ts`'s "passes subagentSessionsDir from the constructor" threading test is obsolete — the constructor no longer carries the triple; replaced by an `isSubagent`-called-with-ctx assertion. 3. **Tests that stay as-is:** `test/authority/subagent-context.test.ts` (moved, content intact) — it pins the registry → env → filesystem priority order and the [#298] sibling-eviction guarantee, which live in the pure functions this class delegates to. `test/composition-root.test.ts` stays untouched and keeps end-to-end coverage of real detection through the factory (including the subagent-registry-sharing round-trip). ## Invariants at risk | Invariant (source) | Pinned by | Risk handling | | --------------------------------------------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | | `canConfirm()` = `hasUI ∨ isSubagent` — no yolo arm (Step 3 / [#527] `Landed:`) | `test/prompting-gateway.test.ts` `canConfirm` suite | Rewire preserves the disjunction; adapted tests assert both operands | | Child-gated service publish (Step outcome of [#302]) | `test/service-lifecycle.test.ts` + `test/composition-root.test.ts` | `activate()` logic unchanged; only the collaborator type narrows | | Registry-first detection priority; sibling disposal cannot evict ([#296], [#298]) | `test/subagent-context.test.ts` | Pure functions untouched; test file moves intact | | Forwarding-harness consolidation (Step 4 / [#528] `Landed:`) | `test/helpers/forwarding-fixtures.ts` consumers | `makeForwarderDeps` updated in place; `forwarding-manager.test.ts` stays off the harness per that plan's Non-Goals | ## TDD Order 1. **Move `subagent-context` into `src/authority/` (mechanical, no behavior change).** `git mv src/subagent-context.ts src/authority/subagent-context.ts` and `git mv test/subagent-context.test.ts test/authority/subagent-context.test.ts`; switch the moved module's internal imports to `#src/` aliases; update the four `src/` importers' paths, the moved test's `#src/authority/subagent-context` import, and `forwarding-manager.test.ts`'s `vi.mock` path. Verify: `pnpm run check` + full suite green. Commit: `refactor(pi-permission-system): move subagent-context into src/authority/ (#529)`. 2. **Red → green: `SubagentDetection`.** New `test/authority/subagent-detection.test.ts`: `isSubagent` true/false via registry, env hint, and filesystem fallback (thin — delegation smoke, not a re-test of the matrix); `isRegisteredChild` true/false; `isRegisteredChild` → `false` when constructed without a registry. Then implement `src/authority/subagent-detection.ts` as sketched. Commit: `refactor(pi-permission-system): add SubagentDetection collaborator (#529)`. 3. **Rewire `PromptingGateway`.** Red: adapt `makeDeps` in `test/prompting-gateway.test.ts` to inject `detection` (fake, default `isSubagent` → `false`); replace the env-hint case with a fake-detector case. Green: `PromptingGatewayDeps` swap + `canConfirm()` delegation; `index.ts` constructs `subagentDetection` once and passes it. Commit: `refactor(pi-permission-system): rewire PromptingGateway onto SubagentDetection (#529)`. 4. **Rewire `ForwardingManager`.** Red: drop the `vi.mock` module mock in `test/forwarding-manager.test.ts`; inject a fake detector; replace the constructor-threading test with a delegation assertion. Green: constructor `(detection, forwarder)`; `index.ts` call site. Commit: `refactor(pi-permission-system): rewire ForwardingManager onto SubagentDetection (#529)`. 5. **Rewire `PermissionForwarder`.** Red: update `makeForwarderDeps` (drop `subagentSessionsDir`/`platform`, add `detection`); adapt any `permission-forwarder.test.ts` case that reaches the forwarded path to pass `detection` returning `true`. Green: `PermissionForwarderDeps` swap, two call-site delegations; `index.ts` `forwardingDeps`. Commit: `refactor(pi-permission-system): rewire PermissionForwarder onto SubagentDetection (#529)`. 6. **Rewire `PermissionServiceLifecycle`.** Red: `test/service-lifecycle.test.ts` injects a fake `RegisteredChildDetector`. Green: constructor swap (`RegisteredChildDetector` for `SubagentSessionRegistry`), drop the `isRegisteredSubagentChild` import; `index.ts` call site. Commit: `refactor(pi-permission-system): rewire service lifecycle onto RegisteredChildDetector (#529)`. 7. **Docs.** `docs/architecture/architecture.md` updates listed in Module-Level Changes (✅ Step 5 heading, Mermaid `S5`, metrics row, `Landed:` bullet, path reference, module tree `authority/` subtree). Verify with `pnpm run lint` (rumdl) and a Mermaid render check per the `mermaid` skill. Commit: `docs(pi-permission-system): mark Phase 8 Step 5 complete (#529)`. Steps 3–6 each fold the consumer change, its test, and the `index.ts` call site into one commit — the type checker forbids splitting them. ## Risks and Mitigations - **Silent behavior drift in a consumer rewire** — each consumer's decision logic is a one-line delegation swap; the adapted per-consumer tests assert the same outcomes (poll/no-poll, confirm/deny, publish/skip), and `test/composition-root.test.ts` plus the forwarding round-trip test cover the real wiring end-to-end. - **`makeForwarderDeps` default flips a test's path** — the fake detector defaults to `isSubagent` → `false`, matching today's default fixture environment (no env hints, non-subagent dirs); tests needing the forwarded path opt in explicitly, which is more legible than the current implicit env/dir coupling. - **Import-path churn misses a consumer** — step 1 is mechanical and verified by `tsc` (`pnpm run check`); the grep inventory above found exactly four `src/` importers, three test files, and one fixture. - **Doc staleness** — the architecture-doc updates are enumerated file-by-file above and land in the implementation's own docs commit per the package skill's roadmap-marker rule. ## Open Questions None — both design forks (detection scope; delegate vs. absorb) were resolved with the operator before planning. [#296]: https://github.com/gotgenes/pi-packages/issues/296 [#298]: https://github.com/gotgenes/pi-packages/issues/298 [#302]: https://github.com/gotgenes/pi-packages/issues/302 [#527]: https://github.com/gotgenes/pi-packages/issues/527 [#528]: https://github.com/gotgenes/pi-packages/issues/528 [#530]: https://github.com/gotgenes/pi-packages/issues/530