claude-dev-env 2.0.1 → 2.1.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 (127) hide show
  1. package/CLAUDE.md +1 -1
  2. package/agents/clean-coder.md +31 -1
  3. package/audit-rubrics/prompts/category-e-dead-code.md +2 -2
  4. package/bin/install.test.mjs +2 -2
  5. package/docs/CLAUDE.md +6 -3
  6. package/docs/CODE_RULES.md +5 -1
  7. package/docs/agent-spawn-protocol.md +39 -0
  8. package/docs/nas-ssh-invocation.md +23 -0
  9. package/docs/worker-completion-gate.md +33 -0
  10. package/hooks/blocking/CLAUDE.md +1 -0
  11. package/hooks/blocking/code_rules_dead_module_constant.py +11 -5
  12. package/hooks/blocking/code_rules_shared.py +126 -47
  13. package/hooks/blocking/config/CLAUDE.md +2 -0
  14. package/hooks/blocking/config/verified_commit_constants.py +8 -1
  15. package/hooks/blocking/config/verified_commit_context_constants.py +21 -0
  16. package/hooks/blocking/config/verified_commit_gate_output_constants.py +14 -0
  17. package/hooks/blocking/conftest.py +36 -30
  18. package/hooks/blocking/convergence_gate_blocker.py +76 -8
  19. package/hooks/blocking/plain_language_blocker.py +8 -0
  20. package/hooks/blocking/state_description_blocker.py +4 -1
  21. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +58 -0
  22. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant_alias.py +133 -0
  23. package/hooks/blocking/test_code_rules_shared.py +120 -20
  24. package/hooks/blocking/test_convergence_gate_blocker.py +146 -0
  25. package/hooks/blocking/test_plain_language_blocker.py +15 -0
  26. package/hooks/blocking/test_state_description_blocker.py +15 -0
  27. package/hooks/blocking/test_verification_verdict_store.py +12 -0
  28. package/hooks/blocking/test_verified_commit_config_bootstrap.py +18 -0
  29. package/hooks/blocking/test_verified_commit_docs_delta.py +176 -0
  30. package/hooks/blocking/test_verified_commit_gate_additional_context.py +134 -0
  31. package/hooks/blocking/tests/test_pii_prevention_blocker.py +0 -1
  32. package/hooks/blocking/tests/test_verified_commit_gate.py +41 -0
  33. package/hooks/blocking/verified_commit_config_bootstrap.py +22 -10
  34. package/hooks/blocking/verified_commit_gate.py +113 -568
  35. package/hooks/blocking/verified_commit_gate_parts/CLAUDE.md +28 -0
  36. package/hooks/blocking/verified_commit_gate_parts/__init__.py +1 -0
  37. package/hooks/blocking/verified_commit_gate_parts/command_tokenization.py +174 -0
  38. package/hooks/blocking/verified_commit_gate_parts/deny_payload.py +53 -0
  39. package/hooks/blocking/verified_commit_gate_parts/deny_reason.py +80 -0
  40. package/hooks/blocking/verified_commit_gate_parts/directory_resolution.py +170 -0
  41. package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +205 -0
  42. package/hooks/blocking/verified_commit_gate_parts/tests/conftest.py +10 -0
  43. package/hooks/blocking/verified_commit_gate_parts/tests/test_command_tokenization.py +94 -0
  44. package/hooks/blocking/verified_commit_gate_parts/tests/test_deny_payload.py +17 -0
  45. package/hooks/blocking/verified_commit_gate_parts/tests/test_deny_reason.py +38 -0
  46. package/hooks/blocking/verified_commit_gate_parts/tests/test_directory_resolution.py +71 -0
  47. package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +61 -0
  48. package/hooks/hooks_constants/CLAUDE.md +4 -1
  49. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
  50. package/hooks/hooks_constants/convergence_gate_blocker_constants.py +36 -0
  51. package/hooks/hooks_constants/dead_module_constant_constants.py +8 -0
  52. package/hooks/hooks_constants/harness_scratchpad_constants.py +10 -9
  53. package/hooks/hooks_constants/mypy_integration_constants.py +16 -0
  54. package/hooks/hooks_constants/pii_prevention_constants.py +1 -0
  55. package/hooks/validators/mypy_integration.py +145 -24
  56. package/hooks/validators/python_antipattern_checks.py +16 -0
  57. package/hooks/validators/run_all_validators.py +9 -3
  58. package/hooks/validators/test_mypy_integration.py +154 -0
  59. package/hooks/validators/test_python_antipattern_checks.py +112 -1
  60. package/hooks/validators/test_run_all_validators_pretooluse.py +16 -0
  61. package/package.json +1 -1
  62. package/rules/CLAUDE.md +3 -3
  63. package/rules/agent-spawn-protocol.md +5 -43
  64. package/rules/code-standards.md +1 -36
  65. package/rules/env-var-table-code-drift.md +2 -21
  66. package/rules/hook-prose-matches-detector.md +5 -16
  67. package/rules/nas-ssh-invocation.md +3 -15
  68. package/rules/no-historical-clutter.md +7 -49
  69. package/rules/no-inline-destructive-literals.md +3 -5
  70. package/rules/package-inventory-stale-entry.md +7 -32
  71. package/rules/re-stage-before-commit.md +6 -23
  72. package/rules/shell-invocation-policy.md +1 -1
  73. package/rules/vault-context.md +3 -3
  74. package/rules/verified-commit-gate-skip.md +28 -0
  75. package/rules/workers-done-before-complete.md +2 -30
  76. package/scripts/claude_chain_runner.py +39 -3
  77. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +3 -0
  78. package/scripts/test_claude_chain_runner.py +112 -0
  79. package/scripts/test_grok_headless_runner.py +0 -1
  80. package/skills/auditing-claude-config/CLAUDE.md +2 -1
  81. package/skills/auditing-claude-config/SKILL.md +114 -176
  82. package/skills/auditing-claude-config/reference/probe-hook.md +74 -0
  83. package/skills/autoconverge/CLAUDE.md +1 -0
  84. package/skills/autoconverge/SKILL.md +310 -346
  85. package/skills/autoconverge/reference/CLAUDE.md +1 -0
  86. package/skills/autoconverge/reference/copilot-findings.md +51 -0
  87. package/skills/closeout/SKILL.md +7 -9
  88. package/skills/copilot-finding-triage/SKILL.md +5 -7
  89. package/skills/copilot-review/CLAUDE.md +3 -2
  90. package/skills/copilot-review/SKILL.md +119 -155
  91. package/skills/copilot-review/templates/subagent-prompt.md +49 -0
  92. package/skills/everything-search/SKILL.md +7 -1
  93. package/skills/fresh-branch/CLAUDE.md +6 -9
  94. package/skills/fresh-branch/SKILL.md +84 -33
  95. package/skills/fresh-branch/scripts/create_fresh_branch.py +445 -0
  96. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/__init__.py +1 -0
  97. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +74 -0
  98. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +670 -0
  99. package/skills/orchestrator/SKILL.md +161 -147
  100. package/skills/pr-converge/SKILL.md +277 -422
  101. package/skills/pr-converge/reference/CLAUDE.md +1 -0
  102. package/skills/pr-converge/reference/progress-checklist.md +168 -0
  103. package/skills/pr-converge/test_step5_host_branch.py +8 -6
  104. package/skills/pr-loop-cloud-transport/SKILL.md +1 -1
  105. package/skills/privacy-hygiene/SKILL.md +68 -115
  106. package/skills/privacy-hygiene/reference/sweep-procedure.md +62 -0
  107. package/skills/session-log/CLAUDE.md +2 -1
  108. package/skills/session-log/SKILL.md +4 -26
  109. package/skills/session-log/templates/frontmatter.md +40 -0
  110. package/skills/skill-builder/CLAUDE.md +8 -7
  111. package/skills/skill-builder/SKILL.md +26 -11
  112. package/skills/skill-builder/references/CLAUDE.md +3 -1
  113. package/skills/skill-builder/references/delegation-map.md +21 -12
  114. package/skills/skill-builder/references/description-field.md +9 -11
  115. package/skills/skill-builder/references/deterministic-elements.md +218 -0
  116. package/skills/skill-builder/references/self-audit-checklist.md +62 -45
  117. package/skills/skill-builder/references/skill-modularity.md +8 -9
  118. package/skills/skill-builder/templates/CLAUDE.md +2 -2
  119. package/skills/skill-builder/templates/gap-analysis.md +15 -0
  120. package/skills/skill-builder/workflows/CLAUDE.md +5 -5
  121. package/skills/skill-builder/workflows/improve-skill.md +18 -9
  122. package/skills/skill-builder/workflows/new-skill.md +23 -15
  123. package/skills/skill-builder/workflows/polish-skill.md +28 -21
  124. package/system-prompts/CLAUDE.md +3 -3
  125. package/docs/agents-md-alignment-plan.md +0 -123
  126. package/docs/emotion-informed-prompt-design.md +0 -362
  127. package/rules/es-exe-file-search.md +0 -17
@@ -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",
@@ -211,6 +211,18 @@ def test_corrective_message_scopes_comment_exemption_to_python() -> None:
211
211
  assert "comment-, and test-only surfaces are exempt" not in lowered_message
212
212
 
213
213
 
214
+ def test_corrective_message_names_worktree_surface_keying() -> None:
215
+ lowered_message = CORRECTIVE_MESSAGE.lower()
216
+ assert "work tree" in lowered_message
217
+ assert "byte-identical" in lowered_message
218
+
219
+
220
+ def test_corrective_message_gives_cross_worktree_remedy() -> None:
221
+ lowered_message = CORRECTIVE_MESSAGE.lower()
222
+ assert "run this command from the work tree" in lowered_message
223
+ assert "verify this surface here" in lowered_message
224
+
225
+
214
226
  def test_untracked_claude_production_hook_is_gated(tmp_path: pathlib.Path) -> None:
215
227
  work_dir = _make_repo_with_origin(tmp_path)
216
228
  new_hook_dir = work_dir / ".claude" / "hooks" / "blocking"
@@ -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,176 @@
1
+ """Gate behavior for docs-only deltas and the cross-work-tree deny message.
2
+
3
+ Each test builds a real git repository with a real origin remote and drives
4
+ ``deny_reason_for_directory`` — the same decision the verified_commit_gate hook
5
+ runs — so the docs-only exemption, the docs-after-verified-code block, and the
6
+ denial message's work-tree keying are asserted against live git state.
7
+ """
8
+
9
+ import importlib.util
10
+ import pathlib
11
+ import subprocess
12
+ import sys
13
+
14
+ import pytest
15
+
16
+ _HOOK_DIR = pathlib.Path(__file__).parent
17
+ if str(_HOOK_DIR) not in sys.path:
18
+ sys.path.insert(0, str(_HOOK_DIR))
19
+
20
+ gate_spec = importlib.util.spec_from_file_location(
21
+ "verified_commit_gate",
22
+ _HOOK_DIR / "verified_commit_gate.py",
23
+ )
24
+ assert gate_spec is not None
25
+ assert gate_spec.loader is not None
26
+ gate_module = importlib.util.module_from_spec(gate_spec)
27
+ gate_spec.loader.exec_module(gate_module)
28
+ deny_reason_for_directory = gate_module.deny_reason_for_directory
29
+
30
+ store_spec = importlib.util.spec_from_file_location(
31
+ "verification_verdict_store",
32
+ _HOOK_DIR / "verification_verdict_store.py",
33
+ )
34
+ assert store_spec is not None
35
+ assert store_spec.loader is not None
36
+ store_module = importlib.util.module_from_spec(store_spec)
37
+ store_spec.loader.exec_module(store_module)
38
+ resolve_merge_base = store_module.resolve_merge_base
39
+ branch_surface_manifest = store_module.branch_surface_manifest
40
+ manifest_sha256 = store_module.manifest_sha256
41
+ write_verdict = store_module.write_verdict
42
+
43
+ PRODUCTION_SOURCE = "def add(left: int, right: int) -> int:\n return left + right\n"
44
+ BEHAVIORAL_EDIT_SOURCE = "def add(left: int, right: int) -> int:\n return left - right\n"
45
+
46
+
47
+ def _run_git(repo_dir: pathlib.Path, *git_arguments: str) -> None:
48
+ subprocess.run(
49
+ ["git", "-C", str(repo_dir), *git_arguments],
50
+ check=True,
51
+ capture_output=True,
52
+ text=True,
53
+ )
54
+
55
+
56
+ def _isolate_home(monkeypatch: pytest.MonkeyPatch, fake_home: pathlib.Path) -> None:
57
+ home_text = str(fake_home)
58
+ monkeypatch.setenv("HOME", home_text)
59
+ monkeypatch.setenv("USERPROFILE", home_text)
60
+ monkeypatch.delenv("HOMEDRIVE", raising=False)
61
+ monkeypatch.delenv("HOMEPATH", raising=False)
62
+
63
+
64
+ def _empty_hooks_dir(tmp_path: pathlib.Path) -> pathlib.Path:
65
+ hooks_dir = tmp_path / "nohooks"
66
+ hooks_dir.mkdir(exist_ok=True)
67
+ return hooks_dir
68
+
69
+
70
+ def _empty_transcript(tmp_path: pathlib.Path) -> pathlib.Path:
71
+ transcript_path = tmp_path / "projects" / "demo" / "sess1.jsonl"
72
+ transcript_path.parent.mkdir(parents=True, exist_ok=True)
73
+ transcript_path.write_text("", encoding="utf-8")
74
+ return transcript_path
75
+
76
+
77
+ def _init_pushed_repo(tmp_path: pathlib.Path) -> pathlib.Path:
78
+ origin_dir = tmp_path / "origin.git"
79
+ work_dir = tmp_path / "work"
80
+ work_dir.mkdir()
81
+ subprocess.run(
82
+ ["git", "init", "--bare", "--initial-branch=main", str(origin_dir)],
83
+ check=True,
84
+ capture_output=True,
85
+ text=True,
86
+ )
87
+ _run_git(work_dir, "init", "--initial-branch=main")
88
+ _run_git(work_dir, "config", "user.email", "tests@example.com")
89
+ _run_git(work_dir, "config", "user.name", "Docs Delta Tests")
90
+ _run_git(work_dir, "config", "core.hooksPath", str(_empty_hooks_dir(tmp_path)))
91
+ (work_dir / "app.py").write_text(PRODUCTION_SOURCE, encoding="utf-8")
92
+ (work_dir / "README.md").write_text("# base\n", encoding="utf-8")
93
+ _run_git(work_dir, "add", "-A")
94
+ _run_git(work_dir, "commit", "-m", "base")
95
+ _run_git(work_dir, "remote", "add", "origin", str(origin_dir))
96
+ _run_git(work_dir, "push", "-u", "origin", "main")
97
+ return work_dir
98
+
99
+
100
+ def _make_docs_only_repo(tmp_path: pathlib.Path) -> pathlib.Path:
101
+ work_dir = _init_pushed_repo(tmp_path)
102
+ (work_dir / "README.md").write_text("# base\n\nUpdated docs.\n", encoding="utf-8")
103
+ return work_dir
104
+
105
+
106
+ def _make_behavioral_code_repo(tmp_path: pathlib.Path) -> pathlib.Path:
107
+ work_dir = _init_pushed_repo(tmp_path)
108
+ (work_dir / "app.py").write_text(BEHAVIORAL_EDIT_SOURCE, encoding="utf-8")
109
+ return work_dir
110
+
111
+
112
+ def _live_surface_hash(work_dir: pathlib.Path) -> str:
113
+ merge_base_sha = resolve_merge_base(str(work_dir))
114
+ assert merge_base_sha is not None
115
+ surface_manifest_text = branch_surface_manifest(str(work_dir), merge_base_sha)
116
+ assert surface_manifest_text is not None
117
+ return manifest_sha256(surface_manifest_text)
118
+
119
+
120
+ def test_pure_docs_only_branch_is_allowed_without_verdict(
121
+ monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
122
+ ) -> None:
123
+ fake_home = tmp_path / "home"
124
+ fake_home.mkdir()
125
+ _isolate_home(monkeypatch, fake_home)
126
+ work_dir = _make_docs_only_repo(tmp_path)
127
+ transcript_path = _empty_transcript(tmp_path)
128
+ assert deny_reason_for_directory(str(work_dir), str(transcript_path)) is None
129
+
130
+
131
+ def test_readme_change_after_verified_code_commit_is_denied(
132
+ monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
133
+ ) -> None:
134
+ fake_home = tmp_path / "home"
135
+ fake_home.mkdir()
136
+ _isolate_home(monkeypatch, fake_home)
137
+ work_dir = _make_behavioral_code_repo(tmp_path)
138
+ code_surface_hash = _live_surface_hash(work_dir)
139
+ write_verdict(str(work_dir), code_surface_hash, True, [], "agent-x")
140
+ transcript_path = _empty_transcript(tmp_path)
141
+ assert deny_reason_for_directory(str(work_dir), str(transcript_path)) is None
142
+ _run_git(work_dir, "add", "-A")
143
+ _run_git(work_dir, "commit", "-m", "verified code change")
144
+ (work_dir / "README.md").write_text("# added after verification\n", encoding="utf-8")
145
+ deny_reason = deny_reason_for_directory(str(work_dir), str(transcript_path))
146
+ assert deny_reason is not None
147
+ assert "VERIFIED_COMMIT_GATE" in deny_reason
148
+
149
+
150
+ def test_readme_added_beside_unverified_code_is_denied(
151
+ monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
152
+ ) -> None:
153
+ fake_home = tmp_path / "home"
154
+ fake_home.mkdir()
155
+ _isolate_home(monkeypatch, fake_home)
156
+ work_dir = _make_behavioral_code_repo(tmp_path)
157
+ (work_dir / "README.md").write_text("# docs beside code\n", encoding="utf-8")
158
+ transcript_path = _empty_transcript(tmp_path)
159
+ deny_reason = deny_reason_for_directory(str(work_dir), str(transcript_path))
160
+ assert deny_reason is not None
161
+ assert "VERIFIED_COMMIT_GATE" in deny_reason
162
+
163
+
164
+ def test_deny_reason_for_directory_names_worktree_keying_and_remedy(
165
+ monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
166
+ ) -> None:
167
+ fake_home = tmp_path / "home"
168
+ fake_home.mkdir()
169
+ _isolate_home(monkeypatch, fake_home)
170
+ work_dir = _make_behavioral_code_repo(tmp_path)
171
+ transcript_path = _empty_transcript(tmp_path)
172
+ deny_reason = deny_reason_for_directory(str(work_dir), str(transcript_path))
173
+ assert deny_reason is not None
174
+ lowered_reason = deny_reason.lower()
175
+ assert "work tree" in lowered_reason
176
+ assert "run this command from the work tree" in lowered_reason
@@ -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 == ""
@@ -142,7 +142,6 @@ def test_commit_with_missing_local_identity_still_blocks_the_value(
142
142
  assert deny_reason is not None
143
143
  assert "email" in deny_reason
144
144
 
145
-
146
145
  def _init_repo_with_staged_pii(repository_root: Path) -> None:
147
146
  repository_root.mkdir(parents=True, exist_ok=True)
148
147
  subprocess.run(["git", "init", "-q"], cwd=repository_root, check=True)
@@ -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") == ""
@@ -1,12 +1,13 @@
1
- """Deterministic loader for the verified-commit constants module.
1
+ """Deterministic loader for the verified-commit constants modules.
2
2
 
3
3
  The blocking hooks import their shared constants as
4
- ``from config.verified_commit_constants import ...``. In the installed hook
5
- tree a second, unrelated ``config`` package can sit ahead of this package on
6
- ``sys.path`` and win that dotted name by path order, so the import binds to the
7
- wrong file and raises ImportError on any constant the stale copy lacks. This
8
- module binds the dotted name to the sibling ``config/verified_commit_constants``
9
- file by explicit location, so resolution never depends on ``sys.path`` order.
4
+ ``from config.verified_commit_constants import ...`` and
5
+ ``from config.verified_commit_context_constants import ...``. In the installed
6
+ hook tree a second, unrelated ``config`` package can sit ahead of this package
7
+ on ``sys.path`` and win those dotted names by path order, so the import binds
8
+ to the wrong file and raises ImportError on any constant the stale copy lacks.
9
+ This module binds each dotted name to its sibling ``config/`` file by explicit
10
+ location, so resolution never depends on ``sys.path`` order.
10
11
  """
11
12
 
12
13
  from __future__ import annotations
@@ -17,7 +18,7 @@ from pathlib import Path
17
18
 
18
19
 
19
20
  def register_verified_commit_constants() -> None:
20
- """Bind ``config.verified_commit_constants`` to the sibling config file.
21
+ """Bind both verified-commit constants dotted names to their config files.
21
22
 
22
23
  ::
23
24
 
@@ -35,11 +36,22 @@ def register_verified_commit_constants() -> None:
35
36
  Returns:
36
37
  None. The effect is the ``sys.modules`` registration.
37
38
  """
38
- config_module_dotted_name = "config.verified_commit_constants"
39
+ all_constants_module_stems = (
40
+ "verified_commit_constants",
41
+ "verified_commit_context_constants",
42
+ "verified_commit_gate_output_constants",
43
+ )
44
+ for each_module_stem in all_constants_module_stems:
45
+ _register_config_module(each_module_stem)
46
+
47
+
48
+ def _register_config_module(module_stem: str) -> None:
49
+ """Bind one ``config.<module_stem>`` dotted name to its sibling file."""
50
+ config_module_dotted_name = f"config.{module_stem}"
39
51
  if config_module_dotted_name in sys.modules:
40
52
  return
41
53
  constants_file_path = (
42
- Path(__file__).resolve().parent / "config" / "verified_commit_constants.py"
54
+ Path(__file__).resolve().parent / "config" / f"{module_stem}.py"
43
55
  )
44
56
  module_spec = importlib.util.spec_from_file_location(
45
57
  config_module_dotted_name, constants_file_path