claude-dev-env 1.58.0 → 1.60.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 (106) hide show
  1. package/CLAUDE.md +2 -2
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +36 -3
  3. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +6 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
  5. package/_shared/pr-loop/scripts/reviews_disabled.py +12 -0
  6. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +265 -0
  7. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +29 -0
  8. package/audit-rubrics/category_rubrics/category-b-selector-engine-compat.md +1 -1
  9. package/audit-rubrics/category_rubrics/category-e-dead-code.md +1 -0
  10. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  11. package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
  12. package/bin/install.mjs +100 -27
  13. package/bin/install.test.mjs +133 -1
  14. package/docs/CODE_RULES.md +3 -3
  15. package/hooks/blocking/code_rules_annotations_length.py +153 -0
  16. package/hooks/blocking/code_rules_dead_dataclass_field.py +319 -0
  17. package/hooks/blocking/code_rules_dead_module_constant.py +321 -0
  18. package/hooks/blocking/code_rules_duplicate_body.py +439 -0
  19. package/hooks/blocking/code_rules_enforcer.py +190 -21
  20. package/hooks/blocking/code_rules_magic_values.py +98 -0
  21. package/hooks/blocking/code_rules_shared.py +41 -0
  22. package/hooks/blocking/code_rules_typeddict_stub.py +172 -0
  23. package/hooks/blocking/config/__init__.py +5 -0
  24. package/hooks/blocking/config/verified_commit_constants.py +106 -0
  25. package/hooks/blocking/destructive_command_blocker.py +1027 -12
  26. package/hooks/blocking/hook_prose_detector_consistency.py +150 -0
  27. package/hooks/blocking/subprocess_budget_completeness.py +380 -0
  28. package/hooks/blocking/test_code_rules_enforcer_annotations.py +225 -0
  29. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
  30. package/hooks/blocking/test_code_rules_enforcer_cross_skill_duplicate.py +146 -0
  31. package/hooks/blocking/test_code_rules_enforcer_dead_dataclass_field.py +467 -0
  32. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +188 -0
  33. package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +330 -0
  34. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +179 -0
  35. package/hooks/blocking/test_code_rules_enforcer_magic_slice_bounds.py +133 -0
  36. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias.py +415 -0
  37. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +156 -0
  38. package/hooks/blocking/test_destructive_command_blocker.py +622 -3
  39. package/hooks/blocking/test_hook_prose_detector_consistency.py +265 -0
  40. package/hooks/blocking/test_subprocess_budget_completeness.py +588 -0
  41. package/hooks/blocking/test_verdict_directory_write_blocker.py +720 -0
  42. package/hooks/blocking/test_verification_verdict_store.py +278 -0
  43. package/hooks/blocking/test_verified_commit_gate.py +368 -0
  44. package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -0
  45. package/hooks/blocking/test_verifier_verdict_minter.py +214 -0
  46. package/hooks/blocking/test_workflow_substitution_slot_blocker.py +242 -0
  47. package/hooks/blocking/verdict_directory_write_blocker.py +667 -0
  48. package/hooks/blocking/verification_verdict_store.py +446 -0
  49. package/hooks/blocking/verified_commit_gate.py +523 -0
  50. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +152 -0
  51. package/hooks/blocking/verifier_verdict_minter.py +299 -0
  52. package/hooks/blocking/workflow_substitution_slot_blocker.py +159 -0
  53. package/hooks/diagnostic/test_hook_log_extractor.py +3 -3
  54. package/hooks/hooks.json +58 -1
  55. package/hooks/hooks_constants/blocking_check_limits.py +1 -0
  56. package/hooks/hooks_constants/code_rules_enforcer_constants.py +16 -0
  57. package/hooks/hooks_constants/dead_dataclass_field_constants.py +25 -0
  58. package/hooks/hooks_constants/dead_module_constant_constants.py +20 -0
  59. package/hooks/hooks_constants/destructive_command_segment_constants.py +178 -0
  60. package/hooks/hooks_constants/duplicate_function_body_constants.py +34 -0
  61. package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +30 -0
  62. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
  63. package/hooks/hooks_constants/subprocess_budget_completeness_constants.py +5 -0
  64. package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +22 -0
  65. package/package.json +1 -1
  66. package/rules/docstring-prose-matches-implementation.md +43 -0
  67. package/rules/file-global-constants.md +7 -1
  68. package/rules/hook-prose-matches-detector.md +26 -0
  69. package/rules/no-cross-skill-duplicate-helpers.md +29 -0
  70. package/rules/no-inline-destructive-literals.md +11 -0
  71. package/rules/workflow-substitution-slots.md +7 -0
  72. package/skills/_shared/pr-loop/scripts/preflight_worktree.py +392 -0
  73. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/preflight_constants.py +70 -0
  74. package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +263 -0
  75. package/skills/autoconverge/SKILL.md +67 -19
  76. package/skills/autoconverge/reference/closing-report.md +59 -17
  77. package/skills/autoconverge/reference/convergence.md +7 -3
  78. package/skills/autoconverge/reference/stop-conditions.md +7 -2
  79. package/skills/autoconverge/workflow/aggregate_runs.py +371 -0
  80. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +193 -76
  81. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +76 -0
  82. package/skills/autoconverge/workflow/converge.contract.test.mjs +206 -206
  83. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +265 -0
  84. package/skills/autoconverge/workflow/converge.mjs +234 -42
  85. package/skills/autoconverge/workflow/convergence_summary.py +110 -0
  86. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ab1c2d3e4f5a6b7c8.jsonl +2 -0
  87. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +7 -0
  88. package/skills/autoconverge/workflow/render_report.py +488 -397
  89. package/skills/autoconverge/workflow/test_aggregate_runs.py +134 -0
  90. package/skills/autoconverge/workflow/test_convergence_summary.py +132 -0
  91. package/skills/autoconverge/workflow/test_render_report.py +488 -259
  92. package/skills/pr-converge/reference/per-tick.md +28 -8
  93. package/skills/pr-converge/scripts/check_convergence.py +195 -64
  94. package/skills/pr-converge/scripts/test_check_convergence.py +173 -2
  95. package/skills/rebase/SKILL.md +2 -4
  96. package/skills/update/SKILL.md +37 -5
  97. package/system-prompts/software-engineer.xml +2 -6
  98. package/hooks/blocking/content_search_to_zoekt_redirector.py +0 -59
  99. package/hooks/blocking/content_search_zoekt_bash_block_reason.py +0 -25
  100. package/hooks/blocking/content_search_zoekt_block_payload.py +0 -21
  101. package/hooks/blocking/content_search_zoekt_indexed_paths.py +0 -24
  102. package/hooks/blocking/content_search_zoekt_indexed_roots_config.py +0 -131
  103. package/hooks/blocking/content_search_zoekt_redirect_guidance.py +0 -52
  104. package/hooks/blocking/test_content_search_to_zoekt_redirector_integration.py +0 -61
  105. package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +0 -92
  106. package/hooks/blocking/test_content_search_zoekt_indexed_roots_config.py +0 -102
@@ -0,0 +1,299 @@
1
+ """SubagentStop hook: mint a commit-gate verdict when code-verifier finishes.
2
+
3
+ Only this hook writes verdict files — the main session is denied writes to
4
+ the verdict directory, so a session cannot fabricate a passing verdict. The
5
+ SubagentStop payload names the stopping subagent by ``agent_id``. The hook
6
+ recovers the spawning agent type from the parent transcript
7
+ (``transcript_path``), where the agent's completion record carries its
8
+ identity as sibling ``agentId`` and ``agentType`` keys. When that type is
9
+ ``code-verifier``, the hook pulls the verdict block out of the agent's own
10
+ transcript — the payload key ``agent_transcript_path``; the parent
11
+ ``transcript_path`` supplies only the spawning type and never the verdict, so
12
+ text printed by the main session can never mint — recomputes the live
13
+ change-surface hash for the session
14
+ repository, and writes the verdict bound to that hash. The companion
15
+ ``verified_commit_gate.py`` (PreToolUse) then allows ``git commit`` /
16
+ ``git push`` only while the work tree still matches the verified state.
17
+
18
+ The verifier's final message must end with a fenced block::
19
+
20
+ ```verdict
21
+ {"all_pass": true, "findings": []}
22
+ ```
23
+
24
+ A missing or unparseable block mints nothing, which leaves the gate closed.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import re
31
+ import sys
32
+ import time
33
+ from pathlib import Path
34
+
35
+ blocking_directory = str(Path(__file__).resolve().parent)
36
+ if blocking_directory not in sys.path:
37
+ sys.path.insert(0, blocking_directory)
38
+
39
+ from config.verified_commit_constants import (
40
+ MINTING_AGENT_TYPE,
41
+ SPAWN_LOOKUP_ATTEMPT_COUNT,
42
+ SPAWN_LOOKUP_RETRY_DELAY_SECONDS,
43
+ )
44
+ from verification_verdict_store import (
45
+ branch_surface_manifest,
46
+ manifest_sha256,
47
+ resolve_merge_base,
48
+ resolve_repo_root,
49
+ write_verdict,
50
+ )
51
+
52
+
53
+ def assistant_text_blocks(transcript_path: str) -> list[str]:
54
+ """Collect every assistant text block from a transcript JSONL file.
55
+
56
+ Args:
57
+ transcript_path: Path to the subagent's transcript.
58
+
59
+ Returns:
60
+ The text of each assistant content block, in transcript order;
61
+ empty when the file is missing or holds no assistant text.
62
+ """
63
+ collected_blocks: list[str] = []
64
+ try:
65
+ transcript_lines = (
66
+ Path(transcript_path).read_text(encoding="utf-8", errors="replace").splitlines()
67
+ )
68
+ except OSError:
69
+ return collected_blocks
70
+ for each_line in transcript_lines:
71
+ try:
72
+ transcript_entry = json.loads(each_line)
73
+ except json.JSONDecodeError:
74
+ continue
75
+ if not isinstance(transcript_entry, dict):
76
+ continue
77
+ if transcript_entry.get("type") != "assistant":
78
+ continue
79
+ message_body = transcript_entry.get("message")
80
+ if not isinstance(message_body, dict):
81
+ continue
82
+ content_blocks = message_body.get("content")
83
+ if not isinstance(content_blocks, list):
84
+ continue
85
+ for each_block in content_blocks:
86
+ if isinstance(each_block, dict) and each_block.get("type") == "text":
87
+ block_text = each_block.get("text")
88
+ if isinstance(block_text, str):
89
+ collected_blocks.append(block_text)
90
+ return collected_blocks
91
+
92
+
93
+ def last_verdict_in_blocks(all_text_blocks: list[str]) -> dict | None:
94
+ """Extract the final verdict fence from assistant text blocks.
95
+
96
+ Args:
97
+ all_text_blocks: Assistant text blocks in transcript order.
98
+
99
+ Returns:
100
+ The parsed verdict mapping carrying a boolean ``all_pass`` and a
101
+ list ``findings``, or None when no block holds a wellformed fence.
102
+ """
103
+ verdict_fence_pattern = re.compile(r"```verdict\s*\n(.*?)```", re.DOTALL)
104
+ fence_bodies: list[str] = []
105
+ for each_block in all_text_blocks:
106
+ fence_bodies.extend(verdict_fence_pattern.findall(each_block))
107
+ for each_fence_body in reversed(fence_bodies):
108
+ try:
109
+ verdict_record = json.loads(each_fence_body)
110
+ except json.JSONDecodeError:
111
+ continue
112
+ if not isinstance(verdict_record, dict):
113
+ continue
114
+ if not isinstance(verdict_record.get("all_pass"), bool):
115
+ continue
116
+ if not isinstance(verdict_record.get("findings"), list):
117
+ continue
118
+ return verdict_record
119
+ return None
120
+
121
+
122
+ def _transcript_entries(transcript_path: str) -> list[dict]:
123
+ """Parse every JSON object line of a transcript file.
124
+
125
+ Args:
126
+ transcript_path: Path to the parent session transcript.
127
+
128
+ Returns:
129
+ Each parseable object entry in transcript order; empty when the
130
+ file is missing or holds no object lines.
131
+ """
132
+ parsed_entries: list[dict] = []
133
+ try:
134
+ transcript_lines = (
135
+ Path(transcript_path).read_text(encoding="utf-8", errors="replace").splitlines()
136
+ )
137
+ except OSError:
138
+ return parsed_entries
139
+ for each_line in transcript_lines:
140
+ try:
141
+ transcript_entry = json.loads(each_line)
142
+ except json.JSONDecodeError:
143
+ continue
144
+ if isinstance(transcript_entry, dict):
145
+ parsed_entries.append(transcript_entry)
146
+ return parsed_entries
147
+
148
+
149
+ def _agent_type_in_node(transcript_node: object, agent_id: str) -> str | None:
150
+ """Search one parsed transcript value for a spawn record naming an agent.
151
+
152
+ Walks a transcript value and its nested mappings and sequences for a
153
+ mapping whose ``agentId`` equals the stopping agent and whose
154
+ ``agentType`` is a string. Only a structured ``agentType`` key counts, so
155
+ a main-session text block that merely quotes the words cannot match.
156
+
157
+ Args:
158
+ transcript_node: A JSON value drawn from a parsed transcript entry.
159
+ agent_id: The stopping subagent's id from the payload.
160
+
161
+ Returns:
162
+ The ``agentType`` of the matching mapping, or None when no nested
163
+ value names this agent.
164
+ """
165
+ if isinstance(transcript_node, dict):
166
+ recorded_type = transcript_node.get("agentType")
167
+ if transcript_node.get("agentId") == agent_id and isinstance(recorded_type, str):
168
+ return recorded_type
169
+ for each_value in transcript_node.values():
170
+ nested_type = _agent_type_in_node(each_value, agent_id)
171
+ if nested_type is not None:
172
+ return nested_type
173
+ return None
174
+ if isinstance(transcript_node, list):
175
+ for each_item in transcript_node:
176
+ nested_type = _agent_type_in_node(each_item, agent_id)
177
+ if nested_type is not None:
178
+ return nested_type
179
+ return None
180
+
181
+
182
+ def _agent_type_from_entries(all_entries: list[dict], agent_id: str) -> str | None:
183
+ """Find the spawn record naming an agent across parent-transcript entries.
184
+
185
+ Args:
186
+ all_entries: Parsed parent-transcript entries.
187
+ agent_id: The stopping subagent's id from the payload.
188
+
189
+ Returns:
190
+ The ``agentType`` recorded for the agent, or None when no entry's
191
+ spawn record names it.
192
+ """
193
+ for each_entry in all_entries:
194
+ recorded_type = _agent_type_in_node(each_entry, agent_id)
195
+ if recorded_type is not None:
196
+ return recorded_type
197
+ return None
198
+
199
+
200
+ def _resolve_agent_type_with_retry(transcript_path: str, agent_id: str) -> str | None:
201
+ """Read the parent transcript and resolve the agent's type, with retry.
202
+
203
+ The agent's completion record is not reliably flushed to the parent
204
+ transcript at the instant SubagentStop fires, so a single read can miss it
205
+ and silently mint nothing. Each attempt re-reads the transcript; a bounded
206
+ sleep separates attempts so a late-arriving record resolves on a later read.
207
+
208
+ Args:
209
+ transcript_path: Path to the parent session transcript.
210
+ agent_id: The stopping subagent's id from the payload.
211
+
212
+ Returns:
213
+ The recorded ``agentType``, or None when no attempt finds the spawn
214
+ record naming this agent.
215
+ """
216
+ for each_attempt_index in range(SPAWN_LOOKUP_ATTEMPT_COUNT):
217
+ all_entries = _transcript_entries(transcript_path)
218
+ recorded_type = _agent_type_from_entries(all_entries, agent_id)
219
+ if recorded_type is not None:
220
+ return recorded_type
221
+ if each_attempt_index < SPAWN_LOOKUP_ATTEMPT_COUNT - 1:
222
+ time.sleep(SPAWN_LOOKUP_RETRY_DELAY_SECONDS)
223
+ return None
224
+
225
+
226
+ def resolved_subagent_type(subagent_stop_payload: dict) -> str | None:
227
+ """Recover the spawning agent type for a SubagentStop payload.
228
+
229
+ The payload names the stopping subagent by ``agent_id``. Its spawn type
230
+ lives on the agent's completion record in the parent transcript, attached
231
+ as sibling ``agentId`` and ``agentType`` keys, so the type is read from
232
+ that record. The read retries because the record may not be flushed at the
233
+ instant the hook fires.
234
+
235
+ Args:
236
+ subagent_stop_payload: The SubagentStop hook payload.
237
+
238
+ Returns:
239
+ The agent type this subagent was spawned with, or None when the agent
240
+ id is absent or no spawn record names its type.
241
+ """
242
+ agent_id = subagent_stop_payload.get("agent_id", "")
243
+ if not agent_id:
244
+ return None
245
+ return _resolve_agent_type_with_retry(
246
+ subagent_stop_payload.get("transcript_path", ""), agent_id
247
+ )
248
+
249
+
250
+ def mint_for_payload(subagent_stop_payload: dict) -> Path | None:
251
+ """Mint a verdict file for a code-verifier stop event.
252
+
253
+ Args:
254
+ subagent_stop_payload: The SubagentStop hook payload.
255
+
256
+ Returns:
257
+ The verdict file path when minted; None when the payload is not a
258
+ code-verifier stop, the transcript holds no verdict, or the
259
+ session directory is not a work tree with an upstream base.
260
+ """
261
+ if resolved_subagent_type(subagent_stop_payload) != MINTING_AGENT_TYPE:
262
+ return None
263
+ agent_transcript_path = subagent_stop_payload.get("agent_transcript_path", "")
264
+ if not agent_transcript_path:
265
+ return None
266
+ verdict_record = last_verdict_in_blocks(assistant_text_blocks(agent_transcript_path))
267
+ if verdict_record is None:
268
+ return None
269
+ repo_root = resolve_repo_root(subagent_stop_payload.get("cwd", "."))
270
+ if repo_root is None:
271
+ return None
272
+ merge_base_sha = resolve_merge_base(repo_root)
273
+ if merge_base_sha is None:
274
+ return None
275
+ surface_manifest_text = branch_surface_manifest(repo_root, merge_base_sha)
276
+ if surface_manifest_text is None:
277
+ return None
278
+ return write_verdict(
279
+ repo_root,
280
+ manifest_sha256(surface_manifest_text),
281
+ verdict_record["all_pass"],
282
+ verdict_record["findings"],
283
+ str(subagent_stop_payload.get("agent_id", "")),
284
+ )
285
+
286
+
287
+ def main() -> None:
288
+ """Read the SubagentStop payload and mint a verdict when one applies."""
289
+ try:
290
+ subagent_stop_payload = json.load(sys.stdin)
291
+ except json.JSONDecodeError:
292
+ return
293
+ if not isinstance(subagent_stop_payload, dict):
294
+ return
295
+ mint_for_payload(subagent_stop_payload)
296
+
297
+
298
+ if __name__ == "__main__":
299
+ main()
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook: block bare per-iteration index tokens in .workflow.js templates.
3
+
4
+ Root cause: a `.workflow.js` agent-prompt block that loops over an index (for
5
+ example "For EACH candidate i, build a dir cand_i ...") sometimes writes the
6
+ per-iteration directory or output key as a bare token like `cand_i`. A bare
7
+ `_i`-suffixed token reads as a fixed literal rather than a substitution slot, so
8
+ an agent can plausibly create one literal directory named `cand_i` and overwrite
9
+ it across every iteration -- collapsing an N-iteration gate into a single run.
10
+
11
+ The established convention in these templates marks every per-call substitution
12
+ slot with angle brackets (`<plate.svg>`, `<object.svg>`, `<glow_hex>`). The fix
13
+ is to mark the index the same way: `cand_<i>`.
14
+
15
+ Detection strategy: act only on Write/Edit to a path ending in `.workflow.js`.
16
+ Within the written content, fire only when ALL of the following hold, so the
17
+ hook catches exactly the bare-literal shape and never a template that does not
18
+ use the substitution convention at all:
19
+
20
+ 1. the content uses the angle-bracket substitution convention somewhere
21
+ (a `<...>` slot), proving the author marks per-call values that way;
22
+ 2. the content establishes a per-iteration loop (an "each"/"EACH"/"for i"
23
+ style phrase, or an explicit `cand_0` enumeration);
24
+ 3. a bare `<word>_<i|j|k>` token appears as a per-iteration path segment
25
+ (adjacent to a path separator). A quoted structured-output key whose name
26
+ ends in `_i|_j|_k` (a permanent identifier with no per-iteration path) does
27
+ not fire on its own; only the per-iteration path shape triggers a block.
28
+
29
+ Fails OPEN (approves) on malformed input or a non-workflow path; the violation
30
+ shape is narrow enough that a false negative is preferable to blocking
31
+ unrelated edits.
32
+ """
33
+
34
+ import json
35
+ import re
36
+ import sys
37
+ from pathlib import Path
38
+
39
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
40
+ if _hooks_dir not in sys.path:
41
+ sys.path.insert(0, _hooks_dir)
42
+
43
+ from hooks_constants.workflow_substitution_slot_blocker_constants import ( # noqa: E402
44
+ CORRECTIVE_MESSAGE,
45
+ EDIT_TOOL_NAME,
46
+ MULTI_EDIT_TOOL_NAME,
47
+ WORKFLOW_FILE_SUFFIX,
48
+ WRITE_TOOL_NAME,
49
+ )
50
+
51
+ def multi_edit_new_strings(all_tool_input: dict[str, object]) -> str:
52
+ all_edits = all_tool_input.get("edits", [])
53
+ if not isinstance(all_edits, list):
54
+ return ""
55
+ all_new_strings = [
56
+ each_edit["new_string"]
57
+ for each_edit in all_edits
58
+ if isinstance(each_edit, dict) and isinstance(each_edit.get("new_string"), str)
59
+ ]
60
+ return "\n".join(all_new_strings)
61
+
62
+
63
+ def written_content(tool_name: str, all_tool_input: dict[str, object]) -> str:
64
+ if tool_name == WRITE_TOOL_NAME:
65
+ content = all_tool_input.get("content", "")
66
+ return content if isinstance(content, str) else ""
67
+ if tool_name == EDIT_TOOL_NAME:
68
+ new_string = all_tool_input.get("new_string", "")
69
+ return new_string if isinstance(new_string, str) else ""
70
+ if tool_name == MULTI_EDIT_TOOL_NAME:
71
+ return multi_edit_new_strings(all_tool_input)
72
+ return ""
73
+
74
+
75
+ def target_path(all_tool_input: dict[str, object]) -> str:
76
+ file_path = all_tool_input.get("file_path", "")
77
+ return file_path if isinstance(file_path, str) else ""
78
+
79
+
80
+ def uses_angle_slot_convention(content: str) -> bool:
81
+ angle_slot_pattern = re.compile(r"<[^<>\n]+>")
82
+ return bool(angle_slot_pattern.search(content))
83
+
84
+
85
+ def has_iteration_loop(content: str) -> bool:
86
+ loop_phrase_pattern = re.compile(
87
+ r"\b(?:for\s+each|each\s+candidate|for\s+[ijk]\b|candidate\s+[ijk]\b|cand_0)\b",
88
+ re.IGNORECASE,
89
+ )
90
+ uppercase_each_keyword_pattern = re.compile(r"\bEACH\b")
91
+ return bool(
92
+ loop_phrase_pattern.search(content)
93
+ or uppercase_each_keyword_pattern.search(content)
94
+ )
95
+
96
+
97
+ def find_bare_path_segments(content: str) -> set[str]:
98
+ loop_letters = "ijk"
99
+ path_context = re.compile(
100
+ r"(?:[\\/]\s*([A-Za-z][\w]*?_[" + loop_letters + r"])(?![\w>])"
101
+ r"|([A-Za-z][\w]*?_[" + loop_letters + r"])(?![\w>])\s*[\\/])"
102
+ )
103
+ all_path_segments: set[str] = set()
104
+ for each_match in path_context.finditer(content):
105
+ each_token = next(
106
+ (each_group for each_group in each_match.groups() if each_group),
107
+ "",
108
+ )
109
+ if each_token:
110
+ all_path_segments.add(each_token)
111
+ return all_path_segments
112
+
113
+
114
+ def find_bare_index_segments(content: str) -> set[str]:
115
+ return find_bare_path_segments(content)
116
+
117
+
118
+ def content_has_violation(content: str) -> bool:
119
+ if not uses_angle_slot_convention(content):
120
+ return False
121
+ if not has_iteration_loop(content):
122
+ return False
123
+ return bool(find_bare_index_segments(content))
124
+
125
+
126
+ def main() -> None:
127
+ try:
128
+ hook_input = json.load(sys.stdin)
129
+ except json.JSONDecodeError:
130
+ sys.exit(0)
131
+
132
+ tool_name = hook_input.get("tool_name", "")
133
+ if tool_name not in (WRITE_TOOL_NAME, EDIT_TOOL_NAME, MULTI_EDIT_TOOL_NAME):
134
+ sys.exit(0)
135
+
136
+ all_tool_input = hook_input.get("tool_input", {})
137
+ if not isinstance(all_tool_input, dict):
138
+ sys.exit(0)
139
+
140
+ if not target_path(all_tool_input).endswith(WORKFLOW_FILE_SUFFIX):
141
+ sys.exit(0)
142
+
143
+ if not content_has_violation(written_content(tool_name, all_tool_input)):
144
+ sys.exit(0)
145
+
146
+ deny_payload = {
147
+ "hookSpecificOutput": {
148
+ "hookEventName": "PreToolUse",
149
+ "permissionDecision": "deny",
150
+ "permissionDecisionReason": CORRECTIVE_MESSAGE,
151
+ }
152
+ }
153
+ print(json.dumps(deny_payload))
154
+ sys.stdout.flush()
155
+ sys.exit(0)
156
+
157
+
158
+ if __name__ == "__main__":
159
+ main()
@@ -82,7 +82,7 @@ def _make_blocking_line(
82
82
  hook_event: str = "PreToolUse",
83
83
  tool_use_id: str = "toolu_002",
84
84
  blocking_message: str = "blocked for reason",
85
- command: str = "python C:/Users/jon/.claude/hooks/blocking/content_search_to_zoekt_redirector.py",
85
+ command: str = "python C:/Users/jon/.claude/hooks/blocking/block_main_commit.py",
86
86
  timestamp: str = "2026-04-24T13:32:54.293Z",
87
87
  cwd: str = "Y:\\Projects\\repo",
88
88
  git_branch: str = "main",
@@ -640,7 +640,7 @@ def test_run_summary_prints_table_when_rows_returned(
640
640
  ) -> None:
641
641
  fake_cursor = MagicMock()
642
642
  fake_cursor.fetchall.return_value = [
643
- ("content_search_to_zoekt_redirector.py", "blocking", 7, "Bash(grep foo)"),
643
+ ("block_main_commit.py", "blocking", 7, "Bash(git commit)"),
644
644
  ]
645
645
  fake_connection = MagicMock()
646
646
  fake_connection.cursor.return_value.__enter__.return_value = fake_cursor
@@ -652,7 +652,7 @@ def test_run_summary_prints_table_when_rows_returned(
652
652
 
653
653
  captured = capsys.readouterr()
654
654
  assert exit_code == 0
655
- assert "content_search_to_zoekt_redirector.py" in captured.out
655
+ assert "block_main_commit.py" in captured.out
656
656
  assert "blocking" in captured.out
657
657
  assert "7" in captured.out
658
658
 
package/hooks/hooks.json CHANGED
@@ -44,12 +44,32 @@
44
44
  "type": "command",
45
45
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/state_description_blocker.py",
46
46
  "timeout": 10
47
+ },
48
+ {
49
+ "type": "command",
50
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/subprocess_budget_completeness.py",
51
+ "timeout": 10
52
+ },
53
+ {
54
+ "type": "command",
55
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/hook_prose_detector_consistency.py",
56
+ "timeout": 10
57
+ },
58
+ {
59
+ "type": "command",
60
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/verified_commit_message_accuracy_blocker.py",
61
+ "timeout": 10
47
62
  }
48
63
  ]
49
64
  },
50
65
  {
51
66
  "matcher": "Write|Edit|MultiEdit",
52
67
  "hooks": [
68
+ {
69
+ "type": "command",
70
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/workflow_substitution_slot_blocker.py",
71
+ "timeout": 10
72
+ },
53
73
  {
54
74
  "type": "command",
55
75
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/open_questions_in_plans_blocker.py",
@@ -103,7 +123,7 @@
103
123
  {
104
124
  "type": "command",
105
125
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/precommit_code_rules_gate.py",
106
- "timeout": 30
126
+ "timeout": 150
107
127
  },
108
128
  {
109
129
  "type": "command",
@@ -129,6 +149,31 @@
129
149
  "type": "command",
130
150
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/gh_pr_author_enforcer.py",
131
151
  "timeout": 30
152
+ },
153
+ {
154
+ "type": "command",
155
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/verified_commit_gate.py",
156
+ "timeout": 15
157
+ },
158
+ {
159
+ "type": "command",
160
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/verdict_directory_write_blocker.py",
161
+ "timeout": 10
162
+ }
163
+ ]
164
+ },
165
+ {
166
+ "matcher": "PowerShell",
167
+ "hooks": [
168
+ {
169
+ "type": "command",
170
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/verified_commit_gate.py",
171
+ "timeout": 15
172
+ },
173
+ {
174
+ "type": "command",
175
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/verdict_directory_write_blocker.py",
176
+ "timeout": 10
132
177
  }
133
178
  ]
134
179
  },
@@ -232,6 +277,18 @@
232
277
  ]
233
278
  }
234
279
  ],
280
+ "SubagentStop": [
281
+ {
282
+ "matcher": "",
283
+ "hooks": [
284
+ {
285
+ "type": "command",
286
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/verifier_verdict_minter.py",
287
+ "timeout": 30
288
+ }
289
+ ]
290
+ }
291
+ ],
235
292
  "SessionEnd": [
236
293
  {
237
294
  "matcher": "",
@@ -20,6 +20,7 @@ MAX_DOCSTRING_ARGS_SIGNATURE_ISSUES: int = 5
20
20
  MAX_IGNORED_MUST_CHECK_RETURN_ISSUES: int = 5
21
21
  MAX_TYPE_ESCAPE_HATCH_ISSUES: int = 5
22
22
  MAX_THIN_WRAPPER_ISSUES: int = 1
23
+ MAX_ZERO_PAYLOAD_ALIAS_ISSUES: int = 3
23
24
  MAX_LOGGING_FSTRING_ISSUES: int = 3
24
25
  MAX_WINDOWS_API_NONE_ISSUES: int = 3
25
26
  MAX_E2E_TEST_NAMING_ISSUES: int = 3
@@ -24,6 +24,8 @@ ALL_MIGRATION_PATH_PATTERNS = {"/migrations/", "\\migrations\\"}
24
24
  ADVISORY_LINE_THRESHOLD_SOFT = 400
25
25
  ADVISORY_LINE_THRESHOLD_HARD = 1000
26
26
 
27
+ DENY_REASON_ISSUE_PREVIEW_COUNT = 10
28
+
27
29
  ALL_BOOLEAN_NAME_PREFIXES: tuple[str, ...] = ("is_", "has_", "should_", "can_", "was_", "did_")
28
30
  UPPER_SNAKE_CONSTANT_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]*$")
29
31
 
@@ -108,6 +110,20 @@ ALL_BUILTIN_DICT_METHOD_NAMES: frozenset[str] = frozenset({
108
110
  })
109
111
  ALL_UNION_TYPING_NAMES: frozenset[str] = frozenset({"Optional", "Union"})
110
112
  ALL_SELF_AND_CLS_PARAMETER_NAMES: frozenset[str] = frozenset({"self", "cls"})
113
+ ANNOTATION_BY_PYTEST_FIXTURE: dict[str, str] = {
114
+ "tmp_path": "Path",
115
+ "tmp_path_factory": "pytest.TempPathFactory",
116
+ "monkeypatch": "pytest.MonkeyPatch",
117
+ "capsys": "pytest.CaptureFixture[str]",
118
+ "capfd": "pytest.CaptureFixture[str]",
119
+ "caplog": "pytest.LogCaptureFixture",
120
+ "request": "pytest.FixtureRequest",
121
+ }
122
+ KNOWN_PYTEST_FIXTURE_ANNOTATION_MESSAGE_SUFFIX: str = (
123
+ "known pytest fixture parameter must carry its single documented type "
124
+ "(CODE_RULES §6; pytest builtin fixture reference "
125
+ "https://docs.pytest.org/en/stable/reference/fixtures.html)"
126
+ )
111
127
  ALL_LOOP_INDEX_LETTER_EXEMPTIONS: frozenset[str] = frozenset({"i", "j", "k", "_"})
112
128
  EACH_PREFIX = "each_"
113
129
  BARE_EACH_TOKEN = "each"
@@ -0,0 +1,25 @@
1
+ """Constants for the dead dataclass-field detector in ``code_rules_enforcer``.
2
+
3
+ Lives under the hooks-tree ``hooks_constants`` package so module-level
4
+ UPPER_SNAKE constants satisfy the CODE_RULES "constants live in config"
5
+ requirement and share a home with the other hook-tree configuration.
6
+ """
7
+
8
+ ALL_DATACLASS_DECORATOR_NAMES: frozenset[str] = frozenset({"dataclass", "dataclasses"})
9
+ ATTRGETTER_FUNCTION_NAME: str = "attrgetter"
10
+ CLASSVAR_ANNOTATION_NAME: str = "ClassVar"
11
+ GETATTR_FUNCTION_NAME: str = "getattr"
12
+ GETATTR_NAME_ARGUMENT_MINIMUM: int = 2
13
+ ALL_REFLECTIVE_FIELD_CONSUMER_NAMES: frozenset[str] = frozenset(
14
+ {"asdict", "astuple", "fields", "replace", "vars"}
15
+ )
16
+ WHOLE_INSTANCE_DICT_ATTRIBUTE_NAME: str = "__dict__"
17
+ ALL_WHOLE_INSTANCE_STRINGIFY_NAMES: frozenset[str] = frozenset(
18
+ {"str", "repr", "format"}
19
+ )
20
+ MAX_DEAD_DATACLASS_FIELD_ISSUES: int = 25
21
+ DEAD_DATACLASS_FIELD_GUIDANCE: str = (
22
+ "field is assigned but never read in this file - remove the field and the code"
23
+ " that only exists to populate it, or read it where the value is needed"
24
+ " (CODE_RULES §9.8)"
25
+ )
@@ -0,0 +1,20 @@
1
+ """Constants for the dead module-level constant detector in ``code_rules_enforcer``.
2
+
3
+ Lives under the hooks-tree ``hooks_constants`` package so module-level
4
+ UPPER_SNAKE constants satisfy the CODE_RULES "constants live in config"
5
+ requirement and share a home with the other hook-tree configuration.
6
+ """
7
+
8
+ PYTHON_SOURCE_SUFFIX: str = ".py"
9
+ DUNDER_INIT_FILENAME: str = "__init__.py"
10
+ CONSTANTS_MODULE_SUFFIX: str = "_constants.py"
11
+ CONFIG_DIRECTORY_SEGMENT: str = "config"
12
+ DUNDER_ALL_NAME: str = "__all__"
13
+ MINIMUM_UPPER_SNAKE_LENGTH: int = 2
14
+ MAX_DEAD_MODULE_CONSTANT_ISSUES: int = 25
15
+ MAX_SCAN_ROOT_FILE_COUNT: int = 2000
16
+ DEAD_MODULE_CONSTANT_GUIDANCE: str = (
17
+ "module-level constant is defined here but never imported or read by any"
18
+ " module in the enclosing package tree - remove the constant, or reference it"
19
+ " where its value is needed (CODE_RULES §9.8)"
20
+ )