claude-dev-env 8.44.0 → 8.44.2
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.
- package/hooks/blocking/reply_length_gate.py +20 -14
- package/hooks/blocking/test_reply_length_gate.py +65 -0
- package/hooks/hooks_constants/reply_length_gate_constants.py +3 -0
- package/hooks/hooks_constants/test_reply_length_gate_constants.py +26 -0
- package/package.json +1 -1
- package/rules/bdd.md +2 -0
- package/rules/code-standards.md +2 -0
- package/rules/doc-inventory-integrity.md +2 -0
- package/rules/windows-filesystem-safe.md +2 -0
- package/scripts/tests/test_rule_index_shape.py +11 -0
|
@@ -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
|
|
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(
|
|
173
|
-
"""Collect
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
|
@@ -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
package/rules/bdd.md
CHANGED
|
@@ -9,6 +9,8 @@ paths:
|
|
|
9
9
|
|
|
10
10
|
# BDD (discovery-driven development)
|
|
11
11
|
|
|
12
|
+
**When:** Starting a non-trivial feature.
|
|
13
|
+
|
|
12
14
|
For every non-trivial feature, discover what you do not know, illustrate it with concrete examples in chat, formulate each behavior as a narrow "should ..." specification, then build each one through the TDD loop (CODE_RULES §8). Name developer-facing specs and tests as should sentences.
|
|
13
15
|
|
|
14
16
|
**Full text:** [`docs/rule-guides/bdd.md`](../docs/rule-guides/bdd.md), with the long-form BDD references.
|
package/rules/code-standards.md
CHANGED
|
@@ -10,6 +10,8 @@ paths:
|
|
|
10
10
|
|
|
11
11
|
# Code Standards
|
|
12
12
|
|
|
13
|
+
**When:** Writing, reviewing, or generating code.
|
|
14
|
+
|
|
13
15
|
[`CODE_RULES.md`](../docs/CODE_RULES.md) is the review contract for code quality. Load it when you review a pull request, resolve a policy conflict, or generate code. Red, green, refactor is the default loop (CODE_RULES §8). Keep engineering right-sized (CODE_RULES §7).
|
|
14
16
|
|
|
15
17
|
**Enforcement:** the staged policy lint runs `hooks/blocking/code_rules_enforcer.py` over each changed file, and CI runs it against the merge base.
|
|
@@ -11,6 +11,8 @@ paths:
|
|
|
11
11
|
|
|
12
12
|
# Documentation Inventory Integrity
|
|
13
13
|
|
|
14
|
+
**When:** Changing code that a document inventories.
|
|
15
|
+
|
|
14
16
|
A doc that inventories code stays in step with the code, in the same change:
|
|
15
17
|
|
|
16
18
|
1. Every bare filename a per-directory `CLAUDE.md` names in a table cell or a fenced run command exists in its subtree.
|
|
@@ -8,6 +8,8 @@ paths:
|
|
|
8
8
|
|
|
9
9
|
# Windows Filesystem Safety
|
|
10
10
|
|
|
11
|
+
**When:** Removing a directory tree or creating a directory in Python or Node.
|
|
12
|
+
|
|
11
13
|
Call `shutil.rmtree` with an `onexc` (Python 3.12 and later) or `onerror` handler that runs `os.chmod(target_path, stat.S_IWRITE)` and retries. `ignore_errors=True` leaves read-only trees on disk. In Node, call `mkdirSync(targetPath, { recursive: true })` on a path that may exist. Import the shared `force_rmtree` handler trio from its one shared Windows-filesystem module.
|
|
12
14
|
|
|
13
15
|
**Enforcement:** the staged policy lint's `rmtree-safety` rule, which returns the full `force_rmtree` code. CI runs it against the merge base.
|
|
@@ -22,6 +22,7 @@ FULL_TEXT_LINK_PATTERN = re.compile(
|
|
|
22
22
|
r"^\*\*Full text:\*\*.*?\]\((\.\./docs/rule-guides/[^)#\s]+\.md)\)", re.MULTILINE
|
|
23
23
|
)
|
|
24
24
|
TITLE_PATTERN = re.compile(r"^# \S", re.MULTILINE)
|
|
25
|
+
WHEN_LINE_PATTERN = re.compile(r"^\*\*When(?: this applies)?:\*\* \S", re.MULTILINE)
|
|
25
26
|
|
|
26
27
|
MAXIMUM_ENTRY_BYTES = 1_500
|
|
27
28
|
MAXIMUM_ALWAYS_ON_BYTES = 12_000
|
|
@@ -88,6 +89,16 @@ def test_each_entry_opens_with_a_title_after_its_frontmatter() -> None:
|
|
|
88
89
|
assert untitled == []
|
|
89
90
|
|
|
90
91
|
|
|
92
|
+
def test_each_entry_states_when_it_applies() -> None:
|
|
93
|
+
without_when_line = [
|
|
94
|
+
each_path.name
|
|
95
|
+
for each_path in _entry_paths()
|
|
96
|
+
if each_path.name not in POINTER_ENTRY_NAMES | CODEX_VERBATIM_ENTRY_NAMES
|
|
97
|
+
and not WHEN_LINE_PATTERN.search(_entry_text(each_path))
|
|
98
|
+
]
|
|
99
|
+
assert without_when_line == []
|
|
100
|
+
|
|
101
|
+
|
|
91
102
|
def _full_text_link_problems(entry_path: Path) -> list[str]:
|
|
92
103
|
all_guide_names = _linked_guide_names(_entry_text(entry_path))
|
|
93
104
|
if not all_guide_names:
|