claude-dev-env 2.3.0 → 2.4.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 (136) hide show
  1. package/CLAUDE.md +3 -2
  2. package/agents/CLAUDE.md +1 -0
  3. package/agents/code-verifier.md +3 -3
  4. package/agents/skill-writer-agent.md +84 -0
  5. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +141 -41
  6. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +29 -13
  7. package/docs/CLAUDE.md +1 -0
  8. package/docs/references/CLAUDE.md +1 -0
  9. package/docs/references/code-review-enforcement.md +97 -0
  10. package/docs/wsl-docker-cowork-starter-matrix.md +89 -0
  11. package/hooks/blocking/CLAUDE.md +8 -1
  12. package/hooks/blocking/code_review_enforcement_config_bootstrap.py +53 -0
  13. package/hooks/blocking/code_review_gate_deny.py +74 -0
  14. package/hooks/blocking/code_review_pr_create_gate.py +194 -0
  15. package/hooks/blocking/code_review_push_gate.py +140 -0
  16. package/hooks/blocking/code_review_stamp_directory_write_blocker.py +340 -0
  17. package/hooks/blocking/code_review_stamp_store.py +233 -0
  18. package/hooks/blocking/code_review_stamp_write_blocker_parts/__init__.py +7 -0
  19. package/hooks/blocking/code_review_stamp_write_blocker_parts/conftest.py +15 -0
  20. package/hooks/blocking/code_review_stamp_write_blocker_parts/obfuscated_stamp_path_reference.py +212 -0
  21. package/hooks/blocking/code_review_stamp_write_blocker_parts/split_directory_change_into_stamp.py +138 -0
  22. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_obfuscated_stamp_path_reference.py +49 -0
  23. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_split_directory_change_into_stamp.py +38 -0
  24. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +39 -27
  25. package/hooks/blocking/config/code_review_enforcement_constants.py +110 -0
  26. package/hooks/blocking/config/test_code_review_enforcement_constants.py +108 -0
  27. package/hooks/blocking/config/verified_commit_constants.py +13 -9
  28. package/hooks/blocking/conftest.py +2 -0
  29. package/hooks/blocking/convergence_gate_blocker.py +112 -23
  30. package/hooks/blocking/destructive_command_blocker.py +19 -6
  31. package/hooks/blocking/pr_description_proof_of_work.py +52 -34
  32. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
  33. package/hooks/blocking/test_code_review_enforcement_config_bootstrap.py +62 -0
  34. package/hooks/blocking/test_code_review_gate_deny.py +54 -0
  35. package/hooks/blocking/test_code_review_pr_create_gate.py +185 -0
  36. package/hooks/blocking/test_code_review_push_gate.py +189 -0
  37. package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +180 -0
  38. package/hooks/blocking/test_code_review_stamp_store.py +205 -0
  39. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +124 -2
  40. package/hooks/blocking/test_convergence_gate_blocker.py +153 -5
  41. package/hooks/blocking/test_destructive_command_blocker.py +1 -1
  42. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +45 -0
  43. package/hooks/blocking/test_pr_description_proof_of_work.py +151 -0
  44. package/hooks/blocking/test_pre_tool_use_dispatcher.py +8 -8
  45. package/hooks/blocking/test_verification_verdict_store.py +920 -903
  46. package/hooks/blocking/test_volatile_path_in_post_blocker.py +114 -2
  47. package/hooks/blocking/verification_verdict_store.py +27 -5
  48. package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +29 -17
  49. package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +35 -0
  50. package/hooks/blocking/volatile_path_in_post_blocker.py +69 -8
  51. package/hooks/git-hooks/git_hooks_constants/__init__.py +6 -0
  52. package/hooks/git-hooks/pre_push.py +89 -2
  53. package/hooks/git-hooks/test_pre_push.py +103 -0
  54. package/hooks/hooks.json +16 -1
  55. package/hooks/hooks_constants/CLAUDE.md +1 -0
  56. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +8 -0
  57. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
  58. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +26 -11
  59. package/hooks/hooks_constants/convergence_gate_blocker_constants.py +20 -3
  60. package/hooks/hooks_constants/destructive_command_segment_constants.py +3 -1
  61. package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +0 -4
  62. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  63. package/hooks/hooks_constants/pyproject_config_discovery_constants.py +16 -0
  64. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +24 -0
  65. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +6 -0
  66. package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +8 -1
  67. package/hooks/validators/CLAUDE.md +1 -0
  68. package/hooks/validators/mypy_integration.py +63 -50
  69. package/hooks/validators/pyproject_config_discovery.py +101 -0
  70. package/hooks/validators/ruff_integration.py +211 -23
  71. package/hooks/validators/run_all_validators.py +21 -14
  72. package/hooks/validators/test_mypy_integration.py +32 -0
  73. package/hooks/validators/test_pyproject_config_discovery.py +94 -0
  74. package/hooks/validators/test_ruff_integration.py +68 -1
  75. package/hooks/validators/test_run_all_validators.py +25 -0
  76. package/hooks/validators/test_run_all_validators_config_discovery.py +123 -0
  77. package/package.json +1 -1
  78. package/rules/docstring-prose-matches-implementation.md +20 -43
  79. package/rules/durable-post-artifacts.md +7 -0
  80. package/scripts/codec_forwarding_test_support.py +83 -0
  81. package/scripts/conftest.py +8 -0
  82. package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -1
  83. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +44 -1
  84. package/scripts/dev_env_scripts_constants/code_review_constants.py +129 -12
  85. package/scripts/dev_env_scripts_constants/test_code_review_constants.py +55 -0
  86. package/scripts/invoke_code_review.py +550 -38
  87. package/scripts/resolve_worker_spawn.py +8 -1
  88. package/scripts/test_invoke_code_review.py +298 -4
  89. package/scripts/test_invoke_code_review_codec.py +77 -0
  90. package/scripts/test_resolve_worker_spawn_codec.py +101 -0
  91. package/skills/autoconverge/SKILL.md +9 -3
  92. package/skills/autoconverge/reference/convergence.md +2 -1
  93. package/skills/autoconverge/reference/multi-pr.md +6 -1
  94. package/skills/autoconverge/reference/stop-conditions.md +16 -10
  95. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +1 -1
  96. package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +175 -3
  97. package/skills/autoconverge/workflow/converge.contract.test.mjs +19 -0
  98. package/skills/autoconverge/workflow/converge.mjs +24 -8
  99. package/skills/autoconverge/workflow/converge_multi.mjs +7 -3
  100. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +5 -0
  101. package/skills/fresh-branch/CLAUDE.md +2 -0
  102. package/skills/fresh-branch/SKILL.md +2 -0
  103. package/skills/fresh-branch/scripts/create_fresh_branch.py +78 -180
  104. package/skills/fresh-branch/scripts/fresh_branch_git_commands.py +285 -0
  105. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -0
  106. package/skills/fresh-branch/scripts/pytest.ini +4 -0
  107. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +98 -0
  108. package/skills/fresh-branch/scripts/test_fresh_branch_git_commands.py +310 -0
  109. package/skills/prototype/SKILL.md +86 -0
  110. package/skills/prototype/reference/honest-limitations.md +23 -0
  111. package/skills/prototype/reference/promotion-tasks.md +23 -0
  112. package/skills/prototype/scripts/build_sandbox_settings.py +249 -0
  113. package/skills/prototype/scripts/conftest.py +15 -0
  114. package/skills/prototype/scripts/launch_sandbox.py +205 -0
  115. package/skills/prototype/scripts/probe_sandbox_safety.py +311 -0
  116. package/skills/prototype/scripts/prototype_scripts_constants/__init__.py +1 -0
  117. package/skills/prototype/scripts/prototype_scripts_constants/config/__init__.py +0 -0
  118. package/skills/prototype/scripts/prototype_scripts_constants/config/build_sandbox_settings_constants.py +41 -0
  119. package/skills/prototype/scripts/prototype_scripts_constants/config/launch_sandbox_constants.py +23 -0
  120. package/skills/prototype/scripts/prototype_scripts_constants/config/probe_sandbox_safety_constants.py +45 -0
  121. package/skills/prototype/scripts/prototype_scripts_constants/config/prototype_common_constants.py +10 -0
  122. package/skills/prototype/scripts/test_build_sandbox_settings.py +275 -0
  123. package/skills/prototype/scripts/test_launch_sandbox.py +303 -0
  124. package/skills/prototype/scripts/test_probe_sandbox_safety.py +284 -0
  125. package/skills/prototype/workflows/promotion.md +27 -0
  126. package/skills/prototype/workflows/sandbox.md +35 -0
  127. package/skills/skill-builder/CLAUDE.md +3 -3
  128. package/skills/skill-builder/SKILL.md +5 -5
  129. package/skills/skill-builder/references/CLAUDE.md +1 -1
  130. package/skills/skill-builder/references/delegation-map.md +3 -3
  131. package/skills/skill-builder/references/description-field.md +1 -1
  132. package/skills/skill-builder/references/skill-modularity.md +2 -3
  133. package/skills/skill-builder/workflows/CLAUDE.md +1 -1
  134. package/skills/skill-builder/workflows/improve-skill.md +1 -1
  135. package/skills/skill-builder/workflows/new-skill.md +2 -2
  136. package/skills/team-advisor/SKILL.md +2 -2
@@ -41,7 +41,7 @@ def test_scan_detects_job_scratch_path_forward_slash() -> None:
41
41
 
42
42
 
43
43
  def test_scan_detects_worktree_path() -> None:
44
- text = r"edited .claude\worktrees\feature\file.py"
44
+ text = r"edited C:\Users\me\.claude\worktrees\feature\file.py"
45
45
  assert scan_text_for_volatile_marker(text) == ".claude/worktrees/"
46
46
 
47
47
 
@@ -71,6 +71,103 @@ def test_scan_clean_body_returns_none() -> None:
71
71
  assert scan_text_for_volatile_marker(text) is None
72
72
 
73
73
 
74
+ def test_scan_bare_backticked_worktree_mention_is_allowed() -> None:
75
+ text = "the `.claude/worktrees/` prefix sits in ALL_TOOLING_STATE_PREFIXES"
76
+ assert scan_text_for_volatile_marker(text) is None
77
+
78
+
79
+ def test_scan_bare_start_of_text_worktree_mention_is_allowed() -> None:
80
+ text = ".claude/worktrees/ entries are skipped by the manifest"
81
+ assert scan_text_for_volatile_marker(text) is None
82
+
83
+
84
+ def test_scan_windows_absolute_worktree_path_is_detected() -> None:
85
+ text = r"C:\Users\me\.claude\worktrees\wt-1\notes.md"
86
+ assert scan_text_for_volatile_marker(text) == ".claude/worktrees/"
87
+
88
+
89
+ def test_scan_home_anchored_worktree_path_is_detected() -> None:
90
+ text = "~/.claude/worktrees/wt-2/file.py"
91
+ assert scan_text_for_volatile_marker(text) == ".claude/worktrees/"
92
+
93
+
94
+ def test_scan_posix_absolute_job_scratch_path_is_detected() -> None:
95
+ text = "/home/me/.claude-editor/jobs/j1/log.txt"
96
+ assert scan_text_for_volatile_marker(text) == ".claude-editor/jobs/"
97
+
98
+
99
+ def test_scan_bare_parenthesized_job_scratch_mention_is_allowed() -> None:
100
+ text = "(.claude-editor/jobs/) as a directory name in prose"
101
+ assert scan_text_for_volatile_marker(text) is None
102
+
103
+
104
+ def test_scan_bare_mention_before_anchored_path_is_detected() -> None:
105
+ text = (
106
+ ".claude/worktrees/ is the manifest key; the live path is "
107
+ r"C:\Users\me\.claude\worktrees\wt-1\notes.md"
108
+ )
109
+ assert scan_text_for_volatile_marker(text) == ".claude/worktrees/"
110
+
111
+
112
+ def test_scan_unchanged_bare_markers_still_detected() -> None:
113
+ assert scan_text_for_volatile_marker(r"saved to %TEMP%\x") == "%temp%"
114
+ assert scan_text_for_volatile_marker("log at /tmp/x") == "/tmp/"
115
+ assert (
116
+ scan_text_for_volatile_marker(r"C:\Users\x\AppData\Local\Temp\y")
117
+ == "appdata/local/temp"
118
+ )
119
+
120
+
121
+ def test_scan_space_prefixed_relative_worktree_path_is_detected() -> None:
122
+ text = "see .claude/worktrees/wt-1/notes.md"
123
+ assert scan_text_for_volatile_marker(text) == ".claude/worktrees/"
124
+
125
+
126
+ def test_scan_cd_relative_worktree_path_is_detected() -> None:
127
+ text = "cd .claude/worktrees/wt-199 && pytest"
128
+ assert scan_text_for_volatile_marker(text) == ".claude/worktrees/"
129
+
130
+
131
+ def test_scan_start_of_text_relative_worktree_path_is_detected() -> None:
132
+ text = ".claude/worktrees/wt-1/notes.md"
133
+ assert scan_text_for_volatile_marker(text) == ".claude/worktrees/"
134
+
135
+
136
+ def test_scan_markdown_link_relative_worktree_target_is_detected() -> None:
137
+ text = "[notes](.claude/worktrees/wt-1/notes.md)"
138
+ assert scan_text_for_volatile_marker(text) == ".claude/worktrees/"
139
+
140
+
141
+ def test_scan_space_prefixed_relative_job_scratch_path_is_detected() -> None:
142
+ text = "see .claude-editor/jobs/j1/log.txt"
143
+ assert scan_text_for_volatile_marker(text) == ".claude-editor/jobs/"
144
+
145
+
146
+ def test_scan_backslash_relative_worktree_path_is_detected() -> None:
147
+ text = r"see .claude\worktrees\wt-1\notes.md"
148
+ assert scan_text_for_volatile_marker(text) == ".claude/worktrees/"
149
+
150
+
151
+ def test_scan_prose_child_token_after_marker_is_detected() -> None:
152
+ text = "the .claude/worktrees/wt-name pattern"
153
+ assert scan_text_for_volatile_marker(text) == ".claude/worktrees/"
154
+
155
+
156
+ def test_scan_relative_worktree_path_with_hyphen_child_is_detected() -> None:
157
+ text = "see .claude/worktrees/-scratch/notes.md"
158
+ assert scan_text_for_volatile_marker(text) == ".claude/worktrees/"
159
+
160
+
161
+ def test_scan_placeholder_child_segment_is_allowed() -> None:
162
+ text = "worktrees live under `.claude/worktrees/<name>`"
163
+ assert scan_text_for_volatile_marker(text) is None
164
+
165
+
166
+ def test_scan_sentence_final_marker_period_is_allowed() -> None:
167
+ text = "the constant is `.claude/worktrees/`. Next."
168
+ assert scan_text_for_volatile_marker(text) is None
169
+
170
+
74
171
  def test_gh_comment_with_job_scratch_path_is_blocked() -> None:
75
172
  command = (
76
173
  'gh pr comment 669 --body "Contact sheet at '
@@ -147,7 +244,7 @@ def test_mcp_issue_comment_body_blocked() -> None:
147
244
 
148
245
 
149
246
  def test_mcp_review_comment_param_blocked() -> None:
150
- tool_input: dict[str, object] = {"comment": r"see .claude\worktrees\x\file"}
247
+ tool_input: dict[str, object] = {"comment": r"see C:\Users\me\.claude\worktrees\x\file"}
151
248
  assert _body_names_volatile_path(
152
249
  "mcp__plugin_github_github__add_reply_to_pull_request_comment", tool_input
153
250
  )
@@ -177,6 +274,21 @@ def test_extract_gh_post_body_texts_returns_inline_and_file(tmp_path: pathlib.Pa
177
274
  assert "file body text" in all_texts
178
275
 
179
276
 
277
+ def test_gh_issue_edit_quoting_bare_constant_is_allowed(tmp_path: pathlib.Path) -> None:
278
+ body_file = tmp_path / "issue_body.md"
279
+ body_file.write_text(
280
+ "The manifest skips these tooling-state prefixes:\n"
281
+ ' ".claude/worktrees/",\n'
282
+ ' ".claude-editor/jobs/",\n'
283
+ "Neither is a machine-local path.",
284
+ encoding="utf-8",
285
+ )
286
+ command = f"gh issue edit 134 --body-file {body_file}"
287
+ all_body_texts = extract_gh_post_body_texts(command)
288
+ assert all_body_texts
289
+ assert hook_module._first_volatile_marker(all_body_texts) is None
290
+
291
+
180
292
  def test_hook_subprocess_denies_volatile_gh_comment() -> None:
181
293
  payload = {
182
294
  "tool_name": "Bash",
@@ -390,12 +390,36 @@ def verdict_directory() -> Path:
390
390
  return Path.home() / CLAUDE_HOME_DIRECTORY_NAME / VERDICT_DIRECTORY_NAME
391
391
 
392
392
 
393
+ def root_key_for_repo(repo_root: str) -> str:
394
+ """Derive the shared path-key that names a work tree's store file.
395
+
396
+ ::
397
+
398
+ root_key_for_repo("/repo") == root_key_for_repo("/repo/sub") (same tree)
399
+ root_key_for_repo("/Repo") == root_key_for_repo("/repo") (case-folded)
400
+
401
+ The path is resolved, forward-slashed, and lowercased before hashing, so a
402
+ subdirectory of a work tree keys to the same file as its root and case
403
+ differences fold together. Both the verdict store and the code-review stamp
404
+ store key their per-work-tree files by this one derivation, so the two never
405
+ drift and an existing verdict file still resolves.
406
+
407
+ Args:
408
+ repo_root: The repository top-level directory.
409
+
410
+ Returns:
411
+ The lowercased hex path-key, truncated to ``ROOT_KEY_HEX_LENGTH`` chars.
412
+ """
413
+ normalized_root = str(Path(repo_root).resolve()).replace("\\", "/").lower()
414
+ return hashlib.sha256(normalized_root.encode("utf-8")).hexdigest()[:ROOT_KEY_HEX_LENGTH]
415
+
416
+
393
417
  def verdict_path_for_repo(repo_root: str) -> Path:
394
418
  """Derive the verdict file path for a repository work tree.
395
419
 
396
420
  Verdicts live outside the repository (under the user's Claude home) so
397
- no repo accumulates untracked files, keyed by a hash of the normalized
398
- work-tree path so every worktree gets its own verdict.
421
+ no repo accumulates untracked files, keyed by the shared
422
+ ``root_key_for_repo`` derivation so every worktree gets its own verdict.
399
423
 
400
424
  Args:
401
425
  repo_root: The repository top-level directory.
@@ -403,9 +427,7 @@ def verdict_path_for_repo(repo_root: str) -> Path:
403
427
  Returns:
404
428
  The verdict file path for this work tree.
405
429
  """
406
- normalized_root = str(Path(repo_root).resolve()).replace("\\", "/").lower()
407
- root_key = hashlib.sha256(normalized_root.encode("utf-8")).hexdigest()[:ROOT_KEY_HEX_LENGTH]
408
- return verdict_directory() / f"{root_key}.json"
430
+ return verdict_directory() / f"{root_key_for_repo(repo_root)}.json"
409
431
 
410
432
 
411
433
  def load_valid_verdict(repo_root: str, expected_manifest_sha256: str) -> dict | None:
@@ -81,14 +81,15 @@ def _resolved_option_value(
81
81
  return value_after_option(all_following_tokens, option_index)
82
82
 
83
83
 
84
- def gated_invocation_directory(all_following_tokens: list[str]) -> tuple[bool, str | None]:
84
+ def gated_invocation_directory(
85
+ all_following_tokens: list[str],
86
+ all_gated_subcommands: frozenset[str] = GATED_GIT_SUBCOMMANDS,
87
+ ) -> tuple[bool, str | None]:
85
88
  """Walk the quote-stripped tokens after a ``git`` word to its subcommand.
86
89
 
87
- ``git stash push`` is not gated ("push" is an argument); ``git commit -m
88
- x`` is gated ("commit" sits in subcommand position).
89
-
90
- Returns:
91
- Whether the subcommand is gated, and its ``-C``/``--work-tree`` directory.
90
+ Args:
91
+ all_following_tokens: Quote-stripped tokens after the ``git`` word.
92
+ all_gated_subcommands: Subcommand names that gate; defaults to commit+push.
92
93
  """
93
94
  repo_directory: str | None = None
94
95
  work_tree_directory: str | None = None
@@ -108,7 +109,7 @@ def gated_invocation_directory(all_following_tokens: list[str]) -> tuple[bool, s
108
109
  if each_token.startswith("-"):
109
110
  token_index += 1
110
111
  continue
111
- return each_token.lower() in GATED_GIT_SUBCOMMANDS, repo_directory or work_tree_directory
112
+ return each_token.lower() in all_gated_subcommands, repo_directory or work_tree_directory
112
113
  return False, repo_directory or work_tree_directory
113
114
 
114
115
 
@@ -167,11 +168,16 @@ def _apply_directory_change(
167
168
 
168
169
 
169
170
  def _target_directory_for_match(
170
- command_text: str, git_word_match: re.Match[str], active_directory: str
171
+ command_text: str,
172
+ git_word_match: re.Match[str],
173
+ active_directory: str,
174
+ all_gated_subcommands: frozenset[str],
171
175
  ) -> str | None:
172
176
  """Resolve the gated directory for one ``git`` word match, or None."""
173
177
  all_following_tokens = _following_tokens(command_text, git_word_match.end())
174
- is_gated, flagged_directory = gated_invocation_directory(all_following_tokens)
178
+ is_gated, flagged_directory = gated_invocation_directory(
179
+ all_following_tokens, all_gated_subcommands
180
+ )
175
181
  if not is_gated:
176
182
  return None
177
183
  if flagged_directory is None:
@@ -179,18 +185,22 @@ def _target_directory_for_match(
179
185
  return resolve_against(active_directory, flagged_directory)
180
186
 
181
187
 
182
- def gated_repo_directories(command_text: str, fallback_directory: str) -> list[str]:
183
- """Collect the directories of every git commit/push found in a command.
188
+ def gated_repo_directories(
189
+ command_text: str,
190
+ fallback_directory: str,
191
+ all_gated_subcommands: frozenset[str] = GATED_GIT_SUBCOMMANDS,
192
+ ) -> list[str]:
193
+ """Collect the directories of every gated git call found in a command.
184
194
 
185
195
  Args:
186
196
  command_text: The raw command string from the tool payload.
187
- fallback_directory: The session working directory, used as the
188
- active directory until a directory-change verb changes it and
189
- when the git call carries no ``-C`` flag.
197
+ fallback_directory: The session working directory, the active directory
198
+ until a directory-change verb or a ``-C`` flag overrides it.
199
+ all_gated_subcommands: Subcommand names that gate; defaults to commit+push.
190
200
 
191
201
  Returns:
192
- One directory per detected commit/push invocation, in order; empty
193
- when the command carries no gated git verb.
202
+ One directory per detected gated invocation, in order; empty when the
203
+ command carries no gated git verb.
194
204
  """
195
205
  command_text = collapse_line_continuations(command_text)
196
206
  active_directory = fallback_directory
@@ -199,7 +209,9 @@ def gated_repo_directories(command_text: str, fallback_directory: str) -> list[s
199
209
  if each_match.group().lower().strip("\"'") in DIRECTORY_CHANGE_VERBS:
200
210
  active_directory = _apply_directory_change(command_text, each_match, active_directory)
201
211
  continue
202
- target_directory = _target_directory_for_match(command_text, each_match, active_directory)
212
+ target_directory = _target_directory_for_match(
213
+ command_text, each_match, active_directory, all_gated_subcommands
214
+ )
203
215
  if target_directory is not None:
204
216
  target_directories.append(target_directory)
205
217
  return target_directories
@@ -59,3 +59,38 @@ def test_gated_repo_directories_gates_an_empty_work_tree_against_the_session() -
59
59
  assert gated_repo_directories("git --work-tree= commit -m x", "/session") == [
60
60
  os.path.join("/session", "")
61
61
  ]
62
+
63
+
64
+ def test_default_gated_subcommands_gates_both_commit_and_push() -> None:
65
+ assert gated_repo_directories("git commit -m x", "/session") == ["/session"]
66
+ assert gated_repo_directories("git push", "/session") == ["/session"]
67
+
68
+
69
+ def test_push_only_subcommands_gate_push_but_not_commit() -> None:
70
+ push_only = frozenset({"push"})
71
+ assert gated_invocation_directory(["push"], push_only)[0] is True
72
+ assert gated_invocation_directory(["commit", "-m", "x"], push_only)[0] is False
73
+
74
+
75
+ def test_commit_only_subcommands_gate_commit_but_not_push() -> None:
76
+ commit_only = frozenset({"commit"})
77
+ assert gated_invocation_directory(["commit", "-m", "x"], commit_only)[0] is True
78
+ assert gated_invocation_directory(["push"], commit_only)[0] is False
79
+
80
+
81
+ def test_gated_repo_directories_threads_push_only_subcommands() -> None:
82
+ push_only = frozenset({"push"})
83
+ assert gated_repo_directories("git push", "/session", push_only) == ["/session"]
84
+ assert gated_repo_directories("git commit -m x", "/session", push_only) == []
85
+
86
+
87
+ def test_gated_repo_directories_accepts_the_all_gated_subcommands_keyword() -> None:
88
+ push_only = frozenset({"push"})
89
+ assert gated_repo_directories(
90
+ "git push", "/session", all_gated_subcommands=push_only
91
+ ) == ["/session"]
92
+
93
+
94
+ def test_gated_invocation_directory_accepts_the_all_gated_subcommands_keyword() -> None:
95
+ push_only = frozenset({"push"})
96
+ assert gated_invocation_directory(["push"], all_gated_subcommands=push_only)[0] is True
@@ -18,6 +18,15 @@ and scanned, since that content is what gets embedded in the post). The ``gh``
18
18
  command is tokenized with ``shlex.split`` and the ``gh`` word must be the first
19
19
  command token, so a ``gh pr comment`` that only appears as quoted data inside
20
20
  another argument never classifies.
21
+
22
+ The marker scan runs in two tiers over the normalized body. Five bare markers
23
+ — the system temp locations and env tokens — match as a plain substring
24
+ anywhere. Two dot-relative directory markers, ``.claude-editor/jobs/`` and
25
+ ``.claude/worktrees/``, match when either a slash sits immediately before the
26
+ marker or a path segment follows it. A machine-local absolute path
27
+ (``~/.claude/worktrees/wt/f.py``) and a relative path that names a child
28
+ (``see .claude/worktrees/wt-1/notes.md``) both count; a bare directory-name
29
+ mention with neither anchor posts cleanly.
21
30
  """
22
31
 
23
32
  import json
@@ -42,30 +51,79 @@ from blocking._gh_body_arg_utils import ( # noqa: E402
42
51
  )
43
52
  from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
44
53
  from hooks_constants.volatile_path_in_post_blocker_constants import ( # noqa: E402
54
+ ALL_BARE_VOLATILE_PATH_MARKERS,
45
55
  ALL_GH_POST_SUBCOMMANDS,
46
56
  ALL_MCP_BODY_PARAM_NAMES,
47
- ALL_VOLATILE_PATH_MARKERS,
57
+ ALL_PATH_ANCHORED_VOLATILE_PATH_MARKERS,
48
58
  BASH_TOOL_NAME,
49
59
  BODY_FILE_ENCODING,
50
60
  CORRECTIVE_MESSAGE,
51
61
  GH_COMMAND_NAME,
52
62
  MCP_GITHUB_TOOL_PREFIX,
53
63
  MINIMUM_POST_SUBCOMMAND_TOKEN_COUNT,
64
+ PATH_ANCHOR_CHARACTER,
65
+ PATH_SEGMENT_START_CHARACTERS,
54
66
  TOKEN_JOIN_SEPARATOR,
55
67
  )
56
68
 
57
69
 
70
+ def _character_starts_path_segment(character: str) -> bool:
71
+ """Return whether a character can open a path segment after a marker."""
72
+ return bool(character) and (
73
+ character.isalnum() or character in PATH_SEGMENT_START_CHARACTERS
74
+ )
75
+
76
+
77
+ def _text_has_anchored_marker(normalized_text: str, marker: str) -> bool:
78
+ """Return whether the marker appears as part of a path.
79
+
80
+ A match counts when either a slash sits immediately before the marker or a
81
+ path segment follows it. Every occurrence is scanned, so a bare directory
82
+ mention earlier in the text never masks a path occurrence later::
83
+
84
+ ok: ".claude/worktrees/ is the manifest key" -> False
85
+ flag: "path c:/users/me/.claude/worktrees/wt/f.py" -> True
86
+ flag: "see .claude/worktrees/wt-1/notes.md" -> True
87
+
88
+ Args:
89
+ normalized_text: The body text with backslashes folded to forward
90
+ slashes and lowercased.
91
+ marker: A dot-relative directory marker to search for.
92
+
93
+ Returns:
94
+ True when at least one occurrence is path-anchored.
95
+ """
96
+ search_start = 0
97
+ while True:
98
+ found_index = normalized_text.find(marker, search_start)
99
+ if found_index < 0:
100
+ return False
101
+ preceding_character = normalized_text[found_index - 1 : found_index]
102
+ following_index = found_index + len(marker)
103
+ following_character = normalized_text[following_index : following_index + 1]
104
+ is_slash_before = preceding_character == PATH_ANCHOR_CHARACTER
105
+ is_segment_after = _character_starts_path_segment(following_character)
106
+ if is_slash_before or is_segment_after:
107
+ return True
108
+ search_start = found_index + 1
109
+
110
+
58
111
  def scan_text_for_volatile_marker(text: str) -> str | None:
59
112
  """Return the first volatile-path marker found in text, or None.
60
113
 
61
114
  Backslashes normalize to forward slashes and the text lowercases before the
62
- scan, so a marker matches regardless of slash direction or letter case::
115
+ scan. The two dot-relative directory markers count when either a slash sits
116
+ immediately before them or a path segment follows them; the five bare
117
+ markers match anywhere::
63
118
 
64
- ok: "See the log table pasted below." -> None
65
- flag: r"C:\\Users\\me\\.claude-editor\\jobs\\x" -> ".claude-editor/jobs/"
119
+ ok: "the `.claude/worktrees/` manifest key" -> None
120
+ flag: r"C:\\Users\\me\\.claude-editor\\jobs\\x" -> ".claude-editor/jobs/"
121
+ flag: "see .claude/worktrees/wt-1/notes.md" -> ".claude/worktrees/"
122
+ flag: "saved to %TEMP%\\out.txt" -> "%temp%"
66
123
 
67
- Env-token markers such as ``%TEMP%`` and ``$CLAUDE_JOB_DIR`` match on the
68
- same lowercased text.
124
+ A dot-relative marker with neither a slash before it nor a path segment
125
+ after it — start of text naming the directory, a quoted config constant,
126
+ or a placeholder form such as ``.claude/worktrees/<name>`` — posts cleanly.
69
127
 
70
128
  Args:
71
129
  text: The post body text to scan.
@@ -73,8 +131,11 @@ def scan_text_for_volatile_marker(text: str) -> str | None:
73
131
  Returns:
74
132
  The matched marker string, or None when the text names no volatile path.
75
133
  """
76
- normalized_text = text.replace("\\", "/").lower()
77
- for each_marker in ALL_VOLATILE_PATH_MARKERS:
134
+ normalized_text = text.replace("\\", PATH_ANCHOR_CHARACTER).lower()
135
+ for each_marker in ALL_PATH_ANCHORED_VOLATILE_PATH_MARKERS:
136
+ if _text_has_anchored_marker(normalized_text, each_marker):
137
+ return each_marker
138
+ for each_marker in ALL_BARE_VOLATILE_PATH_MARKERS:
78
139
  if each_marker in normalized_text:
79
140
  return each_marker
80
141
  return None
@@ -49,6 +49,12 @@ NO_PARSEABLE_STDIN_LINES_MESSAGE: str = (
49
49
  )
50
50
  NO_PARSEABLE_STDIN_LINES_SENTINEL: str = "__no_parseable_stdin_lines__"
51
51
  LOCAL_BRANCH_REFERENCE_PREFIX: str = "refs/heads/"
52
+ CODE_REVIEW_PUSH_GATE_PATH_OVERRIDE_ENV_VAR: str = "CODE_REVIEW_PUSH_GATE_PATH"
53
+ CODE_REVIEW_PUSH_GATE_MODULE_NAME: str = "code_review_push_gate"
54
+ CODE_REVIEW_PUSH_GATE_SCRIPT_FILENAME: str = "code_review_push_gate.py"
55
+ CODE_REVIEW_DENY_REASON_FUNCTION_NAME: str = "deny_reason_for_directory"
56
+ BLOCKING_DIRECTORY_NAME: str = "blocking"
57
+ CODE_REVIEW_STAMP_BLOCK_EXIT_CODE: int = 1
52
58
  ALL_PROTECTED_BRANCH_PUSH_NAMES: tuple[str, ...] = ("main", "master")
53
59
  PROTECTED_BRANCH_PUSH_BLOCK_EXIT_CODE: int = 1
54
60
  PROTECTED_BRANCH_PUSH_BLOCK_MESSAGE: str = (
@@ -30,6 +30,8 @@ Exit codes:
30
30
 
31
31
  from __future__ import annotations
32
32
 
33
+ import importlib.util
34
+ import os
33
35
  import subprocess
34
36
  import sys
35
37
  from pathlib import Path
@@ -38,6 +40,12 @@ from git_hooks_constants import (
38
40
  ALL_PROTECTED_BRANCH_PUSH_NAMES,
39
41
  ALL_ZEROS_OBJECT_NAME_CHARACTER,
40
42
  BASE_REFERENCE_ARGUMENT,
43
+ BLOCKING_DIRECTORY_NAME,
44
+ CODE_REVIEW_DENY_REASON_FUNCTION_NAME,
45
+ CODE_REVIEW_PUSH_GATE_MODULE_NAME,
46
+ CODE_REVIEW_PUSH_GATE_PATH_OVERRIDE_ENV_VAR,
47
+ CODE_REVIEW_PUSH_GATE_SCRIPT_FILENAME,
48
+ CODE_REVIEW_STAMP_BLOCK_EXIT_CODE,
41
49
  DEFAULT_REMOTE_BASE_REFERENCE,
42
50
  GATE_INFRASTRUCTURE_FAILURE_EXIT_CODE,
43
51
  INVOKE_GATE_FAILURE_MESSAGE,
@@ -158,6 +166,82 @@ def invoke_gate(gate_script_path: Path, base_reference: str) -> int:
158
166
  return completion.returncode
159
167
 
160
168
 
169
+ def resolve_code_review_gate_path() -> tuple[Path, Path | None]:
170
+ """Return the code-review push gate script path and its exact-allow override.
171
+
172
+ The override environment variable, when set, names the only path the trust
173
+ check accepts; otherwise the gate script resolves to the ``blocking``
174
+ directory beside this hook's ``git-hooks`` directory, which holds in both
175
+ the repository layout and the installed ``~/.claude/hooks`` layout.
176
+
177
+ Returns:
178
+ A pair of the resolved gate script path and either the exact override
179
+ path (override set) or None (trust-root case).
180
+ """
181
+ override_path_raw = os.environ.get(
182
+ CODE_REVIEW_PUSH_GATE_PATH_OVERRIDE_ENV_VAR, ""
183
+ ).strip()
184
+ if override_path_raw:
185
+ exact_override = Path(override_path_raw).resolve()
186
+ return exact_override, exact_override
187
+ blocking_directory = Path(__file__).resolve().parent.parent / BLOCKING_DIRECTORY_NAME
188
+ return blocking_directory / CODE_REVIEW_PUSH_GATE_SCRIPT_FILENAME, None
189
+
190
+
191
+ def load_code_review_deny_reason(gate_script_path: Path, work_tree_directory: str) -> str | None:
192
+ """Import the gate script and read its push deny reason for a work tree.
193
+
194
+ Any import or evaluation failure reads as no deny reason.
195
+
196
+ Args:
197
+ gate_script_path: The resolved code-review push gate script path.
198
+ work_tree_directory: The work tree the push targets.
199
+
200
+ Returns:
201
+ The gate's deny reason when the surface lacks a covering low stamp;
202
+ None when the gate allows the push or could not be evaluated.
203
+ """
204
+ try:
205
+ gate_specification = importlib.util.spec_from_file_location(
206
+ CODE_REVIEW_PUSH_GATE_MODULE_NAME, str(gate_script_path)
207
+ )
208
+ if gate_specification is None or gate_specification.loader is None:
209
+ return None
210
+ gate_module = importlib.util.module_from_spec(gate_specification)
211
+ gate_specification.loader.exec_module(gate_module)
212
+ deny_reason_function = getattr(gate_module, CODE_REVIEW_DENY_REASON_FUNCTION_NAME, None)
213
+ if deny_reason_function is None:
214
+ return None
215
+ deny_reason = deny_reason_function(work_tree_directory)
216
+ except Exception:
217
+ return None
218
+ if isinstance(deny_reason, str) and deny_reason:
219
+ return deny_reason
220
+ return None
221
+
222
+
223
+ def code_review_stamp_block_exit_code() -> int:
224
+ """Block the push when no clean low code-review stamp covers the surface.
225
+
226
+ Runs the gate decision only when the gate script sits at a trusted
227
+ installed location (or the exact override path). When the gate is absent
228
+ from a trusted location, the check is skipped and the push is allowed,
229
+ matching the CODE_RULES gate's fail-open posture.
230
+
231
+ Returns:
232
+ The block exit code when a covering low stamp is missing; 0 when the
233
+ surface is covered or the gate is not installed at a trusted location.
234
+ """
235
+ gate_script_path, exact_allowed_path = resolve_code_review_gate_path()
236
+ if not is_safe_regular_file(gate_script_path, exact_allowed_path):
237
+ return 0
238
+ deny_reason = load_code_review_deny_reason(gate_script_path, os.getcwd())
239
+ if deny_reason is None:
240
+ return 0
241
+ sys.stderr.write(deny_reason + "\n")
242
+ return CODE_REVIEW_STAMP_BLOCK_EXIT_CODE
243
+
244
+
161
245
  def main() -> int:
162
246
  stdin_read_failure_message = STDIN_READ_FAILURE_MESSAGE
163
247
  gate_infrastructure_failure_exit_code = GATE_INFRASTRUCTURE_FAILURE_EXIT_CODE
@@ -191,14 +275,17 @@ def main() -> int:
191
275
  pre_push_gate_script_not_found_message.format(path=gate_script_path),
192
276
  file=sys.stderr,
193
277
  )
194
- return 0
278
+ return code_review_stamp_block_exit_code()
195
279
  base_reference = resolve_base_reference_from_stdin(stdin_text)
196
280
  if base_reference is None:
197
281
  return 0
198
282
  if base_reference == no_parseable_stdin_lines_sentinel:
199
283
  print(no_parseable_stdin_lines_message, file=sys.stderr)
200
284
  return gate_infrastructure_failure_exit_code
201
- return invoke_gate(gate_script_path, base_reference)
285
+ code_rules_exit_code = invoke_gate(gate_script_path, base_reference)
286
+ if code_rules_exit_code != 0:
287
+ return code_rules_exit_code
288
+ return code_review_stamp_block_exit_code()
202
289
 
203
290
 
204
291
  if __name__ == "__main__":