claude-dev-env 8.53.0 → 8.53.2
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/docs/second-claude-account.md +1 -2
- package/hooks/blocking/reply_length_gate.py +27 -0
- package/hooks/blocking/test_reply_length_gate.py +45 -0
- package/hooks/hooks_constants/reply_length_gate_constants.py +7 -0
- package/hooks/hooks_constants/test_reply_length_gate_constants.py +6 -0
- package/package.json +1 -1
- package/scripts/dev_env_scripts_constants/claude_account_constants.py +4 -4
- package/scripts/dev_env_scripts_constants/test_claude_account_constants.py +1 -10
- package/scripts/test_account_broker.py +12 -12
|
@@ -43,8 +43,7 @@ room.
|
|
|
43
43
|
|
|
44
44
|
| Account | Has room while |
|
|
45
45
|
|---|---|
|
|
46
|
-
|
|
|
47
|
-
| second and each extra profile | under 95% of its week and under 90% of its 5-hour window |
|
|
46
|
+
| every account | under 99% of its week and under 95% of its 5-hour window |
|
|
48
47
|
|
|
49
48
|
When no account has room, the job waits until the next account reset, or for one
|
|
50
49
|
hour when no reset is known. An unreadable meter never picks its account, and
|
|
@@ -10,6 +10,13 @@ It also denies a pull request number the reader cannot open::
|
|
|
10
10
|
flag: It merged in #4347.
|
|
11
11
|
ok: Both land in [PR 5256](https://github.com/owner/repo/pull/5256).
|
|
12
12
|
|
|
13
|
+
It denies a colon that joins two clauses on one line, and an em dash::
|
|
14
|
+
|
|
15
|
+
flag: That second reading matters: one message added 6.7k.
|
|
16
|
+
ok: That second reading matters. One message added 6.7k.
|
|
17
|
+
ok: Two checks failed at 9:47, then the list follows:
|
|
18
|
+
(each item on its own line below the colon)
|
|
19
|
+
|
|
13
20
|
It denies a banned word or phrase, matched whole and case-insensitive::
|
|
14
21
|
|
|
15
22
|
flag: The cause is likely the cache.
|
|
@@ -64,6 +71,8 @@ from hooks_constants.reply_length_gate_constants import (
|
|
|
64
71
|
CONFIG_FILE_ENCODING,
|
|
65
72
|
DECISION_CARD_OPTIONS_KEY,
|
|
66
73
|
DECISION_CARD_TOOL_NAME,
|
|
74
|
+
EM_DASH_MESSAGE,
|
|
75
|
+
EM_DASH_PATTERN,
|
|
67
76
|
FENCED_BLOCK_PATTERN,
|
|
68
77
|
HOOK_EVENT_NAME,
|
|
69
78
|
INLINE_CODE_PATTERN,
|
|
@@ -73,6 +82,8 @@ from hooks_constants.reply_length_gate_constants import (
|
|
|
73
82
|
MARKDOWN_LINK_PATTERN,
|
|
74
83
|
MAXIMUM_SENTENCE_COUNT,
|
|
75
84
|
MAXIMUM_WORDS_PER_SENTENCE,
|
|
85
|
+
MID_SENTENCE_COLON_MESSAGE,
|
|
86
|
+
MID_SENTENCE_COLON_PATTERN,
|
|
76
87
|
RETRY_INSTRUCTION,
|
|
77
88
|
SENTENCE_END_PATTERN,
|
|
78
89
|
SENTENCE_PREVIEW_SUFFIX,
|
|
@@ -126,6 +137,20 @@ def length_violation(reply_text: str) -> str | None:
|
|
|
126
137
|
return None
|
|
127
138
|
|
|
128
139
|
|
|
140
|
+
def mid_sentence_colon_violation(reply_text: str) -> str | None:
|
|
141
|
+
"""Return the deny reason for a colon followed by prose on its line, or None."""
|
|
142
|
+
if MID_SENTENCE_COLON_PATTERN.search(countable_text(reply_text)) is None:
|
|
143
|
+
return None
|
|
144
|
+
return MID_SENTENCE_COLON_MESSAGE
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def em_dash_violation(reply_text: str) -> str | None:
|
|
148
|
+
"""Return the deny reason for an em dash in the prose, or None."""
|
|
149
|
+
if EM_DASH_PATTERN.search(countable_text(reply_text)) is None:
|
|
150
|
+
return None
|
|
151
|
+
return EM_DASH_MESSAGE
|
|
152
|
+
|
|
153
|
+
|
|
129
154
|
def unlinked_pull_request_violation(reply_text: str) -> str | None:
|
|
130
155
|
"""Return the deny reason for a pull request number outside a link, or None."""
|
|
131
156
|
without_fences = FENCED_BLOCK_PATTERN.sub(" ", reply_text)
|
|
@@ -203,6 +228,8 @@ def tool_violation(tool_name: object, all_tool_input: dict[str, object]) -> tupl
|
|
|
203
228
|
return None
|
|
204
229
|
violation = (
|
|
205
230
|
length_violation(reply_text)
|
|
231
|
+
or mid_sentence_colon_violation(reply_text)
|
|
232
|
+
or em_dash_violation(reply_text)
|
|
206
233
|
or unlinked_pull_request_violation(reply_text)
|
|
207
234
|
or banned_word_violation(reply_text, configured_banned_words())
|
|
208
235
|
)
|
|
@@ -442,3 +442,48 @@ def test_should_apply_the_configured_list_to_decision_cards(
|
|
|
442
442
|
)
|
|
443
443
|
assert (probably_exit_code, synergy_exit_code) == (0, 2)
|
|
444
444
|
assert 'Banned word "synergy"' in stderr_text
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
QUOTED_COLON_SENTENCE = "That second reading matters: one message added 6.7k to Messages."
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def test_should_deny_the_quoted_mid_sentence_colon(
|
|
451
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
452
|
+
) -> None:
|
|
453
|
+
exit_code, stderr_text = run_gate(
|
|
454
|
+
monkeypatch, capsys, POST_TOOL_NAME, {"text": QUOTED_COLON_SENTENCE}
|
|
455
|
+
)
|
|
456
|
+
assert exit_code == 2
|
|
457
|
+
assert "colon" in stderr_text
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
@pytest.mark.parametrize(
|
|
461
|
+
"allowed_text",
|
|
462
|
+
[
|
|
463
|
+
"That second reading matters. One message added 6.7k to Messages.",
|
|
464
|
+
"Two checks failed:\n- lint\n- types",
|
|
465
|
+
"It fired at 9:47 today.",
|
|
466
|
+
"The call `a: int` passed.",
|
|
467
|
+
"See https://example.com/a: b for it.",
|
|
468
|
+
"Both land in [PR 5256](https://github.com/owner/repo/pull/5256).",
|
|
469
|
+
"```\nflag: x\n```",
|
|
470
|
+
],
|
|
471
|
+
)
|
|
472
|
+
def test_should_allow_a_colon_that_introduces_a_list_or_sits_in_a_span(
|
|
473
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], allowed_text: str
|
|
474
|
+
) -> None:
|
|
475
|
+
exit_code, stderr_text = run_gate(monkeypatch, capsys, REPLY_TOOL_NAME, {"text": allowed_text})
|
|
476
|
+
assert (exit_code, stderr_text) == (0, "")
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def test_should_deny_an_em_dash_and_allow_one_in_code(
|
|
480
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
481
|
+
) -> None:
|
|
482
|
+
denied_code, denied_text = run_gate(
|
|
483
|
+
monkeypatch, capsys, REPLY_TOOL_NAME, {"text": "The fix \u2014 a rename \u2014 is in."}
|
|
484
|
+
)
|
|
485
|
+
allowed_code, _ = run_gate(
|
|
486
|
+
monkeypatch, capsys, REPLY_TOOL_NAME, {"text": "The fix uses `\u2014` in a label."}
|
|
487
|
+
)
|
|
488
|
+
assert (denied_code, allowed_code) == (2, 0)
|
|
489
|
+
assert "em dash" in denied_text
|
|
@@ -24,6 +24,8 @@ MARKDOWN_LINK_PATTERN = re.compile(r"\[[^\]\n]*\]\([^)\s]*\)")
|
|
|
24
24
|
UNLINKED_PULL_REQUEST_PATTERN = re.compile(
|
|
25
25
|
r"\b(?:PRs?|pull requests?)\s*#?\d+|(?<![\w/&])#\d+", re.IGNORECASE
|
|
26
26
|
)
|
|
27
|
+
MID_SENTENCE_COLON_PATTERN = re.compile(r":[ \t]+\S")
|
|
28
|
+
EM_DASH_PATTERN = re.compile("\u2014")
|
|
27
29
|
LINE_BREAK_PATTERN = re.compile(r"\n+")
|
|
28
30
|
SENTENCE_END_PATTERN = re.compile(r"(?<=[.!?])\s+")
|
|
29
31
|
WORD_PATTERN = re.compile(r"[A-Za-z0-9]+(?:['.,-][A-Za-z0-9]+)*")
|
|
@@ -33,6 +35,11 @@ SENTENCE_PREVIEW_SUFFIX = "..."
|
|
|
33
35
|
RETRY_INSTRUCTION = " Cut the text and resend the call."
|
|
34
36
|
TOO_MANY_SENTENCES_MESSAGE = "Reply too long: {sentence_count} sentences, limit {sentence_limit}."
|
|
35
37
|
UNLINKED_PULL_REQUEST_MESSAGE = 'Pull request "{reference}" has no link. Write it as [PR N](https://github.com/<owner>/<repo>/pull/N).'
|
|
38
|
+
MID_SENTENCE_COLON_MESSAGE = (
|
|
39
|
+
"A colon joins two clauses on one line. Write two sentences, or end the line with the colon"
|
|
40
|
+
" and put the list on the lines below."
|
|
41
|
+
)
|
|
42
|
+
EM_DASH_MESSAGE = "An em dash is in the text. Use a period or a comma."
|
|
36
43
|
ALL_DEFAULT_BANNED_WORDS = (
|
|
37
44
|
"real",
|
|
38
45
|
"really",
|
|
@@ -45,3 +45,9 @@ def test_gate_reads_only_the_prose_fields_of_a_decision_card() -> None:
|
|
|
45
45
|
constants.DECISION_CARD_OPTIONS_KEY,
|
|
46
46
|
constants.ALL_DECISION_OPTION_PROSE_KEYS,
|
|
47
47
|
) == (("question", "context"), "options", ("label", "consequence"))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_colon_pattern_matches_a_colon_with_prose_after_it_on_the_line() -> None:
|
|
51
|
+
assert constants.MID_SENTENCE_COLON_PATTERN.search("matters: one message")
|
|
52
|
+
assert constants.MID_SENTENCE_COLON_PATTERN.search("at 9:47") is None
|
|
53
|
+
assert constants.MID_SENTENCE_COLON_PATTERN.search("items:\n- one") is None
|
package/package.json
CHANGED
|
@@ -130,16 +130,16 @@ WINDOWS_OS_NAME: str = "nt"
|
|
|
130
130
|
TEXT_ENCODING: str = "utf-8"
|
|
131
131
|
"""Encoding for the launcher file and the JSON reports."""
|
|
132
132
|
|
|
133
|
-
MAIN_WEEKLY_USED_CEILING_PERCENT: float =
|
|
133
|
+
MAIN_WEEKLY_USED_CEILING_PERCENT: float = 99.0
|
|
134
134
|
"""Main takes jobs only while its weekly use is under this percent."""
|
|
135
135
|
|
|
136
|
-
MAIN_SESSION_USED_CEILING_PERCENT: float =
|
|
136
|
+
MAIN_SESSION_USED_CEILING_PERCENT: float = 95.0
|
|
137
137
|
"""Main takes jobs only while its 5-hour use is under this percent."""
|
|
138
138
|
|
|
139
|
-
SECOND_WEEKLY_USED_CEILING_PERCENT: float =
|
|
139
|
+
SECOND_WEEKLY_USED_CEILING_PERCENT: float = 99.0
|
|
140
140
|
"""The second account takes jobs while its weekly use is under this percent."""
|
|
141
141
|
|
|
142
|
-
SECOND_SESSION_USED_CEILING_PERCENT: float =
|
|
142
|
+
SECOND_SESSION_USED_CEILING_PERCENT: float = 95.0
|
|
143
143
|
"""The second account takes jobs while its 5-hour use is under this percent."""
|
|
144
144
|
|
|
145
145
|
FULL_PERCENT: float = 100.0
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""The
|
|
1
|
+
"""The picker report and meter keys stay distinct."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -11,18 +11,9 @@ from dev_env_scripts_constants.claude_account_constants import (
|
|
|
11
11
|
JSON_SESSION_USED_PERCENT_KEY,
|
|
12
12
|
JSON_WEEKLY_RESETS_AT_KEY,
|
|
13
13
|
JSON_WEEKLY_USED_PERCENT_KEY,
|
|
14
|
-
MAIN_SESSION_USED_CEILING_PERCENT,
|
|
15
|
-
MAIN_WEEKLY_USED_CEILING_PERCENT,
|
|
16
|
-
SECOND_SESSION_USED_CEILING_PERCENT,
|
|
17
|
-
SECOND_WEEKLY_USED_CEILING_PERCENT,
|
|
18
14
|
)
|
|
19
15
|
|
|
20
16
|
|
|
21
|
-
def test_main_ceilings_sit_below_the_second_account_ceilings() -> None:
|
|
22
|
-
assert MAIN_WEEKLY_USED_CEILING_PERCENT < SECOND_WEEKLY_USED_CEILING_PERCENT
|
|
23
|
-
assert MAIN_SESSION_USED_CEILING_PERCENT < SECOND_SESSION_USED_CEILING_PERCENT
|
|
24
|
-
|
|
25
|
-
|
|
26
17
|
def test_picker_report_keys_never_collide() -> None:
|
|
27
18
|
all_report_keys = [
|
|
28
19
|
JSON_ACCOUNT_KEY,
|
|
@@ -112,8 +112,8 @@ def test_should_keep_list_order_when_tighter_windows_tie() -> None:
|
|
|
112
112
|
@pytest.mark.parametrize(
|
|
113
113
|
("weekly_reset", "weekly_left", "short_left"),
|
|
114
114
|
(
|
|
115
|
-
(NOW + timedelta(hours=4),
|
|
116
|
-
(NOW + timedelta(hours=4), 20,
|
|
115
|
+
(NOW + timedelta(hours=4), 1, 80),
|
|
116
|
+
(NOW + timedelta(hours=4), 20, 5),
|
|
117
117
|
),
|
|
118
118
|
)
|
|
119
119
|
def test_should_guard_main_at_each_limit(
|
|
@@ -158,7 +158,7 @@ def test_should_pick_an_extra_with_more_room_than_main() -> None:
|
|
|
158
158
|
assert decision.account == extra.account
|
|
159
159
|
|
|
160
160
|
|
|
161
|
-
@pytest.mark.parametrize(("main_short_left", "expected_name"), ((
|
|
161
|
+
@pytest.mark.parametrize(("main_short_left", "expected_name"), ((6, "main"), (5, "extra")))
|
|
162
162
|
def test_should_pick_main_only_while_under_its_5_hour_ceiling(
|
|
163
163
|
main_short_left: float, expected_name: str
|
|
164
164
|
) -> None:
|
|
@@ -166,7 +166,7 @@ def test_should_pick_main_only_while_under_its_5_hour_ceiling(
|
|
|
166
166
|
_account("main", Product.CLAUDE, main=True),
|
|
167
167
|
_meters(main_short_left, 95, weekly_reset=NOW + timedelta(days=5)),
|
|
168
168
|
)
|
|
169
|
-
extra = Reading(_account("extra", Product.CLAUDE), _meters(20,
|
|
169
|
+
extra = Reading(_account("extra", Product.CLAUDE), _meters(20, 2))
|
|
170
170
|
|
|
171
171
|
decision = choose_from_readings(Product.CLAUDE, (main, extra), now=NOW)
|
|
172
172
|
|
|
@@ -190,7 +190,7 @@ def test_should_wait_for_main_5_hour_reset_while_its_week_resets_days_away() ->
|
|
|
190
190
|
short_reset = NOW + timedelta(hours=2)
|
|
191
191
|
main = Reading(
|
|
192
192
|
_account("main", Product.CLAUDE, main=True),
|
|
193
|
-
_meters(
|
|
193
|
+
_meters(5, 50, short_reset=short_reset, weekly_reset=NOW + timedelta(days=3)),
|
|
194
194
|
)
|
|
195
195
|
|
|
196
196
|
decision = choose_from_readings(Product.CLAUDE, (main,), now=NOW)
|
|
@@ -202,8 +202,8 @@ def test_should_wait_for_main_5_hour_reset_while_its_week_resets_days_away() ->
|
|
|
202
202
|
@pytest.mark.parametrize(
|
|
203
203
|
("short_left", "weekly_left", "weekly_reset", "expected_reset"),
|
|
204
204
|
(
|
|
205
|
-
(
|
|
206
|
-
(80,
|
|
205
|
+
(5, 20, NOW + timedelta(hours=4), NOW + timedelta(hours=2)),
|
|
206
|
+
(80, 1, NOW + timedelta(hours=4), NOW + timedelta(hours=4)),
|
|
207
207
|
),
|
|
208
208
|
)
|
|
209
209
|
def test_should_wait_for_the_meter_or_window_that_blocks_main(
|
|
@@ -292,7 +292,7 @@ def test_should_wait_until_both_blocking_windows_reset() -> None:
|
|
|
292
292
|
|
|
293
293
|
def test_should_use_claude_extra_floors_and_wait_for_unread() -> None:
|
|
294
294
|
blocked = Reading(
|
|
295
|
-
_account("blocked", Product.CLAUDE), _meters(
|
|
295
|
+
_account("blocked", Product.CLAUDE), _meters(5, 1)
|
|
296
296
|
)
|
|
297
297
|
unread = Reading(_account("unread", Product.CLAUDE), None)
|
|
298
298
|
|
|
@@ -462,14 +462,14 @@ _EV_CLAUDE = _account("ev", Product.CLAUDE)
|
|
|
462
462
|
id="every-meter-unreadable",
|
|
463
463
|
),
|
|
464
464
|
pytest.param(
|
|
465
|
-
(Reading(_MAIN_CLAUDE, _meters(
|
|
465
|
+
(Reading(_MAIN_CLAUDE, _meters(5, 50, short_reset=NOW + timedelta(minutes=30))), Reading(_EV_CLAUDE, None)),
|
|
466
466
|
{},
|
|
467
467
|
"no readable account has room; meter unreadable for ev; next reset at 2026-10-03T00:30:00+00:00",
|
|
468
468
|
NOW + timedelta(minutes=30),
|
|
469
469
|
id="unreadable-beside-a-reset-before-the-check",
|
|
470
470
|
),
|
|
471
471
|
pytest.param(
|
|
472
|
-
(Reading(_MAIN_CLAUDE, _meters(
|
|
472
|
+
(Reading(_MAIN_CLAUDE, _meters(5, 50, short_reset=NOW + timedelta(hours=2))), Reading(_EV_CLAUDE, None)),
|
|
473
473
|
{},
|
|
474
474
|
"no readable account has room; meter unreadable for ev; next check at 2026-10-03T01:00:00+00:00",
|
|
475
475
|
NOW + timedelta(hours=1),
|
|
@@ -484,7 +484,7 @@ _EV_CLAUDE = _account("ev", Product.CLAUDE)
|
|
|
484
484
|
),
|
|
485
485
|
pytest.param(
|
|
486
486
|
(
|
|
487
|
-
Reading(_MAIN_CLAUDE, _meters(
|
|
487
|
+
Reading(_MAIN_CLAUDE, _meters(5, 50, short_reset=NOW + timedelta(hours=2))),
|
|
488
488
|
Reading(_EV_CLAUDE, _meters(5, 80, short_reset=NOW + timedelta(hours=3))),
|
|
489
489
|
),
|
|
490
490
|
{},
|
|
@@ -493,7 +493,7 @@ _EV_CLAUDE = _account("ev", Product.CLAUDE)
|
|
|
493
493
|
id="every-meter-read-with-known-resets",
|
|
494
494
|
),
|
|
495
495
|
pytest.param(
|
|
496
|
-
(Reading(_MAIN_CLAUDE, Meters(
|
|
496
|
+
(Reading(_MAIN_CLAUDE, Meters(5, None, 50, None)),),
|
|
497
497
|
{},
|
|
498
498
|
"no account has room; next check at 2026-10-03T01:00:00+00:00",
|
|
499
499
|
NOW + timedelta(hours=1),
|