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
|
@@ -0,0 +1,280 @@
|
|
|
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
|
+
patch.object(cleanup.sys, "platform", "win32"),
|
|
183
|
+
):
|
|
184
|
+
cleanup.main()
|
|
185
|
+
assert captured_call["session_id"] == "session-from-stdin"
|
|
186
|
+
|
|
187
|
+
def test_main_passes_empty_session_id_when_stdin_invalid(self) -> None:
|
|
188
|
+
captured_call = {}
|
|
189
|
+
|
|
190
|
+
def fake_prune(
|
|
191
|
+
session_env_directory: str,
|
|
192
|
+
session_id: str,
|
|
193
|
+
stale_age_seconds: float,
|
|
194
|
+
) -> None:
|
|
195
|
+
captured_call["session_id"] = session_id
|
|
196
|
+
|
|
197
|
+
stdin_payload = io.StringIO("not json at all")
|
|
198
|
+
with (
|
|
199
|
+
patch.object(cleanup, "prune_session_env", side_effect=fake_prune),
|
|
200
|
+
patch("sys.stdin", stdin_payload),
|
|
201
|
+
patch.object(cleanup.sys, "platform", "win32"),
|
|
202
|
+
):
|
|
203
|
+
cleanup.main()
|
|
204
|
+
assert captured_call["session_id"] == ""
|
|
205
|
+
|
|
206
|
+
def test_main_rejects_session_id_with_path_separator(self) -> None:
|
|
207
|
+
captured_call = {}
|
|
208
|
+
|
|
209
|
+
def fake_prune(
|
|
210
|
+
session_env_directory: str,
|
|
211
|
+
session_id: str,
|
|
212
|
+
stale_age_seconds: float,
|
|
213
|
+
) -> None:
|
|
214
|
+
captured_call["session_id"] = session_id
|
|
215
|
+
|
|
216
|
+
stdin_payload = io.StringIO(json.dumps({"session_id": "../../../etc/passwd"}))
|
|
217
|
+
with (
|
|
218
|
+
patch.object(cleanup, "prune_session_env", side_effect=fake_prune),
|
|
219
|
+
patch("sys.stdin", stdin_payload),
|
|
220
|
+
patch.object(cleanup.sys, "platform", "win32"),
|
|
221
|
+
):
|
|
222
|
+
cleanup.main()
|
|
223
|
+
assert captured_call["session_id"] == ""
|
|
224
|
+
|
|
225
|
+
def test_main_rejects_absolute_windows_path_session_id(self) -> None:
|
|
226
|
+
captured_call = {}
|
|
227
|
+
|
|
228
|
+
def fake_prune(
|
|
229
|
+
session_env_directory: str,
|
|
230
|
+
session_id: str,
|
|
231
|
+
stale_age_seconds: float,
|
|
232
|
+
) -> None:
|
|
233
|
+
captured_call["session_id"] = session_id
|
|
234
|
+
|
|
235
|
+
stdin_payload = io.StringIO(json.dumps({"session_id": "C:\\Windows\\Temp"}))
|
|
236
|
+
with (
|
|
237
|
+
patch.object(cleanup, "prune_session_env", side_effect=fake_prune),
|
|
238
|
+
patch("sys.stdin", stdin_payload),
|
|
239
|
+
patch.object(cleanup.sys, "platform", "win32"),
|
|
240
|
+
):
|
|
241
|
+
cleanup.main()
|
|
242
|
+
assert captured_call["session_id"] == ""
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class TestMainPlatformGuard:
|
|
246
|
+
def test_main_no_ops_on_non_windows(self) -> None:
|
|
247
|
+
captured_call = {"called": False}
|
|
248
|
+
|
|
249
|
+
def fake_prune(
|
|
250
|
+
session_env_directory: str,
|
|
251
|
+
session_id: str,
|
|
252
|
+
stale_age_seconds: float,
|
|
253
|
+
) -> None:
|
|
254
|
+
captured_call["called"] = True
|
|
255
|
+
|
|
256
|
+
stdin_payload = io.StringIO(json.dumps({"session_id": "abc-123"}))
|
|
257
|
+
with (
|
|
258
|
+
patch.object(cleanup, "prune_session_env", side_effect=fake_prune),
|
|
259
|
+
patch("sys.stdin", stdin_payload),
|
|
260
|
+
patch.object(cleanup.sys, "platform", "linux"),
|
|
261
|
+
):
|
|
262
|
+
cleanup.main()
|
|
263
|
+
assert captured_call["called"] is False
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class TestPruneHandlesListdirFailure:
|
|
267
|
+
def test_prune_returns_silently_when_listdir_raises(self, tmp_path: Path) -> None:
|
|
268
|
+
existing_session_directory = tmp_path / "still-there"
|
|
269
|
+
existing_session_directory.mkdir()
|
|
270
|
+
|
|
271
|
+
def raise_oserror(path: str) -> list[str]:
|
|
272
|
+
raise OSError("simulated listdir failure")
|
|
273
|
+
|
|
274
|
+
with patch("os.listdir", side_effect=raise_oserror):
|
|
275
|
+
cleanup.prune_session_env(
|
|
276
|
+
session_env_directory=str(tmp_path),
|
|
277
|
+
session_id="",
|
|
278
|
+
stale_age_seconds=SEVEN_DAYS_IN_SECONDS,
|
|
279
|
+
)
|
|
280
|
+
assert existing_session_directory.exists()
|
package/package.json
CHANGED
|
@@ -0,0 +1,91 @@
|
|
|
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; h = lambda f, p, *_: (os.chmod(p, stat.S_IWRITE), f(p)); shutil.rmtree(r'<path>', **({'onexc': h} if sys.version_info >= (3, 12) else {'onerror': h}))"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Prefer the multi-line `force_rmtree` helper — the one-liner is hard to read and easy to mis-quote.
|
|
60
|
+
|
|
61
|
+
## Rule 2 — `mkdirSync` without `{ recursive: true }` on possibly-existing paths
|
|
62
|
+
|
|
63
|
+
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.
|
|
64
|
+
|
|
65
|
+
### Safe pattern
|
|
66
|
+
|
|
67
|
+
```javascript
|
|
68
|
+
import { mkdirSync } from 'node:fs';
|
|
69
|
+
|
|
70
|
+
mkdirSync(targetPath, { recursive: true });
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`recursive: true` makes `mkdirSync` idempotent — it succeeds whether the directory exists or not, and skips the attribute check on the existing path.
|
|
74
|
+
|
|
75
|
+
### When you cannot use `{ recursive: true }`
|
|
76
|
+
|
|
77
|
+
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:
|
|
78
|
+
|
|
79
|
+
```powershell
|
|
80
|
+
(Get-Item $path -Force).Attributes = "Directory"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
os.chmod(path, stat.S_IWRITE)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
…and only then call the non-recursive `mkdir`.
|
|
88
|
+
|
|
89
|
+
## Enforcement
|
|
90
|
+
|
|
91
|
+
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.
|
|
@@ -35,8 +35,47 @@ cd into `<worktree_path>` before any git, gh, or file operation.
|
|
|
35
35
|
H. Security boundaries (injection, path traversal, auth bypass, secret leakage)
|
|
36
36
|
I. Concurrency hazards (race conditions, missing awaits, shared mutable state)
|
|
37
37
|
J. Magic values and configuration drift
|
|
38
|
+
Copilot-derived addendum (K–N) — verify each one explicitly. Return at
|
|
39
|
+
least one finding per category OR a verified-clean entry that names the
|
|
40
|
+
exact files and lines you walked.
|
|
41
|
+
K. Collection naming. Every tuple, list, set, dict, mapping, or sequence
|
|
42
|
+
parameter must follow the CODE_RULES.md §5 "Extended naming rules"
|
|
43
|
+
prefix discipline:
|
|
44
|
+
- module-level constant whose value is a tuple/list/set/dict/frozenset
|
|
45
|
+
literal MUST start with `ALL_` (e.g. `ALL_THEMES_INSERT_REQUIRED_COLUMN_NAMES`)
|
|
46
|
+
- function/method parameter whose annotation is `list[...]`, `tuple[...]`,
|
|
47
|
+
`set[...]`, `dict[...]`, `Iterable[...]`, `Sequence[...]`, `Mapping[...]`,
|
|
48
|
+
or `frozenset[...]` MUST start with `all_` (e.g. `all_column_value_pairs`)
|
|
49
|
+
- exempt: dict/map names that follow the `X_by_Y` pattern (e.g.
|
|
50
|
+
`price_by_product`)
|
|
51
|
+
L. Library print / direct stdout. In any module that is not a CLI entry
|
|
52
|
+
point (`__main__`, `*_cli.py`, `scripts/*.py`), every `print(...)`,
|
|
53
|
+
`sys.stdout.write(...)`, `sys.stderr.write(...)` call is a finding.
|
|
54
|
+
The fix is to route through a `logger` call OR to make the output
|
|
55
|
+
stream an explicit parameter so callers can redirect it.
|
|
56
|
+
M. String-literal magic values. Treat domain-identifier string literals
|
|
57
|
+
(database column names, table names, HTTP header names, status enums,
|
|
58
|
+
environment-variable names) inside a function body as magic values
|
|
59
|
+
even when the existing number-only check would let them pass. The
|
|
60
|
+
fix is to extract them into `config/` and reference the imported
|
|
61
|
+
name. Do not flag plain log messages, error messages, or one-off
|
|
62
|
+
human-readable strings.
|
|
63
|
+
N. Wrapper plumb-through. When a public function delegates to an
|
|
64
|
+
inner function defined in the same package, every optional kwarg
|
|
65
|
+
accepted by the inner function MUST appear in the public wrapper
|
|
66
|
+
unless the wrapper docstring explicitly states the kwarg is fixed
|
|
67
|
+
to a sentinel default. Silently dropping `loud_banner_stream`,
|
|
68
|
+
`timeout`, `dry_run`, or any similar optional kwarg is a finding.
|
|
38
69
|
</bug_categories>
|
|
39
70
|
|
|
71
|
+
<copilot_derived_addendum_source>
|
|
72
|
+
The K–N categories were added after Copilot raised real findings on
|
|
73
|
+
PR #70 (writer.py / summary.py) and PR #73 (constants.py / writer.py /
|
|
74
|
+
tracker.py) that converged "0 P0 / 0 P1 / 0 P2" under the original
|
|
75
|
+
A–J rubric. See ~/.claude/skills/bugteam/reference/copilot-gap-analysis.md
|
|
76
|
+
for the inventory and the validators that now back categories K and L.
|
|
77
|
+
</copilot_derived_addendum_source>
|
|
78
|
+
|
|
40
79
|
<constraints>
|
|
41
80
|
- Read-only on source code: the audit does not modify any source file.
|
|
42
81
|
- Cite file:line for every finding.
|
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
|
|
@@ -73,7 +81,9 @@ Non-zero → fix before grant. `BUGTEAM_PREFLIGHT_SKIP=1` emergency only. `--pre
|
|
|
73
81
|
[ ] Step 0: project permissions granted
|
|
74
82
|
[ ] Step 1: PR scope resolved
|
|
75
83
|
[ ] Step 2: agent team created + loop state set
|
|
84
|
+
[ ] Step 2.6: INITIAL standards review against cumulative PR diff
|
|
76
85
|
[ ] Step 3: cycle complete (converged | cap reached | stuck | error)
|
|
86
|
+
[ ] Step 3.5: FINAL standards review against cumulative PR diff
|
|
77
87
|
[ ] Step 4: team torn down + working tree clean
|
|
78
88
|
[ ] Step 4.5: PR description rewritten (or skip warning logged)
|
|
79
89
|
[ ] Step 5: project permissions revoked
|
|
@@ -195,6 +205,23 @@ jq -n \
|
|
|
195
205
|
|
|
196
206
|
**Endpoints:** `POST .../pulls/{pull}/reviews`; `POST .../pulls/{pull}/comments/{id}/replies`; fallback `POST .../issues/{issue}/comments` (`issue` = PR number).
|
|
197
207
|
|
|
208
|
+
### Step 2.6: INITIAL standards review (once, before Loop 1 audit)
|
|
209
|
+
|
|
210
|
+
Run BEFORE the first pre-audit gate fires. Spawn a fresh `code-quality-agent`
|
|
211
|
+
teammate inside the same team and drive it through the K–N addendum (see
|
|
212
|
+
PROMPTS.md `<copilot_derived_addendum_source>`). The teammate audits the
|
|
213
|
+
cumulative PR diff (`gh pr diff <N>`) instead of a single loop's incremental
|
|
214
|
+
patch; clean-room context is preserved by the same agent-team isolation as
|
|
215
|
+
the per-loop bugfind teammate. Findings are posted using the same Step 2.5
|
|
216
|
+
review-shape with body `## /bugteam INITIAL standards review against PR #<N>
|
|
217
|
+
cumulative diff: <P0>P0 / <P1>P1 / <P2>P2`. Findings advance the audit/fix
|
|
218
|
+
cycle exactly as if they had been raised in Loop 1: the lead increments
|
|
219
|
+
`loop_count` to 1, sets `last_action = "audited"` with the merged
|
|
220
|
+
`last_findings`, and Step 3 begins on the FIX branch. When the INITIAL
|
|
221
|
+
review returns zero findings, `loop_count` stays at 0 and Step 3 begins on
|
|
222
|
+
the AUDIT branch as before. Failure on this phase logs the error and
|
|
223
|
+
proceeds to Step 3 unchanged so the legacy A–J cycle still runs.
|
|
224
|
+
|
|
198
225
|
### Step 3: The cycle
|
|
199
226
|
|
|
200
227
|
Run the AUDIT-FIX cycle for each PR in all_prs, reusing the same team across PRs. The 10-loop cap applies per PR. Exit reasons (converged, cap reached, stuck, error) are tracked per PR; the final report lists one outcome line per PR.
|
|
@@ -277,13 +304,32 @@ Pass finding comment URLs/ids from `loop_comment_index` in XML. Replies: `Fixed
|
|
|
277
304
|
|
|
278
305
|
[`PROMPTS.md`](PROMPTS.md): fix XML + schema. Verify: `git rev-parse HEAD` advanced; `git fetch origin <branch> && git rev-parse origin/<branch>` matches `HEAD`. Unchanged HEAD → `stuck — bugfix teammate could not address findings`.
|
|
279
306
|
|
|
307
|
+
### Step 3.5: FINAL standards review (once, after convergence)
|
|
308
|
+
|
|
309
|
+
Run AFTER Step 3 exits with `converged`, `cap reached`, or `stuck`, and
|
|
310
|
+
BEFORE Step 4 teardown. Spawn one more fresh `code-quality-agent` teammate;
|
|
311
|
+
audit the cumulative PR diff against the K–N addendum a second time. Post
|
|
312
|
+
the review with body `## /bugteam FINAL standards review against PR #<N>
|
|
313
|
+
cumulative diff: <P0>P0 / <P1>P1 / <P2>P2`. When findings remain, the
|
|
314
|
+
exit reason is upgraded to `error: final standards review found <P0>+<P1>+<P2>
|
|
315
|
+
unresolved finding(s)` and the loop log gains an extra `final-review` line.
|
|
316
|
+
A clean FINAL review preserves the existing exit reason. Failure on this
|
|
317
|
+
phase logs the error and continues to Step 4 unchanged so teardown,
|
|
318
|
+
permission revoke, and the final report still run.
|
|
319
|
+
|
|
280
320
|
### Step 4: Teardown
|
|
281
321
|
|
|
282
322
|
1. For each live teammate: `SendMessage(to="<name>", message={"type": "shutdown_request", "reason": "bugteam cycle ending"})`. `approve: false` on cleanup → log and continue.
|
|
283
323
|
|
|
284
324
|
2. `TeamDelete()`
|
|
285
325
|
|
|
286
|
-
3. `
|
|
326
|
+
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:
|
|
327
|
+
|
|
328
|
+
```bash
|
|
329
|
+
python -c "import os, shutil, stat, sys; \
|
|
330
|
+
h = lambda f, p, *_: (os.chmod(p, stat.S_IWRITE), f(p)); \
|
|
331
|
+
shutil.rmtree(r'<team_temp_dir>', **({'onexc': h} if sys.version_info >= (3, 12) else {'onerror': h}))"
|
|
332
|
+
```
|
|
287
333
|
|
|
288
334
|
### Step 4.5: PR description
|
|
289
335
|
|
|
@@ -315,8 +361,10 @@ Final commit: <current_HEAD_sha7>
|
|
|
315
361
|
Net change: <total_files> files, +<total_add>/-<total_del>
|
|
316
362
|
|
|
317
363
|
Loop log:
|
|
364
|
+
initial standards review: 1P0 0P1 2P2
|
|
318
365
|
1 audit: 3P0 2P1 0P2
|
|
319
366
|
...
|
|
367
|
+
final standards review: 0P0 0P1 0P2
|
|
320
368
|
```
|
|
321
369
|
|
|
322
370
|
`cap reached` → suggest `/findbugs`. `stuck` → which findings. `error` → detail + loop.
|
|
@@ -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 |
|