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