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.
- package/bin/install.prompt-context.test.mjs +2 -1
- package/hooks/blocking/chat_reply_gate.py +246 -0
- package/hooks/blocking/inventory_intent_records/records.py +2 -5
- package/hooks/blocking/test_chat_reply_gate.py +224 -0
- package/hooks/features/conduct-gates.md +3 -0
- package/hooks/hooks.json +21 -0
- package/hooks/hooks_constants/chat_reply_gate_constants.py +58 -0
- package/hooks/hooks_constants/session_file_id.py +17 -0
- package/hooks/hooks_constants/test_session_file_id.py +17 -0
- package/hooks/observability/session_file_edit_tracker.py +2 -5
- package/hooks/session/session_edit_tracker_cleanup.py +2 -5
- package/package.json +1 -1
- package/scripts/policy_lint/adapter_configuration.py +3 -1
- package/scripts/policy_lint/config/constants.py +1 -0
- package/scripts/tests/test_adapter_configuration.py +8 -0
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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"],
|