claude-dev-env 8.98.4 → 8.98.7

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.
Files changed (30) hide show
  1. package/.agents/skills/pr-lifecycle/SKILL.md +4 -4
  2. package/hooks/blocking/open_tasks_stop_gate.py +62 -9
  3. package/hooks/blocking/open_tasks_transcript.py +71 -3
  4. package/hooks/blocking/pr_lifecycle_skill_gate.py +36 -10
  5. package/hooks/blocking/reply_length_gate.py +11 -2
  6. package/hooks/blocking/resolve_thread_reply_gate.py +135 -0
  7. package/hooks/blocking/test_open_tasks_stop_gate.py +135 -2
  8. package/hooks/blocking/test_pr_lifecycle_skill_gate.py +5 -0
  9. package/hooks/blocking/test_reply_length_gate.py +11 -0
  10. package/hooks/blocking/test_resolve_thread_reply_gate.py +199 -0
  11. package/hooks/features/conduct-gates.md +3 -0
  12. package/hooks/hooks.json +10 -0
  13. package/hooks/hooks_constants/artifact_template_pointer_constants.py +3 -1
  14. package/hooks/hooks_constants/open_tasks_stop_gate_constants.py +24 -0
  15. package/hooks/hooks_constants/pr_lifecycle_skill_gate_constants.py +10 -1
  16. package/hooks/hooks_constants/resolve_thread_reply_gate_constants.py +22 -0
  17. package/hooks/hooks_constants/test_artifact_template_pointer_constants.py +12 -0
  18. package/hooks/hooks_constants/transcript_skill_scan_constants.py +2 -0
  19. package/hooks/session/artifact_template_pointer.py +26 -3
  20. package/hooks/session/test_artifact_template_pointer.py +16 -0
  21. package/hooks/test_transcript_skill_scan.py +30 -0
  22. package/hooks/transcript_skill_scan.py +72 -1
  23. package/package.json +1 -1
  24. package/scripts/durable_post_lint.py +47 -1
  25. package/scripts/durable_post_lint_config/config/constants.py +6 -0
  26. package/scripts/durable_post_lint_config/config/test_constants.py +1 -0
  27. package/scripts/policy_lint/adapter_configuration.py +5 -0
  28. package/scripts/policy_lint/config/constants.py +1 -0
  29. package/scripts/test_durable_post_lint.py +176 -3
  30. package/scripts/tests/test_adapter_configuration.py +8 -0
@@ -22,7 +22,7 @@ User-level rule: applies to **every** git repo that uses GitHub with `gh`. Small
22
22
 
23
23
  ### Pull request submission rules
24
24
 
25
- **Prove the change where it runs before opening a PR.** Read `~/.claude/docs/rule-guides/proof-before-pull-request.md` for the body and hook requirements. A `feat` PR also needs an "Eval" section and a session that invoked `/claude-api build-eval`; read `~/.claude/docs/rule-guides/features-start-with-an-eval.md` before you build the feature.
25
+ **Prove the change where it runs before opening a PR.** Read `~/.claude/docs/rule-guides/proof-before-pull-request.md` for the body and hook requirements. A `feat` PR also needs an "Eval" section and a session that invoked `/claude-api build-eval`; read `~/.claude/docs/rule-guides/features-start-with-an-eval.md` before you build the feature. A new workflow that runs only on `workflow_dispatch` cannot run until its file is on the default branch: before the PR, run its `run:` steps with a stub `gh` on PATH, name in the body the live run that waits on the merge, and dispatch it right after the merge.
26
26
 
27
27
  **Open PRs ready for review; use `--draft` when the owner requests it.** Before you commit copied files or open a PR, list the open PRs that change a file whose path starts with yours, then read each hit, with `gh pr list --state open --limit 1000 --json number,files --jq '.[] | select(any(.files[]; .path | startswith("<path>"))) | .number'`, so you do not duplicate a peer's PR. Each pre-existing failure or remaining defect the PR body names links a tracked issue.
28
28
 
@@ -121,7 +121,7 @@ An image a PR needs as visual evidence is not an exception to that row. Upload i
121
121
  1. Fetch every reviewer comment before making any fix.
122
122
  2. Create a checklist in the session's task tool with one item per comment.
123
123
  3. Fix systematically, marking each todo complete.
124
- 4. Reply to each comment inline.
124
+ 4. Reply to each comment inline, read the reply back from the thread, and resolve the thread only after the reply shows there.
125
125
 
126
126
  Repair only the reported findings. A finding that comes back on a second head escalates. Fix the source the reviewer reads, or change the gate that files the finding. Post no second reply with the same text, because a repeat finding shows the first reply changed nothing the reviewer sees.
127
127
 
@@ -405,7 +405,7 @@ Resolve the active managed root (`CLAUDE_CONFIG_DIR` when set, `~/.claude`
405
405
  otherwise), then run `<managed-root>/scripts/durable_post_lint.py` before the
406
406
  server write. Pass the matching action and body file. Use `pr-create`,
407
407
  `pr-edit`, `pr-comment`, `pr-review`, `issue-create`, `issue-edit`,
408
- `issue-comment`, or `github-mcp-post`.
408
+ `issue-comment`, `github-mcp-post`, or `github-mcp-comment` for an MCP comment or reply.
409
409
 
410
410
  Pass `--repository <owner>/<name>` for the repository the post targets. A
411
411
  post may name a private organization only inside a repository that
@@ -440,7 +440,7 @@ For pull requests, use
440
440
  `.agents/skills/pull-request/scripts/pull_request.py`. It passes
441
441
  `--body-file` to `gh` after the action-aware linter succeeds. For issues and
442
442
  GitHub MCP posts, run the linter directly with `issue-create`, `issue-edit`,
443
- `issue-comment`, or `github-mcp-post` as the action.
443
+ `issue-comment`, `github-mcp-post`, or `github-mcp-comment` as the action.
444
444
 
445
445
  ### Paginated reads slurp before they filter
446
446
 
@@ -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
 
@@ -559,15 +559,24 @@ def gh_command_token_runs(all_command_tokens: list[str]) -> list[list[str]]:
559
559
  return all_runs
560
560
 
561
561
 
562
+ def comment_length_violation(comment_text: str) -> str | None:
563
+ """Return the deny reason for one over-long GitHub comment text, or None when it fits.
564
+
565
+ Fenced code blocks and collapsed ``<details>`` blocks carry no words.
566
+ """
567
+ violation = length_violation(DETAILS_BLOCK_PATTERN.sub(" ", comment_text))
568
+ return None if violation is None else violation + COMMENT_RETRY_INSTRUCTION
569
+
570
+
562
571
  def github_comment_violation(tool_name: object, all_tool_input: dict[str, object]) -> tuple[str, str] | None:
563
572
  """Return the deny reason and text for an over-long GitHub comment, or None.
564
573
 
565
574
  Fenced code blocks and collapsed ``<details>`` blocks carry no words.
566
575
  """
567
576
  for each_comment_text in github_comment_texts(tool_name, all_tool_input):
568
- violation = length_violation(DETAILS_BLOCK_PATTERN.sub(" ", each_comment_text))
577
+ violation = comment_length_violation(each_comment_text)
569
578
  if violation is not None:
570
- return violation + COMMENT_RETRY_INSTRUCTION, each_comment_text
579
+ return violation, each_comment_text
571
580
  return None
572
581
 
573
582
 
@@ -0,0 +1,135 @@
1
+ """Deny a review thread resolve until a reply posted after the last user prompt and the last resolve."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from collections.abc import Iterable
7
+ from pathlib import Path
8
+
9
+ hooks_directory = str(Path(__file__).resolve().parent.parent)
10
+ if hooks_directory not in sys.path:
11
+ sys.path.insert(0, hooks_directory)
12
+
13
+ from blocking.pr_lifecycle_skill_gate import deny_decision, transcript_paths
14
+ from hooks_constants.pr_lifecycle_skill_gate_constants import SHELL_TOOL_NAMES
15
+ from hooks_constants.pre_tool_use_stdin import print_decision_from_stdin
16
+ from hooks_constants.resolve_thread_reply_gate_constants import (
17
+ ALL_REPLY_TOOL_NAME_SUFFIXES,
18
+ MISSING_REPLY_REASON,
19
+ RESOLVE_TOOL_NAME_PATTERN,
20
+ SHELL_COMMAND_KEY,
21
+ SHELL_REPLY_COMMAND_PATTERN,
22
+ )
23
+ from hooks_constants.setup_project_paths_constants import DECODE_ERRORS_POLICY, UTF8_ENCODING
24
+ from transcript_skill_scan import TranscriptToolCall, tool_calls_since_last_prompt
25
+
26
+
27
+ def is_resolve_call(tool_name: object) -> bool:
28
+ """Return whether a tool name resolves a review thread, such as ``mcp__github__resolve_review_thread``.
29
+
30
+ Args:
31
+ tool_name: The tool name from a hook payload or transcript.
32
+ """
33
+ return isinstance(tool_name, str) and RESOLVE_TOOL_NAME_PATTERN.match(tool_name) is not None
34
+
35
+
36
+ def is_reply_call(tool_call: TranscriptToolCall) -> bool:
37
+ """Return whether a transcript tool call replies on a review thread.
38
+
39
+ ::
40
+
41
+ mcp__github__add_reply_to_pull_request_comment -> True
42
+ Bash "gh api repos/o/r/pulls/7/comments/99/replies -f body=x" -> True
43
+ Bash "gh api graphql" with addPullRequestReviewThreadReply -> True
44
+ mcp__github__add_issue_comment -> False
45
+
46
+ Args:
47
+ tool_call: One tool call read from the transcript.
48
+ """
49
+ if tool_call.name.endswith(ALL_REPLY_TOOL_NAME_SUFFIXES):
50
+ return True
51
+ command = tool_call.tool_input.get(SHELL_COMMAND_KEY)
52
+ return (
53
+ tool_call.name in SHELL_TOOL_NAMES
54
+ and isinstance(command, str)
55
+ and SHELL_REPLY_COMMAND_PATTERN.search(command) is not None
56
+ )
57
+
58
+
59
+ def has_posted_reply_since_last_resolve(all_transcript_lines: Iterable[str]) -> bool:
60
+ """Return whether a reply that succeeded since the last user prompt is not yet spent on a successful resolve.
61
+
62
+ ::
63
+
64
+ prompt, reply ok, resolve pending -> True
65
+ prompt, reply refused by a hook, resolve -> False
66
+ prompt, resolve -> False
67
+ prompt, reply ok, resolve ok, resolve pending -> False
68
+ prompt, reply ok, reply ok, resolve ok, resolve pending -> True
69
+
70
+ Each successful resolve spends one successful reply. A failed or denied
71
+ resolve spends none, so a retry after a resolve error needs no second reply.
72
+
73
+ Args:
74
+ all_transcript_lines: JSON transcript entries, one per line.
75
+ """
76
+ unspent_reply_count = 0
77
+ for each_call in tool_calls_since_last_prompt(all_transcript_lines):
78
+ unspent_reply_count = _unspent_reply_count_after(unspent_reply_count, each_call)
79
+ return unspent_reply_count > 0
80
+
81
+
82
+ def _unspent_reply_count_after(unspent_reply_count: int, tool_call: TranscriptToolCall) -> int:
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:
93
+ try:
94
+ with open(transcript_path, encoding=UTF8_ENCODING, errors=DECODE_ERRORS_POLICY) as transcript:
95
+ return has_posted_reply_since_last_resolve(transcript)
96
+ except OSError:
97
+ return None
98
+
99
+
100
+ 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.
102
+
103
+ ::
104
+
105
+ resolve after a reply that posted -> None
106
+ resolve after a reply a hook refused -> MISSING_REPLY_REASON
107
+ resolve with no reply this turn -> MISSING_REPLY_REASON
108
+ second resolve after one reply -> MISSING_REPLY_REASON
109
+ resolve with an unreadable transcript -> None
110
+ any other tool -> None
111
+
112
+ The gate reads the calling agent's own transcript: a subagent's file when
113
+ the payload names one, else the session transcript.
114
+
115
+ Args:
116
+ all_payload_fields: The parsed PreToolUse input.
117
+ """
118
+ if not is_resolve_call(all_payload_fields.get("tool_name")):
119
+ return None
120
+ all_paths = transcript_paths(all_payload_fields)
121
+ if not all_paths:
122
+ return None
123
+ shows_reply = _transcript_shows_reply(all_paths[-1])
124
+ if shows_reply is None or shows_reply:
125
+ return None
126
+ return deny_decision(MISSING_REPLY_REASON)
127
+
128
+
129
+ def main() -> int:
130
+ """Read one payload, print a deny when needed, and exit zero."""
131
+ return print_decision_from_stdin(decision_for)
132
+
133
+
134
+ if __name__ == "__main__":
135
+ sys.exit(main())