claude-dev-env 8.98.9 → 8.98.11
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 +1 -1
- package/bin/install.prompt-context.test.mjs +1 -1
- package/docs/account-broker.md +1 -1
- package/docs/agents-guidance/asks.md +13 -12
- package/docs/codex-accounts.md +11 -0
- package/docs/rule-guides/session-title.md +15 -4
- package/hooks/blocking/open_tasks_stop_gate.py +60 -259
- package/hooks/blocking/open_tasks_transcript.py +104 -90
- package/hooks/blocking/session_title_turn_gate.py +142 -56
- package/hooks/blocking/test_open_tasks_stop_gate.py +203 -500
- package/hooks/blocking/test_open_tasks_transcript.py +123 -15
- package/hooks/blocking/test_session_title_turn_gate.py +173 -90
- package/hooks/features/conduct-gates.md +9 -9
- package/hooks/hooks.json +1 -1
- package/hooks/hooks_constants/open_tasks_stop_gate_constants.py +27 -60
- package/hooks/hooks_constants/session_title_constants.py +19 -1
- package/hooks/open_task_asks.py +6 -101
- package/hooks/session/test_tracking_nudge.py +124 -0
- package/hooks/session/tracking_nudge.py +113 -0
- package/hooks/test_open_task_asks.py +6 -13
- package/package.json +1 -1
- package/rules/index.md +1 -1
- package/scripts/codex_account_choice.py +98 -6
- package/scripts/dev_env_scripts_constants/codex_account_constants.py +17 -0
- package/scripts/policy_lint/adapter_configuration.py +6 -6
- package/scripts/test_account_broker.py +12 -0
- package/scripts/test_codex_account_choice.py +121 -0
- package/hooks/session/open_task_ask_recorder.py +0 -38
- package/hooks/session/test_open_task_ask_recorder.py +0 -57
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Rebuild a session task list and its
|
|
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
|
|
20
|
+
The replay also notes what the current turn did::
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
191
|
-
"""Return
|
|
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
|
|
181
|
+
return None
|
|
196
182
|
if any(all_record_fields.get(each_flag) for each_flag in ALL_SKIPPED_PROMPT_FLAGS):
|
|
197
|
-
return
|
|
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
|
|
190
|
+
return None
|
|
205
191
|
if any(each_block.get(RECORD_TYPE_KEY) != TEXT_KEY for each_block in all_blocks):
|
|
206
|
-
return
|
|
207
|
-
prompt_text =
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
return
|
|
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
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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.
|
|
317
|
-
self.
|
|
318
|
-
self.
|
|
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
|
-
|
|
322
|
-
if
|
|
323
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
|
336
|
-
self.
|
|
337
|
-
|
|
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]
|
|
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
|
|
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]
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
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
|
|
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
|
|
252
|
-
each_block.get(
|
|
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]],
|
|
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
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
no title
|
|
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
|
|
362
|
+
if not turn_ran_a_tool(all_entries):
|
|
285
363
|
return None
|
|
286
|
-
|
|
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(
|
|
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(
|
|
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
|