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,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()