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