claude-dev-env 1.72.0 → 1.74.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 (99) hide show
  1. package/CLAUDE.md +2 -0
  2. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +2 -2
  3. package/bin/install.mjs +73 -5
  4. package/bin/install.test.mjs +360 -4
  5. package/hooks/blocking/CLAUDE.md +6 -1
  6. package/hooks/blocking/block_main_commit.py +14 -0
  7. package/hooks/blocking/bot_mention_comment_blocker.py +7 -0
  8. package/hooks/blocking/claude_md_orphan_file_blocker.py +19 -48
  9. package/hooks/blocking/code_rules_dead_config_field.py +69 -56
  10. package/hooks/blocking/code_rules_docstrings.py +839 -0
  11. package/hooks/blocking/code_rules_enforcer.py +38 -0
  12. package/hooks/blocking/code_rules_shared.py +19 -0
  13. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +426 -0
  14. package/hooks/blocking/convergence_gate_blocker.py +17 -3
  15. package/hooks/blocking/destructive_command_blocker.py +7 -0
  16. package/hooks/blocking/docstring_rule_gate_count_blocker.py +321 -0
  17. package/hooks/blocking/gh_body_arg_blocker.py +8 -0
  18. package/hooks/blocking/gh_pr_author_enforcer.py +7 -0
  19. package/hooks/blocking/hedging_language_blocker.py +16 -10
  20. package/hooks/blocking/hook_prose_detector_consistency.py +7 -0
  21. package/hooks/blocking/intent_only_ending_blocker.py +17 -11
  22. package/hooks/blocking/md_to_html_blocker.py +17 -10
  23. package/hooks/blocking/open_questions_in_plans_blocker.py +15 -8
  24. package/hooks/blocking/package_inventory_stale_blocker.py +398 -0
  25. package/hooks/blocking/plain_language_blocker.py +57 -16
  26. package/hooks/blocking/pr_converge_bugteam_enforcer.py +11 -5
  27. package/hooks/blocking/pr_description_enforcer.py +6 -0
  28. package/hooks/blocking/pre_tool_use_dispatcher.py +545 -0
  29. package/hooks/blocking/precommit_code_rules_gate.py +10 -1
  30. package/hooks/blocking/pytest_testpaths_orphan_blocker.py +366 -0
  31. package/hooks/blocking/question_to_user_enforcer.py +18 -12
  32. package/hooks/blocking/send_user_file_open_locally_blocker.py +70 -0
  33. package/hooks/blocking/sensitive_file_protector.py +15 -1
  34. package/hooks/blocking/session_handoff_blocker.py +14 -8
  35. package/hooks/blocking/state_description_blocker.py +81 -36
  36. package/hooks/blocking/subprocess_budget_completeness.py +9 -3
  37. package/hooks/blocking/tdd_enforcer.py +6 -0
  38. package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +81 -0
  39. package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +93 -0
  40. package/hooks/blocking/test_code_rules_enforcer_docstring_returns_plural_cardinality.py +207 -0
  41. package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +262 -0
  42. package/hooks/blocking/test_code_rules_enforcer_docstring_undefined_constant.py +253 -0
  43. package/hooks/blocking/test_code_rules_enforcer_docstring_unguarded_payload.py +188 -0
  44. package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +279 -0
  45. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +501 -0
  46. package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +203 -0
  47. package/hooks/blocking/test_hook_block_logger_coverage.py +53 -0
  48. package/hooks/blocking/test_package_inventory_stale_blocker.py +329 -0
  49. package/hooks/blocking/test_plain_language_blocker.py +36 -0
  50. package/hooks/blocking/test_pre_tool_use_dispatcher.py +816 -0
  51. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +341 -0
  52. package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +247 -0
  53. package/hooks/blocking/test_send_user_file_open_locally_blocker.py +114 -0
  54. package/hooks/blocking/test_shared_stdin_adoption.py +208 -0
  55. package/hooks/blocking/test_state_description_blocker.py +41 -0
  56. package/hooks/blocking/test_verdict_directory_write_blocker.py +49 -0
  57. package/hooks/blocking/test_workflow_substitution_slot_blocker.py +4 -19
  58. package/hooks/blocking/verdict_directory_write_blocker.py +21 -7
  59. package/hooks/blocking/verified_commit_gate.py +11 -0
  60. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +16 -1
  61. package/hooks/blocking/windows_rmtree_blocker.py +7 -0
  62. package/hooks/blocking/workflow_substitution_slot_blocker.py +10 -5
  63. package/hooks/blocking/write_existing_file_blocker.py +16 -1
  64. package/hooks/hooks.json +19 -79
  65. package/hooks/hooks_constants/CLAUDE.md +7 -1
  66. package/hooks/hooks_constants/blocking_check_limits.py +74 -0
  67. package/hooks/hooks_constants/code_rules_enforcer_constants.py +9 -0
  68. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +45 -0
  69. package/hooks/hooks_constants/dead_config_field_constants.py +5 -5
  70. package/hooks/hooks_constants/docstring_rule_gate_count_blocker_constants.py +90 -0
  71. package/hooks/hooks_constants/hook_block_logger.py +59 -0
  72. package/hooks/hooks_constants/multi_edit_reconstruction.py +56 -0
  73. package/hooks/hooks_constants/mypy_validator_cache_constants.py +36 -0
  74. package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +111 -0
  75. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +68 -0
  76. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +143 -0
  77. package/hooks/hooks_constants/pytest_testpaths_orphan_blocker_constants.py +79 -0
  78. package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +18 -0
  79. package/hooks/hooks_constants/test_dispatcher_constants_docstrings.py +44 -0
  80. package/hooks/hooks_constants/test_hook_block_logger.py +159 -0
  81. package/hooks/lifecycle/config_change_guard.py +12 -0
  82. package/hooks/lifecycle/test_config_change_guard.py +23 -0
  83. package/hooks/validation/hook_format_validator.py +13 -0
  84. package/hooks/validation/mypy_validator.py +245 -18
  85. package/hooks/validation/post_tool_use_dispatcher.py +344 -0
  86. package/hooks/validation/test_hook_format_validator.py +64 -0
  87. package/hooks/validation/test_mypy_validator.py +206 -1
  88. package/hooks/validation/test_post_tool_use_dispatcher.py +610 -0
  89. package/hooks/workflow/test_auto_formatter.py +10 -9
  90. package/package.json +1 -1
  91. package/rules/CLAUDE.md +1 -0
  92. package/rules/docstring-prose-matches-implementation.md +4 -2
  93. package/rules/package-inventory-stale-entry.md +24 -0
  94. package/skills/autoconverge/SKILL.md +111 -1
  95. package/skills/autoconverge/workflow/converge.contract.test.mjs +106 -0
  96. package/skills/autoconverge/workflow/converge.mjs +29 -3
  97. package/skills/autoconverge/workflow/converge.path-aware.test.mjs +47 -0
  98. package/skills/autoconverge/workflow/converge_multi.mjs +161 -0
  99. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +100 -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()
@@ -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,6 +16,7 @@ _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
20
21
 
21
22
  PLUGIN_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
@@ -120,21 +121,26 @@ def main() -> None:
120
121
  "(no research-mode skill installed; verify with sources or prompt the user via AskUserQuestion with potential options + context)"
121
122
  )
122
123
 
124
+ block_reason = (
125
+ f"ANTI-HALLUCINATION GUARDRAIL: Your response contains hedging language: "
126
+ f"{formatted_term_list}. "
127
+ f"These words signal unverified claims. You MUST rewrite your response "
128
+ f"{skill_reference}\n\n"
129
+ f"Do NOT simply remove the hedging word and keep the unverified claim. "
130
+ 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"
131
+ f"You MUST re-output the complete, revised response with the corrections applied."
132
+ )
123
133
  block_response = {
124
134
  "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
- ),
135
+ "reason": block_reason,
134
136
  "systemMessage": USER_FACING_NOTICE,
135
137
  "suppressOutput": True,
136
138
  }
137
-
139
+ log_hook_block(
140
+ calling_hook_name="hedging_language_blocker.py",
141
+ hook_event="Stop",
142
+ block_reason=block_reason,
143
+ )
138
144
  print(json.dumps(block_response))
139
145
  sys.exit(0)
140
146
 
@@ -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,6 +18,7 @@ _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
 
23
24
 
@@ -131,22 +132,27 @@ def main() -> None:
131
132
  if not find_intent_only_ending(assistant_message):
132
133
  sys.exit(0)
133
134
 
135
+ block_reason = (
136
+ "LONG-HORIZON-AUTONOMY GUARDRAIL: Your turn ends on a promise about work "
137
+ "that is not yet done, rather than doing it. Do the work NOW with tool calls "
138
+ "instead of describing what you are about to do.\n\n"
139
+ "If the work is genuinely blocked on input only the user can give, route the "
140
+ "ask through an AskUserQuestion tool call and end the turn cleanly. Otherwise, "
141
+ "carry out the stated action this turn.\n\n"
142
+ "You MUST re-output the complete response with the work actually performed, "
143
+ "per the long-horizon-autonomy rule."
144
+ )
134
145
  block_response = {
135
146
  "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
- ),
147
+ "reason": block_reason,
146
148
  "systemMessage": USER_FACING_INTENT_ENDING_NOTICE,
147
149
  "suppressOutput": True,
148
150
  }
149
-
151
+ log_hook_block(
152
+ calling_hook_name="intent_only_ending_blocker.py",
153
+ hook_event="Stop",
154
+ block_reason=block_reason,
155
+ )
150
156
  print(json.dumps(block_response))
151
157
  sys.exit(0)
152
158
 
@@ -18,6 +18,8 @@ _blocking_directory = str(Path(__file__).resolve().parent)
18
18
  if _blocking_directory not in sys.path:
19
19
  sys.path.insert(0, _blocking_directory)
20
20
 
21
+ from md_path_exemptions import is_exempt_path # noqa: E402
22
+
21
23
  from hooks_constants.md_to_html_blocker_constants import ( # noqa: E402
22
24
  ALL_CLAUDE_CODE_SOURCE_TOP_DIRECTORIES,
23
25
  ALL_EXEMPT_ANYWHERE_FILENAMES,
@@ -29,8 +31,10 @@ from hooks_constants.md_to_html_blocker_constants import ( # noqa: E402
29
31
  PACKAGES_TOP_LEVEL_SEGMENT,
30
32
  PLUGIN_ROOT_MARKER_DIRECTORY_NAME,
31
33
  )
32
- from md_path_exemptions import is_exempt_path # noqa: E402
33
-
34
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
35
+ from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
36
+ read_hook_input_dictionary_from_stdin,
37
+ )
34
38
 
35
39
  _markdown_extension = ".md"
36
40
  _html_effectiveness_url = "https://thariqs.github.io/html-effectiveness/"
@@ -95,12 +99,8 @@ def main() -> None:
95
99
  Returns:
96
100
  None (exits process).
97
101
  """
98
- try:
99
- input_data = json.load(sys.stdin)
100
- except json.JSONDecodeError:
101
- sys.exit(0)
102
-
103
- if not isinstance(input_data, dict):
102
+ input_data = read_hook_input_dictionary_from_stdin()
103
+ if input_data is None:
104
104
  sys.exit(0)
105
105
 
106
106
  tool_name = input_data.get("tool_name", "")
@@ -124,17 +124,24 @@ def main() -> None:
124
124
  if is_exempt_path(file_path):
125
125
  sys.exit(0)
126
126
 
127
+ deny_reason = _block_reason(file_path)
127
128
  block_payload = {
128
129
  "hookSpecificOutput": {
129
130
  "hookEventName": "PreToolUse",
130
131
  "permissionDecision": "deny",
131
- "permissionDecisionReason": _block_reason(file_path),
132
+ "permissionDecisionReason": deny_reason,
132
133
  "additionalContext": _block_context(),
133
134
  },
134
135
  "systemMessage": _block_system_message(),
135
136
  "suppressOutput": True,
136
137
  }
137
-
138
+ log_hook_block(
139
+ calling_hook_name="md_to_html_blocker.py",
140
+ hook_event="PreToolUse",
141
+ block_reason=deny_reason,
142
+ tool_name=tool_name,
143
+ offending_input_preview=file_path,
144
+ )
138
145
  _emit_hook_result(block_payload, sys.stdout)
139
146
  sys.exit(0)
140
147
 
@@ -31,6 +31,10 @@ from hooks_constants.open_questions_in_plans_blocker_constants import ( # noqa:
31
31
  PLANS_PATH_SEGMENT,
32
32
  UNREADABLE_FILE_SYNTHETIC_CONTENT,
33
33
  )
34
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
35
+ from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
36
+ read_hook_input_dictionary_from_stdin,
37
+ )
34
38
 
35
39
 
36
40
  def _is_markdown_file(file_path: str) -> bool:
@@ -204,12 +208,8 @@ def _emit_hook_result(payload: dict, output_stream: TextIO) -> None:
204
208
 
205
209
 
206
210
  def main() -> None:
207
- try:
208
- input_data = json.load(sys.stdin)
209
- except json.JSONDecodeError:
210
- sys.exit(0)
211
-
212
- if not isinstance(input_data, dict):
211
+ input_data = read_hook_input_dictionary_from_stdin()
212
+ if input_data is None:
213
213
  sys.exit(0)
214
214
 
215
215
  tool_name = input_data.get("tool_name", "")
@@ -237,17 +237,24 @@ def main() -> None:
237
237
  if not _content_has_open_questions(candidate_content):
238
238
  sys.exit(0)
239
239
 
240
+ deny_reason = _block_reason(file_path)
240
241
  block_payload = {
241
242
  "hookSpecificOutput": {
242
243
  "hookEventName": "PreToolUse",
243
244
  "permissionDecision": "deny",
244
- "permissionDecisionReason": _block_reason(file_path),
245
+ "permissionDecisionReason": deny_reason,
245
246
  "additionalContext": _block_context(),
246
247
  },
247
248
  "systemMessage": _block_system_message(),
248
249
  "suppressOutput": True,
249
250
  }
250
-
251
+ log_hook_block(
252
+ calling_hook_name="open_questions_in_plans_blocker.py",
253
+ hook_event="PreToolUse",
254
+ block_reason=deny_reason,
255
+ tool_name=tool_name,
256
+ offending_input_preview=file_path,
257
+ )
251
258
  _emit_hook_result(block_payload, sys.stdout)
252
259
  sys.exit(0)
253
260