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,227 @@
1
+ """Failing-first tests for hook_log_init.
2
+
3
+ Covers: environment variable verification, idempotent DDL apply, sentinel
4
+ insert/select/delete round-trip, and success report format.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+ from pathlib import Path
11
+ from unittest.mock import MagicMock, patch
12
+
13
+ import pytest
14
+
15
+ _HOOKS_ROOT = Path(__file__).resolve().parent.parent
16
+ if str(_HOOKS_ROOT) not in sys.path:
17
+ sys.path.insert(0, str(_HOOKS_ROOT))
18
+
19
+ from diagnostic import hook_log_init
20
+ from config import hook_log_extractor_constants
21
+ from config.hook_log_extractor_constants import (
22
+ EXIT_CODE_ENVIRONMENT_MISSING,
23
+ NEON_DATABASE_URL_ENVIRONMENT_VARIABLE,
24
+ OUTCOME_INIT_PROBE,
25
+ )
26
+
27
+
28
+ def test_main_exits_1_when_neon_database_url_missing(
29
+ monkeypatch: pytest.MonkeyPatch,
30
+ capsys: pytest.CaptureFixture[str],
31
+ ) -> None:
32
+ monkeypatch.delenv(NEON_DATABASE_URL_ENVIRONMENT_VARIABLE, raising=False)
33
+
34
+ exit_code = hook_log_init.main()
35
+
36
+ assert exit_code == EXIT_CODE_ENVIRONMENT_MISSING
37
+ captured = capsys.readouterr()
38
+ assert NEON_DATABASE_URL_ENVIRONMENT_VARIABLE in captured.err
39
+
40
+
41
+ def test_main_exits_1_when_neon_database_url_is_empty_string(
42
+ monkeypatch: pytest.MonkeyPatch,
43
+ capsys: pytest.CaptureFixture[str],
44
+ ) -> None:
45
+ """Empty NEON_HOOK_LOGS_DATABASE_URL must fire missing-env branch."""
46
+ monkeypatch.setenv(NEON_DATABASE_URL_ENVIRONMENT_VARIABLE, "")
47
+
48
+ exit_code = hook_log_init.main()
49
+
50
+ assert exit_code == EXIT_CODE_ENVIRONMENT_MISSING
51
+ captured = capsys.readouterr()
52
+ assert NEON_DATABASE_URL_ENVIRONMENT_VARIABLE in captured.err
53
+
54
+
55
+ def test_main_exits_1_when_neon_database_url_is_whitespace_only(
56
+ monkeypatch: pytest.MonkeyPatch,
57
+ capsys: pytest.CaptureFixture[str],
58
+ ) -> None:
59
+ """Whitespace-only NEON_HOOK_LOGS_DATABASE_URL must fire missing-env branch."""
60
+ monkeypatch.setenv(NEON_DATABASE_URL_ENVIRONMENT_VARIABLE, " ")
61
+
62
+ exit_code = hook_log_init.main()
63
+
64
+ assert exit_code == EXIT_CODE_ENVIRONMENT_MISSING
65
+ captured = capsys.readouterr()
66
+ assert NEON_DATABASE_URL_ENVIRONMENT_VARIABLE in captured.err
67
+
68
+
69
+ def test_apply_schema_executes_ddl_from_schema_sql() -> None:
70
+ fake_cursor = MagicMock()
71
+ fake_connection = MagicMock()
72
+ fake_connection.cursor.return_value.__enter__.return_value = fake_cursor
73
+
74
+ hook_log_init.apply_schema(fake_connection)
75
+
76
+ all_executed_statements = [
77
+ each_call.args[0] for each_call in fake_cursor.execute.call_args_list
78
+ ]
79
+ joined_schema_text = "\n".join(all_executed_statements)
80
+ assert "hook_events" in joined_schema_text
81
+ assert "CREATE TABLE IF NOT EXISTS" in joined_schema_text
82
+ assert "blocked_commands" in joined_schema_text
83
+
84
+
85
+ def test_run_sentinel_round_trip_inserts_selects_and_deletes() -> None:
86
+ fake_cursor = MagicMock()
87
+ fake_cursor.fetchone.side_effect = [(1,), (1,)]
88
+ fake_connection = MagicMock()
89
+ fake_connection.cursor.return_value.__enter__.return_value = fake_cursor
90
+
91
+ hook_log_init.run_sentinel_round_trip(fake_connection)
92
+
93
+ all_executed_statements = [
94
+ each_call.args[0].upper() for each_call in fake_cursor.execute.call_args_list
95
+ ]
96
+ assert any("INSERT" in each_statement for each_statement in all_executed_statements)
97
+ assert any("SELECT" in each_statement for each_statement in all_executed_statements)
98
+ assert any("DELETE" in each_statement for each_statement in all_executed_statements)
99
+
100
+
101
+ def test_run_sentinel_round_trip_uses_init_probe_outcome() -> None:
102
+ fake_cursor = MagicMock()
103
+ fake_cursor.fetchone.side_effect = [(1,), (1,)]
104
+ fake_connection = MagicMock()
105
+ fake_connection.cursor.return_value.__enter__.return_value = fake_cursor
106
+
107
+ hook_log_init.run_sentinel_round_trip(fake_connection)
108
+
109
+ all_call_parameters = [
110
+ each_call.args for each_call in fake_cursor.execute.call_args_list
111
+ ]
112
+ joined_parameter_text = " ".join(
113
+ str(each_parameter_tuple) for each_parameter_tuple in all_call_parameters
114
+ )
115
+ assert OUTCOME_INIT_PROBE in joined_parameter_text
116
+
117
+
118
+ def test_print_success_report_includes_host_table_and_rowcount(
119
+ capsys: pytest.CaptureFixture[str],
120
+ ) -> None:
121
+ hook_log_init.print_success_report(
122
+ neon_host="ep-fake-neon.aws.neon.tech",
123
+ table_name="hook_events",
124
+ row_count=42,
125
+ )
126
+
127
+ captured = capsys.readouterr()
128
+ assert "ep-fake-neon.aws.neon.tech" in captured.out
129
+ assert "hook_events" in captured.out
130
+ assert "42" in captured.out
131
+
132
+
133
+ def test_main_happy_path_returns_zero(monkeypatch: pytest.MonkeyPatch) -> None:
134
+ monkeypatch.setenv(
135
+ NEON_DATABASE_URL_ENVIRONMENT_VARIABLE,
136
+ "postgres://u:p@ep-fake-host.aws.neon.tech/db",
137
+ )
138
+
139
+ fake_cursor = MagicMock()
140
+ fake_cursor.fetchone.side_effect = [(1,), (1,), (5,)]
141
+ fake_connection = MagicMock()
142
+ fake_connection.cursor.return_value.__enter__.return_value = fake_cursor
143
+
144
+ with patch.object(hook_log_init, "connect_to_neon", return_value=fake_connection):
145
+ exit_code = hook_log_init.main()
146
+
147
+ assert exit_code == 0
148
+
149
+
150
+ def test_connect_to_neon_raises_when_psycopg_missing(
151
+ monkeypatch: pytest.MonkeyPatch,
152
+ ) -> None:
153
+ monkeypatch.setenv(
154
+ NEON_DATABASE_URL_ENVIRONMENT_VARIABLE,
155
+ "postgres://u:p@ep-fake-host.aws.neon.tech/db",
156
+ )
157
+
158
+ with patch.object(hook_log_init, "psycopg", None):
159
+ with pytest.raises(hook_log_init.MissingPsycopgDependencyError):
160
+ hook_log_init.connect_to_neon()
161
+
162
+
163
+ def test_run_sentinel_round_trip_raises_when_select_returns_nothing() -> None:
164
+ fake_cursor = MagicMock()
165
+ fake_cursor.fetchone.side_effect = [(1,), None]
166
+ fake_connection = MagicMock()
167
+ fake_connection.cursor.return_value.__enter__.return_value = fake_cursor
168
+
169
+ with pytest.raises(RuntimeError):
170
+ hook_log_init.run_sentinel_round_trip(fake_connection)
171
+
172
+
173
+ def test_run_sentinel_round_trip_raises_when_select_returns_wrong_id() -> None:
174
+ fake_cursor = MagicMock()
175
+ fake_cursor.fetchone.side_effect = [(1,), (999,)]
176
+ fake_connection = MagicMock()
177
+ fake_connection.cursor.return_value.__enter__.return_value = fake_cursor
178
+
179
+ with pytest.raises(RuntimeError):
180
+ hook_log_init.run_sentinel_round_trip(fake_connection)
181
+
182
+
183
+ def test_run_sentinel_round_trip_raises_when_insert_returns_no_row() -> None:
184
+ fake_cursor = MagicMock()
185
+ fake_cursor.fetchone.return_value = None
186
+ fake_connection = MagicMock()
187
+ fake_connection.cursor.return_value.__enter__.return_value = fake_cursor
188
+
189
+ with pytest.raises(RuntimeError):
190
+ hook_log_init.run_sentinel_round_trip(fake_connection)
191
+
192
+
193
+ def test_claude_home_resolver_falls_back_to_home_when_env_var_is_empty(
194
+ monkeypatch: pytest.MonkeyPatch,
195
+ ) -> None:
196
+ """Empty CLAUDE_HOME must fall back to ~/.claude (not process CWD)."""
197
+ monkeypatch.setenv("CLAUDE_HOME", "")
198
+
199
+ resolved_home = hook_log_extractor_constants._resolve_claude_home_directory()
200
+
201
+ expected_home = Path.home() / ".claude"
202
+ assert resolved_home == expected_home
203
+
204
+
205
+ def test_claude_home_resolver_falls_back_to_home_when_env_var_is_whitespace(
206
+ monkeypatch: pytest.MonkeyPatch,
207
+ ) -> None:
208
+ """Whitespace-only CLAUDE_HOME must fall back to ~/.claude."""
209
+ monkeypatch.setenv("CLAUDE_HOME", " ")
210
+
211
+ resolved_home = hook_log_extractor_constants._resolve_claude_home_directory()
212
+
213
+ expected_home = Path.home() / ".claude"
214
+ assert resolved_home == expected_home
215
+
216
+
217
+ def test_claude_home_resolver_honors_explicit_path(
218
+ monkeypatch: pytest.MonkeyPatch,
219
+ tmp_path: Path,
220
+ ) -> None:
221
+ """Explicit CLAUDE_HOME path must win over the home fallback."""
222
+ explicit_claude_home = tmp_path / "explicit-claude-home"
223
+ monkeypatch.setenv("CLAUDE_HOME", str(explicit_claude_home))
224
+
225
+ resolved_home = hook_log_extractor_constants._resolve_claude_home_directory()
226
+
227
+ assert resolved_home == explicit_claude_home
@@ -0,0 +1,98 @@
1
+ """Tests for hook_log_stop_wrapper — Stop-hook wrapper that never fails."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ _HOOKS_ROOT = Path(__file__).resolve().parent.parent
11
+ if str(_HOOKS_ROOT) not in sys.path:
12
+ sys.path.insert(0, str(_HOOKS_ROOT))
13
+
14
+ from diagnostic import hook_log_stop_wrapper
15
+ from config.hook_log_extractor_constants import (
16
+ BWS_ACCESS_TOKEN_ENV_VAR,
17
+ BWS_EXECUTABLE_NAME,
18
+ FLAG_INCREMENTAL,
19
+ )
20
+
21
+
22
+ def test_main_returns_zero_when_bws_missing_and_extractor_fails(
23
+ monkeypatch: pytest.MonkeyPatch,
24
+ ) -> None:
25
+ monkeypatch.delenv(BWS_ACCESS_TOKEN_ENV_VAR, raising=False)
26
+ monkeypatch.setattr(hook_log_stop_wrapper.shutil, "which", lambda _name: None)
27
+
28
+ def _raise(*_args: object, **_kwargs: object) -> None:
29
+ raise RuntimeError("boom")
30
+
31
+ monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "run", _raise)
32
+
33
+ assert hook_log_stop_wrapper.main() == 0
34
+
35
+
36
+ def test_main_uses_bws_when_token_and_binary_present(
37
+ monkeypatch: pytest.MonkeyPatch,
38
+ ) -> None:
39
+ monkeypatch.setenv(BWS_ACCESS_TOKEN_ENV_VAR, "secret-value")
40
+ monkeypatch.setattr(
41
+ hook_log_stop_wrapper.shutil,
42
+ "which",
43
+ lambda _name: "/usr/local/bin/bws",
44
+ )
45
+
46
+ captured_commands: list[list[str]] = []
47
+
48
+ def _fake_run(command_list: list[str], **_kwargs: object) -> None:
49
+ captured_commands.append(list(command_list))
50
+
51
+ monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "run", _fake_run)
52
+
53
+ exit_code = hook_log_stop_wrapper.main()
54
+
55
+ assert exit_code == 0
56
+ assert len(captured_commands) == 1
57
+ assert captured_commands[0][0] == BWS_EXECUTABLE_NAME
58
+ assert FLAG_INCREMENTAL in captured_commands[0]
59
+
60
+
61
+ def test_main_skips_bws_when_token_missing(monkeypatch: pytest.MonkeyPatch) -> None:
62
+ monkeypatch.delenv(BWS_ACCESS_TOKEN_ENV_VAR, raising=False)
63
+ monkeypatch.setattr(
64
+ hook_log_stop_wrapper.shutil,
65
+ "which",
66
+ lambda _name: "/usr/local/bin/bws",
67
+ )
68
+
69
+ captured_commands: list[list[str]] = []
70
+
71
+ def _fake_run(command_list: list[str], **_kwargs: object) -> None:
72
+ captured_commands.append(list(command_list))
73
+
74
+ monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "run", _fake_run)
75
+
76
+ exit_code = hook_log_stop_wrapper.main()
77
+
78
+ assert exit_code == 0
79
+ assert len(captured_commands) == 1
80
+ assert BWS_EXECUTABLE_NAME not in captured_commands[0]
81
+
82
+
83
+ def test_main_skips_bws_when_binary_missing(monkeypatch: pytest.MonkeyPatch) -> None:
84
+ monkeypatch.setenv(BWS_ACCESS_TOKEN_ENV_VAR, "secret-value")
85
+ monkeypatch.setattr(hook_log_stop_wrapper.shutil, "which", lambda _name: None)
86
+
87
+ captured_commands: list[list[str]] = []
88
+
89
+ def _fake_run(command_list: list[str], **_kwargs: object) -> None:
90
+ captured_commands.append(list(command_list))
91
+
92
+ monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "run", _fake_run)
93
+
94
+ exit_code = hook_log_stop_wrapper.main()
95
+
96
+ assert exit_code == 0
97
+ assert len(captured_commands) == 1
98
+ assert BWS_EXECUTABLE_NAME not in captured_commands[0]
package/hooks/hooks.json CHANGED
@@ -133,6 +133,16 @@
133
133
  "type": "command",
134
134
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/hedging_language_blocker.py",
135
135
  "timeout": 10
136
+ },
137
+ {
138
+ "type": "command",
139
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/question_to_user_enforcer.py",
140
+ "timeout": 10
141
+ },
142
+ {
143
+ "type": "command",
144
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/diagnostic/hook_log_stop_wrapper.py",
145
+ "timeout": 30
136
146
  }
137
147
  ]
138
148
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.30.0",
3
+ "version": "1.31.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,44 @@
1
+ # AskUserQuestion Required
2
+
3
+ **When this applies:** Any time you would ask the user a question during discovery, scoping, or implementation planning — after the `verify-before-asking` decision checklist confirms the question genuinely belongs to the user.
4
+
5
+ ## Rule
6
+
7
+ Route every user-directed question through the `AskUserQuestion` tool. Embedded plain-text questions in the final paragraph of an assistant message are blocked by a Stop hook, and the response must be re-output with the ask moved into an `AskUserQuestion` tool call.
8
+
9
+ ## Detection Criteria
10
+
11
+ The `question_to_user_enforcer` Stop hook inspects the last non-empty paragraph of the response after stripping fenced code blocks, inline code (backticks), and blockquoted lines (`> …`). The response is blocked when either signal is present:
12
+
13
+ - The final paragraph's last sentence ends with a question mark.
14
+ - The final paragraph contains any of these preamble phrases (case-insensitive, word-boundary matched): `would you like`, `should I`, `do you want`, `which would you prefer`, `let me know if`, `let me know which`, `let me know whether`, `please confirm`, `please let me know`, `want me to`.
15
+
16
+ ## Acceptable Plain-Text Question Patterns
17
+
18
+ These remain allowed and do not trigger the hook:
19
+
20
+ - **Rhetorical questions answered in the same paragraph.** `"What happens if the queue is empty? The handler short-circuits cleanly."` The question frames its own answer; the reader never has to respond.
21
+ - **Questions inside code, diffs, or documentation excerpts.** Code fences, inline backticks, and `>` blockquotes are stripped before detection. Quoting a GitHub issue title, a user's prior message, or a log line inside a blockquote is fine.
22
+ - **Middle-paragraph questions when the closing paragraph is declarative.** Only the final paragraph is scanned.
23
+
24
+ ## AskUserQuestion Structure
25
+
26
+ When a question is genuinely for the user, call the tool with:
27
+
28
+ - A concise `question` string stating what is needed.
29
+ - A `header` of twelve characters or fewer summarizing the decision.
30
+ - Two to four `options`, each with a short `label` the user can pick. An "Other" free-text fallback is already provided by the UI; do not add one manually.
31
+ - `multiSelect: false` unless the user can genuinely combine choices.
32
+
33
+ ## Why
34
+
35
+ - **Structured options reduce re-reading friction.** The user sees labeled choices directly rather than scanning prose for the ask.
36
+ - **Transcript clarity.** Tool-use entries are easy to locate in the JSONL transcript; prose questions disappear into the response text.
37
+ - **Reduced drift.** Claude's next turn cannot move past an unanswered structured question; prose questions can be silently bypassed.
38
+
39
+ ## Enforcement
40
+
41
+ - Hook: `packages/claude-dev-env/hooks/blocking/question_to_user_enforcer.py`, registered on the `Stop` matcher in `packages/claude-dev-env/hooks/hooks.json`.
42
+ - Loop prevention: the hook honors Claude Code's `stop_hook_active` flag and does not re-block on retry.
43
+ - User-facing notice: `USER_FACING_ASKUSERQUESTION_NOTICE` in `packages/claude-dev-env/hooks/config/messages.py`.
44
+ - Related rule: `packages/claude-dev-env/rules/verify-before-asking.md` gates whether the question belongs to the user in the first place.
@@ -21,10 +21,6 @@ def _load_config_module():
21
21
  groq_bugteam_config = _load_config_module()
22
22
 
23
23
 
24
- def test_spec_implementer_prompt_exists():
25
- assert hasattr(groq_bugteam_config, "SPEC_IMPLEMENTER_SYSTEM_PROMPT")
26
-
27
-
28
24
  def test_spec_implementer_prompt_is_non_empty_string():
29
25
  prompt_text = groq_bugteam_config.SPEC_IMPLEMENTER_SYSTEM_PROMPT
30
26
  assert isinstance(prompt_text, str)
@@ -36,14 +36,6 @@ def _load_spec_module():
36
36
  groq_bugteam_spec = _load_spec_module()
37
37
 
38
38
 
39
- def test_apply_fix_from_spec_is_callable():
40
- assert callable(groq_bugteam_spec.apply_fix_from_spec)
41
-
42
-
43
- def test_run_spec_mode_main_is_callable():
44
- assert callable(groq_bugteam_spec.run_spec_mode_main)
45
-
46
-
47
39
  def test_is_spec_mode_invocation_detects_flag_value_pair():
48
40
  assert groq_bugteam_spec.is_spec_mode_invocation(["--mode", "spec"]) is True
49
41
  assert groq_bugteam_spec.is_spec_mode_invocation(["--mode", "pipeline"]) is False