claude-dev-env 8.7.0 → 8.9.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/.agents/skills/privacy-hygiene/SKILL.md +1 -0
- package/.agents/skills/pull-request/scripts/pull_request.py +1 -0
- package/.agents/skills/pull-request/scripts/test_pull_request.py +2 -0
- package/.agents/skills/team-advisor/SKILL.md +2 -2
- package/_shared/advisor/reference/identity.md +1 -1
- package/_shared/advisor/reference/spawn-walk-log.md +1 -1
- package/_shared/advisor/reference/warm-up.md +3 -1
- package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +15 -0
- package/_shared/advisor/scripts/model_tier_run_validator.py +20 -3
- package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +50 -6
- package/docs/references/advisor-tool.md +3 -2
- package/docs/second-claude-account.md +57 -0
- package/hooks/blocking/test_pii_scanner.py +8 -0
- package/hooks/hooks.json +25 -0
- package/hooks/hooks_constants/pii_prevention_constants.py +1 -0
- package/hooks/hooks_constants/skill_loaded_reminder_constants.py +58 -0
- package/hooks/session/skill_loaded_reminder.py +199 -0
- package/hooks/session/test_skill_loaded_reminder.py +206 -0
- package/package.json +1 -1
- package/rules/durable-post-artifacts.md +26 -1
- package/rules/long-horizon-autonomy.md +11 -0
- package/rules/prefer-existing-tools.md +35 -0
- package/rules/review-closure-is-a-check.md +1 -1
- package/scripts/claude_account_choice.py +291 -0
- package/scripts/claude_account_profile.py +371 -0
- package/scripts/claude_chain_usage.py +49 -5
- package/scripts/dev_env_scripts_constants/claude_account_constants.py +181 -0
- package/scripts/dev_env_scripts_constants/gh_artifact_upload_constants.py +27 -0
- package/scripts/dev_env_scripts_constants/private_term_constants.py +60 -0
- package/scripts/dev_env_scripts_constants/test_claude_account_constants.py +46 -0
- package/scripts/dev_env_scripts_constants/test_private_term_constants.py +29 -0
- package/scripts/durable_post_lint.py +47 -1
- package/scripts/durable_post_lint_config/config/constants.py +1 -0
- package/scripts/gh_artifact_upload.py +56 -3
- package/scripts/private_term_scan.py +135 -0
- package/scripts/private_terms.py +87 -0
- package/scripts/repository_checks/config/constants.py +3 -0
- package/scripts/repository_checks/runner.py +7 -0
- package/scripts/repository_checks/tracked_private_terms.py +57 -0
- package/scripts/test_claude_account_choice.py +245 -0
- package/scripts/test_claude_account_profile.py +220 -0
- package/scripts/test_claude_chain_usage.py +40 -0
- package/scripts/test_durable_post_lint.py +90 -0
- package/scripts/tests/test_gh_artifact_upload.py +75 -0
- package/scripts/tests/test_match_exemptions.py +2 -2
- package/scripts/tests/test_private_term_scan.py +176 -0
- package/scripts/tests/test_private_terms.py +59 -0
- package/scripts/tests/test_repository_policy.py +2 -0
- package/scripts/tests/test_runner.py +20 -0
- package/scripts/tests/test_tracked_private_terms.py +71 -0
- package/scripts/tests/test_tracked_secrets.py +1 -1
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Tests for skill_loaded_reminder. Checks every fresh context gets poteto-mode, and loaded ones hear nothing."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from io import BytesIO, StringIO, TextIOWrapper
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from unittest.mock import patch
|
|
8
|
+
|
|
9
|
+
_SESSION_DIR = Path(__file__).resolve().parent
|
|
10
|
+
_HOOKS_ROOT = _SESSION_DIR.parent
|
|
11
|
+
for each_sys_path_entry in (str(_SESSION_DIR), str(_HOOKS_ROOT)):
|
|
12
|
+
if each_sys_path_entry not in sys.path:
|
|
13
|
+
sys.path.insert(0, each_sys_path_entry)
|
|
14
|
+
|
|
15
|
+
import skill_loaded_reminder as reminder
|
|
16
|
+
from hooks_constants.skill_loaded_reminder_constants import (
|
|
17
|
+
COMPACTION_REMINDER,
|
|
18
|
+
NOT_LOADED_REMINDER,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
SKILL_CALL_LINE = json.dumps(
|
|
22
|
+
{
|
|
23
|
+
"type": "assistant",
|
|
24
|
+
"message": {
|
|
25
|
+
"role": "assistant",
|
|
26
|
+
"content": [
|
|
27
|
+
{
|
|
28
|
+
"type": "tool_use",
|
|
29
|
+
"name": "Skill",
|
|
30
|
+
"input": {"skill": "pstack:poteto-mode"},
|
|
31
|
+
}
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
TYPED_COMMAND_LINE = json.dumps(
|
|
37
|
+
{
|
|
38
|
+
"type": "user",
|
|
39
|
+
"message": {
|
|
40
|
+
"role": "user",
|
|
41
|
+
"content": "<command-name>/pstack:poteto-mode</command-name>",
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
COMPACT_BOUNDARY_LINE = json.dumps({"type": "system", "subtype": "compact_boundary"})
|
|
46
|
+
READ_CALL_LINE = json.dumps(
|
|
47
|
+
{
|
|
48
|
+
"type": "assistant",
|
|
49
|
+
"message": {
|
|
50
|
+
"content": [{"type": "tool_use", "name": "Read", "input": {"file_path": "a.py"}}]
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
TOOL_RESULT_QUOTING_THE_SKILL_LINE = json.dumps(
|
|
55
|
+
{
|
|
56
|
+
"type": "user",
|
|
57
|
+
"message": {
|
|
58
|
+
"content": [
|
|
59
|
+
{
|
|
60
|
+
"type": "tool_result",
|
|
61
|
+
"content": 'SKILL = "pstack:poteto-mode" and <command-name>/pstack:poteto-mode</command-name>',
|
|
62
|
+
}
|
|
63
|
+
]
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _run_main(hook_payload: object) -> str:
|
|
70
|
+
"""Return the text main() prints for the given hook payload."""
|
|
71
|
+
stdin_text = hook_payload if isinstance(hook_payload, str) else json.dumps(hook_payload)
|
|
72
|
+
captured_stdout = StringIO()
|
|
73
|
+
fake_stdin = TextIOWrapper(BytesIO(stdin_text.encode("utf-8")), encoding="utf-8")
|
|
74
|
+
with patch("sys.stdin", fake_stdin), patch("sys.stdout", captured_stdout):
|
|
75
|
+
reminder.main()
|
|
76
|
+
return captured_stdout.getvalue()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _agent_call(tool_input: dict[str, object], tool_name: str = "Agent") -> dict[str, object]:
|
|
80
|
+
return {"hook_event_name": "PreToolUse", "tool_name": tool_name, "tool_input": tool_input}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _user_turn(transcript_path: Path) -> dict[str, object]:
|
|
84
|
+
return {"hook_event_name": "UserPromptSubmit", "transcript_path": str(transcript_path)}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _write_transcript(tmp_path: Path, all_lines: list[str]) -> Path:
|
|
88
|
+
transcript_path = tmp_path / "session.jsonl"
|
|
89
|
+
transcript_path.write_text("\n".join(all_lines) + "\n", encoding="utf-8")
|
|
90
|
+
return transcript_path
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class TestSubagentSpawn:
|
|
94
|
+
def test_a_plain_subagent_prompt_opens_with_the_poteto_mode_invocation(self) -> None:
|
|
95
|
+
emitted = json.loads(
|
|
96
|
+
_run_main(
|
|
97
|
+
_agent_call(
|
|
98
|
+
{
|
|
99
|
+
"description": "Find callers",
|
|
100
|
+
"subagent_type": "Explore",
|
|
101
|
+
"prompt": "List every caller of main.",
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
hook_output = emitted["hookSpecificOutput"]
|
|
107
|
+
rewritten_input = hook_output["updatedInput"]
|
|
108
|
+
assert hook_output["permissionDecision"] == "allow"
|
|
109
|
+
assert rewritten_input["prompt"].startswith(
|
|
110
|
+
"Before any other work, invoke the pstack:poteto-mode skill with the Skill tool."
|
|
111
|
+
)
|
|
112
|
+
assert rewritten_input["prompt"].endswith("\n\nList every caller of main.")
|
|
113
|
+
assert rewritten_input["subagent_type"] == "Explore"
|
|
114
|
+
assert rewritten_input["description"] == "Find callers"
|
|
115
|
+
|
|
116
|
+
def test_the_legacy_task_tool_name_is_rewritten_too(self) -> None:
|
|
117
|
+
emitted = json.loads(_run_main(_agent_call({"prompt": "Go."}, tool_name="Task")))
|
|
118
|
+
assert "pstack:poteto-mode" in emitted["hookSpecificOutput"]["updatedInput"]["prompt"]
|
|
119
|
+
|
|
120
|
+
def test_a_prompt_that_already_names_the_skill_is_left_alone(self) -> None:
|
|
121
|
+
assert _run_main(_agent_call({"prompt": "Invoke pstack:poteto-mode first. Then go."})) == ""
|
|
122
|
+
|
|
123
|
+
def test_a_poteto_agent_is_left_alone_because_it_loads_the_skill_itself(self) -> None:
|
|
124
|
+
assert (
|
|
125
|
+
_run_main(_agent_call({"subagent_type": "pstack:poteto-agent", "prompt": "Go."})) == ""
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def test_another_tool_is_left_alone(self) -> None:
|
|
129
|
+
assert _run_main(_agent_call({"prompt": "Go."}, tool_name="Bash")) == ""
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class TestSubagentInputWithPotetoMode:
|
|
133
|
+
def test_every_other_input_field_is_kept(self) -> None:
|
|
134
|
+
rewritten_input = reminder.subagent_input_with_poteto_mode(
|
|
135
|
+
{"prompt": "Go.", "model": "sonnet", "run_in_background": True}
|
|
136
|
+
)
|
|
137
|
+
assert rewritten_input is not None
|
|
138
|
+
assert rewritten_input["model"] == "sonnet"
|
|
139
|
+
assert rewritten_input["run_in_background"] is True
|
|
140
|
+
|
|
141
|
+
def test_an_input_with_no_prompt_is_left_alone(self) -> None:
|
|
142
|
+
assert reminder.subagent_input_with_poteto_mode({"description": "Go"}) is None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class TestIsPotetoModeLoaded:
|
|
146
|
+
def test_a_skill_call_loads_it(self) -> None:
|
|
147
|
+
assert reminder.is_poteto_mode_loaded([READ_CALL_LINE, SKILL_CALL_LINE, READ_CALL_LINE])
|
|
148
|
+
|
|
149
|
+
def test_a_typed_slash_command_loads_it(self) -> None:
|
|
150
|
+
assert reminder.is_poteto_mode_loaded([TYPED_COMMAND_LINE, READ_CALL_LINE])
|
|
151
|
+
|
|
152
|
+
def test_a_compaction_after_the_skill_call_drops_it(self) -> None:
|
|
153
|
+
assert not reminder.is_poteto_mode_loaded(
|
|
154
|
+
[SKILL_CALL_LINE, COMPACT_BOUNDARY_LINE, READ_CALL_LINE]
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def test_a_skill_call_after_the_compaction_loads_it_again(self) -> None:
|
|
158
|
+
assert reminder.is_poteto_mode_loaded(
|
|
159
|
+
[SKILL_CALL_LINE, COMPACT_BOUNDARY_LINE, SKILL_CALL_LINE]
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def test_a_plain_user_prompt_does_not_load_it(self) -> None:
|
|
163
|
+
plain_prompt_line = json.dumps(
|
|
164
|
+
{"type": "user", "message": {"content": "Read pstack:poteto-mode docs."}}
|
|
165
|
+
)
|
|
166
|
+
assert not reminder.is_poteto_mode_loaded([plain_prompt_line])
|
|
167
|
+
|
|
168
|
+
def test_a_tool_result_that_quotes_the_skill_name_does_not_load_it(self) -> None:
|
|
169
|
+
assert not reminder.is_poteto_mode_loaded([TOOL_RESULT_QUOTING_THE_SKILL_LINE])
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class TestReminderFor:
|
|
173
|
+
def test_a_user_turn_in_a_session_that_never_loaded_the_skill_gets_the_reminder(
|
|
174
|
+
self, tmp_path: Path
|
|
175
|
+
) -> None:
|
|
176
|
+
transcript_path = _write_transcript(tmp_path, [READ_CALL_LINE])
|
|
177
|
+
emitted = json.loads(_run_main(_user_turn(transcript_path)))
|
|
178
|
+
hook_output = emitted["hookSpecificOutput"]
|
|
179
|
+
assert hook_output["hookEventName"] == "UserPromptSubmit"
|
|
180
|
+
assert hook_output["additionalContext"] == NOT_LOADED_REMINDER
|
|
181
|
+
assert "pstack:poteto-mode" in NOT_LOADED_REMINDER
|
|
182
|
+
|
|
183
|
+
def test_a_user_turn_in_a_session_that_loaded_the_skill_prints_nothing(
|
|
184
|
+
self, tmp_path: Path
|
|
185
|
+
) -> None:
|
|
186
|
+
transcript_path = _write_transcript(tmp_path, [SKILL_CALL_LINE, READ_CALL_LINE])
|
|
187
|
+
assert _run_main(_user_turn(transcript_path)) == ""
|
|
188
|
+
|
|
189
|
+
def test_a_user_turn_with_no_transcript_yet_gets_the_reminder(self, tmp_path: Path) -> None:
|
|
190
|
+
assert (
|
|
191
|
+
reminder.reminder_for(_user_turn(tmp_path / "not-written-yet.jsonl"))
|
|
192
|
+
== NOT_LOADED_REMINDER
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def test_session_start_after_compaction_tells_the_session_to_invoke_it_again(self) -> None:
|
|
196
|
+
emitted = json.loads(_run_main({"hook_event_name": "SessionStart", "source": "compact"}))
|
|
197
|
+
hook_output = emitted["hookSpecificOutput"]
|
|
198
|
+
assert hook_output["hookEventName"] == "SessionStart"
|
|
199
|
+
assert hook_output["additionalContext"] == COMPACTION_REMINDER
|
|
200
|
+
assert "pstack:poteto-mode" in COMPACTION_REMINDER
|
|
201
|
+
|
|
202
|
+
def test_a_resumed_session_keeps_its_context_and_prints_nothing(self) -> None:
|
|
203
|
+
assert _run_main({"hook_event_name": "SessionStart", "source": "resume"}) == ""
|
|
204
|
+
|
|
205
|
+
def test_a_payload_that_is_not_json_prints_nothing(self) -> None:
|
|
206
|
+
assert _run_main("not json") == ""
|
package/package.json
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
## When this applies
|
|
4
4
|
|
|
5
5
|
Use this rule for a GitHub issue, pull request, comment, or review created
|
|
6
|
-
through `gh` or a GitHub MCP post tool.
|
|
6
|
+
through `gh` or a GitHub MCP post tool. The image section also covers a PNG a
|
|
7
|
+
commit adds and a file a release upload sends.
|
|
7
8
|
|
|
8
9
|
## Rule
|
|
9
10
|
|
|
@@ -26,6 +27,24 @@ Handle text and binary content differently:
|
|
|
26
27
|
under a `YYYYMMDD_HHMMSS_<name>` asset name, and prints a permanent download
|
|
27
28
|
URL. Put that URL in the post.
|
|
28
29
|
|
|
30
|
+
## Optimize every image before it reaches GitHub
|
|
31
|
+
|
|
32
|
+
The helper shrinks a PNG with [oxipng](https://github.com/oxipng/oxipng) before
|
|
33
|
+
it uploads, and prints the size before and after. Oxipng is lossless.
|
|
34
|
+
`--strip none` keeps every chunk, and `--nb --nc` keep the bit depth and the
|
|
35
|
+
color type, so a reader that checks the image mode sees the same file shape.
|
|
36
|
+
|
|
37
|
+
Any other route to GitHub runs the same pass first: a PNG a commit adds, and a
|
|
38
|
+
file sent with `gh release upload`.
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
oxipng --opt 4 --strip none --nb --nc <file> [<file> ...]
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The `Binary optimization` check fails a pull request whose changed PNG files
|
|
45
|
+
still shrink under that pass. A fixture whose exact bytes a test pins takes
|
|
46
|
+
`binary-optimizer=keep` in `.gitattributes`, and the check skips it.
|
|
47
|
+
|
|
29
48
|
## Volatile paths that must not appear in a post body
|
|
30
49
|
|
|
31
50
|
- A job scratch directory: `.claude-profile-a/jobs/`
|
|
@@ -46,5 +65,11 @@ server write. Pass the matching action and body file. Use `pr-create`,
|
|
|
46
65
|
`pr-edit`, `pr-comment`, `pr-review`, `issue-create`, `issue-edit`,
|
|
47
66
|
`issue-comment`, or `github-mcp-post`.
|
|
48
67
|
|
|
68
|
+
Pass `--repository <owner>/<name>` for the repository the post targets. A
|
|
69
|
+
post may name a private organization only inside a repository that
|
|
70
|
+
organization owns. The linter holds the names as digests, so it reports the
|
|
71
|
+
line that names one without printing the name. Describe the organization in
|
|
72
|
+
general terms and drop the link.
|
|
73
|
+
|
|
49
74
|
The linter reads the body file and reports a volatile local path without
|
|
50
75
|
printing the body. Fix the body and rerun the linter before posting.
|
|
@@ -18,6 +18,17 @@ A pause is a request you deliver, not a state you enter. Put it in the channel t
|
|
|
18
18
|
|
|
19
19
|
Before you end any turn, read your last paragraph. If it is a plan, an analysis, a list of next steps, or a statement of intent ("I'll run the tests", "next I'll wire it up"), do that work with tool calls before you stop. End the turn only when the task is done or you are blocked on input only the user can give.
|
|
20
20
|
|
|
21
|
+
Four turn endings leave owed work unstarted. Each one is a stop to remove:
|
|
22
|
+
|
|
23
|
+
- A summary of the work so far that closes by naming the next step, with no tool call to start it.
|
|
24
|
+
- An offer to continue unless the user objects.
|
|
25
|
+
- A list of decisions for the user when none of them blocks the remaining work.
|
|
26
|
+
- A pause chosen because the turn ran long or a milestone finished.
|
|
27
|
+
|
|
28
|
+
A status note or a recommendation on an open decision rides in the same message as the next tool call. The stops that stay are the two where nothing can move: the next step needs the user, or the blocker is a control that is protected from you on purpose. Confirmation before a risky or destructive action still applies.
|
|
29
|
+
|
|
30
|
+
A harness or routine that drives an unattended run reads a turn that ends in text as a report. When open checklist items remain and the turn names no blocker, it sends one short message naming those items. It stops after two or three such continuations on one task, so a stuck run ends where a reader can review it. [Prompting Claude Opus 5.5](https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/prompting-claude-opus-5-5#unattended-agentic-runs) is the source for this list and this loop.
|
|
31
|
+
|
|
21
32
|
In an autonomous pipeline the user cannot answer mid-task. For reversible actions that follow from the original request, act without asking; save any follow-up offers for after the task is done.
|
|
22
33
|
|
|
23
34
|
Authority the task already granted stays granted. A later preference about tone, brevity, or reply format changes how you report, never what you are allowed to do. Re-asking for permission the task already gave hands the work back to the user, who then does it by hand.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Prefer Existing Tools
|
|
2
|
+
|
|
3
|
+
**When this applies:** Before you build a tool, check, scanner, script, or library of your own.
|
|
4
|
+
|
|
5
|
+
## Rule
|
|
6
|
+
|
|
7
|
+
Search in this order, and stop at the first fit:
|
|
8
|
+
|
|
9
|
+
1. **Internal code.** Search the repository and its shared packages for a tool that already does the job. Reuse or extend it.
|
|
10
|
+
2. **An established open-source option.** Search for a plugin, package, or tool that already does the job.
|
|
11
|
+
3. **Your own code.** Build it only when steps 1 and 2 find nothing that fits.
|
|
12
|
+
|
|
13
|
+
Use an open-source option only when it meets all four conditions:
|
|
14
|
+
|
|
15
|
+
- It is secure: it has an active security process and no open critical advisory.
|
|
16
|
+
- It is well known in its field.
|
|
17
|
+
- It is widely used: many stars, downloads, or dependents.
|
|
18
|
+
- It is maintained: it had a release or commit in the last year.
|
|
19
|
+
|
|
20
|
+
Add only what the option lacks. Put custom rules in its own configuration format. Do not wrap it in a new layer of your own code.
|
|
21
|
+
|
|
22
|
+
## Report the choice
|
|
23
|
+
|
|
24
|
+
Tell the user which tool you chose before the work is marked ready. Give its name, link, license, and one number that shows how widely it is used. When you build your own code, name what you searched and why each candidate did not fit.
|
|
25
|
+
|
|
26
|
+
## Why
|
|
27
|
+
|
|
28
|
+
Code of your own is code you maintain alone. A trusted tool carries fixes and new patterns from its community, and its users find its defects first.
|
|
29
|
+
|
|
30
|
+
## Sibling rules
|
|
31
|
+
|
|
32
|
+
| Rule | Role |
|
|
33
|
+
|---|---|
|
|
34
|
+
| [`explore-thoroughly.md`](explore-thoroughly.md) | Read what exists before you propose a change |
|
|
35
|
+
| [`verify-before-asking.md`](verify-before-asking.md) | Answer a question with a tool before you ask it |
|
|
@@ -33,7 +33,7 @@ The driving account is the one that opened the pull request. Where the agent com
|
|
|
33
33
|
|
|
34
34
|
`.github/workflows/review-closure.yml` runs it here on a push to a pull request, on a submitted or dismissed review, and on a review comment. Each run reports on the pull request's head commit, so a finding posted after the last push still turns the check red.
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
A private repository that installs this package runs the same command from its own workflow, against the revision of this package that its workflow pins.
|
|
37
37
|
|
|
38
38
|
## Sibling rules
|
|
39
39
|
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Name the Claude account a runner job uses, protecting the main account.
|
|
3
|
+
|
|
4
|
+
The main account is the one its owner works on, so a job borrows it only to
|
|
5
|
+
spend leftover usage that expires soon. Every other job goes to the second
|
|
6
|
+
account while that account has room, and waits when it has none::
|
|
7
|
+
|
|
8
|
+
main week resets in 5 hours, 20% left, 5-hour window 10% used -> main
|
|
9
|
+
main week resets in 3 days -> second
|
|
10
|
+
second at 99% of its week, main not spendable -> wait
|
|
11
|
+
main meter unread -> never main
|
|
12
|
+
|
|
13
|
+
::
|
|
14
|
+
|
|
15
|
+
python claude_account_choice.py --main-config-dir C:/Users/me/.claude
|
|
16
|
+
--second-config-dir C:/Users/me/.claude-profiles/ev
|
|
17
|
+
{"account": "second", "config_dir": "C:/Users/me/.claude-profiles/ev",
|
|
18
|
+
"reason": "second account has 60% of its week and 90% of its 5-hour window left"}
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import json
|
|
25
|
+
import sys
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from datetime import datetime
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from claude_account_profile import default_profile_home
|
|
31
|
+
from claude_chain_usage import (
|
|
32
|
+
AccountUsageMeters,
|
|
33
|
+
WeeklyUtilizationProbeError,
|
|
34
|
+
probe_account_meters,
|
|
35
|
+
)
|
|
36
|
+
from dev_env_scripts_constants.claude_account_constants import (
|
|
37
|
+
CHOICE_MAIN,
|
|
38
|
+
CHOICE_SECOND,
|
|
39
|
+
CHOICE_WAIT,
|
|
40
|
+
CREDENTIALS_FILE_NAME,
|
|
41
|
+
FULL_PERCENT,
|
|
42
|
+
JSON_ACCOUNT_KEY,
|
|
43
|
+
JSON_CONFIG_DIRECTORY_KEY,
|
|
44
|
+
JSON_METERS_KEY,
|
|
45
|
+
JSON_REASON_KEY,
|
|
46
|
+
JSON_SESSION_RESETS_AT_KEY,
|
|
47
|
+
JSON_SESSION_USED_PERCENT_KEY,
|
|
48
|
+
JSON_WEEKLY_RESETS_AT_KEY,
|
|
49
|
+
JSON_WEEKLY_USED_PERCENT_KEY,
|
|
50
|
+
MAIN_CLAUDE_HOME_DIRECTORY_NAME,
|
|
51
|
+
MAIN_SESSION_USED_CEILING_PERCENT,
|
|
52
|
+
MAIN_SPEND_WINDOW,
|
|
53
|
+
MAIN_WEEKLY_USED_CEILING_PERCENT,
|
|
54
|
+
REASON_MAIN_EXPIRING_TEMPLATE,
|
|
55
|
+
REASON_SECOND_HAS_ROOM_TEMPLATE,
|
|
56
|
+
REASON_SECOND_UNREAD,
|
|
57
|
+
REASON_WAIT_TEMPLATE,
|
|
58
|
+
SECOND_SESSION_USED_CEILING_PERCENT,
|
|
59
|
+
SECOND_WEEKLY_USED_CEILING_PERCENT,
|
|
60
|
+
SECONDS_PER_HOUR,
|
|
61
|
+
UNKNOWN_RESET_TEXT,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class AccountDecision:
|
|
67
|
+
"""The account a job runs on, and the plain-words reason for it."""
|
|
68
|
+
|
|
69
|
+
account: str
|
|
70
|
+
reason: str
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _main_spendable_reason(
|
|
74
|
+
main_meters: AccountUsageMeters | None, now: datetime
|
|
75
|
+
) -> str | None:
|
|
76
|
+
if main_meters is None:
|
|
77
|
+
return None
|
|
78
|
+
session_used = main_meters.session_utilization
|
|
79
|
+
weekly_used = main_meters.weekly_utilization
|
|
80
|
+
weekly_resets_at = main_meters.weekly_resets_at
|
|
81
|
+
if session_used is None or weekly_used is None or weekly_resets_at is None:
|
|
82
|
+
return None
|
|
83
|
+
time_until_reset = weekly_resets_at - now
|
|
84
|
+
is_expiring_soon = time_until_reset <= MAIN_SPEND_WINDOW
|
|
85
|
+
has_weekly_room = weekly_used < MAIN_WEEKLY_USED_CEILING_PERCENT
|
|
86
|
+
has_session_room = session_used < MAIN_SESSION_USED_CEILING_PERCENT
|
|
87
|
+
if not (is_expiring_soon and has_weekly_room and has_session_room):
|
|
88
|
+
return None
|
|
89
|
+
return REASON_MAIN_EXPIRING_TEMPLATE.format(
|
|
90
|
+
hours_until_reset=int(time_until_reset.total_seconds() // SECONDS_PER_HOUR),
|
|
91
|
+
remaining_percent=FULL_PERCENT - weekly_used,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _wait_decision(
|
|
96
|
+
second_meters: AccountUsageMeters, *, is_week_blocked: bool
|
|
97
|
+
) -> AccountDecision:
|
|
98
|
+
blocking_reset = (
|
|
99
|
+
second_meters.weekly_resets_at
|
|
100
|
+
if is_week_blocked
|
|
101
|
+
else second_meters.session_resets_at
|
|
102
|
+
)
|
|
103
|
+
return AccountDecision(
|
|
104
|
+
account=CHOICE_WAIT,
|
|
105
|
+
reason=REASON_WAIT_TEMPLATE.format(
|
|
106
|
+
weekly_used_percent=second_meters.weekly_utilization,
|
|
107
|
+
session_used_percent=second_meters.session_utilization,
|
|
108
|
+
next_reset=(
|
|
109
|
+
blocking_reset.isoformat() if blocking_reset else UNKNOWN_RESET_TEXT
|
|
110
|
+
),
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _second_account_decision(
|
|
116
|
+
second_meters: AccountUsageMeters | None,
|
|
117
|
+
) -> AccountDecision:
|
|
118
|
+
if second_meters is None:
|
|
119
|
+
return AccountDecision(account=CHOICE_SECOND, reason=REASON_SECOND_UNREAD)
|
|
120
|
+
session_used = second_meters.session_utilization
|
|
121
|
+
weekly_used = second_meters.weekly_utilization
|
|
122
|
+
if session_used is None or weekly_used is None:
|
|
123
|
+
return AccountDecision(account=CHOICE_SECOND, reason=REASON_SECOND_UNREAD)
|
|
124
|
+
is_week_blocked = weekly_used >= SECOND_WEEKLY_USED_CEILING_PERCENT
|
|
125
|
+
is_session_blocked = session_used >= SECOND_SESSION_USED_CEILING_PERCENT
|
|
126
|
+
if is_week_blocked or is_session_blocked:
|
|
127
|
+
return _wait_decision(second_meters, is_week_blocked=is_week_blocked)
|
|
128
|
+
return AccountDecision(
|
|
129
|
+
account=CHOICE_SECOND,
|
|
130
|
+
reason=REASON_SECOND_HAS_ROOM_TEMPLATE.format(
|
|
131
|
+
weekly_remaining_percent=FULL_PERCENT - weekly_used,
|
|
132
|
+
session_remaining_percent=FULL_PERCENT - session_used,
|
|
133
|
+
),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def choose_account(
|
|
138
|
+
*,
|
|
139
|
+
main_meters: AccountUsageMeters | None,
|
|
140
|
+
second_meters: AccountUsageMeters | None,
|
|
141
|
+
now: datetime,
|
|
142
|
+
) -> AccountDecision:
|
|
143
|
+
"""Pick the account a job runs on from both accounts' meters.
|
|
144
|
+
|
|
145
|
+
::
|
|
146
|
+
|
|
147
|
+
main: week resets in 5h, 80% used, 5-hour 10% used -> main
|
|
148
|
+
main: week resets in 30h -> second, if it has room
|
|
149
|
+
main unread, second at 99% of its week -> wait
|
|
150
|
+
main unread, second unread -> second
|
|
151
|
+
|
|
152
|
+
Main runs a job only to spend leftover usage that expires soon.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
main_meters: The main account's meters, or None when unread.
|
|
156
|
+
second_meters: The second account's meters, or None when unread.
|
|
157
|
+
now: The time the reset windows are measured from.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
The chosen account and the reason.
|
|
161
|
+
"""
|
|
162
|
+
main_reason = _main_spendable_reason(main_meters, now)
|
|
163
|
+
if main_reason is not None:
|
|
164
|
+
return AccountDecision(account=CHOICE_MAIN, reason=main_reason)
|
|
165
|
+
return _second_account_decision(second_meters)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def read_account_meters(credentials_path: Path) -> AccountUsageMeters | None:
|
|
169
|
+
"""Read one account's meters, or None when its credential or probe fails.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
credentials_path: The account's CLI credential file.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
The meters, or None when they cannot be read.
|
|
176
|
+
"""
|
|
177
|
+
try:
|
|
178
|
+
return probe_account_meters(credentials_path)
|
|
179
|
+
except WeeklyUtilizationProbeError:
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _build_argument_parser() -> argparse.ArgumentParser:
|
|
184
|
+
parser = argparse.ArgumentParser(
|
|
185
|
+
description="Name the Claude account a runner job uses."
|
|
186
|
+
)
|
|
187
|
+
parser.add_argument(
|
|
188
|
+
"--main-config-dir",
|
|
189
|
+
type=Path,
|
|
190
|
+
default=Path.home() / MAIN_CLAUDE_HOME_DIRECTORY_NAME,
|
|
191
|
+
)
|
|
192
|
+
parser.add_argument(
|
|
193
|
+
"--second-config-dir",
|
|
194
|
+
type=Path,
|
|
195
|
+
default=default_profile_home(),
|
|
196
|
+
)
|
|
197
|
+
return parser
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def decision_payload(
|
|
201
|
+
decision: AccountDecision, *, config_directory: Path | None
|
|
202
|
+
) -> dict[str, str | None]:
|
|
203
|
+
"""Shape a decision as the JSON object the runner job reads.
|
|
204
|
+
|
|
205
|
+
::
|
|
206
|
+
|
|
207
|
+
AccountDecision("second", "...") with the profile directory
|
|
208
|
+
-> {"account": "second", "config_dir": "<profile>", "reason": "..."}
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
decision: The chosen account and reason.
|
|
212
|
+
config_directory: The Claude home the account runs under, or None on wait.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
The account, its Claude home, and the reason.
|
|
216
|
+
"""
|
|
217
|
+
return {
|
|
218
|
+
JSON_ACCOUNT_KEY: decision.account,
|
|
219
|
+
JSON_CONFIG_DIRECTORY_KEY: str(config_directory) if config_directory else None,
|
|
220
|
+
JSON_REASON_KEY: decision.reason,
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _iso_or_none(moment: datetime | None) -> str | None:
|
|
225
|
+
return moment.isoformat() if moment else None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def meters_payload(
|
|
229
|
+
account_meters: AccountUsageMeters | None,
|
|
230
|
+
) -> dict[str, float | str | None] | None:
|
|
231
|
+
"""Shape one account's meters as the JSON object a usage report reads.
|
|
232
|
+
|
|
233
|
+
::
|
|
234
|
+
|
|
235
|
+
AccountUsageMeters(12.0, <reset>, 34.0, <reset>)
|
|
236
|
+
-> {"session_used_percent": 12.0, "session_resets_at": "2026-09-22T22:00:00+00:00",
|
|
237
|
+
"weekly_used_percent": 34.0, "weekly_resets_at": "2026-09-25T21:00:00+00:00"}
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
account_meters: The account's meters, or None when unread.
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
Each used percent and reset time, or None for an unread account.
|
|
244
|
+
"""
|
|
245
|
+
if account_meters is None:
|
|
246
|
+
return None
|
|
247
|
+
return {
|
|
248
|
+
JSON_SESSION_USED_PERCENT_KEY: account_meters.session_utilization,
|
|
249
|
+
JSON_SESSION_RESETS_AT_KEY: _iso_or_none(account_meters.session_resets_at),
|
|
250
|
+
JSON_WEEKLY_USED_PERCENT_KEY: account_meters.weekly_utilization,
|
|
251
|
+
JSON_WEEKLY_RESETS_AT_KEY: _iso_or_none(account_meters.weekly_resets_at),
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def main(all_command_arguments: list[str]) -> int:
|
|
256
|
+
"""Print the chosen account and both accounts' meters as JSON.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
all_command_arguments: Command-line arguments after the program name.
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
Zero once the choice is printed.
|
|
263
|
+
"""
|
|
264
|
+
arguments = _build_argument_parser().parse_args(all_command_arguments)
|
|
265
|
+
config_directory_by_account = {
|
|
266
|
+
CHOICE_MAIN: arguments.main_config_dir,
|
|
267
|
+
CHOICE_SECOND: arguments.second_config_dir,
|
|
268
|
+
}
|
|
269
|
+
main_meters = read_account_meters(arguments.main_config_dir / CREDENTIALS_FILE_NAME)
|
|
270
|
+
second_meters = read_account_meters(
|
|
271
|
+
arguments.second_config_dir / CREDENTIALS_FILE_NAME
|
|
272
|
+
)
|
|
273
|
+
decision = choose_account(
|
|
274
|
+
main_meters=main_meters,
|
|
275
|
+
second_meters=second_meters,
|
|
276
|
+
now=datetime.now().astimezone(),
|
|
277
|
+
)
|
|
278
|
+
config_directory = config_directory_by_account.get(decision.account)
|
|
279
|
+
report = {
|
|
280
|
+
**decision_payload(decision, config_directory=config_directory),
|
|
281
|
+
JSON_METERS_KEY: {
|
|
282
|
+
CHOICE_MAIN: meters_payload(main_meters),
|
|
283
|
+
CHOICE_SECOND: meters_payload(second_meters),
|
|
284
|
+
},
|
|
285
|
+
}
|
|
286
|
+
print(json.dumps(report))
|
|
287
|
+
return 0
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
if __name__ == "__main__":
|
|
291
|
+
sys.exit(main(sys.argv[1:]))
|