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,163 @@
1
+ """Per-session record of the user's prompts, read by the open tasks Stop gate.
2
+
3
+ Every prompt lands in one file outside the task store::
4
+
5
+ ~/.claude/open-task-asks/<session id>.json
6
+ {"prompts": [{"text": "...", "recorded_at": 1761076800.0, "ask_id": "ask-1"}, ...]}
7
+
8
+ A prompt that asks for work gets the next ``ask-N`` id. A question-only
9
+ prompt, an acknowledgement such as ``thanks!``, an empty prompt, and a harness envelope such as
10
+ ``<task-notification>`` get ``ask_id`` null, and the gate still reads their
11
+ text when it checks the quote on a ``Dropped:`` line.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import re
19
+ import time
20
+ from pathlib import Path
21
+ from typing import NamedTuple
22
+
23
+ from atomic_file_writer import write_text_atomically
24
+ from hooks_constants.open_tasks_stop_gate_constants import (
25
+ ACKNOWLEDGEMENT_PATTERN,
26
+ ASK_ID_TEMPLATE,
27
+ ASK_PROMPT_ID_KEY,
28
+ ASK_PROMPT_RECORDED_AT_KEY,
29
+ ASK_PROMPT_TEXT_KEY,
30
+ ASKS_DIRECTORY_NAME,
31
+ ASKS_FILE_SUFFIX,
32
+ ASKS_PROMPTS_KEY,
33
+ ASKS_TEMPORARY_PREFIX,
34
+ ASKS_TEMPORARY_SUFFIX,
35
+ CLAUDE_CONFIG_DIRECTORY_ENVIRONMENT_VARIABLE,
36
+ CLAUDE_HOME_DIRECTORY_NAME,
37
+ INTERROGATIVE_START_PATTERN,
38
+ POLITE_REQUEST_START_PATTERN,
39
+ QUESTION_END_PATTERN,
40
+ SAFE_SESSION_ID_PATTERN,
41
+ SENTENCE_SPLIT_PATTERN,
42
+ UTF8_ENCODING,
43
+ YES_OR_NO_END_PATTERN,
44
+ )
45
+ from hooks_constants.spawn_readiness_hook_constants import ALL_HARNESS_ENVELOPE_MARKERS
46
+
47
+
48
+ class RecordedPrompt(NamedTuple):
49
+ """One prompt the user sent.
50
+
51
+ Attributes:
52
+ text: The prompt as the user sent it.
53
+ recorded_at: Seconds since the epoch when the recorder saw it.
54
+ ask_id: ``ask-N`` for a prompt that asks for work, or None.
55
+ """
56
+
57
+ text: str
58
+ recorded_at: float
59
+ ask_id: str | None
60
+
61
+
62
+ def claude_home() -> Path:
63
+ """Return the Claude home directory, honoring ``CLAUDE_CONFIG_DIR``."""
64
+ configured_directory = os.environ.get(CLAUDE_CONFIG_DIRECTORY_ENVIRONMENT_VARIABLE)
65
+ if configured_directory:
66
+ return Path(configured_directory)
67
+ return Path.home() / CLAUDE_HOME_DIRECTORY_NAME
68
+
69
+
70
+ def _asks_file(session_id: object) -> Path | None:
71
+ if not isinstance(session_id, str) or not re.fullmatch(SAFE_SESSION_ID_PATTERN, session_id):
72
+ return None
73
+ return claude_home() / ASKS_DIRECTORY_NAME / (session_id + ASKS_FILE_SUFFIX)
74
+
75
+
76
+ def is_question_only(prompt_text: str) -> bool:
77
+ """Return True when the prompt asks a question and asks for no work.
78
+
79
+ ::
80
+
81
+ "did the export fail?" -> True
82
+ "are you monitoring these? yes or no" -> True
83
+ "can you check the release dates in neon?" -> False
84
+ "Export the APKs and submit them" -> False
85
+ """
86
+ all_sentences = [
87
+ each_part.strip()
88
+ for each_part in re.split(SENTENCE_SPLIT_PATTERN, prompt_text.strip())
89
+ if each_part.strip()
90
+ ]
91
+ if not all_sentences or re.search(POLITE_REQUEST_START_PATTERN, prompt_text, re.IGNORECASE):
92
+ return False
93
+ if all(re.search(QUESTION_END_PATTERN, each_sentence) for each_sentence in all_sentences):
94
+ return True
95
+ return bool(
96
+ re.search(YES_OR_NO_END_PATTERN, all_sentences[-1], re.IGNORECASE)
97
+ and re.search(INTERROGATIVE_START_PATTERN, all_sentences[0], re.IGNORECASE)
98
+ )
99
+
100
+
101
+ def is_ask(prompt_text: str) -> bool:
102
+ """Return True when the prompt asks for work the gate should hold the turn for."""
103
+ stripped_text = prompt_text.strip()
104
+ if not stripped_text or stripped_text.startswith(ALL_HARNESS_ENVELOPE_MARKERS):
105
+ return False
106
+ if re.fullmatch(ACKNOWLEDGEMENT_PATTERN, stripped_text, re.IGNORECASE):
107
+ return False
108
+ return not is_question_only(stripped_text)
109
+
110
+
111
+ def recorded_prompts(session_id: object) -> list[RecordedPrompt]:
112
+ """Return every prompt recorded for the session, oldest first."""
113
+ asks_file = _asks_file(session_id)
114
+ if asks_file is None:
115
+ return []
116
+ try:
117
+ all_file_fields = json.loads(asks_file.read_text(encoding=UTF8_ENCODING))
118
+ except (OSError, ValueError):
119
+ return []
120
+ all_entries = (
121
+ all_file_fields.get(ASKS_PROMPTS_KEY) if isinstance(all_file_fields, dict) else None
122
+ )
123
+ if not isinstance(all_entries, list):
124
+ return []
125
+ return [
126
+ _parsed_prompt(each_entry) for each_entry in all_entries if isinstance(each_entry, dict)
127
+ ]
128
+
129
+
130
+ def _parsed_prompt(all_entry_fields: dict[str, object]) -> RecordedPrompt:
131
+ recorded_at = all_entry_fields.get(ASK_PROMPT_RECORDED_AT_KEY)
132
+ ask_id = all_entry_fields.get(ASK_PROMPT_ID_KEY)
133
+ return RecordedPrompt(
134
+ str(all_entry_fields.get(ASK_PROMPT_TEXT_KEY, "")),
135
+ float(recorded_at) if isinstance(recorded_at, (int, float)) else 0.0,
136
+ ask_id if isinstance(ask_id, str) else None,
137
+ )
138
+
139
+
140
+ def record_prompt(session_id: object, prompt_text: str) -> str | None:
141
+ """Append the prompt to the session's file and return its ``ask-N`` id, or None for no ask."""
142
+ asks_file = _asks_file(session_id)
143
+ if asks_file is None:
144
+ return None
145
+ all_prompts = recorded_prompts(session_id)
146
+ ask_count = sum(1 for each_prompt in all_prompts if each_prompt.ask_id)
147
+ ask_id = ASK_ID_TEMPLATE.format(ask_number=ask_count + 1) if is_ask(prompt_text) else None
148
+ all_prompts.append(RecordedPrompt(prompt_text, time.time(), ask_id))
149
+ serialized_text = json.dumps(
150
+ {ASKS_PROMPTS_KEY: [each_prompt._asdict() for each_prompt in all_prompts]}
151
+ )
152
+ try:
153
+ write_text_atomically(
154
+ asks_file,
155
+ serialized_text,
156
+ UTF8_ENCODING,
157
+ ASKS_TEMPORARY_PREFIX,
158
+ ASKS_TEMPORARY_SUFFIX,
159
+ False,
160
+ )
161
+ except OSError:
162
+ return None
163
+ return ask_id
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env python3
2
+ """UserPromptSubmit hook that records each prompt for the open tasks Stop gate.
3
+
4
+ A prompt that asks for work becomes ``#ask-N`` in
5
+ ``~/.claude/open-task-asks/<session id>.json``. The gate then holds the turn
6
+ until a reply closes it with ``Done: #ask-N``, or with
7
+ ``Dropped: #ask-N - "<the user's words>"`` quoting a later prompt. The hook
8
+ prints nothing and always exits 0.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ hooks_root_directory = str(Path(__file__).resolve().parent.parent)
17
+ if hooks_root_directory not in sys.path:
18
+ sys.path.insert(0, hooks_root_directory)
19
+
20
+ from hooks_constants.open_tasks_stop_gate_constants import (
21
+ ALLOW_EXIT_CODE,
22
+ PROMPT_KEY,
23
+ SESSION_ID_KEY,
24
+ )
25
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
26
+ from open_task_asks import record_prompt
27
+
28
+
29
+ def main() -> int:
30
+ hook_input = read_hook_input_dictionary_from_stdin()
31
+ prompt_value = hook_input.get(PROMPT_KEY) if hook_input is not None else None
32
+ if hook_input is not None and isinstance(prompt_value, str):
33
+ record_prompt(hook_input.get(SESSION_ID_KEY), prompt_value)
34
+ return ALLOW_EXIT_CODE
35
+
36
+
37
+ if __name__ == "__main__":
38
+ sys.exit(main())
@@ -0,0 +1,57 @@
1
+ import json
2
+ import subprocess
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ RECORDER_PATH = Path(__file__).resolve().parent / "open_task_ask_recorder.py"
7
+ SESSION_ID = "s-123"
8
+
9
+
10
+ def submit_prompt(home_directory: Path, prompt_text: str) -> subprocess.CompletedProcess[str]:
11
+ hook_input = {
12
+ "hook_event_name": "UserPromptSubmit",
13
+ "session_id": SESSION_ID,
14
+ "prompt": prompt_text,
15
+ }
16
+ return subprocess.run(
17
+ [sys.executable, str(RECORDER_PATH)],
18
+ input=json.dumps(hook_input),
19
+ capture_output=True,
20
+ text=True,
21
+ env={
22
+ "HOME": str(home_directory),
23
+ "USERPROFILE": str(home_directory),
24
+ "PATH": "",
25
+ },
26
+ check=False,
27
+ )
28
+
29
+
30
+ def recorded_ask_ids(home_directory: Path) -> list[str]:
31
+ asks_path = home_directory / ".claude" / "open-task-asks" / f"{SESSION_ID}.json"
32
+ all_prompts = json.loads(asks_path.read_text(encoding="utf-8"))["prompts"]
33
+ return [each_prompt["ask_id"] for each_prompt in all_prompts if each_prompt["ask_id"]]
34
+
35
+
36
+ def test_should_record_each_ask_outside_the_task_store_and_stay_silent(
37
+ tmp_path: Path,
38
+ ) -> None:
39
+ first_process = submit_prompt(tmp_path, "Export the OneUI8 and OneUI9 APKs and submit them")
40
+ submit_prompt(tmp_path, "did the export fail?")
41
+ submit_prompt(tmp_path, "<task-notification>\n<task-id>b1</task-id>\n</task-notification>")
42
+ submit_prompt(tmp_path, "can you check the release dates in neon?")
43
+ assert (first_process.returncode, first_process.stdout) == (0, "")
44
+ assert recorded_ask_ids(tmp_path) == ["ask-1", "ask-2"]
45
+ assert not (tmp_path / ".claude" / "tasks").exists()
46
+
47
+
48
+ def test_should_allow_malformed_input(tmp_path: Path) -> None:
49
+ completed_process = subprocess.run(
50
+ [sys.executable, str(RECORDER_PATH)],
51
+ input="not json",
52
+ capture_output=True,
53
+ text=True,
54
+ env={"HOME": str(tmp_path), "USERPROFILE": str(tmp_path), "PATH": ""},
55
+ check=False,
56
+ )
57
+ assert (completed_process.returncode, completed_process.stdout) == (0, "")
@@ -0,0 +1,52 @@
1
+ from pathlib import Path
2
+
3
+ import pytest
4
+
5
+ from open_task_asks import is_ask, record_prompt, recorded_prompts
6
+
7
+
8
+ @pytest.fixture(autouse=True)
9
+ def isolated_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
10
+ monkeypatch.setenv("HOME", str(tmp_path))
11
+ monkeypatch.setenv("USERPROFILE", str(tmp_path))
12
+ monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False)
13
+
14
+
15
+ @pytest.mark.parametrize(
16
+ ("prompt_text", "is_expected_ask"),
17
+ [
18
+ ("Export the OneUI8 and OneUI9 APKs and submit them", True),
19
+ ("can you check the release dates in neon?", True),
20
+ (
21
+ "set the 8 to ready to export in neon\n\ni dont think you're correct in none are released?",
22
+ True,
23
+ ),
24
+ ("wait, did /deepstack-mode fail?", False),
25
+ ("are you monitoring these? yes or no", False),
26
+ ("<task-notification>\n<task-id>b1</task-id>", False),
27
+ (" ", False),
28
+ ("thanks!", False),
29
+ ("ok, thanks", False),
30
+ ("Looks good", False),
31
+ ("ok, ship it", True),
32
+ ("yes", True),
33
+ ],
34
+ )
35
+ def test_should_classify_work_prompts_as_asks(prompt_text: str, is_expected_ask: bool) -> None:
36
+ assert is_ask(prompt_text) is is_expected_ask
37
+
38
+
39
+ def test_should_number_asks_and_keep_every_prompt_in_order() -> None:
40
+ assert record_prompt("s-1", "Export the APKs") == "ask-1"
41
+ assert record_prompt("s-1", "did it fail?") is None
42
+ assert record_prompt("s-1", "Submit them") == "ask-2"
43
+ assert [each_prompt.ask_id for each_prompt in recorded_prompts("s-1")] == [
44
+ "ask-1",
45
+ None,
46
+ "ask-2",
47
+ ]
48
+
49
+
50
+ def test_should_skip_a_session_id_that_is_not_a_plain_name() -> None:
51
+ assert record_prompt("../escape", "Export the APKs") is None
52
+ assert recorded_prompts("../escape") == []
@@ -11,6 +11,7 @@ if str(HOOKS_DIRECTORY) not in sys.path:
11
11
  from transcript_skill_scan import (
12
12
  invokes_skill_with_argument,
13
13
  is_skill_loaded_after_last_compaction,
14
+ is_skill_loaded_since_last_prompt,
14
15
  skill_invocation_status,
15
16
  )
16
17
 
@@ -134,3 +135,26 @@ def test_build_eval_invocation_before_a_compact_boundary_should_still_count() ->
134
135
  _skill_entry("other"),
135
136
  ]
136
137
  assert _invokes_build_eval(lines)
138
+
139
+
140
+ def _prompt_entry(content: object) -> str:
141
+ return json.dumps({"type": "user", "message": {"content": content}})
142
+
143
+
144
+ def test_skill_since_last_prompt_survives_tool_results_and_skill_bodies() -> None:
145
+ tool_result = _prompt_entry([{"type": "tool_result", "content": "Launching skill: verify"}])
146
+ skill_body = json.dumps({"type": "user", "isMeta": True, "message": {"content": "Base directory for this skill"}})
147
+ lines = [_prompt_entry("Commit it."), _skill_entry("verify"), tool_result, skill_body]
148
+ assert is_skill_loaded_since_last_prompt(lines, ("verify",), ())
149
+
150
+
151
+ def test_next_prompt_drops_a_skill_loaded_in_the_last_turn() -> None:
152
+ lines = [_prompt_entry("First."), _skill_entry("verify"), _prompt_entry([{"type": "text", "text": "Second."}])]
153
+ assert not is_skill_loaded_since_last_prompt(lines, ("verify",), ())
154
+
155
+
156
+ def test_slash_command_prompt_loads_the_skill_for_its_own_turn() -> None:
157
+ marker = "<command-name>/verify</command-name>"
158
+ assert is_skill_loaded_since_last_prompt([_prompt_entry("First."), _prompt_entry(marker)], ("verify",), (marker,))
159
+ compaction = json.dumps({"subtype": "compact_boundary"})
160
+ assert not is_skill_loaded_since_last_prompt([_prompt_entry(marker), compaction], ("verify",), (marker,))
@@ -10,7 +10,9 @@ from hooks_constants.transcript_skill_scan_constants import (
10
10
  ASSISTANT_ENTRY_TYPE,
11
11
  COMMAND_ARGUMENTS_PATTERN,
12
12
  COMPACT_BOUNDARY_SUBTYPE,
13
+ META_ENTRY_FIELD,
13
14
  SKILL_TOOL_NAME,
15
+ TOOL_RESULT_BLOCK_TYPE,
14
16
  TOOL_USE_BLOCK_TYPE,
15
17
  USER_ENTRY_TYPE,
16
18
  )
@@ -87,9 +89,7 @@ def _invokes_skill_with_argument(
87
89
  )
88
90
 
89
91
 
90
- def _relevant_entry(each_line: str, skill_names: Collection[str]) -> dict[str, object] | None:
91
- if not any(name in each_line for name in skill_names) and COMPACT_BOUNDARY_SUBTYPE not in each_line:
92
- return None
92
+ def _parsed_entry(each_line: str) -> dict[str, object] | None:
93
93
  try:
94
94
  all_entry_fields = json.loads(each_line)
95
95
  except json.JSONDecodeError:
@@ -97,6 +97,12 @@ def _relevant_entry(each_line: str, skill_names: Collection[str]) -> dict[str, o
97
97
  return all_entry_fields if isinstance(all_entry_fields, dict) else None
98
98
 
99
99
 
100
+ def _relevant_entry(each_line: str, skill_names: Collection[str]) -> dict[str, object] | None:
101
+ if not any(name in each_line for name in skill_names) and COMPACT_BOUNDARY_SUBTYPE not in each_line:
102
+ return None
103
+ return _parsed_entry(each_line)
104
+
105
+
100
106
  def skill_invocation_status(
101
107
  all_transcript_lines: Iterable[str],
102
108
  skill_names: Collection[str],
@@ -140,6 +146,51 @@ def is_skill_loaded_after_last_compaction(
140
146
  return skill_invocation_status(all_transcript_lines, skill_names, slash_command_markers)[1]
141
147
 
142
148
 
149
+ def _is_user_prompt(all_entry_fields: dict[str, object]) -> bool:
150
+ message = all_entry_fields.get("message")
151
+ if all_entry_fields.get("type") != USER_ENTRY_TYPE or all_entry_fields.get(META_ENTRY_FIELD):
152
+ return False
153
+ content = message.get("content") if isinstance(message, dict) else None
154
+ if isinstance(content, str):
155
+ return True
156
+ return isinstance(content, list) and not any(
157
+ isinstance(each_block, dict) and each_block.get("type") == TOOL_RESULT_BLOCK_TYPE
158
+ for each_block in content
159
+ )
160
+
161
+
162
+ def is_skill_loaded_since_last_prompt(
163
+ all_transcript_lines: Iterable[str],
164
+ skill_names: Collection[str],
165
+ slash_command_markers: Collection[str],
166
+ ) -> bool:
167
+ """Return whether a matching invocation follows the last user prompt and compact boundary.
168
+
169
+ ::
170
+
171
+ prompt, Skill verify, tool result, skill body -> True
172
+ prompt, Skill verify, prompt -> False
173
+ prompt carrying the /verify command marker -> True
174
+ prompt with the marker, compact boundary -> False
175
+
176
+ A tool result and a meta entry, such as the skill body, leave the turn open.
177
+
178
+ Args:
179
+ all_transcript_lines: JSON transcript entries, one per line.
180
+ skill_names: Accepted skill names, with plugin prefixes accepted.
181
+ slash_command_markers: Accepted user command tags.
182
+ """
183
+ loaded = False
184
+ for each_line in all_transcript_lines:
185
+ all_entry_fields = _parsed_entry(each_line)
186
+ if all_entry_fields is None:
187
+ continue
188
+ if all_entry_fields.get("subtype") == COMPACT_BOUNDARY_SUBTYPE or _is_user_prompt(all_entry_fields):
189
+ loaded = False
190
+ loaded = loaded or _invokes_skill(all_entry_fields, skill_names, slash_command_markers)
191
+ return loaded
192
+
193
+
143
194
  def invokes_skill_with_argument(
144
195
  all_transcript_lines: Iterable[str],
145
196
  skill_names: Collection[str],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.97.0",
3
+ "version": "8.98.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -128,6 +128,7 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
128
128
 
129
129
  hooks/blocking/bash_pre_tool_use_dispatcher.py -> exempt
130
130
  hooks/blocking/pr_lifecycle_skill_gate.py -> exempt
131
+ hooks/blocking/verify_before_commit_gate.py -> exempt
131
132
  hooks/blocking/step_note_gate.py -> exempt
132
133
  hooks/blocking/reply_length_gate.py -> exempt
133
134
  hooks/blocking/chat_reply_gate.py -> exempt
@@ -155,6 +156,10 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
155
156
  a second open ``Follow-up to #N`` pull request for one parent, a delivery
156
157
  rule that names the open follow-up to extend.
157
158
 
159
+ The verify-before-commit gate denies a code commit in a repository with a
160
+ verify skill until the session loads that skill in the current turn. It
161
+ decides when a skill loads and no code or safety policy.
162
+
158
163
  The step-note gate allows every call until the user runs ``/step-notes on``.
159
164
  It asks for a readable status line and decides no code or safety policy.
160
165
 
@@ -64,6 +64,7 @@ ALL_ACTION_BOUNDARY_PREFIXES = ("deny_", "block_", "ask_")
64
64
  ALL_ACTION_BOUNDARY_EXEMPT_REGISTRATION_PATHS = (
65
65
  "blocking/bash_pre_tool_use_dispatcher.py",
66
66
  "blocking/pr_lifecycle_skill_gate.py",
67
+ "blocking/verify_before_commit_gate.py",
67
68
  "blocking/merge_review_gate.py",
68
69
  "blocking/step_note_gate.py",
69
70
  "blocking/reply_length_gate.py",
@@ -170,6 +170,14 @@ def test_new_eval_write_gate_registration_is_clean(tmp_path: Path) -> None:
170
170
  assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
171
171
 
172
172
 
173
+ def test_new_verify_before_commit_gate_registration_is_clean(tmp_path: Path) -> None:
174
+ current_document = _hook_document(
175
+ ["hooks/blocking/verify_before_commit_gate.py"],
176
+ '{"hooks": {}}',
177
+ )
178
+ assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
179
+
180
+
173
181
  def test_new_blocker_in_staged_change_is_rejected(tmp_path: Path) -> None:
174
182
  current_document = _hook_document(
175
183
  ["hooks/blocking/new.py"],
package/settings.json CHANGED
@@ -24,6 +24,7 @@
24
24
  ]
25
25
  },
26
26
  "env": {
27
- "CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS": "100000"
27
+ "CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS": "100000",
28
+ "PYTHONUTF8": "1"
28
29
  }
29
30
  }