claude-dev-env 1.59.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/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/hooks/blocking/code_rules_dead_module_constant.py +321 -0
- package/hooks/blocking/code_rules_duplicate_body.py +152 -0
- package/hooks/blocking/code_rules_enforcer.py +30 -15
- 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/test_code_rules_enforcer_cross_skill_duplicate.py +146 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +188 -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_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/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/diagnostic/test_hook_log_extractor.py +3 -3
- package/hooks/hooks.json +43 -1
- package/hooks/hooks_constants/blocking_check_limits.py +1 -0
- package/hooks/hooks_constants/dead_module_constant_constants.py +20 -0
- package/hooks/hooks_constants/duplicate_function_body_constants.py +22 -5
- package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
- package/package.json +1 -1
- package/rules/file-global-constants.md +7 -1
- package/rules/no-cross-skill-duplicate-helpers.md +29 -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 +54 -17
- package/skills/autoconverge/reference/closing-report.md +59 -17
- 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.mjs +128 -6
- 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/rebase/SKILL.md +2 -4
- 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()
|
|
@@ -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
|
@@ -54,6 +54,11 @@
|
|
|
54
54
|
"type": "command",
|
|
55
55
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/hook_prose_detector_consistency.py",
|
|
56
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
|
|
57
62
|
}
|
|
58
63
|
]
|
|
59
64
|
},
|
|
@@ -118,7 +123,7 @@
|
|
|
118
123
|
{
|
|
119
124
|
"type": "command",
|
|
120
125
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/precommit_code_rules_gate.py",
|
|
121
|
-
"timeout":
|
|
126
|
+
"timeout": 150
|
|
122
127
|
},
|
|
123
128
|
{
|
|
124
129
|
"type": "command",
|
|
@@ -144,6 +149,31 @@
|
|
|
144
149
|
"type": "command",
|
|
145
150
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/gh_pr_author_enforcer.py",
|
|
146
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
|
|
147
177
|
}
|
|
148
178
|
]
|
|
149
179
|
},
|
|
@@ -247,6 +277,18 @@
|
|
|
247
277
|
]
|
|
248
278
|
}
|
|
249
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
|
+
],
|
|
250
292
|
"SessionEnd": [
|
|
251
293
|
{
|
|
252
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
|
|
@@ -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
|
+
)
|
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
"""Constants for the
|
|
1
|
+
"""Constants for the duplicate-function-body scan in ``code_rules_enforcer``.
|
|
2
2
|
|
|
3
|
-
The scan flags a top-level function whose body is structurally identical
|
|
4
|
-
top-level function already defined in a sibling ``.py`` module in the same
|
|
5
|
-
directory. This catches the Reuse-before-create / DRY violation where a helper
|
|
6
|
-
|
|
3
|
+
The blocking scan flags a top-level function whose body is structurally identical
|
|
4
|
+
to a top-level function already defined in a sibling ``.py`` module in the same
|
|
5
|
+
directory. This catches the Reuse-before-create / DRY violation where a helper is
|
|
6
|
+
copy-pasted across several modules instead of imported from one shared home.
|
|
7
|
+
|
|
8
|
+
The ``CROSS_SKILL_*`` and ``SKILL*`` constants feed the non-blocking companion
|
|
9
|
+
advisory: a helper copied between two skills' ``scripts`` directories, where a
|
|
10
|
+
shared module would break independent install. That advisory names the source
|
|
11
|
+
skill on stderr rather than denying the write.
|
|
7
12
|
"""
|
|
8
13
|
|
|
9
14
|
MINIMUM_DUPLICATE_BODY_STATEMENTS: int = 3
|
|
@@ -15,3 +20,15 @@ DUPLICATE_BODY_GUIDANCE: str = (
|
|
|
15
20
|
"extract a single shared helper (for example in hooks_constants/) and "
|
|
16
21
|
"import it from both modules instead of copying it (Reuse before create / DRY)"
|
|
17
22
|
)
|
|
23
|
+
|
|
24
|
+
SKILLS_DIRECTORY_NAME: str = "skills"
|
|
25
|
+
SKILL_SCRIPTS_DIRECTORY_NAME: str = "scripts"
|
|
26
|
+
MAX_CROSS_SKILL_ADVISORY_ISSUES: int = 25
|
|
27
|
+
CROSS_SKILL_ADVISORY_PREFIX: str = "[CODE_RULES advisory]"
|
|
28
|
+
CROSS_SKILL_DUPLICATE_GUIDANCE: str = (
|
|
29
|
+
"two skill folders install on their own, so this copy is a defensible "
|
|
30
|
+
"skill-isolation tradeoff; a shared module would couple the skills and "
|
|
31
|
+
"break independent install. Confirm the copy is intentional, or for a "
|
|
32
|
+
"large or behavior-bearing body raise the choice through AskUserQuestion "
|
|
33
|
+
"(see the no-cross-skill-duplicate-helpers rule)"
|
|
34
|
+
)
|
|
@@ -8,7 +8,7 @@ from pathlib import Path
|
|
|
8
8
|
|
|
9
9
|
GIT_DASH_C_COMMIT_PATTERN: str = r"git\s+-C\s+[\"']?[^\"';&|]+?[\"']?\s+commit\b"
|
|
10
10
|
GIT_COMMAND_TIMEOUT_SECONDS: int = 5
|
|
11
|
-
GATE_TIMEOUT_SECONDS: int =
|
|
11
|
+
GATE_TIMEOUT_SECONDS: int = 120
|
|
12
12
|
GATE_RELATIVE_PATH: Path = Path("_shared") / "pr-loop" / "scripts" / "code_rules_gate.py"
|
|
13
13
|
ALL_STAGED_PYTHON_FILES_COMMAND: tuple[str, ...] = (
|
|
14
14
|
"git",
|
package/package.json
CHANGED
|
@@ -20,7 +20,13 @@ Test-file detection uses the following anchored patterns against the full relati
|
|
|
20
20
|
|
|
21
21
|
## `config/` files are exempt
|
|
22
22
|
|
|
23
|
-
Constants placed in `config/` satisfy the constants-location rule; the use-count
|
|
23
|
+
Constants placed in `config/` satisfy the constants-location rule; the use-count rule applies only to production code outside `config/`.
|
|
24
|
+
|
|
25
|
+
## Dead constant in a dedicated constants module (cross-module)
|
|
26
|
+
|
|
27
|
+
The use-count rule above governs a file-global constant in production code outside `config/` by counting same-file references. A dedicated constants module — a file whose name ends in `_constants.py`, or any module under a `config/` directory — exports its constants to importer modules elsewhere, so a same-file count proves nothing. A separate hook, `check_dead_module_constants` (dispatched from `code_rules_enforcer`), governs these modules: it flags an `UPPER_SNAKE` constant defined in the written module whose name appears in no `.py` module anywhere under the enclosing package tree — not imported, not read, not listed in an `__all__` literal, not named in a string annotation. That is the dead exported constant CODE_RULES §9.8 targets, caught at Write/Edit time.
|
|
28
|
+
|
|
29
|
+
The scan resolves the enclosing package tree from the written file: for a constants module inside a package subdirectory, the tree is the package's parent (so an importer one directory up is in scope); for a `config/` module, the tree is the parent of the `config` directory. A module that declares its own `__all__` is skipped — the author's explicit export surface is taken as the liveness contract. A reference from a test module under the tree keeps a constant live. Test modules and migration modules are themselves exempt from the check.
|
|
24
30
|
|
|
25
31
|
## Examples
|
|
26
32
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths: "**/skills/*/scripts/**/*.py"
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Cross-Skill Duplicate Helpers
|
|
6
|
+
|
|
7
|
+
**When this applies:** Any Write or Edit to a `.py` file under a skill's `scripts/` directory (`**/skills/<skill-name>/scripts/**/*.py`) that copies a top-level helper from another skill's `scripts/` directory.
|
|
8
|
+
|
|
9
|
+
## The two duplication cases differ
|
|
10
|
+
|
|
11
|
+
CODE_RULES "Reuse before create" / DRY says one helper lives in one home and both call sites import it. That rule is blocking **within one skill** — two `.py` modules in the same skill's `scripts/` directory that carry the same top-level function body fail the `code_rules_duplicate_body` gate, and the fix is a shared module both import.
|
|
12
|
+
|
|
13
|
+
Across **two skill folders** the same copy is a different call. Each skill folder installs on its own, so a shared module would couple two skills the install model keeps separate: deleting or reinstalling one skill would break a helper the other depends on. A small launch helper copied into each skill (for example a Chrome-open helper that reads the registry and runs `chrome.exe`) is a defensible skill-isolation tradeoff, not a regression.
|
|
14
|
+
|
|
15
|
+
## Decision
|
|
16
|
+
|
|
17
|
+
Before you copy a top-level helper from one skill's `scripts/` directory into another:
|
|
18
|
+
|
|
19
|
+
- **Same skill, two modules** — extract one shared module in that skill and import it from both. The `code_rules_duplicate_body` gate blocks the copy.
|
|
20
|
+
- **Two skill folders, a small self-contained helper** — copy it; the skill-isolation tradeoff stands. A non-blocking `[CODE_RULES advisory]` names the source skill at Write time so the copy is a deliberate choice on record, not an oversight.
|
|
21
|
+
- **Two skill folders, a large or behavior-bearing body** — when the copied body is large, holds business logic, or would drift in a way that changes behavior, raise the choice through `AskUserQuestion`: copy and accept drift, or stand up a shared dependency both skills declare (for example a published package both `requirements` files name, or a `_shared` module the install step writes into each skill). A shared dependency that survives independent install is the only shared-home path that does not break the install model.
|
|
22
|
+
|
|
23
|
+
## What the advisory tells you
|
|
24
|
+
|
|
25
|
+
The `advise_cross_skill_duplicate_helper` check in `code_rules_duplicate_body` prints to stderr (never blocks) when a top-level function in the file being written has the same normalized body as a top-level function in another skill's `scripts/` directory. The message names the source skill and function so a reviewer can confirm the copy was intentional. It fires only across skill folders; within one skill the blocking gate already covers the copy.
|
|
26
|
+
|
|
27
|
+
## Why this is a rule, not a wider gate
|
|
28
|
+
|
|
29
|
+
Extending the blocking duplicate-body gate to span skill folders would deny the exact skill-isolation copy that keeps skills independently installable — a false positive on a sanctioned pattern. The boundary between "same skill, block" and "two skills, signal" is a judgment the writer makes with the source skill named in front of them. The rule states the judgment; the `[CODE_RULES advisory]` surfaces the signal; neither blocks the defensible copy.
|