claude-dev-env 1.31.0 → 1.33.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 (28) hide show
  1. package/hooks/blocking/code_rules_enforcer.py +109 -0
  2. package/hooks/blocking/test_windows_rmtree_blocker.py +155 -0
  3. package/hooks/blocking/windows_rmtree_blocker.py +102 -0
  4. package/hooks/config/hook_log_extractor_constants.py +13 -0
  5. package/hooks/config/session_env_cleanup_constants.py +20 -0
  6. package/hooks/config/test_hook_log_extractor_constants.py +27 -0
  7. package/hooks/config/test_session_env_cleanup_constants.py +60 -0
  8. package/hooks/diagnostic/hook_log_stop_wrapper.py +107 -19
  9. package/hooks/diagnostic/test_hook_log_stop_wrapper.py +258 -11
  10. package/hooks/hooks.json +15 -0
  11. package/hooks/session/session_env_cleanup.py +130 -0
  12. package/hooks/session/test_session_env_cleanup.py +280 -0
  13. package/package.json +1 -1
  14. package/rules/windows-filesystem-safe.md +91 -0
  15. package/skills/bugteam/PROMPTS.md +39 -0
  16. package/skills/bugteam/SKILL.md +49 -1
  17. package/skills/bugteam/SKILL_EVALS.md +1 -1
  18. package/skills/bugteam/reference/copilot-gap-analysis.md +496 -0
  19. package/skills/bugteam/reference/teardown-publish-permissions.md +1 -1
  20. package/skills/bugteam/scripts/README.md +17 -0
  21. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +94 -0
  22. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +260 -0
  23. package/skills/bugteam/scripts/config/__init__.py +0 -0
  24. package/skills/bugteam/scripts/config/bugteam_fix_hookspath_constants.py +17 -0
  25. package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +267 -0
  26. package/skills/logifix/SKILL.md +69 -0
  27. package/skills/logifix/scripts/logifix.ps1 +205 -0
  28. package/skills/rebase/SKILL.md +164 -0
@@ -1,12 +1,25 @@
1
1
  #!/usr/bin/env python3
2
- """Stop-hook wrapper for hook_log_extractor that never surfaces a hook failure.
3
-
4
- Invokes ``hook_log_extractor.py --incremental`` via ``bws run`` only when
5
- both ``bws`` is on PATH and ``BWS_ACCESS_TOKEN`` is set; otherwise falls
6
- through to run the extractor directly. The extractor itself exits 0 when
7
- ``NEON_HOOK_LOGS_DATABASE_URL`` is unset, so the wrapper can rely on that
8
- offline-graceful path. This wrapper always exits 0 so the Stop hook
9
- never blocks session end on a missing dependency.
2
+ """Stop-hook wrapper for hook_log_extractor: debounced, fire-and-forget.
3
+
4
+ Runs after every assistant turn (Stop hook), so per-turn latency must
5
+ stay near zero. The wrapper:
6
+
7
+ 1. Reads the last-spawn timestamp; if it falls within the debounce
8
+ window, exits 0 immediately without spawning anything (typical fast
9
+ path: a small file read, well under 10ms).
10
+ 2. Otherwise records the current timestamp, then launches the extractor
11
+ as a fully detached background process (no stdio, separate process
12
+ group on POSIX or DETACHED_PROCESS|CREATE_NEW_PROCESS_GROUP on
13
+ Windows) and returns without waiting for it.
14
+
15
+ Bitwarden injection: when both ``bws`` is on PATH and
16
+ ``BWS_ACCESS_TOKEN`` is set, the extractor is launched via
17
+ ``bws run --`` so the Neon URL never hits disk; otherwise it is
18
+ launched directly. The extractor itself exits 0 when
19
+ ``NEON_HOOK_LOGS_DATABASE_URL`` is unset, so missing dependencies
20
+ cannot block session shutdown.
21
+
22
+ This wrapper always exits 0 so the Stop hook never surfaces a failure.
10
23
  """
11
24
 
12
25
  from __future__ import annotations
@@ -15,6 +28,7 @@ import os
15
28
  import shutil
16
29
  import subprocess
17
30
  import sys
31
+ import time
18
32
  from pathlib import Path
19
33
 
20
34
  if str(Path(__file__).resolve().parent.parent) not in sys.path:
@@ -27,7 +41,12 @@ from config.hook_log_extractor_constants import (
27
41
  BWS_RUN_SUBCOMMAND,
28
42
  EXIT_CODE_SUCCESS,
29
43
  FLAG_INCREMENTAL,
44
+ STOP_WRAPPER_DEBOUNCE_SECONDS,
30
45
  STOP_WRAPPER_EXTRACTOR_SCRIPT_NAME,
46
+ STOP_WRAPPER_LAST_RUN_TIMESTAMP_FILE,
47
+ WINDOWS_CREATE_NEW_PROCESS_GROUP_FLAG,
48
+ WINDOWS_DETACHED_PROCESS_FLAG,
49
+ WINDOWS_OS_NAME,
31
50
  )
32
51
 
33
52
 
@@ -37,14 +56,77 @@ def _extractor_script_path() -> str:
37
56
  )
38
57
 
39
58
 
59
+ def _last_run_timestamp_path() -> Path:
60
+ return Path(STOP_WRAPPER_LAST_RUN_TIMESTAMP_FILE)
61
+
62
+
63
+ def _is_within_debounce_window() -> bool:
64
+ timestamp_path = _last_run_timestamp_path()
65
+ if not timestamp_path.exists():
66
+ return False
67
+ try:
68
+ previous_timestamp = float(timestamp_path.read_text().strip())
69
+ except (OSError, ValueError):
70
+ return False
71
+ seconds_since_previous_spawn = time.time() - previous_timestamp
72
+ if seconds_since_previous_spawn < 0:
73
+ return False
74
+ return seconds_since_previous_spawn < STOP_WRAPPER_DEBOUNCE_SECONDS
75
+
76
+
77
+ def _record_current_timestamp() -> None:
78
+ timestamp_path = _last_run_timestamp_path()
79
+ timestamp_path.parent.mkdir(parents=True, exist_ok=True)
80
+ timestamp_path.write_text(str(time.time()))
81
+
82
+
83
+ def _clear_recorded_timestamp() -> None:
84
+ """Remove the debounce timestamp regardless of which process wrote it.
85
+
86
+ The unlink is unconditional: if process A writes a timestamp, process
87
+ B observes it and debounces, then A's spawn fails, A's rollback here
88
+ deletes the timestamp B already saw. The next Stop hook then spawns
89
+ a fresh extractor instead of debouncing the remainder of the window.
90
+ Consequence is benign because the extractor's own offset-file lock
91
+ (LOCK_MAXIMUM_RETRY_COUNT in hook_log_extractor_constants) serializes
92
+ concurrent extractor runs, so at most one extra extractor briefly
93
+ waits on the lock. Scoping the rollback to A's own write would
94
+ require a per-process sentinel and tighter atomicity than the
95
+ benefit warrants for this Stop-hook fast path.
96
+ """
97
+ timestamp_path = _last_run_timestamp_path()
98
+ timestamp_path.unlink(missing_ok=True)
99
+
100
+
40
101
  def _can_use_bws() -> bool:
41
102
  if not os.environ.get(BWS_ACCESS_TOKEN_ENV_VAR):
42
103
  return False
43
104
  return shutil.which(BWS_EXECUTABLE_NAME) is not None
44
105
 
45
106
 
46
- def _run_with_bws() -> None:
47
- subprocess.run(
107
+ def _detached_spawn_keyword_arguments() -> dict[str, object]:
108
+ spawn_arguments: dict[str, object] = {
109
+ "stdin": subprocess.DEVNULL,
110
+ "stdout": subprocess.DEVNULL,
111
+ "stderr": subprocess.DEVNULL,
112
+ "close_fds": True,
113
+ }
114
+ if os.name == WINDOWS_OS_NAME:
115
+ spawn_arguments["creationflags"] = (
116
+ WINDOWS_DETACHED_PROCESS_FLAG
117
+ | WINDOWS_CREATE_NEW_PROCESS_GROUP_FLAG
118
+ )
119
+ startup_info = subprocess.STARTUPINFO()
120
+ startup_info.dwFlags |= subprocess.STARTF_USESHOWWINDOW
121
+ startup_info.wShowWindow = subprocess.SW_HIDE
122
+ spawn_arguments["startupinfo"] = startup_info
123
+ else:
124
+ spawn_arguments["start_new_session"] = True
125
+ return spawn_arguments
126
+
127
+
128
+ def _spawn_with_bws() -> None:
129
+ subprocess.Popen(
48
130
  [
49
131
  BWS_EXECUTABLE_NAME,
50
132
  BWS_RUN_SUBCOMMAND,
@@ -53,28 +135,34 @@ def _run_with_bws() -> None:
53
135
  _extractor_script_path(),
54
136
  FLAG_INCREMENTAL,
55
137
  ],
56
- check=False,
138
+ **_detached_spawn_keyword_arguments(),
57
139
  )
58
140
 
59
141
 
60
- def _run_without_bws() -> None:
61
- subprocess.run(
142
+ def _spawn_without_bws() -> None:
143
+ subprocess.Popen(
62
144
  [
63
145
  sys.executable,
64
146
  _extractor_script_path(),
65
147
  FLAG_INCREMENTAL,
66
148
  ],
67
- check=False,
149
+ **_detached_spawn_keyword_arguments(),
68
150
  )
69
151
 
70
152
 
71
153
  def main() -> int:
72
- """Invoke the extractor with or without bws; swallow all failures."""
154
+ """Debounce, then fire-and-forget the extractor; always exit 0."""
73
155
  try:
74
- if _can_use_bws():
75
- _run_with_bws()
76
- else:
77
- _run_without_bws()
156
+ if _is_within_debounce_window():
157
+ return EXIT_CODE_SUCCESS
158
+ _record_current_timestamp()
159
+ try:
160
+ if _can_use_bws():
161
+ _spawn_with_bws()
162
+ else:
163
+ _spawn_without_bws()
164
+ except Exception:
165
+ _clear_recorded_timestamp()
78
166
  except Exception:
79
167
  pass
80
168
  return EXIT_CODE_SUCCESS
@@ -1,8 +1,11 @@
1
- """Tests for hook_log_stop_wrapper Stop-hook wrapper that never fails."""
1
+ """Tests for hook_log_stop_wrapper -- debounced, fire-and-forget Stop hook."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import os
6
+ import subprocess
5
7
  import sys
8
+ import time
6
9
  from pathlib import Path
7
10
 
8
11
  import pytest
@@ -16,26 +19,88 @@ from config.hook_log_extractor_constants import (
16
19
  BWS_ACCESS_TOKEN_ENV_VAR,
17
20
  BWS_EXECUTABLE_NAME,
18
21
  FLAG_INCREMENTAL,
22
+ STOP_WRAPPER_DEBOUNCE_SECONDS,
19
23
  )
20
24
 
21
25
 
22
- def test_main_returns_zero_when_bws_missing_and_extractor_fails(
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(
23
39
  monkeypatch: pytest.MonkeyPatch,
40
+ tmp_path: Path,
24
41
  ) -> None:
42
+ timestamp_file = _redirect_timestamp_path(monkeypatch, tmp_path)
25
43
  monkeypatch.delenv(BWS_ACCESS_TOKEN_ENV_VAR, raising=False)
26
44
  monkeypatch.setattr(hook_log_stop_wrapper.shutil, "which", lambda _name: None)
27
45
 
28
46
  def _raise(*_args: object, **_kwargs: object) -> None:
29
47
  raise RuntimeError("boom")
30
48
 
31
- monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "run", _raise)
49
+ monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "Popen", _raise)
32
50
 
33
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()
34
97
 
35
98
 
36
99
  def test_main_uses_bws_when_token_and_binary_present(
37
100
  monkeypatch: pytest.MonkeyPatch,
101
+ tmp_path: Path,
38
102
  ) -> None:
103
+ _redirect_timestamp_path(monkeypatch, tmp_path)
39
104
  monkeypatch.setenv(BWS_ACCESS_TOKEN_ENV_VAR, "secret-value")
40
105
  monkeypatch.setattr(
41
106
  hook_log_stop_wrapper.shutil,
@@ -45,10 +110,11 @@ def test_main_uses_bws_when_token_and_binary_present(
45
110
 
46
111
  captured_commands: list[list[str]] = []
47
112
 
48
- def _fake_run(command_list: list[str], **_kwargs: object) -> None:
113
+ def _fake_popen(command_list: list[str], **_kwargs: object) -> object:
49
114
  captured_commands.append(list(command_list))
115
+ return object()
50
116
 
51
- monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "run", _fake_run)
117
+ monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "Popen", _fake_popen)
52
118
 
53
119
  exit_code = hook_log_stop_wrapper.main()
54
120
 
@@ -58,7 +124,11 @@ def test_main_uses_bws_when_token_and_binary_present(
58
124
  assert FLAG_INCREMENTAL in captured_commands[0]
59
125
 
60
126
 
61
- def test_main_skips_bws_when_token_missing(monkeypatch: pytest.MonkeyPatch) -> None:
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)
62
132
  monkeypatch.delenv(BWS_ACCESS_TOKEN_ENV_VAR, raising=False)
63
133
  monkeypatch.setattr(
64
134
  hook_log_stop_wrapper.shutil,
@@ -68,10 +138,11 @@ def test_main_skips_bws_when_token_missing(monkeypatch: pytest.MonkeyPatch) -> N
68
138
 
69
139
  captured_commands: list[list[str]] = []
70
140
 
71
- def _fake_run(command_list: list[str], **_kwargs: object) -> None:
141
+ def _fake_popen(command_list: list[str], **_kwargs: object) -> object:
72
142
  captured_commands.append(list(command_list))
143
+ return object()
73
144
 
74
- monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "run", _fake_run)
145
+ monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "Popen", _fake_popen)
75
146
 
76
147
  exit_code = hook_log_stop_wrapper.main()
77
148
 
@@ -80,19 +151,195 @@ def test_main_skips_bws_when_token_missing(monkeypatch: pytest.MonkeyPatch) -> N
80
151
  assert BWS_EXECUTABLE_NAME not in captured_commands[0]
81
152
 
82
153
 
83
- def test_main_skips_bws_when_binary_missing(monkeypatch: pytest.MonkeyPatch) -> None:
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)
84
159
  monkeypatch.setenv(BWS_ACCESS_TOKEN_ENV_VAR, "secret-value")
85
160
  monkeypatch.setattr(hook_log_stop_wrapper.shutil, "which", lambda _name: None)
86
161
 
87
162
  captured_commands: list[list[str]] = []
88
163
 
89
- def _fake_run(command_list: list[str], **_kwargs: object) -> None:
164
+ def _fake_popen(command_list: list[str], **_kwargs: object) -> object:
90
165
  captured_commands.append(list(command_list))
166
+ return object()
91
167
 
92
- monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "run", _fake_run)
168
+ monkeypatch.setattr(hook_log_stop_wrapper.subprocess, "Popen", _fake_popen)
93
169
 
94
170
  exit_code = hook_log_stop_wrapper.main()
95
171
 
96
172
  assert exit_code == 0
97
173
  assert len(captured_commands) == 1
98
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",
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env python3
2
+ """SessionStart hook — clean the Claude Code session-env directory on Windows.
3
+
4
+ Claude Code's Bash tool sets up a per-session sandbox at
5
+ ``~/.claude/session-env/<session_id>/``. The mkdir call appears non-recursive,
6
+ so once the directory exists, later Bash invocations in the same session can
7
+ throw ``EEXIST`` and abort. PowerShell tool calls are unaffected.
8
+
9
+ This hook removes the current session's pre-existing directory at start and
10
+ prunes sibling entries whose mtime is older than the stale-age threshold so
11
+ the parent directory does not grow without bound.
12
+
13
+ Tracking: https://github.com/anthropics/claude-code/issues — Windows-only
14
+ mkdir bug separate from the EEXIST fixes in v2.1.70-v2.1.72.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import shutil
22
+ import stat
23
+ import sys
24
+ import time
25
+ from pathlib import Path
26
+ from typing import Callable
27
+
28
+
29
+ def _insert_hooks_tree_for_imports() -> None:
30
+ hooks_tree = Path(__file__).resolve().parent.parent
31
+ hooks_tree_string = str(hooks_tree)
32
+ if hooks_tree_string not in sys.path:
33
+ sys.path.insert(0, hooks_tree_string)
34
+
35
+
36
+ _insert_hooks_tree_for_imports()
37
+
38
+ from config.session_env_cleanup_constants import (
39
+ ALL_RMTREE_ONEXC_PYTHON_VERSION_PARTS,
40
+ SESSION_ENV_DIRECTORY,
41
+ SESSION_ID_PATTERN,
42
+ SESSION_ID_PAYLOAD_KEY,
43
+ STALE_AGE_SECONDS,
44
+ WINDOWS_PLATFORM_TAG,
45
+ )
46
+
47
+
48
+ def _strip_read_only_and_retry(
49
+ removal_function: Callable[[str], None],
50
+ target_path: str,
51
+ *_unused_exception_info: object,
52
+ ) -> None:
53
+ try:
54
+ os.chmod(target_path, stat.S_IWRITE)
55
+ removal_function(target_path)
56
+ except OSError:
57
+ pass
58
+
59
+
60
+ def _force_rmtree(target_path: str) -> None:
61
+ rmtree_onexc_python_version = ALL_RMTREE_ONEXC_PYTHON_VERSION_PARTS
62
+ try:
63
+ if sys.version_info >= rmtree_onexc_python_version:
64
+ shutil.rmtree(target_path, onexc=_strip_read_only_and_retry)
65
+ else:
66
+ shutil.rmtree(target_path, onerror=_strip_read_only_and_retry)
67
+ except OSError:
68
+ pass
69
+
70
+
71
+ def prune_session_env(
72
+ session_env_directory: str,
73
+ session_id: str,
74
+ stale_age_seconds: float,
75
+ ) -> None:
76
+ """Remove the current session's directory and prune stale siblings."""
77
+ if session_id:
78
+ current_session_path = os.path.join(session_env_directory, session_id)
79
+ if os.path.isdir(current_session_path):
80
+ _force_rmtree(current_session_path)
81
+ if not os.path.isdir(session_env_directory):
82
+ return
83
+ stale_cutoff_seconds = time.time() - stale_age_seconds
84
+ try:
85
+ all_entry_names = os.listdir(session_env_directory)
86
+ except OSError:
87
+ return
88
+ for each_entry_name in all_entry_names:
89
+ entry_path = os.path.join(session_env_directory, each_entry_name)
90
+ try:
91
+ entry_mtime_seconds = os.path.getmtime(entry_path)
92
+ except OSError:
93
+ continue
94
+ if entry_mtime_seconds >= stale_cutoff_seconds:
95
+ continue
96
+ _force_rmtree(entry_path)
97
+
98
+
99
+ def _read_session_id_from_stdin() -> str:
100
+ session_id_pattern = SESSION_ID_PATTERN
101
+ try:
102
+ payload = json.load(sys.stdin)
103
+ except (json.JSONDecodeError, ValueError):
104
+ return ""
105
+ if not isinstance(payload, dict):
106
+ return ""
107
+ raw_session_id = payload.get(SESSION_ID_PAYLOAD_KEY)
108
+ if not isinstance(raw_session_id, str):
109
+ return ""
110
+ if not session_id_pattern.fullmatch(raw_session_id):
111
+ return ""
112
+ return raw_session_id
113
+
114
+
115
+ def main() -> None:
116
+ windows_platform_tag = WINDOWS_PLATFORM_TAG
117
+ if sys.platform != windows_platform_tag:
118
+ return
119
+ session_env_directory = SESSION_ENV_DIRECTORY
120
+ stale_age_seconds = STALE_AGE_SECONDS
121
+ session_id = _read_session_id_from_stdin()
122
+ prune_session_env(
123
+ session_env_directory=session_env_directory,
124
+ session_id=session_id,
125
+ stale_age_seconds=stale_age_seconds,
126
+ )
127
+
128
+
129
+ if __name__ == "__main__":
130
+ main()