claude-dev-env 8.43.6 → 8.44.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.
@@ -18,6 +18,10 @@ It denies a banned word or phrase, matched whole and case-insensitive::
18
18
  The gate uses the ``banned_words`` list in ``~/.claude/reply-banned-words.json``,
19
19
  or in the file that CLAUDE_REPLY_BANNED_WORDS_PATH names. Without a valid
20
20
  list there, it uses ALL_DEFAULT_BANNED_WORDS.
21
+
22
+ The banned-word check also reads every text field of a decision card.
23
+ A decision card gets no length check.
24
+
21
25
  Each non-empty line counts as its own sentence, so a list counts one
22
26
  sentence per item. URLs, markdown link targets, inline code spans, and
23
27
  fenced blocks carry no words.
@@ -52,8 +56,10 @@ from hooks_constants.reply_length_gate_constants import (
52
56
  BANNED_WORDS_JSON_KEY,
53
57
  BANNED_WORDS_PATH_ENV_VAR,
54
58
  BLOCK_EXIT_CODE,
59
+ CARD_TEXT_SEPARATOR,
55
60
  CLAUDE_HOME_DIRECTORY_NAME,
56
61
  CONFIG_FILE_ENCODING,
62
+ DECISION_CARD_TOOL_NAME,
57
63
  FENCED_BLOCK_PATTERN,
58
64
  HOOK_EVENT_NAME,
59
65
  INLINE_CODE_PATTERN,
@@ -163,26 +169,52 @@ def banned_word_violation(reply_text: str, all_banned_words: tuple[str, ...]) ->
163
169
  return None
164
170
 
165
171
 
172
+ def all_card_texts(card_value: object) -> list[str]:
173
+ """Collect every string inside a decision card's input, in order."""
174
+ if isinstance(card_value, str):
175
+ return [card_value]
176
+ if isinstance(card_value, dict):
177
+ return [
178
+ each_text
179
+ for each_value in card_value.values()
180
+ for each_text in all_card_texts(each_value)
181
+ ]
182
+ if isinstance(card_value, list):
183
+ return [each_text for each_value in card_value for each_text in all_card_texts(each_value)]
184
+ return []
185
+
186
+
187
+ def tool_violation(tool_name: object, all_tool_input: dict[str, object]) -> tuple[str, str] | None:
188
+ """Return the deny reason and the checked text for one call, or None when it passes."""
189
+ if tool_name == DECISION_CARD_TOOL_NAME:
190
+ card_text = CARD_TEXT_SEPARATOR.join(all_card_texts(all_tool_input))
191
+ card_violation = banned_word_violation(card_text, configured_banned_words())
192
+ return None if card_violation is None else (card_violation, card_text)
193
+ if tool_name not in ALL_CHECKED_TOOL_NAMES:
194
+ return None
195
+ reply_text = all_tool_input.get(TEXT_KEY)
196
+ if not isinstance(reply_text, str):
197
+ return None
198
+ violation = (
199
+ length_violation(reply_text)
200
+ or unlinked_pull_request_violation(reply_text)
201
+ or banned_word_violation(reply_text, configured_banned_words())
202
+ )
203
+ return None if violation is None else (violation, reply_text)
204
+
205
+
166
206
  def main() -> int:
167
207
  hook_input = read_hook_input_dictionary_from_stdin()
168
208
  if hook_input is None:
169
209
  return ALLOW_EXIT_CODE
170
210
  tool_name = hook_input.get(TOOL_NAME_KEY)
171
- if tool_name not in ALL_CHECKED_TOOL_NAMES:
172
- return ALLOW_EXIT_CODE
173
211
  tool_input = hook_input.get(TOOL_INPUT_KEY)
174
212
  if not isinstance(tool_input, dict):
175
213
  return ALLOW_EXIT_CODE
176
- reply_text = tool_input.get(TEXT_KEY)
177
- if not isinstance(reply_text, str):
178
- return ALLOW_EXIT_CODE
179
- violation = (
180
- length_violation(reply_text)
181
- or unlinked_pull_request_violation(reply_text)
182
- or banned_word_violation(reply_text, configured_banned_words())
183
- )
184
- if violation is None:
214
+ checked = tool_violation(tool_name, tool_input)
215
+ if checked is None:
185
216
  return ALLOW_EXIT_CODE
217
+ violation, reply_text = checked
186
218
  block_reason = violation + RETRY_INSTRUCTION
187
219
  log_hook_block(
188
220
  Path(__file__).name,
@@ -322,3 +322,58 @@ def test_should_keep_the_defaults_when_the_config_file_is_malformed(
322
322
  config_path.write_text("{not json", encoding="utf-8")
323
323
  exit_code, _ = run_gate(monkeypatch, capsys, REPLY_TOOL_NAME, {"text": "It likely passed."})
324
324
  assert exit_code == 2
325
+
326
+
327
+ DECISION_TOOL_NAME = "mcp__hearthbot__ask_decision"
328
+
329
+
330
+ def test_should_deny_a_decision_card_that_hedges_in_an_option(
331
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
332
+ ) -> None:
333
+ exit_code, stderr_text = run_gate(
334
+ monkeypatch,
335
+ capsys,
336
+ DECISION_TOOL_NAME,
337
+ {
338
+ "question": "Exclude the call button?",
339
+ "context": "Crops show the call button flagged in 7 themes.",
340
+ "options": [
341
+ {"label": "Exclude it", "consequence": "Three themes probably pass."},
342
+ {"label": "Leave it", "consequence": "Three themes keep a warning."},
343
+ ],
344
+ "recommended": 0,
345
+ },
346
+ )
347
+ assert exit_code == 2
348
+ assert '"probably"' in stderr_text
349
+
350
+
351
+ def test_should_allow_a_long_decision_card_with_no_hedge(
352
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
353
+ ) -> None:
354
+ exit_code, stderr_text = run_gate(
355
+ monkeypatch,
356
+ capsys,
357
+ DECISION_TOOL_NAME,
358
+ {
359
+ "question": "Exclude the call button?",
360
+ "context": " ".join([FIFTEEN_WORD_SENTENCE] * 5),
361
+ "options": [
362
+ {"label": "Exclude it", "consequence": SIXTEEN_WORD_SENTENCE},
363
+ {"label": "Leave it", "consequence": "Three themes keep a warning."},
364
+ ],
365
+ "recommended": 0,
366
+ },
367
+ )
368
+ assert (exit_code, stderr_text) == (0, "")
369
+
370
+
371
+ @pytest.mark.parametrize(
372
+ "reply_text",
373
+ ["It maybe passed.", "Perhaps it passed.", "It might be the cache.", "I am not sure it passed."],
374
+ )
375
+ def test_should_deny_the_hedges_the_default_list_adds(
376
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], reply_text: str
377
+ ) -> None:
378
+ exit_code, _ = run_gate(monkeypatch, capsys, REPLY_TOOL_NAME, {"text": reply_text})
379
+ assert exit_code == 2
package/hooks/hooks.json CHANGED
@@ -83,7 +83,7 @@
83
83
  ]
84
84
  },
85
85
  {
86
- "matcher": "mcp__hearthbot__reply|mcp__hearthbot__post_message",
86
+ "matcher": "mcp__hearthbot__reply|mcp__hearthbot__post_message|mcp__hearthbot__ask_decision",
87
87
  "hooks": [
88
88
  {
89
89
  "type": "command",
@@ -5,6 +5,8 @@ import re
5
5
  MAXIMUM_SENTENCE_COUNT = 3
6
6
  MAXIMUM_WORDS_PER_SENTENCE = 15
7
7
  ALL_CHECKED_TOOL_NAMES = frozenset({"mcp__hearthbot__reply", "mcp__hearthbot__post_message"})
8
+ DECISION_CARD_TOOL_NAME = "mcp__hearthbot__ask_decision"
9
+ CARD_TEXT_SEPARATOR = "\n"
8
10
  TOOL_NAME_KEY = "tool_name"
9
11
  TOOL_INPUT_KEY = "tool_input"
10
12
  TEXT_KEY = "text"
@@ -45,6 +47,15 @@ ALL_DEFAULT_BANNED_WORDS = (
45
47
  "suspect",
46
48
  "guess",
47
49
  "my theory",
50
+ "maybe",
51
+ "perhaps",
52
+ "presumably",
53
+ "might be",
54
+ "may be",
55
+ "appears to",
56
+ "i believe",
57
+ "not sure",
58
+ "unsure",
48
59
  )
49
60
  BANNED_WORD_PART_SEPARATOR = r"\s+"
50
61
  BANNED_WORD_PATTERN_TEMPLATE = r"(?<![A-Za-z0-9]){word_pattern}(?![A-Za-z0-9])"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.43.6",
3
+ "version": "8.44.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {