claude-dev-env 1.30.1 → 1.32.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/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/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/blocking/test_windows_rmtree_blocker.py +148 -0
- package/hooks/blocking/windows_rmtree_blocker.py +106 -0
- package/hooks/config/hook_log_extractor_constants.py +234 -0
- package/hooks/config/messages.py +3 -0
- package/hooks/config/session_env_cleanup_constants.py +18 -0
- package/hooks/config/test_hook_log_extractor_constants.py +123 -0
- package/hooks/config/test_messages.py +5 -0
- package/hooks/config/test_session_env_cleanup_constants.py +55 -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 +172 -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 +345 -0
- package/hooks/hooks.json +25 -0
- package/hooks/session/session_env_cleanup.py +129 -0
- package/hooks/session/test_session_env_cleanup.py +278 -0
- package/package.json +1 -1
- package/rules/ask-user-question-required.md +44 -0
- package/rules/windows-filesystem-safe.md +93 -0
- package/scripts/config/test_spec_implementer_prompt.py +0 -4
- package/scripts/test_groq_bugteam_spec.py +0 -8
- package/skills/bugteam/SKILL.md +15 -1
- package/skills/bugteam/SKILL_EVALS.md +1 -1
- package/skills/bugteam/reference/teardown-publish-permissions.md +1 -1
- package/skills/bugteam/scripts/README.md +17 -0
- package/skills/bugteam/scripts/bugteam_fix_hookspath.py +238 -0
- package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +267 -0
- package/skills/logifix/SKILL.md +69 -0
- package/skills/logifix/scripts/logifix.ps1 +205 -0
- package/skills/rebase/SKILL.md +157 -0
|
@@ -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.
|
|
@@ -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"):
|