claude-dev-env 8.98.3 → 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.
@@ -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 must approve the org secret
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 unreported_open_items(all_hook_input_fields: dict[str, object]) -> list[TaskItem]:
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
- reply_value = all_hook_input_fields.get(LAST_ASSISTANT_MESSAGE_KEY)
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
- all_open_items = unreported_open_items(hook_input)
299
- if not all_open_items:
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
- block_reason = block_reason_for(all_open_items)
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 _TranscriptReplay:
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) -> _TranscriptReplay:
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 = _TranscriptReplay()
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
- all_pieces = list(COMMAND_PIECE_PATTERN.finditer(command))
95
- if any(each_piece.lastgroup == UNCLOSED_QUOTE_PIECE for each_piece in all_pieces):
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
- all_replacement_texts: dict[str | None, str] = {
98
- SEPARATOR_PIECE: SEGMENT_BREAK,
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
- all_replacement_texts.get(each_piece.lastgroup, each_piece.group()) for each_piece in all_pieces
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 approves the secret\n- Open: #2 - checks running"
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 must approve the org secret"}
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)}])(?<!\\\n)#[^\n]*)"
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"|.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.98.3",
3
+ "version": "8.98.6",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {