Internal working engineering record - kept for reference, not a user-facing guide.
Hooks ownership split: hooks-sdk owns definition + fire path, axctl owns config + analysis
Status: Accepted (2026-06-13)
Hook functionality had grown in two places at once - packages/hooks-sdk (the
fire-path runtime consumed from ~/.ax/hooks via a file: dep) and
apps/axctl/src/hooks (the CLI surface) - with no stated boundary. The locked
split:
@ax/hooks-sdkowns the typed hook definition (defineHook,HookDefinition,runHook/runMain), verdicts (allow / block / warn / inject, defects fail OPEN), the fire path (bun <file>.ts, ~70ms budget, no axctl in the hot path), the prebuilt hooks (enforce-worktree,enforce-worktree-write,route-dispatch), theGitEnvservice, AND the routing-table schema + loader (src/routing-table.ts: Schema validation,~/.ax/hooks/routing-table.jsonpath, sync fail-open read for the fire path, normalized Effect read for the compile side).axctlowns provider config CRUD viaHookProviderRegistry(claude/codex codecs, ax ownership markers, park sidecars), install fan-out (ax hooks init|install), telemetry (hook_command_invocationevidence joins), backtest/cases (ax hooks backtest|cases), and routing ANALYSIS + WRITES (ax dispatches,ax routing compile|tune|show: merge/append/save inqueries/routing-table-io.ts).
The dependency arrow only ever points axctl → hooks-sdk. The sdk stays
dependency-light (effect only, pinned not catalog:) because ~/.ax/hooks
workspaces resolve it outside the monorepo.
Consequence: routing-table dedup
route-dispatch.ts and queries/routing-table-io.ts each carried their own
schema + parse of routing-table.json, and the default class seed was duplicated
verbatim (DEFAULT_TABLE in the hook, ROUTING_CLASSES in
dispatch-analytics.ts) with mirror-me comments. Both now import one module,
@ax/hooks-sdk/routing-table; ROUTING_CLASSES is an alias of the sdk's
DEFAULT_ROUTING_TABLE, so the hook's fallback and the compile seed cannot
drift. The defaults live in the sdk rather than axctl because the fire path
must work before any ax routing compile step exists; axctl re-exports them,
so no import cycle (this also broke the prior routing-table-io ↔
dispatch-analytics cycle).
The two read semantics are deliberately both kept, side by side in the one
module: the fire path does a whole-table fail-open decode (any problem →
built-in defaults; a corrupt table must never wedge the agent), while the
compile side normalizes row-by-row and returns null on a structurally bad file
(so ax routing compile can refuse to overwrite it).
Also folded in: route-dispatch no longer requires GitEnv - routing is pure
table matching on tool input. HookDefinition.run's R-channel stays typed as
GitEnv (covariant, never is assignable), so hooks that need git state keep
it and pure hooks just don't yield it.
Trade-offs
- The sdk imports
node:fsinrouting-table.ts(sync read keeps the hook's error channelneverunder plain bun); thecheck:no-node-fsallowlist entry moved fromhooks/route-dispatch.tstorouting-table.ts. Match logic is still implemented twiceRESOLVED (follow-up): the matcher now lives once inrouting-table.tsasmatchRoutingTable, taking the looseRoutingTableShape(the strict in-memoryRoutingTableis assignable to it), so both the fire-path hook andax dispatches --candidatescall it. The axctl-sidematchRoutingWith/matchRoutingnames survive as thin delegators for existing callers and tests.