claude-dev-env 2.18.0 → 2.19.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 (93) hide show
  1. package/.agents/skills/AGENTS.md +4 -4
  2. package/.agents/skills/_shared/AGENTS.md +1 -0
  3. package/.agents/skills/_shared/pr-loop/AGENTS.md +1 -0
  4. package/.agents/skills/_shared/pr-loop/preflight-proposal.contract.test.mjs +89 -0
  5. package/.agents/skills/_shared/pr-loop/preflight-proposal.md +5 -0
  6. package/.agents/skills/pr-cleanup/SKILL.md +17 -85
  7. package/.agents/skills/pr-cleanup/reference/process-inventory.md +15 -0
  8. package/.agents/skills/pr-cleanup/reference/task-seeds.md +20 -0
  9. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/SKILL.md +18 -12
  10. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/offense-examples.md +1 -1
  11. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/task-seeds.md +3 -2
  12. package/.agents/skills/pr-refinement/SKILL.md +31 -0
  13. package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/SKILL.md +24 -7
  14. package/.agents/skills/{small-cl → pr-small-cl}/SKILL.md +1 -1
  15. package/.agents/skills/prototype/scripts/test_probe_sandbox_safety.py +33 -5
  16. package/.agents/skills/skill-builder/SKILL.md +1 -0
  17. package/.agents/skills-archived/bugteam/SKILL.md +1 -1
  18. package/.agents/skills-archived/bugteam/test_skill_additions.py +1 -1
  19. package/.agents/skills-archived/comments/SKILL.md +1 -1
  20. package/.agents/skills-archived/descriptions/SKILL.md +1 -1
  21. package/.agents/skills-archived/reviews/SKILL.md +1 -1
  22. package/AGENTS.md +1 -1
  23. package/_shared/pr-loop/AGENTS.md +2 -1
  24. package/_shared/pr-loop/preflight-proposal.md +56 -0
  25. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +47 -4
  26. package/bin/ever-shipped-skills.mjs +14 -0
  27. package/bin/install-constants.mjs +5 -0
  28. package/bin/install.agents-home.test.mjs +23 -0
  29. package/bin/install.codex-rules.test.mjs +61 -6
  30. package/bin/install.mjs +57 -9
  31. package/bin/install.prune.test.mjs +65 -3
  32. package/bin/install.test.mjs +202 -2
  33. package/docs/CODE_RULES.md +4 -0
  34. package/hooks/advisory/migration_safety_advisor.py +14 -14
  35. package/hooks/advisory/test_migration_safety_advisor.py +120 -0
  36. package/hooks/atomic_file_writer.py +81 -0
  37. package/hooks/blocking/AGENTS.md +2 -1
  38. package/hooks/blocking/code_rules_banned_identifiers.py +55 -4
  39. package/hooks/blocking/code_rules_enforcer_test_support.py +92 -0
  40. package/hooks/blocking/destructive_command_blocker.py +8 -7
  41. package/hooks/blocking/pre_tool_use_dispatcher.py +11 -7
  42. package/hooks/blocking/precommit_code_rules_gate.py +14 -139
  43. package/hooks/blocking/sensitive_file_protector.py +8 -6
  44. package/hooks/blocking/session_edit_stage_gate.py +31 -1
  45. package/hooks/blocking/test_claude_md_orphan_file_blocker.py +171 -1
  46. package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +37 -12
  47. package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +54 -0
  48. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +15 -53
  49. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +13 -62
  50. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +308 -0
  51. package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +14 -71
  52. package/hooks/blocking/test_code_rules_enforcer_scratchpad.py +14 -16
  53. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +31 -46
  54. package/hooks/blocking/test_code_rules_enforcer_stage.py +192 -0
  55. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +13 -59
  56. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +42 -25
  57. package/hooks/blocking/test_destructive_command_blocker_no_verify.py +58 -38
  58. package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +113 -1
  59. package/hooks/blocking/test_env_var_table_code_drift_blocker.py +70 -3
  60. package/hooks/blocking/test_hook_subprocess_support.py +296 -0
  61. package/hooks/blocking/test_package_inventory_stale_blocker.py +1 -1
  62. package/hooks/blocking/test_pii_scanner.py +81 -0
  63. package/hooks/blocking/test_pre_tool_use_dispatcher.py +32 -3
  64. package/hooks/blocking/test_precommit_code_rules_gate.py +11 -16
  65. package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +168 -0
  66. package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +25 -18
  67. package/hooks/blocking/test_sensitive_file_protector.py +145 -5
  68. package/hooks/blocking/test_session_edit_stage_gate_staging.py +64 -0
  69. package/hooks/blocking/test_session_edit_stage_gate_support.py +204 -0
  70. package/hooks/blocking/test_session_edit_stage_gate_tracker.py +116 -0
  71. package/hooks/blocking/test_shared_stdin_adoption.py +31 -20
  72. package/hooks/blocking/test_test_preflight_check.py +80 -0
  73. package/hooks/git-hooks/pre_commit.py +6 -2
  74. package/hooks/git-hooks/test_pre_push.py +57 -0
  75. package/hooks/hooks_constants/AGENTS.md +3 -1
  76. package/hooks/hooks_constants/atomic_file_writer_constants.py +4 -0
  77. package/hooks/hooks_constants/banned_identifiers_constants.py +1 -0
  78. package/hooks/hooks_constants/destructive_command_environment_constants.py +18 -0
  79. package/hooks/hooks_constants/destructive_command_segment_constants.py +1 -11
  80. package/hooks/hooks_constants/hardcoded_user_path_constants.py +9 -3
  81. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +1 -1
  82. package/hooks/hooks_constants/sensitive_file_protector_constants.py +2 -4
  83. package/hooks/json_file_reader.py +22 -0
  84. package/hooks/pending_sidecars.py +33 -0
  85. package/hooks/test_atomic_file_writer.py +73 -0
  86. package/hooks/test_json_file_reader.py +24 -0
  87. package/hooks/test_pending_sidecars.py +25 -0
  88. package/package.json +1 -1
  89. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/fetch-commands.md +0 -0
  90. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/report-template.md +0 -0
  91. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/rule-checklist.md +0 -0
  92. /package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/reference/examples.md +0 -0
  93. /package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/reference/offense-taxonomy.md +0 -0
@@ -0,0 +1,64 @@
1
+ """Production-path staging tests for the session-edit stage gate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ _TEST_DIRECTORY = Path(__file__).resolve().parent
9
+ if str(_TEST_DIRECTORY) not in sys.path:
10
+ sys.path.insert(0, str(_TEST_DIRECTORY))
11
+
12
+ from test_session_edit_stage_gate_support import ( # noqa: E402
13
+ make_test_directories,
14
+ parse_dispatcher_decision,
15
+ prepare_repository_with_unstaged_edit,
16
+ run_bash_dispatcher,
17
+ run_tracker_event,
18
+ read_tracker_paths,
19
+ )
20
+
21
+ SESSION_ID = "staging-production-session"
22
+
23
+
24
+ def test_dispatcher_denies_tracker_recorded_unstaged_edit(tmp_path: Path) -> None:
25
+ """The Bash dispatcher denies a commit for a tracker-recorded edit."""
26
+ repository_root, temp_directory, home_directory = make_test_directories(tmp_path)
27
+ tracked_file = prepare_repository_with_unstaged_edit(repository_root)
28
+ tracker_run = run_tracker_event(
29
+ tracked_file, SESSION_ID, repository_root, temp_directory, home_directory
30
+ )
31
+ assert tracker_run.returncode == 0
32
+ assert read_tracker_paths(temp_directory, SESSION_ID) == [str(tracked_file.resolve())]
33
+
34
+ dispatcher_run = run_bash_dispatcher(
35
+ "git commit -m update",
36
+ SESSION_ID,
37
+ repository_root,
38
+ temp_directory,
39
+ home_directory,
40
+ )
41
+ assert dispatcher_run.returncode == 0
42
+ decision, reason = parse_dispatcher_decision(dispatcher_run.stdout)
43
+ assert decision == "deny"
44
+ assert "widget.py" in reason
45
+
46
+
47
+ def test_dispatcher_allows_specific_staging_segment(tmp_path: Path) -> None:
48
+ """The Bash dispatcher accepts a commit preceded by a specific git add."""
49
+ repository_root, temp_directory, home_directory = make_test_directories(tmp_path)
50
+ tracked_file = prepare_repository_with_unstaged_edit(repository_root)
51
+ tracker_run = run_tracker_event(
52
+ tracked_file, SESSION_ID, repository_root, temp_directory, home_directory
53
+ )
54
+ assert tracker_run.returncode == 0
55
+
56
+ dispatcher_run = run_bash_dispatcher(
57
+ "git add widget.py && git commit -m update",
58
+ SESSION_ID,
59
+ repository_root,
60
+ temp_directory,
61
+ home_directory,
62
+ )
63
+ assert dispatcher_run.returncode == 0
64
+ assert parse_dispatcher_decision(dispatcher_run.stdout) == ("", "")
@@ -0,0 +1,204 @@
1
+ """Production-neutral fixtures for session-edit stage gate tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ _SUPPORT_DIRECTORY = Path(__file__).resolve().parent
12
+ _HOOKS_TREE = _SUPPORT_DIRECTORY.parent
13
+ if str(_HOOKS_TREE) not in sys.path:
14
+ sys.path.insert(0, str(_HOOKS_TREE))
15
+
16
+ from hooks_constants.session_edit_stage_gate_constants import ( # noqa: E402
17
+ ALL_EDITED_FILE_PATHS_KEY,
18
+ SESSION_EDIT_FILE_PREFIX,
19
+ SESSION_EDIT_FILE_SUFFIX,
20
+ )
21
+
22
+
23
+ def clean_git_environment() -> dict[str, str]:
24
+ """Return the environment without inherited repository overrides."""
25
+ return {
26
+ each_key: each_value
27
+ for each_key, each_value in os.environ.items()
28
+ if not each_key.startswith("GIT_")
29
+ }
30
+
31
+
32
+ def run_git(repository_root: Path, *git_arguments: str) -> None:
33
+ """Run one Git command in a test repository."""
34
+ subprocess.run(
35
+ ["git", "-C", str(repository_root), *git_arguments],
36
+ check=True,
37
+ capture_output=True,
38
+ env=clean_git_environment(),
39
+ )
40
+
41
+
42
+ def initialize_repository(repository_root: Path) -> None:
43
+ """Create a Git repository on a non-protected test branch."""
44
+ run_git(repository_root, "init")
45
+ run_git(repository_root, "checkout", "-b", "test-session-stage")
46
+ run_git(repository_root, "config", "user.email", "tests@example.com")
47
+ run_git(repository_root, "config", "user.name", "Gate Tests")
48
+
49
+
50
+ def prepare_repository_with_unstaged_edit(repository_root: Path) -> Path:
51
+ """Create one tracked file with an unstaged edit."""
52
+ initialize_repository(repository_root)
53
+ tracked_file = repository_root / "widget.py"
54
+ tracked_file.write_text("x = 1\n", encoding="utf-8")
55
+ run_git(repository_root, "add", "widget.py")
56
+ run_git(repository_root, "commit", "-m", "add widget")
57
+ tracked_file.write_text("x = 2\n", encoding="utf-8")
58
+ return tracked_file
59
+
60
+
61
+ def prepare_repository_with_two_unstaged_edits(repository_root: Path) -> tuple[Path, Path]:
62
+ """Create two tracked files with separate unstaged edits."""
63
+ initialize_repository(repository_root)
64
+ tracked_file = repository_root / "widget.py"
65
+ second_file = repository_root / "second_widget.py"
66
+ tracked_file.write_text("x = 1\n", encoding="utf-8")
67
+ second_file.write_text("y = 1\n", encoding="utf-8")
68
+ run_git(repository_root, "add", "widget.py", "second_widget.py")
69
+ run_git(repository_root, "commit", "-m", "add widgets")
70
+ tracked_file.write_text("x = 2\n", encoding="utf-8")
71
+ second_file.write_text("y = 2\n", encoding="utf-8")
72
+ return tracked_file, second_file
73
+
74
+
75
+ def make_test_directories(tmp_path: Path) -> tuple[Path, Path, Path]:
76
+ """Create repository, tracker, and home directories for one test."""
77
+ repository_root = tmp_path / "repo"
78
+ repository_root.mkdir()
79
+ temp_directory = tmp_path / "tmp"
80
+ temp_directory.mkdir()
81
+ home_directory = tmp_path / "home"
82
+ home_directory.mkdir()
83
+ return repository_root, temp_directory, home_directory
84
+
85
+
86
+ def make_process_repository(tmp_path: Path) -> Path:
87
+ """Create a separate Git repository for the dispatcher process directory."""
88
+ process_repository = tmp_path / "process-repository"
89
+ process_repository.mkdir()
90
+ initialize_repository(process_repository)
91
+ return process_repository
92
+
93
+
94
+ def prepare_process_child_repository(process_repository: Path) -> None:
95
+ """Create the relative command directory inside process repository B."""
96
+ process_child_repository = process_repository / "child"
97
+ process_child_repository.mkdir()
98
+ initialize_repository(process_child_repository)
99
+
100
+
101
+ def build_hook_environment(
102
+ temp_directory: Path,
103
+ home_directory: Path,
104
+ ) -> dict[str, str]:
105
+ """Build isolated environment variables for production hooks."""
106
+ environment = clean_git_environment()
107
+ environment["TMP"] = str(temp_directory)
108
+ environment["TEMP"] = str(temp_directory)
109
+ environment["TMPDIR"] = str(temp_directory)
110
+ environment["USERPROFILE"] = str(home_directory)
111
+ environment["HOME"] = str(home_directory)
112
+ return environment
113
+
114
+
115
+ def run_production_hook(
116
+ hook_path: Path,
117
+ payload: str,
118
+ repository_root: Path,
119
+ temp_directory: Path,
120
+ home_directory: Path,
121
+ process_directory: Path | None = None,
122
+ ) -> subprocess.CompletedProcess[str]:
123
+ """Run one hook script as a production subprocess."""
124
+ return subprocess.run(
125
+ [sys.executable, str(hook_path)],
126
+ check=False,
127
+ input=payload,
128
+ capture_output=True,
129
+ text=True,
130
+ cwd=str(process_directory or repository_root),
131
+ env=build_hook_environment(temp_directory, home_directory),
132
+ timeout=60,
133
+ )
134
+
135
+
136
+ def run_tracker_event(
137
+ tracked_file: Path,
138
+ session_id: str,
139
+ repository_root: Path,
140
+ temp_directory: Path,
141
+ home_directory: Path,
142
+ ) -> subprocess.CompletedProcess[str]:
143
+ """Run the production PostToolUse tracker for one edited file."""
144
+ payload = json.dumps(
145
+ {
146
+ "tool_name": "Edit",
147
+ "session_id": session_id,
148
+ "tool_input": {"file_path": str(tracked_file)},
149
+ }
150
+ )
151
+ return run_production_hook(
152
+ _HOOKS_TREE / "observability" / "session_file_edit_tracker.py",
153
+ payload,
154
+ repository_root,
155
+ temp_directory,
156
+ home_directory,
157
+ )
158
+
159
+
160
+ def run_bash_dispatcher(
161
+ bash_command: str,
162
+ session_id: str,
163
+ repository_root: Path,
164
+ temp_directory: Path,
165
+ home_directory: Path,
166
+ process_directory: Path | None = None,
167
+ ) -> subprocess.CompletedProcess[str]:
168
+ """Run the production Bash PreToolUse dispatcher for one command."""
169
+ payload = json.dumps(
170
+ {
171
+ "tool_name": "Bash",
172
+ "cwd": str(repository_root),
173
+ "session_id": session_id,
174
+ "tool_input": {"command": bash_command},
175
+ }
176
+ )
177
+ return run_production_hook(
178
+ _SUPPORT_DIRECTORY / "bash_pre_tool_use_dispatcher.py",
179
+ payload,
180
+ repository_root,
181
+ temp_directory,
182
+ home_directory,
183
+ process_directory,
184
+ )
185
+
186
+
187
+ def read_tracker_paths(temp_directory: Path, session_id: str) -> list[str]:
188
+ """Read the recorded paths from one production tracker file."""
189
+ tracker_file = (
190
+ temp_directory / f"{SESSION_EDIT_FILE_PREFIX}{session_id}{SESSION_EDIT_FILE_SUFFIX}"
191
+ )
192
+ tracker_payload = json.loads(tracker_file.read_text(encoding="utf-8"))
193
+ return tracker_payload[ALL_EDITED_FILE_PATHS_KEY]
194
+
195
+
196
+ def parse_dispatcher_decision(stdout_text: str) -> tuple[str, str]:
197
+ """Return the dispatcher decision and reason from its stdout payload."""
198
+ if not stdout_text.strip():
199
+ return "", ""
200
+ hook_specific_output = json.loads(stdout_text)["hookSpecificOutput"]
201
+ return (
202
+ hook_specific_output.get("permissionDecision", ""),
203
+ hook_specific_output.get("permissionDecisionReason", ""),
204
+ )
@@ -0,0 +1,116 @@
1
+ """Production-path tracker event-order tests for the session-edit stage gate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ _TEST_DIRECTORY = Path(__file__).resolve().parent
11
+ if str(_TEST_DIRECTORY) not in sys.path:
12
+ sys.path.insert(0, str(_TEST_DIRECTORY))
13
+
14
+ from test_session_edit_stage_gate_support import ( # noqa: E402
15
+ make_test_directories,
16
+ make_process_repository,
17
+ parse_dispatcher_decision,
18
+ prepare_process_child_repository,
19
+ prepare_repository_with_unstaged_edit,
20
+ prepare_repository_with_two_unstaged_edits,
21
+ read_tracker_paths,
22
+ run_bash_dispatcher,
23
+ run_tracker_event,
24
+ )
25
+
26
+ SESSION_ID = "tracker-production-session"
27
+
28
+
29
+ def test_tracker_events_precede_dispatcher_stage_decision(tmp_path: Path) -> None:
30
+ """The dispatcher sees every edit recorded before the commit event."""
31
+ repository_root, temp_directory, home_directory = make_test_directories(tmp_path)
32
+ tracked_file, second_file = prepare_repository_with_two_unstaged_edits(repository_root)
33
+ for each_file in (tracked_file, second_file):
34
+ tracker_run = run_tracker_event(
35
+ each_file, SESSION_ID, repository_root, temp_directory, home_directory
36
+ )
37
+ assert tracker_run.returncode == 0
38
+
39
+ assert read_tracker_paths(temp_directory, SESSION_ID) == [
40
+ str(tracked_file.resolve()),
41
+ str(second_file.resolve()),
42
+ ]
43
+
44
+ dispatcher_run = run_bash_dispatcher(
45
+ "git commit -m update",
46
+ SESSION_ID,
47
+ repository_root,
48
+ temp_directory,
49
+ home_directory,
50
+ )
51
+ assert dispatcher_run.returncode == 0
52
+ decision, reason = parse_dispatcher_decision(dispatcher_run.stdout)
53
+ assert decision == "deny"
54
+ assert "widget.py" in reason
55
+ assert "second_widget.py" in reason
56
+
57
+
58
+ def test_dispatcher_uses_event_cwd_when_process_runs_elsewhere(tmp_path: Path) -> None:
59
+ """The dispatcher passes event cwd so the gate inspects the event repository."""
60
+ repository_root, temp_directory, home_directory = make_test_directories(tmp_path)
61
+ process_repository = make_process_repository(tmp_path)
62
+ tracked_file = prepare_repository_with_unstaged_edit(repository_root)
63
+ tracker_run = run_tracker_event(
64
+ tracked_file, SESSION_ID, repository_root, temp_directory, home_directory
65
+ )
66
+ assert tracker_run.returncode == 0
67
+
68
+ dispatcher_run = run_bash_dispatcher(
69
+ "git commit -m update",
70
+ SESSION_ID,
71
+ repository_root,
72
+ temp_directory,
73
+ home_directory,
74
+ process_directory=process_repository,
75
+ )
76
+ assert dispatcher_run.returncode == 0
77
+ decision, reason = parse_dispatcher_decision(dispatcher_run.stdout)
78
+ assert decision == "deny"
79
+ assert "widget.py" in reason
80
+
81
+
82
+ @pytest.mark.parametrize(
83
+ "bash_command",
84
+ [
85
+ "cd child && git commit -m update",
86
+ "pushd child && git commit -m update",
87
+ "git -C child commit -m update",
88
+ ],
89
+ )
90
+ def test_dispatcher_anchors_relative_command_directory_to_event_cwd(
91
+ tmp_path: Path,
92
+ bash_command: str,
93
+ ) -> None:
94
+ """Relative commit directories resolve from event cwd across command forms."""
95
+ repository_root, temp_directory, home_directory = make_test_directories(tmp_path)
96
+ process_repository = make_process_repository(tmp_path)
97
+ prepare_process_child_repository(process_repository)
98
+ (repository_root / "child").mkdir()
99
+ tracked_file = prepare_repository_with_unstaged_edit(repository_root)
100
+ tracker_run = run_tracker_event(
101
+ tracked_file, SESSION_ID, repository_root, temp_directory, home_directory
102
+ )
103
+ assert tracker_run.returncode == 0
104
+
105
+ dispatcher_run = run_bash_dispatcher(
106
+ bash_command,
107
+ SESSION_ID,
108
+ repository_root,
109
+ temp_directory,
110
+ home_directory,
111
+ process_directory=process_repository,
112
+ )
113
+ assert dispatcher_run.returncode == 0
114
+ decision, reason = parse_dispatcher_decision(dispatcher_run.stdout)
115
+ assert decision == "deny"
116
+ assert "widget.py" in reason
@@ -26,6 +26,13 @@ from pathlib import Path
26
26
  import pytest
27
27
 
28
28
  _BLOCKING_DIRECTORY = Path(__file__).resolve().parent
29
+ if str(_BLOCKING_DIRECTORY) not in sys.path:
30
+ sys.path.insert(0, str(_BLOCKING_DIRECTORY))
31
+
32
+ from test_hook_subprocess_support import ( # noqa: E402
33
+ assert_hook_deny_log_contains,
34
+ run_hook_as_subprocess,
35
+ )
29
36
 
30
37
  ALL_CONVERTED_HOOK_FILENAMES = (
31
38
  "open_questions_in_plans_blocker.py",
@@ -48,15 +55,15 @@ ALL_FAIL_SOFT_PAYLOADS = (
48
55
  )
49
56
 
50
57
 
51
- def _run_hook_script(hook_filename: str, stdin_text: str) -> subprocess.CompletedProcess:
58
+ def _run_hook_script(
59
+ hook_filename: str, stdin_text: str, temporary_home_directory: Path
60
+ ) -> subprocess.CompletedProcess[str]:
52
61
  hook_script_path = _BLOCKING_DIRECTORY / hook_filename
53
- return subprocess.run(
54
- [sys.executable, str(hook_script_path)],
55
- input=stdin_text,
56
- capture_output=True,
57
- text=True,
58
- check=False,
59
- cwd=str(Path.home()),
62
+ return run_hook_as_subprocess(
63
+ hook_script_path=hook_script_path,
64
+ payload_text=stdin_text,
65
+ working_directory=temporary_home_directory,
66
+ home_directory=temporary_home_directory,
60
67
  )
61
68
 
62
69
 
@@ -69,8 +76,10 @@ def _decision_from_stdout(completed: subprocess.CompletedProcess) -> str | None:
69
76
 
70
77
  @pytest.mark.parametrize("hook_filename", ALL_CONVERTED_HOOK_FILENAMES)
71
78
  @pytest.mark.parametrize("stdin_text", ALL_FAIL_SOFT_PAYLOADS)
72
- def test_fail_soft_payload_exits_zero_with_no_decision(hook_filename: str, stdin_text: str) -> None:
73
- completed = _run_hook_script(hook_filename, stdin_text)
79
+ def test_fail_soft_payload_exits_zero_with_no_decision(
80
+ hook_filename: str, stdin_text: str, tmp_path: Path
81
+ ) -> None:
82
+ completed = _run_hook_script(hook_filename, stdin_text, tmp_path)
74
83
  assert completed.returncode == 0, (
75
84
  f"{hook_filename} must exit zero on fail-soft stdin; "
76
85
  f"got code {completed.returncode}, stderr {completed.stderr!r}"
@@ -93,9 +102,10 @@ def test_open_questions_blocker_still_denies_plan_with_open_questions(
93
102
  "tool_input": {"file_path": str(plan_path), "content": plan_body},
94
103
  }
95
104
  )
96
- completed = _run_hook_script("open_questions_in_plans_blocker.py", payload)
97
- assert completed.returncode == 0
105
+ completed = _run_hook_script("open_questions_in_plans_blocker.py", payload, tmp_path)
106
+ assert completed.returncode == 0, completed.stderr
98
107
  assert _decision_from_stdout(completed) == "deny"
108
+ assert_hook_deny_log_contains(tmp_path, "open_questions_in_plans_blocker.py")
99
109
 
100
110
 
101
111
  def test_open_questions_blocker_still_allows_plan_without_open_questions(
@@ -111,8 +121,8 @@ def test_open_questions_blocker_still_allows_plan_without_open_questions(
111
121
  "tool_input": {"file_path": str(plan_path), "content": plan_body},
112
122
  }
113
123
  )
114
- completed = _run_hook_script("open_questions_in_plans_blocker.py", payload)
115
- assert completed.returncode == 0
124
+ completed = _run_hook_script("open_questions_in_plans_blocker.py", payload, tmp_path)
125
+ assert completed.returncode == 0, completed.stderr
116
126
  assert _decision_from_stdout(completed) is None
117
127
 
118
128
 
@@ -130,9 +140,10 @@ def test_package_inventory_blocker_still_denies_uninventoried_new_file(
130
140
  "tool_input": {"file_path": str(new_file_path), "content": "x = 1\n"},
131
141
  }
132
142
  )
133
- completed = _run_hook_script("package_inventory_stale_blocker.py", payload)
134
- assert completed.returncode == 0
143
+ completed = _run_hook_script("package_inventory_stale_blocker.py", payload, tmp_path)
144
+ assert completed.returncode == 0, completed.stderr
135
145
  assert _decision_from_stdout(completed) == "deny"
146
+ assert_hook_deny_log_contains(tmp_path, "package_inventory_stale_blocker.py")
136
147
 
137
148
 
138
149
  def test_package_inventory_blocker_still_allows_inventoried_new_file(
@@ -152,15 +163,15 @@ def test_package_inventory_blocker_still_allows_inventoried_new_file(
152
163
  "tool_input": {"file_path": str(new_file_path), "content": "x = 1\n"},
153
164
  }
154
165
  )
155
- completed = _run_hook_script("package_inventory_stale_blocker.py", payload)
156
- assert completed.returncode == 0
166
+ completed = _run_hook_script("package_inventory_stale_blocker.py", payload, tmp_path)
167
+ assert completed.returncode == 0, completed.stderr
157
168
  assert _decision_from_stdout(completed) is None
158
169
 
159
170
 
160
- def test_converted_hooks_allow_unrelated_tool_name() -> None:
171
+ def test_converted_hooks_allow_unrelated_tool_name(tmp_path: Path) -> None:
161
172
  payload = json.dumps({"tool_name": "Bash", "tool_input": {"command": "ls"}})
162
173
  for each_hook_filename in ALL_CONVERTED_HOOK_FILENAMES:
163
- completed = _run_hook_script(each_hook_filename, payload)
174
+ completed = _run_hook_script(each_hook_filename, payload, tmp_path)
164
175
  assert completed.returncode == 0, (
165
176
  f"{each_hook_filename} must exit zero on an unrelated tool; stderr {completed.stderr!r}"
166
177
  )
@@ -0,0 +1,80 @@
1
+ """Production-path tests for pytest-Django and Playwright preflight selection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ _BLOCKING_DIRECTORY = Path(__file__).resolve().parent
10
+ _DISPATCHER_PATH = _BLOCKING_DIRECTORY / "bash_pre_tool_use_dispatcher.py"
11
+ if str(_BLOCKING_DIRECTORY) not in sys.path:
12
+ sys.path.insert(0, str(_BLOCKING_DIRECTORY))
13
+
14
+ from test_hook_subprocess_support import ( # noqa: E402
15
+ build_bash_payload,
16
+ read_hook_permission_decision,
17
+ run_hook_as_subprocess,
18
+ )
19
+
20
+
21
+ def _run_bash_dispatch(
22
+ command: str, project_root: Path
23
+ ) -> subprocess.CompletedProcess[str]:
24
+ return run_hook_as_subprocess(
25
+ hook_script_path=_DISPATCHER_PATH,
26
+ payload_text=build_bash_payload(command),
27
+ working_directory=project_root,
28
+ home_directory=project_root,
29
+ environment_updates_by_name={"PWD": str(project_root)},
30
+ )
31
+
32
+
33
+ def _create_django_project_fixture(tmp_path: Path) -> Path:
34
+ project_root = tmp_path / "django-project"
35
+ project_root.mkdir()
36
+ (project_root / "manage.py").write_text(
37
+ "import sys\n\nif __name__ == '__main__':\n sys.exit(0)\n",
38
+ encoding="utf-8",
39
+ )
40
+ return project_root
41
+
42
+
43
+ def test_dispatcher_selects_pytest_django_changed_test(tmp_path: Path) -> None:
44
+ project_root = _create_django_project_fixture(tmp_path)
45
+
46
+ command = "python -m pytest tests/test_models.py"
47
+ completed_process = _run_bash_dispatch(command, project_root)
48
+
49
+ assert completed_process.returncode == 0, completed_process.stderr
50
+ decision, reason = read_hook_permission_decision(completed_process.stdout)
51
+ assert decision == "deny"
52
+ assert "No database file (db.sqlite3)" in reason
53
+
54
+
55
+ def test_dispatcher_ignores_unrecognized_django_test_runner(tmp_path: Path) -> None:
56
+ project_root = _create_django_project_fixture(tmp_path)
57
+
58
+ command = "python -m unittest tests/test_models.py"
59
+ completed_process = _run_bash_dispatch(command, project_root)
60
+
61
+ assert completed_process.returncode == 0, completed_process.stderr
62
+ assert completed_process.stdout == ""
63
+
64
+
65
+ def test_dispatcher_selects_playwright_changed_spec(tmp_path: Path) -> None:
66
+ command = "npx playwright test tests/account.spec.ts --base-url http://127.0.0.1:1"
67
+ completed_process = _run_bash_dispatch(command, tmp_path)
68
+
69
+ assert completed_process.returncode == 0, completed_process.stderr
70
+ decision, reason = read_hook_permission_decision(completed_process.stdout)
71
+ assert decision == "deny"
72
+ assert "Server at http://127.0.0.1:1 is unreachable" in reason
73
+
74
+
75
+ def test_dispatcher_ignores_non_playwright_test_runner(tmp_path: Path) -> None:
76
+ command = "node tests/account.spec.ts"
77
+ completed_process = _run_bash_dispatch(command, tmp_path)
78
+
79
+ assert completed_process.returncode == 0, completed_process.stderr
80
+ assert completed_process.stdout == ""
@@ -1,10 +1,13 @@
1
1
  #!/usr/bin/env python3
2
- """Git pre-commit hook: run the CODE_RULES gate over staged changes.
2
+ """Native Git pre-commit owner for staged CODE_RULES validation.
3
3
 
4
4
  Installed to the user's shared git-hooks directory via the claude-dev-env
5
5
  installer; git invokes this file as `pre-commit` (the installer strips the
6
6
  `_` and `.py` suffix when copying into the live hooks path).
7
7
 
8
+ The Agent Bash commit surface records passive staged-surface evidence. This
9
+ native hook invokes the shared gate and owns the staged commit decision.
10
+
8
11
  Exit codes:
9
12
  0 - staged changes pass the gate (or the gate is not installed locally).
10
13
  1 - staged changes introduce one or more blocking violations.
@@ -17,16 +20,17 @@ import subprocess
17
20
  import sys
18
21
  from pathlib import Path
19
22
 
23
+ from gate_utils import is_safe_regular_file, resolve_gate_script_path
20
24
  from git_hooks_constants import (
21
25
  GATE_INFRASTRUCTURE_FAILURE_EXIT_CODE,
22
26
  GATE_SCRIPT_NOT_FOUND_MESSAGE,
23
27
  INVOKE_GATE_FAILURE_MESSAGE,
24
28
  STAGED_SCOPE_ARGUMENT,
25
29
  )
26
- from gate_utils import is_safe_regular_file, resolve_gate_script_path
27
30
 
28
31
 
29
32
  def invoke_gate(gate_script_path: Path) -> int:
33
+ """Invoke the shared staged gate and return its exit code."""
30
34
  staged_scope_argument = STAGED_SCOPE_ARGUMENT
31
35
  invoke_gate_failure_message = INVOKE_GATE_FAILURE_MESSAGE
32
36
  gate_infrastructure_failure_exit_code = GATE_INFRASTRUCTURE_FAILURE_EXIT_CODE
@@ -1567,6 +1567,63 @@ def test_main_reads_default_branch_tracking_reference_when_origin_head_is_unset(
1567
1567
  assert exit_code == 0
1568
1568
 
1569
1569
 
1570
+ def test_main_selects_stdin_bases_for_new_and_existing_branch_pushes(
1571
+ tmp_path: Path,
1572
+ monkeypatch: pytest.MonkeyPatch,
1573
+ ) -> None:
1574
+ repository, default_branch_tip, topic_branch_tip = _build_default_branch_repository(
1575
+ tmp_path
1576
+ )
1577
+ recorded_arguments_path = tmp_path / "recorded_arguments.txt"
1578
+ recording_gate_script_path = tmp_path / "recording_gate.py"
1579
+ recording_gate_script_path.write_text(
1580
+ "import pathlib, sys\n"
1581
+ f'pathlib.Path(r"{recorded_arguments_path}").write_text('
1582
+ "'\\n'.join(sys.argv[1:]), encoding='utf-8')\n"
1583
+ "sys.exit(0)\n",
1584
+ encoding="utf-8",
1585
+ )
1586
+ monkeypatch.setenv("CODE_RULES_GATE_PATH", str(recording_gate_script_path))
1587
+ monkeypatch.chdir(repository)
1588
+ monkeypatch.setattr(
1589
+ sys,
1590
+ "stdin",
1591
+ io.StringIO(
1592
+ _push_stdin_line(
1593
+ FEATURE_BRANCH_NAME,
1594
+ topic_branch_tip,
1595
+ FEATURE_BRANCH_NAME,
1596
+ ALL_ZEROS_OBJECT_NAME,
1597
+ )
1598
+ ),
1599
+ )
1600
+
1601
+ assert pre_push.main() == 0
1602
+ assert recorded_arguments_path.read_text(encoding="utf-8").splitlines() == [
1603
+ "--base",
1604
+ git_hooks_constants.DEFAULT_REMOTE_BASE_REFERENCE,
1605
+ ]
1606
+
1607
+ monkeypatch.setattr(
1608
+ sys,
1609
+ "stdin",
1610
+ io.StringIO(
1611
+ _push_stdin_line(
1612
+ FEATURE_BRANCH_NAME,
1613
+ topic_branch_tip,
1614
+ FEATURE_BRANCH_NAME,
1615
+ NON_ZERO_REMOTE_SHA_ONE,
1616
+ )
1617
+ ),
1618
+ )
1619
+
1620
+ assert pre_push.main() == 0
1621
+ assert recorded_arguments_path.read_text(encoding="utf-8").splitlines() == [
1622
+ "--base",
1623
+ default_branch_tip,
1624
+ ]
1625
+
1626
+
1570
1627
  def test_main_skips_a_deletion_line_to_reach_the_branch_update(
1571
1628
  tmp_path: Path,
1572
1629
  monkeypatch: pytest.MonkeyPatch,