claude-dev-env 1.73.0 → 1.75.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 (105) hide show
  1. package/CLAUDE.md +2 -0
  2. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  3. package/hooks/blocking/CLAUDE.md +4 -0
  4. package/hooks/blocking/block_main_commit.py +14 -0
  5. package/hooks/blocking/bot_mention_comment_blocker.py +7 -0
  6. package/hooks/blocking/claude_md_orphan_file_blocker.py +14 -42
  7. package/hooks/blocking/code_rules_docstrings.py +223 -0
  8. package/hooks/blocking/code_rules_enforcer.py +16 -0
  9. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +12 -5
  10. package/hooks/blocking/convergence_gate_blocker.py +17 -3
  11. package/hooks/blocking/destructive_command_blocker.py +7 -0
  12. package/hooks/blocking/docstring_rule_gate_count_blocker.py +321 -0
  13. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +155 -0
  14. package/hooks/blocking/gh_body_arg_blocker.py +8 -0
  15. package/hooks/blocking/gh_pr_author_enforcer.py +7 -0
  16. package/hooks/blocking/hedging_language_blocker.py +17 -23
  17. package/hooks/blocking/hook_prose_detector_consistency.py +7 -0
  18. package/hooks/blocking/intent_only_ending_blocker.py +18 -26
  19. package/hooks/blocking/md_to_html_blocker.py +10 -2
  20. package/hooks/blocking/open_questions_in_plans_blocker.py +10 -2
  21. package/hooks/blocking/package_inventory_stale_blocker.py +398 -0
  22. package/hooks/blocking/plain_language_blocker.py +6 -0
  23. package/hooks/blocking/pr_converge_bugteam_enforcer.py +6 -0
  24. package/hooks/blocking/pr_description_enforcer.py +6 -0
  25. package/hooks/blocking/pre_tool_use_dispatcher.py +5 -6
  26. package/hooks/blocking/precommit_code_rules_gate.py +10 -1
  27. package/hooks/blocking/pytest_testpaths_orphan_blocker.py +8 -0
  28. package/hooks/blocking/question_to_user_enforcer.py +19 -23
  29. package/hooks/blocking/send_user_file_open_locally_blocker.py +70 -0
  30. package/hooks/blocking/sensitive_file_protector.py +15 -1
  31. package/hooks/blocking/session_handoff_blocker.py +15 -23
  32. package/hooks/blocking/state_description_blocker.py +6 -0
  33. package/hooks/blocking/subprocess_budget_completeness.py +9 -3
  34. package/hooks/blocking/tdd_enforcer.py +6 -0
  35. package/hooks/blocking/test_code_rules_enforcer_docstring_returns_plural_cardinality.py +207 -0
  36. package/hooks/blocking/test_code_rules_enforcer_docstring_unguarded_payload.py +188 -0
  37. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +61 -0
  38. package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +203 -0
  39. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +328 -0
  40. package/hooks/blocking/test_hedging_language_blocker.py +6 -0
  41. package/hooks/blocking/test_hook_block_logger_coverage.py +53 -0
  42. package/hooks/blocking/test_intent_only_ending_blocker.py +5 -0
  43. package/hooks/blocking/test_package_inventory_stale_blocker.py +329 -0
  44. package/hooks/blocking/test_plain_language_blocker.py +36 -0
  45. package/hooks/blocking/test_pre_tool_use_dispatcher.py +55 -8
  46. package/hooks/blocking/test_question_to_user_enforcer.py +6 -0
  47. package/hooks/blocking/test_send_user_file_open_locally_blocker.py +114 -0
  48. package/hooks/blocking/test_session_handoff_blocker.py +6 -0
  49. package/hooks/blocking/test_shared_stdin_adoption.py +42 -0
  50. package/hooks/blocking/test_state_description_blocker.py +41 -0
  51. package/hooks/blocking/test_verdict_directory_write_blocker.py +49 -0
  52. package/hooks/blocking/test_workflow_substitution_slot_blocker.py +4 -19
  53. package/hooks/blocking/verdict_directory_write_blocker.py +10 -1
  54. package/hooks/blocking/verified_commit_gate.py +11 -0
  55. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +16 -1
  56. package/hooks/blocking/windows_rmtree_blocker.py +7 -0
  57. package/hooks/blocking/workflow_substitution_slot_blocker.py +10 -5
  58. package/hooks/blocking/write_existing_file_blocker.py +16 -1
  59. package/hooks/hooks.json +10 -0
  60. package/hooks/hooks_constants/CLAUDE.md +8 -1
  61. package/hooks/hooks_constants/blocking_check_limits.py +13 -0
  62. package/hooks/hooks_constants/code_rules_enforcer_constants.py +3 -0
  63. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +2 -1
  64. package/hooks/hooks_constants/docstring_rule_gate_count_blocker_constants.py +90 -0
  65. package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +27 -0
  66. package/hooks/hooks_constants/hook_block_logger.py +59 -0
  67. package/hooks/hooks_constants/multi_edit_reconstruction.py +56 -0
  68. package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +111 -0
  69. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +3 -2
  70. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +17 -3
  71. package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +18 -0
  72. package/hooks/hooks_constants/test_dispatcher_constants_docstrings.py +44 -0
  73. package/hooks/hooks_constants/test_hook_block_logger.py +159 -0
  74. package/hooks/hooks_constants/test_post_tool_use_dispatcher_constants.py +43 -0
  75. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +99 -0
  76. package/hooks/hooks_constants/test_text_stripping.py +39 -0
  77. package/hooks/hooks_constants/text_stripping.py +36 -0
  78. package/hooks/lifecycle/config_change_guard.py +12 -0
  79. package/hooks/lifecycle/test_config_change_guard.py +23 -0
  80. package/hooks/validation/CLAUDE.md +1 -0
  81. package/hooks/validation/hook_format_validator.py +13 -0
  82. package/hooks/validation/mypy_validator.py +30 -1
  83. package/hooks/validation/post_tool_use_dispatcher.py +2 -2
  84. package/hooks/validation/test_hook_format_validator.py +64 -0
  85. package/hooks/validation/test_mypy_validator.py +23 -1
  86. package/hooks/validation/test_post_tool_use_dispatcher.py +6 -0
  87. package/hooks/workflow/auto_formatter.py +8 -5
  88. package/hooks/workflow/test_auto_formatter.py +33 -0
  89. package/package.json +1 -1
  90. package/rules/CLAUDE.md +1 -0
  91. package/rules/docstring-prose-matches-implementation.md +2 -1
  92. package/rules/package-inventory-stale-entry.md +24 -0
  93. package/rules/windows-filesystem-safe.md +2 -0
  94. package/skills/autoconverge/SKILL.md +21 -1
  95. package/skills/autoconverge/reference/stop-conditions.md +7 -0
  96. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +5 -4
  97. package/skills/autoconverge/workflow/converge.contract.test.mjs +398 -116
  98. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +16 -16
  99. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +36 -44
  100. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +16 -24
  101. package/skills/autoconverge/workflow/converge.mjs +599 -606
  102. package/skills/autoconverge/workflow/convergence_summary.py +1 -1
  103. package/skills/autoconverge/workflow/render_report.py +2 -6
  104. package/skills/autoconverge/workflow/test_convergence_summary.py +17 -0
  105. package/skills/autoconverge/workflow/test_render_report.py +1 -0
@@ -0,0 +1,321 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook: blocks a stale gate-validator count in the docstring-prose rule.
3
+
4
+ The rule ``docstring-prose-matches-implementation.md`` enumerates the
5
+ ``check_docstring_*`` gate validators that cover deterministic slices of docstring
6
+ prose, both as a spelled-out count ("Four more gate validators", "five gated
7
+ slices") and as a backticked list of the validator names. When a new gate
8
+ validator is registered but the count word is left unchanged, the rule's stated
9
+ count drifts from the validators it actually names — the same companion-doc drift
10
+ the rule itself governs. This hook fires on a Write, Edit, or MultiEdit targeting
11
+ that rule file and blocks the write when the spelled-out "<count> more gate
12
+ validators" count disagrees with the number of distinct free-form validators the
13
+ prose names, or when the "<count> gated slices" total is not that count plus one
14
+ (the count plus the ``Args:`` gate). An edit that leaves the count words and the
15
+ named-validator list in step is allowed.
16
+ """
17
+
18
+ import json
19
+ import os
20
+ import sys
21
+ from pathlib import Path
22
+ from typing import TextIO
23
+
24
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
25
+ if _hooks_dir not in sys.path:
26
+ sys.path.insert(0, _hooks_dir)
27
+
28
+ from hooks_constants.docstring_rule_gate_count_blocker_constants import ( # noqa: E402
29
+ ALL_NUMBER_WORDS_BY_VALUE,
30
+ ARGS_GATE_VALIDATOR_NAME,
31
+ CODE_FENCE_PATTERN,
32
+ FREE_FORM_GATE_COUNT_PATTERN,
33
+ GATE_COUNT_ADDITIONAL_CONTEXT,
34
+ GATE_COUNT_MESSAGE_TEMPLATE,
35
+ GATE_COUNT_SYSTEM_MESSAGE,
36
+ GATE_VALIDATOR_NAME_PATTERN,
37
+ MAX_GATE_COUNT_ISSUES,
38
+ TARGET_RULE_BASENAME,
39
+ TOTAL_GATED_SLICE_COUNT_PATTERN,
40
+ )
41
+ from hooks_constants.multi_edit_reconstruction import ( # noqa: E402
42
+ apply_edits,
43
+ edits_for_tool,
44
+ )
45
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
46
+ from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
47
+ read_hook_input_dictionary_from_stdin,
48
+ )
49
+
50
+
51
+ def is_target_rule_file(file_path: str) -> bool:
52
+ """Return whether *file_path* names the docstring-prose rule this hook guards.
53
+
54
+ Args:
55
+ file_path: The destination path of the write or edit.
56
+
57
+ Returns:
58
+ True when the path's basename is the target rule basename.
59
+ """
60
+ return os.path.basename(file_path) == TARGET_RULE_BASENAME
61
+
62
+
63
+ def _lines_outside_code_fences(content: str) -> list[str]:
64
+ """Return the rule lines that sit outside any fenced code block.
65
+
66
+ A line inside a ``` or ~~~ fence pair is example or sample text, not a live
67
+ enumeration, so it is dropped. This mirrors the fence handling in the sibling
68
+ inventory and orphan-file blockers.
69
+
70
+ Args:
71
+ content: The full rule-file text being written.
72
+
73
+ Returns:
74
+ The lines that lie outside every code fence, in document order.
75
+ """
76
+ live_lines: list[str] = []
77
+ is_inside_code_fence = False
78
+ for each_line in content.splitlines():
79
+ if CODE_FENCE_PATTERN.match(each_line) is not None:
80
+ is_inside_code_fence = not is_inside_code_fence
81
+ continue
82
+ if is_inside_code_fence:
83
+ continue
84
+ live_lines.append(each_line)
85
+ return live_lines
86
+
87
+
88
+ def _named_free_form_validators(enumeration_window: str) -> list[str]:
89
+ """Return the distinct free-form gate validators an enumeration window names.
90
+
91
+ The window is the prose between the "<count> more gate validators" phrase and
92
+ the "<count> gated slices" total — the span where the rule enumerates its
93
+ free-form gates. Every backticked ``check_*`` token in that window is
94
+ collected, the ``Args:`` gate (``check_docstring_args_match_signature``) is
95
+ removed since it is counted separately as part of the total rather than the
96
+ "more gate validators" tally, and duplicates are dropped while first-seen order
97
+ is preserved. Scoping to the window keeps a validator named only in the
98
+ worked-example or enforcement sections from inflating the count.
99
+
100
+ Args:
101
+ enumeration_window: The prose between the two count clauses.
102
+
103
+ Returns:
104
+ Each distinct free-form gate validator name, in first-seen order.
105
+ """
106
+ all_named_validators: list[str] = []
107
+ already_seen: set[str] = set()
108
+ for each_match in GATE_VALIDATOR_NAME_PATTERN.finditer(enumeration_window):
109
+ validator_name = each_match.group(1)
110
+ if validator_name == ARGS_GATE_VALIDATOR_NAME:
111
+ continue
112
+ if validator_name in already_seen:
113
+ continue
114
+ already_seen.add(validator_name)
115
+ all_named_validators.append(validator_name)
116
+ return all_named_validators
117
+
118
+
119
+ def _count_word_value(count_word: str) -> int | None:
120
+ """Return the integer a spelled-out count word names, or None when unknown.
121
+
122
+ Args:
123
+ count_word: The captured count word, such as ``four``.
124
+
125
+ Returns:
126
+ The integer the word maps to, or None when the word is not a known
127
+ spelled-out number.
128
+ """
129
+ return ALL_NUMBER_WORDS_BY_VALUE.get(count_word.strip().lower())
130
+
131
+
132
+ def find_gate_count_drift(content: str) -> list[str]:
133
+ """Return one issue per gate-count word that drifts from the named validators.
134
+
135
+ Requires both count clauses to be present and in document order, since the
136
+ validators are enumerated in the window between them: the "<count> more gate
137
+ validators" phrase first, then the "<count> gated slices" total. When either
138
+ clause is absent, or the total clause does not sit after the free-form clause,
139
+ the content is not a judgeable enumeration, so no issue results. The free-form
140
+ validators are the
141
+ distinct backticked ``check_*`` tokens in that window, excluding the ``Args:``
142
+ gate. When the "<count> more gate validators" spelled-out count disagrees with
143
+ the number of validators named in the window, it is an issue; when the
144
+ "<count> gated slices" total is not that count plus one (plus the ``Args:``
145
+ gate), it is an issue. A count word that is not a known spelled-out number
146
+ yields no issue.
147
+
148
+ Args:
149
+ content: The full rule-file text being written.
150
+
151
+ Returns:
152
+ Each drift issue message, capped at the issue budget.
153
+ """
154
+ prose_text = "\n".join(_lines_outside_code_fences(content))
155
+ free_form_match = FREE_FORM_GATE_COUNT_PATTERN.search(prose_text)
156
+ total_match = TOTAL_GATED_SLICE_COUNT_PATTERN.search(prose_text)
157
+ if free_form_match is None or total_match is None:
158
+ return []
159
+ if total_match.start() <= free_form_match.end():
160
+ return []
161
+ enumeration_window = prose_text[free_form_match.end() : total_match.start()]
162
+ all_named_validators = _named_free_form_validators(enumeration_window)
163
+ named_count = len(all_named_validators)
164
+ issues: list[str] = []
165
+ stated_free_form = _count_word_value(free_form_match.group(1))
166
+ if stated_free_form is not None and stated_free_form != named_count:
167
+ issues.append(
168
+ _format_issue(
169
+ free_form_match.group(0), stated_free_form, all_named_validators, named_count
170
+ )
171
+ )
172
+ stated_total = _count_word_value(total_match.group(1))
173
+ if stated_total is not None and stated_total != named_count + 1:
174
+ issues.append(
175
+ _format_issue(total_match.group(0), stated_total, all_named_validators, named_count)
176
+ )
177
+ return issues[:MAX_GATE_COUNT_ISSUES]
178
+
179
+
180
+ def _format_issue(
181
+ stated_phrase: str,
182
+ stated_count: int,
183
+ all_named_validators: list[str],
184
+ named_count: int,
185
+ ) -> str:
186
+ """Build one drift-issue message for a stale count clause.
187
+
188
+ Args:
189
+ stated_phrase: The matched count clause text, such as ``four gated slices``.
190
+ stated_count: The integer the clause's count word names.
191
+ all_named_validators: The distinct free-form validators the prose names.
192
+ named_count: The number of distinct free-form validators named.
193
+
194
+ Returns:
195
+ The formatted block-reason message for this drift.
196
+ """
197
+ formatted_validators = ", ".join(f"`{each_name}`" for each_name in all_named_validators)
198
+ return GATE_COUNT_MESSAGE_TEMPLATE.format(
199
+ rule_basename=TARGET_RULE_BASENAME,
200
+ stated_phrase=stated_phrase,
201
+ stated_count=stated_count,
202
+ named_count=named_count,
203
+ named_validators=formatted_validators,
204
+ total_count=named_count + 1,
205
+ )
206
+
207
+
208
+ def _read_existing_file_content(file_path: str) -> str | None:
209
+ """Return the current on-disk content of *file_path*, or None when unreadable.
210
+
211
+ Args:
212
+ file_path: The path of the file the edit targets.
213
+
214
+ Returns:
215
+ The file's text, or None when the file is missing or cannot be decoded.
216
+ """
217
+ try:
218
+ return Path(file_path).read_text(encoding="utf-8")
219
+ except (OSError, UnicodeDecodeError):
220
+ return None
221
+
222
+
223
+ def _post_edit_content(tool_name: str, tool_input: dict, file_path: str) -> str | None:
224
+ """Return the content the write or edit would leave on disk, or None.
225
+
226
+ For Write the content is the full new payload. For Edit and MultiEdit the
227
+ existing file is read and the replacements applied, so a count clause on a line
228
+ the edit does not touch still participates in the check. When the existing file
229
+ cannot be read, None results so the hook stays silent rather than judging a
230
+ partial fragment.
231
+
232
+ Args:
233
+ tool_name: The intercepted tool — ``Write``, ``Edit``, or ``MultiEdit``.
234
+ tool_input: The tool's input payload.
235
+ file_path: The destination path of the write or edit.
236
+
237
+ Returns:
238
+ The reconstructed post-edit content, or None when it cannot be built.
239
+ """
240
+ if tool_name == "Write":
241
+ content = tool_input.get("content", "")
242
+ return content if isinstance(content, str) and content else None
243
+ existing_content = _read_existing_file_content(file_path)
244
+ if existing_content is None:
245
+ return None
246
+ return apply_edits(existing_content, edits_for_tool(tool_name, tool_input))
247
+
248
+
249
+ def _build_block_payload(all_issues: list[str]) -> dict:
250
+ """Build the PreToolUse deny payload carrying each gate-count drift issue.
251
+
252
+ Args:
253
+ all_issues: The drift-issue messages the check produced.
254
+
255
+ Returns:
256
+ The hook-result dictionary the harness reads to deny the write.
257
+ """
258
+ reason = " | ".join(all_issues)
259
+ return {
260
+ "hookSpecificOutput": {
261
+ "hookEventName": "PreToolUse",
262
+ "permissionDecision": "deny",
263
+ "permissionDecisionReason": reason,
264
+ "additionalContext": GATE_COUNT_ADDITIONAL_CONTEXT,
265
+ },
266
+ "systemMessage": GATE_COUNT_SYSTEM_MESSAGE,
267
+ "suppressOutput": True,
268
+ }
269
+
270
+
271
+ def _emit_hook_result(all_hook_data: dict, output_stream: TextIO) -> None:
272
+ """Write the hook result JSON to the given output stream.
273
+
274
+ Args:
275
+ all_hook_data: The hook-result dictionary to serialize.
276
+ output_stream: The stream the harness reads the decision from.
277
+ """
278
+ output_stream.write(json.dumps(all_hook_data) + "\n")
279
+ output_stream.flush()
280
+
281
+
282
+ def main() -> None:
283
+ """Read the PreToolUse payload from stdin and block a stale gate-count edit."""
284
+ input_data = read_hook_input_dictionary_from_stdin()
285
+ if input_data is None:
286
+ sys.exit(0)
287
+
288
+ tool_name = input_data.get("tool_name", "")
289
+ if not isinstance(tool_name, str) or tool_name not in ("Write", "Edit", "MultiEdit"):
290
+ sys.exit(0)
291
+
292
+ tool_input = input_data.get("tool_input", {})
293
+ if not isinstance(tool_input, dict):
294
+ sys.exit(0)
295
+
296
+ file_path = tool_input.get("file_path", "")
297
+ if not isinstance(file_path, str) or not is_target_rule_file(file_path):
298
+ sys.exit(0)
299
+
300
+ post_edit_content = _post_edit_content(tool_name, tool_input, file_path)
301
+ if post_edit_content is None:
302
+ sys.exit(0)
303
+
304
+ gate_count_issues = find_gate_count_drift(post_edit_content)
305
+ if not gate_count_issues:
306
+ sys.exit(0)
307
+
308
+ block_payload = _build_block_payload(gate_count_issues)
309
+ log_hook_block(
310
+ calling_hook_name="docstring_rule_gate_count_blocker.py",
311
+ hook_event="PreToolUse",
312
+ block_reason=block_payload["hookSpecificOutput"]["permissionDecisionReason"],
313
+ tool_name=tool_name,
314
+ offending_input_preview=file_path,
315
+ )
316
+ _emit_hook_result(block_payload, sys.stdout)
317
+ sys.exit(0)
318
+
319
+
320
+ if __name__ == "__main__":
321
+ main()
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook: block a local re-definition of the Windows-safe rmtree helper trio.
3
+
4
+ The Windows-safe deletion helper trio — `_strip_read_only_and_retry`,
5
+ `_force_remove_tree`/`force_rmtree`, and the `inspect.signature` onexc/onerror guard —
6
+ is the sanctioned pattern for removing a directory tree that may hold ReadOnly files.
7
+ Because the windows_rmtree_blocker corrective message ships the trio as a paste-ready
8
+ snippet, agents paste a fresh local copy into each module that needs cleanup. Three
9
+ near-matching copies already span one codebase (a parser service, a categorizer, and a
10
+ test isolation helper), so a fix to one copy never reaches the others — the exact
11
+ "duplicated logic drifts" failure CODE_RULES.md section 3 (Reuse before create) names.
12
+
13
+ This hook scans Write/Edit content to a Python file for a `def` of any sanctioned
14
+ helper name and blocks it with a corrective message pointing to a single shared
15
+ force_rmtree utility. The canonical shared-helper home, the rmtree-blocker hook
16
+ sources (whose corrective strings embed the snippet), and test files are exempt.
17
+
18
+ This complements the same-directory `check_duplicate_function_body_across_files`
19
+ gate, which compares a written function only against `.py` siblings in its own
20
+ directory. That scope leaves a copy of this trio between two distant packages
21
+ unguarded, which is how the copies above spread. Keying on the sanctioned helper
22
+ names blocks the cross-directory copy the structural same-directory check cannot
23
+ reach.
24
+ """
25
+
26
+ import json
27
+ import sys
28
+ from pathlib import Path
29
+
30
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
31
+ if _hooks_dir not in sys.path:
32
+ sys.path.insert(0, _hooks_dir)
33
+
34
+ from hooks_constants.duplicate_rmtree_helper_blocker_constants import ( # noqa: E402
35
+ ALL_EXEMPT_PATH_FRAGMENTS,
36
+ ALL_EXEMPT_TEST_FILE_PREFIXES,
37
+ ALL_EXEMPT_TEST_FILE_SUFFIXES,
38
+ HELPER_DEFINITION_PATTERN,
39
+ PYTHON_FILE_EXTENSION,
40
+ TRIPLE_QUOTED_STRING_PATTERN,
41
+ )
42
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin # noqa: E402
43
+
44
+
45
+ def payload_defines_sanctioned_helper(payload_text: str) -> bool:
46
+ """Return True when the text defines a sanctioned Windows-safe rmtree helper.
47
+
48
+ Args:
49
+ payload_text: The file content or new_string fragment under inspection.
50
+
51
+ Returns:
52
+ True when a line defines `_strip_read_only_and_retry`, `_force_remove_tree`,
53
+ or `force_rmtree`. Triple-quoted string bodies are masked before the
54
+ line-anchored pattern runs, so a `def` that begins its own line inside a
55
+ documentation snippet or multi-line string literal is left untouched. A
56
+ helper name inside a single-line quoted string carries a quote before `def`,
57
+ so the line-anchored pattern leaves it untouched as well.
58
+ """
59
+ if not payload_text:
60
+ return False
61
+ masked_text = TRIPLE_QUOTED_STRING_PATTERN.sub("", payload_text)
62
+ return bool(HELPER_DEFINITION_PATTERN.search(masked_text))
63
+
64
+
65
+ def path_is_exempt(file_path: str) -> bool:
66
+ """Return True when a Python path may carry the helper definition.
67
+
68
+ Args:
69
+ file_path: The target path the Write/Edit writes to.
70
+
71
+ Returns:
72
+ True when the path's basename is the canonical shared-helper home, an
73
+ rmtree-blocker hook source, one of the existing in-repo definition sites
74
+ (session_env_cleanup.py, _md_to_html_blocker_test_support.py,
75
+ teardown_worktrees.py), or a test file. A definition there is intentional.
76
+ Basename equality (not substring containment) prevents a sibling whose name
77
+ merely contains an exempt fragment from bypassing the block.
78
+ """
79
+ normalized_path = file_path.replace("\\", "/")
80
+ file_name = normalized_path.rsplit("/", 1)[-1]
81
+ if any(file_name.startswith(each_prefix) for each_prefix in ALL_EXEMPT_TEST_FILE_PREFIXES):
82
+ return True
83
+ if any(file_name.endswith(each_suffix) for each_suffix in ALL_EXEMPT_TEST_FILE_SUFFIXES):
84
+ return True
85
+ return file_name in ALL_EXEMPT_PATH_FRAGMENTS
86
+
87
+
88
+ def extract_payload_text(tool_name: str, tool_input: dict) -> tuple[str, str]:
89
+ """Return the (file_path, scanned_text) pair for a Write/Edit to a Python file.
90
+
91
+ Args:
92
+ tool_name: The PreToolUse tool name.
93
+ tool_input: The tool input dictionary.
94
+
95
+ Returns:
96
+ A pair of the target path and the text to scan. The text is empty for an
97
+ unrelated tool or a non-Python target, so the caller exits without blocking.
98
+ """
99
+ if tool_name not in {"Write", "Edit"}:
100
+ return "", ""
101
+ file_path = tool_input.get("file_path", "") or ""
102
+ if file_path and not file_path.endswith(PYTHON_FILE_EXTENSION):
103
+ return file_path, ""
104
+ scanned_text = tool_input.get("content", "") or tool_input.get("new_string", "") or ""
105
+ return file_path, scanned_text
106
+
107
+
108
+ def main() -> None:
109
+ corrective_message = (
110
+ "BLOCKED [duplicate-rmtree-helper]: this Write/Edit defines a local copy of "
111
+ "the Windows-safe rmtree helper trio (_strip_read_only_and_retry, "
112
+ "_force_remove_tree / force_rmtree). The trio is already implemented once; a "
113
+ "second copy drifts from the original — a fix lands in one copy and the other "
114
+ "keeps the bug (CODE_RULES.md section 3, Reuse before create).\n\n"
115
+ "Import the shared force_rmtree helper rather than pasting the trio:\n\n"
116
+ " from <shared_package>.windows_filesystem import force_rmtree\n"
117
+ " force_rmtree(staging_directory)\n\n"
118
+ "When no shared helper module exists yet, create ONE — a windows-filesystem "
119
+ "utility module the consuming packages can import — define the trio there once, "
120
+ "and import it from every call site. Do not paste the trio from the "
121
+ "windows_rmtree_blocker corrective message into each module.\n\n"
122
+ "See ~/.claude/rules/windows-filesystem-safe.md for the sanctioned pattern."
123
+ )
124
+ hook_input = read_hook_input_dictionary_from_stdin()
125
+ if hook_input is None:
126
+ sys.exit(0)
127
+
128
+ raw_tool_name = hook_input.get("tool_name", "")
129
+ raw_tool_input = hook_input.get("tool_input", {})
130
+ tool_name = raw_tool_name if isinstance(raw_tool_name, str) else ""
131
+ tool_input = raw_tool_input if isinstance(raw_tool_input, dict) else {}
132
+
133
+ file_path, scanned_text = extract_payload_text(tool_name, tool_input)
134
+
135
+ if not scanned_text:
136
+ sys.exit(0)
137
+ if path_is_exempt(file_path):
138
+ sys.exit(0)
139
+ if not payload_defines_sanctioned_helper(scanned_text):
140
+ sys.exit(0)
141
+
142
+ deny_response = {
143
+ "hookSpecificOutput": {
144
+ "hookEventName": "PreToolUse",
145
+ "permissionDecision": "deny",
146
+ "permissionDecisionReason": corrective_message,
147
+ }
148
+ }
149
+ print(json.dumps(deny_response))
150
+ sys.stdout.flush()
151
+ sys.exit(0)
152
+
153
+
154
+ if __name__ == "__main__":
155
+ main()
@@ -35,6 +35,7 @@ from blocking._gh_body_arg_utils import ( # noqa: E402
35
35
  get_logical_first_line,
36
36
  iter_significant_tokens,
37
37
  )
38
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
38
39
 
39
40
  _GH_BODY_SUBCOMMANDS = re.compile(
40
41
  r"\bgh\s+(?:"
@@ -159,6 +160,13 @@ def main() -> None:
159
160
  "permissionDecisionReason": _CORRECTIVE_MESSAGE,
160
161
  }
161
162
  }
163
+ log_hook_block(
164
+ calling_hook_name="gh_body_arg_blocker.py",
165
+ hook_event="PreToolUse",
166
+ block_reason=_CORRECTIVE_MESSAGE,
167
+ tool_name=tool_name,
168
+ offending_input_preview=command,
169
+ )
162
170
  print(json.dumps(deny_payload))
163
171
  sys.stdout.flush()
164
172
  sys.exit(0)
@@ -59,6 +59,7 @@ from hooks_constants.gh_pr_author_swap_constants import (
59
59
  STATE_FILE_PRIMARY_ACCOUNT_KEY,
60
60
  WEB_FLAG_PATTERN,
61
61
  )
62
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
62
63
 
63
64
 
64
65
  def _active_gh_account() -> str | None:
@@ -410,6 +411,12 @@ def _emit_deny_payload(reason_text: str) -> None:
410
411
  "permissionDecisionReason": reason_text,
411
412
  }
412
413
  }
414
+ log_hook_block(
415
+ calling_hook_name="gh_pr_author_enforcer.py",
416
+ hook_event="PreToolUse",
417
+ block_reason=reason_text,
418
+ tool_name=BASH_TOOL_NAME,
419
+ )
413
420
  _write_line(json.dumps(deny_payload), sys.stdout)
414
421
 
415
422
 
@@ -16,7 +16,9 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
16
16
  if _hooks_dir not in sys.path:
17
17
  sys.path.insert(0, _hooks_dir)
18
18
 
19
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
19
20
  from hooks_constants.messages import USER_FACING_NOTICE # noqa: E402
21
+ from hooks_constants.text_stripping import strip_code_and_quotes # noqa: E402
20
22
 
21
23
  PLUGIN_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
22
24
 
@@ -57,19 +59,6 @@ ALL_HEDGING_PATTERNS = [
57
59
  re.compile(pattern, re.IGNORECASE) for pattern in HEDGING_WORDS + HEDGING_PHRASES
58
60
  ]
59
61
 
60
- CODE_BLOCK_PATTERN = re.compile(r"```[\s\S]*?```", re.MULTILINE)
61
- INLINE_CODE_PATTERN = re.compile(r"`[^`]+`")
62
- QUOTED_BLOCK_PATTERN = re.compile(r"^>.*$", re.MULTILINE)
63
-
64
-
65
- def strip_code_and_quotes(text: str) -> str:
66
- """Remove code blocks, inline code, and blockquotes to avoid false positives."""
67
- text = CODE_BLOCK_PATTERN.sub("", text)
68
- text = INLINE_CODE_PATTERN.sub("", text)
69
- text = QUOTED_BLOCK_PATTERN.sub("", text)
70
- return text
71
-
72
-
73
62
  def find_hedging_words(text: str) -> list[str]:
74
63
  """Return all hedging words/phrases found in the text."""
75
64
  prose_text = strip_code_and_quotes(text)
@@ -120,21 +109,26 @@ def main() -> None:
120
109
  "(no research-mode skill installed; verify with sources or prompt the user via AskUserQuestion with potential options + context)"
121
110
  )
122
111
 
112
+ block_reason = (
113
+ f"ANTI-HALLUCINATION GUARDRAIL: Your response contains hedging language: "
114
+ f"{formatted_term_list}. "
115
+ f"These words signal unverified claims. You MUST rewrite your response "
116
+ f"{skill_reference}\n\n"
117
+ f"Do NOT simply remove the hedging word and keep the unverified claim. "
118
+ 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"
119
+ f"You MUST re-output the complete, revised response with the corrections applied."
120
+ )
123
121
  block_response = {
124
122
  "decision": "block",
125
- "reason": (
126
- f"ANTI-HALLUCINATION GUARDRAIL: Your response contains hedging language: "
127
- f"{formatted_term_list}. "
128
- f"These words signal unverified claims. You MUST rewrite your response "
129
- f"{skill_reference}\n\n"
130
- f"Do NOT simply remove the hedging word and keep the unverified claim. "
131
- 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"
132
- f"You MUST re-output the complete, revised response with the corrections applied."
133
- ),
123
+ "reason": block_reason,
134
124
  "systemMessage": USER_FACING_NOTICE,
135
125
  "suppressOutput": True,
136
126
  }
137
-
127
+ log_hook_block(
128
+ calling_hook_name="hedging_language_blocker.py",
129
+ hook_event="Stop",
130
+ block_reason=block_reason,
131
+ )
138
132
  print(json.dumps(block_response))
139
133
  sys.exit(0)
140
134
 
@@ -39,6 +39,7 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
39
39
  if _hooks_dir not in sys.path:
40
40
  sys.path.insert(0, _hooks_dir)
41
41
 
42
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
42
43
  from hooks_constants.hook_prose_detector_consistency_constants import ( # noqa: E402
43
44
  CONSTANTS_MODULE_SUFFIX,
44
45
  CORRECTIVE_MESSAGE,
@@ -141,6 +142,12 @@ def main() -> None:
141
142
  "permissionDecisionReason": CORRECTIVE_MESSAGE,
142
143
  }
143
144
  }
145
+ log_hook_block(
146
+ calling_hook_name="hook_prose_detector_consistency.py",
147
+ hook_event="PreToolUse",
148
+ block_reason=CORRECTIVE_MESSAGE,
149
+ tool_name=tool_name,
150
+ )
144
151
  print(json.dumps(deny_payload))
145
152
  sys.stdout.flush()
146
153
  sys.exit(0)
@@ -18,22 +18,9 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
18
18
  if _hooks_dir not in sys.path:
19
19
  sys.path.insert(0, _hooks_dir)
20
20
 
21
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
21
22
  from hooks_constants.messages import USER_FACING_INTENT_ENDING_NOTICE # noqa: E402
22
-
23
-
24
- def strip_code_and_quotes(text: str) -> str:
25
- """Remove code blocks, inline code, and blockquotes to avoid false positives.
26
-
27
- Args:
28
- text: The raw assistant message to clean.
29
- """
30
- code_block_pattern = re.compile(r"```[\s\S]*?```", re.MULTILINE)
31
- inline_code_pattern = re.compile(r"`[^`]+`")
32
- quoted_block_pattern = re.compile(r"^>.*$", re.MULTILINE)
33
- text = code_block_pattern.sub("", text)
34
- text = inline_code_pattern.sub("", text)
35
- text = quoted_block_pattern.sub("", text)
36
- return text
23
+ from hooks_constants.text_stripping import strip_code_and_quotes # noqa: E402
37
24
 
38
25
 
39
26
  def extract_final_paragraph(text: str) -> str:
@@ -131,22 +118,27 @@ def main() -> None:
131
118
  if not find_intent_only_ending(assistant_message):
132
119
  sys.exit(0)
133
120
 
121
+ block_reason = (
122
+ "LONG-HORIZON-AUTONOMY GUARDRAIL: Your turn ends on a promise about work "
123
+ "that is not yet done, rather than doing it. Do the work NOW with tool calls "
124
+ "instead of describing what you are about to do.\n\n"
125
+ "If the work is genuinely blocked on input only the user can give, route the "
126
+ "ask through an AskUserQuestion tool call and end the turn cleanly. Otherwise, "
127
+ "carry out the stated action this turn.\n\n"
128
+ "You MUST re-output the complete response with the work actually performed, "
129
+ "per the long-horizon-autonomy rule."
130
+ )
134
131
  block_response = {
135
132
  "decision": "block",
136
- "reason": (
137
- "LONG-HORIZON-AUTONOMY GUARDRAIL: Your turn ends on a promise about work "
138
- "that is not yet done, rather than doing it. Do the work NOW with tool calls "
139
- "instead of describing what you are about to do.\n\n"
140
- "If the work is genuinely blocked on input only the user can give, route the "
141
- "ask through an AskUserQuestion tool call and end the turn cleanly. Otherwise, "
142
- "carry out the stated action this turn.\n\n"
143
- "You MUST re-output the complete response with the work actually performed, "
144
- "per the long-horizon-autonomy rule."
145
- ),
133
+ "reason": block_reason,
146
134
  "systemMessage": USER_FACING_INTENT_ENDING_NOTICE,
147
135
  "suppressOutput": True,
148
136
  }
149
-
137
+ log_hook_block(
138
+ calling_hook_name="intent_only_ending_blocker.py",
139
+ hook_event="Stop",
140
+ block_reason=block_reason,
141
+ )
150
142
  print(json.dumps(block_response))
151
143
  sys.exit(0)
152
144