claude-dev-env 8.80.0 → 8.82.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 (27) hide show
  1. package/hooks/blocking/final_reply_gate.py +77 -0
  2. package/hooks/blocking/pr_lifecycle_skill_gate.py +13 -3
  3. package/hooks/blocking/pull_request_proof.py +42 -13
  4. package/hooks/blocking/pull_request_proof_edit_gate.py +180 -0
  5. package/hooks/blocking/reply_length_gate.py +114 -17
  6. package/hooks/blocking/test_final_reply_gate.py +94 -0
  7. package/hooks/blocking/test_pr_lifecycle_skill_gate.py +8 -0
  8. package/hooks/blocking/test_pre_tool_use_dispatcher.py +1 -0
  9. package/hooks/blocking/test_pull_request_proof.py +7 -0
  10. package/hooks/blocking/test_pull_request_proof_edit_gate.py +199 -0
  11. package/hooks/blocking/test_reply_length_gate.py +208 -0
  12. package/hooks/features/conduct-gates.md +7 -3
  13. package/hooks/features/write-edit-blocking.md +4 -1
  14. package/hooks/hooks.json +13 -2
  15. package/hooks/hooks_constants/final_reply_gate_constants.py +10 -0
  16. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  17. package/hooks/hooks_constants/pull_request_proof_constants.py +22 -3
  18. package/hooks/hooks_constants/reply_length_gate_constants.py +56 -1
  19. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +9 -6
  20. package/hooks/hooks_constants/test_reply_length_gate_constants.py +2 -2
  21. package/hooks/hooks_constants/visual_reply_rules_constants.py +2 -0
  22. package/hooks/test_visual_reply_rules.py +21 -0
  23. package/hooks/visual_reply_rules.py +37 -5
  24. package/package.json +1 -1
  25. package/scripts/policy_lint/adapter_configuration.py +1 -0
  26. package/scripts/policy_lint/config/constants.py +1 -0
  27. package/scripts/tests/test_adapter_configuration.py +8 -0
package/hooks/hooks.json CHANGED
@@ -133,7 +133,7 @@
133
133
  ]
134
134
  },
135
135
  {
136
- "matcher": "mcp__hearthbot__reply|mcp__hearthbot__post_message|mcp__hearthbot__ask_decision|mcp__hearthbot__post_widget",
136
+ "matcher": "mcp__hearthbot__reply|mcp__hearthbot__post_message|mcp__hearthbot__update_message|mcp__hearthbot__ask_decision|AskUserQuestion|mcp__hearthbot__post_widget",
137
137
  "hooks": [
138
138
  {
139
139
  "type": "command",
@@ -147,7 +147,7 @@
147
147
  "hooks": [
148
148
  {
149
149
  "type": "prompt",
150
- "prompt": "You check one chat message before it posts. The hook input JSON is: $ARGUMENTS\n\nRead the message prose in tool_input: the text, body, question, context, reason, and option fields. Find each claim the message states as fact about how code, a file, a tool, a service, a run, or a process behaves, what state it is in, or why something happened.\n\nA claim passes when the message carries evidence that shows it: inline code that quotes output, an exit code, a count, a log line, or a code line; a file:line reference; or a URL or markdown link to the run, file, pull request, or page. A pull request or commit link covers what that change does, what it blocks or lets through, and the state of its checks, also in the later sentences about that same change. The evidence may sit in another sentence of the same message when it covers that claim. A link to one thing does not cover a claim about something else.\n\nThese pass with no evidence: the author's own actions, done, in progress, or planned, with or without \"I\", including what the action is aimed at, such as \"I'm finding where the check failed\"; a change the author made; a question; an instruction or request; a restatement of what the user said; a claim labeled as not yet checked; and an option's consequence on a decision card.\n\nWhen every claim passes, return {\"ok\": true}. Otherwise return {\"ok\": false, \"reason\": \"Fact with no source: <the first failing claim>. Put the file:line, the quoted output, or the link that shows it in the message, or say you have not checked it.\"}",
150
+ "prompt": "You check one chat message before it posts. The hook input JSON is: $ARGUMENTS\n\nRead the message prose in tool_input: the text, body, question, context, reason, and option fields. Find each claim the message states as fact about how code, a file, a tool, a service, a run, or a process behaves, what state it is in, or why something happened.\n\nA claim passes when the message carries evidence that shows it: inline code that quotes output, an exit code, a count, a log line, or a code line; a file:line reference; or a URL or markdown link to the run, file, pull request, or page. A pull request or commit link covers what that change does, what it blocks or lets through, and the state of its checks, also in the later sentences about that same change. The evidence may sit in another sentence of the same message when it covers that claim. A link to one thing does not cover a claim about something else.\n\nThese pass with no evidence: the author's own actions, done, in progress, or planned, with or without \"I\", including what the action is aimed at, such as \"I'm finding where the check failed\"; a change the author made; a question; an instruction or request; a restatement of what the user said; and an option's consequence on a decision card.\n\nWhen every claim passes, return {\"ok\": true}. Otherwise return {\"ok\": false, \"reason\": \"Fact with no source: <the first failing claim>. Put the file:line, the quoted output, or the link that shows it in the message, run the check first, or leave the claim out.\"}",
151
151
  "timeout": 30,
152
152
  "continueOnBlock": true,
153
153
  "statusMessage": "claude-dev-env: checking each claim for its source"
@@ -347,6 +347,17 @@
347
347
  ]
348
348
  }
349
349
  ],
350
+ "Stop": [
351
+ {
352
+ "hooks": [
353
+ {
354
+ "type": "command",
355
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/final_reply_gate.py",
356
+ "timeout": 10
357
+ }
358
+ ]
359
+ }
360
+ ],
350
361
  "SessionEnd": [
351
362
  {
352
363
  "matcher": "",
@@ -0,0 +1,10 @@
1
+ """Constants for the final reply gate Stop hook."""
2
+
3
+ HOOK_EVENT_NAME = "Stop"
4
+ LAST_ASSISTANT_MESSAGE_KEY = "last_assistant_message"
5
+ TRANSCRIPT_PATH_KEY = "transcript_path"
6
+ DECISION_KEY = "decision"
7
+ BLOCK_DECISION = "block"
8
+ REASON_KEY = "reason"
9
+ ALLOW_EXIT_CODE = 0
10
+ REWRITE_INSTRUCTION = " Rewrite the reply without it."
@@ -87,4 +87,8 @@ ALL_HOSTED_HOOK_ENTRIES: tuple[HostedHookEntry, ...] = (
87
87
  script_relative_path="blocking/context_budget_blocker.py",
88
88
  applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES,
89
89
  ),
90
+ HostedHookEntry(
91
+ script_relative_path="blocking/pull_request_proof_edit_gate.py",
92
+ applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES,
93
+ ),
90
94
  )
@@ -59,9 +59,10 @@ MISSING_LOOK_REASON = (
59
59
  CHANGED_FILES_SEPARATOR = ", "
60
60
  UNREADABLE_CHANGES_REASON = (
61
61
  "Git in this directory cannot list the files this pull request changes, so the"
62
- " picture check cannot tell whether people see the change. Run the create call"
63
- " from a checkout of the head branch with origin fetched, or put a screenshot"
64
- " link in the 'Proof in practice' section. Read " + PROOF_GUIDE_PATH + " for the full rule."
62
+ " picture check cannot tell whether people see the change. Put a screenshot or"
63
+ " Artifact link in the 'Proof in practice' section and run the create call again,"
64
+ " or run it from a checkout of the head branch with origin fetched. Take one of"
65
+ " these routes now, before any change to this gate. Read " + PROOF_GUIDE_PATH + " for the full rule."
65
66
  )
66
67
  FEATURE_TITLE_PATTERN = r"^\s*feat(\([^)]*\))?!?:"
67
68
  EVAL_HEADING_PATTERN = r"^(#{1,6})[ \t]+evals?[ \t]*#*[ \t]*$"
@@ -83,3 +84,21 @@ MISSING_BUILD_EVAL_REASON = (
83
84
  + EVAL_GUIDE_PATH
84
85
  + " for the full rule."
85
86
  )
87
+ ALL_PROOF_GATE_DENY_MARKERS = (PROOF_GUIDE_PATH, EXISTING_WORK_GUIDE_PATH, EVAL_GUIDE_PATH)
88
+ ALL_PROOF_GATE_FILE_NAMES = frozenset(
89
+ {
90
+ "pull_request_proof.py",
91
+ "pull_request_proof_constants.py",
92
+ "pull_request_proof_edit_gate.py",
93
+ "pre_tool_use_dispatcher_constants.py",
94
+ }
95
+ )
96
+ GATE_EDIT_BEFORE_ROUTE_REASON = (
97
+ "The pull request proof gate denied a create call in this session, and its deny"
98
+ " named the way through. Take that route now. Add what the deny asked for, such as"
99
+ " a screenshot or Artifact link in the 'Proof in practice' section, then run the"
100
+ " create call again. The deny text is the instruction. Edit this gate only"
101
+ " after a create call passes it. Read " + PROOF_GUIDE_PATH + " for the full rule."
102
+ )
103
+ WINDOWS_PATH_SEPARATOR = "\\"
104
+ POSIX_PATH_SEPARATOR = "/"
@@ -4,12 +4,18 @@ import re
4
4
 
5
5
  MAXIMUM_SENTENCE_COUNT = 3
6
6
  MAXIMUM_WORDS_PER_SENTENCE = 15
7
- ALL_CHECKED_TOOL_NAMES = frozenset({"mcp__hearthbot__reply", "mcp__hearthbot__post_message"})
7
+ ALL_CHECKED_TOOL_NAMES = frozenset(
8
+ {"mcp__hearthbot__reply", "mcp__hearthbot__post_message", "mcp__hearthbot__update_message"}
9
+ )
8
10
  DECISION_CARD_TOOL_NAME = "mcp__hearthbot__ask_decision"
9
11
  CARD_TEXT_SEPARATOR = "\n"
10
12
  ALL_DECISION_CARD_PROSE_KEYS = ("question", "context")
11
13
  DECISION_CARD_OPTIONS_KEY = "options"
12
14
  ALL_DECISION_OPTION_PROSE_KEYS = ("label", "consequence")
15
+ QUESTION_CARD_TOOL_NAME = "AskUserQuestion"
16
+ QUESTION_CARD_QUESTIONS_KEY = "questions"
17
+ ALL_QUESTION_CARD_PROSE_KEYS = ("question", "header")
18
+ ALL_QUESTION_OPTION_PROSE_KEYS = ("label", "description")
13
19
  TOOL_NAME_KEY = "tool_name"
14
20
  TOOL_INPUT_KEY = "tool_input"
15
21
  TEXT_KEY = "text"
@@ -88,5 +94,54 @@ BANNED_WORD_MESSAGE = 'Banned word "{banned_word}". Delete it and name the evide
88
94
  LONG_SENTENCE_MESSAGE = (
89
95
  'Sentence too long: {word_count} words, limit {word_limit}: "{sentence_preview}".'
90
96
  )
97
+ CURLY_APOSTROPHE = "\u2019"
98
+ STRAIGHT_APOSTROPHE = "'"
99
+ QUANTITY_PATTERN = (
100
+ r"(?:\d+(?:\.\d+)?|an?|one|two|three|four|five|six|seven|eight|nine|ten"
101
+ r"|eleven|twelve|(?:thir|four|fif|six|seven|eigh|nine)teen"
102
+ r"|twenty|thirty|forty(?:-five)?|fifty|sixty|seventy|eighty|ninety"
103
+ r"|(?:an?\s+|one\s+)?hundred|half an?|a couple(?: of)?|a few|several)"
104
+ )
105
+ DURATION_UNIT_PATTERN = r"(?:hours?|hrs?|minutes?|mins?|seconds?|secs?|days?|weeks?)"
106
+ NEGATION_PATTERN = (
107
+ r"(?:(?:did|have|has|could|was|were|is|are)(?:\s+not|n't)|can(?:not|'t)|not\s+yet|never)"
108
+ )
109
+ FIRST_PERSON_PATTERN = r"(?:i|we)"
110
+ CHECK_VERB_PATTERN = r"(?:confirm|verif(?:y|ied)|validate|check|test)(?:ed)?"
111
+ CHECKED_PARTICIPLE_PATTERN = r"(?:confirmed|verified|validated|tested|checked(?!\s+(?:in|out)\b))"
112
+ PASSIVE_NEGATION_PATTERN = (
113
+ r"(?:(?:has|have)(?:\s+not|n't)(?:\s+yet)?\s+been"
114
+ r"|(?:was|were|is|are)(?:\s+not|n't)(?:\s+yet)?(?:\s+been)?"
115
+ r"|can(?:not|'t)\s+be)"
116
+ )
117
+ FILLER_WORDS_PATTERN = r"(?:(?!(?:a|an|the|what|which|something|anything)\b)\w+\s+){0,2}"
118
+ UNVALIDATED_CLAIM_PATTERN = re.compile(
119
+ r"\b(?:guess(?:es|ed|ing)?|estimat(?:e|es|ed|ing)|eta|ballpark"
120
+ r"|assum(?:e|es|ed|ing)|i think|i expect"
121
+ r"|un(?:confirmed|verified|validated|checked|tested))\b"
122
+ rf"|\b{FIRST_PERSON_PATTERN}\s+{NEGATION_PATTERN}\s+{FILLER_WORDS_PATTERN}{CHECK_VERB_PATTERN}\b"
123
+ rf"|\b{FIRST_PERSON_PATTERN}\s+{NEGATION_PATTERN}\s+{FILLER_WORDS_PATTERN}run\b(?!\s+(?:into|out)\b)"
124
+ rf"|\b{PASSIVE_NEGATION_PATTERN}\s+(?:{CHECKED_PARTICIPLE_PATTERN}|run)\b",
125
+ re.IGNORECASE,
126
+ )
127
+ DURATION_CLAIM_PATTERN = re.compile(
128
+ rf"\btakes?\s+(?:about\s+)?{QUANTITY_PATTERN}\s+{DURATION_UNIT_PATTERN}\b"
129
+ rf"|\b{QUANTITY_PATTERN}\s+(?:more\s+)?{DURATION_UNIT_PATTERN}\s+(?:left|remaining|to go)\b"
130
+ rf"|\b(?:about|around|approximately|roughly|another)\s+{QUANTITY_PATTERN}\s+{DURATION_UNIT_PATTERN}\b"
131
+ rf"|\b{QUANTITY_PATTERN}\s*(?:to|-|or)\s*{QUANTITY_PATTERN}\s+{DURATION_UNIT_PATTERN}\b",
132
+ re.IGNORECASE,
133
+ )
134
+ UNVALIDATED_CLAIM_MESSAGE = (
135
+ 'No tool validated this: "{claim}". Run the check yourself or through a subagent,'
136
+ " then state what the run, log line, output, or file and line shows, or leave the claim out."
137
+ )
138
+ CITED_NUMBER_PATTERN = re.compile(
139
+ r"/actions/runs/(\d+)|/pull/(\d+)|\b(?:run|job|check)\s+#?(\d{5,})\b", re.IGNORECASE
140
+ )
141
+ CITED_NUMBER_IN_RESULT_TEMPLATE = r"(?<!\d){number}(?!\d)"
142
+ UNSEEN_CITATION_MESSAGE = (
143
+ 'The text cites "{citation}", and no tool result in this session shows it.'
144
+ " Read it yourself or through a subagent first."
145
+ )
91
146
 
92
147
  ADDITIONAL_CONTEXT_KEY = "additionalContext"
@@ -22,12 +22,15 @@ from hooks_constants.pre_tool_use_dispatcher_constants import (
22
22
  from pre_tool_use_dispatcher import HostedHookResult, aggregate_hosted_hook_results
23
23
 
24
24
 
25
- def test_roster_hosts_only_the_context_budget_gate() -> None:
26
- assert len(ALL_HOSTED_HOOK_ENTRIES) == 1
27
- entry = ALL_HOSTED_HOOK_ENTRIES[0]
28
- assert entry.script_relative_path == "blocking/context_budget_blocker.py"
29
- assert entry.is_blocking
30
- assert entry.applicable_tool_names == ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES
25
+ def test_roster_hosts_the_context_budget_and_proof_edit_gates() -> None:
26
+ assert len(ALL_HOSTED_HOOK_ENTRIES) == 2
27
+ context_budget_entry, proof_edit_entry = ALL_HOSTED_HOOK_ENTRIES
28
+ assert context_budget_entry.script_relative_path == "blocking/context_budget_blocker.py"
29
+ assert context_budget_entry.is_blocking
30
+ assert context_budget_entry.applicable_tool_names == ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES
31
+ assert proof_edit_entry.script_relative_path == "blocking/pull_request_proof_edit_gate.py"
32
+ assert proof_edit_entry.is_blocking
33
+ assert proof_edit_entry.applicable_tool_names == ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES
31
34
 
32
35
 
33
36
  def test_every_hosted_script_path_exists_under_the_hooks_root() -> None:
@@ -5,9 +5,9 @@ def test_gate_limits_replies_to_three_sentences_of_fifteen_words() -> None:
5
5
  assert (constants.MAXIMUM_SENTENCE_COUNT, constants.MAXIMUM_WORDS_PER_SENTENCE) == (3, 15)
6
6
 
7
7
 
8
- def test_gate_checks_the_two_chat_tools_that_post_to_the_user() -> None:
8
+ def test_gate_checks_the_three_chat_tools_that_post_to_the_user() -> None:
9
9
  assert constants.ALL_CHECKED_TOOL_NAMES == frozenset(
10
- {"mcp__hearthbot__reply", "mcp__hearthbot__post_message"}
10
+ {"mcp__hearthbot__reply", "mcp__hearthbot__post_message", "mcp__hearthbot__update_message"}
11
11
  )
12
12
 
13
13
 
@@ -61,6 +61,8 @@ BLOCK_ID_KEY = "id"
61
61
  TOOL_USE_ID_KEY = "tool_use_id"
62
62
  IS_ERROR_KEY = "is_error"
63
63
  IS_META_KEY = "isMeta"
64
+ BLOCK_TEXT_KEY = "text"
65
+ RESULT_TEXT_SEPARATOR = "\n"
64
66
 
65
67
  ABBREVIATION_MESSAGE = 'Abbreviation "{abbreviation}" in the text. {reminder}'
66
68
  TRACKER_NUMBER_MESSAGE = 'Tracker number "{reference}" in the text. {reminder}'
@@ -15,6 +15,7 @@ from visual_reply_rules import (
15
15
  mode_enabled,
16
16
  replies_sent_this_turn,
17
17
  second_reply_violation,
18
+ successful_result_text,
18
19
  visual_shown_this_turn,
19
20
  visual_violation,
20
21
  widget_anchor_violation,
@@ -207,3 +208,23 @@ def test_widget_flowchart_advice_should_stay_quiet_when_the_rule_list_has_no_flo
207
208
  assert rules is not None
208
209
  card_input = {"widget_code": "<div class='card'>Merged</div>"}
209
210
  assert widget_flowchart_advice("mcp__hearthbot__post_widget", card_input, rules) is None
211
+
212
+
213
+ def test_successful_result_text_joins_only_results_without_an_error(tmp_path: Path) -> None:
214
+ all_entries = [
215
+ {"type": "user", "message": {"content": "run 111 from the prompt"}},
216
+ {"type": "user", "message": {"content": [{"type": "tool_result", "content": "run 222 ok"}]}},
217
+ {
218
+ "type": "user",
219
+ "message": {
220
+ "content": [
221
+ {"type": "tool_result", "content": [{"type": "text", "text": "run 333 ok"}]},
222
+ {"type": "tool_result", "is_error": True, "content": "run 444 failed"},
223
+ ]
224
+ },
225
+ },
226
+ ]
227
+ transcript_path = tmp_path / "s.jsonl"
228
+ transcript_path.write_text("\n".join(json.dumps(each) for each in all_entries), encoding="utf-8")
229
+ assert successful_result_text(str(transcript_path)) == "run 222 ok\nrun 333 ok"
230
+ assert successful_result_text(str(tmp_path / "missing.jsonl")) is None
@@ -12,7 +12,7 @@ from __future__ import annotations
12
12
 
13
13
  import json
14
14
  import re
15
- from collections.abc import Iterable
15
+ from collections.abc import Iterable, Iterator
16
16
  from dataclasses import dataclass
17
17
  from pathlib import Path
18
18
 
@@ -26,6 +26,7 @@ from hooks_constants.visual_reply_rules_constants import (
26
26
  ASSISTANT_ENTRY_TYPE,
27
27
  BLOCK_ID_KEY,
28
28
  BLOCK_NAME_KEY,
29
+ BLOCK_TEXT_KEY,
29
30
  BLOCK_TYPE_KEY,
30
31
  CAPITALIZED_ABBREVIATION_PATTERN,
31
32
  CONTENT_KEY,
@@ -45,6 +46,7 @@ from hooks_constants.visual_reply_rules_constants import (
45
46
  RULE_KEY_FIELD,
46
47
  RULE_LABEL_FIELD,
47
48
  RULE_REMINDER_FIELD,
49
+ RESULT_TEXT_SEPARATOR,
48
50
  RULES_FILE_ENCODING,
49
51
  RULES_FILE_PATH,
50
52
  RULES_LIST_KEY,
@@ -248,14 +250,44 @@ def _read_transcript_lines(transcript_path: object) -> list[str] | None:
248
250
  return None
249
251
 
250
252
 
251
- def _result_ids(all_entries: list[dict[str, object]], is_error: bool) -> set[object]:
252
- return {
253
- each_block.get(TOOL_USE_ID_KEY)
253
+ def _result_blocks(all_entries: Iterable[dict[str, object]], is_error: bool) -> Iterator[dict[str, object]]:
254
+ return (
255
+ each_block
254
256
  for each_entry in all_entries
255
257
  for each_block in _content_blocks(each_entry, USER_ENTRY_TYPE)
256
258
  if each_block.get(BLOCK_TYPE_KEY) == TOOL_RESULT_BLOCK_TYPE
257
259
  and bool(each_block.get(IS_ERROR_KEY)) is is_error
258
- }
260
+ )
261
+
262
+
263
+ def _result_ids(all_entries: list[dict[str, object]], is_error: bool) -> set[object]:
264
+ return {each_block.get(TOOL_USE_ID_KEY) for each_block in _result_blocks(all_entries, is_error)}
265
+
266
+
267
+ def _result_texts(result_content: object) -> list[str]:
268
+ if isinstance(result_content, str):
269
+ return [result_content]
270
+ return [
271
+ each_block[BLOCK_TEXT_KEY]
272
+ for each_block in (result_content if isinstance(result_content, list) else [])
273
+ if isinstance(each_block, dict) and isinstance(each_block.get(BLOCK_TEXT_KEY), str)
274
+ ]
275
+
276
+
277
+ def successful_result_text(transcript_path: object) -> str | None:
278
+ """Join the text of every tool result in the session that came back without an error.
279
+
280
+ A subagent's report is the result of its Agent call, so it counts. Returns None
281
+ when the transcript cannot be read.
282
+ """
283
+ all_lines = _read_transcript_lines(transcript_path)
284
+ if all_lines is None:
285
+ return None
286
+ return RESULT_TEXT_SEPARATOR.join(
287
+ each_text
288
+ for each_block in _result_blocks(_parsed_entries(all_lines), is_error=False)
289
+ for each_text in _result_texts(each_block.get(CONTENT_KEY))
290
+ )
259
291
 
260
292
 
261
293
  def visual_shown_this_turn(all_transcript_lines: Iterable[str]) -> bool:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.80.0",
3
+ "version": "8.82.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -136,6 +136,7 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
136
136
  hooks/blocking/session_title_format_gate.py -> exempt
137
137
  hooks/blocking/artifact_dark_mode_gate.py -> exempt
138
138
  hooks/blocking/merge_over_review_offer_gate.py -> exempt
139
+ hooks/blocking/final_reply_gate.py -> exempt
139
140
  hooks/blocking/some_new_blocker.py -> flagged
140
141
 
141
142
  The Bash PreToolUse dispatcher sits under ``blocking/`` for layout reasons
@@ -75,6 +75,7 @@ ALL_ACTION_BOUNDARY_EXEMPT_REGISTRATION_PATHS = (
75
75
  "blocking/artifact_dark_mode_gate.py",
76
76
  "blocking/haiku_spawn_slim_gate.py",
77
77
  "blocking/merge_over_review_offer_gate.py",
78
+ "blocking/final_reply_gate.py",
78
79
  )
79
80
  ALL_TEST_DIRECTORY_NAMES = frozenset({"tests"})
80
81
  ALL_TEST_FILE_PREFIXES = ("test_",)
@@ -66,6 +66,14 @@ def test_new_merge_offer_gate_registration_is_clean(tmp_path: Path) -> None:
66
66
  assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
67
67
 
68
68
 
69
+ def test_new_final_reply_gate_registration_is_clean(tmp_path: Path) -> None:
70
+ current_document = _hook_document(
71
+ ["hooks/blocking/final_reply_gate.py"],
72
+ '{"hooks": {}}',
73
+ )
74
+ assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
75
+
76
+
69
77
  def test_new_step_note_gate_registration_is_clean(tmp_path: Path) -> None:
70
78
  current_document = _hook_document(
71
79
  ["hooks/blocking/step_note_gate.py"],