claude-dev-env 1.59.0 → 1.60.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 (62) hide show
  1. package/audit-rubrics/category_rubrics/category-b-selector-engine-compat.md +1 -1
  2. package/audit-rubrics/category_rubrics/category-e-dead-code.md +1 -0
  3. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  4. package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
  5. package/hooks/blocking/code_rules_dead_module_constant.py +321 -0
  6. package/hooks/blocking/code_rules_duplicate_body.py +152 -0
  7. package/hooks/blocking/code_rules_enforcer.py +30 -15
  8. package/hooks/blocking/code_rules_typeddict_stub.py +172 -0
  9. package/hooks/blocking/config/__init__.py +5 -0
  10. package/hooks/blocking/config/verified_commit_constants.py +106 -0
  11. package/hooks/blocking/test_code_rules_enforcer_cross_skill_duplicate.py +146 -0
  12. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +188 -0
  13. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias.py +415 -0
  14. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +156 -0
  15. package/hooks/blocking/test_verdict_directory_write_blocker.py +720 -0
  16. package/hooks/blocking/test_verification_verdict_store.py +278 -0
  17. package/hooks/blocking/test_verified_commit_gate.py +368 -0
  18. package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -0
  19. package/hooks/blocking/test_verifier_verdict_minter.py +214 -0
  20. package/hooks/blocking/verdict_directory_write_blocker.py +667 -0
  21. package/hooks/blocking/verification_verdict_store.py +446 -0
  22. package/hooks/blocking/verified_commit_gate.py +523 -0
  23. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +152 -0
  24. package/hooks/blocking/verifier_verdict_minter.py +299 -0
  25. package/hooks/diagnostic/test_hook_log_extractor.py +3 -3
  26. package/hooks/hooks.json +43 -1
  27. package/hooks/hooks_constants/blocking_check_limits.py +1 -0
  28. package/hooks/hooks_constants/dead_module_constant_constants.py +20 -0
  29. package/hooks/hooks_constants/duplicate_function_body_constants.py +22 -5
  30. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
  31. package/package.json +1 -1
  32. package/rules/file-global-constants.md +7 -1
  33. package/rules/no-cross-skill-duplicate-helpers.md +29 -0
  34. package/skills/_shared/pr-loop/scripts/preflight_worktree.py +392 -0
  35. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/preflight_constants.py +70 -0
  36. package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +263 -0
  37. package/skills/autoconverge/SKILL.md +54 -17
  38. package/skills/autoconverge/reference/closing-report.md +59 -17
  39. package/skills/autoconverge/workflow/aggregate_runs.py +371 -0
  40. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +193 -76
  41. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +76 -0
  42. package/skills/autoconverge/workflow/converge.contract.test.mjs +206 -206
  43. package/skills/autoconverge/workflow/converge.mjs +128 -6
  44. package/skills/autoconverge/workflow/convergence_summary.py +110 -0
  45. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ab1c2d3e4f5a6b7c8.jsonl +2 -0
  46. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +7 -0
  47. package/skills/autoconverge/workflow/render_report.py +488 -397
  48. package/skills/autoconverge/workflow/test_aggregate_runs.py +134 -0
  49. package/skills/autoconverge/workflow/test_convergence_summary.py +132 -0
  50. package/skills/autoconverge/workflow/test_render_report.py +488 -259
  51. package/skills/pr-converge/reference/per-tick.md +28 -8
  52. package/skills/rebase/SKILL.md +2 -4
  53. package/system-prompts/software-engineer.xml +2 -6
  54. package/hooks/blocking/content_search_to_zoekt_redirector.py +0 -59
  55. package/hooks/blocking/content_search_zoekt_bash_block_reason.py +0 -25
  56. package/hooks/blocking/content_search_zoekt_block_payload.py +0 -21
  57. package/hooks/blocking/content_search_zoekt_indexed_paths.py +0 -24
  58. package/hooks/blocking/content_search_zoekt_indexed_roots_config.py +0 -131
  59. package/hooks/blocking/content_search_zoekt_redirect_guidance.py +0 -52
  60. package/hooks/blocking/test_content_search_to_zoekt_redirector_integration.py +0 -61
  61. package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +0 -92
  62. package/hooks/blocking/test_content_search_zoekt_indexed_roots_config.py +0 -102
@@ -0,0 +1,523 @@
1
+ """PreToolUse gate: git commit/push lands only behind a minted verifier verdict.
2
+
3
+ Fires on Bash and PowerShell tool calls. When the command carries a
4
+ ``git commit`` or ``git push``, the gate resolves the repository the command
5
+ targets, computes the live change-surface manifest against the merge base,
6
+ and allows the command only when one of these holds:
7
+
8
+ - the repository has no resolvable upstream base — no ``origin/HEAD``, no
9
+ configured tracking ref, and neither ``origin/main`` nor ``origin/master``
10
+ (scratch repos with no remote branch are out of scope),
11
+ - the surface is mechanically exempt (docs/images by extension, pytest
12
+ test files by name convention, Python files whose docstring-stripped
13
+ AST is unchanged), or
14
+ - a verdict minted by ``verifier_verdict_minter.py`` reports ``all_pass``
15
+ and binds to the exact live manifest hash.
16
+
17
+ The surface binds every changed and untracked file's content, so slicing
18
+ work into small commits or staging files cannot move the hash, while any
19
+ content edit or new file after verification invalidates the verdict.
20
+ Verdict files live under ``~/.claude/verification/`` and are minted only by
21
+ the SubagentStop hook when a ``code-verifier`` agent finishes.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import os
28
+ import re
29
+ import sys
30
+ from pathlib import Path
31
+
32
+ blocking_directory = str(Path(__file__).resolve().parent)
33
+ if blocking_directory not in sys.path:
34
+ sys.path.insert(0, blocking_directory)
35
+
36
+ from config.verified_commit_constants import (
37
+ ALL_GIT_BINARY_NAMES,
38
+ CORRECTIVE_MESSAGE,
39
+ DIRECTORY_CHANGE_OPTION_TERMINATOR,
40
+ DIRECTORY_CHANGE_PATH_OPTIONS,
41
+ DIRECTORY_CHANGE_PATTERN_PREFIX,
42
+ DIRECTORY_CHANGE_PATTERN_SUFFIX,
43
+ DIRECTORY_CHANGE_VERBS,
44
+ GATED_GIT_SUBCOMMANDS,
45
+ ALL_GATED_TOOL_NAMES,
46
+ HASH_PREVIEW_LENGTH,
47
+ OPTION_WITH_VALUE_STEP,
48
+ REPO_DIRECTORY_OPTION,
49
+ VALUE_TAKING_GIT_OPTIONS,
50
+ WORK_TREE_OPTION,
51
+ )
52
+ from verification_verdict_store import (
53
+ branch_surface_manifest,
54
+ is_verification_exempt_diff,
55
+ load_valid_verdict,
56
+ manifest_sha256,
57
+ resolve_merge_base,
58
+ resolve_repo_root,
59
+ )
60
+
61
+
62
+ def _collapse_line_continuations(command_text: str) -> str:
63
+ """Remove backslash-newline line continuations the shell would erase.
64
+
65
+ Bash joins a line continuation — a backslash immediately followed by a
66
+ newline — by deleting both characters, so ``git \\<newline>commit``,
67
+ ``git commit\\<newline> -m x``, and ``g\\<newline>it commit`` all run as a
68
+ plain ``git commit``. Stripping the pair before tokenizing makes the token
69
+ stream match what the shell executes, so a continuation abutting the
70
+ subcommand or splitting the ``git`` word cannot evade the gate.
71
+
72
+ Args:
73
+ command_text: The raw command string from the tool payload.
74
+
75
+ Returns:
76
+ The command with every backslash-newline pair removed.
77
+ """
78
+ return re.sub(r"\\\r?\n", "", command_text)
79
+
80
+
81
+ def _quoted_spans(command_text: str) -> list[tuple[int, int]]:
82
+ """Find the character spans of every quoted region in a command.
83
+
84
+ Scans single- and double-quoted runs left to right so a verb sitting
85
+ inside a quoted ``-m`` commit message is recognised as message text
86
+ rather than a real shell directory change.
87
+
88
+ Args:
89
+ command_text: The raw command string from the tool payload.
90
+
91
+ Returns:
92
+ The ``(start, end)`` span of each quoted region, in order.
93
+ """
94
+ quoted_region_pattern = re.compile(r"\"[^\"]*\"|'[^']*'")
95
+ return [
96
+ (each_match.start(), each_match.end())
97
+ for each_match in quoted_region_pattern.finditer(command_text)
98
+ ]
99
+
100
+
101
+ def _is_inside_quoted_region(position: int, all_quoted_spans: list[tuple[int, int]]) -> bool:
102
+ """Decide whether a position falls inside any quoted region.
103
+
104
+ Args:
105
+ position: A character offset into the command string.
106
+ all_quoted_spans: The quoted-region spans from ``_quoted_spans``.
107
+
108
+ Returns:
109
+ True when the offset sits within a quoted region's bounds.
110
+ """
111
+ for each_span_start, each_span_end in all_quoted_spans:
112
+ if each_span_start <= position < each_span_end:
113
+ return True
114
+ return False
115
+
116
+
117
+ def _containing_quoted_span(
118
+ position: int, all_quoted_spans: list[tuple[int, int]]
119
+ ) -> tuple[int, int] | None:
120
+ """Return the quoted region a position falls inside, or None.
121
+
122
+ Args:
123
+ position: A character offset into the command string.
124
+ all_quoted_spans: The quoted-region spans from ``_quoted_spans``.
125
+
126
+ Returns:
127
+ The ``(start, end)`` span containing the offset, or None when the
128
+ offset sits outside every quoted region.
129
+ """
130
+ for each_span_start, each_span_end in all_quoted_spans:
131
+ if each_span_start <= position < each_span_end:
132
+ return (each_span_start, each_span_end)
133
+ return None
134
+
135
+
136
+ def _git_word_match_gates(
137
+ git_word_match: re.Match[str],
138
+ command_text: str,
139
+ all_quoted_spans: list[tuple[int, int]],
140
+ ) -> bool:
141
+ """Decide whether a ``git`` word match counts as a real invocation.
142
+
143
+ A ``git`` word outside every quoted region always gates. Inside a quoted
144
+ region the word gates only when the region's content, with edge quotes
145
+ stripped, is a path whose final ``[\\/]``-split segment is ``git`` or
146
+ ``git.exe`` — a wrapper-quoted binary (``"git" commit``), a quoted
147
+ call-operator path (``& 'C:/x/git.exe' commit``), or a quoted install
148
+ path whose directory components carry spaces
149
+ (``& "C:\\Program Files\\Git\\cmd\\git.exe" commit``). A ``git`` word that
150
+ is one word among prose inside a quoted string — an
151
+ ``echo "Next: git commit"`` mention or a ``gh pr comment -b "please git
152
+ commit"`` body — does not gate, because the prose's final path segment is
153
+ the surrounding sentence rather than a bare ``git``/``git.exe`` binary
154
+ name, so the shell never runs that quoted text as a command.
155
+
156
+ Args:
157
+ git_word_match: A ``git`` word match in the command.
158
+ command_text: The raw command string from the tool payload.
159
+ all_quoted_spans: The quoted-region spans from ``_quoted_spans``.
160
+
161
+ Returns:
162
+ True when the matched ``git`` word counts as a real git invocation.
163
+ """
164
+ containing_span = _containing_quoted_span(git_word_match.start(), all_quoted_spans)
165
+ if containing_span is None:
166
+ return True
167
+ span_start, span_end = containing_span
168
+ quoted_content = _strip_token_quotes(command_text[span_start:span_end])
169
+ final_segment = re.split(r"[\\/]", quoted_content)[-1].lower()
170
+ return final_segment in ALL_GIT_BINARY_NAMES
171
+
172
+
173
+ def _strip_token_quotes(token_text: str) -> str:
174
+ """Remove quote characters from a token's edges.
175
+
176
+ Tokens cut from inside a quoted shell-wrapper argument can carry an
177
+ unpaired edge quote (``push"``), so both edges are stripped rather
178
+ than only matched pairs.
179
+
180
+ Args:
181
+ token_text: One quote-aware token from a command string.
182
+
183
+ Returns:
184
+ The token without leading or trailing quote characters.
185
+ """
186
+ return token_text.strip("\"'")
187
+
188
+
189
+ def _gated_invocation_directory(all_following_tokens: list[str]) -> tuple[bool, str | None]:
190
+ """Walk the tokens after a ``git`` word to its first subcommand.
191
+
192
+ Skips git's global options (recording the targeted directory when one
193
+ appears) so a gated verb counts only in subcommand position — never as
194
+ an argument like ``git stash push`` or ``git log --grep commit``. The
195
+ ``-C`` directory wins when both ``-C`` and ``--work-tree`` are present;
196
+ otherwise a ``--work-tree`` value supplies the targeted directory so a
197
+ commit aimed at another repo's work tree gates against that work tree
198
+ rather than the session directory.
199
+
200
+ Args:
201
+ all_following_tokens: Quote-stripped tokens after the ``git`` word.
202
+
203
+ Returns:
204
+ Whether the first subcommand is gated, and the directory the
205
+ invocation targets via ``-C`` (or ``--work-tree``) when one appears.
206
+ """
207
+ repo_directory: str | None = None
208
+ work_tree_directory: str | None = None
209
+ token_index = 0
210
+ while token_index < len(all_following_tokens):
211
+ each_token = all_following_tokens[token_index]
212
+ option_name, attached_value = _split_option_value(each_token)
213
+ if option_name in VALUE_TAKING_GIT_OPTIONS:
214
+ option_value = (
215
+ attached_value
216
+ if attached_value is not None
217
+ else _value_after_option(all_following_tokens, token_index)
218
+ )
219
+ if option_name == REPO_DIRECTORY_OPTION and option_value is not None:
220
+ repo_directory = _expand_home_prefix(option_value)
221
+ if option_name == WORK_TREE_OPTION and option_value is not None:
222
+ work_tree_directory = _expand_home_prefix(option_value)
223
+ token_index += 1 if attached_value is not None else OPTION_WITH_VALUE_STEP
224
+ continue
225
+ if each_token.startswith("-"):
226
+ token_index += 1
227
+ continue
228
+ return (
229
+ each_token.lower() in GATED_GIT_SUBCOMMANDS,
230
+ repo_directory or work_tree_directory,
231
+ )
232
+ return (False, repo_directory or work_tree_directory)
233
+
234
+
235
+ def _split_option_value(option_token: str) -> tuple[str, str | None]:
236
+ """Split a ``--name=value`` option token into its name and value.
237
+
238
+ Args:
239
+ option_token: One quote-stripped token after the ``git`` word.
240
+
241
+ Returns:
242
+ The option name and its attached value, or the whole token and None
243
+ when the token carries no ``=`` value.
244
+ """
245
+ if option_token.startswith("--") and "=" in option_token:
246
+ option_name, _, attached_value = option_token.partition("=")
247
+ return (option_name, attached_value)
248
+ return (option_token, None)
249
+
250
+
251
+ def _value_after_option(all_following_tokens: list[str], option_index: int) -> str | None:
252
+ """Read the separate value token that follows a value-taking option.
253
+
254
+ Args:
255
+ all_following_tokens: Quote-stripped tokens after the ``git`` word.
256
+ option_index: Index of the value-taking option token.
257
+
258
+ Returns:
259
+ The next token when one exists, or None at the end of the tokens.
260
+ """
261
+ if option_index + 1 < len(all_following_tokens):
262
+ return all_following_tokens[option_index + 1]
263
+ return None
264
+
265
+
266
+ def _expand_home_prefix(directory_token: str) -> str:
267
+ """Expand a leading ``~`` to the home directory the shell would use.
268
+
269
+ Git does not expand ``~`` for ``-C`` or ``--work-tree`` and never sees a
270
+ shell's ``cd ~`` expansion, so the gate must expand the token itself;
271
+ otherwise it resolves a non-existent ``~/...`` path that git rejects while
272
+ the shell commits in the real home-anchored repo.
273
+
274
+ Args:
275
+ directory_token: A directory token that may start with ``~``.
276
+
277
+ Returns:
278
+ The token with any leading home prefix expanded, unchanged otherwise.
279
+ """
280
+ if directory_token.startswith("~"):
281
+ return os.path.expanduser(directory_token)
282
+ return directory_token
283
+
284
+
285
+ def _is_absolute_directory(directory_token: str) -> bool:
286
+ """Decide whether a directory-change target is already absolute.
287
+
288
+ Treats a POSIX root, a Windows drive or UNC root, a leading slash or
289
+ backslash, and a home-relative ``~`` token as absolute so they are used
290
+ as given rather than joined onto the active directory.
291
+
292
+ Args:
293
+ directory_token: The destination of a directory-change verb.
294
+
295
+ Returns:
296
+ True when the token names an absolute or home-anchored location.
297
+ """
298
+ if directory_token.startswith("~"):
299
+ return True
300
+ if directory_token.startswith(("/", "\\")):
301
+ return True
302
+ return os.path.isabs(directory_token)
303
+
304
+
305
+ def _resolve_against(active_directory: str, changed_directory: str) -> str:
306
+ """Resolve a directory-change target against the active directory.
307
+
308
+ An absolute or home-anchored target replaces the active directory; a
309
+ relative target is joined onto it so a ``cd subdir`` gates against the
310
+ session directory's subdirectory rather than a token git would resolve
311
+ against the hook process's own working directory.
312
+
313
+ Args:
314
+ active_directory: The directory in effect before this change.
315
+ changed_directory: The destination of a directory-change verb.
316
+
317
+ Returns:
318
+ The directory the shell runs in after the change.
319
+ """
320
+ if _is_absolute_directory(changed_directory):
321
+ return _expand_home_prefix(changed_directory)
322
+ return os.path.join(active_directory, changed_directory)
323
+
324
+
325
+ def _directory_change_target(command_text: str, match_end: int) -> str | None:
326
+ """Read the destination of a directory-change verb.
327
+
328
+ Walks the arguments after the verb, skipping a leading ``--`` terminator
329
+ and consuming the value after a PowerShell path option
330
+ (``-Path``/``-LiteralPath``) so the destination is the path rather than
331
+ the flag. A leading shell operator (``cd && git ...``) means no argument
332
+ and the active directory stays unchanged. Applies to every spelling in
333
+ ``DIRECTORY_CHANGE_VERBS`` (``cd``, ``pushd``, ``Set-Location``, ``sl``).
334
+
335
+ Args:
336
+ command_text: The raw command string from the tool payload.
337
+ match_end: The offset just past the directory-change verb word.
338
+
339
+ Returns:
340
+ The destination path when one follows the verb, or None for a bare
341
+ ``cd`` (a return to the home directory, which the gate ignores).
342
+ """
343
+ all_argument_tokens = _argument_tokens_after_verb(command_text, match_end)
344
+ token_index = 0
345
+ while token_index < len(all_argument_tokens):
346
+ each_token = _strip_token_quotes(all_argument_tokens[token_index])
347
+ if each_token == DIRECTORY_CHANGE_OPTION_TERMINATOR:
348
+ token_index += 1
349
+ continue
350
+ if each_token.lower() in DIRECTORY_CHANGE_PATH_OPTIONS:
351
+ token_index += 1
352
+ continue
353
+ return each_token
354
+ return None
355
+
356
+
357
+ def _argument_tokens_after_verb(command_text: str, match_end: int) -> list[str]:
358
+ """Cut the run of argument tokens that follows a directory-change verb.
359
+
360
+ Reads tokens until the first shell command separator (``;``, ``&``,
361
+ ``|``, or a newline), so only the verb's own arguments are returned and a
362
+ following command is left untouched.
363
+
364
+ Args:
365
+ command_text: The raw command string from the tool payload.
366
+ match_end: The offset just past the directory-change verb word.
367
+
368
+ Returns:
369
+ The quote-aware argument tokens following the verb, in order.
370
+ """
371
+ argument_run_pattern = re.compile(r"[ \t]+((?:\"[^\"]*\"|'[^']*'|[^\s;&|])+)")
372
+ argument_token_pattern = re.compile(r"\"[^\"]*\"|'[^']*'|[^\s;&|]+")
373
+ all_argument_tokens: list[str] = []
374
+ scan_position = match_end
375
+ while True:
376
+ run_match = argument_run_pattern.match(command_text, scan_position)
377
+ if run_match is None:
378
+ return all_argument_tokens
379
+ all_argument_tokens.extend(argument_token_pattern.findall(run_match.group(1)))
380
+ scan_position = run_match.end()
381
+
382
+
383
+ def gated_repo_directories(command_text: str, fallback_directory: str) -> list[str]:
384
+ """Collect the directories of every git commit/push found in a command.
385
+
386
+ Backslash-newline line continuations are removed first so the token
387
+ stream matches what the shell runs (``git \\<newline>commit`` is a real
388
+ commit). Scans every ``git`` word in the command — the bare ``git`` and
389
+ the Windows ``git.exe`` spelling, a path-prefixed binary whose final
390
+ segment is ``git``/``git.exe`` (``/usr/bin/git``,
391
+ ``C:\\...\\git.exe``), and a quoted git binary whose stripped content is
392
+ a single token ending in ``git``/``git.exe`` (``"git" commit``,
393
+ ``& 'C:/x/git.exe' commit``) — and token-walks from each to its first
394
+ subcommand. A ``git`` word that is one word among prose inside a quoted
395
+ string (``echo "Next: git commit"``, a ``gh pr comment -b`` body) is left
396
+ alone, because the shell never runs that quoted text. A
397
+ directory-change verb (``cd``, ``pushd``, PowerShell ``Set-Location``,
398
+ or its ``sl`` alias) earlier in the command moves the active directory,
399
+ so a following un-``-C``'d commit/push gates against the directory the
400
+ shell actually runs it in rather than the session cwd. A relative
401
+ change target joins onto the active directory so it resolves the same
402
+ way the shell would, not against the hook process's own cwd.
403
+
404
+ Args:
405
+ command_text: The raw command string from the tool payload.
406
+ fallback_directory: The session working directory, used as the
407
+ active directory until a directory-change verb changes it and
408
+ when the git call carries no ``-C`` flag.
409
+
410
+ Returns:
411
+ One directory per detected commit/push invocation, in order; empty
412
+ when the command carries no gated git verb.
413
+ """
414
+ command_text = _collapse_line_continuations(command_text)
415
+ git_word_pattern = re.compile(
416
+ r"(?:^|(?<=[\s;&|(\"'/\\]))git(?:\.exe)?(?:[\"'](?=\s|$)|(?=\s|$))",
417
+ re.IGNORECASE,
418
+ )
419
+ directory_change_verb_alternation = "|".join(
420
+ re.escape(each_verb) for each_verb in sorted(DIRECTORY_CHANGE_VERBS)
421
+ )
422
+ directory_change_pattern = re.compile(
423
+ DIRECTORY_CHANGE_PATTERN_PREFIX
424
+ + directory_change_verb_alternation
425
+ + DIRECTORY_CHANGE_PATTERN_SUFFIX,
426
+ re.IGNORECASE,
427
+ )
428
+ command_token_pattern = re.compile(r"\"[^\"]*\"|'[^']*'|\S+")
429
+ all_quoted_spans = _quoted_spans(command_text)
430
+ all_directory_change_matches = [
431
+ each_match
432
+ for each_match in directory_change_pattern.finditer(command_text)
433
+ if not _is_inside_quoted_region(each_match.start(), all_quoted_spans)
434
+ ]
435
+ all_git_word_matches = [
436
+ each_match
437
+ for each_match in git_word_pattern.finditer(command_text)
438
+ if _git_word_match_gates(each_match, command_text, all_quoted_spans)
439
+ ]
440
+ all_ordered_matches = sorted(
441
+ all_git_word_matches + all_directory_change_matches,
442
+ key=lambda each_match: each_match.start(),
443
+ )
444
+ active_directory = fallback_directory
445
+ target_directories: list[str] = []
446
+ for each_match in all_ordered_matches:
447
+ if each_match.group().lower().strip("\"'") in DIRECTORY_CHANGE_VERBS:
448
+ changed_directory = _directory_change_target(command_text, each_match.end())
449
+ if changed_directory is not None:
450
+ active_directory = _resolve_against(active_directory, changed_directory)
451
+ continue
452
+ following_text = command_text[each_match.end():]
453
+ all_following_tokens = [
454
+ _strip_token_quotes(each_token)
455
+ for each_token in command_token_pattern.findall(following_text)
456
+ ]
457
+ is_gated, flagged_directory = _gated_invocation_directory(all_following_tokens)
458
+ if is_gated:
459
+ target_directories.append(
460
+ _resolve_against(active_directory, flagged_directory)
461
+ if flagged_directory is not None
462
+ else active_directory
463
+ )
464
+ return target_directories
465
+
466
+
467
+ def deny_reason_for_directory(target_directory: str) -> str | None:
468
+ """Decide whether a commit/push in a directory must be blocked.
469
+
470
+ Args:
471
+ target_directory: The directory the git command targets.
472
+
473
+ Returns:
474
+ The deny reason when the branch diff needs a verdict and none binds
475
+ to it; None when the command may proceed.
476
+ """
477
+ repo_root = resolve_repo_root(target_directory)
478
+ if repo_root is None:
479
+ return None
480
+ merge_base_sha = resolve_merge_base(repo_root)
481
+ if merge_base_sha is None:
482
+ return None
483
+ if is_verification_exempt_diff(repo_root, merge_base_sha):
484
+ return None
485
+ surface_manifest_text = branch_surface_manifest(repo_root, merge_base_sha)
486
+ if surface_manifest_text is None:
487
+ return f"{CORRECTIVE_MESSAGE} (surface manifest failed in {repo_root})"
488
+ live_manifest_sha256 = manifest_sha256(surface_manifest_text)
489
+ if load_valid_verdict(repo_root, live_manifest_sha256) is None:
490
+ hash_preview = live_manifest_sha256[:HASH_PREVIEW_LENGTH]
491
+ return f"{CORRECTIVE_MESSAGE} (repo: {repo_root}, surface sha256 {hash_preview}...)"
492
+ return None
493
+
494
+
495
+ def main() -> None:
496
+ """Read the PreToolUse payload and deny unverified commit/push commands."""
497
+ try:
498
+ pretooluse_payload = json.load(sys.stdin)
499
+ except json.JSONDecodeError:
500
+ return
501
+ if pretooluse_payload.get("tool_name", "") not in ALL_GATED_TOOL_NAMES:
502
+ return
503
+ command_text = pretooluse_payload.get("tool_input", {}).get("command", "")
504
+ if not command_text:
505
+ return
506
+ session_directory = pretooluse_payload.get("cwd", ".")
507
+ for each_target_directory in gated_repo_directories(command_text, session_directory):
508
+ deny_reason = deny_reason_for_directory(each_target_directory)
509
+ if deny_reason is None:
510
+ continue
511
+ deny_payload = {
512
+ "hookSpecificOutput": {
513
+ "hookEventName": "PreToolUse",
514
+ "permissionDecision": "deny",
515
+ "permissionDecisionReason": deny_reason,
516
+ }
517
+ }
518
+ print(json.dumps(deny_payload))
519
+ return
520
+
521
+
522
+ if __name__ == "__main__":
523
+ main()
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook: keep verified-commit exemption messages accurate.
3
+
4
+ The verified-commit gate exempts a branch surface only when every changed path
5
+ matches one of two narrow rules, implemented in ``is_verification_exempt_diff``:
6
+
7
+ 1. file extension in ``DOCS_ONLY_EXTENSIONS`` (docs and image extensions), or
8
+ 2. a ``.py`` file whose docstring/comment-stripped AST is unchanged.
9
+
10
+ A comment-only change to a non-Python, non-doc file (for example ``.sh``,
11
+ ``.json``, ``.yaml``) is therefore NOT exempt: comments are ignored for
12
+ exemption purposes only inside Python files via the AST path. A corrective or
13
+ guard message that claims comment-only or docs-only surfaces are blanket exempt
14
+ overstates the rule and misleads users into expecting such a change to skip
15
+ verification.
16
+
17
+ This hook fires on Write/Edit of any verified-commit constants module and denies
18
+ content whose exemption-claim wording asserts a blanket comment-only or docs-only
19
+ exemption. It guards the message constants at authoring time, before the change
20
+ reaches review.
21
+ """
22
+
23
+ import json
24
+ import os
25
+ import re
26
+ import sys
27
+
28
+
29
+ def is_guarded_file(file_path: str) -> bool:
30
+ """Return True for any verified-commit constants module carrying messages."""
31
+ all_guarded_file_names = frozenset({"verified_commit_constants.py"})
32
+ return os.path.basename(file_path) in all_guarded_file_names
33
+
34
+
35
+ def join_adjacent_string_literals(written_text: str) -> str:
36
+ """Collapse a closing quote, inter-literal whitespace, and an opening quote.
37
+
38
+ Write/Edit content carries the message constant as it appears in source: a
39
+ long sentence split across adjacent Python string literals. Between two
40
+ literals sit a closing quote, a newline, indentation, and an opening quote.
41
+ Replacing that run with a single space rejoins the prose so a phrase wrapped
42
+ across a literal boundary — for example ``exempt "`` then ``"automatically``
43
+ — reads as one continuous clause for matching.
44
+ """
45
+ inter_literal_noise_pattern = re.compile(r"[\"']\s*[\"']")
46
+ return inter_literal_noise_pattern.sub(" ", written_text)
47
+
48
+
49
+ def claims_blanket_comment_exemption(written_text: str) -> bool:
50
+ """Return True when the text claims comment surfaces are blanket-exempt.
51
+
52
+ Rejoins source-wrapped string literals first, then matches only a genuine
53
+ blanket form in which a comment surface is the grammatical subject of
54
+ "exempt automatically". A comment surface is the bare noun ``comments`` or
55
+ the ``comment-only`` category — never the ``comment`` stem inside an
56
+ unrelated word such as ``commentary`` or the ``comment-stripped`` AST
57
+ qualifier.
58
+
59
+ Two blanket shapes match. The direct shape names a comment surface as the
60
+ immediate subject of "(are|is) exempt automatically" across a bridge that
61
+ crosses no period, semicolon, or comma, so an intervening predicate (for
62
+ example "Comments are handled, and docs are exempt automatically") leaves
63
+ docs, not comments, as the exemption subject and does not match. The
64
+ enumerated shape names a comment item inside an "...-only surfaces are
65
+ exempt automatically" list (for example "Docs-, comment-, and test-only
66
+ surfaces are exempt automatically").
67
+
68
+ An accurate sentence whose true exemption subject is docs, or that qualifies
69
+ comments as the stripped input to the AST comparison, does not match. This
70
+ isolates the overstated form the verified-commit gate does not honor for
71
+ non-Python files.
72
+ """
73
+ comment_surface = r"comments?-only|comments?\b(?!-)"
74
+ blanket_direct_claim = (
75
+ "(?:" + comment_surface + r")(?:[^.;,]*?\b)?(?:are|is)\s+exempt\s+automatically"
76
+ )
77
+ blanket_enumerated_claim = (
78
+ "(?:" + comment_surface + "|comment-)"
79
+ r"[^.;]*?-only\s+surfaces\s+are\s+exempt\s+automatically"
80
+ )
81
+ blanket_exemption_claim_pattern = re.compile(
82
+ "(?:" + blanket_direct_claim + ")|(?:" + blanket_enumerated_claim + ")",
83
+ re.IGNORECASE,
84
+ )
85
+ joined_text = join_adjacent_string_literals(written_text)
86
+ return bool(blanket_exemption_claim_pattern.search(joined_text))
87
+
88
+
89
+ def build_corrective_message() -> str:
90
+ """Return the deny reason explaining the real, narrower exemption rules."""
91
+ accurate_exemption_phrasing = (
92
+ "Docs and images are exempt by extension, and Python files whose "
93
+ "docstring- and comment-stripped AST is unchanged; a comment-only "
94
+ "change to a non-Python file still needs a verdict."
95
+ )
96
+ return (
97
+ "BLOCKED [verified-commit-message-accuracy]: this exemption message "
98
+ "claims comment-only surfaces are exempt automatically, but the "
99
+ "verified-commit gate exempts comments only inside Python files (via the "
100
+ "docstring/comment stripped AST path). A comment-only change to a "
101
+ "non-Python file is NOT exempt and still needs a verifier verdict, so "
102
+ "the blanket wording misleads users.\n\nDescribe the real exemption "
103
+ "rules instead, for example:\n " + accurate_exemption_phrasing
104
+ )
105
+
106
+
107
+ def extract_written_text(all_written_fields: dict[str, str]) -> str:
108
+ """Return the Write ``content`` and Edit ``new_string`` joined for scanning."""
109
+ return (
110
+ all_written_fields.get("content", "")
111
+ + "\n"
112
+ + all_written_fields.get("new_string", "")
113
+ )
114
+
115
+
116
+ def main() -> None:
117
+ all_write_edit_tools = frozenset({"Write", "Edit"})
118
+ try:
119
+ hook_input = json.load(sys.stdin)
120
+ except json.JSONDecodeError:
121
+ sys.exit(0)
122
+
123
+ tool_name = hook_input.get("tool_name", "")
124
+ if tool_name not in all_write_edit_tools:
125
+ sys.exit(0)
126
+
127
+ tool_input = hook_input.get("tool_input", {})
128
+ file_path = tool_input.get("file_path", "")
129
+ if not file_path:
130
+ sys.exit(0)
131
+
132
+ if not is_guarded_file(file_path):
133
+ sys.exit(0)
134
+
135
+ written_text = extract_written_text(tool_input)
136
+ if not claims_blanket_comment_exemption(written_text):
137
+ sys.exit(0)
138
+
139
+ deny_response = {
140
+ "hookSpecificOutput": {
141
+ "hookEventName": "PreToolUse",
142
+ "permissionDecision": "deny",
143
+ "permissionDecisionReason": build_corrective_message(),
144
+ }
145
+ }
146
+ print(json.dumps(deny_response))
147
+ sys.stdout.flush()
148
+ sys.exit(0)
149
+
150
+
151
+ if __name__ == "__main__":
152
+ main()