claude-dev-env 8.52.1 → 8.53.1
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/advisory/auto_mode_denial_quick_fix.py +156 -0
- package/hooks/advisory/test_auto_mode_denial_quick_fix.py +113 -0
- package/hooks/hooks.json +12 -0
- package/hooks/hooks_constants/auto_mode_denial_quick_fix_constants.py +79 -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
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PermissionDenied advisor: a proposed quick fix for each auto mode denial.
|
|
3
|
+
|
|
4
|
+
This hook never retries the denied call. It reads the denial, names the
|
|
5
|
+
classifier rule from the bracketed label in ``denial_reason``, drafts one
|
|
6
|
+
``autoMode.allow`` entry for the denied action, and adds a PowerShell 7 block
|
|
7
|
+
that writes the entry to ``~/.claude/settings.json`` with ``"$defaults"``
|
|
8
|
+
kept. The agent receives the proposal as ``additionalContext``; the user
|
|
9
|
+
receives a one-line ``systemMessage``.
|
|
10
|
+
|
|
11
|
+
A denial with no ``classifier_verdict`` gets a short note and no block,
|
|
12
|
+
because an allow entry cannot clear it.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
_hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
23
|
+
if _hooks_root_directory not in sys.path:
|
|
24
|
+
sys.path.insert(0, _hooks_root_directory)
|
|
25
|
+
|
|
26
|
+
from hooks_constants.auto_mode_denial_quick_fix_constants import (
|
|
27
|
+
ALL_COMMAND_INPUT_KEYS,
|
|
28
|
+
ALLOW_ENTRY_TEMPLATE,
|
|
29
|
+
HOOK_EVENT_NAME,
|
|
30
|
+
MAXIMUM_ACTION_SUMMARY_CHARACTERS,
|
|
31
|
+
NO_VERDICT_CONTEXT_TEMPLATE,
|
|
32
|
+
POWERSHELL_BLOCK_TEMPLATE,
|
|
33
|
+
RULE_LABEL_PATTERN,
|
|
34
|
+
SUMMARY_WORD_SEPARATOR,
|
|
35
|
+
TRUNCATION_MARKER,
|
|
36
|
+
UNNAMED_RULE_LABEL,
|
|
37
|
+
USER_MESSAGE_TEMPLATE,
|
|
38
|
+
VERDICT_CONTEXT_TEMPLATE,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def rule_label_from(denial_reason: str) -> str:
|
|
43
|
+
"""Return the classifier rule name from a denial reason.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
denial_reason: The ``denial_reason`` text, such as
|
|
47
|
+
``"[Security Weaken] lowers a guard"``.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
The first bracketed label, or the unnamed-rule label.
|
|
51
|
+
"""
|
|
52
|
+
label_match = re.search(RULE_LABEL_PATTERN, denial_reason)
|
|
53
|
+
return label_match.group(1).strip() if label_match else UNNAMED_RULE_LABEL
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _summary_source_text(tool_input: object) -> str:
|
|
57
|
+
if not isinstance(tool_input, dict):
|
|
58
|
+
return json.dumps(tool_input, sort_keys=True)
|
|
59
|
+
for each_key in ALL_COMMAND_INPUT_KEYS:
|
|
60
|
+
if isinstance(tool_input.get(each_key), str):
|
|
61
|
+
return tool_input[each_key]
|
|
62
|
+
return json.dumps(tool_input, sort_keys=True)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def action_summary_from(tool_input: object) -> str:
|
|
66
|
+
"""Return one short line that names the denied action.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
tool_input: The denied call's ``tool_input``.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
The command, URL or path when the input has one, else the compact
|
|
73
|
+
JSON input, cut to the summary length limit.
|
|
74
|
+
"""
|
|
75
|
+
single_line_text = SUMMARY_WORD_SEPARATOR.join(_summary_source_text(tool_input).split())
|
|
76
|
+
if len(single_line_text) <= MAXIMUM_ACTION_SUMMARY_CHARACTERS:
|
|
77
|
+
return single_line_text
|
|
78
|
+
kept_length = MAXIMUM_ACTION_SUMMARY_CHARACTERS - len(TRUNCATION_MARKER)
|
|
79
|
+
return single_line_text[:kept_length] + TRUNCATION_MARKER
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def powershell_block_for(allow_entry: str) -> str:
|
|
83
|
+
"""Return the PowerShell 7 block that adds one allow entry.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
allow_entry: The ``autoMode.allow`` entry text.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
The block, with the entry in a single-quoted PowerShell literal.
|
|
90
|
+
"""
|
|
91
|
+
escaped_entry = allow_entry.replace("'", "''")
|
|
92
|
+
return POWERSHELL_BLOCK_TEMPLATE.replace("{escaped_entry}", escaped_entry)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _verdict_context_text(
|
|
96
|
+
tool_name: str, denial_reason: str, action_summary: str, rule_label: str
|
|
97
|
+
) -> str:
|
|
98
|
+
allow_entry = ALLOW_ENTRY_TEMPLATE.format(
|
|
99
|
+
rule_label=rule_label,
|
|
100
|
+
tool_name=tool_name,
|
|
101
|
+
action_summary=action_summary,
|
|
102
|
+
)
|
|
103
|
+
return VERDICT_CONTEXT_TEMPLATE.format(
|
|
104
|
+
tool_name=tool_name,
|
|
105
|
+
rule_label=rule_label,
|
|
106
|
+
denial_reason=denial_reason,
|
|
107
|
+
action_summary=action_summary,
|
|
108
|
+
powershell_block=powershell_block_for(allow_entry),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def build_hook_output(all_denial_fields: dict[str, object]) -> dict[str, object]:
|
|
113
|
+
"""Return the PermissionDenied hook output for one denial payload.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
all_denial_fields: The hook input JSON.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
A ``hookSpecificOutput`` with ``additionalContext`` for the agent and
|
|
120
|
+
a top-level ``systemMessage`` for the user.
|
|
121
|
+
"""
|
|
122
|
+
tool_name = str(all_denial_fields.get("tool_name") or "tool")
|
|
123
|
+
denial_reason = str(all_denial_fields.get("denial_reason") or "")
|
|
124
|
+
action_summary = action_summary_from(all_denial_fields.get("tool_input"))
|
|
125
|
+
rule_label = rule_label_from(denial_reason)
|
|
126
|
+
if all_denial_fields.get("classifier_verdict"):
|
|
127
|
+
context_text = _verdict_context_text(tool_name, denial_reason, action_summary, rule_label)
|
|
128
|
+
else:
|
|
129
|
+
context_text = NO_VERDICT_CONTEXT_TEMPLATE.format(
|
|
130
|
+
tool_name=tool_name,
|
|
131
|
+
denial_reason=denial_reason,
|
|
132
|
+
action_summary=action_summary,
|
|
133
|
+
)
|
|
134
|
+
return {
|
|
135
|
+
"hookSpecificOutput": {
|
|
136
|
+
"hookEventName": HOOK_EVENT_NAME,
|
|
137
|
+
"additionalContext": context_text,
|
|
138
|
+
},
|
|
139
|
+
"systemMessage": USER_MESSAGE_TEMPLATE.format(tool_name=tool_name, rule_label=rule_label),
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def main() -> int:
|
|
144
|
+
try:
|
|
145
|
+
payload = json.load(sys.stdin)
|
|
146
|
+
except (json.JSONDecodeError, ValueError):
|
|
147
|
+
return 0
|
|
148
|
+
if not isinstance(payload, dict):
|
|
149
|
+
return 0
|
|
150
|
+
sys.stdout.write(json.dumps(build_hook_output(payload)))
|
|
151
|
+
sys.stdout.flush()
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
if __name__ == "__main__":
|
|
156
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Behavior tests for the auto mode denial quick-fix advisor."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
advisory_directory = str(Path(__file__).resolve().parent)
|
|
14
|
+
if advisory_directory not in sys.path:
|
|
15
|
+
sys.path.insert(0, advisory_directory)
|
|
16
|
+
import auto_mode_denial_quick_fix
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _denial_payload(**overrides: object) -> dict[str, object]:
|
|
20
|
+
payload: dict[str, object] = {
|
|
21
|
+
"hook_event_name": "PermissionDenied",
|
|
22
|
+
"tool_name": "mcp__github__resolve_review_thread",
|
|
23
|
+
"tool_input": {"threadId": "PRRT_abc"},
|
|
24
|
+
"denial_reason": "[Security Weaken] resolving review threads bypasses review",
|
|
25
|
+
"classifier_verdict": "deny",
|
|
26
|
+
}
|
|
27
|
+
payload.update(overrides)
|
|
28
|
+
return payload
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_should_name_the_bracketed_rule() -> None:
|
|
32
|
+
assert (
|
|
33
|
+
auto_mode_denial_quick_fix.rule_label_from("[Git Destructive] force") == "Git Destructive"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_should_fall_back_when_the_reason_has_no_label() -> None:
|
|
38
|
+
assert auto_mode_denial_quick_fix.rule_label_from("Blocked by classifier") == "unnamed rule"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_should_summarize_a_bash_call_by_its_command() -> None:
|
|
42
|
+
summary = auto_mode_denial_quick_fix.action_summary_from(
|
|
43
|
+
{"command": "git push\n --force origin x"}
|
|
44
|
+
)
|
|
45
|
+
assert summary == "git push --force origin x"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_should_cut_a_long_summary_to_the_limit() -> None:
|
|
49
|
+
summary = auto_mode_denial_quick_fix.action_summary_from({"command": "x" * 500})
|
|
50
|
+
assert len(summary) == 160
|
|
51
|
+
assert summary.endswith("...")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_should_escape_single_quotes_in_the_powershell_entry() -> None:
|
|
55
|
+
block = auto_mode_denial_quick_fix.powershell_block_for("it's allowed")
|
|
56
|
+
assert "$entry = 'it''s allowed'" in block
|
|
57
|
+
assert "$allowList.Add('$defaults')" in block
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_should_propose_rule_entry_and_block_for_a_classifier_denial() -> None:
|
|
61
|
+
hook_output = auto_mode_denial_quick_fix.build_hook_output(_denial_payload())
|
|
62
|
+
context_text = hook_output["hookSpecificOutput"]["additionalContext"]
|
|
63
|
+
assert "rule [Security Weaken]" in context_text
|
|
64
|
+
assert (
|
|
65
|
+
"Security Weaken exception: mcp__github__resolve_review_thread calls like" in context_text
|
|
66
|
+
)
|
|
67
|
+
assert "```powershell" in context_text
|
|
68
|
+
assert "retry" not in hook_output["hookSpecificOutput"]
|
|
69
|
+
assert hook_output["systemMessage"].startswith(
|
|
70
|
+
"Auto mode blocked mcp__github__resolve_review_thread"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_should_omit_the_block_when_no_verdict_exists() -> None:
|
|
75
|
+
payload = _denial_payload(denial_reason="classifier unavailable")
|
|
76
|
+
del payload["classifier_verdict"]
|
|
77
|
+
context_text = auto_mode_denial_quick_fix.build_hook_output(payload)["hookSpecificOutput"][
|
|
78
|
+
"additionalContext"
|
|
79
|
+
]
|
|
80
|
+
assert "no classifier verdict" in context_text
|
|
81
|
+
assert "powershell" not in context_text
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_should_print_the_output_from_stdin(
|
|
85
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
86
|
+
) -> None:
|
|
87
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(_denial_payload())))
|
|
88
|
+
assert auto_mode_denial_quick_fix.main() == 0
|
|
89
|
+
assert (
|
|
90
|
+
json.loads(capsys.readouterr().out)["hookSpecificOutput"]["hookEventName"]
|
|
91
|
+
== "PermissionDenied"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_should_stay_quiet_on_malformed_input(
|
|
96
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
97
|
+
) -> None:
|
|
98
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO("not json"))
|
|
99
|
+
assert auto_mode_denial_quick_fix.main() == 0
|
|
100
|
+
assert capsys.readouterr().out == ""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_should_write_the_entry_with_defaults_when_powershell_runs(tmp_path: Path) -> None:
|
|
104
|
+
block = (
|
|
105
|
+
auto_mode_denial_quick_fix.powershell_block_for("it's allowed")
|
|
106
|
+
.replace("claude auto-mode config", "")
|
|
107
|
+
.replace("Join-Path $HOME", f"Join-Path '{tmp_path}'")
|
|
108
|
+
)
|
|
109
|
+
subprocess.run(["pwsh", "-NoProfile", "-Command", block], check=True)
|
|
110
|
+
written_settings = json.loads(
|
|
111
|
+
(tmp_path / ".claude" / "settings.json").read_text(encoding="utf-8")
|
|
112
|
+
)
|
|
113
|
+
assert written_settings["autoMode"]["allow"] == ["$defaults", "it's allowed"]
|
package/hooks/hooks.json
CHANGED
|
@@ -335,6 +335,18 @@
|
|
|
335
335
|
}
|
|
336
336
|
]
|
|
337
337
|
}
|
|
338
|
+
],
|
|
339
|
+
"PermissionDenied": [
|
|
340
|
+
{
|
|
341
|
+
"matcher": "*",
|
|
342
|
+
"hooks": [
|
|
343
|
+
{
|
|
344
|
+
"type": "command",
|
|
345
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/advisory/auto_mode_denial_quick_fix.py",
|
|
346
|
+
"timeout": 10
|
|
347
|
+
}
|
|
348
|
+
]
|
|
349
|
+
}
|
|
338
350
|
]
|
|
339
351
|
}
|
|
340
352
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Constants for the auto mode denial quick-fix advisor.
|
|
2
|
+
|
|
3
|
+
The advisor is a PermissionDenied hook. It never retries the call. It turns
|
|
4
|
+
each auto mode denial into a proposed fix: the rule name, a draft
|
|
5
|
+
``autoMode.allow`` entry, and a PowerShell 7 block that adds the entry to
|
|
6
|
+
``~/.claude/settings.json`` with ``"$defaults"`` kept in the list.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"HOOK_EVENT_NAME",
|
|
13
|
+
"RULE_LABEL_PATTERN",
|
|
14
|
+
"UNNAMED_RULE_LABEL",
|
|
15
|
+
"MAXIMUM_ACTION_SUMMARY_CHARACTERS",
|
|
16
|
+
"TRUNCATION_MARKER",
|
|
17
|
+
"SUMMARY_WORD_SEPARATOR",
|
|
18
|
+
"ALL_COMMAND_INPUT_KEYS",
|
|
19
|
+
"ALLOW_ENTRY_TEMPLATE",
|
|
20
|
+
"POWERSHELL_BLOCK_TEMPLATE",
|
|
21
|
+
"VERDICT_CONTEXT_TEMPLATE",
|
|
22
|
+
"NO_VERDICT_CONTEXT_TEMPLATE",
|
|
23
|
+
"USER_MESSAGE_TEMPLATE",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
HOOK_EVENT_NAME = "PermissionDenied"
|
|
27
|
+
RULE_LABEL_PATTERN = r"\[([^\]]+)\]"
|
|
28
|
+
UNNAMED_RULE_LABEL = "unnamed rule"
|
|
29
|
+
MAXIMUM_ACTION_SUMMARY_CHARACTERS = 160
|
|
30
|
+
TRUNCATION_MARKER = "..."
|
|
31
|
+
SUMMARY_WORD_SEPARATOR = " "
|
|
32
|
+
ALL_COMMAND_INPUT_KEYS = ("command", "url", "file_path", "path")
|
|
33
|
+
|
|
34
|
+
ALLOW_ENTRY_TEMPLATE = (
|
|
35
|
+
"{rule_label} exception: {tool_name} calls like `{action_summary}` "
|
|
36
|
+
"are allowed when they serve the task the user gave."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
POWERSHELL_BLOCK_TEMPLATE = """\
|
|
40
|
+
$settingsPath = Join-Path $HOME '.claude/settings.json'
|
|
41
|
+
New-Item -ItemType Directory -Force -Path (Split-Path $settingsPath) | Out-Null
|
|
42
|
+
$settings = if (Test-Path $settingsPath) { Get-Content -Raw $settingsPath | ConvertFrom-Json -AsHashtable } else { @{} }
|
|
43
|
+
if (-not $settings.ContainsKey('autoMode')) { $settings['autoMode'] = @{} }
|
|
44
|
+
$allowList = [System.Collections.Generic.List[string]]::new()
|
|
45
|
+
if ($settings['autoMode'].ContainsKey('allow')) { $allowList.AddRange([string[]]@($settings['autoMode']['allow'])) } else { $allowList.Add('$defaults') }
|
|
46
|
+
$entry = '{escaped_entry}'
|
|
47
|
+
if ($allowList -notcontains $entry) { $allowList.Add($entry) }
|
|
48
|
+
$settings['autoMode']['allow'] = $allowList.ToArray()
|
|
49
|
+
$settings | ConvertTo-Json -Depth 32 | Set-Content -Encoding utf8NoBOM $settingsPath
|
|
50
|
+
claude auto-mode config"""
|
|
51
|
+
|
|
52
|
+
VERDICT_CONTEXT_TEMPLATE = """\
|
|
53
|
+
=== AUTO MODE DENIAL QUICK FIX ===
|
|
54
|
+
Auto mode denied {tool_name} under rule [{rule_label}].
|
|
55
|
+
Denial reason: {denial_reason}
|
|
56
|
+
Denied action: {action_summary}
|
|
57
|
+
|
|
58
|
+
In your next message to the user, propose these two fixes. Do not retry and do not run the block yourself.
|
|
59
|
+
1. One-off: the user replies with a message that names this exact action and its target; then you retry once.
|
|
60
|
+
2. Lasting: the user runs this PowerShell 7 block on their machine. It adds one autoMode.allow entry to ~/.claude/settings.json and keeps "$defaults". Narrow the entry text to this action before you send it.
|
|
61
|
+
|
|
62
|
+
```powershell
|
|
63
|
+
{powershell_block}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
For cloud sessions, the same entry must land in the ~/.claude/settings.json that the session's setup installs.
|
|
67
|
+
Read the rule wording with: claude auto-mode defaults --label '{rule_label}'"""
|
|
68
|
+
|
|
69
|
+
NO_VERDICT_CONTEXT_TEMPLATE = """\
|
|
70
|
+
=== AUTO MODE DENIAL QUICK FIX ===
|
|
71
|
+
Auto mode denied {tool_name} with no classifier verdict.
|
|
72
|
+
Denial reason: {denial_reason}
|
|
73
|
+
Denied action: {action_summary}
|
|
74
|
+
|
|
75
|
+
An autoMode.allow entry cannot clear a denial with no verdict. Tell the user the action and the reason, and ask them to run it themselves or to retry it later."""
|
|
76
|
+
|
|
77
|
+
USER_MESSAGE_TEMPLATE = (
|
|
78
|
+
"Auto mode blocked {tool_name} [{rule_label}]. Claude will propose a quick fix."
|
|
79
|
+
)
|
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),
|