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.
- package/agents/clean-coder.md +275 -111
- package/agents/code-quality-agent.md +196 -209
- package/bin/install.mjs +81 -0
- package/bin/install.test.mjs +158 -0
- package/bin/install_mypy_ini.mjs +51 -0
- package/bin/install_mypy_ini.test.mjs +121 -0
- package/commands/hook-log-extract.md +70 -0
- package/commands/hook-log-init.md +76 -0
- package/hooks/blocking/code_rules_enforcer.py +5 -3
- package/hooks/blocking/destructive_command_blocker.py +187 -0
- package/hooks/blocking/question_to_user_enforcer.py +140 -0
- package/hooks/blocking/test_code_rules_enforcer_file_global_constants.py +39 -0
- package/hooks/blocking/test_destructive_command_blocker.py +397 -0
- package/hooks/blocking/test_question_to_user_enforcer.py +163 -0
- package/hooks/blocking/test_windows_rmtree_blocker.py +148 -0
- package/hooks/blocking/windows_rmtree_blocker.py +106 -0
- package/hooks/config/hook_log_extractor_constants.py +234 -0
- package/hooks/config/messages.py +3 -0
- package/hooks/config/session_env_cleanup_constants.py +18 -0
- package/hooks/config/test_hook_log_extractor_constants.py +123 -0
- package/hooks/config/test_messages.py +5 -0
- package/hooks/config/test_session_env_cleanup_constants.py +55 -0
- package/hooks/diagnostic/hook_log_extractor.py +907 -0
- package/hooks/diagnostic/hook_log_init.py +202 -0
- package/hooks/diagnostic/hook_log_stop_wrapper.py +172 -0
- package/hooks/diagnostic/migrations/2026-04-25-drop-themes-hook-events.sql +3 -0
- package/hooks/diagnostic/migrations/README.md +77 -0
- package/hooks/diagnostic/queries/block_details_for_hook.sql +26 -0
- package/hooks/diagnostic/queries/blocks_by_category.sql +10 -0
- package/hooks/diagnostic/queries/blocks_by_tool.sql +9 -0
- package/hooks/diagnostic/queries/blocks_last_7_days.sql +11 -0
- package/hooks/diagnostic/queries/top_blockers_last_24_hours.sql +12 -0
- package/hooks/diagnostic/queries/top_blockers_overall.sql +12 -0
- package/hooks/diagnostic/requirements-hook-logs-dev.txt +2 -0
- package/hooks/diagnostic/requirements-hook-logs.txt +1 -0
- package/hooks/diagnostic/schema.sql +51 -0
- package/hooks/diagnostic/test_hook_log_extractor.py +1531 -0
- package/hooks/diagnostic/test_hook_log_init.py +227 -0
- package/hooks/diagnostic/test_hook_log_stop_wrapper.py +345 -0
- package/hooks/hooks.json +25 -0
- package/hooks/session/session_env_cleanup.py +129 -0
- package/hooks/session/test_session_env_cleanup.py +278 -0
- package/package.json +1 -1
- package/rules/ask-user-question-required.md +44 -0
- package/rules/windows-filesystem-safe.md +93 -0
- package/scripts/config/test_spec_implementer_prompt.py +0 -4
- package/scripts/test_groq_bugteam_spec.py +0 -8
- package/skills/bugteam/SKILL.md +15 -1
- package/skills/bugteam/SKILL_EVALS.md +1 -1
- package/skills/bugteam/reference/teardown-publish-permissions.md +1 -1
- package/skills/bugteam/scripts/README.md +17 -0
- package/skills/bugteam/scripts/bugteam_fix_hookspath.py +238 -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 +157 -0
|
@@ -0,0 +1,129 @@
|
|
|
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
|
+
RMTREE_ONEXC_PYTHON_VERSION,
|
|
40
|
+
SESSION_ENV_DIRECTORY,
|
|
41
|
+
SESSION_ID_PATTERN,
|
|
42
|
+
STALE_AGE_SECONDS,
|
|
43
|
+
WINDOWS_PLATFORM_TAG,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _strip_read_only_and_retry(
|
|
48
|
+
removal_function: Callable[[str], None],
|
|
49
|
+
target_path: str,
|
|
50
|
+
*_unused_exception_info: object,
|
|
51
|
+
) -> None:
|
|
52
|
+
try:
|
|
53
|
+
os.chmod(target_path, stat.S_IWRITE)
|
|
54
|
+
removal_function(target_path)
|
|
55
|
+
except OSError:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _force_rmtree(target_path: str) -> None:
|
|
60
|
+
rmtree_onexc_python_version = RMTREE_ONEXC_PYTHON_VERSION
|
|
61
|
+
try:
|
|
62
|
+
if sys.version_info >= rmtree_onexc_python_version:
|
|
63
|
+
shutil.rmtree(target_path, onexc=_strip_read_only_and_retry)
|
|
64
|
+
else:
|
|
65
|
+
shutil.rmtree(target_path, onerror=_strip_read_only_and_retry)
|
|
66
|
+
except OSError:
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def prune_session_env(
|
|
71
|
+
session_env_directory: str,
|
|
72
|
+
session_id: str,
|
|
73
|
+
stale_age_seconds: float,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Remove the current session's directory and prune stale siblings."""
|
|
76
|
+
if session_id:
|
|
77
|
+
current_session_path = os.path.join(session_env_directory, session_id)
|
|
78
|
+
if os.path.isdir(current_session_path):
|
|
79
|
+
_force_rmtree(current_session_path)
|
|
80
|
+
if not os.path.isdir(session_env_directory):
|
|
81
|
+
return
|
|
82
|
+
stale_cutoff_seconds = time.time() - stale_age_seconds
|
|
83
|
+
try:
|
|
84
|
+
all_entry_names = os.listdir(session_env_directory)
|
|
85
|
+
except OSError:
|
|
86
|
+
return
|
|
87
|
+
for each_entry_name in all_entry_names:
|
|
88
|
+
entry_path = os.path.join(session_env_directory, each_entry_name)
|
|
89
|
+
try:
|
|
90
|
+
entry_mtime_seconds = os.path.getmtime(entry_path)
|
|
91
|
+
except OSError:
|
|
92
|
+
continue
|
|
93
|
+
if entry_mtime_seconds >= stale_cutoff_seconds:
|
|
94
|
+
continue
|
|
95
|
+
_force_rmtree(entry_path)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _read_session_id_from_stdin() -> str:
|
|
99
|
+
session_id_pattern = SESSION_ID_PATTERN
|
|
100
|
+
try:
|
|
101
|
+
payload = json.load(sys.stdin)
|
|
102
|
+
except (json.JSONDecodeError, ValueError):
|
|
103
|
+
return ""
|
|
104
|
+
if not isinstance(payload, dict):
|
|
105
|
+
return ""
|
|
106
|
+
raw_session_id = payload.get("session_id")
|
|
107
|
+
if not isinstance(raw_session_id, str):
|
|
108
|
+
return ""
|
|
109
|
+
if not session_id_pattern.match(raw_session_id):
|
|
110
|
+
return ""
|
|
111
|
+
return raw_session_id
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def main() -> None:
|
|
115
|
+
windows_platform_tag = WINDOWS_PLATFORM_TAG
|
|
116
|
+
if sys.platform != windows_platform_tag:
|
|
117
|
+
return
|
|
118
|
+
session_env_directory = SESSION_ENV_DIRECTORY
|
|
119
|
+
stale_age_seconds = STALE_AGE_SECONDS
|
|
120
|
+
session_id = _read_session_id_from_stdin()
|
|
121
|
+
prune_session_env(
|
|
122
|
+
session_env_directory=session_env_directory,
|
|
123
|
+
session_id=session_id,
|
|
124
|
+
stale_age_seconds=stale_age_seconds,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
if __name__ == "__main__":
|
|
129
|
+
main()
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Tests for session_env_cleanup — SessionStart hook for Bash EEXIST workaround."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import stat
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from unittest.mock import patch
|
|
13
|
+
|
|
14
|
+
_SESSION_DIR = Path(__file__).resolve().parent
|
|
15
|
+
_HOOKS_ROOT = _SESSION_DIR.parent
|
|
16
|
+
for each_sys_path_entry in (str(_SESSION_DIR), str(_HOOKS_ROOT)):
|
|
17
|
+
if each_sys_path_entry not in sys.path:
|
|
18
|
+
sys.path.insert(0, each_sys_path_entry)
|
|
19
|
+
|
|
20
|
+
import session_env_cleanup as cleanup
|
|
21
|
+
|
|
22
|
+
SECONDS_PER_DAY = 24 * 60 * 60
|
|
23
|
+
SEVEN_DAYS_IN_SECONDS = 7 * SECONDS_PER_DAY
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _set_mtime_days_ago(target_path: Path, days_ago: float) -> None:
|
|
27
|
+
target_mtime_seconds = time.time() - (days_ago * SECONDS_PER_DAY)
|
|
28
|
+
os.utime(target_path, (target_mtime_seconds, target_mtime_seconds))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TestRemovesCurrentSessionDirectory:
|
|
32
|
+
def test_removes_directory_matching_session_id(self, tmp_path: Path) -> None:
|
|
33
|
+
current_session_id = "abc-123"
|
|
34
|
+
current_session_directory = tmp_path / current_session_id
|
|
35
|
+
current_session_directory.mkdir()
|
|
36
|
+
cleanup.prune_session_env(
|
|
37
|
+
session_env_directory=str(tmp_path),
|
|
38
|
+
session_id=current_session_id,
|
|
39
|
+
stale_age_seconds=SEVEN_DAYS_IN_SECONDS,
|
|
40
|
+
)
|
|
41
|
+
assert not current_session_directory.exists()
|
|
42
|
+
|
|
43
|
+
def test_removes_current_session_directory_with_contents(
|
|
44
|
+
self, tmp_path: Path
|
|
45
|
+
) -> None:
|
|
46
|
+
current_session_id = "abc-123"
|
|
47
|
+
current_session_directory = tmp_path / current_session_id
|
|
48
|
+
current_session_directory.mkdir()
|
|
49
|
+
(current_session_directory / "leftover.txt").write_text("data")
|
|
50
|
+
cleanup.prune_session_env(
|
|
51
|
+
session_env_directory=str(tmp_path),
|
|
52
|
+
session_id=current_session_id,
|
|
53
|
+
stale_age_seconds=SEVEN_DAYS_IN_SECONDS,
|
|
54
|
+
)
|
|
55
|
+
assert not current_session_directory.exists()
|
|
56
|
+
|
|
57
|
+
def test_no_current_session_removal_when_session_id_empty(
|
|
58
|
+
self, tmp_path: Path
|
|
59
|
+
) -> None:
|
|
60
|
+
sibling_directory = tmp_path / "some-other-session"
|
|
61
|
+
sibling_directory.mkdir()
|
|
62
|
+
_set_mtime_days_ago(sibling_directory, days_ago=0)
|
|
63
|
+
cleanup.prune_session_env(
|
|
64
|
+
session_env_directory=str(tmp_path),
|
|
65
|
+
session_id="",
|
|
66
|
+
stale_age_seconds=SEVEN_DAYS_IN_SECONDS,
|
|
67
|
+
)
|
|
68
|
+
assert sibling_directory.exists()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class TestPrunesStaleEntries:
|
|
72
|
+
def test_removes_entry_older_than_threshold(self, tmp_path: Path) -> None:
|
|
73
|
+
stale_directory = tmp_path / "old-session"
|
|
74
|
+
stale_directory.mkdir()
|
|
75
|
+
_set_mtime_days_ago(stale_directory, days_ago=10)
|
|
76
|
+
cleanup.prune_session_env(
|
|
77
|
+
session_env_directory=str(tmp_path),
|
|
78
|
+
session_id="",
|
|
79
|
+
stale_age_seconds=SEVEN_DAYS_IN_SECONDS,
|
|
80
|
+
)
|
|
81
|
+
assert not stale_directory.exists()
|
|
82
|
+
|
|
83
|
+
def test_keeps_entry_within_threshold(self, tmp_path: Path) -> None:
|
|
84
|
+
fresh_directory = tmp_path / "fresh-session"
|
|
85
|
+
fresh_directory.mkdir()
|
|
86
|
+
_set_mtime_days_ago(fresh_directory, days_ago=2)
|
|
87
|
+
cleanup.prune_session_env(
|
|
88
|
+
session_env_directory=str(tmp_path),
|
|
89
|
+
session_id="",
|
|
90
|
+
stale_age_seconds=SEVEN_DAYS_IN_SECONDS,
|
|
91
|
+
)
|
|
92
|
+
assert fresh_directory.exists()
|
|
93
|
+
|
|
94
|
+
def test_removes_stale_directory_with_contents(self, tmp_path: Path) -> None:
|
|
95
|
+
stale_directory = tmp_path / "stale-with-content"
|
|
96
|
+
stale_directory.mkdir()
|
|
97
|
+
(stale_directory / "leftover.txt").write_text("old data")
|
|
98
|
+
_set_mtime_days_ago(stale_directory, days_ago=14)
|
|
99
|
+
cleanup.prune_session_env(
|
|
100
|
+
session_env_directory=str(tmp_path),
|
|
101
|
+
session_id="",
|
|
102
|
+
stale_age_seconds=SEVEN_DAYS_IN_SECONDS,
|
|
103
|
+
)
|
|
104
|
+
assert not stale_directory.exists()
|
|
105
|
+
|
|
106
|
+
def test_keeps_fresh_when_pruning_among_mixed_ages(self, tmp_path: Path) -> None:
|
|
107
|
+
fresh_directory = tmp_path / "fresh-keep"
|
|
108
|
+
stale_directory = tmp_path / "stale-remove"
|
|
109
|
+
fresh_directory.mkdir()
|
|
110
|
+
stale_directory.mkdir()
|
|
111
|
+
_set_mtime_days_ago(fresh_directory, days_ago=1)
|
|
112
|
+
_set_mtime_days_ago(stale_directory, days_ago=30)
|
|
113
|
+
cleanup.prune_session_env(
|
|
114
|
+
session_env_directory=str(tmp_path),
|
|
115
|
+
session_id="",
|
|
116
|
+
stale_age_seconds=SEVEN_DAYS_IN_SECONDS,
|
|
117
|
+
)
|
|
118
|
+
assert fresh_directory.exists()
|
|
119
|
+
assert not stale_directory.exists()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class TestRemovesReadOnlyDirectories:
|
|
123
|
+
def test_removes_session_directory_with_read_only_contents(
|
|
124
|
+
self, tmp_path: Path
|
|
125
|
+
) -> None:
|
|
126
|
+
current_session_id = "readonly-session"
|
|
127
|
+
current_session_directory = tmp_path / current_session_id
|
|
128
|
+
current_session_directory.mkdir()
|
|
129
|
+
leftover_file = current_session_directory / "leftover.txt"
|
|
130
|
+
leftover_file.write_text("data")
|
|
131
|
+
leftover_file.chmod(stat.S_IREAD)
|
|
132
|
+
cleanup.prune_session_env(
|
|
133
|
+
session_env_directory=str(tmp_path),
|
|
134
|
+
session_id=current_session_id,
|
|
135
|
+
stale_age_seconds=SEVEN_DAYS_IN_SECONDS,
|
|
136
|
+
)
|
|
137
|
+
assert not current_session_directory.exists()
|
|
138
|
+
|
|
139
|
+
def test_prunes_stale_directory_with_read_only_contents(
|
|
140
|
+
self, tmp_path: Path
|
|
141
|
+
) -> None:
|
|
142
|
+
stale_directory = tmp_path / "stale-readonly"
|
|
143
|
+
stale_directory.mkdir()
|
|
144
|
+
stale_file = stale_directory / "old.txt"
|
|
145
|
+
stale_file.write_text("old data")
|
|
146
|
+
stale_file.chmod(stat.S_IREAD)
|
|
147
|
+
_set_mtime_days_ago(stale_directory, days_ago=14)
|
|
148
|
+
cleanup.prune_session_env(
|
|
149
|
+
session_env_directory=str(tmp_path),
|
|
150
|
+
session_id="",
|
|
151
|
+
stale_age_seconds=SEVEN_DAYS_IN_SECONDS,
|
|
152
|
+
)
|
|
153
|
+
assert not stale_directory.exists()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class TestParentDirectoryMissing:
|
|
157
|
+
def test_returns_silently_when_parent_missing(self, tmp_path: Path) -> None:
|
|
158
|
+
absent_path = tmp_path / "does-not-exist"
|
|
159
|
+
cleanup.prune_session_env(
|
|
160
|
+
session_env_directory=str(absent_path),
|
|
161
|
+
session_id="abc-123",
|
|
162
|
+
stale_age_seconds=SEVEN_DAYS_IN_SECONDS,
|
|
163
|
+
)
|
|
164
|
+
assert not absent_path.exists()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class TestMainReadsSessionIdFromStdin:
|
|
168
|
+
def test_main_invokes_prune_with_stdin_session_id(self, tmp_path: Path) -> None:
|
|
169
|
+
captured_call = {}
|
|
170
|
+
|
|
171
|
+
def fake_prune(
|
|
172
|
+
session_env_directory: str,
|
|
173
|
+
session_id: str,
|
|
174
|
+
stale_age_seconds: float,
|
|
175
|
+
) -> None:
|
|
176
|
+
captured_call["session_id"] = session_id
|
|
177
|
+
|
|
178
|
+
stdin_payload = io.StringIO(json.dumps({"session_id": "session-from-stdin"}))
|
|
179
|
+
with (
|
|
180
|
+
patch.object(cleanup, "prune_session_env", side_effect=fake_prune),
|
|
181
|
+
patch("sys.stdin", stdin_payload),
|
|
182
|
+
):
|
|
183
|
+
cleanup.main()
|
|
184
|
+
assert captured_call["session_id"] == "session-from-stdin"
|
|
185
|
+
|
|
186
|
+
def test_main_passes_empty_session_id_when_stdin_invalid(self) -> None:
|
|
187
|
+
captured_call = {}
|
|
188
|
+
|
|
189
|
+
def fake_prune(
|
|
190
|
+
session_env_directory: str,
|
|
191
|
+
session_id: str,
|
|
192
|
+
stale_age_seconds: float,
|
|
193
|
+
) -> None:
|
|
194
|
+
captured_call["session_id"] = session_id
|
|
195
|
+
|
|
196
|
+
stdin_payload = io.StringIO("not json at all")
|
|
197
|
+
with (
|
|
198
|
+
patch.object(cleanup, "prune_session_env", side_effect=fake_prune),
|
|
199
|
+
patch("sys.stdin", stdin_payload),
|
|
200
|
+
):
|
|
201
|
+
cleanup.main()
|
|
202
|
+
assert captured_call["session_id"] == ""
|
|
203
|
+
|
|
204
|
+
def test_main_rejects_session_id_with_path_separator(self) -> None:
|
|
205
|
+
captured_call = {}
|
|
206
|
+
|
|
207
|
+
def fake_prune(
|
|
208
|
+
session_env_directory: str,
|
|
209
|
+
session_id: str,
|
|
210
|
+
stale_age_seconds: float,
|
|
211
|
+
) -> None:
|
|
212
|
+
captured_call["session_id"] = session_id
|
|
213
|
+
|
|
214
|
+
stdin_payload = io.StringIO(json.dumps({"session_id": "../../../etc/passwd"}))
|
|
215
|
+
with (
|
|
216
|
+
patch.object(cleanup, "prune_session_env", side_effect=fake_prune),
|
|
217
|
+
patch("sys.stdin", stdin_payload),
|
|
218
|
+
patch.object(cleanup.sys, "platform", "win32"),
|
|
219
|
+
):
|
|
220
|
+
cleanup.main()
|
|
221
|
+
assert captured_call["session_id"] == ""
|
|
222
|
+
|
|
223
|
+
def test_main_rejects_absolute_windows_path_session_id(self) -> None:
|
|
224
|
+
captured_call = {}
|
|
225
|
+
|
|
226
|
+
def fake_prune(
|
|
227
|
+
session_env_directory: str,
|
|
228
|
+
session_id: str,
|
|
229
|
+
stale_age_seconds: float,
|
|
230
|
+
) -> None:
|
|
231
|
+
captured_call["session_id"] = session_id
|
|
232
|
+
|
|
233
|
+
stdin_payload = io.StringIO(json.dumps({"session_id": "C:\\Windows\\Temp"}))
|
|
234
|
+
with (
|
|
235
|
+
patch.object(cleanup, "prune_session_env", side_effect=fake_prune),
|
|
236
|
+
patch("sys.stdin", stdin_payload),
|
|
237
|
+
patch.object(cleanup.sys, "platform", "win32"),
|
|
238
|
+
):
|
|
239
|
+
cleanup.main()
|
|
240
|
+
assert captured_call["session_id"] == ""
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class TestMainPlatformGuard:
|
|
244
|
+
def test_main_no_ops_on_non_windows(self) -> None:
|
|
245
|
+
captured_call = {"called": False}
|
|
246
|
+
|
|
247
|
+
def fake_prune(
|
|
248
|
+
session_env_directory: str,
|
|
249
|
+
session_id: str,
|
|
250
|
+
stale_age_seconds: float,
|
|
251
|
+
) -> None:
|
|
252
|
+
captured_call["called"] = True
|
|
253
|
+
|
|
254
|
+
stdin_payload = io.StringIO(json.dumps({"session_id": "abc-123"}))
|
|
255
|
+
with (
|
|
256
|
+
patch.object(cleanup, "prune_session_env", side_effect=fake_prune),
|
|
257
|
+
patch("sys.stdin", stdin_payload),
|
|
258
|
+
patch.object(cleanup.sys, "platform", "linux"),
|
|
259
|
+
):
|
|
260
|
+
cleanup.main()
|
|
261
|
+
assert captured_call["called"] is False
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class TestPruneHandlesListdirFailure:
|
|
265
|
+
def test_prune_returns_silently_when_listdir_raises(self, tmp_path: Path) -> None:
|
|
266
|
+
existing_session_directory = tmp_path / "still-there"
|
|
267
|
+
existing_session_directory.mkdir()
|
|
268
|
+
|
|
269
|
+
def raise_oserror(path: str) -> list[str]:
|
|
270
|
+
raise OSError("simulated listdir failure")
|
|
271
|
+
|
|
272
|
+
with patch("os.listdir", side_effect=raise_oserror):
|
|
273
|
+
cleanup.prune_session_env(
|
|
274
|
+
session_env_directory=str(tmp_path),
|
|
275
|
+
session_id="",
|
|
276
|
+
stale_age_seconds=SEVEN_DAYS_IN_SECONDS,
|
|
277
|
+
)
|
|
278
|
+
assert existing_session_directory.exists()
|
package/package.json
CHANGED
|
@@ -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.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Windows Filesystem Safety
|
|
2
|
+
|
|
3
|
+
**When this applies:** Any code that recursively deletes directory trees, or that creates directories on Windows where the path may already exist with a `ReadOnly` attribute set.
|
|
4
|
+
|
|
5
|
+
## Rule 1 — Never use `shutil.rmtree(..., ignore_errors=True)`
|
|
6
|
+
|
|
7
|
+
`shutil.rmtree` on Windows raises `PermissionError` when it encounters a file carrying the `ReadOnly` attribute (`FILE_ATTRIBUTE_READONLY`). Linux never hits this case because `unlink` on Linux only requires write on the parent directory, not on the file itself. With `ignore_errors=True` the failure is swallowed and the tree stays on disk — cleanup *looks* successful but pruned nothing.
|
|
8
|
+
|
|
9
|
+
Tests run inside `pytest`'s `tmp_path` do not exercise the regression path because tmp directories do not carry the attribute. The only place this surfaces is real Windows checkouts (notably git working trees, where `.git/objects/pack/` files are read-only by design).
|
|
10
|
+
|
|
11
|
+
### Tell-tale sign
|
|
12
|
+
|
|
13
|
+
`rmtree`-based cleanup that "succeeds" against a real Windows directory but the count of removed entries is zero.
|
|
14
|
+
|
|
15
|
+
### Safe pattern (inline `force_rmtree`)
|
|
16
|
+
|
|
17
|
+
Replace `ignore_errors=True` with an `onexc`/`onerror` handler that strips the attribute and retries the same syscall:
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
import os
|
|
21
|
+
import shutil
|
|
22
|
+
import stat
|
|
23
|
+
import sys
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _strip_read_only_and_retry(removal_function, target_path, *_exc_info):
|
|
27
|
+
try:
|
|
28
|
+
os.chmod(target_path, stat.S_IWRITE)
|
|
29
|
+
removal_function(target_path)
|
|
30
|
+
except OSError:
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def force_rmtree(target_path: str) -> None:
|
|
35
|
+
handler_kw = (
|
|
36
|
+
{"onexc": _strip_read_only_and_retry}
|
|
37
|
+
if sys.version_info >= (3, 12)
|
|
38
|
+
else {"onerror": _strip_read_only_and_retry}
|
|
39
|
+
)
|
|
40
|
+
try:
|
|
41
|
+
shutil.rmtree(target_path, **handler_kw)
|
|
42
|
+
except OSError:
|
|
43
|
+
pass
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Two things to know about the handler:
|
|
47
|
+
|
|
48
|
+
- `*_exc_info` collapses the signature difference. `onerror` passes `(type, value, traceback)`; `onexc` (Python 3.12+) passes a single exception. The variadic absorbs both.
|
|
49
|
+
- `removal_function` is whichever syscall `rmtree` was attempting when it failed — `os.unlink` for files, `os.rmdir` for directories. Re-calling it after `chmod` finishes the work that originally failed.
|
|
50
|
+
|
|
51
|
+
### One-liner safe pattern (when shell context demands it)
|
|
52
|
+
|
|
53
|
+
If a skill or runbook genuinely needs a one-line shell invocation, the equivalent without `ignore_errors=True` is:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
python -c "import os, shutil, stat, sys; \
|
|
57
|
+
def _h(f, p, *_): os.chmod(p, stat.S_IWRITE); f(p); \
|
|
58
|
+
shutil.rmtree(r'<path>', **({'onexc': _h} if sys.version_info >= (3, 12) else {'onerror': _h}))"
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Prefer the multi-line `force_rmtree` helper — the one-liner is hard to read and easy to mis-quote.
|
|
62
|
+
|
|
63
|
+
## Rule 2 — `mkdirSync` without `{ recursive: true }` on possibly-existing paths
|
|
64
|
+
|
|
65
|
+
Windows directories can also carry the `ReadOnly` attribute (e.g. anything Claude Code creates under `~/.claude/teams/<name>/`, `~/.claude/session-env/<id>/`). The attribute does not break `shutil.rmtree` directly — it breaks Node's `fs.mkdirSync` when called *without* `{ recursive: true }` on a path that already exists.
|
|
66
|
+
|
|
67
|
+
### Safe pattern
|
|
68
|
+
|
|
69
|
+
```javascript
|
|
70
|
+
import { mkdirSync } from 'node:fs';
|
|
71
|
+
|
|
72
|
+
mkdirSync(targetPath, { recursive: true });
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`recursive: true` makes `mkdirSync` idempotent — it succeeds whether the directory exists or not, and skips the attribute check on the existing path.
|
|
76
|
+
|
|
77
|
+
### When you cannot use `{ recursive: true }`
|
|
78
|
+
|
|
79
|
+
If the call must be non-recursive for reasons specific to that code path (the existing `bin/git_hooks_installer.mjs` uses `recursive: false` deliberately to assert non-existence), strip the attribute first:
|
|
80
|
+
|
|
81
|
+
```powershell
|
|
82
|
+
(Get-Item $path -Force).Attributes = "Directory"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
os.chmod(path, stat.S_IWRITE)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
…and only then call the non-recursive `mkdir`.
|
|
90
|
+
|
|
91
|
+
## Enforcement
|
|
92
|
+
|
|
93
|
+
A `PreToolUse` hook (`windows_rmtree_blocker.py`) blocks any `Write`, `Edit`, or `Bash` invocation whose payload contains `shutil.rmtree(..., ignore_errors=True)` and returns this rule's safe pattern as the corrective message.
|
|
@@ -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
|
package/skills/bugteam/SKILL.md
CHANGED
|
@@ -65,6 +65,14 @@ python "${CLAUDE_SKILL_DIR}/scripts/bugteam_preflight.py"
|
|
|
65
65
|
|
|
66
66
|
Non-zero → fix before grant. `BUGTEAM_PREFLIGHT_SKIP=1` emergency only. `--pre-commit` if `.pre-commit-config.yaml` exists.
|
|
67
67
|
|
|
68
|
+
**Auto-remediation for `core.hooksPath`:** when preflight fails with stderr containing `core.hooksPath` (the message starts with `bugteam_preflight: core.hooksPath is`, or `Git-side CODE_RULES enforcement is not active`), Claude must auto-invoke the fix script — do not fall through to `AskUserQuestion`, do not punt to the user, do not ask for confirmation:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
python "${CLAUDE_SKILL_DIR}/scripts/bugteam_fix_hookspath.py"
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The fix script removes any non-canonical local-scope override on the active repository, sets the global `core.hooksPath` to `~/.claude/hooks/git-hooks` if missing or wrong, and re-runs `bugteam_preflight.py`. Its exit code becomes the preflight outcome. Exit 0 → continue to Step 0. Non-zero only when the canonical hooks directory is missing (run `npx claude-dev-env .` first) or `git config --global` writes are blocked. Other preflight failures (pytest, pre-commit) still require manual fixes — the auto-remediation only applies to the `core.hooksPath` failure mode.
|
|
75
|
+
|
|
68
76
|
## The Process
|
|
69
77
|
|
|
70
78
|
### Progress checklist
|
|
@@ -283,7 +291,13 @@ Pass finding comment URLs/ids from `loop_comment_index` in XML. Replies: `Fixed
|
|
|
283
291
|
|
|
284
292
|
2. `TeamDelete()`
|
|
285
293
|
|
|
286
|
-
3. `
|
|
294
|
+
3. Windows-safe teardown — `ignore_errors=True` silently swallows ReadOnly-attribute failures on Windows (see `~/.claude/rules/windows-filesystem-safe.md`). Use the inline `force_rmtree` helper:
|
|
295
|
+
|
|
296
|
+
```bash
|
|
297
|
+
python -c "import os, shutil, stat, sys; \
|
|
298
|
+
h = lambda f, p, *_: (os.chmod(p, stat.S_IWRITE), f(p)); \
|
|
299
|
+
shutil.rmtree(r'<team_temp_dir>', **({'onexc': h} if sys.version_info >= (3, 12) else {'onerror': h}))"
|
|
300
|
+
```
|
|
287
301
|
|
|
288
302
|
### Step 4.5: PR description
|
|
289
303
|
|
|
@@ -124,7 +124,7 @@ The harness does not yet exist; this document defines its contract.
|
|
|
124
124
|
| 17 | `Read(".bugteam-loop-2.outcomes.xml")` — zero findings | `SKILL.md` § AUDIT action |
|
|
125
125
|
| 18 | `SendMessage(to="bugfind", message={type: "shutdown_request", reason: "audit loop 2 complete; zero findings"})` | `SKILL.md` § AUDIT action (**Shutdown** fallback) |
|
|
126
126
|
| 19 | `TeamDelete()` | `SKILL.md` § Step 4 |
|
|
127
|
-
| 20 | `Bash("python -c \"import shutil; shutil.rmtree(r'<team_temp_dir>',
|
|
127
|
+
| 20 | `Bash("python -c \"import os, shutil, stat, sys; h = lambda f, p, *_: (os.chmod(p, stat.S_IWRITE), f(p)); shutil.rmtree(r'<team_temp_dir>', **({'onexc': h} if sys.version_info >= (3, 12) else {'onerror': h}))\"")` | `SKILL.md` § Step 4 (Windows-safe teardown) |
|
|
128
128
|
| 21 | `Bash("gh pr diff 42 -R ... > .bugteam-final.diff")` | `SKILL.md` § Step 4.5 step 1 |
|
|
129
129
|
| 22 | `Bash("gh pr view 42 -R ... --json body --jq .body > .bugteam-original-body.md")` | `SKILL.md` § Step 4.5 step 2 |
|
|
130
130
|
| 23 | `Agent(subagent_type="pr-description-writer", description=..., prompt=<brief>)` | `SKILL.md` § Step 4.5 |
|
|
@@ -24,7 +24,7 @@ When the cycle exits (any reason), run these steps in order from **this** sessio
|
|
|
24
24
|
|
|
25
25
|
2. **Clean up the team** with `TeamDelete()` (no arguments — reads `<team_name>` from session context). Maps to “clean up the team” in the docs; quote: [`../sources.md`](../sources.md).
|
|
26
26
|
|
|
27
|
-
3. **Delete the per-team temp directory** using the Python one-liner in `SKILL.md` with the same literal `<team_temp_dir>` from Step 2. `
|
|
27
|
+
3. **Delete the per-team temp directory** using the Python one-liner in `SKILL.md` with the same literal `<team_temp_dir>` from Step 2. The one-liner uses an `onexc`/`onerror` handler that strips the Windows ReadOnly attribute and retries the failing syscall — `ignore_errors=True` is unsafe on Windows because it silently swallows ReadOnly-attribute failures (see `~/.claude/rules/windows-filesystem-safe.md`).
|
|
28
28
|
|
|
29
29
|
## Step 4.5 — Finalize the PR description (mandatory)
|
|
30
30
|
|
|
@@ -5,6 +5,7 @@ Scripts in this directory are **executed** by the lead or teammates. They are no
|
|
|
5
5
|
| Script | Purpose |
|
|
6
6
|
|--------|---------|
|
|
7
7
|
| `bugteam_preflight.py` | Run pytest (when configured) and optional `pre-commit` before `/bugteam`. |
|
|
8
|
+
| `bugteam_fix_hookspath.py` | Auto-remediate a stale local `core.hooksPath` override, set canonical global value, re-run `bugteam_preflight.py`. Invoked by Claude when preflight reports a `core.hooksPath` failure. |
|
|
8
9
|
| `bugteam_code_rules_gate.py` | Run `validate_content` from `code-rules-enforcer.py` on PR-scoped files (`git diff` vs merge-base). Exit `1` if any mandatory rule fails. Invoked **before each audit**; the fixer clears it before the auditor runs. |
|
|
9
10
|
| `grant_project_claude_permissions.py` | Idempotent grant of Edit/Write/Read on `cwd/.claude/**` into `~/.claude/settings.json`. |
|
|
10
11
|
| `revoke_project_claude_permissions.py` | Removes the matching grant entries from `~/.claude/settings.json`. |
|
|
@@ -24,6 +25,22 @@ python "${CLAUDE_SKILL_DIR}/scripts/bugteam_preflight.py"
|
|
|
24
25
|
- Pytest exit code `5` (no tests collected) is treated as success.
|
|
25
26
|
- Add `--pre-commit` to run `pre-commit run --all-files` when `.pre-commit-config.yaml` exists.
|
|
26
27
|
|
|
28
|
+
## `bugteam_fix_hookspath.py`
|
|
29
|
+
|
|
30
|
+
From the repository root:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
python "${CLAUDE_SKILL_DIR}/scripts/bugteam_fix_hookspath.py"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
- Removes any local-scope `core.hooksPath` value that does not end in `hooks/git-hooks`.
|
|
37
|
+
- Sets `git config --global core.hooksPath ~/.claude/hooks/git-hooks` when the global value is unset or non-canonical.
|
|
38
|
+
- Refuses to run (exit non-zero) when `~/.claude/hooks/git-hooks` does not exist on disk — install via `npx claude-dev-env .` first.
|
|
39
|
+
- Idempotent: a second invocation is a clean no-op.
|
|
40
|
+
- Re-runs `bugteam_preflight.py --no-pytest` and propagates its exit code.
|
|
41
|
+
|
|
42
|
+
The bugteam SKILL invokes this automatically when preflight stderr indicates a `core.hooksPath` failure, so Claude does not surface the error to the user.
|
|
43
|
+
|
|
27
44
|
## `bugteam_code_rules_gate.py`
|
|
28
45
|
|
|
29
46
|
From the repository root (same merge-base rules as the PR head vs base — default `--base origin/main`):
|