34 KiB
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
-
✅ Split
tool-input-preview.tsinto cohesive modules (#314)- Target:
src/tool-input-preview.ts(the solefallowrefactoring target). - Extracted the three prompt formatters plus
getPromptPathinto a newsrc/tool-input-prompt-formatters.ts; left the text utilities (truncateInlineText,countTextLines,formatCount),serializeToolInputPreview, and the three limit constants intool-input-preview.ts. - Repointed the sole production consumer (
tool-preview-formatter.ts) and relocated the moved functions' unit coverage intotest/tool-input-prompt-formatters.test.ts; all four new exports are consumed, sofallowflags no dead re-export. - Smell category: B (oversized) / E (cohesion).
- Outcome:
tool-input-preview.tsdropped off the refactoring-target list; refactoring targets 1 → 0 (confirmed byfallow health --targets).
- Target:
-
✅ Introduce a
PermissionForwardercollaborator (own the state) (#315)- Target: new
src/forwarded-permissions/permission-forwarder.ts;forwarding-manager.ts;index.ts. - Added a
PermissionForwarderclass exposingrequestApproval(ctx, message, options?, forwarded?)andprocessInbox(ctx); for this lift-and-shift step it holds thePermissionForwardingDepsbag privately (shouldAutoApprovesupplied once at construction) and delegates to the existingpolling.tsfree functions, so behavior is unchanged. - Wired
ForwardingManagerto a narrowInboxProcessorseam (the manager only callsprocessInbox, mirroring the existingForwardingControllerconvention and dropping the test'sas unknown ascast); constructed the single forwarder inindex.tsand injected it. - Smell category: C (anemic domain model - give the forwarding behavior an owner).
- Outcome: one forwarder instance replaces the threaded
index.tsforwarding bag;ForwardingManagertells the forwarder instead of threading a deps bag. The bag interface itself is dismantled in #317.
- Target: new
-
✅ Fold
PermissionPrompter.buildForwardingDeps()into the injected forwarder (#316)- Target:
src/permission-prompter.ts;src/forwarded-permissions/permission-forwarder.ts;index.ts. - Added the
ApprovalRequesternarrow seam (alongsideInboxProcessor) topermission-forwarder.ts; narrowedPermissionPrompterDepsfrom 7 fields to 4 (removingsubagentSessionsDir,forwardingDir,registry,requestPermissionDecisionFromUi); replaced theconfirmPermission(..., this.buildForwardingDeps(), ...)call withthis.deps.forwarder.requestApproval(...)and deletedbuildForwardingDeps()and itseslint-disable unbound-methodcluster; reorderedindex.tsto 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;
PermissionForwardingDepsbag is dismantled in #317.
- Target:
-
✅ Remove
PermissionForwardingDeps; inline the polling logic as forwarder methods (#317)- Target:
src/forwarded-permissions/polling.ts→permission-forwarder.ts(sequence after Steps 2-3). - Added
PermissionForwarderDeps(replacesPermissionForwardingDeps); dissolved the bag into individualprivate readonlyfields onPermissionForwarder; inlinedwaitForForwardedPermissionApprovalandprocessForwardedPermissionRequestsas private methods readingthis; extractedbuildForwardedRequest(returns a value object),pollForForwardedResponse(owns the deadline loop + file cleanup), andprocessSingleForwardedRequest(per-request workflow) as focused private helpers; movedgetSessionId,getContextSystemPrompt,formatForwardedPermissionPromptto module-private functions (no external callers); deletedpolling.ts; updatedindex.tsto importPermissionForwarderDepsfrompermission-forwarder; rewrotepermission-forwarder.test.tswith real behavior tests (migrated frompermission-forwarding.test.ts); removed stalevi.mockfor polling fromruntime.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;
PermissionForwardingDepsis gone; the forwarding subsystem is fully class-based (Track B complete).
- Target:
-
✅ Introduce an
McpTargetListvalue object (#318)- Target:
src/mcp-targets.ts. - Added an exported
McpTargetListclass:add(value)owns the null/empty guard and theincludesdedup (first-insertion wins);toArray()returns an independent ordered copy. RewrotecreateMcpPermissionTargets,pushMcpToolPermissionTargets, andaddDerivedMcpServerTargetsto construct anMcpTargetListand calltargets.add(...)- the per-mode branches tell the list instead of asking the array.McpTargetListis exported and covered by direct unit tests (invariant: ignores null/empty, dedups, preserves order,toArrayreturns an independent copy). - Smell category: C (mutable closure state → value object that owns its invariant).
- Outcome: the
pushTargetclosure and theincludes-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).
- Target:
-
✅ 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. getSessionRulesetwas a pure relay - at every call site (the runner and everydescribe*gate) it only fed the nextcheckPermission. Collapsed the pair into a singlePermissionResolver.resolve(surface, input, agentName)thatPermissionSessionimplements; migrated all gates and the runner bag off the(checkPermission, getSessionRuleset)pair.GateRunnerDepsnowextends PermissionResolver.- The original single-
GateRunnerContextframing 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;
getSessionRulesetno 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).
- Target:
-
✅ 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. writeReviewLogandemitDecisionwere built as per-handleToolCallclosures - a Law-of-Demeter reach-through (session.logger.review) and a bus-wrapping closure - then threaded intoGateRunnerDepsas two flat members. Both fired by the runner (session-hit path, decision emit,applyPermissionGatecallback) and the bypass branch; the same reach-through appeared again inhandleInput. Extracted into aDecisionReporterinterface +GateDecisionReporterclass (ownsSessionLogger+ event bus); built once inPermissionGateHandler's constructor and shared byhandleToolCallandhandleInput.GateRunnerDepsnow carriesreporter: 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/emitDecisionclosures are gone; twounbound-methodeslint-disables removed; the event bus has a clear owner (GateDecisionReporter);GateDecisionReporteris directly unit-testable in isolation.
- Target:
-
✅ Replace
GateRunnerDepswith an injectedGateRunnerclass (#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)) andSessionApprovalRecorderrole interfaces;PermissionSessionimplements both via stored-context adapters.GateRunneris constructed withPermissionResolver,SessionApprovalRecorder,GatePrompter,DecisionReporterand exposesrun(gate, agentName, toolCallId)— absorbing the null/bypass/descriptor dispatch that previously lived in the handler's anonymousrunGateclosure.PermissionGateHandlerconstructs oneGateRunnerin its constructor and callsrunner.run(...)per gate; therunnerDepsbag, the four collaborator closures, and therunGateclosure are deleted. - Smell category: C (the bag's stable collaborators belong on a class, not threaded through a function).
- Outcome:
GateRunnerDepsis deleted;runGateCheckis deleted; the runner is a proper collaborator the handler constructs once and reuses;makeRunnerDepsreplaced bymakeGateRunner({ runner, deps })ingate-fixtures.ts.
- Target:
-
Unify
handleInput's skill-input gate with theGateRunnerpipeline (#326)- Target:
src/handlers/permission-gate-handler.ts; newsrc/handlers/gates/skill-input.ts;src/denial-messages.ts;test/handlers/input*.test.ts. handleInputhand-rolls thecheck → log → emit → approvecycle thatGateRunner.runDescriptorowns, with a nested eslint-disabled resolution ternary that duplicatesderiveResolution()and direct reaches intoemitDecision/writeReviewLog/prompt/canPrompt— the file's worst-CRAP function (79.4). Extract adescribeSkillInputGate(tcc, ...)pure descriptor factory (mirroringdescribeSkillReadGate;preCheckpreserving the rawcheckPermissionsemantics), add askill_inputDenialContextkind, and run the descriptor through the sharedrunner.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;
handleInputbecomes activate → resolveAgentName → describe → run; the handler's residualPermissionSessionsurface shrinks ahead of Step 11 (#325).
- Target:
-
✅ Extract a
ToolCallGatePipelinecollaborator (#327)- Target: new
src/handlers/gates/tool-call-gate-pipeline.ts;src/handlers/permission-gate-handler.ts;src/permission-session.ts;src/index.ts. handleToolCallassembled six gate producers by reaching for anemic session getters (getActiveSkillEntries,getInfrastructureDirs+getInfrastructureReadPaths,config) — gate-construction work with no owner. IntroducedToolCallGatePipeline(constructed once inindex.ts, injected intoPermissionGateHandler) that owns bash-command extraction, the singleBashProgram.parse,ToolPreviewFormatterconstruction, 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()replacesresolveToolPreviewLimits(session.config). Removed now-unusedgetInfrastructureDirs()/getInfrastructureReadPaths()fromPermissionSession.- Smell category: C (anemic getters / missing collaborator).
- Outcome: gate construction has an owner the handler tells;
handleToolCallshrinks to activate → validate → buildtcc→ pipeline.evaluate → map outcome; the handler's residualPermissionSessionsurface ahead of Step 11 (#325) isactivate+resolveAgentNameplus the skill-input path'scheckPermission+createPermissionRequestId.
- Target: new
-
✅ Retype
PermissionGateHandleragainst 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); theas unknown as PermissionSessioncasts in every test mock disable TypeScript's structural check — the regression that prompted this (a mock missingresolve()) broke at runtime in #319, not atpnpm run check. After Steps 9-10 (#326, #327) the handler's residual session surface is four methods —activate,resolveAgentName,checkPermission,createPermissionRequestId— plus thesession.loggerread and the three roles passed toGateRunner. IntroducedGateHandlerSession(those four methods, top-levelsrc/, implemented byPermissionSession); injected the pre-builtGateRunner(buildGateDecisionReporter+GateRunnerinindex.ts) so the handler stops constructing collaborators and reachingsession.logger, and dropped theeventsconstructor param; retyped the threemakeSessionfixtures to theMockGateHandlerSessionintersection usingvi.fn<T>()and dropped the casts. - Planning surfaced three follow-ups that finish the arc: extract a
SkillInputGatePipeline(#329, which shrinksGateHandlerSessionto a two-method context role), relocatecreatePermissionRequestIdonto the request-creation collaborator (#330), and narrowAgentPrepHandler+SessionLifecycleHandlerthe same way (#331). - Smell category: C (concrete class dependency forces wide mocks; narrow interfaces enforce completeness at the type level).
- Outcome:
as unknown as PermissionSessioncasts 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 atpnpm run check, not at runtime.
- Target: new
-
✅ Extract a
SkillInputGatePipelinecollaborator (#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. handleInputhand-assembled the skill-input gate (rawcheckPermissionpre-check, deny notify,describeSkillInputGate, request-id mint,runner.run) — gate-construction work with no owner, asymmetric with thetool_callpath'sToolCallGatePipeline(#327). ExtractedSkillInputGatePipeline(constructed inindex.ts, injected intoPermissionGateHandler); reducedhandleInputto activate → resolveAgentName → extract skill name → pipeline.evaluate → map outcome.- Smell category: C (missing collaborator).
- Outcome: the
inputandtool_callpaths are symmetric;checkPermission+createPermissionRequestIdleft the handler's session surface;GateHandlerSessioncollapsed to a two-method context role (activate+resolveAgentName).
- Target: new
-
✅ Relocate
createPermissionRequestIdonto the request-creation collaborator (#330) — folded into Step 12.createPermissionRequestIdmoved intoSkillInputGatePipelineas the module-levelcreateSkillInputRequestId()helper; removed fromPermissionSession.- Outcome:
PermissionSessionsheds a stateless utility; request-id creation lives next to request creation.
-
✅ Narrow
AgentPrepHandler+SessionLifecycleHandleragainst role interfaces (#331)- Target: new
src/agent-prep-session.ts; newsrc/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: PermissionSessionwithas unknown as PermissionSessionlocal mocks; the same structural smell #325 removed fromPermissionGateHandler. IntroducedAgentPrepSession(extendsGateHandlerSession+SkillPermissionChecker; adds 8 prep-specific methods) andSessionLifecycleSession(9-member role; intentionally omitsactivate— ISP); widenedGateHandlerSession.resolveAgentNameto accept an optionalsystemPromptparameter soAgentPrepHandlerreuses the shared context role without redefining it;PermissionSessionadds both roles to itsimplementslist with no method-body changes; retyped both localmakeSessionfixtures to the role withvi.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 lastas unknown as PermissionSessioncasts in the handler test tree are gone; mock completeness is enforced atpnpm run checkfor all three handlers.
- Target: new
-
✅ Reframe the
index.tscomposition root as collaborator injection (#320)- Target: new
src/permissions-service.ts; newsrc/service-lifecycle.ts;src/handlers/lifecycle.ts;src/index.ts. - Promoted the inline
permissionsServiceliteral toLocalPermissionsService(injectedPermissionManager+SessionRules+ToolInputFormatterRegistry) and theactivateServiceForSession+ teardown closures toPermissionServiceLifecycle(implementing a narrowServiceLifecycleinterface); retypedSessionLifecycleHandlerto takeServiceLifecycleinstead of two raw callbacks. - The established injection-bag construction (
PermissionSessionRuntimeDeps,PermissionPrompterDeps,PermissionForwarderDeps, command/RPC deps) was intentionally kept inline: relocating it intobuildX()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:
LocalPermissionsServiceandPermissionServiceLifecycleprovide testable homes for the two anemic inline constructs;SessionLifecycleHandlerdepends 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.
- Target: new
-
✅ 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; extendedgate-fixtures.tswithresolveResultoption onmakeGateRunner,makeDenialDescriptor, andmakePathDispatchResolver; extendedhandler-fixtures.tswithmakeSurfaceCheck,makeBashCommandCheck, and thetoolsshortcut onmakeHandler. - 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.tscarries a residual local-makeSessionclone family that is outside the four-file scope — a follow-up issue will address it.
- Target: the four largest remaining clone families -
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 (PermissionResolver → DecisionReporter → GateRunner → handleInput 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) |