claude-dev-env 8.12.0 → 8.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/.agents/skills/pull-request/scripts/test_pull_request.py +1 -1
  2. package/.agents/skills-archived/pr-converge/scripts/test_check_convergence.py +4 -4
  3. package/_shared/advisor/reference/astra-rung.md +7 -7
  4. package/_shared/advisor/reference/third-party-bind.md +2 -2
  5. package/_shared/advisor/scripts/codex_astra_advisor.py +33 -26
  6. package/_shared/advisor/scripts/codex_astra_preflight.py +57 -55
  7. package/_shared/advisor/scripts/config/advisor_scripts_constants/astra_advisor_constants.py +8 -8
  8. package/_shared/advisor/scripts/tests/test_codex_astra_advisor.py +69 -23
  9. package/_shared/advisor/scripts/tests/test_codex_astra_preflight.py +4 -4
  10. package/_shared/pr-loop/scripts/check_convergence.py +54 -49
  11. package/_shared/pr-loop/scripts/codex_review_scripts_constants/run_constants.py +4 -0
  12. package/_shared/pr-loop/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +10 -1
  13. package/_shared/pr-loop/scripts/run_codex_review.py +4 -6
  14. package/_shared/pr-loop/scripts/test_check_convergence.py +6 -6
  15. package/_shared/pr-loop/scripts/test_check_convergence_codex.py +73 -31
  16. package/_shared/pr-loop/scripts/test_check_convergence_contract.py +2 -2
  17. package/_shared/pr-loop/scripts/tests/test__claude_permissions_common.py +2 -2
  18. package/bin/git_hooks_installer.test.mjs +1 -1
  19. package/bin/install-constants.mjs +1 -6
  20. package/bin/install.cursor-rules.test.mjs +24 -51
  21. package/bin/install.mjs +16 -70
  22. package/docs/codex-accounts.md +79 -0
  23. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +5 -5
  24. package/hooks/blocking/test_pii_scanner.py +1 -1
  25. package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +2 -2
  26. package/hooks/git-hooks/conftest.py +16 -0
  27. package/hooks/git-hooks/git_hooks_constants/verification_notice_constants.py +1 -1
  28. package/hooks/git-hooks/test_post_commit.py +1 -1
  29. package/hooks/git-hooks/test_pre_commit.py +1 -1
  30. package/hooks/git-hooks/test_pre_push.py +2 -2
  31. package/hooks/git-hooks/test_verification_start.py +1 -1
  32. package/hooks/git-hooks/verification_notice.py +2 -2
  33. package/hooks/git-hooks/verification_notice_context.py +21 -2
  34. package/hooks/git-hooks/verification_start.py +2 -3
  35. package/package.json +1 -1
  36. package/rules/durable-post-artifacts.md +4 -3
  37. package/scripts/claude_account_profile.py +21 -9
  38. package/scripts/codex_account_choice.py +396 -0
  39. package/scripts/codex_account_meters.py +372 -0
  40. package/scripts/dev_env_scripts_constants/codex_account_constants.py +141 -0
  41. package/scripts/dev_env_scripts_constants/private_term_constants.py +17 -2
  42. package/scripts/private_term_scan.py +6 -2
  43. package/scripts/repository_checks/config/constants.py +1 -0
  44. package/scripts/repository_checks/tracked_private_terms.py +7 -0
  45. package/scripts/test_claude_account_profile.py +12 -0
  46. package/scripts/test_codex_account_choice.py +276 -0
  47. package/scripts/test_codex_account_meters.py +205 -0
  48. package/scripts/tests/test_automatic_advisory.py +3 -3
  49. package/scripts/tests/test_automatic_advisory_polling.py +1 -1
  50. package/scripts/tests/test_automatic_advisory_recovery.py +1 -1
  51. package/scripts/tests/test_closed_pr_label.py +1 -1
  52. package/scripts/tests/test_private_term_scan.py +28 -0
  53. package/scripts/tests/test_review_closure.py +2 -2
  54. package/scripts/tests/test_review_closure_model.py +2 -2
  55. package/scripts/tests/test_rules.py +2 -83
  56. package/scripts/tests/test_tracked_private_terms.py +15 -0
  57. package/scripts/tests/test_verification_notice.py +29 -13
  58. package/.agents/skills/cert-classification-rule/SKILL.md +0 -224
  59. package/.agents/skills/cert-classification-rule/scripts/resort_cert_folders.py +0 -158
  60. package/_shared/pr-loop/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +0 -82
  61. package/_shared/pr-loop/scripts/codex_usage_probe.py +0 -573
  62. package/_shared/pr-loop/scripts/test_codex_usage_probe.py +0 -683
  63. package/scripts/sync_to_cursor/AGENTS.md +0 -1
  64. package/scripts/sync_to_cursor/CLAUDE.md +0 -1
  65. package/scripts/sync_to_cursor/__init__.py +0 -13
  66. package/scripts/sync_to_cursor/canonical_docs.py +0 -66
  67. package/scripts/sync_to_cursor/config/__init__.py +0 -8
  68. package/scripts/sync_to_cursor/engine.py +0 -219
  69. package/scripts/sync_to_cursor/hashing.py +0 -7
  70. package/scripts/sync_to_cursor/paths.py +0 -18
  71. package/scripts/sync_to_cursor/rules.py +0 -494
  72. package/scripts/sync_to_cursor.py +0 -22
  73. package/scripts/tests/test_engine.py +0 -101
  74. package/scripts/tests/test_sync_to_cursor.py +0 -315
@@ -22,7 +22,7 @@ import pull_request
22
22
  REPOSITORY = "jl-cmd/claude-dev-env"
23
23
  PR_NUMBER = "2518"
24
24
  PR_TITLE = "feat(skills): add guarded pull request publication"
25
- ACCOUNT_A = "JonEcho"
25
+ ACCOUNT_A = "octocat"
26
26
  ACCOUNT_B = "ReviewBot"
27
27
  BODY_FILENAME = "pull-request-body.md"
28
28
  LINTER_PATH = _SCRIPTS_DIRECTORY.parents[3] / "scripts/durable_post_lint.py"
@@ -135,7 +135,7 @@ def should_pass_when_clean_bugteam_review_present_on_current_head(
135
135
  check_convergence, "_gh_api_paginated", _make_stub_gh_paginated(reviews_payload)
136
136
  )
137
137
  passed, detail = check_convergence._check_bugteam_clean(
138
- owner="JonEcho", repo="tests", number=42, head_sha=CURRENT_HEAD_SHA
138
+ owner="octocat", repo="tests", number=42, head_sha=CURRENT_HEAD_SHA
139
139
  )
140
140
  assert passed is True
141
141
  assert "clean bugteam audit" in detail
@@ -157,7 +157,7 @@ def should_fail_when_dirty_bugteam_review_present_on_current_head(
157
157
  check_convergence, "_gh_api_paginated", _make_stub_gh_paginated(reviews_payload)
158
158
  )
159
159
  passed, detail = check_convergence._check_bugteam_clean(
160
- owner="JonEcho", repo="tests", number=42, head_sha=CURRENT_HEAD_SHA
160
+ owner="octocat", repo="tests", number=42, head_sha=CURRENT_HEAD_SHA
161
161
  )
162
162
  assert passed is False
163
163
  assert "dirty bugteam audit" in detail
@@ -185,7 +185,7 @@ def should_fail_when_no_bugteam_review_present_on_current_head(
185
185
  check_convergence, "_gh_api_paginated", _make_stub_gh_paginated(reviews_payload)
186
186
  )
187
187
  passed, detail = check_convergence._check_bugteam_clean(
188
- owner="JonEcho", repo="tests", number=42, head_sha=CURRENT_HEAD_SHA
188
+ owner="octocat", repo="tests", number=42, head_sha=CURRENT_HEAD_SHA
189
189
  )
190
190
  assert passed is False
191
191
  assert "no bugteam review found" in detail
@@ -204,7 +204,7 @@ def should_fail_with_shape_detail_when_gh_returns_non_list_payload(
204
204
  check_convergence, "_gh_api_paginated", stub_gh_api_paginated_returning_object
205
205
  )
206
206
  passed, detail = check_convergence._check_bugteam_clean(
207
- owner="JonEcho", repo="tests", number=42, head_sha=CURRENT_HEAD_SHA
207
+ owner="octocat", repo="tests", number=42, head_sha=CURRENT_HEAD_SHA
208
208
  )
209
209
  assert passed is False
210
210
  assert "unexpected gh api response shape" in detail
@@ -14,26 +14,26 @@ A Windows `setx` write only updates the persisted user environment; only a proce
14
14
 
15
15
  `ADVISOR_EFFORT` selects Codex `model_reasoning_effort` for Astra: `low`, `medium`, `high`, `xhigh`, or `max`. The policy default is `medium`, which sends `model_reasoning_effort="medium"`. Astra Xhigh and Max requests route to Medium. Pass `--effort <level>` on the helper to set effort for that Astra run without changing the environment. An unset value uses the policy default. An unknown value blocks advisor routing.
16
16
 
17
- Every fallback reply carries a `fallback_kind` field. `declined` means policy closed the rung (flag off, usage meter at or below the gate). `broken` means the Astra path itself failed (missing executable, spawn error, timeout, malformed reply). A `broken` fallback is a defect to report.
17
+ Every fallback reply carries a `fallback_kind` field. `declined` means policy closed the rung (flag off, or the account picker named no Codex account on the `normal` tier). `broken` means the Astra path itself failed (missing executable, spawn error, timeout, malformed reply). A `broken` fallback is a defect to report.
18
18
 
19
19
  ## Preflight
20
20
 
21
- Flag on: run the Codex preflight first.
21
+ Flag on: the helper asks the Codex account picker for an account first.
22
22
 
23
23
  ```
24
- python ~/.claude/_shared/pr-loop/scripts/codex_usage_probe.py
24
+ python ~/.claude/scripts/codex_account_choice.py choose
25
25
  ```
26
26
 
27
- **GOTCHA. Probe path.** That installed path is the only one. `codex_astra_advisor.resolve_usage_probe_path` builds `~/.claude/_shared/pr-loop/scripts/codex_usage_probe.py` and stops. Do not hunt `skills/codex-review/`, Codex worktrees under `C:\dev\.codex\worktrees\`, or other copies. Repo home: `packages/claude-dev-env/_shared/pr-loop/scripts/`.
27
+ **GOTCHA. Picker path.** `codex_astra_advisor.resolve_account_picker_path` builds the picker path from the helper's own location: two directories above `_shared/advisor/scripts`, then `scripts/codex_account_choice.py`. Installed, that is `~/.claude/scripts/codex_account_choice.py`. Do not hunt other copies. Repo home: `packages/claude-dev-env/scripts/`. The accounts, their order, and sign-in steps are in [`codex-accounts.md`](../../../docs/codex-accounts.md).
28
28
 
29
- The shared entry point is `~/.claude/_shared/advisor/scripts/codex_astra_advisor.py`. It calls the installed probe and owns Astra bind or resume parsing. Bind with `python ~/.claude/_shared/advisor/scripts/codex_astra_advisor.py --bind --enable-astra --cwd <repo-root>` and pipe the charter on stdin. Resume with `--resume <session_id>` and pipe the delta consult on stdin.
29
+ The shared entry point is `~/.claude/_shared/advisor/scripts/codex_astra_advisor.py`. It runs the picker and owns Astra bind or resume parsing. Bind with `python ~/.claude/_shared/advisor/scripts/codex_astra_advisor.py --bind --enable-astra --cwd <repo-root>` and pipe the charter on stdin. Resume with `--resume <session_id>` and pipe the delta consult on stdin.
30
30
 
31
- The gate passes only when the probe exits 0, `percent_left` is finite numeric data, and `percent_left` is strictly greater than `WEEKLY_USAGE_GATE_THRESHOLD_PERCENT` from the existing probe. The exact-threshold case fails closed when Fable did not bind.
31
+ The gate passes only when the picker exits 0 and answers `tier: normal` with a `codex_home` and a finite `percent_left`. The helper then runs Codex with `CODEX_HOME` set to that `codex_home`. A `luna` or `wait` answer fails closed when Fable did not bind.
32
32
 
33
33
  ## Branches
34
34
 
35
35
  **Preflight pass.** Bind one Codex CLI session at `gpt-6-astra` with `model_reasoning_effort` set from the policy-selected `ADVISOR_EFFORT` value, `--sandbox read-only`, and JSON output. The helper receives the standing-reviewer charter on stdin and returns only parsed ENDORSE / CORRECTION / PLAN / STOP guidance with a session ID.
36
36
 
37
- **Preflight fail.** Probe failure, non-zero exit, timeout, missing or malformed usage, `null`, non-finite usage, or usage at or below the threshold fails closed when Fable did not bind.
37
+ **Preflight fail.** A picker failure, non-zero exit, timeout, malformed answer, or a `luna` or `wait` tier fails closed when Fable did not bind.
38
38
 
39
39
  The helper owns the Astra attempt and returns an explicit fallback result. The consuming advisor path owns the Fable bind. Apply the same gate to every Astra attempt, including resume.
@@ -5,13 +5,13 @@ Open this when binding or re-binding the advisor from a third-party (non-Claude,
5
5
 
6
6
  ## GOTCHA: Cursor Astra first shot
7
7
 
8
- Cursor is a third-party host. When the walk reaches Astra (`ADVISOR_ASTRA=1` / `--enable-astra` after Fable is out of usage, or the user asks for Astra), bind Astra on the **first** tool call through the headless Codex helper. Do not use the Agent or Task tool, do not spawn Grok as a substitute, and do not search the filesystem for `codex_usage_probe.py`.
8
+ Cursor is a third-party host. When the walk reaches Astra (`ADVISOR_ASTRA=1` / `--enable-astra` after Fable is out of usage, or the user asks for Astra), bind Astra on the **first** tool call through the headless Codex helper. Do not use the Agent or Task tool, do not spawn Grok as a substitute, and do not search the filesystem for the Codex account picker.
9
9
 
10
10
  ```
11
11
  python "$HOME/.claude/_shared/advisor/scripts/codex_astra_advisor.py" --bind --enable-astra --cwd <repo-root>
12
12
  ```
13
13
 
14
- Pipe the standing-reviewer charter on stdin. Persist `session_id` from the JSON reply. Later consults: `--resume <session_id>` with the delta on stdin. Capture helper / `codex` stdout as UTF-8 on Windows (`encoding="utf-8"`, `errors="replace"`). Probe path and preflight: [`astra-rung.md`](astra-rung.md).
14
+ Pipe the standing-reviewer charter on stdin. Persist `session_id` from the JSON reply. Later consults: `--resume <session_id>` with the delta on stdin. Capture helper / `codex` stdout as UTF-8 on Windows (`encoding="utf-8"`, `errors="replace"`). Picker path and preflight: [`astra-rung.md`](astra-rung.md).
15
15
 
16
16
  When the Astra flag is off, follow the Claude-chain steps below.
17
17
 
@@ -30,6 +30,7 @@ from advisor_scripts_constants.advisor_route_constants import (
30
30
  )
31
31
  from advisor_scripts_constants.astra_advisor_constants import (
32
32
  ADVISOR_CODEX_EXECUTABLE_ENV_VAR,
33
+ ALL_ACCOUNT_PICKER_RELATIVE_PARTS,
33
34
  ALL_ASTRA_TRUTHY_VALUES,
34
35
  ASTRA_BIND_FAILURE_REASON,
35
36
  ASTRA_CODEX_TIMEOUT_REASON,
@@ -41,10 +42,10 @@ from advisor_scripts_constants.astra_advisor_constants import (
41
42
  ASTRA_FALLBACK_KIND_BROKEN,
42
43
  ASTRA_FALLBACK_KIND_DECLINED,
43
44
  ASTRA_SESSION_ID_METAVAR,
44
- CLAUDE_CONFIG_DIRECTORY_NAME,
45
45
  CODEX_CONFIG_FLAG,
46
46
  CODEX_EXEC_SUBCOMMAND,
47
47
  CODEX_EXECUTABLE,
48
+ CODEX_HOME_ENV_VAR,
48
49
  CODEX_JSON_FLAG,
49
50
  CODEX_MODEL_FLAG,
50
51
  CODEX_PROMPT_FROM_STDIN,
@@ -52,10 +53,7 @@ from advisor_scripts_constants.astra_advisor_constants import (
52
53
  CODEX_REASONING_CONFIG_TEMPLATE,
53
54
  CODEX_RESUME_SUBCOMMAND,
54
55
  CODEX_SANDBOX_FLAG,
55
- USAGE_PROBE_FILENAME,
56
- USAGE_PROBE_PACKAGE_DIRECTORY_NAME,
57
- USAGE_PROBE_SCRIPTS_DIRECTORY_NAME,
58
- USAGE_PROBE_SHARED_DIRECTORY_NAME,
56
+ SHARED_PACKAGE_ROOT_PARENT_INDEX,
59
57
  )
60
58
  from codex_astra_preflight import AstraPreflight, run_astra_preflight
61
59
  from codex_astra_reply import (
@@ -176,23 +174,18 @@ def resolve_advisor_effort(all_settings: Mapping[str, str] | None) -> str:
176
174
  return resolve_advisor_pair(all_settings, None)[1]
177
175
 
178
176
 
179
- def resolve_usage_probe_path(home_directory: Path) -> Path:
180
- """Return the Codex usage-probe path under home_directory.
177
+ def resolve_account_picker_path() -> Path:
178
+ """Return the Codex account picker in the scripts directory beside this shared tree.
181
179
 
182
- Args:
183
- home_directory: Home directory that holds the installed probe.
180
+ ::
181
+
182
+ ~/.claude/_shared/advisor/scripts -> ~/.claude/scripts/codex_account_choice.py
184
183
 
185
184
  Returns:
186
- Path to the usage probe under that home.
185
+ Path to ``codex_account_choice.py``.
187
186
  """
188
- return (
189
- home_directory
190
- / CLAUDE_CONFIG_DIRECTORY_NAME
191
- / USAGE_PROBE_SHARED_DIRECTORY_NAME
192
- / USAGE_PROBE_PACKAGE_DIRECTORY_NAME
193
- / USAGE_PROBE_SCRIPTS_DIRECTORY_NAME
194
- / USAGE_PROBE_FILENAME
195
- )
187
+ shared_root = _scripts_directory.parents[SHARED_PACKAGE_ROOT_PARENT_INDEX]
188
+ return shared_root.joinpath(*ALL_ACCOUNT_PICKER_RELATIVE_PARTS)
196
189
 
197
190
 
198
191
  def resolve_codex_executable(all_settings: Mapping[str, str] | None) -> str | None:
@@ -244,21 +237,28 @@ def build_codex_arguments(
244
237
 
245
238
  def _resolve_preflight(
246
239
  preflight: AstraPreflight | None,
247
- probe_path: Path | None,
240
+ picker_path: Path | None,
248
241
  process_runner: Callable[..., subprocess.CompletedProcess[str]],
249
242
  ) -> AstraPreflight:
250
243
  if preflight is not None:
251
244
  return preflight
252
- resolved_path = resolve_usage_probe_path(Path.home()) if probe_path is None else probe_path
245
+ resolved_path = resolve_account_picker_path() if picker_path is None else picker_path
253
246
  return run_astra_preflight(resolved_path, process_runner)
254
247
 
255
248
 
249
+ def _codex_environment(codex_home: Path | None) -> dict[str, str] | None:
250
+ if codex_home is None:
251
+ return None
252
+ return {**os.environ, CODEX_HOME_ENV_VAR: str(codex_home)}
253
+
254
+
256
255
  def _run_codex(
257
256
  prompt: str,
258
257
  working_directory: Path,
259
258
  session_id: str | None,
260
259
  all_settings: Mapping[str, str] | None,
261
260
  executable: str,
261
+ codex_home: Path | None,
262
262
  process_runner: Callable[..., subprocess.CompletedProcess[str]],
263
263
  ) -> subprocess.CompletedProcess[str]:
264
264
  model_id, effort = resolve_advisor_pair(all_settings, None)
@@ -270,6 +270,7 @@ def _run_codex(
270
270
  model_id=model_id,
271
271
  ),
272
272
  cwd=str(working_directory),
273
+ env=_codex_environment(codex_home),
273
274
  input=prompt,
274
275
  capture_output=True,
275
276
  text=True,
@@ -292,7 +293,13 @@ def _run_enabled_advisor(
292
293
  return build_fallback_reply(resolved_preflight.reason, True, resolved_preflight.fallback_kind)
293
294
  try:
294
295
  completed = _run_codex(
295
- prompt, working_directory, session_id, setting_by_name, executable, process_runner
296
+ prompt,
297
+ working_directory,
298
+ session_id,
299
+ setting_by_name,
300
+ executable,
301
+ resolved_preflight.codex_home,
302
+ process_runner,
296
303
  )
297
304
  except subprocess.TimeoutExpired as error:
298
305
  return build_fallback_reply(
@@ -324,7 +331,7 @@ def run_codex_astra_advisor(
324
331
  prompt: str,
325
332
  working_directory: Path,
326
333
  preflight: AstraPreflight | None,
327
- probe_path: Path | None,
334
+ picker_path: Path | None,
328
335
  setting_by_name: Mapping[str, str] | None,
329
336
  session_id: str | None,
330
337
  process_runner: Callable[..., subprocess.CompletedProcess[str]],
@@ -334,11 +341,11 @@ def run_codex_astra_advisor(
334
341
  Args:
335
342
  prompt: Advisor prompt text.
336
343
  working_directory: Working directory for Codex.
337
- preflight: Usage probe result, or None to run the probe.
338
- probe_path: Usage-probe path, or None for the installed path.
344
+ preflight: Account picker result, or None to run the picker.
345
+ picker_path: Account picker path, or None for the one beside this shared tree.
339
346
  setting_by_name: Environment mapping, or None to read os.environ.
340
347
  session_id: Session to resume, or None for a new bind.
341
- process_runner: Callable that runs the probe and Codex.
348
+ process_runner: Callable that runs the picker and Codex.
342
349
 
343
350
  Returns:
344
351
  Advisor reply, or a fallback.
@@ -348,7 +355,7 @@ def run_codex_astra_advisor(
348
355
  executable = resolve_codex_executable(setting_by_name)
349
356
  if executable is None:
350
357
  return build_fallback_reply(ASTRA_EXECUTABLE_NOT_FOUND_REASON, True)
351
- resolved_preflight = _resolve_preflight(preflight, probe_path, process_runner)
358
+ resolved_preflight = _resolve_preflight(preflight, picker_path, process_runner)
352
359
  return _run_enabled_advisor(prompt, working_directory, setting_by_name, session_id, executable, resolved_preflight, process_runner)
353
360
 
354
361
 
@@ -1,8 +1,7 @@
1
- """Evaluate Codex usage before an Astra advisor request."""
1
+ """Ask the Codex account picker for an account before an Astra advisor request."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import importlib
6
5
  import json
7
6
  import math
8
7
  import subprocess
@@ -12,11 +11,13 @@ from dataclasses import dataclass
12
11
  from pathlib import Path
13
12
 
14
13
  from advisor_scripts_constants.astra_advisor_constants import (
14
+ ACCOUNT_PICKER_CHOOSE_COMMAND,
15
+ ASTRA_ACCOUNT_PICK_TIMEOUT_REASON,
16
+ ASTRA_ACCOUNT_PICK_TIMEOUT_SECONDS,
15
17
  ASTRA_FALLBACK_KIND_BROKEN,
16
18
  ASTRA_FALLBACK_KIND_DECLINED,
17
19
  ASTRA_PREFLIGHT_FAILURE_REASON,
18
- ASTRA_PROBE_TIMEOUT_REASON,
19
- ASTRA_USAGE_PROBE_TIMEOUT_SECONDS,
20
+ CODEX_TIER_NORMAL,
20
21
  )
21
22
 
22
23
 
@@ -26,6 +27,7 @@ class AstraPreflight:
26
27
  percent_left: float | None
27
28
  reason: str
28
29
  fallback_kind: str | None = None
30
+ codex_home: Path | None = None
29
31
 
30
32
 
31
33
  def _preflight_fallback(
@@ -34,82 +36,82 @@ def _preflight_fallback(
34
36
  return AstraPreflight(False, percent_left, reason, fallback_kind)
35
37
 
36
38
 
37
- def _load_usage_gate(probe_path: Path) -> Callable[[float], bool]:
38
- probe_directory = str(probe_path.parent)
39
- if probe_directory not in sys.path:
40
- sys.path.insert(0, probe_directory)
41
- return importlib.import_module("codex_usage_probe").is_codex_review_required
39
+ def _broken(detail: str) -> AstraPreflight:
40
+ return _preflight_fallback(
41
+ f"{ASTRA_PREFLIGHT_FAILURE_REASON}: {detail}", None, ASTRA_FALLBACK_KIND_BROKEN
42
+ )
42
43
 
43
44
 
44
- def _parse_probe_percent(stdout_text: str) -> tuple[float | None, str | None]:
45
- try:
46
- report = json.loads(stdout_text)
47
- except (TypeError, json.JSONDecodeError):
48
- return None, "usage report is malformed"
49
- if not isinstance(report, dict):
50
- return None, "usage report is malformed"
51
- raw_percent = report.get("percent_left")
45
+ def _finite_percent(raw_percent: object) -> float | None:
52
46
  if isinstance(raw_percent, bool) or not isinstance(raw_percent, (int, float)):
53
- return None, "usage meter is unknown" if raw_percent is None else "usage meter is malformed"
54
- percent_left = float(raw_percent)
55
- if not math.isfinite(percent_left):
56
- return None, "usage meter is malformed"
57
- return percent_left, None
58
-
59
-
60
- def _run_probe(
61
- probe_path: Path,
47
+ return None
48
+ return float(raw_percent) if math.isfinite(raw_percent) else None
49
+
50
+
51
+ def _preflight_from_answer(field_by_name: dict[str, object]) -> AstraPreflight:
52
+ if not isinstance(field_by_name.get("tier"), str):
53
+ return _broken("picker answer is malformed")
54
+ if field_by_name["tier"] != CODEX_TIER_NORMAL:
55
+ reason = f"{ASTRA_PREFLIGHT_FAILURE_REASON}: no Codex account has room ({field_by_name.get('reason')})"
56
+ return _preflight_fallback(reason, None, ASTRA_FALLBACK_KIND_DECLINED)
57
+ codex_home = field_by_name.get("codex_home")
58
+ percent_left = _finite_percent(field_by_name.get("percent_left"))
59
+ if not isinstance(codex_home, str) or not codex_home or percent_left is None:
60
+ return _broken("picker answer names no Codex home with room")
61
+ reason = f"{field_by_name.get('account')} has {percent_left:.0f}% left"
62
+ return AstraPreflight(True, percent_left, reason, codex_home=Path(codex_home))
63
+
64
+
65
+ def _run_picker(
66
+ picker_path: Path,
62
67
  process_runner: Callable[..., subprocess.CompletedProcess[str]],
63
68
  ) -> subprocess.CompletedProcess[str]:
64
69
  return process_runner(
65
- [sys.executable, str(probe_path)],
70
+ [sys.executable, str(picker_path), ACCOUNT_PICKER_CHOOSE_COMMAND],
66
71
  capture_output=True,
67
72
  text=True,
68
73
  check=False,
69
74
  shell=False,
70
- timeout=ASTRA_USAGE_PROBE_TIMEOUT_SECONDS,
75
+ timeout=ASTRA_ACCOUNT_PICK_TIMEOUT_SECONDS,
71
76
  )
72
77
 
73
78
 
74
- def _preflight_from_probe(
75
- probe_path: Path, completed: subprocess.CompletedProcess[str]
76
- ) -> AstraPreflight:
79
+ def _preflight_from_picker(completed: subprocess.CompletedProcess[str]) -> AstraPreflight:
77
80
  if completed.returncode != 0:
78
- reason = f"{ASTRA_PREFLIGHT_FAILURE_REASON}: probe exit {completed.returncode}"
79
- return _preflight_fallback(reason, None, ASTRA_FALLBACK_KIND_BROKEN)
80
- percent_left, parse_reason = _parse_probe_percent(completed.stdout)
81
- if parse_reason is not None or percent_left is None:
82
- reason = f"{ASTRA_PREFLIGHT_FAILURE_REASON}: {parse_reason or 'usage meter is unknown'}"
83
- return _preflight_fallback(reason, None, ASTRA_FALLBACK_KIND_BROKEN)
84
- if not _load_usage_gate(probe_path)(percent_left):
85
- reason = f"{ASTRA_PREFLIGHT_FAILURE_REASON}: usage meter is at or below the gate"
86
- return _preflight_fallback(reason, percent_left, ASTRA_FALLBACK_KIND_DECLINED)
87
- return AstraPreflight(True, percent_left, "usage meter is above the Astra gate")
81
+ return _broken(f"picker exit {completed.returncode}")
82
+ try:
83
+ answer = json.loads(completed.stdout)
84
+ except (TypeError, json.JSONDecodeError):
85
+ return _broken("picker answer is malformed")
86
+ if not isinstance(answer, dict):
87
+ return _broken("picker answer is malformed")
88
+ return _preflight_from_answer(answer)
88
89
 
89
90
 
90
91
  def run_astra_preflight(
91
- probe_path: Path,
92
+ picker_path: Path,
92
93
  process_runner: Callable[..., subprocess.CompletedProcess[str]],
93
94
  ) -> AstraPreflight:
94
- """Run the usage probe and decide whether Astra may bind.
95
+ """Run the Codex account picker and decide whether Astra may bind.
96
+
97
+ ::
98
+
99
+ tier normal, codex-2, 62% left -> eligible, codex_home of codex-2
100
+ tier luna or wait -> declined
101
+ picker exit 1, bad JSON, timeout -> broken
95
102
 
96
103
  Args:
97
- probe_path: Path to the installed usage probe.
98
- process_runner: Callable that runs the probe.
104
+ picker_path: Path to ``codex_account_choice.py``.
105
+ process_runner: Callable that runs the picker.
99
106
 
100
107
  Returns:
101
- Eligibility, remaining usage percent, reason, and fallback kind.
108
+ Eligibility, the chosen account's percent left and Codex home, reason, and fallback kind.
102
109
  """
103
110
  try:
104
- completed = _run_probe(probe_path, process_runner)
105
- return _preflight_from_probe(probe_path, completed)
111
+ return _preflight_from_picker(_run_picker(picker_path, process_runner))
106
112
  except subprocess.TimeoutExpired as error:
107
113
  return _preflight_fallback(
108
- f"{ASTRA_PROBE_TIMEOUT_REASON}: {error}", None, ASTRA_FALLBACK_KIND_BROKEN
109
- )
110
- except (OSError, subprocess.SubprocessError, ImportError, AttributeError, TypeError, ValueError) as error:
111
- return _preflight_fallback(
112
- f"{ASTRA_PREFLIGHT_FAILURE_REASON}: {error}",
113
- None,
114
- ASTRA_FALLBACK_KIND_BROKEN,
114
+ f"{ASTRA_ACCOUNT_PICK_TIMEOUT_REASON}: {error}", None, ASTRA_FALLBACK_KIND_BROKEN
115
115
  )
116
+ except (OSError, subprocess.SubprocessError, TypeError, ValueError) as error:
117
+ return _broken(str(error))
@@ -13,20 +13,20 @@ CODEX_PROMPT_FROM_STDIN: str = "-"
13
13
  CODEX_EXEC_SUBCOMMAND: str = "exec"
14
14
  CODEX_RESUME_SUBCOMMAND: str = "resume"
15
15
  CODEX_SANDBOX_FLAG: str = "--sandbox"
16
- CLAUDE_CONFIG_DIRECTORY_NAME: str = ".claude"
17
- USAGE_PROBE_SHARED_DIRECTORY_NAME: str = "_shared"
18
- USAGE_PROBE_PACKAGE_DIRECTORY_NAME: str = "pr-loop"
19
- USAGE_PROBE_SCRIPTS_DIRECTORY_NAME: str = "scripts"
20
- USAGE_PROBE_FILENAME: str = "codex_usage_probe.py"
16
+ CODEX_HOME_ENV_VAR: str = "CODEX_HOME"
17
+ ALL_ACCOUNT_PICKER_RELATIVE_PARTS: tuple[str, ...] = ("scripts", "codex_account_choice.py")
18
+ SHARED_PACKAGE_ROOT_PARENT_INDEX: int = 2
19
+ ACCOUNT_PICKER_CHOOSE_COMMAND: str = "choose"
20
+ CODEX_TIER_NORMAL: str = "normal"
21
21
  ASTRA_SESSION_ID_METAVAR: str = "SESSION_ID"
22
22
  ASTRA_CODEX_TIMEOUT_SECONDS: float = 120.0
23
- ASTRA_USAGE_PROBE_TIMEOUT_SECONDS: float = 30.0
23
+ ASTRA_ACCOUNT_PICK_TIMEOUT_SECONDS: float = 150.0
24
24
  ASTRA_ENV_VAR: str = "ADVISOR_ASTRA"
25
25
  ALL_ASTRA_TRUTHY_VALUES: frozenset[str] = frozenset({"1", "true", "yes", "on"})
26
- ASTRA_PREFLIGHT_FAILURE_REASON: str = "astra preflight did not establish an eligible Codex meter"
26
+ ASTRA_PREFLIGHT_FAILURE_REASON: str = "astra preflight found no Codex account to bind"
27
27
  ASTRA_BIND_FAILURE_REASON: str = "Codex Astra bind failed"
28
28
  ASTRA_REPLY_FAILURE_REASON: str = "Codex Astra returned no advisor guidance"
29
- ASTRA_PROBE_TIMEOUT_REASON: str = "Codex Astra meter check timed out"
29
+ ASTRA_ACCOUNT_PICK_TIMEOUT_REASON: str = "Codex Astra account pick timed out"
30
30
  ASTRA_CODEX_TIMEOUT_REASON: str = "Codex Astra request timed out"
31
31
  ASTRA_MALFORMED_JSONL_REASON: str = "Codex Astra returned malformed JSONL"
32
32
  ASTRA_MISSING_SESSION_REASON: str = "Codex Astra returned no session id"
@@ -31,9 +31,15 @@ def _load_astra_module() -> ModuleType:
31
31
  astra_advisor = _load_astra_module()
32
32
 
33
33
  SCRIPTS_ROOT = Path(__file__).parent.parent
34
- USAGE_PROBE_PATH = (
35
- SCRIPTS_ROOT.parents[1] / "pr-loop" / "scripts" / "codex_usage_probe.py"
36
- )
34
+ PICKER_PATH = SCRIPTS_ROOT.parents[2] / "scripts" / "codex_account_choice.py"
35
+ PICKED_CODEX_HOME = Path("codex-profiles") / "codex-2"
36
+ NORMAL_ANSWER = {
37
+ "tier": "normal",
38
+ "account": "codex-2",
39
+ "codex_home": str(PICKED_CODEX_HOME),
40
+ "percent_left": 90,
41
+ "reason": "codex-2 has 90% left",
42
+ }
37
43
  ENABLED_SETTINGS = {astra_advisor.ASTRA_ENV_VAR: "1"}
38
44
 
39
45
 
@@ -42,9 +48,9 @@ def _codex_on_search_path(monkeypatch: pytest.MonkeyPatch) -> None:
42
48
  monkeypatch.setattr(astra_advisor.shutil, "which", lambda name: "codex")
43
49
 
44
50
 
45
- def _probe(payload: object, returncode: int = 0) -> subprocess.CompletedProcess[str]:
51
+ def _pick(payload: object, returncode: int = 0) -> subprocess.CompletedProcess[str]:
46
52
  return subprocess.CompletedProcess(
47
- [sys.executable, str(USAGE_PROBE_PATH)],
53
+ [sys.executable, str(PICKER_PATH), "choose"],
48
54
  returncode,
49
55
  json.dumps(payload),
50
56
  "",
@@ -66,14 +72,18 @@ def _events(session_id: str = "thread-1", guidance: str = "PLAN\ninspect") -> st
66
72
 
67
73
 
68
74
  def _two_step_runner(
69
- all_calls: list[list[str]], guidance: str = "PLAN\ninspect"
75
+ all_calls: list[list[str]],
76
+ guidance: str = "PLAN\ninspect",
77
+ all_call_keywords: list[dict[str, object]] | None = None,
70
78
  ) -> _ProcessRunner:
71
79
  def runner(
72
80
  arguments: list[str], **kwargs: object
73
81
  ) -> subprocess.CompletedProcess[str]:
74
82
  all_calls.append(arguments)
83
+ if all_call_keywords is not None:
84
+ all_call_keywords.append(kwargs)
75
85
  if len(all_calls) == 1:
76
- return _probe({"percent_left": 90})
86
+ return _pick(NORMAL_ANSWER)
77
87
  return subprocess.CompletedProcess(arguments, 0, _events(guidance=guidance), "")
78
88
  return runner
79
89
 
@@ -155,31 +165,63 @@ def test_argument_parser_accepts_astra_and_rejects_sol() -> None:
155
165
  parser.parse_args(["--bind", "--cwd", ".", "--enable-sol"])
156
166
 
157
167
 
158
- def test_usage_probe_path_uses_supplied_home(tmp_path: Path) -> None:
159
- path = astra_advisor.resolve_usage_probe_path(tmp_path)
160
- assert path == tmp_path / ".claude" / "_shared" / "pr-loop" / "scripts" / "codex_usage_probe.py"
168
+ def test_account_picker_path_names_the_packaged_picker() -> None:
169
+ path = astra_advisor.resolve_account_picker_path()
170
+ assert path == PICKER_PATH.resolve()
171
+ assert path.is_file()
172
+
173
+
174
+ def test_preflight_runs_the_picker_choose_command() -> None:
175
+ all_calls: list[list[str]] = []
176
+
177
+ def runner(arguments: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
178
+ all_calls.append(arguments)
179
+ return _pick(NORMAL_ANSWER)
161
180
 
181
+ astra_advisor.run_astra_preflight(PICKER_PATH, runner)
182
+ assert all_calls == [[sys.executable, str(PICKER_PATH), "choose"]]
162
183
 
163
- def test_preflight_accepts_meter_above_gate() -> None:
184
+
185
+ def test_preflight_accepts_the_normal_tier_and_keeps_its_codex_home() -> None:
164
186
  preflight = astra_advisor.run_astra_preflight(
165
- USAGE_PROBE_PATH, lambda arguments, **kwargs: _probe({"percent_left": 90})
187
+ PICKER_PATH, lambda arguments, **kwargs: _pick(NORMAL_ANSWER)
166
188
  )
167
189
  assert preflight.eligible
168
190
  assert preflight.percent_left == 90
191
+ assert preflight.codex_home == PICKED_CODEX_HOME
169
192
 
170
193
 
171
- def test_preflight_declines_meter_at_gate() -> None:
194
+ @pytest.mark.parametrize("tier", ["luna", "wait"])
195
+ def test_preflight_declines_a_tier_without_room(tier: str) -> None:
196
+ answer = {**NORMAL_ANSWER, "tier": tier, "reason": "every account is low"}
172
197
  preflight = astra_advisor.run_astra_preflight(
173
- USAGE_PROBE_PATH, lambda arguments, **kwargs: _probe({"percent_left": 10})
198
+ PICKER_PATH, lambda arguments, **kwargs: _pick(answer)
174
199
  )
175
200
  assert not preflight.eligible
176
201
  assert preflight.fallback_kind == astra_advisor.ASTRA_FALLBACK_KIND_DECLINED
202
+ assert preflight.codex_home is None
177
203
 
178
204
 
179
- @pytest.mark.parametrize("payload", [{"percent_left": None}, {"percent_left": "90"}, ["bad"]])
180
- def test_preflight_rejects_malformed_meter(payload: object) -> None:
205
+ @pytest.mark.parametrize(
206
+ "payload",
207
+ [
208
+ {"tier": None},
209
+ {**NORMAL_ANSWER, "codex_home": None},
210
+ {**NORMAL_ANSWER, "percent_left": "90"},
211
+ ["bad"],
212
+ ],
213
+ )
214
+ def test_preflight_rejects_a_malformed_answer(payload: object) -> None:
215
+ preflight = astra_advisor.run_astra_preflight(
216
+ PICKER_PATH, lambda arguments, **kwargs: _pick(payload)
217
+ )
218
+ assert not preflight.eligible
219
+ assert preflight.fallback_kind == astra_advisor.ASTRA_FALLBACK_KIND_BROKEN
220
+
221
+
222
+ def test_preflight_rejects_a_failed_picker() -> None:
181
223
  preflight = astra_advisor.run_astra_preflight(
182
- USAGE_PROBE_PATH, lambda arguments, **kwargs: _probe(payload)
224
+ PICKER_PATH, lambda arguments, **kwargs: _pick(NORMAL_ANSWER, returncode=1)
183
225
  )
184
226
  assert not preflight.eligible
185
227
  assert preflight.fallback_kind == astra_advisor.ASTRA_FALLBACK_KIND_BROKEN
@@ -208,17 +250,21 @@ def test_jsonl_parser_falls_back_for_invalid_reply(jsonl_text: str) -> None:
208
250
  assert not reply.successful
209
251
 
210
252
 
211
- def test_bind_runs_probe_then_codex() -> None:
253
+ def test_bind_runs_picker_then_codex_under_the_chosen_home() -> None:
212
254
  calls: list[list[str]] = []
255
+ all_call_keywords: list[dict[str, object]] = []
213
256
  reply = astra_advisor.run_codex_astra_advisor(
214
257
  "consult",
215
258
  Path("."),
216
259
  None,
217
- USAGE_PROBE_PATH,
260
+ PICKER_PATH,
218
261
  ENABLED_SETTINGS,
219
262
  None,
220
- _two_step_runner(calls),
263
+ _two_step_runner(calls, all_call_keywords=all_call_keywords),
221
264
  )
265
+ codex_environment = all_call_keywords[1]["env"]
266
+ assert isinstance(codex_environment, dict)
267
+ assert codex_environment["CODEX_HOME"] == str(PICKED_CODEX_HOME)
222
268
  assert reply.successful
223
269
  assert reply.selected_tier == "Astra"
224
270
  assert reply.outcome == "codex"
@@ -236,7 +282,7 @@ def test_bind_routes_astra_xhigh_to_the_policy_effort() -> None:
236
282
  "consult",
237
283
  Path("."),
238
284
  None,
239
- USAGE_PROBE_PATH,
285
+ PICKER_PATH,
240
286
  settings,
241
287
  None,
242
288
  _two_step_runner(calls),
@@ -263,7 +309,7 @@ def test_disabled_flag_returns_declined_fallback() -> None:
263
309
  def test_missing_codex_returns_broken_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
264
310
  monkeypatch.setattr(astra_advisor.shutil, "which", lambda name: None)
265
311
  reply = astra_advisor.run_codex_astra_advisor(
266
- "consult", Path("."), None, USAGE_PROBE_PATH, ENABLED_SETTINGS, None, subprocess.run
312
+ "consult", Path("."), None, PICKER_PATH, ENABLED_SETTINGS, None, subprocess.run
267
313
  )
268
314
  assert reply.is_fallback
269
315
  assert reply.fallback_kind == astra_advisor.ASTRA_FALLBACK_KIND_BROKEN
@@ -275,7 +321,7 @@ def test_resume_uses_existing_session() -> None:
275
321
  "resume",
276
322
  Path("."),
277
323
  None,
278
- USAGE_PROBE_PATH,
324
+ PICKER_PATH,
279
325
  ENABLED_SETTINGS,
280
326
  "thread-1",
281
327
  _two_step_runner(calls, "ENDORSE\nready"),
@@ -1,4 +1,4 @@
1
- """Tests for Codex Astra usage preflight."""
1
+ """Tests for the Codex Astra account preflight."""
2
2
 
3
3
  import json
4
4
  import subprocess
@@ -12,13 +12,13 @@ sys.path.insert(0, str(SCRIPTS_ROOT))
12
12
  from codex_astra_preflight import run_astra_preflight
13
13
 
14
14
 
15
- def test_run_astra_preflight_rejects_probe_failure(tmp_path: Path) -> None:
16
- probe_path = tmp_path / "codex_usage_probe.py"
15
+ def test_run_astra_preflight_rejects_picker_failure(tmp_path: Path) -> None:
16
+ picker_path = tmp_path / "codex_account_choice.py"
17
17
 
18
18
  def process_runner(*args: object, **kwargs: object) -> subprocess.CompletedProcess[str]:
19
19
  return subprocess.CompletedProcess([], 1, json.dumps({}), "")
20
20
 
21
- preflight = run_astra_preflight(probe_path, process_runner)
21
+ preflight = run_astra_preflight(picker_path, process_runner)
22
22
 
23
23
  assert not preflight.eligible
24
24
  assert preflight.fallback_kind == "broken"