claude-dev-env 8.92.3 → 8.93.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -77,9 +77,11 @@ later pass, so this gate denies it and the model resends a shorter one.
77
77
 
78
78
  from __future__ import annotations
79
79
 
80
+ import itertools
80
81
  import json
81
82
  import os
82
83
  import re
84
+ import shlex
83
85
  import sys
84
86
  from pathlib import Path
85
87
 
@@ -87,6 +89,7 @@ hooks_root_directory = str(Path(__file__).resolve().parent.parent)
87
89
  if hooks_root_directory not in sys.path:
88
90
  sys.path.insert(0, hooks_root_directory)
89
91
 
92
+ from gh_post_body_texts import extract_mcp_body_texts
90
93
  from json_file_reader import read_json_object
91
94
  from visual_reply_rules import (
92
95
  VisualReplyRules,
@@ -106,6 +109,17 @@ from hooks_constants.hook_specific_output_keys import (
106
109
  HOOK_SPECIFIC_OUTPUT_KEY,
107
110
  )
108
111
  from hooks_constants.reply_length_gate_constants import (
112
+ ALL_CHECKED_MCP_TOOL_NAMES,
113
+ BASH_TOOL_NAME,
114
+ COMMAND_KEY,
115
+ COMMENT_RETRY_INSTRUCTION,
116
+ DETAILS_BLOCK_PATTERN,
117
+ ALL_GH_BODY_FLAGS,
118
+ ALL_GH_COMMENT_ACTIONS,
119
+ ALL_GH_COMMENT_GROUPS,
120
+ GH_BODY_FLAG_PREFIX,
121
+ GH_PROGRAM_NAME,
122
+ SHELL_OPERATOR_CHARACTERS,
109
123
  ALL_CHECKED_TOOL_NAMES,
110
124
  ALL_DECISION_CARD_LENGTH_KEYS,
111
125
  ALL_DECISION_CARD_PROSE_KEYS,
@@ -482,6 +496,81 @@ def tool_violation(
482
496
  return None if violation is None else (violation, reply_text)
483
497
 
484
498
 
499
+ def github_comment_texts(tool_name: object, all_tool_input: dict[str, object]) -> list[str]:
500
+ """Return the GitHub comment texts one call would post, or an empty list.
501
+
502
+ ::
503
+
504
+ mcp__github__add_issue_comment {"body": "Done."} -> ["Done."]
505
+ Bash 'gh pr comment 12 --body "Done."' -> ["Done."]
506
+ Bash 'git push && gh pr comment 12 --body "Done."' -> ["Done."]
507
+ Bash 'gh pr create --body "Long body"' -> []
508
+ """
509
+ if tool_name in ALL_CHECKED_MCP_TOOL_NAMES:
510
+ return extract_mcp_body_texts(all_tool_input)
511
+ command = all_tool_input.get(COMMAND_KEY)
512
+ if tool_name != BASH_TOOL_NAME or not isinstance(command, str):
513
+ return []
514
+ try:
515
+ command_lexer = shlex.shlex(command, posix=True, punctuation_chars=True)
516
+ command_lexer.whitespace_split = True
517
+ all_command_tokens = list(command_lexer)
518
+ except ValueError:
519
+ return []
520
+ return gh_comment_bodies(all_command_tokens)
521
+
522
+
523
+ def gh_comment_bodies(all_command_tokens: list[str]) -> list[str]:
524
+ """Return the ``--body`` values of each ``gh pr|issue comment|review`` call in the tokens.
525
+
526
+ ::
527
+
528
+ git push && gh -R o/r pr comment 12 --body "Done." -> ["Done."]
529
+ gh pr create --body "Long body" -> []
530
+ """
531
+ all_bodies: list[str] = []
532
+ for each_command_tokens in gh_command_token_runs(all_command_tokens):
533
+ all_pairs = list(itertools.pairwise(each_command_tokens))
534
+ if not any(
535
+ each_group in ALL_GH_COMMENT_GROUPS and each_action in ALL_GH_COMMENT_ACTIONS
536
+ for each_group, each_action in all_pairs
537
+ ):
538
+ continue
539
+ all_bodies.extend(
540
+ each_token[len(GH_BODY_FLAG_PREFIX):]
541
+ for each_token in each_command_tokens
542
+ if each_token.startswith(GH_BODY_FLAG_PREFIX)
543
+ )
544
+ all_bodies.extend(each_value for each_flag, each_value in all_pairs if each_flag in ALL_GH_BODY_FLAGS)
545
+ return all_bodies
546
+
547
+
548
+ def is_command_token(token: str) -> bool:
549
+ """Return True when the token is part of a command, False for ``&&``, ``||``, ``;`` or ``|``."""
550
+ return not set(token) <= SHELL_OPERATOR_CHARACTERS
551
+
552
+
553
+ def gh_command_token_runs(all_command_tokens: list[str]) -> list[list[str]]:
554
+ """Return the tokens after each ``gh`` token, up to the next shell operator."""
555
+ all_runs: list[list[str]] = []
556
+ for each_index, each_token in enumerate(all_command_tokens):
557
+ if each_token == GH_PROGRAM_NAME:
558
+ all_runs.append(list(itertools.takewhile(is_command_token, all_command_tokens[each_index + 1:])))
559
+ return all_runs
560
+
561
+
562
+ def github_comment_violation(tool_name: object, all_tool_input: dict[str, object]) -> tuple[str, str] | None:
563
+ """Return the deny reason and text for an over-long GitHub comment, or None.
564
+
565
+ Fenced code blocks and collapsed ``<details>`` blocks carry no words.
566
+ """
567
+ for each_comment_text in github_comment_texts(tool_name, all_tool_input):
568
+ violation = length_violation(DETAILS_BLOCK_PATTERN.sub(" ", each_comment_text))
569
+ if violation is not None:
570
+ return violation + COMMENT_RETRY_INSTRUCTION, each_comment_text
571
+ return None
572
+
573
+
485
574
  def write_flowchart_advice(tool_name: object, all_tool_input: dict[str, object]) -> None:
486
575
  """Print the flowchart advice for an allowed widget call that is not a flowchart."""
487
576
  mode_rules = active_mode_rules()
@@ -502,7 +591,9 @@ def main() -> int:
502
591
  tool_input = hook_input.get(TOOL_INPUT_KEY)
503
592
  if not isinstance(tool_input, dict):
504
593
  return ALLOW_EXIT_CODE
505
- checked = tool_violation(tool_name, tool_input, hook_input.get(TRANSCRIPT_PATH_KEY))
594
+ checked = github_comment_violation(tool_name, tool_input) or tool_violation(
595
+ tool_name, tool_input, hook_input.get(TRANSCRIPT_PATH_KEY)
596
+ )
506
597
  if checked is None:
507
598
  write_flowchart_advice(tool_name, tool_input)
508
599
  return ALLOW_EXIT_CODE
@@ -1022,3 +1022,46 @@ def test_should_leave_the_transcript_unread_when_the_text_cites_nothing(
1022
1022
  def test_should_split_prose_sentences_outside_fenced_blocks() -> None:
1023
1023
  reply_text = "First one. Second one?\n```\nskipped. code\n```\nThird"
1024
1024
  assert reply_length_gate.prose_sentences(reply_text) == ["First one.", "Second one?", "", "Third"]
1025
+
1026
+
1027
+ LONG_GITHUB_COMMENT = " ".join(
1028
+ "Sentence number one of a long comment that keeps going." for _ in range(50)
1029
+ )
1030
+ SHORT_GITHUB_COMMENT = "Fixed the cap check. The test now covers it. Checks pass."
1031
+
1032
+
1033
+ def test_should_deny_long_github_comment(
1034
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
1035
+ ) -> None:
1036
+ exit_code, error_text = run_gate(
1037
+ monkeypatch, capsys, "mcp__github__add_issue_comment", {"body": LONG_GITHUB_COMMENT}
1038
+ )
1039
+ assert exit_code == 2
1040
+ assert "50 sentences, limit 3" in error_text
1041
+
1042
+
1043
+ def test_should_allow_short_github_comment_with_code_and_details(
1044
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
1045
+ ) -> None:
1046
+ comment_text = (
1047
+ SHORT_GITHUB_COMMENT
1048
+ + "\n```\n" + LONG_GITHUB_COMMENT + "\n```\n"
1049
+ + "<details><summary>Log</summary>\n" + LONG_GITHUB_COMMENT + "\n</details>"
1050
+ )
1051
+ exit_code, error_text = run_gate(
1052
+ monkeypatch, capsys, "mcp__github__add_issue_comment", {"body": comment_text}
1053
+ )
1054
+ assert (exit_code, error_text) == (0, "")
1055
+
1056
+
1057
+ def test_should_read_gh_comment_body_and_skip_pr_create() -> None:
1058
+ comment_command = f'gh pr comment 12 --body "{LONG_GITHUB_COMMENT}"'
1059
+ create_command = f'gh pr create --body "Run gh pr comment 12 to reply. {LONG_GITHUB_COMMENT}"'
1060
+ assert reply_length_gate.github_comment_texts("Bash", {"command": comment_command}) == [LONG_GITHUB_COMMENT]
1061
+ assert reply_length_gate.github_comment_texts("Bash", {"command": create_command}) == []
1062
+ multiline_command = 'gh pr comment 12 --body "' + LONG_GITHUB_COMMENT.replace(". ", ".\n") + '"'
1063
+ assert len(reply_length_gate.github_comment_texts("Bash", {"command": multiline_command})) == 1
1064
+ flagged_command = f'git push\ngh -R o/r pr comment 12 --body "note=x. {LONG_GITHUB_COMMENT}"'
1065
+ assert reply_length_gate.github_comment_texts("Bash", {"command": flagged_command}) == [f"note=x. {LONG_GITHUB_COMMENT}"]
1066
+ chained_command = f"git push && {comment_command}"
1067
+ assert reply_length_gate.github_comment_texts("Bash", {"command": chained_command}) == [LONG_GITHUB_COMMENT]
package/hooks/hooks.json CHANGED
@@ -273,6 +273,16 @@
273
273
  "timeout": 15
274
274
  }
275
275
  ]
276
+ },
277
+ {
278
+ "matcher": "Bash|mcp__github__add_issue_comment|mcp__github__update_issue_comment|mcp__github__add_reply_to_pull_request_comment|mcp__github__add_comment_to_pending_review|mcp__github__pull_request_review_write",
279
+ "hooks": [
280
+ {
281
+ "type": "command",
282
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/reply_length_gate.py",
283
+ "timeout": 10
284
+ }
285
+ ]
276
286
  }
277
287
  ],
278
288
  "UserPromptSubmit": [
@@ -159,3 +159,25 @@ UNSEEN_CITATION_MESSAGE = (
159
159
  )
160
160
 
161
161
  ADDITIONAL_CONTEXT_KEY = "additionalContext"
162
+ ALL_CHECKED_MCP_TOOL_NAMES = frozenset(
163
+ {
164
+ "mcp__github__add_issue_comment",
165
+ "mcp__github__update_issue_comment",
166
+ "mcp__github__add_reply_to_pull_request_comment",
167
+ "mcp__github__add_comment_to_pending_review",
168
+ "mcp__github__pull_request_review_write",
169
+ }
170
+ )
171
+ BASH_TOOL_NAME = "Bash"
172
+ COMMAND_KEY = "command"
173
+ SHELL_OPERATOR_CHARACTERS = frozenset("&|;")
174
+ GH_PROGRAM_NAME = "gh"
175
+ ALL_GH_COMMENT_GROUPS = frozenset({"pr", "issue"})
176
+ ALL_GH_COMMENT_ACTIONS = frozenset({"comment", "review"})
177
+ ALL_GH_BODY_FLAGS = frozenset({"--body", "-b"})
178
+ GH_BODY_FLAG_PREFIX = "--body="
179
+ DETAILS_BLOCK_PATTERN = re.compile(r"<details\b.*?</details>", re.IGNORECASE | re.DOTALL)
180
+ COMMENT_RETRY_INSTRUCTION = (
181
+ " A comment on GitHub holds at most three short sentences."
182
+ " Put detail in the pull request body, a code block, or a collapsed <details> block, then post again."
183
+ )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.92.3",
3
+ "version": "8.93.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {