claude-dev-env 2.2.1 → 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 (170) hide show
  1. package/CLAUDE.md +3 -2
  2. package/_shared/advisor/advisor-protocol.md +2 -2
  3. package/_shared/pr-loop/scripts/CLAUDE.md +1 -0
  4. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -284
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
  6. package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +3 -3
  7. package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -0
  8. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +5 -0
  9. package/_shared/pr-loop/scripts/stale_worktree_rule_sweep.py +107 -0
  10. package/_shared/pr-loop/scripts/tests/CLAUDE.md +2 -0
  11. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +55 -73
  12. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +9 -15
  13. package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +91 -1
  14. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +88 -1
  15. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -0
  16. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -0
  17. package/_shared/pr-loop/worker-spawn.md +1 -1
  18. package/agents/CLAUDE.md +1 -0
  19. package/agents/code-verifier.md +4 -4
  20. package/agents/skill-writer-agent.md +84 -0
  21. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +141 -41
  22. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +29 -13
  23. package/docs/CLAUDE.md +1 -0
  24. package/docs/references/CLAUDE.md +1 -0
  25. package/docs/references/code-review-enforcement.md +97 -0
  26. package/docs/wsl-docker-cowork-starter-matrix.md +89 -0
  27. package/hooks/blocking/CLAUDE.md +8 -1
  28. package/hooks/blocking/code_review_enforcement_config_bootstrap.py +53 -0
  29. package/hooks/blocking/code_review_gate_deny.py +74 -0
  30. package/hooks/blocking/code_review_pr_create_gate.py +194 -0
  31. package/hooks/blocking/code_review_push_gate.py +140 -0
  32. package/hooks/blocking/code_review_stamp_directory_write_blocker.py +340 -0
  33. package/hooks/blocking/code_review_stamp_store.py +233 -0
  34. package/hooks/blocking/code_review_stamp_write_blocker_parts/__init__.py +7 -0
  35. package/hooks/blocking/code_review_stamp_write_blocker_parts/conftest.py +15 -0
  36. package/hooks/blocking/code_review_stamp_write_blocker_parts/obfuscated_stamp_path_reference.py +212 -0
  37. package/hooks/blocking/code_review_stamp_write_blocker_parts/split_directory_change_into_stamp.py +138 -0
  38. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_obfuscated_stamp_path_reference.py +49 -0
  39. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_split_directory_change_into_stamp.py +38 -0
  40. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +39 -27
  41. package/hooks/blocking/config/code_review_enforcement_constants.py +110 -0
  42. package/hooks/blocking/config/test_code_review_enforcement_constants.py +108 -0
  43. package/hooks/blocking/config/verified_commit_constants.py +24 -9
  44. package/hooks/blocking/conftest.py +2 -0
  45. package/hooks/blocking/convergence_gate_blocker.py +112 -23
  46. package/hooks/blocking/destructive_command_blocker.py +19 -6
  47. package/hooks/blocking/pr_description_proof_of_work.py +52 -34
  48. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
  49. package/hooks/blocking/test_code_review_enforcement_config_bootstrap.py +62 -0
  50. package/hooks/blocking/test_code_review_gate_deny.py +54 -0
  51. package/hooks/blocking/test_code_review_pr_create_gate.py +185 -0
  52. package/hooks/blocking/test_code_review_push_gate.py +189 -0
  53. package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +180 -0
  54. package/hooks/blocking/test_code_review_stamp_store.py +205 -0
  55. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +124 -2
  56. package/hooks/blocking/test_code_verifier_tools_contract.py +28 -0
  57. package/hooks/blocking/test_convergence_gate_blocker.py +153 -5
  58. package/hooks/blocking/test_destructive_command_blocker.py +1 -1
  59. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +45 -0
  60. package/hooks/blocking/test_pr_description_proof_of_work.py +151 -0
  61. package/hooks/blocking/test_pre_tool_use_dispatcher.py +8 -8
  62. package/hooks/blocking/test_verification_verdict_store.py +920 -810
  63. package/hooks/blocking/test_volatile_path_in_post_blocker.py +114 -2
  64. package/hooks/blocking/verification_verdict_store.py +118 -5
  65. package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +29 -17
  66. package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +35 -0
  67. package/hooks/blocking/volatile_path_in_post_blocker.py +69 -8
  68. package/hooks/git-hooks/git_hooks_constants/__init__.py +6 -0
  69. package/hooks/git-hooks/pre_push.py +89 -2
  70. package/hooks/git-hooks/test_pre_push.py +103 -0
  71. package/hooks/hooks.json +16 -1
  72. package/hooks/hooks_constants/CLAUDE.md +1 -0
  73. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +8 -0
  74. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
  75. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +26 -11
  76. package/hooks/hooks_constants/convergence_gate_blocker_constants.py +20 -3
  77. package/hooks/hooks_constants/destructive_command_segment_constants.py +3 -1
  78. package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +0 -4
  79. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  80. package/hooks/hooks_constants/pyproject_config_discovery_constants.py +16 -0
  81. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +24 -0
  82. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +6 -0
  83. package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +8 -1
  84. package/hooks/validators/CLAUDE.md +1 -0
  85. package/hooks/validators/mypy_integration.py +63 -50
  86. package/hooks/validators/pyproject_config_discovery.py +101 -0
  87. package/hooks/validators/ruff_integration.py +211 -23
  88. package/hooks/validators/run_all_validators.py +21 -14
  89. package/hooks/validators/test_mypy_integration.py +32 -0
  90. package/hooks/validators/test_pyproject_config_discovery.py +94 -0
  91. package/hooks/validators/test_ruff_integration.py +68 -1
  92. package/hooks/validators/test_run_all_validators.py +25 -0
  93. package/hooks/validators/test_run_all_validators_config_discovery.py +123 -0
  94. package/package.json +1 -1
  95. package/rules/docstring-prose-matches-implementation.md +20 -43
  96. package/rules/durable-post-artifacts.md +7 -0
  97. package/scripts/CLAUDE.md +2 -1
  98. package/scripts/claude-chain.example.json +15 -3
  99. package/scripts/claude_chain_runner.py +130 -27
  100. package/scripts/claude_chain_usage.py +346 -0
  101. package/scripts/codec_forwarding_test_support.py +83 -0
  102. package/scripts/conftest.py +8 -0
  103. package/scripts/dev_env_scripts_constants/CLAUDE.md +4 -3
  104. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +57 -2
  105. package/scripts/dev_env_scripts_constants/claude_chain_usage_constants.py +58 -0
  106. package/scripts/dev_env_scripts_constants/code_review_constants.py +129 -12
  107. package/scripts/dev_env_scripts_constants/test_code_review_constants.py +55 -0
  108. package/scripts/dev_env_scripts_constants/timing.py +1 -1
  109. package/scripts/invoke_code_review.py +550 -38
  110. package/scripts/resolve_worker_spawn.py +8 -1
  111. package/scripts/test_claude_chain_runner.py +412 -6
  112. package/scripts/test_claude_chain_usage.py +534 -0
  113. package/scripts/test_invoke_code_review.py +298 -4
  114. package/scripts/test_invoke_code_review_codec.py +77 -0
  115. package/scripts/test_resolve_worker_spawn_codec.py +101 -0
  116. package/skills/autoconverge/SKILL.md +9 -3
  117. package/skills/autoconverge/reference/convergence.md +2 -1
  118. package/skills/autoconverge/reference/multi-pr.md +6 -1
  119. package/skills/autoconverge/reference/stop-conditions.md +16 -10
  120. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +1 -1
  121. package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +175 -3
  122. package/skills/autoconverge/workflow/converge.contract.test.mjs +19 -0
  123. package/skills/autoconverge/workflow/converge.mjs +24 -8
  124. package/skills/autoconverge/workflow/converge_multi.mjs +7 -3
  125. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +5 -0
  126. package/skills/fresh-branch/CLAUDE.md +2 -0
  127. package/skills/fresh-branch/SKILL.md +2 -0
  128. package/skills/fresh-branch/scripts/create_fresh_branch.py +78 -180
  129. package/skills/fresh-branch/scripts/fresh_branch_git_commands.py +285 -0
  130. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -0
  131. package/skills/fresh-branch/scripts/pytest.ini +4 -0
  132. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +98 -0
  133. package/skills/fresh-branch/scripts/test_fresh_branch_git_commands.py +310 -0
  134. package/skills/orchestrator/SKILL.md +1 -20
  135. package/skills/orchestrator-refresh/SKILL.md +1 -1
  136. package/skills/pr-converge/SKILL.md +3 -3
  137. package/skills/pr-converge/reference/examples.md +3 -3
  138. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  139. package/skills/pr-converge/reference/ground-rules.md +3 -3
  140. package/skills/pr-converge/reference/per-tick.md +5 -5
  141. package/skills/pr-converge/reference/progress-checklist.md +1 -1
  142. package/skills/pr-converge/test_step5_host_branch.py +1 -1
  143. package/skills/prototype/SKILL.md +86 -0
  144. package/skills/prototype/reference/honest-limitations.md +23 -0
  145. package/skills/prototype/reference/promotion-tasks.md +23 -0
  146. package/skills/prototype/scripts/build_sandbox_settings.py +249 -0
  147. package/skills/prototype/scripts/conftest.py +15 -0
  148. package/skills/prototype/scripts/launch_sandbox.py +205 -0
  149. package/skills/prototype/scripts/probe_sandbox_safety.py +311 -0
  150. package/skills/prototype/scripts/prototype_scripts_constants/__init__.py +1 -0
  151. package/skills/prototype/scripts/prototype_scripts_constants/config/__init__.py +0 -0
  152. package/skills/prototype/scripts/prototype_scripts_constants/config/build_sandbox_settings_constants.py +41 -0
  153. package/skills/prototype/scripts/prototype_scripts_constants/config/launch_sandbox_constants.py +23 -0
  154. package/skills/prototype/scripts/prototype_scripts_constants/config/probe_sandbox_safety_constants.py +45 -0
  155. package/skills/prototype/scripts/prototype_scripts_constants/config/prototype_common_constants.py +10 -0
  156. package/skills/prototype/scripts/test_build_sandbox_settings.py +275 -0
  157. package/skills/prototype/scripts/test_launch_sandbox.py +303 -0
  158. package/skills/prototype/scripts/test_probe_sandbox_safety.py +284 -0
  159. package/skills/prototype/workflows/promotion.md +27 -0
  160. package/skills/prototype/workflows/sandbox.md +35 -0
  161. package/skills/skill-builder/CLAUDE.md +3 -3
  162. package/skills/skill-builder/SKILL.md +5 -5
  163. package/skills/skill-builder/references/CLAUDE.md +1 -1
  164. package/skills/skill-builder/references/delegation-map.md +3 -3
  165. package/skills/skill-builder/references/description-field.md +1 -1
  166. package/skills/skill-builder/references/skill-modularity.md +2 -3
  167. package/skills/skill-builder/workflows/CLAUDE.md +1 -1
  168. package/skills/skill-builder/workflows/improve-skill.md +1 -1
  169. package/skills/skill-builder/workflows/new-skill.md +2 -2
  170. package/skills/team-advisor/SKILL.md +5 -4
@@ -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",
@@ -13,6 +13,7 @@ and reading/writing verdict files.
13
13
  from __future__ import annotations
14
14
 
15
15
  import ast
16
+ import datetime
16
17
  import hashlib
17
18
  import json
18
19
  import re
@@ -20,6 +21,7 @@ import subprocess
20
21
  import sys
21
22
  import time
22
23
  from pathlib import Path
24
+ from collections.abc import Mapping
23
25
 
24
26
  blocking_directory = str(Path(__file__).resolve().parent)
25
27
  if blocking_directory not in sys.path:
@@ -45,6 +47,15 @@ from config.verified_commit_constants import (
45
47
  ALL_FALLBACK_BASE_REFERENCES,
46
48
  EMPTY_SURFACE_GUARD_MESSAGE,
47
49
  GIT_TIMEOUT_SECONDS,
50
+ LEDGER_KEY_ALL_PASS,
51
+ LEDGER_KEY_BRANCH,
52
+ LEDGER_KEY_FINDING_CHECK_NAMES,
53
+ LEDGER_KEY_FINDING_COUNT,
54
+ LEDGER_KEY_MANIFEST_HASH,
55
+ LEDGER_KEY_MINTED_FROM_AGENT_ID,
56
+ LEDGER_KEY_REPO_ROOT,
57
+ LEDGER_KEY_TIMESTAMP,
58
+ LOGS_DIRECTORY_NAME,
48
59
  MANIFEST_HASH_CLI_FLAG,
49
60
  MANIFEST_HASH_FOR_BRANCH_CLI_FLAG,
50
61
  MINIMUM_STATUS_FIELD_COUNT,
@@ -65,10 +76,12 @@ from config.verified_commit_constants import (
65
76
  VERDICT_DIRECTORY_NAME,
66
77
  VERDICT_FENCE_PATTERN,
67
78
  VERDICT_FILE_GLOB,
79
+ VERDICT_FINDING_CHECK_KEY,
68
80
  VERDICT_JSON_INDENT,
69
81
  VERDICT_KEY_ALL_PASS,
70
82
  VERDICT_KEY_FINDINGS,
71
83
  VERDICT_KEY_MANIFEST_SHA256,
84
+ VERIFIER_VERDICTS_JSONL_FILENAME,
72
85
  WORKTREE_LIST_BRANCH_PREFIX,
73
86
  WORKTREE_LIST_PATH_PREFIX,
74
87
  )
@@ -377,12 +390,36 @@ def verdict_directory() -> Path:
377
390
  return Path.home() / CLAUDE_HOME_DIRECTORY_NAME / VERDICT_DIRECTORY_NAME
378
391
 
379
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
+
380
417
  def verdict_path_for_repo(repo_root: str) -> Path:
381
418
  """Derive the verdict file path for a repository work tree.
382
419
 
383
420
  Verdicts live outside the repository (under the user's Claude home) so
384
- no repo accumulates untracked files, keyed by a hash of the normalized
385
- 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.
386
423
 
387
424
  Args:
388
425
  repo_root: The repository top-level directory.
@@ -390,9 +427,7 @@ def verdict_path_for_repo(repo_root: str) -> Path:
390
427
  Returns:
391
428
  The verdict file path for this work tree.
392
429
  """
393
- normalized_root = str(Path(repo_root).resolve()).replace("\\", "/").lower()
394
- root_key = hashlib.sha256(normalized_root.encode("utf-8")).hexdigest()[:ROOT_KEY_HEX_LENGTH]
395
- return verdict_directory() / f"{root_key}.json"
430
+ return verdict_directory() / f"{root_key_for_repo(repo_root)}.json"
396
431
 
397
432
 
398
433
  def load_valid_verdict(repo_root: str, expected_manifest_sha256: str) -> dict | None:
@@ -654,6 +689,74 @@ def workflow_verdict_covers_surface(
654
689
  return False
655
690
 
656
691
 
692
+ def _finding_check_names(all_findings: list) -> list[str]:
693
+ """Collect the ``check`` name from each finding mapping that carries one.
694
+
695
+ Args:
696
+ all_findings: The verifier's findings list (empty when clean).
697
+
698
+ Returns:
699
+ Ordered list of non-empty check name strings; entries without a
700
+ string ``check`` field are omitted.
701
+ """
702
+ all_check_names: list[str] = []
703
+ for each_finding in all_findings:
704
+ if not isinstance(each_finding, Mapping):
705
+ continue
706
+ check_name = each_finding.get(VERDICT_FINDING_CHECK_KEY)
707
+ if isinstance(check_name, str) and check_name:
708
+ all_check_names.append(check_name)
709
+ return all_check_names
710
+
711
+
712
+ def _append_verdict_ledger_row(
713
+ repo_root: str,
714
+ bound_manifest_sha256: str,
715
+ is_all_pass: bool,
716
+ all_findings: list,
717
+ minted_from_agent_id: str,
718
+ ) -> None:
719
+ """Append one fail-safe JSONL row for a minted verdict.
720
+
721
+ Writes to ``~/.claude/logs/verifier-verdicts.jsonl``. Swallows home
722
+ resolution and IO errors so a ledger failure never changes the mint.
723
+
724
+ Args:
725
+ repo_root: The repository top-level directory.
726
+ bound_manifest_sha256: Hash of the surface the verdict covers.
727
+ is_all_pass: Whether the verifier reported a clean verdict.
728
+ all_findings: The verifier's findings list (empty when clean).
729
+ minted_from_agent_id: The subagent invocation id, kept for audit.
730
+ """
731
+ try:
732
+ home_directory = Path.home()
733
+ except RuntimeError:
734
+ return
735
+ try:
736
+ ledger_path = (
737
+ home_directory
738
+ / CLAUDE_HOME_DIRECTORY_NAME
739
+ / LOGS_DIRECTORY_NAME
740
+ / VERIFIER_VERDICTS_JSONL_FILENAME
741
+ )
742
+ ledger_path.parent.mkdir(parents=True, exist_ok=True)
743
+ branch_name = _head_branch_name(repo_root)
744
+ ledger_row = {
745
+ LEDGER_KEY_TIMESTAMP: datetime.datetime.now(datetime.timezone.utc).isoformat(),
746
+ LEDGER_KEY_REPO_ROOT: repo_root,
747
+ LEDGER_KEY_BRANCH: branch_name,
748
+ LEDGER_KEY_MANIFEST_HASH: bound_manifest_sha256,
749
+ LEDGER_KEY_ALL_PASS: is_all_pass,
750
+ LEDGER_KEY_FINDING_COUNT: len(all_findings),
751
+ LEDGER_KEY_FINDING_CHECK_NAMES: _finding_check_names(all_findings),
752
+ LEDGER_KEY_MINTED_FROM_AGENT_ID: minted_from_agent_id,
753
+ }
754
+ with ledger_path.open("a", encoding="utf-8") as ledger_file:
755
+ ledger_file.write(json.dumps(ledger_row) + "\n")
756
+ except OSError:
757
+ return
758
+
759
+
657
760
  def write_verdict(
658
761
  repo_root: str,
659
762
  bound_manifest_sha256: str,
@@ -663,6 +766,9 @@ def write_verdict(
663
766
  ) -> Path:
664
767
  """Write a verdict file binding a verification outcome to a surface hash.
665
768
 
769
+ Also appends one durable JSONL ledger row under the Claude logs directory.
770
+ A ledger write failure never changes the mint outcome.
771
+
666
772
  Args:
667
773
  repo_root: The repository top-level directory.
668
774
  bound_manifest_sha256: Hash of the surface manifest the verdict covers.
@@ -686,6 +792,13 @@ def write_verdict(
686
792
  verdict_file.write_text(
687
793
  json.dumps(verdict_record, indent=VERDICT_JSON_INDENT), encoding="utf-8"
688
794
  )
795
+ _append_verdict_ledger_row(
796
+ repo_root,
797
+ bound_manifest_sha256,
798
+ is_all_pass,
799
+ all_findings,
800
+ minted_from_agent_id,
801
+ )
689
802
  return verdict_file
690
803
 
691
804
 
@@ -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 = (