claude-smith 3.1.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.
package/README.ko.md CHANGED
@@ -1,8 +1,8 @@
1
1
  [English](README.md) | **한국어**
2
2
 
3
- # 🔨 claude-smith
3
+ # 🕵️ claude-smith
4
4
 
5
- > **Claude Code의 ESLint** — AI 코딩 세션에 물리적 강제를 더합니다.
5
+ > **코드의 조용한 수호자** — AI 코딩 세션의 물리적 규율 강제
6
6
 
7
7
  ## 문제
8
8
 
@@ -39,6 +39,24 @@ npx claude-smith init
39
39
 
40
40
  언어를 선택하면(en/ko/zh/ja) 끝. 12개 Hook 설치, 규칙 주입, 강제 활성화.
41
41
 
42
+ ## 실시간 알림
43
+
44
+ Claude Code 세션 중 Hook 활동을 실시간으로 확인하세요:
45
+
46
+ ```json
47
+ { "notify": true }
48
+ ```
49
+
50
+ 활성화하면 터미널에서 간략한 상태 메시지를 볼 수 있습니다:
51
+ ```
52
+ 🕵️ Smith — ⚠️ TDD Guard: LoginForm.tsx 테스트 파일 없음
53
+ 🕵️ Smith — ✅ Commit Guard: 커밋 허용
54
+ 🕵️ Smith — 🚫 Debug Loop: app.tsx 5회 편집 차단
55
+ 🕵️ Smith — 🤖 Subagent Inject: 규칙 주입됨
56
+ ```
57
+
58
+ `"notify": false` (기본값)로 설정하면 비활성화됩니다.
59
+
42
60
  ## 12개 Hook
43
61
 
44
62
  ### 차단하는 Hook (exit code 2 — 진행 자체를 막음)
@@ -148,6 +166,7 @@ claude-smith uninstall # 모든 구성 요소 제거
148
166
  ```json
149
167
  {
150
168
  "language": "ko",
169
+ "notify": false,
151
170
  "hooks": {
152
171
  "commit-guard": { "maxTestAge": 300 },
153
172
  "debug-loop": { "warnAt": 3, "blockAt": 5 },
@@ -187,13 +206,36 @@ claude-smith uninstall # 모든 구성 요소 제거
187
206
  - **안전한 권한**: 디렉터리 `0o700`, 파일 `0o600`
188
207
  - **경쟁 조건 방지**: Hook별 원자적 카운터 (공유 JSON 미사용)
189
208
  - **제로 의존성**: Node.js 기본 모듈만 사용
190
- - **63개 테스트**: 보안 엣지 케이스, 에러 처리, 크로스 플랫폼 시나리오
209
+ - **포괄적 테스트 스위트**: 보안 엣지 케이스, 에러 처리, 크로스 플랫폼 시나리오
191
210
 
192
211
  ## 요구 사항
193
212
 
194
213
  - Node.js >= 18
195
214
  - Hook을 지원하는 Claude Code (`.claude/settings.json`)
196
215
 
216
+ ## 문제 해결
217
+
218
+ ### Hook이 작동하지 않음
219
+ 1. 설치 검증: `claude-smith check`
220
+ 2. `.claude/settings.json`에 Hook 정의가 있는지 확인
221
+ 3. Node.js >= 18 확인: `node --version`
222
+
223
+ ### 병렬 에이전트에서 오탐 발생
224
+ `.claude-smith.json`에서 OMC 호환 모드 활성화:
225
+ ```json
226
+ { "omcCompat": true }
227
+ ```
228
+ debug-loop, batch-checkpoint, scope-guard, plan-guard 임계값이 상향됩니다.
229
+
230
+ ### 테스트가 "실행 안 됨"으로 표시됨
231
+ test-tracker Hook은 Bash 도구 호출의 테스트 명령만 감지합니다. 인라인이 아닌 터미널을 통해 테스트를 실행하세요.
232
+
233
+ ### Hook 에러
234
+ 개별 Hook을 수동으로 실행하여 디버깅:
235
+ ```bash
236
+ echo '{"tool_name":"Edit","tool_input":{"file_path":"test.ts"},"session_id":"debug"}' | node .claude/hooks/tdd-guard.mjs
237
+ ```
238
+
197
239
  ## 라이선스
198
240
 
199
241
  MIT
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  **English** | [한국어](README.ko.md)
2
2
 
3
- # 🔨 claude-smith
3
+ # 🕵️ claude-smith
4
4
 
5
- > **Claude Code's ESLint** — Physical enforcement for AI coding sessions.
5
+ > **Your code's silent guardian** — Physical enforcement for AI coding sessions.
6
6
 
7
7
  ## The Problem
8
8
 
@@ -39,6 +39,24 @@ npx claude-smith init
39
39
 
40
40
  Select your language (en/ko/zh/ja), and you're done. 12 hooks installed, rules injected, enforcement active.
41
41
 
42
+ ## Live Notifications
43
+
44
+ See hook activity in real-time during Claude Code sessions:
45
+
46
+ ```json
47
+ { "notify": true }
48
+ ```
49
+
50
+ When enabled, hooks write brief status messages visible in your terminal:
51
+ ```
52
+ 🕵️ Smith — ⚠️ TDD Guard: LoginForm.tsx test file missing
53
+ 🕵️ Smith — ✅ Commit Guard: Commit allowed
54
+ 🕵️ Smith — 🚫 Debug Loop: app.tsx 5 edits blocked
55
+ 🕵️ Smith — 🤖 Subagent Inject: Rules injected
56
+ ```
57
+
58
+ Set `"notify": false` (default) to disable.
59
+
42
60
  ## The 12 Hooks
43
61
 
44
62
  ### Hooks That Block (exit code 2 — action physically prevented)
@@ -148,6 +166,7 @@ Project-level settings in `.claude-smith.json` (all optional):
148
166
  ```json
149
167
  {
150
168
  "language": "en",
169
+ "notify": false,
151
170
  "hooks": {
152
171
  "commit-guard": { "maxTestAge": 300 },
153
172
  "debug-loop": { "warnAt": 3, "blockAt": 5 },
@@ -187,13 +206,36 @@ User settings always take priority over `omcCompat` defaults.
187
206
  - **Secure permissions**: Directories `0o700`, files `0o600`
188
207
  - **Race-condition safe**: Per-hook atomic counters (no shared JSON)
189
208
  - **Zero dependencies**: Node.js built-in modules only
190
- - **63 tests**: Security edge cases, error handling, cross-platform scenarios
209
+ - **Comprehensive test suite**: Security edge cases, error handling, cross-platform scenarios
191
210
 
192
211
  ## Requirements
193
212
 
194
213
  - Node.js >= 18
195
214
  - Claude Code with hook support (`.claude/settings.json`)
196
215
 
216
+ ## Troubleshooting
217
+
218
+ ### Hooks not firing
219
+ 1. Verify installation: `claude-smith check`
220
+ 2. Check `.claude/settings.json` contains hook definitions
221
+ 3. Ensure Node.js >= 18: `node --version`
222
+
223
+ ### False positives in parallel agents
224
+ Enable OMC compatibility mode in `.claude-smith.json`:
225
+ ```json
226
+ { "omcCompat": true }
227
+ ```
228
+ This raises thresholds for debug-loop, batch-checkpoint, scope-guard, and plan-guard.
229
+
230
+ ### Tests show as "not run"
231
+ The test-tracker hook only detects test commands in Bash tool calls. Ensure you run tests via the terminal, not inline.
232
+
233
+ ### Hook errors
234
+ Run individual hooks manually to debug:
235
+ ```bash
236
+ echo '{"tool_name":"Edit","tool_input":{"file_path":"test.ts"},"session_id":"debug"}' | node .claude/hooks/tdd-guard.mjs
237
+ ```
238
+
197
239
  ## License
198
240
 
199
241
  MIT
package/bin/cli.mjs CHANGED
@@ -79,7 +79,7 @@ switch (command) {
79
79
 
80
80
  async function init() {
81
81
  const cwd = process.cwd();
82
- console.log(`\n🔨 Smith v${VERSION} - init\n`);
82
+ console.log(`\n🕵️ Smith v${VERSION} - init\n`);
83
83
 
84
84
  // 0. Determine language
85
85
  const lang = await resolveLanguage();
@@ -265,7 +265,7 @@ async function resolveLanguage() {
265
265
 
266
266
  function update() {
267
267
  const cwd = process.cwd();
268
- console.log(`\n🔨 Smith v${VERSION} - update\n`);
268
+ console.log(`\n🕵️ Smith v${VERSION} - update\n`);
269
269
 
270
270
  // 1. Overwrite hooks (always latest)
271
271
  const hooksDir = join(cwd, '.claude', 'hooks');
@@ -319,7 +319,8 @@ function injectRules(claudeMdPath, lang = 'en') {
319
319
  const fallback = join(TEMPLATES_DIR, 'rules.en.md');
320
320
  const rulesPath = existsSync(rulesFile) ? rulesFile : fallback;
321
321
  const rules = readFileSync(rulesPath, 'utf8')
322
- .replace(/claude-smith v\d+\.\d+\.\d+/g, `claude-smith v${VERSION}`);
322
+ .replace(/claude-smith v\d+\.\d+\.\d+/g, `claude-smith v${VERSION}`)
323
+ .replace(/\{\{SMITH_VERSION\}\}/g, `v${VERSION}`);
323
324
 
324
325
  if (existsSync(claudeMdPath)) {
325
326
  let content = readFileSync(claudeMdPath, 'utf8');
@@ -385,7 +386,7 @@ function check() {
385
386
  const ciMode = process.argv.includes('--ci');
386
387
  const results = { version: VERSION, ok: true, hooks: {}, settings: false, rules: false };
387
388
 
388
- if (!ciMode) console.log(`\n🔨 Smith v${VERSION} - check\n`);
389
+ if (!ciMode) console.log(`\n🕵️ Smith v${VERSION} - check\n`);
389
390
 
390
391
  // Check hooks
391
392
  const hooksDir = join(cwd, '.claude', 'hooks');
@@ -448,7 +449,7 @@ function check() {
448
449
  }
449
450
 
450
451
  function stats() {
451
- console.log(`\n🔨 Smith v${VERSION} - stats\n`);
452
+ console.log(`\n🕵️ Smith v${VERSION} - stats\n`);
452
453
 
453
454
  // Try to find stats from any active session
454
455
  const baseDir = join(tmpdir(), '.claude-smith');
@@ -500,7 +501,7 @@ function stats() {
500
501
 
501
502
  async function uninstall() {
502
503
  const cwd = process.cwd();
503
- console.log(`\n🔨 Smith v${VERSION} - uninstall\n`);
504
+ console.log(`\n🕵️ Smith v${VERSION} - uninstall\n`);
504
505
 
505
506
  const SMITH_HOOKS = ['tdd-guard.mjs', 'commit-guard.mjs', 'test-tracker.mjs', 'debug-loop.mjs',
506
507
  'batch-checkpoint.mjs', 'subagent-inject.mjs', 'commit-message.mjs', 'build-guard.mjs',
@@ -595,7 +596,7 @@ async function uninstall() {
595
596
  }
596
597
 
597
598
  function report() {
598
- console.log(`\n🔨 Smith v${VERSION} - session report\n`);
599
+ console.log(`\n🕵️ Smith v${VERSION} - session report\n`);
599
600
 
600
601
  const baseDir = join(tmpdir(), '.claude-smith');
601
602
  if (!existsSync(baseDir)) {
@@ -784,7 +785,7 @@ ${!isPreTool ? "const toolResponse = input.tool_response || {};\n" : ""}
784
785
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
785
786
  }
786
787
 
787
- console.log(`\n🔨 Smith v${VERSION} - create-hook\n`);
788
+ console.log(`\n🕵️ Smith v${VERSION} - create-hook\n`);
788
789
  console.log(`✅ Hook created → .claude/hooks/${fileName}`);
789
790
  console.log(`✅ Registered in .claude/settings.json (${event})`);
790
791
  console.log(`\nNext steps:`);
@@ -803,7 +804,7 @@ function escapeHtml(str) {
803
804
  }
804
805
 
805
806
  function dashboard() {
806
- console.log(`\n🔨 Smith v${VERSION} - dashboard\n`);
807
+ console.log(`\n🕵️ Smith v${VERSION} - dashboard\n`);
807
808
 
808
809
  const baseDir = join(tmpdir(), '.claude-smith');
809
810
  if (!existsSync(baseDir)) {
@@ -1056,7 +1057,7 @@ function dashboard() {
1056
1057
  <head>
1057
1058
  <meta charset="UTF-8">
1058
1059
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1059
- <title>🔨 Smith Dashboard</title>
1060
+ <title>🕵️ Smith Dashboard</title>
1060
1061
  <style>
1061
1062
  * { margin: 0; padding: 0; box-sizing: border-box; }
1062
1063
  body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0d1117; color: #c9d1d9; padding: 2rem; }
@@ -1113,7 +1114,7 @@ function dashboard() {
1113
1114
  </style>
1114
1115
  </head>
1115
1116
  <body>
1116
- <h1>🔨 Smith</h1>
1117
+ <h1>🕵️ Smith</h1>
1117
1118
  <p class="subtitle">Session Protection Report — ${new Date().toISOString().split('T')[0]}</p>
1118
1119
 
1119
1120
  <div class="hero-grid">
@@ -1188,7 +1189,7 @@ ${hookNames.map((name, i) => {
1188
1189
  ${insightsHtml}
1189
1190
  </div>
1190
1191
 
1191
- <footer>Generated by 🔨 Smith v${escapeHtml(VERSION)} | ${escapeHtml(new Date().toISOString())}</footer>
1192
+ <footer>Generated by 🕵️ Smith v${escapeHtml(VERSION)} | ${escapeHtml(new Date().toISOString())}</footer>
1192
1193
  </body>
1193
1194
  </html>`;
1194
1195
 
@@ -1200,7 +1201,7 @@ ${insightsHtml}
1200
1201
 
1201
1202
  function help() {
1202
1203
  console.log(`
1203
- 🔨 Smith v${VERSION}
1204
+ 🕵️ Smith v${VERSION}
1204
1205
  Claude Code workflow enforcement CLI - forging coding discipline into every session
1205
1206
 
1206
1207
  Usage:
@@ -5,34 +5,23 @@
5
5
  * Counts distinct files edited, reminds to report progress at threshold (default: 3)
6
6
  */
7
7
 
8
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
8
+ import { writeFileSync, existsSync, mkdirSync, readFileSync } 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
13
  import { appendEvent } from '../lib/event-log.mjs';
14
+ import { sanitizeId, parseHookInput, isOmcAutoMode, notifyUser } from '../lib/hook-utils.mjs';
14
15
 
15
16
  const config = getHookConfig('batch-checkpoint', process.cwd());
16
17
  if (!config.enabled) process.exit(0);
17
18
 
18
19
  // Skip in OMC autonomous execution modes (autopilot, ultrawork, ralph)
19
20
  // These modes manage their own progress reporting and checkpoint would interfere
20
- const omcStateDir = join(process.cwd(), '.omc', 'state');
21
- const omcAutoModes = ['autopilot-state.json', 'ultrawork-state.json', 'ralph-state.json'];
22
- const isOmcAutoMode = omcAutoModes.some(f => {
23
- try {
24
- const state = JSON.parse(readFileSync(join(omcStateDir, f), 'utf8'));
25
- return state.active === true || state.status === 'running';
26
- } catch { return false; }
27
- });
28
- if (isOmcAutoMode) process.exit(0);
21
+ if (isOmcAutoMode(process.cwd())) process.exit(0);
29
22
 
30
- let input;
31
- try {
32
- input = JSON.parse(readFileSync(0, 'utf8'));
33
- } catch {
34
- process.exit(0);
35
- }
23
+ const input = parseHookInput();
24
+ if (!input) process.exit(0);
36
25
 
37
26
  const toolName = input.tool_name;
38
27
  const filePath = input.tool_input?.file_path || '';
@@ -43,11 +32,6 @@ if (!filePath) process.exit(0);
43
32
  // Skip non-source files
44
33
  if (!/\.(ts|tsx|js|jsx|py|go|rs|css|json)$/.test(filePath)) process.exit(0);
45
34
 
46
- function sanitizeId(id) {
47
- if (!id || typeof id !== 'string') return 'default';
48
- return id.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128) || 'default';
49
- }
50
-
51
35
  const sessionId = sanitizeId(input.session_id);
52
36
  const stateDir = join(tmpdir(), '.claude-smith', sessionId);
53
37
  mkdirSync(stateDir, { recursive: true, mode: 0o700 });
@@ -73,10 +57,11 @@ if (!editedFiles.includes(filePath)) {
73
57
  if (editedFiles.length > 0 && editedFiles.length % config.fileThreshold === 0) {
74
58
  recordEvent(sessionId, 'batch-checkpoint', 'warn');
75
59
  appendEvent(process.cwd(), { hook: 'batch-checkpoint', event: 'warn', message: `${editedFiles.length} files edited` });
60
+ notifyUser(config.notify, 'Batch Checkpoint', 'warn', `${editedFiles.length}개 파일 편집`);
76
61
  const output = JSON.stringify({
77
62
  hookSpecificOutput: {
78
63
  hookEventName: "PostToolUse",
79
- additionalContext: `🔨 Smith 📋 Batch Checkpoint (rule 2-9): ${editedFiles.length} files edited. Report progress to user before continuing.`
64
+ additionalContext: `🕵️ Smith 📋 Batch Checkpoint (rule 2-9): ${editedFiles.length} files edited. Report progress to user before continuing.`
80
65
  }
81
66
  });
82
67
  process.stdout.write(output);
@@ -11,16 +11,13 @@ import { tmpdir } from 'os';
11
11
  import { getHookConfig } from '../lib/config.mjs';
12
12
  import { recordEvent } from '../lib/stats.mjs';
13
13
  import { appendEvent } from '../lib/event-log.mjs';
14
+ import { sanitizeId, parseHookInput, notifyUser } from '../lib/hook-utils.mjs';
14
15
 
15
16
  const config = getHookConfig('build-guard', process.cwd());
16
17
  if (!config.enabled) process.exit(0);
17
18
 
18
- let input;
19
- try {
20
- input = JSON.parse(readFileSync(0, 'utf8'));
21
- } catch {
22
- process.exit(0);
23
- }
19
+ const input = parseHookInput();
20
+ if (!input) process.exit(0);
24
21
 
25
22
  const command = input.tool_input?.command || '';
26
23
 
@@ -28,11 +25,6 @@ if (input.tool_name !== 'Bash') process.exit(0);
28
25
  if (!command.includes('git commit')) process.exit(0);
29
26
  if (command.includes('--allow-empty')) process.exit(0);
30
27
 
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
28
  const sessionId = sanitizeId(input.session_id);
37
29
  const stateDir = join(tmpdir(), '.claude-smith', sessionId);
38
30
  const lastBuildResult = join(stateDir, 'last-build-result');
@@ -44,7 +36,8 @@ if (existsSync(lastBuildResult)) {
44
36
  if (result === 'fail') {
45
37
  recordEvent(sessionId, 'build-guard', 'block');
46
38
  appendEvent(process.cwd(), { hook: 'build-guard', event: 'block', message: 'Build failing' });
47
- process.stderr.write("🔨 Smith Commit blocked: last build FAILED. Run build and fix errors before committing.");
39
+ notifyUser(config.notify, 'Build Guard', 'block', '빌드 실패 커밋 차단');
40
+ process.stderr.write("🕵️ Smith ✋ Commit blocked: last build FAILED. Run build and fix errors before committing.");
48
41
  process.exit(2);
49
42
  }
50
43
  }
@@ -5,22 +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
13
  import { appendEvent } from '../lib/event-log.mjs';
14
+ import { sanitizeId, parseHookInput, notifyUser } from '../lib/hook-utils.mjs';
14
15
 
15
16
  const config = getHookConfig('build-tracker', process.cwd());
16
17
  if (!config.enabled) process.exit(0);
17
18
 
18
- let input;
19
- try {
20
- input = JSON.parse(readFileSync(0, 'utf8'));
21
- } catch {
22
- process.exit(0);
23
- }
19
+ const input = parseHookInput();
20
+ if (!input) process.exit(0);
24
21
 
25
22
  const command = input.tool_input?.command || '';
26
23
 
@@ -28,11 +25,6 @@ if (input.tool_name !== 'Bash') process.exit(0);
28
25
 
29
26
  // Detect build commands
30
27
  if (/pnpm build|npm run build|yarn build|tsc|next build/.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
28
  const sessionId = sanitizeId(input.session_id);
37
29
  const stateDir = join(tmpdir(), '.claude-smith', sessionId);
38
30
  mkdirSync(stateDir, { recursive: true, mode: 0o700 });
@@ -44,4 +36,5 @@ if (/pnpm build|npm run build|yarn build|tsc|next build/.test(command)) {
44
36
  writeFileSync(join(stateDir, 'last-build-result'), result, { mode: 0o600 });
45
37
  recordEvent(sessionId, 'build-tracker', 'fire');
46
38
  appendEvent(process.cwd(), { hook: 'build-tracker', event: 'fire', message: result });
39
+ notifyUser(config.notify, 'Build Tracker', 'track', failed ? '❌ fail' : '✅ pass');
47
40
  }
@@ -13,16 +13,13 @@ import { tmpdir } from 'os';
13
13
  import { getHookConfig } from '../lib/config.mjs';
14
14
  import { recordEvent } from '../lib/stats.mjs';
15
15
  import { appendEvent } from '../lib/event-log.mjs';
16
+ import { sanitizeId, parseHookInput, notifyUser } from '../lib/hook-utils.mjs';
16
17
 
17
18
  const config = getHookConfig('commit-guard', process.cwd());
18
19
  if (!config.enabled) process.exit(0);
19
20
 
20
- let input;
21
- try {
22
- input = JSON.parse(readFileSync(0, 'utf8'));
23
- } catch {
24
- process.exit(0);
25
- }
21
+ const input = parseHookInput();
22
+ if (!input) process.exit(0);
26
23
 
27
24
  const toolName = input.tool_name;
28
25
  const command = input.tool_input?.command || '';
@@ -33,26 +30,19 @@ if (!command.includes('git commit')) process.exit(0);
33
30
  // Skip amend, merge commits
34
31
  if (command.includes('--allow-empty')) process.exit(0);
35
32
 
36
- function sanitizeId(id) {
37
- if (!id || typeof id !== 'string') return 'default';
38
- return id.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128) || 'default';
39
- }
40
-
41
33
  const sessionId = sanitizeId(input.session_id);
42
34
  const stateDir = join(tmpdir(), '.claude-smith', sessionId);
43
35
  const lastTestFile = join(stateDir, 'last-test-run');
44
36
  const lastResultFile = join(stateDir, 'last-test-result');
45
37
 
46
- recordEvent(sessionId, 'commit-guard', 'fire');
47
- appendEvent(process.cwd(), { hook: 'commit-guard', event: 'fire', message: 'Tests passed, commit allowed' });
48
-
49
38
  // Check test result first (hard block on failure)
50
39
  if (existsSync(lastResultFile)) {
51
40
  const result = readFileSync(lastResultFile, 'utf8').trim();
52
41
  if (result === 'fail') {
53
42
  recordEvent(sessionId, 'commit-guard', 'block');
54
43
  appendEvent(process.cwd(), { hook: 'commit-guard', event: 'block', message: 'Tests failing' });
55
- 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.");
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.");
56
46
  process.exit(2);
57
47
  }
58
48
  }
@@ -65,22 +55,31 @@ if (existsSync(lastTestFile)) {
65
55
  if (diff > config.maxTestAge) {
66
56
  recordEvent(sessionId, 'commit-guard', 'warn');
67
57
  appendEvent(process.cwd(), { hook: 'commit-guard', event: 'warn', message: 'Tests stale' });
58
+ notifyUser(config.notify, 'Commit Guard', 'warn', '테스트 경과 시간 초과');
68
59
  const output = JSON.stringify({
69
60
  hookSpecificOutput: {
70
61
  hookEventName: "PreToolUse",
71
- 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.`
72
63
  }
73
64
  });
74
65
  process.stdout.write(output);
66
+ process.exit(0);
75
67
  }
76
68
  } else {
77
69
  recordEvent(sessionId, 'commit-guard', 'warn');
78
70
  appendEvent(process.cwd(), { hook: 'commit-guard', event: 'warn', message: 'No test run detected' });
71
+ notifyUser(config.notify, 'Commit Guard', 'warn', '테스트 실행 기록 없음');
79
72
  const output = JSON.stringify({
80
73
  hookSpecificOutput: {
81
74
  hookEventName: "PreToolUse",
82
- 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."
83
76
  }
84
77
  });
85
78
  process.stdout.write(output);
79
+ process.exit(0);
86
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,40 +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';
11
10
  import { appendEvent } from '../lib/event-log.mjs';
11
+ import { sanitizeId, parseHookInput, notifyUser } from '../lib/hook-utils.mjs';
12
12
 
13
13
  const config = getHookConfig('commit-message', process.cwd());
14
14
  if (!config.enabled) process.exit(0);
15
15
 
16
- let input;
17
- try {
18
- input = JSON.parse(readFileSync(0, 'utf8'));
19
- } catch {
20
- process.exit(0);
21
- }
16
+ const input = parseHookInput();
17
+ if (!input) process.exit(0);
22
18
 
23
19
  const command = input.tool_input?.command || '';
24
20
 
25
21
  if (input.tool_name !== 'Bash') process.exit(0);
26
22
  if (!command.includes('git commit')) process.exit(0);
27
23
 
28
- function sanitizeId(id) {
29
- if (!id || typeof id !== 'string') return 'default';
30
- return id.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128) || 'default';
31
- }
32
-
33
24
  const sessionId = sanitizeId(input.session_id);
34
25
 
35
26
  // Extract commit message from -m flag
36
- const msgMatch = command.match(/-m\s+"([^"]+)"/s)
37
- || command.match(/-m\s+'([^']+)'/s)
38
- || 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);
39
31
  if (!msgMatch) process.exit(0); // Can't parse message, let it through
40
32
 
41
- const msg = msgMatch[1] || msgMatch[2] || '';
33
+ const msg = msgMatch[1] || '';
42
34
  const firstLine = msg.split('\n')[0].trim();
43
35
 
44
36
  // Conventional commit pattern: type(scope): description OR type: description
@@ -47,11 +39,17 @@ const pattern = /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)
47
39
  if (!pattern.test(firstLine)) {
48
40
  recordEvent(sessionId, 'commit-message', 'warn');
49
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)}`);
50
43
  const output = JSON.stringify({
51
44
  hookSpecificOutput: {
52
45
  hookEventName: "PreToolUse",
53
- 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)`
54
47
  }
55
48
  });
56
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', '형식 유효');
57
55
  }
@@ -12,16 +12,13 @@ import { createHash } from 'crypto';
12
12
  import { getHookConfig } from '../lib/config.mjs';
13
13
  import { recordEvent } from '../lib/stats.mjs';
14
14
  import { appendEvent } from '../lib/event-log.mjs';
15
+ import { sanitizeId, parseHookInput, isOmcAutoMode, notifyUser } from '../lib/hook-utils.mjs';
15
16
 
16
17
  const config = getHookConfig('debug-loop', process.cwd());
17
18
  if (!config.enabled) process.exit(0);
18
19
 
19
- let input;
20
- try {
21
- input = JSON.parse(readFileSync(0, 'utf8'));
22
- } catch {
23
- process.exit(0);
24
- }
20
+ const input = parseHookInput();
21
+ if (!input) process.exit(0);
25
22
 
26
23
  const toolName = input.tool_name;
27
24
  const filePath = input.tool_input?.file_path || '';
@@ -32,11 +29,6 @@ if (!filePath) process.exit(0);
32
29
  // Skip non-source files
33
30
  if (!/\.(ts|tsx|js|jsx|py|go|rs)$/.test(filePath)) process.exit(0);
34
31
 
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
32
  // Isolate state per agent when sub-agents provide their own ID
41
33
  const sessionId = sanitizeId(input.session_id);
42
34
  const agentId = sanitizeId(input.agent_id || '');
@@ -45,15 +37,7 @@ const stateDir = join(tmpdir(), '.claude-smith', stateKey);
45
37
  mkdirSync(stateDir, { recursive: true, mode: 0o700 });
46
38
 
47
39
  // Raise thresholds during OMC autonomous modes to avoid false positives in parallel execution
48
- const omcStateDir = join(process.cwd(), '.omc', 'state');
49
- const omcAutoModes = ['autopilot-state.json', 'ultrawork-state.json', 'ralph-state.json'];
50
- const isOmcAutoMode = omcAutoModes.some(f => {
51
- try {
52
- const state = JSON.parse(readFileSync(join(omcStateDir, f), 'utf8'));
53
- return state.active === true || state.status === 'running';
54
- } catch { return false; }
55
- });
56
- if (isOmcAutoMode) {
40
+ if (isOmcAutoMode(process.cwd())) {
57
41
  config.warnAt = Math.max(config.warnAt, 6);
58
42
  config.blockAt = Math.max(config.blockAt, 10);
59
43
  }
@@ -102,16 +86,18 @@ const patternHint = pattern === 'divergent'
102
86
  if (count === config.warnAt) {
103
87
  recordEvent(sessionId, 'debug-loop', 'warn');
104
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' ? ' (반복 패턴)' : ''}`);
105
90
  const output = JSON.stringify({
106
91
  hookSpecificOutput: {
107
92
  hookEventName: "PostToolUse",
108
- 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.`
109
94
  }
110
95
  });
111
96
  process.stdout.write(output);
112
97
  } else if (count >= config.blockAt) {
113
98
  recordEvent(sessionId, 'debug-loop', 'block');
114
99
  appendEvent(process.cwd(), { hook: 'debug-loop', event: 'block', file: filePath, message: `Edit #${count} - blocked` });
115
- 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.`);
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.`);
116
102
  process.exit(2);
117
103
  }
@@ -10,16 +10,13 @@ import { basename } from 'path';
10
10
  import { getHookConfig } from '../lib/config.mjs';
11
11
  import { recordEvent } from '../lib/stats.mjs';
12
12
  import { appendEvent } from '../lib/event-log.mjs';
13
+ import { sanitizeId, parseHookInput, notifyUser } from '../lib/hook-utils.mjs';
13
14
 
14
15
  const config = getHookConfig('file-size-warn', process.cwd());
15
16
  if (!config.enabled) process.exit(0);
16
17
 
17
- let input;
18
- try {
19
- input = JSON.parse(readFileSync(0, 'utf8'));
20
- } catch {
21
- process.exit(0);
22
- }
18
+ const input = parseHookInput();
19
+ if (!input) process.exit(0);
23
20
 
24
21
  const filePath = input.tool_input?.file_path || '';
25
22
 
@@ -31,11 +28,6 @@ if (!/\.(ts|tsx|js|jsx|py|go|rs)$/.test(filePath)) process.exit(0);
31
28
 
32
29
  if (!existsSync(filePath)) process.exit(0);
33
30
 
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
31
  const sessionId = sanitizeId(input.session_id);
40
32
 
41
33
  const lines = readFileSync(filePath, 'utf8').split('\n').length;
@@ -44,10 +36,11 @@ const threshold = config.maxLines || 500;
44
36
  if (lines > threshold) {
45
37
  recordEvent(sessionId, 'file-size-warn', 'warn');
46
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})`);
47
40
  const output = JSON.stringify({
48
41
  hookSpecificOutput: {
49
42
  hookEventName: "PostToolUse",
50
- 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.`
51
44
  }
52
45
  });
53
46
  process.stdout.write(output);
@@ -13,25 +13,17 @@ import { tmpdir } from 'os';
13
13
  import { getHookConfig } from '../lib/config.mjs';
14
14
  import { recordEvent } from '../lib/stats.mjs';
15
15
  import { appendEvent } from '../lib/event-log.mjs';
16
+ import { sanitizeId, parseHookInput, isOmcAutoMode, notifyUser } from '../lib/hook-utils.mjs';
16
17
 
17
18
  const config = getHookConfig('plan-guard', process.cwd());
18
19
  if (!config.enabled) process.exit(0);
19
20
 
20
- let input;
21
- try {
22
- input = JSON.parse(readFileSync(0, 'utf8'));
23
- } catch {
24
- process.exit(0);
25
- }
21
+ const input = parseHookInput();
22
+ if (!input) process.exit(0);
26
23
 
27
24
  const filePath = input.tool_input?.file_path || '';
28
25
  if (!filePath) process.exit(0);
29
26
 
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
27
  const sessionId = sanitizeId(input.session_id);
36
28
  const agentId = sanitizeId(input.agent_id || '');
37
29
  const stateKey = agentId && agentId !== 'default' ? `${sessionId}-${agentId}` : sessionId;
@@ -92,23 +84,16 @@ count++;
92
84
  writeFileSync(counterFile, String(count), { mode: 0o600 });
93
85
 
94
86
  // Raise threshold during OMC autonomous modes
95
- const omcStateDir = join(process.cwd(), '.omc', 'state');
96
- const omcAutoModes = ['autopilot-state.json', 'ultrawork-state.json', 'ralph-state.json'];
97
- const isOmcAutoMode = omcAutoModes.some(f => {
98
- try {
99
- const state = JSON.parse(readFileSync(join(omcStateDir, f), 'utf8'));
100
- return state.active === true || state.status === 'running';
101
- } catch { return false; }
102
- });
103
-
104
- const warnAt = isOmcAutoMode ? (config.warnAt || 5) * 2 : (config.warnAt || 5);
105
- 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);
106
90
 
107
91
  // Block if threshold reached
108
92
  if (count >= blockAt) {
109
93
  recordEvent(sessionId, 'plan-guard', 'block');
110
94
  appendEvent(process.cwd(), { hook: 'plan-guard', event: 'block', message: `${count} files without plan` });
111
- process.stderr.write(`🔨 Smith ✋ Plan Guard: ${count} code files edited without a decomposition plan. Create a plan first.`);
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.`);
112
97
  process.exit(2);
113
98
  }
114
99
 
@@ -116,10 +101,11 @@ if (count >= blockAt) {
116
101
  if (count >= warnAt) {
117
102
  recordEvent(sessionId, 'plan-guard', 'warn');
118
103
  appendEvent(process.cwd(), { hook: 'plan-guard', event: 'warn', message: `${count} files without plan` });
104
+ notifyUser(config.notify, 'Plan Guard', 'warn', `${count}개 파일 계획 없이 편집`);
119
105
  const output = JSON.stringify({
120
106
  hookSpecificOutput: {
121
107
  hookEventName: "PreToolUse",
122
- 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.
123
109
  💡 Create a plan file (docs/plans/*.md or plan.md) that includes:
124
110
  1. Each unit's responsibility (one sentence)
125
111
  2. Dependency direction between units
@@ -11,16 +11,13 @@ import { tmpdir } from 'os';
11
11
  import { getHookConfig } from '../lib/config.mjs';
12
12
  import { recordEvent } from '../lib/stats.mjs';
13
13
  import { appendEvent } from '../lib/event-log.mjs';
14
+ import { sanitizeId, parseHookInput, isOmcAutoMode, notifyUser } from '../lib/hook-utils.mjs';
14
15
 
15
16
  const config = getHookConfig('scope-guard', process.cwd());
16
17
  if (!config.enabled) process.exit(0);
17
18
 
18
- let input;
19
- try {
20
- input = JSON.parse(readFileSync(0, 'utf8'));
21
- } catch {
22
- process.exit(0);
23
- }
19
+ const input = parseHookInput();
20
+ if (!input) process.exit(0);
24
21
 
25
22
  const filePath = input.tool_input?.file_path || '';
26
23
 
@@ -28,11 +25,6 @@ if (!['Edit', 'Write'].includes(input.tool_name)) process.exit(0);
28
25
  if (!filePath) process.exit(0);
29
26
  if (!/\.(ts|tsx|js|jsx|py|go|rs|css|json)$/.test(filePath)) process.exit(0);
30
27
 
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
28
  // Isolate state per agent when sub-agents provide their own ID
37
29
  const sessionId = sanitizeId(input.session_id);
38
30
  const agentId = sanitizeId(input.agent_id || '');
@@ -40,16 +32,6 @@ const stateKey = agentId && agentId !== 'default' ? `${sessionId}-${agentId}` :
40
32
  const stateDir = join(tmpdir(), '.claude-smith', stateKey);
41
33
  mkdirSync(stateDir, { recursive: true, mode: 0o700 });
42
34
 
43
- // Raise threshold during OMC autonomous modes to avoid false positives in parallel execution
44
- const omcStateDir = join(process.cwd(), '.omc', 'state');
45
- const omcAutoModes = ['autopilot-state.json', 'ultrawork-state.json', 'ralph-state.json'];
46
- const isOmcAutoMode = omcAutoModes.some(f => {
47
- try {
48
- const state = JSON.parse(readFileSync(join(omcStateDir, f), 'utf8'));
49
- return state.active === true || state.status === 'running';
50
- } catch { return false; }
51
- });
52
-
53
35
  const stateFile = join(stateDir, 'scope-directories');
54
36
  const dir = dirname(filePath);
55
37
 
@@ -67,15 +49,17 @@ if (!dirs.includes(dir)) {
67
49
  writeFileSync(stateFile, JSON.stringify(dirs), { mode: 0o600 });
68
50
  }
69
51
 
70
- 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);
71
54
 
72
55
  if (dirs.length >= threshold && (dirs.length === threshold || dirs.length % threshold === 0)) {
73
56
  recordEvent(sessionId, 'scope-guard', 'warn');
74
57
  appendEvent(process.cwd(), { hook: 'scope-guard', event: 'warn', message: `${dirs.length} directories` });
58
+ notifyUser(config.notify, 'Scope Guard', 'warn', `${dirs.length}개 디렉토리 변경`);
75
59
  const output = JSON.stringify({
76
60
  hookSpecificOutput: {
77
61
  hookEventName: "PostToolUse",
78
- 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.`
79
63
  }
80
64
  });
81
65
  process.stdout.write(output);
@@ -5,31 +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';
11
10
  import { appendEvent } from '../lib/event-log.mjs';
11
+ import { sanitizeId, parseHookInput, notifyUser } from '../lib/hook-utils.mjs';
12
12
 
13
13
  const config = getHookConfig('subagent-inject', process.cwd());
14
14
  if (!config.enabled) process.exit(0);
15
15
 
16
16
  // Consume stdin (required by hook protocol)
17
- let input;
18
- try {
19
- input = JSON.parse(readFileSync(0, 'utf8'));
20
- } catch {
21
- process.exit(0);
22
- }
23
-
24
- function sanitizeId(id) {
25
- if (!id || typeof id !== 'string') return 'default';
26
- return id.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128) || 'default';
27
- }
17
+ const input = parseHookInput();
18
+ if (!input) process.exit(0);
28
19
 
29
20
  const sessionId = sanitizeId(input.session_id);
30
21
 
31
22
  recordEvent(sessionId, 'subagent-inject', 'fire');
32
23
  appendEvent(process.cwd(), { hook: 'subagent-inject', event: 'fire', message: 'Rules injected into subagent' });
24
+ notifyUser(config.notify, 'Subagent Inject', 'inject', '규칙 주입됨');
33
25
  const output = JSON.stringify({
34
26
  hookSpecificOutput: {
35
27
  hookEventName: "SubagentStart",
@@ -5,21 +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
12
  import { appendEvent } from '../lib/event-log.mjs';
13
+ import { sanitizeId, parseHookInput, notifyUser } from '../lib/hook-utils.mjs';
13
14
 
14
15
  const config = getHookConfig('tdd-guard', process.cwd());
15
16
  if (!config.enabled) process.exit(0);
16
17
 
17
- let input;
18
- try {
19
- input = JSON.parse(readFileSync(0, 'utf8'));
20
- } catch {
21
- process.exit(0);
22
- }
18
+ const input = parseHookInput();
19
+ if (!input) process.exit(0);
23
20
 
24
21
  const filePath = input.tool_input?.file_path || '';
25
22
 
@@ -42,11 +39,6 @@ if (/node_modules|\.next|dist|build|\.storybook/.test(filePath)) process.exit(0)
42
39
  // Skip layout, page files (Next.js App Router)
43
40
  if (/\/(layout|page|loading|error|not-found)\.(ts|tsx)$/.test(filePath)) process.exit(0);
44
41
 
45
- function sanitizeId(id) {
46
- if (!id || typeof id !== 'string') return 'default';
47
- return id.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128) || 'default';
48
- }
49
-
50
42
  const sessionId = sanitizeId(input.session_id);
51
43
 
52
44
  const dir = dirname(filePath);
@@ -61,10 +53,11 @@ const testFileTs = join(dir, `${name}.test.ts`);
61
53
  if (!existsSync(testFile) && !existsSync(specFile) && !existsSync(testFileTsx) && !existsSync(testFileTs)) {
62
54
  recordEvent(sessionId, 'tdd-guard', 'warn');
63
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)} 테스트 파일 없음`);
64
57
  const output = JSON.stringify({
65
58
  hookSpecificOutput: {
66
59
  hookEventName: "PreToolUse",
67
- 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.`
68
61
  }
69
62
  });
70
63
  process.stdout.write(output);
@@ -11,16 +11,13 @@ import { tmpdir } from 'os';
11
11
  import { getHookConfig } from '../lib/config.mjs';
12
12
  import { recordEvent } from '../lib/stats.mjs';
13
13
  import { appendEvent } from '../lib/event-log.mjs';
14
+ import { sanitizeId, parseHookInput, notifyUser } from '../lib/hook-utils.mjs';
14
15
 
15
16
  const config = getHookConfig('test-tracker', process.cwd());
16
17
  if (!config.enabled) process.exit(0);
17
18
 
18
- let input;
19
- try {
20
- input = JSON.parse(readFileSync(0, 'utf8'));
21
- } catch {
22
- process.exit(0);
23
- }
19
+ const input = parseHookInput();
20
+ if (!input) process.exit(0);
24
21
 
25
22
  const toolName = input.tool_name;
26
23
  const command = input.tool_input?.command || '';
@@ -29,11 +26,6 @@ if (toolName !== 'Bash') process.exit(0);
29
26
 
30
27
  // Detect test commands
31
28
  if (/pnpm test|npm test|yarn test|bun test|vitest|jest|pytest|go test|cargo test|make test|mocha/.test(command)) {
32
- function sanitizeId(id) {
33
- if (!id || typeof id !== 'string') return 'default';
34
- return id.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128) || 'default';
35
- }
36
-
37
29
  const sessionId = sanitizeId(input.session_id);
38
30
  const stateDir = join(tmpdir(), '.claude-smith', sessionId);
39
31
  mkdirSync(stateDir, { recursive: true, mode: 0o700 });
@@ -50,6 +42,7 @@ if (/pnpm test|npm test|yarn test|bun test|vitest|jest|pytest|go test|cargo test
50
42
  writeFileSync(join(stateDir, 'last-test-result'), failed ? 'fail' : 'pass', { mode: 0o600 });
51
43
  recordEvent(sessionId, 'test-tracker', 'fire');
52
44
  appendEvent(process.cwd(), { hook: 'test-tracker', event: 'fire', message: `${failed ? 'fail' : 'pass'}` });
45
+ notifyUser(config.notify, 'Test Tracker', 'track', failed ? '❌ fail' : '✅ pass');
53
46
 
54
47
  // Extract coverage percentage from test output
55
48
  function parseCoverage(text) {
@@ -82,7 +75,7 @@ if (/pnpm test|npm test|yarn test|bun test|vitest|jest|pytest|go test|cargo test
82
75
  const output = JSON.stringify({
83
76
  hookSpecificOutput: {
84
77
  hookEventName: "PostToolUse",
85
- 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`
86
79
  }
87
80
  });
88
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']);
package/lib/event-log.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
  * Provides project-level audit trail of hook activity
7
7
  */
8
8
 
9
- import { appendFileSync, readFileSync, mkdirSync, existsSync, readdirSync } from 'fs';
9
+ import { appendFileSync, readFileSync, mkdirSync, existsSync, readdirSync, lstatSync } from 'fs';
10
10
  import { join } from 'path';
11
11
 
12
12
  /**
@@ -21,9 +21,19 @@ export function appendEvent(projectDir, { hook, event, file = '', message = '' }
21
21
  const eventsDir = join(projectDir, '.smith', 'events');
22
22
  mkdirSync(eventsDir, { recursive: true, mode: 0o700 });
23
23
 
24
+ // Symlink check: ensure events dir is a real directory, not a symlink
25
+ const stat = lstatSync(eventsDir);
26
+ if (!stat.isDirectory()) return; // Don't write to symlinks
27
+
24
28
  const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
25
29
  const logFile = join(eventsDir, `${today}.jsonl`);
26
30
 
31
+ // Also check the log file isn't a symlink
32
+ if (existsSync(logFile)) {
33
+ const fileStat = lstatSync(logFile);
34
+ if (!fileStat.isFile()) return;
35
+ }
36
+
27
37
  const entry = JSON.stringify({
28
38
  t: new Date().toISOString(),
29
39
  h: hook,
@@ -50,10 +60,22 @@ export function readEvents(projectDir, { days = 7 } = {}) {
50
60
  const eventsDir = join(projectDir, '.smith', 'events');
51
61
  if (!existsSync(eventsDir)) return [];
52
62
 
63
+ // Symlink check: ensure events dir is a real directory
64
+ const stat = lstatSync(eventsDir);
65
+ if (!stat.isDirectory()) return [];
66
+
67
+ // Calculate cutoff date for filtering
68
+ const cutoff = new Date();
69
+ cutoff.setDate(cutoff.getDate() - days);
70
+ const cutoffStr = cutoff.toISOString().split('T')[0]; // YYYY-MM-DD
71
+
53
72
  const files = readdirSync(eventsDir)
54
73
  .filter(f => f.endsWith('.jsonl'))
55
- .sort()
56
- .slice(-days); // Last N days
74
+ .filter(f => {
75
+ const dateStr = f.replace('.jsonl', '');
76
+ return /^\d{4}-\d{2}-\d{2}$/.test(dateStr) && dateStr >= cutoffStr;
77
+ })
78
+ .sort();
57
79
 
58
80
  const events = [];
59
81
  for (const file of files) {
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Hook Utilities
3
+ * Common functions used across hook scripts
4
+ */
5
+
6
+ import { readFileSync } from 'fs';
7
+ import { join } from 'path';
8
+
9
+ /**
10
+ * Sanitize session/agent IDs to prevent path traversal
11
+ * Currently duplicated 11 times across hooks
12
+ * @param {string} id - The ID to sanitize
13
+ * @returns {string} Sanitized ID safe for filesystem use
14
+ */
15
+ export function sanitizeId(id) {
16
+ if (!id || typeof id !== 'string') return 'default';
17
+ return id.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128) || 'default';
18
+ }
19
+
20
+ /**
21
+ * Check if OMC autonomous mode is active
22
+ * Currently duplicated 4 times (debug-loop, scope-guard, batch-checkpoint, plan-guard)
23
+ * @param {string} cwd - Current working directory
24
+ * @returns {boolean} True if any OMC autonomous mode is active
25
+ */
26
+ export function isOmcAutoMode(cwd) {
27
+ const omcStateDir = join(cwd, '.omc', 'state');
28
+ const omcAutoModes = ['autopilot-state.json', 'ultrawork-state.json', 'ralph-state.json'];
29
+ return omcAutoModes.some(f => {
30
+ try {
31
+ const state = JSON.parse(readFileSync(join(omcStateDir, f), 'utf8'));
32
+ return state.active === true || state.status === 'running';
33
+ } catch { return false; }
34
+ });
35
+ }
36
+
37
+ /**
38
+ * Parse hook input from stdin (JSON)
39
+ * Common pattern across all hooks
40
+ * @returns {object|null} Parsed input or null if parsing fails
41
+ */
42
+ export function parseHookInput() {
43
+ try {
44
+ return JSON.parse(readFileSync(0, 'utf8'));
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Send a brief notification to stderr for user visibility
52
+ * Only active when notify: true in .claude-smith.json
53
+ * @param {boolean} notify - Whether notifications are enabled
54
+ * @param {string} hookName - Display name of the hook
55
+ * @param {string} eventType - fire|warn|block|inject|track
56
+ * @param {string} message - Brief description
57
+ */
58
+ export function notifyUser(notify, hookName, eventType, message) {
59
+ if (!notify) return;
60
+ const icons = {
61
+ fire: '✅',
62
+ warn: '⚠️',
63
+ block: '🚫',
64
+ inject: '🤖',
65
+ track: '📊'
66
+ };
67
+ const icon = icons[eventType] || '📝';
68
+ process.stderr.write(`🕵️ Smith — ${icon} ${hookName}: ${message}\n`);
69
+ }
package/lib/stats.mjs CHANGED
@@ -7,8 +7,20 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from
7
7
  import { join } from 'path';
8
8
  import { tmpdir } from 'os';
9
9
 
10
+ const KNOWN_EVENTS = ['fire', 'warn', 'block'];
11
+
10
12
  export function recordEvent(sessionId, hookName, eventType) {
11
- const stateDir = join(tmpdir(), '.claude-smith', sessionId || 'default');
13
+ // Guard: only record known event types
14
+ if (!KNOWN_EVENTS.includes(eventType)) return;
15
+
16
+ // Sanitize sessionId and validate path
17
+ const safeId = (sessionId || 'default').replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128) || 'default';
18
+ const stateDir = join(tmpdir(), '.claude-smith', safeId);
19
+
20
+ // Verify resolved path is under expected parent
21
+ const expectedParent = join(tmpdir(), '.claude-smith');
22
+ if (!stateDir.startsWith(expectedParent)) return;
23
+
12
24
  mkdirSync(stateDir, { recursive: true, mode: 0o700 });
13
25
 
14
26
  const counterFile = join(stateDir, `stat-${hookName}-${eventType}`);
@@ -18,16 +30,31 @@ export function recordEvent(sessionId, hookName, eventType) {
18
30
  }
19
31
 
20
32
  export function readStats(sessionId) {
21
- const stateDir = join(tmpdir(), '.claude-smith', sessionId || 'default');
33
+ // Sanitize sessionId and validate path
34
+ const safeId = (sessionId || 'default').replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128) || 'default';
35
+ const stateDir = join(tmpdir(), '.claude-smith', safeId);
36
+
37
+ // Verify resolved path is under expected parent
38
+ const expectedParent = join(tmpdir(), '.claude-smith');
39
+ if (!stateDir.startsWith(expectedParent)) return {};
40
+
22
41
  const stats = {};
23
42
 
24
43
  try {
25
44
  const files = readdirSync(stateDir).filter(f => f.startsWith('stat-'));
26
45
  for (const f of files) {
27
46
  // Format: stat-{hookName}-{eventType}
28
- const parts = f.replace('stat-', '').split('-');
29
- const eventType = parts.pop();
30
- const hookName = parts.join('-');
47
+ // Use safer parsing with explicit known event types
48
+ const raw = f.replace('stat-', ''); // e.g., "batch-checkpoint-warn"
49
+ const lastDash = raw.lastIndexOf('-');
50
+ if (lastDash === -1) continue;
51
+
52
+ const eventType = raw.slice(lastDash + 1);
53
+ const hookName = raw.slice(0, lastDash);
54
+
55
+ // Skip unknown event types
56
+ if (!KNOWN_EVENTS.includes(eventType)) continue;
57
+
31
58
  if (!stats[hookName]) stats[hookName] = { fire: 0, warn: 0, block: 0 };
32
59
  try {
33
60
  stats[hookName][eventType] = parseInt(readFileSync(join(stateDir, f), 'utf8'), 10) || 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-smith",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "description": "Claude Code workflow enforcement CLI - forging coding discipline into every session",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -4,6 +4,6 @@ Steps:
4
4
  1. Run: `npx claude-smith --version` to get the current installed version
5
5
  2. Run: `npm view claude-smith version 2>/dev/null` to check the latest published version
6
6
  3. Compare the two versions:
7
- - If they match: Report "🔨 Smith is up to date (vX.X.X)"
7
+ - If they match: Report "🕵️ Smith is up to date (vX.X.X)"
8
8
  - If latest is newer: Report the version difference and run `npx claude-smith update` to apply the update
9
9
  - If npm check fails: Report that version check failed (possibly offline) and suggest running `npx claude-smith update` manually
@@ -1,7 +1,7 @@
1
- <!-- CLAUDE-SMITH:START v1.0.0 -->
2
- # 🔨 Smith - Workflow Enforcement Rules
1
+ <!-- CLAUDE-SMITH:START {{SMITH_VERSION}} -->
2
+ # 🕵️ Smith - Workflow Enforcement Rules
3
3
 
4
- > Installed by 🔨 Smith v1.0.0. Do not edit manually.
4
+ > Installed by 🕵️ Smith {{SMITH_VERSION}}. Do not edit manually.
5
5
  > Update with: `claude-smith update`
6
6
 
7
7
  ## 1. Workflow Protocol (auto-applied)
@@ -1,7 +1,7 @@
1
- <!-- CLAUDE-SMITH:START v1.0.0 -->
2
- # 🔨 Smith - ワークフロー実行ルール
1
+ <!-- CLAUDE-SMITH:START {{SMITH_VERSION}} -->
2
+ # 🕵️ Smith - ワークフロー実行ルール
3
3
 
4
- > 🔨 Smith v1.0.0 によりインストール。手動で編集しないでください。
4
+ > 🕵️ Smith {{SMITH_VERSION}} によりインストール。手動で編集しないでください。
5
5
  > 更新コマンド:`claude-smith update`
6
6
 
7
7
  ## 1. ワークフロープロトコル(自動適用)
@@ -1,7 +1,7 @@
1
- <!-- CLAUDE-SMITH:START v1.0.0 -->
2
- # 🔨 Smith - Workflow Enforcement Rules
1
+ <!-- CLAUDE-SMITH:START {{SMITH_VERSION}} -->
2
+ # 🕵️ Smith - Workflow Enforcement Rules
3
3
 
4
- > Installed by 🔨 Smith v1.0.0. Do not edit manually.
4
+ > Installed by 🕵️ Smith {{SMITH_VERSION}}. Do not edit manually.
5
5
  > Update with: `claude-smith update`
6
6
 
7
7
  ## 1. Workflow Protocol (자동 적용)
@@ -1,7 +1,7 @@
1
- <!-- CLAUDE-SMITH:START v1.0.0 -->
2
- # 🔨 Smith - Workflow Enforcement Rules
1
+ <!-- CLAUDE-SMITH:START {{SMITH_VERSION}} -->
2
+ # 🕵️ Smith - Workflow Enforcement Rules
3
3
 
4
- > Installed by 🔨 Smith v1.0.0. Do not edit manually.
4
+ > Installed by 🕵️ Smith {{SMITH_VERSION}}. Do not edit manually.
5
5
  > Update with: `claude-smith update`
6
6
 
7
7
  ## 1. Workflow Protocol (auto-applied)
@@ -1,7 +1,7 @@
1
- <!-- CLAUDE-SMITH:START v1.0.0 -->
2
- # 🔨 Smith - 工作流执行规则
1
+ <!-- CLAUDE-SMITH:START {{SMITH_VERSION}} -->
2
+ # 🕵️ Smith - 工作流执行规则
3
3
 
4
- > 由 🔨 Smith v1.0.0 安装。请勿手动编辑。
4
+ > 由 🕵️ Smith {{SMITH_VERSION}} 安装。请勿手动编辑。
5
5
  > 更新命令:`claude-smith update`
6
6
 
7
7
  ## 1. 工作流协议(自动应用)