claude-dev-env 1.59.0 → 1.61.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 (81) hide show
  1. package/CLAUDE.md +4 -0
  2. package/audit-rubrics/category_rubrics/category-b-selector-engine-compat.md +1 -1
  3. package/audit-rubrics/category_rubrics/category-e-dead-code.md +1 -0
  4. package/audit-rubrics/category_rubrics/category-f-silent-failures.md +1 -1
  5. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  6. package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
  7. package/audit-rubrics/prompts/category-e-dead-code.md +17 -4
  8. package/audit-rubrics/prompts/category-f-silent-failures.md +1 -0
  9. package/docs/CODE_RULES.md +2 -2
  10. package/hooks/blocking/code_rules_annotations_length.py +189 -10
  11. package/hooks/blocking/code_rules_dead_module_constant.py +321 -0
  12. package/hooks/blocking/code_rules_duplicate_body.py +152 -0
  13. package/hooks/blocking/code_rules_enforcer.py +38 -15
  14. package/hooks/blocking/code_rules_orphan_css_class.py +196 -0
  15. package/hooks/blocking/code_rules_typeddict_stub.py +172 -0
  16. package/hooks/blocking/config/__init__.py +5 -0
  17. package/hooks/blocking/config/verified_commit_constants.py +118 -0
  18. package/hooks/blocking/destructive_command_blocker.py +483 -61
  19. package/hooks/blocking/test_code_rules_enforcer_annotations.py +240 -0
  20. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
  21. package/hooks/blocking/test_code_rules_enforcer_cross_skill_duplicate.py +146 -0
  22. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +188 -0
  23. package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +82 -0
  24. package/hooks/blocking/test_code_rules_enforcer_orphan_css_class.py +196 -0
  25. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias.py +415 -0
  26. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +156 -0
  27. package/hooks/blocking/test_destructive_command_blocker.py +213 -0
  28. package/hooks/blocking/test_verdict_directory_write_blocker.py +720 -0
  29. package/hooks/blocking/test_verification_verdict_store.py +490 -0
  30. package/hooks/blocking/test_verified_commit_gate.py +495 -0
  31. package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -0
  32. package/hooks/blocking/test_verifier_verdict_minter.py +193 -0
  33. package/hooks/blocking/verdict_directory_write_blocker.py +667 -0
  34. package/hooks/blocking/verification_verdict_store.py +686 -0
  35. package/hooks/blocking/verified_commit_gate.py +535 -0
  36. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +152 -0
  37. package/hooks/blocking/verifier_verdict_minter.py +221 -0
  38. package/hooks/diagnostic/test_hook_log_extractor.py +3 -3
  39. package/hooks/hooks.json +43 -1
  40. package/hooks/hooks_constants/blocking_check_limits.py +1 -0
  41. package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
  42. package/hooks/hooks_constants/dead_module_constant_constants.py +20 -0
  43. package/hooks/hooks_constants/destructive_command_segment_constants.py +15 -0
  44. package/hooks/hooks_constants/duplicate_function_body_constants.py +22 -5
  45. package/hooks/hooks_constants/orphan_css_class_constants.py +40 -0
  46. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
  47. package/hooks/validation/mypy_validator.py +59 -7
  48. package/hooks/validation/test_mypy_validator.py +94 -0
  49. package/package.json +1 -1
  50. package/rules/file-global-constants.md +7 -1
  51. package/rules/no-cross-skill-duplicate-helpers.md +29 -0
  52. package/rules/orphan-css-class.md +23 -0
  53. package/skills/_shared/pr-loop/scripts/preflight_worktree.py +392 -0
  54. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/preflight_constants.py +70 -0
  55. package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +263 -0
  56. package/skills/autoconverge/SKILL.md +54 -17
  57. package/skills/autoconverge/reference/closing-report.md +59 -17
  58. package/skills/autoconverge/workflow/aggregate_runs.py +371 -0
  59. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +192 -76
  60. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +76 -0
  61. package/skills/autoconverge/workflow/converge.contract.test.mjs +395 -206
  62. package/skills/autoconverge/workflow/converge.mjs +520 -57
  63. package/skills/autoconverge/workflow/convergence_summary.py +110 -0
  64. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ab1c2d3e4f5a6b7c8.jsonl +2 -0
  65. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +7 -0
  66. package/skills/autoconverge/workflow/render_report.py +488 -397
  67. package/skills/autoconverge/workflow/test_aggregate_runs.py +134 -0
  68. package/skills/autoconverge/workflow/test_convergence_summary.py +132 -0
  69. package/skills/autoconverge/workflow/test_render_report.py +518 -259
  70. package/skills/pr-converge/reference/per-tick.md +28 -8
  71. package/skills/rebase/SKILL.md +2 -4
  72. package/system-prompts/software-engineer.xml +2 -6
  73. package/hooks/blocking/content_search_to_zoekt_redirector.py +0 -59
  74. package/hooks/blocking/content_search_zoekt_bash_block_reason.py +0 -25
  75. package/hooks/blocking/content_search_zoekt_block_payload.py +0 -21
  76. package/hooks/blocking/content_search_zoekt_indexed_paths.py +0 -24
  77. package/hooks/blocking/content_search_zoekt_indexed_roots_config.py +0 -131
  78. package/hooks/blocking/content_search_zoekt_redirect_guidance.py +0 -52
  79. package/hooks/blocking/test_content_search_to_zoekt_redirector_integration.py +0 -61
  80. package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +0 -92
  81. package/hooks/blocking/test_content_search_zoekt_indexed_roots_config.py +0 -102
@@ -0,0 +1,221 @@
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's own transcript
6
+ (``agent_transcript_path``), which sits beside a harness-written
7
+ ``agent-<id>.meta.json`` sidecar naming the spawning ``agentType``. The hook
8
+ reads that type from the sidecar, so it resolves identically in interactive,
9
+ background, and worktree-switched sessions. When that type is
10
+ ``code-verifier``, the hook pulls the verdict block out of the agent's own
11
+ transcript (``agent_transcript_path``); the main session writes neither that
12
+ transcript nor the sidecar, so text it prints can never mint — recomputes the
13
+ live change-surface hash for the session repository, and writes the verdict
14
+ bound to that hash. The companion ``verified_commit_gate.py`` (PreToolUse)
15
+ then allows ``git commit`` / ``git push`` only while the work tree still
16
+ 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
+ from pathlib import Path
33
+
34
+ blocking_directory = str(Path(__file__).resolve().parent)
35
+ if blocking_directory not in sys.path:
36
+ sys.path.insert(0, blocking_directory)
37
+
38
+ from config.verified_commit_constants import MINTING_AGENT_TYPE
39
+ from verification_verdict_store import (
40
+ branch_surface_manifest,
41
+ manifest_sha256,
42
+ resolve_merge_base,
43
+ resolve_repo_root,
44
+ write_verdict,
45
+ )
46
+
47
+
48
+ def assistant_text_blocks(transcript_path: str) -> list[str]:
49
+ """Collect every assistant text block from a transcript JSONL file.
50
+
51
+ Args:
52
+ transcript_path: Path to the subagent's transcript.
53
+
54
+ Returns:
55
+ The text of each assistant content block, in transcript order;
56
+ empty when the file is missing or holds no assistant text.
57
+ """
58
+ collected_blocks: list[str] = []
59
+ try:
60
+ transcript_lines = (
61
+ Path(transcript_path).read_text(encoding="utf-8", errors="replace").splitlines()
62
+ )
63
+ except OSError:
64
+ return collected_blocks
65
+ for each_line in transcript_lines:
66
+ try:
67
+ transcript_entry = json.loads(each_line)
68
+ except json.JSONDecodeError:
69
+ continue
70
+ if not isinstance(transcript_entry, dict):
71
+ continue
72
+ if transcript_entry.get("type") != "assistant":
73
+ continue
74
+ message_body = transcript_entry.get("message")
75
+ if not isinstance(message_body, dict):
76
+ continue
77
+ content_blocks = message_body.get("content")
78
+ if not isinstance(content_blocks, list):
79
+ continue
80
+ for each_block in content_blocks:
81
+ if isinstance(each_block, dict) and each_block.get("type") == "text":
82
+ block_text = each_block.get("text")
83
+ if isinstance(block_text, str):
84
+ collected_blocks.append(block_text)
85
+ return collected_blocks
86
+
87
+
88
+ def last_verdict_in_blocks(all_text_blocks: list[str]) -> dict | None:
89
+ """Extract the final verdict fence from assistant text blocks.
90
+
91
+ Args:
92
+ all_text_blocks: Assistant text blocks in transcript order.
93
+
94
+ Returns:
95
+ The parsed verdict mapping carrying a boolean ``all_pass`` and a
96
+ list ``findings``, or None when no block holds a wellformed fence.
97
+ """
98
+ verdict_fence_pattern = re.compile(r"```verdict\s*\n(.*?)```", re.DOTALL)
99
+ fence_bodies: list[str] = []
100
+ for each_block in all_text_blocks:
101
+ fence_bodies.extend(verdict_fence_pattern.findall(each_block))
102
+ for each_fence_body in reversed(fence_bodies):
103
+ try:
104
+ verdict_record = json.loads(each_fence_body)
105
+ except json.JSONDecodeError:
106
+ continue
107
+ if not isinstance(verdict_record, dict):
108
+ continue
109
+ if not isinstance(verdict_record.get("all_pass"), bool):
110
+ continue
111
+ if not isinstance(verdict_record.get("findings"), list):
112
+ continue
113
+ return verdict_record
114
+ return None
115
+
116
+
117
+ def _agent_type_from_meta_sidecar(agent_transcript_path: str) -> str | None:
118
+ """Read the spawning agentType from a subagent transcript's sidecar.
119
+
120
+ Each subagent transcript ``agent-<id>.jsonl`` sits beside a harness-written
121
+ ``agent-<id>.meta.json`` naming the spawning ``agentType``. Reading the type
122
+ from this sidecar binds it to the stopping subagent itself, so it resolves
123
+ identically in interactive, background, and worktree-switched sessions and
124
+ needs no parent-transcript scan or flush retry.
125
+
126
+ Args:
127
+ agent_transcript_path: The stopping subagent's own transcript path from
128
+ the SubagentStop payload.
129
+
130
+ Returns:
131
+ The recorded ``agentType``, or None when the path is empty, the sidecar
132
+ is absent or cannot be read or parsed, it does not hold a JSON object,
133
+ or it names no string ``agentType``.
134
+ """
135
+ if not agent_transcript_path:
136
+ return None
137
+ transcript_file = Path(agent_transcript_path)
138
+ sidecar_file = transcript_file.with_name(f"{transcript_file.stem}.meta.json")
139
+ try:
140
+ sidecar_record = json.loads(sidecar_file.read_text(encoding="utf-8"))
141
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
142
+ return None
143
+ if not isinstance(sidecar_record, dict):
144
+ return None
145
+ recorded_type = sidecar_record.get("agentType")
146
+ return recorded_type if isinstance(recorded_type, str) else None
147
+
148
+
149
+ def resolved_subagent_type(subagent_stop_payload: dict) -> str | None:
150
+ """Recover the spawning agent type for a SubagentStop payload.
151
+
152
+ The stopping subagent's own transcript (``agent_transcript_path``) sits
153
+ beside a harness-written ``agent-<id>.meta.json`` sidecar naming its
154
+ ``agentType``. Reading the type from that sidecar binds it to the subagent
155
+ itself, so it resolves the same across interactive, background, and
156
+ worktree-switched sessions.
157
+
158
+ Args:
159
+ subagent_stop_payload: The SubagentStop hook payload.
160
+
161
+ Returns:
162
+ The agent type this subagent was spawned with, or None when the
163
+ ``agent_transcript_path`` is empty, the sidecar is absent or cannot be
164
+ read or parsed, it does not hold a JSON object, or it names no string
165
+ ``agentType``.
166
+ """
167
+ return _agent_type_from_meta_sidecar(
168
+ subagent_stop_payload.get("agent_transcript_path", "")
169
+ )
170
+
171
+
172
+ def mint_for_payload(subagent_stop_payload: dict) -> Path | None:
173
+ """Mint a verdict file for a code-verifier stop event.
174
+
175
+ Args:
176
+ subagent_stop_payload: The SubagentStop hook payload.
177
+
178
+ Returns:
179
+ The verdict file path when minted; None when the payload is not a
180
+ code-verifier stop, the transcript holds no verdict, or the
181
+ session directory is not a work tree with an upstream base.
182
+ """
183
+ if resolved_subagent_type(subagent_stop_payload) != MINTING_AGENT_TYPE:
184
+ return None
185
+ agent_transcript_path = subagent_stop_payload.get("agent_transcript_path", "")
186
+ if not agent_transcript_path:
187
+ return None
188
+ verdict_record = last_verdict_in_blocks(assistant_text_blocks(agent_transcript_path))
189
+ if verdict_record is None:
190
+ return None
191
+ repo_root = resolve_repo_root(subagent_stop_payload.get("cwd", "."))
192
+ if repo_root is None:
193
+ return None
194
+ merge_base_sha = resolve_merge_base(repo_root)
195
+ if merge_base_sha is None:
196
+ return None
197
+ surface_manifest_text = branch_surface_manifest(repo_root, merge_base_sha)
198
+ if surface_manifest_text is None:
199
+ return None
200
+ return write_verdict(
201
+ repo_root,
202
+ manifest_sha256(surface_manifest_text),
203
+ verdict_record["all_pass"],
204
+ verdict_record["findings"],
205
+ str(subagent_stop_payload.get("agent_id", "")),
206
+ )
207
+
208
+
209
+ def main() -> None:
210
+ """Read the SubagentStop payload and mint a verdict when one applies."""
211
+ try:
212
+ subagent_stop_payload = json.load(sys.stdin)
213
+ except json.JSONDecodeError:
214
+ return
215
+ if not isinstance(subagent_stop_payload, dict):
216
+ return
217
+ mint_for_payload(subagent_stop_payload)
218
+
219
+
220
+ if __name__ == "__main__":
221
+ 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
@@ -124,6 +124,12 @@ KNOWN_PYTEST_FIXTURE_ANNOTATION_MESSAGE_SUFFIX: str = (
124
124
  "(CODE_RULES §6; pytest builtin fixture reference "
125
125
  "https://docs.pytest.org/en/stable/reference/fixtures.html)"
126
126
  )
127
+ UNUSED_PYTEST_FIXTURE_PARAMETER_MESSAGE_SUFFIX: str = (
128
+ "known pytest fixture parameter is declared but never referenced in the "
129
+ "function body; pytest still materializes its setup, so drop the unused "
130
+ "parameter (pytest builtin fixture reference "
131
+ "https://docs.pytest.org/en/stable/reference/fixtures.html)"
132
+ )
127
133
  ALL_LOOP_INDEX_LETTER_EXEMPTIONS: frozenset[str] = frozenset({"i", "j", "k", "_"})
128
134
  EACH_PREFIX = "each_"
129
135
  BARE_EACH_TOKEN = "each"
@@ -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
+ )
@@ -41,6 +41,7 @@ ALL_INTERPRETER_AND_WRAPPER_COMMANDS: frozenset[str] = frozenset(
41
41
  "su",
42
42
  "env",
43
43
  "xargs",
44
+ "parallel",
44
45
  "awk",
45
46
  "gawk",
46
47
  "mawk",
@@ -66,6 +67,12 @@ ALL_REMOTE_AND_PROGRAM_STRING_EXECUTORS: frozenset[str] = frozenset(
66
67
  }
67
68
  )
68
69
  ALL_STRING_ARGUMENT_EXECUTION_FLAGS: frozenset[str] = frozenset({"-c", "-e"})
70
+ FIND_PROGRAM_NAME: str = "find"
71
+ ALL_FIND_EXEC_ACTION_FLAGS: frozenset[str] = frozenset({"-exec", "-execdir"})
72
+ ALL_FIND_EXEC_ACTION_TERMINATORS: frozenset[str] = frozenset({";", "+"})
73
+ ALL_FIND_GLOBAL_OPTION_FLAGS_WITHOUT_VALUE: frozenset[str] = frozenset({"-H", "-L", "-P"})
74
+ ALL_FIND_GLOBAL_OPTION_FLAGS_TAKING_A_VALUE: frozenset[str] = frozenset({"-D"})
75
+ FIND_OPTIMIZATION_LEVEL_OPTION_PREFIX: str = "-O"
69
76
  ALL_BENIGN_COMPOUND_SEGMENT_COMMANDS: frozenset[str] = frozenset(
70
77
  {
71
78
  "echo",
@@ -176,3 +183,11 @@ ALL_LAUNCHER_OPTIONS_TAKING_SEPARATE_VALUE: frozenset[str] = frozenset(
176
183
  }
177
184
  )
178
185
  ALL_SUBSHELL_GROUPING_CHARACTERS: str = "({"
186
+ ALL_KNOWN_TEMPORARY_ENVIRONMENT_VARIABLE_NAMES: frozenset[str] = frozenset(
187
+ {
188
+ "TEMP",
189
+ "TMP",
190
+ "TMPDIR",
191
+ "CLAUDE_JOB_DIR",
192
+ }
193
+ )
@@ -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
+ )
@@ -0,0 +1,40 @@
1
+ """Constants for the orphan-CSS-class check in code_rules_enforcer.
2
+
3
+ A Python module that builds HTML emits ``class="..."`` attributes in string
4
+ literals and pairs them with a ``<style>`` block whose selectors style those
5
+ classes. When a class appears in the markup but no selector defines it, the
6
+ markup carries a dead attribute or the style block is missing a rule. This
7
+ module holds the patterns that pair the two halves and the package-scan
8
+ budget that bounds the sibling read.
9
+ """
10
+
11
+ import re
12
+
13
+ __all__ = [
14
+ "CLASS_ATTRIBUTE_PATTERN",
15
+ "STYLE_BLOCK_PATTERN",
16
+ "CSS_CLASS_SELECTOR_PATTERN",
17
+ "PYTHON_MODULE_GLOB",
18
+ "MAX_ORPHAN_CSS_CLASS_ISSUES",
19
+ "MAX_SIBLING_MODULES_SCANNED",
20
+ "ORPHAN_CSS_CLASS_MESSAGE_SUFFIX",
21
+ ]
22
+
23
+ CLASS_ATTRIBUTE_PATTERN: re.Pattern[str] = re.compile(r"""class\s*=\s*["']([^"']+)["']""")
24
+
25
+ STYLE_BLOCK_PATTERN: re.Pattern[str] = re.compile(
26
+ r"<style[^>]*>(.*?)</style>", re.DOTALL | re.IGNORECASE
27
+ )
28
+
29
+ CSS_CLASS_SELECTOR_PATTERN: re.Pattern[str] = re.compile(r"\.(-?[_a-zA-Z][\w-]*)")
30
+
31
+ PYTHON_MODULE_GLOB: str = "*.py"
32
+
33
+ MAX_ORPHAN_CSS_CLASS_ISSUES: int = 10
34
+
35
+ MAX_SIBLING_MODULES_SCANNED: int = 60
36
+
37
+ ORPHAN_CSS_CLASS_MESSAGE_SUFFIX: str = (
38
+ "add a matching '.<class>' selector to the <style> block, "
39
+ "or drop the unused class attribute (CODE_RULES self-documenting markup)"
40
+ )
@@ -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",
@@ -69,13 +69,55 @@ def is_file_within_project(target_file: str, project_root: Path) -> bool:
69
69
  return False
70
70
 
71
71
 
72
- def build_mypy_command(relative_file_path: str) -> list[str]:
73
- if IS_WINDOWS:
74
- base_command = [sys.executable, "-m", "mypy"]
75
- else:
76
- base_command = ["mypy"]
72
+ def discover_mypy_config(target_file: Path) -> Path | None:
73
+ """Return the nearest ancestor ``pyproject.toml`` that configures mypy.
74
+
75
+ Mypy applies a project's ``[tool.mypy]`` settings only when the config file
76
+ is on its invocation path; handing the discovered config to mypy lets a
77
+ check run from the repository root still honor the project's own import
78
+ resolution settings (such as ``ignore_missing_imports``) for a module that
79
+ imports its siblings by name. Reuses the validators-package walk-up so the
80
+ discovery logic lives in one place.
81
+
82
+ Args:
83
+ target_file: The Python file mypy will check.
84
+
85
+ Returns:
86
+ The nearest ancestor ``pyproject.toml`` declaring a ``[tool.mypy]``
87
+ table, or None when none exists above the file or the walk-up helper
88
+ cannot be imported.
89
+ """
90
+ validators_directory = os.path.join(
91
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "validators"
92
+ )
93
+ if validators_directory not in sys.path:
94
+ sys.path.insert(0, validators_directory)
95
+ try:
96
+ integration_module = importlib.import_module("mypy_integration")
97
+ except ImportError:
98
+ return None
99
+ discovered_config = integration_module.find_pyproject_with_mypy_config(target_file)
100
+ return discovered_config if isinstance(discovered_config, Path) else None
101
+
102
+
103
+ def build_mypy_command(relative_file_path: str, mypy_config_file: Path | None) -> list[str]:
104
+ """Build the mypy command line for one file.
77
105
 
78
- return base_command + [
106
+ Args:
107
+ relative_file_path: The target file path relative to the project root.
108
+ mypy_config_file: The ``pyproject.toml`` to pass via ``--config-file``,
109
+ or None to let mypy fall back to its own config discovery.
110
+
111
+ Returns:
112
+ The full mypy argument vector, including the interpreter prefix on
113
+ Windows and the config file when one was discovered.
114
+ """
115
+ base_command = [sys.executable, "-m", "mypy"] if IS_WINDOWS else ["mypy"]
116
+
117
+ config_arguments = (
118
+ ["--config-file", str(mypy_config_file)] if mypy_config_file is not None else []
119
+ )
120
+ return base_command + config_arguments + [
79
121
  "--no-error-summary",
80
122
  "--show-error-codes",
81
123
  "--no-color",
@@ -84,8 +126,18 @@ def build_mypy_command(relative_file_path: str) -> list[str]:
84
126
 
85
127
 
86
128
  def run_mypy(target_file: str, project_root: str) -> tuple[int, str]:
129
+ """Run mypy on one file from the project root and return its result.
130
+
131
+ Args:
132
+ target_file: The absolute path of the file to type-check.
133
+ project_root: The directory mypy runs from.
134
+
135
+ Returns:
136
+ The mypy exit code paired with its combined stdout and stderr text.
137
+ """
87
138
  relative_file_path = os.path.relpath(target_file, project_root)
88
- mypy_command = build_mypy_command(relative_file_path)
139
+ mypy_config_file = discover_mypy_config(Path(target_file))
140
+ mypy_command = build_mypy_command(relative_file_path, mypy_config_file)
89
141
 
90
142
  completed_process = subprocess.run(
91
143
  mypy_command,
@@ -0,0 +1,94 @@
1
+ """Behavior tests for the mypy_validator config-discovery fix.
2
+
3
+ The hook runs mypy from the project root, so without handing mypy the project's
4
+ own ``[tool.mypy]`` config a module that imports its siblings by name draws a
5
+ spurious ``import-not-found`` error. These tests drive the real production
6
+ functions: ``discover_mypy_config`` walks up to the nearest configuring
7
+ ``pyproject.toml`` and ``run_mypy`` passes it through so the project's
8
+ ``ignore_missing_imports`` setting applies.
9
+ """
10
+
11
+ import importlib.util
12
+ from pathlib import Path
13
+ from types import ModuleType
14
+
15
+ HOOK_PATH = Path(__file__).resolve().parent / "mypy_validator.py"
16
+
17
+ MODULE_WITH_SIBLING_IMPORT = (
18
+ "from sibling_only_resolvable_at_runtime import value\n\nx: int = value\n"
19
+ )
20
+ TOOL_MYPY_PYPROJECT = "[tool.mypy]\nignore_missing_imports = true\n"
21
+ NON_MYPY_PYPROJECT = "[tool.ruff]\nline-length = 100\n"
22
+
23
+
24
+ def _load_validator() -> ModuleType:
25
+ spec = importlib.util.spec_from_file_location("mypy_validator_under_test", HOOK_PATH)
26
+ assert spec is not None and spec.loader is not None
27
+ module = importlib.util.module_from_spec(spec)
28
+ spec.loader.exec_module(module)
29
+ return module
30
+
31
+
32
+ def test_discover_mypy_config_finds_nearest_tool_mypy_pyproject(tmp_path: Path) -> None:
33
+ validator = _load_validator()
34
+ (tmp_path / "pyproject.toml").write_text(TOOL_MYPY_PYPROJECT, encoding="utf-8")
35
+ nested_module = tmp_path / "package" / "module.py"
36
+ nested_module.parent.mkdir(parents=True)
37
+ nested_module.write_text("value: int = 1\n", encoding="utf-8")
38
+
39
+ discovered = validator.discover_mypy_config(nested_module)
40
+
41
+ assert discovered is not None
42
+ assert discovered.resolve() == (tmp_path / "pyproject.toml").resolve()
43
+
44
+
45
+ def test_discover_mypy_config_returns_none_without_tool_mypy(tmp_path: Path) -> None:
46
+ validator = _load_validator()
47
+ (tmp_path / "pyproject.toml").write_text(NON_MYPY_PYPROJECT, encoding="utf-8")
48
+ standalone_module = tmp_path / "module.py"
49
+ standalone_module.write_text("value: int = 1\n", encoding="utf-8")
50
+
51
+ assert validator.discover_mypy_config(standalone_module) is None
52
+
53
+
54
+ def test_build_mypy_command_includes_config_file_when_present(tmp_path: Path) -> None:
55
+ validator = _load_validator()
56
+ config_file = tmp_path / "pyproject.toml"
57
+
58
+ command = validator.build_mypy_command("package/module.py", config_file)
59
+
60
+ assert "--config-file" in command
61
+ assert command[command.index("--config-file") + 1] == str(config_file)
62
+ assert command[-1] == "package/module.py"
63
+
64
+
65
+ def test_build_mypy_command_omits_config_file_when_absent(tmp_path: Path) -> None:
66
+ validator = _load_validator()
67
+
68
+ command = validator.build_mypy_command("package/module.py", None)
69
+
70
+ assert "--config-file" not in command
71
+ assert command[-1] == "package/module.py"
72
+
73
+
74
+ def test_run_mypy_suppresses_sibling_import_error_with_tool_mypy_config(tmp_path: Path) -> None:
75
+ validator = _load_validator()
76
+ (tmp_path / "pyproject.toml").write_text(TOOL_MYPY_PYPROJECT, encoding="utf-8")
77
+ importer_module = tmp_path / "importer.py"
78
+ importer_module.write_text(MODULE_WITH_SIBLING_IMPORT, encoding="utf-8")
79
+
80
+ exit_code, output = validator.run_mypy(str(importer_module), str(tmp_path))
81
+
82
+ assert exit_code == 0, output
83
+ assert "import-not-found" not in output
84
+
85
+
86
+ def test_run_mypy_reports_import_error_without_tool_mypy_config(tmp_path: Path) -> None:
87
+ validator = _load_validator()
88
+ importer_module = tmp_path / "importer.py"
89
+ importer_module.write_text(MODULE_WITH_SIBLING_IMPORT, encoding="utf-8")
90
+
91
+ exit_code, output = validator.run_mypy(str(importer_module), str(tmp_path))
92
+
93
+ assert exit_code != 0
94
+ assert "import-not-found" in output
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.59.0",
3
+ "version": "1.61.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {