claude-dev-env 8.98.8 → 8.98.10
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/AGENTS.md +1 -1
- package/bin/install.prompt-context.test.mjs +1 -1
- package/docs/agents-guidance/asks.md +13 -12
- package/docs/rule-guides/session-title.md +15 -4
- package/hooks/blocking/open_tasks_stop_gate.py +60 -259
- package/hooks/blocking/open_tasks_transcript.py +104 -90
- package/hooks/blocking/resolve_thread_reply_gate.py +203 -41
- package/hooks/blocking/session_title_turn_gate.py +142 -56
- package/hooks/blocking/test_open_tasks_stop_gate.py +203 -500
- package/hooks/blocking/test_open_tasks_transcript.py +123 -15
- package/hooks/blocking/test_resolve_thread_reply_gate.py +341 -25
- package/hooks/blocking/test_session_title_turn_gate.py +173 -90
- package/hooks/features/conduct-gates.md +12 -12
- package/hooks/hooks.json +2 -2
- package/hooks/hooks_constants/open_tasks_stop_gate_constants.py +27 -60
- package/hooks/hooks_constants/resolve_thread_reply_gate_constants.py +18 -3
- package/hooks/hooks_constants/session_title_constants.py +19 -1
- package/hooks/open_task_asks.py +6 -101
- package/hooks/session/test_tracking_nudge.py +124 -0
- package/hooks/session/tracking_nudge.py +113 -0
- package/hooks/test_open_task_asks.py +6 -13
- package/package.json +1 -1
- package/rules/index.md +1 -1
- package/scripts/policy_lint/adapter_configuration.py +6 -6
- package/hooks/session/open_task_ask_recorder.py +0 -38
- package/hooks/session/test_open_task_ask_recorder.py +0 -57
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
import json
|
|
2
2
|
from pathlib import Path
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from open_tasks_transcript import (
|
|
7
|
+
TaskItem,
|
|
8
|
+
claude_prompt_text,
|
|
9
|
+
codex_prompt_text,
|
|
10
|
+
is_assistant_record,
|
|
11
|
+
is_codex_work_call,
|
|
12
|
+
is_work_tool_name,
|
|
13
|
+
parsed_object,
|
|
14
|
+
replayed_transcript,
|
|
15
|
+
unwrapped_tool_call,
|
|
16
|
+
)
|
|
5
17
|
|
|
6
18
|
|
|
7
19
|
def write_transcript(transcript_path: Path, all_records: list[dict[str, object]]) -> Path:
|
|
@@ -26,7 +38,7 @@ def test_should_unwrap_a_call_dynamic_tool_todo_write_call() -> None:
|
|
|
26
38
|
assert unwrapped_tool_call("CallDynamicTool", {"toolName": "TodoWrite"}) == ("CallDynamicTool", {"toolName": "TodoWrite"})
|
|
27
39
|
|
|
28
40
|
|
|
29
|
-
def
|
|
41
|
+
def test_should_replay_cursor_dynamic_todo_writes_as_tracker_calls(tmp_path: Path) -> None:
|
|
30
42
|
all_first_todos = [
|
|
31
43
|
{"id": "1", "content": "write hello.txt", "status": "in_progress"},
|
|
32
44
|
{"id": "2", "content": "rotate the deploy key", "status": "pending"},
|
|
@@ -45,7 +57,103 @@ def test_should_replay_cursor_dynamic_todo_writes_and_the_last_reply(tmp_path: P
|
|
|
45
57
|
TaskItem("1", "write hello.txt", "completed"),
|
|
46
58
|
TaskItem("2", "rotate the deploy key", "pending"),
|
|
47
59
|
]
|
|
48
|
-
assert replay.
|
|
60
|
+
assert replay.has_turn_tracker_write is True
|
|
61
|
+
assert replay.has_turn_work_tool is False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_should_start_a_new_turn_at_each_prompt_and_reset_its_flags(tmp_path: Path) -> None:
|
|
65
|
+
bash_call = {"type": "tool_use", "id": "b1", "name": "Bash", "input": {"command": "ls"}}
|
|
66
|
+
transcript_path = write_transcript(
|
|
67
|
+
tmp_path / "claude.jsonl",
|
|
68
|
+
[
|
|
69
|
+
{"type": "user", "message": {"role": "user", "content": "Write the gate"}},
|
|
70
|
+
dynamic_todo_record([{"id": "1", "content": "Write the gate", "status": "in_progress"}], False),
|
|
71
|
+
{"type": "user", "message": {"role": "user", "content": "Export the APKs"}},
|
|
72
|
+
{"type": "assistant", "message": {"content": [bash_call]}},
|
|
73
|
+
],
|
|
74
|
+
)
|
|
75
|
+
replay = replayed_transcript(str(transcript_path))
|
|
76
|
+
assert replay.turn_prompt_text == "Export the APKs"
|
|
77
|
+
assert replay.has_turn_work_tool is True
|
|
78
|
+
assert replay.has_turn_tracker_write is False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_should_not_count_a_question_card_lookup_or_title_call_as_work(tmp_path: Path) -> None:
|
|
82
|
+
all_non_work_calls = [
|
|
83
|
+
{"type": "tool_use", "id": f"n{each_index}", "name": each_name, "input": {}}
|
|
84
|
+
for each_index, each_name in enumerate(
|
|
85
|
+
["AskUserQuestion", "ToolSearch", "TaskList", "TaskGet", "mcp__claude-code-remote__set_session_title"]
|
|
86
|
+
)
|
|
87
|
+
]
|
|
88
|
+
transcript_path = write_transcript(
|
|
89
|
+
tmp_path / "claude.jsonl",
|
|
90
|
+
[
|
|
91
|
+
{"type": "user", "message": {"role": "user", "content": "Export the APKs"}},
|
|
92
|
+
{"type": "assistant", "message": {"content": all_non_work_calls}},
|
|
93
|
+
],
|
|
94
|
+
)
|
|
95
|
+
replay = replayed_transcript(str(transcript_path))
|
|
96
|
+
assert replay.has_turn_work_tool is False
|
|
97
|
+
assert replay.has_turn_tracker_write is False
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@pytest.mark.parametrize(
|
|
101
|
+
("record", "expected_text"),
|
|
102
|
+
[
|
|
103
|
+
({"type": "user", "message": {"role": "user", "content": "Export the APKs"}}, "Export the APKs"),
|
|
104
|
+
({"type": "user", "message": {"content": [{"type": "text", "text": "Fix it"}]}}, "Fix it"),
|
|
105
|
+
({"type": "user", "message": {"content": [{"type": "tool_result", "tool_use_id": "b1"}]}}, None),
|
|
106
|
+
({"type": "user", "isMeta": True, "message": {"content": "Stop hook feedback: hold"}}, None),
|
|
107
|
+
({"type": "user", "message": {"content": "Stop hook feedback:\nhold"}}, None),
|
|
108
|
+
({"type": "assistant", "message": {"content": "Done."}}, None),
|
|
109
|
+
],
|
|
110
|
+
)
|
|
111
|
+
def test_should_read_prompt_text_only_from_a_record_that_starts_a_turn(
|
|
112
|
+
record: dict[str, object], expected_text: str | None
|
|
113
|
+
) -> None:
|
|
114
|
+
assert claude_prompt_text(record) == expected_text
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_should_read_codex_user_message_text() -> None:
|
|
118
|
+
user_message = {
|
|
119
|
+
"type": "response_item",
|
|
120
|
+
"payload": {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "Ship it"}]},
|
|
121
|
+
}
|
|
122
|
+
assistant_message = {"type": "response_item", "payload": {"type": "message", "role": "assistant", "content": "ok"}}
|
|
123
|
+
assert codex_prompt_text(user_message) == "Ship it"
|
|
124
|
+
assert codex_prompt_text(assistant_message) is None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_should_count_codex_tool_calls_other_than_the_plan_as_work() -> None:
|
|
128
|
+
def codex_call(call_type: str, tool_name: str) -> dict[str, object]:
|
|
129
|
+
return {"type": "response_item", "payload": {"type": call_type, "name": tool_name}}
|
|
130
|
+
|
|
131
|
+
assert is_codex_work_call(codex_call("function_call", "shell")) is True
|
|
132
|
+
assert is_codex_work_call(codex_call("local_shell_call", "")) is True
|
|
133
|
+
assert is_codex_work_call(codex_call("function_call", "update_plan")) is False
|
|
134
|
+
assert is_codex_work_call(codex_call("function_call", "request_user_input")) is False
|
|
135
|
+
assert is_codex_work_call(codex_call("function_call", "request_user_input_async")) is False
|
|
136
|
+
assert is_codex_work_call({"type": "user", "payload": {"type": "function_call"}}) is False
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_should_not_count_a_codex_title_call_as_work() -> None:
|
|
140
|
+
title_call = {"type": "response_item", "payload": {"type": "function_call", "name": "mcp__session__set_session_title"}}
|
|
141
|
+
assert is_codex_work_call(title_call) is False
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_should_treat_question_cards_and_the_title_tool_as_non_work() -> None:
|
|
145
|
+
assert is_work_tool_name("Bash") is True
|
|
146
|
+
assert is_work_tool_name(None) is True
|
|
147
|
+
assert is_work_tool_name("mcp__hearthbot__ask_decision") is False
|
|
148
|
+
assert is_work_tool_name("mcp__hearthbot__request_decision") is False
|
|
149
|
+
assert is_work_tool_name("mcp__ccd_session__set_session_title") is False
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_should_recognize_an_assistant_record_by_type_role_or_message_role() -> None:
|
|
153
|
+
assert is_assistant_record({"type": "assistant"}) is True
|
|
154
|
+
assert is_assistant_record({"role": "assistant"}) is True
|
|
155
|
+
assert is_assistant_record({"message": {"role": "assistant"}}) is True
|
|
156
|
+
assert is_assistant_record({"type": "user", "message": {"role": "user"}}) is False
|
|
49
157
|
|
|
50
158
|
|
|
51
159
|
def test_should_parse_only_json_objects() -> None:
|
|
@@ -59,9 +167,9 @@ def ccd_plan_record(all_plan_input: dict[str, object], record_type: str = "assis
|
|
|
59
167
|
return {"type": record_type, "timestamp": "2026-01-01T00:00:00.000Z", "message": {"content": [tool_use]}}
|
|
60
168
|
|
|
61
169
|
|
|
62
|
-
def plan_statuses(transcript_path: Path) -> list[tuple[str, str
|
|
170
|
+
def plan_statuses(transcript_path: Path) -> list[tuple[str, str]]:
|
|
63
171
|
all_items = replayed_transcript(str(transcript_path)).items() or []
|
|
64
|
-
return [(each_item.subject, each_item.status
|
|
172
|
+
return [(each_item.subject, each_item.status) for each_item in all_items]
|
|
65
173
|
|
|
66
174
|
|
|
67
175
|
def test_should_replay_ccd_turn_advance_once_and_skip_request_blob_copies(tmp_path: Path) -> None:
|
|
@@ -79,9 +187,9 @@ def test_should_replay_ccd_turn_advance_once_and_skip_request_blob_copies(tmp_pa
|
|
|
79
187
|
],
|
|
80
188
|
)
|
|
81
189
|
assert plan_statuses(transcript_path) == [
|
|
82
|
-
("Read the gate", "completed"
|
|
83
|
-
("Write the tests", "in_progress"
|
|
84
|
-
("Open the PR", "pending"
|
|
190
|
+
("Read the gate", "completed"),
|
|
191
|
+
("Write the tests", "in_progress"),
|
|
192
|
+
("Open the PR", "pending"),
|
|
85
193
|
]
|
|
86
194
|
|
|
87
195
|
|
|
@@ -99,9 +207,9 @@ def test_should_carry_unfinished_items_after_the_replacing_list(tmp_path: Path)
|
|
|
99
207
|
],
|
|
100
208
|
)
|
|
101
209
|
assert plan_statuses(transcript_path) == [
|
|
102
|
-
("Set rows in Neon", "in_progress"
|
|
103
|
-
("Export the OneUI8 APK", "in_progress"
|
|
104
|
-
("Submit both APKs", "pending"
|
|
210
|
+
("Set rows in Neon", "in_progress"),
|
|
211
|
+
("Export the OneUI8 APK", "in_progress"),
|
|
212
|
+
("Submit both APKs", "pending"),
|
|
105
213
|
]
|
|
106
214
|
|
|
107
215
|
|
|
@@ -120,9 +228,9 @@ def test_should_point_done_and_start_indices_at_the_tools_own_items(tmp_path: Pa
|
|
|
120
228
|
],
|
|
121
229
|
)
|
|
122
230
|
assert plan_statuses(transcript_path) == [
|
|
123
|
-
("Beta", "completed"
|
|
124
|
-
("Gamma", "in_progress"
|
|
125
|
-
("Alpha", "pending"
|
|
231
|
+
("Beta", "completed"),
|
|
232
|
+
("Gamma", "in_progress"),
|
|
233
|
+
("Alpha", "pending"),
|
|
126
234
|
]
|
|
127
235
|
|
|
128
236
|
|
|
@@ -140,4 +248,4 @@ def test_should_let_a_later_list_close_a_carried_item_by_subject(tmp_path: Path)
|
|
|
140
248
|
),
|
|
141
249
|
],
|
|
142
250
|
)
|
|
143
|
-
assert plan_statuses(transcript_path) == [("Beta", "completed"
|
|
251
|
+
assert plan_statuses(transcript_path) == [("Beta", "completed"), ("alpha", "completed")]
|
|
@@ -16,6 +16,11 @@ from transcript_skill_scan import TranscriptToolCall
|
|
|
16
16
|
GATE_PATH = Path(__file__).resolve().parent / "resolve_thread_reply_gate.py"
|
|
17
17
|
RESOLVE_TOOL_NAME = "mcp__github__resolve_review_thread"
|
|
18
18
|
REPLY_TOOL_NAME = "mcp__github__add_reply_to_pull_request_comment"
|
|
19
|
+
READ_TOOL_NAME = "mcp__github__pull_request_read"
|
|
20
|
+
FIRST_THREAD_ID = "PRRT_kwDOfirst"
|
|
21
|
+
SECOND_THREAD_ID = "PRRT_kwDOsecond"
|
|
22
|
+
FIRST_COMMENT_ID = 350000001
|
|
23
|
+
SECOND_COMMENT_ID = 350000005
|
|
19
24
|
HOOK_DENIAL_TEXT = "PreToolUse:mcp__github__add_reply_to_pull_request_comment hook error: too many sentences"
|
|
20
25
|
|
|
21
26
|
|
|
@@ -33,17 +38,60 @@ def _result(tool_use_id: str, is_error: bool = False, text: str = "ok") -> str:
|
|
|
33
38
|
return json.dumps({"type": "user", "message": {"content": [tool_result]}})
|
|
34
39
|
|
|
35
40
|
|
|
41
|
+
def _threads_read(tool_use_id: str = "read-1") -> list[str]:
|
|
42
|
+
all_threads = [
|
|
43
|
+
{
|
|
44
|
+
"id": each_thread_id,
|
|
45
|
+
"is_resolved": False,
|
|
46
|
+
"comments": [
|
|
47
|
+
{"path": "a.py", "html_url": f"https://github.com/o/r/pull/7#discussion_r{each_comment_id}"},
|
|
48
|
+
{"path": "a.py", "html_url": f"https://github.com/o/r/pull/7#discussion_r{each_comment_id + 1}"},
|
|
49
|
+
],
|
|
50
|
+
}
|
|
51
|
+
for each_thread_id, each_comment_id in ((FIRST_THREAD_ID, FIRST_COMMENT_ID), (SECOND_THREAD_ID, SECOND_COMMENT_ID))
|
|
52
|
+
]
|
|
53
|
+
return [
|
|
54
|
+
_call(tool_use_id, READ_TOOL_NAME, {"method": "get_review_comments", "pullNumber": 7}),
|
|
55
|
+
_result(tool_use_id, text=json.dumps({"review_threads": all_threads, "totalCount": 2})),
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _reply(tool_use_id: str, comment_id: int = FIRST_COMMENT_ID) -> str:
|
|
60
|
+
return _call(tool_use_id, REPLY_TOOL_NAME, {"commentId": comment_id, "body": "Fixed in `abc1234`."})
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _resolve(tool_use_id: str, thread_id: str = FIRST_THREAD_ID) -> str:
|
|
64
|
+
return _call(tool_use_id, RESOLVE_TOOL_NAME, {"threadID": thread_id})
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _shell_resolve_command(thread_id: str = FIRST_THREAD_ID) -> str:
|
|
68
|
+
return (
|
|
69
|
+
"gh api graphql -f query='mutation { resolveReviewThread(input: {threadId: \""
|
|
70
|
+
+ thread_id
|
|
71
|
+
+ "\"}) { thread { isResolved } } }'"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
36
75
|
def _write_transcript(tmp_path: Path, all_lines: list[str]) -> Path:
|
|
37
76
|
transcript_path = tmp_path / "session.jsonl"
|
|
38
77
|
transcript_path.write_text("\n".join(all_lines) + "\n", encoding="utf-8")
|
|
39
78
|
return transcript_path
|
|
40
79
|
|
|
41
80
|
|
|
42
|
-
def _resolve_payload(transcript_path: Path) -> dict[str, object]:
|
|
81
|
+
def _resolve_payload(transcript_path: Path, thread_id: str = FIRST_THREAD_ID) -> dict[str, object]:
|
|
43
82
|
return {
|
|
44
83
|
"hook_event_name": "PreToolUse",
|
|
45
84
|
"tool_name": RESOLVE_TOOL_NAME,
|
|
46
|
-
"tool_input": {"
|
|
85
|
+
"tool_input": {"threadID": thread_id},
|
|
86
|
+
"transcript_path": str(transcript_path),
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _shell_resolve_payload(transcript_path: Path, command: str) -> dict[str, object]:
|
|
91
|
+
return {
|
|
92
|
+
"hook_event_name": "PreToolUse",
|
|
93
|
+
"tool_name": "Bash",
|
|
94
|
+
"tool_input": {"command": command},
|
|
47
95
|
"transcript_path": str(transcript_path),
|
|
48
96
|
}
|
|
49
97
|
|
|
@@ -62,9 +110,10 @@ def should_allow_a_resolve_after_a_reply_that_posted(tmp_path: Path) -> None:
|
|
|
62
110
|
tmp_path,
|
|
63
111
|
[
|
|
64
112
|
_prompt("Address the review threads."),
|
|
65
|
-
|
|
113
|
+
*_threads_read(),
|
|
114
|
+
_reply("reply-1"),
|
|
66
115
|
_result("reply-1"),
|
|
67
|
-
|
|
116
|
+
_resolve("resolve-1"),
|
|
68
117
|
],
|
|
69
118
|
)
|
|
70
119
|
assert gate.decision_for(_resolve_payload(transcript_path)) is None
|
|
@@ -75,9 +124,10 @@ def should_deny_a_resolve_after_a_reply_a_hook_refused(tmp_path: Path) -> None:
|
|
|
75
124
|
tmp_path,
|
|
76
125
|
[
|
|
77
126
|
_prompt("Address the review threads."),
|
|
78
|
-
|
|
127
|
+
*_threads_read(),
|
|
128
|
+
_reply("reply-1"),
|
|
79
129
|
_result("reply-1", is_error=True, text=HOOK_DENIAL_TEXT),
|
|
80
|
-
|
|
130
|
+
_resolve("resolve-1"),
|
|
81
131
|
],
|
|
82
132
|
)
|
|
83
133
|
assert _deny_reason(gate.decision_for(_resolve_payload(transcript_path))) == MISSING_REPLY_REASON
|
|
@@ -86,7 +136,7 @@ def should_deny_a_resolve_after_a_reply_a_hook_refused(tmp_path: Path) -> None:
|
|
|
86
136
|
def should_deny_a_resolve_with_no_reply(tmp_path: Path) -> None:
|
|
87
137
|
transcript_path = _write_transcript(
|
|
88
138
|
tmp_path,
|
|
89
|
-
[_prompt("Address the review threads."),
|
|
139
|
+
[_prompt("Address the review threads."), *_threads_read(), _resolve("resolve-1")],
|
|
90
140
|
)
|
|
91
141
|
assert _deny_reason(gate.decision_for(_resolve_payload(transcript_path))) == MISSING_REPLY_REASON
|
|
92
142
|
|
|
@@ -96,42 +146,243 @@ def should_deny_a_second_resolve_after_one_reply(tmp_path: Path) -> None:
|
|
|
96
146
|
tmp_path,
|
|
97
147
|
[
|
|
98
148
|
_prompt("Address the review threads."),
|
|
99
|
-
|
|
149
|
+
*_threads_read(),
|
|
150
|
+
_reply("reply-1"),
|
|
100
151
|
_result("reply-1"),
|
|
101
|
-
|
|
152
|
+
_resolve("resolve-1"),
|
|
102
153
|
_result("resolve-1"),
|
|
103
|
-
|
|
154
|
+
_resolve("resolve-2"),
|
|
104
155
|
],
|
|
105
156
|
)
|
|
106
157
|
assert _deny_reason(gate.decision_for(_resolve_payload(transcript_path))) == MISSING_REPLY_REASON
|
|
107
158
|
|
|
108
159
|
|
|
109
|
-
def
|
|
160
|
+
def should_allow_a_second_resolve_after_a_reply_on_each_thread(tmp_path: Path) -> None:
|
|
110
161
|
transcript_path = _write_transcript(
|
|
111
162
|
tmp_path,
|
|
112
163
|
[
|
|
113
164
|
_prompt("Address the review threads."),
|
|
114
|
-
|
|
165
|
+
*_threads_read(),
|
|
166
|
+
_reply("reply-1"),
|
|
115
167
|
_result("reply-1"),
|
|
116
|
-
|
|
168
|
+
_reply("reply-2", SECOND_COMMENT_ID),
|
|
117
169
|
_result("reply-2"),
|
|
118
|
-
|
|
170
|
+
_resolve("resolve-1"),
|
|
119
171
|
_result("resolve-1"),
|
|
120
|
-
|
|
172
|
+
_resolve("resolve-2", SECOND_THREAD_ID),
|
|
173
|
+
],
|
|
174
|
+
)
|
|
175
|
+
assert gate.decision_for(_resolve_payload(transcript_path, SECOND_THREAD_ID)) is None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def should_deny_a_resolve_on_a_thread_the_reply_did_not_answer(tmp_path: Path) -> None:
|
|
179
|
+
transcript_path = _write_transcript(
|
|
180
|
+
tmp_path,
|
|
181
|
+
[
|
|
182
|
+
_prompt("Address the review threads."),
|
|
183
|
+
*_threads_read(),
|
|
184
|
+
_reply("reply-1"),
|
|
185
|
+
_result("reply-1"),
|
|
186
|
+
_resolve("resolve-1", SECOND_THREAD_ID),
|
|
187
|
+
],
|
|
188
|
+
)
|
|
189
|
+
decision = gate.decision_for(_resolve_payload(transcript_path, SECOND_THREAD_ID))
|
|
190
|
+
assert _deny_reason(decision) == MISSING_REPLY_REASON
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def should_deny_a_second_resolve_of_one_thread_after_replies_on_two_threads(tmp_path: Path) -> None:
|
|
194
|
+
transcript_path = _write_transcript(
|
|
195
|
+
tmp_path,
|
|
196
|
+
[
|
|
197
|
+
_prompt("Address the review threads."),
|
|
198
|
+
*_threads_read(),
|
|
199
|
+
_reply("reply-1"),
|
|
200
|
+
_result("reply-1"),
|
|
201
|
+
_reply("reply-2", SECOND_COMMENT_ID),
|
|
202
|
+
_result("reply-2"),
|
|
203
|
+
_resolve("resolve-1"),
|
|
204
|
+
_result("resolve-1"),
|
|
205
|
+
_resolve("resolve-2"),
|
|
206
|
+
],
|
|
207
|
+
)
|
|
208
|
+
assert _deny_reason(gate.decision_for(_resolve_payload(transcript_path))) == MISSING_REPLY_REASON
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def should_match_a_reply_on_a_later_comment_in_the_thread(tmp_path: Path) -> None:
|
|
212
|
+
transcript_path = _write_transcript(
|
|
213
|
+
tmp_path,
|
|
214
|
+
[
|
|
215
|
+
_prompt("Address the review threads."),
|
|
216
|
+
*_threads_read(),
|
|
217
|
+
_reply("reply-1", SECOND_COMMENT_ID + 1),
|
|
218
|
+
_result("reply-1"),
|
|
219
|
+
_resolve("resolve-1", SECOND_THREAD_ID),
|
|
220
|
+
],
|
|
221
|
+
)
|
|
222
|
+
assert gate.decision_for(_resolve_payload(transcript_path, SECOND_THREAD_ID)) is None
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def should_read_the_thread_map_from_before_the_last_prompt(tmp_path: Path) -> None:
|
|
226
|
+
transcript_path = _write_transcript(
|
|
227
|
+
tmp_path,
|
|
228
|
+
[
|
|
229
|
+
_prompt("List the review threads."),
|
|
230
|
+
*_threads_read(),
|
|
231
|
+
_prompt("Reply and resolve."),
|
|
232
|
+
_reply("reply-1"),
|
|
233
|
+
_result("reply-1"),
|
|
234
|
+
_resolve("resolve-1"),
|
|
121
235
|
],
|
|
122
236
|
)
|
|
123
237
|
assert gate.decision_for(_resolve_payload(transcript_path)) is None
|
|
124
238
|
|
|
125
239
|
|
|
240
|
+
def should_deny_a_resolve_when_no_read_ties_the_reply_to_the_thread(tmp_path: Path) -> None:
|
|
241
|
+
transcript_path = _write_transcript(
|
|
242
|
+
tmp_path,
|
|
243
|
+
[
|
|
244
|
+
_prompt("Address the review threads."),
|
|
245
|
+
_reply("reply-1"),
|
|
246
|
+
_result("reply-1"),
|
|
247
|
+
_resolve("resolve-1"),
|
|
248
|
+
],
|
|
249
|
+
)
|
|
250
|
+
assert _deny_reason(gate.decision_for(_resolve_payload(transcript_path))) == MISSING_REPLY_REASON
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def should_allow_a_resolve_after_a_graphql_reply_on_the_thread(tmp_path: Path) -> None:
|
|
254
|
+
reply_command = (
|
|
255
|
+
"gh api graphql -f query='mutation { addPullRequestReviewThreadReply(input: "
|
|
256
|
+
f'{{pullRequestReviewThreadId: "{FIRST_THREAD_ID}", body: "Fixed."}}) {{ comment {{ id }} }} }}\''
|
|
257
|
+
)
|
|
258
|
+
transcript_path = _write_transcript(
|
|
259
|
+
tmp_path,
|
|
260
|
+
[
|
|
261
|
+
_prompt("Address the review threads."),
|
|
262
|
+
_call("reply-1", "Bash", {"command": reply_command}),
|
|
263
|
+
_result("reply-1"),
|
|
264
|
+
_resolve("resolve-1"),
|
|
265
|
+
],
|
|
266
|
+
)
|
|
267
|
+
assert gate.decision_for(_resolve_payload(transcript_path)) is None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def should_deny_a_shell_resolve_with_no_reply(tmp_path: Path) -> None:
|
|
271
|
+
transcript_path = _write_transcript(tmp_path, [_prompt("Address the review threads."), *_threads_read()])
|
|
272
|
+
decision = gate.decision_for(_shell_resolve_payload(transcript_path, _shell_resolve_command()))
|
|
273
|
+
assert _deny_reason(decision) == MISSING_REPLY_REASON
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def should_allow_a_shell_resolve_after_a_rest_reply_on_the_thread(tmp_path: Path) -> None:
|
|
277
|
+
reply_command = f"gh api repos/o/r/pulls/7/comments/{FIRST_COMMENT_ID}/replies -f body=Fixed."
|
|
278
|
+
transcript_path = _write_transcript(
|
|
279
|
+
tmp_path,
|
|
280
|
+
[
|
|
281
|
+
_prompt("Address the review threads."),
|
|
282
|
+
*_threads_read(),
|
|
283
|
+
_call("reply-1", "Bash", {"command": reply_command}),
|
|
284
|
+
_result("reply-1"),
|
|
285
|
+
],
|
|
286
|
+
)
|
|
287
|
+
assert gate.decision_for(_shell_resolve_payload(transcript_path, _shell_resolve_command())) is None
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def should_deny_a_shell_resolve_on_a_thread_the_reply_did_not_answer(tmp_path: Path) -> None:
|
|
291
|
+
transcript_path = _write_transcript(
|
|
292
|
+
tmp_path,
|
|
293
|
+
[_prompt("Address the review threads."), *_threads_read(), _reply("reply-1"), _result("reply-1")],
|
|
294
|
+
)
|
|
295
|
+
decision = gate.decision_for(_shell_resolve_payload(transcript_path, _shell_resolve_command(SECOND_THREAD_ID)))
|
|
296
|
+
assert _deny_reason(decision) == MISSING_REPLY_REASON
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def should_deny_a_shell_resolve_that_names_no_thread_id(tmp_path: Path) -> None:
|
|
300
|
+
transcript_path = _write_transcript(
|
|
301
|
+
tmp_path,
|
|
302
|
+
[_prompt("Address the review threads."), *_threads_read(), _reply("reply-1"), _result("reply-1")],
|
|
303
|
+
)
|
|
304
|
+
command = "gh api graphql -f threadId=\"$thread\" -f query='mutation($threadId: ID!) { resolveReviewThread(input: {threadId: $threadId}) { thread { isResolved } } }'"
|
|
305
|
+
assert _deny_reason(gate.decision_for(_shell_resolve_payload(transcript_path, command))) == MISSING_REPLY_REASON
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def should_spend_the_reply_on_a_shell_resolve(tmp_path: Path) -> None:
|
|
309
|
+
transcript_path = _write_transcript(
|
|
310
|
+
tmp_path,
|
|
311
|
+
[
|
|
312
|
+
_prompt("Address the review threads."),
|
|
313
|
+
*_threads_read(),
|
|
314
|
+
_reply("reply-1"),
|
|
315
|
+
_result("reply-1"),
|
|
316
|
+
_call("resolve-1", "Bash", {"command": _shell_resolve_command()}),
|
|
317
|
+
_result("resolve-1"),
|
|
318
|
+
_resolve("resolve-2"),
|
|
319
|
+
],
|
|
320
|
+
)
|
|
321
|
+
assert _deny_reason(gate.decision_for(_resolve_payload(transcript_path))) == MISSING_REPLY_REASON
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _ccr_resolve_command(comment_id: int = FIRST_COMMENT_ID) -> str:
|
|
325
|
+
return f"gh api -X POST repos/o/r/pulls/7/ccr/comments/{comment_id}/resolve"
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def should_deny_a_ccr_resolve_with_no_reply(tmp_path: Path) -> None:
|
|
329
|
+
transcript_path = _write_transcript(tmp_path, [_prompt("Address the review threads.")])
|
|
330
|
+
decision = gate.decision_for(_shell_resolve_payload(transcript_path, _ccr_resolve_command()))
|
|
331
|
+
assert _deny_reason(decision) == MISSING_REPLY_REASON
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def should_allow_a_ccr_resolve_of_the_comment_the_reply_answered(tmp_path: Path) -> None:
|
|
335
|
+
transcript_path = _write_transcript(
|
|
336
|
+
tmp_path,
|
|
337
|
+
[_prompt("Address the review threads."), _reply("reply-1"), _result("reply-1")],
|
|
338
|
+
)
|
|
339
|
+
assert gate.decision_for(_shell_resolve_payload(transcript_path, _ccr_resolve_command())) is None
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def should_deny_a_ccr_resolve_of_a_comment_in_another_thread(tmp_path: Path) -> None:
|
|
343
|
+
transcript_path = _write_transcript(
|
|
344
|
+
tmp_path,
|
|
345
|
+
[_prompt("Address the review threads."), *_threads_read(), _reply("reply-1"), _result("reply-1")],
|
|
346
|
+
)
|
|
347
|
+
decision = gate.decision_for(_shell_resolve_payload(transcript_path, _ccr_resolve_command(SECOND_COMMENT_ID)))
|
|
348
|
+
assert _deny_reason(decision) == MISSING_REPLY_REASON
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def should_spend_the_reply_on_a_ccr_resolve_of_the_same_thread(tmp_path: Path) -> None:
|
|
352
|
+
transcript_path = _write_transcript(
|
|
353
|
+
tmp_path,
|
|
354
|
+
[
|
|
355
|
+
_prompt("Address the review threads."),
|
|
356
|
+
*_threads_read(),
|
|
357
|
+
_reply("reply-1"),
|
|
358
|
+
_result("reply-1"),
|
|
359
|
+
_call("resolve-1", "Bash", {"command": _ccr_resolve_command(FIRST_COMMENT_ID + 1)}),
|
|
360
|
+
_result("resolve-1"),
|
|
361
|
+
_resolve("resolve-2"),
|
|
362
|
+
],
|
|
363
|
+
)
|
|
364
|
+
assert _deny_reason(gate.decision_for(_resolve_payload(transcript_path))) == MISSING_REPLY_REASON
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def should_ignore_shell_commands_other_than_a_resolve(tmp_path: Path) -> None:
|
|
368
|
+
transcript_path = _write_transcript(tmp_path, [_prompt("Address the review threads.")])
|
|
369
|
+
assert gate.decision_for(_shell_resolve_payload(transcript_path, "gh pr view 7")) is None
|
|
370
|
+
unresolve_command = _shell_resolve_command().replace("resolveReviewThread", "unresolveReviewThread")
|
|
371
|
+
assert gate.decision_for(_shell_resolve_payload(transcript_path, unresolve_command)) is None
|
|
372
|
+
ccr_unresolve_command = _ccr_resolve_command().replace("/resolve", "/unresolve")
|
|
373
|
+
assert gate.decision_for(_shell_resolve_payload(transcript_path, ccr_unresolve_command)) is None
|
|
374
|
+
|
|
375
|
+
|
|
126
376
|
def should_deny_a_resolve_whose_reply_came_before_the_last_prompt(tmp_path: Path) -> None:
|
|
127
377
|
transcript_path = _write_transcript(
|
|
128
378
|
tmp_path,
|
|
129
379
|
[
|
|
130
380
|
_prompt("Reply on the thread."),
|
|
131
|
-
|
|
381
|
+
*_threads_read(),
|
|
382
|
+
_reply("reply-1"),
|
|
132
383
|
_result("reply-1"),
|
|
133
|
-
_prompt("Now resolve the
|
|
134
|
-
|
|
384
|
+
_prompt("Now resolve the thread."),
|
|
385
|
+
_resolve("resolve-1"),
|
|
135
386
|
],
|
|
136
387
|
)
|
|
137
388
|
assert _deny_reason(gate.decision_for(_resolve_payload(transcript_path))) == MISSING_REPLY_REASON
|
|
@@ -142,11 +393,12 @@ def should_allow_a_retry_after_a_resolve_error(tmp_path: Path) -> None:
|
|
|
142
393
|
tmp_path,
|
|
143
394
|
[
|
|
144
395
|
_prompt("Address the review threads."),
|
|
145
|
-
|
|
396
|
+
*_threads_read(),
|
|
397
|
+
_reply("reply-1"),
|
|
146
398
|
_result("reply-1"),
|
|
147
|
-
|
|
399
|
+
_resolve("resolve-1"),
|
|
148
400
|
_result("resolve-1", is_error=True, text="Bad gateway"),
|
|
149
|
-
|
|
401
|
+
_resolve("resolve-2"),
|
|
150
402
|
],
|
|
151
403
|
)
|
|
152
404
|
assert gate.decision_for(_resolve_payload(transcript_path)) is None
|
|
@@ -177,16 +429,52 @@ def should_count_a_gh_api_reply_command_as_a_reply() -> None:
|
|
|
177
429
|
assert not gate.is_reply_call(TranscriptToolCall("mcp__github__add_issue_comment", {}, True))
|
|
178
430
|
|
|
179
431
|
|
|
432
|
+
def should_name_the_threads_a_call_resolves() -> None:
|
|
433
|
+
assert gate.resolved_thread_ids(RESOLVE_TOOL_NAME, {"threadID": FIRST_THREAD_ID}) == {FIRST_THREAD_ID}
|
|
434
|
+
assert gate.resolved_thread_ids(RESOLVE_TOOL_NAME, {"threadId": FIRST_THREAD_ID}) == {FIRST_THREAD_ID}
|
|
435
|
+
assert gate.resolved_thread_ids("Bash", {"command": _shell_resolve_command()}) == {FIRST_THREAD_ID}
|
|
436
|
+
assert gate.resolved_thread_ids("PowerShell", {"command": _shell_resolve_command()}) == {FIRST_THREAD_ID}
|
|
437
|
+
assert gate.resolved_thread_ids("Bash", {"command": _ccr_resolve_command()}) == {str(FIRST_COMMENT_ID)}
|
|
438
|
+
assert gate.resolved_thread_ids("Bash", {"command": "gh pr view 7"}) is None
|
|
439
|
+
assert gate.resolved_thread_ids(REPLY_TOOL_NAME, {"commentId": FIRST_COMMENT_ID}) is None
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def should_map_comment_ids_to_threads_from_tool_results() -> None:
|
|
443
|
+
graphql_text = json.dumps(
|
|
444
|
+
{"nodes": [{"id": SECOND_THREAD_ID, "comments": {"nodes": [{"databaseId": SECOND_COMMENT_ID}]}}]}
|
|
445
|
+
)
|
|
446
|
+
graphql_result = json.dumps(
|
|
447
|
+
{
|
|
448
|
+
"type": "user",
|
|
449
|
+
"message": {
|
|
450
|
+
"content": [
|
|
451
|
+
{"type": "tool_result", "tool_use_id": "read-2", "content": [{"type": "text", "text": graphql_text}]}
|
|
452
|
+
]
|
|
453
|
+
},
|
|
454
|
+
}
|
|
455
|
+
)
|
|
456
|
+
refused_text = json.dumps({"id": SECOND_THREAD_ID, "html_url": "x#discussion_r1"})
|
|
457
|
+
thread_id_by_comment_id = gate.thread_ids_by_comment_id(
|
|
458
|
+
[*_threads_read(), graphql_result, _result("read-3", is_error=True, text=refused_text), "not json"]
|
|
459
|
+
)
|
|
460
|
+
assert thread_id_by_comment_id[str(FIRST_COMMENT_ID)] == FIRST_THREAD_ID
|
|
461
|
+
assert thread_id_by_comment_id[str(FIRST_COMMENT_ID + 1)] == FIRST_THREAD_ID
|
|
462
|
+
assert thread_id_by_comment_id[str(SECOND_COMMENT_ID)] == SECOND_THREAD_ID
|
|
463
|
+
assert "1" not in thread_id_by_comment_id
|
|
464
|
+
|
|
465
|
+
|
|
180
466
|
def should_report_a_posted_reply_from_transcript_lines() -> None:
|
|
181
|
-
all_lines = [_prompt("Go."),
|
|
182
|
-
assert gate.
|
|
183
|
-
assert not gate.
|
|
467
|
+
all_lines = [_prompt("Go."), *_threads_read(), _reply("reply-1"), _result("reply-1")]
|
|
468
|
+
assert gate.has_posted_reply_on_threads(all_lines, frozenset({FIRST_THREAD_ID}))
|
|
469
|
+
assert not gate.has_posted_reply_on_threads(all_lines, frozenset({SECOND_THREAD_ID}))
|
|
470
|
+
assert not gate.has_posted_reply_on_threads(all_lines[:-1], frozenset({FIRST_THREAD_ID}))
|
|
471
|
+
assert not gate.has_posted_reply_on_threads(all_lines, frozenset())
|
|
184
472
|
|
|
185
473
|
|
|
186
474
|
def should_print_the_deny_when_run_as_a_hook(tmp_path: Path) -> None:
|
|
187
475
|
transcript_path = _write_transcript(
|
|
188
476
|
tmp_path,
|
|
189
|
-
[_prompt("Address the review threads."),
|
|
477
|
+
[_prompt("Address the review threads."), _resolve("resolve-1")],
|
|
190
478
|
)
|
|
191
479
|
completed_process = subprocess.run(
|
|
192
480
|
[sys.executable, str(GATE_PATH)],
|
|
@@ -197,3 +485,31 @@ def should_print_the_deny_when_run_as_a_hook(tmp_path: Path) -> None:
|
|
|
197
485
|
)
|
|
198
486
|
assert completed_process.returncode == 0
|
|
199
487
|
assert _deny_reason(json.loads(completed_process.stdout)) == MISSING_REPLY_REASON
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _review_write_payload(transcript_path: Path, method: str, thread_id: str = FIRST_THREAD_ID) -> dict[str, object]:
|
|
491
|
+
return {
|
|
492
|
+
"hook_event_name": "PreToolUse",
|
|
493
|
+
"tool_name": "mcp__github__pull_request_review_write",
|
|
494
|
+
"tool_input": {"method": method, "threadId": thread_id, "owner": "o", "repo": "r", "pullNumber": 7},
|
|
495
|
+
"transcript_path": str(transcript_path),
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def should_deny_a_review_write_resolve_with_no_reply(tmp_path: Path) -> None:
|
|
500
|
+
transcript_path = _write_transcript(tmp_path, [_prompt("Address the review threads.")])
|
|
501
|
+
decision = gate.decision_for(_review_write_payload(transcript_path, "resolve_thread"))
|
|
502
|
+
assert _deny_reason(decision) == MISSING_REPLY_REASON
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def should_allow_a_review_write_resolve_after_a_reply_on_that_thread(tmp_path: Path) -> None:
|
|
506
|
+
transcript_path = _write_transcript(
|
|
507
|
+
tmp_path,
|
|
508
|
+
[_prompt("Address the review threads."), *_threads_read(), _reply("reply-1"), _result("reply-1")],
|
|
509
|
+
)
|
|
510
|
+
assert gate.decision_for(_review_write_payload(transcript_path, "resolve_thread")) is None
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def should_leave_a_review_write_that_submits_a_review_alone(tmp_path: Path) -> None:
|
|
514
|
+
transcript_path = _write_transcript(tmp_path, [_prompt("Review the pull request.")])
|
|
515
|
+
assert gate.decision_for(_review_write_payload(transcript_path, "create")) is None
|