claude-dev-env 2.30.0 → 2.31.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 (128) hide show
  1. package/.agents/agents/pr-description-writer.md +1 -1
  2. package/.agents/skills/e-simplify/SKILL.md +20 -8
  3. package/AGENTS.md +6 -0
  4. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +37 -0
  5. package/bin/install.prune.test.mjs +4 -2
  6. package/docs/CODE_RULES.md +11 -0
  7. package/hooks/advisory/migration_safety_advisor.py +25 -1
  8. package/hooks/advisory/refactor_guard.py +85 -18
  9. package/hooks/advisory/test_migration_safety_advisor.py +89 -5
  10. package/hooks/advisory/test_refactor_guard.py +87 -0
  11. package/hooks/advisory/test_refactor_guard_advisory.py +72 -1
  12. package/hooks/blocking/code_rules_constants_config.py +7 -6
  13. package/hooks/blocking/code_rules_enforcer.py +59 -36
  14. package/hooks/blocking/codex_apply_patch.py +136 -21
  15. package/hooks/blocking/config/codex_apply_patch_constants.py +13 -0
  16. package/hooks/blocking/conftest.py +70 -0
  17. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +26 -16
  18. package/hooks/blocking/hook_prose_detector_consistency.py +30 -20
  19. package/hooks/blocking/pii_payload_scan.py +61 -0
  20. package/hooks/blocking/pii_prevention_blocker.py +5 -0
  21. package/hooks/blocking/pr_description_writer_gate.py +263 -0
  22. package/hooks/blocking/pre_tool_use_dispatcher.py +1 -1
  23. package/hooks/blocking/sensitive_file_protector.py +18 -3
  24. package/hooks/blocking/shell_substitution_blocker.py +62 -8
  25. package/hooks/blocking/stale_comment_reference_blocker.py +94 -24
  26. package/hooks/blocking/state_description_blocker.py +39 -26
  27. package/hooks/blocking/subprocess_budget_completeness.py +17 -43
  28. package/hooks/blocking/tdd_enforcer.py +57 -14
  29. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +11 -1
  30. package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +180 -0
  31. package/hooks/blocking/tdd_enforcer_parts/decisions.py +6 -2
  32. package/hooks/blocking/tdd_enforcer_parts/freshness.py +18 -3
  33. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +66 -1
  34. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +143 -0
  35. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +12 -0
  36. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +63 -0
  37. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +15 -0
  38. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +81 -2
  39. package/hooks/blocking/test_code_rules_enforcer_codex_apply_patch.py +35 -0
  40. package/hooks/blocking/test_code_rules_enforcer_config_path.py +29 -16
  41. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +2 -0
  42. package/hooks/blocking/test_code_rules_enforcer_multi_edit.py +118 -0
  43. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +2 -0
  44. package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +2 -0
  45. package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +29 -28
  46. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +2 -0
  47. package/hooks/blocking/test_code_rules_enforcer_stage.py +2 -0
  48. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +2 -0
  49. package/hooks/blocking/test_code_rules_validation_phase.py +51 -0
  50. package/hooks/blocking/test_codex_apply_patch.py +43 -0
  51. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +68 -0
  52. package/hooks/blocking/test_hook_prose_detector_consistency.py +41 -0
  53. package/hooks/blocking/test_pii_payload_scan.py +65 -2
  54. package/hooks/blocking/test_pii_prevention_blocker.py +80 -10
  55. package/hooks/blocking/test_pr_description_writer_gate.py +216 -0
  56. package/hooks/blocking/test_pre_tool_use_dispatcher.py +215 -25
  57. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +9 -4
  58. package/hooks/blocking/test_sensitive_file_protector.py +42 -2
  59. package/hooks/blocking/test_shell_substitution_blocker.py +69 -0
  60. package/hooks/blocking/test_stale_comment_reference_blocker.py +71 -0
  61. package/hooks/blocking/test_state_description_blocker.py +68 -0
  62. package/hooks/blocking/test_subprocess_budget_completeness.py +67 -0
  63. package/hooks/blocking/test_tdd_enforcer.py +182 -2
  64. package/hooks/blocking/test_windows_rmtree_blocker.py +63 -0
  65. package/hooks/blocking/test_write_existing_file_blocker.py +122 -0
  66. package/hooks/blocking/windows_rmtree_blocker.py +21 -7
  67. package/hooks/blocking/write_existing_file_blocker.py +75 -29
  68. package/hooks/conftest.py +47 -0
  69. package/hooks/hooks.json +8 -13
  70. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +3 -0
  71. package/hooks/hooks_constants/code_rules_enforcer_constants.py +4 -1
  72. package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +3 -0
  73. package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +7 -2
  74. package/hooks/hooks_constants/migration_safety_advisor_constants.py +7 -0
  75. package/hooks/hooks_constants/multi_edit_reconstruction.py +44 -3
  76. package/hooks/hooks_constants/pii_prevention_constants.py +9 -7
  77. package/hooks/hooks_constants/pr_description_writer_gate_constants.py +39 -0
  78. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +31 -14
  79. package/hooks/hooks_constants/session_edit_stage_gate_constants.py +15 -1
  80. package/hooks/hooks_constants/shell_substitution_blocker_constants.py +4 -1
  81. package/hooks/hooks_constants/state_description_blocker_constants.py +1 -0
  82. package/hooks/hooks_constants/subprocess_budget_completeness_content.py +71 -0
  83. package/hooks/hooks_constants/task_tool_prompt_constants.py +13 -0
  84. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +2 -0
  85. package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +2 -9
  86. package/hooks/hooks_constants/test_multi_edit_reconstruction.py +102 -0
  87. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +139 -25
  88. package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +70 -0
  89. package/hooks/hooks_constants/validation_phase_constants.py +2 -0
  90. package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +7 -3
  91. package/hooks/observability/pr_description_writer_spawn_tracker.py +121 -0
  92. package/hooks/observability/session_file_edit_tracker.py +25 -4
  93. package/hooks/observability/test_pr_description_writer_spawn_tracker.py +146 -0
  94. package/hooks/observability/test_session_file_edit_tracker.py +32 -0
  95. package/hooks/session/task_tool_prompt.py +37 -0
  96. package/hooks/session/test_task_tool_prompt.py +65 -0
  97. package/hooks/validation/hook_format_validator.py +23 -9
  98. package/hooks/validation/test_hook_format_validator.py +58 -0
  99. package/hooks/validators/README.md +1 -1
  100. package/hooks/validators/abbreviation_checks.py +3 -5
  101. package/hooks/validators/config/abbreviation_checks_constants.py +3 -0
  102. package/hooks/validators/config/fast_save_validator_constants.py +32 -0
  103. package/hooks/validators/config/hook_timing_harness_constants.py +3 -1
  104. package/hooks/validators/config/test_abbreviation_checks_constants.py +11 -0
  105. package/hooks/validators/fast_save_validators.py +332 -0
  106. package/hooks/validators/hook_timing_harness.py +8 -4
  107. package/hooks/validators/python_style_checks.py +34 -60
  108. package/hooks/validators/python_style_helpers.py +16 -2
  109. package/hooks/validators/python_style_import_bootstrap.py +83 -0
  110. package/hooks/validators/run_all_validators.py +42 -41
  111. package/hooks/validators/test_abbreviation_checks.py +20 -0
  112. package/hooks/validators/test_fast_save_validators.py +172 -0
  113. package/hooks/validators/test_python_style_checks.py +9 -0
  114. package/hooks/validators/test_python_style_checks_import_order.py +81 -0
  115. package/hooks/validators/test_python_style_helpers.py +35 -0
  116. package/hooks/validators/test_python_style_import_bootstrap.py +69 -0
  117. package/hooks/validators/test_run_all_validators.py +46 -0
  118. package/hooks/validators/test_run_all_validators_pretooluse.py +2 -0
  119. package/package.json +1 -1
  120. package/rules/docstring-prose-matches-implementation.md +1 -1
  121. package/rules/git-workflow.md +2 -0
  122. package/rules/testing.md +4 -0
  123. package/scripts/codex_compat_materializer.py +5 -0
  124. package/scripts/tests/test_codex_compat_materializer.py +37 -11
  125. package/hooks/blocking/tests/conftest.py +0 -10
  126. package/hooks/hooks_constants/task_list_loop_starter_constants.py +0 -19
  127. package/hooks/session/task_list_loop_starter.py +0 -45
  128. package/hooks/session/test_task_list_loop_starter.py +0 -63
@@ -0,0 +1,122 @@
1
+ """Tests for the write_existing_file_blocker hook.
2
+
3
+ Each case drives the real hook's ``main()`` through its production stdin path,
4
+ feeding it the PreToolUse JSON payload Claude Code sends and reading the
5
+ permission decision back off stdout.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ import io
12
+ import json
13
+ from collections.abc import Mapping
14
+ from pathlib import Path
15
+
16
+ import pytest
17
+
18
+ import write_existing_file_blocker
19
+
20
+
21
+ def _run_payload(
22
+ payload: Mapping[str, object],
23
+ monkeypatch: pytest.MonkeyPatch,
24
+ capsys: pytest.CaptureFixture[str],
25
+ ) -> str:
26
+ """Run the real hook entry point and return its stdout."""
27
+ monkeypatch.setattr(write_existing_file_blocker.sys, "stdin", io.StringIO(json.dumps(payload)))
28
+ with contextlib.suppress(SystemExit):
29
+ write_existing_file_blocker.main()
30
+ return capsys.readouterr().out
31
+
32
+
33
+ def _is_deny(stdout_text: str) -> bool:
34
+ if not stdout_text.strip():
35
+ return False
36
+ decision = json.loads(stdout_text)
37
+ return decision["hookSpecificOutput"]["permissionDecision"] == "deny"
38
+
39
+
40
+ def test_write_denies_an_existing_target(
41
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
42
+ ) -> None:
43
+ """A Write onto a path that already exists is denied."""
44
+ existing_path = tmp_path / "already_here.py"
45
+ existing_path.write_text("x = 1\n", encoding="utf-8")
46
+ payload = {
47
+ "tool_name": "Write",
48
+ "tool_input": {"file_path": str(existing_path), "content": "x = 2\n"},
49
+ }
50
+
51
+ stdout = _run_payload(payload, monkeypatch, capsys)
52
+
53
+ assert _is_deny(stdout), f"expected a deny, got {stdout!r}"
54
+
55
+
56
+ def test_apply_patch_add_denies_an_existing_target(
57
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
58
+ ) -> None:
59
+ """An apply_patch 'Add File' section targeting an existing path is denied."""
60
+ existing_path = tmp_path / "already_here.py"
61
+ existing_path.write_text("x = 1\n", encoding="utf-8")
62
+ payload = {
63
+ "tool_name": "apply_patch",
64
+ "cwd": str(tmp_path),
65
+ "tool_input": {
66
+ "command": ("*** Begin Patch\n*** Add File: already_here.py\n+x = 2\n*** End Patch")
67
+ },
68
+ }
69
+
70
+ stdout = _run_payload(payload, monkeypatch, capsys)
71
+
72
+ assert _is_deny(stdout), f"expected a deny, got {stdout!r}"
73
+
74
+
75
+ def test_apply_patch_update_on_an_existing_target_is_allowed(
76
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
77
+ ) -> None:
78
+ """An 'Update File' section on an existing path is not an unread overwrite."""
79
+ existing_path = tmp_path / "already_here.py"
80
+ existing_path.write_text("x = 1\n", encoding="utf-8")
81
+ payload = {
82
+ "tool_name": "apply_patch",
83
+ "cwd": str(tmp_path),
84
+ "tool_input": {
85
+ "command": (
86
+ "*** Begin Patch\n"
87
+ "*** Update File: already_here.py\n"
88
+ "@@\n"
89
+ "-x = 1\n"
90
+ "+x = 2\n"
91
+ "*** End Patch"
92
+ )
93
+ },
94
+ }
95
+
96
+ stdout = _run_payload(payload, monkeypatch, capsys)
97
+
98
+ assert not _is_deny(stdout), f"expected an allow, got {stdout!r}"
99
+
100
+
101
+ def test_multi_edit_on_an_existing_target_is_allowed(
102
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
103
+ ) -> None:
104
+ """A MultiEdit always targets a file already on disk, so it is never denied here.
105
+
106
+ Every MultiEdit edit must match an ``old_string`` already present in the
107
+ file, so MultiEdit carries no blind create-or-clobber path the way a Write
108
+ or an apply_patch 'Add File' does — this hook allows it unconditionally.
109
+ """
110
+ existing_path = tmp_path / "already_here.py"
111
+ existing_path.write_text("x = 1\n", encoding="utf-8")
112
+ payload = {
113
+ "tool_name": "MultiEdit",
114
+ "tool_input": {
115
+ "file_path": str(existing_path),
116
+ "edits": [{"old_string": "x = 1", "new_string": "x = 2"}],
117
+ },
118
+ }
119
+
120
+ stdout = _run_payload(payload, monkeypatch, capsys)
121
+
122
+ assert not _is_deny(stdout), f"expected an allow, got {stdout!r}"
@@ -17,13 +17,22 @@ import re
17
17
  import sys
18
18
  from pathlib import Path
19
19
 
20
- _hooks_dir = str(Path(__file__).resolve().parent.parent)
21
- if _hooks_dir not in sys.path:
22
- sys.path.insert(0, _hooks_dir)
23
-
24
- from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
25
- from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin # noqa: E402
26
- from hooks_constants.windows_rmtree_blocker_constants import PYTHON_FILE_EXTENSION # noqa: E402
20
+ try:
21
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
22
+ if _hooks_dir not in sys.path:
23
+ sys.path.insert(0, _hooks_dir)
24
+
25
+ from hooks_constants.hook_block_logger import log_hook_block
26
+ from hooks_constants.multi_edit_reconstruction import joined_new_strings
27
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
28
+ from hooks_constants.windows_rmtree_blocker_constants import (
29
+ PYTHON_FILE_EXTENSION,
30
+ )
31
+ except ImportError as import_error:
32
+ raise ImportError(
33
+ "windows_rmtree_blocker: cannot import its sibling modules; "
34
+ "ensure the hooks directory is importable."
35
+ ) from import_error
27
36
 
28
37
 
29
38
  def payload_contains_unsafe_rmtree(payload_text: str) -> bool:
@@ -37,6 +46,11 @@ def payload_contains_unsafe_rmtree(payload_text: str) -> bool:
37
46
 
38
47
 
39
48
  def extract_payload_text(tool_name: str, tool_input: dict) -> str:
49
+ if tool_name == "MultiEdit":
50
+ file_path = tool_input.get("file_path", "")
51
+ if file_path and not file_path.endswith(PYTHON_FILE_EXTENSION):
52
+ return ""
53
+ return joined_new_strings(tool_input)
40
54
  if tool_name in {"Write", "Edit"}:
41
55
  file_path = tool_input.get("file_path", "")
42
56
  if file_path and not file_path.endswith(PYTHON_FILE_EXTENSION):
@@ -1,8 +1,14 @@
1
1
  #!/usr/bin/env python3
2
- """PreToolUse:Write hook — blocks Write tool when the target file already exists.
2
+ """PreToolUse hook — blocks Write and apply_patch "Add File" onto an existing target.
3
3
 
4
- Agents should use Edit for modifying existing files. Write is only for new file creation.
4
+ Agents should use Edit for modifying existing files, and an apply_patch
5
+ "Update File" section rather than "Add File" for a path that already exists.
5
6
  Exemptions: Jupyter notebooks (.ipynb) and files in ~/.claude/hooks/ (standalone scripts).
7
+
8
+ MultiEdit carries no create-or-clobber path this hook needs to guard: every
9
+ MultiEdit edit must match an ``old_string`` already present in the target file,
10
+ so a MultiEdit can never introduce content nobody read the way a blind Write or
11
+ an apply_patch "Add File" can. This hook allows MultiEdit unconditionally.
6
12
  """
7
13
 
8
14
  import json
@@ -14,7 +20,22 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
14
20
  if _hooks_dir not in sys.path:
15
21
  sys.path.insert(0, _hooks_dir)
16
22
 
17
- from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
23
+ try:
24
+ from codex_apply_patch import (
25
+ CODEX_ADD_OPERATION,
26
+ CodexPatchError,
27
+ codex_patch_operation_targets,
28
+ payload_patch_command,
29
+ )
30
+ from hooks_constants.hook_block_logger import log_hook_block
31
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
32
+ from hooks_constants.pre_tool_use_dispatcher_constants import APPLY_PATCH_TOOL_NAME
33
+ from sensitive_file_protector import build_deny_response
34
+ except ImportError as import_error:
35
+ raise ImportError(
36
+ "write_existing_file_blocker: cannot import its sibling modules; "
37
+ "ensure the hooks directory is importable."
38
+ ) from import_error
18
39
 
19
40
  JUPYTER_EXTENSION = ".ipynb"
20
41
  HOOKS_DIRECTORY = os.path.normpath(os.path.expanduser("~/.claude/hooks"))
@@ -29,48 +50,73 @@ def is_inside_hooks_directory(file_path: str) -> bool:
29
50
  return normalized_path.startswith(HOOKS_DIRECTORY)
30
51
 
31
52
 
32
- def main() -> None:
53
+ def _apply_patch_add_deny(payload: dict, tool_input: dict) -> tuple[str, str] | None:
54
+ """Return (deny_reason, offending_path) for an apply_patch 'Add File' onto an existing path."""
55
+ command, working_directory = payload_patch_command(payload, tool_input)
56
+ if not command:
57
+ return None
33
58
  try:
34
- input_payload = json.load(sys.stdin)
35
- except json.JSONDecodeError:
36
- sys.exit(0)
59
+ all_operation_targets = codex_patch_operation_targets(command, working_directory)
60
+ except CodexPatchError:
61
+ return None
62
+ for each_operation, each_target_path in all_operation_targets:
63
+ if each_operation != CODEX_ADD_OPERATION:
64
+ continue
65
+ if os.path.exists(each_target_path):
66
+ deny_reason = (
67
+ f"BLOCKED: apply_patch 'Add File' on existing file {each_target_path}. "
68
+ "Use an 'Update File' section instead."
69
+ )
70
+ return deny_reason, each_target_path
71
+ return None
72
+
73
+
74
+ def _write_onto_existing_file_deny(tool_input: dict) -> tuple[str, str] | None:
75
+ """Return (deny_reason, offending_path) for a Write onto an existing target."""
76
+ target_file_path = tool_input.get("file_path", "")
77
+ if not target_file_path:
78
+ return None
79
+ if is_jupyter_notebook(target_file_path):
80
+ return None
81
+ if is_inside_hooks_directory(target_file_path):
82
+ return None
83
+ if not os.path.exists(target_file_path):
84
+ return None
85
+ deny_reason = f"BLOCKED: Write on existing file {target_file_path}. Use Edit tool instead."
86
+ return deny_reason, target_file_path
37
87
 
38
- tool_name = input_payload.get("tool_name", "")
39
- tool_input = input_payload.get("tool_input", {})
40
88
 
89
+ def _resolve_deny(tool_name: str, payload: dict, tool_input: dict) -> tuple[str, str] | None:
90
+ """Return (deny_reason, offending_path) for the payload, or None to allow."""
91
+ if tool_name == APPLY_PATCH_TOOL_NAME:
92
+ return _apply_patch_add_deny(payload, tool_input)
41
93
  if tool_name != "Write":
42
- sys.exit(0)
94
+ return None
95
+ return _write_onto_existing_file_deny(tool_input)
43
96
 
44
- target_file_path = tool_input.get("file_path", "")
45
97
 
46
- if not target_file_path:
47
- sys.exit(0)
48
-
49
- if is_jupyter_notebook(target_file_path):
98
+ def main() -> None:
99
+ input_payload = read_hook_input_dictionary_from_stdin()
100
+ if input_payload is None:
50
101
  sys.exit(0)
51
102
 
52
- if is_inside_hooks_directory(target_file_path):
53
- sys.exit(0)
103
+ raw_tool_name = input_payload.get("tool_name", "")
104
+ raw_tool_input = input_payload.get("tool_input", {})
105
+ tool_name = raw_tool_name if isinstance(raw_tool_name, str) else ""
106
+ tool_input = raw_tool_input if isinstance(raw_tool_input, dict) else {}
54
107
 
55
- if not os.path.exists(target_file_path):
108
+ resolved_deny = _resolve_deny(tool_name, input_payload, tool_input)
109
+ if resolved_deny is None:
56
110
  sys.exit(0)
111
+ deny_reason, offending_path = resolved_deny
57
112
 
58
- deny_reason = f"BLOCKED: Write on existing file {target_file_path}. Use Edit tool instead."
59
- denial = {
60
- "hookSpecificOutput": {
61
- "hookEventName": "PreToolUse",
62
- "permissionDecision": "deny",
63
- "permissionDecisionReason": deny_reason,
64
- }
65
- }
66
113
  log_hook_block(
67
114
  calling_hook_name="write_existing_file_blocker.py",
68
115
  hook_event="PreToolUse",
69
116
  block_reason=deny_reason,
70
- tool_name="Write",
71
- offending_input_preview=target_file_path,
117
+ offending_input_preview=offending_path,
72
118
  )
73
- print(json.dumps(denial))
119
+ print(json.dumps(build_deny_response(deny_reason)))
74
120
  sys.exit(0)
75
121
 
76
122
 
@@ -0,0 +1,47 @@
1
+ """Shared pytest fixtures for the hooks/ suite, visible under blocking/ and validators/."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ _HOOKS_DIRECTORY = Path(__file__).resolve().parent
12
+ _BLOCKING_DIRECTORY = _HOOKS_DIRECTORY / "blocking"
13
+ for each_directory in (_HOOKS_DIRECTORY, _BLOCKING_DIRECTORY):
14
+ if str(each_directory) not in sys.path:
15
+ sys.path.insert(0, str(each_directory))
16
+
17
+ _is_ephemeral_script_path = importlib.import_module("code_rules_shared").is_ephemeral_script_path
18
+ _EPHEMERAL_EXEMPT_DISABLE_ENVIRONMENT_VARIABLE_NAME = importlib.import_module(
19
+ "hooks_constants.code_rules_enforcer_constants"
20
+ ).EPHEMERAL_EXEMPT_DISABLE_ENVIRONMENT_VARIABLE_NAME
21
+
22
+ _ROOT_ANCHORED_PROBE_PATH = "/tmp/scratch.py"
23
+
24
+
25
+ @pytest.fixture
26
+ def ephemeral_exempt_off(monkeypatch: pytest.MonkeyPatch) -> None:
27
+ """Turn off the ephemeral scratch-path exemption for the current test.
28
+
29
+ Sets ``CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT`` so a gate under test
30
+ judges the test's own scratch targets for real, rather than stepping aside
31
+ because they sit under a temp root.
32
+
33
+ The probe path is root-anchored ``/tmp``, which the classifier treats as
34
+ ephemeral on every platform, so the assert fails on any change that stops
35
+ reading the disable flag. A probe on the shared OS temp root would pass on
36
+ Windows whether the flag is read or not, since the classifier already
37
+ excludes that root by design.
38
+
39
+ Args:
40
+ monkeypatch: Pytest's environment-variable patcher, torn down after the test.
41
+ """
42
+ monkeypatch.setenv(_EPHEMERAL_EXEMPT_DISABLE_ENVIRONMENT_VARIABLE_NAME, "1")
43
+ assert _is_ephemeral_script_path(_ROOT_ANCHORED_PROBE_PATH) is False, (
44
+ "ephemeral_exempt_off must turn off the scratch-path exemption; a change "
45
+ "that stops reading the disable flag must fail here, not silently return "
46
+ "every test in the file to deciding nothing"
47
+ )
package/hooks/hooks.json CHANGED
@@ -13,7 +13,7 @@
13
13
  ]
14
14
  },
15
15
  {
16
- "matcher": "Write|Edit|MultiEdit",
16
+ "matcher": "Write|Edit|MultiEdit|apply_patch",
17
17
  "hooks": [
18
18
  {
19
19
  "type": "command",
@@ -22,16 +22,6 @@
22
22
  }
23
23
  ]
24
24
  },
25
- {
26
- "matcher": "apply_patch",
27
- "hooks": [
28
- {
29
- "type": "command",
30
- "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/code_rules_enforcer.py",
31
- "timeout": 60
32
- }
33
- ]
34
- },
35
25
  {
36
26
  "matcher": "Bash|PowerShell",
37
27
  "hooks": [
@@ -171,7 +161,7 @@
171
161
  },
172
162
  {
173
163
  "type": "command",
174
- "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/task_list_loop_starter.py",
164
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/task_tool_prompt.py",
175
165
  "timeout": 10
176
166
  },
177
167
  {
@@ -245,7 +235,7 @@
245
235
  ]
246
236
  },
247
237
  {
248
- "matcher": "Write|Edit|MultiEdit",
238
+ "matcher": "Write|Edit|MultiEdit|apply_patch",
249
239
  "hooks": [
250
240
  {
251
241
  "type": "command",
@@ -261,6 +251,11 @@
261
251
  "type": "command",
262
252
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/workflow/investigation_tracker_reset.py",
263
253
  "timeout": 30
254
+ },
255
+ {
256
+ "type": "command",
257
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/observability/pr_description_writer_spawn_tracker.py",
258
+ "timeout": 30
264
259
  }
265
260
  ]
266
261
  },
@@ -76,4 +76,7 @@ ALL_BASH_HOSTED_HOOK_ENTRIES: tuple[BashHostedHookEntry, ...] = (
76
76
  BashHostedHookEntry("blocking/test_preflight_check.py", ALL_BASH_ONLY_TOOL_NAMES),
77
77
  BashHostedHookEntry("blocking/windows_rmtree_blocker.py", ALL_BASH_ONLY_TOOL_NAMES),
78
78
  BashHostedHookEntry("blocking/gh_pr_author_enforcer.py", ALL_BASH_ONLY_TOOL_NAMES),
79
+ BashHostedHookEntry(
80
+ "blocking/pr_description_writer_gate.py", ALL_BASH_AND_POWERSHELL_TOOL_NAMES
81
+ ),
79
82
  )
@@ -6,8 +6,11 @@ Extracted from code_rules_enforcer.py to satisfy the constants-location rule.
6
6
  import re
7
7
  import tokenize
8
8
 
9
+ from hooks_constants.multi_edit_reconstruction import ( # noqa: F401 re-exported for code_rules_enforcer.py
10
+ apply_edits,
11
+ edits_for_tool,
12
+ )
9
13
  from hooks_constants.validation_phase_constants import ( # noqa: F401 re-exported for code_rules_enforcer.py
10
- ALL_HOOK_INFRASTRUCTURE_EDIT_LANE_CHECK_NAMES,
11
14
  ALL_VALIDATION_PHASES,
12
15
  EDIT_LANE_PHASE,
13
16
  FULL_GATE_PHASE,
@@ -24,3 +24,6 @@ ALL_EXEMPT_PATH_FRAGMENTS: tuple[str, ...] = (
24
24
 
25
25
  ALL_EXEMPT_TEST_FILE_PREFIXES: tuple[str, ...] = ("test_",)
26
26
  ALL_EXEMPT_TEST_FILE_SUFFIXES: tuple[str, ...] = ("_test.py",)
27
+
28
+
29
+ ALL_APPLICABLE_TOOL_NAMES: frozenset[str] = frozenset({"Write", "Edit", "MultiEdit"})
@@ -1,7 +1,12 @@
1
1
  """Configuration constants for the hook_prose_detector_consistency PreToolUse hook."""
2
2
 
3
- WRITE_TOOL_NAME: str = "Write"
4
- EDIT_TOOL_NAME: str = "Edit"
3
+ # The dispatcher constants module is the single source for tool names.
4
+ from hooks_constants.pre_tool_use_dispatcher_constants import ( # noqa: F401
5
+ EDIT_TOOL_NAME,
6
+ MULTI_EDIT_TOOL_NAME,
7
+ WRITE_TOOL_NAME,
8
+ )
9
+
5
10
 
6
11
  HOOK_MODULE_PATH_SEGMENT: str = "/hooks/"
7
12
  PYTHON_FILE_SUFFIX: str = ".py"
@@ -0,0 +1,7 @@
1
+ """Configuration constants for the migration_safety_advisor PreToolUse hook."""
2
+
3
+ # The dispatcher constants module is the single source for tool names.
4
+ from hooks_constants.pre_tool_use_dispatcher_constants import ( # noqa: F401
5
+ MULTI_EDIT_TOOL_NAME,
6
+ )
7
+
@@ -12,6 +12,7 @@ from __future__ import annotations
12
12
  __all__ = [
13
13
  "apply_edits",
14
14
  "edits_for_tool",
15
+ "joined_new_strings",
15
16
  ]
16
17
 
17
18
 
@@ -24,9 +25,19 @@ def apply_edits(existing_content: str, all_edits: list[dict]) -> str:
24
25
  ``edits`` list, each a mapping with an ``old_string`` and a
25
26
  ``new_string``.
26
27
 
28
+ An edit carrying ``replace_all`` rewrites every occurrence, matching what
29
+ MultiEdit itself does; every other edit rewrites the first one::
30
+
31
+ {"old_string": "old", "new_string": "new", "replace_all": True}
32
+ "old old old" -> "new new new"
33
+ {"old_string": "old", "new_string": "new"}
34
+ "old old old" -> "new old old"
35
+
36
+ Each edit reads its own flag, so one replace_all edit in a list leaves the
37
+ edits beside it alone.
38
+
27
39
  Returns:
28
- The content after replacing the first occurrence of each edit's
29
- ``old_string`` with its ``new_string``, in list order.
40
+ The content after applying each edit in list order.
30
41
  """
31
42
  edited_content = existing_content
32
43
  for each_edit in all_edits:
@@ -35,7 +46,10 @@ def apply_edits(existing_content: str, all_edits: list[dict]) -> str:
35
46
  old_string = each_edit.get("old_string", "")
36
47
  new_string = each_edit.get("new_string", "")
37
48
  if isinstance(old_string, str) and isinstance(new_string, str) and old_string:
38
- edited_content = edited_content.replace(old_string, new_string, 1)
49
+ is_replace_all = bool(each_edit.get("replace_all"))
50
+ edited_content = edited_content.replace(
51
+ old_string, new_string, -1 if is_replace_all else 1
52
+ )
39
53
  return edited_content
40
54
 
41
55
 
@@ -54,3 +68,30 @@ def edits_for_tool(tool_name: str, tool_input: dict) -> list[dict]:
54
68
  return [tool_input]
55
69
  all_edits = tool_input.get("edits", [])
56
70
  return all_edits if isinstance(all_edits, list) else []
71
+
72
+
73
+ def joined_new_strings(tool_input: dict) -> str:
74
+ """Return one scannable text holding every string a MultiEdit introduces.
75
+
76
+ ::
77
+
78
+ edits = [{"new_string": "import os"}, {"new_string": "shutil.rmtree(p)"}]
79
+ joined_new_strings(...) -> "import os\\nshutil.rmtree(p)"
80
+
81
+ A blocker scans that single text in one pass. The joining newline keeps one
82
+ edit's last line apart from the next edit's first line, so a phrase neither
83
+ edit wrote never appears across the seam.
84
+
85
+ Args:
86
+ tool_input: The MultiEdit tool input payload.
87
+
88
+ Returns:
89
+ Every string-valued new_string in the payload, joined on a newline.
90
+ """
91
+ multi_edit_new_string_join_separator = "\n"
92
+ all_new_strings = [
93
+ each_edit.get("new_string", "")
94
+ for each_edit in edits_for_tool("MultiEdit", tool_input)
95
+ if isinstance(each_edit, dict) and isinstance(each_edit.get("new_string"), str)
96
+ ]
97
+ return multi_edit_new_string_join_separator.join(all_new_strings)
@@ -6,15 +6,17 @@ import re
6
6
 
7
7
  from hooks_constants.hardcoded_user_path_constants import HARDCODED_USER_PATH_PATTERN
8
8
 
9
+ # The dispatcher constants module is the single source for tool names.
10
+ from hooks_constants.pre_tool_use_dispatcher_constants import ( # noqa: F401
11
+ ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
12
+ APPLY_PATCH_TOOL_NAME,
13
+ EDIT_TOOL_NAME,
14
+ MULTI_EDIT_TOOL_NAME,
15
+ WRITE_TOOL_NAME,
16
+ )
17
+
9
18
  BASH_TOOL_NAME: str = "Bash"
10
19
  POWERSHELL_TOOL_NAME: str = "PowerShell"
11
- WRITE_TOOL_NAME: str = "Write"
12
- EDIT_TOOL_NAME: str = "Edit"
13
- MULTI_EDIT_TOOL_NAME: str = "MultiEdit"
14
-
15
- ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES: frozenset[str] = frozenset(
16
- {WRITE_TOOL_NAME, EDIT_TOOL_NAME, MULTI_EDIT_TOOL_NAME}
17
- )
18
20
 
19
21
  ALL_SHELL_TOOL_NAMES: frozenset[str] = frozenset(
20
22
  {BASH_TOOL_NAME, POWERSHELL_TOOL_NAME}
@@ -0,0 +1,39 @@
1
+ """Constants for the pr-description-writer spawn tracker and its gh pr create gate.
2
+
3
+ The tracker records one fact per session: the session spawned the
4
+ ``pr-description-writer`` agent. The gate reads that fact when the session runs
5
+ ``gh pr create`` and denies the command when the fact is absent.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ PR_DESCRIPTION_WRITER_SUBAGENT_TYPE: str = "pr-description-writer"
11
+
12
+ ALL_AGENT_SPAWN_TOOL_NAMES: frozenset[str] = frozenset({"Agent", "Task"})
13
+
14
+ SPAWN_MARKER_FILE_PREFIX: str = "claude-session-pr-description-writer-"
15
+ SPAWN_MARKER_FILE_SUFFIX: str = ".marker"
16
+
17
+ SUBAGENT_TYPE_FIELD_NAME: str = "subagent_type"
18
+ TOOL_INPUT_FIELD_NAME: str = "tool_input"
19
+ TOOL_NAME_FIELD_NAME: str = "tool_name"
20
+ COMMAND_FIELD_NAME: str = "command"
21
+
22
+ ALL_GH_EXECUTABLE_BASENAMES: frozenset[str] = frozenset({"gh", "gh.exe", "gh.cmd"})
23
+ PR_SUBCOMMAND_TOKEN: str = "pr"
24
+ CREATE_SUBCOMMAND_TOKEN: str = "create"
25
+ GH_PR_CREATE_MINIMUM_TOKEN_COUNT: int = 3
26
+
27
+ SPAWN_BYPASS_MARKER: str = "# pr-description-skip"
28
+
29
+ CORRECTIVE_MESSAGE: str = (
30
+ "BLOCKED: [PR_DESCRIPTION_WRITER] This session runs `gh pr create` with no "
31
+ "`pr-description-writer` agent spawn recorded.\n\n"
32
+ "The agent writes the title and body from the current diff, then returns the "
33
+ "markdown and a body-file path for you to publish.\n\n"
34
+ "Two ways forward:\n"
35
+ " 1. Spawn the agent (Agent tool, subagent_type `pr-description-writer`), "
36
+ "then run `gh pr create --body-file <path>` with what it returns.\n"
37
+ " 2. Append the trailing comment `# pr-description-skip` to the command when "
38
+ "you are writing the body yourself on purpose."
39
+ )