The moment I knew the code had to move was when I fixed a CVE enrichment bug in @git-fabric/cve and then had to open git-steer and fix it again in src/fabric/cve.ts. Same bug. Same fix. Two repos. Two PRs. Two sets of tests to update.

Seven hundred and twenty lines of inline CVE logic had grown inside git-steer over the past few weeks — 558 in cve.ts, 163 in adapter.ts. Detection layers, intelligence enrichment, triage decisions, queue management, compaction. The code was solid. It worked. It just lived in the wrong place.

This is the story of extracting it.

The org, the packages, the plan

On February 7th I created the git-fabric GitHub org. The idea was straightforward: one composable package per domain, one gateway to connect them, and git-steer stays thin — an MCP server that orchestrates but doesn’t own business logic.

The extraction had three deliverables:

  1. @git-fabric/cve — standalone package with 5 composable layers (detection, intelligence, decision, action, state) plus a createApp() factory for gateway registration
  2. @git-fabric/gateway — routing and orchestration layer with a registry that any fabric app can plug into
  3. git-steer rewrite — replace 720 lines of inline code with a 65-line gateway module

Simple on paper. Papers don’t have dependency graphs.

Building the layers

The CVE package came together fast. Five layers, each with a clean responsibility boundary. Detection scans repos for Dependabot and code scanning alerts. Intelligence enriches with CVSS scores and exploitability data. Decision applies triage rules. Action dispatches fixes. State manages a persistent queue with compaction.

The part that took longer than expected was the createApp() factory. The gateway’s registry system needs a standard interface — a function that takes configuration and returns a FabricApp with tool definitions, input schemas, and a route handler. Getting that contract right meant iterating on the type signatures three times before the gateway could actually consume it.

I also hardened the Octokit adapter with @octokit/plugin-throttling and @octokit/plugin-retry. This was non-negotiable. A few weeks earlier, automated API patterns from git-steer had triggered a GitHub account suspension. The throttling plugin intercepts rate-limit responses and backs off automatically. The retry plugin handles transient failures. Both were already proven in git-steer’s own hardening sprint — but the fabric packages needed their own instance because they’d be running in a different process context.

The dist/ problem

This is where things broke.

Both @git-fabric/gateway and @git-fabric/cve had "files": ["bin/", "dist/"] in their package.json. Standard practice — declare what gets published. But neither repo had a dist/ directory committed to Git. Why would they? Compiled output goes in .gitignore. Everyone knows that.

Except when npm installs from a GitHub URL instead of the npm registry, it doesn’t run your build. It clones the repo, filters to the files you declared, and that’s your package. No prepublishOnly. No prepare step with the full devDependency tree. Just whatever files actually exist in the repo.

So git-steer ran npm install and got… nothing. Empty packages. TypeScript tried to compile and threw:

error TS2307: Cannot find module '@git-fabric/gateway' or its corresponding type declarations.
error TS2307: Cannot find module '@git-fabric/cve' or its corresponding type declarations.

I sat with this for a while. The options weren’t great:

  • TypeScript path mapping — Too invasive. Would’ve meant custom resolution throughout git-steer just to work around a packaging problem.
  • "prepare": "tsc" in package.json — Sounds clever until you realize npm doesn’t install devDependencies when pulling from GitHub. No devDeps means no TypeScript compiler. Dead end.
  • Local type stubs — Maintaining hand-written .d.ts files for packages I control. This is the kind of solution that works on Tuesday and becomes a maintenance nightmare by Thursday.

The pragmatic fix was the ugly one: compile locally, remove dist/ from .gitignore, and push the compiled artifacts. It felt wrong. Committed build output. But two commits later, npm install pulled working packages and tsc was happy. Sometimes the right answer is the one that ships.

The rewrite

With the packages installable, the git-steer side went fast.

The monolithic TOOLS array — 38 tools in one flat list — got split into CORE_TOOLS (32) and FABRIC_CVE_TOOLS (6). The fabric tools only appear in the tool listing if the gateway loaded successfully. Graceful degradation: if @git-fabric/gateway isn’t installed or createApp() throws, git-steer starts normally with just core tools. No crash. No missing functionality beyond the fabric domain.

The new src/fabric/gateway.ts module is 65 lines. It initializes the gateway, registers the CVE app, builds a TOOL_MAP that translates fabric_cve_scan to cve_scan, and exposes a single route() function. The old switch statement with 6 case blocks and a getFabricAdapters() helper? Gone.

Then the tests. Sixteen unit tests covering the gateway init path, degraded fallback, tool mapping completeness, conditional listing, and JSON result parsing. Three integration tests that exercise live routing against the real gateway — guarded by describe.skipIf(!token) so CI doesn’t need a GitHub token.

 Test Files  4 passed | 1 skipped (5)
       Tests  32 passed | 3 skipped (35)
    Duration  452ms

All green. No regressions in the existing 16 tests. The 3 skipped tests are the integration suite waiting for a token.

The blind spot

Here’s the part that’s hard to write.

I have Aiana — an AI memory assistant — wired into my Claude sessions via MCP. She records conversations, indexes decisions, and provides recall across sessions. My CLAUDE.md files explicitly instruct Claude to call memory_recall at the start of every session. It’s right there in the instructions. Bold text. Clear directive.

Over the entire gateway integration session — 130 assistant messages, 4 sub-agent tasks, commits pushed to 3 repos — Aiana was never called. Not once. No memory_recall for project context. No memory_search for past decisions. No memory_add to save what we learned.

Had she been consulted, Aiana would have surfaced the GitHub account suspension history and the rate-limiting patterns we’d already established. We arrived at the right answer anyway (the Octokit throttling plugins), but we got there by re-reading code instead of recalling context.

The lesson isn’t that the instructions were unclear. The lesson is that written instructions aren’t enough. AI memory needs hooks — automated triggers that inject context at session start, not suggestions that get skipped when a session gets complex. Policy is not mechanism.

The numbers

MetricBeforeAfter
Inline fabric code720 lines0 lines
Gateway module65 lines
Test coverage (gateway)0 tests19 tests
Fabric apps supported1 (hardcoded)N (registry-based)
Code duplicationcve.ts + @git-fabric/cve@git-fabric/cve only

What comes next

The gateway infrastructure is now sitting there, waiting for N=2. The registry accepts any fabric app that implements the createApp() contract. Compliance scanning, drift detection, dependency auditing — whatever the next domain is, it registers with the gateway, adds tool definitions to a FABRIC_*_TOOLS array, extends the TOOL_MAP, and routing just works.

Three lessons from the extraction:

  1. GitHub-sourced npm packages need compiled artifacts. If your package.json declares "files": ["dist/"] but dist/ is gitignored, consumers get an empty package. This doesn’t bite you on the npm registry where prepublishOnly runs. It bites you every time with GitHub URLs.

  2. AI memory needs mechanical triggers. Aiana’s CLAUDE.md directives are necessary but not sufficient. The next step is wiring hooks that automatically inject memory_recall at session start — making context retrieval a system behavior, not a suggestion.

  3. Gateway patterns pay off at N=2. The infrastructure is arguably over-engineered for one CVE app. But the bet is that the second app is coming soon, and when it does, all the registration, routing, and conditional listing machinery is already in place.

Seven hundred and twenty lines deleted. Sixty-five lines added. The code lives where it belongs now.


Built with Claude Code (Opus 4.6).