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.
- package/hooks/blocking/final_reply_gate.py +77 -0
- package/hooks/blocking/pr_lifecycle_skill_gate.py +13 -3
- package/hooks/blocking/pull_request_proof.py +42 -13
- package/hooks/blocking/pull_request_proof_edit_gate.py +180 -0
- package/hooks/blocking/reply_length_gate.py +114 -17
- package/hooks/blocking/test_final_reply_gate.py +94 -0
- package/hooks/blocking/test_pr_lifecycle_skill_gate.py +8 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +1 -0
- package/hooks/blocking/test_pull_request_proof.py +7 -0
- package/hooks/blocking/test_pull_request_proof_edit_gate.py +199 -0
- package/hooks/blocking/test_reply_length_gate.py +208 -0
- package/hooks/features/conduct-gates.md +7 -3
- package/hooks/features/write-edit-blocking.md +4 -1
- package/hooks/hooks.json +13 -2
- package/hooks/hooks_constants/final_reply_gate_constants.py +10 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/pull_request_proof_constants.py +22 -3
- package/hooks/hooks_constants/reply_length_gate_constants.py +56 -1
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +9 -6
- package/hooks/hooks_constants/test_reply_length_gate_constants.py +2 -2
- package/hooks/hooks_constants/visual_reply_rules_constants.py +2 -0
- package/hooks/test_visual_reply_rules.py +21 -0
- package/hooks/visual_reply_rules.py +37 -5
- package/package.json +1 -1
- package/scripts/policy_lint/adapter_configuration.py +1 -0
- package/scripts/policy_lint/config/constants.py +1 -0
- 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;
|
|
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.
|
|
63
|
-
"
|
|
64
|
-
"
|
|
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(
|
|
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
|
|
26
|
-
assert len(ALL_HOSTED_HOOK_ENTRIES) ==
|
|
27
|
-
|
|
28
|
-
assert
|
|
29
|
-
assert
|
|
30
|
-
assert
|
|
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
|
|
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
|
|
252
|
-
return
|
|
253
|
-
each_block
|
|
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
|
@@ -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"],
|