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
17 changes: 13 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -392,10 +392,19 @@ warn / inject; defects fail OPEN. `GitEnv` service makes guards layer-testable.
read `readEnv(event, NAME) = event.env?.[NAME] ?? process.env[NAME]` for
bypass flags (`ALLOW_MAIN_WRITE`/`ALLOW_BRANCH_CHECKOUT`/
`ALLOW_DIRTY_MAIN_MUTATION`) + `AX_SPEND_MODE` - additive, so the spawned path
is unchanged. The endpoint is INERT until the shim ships: stage 3b adds the
effect-free bun shim (POST daemon-first, lazy-import the bundle on fallback) +
an opt-in install that swaps `bun dispatch.js` for the shim. Verified via
`handleDashboardRequest` (no live serve needed).
is unchanged.
- **Daemon shim** (`ax hooks install --all --daemon`, OPT-IN; default stays the
direct dispatcher): an EFFECT-FREE bun shim (`@ax/hooks-sdk/shim-core`
`runShim`, scaffolded `dispatch-shim.{ts,js}` via `SHIM_NAME`) that forwards
`_ax_env` and POSTs to `/hooks/eval` (fast path, no effect parse), and on a
daemon-down/timeout/non-2xx LAZY-imports the sibling dispatch bundle
(`runDispatchFromStdin`) so guards still fire offline. The install swaps the
dispatcher command for the shim and removes the other dispatcher-family entry
(`dispatcherFamilyCommands` + `keepCommand` in `planLegacyRemoval`) so a
dispatch<->shim switch never double-fires. Pure bits (`withForwardedEnv`,
`planLegacyRemoval`) unit-tested; the full round-trip is **live-verified**
against a source-booted `ax serve` (daemon up -> warm verdict + forwarded
bypass honored; daemon killed -> fallback still blocks).
- `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]` -
Expand Down
26 changes: 18 additions & 8 deletions apps/axctl/src/hooks/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
import type { HookScope } from "./providers/types.ts";
import { HookNotFoundError } from "./errors.ts";
import { installHookFile, stripAxMarker } from "./sdk-install.ts";
import { installDispatcher, resolveDispatcherPath } from "./dispatch-install.ts";
import { installDispatcher, resolveDispatcherPath, resolveShimPath } 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 @@ -235,9 +235,10 @@ const installCommand = Command.make(
providers: Flag.string("providers").pipe(Flag.withDefault("claude,codex")),
scope: Flag.string("scope").pipe(Flag.withDefault("global")),
all: Flag.boolean("all").pipe(Flag.withDefault(false)),
daemon: Flag.boolean("daemon").pipe(Flag.withDefault(false)),
dir: Flag.string("dir").pipe(Flag.withDefault("~/.ax/hooks")),
},
({ file, providers, scope, all, dir }) =>
({ file, providers, scope, all, daemon, dir }) =>
Effect.gen(function* () {
// Works on both source (.ts against the @ax/hooks-sdk workspace) and a
// compiled binary (the standalone .js bundles `ax hooks init` wrote
Expand All @@ -259,22 +260,31 @@ const installCommand = Command.make(
// install the single positional file.
if (all) {
const workspaceDir = expandTilde(dir);
const dispatchPath = yield* resolveDispatcherPath(workspaceDir);
if (dispatchPath === null) {
// --daemon installs the shim (POST to `ax serve`, fall back to the
// bundle); default installs the dispatcher directly.
const commandPath = daemon
? yield* resolveShimPath(workspaceDir)
: yield* resolveDispatcherPath(workspaceDir);
if (commandPath === null) {
const missing = daemon ? "dispatch-shim.ts/.js" : "dispatch.ts/.js";
console.error(
`no dispatcher found in ${workspaceDir} (dispatch.ts/.js). Run 'ax hooks init' first to scaffold it.`,
`no ${daemon ? "shim" : "dispatcher"} found in ${workspaceDir} (${missing}). Run 'ax hooks init' first to scaffold it.`,
);
process.exit(1);
}
const { entries, removed } = yield* installDispatcher(
dispatchPath,
commandPath,
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)`);
console.log(
daemon
? `daemon shim: ${path.basename(commandPath)} (POST /hooks/eval, falls back to the bundle)`
: `dispatcher: ${path.basename(commandPath)} (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}`);
Expand Down Expand Up @@ -321,7 +331,7 @@ const installCommand = Command.make(
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 the dispatcher (multiplexes all guards) + migrate off legacy per-guard entries (--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; add --daemon to install the warm daemon shim instead (--providers=claude,codex --scope=global --dir=~/.ax/hooks)"));

const backtestCommand = Command.make(
"backtest",
Expand Down
28 changes: 26 additions & 2 deletions apps/axctl/src/hooks/dispatch-install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,16 +73,40 @@ describe("planLegacyRemoval", () => {
expect(out).toEqual([]);
});

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

test("dispatch->shim switch removes the old dispatcher entry", () => {
const out = planLegacyRemoval(
[row({ command: `bun ${DIR}/dispatch.ts`, id: "d1" })],
DIR,
["claude"],
"global",
`bun ${DIR}/dispatch-shim.ts`, // installing the shim now
);
expect(out.map((h) => h.id)).toEqual(["d1"]);
});

test("shim->dispatch switch removes the old shim entry", () => {
const out = planLegacyRemoval(
[row({ command: `bun ${DIR}/dispatch-shim.js # ax:s1`, id: "s1" })],
DIR,
["claude"],
"global",
`bun ${DIR}/dispatch.js`, // installing the dispatcher now
);
expect(out.map((h) => h.id)).toEqual(["s1"]);
});

test("skips other providers and other scopes", () => {
const rows = [
row({ provider: "cursor", id: "x" }),
Expand Down
62 changes: 52 additions & 10 deletions apps/axctl/src/hooks/dispatch-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { PlatformError } from "effect/PlatformError";
import type { DbError } from "@ax/lib/errors";
import type { SurrealClient } from "@ax/lib/db";
import { dispatchInstallPlan } from "@ax/hooks-sdk/dispatch";
import { GUARD_NAMES, DISPATCHER_NAME } from "./guard-names.ts";
import { GUARD_NAMES, DISPATCHER_NAME, SHIM_NAME } from "./guard-names.ts";
import { addHook, readAllHooks, removeHook } from "./config.ts";
import { HookProviderRegistry } from "./providers/registry.ts";
import {
Expand Down Expand Up @@ -62,6 +62,18 @@ export const legacyGuardCommands = (dir: string): ReadonlySet<string> => {
return cmds;
};

/** Commands in the dispatcher "family" - the dispatcher AND the shim, either
* extension. Switching between `--all` (dispatch) and `--all --daemon` (shim)
* must remove the OTHER family entry so they don't double-fire. */
export const dispatcherFamilyCommands = (dir: string): ReadonlySet<string> => {
const cmds = new Set<string>();
for (const name of [DISPATCHER_NAME, SHIM_NAME]) {
cmds.add(`bun ${dir}/${name}.ts`);
cmds.add(`bun ${dir}/${name}.js`);
}
return cmds;
};

/** A configured hook row, narrowed to the fields migration needs. */
export interface ConfiguredHookRow {
readonly provider: string;
Expand All @@ -75,25 +87,33 @@ export interface ConfiguredHookRow {
}

/**
* Pure: which existing entries to remove when migrating to the dispatcher. ONLY
* ax-owned rows (an `axId` marker) whose marker-stripped command is a legacy
* per-guard command for THIS dir, on a target provider + scope. A user's own
* hand-written hook (no axId) is never touched, even if its command collides.
* Pure: which existing entries to remove when (re)pointing the dispatcher. ONLY
* ax-owned rows (an `axId` marker), on a target provider + scope, whose
* marker-stripped command is either a legacy per-guard command OR a
* dispatcher-family command (dispatch / dispatch-shim) for THIS dir - EXCEPT the
* `keepCommand` being installed now. So a fresh guard->dispatcher migration AND
* a dispatch<->shim switch both clean up, while re-running the same install is a
* no-op. A user's own hand-written hook (no axId) is never touched.
*/
export const planLegacyRemoval = (
existing: ReadonlyArray<ConfiguredHookRow>,
dir: string,
providers: ReadonlyArray<string>,
scope: string,
keepCommand?: string,
): ConfiguredHookRow[] => {
const legacy = legacyGuardCommands(dir);
const removable = new Set<string>([
...legacyGuardCommands(dir),
...dispatcherFamilyCommands(dir),
]);
if (keepCommand) removable.delete(keepCommand);
return existing.filter(
(h) =>
h.axId !== undefined &&
h.axId !== null &&
providers.includes(h.provider) &&
h.scope === scope &&
legacy.has(stripAxMarker(h.command)),
removable.has(stripAxMarker(h.command)),
);
};

Expand All @@ -116,6 +136,21 @@ export const resolveDispatcherPath = (
return null;
});

/** The scaffolded daemon shim in `dir`: prefer `dispatch-shim.ts`, else `.js`,
* else null. */
export const resolveShimPath = (
dir: string,
): Effect.Effect<string | null, PlatformError, FileSystem.FileSystem | Path.Path> =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const pathSvc = yield* Path.Path;
const tsPath = pathSvc.join(dir, `${SHIM_NAME}.ts`);
if (yield* fs.exists(tsPath)) return tsPath;
const jsPath = pathSvc.join(dir, `${SHIM_NAME}.js`);
if (yield* fs.exists(jsPath)) return jsPath;
return null;
});

// ---------------------------------------------------------------------------
// installDispatcher (Effect: config + DB)
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -167,9 +202,16 @@ export const installDispatcher = (
entries.push({ ...entry, writtenPath });
}

// Migration: drop ax-owned legacy per-guard entries (the dispatcher now
// covers them; leaving them would double-fire each guard).
const toRemove = planLegacyRemoval(existing as ReadonlyArray<ConfiguredHookRow>, dir, providers, scope);
// Migration: drop ax-owned legacy per-guard entries AND the other
// dispatcher-family command (so a dispatch<->shim switch doesn't
// double-fire); never the one just installed.
const toRemove = planLegacyRemoval(
existing as ReadonlyArray<ConfiguredHookRow>,
dir,
providers,
scope,
`bun ${dispatchPath}`,
);
const removed: ConfiguredHookRow[] = [];
for (const h of toRemove) {
yield* removeHook({ provider: h.provider, scope, file: h.file, id: h.id, repoRoot: opts.repoRoot });
Expand Down
13 changes: 13 additions & 0 deletions apps/axctl/src/hooks/guard-names.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,16 @@ export const DISPATCHER_NAME = "dispatch";

export const dispatcherScaffoldContent = (): string =>
`import { runDispatchMain } from "@ax/hooks-sdk/dispatch";\n\nif (import.meta.main) void runDispatchMain();\n`;

/**
* The daemon-first shim (`ax hooks install --all --daemon`). POSTs the event to
* the running `ax serve` `/hooks/eval` (warm) and applies its outcome; falls
* back to the sibling dispatch bundle when the daemon is down. Effect-free on
* the fast path. `dispatchExt` is the sibling dispatcher's extension - `ts` for
* the source scaffold, `js` for the embedded binary bundle - so the runtime
* fallback import resolves the right file next to the shim.
*/
export const SHIM_NAME = "dispatch-shim";

export const shimScaffoldContent = (dispatchExt: "ts" | "js"): string =>
`import { runShim } from "@ax/hooks-sdk/shim-core";\n\nif (import.meta.main) void runShim({ fallbackUrl: new URL("./${DISPATCHER_NAME}.${dispatchExt}", import.meta.url) });\n`;
14 changes: 12 additions & 2 deletions apps/axctl/src/hooks/sdk-workspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,15 @@ describe("scaffoldWorkspace", () => {
expect(dispatchContent).toContain('@ax/hooks-sdk/dispatch');
expect(dispatchContent).toContain('runDispatchMain');

// The daemon shim is scaffolded too, with a .ts sibling fallback.
const shimPath = join(dir, "dispatch-shim.ts");
expect(existsSync(shimPath)).toBe(true);
expect(written).toContain(shimPath);
const shimContent = readFileSync(shimPath, "utf8");
expect(shimContent).toContain('@ax/hooks-sdk/shim-core');
expect(shimContent).toContain('runShim');
expect(shimContent).toContain('./dispatch.ts');

// All hook files should be in the written list
expect(written).toContain(ewPath);
expect(written).toContain(ewwPath);
Expand Down Expand Up @@ -130,8 +139,9 @@ describe("scaffoldWorkspace", () => {
expect(written).toContain(join(dir, "route-dispatch.ts"));
expect(written).toContain(join(dir, "refresh-quota.ts"));
expect(written).toContain(join(dir, "dispatch.ts"));
// package.json + 4 guards + the dispatcher entry.
expect(written.length).toBe(6);
expect(written).toContain(join(dir, "dispatch-shim.ts"));
// package.json + 4 guards + the dispatcher + the daemon shim.
expect(written.length).toBe(7);
});

test("creates dir if it does not exist", async () => {
Expand Down
10 changes: 10 additions & 0 deletions apps/axctl/src/hooks/sdk-workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
starterHookContent,
DISPATCHER_NAME,
dispatcherScaffoldContent,
SHIM_NAME,
shimScaffoldContent,
} from "./guard-names.ts";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -173,6 +175,14 @@ export const scaffoldWorkspace = (
written.push(dispatchPath);
}

// 3c. The daemon-first shim (opt-in via `--daemon`); falls back to the
// sibling dispatch.ts. Source scaffold -> .ts sibling.
const shimPath = pathSvc.join(opts.dir, `${SHIM_NAME}.ts`);
if (!(yield* fs.exists(shimPath))) {
yield* writeFileAtomic(shimPath, shimScaffoldContent("ts"), { backup: false });
written.push(shimPath);
}

// 4. bun install
if (opts.install) {
yield* Effect.tryPromise({
Expand Down
1 change: 1 addition & 0 deletions apps/site/app/routes/docs/-cli-reference.data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,7 @@ correction-loop 2 sessions flaky integration test`,
flags: [
{ flag: "--providers=claude,codex", desc: "(install) fan-out targets" },
{ flag: "--all", desc: "(install) register the dispatcher (all guards, one spawn) + migrate legacy entries" },
{ flag: "--daemon", desc: "(install --all) install the warm daemon shim instead (POST /hooks/eval, falls back to the bundle)" },
{ flag: "--dir=~/.ax/hooks", desc: "(install --all) workspace holding the dispatcher" },
{ flag: "--days=N", desc: "(backtest) replay window (default 30)" },
{ flag: "--scope=global|project|local", desc: "(install/add) where the hook lives" },
Expand Down
25 changes: 19 additions & 6 deletions packages/hooks-sdk/src/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,14 +92,16 @@ export const dispatchInstallPlan = (
* the whole guard set in one spawn. Reads stdin, runs the guards against the
* agent's own env + live git, emits the merged outcome, exits.
*/
export const runDispatchMain = async (
/**
* Run the dispatcher against an ALREADY-READ stdin string, emit the merged
* outcome, exit. This is the daemon shim's fallback entry: when the daemon is
* down the shim has already consumed stdin, so it hands the text here rather
* than re-reading it. Pulls effect/GitEnvLive (the slow path) on purpose.
*/
export const runDispatchFromStdin = async (
stdinText: string,
guards: ReadonlyArray<HookDefinition> = ALL_GUARDS,
): Promise<void> => {
if (process.stdin.isTTY) {
process.stderr.write("ax hook dispatcher expects JSON on stdin (see @ax/hooks-sdk)\n");
process.exit(0);
}
const stdinText = await Bun.stdin.text();
const outcome = await Effect.runPromise(
dispatchEvent(stdinText, process.env as Record<string, string | undefined>, guards).pipe(
Effect.provide(GitEnvLive),
Expand All @@ -110,4 +112,15 @@ export const runDispatchMain = async (
process.exit(outcome.exitCode);
};

export const runDispatchMain = async (
guards: ReadonlyArray<HookDefinition> = ALL_GUARDS,
): Promise<void> => {
if (process.stdin.isTTY) {
process.stderr.write("ax hook dispatcher expects JSON on stdin (see @ax/hooks-sdk)\n");
process.exit(0);
}
const stdinText = await Bun.stdin.text();
await runDispatchFromStdin(stdinText, guards);
};

if (import.meta.main) void runDispatchMain();
10 changes: 10 additions & 0 deletions packages/hooks-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,18 @@ export {
dispatchEvent,
dispatchInstallPlan,
runDispatchMain,
runDispatchFromStdin,
type DispatchInstallEntry,
} from "./dispatch.ts";
export {
FORWARDED_ENV_KEYS,
withForwardedEnv,
isDaemonOutcome,
hookEvalUrl,
runShim,
type DaemonOutcome,
type RunShimOptions,
} from "./shim-core.ts";
export { Verdict } from "./verdict.ts";
export { readEnv } from "./event.ts";
export type { Harness, HookEvent, HookEventName } from "./event.ts";
Expand Down
Loading
Loading