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.
Files changed (56) hide show
  1. package/agents/clean-coder.md +275 -111
  2. package/agents/code-quality-agent.md +196 -209
  3. package/bin/install.mjs +81 -0
  4. package/bin/install.test.mjs +158 -0
  5. package/bin/install_mypy_ini.mjs +51 -0
  6. package/bin/install_mypy_ini.test.mjs +121 -0
  7. package/commands/hook-log-extract.md +70 -0
  8. package/commands/hook-log-init.md +76 -0
  9. package/hooks/blocking/code_rules_enforcer.py +5 -3
  10. package/hooks/blocking/destructive_command_blocker.py +187 -0
  11. package/hooks/blocking/question_to_user_enforcer.py +140 -0
  12. package/hooks/blocking/test_code_rules_enforcer_file_global_constants.py +39 -0
  13. package/hooks/blocking/test_destructive_command_blocker.py +397 -0
  14. package/hooks/blocking/test_question_to_user_enforcer.py +163 -0
  15. package/hooks/blocking/test_windows_rmtree_blocker.py +148 -0
  16. package/hooks/blocking/windows_rmtree_blocker.py +106 -0
  17. package/hooks/config/hook_log_extractor_constants.py +234 -0
  18. package/hooks/config/messages.py +3 -0
  19. package/hooks/config/session_env_cleanup_constants.py +18 -0
  20. package/hooks/config/test_hook_log_extractor_constants.py +123 -0
  21. package/hooks/config/test_messages.py +5 -0
  22. package/hooks/config/test_session_env_cleanup_constants.py +55 -0
  23. package/hooks/diagnostic/hook_log_extractor.py +907 -0
  24. package/hooks/diagnostic/hook_log_init.py +202 -0
  25. package/hooks/diagnostic/hook_log_stop_wrapper.py +172 -0
  26. package/hooks/diagnostic/migrations/2026-04-25-drop-themes-hook-events.sql +3 -0
  27. package/hooks/diagnostic/migrations/README.md +77 -0
  28. package/hooks/diagnostic/queries/block_details_for_hook.sql +26 -0
  29. package/hooks/diagnostic/queries/blocks_by_category.sql +10 -0
  30. package/hooks/diagnostic/queries/blocks_by_tool.sql +9 -0
  31. package/hooks/diagnostic/queries/blocks_last_7_days.sql +11 -0
  32. package/hooks/diagnostic/queries/top_blockers_last_24_hours.sql +12 -0
  33. package/hooks/diagnostic/queries/top_blockers_overall.sql +12 -0
  34. package/hooks/diagnostic/requirements-hook-logs-dev.txt +2 -0
  35. package/hooks/diagnostic/requirements-hook-logs.txt +1 -0
  36. package/hooks/diagnostic/schema.sql +51 -0
  37. package/hooks/diagnostic/test_hook_log_extractor.py +1531 -0
  38. package/hooks/diagnostic/test_hook_log_init.py +227 -0
  39. package/hooks/diagnostic/test_hook_log_stop_wrapper.py +345 -0
  40. package/hooks/hooks.json +25 -0
  41. package/hooks/session/session_env_cleanup.py +129 -0
  42. package/hooks/session/test_session_env_cleanup.py +278 -0
  43. package/package.json +1 -1
  44. package/rules/ask-user-question-required.md +44 -0
  45. package/rules/windows-filesystem-safe.md +93 -0
  46. package/scripts/config/test_spec_implementer_prompt.py +0 -4
  47. package/scripts/test_groq_bugteam_spec.py +0 -8
  48. package/skills/bugteam/SKILL.md +15 -1
  49. package/skills/bugteam/SKILL_EVALS.md +1 -1
  50. package/skills/bugteam/reference/teardown-publish-permissions.md +1 -1
  51. package/skills/bugteam/scripts/README.md +17 -0
  52. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +238 -0
  53. package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +267 -0
  54. package/skills/logifix/SKILL.md +69 -0
  55. package/skills/logifix/scripts/logifix.ps1 +205 -0
  56. 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 and non-Python files
1343
- are exempt. Constants with zero function references are out of scope.
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"):