Skip to content
Closed
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
23 changes: 23 additions & 0 deletions hooks/hook-spiral-detector/README.md
Original file line number Diff line number Diff line change
@@ -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)
50 changes: 50 additions & 0 deletions hooks/hook-spiral-detector/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
72 changes: 72 additions & 0 deletions hooks/hook-spiral-detector/src/hook.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>> {
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 });
});
});
93 changes: 93 additions & 0 deletions hooks/hook-spiral-detector/src/hook.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<string, unknown>;
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();
25 changes: 25 additions & 0 deletions hooks/hook-spiral-detector/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"]
}
33 changes: 21 additions & 12 deletions src/cli/cli.test.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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(),
Expand All @@ -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<any> {
const { stdout } = await run(...args, "--json");
return JSON.parse(stdout.trim());
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand All @@ -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", () => {
Expand Down
4 changes: 2 additions & 2 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading
Loading