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
@@ -21,8 +21,11 @@ __all__ = [
21
21
  "WRITE_TOOL_NAME",
22
22
  "EDIT_TOOL_NAME",
23
23
  "MULTI_EDIT_TOOL_NAME",
24
+ "APPLY_PATCH_TOOL_NAME",
24
25
  "ALL_WRITE_AND_EDIT_TOOL_NAMES",
25
26
  "ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES",
27
+ "ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES",
28
+ "ALL_IMMEDIATE_HARM_SCRIPT_PATHS",
26
29
  "STATE_DESCRIPTION_BLOCKER_MODULE_NAME",
27
30
  "SYSTEM_MESSAGE_JOIN_SEPARATOR",
28
31
  "HostedHookEntry",
@@ -42,11 +45,25 @@ CONTEXT_JOIN_SEPARATOR = "\n"
42
45
  WRITE_TOOL_NAME = "Write"
43
46
  EDIT_TOOL_NAME = "Edit"
44
47
  MULTI_EDIT_TOOL_NAME = "MultiEdit"
48
+ APPLY_PATCH_TOOL_NAME = "apply_patch"
45
49
 
46
50
  ALL_WRITE_AND_EDIT_TOOL_NAMES: frozenset[str] = frozenset({WRITE_TOOL_NAME, EDIT_TOOL_NAME})
47
51
  ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES: frozenset[str] = frozenset(
48
52
  {WRITE_TOOL_NAME, EDIT_TOOL_NAME, MULTI_EDIT_TOOL_NAME}
49
53
  )
54
+ ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES: frozenset[str] = frozenset(
55
+ ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES | {APPLY_PATCH_TOOL_NAME}
56
+ )
57
+
58
+ # apply_patch reaches this narrower roster: the gates whose miss causes immediate
59
+ # harm, plus the TDD gate and the edit tracker. It stays off the full lint surface.
60
+ ALL_IMMEDIATE_HARM_SCRIPT_PATHS: tuple[str, ...] = (
61
+ "blocking/pii_prevention_blocker.py",
62
+ "blocking/sensitive_file_protector.py",
63
+ "blocking/write_existing_file_blocker.py",
64
+ "blocking/code_rules_enforcer.py",
65
+ "blocking/tdd_enforcer.py",
66
+ )
50
67
 
51
68
 
52
69
  STATE_DESCRIPTION_BLOCKER_MODULE_NAME = "state_description_blocker"
@@ -78,52 +95,52 @@ class HostedHookEntry:
78
95
  ALL_HOSTED_HOOK_ENTRIES: tuple[HostedHookEntry, ...] = (
79
96
  HostedHookEntry(
80
97
  script_relative_path="blocking/write_existing_file_blocker.py",
81
- applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
98
+ applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES,
82
99
  ),
83
100
  HostedHookEntry(
84
101
  script_relative_path="blocking/sensitive_file_protector.py",
85
- applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
102
+ applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES,
86
103
  ),
87
104
  HostedHookEntry(
88
105
  script_relative_path="blocking/pii_prevention_blocker.py",
89
- applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
106
+ applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES,
90
107
  ),
91
108
  HostedHookEntry(
92
109
  script_relative_path="validation/hook_format_validator.py",
93
- applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
110
+ applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
94
111
  ),
95
112
  HostedHookEntry(
96
113
  script_relative_path="blocking/code_rules_enforcer.py",
97
- applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
114
+ applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES,
98
115
  ),
99
116
  HostedHookEntry(
100
117
  script_relative_path="blocking/tdd_enforcer.py",
101
- applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
118
+ applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES,
102
119
  ),
103
120
  HostedHookEntry(
104
121
  script_relative_path="blocking/windows_rmtree_blocker.py",
105
- applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
122
+ applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
106
123
  ),
107
124
  HostedHookEntry(
108
125
  script_relative_path="blocking/duplicate_rmtree_helper_blocker.py",
109
- applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
126
+ applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
110
127
  ),
111
128
  HostedHookEntry(
112
129
  script_relative_path="blocking/state_description_blocker.py",
113
- applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
130
+ applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
114
131
  native_module_name=STATE_DESCRIPTION_BLOCKER_MODULE_NAME,
115
132
  ),
116
133
  HostedHookEntry(
117
134
  script_relative_path="blocking/stale_comment_reference_blocker.py",
118
- applicable_tool_names=frozenset({EDIT_TOOL_NAME}),
135
+ applicable_tool_names=frozenset({EDIT_TOOL_NAME, MULTI_EDIT_TOOL_NAME}),
119
136
  ),
120
137
  HostedHookEntry(
121
138
  script_relative_path="blocking/subprocess_budget_completeness.py",
122
- applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
139
+ applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
123
140
  ),
124
141
  HostedHookEntry(
125
142
  script_relative_path="blocking/hook_prose_detector_consistency.py",
126
- applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
143
+ applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
127
144
  ),
128
145
  HostedHookEntry(
129
146
  script_relative_path="blocking/workflow_substitution_slot_blocker.py",
@@ -155,12 +172,12 @@ ALL_HOSTED_HOOK_ENTRIES: tuple[HostedHookEntry, ...] = (
155
172
  ),
156
173
  HostedHookEntry(
157
174
  script_relative_path="advisory/refactor_guard.py",
158
- applicable_tool_names=frozenset({EDIT_TOOL_NAME}),
175
+ applicable_tool_names=frozenset({EDIT_TOOL_NAME, MULTI_EDIT_TOOL_NAME}),
159
176
  is_blocking=False,
160
177
  ),
161
178
  HostedHookEntry(
162
179
  script_relative_path="advisory/migration_safety_advisor.py",
163
- applicable_tool_names=frozenset({EDIT_TOOL_NAME}),
180
+ applicable_tool_names=frozenset({EDIT_TOOL_NAME, MULTI_EDIT_TOOL_NAME}),
164
181
  is_blocking=False,
165
182
  ),
166
183
  )
@@ -18,6 +18,15 @@ from __future__ import annotations
18
18
 
19
19
  import re
20
20
 
21
+ # The dispatcher constants module is the single source for tool names.
22
+ from hooks_constants.pre_tool_use_dispatcher_constants import ( # noqa: F401
23
+ APPLY_PATCH_TOOL_NAME,
24
+ EDIT_TOOL_NAME,
25
+ MULTI_EDIT_TOOL_NAME,
26
+ WRITE_TOOL_NAME,
27
+ )
28
+
29
+
21
30
  SESSION_EDIT_FILE_PREFIX: str = "claude-session-edits-"
22
31
  SESSION_EDIT_FILE_SUFFIX: str = ".json"
23
32
  ALL_EDITED_FILE_PATHS_KEY: str = "all_edited_file_paths"
@@ -27,7 +36,12 @@ SESSION_ID_UNSAFE_CHARACTERS_PATTERN: re.Pattern[str] = re.compile(r"[^A-Za-z0-9
27
36
  SESSION_START_SOURCE_PAYLOAD_KEY: str = "source"
28
37
  SESSION_START_SOURCE_FRESH_STARTUP: str = "startup"
29
38
 
30
- ALL_TRACKED_EDIT_TOOL_NAMES: tuple[str, ...] = ("Write", "Edit", "MultiEdit")
39
+ ALL_TRACKED_EDIT_TOOL_NAMES: tuple[str, ...] = (
40
+ WRITE_TOOL_NAME,
41
+ EDIT_TOOL_NAME,
42
+ MULTI_EDIT_TOOL_NAME,
43
+ APPLY_PATCH_TOOL_NAME,
44
+ )
31
45
 
32
46
  STATE_FILE_ATOMIC_WRITE_SUFFIX: str = ".tmp"
33
47
  STATE_FILE_JSON_INDENT_SPACES: int = 2
@@ -41,7 +41,10 @@ PERMISSION_DECISION_KEY = "permissionDecision"
41
41
  DENY_DECISION = "deny"
42
42
  PERMISSION_DECISION_REASON_KEY = "permissionDecisionReason"
43
43
 
44
- DOLLAR_PAREN_PATTERN = re.compile(r"\$\((?!\()")
44
+ DOLLAR_PAREN_PATTERN = re.compile(r"\$\(")
45
+ OPEN_PAREN_CHARACTER = "("
46
+ CLOSE_PAREN_CHARACTER = ")"
47
+ PAREN_DEPTH_AFTER_DOUBLE_OPEN = 2
45
48
  EVEN_BACKSLASH_BACKTICK_PATTERN = re.compile(r"(?<!\\)(?:\\\\)*`")
46
49
  PROCESS_SUBSTITUTION_PATTERN = re.compile(r"[<>]\(")
47
50
  SINGLE_QUOTED_RUN_PATTERN = re.compile(r"'[^']*'")
@@ -2,6 +2,7 @@
2
2
 
3
3
  from re import IGNORECASE, Pattern, compile
4
4
 
5
+
5
6
  ALL_COMMENT_TRANSITION_PATTERNS: list[Pattern[str]] = [
6
7
  compile(r"\binstead of\b", IGNORECASE),
7
8
  compile(r"\bpreviously\b", IGNORECASE),
@@ -0,0 +1,71 @@
1
+ """Resolve the post-edit content a Write, Edit, or MultiEdit payload leaves on disk.
2
+
3
+ Shared by subprocess_budget_completeness.py, which parses this content's AST to
4
+ find a named budget helper that omits a reachable subprocess timeout.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from hooks_constants.multi_edit_reconstruction import apply_edits
10
+
11
+ __all__ = [
12
+ "existing_file_content",
13
+ "reconstructed_edit_content",
14
+ "reconstructed_multi_edit_content",
15
+ "resolved_content",
16
+ ]
17
+
18
+
19
+ def existing_file_content(file_path: str) -> str | None:
20
+ try:
21
+ with open(file_path, "r", encoding="utf-8") as existing_file:
22
+ return existing_file.read()
23
+ except (FileNotFoundError, OSError, UnicodeDecodeError):
24
+ return None
25
+
26
+
27
+ def reconstructed_edit_content(all_tool_input_fields: dict[str, object]) -> str:
28
+ file_path = all_tool_input_fields.get("file_path")
29
+ old_string = all_tool_input_fields.get("old_string")
30
+ new_string = all_tool_input_fields.get("new_string")
31
+ if not isinstance(file_path, str) or not isinstance(old_string, str):
32
+ return ""
33
+ if not isinstance(new_string, str) or not old_string:
34
+ return ""
35
+ existing_content = existing_file_content(file_path)
36
+ if existing_content is None or old_string not in existing_content:
37
+ return ""
38
+ return existing_content.replace(old_string, new_string, 1)
39
+
40
+
41
+ def reconstructed_multi_edit_content(
42
+ all_tool_input_fields: dict[str, object], all_edits: list[object]
43
+ ) -> str:
44
+ """Return the whole file MultiEdit's edit list would leave on disk.
45
+
46
+ Args:
47
+ all_tool_input_fields: The MultiEdit payload's tool_input mapping.
48
+ all_edits: The payload's ``edits`` list.
49
+
50
+ Returns:
51
+ The reconstructed content, or an empty string when the target file
52
+ cannot be read.
53
+ """
54
+ file_path = all_tool_input_fields.get("file_path")
55
+ if not isinstance(file_path, str):
56
+ return ""
57
+ existing_content = existing_file_content(file_path)
58
+ if existing_content is None:
59
+ return ""
60
+ all_edit_dicts = [each_edit for each_edit in all_edits if isinstance(each_edit, dict)]
61
+ return apply_edits(existing_content, all_edit_dicts)
62
+
63
+
64
+ def resolved_content(all_tool_input_fields: dict[str, object]) -> str:
65
+ written_content = all_tool_input_fields.get("content")
66
+ if isinstance(written_content, str):
67
+ return written_content
68
+ all_edits = all_tool_input_fields.get("edits")
69
+ if isinstance(all_edits, list):
70
+ return reconstructed_multi_edit_content(all_tool_input_fields, all_edits)
71
+ return reconstructed_edit_content(all_tool_input_fields)
@@ -0,0 +1,13 @@
1
+ """Directive text for the task_tool_prompt SessionStart hook."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "TASK_TOOL_DIRECTIVE",
7
+ ]
8
+
9
+ TASK_TOOL_DIRECTIVE = (
10
+ "TASK TRACKING: Track this session's work with the session's task or todo "
11
+ "tracking tool, whichever this host provides. Create tasks at the start "
12
+ "of the session, then update them as work proceeds."
13
+ )
@@ -38,12 +38,14 @@ _EXPECTED_BASH_ORDER = (
38
38
  "blocking/test_preflight_check.py",
39
39
  "blocking/windows_rmtree_blocker.py",
40
40
  "blocking/gh_pr_author_enforcer.py",
41
+ "blocking/pr_description_writer_gate.py",
41
42
  )
42
43
 
43
44
  _POWERSHELL_APPLICABLE = (
44
45
  "blocking/cursor_cli_python_misfire_blocker.py",
45
46
  "blocking/unscoped_search_blocker.py",
46
47
  "blocking/pii_prevention_blocker.py",
48
+ "blocking/pr_description_writer_gate.py",
47
49
  )
48
50
 
49
51
 
@@ -17,7 +17,6 @@ if str(_HOOKS_ROOT) not in sys.path:
17
17
  sys.path.insert(0, str(_HOOKS_ROOT))
18
18
 
19
19
  from hooks_constants.code_rules_enforcer_constants import (
20
- ALL_HOOK_INFRASTRUCTURE_EDIT_LANE_CHECK_NAMES,
21
20
  ALL_VALIDATION_PHASES,
22
21
  CONFIG_DIRECTORY_SEGMENT,
23
22
  CONSTANTS_MODULE_SUFFIX,
@@ -28,6 +27,8 @@ from hooks_constants.code_rules_enforcer_constants import (
28
27
  RETURN_CALL_OPENING_PARENTHESIS_PATTERN,
29
28
  SCHEMA_OPTIONS_PROPERTY_KEY_PATTERN,
30
29
  UNKNOWN_VALIDATION_PHASE_MESSAGE_TEMPLATE,
30
+ apply_edits,
31
+ edits_for_tool,
31
32
  )
32
33
 
33
34
 
@@ -111,11 +112,3 @@ def test_validation_phase_constants_are_reexported_and_render() -> None:
111
112
  phase="bogus_phase", all_phases=sorted(ALL_VALIDATION_PHASES)
112
113
  )
113
114
  assert "bogus_phase" in rendered_message
114
-
115
-
116
- def test_hook_infrastructure_edit_lane_check_names_are_reexported() -> None:
117
- assert ALL_HOOK_INFRASTRUCTURE_EDIT_LANE_CHECK_NAMES == {
118
- "check_same_file_inline_duplicate_body",
119
- "check_zero_payload_function_alias",
120
- "check_unanchored_command_dispatch",
121
- }
@@ -0,0 +1,102 @@
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
+ def test_joined_new_strings_joins_every_new_string_on_a_newline() -> None:
73
+ """Each edit's introduced text reaches the scanner as its own line."""
74
+ tool_input = {
75
+ "edits": [
76
+ {"old_string": "x", "new_string": "import os"},
77
+ {"old_string": "y", "new_string": "shutil.rmtree(p)"},
78
+ ]
79
+ }
80
+ assert joined_new_strings(tool_input) == "import os\nshutil.rmtree(p)"
81
+
82
+
83
+ def test_joined_new_strings_separator_keeps_adjacent_edits_apart() -> None:
84
+ """A phrase spanning the seam belongs to neither edit, so the join breaks it."""
85
+ tool_input = {
86
+ "edits": [
87
+ {"old_string": "x", "new_string": "currently"},
88
+ {"old_string": "y", "new_string": "holds"},
89
+ ]
90
+ }
91
+ assert "currentlyholds" not in joined_new_strings(tool_input)
92
+
93
+
94
+ def test_joined_new_strings_skips_edits_whose_new_string_is_not_a_string() -> None:
95
+ """A malformed edit contributes nothing rather than raising."""
96
+ tool_input = {"edits": [{"new_string": None}, {"new_string": "kept"}]}
97
+ assert joined_new_strings(tool_input) == "kept"
98
+
99
+
100
+ def test_joined_new_strings_returns_empty_text_for_a_payload_with_no_edits() -> None:
101
+ """A payload carrying no edit list scans as empty text."""
102
+ 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
+ )