claude-dev-env 8.84.0 → 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/pull_request_proof.py +133 -17
- package/hooks/blocking/test_chat_reply_gate.py +224 -0
- package/hooks/blocking/test_pull_request_proof.py +144 -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/pull_request_proof_constants.py +18 -5
- 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/conftest.py +22 -13
- package/scripts/dev_env_scripts_constants/account_broker_constants.py +12 -0
- package/scripts/dev_env_scripts_constants/test_account_broker_constants.py +12 -0
- package/scripts/policy_lint/adapter_configuration.py +3 -1
- package/scripts/policy_lint/config/constants.py +1 -0
- package/scripts/test_account_broker.py +0 -6
- package/scripts/test_account_broker_support.py +6 -7
- package/scripts/test_broker_state_isolation.py +7 -8
- package/scripts/test_conftest.py +54 -0
- package/scripts/tests/test_adapter_configuration.py +8 -0
|
@@ -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"
|
|
@@ -4,12 +4,15 @@ import subprocess
|
|
|
4
4
|
import sys
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
7
9
|
HOOKS_DIRECTORY = Path(__file__).resolve().parent.parent
|
|
8
10
|
if str(HOOKS_DIRECTORY) not in sys.path:
|
|
9
11
|
sys.path.insert(0, str(HOOKS_DIRECTORY))
|
|
10
12
|
|
|
11
13
|
from blocking import pull_request_proof as gate
|
|
12
14
|
from hooks_constants.pull_request_proof_constants import (
|
|
15
|
+
COMPARE_FILE_LIST_LIMIT,
|
|
13
16
|
MISSING_BODY_REASON,
|
|
14
17
|
MISSING_EVAL_SECTION_REASON,
|
|
15
18
|
MISSING_EXISTING_WORK_REASON,
|
|
@@ -306,6 +309,147 @@ def test_should_deny_when_git_cannot_read_the_branch_and_proof_shows_no_picture(
|
|
|
306
309
|
assert gate.missing_look_reason(payload) == UNREADABLE_CHANGES_REASON
|
|
307
310
|
|
|
308
311
|
|
|
312
|
+
def _fake_gh_api(all_responses: dict[str, list[str] | None], all_endpoints: list[str]):
|
|
313
|
+
def fake_gh_api_lines(endpoint: str, query: str) -> list[str] | None:
|
|
314
|
+
all_endpoints.append(endpoint)
|
|
315
|
+
return all_responses.get(endpoint)
|
|
316
|
+
|
|
317
|
+
return fake_gh_api_lines
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _shell_create_elsewhere(tmp_path: Path, extra_flags: str) -> dict[str, object]:
|
|
321
|
+
body_path = tmp_path / "body.md"
|
|
322
|
+
body_path.write_text(PROVEN_BODY, encoding="utf-8")
|
|
323
|
+
return _shell(f"gh pr create --title t --body-file {body_path} {extra_flags}", tmp_path)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def test_should_allow_a_python_only_change_the_compare_api_lists_when_git_cannot_read_it(
|
|
327
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
328
|
+
) -> None:
|
|
329
|
+
all_endpoints: list[str] = []
|
|
330
|
+
compare_endpoint = "repos/o/r/compare/main...feature"
|
|
331
|
+
monkeypatch.setattr(gate, "_gh_api_lines", _fake_gh_api({compare_endpoint: ["tool.py"]}, all_endpoints))
|
|
332
|
+
payload = _shell_create_elsewhere(tmp_path, "--repo o/r --base main --head feature")
|
|
333
|
+
assert gate.missing_look_reason(payload) is None
|
|
334
|
+
assert all_endpoints == [compare_endpoint]
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def test_should_deny_a_page_change_the_compare_api_lists_without_a_picture(
|
|
338
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
339
|
+
) -> None:
|
|
340
|
+
all_responses = {"repos/o/r": ["trunk"], "repos/o/r/compare/trunk...feature": ["web/page.html", "tool.py"]}
|
|
341
|
+
all_endpoints: list[str] = []
|
|
342
|
+
monkeypatch.setattr(gate, "_gh_api_lines", _fake_gh_api(all_responses, all_endpoints))
|
|
343
|
+
reason = gate.missing_look_reason(_shell_create_elsewhere(tmp_path, "-R o/r --head=feature"))
|
|
344
|
+
assert reason is not None
|
|
345
|
+
assert "web/page.html" in reason
|
|
346
|
+
assert "tool.py" not in reason
|
|
347
|
+
assert all_endpoints == ["repos/o/r", "repos/o/r/compare/trunk...feature"]
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def test_should_deny_as_unreadable_when_the_compare_api_fails(
|
|
351
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
352
|
+
) -> None:
|
|
353
|
+
monkeypatch.setattr(gate, "_gh_api_lines", _fake_gh_api({}, []))
|
|
354
|
+
payload = _shell_create_elsewhere(tmp_path, "--repo o/r --base main --head feature")
|
|
355
|
+
reason = gate.missing_look_reason(payload)
|
|
356
|
+
assert reason == UNREADABLE_CHANGES_REASON
|
|
357
|
+
assert reason is not None
|
|
358
|
+
assert "compare API" in reason
|
|
359
|
+
assert "--repo" in reason
|
|
360
|
+
assert "--head" in reason
|
|
361
|
+
assert "origin fetched" in reason
|
|
362
|
+
assert "Artifact" in reason
|
|
363
|
+
assert "before any change to this gate" in reason
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _python_paths(count: int) -> list[str]:
|
|
367
|
+
return [f"pkg/module_{each_index}.py" for each_index in range(count)]
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def test_should_deny_as_unreadable_when_a_full_compare_page_lists_no_visible_file(
|
|
371
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
372
|
+
) -> None:
|
|
373
|
+
all_endpoints: list[str] = []
|
|
374
|
+
compare_endpoint = "repos/o/r/compare/main...feature"
|
|
375
|
+
monkeypatch.setattr(
|
|
376
|
+
gate,
|
|
377
|
+
"_gh_api_lines",
|
|
378
|
+
_fake_gh_api({compare_endpoint: _python_paths(COMPARE_FILE_LIST_LIMIT)}, all_endpoints),
|
|
379
|
+
)
|
|
380
|
+
assert COMPARE_FILE_LIST_LIMIT == 300
|
|
381
|
+
payload = _shell_create_elsewhere(tmp_path, "--repo o/r --base main --head feature")
|
|
382
|
+
assert gate.remote_visible_changed_files("o/r", "main", "feature") is None
|
|
383
|
+
assert gate.missing_look_reason(payload) == UNREADABLE_CHANGES_REASON
|
|
384
|
+
assert all_endpoints == [compare_endpoint, compare_endpoint]
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def test_should_allow_a_short_compare_page_that_lists_only_python(
|
|
388
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
389
|
+
) -> None:
|
|
390
|
+
all_endpoints: list[str] = []
|
|
391
|
+
compare_endpoint = "repos/o/r/compare/main...feature"
|
|
392
|
+
monkeypatch.setattr(
|
|
393
|
+
gate,
|
|
394
|
+
"_gh_api_lines",
|
|
395
|
+
_fake_gh_api({compare_endpoint: _python_paths(COMPARE_FILE_LIST_LIMIT - 1)}, all_endpoints),
|
|
396
|
+
)
|
|
397
|
+
payload = _shell_create_elsewhere(tmp_path, "--repo o/r --base main --head feature")
|
|
398
|
+
assert gate.remote_visible_changed_files("o/r", "main", "feature") == []
|
|
399
|
+
assert gate.missing_look_reason(payload) is None
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def test_should_deny_a_visible_file_on_a_full_compare_page(
|
|
403
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
404
|
+
) -> None:
|
|
405
|
+
all_endpoints: list[str] = []
|
|
406
|
+
compare_endpoint = "repos/o/r/compare/main...feature"
|
|
407
|
+
all_paths = [*_python_paths(COMPARE_FILE_LIST_LIMIT - 1), "web/page.html"]
|
|
408
|
+
monkeypatch.setattr(gate, "_gh_api_lines", _fake_gh_api({compare_endpoint: all_paths}, all_endpoints))
|
|
409
|
+
reason = gate.missing_look_reason(_shell_create_elsewhere(tmp_path, "--repo o/r --base main --head feature"))
|
|
410
|
+
assert reason is not None
|
|
411
|
+
assert "web/page.html" in reason
|
|
412
|
+
assert "module_0.py" not in reason
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def test_should_allow_a_page_the_compare_api_lists_when_the_proof_links_an_artifact(
|
|
416
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
417
|
+
) -> None:
|
|
418
|
+
all_endpoints: list[str] = []
|
|
419
|
+
compare_endpoint = "repos/o/r/compare/main...feature"
|
|
420
|
+
monkeypatch.setattr(gate, "_gh_api_lines", _fake_gh_api({compare_endpoint: ["page.html"]}, all_endpoints))
|
|
421
|
+
body_path = tmp_path / "body.md"
|
|
422
|
+
body_path.write_text(PROVEN_BODY + "https://claude.ai/code/artifact/abc-123\n", encoding="utf-8")
|
|
423
|
+
payload = _shell(
|
|
424
|
+
f"gh pr create --title t --body-file {body_path} --repo o/r --base main --head feature",
|
|
425
|
+
tmp_path,
|
|
426
|
+
)
|
|
427
|
+
assert gate.missing_look_reason(payload) is None
|
|
428
|
+
assert all_endpoints == [compare_endpoint]
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def test_should_skip_the_compare_api_without_a_named_repository(
|
|
432
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
433
|
+
) -> None:
|
|
434
|
+
all_endpoints: list[str] = []
|
|
435
|
+
monkeypatch.setattr(gate, "_gh_api_lines", _fake_gh_api({}, all_endpoints))
|
|
436
|
+
payload = _shell_create_elsewhere(tmp_path, "--base main --head feature")
|
|
437
|
+
assert gate.missing_look_reason(payload) == UNREADABLE_CHANGES_REASON
|
|
438
|
+
assert all_endpoints == []
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def test_should_read_owner_and_repo_of_an_mcp_create_for_the_compare_api(
|
|
442
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
443
|
+
) -> None:
|
|
444
|
+
all_endpoints: list[str] = []
|
|
445
|
+
compare_endpoint = "repos/o/r/compare/main...feature"
|
|
446
|
+
monkeypatch.setattr(gate, "_gh_api_lines", _fake_gh_api({compare_endpoint: ["tool.py"]}, all_endpoints))
|
|
447
|
+
payload = _mcp_create_on_branch(PROVEN_BODY, tmp_path)
|
|
448
|
+
payload["tool_input"].update(owner="o", repo="r", base="main")
|
|
449
|
+
assert gate.missing_look_reason(payload) is None
|
|
450
|
+
assert all_endpoints == [compare_endpoint]
|
|
451
|
+
|
|
452
|
+
|
|
309
453
|
def test_visible_changed_files_falls_back_to_origin_main_without_origin_head(tmp_path: Path) -> None:
|
|
310
454
|
clone_path = _repository_with_change(tmp_path, "page.html")
|
|
311
455
|
subprocess.run(["git", "-C", str(clone_path), "remote", "set-head", "origin", "-d"], check=True)
|
|
@@ -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
|
+
)
|
|
@@ -85,7 +85,17 @@ ALL_DEFAULT_BRANCH_REFERENCES = ("origin/HEAD", "origin/main", "main")
|
|
|
85
85
|
REMOTE_BRANCH_PREFIX = "origin/"
|
|
86
86
|
ALL_HEAD_FLAGS = frozenset({"--head", "-H"})
|
|
87
87
|
HEAD_FLAG_ASSIGNMENT_PREFIX = "--head="
|
|
88
|
+
ALL_BASE_FLAGS = frozenset({"--base", "-B"})
|
|
89
|
+
BASE_FLAG_ASSIGNMENT_PREFIX = "--base="
|
|
90
|
+
ALL_REPOSITORY_FLAGS = frozenset({"--repo", "-R"})
|
|
91
|
+
REPOSITORY_FLAG_ASSIGNMENT_PREFIX = "--repo="
|
|
88
92
|
GIT_TIMEOUT_SECONDS = 5
|
|
93
|
+
GH_TIMEOUT_SECONDS = 15
|
|
94
|
+
COMPARE_ENDPOINT_TEMPLATE = "repos/{repository}/compare/{base_branch}...{head_branch}"
|
|
95
|
+
COMPARE_FILE_NAMES_QUERY = ".files[].filename"
|
|
96
|
+
COMPARE_FILE_LIST_LIMIT = 300
|
|
97
|
+
REPOSITORY_ENDPOINT_TEMPLATE = "repos/{repository}"
|
|
98
|
+
DEFAULT_BRANCH_QUERY = ".default_branch"
|
|
89
99
|
MISSING_LOOK_REASON = (
|
|
90
100
|
"This pull request changes files people see ({changed_files}), and its 'Proof in"
|
|
91
101
|
" practice' section shows no picture. Render the change, open the screenshot with the"
|
|
@@ -96,11 +106,14 @@ MISSING_LOOK_REASON = (
|
|
|
96
106
|
)
|
|
97
107
|
CHANGED_FILES_SEPARATOR = ", "
|
|
98
108
|
UNREADABLE_CHANGES_REASON = (
|
|
99
|
-
"
|
|
100
|
-
" picture check cannot tell whether people see the change.
|
|
101
|
-
"
|
|
102
|
-
"
|
|
103
|
-
"
|
|
109
|
+
"Neither git in this directory nor the GitHub compare API can list the files this"
|
|
110
|
+
" pull request changes, so the picture check cannot tell whether people see the change."
|
|
111
|
+
" Name the repository and head branch with --repo and --head, run the create call"
|
|
112
|
+
" from a checkout of the head branch with origin fetched, or put a screenshot or"
|
|
113
|
+
" Artifact link in the 'Proof in practice' section and run the create call again."
|
|
114
|
+
" Take one of these routes now, before any change to this gate. Read "
|
|
115
|
+
+ PROOF_GUIDE_PATH
|
|
116
|
+
+ " for the full rule."
|
|
104
117
|
)
|
|
105
118
|
FEATURE_TITLE_PATTERN = r"^\s*feat(\([^)]*\))?!?:"
|
|
106
119
|
EVAL_HEADING_PATTERN = r"^(#{1,6})[ \t]+evals?[ \t]*#*[ \t]*$"
|
|
@@ -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:
|