claude-dev-env 8.98.9 → 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,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- """Rebuild a session task list and its last assistant text from a transcript.
2
+ """Rebuild a session task list and what its current turn did from a transcript.
3
3
 
4
4
  The open tasks Stop gate reads four transcript shapes, replayed in order::
5
5
 
@@ -17,15 +17,19 @@ A desktop plan call either lists items or moves the current list::
17
17
  Only ``assistant`` records count for a desktop plan call, because Claude Code
18
18
  also copies each call into ``api-request-blob`` records.
19
19
 
20
- The replay also notes whether the current turn called a question card::
20
+ The replay also notes what the current turn did::
21
21
 
22
- AskUserQuestion, mcp__hearthbot__ask_decision, mcp__hearthbot__request_decision (Claude Code)
23
- request_user_input, request_user_input_async (Codex)
22
+ turn_prompt_text the text of the prompt that started the turn
23
+ has_turn_tracker_write a TaskCreate, TaskUpdate, TodoWrite, update_plan, or desktop plan call ran
24
+ has_turn_work_tool any other assistant tool call ran, except the title tool,
25
+ AskUserQuestion, a hearthbot decision card, ToolSearch,
26
+ TaskList, TaskGet, and the Codex request_user_input card
24
27
 
25
28
  A turn starts at the last user prompt record. A tool result, a record flagged
26
29
  ``isMeta``, ``isSidechain`` or ``isCompactSummary``, and a ``Stop hook feedback``
27
30
  message do not start a turn; these are the flags session_title_turn_gate.py
28
31
  skips, and its tests model Stop hook feedback as an ``isMeta`` user record.
32
+ A Codex ``response_item`` user message starts a Codex turn.
29
33
 
30
34
  The Cursor CLI can also reach TodoWrite through ``CallDynamicTool``, with the
31
35
  TodoWrite input under ``arguments``; that call counts as the TodoWrite call.
@@ -36,7 +40,6 @@ from __future__ import annotations
36
40
  import json
37
41
  import re
38
42
  import sys
39
- from datetime import datetime
40
43
  from pathlib import Path
41
44
  from typing import NamedTuple
42
45
 
@@ -47,6 +50,9 @@ if hooks_root_directory not in sys.path:
47
50
  from hooks_constants.open_tasks_stop_gate_constants import (
48
51
  ALL_SKIPPED_PROMPT_FLAGS,
49
52
  ASSISTANT_RECORD_TYPE,
53
+ ALL_CODEX_TOOL_CALL_TYPES,
54
+ ALL_NON_WORK_TOOL_NAMES,
55
+ ALL_TRACKER_TOOL_NAMES,
50
56
  ASSISTANT_ROLE,
51
57
  CCD_PLAN_ADVANCE_KEY,
52
58
  CCD_PLAN_CONTENT_KEY,
@@ -56,19 +62,16 @@ from hooks_constants.open_tasks_stop_gate_constants import (
56
62
  CCD_PLAN_START_KEY,
57
63
  CCD_PLAN_TOOL_NAME,
58
64
  CODEX_ARGUMENTS_KEY,
59
- CODEX_CARD_EVENT,
60
65
  CODEX_FUNCTION_CALL_TYPE,
61
66
  CODEX_MESSAGE_TYPE,
62
67
  CODEX_PAYLOAD_KEY,
63
68
  CODEX_PLAN_KEY,
64
69
  CODEX_PLAN_TOOL_NAME,
65
- CODEX_PROMPT_EVENT,
66
70
  CODEX_RESPONSE_ITEM_TYPE,
67
71
  CODEX_STEP_KEY,
68
72
  COMPLETED_STATUS,
69
73
  CONTENT_KEY,
70
74
  CREATED_TASK_ID_GROUP_INDEX,
71
- DECISION_CARD_TOOL_PATTERN,
72
75
  DYNAMIC_TOOL_ARGUMENTS_KEY,
73
76
  DYNAMIC_TOOL_CALL_NAME,
74
77
  DYNAMIC_TOOL_NAME_KEY,
@@ -77,9 +80,7 @@ from hooks_constants.open_tasks_stop_gate_constants import (
77
80
  IN_PROGRESS_STATUS,
78
81
  MESSAGE_KEY,
79
82
  PENDING_STATUS,
80
- RECORD_TIMESTAMP_KEY,
81
83
  RECORD_TYPE_KEY,
82
- REPLY_TEXT_SEPARATOR,
83
84
  ROLE_KEY,
84
85
  STOP_HOOK_FEEDBACK_PREFIX,
85
86
  TASK_CREATED_PATTERN,
@@ -88,6 +89,7 @@ from hooks_constants.open_tasks_stop_gate_constants import (
88
89
  TASK_STATUS_KEY,
89
90
  TASK_SUBJECT_KEY,
90
91
  TASK_UPDATE_ID_KEY,
92
+ SESSION_TITLE_TOOL_SUFFIX,
91
93
  TASK_UPDATE_TOOL_NAME,
92
94
  TEXT_KEY,
93
95
  TODOS_KEY,
@@ -100,8 +102,6 @@ from hooks_constants.open_tasks_stop_gate_constants import (
100
102
  TOOL_RESULT_BLOCK_TYPE,
101
103
  TOOL_USE_BLOCK_TYPE,
102
104
  TOOL_USE_ID_KEY,
103
- UTC_OFFSET_TEXT,
104
- UTC_SUFFIX,
105
105
  USER_RECORD_TYPE,
106
106
  USER_ROLE,
107
107
  UTF8_ENCODING,
@@ -116,15 +116,11 @@ class TaskItem(NamedTuple):
116
116
  item_id: The task number or position, as text.
117
117
  subject: The item's wording.
118
118
  status: The item's status, such as ``pending`` or ``completed``.
119
- listed_at: For a desktop plan item, seconds since the epoch of the last list that held it.
120
- is_carried: True for a desktop plan item a replacing list left out.
121
119
  """
122
120
 
123
121
  item_id: str
124
122
  subject: str
125
123
  status: str
126
- listed_at: float | None = None
127
- is_carried: bool = False
128
124
 
129
125
 
130
126
  def normalized_text(text: str) -> str:
@@ -132,17 +128,6 @@ def normalized_text(text: str) -> str:
132
128
  return WORD_SEPARATOR.join(text.lower().split())
133
129
 
134
130
 
135
- def record_epoch(all_record_fields: dict[str, object]) -> float | None:
136
- """Return the record's ISO ``timestamp`` as seconds since the epoch, or None."""
137
- timestamp_value = all_record_fields.get(RECORD_TIMESTAMP_KEY)
138
- if not isinstance(timestamp_value, str):
139
- return None
140
- try:
141
- return datetime.fromisoformat(timestamp_value.replace(UTC_SUFFIX, UTC_OFFSET_TEXT)).timestamp()
142
- except ValueError:
143
- return None
144
-
145
-
146
131
  def parsed_object(json_text: str) -> dict[str, object] | None:
147
132
  """Return the JSON object in the text, or None for anything else."""
148
133
  try:
@@ -182,45 +167,76 @@ def _result_text(all_block_fields: dict[str, object]) -> str:
182
167
  )
183
168
 
184
169
 
185
- def is_decision_card_tool(tool_name: object) -> bool:
186
- """Return True for a tool that puts a question card in front of the user."""
187
- return isinstance(tool_name, str) and re.search(DECISION_CARD_TOOL_PATTERN, tool_name) is not None
170
+ def _text_of_blocks(all_blocks: list[dict[str, object]]) -> str:
171
+ return WORD_SEPARATOR.join(
172
+ str(each_block.get(TEXT_KEY, "")) for each_block in all_blocks if isinstance(each_block.get(TEXT_KEY), str)
173
+ )
188
174
 
189
175
 
190
- def is_user_prompt_record(all_record_fields: dict[str, object]) -> bool:
191
- """Return True for a record that holds a prompt the user sent, which starts a new turn."""
176
+ def claude_prompt_text(all_record_fields: dict[str, object]) -> str | None:
177
+ """Return the prompt text of a record that starts a turn, or None for any other record."""
192
178
  message_body = all_record_fields.get(MESSAGE_KEY)
193
179
  message_role = message_body.get(ROLE_KEY) if isinstance(message_body, dict) else None
194
180
  if USER_RECORD_TYPE != all_record_fields.get(RECORD_TYPE_KEY) and USER_ROLE not in (all_record_fields.get(ROLE_KEY), message_role):
195
- return False
181
+ return None
196
182
  if any(all_record_fields.get(each_flag) for each_flag in ALL_SKIPPED_PROMPT_FLAGS):
197
- return False
183
+ return None
198
184
  content_value = message_body.get(CONTENT_KEY) if isinstance(message_body, dict) else None
199
185
  if isinstance(content_value, str):
200
186
  prompt_text = content_value
201
187
  else:
202
188
  all_blocks = _content_blocks(all_record_fields)
203
189
  if any(each_block.get(RECORD_TYPE_KEY) == TOOL_RESULT_BLOCK_TYPE for each_block in all_blocks):
204
- return False
190
+ return None
205
191
  if any(each_block.get(RECORD_TYPE_KEY) != TEXT_KEY for each_block in all_blocks):
206
- return True
207
- prompt_text = WORD_SEPARATOR.join(
208
- str(each_block.get(TEXT_KEY, "")) for each_block in all_blocks if each_block.get(RECORD_TYPE_KEY) == TEXT_KEY
209
- )
210
- return bool(prompt_text.strip()) and not prompt_text.lstrip().startswith(STOP_HOOK_FEEDBACK_PREFIX)
192
+ return _text_of_blocks(all_blocks)
193
+ prompt_text = _text_of_blocks(all_blocks)
194
+ if not prompt_text.strip() or prompt_text.lstrip().startswith(STOP_HOOK_FEEDBACK_PREFIX):
195
+ return None
196
+ return prompt_text
211
197
 
212
198
 
213
- def codex_turn_event(all_record_fields: dict[str, object]) -> str | None:
214
- """Return ``card`` for a Codex question card call, ``prompt`` for a Codex user message, else None."""
199
+ def _codex_payload(all_record_fields: dict[str, object]) -> dict[str, object] | None:
215
200
  payload_body = all_record_fields.get(CODEX_PAYLOAD_KEY)
216
201
  if all_record_fields.get(RECORD_TYPE_KEY) != CODEX_RESPONSE_ITEM_TYPE or not isinstance(payload_body, dict):
217
202
  return None
218
- payload_type = payload_body.get(RECORD_TYPE_KEY)
219
- if payload_type == CODEX_FUNCTION_CALL_TYPE and is_decision_card_tool(payload_body.get(TOOL_NAME_KEY)):
220
- return CODEX_CARD_EVENT
221
- if payload_type == CODEX_MESSAGE_TYPE and payload_body.get(ROLE_KEY) == USER_ROLE:
222
- return CODEX_PROMPT_EVENT
223
- return None
203
+ return payload_body
204
+
205
+
206
+ def codex_prompt_text(all_record_fields: dict[str, object]) -> str | None:
207
+ """Return the text of a Codex user message record, or None for any other record."""
208
+ payload_body = _codex_payload(all_record_fields)
209
+ if payload_body is None or payload_body.get(RECORD_TYPE_KEY) != CODEX_MESSAGE_TYPE or payload_body.get(ROLE_KEY) != USER_ROLE:
210
+ return None
211
+ content_value = payload_body.get(CONTENT_KEY)
212
+ if isinstance(content_value, str):
213
+ return content_value
214
+ all_blocks = [each_block for each_block in content_value if isinstance(each_block, dict)] if isinstance(content_value, list) else []
215
+ return _text_of_blocks(all_blocks)
216
+
217
+
218
+ def is_codex_work_call(all_record_fields: dict[str, object]) -> bool:
219
+ """Return True for a Codex tool call record other than ``update_plan`` or a question card call."""
220
+ payload_body = _codex_payload(all_record_fields)
221
+ if payload_body is None or payload_body.get(RECORD_TYPE_KEY) not in ALL_CODEX_TOOL_CALL_TYPES:
222
+ return False
223
+ tool_name = payload_body.get(TOOL_NAME_KEY)
224
+ return tool_name != CODEX_PLAN_TOOL_NAME and is_work_tool_name(tool_name)
225
+
226
+
227
+ def is_work_tool_name(tool_name: object) -> bool:
228
+ """Return True for a tool name other than a non-work tool or the session title tool."""
229
+ return not (
230
+ isinstance(tool_name, str)
231
+ and (tool_name in ALL_NON_WORK_TOOL_NAMES or tool_name.endswith(SESSION_TITLE_TOOL_SUFFIX))
232
+ )
233
+
234
+
235
+ def is_assistant_record(all_record_fields: dict[str, object]) -> bool:
236
+ """Return True for a record the assistant wrote, by its type, its role, or its message role."""
237
+ message_body = all_record_fields.get(MESSAGE_KEY)
238
+ message_role = message_body.get(ROLE_KEY) if isinstance(message_body, dict) else None
239
+ return ASSISTANT_ROLE in (all_record_fields.get(ROLE_KEY), all_record_fields.get(RECORD_TYPE_KEY), message_role)
224
240
 
225
241
 
226
242
  def _numbered_items(all_entries: object, subject_key: str) -> list[TaskItem]:
@@ -305,7 +321,7 @@ def unwrapped_tool_call(tool_name: str, all_input_fields: dict[str, object]) ->
305
321
 
306
322
 
307
323
  class TranscriptReplay:
308
- """Fold transcript records into the newest task list they describe."""
324
+ """Fold transcript records into the newest task list they describe and what the current turn did."""
309
325
 
310
326
  def __init__(self) -> None:
311
327
  self.latest_items: list[TaskItem] | None = None
@@ -313,37 +329,44 @@ class TranscriptReplay:
313
329
  self.subject_by_tool_use_id: dict[str, str] = {}
314
330
  self.plan_items: list[TaskItem] | None = None
315
331
  self.own_plan_count = 0
316
- self.last_assistant_text = ""
317
- self.all_assistant_texts: list[str] = []
318
- self.has_turn_decision_card = False
332
+ self.turn_prompt_text = ""
333
+ self.has_turn_tracker_write = False
334
+ self.has_turn_work_tool = False
335
+
336
+ def start_turn(self, prompt_text: str) -> None:
337
+ self.turn_prompt_text = prompt_text
338
+ self.has_turn_tracker_write = False
339
+ self.has_turn_work_tool = False
319
340
 
320
341
  def apply_record(self, all_record_fields: dict[str, object]) -> None:
321
- codex_event = codex_turn_event(all_record_fields)
322
- if codex_event is not None:
323
- self.has_turn_decision_card = codex_event == CODEX_CARD_EVENT
342
+ prompt_text = codex_prompt_text(all_record_fields)
343
+ if prompt_text is None:
344
+ prompt_text = claude_prompt_text(all_record_fields)
345
+ if prompt_text is not None:
346
+ self.start_turn(prompt_text)
324
347
  return
325
- if is_user_prompt_record(all_record_fields):
326
- self.has_turn_decision_card = False
327
348
  plan_items = codex_plan_items(all_record_fields)
328
349
  if plan_items is not None:
329
350
  self.latest_items = plan_items
351
+ self.has_turn_tracker_write = True
330
352
  return
331
- all_blocks = _content_blocks(all_record_fields)
332
- is_assistant_record = all_record_fields.get(RECORD_TYPE_KEY) == ASSISTANT_RECORD_TYPE
333
- for each_block in all_blocks:
353
+ if is_codex_work_call(all_record_fields):
354
+ self.has_turn_work_tool = True
355
+ return
356
+ is_typed_assistant_record = all_record_fields.get(RECORD_TYPE_KEY) == ASSISTANT_RECORD_TYPE
357
+ is_from_assistant = is_assistant_record(all_record_fields)
358
+ for each_block in _content_blocks(all_record_fields):
334
359
  plan_input = each_block.get(TOOL_INPUT_KEY)
335
- if is_assistant_record and each_block.get(RECORD_TYPE_KEY) == TOOL_USE_BLOCK_TYPE and is_decision_card_tool(each_block.get(TOOL_NAME_KEY)):
336
- self.has_turn_decision_card = True
337
- if is_assistant_record and each_block.get(TOOL_NAME_KEY) == CCD_PLAN_TOOL_NAME and isinstance(plan_input, dict):
338
- self.apply_desktop_plan(plan_input, record_epoch(all_record_fields))
360
+ if is_typed_assistant_record and each_block.get(TOOL_NAME_KEY) == CCD_PLAN_TOOL_NAME and isinstance(plan_input, dict):
361
+ self.apply_desktop_plan(plan_input)
362
+ self.has_turn_tracker_write = True
339
363
  else:
340
- self.apply_block(each_block)
341
- self.note_assistant_text(all_record_fields, all_blocks)
364
+ self.apply_block(each_block, is_from_assistant)
342
365
 
343
- def apply_desktop_plan(self, all_plan_fields: dict[str, object], listed_at: float | None) -> None:
366
+ def apply_desktop_plan(self, all_plan_fields: dict[str, object]) -> None:
344
367
  all_listed_entries = all_plan_fields.get(CCD_PLAN_ITEMS_KEY)
345
368
  if isinstance(all_listed_entries, list):
346
- self.replace_desktop_plan(all_listed_entries, listed_at)
369
+ self.replace_desktop_plan(all_listed_entries)
347
370
  return
348
371
  all_own_items = (self.plan_items or [])[: self.own_plan_count]
349
372
  all_done_positions = all_plan_fields.get(CCD_PLAN_DONE_KEY)
@@ -357,41 +380,37 @@ class TranscriptReplay:
357
380
  all_own_items = advanced_items(all_own_items)
358
381
  self.plan_items = all_own_items + (self.plan_items or [])[self.own_plan_count :]
359
382
 
360
- def replace_desktop_plan(self, all_listed_entries: list[object], listed_at: float | None) -> None:
383
+ def replace_desktop_plan(self, all_listed_entries: list[object]) -> None:
361
384
  all_own_items = [
362
- each_item._replace(item_id=CCD_PLAN_ITEM_ID_TEMPLATE.format(position=each_item.item_id), listed_at=listed_at)
385
+ each_item._replace(item_id=CCD_PLAN_ITEM_ID_TEMPLATE.format(position=each_item.item_id))
363
386
  for each_item in _numbered_items(all_listed_entries, CCD_PLAN_CONTENT_KEY)
364
387
  ]
365
388
  all_listed_subjects = {normalized_text(each_item.subject) for each_item in all_own_items}
366
389
  all_carried_items = [
367
- each_item._replace(is_carried=True)
390
+ each_item
368
391
  for each_item in self.plan_items or []
369
392
  if each_item.status in INCOMPLETE_STATUSES and normalized_text(each_item.subject) not in all_listed_subjects
370
393
  ]
371
394
  self.plan_items = all_own_items + all_carried_items
372
395
  self.own_plan_count = len(all_own_items)
373
396
 
374
- def note_assistant_text(self, all_record_fields: dict[str, object], all_blocks: list[dict[str, object]]) -> None:
375
- message_body = all_record_fields.get(MESSAGE_KEY)
376
- message_role = message_body.get(ROLE_KEY) if isinstance(message_body, dict) else None
377
- if ASSISTANT_ROLE not in (all_record_fields.get(ROLE_KEY), all_record_fields.get(RECORD_TYPE_KEY), message_role):
378
- return
379
- record_text = REPLY_TEXT_SEPARATOR.join(
380
- str(each_block.get(TEXT_KEY, "")) for each_block in all_blocks if each_block.get(RECORD_TYPE_KEY) == TEXT_KEY
381
- ).strip()
382
- if record_text:
383
- self.last_assistant_text = record_text
384
- self.all_assistant_texts.append(record_text)
385
-
386
- def apply_block(self, all_block_fields: dict[str, object]) -> None:
397
+ def apply_block(self, all_block_fields: dict[str, object], is_from_assistant: bool) -> None:
387
398
  block_type = all_block_fields.get(RECORD_TYPE_KEY)
388
399
  tool_input = all_block_fields.get(TOOL_INPUT_KEY)
389
400
  if block_type == TOOL_USE_BLOCK_TYPE and isinstance(tool_input, dict):
390
401
  tool_name, all_tool_arguments = unwrapped_tool_call(str(all_block_fields.get(TOOL_NAME_KEY)), tool_input)
391
402
  self.apply_tool_use(tool_name, str(all_block_fields.get(TASK_ID_KEY)), all_tool_arguments)
403
+ if is_from_assistant:
404
+ self.note_turn_tool(tool_name)
392
405
  elif block_type == TOOL_RESULT_BLOCK_TYPE:
393
406
  self.apply_tool_result(all_block_fields)
394
407
 
408
+ def note_turn_tool(self, tool_name: str) -> None:
409
+ if tool_name in ALL_TRACKER_TOOL_NAMES:
410
+ self.has_turn_tracker_write = True
411
+ elif is_work_tool_name(tool_name):
412
+ self.has_turn_work_tool = True
413
+
395
414
  def apply_tool_use(self, tool_name: str, tool_use_id: str, all_input_fields: dict[str, object]) -> None:
396
415
  if tool_name == TODO_WRITE_TOOL_NAME:
397
416
  all_todo_entries = all_input_fields.get(TODOS_KEY)
@@ -433,13 +452,8 @@ class TranscriptReplay:
433
452
 
434
453
 
435
454
  def replayed_transcript(transcript_path: object) -> TranscriptReplay:
436
- """Fold every transcript record into one replay of the task list and the last assistant text."""
455
+ """Fold every transcript record into one replay of the task list and the current turn."""
437
456
  replay = TranscriptReplay()
438
457
  for each_record in _transcript_records(transcript_path):
439
458
  replay.apply_record(each_record)
440
459
  return replay
441
-
442
-
443
- def transcript_items(transcript_path: object) -> list[TaskItem] | None:
444
- """Rebuild the task list from TaskCreate/TaskUpdate, TodoWrite, and Codex update_plan calls."""
445
- return replayed_transcript(transcript_path).items()
@@ -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