claude-dev-env 8.44.1 → 8.44.3

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.
@@ -19,7 +19,8 @@ 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
21
 
22
- The banned-word check also reads every text field of a decision card.
22
+ The banned-word check also reads the prose of a decision card: its question,
23
+ its context, and each option's label and consequence.
23
24
  A decision card gets no length check.
24
25
 
25
26
  Each non-empty line counts as its own sentence, so a list counts one
@@ -47,6 +48,8 @@ from hooks_constants.hook_block_logger import log_hook_block
47
48
  from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
48
49
  from hooks_constants.reply_length_gate_constants import (
49
50
  ALL_CHECKED_TOOL_NAMES,
51
+ ALL_DECISION_CARD_PROSE_KEYS,
52
+ ALL_DECISION_OPTION_PROSE_KEYS,
50
53
  ALL_DEFAULT_BANNED_WORDS,
51
54
  ALLOW_EXIT_CODE,
52
55
  BANNED_WORD_MESSAGE,
@@ -59,6 +62,7 @@ from hooks_constants.reply_length_gate_constants import (
59
62
  CARD_TEXT_SEPARATOR,
60
63
  CLAUDE_HOME_DIRECTORY_NAME,
61
64
  CONFIG_FILE_ENCODING,
65
+ DECISION_CARD_OPTIONS_KEY,
62
66
  DECISION_CARD_TOOL_NAME,
63
67
  FENCED_BLOCK_PATTERN,
64
68
  HOOK_EVENT_NAME,
@@ -169,19 +173,21 @@ def banned_word_violation(reply_text: str, all_banned_words: tuple[str, ...]) ->
169
173
  return None
170
174
 
171
175
 
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 []
176
+ def all_card_texts(all_card_input: dict[str, object]) -> list[str]:
177
+ """Collect the prose fields of a decision card: question, context, and each option's text."""
178
+ all_options = all_card_input.get(DECISION_CARD_OPTIONS_KEY)
179
+ all_option_fields = [
180
+ each_option.get(each_key)
181
+ for each_option in (all_options if isinstance(all_options, list) else [])
182
+ if isinstance(each_option, dict)
183
+ for each_key in ALL_DECISION_OPTION_PROSE_KEYS
184
+ ]
185
+ all_card_fields = [all_card_input.get(each_key) for each_key in ALL_DECISION_CARD_PROSE_KEYS]
186
+ return [
187
+ each_field
188
+ for each_field in all_card_fields + all_option_fields
189
+ if isinstance(each_field, str)
190
+ ]
185
191
 
186
192
 
187
193
  def tool_violation(tool_name: object, all_tool_input: dict[str, object]) -> tuple[str, str] | None:
@@ -377,3 +377,68 @@ def test_should_deny_the_hedges_the_default_list_adds(
377
377
  ) -> None:
378
378
  exit_code, _ = run_gate(monkeypatch, capsys, REPLY_TOOL_NAME, {"text": reply_text})
379
379
  assert exit_code == 2
380
+
381
+
382
+ def test_should_ignore_a_banned_word_in_a_card_field_that_holds_no_prose(
383
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
384
+ ) -> None:
385
+ exit_code, stderr_text = run_gate(
386
+ monkeypatch,
387
+ capsys,
388
+ DECISION_TOOL_NAME,
389
+ {
390
+ "question": "Exclude the call button?",
391
+ "context": "Crops show the call button flagged in 7 themes.",
392
+ "options": [
393
+ {"id": "maybe", "label": "Exclude it", "consequence": "Seven themes pass."},
394
+ {"id": "leave", "label": "Leave it", "consequence": "Seven themes keep a warning."},
395
+ ],
396
+ "kind": "maybe",
397
+ "recommended": 0,
398
+ },
399
+ )
400
+ assert (exit_code, stderr_text) == (0, "")
401
+
402
+
403
+ @pytest.mark.parametrize(
404
+ "card_field_name",
405
+ ["question", "context", "label", "consequence"],
406
+ )
407
+ def test_should_deny_a_banned_word_in_each_card_prose_field(
408
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], card_field_name: str
409
+ ) -> None:
410
+ option = {"label": "Exclude it", "consequence": "Seven themes pass."}
411
+ card: dict[str, object] = {
412
+ "question": "Exclude the call button?",
413
+ "context": "Crops show the call button flagged.",
414
+ "options": [option],
415
+ }
416
+ if card_field_name in option:
417
+ option[card_field_name] = "It maybe passes."
418
+ else:
419
+ card[card_field_name] = "It maybe passes."
420
+ exit_code, stderr_text = run_gate(monkeypatch, capsys, DECISION_TOOL_NAME, card)
421
+ assert exit_code == 2
422
+ assert '"maybe"' in stderr_text
423
+
424
+
425
+ def test_should_apply_the_configured_list_to_decision_cards(
426
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
427
+ ) -> None:
428
+ config_path = tmp_path / ".claude" / "reply-banned-words.json"
429
+ config_path.parent.mkdir()
430
+ config_path.write_text(json.dumps({"banned_words": ["synergy"]}), encoding="utf-8")
431
+ probably_exit_code, _ = run_gate(
432
+ monkeypatch,
433
+ capsys,
434
+ DECISION_TOOL_NAME,
435
+ {"question": "Ship it?", "context": "It probably passes."},
436
+ )
437
+ synergy_exit_code, stderr_text = run_gate(
438
+ monkeypatch,
439
+ capsys,
440
+ DECISION_TOOL_NAME,
441
+ {"question": "Ship it?", "context": "Synergy shipped."},
442
+ )
443
+ assert (probably_exit_code, synergy_exit_code) == (0, 2)
444
+ assert 'Banned word "synergy"' in stderr_text
@@ -1,6 +1,6 @@
1
1
  import io
2
2
  import json
3
- import threading
3
+ from collections.abc import Callable
4
4
  from pathlib import Path
5
5
 
6
6
  import pytest
@@ -160,6 +160,24 @@ def test_should_allow_every_parallel_call_under_one_note(
160
160
  assert (first_exit_code, second_exit_code) == (0, 0)
161
161
 
162
162
 
163
+ class HarnessWritesDuringFirstSleep:
164
+ """Stand-in for the time module that runs the harness write on the first poll sleep."""
165
+
166
+ def __init__(self, write_late_lines: Callable[[], None]) -> None:
167
+ self.write_late_lines = write_late_lines
168
+ self.elapsed_seconds = 0.0
169
+ self.sleep_count = 0
170
+
171
+ def monotonic(self) -> float:
172
+ return self.elapsed_seconds
173
+
174
+ def sleep(self, seconds: float) -> None:
175
+ if self.sleep_count == 0:
176
+ self.write_late_lines()
177
+ self.sleep_count += 1
178
+ self.elapsed_seconds += seconds
179
+
180
+
163
181
  def test_should_see_a_note_the_harness_writes_after_the_hook_starts(
164
182
  tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
165
183
  ) -> None:
@@ -174,12 +192,11 @@ def test_should_see_a_note_the_harness_writes_after_the_hook_starts(
174
192
  with transcript.open("a", encoding="utf-8") as transcript_file:
175
193
  transcript_file.write(late_lines)
176
194
 
177
- writer = threading.Timer(0.15, append_late_lines)
178
- writer.start()
195
+ harness_clock = HarnessWritesDuringFirstSleep(append_late_lines)
196
+ monkeypatch.setattr(step_note_gate, "time", harness_clock)
179
197
  exit_code, _ = run_gate(monkeypatch, capsys, transcript, "call_1")
180
- writer.join()
181
198
 
182
- assert exit_code == 0
199
+ assert (exit_code, harness_clock.sleep_count) == (0, 1)
183
200
 
184
201
 
185
202
  def test_should_block_a_bare_call_the_harness_writes_after_the_hook_starts(
@@ -192,12 +209,11 @@ def test_should_block_a_bare_call_the_harness_writes_after_the_hook_starts(
192
209
  with transcript.open("a", encoding="utf-8") as transcript_file:
193
210
  transcript_file.write(json.dumps(call_entry("m1", "call_1")) + "\n")
194
211
 
195
- writer = threading.Timer(0.15, append_late_call)
196
- writer.start()
212
+ harness_clock = HarnessWritesDuringFirstSleep(append_late_call)
213
+ monkeypatch.setattr(step_note_gate, "time", harness_clock)
197
214
  exit_code, _ = run_gate(monkeypatch, capsys, transcript, "call_1")
198
- writer.join()
199
215
 
200
- assert exit_code == 2
216
+ assert (exit_code, harness_clock.sleep_count) == (2, 1)
201
217
 
202
218
 
203
219
  def test_should_allow_a_call_that_never_reaches_the_transcript(
@@ -7,6 +7,9 @@ MAXIMUM_WORDS_PER_SENTENCE = 15
7
7
  ALL_CHECKED_TOOL_NAMES = frozenset({"mcp__hearthbot__reply", "mcp__hearthbot__post_message"})
8
8
  DECISION_CARD_TOOL_NAME = "mcp__hearthbot__ask_decision"
9
9
  CARD_TEXT_SEPARATOR = "\n"
10
+ ALL_DECISION_CARD_PROSE_KEYS = ("question", "context")
11
+ DECISION_CARD_OPTIONS_KEY = "options"
12
+ ALL_DECISION_OPTION_PROSE_KEYS = ("label", "consequence")
10
13
  TOOL_NAME_KEY = "tool_name"
11
14
  TOOL_INPUT_KEY = "tool_input"
12
15
  TEXT_KEY = "text"
@@ -19,3 +19,29 @@ def test_default_banned_words_hold_the_hedges_and_intensifiers() -> None:
19
19
  assert {"likely", "probably", "seems", "real", "actually", "genuine"} <= set(
20
20
  constants.ALL_DEFAULT_BANNED_WORDS
21
21
  )
22
+
23
+
24
+ def test_gate_checks_the_project_decision_card_tool() -> None:
25
+ assert constants.DECISION_CARD_TOOL_NAME == "mcp__hearthbot__ask_decision"
26
+
27
+
28
+ def test_default_banned_words_hold_the_added_hedge_phrases() -> None:
29
+ assert {
30
+ "maybe",
31
+ "perhaps",
32
+ "presumably",
33
+ "might be",
34
+ "may be",
35
+ "appears to",
36
+ "i believe",
37
+ "not sure",
38
+ "unsure",
39
+ } <= set(constants.ALL_DEFAULT_BANNED_WORDS)
40
+
41
+
42
+ def test_gate_reads_only_the_prose_fields_of_a_decision_card() -> None:
43
+ assert (
44
+ constants.ALL_DECISION_CARD_PROSE_KEYS,
45
+ constants.DECISION_CARD_OPTIONS_KEY,
46
+ constants.ALL_DECISION_OPTION_PROSE_KEYS,
47
+ ) == (("question", "context"), "options", ("label", "consequence"))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.44.1",
3
+ "version": "8.44.3",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {