diff --git a/hooks/hook-spiral-detector/README.md b/hooks/hook-spiral-detector/README.md new file mode 100644 index 0000000..334b2e3 --- /dev/null +++ b/hooks/hook-spiral-detector/README.md @@ -0,0 +1,23 @@ +# hook-spiral-detector + +A PostToolUse hook that interrupts Claude Code after five consecutive Bash calls produce the same command, non-zero exit status, and first line of stderr. + +## Installation + +```bash +hooks install spiral-detector +``` + +## Behavior + +- Hashes the command and error signature; command text and stderr are not persisted +- Keeps streaks separate by session +- Resets the streak after a success, a different failure, or a non-Bash tool call +- Returns `{ "continue": false }` on the fifth identical failure, stopping the agent loop +- Fails open if input or state cannot be read or written + +The threshold is deliberately five: it permits a small number of legitimate retries while interrupting a repeated repair loop early. + +## Event + +- **PostToolUse** (all tools, so non-Bash calls can reset the streak) diff --git a/hooks/hook-spiral-detector/package.json b/hooks/hook-spiral-detector/package.json new file mode 100644 index 0000000..e257d8c --- /dev/null +++ b/hooks/hook-spiral-detector/package.json @@ -0,0 +1,50 @@ +{ + "name": "@hasna/hook-spiral-detector", + "version": "0.1.0", + "description": "Interrupts Claude Code sessions after repeated identical command failures", + "type": "module", + "main": "./dist/hook.js", + "exports": { + ".": { + "import": "./dist/hook.js", + "types": "./dist/hook.d.ts" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "bun build ./src/hook.ts --outdir ./dist --target node", + "prepublishOnly": "bun run build", + "test": "bun test", + "typecheck": "tsc --noEmit" + }, + "keywords": [ + "claude-code", + "claude", + "hook", + "spiral", + "failures", + "safety" + ], + "author": "Hasna", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/hasna/hooks.git" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "engines": { + "node": ">=18", + "bun": ">=1.0" + }, + "devDependencies": { + "@types/bun": "^1.3.8", + "@types/node": "^20", + "typescript": "^5.0.0" + } +} diff --git a/hooks/hook-spiral-detector/src/hook.test.ts b/hooks/hook-spiral-detector/src/hook.test.ts new file mode 100644 index 0000000..c62a3b5 --- /dev/null +++ b/hooks/hook-spiral-detector/src/hook.test.ts @@ -0,0 +1,72 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const homes: string[] = []; +const hookPath = join(import.meta.dir, "hook.ts"); + +async function invoke(home: string, input: object): Promise> { + const proc = Bun.spawn(["bun", "run", hookPath], { + stdin: new Response(JSON.stringify(input)), + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, HOME: home }, + }); + const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + expect(exitCode).toBe(0); + return JSON.parse(stdout); +} + +function input(session: string, command = "bun test", exitCode = 1): object { + return { + session_id: session, + tool_name: "Bash", + tool_input: { command }, + tool_response: { exit_code: exitCode, stderr: "tests failed\nmore details" }, + }; +} + +afterEach(() => { + for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true }); +}); + +describe("spiral detector", () => { + test("interrupts on the fifth identical red signature", async () => { + const home = mkdtempSync(join(tmpdir(), "spiral-detector-")); + homes.push(home); + for (let attempt = 1; attempt < 5; attempt++) { + expect(await invoke(home, input("session-1"))).toEqual({ continue: true }); + } + expect(await invoke(home, input("session-1"))).toMatchObject({ + continue: false, + stopReason: expect.stringContaining("after 5 identical command failures"), + }); + }); + + test("successes and changed failures reset the streak", async () => { + const home = mkdtempSync(join(tmpdir(), "spiral-detector-")); + homes.push(home); + for (let attempt = 0; attempt < 4; attempt++) await invoke(home, input("session-2")); + expect(await invoke(home, input("session-2", "bun test", 0))).toEqual({ continue: true }); + for (let attempt = 0; attempt < 4; attempt++) await invoke(home, input("session-2")); + expect(await invoke(home, input("session-2", "bun run typecheck"))).toEqual({ continue: true }); + for (let attempt = 1; attempt < 5; attempt++) { + expect(await invoke(home, input("session-2"))).toEqual({ continue: true }); + } + expect(await invoke(home, input("session-2"))).toMatchObject({ continue: false }); + }); + + test("supports the repository's legacy tool_output field", async () => { + const home = mkdtempSync(join(tmpdir(), "spiral-detector-")); + homes.push(home); + const legacy = { + session_id: "session-3", + tool_name: "Bash", + tool_input: { command: "npm test" }, + tool_output: { exitCode: "2", stderr: "same error" }, + }; + for (let attempt = 1; attempt < 5; attempt++) await invoke(home, legacy); + expect(await invoke(home, legacy)).toMatchObject({ continue: false }); + }); +}); diff --git a/hooks/hook-spiral-detector/src/hook.ts b/hooks/hook-spiral-detector/src/hook.ts new file mode 100644 index 0000000..6df3096 --- /dev/null +++ b/hooks/hook-spiral-detector/src/hook.ts @@ -0,0 +1,93 @@ +#!/usr/bin/env bun + +/** Interrupt a session after five consecutive identical Bash failures. */ + +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +interface HookInput { + session_id?: string; + tool_name?: string; + tool_input?: Record; + tool_response?: unknown; + tool_output?: unknown; +} + +interface State { + signature: string; + count: number; +} + +const THRESHOLD = 5; +const STATE_DIR = join(homedir(), ".hasna", "hooks", "state", "spiral-detector"); + +function hash(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function statePath(sessionId: string): string { + return join(STATE_DIR, `${hash(sessionId)}.json`); +} + +function readState(path: string): State { + try { + const state = JSON.parse(readFileSync(path, "utf8")) as State; + if (typeof state.signature === "string" && Number.isInteger(state.count) && state.count > 0) return state; + } catch {} + return { signature: "", count: 0 }; +} + +function clearState(path: string): void { + try { + if (existsSync(path)) unlinkSync(path); + } catch {} +} + +function redSignature(input: HookInput): string | null { + if (input.tool_name !== "Bash" || typeof input.tool_input?.command !== "string") return null; + const output = input.tool_response ?? input.tool_output; + if (!output || typeof output !== "object") return null; + const record = output as Record; + const rawCode = record.exit_code ?? record.exitCode ?? record.code; + const exitCode = typeof rawCode === "string" ? Number(rawCode) : rawCode; + if (typeof exitCode !== "number" || !Number.isFinite(exitCode) || exitCode === 0) return null; + const stderr = typeof record.stderr === "string" ? record.stderr : ""; + const firstLine = stderr.split(/\r?\n/, 1)[0] ?? ""; + return hash(`${hash(input.tool_input.command)}\0${exitCode}\0${firstLine}`); +} + +export function processInput(input: HookInput): { continue: boolean; stopReason?: string } { + if (!input.session_id) return { continue: true }; + const path = statePath(input.session_id); + const signature = redSignature(input); + if (!signature) { + clearState(path); + return { continue: true }; + } + + const previous = readState(path); + const count = previous.signature === signature ? previous.count + 1 : 1; + try { + mkdirSync(STATE_DIR, { recursive: true }); + writeFileSync(path, JSON.stringify({ signature, count })); + } catch { + return { continue: true }; + } + + return count >= THRESHOLD + ? { continue: false, stopReason: `Spiral detector interrupted the session after ${count} identical command failures. Change the command or underlying state before resuming.` } + : { continue: true }; +} + +export function run(): void { + try { + const input = JSON.parse(readFileSync(0, "utf8")) as HookInput; + console.log(JSON.stringify(processInput(input))); + } catch { + console.log(JSON.stringify({ continue: true })); + } +} + +if (import.meta.main) run(); diff --git a/hooks/hook-spiral-detector/tsconfig.json b/hooks/hook-spiral-detector/tsconfig.json new file mode 100644 index 0000000..7e079aa --- /dev/null +++ b/hooks/hook-spiral-detector/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "lib": ["ESNext"], + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "rootDir": "./src", + "types": ["bun-types"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/src/cli/cli.test.ts b/src/cli/cli.test.ts index acd8e8e..39391c9 100644 --- a/src/cli/cli.test.ts +++ b/src/cli/cli.test.ts @@ -1,10 +1,13 @@ -import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { describe, test, expect, afterAll } from "bun:test"; import { join } from "path"; -import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "fs"; -import { homedir, tmpdir } from "os"; +import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, mkdtempSync } from "fs"; +import { tmpdir } from "os"; const CLI = join(import.meta.dir, "index.tsx"); -const SETTINGS_PATH = join(homedir(), ".claude", "settings.json"); +const previousClaudeSettingsPath = process.env.HASNA_HOOKS_CLAUDE_SETTINGS_PATH; +const TEST_HOME = mkdtempSync(join(tmpdir(), "hooks-cli-home-")); +const SETTINGS_PATH = join(TEST_HOME, ".claude", "settings.json"); +process.env.HASNA_HOOKS_CLAUDE_SETTINGS_PATH = SETTINGS_PATH; let settingsBackup: string | null = null; @@ -29,7 +32,7 @@ async function run(...args: string[]): Promise<{ stdout: string; stderr: string; const proc = Bun.spawn(["bun", "run", CLI, ...args], { stdout: "pipe", stderr: "pipe", - env: { ...process.env, NO_COLOR: "1" }, + env: { ...process.env, HASNA_HOOKS_CLAUDE_SETTINGS_PATH: SETTINGS_PATH, NO_COLOR: "1" }, }); const [stdout, stderr] = await Promise.all([ new Response(proc.stdout).text(), @@ -39,6 +42,12 @@ async function run(...args: string[]): Promise<{ stdout: string; stderr: string; return { stdout, stderr, exitCode }; } +afterAll(() => { + if (previousClaudeSettingsPath === undefined) delete process.env.HASNA_HOOKS_CLAUDE_SETTINGS_PATH; + else process.env.HASNA_HOOKS_CLAUDE_SETTINGS_PATH = previousClaudeSettingsPath; + rmSync(TEST_HOME, { recursive: true, force: true }); +}); + async function runJson(...args: string[]): Promise { const { stdout } = await run(...args, "--json"); return JSON.parse(stdout.trim()); @@ -77,7 +86,7 @@ describe("CLI", () => { describe("hooks list", () => { test("lists all hooks", async () => { const { stdout } = await run("list"); - expect(stdout).toContain("Available hooks (48, showing 20)"); + expect(stdout).toContain("Available hooks (49, showing 20)"); expect(stdout).toContain("Git Safety"); expect(stdout).toContain("Code Quality"); expect(stdout).toContain("Security"); @@ -426,13 +435,13 @@ describe("CLI", () => { }); describe("hooks install --all (JSON)", () => { - test("--all --json attempts all 48 hooks and reports target-incompatible Codewith-only hooks", async () => { + test("--all --json attempts all 49 hooks and reports target-incompatible Codewith-only hooks", async () => { backupSettings(); try { const data = await runJson("install", "--all"); - expect(data.total).toBe(48); - expect(data.success).toBe(46); - expect(data.installed).toHaveLength(46); + expect(data.total).toBe(49); + expect(data.success).toBe(47); + expect(data.installed).toHaveLength(47); expect(data.failed.map((f: any) => f.hook)).toEqual(["knowledge-context", "prompt-guard"]); expect(data.scope).toBe("global"); } finally { @@ -735,7 +744,7 @@ describe("CLI", () => { backupSettings(); try { const install = await runJson("install", "--all"); - expect(install.success).toBe(46); + expect(install.success).toBe(47); const listed = await runJson("list", "--installed"); expect(listed.length).toBeGreaterThanOrEqual(30); @@ -751,7 +760,7 @@ describe("CLI", () => { } finally { restoreSettings(); } - }, 60_000); // 46 hooks × spawn per install/remove — needs more than the 5s default + }, 60_000); // 47 hooks × spawn per install/remove — needs more than the 5s default }); describe("hooks info --json for every hook", () => { diff --git a/src/index.test.ts b/src/index.test.ts index 817d534..7588a21 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -38,9 +38,9 @@ import { } from "./index.js"; describe("library exports", () => { - test("HOOKS is an array of 48 hooks", () => { + test("HOOKS is an array of 49 hooks", () => { expect(Array.isArray(HOOKS)).toBe(true); - expect(HOOKS).toHaveLength(48); + expect(HOOKS).toHaveLength(49); }); test("CATEGORIES is an array of 10 categories", () => { diff --git a/src/lib/installer.test.ts b/src/lib/installer.test.ts index 27e9ce7..4128f4c 100644 --- a/src/lib/installer.test.ts +++ b/src/lib/installer.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { describe, test, expect, beforeEach, afterEach, afterAll } from "bun:test"; import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, mkdtempSync } from "fs"; import { dirname, join } from "path"; import { homedir, tmpdir } from "os"; @@ -17,7 +17,10 @@ import { } from "./installer.js"; import { HOOKS, getHookEvents } from "./registry.js"; -const GLOBAL_SETTINGS = join(homedir(), ".claude", "settings.json"); +const previousClaudeSettingsPath = process.env.HASNA_HOOKS_CLAUDE_SETTINGS_PATH; +const TEST_HOME = mkdtempSync(join(tmpdir(), "hooks-installer-home-")); +const GLOBAL_SETTINGS = join(TEST_HOME, ".claude", "settings.json"); +process.env.HASNA_HOOKS_CLAUDE_SETTINGS_PATH = GLOBAL_SETTINGS; let settingsBackup: string | null = null; @@ -61,10 +64,16 @@ afterEach(() => { restoreSettings(); }); +afterAll(() => { + if (previousClaudeSettingsPath === undefined) delete process.env.HASNA_HOOKS_CLAUDE_SETTINGS_PATH; + else process.env.HASNA_HOOKS_CLAUDE_SETTINGS_PATH = previousClaudeSettingsPath; + rmSync(TEST_HOME, { recursive: true, force: true }); +}); + describe("installer", () => { describe("getSettingsPath", () => { - test("global returns ~/.claude/settings.json", () => { - expect(getSettingsPath("global")).toBe(join(homedir(), ".claude", "settings.json")); + test("global returns the configured Claude settings path", () => { + expect(getSettingsPath("global")).toBe(GLOBAL_SETTINGS); }); test("project returns .claude/settings.json in cwd", () => { @@ -117,9 +126,9 @@ describe("installer", () => { expect(hookExists("nonexistent")).toBe(false); }); - test("returns true for all 48 registered hooks", () => { + test("returns true for all 49 registered hooks", () => { const names = HOOKS.map((hook) => hook.name); - expect(names).toHaveLength(48); + expect(names).toHaveLength(49); for (const name of names) { expect(hookExists(name)).toBe(true); } @@ -355,10 +364,10 @@ describe("installer", () => { const allNames = HOOKS .filter((hook) => getHookEvents(hook).every((event) => isEventSupported(event, "claude"))) .map((hook) => hook.name); - expect(allNames).toHaveLength(46); + expect(allNames).toHaveLength(47); const results = installHooks(allNames); expect(results.every((r) => r.success)).toBe(true); - expect(getRegisteredHooks().length).toBeGreaterThanOrEqual(46); + expect(getRegisteredHooks().length).toBeGreaterThanOrEqual(47); for (const name of allNames) { expect(removeHook(name)).toBe(true); @@ -480,7 +489,7 @@ describe("installer", () => { describe("getSettingsPath default", () => { test("defaults to global when no argument", () => { - expect(getSettingsPath()).toBe(join(homedir(), ".claude", "settings.json")); + expect(getSettingsPath()).toBe(GLOBAL_SETTINGS); }); test("gemini global path", () => { diff --git a/src/lib/installer.ts b/src/lib/installer.ts index c81aa5f..ea749ac 100644 --- a/src/lib/installer.ts +++ b/src/lib/installer.ts @@ -92,6 +92,11 @@ function getTargetSettingsDir(target: SingleTarget): string { return ".claude"; } +function getJsonSettingsPathOverride(target: WritableJsonTarget): string | undefined { + if (target === "claude") return process.env.HASNA_HOOKS_CLAUDE_SETTINGS_PATH; + return process.env.HASNA_HOOKS_GEMINI_SETTINGS_PATH; +} + export interface InstallResult { hook: string; success: boolean; @@ -125,6 +130,9 @@ export function getSettingsPath(scope: Scope = "global", target: SingleTarget = if (target === "codewith" && process.env.HASNA_HOOKS_CODEWITH_CONFIG_PATH) { return process.env.HASNA_HOOKS_CODEWITH_CONFIG_PATH; } + if (scope === "global" && (target === "claude" || target === "gemini") && getJsonSettingsPathOverride(target)) { + return getJsonSettingsPathOverride(target)!; + } const dir = getTargetSettingsDir(target); if (scope === "project") { return target === "codewith" ? join(process.cwd(), dir, "config.toml") : join(process.cwd(), dir, "settings.json"); diff --git a/src/lib/registry.test.ts b/src/lib/registry.test.ts index 47a9eeb..eda6f5d 100644 --- a/src/lib/registry.test.ts +++ b/src/lib/registry.test.ts @@ -13,8 +13,8 @@ import { describe("registry", () => { describe("HOOKS", () => { - test("contains 48 hooks", () => { - expect(HOOKS).toHaveLength(48); + test("contains 49 hooks", () => { + expect(HOOKS).toHaveLength(49); }); test("every hook has required fields", () => { @@ -130,7 +130,8 @@ describe("registry", () => { test("returns Observability hooks", () => { const hooks = getHooksByCategory("Observability"); - expect(hooks).toHaveLength(4); + expect(hooks).toHaveLength(5); + expect(hooks.map((h) => h.name)).toContain("spiral-detector"); }); test("returns Agent Teams hooks", () => { @@ -315,7 +316,7 @@ describe("registry", () => { test("correct count per event type", () => { expect(HOOKS.filter((h) => h.event === "PreToolUse")).toHaveLength(15); - expect(HOOKS.filter((h) => h.event === "PostToolUse")).toHaveLength(15); + expect(HOOKS.filter((h) => h.event === "PostToolUse")).toHaveLength(16); expect(HOOKS.filter((h) => h.event === "Stop")).toHaveLength(9); expect(HOOKS.filter((h) => h.event === "Notification")).toHaveLength(3); expect(HOOKS.filter((h) => h.event === "SessionStart")).toHaveLength(5); diff --git a/src/lib/registry.ts b/src/lib/registry.ts index 5a1067a..42025c5 100644 --- a/src/lib/registry.ts +++ b/src/lib/registry.ts @@ -402,6 +402,16 @@ export const HOOKS: HookMeta[] = [ matcher: "", tags: ["errors", "failures", "logging", "debugging"], }, + { + name: "spiral-detector", + displayName: "Spiral Detector", + description: "Interrupts sessions after five consecutive identical command failures", + version: "0.1.0", + category: "Observability", + event: "PostToolUse", + matcher: "", + tags: ["spiral", "failures", "retries", "safety", "interrupt"], + }, // Code Quality { diff --git a/src/mcp/server.test.ts b/src/mcp/server.test.ts index b6ae471..919e09f 100644 --- a/src/mcp/server.test.ts +++ b/src/mcp/server.test.ts @@ -1,14 +1,17 @@ import { describe, test, expect, beforeEach, afterEach, beforeAll, afterAll } from "bun:test"; -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, mkdtempSync } from "fs"; import { join } from "path"; -import { homedir, tmpdir } from "os"; +import { tmpdir } from "os"; import { Client } from "@modelcontextprotocol/sdk/client"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { createHooksServer, MCP_PORT } from "./server.js"; import { closeDb, getDb } from "../db/index.js"; -const SETTINGS_PATH = join(homedir(), ".claude", "settings.json"); const TEST_PORT = 39428; +const previousClaudeSettingsPath = process.env.HASNA_HOOKS_CLAUDE_SETTINGS_PATH; +const TEST_HOME = mkdtempSync(join(tmpdir(), "hooks-mcp-home-")); +const SETTINGS_PATH = join(TEST_HOME, ".claude", "settings.json"); +process.env.HASNA_HOOKS_CLAUDE_SETTINGS_PATH = SETTINGS_PATH; let settingsBackup: string | null = null; @@ -96,6 +99,12 @@ function seedLogDb(rowCount: number, options: { withErrors?: boolean } = {}): () }; } +afterAll(() => { + if (previousClaudeSettingsPath === undefined) delete process.env.HASNA_HOOKS_CLAUDE_SETTINGS_PATH; + else process.env.HASNA_HOOKS_CLAUDE_SETTINGS_PATH = previousClaudeSettingsPath; + rmSync(TEST_HOME, { recursive: true, force: true }); +}); + describe("MCP server", () => { describe("constants", () => { test("MCP_PORT is 39427", () => { @@ -181,9 +190,9 @@ describe("MCP server", () => { test("hooks_list returns all hooks by category", async () => { const data = parseResult(await client.callTool({ name: "hooks_list", arguments: {} })); - expect(data.total).toBe(48); + expect(data.total).toBe(49); expect(data.count).toBe(25); - expect(data.omitted).toBe(23); + expect(data.omitted).toBe(24); expect(data.hooks[0]).toHaveProperty("name"); expect(data.hooks[0]).not.toHaveProperty("description"); expect(data.hint).toContain("compact:false"); @@ -346,9 +355,9 @@ describe("MCP server", () => { test("hooks_install_all installs default-compatible hooks", async () => { const data = parseResult(await client.callTool({ name: "hooks_install_all", arguments: {} })); - expect(data.total).toBe(48); - expect(data.success).toBe(46); - expect(data.installed).toHaveLength(46); + expect(data.total).toBe(49); + expect(data.success).toBe(47); + expect(data.installed).toHaveLength(47); expect(data.failed.map((f: any) => f.hook)).toEqual(["knowledge-context", "prompt-guard"]); }); @@ -522,7 +531,7 @@ describe("MCP server", () => { test("hooks_install_all with overwrite after install", async () => { await client.callTool({ name: "hooks_install_all", arguments: {} }); const data = parseResult(await client.callTool({ name: "hooks_install_all", arguments: { overwrite: true } })); - expect(data.success).toBe(46); + expect(data.success).toBe(47); }); // --- docs for every hook --- @@ -583,7 +592,7 @@ describe("MCP server", () => { test("install all compatible default hooks then remove a subset", async () => { const install = parseResult(await client.callTool({ name: "hooks_install_all", arguments: {} })); - expect(install.success).toBe(46); + expect(install.success).toBe(47); const allHooks = [ "gitguard", "branchprotect", "checkpoint", @@ -863,7 +872,7 @@ describe("MCP server", () => { test("hooks_list compact returns minimal fields", async () => { const data = parseResult(await client.callTool({ name: "hooks_list", arguments: { compact: true } })); - expect(data.total).toBe(48); + expect(data.total).toBe(49); expect(data.count).toBe(25); expect(data.hooks[0]).toHaveProperty("name"); expect(data.hooks[0]).toHaveProperty("event");