claude-dev-env 2.18.0 → 2.20.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 (133) 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 +119 -0
  5. package/.agents/skills/_shared/pr-loop/preflight-proposal.md +5 -0
  6. package/.agents/skills/e-code-review/SKILL.md +12 -1
  7. package/.agents/skills/e-code-review/reference/fix.md +5 -1
  8. package/.agents/skills/e-code-review/reference/loop.md +4 -0
  9. package/.agents/skills/e-code-review/reference/mode-contract.test.mjs +66 -0
  10. package/.agents/skills/e-code-review/reference/preflight-proposal.md +40 -0
  11. package/.agents/skills/e-code-review/reference/runner-selection.md +1 -0
  12. package/.agents/skills/pr-cleanup/SKILL.md +105 -75
  13. package/.agents/skills/pr-cleanup/reference/process-inventory.md +15 -0
  14. package/.agents/skills/pr-cleanup/reference/task-seeds.md +20 -0
  15. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/SKILL.md +18 -12
  16. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/offense-examples.md +1 -1
  17. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/task-seeds.md +3 -2
  18. package/.agents/skills/pr-refinement/SKILL.md +31 -0
  19. package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/SKILL.md +24 -7
  20. package/.agents/skills/{small-cl → pr-small-cl}/SKILL.md +1 -1
  21. package/.agents/skills/prototype/scripts/test_probe_sandbox_safety.py +33 -5
  22. package/.agents/skills/skill-builder/SKILL.md +1 -0
  23. package/.agents/skills-archived/bugteam/SKILL.md +1 -1
  24. package/.agents/skills-archived/bugteam/test_skill_additions.py +1 -1
  25. package/.agents/skills-archived/comments/SKILL.md +1 -1
  26. package/.agents/skills-archived/descriptions/SKILL.md +1 -1
  27. package/.agents/skills-archived/reviews/SKILL.md +1 -1
  28. package/AGENTS.md +1 -1
  29. package/_shared/pr-loop/AGENTS.md +2 -1
  30. package/_shared/pr-loop/preflight-proposal.md +56 -0
  31. package/_shared/pr-loop/scripts/code_rules_gate.py +29 -6
  32. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +15 -3
  33. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +47 -4
  34. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +4 -0
  35. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +47 -0
  36. package/bin/ever-shipped-skills.mjs +14 -0
  37. package/bin/install-constants.mjs +5 -0
  38. package/bin/install.agents-home.test.mjs +23 -0
  39. package/bin/install.codex-rules.test.mjs +61 -6
  40. package/bin/install.mjs +57 -9
  41. package/bin/install.prune.test.mjs +65 -3
  42. package/bin/install.test.mjs +202 -2
  43. package/docs/CODE_RULES.md +6 -0
  44. package/hooks/advisory/conftest.py +10 -0
  45. package/hooks/advisory/migration_safety_advisor.py +14 -14
  46. package/hooks/advisory/refactor_guard.py +250 -144
  47. package/hooks/advisory/refactor_guard_test_support.py +46 -0
  48. package/hooks/advisory/test_migration_safety_advisor.py +120 -0
  49. package/hooks/advisory/test_refactor_guard_advisory.py +171 -0
  50. package/hooks/advisory/test_refactor_guard_eligibility.py +166 -0
  51. package/hooks/atomic_file_writer.py +81 -0
  52. package/hooks/blocking/AGENTS.md +2 -1
  53. package/hooks/blocking/block_main_commit.py +66 -33
  54. package/hooks/blocking/code_rules_banned_identifiers.py +55 -4
  55. package/hooks/blocking/code_rules_blast_radius.py +194 -0
  56. package/hooks/blocking/code_rules_enforcer.py +12 -0
  57. package/hooks/blocking/code_rules_enforcer_test_support.py +92 -0
  58. package/hooks/blocking/destructive_command_blocker.py +8 -7
  59. package/hooks/blocking/pre_tool_use_dispatcher.py +11 -7
  60. package/hooks/blocking/precommit_code_rules_gate.py +14 -139
  61. package/hooks/blocking/sensitive_file_protector.py +8 -6
  62. package/hooks/blocking/session_edit_stage_gate.py +31 -1
  63. package/hooks/blocking/test_block_main_commit.py +145 -0
  64. package/hooks/blocking/test_claude_md_orphan_file_blocker.py +171 -1
  65. package/hooks/blocking/test_code_rules_blast_radius.py +161 -0
  66. package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +37 -12
  67. package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +54 -0
  68. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +15 -53
  69. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +13 -62
  70. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +309 -0
  71. package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +14 -71
  72. package/hooks/blocking/test_code_rules_enforcer_scratchpad.py +14 -16
  73. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +31 -46
  74. package/hooks/blocking/test_code_rules_enforcer_stage.py +192 -0
  75. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +13 -59
  76. package/hooks/blocking/test_destructive_command_blocker.py +154 -138
  77. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +85 -25
  78. package/hooks/blocking/test_destructive_command_blocker_no_verify.py +58 -38
  79. package/hooks/blocking/test_destructive_command_blocker_patterns.py +133 -0
  80. package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +113 -1
  81. package/hooks/blocking/test_env_var_table_code_drift_blocker.py +70 -3
  82. package/hooks/blocking/test_hook_subprocess_support.py +296 -0
  83. package/hooks/blocking/test_package_inventory_stale_blocker.py +1 -1
  84. package/hooks/blocking/test_pii_scanner.py +81 -0
  85. package/hooks/blocking/test_pre_tool_use_dispatcher.py +32 -3
  86. package/hooks/blocking/test_precommit_code_rules_gate.py +11 -16
  87. package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +234 -0
  88. package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +25 -18
  89. package/hooks/blocking/test_sensitive_file_protector.py +145 -5
  90. package/hooks/blocking/test_session_edit_stage_gate_staging.py +64 -0
  91. package/hooks/blocking/test_session_edit_stage_gate_support.py +204 -0
  92. package/hooks/blocking/test_session_edit_stage_gate_tracker.py +116 -0
  93. package/hooks/blocking/test_shared_stdin_adoption.py +31 -20
  94. package/hooks/blocking/test_test_preflight_check.py +80 -0
  95. package/hooks/git-hooks/AGENTS.md +1 -1
  96. package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -0
  97. package/hooks/git-hooks/post_commit.py +160 -51
  98. package/hooks/git-hooks/pre_commit.py +9 -5
  99. package/hooks/git-hooks/test_post_commit.py +203 -0
  100. package/hooks/git-hooks/test_pre_commit.py +2 -2
  101. package/hooks/git-hooks/test_pre_push.py +57 -0
  102. package/hooks/hooks_constants/AGENTS.md +3 -1
  103. package/hooks/hooks_constants/atomic_file_writer_constants.py +4 -0
  104. package/hooks/hooks_constants/banned_identifiers_constants.py +1 -0
  105. package/hooks/hooks_constants/blast_radius_constants.py +14 -0
  106. package/hooks/hooks_constants/destructive_command_environment_constants.py +18 -0
  107. package/hooks/hooks_constants/destructive_command_segment_constants.py +1 -11
  108. package/hooks/hooks_constants/hardcoded_user_path_constants.py +9 -3
  109. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +1 -1
  110. package/hooks/hooks_constants/refactor_guard_constants.py +75 -0
  111. package/hooks/hooks_constants/sensitive_file_protector_constants.py +2 -4
  112. package/hooks/hooks_constants/test_refactor_guard_constants.py +21 -0
  113. package/hooks/json_file_reader.py +22 -0
  114. package/hooks/observability/test_instructions_loaded_logger.py +54 -0
  115. package/hooks/pending_sidecars.py +33 -0
  116. package/hooks/session/test_plugin_data_dir_cleanup.py +70 -0
  117. package/hooks/session/test_session_edit_tracker_cleanup.py +16 -3
  118. package/hooks/test_atomic_file_writer.py +73 -0
  119. package/hooks/test_json_file_reader.py +24 -0
  120. package/hooks/test_pending_sidecars.py +25 -0
  121. package/hooks/validation/mypy_validator.py +213 -80
  122. package/hooks/validation/test_mypy_validator.py +288 -13
  123. package/hooks/workflow/auto_formatter.py +225 -93
  124. package/hooks/workflow/investigation_tracker_reset.py +2 -0
  125. package/hooks/workflow/test_auto_formatter.py +261 -12
  126. package/hooks/workflow/test_investigation_tracker_reset.py +90 -0
  127. package/package.json +1 -1
  128. package/rules/failure-blast-radius.md +126 -0
  129. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/fetch-commands.md +0 -0
  130. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/report-template.md +0 -0
  131. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/rule-checklist.md +0 -0
  132. /package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/reference/examples.md +0 -0
  133. /package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/reference/offense-taxonomy.md +0 -0
@@ -10,87 +10,196 @@ When you commit in a submodule, this hook:
10
10
  This prevents the "lost work" issue where submodule commits aren't tracked by parent.
11
11
  """
12
12
 
13
+ from __future__ import annotations
14
+
13
15
  import subprocess
14
16
  import sys
17
+ from enum import StrEnum
15
18
  from pathlib import Path
16
19
 
20
+ from git_hooks_constants import GIT_COMMAND_SUCCESS_EXIT_CODE, GIT_EXECUTABLE_NAME
17
21
 
18
- def run_git(*args: str, cwd: Path | None = None) -> str:
19
- """Run a git command and return output."""
20
- result = subprocess.run(
21
- ["git"] + list(args),
22
- cwd=cwd,
23
- check=False, capture_output=True,
24
- text=True,
25
- )
26
- return result.stdout.strip()
22
+
23
+ class ParentPointerStatus(StrEnum):
24
+ """States returned by a parent pointer update."""
25
+
26
+ UPDATED = "updated"
27
+ UNCHANGED = "unchanged"
28
+ FAILED = "failed"
29
+
30
+
31
+ ParentPointerUpdate = tuple[ParentPointerStatus, str]
32
+
33
+
34
+ def execute_git(
35
+ *arguments: str,
36
+ cwd: Path,
37
+ ) -> subprocess.CompletedProcess[str]:
38
+ """Run a Git command and return its completed process."""
39
+ try:
40
+ return subprocess.run(
41
+ [GIT_EXECUTABLE_NAME, *arguments],
42
+ cwd=cwd,
43
+ check=False,
44
+ capture_output=True,
45
+ text=True,
46
+ )
47
+ except OSError as error:
48
+ return subprocess.CompletedProcess(
49
+ [GIT_EXECUTABLE_NAME, *arguments],
50
+ 1,
51
+ "",
52
+ str(error),
53
+ )
54
+
55
+
56
+ def run_git(*arguments: str, cwd: Path) -> str:
57
+ """Run a successful Git command and return its trimmed standard output."""
58
+ command_result = execute_git(*arguments, cwd=cwd)
59
+ if command_result.returncode != GIT_COMMAND_SUCCESS_EXIT_CODE:
60
+ return ""
61
+ return command_result.stdout.strip()
62
+
63
+
64
+ def run_git_from_current_directory(*arguments: str) -> str:
65
+ """Run a successful Git command from the hook's current directory."""
66
+ return run_git(*arguments, cwd=Path.cwd())
27
67
 
28
68
 
29
69
  def find_parent_repo(repo_dir: Path) -> Path | None:
30
- """Find parent repo that has this as a submodule."""
31
- repo_name = repo_dir.name
32
- parent_dir = repo_dir.parent
70
+ """Return the Git superproject that owns the current repository."""
71
+ parent_path_text = run_git(
72
+ "rev-parse",
73
+ "--show-superproject-working-tree",
74
+ cwd=repo_dir,
75
+ )
76
+ if not parent_path_text:
77
+ return None
78
+
79
+ parent_path = Path(parent_path_text).resolve()
80
+ if not parent_path.is_dir():
81
+ return None
82
+ return parent_path
83
+
84
+
85
+ def find_submodule_path(parent_repo: Path, repo_dir: Path) -> Path | None:
86
+ """Return the submodule path relative to its parent repository."""
87
+ try:
88
+ return repo_dir.resolve().relative_to(parent_repo.resolve())
89
+ except ValueError:
90
+ return None
91
+
33
92
 
34
- while parent_dir != parent_dir.parent:
35
- git_path = parent_dir / ".git"
36
- gitmodules_path = parent_dir / ".gitmodules"
93
+ def build_literal_pathspec(submodule_path: Path) -> str:
94
+ """Build a Git pathspec that treats every path character literally."""
95
+ return f":(literal){submodule_path.as_posix()}"
37
96
 
38
- if git_path.exists() and gitmodules_path.exists():
39
- try:
40
- content = gitmodules_path.read_text()
41
- if f"path = {repo_name}" in content:
42
- return parent_dir
43
- except Exception:
44
- pass
45
97
 
46
- parent_dir = parent_dir.parent
98
+ def get_git_failure_diagnostic(
99
+ command_result: subprocess.CompletedProcess[str],
100
+ ) -> str:
101
+ """Return the most useful diagnostic from a failed Git command."""
102
+ return command_result.stderr.strip() or command_result.stdout.strip()
47
103
 
48
- return None
104
+
105
+ def build_parent_commit_message(
106
+ repo_name: str,
107
+ commit_hash: str,
108
+ commit_message: str,
109
+ ) -> str:
110
+ """Build the parent commit message for a submodule pointer update."""
111
+ return (
112
+ f"chore: update {repo_name} submodule to {commit_hash}\n\n"
113
+ f"Submodule commit: {commit_message}\n\n"
114
+ "Co-Authored-By: Claude <noreply@anthropic.com>"
115
+ )
116
+
117
+
118
+ def update_parent_pointer(
119
+ parent_repo: Path,
120
+ repo_dir: Path,
121
+ commit_hash: str,
122
+ commit_message: str,
123
+ ) -> ParentPointerUpdate:
124
+ """Commit the current submodule pointer in its parent repository."""
125
+ submodule_path = find_submodule_path(parent_repo, repo_dir)
126
+ if submodule_path is None:
127
+ return ParentPointerStatus.FAILED, "could not resolve the submodule path"
128
+
129
+ submodule_reference = build_literal_pathspec(submodule_path)
130
+ add_result = execute_git("add", "--", submodule_reference, cwd=parent_repo)
131
+ if add_result.returncode != GIT_COMMAND_SUCCESS_EXIT_CODE:
132
+ return ParentPointerStatus.FAILED, get_git_failure_diagnostic(add_result)
133
+
134
+ staged_difference = execute_git(
135
+ "diff",
136
+ "--cached",
137
+ "--quiet",
138
+ "--",
139
+ submodule_reference,
140
+ cwd=parent_repo,
141
+ )
142
+ if staged_difference.returncode == GIT_COMMAND_SUCCESS_EXIT_CODE:
143
+ return ParentPointerStatus.UNCHANGED, ""
144
+ if staged_difference.returncode != 1:
145
+ return ParentPointerStatus.FAILED, get_git_failure_diagnostic(staged_difference)
146
+
147
+ parent_commit_message = build_parent_commit_message(
148
+ repo_dir.name,
149
+ commit_hash,
150
+ commit_message,
151
+ )
152
+ commit_result = execute_git(
153
+ "commit",
154
+ "--only",
155
+ "-m",
156
+ parent_commit_message,
157
+ "--",
158
+ submodule_reference,
159
+ cwd=parent_repo,
160
+ )
161
+ if commit_result.returncode != GIT_COMMAND_SUCCESS_EXIT_CODE:
162
+ return ParentPointerStatus.FAILED, get_git_failure_diagnostic(commit_result)
163
+ return ParentPointerStatus.UPDATED, ""
49
164
 
50
165
 
51
166
  def main() -> int:
52
- """Main hook logic."""
53
- try:
54
- repo_dir = Path(run_git("rev-parse", "--show-toplevel"))
55
- except Exception:
167
+ """Update a parent repository after a submodule commit."""
168
+ repo_path_text = run_git_from_current_directory("rev-parse", "--show-toplevel")
169
+ if not repo_path_text:
56
170
  return 0
57
171
 
58
- repo_name = repo_dir.name
172
+ repo_dir = Path(repo_path_text).resolve()
59
173
  parent_repo = find_parent_repo(repo_dir)
60
-
61
- if not parent_repo:
174
+ if parent_repo is None:
62
175
  return 0
63
176
 
64
177
  commit_msg = run_git("log", "-1", "--pretty=%s", cwd=repo_dir)
65
- commit_hash = run_git("rev-parse", "--short", "HEAD", cwd=repo_dir)
178
+ commit_hash = run_git("rev-parse", "HEAD", cwd=repo_dir)
179
+ short_commit_hash = run_git("rev-parse", "--short", "HEAD", cwd=repo_dir)
180
+ if not commit_hash or not short_commit_hash:
181
+ return 0
66
182
 
67
183
  print()
68
184
  print("=== Submodule Parent Update ===")
69
- print(f"Submodule: {repo_name} @ {commit_hash}")
185
+ print(f"Submodule: {repo_dir.name} @ {short_commit_hash}")
70
186
  print(f"Parent: {parent_repo}")
71
187
 
72
- run_git("add", repo_name, cwd=parent_repo)
73
-
74
- diff_result = subprocess.run(
75
- ["git", "diff", "--cached", "--quiet"],
76
- check=False, cwd=parent_repo,
188
+ parent_pointer_status, parent_pointer_diagnostic = update_parent_pointer(
189
+ parent_repo,
190
+ repo_dir,
191
+ commit_hash,
192
+ commit_msg,
77
193
  )
78
-
79
- if diff_result.returncode == 0:
194
+ if parent_pointer_status is ParentPointerStatus.FAILED:
195
+ print("Parent update failed.")
196
+ if parent_pointer_diagnostic:
197
+ print(f"Git diagnostic: {parent_pointer_diagnostic}")
198
+ return 0
199
+ if parent_pointer_status is ParentPointerStatus.UNCHANGED:
80
200
  print("Parent already up to date.")
81
201
  return 0
82
202
 
83
- full_commit_msg = f"""chore: update {repo_name} submodule to {commit_hash}
84
-
85
- Submodule commit: {commit_msg}
86
-
87
- Co-Authored-By: Claude <noreply@anthropic.com>"""
88
-
89
- subprocess.run(
90
- ["git", "commit", "-m", full_commit_msg],
91
- check=False, cwd=parent_repo,
92
- )
93
-
94
203
  print("Parent updated successfully.")
95
204
  print("================================")
96
205
  print()
@@ -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,23 +20,24 @@ 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,
27
+ IMMEDIATE_SCOPE_ARGUMENT,
23
28
  INVOKE_GATE_FAILURE_MESSAGE,
24
- 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:
30
- staged_scope_argument = STAGED_SCOPE_ARGUMENT
33
+ """Invoke the shared staged gate and return its exit code."""
34
+ immediate_scope_argument = IMMEDIATE_SCOPE_ARGUMENT
31
35
  invoke_gate_failure_message = INVOKE_GATE_FAILURE_MESSAGE
32
36
  gate_infrastructure_failure_exit_code = GATE_INFRASTRUCTURE_FAILURE_EXIT_CODE
33
37
  try:
34
38
  resolved_gate_path = gate_script_path.resolve(strict=True)
35
39
  completion = subprocess.run(
36
- [sys.executable, str(resolved_gate_path), staged_scope_argument],
40
+ [sys.executable, str(resolved_gate_path), immediate_scope_argument],
37
41
  check=False,
38
42
  )
39
43
  except OSError as launch_error:
@@ -0,0 +1,203 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+ import post_commit
8
+ import pytest
9
+ from git_hooks_constants import GIT_EXECUTABLE_NAME
10
+
11
+
12
+ def build_fixture_git_environment() -> dict[str, str]:
13
+ """Copy the process environment without inherited Git state overrides."""
14
+ return {
15
+ each_name: each_value
16
+ for each_name, each_value in os.environ.items()
17
+ if not each_name.upper().startswith("GIT_")
18
+ }
19
+
20
+
21
+ def clear_inherited_git_environment(monkeypatch: pytest.MonkeyPatch) -> None:
22
+ """Remove inherited Git state overrides from the current test process."""
23
+ for each_name in list(os.environ):
24
+ if each_name.upper().startswith("GIT_"):
25
+ monkeypatch.delenv(each_name, raising=False)
26
+
27
+
28
+ def test_fixture_git_environment_preserves_process_execution_path(
29
+ monkeypatch: pytest.MonkeyPatch,
30
+ ) -> None:
31
+ """The fixture environment keeps PATH while removing Git overrides."""
32
+ monkeypatch.setenv("PATH", "fixture-execution-path")
33
+ monkeypatch.setenv("GIT_DIR", "unrelated-repository")
34
+
35
+ fixture_environment = build_fixture_git_environment()
36
+
37
+ assert fixture_environment["PATH"] == "fixture-execution-path"
38
+ assert all(not each_name.upper().startswith("GIT_") for each_name in fixture_environment)
39
+
40
+
41
+ def run_fixture_git(repository_path: Path, *arguments: str) -> str:
42
+ """Run Git in a fixture repository and return standard output."""
43
+ completed_process = subprocess.run(
44
+ [GIT_EXECUTABLE_NAME, *arguments],
45
+ cwd=repository_path,
46
+ check=False,
47
+ capture_output=True,
48
+ text=True,
49
+ env=build_fixture_git_environment(),
50
+ )
51
+ assert completed_process.returncode == 0, completed_process.stderr
52
+ return completed_process.stdout.strip()
53
+
54
+
55
+ def configure_fixture_identity(repository_path: Path) -> None:
56
+ """Configure a stable identity for a fixture repository."""
57
+ run_fixture_git(repository_path, "config", "user.name", "Fixture User")
58
+ run_fixture_git(
59
+ repository_path,
60
+ "config",
61
+ "user.email",
62
+ "fixture@example.invalid",
63
+ )
64
+ run_fixture_git(repository_path, "config", "commit.gpgsign", "false")
65
+ fixture_hooks_directory = repository_path / ".fixture-hooks"
66
+ fixture_hooks_directory.mkdir(exist_ok=True)
67
+ run_fixture_git(
68
+ repository_path,
69
+ "config",
70
+ "core.hooksPath",
71
+ str(fixture_hooks_directory),
72
+ )
73
+
74
+
75
+ def initialize_fixture_repository(repository_path: Path) -> None:
76
+ """Create and configure a fixture Git repository."""
77
+ repository_path.mkdir()
78
+ run_fixture_git(repository_path, "init", "-b", "main")
79
+ configure_fixture_identity(repository_path)
80
+
81
+
82
+ def test_native_submodule_commit_records_parent_pointer(
83
+ tmp_path: Path,
84
+ monkeypatch: pytest.MonkeyPatch,
85
+ ) -> None:
86
+ """A real submodule commit creates the matching parent pointer commit."""
87
+ unrelated_repository = tmp_path / "unrelated-repository"
88
+ initialize_fixture_repository(unrelated_repository)
89
+ monkeypatch.setenv("GIT_DIR", str(unrelated_repository / ".git"))
90
+
91
+ source_repository = tmp_path / "source-repository"
92
+ parent_repository = tmp_path / "parent-repository"
93
+ initialize_fixture_repository(source_repository)
94
+ (source_repository / "README.md").write_text("fixture source\n", encoding="utf-8")
95
+ run_fixture_git(source_repository, "add", "README.md")
96
+ run_fixture_git(source_repository, "commit", "-m", "Create fixture source")
97
+
98
+ initialize_fixture_repository(parent_repository)
99
+ (parent_repository / "README.md").write_text("fixture parent\n", encoding="utf-8")
100
+ run_fixture_git(parent_repository, "add", "README.md")
101
+ run_fixture_git(parent_repository, "commit", "-m", "Create fixture parent")
102
+ run_fixture_git(
103
+ parent_repository,
104
+ "-c",
105
+ "protocol.file.allow=always",
106
+ "submodule",
107
+ "add",
108
+ str(source_repository),
109
+ "nested/sub[module]",
110
+ )
111
+ run_fixture_git(parent_repository, "commit", "-am", "Add fixture submodule")
112
+
113
+ submodule_repository = parent_repository / "nested" / "sub[module]"
114
+ configure_fixture_identity(submodule_repository)
115
+ (parent_repository / "staged-note.txt").write_text("keep staged\n", encoding="utf-8")
116
+ run_fixture_git(parent_repository, "add", "staged-note.txt")
117
+ (submodule_repository / "change.txt").write_text("fixture change\n", encoding="utf-8")
118
+ run_fixture_git(submodule_repository, "add", "change.txt")
119
+ run_fixture_git(submodule_repository, "commit", "-m", "Record fixture change")
120
+ clear_inherited_git_environment(monkeypatch)
121
+ monkeypatch.chdir(submodule_repository)
122
+ assert post_commit.main() == 0
123
+
124
+ submodule_commit_hash = run_fixture_git(submodule_repository, "rev-parse", "HEAD")
125
+ parent_commit_subject = run_fixture_git(parent_repository, "log", "-1", "--pretty=%s")
126
+ parent_commit_body = run_fixture_git(parent_repository, "log", "-1", "--pretty=%B")
127
+ recorded_pointer_hash = run_fixture_git(
128
+ parent_repository,
129
+ "rev-parse",
130
+ "HEAD:nested/sub[module]",
131
+ )
132
+
133
+ assert recorded_pointer_hash == submodule_commit_hash
134
+ assert parent_commit_subject == (
135
+ f"chore: update sub[module] submodule to {submodule_commit_hash}"
136
+ )
137
+ assert "Submodule commit: Record fixture change" in parent_commit_body
138
+ assert run_fixture_git(parent_repository, "status", "--short") == "A staged-note.txt"
139
+
140
+
141
+ def test_parent_update_returns_git_diagnostic_for_failed_add(
142
+ tmp_path: Path,
143
+ monkeypatch: pytest.MonkeyPatch,
144
+ ) -> None:
145
+ """A failed parent add returns the Git diagnostic and literal pathspec."""
146
+ parent_repository = tmp_path / "parent-repository"
147
+ submodule_repository = parent_repository / "sub[module]"
148
+ submodule_repository.mkdir(parents=True)
149
+ recorded_commands: list[tuple[str, ...]] = []
150
+
151
+ def fail_git_add(*arguments: str, cwd: Path) -> subprocess.CompletedProcess[str]:
152
+ recorded_commands.append(arguments)
153
+ return subprocess.CompletedProcess(
154
+ ["git", *arguments],
155
+ 1,
156
+ "",
157
+ "fatal: fixture add failure",
158
+ )
159
+
160
+ monkeypatch.setattr(post_commit, "execute_git", fail_git_add)
161
+
162
+ parent_update = post_commit.update_parent_pointer(
163
+ parent_repository,
164
+ submodule_repository,
165
+ "a" * 40,
166
+ "Fixture commit",
167
+ )
168
+
169
+ assert parent_update == (
170
+ post_commit.ParentPointerStatus.FAILED,
171
+ "fatal: fixture add failure",
172
+ )
173
+ assert recorded_commands == [("add", "--", ":(literal)sub[module]")]
174
+
175
+
176
+ def test_main_prints_git_failure_diagnostic(
177
+ tmp_path: Path,
178
+ monkeypatch: pytest.MonkeyPatch,
179
+ capsys: pytest.CaptureFixture[str],
180
+ ) -> None:
181
+ """The hook prints the Git diagnostic when the parent update fails."""
182
+ submodule_repository = tmp_path / "submodule"
183
+ parent_repository = tmp_path / "parent"
184
+ submodule_repository.mkdir()
185
+ parent_repository.mkdir()
186
+ monkeypatch.setattr(
187
+ post_commit,
188
+ "run_git_from_current_directory",
189
+ lambda *arguments: str(submodule_repository),
190
+ )
191
+ monkeypatch.setattr(post_commit, "find_parent_repo", lambda repo: parent_repository)
192
+ monkeypatch.setattr(post_commit, "run_git", lambda *arguments, cwd: "a" * 40)
193
+ monkeypatch.setattr(
194
+ post_commit,
195
+ "update_parent_pointer",
196
+ lambda *arguments: (
197
+ post_commit.ParentPointerStatus.FAILED,
198
+ "fatal: fixture commit failure",
199
+ ),
200
+ )
201
+
202
+ assert post_commit.main() == 0
203
+ assert "Git diagnostic: fatal: fixture commit failure" in capsys.readouterr().out
@@ -60,7 +60,7 @@ def test_main_propagates_passing_exit_code_from_gate(
60
60
  assert exit_code == 0
61
61
 
62
62
 
63
- def test_main_invokes_gate_with_staged_flag(
63
+ def test_main_invokes_gate_with_immediate_flag(
64
64
  tmp_path: Path,
65
65
  monkeypatch: pytest.MonkeyPatch,
66
66
  ) -> None:
@@ -84,7 +84,7 @@ def test_main_invokes_gate_with_staged_flag(
84
84
  recorded_arguments = recorded_arguments_path.read_text(
85
85
  encoding="utf-8"
86
86
  ).splitlines()
87
- assert recorded_arguments == ["--staged"]
87
+ assert recorded_arguments == ["--immediate"]
88
88
 
89
89
 
90
90
  def test_main_exits_two_when_invoke_gate_raises_oserror(
@@ -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,
@@ -11,6 +11,7 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
11
11
  | `ask_user_question_shape.py` | Pure AskUserQuestion lean-block shape analyzer: grades question and option-description prose against shared chat-detail markers and sentence/word caps with no hook I/O |
12
12
  | `banned_identifiers_constants.py` | The set of banned short identifiers and banned function-name prefixes |
13
13
  | `bash_pre_tool_use_dispatcher_constants.py` | Permission outcomes, tool-name sets, and the ordered hosted-hook roster for the Bash PreToolUse dispatcher (also covers PowerShell-shared gates) |
14
+ | `pre_tool_use_dispatcher_constants.py` | Shared Write, Edit, and MultiEdit tool-name set and the ordered hosted-hook roster for the Write/Edit/MultiEdit PreToolUse dispatcher |
14
15
  | `blocking_check_limits.py` | Max issue counts and preview lengths for blocking hooks |
15
16
  | `bot_mention_comment_blocker_constants.py` | Patterns for detecting bot @-mentions in PR comments |
16
17
  | `claude_md_orphan_file_blocker_constants.py` | Table patterns, file extensions, scan budget, and block-message text for the CLAUDE.md orphan-file blocker |
@@ -24,6 +25,7 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
24
25
  | `dead_config_field_constants.py` | Patterns for detecting unused `*Config` / `*Selectors` dataclass fields |
25
26
  | `dead_dataclass_field_constants.py` | Patterns for detecting unused dataclass fields |
26
27
  | `dead_module_constant_constants.py` | Patterns for detecting unexported `UPPER_SNAKE` constants in `*_constants.py` modules |
28
+ | `destructive_command_environment_constants.py` | Environment keys and shared truthy values used by destructive-command hooks |
27
29
  | `destructive_command_segment_constants.py` | The list of destructive shell command patterns the blocker matches |
28
30
  | `docstring_rule_gate_count_blocker_constants.py` | Target rule basename, spelled-out-number lookup, count-clause and `check_*` validator patterns, and block-message text for the docstring-rule gate-count staleness blocker |
29
31
  | `duplicate_function_body_constants.py` | Hashing and comparison config for the duplicate-body check |
@@ -62,7 +64,7 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
62
64
  | `python_style_checks_constants.py` | Command-line argument count and blank-line count between top-level functions for the style validator |
63
65
  | `ruff_integration_constants.py` | ``NO_COLOR`` / ``FORCE_COLOR`` environment variable names for plain ruff diagnostics |
64
66
  | `send_user_file_open_locally_blocker_constants.py` | Tool name, proactive status, and the block message for the open-locally attach blocker |
65
- | `sensitive_file_protector_constants.py` | Sensitive filename patterns, the committed-template suffixes that earn an exemption, the write/edit tool names, and the deny decision and message template for `sensitive_file_protector` |
67
+ | `sensitive_file_protector_constants.py` | Sensitive filename patterns, committed-template suffixes that earn an exemption, and the deny decision and message template for `sensitive_file_protector` |
66
68
  | `session_edit_stage_gate_constants.py` | Tracker filename prefix/suffix, JSON payload key, edit tool name set, session-id sanitize pattern, lock filename suffix and lock-acquire timing, git diff command, commit flag escapes, and deny-message template shared by the session edit stage gate trio |
67
69
  | `session_env_cleanup_constants.py` | Stale-age threshold and directory names for the session-env cleanup hook |
68
70
  | `session_start_injector.py` | Pure SessionStart context injector: normalize `source` (`startup`/`resume`/`clear`/`compact`), honor enable/timeout, return structured injection results for starter hooks |
@@ -0,0 +1,4 @@
1
+ """Constants for atomic hook-state text replacement."""
2
+
3
+ TEXT_WRITE_MODE = "w"
4
+ ATOMIC_WRITE_TEMPORARY_SUFFIX = ".tmp"
@@ -38,6 +38,7 @@ ALL_BANNED_NOUN_WORDS: frozenset[str] = frozenset(
38
38
  "temp",
39
39
  }
40
40
  )
41
+ BANNED_NOUN_COMPATIBILITY_COMMENT: str = "# pragma: no-banned-noun"
41
42
  CAMEL_CASE_WORD_PATTERN: re.Pattern[str] = re.compile(
42
43
  r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+"
43
44
  )
@@ -0,0 +1,14 @@
1
+ """Constants for the blast-radius declaration check."""
2
+
3
+ BLAST_RADIUS_RUN_SUFFIX = "RunFatal"
4
+ BLAST_RADIUS_ITEM_SUFFIX = "ItemBlocked"
5
+ ALL_BLAST_RADIUS_SUFFIXES = (BLAST_RADIUS_RUN_SUFFIX, BLAST_RADIUS_ITEM_SUFFIX)
6
+
7
+ MAX_BLAST_RADIUS_ISSUES = 20
8
+
9
+ BLAST_RADIUS_MESSAGE_SUFFIX = (
10
+ "raises inside per-item work; name its blast radius by ending the type in "
11
+ f"{BLAST_RADIUS_RUN_SUFFIX} when the whole run stops, or {BLAST_RADIUS_ITEM_SUFFIX} "
12
+ "when this one item stops and the batch carries on. See "
13
+ "rules/failure-blast-radius.md."
14
+ )
@@ -0,0 +1,18 @@
1
+ """Environment keys and values used by destructive-command hooks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ ALL_KNOWN_TEMPORARY_ENVIRONMENT_VARIABLE_NAMES: frozenset[str] = frozenset(
6
+ {
7
+ "TEMP",
8
+ "TMP",
9
+ "TMPDIR",
10
+ "CLAUDE_JOB_DIR",
11
+ }
12
+ )
13
+ ALL_TRUTHY_ENV_VALUES: frozenset[str] = frozenset({"1", "true", "yes", "on"})
14
+ DESTRUCTIVE_DENY_MODE_ENV_VAR: str = "CLAUDE_DESTRUCTIVE_DENY_MODE"
15
+ EPHEMERAL_AUTO_ALLOW_DISABLE_ENV_VAR: str = (
16
+ "CLAUDE_DESTRUCTIVE_DISABLE_EPHEMERAL_AUTO_ALLOW"
17
+ )
18
+ GH_REDIRECT_ACTIVE_ENV_VAR: str = "CLAUDE_GH_REDIRECT_ACTIVE"
@@ -1,4 +1,4 @@
1
- """Segment-splitting, command-name, and deny-mode constants for the destructive command blocker."""
1
+ """Segment-splitting and command-name constants for the destructive command blocker."""
2
2
 
3
3
  ALL_SHELL_CONTROL_OPERATOR_TOKENS: frozenset[str] = frozenset({"&&", "||", ";", "|&", "|", "&", "\n", "\r"})
4
4
  ALL_COMMAND_LAUNCHER_WRAPPER_COMMANDS: frozenset[str] = frozenset(
@@ -183,13 +183,3 @@ ALL_LAUNCHER_OPTIONS_TAKING_SEPARATE_VALUE: frozenset[str] = frozenset(
183
183
  }
184
184
  )
185
185
  ALL_SUBSHELL_GROUPING_CHARACTERS: str = "({"
186
- ALL_KNOWN_TEMPORARY_ENVIRONMENT_VARIABLE_NAMES: frozenset[str] = frozenset(
187
- {
188
- "TEMP",
189
- "TMP",
190
- "TMPDIR",
191
- "CLAUDE_JOB_DIR",
192
- }
193
- )
194
- DESTRUCTIVE_DENY_MODE_ENV_VAR: str = "CLAUDE_DESTRUCTIVE_DENY_MODE"
195
- ALL_TRUTHY_ENV_VALUES: frozenset[str] = frozenset({"1", "true", "yes", "on"})