claude-dev-env 7.0.0 → 7.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 (210) hide show
  1. package/.agents/skills/pull-request/scripts/pull_request.py +2 -0
  2. package/.agents/skills/pull-request/scripts/test_pull_request.py +34 -0
  3. package/.agents/skills/session-continuity/AGENTS.md +1 -0
  4. package/.agents/skills/session-continuity/CLAUDE.md +1 -0
  5. package/.agents/skills/session-continuity/README.md +189 -0
  6. package/.agents/skills/session-continuity/SKILL.md +114 -0
  7. package/.agents/skills/session-continuity/continuity.mjs +375 -0
  8. package/.agents/skills/session-continuity/continuity.test.mjs +303 -0
  9. package/.agents/skills/syncing-submodules/scripts/test_sync_parent_pointer.py +0 -7
  10. package/.agents/skills/windows-scheduled-task/SKILL.md +153 -0
  11. package/bin/cde.mjs +36 -8
  12. package/bin/cde.test.mjs +48 -0
  13. package/bin/ever-shipped-skills.mjs +2 -20
  14. package/bin/ever-shipped-skills.test.mjs +8 -0
  15. package/bin/expand_home_directory_tokens.mjs +4 -5
  16. package/bin/expand_home_directory_tokens.test.mjs +128 -0
  17. package/bin/git_hooks_installer.mjs +22 -3
  18. package/bin/git_hooks_installer.test.mjs +159 -5
  19. package/bin/install-session-continuity.mjs +78 -0
  20. package/bin/install-session-continuity.test.mjs +42 -0
  21. package/bin/install.codex-rules.test.mjs +5 -2
  22. package/bin/install.cursor-rules.test.mjs +5 -2
  23. package/bin/install.mjs +478 -63
  24. package/bin/install.policy-lint.test.mjs +56 -4
  25. package/bin/install.profiles.test.mjs +10 -0
  26. package/bin/install.prune.test.mjs +227 -17
  27. package/bin/install.test.mjs +500 -120
  28. package/bin/install.transaction.test.mjs +3 -3
  29. package/docs/CODE_RULES.md +1 -1
  30. package/hooks/blocking/code_rules_banned_identifiers.py +3 -0
  31. package/hooks/blocking/precommit_code_rules_gate.py +5 -8
  32. package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +7 -52
  33. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +0 -69
  34. package/hooks/blocking/test_code_rules_banned_identifiers.py +64 -0
  35. package/hooks/blocking/test_pre_tool_use_dispatcher.py +10 -244
  36. package/hooks/blocking/test_shared_stdin_adoption.py +1 -47
  37. package/hooks/git-hooks/git_hooks_constants/verification_notice_constants.py +116 -0
  38. package/hooks/git-hooks/post_commit.py +33 -0
  39. package/hooks/git-hooks/pre_commit.py +18 -90
  40. package/hooks/git-hooks/pre_push.py +45 -541
  41. package/hooks/git-hooks/test_installed_policy_lint.py +55 -6
  42. package/hooks/git-hooks/test_native_hook_support.py +129 -0
  43. package/hooks/git-hooks/test_native_policy_commit.py +39 -6
  44. package/hooks/git-hooks/test_post_commit.py +43 -0
  45. package/hooks/git-hooks/test_pre_commit.py +36 -392
  46. package/hooks/git-hooks/test_pre_push.py +48 -1631
  47. package/hooks/git-hooks/test_verification_notice.py +60 -0
  48. package/hooks/git-hooks/test_verification_start.py +173 -0
  49. package/hooks/git-hooks/verification_notice.py +192 -0
  50. package/hooks/git-hooks/verification_notice_context.py +253 -0
  51. package/hooks/git-hooks/verification_notice_state.py +237 -0
  52. package/hooks/git-hooks/verification_start.py +100 -0
  53. package/hooks/hooks.json +0 -119
  54. package/hooks/hooks_constants/banned_identifiers_constants.py +4 -0
  55. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +1 -15
  56. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +0 -4
  57. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +0 -50
  58. package/hooks/hooks_constants/stop_dispatcher_constants.py +1 -4
  59. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +3 -66
  60. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +42 -185
  61. package/hooks/hooks_constants/test_stop_dispatcher_constants.py +3 -25
  62. package/hooks/validation/test_post_tool_use_dispatcher.py +35 -298
  63. package/hooks/validators/run_all_validators.py +5 -1
  64. package/hooks/validators/security_checks.py +57 -2
  65. package/hooks/validators/test_hook_timing_harness.py +2 -5
  66. package/hooks/validators/test_run_all_validators_mutation_tool_parity.py +3 -11
  67. package/hooks/validators/test_run_all_validators_policy_lint.py +36 -0
  68. package/hooks/validators/test_security_checks.py +57 -0
  69. package/package.json +1 -1
  70. package/rules/confirm-implementation-forks.md +2 -0
  71. package/rules/git-workflow.md +12 -0
  72. package/rules/pstack-host-mapping.md +48 -0
  73. package/rules/vault-context.md +3 -5
  74. package/rules/verify-runtime-state.md +7 -0
  75. package/scripts/audit-hooks-cli.mjs +5 -0
  76. package/scripts/audit-hooks.mjs +72 -16
  77. package/scripts/automatic_advisory/__init__.py +1 -0
  78. package/scripts/automatic_advisory/checkout.py +51 -0
  79. package/scripts/automatic_advisory/cli.py +334 -0
  80. package/scripts/automatic_advisory/config/__init__.py +1 -0
  81. package/scripts/automatic_advisory/config/constants.py +93 -0
  82. package/scripts/automatic_advisory/config/timing.py +4 -0
  83. package/scripts/automatic_advisory/configuration.py +197 -0
  84. package/scripts/automatic_advisory/execution.py +350 -0
  85. package/scripts/automatic_advisory/git.py +69 -0
  86. package/scripts/automatic_advisory/model.py +107 -0
  87. package/scripts/automatic_advisory/process_host.py +32 -0
  88. package/scripts/automatic_advisory/publisher.py +84 -0
  89. package/scripts/automatic_advisory/runner.py +399 -0
  90. package/scripts/automatic_advisory/state.py +116 -0
  91. package/scripts/automatic_advisory/window_flags.py +41 -0
  92. package/scripts/automatic_advisory/windows_job.py +122 -0
  93. package/scripts/codex_compat_materializer.py +99 -13
  94. package/scripts/durable_post_lint.py +30 -2
  95. package/scripts/durable_post_lint_config/config/constants.py +10 -0
  96. package/scripts/durable_post_lint_config/config/test_constants.py +13 -0
  97. package/scripts/local_report_cli.py +184 -0
  98. package/scripts/local_report_core.py +247 -0
  99. package/scripts/local_report_publisher.py +12 -0
  100. package/scripts/local_report_validation.py +357 -0
  101. package/scripts/local_verification/__init__.py +23 -0
  102. package/scripts/local_verification/check_runner.py +364 -0
  103. package/scripts/local_verification/check_support.py +99 -0
  104. package/scripts/local_verification/cli.py +158 -0
  105. package/scripts/local_verification/command_runner.py +84 -0
  106. package/scripts/local_verification/config/__init__.py +1 -0
  107. package/scripts/local_verification/config/constants.py +89 -0
  108. package/scripts/local_verification/config/timing.py +1 -0
  109. package/scripts/local_verification/git_state.py +146 -0
  110. package/scripts/local_verification/manifest.py +224 -0
  111. package/scripts/local_verification/model.py +174 -0
  112. package/scripts/local_verification/runner.py +234 -0
  113. package/scripts/policy_lint/adapter_pairing.py +58 -3
  114. package/scripts/policy_lint/config/approved_test_pairs.py +323 -0
  115. package/scripts/pr_verification/__init__.py +1 -0
  116. package/scripts/pr_verification/branch_refs.py +114 -0
  117. package/scripts/pr_verification/config/__init__.py +1 -0
  118. package/scripts/pr_verification/config/constants.py +84 -0
  119. package/scripts/pr_verification/config/timing.py +5 -0
  120. package/scripts/pr_verification/github.py +137 -0
  121. package/scripts/pr_verification/github_api.py +376 -0
  122. package/scripts/pr_verification/github_labels.py +88 -0
  123. package/scripts/pr_verification/github_parsing.py +182 -0
  124. package/scripts/pr_verification/github_transport.py +91 -0
  125. package/scripts/pr_verification/lock.py +122 -0
  126. package/scripts/pr_verification/model.py +43 -0
  127. package/scripts/pr_verification/requirements.txt +1 -0
  128. package/scripts/test_durable_post_lint.py +97 -0
  129. package/scripts/tests/test-audit-hooks-cli.mjs +150 -0
  130. package/scripts/tests/test-audit-hooks.mjs +94 -2
  131. package/scripts/tests/test_automatic_advisory.py +376 -0
  132. package/scripts/tests/test_automatic_advisory_job_cleanup.py +71 -0
  133. package/scripts/tests/test_automatic_advisory_polling.py +216 -0
  134. package/scripts/tests/test_automatic_advisory_recovery.py +320 -0
  135. package/scripts/tests/test_automatic_advisory_repairs.py +400 -0
  136. package/scripts/tests/test_automatic_advisory_settings.py +48 -0
  137. package/scripts/tests/test_automatic_advisory_window_flags.py +163 -0
  138. package/scripts/tests/test_closed_pr_label.py +157 -0
  139. package/scripts/tests/test_codex_compat_materializer.py +189 -6
  140. package/scripts/tests/test_github_labels.py +101 -0
  141. package/scripts/tests/test_github_transport.py +125 -0
  142. package/scripts/tests/test_local_report_publisher.py +396 -0
  143. package/scripts/tests/test_local_report_publisher_cli.py +85 -0
  144. package/scripts/tests/test_local_verification.py +348 -0
  145. package/scripts/tests/test_local_verification_cli.py +98 -0
  146. package/scripts/tests/test_policy_lint_rules_pairing.py +163 -0
  147. package/scripts/tests/test_pr_verification_github.py +392 -0
  148. package/scripts/tests/test_pr_verification_lock.py +168 -0
  149. package/scripts/tests/test_verification_notice.py +347 -0
  150. package/hooks/blocking/ask_user_question_shape_blocker.py +0 -83
  151. package/hooks/blocking/bot_mention_comment_blocker.py +0 -76
  152. package/hooks/blocking/claude_md_orphan_file_blocker.py +0 -129
  153. package/hooks/blocking/cursor_cli_python_misfire_blocker.py +0 -108
  154. package/hooks/blocking/destructive_command_blocker.py +0 -2073
  155. package/hooks/blocking/fable_spawn_gate.py +0 -187
  156. package/hooks/blocking/luna_fast_mode_gate.py +0 -160
  157. package/hooks/blocking/nas_ssh_binary_enforcer.py +0 -163
  158. package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +0 -260
  159. package/hooks/blocking/package_inventory_stale_blocker.py +0 -107
  160. package/hooks/blocking/pii_prevention_blocker.py +0 -365
  161. package/hooks/blocking/piped_pytest_blocker.py +0 -1223
  162. package/hooks/blocking/question_to_user_enforcer.py +0 -130
  163. package/hooks/blocking/send_user_file_open_locally_blocker.py +0 -71
  164. package/hooks/blocking/sensitive_file_protector.py +0 -167
  165. package/hooks/blocking/session_edit_stage_gate.py +0 -684
  166. package/hooks/blocking/session_handoff_blocker.py +0 -182
  167. package/hooks/blocking/shell_substitution_blocker.py +0 -350
  168. package/hooks/blocking/test_ask_user_question_shape.py +0 -69
  169. package/hooks/blocking/test_bot_mention_comment_blocker.py +0 -131
  170. package/hooks/blocking/test_claude_md_orphan_file_blocker.py +0 -1277
  171. package/hooks/blocking/test_cursor_cli_python_misfire_blocker.py +0 -208
  172. package/hooks/blocking/test_destructive_command_blocker.py +0 -2024
  173. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +0 -105
  174. package/hooks/blocking/test_destructive_command_blocker_no_verify.py +0 -122
  175. package/hooks/blocking/test_destructive_command_blocker_patterns.py +0 -133
  176. package/hooks/blocking/test_fable_spawn_gate.py +0 -381
  177. package/hooks/blocking/test_inventory_deadlock_resolution.py +0 -154
  178. package/hooks/blocking/test_luna_fast_mode_gate.py +0 -211
  179. package/hooks/blocking/test_nas_ssh_binary_enforcer.py +0 -225
  180. package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +0 -231
  181. package/hooks/blocking/test_package_inventory_stale_blocker.py +0 -382
  182. package/hooks/blocking/test_pii_prevention_blocker.py +0 -1222
  183. package/hooks/blocking/test_pii_prevention_windows_git_path.py +0 -41
  184. package/hooks/blocking/test_pii_write_surface_ephemeral.py +0 -221
  185. package/hooks/blocking/test_piped_pytest_blocker.py +0 -587
  186. package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +0 -233
  187. package/hooks/blocking/test_question_to_user_enforcer.py +0 -169
  188. package/hooks/blocking/test_send_user_file_open_locally_blocker.py +0 -118
  189. package/hooks/blocking/test_sensitive_file_protector.py +0 -365
  190. package/hooks/blocking/test_session_edit_stage_gate.py +0 -537
  191. package/hooks/blocking/test_session_edit_stage_gate_staging.py +0 -64
  192. package/hooks/blocking/test_session_edit_stage_gate_support.py +0 -204
  193. package/hooks/blocking/test_session_edit_stage_gate_tracker.py +0 -116
  194. package/hooks/blocking/test_session_handoff_blocker.py +0 -318
  195. package/hooks/blocking/test_shell_substitution_blocker.py +0 -298
  196. package/hooks/blocking/test_unscoped_search_blocker.py +0 -102
  197. package/hooks/blocking/test_write_existing_file_blocker.py +0 -122
  198. package/hooks/blocking/tests/test_pii_prevention_blocker.py +0 -259
  199. package/hooks/blocking/unscoped_search_blocker.py +0 -391
  200. package/hooks/blocking/write_existing_file_blocker.py +0 -124
  201. package/hooks/hooks_constants/bot_mention_comment_blocker_constants.py +0 -19
  202. package/hooks/hooks_constants/cursor_cli_python_misfire_blocker_constants.py +0 -79
  203. package/hooks/hooks_constants/fable_spawn_gate_constants.py +0 -61
  204. package/hooks/hooks_constants/luna_fast_mode_gate_constants.py +0 -45
  205. package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +0 -20
  206. package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +0 -57
  207. package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +0 -17
  208. package/hooks/hooks_constants/sensitive_file_protector_constants.py +0 -40
  209. package/hooks/hooks_constants/session_handoff_blocker_constants.py +0 -10
  210. package/hooks/hooks_constants/shell_substitution_blocker_constants.py +0 -80
@@ -0,0 +1,347 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ HOOKS_DIRECTORY = Path(__file__).resolve().parents[2] / "hooks" / "git-hooks"
11
+ NOTICE_SCRIPT = HOOKS_DIRECTORY / "verification_notice.py"
12
+ ALL_REMOTE_URLS = (
13
+ "https://github.com/JonEcho/python-automation.git",
14
+ "git@github.com:JonEcho/python-automation.git",
15
+ "ssh://git@github.com/JonEcho/python-automation.git",
16
+ )
17
+
18
+
19
+ def run_notice(
20
+ repository_path: Path,
21
+ event_name: str = "commit",
22
+ ) -> subprocess.CompletedProcess[str]:
23
+ return subprocess.run(
24
+ [
25
+ sys.executable,
26
+ str(NOTICE_SCRIPT),
27
+ "--event",
28
+ event_name,
29
+ "--repo",
30
+ str(repository_path),
31
+ ],
32
+ check=False,
33
+ capture_output=True,
34
+ text=True,
35
+ encoding="utf-8",
36
+ env={**os.environ, "PYTHONIOENCODING": "utf-8"},
37
+ )
38
+
39
+
40
+ def create_target_repository(
41
+ temporary_directory: Path,
42
+ remote_url: str,
43
+ ) -> tuple[Path, str]:
44
+ repository_path = temporary_directory / "Jon Echo project with spaces Ω"
45
+ repository_path.mkdir(parents=True)
46
+ run_git(repository_path, "init", "--quiet")
47
+ run_git(repository_path, "config", "user.email", "test@example.invalid")
48
+ run_git(repository_path, "config", "user.name", "Verification Test")
49
+ (repository_path / "README.md").write_text("check\n", encoding="utf-8")
50
+ run_git(repository_path, "add", "README.md")
51
+ run_git(repository_path, "commit", "--quiet", "-m", "initial")
52
+ run_git(repository_path, "remote", "add", "origin", remote_url)
53
+ current_head = run_git(repository_path, "rev-parse", "HEAD").stdout.strip()
54
+ return repository_path, current_head
55
+
56
+
57
+ def run_git(
58
+ repository_path: Path,
59
+ *arguments: str,
60
+ ) -> subprocess.CompletedProcess[str]:
61
+ return subprocess.run(
62
+ ["git", *arguments],
63
+ cwd=repository_path,
64
+ check=True,
65
+ capture_output=True,
66
+ text=True,
67
+ encoding="utf-8",
68
+ )
69
+
70
+
71
+ def write_report(
72
+ repository_path: Path,
73
+ report_fields: dict[str, object],
74
+ ) -> None:
75
+ manifest_path = repository_path / "config" / "local-verification.json"
76
+ manifest_path.parent.mkdir(exist_ok=True)
77
+ manifest_mapping = {
78
+ "version": 1,
79
+ "checks": [
80
+ {
81
+ "id": "smoke",
82
+ "argv": ["python", "-c", "pass"],
83
+ "cwd": ".",
84
+ "timeout_seconds": 1.0,
85
+ "minimum_tests": None,
86
+ }
87
+ ],
88
+ "exclusions": [],
89
+ }
90
+ manifest_path.write_text(json.dumps(manifest_mapping), encoding="utf-8")
91
+ report_path = repository_path / ".git" / "local-verification" / "report.json"
92
+ report_path.parent.mkdir(exist_ok=True)
93
+ all_report_fields = {
94
+ "selection": {"selected_manifest_path": "config/local-verification.json"},
95
+ "manifest_digest": hashlib.sha256(
96
+ json.dumps(manifest_mapping, sort_keys=True, separators=(",", ":")).encode()
97
+ ).hexdigest(),
98
+ **report_fields,
99
+ }
100
+ report_path.write_text(json.dumps(all_report_fields), encoding="utf-8")
101
+
102
+
103
+ def write_passing_report(repository_path: Path) -> str:
104
+ write_report(repository_path, {})
105
+ run_git(repository_path, "add", "config/local-verification.json")
106
+ run_git(repository_path, "commit", "--quiet", "-m", "manifest")
107
+ current_head = run_git(repository_path, "rev-parse", "HEAD").stdout.strip()
108
+ run_git(repository_path, "update-ref", "refs/remotes/origin/main", current_head)
109
+ write_report(
110
+ repository_path,
111
+ {
112
+ "head": current_head,
113
+ "base": current_head,
114
+ "worktree_clean": True,
115
+ "inputs_unchanged": True,
116
+ "publishable": True,
117
+ "status": "passed",
118
+ "aggregate": {
119
+ "status": "passed",
120
+ "total": 1,
121
+ "passed": 1,
122
+ "failed": 0,
123
+ "incomplete": 0,
124
+ "exit_code": 0,
125
+ },
126
+ },
127
+ )
128
+ return current_head
129
+
130
+
131
+ def write_worktree_passing_report(
132
+ repository_path: Path,
133
+ linked_git_directory: Path,
134
+ ) -> str:
135
+ current_head = run_git(repository_path, "rev-parse", "HEAD").stdout.strip()
136
+ source_manifest_path = repository_path / "config" / "local-verification.json"
137
+ source_report_path = repository_path / ".git" / "local-verification" / "report.json"
138
+ metadata_directory = linked_git_directory / "local-verification"
139
+ metadata_directory.mkdir(parents=True, exist_ok=True)
140
+ metadata_manifest_path = metadata_directory / "local-verification.json"
141
+ metadata_manifest_path.write_bytes(source_manifest_path.read_bytes())
142
+ all_report_fields = json.loads(source_report_path.read_text(encoding="utf-8"))
143
+ all_report_fields["selection"]["selected_manifest_path"] = str(
144
+ metadata_manifest_path
145
+ )
146
+ metadata_report_path = metadata_directory / "report.json"
147
+ metadata_report_path.write_text(json.dumps(all_report_fields), encoding="utf-8")
148
+ return current_head
149
+
150
+
151
+ def test_notice_matches_remote_formats_and_quotes_paths(tmp_path: Path) -> None:
152
+ for each_index, each_remote_url in enumerate(ALL_REMOTE_URLS):
153
+ repository_path, current_head = create_target_repository(
154
+ tmp_path / str(each_index), each_remote_url
155
+ )
156
+ completed_notice = run_notice(repository_path, "push")
157
+ assert completed_notice.returncode == 0
158
+ assert "LOCAL VERIFICATION ADVISORY" in completed_notice.stdout
159
+ assert f"Current SHA: {current_head}" in completed_notice.stdout
160
+ assert "State: pending" in completed_notice.stdout
161
+ assert "python '" in completed_notice.stdout
162
+ wrapper_suffix = str(Path(".github") / "ci" / "local_verify.py")
163
+ assert f"{wrapper_suffix}' --base origin/main" in completed_notice.stdout
164
+ assert "--executor '" in completed_notice.stdout
165
+ assert "--output '" in completed_notice.stdout
166
+ assert repository_path.name in completed_notice.stdout
167
+ assert "--base origin/main" in completed_notice.stdout
168
+ assert "does not block commit or push" in completed_notice.stdout
169
+
170
+
171
+ def test_notice_reports_pending_when_manifest_exists_without_report(
172
+ tmp_path: Path,
173
+ ) -> None:
174
+ repository_path, _ = create_target_repository(
175
+ tmp_path,
176
+ "https://github.com/JonEcho/python-automation.git",
177
+ )
178
+ write_report(repository_path, {})
179
+ (repository_path / ".git" / "local-verification" / "report.json").unlink()
180
+
181
+ completed_notice = run_notice(repository_path)
182
+
183
+ assert completed_notice.returncode == 0
184
+ assert "State: pending" in completed_notice.stdout
185
+ assert "manifest is missing or unreadable" not in completed_notice.stdout
186
+ assert "No complete current pass is recorded" in completed_notice.stdout
187
+
188
+
189
+ def test_notice_ignores_unrelated_remote(tmp_path: Path) -> None:
190
+ repository_path, _ = create_target_repository(
191
+ tmp_path,
192
+ "https://github.com/other-owner/other-repository.git",
193
+ )
194
+ completed_notice = run_notice(repository_path)
195
+ assert completed_notice.returncode == 0
196
+ assert completed_notice.stdout == ""
197
+
198
+
199
+ def test_notice_reports_failed_checks_without_pass_label(tmp_path: Path) -> None:
200
+ repository_path, current_head = create_target_repository(
201
+ tmp_path,
202
+ "https://github.com/JonEcho/python-automation.git",
203
+ )
204
+ write_report(repository_path, {"head": current_head, "status": "failed"})
205
+ completed_notice = run_notice(repository_path)
206
+ assert completed_notice.returncode == 0
207
+ assert f"Current SHA: {current_head}" in completed_notice.stdout
208
+ assert "State: failed" in completed_notice.stdout
209
+ assert "recorded local checks failed" in completed_notice.stdout
210
+ assert "local-checks:passed" not in completed_notice.stdout
211
+
212
+
213
+ def test_notice_reports_pass_only_for_complete_current_revision(tmp_path: Path) -> None:
214
+ repository_path, _ = create_target_repository(
215
+ tmp_path,
216
+ "https://github.com/JonEcho/python-automation.git",
217
+ )
218
+ current_head = write_passing_report(repository_path)
219
+ completed_notice = run_notice(repository_path)
220
+ assert completed_notice.returncode == 0
221
+ assert f"Verified SHA: {current_head}" in completed_notice.stdout
222
+ assert "State: passed" in completed_notice.stdout
223
+ assert (
224
+ "local-checks:passed is valid only for this exact current SHA"
225
+ in completed_notice.stdout
226
+ )
227
+
228
+
229
+ def test_notice_handles_malformed_report_and_missing_repository(tmp_path: Path) -> None:
230
+ repository_path, _ = create_target_repository(
231
+ tmp_path,
232
+ "https://github.com/JonEcho/python-automation.git",
233
+ )
234
+ manifest_path = repository_path / "config" / "local-verification.json"
235
+ manifest_path.parent.mkdir(exist_ok=True)
236
+ manifest_mapping = {
237
+ "version": 1,
238
+ "checks": [
239
+ {
240
+ "id": "smoke",
241
+ "argv": ["python", "-c", "pass"],
242
+ "cwd": ".",
243
+ "timeout_seconds": 1.0,
244
+ "minimum_tests": None,
245
+ }
246
+ ],
247
+ "exclusions": [],
248
+ }
249
+ manifest_path.write_text(json.dumps(manifest_mapping), encoding="utf-8")
250
+ report_path = repository_path / ".git" / "local-verification" / "report.json"
251
+ report_path.parent.mkdir(exist_ok=True)
252
+ report_path.write_bytes(b"not-json\xff")
253
+ malformed_notice = run_notice(repository_path)
254
+ missing_notice = run_notice(tmp_path / "missing repository")
255
+ assert malformed_notice.returncode == 0
256
+ assert "State: unverified" in malformed_notice.stdout
257
+ assert missing_notice.returncode == 0
258
+ assert missing_notice.stdout == ""
259
+
260
+
261
+ def test_notice_uses_worktree_specific_report_path(tmp_path: Path) -> None:
262
+ repository_path, _ = create_target_repository(
263
+ tmp_path / "main",
264
+ "https://github.com/JonEcho/python-automation.git",
265
+ )
266
+ linked_worktree_path = tmp_path / "linked worktree Ω"
267
+ run_git(repository_path, "worktree", "add", "--detach", str(linked_worktree_path))
268
+ linked_git_directory = Path(
269
+ run_git(linked_worktree_path, "rev-parse", "--absolute-git-dir").stdout.strip()
270
+ )
271
+
272
+ completed_notice = run_notice(linked_worktree_path)
273
+
274
+ expected_report_path = linked_git_directory / "local-verification" / "report.json"
275
+ shared_report_path = repository_path / ".git" / "local-verification" / "report.json"
276
+ assert completed_notice.returncode == 0
277
+ assert str(expected_report_path) in completed_notice.stdout
278
+ assert str(shared_report_path) not in completed_notice.stdout
279
+
280
+
281
+ def test_notice_accepts_complete_report_in_worktree_metadata(tmp_path: Path) -> None:
282
+ repository_path, _ = create_target_repository(
283
+ tmp_path / "main",
284
+ "https://github.com/JonEcho/python-automation.git",
285
+ )
286
+ current_head = write_passing_report(repository_path)
287
+ linked_worktree_path = tmp_path / "linked worktree Ω"
288
+ run_git(repository_path, "worktree", "add", "--detach", str(linked_worktree_path))
289
+ linked_git_directory = Path(
290
+ run_git(linked_worktree_path, "rev-parse", "--absolute-git-dir").stdout.strip()
291
+ )
292
+ write_worktree_passing_report(repository_path, linked_git_directory)
293
+
294
+ completed_notice = run_notice(linked_worktree_path)
295
+
296
+ future_path = linked_worktree_path / "future.txt"
297
+ future_path.write_text("future\n", encoding="utf-8")
298
+ run_git(linked_worktree_path, "add", "future.txt")
299
+ run_git(linked_worktree_path, "commit", "--quiet", "-m", "future")
300
+ future_head = run_git(linked_worktree_path, "rev-parse", "HEAD").stdout.strip()
301
+ stale_notice = run_notice(linked_worktree_path)
302
+ run_git(linked_worktree_path, "reset", "--quiet", "--hard", current_head)
303
+ run_git(linked_worktree_path, "update-ref", "refs/remotes/origin/main", future_head)
304
+ moved_base_notice = run_notice(linked_worktree_path)
305
+
306
+ assert "State: passed" in completed_notice.stdout
307
+ assert f"Verified SHA: {current_head}" in completed_notice.stdout
308
+ assert "State: stale" in stale_notice.stdout
309
+ assert "State: unverified" in moved_base_notice.stdout
310
+
311
+
312
+ def test_notice_invalidates_pass_after_worktree_edit(tmp_path: Path) -> None:
313
+ repository_path, _ = create_target_repository(
314
+ tmp_path,
315
+ "https://github.com/JonEcho/python-automation.git",
316
+ )
317
+ current_head = write_passing_report(repository_path)
318
+ passed_notice = run_notice(repository_path)
319
+ (repository_path / "untracked.txt").write_text("changed\n", encoding="utf-8")
320
+
321
+ changed_notice = run_notice(repository_path)
322
+
323
+ assert f"Verified SHA: {current_head}" in passed_notice.stdout
324
+ assert "State: passed" in passed_notice.stdout
325
+ assert "Verified SHA: none" in changed_notice.stdout
326
+ assert "State: unverified" in changed_notice.stdout
327
+
328
+
329
+ def test_notice_invalidates_pass_after_origin_base_moves(tmp_path: Path) -> None:
330
+ repository_path, _ = create_target_repository(
331
+ tmp_path,
332
+ "https://github.com/JonEcho/python-automation.git",
333
+ )
334
+ current_head = write_passing_report(repository_path)
335
+ passed_notice = run_notice(repository_path)
336
+ (repository_path / "future.txt").write_text("future\n", encoding="utf-8")
337
+ run_git(repository_path, "add", "future.txt")
338
+ run_git(repository_path, "commit", "--quiet", "-m", "future")
339
+ future_head = run_git(repository_path, "rev-parse", "HEAD").stdout.strip()
340
+ run_git(repository_path, "reset", "--quiet", "--hard", current_head)
341
+ run_git(repository_path, "update-ref", "refs/remotes/origin/main", future_head)
342
+
343
+ moved_base_notice = run_notice(repository_path)
344
+
345
+ assert "State: passed" in passed_notice.stdout
346
+ assert "Verified SHA: none" in moved_base_notice.stdout
347
+ assert "State: unverified" in moved_base_notice.stdout
@@ -1,83 +0,0 @@
1
- #!/usr/bin/env python3
2
- """PreToolUse hook that enforces AskUserQuestion field shape."""
3
-
4
- from __future__ import annotations
5
-
6
- import json
7
- import sys
8
- from collections.abc import Mapping, Sequence
9
- from pathlib import Path
10
-
11
- _hooks_dir = str(Path(__file__).resolve().parent.parent)
12
- if _hooks_dir not in sys.path:
13
- sys.path.insert(0, _hooks_dir)
14
-
15
- from hooks_constants.ask_user_question_shape import ( # noqa: E402
16
- analyze_ask_user_question_shape,
17
- )
18
- from hooks_constants.ask_user_question_shape_constants import ( # noqa: E402
19
- ASK_USER_QUESTION_TOOL_NAME,
20
- LEAN_QUESTION_BLOCK_GUIDANCE,
21
- LEAN_QUESTION_BLOCK_PREFIX,
22
- LEAN_QUESTION_VIOLATION_SEPARATOR,
23
- USER_FACING_LEAN_QUESTION_NOTICE,
24
- )
25
- from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
26
-
27
-
28
- def build_lean_block_reason(all_violations: Sequence[str]) -> str:
29
- """Build a deny reason that names each field-shape violation."""
30
- violation_list = LEAN_QUESTION_VIOLATION_SEPARATOR.join(all_violations)
31
- return f"{LEAN_QUESTION_BLOCK_PREFIX}{violation_list}. {LEAN_QUESTION_BLOCK_GUIDANCE}"
32
-
33
-
34
- def evaluate(payload_by_key: Mapping[str, object]) -> str | None:
35
- """Return a deny reason when an AskUserQuestion payload breaks field shape."""
36
- tool_name = payload_by_key.get("tool_name", "")
37
- tool_input = payload_by_key.get("tool_input", {})
38
- if tool_name != ASK_USER_QUESTION_TOOL_NAME:
39
- return None
40
- if not isinstance(tool_input, Mapping):
41
- return None
42
- shape_result = analyze_ask_user_question_shape(tool_input)
43
- if shape_result.is_lean:
44
- return None
45
- return build_lean_block_reason(shape_result.all_violations)
46
-
47
-
48
- def build_deny_payload(deny_reason: str) -> dict[str, object]:
49
- """Build the standard PreToolUse deny payload for a shape violation."""
50
- log_hook_block(
51
- calling_hook_name="ask_user_question_shape_blocker.py",
52
- hook_event="PreToolUse",
53
- block_reason=deny_reason,
54
- )
55
- return {
56
- "hookSpecificOutput": {
57
- "hookEventName": "PreToolUse",
58
- "permissionDecision": "deny",
59
- "permissionDecisionReason": deny_reason,
60
- },
61
- "systemMessage": USER_FACING_LEAN_QUESTION_NOTICE,
62
- "suppressOutput": True,
63
- }
64
-
65
-
66
- def main() -> None:
67
- """Read one PreToolUse payload and deny malformed question shape."""
68
- try:
69
- payload_by_key = json.load(sys.stdin)
70
- except json.JSONDecodeError:
71
- sys.exit(0)
72
-
73
- if not isinstance(payload_by_key, dict):
74
- sys.exit(0)
75
- deny_reason = evaluate(payload_by_key)
76
- if deny_reason is None:
77
- sys.exit(0)
78
- sys.stdout.write(json.dumps(build_deny_payload(deny_reason)))
79
- sys.stdout.flush()
80
-
81
-
82
- if __name__ == "__main__":
83
- main()
@@ -1,76 +0,0 @@
1
- #!/usr/bin/env python3
2
- """PreToolUse hook: block @cursor or @copilot mentions in add_issue_comment body.
3
-
4
- Bugbot trigger requires exactly ``bugbot run`` with no other text. Copilot review
5
- requires the GitHub REST API endpoint, not an issue comment mention. This hook
6
- catches both mistakes and returns the correct procedure for each.
7
- """
8
-
9
- import json
10
- import sys
11
- from pathlib import Path
12
-
13
- _hooks_dir = str(Path(__file__).resolve().parent.parent)
14
- if _hooks_dir not in sys.path:
15
- sys.path.insert(0, _hooks_dir)
16
-
17
- from hooks_constants.bot_mention_comment_blocker_constants import ( # noqa: E402
18
- COPILOT_MENTION_TOKEN,
19
- CORRECTIVE_MESSAGE_COPILOT,
20
- CORRECTIVE_MESSAGE_CURSOR,
21
- CURSOR_MENTION_TOKEN,
22
- TOOL_NAME,
23
- )
24
- from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
25
-
26
-
27
- def _body_contains_token(body: str, token: str) -> bool:
28
- return token.lower() in body.lower()
29
-
30
-
31
- def _detect_bot_mention(body: str) -> str | None:
32
- """Return corrective message if body contains a blocked mention, else None."""
33
- if _body_contains_token(body, COPILOT_MENTION_TOKEN):
34
- return CORRECTIVE_MESSAGE_COPILOT
35
- if _body_contains_token(body, CURSOR_MENTION_TOKEN):
36
- return CORRECTIVE_MESSAGE_CURSOR
37
- return None
38
-
39
-
40
- def main() -> None:
41
- try:
42
- hook_input = json.load(sys.stdin)
43
- except json.JSONDecodeError:
44
- sys.exit(0)
45
-
46
- if hook_input.get("tool_name", "") != TOOL_NAME:
47
- sys.exit(0)
48
-
49
- body = hook_input.get("tool_input", {}).get("body", "")
50
- if not body:
51
- sys.exit(0)
52
-
53
- corrective_message = _detect_bot_mention(body)
54
- if corrective_message is None:
55
- sys.exit(0)
56
-
57
- deny_payload = {
58
- "hookSpecificOutput": {
59
- "hookEventName": "PreToolUse",
60
- "permissionDecision": "deny",
61
- "permissionDecisionReason": corrective_message,
62
- }
63
- }
64
- log_hook_block(
65
- calling_hook_name="bot_mention_comment_blocker.py",
66
- hook_event="PreToolUse",
67
- block_reason=corrective_message,
68
- tool_name=TOOL_NAME,
69
- )
70
- print(json.dumps(deny_payload))
71
- sys.stdout.flush()
72
- sys.exit(0)
73
-
74
-
75
- if __name__ == "__main__":
76
- main()
@@ -1,129 +0,0 @@
1
- #!/usr/bin/env python3
2
- """PreToolUse hook: blocks a per-directory CLAUDE.md that names a file absent from its subtree.
3
-
4
- A per-directory ``CLAUDE.md`` documents the files reachable from its own
5
- directory in a markdown table whose first column names each file in backticks,
6
- and shows run commands inside fenced code blocks that invoke those files. When a
7
- first-column cell, or an interpreter invocation inside a fenced run command
8
- (``python script.py``), names a bare filename that exists nowhere under the scan
9
- root (the CLAUDE.md directory's parent, which covers the directory, its
10
- subdirectories, and its siblings), the doc points a reader at a file that is not
11
- there. This hook fires on Write, Edit, and MultiEdit targeting a file named
12
- ``CLAUDE.md`` and blocks the write when any such cell or run command names a file
13
- absent from the scan root. A table block or a run-command fence whose own region
14
- declares an explicit relative-path source (a ``../`` token, in the rows/fence or
15
- the prose that introduces it) documents files outside the subtree, so that block's
16
- rows or that fence's commands are left alone — the exemption is scoped to the
17
- region, not the whole file.
18
- """
19
-
20
- import sys
21
- from pathlib import Path
22
-
23
- try:
24
- _hooks_root_directory = str(Path(__file__).resolve().parent.parent)
25
- _blocking_directory = str(Path(__file__).resolve().parent)
26
- for each_bootstrap_directory in (_hooks_root_directory, _blocking_directory):
27
- if each_bootstrap_directory not in sys.path:
28
- sys.path.insert(0, each_bootstrap_directory)
29
- from claude_md_orphan_file_blocker_parts import (
30
- decision,
31
- references,
32
- scan_plan,
33
- subtree_scan,
34
- )
35
- from claude_md_orphan_file_blocker_parts.subtree_scan import (
36
- MAX_SUBTREE_FILES_SCANNED, # noqa: F401
37
- )
38
- from inventory_intent_records import records
39
-
40
- from hooks_constants.pre_tool_use_stdin import (
41
- read_hook_input_dictionary_from_stdin,
42
- )
43
- except ImportError as import_error:
44
- raise ImportError(
45
- "claude_md_orphan_file_blocker: cannot import its parts submodules; "
46
- "ensure the hooks directory is importable."
47
- ) from import_error
48
-
49
-
50
- is_claude_md_file = references.is_claude_md_file
51
- find_referenced_filenames = references.find_referenced_filenames
52
- find_run_command_filenames = references.find_run_command_filenames
53
- find_missing_filenames = subtree_scan.find_missing_filenames
54
- build_orphan_scan_plan = scan_plan.build_orphan_scan_plan
55
- collect_missing_filenames = scan_plan.collect_missing_filenames
56
- deny_orphan_files = decision.deny_orphan_files
57
-
58
-
59
- def _partition_by_file_intent(
60
- session_id: str, directory: str, all_missing_filenames: list[str]
61
- ) -> tuple[list[str], list[str]]:
62
- """Split missing filenames into those with a pending file intent and those without."""
63
- matched_filenames = [
64
- each_filename
65
- for each_filename in all_missing_filenames
66
- if records.has_fresh_file_intent(session_id, directory, each_filename)
67
- ]
68
- unmatched_filenames = [
69
- each_filename
70
- for each_filename in all_missing_filenames
71
- if each_filename not in matched_filenames
72
- ]
73
- return matched_filenames, unmatched_filenames
74
-
75
-
76
- def _emit_orphan_decision(
77
- input_data: dict,
78
- tool_name: str,
79
- file_path: str,
80
- directory: str,
81
- all_missing_filenames: list[str],
82
- ) -> None:
83
- """Consume covered rows and deny any genuine orphan the change introduces."""
84
- session_id = str(input_data.get("session_id") or "")
85
- matched_filenames, unmatched_filenames = _partition_by_file_intent(
86
- session_id, directory, all_missing_filenames
87
- )
88
- if unmatched_filenames:
89
- for each_filename in unmatched_filenames:
90
- records.record_row_intent(session_id, directory, each_filename)
91
- deny_orphan_files(tool_name, file_path, directory, unmatched_filenames)
92
- return
93
- for each_filename in matched_filenames:
94
- records.consume_file_intent(session_id, directory, each_filename)
95
-
96
-
97
- def main() -> None:
98
- """Read the PreToolUse payload from stdin and block an orphan-file CLAUDE.md."""
99
- input_data = read_hook_input_dictionary_from_stdin()
100
- if input_data is None:
101
- sys.exit(0)
102
- tool_name = input_data.get("tool_name", "")
103
- if not isinstance(tool_name, str):
104
- sys.exit(0)
105
- tool_input = input_data.get("tool_input", {})
106
- if not isinstance(tool_input, dict):
107
- sys.exit(0)
108
- if tool_name not in ("Write", "Edit", "MultiEdit"):
109
- sys.exit(0)
110
- file_path = tool_input.get("file_path", "")
111
- if not isinstance(file_path, str) or not is_claude_md_file(file_path):
112
- sys.exit(0)
113
- claude_md_directory = Path(file_path).resolve().parent
114
- if not claude_md_directory.is_dir():
115
- sys.exit(0)
116
- scan_plan_result = build_orphan_scan_plan(tool_name, tool_input, file_path, claude_md_directory)
117
- if not scan_plan_result.candidate_contents:
118
- sys.exit(0)
119
- missing_filenames = collect_missing_filenames(scan_plan_result, claude_md_directory)
120
- if not missing_filenames:
121
- sys.exit(0)
122
- _emit_orphan_decision(
123
- input_data, tool_name, file_path, str(claude_md_directory), missing_filenames
124
- )
125
- sys.exit(0)
126
-
127
-
128
- if __name__ == "__main__":
129
- main()