claude-dev-env 1.31.0 → 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/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 +13 -0
- package/hooks/config/session_env_cleanup_constants.py +18 -0
- package/hooks/config/test_hook_log_extractor_constants.py +27 -0
- package/hooks/config/test_session_env_cleanup_constants.py +55 -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 +129 -0
- package/hooks/session/test_session_env_cleanup.py +278 -0
- package/package.json +1 -1
- package/rules/windows-filesystem-safe.md +93 -0
- 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,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,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.
|
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`):
|