25 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 529 | 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
SubagentDetectionclass built once inindex.tswith (subagentSessionsDir,platform,registry). PromptingGateway,ForwardingManager, andPermissionForwarderdrop the threaded dep triple and take a narrowSubagentDetectorseam.PermissionServiceLifecycledrops its rawSubagentSessionRegistryfield and takes a narrowRegisteredChildDetectorseam, 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, seedingsrc/authority/. - Non-breaking: no config, command, or observable-behavior change; all commits are
refactor:/docs:.
Non-Goals
- The Phase 9 spine.
No
Authorizerinterface, nocanConfirm()dissolution — this step only produces the single-owner predicate the spine's selection will consume. - Step 6 (#530).
PermissionForwarderkeeps both roles and itsforwarded-permissions/location; only its detection deps change here. - Absorbing the pure functions.
isSubagentExecutionContextandisRegisteredSubagentChildstay 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.tsorpermission-forwarding.ts. The directory sketch defers those to Phase 9. - Migrating
forwarding-manager.test.tsonto the forwarding harness. Step 4 (#528) deliberately left it off; this plan only removes itsvi.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.tsexportsSubagentDetectionContext(narrow{ sessionManager: { getSessionId; getSessionDir } }context),normalizeFilesystemPath,isRegisteredSubagentChild(ctx, registry), andisSubagentExecutionContext(ctx, subagentSessionsDir, platform, registry?)(registry → env hints → filesystem fallback, in priority order — the #296/#298 regression class is pinned bytest/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) andwaitForForwardedApproval(theisSubagentfield of target resolution); it also usesregistrydirectly forresolvePermissionForwardingTargetSessionId, soregistrystays a forwarder dep.src/service-lifecycle.ts—activate()callsisRegisteredSubagentChild(ctx, registry)(the #302 child-gated publish); its only use of itsregistryfield.
index.tscurrently threadspaths.subagentSessionsDir+hostPlatform+subagentRegistryinto the three ask-path constructors, andsubagentRegistryintoPermissionServiceLifecycleandsubscribeSubagentLifecycle(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 totest/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:
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;PermissionServiceLifecyclereads onlyisRegisteredChild. Neither seam carries the other's method, so a one-field fake satisfies each consumer's tests without casts. registrystays optional on the deps, mirroring the current consumer signatures;isRegisteredChildwith no registry isfalse(not a registered child) — consistent withisSubagentExecutionContext's registry-optional behavior.index.tsalways passes the real registry fromgetSubagentSessionRegistry().SubagentDetectionContextstays the parameter type — bothExtensionContextandForwarderContextalready satisfy it structurally;isSubagentreadsgetSessionDir+getSessionId,isRegisteredChildreadsgetSessionId, so the type carries no unused surface worth splitting.
Consumer call sites (Tell-Don't-Ask / LoD verified — one hop, no reach-through):
// 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:
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:
PermissionForwardercallsisSubagentat two sites (requestApproval,waitForForwardedApproval); both becomethis.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.tsswitches 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— referencesisSubagentExecutionContext()(still exported) and thesubagent-context.tsleaf by module name, not path; both stay accurate.docs/subagent-integration.md— namesisSubagentExecutionContext()only; still accurate.src/subagent-registry.ts— its doc comment namesisSubagentExecutionContext(); still accurate.README.md— no references to the moved/changed symbols.test/composition-root.test.ts— exercises the real factory end-to-end; no directsubagent-contextimport.
Test Impact Analysis
- New unit tests enabled:
SubagentDetectionis 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 lastvi.mockmodule mock inforwarding-manager.test.ts. - Tests that become redundant:
prompting-gateway.test.ts's env-hintcanConfirmcase duplicates coverage owned bysubagent-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 anisSubagent-called-with-ctx assertion. - 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.tsstays 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
- Move
subagent-contextintosrc/authority/(mechanical, no behavior change).git mv src/subagent-context.ts src/authority/subagent-context.tsandgit mv test/subagent-context.test.ts test/authority/subagent-context.test.ts; switch the moved module's internal imports to#src/aliases; update the foursrc/importers' paths, the moved test's#src/authority/subagent-contextimport, andforwarding-manager.test.ts'svi.mockpath. Verify:pnpm run check+ full suite green. Commit:refactor(pi-permission-system): move subagent-context into src/authority/ (#529). - Red → green:
SubagentDetection. Newtest/authority/subagent-detection.test.ts:isSubagenttrue/false via registry, env hint, and filesystem fallback (thin — delegation smoke, not a re-test of the matrix);isRegisteredChildtrue/false;isRegisteredChild→falsewhen constructed without a registry. Then implementsrc/authority/subagent-detection.tsas sketched. Commit:refactor(pi-permission-system): add SubagentDetection collaborator (#529). - Rewire
PromptingGateway. Red: adaptmakeDepsintest/prompting-gateway.test.tsto injectdetection(fake, defaultisSubagent→false); replace the env-hint case with a fake-detector case. Green:PromptingGatewayDepsswap +canConfirm()delegation;index.tsconstructssubagentDetectiononce and passes it. Commit:refactor(pi-permission-system): rewire PromptingGateway onto SubagentDetection (#529). - Rewire
ForwardingManager. Red: drop thevi.mockmodule mock intest/forwarding-manager.test.ts; inject a fake detector; replace the constructor-threading test with a delegation assertion. Green: constructor(detection, forwarder);index.tscall site. Commit:refactor(pi-permission-system): rewire ForwardingManager onto SubagentDetection (#529). - Rewire
PermissionForwarder. Red: updatemakeForwarderDeps(dropsubagentSessionsDir/platform, adddetection); adapt anypermission-forwarder.test.tscase that reaches the forwarded path to passdetectionreturningtrue. Green:PermissionForwarderDepsswap, two call-site delegations;index.tsforwardingDeps. Commit:refactor(pi-permission-system): rewire PermissionForwarder onto SubagentDetection (#529). - Rewire
PermissionServiceLifecycle. Red:test/service-lifecycle.test.tsinjects a fakeRegisteredChildDetector. Green: constructor swap (RegisteredChildDetectorforSubagentSessionRegistry), drop theisRegisteredSubagentChildimport;index.tscall site. Commit:refactor(pi-permission-system): rewire service lifecycle onto RegisteredChildDetector (#529). - Docs.
docs/architecture/architecture.mdupdates listed in Module-Level Changes (✅ Step 5 heading, MermaidS5, metrics row,Landed:bullet, path reference, module treeauthority/subtree). Verify withpnpm run lint(rumdl) and a Mermaid render check per themermaidskill. 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.tsplus the forwarding round-trip test cover the real wiring end-to-end. makeForwarderDepsdefault flips a test's path — the fake detector defaults toisSubagent→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 foursrc/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.