claude-dev-env 1.31.0 → 1.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/hooks/blocking/code_rules_enforcer.py +109 -0
  2. package/hooks/blocking/test_windows_rmtree_blocker.py +155 -0
  3. package/hooks/blocking/windows_rmtree_blocker.py +102 -0
  4. package/hooks/config/hook_log_extractor_constants.py +13 -0
  5. package/hooks/config/session_env_cleanup_constants.py +20 -0
  6. package/hooks/config/test_hook_log_extractor_constants.py +27 -0
  7. package/hooks/config/test_session_env_cleanup_constants.py +60 -0
  8. package/hooks/diagnostic/hook_log_stop_wrapper.py +107 -19
  9. package/hooks/diagnostic/test_hook_log_stop_wrapper.py +258 -11
  10. package/hooks/hooks.json +15 -0
  11. package/hooks/session/session_env_cleanup.py +130 -0
  12. package/hooks/session/test_session_env_cleanup.py +280 -0
  13. package/package.json +1 -1
  14. package/rules/windows-filesystem-safe.md +91 -0
  15. package/skills/bugteam/PROMPTS.md +39 -0
  16. package/skills/bugteam/SKILL.md +49 -1
  17. package/skills/bugteam/SKILL_EVALS.md +1 -1
  18. package/skills/bugteam/reference/copilot-gap-analysis.md +496 -0
  19. package/skills/bugteam/reference/teardown-publish-permissions.md +1 -1
  20. package/skills/bugteam/scripts/README.md +17 -0
  21. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +94 -0
  22. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +260 -0
  23. package/skills/bugteam/scripts/config/__init__.py +0 -0
  24. package/skills/bugteam/scripts/config/bugteam_fix_hookspath_constants.py +17 -0
  25. package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +267 -0
  26. package/skills/logifix/SKILL.md +69 -0
  27. package/skills/logifix/scripts/logifix.ps1 +205 -0
  28. package/skills/rebase/SKILL.md +164 -0
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.31.0",
3
+ "version": "1.33.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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.
@@ -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. `python -c "import shutil; shutil.rmtree(r'<team_temp_dir>', ignore_errors=True)"`
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>', ignore_errors=True)\"")` | `SKILL.md` § Step 4 |
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 |