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.
- package/AGENTS.md +16 -71
- package/bin/cursor-agents-guidance.mjs +94 -0
- package/bin/cursor-agents-guidance.test.mjs +94 -0
- package/bin/cursor-native-hooks.mjs +128 -0
- package/bin/cursor-native-hooks.test.mjs +78 -0
- package/bin/install-constants.mjs +29 -0
- package/bin/install-deepstack-plugin.mjs +84 -39
- package/bin/install-deepstack-plugin.test.mjs +51 -11
- package/bin/install.cursor-rules.test.mjs +65 -2
- package/bin/install.mjs +35 -0
- package/bin/install.test.mjs +10 -5
- package/docs/agents-guidance/asks.md +49 -0
- package/docs/agents-guidance/banned-word.md +13 -0
- package/docs/agents-guidance/evidence.md +11 -0
- package/docs/agents-guidance/prose.md +34 -0
- package/docs/agents-guidance/tools.md +9 -0
- package/hooks/blocking/open_tasks_stop_gate.py +210 -0
- package/hooks/blocking/open_tasks_transcript.py +270 -0
- package/hooks/blocking/test_open_tasks_stop_gate.py +297 -0
- package/hooks/blocking/test_open_tasks_transcript.py +54 -0
- package/hooks/features/conduct-gates.md +3 -0
- package/hooks/hooks.json +5 -0
- package/hooks/hooks_constants/cursor_agents_guidance_constants.py +6 -0
- package/hooks/hooks_constants/open_tasks_stop_gate_constants.py +72 -0
- package/hooks/session/cursor_agents_guidance.py +54 -0
- package/hooks/session/test_cursor_agents_guidance.py +24 -0
- package/package.json +1 -1
- package/scripts/policy_lint/adapter_configuration.py +6 -0
- package/scripts/policy_lint/config/constants.py +1 -0
- 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()
|