claude-dev-env 1.37.1 → 1.38.1

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 (94) 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/code_rules_enforcer.py +93 -32
  15. package/hooks/blocking/hedging_language_blocker.py +2 -2
  16. package/hooks/blocking/state_description_blocker.py +243 -0
  17. package/hooks/blocking/tdd_enforcer.py +94 -0
  18. package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +158 -0
  19. package/hooks/blocking/test_hedging_language_blocker.py +1 -1
  20. package/hooks/blocking/test_state_description_blocker.py +618 -0
  21. package/hooks/blocking/test_tdd_enforcer.py +152 -0
  22. package/hooks/config/state_description_blocker_constants.py +130 -0
  23. package/hooks/hooks.json +10 -0
  24. package/package.json +1 -1
  25. package/rules/no-historical-clutter.md +31 -10
  26. package/scripts/config/groq_bugteam_config.py +13 -5
  27. package/skills/bugteam/CONSTRAINTS.md +20 -27
  28. package/skills/bugteam/EXAMPLES.md +1 -1
  29. package/skills/bugteam/PROMPTS.md +60 -31
  30. package/skills/bugteam/SKILL.md +47 -47
  31. package/skills/bugteam/SKILL_EVALS.md +8 -8
  32. package/skills/bugteam/reference/github-pr-reviews.md +31 -31
  33. package/skills/bugteam/reference/team-setup.md +1 -1
  34. package/skills/bugteam/reference/teardown-publish-permissions.md +4 -4
  35. package/skills/copilot-review/SKILL.md +7 -14
  36. package/skills/findbugs/SKILL.md +2 -2
  37. package/skills/fixbugs/SKILL.md +1 -1
  38. package/skills/monitor-open-prs/SKILL.md +6 -6
  39. package/skills/pr-converge/SKILL.md +7 -6
  40. package/skills/pr-converge/reference/convergence-gates.md +28 -30
  41. package/skills/pr-converge/reference/examples.md +4 -4
  42. package/skills/pr-converge/reference/fix-protocol.md +6 -8
  43. package/skills/pr-converge/reference/multi-pr-orchestration.md +10 -10
  44. package/skills/pr-converge/reference/per-tick.md +18 -33
  45. package/skills/pr-converge/reference/stop-conditions.md +7 -7
  46. package/skills/pr-converge/scripts/README.md +65 -117
  47. package/skills/pr-review-responder/EXAMPLES.md +2 -2
  48. package/skills/pr-review-responder/PRINCIPLES.md +2 -8
  49. package/skills/pr-review-responder/README.md +7 -48
  50. package/skills/pr-review-responder/SKILL.md +2 -3
  51. package/skills/pr-review-responder/TESTING.md +8 -65
  52. package/skills/qbug/SKILL.md +10 -16
  53. package/_shared/pr-loop/scripts/config/gh_util_constants.py +0 -31
  54. package/_shared/pr-loop/scripts/gh_util.py +0 -193
  55. package/_shared/pr-loop/scripts/tests/test_gh_util.py +0 -257
  56. package/_shared/pr-loop/scripts/tests/test_gh_util_constants.py +0 -61
  57. package/skills/pr-converge/scripts/check_pr_mergeability.py +0 -78
  58. package/skills/pr-converge/scripts/config/pr_converge_constants.py +0 -134
  59. package/skills/pr-converge/scripts/config/test_pr_converge_constants.py +0 -152
  60. package/skills/pr-converge/scripts/fetch_bugbot_inline_comments.py +0 -70
  61. package/skills/pr-converge/scripts/fetch_bugbot_reviews.py +0 -57
  62. package/skills/pr-converge/scripts/fetch_claude_inline_comments.py +0 -70
  63. package/skills/pr-converge/scripts/fetch_claude_reviews.py +0 -61
  64. package/skills/pr-converge/scripts/fetch_copilot_inline_comments.py +0 -70
  65. package/skills/pr-converge/scripts/fetch_copilot_reviews.py +0 -61
  66. package/skills/pr-converge/scripts/mark_pr_ready.py +0 -54
  67. package/skills/pr-converge/scripts/post-bugbot-run.helpers.ps1 +0 -49
  68. package/skills/pr-converge/scripts/post-bugbot-run.ps1 +0 -33
  69. package/skills/pr-converge/scripts/reply_to_inline_comment.py +0 -84
  70. package/skills/pr-converge/scripts/request_copilot_review.py +0 -71
  71. package/skills/pr-converge/scripts/resolve_pr_head.py +0 -58
  72. package/skills/pr-converge/scripts/review_field_helpers.py +0 -43
  73. package/skills/pr-converge/scripts/reviewer_fetch_core.py +0 -153
  74. package/skills/pr-converge/scripts/reviewer_specs.py +0 -98
  75. package/skills/pr-converge/scripts/test_check_pr_mergeability.py +0 -126
  76. package/skills/pr-converge/scripts/test_fetch_bugbot_inline_comments.py +0 -443
  77. package/skills/pr-converge/scripts/test_fetch_bugbot_reviews.py +0 -299
  78. package/skills/pr-converge/scripts/test_fetch_claude_inline_comments.py +0 -485
  79. package/skills/pr-converge/scripts/test_fetch_claude_reviews.py +0 -368
  80. package/skills/pr-converge/scripts/test_fetch_copilot_inline_comments.py +0 -440
  81. package/skills/pr-converge/scripts/test_fetch_copilot_reviews.py +0 -366
  82. package/skills/pr-converge/scripts/test_mark_pr_ready.py +0 -69
  83. package/skills/pr-converge/scripts/test_post_bugbot_run.py +0 -195
  84. package/skills/pr-converge/scripts/test_reply_to_inline_comment.py +0 -159
  85. package/skills/pr-converge/scripts/test_request_copilot_review.py +0 -101
  86. package/skills/pr-converge/scripts/test_resolve_pr_head.py +0 -79
  87. package/skills/pr-converge/scripts/test_review_field_helpers.py +0 -80
  88. package/skills/pr-converge/scripts/test_reviewer_fetch_core.py +0 -448
  89. package/skills/pr-converge/scripts/test_reviewer_specs.py +0 -107
  90. package/skills/pr-converge/scripts/test_trigger_bugbot.py +0 -139
  91. package/skills/pr-converge/scripts/test_view_pr_context.py +0 -155
  92. package/skills/pr-converge/scripts/trigger_bugbot.py +0 -77
  93. package/skills/pr-converge/scripts/view_pr_context.py +0 -78
  94. package/skills/pr-review-responder/scripts/respond_to_reviews.py +0 -376
@@ -2580,7 +2580,30 @@ def _collect_load_names_outside_import_ranges(
2580
2580
  return referenced_names
2581
2581
 
2582
2582
 
2583
- def check_unused_module_level_imports(content: str, file_path: str) -> list[str]:
2583
+ def _module_declares_dunder_all(tree: ast.Module) -> bool:
2584
+ """Return True when the module body assigns or annotates ``__all__``."""
2585
+ return any(
2586
+ (
2587
+ isinstance(each_node, ast.Assign)
2588
+ and any(
2589
+ isinstance(each_target, ast.Name) and each_target.id == "__all__"
2590
+ for each_target in each_node.targets
2591
+ )
2592
+ )
2593
+ or (
2594
+ isinstance(each_node, ast.AnnAssign)
2595
+ and isinstance(each_node.target, ast.Name)
2596
+ and each_node.target.id == "__all__"
2597
+ )
2598
+ for each_node in tree.body
2599
+ )
2600
+
2601
+
2602
+ def check_unused_module_level_imports(
2603
+ content: str,
2604
+ file_path: str,
2605
+ full_file_content: str | None = None,
2606
+ ) -> list[str]:
2584
2607
  """Flag module-level imports that are never referenced in the rest of the file.
2585
2608
 
2586
2609
  References are detected from AST ``Name`` / ``Attribute`` loads outside import
@@ -2589,42 +2612,39 @@ def check_unused_module_level_imports(content: str, file_path: str) -> list[str]
2589
2612
  whose module body includes ``if TYPE_CHECKING:`` (or
2590
2613
  ``typing[._extensions].TYPE_CHECKING``) are skipped. Suppression honors bare
2591
2614
  ``# noqa`` or an explicit ``F401`` code in the noqa list only.
2615
+
2616
+ When ``full_file_content`` is provided, ``content`` is treated as an Edit
2617
+ fragment containing the imports being added or replaced, while the
2618
+ ``__all__`` / ``TYPE_CHECKING`` gate detection and reference scanning run
2619
+ against ``full_file_content`` (the post-edit file as it will look once the
2620
+ Edit applies). This prevents false-positive flags on imports added in the
2621
+ same Edit as their consumers.
2592
2622
  """
2593
2623
  if is_test_file(file_path):
2594
2624
  return []
2595
2625
  if is_workflow_registry_file(file_path) or is_migration_file(file_path):
2596
2626
  return []
2597
2627
  try:
2598
- tree = ast.parse(content)
2628
+ fragment_tree = ast.parse(content)
2599
2629
  except SyntaxError:
2600
2630
  return []
2601
- file_declares_dunder_all = any(
2602
- (
2603
- isinstance(each_node, ast.Assign)
2604
- and any(
2605
- isinstance(each_target, ast.Name) and each_target.id == "__all__"
2606
- for each_target in each_node.targets
2607
- )
2608
- )
2609
- or (
2610
- isinstance(each_node, ast.AnnAssign)
2611
- and isinstance(each_node.target, ast.Name)
2612
- and each_node.target.id == "__all__"
2613
- )
2614
- for each_node in tree.body
2615
- )
2616
- if file_declares_dunder_all:
2631
+ reference_source = full_file_content if full_file_content is not None else content
2632
+ try:
2633
+ reference_tree = ast.parse(reference_source)
2634
+ except SyntaxError:
2635
+ return []
2636
+ if _module_declares_dunder_all(reference_tree):
2617
2637
  return []
2618
- if _module_body_declares_type_checking_gate(tree):
2638
+ if _module_body_declares_type_checking_gate(reference_tree):
2619
2639
  return []
2620
- content_lines = content.splitlines()
2621
- import_line_ranges = _import_statement_line_ranges(tree)
2640
+ fragment_lines = content.splitlines()
2641
+ reference_import_ranges = _import_statement_line_ranges(reference_tree)
2622
2642
  referenced_names = _collect_load_names_outside_import_ranges(
2623
- tree,
2624
- import_line_ranges,
2643
+ reference_tree,
2644
+ reference_import_ranges,
2625
2645
  )
2626
2646
  import_bindings: list[tuple[str, int, int | None]] = []
2627
- for each_node in tree.body:
2647
+ for each_node in fragment_tree.body:
2628
2648
  if isinstance(each_node, (ast.Import, ast.ImportFrom)):
2629
2649
  if isinstance(each_node, ast.ImportFrom) and each_node.module == "__future__":
2630
2650
  continue
@@ -2632,14 +2652,14 @@ def check_unused_module_level_imports(content: str, file_path: str) -> list[str]
2632
2652
  import_bindings.append(each_binding)
2633
2653
  issues: list[str] = []
2634
2654
  for each_name, each_line_number, each_from_keyword_line in import_bindings:
2635
- if 1 <= each_line_number <= len(content_lines):
2636
- if line_suppresses_unused_import_via_noqa(content_lines[each_line_number - 1]):
2655
+ if 1 <= each_line_number <= len(fragment_lines):
2656
+ if line_suppresses_unused_import_via_noqa(fragment_lines[each_line_number - 1]):
2637
2657
  continue
2638
2658
  if each_from_keyword_line is not None and 1 <= each_from_keyword_line <= len(
2639
- content_lines
2659
+ fragment_lines
2640
2660
  ):
2641
2661
  if line_suppresses_unused_import_via_noqa(
2642
- content_lines[each_from_keyword_line - 1]
2662
+ fragment_lines[each_from_keyword_line - 1]
2643
2663
  ):
2644
2664
  continue
2645
2665
  if each_name in referenced_names:
@@ -2904,14 +2924,25 @@ def check_return_annotations(content: str, file_path: str) -> list[str]:
2904
2924
  return issues
2905
2925
 
2906
2926
 
2907
- def validate_content(content: str, file_path: str, old_content: str = "") -> list[str]:
2927
+ def validate_content(
2928
+ content: str,
2929
+ file_path: str,
2930
+ old_content: str = "",
2931
+ full_file_content: str | None = None,
2932
+ ) -> list[str]:
2908
2933
  """Run all applicable validators on content.
2909
2934
 
2910
2935
  Args:
2911
- content: The new content being written.
2936
+ content: The new content being written. For Edit, this is the
2937
+ ``new_string`` fragment; for Write, the entire new file body.
2912
2938
  file_path: Path to the file.
2913
2939
  old_content: Previous content (old_string for Edit, existing file for Write).
2914
2940
  Used to detect comment additions/removals instead of flagging all comments.
2941
+ full_file_content: For Edit operations, the reconstructed post-edit
2942
+ content of the entire file (existing file with ``old_string`` replaced
2943
+ by ``new_string``). Whole-file checks such as the unused-import
2944
+ scanner use this to evaluate references across the file rather than
2945
+ just within the inserted fragment.
2915
2946
  """
2916
2947
  extension = get_file_extension(file_path)
2917
2948
  all_issues = []
@@ -2938,7 +2969,9 @@ def validate_content(content: str, file_path: str, old_content: str = "") -> lis
2938
2969
  all_issues.extend(check_stuttering_collection_prefix(content, file_path))
2939
2970
  all_issues.extend(check_hardcoded_user_paths(content, file_path))
2940
2971
  all_issues.extend(check_sys_path_insert_deduplication_guard(content, file_path))
2941
- all_issues.extend(check_unused_module_level_imports(content, file_path))
2972
+ all_issues.extend(
2973
+ check_unused_module_level_imports(content, file_path, full_file_content)
2974
+ )
2942
2975
  all_issues.extend(check_library_print(content, file_path))
2943
2976
  all_issues.extend(check_parameter_annotations(content, file_path))
2944
2977
  all_issues.extend(check_return_annotations(content, file_path))
@@ -2959,6 +2992,30 @@ def validate_content(content: str, file_path: str, old_content: str = "") -> lis
2959
2992
  return all_issues
2960
2993
 
2961
2994
 
2995
+ def _reconstruct_post_edit_file_content(
2996
+ file_path: str, old_string: str, new_string: str,
2997
+ ) -> str | None:
2998
+ """Return the file content as it will look after the Edit applies, or None.
2999
+
3000
+ Reads ``file_path`` from disk and replaces the first occurrence of
3001
+ ``old_string`` with ``new_string``, mirroring how the Edit tool itself
3002
+ applies a single replacement. Returns None when the file cannot be read,
3003
+ ``old_string`` is empty, or ``old_string`` is not present in the existing
3004
+ file (which means the Edit will fail or has already been applied — neither
3005
+ case yields a well-defined post-edit view).
3006
+ """
3007
+ if not old_string:
3008
+ return None
3009
+ try:
3010
+ with open(file_path, "r", encoding="utf-8") as existing_file:
3011
+ existing_content = existing_file.read()
3012
+ except (FileNotFoundError, OSError, UnicodeDecodeError):
3013
+ return None
3014
+ if old_string not in existing_content:
3015
+ return None
3016
+ return existing_content.replace(old_string, new_string, 1)
3017
+
3018
+
2962
3019
  def main() -> None:
2963
3020
  try:
2964
3021
  input_data = json.load(sys.stdin)
@@ -2980,9 +3037,13 @@ def main() -> None:
2980
3037
  sys.exit(0)
2981
3038
 
2982
3039
  old_content = ""
3040
+ full_file_content_after_edit: str | None = None
2983
3041
  if tool_name == "Edit":
2984
3042
  content = tool_input.get("new_string", "")
2985
3043
  old_content = tool_input.get("old_string", "")
3044
+ full_file_content_after_edit = _reconstruct_post_edit_file_content(
3045
+ file_path, old_content, content,
3046
+ )
2986
3047
  else:
2987
3048
  content = tool_input.get("content", "") or tool_input.get("new_string", "")
2988
3049
  try:
@@ -2997,7 +3058,7 @@ def main() -> None:
2997
3058
  if not content:
2998
3059
  sys.exit(0)
2999
3060
 
3000
- issues = validate_content(content, file_path, old_content)
3061
+ issues = validate_content(content, file_path, old_content, full_file_content_after_edit)
3001
3062
 
3002
3063
  if issues:
3003
3064
  issue_list = "; ".join(issues[:10])
@@ -123,7 +123,7 @@ def main() -> None:
123
123
  else:
124
124
  skill_reference = (
125
125
  "under research-mode constraints "
126
- "(no research-mode skill installed; verify with sources or reply 'I don't know')"
126
+ "(no research-mode skill installed; verify with sources or prompt the user via AskUserQuestion with potential options + context)"
127
127
  )
128
128
 
129
129
  block_response = {
@@ -134,7 +134,7 @@ def main() -> None:
134
134
  f"These words signal unverified claims. You MUST rewrite your response "
135
135
  f"{skill_reference}\n\n"
136
136
  f"Do NOT simply remove the hedging word and keep the unverified claim. "
137
- f"Either VERIFY it with a source or replace it with 'I don't know'.\n\n"
137
+ f"Do more research to VERIFY it with a source, or prompt the user via AskUserQuestion with some potential options + context if you are unable to find anything online.\n\n"
138
138
  f"You MUST re-output the complete, revised response with the corrections applied."
139
139
  ),
140
140
  "systemMessage": USER_FACING_NOTICE,
@@ -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()