claude-dev-env 8.98.4 → 8.98.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/.agents/skills/pr-lifecycle/SKILL.md +4 -4
  2. package/hooks/blocking/open_tasks_stop_gate.py +62 -9
  3. package/hooks/blocking/open_tasks_transcript.py +71 -3
  4. package/hooks/blocking/pr_lifecycle_skill_gate.py +36 -10
  5. package/hooks/blocking/reply_length_gate.py +11 -2
  6. package/hooks/blocking/resolve_thread_reply_gate.py +135 -0
  7. package/hooks/blocking/test_open_tasks_stop_gate.py +135 -2
  8. package/hooks/blocking/test_pr_lifecycle_skill_gate.py +5 -0
  9. package/hooks/blocking/test_reply_length_gate.py +11 -0
  10. package/hooks/blocking/test_resolve_thread_reply_gate.py +199 -0
  11. package/hooks/features/conduct-gates.md +3 -0
  12. package/hooks/hooks.json +10 -0
  13. package/hooks/hooks_constants/artifact_template_pointer_constants.py +3 -1
  14. package/hooks/hooks_constants/open_tasks_stop_gate_constants.py +24 -0
  15. package/hooks/hooks_constants/pr_lifecycle_skill_gate_constants.py +10 -1
  16. package/hooks/hooks_constants/resolve_thread_reply_gate_constants.py +22 -0
  17. package/hooks/hooks_constants/test_artifact_template_pointer_constants.py +12 -0
  18. package/hooks/hooks_constants/transcript_skill_scan_constants.py +2 -0
  19. package/hooks/session/artifact_template_pointer.py +26 -3
  20. package/hooks/session/test_artifact_template_pointer.py +16 -0
  21. package/hooks/test_transcript_skill_scan.py +30 -0
  22. package/hooks/transcript_skill_scan.py +72 -1
  23. package/package.json +1 -1
  24. package/scripts/durable_post_lint.py +47 -1
  25. package/scripts/durable_post_lint_config/config/constants.py +6 -0
  26. package/scripts/durable_post_lint_config/config/test_constants.py +1 -0
  27. package/scripts/policy_lint/adapter_configuration.py +5 -0
  28. package/scripts/policy_lint/config/constants.py +1 -0
  29. package/scripts/test_durable_post_lint.py +176 -3
  30. package/scripts/tests/test_adapter_configuration.py +8 -0
@@ -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.4",
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"],