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.
- package/README.ko.md +45 -3
- package/README.md +45 -3
- package/bin/cli.mjs +331 -39
- package/hooks/batch-checkpoint.mjs +9 -22
- package/hooks/build-guard.mjs +7 -12
- package/hooks/build-tracker.mjs +9 -13
- package/hooks/commit-guard.mjs +20 -16
- package/hooks/commit-message.mjs +17 -17
- package/hooks/debug-loop.mjs +11 -22
- package/hooks/file-size-warn.mjs +7 -12
- package/hooks/plan-guard.mjs +14 -24
- package/hooks/scope-guard.mjs +9 -23
- package/hooks/subagent-inject.mjs +6 -12
- package/hooks/tdd-guard.mjs +8 -13
- package/hooks/test-tracker.mjs +10 -13
- package/lib/config.mjs +4 -1
- package/lib/event-log.mjs +96 -0
- package/lib/hook-utils.mjs +69 -0
- package/lib/stats.mjs +32 -5
- package/package.json +1 -1
- package/templates/commands/smith-dashboard.md +10 -0
- package/templates/commands/smith-update.md +1 -1
- package/templates/rules.en.md +3 -3
- package/templates/rules.ja.md +3 -3
- package/templates/rules.ko.md +3 -3
- package/templates/rules.md +3 -3
- package/templates/rules.zh.md +3 -3
package/hooks/build-tracker.mjs
CHANGED
|
@@ -5,21 +5,19 @@
|
|
|
5
5
|
* Records build pass/fail result for build-guard reference
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
}
|
package/hooks/commit-guard.mjs
CHANGED
|
@@ -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
|
-
|
|
20
|
-
|
|
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.
|
|
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:
|
|
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: "
|
|
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', '커밋 허용');
|
package/hooks/commit-message.mjs
CHANGED
|
@@ -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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|| command.match(/-m\s+"
|
|
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] ||
|
|
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:
|
|
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
|
}
|
package/hooks/debug-loop.mjs
CHANGED
|
@@ -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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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
|
}
|
package/hooks/file-size-warn.mjs
CHANGED
|
@@ -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
|
-
|
|
17
|
-
|
|
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:
|
|
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);
|
package/hooks/plan-guard.mjs
CHANGED
|
@@ -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
|
-
|
|
20
|
-
|
|
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
|
|
94
|
-
const
|
|
95
|
-
const
|
|
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.
|
|
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:
|
|
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
|
package/hooks/scope-guard.mjs
CHANGED
|
@@ -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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
17
|
-
|
|
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",
|
package/hooks/tdd-guard.mjs
CHANGED
|
@@ -5,20 +5,18 @@
|
|
|
5
5
|
* Warns when editing a source file that has no corresponding test file
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
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
|
-
|
|
17
|
-
|
|
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:
|
|
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);
|
package/hooks/test-tracker.mjs
CHANGED
|
@@ -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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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']);
|