claude-dev-env 1.44.0 → 1.45.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/CLAUDE.md +9 -0
- package/_shared/pr-loop/scripts/code_rules_gate.py +426 -85
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +20 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +625 -21
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +15 -0
- package/agents/clean-coder.md +7 -1
- package/agents/code-quality-agent.md +8 -5
- package/hooks/blocking/code_rules_enforcer.py +1562 -37
- package/hooks/blocking/open_questions_in_plans_blocker.py +249 -0
- package/hooks/blocking/test_code_rules_enforcer.py +1389 -0
- package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +292 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +46 -8
- package/hooks/blocking/test_code_rules_enforcer_exempt_marker_chained.py +189 -0
- package/hooks/blocking/test_code_rules_enforcer_function_length.py +210 -0
- package/hooks/blocking/test_code_rules_enforcer_tests_isolate_home_temp.py +1512 -0
- package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +9 -5
- package/hooks/blocking/test_open_questions_in_plans_blocker.py +790 -0
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/banned_identifiers_constants.py +19 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +129 -2
- package/hooks/hooks_constants/open_questions_in_plans_blocker_constants.py +35 -0
- package/hooks/hooks_constants/test_open_questions_in_plans_blocker_constants.py +125 -0
- package/package.json +1 -1
- package/skills/_shared/pr-loop/scripts/_path_resolver.py +34 -13
- package/skills/_shared/pr-loop/scripts/init_loop_state.py +1 -2
- package/skills/_shared/pr-loop/scripts/teardown_worktrees.py +1 -4
- package/skills/_shared/pr-loop/scripts/test__path_resolver.py +57 -0
- package/skills/_shared/pr-loop/scripts/test_init_loop_state.py +48 -0
- package/skills/_shared/pr-loop/scripts/test_teardown_worktrees.py +59 -0
- package/skills/bugteam/PROMPTS.md +48 -12
- package/skills/bugteam/reference/team-setup.md +4 -2
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +487 -76
- package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_code_rules_gate_constants.py +22 -1
- package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +597 -12
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse:Write|Edit|MultiEdit hook — blocks plan files that contain an "Open Questions" section.
|
|
3
|
+
|
|
4
|
+
Plans under `~/.claude/plans/` (or any `.claude/plans/` directory) must not be
|
|
5
|
+
written with an unresolved "Open Questions" section. When detected, the agent is
|
|
6
|
+
forced to (1) investigate the codebase for answers itself first, then (2) confirm
|
|
7
|
+
its interpretations via the AskUserQuestion tool in plain everyday language, and
|
|
8
|
+
(3) re-write the plan with the section resolved and removed.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TextIO
|
|
16
|
+
|
|
17
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
18
|
+
if _hooks_dir not in sys.path:
|
|
19
|
+
sys.path.insert(0, _hooks_dir)
|
|
20
|
+
|
|
21
|
+
from hooks_constants.open_questions_in_plans_blocker_constants import ( # noqa: E402
|
|
22
|
+
CODE_FENCE_PATTERN,
|
|
23
|
+
INLINE_CODE_PATTERN,
|
|
24
|
+
MARKDOWN_EXTENSION,
|
|
25
|
+
OPEN_QUESTIONS_HEADING_PATTERN,
|
|
26
|
+
PLAN_FILE_ENCODING,
|
|
27
|
+
PLANS_PATH_PREFIX,
|
|
28
|
+
PLANS_PATH_SEGMENT,
|
|
29
|
+
UNREADABLE_FILE_SYNTHETIC_CONTENT,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _is_markdown_file(file_path: str) -> bool:
|
|
34
|
+
return file_path.lower().endswith(MARKDOWN_EXTENSION)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _is_inside_plans_directory(file_path: str) -> bool:
|
|
38
|
+
expanded = os.path.expanduser(file_path)
|
|
39
|
+
normalized = os.path.normpath(expanded).replace("\\", "/").lower()
|
|
40
|
+
if PLANS_PATH_SEGMENT in normalized:
|
|
41
|
+
return True
|
|
42
|
+
if normalized.startswith(PLANS_PATH_PREFIX):
|
|
43
|
+
return True
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _strip_code_regions(text: str) -> str:
|
|
48
|
+
"""Remove fenced code blocks and inline code spans so quoted headings don't trigger the regex."""
|
|
49
|
+
without_fences = CODE_FENCE_PATTERN.sub("", text)
|
|
50
|
+
return INLINE_CODE_PATTERN.sub("", without_fences)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _content_has_open_questions(text: str) -> bool:
|
|
54
|
+
if not text:
|
|
55
|
+
return False
|
|
56
|
+
return bool(OPEN_QUESTIONS_HEADING_PATTERN.search(_strip_code_regions(text)))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _read_plan_file_text_and_missing_flag(file_path: str) -> tuple[str | None, bool]:
|
|
60
|
+
"""Return `(text, file_is_missing)` for the existing plan file on disk.
|
|
61
|
+
|
|
62
|
+
Three outcomes:
|
|
63
|
+
* `(text, False)` — file read successfully.
|
|
64
|
+
* `(None, True)` — file is missing (FileNotFoundError) or `file_path`
|
|
65
|
+
points at a directory (IsADirectoryError). Callers fall back to scanning
|
|
66
|
+
candidate `new_string` content for an Open Questions heading.
|
|
67
|
+
* `(None, False)` — file exists but is unreadable (PermissionError on a
|
|
68
|
+
locked file, UnicodeDecodeError on bytes the configured encoding cannot
|
|
69
|
+
decode). Callers cannot prove the on-disk content is clean and must
|
|
70
|
+
conservatively block.
|
|
71
|
+
|
|
72
|
+
Narrow exceptions only — any other failure is left to propagate.
|
|
73
|
+
"""
|
|
74
|
+
try:
|
|
75
|
+
return (
|
|
76
|
+
Path(os.path.expanduser(file_path)).read_text(encoding=PLAN_FILE_ENCODING),
|
|
77
|
+
False,
|
|
78
|
+
)
|
|
79
|
+
except (FileNotFoundError, IsADirectoryError):
|
|
80
|
+
return (None, True)
|
|
81
|
+
except (PermissionError, UnicodeDecodeError):
|
|
82
|
+
return (None, False)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _apply_edit_to_text(existing_text: str, old_string: str, new_string: str) -> str:
|
|
86
|
+
"""Apply Claude Code's Edit semantics: replace the first occurrence only."""
|
|
87
|
+
return existing_text.replace(old_string, new_string, 1)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _is_valid_old_string(old_string: object) -> bool:
|
|
91
|
+
"""Return True when `old_string` is a non-empty string suitable for `str.replace`.
|
|
92
|
+
|
|
93
|
+
Empty or non-string `old_string` cannot be replaced safely:
|
|
94
|
+
`existing_text.replace("", new, 1)` prepends `new` rather than performing
|
|
95
|
+
a real edit, which would fabricate post-edit content the real Edit tool
|
|
96
|
+
would never produce.
|
|
97
|
+
"""
|
|
98
|
+
return isinstance(old_string, str) and old_string != ""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _post_edit_content_for_edit(existing_text: str | None, tool_input: dict) -> str:
|
|
102
|
+
old_string = tool_input.get("old_string", "")
|
|
103
|
+
new_string = tool_input.get("new_string", "")
|
|
104
|
+
safe_new = new_string if isinstance(new_string, str) else ""
|
|
105
|
+
if existing_text is None:
|
|
106
|
+
return safe_new
|
|
107
|
+
if not _is_valid_old_string(old_string):
|
|
108
|
+
return existing_text
|
|
109
|
+
return _apply_edit_to_text(existing_text, old_string, safe_new)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _post_edit_content_for_multiedit(existing_text: str | None, tool_input: dict) -> str:
|
|
113
|
+
all_edits = tool_input.get("edits", []) or []
|
|
114
|
+
if existing_text is None:
|
|
115
|
+
return _multiedit_missing_file_new_strings(all_edits)
|
|
116
|
+
accumulated_text = existing_text
|
|
117
|
+
for each_edit in all_edits:
|
|
118
|
+
if not isinstance(each_edit, dict):
|
|
119
|
+
continue
|
|
120
|
+
old_string = each_edit.get("old_string", "")
|
|
121
|
+
new_string = each_edit.get("new_string", "")
|
|
122
|
+
if not _is_valid_old_string(old_string):
|
|
123
|
+
continue
|
|
124
|
+
safe_new = new_string if isinstance(new_string, str) else ""
|
|
125
|
+
accumulated_text = _apply_edit_to_text(accumulated_text, old_string, safe_new)
|
|
126
|
+
return accumulated_text
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _multiedit_missing_file_new_strings(all_edits: list) -> str:
|
|
130
|
+
"""Join every edit's `new_string` for the missing-file scan.
|
|
131
|
+
|
|
132
|
+
When the target file does not exist on disk, the hook starts from an empty
|
|
133
|
+
base and concatenates candidate content from every edit — valid or invalid
|
|
134
|
+
`old_string` alike. The goal is over-blocking: any `Open Questions` heading
|
|
135
|
+
anywhere in the proposed payload must trigger the deny. The valid/invalid
|
|
136
|
+
`old_string` distinction only matters for the existing-file branch, where
|
|
137
|
+
`replace('', X, 1)` would fabricate a prepend.
|
|
138
|
+
"""
|
|
139
|
+
fallback_new_strings: list[str] = []
|
|
140
|
+
for each_edit in all_edits:
|
|
141
|
+
if not isinstance(each_edit, dict):
|
|
142
|
+
continue
|
|
143
|
+
new_string = each_edit.get("new_string", "")
|
|
144
|
+
safe_new = new_string if isinstance(new_string, str) else ""
|
|
145
|
+
fallback_new_strings.append(safe_new)
|
|
146
|
+
return "\n".join(fallback_new_strings)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _extract_candidate_content(tool_name: str, tool_input: dict, file_path: str) -> str:
|
|
150
|
+
if tool_name == "Write":
|
|
151
|
+
content = tool_input.get("content", "")
|
|
152
|
+
return content if isinstance(content, str) else ""
|
|
153
|
+
existing_text, file_is_missing = _read_plan_file_text_and_missing_flag(file_path)
|
|
154
|
+
if existing_text is None and not file_is_missing:
|
|
155
|
+
return UNREADABLE_FILE_SYNTHETIC_CONTENT
|
|
156
|
+
if tool_name == "Edit":
|
|
157
|
+
return _post_edit_content_for_edit(existing_text, tool_input)
|
|
158
|
+
if tool_name == "MultiEdit":
|
|
159
|
+
return _post_edit_content_for_multiedit(existing_text, tool_input)
|
|
160
|
+
return ""
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _block_reason(file_path: str) -> str:
|
|
164
|
+
return (
|
|
165
|
+
f"BLOCKED: Plan file '{file_path}' contains an 'Open Questions' section. "
|
|
166
|
+
"Open questions in plans are unacceptable — they must be resolved before the plan is saved."
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _block_context() -> str:
|
|
171
|
+
return (
|
|
172
|
+
"An 'Open Questions' section means the plan is not yet ready to commit. Resolve it before retrying:\n\n"
|
|
173
|
+
"1. Investigate the codebase yourself first. For each open question, try to answer it by "
|
|
174
|
+
"reading source files, grepping, or dispatching an Explore agent. Do not skip this step — "
|
|
175
|
+
"always attempt to find the answer before bothering the user.\n\n"
|
|
176
|
+
"2. Confirm interpretations via AskUserQuestion. Once you have a proposed answer or "
|
|
177
|
+
"interpretation for each question, call the AskUserQuestion tool. Phrase the questions in "
|
|
178
|
+
"plain everyday language: state what you found, what you think it means, and ask the user "
|
|
179
|
+
"to confirm or correct. Make it easy to digest and comprehend exactly what you are doing. "
|
|
180
|
+
"Prefer one AskUserQuestion call that covers all open questions where possible.\n\n"
|
|
181
|
+
"3. Re-write the plan. After the user confirms, remove the 'Open Questions' section "
|
|
182
|
+
"entirely and fold the resolved answers into the relevant sections of the plan, then "
|
|
183
|
+
"retry the Write/Edit/MultiEdit."
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _block_system_message() -> str:
|
|
188
|
+
return (
|
|
189
|
+
"Plan blocked — 'Open Questions' must be resolved (investigate the codebase, then confirm "
|
|
190
|
+
"interpretations via AskUserQuestion in plain language) before saving."
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _emit_hook_result(payload: dict, output_stream: TextIO) -> None:
|
|
195
|
+
output_stream.write(json.dumps(payload) + "\n")
|
|
196
|
+
output_stream.flush()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def main() -> None:
|
|
200
|
+
try:
|
|
201
|
+
input_data = json.load(sys.stdin)
|
|
202
|
+
except json.JSONDecodeError:
|
|
203
|
+
sys.exit(0)
|
|
204
|
+
|
|
205
|
+
if not isinstance(input_data, dict):
|
|
206
|
+
sys.exit(0)
|
|
207
|
+
|
|
208
|
+
tool_name = input_data.get("tool_name", "")
|
|
209
|
+
if not isinstance(tool_name, str):
|
|
210
|
+
sys.exit(0)
|
|
211
|
+
|
|
212
|
+
tool_input = input_data.get("tool_input", {})
|
|
213
|
+
if not isinstance(tool_input, dict):
|
|
214
|
+
sys.exit(0)
|
|
215
|
+
|
|
216
|
+
if tool_name not in ("Write", "Edit", "MultiEdit"):
|
|
217
|
+
sys.exit(0)
|
|
218
|
+
|
|
219
|
+
file_path = tool_input.get("file_path", "")
|
|
220
|
+
if not isinstance(file_path, str) or not file_path:
|
|
221
|
+
sys.exit(0)
|
|
222
|
+
|
|
223
|
+
if not _is_markdown_file(file_path):
|
|
224
|
+
sys.exit(0)
|
|
225
|
+
|
|
226
|
+
if not _is_inside_plans_directory(file_path):
|
|
227
|
+
sys.exit(0)
|
|
228
|
+
|
|
229
|
+
candidate_content = _extract_candidate_content(tool_name, tool_input, file_path)
|
|
230
|
+
if not _content_has_open_questions(candidate_content):
|
|
231
|
+
sys.exit(0)
|
|
232
|
+
|
|
233
|
+
block_payload = {
|
|
234
|
+
"hookSpecificOutput": {
|
|
235
|
+
"hookEventName": "PreToolUse",
|
|
236
|
+
"permissionDecision": "deny",
|
|
237
|
+
"permissionDecisionReason": _block_reason(file_path),
|
|
238
|
+
"additionalContext": _block_context(),
|
|
239
|
+
},
|
|
240
|
+
"systemMessage": _block_system_message(),
|
|
241
|
+
"suppressOutput": True,
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
_emit_hook_result(block_payload, sys.stdout)
|
|
245
|
+
sys.exit(0)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
if __name__ == "__main__":
|
|
249
|
+
main()
|