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.
Files changed (62) hide show
  1. package/audit-rubrics/category_rubrics/category-b-selector-engine-compat.md +1 -1
  2. package/audit-rubrics/category_rubrics/category-e-dead-code.md +1 -0
  3. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  4. package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
  5. package/hooks/blocking/code_rules_dead_module_constant.py +321 -0
  6. package/hooks/blocking/code_rules_duplicate_body.py +152 -0
  7. package/hooks/blocking/code_rules_enforcer.py +30 -15
  8. package/hooks/blocking/code_rules_typeddict_stub.py +172 -0
  9. package/hooks/blocking/config/__init__.py +5 -0
  10. package/hooks/blocking/config/verified_commit_constants.py +106 -0
  11. package/hooks/blocking/test_code_rules_enforcer_cross_skill_duplicate.py +146 -0
  12. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +188 -0
  13. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias.py +415 -0
  14. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +156 -0
  15. package/hooks/blocking/test_verdict_directory_write_blocker.py +720 -0
  16. package/hooks/blocking/test_verification_verdict_store.py +278 -0
  17. package/hooks/blocking/test_verified_commit_gate.py +368 -0
  18. package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -0
  19. package/hooks/blocking/test_verifier_verdict_minter.py +214 -0
  20. package/hooks/blocking/verdict_directory_write_blocker.py +667 -0
  21. package/hooks/blocking/verification_verdict_store.py +446 -0
  22. package/hooks/blocking/verified_commit_gate.py +523 -0
  23. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +152 -0
  24. package/hooks/blocking/verifier_verdict_minter.py +299 -0
  25. package/hooks/diagnostic/test_hook_log_extractor.py +3 -3
  26. package/hooks/hooks.json +43 -1
  27. package/hooks/hooks_constants/blocking_check_limits.py +1 -0
  28. package/hooks/hooks_constants/dead_module_constant_constants.py +20 -0
  29. package/hooks/hooks_constants/duplicate_function_body_constants.py +22 -5
  30. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
  31. package/package.json +1 -1
  32. package/rules/file-global-constants.md +7 -1
  33. package/rules/no-cross-skill-duplicate-helpers.md +29 -0
  34. package/skills/_shared/pr-loop/scripts/preflight_worktree.py +392 -0
  35. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/preflight_constants.py +70 -0
  36. package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +263 -0
  37. package/skills/autoconverge/SKILL.md +54 -17
  38. package/skills/autoconverge/reference/closing-report.md +59 -17
  39. package/skills/autoconverge/workflow/aggregate_runs.py +371 -0
  40. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +193 -76
  41. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +76 -0
  42. package/skills/autoconverge/workflow/converge.contract.test.mjs +206 -206
  43. package/skills/autoconverge/workflow/converge.mjs +128 -6
  44. package/skills/autoconverge/workflow/convergence_summary.py +110 -0
  45. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ab1c2d3e4f5a6b7c8.jsonl +2 -0
  46. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +7 -0
  47. package/skills/autoconverge/workflow/render_report.py +488 -397
  48. package/skills/autoconverge/workflow/test_aggregate_runs.py +134 -0
  49. package/skills/autoconverge/workflow/test_convergence_summary.py +132 -0
  50. package/skills/autoconverge/workflow/test_render_report.py +488 -259
  51. package/skills/pr-converge/reference/per-tick.md +28 -8
  52. package/skills/rebase/SKILL.md +2 -4
  53. package/system-prompts/software-engineer.xml +2 -6
  54. package/hooks/blocking/content_search_to_zoekt_redirector.py +0 -59
  55. package/hooks/blocking/content_search_zoekt_bash_block_reason.py +0 -25
  56. package/hooks/blocking/content_search_zoekt_block_payload.py +0 -21
  57. package/hooks/blocking/content_search_zoekt_indexed_paths.py +0 -24
  58. package/hooks/blocking/content_search_zoekt_indexed_roots_config.py +0 -131
  59. package/hooks/blocking/content_search_zoekt_redirect_guidance.py +0 -52
  60. package/hooks/blocking/test_content_search_to_zoekt_redirector_integration.py +0 -61
  61. package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +0 -92
  62. 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/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
@@ -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": 30
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 cross-file duplicate-function-body scan in ``code_rules_enforcer``.
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 to a
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
- is copy-pasted across several modules instead of imported from one shared home.
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 = 25
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.59.0",
3
+ "version": "1.60.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 requirement applies only to production code outside `config/`.
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.