claude-dev-env 8.84.1 → 8.85.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.
@@ -35,11 +35,12 @@ test('reinstall drops the retired style reminder and keeps one skill reminder ho
35
35
  mergeHooksIntoSettings(settings, hooksConfig, 'C:/Users/example/.claude', 'python');
36
36
 
37
37
  const userPromptHooks = settings.hooks.UserPromptSubmit.flatMap(eachGroup => eachGroup.hooks);
38
- assert.equal(userPromptHooks.length, 4);
38
+ assert.equal(userPromptHooks.length, 5);
39
39
  assert.equal(userPromptHooks[0].command, userCommand);
40
40
  assert.match(userPromptHooks[1].command, /session\/skill_loaded_reminder\.py$/);
41
41
  assert.match(userPromptHooks[2].command, /session\/deepen_first_prompt\.py$/);
42
42
  assert.match(userPromptHooks[3].command, /lifecycle\/nested_project_hooks\.py$/);
43
+ assert.match(userPromptHooks[4].command, /blocking\/chat_reply_gate\.py$/);
43
44
  assert.ok(userPromptHooks.every(eachHook => !eachHook.command.includes('style_reminder_prompt')));
44
45
 
45
46
  const firstMerge = JSON.stringify(settings);
@@ -0,0 +1,246 @@
1
+ #!/usr/bin/env python3
2
+ """Hook that shows the user one short chat reply per message.
3
+
4
+ ``reply_length_gate.py`` checks only the chat tools that post to Slack.
5
+ A reply typed straight into the chat is no tool call, so this hook runs
6
+ the same checks on it::
7
+
8
+ UserPromptSubmit -> additionalContext states the rules
9
+ MessageDisplay, batch before the last -> hold the text, show nothing yet
10
+ MessageDisplay, last batch, passes -> show the whole message
11
+ MessageDisplay, last batch, fails -> show a draft from a small model that passes
12
+
13
+ A Stop hook would see the reply only after it is on screen, so each block
14
+ would add a second reply under the first. MessageDisplay changes the text
15
+ before it renders. The transcript keeps the original, so the next
16
+ UserPromptSubmit tells the agent the text the user saw.
17
+
18
+ The draft comes from ``claude -p`` on DEFAULT_DRAFTER_MODEL with no setting
19
+ sources and no tools. The draft must pass ``reply_violation``; after
20
+ MAXIMUM_DRAFT_ATTEMPTS failed drafts, the original shows.
21
+
22
+ The checks come from ``reply_length_gate.reply_violation``: at most
23
+ MAXIMUM_SENTENCE_COUNT sentences of at most MAXIMUM_WORDS_PER_SENTENCE
24
+ words, no mid-sentence colon, no em dash, linked pull requests, a source
25
+ for each causal claim, and no banned word.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import os
32
+ import shutil
33
+ import subprocess
34
+ import sys
35
+ import tempfile
36
+ import uuid
37
+ from pathlib import Path
38
+
39
+ hooks_root_directory = str(Path(__file__).resolve().parent.parent)
40
+ if hooks_root_directory not in sys.path:
41
+ sys.path.insert(0, hooks_root_directory)
42
+ blocking_directory = str(Path(__file__).resolve().parent)
43
+ if blocking_directory not in sys.path:
44
+ sys.path.insert(0, blocking_directory)
45
+
46
+ from reply_length_gate import reply_violation
47
+ from hooks_constants.chat_reply_gate_constants import (
48
+ CLAUDE_SESSION_ID_ENV_VAR,
49
+ DEFAULT_DRAFTER_MODEL,
50
+ DELTA_KEY,
51
+ DISPLAY_CONTENT_KEY,
52
+ DRAFTER_GUARD_ENV_VAR,
53
+ DRAFTER_MODEL_ENV_VAR,
54
+ DRAFTER_REQUEST_TEMPLATE,
55
+ DRAFTER_ERROR_KEY,
56
+ DRAFTER_EXECUTABLE_NAME,
57
+ DRAFTER_RESULT_KEY,
58
+ DRAFTER_RULES,
59
+ DRAFTER_TIMEOUT_SECONDS,
60
+ FINAL_KEY,
61
+ HELD_TEXT_FILE_PREFIX,
62
+ HOOK_EVENT_NAME_INPUT_KEY,
63
+ MAXIMUM_DRAFT_ATTEMPTS,
64
+ MESSAGE_DISPLAY_EVENT_NAME,
65
+ MESSAGE_ID_KEY,
66
+ REPLY_LIMIT_REMINDER,
67
+ SESSION_END_EVENT_NAME,
68
+ SESSION_ID_KEY,
69
+ SHOWN_DRAFT_FILE_PREFIX,
70
+ SHOWN_DRAFT_PREFIX,
71
+ STATE_FILE_ENCODING,
72
+ STATE_FILE_SUFFIX,
73
+ USER_PROMPT_SUBMIT_EVENT_NAME,
74
+ )
75
+ from hooks_constants.hook_block_logger import log_hook_block
76
+ from hooks_constants.hook_specific_output_keys import (
77
+ HOOK_EVENT_NAME_KEY,
78
+ HOOK_SPECIFIC_OUTPUT_KEY,
79
+ )
80
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
81
+ from hooks_constants.reply_length_gate_constants import (
82
+ ADDITIONAL_CONTEXT_KEY,
83
+ ALLOW_EXIT_CODE,
84
+ )
85
+ from hooks_constants.session_file_id import session_file_id
86
+
87
+
88
+ def state_file_path(file_prefix: str, state_key: object) -> Path:
89
+ """Return the temp file that holds one message's held text or one session's shown draft."""
90
+ file_name = f"{file_prefix}{session_file_id(state_key)}{STATE_FILE_SUFFIX}"
91
+ return Path(tempfile.gettempdir()) / file_name
92
+
93
+
94
+ def read_state_text(state_path: Path) -> str:
95
+ """Return the stored text, or an empty string when the file is absent or unreadable."""
96
+ try:
97
+ return state_path.read_text(encoding=STATE_FILE_ENCODING)
98
+ except OSError:
99
+ return ""
100
+
101
+
102
+ def write_state_text(state_path: Path, state_text: str) -> None:
103
+ """Store the text; a failed write leaves the shown text unchanged."""
104
+ try:
105
+ state_path.write_text(state_text, encoding=STATE_FILE_ENCODING)
106
+ except OSError:
107
+ return
108
+
109
+
110
+ def drafter_command() -> list[str]:
111
+ """Return the headless claude command that drafts with no settings and no tools."""
112
+ return [
113
+ shutil.which(DRAFTER_EXECUTABLE_NAME) or DRAFTER_EXECUTABLE_NAME,
114
+ "-p",
115
+ "--model",
116
+ os.environ.get(DRAFTER_MODEL_ENV_VAR, DEFAULT_DRAFTER_MODEL),
117
+ "--setting-sources",
118
+ "",
119
+ "--tools",
120
+ "",
121
+ "--no-session-persistence",
122
+ "--session-id",
123
+ str(uuid.uuid4()),
124
+ "--output-format",
125
+ "json",
126
+ "--system-prompt",
127
+ DRAFTER_RULES,
128
+ ]
129
+
130
+
131
+ def drafter_environment() -> dict[str, str]:
132
+ """Return the drafter's environment, marked so this hook exits inside it."""
133
+ environment = dict(os.environ)
134
+ environment[DRAFTER_GUARD_ENV_VAR] = "1"
135
+ environment.pop(CLAUDE_SESSION_ID_ENV_VAR, None)
136
+ return environment
137
+
138
+
139
+ def ask_drafter(reply_text: str, violation: str) -> str | None:
140
+ """Return one draft from the small model, or None when the call fails."""
141
+ try:
142
+ completed = subprocess.run(
143
+ drafter_command(),
144
+ input=DRAFTER_REQUEST_TEMPLATE.format(violation=violation, reply_text=reply_text),
145
+ capture_output=True,
146
+ text=True,
147
+ timeout=DRAFTER_TIMEOUT_SECONDS,
148
+ env=drafter_environment(),
149
+ check=False,
150
+ )
151
+ drafter_output = json.loads(completed.stdout)
152
+ draft_text = drafter_output.get(DRAFTER_RESULT_KEY)
153
+ except (OSError, subprocess.TimeoutExpired, json.JSONDecodeError, AttributeError):
154
+ return None
155
+ if drafter_output.get(DRAFTER_ERROR_KEY) is True:
156
+ return None
157
+ if not isinstance(draft_text, str) or not draft_text.strip():
158
+ return None
159
+ return draft_text.strip()
160
+
161
+
162
+ def compliant_draft(reply_text: str, violation: str) -> str | None:
163
+ """Return a draft that passes ``reply_violation``, or None after MAXIMUM_DRAFT_ATTEMPTS."""
164
+ current_violation = violation
165
+ for _ in range(MAXIMUM_DRAFT_ATTEMPTS):
166
+ draft_text = ask_drafter(reply_text, current_violation)
167
+ if draft_text is None:
168
+ continue
169
+ draft_violation = reply_violation(draft_text, None, None)
170
+ if draft_violation is None:
171
+ return draft_text
172
+ current_violation = draft_violation
173
+ return None
174
+
175
+
176
+ def shown_text(all_hook_input: dict[str, object]) -> str | None:
177
+ """Return the text to show for this batch, or None to show the batch as it is."""
178
+ message_id = all_hook_input.get(MESSAGE_ID_KEY)
179
+ if not isinstance(message_id, str) or not message_id:
180
+ return None
181
+ held_path = state_file_path(HELD_TEXT_FILE_PREFIX, message_id)
182
+ held_text = read_state_text(held_path)
183
+ message_text = held_text + str(all_hook_input.get(DELTA_KEY) or "")
184
+ if not all_hook_input.get(FINAL_KEY):
185
+ write_state_text(held_path, message_text)
186
+ return ""
187
+ held_path.unlink(missing_ok=True)
188
+ shown_draft_path = state_file_path(SHOWN_DRAFT_FILE_PREFIX, all_hook_input.get(SESSION_ID_KEY))
189
+ violation = reply_violation(message_text, None, None)
190
+ draft_text = None if violation is None else compliant_draft(message_text, violation)
191
+ if violation is None or draft_text is None:
192
+ shown_draft_path.unlink(missing_ok=True)
193
+ return message_text if held_text else None
194
+ write_state_text(shown_draft_path, draft_text)
195
+ log_hook_block(
196
+ Path(__file__).name,
197
+ MESSAGE_DISPLAY_EVENT_NAME,
198
+ violation,
199
+ offending_input_preview=message_text,
200
+ )
201
+ return draft_text
202
+
203
+
204
+ def write_hook_output(event_name: str, output_key: str, output_text: str) -> None:
205
+ """Print one hookSpecificOutput field for the event."""
206
+ hook_event_output = {HOOK_EVENT_NAME_KEY: event_name, output_key: output_text}
207
+ sys.stdout.write(json.dumps({HOOK_SPECIFIC_OUTPUT_KEY: hook_event_output}))
208
+
209
+
210
+ def reply_limit_reminder(session_id: object) -> str:
211
+ """Return the reply rules, plus the draft the user saw in place of the last reply."""
212
+ shown_draft_path = state_file_path(SHOWN_DRAFT_FILE_PREFIX, session_id)
213
+ shown_draft = read_state_text(shown_draft_path)
214
+ if not shown_draft:
215
+ return REPLY_LIMIT_REMINDER
216
+ shown_draft_path.unlink(missing_ok=True)
217
+ return REPLY_LIMIT_REMINDER + SHOWN_DRAFT_PREFIX + shown_draft
218
+
219
+
220
+ def main() -> int:
221
+ if os.environ.get(DRAFTER_GUARD_ENV_VAR):
222
+ return ALLOW_EXIT_CODE
223
+ hook_input = read_hook_input_dictionary_from_stdin()
224
+ if hook_input is None:
225
+ return ALLOW_EXIT_CODE
226
+ event_name = hook_input.get(HOOK_EVENT_NAME_INPUT_KEY)
227
+ if event_name == USER_PROMPT_SUBMIT_EVENT_NAME:
228
+ write_hook_output(
229
+ USER_PROMPT_SUBMIT_EVENT_NAME,
230
+ ADDITIONAL_CONTEXT_KEY,
231
+ reply_limit_reminder(hook_input.get(SESSION_ID_KEY)),
232
+ )
233
+ return ALLOW_EXIT_CODE
234
+ if event_name == SESSION_END_EVENT_NAME:
235
+ state_file_path(SHOWN_DRAFT_FILE_PREFIX, hook_input.get(SESSION_ID_KEY)).unlink(missing_ok=True)
236
+ return ALLOW_EXIT_CODE
237
+ if event_name != MESSAGE_DISPLAY_EVENT_NAME:
238
+ return ALLOW_EXIT_CODE
239
+ display_text = shown_text(hook_input)
240
+ if display_text is not None:
241
+ write_hook_output(MESSAGE_DISPLAY_EVENT_NAME, DISPLAY_CONTENT_KEY, display_text)
242
+ return ALLOW_EXIT_CODE
243
+
244
+
245
+ if __name__ == "__main__":
246
+ sys.exit(main())
@@ -33,11 +33,10 @@ from inventory_intent_records.config.intent_records_constants import (
33
33
  )
34
34
 
35
35
  from hooks_constants.session_edit_stage_gate_constants import (
36
- SESSION_ID_UNSAFE_CHARACTERS_PATTERN,
37
36
  STATE_FILE_ATOMIC_WRITE_SUFFIX,
38
- STATE_FILE_DEFAULT_SESSION_ID,
39
37
  STATE_FILE_JSON_INDENT_SPACES,
40
38
  )
39
+ from hooks_constants.session_file_id import session_file_id
41
40
 
42
41
 
43
42
  def _intent_file_path(session_id: str) -> Path:
@@ -50,9 +49,7 @@ def _intent_file_path(session_id: str) -> Path:
50
49
  Returns:
51
50
  The absolute path of this session's intent-records file.
52
51
  """
53
- sanitized_session_id = SESSION_ID_UNSAFE_CHARACTERS_PATTERN.sub("", session_id)
54
- effective_session_id = sanitized_session_id or STATE_FILE_DEFAULT_SESSION_ID
55
- file_name = f"{INTENT_FILE_PREFIX}{effective_session_id}{INTENT_FILE_SUFFIX}"
52
+ file_name = f"{INTENT_FILE_PREFIX}{session_file_id(session_id)}{INTENT_FILE_SUFFIX}"
56
53
  return Path(tempfile.gettempdir()) / file_name
57
54
 
58
55
 
@@ -0,0 +1,224 @@
1
+ import io
2
+ import json
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ import chat_reply_gate
8
+
9
+ FOUR_SENTENCE_REPLY = "PR one merged. PR two is queued. PR three needs a fix. Nothing else is open."
10
+ THREE_SENTENCE_REPLY = "PR one merged. PR two is queued. Nothing else is open."
11
+ PASSING_DRAFT = "PR one merged. Two others wait."
12
+
13
+
14
+ @pytest.fixture(autouse=True)
15
+ def default_banned_words(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
16
+ monkeypatch.setenv("CLAUDE_REPLY_BANNED_WORDS_PATH", str(tmp_path / "absent.json"))
17
+ monkeypatch.delenv("CHAT_REPLY_DRAFTER_ACTIVE", raising=False)
18
+ monkeypatch.setattr(chat_reply_gate, "log_hook_block", lambda *_args, **_kwargs: None)
19
+ monkeypatch.setattr(chat_reply_gate.tempfile, "gettempdir", lambda: str(tmp_path))
20
+
21
+
22
+ def run_hook(
23
+ monkeypatch: pytest.MonkeyPatch,
24
+ capsys: pytest.CaptureFixture[str],
25
+ hook_input: dict[str, object],
26
+ ) -> tuple[int, dict[str, object] | None]:
27
+ monkeypatch.setattr(
28
+ "sys.stdin", io.TextIOWrapper(io.BytesIO(json.dumps(hook_input).encode("utf-8")))
29
+ )
30
+ exit_code = chat_reply_gate.main()
31
+ output_text = capsys.readouterr().out
32
+ return exit_code, json.loads(output_text)["hookSpecificOutput"] if output_text else None
33
+
34
+
35
+ def display_input(delta: str, is_final: bool = True) -> dict[str, object]:
36
+ return {
37
+ "hook_event_name": "MessageDisplay",
38
+ "session_id": "abc",
39
+ "message_id": "m1",
40
+ "delta": delta,
41
+ "final": is_final,
42
+ }
43
+
44
+
45
+ def test_should_show_a_passing_draft_in_place_of_a_long_reply(
46
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
47
+ ) -> None:
48
+ monkeypatch.setattr(chat_reply_gate, "ask_drafter", lambda *_args: PASSING_DRAFT)
49
+ exit_code, hook_output = run_hook(monkeypatch, capsys, display_input(FOUR_SENTENCE_REPLY))
50
+ assert exit_code == 0
51
+ assert hook_output == {"hookEventName": "MessageDisplay", "displayContent": PASSING_DRAFT}
52
+
53
+
54
+ def test_should_show_a_reply_within_the_limit_unchanged(
55
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
56
+ ) -> None:
57
+ monkeypatch.setattr(chat_reply_gate, "ask_drafter", lambda *_args: pytest.fail("drafter called"))
58
+ exit_code, hook_output = run_hook(monkeypatch, capsys, display_input(THREE_SENTENCE_REPLY))
59
+ assert exit_code == 0
60
+ assert hook_output is None
61
+
62
+
63
+ def test_should_hold_batches_until_the_last_one_arrives(
64
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
65
+ ) -> None:
66
+ all_requests: list[str] = []
67
+ monkeypatch.setattr(
68
+ chat_reply_gate,
69
+ "ask_drafter",
70
+ lambda reply_text, _violation: all_requests.append(reply_text) or PASSING_DRAFT,
71
+ )
72
+ _, first_output = run_hook(
73
+ monkeypatch, capsys, display_input("PR one merged. PR two is queued.\n", False)
74
+ )
75
+ _, last_output = run_hook(
76
+ monkeypatch, capsys, display_input("PR three needs a fix. Nothing else is open.")
77
+ )
78
+ assert first_output == {"hookEventName": "MessageDisplay", "displayContent": ""}
79
+ assert last_output == {"hookEventName": "MessageDisplay", "displayContent": PASSING_DRAFT}
80
+ assert all_requests == [
81
+ "PR one merged. PR two is queued.\nPR three needs a fix. Nothing else is open."
82
+ ]
83
+
84
+
85
+ def test_should_show_the_whole_held_message_when_it_passes(
86
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
87
+ ) -> None:
88
+ run_hook(monkeypatch, capsys, display_input("PR one merged.\n", False))
89
+ _, hook_output = run_hook(monkeypatch, capsys, display_input("Nothing else is open."))
90
+ assert hook_output == {
91
+ "hookEventName": "MessageDisplay",
92
+ "displayContent": "PR one merged.\nNothing else is open.",
93
+ }
94
+
95
+
96
+ def test_should_show_the_original_when_no_draft_passes(
97
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
98
+ ) -> None:
99
+ all_calls: list[str] = []
100
+ monkeypatch.setattr(
101
+ chat_reply_gate,
102
+ "ask_drafter",
103
+ lambda reply_text, _violation: all_calls.append(reply_text) or FOUR_SENTENCE_REPLY,
104
+ )
105
+ _, hook_output = run_hook(monkeypatch, capsys, display_input(FOUR_SENTENCE_REPLY))
106
+ assert hook_output is None
107
+ assert len(all_calls) == 2
108
+
109
+
110
+ def test_should_tell_the_agent_which_draft_the_user_saw(
111
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
112
+ ) -> None:
113
+ monkeypatch.setattr(chat_reply_gate, "ask_drafter", lambda *_args: PASSING_DRAFT)
114
+ run_hook(monkeypatch, capsys, display_input(FOUR_SENTENCE_REPLY))
115
+ prompt_input = {"hook_event_name": "UserPromptSubmit", "session_id": "abc"}
116
+ _, first_output = run_hook(monkeypatch, capsys, prompt_input)
117
+ _, second_output = run_hook(monkeypatch, capsys, prompt_input)
118
+ assert first_output is not None and second_output is not None
119
+ assert str(first_output["additionalContext"]).endswith(PASSING_DRAFT)
120
+ assert PASSING_DRAFT not in str(second_output["additionalContext"])
121
+
122
+
123
+ def test_should_delete_the_shown_draft_at_session_end(
124
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
125
+ ) -> None:
126
+ monkeypatch.setattr(chat_reply_gate, "ask_drafter", lambda *_args: PASSING_DRAFT)
127
+ run_hook(monkeypatch, capsys, display_input(FOUR_SENTENCE_REPLY))
128
+ shown_draft_path = chat_reply_gate.state_file_path("claude-chat-reply-shown-", "abc")
129
+ assert shown_draft_path.exists()
130
+ exit_code, hook_output = run_hook(
131
+ monkeypatch, capsys, {"hook_event_name": "SessionEnd", "session_id": "abc"}
132
+ )
133
+ assert exit_code == 0
134
+ assert hook_output is None
135
+ assert not shown_draft_path.exists()
136
+
137
+
138
+ def test_should_add_the_reply_limit_on_each_user_prompt(
139
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
140
+ ) -> None:
141
+ exit_code, hook_output = run_hook(
142
+ monkeypatch, capsys, {"hook_event_name": "UserPromptSubmit", "prompt": "status?"}
143
+ )
144
+ assert exit_code == 0
145
+ assert hook_output is not None
146
+ assert hook_output["hookEventName"] == "UserPromptSubmit"
147
+ assert "3 sentences or fewer" in str(hook_output["additionalContext"])
148
+
149
+
150
+ def test_should_do_nothing_inside_the_drafter_process(
151
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
152
+ ) -> None:
153
+ monkeypatch.setenv("CHAT_REPLY_DRAFTER_ACTIVE", "1")
154
+ exit_code, hook_output = run_hook(monkeypatch, capsys, display_input(FOUR_SENTENCE_REPLY))
155
+ assert exit_code == 0
156
+ assert hook_output is None
157
+
158
+
159
+ def test_should_ignore_the_stop_event(
160
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
161
+ ) -> None:
162
+ exit_code, hook_output = run_hook(
163
+ monkeypatch,
164
+ capsys,
165
+ {"hook_event_name": "Stop", "last_assistant_message": FOUR_SENTENCE_REPLY},
166
+ )
167
+ assert exit_code == 0
168
+ assert hook_output is None
169
+
170
+
171
+ def test_ask_drafter_should_read_the_result_field(monkeypatch: pytest.MonkeyPatch) -> None:
172
+ class CompletedDrafter:
173
+ stdout = json.dumps({"result": f" {PASSING_DRAFT}\n"})
174
+
175
+ all_commands: list[list[str]] = []
176
+ monkeypatch.setattr(chat_reply_gate.shutil, "which", lambda _name: "C:/npm/claude.cmd")
177
+ monkeypatch.setattr(
178
+ chat_reply_gate.subprocess,
179
+ "run",
180
+ lambda command, **_kwargs: all_commands.append(command) or CompletedDrafter(),
181
+ )
182
+ assert chat_reply_gate.ask_drafter(FOUR_SENTENCE_REPLY, "too long") == PASSING_DRAFT
183
+ assert all_commands[0][:4] == ["C:/npm/claude.cmd", "-p", "--model", "haiku"]
184
+
185
+
186
+ def test_ask_drafter_should_return_none_for_an_error_result(monkeypatch: pytest.MonkeyPatch) -> None:
187
+ class FailedDrafter:
188
+ stdout = json.dumps({"is_error": True, "result": "Credit balance is too low"})
189
+
190
+ monkeypatch.setattr(chat_reply_gate.subprocess, "run", lambda *_args, **_kwargs: FailedDrafter())
191
+ assert chat_reply_gate.ask_drafter(FOUR_SENTENCE_REPLY, "too long") is None
192
+
193
+
194
+ def test_should_forget_the_shown_draft_when_a_later_reply_passes(
195
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
196
+ ) -> None:
197
+ monkeypatch.setattr(chat_reply_gate, "ask_drafter", lambda *_args: PASSING_DRAFT)
198
+ run_hook(monkeypatch, capsys, display_input(FOUR_SENTENCE_REPLY))
199
+ run_hook(monkeypatch, capsys, {**display_input(THREE_SENTENCE_REPLY), "message_id": "m2"})
200
+ _, hook_output = run_hook(monkeypatch, capsys, {"hook_event_name": "UserPromptSubmit", "session_id": "abc"})
201
+ assert hook_output is not None
202
+ assert PASSING_DRAFT not in str(hook_output["additionalContext"])
203
+
204
+
205
+ def test_should_show_a_batch_without_a_message_id_as_it_is(
206
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
207
+ ) -> None:
208
+ monkeypatch.setattr(chat_reply_gate, "ask_drafter", lambda *_args: pytest.fail("drafter called"))
209
+ batch_input = {**display_input("PR one merged. PR two", is_final=False), "message_id": None}
210
+ exit_code, hook_output = run_hook(monkeypatch, capsys, batch_input)
211
+ assert exit_code == 0
212
+ assert hook_output is None
213
+
214
+
215
+ def test_drafter_environment_should_carry_the_guard(monkeypatch: pytest.MonkeyPatch) -> None:
216
+ monkeypatch.setenv("CLAUDE_CODE_SESSION_ID", "parent")
217
+ environment = chat_reply_gate.drafter_environment()
218
+ assert environment["CHAT_REPLY_DRAFTER_ACTIVE"] == "1"
219
+ assert "CLAUDE_CODE_SESSION_ID" not in environment
220
+
221
+
222
+ def test_state_file_path_should_strip_unsafe_characters(tmp_path: Path) -> None:
223
+ state_path = chat_reply_gate.state_file_path("claude-chat-reply-held-", "../a b/c")
224
+ assert state_path == tmp_path / "claude-chat-reply-held-abc.txt"
@@ -7,6 +7,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
7
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
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
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.
10
+ - `blocking/chat_reply_gate.py` runs the `reply_length_gate.py` reply checks on a plain chat reply, which is no tool call. On `UserPromptSubmit` it adds the reply rules as `additionalContext`. On `MessageDisplay` it holds each streamed batch until the message ends. A message that passes shows whole. A message that fails shows as a draft from `claude -p` on Haiku that passes the same checks, and the original shows when two drafts fail. The draft changes only the text on screen, so the next `UserPromptSubmit` tells the agent the text the user saw, and `SessionEnd` deletes the stored draft. The [reply arena harness](https://github.com/jl-cmd-projects/claude-dev-env/tree/160349b90f627ea542fc6bc7b2b7f86dcbf98b73/tests/reply_arena) ran the comparison that chose this display path.
10
11
  - `blocking/edit_marker_gate.py` rejects strikethrough and edit-note markers in replacement chat text or cards.
11
12
  - `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.
12
13
  - `blocking/session_title_format_gate.py` denies a session title that breaks the `<emoji> <name>` shape or has a name longer than 25 characters.
@@ -23,6 +24,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
23
24
  - `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`.
24
25
  - `blocking/final_reply_gate.py` runs on `Stop`, timeout `10` seconds in `hooks.json`.
25
26
  - 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`.
27
+ - `blocking/chat_reply_gate.py` runs on `UserPromptSubmit`, matcher empty, timeout `10` seconds, on `MessageDisplay`, timeout `100` seconds, and on `SessionEnd`, timeout `3` seconds, in `hooks.json`.
26
28
  - `blocking/edit_marker_gate.py` runs on `PreToolUse`, matcher `mcp__.*__update_message`, timeout `10` seconds in `hooks.json`.
27
29
  - `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`.
28
30
  - `blocking/session_title_format_gate.py` runs on `PreToolUse`, matcher `mcp__.*__set_session_title`, timeout `10` seconds in `hooks.json`.
@@ -43,6 +45,7 @@ Preconditions:
43
45
  - **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`.
44
46
  - **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.
45
47
  - **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.
48
+ - **Chat reply.** Input is a `MessageDisplay` event whose message holds four sentences, the same message split over two batches, and a `UserPromptSubmit` event. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_chat_reply_gate.py -q`. The adjacent test observes the drafter's text in `displayContent`, an empty first batch, no output for three sentences, the original after two failed drafts, the shown draft in the next `additionalContext`, and the stored draft deleted at session end.
46
49
  - **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.
47
50
  - **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.
48
51
  - **Session title.** Input is a set session title call with no leading emoji or a name over 25 characters. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_session_title_format_gate.py -q`. The adjacent test observes a denial that names the broken rule; a well-formed title passes.
package/hooks/hooks.json CHANGED
@@ -293,6 +293,11 @@
293
293
  "type": "command",
294
294
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/lifecycle/nested_project_hooks.py",
295
295
  "timeout": 120
296
+ },
297
+ {
298
+ "type": "command",
299
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/chat_reply_gate.py",
300
+ "timeout": 10
296
301
  }
297
302
  ]
298
303
  }
@@ -391,6 +396,11 @@
391
396
  "type": "command",
392
397
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/session_edit_tracker_cleanup.py",
393
398
  "timeout": 3
399
+ },
400
+ {
401
+ "type": "command",
402
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/chat_reply_gate.py",
403
+ "timeout": 3
394
404
  }
395
405
  ]
396
406
  }
@@ -470,6 +480,17 @@
470
480
  }
471
481
  ]
472
482
  }
483
+ ],
484
+ "MessageDisplay": [
485
+ {
486
+ "hooks": [
487
+ {
488
+ "type": "command",
489
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/chat_reply_gate.py",
490
+ "timeout": 100
491
+ }
492
+ ]
493
+ }
473
494
  ]
474
495
  }
475
496
  }
@@ -0,0 +1,58 @@
1
+ """Constants for the chat reply gate UserPromptSubmit and MessageDisplay hook."""
2
+
3
+ from hooks_constants.reply_length_gate_constants import (
4
+ MAXIMUM_SENTENCE_COUNT,
5
+ MAXIMUM_WORDS_PER_SENTENCE,
6
+ )
7
+
8
+ HOOK_EVENT_NAME_INPUT_KEY = "hook_event_name"
9
+ MESSAGE_DISPLAY_EVENT_NAME = "MessageDisplay"
10
+ USER_PROMPT_SUBMIT_EVENT_NAME = "UserPromptSubmit"
11
+ SESSION_END_EVENT_NAME = "SessionEnd"
12
+ SESSION_ID_KEY = "session_id"
13
+ MESSAGE_ID_KEY = "message_id"
14
+ DELTA_KEY = "delta"
15
+ FINAL_KEY = "final"
16
+ DISPLAY_CONTENT_KEY = "displayContent"
17
+ STATE_FILE_ENCODING = "utf-8"
18
+ HELD_TEXT_FILE_PREFIX = "claude-chat-reply-held-"
19
+ SHOWN_DRAFT_FILE_PREFIX = "claude-chat-reply-shown-"
20
+ STATE_FILE_SUFFIX = ".txt"
21
+ REPLY_LIMIT_REMINDER = (
22
+ f"Your reply to the user is final and gets no second pass. Keep it to {MAXIMUM_SENTENCE_COUNT}"
23
+ f" sentences or fewer, each {MAXIMUM_WORDS_PER_SENTENCE} words or fewer."
24
+ " Each line and each list item counts as one sentence, so prefer short prose over lists."
25
+ " Lead with the outcome and cut detail the user did not ask for."
26
+ " No colon in the middle of a sentence, and no em dash."
27
+ " Put any pull request number inside a markdown link."
28
+ " A sentence with so, because, caused, due to, therefore, or means needs inline code, a URL, or a link in it."
29
+ " Never write real, really, genuine, actual, actually, likely, probably, seems, maybe, perhaps, may be, or might be."
30
+ " Count your sentences before you send."
31
+ )
32
+ SHOWN_DRAFT_PREFIX = "\n\nThe user saw your last reply condensed to this text, so build on it:\n\n"
33
+ DRAFTER_GUARD_ENV_VAR = "CHAT_REPLY_DRAFTER_ACTIVE"
34
+ DRAFTER_MODEL_ENV_VAR = "CHAT_REPLY_DRAFTER_MODEL"
35
+ DEFAULT_DRAFTER_MODEL = "haiku"
36
+ DRAFTER_TIMEOUT_SECONDS = 45
37
+ MAXIMUM_DRAFT_ATTEMPTS = 2
38
+ DRAFTER_RESULT_KEY = "result"
39
+ DRAFTER_ERROR_KEY = "is_error"
40
+ DRAFTER_EXECUTABLE_NAME = "claude"
41
+ CLAUDE_SESSION_ID_ENV_VAR = "CLAUDE_CODE_SESSION_ID"
42
+ DRAFTER_REQUEST_TEMPLATE = "Rule the reply broke: {violation}\n\nReply to rewrite:\n\n{reply_text}"
43
+ DRAFTER_RULES = (
44
+ f"Rewrite the reply below so it obeys every rule. At most {MAXIMUM_SENTENCE_COUNT} sentences,"
45
+ f" each at most {MAXIMUM_WORDS_PER_SENTENCE} words. Each line and each list item counts as one sentence."
46
+ " Keep the outcome, the recommendation, and any number, path, or command the user needs."
47
+ " Drop background the user did not ask for."
48
+ " No colon in the middle of a sentence. No em dash."
49
+ " Write a pull request number only inside a markdown link."
50
+ " A sentence that gives a cause, with so, because, caused, due to, therefore, or means,"
51
+ " must hold its own evidence as inline code, a URL, or a markdown link. Otherwise state the fact without the cause word."
52
+ " Never use these words: real, really, genuine, actual, actually, likely, probably, seems, maybe, perhaps,"
53
+ " might be, may be, appears to, suspect, guess, presumably."
54
+ " Speak as the original author to the original reader."
55
+ " Never mention the rule, the length, or this rewrite."
56
+ " Never add, drop, or change a fact, a command, a flag, or a file name; leave out a whole point instead."
57
+ " Return only the rewritten reply, with no preface."
58
+ )
@@ -0,0 +1,17 @@
1
+ """Turn a hook payload's session id into a safe part of a temp file name."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from hooks_constants.session_edit_stage_gate_constants import (
6
+ SESSION_ID_UNSAFE_CHARACTERS_PATTERN,
7
+ STATE_FILE_DEFAULT_SESSION_ID,
8
+ )
9
+
10
+ __all__ = ["session_file_id"]
11
+
12
+
13
+ def session_file_id(session_id: object) -> str:
14
+ """Return the session id with unsafe characters stripped, or the default id when none remain."""
15
+ raw_session_id = session_id if isinstance(session_id, str) else ""
16
+ sanitized_session_id = SESSION_ID_UNSAFE_CHARACTERS_PATTERN.sub("", raw_session_id)
17
+ return sanitized_session_id or STATE_FILE_DEFAULT_SESSION_ID
@@ -0,0 +1,17 @@
1
+ """Tests for the shared session id that names per-session temp files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from hooks_constants.session_file_id import session_file_id
6
+
7
+
8
+ def test_should_strip_path_characters_from_the_session_id() -> None:
9
+ assert session_file_id("../a b/c-1_2") == "abc-1_2"
10
+
11
+
12
+ def test_should_fall_back_to_the_default_id_when_nothing_safe_remains() -> None:
13
+ assert session_file_id("../ /") == "default"
14
+
15
+
16
+ def test_should_fall_back_to_the_default_id_for_a_missing_session_id() -> None:
17
+ assert session_file_id(None) == "default"
@@ -39,11 +39,10 @@ from hooks_constants.session_edit_stage_gate_constants import ( # noqa: E402
39
39
  APPLY_PATCH_TOOL_NAME,
40
40
  SESSION_EDIT_FILE_PREFIX,
41
41
  SESSION_EDIT_FILE_SUFFIX,
42
- SESSION_ID_UNSAFE_CHARACTERS_PATTERN,
43
42
  STATE_FILE_ATOMIC_WRITE_SUFFIX,
44
- STATE_FILE_DEFAULT_SESSION_ID,
45
43
  STATE_FILE_JSON_INDENT_SPACES,
46
44
  )
45
+ from hooks_constants.session_file_id import session_file_id
47
46
 
48
47
 
49
48
  def _resolved_path_or_none(file_path: str) -> str | None:
@@ -74,9 +73,7 @@ def _session_edit_file_path(session_id: str) -> Path:
74
73
  Returns:
75
74
  Absolute path to this session's tracker file.
76
75
  """
77
- sanitized_session_id = SESSION_ID_UNSAFE_CHARACTERS_PATTERN.sub("", session_id)
78
- effective_session_id = sanitized_session_id or STATE_FILE_DEFAULT_SESSION_ID
79
- file_name = f"{SESSION_EDIT_FILE_PREFIX}{effective_session_id}{SESSION_EDIT_FILE_SUFFIX}"
76
+ file_name = f"{SESSION_EDIT_FILE_PREFIX}{session_file_id(session_id)}{SESSION_EDIT_FILE_SUFFIX}"
80
77
  return Path(tempfile.gettempdir()) / file_name
81
78
 
82
79
 
@@ -35,11 +35,10 @@ from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
35
35
  from hooks_constants.session_edit_stage_gate_constants import ( # noqa: E402
36
36
  SESSION_EDIT_FILE_PREFIX,
37
37
  SESSION_EDIT_FILE_SUFFIX,
38
- SESSION_ID_UNSAFE_CHARACTERS_PATTERN,
39
38
  SESSION_START_SOURCE_FRESH_STARTUP,
40
39
  SESSION_START_SOURCE_PAYLOAD_KEY,
41
- STATE_FILE_DEFAULT_SESSION_ID,
42
40
  )
41
+ from hooks_constants.session_file_id import session_file_id
43
42
 
44
43
 
45
44
  def _delete_file(target_file: Path) -> None:
@@ -66,9 +65,7 @@ def _current_session_file_name(session_id: str) -> str:
66
65
  The tracker file name for the sanitized session id, falling back to
67
66
  the default id when sanitizing leaves an empty string.
68
67
  """
69
- sanitized_session_id = SESSION_ID_UNSAFE_CHARACTERS_PATTERN.sub("", session_id)
70
- effective_session_id = sanitized_session_id or STATE_FILE_DEFAULT_SESSION_ID
71
- return f"{SESSION_EDIT_FILE_PREFIX}{effective_session_id}{SESSION_EDIT_FILE_SUFFIX}"
68
+ return f"{SESSION_EDIT_FILE_PREFIX}{session_file_id(session_id)}{SESSION_EDIT_FILE_SUFFIX}"
72
69
 
73
70
 
74
71
  def _is_tracker_deletion_lifecycle_event(payload_by_field: dict[str, object]) -> bool:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.84.1",
3
+ "version": "8.85.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -130,6 +130,7 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
130
130
  hooks/blocking/pr_lifecycle_skill_gate.py -> exempt
131
131
  hooks/blocking/step_note_gate.py -> exempt
132
132
  hooks/blocking/reply_length_gate.py -> exempt
133
+ hooks/blocking/chat_reply_gate.py -> exempt
133
134
  hooks/blocking/edit_marker_gate.py -> exempt
134
135
  hooks/blocking/issue_close_handoff_gate.py -> exempt
135
136
  hooks/blocking/verify_before_acting.py -> exempt
@@ -154,7 +155,8 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
154
155
  It asks for a readable status line and decides no code or safety policy.
155
156
 
156
157
  The reply length gate caps the sentences in a chat reply to the user. It
157
- decides the shape of prose and no code or safety policy.
158
+ decides the shape of prose and no code or safety policy. The chat reply
159
+ gate runs the same checks on a reply typed straight into the chat.
158
160
 
159
161
  The edit marker gate keeps strikethrough and edit notes out of an edited
160
162
  chat message. It decides the shape of prose and no code or safety policy.
@@ -67,6 +67,7 @@ ALL_ACTION_BOUNDARY_EXEMPT_REGISTRATION_PATHS = (
67
67
  "blocking/merge_review_gate.py",
68
68
  "blocking/step_note_gate.py",
69
69
  "blocking/reply_length_gate.py",
70
+ "blocking/chat_reply_gate.py",
70
71
  "blocking/edit_marker_gate.py",
71
72
  "blocking/issue_close_handoff_gate.py",
72
73
  "blocking/verify_before_acting.py",
@@ -90,6 +90,14 @@ def test_new_reply_length_gate_registration_is_clean(tmp_path: Path) -> None:
90
90
  assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
91
91
 
92
92
 
93
+ def test_new_chat_reply_gate_registration_is_clean(tmp_path: Path) -> None:
94
+ current_document = _hook_document(
95
+ ["hooks/blocking/chat_reply_gate.py"],
96
+ '{"hooks": {}}',
97
+ )
98
+ assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
99
+
100
+
93
101
  def test_new_edit_marker_gate_registration_is_clean(tmp_path: Path) -> None:
94
102
  current_document = _hook_document(
95
103
  ["hooks/blocking/edit_marker_gate.py"],