Files
my-pi/pi-permission-system/docs/architecture/history/phase-3-collaborator-encapsulation.md
T

34 KiB
Raw Blame History

Phase 3: State-owning collaborators

Goal: convert the package's remaining bags-of-state-and-closures into class-based collaborators that own their state and expose behavior (Tell-Don't-Ask), then clear the one outstanding fallow cohesion target and the test-tree duplication.

Phases 1 and 2 already gave the core domain good collaborators - PermissionSession, ForwardingManager, SessionRules, SessionApproval, BashProgram, PermissionManager. Phase 3 finishes that arc where it stalled: the forwarding subsystem got a lifecycle class (ForwardingManager) but its behavior still lives as free functions reaching into a PermissionForwardingDeps bag that is assembled in two places. The lens for this phase is not "extract a function" but "which stateful owner is missing, such that a caller reaches into a bag instead of telling an object?".

Phase 3 is independent of any open feature issue - it is a pure debt-reduction round.

Current health metrics

Metric Value
Health score 75 B
LOC 35,515
Dead files / exports 0%
Avg cyclomatic 1.4
p90 cyclomatic 2
Maintainability 91.3 (good)
Duplication 7.6% (2,700 lines, all in test/)
Churn hotspots 41 files
Refactoring targets 0
Dominant churn hotspot index.ts 45.5 (accelerating) - 4× the next file

Measurement note: bash-token-classification.ts reports the highest src CRAP (37.1, one function above threshold), but this is an artifact - rejectNonPathToken is a private helper, so fallow estimates 0% coverage and inflates its CRAP even though the module carries 43 dedicated unit tests. It is not a real finding and gets no step.

Findings

The headline findings are coupling smells (Category C) - anemic behavior, mutable closure state, and relay-only dependency bags - that fallow's complexity metrics under-weight but the composition-root and forwarding code make obvious.

# Finding Category Files Impact Risk Priority
1 Anemic forwarding subsystem: the forwarding lifecycle has a class (ForwardingManager) but its behavior is three free functions (confirmPermission, waitForForwardedPermissionApproval 132 lines, processForwardedPermissionRequests 144 lines) that reach into a PermissionForwardingDeps bag (8 members). The bag is assembled in index.ts and re-synthesized in PermissionPrompter.buildForwardingDeps() with divergent values and a cluster of eslint-disable unbound-method lines C: anemic / mutable closure state / relay-only deps forwarded-permissions/polling.ts, permission-prompter.ts, index.ts 5 3 15
2 Resolved (#314) - tool-input-preview.ts was a flat bag of 8 functions mixing prompt formatting (format{Edit,Write,Read}InputForPrompt, getPromptPath), text utilities (truncateInlineText, countTextLines, formatCount), and serialization (serializeToolInputPreview) - density 0.33, 6 dependents; fallow's only refactoring target. Prompt formatters split into tool-input-prompt-formatters.ts; tool-input-preview.ts is no longer a refactoring target. B: oversized / E: cohesion tool-input-preview.ts 4 2 16
3 7.6% duplication, entirely in the test tree, is the largest single health deduction; the biggest clone families are external-directory-integration.test.ts (17 groups, 164 lines), bash-path.test.ts (9 groups, 120 lines), runner.test.ts (9 groups, 105 lines), and tool-call.test.ts (6 groups, 108 lines) D: test duplication test/ (clone families) 3 1 15
4 Resolved (#318) - createMcpPermissionTargets accumulated candidates through a pushTarget closure that mutated a local array and deduped via includes - every push site asked the array what it already held, then acted (Tell-Don't-Ask); mutable closure state with no owner. Replaced by the McpTargetList value object: add owns the null guard + dedup, toArray returns the ordered result; the per-mode branches now tell the list. C: mutable closure state mcp-targets.ts 3 2 12
5 Resolved (#320) — piPermissionSystemExtension was a 206-line composition root (149 at original analysis, grown since). The two genuinely anemic constructs — the inline permissionsService literal and the activateServiceForSession + teardown closures — were promoted to LocalPermissionsService and PermissionServiceLifecycle (ServiceLifecycle interface). The established injection-bag construction (PermissionSessionRuntimeDeps, PermissionPrompterDeps, etc.) is legitimate wiring kept inline per the anti-procedure-splitting rule. index.ts now ~170 lines; the "< 100 lines" target was explicitly deferred as procedure-splitting. C: adapter closure density / E: wiring overhead index.ts 4 3 12
6 handleToolCall hand-assembled a 7-member GateRunnerDeps closure bag. Investigation (#319) found it was really a relay (checkPermission + getSessionRuleset) plus four genuine roles (resolve, record, prompt, report). Decomposed into the relay collapse (PermissionResolver, #319 ), a DecisionReporter (#322 ), a GateRunner class injected with role collaborators (#323 ), and PermissionGateHandler role-interface retyping (#325); planning #325 surfaced two preparatory refactors that shrink the handler first — unifying handleInput with the runner (#326) and extracting a ToolCallGatePipeline (#327) C: relay-only dependencies handlers/permission-gate-handler.ts, handlers/gates/descriptor.ts 3 3 9

Steps

  1. Split tool-input-preview.ts into cohesive modules (#314)

    • Target: src/tool-input-preview.ts (the sole fallow refactoring target).
    • Extracted the three prompt formatters plus getPromptPath into a new src/tool-input-prompt-formatters.ts; left the text utilities (truncateInlineText, countTextLines, formatCount), serializeToolInputPreview, and the three limit constants in tool-input-preview.ts.
    • Repointed the sole production consumer (tool-preview-formatter.ts) and relocated the moved functions' unit coverage into test/tool-input-prompt-formatters.test.ts; all four new exports are consumed, so fallow flags no dead re-export.
    • Smell category: B (oversized) / E (cohesion).
    • Outcome: tool-input-preview.ts dropped off the refactoring-target list; refactoring targets 1 → 0 (confirmed by fallow health --targets).
  2. Introduce a PermissionForwarder collaborator (own the state) (#315)

    • Target: new src/forwarded-permissions/permission-forwarder.ts; forwarding-manager.ts; index.ts.
    • Added a PermissionForwarder class exposing requestApproval(ctx, message, options?, forwarded?) and processInbox(ctx); for this lift-and-shift step it holds the PermissionForwardingDeps bag privately (shouldAutoApprove supplied once at construction) and delegates to the existing polling.ts free functions, so behavior is unchanged.
    • Wired ForwardingManager to a narrow InboxProcessor seam (the manager only calls processInbox, mirroring the existing ForwardingController convention and dropping the test's as unknown as cast); constructed the single forwarder in index.ts and injected it.
    • Smell category: C (anemic domain model - give the forwarding behavior an owner).
    • Outcome: one forwarder instance replaces the threaded index.ts forwarding bag; ForwardingManager tells the forwarder instead of threading a deps bag. The bag interface itself is dismantled in #317.
  3. Fold PermissionPrompter.buildForwardingDeps() into the injected forwarder (#316)

    • Target: src/permission-prompter.ts; src/forwarded-permissions/permission-forwarder.ts; index.ts.
    • Added the ApprovalRequester narrow seam (alongside InboxProcessor) to permission-forwarder.ts; narrowed PermissionPrompterDeps from 7 fields to 4 (removing subagentSessionsDir, forwardingDir, registry, requestPermissionDecisionFromUi); replaced the confirmPermission(..., this.buildForwardingDeps(), ...) call with this.deps.forwarder.requestApproval(...) and deleted buildForwardingDeps() and its eslint-disable unbound-method cluster; reordered index.ts to construct the single forwarder before the prompter and inject it.
    • Smell category: C (relay-only deps / duplicated bag construction).
    • Outcome: the forwarding dependency set is constructed exactly once; the prompter depends on a one-method interface instead of re-deriving a bag; PermissionForwardingDeps bag is dismantled in #317.
  4. Remove PermissionForwardingDeps; inline the polling logic as forwarder methods (#317)

    • Target: src/forwarded-permissions/polling.tspermission-forwarder.ts (sequence after Steps 2-3).
    • Added PermissionForwarderDeps (replaces PermissionForwardingDeps); dissolved the bag into individual private readonly fields on PermissionForwarder; inlined waitForForwardedPermissionApproval and processForwardedPermissionRequests as private methods reading this; extracted buildForwardedRequest (returns a value object), pollForForwardedResponse (owns the deadline loop + file cleanup), and processSingleForwardedRequest (per-request workflow) as focused private helpers; moved getSessionId, getContextSystemPrompt, formatForwardedPermissionPrompt to module-private functions (no external callers); deleted polling.ts; updated index.ts to import PermissionForwarderDeps from permission-forwarder; rewrote permission-forwarder.test.ts with real behavior tests (migrated from permission-forwarding.test.ts); removed stale vi.mock for polling from runtime.test.ts.
    • Smell category: C + B (the two god functions decompose as a consequence of the state having an owner).
    • Outcome: the 144-line and 132-line free functions became focused methods; PermissionForwardingDeps is gone; the forwarding subsystem is fully class-based (Track B complete).
  5. Introduce an McpTargetList value object (#318)

    • Target: src/mcp-targets.ts.
    • Added an exported McpTargetList class: add(value) owns the null/empty guard and the includes dedup (first-insertion wins); toArray() returns an independent ordered copy. Rewrote createMcpPermissionTargets, pushMcpToolPermissionTargets, and addDerivedMcpServerTargets to construct an McpTargetList and call targets.add(...) - the per-mode branches tell the list instead of asking the array. McpTargetList is exported and covered by direct unit tests (invariant: ignores null/empty, dedups, preserves order, toArray returns an independent copy).
    • Smell category: C (mutable closure state → value object that owns its invariant).
    • Outcome: the pushTarget closure and the includes-ask are gone; the uniqueness invariant lives in one owner; the per-mode dispatch reads as a sequence of tells; 6 new focused unit tests document the invariant in isolation (Track C complete for the accumulator).
  6. Introduce PermissionResolver; remove the session-rule relay (#319)

    • Target: src/permission-resolver.ts (new); src/permission-session.ts; the four gate descriptor factories (path.ts, bash-path.ts, bash-external-directory.ts, bash-command.ts); handlers/gates/{descriptor,runner}.ts; handlers/permission-gate-handler.ts.
    • getSessionRuleset was a pure relay - at every call site (the runner and every describe* gate) it only fed the next checkPermission. Collapsed the pair into a single PermissionResolver.resolve(surface, input, agentName) that PermissionSession implements; migrated all gates and the runner bag off the (checkPermission, getSessionRuleset) pair. GateRunnerDeps now extends PermissionResolver.
    • The original single-GateRunnerContext framing was rejected: a session-implemented interface would just re-expose the session ("glomming state"). The bag is a relay plus four roles, decomposed across this step and two follow-ups.
    • Smell category: C (relay-only dependencies).
    • Outcome: the relay is gone from every gate; getSessionRuleset no longer appears in the gate-facing surface. The remaining roles are extracted in follow-ups - see step 7 (DecisionReporter, #322 ) and steps 8-9 (GateRunner, #323; role-interface retyping, #325).
  7. Extract DecisionReporter; remove the review-log and decision-event closures (#322)

    • Target: src/decision-reporter.ts (new); src/handlers/gates/descriptor.ts; src/handlers/gates/runner.ts; src/handlers/permission-gate-handler.ts; test/helpers/gate-fixtures.ts; test/handlers/gates/runner.test.ts.
    • writeReviewLog and emitDecision were built as per-handleToolCall closures - a Law-of-Demeter reach-through (session.logger.review) and a bus-wrapping closure - then threaded into GateRunnerDeps as two flat members. Both fired by the runner (session-hit path, decision emit, applyPermissionGate callback) and the bypass branch; the same reach-through appeared again in handleInput. Extracted into a DecisionReporter interface + GateDecisionReporter class (owns SessionLogger + event bus); built once in PermissionGateHandler's constructor and shared by handleToolCall and handleInput. GateRunnerDeps now carries reporter: DecisionReporter (replacing the two inline members); the runner and bypass branch fire through it.
    • Smell category: C (LoD violation + relay-only closure).
    • Outcome: the writeReviewLog/emitDecision closures are gone; two unbound-method eslint-disables removed; the event bus has a clear owner (GateDecisionReporter); GateDecisionReporter is directly unit-testable in isolation.
  8. Replace GateRunnerDeps with an injected GateRunner class (#323) — completed

    • Target: src/gate-prompter.ts (new); src/session-approval-recorder.ts (new); src/permission-session.ts; src/handlers/gates/runner.ts; src/handlers/gates/descriptor.ts; src/handlers/permission-gate-handler.ts; test/helpers/gate-fixtures.ts; test/handlers/gates/runner.test.ts.
    • Added GatePrompter (canConfirm() + promptPermission(details)) and SessionApprovalRecorder role interfaces; PermissionSession implements both via stored-context adapters. GateRunner is constructed with PermissionResolver, SessionApprovalRecorder, GatePrompter, DecisionReporter and exposes run(gate, agentName, toolCallId) — absorbing the null/bypass/descriptor dispatch that previously lived in the handler's anonymous runGate closure. PermissionGateHandler constructs one GateRunner in its constructor and calls runner.run(...) per gate; the runnerDeps bag, the four collaborator closures, and the runGate closure are deleted.
    • Smell category: C (the bag's stable collaborators belong on a class, not threaded through a function).
    • Outcome: GateRunnerDeps is deleted; runGateCheck is deleted; the runner is a proper collaborator the handler constructs once and reuses; makeRunnerDeps replaced by makeGateRunner({ runner, deps }) in gate-fixtures.ts.
  9. Unify handleInput's skill-input gate with the GateRunner pipeline (#326)

    • Target: src/handlers/permission-gate-handler.ts; new src/handlers/gates/skill-input.ts; src/denial-messages.ts; test/handlers/input*.test.ts.
    • handleInput hand-rolls the check → log → emit → approve cycle that GateRunner.runDescriptor owns, with a nested eslint-disabled resolution ternary that duplicates deriveResolution() and direct reaches into emitDecision / writeReviewLog / prompt / canPrompt — the file's worst-CRAP function (79.4). Extract a describeSkillInputGate(tcc, ...) pure descriptor factory (mirroring describeSkillReadGate; preCheck preserving the raw checkPermission semantics), add a skill_input DenialContext kind, and run the descriptor through the shared runner.run(...).
    • Deliberate change to settle in review: the skill-input deny messages gain the [pi-permission-system] tag (every other surface already carries it).
    • Smell category: A (duplication) / C (LoD reach-through).
    • Outcome: the inline gate, the nested ternary, and the direct reporter/prompter reaches are gone; handleInput becomes activate → resolveAgentName → describe → run; the handler's residual PermissionSession surface shrinks ahead of Step 11 (#325).
  10. Extract a ToolCallGatePipeline collaborator (#327)

    • Target: new src/handlers/gates/tool-call-gate-pipeline.ts; src/handlers/permission-gate-handler.ts; src/permission-session.ts; src/index.ts.
    • handleToolCall assembled six gate producers by reaching for anemic session getters (getActiveSkillEntries, getInfrastructureDirs + getInfrastructureReadPaths, config) — gate-construction work with no owner. Introduced ToolCallGatePipeline (constructed once in index.ts, injected into PermissionGateHandler) that owns bash-command extraction, the single BashProgram.parse, ToolPreviewFormatter construction, all six gate producers, and the run loop; evaluate(tcc, runner) returns the first block or allow. Applied Tell-Don't-Ask narrowings: getInfrastructureReadDirs() replaces the two-method reach + handler concat; getToolPreviewLimits() replaces resolveToolPreviewLimits(session.config). Removed now-unused getInfrastructureDirs() / getInfrastructureReadPaths() from PermissionSession.
    • Smell category: C (anemic getters / missing collaborator).
    • Outcome: gate construction has an owner the handler tells; handleToolCall shrinks to activate → validate → build tcc → pipeline.evaluate → map outcome; the handler's residual PermissionSession surface ahead of Step 11 (#325) is activate + resolveAgentName plus the skill-input path's checkPermission + createPermissionRequestId.
  11. Retype PermissionGateHandler against narrow role interfaces (#325)

    • Target: new src/gate-handler-session.ts; src/permission-session.ts; src/handlers/permission-gate-handler.ts; src/index.ts; test/helpers/handler-fixtures.ts; test/handlers/external-directory-integration.test.ts; test/handlers/external-directory-session-dedup.test.ts.
    • The handler's constructor takes session: PermissionSession (concrete class, 36 public members); the as unknown as PermissionSession casts in every test mock disable TypeScript's structural check — the regression that prompted this (a mock missing resolve()) broke at runtime in #319, not at pnpm run check. After Steps 9-10 (#326, #327) the handler's residual session surface is four methods — activate, resolveAgentName, checkPermission, createPermissionRequestId — plus the session.logger read and the three roles passed to GateRunner. Introduced GateHandlerSession (those four methods, top-level src/, implemented by PermissionSession); injected the pre-built GateRunner (build GateDecisionReporter + GateRunner in index.ts) so the handler stops constructing collaborators and reaching session.logger, and dropped the events constructor param; retyped the three makeSession fixtures to the MockGateHandlerSession intersection using vi.fn<T>() and dropped the casts.
    • Planning surfaced three follow-ups that finish the arc: extract a SkillInputGatePipeline (#329, which shrinks GateHandlerSession to a two-method context role), relocate createPermissionRequestId onto the request-creation collaborator (#330), and narrow AgentPrepHandler + SessionLifecycleHandler the same way (#331).
    • Smell category: C (concrete class dependency forces wide mocks; narrow interfaces enforce completeness at the type level).
    • Outcome: as unknown as PermissionSession casts are gone from the gate-handler mocks; the runner is injected, not built in the handler; a consumer calling a method the mock lacks fails at pnpm run check, not at runtime.
  12. Extract a SkillInputGatePipeline collaborator (#329)

    • Target: new src/handlers/gates/skill-input-gate-pipeline.ts; src/handlers/permission-gate-handler.ts; src/index.ts; test/handlers/input*.test.ts.
    • handleInput hand-assembled the skill-input gate (raw checkPermission pre-check, deny notify, describeSkillInputGate, request-id mint, runner.run) — gate-construction work with no owner, asymmetric with the tool_call path's ToolCallGatePipeline (#327). Extracted SkillInputGatePipeline (constructed in index.ts, injected into PermissionGateHandler); reduced handleInput to activate → resolveAgentName → extract skill name → pipeline.evaluate → map outcome.
    • Smell category: C (missing collaborator).
    • Outcome: the input and tool_call paths are symmetric; checkPermission + createPermissionRequestId left the handler's session surface; GateHandlerSession collapsed to a two-method context role (activate + resolveAgentName).
  13. Relocate createPermissionRequestId onto the request-creation collaborator (#330) — folded into Step 12.

    • createPermissionRequestId moved into SkillInputGatePipeline as the module-level createSkillInputRequestId() helper; removed from PermissionSession.
    • Outcome: PermissionSession sheds a stateless utility; request-id creation lives next to request creation.
  14. Narrow AgentPrepHandler + SessionLifecycleHandler against role interfaces (#331)

    • Target: new src/agent-prep-session.ts; new src/session-lifecycle-session.ts; src/gate-handler-session.ts; src/permission-session.ts; src/handlers/before-agent-start.ts; src/handlers/lifecycle.ts; test/handlers/before-agent-start.test.ts; test/handlers/lifecycle.test.ts.
    • Both handlers took session: PermissionSession with as unknown as PermissionSession local mocks; the same structural smell #325 removed from PermissionGateHandler. Introduced AgentPrepSession (extends GateHandlerSession + SkillPermissionChecker; adds 8 prep-specific methods) and SessionLifecycleSession (9-member role; intentionally omits activate — ISP); widened GateHandlerSession.resolveAgentName to accept an optional systemPrompt parameter so AgentPrepHandler reuses the shared context role without redefining it; PermissionSession adds both roles to its implements list with no method-body changes; retyped both local makeSession fixtures to the role with vi.fn<T>() per field and dropped the casts.
    • Smell category: C (concrete-class dependency forces wide mocks).
    • Outcome: no handler depends on the concrete PermissionSession; the last as unknown as PermissionSession casts in the handler test tree are gone; mock completeness is enforced at pnpm run check for all three handlers.
  15. Reframe the index.ts composition root as collaborator injection (#320)

    • Target: new src/permissions-service.ts; new src/service-lifecycle.ts; src/handlers/lifecycle.ts; src/index.ts.
    • Promoted the inline permissionsService literal to LocalPermissionsService (injected PermissionManager + SessionRules + ToolInputFormatterRegistry) and the activateServiceForSession + teardown closures to PermissionServiceLifecycle (implementing a narrow ServiceLifecycle interface); retyped SessionLifecycleHandler to take ServiceLifecycle instead of two raw callbacks.
    • The established injection-bag construction (PermissionSessionRuntimeDeps, PermissionPrompterDeps, PermissionForwarderDeps, command/RPC deps) was intentionally kept inline: relocating it into buildX() helpers would be pure statement relocation with no new collaborator — procedure-splitting per AGENTS.md.
    • Verified with test/composition-root.test.ts: handler registration, #302 child-gated service publish, and synchronous lifecycle subscription all unchanged.
    • Smell category: C (adapter closure density) / E (wiring overhead).
    • Outcome: LocalPermissionsService and PermissionServiceLifecycle provide testable homes for the two anemic inline constructs; SessionLifecycleHandler depends on a narrow two-method interface instead of raw callbacks; index.ts ~206 → ~170 lines. The "< 100 lines" target was explicitly deferred as procedure-splitting.
  16. Continue shared test-fixture extraction (#321) — completed

    • Target: the four largest remaining clone families - external-directory-integration.test.ts, bash-path.test.ts, runner.test.ts, tool-call.test.ts.
    • Migrated all four families onto the existing test/helpers/ fixtures; extended gate-fixtures.ts with resolveResult option on makeGateRunner, makeDenialDescriptor, and makePathDispatchResolver; extended handler-fixtures.ts with makeSurfaceCheck, makeBashCommandCheck, and the tools shortcut on makeHandler.
    • Smell category: D (test duplication).
    • Outcome: duplication 7.6% → 6.6%; clone groups 133 → 122. The <6% target was not fully reached; external-directory-session-dedup.test.ts carries a residual local-makeSession clone family that is outside the four-file scope — a follow-up issue will address it.

Step dependency diagram

The forwarding collaborator is a lift-and-shift sequence: Step 2 introduces the class, Step 3 removes the duplicated bag, Step 4 inlines the logic and deletes the interface (introduce-new-alongside-old, remove-old-last). The gate-runner rework is a sequential extraction chain: Steps 6-8 are done; Steps 9-11 unify the input gate, extract the gate pipeline, and retype the handler against narrow interfaces, then Steps 12-14 are the #325 follow-ups that finish the arc — each shrinks the handler's session surface before the next. Step 12 (SkillInputGatePipeline) depends on Step 11 and shrinks GateHandlerSession to a two-method context role; Step 13 relocates the request-id minter onto that pipeline; Step 14 narrows the remaining two handlers, reusing the context role (soft edge). Step 15 (composition root) depends on the forwarding collaborator (Steps 2-4) and the full gate-runner/handler rework (Steps 6-14); it is sequenced after Step 12 so the SkillInputGatePipeline already exists to be injected, rather than re-touching index.ts after the reframe. Step 16 is best sequenced after the production refactors whose tested call sites it touches (dashed edges) - those refactors are behavior-preserving, so the soft ordering only avoids re-migrating fixtures, it does not block.

flowchart TD
    S1["Step 1: Split tool-input-preview.ts (#314)"]
    S2["Step 2: Introduce PermissionForwarder (#315)"]
    S3["Step 3: Fold buildForwardingDeps into forwarder (#316)"]
    S4["Step 4: Remove PermissionForwardingDeps bag (#317)"]
    S5["Step 5: McpTargetList value object (#318)"]
    S6["Step 6: PermissionResolver, relay removal (#319)"]
    S7["Step 7: DecisionReporter extraction (#322)"]
    S8["Step 8: ✅ GateRunner class, role collaborators (#323)"]
    S9["Step 9: ✅ Unify handleInput with GateRunner (#326)"]
    S10["Step 10: ✅ Extract ToolCallGatePipeline (#327)"]
    S11["Step 11: ✅ PermissionGateHandler role-interface retyping (#325)"]
    S12["Step 12: Extract SkillInputGatePipeline (#329)"]
    S13["Step 13: Relocate createPermissionRequestId (#330)"]
    S14["Step 14: Narrow remaining handlers (#331)"]
    S15["Step 15: ✅ Composition root as collaborator injection (#320)"]
    S16["Step 16: ✅ Continue test-fixture extraction (#321)"]

    S2 --> S3
    S3 --> S4
    S6 --> S7
    S7 --> S8
    S8 --> S9
    S8 --> S10
    S9 --> S11
    S10 --> S11
    S11 --> S12
    S12 --> S13
    S11 --> S14
    S12 -.-> S14
    S4 --> S15
    S11 --> S15
    S12 --> S15
    S1 -.-> S16
    S4 -.-> S16
    S5 -.-> S16
    S6 -.-> S16
    S15 -.-> S16

Tracks

Track Steps Description
A: Module cohesion 1 Split the tool-input-preview.ts bag (independent)
B: Forwarding collaborator 2 → 3 → 4 Give the forwarding behavior a stateful owner; delete the duplicated bag (sequential lift-and-shift)
C: State encapsulation 5, 6, 7, 8, 9, 10, 11, 12, 13, 14 McpTargetList value object, the gate-runner collaborator rework (PermissionResolverDecisionReporterGateRunnerhandleInput unification → ToolCallGatePipeline → role-interface retyping), and the #325 follow-ups (SkillInputGatePipeline → request-id relocation → narrowing the remaining handlers)
D: Composition root 15 Reframe index.ts as collaborator injection (after Tracks B and C)
E: Test duplication 16 Migrate the four largest clone families onto shared fixtures (best last)