claude-dev-env 1.32.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 +7 -0
- package/hooks/blocking/windows_rmtree_blocker.py +45 -49
- package/hooks/config/session_env_cleanup_constants.py +3 -1
- package/hooks/config/test_session_env_cleanup_constants.py +6 -1
- package/hooks/session/session_env_cleanup.py +5 -4
- package/hooks/session/test_session_env_cleanup.py +2 -0
- package/package.json +1 -1
- package/rules/windows-filesystem-safe.md +1 -3
- package/skills/bugteam/PROMPTS.md +39 -0
- package/skills/bugteam/SKILL.md +34 -0
- package/skills/bugteam/reference/copilot-gap-analysis.md +496 -0
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +94 -0
- package/skills/bugteam/scripts/bugteam_fix_hookspath.py +34 -12
- package/skills/bugteam/scripts/config/__init__.py +0 -0
- package/skills/bugteam/scripts/config/bugteam_fix_hookspath_constants.py +17 -0
- package/skills/rebase/SKILL.md +15 -8
|
@@ -60,6 +60,13 @@ NOT_INSIDE_TYPE_CHECKING_BLOCK = -1
|
|
|
60
60
|
MAX_ISSUES_PER_CHECK = 3
|
|
61
61
|
FILE_GLOBAL_UPPER_SNAKE_PATTERN = re.compile(r"^_?[A-Z][A-Z0-9_]*$")
|
|
62
62
|
|
|
63
|
+
COLLECTION_TYPE_NAMES: frozenset[str] = frozenset({
|
|
64
|
+
"list", "tuple", "set", "frozenset", "dict",
|
|
65
|
+
"Iterable", "Sequence", "Mapping", "MutableMapping", "FrozenSet",
|
|
66
|
+
})
|
|
67
|
+
COLLECTION_BY_NAME_PATTERN: re.Pattern[str] = re.compile(r"^[a-z][a-z0-9]*_by_[a-z][a-z0-9_]*$")
|
|
68
|
+
CLI_FILE_PATH_MARKERS: tuple[str, ...] = ("/scripts/", "\\scripts\\", "_cli.py", "/cli.py", "\\cli.py")
|
|
69
|
+
|
|
63
70
|
|
|
64
71
|
def get_file_extension(file_path: str) -> str:
|
|
65
72
|
"""Extract lowercase file extension."""
|
|
@@ -1933,6 +1940,106 @@ def check_unused_optional_parameters(content: str, file_path: str) -> list[str]:
|
|
|
1933
1940
|
return issues
|
|
1934
1941
|
|
|
1935
1942
|
|
|
1943
|
+
def _annotation_names_collection(annotation_node: ast.expr | None) -> bool:
|
|
1944
|
+
if annotation_node is None:
|
|
1945
|
+
return False
|
|
1946
|
+
if isinstance(annotation_node, ast.Name):
|
|
1947
|
+
return annotation_node.id in COLLECTION_TYPE_NAMES
|
|
1948
|
+
if isinstance(annotation_node, ast.Subscript):
|
|
1949
|
+
return _annotation_names_collection(annotation_node.value)
|
|
1950
|
+
if isinstance(annotation_node, ast.Attribute):
|
|
1951
|
+
return annotation_node.attr in COLLECTION_TYPE_NAMES
|
|
1952
|
+
return False
|
|
1953
|
+
|
|
1954
|
+
|
|
1955
|
+
def check_collection_prefix(content: str, file_path: str) -> list[str]:
|
|
1956
|
+
if is_test_file(file_path):
|
|
1957
|
+
return []
|
|
1958
|
+
if is_workflow_registry_file(file_path) or is_migration_file(file_path):
|
|
1959
|
+
return []
|
|
1960
|
+
try:
|
|
1961
|
+
tree = ast.parse(content)
|
|
1962
|
+
except SyntaxError:
|
|
1963
|
+
return []
|
|
1964
|
+
issues: list[str] = []
|
|
1965
|
+
for node in tree.body:
|
|
1966
|
+
target_name: str | None = None
|
|
1967
|
+
target_line = 0
|
|
1968
|
+
is_collection_value = False
|
|
1969
|
+
if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
|
|
1970
|
+
target_name = node.target.id
|
|
1971
|
+
target_line = node.lineno
|
|
1972
|
+
is_collection_value = _annotation_names_collection(node.annotation)
|
|
1973
|
+
elif isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
|
|
1974
|
+
target_name = node.targets[0].id
|
|
1975
|
+
target_line = node.lineno
|
|
1976
|
+
is_collection_value = isinstance(node.value, (ast.Tuple, ast.List, ast.Set, ast.Dict))
|
|
1977
|
+
if target_name is None or not is_collection_value:
|
|
1978
|
+
continue
|
|
1979
|
+
if not UPPER_SNAKE_CONSTANT_PATTERN.match(target_name):
|
|
1980
|
+
continue
|
|
1981
|
+
if target_name.startswith("ALL_") or COLLECTION_BY_NAME_PATTERN.match(target_name.lower()):
|
|
1982
|
+
continue
|
|
1983
|
+
issues.append(
|
|
1984
|
+
f"Line {target_line}: Collection constant {target_name} - prefix with ALL_ (CODE_RULES §5)"
|
|
1985
|
+
)
|
|
1986
|
+
if len(issues) >= MAX_ISSUES_PER_CHECK:
|
|
1987
|
+
break
|
|
1988
|
+
for node in ast.walk(tree):
|
|
1989
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
1990
|
+
continue
|
|
1991
|
+
for each_arg in _collect_annotated_arguments(node):
|
|
1992
|
+
if not _annotation_names_collection(each_arg.annotation):
|
|
1993
|
+
continue
|
|
1994
|
+
if each_arg.arg in {"self", "cls"}:
|
|
1995
|
+
continue
|
|
1996
|
+
if each_arg.arg.startswith("all_") or COLLECTION_BY_NAME_PATTERN.match(each_arg.arg):
|
|
1997
|
+
continue
|
|
1998
|
+
issues.append(
|
|
1999
|
+
f"Line {each_arg.lineno}: Collection parameter {each_arg.arg} - prefix with all_ (CODE_RULES §5)"
|
|
2000
|
+
)
|
|
2001
|
+
if len(issues) >= MAX_ISSUES_PER_CHECK:
|
|
2002
|
+
return issues
|
|
2003
|
+
return issues
|
|
2004
|
+
|
|
2005
|
+
|
|
2006
|
+
def _is_cli_entry_point(file_path: str) -> bool:
|
|
2007
|
+
path_lower = file_path.lower().replace("\\", "/")
|
|
2008
|
+
return any(marker.replace("\\", "/") in path_lower for marker in CLI_FILE_PATH_MARKERS)
|
|
2009
|
+
|
|
2010
|
+
|
|
2011
|
+
def check_library_print(content: str, file_path: str) -> list[str]:
|
|
2012
|
+
if is_test_file(file_path) or is_config_file(file_path) or is_hook_infrastructure(file_path):
|
|
2013
|
+
return []
|
|
2014
|
+
if _is_cli_entry_point(file_path):
|
|
2015
|
+
return []
|
|
2016
|
+
if get_file_extension(file_path) not in PYTHON_EXTENSIONS:
|
|
2017
|
+
return []
|
|
2018
|
+
try:
|
|
2019
|
+
tree = ast.parse(content)
|
|
2020
|
+
except SyntaxError:
|
|
2021
|
+
return []
|
|
2022
|
+
issues: list[str] = []
|
|
2023
|
+
for node in ast.walk(tree):
|
|
2024
|
+
if not isinstance(node, ast.Call):
|
|
2025
|
+
continue
|
|
2026
|
+
function_reference = node.func
|
|
2027
|
+
if isinstance(function_reference, ast.Name) and function_reference.id == "print":
|
|
2028
|
+
issues.append(
|
|
2029
|
+
f"Line {node.lineno}: Library print() - route through logger or accept an explicit stream parameter"
|
|
2030
|
+
)
|
|
2031
|
+
elif isinstance(function_reference, ast.Attribute) and function_reference.attr == "write":
|
|
2032
|
+
value_node = function_reference.value
|
|
2033
|
+
if isinstance(value_node, ast.Attribute) and isinstance(value_node.value, ast.Name):
|
|
2034
|
+
if value_node.value.id == "sys" and value_node.attr in {"stdout", "stderr"}:
|
|
2035
|
+
issues.append(
|
|
2036
|
+
f"Line {node.lineno}: sys.{value_node.attr}.write - route through logger"
|
|
2037
|
+
)
|
|
2038
|
+
if len(issues) >= MAX_ISSUES_PER_CHECK:
|
|
2039
|
+
break
|
|
2040
|
+
return issues
|
|
2041
|
+
|
|
2042
|
+
|
|
1936
2043
|
def validate_content(content: str, file_path: str, old_content: str = "") -> list[str]:
|
|
1937
2044
|
"""Run all applicable validators on content.
|
|
1938
2045
|
|
|
@@ -1964,6 +2071,8 @@ def validate_content(content: str, file_path: str, old_content: str = "") -> lis
|
|
|
1964
2071
|
all_issues.extend(check_existence_check_tests(content, file_path))
|
|
1965
2072
|
all_issues.extend(check_constant_equality_tests(content, file_path))
|
|
1966
2073
|
all_issues.extend(check_unused_optional_parameters(content, file_path))
|
|
2074
|
+
all_issues.extend(check_collection_prefix(content, file_path))
|
|
2075
|
+
all_issues.extend(check_library_print(content, file_path))
|
|
1967
2076
|
check_incomplete_mocks(content, file_path)
|
|
1968
2077
|
check_duplicated_format_patterns(content, file_path)
|
|
1969
2078
|
|
|
@@ -76,6 +76,12 @@ def test_allows_ignore_errors_false() -> None:
|
|
|
76
76
|
)
|
|
77
77
|
|
|
78
78
|
|
|
79
|
+
def test_blocks_rmtree_with_nested_parens_in_args() -> None:
|
|
80
|
+
assert payload_contains_unsafe_rmtree(
|
|
81
|
+
"shutil.rmtree(Path(target).resolve(), ignore_errors=True)"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
79
85
|
def test_extract_payload_handles_write_content() -> None:
|
|
80
86
|
extracted = extract_payload_text("Write", {"content": "abc"})
|
|
81
87
|
assert extracted == "abc"
|
|
@@ -98,6 +104,7 @@ def test_extract_payload_returns_empty_for_unknown_tool() -> None:
|
|
|
98
104
|
|
|
99
105
|
def _run_hook(hook_input: dict) -> tuple[str, int]:
|
|
100
106
|
captured = io.StringIO()
|
|
107
|
+
exit_code = 0
|
|
101
108
|
sys.stdin = io.StringIO(json.dumps(hook_input))
|
|
102
109
|
try:
|
|
103
110
|
with redirect_stdout(captured):
|
|
@@ -16,70 +16,66 @@ import json
|
|
|
16
16
|
import re
|
|
17
17
|
import sys
|
|
18
18
|
|
|
19
|
-
_WRITE_EDIT_TOOL_NAMES = {"Write", "Edit"}
|
|
20
|
-
_BASH_TOOL_NAME = "Bash"
|
|
21
|
-
|
|
22
|
-
_RMTREE_IGNORE_ERRORS_PATTERN = re.compile(
|
|
23
|
-
r"shutil\s*\.\s*rmtree\s*\([^)]*\bignore_errors\s*=\s*True\b",
|
|
24
|
-
re.DOTALL,
|
|
25
|
-
)
|
|
26
|
-
|
|
27
|
-
_CORRECTIVE_MESSAGE = (
|
|
28
|
-
"BLOCKED [windows-rmtree]: shutil.rmtree(..., ignore_errors=True) silently "
|
|
29
|
-
"fails on Windows when a file carries the ReadOnly attribute "
|
|
30
|
-
"(FILE_ATTRIBUTE_READONLY). The PermissionError is swallowed and the tree "
|
|
31
|
-
"stays on disk -- cleanup looks successful but removes nothing. Linux is "
|
|
32
|
-
"unaffected because unlink only needs write on the parent directory.\n\n"
|
|
33
|
-
"Use a Windows-safe handler that strips the attribute and retries the "
|
|
34
|
-
"syscall:\n\n"
|
|
35
|
-
" import os\n"
|
|
36
|
-
" import shutil\n"
|
|
37
|
-
" import stat\n"
|
|
38
|
-
" import sys\n\n"
|
|
39
|
-
" def _strip_read_only_and_retry(removal_function, target_path, *_exc_info):\n"
|
|
40
|
-
" try:\n"
|
|
41
|
-
" os.chmod(target_path, stat.S_IWRITE)\n"
|
|
42
|
-
" removal_function(target_path)\n"
|
|
43
|
-
" except OSError:\n"
|
|
44
|
-
" pass\n\n"
|
|
45
|
-
" def force_rmtree(target_path: str) -> None:\n"
|
|
46
|
-
" handler_kw = (\n"
|
|
47
|
-
' {"onexc": _strip_read_only_and_retry}\n'
|
|
48
|
-
" if sys.version_info >= (3, 12)\n"
|
|
49
|
-
' else {"onerror": _strip_read_only_and_retry}\n'
|
|
50
|
-
" )\n"
|
|
51
|
-
" try:\n"
|
|
52
|
-
" shutil.rmtree(target_path, **handler_kw)\n"
|
|
53
|
-
" except OSError:\n"
|
|
54
|
-
" pass\n\n"
|
|
55
|
-
"Two things to know about the handler:\n"
|
|
56
|
-
" - *_exc_info collapses the signature difference. onerror passes "
|
|
57
|
-
"(type, value, traceback); onexc (Python 3.12+) passes a single exception.\n"
|
|
58
|
-
" - removal_function is whichever syscall rmtree was attempting "
|
|
59
|
-
"(os.unlink for files, os.rmdir for dirs). Re-call it after chmod to finish "
|
|
60
|
-
"the work that originally failed.\n\n"
|
|
61
|
-
"See ~/.claude/rules/windows-filesystem-safe.md for full guidance."
|
|
62
|
-
)
|
|
63
|
-
|
|
64
19
|
|
|
65
20
|
def payload_contains_unsafe_rmtree(payload_text: str) -> bool:
|
|
66
21
|
if not payload_text:
|
|
67
22
|
return False
|
|
68
|
-
|
|
23
|
+
rmtree_ignore_errors_pattern = re.compile(
|
|
24
|
+
r"shutil\s*\.\s*rmtree\s*\(.*?\bignore_errors\s*=\s*True\b",
|
|
25
|
+
re.DOTALL,
|
|
26
|
+
)
|
|
27
|
+
return bool(rmtree_ignore_errors_pattern.search(payload_text))
|
|
69
28
|
|
|
70
29
|
|
|
71
30
|
def extract_payload_text(tool_name: str, tool_input: dict) -> str:
|
|
72
|
-
if tool_name in
|
|
31
|
+
if tool_name in {"Write", "Edit"}:
|
|
73
32
|
return tool_input.get("content", "") or tool_input.get("new_string", "") or ""
|
|
74
|
-
if tool_name ==
|
|
33
|
+
if tool_name == "Bash":
|
|
75
34
|
return tool_input.get("command", "") or ""
|
|
76
35
|
return ""
|
|
77
36
|
|
|
78
37
|
|
|
79
38
|
def main() -> None:
|
|
39
|
+
corrective_message = (
|
|
40
|
+
"BLOCKED [windows-rmtree]: shutil.rmtree(..., ignore_errors=True) silently "
|
|
41
|
+
"fails on Windows when a file carries the ReadOnly attribute "
|
|
42
|
+
"(FILE_ATTRIBUTE_READONLY). The PermissionError is swallowed and the tree "
|
|
43
|
+
"stays on disk -- cleanup looks successful but removes nothing. Linux is "
|
|
44
|
+
"unaffected because unlink only needs write on the parent directory.\n\n"
|
|
45
|
+
"Use a Windows-safe handler that strips the attribute and retries the "
|
|
46
|
+
"syscall:\n\n"
|
|
47
|
+
" import os\n"
|
|
48
|
+
" import shutil\n"
|
|
49
|
+
" import stat\n"
|
|
50
|
+
" import sys\n\n"
|
|
51
|
+
" def _strip_read_only_and_retry(removal_function, target_path, *_exc_info):\n"
|
|
52
|
+
" try:\n"
|
|
53
|
+
" os.chmod(target_path, stat.S_IWRITE)\n"
|
|
54
|
+
" removal_function(target_path)\n"
|
|
55
|
+
" except OSError:\n"
|
|
56
|
+
" pass\n\n"
|
|
57
|
+
" def force_rmtree(target_path: str) -> None:\n"
|
|
58
|
+
" handler_kw = (\n"
|
|
59
|
+
' {"onexc": _strip_read_only_and_retry}\n'
|
|
60
|
+
" if sys.version_info >= (3, 12)\n"
|
|
61
|
+
' else {"onerror": _strip_read_only_and_retry}\n'
|
|
62
|
+
" )\n"
|
|
63
|
+
" try:\n"
|
|
64
|
+
" shutil.rmtree(target_path, **handler_kw)\n"
|
|
65
|
+
" except OSError:\n"
|
|
66
|
+
" pass\n\n"
|
|
67
|
+
"Two things to know about the handler:\n"
|
|
68
|
+
" - *_exc_info collapses the signature difference. onerror passes "
|
|
69
|
+
"(type, value, traceback); onexc (Python 3.12+) passes a single exception.\n"
|
|
70
|
+
" - removal_function is whichever syscall rmtree was attempting "
|
|
71
|
+
"(os.unlink for files, os.rmdir for dirs). Re-call it after chmod to finish "
|
|
72
|
+
"the work that originally failed.\n\n"
|
|
73
|
+
"See ~/.claude/rules/windows-filesystem-safe.md for full guidance."
|
|
74
|
+
)
|
|
80
75
|
try:
|
|
81
76
|
hook_input = json.load(sys.stdin)
|
|
82
77
|
except json.JSONDecodeError:
|
|
78
|
+
sys.stderr.write("windows_rmtree_blocker: malformed JSON on stdin\n")
|
|
83
79
|
sys.exit(0)
|
|
84
80
|
|
|
85
81
|
tool_name = hook_input.get("tool_name", "")
|
|
@@ -94,7 +90,7 @@ def main() -> None:
|
|
|
94
90
|
"hookSpecificOutput": {
|
|
95
91
|
"hookEventName": "PreToolUse",
|
|
96
92
|
"permissionDecision": "deny",
|
|
97
|
-
"permissionDecisionReason":
|
|
93
|
+
"permissionDecisionReason": corrective_message,
|
|
98
94
|
}
|
|
99
95
|
}
|
|
100
96
|
print(json.dumps(deny_response))
|
|
@@ -11,7 +11,9 @@ SECONDS_PER_DAY = 24 * 60 * 60
|
|
|
11
11
|
STALE_AGE_DAYS = 7
|
|
12
12
|
STALE_AGE_SECONDS = STALE_AGE_DAYS * SECONDS_PER_DAY
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
ALL_RMTREE_ONEXC_PYTHON_VERSION_PARTS: tuple[int, int] = (3, 12)
|
|
15
|
+
|
|
16
|
+
SESSION_ID_PAYLOAD_KEY: str = "session_id"
|
|
15
17
|
|
|
16
18
|
SESSION_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
|
17
19
|
|
|
@@ -11,7 +11,7 @@ for each_sys_path_entry in (str(_CONFIG_DIRECTORY), str(_HOOKS_ROOT)):
|
|
|
11
11
|
if each_sys_path_entry not in sys.path:
|
|
12
12
|
sys.path.insert(0, each_sys_path_entry)
|
|
13
13
|
|
|
14
|
-
from config.session_env_cleanup_constants import SESSION_ID_PATTERN
|
|
14
|
+
from config.session_env_cleanup_constants import SESSION_ID_PATTERN, SESSION_ID_PAYLOAD_KEY
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
class TestSessionIdPatternAccepts:
|
|
@@ -31,6 +31,11 @@ class TestSessionIdPatternAccepts:
|
|
|
31
31
|
assert matched.group(0) == underscore_input
|
|
32
32
|
|
|
33
33
|
|
|
34
|
+
class TestSessionIdPayloadKey:
|
|
35
|
+
def test_session_id_payload_key_matches_hook_protocol_field(self) -> None:
|
|
36
|
+
assert SESSION_ID_PAYLOAD_KEY == "session_id"
|
|
37
|
+
|
|
38
|
+
|
|
34
39
|
class TestSessionIdPatternRejects:
|
|
35
40
|
def test_rejects_forward_slash(self) -> None:
|
|
36
41
|
assert SESSION_ID_PATTERN.match("etc/passwd") is None
|
|
@@ -36,9 +36,10 @@ def _insert_hooks_tree_for_imports() -> None:
|
|
|
36
36
|
_insert_hooks_tree_for_imports()
|
|
37
37
|
|
|
38
38
|
from config.session_env_cleanup_constants import (
|
|
39
|
-
|
|
39
|
+
ALL_RMTREE_ONEXC_PYTHON_VERSION_PARTS,
|
|
40
40
|
SESSION_ENV_DIRECTORY,
|
|
41
41
|
SESSION_ID_PATTERN,
|
|
42
|
+
SESSION_ID_PAYLOAD_KEY,
|
|
42
43
|
STALE_AGE_SECONDS,
|
|
43
44
|
WINDOWS_PLATFORM_TAG,
|
|
44
45
|
)
|
|
@@ -57,7 +58,7 @@ def _strip_read_only_and_retry(
|
|
|
57
58
|
|
|
58
59
|
|
|
59
60
|
def _force_rmtree(target_path: str) -> None:
|
|
60
|
-
rmtree_onexc_python_version =
|
|
61
|
+
rmtree_onexc_python_version = ALL_RMTREE_ONEXC_PYTHON_VERSION_PARTS
|
|
61
62
|
try:
|
|
62
63
|
if sys.version_info >= rmtree_onexc_python_version:
|
|
63
64
|
shutil.rmtree(target_path, onexc=_strip_read_only_and_retry)
|
|
@@ -103,10 +104,10 @@ def _read_session_id_from_stdin() -> str:
|
|
|
103
104
|
return ""
|
|
104
105
|
if not isinstance(payload, dict):
|
|
105
106
|
return ""
|
|
106
|
-
raw_session_id = payload.get(
|
|
107
|
+
raw_session_id = payload.get(SESSION_ID_PAYLOAD_KEY)
|
|
107
108
|
if not isinstance(raw_session_id, str):
|
|
108
109
|
return ""
|
|
109
|
-
if not session_id_pattern.
|
|
110
|
+
if not session_id_pattern.fullmatch(raw_session_id):
|
|
110
111
|
return ""
|
|
111
112
|
return raw_session_id
|
|
112
113
|
|
|
@@ -179,6 +179,7 @@ class TestMainReadsSessionIdFromStdin:
|
|
|
179
179
|
with (
|
|
180
180
|
patch.object(cleanup, "prune_session_env", side_effect=fake_prune),
|
|
181
181
|
patch("sys.stdin", stdin_payload),
|
|
182
|
+
patch.object(cleanup.sys, "platform", "win32"),
|
|
182
183
|
):
|
|
183
184
|
cleanup.main()
|
|
184
185
|
assert captured_call["session_id"] == "session-from-stdin"
|
|
@@ -197,6 +198,7 @@ class TestMainReadsSessionIdFromStdin:
|
|
|
197
198
|
with (
|
|
198
199
|
patch.object(cleanup, "prune_session_env", side_effect=fake_prune),
|
|
199
200
|
patch("sys.stdin", stdin_payload),
|
|
201
|
+
patch.object(cleanup.sys, "platform", "win32"),
|
|
200
202
|
):
|
|
201
203
|
cleanup.main()
|
|
202
204
|
assert captured_call["session_id"] == ""
|
package/package.json
CHANGED
|
@@ -53,9 +53,7 @@ Two things to know about the handler:
|
|
|
53
53
|
If a skill or runbook genuinely needs a one-line shell invocation, the equivalent without `ignore_errors=True` is:
|
|
54
54
|
|
|
55
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}))"
|
|
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}))"
|
|
59
57
|
```
|
|
60
58
|
|
|
61
59
|
Prefer the multi-line `force_rmtree` helper — the one-liner is hard to read and easy to mis-quote.
|
|
@@ -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
|
@@ -81,7 +81,9 @@ The fix script removes any non-canonical local-scope override on the active repo
|
|
|
81
81
|
[ ] Step 0: project permissions granted
|
|
82
82
|
[ ] Step 1: PR scope resolved
|
|
83
83
|
[ ] Step 2: agent team created + loop state set
|
|
84
|
+
[ ] Step 2.6: INITIAL standards review against cumulative PR diff
|
|
84
85
|
[ ] Step 3: cycle complete (converged | cap reached | stuck | error)
|
|
86
|
+
[ ] Step 3.5: FINAL standards review against cumulative PR diff
|
|
85
87
|
[ ] Step 4: team torn down + working tree clean
|
|
86
88
|
[ ] Step 4.5: PR description rewritten (or skip warning logged)
|
|
87
89
|
[ ] Step 5: project permissions revoked
|
|
@@ -203,6 +205,23 @@ jq -n \
|
|
|
203
205
|
|
|
204
206
|
**Endpoints:** `POST .../pulls/{pull}/reviews`; `POST .../pulls/{pull}/comments/{id}/replies`; fallback `POST .../issues/{issue}/comments` (`issue` = PR number).
|
|
205
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
|
+
|
|
206
225
|
### Step 3: The cycle
|
|
207
226
|
|
|
208
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.
|
|
@@ -285,6 +304,19 @@ Pass finding comment URLs/ids from `loop_comment_index` in XML. Replies: `Fixed
|
|
|
285
304
|
|
|
286
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`.
|
|
287
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
|
+
|
|
288
320
|
### Step 4: Teardown
|
|
289
321
|
|
|
290
322
|
1. For each live teammate: `SendMessage(to="<name>", message={"type": "shutdown_request", "reason": "bugteam cycle ending"})`. `approve: false` on cleanup → log and continue.
|
|
@@ -329,8 +361,10 @@ Final commit: <current_HEAD_sha7>
|
|
|
329
361
|
Net change: <total_files> files, +<total_add>/-<total_del>
|
|
330
362
|
|
|
331
363
|
Loop log:
|
|
364
|
+
initial standards review: 1P0 0P1 2P2
|
|
332
365
|
1 audit: 3P0 2P1 0P2
|
|
333
366
|
...
|
|
367
|
+
final standards review: 0P0 0P1 0P2
|
|
334
368
|
```
|
|
335
369
|
|
|
336
370
|
`cap reached` → suggest `/findbugs`. `stuck` → which findings. `error` → detail + loop.
|