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,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": [
@@ -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,6 +6,10 @@ 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
14
  ALL_VALIDATION_PHASES,
11
15
  EDIT_LANE_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,73 @@
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
+ import tempfile
11
+ from pathlib import Path
12
+
13
+ from hooks_constants.session_edit_stage_gate_constants import (
14
+ SESSION_ID_UNSAFE_CHARACTERS_PATTERN,
15
+ )
16
+
17
+ PR_DESCRIPTION_WRITER_SUBAGENT_TYPE: str = "pr-description-writer"
18
+
19
+ ALL_AGENT_SPAWN_TOOL_NAMES: frozenset[str] = frozenset({"Agent", "Task"})
20
+
21
+ SPAWN_MARKER_FILE_PREFIX: str = "claude-session-pr-description-writer-"
22
+ SPAWN_MARKER_FILE_SUFFIX: str = ".marker"
23
+
24
+ SUBAGENT_TYPE_FIELD_NAME: str = "subagent_type"
25
+ TOOL_INPUT_FIELD_NAME: str = "tool_input"
26
+ TOOL_NAME_FIELD_NAME: str = "tool_name"
27
+ COMMAND_FIELD_NAME: str = "command"
28
+
29
+ ALL_GH_EXECUTABLE_BASENAMES: frozenset[str] = frozenset({"gh", "gh.exe", "gh.cmd"})
30
+ PR_SUBCOMMAND_TOKEN: str = "pr"
31
+ CREATE_SUBCOMMAND_TOKEN: str = "create"
32
+ GH_PR_CREATE_MINIMUM_TOKEN_COUNT: int = 3
33
+
34
+ SPAWN_BYPASS_MARKER: str = "# pr-description-skip"
35
+
36
+ CORRECTIVE_MESSAGE: str = (
37
+ "BLOCKED: [PR_DESCRIPTION_WRITER] This session runs `gh pr create` with no "
38
+ "`pr-description-writer` agent spawn recorded.\n\n"
39
+ "The agent writes the title and body from the current diff, then returns the "
40
+ "markdown and a body-file path for you to publish.\n\n"
41
+ "Two ways forward:\n"
42
+ " 1. Spawn the agent (Agent tool, subagent_type `pr-description-writer`), "
43
+ "then run `gh pr create --body-file <path>` with what it returns.\n"
44
+ " 2. Append the trailing comment `# pr-description-skip` to the command when "
45
+ "you are writing the body yourself on purpose."
46
+ )
47
+
48
+
49
+ def spawn_marker_path(session_id: str, temp_directory: Path | None = None) -> Path | None:
50
+ """Return this session's marker file path, or None when it names no session.
51
+
52
+ The tracker writes this path and the gate reads it, so the file name lives
53
+ in one place. A session id that sanitizes to nothing yields no path, so
54
+ every marker on disk belongs to one real session and a marker a crashed
55
+ session leaves behind is inert.
56
+
57
+ Args:
58
+ session_id: Raw ``session_id`` from the hook payload.
59
+ temp_directory: The directory holding the marker. Defaults to the
60
+ system temp directory.
61
+
62
+ Returns:
63
+ Absolute path to this session's marker file, or None when the payload
64
+ names no session this hook can key on.
65
+ """
66
+ sanitized_session_id = SESSION_ID_UNSAFE_CHARACTERS_PATTERN.sub("", session_id)
67
+ if not sanitized_session_id:
68
+ return None
69
+ file_name = (
70
+ f"{SPAWN_MARKER_FILE_PREFIX}{sanitized_session_id}{SPAWN_MARKER_FILE_SUFFIX}"
71
+ )
72
+ resolved_temp_directory = temp_directory or Path(tempfile.gettempdir())
73
+ return resolved_temp_directory / file_name
@@ -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
@@ -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, edits_for_tool
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 apply_edits(existing_content, edits_for_tool("Edit", all_tool_input_fields))
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)
@@ -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
 
@@ -27,6 +27,8 @@ from hooks_constants.code_rules_enforcer_constants import (
27
27
  RETURN_CALL_OPENING_PARENTHESIS_PATTERN,
28
28
  SCHEMA_OPTIONS_PROPERTY_KEY_PATTERN,
29
29
  UNKNOWN_VALIDATION_PHASE_MESSAGE_TEMPLATE,
30
+ apply_edits,
31
+ edits_for_tool,
30
32
  )
31
33
 
32
34