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
@@ -69,7 +69,14 @@ from code_rules_docstrings import ( # noqa: E402
69
69
  check_docstring_args_match_signature,
70
70
  check_docstring_fallback_branch_coverage,
71
71
  check_docstring_format,
72
+ check_docstring_names_undefined_constant,
72
73
  check_docstring_no_consumer_claim,
74
+ check_docstring_no_inline_literal_claim,
75
+ check_docstring_returns_plural_cardinality,
76
+ check_docstring_step_enumeration_dispatch_coverage,
77
+ check_docstring_tuple_enumeration_match,
78
+ check_docstring_unguarded_malformed_payload_claim,
79
+ check_module_docstring_names_public_checks,
73
80
  )
74
81
  from code_rules_duplicate_body import ( # noqa: E402
75
82
  advise_cross_skill_duplicate_helper,
@@ -154,6 +161,7 @@ from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
154
161
  PRECHECK_USAGE_EXIT_CODE,
155
162
  PRECHECK_USAGE_MESSAGE,
156
163
  )
164
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
157
165
  from hooks_constants.setup_project_paths_constants import ( # noqa: E402
158
166
  UTF8_BYTE_ORDER_MARK,
159
167
  )
@@ -255,9 +263,34 @@ def validate_content(
255
263
  all_issues.extend(check_docstring_args_match_signature(effective_content, file_path))
256
264
  all_issues.extend(check_docstring_fallback_branch_coverage(effective_content, file_path))
257
265
  all_issues.extend(check_docstring_no_consumer_claim(effective_content, file_path))
266
+ all_issues.extend(
267
+ check_docstring_unguarded_malformed_payload_claim(
268
+ effective_content, file_path
269
+ )
270
+ )
271
+ all_issues.extend(
272
+ check_docstring_no_inline_literal_claim(effective_content, file_path)
273
+ )
258
274
  all_issues.extend(
259
275
  check_class_docstring_names_public_methods(effective_content, file_path)
260
276
  )
277
+ all_issues.extend(
278
+ check_module_docstring_names_public_checks(effective_content, file_path)
279
+ )
280
+ all_issues.extend(
281
+ check_docstring_tuple_enumeration_match(effective_content, file_path)
282
+ )
283
+ all_issues.extend(
284
+ check_docstring_step_enumeration_dispatch_coverage(
285
+ effective_content, file_path
286
+ )
287
+ )
288
+ all_issues.extend(
289
+ check_docstring_returns_plural_cardinality(effective_content, file_path)
290
+ )
291
+ all_issues.extend(
292
+ check_docstring_names_undefined_constant(effective_content, file_path)
293
+ )
261
294
  all_issues.extend(
262
295
  check_boolean_naming(
263
296
  effective_content,
@@ -761,6 +794,11 @@ def _write_deny_payload(deny_reason: str, deny_stream: TextIO) -> None:
761
794
  "permissionDecisionReason": deny_reason,
762
795
  }
763
796
  }
797
+ log_hook_block(
798
+ calling_hook_name="code_rules_enforcer.py",
799
+ hook_event="PreToolUse",
800
+ block_reason=deny_reason,
801
+ )
764
802
  deny_stream.write(json.dumps(deny_payload) + "\n")
765
803
  deny_stream.flush()
766
804
 
@@ -20,12 +20,14 @@ from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
20
20
  ALL_HOOK_INFRASTRUCTURE_PATTERNS,
21
21
  ALL_MIGRATION_PATH_PATTERNS,
22
22
  ALL_ROOT_ANCHORED_EPHEMERAL_DIRECTORIES,
23
+ ALL_STRICT_TEST_DIRECTORY_SEGMENTS,
23
24
  ALL_TEST_PATH_PATTERNS,
24
25
  ALL_WORKFLOW_REGISTRY_PATTERNS,
25
26
  CLAUDE_JOB_DIR_ENVIRONMENT_VARIABLE_NAME,
26
27
  CLAUDE_JOB_DIR_SCRATCH_SUBDIRECTORY,
27
28
  EPHEMERAL_EXEMPT_DISABLE_ENVIRONMENT_VARIABLE_NAME,
28
29
  LEADING_DRIVE_LETTER_PATTERN,
30
+ STRICT_TEST_FILE_BASENAME_PATTERN,
29
31
  )
30
32
  from hooks_constants.unused_module_import_constants import ( # noqa: E402
31
33
  TYPE_CHECKING_IDENTIFIER,
@@ -55,6 +57,23 @@ def is_test_file(file_path: str) -> bool:
55
57
  return any(pattern in path_lower for pattern in ALL_TEST_PATH_PATTERNS)
56
58
 
57
59
 
60
+ def is_strict_test_file(file_path: str) -> bool:
61
+ """Check if a file is a genuine test module by its basename, not a mid-name match.
62
+
63
+ A production module whose name carries the substring ``test`` mid-name —
64
+ such as ``code_rules_test_assertions.py`` — is not a test module. This
65
+ predicate anchors on the basename shape (``test_*`` / ``*_test.*`` /
66
+ ``*.test.*`` / ``*.spec.*`` / ``conftest.py``) or a ``/tests/`` path
67
+ segment, so it keeps such production modules out of the test exemption that
68
+ the substring-based is_test_file applies.
69
+ """
70
+ normalized_path = file_path.lower().replace("\\", "/")
71
+ if any(segment in normalized_path for segment in ALL_STRICT_TEST_DIRECTORY_SEGMENTS):
72
+ return True
73
+ basename_lower = normalized_path.rsplit("/", 1)[-1]
74
+ return STRICT_TEST_FILE_BASENAME_PATTERN.match(basename_lower) is not None
75
+
76
+
58
77
  def is_workflow_registry_file(file_path: str) -> bool:
59
78
  """Check if file is a workflow state/module registry file.
60
79
 
@@ -0,0 +1,426 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook: pre-flight gate for the code-verifier subagent spawn.
3
+
4
+ The hook fires only on an ``Agent`` tool call whose ``subagent_type`` is
5
+ ``code-verifier``. Before that verification spawn runs, the hook checks the
6
+ branch for two committability problems against the resolved base ref: a real
7
+ merge conflict (a non-mutating trial-merge of HEAD against the base ref) and a
8
+ CODE_RULES violation on a line added in the uncommitted working tree. When
9
+ either fires, the hook denies the spawn with a reason addressed to the spawning
10
+ agent that names the conflicting files and the violating file:line, so that
11
+ agent fixes them and re-spawns. Both checks fail OPEN on any infrastructure
12
+ problem — a non-repo cwd, an absent base ref, a git or engine failure, or a
13
+ timeout — because the authoritative fail-closed CODE_RULES enforcement already
14
+ runs at Write time and at commit time. The hook never network-fetches and never
15
+ mutates the index or working tree.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import contextlib
21
+ import io
22
+ import json
23
+ import subprocess
24
+ import sys
25
+ from pathlib import Path
26
+ from typing import TextIO
27
+
28
+ _blocking_dir = str(Path(__file__).resolve().parent)
29
+ if _blocking_dir not in sys.path:
30
+ sys.path.insert(0, _blocking_dir)
31
+
32
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
33
+ if _hooks_dir not in sys.path:
34
+ sys.path.append(_hooks_dir)
35
+
36
+ from verification_verdict_store import ( # noqa: E402
37
+ candidate_base_references,
38
+ resolve_merge_base,
39
+ resolve_repo_root,
40
+ run_git,
41
+ untracked_file_paths,
42
+ )
43
+
44
+ from hooks_constants.code_verifier_spawn_preflight_gate_constants import ( # noqa: E402
45
+ ALL_MERGE_TREE_COMMAND_FLAGS,
46
+ ALL_NAME_ONLY_WORKTREE_DIFF_FLAGS,
47
+ ALL_UNIFIED_ZERO_DIFF_FLAGS,
48
+ CODE_RULES_SECTION_HEADER,
49
+ CODE_VERIFIER_SUBAGENT_TYPE,
50
+ DENY_REASON_LEAD,
51
+ GATE_SCRIPTS_RELATIVE_PATH,
52
+ MERGE_CONFLICT_SECTION_HEADER,
53
+ MERGE_TREE_CLEAN_EXIT_CODE,
54
+ MERGE_TREE_CONFLICT_EXIT_CODE,
55
+ MERGE_TREE_TIMEOUT_SECONDS,
56
+ )
57
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
58
+ from hooks_constants.pr_converge_bugteam_enforcer_constants import ( # noqa: E402
59
+ AGENT_TOOL_NAME,
60
+ )
61
+
62
+ _scripts_dir = str(Path(__file__).resolve().parents[2] / GATE_SCRIPTS_RELATIVE_PATH)
63
+ if _scripts_dir not in sys.path:
64
+ sys.path.insert(0, _scripts_dir)
65
+
66
+ from code_rules_gate import ( # noqa: E402
67
+ ValidateContentCallable,
68
+ _collect_partitioned_violations,
69
+ _report_partitioned_violations,
70
+ is_code_path,
71
+ load_validate_content,
72
+ parse_added_line_numbers,
73
+ whole_file_line_set,
74
+ )
75
+
76
+
77
+ def _should_run(payload_by_field: dict[str, object]) -> bool:
78
+ """Return True only for a code-verifier Agent spawn.
79
+
80
+ Args:
81
+ payload_by_field: The full PreToolUse hook payload (already
82
+ JSON-parsed), keyed by top-level field name.
83
+
84
+ Returns:
85
+ True when the tool is Agent and ``tool_input.subagent_type`` is
86
+ ``code-verifier``; False for every other shape.
87
+ """
88
+ if payload_by_field.get("tool_name", "") != AGENT_TOOL_NAME:
89
+ return False
90
+ tool_input = payload_by_field.get("tool_input", {})
91
+ if not isinstance(tool_input, dict):
92
+ return False
93
+ return tool_input.get("subagent_type", "") == CODE_VERIFIER_SUBAGENT_TYPE
94
+
95
+
96
+ def _resolve_repo_root_and_base(working_directory: str | None) -> tuple[str, str, str] | None:
97
+ """Resolve the repo root, merge-base sha, and chosen base ref.
98
+
99
+ Args:
100
+ working_directory: The spawn's working directory from the payload, or
101
+ None when the payload carries no ``cwd``.
102
+
103
+ Returns:
104
+ A ``(repo_root, merge_base_sha, base_ref)`` triple, or None when the
105
+ directory is not a work tree or no base ref resolves on disk — the
106
+ caller fails OPEN on None.
107
+ """
108
+ start_directory = working_directory if working_directory else str(Path.cwd())
109
+ repo_root = resolve_repo_root(start_directory)
110
+ if repo_root is None:
111
+ return None
112
+ merge_base_sha = resolve_merge_base(repo_root)
113
+ if merge_base_sha is None:
114
+ return None
115
+ for each_reference in candidate_base_references(repo_root):
116
+ if run_git(repo_root, "merge-base", "HEAD", each_reference) is not None:
117
+ return repo_root, merge_base_sha, each_reference
118
+ return None
119
+
120
+
121
+ def _run_trial_merge(repo_root: str, base_ref: str) -> tuple[int, str] | None:
122
+ """Run the non-mutating trial-merge and return its exit code and stdout.
123
+
124
+ Args:
125
+ repo_root: The repository top-level directory.
126
+ base_ref: The base ref to trial-merge HEAD against.
127
+
128
+ Returns:
129
+ A ``(returncode, stdout)`` pair, or None when the command is missing,
130
+ times out, or raises an OS-level error — the caller fails OPEN on None.
131
+ """
132
+ try:
133
+ completed_process = subprocess.run(
134
+ ["git", "-C", repo_root, *ALL_MERGE_TREE_COMMAND_FLAGS, base_ref, "HEAD"],
135
+ capture_output=True,
136
+ text=True,
137
+ encoding="utf-8",
138
+ errors="replace",
139
+ timeout=MERGE_TREE_TIMEOUT_SECONDS,
140
+ check=False,
141
+ )
142
+ except (OSError, subprocess.TimeoutExpired):
143
+ return None
144
+ return completed_process.returncode, completed_process.stdout
145
+
146
+
147
+ def _conflicting_files(repo_root: str, base_ref: str) -> list[str] | None:
148
+ """Return the files that conflict when HEAD trial-merges against the base.
149
+
150
+ Args:
151
+ repo_root: The repository top-level directory.
152
+ base_ref: The base ref to trial-merge HEAD against.
153
+
154
+ Returns:
155
+ The conflicting file paths on a conflict exit, an empty list on a
156
+ clean merge, or None on any infrastructure failure (command missing,
157
+ timeout, or an exit code that is neither clean nor conflict) — the
158
+ caller fails OPEN on None.
159
+ """
160
+ merge_outcome = _run_trial_merge(repo_root, base_ref)
161
+ if merge_outcome is None:
162
+ return None
163
+ return_code, merge_stdout = merge_outcome
164
+ if return_code == MERGE_TREE_CLEAN_EXIT_CODE:
165
+ return []
166
+ if return_code != MERGE_TREE_CONFLICT_EXIT_CODE:
167
+ return None
168
+ return _parse_conflicting_paths(merge_stdout)
169
+
170
+
171
+ def _parse_conflicting_paths(merge_stdout: str) -> list[str]:
172
+ """Extract conflicting paths from trial-merge stdout.
173
+
174
+ Args:
175
+ merge_stdout: The stdout of a conflict-exit trial-merge: the written
176
+ tree-OID line, then one conflicting path per line, then a blank
177
+ line and informational text.
178
+
179
+ Returns:
180
+ The conflicting file paths — the lines after the tree-OID up to the
181
+ first blank line.
182
+ """
183
+ all_lines = merge_stdout.splitlines()
184
+ conflicting_paths: list[str] = []
185
+ for each_line in all_lines[1:]:
186
+ if not each_line.strip():
187
+ break
188
+ conflicting_paths.append(each_line)
189
+ return conflicting_paths
190
+
191
+
192
+ def _working_tree_added_lines_by_path(
193
+ repo_root: str, merge_base_sha: str
194
+ ) -> tuple[list[Path], dict[Path, set[int]]] | None:
195
+ """Build the code-file surface and its working-tree-vs-merge-base added lines.
196
+
197
+ Args:
198
+ repo_root: The repository top-level directory.
199
+ merge_base_sha: The merge-base sha the surface diffs against.
200
+
201
+ Returns:
202
+ A ``(file_paths, added_lines_by_path)`` pair keyed by resolved
203
+ absolute path, or None when a surface git query fails — the caller
204
+ fails OPEN on None.
205
+ """
206
+ tracked_changed_text = run_git(repo_root, *ALL_NAME_ONLY_WORKTREE_DIFF_FLAGS, merge_base_sha)
207
+ if tracked_changed_text is None:
208
+ return None
209
+ untracked_paths = untracked_file_paths(repo_root)
210
+ if untracked_paths is None:
211
+ return None
212
+ repo_root_path = Path(repo_root)
213
+ file_paths: list[Path] = []
214
+ added_lines_by_path: dict[Path, set[int]] = {}
215
+ for each_relative in tracked_changed_text.splitlines():
216
+ if not each_relative or not is_code_path(Path(each_relative)):
217
+ continue
218
+ resolved_path = (repo_root_path / each_relative).resolve()
219
+ file_paths.append(resolved_path)
220
+ added_lines_by_path[resolved_path] = _tracked_file_added_lines(
221
+ repo_root, merge_base_sha, each_relative
222
+ )
223
+ for each_relative in untracked_paths:
224
+ if not is_code_path(Path(each_relative)):
225
+ continue
226
+ resolved_path = (repo_root_path / each_relative).resolve()
227
+ file_paths.append(resolved_path)
228
+ added_lines_by_path[resolved_path] = whole_file_line_set(resolved_path)
229
+ return file_paths, added_lines_by_path
230
+
231
+
232
+ def _tracked_file_added_lines(repo_root: str, merge_base_sha: str, relative_path: str) -> set[int]:
233
+ """Return the working-tree-added line numbers for one tracked file.
234
+
235
+ Args:
236
+ repo_root: The repository top-level directory.
237
+ merge_base_sha: The merge-base sha the diff runs against.
238
+ relative_path: The repo-relative path of the tracked changed file.
239
+
240
+ Returns:
241
+ The 1-indexed line numbers added vs the merge base in the working
242
+ tree, or an empty set when the per-file diff fails.
243
+ """
244
+ unified_diff_text = run_git(
245
+ repo_root, *ALL_UNIFIED_ZERO_DIFF_FLAGS, merge_base_sha, "--", relative_path
246
+ )
247
+ if unified_diff_text is None:
248
+ return set()
249
+ return parse_added_line_numbers(unified_diff_text)
250
+
251
+
252
+ def _code_rules_report(
253
+ repo_root: str, all_file_paths: list[Path], all_added_lines_by_path: dict[Path, set[int]]
254
+ ) -> str | None:
255
+ """Run the CODE_RULES engine and return its blocking report, or None.
256
+
257
+ Args:
258
+ repo_root: The repository top-level directory.
259
+ all_file_paths: The resolved code-file paths to inspect.
260
+ all_added_lines_by_path: Per-file working-tree-added line numbers keyed
261
+ by resolved absolute path.
262
+
263
+ Returns:
264
+ The engine's grouped file:line report when a blocking violation lands
265
+ on an added line, or None when the surface is clean, only an unreadable
266
+ changed file caused a non-zero gate exit, the engine fails to load, or
267
+ any engine error arises — every non-block outcome fails OPEN. The
268
+ harness hook timeout in hooks.json is the wall-clock bound on a runaway
269
+ engine.
270
+ """
271
+ if not all_file_paths:
272
+ return None
273
+ try:
274
+ validate_content = load_validate_content()
275
+ except SystemExit:
276
+ return None
277
+ try:
278
+ blocking_present, captured_report = _run_gate_capturing_stderr(
279
+ validate_content, all_file_paths, Path(repo_root), all_added_lines_by_path
280
+ )
281
+ except OSError:
282
+ return None
283
+ if not blocking_present:
284
+ return None
285
+ return captured_report
286
+
287
+
288
+ def _run_gate_capturing_stderr(
289
+ validate_content: ValidateContentCallable,
290
+ all_file_paths: list[Path],
291
+ repository_root: Path,
292
+ all_added_lines_by_path: dict[Path, set[int]],
293
+ ) -> tuple[bool, str]:
294
+ """Run the gate, reporting whether a blocking violation was actually found.
295
+
296
+ Args:
297
+ validate_content: The enforcer ``validate_content`` callable.
298
+ all_file_paths: The resolved code-file paths to inspect.
299
+ repository_root: The repository root path the gate resolves against.
300
+ all_added_lines_by_path: Per-file working-tree-added line numbers keyed
301
+ by resolved absolute path.
302
+
303
+ Returns:
304
+ A ``(blocking_present, captured_report)`` pair. ``blocking_present`` is
305
+ True only when at least one blocking violation landed on an added line;
306
+ an unreadable changed file alone (which exits the gate non-zero) leaves
307
+ it False, so the caller fails OPEN. ``captured_report`` is the gate's
308
+ grouped stderr report.
309
+ """
310
+ blocking_by_file, advisory_by_file, skipped_unreadable_count = (
311
+ _collect_partitioned_violations(
312
+ validate_content,
313
+ all_file_paths,
314
+ repository_root,
315
+ all_added_lines_by_path,
316
+ False,
317
+ )
318
+ )
319
+ captured_stderr = io.StringIO()
320
+ with contextlib.redirect_stderr(captured_stderr):
321
+ _report_partitioned_violations(
322
+ blocking_by_file,
323
+ advisory_by_file,
324
+ repository_root,
325
+ False,
326
+ skipped_unreadable_count,
327
+ )
328
+ return bool(blocking_by_file), captured_stderr.getvalue()
329
+
330
+
331
+ def _build_deny_reason(
332
+ all_conflicting_files: list[str] | None, base_ref: str, code_rules_report: str | None
333
+ ) -> str | None:
334
+ """Assemble the spawner-addressed deny reason from the two check results.
335
+
336
+ Args:
337
+ all_conflicting_files: The conflicting file paths from the conflict
338
+ check, an empty list when clean, or None when that check failed open.
339
+ base_ref: The base ref named in the conflict section header.
340
+ code_rules_report: The grouped report from the CODE_RULES check, or None
341
+ when that check found nothing or failed open.
342
+
343
+ Returns:
344
+ The full deny reason when either check fired, or None when neither
345
+ produced an issue.
346
+ """
347
+ reason_sections: list[str] = []
348
+ if all_conflicting_files:
349
+ conflict_lines = "\n".join(f" {each_path}" for each_path in all_conflicting_files)
350
+ conflict_header = MERGE_CONFLICT_SECTION_HEADER.format(base_ref=base_ref)
351
+ reason_sections.append(f"{conflict_header}\n{conflict_lines}")
352
+ if code_rules_report:
353
+ reason_sections.append(f"{CODE_RULES_SECTION_HEADER}\n{code_rules_report.strip()}")
354
+ if not reason_sections:
355
+ return None
356
+ return DENY_REASON_LEAD + "\n\n" + "\n\n".join(reason_sections)
357
+
358
+
359
+ def _emit_deny_payload(output_stream: TextIO, reason: str) -> None:
360
+ """Write the PreToolUse deny payload to the provided stream.
361
+
362
+ Args:
363
+ output_stream: Writable text stream — production code passes
364
+ ``sys.stdout``; tests pass a ``StringIO`` to capture the JSON.
365
+ reason: The ``permissionDecisionReason`` text for the deny payload.
366
+ """
367
+ deny_payload = {
368
+ "hookSpecificOutput": {
369
+ "hookEventName": "PreToolUse",
370
+ "permissionDecision": "deny",
371
+ "permissionDecisionReason": reason,
372
+ }
373
+ }
374
+ log_hook_block(
375
+ calling_hook_name="code_verifier_spawn_preflight_gate.py",
376
+ hook_event="PreToolUse",
377
+ block_reason=reason,
378
+ )
379
+ output_stream.write(json.dumps(deny_payload) + "\n")
380
+ output_stream.flush()
381
+
382
+
383
+ def _preflight_deny_reason(payload_by_field: dict[str, object]) -> str | None:
384
+ """Run both pre-flight checks and return a deny reason, or None to allow.
385
+
386
+ Args:
387
+ payload_by_field: The full PreToolUse hook payload, keyed by top-level
388
+ field name.
389
+
390
+ Returns:
391
+ The deny reason when a check fired, or None when both checks pass or
392
+ fail open.
393
+ """
394
+ working_directory = payload_by_field.get("cwd")
395
+ resolution = _resolve_repo_root_and_base(
396
+ working_directory if isinstance(working_directory, str) else None
397
+ )
398
+ if resolution is None:
399
+ return None
400
+ repo_root, merge_base_sha, base_ref = resolution
401
+ conflicting_files = _conflicting_files(repo_root, base_ref)
402
+ surface = _working_tree_added_lines_by_path(repo_root, merge_base_sha)
403
+ code_rules_report = None
404
+ if surface is not None:
405
+ file_paths, added_lines_by_path = surface
406
+ code_rules_report = _code_rules_report(repo_root, file_paths, added_lines_by_path)
407
+ return _build_deny_reason(conflicting_files, base_ref, code_rules_report)
408
+
409
+
410
+ def main() -> None:
411
+ try:
412
+ hook_payload = json.load(sys.stdin)
413
+ except json.JSONDecodeError:
414
+ sys.exit(0)
415
+ if not isinstance(hook_payload, dict):
416
+ sys.exit(0)
417
+ if not _should_run(hook_payload):
418
+ sys.exit(0)
419
+ deny_reason = _preflight_deny_reason(hook_payload)
420
+ if deny_reason is not None:
421
+ _emit_deny_payload(sys.stdout, deny_reason)
422
+ sys.exit(0)
423
+
424
+
425
+ if __name__ == "__main__":
426
+ main()
@@ -14,6 +14,13 @@ import subprocess
14
14
  import sys
15
15
  from pathlib import Path
16
16
 
17
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
18
+ if _hooks_dir not in sys.path:
19
+ sys.path.insert(0, _hooks_dir)
20
+
21
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
22
+
23
+
17
24
  def _resolve_pr_number(command: str, cwd: str | None) -> int | None:
18
25
  direct_match = re.search(r"\bgh\s+pr\s+ready\s+(\d+)", command)
19
26
  if direct_match:
@@ -112,15 +119,22 @@ def main() -> None:
112
119
  if completed_process.returncode in (0, 2):
113
120
  sys.exit(0)
114
121
 
122
+ block_reason = (
123
+ "Convergence check failed — PR is not ready to mark ready:\n\n" + completed_process.stdout
124
+ )
115
125
  deny_payload = {
116
126
  "hookSpecificOutput": {
117
127
  "hookEventName": "PreToolUse",
118
128
  "permissionDecision": "deny",
119
- "permissionDecisionReason": (
120
- "Convergence check failed — PR is not ready to mark ready:\n\n" + completed_process.stdout
121
- ),
129
+ "permissionDecisionReason": block_reason,
122
130
  }
123
131
  }
132
+ log_hook_block(
133
+ calling_hook_name="convergence_gate_blocker.py",
134
+ hook_event="PreToolUse",
135
+ block_reason=block_reason,
136
+ tool_name="Bash",
137
+ )
124
138
  print(json.dumps(deny_payload))
125
139
  sys.stdout.flush()
126
140
  sys.exit(0)
@@ -19,6 +19,7 @@ from hooks_constants.convergence_branch_constants import ( # noqa: E402
19
19
  CONVERGENCE_BRANCH_SUFFIX_PATTERN,
20
20
  CONVERGENCE_FORCE_PUSH_DETECTION_PATTERN,
21
21
  )
22
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
22
23
  from hooks_constants.destructive_command_segment_constants import ( # noqa: E402
23
24
  ALL_BENIGN_COMPOUND_SEGMENT_COMMANDS,
24
25
  ALL_COMMAND_LAUNCHER_WRAPPER_COMMANDS,
@@ -197,6 +198,12 @@ def _build_silent_gh_deny_response(matched_description: str) -> dict:
197
198
  "Bash call prevents duplicate execution."
198
199
  )
199
200
  _append_destructive_gate_log_entry(brief_label, full_reason)
201
+ log_hook_block(
202
+ calling_hook_name="destructive_command_blocker.py",
203
+ hook_event="PreToolUse",
204
+ block_reason=full_reason,
205
+ tool_name="Bash",
206
+ )
200
207
  return {
201
208
  "hookSpecificOutput": {
202
209
  "hookEventName": "PreToolUse",