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.
- package/AGENTS.md +1 -1
- package/bin/install.prompt-context.test.mjs +1 -1
- package/docs/agents-guidance/asks.md +13 -12
- 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/resolve_thread_reply_gate.py +203 -41
- 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_resolve_thread_reply_gate.py +341 -25
- package/hooks/blocking/test_session_title_turn_gate.py +173 -90
- package/hooks/features/conduct-gates.md +12 -12
- package/hooks/hooks.json +2 -2
- package/hooks/hooks_constants/open_tasks_stop_gate_constants.py +27 -60
- package/hooks/hooks_constants/resolve_thread_reply_gate_constants.py +18 -3
- 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/policy_lint/adapter_configuration.py +6 -6
- package/hooks/session/open_task_ask_recorder.py +0 -38
- package/hooks/session/test_open_task_ask_recorder.py +0 -57
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
"""Deny a review thread resolve until a reply posted after the last user prompt and
|
|
1
|
+
"""Deny a review thread resolve until a reply on that thread posted after the last user prompt and its last resolve."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import json
|
|
5
6
|
import sys
|
|
6
|
-
from collections.abc import Iterable
|
|
7
|
+
from collections.abc import Iterable, Iterator
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
|
|
9
10
|
hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
@@ -15,12 +16,25 @@ from hooks_constants.pr_lifecycle_skill_gate_constants import SHELL_TOOL_NAMES
|
|
|
15
16
|
from hooks_constants.pre_tool_use_stdin import print_decision_from_stdin
|
|
16
17
|
from hooks_constants.resolve_thread_reply_gate_constants import (
|
|
17
18
|
ALL_REPLY_TOOL_NAME_SUFFIXES,
|
|
19
|
+
ALL_THREAD_ID_INPUT_KEYS,
|
|
20
|
+
COMMENT_ANCHOR_PATTERN,
|
|
21
|
+
COMMENT_ID_INPUT_KEY,
|
|
18
22
|
MISSING_REPLY_REASON,
|
|
19
23
|
RESOLVE_TOOL_NAME_PATTERN,
|
|
24
|
+
RESOLVE_THREAD_METHOD,
|
|
25
|
+
REVIEW_WRITE_METHOD_KEY,
|
|
26
|
+
REVIEW_WRITE_TOOL_NAME_PATTERN,
|
|
20
27
|
SHELL_COMMAND_KEY,
|
|
21
28
|
SHELL_REPLY_COMMAND_PATTERN,
|
|
29
|
+
SHELL_REPLY_COMMENT_ID_PATTERN,
|
|
30
|
+
SHELL_RESOLVE_COMMAND_PATTERN,
|
|
31
|
+
SHELL_RESOLVE_COMMENT_ID_PATTERN,
|
|
32
|
+
TEXT_BLOCK_TYPE,
|
|
33
|
+
TEXT_FIELD,
|
|
34
|
+
THREAD_ID_PATTERN,
|
|
22
35
|
)
|
|
23
36
|
from hooks_constants.setup_project_paths_constants import DECODE_ERRORS_POLICY, UTF8_ENCODING
|
|
37
|
+
from hooks_constants.transcript_skill_scan_constants import IS_ERROR_FIELD, TOOL_RESULT_BLOCK_TYPE
|
|
24
38
|
from transcript_skill_scan import TranscriptToolCall, tool_calls_since_last_prompt
|
|
25
39
|
|
|
26
40
|
|
|
@@ -33,6 +47,51 @@ def is_resolve_call(tool_name: object) -> bool:
|
|
|
33
47
|
return isinstance(tool_name, str) and RESOLVE_TOOL_NAME_PATTERN.match(tool_name) is not None
|
|
34
48
|
|
|
35
49
|
|
|
50
|
+
def _is_review_write_resolve(tool_name: object, all_tool_input_fields: dict[str, object]) -> bool:
|
|
51
|
+
return (
|
|
52
|
+
isinstance(tool_name, str)
|
|
53
|
+
and REVIEW_WRITE_TOOL_NAME_PATTERN.match(tool_name) is not None
|
|
54
|
+
and all_tool_input_fields.get(REVIEW_WRITE_METHOD_KEY) == RESOLVE_THREAD_METHOD
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _shell_command(tool_name: object, all_tool_input_fields: dict[str, object]) -> str | None:
|
|
59
|
+
command = all_tool_input_fields.get(SHELL_COMMAND_KEY)
|
|
60
|
+
return command if tool_name in SHELL_TOOL_NAMES and isinstance(command, str) else None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def resolved_thread_ids(tool_name: object, all_tool_input_fields: dict[str, object]) -> frozenset[str] | None:
|
|
64
|
+
"""Return the thread ids or comment ids a call resolves, or None when the call resolves no review thread.
|
|
65
|
+
|
|
66
|
+
::
|
|
67
|
+
|
|
68
|
+
mcp__github__resolve_review_thread {"threadID": "PRRT_a"} -> {"PRRT_a"}
|
|
69
|
+
mcp__github__pull_request_review_write
|
|
70
|
+
{"method": "resolve_thread", "threadId": "PRRT_a"} -> {"PRRT_a"}
|
|
71
|
+
Bash "gh api graphql ... resolveReviewThread ... PRRT_a" -> {"PRRT_a"}
|
|
72
|
+
Bash "gh api graphql ... resolveReviewThread ... $thread" -> {}
|
|
73
|
+
Bash "gh api -X POST .../pulls/7/ccr/comments/9/resolve" -> {"9"}
|
|
74
|
+
Bash "gh pr view 7" -> None
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
tool_name: The tool name from a hook payload or transcript.
|
|
78
|
+
all_tool_input_fields: The tool input from a hook payload or transcript.
|
|
79
|
+
"""
|
|
80
|
+
if is_resolve_call(tool_name) or _is_review_write_resolve(tool_name, all_tool_input_fields):
|
|
81
|
+
return frozenset(
|
|
82
|
+
each_value
|
|
83
|
+
for each_value in (all_tool_input_fields.get(each_key) for each_key in ALL_THREAD_ID_INPUT_KEYS)
|
|
84
|
+
if isinstance(each_value, str) and each_value
|
|
85
|
+
)
|
|
86
|
+
command = _shell_command(tool_name, all_tool_input_fields)
|
|
87
|
+
if command is None:
|
|
88
|
+
return None
|
|
89
|
+
all_resolved_comment_ids = frozenset(SHELL_RESOLVE_COMMENT_ID_PATTERN.findall(command))
|
|
90
|
+
if not all_resolved_comment_ids and SHELL_RESOLVE_COMMAND_PATTERN.search(command) is None:
|
|
91
|
+
return None
|
|
92
|
+
return frozenset(THREAD_ID_PATTERN.findall(command)) | all_resolved_comment_ids
|
|
93
|
+
|
|
94
|
+
|
|
36
95
|
def is_reply_call(tool_call: TranscriptToolCall) -> bool:
|
|
37
96
|
"""Return whether a transcript tool call replies on a review thread.
|
|
38
97
|
|
|
@@ -48,66 +107,165 @@ def is_reply_call(tool_call: TranscriptToolCall) -> bool:
|
|
|
48
107
|
"""
|
|
49
108
|
if tool_call.name.endswith(ALL_REPLY_TOOL_NAME_SUFFIXES):
|
|
50
109
|
return True
|
|
51
|
-
command = tool_call.tool_input
|
|
52
|
-
return (
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
)
|
|
110
|
+
command = _shell_command(tool_call.name, tool_call.tool_input)
|
|
111
|
+
return command is not None and SHELL_REPLY_COMMAND_PATTERN.search(command) is not None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _reply_comment_ids(all_tool_input_fields: dict[str, object]) -> frozenset[str]:
|
|
115
|
+
comment_id = all_tool_input_fields.get(COMMENT_ID_INPUT_KEY)
|
|
116
|
+
if isinstance(comment_id, (int, float)) and not isinstance(comment_id, bool):
|
|
117
|
+
return frozenset({str(int(comment_id))})
|
|
118
|
+
if isinstance(comment_id, str) and comment_id.isdigit():
|
|
119
|
+
return frozenset({comment_id})
|
|
120
|
+
return frozenset()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _reply_targets(tool_call: TranscriptToolCall) -> tuple[frozenset[str], frozenset[str]]:
|
|
124
|
+
command = _shell_command(tool_call.name, tool_call.tool_input)
|
|
125
|
+
if command is None:
|
|
126
|
+
return frozenset(), _reply_comment_ids(tool_call.tool_input)
|
|
127
|
+
return frozenset(THREAD_ID_PATTERN.findall(command)), frozenset(SHELL_REPLY_COMMENT_ID_PATTERN.findall(command))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _result_content_texts(result_content: object) -> list[str]:
|
|
131
|
+
if isinstance(result_content, str):
|
|
132
|
+
return [result_content]
|
|
133
|
+
all_parts = result_content if isinstance(result_content, list) else []
|
|
134
|
+
return [
|
|
135
|
+
each_part[TEXT_FIELD]
|
|
136
|
+
for each_part in all_parts
|
|
137
|
+
if isinstance(each_part, dict)
|
|
138
|
+
and each_part.get("type") == TEXT_BLOCK_TYPE
|
|
139
|
+
and isinstance(each_part.get(TEXT_FIELD), str)
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _successful_result_texts(all_entry_fields: dict[str, object]) -> Iterator[str]:
|
|
144
|
+
message = all_entry_fields.get("message")
|
|
145
|
+
content = message.get("content") if isinstance(message, dict) else None
|
|
146
|
+
for each_block in content if isinstance(content, list) else []:
|
|
147
|
+
if not isinstance(each_block, dict) or each_block.get("type") != TOOL_RESULT_BLOCK_TYPE:
|
|
148
|
+
continue
|
|
149
|
+
if each_block.get(IS_ERROR_FIELD) is not True:
|
|
150
|
+
yield from _result_content_texts(each_block.get("content"))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _thread_ids_by_comment_id_in_text(text: str) -> dict[str, str]:
|
|
154
|
+
all_thread_matches = list(THREAD_ID_PATTERN.finditer(text))
|
|
155
|
+
if not all_thread_matches:
|
|
156
|
+
return {}
|
|
157
|
+
all_span_ends = [each_match.start() for each_match in all_thread_matches[1:]] + [len(text)]
|
|
158
|
+
return {
|
|
159
|
+
each_anchor.group(1): each_thread_match.group()
|
|
160
|
+
for each_thread_match, each_span_end in zip(all_thread_matches, all_span_ends, strict=True)
|
|
161
|
+
for each_anchor in COMMENT_ANCHOR_PATTERN.finditer(text, each_thread_match.end(), each_span_end)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def thread_ids_by_comment_id(all_transcript_lines: Iterable[str]) -> dict[str, str]:
|
|
166
|
+
"""Return each review comment id mapped to its thread id, read from successful tool results.
|
|
167
|
+
|
|
168
|
+
::
|
|
169
|
+
|
|
170
|
+
get_review_comments {"id": "PRRT_a", "comments": [{"html_url": "...#discussion_r9"}]} -> {"9": "PRRT_a"}
|
|
171
|
+
gh api graphql {"id": "PRRT_a", "comments": {"nodes": [{"databaseId": 9}]}} -> {"9": "PRRT_a"}
|
|
57
172
|
|
|
173
|
+
A comment id belongs to the nearest thread id before it in the same result text.
|
|
58
174
|
|
|
59
|
-
|
|
60
|
-
|
|
175
|
+
Args:
|
|
176
|
+
all_transcript_lines: JSON transcript entries, one per line.
|
|
177
|
+
"""
|
|
178
|
+
thread_id_by_comment_id: dict[str, str] = {}
|
|
179
|
+
for each_line in all_transcript_lines:
|
|
180
|
+
try:
|
|
181
|
+
entry_fields = json.loads(each_line)
|
|
182
|
+
except ValueError:
|
|
183
|
+
continue
|
|
184
|
+
if not isinstance(entry_fields, dict):
|
|
185
|
+
continue
|
|
186
|
+
for each_text in _successful_result_texts(entry_fields):
|
|
187
|
+
thread_id_by_comment_id.update(_thread_ids_by_comment_id_in_text(each_text))
|
|
188
|
+
return thread_id_by_comment_id
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _thread_keys(all_ids: Iterable[str], all_thread_ids_by_comment_id: dict[str, str]) -> set[str]:
|
|
192
|
+
return {all_thread_ids_by_comment_id.get(each_id, each_id) for each_id in all_ids}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _keys_after_call(
|
|
196
|
+
all_answered_keys: set[str], tool_call: TranscriptToolCall, all_thread_ids_by_comment_id: dict[str, str]
|
|
197
|
+
) -> set[str]:
|
|
198
|
+
if not tool_call.succeeded:
|
|
199
|
+
return all_answered_keys
|
|
200
|
+
spent_thread_ids = resolved_thread_ids(tool_call.name, tool_call.tool_input)
|
|
201
|
+
if spent_thread_ids is not None:
|
|
202
|
+
return all_answered_keys - _thread_keys(spent_thread_ids, all_thread_ids_by_comment_id)
|
|
203
|
+
if not is_reply_call(tool_call):
|
|
204
|
+
return all_answered_keys
|
|
205
|
+
reply_thread_ids, reply_comment_ids = _reply_targets(tool_call)
|
|
206
|
+
return all_answered_keys | _thread_keys(reply_thread_ids | reply_comment_ids, all_thread_ids_by_comment_id)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _answered_thread_keys(
|
|
210
|
+
all_calls: Iterable[TranscriptToolCall], all_thread_ids_by_comment_id: dict[str, str]
|
|
211
|
+
) -> set[str]:
|
|
212
|
+
answered_thread_keys: set[str] = set()
|
|
213
|
+
for each_call in all_calls:
|
|
214
|
+
answered_thread_keys = _keys_after_call(answered_thread_keys, each_call, all_thread_ids_by_comment_id)
|
|
215
|
+
return answered_thread_keys
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def has_posted_reply_on_threads(all_transcript_lines: Iterable[str], all_thread_ids: frozenset[str]) -> bool:
|
|
219
|
+
"""Return whether each named thread has a reply that succeeded since the last user prompt and its last resolve.
|
|
61
220
|
|
|
62
221
|
::
|
|
63
222
|
|
|
64
|
-
prompt, reply ok, resolve pending
|
|
65
|
-
prompt, reply
|
|
66
|
-
prompt, resolve
|
|
67
|
-
prompt, reply ok, resolve ok, resolve pending
|
|
68
|
-
prompt, reply ok
|
|
223
|
+
prompt, read, reply ok on thread A, resolve A pending -> True
|
|
224
|
+
prompt, read, reply ok on thread A, resolve B pending -> False
|
|
225
|
+
prompt, reply refused by a hook, resolve A -> False
|
|
226
|
+
prompt, reply ok on A, resolve A ok, resolve A pending -> False
|
|
227
|
+
prompt, reply ok on A and on B, resolve A ok, resolve B pending -> True
|
|
228
|
+
no thread ids -> False
|
|
69
229
|
|
|
70
|
-
|
|
230
|
+
A successful resolve spends the replies on its threads. A failed or denied
|
|
71
231
|
resolve spends none, so a retry after a resolve error needs no second reply.
|
|
232
|
+
A reply by comment id counts for the thread that a successful tool result,
|
|
233
|
+
such as ``pull_request_read`` ``get_review_comments``, lists the comment under,
|
|
234
|
+
and for a resolve that names the same comment id.
|
|
72
235
|
|
|
73
236
|
Args:
|
|
74
237
|
all_transcript_lines: JSON transcript entries, one per line.
|
|
238
|
+
all_thread_ids: The thread ids, or comment ids, the pending resolve names.
|
|
75
239
|
"""
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
return
|
|
240
|
+
all_lines = list(all_transcript_lines)
|
|
241
|
+
thread_id_by_comment_id = thread_ids_by_comment_id(all_lines)
|
|
242
|
+
answered_thread_keys = _answered_thread_keys(tool_calls_since_last_prompt(all_lines), thread_id_by_comment_id)
|
|
243
|
+
return bool(all_thread_ids) and _thread_keys(all_thread_ids, thread_id_by_comment_id) <= answered_thread_keys
|
|
80
244
|
|
|
81
245
|
|
|
82
|
-
def
|
|
83
|
-
if not tool_call.succeeded:
|
|
84
|
-
return unspent_reply_count
|
|
85
|
-
if is_resolve_call(tool_call.name):
|
|
86
|
-
return max(0, unspent_reply_count - 1)
|
|
87
|
-
if is_reply_call(tool_call):
|
|
88
|
-
return unspent_reply_count + 1
|
|
89
|
-
return unspent_reply_count
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def _transcript_shows_reply(transcript_path: str) -> bool | None:
|
|
246
|
+
def _transcript_shows_reply(transcript_path: str, all_thread_ids: frozenset[str]) -> bool | None:
|
|
93
247
|
try:
|
|
94
248
|
with open(transcript_path, encoding=UTF8_ENCODING, errors=DECODE_ERRORS_POLICY) as transcript:
|
|
95
|
-
return
|
|
249
|
+
return has_posted_reply_on_threads(transcript, all_thread_ids)
|
|
96
250
|
except OSError:
|
|
97
251
|
return None
|
|
98
252
|
|
|
99
253
|
|
|
100
254
|
def decision_for(all_payload_fields: dict[str, object]) -> dict[str, object] | None:
|
|
101
|
-
"""Return a deny for a review thread resolve with no posted reply before it, else None.
|
|
255
|
+
"""Return a deny for a review thread resolve with no posted reply on that thread before it, else None.
|
|
102
256
|
|
|
103
257
|
::
|
|
104
258
|
|
|
105
|
-
resolve after a reply that posted
|
|
106
|
-
resolve after a reply
|
|
107
|
-
resolve
|
|
108
|
-
|
|
109
|
-
resolve
|
|
110
|
-
|
|
259
|
+
resolve after a reply on the thread that posted -> None
|
|
260
|
+
resolve after a reply on another thread -> MISSING_REPLY_REASON
|
|
261
|
+
resolve after a reply a hook refused -> MISSING_REPLY_REASON
|
|
262
|
+
resolve with no reply this turn -> MISSING_REPLY_REASON
|
|
263
|
+
second resolve after one reply -> MISSING_REPLY_REASON
|
|
264
|
+
gh api graphql resolveReviewThread, no reply -> MISSING_REPLY_REASON
|
|
265
|
+
gh api .../ccr/comments/9/resolve, no reply -> MISSING_REPLY_REASON
|
|
266
|
+
pull_request_review_write resolve_thread, no reply -> MISSING_REPLY_REASON
|
|
267
|
+
resolve with an unreadable transcript -> None
|
|
268
|
+
any other tool or shell command -> None
|
|
111
269
|
|
|
112
270
|
The gate reads the calling agent's own transcript: a subagent's file when
|
|
113
271
|
the payload names one, else the session transcript.
|
|
@@ -115,12 +273,16 @@ def decision_for(all_payload_fields: dict[str, object]) -> dict[str, object] | N
|
|
|
115
273
|
Args:
|
|
116
274
|
all_payload_fields: The parsed PreToolUse input.
|
|
117
275
|
"""
|
|
118
|
-
|
|
276
|
+
tool_input = all_payload_fields.get("tool_input")
|
|
277
|
+
all_thread_ids = resolved_thread_ids(
|
|
278
|
+
all_payload_fields.get("tool_name"), tool_input if isinstance(tool_input, dict) else {}
|
|
279
|
+
)
|
|
280
|
+
if all_thread_ids is None:
|
|
119
281
|
return None
|
|
120
282
|
all_paths = transcript_paths(all_payload_fields)
|
|
121
283
|
if not all_paths:
|
|
122
284
|
return None
|
|
123
|
-
shows_reply = _transcript_shows_reply(all_paths[-1])
|
|
285
|
+
shows_reply = _transcript_shows_reply(all_paths[-1], all_thread_ids)
|
|
124
286
|
if shows_reply is None or shows_reply:
|
|
125
287
|
return None
|
|
126
288
|
return deny_decision(MISSING_REPLY_REASON)
|
|
@@ -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
|