claude-dev-env 8.83.2 → 8.84.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.
@@ -6,7 +6,7 @@ Claude's main home is `~/.claude`. Its `claude-chain.json` file contains a `chai
6
6
 
7
7
  The optional `claude-account-order.json` file in the main home sets the Claude pick priority. It is a JSON list of account names, first choice first, such as `["claude-second", "claude"]`. An entry matches an account's name, its command, or its `claude-<name>` launcher, and `claude` names the main account. A new job goes to the first listed account with room. When no listed account has room, the broker picks the unlisted account with the most room, and it waits when no account has room. A resumed session stays on its bound account while that account has room. A listed name absent from the roster is skipped. Without the file, every job goes to the account with the most room. Edit the file to change the order; the next pick reads it.
8
8
 
9
- Codex uses its configured account roster. When no Codex roster is configured, the broker chooses the default Codex home.
9
+ Codex uses its configured account roster. When no Codex roster is configured, the broker chooses the default Codex home. A new Codex job goes to an account on a Pro plan before any other account in the same tier. The plan comes from the account's meter reply, and `ALL_PRO_PLAN_TYPES` lists the Pro plans: `pro`, `prolite`, and `promax`. Among accounts of the same kind, the one with the most room wins. A Pro account below the `normal` floor still loses to any account with room in the `normal` tier.
10
10
 
11
11
  ## Commands
12
12
 
@@ -91,8 +91,8 @@ A window that reports no length counts as weekly.
91
91
 
92
92
  | Condition | Answer |
93
93
  |---|---|
94
- | One or more accounts with more than 10% left | `normal` on the account with the most room |
95
- | No account over 10%, one or more over 1% | `luna` on the account with the most room |
94
+ | One or more accounts with more than 10% left | `normal` on a Pro plan account (`pro`, `prolite` and `promax`) before any other account in this tier; among accounts of the same kind, the one with the most room |
95
+ | No account over 10%, one or more over 1% | `luna` on a Pro plan account (`pro`, `prolite` and `promax`) before any other account in this tier; among accounts of the same kind, the one with the most room |
96
96
  | An account that reports a window shorter than a week, such as the 5-hour window, with under 20% of that window left | never takes `luna` |
97
97
  | No account over 1% | `wait`, naming the next reset when known |
98
98
  | A meter read fails | The broker reads it again, up to 3 reads, waiting 1 second and then 2 seconds |
@@ -21,10 +21,20 @@ from hooks_constants.pull_request_proof_constants import (
21
21
  ALL_VISIBLE_FILE_SUFFIXES,
22
22
  CHANGED_FILES_SEPARATOR,
23
23
  COMMAND_MARKER,
24
+ ALL_BASE_FLAGS,
24
25
  ALL_DEFAULT_BRANCH_REFERENCES,
25
26
  ALL_HEAD_FLAGS,
27
+ ALL_REPOSITORY_FLAGS,
28
+ BASE_FLAG_ASSIGNMENT_PREFIX,
29
+ COMPARE_ENDPOINT_TEMPLATE,
30
+ COMPARE_FILE_LIST_LIMIT,
31
+ COMPARE_FILE_NAMES_QUERY,
32
+ DEFAULT_BRANCH_QUERY,
33
+ GH_TIMEOUT_SECONDS,
26
34
  HEAD_FLAG_ASSIGNMENT_PREFIX,
27
35
  REMOTE_BRANCH_PREFIX,
36
+ REPOSITORY_ENDPOINT_TEMPLATE,
37
+ REPOSITORY_FLAG_ASSIGNMENT_PREFIX,
28
38
  UNREADABLE_CHANGES_REASON,
29
39
  EVAL_HEADING_PATTERN,
30
40
  EXISTING_WORK_HEADING_PATTERN,
@@ -174,28 +184,55 @@ def _shell_create_calls(command: str, working_directory: str) -> list[CreateCall
174
184
  ]
175
185
 
176
186
 
177
- def _head_flag_value(all_create_arguments: list[str]) -> str | None:
187
+ def _flag_value(all_create_arguments: list[str], all_flags: frozenset[str], assignment_prefix: str) -> str | None:
178
188
  for each_position, each_argument in enumerate(all_create_arguments):
179
- if each_argument in ALL_HEAD_FLAGS and each_position + 1 < len(all_create_arguments):
180
- return all_create_arguments[each_position + 1]
181
- if each_argument.startswith(HEAD_FLAG_ASSIGNMENT_PREFIX):
182
- return each_argument[len(HEAD_FLAG_ASSIGNMENT_PREFIX) :]
189
+ if each_argument in all_flags and each_position + 1 < len(all_create_arguments):
190
+ return unquoted_token(all_create_arguments[each_position + 1])
191
+ if each_argument.startswith(assignment_prefix):
192
+ return unquoted_token(each_argument[len(assignment_prefix) :])
183
193
  return None
184
194
 
185
195
 
186
- def _named_head_branch(all_payload_fields: Mapping[str, object]) -> str | None:
187
- create_fields = create_tool_fields(all_payload_fields)
188
- head_branch = None if create_fields is None else create_fields.get("head")
189
- if isinstance(head_branch, str):
190
- return head_branch
196
+ def _shell_flag_value(
197
+ all_payload_fields: Mapping[str, object], all_flags: frozenset[str], assignment_prefix: str
198
+ ) -> str | None:
191
199
  command = _shell_command(all_payload_fields)
192
200
  if command is None:
193
201
  return None
194
- all_head_flag_values = [
195
- _head_flag_value(all_create_arguments)
202
+ all_flag_values = [
203
+ _flag_value(all_create_arguments, all_flags, assignment_prefix)
196
204
  for all_create_arguments in _all_create_argument_lists(command)
197
205
  ]
198
- return next((each_value for each_value in all_head_flag_values if each_value), None)
206
+ return next((each_value for each_value in all_flag_values if each_value), None)
207
+
208
+
209
+ def _named_create_field(
210
+ all_payload_fields: Mapping[str, object], field_name: str, all_flags: frozenset[str], assignment_prefix: str
211
+ ) -> str | None:
212
+ create_fields = create_tool_fields(all_payload_fields)
213
+ field_value = None if create_fields is None else create_fields.get(field_name)
214
+ if isinstance(field_value, str) and field_value:
215
+ return field_value
216
+ return _shell_flag_value(all_payload_fields, all_flags, assignment_prefix)
217
+
218
+
219
+ def _named_head_branch(all_payload_fields: Mapping[str, object]) -> str | None:
220
+ return _named_create_field(all_payload_fields, "head", ALL_HEAD_FLAGS, HEAD_FLAG_ASSIGNMENT_PREFIX)
221
+
222
+
223
+ def _named_base_branch(all_payload_fields: Mapping[str, object]) -> str | None:
224
+ return _named_create_field(all_payload_fields, "base", ALL_BASE_FLAGS, BASE_FLAG_ASSIGNMENT_PREFIX)
225
+
226
+
227
+ def _named_repository(all_payload_fields: Mapping[str, object]) -> str | None:
228
+ create_fields = create_tool_fields(all_payload_fields)
229
+ if create_fields is not None:
230
+ owner = create_fields.get("owner")
231
+ repository_name = create_fields.get("repo")
232
+ if isinstance(owner, str) and owner and isinstance(repository_name, str) and repository_name:
233
+ return f"{owner}/{repository_name}"
234
+ return None
235
+ return _shell_flag_value(all_payload_fields, ALL_REPOSITORY_FLAGS, REPOSITORY_FLAG_ASSIGNMENT_PREFIX)
199
236
 
200
237
 
201
238
  def _create_calls(all_payload_fields: Mapping[str, object]) -> list[CreateCall]:
@@ -485,9 +522,83 @@ def visible_changed_files(working_directory: str, head_branch: object) -> list[s
485
522
  all_changed_paths = _git_lines(working_directory, "diff", "--name-only", merge_base, head_reference)
486
523
  if all_changed_paths is None:
487
524
  return None
525
+ return _visible_paths(all_changed_paths)
526
+
527
+
528
+ def _visible_paths(all_changed_paths: list[str]) -> list[str]:
488
529
  return [each_path for each_path in all_changed_paths if each_path.lower().endswith(ALL_VISIBLE_FILE_SUFFIXES)]
489
530
 
490
531
 
532
+ def _gh_api_lines(endpoint: str, query: str) -> list[str] | None:
533
+ try:
534
+ completed = subprocess.run(
535
+ ["gh", "api", endpoint, "--jq", query],
536
+ capture_output=True,
537
+ text=True,
538
+ timeout=GH_TIMEOUT_SECONDS,
539
+ check=False,
540
+ )
541
+ except (OSError, subprocess.SubprocessError):
542
+ return None
543
+ return completed.stdout.splitlines() if completed.returncode == 0 else None
544
+
545
+
546
+ def _compare_base_branch(repository: str, base_branch: str | None) -> str | None:
547
+ if base_branch:
548
+ return base_branch
549
+ all_default_branch_lines = _gh_api_lines(
550
+ REPOSITORY_ENDPOINT_TEMPLATE.format(repository=repository), DEFAULT_BRANCH_QUERY
551
+ )
552
+ if not all_default_branch_lines:
553
+ return None
554
+ return all_default_branch_lines[0]
555
+
556
+
557
+ def _visible_compare_paths(repository: str, base_branch: str, head_branch: str) -> list[str] | None:
558
+ all_changed_paths = _gh_api_lines(
559
+ COMPARE_ENDPOINT_TEMPLATE.format(
560
+ repository=repository, base_branch=base_branch, head_branch=head_branch
561
+ ),
562
+ COMPARE_FILE_NAMES_QUERY,
563
+ )
564
+ if all_changed_paths is None:
565
+ return None
566
+ all_visible_paths = _visible_paths(all_changed_paths)
567
+ if all_visible_paths or len(all_changed_paths) < COMPARE_FILE_LIST_LIMIT:
568
+ return all_visible_paths
569
+ return None
570
+
571
+
572
+ def remote_visible_changed_files(
573
+ repository: str | None, base_branch: str | None, head_branch: str | None
574
+ ) -> list[str] | None:
575
+ """Return the changed files people see, read from the GitHub compare API, or None when it cannot tell.
576
+
577
+ ::
578
+
579
+ compare lists tool.py -> []
580
+ compare lists web/page.html -> ["web/page.html"]
581
+ compare returns 300 paths and none are visible -> None
582
+ repository or head missing -> None
583
+
584
+ The base is the named base branch, else the repository's default branch.
585
+ A page of 300 paths is the compare API's file cap. A visible file on that
586
+ page is returned. A full page with none is None, because a later file can
587
+ still be one people see.
588
+
589
+ Args:
590
+ repository: The owner/name the create call names, or None.
591
+ base_branch: The base branch the create call names, or None.
592
+ head_branch: The head branch the create call names, or None.
593
+ """
594
+ if not repository or not head_branch:
595
+ return None
596
+ resolved_base_branch = _compare_base_branch(repository, base_branch)
597
+ if resolved_base_branch is None:
598
+ return None
599
+ return _visible_compare_paths(repository, resolved_base_branch, head_branch)
600
+
601
+
491
602
  def missing_look_reason(all_payload_fields: Mapping[str, object]) -> str | None:
492
603
  """Return a deny reason when a pull request changing visible files shows no picture, else None.
493
604
 
@@ -496,7 +607,9 @@ def missing_look_reason(all_payload_fields: Mapping[str, object]) -> str | None:
496
607
  branch changes page.html, proof has ![after](https://x/after.png) -> None
497
608
  branch changes page.html, proof quotes only command output -> MISSING_LOOK_REASON
498
609
  branch changes only .py files -> None
499
- git cannot read the branch, proof shows no picture -> UNREADABLE_CHANGES_REASON
610
+ git cannot read the branch, compare API lists only .py files -> None
611
+ git cannot read the branch, compare API returns 300 .py files -> UNREADABLE_CHANGES_REASON
612
+ neither git nor the compare API can read it, no picture -> UNREADABLE_CHANGES_REASON
500
613
 
501
614
  Args:
502
615
  all_payload_fields: The parsed PreToolUse input.
@@ -505,9 +618,12 @@ def missing_look_reason(all_payload_fields: Mapping[str, object]) -> str | None:
505
618
  if not all_bodies:
506
619
  return None
507
620
  working_directory = all_payload_fields.get("cwd")
508
- all_visible_files = visible_changed_files(
509
- str(working_directory or ""), _named_head_branch(all_payload_fields)
510
- )
621
+ head_branch = _named_head_branch(all_payload_fields)
622
+ all_visible_files = visible_changed_files(str(working_directory or ""), head_branch)
623
+ if all_visible_files is None:
624
+ all_visible_files = remote_visible_changed_files(
625
+ _named_repository(all_payload_fields), _named_base_branch(all_payload_fields), head_branch
626
+ )
511
627
  if all_visible_files == []:
512
628
  return None
513
629
  for each_body in all_bodies:
@@ -4,12 +4,15 @@ import subprocess
4
4
  import sys
5
5
  from pathlib import Path
6
6
 
7
+ import pytest
8
+
7
9
  HOOKS_DIRECTORY = Path(__file__).resolve().parent.parent
8
10
  if str(HOOKS_DIRECTORY) not in sys.path:
9
11
  sys.path.insert(0, str(HOOKS_DIRECTORY))
10
12
 
11
13
  from blocking import pull_request_proof as gate
12
14
  from hooks_constants.pull_request_proof_constants import (
15
+ COMPARE_FILE_LIST_LIMIT,
13
16
  MISSING_BODY_REASON,
14
17
  MISSING_EVAL_SECTION_REASON,
15
18
  MISSING_EXISTING_WORK_REASON,
@@ -306,6 +309,147 @@ def test_should_deny_when_git_cannot_read_the_branch_and_proof_shows_no_picture(
306
309
  assert gate.missing_look_reason(payload) == UNREADABLE_CHANGES_REASON
307
310
 
308
311
 
312
+ def _fake_gh_api(all_responses: dict[str, list[str] | None], all_endpoints: list[str]):
313
+ def fake_gh_api_lines(endpoint: str, query: str) -> list[str] | None:
314
+ all_endpoints.append(endpoint)
315
+ return all_responses.get(endpoint)
316
+
317
+ return fake_gh_api_lines
318
+
319
+
320
+ def _shell_create_elsewhere(tmp_path: Path, extra_flags: str) -> dict[str, object]:
321
+ body_path = tmp_path / "body.md"
322
+ body_path.write_text(PROVEN_BODY, encoding="utf-8")
323
+ return _shell(f"gh pr create --title t --body-file {body_path} {extra_flags}", tmp_path)
324
+
325
+
326
+ def test_should_allow_a_python_only_change_the_compare_api_lists_when_git_cannot_read_it(
327
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
328
+ ) -> None:
329
+ all_endpoints: list[str] = []
330
+ compare_endpoint = "repos/o/r/compare/main...feature"
331
+ monkeypatch.setattr(gate, "_gh_api_lines", _fake_gh_api({compare_endpoint: ["tool.py"]}, all_endpoints))
332
+ payload = _shell_create_elsewhere(tmp_path, "--repo o/r --base main --head feature")
333
+ assert gate.missing_look_reason(payload) is None
334
+ assert all_endpoints == [compare_endpoint]
335
+
336
+
337
+ def test_should_deny_a_page_change_the_compare_api_lists_without_a_picture(
338
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
339
+ ) -> None:
340
+ all_responses = {"repos/o/r": ["trunk"], "repos/o/r/compare/trunk...feature": ["web/page.html", "tool.py"]}
341
+ all_endpoints: list[str] = []
342
+ monkeypatch.setattr(gate, "_gh_api_lines", _fake_gh_api(all_responses, all_endpoints))
343
+ reason = gate.missing_look_reason(_shell_create_elsewhere(tmp_path, "-R o/r --head=feature"))
344
+ assert reason is not None
345
+ assert "web/page.html" in reason
346
+ assert "tool.py" not in reason
347
+ assert all_endpoints == ["repos/o/r", "repos/o/r/compare/trunk...feature"]
348
+
349
+
350
+ def test_should_deny_as_unreadable_when_the_compare_api_fails(
351
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
352
+ ) -> None:
353
+ monkeypatch.setattr(gate, "_gh_api_lines", _fake_gh_api({}, []))
354
+ payload = _shell_create_elsewhere(tmp_path, "--repo o/r --base main --head feature")
355
+ reason = gate.missing_look_reason(payload)
356
+ assert reason == UNREADABLE_CHANGES_REASON
357
+ assert reason is not None
358
+ assert "compare API" in reason
359
+ assert "--repo" in reason
360
+ assert "--head" in reason
361
+ assert "origin fetched" in reason
362
+ assert "Artifact" in reason
363
+ assert "before any change to this gate" in reason
364
+
365
+
366
+ def _python_paths(count: int) -> list[str]:
367
+ return [f"pkg/module_{each_index}.py" for each_index in range(count)]
368
+
369
+
370
+ def test_should_deny_as_unreadable_when_a_full_compare_page_lists_no_visible_file(
371
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
372
+ ) -> None:
373
+ all_endpoints: list[str] = []
374
+ compare_endpoint = "repos/o/r/compare/main...feature"
375
+ monkeypatch.setattr(
376
+ gate,
377
+ "_gh_api_lines",
378
+ _fake_gh_api({compare_endpoint: _python_paths(COMPARE_FILE_LIST_LIMIT)}, all_endpoints),
379
+ )
380
+ assert COMPARE_FILE_LIST_LIMIT == 300
381
+ payload = _shell_create_elsewhere(tmp_path, "--repo o/r --base main --head feature")
382
+ assert gate.remote_visible_changed_files("o/r", "main", "feature") is None
383
+ assert gate.missing_look_reason(payload) == UNREADABLE_CHANGES_REASON
384
+ assert all_endpoints == [compare_endpoint, compare_endpoint]
385
+
386
+
387
+ def test_should_allow_a_short_compare_page_that_lists_only_python(
388
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
389
+ ) -> None:
390
+ all_endpoints: list[str] = []
391
+ compare_endpoint = "repos/o/r/compare/main...feature"
392
+ monkeypatch.setattr(
393
+ gate,
394
+ "_gh_api_lines",
395
+ _fake_gh_api({compare_endpoint: _python_paths(COMPARE_FILE_LIST_LIMIT - 1)}, all_endpoints),
396
+ )
397
+ payload = _shell_create_elsewhere(tmp_path, "--repo o/r --base main --head feature")
398
+ assert gate.remote_visible_changed_files("o/r", "main", "feature") == []
399
+ assert gate.missing_look_reason(payload) is None
400
+
401
+
402
+ def test_should_deny_a_visible_file_on_a_full_compare_page(
403
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
404
+ ) -> None:
405
+ all_endpoints: list[str] = []
406
+ compare_endpoint = "repos/o/r/compare/main...feature"
407
+ all_paths = [*_python_paths(COMPARE_FILE_LIST_LIMIT - 1), "web/page.html"]
408
+ monkeypatch.setattr(gate, "_gh_api_lines", _fake_gh_api({compare_endpoint: all_paths}, all_endpoints))
409
+ reason = gate.missing_look_reason(_shell_create_elsewhere(tmp_path, "--repo o/r --base main --head feature"))
410
+ assert reason is not None
411
+ assert "web/page.html" in reason
412
+ assert "module_0.py" not in reason
413
+
414
+
415
+ def test_should_allow_a_page_the_compare_api_lists_when_the_proof_links_an_artifact(
416
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
417
+ ) -> None:
418
+ all_endpoints: list[str] = []
419
+ compare_endpoint = "repos/o/r/compare/main...feature"
420
+ monkeypatch.setattr(gate, "_gh_api_lines", _fake_gh_api({compare_endpoint: ["page.html"]}, all_endpoints))
421
+ body_path = tmp_path / "body.md"
422
+ body_path.write_text(PROVEN_BODY + "https://claude.ai/code/artifact/abc-123\n", encoding="utf-8")
423
+ payload = _shell(
424
+ f"gh pr create --title t --body-file {body_path} --repo o/r --base main --head feature",
425
+ tmp_path,
426
+ )
427
+ assert gate.missing_look_reason(payload) is None
428
+ assert all_endpoints == [compare_endpoint]
429
+
430
+
431
+ def test_should_skip_the_compare_api_without_a_named_repository(
432
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
433
+ ) -> None:
434
+ all_endpoints: list[str] = []
435
+ monkeypatch.setattr(gate, "_gh_api_lines", _fake_gh_api({}, all_endpoints))
436
+ payload = _shell_create_elsewhere(tmp_path, "--base main --head feature")
437
+ assert gate.missing_look_reason(payload) == UNREADABLE_CHANGES_REASON
438
+ assert all_endpoints == []
439
+
440
+
441
+ def test_should_read_owner_and_repo_of_an_mcp_create_for_the_compare_api(
442
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
443
+ ) -> None:
444
+ all_endpoints: list[str] = []
445
+ compare_endpoint = "repos/o/r/compare/main...feature"
446
+ monkeypatch.setattr(gate, "_gh_api_lines", _fake_gh_api({compare_endpoint: ["tool.py"]}, all_endpoints))
447
+ payload = _mcp_create_on_branch(PROVEN_BODY, tmp_path)
448
+ payload["tool_input"].update(owner="o", repo="r", base="main")
449
+ assert gate.missing_look_reason(payload) is None
450
+ assert all_endpoints == [compare_endpoint]
451
+
452
+
309
453
  def test_visible_changed_files_falls_back_to_origin_main_without_origin_head(tmp_path: Path) -> None:
310
454
  clone_path = _repository_with_change(tmp_path, "page.html")
311
455
  subprocess.run(["git", "-C", str(clone_path), "remote", "set-head", "origin", "-d"], check=True)
@@ -85,7 +85,17 @@ ALL_DEFAULT_BRANCH_REFERENCES = ("origin/HEAD", "origin/main", "main")
85
85
  REMOTE_BRANCH_PREFIX = "origin/"
86
86
  ALL_HEAD_FLAGS = frozenset({"--head", "-H"})
87
87
  HEAD_FLAG_ASSIGNMENT_PREFIX = "--head="
88
+ ALL_BASE_FLAGS = frozenset({"--base", "-B"})
89
+ BASE_FLAG_ASSIGNMENT_PREFIX = "--base="
90
+ ALL_REPOSITORY_FLAGS = frozenset({"--repo", "-R"})
91
+ REPOSITORY_FLAG_ASSIGNMENT_PREFIX = "--repo="
88
92
  GIT_TIMEOUT_SECONDS = 5
93
+ GH_TIMEOUT_SECONDS = 15
94
+ COMPARE_ENDPOINT_TEMPLATE = "repos/{repository}/compare/{base_branch}...{head_branch}"
95
+ COMPARE_FILE_NAMES_QUERY = ".files[].filename"
96
+ COMPARE_FILE_LIST_LIMIT = 300
97
+ REPOSITORY_ENDPOINT_TEMPLATE = "repos/{repository}"
98
+ DEFAULT_BRANCH_QUERY = ".default_branch"
89
99
  MISSING_LOOK_REASON = (
90
100
  "This pull request changes files people see ({changed_files}), and its 'Proof in"
91
101
  " practice' section shows no picture. Render the change, open the screenshot with the"
@@ -96,11 +106,14 @@ MISSING_LOOK_REASON = (
96
106
  )
97
107
  CHANGED_FILES_SEPARATOR = ", "
98
108
  UNREADABLE_CHANGES_REASON = (
99
- "Git in this directory cannot list the files this pull request changes, so the"
100
- " picture check cannot tell whether people see the change. Put a screenshot or"
101
- " Artifact link in the 'Proof in practice' section and run the create call again,"
102
- " or run it from a checkout of the head branch with origin fetched. Take one of"
103
- " these routes now, before any change to this gate. Read " + PROOF_GUIDE_PATH + " for the full rule."
109
+ "Neither git in this directory nor the GitHub compare API can list the files this"
110
+ " pull request changes, so the picture check cannot tell whether people see the change."
111
+ " Name the repository and head branch with --repo and --head, run the create call"
112
+ " from a checkout of the head branch with origin fetched, or put a screenshot or"
113
+ " Artifact link in the 'Proof in practice' section and run the create call again."
114
+ " Take one of these routes now, before any change to this gate. Read "
115
+ + PROOF_GUIDE_PATH
116
+ + " for the full rule."
104
117
  )
105
118
  FEATURE_TITLE_PATTERN = r"^\s*feat(\([^)]*\))?!?:"
106
119
  EVAL_HEADING_PATTERN = r"^(#{1,6})[ \t]+evals?[ \t]*#*[ \t]*$"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.83.2",
3
+ "version": "8.84.1",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -315,6 +315,7 @@ def read_codex_account_meters(account: Account) -> Meters:
315
315
  short_window.resets_at if short_window else None,
316
316
  FULL_PERCENT - weekly_window.used_percent if weekly_window else None,
317
317
  weekly_window.resets_at if weekly_window else None,
318
+ usage.plan_type,
318
319
  )
319
320
 
320
321
 
@@ -500,6 +501,7 @@ def _meter_payload(meters: Meters | None) -> dict[str, object] | None:
500
501
  "session_resets_at": utc_time_text(meters.session_resets_at),
501
502
  "weekly_percent_left": meters.weekly_percent_left,
502
503
  "weekly_resets_at": utc_time_text(meters.weekly_resets_at),
504
+ "plan_type": meters.plan_type,
503
505
  }
504
506
 
505
507
 
@@ -509,7 +511,14 @@ def _meters_from_payload(raw_meters: object) -> Meters | None:
509
511
  all_percentages = (raw_meters.get("session_percent_left"), raw_meters.get("weekly_percent_left"))
510
512
  if any(each_percent is not None and not isinstance(each_percent, (int, float)) for each_percent in all_percentages):
511
513
  return None
512
- return Meters(all_percentages[0], parse_utc_time(raw_meters.get("session_resets_at")), all_percentages[1], parse_utc_time(raw_meters.get("weekly_resets_at")))
514
+ plan_type = raw_meters.get("plan_type")
515
+ return Meters(
516
+ all_percentages[0],
517
+ parse_utc_time(raw_meters.get("session_resets_at")),
518
+ all_percentages[1],
519
+ parse_utc_time(raw_meters.get("weekly_resets_at")),
520
+ plan_type if isinstance(plan_type, str) else None,
521
+ )
513
522
 
514
523
 
515
524
  def _read_one_account(account: Account, adapter: ProductAdapter, all_cache: dict[str, object], now: datetime) -> tuple[Reading, bool]:
@@ -4,10 +4,13 @@
4
4
  Codex reports usage through ``codex app-server`` and the JSON-RPC method
5
5
  ``account/rateLimits/read``. The reply names up to two windows, a short one
6
6
  (5 hours) and a weekly one. The account broker counts the account's room as
7
- the smaller of the two::
7
+ the smaller of the two. The reply also names the account's plan, such as
8
+ ``pro`` or ``team``::
8
9
 
9
10
  5-hour window 30% used, week 85% used -> 15% left
10
11
  week window only, 40% used -> 60% left
12
+ planType "promax" -> plan_type "promax"
13
+ no planType -> plan_type None
11
14
  no window, error reply, no reply -> CodexMeterUnreadError
12
15
 
13
16
  Every read failure raises ``CodexMeterUnreadError``. A missing Codex
@@ -92,9 +95,10 @@ class UsageWindow:
92
95
 
93
96
  @dataclass(frozen=True)
94
97
  class CodexAccountMeters:
95
- """One account's windows, with its tightest short window and its tightest weekly window."""
98
+ """One account's windows and plan, with its tightest short window and its tightest weekly window."""
96
99
 
97
100
  all_windows: tuple[UsageWindow, ...]
101
+ plan_type: str | None = None
98
102
 
99
103
  @property
100
104
  def short_window(self) -> UsageWindow | None:
@@ -176,14 +180,16 @@ def parse_rate_limits_reply(reply_text: str) -> CodexAccountMeters:
176
180
  ::
177
181
 
178
182
  {"id": 2, "result": {"rateLimits": {"primary": {"usedPercent": 30, ...},
179
- "secondary": {"usedPercent": 85, ...}}}}
180
- -> CodexAccountMeters with two windows, 15% left
183
+ "secondary": {"usedPercent": 85, ...},
184
+ "planType": "promax"}}}
185
+ -> CodexAccountMeters with two windows, 15% left, plan_type "promax"
186
+ the same reply without "planType" -> plan_type None
181
187
 
182
188
  Args:
183
189
  reply_text: One line the ``codex app-server`` process wrote.
184
190
 
185
191
  Returns:
186
- The windows the reply carries.
192
+ The windows and the plan type the reply carries.
187
193
 
188
194
  Raises:
189
195
  CodexMeterUnreadError: The line is no reply, an error reply, or names
@@ -197,7 +203,8 @@ def parse_rate_limits_reply(reply_text: str) -> CodexAccountMeters:
197
203
  )
198
204
  if not all_windows:
199
205
  raise CodexMeterUnreadError("rate-limit reply names no usage window")
200
- return CodexAccountMeters(all_windows=all_windows)
206
+ plan_type = rate_limits.get("planType")
207
+ return CodexAccountMeters(all_windows=all_windows, plan_type=plan_type if isinstance(plan_type, str) else None)
201
208
 
202
209
 
203
210
  def request_messages() -> list[dict[str, object]]:
@@ -18,6 +18,7 @@ is in place by the time any test module runs its top-level imports.
18
18
  from __future__ import annotations
19
19
 
20
20
  import importlib
21
+ import site
21
22
  import subprocess
22
23
  import sys
23
24
  from collections.abc import Callable
@@ -35,25 +36,33 @@ for each_import_directory in (
35
36
  if str(each_import_directory) not in sys.path:
36
37
  sys.path.insert(0, str(each_import_directory))
37
38
 
38
- _live_broker_state_resolver = importlib.import_module("account_broker_support").broker_state_path
39
+ from dev_env_scripts_constants.account_broker_constants import ALL_ACCOUNT_ENVIRONMENT_VARIABLES
39
40
 
40
41
 
41
42
  @pytest.fixture(autouse=True)
42
- def isolated_broker_state_path(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
43
- """Point every module's ``broker_state_path`` at a per-test file.
44
-
45
- ``account_broker`` binds ``broker_state_path`` by name from
46
- ``account_broker_support``, so each bound copy is replaced. A test that
47
- patches only one copy still keeps the other off the home directory.
43
+ def isolated_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
44
+ """Point the home directory at a per-test folder for every test here.
45
+
46
+ ``Path.home()`` reads ``HOME`` on POSIX and ``USERPROFILE`` on Windows, so
47
+ redirecting both keeps call-time home lookups, such as
48
+ ``broker_state_path``, off the user's home directory. The variables in
49
+ ``ALL_ACCOUNT_ENVIRONMENT_VARIABLES`` are cleared, so account roots fall
50
+ back to the per-test home and no developer roster applies. A value built
51
+ from ``Path.home()`` at import time keeps the user's home.
52
+ ``PYTHONUSERBASE`` stays on the user base captured before the redirect, so
53
+ a new interpreter still imports packages installed for this user.
48
54
 
49
55
  Returns:
50
- The isolated broker state path for this test.
56
+ The per-test home directory.
51
57
  """
52
- isolated_path = tmp_path / "account-broker" / "state.json"
53
- for each_module in list(sys.modules.values()):
54
- if getattr(each_module, "broker_state_path", None) is _live_broker_state_resolver:
55
- monkeypatch.setattr(each_module, "broker_state_path", lambda: isolated_path)
56
- return isolated_path
58
+ user_base = site.getuserbase()
59
+ isolated_home_directory = tmp_path / "home"
60
+ monkeypatch.setenv("PYTHONUSERBASE", user_base)
61
+ monkeypatch.setenv("HOME", str(isolated_home_directory))
62
+ monkeypatch.setenv("USERPROFILE", str(isolated_home_directory))
63
+ for each_account_variable in ALL_ACCOUNT_ENVIRONMENT_VARIABLES:
64
+ monkeypatch.delenv(each_account_variable, raising=False)
65
+ return isolated_home_directory
57
66
 
58
67
 
59
68
  def _link_directory(*, from_link: Path, to_target: Path) -> None:
@@ -16,11 +16,15 @@ from dev_env_scripts_constants.claude_account_constants import (
16
16
  FULL_PERCENT,
17
17
  MAIN_SESSION_USED_CEILING_PERCENT,
18
18
  MAIN_WEEKLY_USED_CEILING_PERCENT,
19
+ PROFILES_ROOT_ENVIRONMENT_VARIABLE,
19
20
  SECOND_SESSION_USED_CEILING_PERCENT,
20
21
  SECOND_WEEKLY_USED_CEILING_PERCENT,
21
22
  )
22
23
  from dev_env_scripts_constants.codex_account_constants import (
24
+ ALL_PRO_PLAN_TYPES,
25
+ CODEX_ACCOUNT_PROFILES_ENVIRONMENT_VARIABLE,
23
26
  CODEX_HOME_ENVIRONMENT_VARIABLE,
27
+ CODEX_PROFILES_ROOT_ENVIRONMENT_VARIABLE,
24
28
  LUNA_TIER_SHORT_WINDOW_MINIMUM_PERCENT_LEFT,
25
29
  LUNA_TIER_STOP_PERCENT_LEFT,
26
30
  MAIN_CODEX_HOME_DIRECTORY_NAME,
@@ -29,6 +33,7 @@ from dev_env_scripts_constants.codex_account_constants import (
29
33
  TIER_NORMAL,
30
34
  )
31
35
  from dev_env_scripts_constants.shared_tree_constants import (
36
+ CLAUDE_CONFIG_DIR_ENV_VAR,
32
37
  CODEX_CLASSIFIER_CONSTANTS_RELATIVE_PATH,
33
38
  CONSTANTS_PACKAGE_ANCHOR_DEPTH,
34
39
  PR_LOOP_DIRECTORY_NAME,
@@ -63,6 +68,14 @@ BROKER_STATE_DIRECTORY_NAME = "account-broker"
63
68
  BROKER_STATE_FILE_NAME = "state.json"
64
69
  BROKER_STATE_TEMP_SUFFIX = ".json"
65
70
  BROKER_STATE_LOCK_SUFFIX = ".lock"
71
+ ALL_ACCOUNT_ENVIRONMENT_VARIABLES = (
72
+ CLAUDE_CONFIG_DIR_ENV_VAR,
73
+ CODEX_HOME_ENVIRONMENT_VARIABLE,
74
+ CODEX_ACCOUNT_PROFILES_ENVIRONMENT_VARIABLE,
75
+ CODEX_PROFILES_ROOT_ENVIRONMENT_VARIABLE,
76
+ PROFILES_ROOT_ENVIRONMENT_VARIABLE,
77
+ )
78
+ """Variables that point the account tools at a developer's own account roots, Codex home and Codex roster."""
66
79
  ALL_BATCH_FILE_EXTENSIONS = frozenset({".bat", ".cmd"})
67
80
  CMD_SHELL_METACHARACTERS = "&|<>^%!\"\r\n"
68
81
  CLAUDE_BINARY_NAME = "claude"
@@ -193,6 +206,7 @@ class Meters:
193
206
  session_resets_at: datetime | None
194
207
  weekly_percent_left: float | None
195
208
  weekly_resets_at: datetime | None
209
+ plan_type: str | None = None
196
210
 
197
211
  @property
198
212
  def tightest_percent_left(self) -> float | None:
@@ -313,10 +327,11 @@ def _account_reset(reading: Reading, is_spent: bool = False) -> datetime | None:
313
327
  return max(all_known_resets, default=None)
314
328
 
315
329
 
316
- def _rank_key(reading: Reading) -> tuple[bool, int, float]:
330
+ def _rank_key(reading: Reading) -> tuple[bool, int, bool, float]:
317
331
  room = reading.meters.tightest_percent_left if reading.meters else None
318
332
  priority = reading.account.priority
319
- return (priority is None, priority or 0, -(room if room is not None else -1.0))
333
+ is_pro_plan = reading.meters is not None and reading.meters.plan_type in ALL_PRO_PLAN_TYPES
334
+ return (priority is None, priority or 0, not is_pro_plan, -(room if room is not None else -1.0))
320
335
 
321
336
 
322
337
  def _main_has_room(meters: Meters | None) -> bool:
@@ -99,6 +99,9 @@ LUNA_TIER_STOP_PERCENT_LEFT: float = 1.0
99
99
  LUNA_TIER_SHORT_WINDOW_MINIMUM_PERCENT_LEFT: float = 20.0
100
100
  """An account with a window shorter than a week runs Luna only with this much of it left."""
101
101
 
102
+ ALL_PRO_PLAN_TYPES: frozenset[str] = frozenset({"pro", "prolite", "promax"})
103
+ """Plan types of a personal Codex Pro subscription. The broker tries these accounts first."""
104
+
102
105
  WEEKLY_WINDOW_MINUTES: int = 10080
103
106
  """Length of the weekly window. Any shorter window is a short window, such as 5 hours."""
104
107
 
@@ -15,7 +15,9 @@ from pathlib import Path
15
15
  import pytest
16
16
 
17
17
  from dev_env_scripts_constants import account_broker_constants as broker_constants
18
+ from dev_env_scripts_constants import claude_account_constants, codex_account_constants, shared_tree_constants
18
19
  from dev_env_scripts_constants.account_broker_constants import (
20
+ ALL_ACCOUNT_ENVIRONMENT_VARIABLES,
19
21
  ALL_BATCH_FILE_EXTENSIONS,
20
22
  ALL_CLAUDE_FLOORS,
21
23
  ALL_CODEX_FLOORS,
@@ -57,6 +59,16 @@ def test_should_keep_codex_floors_tied_to_the_tier_limits() -> None:
57
59
  }
58
60
 
59
61
 
62
+ def test_should_list_every_account_environment_variable() -> None:
63
+ all_declared_variables = {
64
+ each_value
65
+ for each_module in (claude_account_constants, codex_account_constants, shared_tree_constants)
66
+ for each_name, each_value in vars(each_module).items()
67
+ if each_name.endswith(("_ENVIRONMENT_VARIABLE", "_ENV_VAR"))
68
+ }
69
+ assert all_declared_variables == set(ALL_ACCOUNT_ENVIRONMENT_VARIABLES)
70
+
71
+
60
72
  def test_batch_command_boundaries_include_shell_metacharacters() -> None:
61
73
  assert ALL_BATCH_FILE_EXTENSIONS == frozenset({".bat", ".cmd"})
62
74
  assert set(CMD_SHELL_METACHARACTERS) == set('&|<>^%!"\r\n')
@@ -86,9 +86,14 @@ def test_tier_bars_match_the_documented_table() -> None:
86
86
  normal_bar = documented_percent(constants.NORMAL_TIER_MINIMUM_PERCENT_LEFT)
87
87
  luna_stop = documented_percent(constants.LUNA_TIER_STOP_PERCENT_LEFT)
88
88
  luna_short_window_floor = documented_percent(constants.LUNA_TIER_SHORT_WINDOW_MINIMUM_PERCENT_LEFT)
89
+ pro_plans = documented_list(sorted(constants.ALL_PRO_PLAN_TYPES))
90
+ pro_preference = (
91
+ f"on a Pro plan account ({pro_plans}) before any other account in this tier; "
92
+ "among accounts of the same kind, the one with the most room"
93
+ )
89
94
  all_expected_rows = [
90
- f"| One or more accounts with more than {normal_bar} left | `{constants.TIER_NORMAL}` on the account with the most room |",
91
- f"| No account over {normal_bar}, one or more over {luna_stop} | `{constants.TIER_LUNA}` on the account with the most room |",
95
+ f"| One or more accounts with more than {normal_bar} left | `{constants.TIER_NORMAL}` {pro_preference} |",
96
+ f"| No account over {normal_bar}, one or more over {luna_stop} | `{constants.TIER_LUNA}` {pro_preference} |",
92
97
  f"| An account that reports a window shorter than a week, such as the 5-hour window, with under {luna_short_window_floor} of that window left | never takes `{constants.TIER_LUNA}` |",
93
98
  f"| No account over {luna_stop} | `{constants.TIER_WAIT}`, naming the next reset when known |",
94
99
  ]
@@ -42,12 +42,6 @@ def _freeze_clock(monkeypatch: pytest.MonkeyPatch, instant: datetime) -> None:
42
42
  monkeypatch.setattr(account_broker, "datetime", _FrozenClock)
43
43
 
44
44
 
45
- @pytest.fixture(autouse=True)
46
- def isolated_state(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
47
- monkeypatch.setattr(account_broker, "broker_state_path", lambda: tmp_path / "broker" / "state.json")
48
- monkeypatch.setattr(account_broker.support, "broker_state_path", lambda: tmp_path / "broker" / "state.json")
49
-
50
-
51
45
  def _account(name: str, product: Product = Product.CODEX, main: bool = False) -> Account:
52
46
  return Account(product, name, Path("/profiles") / name, main, name)
53
47
 
@@ -58,12 +52,14 @@ def _meters(
58
52
  *,
59
53
  short_reset: datetime | None = None,
60
54
  weekly_reset: datetime | None = None,
55
+ plan_type: str | None = None,
61
56
  ) -> Meters:
62
57
  return Meters(
63
58
  short_left,
64
59
  short_reset or NOW + timedelta(hours=5),
65
60
  weekly_left,
66
61
  weekly_reset or NOW + timedelta(days=3),
62
+ plan_type,
67
63
  )
68
64
 
69
65
 
@@ -125,6 +121,30 @@ def test_should_put_unread_accounts_after_readable_accounts() -> None:
125
121
  assert decision.account == readings[1].account
126
122
 
127
123
 
124
+ def test_should_pick_a_pro_plan_account_over_a_roomier_team_account() -> None:
125
+ readings = (
126
+ Reading(_account("team-mel"), _meters(90, 85, plan_type="team")),
127
+ Reading(_account("personal"), _meters(40, 30, plan_type="promax")),
128
+ )
129
+
130
+ decision = choose_from_readings(Product.CODEX, readings, now=NOW)
131
+
132
+ assert decision.account == readings[1].account
133
+ assert decision.tier == "normal"
134
+
135
+
136
+ def test_should_pick_a_team_account_in_the_normal_tier_over_a_pro_plan_account_below_the_floor() -> None:
137
+ readings = (
138
+ Reading(_account("personal"), _meters(50, 8, plan_type="pro")),
139
+ Reading(_account("team-jon"), _meters(40, 30, plan_type="team")),
140
+ )
141
+
142
+ decision = choose_from_readings(Product.CODEX, readings, now=NOW)
143
+
144
+ assert decision.account == readings[1].account
145
+ assert decision.tier == "normal"
146
+
147
+
128
148
  def test_should_keep_list_order_when_tighter_windows_tie() -> None:
129
149
  readings = (
130
150
  Reading(_account("first"), _meters(40, 60)),
@@ -192,7 +192,8 @@ def test_should_read_codex_meters_through_injected_reader(
192
192
  UsageWindow(300, 20.0, short_reset),
193
193
  UsageWindow(10080, 30.0, None),
194
194
  UsageWindow(None, 50.0, unknown_length_reset),
195
- )
195
+ ),
196
+ plan_type="promax",
196
197
  )
197
198
  monkeypatch.setattr(support.codex_account_meters, "resolve_codex_path", lambda _: tmp_path / "codex")
198
199
  monkeypatch.setattr(support.codex_account_meters, "read_codex_meters", lambda *_: usage)
@@ -203,6 +204,19 @@ def test_should_read_codex_meters_through_injected_reader(
203
204
  assert meters.session_resets_at == short_reset
204
205
  assert meters.weekly_percent_left == 50.0
205
206
  assert meters.weekly_resets_at == unknown_length_reset
207
+ assert meters.plan_type == "promax"
208
+
209
+
210
+ def test_should_keep_the_plan_through_the_cached_meter_payload() -> None:
211
+ meters = Meters(80.0, None, 70.0, None, "pro")
212
+
213
+ assert support._meters_from_payload(support._meter_payload(meters)) == meters
214
+
215
+
216
+ def test_should_load_a_cached_meter_payload_saved_without_a_plan() -> None:
217
+ old_payload = {"session_percent_left": 80.0, "session_resets_at": None, "weekly_percent_left": 70.0, "weekly_resets_at": None}
218
+
219
+ assert support._meters_from_payload(old_payload) == Meters(80.0, None, 70.0, None, None)
206
220
 
207
221
 
208
222
  def _codex_meter_reads(
@@ -242,8 +256,7 @@ def _recorded_sleeps(monkeypatch: pytest.MonkeyPatch) -> list[float]:
242
256
  return all_delays
243
257
 
244
258
 
245
- def _first_reading(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, account: Account, read_meters: object) -> object:
246
- monkeypatch.setattr(support, "broker_state_path", lambda: tmp_path / "broker" / "state.json")
259
+ def _first_reading(account: Account, read_meters: object) -> object:
247
260
  adapter = ProductAdapter(lambda: (account,), read_meters, "HOME", ())
248
261
  state = support._load_state(support.broker_state_path())
249
262
  now = datetime(2026, 10, 3, tzinfo=timezone.utc)
@@ -258,7 +271,7 @@ def test_should_read_claude_meters_after_transient_failures_with_growing_delays(
258
271
  all_paths_read = _claude_meter_reads(monkeypatch, [unread, unread, usage])
259
272
  all_delays = _recorded_sleeps(monkeypatch)
260
273
 
261
- reading = _first_reading(monkeypatch, tmp_path, Account(Product.CLAUDE, "org-jon", tmp_path), support.read_claude_meters)[0]
274
+ reading = _first_reading(Account(Product.CLAUDE, "org-jon", tmp_path), support.read_claude_meters)[0]
262
275
 
263
276
  assert reading.meters is not None
264
277
  assert reading.meters.weekly_percent_left == 70.0
@@ -275,7 +288,7 @@ def test_should_read_codex_meters_after_transient_failures_with_growing_delays(
275
288
  all_homes_read = _codex_meter_reads(monkeypatch, tmp_path, [unread, unread, usage])
276
289
  all_delays = _recorded_sleeps(monkeypatch)
277
290
 
278
- reading = _first_reading(monkeypatch, tmp_path, Account(Product.CODEX, "one", tmp_path), support.read_codex_account_meters)[0]
291
+ reading = _first_reading(Account(Product.CODEX, "one", tmp_path), support.read_codex_account_meters)[0]
279
292
 
280
293
  assert reading.meters is not None
281
294
  assert reading.meters.weekly_percent_left == 70.0
@@ -293,7 +306,7 @@ def test_should_stop_at_once_when_no_codex_executable_is_found(
293
306
  monkeypatch.setattr(Path, "home", lambda: tmp_path)
294
307
  all_delays = _recorded_sleeps(monkeypatch)
295
308
 
296
- reading = _first_reading(monkeypatch, tmp_path, Account(Product.CODEX, "one", tmp_path), support.read_codex_account_meters)[0]
309
+ reading = _first_reading(Account(Product.CODEX, "one", tmp_path), support.read_codex_account_meters)[0]
297
310
 
298
311
  assert reading.meters is None
299
312
  assert reading.unread_reason == "no codex executable on PATH or at its install path"
@@ -308,7 +321,7 @@ def test_should_keep_the_unread_reason_after_every_claude_read_fails(
308
321
  all_paths_read = _claude_meter_reads(monkeypatch, all_failures)
309
322
  all_delays = _recorded_sleeps(monkeypatch)
310
323
 
311
- reading, adapter, state, now = _first_reading(monkeypatch, tmp_path, Account(Product.CLAUDE, "org-jon", tmp_path), support.read_claude_meters)
324
+ reading, adapter, state, now = _first_reading(Account(Product.CLAUDE, "org-jon", tmp_path), support.read_claude_meters)
312
325
  cached_reading = support.read_accounts(Product.CLAUDE, adapter, all_state=state, now=now)[0]
313
326
 
314
327
  assert reading.meters is None
@@ -326,7 +339,7 @@ def test_should_keep_the_unread_reason_after_every_codex_read_fails(
326
339
  all_homes_read = _codex_meter_reads(monkeypatch, tmp_path, [first_unread, first_unread, last_unread])
327
340
  _recorded_sleeps(monkeypatch)
328
341
 
329
- reading, adapter, state, now = _first_reading(monkeypatch, tmp_path, Account(Product.CODEX, "codex-3", tmp_path), support.read_codex_account_meters)
342
+ reading, adapter, state, now = _first_reading(Account(Product.CODEX, "codex-3", tmp_path), support.read_codex_account_meters)
330
343
  cached_reading = support.read_accounts(Product.CODEX, adapter, all_state=state, now=now)[0]
331
344
 
332
345
  assert reading.meters is None
@@ -1,4 +1,4 @@
1
- """Broker state written during a test stays out of the home directory."""
1
+ """Broker state written during a test lands in the per-test home directory."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -18,14 +18,11 @@ from dev_env_scripts_constants.account_broker_constants import (
18
18
  )
19
19
 
20
20
 
21
- def test_meter_cache_write_lands_outside_the_home_directory(
21
+ def test_meter_cache_write_lands_in_the_isolated_home(
22
22
  monkeypatch: pytest.MonkeyPatch,
23
23
  tmp_path: Path,
24
- isolated_broker_state_path: Path,
24
+ isolated_home: Path,
25
25
  ) -> None:
26
- fake_home = tmp_path / "home"
27
- monkeypatch.setenv("HOME", str(fake_home))
28
- monkeypatch.setenv("USERPROFILE", str(fake_home))
29
26
  account = Account(Product.CLAUDE, "only", tmp_path / "only")
30
27
  reset_at = datetime(2026, 10, 5, tzinfo=timezone.utc)
31
28
  adapter = ProductAdapter(
@@ -39,10 +36,12 @@ def test_meter_cache_write_lands_outside_the_home_directory(
39
36
  def runner(argv: list[str], **options: object) -> subprocess.CompletedProcess[str]:
40
37
  return subprocess.CompletedProcess(argv, 0, "", "")
41
38
 
39
+ isolated_broker_state_path = account_broker.broker_state_path()
40
+ assert Path.home() == isolated_home
41
+ assert isolated_broker_state_path.is_relative_to(isolated_home)
42
+ assert not isolated_broker_state_path.exists()
42
43
  with account_broker.override_subprocess_runner(runner):
43
44
  account_broker.run_job(Product.CLAUDE, ["claude"])
44
45
 
45
- home_state_path = fake_home / ".claude" / "account-broker" / "state.json"
46
- assert not home_state_path.exists()
47
46
  state = json.loads(isolated_broker_state_path.read_text(encoding="utf-8"))
48
47
  assert f"claude:only:{account.home}" in state["meters"]
@@ -65,6 +65,16 @@ class TestParseRateLimitsReply:
65
65
  )
66
66
  assert [each.used_percent for each in meters.all_windows] == [40.0]
67
67
 
68
+ def should_read_the_plan_type_beside_the_windows(self) -> None:
69
+ meters = parse_rate_limits_reply(
70
+ reply_line({"primary": {"usedPercent": 40}, "planType": "promax"})
71
+ )
72
+ assert meters.plan_type == "promax"
73
+
74
+ def should_read_a_reply_without_a_plan_type(self) -> None:
75
+ meters = parse_rate_limits_reply(reply_line({"primary": {"usedPercent": 40}}))
76
+ assert meters.plan_type is None
77
+
68
78
  def should_clamp_used_percent_past_full(self) -> None:
69
79
  meters = parse_rate_limits_reply(reply_line({"primary": {"usedPercent": 130}}))
70
80
  assert [each.used_percent for each in meters.all_windows] == [100.0]
@@ -0,0 +1,54 @@
1
+ import os
2
+ import subprocess
3
+ import sys
4
+ from collections.abc import Iterator
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ import account_broker_support
10
+ from dev_env_scripts_constants.account_broker_constants import ALL_ACCOUNT_ENVIRONMENT_VARIABLES
11
+
12
+
13
+ @pytest.fixture(scope="module")
14
+ def exported_account_variables(tmp_path_factory: pytest.TempPathFactory) -> Iterator[None]:
15
+ exported_root = tmp_path_factory.mktemp("exported-overrides")
16
+ with pytest.MonkeyPatch.context() as module_monkeypatch:
17
+ for each_account_variable in ALL_ACCOUNT_ENVIRONMENT_VARIABLES:
18
+ module_monkeypatch.setenv(each_account_variable, str(exported_root / each_account_variable))
19
+ yield
20
+
21
+
22
+ def test_broker_state_path_follows_the_redirected_home(
23
+ isolated_home: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
24
+ ) -> None:
25
+ assert Path.home() == isolated_home
26
+ assert account_broker_support.broker_state_path().is_relative_to(isolated_home)
27
+ moved_home = tmp_path / "moved-home"
28
+ monkeypatch.setenv("HOME", str(moved_home))
29
+ monkeypatch.setenv("USERPROFILE", str(moved_home))
30
+ assert account_broker_support.broker_state_path().is_relative_to(moved_home)
31
+
32
+
33
+ def test_isolated_home_clears_the_exported_account_variables(
34
+ exported_account_variables: None, isolated_home: Path
35
+ ) -> None:
36
+ for each_account_variable in ALL_ACCOUNT_ENVIRONMENT_VARIABLES:
37
+ assert each_account_variable not in os.environ
38
+
39
+
40
+ def test_isolated_home_keeps_the_python_user_base_for_a_new_interpreter(
41
+ isolated_home: Path,
42
+ ) -> None:
43
+ completed = subprocess.run(
44
+ [sys.executable, "-c", "import site; print(site.getuserbase())"],
45
+ check=False,
46
+ capture_output=True,
47
+ text=True,
48
+ )
49
+
50
+ assert completed.returncode == 0
51
+ child_user_base = Path(completed.stdout.strip())
52
+ assert str(child_user_base) == os.environ["PYTHONUSERBASE"]
53
+ assert child_user_base != isolated_home
54
+ assert isolated_home not in child_user_base.parents