claude-dev-env 1.48.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.
@@ -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": [
@@ -0,0 +1,295 @@
1
+ """Configuration constants for the plain_language_blocker PreToolUse hook."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ REPLACEMENT_BY_TERM: dict[str, str] = {
8
+ "addressees are requested": "(omit), please",
9
+ "under the provisions of": "under",
10
+ "interpose no objection": "don't object",
11
+ "afford an opportunity": "allow, let",
12
+ "has a requirement for": "needs",
13
+ "is in consonance with": "agrees with, follows",
14
+ "not later than 10 may": "by 10 May, before 11 May",
15
+ "provides guidance for": "guides",
16
+ "successfully complete": "complete, pass",
17
+ "with the exception of": "except for",
18
+ "due to the fact that": "due to, since",
19
+ "effect modifications": "make changes",
20
+ "in view of the above": "so",
21
+ "adversely impact on": "hurt, set back",
22
+ "at the present time": "at present, now",
23
+ "not later than 1600": "by 1600",
24
+ "combat environment": "combat",
25
+ "in a timely manner": "on time, promptly",
26
+ "in accordance with": "by, following, per, under",
27
+ "in the near future": "shortly, soon",
28
+ "is responsible for": "(omit) handles",
29
+ "on a regular basis": "(omit)",
30
+ "until such time as": "until",
31
+ "during the period": "during",
32
+ "in the process of": "(omit)",
33
+ "with reference to": "about",
34
+ "as prescribed by": "in, under",
35
+ "in the amount of": "for",
36
+ "is applicable to": "applies to",
37
+ "is authorized to": "may",
38
+ "state-of-the-art": "latest",
39
+ "close proximity": "near",
40
+ "for a period of": "for",
41
+ "in an effort to": "to",
42
+ "in the event of": "if",
43
+ "it is essential": "must, need to",
44
+ "it is requested": "please, we request, I request",
45
+ "notwithstanding": "inspite of, still",
46
+ "the undersigned": "I",
47
+ "arrive onboard": "arrive",
48
+ "in relation to": "about, with, to",
49
+ "incumbent upon": "must",
50
+ "limited number": "limits",
51
+ "take action to": "(omit)",
52
+ "as a means of": "to",
53
+ "in order that": "for, so",
54
+ "pertaining to": "about, of, on",
55
+ "provided that": "if",
56
+ "this activity": "us, we",
57
+ "advantageous": "helpful",
58
+ "consequently": "so",
59
+ "in regard to": "about, concerning, on",
60
+ "remuneration": "pay, payment",
61
+ "set forth in": "in",
62
+ "subsequently": "after, later, then",
63
+ "the month of": "(omit)",
64
+ "a number of": "some",
65
+ "accordingly": "so",
66
+ "adjacent to": "next to",
67
+ "appreciable": "many",
68
+ "appropriate": "(omit), proper, right",
69
+ "approximate": "about",
70
+ "by means of": "by, with",
71
+ "comply with": "follow",
72
+ "consolidate": "combine, join, merge",
73
+ "constitutes": "is, forms, makes up",
74
+ "demonstrate": "prove, show",
75
+ "discontinue": "drop, stop",
76
+ "disseminate": "give, issue, pass, send",
77
+ "expeditious": "fast, quick",
78
+ "immediately": "at once",
79
+ "in addition": "also, besides, too",
80
+ "in order to": "to",
81
+ "inasmuch as": "since",
82
+ "methodology": "method",
83
+ "necessitate": "cause, need",
84
+ "participate": "take part",
85
+ "practicable": "practical",
86
+ "proficiency": "skill",
87
+ "pursuant to": "by, following, per, under",
88
+ "relative to": "about, on",
89
+ "requirement": "need",
90
+ "substantial": "large, much",
91
+ "time period": "(either one)",
92
+ "utilization": "use",
93
+ "your office": "you",
94
+ "a and/or b": "a or b or both",
95
+ "accomplish": "carry out, do",
96
+ "additional": "added, more, other",
97
+ "addressees": "you",
98
+ "anticipate": "expect",
99
+ "assistance": "aid, help",
100
+ "be advised": "(omit)",
101
+ "capability": "ability",
102
+ "concerning": "about, on",
103
+ "equipments": "equipment",
104
+ "expiration": "end",
105
+ "facilitate": "ease, help",
106
+ "frequently": "often",
107
+ "heretofore": "until now",
108
+ "in lieu of": "instead",
109
+ "in view of": "since",
110
+ "indication": "sign",
111
+ "inter alia": "(omit)",
112
+ "it appears": "seems",
113
+ "parameters": "limits",
114
+ "previously": "before",
115
+ "prioritize": "rank",
116
+ "promulgate": "issue, publish",
117
+ "represents": "is",
118
+ "similar to": "like",
119
+ "subsequent": "later, next",
120
+ "sufficient": "enough",
121
+ "the use of": "(omit)",
122
+ "accompany": "go with",
123
+ "ascertain": "find out, learn",
124
+ "component": "part",
125
+ "currently": "(omit), now",
126
+ "designate": "appoint, choose, name",
127
+ "determine": "decide, figure, find",
128
+ "eliminate": "cut, drop, end",
129
+ "encounter": "meet",
130
+ "enumerate": "count",
131
+ "equitable": "fair",
132
+ "establish": "set up, prove, show",
133
+ "evidenced": "showed",
134
+ "expertise": "ability",
135
+ "failed to": "didn't",
136
+ "identical": "same",
137
+ "implement": "carry out, start",
138
+ "inception": "start",
139
+ "interface": "meet, work with",
140
+ "magnitude": "size",
141
+ "objective": "aim, goal",
142
+ "regarding": "about, of, on",
143
+ "remainder": "rest",
144
+ "selection": "choice",
145
+ "terminate": "end, stop",
146
+ "there are": "(omit)",
147
+ "therefore": "so",
148
+ "witnessed": "saw",
149
+ "accorded": "given",
150
+ "accurate": "correct, exact, right",
151
+ "aircraft": "plane",
152
+ "allocate": "divide",
153
+ "apparent": "clear, plain",
154
+ "combined": "joint",
155
+ "commence": "begin, start",
156
+ "comprise": "form, include, make up",
157
+ "contains": "has",
158
+ "disclose": "show",
159
+ "endeavor": "try",
160
+ "expedite": "hasten, speed up",
161
+ "feasible": "can be done, workable",
162
+ "finalize": "complete, finish",
163
+ "function": "act, role, work",
164
+ "herewith": "below, here",
165
+ "identify": "find, name, show",
166
+ "impacted": "affected, changed",
167
+ "indicate": "show, write down",
168
+ "initiate": "start",
169
+ "maintain": "keep, support",
170
+ "minimize": "decrease, method",
171
+ "numerous": "many",
172
+ "obligate": "bind, compel",
173
+ "preclude": "prevent",
174
+ "previous": "earlier",
175
+ "prior to": "before",
176
+ "purchase": "buy",
177
+ "relocate": "move",
178
+ "there is": "(omit)",
179
+ "transmit": "send",
180
+ "validate": "confirm",
181
+ "address": "discuss",
182
+ "attempt": "try",
183
+ "benefit": "help",
184
+ "command": "us, we",
185
+ "convene": "meet",
186
+ "evident": "clear",
187
+ "exhibit": "show",
188
+ "females": "women",
189
+ "forfeit": "give up, lose",
190
+ "forward": "send",
191
+ "furnish": "give, send",
192
+ "however": "but",
193
+ "initial": "first",
194
+ "liaison": "discussion",
195
+ "maximum": "greatest, largest, most",
196
+ "minimum": "least, smallest",
197
+ "monitor": "check, watch",
198
+ "observe": "see",
199
+ "operate": "run, use, work",
200
+ "optimum": "best, greatest, most",
201
+ "perform": "do",
202
+ "portion": "part",
203
+ "possess": "have, own",
204
+ "proceed": "do, go ahead, try",
205
+ "procure": "(omit)",
206
+ "provide": "give, offer, say",
207
+ "reflect": "say, show",
208
+ "request": "ask",
209
+ "require": "must, need",
210
+ "solicit": "ask for, request",
211
+ "subject": "the, this, your",
212
+ "therein": "there",
213
+ "thereof": "its, their",
214
+ "utilize": "use",
215
+ "warrant": "call for, permit",
216
+ "whereas": "because, since",
217
+ "accrue": "add, gain",
218
+ "advise": "recommend, tell",
219
+ "assist": "aid, help",
220
+ "attain": "meet",
221
+ "caveat": "warning",
222
+ "delete": "cut, drop",
223
+ "depart": "leave",
224
+ "desire": "want, wish",
225
+ "employ": "use",
226
+ "ensure": "make sure",
227
+ "expend": "spend",
228
+ "herein": "here",
229
+ "modify": "change",
230
+ "notify": "let know, tell",
231
+ "option": "choice, way",
232
+ "permit": "let",
233
+ "remain": "stay",
234
+ "render": "give, make",
235
+ "reside": "live",
236
+ "retain": "keep",
237
+ "submit": "give, send",
238
+ "timely": "prompt",
239
+ "viable": "practical, workable",
240
+ "elect": "choose, pick",
241
+ "it is": "(omit)",
242
+ "deem": "believe, consider, think",
243
+ "said": "the, this, that",
244
+ "same": "the, this, that",
245
+ "such": "the, this, that",
246
+ "type": "(omit)",
247
+ "vice": "instead of, versus",
248
+ }
249
+
250
+ ALL_TERM_PATTERNS: list[tuple[re.Pattern[str], str]] = [
251
+ (re.compile(r"\b" + re.escape(each_term) + r"\b", re.IGNORECASE), each_replacement)
252
+ for each_term, each_replacement in REPLACEMENT_BY_TERM.items()
253
+ ]
254
+
255
+ ALL_SOFTWARE_TERMS: frozenset[str] = frozenset(
256
+ {
257
+ "command",
258
+ "function",
259
+ "address",
260
+ "parameters",
261
+ "interface",
262
+ "component",
263
+ "monitor",
264
+ "request",
265
+ "validate",
266
+ "however",
267
+ "forward",
268
+ "subject",
269
+ "same",
270
+ "such",
271
+ "said",
272
+ "type",
273
+ "it is",
274
+ "there is",
275
+ "there are",
276
+ }
277
+ )
278
+
279
+ FENCED_CODE_BLOCK_PATTERN: re.Pattern[str] = re.compile(r"```[\s\S]*?```", re.MULTILINE)
280
+ INLINE_CODE_PATTERN: re.Pattern[str] = re.compile(r"`[^`]+`")
281
+ BLOCKQUOTE_LINE_PATTERN: re.Pattern[str] = re.compile(r"^\s*>.*$", re.MULTILINE)
282
+ URL_PATTERN: re.Pattern[str] = re.compile(r"https?://\S+", re.IGNORECASE)
283
+ FILE_PATH_PATTERN: re.Pattern[str] = re.compile(
284
+ r"(?<![\w/\\])(?:[A-Za-z]:[\\/]|~?[\\/]|\.{1,2}[\\/])(?:[\w.\-]+[\\/])*[\w.\-]+"
285
+ r"|(?:[\w.\-]+[\\/])+[\w.\-]+\.[A-Za-z0-9]+"
286
+ )
287
+
288
+ MARKDOWN_EXTENSION: str = ".md"
289
+ ASK_USER_QUESTION_TOOL_NAME: str = "AskUserQuestion"
290
+ ALL_WRITE_EDIT_TOOL_NAMES: frozenset[str] = frozenset({"Write", "Edit", "MultiEdit"})
291
+
292
+ USER_FACING_PLAIN_LANGUAGE_NOTICE: str = (
293
+ "Plain-language check: replace each flagged formal word with the simpler "
294
+ "word named in the reason."
295
+ )
@@ -19,6 +19,8 @@ HEADING_LINE_PATTERN: re.Pattern[str] = re.compile(r"^#+[ \t].*$", re.MULTILINE)
19
19
  BOLD_PAIR_PATTERN: re.Pattern[str] = re.compile(r"\*\*([^*]+?)\*\*")
20
20
  BULLET_MARKER_PATTERN: re.Pattern[str] = re.compile(r"^\s*[-*+]\s+", re.MULTILINE)
21
21
  BLOCKQUOTE_MARKER_PATTERN: re.Pattern[str] = re.compile(r"^\s*>\s+", re.MULTILINE)
22
+ BLOCKQUOTE_LINE_PATTERN: re.Pattern[str] = re.compile(r"^\s*>.*$", re.MULTILINE)
23
+ TABLE_ROW_LINE_PATTERN: re.Pattern[str] = re.compile(r"^\s*\|.*\|.*$", re.MULTILINE)
22
24
  LINK_TEXT_PATTERN: re.Pattern[str] = re.compile(r"\[([^\]]+)\]\([^)]+\)")
23
25
  WHITESPACE_RUN_PATTERN: re.Pattern[str] = re.compile(r"\s+")
24
26
 
@@ -115,6 +117,7 @@ __all__ = [
115
117
  "ALL_HEAVY_TESTING_HEADERS",
116
118
  "ALL_READABILITY_CLI_FLAG_TOKENS",
117
119
  "ATOMIC_WRITE_TEMP_SUFFIX",
120
+ "BLOCKQUOTE_LINE_PATTERN",
118
121
  "BLOCKQUOTE_MARKER_PATTERN",
119
122
  "BOLD_PAIR_PATTERN",
120
123
  "BULLET_MARKER_PATTERN",
@@ -147,6 +150,7 @@ __all__ = [
147
150
  "SELF_CLOSING_REFERENCE_MESSAGE_SUFFIX",
148
151
  "SELF_REFERENCE_PATTERN_TEMPLATE",
149
152
  "STANDARD_SHAPE",
153
+ "TABLE_ROW_LINE_PATTERN",
150
154
  "THIS_PR_OPENING_PATTERN",
151
155
  "TRIVIAL_BODY_CHAR_THRESHOLD",
152
156
  "TRIVIAL_SHAPE",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "1.48.0",
3
+ "version": "1.49.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,6 +2,8 @@
2
2
 
3
3
  **When this applies:** All prose you write — chat responses, `AskUserQuestion` questions and options, documentation and Markdown, PR and issue bodies, and commit messages. Anything a person reads.
4
4
 
5
+ **Hook enforcement:** `plain_language_blocker` (PreToolUse on `AskUserQuestion` and on Write|Edit|MultiEdit of `.md` targets) blocks a heavy word and names the everyday word to swap in. Code fences, inline code, blockquotes, URLs, and file paths are skipped so exact identifiers stay untouched. See `hooks.json` for registration.
6
+
5
7
  ## Rule
6
8
 
7
9
  Write so the reader understands it on the first pass, without hunting for extra context and without wading through more than the point requires. Favor everyday words, short sentences, and concrete phrasing. This is **plain language** (ISO 24495-1:2023; U.S. Plain Writing Act of 2010) — clear writing, not dumbed-down writing.