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.
Files changed (42) hide show
  1. package/CLAUDE.md +8 -0
  2. package/agents/clean-coder.md +275 -111
  3. package/agents/code-quality-agent.md +196 -209
  4. package/bin/install.mjs +81 -0
  5. package/bin/install.test.mjs +158 -0
  6. package/bin/install_mypy_ini.mjs +51 -0
  7. package/bin/install_mypy_ini.test.mjs +121 -0
  8. package/commands/hook-log-extract.md +70 -0
  9. package/commands/hook-log-init.md +76 -0
  10. package/docs/CODE_RULES.md +40 -0
  11. package/hooks/blocking/code_rules_enforcer.py +5 -3
  12. package/hooks/blocking/destructive_command_blocker.py +187 -0
  13. package/hooks/blocking/question_to_user_enforcer.py +140 -0
  14. package/hooks/blocking/test_code_rules_enforcer_file_global_constants.py +39 -0
  15. package/hooks/blocking/test_destructive_command_blocker.py +397 -0
  16. package/hooks/blocking/test_question_to_user_enforcer.py +163 -0
  17. package/hooks/config/hook_log_extractor_constants.py +221 -0
  18. package/hooks/config/messages.py +3 -0
  19. package/hooks/config/test_hook_log_extractor_constants.py +96 -0
  20. package/hooks/config/test_messages.py +5 -0
  21. package/hooks/diagnostic/hook_log_extractor.py +907 -0
  22. package/hooks/diagnostic/hook_log_init.py +202 -0
  23. package/hooks/diagnostic/hook_log_stop_wrapper.py +84 -0
  24. package/hooks/diagnostic/migrations/2026-04-25-drop-themes-hook-events.sql +3 -0
  25. package/hooks/diagnostic/migrations/README.md +77 -0
  26. package/hooks/diagnostic/queries/block_details_for_hook.sql +26 -0
  27. package/hooks/diagnostic/queries/blocks_by_category.sql +10 -0
  28. package/hooks/diagnostic/queries/blocks_by_tool.sql +9 -0
  29. package/hooks/diagnostic/queries/blocks_last_7_days.sql +11 -0
  30. package/hooks/diagnostic/queries/top_blockers_last_24_hours.sql +12 -0
  31. package/hooks/diagnostic/queries/top_blockers_overall.sql +12 -0
  32. package/hooks/diagnostic/requirements-hook-logs-dev.txt +2 -0
  33. package/hooks/diagnostic/requirements-hook-logs.txt +1 -0
  34. package/hooks/diagnostic/schema.sql +51 -0
  35. package/hooks/diagnostic/test_hook_log_extractor.py +1531 -0
  36. package/hooks/diagnostic/test_hook_log_init.py +227 -0
  37. package/hooks/diagnostic/test_hook_log_stop_wrapper.py +98 -0
  38. package/hooks/hooks.json +10 -0
  39. package/package.json +1 -1
  40. package/rules/ask-user-question-required.md +44 -0
  41. package/scripts/config/test_spec_implementer_prompt.py +0 -4
  42. 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.
@@ -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 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"):