claude-dev-env 1.47.0 → 1.49.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.
package/CLAUDE.md CHANGED
@@ -9,6 +9,8 @@ The user delegates execution to you and expects zero manual steps unless strictl
9
9
  ## Code Rules
10
10
  @~/.claude/docs/CODE_RULES.md
11
11
 
12
+ When an edit deletes or rewrites code, delete everything it orphans in the same edit — unused variables, uncalled functions, unpassed parameters, dead branches, unused imports — once Serena's `find_referencing_symbols` (plus a text search for dynamic lookups) confirms they're unreachable from any live entry point, not merely unreferenced; when liveness is uncertain, ask via AskUserQuestion rather than risk deleting live code (CODE_RULES.md §9.8).
13
+
12
14
  ALWAYS call the AskUserQuestion tool if you have a question for the user. Provide content-appropriate default options, with a flag for the recommended one.
13
15
 
14
16
  ## Timeless Documentation (all `.md` files)
@@ -50,3 +52,4 @@ Reserve `Read`/`Grep`/`Glob` for files you will actually touch this turn. Compos
50
52
  ## Additional Non-overlapping Rules
51
53
 
52
54
  - **task_scope:** Match every action to what was explicitly requested. When intent is ambiguous, research official docs and present options via AskUserQuestion before making any changes. Proceed with edits only on explicit instruction.
55
+ - **confirm_implementation_forks:** When two or more viable paths would satisfy the goal and the choice changes the deliverable — its scope, completeness, deferred work, dependencies, or a hard-to-reverse contract — stop and ask which path via AskUserQuestion before implementing. A path that defers work or leaves a placeholder creating a follow-up task is itself a fork to surface, not a default to take silently. Phrase the question in plain language with only the detail needed to decide. See [`confirm-implementation-forks`](rules/confirm-implementation-forks.md).
@@ -288,6 +288,31 @@ Fallback values mask programming errors (KeyError vs RuntimeError vs AttributeEr
288
288
 
289
289
  ---
290
290
 
291
+ ## 9.8 REMOVE CODE YOU ORPHAN (Dead Code Elimination)
292
+
293
+ When an edit deletes or rewrites code, it must also remove everything that edit makes dead. The change is not complete while orphans remain.
294
+
295
+ After deleting or rewriting code, trace what it referenced and remove whatever is unreachable as a result:
296
+
297
+ - **Variables** with no remaining readers after the change
298
+ - **Functions / methods** with no remaining call sites
299
+ - **Parameters** that no caller passes
300
+ - **Branches** made unreachable (dead `if`/`else`, conditions that are always true or always false)
301
+ - **Imports** left unused (also caught by the unused-import hook)
302
+ - **Helper files** whose only consumer you just deleted
303
+
304
+ **Confirm the orphan is truly unreferenced first.** "No remaining call sites" means none *anywhere in the codebase*, not just in the file you edited. Before removing a function, method, class, or module-level name, run Serena's `find_referencing_symbols` on it: the language server resolves call sites, `import` statements, and re-exports across files far more reliably and cheaply than a text sweep. Back it with a plain text search for string-based dynamic lookups (`getattr`, entry-point names) that the language server cannot see. A reference is not proof of life — the referrer can be dead too. The symbol is live only when some chain of references reaches a live entry point: a CLI command, route, public API, test, or any caller outside the code you are removing. Trace the referrers upward. If every chain dead-ends in code that is itself unreferenced, the whole cluster is dead — remove it together in the same commit (§9.6). If any chain reaches a live entry point, the symbol is in use; leave it. Deleting something still reachable breaks its importers; treating a self-referential dead cluster as alive leaves litter.
305
+
306
+ **When liveness is uncertain, ask — never guess.** A tool cannot always tell what is live: a chain may leave the repository (a public API, plugin hook, or module other projects import), or run through dynamic or reflective dispatch that no search resolves. If you cannot conclusively prove a symbol is unreachable, do not delete it. Surface the specific ambiguity to the user through AskUserQuestion and let them decide. Removing live production code is never an acceptable risk — when in doubt, keep it and ask.
307
+
308
+ This is the inverse of comment preservation: existing **comments** are sacred and never removed, but dead **code** is removed in the same edit that orphans it. A function left with no callers is not "preserved" — it is litter.
309
+
310
+ > **See also:** §9.6 (a renamed alias is dead code by another name), the `file_global_constants_use_count` rule (zero references → delete), and the unused-import hook check — each enforces a specific slice of this principle automatically.
311
+
312
+ > **Standard terms (shorthand for this section):** the mechanical part is *dead code elimination* (compilers; Aho et al., *Compilers: Principles, Techniques, and Tools*) and *tree-shaking* (bundlers like Rollup/Webpack) — retain only what is reachable from a live entry point. The ask-when-uncertain overlay guards against the *Lava Flow* anti-pattern — dead code kept because removing it feels risky (Brown et al., *AntiPatterns*, 1998). Compare Fowler's "Remove Dead Code" refactoring (*Refactoring*, 2nd ed., 2018). Direct source links: [`references/dead-code-elimination.md`](references/dead-code-elimination.md).
313
+
314
+ ---
315
+
291
316
  ## 10. NO REDUNDANT DATA FETCHES
292
317
 
293
318
  If you already have data, don't fetch again.
@@ -384,5 +409,6 @@ Manual check:
384
409
  [ ] OCP/LSP/ISP/DIP only applied where abstractions already earn their keep (see §7.5)?
385
410
  [ ] No backwards-compatibility shims (§9.6)?
386
411
  [ ] No fallback/best-effort wrappers (§9.7)?
412
+ [ ] No code orphaned by an edit (§9.8 — dead vars, uncalled functions, unused imports, dead branches)?
387
413
  [ ] Readability: /check
388
414
  ```
@@ -0,0 +1,17 @@
1
+ # Dead Code Elimination — Reference
2
+
3
+ External sources and standard terminology behind [CODE_RULES §9.8](../CODE_RULES.md#98-remove-code-you-orphan-dead-code-elimination). Each entry names the concept, gives a one-line definition, and links a direct source.
4
+
5
+ ## The mechanic — removing code that cannot affect results
6
+
7
+ - **Dead-code elimination (DCE)** — a compiler optimization that removes code which does not affect program results, reducing program size, resource use, and execution time. Foundational treatment: Aho, Lam, Sethi & Ullman, *Compilers: Principles, Techniques, and Tools* (the "Dragon Book"). <https://en.wikipedia.org/wiki/Dead-code_elimination>
8
+ - **Tree shaking** — dead-code elimination driven by `import`/`export` structure: keep only the exports reachable from an entry point and drop the rest. MDN glossary: <https://developer.mozilla.org/en-US/docs/Glossary/Tree_shaking>. Webpack guide: <https://webpack.js.org/guides/tree-shaking/>
9
+ - **Remove Dead Code** — the named refactoring for eliminating unreachable or unused segments to improve clarity and maintainability. Martin Fowler, *Refactoring* (2nd ed., 2018): <https://refactoring.com/catalog/removeDeadCode.html>
10
+
11
+ ## The liveness test — reachability, not mere reference
12
+
13
+ - **Unreachable code / reachability analysis** — code with no control-flow path from the rest of the program; detecting it is a form of control-flow analysis. This is the basis for §9.8 testing reachability from a live entry point rather than the bare presence of a reference. <https://en.wikipedia.org/wiki/Unreachable_code>
14
+
15
+ ## The safety overlay — why uncertain deletions escalate
16
+
17
+ - **Lava Flow anti-pattern** — suboptimal code that survives in production because accumulated dependencies and backward-compatibility fears make removal feel risky. Brown, Malveau, McCormick & Mowbray, *AntiPatterns* (1998). The §9.8 "ask when uncertain, never guess-delete" clause guards against blind risky removals. <https://en.wikipedia.org/wiki/Lava_flow_(programming)>
@@ -0,0 +1,184 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook that blocks heavy words in AskUserQuestion prose and .md writes.
3
+
4
+ Reaches for the everyday word over the formal one: `use` over `utilize`,
5
+ `start` over `initiate`, `enough` over `sufficient`. Two surfaces are guarded --
6
+ AskUserQuestion (its question and option prose) and Write/Edit/MultiEdit targeting a .md
7
+ file. Code fences, inline code, blockquotes, URLs, and file paths are stripped
8
+ before matching so exact identifiers and paths are never flagged.
9
+
10
+ See the plain-language rule for the full guidance this hook enforces.
11
+ """
12
+
13
+ import json
14
+ import sys
15
+ from pathlib import Path
16
+ from typing import TextIO
17
+
18
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
19
+ if _hooks_dir not in sys.path:
20
+ sys.path.insert(0, _hooks_dir)
21
+
22
+ from hooks_constants.plain_language_blocker_constants import ( # noqa: E402
23
+ ALL_SOFTWARE_TERMS,
24
+ ALL_TERM_PATTERNS,
25
+ ALL_WRITE_EDIT_TOOL_NAMES,
26
+ ASK_USER_QUESTION_TOOL_NAME,
27
+ BLOCKQUOTE_LINE_PATTERN,
28
+ FENCED_CODE_BLOCK_PATTERN,
29
+ FILE_PATH_PATTERN,
30
+ INLINE_CODE_PATTERN,
31
+ MARKDOWN_EXTENSION,
32
+ URL_PATTERN,
33
+ USER_FACING_PLAIN_LANGUAGE_NOTICE,
34
+ )
35
+
36
+
37
+ def strip_non_prose_regions(text: str) -> str:
38
+ """Return text with code, quotes, URLs, and file paths removed.
39
+
40
+ These regions carry exact identifiers and references that plain language
41
+ leaves untouched, so they must not contribute matches.
42
+ """
43
+ without_fences = FENCED_CODE_BLOCK_PATTERN.sub("", text)
44
+ without_inline_code = INLINE_CODE_PATTERN.sub("", without_fences)
45
+ without_blockquotes = BLOCKQUOTE_LINE_PATTERN.sub("", without_inline_code)
46
+ without_urls = URL_PATTERN.sub("", without_blockquotes)
47
+ without_paths = FILE_PATH_PATTERN.sub("", without_urls)
48
+ return without_paths
49
+
50
+
51
+ def find_banned_terms(text: str) -> list[tuple[str, str]]:
52
+ """Return each (matched term, suggested replacement) found in the prose.
53
+
54
+ Each term appears at most once, in first-seen order. Matching is
55
+ case-insensitive and respects word boundaries; multi-word phrases match as
56
+ whole units. Terms in the software-term allowlist are exempt and never
57
+ flagged.
58
+ """
59
+ prose_text = strip_non_prose_regions(text)
60
+ all_matches: list[tuple[str, str]] = []
61
+ seen_terms: set[str] = set()
62
+ for each_pattern, each_replacement in ALL_TERM_PATTERNS:
63
+ first_match = each_pattern.search(prose_text)
64
+ if first_match is None:
65
+ continue
66
+ normalized_term = first_match.group(0).lower()
67
+ if normalized_term in seen_terms:
68
+ continue
69
+ if normalized_term in ALL_SOFTWARE_TERMS:
70
+ continue
71
+ seen_terms.add(normalized_term)
72
+ all_matches.append((normalized_term, each_replacement))
73
+ return all_matches
74
+
75
+
76
+ def build_block_reason(all_matches: list[tuple[str, str]]) -> str:
77
+ """Return a deny reason naming each flagged term and its plain replacement."""
78
+ swap_phrases = ", ".join(
79
+ f'use "{each_replacement}" instead of "{each_term}"'
80
+ for each_term, each_replacement in all_matches
81
+ )
82
+ return (
83
+ "BLOCKED: [PLAIN_LANGUAGE] Heavy words detected -- "
84
+ f"{swap_phrases}. Reach for the everyday word the reader understands "
85
+ "on the first pass."
86
+ )
87
+
88
+
89
+ def _collect_ask_user_question_prose(tool_input: dict) -> str:
90
+ all_questions = tool_input.get("questions", [])
91
+ if not isinstance(all_questions, list):
92
+ return ""
93
+ prose_segments: list[str] = []
94
+ for each_question in all_questions:
95
+ if not isinstance(each_question, dict):
96
+ continue
97
+ question_text = each_question.get("question", "")
98
+ if isinstance(question_text, str):
99
+ prose_segments.append(question_text)
100
+ all_options = each_question.get("options", [])
101
+ if isinstance(all_options, list):
102
+ for each_option in all_options:
103
+ if isinstance(each_option, dict):
104
+ option_label = each_option.get("label", "")
105
+ if isinstance(option_label, str):
106
+ prose_segments.append(option_label)
107
+ option_description = each_option.get("description", "")
108
+ if isinstance(option_description, str):
109
+ prose_segments.append(option_description)
110
+ return "\n".join(prose_segments)
111
+
112
+
113
+ def _collect_write_edit_markdown_prose(tool_name: str, tool_input: dict) -> str:
114
+ file_path = tool_input.get("file_path", "")
115
+ if not isinstance(file_path, str) or not file_path.lower().endswith(MARKDOWN_EXTENSION):
116
+ return ""
117
+ if tool_name == "Write":
118
+ content = tool_input.get("content", "")
119
+ return content if isinstance(content, str) else ""
120
+ if tool_name == "Edit":
121
+ new_string = tool_input.get("new_string", "")
122
+ return new_string if isinstance(new_string, str) else ""
123
+ all_edits = tool_input.get("edits", [])
124
+ if not isinstance(all_edits, list):
125
+ return ""
126
+ prose_segments: list[str] = []
127
+ for each_edit in all_edits:
128
+ if isinstance(each_edit, dict):
129
+ new_string = each_edit.get("new_string", "")
130
+ if isinstance(new_string, str):
131
+ prose_segments.append(new_string)
132
+ return "\n".join(prose_segments)
133
+
134
+
135
+ def _collect_prose_for_tool(tool_name: str, tool_input: dict) -> str:
136
+ if tool_name == ASK_USER_QUESTION_TOOL_NAME:
137
+ return _collect_ask_user_question_prose(tool_input)
138
+ if tool_name in ALL_WRITE_EDIT_TOOL_NAMES:
139
+ return _collect_write_edit_markdown_prose(tool_name, tool_input)
140
+ return ""
141
+
142
+
143
+ def _emit_deny(all_matches: list[tuple[str, str]], output_stream: TextIO) -> None:
144
+ deny_payload = {
145
+ "hookSpecificOutput": {
146
+ "hookEventName": "PreToolUse",
147
+ "permissionDecision": "deny",
148
+ "permissionDecisionReason": build_block_reason(all_matches),
149
+ },
150
+ "systemMessage": USER_FACING_PLAIN_LANGUAGE_NOTICE,
151
+ "suppressOutput": True,
152
+ }
153
+ output_stream.write(json.dumps(deny_payload))
154
+ output_stream.flush()
155
+
156
+
157
+ def main() -> None:
158
+ try:
159
+ input_data = json.load(sys.stdin)
160
+ except json.JSONDecodeError:
161
+ sys.exit(0)
162
+
163
+ if not isinstance(input_data, dict):
164
+ sys.exit(0)
165
+
166
+ tool_name = input_data.get("tool_name", "")
167
+ tool_input = input_data.get("tool_input", {})
168
+ if not isinstance(tool_name, str) or not isinstance(tool_input, dict):
169
+ sys.exit(0)
170
+
171
+ prose_text = _collect_prose_for_tool(tool_name, tool_input)
172
+ if not prose_text:
173
+ sys.exit(0)
174
+
175
+ all_matches = find_banned_terms(prose_text)
176
+ if not all_matches:
177
+ sys.exit(0)
178
+
179
+ _emit_deny(all_matches, sys.stdout)
180
+ sys.exit(0)
181
+
182
+
183
+ if __name__ == "__main__":
184
+ main()
@@ -31,6 +31,7 @@ from hooks_constants.pr_description_enforcer_constants import ( # noqa: E402
31
31
  ALL_HEAVY_TESTING_HEADERS,
32
32
  ALL_READABILITY_CLI_FLAG_TOKENS,
33
33
  ATOMIC_WRITE_TEMP_SUFFIX,
34
+ BLOCKQUOTE_LINE_PATTERN,
34
35
  BLOCKQUOTE_MARKER_PATTERN,
35
36
  BOLD_PAIR_PATTERN,
36
37
  BULLET_MARKER_PATTERN,
@@ -63,6 +64,7 @@ from hooks_constants.pr_description_enforcer_constants import ( # noqa: E402
63
64
  SELF_CLOSING_REFERENCE_MESSAGE_SUFFIX,
64
65
  SELF_REFERENCE_PATTERN_TEMPLATE,
65
66
  STANDARD_SHAPE,
67
+ TABLE_ROW_LINE_PATTERN,
66
68
  THIS_PR_OPENING_PATTERN,
67
69
  TRIVIAL_BODY_CHAR_THRESHOLD,
68
70
  TRIVIAL_SHAPE,
@@ -350,6 +352,23 @@ def _count_substantive_prose_chars(body: str) -> int:
350
352
  return len(body_collapsed)
351
353
 
352
354
 
355
+ def _extract_vague_scan_text(body: str) -> str:
356
+ """Return the prose to scan for vague language, with non-prose regions removed.
357
+
358
+ Drops whole blockquote lines and whole pipe-delimited table rows, then strips
359
+ the same Markdown ceremony as the prose-count path -- which removes fenced
360
+ code, inline code, and whole heading lines. This exempts vague phrases that
361
+ appear only inside code fences, inline code, Markdown headings, quoted
362
+ reviewer text, or pipe-delimited example tables -- those are not the author's
363
+ own prose. A pipe-delimited row carries at least two pipes; a line with a
364
+ single leading pipe, or a borderless table row with no leading pipe, stays in
365
+ scope.
366
+ """
367
+ without_blockquote_lines = BLOCKQUOTE_LINE_PATTERN.sub("", body)
368
+ without_table_rows = TABLE_ROW_LINE_PATTERN.sub("", without_blockquote_lines)
369
+ return _strip_markdown_ceremony(without_table_rows)
370
+
371
+
353
372
  def _iter_section_headers(body: str) -> list[str]:
354
373
  """Return every ATX heading line in the body, preserving canonical form.
355
374
 
@@ -813,7 +832,8 @@ def validate_pr_body(body: str, pr_number: int | None = None) -> list[str]:
813
832
  "(Adds, Fixes, Updates, Removes, Tightens, Ports)"
814
833
  )
815
834
 
816
- vague_matches = VAGUE_LANGUAGE_PATTERN.findall(body)
835
+ vague_scan_text = _extract_vague_scan_text(body)
836
+ vague_matches = VAGUE_LANGUAGE_PATTERN.findall(vague_scan_text)
817
837
  if vague_matches:
818
838
  violations.append(
819
839
  f"Vague language detected: {', '.join(vague_matches)} -- "
@@ -0,0 +1,247 @@
1
+ """Tests for the plain_language_blocker PreToolUse hook.
2
+
3
+ Covers the shared prose scanner (fenced code, inline code, blockquotes, URLs,
4
+ file paths), the word-boundary guard, multi-word phrase matching, case
5
+ insensitivity, the term -> replacement block message, and both registered
6
+ PreToolUse surfaces (AskUserQuestion and Write|Edit on .md targets).
7
+ """
8
+
9
+ import importlib.util
10
+ import json
11
+ import os
12
+ import subprocess
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ HOOK_SCRIPT_PATH = Path(__file__).parent / "plain_language_blocker.py"
17
+ _HOOKS_DIR = str(Path(__file__).resolve().parent)
18
+ _HOOKS_ROOT = str(Path(__file__).resolve().parent.parent)
19
+ if _HOOKS_DIR not in sys.path:
20
+ sys.path.insert(0, _HOOKS_DIR)
21
+ if _HOOKS_ROOT not in sys.path:
22
+ sys.path.insert(0, _HOOKS_ROOT)
23
+
24
+
25
+ def _load_hook_module() -> object:
26
+ module_spec = importlib.util.spec_from_file_location(
27
+ "plain_language_blocker_under_test", HOOK_SCRIPT_PATH
28
+ )
29
+ assert module_spec is not None and module_spec.loader is not None
30
+ loaded_module = importlib.util.module_from_spec(module_spec)
31
+ module_spec.loader.exec_module(loaded_module)
32
+ return loaded_module
33
+
34
+
35
+ hook_module = _load_hook_module()
36
+ find_banned_terms = hook_module.find_banned_terms
37
+ strip_non_prose_regions = hook_module.strip_non_prose_regions
38
+ build_block_reason = hook_module.build_block_reason
39
+
40
+
41
+ def _run_hook_with_payload(payload: dict) -> subprocess.CompletedProcess[str]:
42
+ return subprocess.run(
43
+ [sys.executable, str(HOOK_SCRIPT_PATH)],
44
+ input=json.dumps(payload),
45
+ capture_output=True,
46
+ text=True,
47
+ check=False,
48
+ )
49
+
50
+
51
+ def _decision_from(completed: subprocess.CompletedProcess[str]) -> str | None:
52
+ if not completed.stdout:
53
+ return None
54
+ parsed = json.loads(completed.stdout)
55
+ return parsed.get("hookSpecificOutput", {}).get("permissionDecision")
56
+
57
+
58
+ def test_canonical_hook_script_exists_at_expected_path() -> None:
59
+ assert HOOK_SCRIPT_PATH.is_file()
60
+
61
+
62
+ def test_bare_prose_banned_term_is_detected() -> None:
63
+ matched = find_banned_terms("We initiate the worker pool at boot.")
64
+ assert any(each_term == "initiate" for each_term, _replacement in matched)
65
+
66
+
67
+ def test_banned_term_inside_fenced_code_is_exempt() -> None:
68
+ prose = "Start the pool at boot.\n\n```python\nutilize(pool)\n```\n"
69
+ assert find_banned_terms(prose) == []
70
+
71
+
72
+ def test_banned_term_inside_inline_code_is_exempt() -> None:
73
+ prose = "Call the `utilize` helper from the legacy module to migrate."
74
+ assert find_banned_terms(prose) == []
75
+
76
+
77
+ def test_banned_term_inside_blockquote_is_exempt() -> None:
78
+ prose = "> The old guide said to utilize the pool.\n\nUse the pool directly now."
79
+ assert find_banned_terms(prose) == []
80
+
81
+
82
+ def test_banned_term_inside_url_is_exempt() -> None:
83
+ prose = "See https://example.com/initiate-flow for the original write-up."
84
+ assert find_banned_terms(prose) == []
85
+
86
+
87
+ def test_banned_term_inside_file_path_is_exempt() -> None:
88
+ prose = "Edit src/utilize_helpers/initiate.py to wire the new path."
89
+ assert find_banned_terms(prose) == []
90
+
91
+
92
+ def test_word_boundary_guard_does_not_match_substring() -> None:
93
+ assert find_banned_terms("The reinitialize routine reruns the seed.") == []
94
+
95
+
96
+ def test_case_insensitive_match() -> None:
97
+ matched_lower = find_banned_terms("utilize the cache.")
98
+ matched_upper = find_banned_terms("Utilize the cache.")
99
+ assert any(term == "utilize" for term, _ in matched_lower)
100
+ assert any(term == "utilize" for term, _ in matched_upper)
101
+
102
+
103
+ def test_multi_word_phrase_matches_as_unit() -> None:
104
+ matched = find_banned_terms("Run the migration prior to the deploy step.")
105
+ assert any(term == "prior to" for term, _ in matched)
106
+
107
+
108
+ def test_strip_non_prose_regions_removes_code_and_paths() -> None:
109
+ prose = "Use `utilize` and src/initiate.py and https://x.test/utilize here."
110
+ stripped = strip_non_prose_regions(prose)
111
+ assert "utilize" not in stripped
112
+ assert "initiate" not in stripped
113
+
114
+
115
+ def test_block_reason_names_term_and_replacement() -> None:
116
+ reason = build_block_reason([("initiate", "start")])
117
+ assert "initiate" in reason
118
+ assert "start" in reason
119
+
120
+
121
+ def test_ask_user_question_with_banned_term_is_denied() -> None:
122
+ payload = {
123
+ "tool_name": "AskUserQuestion",
124
+ "tool_input": {
125
+ "questions": [
126
+ {
127
+ "question": "Should we utilize the new allocator now?",
128
+ "header": "Allocator",
129
+ "options": [{"label": "Yes", "description": "Switch now."}],
130
+ }
131
+ ]
132
+ },
133
+ }
134
+ completed = _run_hook_with_payload(payload)
135
+ assert _decision_from(completed) == "deny"
136
+
137
+
138
+ def test_ask_user_question_banned_term_in_option_label_is_denied() -> None:
139
+ payload = {
140
+ "tool_name": "AskUserQuestion",
141
+ "tool_input": {
142
+ "questions": [
143
+ {
144
+ "question": "Which path should we take?",
145
+ "header": "Path",
146
+ "options": [{"label": "Utilize the cache", "description": "Go fast."}],
147
+ }
148
+ ]
149
+ },
150
+ }
151
+ completed = _run_hook_with_payload(payload)
152
+ assert _decision_from(completed) == "deny"
153
+
154
+
155
+ def test_clean_ask_user_question_passes_through() -> None:
156
+ payload = {
157
+ "tool_name": "AskUserQuestion",
158
+ "tool_input": {
159
+ "questions": [
160
+ {
161
+ "question": "Should we switch the allocator now?",
162
+ "header": "Allocator",
163
+ "options": [{"label": "Yes", "description": "Switch now."}],
164
+ }
165
+ ]
166
+ },
167
+ }
168
+ completed = _run_hook_with_payload(payload)
169
+ assert _decision_from(completed) is None
170
+
171
+
172
+ def test_write_markdown_with_banned_term_is_denied(tmp_path: Path) -> None:
173
+ target = tmp_path / "notes.md"
174
+ payload = {
175
+ "tool_name": "Write",
176
+ "tool_input": {
177
+ "file_path": str(target),
178
+ "content": "This guide explains how to utilize the new cache layer.",
179
+ },
180
+ }
181
+ completed = _run_hook_with_payload(payload)
182
+ assert _decision_from(completed) == "deny"
183
+
184
+
185
+ def test_write_non_markdown_is_ignored(tmp_path: Path) -> None:
186
+ target = tmp_path / "notes.txt"
187
+ payload = {
188
+ "tool_name": "Write",
189
+ "tool_input": {
190
+ "file_path": str(target),
191
+ "content": "This guide explains how to utilize the new cache layer.",
192
+ },
193
+ }
194
+ completed = _run_hook_with_payload(payload)
195
+ assert _decision_from(completed) is None
196
+
197
+
198
+ def test_edit_markdown_clean_content_passes_through(tmp_path: Path) -> None:
199
+ target = tmp_path / "notes.md"
200
+ payload = {
201
+ "tool_name": "Edit",
202
+ "tool_input": {
203
+ "file_path": str(target),
204
+ "new_string": "This guide explains how to use the new cache layer.",
205
+ },
206
+ }
207
+ completed = _run_hook_with_payload(payload)
208
+ assert _decision_from(completed) is None
209
+
210
+
211
+ def test_multiedit_markdown_with_banned_term_is_denied(tmp_path: Path) -> None:
212
+ target = tmp_path / "notes.md"
213
+ payload = {
214
+ "tool_name": "MultiEdit",
215
+ "tool_input": {
216
+ "file_path": str(target),
217
+ "edits": [
218
+ {"old_string": "intro", "new_string": "This section reads cleanly."},
219
+ {"old_string": "body", "new_string": "Then we utilize the new cache."},
220
+ ],
221
+ },
222
+ }
223
+ completed = _run_hook_with_payload(payload)
224
+ assert _decision_from(completed) == "deny"
225
+
226
+
227
+ def test_other_tool_is_ignored() -> None:
228
+ payload = {"tool_name": "Bash", "tool_input": {"command": "echo utilize"}}
229
+ completed = _run_hook_with_payload(payload)
230
+ assert _decision_from(completed) is None
231
+
232
+
233
+ def test_software_allowlisted_term_is_not_flagged() -> None:
234
+ assert find_banned_terms("Run this command to start the worker.") == []
235
+
236
+
237
+ def test_non_allowlisted_formal_term_still_flagged() -> None:
238
+ matched = find_banned_terms("Please utilize the cache now.")
239
+ assert any(term == "utilize" for term, _ in matched)
240
+
241
+
242
+ def test_prose_slash_token_is_not_stripped_as_path() -> None:
243
+ assert "client/server" in strip_non_prose_regions("Use a client/server split here.")
244
+
245
+
246
+ def test_real_file_path_is_still_stripped() -> None:
247
+ assert "initiate" not in strip_non_prose_regions("Edit src/initiate.py to wire it.")
@@ -225,6 +225,74 @@ def test_validate_blocks_vague_language() -> None:
225
225
  assert any("Vague language" in each_violation for each_violation in violations)
226
226
 
227
227
 
228
+ def _has_vague_language_violation(all_violations: list[str]) -> bool:
229
+ return any("Vague language" in each_violation for each_violation in all_violations)
230
+
231
+
232
+ def test_vague_language_inside_fenced_code_block_is_exempt() -> None:
233
+ body = (
234
+ "The allocator now bounds retries so a runaway request cannot exhaust the "
235
+ "connection pool under sustained load.\n\n"
236
+ "```bash\ngit commit -m \"fixed bug in parser\"\n```\n"
237
+ )
238
+ assert not _has_vague_language_violation(validate_pr_body(body))
239
+
240
+
241
+ def test_vague_language_inside_inline_code_span_is_exempt() -> None:
242
+ body = (
243
+ "This change documents the historical commit message `fixed bug` referenced "
244
+ "in the changelog and rewrites the surrounding allocator narrative for clarity.\n"
245
+ )
246
+ assert not _has_vague_language_violation(validate_pr_body(body))
247
+
248
+
249
+ def test_vague_language_inside_blockquote_line_is_exempt() -> None:
250
+ body = (
251
+ "> The reviewer wrote: minor changes were requested here.\n\n"
252
+ "The allocator rewrite removes the unbounded retry loop and adds a hard ceiling "
253
+ "so a single client cannot starve the pool.\n"
254
+ )
255
+ assert not _has_vague_language_violation(validate_pr_body(body))
256
+
257
+
258
+ def test_vague_language_inside_markdown_table_is_exempt() -> None:
259
+ body = (
260
+ "The commit-message guide contrasts weak and strong messages so contributors "
261
+ "learn the difference before opening a pull request.\n\n"
262
+ "| Bad message | Good message |\n"
263
+ "| --- | --- |\n"
264
+ "| fixed bug | bound retry loop in allocator |\n"
265
+ "| update code | rename pool field to active_count |\n"
266
+ )
267
+ assert not _has_vague_language_violation(validate_pr_body(body))
268
+
269
+
270
+ def test_vague_language_in_bare_prose_still_blocks() -> None:
271
+ body = (
272
+ "The allocator rewrite removes the unbounded retry loop and adds a hard "
273
+ "ceiling so a single client cannot starve the pool. Fixed bug in the parser.\n"
274
+ )
275
+ assert _has_vague_language_violation(validate_pr_body(body))
276
+
277
+
278
+ def test_vague_language_inside_heading_is_exempt() -> None:
279
+ body = (
280
+ "## Fixed bug in the allocator\n\n"
281
+ "The allocator rewrite removes the unbounded retry loop and adds a hard "
282
+ "ceiling so a single client cannot starve the connection pool.\n"
283
+ )
284
+ assert not _has_vague_language_violation(validate_pr_body(body))
285
+
286
+
287
+ def test_vague_language_in_single_pipe_prose_line_still_blocks() -> None:
288
+ body = (
289
+ "The allocator rewrite removes the unbounded retry loop and adds a hard "
290
+ "ceiling so a single client cannot starve the connection pool.\n\n"
291
+ "| fixed bug\n"
292
+ )
293
+ assert _has_vague_language_violation(validate_pr_body(body))
294
+
295
+
228
296
  def test_validate_blocks_short_body() -> None:
229
297
  violations = validate_pr_body("Too short.")
230
298
  assert any("substantive prose" in each_violation.lower() for each_violation in violations)
package/hooks/hooks.json CHANGED
@@ -69,6 +69,11 @@
69
69
  "type": "command",
70
70
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/open_questions_in_plans_blocker.py",
71
71
  "timeout": 10
72
+ },
73
+ {
74
+ "type": "command",
75
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/plain_language_blocker.py",
76
+ "timeout": 10
72
77
  }
73
78
  ]
74
79
  },
@@ -171,6 +176,16 @@
171
176
  "timeout": 10
172
177
  }
173
178
  ]
179
+ },
180
+ {
181
+ "matcher": "AskUserQuestion",
182
+ "hooks": [
183
+ {
184
+ "type": "command",
185
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/plain_language_blocker.py",
186
+ "timeout": 10
187
+ }
188
+ ]
174
189
  }
175
190
  ],
176
191
  "SessionStart": [