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.
@@ -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)
@@ -1,18 +1,27 @@
1
1
  #!/usr/bin/env python3
2
- """Stop hook that holds a turn until it sets the session title.
2
+ """Stop hook that holds a turn only when the session title shows a stale status.
3
+
4
+ The title's emoji tells the user whether a session needs them. The gate leaves
5
+ a title alone while it still fits, and holds the turn once when it does not::
6
+
7
+ no title set yet -> hold
8
+ red flag, and this turn's prompt asks for work -> hold: the user acted on it
9
+ check mark, and the task list has open items -> hold: work is open again
10
+ hourglass, every item completed or deleted -> hold: nothing runs
11
+ anything else -> pass
12
+
13
+ A turn that set a title passes, because the agent's choice stands. So does a
14
+ turn that called no tool other than the title tool, and a held turn
15
+ (``stop_hook_active``). The gate adds at most one step to a turn.
3
16
 
4
17
  A turn starts after the latest user prompt in the transcript. Tool results,
5
18
  meta entries, sidechain entries and compaction summaries are not prompts, and
6
19
  a Stop hook block lands as an attachment, so a held turn stays the same turn.
7
20
  A title call on a sidechain entry belongs to a subagent and does not count.
8
- The turn may end once a tool whose name ends in __set_session_title ran in it
9
- and its result came back without an error. A title the format gate denied
10
- does not count. A turn that called no tool other than the title tool may also
11
- end once an earlier main-chain turn set the title, since that title still
12
- shows and a text-only turn starts no new task. A held turn
13
- (``stop_hook_active`` true) whose title call failed may end, so a title tool
14
- that keeps failing cannot hold it forever. A held turn with no title call
15
- stays held, whichever Stop gate held it.
21
+ A title the format gate denied does not count either.
22
+
23
+ The task list comes from ``open_tasks_stop_gate.session_task_items``. Whether
24
+ a prompt asks for work comes from ``open_task_asks.is_ask``.
16
25
 
17
26
  The session has the title tool when a transcript record names it::
18
27
 
@@ -25,8 +34,7 @@ or with an unreadable transcript, the hook lets the turn end, so Codex,
25
34
  Cursor and sessions without the tool keep working.
26
35
 
27
36
  A block writes ``{"decision": "block", "reason": ...}`` to stdout. The reason
28
- names the four status emoji and tells the model to close with one short
29
- sentence, so the held turn neither ends empty nor repeats the reply.
37
+ names the shown title, why it does not fit, and the four status emoji.
30
38
  """
31
39
 
32
40
  from __future__ import annotations
@@ -38,10 +46,18 @@ from pathlib import Path
38
46
  hooks_root_directory = str(Path(__file__).resolve().parent.parent)
39
47
  if hooks_root_directory not in sys.path:
40
48
  sys.path.insert(0, hooks_root_directory)
49
+ blocking_directory = str(Path(__file__).resolve().parent)
50
+ if blocking_directory not in sys.path:
51
+ sys.path.insert(0, blocking_directory)
41
52
 
42
53
  from hooks_constants.hook_block_logger import log_hook_block
54
+ from hooks_constants.open_tasks_stop_gate_constants import INCOMPLETE_STATUSES
55
+ from open_task_asks import is_ask
56
+ from open_tasks_stop_gate import session_task_items
57
+ from open_tasks_transcript import TaskItem, replayed_transcript
43
58
  from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
44
59
  from hooks_constants.session_title_constants import (
60
+ ALL_ITEMS_COMPLETED_REASON,
45
61
  ALL_SKIPPED_PROMPT_FLAGS,
46
62
  ALL_TOOL_LISTING_NAME_KEYS,
47
63
  ALL_TOOL_LISTING_RECORD_KEYS,
@@ -50,17 +66,27 @@ from hooks_constants.session_title_constants import (
50
66
  ATTACHMENT_KEY,
51
67
  BLOCK_DECISION,
52
68
  BLOCK_ID_KEY,
69
+ BLOCK_INPUT_KEY,
53
70
  BLOCK_NAME_KEY,
71
+ CHECK_MARK_STATUS,
54
72
  CONTENT_KEY,
55
73
  DECISION_KEY,
56
74
  DECODE_ERRORS_POLICY,
57
75
  ENTRY_TYPE_KEY,
76
+ HOURGLASS_STATUS,
58
77
  IS_ERROR_KEY,
59
78
  MESSAGE_KEY,
79
+ NO_TITLE_REASON,
80
+ OPEN_ITEMS_REASON,
60
81
  REASON_KEY,
82
+ RED_FLAG_STATUS,
61
83
  REMOVED_NAMES_KEY,
84
+ SESSION_ID_KEY,
62
85
  SIDECHAIN_FLAG_KEY,
86
+ STALE_FLAG_REASON,
87
+ STATUS_PREFIX_PATTERN,
63
88
  STOP_HOOK_ACTIVE_KEY,
89
+ TITLE_INPUT_KEY,
64
90
  TITLE_TOOL_DISPLAY_SEPARATOR,
65
91
  TITLE_TOOL_NAME_PATTERN,
66
92
  TOOL_RESULT_BLOCK_TYPE,
@@ -175,10 +201,6 @@ def _turn_entries(all_entries: list[dict[str, object]]) -> list[dict[str, object
175
201
  return _main_chain(all_entries[_last_prompt_index(all_entries) + 1 :])
176
202
 
177
203
 
178
- def _earlier_entries(all_entries: list[dict[str, object]]) -> list[dict[str, object]]:
179
- return _main_chain(all_entries[: max(_last_prompt_index(all_entries), 0)])
180
-
181
-
182
204
  def _title_call_ids(all_turn_entries: list[dict[str, object]]) -> set[object]:
183
205
  return {
184
206
  each_block.get(BLOCK_ID_KEY)
@@ -188,18 +210,6 @@ def _title_call_ids(all_turn_entries: list[dict[str, object]]) -> set[object]:
188
210
  }
189
211
 
190
212
 
191
- def title_attempted_this_turn(all_entries: list[dict[str, object]]) -> bool:
192
- """Return True when a title call ran after the latest prompt, whatever its result.
193
-
194
- Args:
195
- all_entries: The transcript entries, oldest first.
196
-
197
- Returns:
198
- True when this turn called the title tool.
199
- """
200
- return bool(_title_call_ids(_turn_entries(all_entries)))
201
-
202
-
203
213
  def title_set_this_turn(all_entries: list[dict[str, object]]) -> bool:
204
214
  """Return True when a title call after the latest prompt came back without an error.
205
215
 
@@ -218,18 +228,6 @@ def title_set_this_turn(all_entries: list[dict[str, object]]) -> bool:
218
228
  return _title_succeeded_in(_turn_entries(all_entries))
219
229
 
220
230
 
221
- def title_set_before_this_turn(all_entries: list[dict[str, object]]) -> bool:
222
- """Return True when a main-chain title call before the latest prompt came back without an error.
223
-
224
- Args:
225
- all_entries: The transcript entries, oldest first.
226
-
227
- Returns:
228
- True when an earlier turn set the title the session still shows.
229
- """
230
- return _title_succeeded_in(_earlier_entries(all_entries))
231
-
232
-
233
231
  def turn_ran_a_tool(all_entries: list[dict[str, object]]) -> bool:
234
232
  """Return True when this turn called any tool other than the title tool.
235
233
 
@@ -246,46 +244,130 @@ def turn_ran_a_tool(all_entries: list[dict[str, object]]) -> bool:
246
244
  )
247
245
 
248
246
 
249
- def _title_succeeded_in(all_scoped_entries: list[dict[str, object]]) -> bool:
247
+ def _successful_title_call_ids(all_scoped_entries: list[dict[str, object]]) -> set[object]:
250
248
  all_title_call_ids = _title_call_ids(all_scoped_entries)
251
- return any(
252
- each_block.get(ENTRY_TYPE_KEY) == TOOL_RESULT_BLOCK_TYPE
253
- and each_block.get(TOOL_USE_ID_KEY) in all_title_call_ids
254
- and not each_block.get(IS_ERROR_KEY)
249
+ return {
250
+ each_block.get(TOOL_USE_ID_KEY)
255
251
  for each_entry in all_scoped_entries
256
252
  if each_entry.get(ENTRY_TYPE_KEY) == USER_ENTRY_TYPE
257
253
  for each_block in _content_blocks(each_entry)
258
- )
254
+ if each_block.get(ENTRY_TYPE_KEY) == TOOL_RESULT_BLOCK_TYPE
255
+ and each_block.get(TOOL_USE_ID_KEY) in all_title_call_ids
256
+ and not each_block.get(IS_ERROR_KEY)
257
+ }
258
+
259
+
260
+ def _title_succeeded_in(all_scoped_entries: list[dict[str, object]]) -> bool:
261
+ return bool(_successful_title_call_ids(all_scoped_entries))
262
+
263
+
264
+ def _title_input(all_block_fields: dict[str, object]) -> str | None:
265
+ all_input_fields = all_block_fields.get(BLOCK_INPUT_KEY)
266
+ title = all_input_fields.get(TITLE_INPUT_KEY) if isinstance(all_input_fields, dict) else None
267
+ return title if isinstance(title, str) else None
268
+
269
+
270
+ def shown_title(all_entries: list[dict[str, object]]) -> str | None:
271
+ """Return the title of the last main-chain title call that came back without an error.
272
+
273
+ ::
274
+
275
+ "\u23f3 Export" ok, then "\u2705 Export" denied -> "\u23f3 Export"
276
+ no title call -> None
277
+
278
+ Args:
279
+ all_entries: The transcript entries, oldest first.
280
+
281
+ Returns:
282
+ The title the session shows, or None when no title call succeeded.
283
+ """
284
+ all_main_entries = _main_chain(all_entries)
285
+ all_successful_ids = _successful_title_call_ids(all_main_entries)
286
+ all_titles = [
287
+ _title_input(each_block)
288
+ for each_entry in all_main_entries
289
+ for each_block in _tool_use_blocks(each_entry)
290
+ if each_block.get(BLOCK_ID_KEY) in all_successful_ids
291
+ ]
292
+ all_text_titles = [each_title for each_title in all_titles if each_title is not None]
293
+ return all_text_titles[-1] if all_text_titles else None
294
+
295
+
296
+ def title_status(title: str) -> str | None:
297
+ """Return the status emoji the title starts with, without a variation selector, or None."""
298
+ status_match = STATUS_PREFIX_PATTERN.match(title)
299
+ return status_match.group(1) if status_match else None
300
+
301
+
302
+ def stale_status_reason(title: str, is_ask_turn: bool, all_task_items: list[TaskItem]) -> str | None:
303
+ """Return why the shown title does not fit the session's state, or None when it fits.
304
+
305
+ ::
306
+
307
+ red flag, the prompt asks for work -> reason
308
+ check mark, an item is pending -> reason
309
+ hourglass, every item completed or deleted -> reason
310
+ hourglass, an item is in progress -> None
311
+
312
+ Args:
313
+ title: The title the session shows.
314
+ is_ask_turn: True when the prompt that started this turn asks for work.
315
+ all_task_items: The session's task list.
316
+
317
+ Returns:
318
+ The reason the status is stale, with the title filled in, or None.
319
+ """
320
+ status = title_status(title)
321
+ has_open_items = any(each_item.status in INCOMPLETE_STATUSES for each_item in all_task_items)
322
+ is_all_completed = bool(all_task_items) and not has_open_items
323
+ if status == RED_FLAG_STATUS and is_ask_turn:
324
+ return STALE_FLAG_REASON.format(title=title)
325
+ if status == CHECK_MARK_STATUS and has_open_items:
326
+ return OPEN_ITEMS_REASON.format(title=title)
327
+ if status == HOURGLASS_STATUS and is_all_completed:
328
+ return ALL_ITEMS_COMPLETED_REASON.format(title=title)
329
+ return None
259
330
 
260
331
 
261
332
  def turn_block_reason(
262
- all_entries: list[dict[str, object]], is_stop_hook_active: bool
333
+ all_entries: list[dict[str, object]],
334
+ is_stop_hook_active: bool,
335
+ is_ask_turn: bool,
336
+ all_task_items: list[TaskItem],
263
337
  ) -> str | None:
264
338
  """Return the reason to hold the turn, or None when the turn may end.
265
339
 
266
340
  ::
267
341
 
268
- no title tool in the transcript -> None
269
- title set this turn -> None
270
- only title calls this turn, earlier turn set the title -> None
271
- held turn whose title call failed (stop_hook_active) -> None
272
- no title call this turn -> reason
342
+ no title tool in the transcript -> None
343
+ held turn (stop_hook_active) -> None
344
+ title set this turn -> None
345
+ no tool other than the title tool this turn -> None
346
+ no title set in the session yet -> reason
347
+ shown title has a stale status -> reason
348
+ shown title still fits -> None
273
349
 
274
350
  Args:
275
351
  all_entries: The transcript entries, oldest first.
276
352
  is_stop_hook_active: True when a Stop hook already held this turn.
353
+ is_ask_turn: True when the prompt that started this turn asks for work.
354
+ all_task_items: The session's task list.
277
355
 
278
356
  Returns:
279
357
  The block reason that names the title tool, or None.
280
358
  """
281
359
  all_title_tools = available_title_tools(all_entries)
282
- if not all_title_tools or title_set_this_turn(all_entries):
360
+ if not all_title_tools or is_stop_hook_active or title_set_this_turn(all_entries):
283
361
  return None
284
- if title_set_before_this_turn(all_entries) and not turn_ran_a_tool(all_entries):
362
+ if not turn_ran_a_tool(all_entries):
285
363
  return None
286
- if is_stop_hook_active and title_attempted_this_turn(all_entries):
364
+ title = shown_title(all_entries)
365
+ stale_reason = NO_TITLE_REASON if title is None else stale_status_reason(title, is_ask_turn, all_task_items)
366
+ if stale_reason is None:
287
367
  return None
288
- return TURN_BLOCK_REASON.format(tool_name=TITLE_TOOL_DISPLAY_SEPARATOR.join(all_title_tools))
368
+ return TURN_BLOCK_REASON.format(
369
+ why=stale_reason, tool_name=TITLE_TOOL_DISPLAY_SEPARATOR.join(all_title_tools)
370
+ )
289
371
 
290
372
 
291
373
  def _parsed_entry(line: str) -> dict[str, object] | None:
@@ -316,9 +398,13 @@ def main() -> int:
316
398
  hook_input = read_hook_input_dictionary_from_stdin()
317
399
  if hook_input is None:
318
400
  return ALLOW_EXIT_CODE
401
+ transcript_path = hook_input.get(TRANSCRIPT_PATH_KEY)
402
+ replay = replayed_transcript(transcript_path)
319
403
  block_reason = turn_block_reason(
320
- read_transcript_entries(hook_input.get(TRANSCRIPT_PATH_KEY)),
404
+ read_transcript_entries(transcript_path),
321
405
  hook_input.get(STOP_HOOK_ACTIVE_KEY) is True,
406
+ is_ask(replay.turn_prompt_text),
407
+ session_task_items(hook_input.get(SESSION_ID_KEY), replay),
322
408
  )
323
409
  if block_reason is None:
324
410
  return ALLOW_EXIT_CODE