claude-dev-env 8.98.7 → 8.98.9

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.
@@ -1,9 +1,10 @@
1
- """Deny a review thread resolve until a reply posted after the last user prompt and the last resolve."""
1
+ """Deny a review thread resolve until a reply on that thread posted after the last user prompt and its last resolve."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
5
6
  import sys
6
- from collections.abc import Iterable
7
+ from collections.abc import Iterable, Iterator
7
8
  from pathlib import Path
8
9
 
9
10
  hooks_directory = str(Path(__file__).resolve().parent.parent)
@@ -15,12 +16,25 @@ from hooks_constants.pr_lifecycle_skill_gate_constants import SHELL_TOOL_NAMES
15
16
  from hooks_constants.pre_tool_use_stdin import print_decision_from_stdin
16
17
  from hooks_constants.resolve_thread_reply_gate_constants import (
17
18
  ALL_REPLY_TOOL_NAME_SUFFIXES,
19
+ ALL_THREAD_ID_INPUT_KEYS,
20
+ COMMENT_ANCHOR_PATTERN,
21
+ COMMENT_ID_INPUT_KEY,
18
22
  MISSING_REPLY_REASON,
19
23
  RESOLVE_TOOL_NAME_PATTERN,
24
+ RESOLVE_THREAD_METHOD,
25
+ REVIEW_WRITE_METHOD_KEY,
26
+ REVIEW_WRITE_TOOL_NAME_PATTERN,
20
27
  SHELL_COMMAND_KEY,
21
28
  SHELL_REPLY_COMMAND_PATTERN,
29
+ SHELL_REPLY_COMMENT_ID_PATTERN,
30
+ SHELL_RESOLVE_COMMAND_PATTERN,
31
+ SHELL_RESOLVE_COMMENT_ID_PATTERN,
32
+ TEXT_BLOCK_TYPE,
33
+ TEXT_FIELD,
34
+ THREAD_ID_PATTERN,
22
35
  )
23
36
  from hooks_constants.setup_project_paths_constants import DECODE_ERRORS_POLICY, UTF8_ENCODING
37
+ from hooks_constants.transcript_skill_scan_constants import IS_ERROR_FIELD, TOOL_RESULT_BLOCK_TYPE
24
38
  from transcript_skill_scan import TranscriptToolCall, tool_calls_since_last_prompt
25
39
 
26
40
 
@@ -33,6 +47,51 @@ def is_resolve_call(tool_name: object) -> bool:
33
47
  return isinstance(tool_name, str) and RESOLVE_TOOL_NAME_PATTERN.match(tool_name) is not None
34
48
 
35
49
 
50
+ def _is_review_write_resolve(tool_name: object, all_tool_input_fields: dict[str, object]) -> bool:
51
+ return (
52
+ isinstance(tool_name, str)
53
+ and REVIEW_WRITE_TOOL_NAME_PATTERN.match(tool_name) is not None
54
+ and all_tool_input_fields.get(REVIEW_WRITE_METHOD_KEY) == RESOLVE_THREAD_METHOD
55
+ )
56
+
57
+
58
+ def _shell_command(tool_name: object, all_tool_input_fields: dict[str, object]) -> str | None:
59
+ command = all_tool_input_fields.get(SHELL_COMMAND_KEY)
60
+ return command if tool_name in SHELL_TOOL_NAMES and isinstance(command, str) else None
61
+
62
+
63
+ def resolved_thread_ids(tool_name: object, all_tool_input_fields: dict[str, object]) -> frozenset[str] | None:
64
+ """Return the thread ids or comment ids a call resolves, or None when the call resolves no review thread.
65
+
66
+ ::
67
+
68
+ mcp__github__resolve_review_thread {"threadID": "PRRT_a"} -> {"PRRT_a"}
69
+ mcp__github__pull_request_review_write
70
+ {"method": "resolve_thread", "threadId": "PRRT_a"} -> {"PRRT_a"}
71
+ Bash "gh api graphql ... resolveReviewThread ... PRRT_a" -> {"PRRT_a"}
72
+ Bash "gh api graphql ... resolveReviewThread ... $thread" -> {}
73
+ Bash "gh api -X POST .../pulls/7/ccr/comments/9/resolve" -> {"9"}
74
+ Bash "gh pr view 7" -> None
75
+
76
+ Args:
77
+ tool_name: The tool name from a hook payload or transcript.
78
+ all_tool_input_fields: The tool input from a hook payload or transcript.
79
+ """
80
+ if is_resolve_call(tool_name) or _is_review_write_resolve(tool_name, all_tool_input_fields):
81
+ return frozenset(
82
+ each_value
83
+ for each_value in (all_tool_input_fields.get(each_key) for each_key in ALL_THREAD_ID_INPUT_KEYS)
84
+ if isinstance(each_value, str) and each_value
85
+ )
86
+ command = _shell_command(tool_name, all_tool_input_fields)
87
+ if command is None:
88
+ return None
89
+ all_resolved_comment_ids = frozenset(SHELL_RESOLVE_COMMENT_ID_PATTERN.findall(command))
90
+ if not all_resolved_comment_ids and SHELL_RESOLVE_COMMAND_PATTERN.search(command) is None:
91
+ return None
92
+ return frozenset(THREAD_ID_PATTERN.findall(command)) | all_resolved_comment_ids
93
+
94
+
36
95
  def is_reply_call(tool_call: TranscriptToolCall) -> bool:
37
96
  """Return whether a transcript tool call replies on a review thread.
38
97
 
@@ -48,66 +107,165 @@ def is_reply_call(tool_call: TranscriptToolCall) -> bool:
48
107
  """
49
108
  if tool_call.name.endswith(ALL_REPLY_TOOL_NAME_SUFFIXES):
50
109
  return True
51
- command = tool_call.tool_input.get(SHELL_COMMAND_KEY)
52
- return (
53
- tool_call.name in SHELL_TOOL_NAMES
54
- and isinstance(command, str)
55
- and SHELL_REPLY_COMMAND_PATTERN.search(command) is not None
56
- )
110
+ command = _shell_command(tool_call.name, tool_call.tool_input)
111
+ return command is not None and SHELL_REPLY_COMMAND_PATTERN.search(command) is not None
112
+
113
+
114
+ def _reply_comment_ids(all_tool_input_fields: dict[str, object]) -> frozenset[str]:
115
+ comment_id = all_tool_input_fields.get(COMMENT_ID_INPUT_KEY)
116
+ if isinstance(comment_id, (int, float)) and not isinstance(comment_id, bool):
117
+ return frozenset({str(int(comment_id))})
118
+ if isinstance(comment_id, str) and comment_id.isdigit():
119
+ return frozenset({comment_id})
120
+ return frozenset()
121
+
122
+
123
+ def _reply_targets(tool_call: TranscriptToolCall) -> tuple[frozenset[str], frozenset[str]]:
124
+ command = _shell_command(tool_call.name, tool_call.tool_input)
125
+ if command is None:
126
+ return frozenset(), _reply_comment_ids(tool_call.tool_input)
127
+ return frozenset(THREAD_ID_PATTERN.findall(command)), frozenset(SHELL_REPLY_COMMENT_ID_PATTERN.findall(command))
128
+
129
+
130
+ def _result_content_texts(result_content: object) -> list[str]:
131
+ if isinstance(result_content, str):
132
+ return [result_content]
133
+ all_parts = result_content if isinstance(result_content, list) else []
134
+ return [
135
+ each_part[TEXT_FIELD]
136
+ for each_part in all_parts
137
+ if isinstance(each_part, dict)
138
+ and each_part.get("type") == TEXT_BLOCK_TYPE
139
+ and isinstance(each_part.get(TEXT_FIELD), str)
140
+ ]
141
+
142
+
143
+ def _successful_result_texts(all_entry_fields: dict[str, object]) -> Iterator[str]:
144
+ message = all_entry_fields.get("message")
145
+ content = message.get("content") if isinstance(message, dict) else None
146
+ for each_block in content if isinstance(content, list) else []:
147
+ if not isinstance(each_block, dict) or each_block.get("type") != TOOL_RESULT_BLOCK_TYPE:
148
+ continue
149
+ if each_block.get(IS_ERROR_FIELD) is not True:
150
+ yield from _result_content_texts(each_block.get("content"))
151
+
152
+
153
+ def _thread_ids_by_comment_id_in_text(text: str) -> dict[str, str]:
154
+ all_thread_matches = list(THREAD_ID_PATTERN.finditer(text))
155
+ if not all_thread_matches:
156
+ return {}
157
+ all_span_ends = [each_match.start() for each_match in all_thread_matches[1:]] + [len(text)]
158
+ return {
159
+ each_anchor.group(1): each_thread_match.group()
160
+ for each_thread_match, each_span_end in zip(all_thread_matches, all_span_ends, strict=True)
161
+ for each_anchor in COMMENT_ANCHOR_PATTERN.finditer(text, each_thread_match.end(), each_span_end)
162
+ }
163
+
164
+
165
+ def thread_ids_by_comment_id(all_transcript_lines: Iterable[str]) -> dict[str, str]:
166
+ """Return each review comment id mapped to its thread id, read from successful tool results.
167
+
168
+ ::
169
+
170
+ get_review_comments {"id": "PRRT_a", "comments": [{"html_url": "...#discussion_r9"}]} -> {"9": "PRRT_a"}
171
+ gh api graphql {"id": "PRRT_a", "comments": {"nodes": [{"databaseId": 9}]}} -> {"9": "PRRT_a"}
57
172
 
173
+ A comment id belongs to the nearest thread id before it in the same result text.
58
174
 
59
- def has_posted_reply_since_last_resolve(all_transcript_lines: Iterable[str]) -> bool:
60
- """Return whether a reply that succeeded since the last user prompt is not yet spent on a successful resolve.
175
+ Args:
176
+ all_transcript_lines: JSON transcript entries, one per line.
177
+ """
178
+ thread_id_by_comment_id: dict[str, str] = {}
179
+ for each_line in all_transcript_lines:
180
+ try:
181
+ entry_fields = json.loads(each_line)
182
+ except ValueError:
183
+ continue
184
+ if not isinstance(entry_fields, dict):
185
+ continue
186
+ for each_text in _successful_result_texts(entry_fields):
187
+ thread_id_by_comment_id.update(_thread_ids_by_comment_id_in_text(each_text))
188
+ return thread_id_by_comment_id
189
+
190
+
191
+ def _thread_keys(all_ids: Iterable[str], all_thread_ids_by_comment_id: dict[str, str]) -> set[str]:
192
+ return {all_thread_ids_by_comment_id.get(each_id, each_id) for each_id in all_ids}
193
+
194
+
195
+ def _keys_after_call(
196
+ all_answered_keys: set[str], tool_call: TranscriptToolCall, all_thread_ids_by_comment_id: dict[str, str]
197
+ ) -> set[str]:
198
+ if not tool_call.succeeded:
199
+ return all_answered_keys
200
+ spent_thread_ids = resolved_thread_ids(tool_call.name, tool_call.tool_input)
201
+ if spent_thread_ids is not None:
202
+ return all_answered_keys - _thread_keys(spent_thread_ids, all_thread_ids_by_comment_id)
203
+ if not is_reply_call(tool_call):
204
+ return all_answered_keys
205
+ reply_thread_ids, reply_comment_ids = _reply_targets(tool_call)
206
+ return all_answered_keys | _thread_keys(reply_thread_ids | reply_comment_ids, all_thread_ids_by_comment_id)
207
+
208
+
209
+ def _answered_thread_keys(
210
+ all_calls: Iterable[TranscriptToolCall], all_thread_ids_by_comment_id: dict[str, str]
211
+ ) -> set[str]:
212
+ answered_thread_keys: set[str] = set()
213
+ for each_call in all_calls:
214
+ answered_thread_keys = _keys_after_call(answered_thread_keys, each_call, all_thread_ids_by_comment_id)
215
+ return answered_thread_keys
216
+
217
+
218
+ def has_posted_reply_on_threads(all_transcript_lines: Iterable[str], all_thread_ids: frozenset[str]) -> bool:
219
+ """Return whether each named thread has a reply that succeeded since the last user prompt and its last resolve.
61
220
 
62
221
  ::
63
222
 
64
- prompt, reply ok, resolve pending -> True
65
- prompt, reply refused by a hook, resolve -> False
66
- prompt, resolve -> False
67
- prompt, reply ok, resolve ok, resolve pending -> False
68
- prompt, reply ok, reply ok, resolve ok, resolve pending -> True
223
+ prompt, read, reply ok on thread A, resolve A pending -> True
224
+ prompt, read, reply ok on thread A, resolve B pending -> False
225
+ prompt, reply refused by a hook, resolve A -> False
226
+ prompt, reply ok on A, resolve A ok, resolve A pending -> False
227
+ prompt, reply ok on A and on B, resolve A ok, resolve B pending -> True
228
+ no thread ids -> False
69
229
 
70
- Each successful resolve spends one successful reply. A failed or denied
230
+ A successful resolve spends the replies on its threads. A failed or denied
71
231
  resolve spends none, so a retry after a resolve error needs no second reply.
232
+ A reply by comment id counts for the thread that a successful tool result,
233
+ such as ``pull_request_read`` ``get_review_comments``, lists the comment under,
234
+ and for a resolve that names the same comment id.
72
235
 
73
236
  Args:
74
237
  all_transcript_lines: JSON transcript entries, one per line.
238
+ all_thread_ids: The thread ids, or comment ids, the pending resolve names.
75
239
  """
76
- unspent_reply_count = 0
77
- for each_call in tool_calls_since_last_prompt(all_transcript_lines):
78
- unspent_reply_count = _unspent_reply_count_after(unspent_reply_count, each_call)
79
- return unspent_reply_count > 0
240
+ all_lines = list(all_transcript_lines)
241
+ thread_id_by_comment_id = thread_ids_by_comment_id(all_lines)
242
+ answered_thread_keys = _answered_thread_keys(tool_calls_since_last_prompt(all_lines), thread_id_by_comment_id)
243
+ return bool(all_thread_ids) and _thread_keys(all_thread_ids, thread_id_by_comment_id) <= answered_thread_keys
80
244
 
81
245
 
82
- def _unspent_reply_count_after(unspent_reply_count: int, tool_call: TranscriptToolCall) -> int:
83
- if not tool_call.succeeded:
84
- return unspent_reply_count
85
- if is_resolve_call(tool_call.name):
86
- return max(0, unspent_reply_count - 1)
87
- if is_reply_call(tool_call):
88
- return unspent_reply_count + 1
89
- return unspent_reply_count
90
-
91
-
92
- def _transcript_shows_reply(transcript_path: str) -> bool | None:
246
+ def _transcript_shows_reply(transcript_path: str, all_thread_ids: frozenset[str]) -> bool | None:
93
247
  try:
94
248
  with open(transcript_path, encoding=UTF8_ENCODING, errors=DECODE_ERRORS_POLICY) as transcript:
95
- return has_posted_reply_since_last_resolve(transcript)
249
+ return has_posted_reply_on_threads(transcript, all_thread_ids)
96
250
  except OSError:
97
251
  return None
98
252
 
99
253
 
100
254
  def decision_for(all_payload_fields: dict[str, object]) -> dict[str, object] | None:
101
- """Return a deny for a review thread resolve with no posted reply before it, else None.
255
+ """Return a deny for a review thread resolve with no posted reply on that thread before it, else None.
102
256
 
103
257
  ::
104
258
 
105
- resolve after a reply that posted -> None
106
- resolve after a reply a hook refused -> MISSING_REPLY_REASON
107
- resolve with no reply this turn -> MISSING_REPLY_REASON
108
- second resolve after one reply -> MISSING_REPLY_REASON
109
- resolve with an unreadable transcript -> None
110
- any other tool -> None
259
+ resolve after a reply on the thread that posted -> None
260
+ resolve after a reply on another thread -> MISSING_REPLY_REASON
261
+ resolve after a reply a hook refused -> MISSING_REPLY_REASON
262
+ resolve with no reply this turn -> MISSING_REPLY_REASON
263
+ second resolve after one reply -> MISSING_REPLY_REASON
264
+ gh api graphql resolveReviewThread, no reply -> MISSING_REPLY_REASON
265
+ gh api .../ccr/comments/9/resolve, no reply -> MISSING_REPLY_REASON
266
+ pull_request_review_write resolve_thread, no reply -> MISSING_REPLY_REASON
267
+ resolve with an unreadable transcript -> None
268
+ any other tool or shell command -> None
111
269
 
112
270
  The gate reads the calling agent's own transcript: a subagent's file when
113
271
  the payload names one, else the session transcript.
@@ -115,12 +273,16 @@ def decision_for(all_payload_fields: dict[str, object]) -> dict[str, object] | N
115
273
  Args:
116
274
  all_payload_fields: The parsed PreToolUse input.
117
275
  """
118
- if not is_resolve_call(all_payload_fields.get("tool_name")):
276
+ tool_input = all_payload_fields.get("tool_input")
277
+ all_thread_ids = resolved_thread_ids(
278
+ all_payload_fields.get("tool_name"), tool_input if isinstance(tool_input, dict) else {}
279
+ )
280
+ if all_thread_ids is None:
119
281
  return None
120
282
  all_paths = transcript_paths(all_payload_fields)
121
283
  if not all_paths:
122
284
  return None
123
- shows_reply = _transcript_shows_reply(all_paths[-1])
285
+ shows_reply = _transcript_shows_reply(all_paths[-1], all_thread_ids)
124
286
  if shows_reply is None or shows_reply:
125
287
  return None
126
288
  return deny_decision(MISSING_REPLY_REASON)