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.
- package/_shared/advisor/reference/astra-rung.md +7 -7
- package/_shared/advisor/scripts/codex_astra_advisor.py +13 -13
- package/_shared/advisor/scripts/codex_astra_preflight.py +62 -33
- package/_shared/advisor/scripts/config/advisor_scripts_constants/astra_advisor_constants.py +5 -2
- package/_shared/advisor/scripts/tests/test_codex_astra_advisor.py +64 -33
- package/_shared/advisor/scripts/tests/test_codex_astra_preflight.py +125 -3
- package/_shared/pr-loop/scripts/check_convergence.py +23 -17
- package/_shared/pr-loop/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +4 -3
- package/_shared/pr-loop/scripts/test_check_convergence_codex.py +64 -25
- package/docs/codex-accounts.md +27 -31
- package/hooks/blocking/reply_length_gate.py +62 -1
- package/hooks/blocking/test_reply_length_gate.py +87 -0
- package/hooks/hooks_constants/reply_length_gate_constants.py +26 -0
- package/hooks/hooks_constants/test_reply_length_gate_constants.py +6 -0
- package/package.json +1 -1
- package/scripts/codex_account_choice.py +8 -303
- package/scripts/test_codex_account_choice.py +11 -273
|
@@ -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
|
|
33
|
-
(``scripts/
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
352
|
-
stays behind it: the
|
|
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
|
|
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
|
|
371
|
-
"""Return the installed
|
|
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(*
|
|
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
|
|
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(
|
|
383
|
-
|
|
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
|
|
396
|
+
if completed.returncode not in ALL_CODEX_BROKER_ACCEPTED_EXIT_CODES or not isinstance(answer, dict):
|
|
394
397
|
return None
|
|
395
|
-
|
|
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
|
-
|
|
72
|
+
ALL_CODEX_ACCOUNT_BROKER_RELATIVE_PARTS: tuple[str, ...] = (
|
|
73
73
|
"scripts",
|
|
74
|
-
"
|
|
74
|
+
"account_broker.py",
|
|
75
75
|
)
|
|
76
|
-
|
|
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
|
|
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
|
|
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
|
|
123
|
+
def _broker_that_must_not_run() -> str | None:
|
|
124
124
|
raise AssertionError(
|
|
125
|
-
"the
|
|
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",
|
|
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
|
|
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
|
|
517
|
-
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
|
516
|
+
def _install_fake_broker(
|
|
517
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, broker_source: str
|
|
518
518
|
) -> None:
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
monkeypatch.setattr(check_convergence, "
|
|
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
|
|
524
|
+
def test_should_read_normal_tier_from_broker_decision(
|
|
525
525
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
526
526
|
) -> None:
|
|
527
|
-
|
|
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
|
-
"
|
|
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
|
|
548
|
-
|
|
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
|
-
|
|
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
|
|
556
|
-
|
|
594
|
+
def test_should_locate_the_packaged_codex_account_broker() -> None:
|
|
595
|
+
broker_path = check_convergence._codex_account_broker_path()
|
|
557
596
|
|
|
558
|
-
assert
|
|
559
|
-
assert
|
|
597
|
+
assert broker_path.name == "account_broker.py"
|
|
598
|
+
assert broker_path.is_file()
|
package/docs/codex-accounts.md
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
# Codex accounts
|
|
2
2
|
|
|
3
|
-
The
|
|
4
|
-
|
|
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
|
|
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/
|
|
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
|
|
25
|
-
|
|
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
|
-
|
|
|
99
|
-
| No account over 10%, one or more over 1% | `luna` on the account with the most room
|
|
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
|
|
102
|
-
|
|
|
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/
|
|
106
|
-
{"
|
|
107
|
-
"
|
|
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 `
|
|
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/
|
|
112
|
+
python packages/claude-dev-env/scripts/account_broker.py check --product codex
|
|
115
113
|
```
|
|
116
114
|
|
|
117
|
-
`check` exits
|
|
118
|
-
|
|
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
|
|
123
|
-
`scripts/
|
|
124
|
-
installed, that is `~/.claude/scripts/
|
|
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
|
|
129
|
-
| `_shared/advisor/scripts/codex_astra_advisor.py` | Binds Astra only on `normal`, and runs Codex with `CODEX_HOME` set to `
|
|
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 =
|
|
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
|
+
)
|