claude-dev-env 2.30.1 → 2.31.1

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 (143) hide show
  1. package/.agents/agents/pr-description-writer.md +1 -1
  2. package/AGENTS.md +6 -0
  3. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +37 -0
  4. package/bin/install.prune.test.mjs +4 -2
  5. package/docs/CODE_RULES.md +12 -0
  6. package/hooks/advisory/migration_safety_advisor.py +25 -1
  7. package/hooks/advisory/refactor_guard.py +85 -18
  8. package/hooks/advisory/test_migration_safety_advisor.py +89 -5
  9. package/hooks/advisory/test_refactor_guard.py +87 -0
  10. package/hooks/advisory/test_refactor_guard_advisory.py +72 -1
  11. package/hooks/blocking/code_rules_constants_config.py +7 -6
  12. package/hooks/blocking/code_rules_duplicate_body.py +272 -100
  13. package/hooks/blocking/code_rules_enforcer.py +89 -45
  14. package/hooks/blocking/code_rules_shared.py +64 -14
  15. package/hooks/blocking/codex_apply_patch.py +136 -21
  16. package/hooks/blocking/config/codex_apply_patch_constants.py +13 -0
  17. package/hooks/blocking/conftest.py +70 -0
  18. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +26 -16
  19. package/hooks/blocking/hook_prose_detector_consistency.py +30 -20
  20. package/hooks/blocking/pii_payload_scan.py +61 -0
  21. package/hooks/blocking/pii_prevention_blocker.py +5 -0
  22. package/hooks/blocking/pr_description_writer_gate.py +256 -0
  23. package/hooks/blocking/pre_tool_use_dispatcher.py +1 -1
  24. package/hooks/blocking/sensitive_file_protector.py +18 -3
  25. package/hooks/blocking/shell_substitution_blocker.py +25 -47
  26. package/hooks/blocking/stale_comment_reference_blocker.py +94 -24
  27. package/hooks/blocking/state_description_blocker.py +39 -26
  28. package/hooks/blocking/subprocess_budget_completeness.py +17 -43
  29. package/hooks/blocking/tdd_enforcer.py +63 -16
  30. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +11 -1
  31. package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +180 -0
  32. package/hooks/blocking/tdd_enforcer_parts/decisions.py +6 -2
  33. package/hooks/blocking/tdd_enforcer_parts/freshness.py +18 -3
  34. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +66 -1
  35. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +143 -0
  36. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +12 -0
  37. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +63 -0
  38. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -0
  39. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +81 -2
  40. package/hooks/blocking/test_code_rules_enforcer_codex_apply_patch.py +35 -0
  41. package/hooks/blocking/test_code_rules_enforcer_config_path.py +29 -16
  42. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +2 -0
  43. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +12 -1
  44. package/hooks/blocking/test_code_rules_enforcer_ephemeral_repository_root.py +118 -0
  45. package/hooks/blocking/test_code_rules_enforcer_multi_edit.py +118 -0
  46. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +2 -0
  47. package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +2 -0
  48. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +244 -0
  49. package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +29 -28
  50. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +24 -0
  51. package/hooks/blocking/test_code_rules_enforcer_stage.py +2 -0
  52. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +2 -0
  53. package/hooks/blocking/test_codex_apply_patch.py +43 -0
  54. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +68 -0
  55. package/hooks/blocking/test_hook_prose_detector_consistency.py +41 -0
  56. package/hooks/blocking/test_pii_payload_scan.py +65 -2
  57. package/hooks/blocking/test_pii_prevention_blocker.py +80 -10
  58. package/hooks/blocking/test_pr_description_writer_gate.py +234 -0
  59. package/hooks/blocking/test_pre_tool_use_dispatcher.py +215 -25
  60. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +18 -6
  61. package/hooks/blocking/test_sensitive_file_protector.py +42 -2
  62. package/hooks/blocking/test_stale_comment_reference_blocker.py +71 -0
  63. package/hooks/blocking/test_state_description_blocker.py +163 -49
  64. package/hooks/blocking/test_subprocess_budget_completeness.py +67 -0
  65. package/hooks/blocking/test_tdd_enforcer.py +246 -2
  66. package/hooks/blocking/test_windows_rmtree_blocker.py +63 -0
  67. package/hooks/blocking/test_write_existing_file_blocker.py +122 -0
  68. package/hooks/blocking/windows_rmtree_blocker.py +21 -7
  69. package/hooks/blocking/write_existing_file_blocker.py +75 -29
  70. package/hooks/conftest.py +47 -0
  71. package/hooks/hooks.json +7 -12
  72. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +3 -0
  73. package/hooks/hooks_constants/code_rules_enforcer_constants.py +4 -0
  74. package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +3 -0
  75. package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +7 -2
  76. package/hooks/hooks_constants/migration_safety_advisor_constants.py +7 -0
  77. package/hooks/hooks_constants/multi_edit_reconstruction.py +44 -3
  78. package/hooks/hooks_constants/pii_prevention_constants.py +9 -7
  79. package/hooks/hooks_constants/pr_description_writer_gate_constants.py +73 -0
  80. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +31 -14
  81. package/hooks/hooks_constants/session_edit_stage_gate_constants.py +15 -1
  82. package/hooks/hooks_constants/state_description_blocker_constants.py +1 -0
  83. package/hooks/hooks_constants/subprocess_budget_completeness_content.py +71 -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 -0
  86. package/hooks/hooks_constants/test_multi_edit_reconstruction.py +103 -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 +103 -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 +113 -0
  92. package/hooks/observability/session_file_edit_tracker.py +25 -4
  93. package/hooks/observability/test_pr_description_writer_spawn_tracker.py +150 -0
  94. package/hooks/observability/test_session_file_edit_tracker.py +32 -0
  95. package/hooks/validation/hook_format_validator.py +23 -9
  96. package/hooks/validation/test_hook_format_validator.py +58 -0
  97. package/hooks/validators/README.md +1 -1
  98. package/hooks/validators/abbreviation_checks.py +6 -8
  99. package/hooks/validators/code_quality_checks.py +4 -4
  100. package/hooks/validators/config/abbreviation_checks_constants.py +3 -0
  101. package/hooks/validators/config/baseline_identity_constants.py +12 -0
  102. package/hooks/validators/config/directory_exemption_constants.py +44 -1
  103. package/hooks/validators/config/fast_save_validator_constants.py +32 -0
  104. package/hooks/validators/config/hook_timing_harness_constants.py +3 -1
  105. package/hooks/validators/config/test_abbreviation_checks_constants.py +11 -0
  106. package/hooks/validators/config/validator_base_constants.py +9 -0
  107. package/hooks/validators/fast_save_validators.py +275 -0
  108. package/hooks/validators/hook_timing_harness.py +8 -4
  109. package/hooks/validators/pr_reference_checks.py +2 -2
  110. package/hooks/validators/python_antipattern_checks.py +3 -3
  111. package/hooks/validators/python_style_checks.py +37 -62
  112. package/hooks/validators/python_style_helpers.py +16 -2
  113. package/hooks/validators/python_style_import_bootstrap.py +83 -0
  114. package/hooks/validators/react_checks.py +2 -9
  115. package/hooks/validators/run_all_validators.py +201 -155
  116. package/hooks/validators/security_checks.py +3 -3
  117. package/hooks/validators/test_abbreviation_checks.py +20 -0
  118. package/hooks/validators/test_directory_exemption_constants.py +30 -24
  119. package/hooks/validators/test_fast_save_validators.py +172 -0
  120. package/hooks/validators/test_hook_timing_harness.py +10 -1
  121. package/hooks/validators/test_python_style_checks.py +9 -0
  122. package/hooks/validators/test_python_style_checks_import_order.py +81 -0
  123. package/hooks/validators/test_python_style_helpers.py +35 -0
  124. package/hooks/validators/test_python_style_import_bootstrap.py +69 -0
  125. package/hooks/validators/test_run_all_validators.py +46 -0
  126. package/hooks/validators/test_run_all_validators_pretooluse.py +163 -0
  127. package/hooks/validators/test_run_all_validators_repository_root.py +202 -0
  128. package/hooks/validators/test_validator_base.py +51 -2
  129. package/hooks/validators/todo_checks.py +2 -2
  130. package/hooks/validators/type_safety_checks.py +3 -3
  131. package/hooks/validators/useless_test_checks.py +3 -3
  132. package/hooks/validators/validator_base.py +73 -1
  133. package/package.json +1 -1
  134. package/rules/docstring-prose-matches-implementation.md +1 -1
  135. package/rules/git-workflow.md +2 -0
  136. package/rules/testing.md +4 -0
  137. package/scripts/codex_compat_materializer.py +5 -0
  138. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +15 -0
  139. package/scripts/grok_headless_runner.py +15 -12
  140. package/scripts/shared_tree_paths.py +70 -0
  141. package/scripts/test_shared_tree_paths.py +143 -0
  142. package/scripts/tests/test_codex_compat_materializer.py +37 -11
  143. package/hooks/blocking/tests/conftest.py +0 -10
@@ -0,0 +1,103 @@
1
+ """Tests for multi_edit_reconstruction.py."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pathlib
6
+ import sys
7
+
8
+ try:
9
+ _HOOKS_ROOT = pathlib.Path(__file__).resolve().parent.parent
10
+ if str(_HOOKS_ROOT) not in sys.path:
11
+ sys.path.insert(0, str(_HOOKS_ROOT))
12
+
13
+ from hooks_constants.multi_edit_reconstruction import (
14
+ apply_edits,
15
+ edits_for_tool,
16
+ joined_new_strings,
17
+ )
18
+ except ImportError as import_error:
19
+ raise ImportError(
20
+ "test_multi_edit_reconstruction: cannot import its sibling modules; "
21
+ "ensure the hooks directory is importable."
22
+ ) from import_error
23
+
24
+
25
+ def test_apply_edits_replaces_each_old_string_in_list_order() -> None:
26
+ """Two edits land in order, each replacing its own first occurrence."""
27
+ all_edits = [
28
+ {"old_string": "alpha", "new_string": "beta"},
29
+ {"old_string": "gamma", "new_string": "delta"},
30
+ ]
31
+ assert apply_edits("alpha gamma", all_edits) == "beta delta"
32
+
33
+
34
+ def test_edits_for_tool_wraps_an_edit_payload_in_a_single_element_list() -> None:
35
+ """An Edit payload is its own only edit."""
36
+ edit_payload = {"old_string": "a", "new_string": "b"}
37
+ assert edits_for_tool("Edit", edit_payload) == [edit_payload]
38
+
39
+
40
+ def test_apply_edits_replaces_every_occurrence_when_replace_all_is_set() -> None:
41
+ """A replace_all edit rewrites every occurrence, the way MultiEdit does.
42
+
43
+ The gates that read this reconstruction judge the file the write would
44
+ leave on disk. When an edit carries replace_all, MultiEdit rewrites all
45
+ occurrences, so a reconstruction that rewrites only the first one hands
46
+ every gate a file that never existed.
47
+ """
48
+ all_edits = [{"old_string": "old", "new_string": "new", "replace_all": True}]
49
+ assert apply_edits("old old old", all_edits) == "new new new"
50
+
51
+
52
+ def test_apply_edits_replaces_only_the_first_occurrence_without_replace_all() -> None:
53
+ """An edit with no replace_all flag rewrites one occurrence."""
54
+ all_edits = [{"old_string": "old", "new_string": "new"}]
55
+ assert apply_edits("old old old", all_edits) == "new old old"
56
+
57
+
58
+ def test_apply_edits_treats_a_false_replace_all_as_a_single_replacement() -> None:
59
+ """An explicit replace_all of False rewrites one occurrence."""
60
+ all_edits = [{"old_string": "old", "new_string": "new", "replace_all": False}]
61
+ assert apply_edits("old old old", all_edits) == "new old old"
62
+
63
+
64
+ def test_apply_edits_mixes_replace_all_and_single_edits_in_one_list() -> None:
65
+ """Each edit in a list honors its own flag, not the list's first one."""
66
+ all_edits = [
67
+ {"old_string": "a", "new_string": "A", "replace_all": True},
68
+ {"old_string": "b", "new_string": "B"},
69
+ ]
70
+ assert apply_edits("a b a b", all_edits) == "A B A b"
71
+
72
+
73
+ def test_joined_new_strings_joins_every_new_string_on_a_newline() -> None:
74
+ """Each edit's introduced text reaches the scanner as its own line."""
75
+ tool_input = {
76
+ "edits": [
77
+ {"old_string": "x", "new_string": "import os"},
78
+ {"old_string": "y", "new_string": "shutil.rmtree(p)"},
79
+ ]
80
+ }
81
+ assert joined_new_strings(tool_input) == "import os\nshutil.rmtree(p)"
82
+
83
+
84
+ def test_joined_new_strings_separator_keeps_adjacent_edits_apart() -> None:
85
+ """A phrase spanning the seam belongs to neither edit, so the join breaks it."""
86
+ tool_input = {
87
+ "edits": [
88
+ {"old_string": "x", "new_string": "currently"},
89
+ {"old_string": "y", "new_string": "holds"},
90
+ ]
91
+ }
92
+ assert "currentlyholds" not in joined_new_strings(tool_input)
93
+
94
+
95
+ def test_joined_new_strings_skips_edits_whose_new_string_is_not_a_string() -> None:
96
+ """A malformed edit contributes nothing rather than raising."""
97
+ tool_input = {"edits": [{"new_string": None}, {"new_string": "kept"}]}
98
+ assert joined_new_strings(tool_input) == "kept"
99
+
100
+
101
+ def test_joined_new_strings_returns_empty_text_for_a_payload_with_no_edits() -> None:
102
+ """A payload carrying no edit list scans as empty text."""
103
+ assert joined_new_strings({"file_path": "a.py"}) == ""
@@ -4,25 +4,36 @@ import importlib
4
4
  import pathlib
5
5
  import sys
6
6
 
7
- _HOOKS_ROOT = pathlib.Path(__file__).resolve().parent.parent
8
- if str(_HOOKS_ROOT) not in sys.path:
9
- sys.path.insert(0, str(_HOOKS_ROOT))
10
-
11
- _BLOCKING_DIR = _HOOKS_ROOT / "blocking"
12
- if str(_BLOCKING_DIR) not in sys.path:
13
- sys.path.insert(0, str(_BLOCKING_DIR))
14
-
15
- from hooks_constants.pre_tool_use_dispatcher_constants import (
16
- ALL_HOSTED_HOOK_ENTRIES,
17
- ALL_WRITE_AND_EDIT_TOOL_NAMES,
18
- BLOCKING_CRASH_DENY_REASON,
19
- EDIT_TOOL_NAME,
20
- WRITE_TOOL_NAME,
21
- )
22
- from pre_tool_use_dispatcher import (
23
- HostedHookResult,
24
- aggregate_hosted_hook_results,
25
- )
7
+ try:
8
+ _HOOKS_ROOT = pathlib.Path(__file__).resolve().parent.parent
9
+ if str(_HOOKS_ROOT) not in sys.path:
10
+ sys.path.insert(0, str(_HOOKS_ROOT))
11
+
12
+ _BLOCKING_DIR = _HOOKS_ROOT / "blocking"
13
+ if str(_BLOCKING_DIR) not in sys.path:
14
+ sys.path.insert(0, str(_BLOCKING_DIR))
15
+
16
+ from hooks_constants.pre_tool_use_dispatcher_constants import (
17
+ ALL_HOSTED_HOOK_ENTRIES,
18
+ ALL_IMMEDIATE_HARM_SCRIPT_PATHS,
19
+ ALL_WRITE_AND_EDIT_TOOL_NAMES,
20
+ ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES,
21
+ ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
22
+ APPLY_PATCH_TOOL_NAME,
23
+ BLOCKING_CRASH_DENY_REASON,
24
+ EDIT_TOOL_NAME,
25
+ MULTI_EDIT_TOOL_NAME,
26
+ WRITE_TOOL_NAME,
27
+ )
28
+ from pre_tool_use_dispatcher import (
29
+ HostedHookResult,
30
+ aggregate_hosted_hook_results,
31
+ )
32
+ except ImportError as import_error:
33
+ raise ImportError(
34
+ "test_pre_tool_use_dispatcher_constants: cannot import its sibling modules; "
35
+ "ensure the hooks and blocking directories are importable."
36
+ ) from import_error
26
37
 
27
38
 
28
39
  def _entry_for(script_relative_path: str):
@@ -63,11 +74,10 @@ def test_duplicate_rmtree_helper_blocker_runs_via_runpy() -> None:
63
74
  assert entry.native_module_name is None
64
75
 
65
76
 
66
-
67
77
  def test_windows_rmtree_blocker_still_registered() -> None:
68
78
  entry = _entry_for("blocking/windows_rmtree_blocker.py")
69
79
  assert entry is not None
70
- assert entry.applicable_tool_names == ALL_WRITE_AND_EDIT_TOOL_NAMES
80
+ assert entry.applicable_tool_names == ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES
71
81
 
72
82
 
73
83
  def test_blocking_hook_crash_deny_reason_surfaces_the_constant() -> None:
@@ -82,22 +92,22 @@ def test_blocking_hook_crash_deny_reason_surfaces_the_constant() -> None:
82
92
  assert BLOCKING_CRASH_DENY_REASON in decision.all_deny_reasons
83
93
 
84
94
 
85
- def test_refactor_guard_is_hosted_as_an_edit_only_advisory_hook() -> None:
95
+ def test_refactor_guard_is_hosted_as_an_edit_and_multi_edit_advisory_hook() -> None:
86
96
  entry = _entry_for("advisory/refactor_guard.py")
87
97
  assert entry is not None, (
88
98
  "refactor_guard must be hosted by the dispatcher rather than spawning its own process"
89
99
  )
90
- assert entry.applicable_tool_names == frozenset({EDIT_TOOL_NAME})
100
+ assert entry.applicable_tool_names == frozenset({EDIT_TOOL_NAME, MULTI_EDIT_TOOL_NAME})
91
101
  assert entry.is_blocking is False
92
102
 
93
103
 
94
- def test_migration_safety_advisor_is_hosted_as_an_edit_only_advisory_hook() -> None:
104
+ def test_migration_safety_advisor_is_hosted_as_an_edit_and_multi_edit_advisory_hook() -> None:
95
105
  entry = _entry_for("advisory/migration_safety_advisor.py")
96
106
  assert entry is not None, (
97
107
  "migration_safety_advisor must be hosted by the dispatcher rather than "
98
108
  "spawning its own process"
99
109
  )
100
- assert entry.applicable_tool_names == frozenset({EDIT_TOOL_NAME})
110
+ assert entry.applicable_tool_names == frozenset({EDIT_TOOL_NAME, MULTI_EDIT_TOOL_NAME})
101
111
  assert entry.is_blocking is False
102
112
 
103
113
 
@@ -125,3 +135,107 @@ def test_every_native_module_exposes_a_callable_evaluate() -> None:
125
135
  f"{each_entry.native_module_name} must expose a callable named evaluate, "
126
136
  "matching the native_module_name docstring contract"
127
137
  )
138
+
139
+
140
+ def test_four_way_set_covers_every_mutation_tool_name() -> None:
141
+ assert ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES == (
142
+ ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES | {APPLY_PATCH_TOOL_NAME}
143
+ )
144
+
145
+
146
+ def test_immediate_harm_hooks_reach_apply_patch() -> None:
147
+ """PII, sensitive-path, existing-file, CODE_RULES, and TDD gates all see apply_patch."""
148
+ for each_script_path in ALL_IMMEDIATE_HARM_SCRIPT_PATHS:
149
+ entry = _entry_for(each_script_path)
150
+ assert entry is not None, f"{each_script_path} must stay on the hosted roster"
151
+ assert APPLY_PATCH_TOOL_NAME in entry.applicable_tool_names, (
152
+ f"{each_script_path} must reach apply_patch for mutation-tool parity"
153
+ )
154
+
155
+
156
+ _ALL_DEFERRED_LINT_SCRIPT_PATHS = (
157
+ "blocking/state_description_blocker.py",
158
+ "blocking/workflow_substitution_slot_blocker.py",
159
+ "blocking/claude_md_orphan_file_blocker.py",
160
+ "blocking/package_inventory_stale_blocker.py",
161
+ "blocking/env_var_table_code_drift_blocker.py",
162
+ "blocking/pytest_testpaths_orphan_blocker.py",
163
+ "blocking/open_questions_in_plans_blocker.py",
164
+ "blocking/docstring_rule_gate_count_blocker.py",
165
+ "blocking/duplicate_rmtree_helper_blocker.py",
166
+ "validation/hook_format_validator.py",
167
+ "blocking/hook_prose_detector_consistency.py",
168
+ "blocking/stale_comment_reference_blocker.py",
169
+ "blocking/subprocess_budget_completeness.py",
170
+ "blocking/windows_rmtree_blocker.py",
171
+ "advisory/refactor_guard.py",
172
+ "advisory/migration_safety_advisor.py",
173
+ )
174
+
175
+
176
+ def test_lint_and_advisory_hooks_stay_off_the_apply_patch_roster() -> None:
177
+ """Every hook reaching MultiEdit but not apply_patch fails the same one rule.
178
+
179
+ apply_patch reaches only a gate whose violation is already real and
180
+ unrecoverable the moment the call returns: a leaked secret, an exposed
181
+ sensitive path, a blind overwrite, or an untested change landing. Every
182
+ hook named here instead judges the quality of the authored content for a
183
+ consequence the write itself does not cause right now: doc-inventory
184
+ drift, prose or naming style, a future-runtime correctness pattern (an
185
+ unsafe rmtree that only misbehaves on Windows, a subprocess-timeout
186
+ budget the harness reads separately), or an advisory suggestion that
187
+ never denies at all. Each is fixable in a later Edit-tool pass, so none
188
+ needs apply_patch's narrower, deny-only roster.
189
+ """
190
+ for each_script_path in _ALL_DEFERRED_LINT_SCRIPT_PATHS:
191
+ entry = _entry_for(each_script_path)
192
+ assert entry is not None, f"{each_script_path} must stay on the hosted roster"
193
+ assert MULTI_EDIT_TOOL_NAME in entry.applicable_tool_names, (
194
+ f"{each_script_path} must still reach MultiEdit"
195
+ )
196
+ assert APPLY_PATCH_TOOL_NAME not in entry.applicable_tool_names, (
197
+ f"{each_script_path} judges deferred-fixable quality, not immediate harm, "
198
+ "so it must stay off the apply_patch roster"
199
+ )
200
+
201
+
202
+ def test_edit_and_multi_edit_applicable_sets_are_equal() -> None:
203
+ """The Edit roster and the MultiEdit roster name the same hooks.
204
+
205
+ Every hook applicable to Edit judges Write's content or an Edit's
206
+ old/new string pair with no dependency on which tool delivered it, so
207
+ the two sets must match exactly. A hook that legitimately stays
208
+ Edit-only is an exception written down here with its reason, not a
209
+ silent gap this test lets back in.
210
+ """
211
+ all_edit_script_paths = {
212
+ each_entry.script_relative_path
213
+ for each_entry in ALL_HOSTED_HOOK_ENTRIES
214
+ if EDIT_TOOL_NAME in each_entry.applicable_tool_names
215
+ }
216
+ all_multi_edit_script_paths = {
217
+ each_entry.script_relative_path
218
+ for each_entry in ALL_HOSTED_HOOK_ENTRIES
219
+ if MULTI_EDIT_TOOL_NAME in each_entry.applicable_tool_names
220
+ }
221
+ assert all_edit_script_paths == all_multi_edit_script_paths, (
222
+ "Edit-only (not MultiEdit): "
223
+ f"{sorted(all_edit_script_paths - all_multi_edit_script_paths)}; "
224
+ "MultiEdit-only (not Edit): "
225
+ f"{sorted(all_multi_edit_script_paths - all_edit_script_paths)}"
226
+ )
227
+
228
+
229
+ def test_multi_edit_widened_hooks_reach_multi_edit() -> None:
230
+ all_multi_edit_widened_script_paths = (
231
+ "blocking/write_existing_file_blocker.py",
232
+ "blocking/code_rules_enforcer.py",
233
+ "blocking/tdd_enforcer.py",
234
+ "blocking/state_description_blocker.py",
235
+ )
236
+ for each_script_path in all_multi_edit_widened_script_paths:
237
+ entry = _entry_for(each_script_path)
238
+ assert entry is not None, f"{each_script_path} must stay on the hosted roster"
239
+ assert MULTI_EDIT_TOOL_NAME in entry.applicable_tool_names, (
240
+ f"{each_script_path} must reach MultiEdit for mutation-tool parity"
241
+ )
@@ -0,0 +1,103 @@
1
+ """Tests for subprocess_budget_completeness_content.py."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pathlib
6
+ import sys
7
+
8
+ try:
9
+ _HOOKS_ROOT = pathlib.Path(__file__).resolve().parent.parent
10
+ if str(_HOOKS_ROOT) not in sys.path:
11
+ sys.path.insert(0, str(_HOOKS_ROOT))
12
+
13
+ from hooks_constants.subprocess_budget_completeness_content import (
14
+ reconstructed_edit_content,
15
+ reconstructed_multi_edit_content,
16
+ resolved_content,
17
+ )
18
+ except ImportError as import_error:
19
+ raise ImportError(
20
+ "test_subprocess_budget_completeness_content: cannot import its sibling modules; "
21
+ "ensure the hooks directory is importable."
22
+ ) from import_error
23
+
24
+
25
+ def test_resolved_content_returns_write_content_directly() -> None:
26
+ assert resolved_content({"content": "abc"}) == "abc"
27
+
28
+
29
+ def test_reconstructed_edit_content_replaces_the_old_string(tmp_path: pathlib.Path) -> None:
30
+ target_file = tmp_path / "module.py"
31
+ target_file.write_text("value = 1\n", encoding="utf-8")
32
+ reconstructed = reconstructed_edit_content(
33
+ {"file_path": str(target_file), "old_string": "value = 1", "new_string": "value = 2"}
34
+ )
35
+ assert reconstructed == "value = 2\n"
36
+
37
+
38
+ def test_reconstructed_multi_edit_content_applies_every_edit_in_order(
39
+ tmp_path: pathlib.Path,
40
+ ) -> None:
41
+ target_file = tmp_path / "module.py"
42
+ target_file.write_text("a = 1\nb = 2\n", encoding="utf-8")
43
+ all_edits = [
44
+ {"old_string": "a = 1", "new_string": "a = 11"},
45
+ {"old_string": "b = 2", "new_string": "b = 22"},
46
+ ]
47
+ reconstructed = reconstructed_multi_edit_content({"file_path": str(target_file)}, all_edits)
48
+ assert reconstructed == "a = 11\nb = 22\n"
49
+
50
+
51
+ def test_resolved_content_dispatches_a_multi_edit_payload(tmp_path: pathlib.Path) -> None:
52
+ target_file = tmp_path / "module.py"
53
+ target_file.write_text("a = 1\n", encoding="utf-8")
54
+ reconstructed = resolved_content(
55
+ {
56
+ "file_path": str(target_file),
57
+ "edits": [{"old_string": "a = 1", "new_string": "a = 11"}],
58
+ }
59
+ )
60
+ assert reconstructed == "a = 11\n"
61
+
62
+
63
+ def test_resolved_content_returns_empty_for_an_unreadable_edit_target(
64
+ tmp_path: pathlib.Path,
65
+ ) -> None:
66
+ missing_target = tmp_path / "missing.py"
67
+ assert (
68
+ resolved_content({"file_path": str(missing_target), "old_string": "a", "new_string": "b"})
69
+ == ""
70
+ )
71
+
72
+
73
+ def test_reconstructed_edit_content_replaces_every_occurrence_when_replace_all_is_set(
74
+ tmp_path: pathlib.Path,
75
+ ) -> None:
76
+ """An Edit carrying replace_all rewrites every occurrence, as the tool does.
77
+
78
+ The MultiEdit branch has honored the flag since it moved onto apply_edits;
79
+ the Edit branch must agree, or one payload reconstructs two ways.
80
+ """
81
+ target_file = tmp_path / "module.py"
82
+ target_file.write_text("a = 1\na = 1\n", encoding="utf-8")
83
+ reconstructed = reconstructed_edit_content(
84
+ {
85
+ "file_path": str(target_file),
86
+ "old_string": "a = 1",
87
+ "new_string": "a = 2",
88
+ "replace_all": True,
89
+ }
90
+ )
91
+ assert reconstructed == "a = 2\na = 2\n"
92
+
93
+
94
+ def test_reconstructed_edit_content_replaces_one_occurrence_without_replace_all(
95
+ tmp_path: pathlib.Path,
96
+ ) -> None:
97
+ """Without the flag, only the first occurrence changes."""
98
+ target_file = tmp_path / "module.py"
99
+ target_file.write_text("a = 1\na = 1\n", encoding="utf-8")
100
+ reconstructed = reconstructed_edit_content(
101
+ {"file_path": str(target_file), "old_string": "a = 1", "new_string": "a = 2"}
102
+ )
103
+ assert reconstructed == "a = 2\na = 1\n"
@@ -4,6 +4,8 @@ The edit lane and the full gate share every check except six that read
4
4
  sibling modules from disk. These names identify each phase, name the checks
5
5
  the full gate alone runs, name the hook-infrastructure checks the edit lane
6
6
  still runs, and template the error for an unrecognized phase.
7
+
8
+ Scope assigns the lane. See ``docs/CODE_RULES.md`` section 11.6.
7
9
  """
8
10
 
9
11
  EDIT_LANE_PHASE: str = "edit_lane"
@@ -1,8 +1,12 @@
1
1
  """Configuration constants for the workflow_substitution_slot_blocker PreToolUse hook."""
2
2
 
3
- WRITE_TOOL_NAME: str = "Write"
4
- EDIT_TOOL_NAME: str = "Edit"
5
- MULTI_EDIT_TOOL_NAME: str = "MultiEdit"
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
+
6
10
 
7
11
  WORKFLOW_FILE_SUFFIX: str = ".workflow.js"
8
12
 
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env python3
2
+ """PostToolUse hook: record that this session spawned the pr-description-writer agent.
3
+
4
+ Picture opening a pull request whose body nobody wrote from the diff — it reads
5
+ like a commit message, and the reviewer learns nothing before opening the files.
6
+ The ``pr-description-writer`` agent exists to write that body, and it hands the
7
+ markdown and a body-file path back to the caller to publish.
8
+
9
+ This hook remembers that the agent ran. After each Agent or Task spawn naming
10
+ ``pr-description-writer``, it touches an empty marker file for this session in
11
+ the system temp directory. The ``gh pr create`` gate reads that marker.
12
+
13
+ The marker carries one fact — the agent ran this session — so a touch records
14
+ everything the gate needs, and two parallel spawns racing on the same touch
15
+ leave the same result. The file name carries the session id, so a marker a
16
+ crashed session leaves behind is inert: no later session reads it.
17
+
18
+ The hook never blocks a tool call. A tool that is not a spawn, a spawn of
19
+ another agent, a malformed payload, a payload naming no session, or a failed
20
+ touch each returns quietly.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import sys
26
+ from pathlib import Path
27
+
28
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
29
+ if _hooks_dir not in sys.path:
30
+ sys.path.insert(0, _hooks_dir)
31
+
32
+ from hooks_constants.pr_description_writer_gate_constants import ( # noqa: E402
33
+ ALL_AGENT_SPAWN_TOOL_NAMES,
34
+ PR_DESCRIPTION_WRITER_SUBAGENT_TYPE,
35
+ SUBAGENT_TYPE_FIELD_NAME,
36
+ TOOL_INPUT_FIELD_NAME,
37
+ TOOL_NAME_FIELD_NAME,
38
+ )
39
+ from hooks_constants.pr_description_writer_gate_constants import ( # noqa: E402
40
+ spawn_marker_path as shared_spawn_marker_path,
41
+ )
42
+ from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
43
+ read_hook_input_dictionary_from_stdin,
44
+ )
45
+
46
+
47
+ def spawn_marker_path(session_id: str, temp_directory: Path | None = None) -> Path | None:
48
+ """Return the marker path for one session, or None without a usable id.
49
+
50
+ ::
51
+
52
+ session_id = "abc-123" -> <temp>/claude-session-pr-description-writer-abc-123.marker
53
+ session_id = "" -> None
54
+ session_id = "///" -> None
55
+
56
+ Unsafe characters are stripped so the name stays anchored inside the temp
57
+ directory. A session id that sanitizes to nothing yields no path, so every
58
+ marker on disk belongs to one real session and a marker a crashed session
59
+ leaves behind is inert — no later session reads it.
60
+
61
+ Args:
62
+ session_id: Raw ``session_id`` from the hook payload.
63
+ temp_directory: The directory holding the marker. Defaults to the
64
+ system temp directory.
65
+
66
+ Returns:
67
+ Absolute path to this session's marker file, or None when the payload
68
+ names no session this hook can key on.
69
+ """
70
+ return shared_spawn_marker_path(session_id, temp_directory)
71
+
72
+
73
+ def _record_spawn(session_id: str) -> None:
74
+ """Touch this session's marker file, ignoring a write failure.
75
+
76
+ Args:
77
+ session_id: Raw ``session_id`` from the hook payload.
78
+ """
79
+ marker_file = spawn_marker_path(session_id)
80
+ if marker_file is None:
81
+ return
82
+ try:
83
+ marker_file.parent.mkdir(parents=True, exist_ok=True)
84
+ marker_file.touch(exist_ok=True)
85
+ except OSError:
86
+ return
87
+
88
+
89
+ def main() -> None:
90
+ """Record the marker when this payload is a pr-description-writer spawn.
91
+
92
+ Reads the PostToolUse payload from stdin and touches this session's marker
93
+ file when an Agent or Task spawn names the ``pr-description-writer`` agent.
94
+ Returns on every other branch — another tool, another agent, a malformed
95
+ payload, or a missing ``tool_input`` — so the spawn is never blocked.
96
+ """
97
+ hook_payload = read_hook_input_dictionary_from_stdin()
98
+ if hook_payload is None:
99
+ return
100
+ tool_name = hook_payload.get(TOOL_NAME_FIELD_NAME, "")
101
+ if tool_name not in ALL_AGENT_SPAWN_TOOL_NAMES:
102
+ return
103
+ tool_input = hook_payload.get(TOOL_INPUT_FIELD_NAME, {})
104
+ if not isinstance(tool_input, dict):
105
+ return
106
+ if tool_input.get(SUBAGENT_TYPE_FIELD_NAME) != PR_DESCRIPTION_WRITER_SUBAGENT_TYPE:
107
+ return
108
+ session_id = str(hook_payload.get("session_id") or "")
109
+ _record_spawn(session_id)
110
+
111
+
112
+ if __name__ == "__main__":
113
+ main()
@@ -31,12 +31,14 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
31
31
  if _hooks_dir not in sys.path:
32
32
  sys.path.insert(0, _hooks_dir)
33
33
 
34
+ from blocking.codex_apply_patch import payload_patch_target_paths # noqa: E402
34
35
  from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
35
36
  read_hook_input_dictionary_from_stdin,
36
37
  )
37
38
  from hooks_constants.session_edit_stage_gate_constants import ( # noqa: E402
38
39
  ALL_EDITED_FILE_PATHS_KEY,
39
40
  ALL_TRACKED_EDIT_TOOL_NAMES,
41
+ APPLY_PATCH_TOOL_NAME,
40
42
  LOCK_ACQUIRE_RETRY_SECONDS,
41
43
  LOCK_ACQUIRE_TIMEOUT_SECONDS,
42
44
  SESSION_EDIT_FILE_PREFIX,
@@ -49,6 +51,23 @@ from hooks_constants.session_edit_stage_gate_constants import ( # noqa: E402
49
51
  )
50
52
 
51
53
 
54
+ def _resolved_path_or_none(file_path: str) -> str | None:
55
+ """Return the resolved absolute path, or None when resolution fails."""
56
+ try:
57
+ return str(Path(file_path).resolve())
58
+ except OSError:
59
+ return None
60
+
61
+
62
+ def _record_apply_patch_edits(hook_payload: dict, tool_input: dict) -> None:
63
+ """Record the resolved path of every file a Codex apply_patch payload names."""
64
+ session_id = str(hook_payload.get("session_id") or "")
65
+ for each_file_path in payload_patch_target_paths(hook_payload, tool_input):
66
+ resolved_file_path = _resolved_path_or_none(each_file_path)
67
+ if resolved_file_path is not None:
68
+ _record_edited_path(session_id, resolved_file_path)
69
+
70
+
52
71
  def _session_edit_file_path(session_id: str) -> Path:
53
72
  """Return the per-session tracker file path in the system temp directory.
54
73
 
@@ -193,7 +212,7 @@ def _record_edited_path(session_id: str, resolved_file_path: str) -> None:
193
212
 
194
213
 
195
214
  def main() -> None:
196
- """Record the edited file path for a Write, Edit, or MultiEdit.
215
+ """Record the edited file path for a Write, Edit, MultiEdit, or apply_patch.
197
216
 
198
217
  Reads the PostToolUse payload from stdin, and for a tracked edit tool
199
218
  appends the resolved absolute file path to this session's tracker file.
@@ -209,12 +228,14 @@ def main() -> None:
209
228
  tool_input = hook_payload.get("tool_input", {})
210
229
  if not isinstance(tool_input, dict):
211
230
  return
231
+ if tool_name == APPLY_PATCH_TOOL_NAME:
232
+ _record_apply_patch_edits(hook_payload, tool_input)
233
+ return
212
234
  file_path = tool_input.get("file_path", "")
213
235
  if not isinstance(file_path, str) or not file_path:
214
236
  return
215
- try:
216
- resolved_file_path = str(Path(file_path).resolve())
217
- except OSError:
237
+ resolved_file_path = _resolved_path_or_none(file_path)
238
+ if resolved_file_path is None:
218
239
  return
219
240
  session_id = str(hook_payload.get("session_id") or "")
220
241
  _record_edited_path(session_id, resolved_file_path)