claude-dev-env 8.43.6 → 8.44.1
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 +43 -11
- package/hooks/blocking/test_reply_length_gate.py +55 -0
- package/hooks/hooks.json +1 -1
- package/hooks/hooks_constants/reply_length_gate_constants.py +11 -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
|
@@ -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
|
-
|
|
177
|
-
if
|
|
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
|
@@ -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
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:
|