claude-dev-env 8.98.6 → 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/reply_length_gate.py +11 -2
- package/hooks/blocking/resolve_thread_reply_gate.py +135 -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/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
|
|
|
@@ -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())
|
|
@@ -1065,3 +1065,14 @@ def test_should_read_gh_comment_body_and_skip_pr_create() -> None:
|
|
|
1065
1065
|
assert reply_length_gate.github_comment_texts("Bash", {"command": flagged_command}) == [f"note=x. {LONG_GITHUB_COMMENT}"]
|
|
1066
1066
|
chained_command = f"git push && {comment_command}"
|
|
1067
1067
|
assert reply_length_gate.github_comment_texts("Bash", {"command": chained_command}) == [LONG_GITHUB_COMMENT]
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
def test_should_report_an_over_long_comment_text_with_the_comment_instruction() -> None:
|
|
1071
|
+
violation = reply_length_gate.comment_length_violation(LONG_GITHUB_COMMENT)
|
|
1072
|
+
assert violation is not None
|
|
1073
|
+
assert violation.endswith(reply_length_gate.COMMENT_RETRY_INSTRUCTION)
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
def test_should_pass_a_comment_text_whose_long_part_sits_in_a_details_block() -> None:
|
|
1077
|
+
comment_text = "Fixed.\n<details><summary>Log</summary>\n" + LONG_GITHUB_COMMENT + "\n</details>"
|
|
1078
|
+
assert reply_length_gate.comment_length_violation(comment_text) is None
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Behavior tests for the gate that holds a review thread resolve until a reply posts."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
HOOKS_DIRECTORY = Path(__file__).resolve().parent.parent
|
|
9
|
+
if str(HOOKS_DIRECTORY) not in sys.path:
|
|
10
|
+
sys.path.insert(0, str(HOOKS_DIRECTORY))
|
|
11
|
+
|
|
12
|
+
from blocking import resolve_thread_reply_gate as gate
|
|
13
|
+
from hooks_constants.resolve_thread_reply_gate_constants import MISSING_REPLY_REASON
|
|
14
|
+
from transcript_skill_scan import TranscriptToolCall
|
|
15
|
+
|
|
16
|
+
GATE_PATH = Path(__file__).resolve().parent / "resolve_thread_reply_gate.py"
|
|
17
|
+
RESOLVE_TOOL_NAME = "mcp__github__resolve_review_thread"
|
|
18
|
+
REPLY_TOOL_NAME = "mcp__github__add_reply_to_pull_request_comment"
|
|
19
|
+
HOOK_DENIAL_TEXT = "PreToolUse:mcp__github__add_reply_to_pull_request_comment hook error: too many sentences"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _prompt(text: str) -> str:
|
|
23
|
+
return json.dumps({"type": "user", "message": {"content": text}})
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _call(tool_use_id: str, tool_name: str, tool_input: dict[str, object] | None = None) -> str:
|
|
27
|
+
tool_use = {"type": "tool_use", "id": tool_use_id, "name": tool_name, "input": tool_input or {}}
|
|
28
|
+
return json.dumps({"type": "assistant", "message": {"content": [tool_use]}})
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _result(tool_use_id: str, is_error: bool = False, text: str = "ok") -> str:
|
|
32
|
+
tool_result = {"type": "tool_result", "tool_use_id": tool_use_id, "is_error": is_error, "content": text}
|
|
33
|
+
return json.dumps({"type": "user", "message": {"content": [tool_result]}})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _write_transcript(tmp_path: Path, all_lines: list[str]) -> Path:
|
|
37
|
+
transcript_path = tmp_path / "session.jsonl"
|
|
38
|
+
transcript_path.write_text("\n".join(all_lines) + "\n", encoding="utf-8")
|
|
39
|
+
return transcript_path
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _resolve_payload(transcript_path: Path) -> dict[str, object]:
|
|
43
|
+
return {
|
|
44
|
+
"hook_event_name": "PreToolUse",
|
|
45
|
+
"tool_name": RESOLVE_TOOL_NAME,
|
|
46
|
+
"tool_input": {"threadId": "PRRT_1"},
|
|
47
|
+
"transcript_path": str(transcript_path),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _deny_reason(decision: dict[str, object] | None) -> str | None:
|
|
52
|
+
if decision is None:
|
|
53
|
+
return None
|
|
54
|
+
hook_output = decision["hookSpecificOutput"]
|
|
55
|
+
assert isinstance(hook_output, dict)
|
|
56
|
+
assert hook_output["permissionDecision"] == "deny"
|
|
57
|
+
return str(hook_output["permissionDecisionReason"])
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def should_allow_a_resolve_after_a_reply_that_posted(tmp_path: Path) -> None:
|
|
61
|
+
transcript_path = _write_transcript(
|
|
62
|
+
tmp_path,
|
|
63
|
+
[
|
|
64
|
+
_prompt("Address the review threads."),
|
|
65
|
+
_call("reply-1", REPLY_TOOL_NAME, {"body": "Fixed in `abc1234`."}),
|
|
66
|
+
_result("reply-1"),
|
|
67
|
+
_call("resolve-1", RESOLVE_TOOL_NAME),
|
|
68
|
+
],
|
|
69
|
+
)
|
|
70
|
+
assert gate.decision_for(_resolve_payload(transcript_path)) is None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def should_deny_a_resolve_after_a_reply_a_hook_refused(tmp_path: Path) -> None:
|
|
74
|
+
transcript_path = _write_transcript(
|
|
75
|
+
tmp_path,
|
|
76
|
+
[
|
|
77
|
+
_prompt("Address the review threads."),
|
|
78
|
+
_call("reply-1", REPLY_TOOL_NAME, {"body": "A long reply."}),
|
|
79
|
+
_result("reply-1", is_error=True, text=HOOK_DENIAL_TEXT),
|
|
80
|
+
_call("resolve-1", RESOLVE_TOOL_NAME),
|
|
81
|
+
],
|
|
82
|
+
)
|
|
83
|
+
assert _deny_reason(gate.decision_for(_resolve_payload(transcript_path))) == MISSING_REPLY_REASON
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def should_deny_a_resolve_with_no_reply(tmp_path: Path) -> None:
|
|
87
|
+
transcript_path = _write_transcript(
|
|
88
|
+
tmp_path,
|
|
89
|
+
[_prompt("Address the review threads."), _call("resolve-1", RESOLVE_TOOL_NAME)],
|
|
90
|
+
)
|
|
91
|
+
assert _deny_reason(gate.decision_for(_resolve_payload(transcript_path))) == MISSING_REPLY_REASON
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def should_deny_a_second_resolve_after_one_reply(tmp_path: Path) -> None:
|
|
95
|
+
transcript_path = _write_transcript(
|
|
96
|
+
tmp_path,
|
|
97
|
+
[
|
|
98
|
+
_prompt("Address the review threads."),
|
|
99
|
+
_call("reply-1", REPLY_TOOL_NAME, {"body": "Fixed in `abc1234`."}),
|
|
100
|
+
_result("reply-1"),
|
|
101
|
+
_call("resolve-1", RESOLVE_TOOL_NAME),
|
|
102
|
+
_result("resolve-1"),
|
|
103
|
+
_call("resolve-2", RESOLVE_TOOL_NAME),
|
|
104
|
+
],
|
|
105
|
+
)
|
|
106
|
+
assert _deny_reason(gate.decision_for(_resolve_payload(transcript_path))) == MISSING_REPLY_REASON
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def should_allow_a_second_resolve_after_two_replies(tmp_path: Path) -> None:
|
|
110
|
+
transcript_path = _write_transcript(
|
|
111
|
+
tmp_path,
|
|
112
|
+
[
|
|
113
|
+
_prompt("Address the review threads."),
|
|
114
|
+
_call("reply-1", REPLY_TOOL_NAME, {"body": "Fixed in `abc1234`."}),
|
|
115
|
+
_result("reply-1"),
|
|
116
|
+
_call("reply-2", REPLY_TOOL_NAME, {"body": "Fixed in `def5678`."}),
|
|
117
|
+
_result("reply-2"),
|
|
118
|
+
_call("resolve-1", RESOLVE_TOOL_NAME),
|
|
119
|
+
_result("resolve-1"),
|
|
120
|
+
_call("resolve-2", RESOLVE_TOOL_NAME),
|
|
121
|
+
],
|
|
122
|
+
)
|
|
123
|
+
assert gate.decision_for(_resolve_payload(transcript_path)) is None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def should_deny_a_resolve_whose_reply_came_before_the_last_prompt(tmp_path: Path) -> None:
|
|
127
|
+
transcript_path = _write_transcript(
|
|
128
|
+
tmp_path,
|
|
129
|
+
[
|
|
130
|
+
_prompt("Reply on the thread."),
|
|
131
|
+
_call("reply-1", REPLY_TOOL_NAME, {"body": "Fixed in `abc1234`."}),
|
|
132
|
+
_result("reply-1"),
|
|
133
|
+
_prompt("Now resolve the other thread."),
|
|
134
|
+
_call("resolve-1", RESOLVE_TOOL_NAME),
|
|
135
|
+
],
|
|
136
|
+
)
|
|
137
|
+
assert _deny_reason(gate.decision_for(_resolve_payload(transcript_path))) == MISSING_REPLY_REASON
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def should_allow_a_retry_after_a_resolve_error(tmp_path: Path) -> None:
|
|
141
|
+
transcript_path = _write_transcript(
|
|
142
|
+
tmp_path,
|
|
143
|
+
[
|
|
144
|
+
_prompt("Address the review threads."),
|
|
145
|
+
_call("reply-1", REPLY_TOOL_NAME, {"body": "Fixed in `abc1234`."}),
|
|
146
|
+
_result("reply-1"),
|
|
147
|
+
_call("resolve-1", RESOLVE_TOOL_NAME),
|
|
148
|
+
_result("resolve-1", is_error=True, text="Bad gateway"),
|
|
149
|
+
_call("resolve-2", RESOLVE_TOOL_NAME),
|
|
150
|
+
],
|
|
151
|
+
)
|
|
152
|
+
assert gate.decision_for(_resolve_payload(transcript_path)) is None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def should_allow_a_resolve_when_the_transcript_cannot_be_read(tmp_path: Path) -> None:
|
|
156
|
+
assert gate.decision_for(_resolve_payload(tmp_path / "missing.jsonl")) is None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def should_ignore_tools_other_than_a_resolve(tmp_path: Path) -> None:
|
|
160
|
+
transcript_path = _write_transcript(tmp_path, [_prompt("Reply on the thread.")])
|
|
161
|
+
payload = _resolve_payload(transcript_path) | {"tool_name": REPLY_TOOL_NAME}
|
|
162
|
+
assert gate.decision_for(payload) is None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def should_read_a_resolve_tool_from_any_mcp_server() -> None:
|
|
166
|
+
assert gate.is_resolve_call("mcp__github_enterprise__resolve_review_thread")
|
|
167
|
+
assert not gate.is_resolve_call("mcp__github__resolve_review_threads")
|
|
168
|
+
assert not gate.is_resolve_call(None)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def should_count_a_gh_api_reply_command_as_a_reply() -> None:
|
|
172
|
+
reply_command = "gh api repos/o/r/pulls/7/comments/99/replies -f body=Fixed."
|
|
173
|
+
assert gate.is_reply_call(TranscriptToolCall("Bash", {"command": reply_command}, True))
|
|
174
|
+
graphql_command = "gh api graphql -f query='mutation { addPullRequestReviewThreadReply(input: {}) { comment { id } } }'"
|
|
175
|
+
assert gate.is_reply_call(TranscriptToolCall("Bash", {"command": graphql_command}, True))
|
|
176
|
+
assert gate.is_reply_call(TranscriptToolCall(REPLY_TOOL_NAME, {}, True))
|
|
177
|
+
assert not gate.is_reply_call(TranscriptToolCall("mcp__github__add_issue_comment", {}, True))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def should_report_a_posted_reply_from_transcript_lines() -> None:
|
|
181
|
+
all_lines = [_prompt("Go."), _call("reply-1", REPLY_TOOL_NAME), _result("reply-1")]
|
|
182
|
+
assert gate.has_posted_reply_since_last_resolve(all_lines)
|
|
183
|
+
assert not gate.has_posted_reply_since_last_resolve(all_lines[:2])
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def should_print_the_deny_when_run_as_a_hook(tmp_path: Path) -> None:
|
|
187
|
+
transcript_path = _write_transcript(
|
|
188
|
+
tmp_path,
|
|
189
|
+
[_prompt("Address the review threads."), _call("resolve-1", RESOLVE_TOOL_NAME)],
|
|
190
|
+
)
|
|
191
|
+
completed_process = subprocess.run(
|
|
192
|
+
[sys.executable, str(GATE_PATH)],
|
|
193
|
+
input=json.dumps(_resolve_payload(transcript_path)),
|
|
194
|
+
capture_output=True,
|
|
195
|
+
text=True,
|
|
196
|
+
check=False,
|
|
197
|
+
)
|
|
198
|
+
assert completed_process.returncode == 0
|
|
199
|
+
assert _deny_reason(json.loads(completed_process.stdout)) == MISSING_REPLY_REASON
|
|
@@ -17,6 +17,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
17
17
|
- `blocking/step_note_gate.py` requires a status line before a tool call while its opt-in flag is on.
|
|
18
18
|
- `blocking/verify_before_acting.py` blocks a completed mutating call when the agent's reasoning contains an unchecked claim.
|
|
19
19
|
- `blocking/pr_lifecycle_skill_gate.py` denies a commit, push, pull request action, or merge until the `pr-lifecycle` skill appears in the transcript since the last compaction.
|
|
20
|
+
- `blocking/resolve_thread_reply_gate.py` denies a review thread resolve until the agent's own transcript shows a reply on a thread that posted after the last user prompt and that no successful resolve has spent. Each successful resolve spends one posted reply. A reply is an `add_reply_to_pull_request_comment` call, or a `gh api` call to a comment's `replies` endpoint or to `addPullRequestReviewThreadReply`. A reply whose result is an error, such as a refusal from `blocking/reply_length_gate.py`, does not count. With no readable transcript, the resolve passes.
|
|
20
21
|
- `blocking/verify_before_commit_gate.py` denies a `git commit` in a repository with a `.claude/skills/verify` or `.agents/skills/verify` directory until the `verify` skill loads after the last user prompt, through a Skill call or the `/verify` command in that prompt. It reads the committed paths from the staged diff, the tracked changes for `-a`, and the paths of each `git add` earlier in the same command, and passes a commit whose paths are all docs, images, or test files.
|
|
21
22
|
- `blocking/build_eval_before_eval_write_gate.py` denies a write to an eval file until a Skill call named exactly `claude-api` with args `build-eval`, or the `/claude-api build-eval` command, appears in the transcript.
|
|
22
23
|
- `blocking/merge_review_gate.py` denies an agent's merge until a review pass on the merged head reported zero findings, or ruled out every finding and linked the posted rulings. It reads the receipt `scripts/merge_review_receipt.py` writes, requires the merge, auto-merge or merge-queue entry to be pinned to that head, and skips repositories listed in `CLAUDE_MERGE_REVIEW_EXEMPT_REPOSITORIES`. Its deny reason names the review command `CLAUDE_MERGE_REVIEW_COMMAND` sets. The reason tells the agent to run that command for the first three passes and `/code-review low --fix <number>` for each later pass; the gate itself counts no passes.
|
|
@@ -39,6 +40,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
39
40
|
- `blocking/step_note_gate.py` runs on `PreToolUse`, matcher `*`, timeout `15` seconds in `hooks.json`.
|
|
40
41
|
- `blocking/verify_before_acting.py` runs on `PostToolUse`, matcher `Write|Edit|MultiEdit|NotebookEdit|Agent|Task|apply_patch|Bash|PowerShell|mcp__.*`, timeout `10` seconds in `hooks.json`.
|
|
41
42
|
- `blocking/pr_lifecycle_skill_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request|actions_run_trigger)`, timeout `10` seconds in `hooks.json`.
|
|
43
|
+
- `blocking/resolve_thread_reply_gate.py` runs on `PreToolUse`, matcher `mcp__.*__resolve_review_thread`, timeout `10` seconds in `hooks.json`.
|
|
42
44
|
- `blocking/verify_before_commit_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell`, timeout `30` seconds in `hooks.json`.
|
|
43
45
|
- `blocking/build_eval_before_eval_write_gate.py` runs on `PreToolUse`, matcher `Write|Edit|MultiEdit|NotebookEdit|apply_patch`, timeout `10` seconds in `hooks.json`.
|
|
44
46
|
- `blocking/merge_review_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(merge_pull_request|enable_pr_auto_merge)`, timeout `10` seconds in `hooks.json`.
|
|
@@ -53,6 +55,7 @@ Preconditions:
|
|
|
53
55
|
- Run each command from the repository root with Python and pytest available. The tests create transcript and payload fixtures.
|
|
54
56
|
|
|
55
57
|
- **Reply length.** Input is a reply with four sentences, a plain pull request number, an abbreviation, two sentences with no widget this turn, or widget code with an anchor link. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_reply_length_gate.py -q`. The adjacent test observes a denial and its count, link, or mode reason. Widget code with no flowchart passes with a flowchart reminder in `additionalContext`.
|
|
58
|
+
- **Resolve reply.** Input is a transcript where a thread reply came back refused, or with no reply, or with one reply already spent on a resolve, then a resolve call. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_resolve_thread_reply_gate.py -q`. The adjacent test observes a deny whose reason says to post the reply, confirm it posted, then resolve. A reply that posted before the resolve passes, and an unreadable transcript passes.
|
|
56
59
|
- **Final reply.** Input is a closing reply that says "My guess is one to two hours." or cites a run no tool result shows. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_final_reply_gate.py -q`. The adjacent test observes a `block` decision that quotes the sentence or the citation; a reply that cites a run a tool result shows passes. A reply with a plain-text `D:\Reports\.claude\report.html` is blocked; the same path in backticks or a fenced block passes.
|
|
57
60
|
- **Open tasks.** Input is a session whose task list still has "rotate the deploy key" pending and a closing reply of "Done.". Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_open_tasks_stop_gate.py -q`. The adjacent test observes a `block` decision that names the item; a reply line `Blocked: rotate the deploy key - <what the user must give>` passes. The same file covers an interrupted ask, a continuation plan list that leaves items out, `Done:` and `Dropped:` lines, and a carried item that a later list marks completed.
|
|
58
61
|
- **Ask recorder.** Input is a `UserPromptSubmit` payload with a work prompt, a question, a `<task-notification>` envelope, and a polite request. Run `python -m pytest packages/claude-dev-env/hooks/session/test_open_task_ask_recorder.py -q`. The adjacent test observes `ask-1` and `ask-2` for the work prompt and the request, no ask for the other two, and empty stdout.
|
package/hooks/hooks.json
CHANGED
|
@@ -102,6 +102,16 @@
|
|
|
102
102
|
}
|
|
103
103
|
]
|
|
104
104
|
},
|
|
105
|
+
{
|
|
106
|
+
"matcher": "mcp__.*__resolve_review_thread",
|
|
107
|
+
"hooks": [
|
|
108
|
+
{
|
|
109
|
+
"type": "command",
|
|
110
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/resolve_thread_reply_gate.py",
|
|
111
|
+
"timeout": 10
|
|
112
|
+
}
|
|
113
|
+
]
|
|
114
|
+
},
|
|
105
115
|
{
|
|
106
116
|
"matcher": "Bash|PowerShell",
|
|
107
117
|
"hooks": [
|
|
@@ -9,6 +9,7 @@ __all__ = [
|
|
|
9
9
|
"ARTIFACT_QUICKSTART_ACTION",
|
|
10
10
|
"ARTIFACT_TEMPLATE_PATH",
|
|
11
11
|
"ARTIFACT_TOOL_NAME",
|
|
12
|
+
"PACKAGE_ROOT_DIRECTORY",
|
|
12
13
|
"HTML_PLAN_TEMPLATE_README_PATH",
|
|
13
14
|
"POINTER_TEXT_TEMPLATE",
|
|
14
15
|
"SKILL_NAME_INPUT_KEY",
|
|
@@ -23,7 +24,8 @@ ARTIFACT_QUICKSTART_ACTION = "quickstart"
|
|
|
23
24
|
SKILL_TOOL_NAME = "Skill"
|
|
24
25
|
SKILL_NAME_INPUT_KEY = "skill"
|
|
25
26
|
ALL_ARTIFACT_DESIGN_SKILL_NAMES = frozenset({"artifact-design"})
|
|
26
|
-
|
|
27
|
+
PACKAGE_ROOT_DIRECTORY = Path(__file__).resolve().parents[2]
|
|
28
|
+
TEMPLATES_DIRECTORY = PACKAGE_ROOT_DIRECTORY / "docs" / "templates"
|
|
27
29
|
ARTIFACT_TEMPLATE_PATH = TEMPLATES_DIRECTORY / "artifact-page" / "README.md"
|
|
28
30
|
HTML_PLAN_TEMPLATE_README_PATH = TEMPLATES_DIRECTORY / "html-plan" / "README.md"
|
|
29
31
|
STATUS_FLOWCHART_TEMPLATE_PATH = TEMPLATES_DIRECTORY / "artifact-page" / "status-flowchart.html"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Constants for the gate that holds a review thread resolve until a reply on it posts."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from hooks_constants.reply_length_gate_constants import (
|
|
6
|
+
MAXIMUM_SENTENCE_COUNT,
|
|
7
|
+
MAXIMUM_WORDS_PER_SENTENCE,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
RESOLVE_TOOL_NAME_PATTERN = re.compile(r"^mcp__.+__resolve_review_thread$")
|
|
11
|
+
ALL_REPLY_TOOL_NAME_SUFFIXES = ("__add_reply_to_pull_request_comment",)
|
|
12
|
+
SHELL_COMMAND_KEY = "command"
|
|
13
|
+
SHELL_REPLY_COMMAND_PATTERN = re.compile(
|
|
14
|
+
r"\bgh\s+api\b.*(?:/comments/\d+/replies\b|addPullRequestReviewThreadReply)", re.DOTALL
|
|
15
|
+
)
|
|
16
|
+
MISSING_REPLY_REASON = (
|
|
17
|
+
"This review thread has no reply that posted since the last user prompt and the last resolve."
|
|
18
|
+
" Post the reply with add_reply_to_pull_request_comment, confirm its result came back without"
|
|
19
|
+
" an error, then resolve the thread. A reply that a hook refused did not post; shorten it to"
|
|
20
|
+
f" at most {MAXIMUM_SENTENCE_COUNT} sentences of at most {MAXIMUM_WORDS_PER_SENTENCE} words each,"
|
|
21
|
+
" footer included, and send it again."
|
|
22
|
+
)
|
|
@@ -47,3 +47,15 @@ def test_every_exported_name_resolves_to_a_module_attribute() -> None:
|
|
|
47
47
|
]
|
|
48
48
|
|
|
49
49
|
assert all_missing_names == []
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_every_template_path_sits_under_the_package_root() -> None:
|
|
53
|
+
package_root = artifact_template_pointer_constants.PACKAGE_ROOT_DIRECTORY
|
|
54
|
+
|
|
55
|
+
assert (package_root / "hooks" / "hooks_constants").is_dir()
|
|
56
|
+
for each_path in (
|
|
57
|
+
artifact_template_pointer_constants.ARTIFACT_TEMPLATE_PATH,
|
|
58
|
+
artifact_template_pointer_constants.HTML_PLAN_TEMPLATE_README_PATH,
|
|
59
|
+
artifact_template_pointer_constants.STATUS_FLOWCHART_TEMPLATE_PATH,
|
|
60
|
+
):
|
|
61
|
+
assert each_path.relative_to(package_root).parts[:2] == ("docs", "templates")
|
|
@@ -37,6 +37,7 @@ from hooks_constants.artifact_template_pointer_constants import (
|
|
|
37
37
|
ARTIFACT_TEMPLATE_PATH,
|
|
38
38
|
ARTIFACT_TOOL_NAME,
|
|
39
39
|
HTML_PLAN_TEMPLATE_README_PATH,
|
|
40
|
+
PACKAGE_ROOT_DIRECTORY,
|
|
40
41
|
POINTER_TEXT_TEMPLATE,
|
|
41
42
|
SKILL_NAME_INPUT_KEY,
|
|
42
43
|
SKILL_TOOL_NAME,
|
|
@@ -53,6 +54,28 @@ from hooks_constants.spawn_readiness_hook_constants import (
|
|
|
53
54
|
TOOL_INPUT_KEY,
|
|
54
55
|
TOOL_NAME_KEY,
|
|
55
56
|
)
|
|
57
|
+
from open_task_asks import claude_home
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def installed_template_path(package_template_path: Path) -> Path:
|
|
61
|
+
"""Return the template path that exists where this hook runs.
|
|
62
|
+
|
|
63
|
+
A source checkout keeps ``docs`` beside ``hooks``. An install puts hooks
|
|
64
|
+
under the agents home and docs under the Claude home, so the same relative
|
|
65
|
+
path is read from the Claude home there::
|
|
66
|
+
|
|
67
|
+
<checkout>/packages/claude-dev-env/docs/templates/x.md -> kept
|
|
68
|
+
~/.agents/docs/templates/x.md (missing) -> ~/.claude/docs/templates/x.md
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
package_template_path: A template path under the package root.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
The package path when it exists, else the same relative path under the Claude home.
|
|
75
|
+
"""
|
|
76
|
+
if package_template_path.exists():
|
|
77
|
+
return package_template_path
|
|
78
|
+
return claude_home() / package_template_path.relative_to(PACKAGE_ROOT_DIRECTORY)
|
|
56
79
|
|
|
57
80
|
|
|
58
81
|
def starts_an_artifact_page(tool_name: object, tool_input: object) -> bool:
|
|
@@ -85,9 +108,9 @@ def decide_hook_output(all_hook_fields: dict[str, object]) -> dict[str, object]
|
|
|
85
108
|
HOOK_SPECIFIC_OUTPUT_KEY: {
|
|
86
109
|
HOOK_EVENT_NAME_KEY: PRE_TOOL_USE_EVENT_NAME,
|
|
87
110
|
ADDITIONAL_CONTEXT_KEY: POINTER_TEXT_TEMPLATE.format(
|
|
88
|
-
path_of_status_flowchart=STATUS_FLOWCHART_TEMPLATE_PATH,
|
|
89
|
-
path_of_plan_readme=HTML_PLAN_TEMPLATE_README_PATH,
|
|
90
|
-
path_of_page_readme=ARTIFACT_TEMPLATE_PATH,
|
|
111
|
+
path_of_status_flowchart=installed_template_path(STATUS_FLOWCHART_TEMPLATE_PATH),
|
|
112
|
+
path_of_plan_readme=installed_template_path(HTML_PLAN_TEMPLATE_README_PATH),
|
|
113
|
+
path_of_page_readme=installed_template_path(ARTIFACT_TEMPLATE_PATH),
|
|
91
114
|
),
|
|
92
115
|
}
|
|
93
116
|
}
|
|
@@ -3,6 +3,8 @@ import subprocess
|
|
|
3
3
|
import sys
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
6
8
|
_SESSION_DIR = Path(__file__).resolve().parent
|
|
7
9
|
_HOOKS_ROOT = _SESSION_DIR.parent
|
|
8
10
|
for each_sys_path_entry in (str(_SESSION_DIR), str(_HOOKS_ROOT)):
|
|
@@ -99,3 +101,17 @@ def test_empty_stdin_stays_quiet() -> None:
|
|
|
99
101
|
[sys.executable, str(HOOK_SCRIPT)], input="", capture_output=True, text=True, check=True
|
|
100
102
|
)
|
|
101
103
|
assert completed.stdout == ""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_installed_template_path_should_keep_a_package_path_that_exists() -> None:
|
|
107
|
+
assert artifact_template_pointer.installed_template_path(ARTIFACT_TEMPLATE_PATH) == ARTIFACT_TEMPLATE_PATH
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_installed_template_path_should_read_the_claude_home_when_the_package_path_is_missing(
|
|
111
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
112
|
+
) -> None:
|
|
113
|
+
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path))
|
|
114
|
+
missing_package_path = artifact_template_pointer.PACKAGE_ROOT_DIRECTORY / "docs" / "templates" / "absent" / "README.md"
|
|
115
|
+
assert artifact_template_pointer.installed_template_path(missing_package_path) == (
|
|
116
|
+
tmp_path / "docs" / "templates" / "absent" / "README.md"
|
|
117
|
+
)
|
|
@@ -9,10 +9,12 @@ if str(HOOKS_DIRECTORY) not in sys.path:
|
|
|
9
9
|
sys.path.insert(0, str(HOOKS_DIRECTORY))
|
|
10
10
|
|
|
11
11
|
from transcript_skill_scan import (
|
|
12
|
+
TranscriptToolCall,
|
|
12
13
|
invokes_skill_with_argument,
|
|
13
14
|
is_skill_loaded_after_last_compaction,
|
|
14
15
|
is_skill_loaded_since_last_prompt,
|
|
15
16
|
skill_invocation_status,
|
|
17
|
+
tool_calls_since_last_prompt,
|
|
16
18
|
)
|
|
17
19
|
|
|
18
20
|
|
|
@@ -158,3 +160,31 @@ def test_slash_command_prompt_loads_the_skill_for_its_own_turn() -> None:
|
|
|
158
160
|
assert is_skill_loaded_since_last_prompt([_prompt_entry("First."), _prompt_entry(marker)], ("verify",), (marker,))
|
|
159
161
|
compaction = json.dumps({"subtype": "compact_boundary"})
|
|
160
162
|
assert not is_skill_loaded_since_last_prompt([_prompt_entry(marker), compaction], ("verify",), (marker,))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def should_list_each_tool_call_since_the_last_prompt_with_its_outcome() -> None:
|
|
166
|
+
def _call(tool_use_id: str, name: str) -> str:
|
|
167
|
+
return json.dumps(
|
|
168
|
+
{"type": "assistant", "message": {"content": [{"type": "tool_use", "id": tool_use_id, "name": name, "input": {}}]}}
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def _result(tool_use_id: str, is_error: bool) -> str:
|
|
172
|
+
tool_result = {"type": "tool_result", "tool_use_id": tool_use_id, "is_error": is_error, "content": "x"}
|
|
173
|
+
return json.dumps({"type": "user", "message": {"content": [tool_result]}})
|
|
174
|
+
|
|
175
|
+
lines = [
|
|
176
|
+
json.dumps({"type": "user", "message": {"content": "First."}}),
|
|
177
|
+
_call("old", "Read"),
|
|
178
|
+
_result("old", False),
|
|
179
|
+
json.dumps({"type": "user", "message": {"content": "Second."}}),
|
|
180
|
+
_call("ok", "Bash"),
|
|
181
|
+
_result("ok", False),
|
|
182
|
+
_call("denied", "Edit"),
|
|
183
|
+
_result("denied", True),
|
|
184
|
+
_call("pending", "Write"),
|
|
185
|
+
]
|
|
186
|
+
assert tool_calls_since_last_prompt(lines) == [
|
|
187
|
+
TranscriptToolCall("Bash", {}, True),
|
|
188
|
+
TranscriptToolCall("Edit", {}, False),
|
|
189
|
+
TranscriptToolCall("Write", {}, False),
|
|
190
|
+
]
|
|
@@ -1,23 +1,37 @@
|
|
|
1
|
-
"""Find a skill invocation after the last transcript compaction, or one with a given first argument anywhere.
|
|
1
|
+
"""Find a skill invocation after the last transcript compaction, or one with a given first argument anywhere.
|
|
2
|
+
|
|
3
|
+
It also lists the tool calls made since the last user prompt, each with whether its result came back without an error.
|
|
4
|
+
"""
|
|
2
5
|
|
|
3
6
|
from __future__ import annotations
|
|
4
7
|
|
|
5
8
|
import json
|
|
6
9
|
import re
|
|
7
10
|
from collections.abc import Collection, Iterable, Iterator
|
|
11
|
+
from typing import NamedTuple
|
|
8
12
|
|
|
9
13
|
from hooks_constants.transcript_skill_scan_constants import (
|
|
10
14
|
ASSISTANT_ENTRY_TYPE,
|
|
11
15
|
COMMAND_ARGUMENTS_PATTERN,
|
|
12
16
|
COMPACT_BOUNDARY_SUBTYPE,
|
|
17
|
+
IS_ERROR_FIELD,
|
|
13
18
|
META_ENTRY_FIELD,
|
|
14
19
|
SKILL_TOOL_NAME,
|
|
15
20
|
TOOL_RESULT_BLOCK_TYPE,
|
|
16
21
|
TOOL_USE_BLOCK_TYPE,
|
|
22
|
+
TOOL_USE_ID_FIELD,
|
|
17
23
|
USER_ENTRY_TYPE,
|
|
18
24
|
)
|
|
19
25
|
|
|
20
26
|
|
|
27
|
+
class TranscriptToolCall(NamedTuple):
|
|
28
|
+
"""One tool call in a transcript: its name, its input, and whether its result came back without an error."""
|
|
29
|
+
|
|
30
|
+
name: str
|
|
31
|
+
tool_input: dict[str, object]
|
|
32
|
+
succeeded: bool
|
|
33
|
+
|
|
34
|
+
|
|
21
35
|
def _user_command_text(all_entry_fields: dict[str, object]) -> str | None:
|
|
22
36
|
message = all_entry_fields.get("message")
|
|
23
37
|
if all_entry_fields.get("type") != USER_ENTRY_TYPE or not isinstance(message, dict):
|
|
@@ -225,3 +239,60 @@ def invokes_skill_with_argument(
|
|
|
225
239
|
if all_entry_fields and _invokes_skill_with_argument(all_entry_fields, skill_names, argument_word, command_marker):
|
|
226
240
|
return True
|
|
227
241
|
return False
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _content_blocks(all_entry_fields: dict[str, object], entry_type: str) -> list[dict[str, object]]:
|
|
245
|
+
message = all_entry_fields.get("message")
|
|
246
|
+
if all_entry_fields.get("type") != entry_type or not isinstance(message, dict):
|
|
247
|
+
return []
|
|
248
|
+
content = message.get("content")
|
|
249
|
+
return [each_block for each_block in (content if isinstance(content, list) else []) if isinstance(each_block, dict)]
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _tool_use_calls(all_entry_fields: dict[str, object]) -> list[tuple[object, str, dict[str, object]]]:
|
|
253
|
+
all_tool_use_calls: list[tuple[object, str, dict[str, object]]] = []
|
|
254
|
+
for each_block in _content_blocks(all_entry_fields, ASSISTANT_ENTRY_TYPE):
|
|
255
|
+
tool_name = each_block.get("name")
|
|
256
|
+
tool_input = each_block.get("input")
|
|
257
|
+
if each_block.get("type") == TOOL_USE_BLOCK_TYPE and isinstance(tool_name, str):
|
|
258
|
+
all_tool_use_calls.append((each_block.get("id"), tool_name, tool_input if isinstance(tool_input, dict) else {}))
|
|
259
|
+
return all_tool_use_calls
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _succeeded_result_ids(all_entry_fields: dict[str, object]) -> set[object]:
|
|
263
|
+
return {
|
|
264
|
+
each_block.get(TOOL_USE_ID_FIELD)
|
|
265
|
+
for each_block in _content_blocks(all_entry_fields, USER_ENTRY_TYPE)
|
|
266
|
+
if each_block.get("type") == TOOL_RESULT_BLOCK_TYPE and each_block.get(IS_ERROR_FIELD) is not True
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def tool_calls_since_last_prompt(all_transcript_lines: Iterable[str]) -> list[TranscriptToolCall]:
|
|
271
|
+
"""Return the tool calls after the last user prompt, in order, each marked by whether it succeeded.
|
|
272
|
+
|
|
273
|
+
::
|
|
274
|
+
|
|
275
|
+
prompt, reply call, its result without an error -> [reply succeeded]
|
|
276
|
+
prompt, reply call, its result with is_error -> [reply failed]
|
|
277
|
+
prompt, resolve call with no result yet -> [resolve failed]
|
|
278
|
+
reply call and result, then a new prompt -> []
|
|
279
|
+
|
|
280
|
+
A hook that denies a call leaves an errored result, so a denied call does
|
|
281
|
+
not count as a success. A tool result leaves the turn open.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
all_transcript_lines: JSON transcript entries, one per line.
|
|
285
|
+
"""
|
|
286
|
+
all_calls: list[tuple[object, str, dict[str, object]]] = []
|
|
287
|
+
all_succeeded_ids: set[object] = set()
|
|
288
|
+
all_entries = (each_entry for each_entry in map(_parsed_entry, all_transcript_lines) if each_entry is not None)
|
|
289
|
+
for each_entry_fields in all_entries:
|
|
290
|
+
if _is_user_prompt(each_entry_fields):
|
|
291
|
+
all_calls, all_succeeded_ids = [], set()
|
|
292
|
+
continue
|
|
293
|
+
all_calls.extend(_tool_use_calls(each_entry_fields))
|
|
294
|
+
all_succeeded_ids.update(_succeeded_result_ids(each_entry_fields))
|
|
295
|
+
return [
|
|
296
|
+
TranscriptToolCall(each_name, each_input, each_id is not None and each_id in all_succeeded_ids)
|
|
297
|
+
for each_id, each_name, each_input in all_calls
|
|
298
|
+
]
|
package/package.json
CHANGED
|
@@ -18,6 +18,7 @@ from dev_env_scripts_constants.private_term_constants import (
|
|
|
18
18
|
PRIVATE_TERM_MESSAGE_TEMPLATE,
|
|
19
19
|
)
|
|
20
20
|
from durable_post_lint_config.config.constants import (
|
|
21
|
+
ACTION_GITHUB_MCP_COMMENT,
|
|
21
22
|
ACTION_PR_CREATE,
|
|
22
23
|
ACTION_PR_EDIT,
|
|
23
24
|
ALL_BARE_VOLATILE_PATH_MARKERS,
|
|
@@ -33,6 +34,7 @@ from durable_post_lint_config.config.constants import (
|
|
|
33
34
|
BODY_MUST_NOT_BE_EMPTY_MESSAGE,
|
|
34
35
|
BODY_REQUIRED_MESSAGE,
|
|
35
36
|
CLEAN_EXIT_CODE,
|
|
37
|
+
COMMENT_LENGTH_FINDING_CODE,
|
|
36
38
|
CONTENT_FINDING_EXIT_CODE,
|
|
37
39
|
CONVENTIONAL_TITLE_PATTERN,
|
|
38
40
|
EDIT_INPUT_REQUIRED_MESSAGE,
|
|
@@ -45,6 +47,8 @@ from durable_post_lint_config.config.constants import (
|
|
|
45
47
|
MISSING_HEADING_MESSAGE_TEMPLATE,
|
|
46
48
|
PATH_ANCHOR_CHARACTER,
|
|
47
49
|
PATH_SEGMENT_START_CHARACTERS,
|
|
50
|
+
PROOF_GATE_CREATE_TOOL_NAME,
|
|
51
|
+
PULL_REQUEST_PROOF_FINDING_CODE,
|
|
48
52
|
RELEASE_BRANCH_PREFIX,
|
|
49
53
|
REPOSITORY_OWNER_SEPARATOR,
|
|
50
54
|
REWRITTEN_RELEASE_BODY_CODE,
|
|
@@ -56,6 +60,17 @@ from durable_post_lint_config.config.constants import (
|
|
|
56
60
|
)
|
|
57
61
|
from private_terms import private_term_line_numbers
|
|
58
62
|
|
|
63
|
+
_hooks_directory = str(Path(__file__).resolve().parents[1] / "hooks")
|
|
64
|
+
if _hooks_directory not in sys.path:
|
|
65
|
+
sys.path.insert(0, _hooks_directory)
|
|
66
|
+
|
|
67
|
+
from blocking.pull_request_proof import (
|
|
68
|
+
missing_eval_section_reason,
|
|
69
|
+
missing_existing_work_reason,
|
|
70
|
+
missing_proof_reason,
|
|
71
|
+
)
|
|
72
|
+
from blocking.reply_length_gate import comment_length_violation
|
|
73
|
+
|
|
59
74
|
|
|
60
75
|
class DurablePostUsageError(ValueError):
|
|
61
76
|
"""Report an invalid GitHub post request shape."""
|
|
@@ -145,6 +160,32 @@ def _description_heading_findings(body_text: str) -> list[DurablePostFinding]:
|
|
|
145
160
|
]
|
|
146
161
|
|
|
147
162
|
|
|
163
|
+
def _pull_request_proof_findings(
|
|
164
|
+
title: str | None, body_text: str
|
|
165
|
+
) -> list[DurablePostFinding]:
|
|
166
|
+
create_payload = {
|
|
167
|
+
"tool_name": PROOF_GATE_CREATE_TOOL_NAME,
|
|
168
|
+
"tool_input": {"title": title, "body": body_text},
|
|
169
|
+
}
|
|
170
|
+
all_reasons = (
|
|
171
|
+
missing_proof_reason(create_payload),
|
|
172
|
+
missing_existing_work_reason(create_payload),
|
|
173
|
+
missing_eval_section_reason(create_payload),
|
|
174
|
+
)
|
|
175
|
+
return [
|
|
176
|
+
DurablePostFinding(code=PULL_REQUEST_PROOF_FINDING_CODE, message=each_reason)
|
|
177
|
+
for each_reason in all_reasons
|
|
178
|
+
if each_reason is not None
|
|
179
|
+
]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _comment_length_findings(body_text: str) -> list[DurablePostFinding]:
|
|
183
|
+
violation = comment_length_violation(body_text)
|
|
184
|
+
if violation is None:
|
|
185
|
+
return []
|
|
186
|
+
return [DurablePostFinding(code=COMMENT_LENGTH_FINDING_CODE, message=violation)]
|
|
187
|
+
|
|
188
|
+
|
|
148
189
|
def _contrast_framing_findings(text: str) -> list[DurablePostFinding]:
|
|
149
190
|
all_lines = text.splitlines()
|
|
150
191
|
return [
|
|
@@ -210,6 +251,7 @@ def _body_findings(
|
|
|
210
251
|
action: str,
|
|
211
252
|
body_text: str | None,
|
|
212
253
|
head_branch: str | None = None,
|
|
254
|
+
title: str | None = None,
|
|
213
255
|
) -> list[DurablePostFinding]:
|
|
214
256
|
if body_text is None:
|
|
215
257
|
return []
|
|
@@ -226,6 +268,10 @@ def _body_findings(
|
|
|
226
268
|
else:
|
|
227
269
|
if action in ALL_PR_DESCRIPTION_ACTIONS:
|
|
228
270
|
all_findings.extend(_description_heading_findings(body_text))
|
|
271
|
+
if action == ACTION_PR_CREATE:
|
|
272
|
+
all_findings.extend(_pull_request_proof_findings(title, body_text))
|
|
273
|
+
if action == ACTION_GITHUB_MCP_COMMENT:
|
|
274
|
+
all_findings.extend(_comment_length_findings(body_text))
|
|
229
275
|
all_findings.extend(_contrast_framing_findings(body_text))
|
|
230
276
|
if find_volatile_path_marker(body_text) is not None:
|
|
231
277
|
all_findings.append(
|
|
@@ -263,7 +309,7 @@ def lint_durable_post(
|
|
|
263
309
|
return (
|
|
264
310
|
*_title_findings(title),
|
|
265
311
|
*_private_term_findings(title, repository),
|
|
266
|
-
*_body_findings(action, body_text, head_branch),
|
|
312
|
+
*_body_findings(action, body_text, head_branch, title),
|
|
267
313
|
*_private_term_findings(body_text, repository),
|
|
268
314
|
)
|
|
269
315
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import re
|
|
4
4
|
|
|
5
|
+
ACTION_GITHUB_MCP_COMMENT = "github-mcp-comment"
|
|
5
6
|
ACTION_GITHUB_MCP_POST = "github-mcp-post"
|
|
6
7
|
ACTION_ISSUE_COMMENT = "issue-comment"
|
|
7
8
|
ACTION_ISSUE_CREATE = "issue-create"
|
|
@@ -13,6 +14,7 @@ ACTION_PR_REVIEW = "pr-review"
|
|
|
13
14
|
|
|
14
15
|
ALL_POST_ACTIONS: frozenset[str] = frozenset(
|
|
15
16
|
{
|
|
17
|
+
ACTION_GITHUB_MCP_COMMENT,
|
|
16
18
|
ACTION_GITHUB_MCP_POST,
|
|
17
19
|
ACTION_ISSUE_COMMENT,
|
|
18
20
|
ACTION_ISSUE_CREATE,
|
|
@@ -25,6 +27,7 @@ ALL_POST_ACTIONS: frozenset[str] = frozenset(
|
|
|
25
27
|
)
|
|
26
28
|
ALL_BODY_REQUIRED_ACTIONS: frozenset[str] = frozenset(
|
|
27
29
|
{
|
|
30
|
+
ACTION_GITHUB_MCP_COMMENT,
|
|
28
31
|
ACTION_GITHUB_MCP_POST,
|
|
29
32
|
ACTION_ISSUE_COMMENT,
|
|
30
33
|
ACTION_ISSUE_CREATE,
|
|
@@ -102,3 +105,6 @@ REWRITTEN_RELEASE_BODY_MESSAGE = (
|
|
|
102
105
|
"keep the generated text whole"
|
|
103
106
|
)
|
|
104
107
|
REPOSITORY_OWNER_SEPARATOR = "/"
|
|
108
|
+
PROOF_GATE_CREATE_TOOL_NAME = "mcp__github__create_pull_request"
|
|
109
|
+
PULL_REQUEST_PROOF_FINDING_CODE = "pull-request-proof-section"
|
|
110
|
+
COMMENT_LENGTH_FINDING_CODE = "github-comment-length"
|
|
@@ -144,6 +144,7 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
|
|
|
144
144
|
hooks/blocking/open_tasks_stop_gate.py -> exempt
|
|
145
145
|
hooks/blocking/owned_pr_merge_stop_gate.py -> exempt
|
|
146
146
|
hooks/blocking/session_title_turn_gate.py -> exempt
|
|
147
|
+
hooks/blocking/resolve_thread_reply_gate.py -> exempt
|
|
147
148
|
hooks/blocking/some_new_blocker.py -> flagged
|
|
148
149
|
|
|
149
150
|
The Bash PreToolUse dispatcher sits under ``blocking/`` for layout reasons
|
|
@@ -202,6 +203,10 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
|
|
|
202
203
|
closing reply names no ``Blocked:`` line for it. It decides when a turn may
|
|
203
204
|
end and no code or safety policy.
|
|
204
205
|
|
|
206
|
+
The resolve reply gate denies a review thread resolve until a reply on the
|
|
207
|
+
thread has posted. It decides the order of two review calls and no code or
|
|
208
|
+
safety policy.
|
|
209
|
+
|
|
205
210
|
Args:
|
|
206
211
|
registered_string: One command, path, script, or entrypoint string.
|
|
207
212
|
"""
|
|
@@ -83,6 +83,7 @@ ALL_ACTION_BOUNDARY_EXEMPT_REGISTRATION_PATHS = (
|
|
|
83
83
|
"blocking/open_tasks_stop_gate.py",
|
|
84
84
|
"blocking/owned_pr_merge_stop_gate.py",
|
|
85
85
|
"blocking/session_title_turn_gate.py",
|
|
86
|
+
"blocking/resolve_thread_reply_gate.py",
|
|
86
87
|
)
|
|
87
88
|
ALL_TEST_DIRECTORY_NAMES = frozenset({"tests"})
|
|
88
89
|
ALL_TEST_FILE_PREFIXES = ("test_",)
|
|
@@ -11,6 +11,11 @@ import pytest
|
|
|
11
11
|
|
|
12
12
|
import durable_post_lint
|
|
13
13
|
from dev_env_scripts_constants.private_term_constants import PrivateTermDigest
|
|
14
|
+
from hooks_constants.pull_request_proof_constants import (
|
|
15
|
+
MISSING_EVAL_SECTION_REASON,
|
|
16
|
+
MISSING_EXISTING_WORK_REASON,
|
|
17
|
+
TEST_ONLY_PROOF_REASON,
|
|
18
|
+
)
|
|
14
19
|
|
|
15
20
|
SCRIPT_PATH = Path(__file__).resolve().parent / "durable_post_lint.py"
|
|
16
21
|
VALID_PR_BODY = """## Why
|
|
@@ -32,7 +37,25 @@ Blast radius text.
|
|
|
32
37
|
## Verification
|
|
33
38
|
|
|
34
39
|
Verification text.
|
|
40
|
+
|
|
41
|
+
## Existing work
|
|
42
|
+
|
|
43
|
+
`scripts/durable_post_lint.py:1` holds the post checks.
|
|
44
|
+
|
|
45
|
+
## Proof in practice
|
|
46
|
+
|
|
47
|
+
Ran `python scripts/durable_post_lint.py --action pr-create --body-file body.md` on the new body.
|
|
48
|
+
|
|
49
|
+
## Eval
|
|
50
|
+
|
|
51
|
+
`python scripts/durable_post_lint.py --action pr-create --body-file eval_body.md`
|
|
35
52
|
"""
|
|
53
|
+
OVER_LONG_REPLY_BODY = (
|
|
54
|
+
"Fixed in `abc1234`.\n\n"
|
|
55
|
+
"The parser now rejects an empty header.\n\n"
|
|
56
|
+
"The test covers both cases.\n\n"
|
|
57
|
+
"---\nPosted by the review agent."
|
|
58
|
+
)
|
|
36
59
|
MINIMAL_PR_BODY = """## Why
|
|
37
60
|
|
|
38
61
|
Why text.
|
|
@@ -54,6 +77,7 @@ Verification text.
|
|
|
54
77
|
("issue-edit", None, "Updated issue body."),
|
|
55
78
|
("issue-comment", None, "Issue comment."),
|
|
56
79
|
("github-mcp-post", None, "MCP body."),
|
|
80
|
+
("github-mcp-comment", None, "Reply body."),
|
|
57
81
|
],
|
|
58
82
|
)
|
|
59
83
|
def test_all_actions_accept_valid_inputs(
|
|
@@ -112,6 +136,7 @@ def test_pr_edit_requires_one_edit_field() -> None:
|
|
|
112
136
|
"issue-edit",
|
|
113
137
|
"issue-comment",
|
|
114
138
|
"github-mcp-post",
|
|
139
|
+
"github-mcp-comment",
|
|
115
140
|
],
|
|
116
141
|
)
|
|
117
142
|
def test_body_actions_require_body(action: str) -> None:
|
|
@@ -176,10 +201,9 @@ def test_pr_body_reports_each_missing_heading(missing_heading: str) -> None:
|
|
|
176
201
|
]
|
|
177
202
|
|
|
178
203
|
|
|
179
|
-
|
|
180
|
-
def should_accept_a_body_carrying_only_the_two_required_headings(action: str) -> None:
|
|
204
|
+
def should_accept_an_edit_body_carrying_only_the_two_required_headings() -> None:
|
|
181
205
|
all_findings = durable_post_lint.lint_durable_post(
|
|
182
|
-
action=
|
|
206
|
+
action="pr-edit",
|
|
183
207
|
title="fix(cli): tighten input handling",
|
|
184
208
|
body_text=MINIMAL_PR_BODY,
|
|
185
209
|
)
|
|
@@ -201,6 +225,155 @@ def should_still_accept_a_body_published_under_the_superseded_heading_set() -> N
|
|
|
201
225
|
assert all_findings == ()
|
|
202
226
|
|
|
203
227
|
|
|
228
|
+
def should_report_a_create_body_with_no_existing_work_section() -> None:
|
|
229
|
+
body_text = VALID_PR_BODY.replace(
|
|
230
|
+
"## Existing work\n\n`scripts/durable_post_lint.py:1` holds the post checks.\n\n", ""
|
|
231
|
+
)
|
|
232
|
+
all_findings = durable_post_lint.lint_durable_post(
|
|
233
|
+
action="pr-create",
|
|
234
|
+
title="feat: add durable post linting",
|
|
235
|
+
body_text=body_text,
|
|
236
|
+
)
|
|
237
|
+
assert [each_finding.message for each_finding in all_findings] == [
|
|
238
|
+
MISSING_EXISTING_WORK_REASON
|
|
239
|
+
]
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def should_report_a_create_body_whose_proof_runs_only_tests() -> None:
|
|
243
|
+
body_text = VALID_PR_BODY.replace(
|
|
244
|
+
"Ran `python scripts/durable_post_lint.py --action pr-create --body-file body.md` on the new body.",
|
|
245
|
+
"Ran `python -m pytest scripts`.",
|
|
246
|
+
)
|
|
247
|
+
all_findings = durable_post_lint.lint_durable_post(
|
|
248
|
+
action="pr-create",
|
|
249
|
+
title="fix: tighten durable post linting",
|
|
250
|
+
body_text=body_text,
|
|
251
|
+
)
|
|
252
|
+
assert [each_finding.message for each_finding in all_findings] == [
|
|
253
|
+
TEST_ONLY_PROOF_REASON
|
|
254
|
+
]
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def should_report_a_feature_create_body_with_no_eval_section() -> None:
|
|
258
|
+
body_text = VALID_PR_BODY.split("## Eval")[0]
|
|
259
|
+
all_findings = durable_post_lint.lint_durable_post(
|
|
260
|
+
action="pr-create",
|
|
261
|
+
title="feat: add durable post linting",
|
|
262
|
+
body_text=body_text,
|
|
263
|
+
)
|
|
264
|
+
assert [each_finding.message for each_finding in all_findings] == [
|
|
265
|
+
MISSING_EVAL_SECTION_REASON
|
|
266
|
+
]
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def should_leave_an_edit_body_with_no_existing_work_section_clean() -> None:
|
|
270
|
+
all_findings = durable_post_lint.lint_durable_post(
|
|
271
|
+
action="pr-edit",
|
|
272
|
+
title=None,
|
|
273
|
+
body_text=MINIMAL_PR_BODY,
|
|
274
|
+
)
|
|
275
|
+
assert all_findings == ()
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def should_exit_with_findings_for_a_create_body_with_no_existing_work_section(
|
|
279
|
+
tmp_path: Path,
|
|
280
|
+
) -> None:
|
|
281
|
+
body_file = tmp_path / "body.md"
|
|
282
|
+
body_file.write_text(
|
|
283
|
+
VALID_PR_BODY.replace("## Existing work", "## Prior art"), encoding="utf-8"
|
|
284
|
+
)
|
|
285
|
+
completed_process = subprocess.run(
|
|
286
|
+
[
|
|
287
|
+
sys.executable,
|
|
288
|
+
str(SCRIPT_PATH),
|
|
289
|
+
"--action",
|
|
290
|
+
"pr-create",
|
|
291
|
+
"--title",
|
|
292
|
+
"feat: add durable post linting",
|
|
293
|
+
"--body-file",
|
|
294
|
+
str(body_file),
|
|
295
|
+
],
|
|
296
|
+
check=False,
|
|
297
|
+
capture_output=True,
|
|
298
|
+
text=True,
|
|
299
|
+
)
|
|
300
|
+
assert completed_process.returncode == 1
|
|
301
|
+
assert completed_process.stderr == f"{MISSING_EXISTING_WORK_REASON}\n"
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def should_report_a_review_reply_over_three_sentences() -> None:
|
|
305
|
+
all_findings = durable_post_lint.lint_durable_post(
|
|
306
|
+
action="github-mcp-comment",
|
|
307
|
+
title=None,
|
|
308
|
+
body_text=OVER_LONG_REPLY_BODY,
|
|
309
|
+
)
|
|
310
|
+
assert [each_finding.code for each_finding in all_findings] == [
|
|
311
|
+
"github-comment-length"
|
|
312
|
+
]
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def should_report_a_review_reply_sentence_over_fifteen_words() -> None:
|
|
316
|
+
all_findings = durable_post_lint.lint_durable_post(
|
|
317
|
+
action="github-mcp-comment",
|
|
318
|
+
title=None,
|
|
319
|
+
body_text=(
|
|
320
|
+
"This change moves the parser into its own module and keeps every"
|
|
321
|
+
" caller on the same import path."
|
|
322
|
+
),
|
|
323
|
+
)
|
|
324
|
+
assert [each_finding.code for each_finding in all_findings] == [
|
|
325
|
+
"github-comment-length"
|
|
326
|
+
]
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def should_accept_a_short_review_reply() -> None:
|
|
330
|
+
all_findings = durable_post_lint.lint_durable_post(
|
|
331
|
+
action="github-mcp-comment",
|
|
332
|
+
title=None,
|
|
333
|
+
body_text="Fixed in `abc1234`.\n\nThe parser now rejects an empty header.",
|
|
334
|
+
)
|
|
335
|
+
assert all_findings == ()
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def should_exit_with_findings_for_an_over_long_review_reply(tmp_path: Path) -> None:
|
|
339
|
+
body_file = tmp_path / "reply.md"
|
|
340
|
+
body_file.write_text(OVER_LONG_REPLY_BODY, encoding="utf-8")
|
|
341
|
+
completed_process = subprocess.run(
|
|
342
|
+
[
|
|
343
|
+
sys.executable,
|
|
344
|
+
str(SCRIPT_PATH),
|
|
345
|
+
"--action",
|
|
346
|
+
"github-mcp-comment",
|
|
347
|
+
"--body-file",
|
|
348
|
+
str(body_file),
|
|
349
|
+
],
|
|
350
|
+
check=False,
|
|
351
|
+
capture_output=True,
|
|
352
|
+
text=True,
|
|
353
|
+
)
|
|
354
|
+
assert completed_process.returncode == 1
|
|
355
|
+
assert "at most three short sentences" in completed_process.stderr
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def should_exit_clean_for_a_short_review_reply(tmp_path: Path) -> None:
|
|
359
|
+
body_file = tmp_path / "reply.md"
|
|
360
|
+
body_file.write_text("Fixed in `abc1234`.\n", encoding="utf-8")
|
|
361
|
+
completed_process = subprocess.run(
|
|
362
|
+
[
|
|
363
|
+
sys.executable,
|
|
364
|
+
str(SCRIPT_PATH),
|
|
365
|
+
"--action",
|
|
366
|
+
"github-mcp-comment",
|
|
367
|
+
"--body-file",
|
|
368
|
+
str(body_file),
|
|
369
|
+
],
|
|
370
|
+
check=False,
|
|
371
|
+
capture_output=True,
|
|
372
|
+
text=True,
|
|
373
|
+
)
|
|
374
|
+
assert completed_process.returncode == 0
|
|
375
|
+
|
|
376
|
+
|
|
204
377
|
def test_non_pr_body_needs_no_pr_headings() -> None:
|
|
205
378
|
all_findings = durable_post_lint.lint_durable_post(
|
|
206
379
|
action="issue-comment",
|
|
@@ -58,6 +58,14 @@ def test_pull_request_lifecycle_gate_registration_is_clean(tmp_path: Path) -> No
|
|
|
58
58
|
assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
|
|
59
59
|
|
|
60
60
|
|
|
61
|
+
def test_new_resolve_thread_reply_gate_registration_is_clean(tmp_path: Path) -> None:
|
|
62
|
+
current_document = _hook_document(
|
|
63
|
+
["hooks/blocking/resolve_thread_reply_gate.py"],
|
|
64
|
+
'{"hooks": {}}',
|
|
65
|
+
)
|
|
66
|
+
assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
|
|
67
|
+
|
|
68
|
+
|
|
61
69
|
def test_new_merge_offer_gate_registration_is_clean(tmp_path: Path) -> None:
|
|
62
70
|
current_document = _hook_document(
|
|
63
71
|
["hooks/blocking/merge_over_review_offer_gate.py"],
|