Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,16 +382,20 @@ warn / inject; defects fail OPEN. `GitEnv` service makes guards layer-testable.
in-process -> `mergeVerdicts` -> encode once), replacing N fat per-guard
bundles. Standalone bundle ~0.9 MB (one, vs ~1.5 MB across four), embedded +
scaffolded like the per-guard bundles. Runtime is live (`bun dispatch.ts`);
flipping `ax hooks install --all` to register the single dispatcher (per-event
matchers via `dispatchInstallPlan`) + migrate off legacy per-guard entries is
the next step, then a daemon `/hooks/eval` fast-path (the latency win).
`ax hooks install --all` now registers the single dispatcher (see below). A
daemon `/hooks/eval` fast-path (the latency win) is the remaining step.
- `ax hooks install <abs-file> --providers=claude,codex` - idempotent fan-out
into provider configs via the existing codecs (ax ownership markers)
- `ax hooks install --all [--providers=claude,codex] [--dir=~/.ax/hooks]` -
one-shot: install EVERY guard scaffolded in the workspace (`.ts` shims or
`.js` bundles, resolved by `listInstallableGuards`), no per-file dance. `ax
install`'s tail nudges `ax hooks init && ax hooks install --all`. The embed
path means a release binary needs NO repo checkout for any of this (#573).
one-shot: register the **dispatcher** once per (provider, event) - per-event
tool matchers UNION-ed via `dispatchInstallPlan` so it fires only on tools a
guard claims - and **migrate off legacy per-guard entries** (ax-owned rows
whose command is `bun <dir>/<guard>.{ts,js}`; user-authored hooks are never
touched). Pure planning (`planDispatcherInstall` / `planLegacyRemoval` in
`dispatch-install.ts`) is unit-tested; `installDispatcher` has an in-process
integration test (real temp config, stub DB - `readAllHooks(withEvidence:false)`
never touches SurrealClient). `ax install`'s tail nudges `ax hooks init &&
ax hooks install --all`. Release binary needs NO repo checkout (#573).
- `ax hooks backtest <file> [--days]` - replay tool_call history through the
hook in-process; state-dependent checks use CURRENT repo state (caveat printed)
- `ax hooks bench <file> [--days --runs --budget-ms --json]` - latency ledger:
Expand Down
87 changes: 53 additions & 34 deletions apps/axctl/src/hooks/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
scaffoldWorkspace,
scaffoldFromEmbed,
isCompiledBinary,
listInstallableGuards,
COMPILED_BINARY_SDK_HOOK_HELP,
} from "./sdk-workspace.ts";
import { HOOKS_EMBED } from "./hooks-embed.gen.ts";
Expand All @@ -25,7 +24,8 @@ import {
} from "./config.ts";
import type { HookScope } from "./providers/types.ts";
import { HookNotFoundError } from "./errors.ts";
import { installHookFile } from "./sdk-install.ts";
import { installHookFile, stripAxMarker } from "./sdk-install.ts";
import { installDispatcher, resolveDispatcherPath } from "./dispatch-install.ts";
import { GitEnvLive } from "@ax/hooks-sdk/git-env";
import { fetchRows, replayRows, summarize, formatReport } from "./backtest.ts";
import { benchHook, renderLedger } from "./bench.ts";
Expand Down Expand Up @@ -254,55 +254,74 @@ const installCommand = Command.make(
process.exit(1);
}

// Resolve the file set: --all installs every guard scaffolded in the
// workspace (one command for the whole default set, no per-file
// dance); otherwise the single positional file.
const filePathArg = optionValue(file);
let files: string[];
// --all installs the SINGLE dispatcher (one spawn multiplexes every
// guard) and migrates off any legacy per-guard entries; otherwise
// install the single positional file.
if (all) {
const workspaceDir = expandTilde(dir);
const guards = yield* listInstallableGuards(workspaceDir);
if (guards.length === 0) {
const dispatchPath = yield* resolveDispatcherPath(workspaceDir);
if (dispatchPath === null) {
console.error(
`no guard hooks found in ${workspaceDir}. Run 'ax hooks init' first to scaffold them.`,
`no dispatcher found in ${workspaceDir} (dispatch.ts/.js). Run 'ax hooks init' first to scaffold it.`,
);
process.exit(1);
}
files = [...guards];
} else {
if (filePathArg === undefined) {
console.error("pass a hook file path, or --all to install every guard in the workspace.");
process.exit(1);
const { entries, removed } = yield* installDispatcher(
dispatchPath,
workspaceDir,
providerList,
asScope(scope),
);
const installedEntries = entries.filter((e) => !e.skipped);
const skippedEntries = entries.filter((e) => e.skipped);
console.log(`dispatcher: ${path.basename(dispatchPath)} (one spawn multiplexes all guards)`);
for (const e of installedEntries) {
const m = e.input.matcher ? ` [matcher: ${e.input.matcher}]` : "";
console.log(` installed ${e.provider} ${e.input.event}${m} -> ${e.writtenPath}`);
}
for (const e of skippedEntries) {
const m = e.input.matcher ? ` [matcher: ${e.input.matcher}]` : "";
console.log(` already installed - skipped ${e.provider} ${e.input.event}${m}`);
}
for (const r of removed) {
console.log(` migrated off legacy ${r.provider} ${r.event} (${stripAxMarker(r.command)})`);
}
console.log("");
const parts = [`${installedEntries.length} dispatcher hook(s) installed`];
if (skippedEntries.length > 0) parts.push(`${skippedEntries.length} skipped (already installed)`);
if (removed.length > 0) parts.push(`${removed.length} legacy per-guard entr${removed.length === 1 ? "y" : "ies"} migrated`);
console.log(`${parts.join(", ")}.`);
if (installedEntries.length > 0 && providerList.includes("codex")) {
console.log("note (codex): approve the new hook(s) when prompted (trust review).");
}
files = [path.resolve(expandTilde(filePathArg))];
return;
}

let installed = 0;
let skipped = 0;
for (const absFile of files) {
if (all) console.log(`${path.basename(absFile)}:`);
const results = yield* installHookFile(absFile, providerList, asScope(scope));
for (const entry of results) {
const matcherStr = entry.input.matcher ? ` [matcher: ${entry.input.matcher}]` : "";
const indent = all ? " " : "";
if (entry.skipped) {
console.log(`${indent}already installed - skipped ${entry.provider} ${entry.input.event}${matcherStr}`);
continue;
}
console.log(`${indent}installed ${entry.provider} ${entry.input.event}${matcherStr} -> ${entry.writtenPath}`);
console.log(`${indent} command: ${entry.input.command}`);
const filePathArg = optionValue(file);
if (filePathArg === undefined) {
console.error("pass a hook file path, or --all to install the dispatcher (all guards).");
process.exit(1);
}
const absFile = path.resolve(expandTilde(filePathArg));
const results = yield* installHookFile(absFile, providerList, asScope(scope));
for (const entry of results) {
const matcherStr = entry.input.matcher ? ` [matcher: ${entry.input.matcher}]` : "";
if (entry.skipped) {
console.log(`already installed - skipped ${entry.provider} ${entry.input.event}${matcherStr}`);
continue;
}
installed += results.filter((r) => !r.skipped).length;
skipped += results.filter((r) => r.skipped).length;
console.log(`installed ${entry.provider} ${entry.input.event}${matcherStr} -> ${entry.writtenPath}`);
console.log(` command: ${entry.input.command}`);
}

const installed = results.filter((r) => !r.skipped).length;
const skipped = results.filter((r) => r.skipped).length;
console.log("");
console.log(`${installed} hook(s) installed${skipped > 0 ? `, ${skipped} skipped (already installed)` : ""}.`);
if (installed > 0 && providerList.includes("codex")) {
console.log("note (codex): approve the new hook(s) when prompted (trust review).");
}
}).pipe(Effect.provide(HookProviderRegistryDefault)),
).pipe(Command.withDescription("Install a SDK hook file into provider configs, or --all to install every scaffolded guard (--providers=claude,codex --scope=global --dir=~/.ax/hooks)"));
).pipe(Command.withDescription("Install a SDK hook file into provider configs, or --all to install the dispatcher (multiplexes all guards) + migrate off legacy per-guard entries (--providers=claude,codex --scope=global --dir=~/.ax/hooks)"));

const backtestCommand = Command.make(
"backtest",
Expand Down
100 changes: 100 additions & 0 deletions apps/axctl/src/hooks/dispatch-install.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { describe, expect, test } from "bun:test";
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Effect, Layer } from "effect";
import { BunFileSystem, BunPath } from "@effect/platform-bun";
import { SurrealClient } from "@ax/lib/db";
import { installDispatcher } from "./dispatch-install.ts";
import { HookProviderRegistryDefault } from "./providers/registry.ts";

// readAllHooks(withEvidence:false) returns before it ever touches SurrealClient,
// so the dispatcher install path needs no DB - a never-invoked stub satisfies
// the requirement type.
const stubDb = Layer.succeed(SurrealClient, {} as never);

const layers = Layer.mergeAll(
HookProviderRegistryDefault,
BunFileSystem.layer,
BunPath.layer,
stubDb,
);

const run = <A, E>(eff: Effect.Effect<A, E, any>): Promise<A> =>
Effect.runPromise(eff.pipe(Effect.provide(layers)) as Effect.Effect<A, never>);

describe("installDispatcher (integration, project scope, no DB)", () => {
test("registers the dispatcher, migrates the legacy ax guard, keeps the user hook", async () => {
const repo = mkdtempSync(join(tmpdir(), "ax-disp-"));
const hooksDir = `${repo}/.ax/hooks`;
const dispatchPath = `${hooksDir}/dispatch.ts`;
mkdirSync(join(repo, ".claude"), { recursive: true });

// Seed: one legacy ax-owned guard entry + one user-authored hook.
const settings = {
hooks: {
PreToolUse: [
{
matcher: "Write|Edit|MultiEdit|apply_patch",
hooks: [
{
type: "command",
command: `bun ${hooksDir}/enforce-worktree-write.ts # ax:legacy01`,
},
],
},
{
matcher: "Bash",
hooks: [{ type: "command", command: "echo user-owned-hook" }],
},
],
},
};
const settingsPath = join(repo, ".claude", "settings.json");
writeFileSync(settingsPath, JSON.stringify(settings, null, 2));

const result = await run(
installDispatcher(dispatchPath, hooksDir, ["claude"], "project", { repoRoot: repo }),
);

// The legacy ax guard was migrated; the user hook was not.
expect(result.removed.map((r) => r.command)).toContain(
`bun ${hooksDir}/enforce-worktree-write.ts # ax:legacy01`,
);

const after = readFileSync(settingsPath, "utf8");
// Dispatcher is now the command for the guarded events.
expect(after).toContain(`bun ${dispatchPath}`);
// Legacy per-guard command is gone.
expect(after).not.toContain("enforce-worktree-write.ts");
// User-authored hook survives.
expect(after).toContain("echo user-owned-hook");

// A dispatcher entry exists for PreToolUse (tool matcher) and SessionStart.
const installedEvents = result.entries
.filter((e) => !e.skipped)
.map((e) => e.input.event);
expect(installedEvents).toContain("PreToolUse");
expect(installedEvents).toContain("SessionStart");
});

test("re-running is idempotent (entries skipped, nothing left to migrate)", async () => {
const repo = mkdtempSync(join(tmpdir(), "ax-disp2-"));
const hooksDir = `${repo}/.ax/hooks`;
const dispatchPath = `${hooksDir}/dispatch.ts`;
mkdirSync(join(repo, ".claude"), { recursive: true });
writeFileSync(join(repo, ".claude", "settings.json"), "{}");

const first = await run(
installDispatcher(dispatchPath, hooksDir, ["claude"], "project", { repoRoot: repo }),
);
expect(first.entries.some((e) => !e.skipped)).toBe(true);

const second = await run(
installDispatcher(dispatchPath, hooksDir, ["claude"], "project", { repoRoot: repo }),
);
// Everything already present -> all skipped, nothing to remove.
expect(second.entries.every((e) => e.skipped)).toBe(true);
expect(second.removed).toEqual([]);
});
});
104 changes: 104 additions & 0 deletions apps/axctl/src/hooks/dispatch-install.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { describe, expect, test } from "bun:test";
import {
planDispatcherInstall,
legacyGuardCommands,
planLegacyRemoval,
type ConfiguredHookRow,
} from "./dispatch-install.ts";

const DIR = "/home/u/.ax/hooks";
const DISPATCH = `${DIR}/dispatch.ts`;

describe("planDispatcherInstall", () => {
test("fans the dispatcher across providers, all pointing at the dispatch entry", () => {
const plan = planDispatcherInstall(DISPATCH, ["claude", "codex"]);
expect(plan.length).toBeGreaterThan(0);
for (const e of plan) expect(e.input.command).toBe(`bun ${DISPATCH}`);
expect(new Set(plan.map((e) => e.provider))).toEqual(new Set(["claude", "codex"]));
});

test("PreToolUse carries a tool matcher; SessionStart has none", () => {
const plan = planDispatcherInstall(DISPATCH, ["claude"]);
const pre = plan.find((e) => e.input.event === "PreToolUse");
const session = plan.find((e) => e.input.event === "SessionStart");
expect(pre?.input.matcher ?? "").toContain("Edit");
expect(session?.input.matcher ?? null).toBeNull();
});
});

describe("legacyGuardCommands", () => {
test("covers .ts and .js for every guard in the dir", () => {
const cmds = legacyGuardCommands(DIR);
expect(cmds.has(`bun ${DIR}/enforce-worktree.ts`)).toBe(true);
expect(cmds.has(`bun ${DIR}/enforce-worktree.js`)).toBe(true);
expect(cmds.has(`bun ${DIR}/route-dispatch.js`)).toBe(true);
// not the dispatcher itself
expect(cmds.has(`bun ${DIR}/dispatch.ts`)).toBe(false);
});
});

const row = (over: Partial<ConfiguredHookRow>): ConfiguredHookRow => ({
provider: "claude",
scope: "global",
event: "PreToolUse",
command: `bun ${DIR}/enforce-worktree.ts`,
id: "h1",
axId: "abc123",
...over,
});

describe("planLegacyRemoval", () => {
test("removes ax-owned legacy per-guard entries", () => {
const out = planLegacyRemoval([row({})], DIR, ["claude"], "global");
expect(out.map((h) => h.id)).toEqual(["h1"]);
});

test("with a marker on the stored command", () => {
const out = planLegacyRemoval(
[row({ command: `bun ${DIR}/route-dispatch.js # ax:zzz`, id: "h2" })],
DIR,
["claude"],
"global",
);
expect(out.map((h) => h.id)).toEqual(["h2"]);
});

test("never removes a NON-ax (user-authored) hook, even on a colliding command", () => {
const out = planLegacyRemoval(
[row({ axId: undefined })],
DIR,
["claude"],
"global",
);
expect(out).toEqual([]);
});

test("never removes the dispatcher entry itself", () => {
const out = planLegacyRemoval(
[row({ command: `bun ${DIR}/dispatch.ts`, id: "d1" })],
DIR,
["claude"],
"global",
);
expect(out).toEqual([]);
});

test("skips other providers and other scopes", () => {
const rows = [
row({ provider: "cursor", id: "x" }),
row({ scope: "project", id: "y" }),
];
const out = planLegacyRemoval(rows, DIR, ["claude"], "global");
expect(out).toEqual([]);
});

test("skips legacy entries from a DIFFERENT workspace dir", () => {
const out = planLegacyRemoval(
[row({ command: `bun /other/dir/enforce-worktree.ts` })],
DIR,
["claude"],
"global",
);
expect(out).toEqual([]);
});
});
Loading
Loading