claude-dev-env 1.30.0 → 1.31.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/CLAUDE.md +8 -0
- package/agents/clean-coder.md +275 -111
- package/agents/code-quality-agent.md +196 -209
- package/bin/install.mjs +81 -0
- package/bin/install.test.mjs +158 -0
- package/bin/install_mypy_ini.mjs +51 -0
- package/bin/install_mypy_ini.test.mjs +121 -0
- package/commands/hook-log-extract.md +70 -0
- package/commands/hook-log-init.md +76 -0
- package/docs/CODE_RULES.md +40 -0
- package/hooks/blocking/code_rules_enforcer.py +5 -3
- package/hooks/blocking/destructive_command_blocker.py +187 -0
- package/hooks/blocking/question_to_user_enforcer.py +140 -0
- package/hooks/blocking/test_code_rules_enforcer_file_global_constants.py +39 -0
- package/hooks/blocking/test_destructive_command_blocker.py +397 -0
- package/hooks/blocking/test_question_to_user_enforcer.py +163 -0
- package/hooks/config/hook_log_extractor_constants.py +221 -0
- package/hooks/config/messages.py +3 -0
- package/hooks/config/test_hook_log_extractor_constants.py +96 -0
- package/hooks/config/test_messages.py +5 -0
- package/hooks/diagnostic/hook_log_extractor.py +907 -0
- package/hooks/diagnostic/hook_log_init.py +202 -0
- package/hooks/diagnostic/hook_log_stop_wrapper.py +84 -0
- package/hooks/diagnostic/migrations/2026-04-25-drop-themes-hook-events.sql +3 -0
- package/hooks/diagnostic/migrations/README.md +77 -0
- package/hooks/diagnostic/queries/block_details_for_hook.sql +26 -0
- package/hooks/diagnostic/queries/blocks_by_category.sql +10 -0
- package/hooks/diagnostic/queries/blocks_by_tool.sql +9 -0
- package/hooks/diagnostic/queries/blocks_last_7_days.sql +11 -0
- package/hooks/diagnostic/queries/top_blockers_last_24_hours.sql +12 -0
- package/hooks/diagnostic/queries/top_blockers_overall.sql +12 -0
- package/hooks/diagnostic/requirements-hook-logs-dev.txt +2 -0
- package/hooks/diagnostic/requirements-hook-logs.txt +1 -0
- package/hooks/diagnostic/schema.sql +51 -0
- package/hooks/diagnostic/test_hook_log_extractor.py +1531 -0
- package/hooks/diagnostic/test_hook_log_init.py +227 -0
- package/hooks/diagnostic/test_hook_log_stop_wrapper.py +98 -0
- package/hooks/hooks.json +10 -0
- package/package.json +1 -1
- package/rules/ask-user-question-required.md +44 -0
- package/scripts/config/test_spec_implementer_prompt.py +0 -4
- package/scripts/test_groq_bugteam_spec.py +0 -8
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import { strict as assert } from 'node:assert';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
import { collectPackageSourceConflicts } from './install.mjs';
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
function createTemporaryGitRepository() {
|
|
12
|
+
const repositoryRoot = mkdtempSync(join(tmpdir(), 'cdev-installer-validation-'));
|
|
13
|
+
const gitOptions = { cwd: repositoryRoot, stdio: 'ignore' };
|
|
14
|
+
execFileSync('git', ['init', '--initial-branch=main'], gitOptions);
|
|
15
|
+
execFileSync('git', ['config', 'user.email', 'test@example.com'], gitOptions);
|
|
16
|
+
execFileSync('git', ['config', 'user.name', 'Test'], gitOptions);
|
|
17
|
+
execFileSync('git', ['config', 'commit.gpgsign', 'false'], gitOptions);
|
|
18
|
+
execFileSync('git', ['config', 'core.autocrlf', 'false'], gitOptions);
|
|
19
|
+
return repositoryRoot;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
function commitAllChanges(repositoryRoot, commitMessage) {
|
|
24
|
+
execFileSync('git', ['add', '.'], { cwd: repositoryRoot, stdio: 'ignore' });
|
|
25
|
+
execFileSync('git', ['commit', '-m', commitMessage], {
|
|
26
|
+
cwd: repositoryRoot,
|
|
27
|
+
stdio: 'ignore',
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
function tryMergeAllowingConflict(repositoryRoot, branchName) {
|
|
33
|
+
try {
|
|
34
|
+
execFileSync('git', ['merge', '--no-edit', branchName], {
|
|
35
|
+
cwd: repositoryRoot,
|
|
36
|
+
stdio: 'ignore',
|
|
37
|
+
});
|
|
38
|
+
} catch {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
test('collectPackageSourceConflicts returns empty list when working tree is clean', () => {
|
|
45
|
+
const repositoryRoot = createTemporaryGitRepository();
|
|
46
|
+
try {
|
|
47
|
+
const packageDirectory = join(repositoryRoot, 'packages', 'thing');
|
|
48
|
+
mkdirSync(packageDirectory, { recursive: true });
|
|
49
|
+
writeFileSync(join(packageDirectory, 'README.md'), 'hello\n');
|
|
50
|
+
commitAllChanges(repositoryRoot, 'init');
|
|
51
|
+
|
|
52
|
+
const conflicts = collectPackageSourceConflicts(packageDirectory);
|
|
53
|
+
assert.deepEqual(conflicts, []);
|
|
54
|
+
} finally {
|
|
55
|
+
rmSync(repositoryRoot, { recursive: true, force: true });
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
test('collectPackageSourceConflicts surfaces both-modified paths under the package directory', () => {
|
|
61
|
+
const repositoryRoot = createTemporaryGitRepository();
|
|
62
|
+
try {
|
|
63
|
+
const packageDirectory = join(repositoryRoot, 'packages', 'thing');
|
|
64
|
+
mkdirSync(packageDirectory, { recursive: true });
|
|
65
|
+
const conflictedFile = join(packageDirectory, 'shared.txt');
|
|
66
|
+
writeFileSync(conflictedFile, 'base content\n');
|
|
67
|
+
commitAllChanges(repositoryRoot, 'base');
|
|
68
|
+
|
|
69
|
+
execFileSync('git', ['checkout', '-b', 'branch-a'], { cwd: repositoryRoot, stdio: 'ignore' });
|
|
70
|
+
writeFileSync(conflictedFile, 'a side\n');
|
|
71
|
+
commitAllChanges(repositoryRoot, 'a');
|
|
72
|
+
|
|
73
|
+
execFileSync('git', ['checkout', '-b', 'branch-b', 'main'], { cwd: repositoryRoot, stdio: 'ignore' });
|
|
74
|
+
writeFileSync(conflictedFile, 'b side\n');
|
|
75
|
+
commitAllChanges(repositoryRoot, 'b');
|
|
76
|
+
|
|
77
|
+
tryMergeAllowingConflict(repositoryRoot, 'branch-a');
|
|
78
|
+
|
|
79
|
+
const conflicts = collectPackageSourceConflicts(packageDirectory);
|
|
80
|
+
assert.equal(conflicts.length, 1);
|
|
81
|
+
assert.equal(conflicts[0].statusCode, 'UU');
|
|
82
|
+
assert.match(conflicts[0].path, /shared\.txt/);
|
|
83
|
+
} finally {
|
|
84
|
+
rmSync(repositoryRoot, { recursive: true, force: true });
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
test('collectPackageSourceConflicts ignores conflicts outside the package directory', () => {
|
|
90
|
+
const repositoryRoot = createTemporaryGitRepository();
|
|
91
|
+
try {
|
|
92
|
+
const packageDirectory = join(repositoryRoot, 'packages', 'thing');
|
|
93
|
+
const otherDirectory = join(repositoryRoot, 'packages', 'other');
|
|
94
|
+
mkdirSync(packageDirectory, { recursive: true });
|
|
95
|
+
mkdirSync(otherDirectory, { recursive: true });
|
|
96
|
+
writeFileSync(join(packageDirectory, 'inside.txt'), 'inside\n');
|
|
97
|
+
const otherFile = join(otherDirectory, 'outside.txt');
|
|
98
|
+
writeFileSync(otherFile, 'base outside\n');
|
|
99
|
+
commitAllChanges(repositoryRoot, 'init');
|
|
100
|
+
|
|
101
|
+
execFileSync('git', ['checkout', '-b', 'side'], { cwd: repositoryRoot, stdio: 'ignore' });
|
|
102
|
+
writeFileSync(otherFile, 'side change\n');
|
|
103
|
+
commitAllChanges(repositoryRoot, 'side');
|
|
104
|
+
|
|
105
|
+
execFileSync('git', ['checkout', 'main'], { cwd: repositoryRoot, stdio: 'ignore' });
|
|
106
|
+
writeFileSync(otherFile, 'main change\n');
|
|
107
|
+
commitAllChanges(repositoryRoot, 'main');
|
|
108
|
+
|
|
109
|
+
tryMergeAllowingConflict(repositoryRoot, 'side');
|
|
110
|
+
|
|
111
|
+
const conflicts = collectPackageSourceConflicts(packageDirectory);
|
|
112
|
+
assert.deepEqual(conflicts, []);
|
|
113
|
+
} finally {
|
|
114
|
+
rmSync(repositoryRoot, { recursive: true, force: true });
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
test('collectPackageSourceConflicts returns empty when directory is not inside a git repo', () => {
|
|
120
|
+
const standaloneDirectory = mkdtempSync(join(tmpdir(), 'cdev-installer-no-git-'));
|
|
121
|
+
try {
|
|
122
|
+
const conflicts = collectPackageSourceConflicts(standaloneDirectory);
|
|
123
|
+
assert.deepEqual(conflicts, []);
|
|
124
|
+
} finally {
|
|
125
|
+
rmSync(standaloneDirectory, { recursive: true, force: true });
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
test('collectPackageSourceConflicts surfaces both-added and deleted-by-them entries', () => {
|
|
131
|
+
const repositoryRoot = createTemporaryGitRepository();
|
|
132
|
+
try {
|
|
133
|
+
const packageDirectory = join(repositoryRoot, 'packages', 'thing');
|
|
134
|
+
mkdirSync(packageDirectory, { recursive: true });
|
|
135
|
+
writeFileSync(join(packageDirectory, 'shared.txt'), 'base\n');
|
|
136
|
+
writeFileSync(join(packageDirectory, 'about_to_disappear.txt'), 'will be removed\n');
|
|
137
|
+
commitAllChanges(repositoryRoot, 'base');
|
|
138
|
+
|
|
139
|
+
execFileSync('git', ['checkout', '-b', 'theirs'], { cwd: repositoryRoot, stdio: 'ignore' });
|
|
140
|
+
rmSync(join(packageDirectory, 'about_to_disappear.txt'));
|
|
141
|
+
writeFileSync(join(packageDirectory, 'fresh.txt'), 'theirs version\n');
|
|
142
|
+
commitAllChanges(repositoryRoot, 'theirs');
|
|
143
|
+
|
|
144
|
+
execFileSync('git', ['checkout', '-b', 'ours', 'main'], { cwd: repositoryRoot, stdio: 'ignore' });
|
|
145
|
+
writeFileSync(join(packageDirectory, 'about_to_disappear.txt'), 'ours edit\n');
|
|
146
|
+
writeFileSync(join(packageDirectory, 'fresh.txt'), 'ours version\n');
|
|
147
|
+
commitAllChanges(repositoryRoot, 'ours');
|
|
148
|
+
|
|
149
|
+
tryMergeAllowingConflict(repositoryRoot, 'theirs');
|
|
150
|
+
|
|
151
|
+
const conflicts = collectPackageSourceConflicts(packageDirectory);
|
|
152
|
+
const allStatusCodes = new Set(conflicts.map(conflictEntry => conflictEntry.statusCode));
|
|
153
|
+
assert.ok(allStatusCodes.has('UD'), `expected UD in ${[...allStatusCodes].join(',')}`);
|
|
154
|
+
assert.ok(allStatusCodes.has('AA'), `expected AA in ${[...allStatusCodes].join(',')}`);
|
|
155
|
+
} finally {
|
|
156
|
+
rmSync(repositoryRoot, { recursive: true, force: true });
|
|
157
|
+
}
|
|
158
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const MYPY_INI_FILENAME = '.mypy.ini';
|
|
5
|
+
const MYPY_CONFIG_SECTION_HEADER = '[mypy]';
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
function normalizeClaudeHooksPathToForwardSlashes(claudeHooksDirectory) {
|
|
9
|
+
return claudeHooksDirectory.replace(/\\/g, '/');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
function buildExpectedMypyPathLine(claudeHooksDirectory) {
|
|
14
|
+
const claudeHooksAsForwardSlashes = normalizeClaudeHooksPathToForwardSlashes(claudeHooksDirectory);
|
|
15
|
+
return `mypy_path = ${claudeHooksAsForwardSlashes}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
function buildMypyIniContentForClaudeHooks(claudeHooksDirectory) {
|
|
20
|
+
const expectedMypyPathLine = buildExpectedMypyPathLine(claudeHooksDirectory);
|
|
21
|
+
return `${MYPY_CONFIG_SECTION_HEADER}\n${expectedMypyPathLine}\n`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
function hasExactMatchingLine(fileContent, expectedLine) {
|
|
26
|
+
const lineBreakPattern = /\r?\n/;
|
|
27
|
+
const allLines = fileContent.split(lineBreakPattern);
|
|
28
|
+
return allLines.some((eachLine) => eachLine.trim() === expectedLine);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
export function installMypyIniForClaudeHooks({ homeDirectory, claudeHooksDirectory }) {
|
|
33
|
+
const mypyIniDestinationPath = join(homeDirectory, MYPY_INI_FILENAME);
|
|
34
|
+
const expectedMypyPathLine = buildExpectedMypyPathLine(claudeHooksDirectory);
|
|
35
|
+
|
|
36
|
+
if (existsSync(mypyIniDestinationPath)) {
|
|
37
|
+
const existingMypyIniContent = readFileSync(mypyIniDestinationPath, 'utf8');
|
|
38
|
+
if (hasExactMatchingLine(existingMypyIniContent, expectedMypyPathLine)) {
|
|
39
|
+
return { action: 'already-configured', path: mypyIniDestinationPath };
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
action: 'skipped-existing',
|
|
43
|
+
path: mypyIniDestinationPath,
|
|
44
|
+
expectedLine: expectedMypyPathLine,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const mypyIniContent = buildMypyIniContentForClaudeHooks(claudeHooksDirectory);
|
|
49
|
+
writeFileSync(mypyIniDestinationPath, mypyIniContent);
|
|
50
|
+
return { action: 'created', path: mypyIniDestinationPath };
|
|
51
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import { strict as assert } from 'node:assert';
|
|
3
|
+
import { mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { installMypyIniForClaudeHooks } from './install_mypy_ini.mjs';
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
function makeTemporaryHomeWithClaudeHooks() {
|
|
11
|
+
const temporaryHomeDirectory = mkdtempSync(join(tmpdir(), 'cdev-mypy-ini-test-'));
|
|
12
|
+
const claudeHooksDirectory = join(temporaryHomeDirectory, '.claude', 'hooks');
|
|
13
|
+
mkdirSync(claudeHooksDirectory, { recursive: true });
|
|
14
|
+
return { temporaryHomeDirectory, claudeHooksDirectory };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
test('installMypyIniForClaudeHooks creates a new .mypy.ini when none exists', () => {
|
|
19
|
+
const { temporaryHomeDirectory, claudeHooksDirectory } = makeTemporaryHomeWithClaudeHooks();
|
|
20
|
+
try {
|
|
21
|
+
const installResult = installMypyIniForClaudeHooks({
|
|
22
|
+
homeDirectory: temporaryHomeDirectory,
|
|
23
|
+
claudeHooksDirectory,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
assert.equal(installResult.action, 'created');
|
|
27
|
+
assert.equal(installResult.path, join(temporaryHomeDirectory, '.mypy.ini'));
|
|
28
|
+
|
|
29
|
+
const writtenContent = readFileSync(installResult.path, 'utf8');
|
|
30
|
+
assert.match(writtenContent, /^\[mypy\]$/m);
|
|
31
|
+
assert.match(writtenContent, /^mypy_path = /m);
|
|
32
|
+
|
|
33
|
+
const claudeHooksAsForwardSlashes = claudeHooksDirectory.replace(/\\/g, '/');
|
|
34
|
+
assert.ok(
|
|
35
|
+
writtenContent.includes(claudeHooksAsForwardSlashes),
|
|
36
|
+
`expected content to include ${claudeHooksAsForwardSlashes}`,
|
|
37
|
+
);
|
|
38
|
+
} finally {
|
|
39
|
+
rmSync(temporaryHomeDirectory, { recursive: true, force: true });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
test('installMypyIniForClaudeHooks leaves existing file untouched when already configured', () => {
|
|
45
|
+
const { temporaryHomeDirectory, claudeHooksDirectory } = makeTemporaryHomeWithClaudeHooks();
|
|
46
|
+
try {
|
|
47
|
+
const claudeHooksAsForwardSlashes = claudeHooksDirectory.replace(/\\/g, '/');
|
|
48
|
+
const preExistingContent = `[mypy]\nmypy_path = ${claudeHooksAsForwardSlashes}\nstrict = True\n`;
|
|
49
|
+
const mypyIniPath = join(temporaryHomeDirectory, '.mypy.ini');
|
|
50
|
+
writeFileSync(mypyIniPath, preExistingContent);
|
|
51
|
+
|
|
52
|
+
const installResult = installMypyIniForClaudeHooks({
|
|
53
|
+
homeDirectory: temporaryHomeDirectory,
|
|
54
|
+
claudeHooksDirectory,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
assert.equal(installResult.action, 'already-configured');
|
|
58
|
+
assert.equal(installResult.path, mypyIniPath);
|
|
59
|
+
|
|
60
|
+
const contentAfterInstall = readFileSync(mypyIniPath, 'utf8');
|
|
61
|
+
assert.equal(contentAfterInstall, preExistingContent);
|
|
62
|
+
} finally {
|
|
63
|
+
rmSync(temporaryHomeDirectory, { recursive: true, force: true });
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
test('installMypyIniForClaudeHooks treats an existing mypy_path that is a strict prefix of the expected path as not configured', () => {
|
|
69
|
+
const { temporaryHomeDirectory, claudeHooksDirectory } = makeTemporaryHomeWithClaudeHooks();
|
|
70
|
+
try {
|
|
71
|
+
const claudeHooksAsForwardSlashes = claudeHooksDirectory.replace(/\\/g, '/');
|
|
72
|
+
const prefixCollidingPath = `${claudeHooksAsForwardSlashes}-old`;
|
|
73
|
+
const preExistingContent = `[mypy]\nmypy_path = ${prefixCollidingPath}\nstrict = True\n`;
|
|
74
|
+
const mypyIniPath = join(temporaryHomeDirectory, '.mypy.ini');
|
|
75
|
+
writeFileSync(mypyIniPath, preExistingContent);
|
|
76
|
+
|
|
77
|
+
const installResult = installMypyIniForClaudeHooks({
|
|
78
|
+
homeDirectory: temporaryHomeDirectory,
|
|
79
|
+
claudeHooksDirectory,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
assert.equal(installResult.action, 'skipped-existing');
|
|
83
|
+
assert.equal(installResult.path, mypyIniPath);
|
|
84
|
+
assert.equal(
|
|
85
|
+
installResult.expectedLine,
|
|
86
|
+
`mypy_path = ${claudeHooksAsForwardSlashes}`,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const contentAfterInstall = readFileSync(mypyIniPath, 'utf8');
|
|
90
|
+
assert.equal(contentAfterInstall, preExistingContent);
|
|
91
|
+
} finally {
|
|
92
|
+
rmSync(temporaryHomeDirectory, { recursive: true, force: true });
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
test('installMypyIniForClaudeHooks does not overwrite existing .mypy.ini that lacks the expected mypy_path', () => {
|
|
98
|
+
const { temporaryHomeDirectory, claudeHooksDirectory } = makeTemporaryHomeWithClaudeHooks();
|
|
99
|
+
try {
|
|
100
|
+
const preExistingContent = `[mypy]\nmypy_path = /some/other/project\nstrict = True\n`;
|
|
101
|
+
const mypyIniPath = join(temporaryHomeDirectory, '.mypy.ini');
|
|
102
|
+
writeFileSync(mypyIniPath, preExistingContent);
|
|
103
|
+
|
|
104
|
+
const installResult = installMypyIniForClaudeHooks({
|
|
105
|
+
homeDirectory: temporaryHomeDirectory,
|
|
106
|
+
claudeHooksDirectory,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
assert.equal(installResult.action, 'skipped-existing');
|
|
110
|
+
assert.equal(installResult.path, mypyIniPath);
|
|
111
|
+
assert.ok(
|
|
112
|
+
installResult.expectedLine.includes(claudeHooksDirectory.replace(/\\/g, '/')),
|
|
113
|
+
`expected expectedLine to include ${claudeHooksDirectory.replace(/\\/g, '/')}`,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const contentAfterInstall = readFileSync(mypyIniPath, 'utf8');
|
|
117
|
+
assert.equal(contentAfterInstall, preExistingContent);
|
|
118
|
+
} finally {
|
|
119
|
+
rmSync(temporaryHomeDirectory, { recursive: true, force: true });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Extract hook-firing records from session transcripts into Neon and show blocker summary
|
|
3
|
+
allowed-tools: Bash
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Scan every JSONL session transcript under `~/.claude/projects/` (or the
|
|
7
|
+
path set by the `CLAUDE_HOME` env var) and ingest `attachment` records
|
|
8
|
+
whose inner `type` is one of the five enumerated variants in
|
|
9
|
+
`OUTCOME_BY_ATTACHMENT_TYPE` (`hook_success`, `hook_blocking_error`,
|
|
10
|
+
`hook_non_blocking_error`, `hook_system_message`,
|
|
11
|
+
`hook_additional_context`). Each ingested record becomes one row in
|
|
12
|
+
the Neon `hook_events` table. Unknown `hook_`-prefixed variants are
|
|
13
|
+
skipped until `OUTCOME_BY_ATTACHMENT_TYPE` is extended to cover them.
|
|
14
|
+
The Stop hook runs this on every session end using the `--incremental`
|
|
15
|
+
flag.
|
|
16
|
+
|
|
17
|
+
## Run modes
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
bws run -- python packages/claude-dev-env/hooks/diagnostic/hook_log_extractor.py
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Full extraction using the current byte offsets in
|
|
24
|
+
`~/.claude/logs/hooks/.state/offsets.json` (override the `~/.claude`
|
|
25
|
+
root by setting `CLAUDE_HOME`). Equivalent to the Stop hook's
|
|
26
|
+
`--incremental` invocation; passing `--incremental` explicitly is a
|
|
27
|
+
documented no-op that selects the same default resumption path.
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
bws run -- python packages/claude-dev-env/hooks/diagnostic/hook_log_extractor.py --full-rebuild
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Clear offsets, truncate `hook_events`, and re-read every JSONL from byte
|
|
34
|
+
zero. Use this after a schema migration or when the offsets file is
|
|
35
|
+
suspected of drift.
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
bws run -- python packages/claude-dev-env/hooks/diagnostic/hook_log_extractor.py --summary
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Skip extraction. Print the top-10 blockers of the last 24 hours with
|
|
42
|
+
their block count and a single truncated command preview, or
|
|
43
|
+
`No new blocks since last run.` when the window is empty.
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
bws run -- python packages/claude-dev-env/hooks/diagnostic/hook_log_extractor.py --query <name>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Run the pre-baked query `queries/<name>.sql` and print the result as an
|
|
50
|
+
aligned text table. Available query names match the SQL files in
|
|
51
|
+
`packages/claude-dev-env/hooks/diagnostic/queries/`:
|
|
52
|
+
|
|
53
|
+
- `top_blockers_overall`
|
|
54
|
+
- `top_blockers_last_24_hours`
|
|
55
|
+
- `blocks_last_7_days`
|
|
56
|
+
- `blocks_by_category`
|
|
57
|
+
- `blocks_by_tool`
|
|
58
|
+
- `block_details_for_hook`
|
|
59
|
+
|
|
60
|
+
## Offline behavior
|
|
61
|
+
|
|
62
|
+
If the psycopg connection fails with `OperationalError`, the
|
|
63
|
+
5-second timeout elapses, `NEON_HOOK_LOGS_DATABASE_URL` is unset, or
|
|
64
|
+
the `psycopg` driver is not installed, the extractor appends one
|
|
65
|
+
ISO-8601 line to `~/.claude/logs/hook-extractor.log` (override the
|
|
66
|
+
`~/.claude` root with the `CLAUDE_HOME` env var) and exits with
|
|
67
|
+
status 0. Session shutdown stays fast, and the next online run
|
|
68
|
+
backfills from the existing offsets. The warning line records only
|
|
69
|
+
the timestamp and the exception class name so connection URLs never
|
|
70
|
+
leak into the log.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Initialize the Neon schema used by the hook-log extractor
|
|
3
|
+
allowed-tools: Bash
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Initialize the Neon Postgres schema that backs the hook-log diagnostic
|
|
7
|
+
extractor. Run this once per new machine, or after rotating the Neon
|
|
8
|
+
project, before the Stop hook begins inserting rows.
|
|
9
|
+
|
|
10
|
+
## Prerequisites
|
|
11
|
+
|
|
12
|
+
One-time setup on each machine:
|
|
13
|
+
|
|
14
|
+
1. **Install the Bitwarden Secret Manager CLI** so the `bws` command is on PATH.
|
|
15
|
+
See https://bitwarden.com/help/secrets-manager-cli/ for platform-specific instructions.
|
|
16
|
+
|
|
17
|
+
2. **Create a Bitwarden machine account** scoped to the Neon connection
|
|
18
|
+
secret, generate its access token, and export it to the current user
|
|
19
|
+
environment.
|
|
20
|
+
|
|
21
|
+
On Windows (PowerShell):
|
|
22
|
+
|
|
23
|
+
```powershell
|
|
24
|
+
setx BWS_ACCESS_TOKEN "<machine-account-access-token>"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Open a new terminal so the variable takes effect.
|
|
28
|
+
|
|
29
|
+
On macOS or Linux (bash/zsh — add to `~/.bashrc`, `~/.zshrc`, or the
|
|
30
|
+
equivalent profile so new shells inherit it):
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
export BWS_ACCESS_TOKEN="<machine-account-access-token>"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
3. **Store the Neon connection string** in the Bitwarden Secrets Manager
|
|
37
|
+
under the key `NEON_HOOK_LOGS_DATABASE_URL`. The value is the full
|
|
38
|
+
`postgres://user:password@host/database?sslmode=require` URL that
|
|
39
|
+
Neon provides on the project dashboard.
|
|
40
|
+
|
|
41
|
+
4. **Install the Python dependencies** so the extractor can reach Neon.
|
|
42
|
+
Production runtime:
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
pip install -r packages/claude-dev-env/hooks/diagnostic/requirements-hook-logs.txt
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Development (adds `pytest` on top of the runtime deps):
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
pip install -r packages/claude-dev-env/hooks/diagnostic/requirements-hook-logs-dev.txt
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Run the init
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
bws run -- python packages/claude-dev-env/hooks/diagnostic/hook_log_init.py
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The init script performs these steps in order:
|
|
61
|
+
|
|
62
|
+
1. Verifies `NEON_HOOK_LOGS_DATABASE_URL` is set. `BWS_ACCESS_TOKEN` is
|
|
63
|
+
consumed by the outer `bws` CLI before it spawns the Python child
|
|
64
|
+
process; `bws run` intentionally strips it from the child
|
|
65
|
+
environment to prevent subprocess credential leakage, so the Python
|
|
66
|
+
script never sees it.
|
|
67
|
+
2. Connects to Neon with a 5-second timeout.
|
|
68
|
+
3. Applies the DDL in `packages/claude-dev-env/hooks/diagnostic/schema.sql`
|
|
69
|
+
using `CREATE TABLE IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, and
|
|
70
|
+
`CREATE OR REPLACE VIEW` so the script stays idempotent.
|
|
71
|
+
4. Inserts a sentinel row with `outcome = 'init_probe'`, selects it back,
|
|
72
|
+
and deletes it to confirm read-write parity.
|
|
73
|
+
5. Prints a success report showing the Neon host, table name, and row count.
|
|
74
|
+
|
|
75
|
+
A missing environment variable exits with status 1 and lists the
|
|
76
|
+
missing name on stderr; any other failure surfaces the psycopg exception.
|
package/docs/CODE_RULES.md
CHANGED
|
@@ -169,6 +169,44 @@ Always: Functions when no state, concrete classes, simple imports
|
|
|
169
169
|
|
|
170
170
|
---
|
|
171
171
|
|
|
172
|
+
## 7.5 SOLID PRINCIPLES
|
|
173
|
+
|
|
174
|
+
**Apply where two or more concrete implementations already share a contract. For code with a single concretion, §7 (Right-Sized Engineering) takes precedence: use concrete classes, functions when no state, and direct imports.**
|
|
175
|
+
|
|
176
|
+
Reference: Robert C. Martin, *Agile Software Development: Principles, Patterns, and Practices* (2002), Ch. 8–12.
|
|
177
|
+
|
|
178
|
+
| Letter | Principle | What it means here |
|
|
179
|
+
|--------|-----------|--------------------|
|
|
180
|
+
| **S** | Single Responsibility Principle | A class, function, or module has one reason to change. One unit = one axis of variation. Ties to §6.5 (file length as smell signal) and Fowler's "Large Class" / "Long Function" smells. |
|
|
181
|
+
| **O** | Open/Closed Principle | Extend behavior by adding new code. Favor a new branch/handler/subclass over editing the same switch in five places. |
|
|
182
|
+
| **L** | Liskov Substitution Principle | A subtype must be usable anywhere its parent type is expected without surprising the caller. If a subclass override breaks caller assumptions, flatten the hierarchy or prefer composition. |
|
|
183
|
+
| **I** | Interface Segregation Principle | Each client depends on exactly the methods it calls. Split one fat interface into several role-specific ones so each caller imports only the role it needs. |
|
|
184
|
+
| **D** | Dependency Inversion Principle | When two or more concretions exist or are imminent, depend on the shared abstraction. With exactly one concretion, import the concrete type directly (see §7). |
|
|
185
|
+
|
|
186
|
+
### Reconciling SOLID with Right-Sized Engineering (§7)
|
|
187
|
+
|
|
188
|
+
SOLID was written for OO codebases where most abstract types have two or more concrete subclasses. In this codebase:
|
|
189
|
+
|
|
190
|
+
- **SRP always applies.** Functions, classes, and modules must have one reason to change regardless of paradigm. This is the only SOLID letter that applies immediately, without waiting for a second implementation.
|
|
191
|
+
- **OCP, LSP, ISP, DIP apply where two or more concrete implementations already share a contract.** A single concrete class satisfies SOLID by default. Introduce interfaces, ABCs, or DI containers only when the second concretion lands.
|
|
192
|
+
- **For code with fewer than two concretions, §7 wins:** concrete classes, functions when no state, direct imports. Refactor toward OCP/DIP at the commit that introduces the second concrete implementation (YAGNI).
|
|
193
|
+
|
|
194
|
+
### Signals that SOLID is being misapplied
|
|
195
|
+
|
|
196
|
+
- Creating an interface or ABC with exactly one implementation (violates §7 DIP guard)
|
|
197
|
+
- Splitting a cohesive 80-line class with one reason to change into four 20-line classes because "SRP" — SRP counts distinct change reasons; size is a separate signal tracked in §6.5
|
|
198
|
+
- Abstract factories for types that have exactly one concrete product
|
|
199
|
+
- Dependency-injection containers where every injected type has exactly one concrete implementation across production and tests
|
|
200
|
+
|
|
201
|
+
### When SOLID adds value
|
|
202
|
+
|
|
203
|
+
- Two or more concrete implementations already exist → DIP and ISP earn their keep
|
|
204
|
+
- A class shows multiple unrelated change reasons in git history → SRP split is justified
|
|
205
|
+
- Subclass overrides break caller assumptions → LSP violation; fix or flatten the hierarchy
|
|
206
|
+
- Editing the same `if`/`switch` block every time a new case is added → OCP refactor is justified
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
172
210
|
## 8. TDD PROCESS
|
|
173
211
|
|
|
174
212
|
1. **RED** - Failing test first
|
|
@@ -221,5 +259,7 @@ Manual check:
|
|
|
221
259
|
[ ] No abbreviations?
|
|
222
260
|
[ ] Complete type hints?
|
|
223
261
|
[ ] Self-contained components?
|
|
262
|
+
[ ] SRP holds (one reason to change per function/class/module)?
|
|
263
|
+
[ ] OCP/LSP/ISP/DIP only applied where abstractions already earn their keep (see §7.5)?
|
|
224
264
|
[ ] Readability: /check or /readability-review
|
|
225
265
|
```
|
|
@@ -1339,13 +1339,15 @@ def check_file_global_constants_use_count(content: str, file_path: str) -> list[
|
|
|
1339
1339
|
"""Flag module-level UPPER_SNAKE constants referenced by only one function/method.
|
|
1340
1340
|
|
|
1341
1341
|
Enforces jl-cmd/claude-code-config#180: a file-global constant used by just
|
|
1342
|
-
one caller belongs in that caller's scope. Test files
|
|
1343
|
-
are exempt. Constants with zero
|
|
1344
|
-
Hook infrastructure files define module-level scalar constants by
|
|
1342
|
+
one caller belongs in that caller's scope. Test files, config files, and
|
|
1343
|
+
non-Python files are exempt. Constants with zero references are out of
|
|
1344
|
+
scope. Hook infrastructure files define module-level scalar constants by
|
|
1345
1345
|
convention and are exempt to avoid self-blocking.
|
|
1346
1346
|
"""
|
|
1347
1347
|
if is_test_file(file_path):
|
|
1348
1348
|
return []
|
|
1349
|
+
if is_config_file(file_path):
|
|
1350
|
+
return []
|
|
1349
1351
|
if get_file_extension(file_path) not in PYTHON_EXTENSIONS:
|
|
1350
1352
|
return []
|
|
1351
1353
|
if file_path.replace("\\", "/").endswith("hooks/blocking/code_rules_enforcer.py"):
|