claude-dev-env 8.7.0 → 8.8.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.
@@ -25,7 +25,7 @@ One warm advisor at the strongest tier this session can reach. This session is t
25
25
 
26
26
  1. Name the session identity first (protocol **Host profiles**), then walk the model floor.
27
27
  2. Claude: when `advisor` is in this session's tool list, the built-in advisor tool is the advisor. Spawn nothing and call `advisor()` at each consult point. Otherwise spawn or resume `pstack:poteto-agent` at `ADVISOR_EFFORT` (default low) with the advisor response contract in the prompt. When the bound model is out of usage, use the configured fallback at the same effort. Codex: use the native advisor path. Third-party: use the headless advisor path. When the host's walk fails, fail closed.
28
- 3. Name the warm instance `team-advisor-agent` and keep its advisor response contract in every first prompt. When a native Codex subagent spawn is the advisor path, pass `flags: ["--advisor"]` with Astra.
28
+ 3. Name the warm instance `team-advisor-agent` and keep its advisor response contract in every first prompt. When a native Codex subagent spawn is the advisor path and the spawn tool has a `flags` field, pass `flags: ["--advisor"]` with Astra and record `advisor_flag: passed`. A spawn that skips an offered flag is a failed bind. When the spawn tool has no `flags` field, as in `codex exec`, spawn `gpt-6-astra` plainly, record `advisor_flag: unavailable`, and treat it as bound.
29
29
  4. Skip the multi-consumer "who you are" opener. This session is the sole consumer.
30
30
  5. When the bind or reply path fails, fail closed and report to the user. On a third-party host, only the bound advisor issues ENDORSE / CORRECTION / PLAN / STOP.
31
31
 
@@ -49,7 +49,7 @@ Aim for two consults on a normal task: one after orientation and one after write
49
49
  - Bind at or above the protocol floor for this host.
50
50
  - The advisor only answers (messaging); the session runs tools and posts.
51
51
  - Keep an optional reference gap in the reference record: path, status, repair action, and repair result. A reference gap leaves bind status separate.
52
- - On Codex, select native Astra when its bind and reply validation pass. Record `selected_tier: Astra` and `reply_path: native`.
52
+ - On Codex, select native Astra when its bind and reply validation pass. Record `selected_tier: Astra` and `reply_path: native`, plus `advisor_flag: passed` or `advisor_flag: unavailable`.
53
53
  - Mark `fallback_kind: broken` only when the selected bind or reply path fails. Record the selected tier, fallback reason, and reply path in the same evidence object.
54
54
  - Record first, recovery, and completion consults with changed evidence, validation, unresolved risks, and report-back status.
55
55
  - Write the evidence object to the session-controlled `model-tier-run.json` record described in `advisor-tool.md`.
@@ -19,7 +19,7 @@ When both `codex` and `claude` tokens appear, Codex wins. Empty text is ThirdPar
19
19
 
20
20
  **Claude.** Spawn `subagent_type: session-advisor` at Fable through the Agent tool. When Fable is out of usage, bind Astra through the Codex helper if `ADVISOR_ASTRA` is on. Fail closed when neither binds.
21
21
 
22
- **Codex.** Spawn a native in-session Astra subagent at `resolve_codex_model_id("Astra")` (`gpt-6-astra`). Walk `candidate_tiers = ["Astra"]`. Record `{tier: "Astra", result: "spawned"}` on success. The `ADVISOR_ASTRA` flag is not required. Fail closed when Astra does not bind. Do not walk Fable on a Codex host.
22
+ **Codex.** Spawn a native in-session Astra subagent at `resolve_codex_model_id("Astra")` (`gpt-6-astra`). Walk `candidate_tiers = ["Astra"]`. When the spawn tool has a `flags` field, pass `flags: ["--advisor"]` and record `fallback.advisor_flag: "passed"`. When it has none, as in `codex exec`, spawn `gpt-6-astra` plainly and record `fallback.advisor_flag: "unavailable"`. Both count as a bind. Record `{tier: "Astra", result: "spawned"}` on success. The `ADVISOR_ASTRA` flag is not required. Fail closed when Astra does not bind, or when the spawn skips a flag the host offers. Do not walk Fable on a Codex host.
23
23
 
24
24
  **ThirdParty.** Bind Fable through the CLI Claude-chain. When Fable is out of usage, bind Astra through the Codex helper if `ADVISOR_ASTRA` is on. Fail closed when neither binds.
25
25
 
@@ -29,5 +29,5 @@ Exit code `1` means a ladder invariant failed.
29
29
  Exit code `2` means the path or JSON was unusable.
30
30
  The same checks are available in-process via `validate_model_tier_run(run)`.
31
31
 
32
- The validator checks ladder shape and validates `evidence` when it is present. On Claude and ThirdParty, Astra is attempted after Fable when `astra_enabled` is enabled, and `selected_tier: "Astra"` requires `result: "codex"`. On Codex, the walk is Astra only, and `result: "spawned"` or `result: "codex"` counts as success. A Codex success record uses `reply_path: "native"`.
32
+ The validator checks ladder shape and validates `evidence` when it is present. On Claude and ThirdParty, Astra is attempted after Fable when `astra_enabled` is enabled, and `selected_tier: "Astra"` requires `result: "codex"`. On Codex, the walk is Astra only, and `result: "spawned"` or `result: "codex"` counts as success. A Codex success record uses `reply_path: "native"` and `advisor_flag: "passed"` or `advisor_flag: "unavailable"`. A missing `advisor_flag` or `advisor_flag: "omitted"` fails validation.
33
33
  Host policy sits on top of it. See the Model floor section of the protocol.
@@ -31,7 +31,9 @@ The agent finishes its first turn standing by. `SendMessage` alone resumes it; b
31
31
  ## Codex host
32
32
 
33
33
  Spawn a native in-session Astra subagent at `resolve_codex_model_id("Astra")` (`gpt-6-astra`) with the charter as its prompt.
34
- Record `{tier: "Astra", result: "spawned"}` on success. Fail closed when that spawn does not bind.
34
+ When the spawn tool has a `flags` field, pass `flags: ["--advisor"]` and record `fallback.advisor_flag: "passed"`.
35
+ When the spawn tool has no `flags` field, as in `codex exec`, spawn `gpt-6-astra` plainly, record `fallback.advisor_flag: "unavailable"`, and treat the advisor as bound.
36
+ Record `{tier: "Astra", result: "spawned"}` on success. Fail closed when that spawn does not bind, or when it skips a flag the host offers.
35
37
  The `ADVISOR_ASTRA` flag is not required. Do not walk Fable. Consults stay in-session with that Astra subagent.
36
38
  Identity routing: [`identity.md`](identity.md).
37
39
 
@@ -106,3 +106,18 @@ ALL_ADVISOR_REPLY_PATHS: frozenset[str] = frozenset(
106
106
  {"native", "sendmessage", "cli", "codex", "none"}
107
107
  )
108
108
  EVIDENCE_MUST_BE_OBJECT_MESSAGE: str = "evidence must be an object"
109
+ ADVISOR_FLAG_PASSED: str = "passed"
110
+ ADVISOR_FLAG_UNAVAILABLE: str = "unavailable"
111
+ ADVISOR_FLAG_OMITTED: str = "omitted"
112
+ ALL_ADVISOR_FLAG_STATES: frozenset[str] = frozenset(
113
+ {ADVISOR_FLAG_PASSED, ADVISOR_FLAG_UNAVAILABLE, ADVISOR_FLAG_OMITTED}
114
+ )
115
+ UNKNOWN_ADVISOR_FLAG_MESSAGE: str = (
116
+ "evidence.fallback.advisor_flag must be passed, unavailable, or omitted"
117
+ )
118
+ MISSING_ADVISOR_FLAG_MESSAGE: str = (
119
+ "Codex Astra success requires evidence.fallback.advisor_flag"
120
+ )
121
+ OMITTED_ADVISOR_FLAG_MESSAGE: str = (
122
+ "Codex Astra spawn skipped the --advisor flag the host offers"
123
+ )
@@ -67,6 +67,8 @@ from advisor_scripts_constants.astra_advisor_constants import (
67
67
  ASTRA_FALLBACK_KIND_DECLINED,
68
68
  )
69
69
  from advisor_scripts_constants.model_tier_run_validator_constants import (
70
+ ADVISOR_FLAG_OMITTED,
71
+ ALL_ADVISOR_FLAG_STATES,
70
72
  ALL_ADVISOR_REPLY_PATHS,
71
73
  ATTEMPT_ORDER_MISMATCH_MESSAGE,
72
74
  ATTEMPT_TIER_OUT_OF_SLICE_MESSAGE,
@@ -82,10 +84,13 @@ from advisor_scripts_constants.model_tier_run_validator_constants import (
82
84
  HOST_PROFILE_JSON_KEY,
83
85
  HOST_PROFILE_MUST_BE_STRING_MESSAGE,
84
86
  INCOMPLETE_FALLBACK_WALK_MESSAGE,
87
+ MISSING_ADVISOR_FLAG_MESSAGE,
85
88
  MISSING_FALLBACK_REASON_MESSAGE,
89
+ OMITTED_ADVISOR_FLAG_MESSAGE,
86
90
  SELECTED_TIER_MISMATCH_MESSAGE,
87
91
  SELECTED_TIER_NOT_NULL_MESSAGE,
88
92
  THIRD_PARTY_MODEL_TIER,
93
+ UNKNOWN_ADVISOR_FLAG_MESSAGE,
89
94
  UNKNOWN_HOST_PROFILE_ERROR,
90
95
  UNKNOWN_OWN_TIER_MESSAGE,
91
96
  )
@@ -212,14 +217,26 @@ def _validate_fallback_reply_path(
212
217
  raise ModelTierRunError(
213
218
  "evidence.fallback.reply_path is not a known advisor path"
214
219
  )
215
- if (
220
+ is_codex_astra_success = (
216
221
  canonical_host_profile(run.host_profile) == HOST_PROFILE_CODEX
217
222
  and maybe_run_selected_tier == ADVISOR_MODEL_TIER
218
- and reply_path != "native"
219
- ):
223
+ )
224
+ if is_codex_astra_success and reply_path != "native":
220
225
  raise ModelTierRunError(
221
226
  "Codex Astra success requires the native reply path"
222
227
  )
228
+ raw_advisor_flag = fallback_by_name.get("advisor_flag")
229
+ if raw_advisor_flag is None:
230
+ if is_codex_astra_success:
231
+ raise ModelTierRunError(MISSING_ADVISOR_FLAG_MESSAGE)
232
+ return
233
+ if (
234
+ not isinstance(raw_advisor_flag, str)
235
+ or raw_advisor_flag not in ALL_ADVISOR_FLAG_STATES
236
+ ):
237
+ raise ModelTierRunError(UNKNOWN_ADVISOR_FLAG_MESSAGE)
238
+ if is_codex_astra_success and raw_advisor_flag == ADVISOR_FLAG_OMITTED:
239
+ raise ModelTierRunError(OMITTED_ADVISOR_FLAG_MESSAGE)
223
240
 
224
241
 
225
242
  def _validate_fallback_evidence(
@@ -44,7 +44,16 @@ def _native_codex_evidence(
44
44
  *,
45
45
  reply_path: str = "native",
46
46
  reference_status: str = "missing",
47
+ advisor_flag: str | None = "passed",
47
48
  ) -> dict[str, object]:
49
+ fallback: dict[str, object] = {
50
+ "selected_tier": "Astra",
51
+ "fallback_kind": None,
52
+ "fallback_reason": None,
53
+ "reply_path": reply_path,
54
+ }
55
+ if advisor_flag is not None:
56
+ fallback["advisor_flag"] = advisor_flag
48
57
  return {
49
58
  "schema_version": 1,
50
59
  "reference": {
@@ -53,12 +62,7 @@ def _native_codex_evidence(
53
62
  "repair_action": "use the projected docs root",
54
63
  "repair_result": "read",
55
64
  },
56
- "fallback": {
57
- "selected_tier": "Astra",
58
- "fallback_kind": None,
59
- "fallback_reason": None,
60
- "reply_path": reply_path,
61
- },
65
+ "fallback": fallback,
62
66
  "consult": {
63
67
  "changed_evidence": ["native bind returned a reply"],
64
68
  "validation": ["signal and session id read back"],
@@ -96,6 +100,46 @@ def test_codex_native_success_rejects_non_native_reply_path() -> None:
96
100
  validate_model_tier_run(run)
97
101
 
98
102
 
103
+ def _codex_astra_success_run(evidence: dict[str, object]) -> ModelTierRun:
104
+ return ModelTierRun(
105
+ own_tier="Opus",
106
+ candidate_tiers=[ADVISOR_MODEL_TIER],
107
+ attempts=[{"tier": ADVISOR_MODEL_TIER, "result": "spawned"}],
108
+ selected_tier=ADVISOR_MODEL_TIER,
109
+ host_profile="Codex",
110
+ evidence=evidence,
111
+ )
112
+
113
+
114
+ def test_codex_plain_astra_spawn_binds_when_host_lacks_advisor_flag() -> None:
115
+ run = _codex_astra_success_run(
116
+ _native_codex_evidence(advisor_flag="unavailable")
117
+ )
118
+
119
+ assert validate_model_tier_run(run) is None
120
+
121
+
122
+ def test_codex_astra_spawn_that_skips_offered_advisor_flag_fails_closed() -> None:
123
+ run = _codex_astra_success_run(_native_codex_evidence(advisor_flag="omitted"))
124
+
125
+ with pytest.raises(ModelTierRunError, match="--advisor"):
126
+ validate_model_tier_run(run)
127
+
128
+
129
+ def test_codex_astra_success_without_advisor_flag_record_fails_closed() -> None:
130
+ run = _codex_astra_success_run(_native_codex_evidence(advisor_flag=None))
131
+
132
+ with pytest.raises(ModelTierRunError, match="advisor_flag"):
133
+ validate_model_tier_run(run)
134
+
135
+
136
+ def test_unknown_advisor_flag_state_is_rejected() -> None:
137
+ run = _codex_astra_success_run(_native_codex_evidence(advisor_flag="skipped"))
138
+
139
+ with pytest.raises(ModelTierRunError, match="advisor_flag"):
140
+ validate_model_tier_run(run)
141
+
142
+
99
143
  def test_broken_bind_keeps_fallback_evidence_separate_from_reference_gap() -> None:
100
144
  evidence = _native_codex_evidence()
101
145
  evidence["fallback"] = {
@@ -40,7 +40,7 @@ Work a disagreement in this order: keep the observed evidence in the record, nam
40
40
 
41
41
  ## Recovery record
42
42
 
43
- Keep one versioned `evidence` object in the session-controlled `model-tier-run.json` record. A missing optional reference belongs in `reference.status`; it keeps the bind result independent. `fallback_kind: "broken"` identifies a bind or reply failure. On a Codex host, a successful native route records `selected_tier: "Astra"` and `reply_path: "native"`.
43
+ Keep one versioned `evidence` object in the session-controlled `model-tier-run.json` record. A missing optional reference belongs in `reference.status`; it keeps the bind result independent. `fallback_kind: "broken"` identifies a bind or reply failure. On a Codex host, a successful native route records `selected_tier: "Astra"`, `reply_path: "native"`, and `advisor_flag`. The value is `"passed"` when the spawn tool offers the `--advisor` flag and the spawn passed it, or `"unavailable"` when the spawn tool has no flag field. `"omitted"` fails the bind.
44
44
 
45
45
  ```json
46
46
  {
@@ -56,7 +56,8 @@ Keep one versioned `evidence` object in the session-controlled `model-tier-run.j
56
56
  "selected_tier": "Astra",
57
57
  "fallback_kind": null,
58
58
  "fallback_reason": null,
59
- "reply_path": "native"
59
+ "reply_path": "native",
60
+ "advisor_flag": "passed"
60
61
  },
61
62
  "consult": {
62
63
  "changed_evidence": ["native bind returned a reply"],
@@ -0,0 +1,50 @@
1
+ # Second Claude account
2
+
3
+ A second Claude subscription runs agents on the same machine as the main one. It
4
+ shares every skill, rule, plugin, setting and doc in the main Claude home, and it
5
+ keeps its own sign-in and history.
6
+
7
+ ## Pieces
8
+
9
+ | File | What it does |
10
+ |---|---|
11
+ | `scripts/claude_account_profile.py` | Links each shared entry of `~/.claude` into the profile `~/.claude-profiles/ev`, moves stale copies into `.replaced/<time>/`, and writes the `claude-ev.cmd` launcher into `~/.local/bin` |
12
+ | `scripts/claude_account_choice.py` | Reads both accounts' 5-hour and weekly meters and prints which account a job runs on |
13
+ | `scripts/dev_env_scripts_constants/claude_account_constants.py` | The profile name, the entries that stay per account, and the picker thresholds |
14
+
15
+ The launcher sets `CLAUDE_CONFIG_DIR` to the profile and passes every argument to
16
+ `claude`, so `claude-ev -p "..."` runs like `claude -p "..."` on the second
17
+ account.
18
+
19
+ These entries stay per account and are never linked: `.credentials.json` and
20
+ `.claude.json` with their backups, `projects`, `sessions`, `todos`, `history.jsonl`,
21
+ and the other state folders named in the constants file.
22
+
23
+ ## Sign in once
24
+
25
+ ```
26
+ python packages/claude-dev-env/scripts/claude_account_profile.py
27
+ claude-ev auth login
28
+ ```
29
+
30
+ The sign-in lives in `~/.claude-profiles/ev/.credentials.json` on that machine.
31
+ Claude refreshes it on each run.
32
+
33
+ ## Which account a job uses
34
+
35
+ The main account belongs to the person who works on it, so a job borrows it only
36
+ to spend leftover usage that expires soon.
37
+
38
+ | Condition | Account |
39
+ |---|---|
40
+ | Main week resets within 24 hours, main under 90% of its week, main under 50% of its 5-hour window | main |
41
+ | Otherwise, second under 95% of its week and under 90% of its 5-hour window | second |
42
+ | Second meter unreadable | second, whose own run refreshes the sign-in |
43
+ | Otherwise | wait, with the next reset time |
44
+
45
+ An unreadable main meter never picks main.
46
+
47
+ ```
48
+ python packages/claude-dev-env/scripts/claude_account_choice.py
49
+ {"account": "second", "config_dir": "C:\\Users\\me\\.claude-profiles\\ev", "reason": "..."}
50
+ ```
@@ -24,6 +24,7 @@ SYNTHETIC_AWS_KEY = "AKIA" + ("B" * 16)
24
24
  SYNTHETIC_PEM_HEADER = "-----BEGIN RSA PRIVATE KEY-----"
25
25
  SYNTHETIC_REAL_EMAIL = "person.fixture@company-example.io"
26
26
  SYNTHETIC_SAFE_EMAIL = "user@example.com"
27
+ SYNTHETIC_GITHUB_NOREPLY_EMAIL = "24366590+octocat@users.noreply.github.com"
27
28
  SYNTHETIC_HOME_PATH = r"C:\Users\fixture_real_user\notes.txt"
28
29
  SYNTHETIC_PLACEHOLDER_HOME = r"C:\Users\<you>\notes.txt"
29
30
  SYNTHETIC_PRIVATE_IP = "192.168.42.17"
@@ -42,6 +43,13 @@ def test_flags_real_email_and_allows_example_domain() -> None:
42
43
  assert all_safe_hits == []
43
44
 
44
45
 
46
+ def test_allows_github_noreply_email_and_still_flags_a_company_lookalike() -> None:
47
+ all_noreply_hits = scan_text_for_pii(f"commit author {SYNTHETIC_GITHUB_NOREPLY_EMAIL}")
48
+ all_lookalike_hits = scan_text_for_pii("contact person@users.noreply.github.com.evil.io")
49
+ assert all_noreply_hits == []
50
+ assert any(each.category == "email" for each in all_lookalike_hits)
51
+
52
+
45
53
  def test_flags_home_path_and_allows_placeholder_user() -> None:
46
54
  all_home_hits = scan_text_for_pii(f"path is {SYNTHETIC_HOME_PATH}")
47
55
  all_placeholder_hits = scan_text_for_pii(f"path is {SYNTHETIC_PLACEHOLDER_HOME}")
@@ -136,6 +136,7 @@ ALL_SAFE_EMAIL_DOMAINS: frozenset[str] = frozenset(
136
136
  "test",
137
137
  "local",
138
138
  "test.local",
139
+ "users.noreply.github.com",
139
140
  }
140
141
  )
141
142
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.7.0",
3
+ "version": "8.8.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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,246 @@
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_REASON_KEY,
45
+ MAIN_CLAUDE_HOME_DIRECTORY_NAME,
46
+ MAIN_SESSION_USED_CEILING_PERCENT,
47
+ MAIN_SPEND_WINDOW,
48
+ MAIN_WEEKLY_USED_CEILING_PERCENT,
49
+ REASON_MAIN_EXPIRING_TEMPLATE,
50
+ REASON_SECOND_HAS_ROOM_TEMPLATE,
51
+ REASON_SECOND_UNREAD,
52
+ REASON_WAIT_TEMPLATE,
53
+ SECOND_SESSION_USED_CEILING_PERCENT,
54
+ SECOND_WEEKLY_USED_CEILING_PERCENT,
55
+ SECONDS_PER_HOUR,
56
+ UNKNOWN_RESET_TEXT,
57
+ )
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class AccountDecision:
62
+ """The account a job runs on, and the plain-words reason for it."""
63
+
64
+ account: str
65
+ reason: str
66
+
67
+
68
+ def _main_spendable_reason(
69
+ main_meters: AccountUsageMeters | None, now: datetime
70
+ ) -> str | None:
71
+ if main_meters is None:
72
+ return None
73
+ session_used = main_meters.session_utilization
74
+ weekly_used = main_meters.weekly_utilization
75
+ weekly_resets_at = main_meters.weekly_resets_at
76
+ if session_used is None or weekly_used is None or weekly_resets_at is None:
77
+ return None
78
+ time_until_reset = weekly_resets_at - now
79
+ is_expiring_soon = time_until_reset <= MAIN_SPEND_WINDOW
80
+ has_weekly_room = weekly_used < MAIN_WEEKLY_USED_CEILING_PERCENT
81
+ has_session_room = session_used < MAIN_SESSION_USED_CEILING_PERCENT
82
+ if not (is_expiring_soon and has_weekly_room and has_session_room):
83
+ return None
84
+ return REASON_MAIN_EXPIRING_TEMPLATE.format(
85
+ hours_until_reset=int(time_until_reset.total_seconds() // SECONDS_PER_HOUR),
86
+ remaining_percent=FULL_PERCENT - weekly_used,
87
+ )
88
+
89
+
90
+ def _wait_decision(
91
+ second_meters: AccountUsageMeters, *, is_week_blocked: bool
92
+ ) -> AccountDecision:
93
+ blocking_reset = (
94
+ second_meters.weekly_resets_at
95
+ if is_week_blocked
96
+ else second_meters.session_resets_at
97
+ )
98
+ return AccountDecision(
99
+ account=CHOICE_WAIT,
100
+ reason=REASON_WAIT_TEMPLATE.format(
101
+ weekly_used_percent=second_meters.weekly_utilization,
102
+ session_used_percent=second_meters.session_utilization,
103
+ next_reset=(
104
+ blocking_reset.isoformat() if blocking_reset else UNKNOWN_RESET_TEXT
105
+ ),
106
+ ),
107
+ )
108
+
109
+
110
+ def _second_account_decision(
111
+ second_meters: AccountUsageMeters | None,
112
+ ) -> AccountDecision:
113
+ if second_meters is None:
114
+ return AccountDecision(account=CHOICE_SECOND, reason=REASON_SECOND_UNREAD)
115
+ session_used = second_meters.session_utilization
116
+ weekly_used = second_meters.weekly_utilization
117
+ if session_used is None or weekly_used is None:
118
+ return AccountDecision(account=CHOICE_SECOND, reason=REASON_SECOND_UNREAD)
119
+ is_week_blocked = weekly_used >= SECOND_WEEKLY_USED_CEILING_PERCENT
120
+ is_session_blocked = session_used >= SECOND_SESSION_USED_CEILING_PERCENT
121
+ if is_week_blocked or is_session_blocked:
122
+ return _wait_decision(second_meters, is_week_blocked=is_week_blocked)
123
+ return AccountDecision(
124
+ account=CHOICE_SECOND,
125
+ reason=REASON_SECOND_HAS_ROOM_TEMPLATE.format(
126
+ weekly_remaining_percent=FULL_PERCENT - weekly_used,
127
+ session_remaining_percent=FULL_PERCENT - session_used,
128
+ ),
129
+ )
130
+
131
+
132
+ def choose_account(
133
+ *,
134
+ main_meters: AccountUsageMeters | None,
135
+ second_meters: AccountUsageMeters | None,
136
+ now: datetime,
137
+ ) -> AccountDecision:
138
+ """Pick the account a job runs on from both accounts' meters.
139
+
140
+ ::
141
+
142
+ main: week resets in 5h, 80% used, 5-hour 10% used -> main
143
+ main: week resets in 30h -> second, if it has room
144
+ main unread, second at 99% of its week -> wait
145
+ main unread, second unread -> second
146
+
147
+ Main runs a job only to spend leftover usage that expires soon.
148
+
149
+ Args:
150
+ main_meters: The main account's meters, or None when unread.
151
+ second_meters: The second account's meters, or None when unread.
152
+ now: The time the reset windows are measured from.
153
+
154
+ Returns:
155
+ The chosen account and the reason.
156
+ """
157
+ main_reason = _main_spendable_reason(main_meters, now)
158
+ if main_reason is not None:
159
+ return AccountDecision(account=CHOICE_MAIN, reason=main_reason)
160
+ return _second_account_decision(second_meters)
161
+
162
+
163
+ def read_account_meters(credentials_path: Path) -> AccountUsageMeters | None:
164
+ """Read one account's meters, or None when its credential or probe fails.
165
+
166
+ Args:
167
+ credentials_path: The account's CLI credential file.
168
+
169
+ Returns:
170
+ The meters, or None when they cannot be read.
171
+ """
172
+ try:
173
+ return probe_account_meters(credentials_path)
174
+ except WeeklyUtilizationProbeError:
175
+ return None
176
+
177
+
178
+ def _build_argument_parser() -> argparse.ArgumentParser:
179
+ parser = argparse.ArgumentParser(
180
+ description="Name the Claude account a runner job uses."
181
+ )
182
+ parser.add_argument(
183
+ "--main-config-dir",
184
+ type=Path,
185
+ default=Path.home() / MAIN_CLAUDE_HOME_DIRECTORY_NAME,
186
+ )
187
+ parser.add_argument(
188
+ "--second-config-dir",
189
+ type=Path,
190
+ default=default_profile_home(),
191
+ )
192
+ return parser
193
+
194
+
195
+ def decision_payload(
196
+ decision: AccountDecision, *, config_directory: Path | None
197
+ ) -> dict[str, str | None]:
198
+ """Shape a decision as the JSON object the runner job reads.
199
+
200
+ ::
201
+
202
+ AccountDecision("second", "...") with the profile directory
203
+ -> {"account": "second", "config_dir": "<profile>", "reason": "..."}
204
+
205
+ Args:
206
+ decision: The chosen account and reason.
207
+ config_directory: The Claude home the account runs under, or None on wait.
208
+
209
+ Returns:
210
+ The account, its Claude home, and the reason.
211
+ """
212
+ return {
213
+ JSON_ACCOUNT_KEY: decision.account,
214
+ JSON_CONFIG_DIRECTORY_KEY: str(config_directory) if config_directory else None,
215
+ JSON_REASON_KEY: decision.reason,
216
+ }
217
+
218
+
219
+ def main(all_command_arguments: list[str]) -> int:
220
+ """Print the chosen account as JSON.
221
+
222
+ Args:
223
+ all_command_arguments: Command-line arguments after the program name.
224
+
225
+ Returns:
226
+ Zero once the choice is printed.
227
+ """
228
+ arguments = _build_argument_parser().parse_args(all_command_arguments)
229
+ config_directory_by_account = {
230
+ CHOICE_MAIN: arguments.main_config_dir,
231
+ CHOICE_SECOND: arguments.second_config_dir,
232
+ }
233
+ decision = choose_account(
234
+ main_meters=read_account_meters(arguments.main_config_dir / CREDENTIALS_FILE_NAME),
235
+ second_meters=read_account_meters(
236
+ arguments.second_config_dir / CREDENTIALS_FILE_NAME
237
+ ),
238
+ now=datetime.now().astimezone(),
239
+ )
240
+ config_directory = config_directory_by_account.get(decision.account)
241
+ print(json.dumps(decision_payload(decision, config_directory=config_directory)))
242
+ return 0
243
+
244
+
245
+ if __name__ == "__main__":
246
+ sys.exit(main(sys.argv[1:]))