@hasna/hooks 0.6.8 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +1 -1
  2. package/bin/index.js +579 -113
  3. package/bin/serve.js +503 -38
  4. package/dist/config.d.ts +7 -5
  5. package/dist/db/pg-migrations.d.ts +1 -1
  6. package/dist/index.js +533 -71
  7. package/dist/openapi.d.ts +216 -0
  8. package/hooks/codewith-native-common.test.ts +25 -25
  9. package/hooks/codewith-native-common.ts +1 -1
  10. package/hooks/hook-autoformat/package.json +1 -1
  11. package/hooks/hook-desktopnotify/package.json +1 -1
  12. package/hooks/hook-envsetup/package.json +1 -1
  13. package/hooks/hook-knowledge-context/README.md +1 -1
  14. package/hooks/hook-knowledge-context/src/hook.test.ts +1 -1
  15. package/hooks/hook-permissionguard/package.json +1 -1
  16. package/hooks/hook-protectfiles/package.json +1 -1
  17. package/hooks/hook-spiral-detector/LICENSE +191 -0
  18. package/hooks/hook-spiral-detector/README.md +23 -0
  19. package/hooks/hook-spiral-detector/package.json +12 -0
  20. package/hooks/hook-spiral-detector/src/hook.test.ts +72 -0
  21. package/hooks/hook-spiral-detector/src/hook.ts +93 -0
  22. package/hooks/hook-workspace-repos-guard/README.md +4 -3
  23. package/hooks/hook-workspace-repos-guard/src/hook.test.ts +7 -7
  24. package/hooks/hook-workspace-repos-guard/src/hook.ts +5 -3
  25. package/hooks/mention-context/src/hasna-mention-context.py +2 -2
  26. package/hooks/mention-context/src/hasna-mention-warm.py +1 -1
  27. package/hooks/pre-bash/README.md +1 -1
  28. package/hooks/prompt-guard/README.md +1 -1
  29. package/hooks/session-start/README.md +1 -1
  30. package/hooks/stop-sync/README.md +1 -1
  31. package/hooks/worktree-guard/README.md +1 -1
  32. package/package.json +10 -6
@@ -0,0 +1,23 @@
1
+ # hook-spiral-detector
2
+
3
+ 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.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ hooks install spiral-detector
9
+ ```
10
+
11
+ ## Behavior
12
+
13
+ - Hashes the command and error signature; command text and stderr are not persisted
14
+ - Keeps streaks separate by session
15
+ - Resets the streak after a success, a different failure, or a non-Bash tool call
16
+ - Returns `{ "continue": false }` on the fifth identical failure, stopping the agent loop
17
+ - Fails open if input or state cannot be read or written
18
+
19
+ The threshold is deliberately five: it permits a small number of legitimate retries while interrupting a repeated repair loop early.
20
+
21
+ ## Event
22
+
23
+ - **PostToolUse** (all tools, so non-Bash calls can reset the streak)
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "hook-spiral-detector",
3
+ "version": "0.1.0",
4
+ "description": "Interrupts Claude Code sessions after repeated identical command failures",
5
+ "type": "module",
6
+ "main": "./src/hook.ts",
7
+ "scripts": {
8
+ "typecheck": "tsc --noEmit"
9
+ },
10
+ "author": "Hasna",
11
+ "license": "Apache-2.0"
12
+ }
@@ -0,0 +1,72 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ const homes: string[] = [];
7
+ const hookPath = join(import.meta.dir, "hook.ts");
8
+
9
+ async function invoke(home: string, input: object): Promise<Record<string, unknown>> {
10
+ const proc = Bun.spawn(["bun", "run", hookPath], {
11
+ stdin: new Response(JSON.stringify(input)),
12
+ stdout: "pipe",
13
+ stderr: "pipe",
14
+ env: { ...process.env, HOME: home },
15
+ });
16
+ const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
17
+ expect(exitCode).toBe(0);
18
+ return JSON.parse(stdout);
19
+ }
20
+
21
+ function input(session: string, command = "bun test", exitCode = 1): object {
22
+ return {
23
+ session_id: session,
24
+ tool_name: "Bash",
25
+ tool_input: { command },
26
+ tool_response: { exit_code: exitCode, stderr: "tests failed\nmore details" },
27
+ };
28
+ }
29
+
30
+ afterEach(() => {
31
+ for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true });
32
+ });
33
+
34
+ describe("spiral detector", () => {
35
+ test("interrupts on the fifth identical red signature", async () => {
36
+ const home = mkdtempSync(join(tmpdir(), "spiral-detector-"));
37
+ homes.push(home);
38
+ for (let attempt = 1; attempt < 5; attempt++) {
39
+ expect(await invoke(home, input("session-1"))).toEqual({ continue: true });
40
+ }
41
+ expect(await invoke(home, input("session-1"))).toMatchObject({
42
+ continue: false,
43
+ stopReason: expect.stringContaining("after 5 identical command failures"),
44
+ });
45
+ });
46
+
47
+ test("successes and changed failures reset the streak", async () => {
48
+ const home = mkdtempSync(join(tmpdir(), "spiral-detector-"));
49
+ homes.push(home);
50
+ for (let attempt = 0; attempt < 4; attempt++) await invoke(home, input("session-2"));
51
+ expect(await invoke(home, input("session-2", "bun test", 0))).toEqual({ continue: true });
52
+ for (let attempt = 0; attempt < 4; attempt++) await invoke(home, input("session-2"));
53
+ expect(await invoke(home, input("session-2", "bun run typecheck"))).toEqual({ continue: true });
54
+ for (let attempt = 1; attempt < 5; attempt++) {
55
+ expect(await invoke(home, input("session-2"))).toEqual({ continue: true });
56
+ }
57
+ expect(await invoke(home, input("session-2"))).toMatchObject({ continue: false });
58
+ });
59
+
60
+ test("supports the repository's legacy tool_output field", async () => {
61
+ const home = mkdtempSync(join(tmpdir(), "spiral-detector-"));
62
+ homes.push(home);
63
+ const legacy = {
64
+ session_id: "session-3",
65
+ tool_name: "Bash",
66
+ tool_input: { command: "npm test" },
67
+ tool_output: { exitCode: "2", stderr: "same error" },
68
+ };
69
+ for (let attempt = 1; attempt < 5; attempt++) await invoke(home, legacy);
70
+ expect(await invoke(home, legacy)).toMatchObject({ continue: false });
71
+ });
72
+ });
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env bun
2
+
3
+ /** Interrupt a session after five consecutive identical Bash failures. */
4
+
5
+ import { createHash } from "node:crypto";
6
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { join } from "node:path";
9
+
10
+ interface HookInput {
11
+ session_id?: string;
12
+ tool_name?: string;
13
+ tool_input?: Record<string, unknown>;
14
+ tool_response?: unknown;
15
+ tool_output?: unknown;
16
+ }
17
+
18
+ interface State {
19
+ signature: string;
20
+ count: number;
21
+ }
22
+
23
+ const THRESHOLD = 5;
24
+ const STATE_DIR = join(homedir(), ".hasna", "hooks", "state", "spiral-detector");
25
+
26
+ function hash(value: string): string {
27
+ return createHash("sha256").update(value).digest("hex");
28
+ }
29
+
30
+ function statePath(sessionId: string): string {
31
+ return join(STATE_DIR, `${hash(sessionId)}.json`);
32
+ }
33
+
34
+ function readState(path: string): State {
35
+ try {
36
+ const state = JSON.parse(readFileSync(path, "utf8")) as State;
37
+ if (typeof state.signature === "string" && Number.isInteger(state.count) && state.count > 0) return state;
38
+ } catch {}
39
+ return { signature: "", count: 0 };
40
+ }
41
+
42
+ function clearState(path: string): void {
43
+ try {
44
+ if (existsSync(path)) unlinkSync(path);
45
+ } catch {}
46
+ }
47
+
48
+ function redSignature(input: HookInput): string | null {
49
+ if (input.tool_name !== "Bash" || typeof input.tool_input?.command !== "string") return null;
50
+ const output = input.tool_response ?? input.tool_output;
51
+ if (!output || typeof output !== "object") return null;
52
+ const record = output as Record<string, unknown>;
53
+ const rawCode = record.exit_code ?? record.exitCode ?? record.code;
54
+ const exitCode = typeof rawCode === "string" ? Number(rawCode) : rawCode;
55
+ if (typeof exitCode !== "number" || !Number.isFinite(exitCode) || exitCode === 0) return null;
56
+ const stderr = typeof record.stderr === "string" ? record.stderr : "";
57
+ const firstLine = stderr.split(/\r?\n/, 1)[0] ?? "";
58
+ return hash(`${hash(input.tool_input.command)}\0${exitCode}\0${firstLine}`);
59
+ }
60
+
61
+ export function processInput(input: HookInput): { continue: boolean; stopReason?: string } {
62
+ if (!input.session_id) return { continue: true };
63
+ const path = statePath(input.session_id);
64
+ const signature = redSignature(input);
65
+ if (!signature) {
66
+ clearState(path);
67
+ return { continue: true };
68
+ }
69
+
70
+ const previous = readState(path);
71
+ const count = previous.signature === signature ? previous.count + 1 : 1;
72
+ try {
73
+ mkdirSync(STATE_DIR, { recursive: true });
74
+ writeFileSync(path, JSON.stringify({ signature, count }));
75
+ } catch {
76
+ return { continue: true };
77
+ }
78
+
79
+ return count >= THRESHOLD
80
+ ? { continue: false, stopReason: `Spiral detector interrupted the session after ${count} identical command failures. Change the command or underlying state before resuming.` }
81
+ : { continue: true };
82
+ }
83
+
84
+ export function run(): void {
85
+ try {
86
+ const input = JSON.parse(readFileSync(0, "utf8")) as HookInput;
87
+ console.log(JSON.stringify(processInput(input)));
88
+ } catch {
89
+ console.log(JSON.stringify({ continue: true }));
90
+ }
91
+ }
92
+
93
+ if (import.meta.main) run();
@@ -35,9 +35,10 @@ it sits under `repos/`; parenthesized command groups
35
35
 
36
36
  ## Configuration
37
37
 
38
- Allowed orgs default to `hasna,hasnaxyz,hasna-internal,hasna-products` and
39
- are overridable with the `WORKSPACE_REPOS_GUARD_ORGS` env var
40
- (comma-separated). The home directory is resolved with `os.homedir()` —
38
+ Allowed orgs default to `hasna,hasnaxyz,hasna-products` and are overridable
39
+ with the `WORKSPACE_REPOS_GUARD_ORGS` env var (comma-separated). Private
40
+ workspace orgs must be added per-install via that var; they are never part
41
+ of the public default. The home directory is resolved with `os.homedir()` —
41
42
  never hardcoded.
42
43
 
43
44
  ## Failure mode
@@ -15,8 +15,8 @@ function isBlocked(result: { output: { decision?: string; reason?: string } }):
15
15
  return null;
16
16
  }describe("hook-workspace-repos-guard", () => {
17
17
  describe("resolveAllowedOrgs", () => {
18
- test("defaults to the four canonical GitHub orgs", () => {
19
- expect([...resolveAllowedOrgs({})].sort()).toEqual(["hasna", "hasna-internal", "hasna-products", "hasnaxyz"]);
18
+ test("defaults to the three public canonical GitHub orgs", () => {
19
+ expect([...resolveAllowedOrgs({})].sort()).toEqual(["hasna", "hasna-products", "hasnaxyz"]);
20
20
  });
21
21
 
22
22
  test("parses comma-separated env override, trimming whitespace and empties", () => {
@@ -31,9 +31,9 @@ function isBlocked(result: { output: { decision?: string; reason?: string } }):
31
31
  expect(isBlocked(result)).toBeNull();
32
32
  });
33
33
 
34
- test("Edit inside hasna-internal org continues", () => {
35
- const result = evaluate(preToolUse("Edit", { file_path: join(REPOS, "hasna-internal", "platform", "docs", "readme.md") }));
36
- expect(result.output.continue).toBe(true);
34
+ test("Edit inside a non-default org is blocked", () => {
35
+ const result = evaluate(preToolUse("Edit", { file_path: join(REPOS, "notanorg", "platform", "docs", "readme.md") }));
36
+ expect(isBlocked(result)).not.toBeNull();
37
37
  });
38
38
 
39
39
  test("MultiEdit with relative file_path resolves against cwd and continues inside allowed org", () => {
@@ -65,7 +65,7 @@ function isBlocked(result: { output: { decision?: string; reason?: string } }):
65
65
  `echo note > ${join(REPOS, "hasna", "apps", "scratch.md")}`,
66
66
  `touch ${join(REPOS, "hasna", "apps", "apps", "foo", "x.txt")}`,
67
67
  `git clone https://github.com/hasna/foo.git ${join(REPOS, "hasna", "foo")}`,
68
- `mkdir -p ${join(REPOS, "hasna-internal", "platform", "apps")}`,
68
+ `mkdir -p ${join(REPOS, "hasnaxyz", "iapp-probe", "docs")}`,
69
69
  ];
70
70
  for (const command of commands) {
71
71
  const result = evaluate(preToolUse("Bash", { command }));
@@ -77,7 +77,7 @@ function isBlocked(result: { output: { decision?: string; reason?: string } }):
77
77
  const commands = [
78
78
  `touch ~/workspace/repos/hasna/apps/foo.md`,
79
79
  `echo x > ~/workspace/repos/hasnaxyz/iapp-probe/notes.md`,
80
- `mkdir -p "${HOME}/workspace/repos/hasna-internal/platform/apps"`,
80
+ `mkdir -p "${HOME}/workspace/repos/hasnaxyz/iapp-probe/notes"`,
81
81
  `touch "$HOME"/workspace/repos/hasna/apps/x.txt`,
82
82
  `echo x > "\${HOME}"/workspace/repos/hasnaxyz/iapp-probe/notes.md`,
83
83
  ];
@@ -30,8 +30,10 @@
30
30
  * tools are inspected through their `*** Add File:` / `*** Update File:` /
31
31
  * `*** Delete File:` markers. Parenthesized command groups are unwrapped.
32
32
  *
33
- * Allowed orgs default to hasna,hasnaxyz,hasna-internal,hasna-products and are
34
- * overridable with the WORKSPACE_REPOS_GUARD_ORGS env var (comma-separated).
33
+ * Allowed orgs default to hasna,hasnaxyz,hasna-products and are overridable
34
+ * with the WORKSPACE_REPOS_GUARD_ORGS env var (comma-separated). Private
35
+ * workspace orgs must be added per-install via that var; they are never part
36
+ * of the public default.
35
37
  * Home is resolved with os.homedir(); never hardcoded. Fail-open on any parse
36
38
  * or evaluation error so a guard defect cannot wedge the agent.
37
39
  */
@@ -51,7 +53,7 @@ const FILE_WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"])
51
53
 
52
54
  const PATCH_TOOLS = new Set(["apply_patch", "ApplyPatch", "functions.apply_patch"]);
53
55
 
54
- const DEFAULT_ORGS = ["hasna", "hasnaxyz", "hasna-internal", "hasna-products"];
56
+ const DEFAULT_ORGS = ["hasna", "hasnaxyz", "hasna-products"];
55
57
 
56
58
  const RULE = "workspace-repos-guard";
57
59
 
@@ -721,7 +721,7 @@ def render_section(t):
721
721
  if proj.get("path"):
722
722
  L.append(f" {'':<11} path {sanitize(proj.get('path'), 120)}")
723
723
  if isinstance(dups, int) and dups > 1:
724
- # Real hazard, not trivia: `open-todos` exists as FIVE project rows
724
+ # Real hazard, not trivia: `todos` exists as FIVE project rows
725
725
  # with task counters 3/50/58/101/150 across three machine_ids, and
726
726
  # the BUG rows are spread over all of them. An agent that files
727
727
  # against the wrong row files into a fossil nobody reads.
@@ -732,7 +732,7 @@ def render_section(t):
732
732
 
733
733
  # --- last 3 bugs -------------------------------------------------------
734
734
  # Merged across every matching project row, because BUG rows are genuinely
735
- # spread across the duplicates: open-todos carries 58 across 5 rows. Scoping
735
+ # spread across the duplicates: todos carries 58 across 5 rows. Scoping
736
736
  # to the primary alone would hide real bugs. The scope is stated on the line
737
737
  # so the count is never read as belonging to the single project above.
738
738
  bugs = t.get("bugs") or []
@@ -340,7 +340,7 @@ def candidate_names(org, word):
340
340
  def warm_slow(org, word, tmpdir, projects_cache):
341
341
  """Resolve the todos project for a repo and collect its last 3 BUG rows.
342
342
 
343
- Duplicate project rows are the norm, not the exception: `open-todos` exists
343
+ Duplicate project rows are the norm, not the exception: `todos` exists
344
344
  FOUR times, with 3 / 101 / 58 / 150 task counters on three different
345
345
  machine_ids, and the BUG rows are spread across all four (2+7+14+34 = 57).
346
346
  Picking by name alone would silently return a 3-task fossil. So the primary
@@ -81,7 +81,7 @@ remote relative path cannot be resolved against the local working directory.
81
81
 
82
82
  ## Install for Codewith
83
83
 
84
- Prefer renderer-managed configuration through open-configs. @hasna/hooks can emit the TOML fragment:
84
+ Prefer renderer-managed configuration through configs. @hasna/hooks can emit the TOML fragment:
85
85
 
86
86
  ```bash
87
87
  hooks install pre-bash --target codewith
@@ -6,7 +6,7 @@ This hook is OSS-safe: optional Hasna CLIs are best-effort and missing CLIs fail
6
6
 
7
7
  ## Install for Codewith
8
8
 
9
- Prefer renderer-managed configuration through open-configs. @hasna/hooks can emit the TOML fragment:
9
+ Prefer renderer-managed configuration through configs. @hasna/hooks can emit the TOML fragment:
10
10
 
11
11
  ```bash
12
12
  hooks install prompt-guard --target codewith
@@ -6,7 +6,7 @@ This hook is OSS-safe: optional Hasna CLIs are best-effort and missing CLIs fail
6
6
 
7
7
  ## Install for Codewith
8
8
 
9
- Prefer renderer-managed configuration through open-configs. @hasna/hooks can emit the TOML fragment:
9
+ Prefer renderer-managed configuration through configs. @hasna/hooks can emit the TOML fragment:
10
10
 
11
11
  ```bash
12
12
  hooks install session-start --target codewith
@@ -6,7 +6,7 @@ This hook is OSS-safe: optional Hasna CLIs are best-effort and missing CLIs fail
6
6
 
7
7
  ## Install for Codewith
8
8
 
9
- Prefer renderer-managed configuration through open-configs. @hasna/hooks can emit the TOML fragment:
9
+ Prefer renderer-managed configuration through configs. @hasna/hooks can emit the TOML fragment:
10
10
 
11
11
  ```bash
12
12
  hooks install stop-sync --target codewith
@@ -61,7 +61,7 @@ from a guardrail by a cooperating agent, not a way to reach a shared checkout.
61
61
 
62
62
  ## Install for Codewith
63
63
 
64
- Prefer renderer-managed configuration through open-configs. @hasna/hooks can emit the TOML fragment:
64
+ Prefer renderer-managed configuration through configs. @hasna/hooks can emit the TOML fragment:
65
65
 
66
66
  ```bash
67
67
  hooks install worktree-guard --target codewith
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/hooks",
3
- "version": "0.6.8",
3
+ "version": "0.7.2",
4
4
  "description": "Open source hooks library for AI coding agents - Install safety, quality, and automation hooks with a single command",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,7 +30,9 @@
30
30
  "dashboard:dev": "cd dashboard && bun run dev",
31
31
  "dashboard:build": "cd dashboard && bun run build",
32
32
  "dashboard:preview": "cd dashboard && bun run preview",
33
- "postinstall": "mkdir -p $HOME/.hasna/hooks/profiles 2>/dev/null || true"
33
+ "postinstall": "mkdir -p $HOME/.hasna/hooks/profiles 2>/dev/null || true",
34
+ "scan:artifact": "bun scripts/artifact-scan.ts",
35
+ "prepack": "bun run build && bun run scan:artifact"
34
36
  },
35
37
  "keywords": [
36
38
  "claude-code",
@@ -49,11 +51,12 @@
49
51
  "@types/pg": "^8.15.6",
50
52
  "@types/react": "^18.2.0",
51
53
  "typescript": "^5",
52
- "bun-types": "1.3.14"
54
+ "bun-types": "1.3.14",
55
+ "@hasna/contracts": "0.13.3"
53
56
  },
54
57
  "dependencies": {
55
- "@hasna/events": "^0.1.6",
56
- "@hasna/secrets": "0.3.0",
58
+ "@hasna/events": "^0.1.16",
59
+ "@hasna/secrets": "0.3.4",
57
60
  "@modelcontextprotocol/sdk": "^1.26.0",
58
61
  "chalk": "^5.3.0",
59
62
  "commander": "^12.1.0",
@@ -64,7 +67,8 @@
64
67
  "ink-text-input": "^6.0.0",
65
68
  "pg": "^8.16.3",
66
69
  "react": "^18.2.0",
67
- "zod": "3.25.76"
70
+ "zod": "3.25.76",
71
+ "@hasna/contracts": "0.13.3"
68
72
  },
69
73
  "engines": {
70
74
  "bun": ">=1.0.0"