claude-dev-env 1.37.1 → 1.38.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 +3 -0
- package/_shared/pr-loop/audit-contract.md +4 -3
- package/_shared/pr-loop/fix-protocol.md +2 -0
- package/_shared/pr-loop/gh-payloads.md +38 -37
- package/_shared/pr-loop/scripts/README.md +0 -1
- package/_shared/pr-loop/scripts/preflight.py +2 -1
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +2 -2
- package/_shared/pr-loop/scripts/tests/test_preflight.py +22 -0
- package/_shared/pr-loop/state-schema.md +10 -10
- package/agents/clean-coder.md +4 -0
- package/agents/code-quality-agent.md +23 -85
- package/agents/groq-coder.md +8 -6
- package/hooks/blocking/__init__.py +0 -0
- package/hooks/blocking/hedging_language_blocker.py +2 -2
- package/hooks/blocking/state_description_blocker.py +243 -0
- package/hooks/blocking/tdd_enforcer.py +94 -0
- package/hooks/blocking/test_hedging_language_blocker.py +1 -1
- package/hooks/blocking/test_state_description_blocker.py +618 -0
- package/hooks/blocking/test_tdd_enforcer.py +152 -0
- package/hooks/config/state_description_blocker_constants.py +130 -0
- package/hooks/hooks.json +10 -0
- package/package.json +1 -1
- package/rules/no-historical-clutter.md +31 -10
- package/scripts/config/groq_bugteam_config.py +13 -5
- package/skills/bugteam/CONSTRAINTS.md +20 -27
- package/skills/bugteam/EXAMPLES.md +1 -1
- package/skills/bugteam/PROMPTS.md +60 -31
- package/skills/bugteam/SKILL.md +47 -47
- package/skills/bugteam/SKILL_EVALS.md +8 -8
- package/skills/bugteam/reference/github-pr-reviews.md +31 -31
- package/skills/bugteam/reference/team-setup.md +1 -1
- package/skills/bugteam/reference/teardown-publish-permissions.md +4 -4
- package/skills/copilot-review/SKILL.md +7 -14
- package/skills/findbugs/SKILL.md +2 -2
- package/skills/fixbugs/SKILL.md +1 -1
- package/skills/monitor-open-prs/SKILL.md +6 -6
- package/skills/pr-converge/SKILL.md +7 -6
- package/skills/pr-converge/reference/convergence-gates.md +28 -30
- package/skills/pr-converge/reference/examples.md +4 -4
- package/skills/pr-converge/reference/fix-protocol.md +6 -8
- package/skills/pr-converge/reference/multi-pr-orchestration.md +10 -10
- package/skills/pr-converge/reference/per-tick.md +18 -33
- package/skills/pr-converge/reference/stop-conditions.md +7 -7
- package/skills/pr-converge/scripts/README.md +65 -117
- package/skills/pr-review-responder/EXAMPLES.md +2 -2
- package/skills/pr-review-responder/PRINCIPLES.md +2 -8
- package/skills/pr-review-responder/README.md +7 -48
- package/skills/pr-review-responder/SKILL.md +2 -3
- package/skills/pr-review-responder/TESTING.md +8 -65
- package/skills/qbug/SKILL.md +10 -16
- package/_shared/pr-loop/scripts/config/gh_util_constants.py +0 -31
- package/_shared/pr-loop/scripts/gh_util.py +0 -193
- package/_shared/pr-loop/scripts/tests/test_gh_util.py +0 -257
- package/_shared/pr-loop/scripts/tests/test_gh_util_constants.py +0 -61
- package/skills/pr-converge/scripts/check_pr_mergeability.py +0 -78
- package/skills/pr-converge/scripts/config/pr_converge_constants.py +0 -134
- package/skills/pr-converge/scripts/config/test_pr_converge_constants.py +0 -152
- package/skills/pr-converge/scripts/fetch_bugbot_inline_comments.py +0 -70
- package/skills/pr-converge/scripts/fetch_bugbot_reviews.py +0 -57
- package/skills/pr-converge/scripts/fetch_claude_inline_comments.py +0 -70
- package/skills/pr-converge/scripts/fetch_claude_reviews.py +0 -61
- package/skills/pr-converge/scripts/fetch_copilot_inline_comments.py +0 -70
- package/skills/pr-converge/scripts/fetch_copilot_reviews.py +0 -61
- package/skills/pr-converge/scripts/mark_pr_ready.py +0 -54
- package/skills/pr-converge/scripts/post-bugbot-run.helpers.ps1 +0 -49
- package/skills/pr-converge/scripts/post-bugbot-run.ps1 +0 -33
- package/skills/pr-converge/scripts/reply_to_inline_comment.py +0 -84
- package/skills/pr-converge/scripts/request_copilot_review.py +0 -71
- package/skills/pr-converge/scripts/resolve_pr_head.py +0 -58
- package/skills/pr-converge/scripts/review_field_helpers.py +0 -43
- package/skills/pr-converge/scripts/reviewer_fetch_core.py +0 -153
- package/skills/pr-converge/scripts/reviewer_specs.py +0 -98
- package/skills/pr-converge/scripts/test_check_pr_mergeability.py +0 -126
- package/skills/pr-converge/scripts/test_fetch_bugbot_inline_comments.py +0 -443
- package/skills/pr-converge/scripts/test_fetch_bugbot_reviews.py +0 -299
- package/skills/pr-converge/scripts/test_fetch_claude_inline_comments.py +0 -485
- package/skills/pr-converge/scripts/test_fetch_claude_reviews.py +0 -368
- package/skills/pr-converge/scripts/test_fetch_copilot_inline_comments.py +0 -440
- package/skills/pr-converge/scripts/test_fetch_copilot_reviews.py +0 -366
- package/skills/pr-converge/scripts/test_mark_pr_ready.py +0 -69
- package/skills/pr-converge/scripts/test_post_bugbot_run.py +0 -195
- package/skills/pr-converge/scripts/test_reply_to_inline_comment.py +0 -159
- package/skills/pr-converge/scripts/test_request_copilot_review.py +0 -101
- package/skills/pr-converge/scripts/test_resolve_pr_head.py +0 -79
- package/skills/pr-converge/scripts/test_review_field_helpers.py +0 -80
- package/skills/pr-converge/scripts/test_reviewer_fetch_core.py +0 -448
- package/skills/pr-converge/scripts/test_reviewer_specs.py +0 -107
- package/skills/pr-converge/scripts/test_trigger_bugbot.py +0 -139
- package/skills/pr-converge/scripts/test_view_pr_context.py +0 -155
- package/skills/pr-converge/scripts/trigger_bugbot.py +0 -77
- package/skills/pr-converge/scripts/view_pr_context.py +0 -78
- package/skills/pr-review-responder/scripts/respond_to_reviews.py +0 -376
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook: blocks Write/Edit containing historical/comparative language in comments and .md files.
|
|
3
|
+
|
|
4
|
+
Enforces the "describe current state only" rule — no "instead of", "previously",
|
|
5
|
+
"now uses", or similar transitional framing. Comments and documentation should
|
|
6
|
+
describe what IS, not what WAS or what CHANGED.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import io
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _insert_hooks_tree_for_imports() -> None:
|
|
17
|
+
hooks_tree = Path(__file__).absolute().parent.parent
|
|
18
|
+
hooks_tree_string = str(hooks_tree)
|
|
19
|
+
if hooks_tree_string not in sys.path:
|
|
20
|
+
sys.path.insert(0, hooks_tree_string)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_insert_hooks_tree_for_imports()
|
|
24
|
+
|
|
25
|
+
from config.state_description_blocker_constants import (
|
|
26
|
+
ALL_BLOCK_COMMENT_EXTENSIONS,
|
|
27
|
+
ALL_BLOCK_COMMENT_ONLY_EXTENSIONS,
|
|
28
|
+
ALL_COMMENT_BEARING_EXTENSIONS,
|
|
29
|
+
ALL_COMMENT_TRANSITION_PATTERNS,
|
|
30
|
+
ALL_HASH_AND_SLASH_EXTENSIONS,
|
|
31
|
+
ALL_HASH_ONLY_EXTENSIONS,
|
|
32
|
+
ALL_MARKDOWN_EXTENSIONS,
|
|
33
|
+
CODE_FENCE_PATTERN,
|
|
34
|
+
INLINE_CODE_PATTERN,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _get_file_extension(file_path: str) -> str:
|
|
39
|
+
_, extension = os.path.splitext(file_path)
|
|
40
|
+
return extension.lower()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def is_markdown_file(file_path: str) -> bool:
|
|
44
|
+
return _get_file_extension(file_path) in ALL_MARKDOWN_EXTENSIONS
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def is_comment_bearing_file(file_path: str) -> bool:
|
|
48
|
+
return _get_file_extension(file_path) in ALL_COMMENT_BEARING_EXTENSIONS
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _get_inline_markers(extension: str) -> tuple[str, ...]:
|
|
52
|
+
if extension in ALL_HASH_ONLY_EXTENSIONS:
|
|
53
|
+
return ("#",)
|
|
54
|
+
if extension in ALL_HASH_AND_SLASH_EXTENSIONS:
|
|
55
|
+
return ("#", "//")
|
|
56
|
+
if extension in ALL_BLOCK_COMMENT_ONLY_EXTENSIONS:
|
|
57
|
+
return ()
|
|
58
|
+
return ("//",)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _extract_comment_lines(text: str, extension: str = "") -> list[str]:
|
|
62
|
+
"""Extract comment lines from source code — Python (#), JS/TS/C/Rust/Go (//), and block comments."""
|
|
63
|
+
all_comment_lines: list[str] = []
|
|
64
|
+
all_lines = text.splitlines()
|
|
65
|
+
|
|
66
|
+
is_in_block_comment = False
|
|
67
|
+
has_block_comments = extension in ALL_BLOCK_COMMENT_EXTENSIONS
|
|
68
|
+
all_inline_markers = _get_inline_markers(extension)
|
|
69
|
+
for each_line in all_lines:
|
|
70
|
+
stripped = each_line.strip()
|
|
71
|
+
|
|
72
|
+
if has_block_comments:
|
|
73
|
+
if any(
|
|
74
|
+
stripped.startswith(each_marker)
|
|
75
|
+
for each_marker in all_inline_markers
|
|
76
|
+
):
|
|
77
|
+
all_comment_lines.append(stripped)
|
|
78
|
+
continue
|
|
79
|
+
if "/*" in stripped and not is_in_block_comment:
|
|
80
|
+
is_in_block_comment = True
|
|
81
|
+
slash_star_index = stripped.find("/*")
|
|
82
|
+
close_star_index = stripped.find("*/", slash_star_index + len("/*"))
|
|
83
|
+
if close_star_index >= 0:
|
|
84
|
+
all_comment_lines.append(
|
|
85
|
+
stripped[slash_star_index : close_star_index + 2]
|
|
86
|
+
)
|
|
87
|
+
is_in_block_comment = False
|
|
88
|
+
after_close = stripped[close_star_index + 2:].lstrip()
|
|
89
|
+
if not after_close:
|
|
90
|
+
continue
|
|
91
|
+
stripped = after_close
|
|
92
|
+
else:
|
|
93
|
+
all_comment_lines.append(stripped[slash_star_index:])
|
|
94
|
+
continue
|
|
95
|
+
if is_in_block_comment:
|
|
96
|
+
close_index = stripped.find("*/")
|
|
97
|
+
if close_index >= 0:
|
|
98
|
+
all_comment_lines.append(stripped[: close_index + 2])
|
|
99
|
+
is_in_block_comment = False
|
|
100
|
+
else:
|
|
101
|
+
all_comment_lines.append(stripped)
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
if any(
|
|
105
|
+
stripped.startswith(each_marker) for each_marker in all_inline_markers
|
|
106
|
+
):
|
|
107
|
+
all_comment_lines.append(stripped)
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
inline_index = _find_inline_comment_start(stripped, all_inline_markers)
|
|
111
|
+
if inline_index is not None and inline_index > 0:
|
|
112
|
+
all_comment_lines.append(stripped[inline_index:])
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
return all_comment_lines
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _find_inline_comment_start(stripped: str, all_markers: tuple[str, ...]) -> int | None:
|
|
119
|
+
"""Find the earliest inline comment marker in a code line, across all markers.
|
|
120
|
+
Skips // when preceded by : to avoid treating URLs as inline comments,
|
|
121
|
+
but continues searching for subsequent // that are actual comments."""
|
|
122
|
+
best_position: int | None = None
|
|
123
|
+
for each_marker in all_markers:
|
|
124
|
+
search_start = 0
|
|
125
|
+
while True:
|
|
126
|
+
position = stripped.find(each_marker, search_start)
|
|
127
|
+
if position <= 0:
|
|
128
|
+
break
|
|
129
|
+
if each_marker == "//" and stripped[position - 1] == ":":
|
|
130
|
+
search_start = position + 1
|
|
131
|
+
continue
|
|
132
|
+
if best_position is None or position < best_position:
|
|
133
|
+
best_position = position
|
|
134
|
+
break
|
|
135
|
+
return best_position
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def find_violations(text: str, file_path: str) -> list[str]:
|
|
139
|
+
"""Return all violated patterns found in text for the given file.
|
|
140
|
+
|
|
141
|
+
For .md files, scans the entire text. For code files, scans only comment lines.
|
|
142
|
+
Returns a list of matched pattern source strings.
|
|
143
|
+
"""
|
|
144
|
+
if is_markdown_file(file_path):
|
|
145
|
+
scan_text = text
|
|
146
|
+
elif is_comment_bearing_file(file_path):
|
|
147
|
+
all_comment_lines = _extract_comment_lines(text, _get_file_extension(file_path))
|
|
148
|
+
scan_text = "\n".join(all_comment_lines)
|
|
149
|
+
else:
|
|
150
|
+
return []
|
|
151
|
+
|
|
152
|
+
if is_markdown_file(file_path):
|
|
153
|
+
scan_text = CODE_FENCE_PATTERN.sub("", scan_text)
|
|
154
|
+
scan_text = INLINE_CODE_PATTERN.sub("", scan_text)
|
|
155
|
+
|
|
156
|
+
if not scan_text.strip():
|
|
157
|
+
return []
|
|
158
|
+
|
|
159
|
+
all_detected: list[str] = []
|
|
160
|
+
all_transition_patterns = ALL_COMMENT_TRANSITION_PATTERNS
|
|
161
|
+
for each_pattern in all_transition_patterns:
|
|
162
|
+
all_matches = each_pattern.findall(scan_text)
|
|
163
|
+
if all_matches:
|
|
164
|
+
all_detected.append(all_matches[0].strip().lower())
|
|
165
|
+
|
|
166
|
+
return all_detected
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def main() -> None:
|
|
170
|
+
try:
|
|
171
|
+
input_data = json.load(sys.stdin)
|
|
172
|
+
except json.JSONDecodeError:
|
|
173
|
+
sys.exit(0)
|
|
174
|
+
|
|
175
|
+
if not isinstance(input_data, dict):
|
|
176
|
+
sys.exit(0)
|
|
177
|
+
|
|
178
|
+
tool_name = input_data.get("tool_name", "")
|
|
179
|
+
if not isinstance(tool_name, str):
|
|
180
|
+
sys.exit(0)
|
|
181
|
+
|
|
182
|
+
tool_input = input_data.get("tool_input", {})
|
|
183
|
+
if not isinstance(tool_input, dict):
|
|
184
|
+
sys.exit(0)
|
|
185
|
+
|
|
186
|
+
if tool_name not in ("Write", "Edit"):
|
|
187
|
+
sys.exit(0)
|
|
188
|
+
|
|
189
|
+
file_path = tool_input.get("file_path", "")
|
|
190
|
+
if not file_path or not (
|
|
191
|
+
is_markdown_file(file_path) or is_comment_bearing_file(file_path)
|
|
192
|
+
):
|
|
193
|
+
sys.exit(0)
|
|
194
|
+
|
|
195
|
+
content_to_check = ""
|
|
196
|
+
if tool_name == "Write":
|
|
197
|
+
content_to_check = tool_input.get("content", "")
|
|
198
|
+
elif tool_name == "Edit":
|
|
199
|
+
content_to_check = tool_input.get("new_string", "")
|
|
200
|
+
|
|
201
|
+
if not content_to_check:
|
|
202
|
+
sys.exit(0)
|
|
203
|
+
|
|
204
|
+
all_detected_patterns = find_violations(content_to_check, file_path)
|
|
205
|
+
if not all_detected_patterns:
|
|
206
|
+
sys.exit(0)
|
|
207
|
+
|
|
208
|
+
formatted = ", ".join(f'"{p}"' for p in all_detected_patterns)
|
|
209
|
+
|
|
210
|
+
block_payload = {
|
|
211
|
+
"hookSpecificOutput": {
|
|
212
|
+
"hookEventName": "PreToolUse",
|
|
213
|
+
"permissionDecision": "deny",
|
|
214
|
+
"permissionDecisionReason": (
|
|
215
|
+
f"Historical/comparative language detected in {file_path}: "
|
|
216
|
+
f"{formatted}. Describe current state only — no 'instead of', "
|
|
217
|
+
f"'previously', 'now uses', etc. The git log tracks what changed. "
|
|
218
|
+
f"Comments and docs describe what IS."
|
|
219
|
+
),
|
|
220
|
+
"additionalContext": (
|
|
221
|
+
"Rewrite the affected comments or documentation to describe "
|
|
222
|
+
"only the current state. For example:\n"
|
|
223
|
+
' BAD: "Uses X instead of Y" → GOOD: "Uses X"\n'
|
|
224
|
+
' BAD: "Previously configured via Z" → GOOD: "Configured via Z"\n'
|
|
225
|
+
"See ~/.claude/rules/no-historical-clutter.md for full rules."
|
|
226
|
+
),
|
|
227
|
+
},
|
|
228
|
+
"systemMessage": "Agent wrote comparative/historical language - describe current state only",
|
|
229
|
+
"suppressOutput": True,
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
_emit_hook_result(block_payload, sys.stdout)
|
|
233
|
+
sys.exit(0)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _emit_hook_result(all_hook_data: dict, output_stream: io.TextIOBase) -> None:
|
|
237
|
+
"""Write the hook result JSON to the given output stream."""
|
|
238
|
+
output_stream.write(json.dumps(all_hook_data) + "\n")
|
|
239
|
+
output_stream.flush()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
if __name__ == "__main__":
|
|
243
|
+
main()
|
|
@@ -13,6 +13,7 @@ import sys
|
|
|
13
13
|
import time
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
|
|
16
|
+
|
|
16
17
|
_hooks_root_path_string = str(Path(__file__).resolve().parent.parent)
|
|
17
18
|
if _hooks_root_path_string not in sys.path:
|
|
18
19
|
sys.path.insert(0, _hooks_root_path_string)
|
|
@@ -58,6 +59,48 @@ def _is_module_docstring_expression(module_level_node: ast.stmt) -> bool:
|
|
|
58
59
|
return isinstance(expression_value.value, str)
|
|
59
60
|
|
|
60
61
|
|
|
62
|
+
def _safe_constant_functions() -> frozenset[str]:
|
|
63
|
+
"""Unqualified function names treated as safe value constructors."""
|
|
64
|
+
return frozenset({"Path", "frozenset"})
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _safe_constant_attribute_calls() -> frozenset[tuple[str, str]]:
|
|
68
|
+
"""(module, attr) pairs treated as safe value constructors."""
|
|
69
|
+
return frozenset({("re", "compile")})
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _rhs_has_unsafe_call(rhs_node: ast.AST) -> bool:
|
|
73
|
+
"""Return True when rhs_node contains a function call outside the safe allowlist.
|
|
74
|
+
|
|
75
|
+
Safe calls are value constructors (``Path(...)``, ``re.compile(...)``)
|
|
76
|
+
that create objects without side effects. Any other call pattern is
|
|
77
|
+
treated as unsafe import-time behavior.
|
|
78
|
+
"""
|
|
79
|
+
safe_functions = _safe_constant_functions()
|
|
80
|
+
safe_attribute_calls = _safe_constant_attribute_calls()
|
|
81
|
+
for each_subnode in ast.walk(rhs_node):
|
|
82
|
+
if isinstance(each_subnode, ast.Call):
|
|
83
|
+
function_node = each_subnode.func
|
|
84
|
+
if isinstance(function_node, ast.Name):
|
|
85
|
+
if function_node.id not in safe_functions:
|
|
86
|
+
return True
|
|
87
|
+
elif isinstance(function_node, ast.Attribute):
|
|
88
|
+
if isinstance(function_node.value, ast.Name):
|
|
89
|
+
pair = (function_node.value.id, function_node.attr)
|
|
90
|
+
if pair not in safe_attribute_calls:
|
|
91
|
+
return True
|
|
92
|
+
else:
|
|
93
|
+
return True
|
|
94
|
+
else:
|
|
95
|
+
return True
|
|
96
|
+
elif isinstance(
|
|
97
|
+
each_subnode,
|
|
98
|
+
(ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp, ast.Lambda),
|
|
99
|
+
):
|
|
100
|
+
return True
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
|
|
61
104
|
def _is_constants_only_python_content(content: str) -> bool:
|
|
62
105
|
if not content.strip():
|
|
63
106
|
return False
|
|
@@ -70,6 +113,10 @@ def _is_constants_only_python_content(content: str) -> bool:
|
|
|
70
113
|
allowed_node_types = _constants_only_allowed_node_types()
|
|
71
114
|
for each_top_level_node in parsed_tree.body:
|
|
72
115
|
if isinstance(each_top_level_node, allowed_node_types):
|
|
116
|
+
if isinstance(each_top_level_node, (ast.Assign, ast.AnnAssign)):
|
|
117
|
+
rhs = each_top_level_node.value
|
|
118
|
+
if rhs is not None and _rhs_has_unsafe_call(rhs):
|
|
119
|
+
return False
|
|
73
120
|
continue
|
|
74
121
|
if _is_module_docstring_expression(each_top_level_node):
|
|
75
122
|
continue
|
|
@@ -77,6 +124,44 @@ def _is_constants_only_python_content(content: str) -> bool:
|
|
|
77
124
|
return True
|
|
78
125
|
|
|
79
126
|
|
|
127
|
+
def _apply_edit_to_content(existing_content: str, old_str: str, new_str: str) -> str:
|
|
128
|
+
"""Replace the first occurrence of old_str with new_str in the content."""
|
|
129
|
+
return existing_content.replace(old_str, new_str, 1)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _is_post_edit_constants_only(existing_content: str, tool_name: str, tool_input: dict) -> bool:
|
|
133
|
+
"""Check if post-edit content remains constants-only after Edit or MultiEdit.
|
|
134
|
+
|
|
135
|
+
Both the existing content and the post-edit result must be constants-only
|
|
136
|
+
to prevent edits on files with behavior from bypassing the TDD gate.
|
|
137
|
+
"""
|
|
138
|
+
if not _is_constants_only_python_content(existing_content):
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
if tool_name == "Edit":
|
|
142
|
+
old_str = tool_input.get("old_string", "")
|
|
143
|
+
new_str = tool_input.get("new_string", "") or ""
|
|
144
|
+
if not old_str:
|
|
145
|
+
return False
|
|
146
|
+
post_edit_content = _apply_edit_to_content(existing_content, old_str, new_str)
|
|
147
|
+
return _is_constants_only_python_content(post_edit_content)
|
|
148
|
+
|
|
149
|
+
if tool_name == "MultiEdit":
|
|
150
|
+
all_edits = tool_input.get("edits", []) or []
|
|
151
|
+
post_edit_content = existing_content
|
|
152
|
+
for each_edit in all_edits:
|
|
153
|
+
if not isinstance(each_edit, dict):
|
|
154
|
+
return False
|
|
155
|
+
each_old = each_edit.get("old_string", "")
|
|
156
|
+
each_new = each_edit.get("new_string", "") or ""
|
|
157
|
+
if not each_old:
|
|
158
|
+
return False
|
|
159
|
+
post_edit_content = _apply_edit_to_content(post_edit_content, each_old, each_new)
|
|
160
|
+
return _is_constants_only_python_content(post_edit_content)
|
|
161
|
+
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
|
|
80
165
|
def _tests_directory_name() -> str:
|
|
81
166
|
return "tests"
|
|
82
167
|
|
|
@@ -297,11 +382,20 @@ def main() -> None:
|
|
|
297
382
|
sys.exit(0)
|
|
298
383
|
|
|
299
384
|
# Block production code - require confirmation
|
|
385
|
+
# Exempt constants-only content for Write (full content provided)
|
|
300
386
|
written_content = _extract_written_content(tool_name, tool_input)
|
|
301
387
|
if tool_name == "Write" and ext == ".py" and _is_constants_only_python_content(written_content):
|
|
302
388
|
emit_allow()
|
|
303
389
|
sys.exit(0)
|
|
304
390
|
|
|
391
|
+
# Exempt Edit/MultiEdit on constants-only files when post-edit content remains constants-only
|
|
392
|
+
if tool_name in ("Edit", "MultiEdit") and ext == ".py" and path.exists():
|
|
393
|
+
existing_content = _read_candidate_text(path)
|
|
394
|
+
if existing_content is not None:
|
|
395
|
+
if _is_post_edit_constants_only(existing_content, tool_name, tool_input):
|
|
396
|
+
emit_allow()
|
|
397
|
+
sys.exit(0)
|
|
398
|
+
|
|
305
399
|
all_candidates = candidate_test_paths_for(path)
|
|
306
400
|
if has_fresh_test(all_candidates, _freshness_seconds()):
|
|
307
401
|
emit_allow()
|
|
@@ -97,7 +97,7 @@ def test_hedging_reason_contains_not_installed_notice_when_skill_absent():
|
|
|
97
97
|
|
|
98
98
|
assert parsed_response["decision"] == "block"
|
|
99
99
|
assert "no research-mode skill installed" in parsed_response["reason"]
|
|
100
|
-
assert "verify with sources or
|
|
100
|
+
assert "verify with sources or prompt the user via AskUserQuestion" in parsed_response["reason"]
|
|
101
101
|
assert "SKILL.md" not in parsed_response["reason"]
|
|
102
102
|
assert RESEARCH_MODE_SKILL_BODY_MARKER not in parsed_response["reason"]
|
|
103
103
|
|