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.
Files changed (51) hide show
  1. package/.agents/skills/privacy-hygiene/SKILL.md +1 -0
  2. package/.agents/skills/pull-request/scripts/pull_request.py +1 -0
  3. package/.agents/skills/pull-request/scripts/test_pull_request.py +2 -0
  4. package/.agents/skills/team-advisor/SKILL.md +2 -2
  5. package/_shared/advisor/reference/identity.md +1 -1
  6. package/_shared/advisor/reference/spawn-walk-log.md +1 -1
  7. package/_shared/advisor/reference/warm-up.md +3 -1
  8. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +15 -0
  9. package/_shared/advisor/scripts/model_tier_run_validator.py +20 -3
  10. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +50 -6
  11. package/docs/references/advisor-tool.md +3 -2
  12. package/docs/second-claude-account.md +57 -0
  13. package/hooks/blocking/test_pii_scanner.py +8 -0
  14. package/hooks/hooks.json +25 -0
  15. package/hooks/hooks_constants/pii_prevention_constants.py +1 -0
  16. package/hooks/hooks_constants/skill_loaded_reminder_constants.py +58 -0
  17. package/hooks/session/skill_loaded_reminder.py +199 -0
  18. package/hooks/session/test_skill_loaded_reminder.py +206 -0
  19. package/package.json +1 -1
  20. package/rules/durable-post-artifacts.md +26 -1
  21. package/rules/long-horizon-autonomy.md +11 -0
  22. package/rules/prefer-existing-tools.md +35 -0
  23. package/rules/review-closure-is-a-check.md +1 -1
  24. package/scripts/claude_account_choice.py +291 -0
  25. package/scripts/claude_account_profile.py +371 -0
  26. package/scripts/claude_chain_usage.py +49 -5
  27. package/scripts/dev_env_scripts_constants/claude_account_constants.py +181 -0
  28. package/scripts/dev_env_scripts_constants/gh_artifact_upload_constants.py +27 -0
  29. package/scripts/dev_env_scripts_constants/private_term_constants.py +60 -0
  30. package/scripts/dev_env_scripts_constants/test_claude_account_constants.py +46 -0
  31. package/scripts/dev_env_scripts_constants/test_private_term_constants.py +29 -0
  32. package/scripts/durable_post_lint.py +47 -1
  33. package/scripts/durable_post_lint_config/config/constants.py +1 -0
  34. package/scripts/gh_artifact_upload.py +56 -3
  35. package/scripts/private_term_scan.py +135 -0
  36. package/scripts/private_terms.py +87 -0
  37. package/scripts/repository_checks/config/constants.py +3 -0
  38. package/scripts/repository_checks/runner.py +7 -0
  39. package/scripts/repository_checks/tracked_private_terms.py +57 -0
  40. package/scripts/test_claude_account_choice.py +245 -0
  41. package/scripts/test_claude_account_profile.py +220 -0
  42. package/scripts/test_claude_chain_usage.py +40 -0
  43. package/scripts/test_durable_post_lint.py +90 -0
  44. package/scripts/tests/test_gh_artifact_upload.py +75 -0
  45. package/scripts/tests/test_match_exemptions.py +2 -2
  46. package/scripts/tests/test_private_term_scan.py +176 -0
  47. package/scripts/tests/test_private_terms.py +59 -0
  48. package/scripts/tests/test_repository_policy.py +2 -0
  49. package/scripts/tests/test_runner.py +20 -0
  50. package/scripts/tests/test_tracked_private_terms.py +71 -0
  51. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.7.0",
3
+ "version": "8.9.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- `Echo-Visuals-Inc/python-automation` runs the same command from its own workflow, against the revision of this package that its workflow pins.
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:]))