claude-dev-env 8.98.4 → 8.98.6
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/hooks/blocking/open_tasks_stop_gate.py +62 -9
- package/hooks/blocking/open_tasks_transcript.py +71 -3
- package/hooks/blocking/pr_lifecycle_skill_gate.py +36 -10
- package/hooks/blocking/test_open_tasks_stop_gate.py +135 -2
- package/hooks/blocking/test_pr_lifecycle_skill_gate.py +5 -0
- package/hooks/hooks_constants/open_tasks_stop_gate_constants.py +24 -0
- package/hooks/hooks_constants/pr_lifecycle_skill_gate_constants.py +10 -1
- package/package.json +1 -1
|
@@ -15,7 +15,7 @@ An item with status ``pending`` or ``in_progress`` is open, and so is an ask
|
|
|
15
15
|
until a reply closes it. The turn may end only when the closing reply names
|
|
16
16
|
every open item on a report line::
|
|
17
17
|
|
|
18
|
-
allow: Blocked: Rotate the deploy key - the owner
|
|
18
|
+
allow: Blocked: Rotate the deploy key - the owner pastes the org secret
|
|
19
19
|
allow: - Open: #3 - waits on the release checks
|
|
20
20
|
allow: Open: #ask-2 - the export job runs next
|
|
21
21
|
block: (reply never mentions "Rotate the deploy key")
|
|
@@ -30,6 +30,22 @@ of the session close an item for good::
|
|
|
30
30
|
A ``Dropped:`` quote must appear in a prompt the user sent after that ask, or
|
|
31
31
|
after the last plan list that held the item.
|
|
32
32
|
|
|
33
|
+
A ``Blocked:`` line that asks the user for a decision needs a question card
|
|
34
|
+
in the same turn. The gate calls a line a decision when it holds a decision
|
|
35
|
+
word: ok, okay, approve, approval, go-ahead, green light, sign-off, decide,
|
|
36
|
+
decision, choose, choice, permission, consent, say-so, your call, or prefer.
|
|
37
|
+
Any other ``Blocked:`` line names an input the user
|
|
38
|
+
supplies, such as a secret, a file, or a step on a device, and needs no card::
|
|
39
|
+
|
|
40
|
+
block: Blocked: #ask-1 - your OK to merge the app branch first (no card this turn)
|
|
41
|
+
allow: the same line after an AskUserQuestion call this turn
|
|
42
|
+
allow: Blocked: #ask-1 - the new deploy key secret, pasted into the session
|
|
43
|
+
|
|
44
|
+
The cards are ``AskUserQuestion``, ``mcp__hearthbot__ask_decision``,
|
|
45
|
+
``mcp__hearthbot__request_decision``, and Codex ``request_user_input``. The
|
|
46
|
+
check runs whether or not any item is open. A Cursor payload skips it, because
|
|
47
|
+
the gate reads no card call from a Cursor transcript.
|
|
48
|
+
|
|
33
49
|
A block sends the open items back as the reason, so the model keeps working or
|
|
34
50
|
reports them. Claude Code ends the turn after eight blocks in a row. Codex
|
|
35
51
|
turns the reason into a continuation prompt. A Cursor payload (it carries
|
|
@@ -61,7 +77,7 @@ if blocking_directory not in sys.path:
|
|
|
61
77
|
from hooks_constants.hook_block_logger import log_hook_block
|
|
62
78
|
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
63
79
|
from open_task_asks import RecordedPrompt, claude_home, recorded_prompts
|
|
64
|
-
from open_tasks_transcript import TaskItem, normalized_text, parsed_object, replayed_transcript
|
|
80
|
+
from open_tasks_transcript import TaskItem, TranscriptReplay, normalized_text, parsed_object, replayed_transcript
|
|
65
81
|
from hooks_constants.open_tasks_stop_gate_constants import (
|
|
66
82
|
ALLOW_EXIT_CODE,
|
|
67
83
|
ASK_CLOSE_SUFFIX,
|
|
@@ -73,13 +89,18 @@ from hooks_constants.open_tasks_stop_gate_constants import (
|
|
|
73
89
|
BLOCK_DECISION,
|
|
74
90
|
BLOCK_REASON_PREFIX,
|
|
75
91
|
BLOCK_REASON_SUFFIX,
|
|
92
|
+
BLOCKED_REPORT_KIND,
|
|
76
93
|
CURSOR_COMPLETED_STATUS,
|
|
77
94
|
CURSOR_CONVERSATION_ID_KEY,
|
|
78
95
|
CURSOR_FOLLOWUP_LIMIT,
|
|
79
96
|
CURSOR_FOLLOWUP_MESSAGE_KEY,
|
|
80
97
|
CURSOR_LOOP_COUNT_KEY,
|
|
81
98
|
CURSOR_STATUS_KEY,
|
|
99
|
+
DECISION_BLOCK_REASON_PREFIX,
|
|
100
|
+
DECISION_BLOCK_REASON_SUFFIX,
|
|
82
101
|
DECISION_KEY,
|
|
102
|
+
DECISION_LINE_TEMPLATE,
|
|
103
|
+
DECISION_WORD_PATTERN,
|
|
83
104
|
DONE_LINE_PATTERN,
|
|
84
105
|
DONE_TEXT_GROUP_INDEX,
|
|
85
106
|
DROPPED_LINE_PATTERN,
|
|
@@ -91,7 +112,9 @@ from hooks_constants.open_tasks_stop_gate_constants import (
|
|
|
91
112
|
LAST_ASSISTANT_MESSAGE_KEY,
|
|
92
113
|
PENDING_STATUS,
|
|
93
114
|
REASON_KEY,
|
|
115
|
+
REPORT_KIND_GROUP_INDEX,
|
|
94
116
|
REPORT_LINE_PATTERN,
|
|
117
|
+
REPLY_TEXT_SEPARATOR,
|
|
95
118
|
REPORTED_TEXT_GROUP_INDEX,
|
|
96
119
|
SESSION_ID_KEY,
|
|
97
120
|
TASK_FILE_GLOB,
|
|
@@ -226,12 +249,28 @@ def deduplicated_by_subject(all_sources: list[list[TaskItem]]) -> list[TaskItem]
|
|
|
226
249
|
return all_kept_items
|
|
227
250
|
|
|
228
251
|
|
|
229
|
-
def
|
|
252
|
+
def closing_reply_text(all_hook_input_fields: dict[str, object], replay: TranscriptReplay) -> str:
|
|
253
|
+
"""Return the closing reply from the hook input, or the last assistant text of the transcript."""
|
|
254
|
+
reply_value = all_hook_input_fields.get(LAST_ASSISTANT_MESSAGE_KEY)
|
|
255
|
+
return reply_value if isinstance(reply_value, str) else replay.last_assistant_text
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def uncarded_decision_lines(reply_text: str, replay: TranscriptReplay) -> list[str]:
|
|
259
|
+
"""Return each ``Blocked:`` line that asks for a decision when no question card ran this turn."""
|
|
260
|
+
if replay.has_turn_decision_card:
|
|
261
|
+
return []
|
|
262
|
+
return [
|
|
263
|
+
each_match.group(0).strip()
|
|
264
|
+
for each_match in _line_matches(REPORT_LINE_PATTERN, [reply_text])
|
|
265
|
+
if each_match.group(REPORT_KIND_GROUP_INDEX).lower() == BLOCKED_REPORT_KIND
|
|
266
|
+
and re.search(DECISION_WORD_PATTERN, each_match.group(REPORTED_TEXT_GROUP_INDEX), re.IGNORECASE)
|
|
267
|
+
]
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def unreported_open_items(all_hook_input_fields: dict[str, object], replay: TranscriptReplay) -> list[TaskItem]:
|
|
230
271
|
"""Return the open task items and asks the closing reply does not report."""
|
|
231
|
-
replay = replayed_transcript(all_hook_input_fields.get(TRANSCRIPT_PATH_KEY))
|
|
232
272
|
session_id = all_hook_input_fields.get(SESSION_ID_KEY)
|
|
233
|
-
|
|
234
|
-
reply_text = reply_value if isinstance(reply_value, str) else replay.last_assistant_text
|
|
273
|
+
reply_text = closing_reply_text(all_hook_input_fields, replay)
|
|
235
274
|
all_closing_texts = [*replay.all_assistant_texts, reply_text]
|
|
236
275
|
all_prompts = recorded_prompts(session_id)
|
|
237
276
|
all_replayed_items = [
|
|
@@ -256,6 +295,12 @@ def _item_line(task_item: TaskItem) -> str:
|
|
|
256
295
|
return ITEM_LINE_TEMPLATE.format(subject=task_item.subject or TASK_NUMBER_REFERENCE_TEMPLATE.format(task_id=task_item.item_id))
|
|
257
296
|
|
|
258
297
|
|
|
298
|
+
def decision_block_reason_for(all_decision_lines: list[str]) -> str:
|
|
299
|
+
"""Return the block reason that quotes each decision line and names the question card."""
|
|
300
|
+
all_quoted_lines = "".join(DECISION_LINE_TEMPLATE.format(line=each_line) for each_line in all_decision_lines)
|
|
301
|
+
return DECISION_BLOCK_REASON_PREFIX + all_quoted_lines + DECISION_BLOCK_REASON_SUFFIX
|
|
302
|
+
|
|
303
|
+
|
|
259
304
|
def block_reason_for(all_open_items: list[TaskItem]) -> str:
|
|
260
305
|
"""Return the block reason that lists each open item, and how to close an ask when one is open."""
|
|
261
306
|
has_open_ask = any(each_item.item_id.startswith(ASK_ID_PREFIX) for each_item in all_open_items)
|
|
@@ -295,10 +340,18 @@ def main() -> int:
|
|
|
295
340
|
hook_input = read_hook_input_dictionary_from_stdin()
|
|
296
341
|
if hook_input is None or is_unfinished_cursor_stop(hook_input) or has_spent_cursor_followups(hook_input):
|
|
297
342
|
return ALLOW_EXIT_CODE
|
|
298
|
-
|
|
299
|
-
|
|
343
|
+
replay = replayed_transcript(hook_input.get(TRANSCRIPT_PATH_KEY))
|
|
344
|
+
all_open_items = unreported_open_items(hook_input, replay)
|
|
345
|
+
all_decision_lines = (
|
|
346
|
+
[] if hook_input.get(CURSOR_CONVERSATION_ID_KEY) else uncarded_decision_lines(closing_reply_text(hook_input, replay), replay)
|
|
347
|
+
)
|
|
348
|
+
if not all_open_items and not all_decision_lines:
|
|
300
349
|
return ALLOW_EXIT_CODE
|
|
301
|
-
|
|
350
|
+
all_reason_parts = [
|
|
351
|
+
*([block_reason_for(all_open_items)] if all_open_items else []),
|
|
352
|
+
*([decision_block_reason_for(all_decision_lines)] if all_decision_lines else []),
|
|
353
|
+
]
|
|
354
|
+
block_reason = REPLY_TEXT_SEPARATOR.join(all_reason_parts)
|
|
302
355
|
reply_value = hook_input.get(LAST_ASSISTANT_MESSAGE_KEY)
|
|
303
356
|
log_hook_block(
|
|
304
357
|
Path(__file__).name,
|
|
@@ -17,6 +17,16 @@ A desktop plan call either lists items or moves the current list::
|
|
|
17
17
|
Only ``assistant`` records count for a desktop plan call, because Claude Code
|
|
18
18
|
also copies each call into ``api-request-blob`` records.
|
|
19
19
|
|
|
20
|
+
The replay also notes whether the current turn called a question card::
|
|
21
|
+
|
|
22
|
+
AskUserQuestion, mcp__hearthbot__ask_decision, mcp__hearthbot__request_decision (Claude Code)
|
|
23
|
+
request_user_input, request_user_input_async (Codex)
|
|
24
|
+
|
|
25
|
+
A turn starts at the last user prompt record. A tool result, a record flagged
|
|
26
|
+
``isMeta``, ``isSidechain`` or ``isCompactSummary``, and a ``Stop hook feedback``
|
|
27
|
+
message do not start a turn; these are the flags session_title_turn_gate.py
|
|
28
|
+
skips, and its tests model Stop hook feedback as an ``isMeta`` user record.
|
|
29
|
+
|
|
20
30
|
The Cursor CLI can also reach TodoWrite through ``CallDynamicTool``, with the
|
|
21
31
|
TodoWrite input under ``arguments``; that call counts as the TodoWrite call.
|
|
22
32
|
"""
|
|
@@ -35,6 +45,7 @@ if hooks_root_directory not in sys.path:
|
|
|
35
45
|
sys.path.insert(0, hooks_root_directory)
|
|
36
46
|
|
|
37
47
|
from hooks_constants.open_tasks_stop_gate_constants import (
|
|
48
|
+
ALL_SKIPPED_PROMPT_FLAGS,
|
|
38
49
|
ASSISTANT_RECORD_TYPE,
|
|
39
50
|
ASSISTANT_ROLE,
|
|
40
51
|
CCD_PLAN_ADVANCE_KEY,
|
|
@@ -45,15 +56,19 @@ from hooks_constants.open_tasks_stop_gate_constants import (
|
|
|
45
56
|
CCD_PLAN_START_KEY,
|
|
46
57
|
CCD_PLAN_TOOL_NAME,
|
|
47
58
|
CODEX_ARGUMENTS_KEY,
|
|
59
|
+
CODEX_CARD_EVENT,
|
|
48
60
|
CODEX_FUNCTION_CALL_TYPE,
|
|
61
|
+
CODEX_MESSAGE_TYPE,
|
|
49
62
|
CODEX_PAYLOAD_KEY,
|
|
50
63
|
CODEX_PLAN_KEY,
|
|
51
64
|
CODEX_PLAN_TOOL_NAME,
|
|
65
|
+
CODEX_PROMPT_EVENT,
|
|
52
66
|
CODEX_RESPONSE_ITEM_TYPE,
|
|
53
67
|
CODEX_STEP_KEY,
|
|
54
68
|
COMPLETED_STATUS,
|
|
55
69
|
CONTENT_KEY,
|
|
56
70
|
CREATED_TASK_ID_GROUP_INDEX,
|
|
71
|
+
DECISION_CARD_TOOL_PATTERN,
|
|
57
72
|
DYNAMIC_TOOL_ARGUMENTS_KEY,
|
|
58
73
|
DYNAMIC_TOOL_CALL_NAME,
|
|
59
74
|
DYNAMIC_TOOL_NAME_KEY,
|
|
@@ -66,6 +81,7 @@ from hooks_constants.open_tasks_stop_gate_constants import (
|
|
|
66
81
|
RECORD_TYPE_KEY,
|
|
67
82
|
REPLY_TEXT_SEPARATOR,
|
|
68
83
|
ROLE_KEY,
|
|
84
|
+
STOP_HOOK_FEEDBACK_PREFIX,
|
|
69
85
|
TASK_CREATED_PATTERN,
|
|
70
86
|
TASK_CREATE_TOOL_NAME,
|
|
71
87
|
TASK_ID_KEY,
|
|
@@ -86,6 +102,8 @@ from hooks_constants.open_tasks_stop_gate_constants import (
|
|
|
86
102
|
TOOL_USE_ID_KEY,
|
|
87
103
|
UTC_OFFSET_TEXT,
|
|
88
104
|
UTC_SUFFIX,
|
|
105
|
+
USER_RECORD_TYPE,
|
|
106
|
+
USER_ROLE,
|
|
89
107
|
UTF8_ENCODING,
|
|
90
108
|
WORD_SEPARATOR,
|
|
91
109
|
)
|
|
@@ -164,6 +182,47 @@ def _result_text(all_block_fields: dict[str, object]) -> str:
|
|
|
164
182
|
)
|
|
165
183
|
|
|
166
184
|
|
|
185
|
+
def is_decision_card_tool(tool_name: object) -> bool:
|
|
186
|
+
"""Return True for a tool that puts a question card in front of the user."""
|
|
187
|
+
return isinstance(tool_name, str) and re.search(DECISION_CARD_TOOL_PATTERN, tool_name) is not None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def is_user_prompt_record(all_record_fields: dict[str, object]) -> bool:
|
|
191
|
+
"""Return True for a record that holds a prompt the user sent, which starts a new turn."""
|
|
192
|
+
message_body = all_record_fields.get(MESSAGE_KEY)
|
|
193
|
+
message_role = message_body.get(ROLE_KEY) if isinstance(message_body, dict) else None
|
|
194
|
+
if USER_RECORD_TYPE != all_record_fields.get(RECORD_TYPE_KEY) and USER_ROLE not in (all_record_fields.get(ROLE_KEY), message_role):
|
|
195
|
+
return False
|
|
196
|
+
if any(all_record_fields.get(each_flag) for each_flag in ALL_SKIPPED_PROMPT_FLAGS):
|
|
197
|
+
return False
|
|
198
|
+
content_value = message_body.get(CONTENT_KEY) if isinstance(message_body, dict) else None
|
|
199
|
+
if isinstance(content_value, str):
|
|
200
|
+
prompt_text = content_value
|
|
201
|
+
else:
|
|
202
|
+
all_blocks = _content_blocks(all_record_fields)
|
|
203
|
+
if any(each_block.get(RECORD_TYPE_KEY) == TOOL_RESULT_BLOCK_TYPE for each_block in all_blocks):
|
|
204
|
+
return False
|
|
205
|
+
if any(each_block.get(RECORD_TYPE_KEY) != TEXT_KEY for each_block in all_blocks):
|
|
206
|
+
return True
|
|
207
|
+
prompt_text = WORD_SEPARATOR.join(
|
|
208
|
+
str(each_block.get(TEXT_KEY, "")) for each_block in all_blocks if each_block.get(RECORD_TYPE_KEY) == TEXT_KEY
|
|
209
|
+
)
|
|
210
|
+
return bool(prompt_text.strip()) and not prompt_text.lstrip().startswith(STOP_HOOK_FEEDBACK_PREFIX)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def codex_turn_event(all_record_fields: dict[str, object]) -> str | None:
|
|
214
|
+
"""Return ``card`` for a Codex question card call, ``prompt`` for a Codex user message, else None."""
|
|
215
|
+
payload_body = all_record_fields.get(CODEX_PAYLOAD_KEY)
|
|
216
|
+
if all_record_fields.get(RECORD_TYPE_KEY) != CODEX_RESPONSE_ITEM_TYPE or not isinstance(payload_body, dict):
|
|
217
|
+
return None
|
|
218
|
+
payload_type = payload_body.get(RECORD_TYPE_KEY)
|
|
219
|
+
if payload_type == CODEX_FUNCTION_CALL_TYPE and is_decision_card_tool(payload_body.get(TOOL_NAME_KEY)):
|
|
220
|
+
return CODEX_CARD_EVENT
|
|
221
|
+
if payload_type == CODEX_MESSAGE_TYPE and payload_body.get(ROLE_KEY) == USER_ROLE:
|
|
222
|
+
return CODEX_PROMPT_EVENT
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
|
|
167
226
|
def _numbered_items(all_entries: object, subject_key: str) -> list[TaskItem]:
|
|
168
227
|
if not isinstance(all_entries, list):
|
|
169
228
|
return []
|
|
@@ -245,7 +304,7 @@ def unwrapped_tool_call(tool_name: str, all_input_fields: dict[str, object]) ->
|
|
|
245
304
|
return inner_name, all_inner_arguments
|
|
246
305
|
|
|
247
306
|
|
|
248
|
-
class
|
|
307
|
+
class TranscriptReplay:
|
|
249
308
|
"""Fold transcript records into the newest task list they describe."""
|
|
250
309
|
|
|
251
310
|
def __init__(self) -> None:
|
|
@@ -256,8 +315,15 @@ class _TranscriptReplay:
|
|
|
256
315
|
self.own_plan_count = 0
|
|
257
316
|
self.last_assistant_text = ""
|
|
258
317
|
self.all_assistant_texts: list[str] = []
|
|
318
|
+
self.has_turn_decision_card = False
|
|
259
319
|
|
|
260
320
|
def apply_record(self, all_record_fields: dict[str, object]) -> None:
|
|
321
|
+
codex_event = codex_turn_event(all_record_fields)
|
|
322
|
+
if codex_event is not None:
|
|
323
|
+
self.has_turn_decision_card = codex_event == CODEX_CARD_EVENT
|
|
324
|
+
return
|
|
325
|
+
if is_user_prompt_record(all_record_fields):
|
|
326
|
+
self.has_turn_decision_card = False
|
|
261
327
|
plan_items = codex_plan_items(all_record_fields)
|
|
262
328
|
if plan_items is not None:
|
|
263
329
|
self.latest_items = plan_items
|
|
@@ -266,6 +332,8 @@ class _TranscriptReplay:
|
|
|
266
332
|
is_assistant_record = all_record_fields.get(RECORD_TYPE_KEY) == ASSISTANT_RECORD_TYPE
|
|
267
333
|
for each_block in all_blocks:
|
|
268
334
|
plan_input = each_block.get(TOOL_INPUT_KEY)
|
|
335
|
+
if is_assistant_record and each_block.get(RECORD_TYPE_KEY) == TOOL_USE_BLOCK_TYPE and is_decision_card_tool(each_block.get(TOOL_NAME_KEY)):
|
|
336
|
+
self.has_turn_decision_card = True
|
|
269
337
|
if is_assistant_record and each_block.get(TOOL_NAME_KEY) == CCD_PLAN_TOOL_NAME and isinstance(plan_input, dict):
|
|
270
338
|
self.apply_desktop_plan(plan_input, record_epoch(all_record_fields))
|
|
271
339
|
else:
|
|
@@ -364,9 +432,9 @@ class _TranscriptReplay:
|
|
|
364
432
|
return [each_item for each_source in all_present_sources for each_item in each_source]
|
|
365
433
|
|
|
366
434
|
|
|
367
|
-
def replayed_transcript(transcript_path: object) ->
|
|
435
|
+
def replayed_transcript(transcript_path: object) -> TranscriptReplay:
|
|
368
436
|
"""Fold every transcript record into one replay of the task list and the last assistant text."""
|
|
369
|
-
replay =
|
|
437
|
+
replay = TranscriptReplay()
|
|
370
438
|
for each_record in _transcript_records(transcript_path):
|
|
371
439
|
replay.apply_record(each_record)
|
|
372
440
|
return replay
|
|
@@ -19,6 +19,7 @@ from hooks_constants.pr_lifecycle_skill_gate_constants import (
|
|
|
19
19
|
ALL_API_ACTION_NAMES,
|
|
20
20
|
ALL_COMMAND_PREFIX_WORDS,
|
|
21
21
|
ALL_GITHUB_MCP_TOOL_SUFFIXES,
|
|
22
|
+
ALL_PIECE_REPLACEMENT_TEXTS,
|
|
22
23
|
ALL_PREFIX_OPTIONS_WITH_VALUE,
|
|
23
24
|
ALL_PYTHON_EXECUTABLES,
|
|
24
25
|
ALL_SHELL_WRAPPER_EXECUTABLES,
|
|
@@ -28,16 +29,19 @@ from hooks_constants.pr_lifecycle_skill_gate_constants import (
|
|
|
28
29
|
BUILD_EVAL_COMMAND_MARKER,
|
|
29
30
|
COMMAND_PIECE_PATTERN,
|
|
30
31
|
COMMAND_WHITESPACE,
|
|
31
|
-
COMMENT_PIECE,
|
|
32
32
|
DENY_DECISION,
|
|
33
33
|
DENY_REASON,
|
|
34
|
+
DOUBLE_QUOTE,
|
|
35
|
+
DOUBLE_QUOTED_ESCAPE_PATTERN,
|
|
34
36
|
ENVIRONMENT_ASSIGNMENT_PATTERN,
|
|
37
|
+
ESCAPED_CHARACTER_GROUP,
|
|
35
38
|
GH_API_COMMAND,
|
|
36
39
|
GH_PULL_REQUEST_COMMAND,
|
|
37
40
|
GIT_ACTIONS,
|
|
38
41
|
GIT_GLOBAL_OPTIONS_WITH_VALUE,
|
|
39
42
|
HOOK_EVENT_NAME,
|
|
40
43
|
LINE_CONTINUATION_PIECE,
|
|
44
|
+
LINE_END,
|
|
41
45
|
MERGE_PATH_SUFFIX,
|
|
42
46
|
NO_COMMENT_CHARACTERS,
|
|
43
47
|
OPTION_AND_VALUE_WORD_COUNT,
|
|
@@ -47,7 +51,6 @@ from hooks_constants.pr_lifecycle_skill_gate_constants import (
|
|
|
47
51
|
PYTHON_OPTIONS_WITH_VALUE,
|
|
48
52
|
REMOVED_TEXT,
|
|
49
53
|
SEGMENT_BREAK,
|
|
50
|
-
SEPARATOR_PIECE,
|
|
51
54
|
SESSION_TRANSCRIPT_PATH_FIELD,
|
|
52
55
|
SHELL_TOOL_NAMES,
|
|
53
56
|
SKILL_NAME,
|
|
@@ -77,6 +80,32 @@ from transcript_skill_scan import (
|
|
|
77
80
|
)
|
|
78
81
|
|
|
79
82
|
|
|
83
|
+
def _joined_piece_text(piece: re.Match[str]) -> str:
|
|
84
|
+
"""Return one command piece with its backslash-newline line continuations removed.
|
|
85
|
+
|
|
86
|
+
::
|
|
87
|
+
|
|
88
|
+
\\<newline> -> ""
|
|
89
|
+
"gi\\<newline>t" -> "git"
|
|
90
|
+
'gi\\<newline>t' -> unchanged
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
piece: One match of ``COMMAND_PIECE_PATTERN`` on the raw command.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
The piece text as bash reads it after joining continuations.
|
|
97
|
+
"""
|
|
98
|
+
if piece.lastgroup == LINE_CONTINUATION_PIECE:
|
|
99
|
+
return REMOVED_TEXT
|
|
100
|
+
if piece.group().startswith(DOUBLE_QUOTE):
|
|
101
|
+
return DOUBLE_QUOTED_ESCAPE_PATTERN.sub(_without_line_continuation, piece.group())
|
|
102
|
+
return piece.group()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _without_line_continuation(escape: re.Match[str]) -> str:
|
|
106
|
+
return REMOVED_TEXT if escape.group(ESCAPED_CHARACTER_GROUP) == LINE_END else escape.group()
|
|
107
|
+
|
|
108
|
+
|
|
80
109
|
def _unquoted_segment_texts(command: str) -> list[str] | None:
|
|
81
110
|
"""Split a command at each separator outside quotes, escapes, and comments.
|
|
82
111
|
|
|
@@ -91,16 +120,13 @@ def _unquoted_segment_texts(command: str) -> list[str] | None:
|
|
|
91
120
|
Returns:
|
|
92
121
|
The raw text of each segment in order, or None when a quote never closes.
|
|
93
122
|
"""
|
|
94
|
-
|
|
95
|
-
if any(each_piece.lastgroup == UNCLOSED_QUOTE_PIECE for each_piece in
|
|
123
|
+
all_raw_pieces = list(COMMAND_PIECE_PATTERN.finditer(command))
|
|
124
|
+
if any(each_piece.lastgroup == UNCLOSED_QUOTE_PIECE for each_piece in all_raw_pieces):
|
|
96
125
|
return None
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
COMMENT_PIECE: REMOVED_TEXT,
|
|
100
|
-
LINE_CONTINUATION_PIECE: REMOVED_TEXT,
|
|
101
|
-
}
|
|
126
|
+
joined_command = REMOVED_TEXT.join(_joined_piece_text(each_piece) for each_piece in all_raw_pieces)
|
|
127
|
+
all_pieces = list(COMMAND_PIECE_PATTERN.finditer(joined_command))
|
|
102
128
|
marked_command = REMOVED_TEXT.join(
|
|
103
|
-
|
|
129
|
+
ALL_PIECE_REPLACEMENT_TEXTS.get(each_piece.lastgroup, each_piece.group()) for each_piece in all_pieces
|
|
104
130
|
)
|
|
105
131
|
return marked_command.split(SEGMENT_BREAK)
|
|
106
132
|
|
|
@@ -79,7 +79,7 @@ def test_should_allow_when_every_open_task_is_reported_as_blocked_or_open(
|
|
|
79
79
|
{"id": "3", "subject": "Write tests", "status": "completed"},
|
|
80
80
|
],
|
|
81
81
|
)
|
|
82
|
-
reply_text = "Tests written.\nBlocked: Rotate the deploy key - the owner
|
|
82
|
+
reply_text = "Tests written.\nBlocked: Rotate the deploy key - the owner pastes the secret\n- Open: #2 - checks running"
|
|
83
83
|
assert run_gate(monkeypatch, capsys, reply_text) == (0, "")
|
|
84
84
|
|
|
85
85
|
|
|
@@ -238,7 +238,7 @@ def test_should_allow_cursor_when_the_last_assistant_text_reports_the_item(
|
|
|
238
238
|
[
|
|
239
239
|
cursor_todo_record(all_todos, False, "Working."),
|
|
240
240
|
{"role": "assistant", "message": {"content": [
|
|
241
|
-
{"type": "text", "text": "Blocked: rotate the deploy key - the owner
|
|
241
|
+
{"type": "text", "text": "Blocked: rotate the deploy key - the owner pastes the org secret"}
|
|
242
242
|
]}},
|
|
243
243
|
],
|
|
244
244
|
)
|
|
@@ -510,3 +510,136 @@ def test_should_mark_the_right_item_for_a_done_index_after_a_continuation(
|
|
|
510
510
|
assert "Submit both APKs" in reason_text
|
|
511
511
|
assert "Read the schema" not in reason_text
|
|
512
512
|
assert "Set rows in Neon" not in reason_text
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
DECISION_BLOCKED_REPLY = "Branch pushed.\nBlocked: #ask-1 - your OK to merge the app branch first."
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def user_prompt_record(prompt_text: str) -> dict[str, object]:
|
|
519
|
+
return {"type": "user", "message": {"role": "user", "content": prompt_text}}
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def tool_call_record(tool_name: str) -> dict[str, object]:
|
|
523
|
+
tool_use = {"type": "tool_use", "id": "q1", "name": tool_name, "input": {}}
|
|
524
|
+
return {"type": "assistant", "message": {"role": "assistant", "content": [tool_use]}}
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def test_should_block_a_decision_blocked_line_with_no_card_this_turn(
|
|
528
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
529
|
+
) -> None:
|
|
530
|
+
record_ask("Merge the release branch")
|
|
531
|
+
transcript_path = write_records(tmp_path / "s.jsonl", [user_prompt_record("Merge the release branch")])
|
|
532
|
+
reason_text = block_reason(monkeypatch, capsys, DECISION_BLOCKED_REPLY, transcript_path)
|
|
533
|
+
assert "AskUserQuestion" in reason_text
|
|
534
|
+
assert "your OK to merge" in reason_text
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
@pytest.mark.parametrize(
|
|
538
|
+
"card_tool_name",
|
|
539
|
+
["AskUserQuestion", "mcp__hearthbot__ask_decision", "mcp__hearthbot__request_decision"],
|
|
540
|
+
)
|
|
541
|
+
def test_should_allow_a_decision_blocked_line_after_a_card_this_turn(
|
|
542
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path, card_tool_name: str
|
|
543
|
+
) -> None:
|
|
544
|
+
record_ask("Merge the release branch")
|
|
545
|
+
transcript_path = write_records(
|
|
546
|
+
tmp_path / "s.jsonl", [user_prompt_record("Merge the release branch"), tool_call_record(card_tool_name)]
|
|
547
|
+
)
|
|
548
|
+
assert run_gate(monkeypatch, capsys, DECISION_BLOCKED_REPLY, transcript_path) == (0, "")
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def test_should_block_a_decision_blocked_line_when_the_card_came_in_an_earlier_turn(
|
|
552
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
553
|
+
) -> None:
|
|
554
|
+
record_ask("Merge the release branch")
|
|
555
|
+
transcript_path = write_records(
|
|
556
|
+
tmp_path / "s.jsonl",
|
|
557
|
+
[
|
|
558
|
+
tool_call_record("AskUserQuestion"),
|
|
559
|
+
user_prompt_record("Merge the release branch"),
|
|
560
|
+
],
|
|
561
|
+
)
|
|
562
|
+
assert "AskUserQuestion" in block_reason(monkeypatch, capsys, DECISION_BLOCKED_REPLY, transcript_path)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def test_should_allow_a_blocked_line_for_an_input_the_user_supplies(
|
|
566
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
567
|
+
) -> None:
|
|
568
|
+
record_ask("Rotate the deploy key")
|
|
569
|
+
transcript_path = write_records(tmp_path / "s.jsonl", [user_prompt_record("Rotate the deploy key")])
|
|
570
|
+
reply_text = "Key script ready.\nBlocked: #ask-1 - the new deploy key secret, pasted into the session"
|
|
571
|
+
assert run_gate(monkeypatch, capsys, reply_text, transcript_path) == (0, "")
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def test_should_keep_the_card_through_stop_hook_feedback(
|
|
575
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
576
|
+
) -> None:
|
|
577
|
+
record_ask("Merge the release branch")
|
|
578
|
+
transcript_path = write_records(
|
|
579
|
+
tmp_path / "s.jsonl",
|
|
580
|
+
[
|
|
581
|
+
user_prompt_record("Merge the release branch"),
|
|
582
|
+
tool_call_record("AskUserQuestion"),
|
|
583
|
+
{"type": "user", "isMeta": True, "message": {"content": "Stop hook feedback"}},
|
|
584
|
+
],
|
|
585
|
+
)
|
|
586
|
+
assert run_gate(monkeypatch, capsys, DECISION_BLOCKED_REPLY, transcript_path) == (0, "")
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def test_should_ignore_a_card_call_copied_into_an_api_request_blob(
|
|
590
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
591
|
+
) -> None:
|
|
592
|
+
record_ask("Merge the release branch")
|
|
593
|
+
blob_record = {**tool_call_record("AskUserQuestion"), "type": "api-request-blob"}
|
|
594
|
+
transcript_path = write_records(tmp_path / "s.jsonl", [user_prompt_record("Merge the release branch"), blob_record])
|
|
595
|
+
assert "AskUserQuestion" in block_reason(monkeypatch, capsys, DECISION_BLOCKED_REPLY, transcript_path)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def test_should_allow_an_input_blocked_line_that_says_whether(
|
|
599
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
600
|
+
) -> None:
|
|
601
|
+
record_ask("Merge the release branch")
|
|
602
|
+
transcript_path = write_records(tmp_path / "s.jsonl", [user_prompt_record("Merge the release branch")])
|
|
603
|
+
input_reply = "Branch pushed.\nBlocked: #ask-1 - paste the log showing whether the migration ran."
|
|
604
|
+
assert run_gate(monkeypatch, capsys, input_reply, transcript_path) == (0, "")
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def test_should_start_a_new_turn_at_an_image_only_prompt(
|
|
608
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
609
|
+
) -> None:
|
|
610
|
+
record_ask("Merge the release branch")
|
|
611
|
+
image_prompt = {"type": "user", "message": {"role": "user", "content": [{"type": "image", "source": {}}]}}
|
|
612
|
+
transcript_path = write_records(
|
|
613
|
+
tmp_path / "s.jsonl",
|
|
614
|
+
[user_prompt_record("Merge the release branch"), tool_call_record("AskUserQuestion"), image_prompt],
|
|
615
|
+
)
|
|
616
|
+
assert "AskUserQuestion" in block_reason(monkeypatch, capsys, DECISION_BLOCKED_REPLY, transcript_path)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def test_should_allow_a_decision_blocked_line_after_a_codex_request_user_input_call(
|
|
620
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
621
|
+
) -> None:
|
|
622
|
+
record_ask("Merge the release branch")
|
|
623
|
+
user_message = {"type": "response_item", "payload": {"type": "message", "role": "user", "content": []}}
|
|
624
|
+
card_call = {"type": "response_item", "payload": {"type": "function_call", "name": "request_user_input", "arguments": "{}"}}
|
|
625
|
+
transcript_path = write_records(tmp_path / "rollout.jsonl", [user_message, card_call])
|
|
626
|
+
assert run_gate(monkeypatch, capsys, DECISION_BLOCKED_REPLY, transcript_path) == (0, "")
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def test_should_allow_a_blocked_line_for_a_device_step_the_user_takes(
|
|
630
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
631
|
+
) -> None:
|
|
632
|
+
record_ask("Install the runner")
|
|
633
|
+
transcript_path = write_records(tmp_path / "s.jsonl", [user_prompt_record("Install the runner")])
|
|
634
|
+
reply_text = "Installer built.\nBlocked: #ask-1 - run the installer on your PC and paste the log"
|
|
635
|
+
assert run_gate(monkeypatch, capsys, reply_text, transcript_path) == (0, "")
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def test_should_leave_a_cursor_decision_blocked_line_alone(
|
|
639
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
640
|
+
) -> None:
|
|
641
|
+
transcript_path = write_cursor_transcript(
|
|
642
|
+
tmp_path / "cursor.jsonl",
|
|
643
|
+
[{"role": "assistant", "message": {"content": [{"type": "text", "text": DECISION_BLOCKED_REPLY}]}}],
|
|
644
|
+
)
|
|
645
|
+
assert run_cursor_gate(monkeypatch, capsys, transcript_path) == {}
|
|
@@ -103,6 +103,11 @@ def test_executable_word_lists_skips_a_comment_and_resumes_on_the_next_line() ->
|
|
|
103
103
|
def test_executable_word_lists_joins_a_line_continuation() -> None:
|
|
104
104
|
assert gate.executable_word_lists("git \\\npush origin") == [["git", "push", "origin"]]
|
|
105
105
|
assert gate.executable_word_lists("git\\\n#note; git push") == [["git#note"], ["git", "push"]]
|
|
106
|
+
assert gate.executable_word_lists("echo a\\\\\n#; git push") == [["echo", "a\\"]]
|
|
107
|
+
assert gate.executable_word_lists('"gi\\\nt" push origin') == [["git", "push", "origin"]]
|
|
108
|
+
assert gate.executable_word_lists('echo "a\\\\\nb"') == [["echo", "a\\\nb"]]
|
|
109
|
+
assert gate.executable_word_lists('g"i\\\nt" push origin') == [["git", "push", "origin"]]
|
|
110
|
+
assert gate.executable_word_lists("echo 'a\\\nb'") == [["echo", "a\\\nb"]]
|
|
106
111
|
|
|
107
112
|
|
|
108
113
|
def test_executable_word_lists_keeps_the_other_segments_beside_a_trailing_backslash() -> None:
|
|
@@ -69,6 +69,30 @@ BLOCK_REASON_SUFFIX = (
|
|
|
69
69
|
"\nKeep working each one now. End the reply with one line per item you cannot finish this turn:"
|
|
70
70
|
" 'Blocked: <item> - <what the user must give>' when only the user can unblock it, or"
|
|
71
71
|
" 'Open: <item> - <the step you take next>' when you are still working it."
|
|
72
|
+
" When the user must decide, choose, or approve, ask on a question card (AskUserQuestion,"
|
|
73
|
+
" or mcp__hearthbot__ask_decision / request_decision) this turn, then add the Blocked line."
|
|
74
|
+
)
|
|
75
|
+
BLOCKED_REPORT_KIND = "blocked"
|
|
76
|
+
REPORT_KIND_GROUP_INDEX = 1
|
|
77
|
+
DECISION_WORD_PATTERN = (
|
|
78
|
+
r"\b(?:ok|okay|approve[sd]?|approval|go-?ahead|green\s+light|sign-?off|decide[sd]?|decision"
|
|
79
|
+
r"|choose|choice|permission|consent|say-?so|your\s+call|prefer(?:s|ence)?)\b"
|
|
80
|
+
)
|
|
81
|
+
DECISION_CARD_TOOL_PATTERN = (
|
|
82
|
+
r"(?:^|__)AskUserQuestion$|hearthbot__(?:ask|request)_decision$|^request_user_input(?:_async)?$"
|
|
83
|
+
)
|
|
84
|
+
ALL_SKIPPED_PROMPT_FLAGS = ("isMeta", "isSidechain", "isCompactSummary")
|
|
85
|
+
USER_ROLE = "user"
|
|
86
|
+
USER_RECORD_TYPE = "user"
|
|
87
|
+
CODEX_MESSAGE_TYPE = "message"
|
|
88
|
+
CODEX_CARD_EVENT = "card"
|
|
89
|
+
CODEX_PROMPT_EVENT = "prompt"
|
|
90
|
+
STOP_HOOK_FEEDBACK_PREFIX = "Stop hook feedback"
|
|
91
|
+
DECISION_LINE_TEMPLATE = "\n- {line}"
|
|
92
|
+
DECISION_BLOCK_REASON_PREFIX = "This reply asks the user for a decision in a Blocked line, with no question card this turn:"
|
|
93
|
+
DECISION_BLOCK_REASON_SUFFIX = (
|
|
94
|
+
"\nPut the decision on a question card now: call AskUserQuestion, or mcp__hearthbot__ask_decision or"
|
|
95
|
+
" mcp__hearthbot__request_decision, with two or three choices. Then end the reply with the Blocked line."
|
|
72
96
|
)
|
|
73
97
|
ASK_CLOSE_SUFFIX = (
|
|
74
98
|
"\nClose a finished ask with a 'Done: #ask-N' line. Close an ask or a carried plan item the user"
|
|
@@ -32,12 +32,21 @@ SEPARATOR_PIECE = "separator"
|
|
|
32
32
|
COMMENT_PIECE = "comment"
|
|
33
33
|
LINE_CONTINUATION_PIECE = "continuation"
|
|
34
34
|
UNCLOSED_QUOTE_PIECE = "unclosed"
|
|
35
|
+
DOUBLE_QUOTE = '"'
|
|
36
|
+
LINE_END = "\n"
|
|
37
|
+
ESCAPED_CHARACTER_GROUP = 1
|
|
38
|
+
DOUBLE_QUOTED_ESCAPE_PATTERN = re.compile(r"\\(.)", re.DOTALL)
|
|
39
|
+
ALL_PIECE_REPLACEMENT_TEXTS: dict[str | None, str] = {
|
|
40
|
+
SEPARATOR_PIECE: SEGMENT_BREAK,
|
|
41
|
+
COMMENT_PIECE: REMOVED_TEXT,
|
|
42
|
+
LINE_CONTINUATION_PIECE: REMOVED_TEXT,
|
|
43
|
+
}
|
|
35
44
|
COMMAND_PIECE_PATTERN = re.compile(
|
|
36
45
|
r"'[^']*'"
|
|
37
46
|
r'|"(?:\\.|[^"\\])*"'
|
|
38
47
|
rf"|(?P<{LINE_CONTINUATION_PIECE}>\\\n)"
|
|
39
48
|
rf"|\\.[^{re.escape(COMMAND_WHITESPACE + COMMAND_SEPARATORS)}'\"\\]*"
|
|
40
|
-
rf"|(?P<{COMMENT_PIECE}>(?<![^{re.escape(COMMAND_WHITESPACE + COMMAND_SEPARATORS)}])
|
|
49
|
+
rf"|(?P<{COMMENT_PIECE}>(?<![^{re.escape(COMMAND_WHITESPACE + COMMAND_SEPARATORS)}])#[^\n]*)"
|
|
41
50
|
rf"|(?P<{SEPARATOR_PIECE}>[{re.escape(COMMAND_SEPARATORS)}])"
|
|
42
51
|
rf"|(?P<{UNCLOSED_QUOTE_PIECE}>['\"])"
|
|
43
52
|
r"|.",
|