claude-dev-env 8.81.0 → 8.82.1

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.
@@ -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
- The banned-word check also reads the prose of a decision card: its question,
37
- its context, and each option's label and consequence.
38
- A decision card gets no length check.
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
- all_sentence_texts = [
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 unsourced_cause_violation(reply_text: str) -> str | None:
191
- """Return the deny reason for the first causal sentence that cites no evidence itself, or None."""
192
- all_sentences = [
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
- for each_sentence in all_sentences:
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 banned_word_violation(card_text, configured_banned_words()) or (
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 == DECISION_CARD_TOOL_NAME:
320
- card_text = CARD_TEXT_SEPARATOR.join(all_card_texts(all_tool_input))
321
- violation = card_violation(card_text, mode_rules)
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
- - 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, claims labeled as unchecked, 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.
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; 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."
@@ -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"
@@ -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.81.0",
3
+ "version": "8.82.1",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,7 +26,7 @@ else:
26
26
  import codex_account_choice
27
27
  import codex_account_meters
28
28
  from claude_account_profile import default_profile_home, validate_profile_name
29
- from claude_chain_usage import WeeklyUtilizationProbeError, probe_account_meters
29
+ from claude_chain_usage import ClaudeSignedOutError, WeeklyUtilizationProbeError, probe_account_meters
30
30
  from dev_env_scripts_constants.account_broker_constants import (
31
31
  ALL_BATCH_FILE_EXTENSIONS,
32
32
  ALL_CLAUDE_BINARY_CANDIDATE_RELATIVE_PARTS,
@@ -41,6 +41,7 @@ from dev_env_scripts_constants.account_broker_constants import (
41
41
  CMD_SHELL_METACHARACTERS,
42
42
  Decision,
43
43
  JobOutcome,
44
+ ALL_LOGIN_REFRESH_ENVIRONMENT,
44
45
  LOGIN_REFRESH_TIMEOUT_SECONDS,
45
46
  METER_READ_ATTEMPTS,
46
47
  Meters,
@@ -253,7 +254,7 @@ def refresh_claude_login(account_home: Path) -> bool:
253
254
  claude_path = resolve_claude_path()
254
255
  if claude_path is None:
255
256
  return False
256
- refresh_environment = {**os.environ, CLAUDE_CONFIG_DIR_ENV_VAR: str(account_home)}
257
+ refresh_environment = {**os.environ, **ALL_LOGIN_REFRESH_ENVIRONMENT, CLAUDE_CONFIG_DIR_ENV_VAR: str(account_home)}
257
258
  try:
258
259
  subprocess.run(
259
260
  [str(claude_path), *ALL_LOGIN_REFRESH_ARGUMENTS],
@@ -325,7 +326,7 @@ def read_meters_with_backoff(read_meters: Callable[[Account], Meters | None], ac
325
326
  Returns:
326
327
  The meters from the first read that succeeds, or None with the last
327
328
  failure text once every attempt failed. A missing Codex executable
328
- fails at once, without a retry.
329
+ or a signed-out Claude account fails at once, without a retry.
329
330
  """
330
331
  unread_reason = None
331
332
  for each_attempt in range(METER_READ_ATTEMPTS):
@@ -333,7 +334,7 @@ def read_meters_with_backoff(read_meters: Callable[[Account], Meters | None], ac
333
334
  time.sleep(meter_read_delay_seconds(each_attempt))
334
335
  try:
335
336
  return read_meters(account), None
336
- except codex_account_meters.CodexExecutableNotFoundError as error:
337
+ except (codex_account_meters.CodexExecutableNotFoundError, ClaudeSignedOutError) as error:
337
338
  return None, str(error)
338
339
  except (
339
340
  codex_account_meters.CodexMeterUnreadError,
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import http.client
6
6
  import importlib.util
7
+ import json
7
8
  import sys
8
9
  import urllib.error
9
10
  from dataclasses import dataclass
@@ -13,6 +14,8 @@ from types import ModuleType
13
14
  from typing import Callable
14
15
 
15
16
  from dev_env_scripts_constants.claude_chain_usage_constants import (
17
+ CREDENTIALS_OAUTH_SECTION_KEY,
18
+ CREDENTIALS_REFRESH_TOKEN_KEY,
16
19
  NO_ACCESS_TOKEN_ERROR_TEMPLATE,
17
20
  RESOLVE_USAGE_WINDOW_FILENAME,
18
21
  RESOLVE_USAGE_WINDOW_MISSING_ERROR_TEMPLATE,
@@ -29,6 +32,10 @@ class WeeklyUtilizationProbeError(Exception):
29
32
  """Raised when an account's usage meters cannot be measured."""
30
33
 
31
34
 
35
+ class ClaudeSignedOutError(WeeklyUtilizationProbeError):
36
+ """Raised when an account has no usable token and no way to get one, so a retry cannot help."""
37
+
38
+
32
39
  def _usage_pause_scripts_directory(module_file: Path) -> Path:
33
40
  """Return the usage-pause scripts directory beside this scripts tree.
34
41
 
@@ -111,6 +118,24 @@ class AccountUsageMeters:
111
118
  weekly_resets_at: datetime | None
112
119
 
113
120
 
121
+ def _holds_refresh_token(credentials_path: Path) -> bool:
122
+ """Report whether a credential file holds a refresh token the CLI can renew a login from.
123
+
124
+ Args:
125
+ credentials_path: The account's CLI credential file.
126
+
127
+ Returns:
128
+ True when the file's OAuth section carries a non-empty refresh token.
129
+ """
130
+ try:
131
+ credentials_payload = json.loads(credentials_path.read_text(encoding="utf-8"))
132
+ except (OSError, ValueError):
133
+ return False
134
+ oauth_section = credentials_payload.get(CREDENTIALS_OAUTH_SECTION_KEY) if isinstance(credentials_payload, dict) else None
135
+ refresh_token = oauth_section.get(CREDENTIALS_REFRESH_TOKEN_KEY) if isinstance(oauth_section, dict) else None
136
+ return isinstance(refresh_token, str) and bool(refresh_token)
137
+
138
+
114
139
  def _read_access_token(
115
140
  usage_window_resolver: ModuleType,
116
141
  credentials_path: Path,
@@ -123,6 +148,7 @@ def _read_access_token(
123
148
 
124
149
  credential file holds a token -> that token
125
150
  token expired, refresh_login saves a new one -> the new token
151
+ no token and no refresh token (signed out) -> no refresh_login call
126
152
  no token, the session's own credential path -> the session ingress token
127
153
  no token, another account's credential path -> None
128
154
 
@@ -142,7 +168,7 @@ def _read_access_token(
142
168
  file_token = usage_window_resolver.read_oauth_access_token(credentials_path, now)
143
169
  if file_token is not None:
144
170
  return file_token
145
- if refresh_login is not None and credentials_path.is_file():
171
+ if refresh_login is not None and _holds_refresh_token(credentials_path):
146
172
  refresh_login(credentials_path.parent)
147
173
  refreshed_token = usage_window_resolver.read_oauth_access_token(credentials_path, datetime.now().astimezone())
148
174
  if refreshed_token is not None:
@@ -162,7 +188,7 @@ def _fetch_account_usage_payload(
162
188
  try:
163
189
  access_token = _read_access_token(usage_window_resolver, credentials_path, now, refresh_login)
164
190
  if access_token is None:
165
- raise WeeklyUtilizationProbeError(
191
+ raise ClaudeSignedOutError(
166
192
  NO_ACCESS_TOKEN_ERROR_TEMPLATE.format(credentials_path=credentials_path)
167
193
  )
168
194
  return usage_window_resolver._fetch_usage_payload(access_token)
@@ -68,7 +68,33 @@ ALL_CLAUDE_BINARY_CANDIDATE_RELATIVE_PARTS: tuple[tuple[str, ...], ...] = (
68
68
  (".local", "bin", "claude.exe"),
69
69
  (".local", "bin", "claude"),
70
70
  )
71
- ALL_LOGIN_REFRESH_ARGUMENTS: tuple[str, ...] = ("-p", "ok", "--model", "haiku")
71
+ ALL_LOGIN_REFRESH_ARGUMENTS: tuple[str, ...] = (
72
+ "-p",
73
+ "ok",
74
+ "--model",
75
+ "haiku",
76
+ "--setting-sources",
77
+ "local",
78
+ "--settings",
79
+ '{"enabledPlugins": {}, "autoMemoryEnabled": false}',
80
+ "--strict-mcp-config",
81
+ "--mcp-config",
82
+ '{"mcpServers": {}}',
83
+ "--no-session-persistence",
84
+ "--disable-slash-commands",
85
+ "--tools",
86
+ "",
87
+ "--max-turns",
88
+ "1",
89
+ )
90
+ ALL_LOGIN_REFRESH_ENVIRONMENT: dict[str, str] = {
91
+ "CLAUDE_CODE_DISABLE_CLAUDE_MDS": "1",
92
+ "CLAUDE_CODE_DISABLE_AUTO_MEMORY": "1",
93
+ "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
94
+ "DISABLE_TELEMETRY": "1",
95
+ "DISABLE_ERROR_REPORTING": "1",
96
+ "DISABLE_AUTOUPDATER": "1",
97
+ }
72
98
  LOGIN_REFRESH_TIMEOUT_SECONDS = 120.0
73
99
  ALL_PARENT_CLAUDE_SESSION_VARIABLES: frozenset[str] = frozenset({
74
100
  "CLAUDE_CODE_SESSION_ID",
@@ -42,6 +42,12 @@ JSON_ERROR_KEY: str = "error"
42
42
  CLI_CONFIG_PATH_FLAG: str = "--config-path"
43
43
  """CLI flag that overrides the chain configuration file path."""
44
44
 
45
+ CREDENTIALS_OAUTH_SECTION_KEY: str = "claudeAiOauth"
46
+ """Credential file key holding the CLI's OAuth login."""
47
+
48
+ CREDENTIALS_REFRESH_TOKEN_KEY: str = "refreshToken"
49
+ """OAuth section key holding the token the CLI renews a login from; empty when signed out."""
50
+
45
51
  NO_ACCESS_TOKEN_ERROR_TEMPLATE: str = (
46
52
  "no usable bearer token from the OAuth credential file at {credentials_path}"
47
53
  )
@@ -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_",)
@@ -348,6 +348,22 @@ def test_should_cap_the_meter_read_delay(monkeypatch: pytest.MonkeyPatch, tmp_pa
348
348
  assert all_delays == [1.0, 2.0, 4.0, 8.0, 8.0]
349
349
 
350
350
 
351
+ def test_should_read_a_signed_out_claude_account_once(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
352
+ all_delays = _recorded_sleeps(monkeypatch)
353
+ all_reads: list[Account] = []
354
+
355
+ def signed_out_reader(account: Account) -> Meters:
356
+ all_reads.append(account)
357
+ raise support.ClaudeSignedOutError("no usable bearer token")
358
+
359
+ meters, unread_reason = support.read_meters_with_backoff(signed_out_reader, Account(Product.CLAUDE, "main", tmp_path))
360
+
361
+ assert meters is None
362
+ assert unread_reason == "no usable bearer token"
363
+ assert len(all_reads) == 1
364
+ assert all_delays == []
365
+
366
+
351
367
  def test_should_cache_account_reading_under_injected_state_path(
352
368
  monkeypatch: pytest.MonkeyPatch, tmp_path: Path
353
369
  ) -> None:
@@ -604,19 +620,35 @@ def test_should_match_order_names_to_an_account_named_by_its_windows_launcher_pa
604
620
 
605
621
 
606
622
  def test_should_run_the_cli_against_the_account_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
607
- all_calls: list[tuple[list[str], str]] = []
623
+ all_calls: list[tuple[list[str], dict[str, str]]] = []
608
624
 
609
625
  def fake_run(command: list[str], **keyword_arguments: object) -> subprocess.CompletedProcess[bytes]:
610
626
  environment = keyword_arguments["env"]
611
627
  assert isinstance(environment, dict)
612
- all_calls.append((command, environment["CLAUDE_CONFIG_DIR"]))
628
+ all_calls.append((command, environment))
613
629
  return subprocess.CompletedProcess(command, 0)
614
630
 
615
631
  monkeypatch.setattr(support, "resolve_claude_path", lambda: Path("claude"))
616
632
  monkeypatch.setattr(support.subprocess, "run", fake_run)
617
633
 
618
634
  assert support.refresh_claude_login(tmp_path) is True
619
- assert all_calls == [(["claude", "-p", "ok", "--model", "haiku"], str(tmp_path))]
635
+ [(command, environment)] = all_calls
636
+ assert command[:5] == ["claude", "-p", "ok", "--model", "haiku"]
637
+ assert command[command.index("--setting-sources") + 1] == "local"
638
+ assert json.loads(command[command.index("--settings") + 1]) == {"enabledPlugins": {}, "autoMemoryEnabled": False}
639
+ assert "--strict-mcp-config" in command
640
+ assert json.loads(command[command.index("--mcp-config") + 1]) == {"mcpServers": {}}
641
+ assert "--no-session-persistence" in command
642
+ assert "--disable-slash-commands" in command
643
+ assert command[command.index("--tools") + 1] == ""
644
+ assert command[command.index("--max-turns") + 1] == "1"
645
+ assert environment["CLAUDE_CONFIG_DIR"] == str(tmp_path)
646
+ assert environment["CLAUDE_CODE_DISABLE_CLAUDE_MDS"] == "1"
647
+ assert environment["CLAUDE_CODE_DISABLE_AUTO_MEMORY"] == "1"
648
+ assert environment["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] == "1"
649
+ assert environment["DISABLE_TELEMETRY"] == "1"
650
+ assert environment["DISABLE_ERROR_REPORTING"] == "1"
651
+ assert environment["DISABLE_AUTOUPDATER"] == "1"
620
652
 
621
653
 
622
654
  def test_should_skip_the_refresh_without_a_cli(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
5
6
  import subprocess
6
7
  import sys
7
8
  import urllib.error
@@ -212,11 +213,18 @@ def _expiring_resolver(all_tokens: list[str | None], all_fetched_tokens: list[st
212
213
  return Resolver()
213
214
 
214
215
 
216
+ def _write_credentials(credentials_path: Path, refresh_token: str) -> None:
217
+ credentials_path.write_text(
218
+ json.dumps({"claudeAiOauth": {"accessToken": "", "refreshToken": refresh_token, "expiresAt": 0}}),
219
+ encoding="utf-8",
220
+ )
221
+
222
+
215
223
  def test_should_refresh_an_expired_login_and_read_the_meter(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
216
224
  account_home = tmp_path / "ev"
217
225
  account_home.mkdir()
218
226
  credentials_path = account_home / ".credentials.json"
219
- credentials_path.write_text("{}", encoding="utf-8")
227
+ _write_credentials(credentials_path, "refresh-value")
220
228
  all_refreshed_homes: list[Path] = []
221
229
  all_fetched_tokens: list[str] = []
222
230
  monkeypatch.setattr(usage, "_load_resolve_usage_window_module", lambda: _expiring_resolver([None, "fresh-token"], all_fetched_tokens))
@@ -230,8 +238,23 @@ def test_should_refresh_an_expired_login_and_read_the_meter(monkeypatch: pytest.
230
238
 
231
239
  def test_should_report_no_token_when_the_refresh_saves_none(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
232
240
  credentials_path = tmp_path / ".credentials.json"
233
- credentials_path.write_text("{}", encoding="utf-8")
241
+ _write_credentials(credentials_path, "refresh-value")
234
242
  monkeypatch.setattr(usage, "_load_resolve_usage_window_module", lambda: _expiring_resolver([None, None], []))
235
243
 
236
- with pytest.raises(usage.WeeklyUtilizationProbeError, match="bearer token"):
244
+ with pytest.raises(usage.ClaudeSignedOutError, match="bearer token"):
237
245
  usage.probe_account_meters(credentials_path, lambda home: True)
246
+
247
+
248
+ @pytest.mark.parametrize("credentials_text", ["{}", '{"claudeAiOauth": {"accessToken": "", "refreshToken": "", "expiresAt": 0}}', "not json"])
249
+ def test_should_skip_the_refresh_for_a_signed_out_account(
250
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path, credentials_text: str
251
+ ) -> None:
252
+ credentials_path = tmp_path / ".credentials.json"
253
+ credentials_path.write_text(credentials_text, encoding="utf-8")
254
+ all_refreshed_homes: list[Path] = []
255
+ monkeypatch.setattr(usage, "_load_resolve_usage_window_module", lambda: _expiring_resolver([None], []))
256
+
257
+ with pytest.raises(usage.ClaudeSignedOutError, match="bearer token"):
258
+ usage.probe_account_meters(credentials_path, all_refreshed_homes.append)
259
+
260
+ assert all_refreshed_homes == []
@@ -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"],