claude-smith 3.0.0 → 3.2.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.
@@ -5,21 +5,19 @@
5
5
  * Records build pass/fail result for build-guard reference
6
6
  */
7
7
 
8
- import { readFileSync, writeFileSync, mkdirSync } from 'fs';
8
+ import { writeFileSync, mkdirSync } from 'fs';
9
9
  import { join } from 'path';
10
10
  import { tmpdir } from 'os';
11
11
  import { getHookConfig } from '../lib/config.mjs';
12
12
  import { recordEvent } from '../lib/stats.mjs';
13
+ import { appendEvent } from '../lib/event-log.mjs';
14
+ import { sanitizeId, parseHookInput, notifyUser } from '../lib/hook-utils.mjs';
13
15
 
14
16
  const config = getHookConfig('build-tracker', process.cwd());
15
17
  if (!config.enabled) process.exit(0);
16
18
 
17
- let input;
18
- try {
19
- input = JSON.parse(readFileSync(0, 'utf8'));
20
- } catch {
21
- process.exit(0);
22
- }
19
+ const input = parseHookInput();
20
+ if (!input) process.exit(0);
23
21
 
24
22
  const command = input.tool_input?.command || '';
25
23
 
@@ -27,11 +25,6 @@ if (input.tool_name !== 'Bash') process.exit(0);
27
25
 
28
26
  // Detect build commands
29
27
  if (/pnpm build|npm run build|yarn build|tsc|next build/.test(command)) {
30
- function sanitizeId(id) {
31
- if (!id || typeof id !== 'string') return 'default';
32
- return id.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128) || 'default';
33
- }
34
-
35
28
  const sessionId = sanitizeId(input.session_id);
36
29
  const stateDir = join(tmpdir(), '.claude-smith', sessionId);
37
30
  mkdirSync(stateDir, { recursive: true, mode: 0o700 });
@@ -39,6 +32,9 @@ if (/pnpm build|npm run build|yarn build|tsc|next build/.test(command)) {
39
32
  const response = JSON.stringify(input.tool_response || '').toLowerCase();
40
33
  const failed = /\b(?:build\s+failed|[1-9]\d*\s+errors?\b|error(?:s)?:\s*[1-9]|exit\s+code\s+[1-9]|failed\s+to\s+compile)/i.test(response)
41
34
  && !/\b0\s+errors?\b/.test(response);
42
- writeFileSync(join(stateDir, 'last-build-result'), failed ? 'fail' : 'pass', { mode: 0o600 });
35
+ const result = failed ? 'fail' : 'pass';
36
+ writeFileSync(join(stateDir, 'last-build-result'), result, { mode: 0o600 });
43
37
  recordEvent(sessionId, 'build-tracker', 'fire');
38
+ appendEvent(process.cwd(), { hook: 'build-tracker', event: 'fire', message: result });
39
+ notifyUser(config.notify, 'Build Tracker', 'track', failed ? '❌ fail' : '✅ pass');
44
40
  }
@@ -12,16 +12,14 @@ import { join } from 'path';
12
12
  import { tmpdir } from 'os';
13
13
  import { getHookConfig } from '../lib/config.mjs';
14
14
  import { recordEvent } from '../lib/stats.mjs';
15
+ import { appendEvent } from '../lib/event-log.mjs';
16
+ import { sanitizeId, parseHookInput, notifyUser } from '../lib/hook-utils.mjs';
15
17
 
16
18
  const config = getHookConfig('commit-guard', process.cwd());
17
19
  if (!config.enabled) process.exit(0);
18
20
 
19
- let input;
20
- try {
21
- input = JSON.parse(readFileSync(0, 'utf8'));
22
- } catch {
23
- process.exit(0);
24
- }
21
+ const input = parseHookInput();
22
+ if (!input) process.exit(0);
25
23
 
26
24
  const toolName = input.tool_name;
27
25
  const command = input.tool_input?.command || '';
@@ -32,24 +30,19 @@ if (!command.includes('git commit')) process.exit(0);
32
30
  // Skip amend, merge commits
33
31
  if (command.includes('--allow-empty')) process.exit(0);
34
32
 
35
- function sanitizeId(id) {
36
- if (!id || typeof id !== 'string') return 'default';
37
- return id.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128) || 'default';
38
- }
39
-
40
33
  const sessionId = sanitizeId(input.session_id);
41
34
  const stateDir = join(tmpdir(), '.claude-smith', sessionId);
42
35
  const lastTestFile = join(stateDir, 'last-test-run');
43
36
  const lastResultFile = join(stateDir, 'last-test-result');
44
37
 
45
- recordEvent(sessionId, 'commit-guard', 'fire');
46
-
47
38
  // Check test result first (hard block on failure)
48
39
  if (existsSync(lastResultFile)) {
49
40
  const result = readFileSync(lastResultFile, 'utf8').trim();
50
41
  if (result === 'fail') {
51
42
  recordEvent(sessionId, 'commit-guard', 'block');
52
- process.stderr.write("🔨 Smith Commit blocked: last test run FAILED. Run your test suite and fix failures before committing.\n💡 Coaching: 1) Run your test suite. 2) Fix failing tests one at a time. 3) Verify all pass. 4) Then commit.");
43
+ appendEvent(process.cwd(), { hook: 'commit-guard', event: 'block', message: 'Tests failing' });
44
+ notifyUser(config.notify, 'Commit Guard', 'block', '테스트 실패 — 커밋 차단');
45
+ process.stderr.write("🕵️ Smith ✋ Commit blocked: last test run FAILED. Run your test suite and fix failures before committing.\n💡 Coaching: 1) Run your test suite. 2) Fix failing tests one at a time. 3) Verify all pass. 4) Then commit.");
53
46
  process.exit(2);
54
47
  }
55
48
  }
@@ -61,21 +54,32 @@ if (existsSync(lastTestFile)) {
61
54
  const diff = now - lastTest;
62
55
  if (diff > config.maxTestAge) {
63
56
  recordEvent(sessionId, 'commit-guard', 'warn');
57
+ appendEvent(process.cwd(), { hook: 'commit-guard', event: 'warn', message: 'Tests stale' });
58
+ notifyUser(config.notify, 'Commit Guard', 'warn', '테스트 경과 시간 초과');
64
59
  const output = JSON.stringify({
65
60
  hookSpecificOutput: {
66
61
  hookEventName: "PreToolUse",
67
- additionalContext: `🔨 Smith > Commit Guard: Last test run was ${Math.floor(diff / 60)}min ago (>5min). Run tests before committing.`
62
+ additionalContext: `🕵️ Smith > Commit Guard: Last test run was ${Math.floor(diff / 60)}min ago (>5min). Run tests before committing.`
68
63
  }
69
64
  });
70
65
  process.stdout.write(output);
66
+ process.exit(0);
71
67
  }
72
68
  } else {
73
69
  recordEvent(sessionId, 'commit-guard', 'warn');
70
+ appendEvent(process.cwd(), { hook: 'commit-guard', event: 'warn', message: 'No test run detected' });
71
+ notifyUser(config.notify, 'Commit Guard', 'warn', '테스트 실행 기록 없음');
74
72
  const output = JSON.stringify({
75
73
  hookSpecificOutput: {
76
74
  hookEventName: "PreToolUse",
77
- additionalContext: "🔨 Smith > Commit Guard: No test run detected this session. Consider running tests before committing.\n💡 Coaching: Run your test suite to verify nothing is broken. Even a quick smoke test prevents broken commits."
75
+ additionalContext: "🕵️ Smith > Commit Guard: No test run detected this session. Consider running tests before committing.\n💡 Coaching: Run your test suite to verify nothing is broken. Even a quick smoke test prevents broken commits."
78
76
  }
79
77
  });
80
78
  process.stdout.write(output);
79
+ process.exit(0);
81
80
  }
81
+
82
+ // All checks passed - commit allowed
83
+ recordEvent(sessionId, 'commit-guard', 'fire');
84
+ appendEvent(process.cwd(), { hook: 'commit-guard', event: 'fire', message: 'Commit allowed' });
85
+ notifyUser(config.notify, 'Commit Guard', 'fire', '커밋 허용');
@@ -5,39 +5,32 @@
5
5
  * Enforces conventional commits format: type(scope): description
6
6
  */
7
7
 
8
- import { readFileSync } from 'fs';
9
8
  import { getHookConfig } from '../lib/config.mjs';
10
9
  import { recordEvent } from '../lib/stats.mjs';
10
+ import { appendEvent } from '../lib/event-log.mjs';
11
+ import { sanitizeId, parseHookInput, notifyUser } from '../lib/hook-utils.mjs';
11
12
 
12
13
  const config = getHookConfig('commit-message', process.cwd());
13
14
  if (!config.enabled) process.exit(0);
14
15
 
15
- let input;
16
- try {
17
- input = JSON.parse(readFileSync(0, 'utf8'));
18
- } catch {
19
- process.exit(0);
20
- }
16
+ const input = parseHookInput();
17
+ if (!input) process.exit(0);
21
18
 
22
19
  const command = input.tool_input?.command || '';
23
20
 
24
21
  if (input.tool_name !== 'Bash') process.exit(0);
25
22
  if (!command.includes('git commit')) process.exit(0);
26
23
 
27
- function sanitizeId(id) {
28
- if (!id || typeof id !== 'string') return 'default';
29
- return id.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128) || 'default';
30
- }
31
-
32
24
  const sessionId = sanitizeId(input.session_id);
33
25
 
34
26
  // Extract commit message from -m flag
35
- const msgMatch = command.match(/-m\s+"([^"]+)"/s)
36
- || command.match(/-m\s+'([^']+)'/s)
37
- || command.match(/-m\s+"\$\(cat\s+<<'?EOF'?\s*\n([\s\S]*?)\n\s*EOF/);
27
+ // Check HEREDOC format first (used by Claude Code)
28
+ const msgMatch = command.match(/-m\s+"\$\(cat\s+<<'?EOF'?\s*\n([\s\S]*?)\n\s*EOF/)
29
+ || command.match(/-m\s+"([^"]+)"/s)
30
+ || command.match(/-m\s+'([^']+)'/s);
38
31
  if (!msgMatch) process.exit(0); // Can't parse message, let it through
39
32
 
40
- const msg = msgMatch[1] || msgMatch[2] || '';
33
+ const msg = msgMatch[1] || '';
41
34
  const firstLine = msg.split('\n')[0].trim();
42
35
 
43
36
  // Conventional commit pattern: type(scope): description OR type: description
@@ -45,11 +38,18 @@ const pattern = /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)
45
38
 
46
39
  if (!pattern.test(firstLine)) {
47
40
  recordEvent(sessionId, 'commit-message', 'warn');
41
+ appendEvent(process.cwd(), { hook: 'commit-message', event: 'warn', message: `Non-conventional: ${firstLine.slice(0, 50)}` });
42
+ notifyUser(config.notify, 'Commit Message', 'warn', `비표준 형식: ${firstLine.slice(0, 40)}`);
48
43
  const output = JSON.stringify({
49
44
  hookSpecificOutput: {
50
45
  hookEventName: "PreToolUse",
51
- additionalContext: `🔨 Smith > Commit Message: "${firstLine}" doesn't follow conventional commits. Expected: type(scope): description (e.g., feat(auth): add login, fix: resolve crash)`
46
+ additionalContext: `🕵️ Smith > Commit Message: "${firstLine}" doesn't follow conventional commits. Expected: type(scope): description (e.g., feat(auth): add login, fix: resolve crash)`
52
47
  }
53
48
  });
54
49
  process.stdout.write(output);
50
+ } else {
51
+ // Format is valid
52
+ recordEvent(sessionId, 'commit-message', 'fire');
53
+ appendEvent(process.cwd(), { hook: 'commit-message', event: 'fire', message: 'Format valid' });
54
+ notifyUser(config.notify, 'Commit Message', 'fire', '형식 유효');
55
55
  }
@@ -11,16 +11,14 @@ import { tmpdir } from 'os';
11
11
  import { createHash } from 'crypto';
12
12
  import { getHookConfig } from '../lib/config.mjs';
13
13
  import { recordEvent } from '../lib/stats.mjs';
14
+ import { appendEvent } from '../lib/event-log.mjs';
15
+ import { sanitizeId, parseHookInput, isOmcAutoMode, notifyUser } from '../lib/hook-utils.mjs';
14
16
 
15
17
  const config = getHookConfig('debug-loop', process.cwd());
16
18
  if (!config.enabled) process.exit(0);
17
19
 
18
- let input;
19
- try {
20
- input = JSON.parse(readFileSync(0, 'utf8'));
21
- } catch {
22
- process.exit(0);
23
- }
20
+ const input = parseHookInput();
21
+ if (!input) process.exit(0);
24
22
 
25
23
  const toolName = input.tool_name;
26
24
  const filePath = input.tool_input?.file_path || '';
@@ -31,11 +29,6 @@ if (!filePath) process.exit(0);
31
29
  // Skip non-source files
32
30
  if (!/\.(ts|tsx|js|jsx|py|go|rs)$/.test(filePath)) process.exit(0);
33
31
 
34
- function sanitizeId(id) {
35
- if (!id || typeof id !== 'string') return 'default';
36
- return id.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128) || 'default';
37
- }
38
-
39
32
  // Isolate state per agent when sub-agents provide their own ID
40
33
  const sessionId = sanitizeId(input.session_id);
41
34
  const agentId = sanitizeId(input.agent_id || '');
@@ -44,15 +37,7 @@ const stateDir = join(tmpdir(), '.claude-smith', stateKey);
44
37
  mkdirSync(stateDir, { recursive: true, mode: 0o700 });
45
38
 
46
39
  // Raise thresholds during OMC autonomous modes to avoid false positives in parallel execution
47
- const omcStateDir = join(process.cwd(), '.omc', 'state');
48
- const omcAutoModes = ['autopilot-state.json', 'ultrawork-state.json', 'ralph-state.json'];
49
- const isOmcAutoMode = omcAutoModes.some(f => {
50
- try {
51
- const state = JSON.parse(readFileSync(join(omcStateDir, f), 'utf8'));
52
- return state.active === true || state.status === 'running';
53
- } catch { return false; }
54
- });
55
- if (isOmcAutoMode) {
40
+ if (isOmcAutoMode(process.cwd())) {
56
41
  config.warnAt = Math.max(config.warnAt, 6);
57
42
  config.blockAt = Math.max(config.blockAt, 10);
58
43
  }
@@ -100,15 +85,19 @@ const patternHint = pattern === 'divergent'
100
85
 
101
86
  if (count === config.warnAt) {
102
87
  recordEvent(sessionId, 'debug-loop', 'warn');
88
+ appendEvent(process.cwd(), { hook: 'debug-loop', event: 'warn', file: filePath, message: `Edit #${count}${patternHint ? ' ' + patternHint : ''}` });
89
+ notifyUser(config.notify, 'Debug Loop', 'warn', `${name} ${count}회 편집${pattern === 'divergent' ? ' (반복 패턴)' : ''}`);
103
90
  const output = JSON.stringify({
104
91
  hookSpecificOutput: {
105
92
  hookEventName: "PostToolUse",
106
- additionalContext: `🔨 Smith > Debug Loop: ${name} edited ${config.warnAt} times.${patternHint}\n💡 Coaching: 1) STOP editing. Read the error message carefully. 2) Add logging to trace data flow. 3) Check recent git changes: git diff HEAD~3. 4) Find a working similar pattern and compare. 5) Form a hypothesis before making the next edit.`
93
+ additionalContext: `🕵️ Smith > Debug Loop: ${name} edited ${config.warnAt} times.${patternHint}\n💡 Coaching: 1) STOP editing. Read the error message carefully. 2) Add logging to trace data flow. 3) Check recent git changes: git diff HEAD~3. 4) Find a working similar pattern and compare. 5) Form a hypothesis before making the next edit.`
107
94
  }
108
95
  });
109
96
  process.stdout.write(output);
110
97
  } else if (count >= config.blockAt) {
111
98
  recordEvent(sessionId, 'debug-loop', 'block');
112
- process.stderr.write(`🔨 Smith Debug Loop blocked: ${name} edited ${count} times.${patternHint}\nPer rule 2-6: 3+ failed fixes → question architecture. Discuss with user before continuing.`);
99
+ appendEvent(process.cwd(), { hook: 'debug-loop', event: 'block', file: filePath, message: `Edit #${count} - blocked` });
100
+ notifyUser(config.notify, 'Debug Loop', 'block', `${name} ${count}회 편집 — 차단`);
101
+ process.stderr.write(`🕵️ Smith ✋ Debug Loop blocked: ${name} edited ${count} times.${patternHint}\nPer rule 2-6: 3+ failed fixes → question architecture. Discuss with user before continuing.`);
113
102
  process.exit(2);
114
103
  }
@@ -9,16 +9,14 @@ import { readFileSync, existsSync } from 'fs';
9
9
  import { basename } from 'path';
10
10
  import { getHookConfig } from '../lib/config.mjs';
11
11
  import { recordEvent } from '../lib/stats.mjs';
12
+ import { appendEvent } from '../lib/event-log.mjs';
13
+ import { sanitizeId, parseHookInput, notifyUser } from '../lib/hook-utils.mjs';
12
14
 
13
15
  const config = getHookConfig('file-size-warn', process.cwd());
14
16
  if (!config.enabled) process.exit(0);
15
17
 
16
- let input;
17
- try {
18
- input = JSON.parse(readFileSync(0, 'utf8'));
19
- } catch {
20
- process.exit(0);
21
- }
18
+ const input = parseHookInput();
19
+ if (!input) process.exit(0);
22
20
 
23
21
  const filePath = input.tool_input?.file_path || '';
24
22
 
@@ -30,11 +28,6 @@ if (!/\.(ts|tsx|js|jsx|py|go|rs)$/.test(filePath)) process.exit(0);
30
28
 
31
29
  if (!existsSync(filePath)) process.exit(0);
32
30
 
33
- function sanitizeId(id) {
34
- if (!id || typeof id !== 'string') return 'default';
35
- return id.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128) || 'default';
36
- }
37
-
38
31
  const sessionId = sanitizeId(input.session_id);
39
32
 
40
33
  const lines = readFileSync(filePath, 'utf8').split('\n').length;
@@ -42,10 +35,12 @@ const threshold = config.maxLines || 500;
42
35
 
43
36
  if (lines > threshold) {
44
37
  recordEvent(sessionId, 'file-size-warn', 'warn');
38
+ appendEvent(process.cwd(), { hook: 'file-size-warn', event: 'warn', file: filePath, message: `${lines} lines` });
39
+ notifyUser(config.notify, 'File Size', 'warn', `${basename(filePath)} ${lines}줄 (>${threshold})`);
45
40
  const output = JSON.stringify({
46
41
  hookSpecificOutput: {
47
42
  hookEventName: "PostToolUse",
48
- additionalContext: `🔨 Smith > File Size: ${basename(filePath)} has ${lines} lines (>${threshold}). Consider splitting into smaller modules.`
43
+ additionalContext: `🕵️ Smith > File Size: ${basename(filePath)} has ${lines} lines (>${threshold}). Consider splitting into smaller modules.`
49
44
  }
50
45
  });
51
46
  process.stdout.write(output);
@@ -12,25 +12,18 @@ import { join, extname } from 'path';
12
12
  import { tmpdir } from 'os';
13
13
  import { getHookConfig } from '../lib/config.mjs';
14
14
  import { recordEvent } from '../lib/stats.mjs';
15
+ import { appendEvent } from '../lib/event-log.mjs';
16
+ import { sanitizeId, parseHookInput, isOmcAutoMode, notifyUser } from '../lib/hook-utils.mjs';
15
17
 
16
18
  const config = getHookConfig('plan-guard', process.cwd());
17
19
  if (!config.enabled) process.exit(0);
18
20
 
19
- let input;
20
- try {
21
- input = JSON.parse(readFileSync(0, 'utf8'));
22
- } catch {
23
- process.exit(0);
24
- }
21
+ const input = parseHookInput();
22
+ if (!input) process.exit(0);
25
23
 
26
24
  const filePath = input.tool_input?.file_path || '';
27
25
  if (!filePath) process.exit(0);
28
26
 
29
- function sanitizeId(id) {
30
- if (!id || typeof id !== 'string') return 'default';
31
- return id.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128) || 'default';
32
- }
33
-
34
27
  const sessionId = sanitizeId(input.session_id);
35
28
  const agentId = sanitizeId(input.agent_id || '');
36
29
  const stateKey = agentId && agentId !== 'default' ? `${sessionId}-${agentId}` : sessionId;
@@ -45,6 +38,7 @@ if (isPostToolUse) {
45
38
  if (/plan/i.test(filePath) && /\.md$/.test(filePath)) {
46
39
  const planDetectedFile = join(stateDir, 'plan-detected');
47
40
  writeFileSync(planDetectedFile, String(Date.now()), { mode: 0o600 });
41
+ appendEvent(process.cwd(), { hook: 'plan-guard', event: 'fire', file: filePath, message: 'Plan file detected' });
48
42
  }
49
43
  process.exit(0);
50
44
  }
@@ -90,32 +84,28 @@ count++;
90
84
  writeFileSync(counterFile, String(count), { mode: 0o600 });
91
85
 
92
86
  // Raise threshold during OMC autonomous modes
93
- const omcStateDir = join(process.cwd(), '.omc', 'state');
94
- const omcAutoModes = ['autopilot-state.json', 'ultrawork-state.json', 'ralph-state.json'];
95
- const isOmcAutoMode = omcAutoModes.some(f => {
96
- try {
97
- const state = JSON.parse(readFileSync(join(omcStateDir, f), 'utf8'));
98
- return state.active === true || state.status === 'running';
99
- } catch { return false; }
100
- });
101
-
102
- const warnAt = isOmcAutoMode ? (config.warnAt || 5) * 2 : (config.warnAt || 5);
103
- const blockAt = isOmcAutoMode ? (config.blockAt || 10) * 2 : (config.blockAt || 10);
87
+ const isOmc = isOmcAutoMode(process.cwd());
88
+ const warnAt = isOmc ? (config.warnAt || 5) * 2 : (config.warnAt || 5);
89
+ const blockAt = isOmc ? (config.blockAt || 10) * 2 : (config.blockAt || 10);
104
90
 
105
91
  // Block if threshold reached
106
92
  if (count >= blockAt) {
107
93
  recordEvent(sessionId, 'plan-guard', 'block');
108
- process.stderr.write(`🔨 Smith Plan Guard: ${count} code files edited without a decomposition plan. Create a plan first.`);
94
+ appendEvent(process.cwd(), { hook: 'plan-guard', event: 'block', message: `${count} files without plan` });
95
+ notifyUser(config.notify, 'Plan Guard', 'block', `${count}개 파일 계획 없이 편집 — 차단`);
96
+ process.stderr.write(`🕵️ Smith ✋ Plan Guard: ${count} code files edited without a decomposition plan. Create a plan first.`);
109
97
  process.exit(2);
110
98
  }
111
99
 
112
100
  // Warn if approaching threshold
113
101
  if (count >= warnAt) {
114
102
  recordEvent(sessionId, 'plan-guard', 'warn');
103
+ appendEvent(process.cwd(), { hook: 'plan-guard', event: 'warn', message: `${count} files without plan` });
104
+ notifyUser(config.notify, 'Plan Guard', 'warn', `${count}개 파일 계획 없이 편집`);
115
105
  const output = JSON.stringify({
116
106
  hookSpecificOutput: {
117
107
  hookEventName: "PreToolUse",
118
- additionalContext: `🔨 Smith > Plan Guard: ${count} code files edited without a plan. Complex work needs decomposition first.
108
+ additionalContext: `🕵️ Smith > Plan Guard: ${count} code files edited without a plan. Complex work needs decomposition first.
119
109
  💡 Create a plan file (docs/plans/*.md or plan.md) that includes:
120
110
  1. Each unit's responsibility (one sentence)
121
111
  2. Dependency direction between units
@@ -10,16 +10,14 @@ import { join, dirname } from 'path';
10
10
  import { tmpdir } from 'os';
11
11
  import { getHookConfig } from '../lib/config.mjs';
12
12
  import { recordEvent } from '../lib/stats.mjs';
13
+ import { appendEvent } from '../lib/event-log.mjs';
14
+ import { sanitizeId, parseHookInput, isOmcAutoMode, notifyUser } from '../lib/hook-utils.mjs';
13
15
 
14
16
  const config = getHookConfig('scope-guard', process.cwd());
15
17
  if (!config.enabled) process.exit(0);
16
18
 
17
- let input;
18
- try {
19
- input = JSON.parse(readFileSync(0, 'utf8'));
20
- } catch {
21
- process.exit(0);
22
- }
19
+ const input = parseHookInput();
20
+ if (!input) process.exit(0);
23
21
 
24
22
  const filePath = input.tool_input?.file_path || '';
25
23
 
@@ -27,11 +25,6 @@ if (!['Edit', 'Write'].includes(input.tool_name)) process.exit(0);
27
25
  if (!filePath) process.exit(0);
28
26
  if (!/\.(ts|tsx|js|jsx|py|go|rs|css|json)$/.test(filePath)) process.exit(0);
29
27
 
30
- function sanitizeId(id) {
31
- if (!id || typeof id !== 'string') return 'default';
32
- return id.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128) || 'default';
33
- }
34
-
35
28
  // Isolate state per agent when sub-agents provide their own ID
36
29
  const sessionId = sanitizeId(input.session_id);
37
30
  const agentId = sanitizeId(input.agent_id || '');
@@ -39,16 +32,6 @@ const stateKey = agentId && agentId !== 'default' ? `${sessionId}-${agentId}` :
39
32
  const stateDir = join(tmpdir(), '.claude-smith', stateKey);
40
33
  mkdirSync(stateDir, { recursive: true, mode: 0o700 });
41
34
 
42
- // Raise threshold during OMC autonomous modes to avoid false positives in parallel execution
43
- const omcStateDir = join(process.cwd(), '.omc', 'state');
44
- const omcAutoModes = ['autopilot-state.json', 'ultrawork-state.json', 'ralph-state.json'];
45
- const isOmcAutoMode = omcAutoModes.some(f => {
46
- try {
47
- const state = JSON.parse(readFileSync(join(omcStateDir, f), 'utf8'));
48
- return state.active === true || state.status === 'running';
49
- } catch { return false; }
50
- });
51
-
52
35
  const stateFile = join(stateDir, 'scope-directories');
53
36
  const dir = dirname(filePath);
54
37
 
@@ -66,14 +49,17 @@ if (!dirs.includes(dir)) {
66
49
  writeFileSync(stateFile, JSON.stringify(dirs), { mode: 0o600 });
67
50
  }
68
51
 
69
- const threshold = isOmcAutoMode ? Math.max((config.maxDirectories || 5) * 2, 10) : (config.maxDirectories || 5);
52
+ // Raise threshold during OMC autonomous modes to avoid false positives in parallel execution
53
+ const threshold = isOmcAutoMode(process.cwd()) ? Math.max((config.maxDirectories || 5) * 2, 10) : (config.maxDirectories || 5);
70
54
 
71
55
  if (dirs.length >= threshold && (dirs.length === threshold || dirs.length % threshold === 0)) {
72
56
  recordEvent(sessionId, 'scope-guard', 'warn');
57
+ appendEvent(process.cwd(), { hook: 'scope-guard', event: 'warn', message: `${dirs.length} directories` });
58
+ notifyUser(config.notify, 'Scope Guard', 'warn', `${dirs.length}개 디렉토리 변경`);
73
59
  const output = JSON.stringify({
74
60
  hookSpecificOutput: {
75
61
  hookEventName: "PostToolUse",
76
- additionalContext: `🔨 Smith > Scope Guard: Changes span ${dirs.length} directories (>=${threshold}). Is the scope too broad? Consider breaking into smaller tasks.`
62
+ additionalContext: `🕵️ Smith > Scope Guard: Changes span ${dirs.length} directories (>=${threshold}). Is the scope too broad? Consider breaking into smaller tasks.`
77
63
  }
78
64
  });
79
65
  process.stdout.write(output);
@@ -5,29 +5,23 @@
5
5
  * Injects core TDD/verification rules into subagent context
6
6
  */
7
7
 
8
- import { readFileSync } from 'fs';
9
8
  import { getHookConfig } from '../lib/config.mjs';
10
9
  import { recordEvent } from '../lib/stats.mjs';
10
+ import { appendEvent } from '../lib/event-log.mjs';
11
+ import { sanitizeId, parseHookInput, notifyUser } from '../lib/hook-utils.mjs';
11
12
 
12
13
  const config = getHookConfig('subagent-inject', process.cwd());
13
14
  if (!config.enabled) process.exit(0);
14
15
 
15
16
  // Consume stdin (required by hook protocol)
16
- let input;
17
- try {
18
- input = JSON.parse(readFileSync(0, 'utf8'));
19
- } catch {
20
- process.exit(0);
21
- }
22
-
23
- function sanitizeId(id) {
24
- if (!id || typeof id !== 'string') return 'default';
25
- return id.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128) || 'default';
26
- }
17
+ const input = parseHookInput();
18
+ if (!input) process.exit(0);
27
19
 
28
20
  const sessionId = sanitizeId(input.session_id);
29
21
 
30
22
  recordEvent(sessionId, 'subagent-inject', 'fire');
23
+ appendEvent(process.cwd(), { hook: 'subagent-inject', event: 'fire', message: 'Rules injected into subagent' });
24
+ notifyUser(config.notify, 'Subagent Inject', 'inject', '규칙 주입됨');
31
25
  const output = JSON.stringify({
32
26
  hookSpecificOutput: {
33
27
  hookEventName: "SubagentStart",
@@ -5,20 +5,18 @@
5
5
  * Warns when editing a source file that has no corresponding test file
6
6
  */
7
7
 
8
- import { readFileSync, existsSync } from 'fs';
8
+ import { existsSync } from 'fs';
9
9
  import { dirname, basename, join, extname } from 'path';
10
10
  import { getHookConfig } from '../lib/config.mjs';
11
11
  import { recordEvent } from '../lib/stats.mjs';
12
+ import { appendEvent } from '../lib/event-log.mjs';
13
+ import { sanitizeId, parseHookInput, notifyUser } from '../lib/hook-utils.mjs';
12
14
 
13
15
  const config = getHookConfig('tdd-guard', process.cwd());
14
16
  if (!config.enabled) process.exit(0);
15
17
 
16
- let input;
17
- try {
18
- input = JSON.parse(readFileSync(0, 'utf8'));
19
- } catch {
20
- process.exit(0);
21
- }
18
+ const input = parseHookInput();
19
+ if (!input) process.exit(0);
22
20
 
23
21
  const filePath = input.tool_input?.file_path || '';
24
22
 
@@ -41,11 +39,6 @@ if (/node_modules|\.next|dist|build|\.storybook/.test(filePath)) process.exit(0)
41
39
  // Skip layout, page files (Next.js App Router)
42
40
  if (/\/(layout|page|loading|error|not-found)\.(ts|tsx)$/.test(filePath)) process.exit(0);
43
41
 
44
- function sanitizeId(id) {
45
- if (!id || typeof id !== 'string') return 'default';
46
- return id.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128) || 'default';
47
- }
48
-
49
42
  const sessionId = sanitizeId(input.session_id);
50
43
 
51
44
  const dir = dirname(filePath);
@@ -59,10 +52,12 @@ const testFileTs = join(dir, `${name}.test.ts`);
59
52
 
60
53
  if (!existsSync(testFile) && !existsSync(specFile) && !existsSync(testFileTsx) && !existsSync(testFileTs)) {
61
54
  recordEvent(sessionId, 'tdd-guard', 'warn');
55
+ appendEvent(process.cwd(), { hook: 'tdd-guard', event: 'warn', file: filePath, message: `No test file for ${basename(filePath)}` });
56
+ notifyUser(config.notify, 'TDD Guard', 'warn', `${basename(filePath)} 테스트 파일 없음`);
62
57
  const output = JSON.stringify({
63
58
  hookSpecificOutput: {
64
59
  hookEventName: "PreToolUse",
65
- additionalContext: `🔨 Smith > TDD Guard: ${basename(filePath)} has no test file (${name}.test${ext}). TDD Iron Law: write failing test FIRST.\n💡 Coaching: 1) Create ${name}.test${ext} in the same directory. 2) Write a test that describes expected behavior. 3) Run the test to confirm it fails. 4) Then implement the code.`
60
+ additionalContext: `🕵️ Smith > TDD Guard: ${basename(filePath)} has no test file (${name}.test${ext}). TDD Iron Law: write failing test FIRST.\n💡 Coaching: 1) Create ${name}.test${ext} in the same directory. 2) Write a test that describes expected behavior. 3) Run the test to confirm it fails. 4) Then implement the code.`
66
61
  }
67
62
  });
68
63
  process.stdout.write(output);
@@ -10,16 +10,14 @@ import { join } from 'path';
10
10
  import { tmpdir } from 'os';
11
11
  import { getHookConfig } from '../lib/config.mjs';
12
12
  import { recordEvent } from '../lib/stats.mjs';
13
+ import { appendEvent } from '../lib/event-log.mjs';
14
+ import { sanitizeId, parseHookInput, notifyUser } from '../lib/hook-utils.mjs';
13
15
 
14
16
  const config = getHookConfig('test-tracker', process.cwd());
15
17
  if (!config.enabled) process.exit(0);
16
18
 
17
- let input;
18
- try {
19
- input = JSON.parse(readFileSync(0, 'utf8'));
20
- } catch {
21
- process.exit(0);
22
- }
19
+ const input = parseHookInput();
20
+ if (!input) process.exit(0);
23
21
 
24
22
  const toolName = input.tool_name;
25
23
  const command = input.tool_input?.command || '';
@@ -28,11 +26,6 @@ if (toolName !== 'Bash') process.exit(0);
28
26
 
29
27
  // Detect test commands
30
28
  if (/pnpm test|npm test|yarn test|bun test|vitest|jest|pytest|go test|cargo test|make test|mocha/.test(command)) {
31
- function sanitizeId(id) {
32
- if (!id || typeof id !== 'string') return 'default';
33
- return id.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128) || 'default';
34
- }
35
-
36
29
  const sessionId = sanitizeId(input.session_id);
37
30
  const stateDir = join(tmpdir(), '.claude-smith', sessionId);
38
31
  mkdirSync(stateDir, { recursive: true, mode: 0o700 });
@@ -45,8 +38,11 @@ if (/pnpm test|npm test|yarn test|bun test|vitest|jest|pytest|go test|cargo test
45
38
  const response = JSON.stringify(input.tool_response || '').toLowerCase();
46
39
  const hasNonZeroFails = /[1-9]\d*\s+fail(?:ed|ing|ures?)?\b/i.test(response);
47
40
  const hasErrors = /[1-9]\d*\s+errors?\b/i.test(response) && !/0\s+errors?\b/.test(response);
48
- writeFileSync(join(stateDir, 'last-test-result'), (hasNonZeroFails || hasErrors) ? 'fail' : 'pass', { mode: 0o600 });
41
+ const failed = hasNonZeroFails || hasErrors;
42
+ writeFileSync(join(stateDir, 'last-test-result'), failed ? 'fail' : 'pass', { mode: 0o600 });
49
43
  recordEvent(sessionId, 'test-tracker', 'fire');
44
+ appendEvent(process.cwd(), { hook: 'test-tracker', event: 'fire', message: `${failed ? 'fail' : 'pass'}` });
45
+ notifyUser(config.notify, 'Test Tracker', 'track', failed ? '❌ fail' : '✅ pass');
50
46
 
51
47
  // Extract coverage percentage from test output
52
48
  function parseCoverage(text) {
@@ -75,10 +71,11 @@ if (/pnpm test|npm test|yarn test|bun test|vitest|jest|pytest|go test|cargo test
75
71
 
76
72
  if (prevCoverage !== null && coverage < prevCoverage) {
77
73
  const delta = (coverage - prevCoverage).toFixed(1);
74
+ appendEvent(process.cwd(), { hook: 'test-tracker', event: 'warn', message: `Coverage: ${prevCoverage}% → ${coverage}%` });
78
75
  const output = JSON.stringify({
79
76
  hookSpecificOutput: {
80
77
  hookEventName: "PostToolUse",
81
- additionalContext: `🔨 Smith 📊 Coverage Delta: ${prevCoverage}% → ${coverage}% (${delta}%). Test coverage decreased.\n💡 Coaching: 1) Check which lines lost coverage. 2) Add tests for uncovered paths. 3) Run coverage report: your-test-cmd --coverage`
78
+ additionalContext: `🕵️ Smith 📊 Coverage Delta: ${prevCoverage}% → ${coverage}% (${delta}%). Test coverage decreased.\n💡 Coaching: 1) Check which lines lost coverage. 2) Add tests for uncovered paths. 3) Run coverage report: your-test-cmd --coverage`
82
79
  }
83
80
  });
84
81
  process.stdout.write(output);
package/lib/config.mjs CHANGED
@@ -9,6 +9,7 @@ import { join } from 'path';
9
9
  const DEFAULTS = {
10
10
  language: 'en',
11
11
  omcCompat: false,
12
+ notify: false,
12
13
  hooks: {
13
14
  'tdd-guard': { enabled: true },
14
15
  'commit-guard': { enabled: true, maxTestAge: 300 },
@@ -68,7 +69,9 @@ export function loadConfig(projectRoot) {
68
69
 
69
70
  export function getHookConfig(hookName, projectRoot) {
70
71
  const config = loadConfig(projectRoot);
71
- return config.hooks?.[hookName] || DEFAULTS.hooks[hookName] || { enabled: true };
72
+ const hookConfig = config.hooks?.[hookName] || DEFAULTS.hooks[hookName] || { enabled: true };
73
+ hookConfig.notify = config.notify || false;
74
+ return hookConfig;
72
75
  }
73
76
 
74
77
  const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);