claude-dev-env 2.0.2 → 2.2.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 (110) hide show
  1. package/CLAUDE.md +1 -1
  2. package/hooks/blocking/CLAUDE.md +1 -0
  3. package/hooks/blocking/code_rules_shared.py +126 -47
  4. package/hooks/blocking/config/CLAUDE.md +2 -0
  5. package/hooks/blocking/config/verified_commit_context_constants.py +21 -0
  6. package/hooks/blocking/config/verified_commit_gate_output_constants.py +14 -0
  7. package/hooks/blocking/conftest.py +36 -30
  8. package/hooks/blocking/convergence_gate_blocker.py +76 -8
  9. package/hooks/blocking/plain_language_blocker.py +8 -0
  10. package/hooks/blocking/state_description_blocker.py +4 -1
  11. package/hooks/blocking/test_code_rules_shared.py +120 -20
  12. package/hooks/blocking/test_convergence_gate_blocker.py +146 -0
  13. package/hooks/blocking/test_plain_language_blocker.py +15 -0
  14. package/hooks/blocking/test_state_description_blocker.py +15 -0
  15. package/hooks/blocking/test_verified_commit_config_bootstrap.py +18 -0
  16. package/hooks/blocking/test_verified_commit_gate_additional_context.py +134 -0
  17. package/hooks/blocking/tests/test_verified_commit_gate.py +41 -0
  18. package/hooks/blocking/verified_commit_config_bootstrap.py +22 -10
  19. package/hooks/blocking/verified_commit_gate.py +113 -568
  20. package/hooks/blocking/verified_commit_gate_parts/CLAUDE.md +28 -0
  21. package/hooks/blocking/verified_commit_gate_parts/__init__.py +1 -0
  22. package/hooks/blocking/verified_commit_gate_parts/command_tokenization.py +174 -0
  23. package/hooks/blocking/verified_commit_gate_parts/deny_payload.py +53 -0
  24. package/hooks/blocking/verified_commit_gate_parts/deny_reason.py +80 -0
  25. package/hooks/blocking/verified_commit_gate_parts/directory_resolution.py +170 -0
  26. package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +205 -0
  27. package/hooks/blocking/verified_commit_gate_parts/tests/conftest.py +10 -0
  28. package/hooks/blocking/verified_commit_gate_parts/tests/test_command_tokenization.py +94 -0
  29. package/hooks/blocking/verified_commit_gate_parts/tests/test_deny_payload.py +17 -0
  30. package/hooks/blocking/verified_commit_gate_parts/tests/test_deny_reason.py +38 -0
  31. package/hooks/blocking/verified_commit_gate_parts/tests/test_directory_resolution.py +71 -0
  32. package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +61 -0
  33. package/hooks/hooks_constants/CLAUDE.md +4 -1
  34. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
  35. package/hooks/hooks_constants/convergence_gate_blocker_constants.py +36 -0
  36. package/hooks/hooks_constants/harness_scratchpad_constants.py +10 -9
  37. package/hooks/hooks_constants/mypy_integration_constants.py +16 -0
  38. package/hooks/validators/mypy_integration.py +145 -24
  39. package/hooks/validators/python_antipattern_checks.py +16 -0
  40. package/hooks/validators/run_all_validators.py +9 -3
  41. package/hooks/validators/test_mypy_integration.py +154 -0
  42. package/hooks/validators/test_python_antipattern_checks.py +112 -1
  43. package/hooks/validators/test_run_all_validators_pretooluse.py +16 -0
  44. package/package.json +1 -1
  45. package/rules/CLAUDE.md +1 -0
  46. package/rules/verified-commit-gate-skip.md +28 -0
  47. package/skills/_shared/pr-loop/CLAUDE.md +18 -13
  48. package/skills/_shared/pr-loop/portable-driver.md +150 -0
  49. package/skills/_shared/pr-loop/scripts/CLAUDE.md +3 -0
  50. package/skills/_shared/pr-loop/scripts/build_converge_task_list.py +310 -0
  51. package/skills/_shared/pr-loop/scripts/portable_converge_driver.py +1637 -0
  52. package/skills/_shared/pr-loop/scripts/select_converge_pacer.py +215 -0
  53. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +3 -0
  54. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/converge_task_list_constants.py +56 -0
  55. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/pacer_constants.py +47 -0
  56. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/portable_driver_constants.py +181 -0
  57. package/skills/_shared/pr-loop/scripts/test_build_converge_task_list.py +140 -0
  58. package/skills/_shared/pr-loop/scripts/test_portable_converge_driver.py +1119 -0
  59. package/skills/_shared/pr-loop/scripts/test_select_converge_pacer.py +207 -0
  60. package/skills/auditing-claude-config/CLAUDE.md +2 -1
  61. package/skills/auditing-claude-config/SKILL.md +114 -176
  62. package/skills/auditing-claude-config/reference/probe-hook.md +74 -0
  63. package/skills/autoconverge/CLAUDE.md +6 -3
  64. package/skills/autoconverge/SKILL.md +421 -346
  65. package/skills/autoconverge/reference/CLAUDE.md +1 -0
  66. package/skills/autoconverge/reference/convergence.md +5 -5
  67. package/skills/autoconverge/reference/copilot-findings.md +51 -0
  68. package/skills/autoconverge/reference/multi-pr.md +33 -2
  69. package/skills/autoconverge/reference/stop-conditions.md +9 -6
  70. package/skills/autoconverge/test_portable_pacer_gate.py +54 -0
  71. package/skills/closeout/SKILL.md +7 -9
  72. package/skills/copilot-finding-triage/SKILL.md +21 -11
  73. package/skills/copilot-review/CLAUDE.md +3 -2
  74. package/skills/copilot-review/SKILL.md +119 -155
  75. package/skills/copilot-review/templates/subagent-prompt.md +49 -0
  76. package/skills/fresh-branch/CLAUDE.md +6 -9
  77. package/skills/fresh-branch/SKILL.md +84 -33
  78. package/skills/fresh-branch/scripts/create_fresh_branch.py +445 -0
  79. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/__init__.py +1 -0
  80. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +74 -0
  81. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +670 -0
  82. package/skills/pr-converge/CLAUDE.md +4 -3
  83. package/skills/pr-converge/SKILL.md +469 -422
  84. package/skills/pr-converge/reference/CLAUDE.md +1 -0
  85. package/skills/pr-converge/reference/multi-pr-orchestration.md +5 -1
  86. package/skills/pr-converge/reference/per-tick.md +51 -24
  87. package/skills/pr-converge/reference/progress-checklist.md +168 -0
  88. package/skills/pr-converge/test_portable_pacer_gate.py +67 -0
  89. package/skills/pr-converge/test_step5_host_branch.py +8 -6
  90. package/skills/pr-converge/workflows/schedule-wakeup-loop.md +5 -3
  91. package/skills/pr-loop-cloud-transport/SKILL.md +1 -1
  92. package/skills/privacy-hygiene/SKILL.md +68 -115
  93. package/skills/privacy-hygiene/reference/sweep-procedure.md +62 -0
  94. package/skills/session-log/CLAUDE.md +2 -1
  95. package/skills/session-log/SKILL.md +4 -26
  96. package/skills/session-log/templates/frontmatter.md +40 -0
  97. package/skills/skill-builder/CLAUDE.md +8 -7
  98. package/skills/skill-builder/SKILL.md +26 -11
  99. package/skills/skill-builder/references/CLAUDE.md +3 -1
  100. package/skills/skill-builder/references/delegation-map.md +21 -12
  101. package/skills/skill-builder/references/description-field.md +9 -11
  102. package/skills/skill-builder/references/deterministic-elements.md +218 -0
  103. package/skills/skill-builder/references/self-audit-checklist.md +62 -45
  104. package/skills/skill-builder/references/skill-modularity.md +8 -9
  105. package/skills/skill-builder/templates/CLAUDE.md +2 -2
  106. package/skills/skill-builder/templates/gap-analysis.md +15 -0
  107. package/skills/skill-builder/workflows/CLAUDE.md +5 -5
  108. package/skills/skill-builder/workflows/improve-skill.md +18 -9
  109. package/skills/skill-builder/workflows/new-skill.md +23 -15
  110. package/skills/skill-builder/workflows/polish-skill.md +28 -21
@@ -12,6 +12,9 @@ SHARED_MODULE_PATH = Path(__file__).parent / "code_rules_shared.py"
12
12
  FIXED_USER_ID = 4242
13
13
  WORKING_DIRECTORY = "/home/user/project"
14
14
  SESSION_ID = "session-abc-123"
15
+ HARNESS_USER_DIRECTORY_WINDOWS = "claude"
16
+ WINDOWS_MANGLED_WORKING_DIRECTORY = "c--Users-dev--claude-project"
17
+ SESSION_ID_ENVIRONMENT_VARIABLE = "CLAUDE_CODE_SESSION_ID"
15
18
 
16
19
 
17
20
  def _load_shared_module() -> ModuleType:
@@ -27,10 +30,6 @@ def _load_shared_module() -> ModuleType:
27
30
  _SHARED_MODULE = _load_shared_module()
28
31
 
29
32
 
30
- def _install_fixed_user_id(monkeypatch: pytest.MonkeyPatch) -> None:
31
- monkeypatch.setattr(os, "getuid", lambda: FIXED_USER_ID, raising=False)
32
-
33
-
34
33
  def _point_temporary_directory_at(monkeypatch: pytest.MonkeyPatch, temporary_root: Path) -> None:
35
34
  monkeypatch.setattr(tempfile, "gettempdir", lambda: str(temporary_root))
36
35
 
@@ -48,6 +47,24 @@ def _build_scratchpad_directory(temporary_root: Path) -> Path:
48
47
  return scratchpad_directory
49
48
 
50
49
 
50
+ def _build_windows_scratchpad_directory(temporary_root: Path) -> Path:
51
+ """Build the Windows-shaped scratchpad directory that carries no user id segment."""
52
+ scratchpad_directory = (
53
+ temporary_root
54
+ / HARNESS_USER_DIRECTORY_WINDOWS
55
+ / WINDOWS_MANGLED_WORKING_DIRECTORY
56
+ / SESSION_ID
57
+ / "scratchpad"
58
+ )
59
+ scratchpad_directory.mkdir(parents=True)
60
+ return scratchpad_directory
61
+
62
+
63
+ def _simulate_windows_platform(monkeypatch: pytest.MonkeyPatch, temporary_root: Path) -> None:
64
+ monkeypatch.delattr(os, "getuid", raising=False)
65
+ monkeypatch.setattr(tempfile, "gettempdir", lambda: str(temporary_root))
66
+
67
+
51
68
  def _session_payload() -> dict[str, str]:
52
69
  return {"cwd": WORKING_DIRECTORY, "session_id": SESSION_ID}
53
70
 
@@ -55,7 +72,6 @@ def _session_payload() -> dict[str, str]:
55
72
  def test_returns_true_for_file_inside_reconstructed_scratchpad(
56
73
  tmp_path: Path, monkeypatch: pytest.MonkeyPatch
57
74
  ) -> None:
58
- _install_fixed_user_id(monkeypatch)
59
75
  _point_temporary_directory_at(monkeypatch, tmp_path)
60
76
  scratchpad_directory = _build_scratchpad_directory(tmp_path)
61
77
  throwaway_script = scratchpad_directory / "one_off_tool.py"
@@ -69,7 +85,6 @@ def test_returns_true_for_file_inside_reconstructed_scratchpad(
69
85
  def test_returns_false_for_file_outside_scratchpad(
70
86
  tmp_path: Path, monkeypatch: pytest.MonkeyPatch
71
87
  ) -> None:
72
- _install_fixed_user_id(monkeypatch)
73
88
  _point_temporary_directory_at(monkeypatch, tmp_path)
74
89
  _build_scratchpad_directory(tmp_path)
75
90
  project_module = tmp_path / "elsewhere" / "orders.py"
@@ -82,7 +97,6 @@ def test_returns_false_for_file_outside_scratchpad(
82
97
  def test_resolves_symlink_into_scratchpad_to_true(
83
98
  tmp_path: Path, monkeypatch: pytest.MonkeyPatch
84
99
  ) -> None:
85
- _install_fixed_user_id(monkeypatch)
86
100
  _point_temporary_directory_at(monkeypatch, tmp_path)
87
101
  scratchpad_directory = _build_scratchpad_directory(tmp_path)
88
102
  real_script = scratchpad_directory / "real_tool.py"
@@ -96,7 +110,6 @@ def test_resolves_symlink_into_scratchpad_to_true(
96
110
  def test_symlink_resolving_outside_scratchpad_is_false(
97
111
  tmp_path: Path, monkeypatch: pytest.MonkeyPatch
98
112
  ) -> None:
99
- _install_fixed_user_id(monkeypatch)
100
113
  _point_temporary_directory_at(monkeypatch, tmp_path)
101
114
  scratchpad_directory = _build_scratchpad_directory(tmp_path)
102
115
  real_target_outside = tmp_path / "outside_target.py"
@@ -113,8 +126,8 @@ def test_symlink_resolving_outside_scratchpad_is_false(
113
126
  def test_missing_session_id_signal_returns_false(
114
127
  tmp_path: Path, monkeypatch: pytest.MonkeyPatch
115
128
  ) -> None:
116
- _install_fixed_user_id(monkeypatch)
117
129
  _point_temporary_directory_at(monkeypatch, tmp_path)
130
+ monkeypatch.delenv(SESSION_ID_ENVIRONMENT_VARIABLE, raising=False)
118
131
  scratchpad_directory = _build_scratchpad_directory(tmp_path)
119
132
  throwaway_script = scratchpad_directory / "one_off_tool.py"
120
133
  payload_without_session = {"cwd": WORKING_DIRECTORY}
@@ -125,10 +138,12 @@ def test_missing_session_id_signal_returns_false(
125
138
  )
126
139
 
127
140
 
128
- def test_missing_working_directory_signal_returns_false(
141
+ def test_exempt_without_working_directory_signal(
129
142
  tmp_path: Path, monkeypatch: pytest.MonkeyPatch
130
143
  ) -> None:
131
- _install_fixed_user_id(monkeypatch)
144
+ """The platform-safe predicate keys on the session id and the temp-directory path
145
+ shape, so a payload carrying the session id alone still exempts a real scratchpad
146
+ target even when it omits the working directory."""
132
147
  _point_temporary_directory_at(monkeypatch, tmp_path)
133
148
  scratchpad_directory = _build_scratchpad_directory(tmp_path)
134
149
  throwaway_script = scratchpad_directory / "one_off_tool.py"
@@ -136,27 +151,28 @@ def test_missing_working_directory_signal_returns_false(
136
151
 
137
152
  assert (
138
153
  _SHARED_MODULE.is_under_session_scratchpad(str(throwaway_script), payload_without_cwd)
139
- is False
154
+ is True
140
155
  )
141
156
 
142
157
 
143
- def test_absent_getuid_returns_false(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
144
- _install_fixed_user_id(monkeypatch)
145
- _point_temporary_directory_at(monkeypatch, tmp_path)
146
- scratchpad_directory = _build_scratchpad_directory(tmp_path)
158
+ def test_exempt_on_windows_without_getuid(
159
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
160
+ ) -> None:
161
+ """On Windows the os.getuid attribute is absent, and a real scratchpad target still
162
+ resolves as exempt through its temp-directory path shape and session id."""
163
+ _simulate_windows_platform(monkeypatch, tmp_path)
164
+ scratchpad_directory = _build_windows_scratchpad_directory(tmp_path)
147
165
  throwaway_script = scratchpad_directory / "one_off_tool.py"
148
- monkeypatch.delattr(os, "getuid", raising=False)
149
166
 
150
167
  assert (
151
168
  _SHARED_MODULE.is_under_session_scratchpad(str(throwaway_script), _session_payload())
152
- is False
169
+ is True
153
170
  )
154
171
 
155
172
 
156
173
  def test_nonexistent_scratchpad_directory_returns_false(
157
174
  tmp_path: Path, monkeypatch: pytest.MonkeyPatch
158
175
  ) -> None:
159
- _install_fixed_user_id(monkeypatch)
160
176
  _point_temporary_directory_at(monkeypatch, tmp_path)
161
177
  mangled_working_directory = WORKING_DIRECTORY.replace("/", "-")
162
178
  unbuilt_target = (
@@ -174,8 +190,92 @@ def test_nonexistent_scratchpad_directory_returns_false(
174
190
 
175
191
 
176
192
  def test_empty_file_path_returns_false(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
177
- _install_fixed_user_id(monkeypatch)
178
193
  _point_temporary_directory_at(monkeypatch, tmp_path)
179
194
  _build_scratchpad_directory(tmp_path)
180
195
 
181
196
  assert _SHARED_MODULE.is_under_session_scratchpad("", _session_payload()) is False
197
+
198
+
199
+ def test_windows_shape_repository_file_is_not_exempt(
200
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
201
+ ) -> None:
202
+ _simulate_windows_platform(monkeypatch, tmp_path)
203
+ _build_windows_scratchpad_directory(tmp_path)
204
+ repository_module = tmp_path / "repository" / "src" / "orders.py"
205
+ repository_module.parent.mkdir(parents=True)
206
+
207
+ assert (
208
+ _SHARED_MODULE.is_under_session_scratchpad(str(repository_module), _session_payload())
209
+ is False
210
+ )
211
+
212
+
213
+ def test_temp_path_outside_scratchpad_shape_is_not_exempt(
214
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
215
+ ) -> None:
216
+ """A path under the harness user directory but off the session/scratchpad shape
217
+ keeps full enforcement."""
218
+ _simulate_windows_platform(monkeypatch, tmp_path)
219
+ _build_windows_scratchpad_directory(tmp_path)
220
+ off_shape_target = tmp_path / HARNESS_USER_DIRECTORY_WINDOWS / "unrelated" / "notes.py"
221
+ off_shape_target.parent.mkdir(parents=True)
222
+
223
+ assert (
224
+ _SHARED_MODULE.is_under_session_scratchpad(str(off_shape_target), _session_payload())
225
+ is False
226
+ )
227
+
228
+
229
+ def test_repository_path_containing_scratchpad_word_is_not_exempt(
230
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
231
+ ) -> None:
232
+ """A repository path outside the temp directory that merely carries a scratchpad
233
+ segment keeps full enforcement."""
234
+ _simulate_windows_platform(monkeypatch, tmp_path)
235
+ _build_windows_scratchpad_directory(tmp_path)
236
+ repository_lookalike = tmp_path.parent / "project_repo" / "scratchpad" / "tool.py"
237
+
238
+ assert (
239
+ _SHARED_MODULE.is_under_session_scratchpad(str(repository_lookalike), _session_payload())
240
+ is False
241
+ )
242
+
243
+
244
+ def test_is_ephemeral_path_true_for_windows_scratchpad(
245
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
246
+ ) -> None:
247
+ _simulate_windows_platform(monkeypatch, tmp_path)
248
+ scratchpad_directory = _build_windows_scratchpad_directory(tmp_path)
249
+ throwaway_script = scratchpad_directory / "probe.py"
250
+
251
+ assert _SHARED_MODULE.is_ephemeral_path(str(throwaway_script), _session_payload()) is True
252
+
253
+
254
+ def test_is_ephemeral_path_true_for_root_anchored_tmp(monkeypatch: pytest.MonkeyPatch) -> None:
255
+ monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
256
+
257
+ assert _SHARED_MODULE.is_ephemeral_path("/tmp/scratch.py") is True
258
+
259
+
260
+ def test_is_ephemeral_path_false_for_repository_file(
261
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
262
+ ) -> None:
263
+ _simulate_windows_platform(monkeypatch, tmp_path)
264
+ monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
265
+ repository_file = tmp_path / "repository" / "orders.py"
266
+ repository_file.parent.mkdir(parents=True)
267
+
268
+ assert _SHARED_MODULE.is_ephemeral_path(str(repository_file), _session_payload()) is False
269
+
270
+
271
+ def test_is_ephemeral_path_reads_session_id_from_environment(
272
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
273
+ ) -> None:
274
+ """With no payload, the session scratchpad match reads the session id from the
275
+ harness environment variable, so a caller that holds no payload still matches."""
276
+ _simulate_windows_platform(monkeypatch, tmp_path)
277
+ monkeypatch.setenv(SESSION_ID_ENVIRONMENT_VARIABLE, SESSION_ID)
278
+ scratchpad_directory = _build_windows_scratchpad_directory(tmp_path)
279
+ throwaway_script = scratchpad_directory / "probe.py"
280
+
281
+ assert _SHARED_MODULE.is_ephemeral_path(str(throwaway_script)) is True
@@ -132,3 +132,149 @@ def test_main_reads_cwd_from_top_level_payload(
132
132
  hook_module.main()
133
133
 
134
134
  assert captured_cwd == [worktree_path]
135
+
136
+
137
+ def _capture_convergence_identity(
138
+ monkeypatch: pytest.MonkeyPatch,
139
+ tmp_path: pathlib.Path,
140
+ command: str,
141
+ cwd_owner_repo: tuple[str, str],
142
+ ) -> tuple[str, str, int]:
143
+ convergence_script = (
144
+ tmp_path / ".claude" / "skills" / "pr-converge" / "scripts" / "check_convergence.py"
145
+ )
146
+ convergence_script.parent.mkdir(parents=True)
147
+ convergence_script.write_text("")
148
+ monkeypatch.setattr(hook_module.Path, "home", classmethod(lambda _cls: tmp_path))
149
+ monkeypatch.setattr(hook_module, "_resolve_owner_repo", lambda _cwd: cwd_owner_repo)
150
+
151
+ captured_identity: list[tuple[str, str, int]] = []
152
+
153
+ def fake_check(
154
+ _script: str, owner: str, repo: str, pr_number: int, _cwd: str | None
155
+ ) -> subprocess.CompletedProcess[str]:
156
+ captured_identity.append((owner, repo, pr_number))
157
+ return subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
158
+
159
+ monkeypatch.setattr(hook_module, "_run_convergence_check", fake_check)
160
+
161
+ payload = {
162
+ "tool_name": "Bash",
163
+ "cwd": str(tmp_path / "worktree"),
164
+ "tool_input": {"command": command},
165
+ }
166
+ monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
167
+ with pytest.raises(SystemExit):
168
+ hook_module.main()
169
+ return captured_identity[0]
170
+
171
+
172
+ def test_repo_flag_overrides_cwd_repo(
173
+ monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
174
+ ) -> None:
175
+ identity = _capture_convergence_identity(
176
+ monkeypatch,
177
+ tmp_path,
178
+ "gh pr ready 161 --repo sample-owner/target-repo",
179
+ ("cwd-owner", "cwd-repo"),
180
+ )
181
+ assert identity == ("sample-owner", "target-repo", 161)
182
+
183
+
184
+ def test_pr_url_yields_repo_and_number(
185
+ monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
186
+ ) -> None:
187
+ identity = _capture_convergence_identity(
188
+ monkeypatch,
189
+ tmp_path,
190
+ "gh pr ready https://github.com/sample-owner/target-repo/pull/161",
191
+ ("cwd-owner", "cwd-repo"),
192
+ )
193
+ assert identity == ("sample-owner", "target-repo", 161)
194
+
195
+
196
+ def test_bare_pr_ready_resolves_repo_from_cwd(
197
+ monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
198
+ ) -> None:
199
+ identity = _capture_convergence_identity(
200
+ monkeypatch,
201
+ tmp_path,
202
+ "gh pr ready 418",
203
+ ("cwd-owner", "cwd-repo"),
204
+ )
205
+ assert identity == ("cwd-owner", "cwd-repo", 418)
206
+
207
+
208
+ def test_parse_repo_flag_space_form() -> None:
209
+ assert hook_module._parse_repo_flag("gh pr ready 161 --repo sample-owner/target-repo") == (
210
+ "sample-owner",
211
+ "target-repo",
212
+ )
213
+
214
+
215
+ def test_parse_repo_flag_equals_form() -> None:
216
+ assert hook_module._parse_repo_flag("gh pr ready 161 --repo=sample-owner/target-repo") == (
217
+ "sample-owner",
218
+ "target-repo",
219
+ )
220
+
221
+
222
+ def test_parse_repo_flag_short_alias() -> None:
223
+ assert hook_module._parse_repo_flag("gh pr ready 161 -R sample-owner/target-repo") == (
224
+ "sample-owner",
225
+ "target-repo",
226
+ )
227
+
228
+
229
+ def test_parse_repo_flag_absent_returns_none() -> None:
230
+ assert hook_module._parse_repo_flag("gh pr ready 418") is None
231
+
232
+
233
+ def test_parse_pr_url_yields_owner_repo_number() -> None:
234
+ assert hook_module._parse_pr_url(
235
+ "gh pr ready https://github.com/sample-owner/target-repo/pull/161"
236
+ ) == ("sample-owner", "target-repo", 161)
237
+
238
+
239
+ def test_parse_pr_url_absent_returns_none() -> None:
240
+ assert hook_module._parse_pr_url("gh pr ready 418") is None
241
+
242
+
243
+ def test_ready_segment_clips_at_command_separator() -> None:
244
+ assert (
245
+ hook_module._ready_command_segment(
246
+ "gh pr ready 161 && gh pr comment 999 --repo other-owner/other-repo"
247
+ )
248
+ == "gh pr ready 161 "
249
+ )
250
+
251
+
252
+ def test_chained_repo_flag_does_not_misbind_the_gate(
253
+ monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
254
+ ) -> None:
255
+ identity = _capture_convergence_identity(
256
+ monkeypatch,
257
+ tmp_path,
258
+ "gh pr ready 161 && gh pr comment 999 --repo other-owner/other-repo --body-file note.md",
259
+ ("cwd-owner", "cwd-repo"),
260
+ )
261
+ assert identity == ("cwd-owner", "cwd-repo", 161)
262
+
263
+
264
+ def test_chained_pr_url_does_not_misbind_the_gate(
265
+ monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
266
+ ) -> None:
267
+ identity = _capture_convergence_identity(
268
+ monkeypatch,
269
+ tmp_path,
270
+ "gh pr ready 161 && echo https://github.com/other-owner/other-repo/pull/5",
271
+ ("cwd-owner", "cwd-repo"),
272
+ )
273
+ assert identity == ("cwd-owner", "cwd-repo", 161)
274
+
275
+
276
+ def test_ready_segment_skips_a_leading_undo_invocation() -> None:
277
+ assert (
278
+ hook_module._ready_command_segment("gh pr ready --undo && gh pr ready 161")
279
+ == "gh pr ready 161"
280
+ )
@@ -14,6 +14,8 @@ import sys
14
14
  from pathlib import Path
15
15
  from unittest.mock import patch
16
16
 
17
+ import pytest
18
+
17
19
  HOOK_SCRIPT_PATH = Path(__file__).parent / "plain_language_blocker.py"
18
20
  _HOOKS_DIR = str(Path(__file__).resolve().parent)
19
21
  _HOOKS_ROOT = str(Path(__file__).resolve().parent.parent)
@@ -185,6 +187,19 @@ def test_write_markdown_with_banned_term_is_denied(tmp_path: Path) -> None:
185
187
  assert _decision_from(completed) == "deny"
186
188
 
187
189
 
190
+ def test_write_markdown_at_ephemeral_path_is_allowed(monkeypatch: pytest.MonkeyPatch) -> None:
191
+ monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
192
+ payload = {
193
+ "tool_name": "Write",
194
+ "tool_input": {
195
+ "file_path": "/tmp/notes.md",
196
+ "content": "This guide explains how to utilize the new cache layer.",
197
+ },
198
+ }
199
+ completed = _run_hook_with_payload(payload)
200
+ assert _decision_from(completed) is None
201
+
202
+
188
203
  def test_write_non_markdown_is_ignored(tmp_path: Path) -> None:
189
204
  target = tmp_path / "notes.txt"
190
205
  payload = {
@@ -7,6 +7,8 @@ import sys
7
7
  from pathlib import Path
8
8
  from unittest.mock import patch
9
9
 
10
+ import pytest
11
+
10
12
  _BLOCKING_DIR = str(Path(__file__).resolve().parent)
11
13
  _HOOKS_ROOT = str(Path(__file__).resolve().parent.parent)
12
14
  if _BLOCKING_DIR not in sys.path:
@@ -207,6 +209,19 @@ def test_detects_now_uses_in_markdown():
207
209
  assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
208
210
 
209
211
 
212
+ def test_markdown_at_ephemeral_path_is_allowed(monkeypatch: pytest.MonkeyPatch) -> None:
213
+ monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
214
+ result = _run_hook(
215
+ "Write",
216
+ {
217
+ "file_path": "/tmp/api.md",
218
+ "content": VIOLATION_MD_INSTEAD,
219
+ },
220
+ )
221
+ assert result.returncode == 0
222
+ assert result.stdout == ""
223
+
224
+
210
225
  def test_detects_edit_new_string():
211
226
  result = _run_hook(
212
227
  "Edit",
@@ -31,6 +31,24 @@ def test_register_binds_dotted_name_to_sibling_constants_file(
31
31
  assert detached_head_line == "HEAD"
32
32
 
33
33
 
34
+ def test_register_binds_gate_output_constants_module(
35
+ run_under_config_shadow: RunUnderConfigShadow,
36
+ ) -> None:
37
+ completed_probe = run_under_config_shadow(
38
+ "import verified_commit_config_bootstrap as bootstrap\n"
39
+ "bootstrap.register_verified_commit_constants()\n"
40
+ "import config.verified_commit_gate_output_constants as loaded\n"
41
+ "print(loaded.__file__)\n"
42
+ "print(loaded.DENY_PERMISSION_DECISION)\n"
43
+ )
44
+ assert completed_probe.returncode == 0, completed_probe.stderr
45
+ resolved_file_line, deny_decision_line = completed_probe.stdout.splitlines()[:2]
46
+ assert resolved_file_line.replace("\\", "/").endswith(
47
+ "blocking/config/verified_commit_gate_output_constants.py"
48
+ )
49
+ assert deny_decision_line == "deny"
50
+
51
+
34
52
  def test_register_leaves_an_already_cached_module_untouched(
35
53
  run_under_config_shadow: RunUnderConfigShadow,
36
54
  ) -> None:
@@ -0,0 +1,134 @@
1
+ """The verified-commit gate's deny payload teaches the verify-skip policy.
2
+
3
+ A blocked commit prints a PreToolUse ``hookSpecificOutput`` deny whose
4
+ ``additionalContext`` carries the ``# verify-skip`` usage rule, so the agent
5
+ learns at the moment of the block when the marker is legitimate.
6
+ """
7
+
8
+ import importlib.util
9
+ import io
10
+ import json
11
+ import pathlib
12
+ import subprocess
13
+ import sys
14
+
15
+ import pytest
16
+
17
+ _HOOK_DIR = pathlib.Path(__file__).parent
18
+ if str(_HOOK_DIR) not in sys.path:
19
+ sys.path.insert(0, str(_HOOK_DIR))
20
+
21
+ gate_spec = importlib.util.spec_from_file_location(
22
+ "verified_commit_gate",
23
+ _HOOK_DIR / "verified_commit_gate.py",
24
+ )
25
+ assert gate_spec is not None
26
+ assert gate_spec.loader is not None
27
+ gate_module = importlib.util.module_from_spec(gate_spec)
28
+ gate_spec.loader.exec_module(gate_module)
29
+ gate_main = gate_module.main
30
+
31
+ PRODUCTION_SOURCE = "def add(left: int, right: int) -> int:\n return left + right\n"
32
+ CHANGED_SOURCE = "def add(left: int, right: int) -> int:\n return left - right\n"
33
+
34
+
35
+ def _run_git(work_dir: pathlib.Path, *git_arguments: str) -> None:
36
+ subprocess.run(
37
+ ["git", "-C", str(work_dir), *git_arguments],
38
+ check=True,
39
+ capture_output=True,
40
+ text=True,
41
+ )
42
+
43
+
44
+ def _make_gated_repo(tmp_path: pathlib.Path) -> pathlib.Path:
45
+ origin_dir = tmp_path / "origin.git"
46
+ work_dir = tmp_path / "work"
47
+ work_dir.mkdir()
48
+ subprocess.run(
49
+ ["git", "init", "--bare", "--initial-branch=main", str(origin_dir)],
50
+ check=True,
51
+ capture_output=True,
52
+ text=True,
53
+ )
54
+ _run_git(work_dir, "init", "--initial-branch=main")
55
+ _run_git(work_dir, "config", "user.email", "tests@example.com")
56
+ _run_git(work_dir, "config", "user.name", "Gate Tests")
57
+ (work_dir / "app.py").write_text(PRODUCTION_SOURCE, encoding="utf-8")
58
+ _run_git(work_dir, "add", "-A")
59
+ _run_git(work_dir, "commit", "-m", "base")
60
+ _run_git(work_dir, "remote", "add", "origin", str(origin_dir))
61
+ _run_git(work_dir, "push", "-u", "origin", "main")
62
+ (work_dir / "app.py").write_text(CHANGED_SOURCE, encoding="utf-8")
63
+ return work_dir
64
+
65
+
66
+ def _isolate_home(monkeypatch: pytest.MonkeyPatch, fake_home: pathlib.Path) -> None:
67
+ home_text = str(fake_home)
68
+ monkeypatch.setenv("HOME", home_text)
69
+ monkeypatch.setenv("USERPROFILE", home_text)
70
+ monkeypatch.delenv("HOMEDRIVE", raising=False)
71
+ monkeypatch.delenv("HOMEPATH", raising=False)
72
+
73
+
74
+ def _run_gate_main(
75
+ monkeypatch: pytest.MonkeyPatch, command_text: str, work_dir: pathlib.Path
76
+ ) -> None:
77
+ payload_text = json.dumps(
78
+ {
79
+ "tool_name": "Bash",
80
+ "tool_input": {"command": command_text},
81
+ "cwd": str(work_dir),
82
+ "transcript_path": "",
83
+ }
84
+ )
85
+ monkeypatch.setattr(sys, "stdin", io.StringIO(payload_text))
86
+ gate_main()
87
+
88
+
89
+ def test_deny_payload_carries_verify_skip_additional_context(
90
+ monkeypatch: pytest.MonkeyPatch,
91
+ capsys: pytest.CaptureFixture[str],
92
+ tmp_path: pathlib.Path,
93
+ ) -> None:
94
+ fake_home = tmp_path / "home"
95
+ fake_home.mkdir()
96
+ _isolate_home(monkeypatch, fake_home)
97
+ work_dir = _make_gated_repo(tmp_path)
98
+ _run_gate_main(monkeypatch, "git commit -m x", work_dir)
99
+ deny_payload = json.loads(capsys.readouterr().out)
100
+ hook_specific_output = deny_payload["hookSpecificOutput"]
101
+ assert hook_specific_output["permissionDecision"] == "deny"
102
+ additional_context = hook_specific_output["additionalContext"]
103
+ assert "# verify-skip" in additional_context
104
+ assert "already passed clean" in additional_context
105
+ assert "fresh verification" in additional_context
106
+
107
+
108
+ def test_marker_inside_a_quoted_message_still_denies(
109
+ monkeypatch: pytest.MonkeyPatch,
110
+ capsys: pytest.CaptureFixture[str],
111
+ tmp_path: pathlib.Path,
112
+ ) -> None:
113
+ fake_home = tmp_path / "home"
114
+ fake_home.mkdir()
115
+ _isolate_home(monkeypatch, fake_home)
116
+ work_dir = _make_gated_repo(tmp_path)
117
+ quoted_marker_command = 'git commit -m "docs: explain the # verify-skip escape hatch"'
118
+ _run_gate_main(monkeypatch, quoted_marker_command, work_dir)
119
+ deny_payload = json.loads(capsys.readouterr().out)
120
+ assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
121
+
122
+
123
+ def test_marker_as_a_trailing_comment_bypasses(
124
+ monkeypatch: pytest.MonkeyPatch,
125
+ capsys: pytest.CaptureFixture[str],
126
+ tmp_path: pathlib.Path,
127
+ ) -> None:
128
+ fake_home = tmp_path / "home"
129
+ fake_home.mkdir()
130
+ _isolate_home(monkeypatch, fake_home)
131
+ work_dir = _make_gated_repo(tmp_path)
132
+ trailing_marker_command = 'git commit -m "wire up feature" # verify-skip'
133
+ _run_gate_main(monkeypatch, trailing_marker_command, work_dir)
134
+ assert capsys.readouterr().out == ""
@@ -0,0 +1,41 @@
1
+ """Entry-boundary behavior of the verified_commit_gate hook process.
2
+
3
+ Runs the hook as a subprocess the way Claude Code does and pins the two
4
+ silent paths: a tool the gate does not cover, and a gated command carrying
5
+ the bypass marker.
6
+ """
7
+
8
+ import json
9
+ import pathlib
10
+ import subprocess
11
+ import sys
12
+
13
+ _BLOCKING_DIRECTORY = pathlib.Path(__file__).resolve().parent.parent
14
+ _GATE_SCRIPT_PATH = _BLOCKING_DIRECTORY / "verified_commit_gate.py"
15
+
16
+
17
+ def _run_gate_process(tool_name: str, command_text: str) -> str:
18
+ payload_text = json.dumps(
19
+ {
20
+ "tool_name": tool_name,
21
+ "tool_input": {"command": command_text},
22
+ "cwd": str(_BLOCKING_DIRECTORY),
23
+ "transcript_path": "",
24
+ }
25
+ )
26
+ completed_process = subprocess.run(
27
+ [sys.executable, str(_GATE_SCRIPT_PATH)],
28
+ input=payload_text,
29
+ capture_output=True,
30
+ text=True,
31
+ check=True,
32
+ )
33
+ return completed_process.stdout
34
+
35
+
36
+ def test_uncovered_tool_name_stays_silent() -> None:
37
+ assert _run_gate_process("Read", "git commit -m x") == ""
38
+
39
+
40
+ def test_bypass_marker_stays_silent_at_the_process_boundary() -> None:
41
+ assert _run_gate_process("Bash", "git commit -m x # verify-skip") == ""