claude-dev-env 8.38.3 → 8.39.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.
@@ -29,10 +29,10 @@ The frozen process env is only a fallback when that disk read fails (logged once
29
29
  A probe error does not waive the gate; the live GitHub checks still run.
30
30
 
31
31
  The Codex gate is conditional-required: it demands
32
- ``codex_clean_at == current_head`` only when the Codex account picker
33
- (``scripts/codex_account_choice.py choose``) answers the ``normal`` tier, so
32
+ ``codex_clean_at == current_head`` only when the account broker
33
+ (``scripts/account_broker.py choose --product codex``) answers the ``normal`` tier, so
34
34
  one signed-in account has room for a review. A ``luna`` or ``wait`` answer, an
35
- unreadable picker, the codex token, or ``codex_down`` never blocks ready.
35
+ unreadable broker, the codex token, or ``codex_down`` never blocks ready.
36
36
  """
37
37
 
38
38
  from __future__ import annotations
@@ -64,11 +64,12 @@ from check_convergence_thread_gates import (
64
64
  _count_unresolved_bot_threads,
65
65
  )
66
66
  from pr_converge_scripts_constants.convergence_gate_constants import (
67
- ALL_CODEX_ACCOUNT_PICKER_RELATIVE_PARTS,
67
+ ALL_CODEX_ACCOUNT_BROKER_RELATIVE_PARTS,
68
+ ALL_CODEX_BROKER_ACCEPTED_EXIT_CODES,
68
69
  BUGBOT_DOWN_BYPASS_NOTE,
69
70
  CLAUDE_JOB_DIR_ENV_VAR_NAME,
70
71
  CODEX_ACCOUNT_PICK_TIMEOUT_SECONDS,
71
- CODEX_ACCOUNT_PICKER_CHOOSE_COMMAND,
72
+ CODEX_ACCOUNT_BROKER_CHOOSE_COMMAND,
72
73
  CODEX_BYPASS_DETAIL,
73
74
  CODEX_CLEAN_AT_STATE_KEY,
74
75
  CODEX_CLEAN_DETAIL_TEMPLATE,
@@ -344,16 +345,16 @@ def _evaluate_codex_clean(
344
345
 
345
346
  ::
346
347
 
347
- clean stamp on head -> pass (picker never runs)
348
+ clean stamp on head -> pass (broker never runs)
348
349
  no stamp, tier luna, wait, or None -> skip (never blocks)
349
350
  no stamp, tier normal -> fail
350
351
 
351
- A stamp on the current HEAD settles the gate on its own, so the picker
352
- stays behind it: the picker starts one Codex app server per account, and
352
+ A stamp on the current HEAD settles the gate on its own, so the broker
353
+ stays behind it: the broker starts one Codex app server per account, and
353
354
  the common already-clean tick has no reason to pay for them.
354
355
 
355
356
  Args:
356
- read_codex_tier: Returns the picker's tier, or None when the picker cannot say.
357
+ read_codex_tier: Returns the broker's tier, or None when the broker cannot say.
357
358
  codex_clean_at: HEAD SHA where Codex last reported clean, or None.
358
359
  head_sha: Current PR HEAD commit SHA.
359
360
 
@@ -367,20 +368,22 @@ def _evaluate_codex_clean(
367
368
  return False, CODEX_MISSING_CLEAN_DETAIL_TEMPLATE % _short_sha(head_sha)
368
369
 
369
370
 
370
- def _codex_account_picker_path() -> Path:
371
- """Return the installed Codex account picker beside this shared tree."""
371
+ def _codex_account_broker_path() -> Path:
372
+ """Return the installed account broker beside this shared tree."""
372
373
  shared_root = Path(__file__).resolve().parents[SHARED_PACKAGE_ROOT_PARENT_INDEX]
373
- return shared_root.joinpath(*ALL_CODEX_ACCOUNT_PICKER_RELATIVE_PARTS)
374
+ return shared_root.joinpath(*ALL_CODEX_ACCOUNT_BROKER_RELATIVE_PARTS)
374
375
 
375
376
 
376
377
  def _read_codex_tier() -> str | None:
377
- """Ask the Codex account picker which tier a review runs on, or None when it cannot say."""
378
+ """Ask the account broker which tier a review runs on, or None when it cannot say."""
378
379
  try:
379
380
  completed = subprocess.run(
380
381
  [
381
382
  sys.executable,
382
- str(_codex_account_picker_path()),
383
- CODEX_ACCOUNT_PICKER_CHOOSE_COMMAND,
383
+ str(_codex_account_broker_path()),
384
+ CODEX_ACCOUNT_BROKER_CHOOSE_COMMAND,
385
+ "--product",
386
+ "codex",
384
387
  ],
385
388
  capture_output=True,
386
389
  text=True,
@@ -390,9 +393,12 @@ def _read_codex_tier() -> str | None:
390
393
  answer = json.loads(completed.stdout)
391
394
  except (OSError, subprocess.SubprocessError, json.JSONDecodeError):
392
395
  return None
393
- if completed.returncode != 0 or not isinstance(answer, dict):
396
+ if completed.returncode not in ALL_CODEX_BROKER_ACCEPTED_EXIT_CODES or not isinstance(answer, dict):
394
397
  return None
395
- tier = answer.get(CODEX_TIER_KEY)
398
+ decision = answer.get("decision")
399
+ if not isinstance(decision, dict):
400
+ return None
401
+ tier = decision.get(CODEX_TIER_KEY)
396
402
  return tier if isinstance(tier, str) else None
397
403
 
398
404
 
@@ -69,11 +69,12 @@ CODEX_CLEAN_AT_STATE_KEY: str = "codex_clean_at"
69
69
  CODEX_DOWN_STATE_KEY: str = "codex_down"
70
70
  MINIMUM_ABBREVIATED_SHA_LENGTH: int = 7
71
71
  SHARED_PACKAGE_ROOT_PARENT_INDEX: int = 3
72
- ALL_CODEX_ACCOUNT_PICKER_RELATIVE_PARTS: tuple[str, ...] = (
72
+ ALL_CODEX_ACCOUNT_BROKER_RELATIVE_PARTS: tuple[str, ...] = (
73
73
  "scripts",
74
- "codex_account_choice.py",
74
+ "account_broker.py",
75
75
  )
76
- CODEX_ACCOUNT_PICKER_CHOOSE_COMMAND: str = "choose"
76
+ CODEX_ACCOUNT_BROKER_CHOOSE_COMMAND: str = "choose"
77
77
  CODEX_ACCOUNT_PICK_TIMEOUT_SECONDS: float = 150.0
78
78
  CODEX_TIER_KEY: str = "tier"
79
+ ALL_CODEX_BROKER_ACCEPTED_EXIT_CODES: tuple[int, ...] = (0, 3)
79
80
  CODEX_TIER_NORMAL: str = "normal"
@@ -1,7 +1,7 @@
1
1
  """Fixture-driven tests for the conditional Codex convergence gate.
2
2
 
3
3
  Covers: required-and-clean, required-and-dirty, skipped-by-tier,
4
- skipped-by-token, skipped-by-down, and the read of the Codex account picker's
4
+ skipped-by-token, skipped-by-down, and the read of the account broker's
5
5
  ``choose`` answer. The required tier comes from ``CODEX_TIER_NORMAL``.
6
6
  """
7
7
 
@@ -117,15 +117,15 @@ def should_pass_when_codex_required_and_clean(
117
117
  assert "All pre-conditions met" in captured
118
118
 
119
119
 
120
- def should_pass_without_running_the_picker_when_the_clean_stamp_is_on_head(
120
+ def test_should_pass_without_running_the_broker_when_the_clean_stamp_is_on_head(
121
121
  monkeypatch: pytest.MonkeyPatch,
122
122
  ) -> None:
123
- def _picker_that_must_not_run() -> str | None:
123
+ def _broker_that_must_not_run() -> str | None:
124
124
  raise AssertionError(
125
- "the Codex account picker must not run when codex_clean_at is on HEAD"
125
+ "the account broker must not run when codex_clean_at is on HEAD"
126
126
  )
127
127
 
128
- monkeypatch.setattr(check_convergence, "_read_codex_tier", _picker_that_must_not_run)
128
+ monkeypatch.setattr(check_convergence, "_read_codex_tier", _broker_that_must_not_run)
129
129
 
130
130
  is_passed, detail = check_convergence._evaluate_codex_clean(
131
131
  read_codex_tier=check_convergence._read_codex_tier,
@@ -228,7 +228,7 @@ def should_fail_when_codex_required_and_dirty(
228
228
 
229
229
 
230
230
  @pytest.mark.parametrize("tier_without_room", ALL_TIERS_WITHOUT_ROOM_FOR_REVIEW)
231
- def should_skip_when_the_picker_names_no_account_with_room_for_review(
231
+ def test_should_skip_when_the_broker_names_no_account_with_room_for_review(
232
232
  tier_without_room: str, tmp_path: Path, capsys: pytest.CaptureFixture[str]
233
233
  ) -> None:
234
234
  fixture_path = _write_fixture(
@@ -513,47 +513,86 @@ def should_accept_codex_clean_at_flag_in_parsed_arguments() -> None:
513
513
  assert arguments.codex_clean_at == HEAD_SHA
514
514
 
515
515
 
516
- def _install_fake_picker(
517
- tmp_path: Path, monkeypatch: pytest.MonkeyPatch, picker_source: str
516
+ def _install_fake_broker(
517
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, broker_source: str
518
518
  ) -> None:
519
- picker_path = tmp_path / "codex_account_choice.py"
520
- picker_path.write_text(picker_source, encoding="utf-8")
521
- monkeypatch.setattr(check_convergence, "_codex_account_picker_path", lambda: picker_path)
519
+ broker_path = tmp_path / "account_broker.py"
520
+ broker_path.write_text(broker_source, encoding="utf-8")
521
+ monkeypatch.setattr(check_convergence, "_codex_account_broker_path", lambda: broker_path)
522
522
 
523
523
 
524
- def should_read_the_tier_the_picker_chooses(
524
+ def test_should_read_normal_tier_from_broker_decision(
525
525
  tmp_path: Path, monkeypatch: pytest.MonkeyPatch
526
526
  ) -> None:
527
- _install_fake_picker(
527
+ broker_path = tmp_path / "account_broker.py"
528
+ broker_path.write_text(
529
+ "import json, sys\n"
530
+ "assert sys.argv[1:] == ['choose', '--product', 'codex']\n"
531
+ "print(json.dumps({'decision': {'tier': 'normal', 'reason': 'room'}, 'accounts': []}))\n",
532
+ encoding="utf-8",
533
+ )
534
+ monkeypatch.setattr(check_convergence, "_codex_account_broker_path", lambda: broker_path)
535
+
536
+ assert check_convergence._read_codex_tier() == CODEX_TIER_NORMAL
537
+
538
+
539
+ def test_should_read_wait_tier_from_broker_exit_three(
540
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
541
+ ) -> None:
542
+ broker_path = tmp_path / "account_broker.py"
543
+ broker_path.write_text(
544
+ "import json, sys\n"
545
+ "assert sys.argv[1:] == ['choose', '--product', 'codex']\n"
546
+ "print(json.dumps({'decision': {'tier': 'wait', 'reason': 'no room'}, 'accounts': []}))\n"
547
+ "sys.exit(3)\n",
548
+ encoding="utf-8",
549
+ )
550
+ monkeypatch.setattr(check_convergence, "_codex_account_broker_path", lambda: broker_path)
551
+
552
+ assert check_convergence._read_codex_tier() == "wait"
553
+
554
+ passed, detail = check_convergence._evaluate_codex_clean(
555
+ read_codex_tier=check_convergence._read_codex_tier,
556
+ codex_clean_at=None,
557
+ head_sha=HEAD_SHA,
558
+ )
559
+ assert passed
560
+ assert "skipped" in detail
561
+
562
+
563
+ def test_should_read_the_tier_the_broker_chooses(
564
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
565
+ ) -> None:
566
+ _install_fake_broker(
528
567
  tmp_path,
529
568
  monkeypatch,
530
569
  "import json, sys\n"
531
- "assert sys.argv[1:] == ['choose']\n"
532
- "print(json.dumps({'tier': 'normal', 'account': 'codex-2'}))\n",
570
+ "assert sys.argv[1:] == ['choose', '--product', 'codex']\n"
571
+ "print(json.dumps({'decision': {'tier': 'normal', 'account': 'codex-2'}, 'accounts': []}))\n",
533
572
  )
534
573
 
535
574
  assert check_convergence._read_codex_tier() == CODEX_TIER_NORMAL
536
575
 
537
576
 
538
577
  @pytest.mark.parametrize(
539
- "picker_source",
578
+ "broker_source",
540
579
  [
541
- "import sys\nprint('{\"tier\": \"normal\"}')\nsys.exit(1)\n",
580
+ "import sys\nprint('{\"decision\": {\"tier\": \"normal\"}}')\nsys.exit(1)\n",
542
581
  "print('not json')\n",
543
582
  "print('[\"normal\"]')\n",
544
- "print('{\"tier\": 1}')\n",
583
+ "print('{\"decision\": {\"tier\": 1}}')\n",
545
584
  ],
546
585
  )
547
- def should_read_no_tier_when_the_picker_fails_or_answers_out_of_shape(
548
- picker_source: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
586
+ def test_should_read_no_tier_when_the_broker_fails_or_answers_out_of_shape(
587
+ broker_source: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
549
588
  ) -> None:
550
- _install_fake_picker(tmp_path, monkeypatch, picker_source)
589
+ _install_fake_broker(tmp_path, monkeypatch, broker_source)
551
590
 
552
591
  assert check_convergence._read_codex_tier() is None
553
592
 
554
593
 
555
- def should_locate_the_packaged_codex_account_picker() -> None:
556
- picker_path = check_convergence._codex_account_picker_path()
594
+ def test_should_locate_the_packaged_codex_account_broker() -> None:
595
+ broker_path = check_convergence._codex_account_broker_path()
557
596
 
558
- assert picker_path.name == "codex_account_choice.py"
559
- assert picker_path.is_file()
597
+ assert broker_path.name == "account_broker.py"
598
+ assert broker_path.is_file()
@@ -1,15 +1,16 @@
1
1
  # Codex accounts
2
2
 
3
- The codex_account_choice picker spreads agent work across the Codex accounts
4
- on one machine. Each account signs in under its own Codex home, and every home
3
+ The account broker assigns agent work across the Codex accounts.
4
+ Each account signs in under its own Codex home, and every home
5
5
  shares the same Codex setup: config, rules, skills, plugins, prompts and agents.
6
- A job asks the picker which account to use.
6
+ A job asks the broker which account to use.
7
7
 
8
8
  ## Pieces
9
9
 
10
10
  | File | What it does |
11
11
  |---|---|
12
- | `scripts/codex_account_choice.py` | `choose` names the account and tier a job runs on, `check` tells a running job whether its account is still above a floor, `sync` links the shared setup into every account's home, `setup` and `install` write one launcher per account |
12
+ | `scripts/account_broker.py` | `choose --product codex` names the account and tier a job runs on; `check --product codex` reports whether any account has room |
13
+ | `scripts/codex_account_choice.py` | `sync` links the shared setup into every account's home; `setup` and `install` write one launcher per account |
13
14
  | `scripts/codex_account_meters.py` | Reads one account's rate-limit windows through `codex app-server` with `CODEX_HOME` set to that account's home |
14
15
  | `scripts/dev_env_scripts_constants/codex_account_constants.py` | The fallback account names, the roster variable and file, the launcher template, the shared entry names, the 10% bar, the 1% Luna stop, and the 20% 5-hour floor for Luna |
15
16
 
@@ -21,8 +22,8 @@ The account roster names the accounts and their order. It comes from the
21
22
  in `account-launchers.json` under the profiles root. When neither exists, the
22
23
  accounts are `codex-1`, `codex-2`, `codex-3` and `codex-4`.
23
24
 
24
- `choose`, `sync` and `check` all work on that list, in that order. `check`
25
- accepts only a name on it. Each account's home is `~/.codex-profiles/<name>`, or
25
+ The broker's `choose` and `check` commands and the setup commands use that list.
26
+ Each account's home is `~/.codex-profiles/<name>`, or
26
27
  `<name>` under `CODEX_PROFILES_ROOT` when set. `~/.codex` holds the shared setup
27
28
  and is never an account. A name uses letters, digits, hyphens or underscores.
28
29
  `main`, `wait` and the Windows device names are refused.
@@ -83,50 +84,45 @@ folder under the profiles root stays, with its `auth.json`, so typing the name
83
84
  again restores it. A blank first line empties the roster and moves every launcher
84
85
  aside.
85
86
 
86
- `check <name>` reads a saved name the same way:
87
-
88
- ```
89
- python packages/claude-dev-env/scripts/codex_account_choice.py check alpha --floor 1
90
- ```
91
-
92
87
  ## Which account a job uses
93
88
 
94
89
  Room is the smaller of an account's two windows: the 5-hour window and the week.
95
90
 
96
91
  | Condition | Answer |
97
92
  |---|---|
98
- | First account in order with more than 10% left | `normal` on that account |
99
- | No account over 10%, one or more over 1% | `luna` on the account with the most room, `stop_below_percent` 1 |
93
+ | One or more accounts with more than 10% left | `normal` on the account with the most room |
94
+ | No account over 10%, one or more over 1% | `luna` on the account with the most room |
100
95
  | An account that reports a 5-hour window, with under 20% of that window left | never takes `luna` |
101
- | No account over 1% | `wait`, naming the account whose blocking windows reset first |
102
- | Account not signed in, or its meter unread | skipped, with the reason in `accounts` |
96
+ | No account over 1% | `wait`, naming the next reset when known |
97
+ | A meter is unread | The account has `meters: null` in `accounts` |
103
98
 
104
99
  ```
105
- python packages/claude-dev-env/scripts/codex_account_choice.py choose
106
- {"tier": "normal", "account": "codex-1", "codex_home": "...\\codex-1", "percent_left": 62.0,
107
- "stop_below_percent": null, "reason": "codex-1 has 62% left", "accounts": [...]}
100
+ python packages/claude-dev-env/scripts/account_broker.py choose --product codex
101
+ {"decision": {"action": "run", "account": "codex-1", "home": "...\\codex-1",
102
+ "resets_at": null, "reason": "codex-1 has 62% left", "tier": "normal"},
103
+ "accounts": [{"name": "codex-1", "home": "...\\codex-1", "is_main": false,
104
+ "meters": {"session_percent_left": 62.0, "session_resets_at": null,
105
+ "weekly_percent_left": 70.0, "weekly_resets_at": null}}]}
108
106
  ```
109
107
 
110
- A job runs Codex with `CODEX_HOME` set to `codex_home`. On `luna`, the job runs
108
+ A job runs Codex with `CODEX_HOME` set to `decision.home`. On `luna`, the job runs
111
109
  Luna and polls `check` between steps:
112
110
 
113
111
  ```
114
- python packages/claude-dev-env/scripts/codex_account_choice.py check codex-2 --floor 1
112
+ python packages/claude-dev-env/scripts/account_broker.py check --product codex
115
113
  ```
116
114
 
117
- `check` exits 0 while the account has more than the floor left, and 3 once it has
118
- not or its meter is unread. The job stops on 3.
115
+ `check` exits 3 while every account is below its floor, and 0 when an account
116
+ has room. The job stops on 3. `choose` also exits 3 for `wait` and still prints
117
+ the JSON answer.
119
118
 
120
119
  ## Shared callers
121
120
 
122
- Two scripts under `_shared` run `choose` by path. Each finds the picker at
123
- `scripts/codex_account_choice.py` in the directory that holds `_shared`. Once
124
- installed, that is `~/.claude/scripts/codex_account_choice.py`.
121
+ Two scripts under `_shared` run `choose --product codex` by path. Each finds the
122
+ broker at `scripts/account_broker.py` in the directory that holds `_shared`.
123
+ Once installed, that is `~/.claude/scripts/account_broker.py`.
125
124
 
126
125
  | Caller | What it does with the answer |
127
126
  |---|---|
128
- | `_shared/pr-loop/scripts/check_convergence.py` | Requires a Codex clean stamp on HEAD only on `normal`. `luna`, `wait`, or a failed picker skips the Codex gate |
129
- | `_shared/advisor/scripts/codex_astra_advisor.py` | Binds Astra only on `normal`, and runs Codex with `CODEX_HOME` set to `codex_home` |
130
-
131
- Pass `--codex-path` when `codex` is off PATH. Without it the picker also tries
132
- the desktop install path under the user home.
127
+ | `_shared/pr-loop/scripts/check_convergence.py` | Requires a Codex clean stamp on HEAD only on `normal`. `luna`, `wait`, or a failed broker skips the Codex gate |
128
+ | `_shared/advisor/scripts/codex_astra_advisor.py` | Binds Astra only on `normal`, and runs Codex with `CODEX_HOME` set to `decision.home` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.38.3",
3
+ "version": "8.39.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {