claude-dev-env 1.37.0 → 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.
Files changed (95) hide show
  1. package/CLAUDE.md +3 -0
  2. package/_shared/pr-loop/audit-contract.md +4 -3
  3. package/_shared/pr-loop/fix-protocol.md +2 -0
  4. package/_shared/pr-loop/gh-payloads.md +38 -37
  5. package/_shared/pr-loop/scripts/README.md +0 -1
  6. package/_shared/pr-loop/scripts/preflight.py +2 -1
  7. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +2 -2
  8. package/_shared/pr-loop/scripts/tests/test_preflight.py +22 -0
  9. package/_shared/pr-loop/state-schema.md +10 -10
  10. package/agents/clean-coder.md +4 -0
  11. package/agents/code-quality-agent.md +23 -85
  12. package/agents/groq-coder.md +8 -6
  13. package/hooks/blocking/__init__.py +0 -0
  14. package/hooks/blocking/hedging_language_blocker.py +2 -2
  15. package/hooks/blocking/state_description_blocker.py +243 -0
  16. package/hooks/blocking/tdd_enforcer.py +94 -0
  17. package/hooks/blocking/test_hedging_language_blocker.py +1 -1
  18. package/hooks/blocking/test_state_description_blocker.py +618 -0
  19. package/hooks/blocking/test_tdd_enforcer.py +152 -0
  20. package/hooks/config/state_description_blocker_constants.py +130 -0
  21. package/hooks/hooks.json +10 -0
  22. package/package.json +1 -1
  23. package/rules/gh-paginate.md +4 -50
  24. package/rules/no-historical-clutter.md +57 -0
  25. package/scripts/config/groq_bugteam_config.py +13 -5
  26. package/skills/bugteam/CONSTRAINTS.md +20 -27
  27. package/skills/bugteam/EXAMPLES.md +1 -1
  28. package/skills/bugteam/PROMPTS.md +78 -42
  29. package/skills/bugteam/SKILL.md +76 -63
  30. package/skills/bugteam/SKILL_EVALS.md +12 -12
  31. package/skills/bugteam/reference/audit-and-teammates.md +21 -48
  32. package/skills/bugteam/reference/audit-contract.md +7 -7
  33. package/skills/bugteam/reference/github-pr-reviews.md +31 -31
  34. package/skills/bugteam/reference/team-setup.md +1 -1
  35. package/skills/bugteam/reference/teardown-publish-permissions.md +4 -4
  36. package/skills/copilot-review/SKILL.md +7 -14
  37. package/skills/findbugs/SKILL.md +2 -2
  38. package/skills/fixbugs/SKILL.md +1 -1
  39. package/skills/monitor-open-prs/SKILL.md +6 -6
  40. package/skills/pr-converge/SKILL.md +7 -6
  41. package/skills/pr-converge/reference/convergence-gates.md +46 -44
  42. package/skills/pr-converge/reference/examples.md +4 -4
  43. package/skills/pr-converge/reference/fix-protocol.md +8 -8
  44. package/skills/pr-converge/reference/multi-pr-orchestration.md +10 -10
  45. package/skills/pr-converge/reference/per-tick.md +24 -36
  46. package/skills/pr-converge/reference/stop-conditions.md +7 -7
  47. package/skills/pr-converge/scripts/README.md +65 -117
  48. package/skills/pr-review-responder/EXAMPLES.md +2 -2
  49. package/skills/pr-review-responder/PRINCIPLES.md +2 -8
  50. package/skills/pr-review-responder/README.md +7 -48
  51. package/skills/pr-review-responder/SKILL.md +2 -3
  52. package/skills/pr-review-responder/TESTING.md +8 -65
  53. package/skills/qbug/SKILL.md +10 -16
  54. package/_shared/pr-loop/scripts/config/gh_util_constants.py +0 -31
  55. package/_shared/pr-loop/scripts/gh_util.py +0 -193
  56. package/_shared/pr-loop/scripts/tests/test_gh_util.py +0 -257
  57. package/_shared/pr-loop/scripts/tests/test_gh_util_constants.py +0 -61
  58. package/skills/pr-converge/scripts/check_pr_mergeability.py +0 -78
  59. package/skills/pr-converge/scripts/config/pr_converge_constants.py +0 -118
  60. package/skills/pr-converge/scripts/config/test_pr_converge_constants.py +0 -152
  61. package/skills/pr-converge/scripts/fetch_bugbot_inline_comments.py +0 -70
  62. package/skills/pr-converge/scripts/fetch_bugbot_reviews.py +0 -57
  63. package/skills/pr-converge/scripts/fetch_claude_inline_comments.py +0 -70
  64. package/skills/pr-converge/scripts/fetch_claude_reviews.py +0 -61
  65. package/skills/pr-converge/scripts/fetch_copilot_inline_comments.py +0 -70
  66. package/skills/pr-converge/scripts/fetch_copilot_reviews.py +0 -61
  67. package/skills/pr-converge/scripts/mark_pr_ready.py +0 -54
  68. package/skills/pr-converge/scripts/post-bugbot-run.helpers.ps1 +0 -49
  69. package/skills/pr-converge/scripts/post-bugbot-run.ps1 +0 -33
  70. package/skills/pr-converge/scripts/reply_to_inline_comment.py +0 -84
  71. package/skills/pr-converge/scripts/request_copilot_review.py +0 -71
  72. package/skills/pr-converge/scripts/resolve_pr_head.py +0 -58
  73. package/skills/pr-converge/scripts/review_field_helpers.py +0 -43
  74. package/skills/pr-converge/scripts/reviewer_fetch_core.py +0 -153
  75. package/skills/pr-converge/scripts/reviewer_specs.py +0 -98
  76. package/skills/pr-converge/scripts/test_check_pr_mergeability.py +0 -126
  77. package/skills/pr-converge/scripts/test_fetch_bugbot_inline_comments.py +0 -443
  78. package/skills/pr-converge/scripts/test_fetch_bugbot_reviews.py +0 -299
  79. package/skills/pr-converge/scripts/test_fetch_claude_inline_comments.py +0 -485
  80. package/skills/pr-converge/scripts/test_fetch_claude_reviews.py +0 -368
  81. package/skills/pr-converge/scripts/test_fetch_copilot_inline_comments.py +0 -440
  82. package/skills/pr-converge/scripts/test_fetch_copilot_reviews.py +0 -366
  83. package/skills/pr-converge/scripts/test_mark_pr_ready.py +0 -69
  84. package/skills/pr-converge/scripts/test_post_bugbot_run.py +0 -195
  85. package/skills/pr-converge/scripts/test_reply_to_inline_comment.py +0 -159
  86. package/skills/pr-converge/scripts/test_request_copilot_review.py +0 -101
  87. package/skills/pr-converge/scripts/test_resolve_pr_head.py +0 -79
  88. package/skills/pr-converge/scripts/test_review_field_helpers.py +0 -80
  89. package/skills/pr-converge/scripts/test_reviewer_fetch_core.py +0 -448
  90. package/skills/pr-converge/scripts/test_reviewer_specs.py +0 -107
  91. package/skills/pr-converge/scripts/test_trigger_bugbot.py +0 -139
  92. package/skills/pr-converge/scripts/test_view_pr_context.py +0 -111
  93. package/skills/pr-converge/scripts/trigger_bugbot.py +0 -77
  94. package/skills/pr-converge/scripts/view_pr_context.py +0 -47
  95. 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 reply" in parsed_response["reason"]
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