claude-dev-env 8.87.0 → 8.88.0

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.
Files changed (30) hide show
  1. package/AGENTS.md +16 -71
  2. package/bin/cursor-agents-guidance.mjs +94 -0
  3. package/bin/cursor-agents-guidance.test.mjs +94 -0
  4. package/bin/cursor-native-hooks.mjs +128 -0
  5. package/bin/cursor-native-hooks.test.mjs +78 -0
  6. package/bin/install-constants.mjs +29 -0
  7. package/bin/install-deepstack-plugin.mjs +84 -39
  8. package/bin/install-deepstack-plugin.test.mjs +51 -11
  9. package/bin/install.cursor-rules.test.mjs +65 -2
  10. package/bin/install.mjs +35 -0
  11. package/bin/install.test.mjs +10 -5
  12. package/docs/agents-guidance/asks.md +49 -0
  13. package/docs/agents-guidance/banned-word.md +13 -0
  14. package/docs/agents-guidance/evidence.md +11 -0
  15. package/docs/agents-guidance/prose.md +34 -0
  16. package/docs/agents-guidance/tools.md +9 -0
  17. package/hooks/blocking/open_tasks_stop_gate.py +210 -0
  18. package/hooks/blocking/open_tasks_transcript.py +270 -0
  19. package/hooks/blocking/test_open_tasks_stop_gate.py +297 -0
  20. package/hooks/blocking/test_open_tasks_transcript.py +54 -0
  21. package/hooks/features/conduct-gates.md +3 -0
  22. package/hooks/hooks.json +5 -0
  23. package/hooks/hooks_constants/cursor_agents_guidance_constants.py +6 -0
  24. package/hooks/hooks_constants/open_tasks_stop_gate_constants.py +72 -0
  25. package/hooks/session/cursor_agents_guidance.py +54 -0
  26. package/hooks/session/test_cursor_agents_guidance.py +24 -0
  27. package/package.json +1 -1
  28. package/scripts/policy_lint/adapter_configuration.py +6 -0
  29. package/scripts/policy_lint/config/constants.py +1 -0
  30. package/scripts/tests/test_adapter_configuration.py +8 -0
@@ -0,0 +1,210 @@
1
+ #!/usr/bin/env python3
2
+ """Stop hook that keeps a session working while its task list has unreported open items.
3
+
4
+ The session's task list comes from the first of these that exists::
5
+
6
+ ~/.claude/tasks/<task list id>/*.json (TaskCreate / TaskUpdate store)
7
+ transcript TaskCreate / TaskUpdate calls (replayed in order)
8
+ transcript TodoWrite input.todos (last call wins)
9
+ transcript Codex update_plan arguments (last call wins)
10
+
11
+ An item with status ``pending`` or ``in_progress`` is open. The turn may end
12
+ only when the closing reply names every open item on a report line::
13
+
14
+ allow: Blocked: Rotate the deploy key - the owner must approve the org secret
15
+ allow: - Open: #3 - waits on the release checks
16
+ block: (reply never mentions "Rotate the deploy key")
17
+
18
+ A block sends the open items back as the reason, so the model keeps working or
19
+ reports them. Claude Code ends the turn after eight blocks in a row. Codex
20
+ turns the reason into a continuation prompt. A Cursor payload (it carries
21
+ ``conversation_id``) also gets the reason as ``followup_message``, which
22
+ Cursor submits as the next user message; Cursor sends no closing reply, so
23
+ the gate reads the last assistant text from the transcript. A Cursor payload
24
+ whose ``status`` is ``aborted`` or ``error`` is allowed, because Cursor would
25
+ submit the follow-up after the user stopped the run, and so is one whose
26
+ ``loop_count`` shows five follow-ups already sent. A TodoWrite call with
27
+ ``merge`` set updates the earlier items by id, and a Cursor ``CallDynamicTool``
28
+ call that wraps TodoWrite counts as that TodoWrite call.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import json
34
+ import os
35
+ import re
36
+ import sys
37
+ from pathlib import Path
38
+
39
+ hooks_root_directory = str(Path(__file__).resolve().parent.parent)
40
+ if hooks_root_directory not in sys.path:
41
+ sys.path.insert(0, hooks_root_directory)
42
+ blocking_directory = str(Path(__file__).resolve().parent)
43
+ if blocking_directory not in sys.path:
44
+ sys.path.insert(0, blocking_directory)
45
+
46
+ from hooks_constants.hook_block_logger import log_hook_block
47
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
48
+ from open_tasks_transcript import TaskItem, parsed_object, replayed_transcript
49
+ from hooks_constants.open_tasks_stop_gate_constants import (
50
+ ALLOW_EXIT_CODE,
51
+ BLOCK_DECISION,
52
+ BLOCK_REASON_PREFIX,
53
+ BLOCK_REASON_SUFFIX,
54
+ CLAUDE_CONFIG_DIRECTORY_ENVIRONMENT_VARIABLE,
55
+ CLAUDE_HOME_DIRECTORY_NAME,
56
+ CURSOR_COMPLETED_STATUS,
57
+ CURSOR_CONVERSATION_ID_KEY,
58
+ CURSOR_FOLLOWUP_LIMIT,
59
+ CURSOR_FOLLOWUP_MESSAGE_KEY,
60
+ CURSOR_LOOP_COUNT_KEY,
61
+ CURSOR_STATUS_KEY,
62
+ DECISION_KEY,
63
+ HOOK_EVENT_NAME,
64
+ INCOMPLETE_STATUSES,
65
+ ITEM_LINE_TEMPLATE,
66
+ LAST_ASSISTANT_MESSAGE_KEY,
67
+ REASON_KEY,
68
+ REPORT_LINE_PATTERN,
69
+ REPORTED_TEXT_GROUP_INDEX,
70
+ SESSION_ID_KEY,
71
+ TASK_FILE_GLOB,
72
+ TASK_ID_KEY,
73
+ TASK_LIST_ID_ENVIRONMENT_VARIABLE,
74
+ TASK_NUMBER_REFERENCE_TEMPLATE,
75
+ TASK_STATUS_KEY,
76
+ TASK_SUBJECT_KEY,
77
+ TASKS_DIRECTORY_NAME,
78
+ TRANSCRIPT_PATH_KEY,
79
+ UTF8_ENCODING,
80
+ WORD_BOUNDARY_PATTERN,
81
+ WORD_SEPARATOR,
82
+ )
83
+
84
+
85
+ def _claude_home() -> Path:
86
+ configured_directory = os.environ.get(CLAUDE_CONFIG_DIRECTORY_ENVIRONMENT_VARIABLE)
87
+ if configured_directory:
88
+ return Path(configured_directory)
89
+ return Path.home() / CLAUDE_HOME_DIRECTORY_NAME
90
+
91
+
92
+ def _task_file_item(task_file: Path) -> TaskItem | None:
93
+ try:
94
+ task_text = task_file.read_text(encoding=UTF8_ENCODING)
95
+ except OSError:
96
+ return None
97
+ all_task_fields = parsed_object(task_text)
98
+ if all_task_fields is None:
99
+ return None
100
+ return TaskItem(
101
+ str(all_task_fields.get(TASK_ID_KEY, task_file.stem)),
102
+ str(all_task_fields.get(TASK_SUBJECT_KEY, "")),
103
+ str(all_task_fields.get(TASK_STATUS_KEY, "")),
104
+ )
105
+
106
+
107
+ def task_store_items(session_id: object) -> list[TaskItem] | None:
108
+ """Return the task store items for the session, or None when no store exists."""
109
+ task_list_id = os.environ.get(TASK_LIST_ID_ENVIRONMENT_VARIABLE) or session_id
110
+ if not isinstance(task_list_id, str) or not task_list_id:
111
+ return None
112
+ task_directory = _claude_home() / TASKS_DIRECTORY_NAME / task_list_id
113
+ if not task_directory.is_dir():
114
+ return None
115
+ all_items = (_task_file_item(each_file) for each_file in sorted(task_directory.glob(TASK_FILE_GLOB)))
116
+ return [each_item for each_item in all_items if each_item is not None]
117
+
118
+
119
+ def _normalized(text: str) -> str:
120
+ return WORD_SEPARATOR.join(text.lower().split())
121
+
122
+
123
+ def _reported_texts(reply_text: str) -> list[str]:
124
+ all_matches = (re.match(REPORT_LINE_PATTERN, each_line, re.IGNORECASE) for each_line in reply_text.splitlines())
125
+ return [_normalized(each_match.group(REPORTED_TEXT_GROUP_INDEX)) for each_match in all_matches if each_match]
126
+
127
+
128
+ def is_reported(task_item: TaskItem, reply_text: str) -> bool:
129
+ """Return True when a Blocked: or Open: line of the reply names the item."""
130
+ subject_text = _normalized(task_item.subject)
131
+ number_pattern = re.escape(TASK_NUMBER_REFERENCE_TEMPLATE.format(task_id=task_item.item_id)) + WORD_BOUNDARY_PATTERN
132
+ return any(
133
+ (subject_text and subject_text in each_text) or re.search(number_pattern, each_text)
134
+ for each_text in _reported_texts(reply_text)
135
+ )
136
+
137
+
138
+ def unreported_open_items(all_hook_input_fields: dict[str, object]) -> list[TaskItem]:
139
+ """Return the open task items the closing reply does not report."""
140
+ replay = replayed_transcript(all_hook_input_fields.get(TRANSCRIPT_PATH_KEY))
141
+ all_items = task_store_items(all_hook_input_fields.get(SESSION_ID_KEY))
142
+ if all_items is None:
143
+ all_items = replay.items() or []
144
+ reply_value = all_hook_input_fields.get(LAST_ASSISTANT_MESSAGE_KEY)
145
+ reply_text = reply_value if isinstance(reply_value, str) else replay.last_assistant_text
146
+ return [
147
+ each_item
148
+ for each_item in all_items
149
+ if each_item.status in INCOMPLETE_STATUSES and not is_reported(each_item, reply_text)
150
+ ]
151
+
152
+
153
+ def block_reason_for(all_open_items: list[TaskItem]) -> str:
154
+ """Return the block reason that lists each open item."""
155
+ all_item_lines = (
156
+ ITEM_LINE_TEMPLATE.format(subject=each_item.subject or TASK_NUMBER_REFERENCE_TEMPLATE.format(task_id=each_item.item_id))
157
+ for each_item in all_open_items
158
+ )
159
+ return BLOCK_REASON_PREFIX + "".join(all_item_lines) + BLOCK_REASON_SUFFIX
160
+
161
+
162
+ def block_output_for(all_hook_input_fields: dict[str, object], block_reason: str) -> dict[str, str]:
163
+ """Return the block output; a Cursor payload also gets the reason as ``followup_message``."""
164
+ block_output = {DECISION_KEY: BLOCK_DECISION, REASON_KEY: block_reason}
165
+ if all_hook_input_fields.get(CURSOR_CONVERSATION_ID_KEY):
166
+ block_output[CURSOR_FOLLOWUP_MESSAGE_KEY] = block_reason
167
+ return block_output
168
+
169
+
170
+ def is_unfinished_cursor_stop(all_hook_input_fields: dict[str, object]) -> bool:
171
+ """Return True for a Cursor stop whose run was aborted or ended in an error."""
172
+ if not all_hook_input_fields.get(CURSOR_CONVERSATION_ID_KEY):
173
+ return False
174
+ stop_status = all_hook_input_fields.get(CURSOR_STATUS_KEY)
175
+ return stop_status is not None and stop_status != CURSOR_COMPLETED_STATUS
176
+
177
+
178
+ def has_spent_cursor_followups(all_hook_input_fields: dict[str, object]) -> bool:
179
+ """Return True for a Cursor stop that already sent the follow-up limit.
180
+
181
+ Cursor caps native stop follow-ups at five per script, but a copy it imports
182
+ from ``~/.claude/settings.json`` has no cap, so the gate stops at five itself.
183
+ """
184
+ if not all_hook_input_fields.get(CURSOR_CONVERSATION_ID_KEY):
185
+ return False
186
+ loop_count = all_hook_input_fields.get(CURSOR_LOOP_COUNT_KEY)
187
+ return isinstance(loop_count, int) and loop_count >= CURSOR_FOLLOWUP_LIMIT
188
+
189
+
190
+ def main() -> int:
191
+ hook_input = read_hook_input_dictionary_from_stdin()
192
+ if hook_input is None or is_unfinished_cursor_stop(hook_input) or has_spent_cursor_followups(hook_input):
193
+ return ALLOW_EXIT_CODE
194
+ all_open_items = unreported_open_items(hook_input)
195
+ if not all_open_items:
196
+ return ALLOW_EXIT_CODE
197
+ block_reason = block_reason_for(all_open_items)
198
+ reply_value = hook_input.get(LAST_ASSISTANT_MESSAGE_KEY)
199
+ log_hook_block(
200
+ Path(__file__).name,
201
+ HOOK_EVENT_NAME,
202
+ block_reason,
203
+ offending_input_preview=reply_value if isinstance(reply_value, str) else None,
204
+ )
205
+ sys.stdout.write(json.dumps(block_output_for(hook_input, block_reason)))
206
+ return ALLOW_EXIT_CODE
207
+
208
+
209
+ if __name__ == "__main__":
210
+ sys.exit(main())
@@ -0,0 +1,270 @@
1
+ #!/usr/bin/env python3
2
+ """Rebuild a session task list and its last assistant text from a transcript.
3
+
4
+ The open tasks Stop gate reads three transcript shapes, replayed in order::
5
+
6
+ TaskCreate / TaskUpdate calls (Claude Code)
7
+ TodoWrite input.todos (Claude Code and Cursor; ``merge`` updates by id)
8
+ update_plan function_call arguments (Codex rollout)
9
+
10
+ The Cursor CLI can also reach TodoWrite through ``CallDynamicTool``, with the
11
+ TodoWrite input under ``arguments``; that call counts as the TodoWrite call.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import re
18
+ import sys
19
+ from pathlib import Path
20
+ from typing import NamedTuple
21
+
22
+ hooks_root_directory = str(Path(__file__).resolve().parent.parent)
23
+ if hooks_root_directory not in sys.path:
24
+ sys.path.insert(0, hooks_root_directory)
25
+
26
+ from hooks_constants.open_tasks_stop_gate_constants import (
27
+ ASSISTANT_ROLE,
28
+ CODEX_ARGUMENTS_KEY,
29
+ CODEX_FUNCTION_CALL_TYPE,
30
+ CODEX_PAYLOAD_KEY,
31
+ CODEX_PLAN_KEY,
32
+ CODEX_PLAN_TOOL_NAME,
33
+ CODEX_RESPONSE_ITEM_TYPE,
34
+ CODEX_STEP_KEY,
35
+ CONTENT_KEY,
36
+ CREATED_TASK_ID_GROUP_INDEX,
37
+ DYNAMIC_TOOL_ARGUMENTS_KEY,
38
+ DYNAMIC_TOOL_CALL_NAME,
39
+ DYNAMIC_TOOL_NAME_KEY,
40
+ FIRST_ITEM_NUMBER,
41
+ MESSAGE_KEY,
42
+ PENDING_STATUS,
43
+ RECORD_TYPE_KEY,
44
+ REPLY_TEXT_SEPARATOR,
45
+ ROLE_KEY,
46
+ TASK_CREATED_PATTERN,
47
+ TASK_CREATE_TOOL_NAME,
48
+ TASK_ID_KEY,
49
+ TASK_STATUS_KEY,
50
+ TASK_SUBJECT_KEY,
51
+ TASK_UPDATE_ID_KEY,
52
+ TASK_UPDATE_TOOL_NAME,
53
+ TEXT_KEY,
54
+ TODOS_KEY,
55
+ TODO_CONTENT_KEY,
56
+ TODO_ID_KEY,
57
+ TODO_MERGE_KEY,
58
+ TODO_WRITE_TOOL_NAME,
59
+ TOOL_INPUT_KEY,
60
+ TOOL_NAME_KEY,
61
+ TOOL_RESULT_BLOCK_TYPE,
62
+ TOOL_USE_BLOCK_TYPE,
63
+ TOOL_USE_ID_KEY,
64
+ UTF8_ENCODING,
65
+ WORD_SEPARATOR,
66
+ )
67
+
68
+
69
+ class TaskItem(NamedTuple):
70
+ """One task list item.
71
+
72
+ Attributes:
73
+ item_id: The task number or position, as text.
74
+ subject: The item's wording.
75
+ status: The item's status, such as ``pending`` or ``completed``.
76
+ """
77
+
78
+ item_id: str
79
+ subject: str
80
+ status: str
81
+
82
+
83
+ def parsed_object(json_text: str) -> dict[str, object] | None:
84
+ """Return the JSON object in the text, or None for anything else."""
85
+ try:
86
+ parsed_value = json.loads(json_text)
87
+ except ValueError:
88
+ return None
89
+ return parsed_value if isinstance(parsed_value, dict) else None
90
+
91
+
92
+ def _transcript_records(transcript_path: object) -> list[dict[str, object]]:
93
+ if not isinstance(transcript_path, str):
94
+ return []
95
+ try:
96
+ all_lines = Path(transcript_path).read_text(encoding=UTF8_ENCODING).splitlines()
97
+ except OSError:
98
+ return []
99
+ all_records = (parsed_object(each_line) for each_line in all_lines)
100
+ return [each_record for each_record in all_records if each_record is not None]
101
+
102
+
103
+ def _content_blocks(all_record_fields: dict[str, object]) -> list[dict[str, object]]:
104
+ message_body = all_record_fields.get(MESSAGE_KEY)
105
+ content_value = message_body.get(CONTENT_KEY) if isinstance(message_body, dict) else None
106
+ if not isinstance(content_value, list):
107
+ return []
108
+ return [each_block for each_block in content_value if isinstance(each_block, dict)]
109
+
110
+
111
+ def _result_text(all_block_fields: dict[str, object]) -> str:
112
+ result_content = all_block_fields.get(CONTENT_KEY)
113
+ if isinstance(result_content, str):
114
+ return result_content
115
+ if not isinstance(result_content, list):
116
+ return ""
117
+ return WORD_SEPARATOR.join(
118
+ str(each_part.get(TEXT_KEY, "")) for each_part in result_content if isinstance(each_part, dict)
119
+ )
120
+
121
+
122
+ def _numbered_items(all_entries: object, subject_key: str) -> list[TaskItem]:
123
+ if not isinstance(all_entries, list):
124
+ return []
125
+ all_dictionaries = [each_entry for each_entry in all_entries if isinstance(each_entry, dict)]
126
+ return [
127
+ TaskItem(str(each_number), str(each_entry.get(subject_key, "")), str(each_entry.get(TASK_STATUS_KEY, "")))
128
+ for each_number, each_entry in enumerate(all_dictionaries, start=FIRST_ITEM_NUMBER)
129
+ ]
130
+
131
+
132
+ def _todo_items(all_todo_entries: object) -> list[TaskItem]:
133
+ if not isinstance(all_todo_entries, list):
134
+ return []
135
+ all_dictionaries = [each_entry for each_entry in all_todo_entries if isinstance(each_entry, dict)]
136
+ return [
137
+ TaskItem(
138
+ str(each_entry.get(TODO_ID_KEY, each_number)),
139
+ str(each_entry.get(TODO_CONTENT_KEY, "")),
140
+ str(each_entry.get(TASK_STATUS_KEY, "")),
141
+ )
142
+ for each_number, each_entry in enumerate(all_dictionaries, start=FIRST_ITEM_NUMBER)
143
+ ]
144
+
145
+
146
+ def merged_todo_items(all_earlier_items: list[TaskItem], all_update_entries: object) -> list[TaskItem]:
147
+ """Apply a ``merge`` TodoWrite call: update earlier items by id and append new ones."""
148
+ item_by_id = {each_item.item_id: each_item for each_item in all_earlier_items}
149
+ for each_update in _todo_items(all_update_entries):
150
+ earlier_item = item_by_id.get(each_update.item_id)
151
+ if earlier_item is None:
152
+ item_by_id[each_update.item_id] = each_update
153
+ continue
154
+ item_by_id[each_update.item_id] = earlier_item._replace(
155
+ subject=each_update.subject or earlier_item.subject,
156
+ status=each_update.status or earlier_item.status,
157
+ )
158
+ return list(item_by_id.values())
159
+
160
+
161
+ def codex_plan_items(all_record_fields: dict[str, object]) -> list[TaskItem] | None:
162
+ """Return the plan from a Codex rollout ``update_plan`` call record, or None for any other record."""
163
+ payload_body = all_record_fields.get(CODEX_PAYLOAD_KEY)
164
+ if all_record_fields.get(RECORD_TYPE_KEY) != CODEX_RESPONSE_ITEM_TYPE or not isinstance(payload_body, dict):
165
+ return None
166
+ if payload_body.get(RECORD_TYPE_KEY) != CODEX_FUNCTION_CALL_TYPE or payload_body.get(TOOL_NAME_KEY) != CODEX_PLAN_TOOL_NAME:
167
+ return None
168
+ all_plan_arguments = parsed_object(str(payload_body.get(CODEX_ARGUMENTS_KEY, "")))
169
+ if all_plan_arguments is None:
170
+ return None
171
+ return _numbered_items(all_plan_arguments.get(CODEX_PLAN_KEY), CODEX_STEP_KEY)
172
+
173
+
174
+ def unwrapped_tool_call(tool_name: str, all_input_fields: dict[str, object]) -> tuple[str, dict[str, object]]:
175
+ """Return the inner tool name and arguments of a Cursor ``CallDynamicTool`` call, or the call itself.
176
+
177
+ The Cursor CLI can reach TodoWrite through ``CallDynamicTool`` with
178
+ ``{"toolName": "TodoWrite", "arguments": {"todos": [...], "merge": true}}``.
179
+ """
180
+ inner_name = all_input_fields.get(DYNAMIC_TOOL_NAME_KEY)
181
+ all_inner_arguments = all_input_fields.get(DYNAMIC_TOOL_ARGUMENTS_KEY)
182
+ if tool_name != DYNAMIC_TOOL_CALL_NAME or not isinstance(inner_name, str) or not isinstance(all_inner_arguments, dict):
183
+ return tool_name, all_input_fields
184
+ return inner_name, all_inner_arguments
185
+
186
+
187
+ class _TranscriptReplay:
188
+ """Fold transcript records into the newest task list they describe."""
189
+
190
+ def __init__(self) -> None:
191
+ self.latest_items: list[TaskItem] | None = None
192
+ self.created_by_id: dict[str, TaskItem] = {}
193
+ self.subject_by_tool_use_id: dict[str, str] = {}
194
+ self.last_assistant_text = ""
195
+
196
+ def apply_record(self, all_record_fields: dict[str, object]) -> None:
197
+ plan_items = codex_plan_items(all_record_fields)
198
+ if plan_items is not None:
199
+ self.latest_items = plan_items
200
+ return
201
+ all_blocks = _content_blocks(all_record_fields)
202
+ for each_block in all_blocks:
203
+ self.apply_block(each_block)
204
+ self.note_assistant_text(all_record_fields, all_blocks)
205
+
206
+ def note_assistant_text(self, all_record_fields: dict[str, object], all_blocks: list[dict[str, object]]) -> None:
207
+ message_body = all_record_fields.get(MESSAGE_KEY)
208
+ message_role = message_body.get(ROLE_KEY) if isinstance(message_body, dict) else None
209
+ if ASSISTANT_ROLE not in (all_record_fields.get(ROLE_KEY), all_record_fields.get(RECORD_TYPE_KEY), message_role):
210
+ return
211
+ record_text = REPLY_TEXT_SEPARATOR.join(
212
+ str(each_block.get(TEXT_KEY, "")) for each_block in all_blocks if each_block.get(RECORD_TYPE_KEY) == TEXT_KEY
213
+ ).strip()
214
+ if record_text:
215
+ self.last_assistant_text = record_text
216
+
217
+ def apply_block(self, all_block_fields: dict[str, object]) -> None:
218
+ block_type = all_block_fields.get(RECORD_TYPE_KEY)
219
+ tool_input = all_block_fields.get(TOOL_INPUT_KEY)
220
+ if block_type == TOOL_USE_BLOCK_TYPE and isinstance(tool_input, dict):
221
+ tool_name, all_tool_arguments = unwrapped_tool_call(str(all_block_fields.get(TOOL_NAME_KEY)), tool_input)
222
+ self.apply_tool_use(tool_name, str(all_block_fields.get(TASK_ID_KEY)), all_tool_arguments)
223
+ elif block_type == TOOL_RESULT_BLOCK_TYPE:
224
+ self.apply_tool_result(all_block_fields)
225
+
226
+ def apply_tool_use(self, tool_name: str, tool_use_id: str, all_input_fields: dict[str, object]) -> None:
227
+ if tool_name == TODO_WRITE_TOOL_NAME:
228
+ all_todo_entries = all_input_fields.get(TODOS_KEY)
229
+ is_merge = all_input_fields.get(TODO_MERGE_KEY) is True and self.latest_items is not None
230
+ self.latest_items = (
231
+ merged_todo_items(self.latest_items or [], all_todo_entries) if is_merge else _todo_items(all_todo_entries)
232
+ )
233
+ return
234
+ if tool_name == TASK_CREATE_TOOL_NAME:
235
+ self.subject_by_tool_use_id[tool_use_id] = str(all_input_fields.get(TASK_SUBJECT_KEY, ""))
236
+ return
237
+ if tool_name == TASK_UPDATE_TOOL_NAME:
238
+ self.apply_task_update(all_input_fields)
239
+
240
+ def apply_task_update(self, all_input_fields: dict[str, object]) -> None:
241
+ task_id = str(all_input_fields.get(TASK_UPDATE_ID_KEY, ""))
242
+ new_status = all_input_fields.get(TASK_STATUS_KEY)
243
+ if task_id in self.created_by_id and new_status:
244
+ self.created_by_id[task_id] = self.created_by_id[task_id]._replace(status=str(new_status))
245
+
246
+ def apply_tool_result(self, all_block_fields: dict[str, object]) -> None:
247
+ created_subject = self.subject_by_tool_use_id.pop(str(all_block_fields.get(TOOL_USE_ID_KEY)), None)
248
+ created_match = re.search(TASK_CREATED_PATTERN, _result_text(all_block_fields))
249
+ if created_subject is None or created_match is None:
250
+ return
251
+ created_id = created_match.group(CREATED_TASK_ID_GROUP_INDEX)
252
+ self.created_by_id[created_id] = TaskItem(created_id, created_subject, PENDING_STATUS)
253
+
254
+ def items(self) -> list[TaskItem] | None:
255
+ if self.created_by_id:
256
+ return list(self.created_by_id.values())
257
+ return self.latest_items
258
+
259
+
260
+ def replayed_transcript(transcript_path: object) -> _TranscriptReplay:
261
+ """Fold every transcript record into one replay of the task list and the last assistant text."""
262
+ replay = _TranscriptReplay()
263
+ for each_record in _transcript_records(transcript_path):
264
+ replay.apply_record(each_record)
265
+ return replay
266
+
267
+
268
+ def transcript_items(transcript_path: object) -> list[TaskItem] | None:
269
+ """Rebuild the task list from TaskCreate/TaskUpdate, TodoWrite, and Codex update_plan calls."""
270
+ return replayed_transcript(transcript_path).items()