claude-dev-env 8.97.0 → 8.98.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 (35) hide show
  1. package/bin/install.prompt-context.test.mjs +3 -2
  2. package/bin/install.settings-defaults.test.mjs +4 -3
  3. package/docs/agents-guidance/asks.md +6 -1
  4. package/hooks/blocking/open_tasks_stop_gate.py +128 -34
  5. package/hooks/blocking/open_tasks_transcript.py +112 -5
  6. package/hooks/blocking/pr_lifecycle_skill_gate.py +56 -14
  7. package/hooks/blocking/python_windows_invocation_gate.py +249 -0
  8. package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +5 -1
  9. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -0
  10. package/hooks/blocking/test_open_tasks_stop_gate.py +202 -0
  11. package/hooks/blocking/test_open_tasks_transcript.py +89 -0
  12. package/hooks/blocking/test_pr_lifecycle_skill_gate.py +8 -0
  13. package/hooks/blocking/test_python_windows_invocation_gate.py +181 -0
  14. package/hooks/blocking/test_verify_before_commit_gate.py +334 -0
  15. package/hooks/blocking/verify_before_commit_gate.py +278 -0
  16. package/hooks/features/bash-dispatchers.md +3 -0
  17. package/hooks/features/conduct-gates.md +10 -2
  18. package/hooks/hooks.json +15 -0
  19. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -0
  20. package/hooks/hooks_constants/open_tasks_stop_gate_constants.py +50 -0
  21. package/hooks/hooks_constants/python_windows_invocation_gate_constants.py +60 -0
  22. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -1
  23. package/hooks/hooks_constants/transcript_skill_scan_constants.py +2 -0
  24. package/hooks/hooks_constants/verify_before_commit_gate_constants.py +54 -0
  25. package/hooks/open_task_asks.py +163 -0
  26. package/hooks/session/open_task_ask_recorder.py +38 -0
  27. package/hooks/session/test_open_task_ask_recorder.py +57 -0
  28. package/hooks/test_open_task_asks.py +52 -0
  29. package/hooks/test_transcript_skill_scan.py +24 -0
  30. package/hooks/transcript_skill_scan.py +54 -3
  31. package/package.json +1 -1
  32. package/scripts/policy_lint/adapter_configuration.py +5 -0
  33. package/scripts/policy_lint/config/constants.py +1 -0
  34. package/scripts/tests/test_adapter_configuration.py +8 -0
  35. package/settings.json +2 -1
@@ -0,0 +1,278 @@
1
+ """Deny a code commit until the repository's verify skill loads in the current turn."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import sys
7
+ from collections.abc import Iterable
8
+ from dataclasses import dataclass
9
+ from pathlib import Path, PurePosixPath
10
+
11
+ hooks_directory = str(Path(__file__).resolve().parent.parent)
12
+ if hooks_directory not in sys.path:
13
+ sys.path.insert(0, hooks_directory)
14
+
15
+ from blocking.pr_lifecycle_skill_gate import (
16
+ deny_decision,
17
+ executable_word_lists,
18
+ file_name,
19
+ next_command_word,
20
+ transcript_paths,
21
+ )
22
+ from hooks_constants.pr_lifecycle_skill_gate_constants import (
23
+ GIT_GLOBAL_OPTIONS_WITH_VALUE,
24
+ SHELL_TOOL_NAMES,
25
+ )
26
+ from hooks_constants.pre_tool_use_stdin import print_decision_from_stdin
27
+ from hooks_constants.setup_project_paths_constants import (
28
+ DECODE_ERRORS_POLICY,
29
+ UTF8_ENCODING,
30
+ )
31
+ from hooks_constants.verify_before_commit_gate_constants import (
32
+ ADD_ACTION,
33
+ ALL_ADD_EVERYTHING_OPTIONS,
34
+ ALL_COMMIT_ALL_OPTIONS,
35
+ ALL_COMMIT_VALUED_SHORT_FLAGS,
36
+ ALL_DOCS_EXTENSIONS,
37
+ ALL_GIT_EXECUTABLES,
38
+ ALL_SLASH_COMMAND_MARKERS,
39
+ ALL_STAGED_PATHS_ARGUMENTS,
40
+ ALL_TEST_DIRECTORY_NAMES,
41
+ ALL_TEST_FILE_INFIXES,
42
+ ALL_TEST_FILE_NAMES,
43
+ ALL_TEST_FILE_PREFIXES,
44
+ ALL_TEST_FILE_SUFFIXES,
45
+ ALL_TOP_LEVEL_ARGUMENTS,
46
+ ALL_TRACKED_CHANGE_ARGUMENTS,
47
+ ALL_VERIFY_SKILL_DIRECTORIES,
48
+ ALL_WORKING_TREE_PATHS_ARGUMENTS,
49
+ COMMIT_ACTION,
50
+ COMMIT_ALL_SHORT_FLAG,
51
+ DENY_REASON,
52
+ GIT_DIRECTORY_OPTION,
53
+ GIT_TIMEOUT_SECONDS,
54
+ PATHSPEC_TERMINATOR,
55
+ SKILL_NAME,
56
+ WHOLE_TREE_PATHSPEC,
57
+ )
58
+ from transcript_skill_scan import is_skill_loaded_since_last_prompt
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class GitCall:
63
+ """One git command in a shell command: its directory, action, and the words after the action."""
64
+
65
+ directory: Path
66
+ action: str
67
+ all_arguments: tuple[str, ...]
68
+
69
+
70
+ def _git_directory(all_global_words: list[str], base_directory: Path) -> Path:
71
+ directory = base_directory
72
+ for each_index, each_word in enumerate(all_global_words[:-1]):
73
+ if each_word == GIT_DIRECTORY_OPTION:
74
+ directory = directory / all_global_words[each_index + 1]
75
+ return directory
76
+
77
+
78
+ def _git_calls(command: str, base_directory: Path) -> list[GitCall]:
79
+ all_calls: list[GitCall] = []
80
+ for each_words in executable_word_lists(command):
81
+ if file_name(each_words[0]) not in ALL_GIT_EXECUTABLES:
82
+ continue
83
+ action, after_action = next_command_word(
84
+ each_words, 1, GIT_GLOBAL_OPTIONS_WITH_VALUE
85
+ )
86
+ if action is None:
87
+ continue
88
+ directory = _git_directory(each_words[1 : after_action - 1], base_directory)
89
+ all_calls.append(GitCall(directory, action, tuple(each_words[after_action:])))
90
+ return all_calls
91
+
92
+
93
+ def _git_lines(directory: Path, all_arguments: Iterable[str]) -> list[str] | None:
94
+ try:
95
+ completed = subprocess.run(
96
+ ["git", "-C", str(directory), *all_arguments],
97
+ capture_output=True,
98
+ text=True,
99
+ encoding=UTF8_ENCODING,
100
+ errors=DECODE_ERRORS_POLICY,
101
+ timeout=GIT_TIMEOUT_SECONDS,
102
+ check=False,
103
+ )
104
+ except (OSError, subprocess.SubprocessError):
105
+ return None
106
+ if completed.returncode != 0:
107
+ return None
108
+ return [each_line for each_line in completed.stdout.splitlines() if each_line]
109
+
110
+
111
+ def _short_flag_cluster(cluster: str) -> tuple[bool, bool]:
112
+ for each_flag in cluster[1:]:
113
+ if each_flag == COMMIT_ALL_SHORT_FLAG:
114
+ return True, False
115
+ if each_flag in ALL_COMMIT_VALUED_SHORT_FLAGS:
116
+ return False, cluster.endswith(each_flag)
117
+ return False, False
118
+
119
+
120
+ def _commits_all_tracked_changes(all_arguments: tuple[str, ...]) -> bool:
121
+ skips_value = False
122
+ for each_word in all_arguments:
123
+ if skips_value:
124
+ skips_value = False
125
+ continue
126
+ if each_word == PATHSPEC_TERMINATOR:
127
+ return False
128
+ if each_word in ALL_COMMIT_ALL_OPTIONS:
129
+ return True
130
+ is_short_cluster = each_word.startswith("-") and not each_word.startswith("--")
131
+ commits_all, skips_value = _short_flag_cluster(each_word) if is_short_cluster else (False, False)
132
+ if commits_all:
133
+ return True
134
+ return False
135
+
136
+
137
+ def _added_pathspecs(all_arguments: tuple[str, ...]) -> list[str]:
138
+ terminator_index = (
139
+ all_arguments.index(PATHSPEC_TERMINATOR) if PATHSPEC_TERMINATOR in all_arguments else len(all_arguments)
140
+ )
141
+ all_option_words = all_arguments[:terminator_index]
142
+ all_pathspecs = [each_word for each_word in all_option_words if not each_word.startswith("-")]
143
+ all_pathspecs.extend(all_arguments[terminator_index + 1 :])
144
+ if not all_pathspecs and any(each_word in ALL_ADD_EVERYTHING_OPTIONS for each_word in all_option_words):
145
+ return [WHOLE_TREE_PATHSPEC]
146
+ return all_pathspecs
147
+
148
+
149
+ def _committed_paths(
150
+ commit: GitCall, all_earlier_calls: list[GitCall]
151
+ ) -> set[str] | None:
152
+ staged_paths = _git_lines(commit.directory, ALL_STAGED_PATHS_ARGUMENTS)
153
+ if staged_paths is None:
154
+ return None
155
+ all_paths = set(staged_paths)
156
+ if _commits_all_tracked_changes(commit.all_arguments):
157
+ all_paths.update(
158
+ _git_lines(commit.directory, ALL_TRACKED_CHANGE_ARGUMENTS) or []
159
+ )
160
+ for each_call in all_earlier_calls:
161
+ if each_call.action != ADD_ACTION or each_call.directory != commit.directory:
162
+ continue
163
+ all_pathspecs = _added_pathspecs(each_call.all_arguments)
164
+ if all_pathspecs:
165
+ all_working_tree_arguments = (
166
+ *ALL_WORKING_TREE_PATHS_ARGUMENTS,
167
+ *all_pathspecs,
168
+ )
169
+ all_paths.update(
170
+ _git_lines(each_call.directory, all_working_tree_arguments) or []
171
+ )
172
+ return all_paths
173
+
174
+
175
+ def _is_docs_or_test_path(repository_path: str) -> bool:
176
+ path = PurePosixPath(repository_path)
177
+ if path.suffix.lower() in ALL_DOCS_EXTENSIONS or path.name in ALL_TEST_FILE_NAMES:
178
+ return True
179
+ if any(each_part in ALL_TEST_DIRECTORY_NAMES for each_part in path.parts[:-1]):
180
+ return True
181
+ return (
182
+ path.name.startswith(ALL_TEST_FILE_PREFIXES)
183
+ or path.name.endswith(ALL_TEST_FILE_SUFFIXES)
184
+ or any(each_infix in path.name for each_infix in ALL_TEST_FILE_INFIXES)
185
+ )
186
+
187
+
188
+ def _has_verify_skill(directory: Path) -> bool:
189
+ top_level = _git_lines(directory, ALL_TOP_LEVEL_ARGUMENTS)
190
+ if not top_level:
191
+ return False
192
+ repository_root = Path(top_level[0])
193
+ return any(
194
+ (repository_root / each_directory).is_dir()
195
+ for each_directory in ALL_VERIFY_SKILL_DIRECTORIES
196
+ )
197
+
198
+
199
+ def _verify_load_status(transcript_path: str) -> bool | None:
200
+ try:
201
+ with open(
202
+ transcript_path, encoding=UTF8_ENCODING, errors=DECODE_ERRORS_POLICY
203
+ ) as transcript:
204
+ return is_skill_loaded_since_last_prompt(
205
+ transcript, (SKILL_NAME,), ALL_SLASH_COMMAND_MARKERS
206
+ )
207
+ except OSError:
208
+ return None
209
+
210
+
211
+ def _is_verified_or_unreadable(all_payload_fields: dict[str, object]) -> bool:
212
+ all_paths = transcript_paths(all_payload_fields)
213
+ if not all_paths:
214
+ return True
215
+ return any(_verify_load_status(each_path) is not False for each_path in all_paths)
216
+
217
+
218
+ def _commits_code(all_git_calls: list[GitCall]) -> bool:
219
+ for each_index, each_call in enumerate(all_git_calls):
220
+ if each_call.action != COMMIT_ACTION or not _has_verify_skill(
221
+ each_call.directory
222
+ ):
223
+ continue
224
+ all_paths = _committed_paths(each_call, all_git_calls[:each_index])
225
+ if all_paths and not all(
226
+ _is_docs_or_test_path(each_path) for each_path in all_paths
227
+ ):
228
+ return True
229
+ return False
230
+
231
+
232
+ def _shell_command(all_payload_fields: dict[str, object]) -> str | None:
233
+ if all_payload_fields.get("tool_name") not in SHELL_TOOL_NAMES:
234
+ return None
235
+ tool_input = all_payload_fields.get("tool_input")
236
+ command = tool_input.get("command") if isinstance(tool_input, dict) else None
237
+ return command if isinstance(command, str) else None
238
+
239
+
240
+ def decision_for(all_payload_fields: dict[str, object]) -> dict[str, object] | None:
241
+ """Return a deny for a code commit made before the verify skill loads this turn, else None.
242
+
243
+ ::
244
+
245
+ git commit, code staged, no verify since the last prompt -> DENY_REASON
246
+ git add app.py; git commit, empty index, no verify -> DENY_REASON
247
+ git commit, only docs or test files -> None
248
+ git commit after a verify Skill call or a /verify prompt -> None
249
+ git commit in a repository with no verify skill directory -> None
250
+ transcript missing or unreadable, or git fails -> None
251
+
252
+ Args:
253
+ all_payload_fields: The parsed PreToolUse input.
254
+ """
255
+ command = _shell_command(all_payload_fields)
256
+ if command is None:
257
+ return None
258
+ working_directory = all_payload_fields.get("cwd")
259
+ base_directory = (
260
+ Path(working_directory) if isinstance(working_directory, str) else Path.cwd()
261
+ )
262
+ all_git_calls = _git_calls(command, base_directory)
263
+ if not any(each_call.action == COMMIT_ACTION for each_call in all_git_calls):
264
+ return None
265
+ if _is_verified_or_unreadable(all_payload_fields) or not _commits_code(
266
+ all_git_calls
267
+ ):
268
+ return None
269
+ return deny_decision(DENY_REASON)
270
+
271
+
272
+ def main() -> int:
273
+ """Read one payload, print a deny when needed, and exit zero."""
274
+ return print_decision_from_stdin(decision_for)
275
+
276
+
277
+ if __name__ == "__main__":
278
+ sys.exit(main())
@@ -9,6 +9,7 @@ This family rewrites a Git Bash command when path conversion would change a revi
9
9
  - `blocking/headless_claude_broker_gate.py` denies a headless `claude -p` or `claude --print` call and names the broker command to run instead.
10
10
  - `blocking/gh_global_account_switch_gate.py` denies `gh auth switch`, `gh auth login`, and `gh auth logout` and names the per-command `GH_TOKEN` form to run instead.
11
11
  - `blocking/cloud_graphql_gate.py` denies the `gh` commands that call GraphQL (`gh api graphql` with any flags before the endpoint, `gh pr`, `gh issue`, `gh project` and `gh discussion` except `gh pr diff` and `gh pr list --web`, `gh status`, `gh org list`, `gh release list`, `gh release view` with a tag, `gh workflow run` without `--ref`, and a few `repo`, `label`, `search`, `ruleset` and `gist` commands, read past `if`, `!` and command substitutions) and an HTTP client or `python -c` style call to `api.github.com/graphql`, with or without a port, inside a cloud session, where the proxy answers GraphQL with HTTP 403, and names the REST routes to run instead.
12
+ - `blocking/python_windows_invocation_gate.py` denies, on a Windows host, `python`, `python3` or `py` run with `-I` and without `-X utf8`, because `-I` ignores `PYTHONUTF8` and non-ASCII output then stops with `UnicodeEncodeError: 'charmap'`. Under the PowerShell tool it also denies a `python -c "..."` argument that holds `\"`, because PowerShell ends the string at that quote, and names a script file or a single-quoted here-string to use instead.
12
13
  - `blocking/bash_post_call_dispatcher.py` runs hosted observers and joins their context output without blocking the call.
13
14
  - `advisory/pr_done_reminder.py` adds a pull request checklist after a successful push or pull request creation.
14
15
  - `advisory/owned_pr_review_loop.py` adds review-loop instructions after the agent opens a pull request on an owned repository outside the preset list of repositories that run their own automated review: run efficient-review until clean, mark the pull request ready, babysit it, and ship it to merge. It also records the pull request for the session in `owned_pr_session_record.py`.
@@ -21,6 +22,7 @@ This family rewrites a Git Bash command when path conversion would change a revi
21
22
  - `blocking/headless_claude_broker_gate.py` runs inside that dispatcher on `PreToolUse`, matcher `Bash`, timeout `60` seconds, for the Bash and PowerShell tools.
22
23
  - `blocking/gh_global_account_switch_gate.py` runs inside that dispatcher on `PreToolUse`, matcher `Bash`, timeout `60` seconds, for the Bash and PowerShell tools.
23
24
  - `blocking/cloud_graphql_gate.py` runs inside that dispatcher on `PreToolUse`, matcher `Bash`, timeout `60` seconds, for the Bash and PowerShell tools, when `CLAUDE_CODE_REMOTE` is `true`.
25
+ - `blocking/python_windows_invocation_gate.py` runs inside that dispatcher on `PreToolUse`, matcher `Bash`, timeout `60` seconds, for the Bash and PowerShell tools. The `-I` check runs only when `os.name` is `nt`.
24
26
  - `blocking/bash_post_call_dispatcher.py` runs on `PostToolUse`, matcher `Bash|PowerShell`, timeout `60` seconds in `hooks.json`.
25
27
  - `advisory/pr_done_reminder.py` runs inside that dispatcher on `PostToolUse`, matcher `Bash|PowerShell`, timeout `60` seconds. `ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES` selects `Bash` and `PowerShell`.
26
28
  - `advisory/owned_pr_review_loop.py` runs on `PostToolUse`, matcher `Bash|PowerShell|exec_command|shell_command|mcp__.*__(create_pull_request|github_create_pull_request)`, timeout `10` seconds in `hooks.json`. It wakes on `gh pr create`, `gh pr new`, a POST to `gh api repos/<owner>/<repo>/pulls`, a `pull_request.py create` call to the pull request publisher, and an MCP pull request creation. The installer also registers it on Codex `PostToolUse` through `ALL_CODEX_NATIVE_POST_TOOL_USE_HOOK_SCRIPT_NAMES`.
@@ -36,6 +38,7 @@ Preconditions:
36
38
  - **Revision rewrite.** Input is a Git command with `origin/main:.claude/settings.json`. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_msys_rev_path_rewriter.py -q`. The adjacent test observes the `origin/main:` exclusion prefix.
37
39
  - **Broker gate.** Input is a `claude -p task` command. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_headless_claude_broker_gate.py -q`. The adjacent test observes a denial that names the broker command.
38
40
  - **Cloud GraphQL gate.** Input is a `gh api graphql` command with `CLAUDE_CODE_REMOTE=true`. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_cloud_graphql_gate.py -q`. The adjacent test observes a denial that names the REST routes, and silence outside a cloud session.
41
+ - **Python Windows invocation gate.** Input is `python -I s.py` on Windows, and a PowerShell `python -c "print(\"x\")"`. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_python_windows_invocation_gate.py -q`. The adjacent test observes a denial that names `-X utf8` or the here-string, and a pass for `-IX utf8`, `-I -X utf8`, and the Bash tool.
39
42
  - **Post-call dispatch.** Input is a Bash call that yields hosted context. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_bash_post_call_dispatcher.py -q`. The adjacent test observes a joined context payload and exit code zero.
40
43
  - **Pull request reminder.** Input is a completed push or pull request creation. Run `python -m pytest packages/claude-dev-env/hooks/advisory/test_pr_done_reminder.py -q`. The adjacent test checks the reminder trigger and checklist verdict.
41
44
  - **Owned pull request review loop.** Input is a pull request creation result on an owned repository. Run `python -m pytest packages/claude-dev-env/hooks/advisory/test_owned_pr_review_loop.py -q`. The adjacent test observes the review-loop block for owned repositories and silence for the preset and foreign repositories.
@@ -6,7 +6,8 @@ This family stops a chat reply, edited message, or tool call when its payload br
6
6
 
7
7
  - `blocking/reply_length_gate.py` caps chat reply length, denies a configured banned word, requires a readable link when the text names a pull request number, and denies a causal claim in a reply that cites no code span, output block, or link. It denies a sentence that guesses, estimates, hedges a duration, or admits a skipped check, such as "I didn't confirm this one." It denies a cited Actions run, pull request, or run, job, or check id that no successful tool result in the transcript shows; a subagent's report is the result of its Agent call. A question card from `AskUserQuestion` gets the banned word check and `unvalidated_claim_violation`, like a decision card. With visual reply mode on, it also runs the checks in `visual_reply_rules.py` from the rule list in `rules/visual-reply-rules.json`: no abbreviation or tracker number outside a link, a widget or page before a reply of more than one sentence, and no anchor link inside widget code. The mode is off by default; `~/.claude/visual-reply-mode.json` with `{"enabled": true}` turns the mode checks on.
8
8
  - `blocking/final_reply_gate.py` applies the banned words, `unvalidated_claim_violation`, and `unseen_citation_violation` from the reply gate to the reply a session ends its turn with. Its `windows_path_violation` blocks a path the terminal would render without one of its backslashes.
9
- - `blocking/open_tasks_stop_gate.py` holds the end of a turn while the session task list has items that are neither completed nor named on a `Blocked:` or `Open:` line of the closing reply. `blocking/open_tasks_transcript.py` rebuilds the task list and the last reply from the transcript for it.
9
+ - `blocking/open_tasks_stop_gate.py` holds the end of a turn while the session has open items that the closing reply does not name on a `Blocked:` or `Open:` line. It merges the task store, the transcript task calls, the desktop `mcp__ccd_turn__update_plan` plan, and the recorded asks, one item per subject. `blocking/open_tasks_transcript.py` rebuilds the task list and the last reply from the transcript for it. Each new desktop plan list carries the unfinished items of the list before it after its own items, so `done` and `start` positions still point at its own items. An ask stays open until a reply line `Done: #ask-N` closes it. A line `Dropped: <#ask-N or carried item> - "<quote>"` closes an ask or a carried plan item when the quote is in a prompt the user sent after that ask or that list.
10
+ - `session/open_task_ask_recorder.py` records each prompt in `~/.claude/open-task-asks/<session id>.json`, outside the task store. A prompt that asks for work gets the next `#ask-N` id. A question-only prompt, an acknowledgement such as `thanks!`, an empty prompt, and a harness envelope such as `<task-notification>` get none.
10
11
  - The claim judge is a `prompt` hook in `hooks.json`, with no `model` set, so Claude Code's small default hook model reads each chat message and denies the first claim about how code, a file, a tool, a service, a run, or a process behaves, or why something happened, when nothing in the message shows it: inline code that quotes output or a code line, a file and line, or a link. A pull request link covers what that change does, what it blocks or lets through, and its checks. The author's own actions, a restatement of the user's words, questions, instructions, and a decision card's option consequences pass. It catches the plain claims the causal word list in `blocking/reply_length_gate.py` misses, such as "The code lets any of them rewrite it mid-check." `continueOnBlock` returns the reason to the agent as the tool error, so it can add the source and send again. The installer owns it through the `claude-dev-env: ` prefix on its `statusMessage`. It skips `post_widget`, whose input is SVG or HTML markup.
11
12
  - `blocking/chat_reply_gate.py` runs the `reply_length_gate.py` reply checks on a plain chat reply, which is no tool call. On `UserPromptSubmit` it adds the reply rules as `additionalContext`. On `MessageDisplay` it holds each streamed batch until the message ends. A message that passes shows whole. A message that fails shows as a draft from `claude -p` on Haiku that passes the same checks, and the original shows when two drafts fail. The draft changes only the text on screen, so the next `UserPromptSubmit` tells the agent the text the user saw, and `SessionEnd` deletes the stored draft. The [reply arena harness](https://github.com/jl-cmd-projects/claude-dev-env/tree/160349b90f627ea542fc6bc7b2b7f86dcbf98b73/tests/reply_arena) ran the comparison that chose this display path.
12
13
  - `blocking/edit_marker_gate.py` rejects strikethrough and edit-note markers in replacement chat text or cards.
@@ -16,6 +17,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
16
17
  - `blocking/step_note_gate.py` requires a status line before a tool call while its opt-in flag is on.
17
18
  - `blocking/verify_before_acting.py` blocks a completed mutating call when the agent's reasoning contains an unchecked claim.
18
19
  - `blocking/pr_lifecycle_skill_gate.py` denies a commit, push, pull request action, or merge until the `pr-lifecycle` skill appears in the transcript since the last compaction.
20
+ - `blocking/verify_before_commit_gate.py` denies a `git commit` in a repository with a `.claude/skills/verify` or `.agents/skills/verify` directory until the `verify` skill loads after the last user prompt, through a Skill call or the `/verify` command in that prompt. It reads the committed paths from the staged diff, the tracked changes for `-a`, and the paths of each `git add` earlier in the same command, and passes a commit whose paths are all docs, images, or test files.
19
21
  - `blocking/build_eval_before_eval_write_gate.py` denies a write to an eval file until a Skill call named exactly `claude-api` with args `build-eval`, or the `/claude-api build-eval` command, appears in the transcript.
20
22
  - `blocking/merge_review_gate.py` denies an agent's merge until a review pass on the merged head reported zero findings, or ruled out every finding and linked the posted rulings. It reads the receipt `scripts/merge_review_receipt.py` writes, requires the merge, auto-merge or merge-queue entry to be pinned to that head, and skips repositories listed in `CLAUDE_MERGE_REVIEW_EXEMPT_REPOSITORIES`. Its deny reason names the review command `CLAUDE_MERGE_REVIEW_COMMAND` sets. The reason tells the agent to run that command for the first three passes and `/code-review low --fix <number>` for each later pass; the gate itself counts no passes.
21
23
  - `blocking/merge_over_review_offer_gate.py` denies a decision card or question that offers a merge while it names open review findings, a red check, unaddressed review comments, or an override. It reads the whole card as one text: every question, header, context, reason, and option, whether the option is a string or a label with a consequence or description.
@@ -27,6 +29,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
27
29
  - `blocking/reply_length_gate.py` runs on `PreToolUse`, matcher `mcp__hearthbot__reply|mcp__hearthbot__post_message|mcp__hearthbot__update_message|mcp__hearthbot__ask_decision|AskUserQuestion|mcp__hearthbot__post_widget`, timeout `10` seconds in `hooks.json`.
28
30
  - `blocking/final_reply_gate.py` runs on `Stop`, timeout `10` seconds in `hooks.json`.
29
31
  - `blocking/open_tasks_stop_gate.py` runs on `Stop`, timeout `10` seconds in `hooks.json`. The installer also registers it on Codex `Stop` and as a native Cursor `stop` entry in `~/.cursor/hooks.json`, where a block also returns `followup_message`. A Cursor stop with status `aborted` or `error` passes. A Cursor stop whose `loop_count` is 5 or more passes, because a copy Cursor imports from `~/.claude/settings.json` has no follow-up limit.
32
+ - `session/open_task_ask_recorder.py` runs on `UserPromptSubmit`, matcher empty, timeout `10` seconds in `hooks.json`. It prints nothing.
30
33
  - The claim judge runs on `PreToolUse`, matcher `mcp__hearthbot__reply|mcp__hearthbot__post_message|mcp__hearthbot__update_message|mcp__hearthbot__ask_decision`, timeout `30` seconds in `hooks.json`.
31
34
  - `blocking/chat_reply_gate.py` runs on `UserPromptSubmit`, matcher empty, timeout `10` seconds, on `MessageDisplay`, timeout `100` seconds, and on `SessionEnd`, timeout `3` seconds, in `hooks.json`.
32
35
  - `blocking/edit_marker_gate.py` runs on `PreToolUse`, matcher `mcp__.*__update_message`, timeout `10` seconds in `hooks.json`.
@@ -36,6 +39,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
36
39
  - `blocking/step_note_gate.py` runs on `PreToolUse`, matcher `*`, timeout `15` seconds in `hooks.json`.
37
40
  - `blocking/verify_before_acting.py` runs on `PostToolUse`, matcher `Write|Edit|MultiEdit|NotebookEdit|Agent|Task|apply_patch|Bash|PowerShell|mcp__.*`, timeout `10` seconds in `hooks.json`.
38
41
  - `blocking/pr_lifecycle_skill_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request|actions_run_trigger)`, timeout `10` seconds in `hooks.json`.
42
+ - `blocking/verify_before_commit_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell`, timeout `30` seconds in `hooks.json`.
39
43
  - `blocking/build_eval_before_eval_write_gate.py` runs on `PreToolUse`, matcher `Write|Edit|MultiEdit|NotebookEdit|apply_patch`, timeout `10` seconds in `hooks.json`.
40
44
  - `blocking/merge_review_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(merge_pull_request|enable_pr_auto_merge)`, timeout `10` seconds in `hooks.json`.
41
45
  - `blocking/merge_over_review_offer_gate.py` runs on `PreToolUse`, matcher `AskUserQuestion|mcp__.*__ask_decision`, timeout `10` seconds in `hooks.json`.
@@ -50,7 +54,8 @@ Preconditions:
50
54
 
51
55
  - **Reply length.** Input is a reply with four sentences, a plain pull request number, an abbreviation, two sentences with no widget this turn, or widget code with an anchor link. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_reply_length_gate.py -q`. The adjacent test observes a denial and its count, link, or mode reason. Widget code with no flowchart passes with a flowchart reminder in `additionalContext`.
52
56
  - **Final reply.** Input is a closing reply that says "My guess is one to two hours." or cites a run no tool result shows. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_final_reply_gate.py -q`. The adjacent test observes a `block` decision that quotes the sentence or the citation; a reply that cites a run a tool result shows passes. A reply with a plain-text `D:\Reports\.claude\report.html` is blocked; the same path in backticks or a fenced block passes.
53
- - **Open tasks.** Input is a session whose task list still has "rotate the deploy key" pending and a closing reply of "Done.". Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_open_tasks_stop_gate.py -q`. The adjacent test observes a `block` decision that names the item; a reply line `Blocked: rotate the deploy key - <what the user must give>` passes.
57
+ - **Open tasks.** Input is a session whose task list still has "rotate the deploy key" pending and a closing reply of "Done.". Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_open_tasks_stop_gate.py -q`. The adjacent test observes a `block` decision that names the item; a reply line `Blocked: rotate the deploy key - <what the user must give>` passes. The same file covers an interrupted ask, a continuation plan list that leaves items out, `Done:` and `Dropped:` lines, and a carried item that a later list marks completed.
58
+ - **Ask recorder.** Input is a `UserPromptSubmit` payload with a work prompt, a question, a `<task-notification>` envelope, and a polite request. Run `python -m pytest packages/claude-dev-env/hooks/session/test_open_task_ask_recorder.py -q`. The adjacent test observes `ask-1` and `ask-2` for the work prompt and the request, no ask for the other two, and empty stdout.
54
59
  - **Claim judge.** Input is a headless `claude -p` session with a stub MCP server named `hearthbot` that exposes `reply` and `ask_decision`, told to send "The code lets any of them rewrite it mid-check." The session settings add the `hooks.json` prompt hook. The stub logs zero bytes sent, and the tool error reads `PreToolUse:mcp__hearthbot__reply hook error: [<the prompt>]: Fact with no source: ...`. The same sentence with a `file:line` in backticks posts. A hook whose `model` Claude Code does not recognize lets every message through, so leave `model` unset. Run `node --test packages/claude-dev-env/bin/install.prompt-hook.test.mjs` for the installer side; it observes one managed prompt hook after two installs and none after uninstall.
55
60
  - **Chat reply.** Input is a `MessageDisplay` event whose message holds four sentences, the same message split over two batches, and a `UserPromptSubmit` event. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_chat_reply_gate.py -q`. The adjacent test observes the drafter's text in `displayContent`, an empty first batch, no output for three sentences, the original after two failed drafts, the shown draft in the next `additionalContext`, and the stored draft deleted at session end.
56
61
  - **Edit marker.** Input is an update message with strikethrough or an edit note. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_edit_marker_gate.py -q`. The adjacent test observes a denial; clean replacement text passes.
@@ -60,6 +65,7 @@ Preconditions:
60
65
  - **Step note.** Input is a tool call after a transcript message without a status line while the flag is on. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_step_note_gate.py -q`. The adjacent test observes a block until a status line appears.
61
66
  - **Checked claim.** Input is a Write after hedged reasoning in the transcript. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_verify_before_acting.py -q`. The adjacent test observes a block and a logged blocked outcome.
62
67
  - **Lifecycle skill.** Input is a `git commit` or `gh pr create` command with no `pr-lifecycle` invocation in the transcript. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_pr_lifecycle_skill_gate.py -q`. The adjacent test observes a denial, then a silent allow once the skill or its slash command appears.
68
+ - **Verify before commit.** Input is `git add app.py; git commit -m change` in a temporary repository with `.agents/skills/verify`, and a transcript with no `verify` load after its last prompt. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_verify_before_commit_gate.py -q`. The adjacent test observes a denial, then a silent allow after a `verify` Skill call or a `/verify` prompt. A load before the last prompt still denies. A docs-only commit, a tests-only commit, and a repository with no verify skill pass.
63
69
  - **Eval write.** Input is a Write to `evals/cases.json` with no build-eval call in the transcript. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_build_eval_before_eval_write_gate.py -q`. The adjacent test observes a denial, then an allow once the call appears; a write outside an eval path passes.
64
70
  - **Clean review.** Input is a merge with no receipt, a receipt with findings, a merge pinned to a newer head, an unpinned merge, or auto-merge with no head. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_merge_review_gate.py -q`. The adjacent test observes a denial for each; a merge or auto-merge pinned to the clean head and a merge in an exempt repository pass.
65
71
  - **Merge offer.** Input is a decision card or AskUserQuestion that offers "Merge now", the merge queue, or a yes to "Merge it now?" while any part of the card names open findings or unaddressed review comments. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_merge_over_review_offer_gate.py -q`. The adjacent test observes a denial for each, including an offer and findings in separate questions; a card with no merge offer, a card about the merge review check itself, a card about shipping a docs change, and a merge offer after a clean review pass.
@@ -68,6 +74,7 @@ Preconditions:
68
74
 
69
75
  ## Gotchas
70
76
 
77
+ - The open tasks gate reads `Done:` and `Dropped:` lines in every assistant text of the transcript, so a close holds for later stops. The ask recorder runs only from `hooks.json`, so Codex and Cursor sessions get no asks.
71
78
  - `blocking/step_note_gate.py` is off by default and polls for the transcript record. Subagent calls and unreadable records pass.
72
79
  - `blocking/verify_before_acting.py` runs after the tool. A block asks the agent to check the claim and undo a contradicted change.
73
80
  - The reply gate counts each nonempty list line as a sentence. Link targets and code spans add no words. Its banned words come from `reply-banned-words.json` in the Claude home, or the file `CLAUDE_REPLY_BANNED_WORDS_PATH` names, with built-in defaults.
@@ -75,6 +82,7 @@ Preconditions:
75
82
  - The edit marker gate scans replacement cards as well as the message text.
76
83
  - The handoff close gate reads the `body` and `comment` fields of issue tools only. A `gh issue close --comment` command in Bash and a pull request body that closes the issue with `Closes #N` do not reach it.
77
84
  - The eval write gate reads Write, Edit, MultiEdit, NotebookEdit and apply_patch calls. A Bash or PowerShell command that writes an eval file reaches it without the gate. An apply_patch that will not parse counts as an eval write when any of its words is an eval path. A missing or unreadable transcript denies the write.
85
+ - The verify gate passes when the transcript is missing or unreadable, or when git cannot resolve the repository. It reads the repository from the payload `cwd` and `git -C`, so a `cd <dir> && git commit` chain is checked against the session directory. A `git commit <pathspec>` that names paths without `git add` is checked against the staged diff only.
78
86
  - The lifecycle gate counts only invocations after the last compaction, so a compacted session invokes `pr-lifecycle` again. A missing or unreadable transcript allows the call.
79
87
  - The dark mode gate denies a page publish when Node, Playwright or Chromium is missing, and its reason names the install command. It checks both dark signals, `prefers-color-scheme: dark` and `data-theme="dark"` on the root element.
80
88
  - The clean-review gate trusts the receipt. A receipt the agent writes by hand with zero findings, or with every finding ruled out and a rulings link, passes, so the pr-lifecycle skill names the review loop that writes it. A `gh pr merge` that names no repository is denied, because the gate cannot tell which repository the current branch belongs to.
package/hooks/hooks.json CHANGED
@@ -102,6 +102,16 @@
102
102
  }
103
103
  ]
104
104
  },
105
+ {
106
+ "matcher": "Bash|PowerShell",
107
+ "hooks": [
108
+ {
109
+ "type": "command",
110
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/verify_before_commit_gate.py",
111
+ "timeout": 30
112
+ }
113
+ ]
114
+ },
105
115
  {
106
116
  "matcher": "Write|Edit|MultiEdit|NotebookEdit|apply_patch",
107
117
  "hooks": [
@@ -314,6 +324,11 @@
314
324
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/chat_reply_gate.py",
315
325
  "timeout": 10
316
326
  },
327
+ {
328
+ "type": "command",
329
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/open_task_ask_recorder.py",
330
+ "timeout": 10
331
+ },
317
332
  {
318
333
  "type": "command",
319
334
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/plugin_change_warning.py",
@@ -79,4 +79,8 @@ ALL_BASH_HOSTED_HOOK_ENTRIES: tuple[BashHostedHookEntry, ...] = (
79
79
  script_relative_path="blocking/cloud_graphql_gate.py",
80
80
  applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
81
81
  ),
82
+ BashHostedHookEntry(
83
+ script_relative_path="blocking/python_windows_invocation_gate.py",
84
+ applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
85
+ ),
82
86
  )
@@ -70,3 +70,53 @@ BLOCK_REASON_SUFFIX = (
70
70
  " 'Blocked: <item> - <what the user must give>' when only the user can unblock it, or"
71
71
  " 'Open: <item> - <the step you take next>' when you are still working it."
72
72
  )
73
+ ASK_CLOSE_SUFFIX = (
74
+ "\nClose a finished ask with a 'Done: #ask-N' line. Close an ask or a carried plan item the user"
75
+ " called off with 'Dropped: <#ask-N or item> - \"<the user's words that called it off>\"'."
76
+ )
77
+ COMPLETED_STATUS = "completed"
78
+ IN_PROGRESS_STATUS = "in_progress"
79
+ ASSISTANT_RECORD_TYPE = "assistant"
80
+ RECORD_TIMESTAMP_KEY = "timestamp"
81
+ UTC_SUFFIX = "Z"
82
+ UTC_OFFSET_TEXT = "+00:00"
83
+ CCD_PLAN_TOOL_NAME = "mcp__ccd_turn__update_plan"
84
+ CCD_PLAN_ITEMS_KEY = "items"
85
+ CCD_PLAN_CONTENT_KEY = "content"
86
+ CCD_PLAN_DONE_KEY = "done"
87
+ CCD_PLAN_START_KEY = "start"
88
+ CCD_PLAN_ADVANCE_KEY = "advance"
89
+ CCD_PLAN_ITEM_ID_TEMPLATE = "plan-{position}"
90
+ ASK_ID_PREFIX = "ask-"
91
+ ASK_ID_TEMPLATE = "ask-{ask_number}"
92
+ ASK_LINE_TEMPLATE = "\n- #{ask_id}: {subject}"
93
+ ASK_SUBJECT_CHARACTER_LIMIT = 120
94
+ ASK_SUBJECT_ELLIPSIS = "..."
95
+ ASKS_DIRECTORY_NAME = "open-task-asks"
96
+ ASKS_FILE_SUFFIX = ".json"
97
+ ASKS_TEMPORARY_PREFIX = ".open-task-asks-"
98
+ ASKS_TEMPORARY_SUFFIX = ".tmp"
99
+ ASKS_PROMPTS_KEY = "prompts"
100
+ ASK_PROMPT_TEXT_KEY = "text"
101
+ ASK_PROMPT_RECORDED_AT_KEY = "recorded_at"
102
+ ASK_PROMPT_ID_KEY = "ask_id"
103
+ PROMPT_KEY = "prompt"
104
+ SAFE_SESSION_ID_PATTERN = r"[\w-]+"
105
+ SENTENCE_SPLIT_PATTERN = r"(?<=[.!?])\s+|\n+"
106
+ QUESTION_END_PATTERN = r"\?\s*$"
107
+ YES_OR_NO_END_PATTERN = r"\byes\s+or\s+no\W*$"
108
+ INTERROGATIVE_START_PATTERN = (
109
+ r"^\s*(?:\w+,\s*)?(?:are|is|am|was|were|do|does|did|have|has|had|can|could|will|would"
110
+ r"|should|shall|may|what|why|how|when|where|which|who|whose)\b"
111
+ )
112
+ ACKNOWLEDGEMENT_PATTERN = (
113
+ r"^\s*(?:ok(?:ay)?|k|thanks?|thank\s+you|thx|ty|cool|great|nice|perfect|awesome|got\s+it"
114
+ r"|sounds\s+good|looks\s+good|lgtm)(?:[\s,]+(?:thanks?|thank\s+you))?\W*$"
115
+ )
116
+ POLITE_REQUEST_START_PATTERN = r"^\s*(?:please\b|(?:can|could|would|will)\s+you\b)"
117
+ DONE_LINE_PATTERN = r"^\s*(?:[-*]\s*)?done\b\s*[:\-]?\s*(.*)$"
118
+ DONE_TEXT_GROUP_INDEX = 1
119
+ ASK_REFERENCE_PATTERN = r"#(ask-\d+)\b"
120
+ DROPPED_LINE_PATTERN = r"^\s*(?:[-*]\s*)?dropped\b\s*[:\-]?\s*(.+?)\s+-\s+[\"“](.+?)[\"”]\s*$"
121
+ DROPPED_TARGET_GROUP_INDEX = 1
122
+ DROPPED_QUOTE_GROUP_INDEX = 2
@@ -0,0 +1,60 @@
1
+ """Constants for the gate that stops two Windows Python invocation failures.
2
+
3
+ Holds the interpreter option letters the option scan reads, the ``-X`` values
4
+ that turn UTF-8 mode on, the Windows ``os.name`` value, the pattern that finds
5
+ a backslash quote inside a ``python -c "..."`` argument, the hook name the
6
+ block log records, and the two deny messages that name the command to run
7
+ instead.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+
14
+ __all__ = [
15
+ "GATE_HOOK_NAME",
16
+ "WINDOWS_OS_NAME",
17
+ "OPTION_PREFIX",
18
+ "END_OF_OPTIONS_MARKER",
19
+ "ISOLATED_MODE_OPTION_LETTER",
20
+ "IMPLEMENTATION_OPTION_LETTER",
21
+ "ALL_VALUE_OPTION_LETTERS",
22
+ "ALL_VALUE_LONG_OPTIONS",
23
+ "ALL_COMMAND_ENDING_OPTION_LETTERS",
24
+ "ALL_UTF8_MODE_OPTION_VALUES",
25
+ "PYTHON_CODE_ARGUMENT_BACKSLASH_QUOTE_PATTERN",
26
+ "ISOLATED_PYTHON_DENY_REASON",
27
+ "POWERSHELL_BACKSLASH_QUOTE_DENY_REASON",
28
+ ]
29
+
30
+ GATE_HOOK_NAME = "python_windows_invocation_gate"
31
+ WINDOWS_OS_NAME = "nt"
32
+
33
+ OPTION_PREFIX = "-"
34
+ END_OF_OPTIONS_MARKER = "--"
35
+ ISOLATED_MODE_OPTION_LETTER = "I"
36
+ IMPLEMENTATION_OPTION_LETTER = "X"
37
+ ALL_VALUE_OPTION_LETTERS: frozenset[str] = frozenset({"X", "W", "c", "m"})
38
+ ALL_VALUE_LONG_OPTIONS: frozenset[str] = frozenset({"--check-hash-based-pycs"})
39
+ ALL_COMMAND_ENDING_OPTION_LETTERS: frozenset[str] = frozenset({"c", "m"})
40
+ ALL_UTF8_MODE_OPTION_VALUES: frozenset[str] = frozenset({"utf8", "utf8=1"})
41
+
42
+ PYTHON_CODE_ARGUMENT_BACKSLASH_QUOTE_PATTERN = re.compile(
43
+ r"(?<![\w.\-])(?:(?:python|pypy)w?[0-9._]*t?|pyw?)(?:\.exe)?"
44
+ r"(?:\s+[^\s\"';|&]+)*?"
45
+ r"\s+-[A-Za-z]*c\s+\"[^\"\n]*\\\"",
46
+ re.IGNORECASE,
47
+ )
48
+
49
+ ISOLATED_PYTHON_DENY_REASON = (
50
+ "BLOCKED: python -I ignores PYTHONUTF8, so on Windows the interpreter writes "
51
+ "with the console code page and non-ASCII output stops with UnicodeEncodeError: "
52
+ "'charmap'. Add -X utf8 after -I, as in python -I -X utf8 script.py."
53
+ )
54
+ POWERSHELL_BACKSLASH_QUOTE_DENY_REASON = (
55
+ 'BLOCKED: PowerShell reads a backslash as a plain character, so \\" inside '
56
+ 'python -c "..." ends the string at that quote and Python gets cut code. '
57
+ "Put the code in a .py file and run the file, or pass it in a single-quoted "
58
+ "here-string: python -c @' on one line, the code on the next lines, and '@ "
59
+ "at the start of its own line."
60
+ )
@@ -12,7 +12,7 @@ ALL_BASH_AND_POWERSHELL_TOOL_NAMES = ALL_CONSTANT_BINDINGS["ALL_BASH_AND_POWERSH
12
12
  BashHostedHookEntry = ALL_CONSTANT_BINDINGS["BashHostedHookEntry"]
13
13
 
14
14
 
15
- def test_roster_hosts_the_four_gates_and_hands_two_to_the_shell_guards_mod() -> None:
15
+ def test_roster_hosts_the_five_gates_and_hands_two_to_the_shell_guards_mod() -> None:
16
16
  assert ALL_BASH_HOSTED_HOOK_ENTRIES == (
17
17
  BashHostedHookEntry(
18
18
  script_relative_path="blocking/msys_rev_path_rewriter.py",
@@ -32,4 +32,8 @@ def test_roster_hosts_the_four_gates_and_hands_two_to_the_shell_guards_mod() ->
32
32
  script_relative_path="blocking/cloud_graphql_gate.py",
33
33
  applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
34
34
  ),
35
+ BashHostedHookEntry(
36
+ script_relative_path="blocking/python_windows_invocation_gate.py",
37
+ applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
38
+ ),
35
39
  )
@@ -6,3 +6,5 @@ SKILL_TOOL_NAME = "Skill"
6
6
  TOOL_USE_BLOCK_TYPE = "tool_use"
7
7
  USER_ENTRY_TYPE = "user"
8
8
  COMMAND_ARGUMENTS_PATTERN = r"<command-args>(.*?)</command-args>"
9
+ META_ENTRY_FIELD = "isMeta"
10
+ TOOL_RESULT_BLOCK_TYPE = "tool_result"
@@ -0,0 +1,54 @@
1
+ """Constants for the verify-before-commit gate."""
2
+
3
+ DENY_REASON = (
4
+ "This repository has a verify skill, and this commit changes code. Invoke the verify "
5
+ "skill with the Skill tool, run the check it routes you to, then run the same command again. "
6
+ "A commit that changes only docs or tests passes without it."
7
+ )
8
+ SKILL_NAME = "verify"
9
+ ALL_SLASH_COMMAND_MARKERS = ("<command-name>/verify</command-name>",)
10
+ ALL_VERIFY_SKILL_DIRECTORIES = (".claude/skills/verify", ".agents/skills/verify")
11
+ COMMIT_ACTION = "commit"
12
+ ADD_ACTION = "add"
13
+ ALL_GIT_EXECUTABLES = frozenset({"git", "git.exe"})
14
+ GIT_DIRECTORY_OPTION = "-C"
15
+ ALL_COMMIT_ALL_OPTIONS = frozenset({"-a", "--all"})
16
+ COMMIT_ALL_SHORT_FLAG = "a"
17
+ ALL_COMMIT_VALUED_SHORT_FLAGS = frozenset("mFCct")
18
+ ALL_ADD_EVERYTHING_OPTIONS = frozenset({"-A", "--all", "-u", "--update"})
19
+ WHOLE_TREE_PATHSPEC = ":/"
20
+ PATHSPEC_TERMINATOR = "--"
21
+ GIT_TIMEOUT_SECONDS = 5
22
+ ALL_TOP_LEVEL_ARGUMENTS = ("rev-parse", "--show-toplevel")
23
+ ALL_STAGED_PATHS_ARGUMENTS = ("diff", "--cached", "--name-only")
24
+ ALL_TRACKED_CHANGE_ARGUMENTS = ("diff", "--name-only")
25
+ ALL_WORKING_TREE_PATHS_ARGUMENTS = (
26
+ "ls-files",
27
+ "--modified",
28
+ "--others",
29
+ "--deleted",
30
+ "--exclude-standard",
31
+ "--full-name",
32
+ "--",
33
+ )
34
+ ALL_DOCS_EXTENSIONS = frozenset(
35
+ {
36
+ ".md",
37
+ ".mdx",
38
+ ".txt",
39
+ ".rst",
40
+ ".adoc",
41
+ ".png",
42
+ ".jpg",
43
+ ".jpeg",
44
+ ".gif",
45
+ ".svg",
46
+ ".webp",
47
+ ".ico",
48
+ }
49
+ )
50
+ ALL_TEST_DIRECTORY_NAMES = frozenset({"tests", "test", "__tests__"})
51
+ ALL_TEST_FILE_PREFIXES = ("test_",)
52
+ ALL_TEST_FILE_SUFFIXES = ("_test.py",)
53
+ ALL_TEST_FILE_INFIXES = (".test.", ".spec.")
54
+ ALL_TEST_FILE_NAMES = frozenset({"conftest.py"})