claude-dev-env 1.89.0 → 1.92.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 (77) hide show
  1. package/CLAUDE.md +0 -13
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +106 -14
  3. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +26 -0
  4. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +160 -0
  5. package/agents/clean-coder.md +2 -2
  6. package/agents/code-verifier.md +0 -1
  7. package/agents/test_agent_frontmatter.py +78 -0
  8. package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +1 -1
  9. package/audit-rubrics/prompts/category-j-code-rules-compliance.md +1 -1
  10. package/bin/install.mjs +1 -0
  11. package/docs/CODE_RULES.md +2 -2
  12. package/hooks/blocking/CLAUDE.md +6 -2
  13. package/hooks/blocking/code_rules_comments.py +2 -2
  14. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +44 -0
  15. package/hooks/blocking/config/verified_commit_constants.py +2 -0
  16. package/hooks/blocking/conftest.py +115 -0
  17. package/hooks/blocking/nas_ssh_binary_enforcer.py +191 -0
  18. package/hooks/blocking/pr_description_enforcer.py +46 -22
  19. package/hooks/blocking/pr_description_pr_number.py +5 -3
  20. package/hooks/blocking/pr_description_proof_of_work.py +367 -0
  21. package/hooks/blocking/precommit_code_rules_gate.py +5 -1
  22. package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +8 -2
  23. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +71 -0
  24. package/hooks/blocking/test_nas_ssh_binary_enforcer.py +168 -0
  25. package/hooks/blocking/test_pr_description_enforcer_proof_gate.py +175 -0
  26. package/hooks/blocking/test_pr_description_proof_of_work.py +162 -0
  27. package/hooks/blocking/test_precommit_code_rules_gate.py +89 -0
  28. package/hooks/blocking/test_verdict_directory_write_blocker.py +4 -0
  29. package/hooks/blocking/test_verification_verdict_store.py +11 -0
  30. package/hooks/blocking/test_verified_commit_config_bootstrap.py +49 -0
  31. package/hooks/blocking/test_verified_commit_gate.py +11 -0
  32. package/hooks/blocking/test_verifier_verdict_minter.py +11 -0
  33. package/hooks/blocking/test_volatile_path_in_post_blocker.py +210 -0
  34. package/hooks/blocking/verdict_directory_write_blocker.py +6 -0
  35. package/hooks/blocking/verification_verdict_store.py +73 -5
  36. package/hooks/blocking/verified_commit_config_bootstrap.py +51 -0
  37. package/hooks/blocking/verified_commit_gate.py +6 -0
  38. package/hooks/blocking/verifier_verdict_minter.py +6 -0
  39. package/hooks/blocking/volatile_path_in_post_blocker.py +351 -0
  40. package/hooks/hooks.json +32 -2
  41. package/hooks/hooks_constants/CLAUDE.md +4 -0
  42. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
  43. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +3 -0
  44. package/hooks/hooks_constants/enter_worktree_prefetch_constants.py +18 -0
  45. package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +59 -0
  46. package/hooks/hooks_constants/pr_description_enforcer_constants.py +2 -0
  47. package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +111 -0
  48. package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +48 -0
  49. package/hooks/lifecycle/CLAUDE.md +3 -1
  50. package/hooks/lifecycle/enter_worktree_origin_prefetch.py +146 -0
  51. package/hooks/lifecycle/test_enter_worktree_origin_prefetch.py +178 -0
  52. package/hooks/validators/run_all_validators.py +216 -4
  53. package/hooks/validators/test_run_all_validators_pretooluse.py +102 -0
  54. package/package.json +1 -1
  55. package/rules/CLAUDE.md +3 -0
  56. package/rules/durable-post-artifacts.md +35 -0
  57. package/rules/nas-ssh-invocation.md +21 -0
  58. package/rules/proof-of-work-pr-comments.md +26 -0
  59. package/scripts/CLAUDE.md +2 -0
  60. package/scripts/claude-chain.example.json +8 -0
  61. package/scripts/claude_chain_runner.py +400 -0
  62. package/scripts/dev_env_scripts_constants/CLAUDE.md +2 -0
  63. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +124 -0
  64. package/scripts/dev_env_scripts_constants/gh_artifact_upload_constants.py +43 -0
  65. package/scripts/gh_artifact_upload.py +256 -0
  66. package/scripts/sync_to_cursor/rules.py +1 -1
  67. package/scripts/test_claude_chain_runner.py +472 -0
  68. package/scripts/tests/test_gh_artifact_upload.py +205 -0
  69. package/skills/CLAUDE.md +3 -0
  70. package/skills/team-advisor/SKILL.md +188 -0
  71. package/skills/team-advisor-refresh/SKILL.md +25 -0
  72. package/skills/usage-pause/SKILL.md +108 -0
  73. package/skills/usage-pause/scripts/resolve_usage_window.py +462 -0
  74. package/skills/usage-pause/scripts/test_resolve_usage_window.py +278 -0
  75. package/skills/usage-pause/scripts/usage_pause_constants/__init__.py +1 -0
  76. package/skills/usage-pause/scripts/usage_pause_constants/resolve_usage_window_constants.py +65 -0
  77. package/system-prompts/software-engineer.xml +3 -2
@@ -0,0 +1,48 @@
1
+ """Configuration constants for the volatile_path_in_post_blocker PreToolUse hook."""
2
+
3
+ BASH_TOOL_NAME: str = "Bash"
4
+
5
+ MCP_GITHUB_TOOL_PREFIX: str = "mcp__plugin_github_github__"
6
+
7
+ ALL_MCP_BODY_PARAM_NAMES: tuple[str, ...] = ("body", "comment")
8
+
9
+ GH_COMMAND_NAME: str = "gh"
10
+
11
+ MINIMUM_POST_SUBCOMMAND_TOKEN_COUNT: int = 2
12
+
13
+ TOKEN_JOIN_SEPARATOR: str = " "
14
+
15
+ BODY_FILE_ENCODING: str = "utf-8"
16
+
17
+ ALL_GH_POST_SUBCOMMANDS: dict[str, frozenset[str]] = {
18
+ "pr": frozenset({"create", "comment", "edit", "review"}),
19
+ "issue": frozenset({"create", "comment", "edit"}),
20
+ }
21
+
22
+ ALL_VOLATILE_PATH_MARKERS: tuple[str, ...] = (
23
+ ".claude-editor/jobs/",
24
+ ".claude/worktrees/",
25
+ "appdata/local/temp",
26
+ "/tmp/",
27
+ "%temp%",
28
+ "$env:temp",
29
+ "$claude_job_dir",
30
+ )
31
+
32
+ GH_ARTIFACT_UPLOAD_INVOCATION: str = (
33
+ "python3 ~/.claude/scripts/gh_artifact_upload.py <file-path> <owner/repo>"
34
+ )
35
+
36
+ CORRECTIVE_MESSAGE: str = (
37
+ "BLOCKED [durable-post-artifacts]: this post body references a volatile path "
38
+ "(a job scratch dir, worktree, or system temp location). The post is durable "
39
+ "and outlives that scratch, so the reference breaks the moment the directory "
40
+ "is cleaned.\n\n"
41
+ "Fix it before posting:\n"
42
+ " 1. Text data (logs, tables, diffs): paste the actual content inline in the "
43
+ "post instead of linking a scratch file path.\n"
44
+ " 2. Binary artifacts (images, archives): upload the file to the repo's "
45
+ "durable 'artifacts' release and link the permanent asset URL it prints:\n"
46
+ f" {GH_ARTIFACT_UPLOAD_INVOCATION}\n\n"
47
+ "See ~/.claude/rules/durable-post-artifacts.md for the full contract."
48
+ )
@@ -7,12 +7,14 @@ Hooks that run at session or config-change boundaries rather than on individual
7
7
  | File | Event | What it does |
8
8
  |---|---|---|
9
9
  | `config_change_guard.py` | PostToolUse (Write/Edit on `settings.json`) | Counts hooks in the edited `settings.json` and logs any change to `~/.claude/cache/config-change-audit.log`; alerts when the hook count drops below the last known value |
10
+ | `enter_worktree_origin_prefetch.py` | PreToolUse (EnterWorktree) | Fetches origin's default branch before a worktree creation call, keeping the `refs/remotes/origin/<default-branch>` ref that `fresh` mode reads current; never blocks on fetch failure |
10
11
  | `pr_converge_bugteam_skill_tracker.py` | PreToolUse (Skill) | Tracks which bugteam skill runs have completed within a pr-converge loop, so the enforcer can verify parallel execution |
11
12
  | `session_end_cleanup.py` | SessionEnd | Purges stale cache entries from `~/.claude/cache/` (entries older than the configured threshold) and old backup files |
12
13
  | `test_config_change_guard.py` | — | Tests for `config_change_guard.py` |
14
+ | `test_enter_worktree_origin_prefetch.py` | — | Tests for `enter_worktree_origin_prefetch.py` |
13
15
  | `test_pr_converge_bugteam_skill_tracker.py` | — | Tests for `pr_converge_bugteam_skill_tracker.py` |
14
16
 
15
17
  ## Conventions
16
18
 
17
- - Constants for these hooks (stale-age threshold, cache directory, known-hook-count file) live in `hooks_constants/session_env_cleanup_constants.py` and `hooks_constants/pr_converge_bugteam_enforcer_constants.py`.
19
+ - Constants for these hooks (stale-age threshold, cache directory, known-hook-count file, EnterWorktree prefetch tuning) live in `hooks_constants/session_env_cleanup_constants.py`, `hooks_constants/pr_converge_bugteam_enforcer_constants.py`, and `hooks_constants/enter_worktree_prefetch_constants.py`.
18
20
  - Tests run with `python -m pytest lifecycle/test_<name>.py`.
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook: fetch origin's default branch before EnterWorktree creates one.
3
+
4
+ ``EnterWorktree`` in its default ``fresh`` mode bases a new worktree on the
5
+ locally cached ``refs/remotes/origin/<default-branch>`` ref and only runs
6
+ ``git fetch`` when that ref is missing entirely -- so a worktree created
7
+ without a recent fetch silently starts behind the remote. This hook fetches
8
+ that ref immediately before the tool resolves its base, so the ref it reads
9
+ is current.
10
+
11
+ ::
12
+
13
+ EnterWorktree({}) # tool_input has no "path"
14
+ |
15
+ v
16
+ this hook: git fetch origin <default-branch> # best-effort, never blocks
17
+ |
18
+ v
19
+ EnterWorktree resolves refs/remotes/origin/<default-branch> # now fresh
20
+
21
+ The hook is a no-op when ``tool_input`` carries a ``path`` (switching into an
22
+ already-existing worktree needs no fresh base) and always exits 0 -- a failed
23
+ or slow fetch never blocks worktree creation.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import subprocess
30
+ import sys
31
+ from pathlib import Path
32
+
33
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
34
+ if _hooks_dir not in sys.path:
35
+ sys.path.insert(0, _hooks_dir)
36
+
37
+ from hooks_constants.enter_worktree_prefetch_constants import ( # noqa: E402
38
+ ENTER_WORKTREE_PATH_INPUT_KEY,
39
+ ENTER_WORKTREE_TOOL_NAME,
40
+ GIT_FETCH_TIMEOUT_SECONDS,
41
+ GIT_SYMBOLIC_REF_TIMEOUT_SECONDS,
42
+ ORIGIN_HEAD_SYMBOLIC_REF,
43
+ ORIGIN_REMOTE_NAME,
44
+ ORIGIN_REMOTE_REF_PREFIX,
45
+ )
46
+
47
+
48
+ def is_enter_worktree_creation(payload_by_field: dict[str, object]) -> bool:
49
+ """Return True when this hook invocation is an EnterWorktree creation call.
50
+
51
+ Args:
52
+ payload_by_field: The full PreToolUse hook payload (already JSON-parsed),
53
+ keyed by top-level field name.
54
+
55
+ Returns:
56
+ True when ``tool_name == "EnterWorktree"`` and ``tool_input`` carries
57
+ no ``path`` key. A ``path`` value means the call switches into an
58
+ already-existing worktree rather than creating one, so it is False
59
+ for that case and for every other tool.
60
+ """
61
+ if payload_by_field.get("tool_name", "") != ENTER_WORKTREE_TOOL_NAME:
62
+ return False
63
+ tool_input = payload_by_field.get("tool_input", {})
64
+ if not isinstance(tool_input, dict):
65
+ return True
66
+ return not tool_input.get(ENTER_WORKTREE_PATH_INPUT_KEY)
67
+
68
+
69
+ def resolve_origin_default_branch(repo_directory: str) -> str | None:
70
+ """Return the branch name origin's HEAD points at, or None if unresolved.
71
+
72
+ Args:
73
+ repo_directory: Working directory to run ``git`` in.
74
+
75
+ Returns:
76
+ The branch name (for example ``"main"``) parsed from
77
+ ``refs/remotes/origin/HEAD``, or None when the symbolic ref is
78
+ unset, git is unavailable, or the command times out.
79
+ """
80
+ try:
81
+ completed_process = subprocess.run(
82
+ ["git", "symbolic-ref", ORIGIN_HEAD_SYMBOLIC_REF],
83
+ capture_output=True,
84
+ text=True,
85
+ timeout=GIT_SYMBOLIC_REF_TIMEOUT_SECONDS,
86
+ cwd=repo_directory,
87
+ check=False,
88
+ )
89
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
90
+ return None
91
+ if completed_process.returncode != 0:
92
+ return None
93
+ resolved_ref = completed_process.stdout.strip()
94
+ if not resolved_ref.startswith(ORIGIN_REMOTE_REF_PREFIX):
95
+ return None
96
+ return resolved_ref[len(ORIGIN_REMOTE_REF_PREFIX):]
97
+
98
+
99
+ def fetch_origin_branch(repo_directory: str, branch_name: str) -> None:
100
+ """Best-effort ``git fetch origin <branch_name>``; never raises.
101
+
102
+ Args:
103
+ repo_directory: Working directory to run ``git`` in.
104
+ branch_name: Branch to fetch from the ``origin`` remote.
105
+ """
106
+ try:
107
+ subprocess.run(
108
+ ["git", "fetch", ORIGIN_REMOTE_NAME, branch_name],
109
+ capture_output=True,
110
+ text=True,
111
+ timeout=GIT_FETCH_TIMEOUT_SECONDS,
112
+ cwd=repo_directory,
113
+ check=False,
114
+ )
115
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
116
+ return
117
+
118
+
119
+ def main() -> None:
120
+ """Entry point for the PreToolUse:EnterWorktree hook.
121
+
122
+ Reads the PreToolUse payload from stdin, and when it is an EnterWorktree
123
+ creation call, fetches origin's default branch in the session's ``cwd``
124
+ before returning. Always exits 0: a fetch failure self-heals on the next
125
+ fetch rather than blocking worktree creation.
126
+ """
127
+ try:
128
+ hook_payload = json.load(sys.stdin)
129
+ except json.JSONDecodeError:
130
+ sys.exit(0)
131
+ if not isinstance(hook_payload, dict):
132
+ sys.exit(0)
133
+ if not is_enter_worktree_creation(hook_payload):
134
+ sys.exit(0)
135
+ repo_directory = hook_payload.get("cwd")
136
+ if not isinstance(repo_directory, str) or not repo_directory:
137
+ sys.exit(0)
138
+ default_branch = resolve_origin_default_branch(repo_directory)
139
+ if default_branch is None:
140
+ sys.exit(0)
141
+ fetch_origin_branch(repo_directory, default_branch)
142
+ sys.exit(0)
143
+
144
+
145
+ if __name__ == "__main__":
146
+ main()
@@ -0,0 +1,178 @@
1
+ """Unit tests for the enter_worktree_origin_prefetch PreToolUse hook.
2
+
3
+ Uses real git repositories (a "remote" repo cloned into a "local" one) so the
4
+ fetch behavior is exercised against actual git plumbing, not mocked calls.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import importlib.util
10
+ import io
11
+ import json
12
+ import pathlib
13
+ import subprocess
14
+ import sys
15
+
16
+ import pytest
17
+
18
+ _HOOK_DIR = pathlib.Path(__file__).parent
19
+ _HOOKS_TREE = _HOOK_DIR.parent
20
+ for each_path in (str(_HOOK_DIR), str(_HOOKS_TREE)):
21
+ if each_path not in sys.path:
22
+ sys.path.insert(0, each_path)
23
+
24
+ hook_spec = importlib.util.spec_from_file_location(
25
+ "enter_worktree_origin_prefetch",
26
+ _HOOK_DIR / "enter_worktree_origin_prefetch.py",
27
+ )
28
+ assert hook_spec is not None
29
+ assert hook_spec.loader is not None
30
+ hook_module = importlib.util.module_from_spec(hook_spec)
31
+ hook_spec.loader.exec_module(hook_module)
32
+
33
+
34
+ def _run_git(repo_directory: pathlib.Path, *args: str) -> subprocess.CompletedProcess[str]:
35
+ return subprocess.run(
36
+ ["git", *args],
37
+ cwd=repo_directory,
38
+ capture_output=True,
39
+ text=True,
40
+ check=True,
41
+ )
42
+
43
+
44
+ def _init_repo_with_commit(repo_directory: pathlib.Path) -> None:
45
+ repo_directory.mkdir(parents=True, exist_ok=True)
46
+ _run_git(repo_directory, "init", "--quiet", "--initial-branch=main")
47
+ _run_git(repo_directory, "config", "user.email", "test@example.com")
48
+ _run_git(repo_directory, "config", "user.name", "Test")
49
+ _run_git(repo_directory, "commit", "--allow-empty", "--quiet", "-m", "init")
50
+
51
+
52
+ def _clone_as_local(remote_directory: pathlib.Path, local_directory: pathlib.Path) -> None:
53
+ subprocess.run(
54
+ ["git", "clone", "--quiet", str(remote_directory), str(local_directory)],
55
+ capture_output=True,
56
+ text=True,
57
+ check=True,
58
+ )
59
+ _run_git(local_directory, "config", "user.email", "test@example.com")
60
+ _run_git(local_directory, "config", "user.name", "Test")
61
+
62
+
63
+ def test_is_enter_worktree_creation_true_when_no_path_given() -> None:
64
+ payload = {"tool_name": "EnterWorktree", "tool_input": {}}
65
+ assert hook_module.is_enter_worktree_creation(payload) is True
66
+
67
+
68
+ def test_is_enter_worktree_creation_true_with_name_only() -> None:
69
+ payload = {"tool_name": "EnterWorktree", "tool_input": {"name": "my-branch"}}
70
+ assert hook_module.is_enter_worktree_creation(payload) is True
71
+
72
+
73
+ def test_is_enter_worktree_creation_false_when_path_given() -> None:
74
+ payload = {"tool_name": "EnterWorktree", "tool_input": {"path": "/some/worktree"}}
75
+ assert hook_module.is_enter_worktree_creation(payload) is False
76
+
77
+
78
+ def test_is_enter_worktree_creation_false_for_other_tool() -> None:
79
+ payload = {"tool_name": "Bash", "tool_input": {}}
80
+ assert hook_module.is_enter_worktree_creation(payload) is False
81
+
82
+
83
+ def test_resolve_origin_default_branch_reads_cloned_head(tmp_path: pathlib.Path) -> None:
84
+ remote_directory = tmp_path / "remote"
85
+ local_directory = tmp_path / "local"
86
+ _init_repo_with_commit(remote_directory)
87
+ _clone_as_local(remote_directory, local_directory)
88
+
89
+ resolved_branch = hook_module.resolve_origin_default_branch(str(local_directory))
90
+
91
+ assert resolved_branch == "main"
92
+
93
+
94
+ def test_resolve_origin_default_branch_returns_none_without_origin(
95
+ tmp_path: pathlib.Path,
96
+ ) -> None:
97
+ solo_directory = tmp_path / "solo"
98
+ _init_repo_with_commit(solo_directory)
99
+
100
+ resolved_branch = hook_module.resolve_origin_default_branch(str(solo_directory))
101
+
102
+ assert resolved_branch is None
103
+
104
+
105
+ def test_fetch_origin_branch_updates_stale_local_ref(tmp_path: pathlib.Path) -> None:
106
+ remote_directory = tmp_path / "remote"
107
+ local_directory = tmp_path / "local"
108
+ _init_repo_with_commit(remote_directory)
109
+ _clone_as_local(remote_directory, local_directory)
110
+
111
+ _run_git(remote_directory, "commit", "--allow-empty", "--quiet", "-m", "second")
112
+ remote_head_sha = _run_git(remote_directory, "rev-parse", "HEAD").stdout.strip()
113
+ local_cached_sha_before = _run_git(
114
+ local_directory, "rev-parse", "refs/remotes/origin/main"
115
+ ).stdout.strip()
116
+ assert local_cached_sha_before != remote_head_sha
117
+
118
+ hook_module.fetch_origin_branch(str(local_directory), "main")
119
+
120
+ local_cached_sha_after = _run_git(
121
+ local_directory, "rev-parse", "refs/remotes/origin/main"
122
+ ).stdout.strip()
123
+ assert local_cached_sha_after == remote_head_sha
124
+
125
+
126
+ def test_fetch_origin_branch_never_raises_when_remote_missing(tmp_path: pathlib.Path) -> None:
127
+ solo_directory = tmp_path / "solo"
128
+ _init_repo_with_commit(solo_directory)
129
+
130
+ hook_module.fetch_origin_branch(str(solo_directory), "main")
131
+
132
+
133
+ def test_main_fetches_stale_ref_and_exits_zero(
134
+ tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
135
+ ) -> None:
136
+ remote_directory = tmp_path / "remote"
137
+ local_directory = tmp_path / "local"
138
+ _init_repo_with_commit(remote_directory)
139
+ _clone_as_local(remote_directory, local_directory)
140
+ _run_git(remote_directory, "commit", "--allow-empty", "--quiet", "-m", "second")
141
+ remote_head_sha = _run_git(remote_directory, "rev-parse", "HEAD").stdout.strip()
142
+
143
+ payload = {
144
+ "tool_name": "EnterWorktree",
145
+ "tool_input": {},
146
+ "cwd": str(local_directory),
147
+ }
148
+ monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
149
+
150
+ with pytest.raises(SystemExit) as exit_info:
151
+ hook_module.main()
152
+
153
+ assert exit_info.value.code == 0
154
+ local_cached_sha_after = _run_git(
155
+ local_directory, "rev-parse", "refs/remotes/origin/main"
156
+ ).stdout.strip()
157
+ assert local_cached_sha_after == remote_head_sha
158
+
159
+
160
+ def test_main_exits_zero_for_non_enter_worktree_tool(
161
+ monkeypatch: pytest.MonkeyPatch,
162
+ ) -> None:
163
+ payload = {"tool_name": "Bash", "tool_input": {"command": "ls"}, "cwd": "/tmp"}
164
+ monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
165
+
166
+ with pytest.raises(SystemExit) as exit_info:
167
+ hook_module.main()
168
+
169
+ assert exit_info.value.code == 0
170
+
171
+
172
+ def test_main_exits_zero_on_malformed_json(monkeypatch: pytest.MonkeyPatch) -> None:
173
+ monkeypatch.setattr(sys, "stdin", io.StringIO("not json"))
174
+
175
+ with pytest.raises(SystemExit) as exit_info:
176
+ hook_module.main()
177
+
178
+ assert exit_info.value.code == 0
@@ -6,6 +6,7 @@ Exit code 0 = all checks pass, 1 = violations found.
6
6
  # pragma: no-tdd-gate
7
7
 
8
8
  import argparse
9
+ import json
9
10
  import os
10
11
  import subprocess
11
12
  import sys
@@ -27,6 +28,15 @@ VALIDATORS_DIR = Path(__file__).parent
27
28
  hooks_dir = VALIDATORS_DIR.parent
28
29
  package_name = VALIDATORS_DIR.name
29
30
 
31
+ _hooks_directory_on_path = str(hooks_dir.resolve())
32
+ if _hooks_directory_on_path not in sys.path:
33
+ sys.path.insert(0, _hooks_directory_on_path)
34
+
35
+ from hooks_constants.multi_edit_reconstruction import ( # noqa: E402
36
+ apply_edits,
37
+ edits_for_tool,
38
+ )
39
+
30
40
 
31
41
  def _windows_non_unc_working_directory_string(
32
42
  candidate_directory_strings: list[str | None],
@@ -99,8 +109,19 @@ def invoke_validator_module(module_stem: str, forwarded_file_paths: List[str]) -
99
109
 
100
110
  def run_validators_entrypoint_subprocess(
101
111
  extra_arguments: List[str],
112
+ stdin_text: Optional[str] = None,
102
113
  ) -> subprocess.CompletedProcess[str]:
103
- """Run ``python -m validators.run_all_validators`` with a Windows-safe cwd."""
114
+ """Run ``python -m validators.run_all_validators`` with a Windows-safe cwd.
115
+
116
+ Args:
117
+ extra_arguments: Argument vector appended after the module name.
118
+ stdin_text: Text replayed as the subprocess stdin, or None to leave
119
+ stdin empty. The PreToolUse gate mode reads its payload from stdin,
120
+ so a caller exercising ``--pre-tool-use`` passes the payload here.
121
+
122
+ Returns:
123
+ The completed subprocess carrying its captured stdout and stderr.
124
+ """
104
125
  working_directory_string, environment = (
105
126
  _hooks_subprocess_working_directory_and_environment()
106
127
  )
@@ -111,6 +132,7 @@ def run_validators_entrypoint_subprocess(
111
132
  text=True,
112
133
  cwd=working_directory_string,
113
134
  env=environment,
135
+ input=stdin_text,
114
136
  )
115
137
 
116
138
 
@@ -375,9 +397,9 @@ def run_comment_checks(files: List[Path]) -> ValidatorResult:
375
397
  """Comment preservation is enforced by code_rules_enforcer hook.
376
398
 
377
399
  The hook compares old vs new content to block NEW comments and
378
- block DELETION of existing comments. This standalone validator
379
- is disabled because it flags ALL comments in existing files,
380
- which forces agents to remove them to pass validation.
400
+ print a stderr advisory when an existing comment is removed. This
401
+ standalone validator is disabled because it flags ALL comments in
402
+ existing files, which forces agents to remove them to pass validation.
381
403
  """
382
404
  return ValidatorResult(
383
405
  name="No Comments",
@@ -658,6 +680,188 @@ def get_changed_files() -> List[Path]:
658
680
  return [Path(f) for f in files if f]
659
681
 
660
682
 
683
+ def _read_target_file_content(file_path: str) -> Optional[str]:
684
+ """Return the on-disk content of *file_path*, or None when it cannot be read."""
685
+ try:
686
+ with open(file_path, "r", encoding="utf-8") as readable_file:
687
+ return readable_file.read()
688
+ except (FileNotFoundError, OSError, UnicodeDecodeError):
689
+ return None
690
+
691
+
692
+ def reconstruct_proposed_content(
693
+ tool_name: str, tool_input: Dict[str, object]
694
+ ) -> Optional[str]:
695
+ """Return the post-edit content one Write, Edit, or MultiEdit payload leaves on disk.
696
+
697
+ ::
698
+
699
+ Write -> tool_input["content"] verbatim
700
+ Edit -> existing file, each old_string rewritten to new_string
701
+ MultiEdit -> existing file, each edit applied in order
702
+
703
+ The Edit and MultiEdit reconstruction reuses the shared applier so this gate
704
+ judges the same post-edit content the standalone blockers judge.
705
+
706
+ Args:
707
+ tool_name: The intercepted tool — Write, Edit, or MultiEdit.
708
+ tool_input: The tool's input payload.
709
+
710
+ Returns:
711
+ The proposed post-edit content, or None when the payload carries no
712
+ readable target for an edit or no string content for a write.
713
+ """
714
+ if tool_name == "Write":
715
+ written_content = tool_input.get("content", "")
716
+ return written_content if isinstance(written_content, str) else None
717
+ file_path = tool_input.get("file_path", "")
718
+ if not isinstance(file_path, str) or not file_path:
719
+ return None
720
+ existing_content = _read_target_file_content(file_path)
721
+ if existing_content is None:
722
+ return None
723
+ return apply_edits(existing_content, edits_for_tool(tool_name, dict(tool_input)))
724
+
725
+
726
+ def run_file_scoped_validators(all_files: List[Path]) -> List[ValidatorResult]:
727
+ """Run every validator scoped to individual files against *all_files*.
728
+
729
+ Excludes the branch-scoped File Structure and Git validators, which grade
730
+ the whole project rather than a single proposed file.
731
+
732
+ Args:
733
+ all_files: The files under validation — a single reconstructed file in
734
+ gate mode.
735
+
736
+ Returns:
737
+ One ValidatorResult per file-scoped validator, in run order.
738
+ """
739
+ return [
740
+ run_python_style_checks(all_files),
741
+ run_test_safety_checks(all_files),
742
+ run_react_checks(all_files),
743
+ run_ruff_checks(all_files),
744
+ run_mypy_checks(all_files),
745
+ run_abbreviation_checks(all_files),
746
+ run_pr_reference_checks(all_files),
747
+ run_magic_value_checks(all_files),
748
+ run_useless_test_checks(all_files),
749
+ run_security_checks(all_files),
750
+ run_code_quality_checks(all_files),
751
+ run_python_antipattern_checks(all_files),
752
+ run_todo_checks(all_files),
753
+ run_type_safety_checks(all_files),
754
+ ]
755
+
756
+
757
+ def validate_proposed_file(
758
+ file_path: str, proposed_content: str
759
+ ) -> List[ValidatorResult]:
760
+ """Validate *proposed_content* as if written to *file_path*.
761
+
762
+ Writes the content to a temporary file that carries the target's basename so
763
+ suffix-based and test-name-based validator filtering matches the real path,
764
+ then runs the file-scoped validators against it.
765
+
766
+ Args:
767
+ file_path: The destination path the write or edit targets.
768
+ proposed_content: The reconstructed post-edit content of that file.
769
+
770
+ Returns:
771
+ One ValidatorResult per file-scoped validator.
772
+ """
773
+ base_name = Path(file_path).name
774
+ with tempfile.TemporaryDirectory() as temporary_directory:
775
+ temporary_file = Path(temporary_directory) / base_name
776
+ temporary_file.write_text(proposed_content, encoding="utf-8")
777
+ return run_file_scoped_validators([temporary_file])
778
+
779
+
780
+ def _proposed_content_deny_reason(failed_results: List[ValidatorResult]) -> str:
781
+ """Compose the deny reason naming each failing validator and its output.
782
+
783
+ Args:
784
+ failed_results: The validator results that did not pass.
785
+
786
+ Returns:
787
+ The composed ``permissionDecisionReason`` text.
788
+ """
789
+ violation_summaries = [
790
+ f"{each_result.name} (checks {each_result.checks}): {each_result.output.strip()}"
791
+ for each_result in failed_results
792
+ ]
793
+ deny_reason_item_separator = " | "
794
+ joined_summaries = deny_reason_item_separator.join(violation_summaries)
795
+ return (
796
+ f"BLOCKED: [validators] {len(failed_results)} "
797
+ f"validator(s) failed: {joined_summaries}"
798
+ )
799
+
800
+
801
+ def _emit_pre_tool_use_deny(deny_reason: str) -> None:
802
+ """Write one PreToolUse deny JSON payload carrying *deny_reason* to stdout."""
803
+ deny_payload = {
804
+ "hookSpecificOutput": {
805
+ "hookEventName": "PreToolUse",
806
+ "permissionDecision": "deny",
807
+ "permissionDecisionReason": deny_reason,
808
+ }
809
+ }
810
+ sys.stdout.write(json.dumps(deny_payload) + "\n")
811
+ sys.stdout.flush()
812
+
813
+
814
+ def _evaluate_pre_tool_use_payload() -> None:
815
+ """Read the PreToolUse payload from stdin and deny a violating file write.
816
+
817
+ Reconstructs the proposed post-edit content of the one target file and runs
818
+ the file-scoped validators against it. Emits a deny decision naming each
819
+ failing validator when any fires; writes nothing for a clean file, an
820
+ unparseable payload, or a payload no validator covers.
821
+ """
822
+ pre_tool_use_payload = json.load(sys.stdin)
823
+ if not isinstance(pre_tool_use_payload, dict):
824
+ return
825
+ tool_name = pre_tool_use_payload.get("tool_name", "")
826
+ tool_input = pre_tool_use_payload.get("tool_input", {})
827
+ if not isinstance(tool_name, str) or not isinstance(tool_input, dict):
828
+ return
829
+ file_path = tool_input.get("file_path", "")
830
+ if not isinstance(file_path, str) or not file_path:
831
+ return
832
+ proposed_content = reconstruct_proposed_content(tool_name, tool_input)
833
+ if not proposed_content:
834
+ return
835
+ all_results = validate_proposed_file(file_path, proposed_content)
836
+ failed_results = [
837
+ each_result
838
+ for each_result in all_results
839
+ if not each_result.passed and not each_result.skipped
840
+ ]
841
+ if failed_results:
842
+ _emit_pre_tool_use_deny(_proposed_content_deny_reason(failed_results))
843
+
844
+
845
+ def run_pre_tool_use_gate() -> int:
846
+ """Run the PreToolUse gate, never crashing the tool call on an internal error.
847
+
848
+ A hook that raises is rendered by the harness as a tool malfunction, so an
849
+ unexpected failure here logs to stderr and returns 0 rather than propagating.
850
+
851
+ Returns:
852
+ Always 0 — the gate signals a block through the deny payload, not an
853
+ exit code.
854
+ """
855
+ try:
856
+ _evaluate_pre_tool_use_payload()
857
+ except json.JSONDecodeError:
858
+ return 0
859
+ except Exception as error:
860
+ sys.stderr.write(f"[run_all_validators] pre-tool-use gate error: {error}\n")
861
+ sys.stderr.flush()
862
+ return 0
863
+
864
+
661
865
  def main() -> int:
662
866
  """Run all validators and report results."""
663
867
  parser = argparse.ArgumentParser(description="Run pre-push validators")
@@ -687,8 +891,16 @@ def main() -> int:
687
891
  default=2,
688
892
  help="Lines of context around violations",
689
893
  )
894
+ parser.add_argument(
895
+ "--pre-tool-use",
896
+ action="store_true",
897
+ help="Run as a PreToolUse gate on the single proposed file write from stdin",
898
+ )
690
899
  args = parser.parse_args()
691
900
 
901
+ if args.pre_tool_use:
902
+ return run_pre_tool_use_gate()
903
+
692
904
  if args.health:
693
905
  health = get_system_health()
694
906
  print_health_report(health)