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.
- package/.agents/skills/pr-lifecycle/SKILL.md +4 -4
- 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/reply_length_gate.py +11 -2
- package/hooks/blocking/resolve_thread_reply_gate.py +135 -0
- package/hooks/blocking/test_open_tasks_stop_gate.py +135 -2
- package/hooks/blocking/test_pr_lifecycle_skill_gate.py +5 -0
- package/hooks/blocking/test_reply_length_gate.py +11 -0
- package/hooks/blocking/test_resolve_thread_reply_gate.py +199 -0
- package/hooks/features/conduct-gates.md +3 -0
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/artifact_template_pointer_constants.py +3 -1
- 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/hooks/hooks_constants/resolve_thread_reply_gate_constants.py +22 -0
- package/hooks/hooks_constants/test_artifact_template_pointer_constants.py +12 -0
- package/hooks/hooks_constants/transcript_skill_scan_constants.py +2 -0
- package/hooks/session/artifact_template_pointer.py +26 -3
- package/hooks/session/test_artifact_template_pointer.py +16 -0
- package/hooks/test_transcript_skill_scan.py +30 -0
- package/hooks/transcript_skill_scan.py +72 -1
- package/package.json +1 -1
- package/scripts/durable_post_lint.py +47 -1
- package/scripts/durable_post_lint_config/config/constants.py +6 -0
- package/scripts/durable_post_lint_config/config/test_constants.py +1 -0
- package/scripts/policy_lint/adapter_configuration.py +5 -0
- package/scripts/policy_lint/config/constants.py +1 -0
- package/scripts/test_durable_post_lint.py +176 -3
- 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-
|
|
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-
|
|
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
|
|
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
|
|
|
@@ -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 =
|
|
577
|
+
violation = comment_length_violation(each_comment_text)
|
|
569
578
|
if violation is not None:
|
|
570
|
-
return violation
|
|
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())
|