claude-dev-env 8.92.2 → 8.93.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hooks/advisory/owned_pr_review_loop.py +57 -3
- package/hooks/advisory/test_owned_pr_review_loop.py +23 -0
- package/hooks/blocking/reply_length_gate.py +92 -1
- package/hooks/blocking/test_reply_length_gate.py +43 -0
- package/hooks/features/bash-dispatchers.md +1 -1
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/owned_pr_review_loop_constants.py +12 -0
- package/hooks/hooks_constants/reply_length_gate_constants.py +22 -0
- package/package.json +1 -1
|
@@ -53,7 +53,10 @@ try:
|
|
|
53
53
|
ALL_PRESET_REPOSITORY_DIGESTS,
|
|
54
54
|
ALL_SHELL_COMMAND_INPUT_KEYS,
|
|
55
55
|
ALL_SHELL_TOOL_NAMES,
|
|
56
|
+
ALL_PYTHON_SCRIPTLESS_OPTIONS,
|
|
56
57
|
ALREADY_EXISTS_MARKER,
|
|
58
|
+
BACKSLASH,
|
|
59
|
+
DOUBLED_BACKSLASH,
|
|
57
60
|
GH_API_POST_METHOD,
|
|
58
61
|
GH_API_PULLS_ENDPOINT_PATTERN,
|
|
59
62
|
GH_API_SUBCOMMAND,
|
|
@@ -68,14 +71,28 @@ try:
|
|
|
68
71
|
MCP_TOOL_NAME_PREFIX,
|
|
69
72
|
NAME_DIGEST_TEXT_ENCODING,
|
|
70
73
|
PULL_REQUEST_URL_PATTERN,
|
|
74
|
+
PYTHON_OPTION_AND_VALUE_WORD_COUNT,
|
|
75
|
+
PYTHON_OPTION_PREFIX,
|
|
76
|
+
PYTHON_SHORT_OPTION_LENGTH,
|
|
71
77
|
REVIEW_LOOP_CONTEXT_TEMPLATE,
|
|
72
78
|
SHELL_ARGUMENT_SEPARATOR,
|
|
73
79
|
)
|
|
74
80
|
from hooks_constants.post_tool_use_context import (
|
|
75
81
|
write_post_tool_use_context_to_stdout,
|
|
76
82
|
)
|
|
83
|
+
from hooks_constants.pr_lifecycle_skill_gate_constants import PYTHON_OPTIONS_WITH_VALUE
|
|
77
84
|
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
78
|
-
from hooks_constants.
|
|
85
|
+
from hooks_constants.pull_request_proof_constants import (
|
|
86
|
+
ALL_PYTHON_PROGRAM_NAMES,
|
|
87
|
+
PULL_REQUEST_SCRIPT_CREATE_WORD,
|
|
88
|
+
PULL_REQUEST_SCRIPT_NAME,
|
|
89
|
+
)
|
|
90
|
+
from hooks_constants.pytest_invocation import unquoted_token
|
|
91
|
+
from hooks_constants.shell_command_segments import (
|
|
92
|
+
command_tokens,
|
|
93
|
+
split_into_segments,
|
|
94
|
+
token_basename,
|
|
95
|
+
)
|
|
79
96
|
from hooks_constants.shell_command_wrappers import (
|
|
80
97
|
all_wrapped_command_texts,
|
|
81
98
|
segment_program_and_arguments,
|
|
@@ -127,8 +144,34 @@ def _is_gh_api_pulls_post(all_arguments: list[str]) -> bool:
|
|
|
127
144
|
return sends_body
|
|
128
145
|
|
|
129
146
|
|
|
147
|
+
def _python_script_index(all_arguments: list[str]) -> int | None:
|
|
148
|
+
index = 0
|
|
149
|
+
while index < len(all_arguments) and all_arguments[index].startswith(PYTHON_OPTION_PREFIX):
|
|
150
|
+
if all_arguments[index][:PYTHON_SHORT_OPTION_LENGTH] in ALL_PYTHON_SCRIPTLESS_OPTIONS:
|
|
151
|
+
return None
|
|
152
|
+
is_option_with_value = all_arguments[index] in PYTHON_OPTIONS_WITH_VALUE
|
|
153
|
+
index += PYTHON_OPTION_AND_VALUE_WORD_COUNT if is_option_with_value else 1
|
|
154
|
+
return index if index < len(all_arguments) else None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _is_pull_request_script_create(program_name: str, all_arguments: list[str]) -> bool:
|
|
158
|
+
all_script_arguments = all_arguments
|
|
159
|
+
if program_name != PULL_REQUEST_SCRIPT_NAME:
|
|
160
|
+
script_index = (
|
|
161
|
+
_python_script_index(all_arguments) if program_name in ALL_PYTHON_PROGRAM_NAMES else None
|
|
162
|
+
)
|
|
163
|
+
if script_index is None:
|
|
164
|
+
return False
|
|
165
|
+
if token_basename(unquoted_token(all_arguments[script_index])) != PULL_REQUEST_SCRIPT_NAME:
|
|
166
|
+
return False
|
|
167
|
+
all_script_arguments = all_arguments[script_index + 1 :]
|
|
168
|
+
return all_script_arguments[:1] == [PULL_REQUEST_SCRIPT_CREATE_WORD]
|
|
169
|
+
|
|
170
|
+
|
|
130
171
|
def _segment_opens_pull_request(all_segment_tokens: list[str]) -> bool:
|
|
131
172
|
program_name, all_arguments = segment_program_and_arguments(all_segment_tokens)
|
|
173
|
+
if _is_pull_request_script_create(program_name, all_arguments):
|
|
174
|
+
return True
|
|
132
175
|
return program_name == GH_PROGRAM_NAME and (
|
|
133
176
|
_is_gh_pr_create(all_arguments) or _is_gh_api_pulls_post(all_arguments)
|
|
134
177
|
)
|
|
@@ -137,11 +180,18 @@ def _segment_opens_pull_request(all_segment_tokens: list[str]) -> bool:
|
|
|
137
180
|
def command_creates_pull_request(command: str) -> bool:
|
|
138
181
|
"""Return True when any segment of the shell command opens a pull request.
|
|
139
182
|
|
|
140
|
-
|
|
183
|
+
Four shapes count::
|
|
141
184
|
|
|
142
185
|
gh pr create --fill
|
|
143
186
|
gh pr new --fill
|
|
144
187
|
gh api repos/acme/widgets/pulls --input pr.json
|
|
188
|
+
python ~/.agents/skills/pull-request/scripts/pull_request.py create --repo acme/widgets
|
|
189
|
+
|
|
190
|
+
The publisher counts when ``python``, ``python3``, or ``py`` runs a
|
|
191
|
+
script named ``pull_request.py`` with ``create``, or the script runs
|
|
192
|
+
alone. Interpreter options such as ``-X utf8`` before the script are
|
|
193
|
+
skipped, and an unquoted Windows path such as
|
|
194
|
+
``C:\\tools\\pull_request.py`` keeps its separators.
|
|
145
195
|
|
|
146
196
|
A ``gh api`` call counts only when it targets the pulls collection. An
|
|
147
197
|
explicit method (``-X POST``, ``-XPOST``, ``--method=POST``) decides
|
|
@@ -157,7 +207,11 @@ def command_creates_pull_request(command: str) -> bool:
|
|
|
157
207
|
return any(
|
|
158
208
|
_segment_opens_pull_request(each_segment)
|
|
159
209
|
for each_command_text in all_wrapped_command_texts(command)
|
|
160
|
-
for
|
|
210
|
+
for each_command_tokens in (
|
|
211
|
+
command_tokens(each_command_text),
|
|
212
|
+
command_tokens(each_command_text.replace(BACKSLASH, DOUBLED_BACKSLASH)),
|
|
213
|
+
)
|
|
214
|
+
for each_segment in split_into_segments(each_command_tokens)
|
|
161
215
|
)
|
|
162
216
|
|
|
163
217
|
|
|
@@ -85,6 +85,13 @@ def _context_of(stdout: str) -> str:
|
|
|
85
85
|
"gh api --method=POST repos/acme/widgets/pulls",
|
|
86
86
|
"gh api -XPOST repos/acme/widgets/pulls",
|
|
87
87
|
"gh api repos/acme/widgets/pulls -Ftitle=t -fhead=x",
|
|
88
|
+
'python "C:/Users/me/.agents/skills/pull-request/scripts/pull_request.py" create --repo acme/widgets',
|
|
89
|
+
"python3 ~/.agents/skills/pull-request/scripts/pull_request.py create --title t",
|
|
90
|
+
"py pull_request.py create --fill",
|
|
91
|
+
"git push && python pull_request.py create --repo acme/widgets 2>&1 | tail -5",
|
|
92
|
+
"python -X utf8 ~/.agents/skills/pull-request/scripts/pull_request.py create --fill",
|
|
93
|
+
"python -I -W ignore pull_request.py create",
|
|
94
|
+
"python C:\\Users\\me\\.agents\\skills\\pull-request\\scripts\\pull_request.py create",
|
|
88
95
|
],
|
|
89
96
|
)
|
|
90
97
|
def test_should_detect_gh_pr_create(command: str) -> None:
|
|
@@ -103,6 +110,12 @@ def test_should_detect_gh_pr_create(command: str) -> None:
|
|
|
103
110
|
"gh api repos/acme/widgets/issues --input issue.json",
|
|
104
111
|
"gh api --method GET repos/acme/widgets/pulls -f state=open",
|
|
105
112
|
"gh api -X GET repos/acme/widgets/pulls -f state=open",
|
|
113
|
+
"python pull_request.py edit --repo acme/widgets --number 42",
|
|
114
|
+
"python other_script.py create",
|
|
115
|
+
"python pull_request.py",
|
|
116
|
+
"python -m pull_request.py create",
|
|
117
|
+
"python -c pull_request.py create",
|
|
118
|
+
"python C:\\Users\\me\\scripts\\my_pull_request.py create",
|
|
106
119
|
],
|
|
107
120
|
)
|
|
108
121
|
def test_should_not_detect_other_commands(command: str) -> None:
|
|
@@ -325,6 +338,16 @@ def test_main_should_emit_the_loop_after_a_gh_api_pulls_post() -> None:
|
|
|
325
338
|
assert "jl-cmd-projects/claude-dev-env#42" in _context_of(stdout)
|
|
326
339
|
|
|
327
340
|
|
|
341
|
+
def test_main_should_emit_the_loop_after_the_pull_request_publisher_creates() -> None:
|
|
342
|
+
publisher_command = (
|
|
343
|
+
'python "C:/Users/me/.agents/skills/pull-request/scripts/pull_request.py" create'
|
|
344
|
+
" --repo jl-cmd-projects/claude-dev-env --base main --head fix/x --title t"
|
|
345
|
+
" --body-file body.md 2>&1 | tail -5"
|
|
346
|
+
)
|
|
347
|
+
stdout = _run_hook(_shell_payload(publisher_command, f"{OWNED_PR_URL}\n"))
|
|
348
|
+
assert "jl-cmd-projects/claude-dev-env#42" in _context_of(stdout)
|
|
349
|
+
|
|
350
|
+
|
|
328
351
|
def test_main_should_emit_the_loop_for_a_codex_shell_call() -> None:
|
|
329
352
|
stdout = _run_hook(
|
|
330
353
|
_shell_payload(
|
|
@@ -77,9 +77,11 @@ later pass, so this gate denies it and the model resends a shorter one.
|
|
|
77
77
|
|
|
78
78
|
from __future__ import annotations
|
|
79
79
|
|
|
80
|
+
import itertools
|
|
80
81
|
import json
|
|
81
82
|
import os
|
|
82
83
|
import re
|
|
84
|
+
import shlex
|
|
83
85
|
import sys
|
|
84
86
|
from pathlib import Path
|
|
85
87
|
|
|
@@ -87,6 +89,7 @@ hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
|
87
89
|
if hooks_root_directory not in sys.path:
|
|
88
90
|
sys.path.insert(0, hooks_root_directory)
|
|
89
91
|
|
|
92
|
+
from gh_post_body_texts import extract_mcp_body_texts
|
|
90
93
|
from json_file_reader import read_json_object
|
|
91
94
|
from visual_reply_rules import (
|
|
92
95
|
VisualReplyRules,
|
|
@@ -106,6 +109,17 @@ from hooks_constants.hook_specific_output_keys import (
|
|
|
106
109
|
HOOK_SPECIFIC_OUTPUT_KEY,
|
|
107
110
|
)
|
|
108
111
|
from hooks_constants.reply_length_gate_constants import (
|
|
112
|
+
ALL_CHECKED_MCP_TOOL_NAMES,
|
|
113
|
+
BASH_TOOL_NAME,
|
|
114
|
+
COMMAND_KEY,
|
|
115
|
+
COMMENT_RETRY_INSTRUCTION,
|
|
116
|
+
DETAILS_BLOCK_PATTERN,
|
|
117
|
+
ALL_GH_BODY_FLAGS,
|
|
118
|
+
ALL_GH_COMMENT_ACTIONS,
|
|
119
|
+
ALL_GH_COMMENT_GROUPS,
|
|
120
|
+
GH_BODY_FLAG_PREFIX,
|
|
121
|
+
GH_PROGRAM_NAME,
|
|
122
|
+
SHELL_OPERATOR_CHARACTERS,
|
|
109
123
|
ALL_CHECKED_TOOL_NAMES,
|
|
110
124
|
ALL_DECISION_CARD_LENGTH_KEYS,
|
|
111
125
|
ALL_DECISION_CARD_PROSE_KEYS,
|
|
@@ -482,6 +496,81 @@ def tool_violation(
|
|
|
482
496
|
return None if violation is None else (violation, reply_text)
|
|
483
497
|
|
|
484
498
|
|
|
499
|
+
def github_comment_texts(tool_name: object, all_tool_input: dict[str, object]) -> list[str]:
|
|
500
|
+
"""Return the GitHub comment texts one call would post, or an empty list.
|
|
501
|
+
|
|
502
|
+
::
|
|
503
|
+
|
|
504
|
+
mcp__github__add_issue_comment {"body": "Done."} -> ["Done."]
|
|
505
|
+
Bash 'gh pr comment 12 --body "Done."' -> ["Done."]
|
|
506
|
+
Bash 'git push && gh pr comment 12 --body "Done."' -> ["Done."]
|
|
507
|
+
Bash 'gh pr create --body "Long body"' -> []
|
|
508
|
+
"""
|
|
509
|
+
if tool_name in ALL_CHECKED_MCP_TOOL_NAMES:
|
|
510
|
+
return extract_mcp_body_texts(all_tool_input)
|
|
511
|
+
command = all_tool_input.get(COMMAND_KEY)
|
|
512
|
+
if tool_name != BASH_TOOL_NAME or not isinstance(command, str):
|
|
513
|
+
return []
|
|
514
|
+
try:
|
|
515
|
+
command_lexer = shlex.shlex(command, posix=True, punctuation_chars=True)
|
|
516
|
+
command_lexer.whitespace_split = True
|
|
517
|
+
all_command_tokens = list(command_lexer)
|
|
518
|
+
except ValueError:
|
|
519
|
+
return []
|
|
520
|
+
return gh_comment_bodies(all_command_tokens)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def gh_comment_bodies(all_command_tokens: list[str]) -> list[str]:
|
|
524
|
+
"""Return the ``--body`` values of each ``gh pr|issue comment|review`` call in the tokens.
|
|
525
|
+
|
|
526
|
+
::
|
|
527
|
+
|
|
528
|
+
git push && gh -R o/r pr comment 12 --body "Done." -> ["Done."]
|
|
529
|
+
gh pr create --body "Long body" -> []
|
|
530
|
+
"""
|
|
531
|
+
all_bodies: list[str] = []
|
|
532
|
+
for each_command_tokens in gh_command_token_runs(all_command_tokens):
|
|
533
|
+
all_pairs = list(itertools.pairwise(each_command_tokens))
|
|
534
|
+
if not any(
|
|
535
|
+
each_group in ALL_GH_COMMENT_GROUPS and each_action in ALL_GH_COMMENT_ACTIONS
|
|
536
|
+
for each_group, each_action in all_pairs
|
|
537
|
+
):
|
|
538
|
+
continue
|
|
539
|
+
all_bodies.extend(
|
|
540
|
+
each_token[len(GH_BODY_FLAG_PREFIX):]
|
|
541
|
+
for each_token in each_command_tokens
|
|
542
|
+
if each_token.startswith(GH_BODY_FLAG_PREFIX)
|
|
543
|
+
)
|
|
544
|
+
all_bodies.extend(each_value for each_flag, each_value in all_pairs if each_flag in ALL_GH_BODY_FLAGS)
|
|
545
|
+
return all_bodies
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def is_command_token(token: str) -> bool:
|
|
549
|
+
"""Return True when the token is part of a command, False for ``&&``, ``||``, ``;`` or ``|``."""
|
|
550
|
+
return not set(token) <= SHELL_OPERATOR_CHARACTERS
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def gh_command_token_runs(all_command_tokens: list[str]) -> list[list[str]]:
|
|
554
|
+
"""Return the tokens after each ``gh`` token, up to the next shell operator."""
|
|
555
|
+
all_runs: list[list[str]] = []
|
|
556
|
+
for each_index, each_token in enumerate(all_command_tokens):
|
|
557
|
+
if each_token == GH_PROGRAM_NAME:
|
|
558
|
+
all_runs.append(list(itertools.takewhile(is_command_token, all_command_tokens[each_index + 1:])))
|
|
559
|
+
return all_runs
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def github_comment_violation(tool_name: object, all_tool_input: dict[str, object]) -> tuple[str, str] | None:
|
|
563
|
+
"""Return the deny reason and text for an over-long GitHub comment, or None.
|
|
564
|
+
|
|
565
|
+
Fenced code blocks and collapsed ``<details>`` blocks carry no words.
|
|
566
|
+
"""
|
|
567
|
+
for each_comment_text in github_comment_texts(tool_name, all_tool_input):
|
|
568
|
+
violation = length_violation(DETAILS_BLOCK_PATTERN.sub(" ", each_comment_text))
|
|
569
|
+
if violation is not None:
|
|
570
|
+
return violation + COMMENT_RETRY_INSTRUCTION, each_comment_text
|
|
571
|
+
return None
|
|
572
|
+
|
|
573
|
+
|
|
485
574
|
def write_flowchart_advice(tool_name: object, all_tool_input: dict[str, object]) -> None:
|
|
486
575
|
"""Print the flowchart advice for an allowed widget call that is not a flowchart."""
|
|
487
576
|
mode_rules = active_mode_rules()
|
|
@@ -502,7 +591,9 @@ def main() -> int:
|
|
|
502
591
|
tool_input = hook_input.get(TOOL_INPUT_KEY)
|
|
503
592
|
if not isinstance(tool_input, dict):
|
|
504
593
|
return ALLOW_EXIT_CODE
|
|
505
|
-
checked =
|
|
594
|
+
checked = github_comment_violation(tool_name, tool_input) or tool_violation(
|
|
595
|
+
tool_name, tool_input, hook_input.get(TRANSCRIPT_PATH_KEY)
|
|
596
|
+
)
|
|
506
597
|
if checked is None:
|
|
507
598
|
write_flowchart_advice(tool_name, tool_input)
|
|
508
599
|
return ALLOW_EXIT_CODE
|
|
@@ -1022,3 +1022,46 @@ def test_should_leave_the_transcript_unread_when_the_text_cites_nothing(
|
|
|
1022
1022
|
def test_should_split_prose_sentences_outside_fenced_blocks() -> None:
|
|
1023
1023
|
reply_text = "First one. Second one?\n```\nskipped. code\n```\nThird"
|
|
1024
1024
|
assert reply_length_gate.prose_sentences(reply_text) == ["First one.", "Second one?", "", "Third"]
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
LONG_GITHUB_COMMENT = " ".join(
|
|
1028
|
+
"Sentence number one of a long comment that keeps going." for _ in range(50)
|
|
1029
|
+
)
|
|
1030
|
+
SHORT_GITHUB_COMMENT = "Fixed the cap check. The test now covers it. Checks pass."
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
def test_should_deny_long_github_comment(
|
|
1034
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
1035
|
+
) -> None:
|
|
1036
|
+
exit_code, error_text = run_gate(
|
|
1037
|
+
monkeypatch, capsys, "mcp__github__add_issue_comment", {"body": LONG_GITHUB_COMMENT}
|
|
1038
|
+
)
|
|
1039
|
+
assert exit_code == 2
|
|
1040
|
+
assert "50 sentences, limit 3" in error_text
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
def test_should_allow_short_github_comment_with_code_and_details(
|
|
1044
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
1045
|
+
) -> None:
|
|
1046
|
+
comment_text = (
|
|
1047
|
+
SHORT_GITHUB_COMMENT
|
|
1048
|
+
+ "\n```\n" + LONG_GITHUB_COMMENT + "\n```\n"
|
|
1049
|
+
+ "<details><summary>Log</summary>\n" + LONG_GITHUB_COMMENT + "\n</details>"
|
|
1050
|
+
)
|
|
1051
|
+
exit_code, error_text = run_gate(
|
|
1052
|
+
monkeypatch, capsys, "mcp__github__add_issue_comment", {"body": comment_text}
|
|
1053
|
+
)
|
|
1054
|
+
assert (exit_code, error_text) == (0, "")
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
def test_should_read_gh_comment_body_and_skip_pr_create() -> None:
|
|
1058
|
+
comment_command = f'gh pr comment 12 --body "{LONG_GITHUB_COMMENT}"'
|
|
1059
|
+
create_command = f'gh pr create --body "Run gh pr comment 12 to reply. {LONG_GITHUB_COMMENT}"'
|
|
1060
|
+
assert reply_length_gate.github_comment_texts("Bash", {"command": comment_command}) == [LONG_GITHUB_COMMENT]
|
|
1061
|
+
assert reply_length_gate.github_comment_texts("Bash", {"command": create_command}) == []
|
|
1062
|
+
multiline_command = 'gh pr comment 12 --body "' + LONG_GITHUB_COMMENT.replace(". ", ".\n") + '"'
|
|
1063
|
+
assert len(reply_length_gate.github_comment_texts("Bash", {"command": multiline_command})) == 1
|
|
1064
|
+
flagged_command = f'git push\ngh -R o/r pr comment 12 --body "note=x. {LONG_GITHUB_COMMENT}"'
|
|
1065
|
+
assert reply_length_gate.github_comment_texts("Bash", {"command": flagged_command}) == [f"note=x. {LONG_GITHUB_COMMENT}"]
|
|
1066
|
+
chained_command = f"git push && {comment_command}"
|
|
1067
|
+
assert reply_length_gate.github_comment_texts("Bash", {"command": chained_command}) == [LONG_GITHUB_COMMENT]
|
|
@@ -23,7 +23,7 @@ This family rewrites a Git Bash command when path conversion would change a revi
|
|
|
23
23
|
- `blocking/cloud_graphql_gate.py` runs inside that dispatcher on `PreToolUse`, matcher `Bash`, timeout `60` seconds, for the Bash and PowerShell tools, when `CLAUDE_CODE_REMOTE` is `true`.
|
|
24
24
|
- `blocking/bash_post_call_dispatcher.py` runs on `PostToolUse`, matcher `Bash|PowerShell`, timeout `60` seconds in `hooks.json`.
|
|
25
25
|
- `advisory/pr_done_reminder.py` runs inside that dispatcher on `PostToolUse`, matcher `Bash|PowerShell`, timeout `60` seconds. `ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES` selects `Bash` and `PowerShell`.
|
|
26
|
-
- `advisory/owned_pr_review_loop.py` runs on `PostToolUse`, matcher `Bash|PowerShell|exec_command|shell_command|mcp__.*__(create_pull_request|github_create_pull_request)`, timeout `10` seconds in `hooks.json`. It wakes on `gh pr create`, `gh pr new`, a POST to `gh api repos/<owner>/<repo>/pulls`, and an MCP pull request creation. The installer also registers it on Codex `PostToolUse` through `ALL_CODEX_NATIVE_POST_TOOL_USE_HOOK_SCRIPT_NAMES`.
|
|
26
|
+
- `advisory/owned_pr_review_loop.py` runs on `PostToolUse`, matcher `Bash|PowerShell|exec_command|shell_command|mcp__.*__(create_pull_request|github_create_pull_request)`, timeout `10` seconds in `hooks.json`. It wakes on `gh pr create`, `gh pr new`, a POST to `gh api repos/<owner>/<repo>/pulls`, a `pull_request.py create` call to the pull request publisher, and an MCP pull request creation. The installer also registers it on Codex `PostToolUse` through `ALL_CODEX_NATIVE_POST_TOOL_USE_HOOK_SCRIPT_NAMES`.
|
|
27
27
|
- `blocking/owned_pr_merge_stop_gate.py` runs on `Stop`, timeout `60` seconds in `hooks.json`. The installer also registers it on Codex `Stop` through `ALL_CODEX_NATIVE_STOP_HOOK_SCRIPT_NAMES`.
|
|
28
28
|
|
|
29
29
|
## Proving it
|
package/hooks/hooks.json
CHANGED
|
@@ -273,6 +273,16 @@
|
|
|
273
273
|
"timeout": 15
|
|
274
274
|
}
|
|
275
275
|
]
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
"matcher": "Bash|mcp__github__add_issue_comment|mcp__github__update_issue_comment|mcp__github__add_reply_to_pull_request_comment|mcp__github__add_comment_to_pending_review|mcp__github__pull_request_review_write",
|
|
279
|
+
"hooks": [
|
|
280
|
+
{
|
|
281
|
+
"type": "command",
|
|
282
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/reply_length_gate.py",
|
|
283
|
+
"timeout": 10
|
|
284
|
+
}
|
|
285
|
+
]
|
|
276
286
|
}
|
|
277
287
|
],
|
|
278
288
|
"UserPromptSubmit": [
|
|
@@ -39,6 +39,12 @@ __all__ = [
|
|
|
39
39
|
"GH_SHORT_OPTION_LENGTH",
|
|
40
40
|
"ALL_GH_API_SHORT_OPTIONS",
|
|
41
41
|
"GH_OPTION_VALUE_SEPARATOR",
|
|
42
|
+
"PYTHON_OPTION_PREFIX",
|
|
43
|
+
"PYTHON_SHORT_OPTION_LENGTH",
|
|
44
|
+
"PYTHON_OPTION_AND_VALUE_WORD_COUNT",
|
|
45
|
+
"ALL_PYTHON_SCRIPTLESS_OPTIONS",
|
|
46
|
+
"BACKSLASH",
|
|
47
|
+
"DOUBLED_BACKSLASH",
|
|
42
48
|
"MCP_TOOL_NAME_PREFIX",
|
|
43
49
|
"ALL_MCP_CREATE_PULL_REQUEST_TOOL_SUFFIXES",
|
|
44
50
|
"MCP_OWNER_INPUT_KEY",
|
|
@@ -85,6 +91,12 @@ GH_LONG_OPTION_PREFIX: str = "--"
|
|
|
85
91
|
GH_SHORT_OPTION_LENGTH: int = 2
|
|
86
92
|
ALL_GH_API_SHORT_OPTIONS: frozenset[str] = frozenset({"-f", "-F", "-X"})
|
|
87
93
|
GH_OPTION_VALUE_SEPARATOR: str = "="
|
|
94
|
+
PYTHON_OPTION_PREFIX: str = "-"
|
|
95
|
+
PYTHON_SHORT_OPTION_LENGTH: int = 2
|
|
96
|
+
PYTHON_OPTION_AND_VALUE_WORD_COUNT: int = 2
|
|
97
|
+
ALL_PYTHON_SCRIPTLESS_OPTIONS: frozenset[str] = frozenset({"-m", "-c"})
|
|
98
|
+
BACKSLASH: str = "\\"
|
|
99
|
+
DOUBLED_BACKSLASH: str = "\\\\"
|
|
88
100
|
|
|
89
101
|
MCP_TOOL_NAME_PREFIX: str = "mcp__"
|
|
90
102
|
ALL_MCP_CREATE_PULL_REQUEST_TOOL_SUFFIXES: tuple[str, ...] = (
|
|
@@ -159,3 +159,25 @@ UNSEEN_CITATION_MESSAGE = (
|
|
|
159
159
|
)
|
|
160
160
|
|
|
161
161
|
ADDITIONAL_CONTEXT_KEY = "additionalContext"
|
|
162
|
+
ALL_CHECKED_MCP_TOOL_NAMES = frozenset(
|
|
163
|
+
{
|
|
164
|
+
"mcp__github__add_issue_comment",
|
|
165
|
+
"mcp__github__update_issue_comment",
|
|
166
|
+
"mcp__github__add_reply_to_pull_request_comment",
|
|
167
|
+
"mcp__github__add_comment_to_pending_review",
|
|
168
|
+
"mcp__github__pull_request_review_write",
|
|
169
|
+
}
|
|
170
|
+
)
|
|
171
|
+
BASH_TOOL_NAME = "Bash"
|
|
172
|
+
COMMAND_KEY = "command"
|
|
173
|
+
SHELL_OPERATOR_CHARACTERS = frozenset("&|;")
|
|
174
|
+
GH_PROGRAM_NAME = "gh"
|
|
175
|
+
ALL_GH_COMMENT_GROUPS = frozenset({"pr", "issue"})
|
|
176
|
+
ALL_GH_COMMENT_ACTIONS = frozenset({"comment", "review"})
|
|
177
|
+
ALL_GH_BODY_FLAGS = frozenset({"--body", "-b"})
|
|
178
|
+
GH_BODY_FLAG_PREFIX = "--body="
|
|
179
|
+
DETAILS_BLOCK_PATTERN = re.compile(r"<details\b.*?</details>", re.IGNORECASE | re.DOTALL)
|
|
180
|
+
COMMENT_RETRY_INSTRUCTION = (
|
|
181
|
+
" A comment on GitHub holds at most three short sentences."
|
|
182
|
+
" Put detail in the pull request body, a code block, or a collapsed <details> block, then post again."
|
|
183
|
+
)
|