claude-dev-env 8.38.3 → 8.40.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.
@@ -29,10 +29,10 @@ The frozen process env is only a fallback when that disk read fails (logged once
29
29
  A probe error does not waive the gate; the live GitHub checks still run.
30
30
 
31
31
  The Codex gate is conditional-required: it demands
32
- ``codex_clean_at == current_head`` only when the Codex account picker
33
- (``scripts/codex_account_choice.py choose``) answers the ``normal`` tier, so
32
+ ``codex_clean_at == current_head`` only when the account broker
33
+ (``scripts/account_broker.py choose --product codex``) answers the ``normal`` tier, so
34
34
  one signed-in account has room for a review. A ``luna`` or ``wait`` answer, an
35
- unreadable picker, the codex token, or ``codex_down`` never blocks ready.
35
+ unreadable broker, the codex token, or ``codex_down`` never blocks ready.
36
36
  """
37
37
 
38
38
  from __future__ import annotations
@@ -64,11 +64,12 @@ from check_convergence_thread_gates import (
64
64
  _count_unresolved_bot_threads,
65
65
  )
66
66
  from pr_converge_scripts_constants.convergence_gate_constants import (
67
- ALL_CODEX_ACCOUNT_PICKER_RELATIVE_PARTS,
67
+ ALL_CODEX_ACCOUNT_BROKER_RELATIVE_PARTS,
68
+ ALL_CODEX_BROKER_ACCEPTED_EXIT_CODES,
68
69
  BUGBOT_DOWN_BYPASS_NOTE,
69
70
  CLAUDE_JOB_DIR_ENV_VAR_NAME,
70
71
  CODEX_ACCOUNT_PICK_TIMEOUT_SECONDS,
71
- CODEX_ACCOUNT_PICKER_CHOOSE_COMMAND,
72
+ CODEX_ACCOUNT_BROKER_CHOOSE_COMMAND,
72
73
  CODEX_BYPASS_DETAIL,
73
74
  CODEX_CLEAN_AT_STATE_KEY,
74
75
  CODEX_CLEAN_DETAIL_TEMPLATE,
@@ -344,16 +345,16 @@ def _evaluate_codex_clean(
344
345
 
345
346
  ::
346
347
 
347
- clean stamp on head -> pass (picker never runs)
348
+ clean stamp on head -> pass (broker never runs)
348
349
  no stamp, tier luna, wait, or None -> skip (never blocks)
349
350
  no stamp, tier normal -> fail
350
351
 
351
- A stamp on the current HEAD settles the gate on its own, so the picker
352
- stays behind it: the picker starts one Codex app server per account, and
352
+ A stamp on the current HEAD settles the gate on its own, so the broker
353
+ stays behind it: the broker starts one Codex app server per account, and
353
354
  the common already-clean tick has no reason to pay for them.
354
355
 
355
356
  Args:
356
- read_codex_tier: Returns the picker's tier, or None when the picker cannot say.
357
+ read_codex_tier: Returns the broker's tier, or None when the broker cannot say.
357
358
  codex_clean_at: HEAD SHA where Codex last reported clean, or None.
358
359
  head_sha: Current PR HEAD commit SHA.
359
360
 
@@ -367,20 +368,22 @@ def _evaluate_codex_clean(
367
368
  return False, CODEX_MISSING_CLEAN_DETAIL_TEMPLATE % _short_sha(head_sha)
368
369
 
369
370
 
370
- def _codex_account_picker_path() -> Path:
371
- """Return the installed Codex account picker beside this shared tree."""
371
+ def _codex_account_broker_path() -> Path:
372
+ """Return the installed account broker beside this shared tree."""
372
373
  shared_root = Path(__file__).resolve().parents[SHARED_PACKAGE_ROOT_PARENT_INDEX]
373
- return shared_root.joinpath(*ALL_CODEX_ACCOUNT_PICKER_RELATIVE_PARTS)
374
+ return shared_root.joinpath(*ALL_CODEX_ACCOUNT_BROKER_RELATIVE_PARTS)
374
375
 
375
376
 
376
377
  def _read_codex_tier() -> str | None:
377
- """Ask the Codex account picker which tier a review runs on, or None when it cannot say."""
378
+ """Ask the account broker which tier a review runs on, or None when it cannot say."""
378
379
  try:
379
380
  completed = subprocess.run(
380
381
  [
381
382
  sys.executable,
382
- str(_codex_account_picker_path()),
383
- CODEX_ACCOUNT_PICKER_CHOOSE_COMMAND,
383
+ str(_codex_account_broker_path()),
384
+ CODEX_ACCOUNT_BROKER_CHOOSE_COMMAND,
385
+ "--product",
386
+ "codex",
384
387
  ],
385
388
  capture_output=True,
386
389
  text=True,
@@ -390,9 +393,12 @@ def _read_codex_tier() -> str | None:
390
393
  answer = json.loads(completed.stdout)
391
394
  except (OSError, subprocess.SubprocessError, json.JSONDecodeError):
392
395
  return None
393
- if completed.returncode != 0 or not isinstance(answer, dict):
396
+ if completed.returncode not in ALL_CODEX_BROKER_ACCEPTED_EXIT_CODES or not isinstance(answer, dict):
394
397
  return None
395
- tier = answer.get(CODEX_TIER_KEY)
398
+ decision = answer.get("decision")
399
+ if not isinstance(decision, dict):
400
+ return None
401
+ tier = decision.get(CODEX_TIER_KEY)
396
402
  return tier if isinstance(tier, str) else None
397
403
 
398
404
 
@@ -69,11 +69,12 @@ CODEX_CLEAN_AT_STATE_KEY: str = "codex_clean_at"
69
69
  CODEX_DOWN_STATE_KEY: str = "codex_down"
70
70
  MINIMUM_ABBREVIATED_SHA_LENGTH: int = 7
71
71
  SHARED_PACKAGE_ROOT_PARENT_INDEX: int = 3
72
- ALL_CODEX_ACCOUNT_PICKER_RELATIVE_PARTS: tuple[str, ...] = (
72
+ ALL_CODEX_ACCOUNT_BROKER_RELATIVE_PARTS: tuple[str, ...] = (
73
73
  "scripts",
74
- "codex_account_choice.py",
74
+ "account_broker.py",
75
75
  )
76
- CODEX_ACCOUNT_PICKER_CHOOSE_COMMAND: str = "choose"
76
+ CODEX_ACCOUNT_BROKER_CHOOSE_COMMAND: str = "choose"
77
77
  CODEX_ACCOUNT_PICK_TIMEOUT_SECONDS: float = 150.0
78
78
  CODEX_TIER_KEY: str = "tier"
79
+ ALL_CODEX_BROKER_ACCEPTED_EXIT_CODES: tuple[int, ...] = (0, 3)
79
80
  CODEX_TIER_NORMAL: str = "normal"
@@ -1,7 +1,7 @@
1
1
  """Fixture-driven tests for the conditional Codex convergence gate.
2
2
 
3
3
  Covers: required-and-clean, required-and-dirty, skipped-by-tier,
4
- skipped-by-token, skipped-by-down, and the read of the Codex account picker's
4
+ skipped-by-token, skipped-by-down, and the read of the account broker's
5
5
  ``choose`` answer. The required tier comes from ``CODEX_TIER_NORMAL``.
6
6
  """
7
7
 
@@ -117,15 +117,15 @@ def should_pass_when_codex_required_and_clean(
117
117
  assert "All pre-conditions met" in captured
118
118
 
119
119
 
120
- def should_pass_without_running_the_picker_when_the_clean_stamp_is_on_head(
120
+ def test_should_pass_without_running_the_broker_when_the_clean_stamp_is_on_head(
121
121
  monkeypatch: pytest.MonkeyPatch,
122
122
  ) -> None:
123
- def _picker_that_must_not_run() -> str | None:
123
+ def _broker_that_must_not_run() -> str | None:
124
124
  raise AssertionError(
125
- "the Codex account picker must not run when codex_clean_at is on HEAD"
125
+ "the account broker must not run when codex_clean_at is on HEAD"
126
126
  )
127
127
 
128
- monkeypatch.setattr(check_convergence, "_read_codex_tier", _picker_that_must_not_run)
128
+ monkeypatch.setattr(check_convergence, "_read_codex_tier", _broker_that_must_not_run)
129
129
 
130
130
  is_passed, detail = check_convergence._evaluate_codex_clean(
131
131
  read_codex_tier=check_convergence._read_codex_tier,
@@ -228,7 +228,7 @@ def should_fail_when_codex_required_and_dirty(
228
228
 
229
229
 
230
230
  @pytest.mark.parametrize("tier_without_room", ALL_TIERS_WITHOUT_ROOM_FOR_REVIEW)
231
- def should_skip_when_the_picker_names_no_account_with_room_for_review(
231
+ def test_should_skip_when_the_broker_names_no_account_with_room_for_review(
232
232
  tier_without_room: str, tmp_path: Path, capsys: pytest.CaptureFixture[str]
233
233
  ) -> None:
234
234
  fixture_path = _write_fixture(
@@ -513,47 +513,86 @@ def should_accept_codex_clean_at_flag_in_parsed_arguments() -> None:
513
513
  assert arguments.codex_clean_at == HEAD_SHA
514
514
 
515
515
 
516
- def _install_fake_picker(
517
- tmp_path: Path, monkeypatch: pytest.MonkeyPatch, picker_source: str
516
+ def _install_fake_broker(
517
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, broker_source: str
518
518
  ) -> None:
519
- picker_path = tmp_path / "codex_account_choice.py"
520
- picker_path.write_text(picker_source, encoding="utf-8")
521
- monkeypatch.setattr(check_convergence, "_codex_account_picker_path", lambda: picker_path)
519
+ broker_path = tmp_path / "account_broker.py"
520
+ broker_path.write_text(broker_source, encoding="utf-8")
521
+ monkeypatch.setattr(check_convergence, "_codex_account_broker_path", lambda: broker_path)
522
522
 
523
523
 
524
- def should_read_the_tier_the_picker_chooses(
524
+ def test_should_read_normal_tier_from_broker_decision(
525
525
  tmp_path: Path, monkeypatch: pytest.MonkeyPatch
526
526
  ) -> None:
527
- _install_fake_picker(
527
+ broker_path = tmp_path / "account_broker.py"
528
+ broker_path.write_text(
529
+ "import json, sys\n"
530
+ "assert sys.argv[1:] == ['choose', '--product', 'codex']\n"
531
+ "print(json.dumps({'decision': {'tier': 'normal', 'reason': 'room'}, 'accounts': []}))\n",
532
+ encoding="utf-8",
533
+ )
534
+ monkeypatch.setattr(check_convergence, "_codex_account_broker_path", lambda: broker_path)
535
+
536
+ assert check_convergence._read_codex_tier() == CODEX_TIER_NORMAL
537
+
538
+
539
+ def test_should_read_wait_tier_from_broker_exit_three(
540
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
541
+ ) -> None:
542
+ broker_path = tmp_path / "account_broker.py"
543
+ broker_path.write_text(
544
+ "import json, sys\n"
545
+ "assert sys.argv[1:] == ['choose', '--product', 'codex']\n"
546
+ "print(json.dumps({'decision': {'tier': 'wait', 'reason': 'no room'}, 'accounts': []}))\n"
547
+ "sys.exit(3)\n",
548
+ encoding="utf-8",
549
+ )
550
+ monkeypatch.setattr(check_convergence, "_codex_account_broker_path", lambda: broker_path)
551
+
552
+ assert check_convergence._read_codex_tier() == "wait"
553
+
554
+ passed, detail = check_convergence._evaluate_codex_clean(
555
+ read_codex_tier=check_convergence._read_codex_tier,
556
+ codex_clean_at=None,
557
+ head_sha=HEAD_SHA,
558
+ )
559
+ assert passed
560
+ assert "skipped" in detail
561
+
562
+
563
+ def test_should_read_the_tier_the_broker_chooses(
564
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
565
+ ) -> None:
566
+ _install_fake_broker(
528
567
  tmp_path,
529
568
  monkeypatch,
530
569
  "import json, sys\n"
531
- "assert sys.argv[1:] == ['choose']\n"
532
- "print(json.dumps({'tier': 'normal', 'account': 'codex-2'}))\n",
570
+ "assert sys.argv[1:] == ['choose', '--product', 'codex']\n"
571
+ "print(json.dumps({'decision': {'tier': 'normal', 'account': 'codex-2'}, 'accounts': []}))\n",
533
572
  )
534
573
 
535
574
  assert check_convergence._read_codex_tier() == CODEX_TIER_NORMAL
536
575
 
537
576
 
538
577
  @pytest.mark.parametrize(
539
- "picker_source",
578
+ "broker_source",
540
579
  [
541
- "import sys\nprint('{\"tier\": \"normal\"}')\nsys.exit(1)\n",
580
+ "import sys\nprint('{\"decision\": {\"tier\": \"normal\"}}')\nsys.exit(1)\n",
542
581
  "print('not json')\n",
543
582
  "print('[\"normal\"]')\n",
544
- "print('{\"tier\": 1}')\n",
583
+ "print('{\"decision\": {\"tier\": 1}}')\n",
545
584
  ],
546
585
  )
547
- def should_read_no_tier_when_the_picker_fails_or_answers_out_of_shape(
548
- picker_source: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
586
+ def test_should_read_no_tier_when_the_broker_fails_or_answers_out_of_shape(
587
+ broker_source: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
549
588
  ) -> None:
550
- _install_fake_picker(tmp_path, monkeypatch, picker_source)
589
+ _install_fake_broker(tmp_path, monkeypatch, broker_source)
551
590
 
552
591
  assert check_convergence._read_codex_tier() is None
553
592
 
554
593
 
555
- def should_locate_the_packaged_codex_account_picker() -> None:
556
- picker_path = check_convergence._codex_account_picker_path()
594
+ def test_should_locate_the_packaged_codex_account_broker() -> None:
595
+ broker_path = check_convergence._codex_account_broker_path()
557
596
 
558
- assert picker_path.name == "codex_account_choice.py"
559
- assert picker_path.is_file()
597
+ assert broker_path.name == "account_broker.py"
598
+ assert broker_path.is_file()
@@ -1,15 +1,16 @@
1
1
  # Codex accounts
2
2
 
3
- The codex_account_choice picker spreads agent work across the Codex accounts
4
- on one machine. Each account signs in under its own Codex home, and every home
3
+ The account broker assigns agent work across the Codex accounts.
4
+ Each account signs in under its own Codex home, and every home
5
5
  shares the same Codex setup: config, rules, skills, plugins, prompts and agents.
6
- A job asks the picker which account to use.
6
+ A job asks the broker which account to use.
7
7
 
8
8
  ## Pieces
9
9
 
10
10
  | File | What it does |
11
11
  |---|---|
12
- | `scripts/codex_account_choice.py` | `choose` names the account and tier a job runs on, `check` tells a running job whether its account is still above a floor, `sync` links the shared setup into every account's home, `setup` and `install` write one launcher per account |
12
+ | `scripts/account_broker.py` | `choose --product codex` names the account and tier a job runs on; `check --product codex` reports whether any account has room |
13
+ | `scripts/codex_account_choice.py` | `sync` links the shared setup into every account's home; `setup` and `install` write one launcher per account |
13
14
  | `scripts/codex_account_meters.py` | Reads one account's rate-limit windows through `codex app-server` with `CODEX_HOME` set to that account's home |
14
15
  | `scripts/dev_env_scripts_constants/codex_account_constants.py` | The fallback account names, the roster variable and file, the launcher template, the shared entry names, the 10% bar, the 1% Luna stop, and the 20% 5-hour floor for Luna |
15
16
 
@@ -21,8 +22,8 @@ The account roster names the accounts and their order. It comes from the
21
22
  in `account-launchers.json` under the profiles root. When neither exists, the
22
23
  accounts are `codex-1`, `codex-2`, `codex-3` and `codex-4`.
23
24
 
24
- `choose`, `sync` and `check` all work on that list, in that order. `check`
25
- accepts only a name on it. Each account's home is `~/.codex-profiles/<name>`, or
25
+ The broker's `choose` and `check` commands and the setup commands use that list.
26
+ Each account's home is `~/.codex-profiles/<name>`, or
26
27
  `<name>` under `CODEX_PROFILES_ROOT` when set. `~/.codex` holds the shared setup
27
28
  and is never an account. A name uses letters, digits, hyphens or underscores.
28
29
  `main`, `wait` and the Windows device names are refused.
@@ -83,50 +84,45 @@ folder under the profiles root stays, with its `auth.json`, so typing the name
83
84
  again restores it. A blank first line empties the roster and moves every launcher
84
85
  aside.
85
86
 
86
- `check <name>` reads a saved name the same way:
87
-
88
- ```
89
- python packages/claude-dev-env/scripts/codex_account_choice.py check alpha --floor 1
90
- ```
91
-
92
87
  ## Which account a job uses
93
88
 
94
89
  Room is the smaller of an account's two windows: the 5-hour window and the week.
95
90
 
96
91
  | Condition | Answer |
97
92
  |---|---|
98
- | First account in order with more than 10% left | `normal` on that account |
99
- | No account over 10%, one or more over 1% | `luna` on the account with the most room, `stop_below_percent` 1 |
93
+ | One or more accounts with more than 10% left | `normal` on the account with the most room |
94
+ | No account over 10%, one or more over 1% | `luna` on the account with the most room |
100
95
  | An account that reports a 5-hour window, with under 20% of that window left | never takes `luna` |
101
- | No account over 1% | `wait`, naming the account whose blocking windows reset first |
102
- | Account not signed in, or its meter unread | skipped, with the reason in `accounts` |
96
+ | No account over 1% | `wait`, naming the next reset when known |
97
+ | A meter is unread | The account has `meters: null` in `accounts` |
103
98
 
104
99
  ```
105
- python packages/claude-dev-env/scripts/codex_account_choice.py choose
106
- {"tier": "normal", "account": "codex-1", "codex_home": "...\\codex-1", "percent_left": 62.0,
107
- "stop_below_percent": null, "reason": "codex-1 has 62% left", "accounts": [...]}
100
+ python packages/claude-dev-env/scripts/account_broker.py choose --product codex
101
+ {"decision": {"action": "run", "account": "codex-1", "home": "...\\codex-1",
102
+ "resets_at": null, "reason": "codex-1 has 62% left", "tier": "normal"},
103
+ "accounts": [{"name": "codex-1", "home": "...\\codex-1", "is_main": false,
104
+ "meters": {"session_percent_left": 62.0, "session_resets_at": null,
105
+ "weekly_percent_left": 70.0, "weekly_resets_at": null}}]}
108
106
  ```
109
107
 
110
- A job runs Codex with `CODEX_HOME` set to `codex_home`. On `luna`, the job runs
108
+ A job runs Codex with `CODEX_HOME` set to `decision.home`. On `luna`, the job runs
111
109
  Luna and polls `check` between steps:
112
110
 
113
111
  ```
114
- python packages/claude-dev-env/scripts/codex_account_choice.py check codex-2 --floor 1
112
+ python packages/claude-dev-env/scripts/account_broker.py check --product codex
115
113
  ```
116
114
 
117
- `check` exits 0 while the account has more than the floor left, and 3 once it has
118
- not or its meter is unread. The job stops on 3.
115
+ `check` exits 3 while every account is below its floor, and 0 when an account
116
+ has room. The job stops on 3. `choose` also exits 3 for `wait` and still prints
117
+ the JSON answer.
119
118
 
120
119
  ## Shared callers
121
120
 
122
- Two scripts under `_shared` run `choose` by path. Each finds the picker at
123
- `scripts/codex_account_choice.py` in the directory that holds `_shared`. Once
124
- installed, that is `~/.claude/scripts/codex_account_choice.py`.
121
+ Two scripts under `_shared` run `choose --product codex` by path. Each finds the
122
+ broker at `scripts/account_broker.py` in the directory that holds `_shared`.
123
+ Once installed, that is `~/.claude/scripts/account_broker.py`.
125
124
 
126
125
  | Caller | What it does with the answer |
127
126
  |---|---|
128
- | `_shared/pr-loop/scripts/check_convergence.py` | Requires a Codex clean stamp on HEAD only on `normal`. `luna`, `wait`, or a failed picker skips the Codex gate |
129
- | `_shared/advisor/scripts/codex_astra_advisor.py` | Binds Astra only on `normal`, and runs Codex with `CODEX_HOME` set to `codex_home` |
130
-
131
- Pass `--codex-path` when `codex` is off PATH. Without it the picker also tries
132
- the desktop install path under the user home.
127
+ | `_shared/pr-loop/scripts/check_convergence.py` | Requires a Codex clean stamp on HEAD only on `normal`. `luna`, `wait`, or a failed broker skips the Codex gate |
128
+ | `_shared/advisor/scripts/codex_astra_advisor.py` | Binds Astra only on `normal`, and runs Codex with `CODEX_HOME` set to `decision.home` |
@@ -9,6 +9,15 @@ It also denies a pull request number the reader cannot open::
9
9
  flag: Both land in PR 5256.
10
10
  flag: It merged in #4347.
11
11
  ok: Both land in [PR 5256](https://github.com/owner/repo/pull/5256).
12
+
13
+ It denies a banned word or phrase, matched whole and case-insensitive::
14
+
15
+ flag: The cause is likely the cache.
16
+ ok: The cache log shows the miss at 12:04.
17
+
18
+ The gate uses the ``banned_words`` list in ``~/.claude/reply-banned-words.json``,
19
+ or in the file that CLAUDE_REPLY_BANNED_WORDS_PATH names. Without a valid
20
+ list there, it uses ALL_DEFAULT_BANNED_WORDS.
12
21
  Each non-empty line counts as its own sentence, so a list counts one
13
22
  sentence per item. URLs, markdown link targets, inline code spans, and
14
23
  fenced blocks carry no words.
@@ -20,6 +29,8 @@ later pass, so this gate denies it and the model resends a shorter one.
20
29
 
21
30
  from __future__ import annotations
22
31
 
32
+ import os
33
+ import re
23
34
  import sys
24
35
  from pathlib import Path
25
36
 
@@ -27,12 +38,22 @@ hooks_root_directory = str(Path(__file__).resolve().parent.parent)
27
38
  if hooks_root_directory not in sys.path:
28
39
  sys.path.insert(0, hooks_root_directory)
29
40
 
41
+ from json_file_reader import read_json_object
30
42
  from hooks_constants.hook_block_logger import log_hook_block
31
43
  from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
32
44
  from hooks_constants.reply_length_gate_constants import (
33
45
  ALL_CHECKED_TOOL_NAMES,
46
+ ALL_DEFAULT_BANNED_WORDS,
34
47
  ALLOW_EXIT_CODE,
48
+ BANNED_WORD_MESSAGE,
49
+ BANNED_WORD_PART_SEPARATOR,
50
+ BANNED_WORD_PATTERN_TEMPLATE,
51
+ BANNED_WORDS_FILE_NAME,
52
+ BANNED_WORDS_JSON_KEY,
53
+ BANNED_WORDS_PATH_ENV_VAR,
35
54
  BLOCK_EXIT_CODE,
55
+ CLAUDE_HOME_DIRECTORY_NAME,
56
+ CONFIG_FILE_ENCODING,
36
57
  FENCED_BLOCK_PATTERN,
37
58
  HOOK_EVENT_NAME,
38
59
  INLINE_CODE_PATTERN,
@@ -106,6 +127,42 @@ def unlinked_pull_request_violation(reply_text: str) -> str | None:
106
127
  return UNLINKED_PULL_REQUEST_MESSAGE.format(reference=unlinked_match.group(0))
107
128
 
108
129
 
130
+ def banned_words_config_path() -> Path:
131
+ """Return the banned-words file named by the environment, or the one in the Claude home."""
132
+ path_override = os.environ.get(BANNED_WORDS_PATH_ENV_VAR)
133
+ if path_override:
134
+ return Path(path_override)
135
+ return Path.home() / CLAUDE_HOME_DIRECTORY_NAME / BANNED_WORDS_FILE_NAME
136
+
137
+
138
+ def configured_banned_words() -> tuple[str, ...]:
139
+ """Return the configured banned words, or the defaults when no valid list is configured."""
140
+ config_document = read_json_object(banned_words_config_path(), CONFIG_FILE_ENCODING)
141
+ if config_document is None:
142
+ return ALL_DEFAULT_BANNED_WORDS
143
+ all_configured_words = config_document.get(BANNED_WORDS_JSON_KEY)
144
+ if not isinstance(all_configured_words, list):
145
+ return ALL_DEFAULT_BANNED_WORDS
146
+ return tuple(
147
+ each_word.strip()
148
+ for each_word in all_configured_words
149
+ if isinstance(each_word, str) and each_word.strip()
150
+ )
151
+
152
+
153
+ def banned_word_violation(reply_text: str, all_banned_words: tuple[str, ...]) -> str | None:
154
+ """Return the deny reason for the first banned word in the prose, or None."""
155
+ prose_text = countable_text(reply_text)
156
+ for each_banned_word in all_banned_words:
157
+ word_pattern = BANNED_WORD_PART_SEPARATOR.join(
158
+ re.escape(each_part) for each_part in each_banned_word.split()
159
+ )
160
+ whole_word_pattern = BANNED_WORD_PATTERN_TEMPLATE.format(word_pattern=word_pattern)
161
+ if re.search(whole_word_pattern, prose_text, re.IGNORECASE):
162
+ return BANNED_WORD_MESSAGE.format(banned_word=each_banned_word)
163
+ return None
164
+
165
+
109
166
  def main() -> int:
110
167
  hook_input = read_hook_input_dictionary_from_stdin()
111
168
  if hook_input is None:
@@ -119,7 +176,11 @@ def main() -> int:
119
176
  reply_text = tool_input.get(TEXT_KEY)
120
177
  if not isinstance(reply_text, str):
121
178
  return ALLOW_EXIT_CODE
122
- violation = length_violation(reply_text) or unlinked_pull_request_violation(reply_text)
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
+ )
123
184
  if violation is None:
124
185
  return ALLOW_EXIT_CODE
125
186
  block_reason = violation + RETRY_INSTRUCTION
@@ -235,3 +235,90 @@ def test_should_allow_a_bare_pull_request_url(
235
235
  {"text": "Pull request: https://github.com/owner/repo/pull/5256"},
236
236
  )
237
237
  assert exit_code == 0
238
+
239
+
240
+ def test_should_deny_a_reply_that_says_likely_and_name_the_word(
241
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
242
+ ) -> None:
243
+ exit_code, stderr_text = run_gate(
244
+ monkeypatch,
245
+ capsys,
246
+ REPLY_TOOL_NAME,
247
+ {"text": "The back-test flagged the garland star. It likely flags the call button."},
248
+ )
249
+ assert exit_code == 2
250
+ assert 'Banned word "likely"' in stderr_text
251
+
252
+
253
+ def test_should_allow_the_same_reply_without_the_banned_word(
254
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
255
+ ) -> None:
256
+ exit_code, stderr_text = run_gate(
257
+ monkeypatch,
258
+ capsys,
259
+ REPLY_TOOL_NAME,
260
+ {"text": "The back-test flagged the garland star. It flags the call button in 7 themes."},
261
+ )
262
+ assert (exit_code, stderr_text) == (0, "")
263
+
264
+
265
+ @pytest.mark.parametrize(
266
+ "reply_text",
267
+ ["Really fast.", "A real-world case.", "In reality it passed.", "ACTUALLY done."],
268
+ )
269
+ def test_should_deny_default_banned_words_in_any_case_and_spacing(
270
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], reply_text: str
271
+ ) -> None:
272
+ exit_code, _ = run_gate(monkeypatch, capsys, POST_TOOL_NAME, {"text": reply_text})
273
+ assert exit_code == 2
274
+
275
+
276
+ @pytest.mark.parametrize(
277
+ "reply_text",
278
+ [
279
+ "I realized the cache missed.",
280
+ "Run `actual_count` again.",
281
+ "See https://example.com/likely.",
282
+ ],
283
+ )
284
+ def test_should_allow_banned_words_inside_longer_words_code_and_urls(
285
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], reply_text: str
286
+ ) -> None:
287
+ exit_code, _ = run_gate(monkeypatch, capsys, REPLY_TOOL_NAME, {"text": reply_text})
288
+ assert exit_code == 0
289
+
290
+
291
+ def test_should_replace_the_defaults_with_the_configured_list(
292
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
293
+ ) -> None:
294
+ config_path = tmp_path / ".claude" / "reply-banned-words.json"
295
+ config_path.parent.mkdir()
296
+ config_path.write_text(json.dumps({"banned_words": ["synergy"]}), encoding="utf-8")
297
+ likely_exit_code, _ = run_gate(
298
+ monkeypatch, capsys, REPLY_TOOL_NAME, {"text": "It likely passed."}
299
+ )
300
+ synergy_exit_code, stderr_text = run_gate(
301
+ monkeypatch, capsys, REPLY_TOOL_NAME, {"text": "Synergy shipped."}
302
+ )
303
+ assert (likely_exit_code, synergy_exit_code) == (0, 2)
304
+ assert 'Banned word "synergy"' in stderr_text
305
+
306
+
307
+ def test_should_read_the_list_from_the_path_the_environment_names(
308
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
309
+ ) -> None:
310
+ config_path = tmp_path / "words.json"
311
+ config_path.write_text(json.dumps({"banned_words": ["basically"]}), encoding="utf-8")
312
+ monkeypatch.setenv("CLAUDE_REPLY_BANNED_WORDS_PATH", str(config_path))
313
+ exit_code, _ = run_gate(monkeypatch, capsys, REPLY_TOOL_NAME, {"text": "Basically done."})
314
+ assert exit_code == 2
315
+
316
+
317
+ def test_should_keep_the_defaults_when_the_config_file_is_malformed(
318
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
319
+ ) -> None:
320
+ config_path = tmp_path / ".claude" / "reply-banned-words.json"
321
+ config_path.parent.mkdir()
322
+ config_path.write_text("{not json", encoding="utf-8")
323
+ exit_code, _ = run_gate(monkeypatch, capsys, REPLY_TOOL_NAME, {"text": "It likely passed."})
324
+ assert exit_code == 2
@@ -28,6 +28,32 @@ SENTENCE_PREVIEW_SUFFIX = "..."
28
28
  RETRY_INSTRUCTION = " Cut the text and resend the call."
29
29
  TOO_MANY_SENTENCES_MESSAGE = "Reply too long: {sentence_count} sentences, limit {sentence_limit}."
30
30
  UNLINKED_PULL_REQUEST_MESSAGE = 'Pull request "{reference}" has no link. Write it as [PR N](https://github.com/<owner>/<repo>/pull/N).'
31
+ ALL_DEFAULT_BANNED_WORDS = (
32
+ "real",
33
+ "really",
34
+ "in reality",
35
+ "genuine",
36
+ "genuinely",
37
+ "actual",
38
+ "actually",
39
+ "likely",
40
+ "unlikely",
41
+ "probably",
42
+ "seems",
43
+ "seem",
44
+ "seemingly",
45
+ "suspect",
46
+ "guess",
47
+ "my theory",
48
+ )
49
+ BANNED_WORD_PART_SEPARATOR = r"\s+"
50
+ BANNED_WORD_PATTERN_TEMPLATE = r"(?<![A-Za-z0-9]){word_pattern}(?![A-Za-z0-9])"
51
+ BANNED_WORDS_JSON_KEY = "banned_words"
52
+ BANNED_WORDS_FILE_NAME = "reply-banned-words.json"
53
+ BANNED_WORDS_PATH_ENV_VAR = "CLAUDE_REPLY_BANNED_WORDS_PATH"
54
+ CLAUDE_HOME_DIRECTORY_NAME = ".claude"
55
+ CONFIG_FILE_ENCODING = "utf-8"
56
+ BANNED_WORD_MESSAGE = 'Banned word "{banned_word}". Delete it and name the evidence: the log line, the check, the file and line.'
31
57
  LONG_SENTENCE_MESSAGE = (
32
58
  'Sentence too long: {word_count} words, limit {word_limit}: "{sentence_preview}".'
33
59
  )
@@ -13,3 +13,9 @@ def test_gate_checks_the_two_chat_tools_that_post_to_the_user() -> None:
13
13
 
14
14
  def test_gate_exit_codes_match_the_hook_contract() -> None:
15
15
  assert (constants.ALLOW_EXIT_CODE, constants.BLOCK_EXIT_CODE) == (0, 2)
16
+
17
+
18
+ def test_default_banned_words_hold_the_hedges_and_intensifiers() -> None:
19
+ assert {"likely", "probably", "seems", "real", "actually", "genuine"} <= set(
20
+ constants.ALL_DEFAULT_BANNED_WORDS
21
+ )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.38.3",
3
+ "version": "8.40.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {