@hasna/hooks 0.0.6 → 0.1.0

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 (49) hide show
  1. package/.claude/settings.json +24 -0
  2. package/bin/index.js +758 -319
  3. package/dist/index.js +156 -1
  4. package/hooks/hook-autoformat/README.md +39 -0
  5. package/hooks/hook-autoformat/package.json +58 -0
  6. package/hooks/hook-autoformat/src/hook.ts +223 -0
  7. package/hooks/hook-autostage/README.md +70 -0
  8. package/hooks/hook-autostage/package.json +12 -0
  9. package/hooks/hook-autostage/src/hook.ts +167 -0
  10. package/hooks/hook-commandlog/README.md +45 -0
  11. package/hooks/hook-commandlog/package.json +12 -0
  12. package/hooks/hook-commandlog/src/hook.ts +92 -0
  13. package/hooks/hook-costwatch/README.md +61 -0
  14. package/hooks/hook-costwatch/package.json +12 -0
  15. package/hooks/hook-costwatch/src/hook.ts +178 -0
  16. package/hooks/hook-desktopnotify/README.md +50 -0
  17. package/hooks/hook-desktopnotify/package.json +57 -0
  18. package/hooks/hook-desktopnotify/src/hook.ts +112 -0
  19. package/hooks/hook-envsetup/README.md +40 -0
  20. package/hooks/hook-envsetup/package.json +58 -0
  21. package/hooks/hook-envsetup/src/hook.ts +197 -0
  22. package/hooks/hook-errornotify/README.md +66 -0
  23. package/hooks/hook-errornotify/package.json +12 -0
  24. package/hooks/hook-errornotify/src/hook.ts +197 -0
  25. package/hooks/hook-permissionguard/README.md +48 -0
  26. package/hooks/hook-permissionguard/package.json +58 -0
  27. package/hooks/hook-permissionguard/src/hook.ts +268 -0
  28. package/hooks/hook-promptguard/README.md +64 -0
  29. package/hooks/hook-promptguard/package.json +12 -0
  30. package/hooks/hook-promptguard/src/hook.ts +200 -0
  31. package/hooks/hook-protectfiles/README.md +62 -0
  32. package/hooks/hook-protectfiles/package.json +58 -0
  33. package/hooks/hook-protectfiles/src/hook.ts +267 -0
  34. package/hooks/hook-sessionlog/README.md +48 -0
  35. package/hooks/hook-sessionlog/package.json +12 -0
  36. package/hooks/hook-sessionlog/src/hook.ts +100 -0
  37. package/hooks/hook-slacknotify/README.md +62 -0
  38. package/hooks/hook-slacknotify/package.json +12 -0
  39. package/hooks/hook-slacknotify/src/hook.ts +146 -0
  40. package/hooks/hook-soundnotify/README.md +63 -0
  41. package/hooks/hook-soundnotify/package.json +12 -0
  42. package/hooks/hook-soundnotify/src/hook.ts +173 -0
  43. package/hooks/hook-taskgate/README.md +62 -0
  44. package/hooks/hook-taskgate/package.json +12 -0
  45. package/hooks/hook-taskgate/src/hook.ts +169 -0
  46. package/hooks/hook-tddguard/README.md +50 -0
  47. package/hooks/hook-tddguard/package.json +12 -0
  48. package/hooks/hook-tddguard/src/hook.ts +263 -0
  49. package/package.json +4 -3
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env bun
2
+
3
+ /**
4
+ * Claude Code Hook: slacknotify
5
+ *
6
+ * Stop hook that sends a Slack webhook notification when Claude Code
7
+ * finishes working in a project.
8
+ *
9
+ * Configuration:
10
+ * - Environment variable: SLACK_WEBHOOK_URL
11
+ * - Or ~/.claude/settings.json key: slackNotifyConfig.webhookUrl
12
+ */
13
+
14
+ import { readFileSync, existsSync } from "fs";
15
+ import { join } from "path";
16
+ import { homedir } from "os";
17
+
18
+ interface HookInput {
19
+ session_id: string;
20
+ cwd: string;
21
+ hook_event_name: string;
22
+ transcript_path?: string;
23
+ }
24
+
25
+ interface HookOutput {
26
+ continue: boolean;
27
+ }
28
+
29
+ interface SlackNotifyConfig {
30
+ webhookUrl?: string;
31
+ enabled?: boolean;
32
+ }
33
+
34
+ const CONFIG_KEY = "slackNotifyConfig";
35
+
36
+ function readStdinJson(): HookInput | null {
37
+ try {
38
+ const input = readFileSync(0, "utf-8").trim();
39
+ if (!input) return null;
40
+ return JSON.parse(input);
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ function respond(output: HookOutput): void {
47
+ console.log(JSON.stringify(output));
48
+ }
49
+
50
+ function getConfig(): SlackNotifyConfig {
51
+ // Try global settings
52
+ const settingsPath = join(homedir(), ".claude", "settings.json");
53
+ try {
54
+ if (existsSync(settingsPath)) {
55
+ const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
56
+ if (settings[CONFIG_KEY]) {
57
+ return settings[CONFIG_KEY] as SlackNotifyConfig;
58
+ }
59
+ }
60
+ } catch {
61
+ // Settings file unreadable, fall through
62
+ }
63
+ return {};
64
+ }
65
+
66
+ function getWebhookUrl(): string | null {
67
+ // Priority 1: environment variable
68
+ const envUrl = process.env.SLACK_WEBHOOK_URL;
69
+ if (envUrl) return envUrl;
70
+
71
+ // Priority 2: settings.json config
72
+ const config = getConfig();
73
+ if (config.webhookUrl) return config.webhookUrl;
74
+
75
+ return null;
76
+ }
77
+
78
+ async function sendSlackNotification(webhookUrl: string, cwd: string): Promise<void> {
79
+ const projectName = cwd.split("/").filter(Boolean).pop() || cwd;
80
+
81
+ const payload = {
82
+ text: `Claude Code finished in ${projectName}`,
83
+ blocks: [
84
+ {
85
+ type: "section",
86
+ text: {
87
+ type: "mrkdwn",
88
+ text: `*Claude Code* finished working in \`${cwd}\``,
89
+ },
90
+ },
91
+ ],
92
+ };
93
+
94
+ try {
95
+ const response = await fetch(webhookUrl, {
96
+ method: "POST",
97
+ headers: {
98
+ "Content-Type": "application/json",
99
+ },
100
+ body: JSON.stringify(payload),
101
+ });
102
+
103
+ if (!response.ok) {
104
+ console.error(
105
+ `[hook-slacknotify] Slack webhook returned ${response.status}: ${response.statusText}`
106
+ );
107
+ }
108
+ } catch (error) {
109
+ const errMsg = error instanceof Error ? error.message : String(error);
110
+ console.error(`[hook-slacknotify] Failed to send Slack notification: ${errMsg}`);
111
+ }
112
+ }
113
+
114
+ export async function run(): Promise<void> {
115
+ const input = readStdinJson();
116
+
117
+ if (!input) {
118
+ respond({ continue: true });
119
+ return;
120
+ }
121
+
122
+ // Check if hook is explicitly disabled
123
+ const config = getConfig();
124
+ if (config.enabled === false) {
125
+ respond({ continue: true });
126
+ return;
127
+ }
128
+
129
+ const webhookUrl = getWebhookUrl();
130
+
131
+ if (!webhookUrl) {
132
+ console.error(
133
+ "[hook-slacknotify] No Slack webhook URL configured. " +
134
+ "Set SLACK_WEBHOOK_URL env var or add slackNotifyConfig.webhookUrl to ~/.claude/settings.json"
135
+ );
136
+ respond({ continue: true });
137
+ return;
138
+ }
139
+
140
+ await sendSlackNotification(webhookUrl, input.cwd);
141
+ respond({ continue: true });
142
+ }
143
+
144
+ if (import.meta.main) {
145
+ run();
146
+ }
@@ -0,0 +1,63 @@
1
+ # hook-soundnotify
2
+
3
+ Claude Code hook that plays a sound when Claude finishes a session.
4
+
5
+ ## Event
6
+
7
+ **Stop** — fires when Claude's session ends.
8
+
9
+ ## What It Does
10
+
11
+ Plays a system sound to notify you that Claude has finished working. Useful when you tab away and want an audible alert.
12
+
13
+ ### Platform Support
14
+
15
+ | Platform | Player | Default Sound |
16
+ |----------|--------|---------------|
17
+ | macOS | `afplay` | `/System/Library/Sounds/Glass.aiff` |
18
+ | Linux | `paplay` / `aplay` | `/usr/share/sounds/freedesktop/stereo/complete.oga` |
19
+
20
+ ### Configuration
21
+
22
+ Set `HOOKS_SOUND_FILE` environment variable to use a custom sound:
23
+
24
+ ```bash
25
+ export HOOKS_SOUND_FILE="/path/to/your/sound.wav"
26
+ ```
27
+
28
+ ## Installation
29
+
30
+ Add to your `.claude/settings.json`:
31
+
32
+ ```json
33
+ {
34
+ "hooks": {
35
+ "Stop": [
36
+ {
37
+ "matcher": "",
38
+ "hooks": [
39
+ {
40
+ "type": "command",
41
+ "command": "bun run hooks/hook-soundnotify/src/hook.ts"
42
+ }
43
+ ]
44
+ }
45
+ ]
46
+ }
47
+ }
48
+ ```
49
+
50
+ ## Output
51
+
52
+ Always `{ "continue": true }` — sound plays asynchronously (fire-and-forget) and never blocks session exit.
53
+
54
+ ## How It Works
55
+
56
+ 1. Looks for a sound file (env var > platform default)
57
+ 2. Spawns the audio player as a detached child process
58
+ 3. Immediately returns without waiting for playback to finish
59
+ 4. The sound plays in the background after Claude exits
60
+
61
+ ## License
62
+
63
+ MIT
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "hook-soundnotify",
3
+ "version": "0.1.0",
4
+ "description": "Claude Code hook that plays a sound when Claude finishes",
5
+ "type": "module",
6
+ "main": "./src/hook.ts",
7
+ "scripts": {
8
+ "typecheck": "tsc --noEmit"
9
+ },
10
+ "author": "Hasna",
11
+ "license": "MIT"
12
+ }
@@ -0,0 +1,173 @@
1
+ #!/usr/bin/env bun
2
+
3
+ /**
4
+ * Claude Code Hook: soundnotify
5
+ *
6
+ * Stop hook that plays a system sound when Claude finishes a session.
7
+ *
8
+ * Platform support:
9
+ * - macOS: afplay (built-in)
10
+ * - Linux: paplay (PulseAudio) or aplay (ALSA) fallback
11
+ *
12
+ * Configuration:
13
+ * - HOOKS_SOUND_FILE env var: path to a custom sound file
14
+ *
15
+ * Runs async (fire-and-forget) — does not block session exit.
16
+ * Always outputs { continue: true }.
17
+ */
18
+
19
+ import { readFileSync, existsSync } from "fs";
20
+ import { spawn } from "child_process";
21
+ import { platform } from "os";
22
+
23
+ interface HookInput {
24
+ session_id: string;
25
+ cwd: string;
26
+ }
27
+
28
+ interface HookOutput {
29
+ continue: true;
30
+ }
31
+
32
+ /**
33
+ * Default sound files per platform
34
+ */
35
+ const DEFAULT_SOUNDS: Record<string, string> = {
36
+ darwin: "/System/Library/Sounds/Glass.aiff",
37
+ linux: "/usr/share/sounds/freedesktop/stereo/complete.oga",
38
+ };
39
+
40
+ /**
41
+ * Fallback sound files for Linux
42
+ */
43
+ const LINUX_FALLBACK_SOUNDS: string[] = [
44
+ "/usr/share/sounds/freedesktop/stereo/complete.oga",
45
+ "/usr/share/sounds/freedesktop/stereo/bell.oga",
46
+ "/usr/share/sounds/freedesktop/stereo/message.oga",
47
+ ];
48
+
49
+ /**
50
+ * Read and parse JSON from stdin
51
+ */
52
+ function readStdinJson(): HookInput | null {
53
+ try {
54
+ const input = readFileSync(0, "utf-8").trim();
55
+ if (!input) return null;
56
+ return JSON.parse(input);
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Find an available sound file
64
+ */
65
+ function findSoundFile(): string | null {
66
+ // Check env var first
67
+ const envSound = process.env.HOOKS_SOUND_FILE;
68
+ if (envSound && existsSync(envSound)) {
69
+ return envSound;
70
+ }
71
+
72
+ const os = platform();
73
+
74
+ if (os === "darwin") {
75
+ const defaultSound = DEFAULT_SOUNDS.darwin;
76
+ if (existsSync(defaultSound)) {
77
+ return defaultSound;
78
+ }
79
+ return null;
80
+ }
81
+
82
+ if (os === "linux") {
83
+ for (const sound of LINUX_FALLBACK_SOUNDS) {
84
+ if (existsSync(sound)) {
85
+ return sound;
86
+ }
87
+ }
88
+ return null;
89
+ }
90
+
91
+ return null;
92
+ }
93
+
94
+ /**
95
+ * Get the appropriate audio player command for the current platform
96
+ */
97
+ function getPlayerCommand(soundFile: string): { cmd: string; args: string[] } | null {
98
+ const os = platform();
99
+
100
+ if (os === "darwin") {
101
+ return { cmd: "afplay", args: [soundFile] };
102
+ }
103
+
104
+ if (os === "linux") {
105
+ // Prefer paplay for PulseAudio, fallback to aplay for ALSA
106
+ if (soundFile.endsWith(".oga") || soundFile.endsWith(".ogg")) {
107
+ return { cmd: "paplay", args: [soundFile] };
108
+ }
109
+ return { cmd: "aplay", args: [soundFile] };
110
+ }
111
+
112
+ return null;
113
+ }
114
+
115
+ /**
116
+ * Play the sound asynchronously (fire-and-forget)
117
+ */
118
+ function playSound(soundFile: string): void {
119
+ const player = getPlayerCommand(soundFile);
120
+ if (!player) {
121
+ console.error(`[hook-soundnotify] No audio player available for ${platform()}`);
122
+ return;
123
+ }
124
+
125
+ try {
126
+ const child = spawn(player.cmd, player.args, {
127
+ stdio: "ignore",
128
+ detached: true,
129
+ });
130
+
131
+ // Unref so the process doesn't keep the parent alive
132
+ child.unref();
133
+
134
+ console.error(`[hook-soundnotify] Playing: ${soundFile}`);
135
+ } catch (error) {
136
+ const errMsg = error instanceof Error ? error.message : String(error);
137
+ console.error(`[hook-soundnotify] Failed to play sound: ${errMsg}`);
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Output hook response
143
+ */
144
+ function respond(): void {
145
+ const output: HookOutput = { continue: true };
146
+ console.log(JSON.stringify(output));
147
+ }
148
+
149
+ /**
150
+ * Main hook execution
151
+ */
152
+ export function run(): void {
153
+ // Read stdin (we don't really need the input, but follow the protocol)
154
+ readStdinJson();
155
+
156
+ const soundFile = findSoundFile();
157
+
158
+ if (!soundFile) {
159
+ console.error("[hook-soundnotify] No sound file found, skipping");
160
+ respond();
161
+ return;
162
+ }
163
+
164
+ // Fire-and-forget — play sound async
165
+ playSound(soundFile);
166
+
167
+ // Always continue
168
+ respond();
169
+ }
170
+
171
+ if (import.meta.main) {
172
+ run();
173
+ }
@@ -0,0 +1,62 @@
1
+ # hook-taskgate
2
+
3
+ Claude Code hook that validates task completion before allowing a task to be marked done.
4
+
5
+ ## Event
6
+
7
+ **TaskCompleted** — fires when Claude attempts to mark a task as complete.
8
+
9
+ ## What It Does
10
+
11
+ A lightweight gate that checks whether a task is actually done:
12
+
13
+ - **Task mentions "test" or "tests"** — verifies that test files exist in the project (looks for `*.test.*`, `*.spec.*`, `test_*`, `*_test.*`, or `test/`/`tests/`/`__tests__/` directories)
14
+ - **Task mentions "lint" or "format"** — approves (cannot verify externally)
15
+ - **All other tasks** — approves by default
16
+
17
+ This hook is designed as a starting point. Extend the validation logic in `src/hook.ts` for your own project needs.
18
+
19
+ ## Installation
20
+
21
+ Add to your `.claude/settings.json`:
22
+
23
+ ```json
24
+ {
25
+ "hooks": {
26
+ "TaskCompleted": [
27
+ {
28
+ "matcher": "",
29
+ "hooks": [
30
+ {
31
+ "type": "command",
32
+ "command": "bun run hooks/hook-taskgate/src/hook.ts"
33
+ }
34
+ ]
35
+ }
36
+ ]
37
+ }
38
+ }
39
+ ```
40
+
41
+ ## Output
42
+
43
+ - `{ "decision": "approve" }` — task passes validation
44
+ - `{ "decision": "block", "reason": "..." }` — task fails validation, provides reason
45
+
46
+ ## Extending
47
+
48
+ To add custom validation, edit `src/hook.ts` and add checks in the `run()` function before the default approve. For example:
49
+
50
+ ```typescript
51
+ // Block tasks mentioning "deploy" unless a deploy script exists
52
+ if (/\bdeploy\b/.test(description)) {
53
+ if (!existsSync(join(cwd, "deploy.sh"))) {
54
+ respond({ decision: "block", reason: "No deploy.sh found" });
55
+ return;
56
+ }
57
+ }
58
+ ```
59
+
60
+ ## License
61
+
62
+ MIT
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "hook-taskgate",
3
+ "version": "0.1.0",
4
+ "description": "Claude Code hook that validates task completion before marking done",
5
+ "type": "module",
6
+ "main": "./src/hook.ts",
7
+ "scripts": {
8
+ "typecheck": "tsc --noEmit"
9
+ },
10
+ "author": "Hasna",
11
+ "license": "MIT"
12
+ }
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env bun
2
+
3
+ /**
4
+ * Claude Code Hook: taskgate
5
+ *
6
+ * TaskCompleted hook that validates a task is actually complete before
7
+ * allowing it to be marked done. Lightweight gate designed to be
8
+ * extended by users with custom validation logic.
9
+ *
10
+ * Current checks:
11
+ * - If task mentions "test" or "tests", verifies test files exist in cwd
12
+ * - If task mentions "lint" or "format", approves (can't verify externally)
13
+ * - For all other tasks, approves by default
14
+ */
15
+
16
+ import { readFileSync, existsSync, readdirSync } from "fs";
17
+ import { join } from "path";
18
+
19
+ interface HookInput {
20
+ session_id: string;
21
+ cwd: string;
22
+ tool_name: string;
23
+ tool_input: Record<string, unknown>;
24
+ }
25
+
26
+ interface HookOutput {
27
+ decision: "approve" | "block";
28
+ reason?: string;
29
+ }
30
+
31
+ /**
32
+ * Read and parse JSON from stdin
33
+ */
34
+ function readStdinJson(): HookInput | null {
35
+ try {
36
+ const input = readFileSync(0, "utf-8").trim();
37
+ if (!input) return null;
38
+ return JSON.parse(input);
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Recursively check if any test files exist in a directory
46
+ * Looks for common test file patterns: *.test.*, *.spec.*, test_*, *_test.*
47
+ */
48
+ function hasTestFiles(dir: string, depth: number = 0): boolean {
49
+ if (depth > 4) return false; // Don't recurse too deep
50
+
51
+ try {
52
+ const entries = readdirSync(dir, { withFileTypes: true });
53
+
54
+ for (const entry of entries) {
55
+ const name = entry.name;
56
+
57
+ // Skip node_modules, .git, dist, build, etc.
58
+ if (entry.isDirectory()) {
59
+ if (["node_modules", ".git", "dist", "build", ".next", "coverage", "__pycache__"].includes(name)) {
60
+ continue;
61
+ }
62
+
63
+ // Check common test directories
64
+ if (["test", "tests", "__tests__", "spec", "specs"].includes(name)) {
65
+ return true;
66
+ }
67
+
68
+ // Recurse into subdirectories
69
+ if (hasTestFiles(join(dir, name), depth + 1)) {
70
+ return true;
71
+ }
72
+ }
73
+
74
+ // Check test file patterns
75
+ if (entry.isFile()) {
76
+ const lower = name.toLowerCase();
77
+ if (
78
+ lower.includes(".test.") ||
79
+ lower.includes(".spec.") ||
80
+ lower.startsWith("test_") ||
81
+ lower.endsWith("_test.py") ||
82
+ lower.endsWith("_test.go") ||
83
+ lower.endsWith("_test.ts") ||
84
+ lower.endsWith("_test.js")
85
+ ) {
86
+ return true;
87
+ }
88
+ }
89
+ }
90
+ } catch {
91
+ // Directory read failed — can't verify, so don't block
92
+ return true;
93
+ }
94
+
95
+ return false;
96
+ }
97
+
98
+ /**
99
+ * Extract task description from tool_input
100
+ */
101
+ function getTaskDescription(toolInput: Record<string, unknown>): string {
102
+ // Try common field names for task description
103
+ const candidates = [
104
+ toolInput.description,
105
+ toolInput.task,
106
+ toolInput.title,
107
+ toolInput.summary,
108
+ toolInput.content,
109
+ toolInput.text,
110
+ ];
111
+
112
+ for (const candidate of candidates) {
113
+ if (candidate && typeof candidate === "string") {
114
+ return candidate;
115
+ }
116
+ }
117
+
118
+ // Fallback: stringify the whole input
119
+ return JSON.stringify(toolInput).toLowerCase();
120
+ }
121
+
122
+ /**
123
+ * Output hook response
124
+ */
125
+ function respond(output: HookOutput): void {
126
+ console.log(JSON.stringify(output));
127
+ }
128
+
129
+ /**
130
+ * Main hook execution
131
+ */
132
+ export function run(): void {
133
+ const input = readStdinJson();
134
+
135
+ if (!input) {
136
+ respond({ decision: "approve" });
137
+ return;
138
+ }
139
+
140
+ const description = getTaskDescription(input.tool_input || {}).toLowerCase();
141
+ const cwd = input.cwd;
142
+
143
+ // Check: if the task mentions tests, verify test files exist
144
+ if (/\btests?\b/.test(description)) {
145
+ if (!hasTestFiles(cwd)) {
146
+ console.error("[hook-taskgate] Task mentions tests but no test files found in project");
147
+ respond({
148
+ decision: "block",
149
+ reason: "Task mentions tests but no test files were found in the project. Please create test files before marking this task as complete.",
150
+ });
151
+ return;
152
+ }
153
+ console.error("[hook-taskgate] Task mentions tests — test files found, approved");
154
+ }
155
+
156
+ // Check: if the task mentions lint/format, approve (can't verify externally)
157
+ if (/\b(lint|linting|format|formatting)\b/.test(description)) {
158
+ console.error("[hook-taskgate] Task mentions lint/format — approved (cannot verify externally)");
159
+ respond({ decision: "approve" });
160
+ return;
161
+ }
162
+
163
+ // Default: approve all other tasks
164
+ respond({ decision: "approve" });
165
+ }
166
+
167
+ if (import.meta.main) {
168
+ run();
169
+ }
@@ -0,0 +1,50 @@
1
+ # hook-tddguard
2
+
3
+ Claude Code hook that enforces Test-Driven Development by blocking implementation file edits unless a corresponding test file exists.
4
+
5
+ ## Overview
6
+
7
+ Before allowing edits to implementation files, this hook checks whether a corresponding test file exists. If no test file is found, the edit is blocked with a message to write tests first.
8
+
9
+ ## Event
10
+
11
+ - **PreToolUse** (matcher: `Edit|Write`)
12
+
13
+ ## Behavior
14
+
15
+ - **Test files** (`*.test.ts`, `*.spec.ts`, `*_test.py`, `test_*.py`, `*_test.go`) are always approved
16
+ - **Config files** (`*.json`, `*.md`, `*.yml`, `*.yaml`, `*.toml`, `*.css`, `*.html`) are always approved
17
+ - **Implementation files** are checked for a corresponding test file:
18
+ - Same directory: `foo.test.ts`, `foo.spec.ts`
19
+ - `__tests__/` subdirectory
20
+ - `tests/` subdirectory
21
+ - Python: `test_foo.py`, `foo_test.py`
22
+ - Go: `foo_test.go`
23
+ - If no test file exists, the edit is **blocked**
24
+
25
+ ## Supported Languages
26
+
27
+ | Language | Test File Patterns |
28
+ |----------|--------------------|
29
+ | TypeScript/JavaScript | `*.test.ts`, `*.spec.ts`, `*.test.js`, `*.spec.js` |
30
+ | Python | `test_*.py`, `*_test.py` |
31
+ | Go | `*_test.go` |
32
+ | Java | `*Test.java` |
33
+ | Ruby | `*_test.rb`, `*_spec.rb` |
34
+
35
+ ## Example
36
+
37
+ ```
38
+ # Editing src/utils.ts without src/utils.test.ts existing:
39
+ # → BLOCKED: "Write tests first (TDD). No test file found for utils.ts."
40
+
41
+ # Editing src/utils.test.ts:
42
+ # → APPROVED (always)
43
+
44
+ # Editing src/utils.ts WITH src/utils.test.ts existing:
45
+ # → APPROVED
46
+ ```
47
+
48
+ ## License
49
+
50
+ MIT
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "hook-tddguard",
3
+ "version": "0.1.0",
4
+ "description": "Claude Code hook that enforces TDD by blocking implementation edits without corresponding test files",
5
+ "type": "module",
6
+ "main": "./src/hook.ts",
7
+ "scripts": {
8
+ "typecheck": "tsc --noEmit"
9
+ },
10
+ "author": "Hasna",
11
+ "license": "MIT"
12
+ }