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,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,345 @@
1
+ """Tests for hook_log_stop_wrapper -- debounced, fire-and-forget Stop hook."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ import time
9
+ from pathlib import Path
10
+
11
+ import pytest
12
+
13
+ _HOOKS_ROOT = Path(__file__).resolve().parent.parent
14
+ if str(_HOOKS_ROOT) not in sys.path:
15
+ sys.path.insert(0, str(_HOOKS_ROOT))
16
+
17
+ from diagnostic import hook_log_stop_wrapper
18
+ from config.hook_log_extractor_constants import (
19
+ BWS_ACCESS_TOKEN_ENV_VAR,
20
+ BWS_EXECUTABLE_NAME,
21
+ FLAG_INCREMENTAL,
22
+ STOP_WRAPPER_DEBOUNCE_SECONDS,
23
+ )
24
+
25
+
26
+ def _redirect_timestamp_path(
27
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
28
+ ) -> Path:
29
+ timestamp_file = tmp_path / "stop_wrapper_last_run.txt"
30
+ monkeypatch.setattr(
31
+ hook_log_stop_wrapper,
32
+ "_last_run_timestamp_path",
33
+ lambda: timestamp_file,
34
+ )
35
+ return timestamp_file
36
+
37
+
38
+ def test_main_returns_zero_when_extractor_spawn_raises(
39
+ monkeypatch: pytest.MonkeyPatch,
40
+ tmp_path: Path,
41
+ ) -> None:
42
+ timestamp_file = _redirect_timestamp_path(monkeypatch, tmp_path)
43
+ monkeypatch.delenv(BWS_ACCESS_TOKEN_ENV_VAR, raising=False)
44
+ monkeypatch.setattr(hook_log_stop_wrapper.shutil, "which", lambda _name: None)
45
+
46
+ def _raise(*_args: object, **_kwargs: object) -> None:
47
+ raise RuntimeError("boom")
48
+
49
+ monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "Popen", _raise)
50
+
51
+ assert hook_log_stop_wrapper.main() == 0
52
+ assert not timestamp_file.exists()
53
+
54
+
55
+ def test_main_writes_timestamp_before_spawn_to_narrow_toctou_window(
56
+ monkeypatch: pytest.MonkeyPatch,
57
+ tmp_path: Path,
58
+ ) -> None:
59
+ timestamp_file = _redirect_timestamp_path(monkeypatch, tmp_path)
60
+ monkeypatch.delenv(BWS_ACCESS_TOKEN_ENV_VAR, raising=False)
61
+ monkeypatch.setattr(hook_log_stop_wrapper.shutil, "which", lambda _name: None)
62
+
63
+ timestamp_file_existed_at_spawn_time: list[bool] = []
64
+
65
+ def _capture_timestamp_state(*_args: object, **_kwargs: object) -> object:
66
+ timestamp_file_existed_at_spawn_time.append(timestamp_file.exists())
67
+ return object()
68
+
69
+ monkeypatch.setattr(
70
+ hook_log_stop_wrapper.subprocess, "Popen", _capture_timestamp_state
71
+ )
72
+
73
+ hook_log_stop_wrapper.main()
74
+
75
+ assert timestamp_file_existed_at_spawn_time == [True]
76
+
77
+
78
+ def test_main_removes_timestamp_when_spawn_fails_to_allow_retry(
79
+ monkeypatch: pytest.MonkeyPatch,
80
+ tmp_path: Path,
81
+ ) -> None:
82
+ timestamp_file = _redirect_timestamp_path(monkeypatch, tmp_path)
83
+ timestamp_file.parent.mkdir(parents=True, exist_ok=True)
84
+ stale_timestamp = time.time() - STOP_WRAPPER_DEBOUNCE_SECONDS - 1
85
+ timestamp_file.write_text(str(stale_timestamp))
86
+
87
+ monkeypatch.delenv(BWS_ACCESS_TOKEN_ENV_VAR, raising=False)
88
+ monkeypatch.setattr(hook_log_stop_wrapper.shutil, "which", lambda _name: None)
89
+
90
+ def _raise(*_args: object, **_kwargs: object) -> None:
91
+ raise RuntimeError("boom")
92
+
93
+ monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "Popen", _raise)
94
+
95
+ assert hook_log_stop_wrapper.main() == 0
96
+ assert not timestamp_file.exists()
97
+
98
+
99
+ def test_main_uses_bws_when_token_and_binary_present(
100
+ monkeypatch: pytest.MonkeyPatch,
101
+ tmp_path: Path,
102
+ ) -> None:
103
+ _redirect_timestamp_path(monkeypatch, tmp_path)
104
+ monkeypatch.setenv(BWS_ACCESS_TOKEN_ENV_VAR, "secret-value")
105
+ monkeypatch.setattr(
106
+ hook_log_stop_wrapper.shutil,
107
+ "which",
108
+ lambda _name: "/usr/local/bin/bws",
109
+ )
110
+
111
+ captured_commands: list[list[str]] = []
112
+
113
+ def _fake_popen(command_list: list[str], **_kwargs: object) -> object:
114
+ captured_commands.append(list(command_list))
115
+ return object()
116
+
117
+ monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "Popen", _fake_popen)
118
+
119
+ exit_code = hook_log_stop_wrapper.main()
120
+
121
+ assert exit_code == 0
122
+ assert len(captured_commands) == 1
123
+ assert captured_commands[0][0] == BWS_EXECUTABLE_NAME
124
+ assert FLAG_INCREMENTAL in captured_commands[0]
125
+
126
+
127
+ def test_main_skips_bws_when_token_missing(
128
+ monkeypatch: pytest.MonkeyPatch,
129
+ tmp_path: Path,
130
+ ) -> None:
131
+ _redirect_timestamp_path(monkeypatch, tmp_path)
132
+ monkeypatch.delenv(BWS_ACCESS_TOKEN_ENV_VAR, raising=False)
133
+ monkeypatch.setattr(
134
+ hook_log_stop_wrapper.shutil,
135
+ "which",
136
+ lambda _name: "/usr/local/bin/bws",
137
+ )
138
+
139
+ captured_commands: list[list[str]] = []
140
+
141
+ def _fake_popen(command_list: list[str], **_kwargs: object) -> object:
142
+ captured_commands.append(list(command_list))
143
+ return object()
144
+
145
+ monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "Popen", _fake_popen)
146
+
147
+ exit_code = hook_log_stop_wrapper.main()
148
+
149
+ assert exit_code == 0
150
+ assert len(captured_commands) == 1
151
+ assert BWS_EXECUTABLE_NAME not in captured_commands[0]
152
+
153
+
154
+ def test_main_skips_bws_when_binary_missing(
155
+ monkeypatch: pytest.MonkeyPatch,
156
+ tmp_path: Path,
157
+ ) -> None:
158
+ _redirect_timestamp_path(monkeypatch, tmp_path)
159
+ monkeypatch.setenv(BWS_ACCESS_TOKEN_ENV_VAR, "secret-value")
160
+ monkeypatch.setattr(hook_log_stop_wrapper.shutil, "which", lambda _name: None)
161
+
162
+ captured_commands: list[list[str]] = []
163
+
164
+ def _fake_popen(command_list: list[str], **_kwargs: object) -> object:
165
+ captured_commands.append(list(command_list))
166
+ return object()
167
+
168
+ monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "Popen", _fake_popen)
169
+
170
+ exit_code = hook_log_stop_wrapper.main()
171
+
172
+ assert exit_code == 0
173
+ assert len(captured_commands) == 1
174
+ assert BWS_EXECUTABLE_NAME not in captured_commands[0]
175
+
176
+
177
+ def test_main_skips_spawn_when_recent_timestamp_within_debounce_window(
178
+ monkeypatch: pytest.MonkeyPatch,
179
+ tmp_path: Path,
180
+ ) -> None:
181
+ timestamp_file = _redirect_timestamp_path(monkeypatch, tmp_path)
182
+ timestamp_file.parent.mkdir(parents=True, exist_ok=True)
183
+ timestamp_file.write_text(str(time.time()))
184
+
185
+ captured_commands: list[list[str]] = []
186
+
187
+ def _fake_popen(command_list: list[str], **_kwargs: object) -> object:
188
+ captured_commands.append(list(command_list))
189
+ return object()
190
+
191
+ monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "Popen", _fake_popen)
192
+
193
+ exit_code = hook_log_stop_wrapper.main()
194
+
195
+ assert exit_code == 0
196
+ assert len(captured_commands) == 0
197
+
198
+
199
+ def test_main_spawns_when_timestamp_older_than_debounce_window(
200
+ monkeypatch: pytest.MonkeyPatch,
201
+ tmp_path: Path,
202
+ ) -> None:
203
+ timestamp_file = _redirect_timestamp_path(monkeypatch, tmp_path)
204
+ timestamp_file.parent.mkdir(parents=True, exist_ok=True)
205
+ stale_timestamp = time.time() - STOP_WRAPPER_DEBOUNCE_SECONDS - 1
206
+ timestamp_file.write_text(str(stale_timestamp))
207
+
208
+ monkeypatch.delenv(BWS_ACCESS_TOKEN_ENV_VAR, raising=False)
209
+ monkeypatch.setattr(hook_log_stop_wrapper.shutil, "which", lambda _name: None)
210
+
211
+ captured_commands: list[list[str]] = []
212
+
213
+ def _fake_popen(command_list: list[str], **_kwargs: object) -> object:
214
+ captured_commands.append(list(command_list))
215
+ return object()
216
+
217
+ monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "Popen", _fake_popen)
218
+
219
+ exit_code = hook_log_stop_wrapper.main()
220
+
221
+ assert exit_code == 0
222
+ assert len(captured_commands) == 1
223
+
224
+
225
+ def test_main_writes_current_timestamp_to_file(
226
+ monkeypatch: pytest.MonkeyPatch,
227
+ tmp_path: Path,
228
+ ) -> None:
229
+ timestamp_file = _redirect_timestamp_path(monkeypatch, tmp_path)
230
+ monkeypatch.delenv(BWS_ACCESS_TOKEN_ENV_VAR, raising=False)
231
+ monkeypatch.setattr(hook_log_stop_wrapper.shutil, "which", lambda _name: None)
232
+ monkeypatch.setattr(
233
+ hook_log_stop_wrapper.subprocess,
234
+ "Popen",
235
+ lambda *_args, **_kwargs: object(),
236
+ )
237
+
238
+ timestamp_before_call = time.time()
239
+ hook_log_stop_wrapper.main()
240
+ timestamp_after_call = time.time()
241
+
242
+ assert timestamp_file.exists()
243
+ written_timestamp = float(timestamp_file.read_text().strip())
244
+ assert timestamp_before_call <= written_timestamp <= timestamp_after_call
245
+
246
+
247
+ def test_main_passes_devnull_streams_to_detached_spawn(
248
+ monkeypatch: pytest.MonkeyPatch,
249
+ tmp_path: Path,
250
+ ) -> None:
251
+ _redirect_timestamp_path(monkeypatch, tmp_path)
252
+ monkeypatch.delenv(BWS_ACCESS_TOKEN_ENV_VAR, raising=False)
253
+ monkeypatch.setattr(hook_log_stop_wrapper.shutil, "which", lambda _name: None)
254
+
255
+ captured_kwargs: dict[str, object] = {}
256
+
257
+ def _fake_popen(command_list: list[str], **kwargs: object) -> object:
258
+ captured_kwargs.update(kwargs)
259
+ return object()
260
+
261
+ monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "Popen", _fake_popen)
262
+
263
+ hook_log_stop_wrapper.main()
264
+
265
+ assert captured_kwargs.get("stdin") is hook_log_stop_wrapper.subprocess.DEVNULL
266
+ assert captured_kwargs.get("stdout") is hook_log_stop_wrapper.subprocess.DEVNULL
267
+ assert captured_kwargs.get("stderr") is hook_log_stop_wrapper.subprocess.DEVNULL
268
+
269
+
270
+ def test_main_recovers_when_timestamp_file_is_corrupted(
271
+ monkeypatch: pytest.MonkeyPatch,
272
+ tmp_path: Path,
273
+ ) -> None:
274
+ timestamp_file = _redirect_timestamp_path(monkeypatch, tmp_path)
275
+ timestamp_file.parent.mkdir(parents=True, exist_ok=True)
276
+ timestamp_file.write_text("not-a-float")
277
+
278
+ monkeypatch.delenv(BWS_ACCESS_TOKEN_ENV_VAR, raising=False)
279
+ monkeypatch.setattr(hook_log_stop_wrapper.shutil, "which", lambda _name: None)
280
+
281
+ captured_commands: list[list[str]] = []
282
+
283
+ def _fake_popen(command_list: list[str], **_kwargs: object) -> object:
284
+ captured_commands.append(list(command_list))
285
+ return object()
286
+
287
+ monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "Popen", _fake_popen)
288
+
289
+ exit_code = hook_log_stop_wrapper.main()
290
+
291
+ assert exit_code == 0
292
+ assert len(captured_commands) == 1
293
+
294
+
295
+ def test_main_treats_future_timestamp_as_not_debounced(
296
+ monkeypatch: pytest.MonkeyPatch,
297
+ tmp_path: Path,
298
+ ) -> None:
299
+ timestamp_file = _redirect_timestamp_path(monkeypatch, tmp_path)
300
+ timestamp_file.parent.mkdir(parents=True, exist_ok=True)
301
+ far_future_timestamp = time.time() + 3600
302
+ timestamp_file.write_text(str(far_future_timestamp))
303
+
304
+ monkeypatch.delenv(BWS_ACCESS_TOKEN_ENV_VAR, raising=False)
305
+ monkeypatch.setattr(hook_log_stop_wrapper.shutil, "which", lambda _name: None)
306
+
307
+ captured_commands: list[list[str]] = []
308
+
309
+ def _fake_popen(command_list: list[str], **_kwargs: object) -> object:
310
+ captured_commands.append(list(command_list))
311
+ return object()
312
+
313
+ monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "Popen", _fake_popen)
314
+
315
+ exit_code = hook_log_stop_wrapper.main()
316
+
317
+ assert exit_code == 0
318
+ assert len(captured_commands) == 1
319
+
320
+
321
+ def test_main_passes_hidden_startupinfo_on_windows(
322
+ monkeypatch: pytest.MonkeyPatch,
323
+ tmp_path: Path,
324
+ ) -> None:
325
+ _redirect_timestamp_path(monkeypatch, tmp_path)
326
+ monkeypatch.delenv(BWS_ACCESS_TOKEN_ENV_VAR, raising=False)
327
+ monkeypatch.setattr(hook_log_stop_wrapper.shutil, "which", lambda _name: None)
328
+
329
+ captured_kwargs: dict[str, object] = {}
330
+
331
+ def _fake_popen(command_list: list[str], **kwargs: object) -> object:
332
+ captured_kwargs.update(kwargs)
333
+ return object()
334
+
335
+ monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "Popen", _fake_popen)
336
+
337
+ hook_log_stop_wrapper.main()
338
+
339
+ if os.name == "nt":
340
+ startup_info = captured_kwargs.get("startupinfo")
341
+ assert startup_info is not None
342
+ assert startup_info.dwFlags & subprocess.STARTF_USESHOWWINDOW
343
+ assert startup_info.wShowWindow == subprocess.SW_HIDE
344
+ else:
345
+ assert captured_kwargs.get("start_new_session") is True
package/hooks/hooks.json CHANGED
@@ -39,6 +39,11 @@
39
39
  "type": "command",
40
40
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/tdd_enforcer.py",
41
41
  "timeout": 10
42
+ },
43
+ {
44
+ "type": "command",
45
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/windows_rmtree_blocker.py",
46
+ "timeout": 10
42
47
  }
43
48
  ]
44
49
  },
@@ -89,6 +94,11 @@
89
94
  "type": "command",
90
95
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/content_search_to_zoekt_redirector.py",
91
96
  "timeout": 10
97
+ },
98
+ {
99
+ "type": "command",
100
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/windows_rmtree_blocker.py",
101
+ "timeout": 10
92
102
  }
93
103
  ]
94
104
  },
@@ -112,6 +122,11 @@
112
122
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/plugin_data_dir_cleanup.py",
113
123
  "timeout": 10
114
124
  },
125
+ {
126
+ "type": "command",
127
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/session_env_cleanup.py",
128
+ "timeout": 10
129
+ },
115
130
  {
116
131
  "type": "command",
117
132
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/untracked_repo_detector.py",
@@ -133,6 +148,16 @@
133
148
  "type": "command",
134
149
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/hedging_language_blocker.py",
135
150
  "timeout": 10
151
+ },
152
+ {
153
+ "type": "command",
154
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/question_to_user_enforcer.py",
155
+ "timeout": 10
156
+ },
157
+ {
158
+ "type": "command",
159
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/diagnostic/hook_log_stop_wrapper.py",
160
+ "timeout": 30
136
161
  }
137
162
  ]
138
163
  }