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
@@ -4,6 +4,21 @@ import json
4
4
  import os
5
5
  import subprocess
6
6
  import sys
7
+ from pathlib import Path
8
+ from unittest.mock import patch
9
+
10
+ _BLOCKING_DIR = str(Path(__file__).resolve().parent)
11
+ _HOOKS_ROOT = str(Path(__file__).resolve().parent.parent)
12
+ if _BLOCKING_DIR not in sys.path:
13
+ sys.path.insert(0, _BLOCKING_DIR)
14
+ if _HOOKS_ROOT not in sys.path:
15
+ sys.path.insert(0, _HOOKS_ROOT)
16
+
17
+ from pre_tool_use_dispatcher import NativeHook, run_native_hook # noqa: E402
18
+ from state_description_blocker import ( # noqa: E402
19
+ build_deny_payload,
20
+ evaluate,
21
+ )
7
22
 
8
23
  HOOK_SCRIPT_PATH = os.path.join(
9
24
  os.path.dirname(__file__), "state_description_blocker.py"
@@ -616,3 +631,29 @@ def test_handles_non_string_tool_name():
616
631
  )
617
632
  assert result.returncode == 0
618
633
  assert result.stdout == ""
634
+
635
+
636
+ def test_native_dispatch_path_logs_the_block(tmp_path: Path) -> None:
637
+ """A deny routed through the dispatcher's native path logs one record.
638
+
639
+ hooks.json wires this hook only through pre_tool_use_dispatcher, whose
640
+ native path calls evaluate() and build_deny_payload() — never main(). The
641
+ block must still land in the hook-blocks log, so the log call lives on
642
+ build_deny_payload, the function the native path executes.
643
+ """
644
+ deny_payload = {
645
+ "tool_name": "Write",
646
+ "tool_input": {"file_path": "src/main.py", "content": VIOLATION_INSTEAD_OF_COMMENT},
647
+ }
648
+ native_hook = NativeHook(evaluate=evaluate, build_deny_payload=build_deny_payload)
649
+
650
+ with patch.object(Path, "home", return_value=tmp_path):
651
+ hosted_result = run_native_hook(native_hook, deny_payload, is_blocking=True)
652
+
653
+ assert hosted_result.captured_stdout
654
+ log_path = tmp_path / ".claude" / "logs" / "hook-blocks.log"
655
+ all_records = log_path.read_text(encoding="utf-8").strip().splitlines()
656
+ assert len(all_records) == 1
657
+ logged_record = json.loads(all_records[0])
658
+ assert logged_record["hook"] == "state_description_blocker.py"
659
+ assert logged_record["event"] == "PreToolUse"
@@ -11,6 +11,8 @@ is denied, and commands that touch unrelated paths pass.
11
11
  import importlib.util
12
12
  import json
13
13
  import pathlib
14
+ import shutil
15
+ import subprocess
14
16
  import sys
15
17
 
16
18
  _HOOK_DIR = pathlib.Path(__file__).parent
@@ -718,3 +720,50 @@ def test_guard_is_registered_on_powershell() -> None:
718
720
  "verdict_directory_write_blocker.py" in each_command
719
721
  for each_command in _pretooluse_commands_for_matcher("PowerShell")
720
722
  )
723
+
724
+
725
+ def test_hook_subprocess_imports_real_config_when_parent_holds_shadowing_config(
726
+ tmp_path: pathlib.Path,
727
+ ) -> None:
728
+ real_blocking_directory = pathlib.Path(__file__).resolve().parent
729
+ real_hooks_directory = real_blocking_directory.parent
730
+
731
+ staged_hooks_directory = tmp_path / "hooks"
732
+ staged_blocking_directory = staged_hooks_directory / "blocking"
733
+ staged_blocking_directory.mkdir(parents=True)
734
+
735
+ shutil.copy(
736
+ real_blocking_directory / "verdict_directory_write_blocker.py",
737
+ staged_blocking_directory / "verdict_directory_write_blocker.py",
738
+ )
739
+ shutil.copytree(
740
+ real_blocking_directory / "config",
741
+ staged_blocking_directory / "config",
742
+ )
743
+ shutil.copytree(
744
+ real_hooks_directory / "hooks_constants",
745
+ staged_hooks_directory / "hooks_constants",
746
+ )
747
+
748
+ shadowing_config_directory = staged_hooks_directory / "config"
749
+ shadowing_config_directory.mkdir(parents=True, exist_ok=True)
750
+ (shadowing_config_directory / "__init__.py").write_text("", encoding="utf-8")
751
+ (shadowing_config_directory / "unrelated_constants.py").write_text(
752
+ "UNRELATED_VALUE = 1\n", encoding="utf-8"
753
+ )
754
+
755
+ benign_payload = json.dumps(
756
+ {"tool_name": "Bash", "tool_input": {"command": "echo hello"}}
757
+ )
758
+ completed = subprocess.run(
759
+ [
760
+ sys.executable,
761
+ str(staged_blocking_directory / "verdict_directory_write_blocker.py"),
762
+ ],
763
+ input=benign_payload,
764
+ capture_output=True,
765
+ text=True,
766
+ )
767
+
768
+ assert "ModuleNotFoundError" not in completed.stderr
769
+ assert completed.returncode == 0
@@ -23,7 +23,6 @@ hook_module = importlib.util.module_from_spec(hook_spec)
23
23
  hook_spec.loader.exec_module(hook_module)
24
24
 
25
25
  content_has_violation = hook_module.content_has_violation
26
- find_bare_index_segments = hook_module.find_bare_index_segments
27
26
  find_bare_path_segments = hook_module.find_bare_path_segments
28
27
  has_iteration_loop = hook_module.has_iteration_loop
29
28
  written_content = hook_module.written_content
@@ -47,37 +46,23 @@ _FIXED_TEMPLATE = (
47
46
 
48
47
 
49
48
  def test_detects_bare_index_in_path_segment() -> None:
50
- assert find_bare_index_segments(
49
+ assert find_bare_path_segments(
51
50
  "render Path(r'${args.work_dir}\\\\cand_i\\\\plate.svg')"
52
51
  ) == {"cand_i"}
53
52
 
54
53
 
55
54
  def test_detects_quoted_key_when_token_also_appears_as_path_segment() -> None:
56
55
  looped_path_and_key = "write ${work}\\\\cand_i\\\\plate.svg\n{key: \"cand_i\", name}"
57
- assert "cand_i" in find_bare_index_segments(looped_path_and_key)
56
+ assert "cand_i" in find_bare_path_segments(looped_path_and_key)
58
57
 
59
58
 
60
59
  def test_quoted_key_alone_without_path_segment_is_not_detected() -> None:
61
- assert find_bare_index_segments('{key: "metric_i", name}') == set()
62
-
63
-
64
- def test_index_segments_equal_path_segments_for_looped_path_and_key() -> None:
65
- looped_path_and_key = "write ${work}\\\\cand_i\\\\plate.svg\n{key: \"cand_i\", name}"
66
- assert find_bare_index_segments(looped_path_and_key) == find_bare_path_segments(
67
- looped_path_and_key
68
- )
69
-
70
-
71
- def test_index_segments_equal_path_segments_for_quoted_only_key() -> None:
72
- quoted_only_key = '{key: "metric_i", name}'
73
- assert find_bare_index_segments(quoted_only_key) == find_bare_path_segments(
74
- quoted_only_key
75
- )
60
+ assert find_bare_path_segments('{key: "metric_i", name}') == set()
76
61
 
77
62
 
78
63
  def test_marked_substitution_slot_is_not_a_bare_segment() -> None:
79
64
  assert (
80
- find_bare_index_segments(
65
+ find_bare_path_segments(
81
66
  "render Path(r'${args.work_dir}\\\\cand_<i>\\\\plate.svg')"
82
67
  )
83
68
  == set()
@@ -39,7 +39,7 @@ if blocking_directory not in sys.path:
39
39
 
40
40
  hooks_directory = str(Path(__file__).resolve().parent.parent)
41
41
  if hooks_directory not in sys.path:
42
- sys.path.insert(0, hooks_directory)
42
+ sys.path.append(hooks_directory)
43
43
 
44
44
  from config.verified_commit_constants import ( # noqa: E402
45
45
  ALL_GATED_TOOL_NAMES,
@@ -78,6 +78,7 @@ from config.verified_commit_constants import ( # noqa: E402
78
78
  WRITE_CALL_REGION_PATTERN,
79
79
  )
80
80
 
81
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
81
82
  from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
82
83
  read_hook_input_dictionary_from_stdin,
83
84
  )
@@ -664,6 +665,14 @@ def main() -> None:
664
665
  deny_decision = decision_for_payload(pretooluse_payload)
665
666
  if deny_decision is None:
666
667
  return
668
+ raw_tool_name = pretooluse_payload.get("tool_name", "")
669
+ tool_name_for_log = raw_tool_name if isinstance(raw_tool_name, str) else ""
670
+ log_hook_block(
671
+ calling_hook_name="verdict_directory_write_blocker.py",
672
+ hook_event="PreToolUse",
673
+ block_reason=VERDICT_DIRECTORY_GUARD_MESSAGE,
674
+ tool_name=tool_name_for_log,
675
+ )
667
676
  print(json.dumps(deny_decision))
668
677
  sys.stdout.flush()
669
678
 
@@ -38,6 +38,10 @@ blocking_directory = str(Path(__file__).resolve().parent)
38
38
  if blocking_directory not in sys.path:
39
39
  sys.path.insert(0, blocking_directory)
40
40
 
41
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
42
+ if _hooks_dir not in sys.path:
43
+ sys.path.insert(0, _hooks_dir)
44
+
41
45
  from config.verified_commit_constants import (
42
46
  ALL_GIT_BINARY_NAMES,
43
47
  CORRECTIVE_MESSAGE,
@@ -55,6 +59,7 @@ from config.verified_commit_constants import (
55
59
  VERIFICATION_BYPASS_MARKER,
56
60
  WORK_TREE_OPTION,
57
61
  )
62
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
58
63
  from verification_verdict_store import (
59
64
  branch_surface_manifest,
60
65
  is_verification_exempt_diff,
@@ -543,6 +548,12 @@ def main() -> None:
543
548
  "permissionDecisionReason": deny_reason,
544
549
  }
545
550
  }
551
+ log_hook_block(
552
+ calling_hook_name="verified_commit_gate.py",
553
+ hook_event="PreToolUse",
554
+ block_reason=deny_reason,
555
+ tool_name=pretooluse_payload.get("tool_name", "") if isinstance(pretooluse_payload.get("tool_name"), str) else None,
556
+ )
546
557
  print(json.dumps(deny_payload))
547
558
  return
548
559
 
@@ -24,6 +24,13 @@ import json
24
24
  import os
25
25
  import re
26
26
  import sys
27
+ from pathlib import Path
28
+
29
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
30
+ if _hooks_dir not in sys.path:
31
+ sys.path.insert(0, _hooks_dir)
32
+
33
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
27
34
 
28
35
 
29
36
  def is_guarded_file(file_path: str) -> bool:
@@ -136,13 +143,21 @@ def main() -> None:
136
143
  if not claims_blanket_comment_exemption(written_text):
137
144
  sys.exit(0)
138
145
 
146
+ corrective_message = build_corrective_message()
139
147
  deny_response = {
140
148
  "hookSpecificOutput": {
141
149
  "hookEventName": "PreToolUse",
142
150
  "permissionDecision": "deny",
143
- "permissionDecisionReason": build_corrective_message(),
151
+ "permissionDecisionReason": corrective_message,
144
152
  }
145
153
  }
154
+ log_hook_block(
155
+ calling_hook_name="verified_commit_message_accuracy_blocker.py",
156
+ hook_event="PreToolUse",
157
+ block_reason=corrective_message,
158
+ tool_name=tool_name,
159
+ offending_input_preview=file_path,
160
+ )
146
161
  print(json.dumps(deny_response))
147
162
  sys.stdout.flush()
148
163
  sys.exit(0)
@@ -21,6 +21,7 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
21
21
  if _hooks_dir not in sys.path:
22
22
  sys.path.insert(0, _hooks_dir)
23
23
 
24
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
24
25
  from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin # noqa: E402
25
26
  from hooks_constants.windows_rmtree_blocker_constants import PYTHON_FILE_EXTENSION # noqa: E402
26
27
 
@@ -104,6 +105,12 @@ def main() -> None:
104
105
  "permissionDecisionReason": corrective_message,
105
106
  }
106
107
  }
108
+ log_hook_block(
109
+ calling_hook_name="windows_rmtree_blocker.py",
110
+ hook_event="PreToolUse",
111
+ block_reason=corrective_message,
112
+ tool_name=tool_name,
113
+ )
107
114
  print(json.dumps(deny_response))
108
115
  sys.stdout.flush()
109
116
  sys.exit(0)
@@ -40,6 +40,7 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
40
40
  if _hooks_dir not in sys.path:
41
41
  sys.path.insert(0, _hooks_dir)
42
42
 
43
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
43
44
  from hooks_constants.workflow_substitution_slot_blocker_constants import ( # noqa: E402
44
45
  CORRECTIVE_MESSAGE,
45
46
  EDIT_TOOL_NAME,
@@ -111,16 +112,12 @@ def find_bare_path_segments(content: str) -> set[str]:
111
112
  return all_path_segments
112
113
 
113
114
 
114
- def find_bare_index_segments(content: str) -> set[str]:
115
- return find_bare_path_segments(content)
116
-
117
-
118
115
  def content_has_violation(content: str) -> bool:
119
116
  if not uses_angle_slot_convention(content):
120
117
  return False
121
118
  if not has_iteration_loop(content):
122
119
  return False
123
- return bool(find_bare_index_segments(content))
120
+ return bool(find_bare_path_segments(content))
124
121
 
125
122
 
126
123
  def main() -> None:
@@ -150,6 +147,14 @@ def main() -> None:
150
147
  "permissionDecisionReason": CORRECTIVE_MESSAGE,
151
148
  }
152
149
  }
150
+ raw_tool_name_for_log = hook_input.get("tool_name", "")
151
+ tool_name_for_log = raw_tool_name_for_log if isinstance(raw_tool_name_for_log, str) else ""
152
+ log_hook_block(
153
+ calling_hook_name="workflow_substitution_slot_blocker.py",
154
+ hook_event="PreToolUse",
155
+ block_reason=CORRECTIVE_MESSAGE,
156
+ tool_name=tool_name_for_log,
157
+ )
153
158
  print(json.dumps(deny_payload))
154
159
  sys.stdout.flush()
155
160
  sys.exit(0)
@@ -8,6 +8,13 @@ Exemptions: Jupyter notebooks (.ipynb) and files in ~/.claude/hooks/ (standalone
8
8
  import json
9
9
  import os
10
10
  import sys
11
+ from pathlib import Path
12
+
13
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
14
+ if _hooks_dir not in sys.path:
15
+ sys.path.insert(0, _hooks_dir)
16
+
17
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
11
18
 
12
19
  JUPYTER_EXTENSION = ".ipynb"
13
20
  HOOKS_DIRECTORY = os.path.normpath(os.path.expanduser("~/.claude/hooks"))
@@ -48,13 +55,21 @@ def main() -> None:
48
55
  if not os.path.exists(target_file_path):
49
56
  sys.exit(0)
50
57
 
58
+ deny_reason = f"BLOCKED: Write on existing file {target_file_path}. Use Edit tool instead."
51
59
  denial = {
52
60
  "hookSpecificOutput": {
53
61
  "hookEventName": "PreToolUse",
54
62
  "permissionDecision": "deny",
55
- "permissionDecisionReason": f"BLOCKED: Write on existing file {target_file_path}. Use Edit tool instead.",
63
+ "permissionDecisionReason": deny_reason,
56
64
  }
57
65
  }
66
+ log_hook_block(
67
+ calling_hook_name="write_existing_file_blocker.py",
68
+ hook_event="PreToolUse",
69
+ block_reason=deny_reason,
70
+ tool_name="Write",
71
+ offending_input_preview=target_file_path,
72
+ )
58
73
  print(json.dumps(denial))
59
74
  sys.exit(0)
60
75
 
package/hooks/hooks.json CHANGED
@@ -161,6 +161,16 @@
161
161
  "timeout": 10
162
162
  }
163
163
  ]
164
+ },
165
+ {
166
+ "matcher": "SendUserFile",
167
+ "hooks": [
168
+ {
169
+ "type": "command",
170
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/send_user_file_open_locally_blocker.py",
171
+ "timeout": 10
172
+ }
173
+ ]
164
174
  }
165
175
  ],
166
176
  "SessionStart": [
@@ -22,18 +22,23 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
22
22
  | `dead_module_constant_constants.py` | Patterns for detecting unexported `UPPER_SNAKE` constants in `*_constants.py` modules |
23
23
  | `destructive_command_segment_constants.py` | The list of destructive shell command patterns the blocker matches |
24
24
  | `doc_gist_auto_publish_constants.py` | Sentinel marker and URL patterns for the doc-gist auto-publish hook |
25
+ | `docstring_rule_gate_count_blocker_constants.py` | Target rule basename, spelled-out-number lookup, count-clause and `check_*` validator patterns, and block-message text for the docstring-rule gate-count staleness blocker |
25
26
  | `duplicate_function_body_constants.py` | Hashing and comparison config for the duplicate-body check |
27
+ | `duplicate_rmtree_helper_blocker_constants.py` | Sanctioned Windows-safe rmtree helper names, the definition pattern, and the exempt-path set for the duplicate-rmtree-helper blocker |
26
28
  | `dynamic_stderr_handler.py` | `DynamicStderrHandler` — a logging handler that resolves `sys.stderr` at emit time (for testability) |
27
29
  | `gh_pr_author_swap_constants.py` | Constants for the PR-author swap enforcement hooks |
28
30
  | `hardcoded_user_path_constants.py` | Patterns for detecting hardcoded home-directory paths |
31
+ | `hook_block_logger.py` | `log_hook_block()` — shared fail-safe logger every blocking hook calls to append a JSON record of each block decision to `~/.claude/logs/hook-blocks.log` |
29
32
  | `hook_log_extractor_constants.py` | Neon table name, offset state file path, timeouts, and outcome-type mapping for the hook-log extractor |
30
33
  | `hook_prose_detector_consistency_constants.py` | Trigger patterns and corrective messages for the hook-prose consistency checker |
31
34
  | `html_companion_constants.py` | Blocked URL schemes and other config for the `.md`-to-`.html` companion hook |
32
35
  | `inline_tuple_string_magic_constants.py` | Patterns for detecting magic strings in inline tuple literals |
33
36
  | `md_to_html_blocker_constants.py` | Path exemptions and trigger patterns for the markdown-to-html blocker |
34
37
  | `messages.py` | Short user-facing notice strings shown when a Stop hook redirects agent behavior |
38
+ | `multi_edit_reconstruction.py` | `apply_edits()` / `edits_for_tool()` — shared helpers that reconstruct the post-edit content of an Edit or MultiEdit, imported by the blockers that judge post-edit content |
35
39
  | `open_questions_in_plans_blocker_constants.py` | Patterns for detecting unresolved open questions in plan documents |
36
40
  | `orphan_css_class_constants.py` | Scan radius and selector patterns for the orphan-CSS-class check |
41
+ | `package_inventory_stale_blocker_constants.py` | Inventory document names, production code extensions, backtick token pattern, smallest inventory size, exempt names, scan budget, and block-message text for the package-inventory stale-entry blocker |
37
42
  | `path_rewriter_constants.py` | Path rewriting patterns for the Everything-search path rewriter |
38
43
  | `plain_language_blocker_constants.py` | The list of heavy words and their everyday replacements |
39
44
  | `pr_converge_bugteam_enforcer_constants.py` | State keys and timing config for the bugteam-parallel enforcer |
@@ -43,6 +48,7 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
43
48
  | `precommit_code_rules_gate_constants.py` | Scope argument and exit-code constants for the precommit gate |
44
49
  | `project_paths_reader.py` | Loads `~/.claude/project-paths.json` — the per-user project-path registry |
45
50
  | `pytest_testpaths_orphan_blocker_constants.py` | Marker filename, section and key names, test-file pattern, search budget, and block-message text for the pytest unregistered-test-directory blocker |
51
+ | `send_user_file_open_locally_blocker_constants.py` | Tool name, proactive status, and the block message for the open-locally attach blocker |
46
52
  | `session_env_cleanup_constants.py` | Stale-age threshold and directory names for the session-env cleanup hook |
47
53
  | `session_handoff_blocker_constants.py` | Trigger phrases for the session-handoff blocker |
48
54
  | `setup_project_paths_constants.py` | Encoding policy, BOM marker, and registry meta-key used across multiple hooks |
@@ -51,6 +57,7 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
51
57
  | `stuttering_import_binding_constants.py` | Import-binding patterns for the stuttering check |
52
58
  | `subprocess_budget_completeness_constants.py` | Required argument names for the subprocess-budget completeness check |
53
59
  | `sys_path_insert_constants.py` | Patterns for detecting unguarded `sys.path.insert` calls |
60
+ | `text_stripping.py` | `strip_code_and_quotes()` — shared helper that removes fenced code blocks, inline code, and blockquotes from prose, imported by the Stop-hook prose blockers |
54
61
  | `unused_module_import_constants.py` | Patterns for detecting unused module-level imports |
55
62
  | `windows_rmtree_blocker_constants.py` | The unsafe `shutil.rmtree` pattern and the safe replacement pattern |
56
63
  | `workflow_substitution_slot_blocker_constants.py` | Per-iteration token patterns for the workflow-slot blocker |
@@ -60,4 +67,4 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
60
67
  - Every file in this package is a pure constants module — no side effects, no I/O.
61
68
  - Hooks import from this package with `from hooks_constants.<module> import <CONSTANT>`.
62
69
  - Tests for these modules live beside them as `test_<module>.py`. Run with `python -m pytest hooks_constants/test_<name>.py`.
63
- - `dynamic_stderr_handler.py` and `pre_tool_use_stdin.py` are utility modules (not pure constants) but live here because they are shared across many hooks.
70
+ - `dynamic_stderr_handler.py`, `pre_tool_use_stdin.py`, `multi_edit_reconstruction.py`, and `text_stripping.py` are utility modules (not pure constants) but live here because they are shared across many hooks.
@@ -30,6 +30,7 @@ DOCSTRING_TRIVIAL_FUNCTION_BODY_LINE_LIMIT: int = 3
30
30
  MAX_DOCSTRING_FALLBACK_BRANCH_ISSUES: int = 3
31
31
  DOCSTRING_FALLBACK_BRANCH_MINIMUM_ROUTE_COUNT: int = 2
32
32
  MAX_DOCSTRING_NO_CONSUMER_CLAIM_ISSUES: int = 3
33
+ MAX_DOCSTRING_UNGUARDED_PAYLOAD_CLAIM_ISSUES: int = 3
33
34
  MAX_STALE_TEST_NAME_TARGET_ISSUES: int = 3
34
35
  STALE_TEST_NAME_MINIMUM_SHARED_TOKEN_COUNT: int = 2
35
36
  MAX_MODULE_DOCSTRING_CHECK_ROSTER_ISSUES: int = 5
@@ -40,6 +41,8 @@ MAX_DOCSTRING_STEP_DISPATCH_ISSUES: int = 5
40
41
  MINIMUM_NAMED_LINEAR_STEPS_FOR_DISPATCH_CHECK: int = 2
41
42
  MINIMUM_TOKENS_FOR_DISPATCH_CALLEE: int = 2
42
43
  MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES: int = 3
44
+ MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES: int = 5
45
+ SINGLE_DICT_KEY_COUNT_FOR_PLURAL_CARDINALITY_DRIFT: int = 1
43
46
  ALL_NAMING_CONVENTION_DESCRIPTOR_TOKENS: frozenset[str] = frozenset(
44
47
  {
45
48
  "UPPER_SNAKE_CASE",
@@ -94,6 +97,16 @@ ALL_DOCSTRING_NO_CONSUMER_CLAIM_PHRASES: tuple[str, ...] = (
94
97
  "not yet read by any consumer",
95
98
  )
96
99
 
100
+ ALL_DOCSTRING_GUARDED_FAILURE_CLAIM_PHRASES: tuple[str, ...] = (
101
+ "malformed payload resolves to none",
102
+ "malformed payload returns none",
103
+ "malformed response resolves to none",
104
+ "malformed response returns none",
105
+ "bad payload resolves to none",
106
+ "invalid payload resolves to none",
107
+ "malformed payload yields none",
108
+ )
109
+
97
110
  MAX_DOCSTRING_INLINE_LITERAL_CLAIM_ISSUES: int = 3
98
111
  ALL_DOCSTRING_NO_INLINE_LITERAL_CLAIM_PHRASES: tuple[str, ...] = (
99
112
  "no literals appear inline",
@@ -42,6 +42,9 @@ UPPER_SNAKE_CONSTANT_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]*$")
42
42
  ALL_MUST_CHECK_RETURN_FUNCTION_NAMES: frozenset[str] = frozenset({"find_and_click", "write_outcome"})
43
43
 
44
44
  DOCSTRING_ARG_ENTRY_PATTERN: re.Pattern[str] = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\s*[:(]")
45
+ DOCSTRING_PLURAL_FAMILY_STOP_PATTERN: re.Pattern[str] = re.compile(
46
+ r"\bthe\s+([a-z][a-z]+)\s+stops\b"
47
+ )
45
48
  INLINE_CODE_TOKEN_PATTERN: re.Pattern[str] = re.compile(r"``?(\.?[A-Za-z_][A-Za-z0-9_.]*)``?")
46
49
  IDENTIFIER_SHAPED_TUPLE_MEMBER_PATTERN: re.Pattern[str] = re.compile(r"^\.?[A-Za-z_][A-Za-z0-9_]*$")
47
50
  ALL_DOCSTRING_ARGS_SECTION_HEADERS: tuple[str, ...] = ("Args:", "Arguments:")
@@ -2,7 +2,8 @@
2
2
 
3
3
  The gate denies an ``Agent`` spawn whose ``subagent_type`` is ``code-verifier``
4
4
  when the branch carries a merge conflict against its base ref or a CODE_RULES
5
- violation on a line added in the uncommitted working tree. It runs two
5
+ violation on a line added in the working tree since the merge base (committed on
6
+ the branch or uncommitted). It runs two
6
7
  pre-flight checks before the expensive verification spawn and addresses its
7
8
  deny reason to the spawning agent so that agent fixes the named issues and
8
9
  re-spawns. Every literal the hook body reads lives here; the hook imports
@@ -0,0 +1,90 @@
1
+ """Constants for the docstring-rule gate-count staleness blocker.
2
+
3
+ The rule file ``docstring-prose-matches-implementation.md`` enumerates the
4
+ ``check_docstring_*`` gate validators that cover deterministic slices of docstring
5
+ prose, both as a spelled-out count ("Three more gate validators", "four gated
6
+ slices") and as a backticked list of the validator names. When a new gate
7
+ validator is registered but the count word is left unchanged, the rule's stated
8
+ count drifts from the validators it actually names — the same companion-doc drift
9
+ the rule itself governs. This module holds the target rule basename, the
10
+ spelled-out-number lookup, the code-fence pattern that marks lines to skip, the
11
+ patterns that find the "<count> more gate validators" and "<count> gated slices"
12
+ count clauses and the backticked ``check_*`` validator names, the args-gate name,
13
+ the issue budget, and the block-message text the hook emits.
14
+ """
15
+
16
+ import re
17
+
18
+ __all__ = [
19
+ "TARGET_RULE_BASENAME",
20
+ "ALL_NUMBER_WORDS_BY_VALUE",
21
+ "CODE_FENCE_PATTERN",
22
+ "FREE_FORM_GATE_COUNT_PATTERN",
23
+ "TOTAL_GATED_SLICE_COUNT_PATTERN",
24
+ "GATE_VALIDATOR_NAME_PATTERN",
25
+ "ARGS_GATE_VALIDATOR_NAME",
26
+ "MAX_GATE_COUNT_ISSUES",
27
+ "GATE_COUNT_MESSAGE_TEMPLATE",
28
+ "GATE_COUNT_SYSTEM_MESSAGE",
29
+ "GATE_COUNT_ADDITIONAL_CONTEXT",
30
+ ]
31
+
32
+ TARGET_RULE_BASENAME: str = "docstring-prose-matches-implementation.md"
33
+
34
+ ALL_NUMBER_WORDS_BY_VALUE: dict[str, int] = {
35
+ "zero": 0,
36
+ "one": 1,
37
+ "two": 2,
38
+ "three": 3,
39
+ "four": 4,
40
+ "five": 5,
41
+ "six": 6,
42
+ "seven": 7,
43
+ "eight": 8,
44
+ "nine": 9,
45
+ "ten": 10,
46
+ }
47
+
48
+ CODE_FENCE_PATTERN: re.Pattern[str] = re.compile(r"^\s*(?:```|~~~)")
49
+
50
+ FREE_FORM_GATE_COUNT_PATTERN: re.Pattern[str] = re.compile(
51
+ r"\b([A-Za-z]+)\s+more\s+gate\s+validators\b",
52
+ re.IGNORECASE,
53
+ )
54
+
55
+ TOTAL_GATED_SLICE_COUNT_PATTERN: re.Pattern[str] = re.compile(
56
+ r"\b([A-Za-z]+)\s+gated\s+slices\b",
57
+ re.IGNORECASE,
58
+ )
59
+
60
+ GATE_VALIDATOR_NAME_PATTERN: re.Pattern[str] = re.compile(r"`(check_[A-Za-z0-9_]+)`")
61
+
62
+ ARGS_GATE_VALIDATOR_NAME: str = "check_docstring_args_match_signature"
63
+
64
+ MAX_GATE_COUNT_ISSUES: int = 4
65
+
66
+ GATE_COUNT_MESSAGE_TEMPLATE: str = (
67
+ "{rule_basename} states '{stated_phrase}' ({stated_count}) but names "
68
+ "{named_count} distinct free-form gate validator(s) ({named_validators}). The "
69
+ "rule's spelled-out gate count drifts from the validators it enumerates — the "
70
+ "companion-doc-vs-implementation drift this rule governs. Update the count "
71
+ "word to {named_count} and the '... gated slices' total to {total_count} in "
72
+ "this same change, and name every gate validator the prose counts."
73
+ )
74
+
75
+ GATE_COUNT_SYSTEM_MESSAGE: str = (
76
+ "Gate-validator count in docstring-prose-matches-implementation.md drifted "
77
+ "from the validators it names - update the count word in this same change"
78
+ )
79
+
80
+ GATE_COUNT_ADDITIONAL_CONTEXT: str = (
81
+ "The rule docstring-prose-matches-implementation.md states a spelled-out "
82
+ "count of free-form docstring gate validators ('Four more gate validators') "
83
+ "and a total ('five gated slices'), then names each validator in backticks "
84
+ "(`check_docstring_fallback_branch_coverage`, ...). When a new "
85
+ "`check_docstring_*` gate is added, name it in the prose and bump both count "
86
+ "words: the 'N more gate validators' count equals the number of distinct "
87
+ "free-form validators named after it, and the 'M gated slices' total equals "
88
+ "that count plus one for check_docstring_args_match_signature. Keep the count "
89
+ "words and the named-validator list in step in the same change."
90
+ )
@@ -0,0 +1,27 @@
1
+ """Configuration constants for the duplicate_rmtree_helper_blocker PreToolUse hook."""
2
+
3
+ import re
4
+
5
+ PYTHON_FILE_EXTENSION: str = ".py"
6
+
7
+ HELPER_DEFINITION_PATTERN: re.Pattern[str] = re.compile(
8
+ r"^[ \t]*def[ \t]+(?:_strip_read_only_and_retry|_force_remove_tree|force_rmtree)[ \t]*\(",
9
+ re.MULTILINE,
10
+ )
11
+
12
+ TRIPLE_QUOTED_STRING_PATTERN: re.Pattern[str] = re.compile(
13
+ r'"""[\s\S]*?"""|\'\'\'[\s\S]*?\'\'\'',
14
+ )
15
+
16
+ ALL_EXEMPT_PATH_FRAGMENTS: tuple[str, ...] = (
17
+ "windows_rmtree_blocker.py",
18
+ "duplicate_rmtree_helper_blocker.py",
19
+ "windows_safe_rmtree.py",
20
+ "windows_filesystem.py",
21
+ "session_env_cleanup.py",
22
+ "_md_to_html_blocker_test_support.py",
23
+ "teardown_worktrees.py",
24
+ )
25
+
26
+ ALL_EXEMPT_TEST_FILE_PREFIXES: tuple[str, ...] = ("test_",)
27
+ ALL_EXEMPT_TEST_FILE_SUFFIXES: tuple[str, ...] = ("_test.py",)
@@ -0,0 +1,59 @@
1
+ """Shared fail-safe logger for hook block events.
2
+
3
+ Every blocking hook calls log_hook_block at the moment it decides to block,
4
+ so the user has a single log showing what tripped and why.
5
+ """
6
+
7
+ import datetime
8
+ import json
9
+ from pathlib import Path
10
+
11
+ _HOOK_BLOCKS_LOG_RELATIVE_PATH = ".claude/logs/hook-blocks.log"
12
+ _MAX_PREVIEW_LENGTH = 500
13
+
14
+
15
+ def log_hook_block(
16
+ calling_hook_name: str,
17
+ hook_event: str,
18
+ block_reason: str,
19
+ tool_name: str | None = None,
20
+ offending_input_preview: str | None = None,
21
+ ) -> None:
22
+ """Append one JSON record to the hook-blocks log for a block decision.
23
+
24
+ Creates the logs directory if absent. Skips logging when the home directory
25
+ cannot be resolved, and silently swallows all IO errors otherwise, so a
26
+ logging failure never changes a hook's decision.
27
+
28
+ Args:
29
+ calling_hook_name: The script basename of the hook that is blocking.
30
+ hook_event: The hook event type, e.g. ``PreToolUse`` or ``Stop``.
31
+ block_reason: The human-readable reason the hook is blocking.
32
+ tool_name: The Claude tool name when available, e.g. ``Bash``.
33
+ offending_input_preview: A short excerpt of the input that triggered
34
+ the block; truncated to 500 characters before writing.
35
+ """
36
+ try:
37
+ home_directory = Path.home()
38
+ except RuntimeError:
39
+ return
40
+
41
+ try:
42
+ log_path = home_directory / _HOOK_BLOCKS_LOG_RELATIVE_PATH
43
+ log_path.parent.mkdir(parents=True, exist_ok=True)
44
+
45
+ log_record: dict[str, str] = {
46
+ "timestamp": datetime.datetime.now().isoformat(),
47
+ "hook": calling_hook_name,
48
+ "event": hook_event,
49
+ "reason": block_reason,
50
+ }
51
+ if tool_name is not None:
52
+ log_record["tool"] = tool_name
53
+ if offending_input_preview is not None:
54
+ log_record["preview"] = offending_input_preview[:_MAX_PREVIEW_LENGTH]
55
+
56
+ with log_path.open("a", encoding="utf-8") as log_file:
57
+ log_file.write(json.dumps(log_record) + "\n")
58
+ except OSError:
59
+ pass