claude-dev-env 2.0.1 → 2.1.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 (127) hide show
  1. package/CLAUDE.md +1 -1
  2. package/agents/clean-coder.md +31 -1
  3. package/audit-rubrics/prompts/category-e-dead-code.md +2 -2
  4. package/bin/install.test.mjs +2 -2
  5. package/docs/CLAUDE.md +6 -3
  6. package/docs/CODE_RULES.md +5 -1
  7. package/docs/agent-spawn-protocol.md +39 -0
  8. package/docs/nas-ssh-invocation.md +23 -0
  9. package/docs/worker-completion-gate.md +33 -0
  10. package/hooks/blocking/CLAUDE.md +1 -0
  11. package/hooks/blocking/code_rules_dead_module_constant.py +11 -5
  12. package/hooks/blocking/code_rules_shared.py +126 -47
  13. package/hooks/blocking/config/CLAUDE.md +2 -0
  14. package/hooks/blocking/config/verified_commit_constants.py +8 -1
  15. package/hooks/blocking/config/verified_commit_context_constants.py +21 -0
  16. package/hooks/blocking/config/verified_commit_gate_output_constants.py +14 -0
  17. package/hooks/blocking/conftest.py +36 -30
  18. package/hooks/blocking/convergence_gate_blocker.py +76 -8
  19. package/hooks/blocking/plain_language_blocker.py +8 -0
  20. package/hooks/blocking/state_description_blocker.py +4 -1
  21. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +58 -0
  22. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant_alias.py +133 -0
  23. package/hooks/blocking/test_code_rules_shared.py +120 -20
  24. package/hooks/blocking/test_convergence_gate_blocker.py +146 -0
  25. package/hooks/blocking/test_plain_language_blocker.py +15 -0
  26. package/hooks/blocking/test_state_description_blocker.py +15 -0
  27. package/hooks/blocking/test_verification_verdict_store.py +12 -0
  28. package/hooks/blocking/test_verified_commit_config_bootstrap.py +18 -0
  29. package/hooks/blocking/test_verified_commit_docs_delta.py +176 -0
  30. package/hooks/blocking/test_verified_commit_gate_additional_context.py +134 -0
  31. package/hooks/blocking/tests/test_pii_prevention_blocker.py +0 -1
  32. package/hooks/blocking/tests/test_verified_commit_gate.py +41 -0
  33. package/hooks/blocking/verified_commit_config_bootstrap.py +22 -10
  34. package/hooks/blocking/verified_commit_gate.py +113 -568
  35. package/hooks/blocking/verified_commit_gate_parts/CLAUDE.md +28 -0
  36. package/hooks/blocking/verified_commit_gate_parts/__init__.py +1 -0
  37. package/hooks/blocking/verified_commit_gate_parts/command_tokenization.py +174 -0
  38. package/hooks/blocking/verified_commit_gate_parts/deny_payload.py +53 -0
  39. package/hooks/blocking/verified_commit_gate_parts/deny_reason.py +80 -0
  40. package/hooks/blocking/verified_commit_gate_parts/directory_resolution.py +170 -0
  41. package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +205 -0
  42. package/hooks/blocking/verified_commit_gate_parts/tests/conftest.py +10 -0
  43. package/hooks/blocking/verified_commit_gate_parts/tests/test_command_tokenization.py +94 -0
  44. package/hooks/blocking/verified_commit_gate_parts/tests/test_deny_payload.py +17 -0
  45. package/hooks/blocking/verified_commit_gate_parts/tests/test_deny_reason.py +38 -0
  46. package/hooks/blocking/verified_commit_gate_parts/tests/test_directory_resolution.py +71 -0
  47. package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +61 -0
  48. package/hooks/hooks_constants/CLAUDE.md +4 -1
  49. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
  50. package/hooks/hooks_constants/convergence_gate_blocker_constants.py +36 -0
  51. package/hooks/hooks_constants/dead_module_constant_constants.py +8 -0
  52. package/hooks/hooks_constants/harness_scratchpad_constants.py +10 -9
  53. package/hooks/hooks_constants/mypy_integration_constants.py +16 -0
  54. package/hooks/hooks_constants/pii_prevention_constants.py +1 -0
  55. package/hooks/validators/mypy_integration.py +145 -24
  56. package/hooks/validators/python_antipattern_checks.py +16 -0
  57. package/hooks/validators/run_all_validators.py +9 -3
  58. package/hooks/validators/test_mypy_integration.py +154 -0
  59. package/hooks/validators/test_python_antipattern_checks.py +112 -1
  60. package/hooks/validators/test_run_all_validators_pretooluse.py +16 -0
  61. package/package.json +1 -1
  62. package/rules/CLAUDE.md +3 -3
  63. package/rules/agent-spawn-protocol.md +5 -43
  64. package/rules/code-standards.md +1 -36
  65. package/rules/env-var-table-code-drift.md +2 -21
  66. package/rules/hook-prose-matches-detector.md +5 -16
  67. package/rules/nas-ssh-invocation.md +3 -15
  68. package/rules/no-historical-clutter.md +7 -49
  69. package/rules/no-inline-destructive-literals.md +3 -5
  70. package/rules/package-inventory-stale-entry.md +7 -32
  71. package/rules/re-stage-before-commit.md +6 -23
  72. package/rules/shell-invocation-policy.md +1 -1
  73. package/rules/vault-context.md +3 -3
  74. package/rules/verified-commit-gate-skip.md +28 -0
  75. package/rules/workers-done-before-complete.md +2 -30
  76. package/scripts/claude_chain_runner.py +39 -3
  77. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +3 -0
  78. package/scripts/test_claude_chain_runner.py +112 -0
  79. package/scripts/test_grok_headless_runner.py +0 -1
  80. package/skills/auditing-claude-config/CLAUDE.md +2 -1
  81. package/skills/auditing-claude-config/SKILL.md +114 -176
  82. package/skills/auditing-claude-config/reference/probe-hook.md +74 -0
  83. package/skills/autoconverge/CLAUDE.md +1 -0
  84. package/skills/autoconverge/SKILL.md +310 -346
  85. package/skills/autoconverge/reference/CLAUDE.md +1 -0
  86. package/skills/autoconverge/reference/copilot-findings.md +51 -0
  87. package/skills/closeout/SKILL.md +7 -9
  88. package/skills/copilot-finding-triage/SKILL.md +5 -7
  89. package/skills/copilot-review/CLAUDE.md +3 -2
  90. package/skills/copilot-review/SKILL.md +119 -155
  91. package/skills/copilot-review/templates/subagent-prompt.md +49 -0
  92. package/skills/everything-search/SKILL.md +7 -1
  93. package/skills/fresh-branch/CLAUDE.md +6 -9
  94. package/skills/fresh-branch/SKILL.md +84 -33
  95. package/skills/fresh-branch/scripts/create_fresh_branch.py +445 -0
  96. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/__init__.py +1 -0
  97. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +74 -0
  98. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +670 -0
  99. package/skills/orchestrator/SKILL.md +161 -147
  100. package/skills/pr-converge/SKILL.md +277 -422
  101. package/skills/pr-converge/reference/CLAUDE.md +1 -0
  102. package/skills/pr-converge/reference/progress-checklist.md +168 -0
  103. package/skills/pr-converge/test_step5_host_branch.py +8 -6
  104. package/skills/pr-loop-cloud-transport/SKILL.md +1 -1
  105. package/skills/privacy-hygiene/SKILL.md +68 -115
  106. package/skills/privacy-hygiene/reference/sweep-procedure.md +62 -0
  107. package/skills/session-log/CLAUDE.md +2 -1
  108. package/skills/session-log/SKILL.md +4 -26
  109. package/skills/session-log/templates/frontmatter.md +40 -0
  110. package/skills/skill-builder/CLAUDE.md +8 -7
  111. package/skills/skill-builder/SKILL.md +26 -11
  112. package/skills/skill-builder/references/CLAUDE.md +3 -1
  113. package/skills/skill-builder/references/delegation-map.md +21 -12
  114. package/skills/skill-builder/references/description-field.md +9 -11
  115. package/skills/skill-builder/references/deterministic-elements.md +218 -0
  116. package/skills/skill-builder/references/self-audit-checklist.md +62 -45
  117. package/skills/skill-builder/references/skill-modularity.md +8 -9
  118. package/skills/skill-builder/templates/CLAUDE.md +2 -2
  119. package/skills/skill-builder/templates/gap-analysis.md +15 -0
  120. package/skills/skill-builder/workflows/CLAUDE.md +5 -5
  121. package/skills/skill-builder/workflows/improve-skill.md +18 -9
  122. package/skills/skill-builder/workflows/new-skill.md +23 -15
  123. package/skills/skill-builder/workflows/polish-skill.md +28 -21
  124. package/system-prompts/CLAUDE.md +3 -3
  125. package/docs/agents-md-alignment-plan.md +0 -123
  126. package/docs/emotion-informed-prompt-design.md +0 -362
  127. package/rules/es-exe-file-search.md +0 -17
@@ -1,10 +1,31 @@
1
1
  """Mypy integration for static type checking."""
2
2
 
3
+ import contextlib
3
4
  import subprocess
5
+ import sys
6
+ import tempfile
4
7
  import tomllib
8
+ from collections.abc import Iterator
5
9
  from dataclasses import dataclass
6
10
  from pathlib import Path
7
11
 
12
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
13
+
14
+ try:
15
+ from hooks_constants.mypy_integration_constants import (
16
+ GIT_DIRECTORY_NAME,
17
+ PYPROJECT_FILENAME,
18
+ PYTHON_SOURCE_SUFFIX,
19
+ )
20
+ except ModuleNotFoundError:
21
+ if _hooks_directory not in sys.path:
22
+ sys.path.insert(0, _hooks_directory)
23
+ from hooks_constants.mypy_integration_constants import (
24
+ GIT_DIRECTORY_NAME,
25
+ PYPROJECT_FILENAME,
26
+ PYTHON_SOURCE_SUFFIX,
27
+ )
28
+
8
29
 
9
30
  @dataclass
10
31
  class MypyResult:
@@ -37,6 +58,28 @@ def _pyproject_contains_tool_mypy(pyproject_path: Path) -> bool:
37
58
  return isinstance(tool_table, dict) and "mypy" in tool_table
38
59
 
39
60
 
61
+ def _ancestor_directories(starting_file: Path) -> list[Path]:
62
+ """Return *starting_file*'s directory and every parent, nearest first.
63
+
64
+ ::
65
+
66
+ repo/pkg/mod.py -> [repo/pkg, repo, ... , filesystem root]
67
+ repo/pkg/ -> [repo/pkg, repo, ... , filesystem root]
68
+
69
+ A file resolves to its containing directory; a directory resolves to
70
+ itself, so both ancestor walks below start from the same first candidate.
71
+
72
+ Args:
73
+ starting_file: The file (or directory) the walk begins from.
74
+
75
+ Returns:
76
+ The resolved starting directory followed by each of its parents.
77
+ """
78
+ resolved_starting_file = starting_file.resolve()
79
+ walk_origin = resolved_starting_file.parent if resolved_starting_file.is_file() else resolved_starting_file
80
+ return [walk_origin, *walk_origin.parents]
81
+
82
+
40
83
  def find_pyproject_with_mypy_config(starting_file: Path) -> Path | None:
41
84
  """Walk up from a starting file to locate a pyproject.toml that configures mypy.
42
85
 
@@ -54,16 +97,103 @@ def find_pyproject_with_mypy_config(starting_file: Path) -> Path | None:
54
97
  table, or ``None`` when no such file exists between ``starting_file``
55
98
  and the filesystem root.
56
99
  """
57
- pyproject_filename_for_lookup = "pyproject.toml"
58
- resolved_starting_file = starting_file.resolve()
59
- current_directory = resolved_starting_file.parent if resolved_starting_file.is_file() else resolved_starting_file
60
- for each_candidate_directory in [current_directory, *current_directory.parents]:
100
+ pyproject_filename_for_lookup = PYPROJECT_FILENAME
101
+ for each_candidate_directory in _ancestor_directories(starting_file):
61
102
  candidate_pyproject = each_candidate_directory / pyproject_filename_for_lookup
62
103
  if candidate_pyproject.is_file() and _pyproject_contains_tool_mypy(candidate_pyproject):
63
104
  return candidate_pyproject
64
105
  return None
65
106
 
66
107
 
108
+ def find_module_resolution_root(starting_file: Path) -> Path | None:
109
+ """Return the nearest ancestor directory that roots a project, else None.
110
+
111
+ A project root is the first ancestor holding a ``.git`` entry or a
112
+ ``pyproject.toml``. Mypy resolves a first-party import against its working
113
+ directory, so anchoring there binds ``config.*`` to the target file's own
114
+ project and keeps a foreign ``config`` in the caller's directory out of scope.
115
+
116
+ ::
117
+
118
+ target_repo/.git + target_repo/tools/x.py -> target_repo
119
+ /tmp/detached/x.py (no marker up-tree) -> None
120
+
121
+ Args:
122
+ starting_file: The file (or directory) the walk begins from.
123
+
124
+ Returns:
125
+ The nearest ancestor Path that holds ``.git`` or ``pyproject.toml``,
126
+ or ``None`` when no such ancestor exists.
127
+ """
128
+ git_entry_name = GIT_DIRECTORY_NAME
129
+ pyproject_filename = PYPROJECT_FILENAME
130
+ for each_candidate_directory in _ancestor_directories(starting_file):
131
+ has_git_entry = (each_candidate_directory / git_entry_name).exists()
132
+ has_pyproject = (each_candidate_directory / pyproject_filename).is_file()
133
+ if has_git_entry or has_pyproject:
134
+ return each_candidate_directory
135
+ return None
136
+
137
+
138
+ def _first_module_resolution_root(all_py_files: list[str]) -> Path | None:
139
+ """Return the project root of the first rooted target file, or None."""
140
+ for each_py_file in all_py_files:
141
+ resolution_root = find_module_resolution_root(Path(each_py_file))
142
+ if resolution_root is not None:
143
+ return resolution_root
144
+ return None
145
+
146
+
147
+ @contextlib.contextmanager
148
+ def mypy_working_directory(all_py_files: list[str]) -> Iterator[str]:
149
+ """Yield the working directory mypy resolves first-party imports from.
150
+
151
+ ::
152
+
153
+ target_repo/tools/serialize_tool.py -> yields target_repo
154
+ /tmp/detached/serialize_tool.py -> yields a fresh empty temp dir
155
+
156
+ A rooted file yields its project root so ``config.constants`` binds to the
157
+ target repo's own package; a detached file yields an isolated directory so
158
+ no foreign top-level package leaks in.
159
+
160
+ Args:
161
+ all_py_files: Absolute or relative paths of the Python files under check.
162
+
163
+ Yields:
164
+ A directory path string mypy should use as its working directory.
165
+ """
166
+ resolution_root = _first_module_resolution_root(all_py_files)
167
+ if resolution_root is not None:
168
+ yield str(resolution_root)
169
+ return
170
+ with tempfile.TemporaryDirectory() as isolated_directory:
171
+ yield isolated_directory
172
+
173
+
174
+ def _mypy_config_argument(all_py_files: list[str]) -> list[str]:
175
+ """Return the ``--config-file`` argument for the first file with a mypy config."""
176
+ for each_py_file in all_py_files:
177
+ discovered_pyproject = find_pyproject_with_mypy_config(Path(each_py_file))
178
+ if discovered_pyproject is not None:
179
+ return ["--config-file", str(discovered_pyproject)]
180
+ return []
181
+
182
+
183
+ def _run_mypy_subprocess(all_py_files: list[str]) -> subprocess.CompletedProcess[str]:
184
+ """Run mypy over *all_py_files* from each file's own project root."""
185
+ config_argument = _mypy_config_argument(all_py_files)
186
+ with mypy_working_directory(all_py_files) as working_directory:
187
+ return subprocess.run(
188
+ ["mypy", *config_argument, "--ignore-missing-imports", "--no-error-summary"]
189
+ + all_py_files,
190
+ check=False,
191
+ capture_output=True,
192
+ text=True,
193
+ cwd=working_directory,
194
+ )
195
+
196
+
67
197
  def run_mypy_check(files: list[Path]) -> MypyResult:
68
198
  """Run mypy on files."""
69
199
  if not files:
@@ -72,29 +202,20 @@ def run_mypy_check(files: list[Path]) -> MypyResult:
72
202
  if not check_mypy_available():
73
203
  return MypyResult(passed=True, output="Mypy not installed - skipping", error_count=0)
74
204
 
75
- py_files = [str(f) for f in files if f.suffix == ".py"]
76
- if not py_files:
205
+ python_source_suffix = PYTHON_SOURCE_SUFFIX
206
+ all_py_files = [
207
+ str(each_file.resolve())
208
+ for each_file in files
209
+ if each_file.suffix == python_source_suffix
210
+ ]
211
+ if not all_py_files:
77
212
  return MypyResult(passed=True, output="No Python files", error_count=0)
78
213
 
79
- config_argument: list[str] = []
80
- for each_py_file in py_files:
81
- discovered_pyproject = find_pyproject_with_mypy_config(Path(each_py_file))
82
- if discovered_pyproject is not None:
83
- config_argument = ["--config-file", str(discovered_pyproject)]
84
- break
85
-
86
- result = subprocess.run(
87
- ["mypy", *config_argument, "--ignore-missing-imports", "--no-error-summary"]
88
- + py_files,
89
- check=False,
90
- capture_output=True,
91
- text=True,
92
- )
93
-
94
- error_count = result.stdout.count(": error:")
214
+ completed_process = _run_mypy_subprocess(all_py_files)
215
+ error_count = completed_process.stdout.count(": error:")
95
216
 
96
217
  return MypyResult(
97
- passed=result.returncode == 0,
98
- output=result.stdout or result.stderr or "No type errors",
218
+ passed=completed_process.returncode == 0,
219
+ output=completed_process.stdout or completed_process.stderr or "No type errors",
99
220
  error_count=error_count,
100
221
  )
@@ -13,6 +13,15 @@ from typing import List
13
13
 
14
14
  from .validator_base import Violation
15
15
 
16
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
17
+
18
+ try:
19
+ from hooks_constants.code_rules_enforcer_constants import ALL_CLI_FILE_PATH_MARKERS
20
+ except ModuleNotFoundError:
21
+ if _hooks_directory not in sys.path:
22
+ sys.path.insert(0, _hooks_directory)
23
+ from hooks_constants.code_rules_enforcer_constants import ALL_CLI_FILE_PATH_MARKERS
24
+
16
25
 
17
26
  def check_mutable_default_args(tree: ast.AST, filename: str) -> List[Violation]:
18
27
  violations: List[Violation] = []
@@ -55,6 +64,13 @@ def check_print_in_production(tree: ast.AST, filename: str) -> List[Violation]:
55
64
  if "test" in filename.lower():
56
65
  return violations
57
66
 
67
+ normalized_path = filename.lower().replace("\\", "/")
68
+ is_cli_entry_point = any(
69
+ marker.replace("\\", "/") in normalized_path for marker in ALL_CLI_FILE_PATH_MARKERS
70
+ )
71
+ if is_cli_entry_point:
72
+ return violations
73
+
58
74
  for node in ast.walk(tree):
59
75
  if isinstance(node, ast.Call):
60
76
  if isinstance(node.func, ast.Name) and node.func.id == "print":
@@ -32,6 +32,7 @@ _hooks_directory_on_path = str(hooks_dir.resolve())
32
32
  if _hooks_directory_on_path not in sys.path:
33
33
  sys.path.insert(0, _hooks_directory_on_path)
34
34
 
35
+ from blocking.code_rules_shared import is_ephemeral_path # noqa: E402
35
36
  from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
36
37
  from hooks_constants.multi_edit_reconstruction import ( # noqa: E402
37
38
  apply_edits,
@@ -821,9 +822,12 @@ def _evaluate_pre_tool_use_payload() -> None:
821
822
  """Read the PreToolUse payload from stdin and deny a violating file write.
822
823
 
823
824
  Reconstructs the proposed post-edit content of the one target file and runs
824
- the file-scoped validators against it. Emits a deny decision naming each
825
- failing validator when any fires; writes nothing for a clean file, an
826
- unparseable payload, or a payload no validator covers.
825
+ the file-scoped validators against it. The path-based exemption decision runs
826
+ against the real target path from the payload, so an ephemeral scratch or
827
+ session scratchpad target passes without validation even though the content
828
+ is validated through a temporary copy. Emits a deny decision naming each
829
+ failing validator when any fires; writes nothing for a clean file, an exempt
830
+ target, an unparseable payload, or a payload no validator covers.
827
831
  """
828
832
  pre_tool_use_payload = json.load(sys.stdin)
829
833
  if not isinstance(pre_tool_use_payload, dict):
@@ -835,6 +839,8 @@ def _evaluate_pre_tool_use_payload() -> None:
835
839
  file_path = tool_input.get("file_path", "")
836
840
  if not isinstance(file_path, str) or not file_path:
837
841
  return
842
+ if is_ephemeral_path(file_path, pre_tool_use_payload):
843
+ return
838
844
  proposed_content = reconstruct_proposed_content(tool_name, tool_input)
839
845
  if not proposed_content:
840
846
  return
@@ -3,14 +3,168 @@
3
3
  from pathlib import Path
4
4
  from unittest.mock import patch
5
5
 
6
+ import pytest
7
+
6
8
  from .mypy_integration import (
7
9
  MypyResult,
8
10
  check_mypy_available,
11
+ find_module_resolution_root,
9
12
  find_pyproject_with_mypy_config,
10
13
  run_mypy_check,
11
14
  )
12
15
 
13
16
 
17
+ def _write_config_package(package_parent: Path, constants_body: str) -> None:
18
+ config_package = package_parent / "config"
19
+ config_package.mkdir(parents=True)
20
+ (config_package / "__init__.py").write_text("", encoding="utf-8")
21
+ (config_package / "constants.py").write_text(constants_body, encoding="utf-8")
22
+
23
+
24
+ def _write_config_importer(script_path: Path) -> None:
25
+ script_path.parent.mkdir(parents=True, exist_ok=True)
26
+ script_path.write_text(
27
+ "from config.constants import SERIALIZED_INDENT_WIDTH\n\n"
28
+ "indent_width: int = SERIALIZED_INDENT_WIDTH\n",
29
+ encoding="utf-8",
30
+ )
31
+
32
+
33
+ def test_find_module_resolution_root_returns_git_marked_ancestor(tmp_path: Path) -> None:
34
+ target_repo = tmp_path / "git_repo"
35
+ nested_directory = target_repo / "src" / "deep"
36
+ nested_directory.mkdir(parents=True)
37
+ (target_repo / ".git").mkdir()
38
+ nested_file = nested_directory / "module.py"
39
+ nested_file.write_text("sample_number: int = 1\n", encoding="utf-8")
40
+ assert find_module_resolution_root(nested_file) == target_repo
41
+
42
+
43
+ def test_find_module_resolution_root_returns_nearest_pyproject_ancestor(
44
+ tmp_path: Path,
45
+ ) -> None:
46
+ outer_repo = tmp_path / "outer"
47
+ inner_project = outer_repo / "inner"
48
+ inner_project.mkdir(parents=True)
49
+ (outer_repo / ".git").mkdir()
50
+ (inner_project / "pyproject.toml").write_text(
51
+ "[project]\nname = 'inner'\n", encoding="utf-8"
52
+ )
53
+ nested_file = inner_project / "module.py"
54
+ nested_file.write_text("sample_number: int = 1\n", encoding="utf-8")
55
+ assert find_module_resolution_root(nested_file) == inner_project
56
+
57
+
58
+ def test_run_mypy_check_resolves_config_against_target_repo_not_session_cwd(
59
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
60
+ ) -> None:
61
+ """A target file's ``config`` import binds to its own repo, not the session cwd.
62
+
63
+ ::
64
+
65
+ cwd=session_worktree, whose config/ lacks SERIALIZED_INDENT_WIDTH
66
+ target_repo/tools/serialize_tool.py imports it from target_repo/config/
67
+ flag (defect): binds config to session_worktree -> attr-defined denial
68
+ ok (fixed): binds config to target_repo -> passes
69
+ """
70
+ session_worktree = tmp_path / "session_worktree"
71
+ _write_config_package(session_worktree, "UNRELATED_SESSION_SETTING: int = 9\n")
72
+
73
+ target_repo = tmp_path / "target_repo"
74
+ _write_config_package(target_repo, "SERIALIZED_INDENT_WIDTH: int = 4\n")
75
+ (target_repo / "pyproject.toml").write_text(
76
+ "[project]\nname = 'synthetic-target'\n", encoding="utf-8"
77
+ )
78
+ target_script = target_repo / "tools" / "serialize_tool.py"
79
+ _write_config_importer(target_script)
80
+
81
+ monkeypatch.chdir(session_worktree)
82
+ mypy_result = run_mypy_check([target_script])
83
+
84
+ assert mypy_result.passed, mypy_result.output
85
+
86
+
87
+ def test_run_mypy_check_ignores_session_config_for_detached_gate_file(
88
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
89
+ ) -> None:
90
+ """A detached gate temp file does not bind ``config`` to the session cwd.
91
+
92
+ ::
93
+
94
+ cwd=session_worktree, whose config/ lacks SERIALIZED_INDENT_WIDTH
95
+ detached_gate_dir/serialize_tool.py (no repo ancestor) imports it
96
+ flag (defect): binds config to session_worktree -> attr-defined denial
97
+ ok (fixed): isolated cwd -> import ignored -> passes
98
+ """
99
+ session_worktree = tmp_path / "session_worktree"
100
+ _write_config_package(session_worktree, "UNRELATED_SESSION_SETTING: int = 9\n")
101
+
102
+ detached_file = tmp_path / "detached_gate_dir" / "serialize_tool.py"
103
+ _write_config_importer(detached_file)
104
+
105
+ monkeypatch.chdir(session_worktree)
106
+ mypy_result = run_mypy_check([detached_file])
107
+
108
+ assert mypy_result.passed, mypy_result.output
109
+
110
+
111
+ def test_run_mypy_check_still_flags_genuine_type_error(
112
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
113
+ ) -> None:
114
+ """A real type error inside the target file is still reported.
115
+
116
+ ::
117
+
118
+ typed_repo/pkg/widths.py -> wrong_width: int = 'not an integer'
119
+ flag: assigning str to an int annotation -> mypy error, passed is False
120
+ """
121
+ typed_repo = tmp_path / "typed_repo"
122
+ package_directory = typed_repo / "pkg"
123
+ package_directory.mkdir(parents=True)
124
+ (typed_repo / "pyproject.toml").write_text(
125
+ "[project]\nname = 'typed-target'\n", encoding="utf-8"
126
+ )
127
+ faulty_script = package_directory / "widths.py"
128
+ faulty_script.write_text("wrong_width: int = 'not an integer'\n", encoding="utf-8")
129
+
130
+ monkeypatch.chdir(tmp_path)
131
+ mypy_result = run_mypy_check([faulty_script])
132
+
133
+ assert not mypy_result.passed
134
+ assert mypy_result.error_count >= 1
135
+
136
+
137
+ def test_run_mypy_check_accepts_relative_path_under_nested_root(
138
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
139
+ ) -> None:
140
+ """A repo-relative target under a nested project root is still readable.
141
+
142
+ ::
143
+
144
+ cwd=repo_root; changed file listed as repo-relative pkg/tool.py
145
+ pkg/ holds its own pyproject.toml -> resolution root is repo_root/pkg
146
+ flag (defect): mypy runs from repo_root/pkg with the repo-relative arg
147
+ -> "can't read file", spurious failure
148
+ ok (fixed): arg resolved to an absolute path -> mypy reads it
149
+ """
150
+ repo_root = tmp_path / "repo_root"
151
+ nested_package = repo_root / "pkg"
152
+ nested_package.mkdir(parents=True)
153
+ (repo_root / ".git").mkdir()
154
+ (nested_package / "pyproject.toml").write_text(
155
+ "[project]\nname = 'nested'\n", encoding="utf-8"
156
+ )
157
+ (nested_package / "module.py").write_text(
158
+ "sample_number: int = 1\n", encoding="utf-8"
159
+ )
160
+
161
+ monkeypatch.chdir(repo_root)
162
+ relative_target = Path("pkg") / "module.py"
163
+ mypy_result = run_mypy_check([relative_target])
164
+
165
+ assert mypy_result.passed, mypy_result.output
166
+
167
+
14
168
  def test_mypy_result_dataclass() -> None:
15
169
  """Test MypyResult dataclass creation."""
16
170
  result = MypyResult(passed=True, output="test", error_count=0)
@@ -1,6 +1,8 @@
1
1
  """Tests for Python anti-pattern detection."""
2
2
 
3
3
  import ast
4
+ import sys
5
+ from pathlib import Path
4
6
 
5
7
  import pytest
6
8
 
@@ -8,8 +10,17 @@ from .python_antipattern_checks import (
8
10
  check_mutable_default_args,
9
11
  check_bare_except,
10
12
  check_print_in_production,
13
+ validate_file,
11
14
  )
12
- from .validator_base import Violation
15
+ from .run_all_validators import ValidatorResult, validate_proposed_file
16
+
17
+ _HOOKS_DIRECTORY = str(Path(__file__).resolve().parent.parent)
18
+ _BLOCKING_DIRECTORY = str(Path(__file__).resolve().parent.parent / "blocking")
19
+ for each_candidate_directory in (_HOOKS_DIRECTORY, _BLOCKING_DIRECTORY):
20
+ if each_candidate_directory not in sys.path:
21
+ sys.path.insert(0, each_candidate_directory)
22
+
23
+ from code_rules_imports_logging import check_library_print # noqa: E402
13
24
 
14
25
 
15
26
  GOOD_NONE_DEFAULT = '''
@@ -111,3 +122,103 @@ class TestPrintInProduction:
111
122
  tree = ast.parse(TEST_FILE_WITH_PRINT)
112
123
  violations = check_print_in_production(tree, "test_utils.py")
113
124
  assert violations == []
125
+
126
+
127
+ CLI_ENTRY_POINT_WITH_PRINTS = '''
128
+ def main():
129
+ print("line one")
130
+ print("line two")
131
+ print("line three")
132
+ print("line four")
133
+ print("line five")
134
+ '''
135
+
136
+ NEAR_MISS_WITH_PRINT = '''
137
+ def render():
138
+ print("output")
139
+ '''
140
+
141
+
142
+ class TestPrintInCliEntryPoint:
143
+ def test_cli_py_path_allows_print(self) -> None:
144
+ tree = ast.parse(CLI_ENTRY_POINT_WITH_PRINTS)
145
+ assert check_print_in_production(tree, "stp_preview/cli.py") == []
146
+
147
+ def test_cli_suffix_path_allows_print(self) -> None:
148
+ tree = ast.parse(CLI_ENTRY_POINT_WITH_PRINTS)
149
+ assert check_print_in_production(tree, "stp_preview/report_cli.py") == []
150
+
151
+ def test_scripts_path_allows_print(self) -> None:
152
+ tree = ast.parse(CLI_ENTRY_POINT_WITH_PRINTS)
153
+ assert check_print_in_production(tree, "packages/foo/scripts/run_job.py") == []
154
+
155
+ def test_near_miss_name_still_flagged(self) -> None:
156
+ tree = ast.parse(NEAR_MISS_WITH_PRINT)
157
+ assert len(check_print_in_production(tree, "publicli.py")) == 1
158
+
159
+ def test_non_cli_production_file_still_flagged(self) -> None:
160
+ tree = ast.parse(NEAR_MISS_WITH_PRINT)
161
+ assert len(check_print_in_production(tree, "stp_preview/renderer.py")) == 1
162
+
163
+
164
+ class TestValidateFile:
165
+ def test_validate_file_flags_bare_except(self, tmp_path: Path) -> None:
166
+ source_file = tmp_path / "worker.py"
167
+ source_file.write_text(
168
+ "def run():\n try:\n do_work()\n except:\n pass\n",
169
+ encoding="utf-8",
170
+ )
171
+ violations = validate_file(source_file)
172
+ assert len(violations) == 1
173
+ assert "except" in violations[0].message.lower()
174
+
175
+
176
+ class TestCrossSurfaceConsistency:
177
+ def test_matches_library_print_surface(self) -> None:
178
+ source = "def main():\n print('x')\n"
179
+ tree = ast.parse(source)
180
+ for path in (
181
+ "stp_preview/cli.py",
182
+ "stp_preview/renderer.py",
183
+ "publicli.py",
184
+ "packages/foo/scripts/run_job.py",
185
+ ):
186
+ production_allows = check_print_in_production(tree, path) == []
187
+ library_allows = check_library_print(source, path) == []
188
+ assert production_allows == library_allows
189
+
190
+
191
+ CLI_BASENAME = "cli.py"
192
+ ANTIPATTERN_VALIDATOR_NAME = "Python Anti-patterns"
193
+ PRINT_FINDING_FRAGMENT = "print() in production"
194
+
195
+ PIPELINE_PRINT_SOURCE = '''
196
+ def emit_status(message):
197
+ print(message)
198
+ '''
199
+
200
+
201
+ def _antipattern_result_for(file_path: str, content: str) -> ValidatorResult:
202
+ """Return the Python Anti-patterns result for content proposed at file_path."""
203
+ all_results = validate_proposed_file(file_path, content)
204
+ return next(
205
+ each_result
206
+ for each_result in all_results
207
+ if each_result.name == ANTIPATTERN_VALIDATOR_NAME
208
+ )
209
+
210
+
211
+ class TestPrintCliExemptionThroughPipeline:
212
+ """Pin the CLI-marker print exemption through the validate_proposed_file pipeline.
213
+
214
+ A basename of cli.py lands in the system temp dir as .../cli.py, so the
215
+ marker /cli.py matches and the print check stays silent. Near-miss basenames
216
+ (publicli.py) are covered at the unit surface; the pipeline stages under a
217
+ temp root that often contains the substring 'test', which short-circuits the
218
+ print check before the CLI-marker path runs.
219
+ """
220
+
221
+ def test_pins_cli_basename_allows_print_through_pipeline(self) -> None:
222
+ antipattern_result = _antipattern_result_for(CLI_BASENAME, PIPELINE_PRINT_SOURCE)
223
+ assert antipattern_result.passed
224
+ assert PRINT_FINDING_FRAGMENT not in antipattern_result.output
@@ -44,6 +44,22 @@ class TestPreToolUseGate:
44
44
  assert '"permissionDecision": "deny"' in completed.stdout
45
45
  assert "Magic Values" in completed.stdout
46
46
 
47
+ def test_write_violating_content_to_ephemeral_scratch_path_is_allowed(
48
+ self, monkeypatch: pytest.MonkeyPatch
49
+ ) -> None:
50
+ monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
51
+ completed = run_gate(
52
+ {
53
+ "tool_name": "Write",
54
+ "tool_input": {
55
+ "file_path": "/tmp/scratch_calculate.py",
56
+ "content": VIOLATING_PYTHON_SOURCE,
57
+ },
58
+ }
59
+ )
60
+ assert completed.returncode == 0, completed.stderr
61
+ assert "deny" not in completed.stdout
62
+
47
63
  def test_write_with_clean_content_allows(self) -> None:
48
64
  completed = run_gate(
49
65
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "2.0.1",
3
+ "version": "2.1.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
package/rules/CLAUDE.md CHANGED
@@ -5,7 +5,7 @@ paths:
5
5
 
6
6
  # rules
7
7
 
8
- Rule files installed into `~/.claude/rules/` by `bin/install.mjs`. Claude Code loads these as always-on behavioral constraints for every session. Each `.md` file covers one named rule; hook-enforced rules are also backed by a Python hook in `hooks/`.
8
+ Rule files installed into `~/.claude/rules/` by `bin/install.mjs`. A rule without `paths:` frontmatter loads at the start of every session; a rule with `paths:` frontmatter loads only when the session works with a file its globs match. The `InstructionsLoaded` log records that match as a `path_glob_match` event. Each `.md` file covers one named rule; hook-enforced rules are also backed by a Python hook in `hooks/`.
9
9
 
10
10
  ## Files
11
11
 
@@ -37,7 +37,6 @@ Rule files installed into `~/.claude/rules/` by `bin/install.mjs`. Claude Code l
37
37
  | `no-inline-destructive-literals.md` | No destructive-command literals in Bash tool command strings, even as data |
38
38
  | `no-justification-noise.md` | Markdown states facts a reader can act on; cut a present-tense sentence that only justifies a stated choice or restates a gain the reader already works out from the behavior or from a rule enforced elsewhere |
39
39
  | `env-var-table-code-drift.md` | Every env-var summary table row in a `.md` file names a code file whose source references the variable |
40
- | `es-exe-file-search.md` | File search on Windows routes through the `es.exe` CLI with a scoped query; the Everything HTTP server stays off |
41
40
  | `orphan-css-class.md` | Every `class="..."` attribute in Python-generated markup has a matching selector in the `<style>` block |
42
41
  | `package-inventory-stale-entry.md` | A new production code file added to a directory carries an entry in that directory's `README.md`/`CLAUDE.md` file inventory |
43
42
  | `paired-test-coverage.md` | A public function omitted by a module's established paired test suite must get a behavioral test |
@@ -50,10 +49,11 @@ Rule files installed into `~/.claude/rules/` by `bin/install.mjs`. Claude Code l
50
49
  | `research-mode.md` | Three anti-hallucination constraints: say "I don't know", verify with citations, quote for factual grounding |
51
50
  | `right-sized-engineering.md` | Simple over clever; functions over classes; concrete over abstract |
52
51
  | `self-contained-docs.md` | Every document is fully self-contained; no references to the conversation that produced it |
53
- | `shell-invocation-policy.md` | All Windows shell commands use `pwsh`; `powershell.exe`, `cmd /c`, and `bash -c` are blocked |
52
+ | `shell-invocation-policy.md` | All Windows shell commands use `pwsh`; `Audit-ShellPolicy.ps1` reports the non-`pwsh` forms in the `settings.json` permission rules and `Migrate-ShellPolicy.ps1` rewrites them to `pwsh`, both run on demand, not as a live gate |
54
53
  | `tdd.md` | Test-driven development: red → green → refactor, no production code before a failing test |
55
54
  | `testing.md` | Test quality and infrastructure standards |
56
55
  | `vault-context.md` | Search Obsidian vault for prior sessions and decisions before substantive project work |
56
+ | `verified-commit-gate-skip.md` | The `# verify-skip` marker on a blocked commit/push is allowed only when the branch surface is the same code a code-verifier already passed clean; any real change since that verdict runs a fresh verification |
57
57
  | `verify-before-asking.md` | Answer questions by inspecting files or running tools before asking; recalled facts expire until re-checked this session |
58
58
  | `verify-runtime-state.md` | A "component is fine / not at fault" verdict rests on a live probe this session, never code reading or prior-session memory |
59
59
  | `windows-filesystem-safe.md` | Use safe `rmtree` patterns on Windows; `mkdirSync` with `recursive: true` on possibly-existing paths |