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.
- package/CLAUDE.md +2 -2
- package/_shared/pr-loop/scripts/code_rules_gate.py +36 -3
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +6 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
- package/_shared/pr-loop/scripts/reviews_disabled.py +12 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +265 -0
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +29 -0
- package/audit-rubrics/category_rubrics/category-b-selector-engine-compat.md +1 -1
- package/audit-rubrics/category_rubrics/category-e-dead-code.md +1 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
- package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
- package/bin/install.mjs +100 -27
- package/bin/install.test.mjs +133 -1
- package/docs/CODE_RULES.md +3 -3
- package/hooks/blocking/code_rules_annotations_length.py +153 -0
- package/hooks/blocking/code_rules_dead_dataclass_field.py +319 -0
- package/hooks/blocking/code_rules_dead_module_constant.py +321 -0
- package/hooks/blocking/code_rules_duplicate_body.py +439 -0
- package/hooks/blocking/code_rules_enforcer.py +190 -21
- package/hooks/blocking/code_rules_magic_values.py +98 -0
- package/hooks/blocking/code_rules_shared.py +41 -0
- package/hooks/blocking/code_rules_typeddict_stub.py +172 -0
- package/hooks/blocking/config/__init__.py +5 -0
- package/hooks/blocking/config/verified_commit_constants.py +106 -0
- package/hooks/blocking/destructive_command_blocker.py +1027 -12
- package/hooks/blocking/hook_prose_detector_consistency.py +150 -0
- package/hooks/blocking/subprocess_budget_completeness.py +380 -0
- package/hooks/blocking/test_code_rules_enforcer_annotations.py +225 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
- package/hooks/blocking/test_code_rules_enforcer_cross_skill_duplicate.py +146 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_dataclass_field.py +467 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +188 -0
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +330 -0
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +179 -0
- package/hooks/blocking/test_code_rules_enforcer_magic_slice_bounds.py +133 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias.py +415 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +156 -0
- package/hooks/blocking/test_destructive_command_blocker.py +622 -3
- package/hooks/blocking/test_hook_prose_detector_consistency.py +265 -0
- package/hooks/blocking/test_subprocess_budget_completeness.py +588 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +720 -0
- package/hooks/blocking/test_verification_verdict_store.py +278 -0
- package/hooks/blocking/test_verified_commit_gate.py +368 -0
- package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -0
- package/hooks/blocking/test_verifier_verdict_minter.py +214 -0
- package/hooks/blocking/test_workflow_substitution_slot_blocker.py +242 -0
- package/hooks/blocking/verdict_directory_write_blocker.py +667 -0
- package/hooks/blocking/verification_verdict_store.py +446 -0
- package/hooks/blocking/verified_commit_gate.py +523 -0
- package/hooks/blocking/verified_commit_message_accuracy_blocker.py +152 -0
- package/hooks/blocking/verifier_verdict_minter.py +299 -0
- package/hooks/blocking/workflow_substitution_slot_blocker.py +159 -0
- package/hooks/diagnostic/test_hook_log_extractor.py +3 -3
- package/hooks/hooks.json +58 -1
- package/hooks/hooks_constants/blocking_check_limits.py +1 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +16 -0
- package/hooks/hooks_constants/dead_dataclass_field_constants.py +25 -0
- package/hooks/hooks_constants/dead_module_constant_constants.py +20 -0
- package/hooks/hooks_constants/destructive_command_segment_constants.py +178 -0
- package/hooks/hooks_constants/duplicate_function_body_constants.py +34 -0
- package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +30 -0
- package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
- package/hooks/hooks_constants/subprocess_budget_completeness_constants.py +5 -0
- package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +22 -0
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +43 -0
- package/rules/file-global-constants.md +7 -1
- package/rules/hook-prose-matches-detector.md +26 -0
- package/rules/no-cross-skill-duplicate-helpers.md +29 -0
- package/rules/no-inline-destructive-literals.md +11 -0
- package/rules/workflow-substitution-slots.md +7 -0
- package/skills/_shared/pr-loop/scripts/preflight_worktree.py +392 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/preflight_constants.py +70 -0
- package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +263 -0
- package/skills/autoconverge/SKILL.md +67 -19
- package/skills/autoconverge/reference/closing-report.md +59 -17
- package/skills/autoconverge/reference/convergence.md +7 -3
- package/skills/autoconverge/reference/stop-conditions.md +7 -2
- package/skills/autoconverge/workflow/aggregate_runs.py +371 -0
- package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +193 -76
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +76 -0
- package/skills/autoconverge/workflow/converge.contract.test.mjs +206 -206
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +265 -0
- package/skills/autoconverge/workflow/converge.mjs +234 -42
- package/skills/autoconverge/workflow/convergence_summary.py +110 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ab1c2d3e4f5a6b7c8.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +7 -0
- package/skills/autoconverge/workflow/render_report.py +488 -397
- package/skills/autoconverge/workflow/test_aggregate_runs.py +134 -0
- package/skills/autoconverge/workflow/test_convergence_summary.py +132 -0
- package/skills/autoconverge/workflow/test_render_report.py +488 -259
- package/skills/pr-converge/reference/per-tick.md +28 -8
- package/skills/pr-converge/scripts/check_convergence.py +195 -64
- package/skills/pr-converge/scripts/test_check_convergence.py +173 -2
- package/skills/rebase/SKILL.md +2 -4
- package/skills/update/SKILL.md +37 -5
- package/system-prompts/software-engineer.xml +2 -6
- package/hooks/blocking/content_search_to_zoekt_redirector.py +0 -59
- package/hooks/blocking/content_search_zoekt_bash_block_reason.py +0 -25
- package/hooks/blocking/content_search_zoekt_block_payload.py +0 -21
- package/hooks/blocking/content_search_zoekt_indexed_paths.py +0 -24
- package/hooks/blocking/content_search_zoekt_indexed_roots_config.py +0 -131
- package/hooks/blocking/content_search_zoekt_redirect_guidance.py +0 -52
- package/hooks/blocking/test_content_search_to_zoekt_redirector_integration.py +0 -61
- package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +0 -92
- 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/
|
|
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
|
-
("
|
|
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 "
|
|
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":
|
|
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
|
+
)
|