claude-dev-env 8.81.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/reply_length_gate.py +114 -17
- package/hooks/blocking/test_final_reply_gate.py +94 -0
- package/hooks/blocking/test_reply_length_gate.py +208 -0
- package/hooks/features/conduct-gates.md +7 -3
- package/hooks/hooks.json +13 -2
- package/hooks/hooks_constants/final_reply_gate_constants.py +10 -0
- package/hooks/hooks_constants/reply_length_gate_constants.py +56 -1
- 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
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Stop hook that checks the closing chat reply for an unvalidated claim.
|
|
3
|
+
|
|
4
|
+
The reply length gate checks the chat tools. This gate checks the reply the
|
|
5
|
+
session ends its turn with, which reaches the user with no tool call::
|
|
6
|
+
|
|
7
|
+
flag: My guess is one to two hours.
|
|
8
|
+
flag: I didn't confirm this one.
|
|
9
|
+
flag: Run 18239174012 passed. (no tool result shows the run)
|
|
10
|
+
ok: Run 18239174012 took 39 minutes. (a tool result shows the run)
|
|
11
|
+
|
|
12
|
+
It applies the banned words, the unvalidated claim pattern, and the cited
|
|
13
|
+
run, job, check, and pull request numbers from ``reply_length_gate.py``. The
|
|
14
|
+
citation check is skipped when the transcript cannot be read. A block sends
|
|
15
|
+
the reason back so the model rewrites the reply. Claude Code ends the turn
|
|
16
|
+
after eight blocks in a row.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
26
|
+
if hooks_root_directory not in sys.path:
|
|
27
|
+
sys.path.insert(0, hooks_root_directory)
|
|
28
|
+
|
|
29
|
+
from reply_length_gate import (
|
|
30
|
+
banned_word_violation,
|
|
31
|
+
configured_banned_words,
|
|
32
|
+
unseen_citation_violation,
|
|
33
|
+
unvalidated_claim_violation,
|
|
34
|
+
)
|
|
35
|
+
from hooks_constants.hook_block_logger import log_hook_block
|
|
36
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
37
|
+
from hooks_constants.final_reply_gate_constants import (
|
|
38
|
+
ALLOW_EXIT_CODE,
|
|
39
|
+
BLOCK_DECISION,
|
|
40
|
+
DECISION_KEY,
|
|
41
|
+
HOOK_EVENT_NAME,
|
|
42
|
+
LAST_ASSISTANT_MESSAGE_KEY,
|
|
43
|
+
REASON_KEY,
|
|
44
|
+
REWRITE_INSTRUCTION,
|
|
45
|
+
TRANSCRIPT_PATH_KEY,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def final_reply_violation(reply_text: str, transcript_path: object) -> str | None:
|
|
50
|
+
"""Return the block reason for the closing reply, or None when it passes."""
|
|
51
|
+
return (
|
|
52
|
+
banned_word_violation(reply_text, configured_banned_words())
|
|
53
|
+
or unvalidated_claim_violation(reply_text)
|
|
54
|
+
or unseen_citation_violation(reply_text, transcript_path)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def main() -> int:
|
|
59
|
+
hook_input = read_hook_input_dictionary_from_stdin()
|
|
60
|
+
if hook_input is None:
|
|
61
|
+
return ALLOW_EXIT_CODE
|
|
62
|
+
reply_text = hook_input.get(LAST_ASSISTANT_MESSAGE_KEY)
|
|
63
|
+
if not isinstance(reply_text, str):
|
|
64
|
+
return ALLOW_EXIT_CODE
|
|
65
|
+
violation = final_reply_violation(reply_text, hook_input.get(TRANSCRIPT_PATH_KEY))
|
|
66
|
+
if violation is None:
|
|
67
|
+
return ALLOW_EXIT_CODE
|
|
68
|
+
block_reason = violation + REWRITE_INSTRUCTION
|
|
69
|
+
log_hook_block(
|
|
70
|
+
Path(__file__).name, HOOK_EVENT_NAME, block_reason, offending_input_preview=reply_text
|
|
71
|
+
)
|
|
72
|
+
sys.stdout.write(json.dumps({DECISION_KEY: BLOCK_DECISION, REASON_KEY: block_reason}))
|
|
73
|
+
return ALLOW_EXIT_CODE
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
sys.exit(main())
|
|
@@ -33,9 +33,23 @@ The gate uses the ``banned_words`` list in ``~/.claude/reply-banned-words.json``
|
|
|
33
33
|
or in the file that CLAUDE_REPLY_BANNED_WORDS_PATH names. Without a valid
|
|
34
34
|
list there, it uses ALL_DEFAULT_BANNED_WORDS.
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
It denies a sentence that guesses, estimates, hedges a duration, or admits
|
|
37
|
+
a check did not run::
|
|
38
|
+
|
|
39
|
+
flag: My guess is one to two hours.
|
|
40
|
+
flag: I didn't confirm this one.
|
|
41
|
+
ok: The last build took 39 minutes.
|
|
42
|
+
|
|
43
|
+
It denies an Actions run, a pull request link, or a run, job, or check id
|
|
44
|
+
that no successful tool result in the transcript shows. A subagent's report
|
|
45
|
+
is the result of its Agent call. With no readable transcript, this check
|
|
46
|
+
is skipped.
|
|
47
|
+
|
|
48
|
+
The banned-word, unvalidated claim, and citation checks also read the prose of a
|
|
49
|
+
decision card: its question, its context, and each option's label and
|
|
50
|
+
consequence. They read an AskUserQuestion card the same way: each
|
|
51
|
+
question, its header, and each option's label and description.
|
|
52
|
+
A card gets no length check.
|
|
39
53
|
|
|
40
54
|
Each non-empty line counts as its own sentence, so a list counts one
|
|
41
55
|
sentence per item. URLs, markdown link targets, inline code spans, and
|
|
@@ -74,6 +88,7 @@ from visual_reply_rules import (
|
|
|
74
88
|
load_rules,
|
|
75
89
|
mode_enabled,
|
|
76
90
|
second_reply_violation,
|
|
91
|
+
successful_result_text,
|
|
77
92
|
visual_violation,
|
|
78
93
|
widget_anchor_violation,
|
|
79
94
|
widget_flowchart_advice,
|
|
@@ -90,6 +105,8 @@ from hooks_constants.reply_length_gate_constants import (
|
|
|
90
105
|
ALL_DECISION_OPTION_PROSE_KEYS,
|
|
91
106
|
ALL_DEFAULT_BANNED_WORDS,
|
|
92
107
|
ALL_EVIDENCE_PATTERNS,
|
|
108
|
+
ALL_QUESTION_CARD_PROSE_KEYS,
|
|
109
|
+
ALL_QUESTION_OPTION_PROSE_KEYS,
|
|
93
110
|
ALLOW_EXIT_CODE,
|
|
94
111
|
BANNED_WORD_MESSAGE,
|
|
95
112
|
BANNED_WORD_PART_SEPARATOR,
|
|
@@ -100,10 +117,14 @@ from hooks_constants.reply_length_gate_constants import (
|
|
|
100
117
|
BLOCK_EXIT_CODE,
|
|
101
118
|
CAUSAL_CLAIM_PATTERN,
|
|
102
119
|
CARD_TEXT_SEPARATOR,
|
|
120
|
+
CITED_NUMBER_IN_RESULT_TEMPLATE,
|
|
121
|
+
CITED_NUMBER_PATTERN,
|
|
103
122
|
CLAUDE_HOME_DIRECTORY_NAME,
|
|
104
123
|
CONFIG_FILE_ENCODING,
|
|
124
|
+
CURLY_APOSTROPHE,
|
|
105
125
|
DECISION_CARD_OPTIONS_KEY,
|
|
106
126
|
DECISION_CARD_TOOL_NAME,
|
|
127
|
+
DURATION_CLAIM_PATTERN,
|
|
107
128
|
EM_DASH_MESSAGE,
|
|
108
129
|
EM_DASH_PATTERN,
|
|
109
130
|
FENCED_BLOCK_PATTERN,
|
|
@@ -118,10 +139,13 @@ from hooks_constants.reply_length_gate_constants import (
|
|
|
118
139
|
MAXIMUM_WORDS_PER_SENTENCE,
|
|
119
140
|
MID_SENTENCE_COLON_MESSAGE,
|
|
120
141
|
MID_SENTENCE_COLON_PATTERN,
|
|
142
|
+
QUESTION_CARD_QUESTIONS_KEY,
|
|
143
|
+
QUESTION_CARD_TOOL_NAME,
|
|
121
144
|
RETRY_INSTRUCTION,
|
|
122
145
|
SENTENCE_END_PATTERN,
|
|
123
146
|
SENTENCE_PREVIEW_SUFFIX,
|
|
124
147
|
SENTENCE_PREVIEW_WORD_COUNT,
|
|
148
|
+
STRAIGHT_APOSTROPHE,
|
|
125
149
|
TEXT_KEY,
|
|
126
150
|
TOO_MANY_SENTENCES_MESSAGE,
|
|
127
151
|
TOOL_INPUT_KEY,
|
|
@@ -129,7 +153,10 @@ from hooks_constants.reply_length_gate_constants import (
|
|
|
129
153
|
TRANSCRIPT_PATH_KEY,
|
|
130
154
|
UNLINKED_PULL_REQUEST_MESSAGE,
|
|
131
155
|
UNLINKED_PULL_REQUEST_PATTERN,
|
|
156
|
+
UNSEEN_CITATION_MESSAGE,
|
|
132
157
|
UNSOURCED_CAUSE_MESSAGE,
|
|
158
|
+
UNVALIDATED_CLAIM_MESSAGE,
|
|
159
|
+
UNVALIDATED_CLAIM_PATTERN,
|
|
133
160
|
URL_PATTERN,
|
|
134
161
|
WORD_PATTERN,
|
|
135
162
|
WORD_SEPARATOR,
|
|
@@ -146,12 +173,9 @@ def countable_text(reply_text: str) -> str:
|
|
|
146
173
|
|
|
147
174
|
def sentence_word_lists(reply_text: str) -> list[list[str]]:
|
|
148
175
|
"""Split the text into sentences, one per line or terminal mark, as word lists."""
|
|
149
|
-
|
|
150
|
-
each_sentence
|
|
151
|
-
for each_line in LINE_BREAK_PATTERN.split(countable_text(reply_text))
|
|
152
|
-
for each_sentence in SENTENCE_END_PATTERN.split(each_line)
|
|
176
|
+
all_word_lists = [
|
|
177
|
+
WORD_PATTERN.findall(each_sentence) for each_sentence in prose_sentences(countable_text(reply_text))
|
|
153
178
|
]
|
|
154
|
-
all_word_lists = [WORD_PATTERN.findall(each_sentence) for each_sentence in all_sentence_texts]
|
|
155
179
|
return [each_word_list for each_word_list in all_word_lists if each_word_list]
|
|
156
180
|
|
|
157
181
|
|
|
@@ -187,14 +211,18 @@ def em_dash_violation(reply_text: str) -> str | None:
|
|
|
187
211
|
return EM_DASH_MESSAGE
|
|
188
212
|
|
|
189
213
|
|
|
190
|
-
def
|
|
191
|
-
"""
|
|
192
|
-
|
|
193
|
-
each_sentence
|
|
214
|
+
def prose_sentences(reply_text: str) -> list[str]:
|
|
215
|
+
"""Split the text outside fenced blocks into sentences, one per line or terminal mark."""
|
|
216
|
+
return [
|
|
217
|
+
each_sentence.strip()
|
|
194
218
|
for each_line in LINE_BREAK_PATTERN.split(FENCED_BLOCK_PATTERN.sub(" ", reply_text))
|
|
195
219
|
for each_sentence in SENTENCE_END_PATTERN.split(each_line)
|
|
196
220
|
]
|
|
197
|
-
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def unsourced_cause_violation(reply_text: str) -> str | None:
|
|
224
|
+
"""Return the deny reason for the first causal sentence that cites no evidence itself, or None."""
|
|
225
|
+
for each_sentence in prose_sentences(reply_text):
|
|
198
226
|
is_causal = CAUSAL_CLAIM_PATTERN.search(INLINE_CODE_PATTERN.sub(" ", each_sentence))
|
|
199
227
|
has_evidence = any(each_pattern.search(each_sentence) for each_pattern in ALL_EVIDENCE_PATTERNS)
|
|
200
228
|
if is_causal and not has_evidence:
|
|
@@ -254,6 +282,45 @@ def banned_word_violation(reply_text: str, all_banned_words: tuple[str, ...]) ->
|
|
|
254
282
|
return None
|
|
255
283
|
|
|
256
284
|
|
|
285
|
+
def unvalidated_claim_violation(reply_text: str) -> str | None:
|
|
286
|
+
"""Return the deny reason quoting the first sentence that guesses or admits a skipped check, or None.
|
|
287
|
+
|
|
288
|
+
A duration counts as a guess unless its sentence cites the run, job, check,
|
|
289
|
+
or pull request that measured it::
|
|
290
|
+
|
|
291
|
+
flag: It should take about 40 minutes.
|
|
292
|
+
ok: The last queue build took 39 minutes, run 18239174012.
|
|
293
|
+
"""
|
|
294
|
+
for each_sentence in prose_sentences(reply_text):
|
|
295
|
+
prose_text = countable_text(each_sentence).replace(CURLY_APOSTROPHE, STRAIGHT_APOSTROPHE)
|
|
296
|
+
is_guess = UNVALIDATED_CLAIM_PATTERN.search(prose_text) is not None
|
|
297
|
+
is_uncited_duration = (
|
|
298
|
+
DURATION_CLAIM_PATTERN.search(prose_text) is not None
|
|
299
|
+
and CITED_NUMBER_PATTERN.search(each_sentence) is None
|
|
300
|
+
)
|
|
301
|
+
if is_guess or is_uncited_duration:
|
|
302
|
+
return UNVALIDATED_CLAIM_MESSAGE.format(claim=each_sentence)
|
|
303
|
+
return None
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def unseen_citation_violation(reply_text: str, transcript_path: object) -> str | None:
|
|
307
|
+
"""Return the deny reason for the first cited run, job, check, or pull request no tool result shows.
|
|
308
|
+
|
|
309
|
+
Returns None when the text cites nothing or the transcript cannot be read.
|
|
310
|
+
"""
|
|
311
|
+
all_citations = list(CITED_NUMBER_PATTERN.finditer(FENCED_BLOCK_PATTERN.sub(" ", reply_text)))
|
|
312
|
+
if not all_citations:
|
|
313
|
+
return None
|
|
314
|
+
result_text = successful_result_text(transcript_path)
|
|
315
|
+
if result_text is None:
|
|
316
|
+
return None
|
|
317
|
+
for each_match in all_citations:
|
|
318
|
+
cited_number = next(each_group for each_group in each_match.groups() if each_group)
|
|
319
|
+
if re.search(CITED_NUMBER_IN_RESULT_TEMPLATE.format(number=cited_number), result_text) is None:
|
|
320
|
+
return UNSEEN_CITATION_MESSAGE.format(citation=each_match.group(0))
|
|
321
|
+
return None
|
|
322
|
+
|
|
323
|
+
|
|
257
324
|
def all_card_texts(all_card_input: dict[str, object]) -> list[str]:
|
|
258
325
|
"""Collect the prose fields of a decision card: question, context, and each option's text."""
|
|
259
326
|
all_options = all_card_input.get(DECISION_CARD_OPTIONS_KEY)
|
|
@@ -271,6 +338,24 @@ def all_card_texts(all_card_input: dict[str, object]) -> list[str]:
|
|
|
271
338
|
]
|
|
272
339
|
|
|
273
340
|
|
|
341
|
+
def all_question_card_texts(all_card_input: dict[str, object]) -> list[str]:
|
|
342
|
+
"""Collect the prose fields of a question card: each question, its header, and each option's text."""
|
|
343
|
+
all_questions = all_card_input.get(QUESTION_CARD_QUESTIONS_KEY)
|
|
344
|
+
all_question_fields: list[object] = []
|
|
345
|
+
for each_question in all_questions if isinstance(all_questions, list) else []:
|
|
346
|
+
if not isinstance(each_question, dict):
|
|
347
|
+
continue
|
|
348
|
+
all_question_fields.extend(each_question.get(each_key) for each_key in ALL_QUESTION_CARD_PROSE_KEYS)
|
|
349
|
+
all_options = each_question.get(DECISION_CARD_OPTIONS_KEY)
|
|
350
|
+
all_question_fields.extend(
|
|
351
|
+
each_option.get(each_key)
|
|
352
|
+
for each_option in (all_options if isinstance(all_options, list) else [])
|
|
353
|
+
if isinstance(each_option, dict)
|
|
354
|
+
for each_key in ALL_QUESTION_OPTION_PROSE_KEYS
|
|
355
|
+
)
|
|
356
|
+
return [each_field for each_field in all_question_fields if isinstance(each_field, str)]
|
|
357
|
+
|
|
358
|
+
|
|
274
359
|
def active_mode_rules() -> VisualReplyRules | None:
|
|
275
360
|
"""Return the visual reply rules when the mode is on and the rule file parses."""
|
|
276
361
|
if not mode_enabled(Path.home() / CLAUDE_HOME_DIRECTORY_NAME):
|
|
@@ -280,7 +365,10 @@ def active_mode_rules() -> VisualReplyRules | None:
|
|
|
280
365
|
|
|
281
366
|
def card_violation(card_text: str, mode_rules: VisualReplyRules | None) -> str | None:
|
|
282
367
|
"""Return the deny reason for a decision card's prose, or None."""
|
|
283
|
-
return
|
|
368
|
+
return (
|
|
369
|
+
banned_word_violation(card_text, configured_banned_words())
|
|
370
|
+
or unvalidated_claim_violation(card_text)
|
|
371
|
+
) or (
|
|
284
372
|
None if mode_rules is None else abbreviation_violation(prose_outside_links(card_text), mode_rules)
|
|
285
373
|
)
|
|
286
374
|
|
|
@@ -296,6 +384,8 @@ def reply_violation(
|
|
|
296
384
|
or unlinked_pull_request_violation(reply_text)
|
|
297
385
|
or unsourced_cause_violation(reply_text)
|
|
298
386
|
or banned_word_violation(reply_text, configured_banned_words())
|
|
387
|
+
or unvalidated_claim_violation(reply_text)
|
|
388
|
+
or unseen_citation_violation(reply_text, transcript_path)
|
|
299
389
|
)
|
|
300
390
|
if violation is not None or mode_rules is None:
|
|
301
391
|
return violation
|
|
@@ -316,9 +406,16 @@ def tool_violation(
|
|
|
316
406
|
anchor_violation = widget_anchor_violation(tool_name, all_tool_input)
|
|
317
407
|
if anchor_violation is not None:
|
|
318
408
|
return anchor_violation, str(all_tool_input)
|
|
319
|
-
if tool_name
|
|
320
|
-
|
|
321
|
-
|
|
409
|
+
if tool_name in (DECISION_CARD_TOOL_NAME, QUESTION_CARD_TOOL_NAME):
|
|
410
|
+
all_texts = (
|
|
411
|
+
all_card_texts(all_tool_input)
|
|
412
|
+
if tool_name == DECISION_CARD_TOOL_NAME
|
|
413
|
+
else all_question_card_texts(all_tool_input)
|
|
414
|
+
)
|
|
415
|
+
card_text = CARD_TEXT_SEPARATOR.join(all_texts)
|
|
416
|
+
violation = card_violation(card_text, mode_rules) or unseen_citation_violation(
|
|
417
|
+
card_text, transcript_path
|
|
418
|
+
)
|
|
322
419
|
return None if violation is None else (violation, card_text)
|
|
323
420
|
if tool_name not in ALL_CHECKED_TOOL_NAMES:
|
|
324
421
|
return None
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
import final_reply_gate
|
|
8
|
+
|
|
9
|
+
GUESSED_REPLY = "My guess is one to two hours if those builds pass first time."
|
|
10
|
+
UNCONFIRMED_REPLY = "Merge queue: I didn\u2019t confirm this one."
|
|
11
|
+
MEASURED_REPLY = "The last queue build took 39 minutes, run 18239174012."
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run_gate(
|
|
15
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
16
|
+
capsys: pytest.CaptureFixture[str],
|
|
17
|
+
reply_text: str,
|
|
18
|
+
transcript_path: Path | None = None,
|
|
19
|
+
) -> tuple[int, str]:
|
|
20
|
+
hook_input: dict[str, object] = {
|
|
21
|
+
"hook_event_name": "Stop",
|
|
22
|
+
"stop_hook_active": False,
|
|
23
|
+
"last_assistant_message": reply_text,
|
|
24
|
+
}
|
|
25
|
+
if transcript_path is not None:
|
|
26
|
+
hook_input["transcript_path"] = str(transcript_path)
|
|
27
|
+
monkeypatch.setattr(
|
|
28
|
+
"sys.stdin", io.TextIOWrapper(io.BytesIO(json.dumps(hook_input).encode("utf-8")))
|
|
29
|
+
)
|
|
30
|
+
exit_code = final_reply_gate.main()
|
|
31
|
+
return exit_code, capsys.readouterr().out
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.fixture(autouse=True)
|
|
35
|
+
def isolated_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
36
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
37
|
+
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
|
38
|
+
monkeypatch.delenv("CLAUDE_REPLY_BANNED_WORDS_PATH", raising=False)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def write_tool_result_transcript(transcript_path: Path, result_text: str) -> Path:
|
|
42
|
+
tool_result = {"type": "tool_result", "tool_use_id": "t1", "content": result_text}
|
|
43
|
+
transcript_path.write_text(
|
|
44
|
+
json.dumps({"type": "user", "message": {"content": [tool_result]}}), encoding="utf-8"
|
|
45
|
+
)
|
|
46
|
+
return transcript_path
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@pytest.mark.parametrize(
|
|
50
|
+
("reply_text", "quoted_text"),
|
|
51
|
+
[
|
|
52
|
+
(GUESSED_REPLY, 'Banned word "guess"'),
|
|
53
|
+
(UNCONFIRMED_REPLY, 'No tool validated this: "Merge queue: I didn\u2019t confirm this one."'),
|
|
54
|
+
("It should take about 40 minutes.", "It should take about 40 minutes."),
|
|
55
|
+
],
|
|
56
|
+
)
|
|
57
|
+
def test_should_block_a_closing_reply_that_guesses(
|
|
58
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
59
|
+
capsys: pytest.CaptureFixture[str],
|
|
60
|
+
reply_text: str,
|
|
61
|
+
quoted_text: str,
|
|
62
|
+
) -> None:
|
|
63
|
+
exit_code, stdout_text = run_gate(monkeypatch, capsys, reply_text)
|
|
64
|
+
decision = json.loads(stdout_text)
|
|
65
|
+
assert exit_code == 0
|
|
66
|
+
assert decision["decision"] == "block"
|
|
67
|
+
assert quoted_text in decision["reason"]
|
|
68
|
+
assert decision["reason"].endswith("Rewrite the reply without it.")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_should_block_a_run_no_tool_result_shows(
|
|
72
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
73
|
+
) -> None:
|
|
74
|
+
transcript_path = write_tool_result_transcript(tmp_path / "s.jsonl", "run 18239170000 ok")
|
|
75
|
+
_, stdout_text = run_gate(monkeypatch, capsys, MEASURED_REPLY, transcript_path)
|
|
76
|
+
assert 'cites "run 18239174012"' in json.loads(stdout_text)["reason"]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_should_allow_a_run_a_tool_result_shows(
|
|
80
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
81
|
+
) -> None:
|
|
82
|
+
transcript_path = write_tool_result_transcript(tmp_path / "s.jsonl", "run 18239174012 took 39m")
|
|
83
|
+
assert run_gate(monkeypatch, capsys, MEASURED_REPLY, transcript_path) == (0, "")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_should_allow_a_closing_reply_with_no_message(
|
|
87
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
88
|
+
) -> None:
|
|
89
|
+
monkeypatch.setattr("sys.stdin", io.TextIOWrapper(io.BytesIO(b'{"hook_event_name": "Stop"}')))
|
|
90
|
+
assert (final_reply_gate.main(), capsys.readouterr().out) == (0, "")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_should_return_none_for_a_measured_reply_without_a_transcript() -> None:
|
|
94
|
+
assert final_reply_gate.final_reply_violation(MEASURED_REPLY, None) is None
|
|
@@ -726,3 +726,211 @@ def test_should_allow_a_reply_with_no_causal_claim(
|
|
|
726
726
|
monkeypatch, capsys, REPLY_TOOL_NAME, {"text": "The runner restarted. Also, so far so good."}
|
|
727
727
|
)
|
|
728
728
|
assert exit_code == 0
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
UPDATE_TOOL_NAME = "mcp__hearthbot__update_message"
|
|
732
|
+
QUESTION_CARD_TOOL_NAME = "AskUserQuestion"
|
|
733
|
+
RUN_LINK_REPLY = (
|
|
734
|
+
"The last build passed in [run 18239174012]"
|
|
735
|
+
"(https://github.com/owner/repo/actions/runs/18239174012)."
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def write_tool_result_transcript(transcript_path: Path, result_text: str, is_error: bool = False) -> Path:
|
|
740
|
+
all_entries = [
|
|
741
|
+
{"type": "user", "message": {"content": "how close are we"}},
|
|
742
|
+
{
|
|
743
|
+
"type": "assistant",
|
|
744
|
+
"message": {"content": [{"type": "tool_use", "id": "t1", "name": "Bash", "input": {}}]},
|
|
745
|
+
},
|
|
746
|
+
{
|
|
747
|
+
"type": "user",
|
|
748
|
+
"message": {
|
|
749
|
+
"content": [
|
|
750
|
+
{
|
|
751
|
+
"type": "tool_result",
|
|
752
|
+
"tool_use_id": "t1",
|
|
753
|
+
"is_error": is_error,
|
|
754
|
+
"content": [{"type": "text", "text": result_text}],
|
|
755
|
+
}
|
|
756
|
+
]
|
|
757
|
+
},
|
|
758
|
+
},
|
|
759
|
+
]
|
|
760
|
+
transcript_path.write_text(
|
|
761
|
+
"\n".join(json.dumps(each_entry) for each_entry in all_entries), encoding="utf-8"
|
|
762
|
+
)
|
|
763
|
+
return transcript_path
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
@pytest.mark.parametrize("tool_name", [REPLY_TOOL_NAME, POST_TOOL_NAME, UPDATE_TOOL_NAME])
|
|
767
|
+
def test_should_deny_an_estimate_and_quote_it(
|
|
768
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tool_name: str
|
|
769
|
+
) -> None:
|
|
770
|
+
exit_code, stderr_text = run_gate(
|
|
771
|
+
monkeypatch, capsys, tool_name, {"text": "My estimate is one to two hours."}
|
|
772
|
+
)
|
|
773
|
+
assert exit_code == 2
|
|
774
|
+
assert 'No tool validated this: "My estimate is one to two hours."' in stderr_text
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
@pytest.mark.parametrize(
|
|
778
|
+
"reply_text",
|
|
779
|
+
[
|
|
780
|
+
"I didn\u2019t confirm this one.",
|
|
781
|
+
"I have not checked the queue.",
|
|
782
|
+
"I assumed the build passed.",
|
|
783
|
+
"Run 18239174012 is unverified.",
|
|
784
|
+
"About 20 minutes left.",
|
|
785
|
+
"It will take two hours.",
|
|
786
|
+
"ETA is 3-4 hours.",
|
|
787
|
+
"Estimate from the last check is two builds.",
|
|
788
|
+
"I did not run the suite.",
|
|
789
|
+
"The tests have not yet been run.",
|
|
790
|
+
"The job takes 39 minutes.",
|
|
791
|
+
"The migration takes seventy minutes.",
|
|
792
|
+
"I was not able to verify the fix.",
|
|
793
|
+
"The fix has not been validated.",
|
|
794
|
+
"The fix was not verified in production.",
|
|
795
|
+
"The suite takes about 90 seconds.",
|
|
796
|
+
],
|
|
797
|
+
)
|
|
798
|
+
def test_should_deny_each_unvalidated_claim_shape(
|
|
799
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], reply_text: str
|
|
800
|
+
) -> None:
|
|
801
|
+
exit_code, stderr_text = run_gate(monkeypatch, capsys, REPLY_TOOL_NAME, {"text": reply_text})
|
|
802
|
+
assert exit_code == 2
|
|
803
|
+
assert "No tool validated this" in stderr_text
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
@pytest.mark.parametrize(
|
|
807
|
+
"reply_text",
|
|
808
|
+
[
|
|
809
|
+
"Two builds are left in the queue.",
|
|
810
|
+
"I will check the queue again within 15 minutes.",
|
|
811
|
+
"The last queue build took 39 minutes.",
|
|
812
|
+
"The gate now refuses `guess` and `assumed`.",
|
|
813
|
+
"The job takes 39 minutes, see run 18239174012.",
|
|
814
|
+
"The hook cannot run on Windows.",
|
|
815
|
+
"The crash is not a test problem.",
|
|
816
|
+
"That is not the check that failed.",
|
|
817
|
+
"The migration did not run out of memory.",
|
|
818
|
+
"We did not run into any errors.",
|
|
819
|
+
"This is not something to check before the release.",
|
|
820
|
+
"The result is not what we tested for.",
|
|
821
|
+
"The migration did not run on the replica.",
|
|
822
|
+
"The old parser did not validate the header length.",
|
|
823
|
+
"The job has not run yet.",
|
|
824
|
+
"The config file is not checked in.",
|
|
825
|
+
"The release branch is not checked out.",
|
|
826
|
+
],
|
|
827
|
+
)
|
|
828
|
+
def test_should_allow_a_reply_with_no_unvalidated_claim(
|
|
829
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], reply_text: str
|
|
830
|
+
) -> None:
|
|
831
|
+
assert run_gate(monkeypatch, capsys, REPLY_TOOL_NAME, {"text": reply_text}) == (0, "")
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def test_should_deny_a_run_no_tool_result_shows(
|
|
835
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
836
|
+
) -> None:
|
|
837
|
+
transcript_path = write_tool_result_transcript(tmp_path / "s.jsonl", "run 18239170000 ok")
|
|
838
|
+
exit_code, stderr_text = run_gate(
|
|
839
|
+
monkeypatch, capsys, REPLY_TOOL_NAME, {"text": RUN_LINK_REPLY}, transcript_path
|
|
840
|
+
)
|
|
841
|
+
assert exit_code == 2
|
|
842
|
+
assert 'cites "run 18239174012"' in stderr_text
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def test_should_deny_a_run_only_an_error_result_shows(
|
|
846
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
847
|
+
) -> None:
|
|
848
|
+
transcript_path = write_tool_result_transcript(
|
|
849
|
+
tmp_path / "s.jsonl", "run 18239174012 ok", is_error=True
|
|
850
|
+
)
|
|
851
|
+
exit_code, stderr_text = run_gate(
|
|
852
|
+
monkeypatch, capsys, REPLY_TOOL_NAME, {"text": RUN_LINK_REPLY}, transcript_path
|
|
853
|
+
)
|
|
854
|
+
assert exit_code == 2
|
|
855
|
+
assert "no tool result in this session shows it" in stderr_text
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def test_should_allow_a_run_a_tool_result_shows(
|
|
859
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
860
|
+
) -> None:
|
|
861
|
+
transcript_path = write_tool_result_transcript(tmp_path / "s.jsonl", "run 18239174012 took 39m")
|
|
862
|
+
assert run_gate(
|
|
863
|
+
monkeypatch, capsys, REPLY_TOOL_NAME, {"text": RUN_LINK_REPLY}, transcript_path
|
|
864
|
+
) == (0, "")
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def test_should_deny_a_question_card_that_admits_a_skipped_check(
|
|
868
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
869
|
+
) -> None:
|
|
870
|
+
card = {
|
|
871
|
+
"questions": [
|
|
872
|
+
{
|
|
873
|
+
"question": "Ship the gate now?",
|
|
874
|
+
"header": "Ship",
|
|
875
|
+
"options": [
|
|
876
|
+
{"label": "Ship", "description": "I did not test the Stop hook."},
|
|
877
|
+
{"label": "Wait", "description": "Run the suite first."},
|
|
878
|
+
],
|
|
879
|
+
}
|
|
880
|
+
]
|
|
881
|
+
}
|
|
882
|
+
exit_code, stderr_text = run_gate(monkeypatch, capsys, QUESTION_CARD_TOOL_NAME, card)
|
|
883
|
+
assert exit_code == 2
|
|
884
|
+
assert "I did not test the Stop hook." in stderr_text
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def test_should_allow_a_question_card_with_no_unvalidated_claim(
|
|
888
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
889
|
+
) -> None:
|
|
890
|
+
card = {
|
|
891
|
+
"questions": [
|
|
892
|
+
{
|
|
893
|
+
"question": "Which gate design?",
|
|
894
|
+
"header": "Design",
|
|
895
|
+
"options": [{"label": "Fold in", "description": "One gate checks every reply."}],
|
|
896
|
+
}
|
|
897
|
+
]
|
|
898
|
+
}
|
|
899
|
+
assert run_gate(monkeypatch, capsys, QUESTION_CARD_TOOL_NAME, card) == (0, "")
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def test_should_collect_each_question_card_prose_field() -> None:
|
|
903
|
+
card = {
|
|
904
|
+
"questions": [
|
|
905
|
+
{"question": "Q?", "header": "H", "options": [{"label": "L", "description": "D"}, "x"]},
|
|
906
|
+
"not a question",
|
|
907
|
+
]
|
|
908
|
+
}
|
|
909
|
+
assert reply_length_gate.all_question_card_texts(card) == ["Q?", "H", "L", "D"]
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def test_should_quote_the_hedged_sentence() -> None:
|
|
913
|
+
assert reply_length_gate.unvalidated_claim_violation("Done. It should take a few minutes.") == (
|
|
914
|
+
'No tool validated this: "It should take a few minutes.". Run the check yourself or through'
|
|
915
|
+
" a subagent, then state what the run, log line, output, or file and line shows, or leave"
|
|
916
|
+
" the claim out."
|
|
917
|
+
)
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def test_should_skip_the_citation_check_without_a_transcript() -> None:
|
|
921
|
+
assert reply_length_gate.unseen_citation_violation(RUN_LINK_REPLY, None) is None
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def test_should_leave_the_transcript_unread_when_the_text_cites_nothing(
|
|
925
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
926
|
+
) -> None:
|
|
927
|
+
all_read_paths: list[object] = []
|
|
928
|
+
monkeypatch.setattr(reply_length_gate, "successful_result_text", all_read_paths.append)
|
|
929
|
+
transcript_path = write_tool_result_transcript(tmp_path / "s.jsonl", "nothing cited")
|
|
930
|
+
assert reply_length_gate.unseen_citation_violation("Two builds left.", transcript_path) is None
|
|
931
|
+
assert all_read_paths == []
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def test_should_split_prose_sentences_outside_fenced_blocks() -> None:
|
|
935
|
+
reply_text = "First one. Second one?\n```\nskipped. code\n```\nThird"
|
|
936
|
+
assert reply_length_gate.prose_sentences(reply_text) == ["First one.", "Second one?", "", "Third"]
|
|
@@ -4,8 +4,9 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
4
4
|
|
|
5
5
|
## Checks
|
|
6
6
|
|
|
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. 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
|
-
-
|
|
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
|
+
- `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.
|
|
9
|
+
- 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.
|
|
9
10
|
- `blocking/edit_marker_gate.py` rejects strikethrough and edit-note markers in replacement chat text or cards.
|
|
10
11
|
- `blocking/issue_close_handoff_gate.py` denies an issue comment or issue write that closes the issue and routes a found defect to another issue or epic.
|
|
11
12
|
- `blocking/session_title_format_gate.py` denies a session title that breaks the `<emoji> <name>` shape or has a name longer than 25 characters.
|
|
@@ -18,7 +19,8 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
18
19
|
|
|
19
20
|
## When it fires
|
|
20
21
|
|
|
21
|
-
- `blocking/reply_length_gate.py` runs on `PreToolUse`, matcher `mcp__hearthbot__reply|mcp__hearthbot__post_message|mcp__hearthbot__ask_decision|mcp__hearthbot__post_widget`, timeout `10` seconds in `hooks.json`.
|
|
22
|
+
- `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`.
|
|
23
|
+
- `blocking/final_reply_gate.py` runs on `Stop`, timeout `10` seconds in `hooks.json`.
|
|
22
24
|
- 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`.
|
|
23
25
|
- `blocking/edit_marker_gate.py` runs on `PreToolUse`, matcher `mcp__.*__update_message`, timeout `10` seconds in `hooks.json`.
|
|
24
26
|
- `blocking/issue_close_handoff_gate.py` runs on `PreToolUse`, matcher `mcp__.*__(add_issue_comment|issue_write|update_issue_comment)`, timeout `10` seconds in `hooks.json`.
|
|
@@ -37,6 +39,7 @@ Preconditions:
|
|
|
37
39
|
- Run each command from the repository root with Python and pytest available. The tests create transcript and payload fixtures.
|
|
38
40
|
|
|
39
41
|
- **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`.
|
|
42
|
+
- **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.
|
|
40
43
|
- **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.
|
|
41
44
|
- **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.
|
|
42
45
|
- **Handoff close.** Input is an issue comment that says the issue is closed and that the found defect belongs to another issue. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_issue_close_handoff_gate.py -q`. The adjacent test observes a denial; a close with a plain cross-reference and a routing comment on an open issue both pass.
|
|
@@ -53,6 +56,7 @@ Preconditions:
|
|
|
53
56
|
- `blocking/step_note_gate.py` is off by default and polls for the transcript record. Subagent calls and unreadable records pass.
|
|
54
57
|
- `blocking/verify_before_acting.py` runs after the tool. A block asks the agent to check the claim and undo a contradicted change.
|
|
55
58
|
- 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.
|
|
59
|
+
- The final reply gate blocks the stop and returns its reason to the model, which rewrites the reply. Claude Code ends the turn anyway after eight blocks in a row. The cited number checks in both gates skip when the transcript cannot be read.
|
|
56
60
|
- The edit marker gate scans replacement cards as well as the message text.
|
|
57
61
|
- 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.
|
|
58
62
|
- 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.
|
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."
|
|
@@ -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"
|
|
@@ -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"],
|