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.
- package/hooks/blocking/code_rules_enforcer.py +109 -0
- package/hooks/blocking/test_windows_rmtree_blocker.py +155 -0
- package/hooks/blocking/windows_rmtree_blocker.py +102 -0
- package/hooks/config/hook_log_extractor_constants.py +13 -0
- package/hooks/config/session_env_cleanup_constants.py +20 -0
- package/hooks/config/test_hook_log_extractor_constants.py +27 -0
- package/hooks/config/test_session_env_cleanup_constants.py +60 -0
- package/hooks/diagnostic/hook_log_stop_wrapper.py +107 -19
- package/hooks/diagnostic/test_hook_log_stop_wrapper.py +258 -11
- package/hooks/hooks.json +15 -0
- package/hooks/session/session_env_cleanup.py +130 -0
- package/hooks/session/test_session_env_cleanup.py +280 -0
- package/package.json +1 -1
- package/rules/windows-filesystem-safe.md +91 -0
- package/skills/bugteam/PROMPTS.md +39 -0
- package/skills/bugteam/SKILL.md +49 -1
- package/skills/bugteam/SKILL_EVALS.md +1 -1
- package/skills/bugteam/reference/copilot-gap-analysis.md +496 -0
- package/skills/bugteam/reference/teardown-publish-permissions.md +1 -1
- package/skills/bugteam/scripts/README.md +17 -0
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +94 -0
- package/skills/bugteam/scripts/bugteam_fix_hookspath.py +260 -0
- package/skills/bugteam/scripts/config/__init__.py +0 -0
- package/skills/bugteam/scripts/config/bugteam_fix_hookspath_constants.py +17 -0
- package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +267 -0
- package/skills/logifix/SKILL.md +69 -0
- package/skills/logifix/scripts/logifix.ps1 +205 -0
- 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
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
47
|
-
|
|
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
|
-
|
|
138
|
+
**_detached_spawn_keyword_arguments(),
|
|
57
139
|
)
|
|
58
140
|
|
|
59
141
|
|
|
60
|
-
def
|
|
61
|
-
subprocess.
|
|
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
|
-
|
|
149
|
+
**_detached_spawn_keyword_arguments(),
|
|
68
150
|
)
|
|
69
151
|
|
|
70
152
|
|
|
71
153
|
def main() -> int:
|
|
72
|
-
"""
|
|
154
|
+
"""Debounce, then fire-and-forget the extractor; always exit 0."""
|
|
73
155
|
try:
|
|
74
|
-
if
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
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
|
|
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, "
|
|
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
|
|
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, "
|
|
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(
|
|
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
|
|
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, "
|
|
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(
|
|
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
|
|
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, "
|
|
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()
|