claude-dev-env 8.79.0 → 8.80.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 (57) hide show
  1. package/.agents/skills/pr-lifecycle/reference/review-loop-before-merge.md +2 -2
  2. package/bin/install.settings-defaults.test.mjs +27 -2
  3. package/bin/merge_settings_defaults.mjs +39 -21
  4. package/bin/merge_settings_defaults.test.mjs +42 -0
  5. package/docs/account-broker.md +1 -1
  6. package/docs/codex-accounts.md +6 -5
  7. package/hooks/blocking/cloud_graphql_gate.py +213 -0
  8. package/hooks/blocking/headless_claude_broker_gate.py +1 -8
  9. package/hooks/blocking/merge_review_gate.py +65 -3
  10. package/hooks/blocking/pr_lifecycle_skill_gate.py +6 -7
  11. package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +5 -1
  12. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
  13. package/hooks/blocking/test_cloud_graphql_gate.py +210 -0
  14. package/hooks/blocking/test_headless_claude_broker_gate.py +12 -7
  15. package/hooks/blocking/test_merge_review_gate.py +63 -0
  16. package/hooks/blocking/test_pr_lifecycle_skill_gate.py +3 -0
  17. package/hooks/conftest.py +1 -1
  18. package/hooks/features/bash-dispatchers.md +3 -0
  19. package/hooks/features/conduct-gates.md +2 -2
  20. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -0
  21. package/hooks/hooks_constants/cloud_graphql_gate_constants.py +116 -0
  22. package/hooks/hooks_constants/cloud_session.py +17 -0
  23. package/hooks/hooks_constants/cloud_session_constants.py +11 -0
  24. package/hooks/hooks_constants/gh_command_words.py +52 -0
  25. package/hooks/hooks_constants/gh_command_words_constants.py +64 -0
  26. package/hooks/hooks_constants/headless_claude_broker_gate_constants.py +1 -7
  27. package/hooks/hooks_constants/hook_block_logger.py +21 -3
  28. package/hooks/hooks_constants/merge_review_gate_constants.py +14 -3
  29. package/hooks/hooks_constants/mod_handoff.py +2 -3
  30. package/hooks/hooks_constants/mod_handoff_constants.py +0 -4
  31. package/hooks/hooks_constants/piped_pytest_blocker_constants.py +27 -2
  32. package/hooks/hooks_constants/pr_lifecycle_skill_gate_constants.py +0 -1
  33. package/hooks/hooks_constants/shell_command_evaluation_constants.py +67 -0
  34. package/hooks/hooks_constants/shell_command_substitution_constants.py +25 -0
  35. package/hooks/hooks_constants/shell_command_wrappers.py +327 -12
  36. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -1
  37. package/hooks/hooks_constants/test_cloud_session.py +27 -0
  38. package/hooks/hooks_constants/test_gh_command_words.py +45 -0
  39. package/hooks/hooks_constants/test_hook_block_logger.py +61 -0
  40. package/hooks/hooks_constants/test_mod_handoff.py +3 -2
  41. package/hooks/hooks_constants/test_piped_pytest_blocker_constants.py +11 -0
  42. package/hooks/hooks_constants/test_shell_command_wrappers.py +125 -0
  43. package/hooks/test_conftest.py +1 -1
  44. package/package.json +1 -1
  45. package/scripts/account_broker_support.py +10 -11
  46. package/scripts/codex_account_meters.py +30 -43
  47. package/scripts/dev_env_scripts_constants/account_broker_constants.py +34 -25
  48. package/scripts/dev_env_scripts_constants/claude_account_constants.py +0 -9
  49. package/scripts/dev_env_scripts_constants/codex_account_constants.py +0 -18
  50. package/scripts/dev_env_scripts_constants/test_account_broker_constants.py +42 -0
  51. package/scripts/dev_env_scripts_constants/test_codex_account_constants.py +119 -0
  52. package/scripts/merge_review_receipt.py +56 -12
  53. package/scripts/test_account_broker.py +12 -0
  54. package/scripts/test_account_broker_support.py +32 -6
  55. package/scripts/test_codex_account_meters.py +26 -14
  56. package/scripts/test_merge_review_receipt.py +86 -0
  57. package/settings.json +3 -0
@@ -2,6 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import base64
6
+
5
7
  import pytest
6
8
 
7
9
  from hooks_constants import shell_command_wrappers
@@ -31,6 +33,29 @@ def test_should_return_only_the_command_when_no_wrapper_runs() -> None:
31
33
  assert shell_command_wrappers.all_wrapped_command_texts("git push") == ["git push"]
32
34
 
33
35
 
36
+ @pytest.mark.parametrize(
37
+ "command",
38
+ [
39
+ "git push",
40
+ 'bash -c "git add -A && git push"; echo done',
41
+ "bash -c \"pwsh -Command 'git push; git status'\"",
42
+ "sudo bash -c 'gh pr list' | head",
43
+ 'echo "$(git push)"',
44
+ "x=`git push`",
45
+ ],
46
+ )
47
+ def test_should_return_the_segments_of_every_wrapped_command_text(command: str) -> None:
48
+ all_segments_from_texts = [
49
+ each_segment
50
+ for each_text in shell_command_wrappers.all_wrapped_command_texts(command)
51
+ for each_segment, _following_operator in (
52
+ shell_command_wrappers.pipeline_segments_for_command(each_text)
53
+ )
54
+ ]
55
+ all_segments = shell_command_wrappers.all_wrapped_command_segments(command)
56
+ assert sorted(all_segments) == sorted(all_segments_from_texts)
57
+
58
+
34
59
  @pytest.mark.parametrize(
35
60
  ("all_segment_tokens", "expected_program_and_arguments"),
36
61
  [
@@ -50,6 +75,13 @@ def test_should_return_only_the_command_when_no_wrapper_runs() -> None:
50
75
  (["time", "sudo", "git", "push"], ("git", ["push"])),
51
76
  (["'/usr/bin/git'", "push"], ("git", ["push"])),
52
77
  (["VAR=x"], ("", [])),
78
+ (["if", "git", "push"], ("git", ["push"])),
79
+ (["then", "git", "push"], ("git", ["push"])),
80
+ (["if", "!", "git", "push"], ("git", ["push"])),
81
+ (["while", "git", "push"], ("git", ["push"])),
82
+ (["command", "git", "push"], ("git", ["push"])),
83
+ (["command", "-p", "git", "push"], ("git", ["push"])),
84
+ (["exec", "-a", "name", "git", "push"], ("git", ["push"])),
53
85
  ],
54
86
  )
55
87
  def test_should_return_the_program_past_its_wrappers(
@@ -59,3 +91,96 @@ def test_should_return_the_program_past_its_wrappers(
59
91
  shell_command_wrappers.segment_program_and_arguments(all_segment_tokens)
60
92
  == expected_program_and_arguments
61
93
  )
94
+
95
+
96
+ @pytest.mark.parametrize(
97
+ ("command", "all_expected_texts"),
98
+ [
99
+ ('echo "$(gh pr list)"', ["gh pr list"]),
100
+ ("x=`git push`", ["git push"]),
101
+ ("echo $(echo $(git push))", ["echo $(git push)"]),
102
+ ("echo $(cat <(ls))", ["cat <(ls)"]),
103
+ ("echo \"$(printf ')'; git push)\"", ["printf ')'; git push"]),
104
+ ('echo $(echo "$(git push)")', ['echo "$(git push)"']),
105
+ ("x=`echo \\`git push\\``", ["echo `git push`"]),
106
+ ("echo '$(gh pr list)'", []),
107
+ ("echo \\$(gh pr list)", []),
108
+ ("git push", []),
109
+ ],
110
+ )
111
+ def test_should_return_each_command_substitution_body_outside_single_quotes(
112
+ command: str, all_expected_texts: list[str]
113
+ ) -> None:
114
+ assert shell_command_wrappers.all_command_substitution_texts(command) == all_expected_texts
115
+
116
+
117
+ def test_should_unwrap_a_command_substitution_in_turn() -> None:
118
+ all_command_texts = shell_command_wrappers.all_wrapped_command_texts(
119
+ "echo \"$(bash -c 'git push')\""
120
+ )
121
+ assert "bash -c 'git push'" in all_command_texts
122
+ assert "git push" in all_command_texts
123
+
124
+
125
+ _ENCODED_GIT_PUSH = base64.b64encode("git push".encode("utf-16-le")).decode()
126
+
127
+
128
+ @pytest.mark.parametrize(
129
+ ("all_segment_tokens", "expected_inner_command"),
130
+ [
131
+ (["eval", "git push"], "git push"),
132
+ (["eval", "git", "push"], "git push"),
133
+ (["Invoke-Expression", "'git push'"], "git push"),
134
+ (["iex", '"git push"'], "git push"),
135
+ (["find", ".", "-exec", "git", "push", ";"], "git push"),
136
+ (["find", ".", "-execdir", "git", "push", "{}", "+"], "git push {}"),
137
+ (["pwsh", "-EncodedCommand", _ENCODED_GIT_PUSH], "git push"),
138
+ (["powershell.exe", "-enc", _ENCODED_GIT_PUSH], "git push"),
139
+ (["pwsh", "-e", _ENCODED_GIT_PUSH], "git push"),
140
+ (["pwsh", "-ec", _ENCODED_GIT_PUSH], "git push"),
141
+ (["pwsh", "-e", "not-base64!"], None),
142
+ (["find", ".", "-exec", "true", ";", "-exec", "git", "push", ";"], "true ; git push"),
143
+ (["-exec", "git", "push", "\\"], "git push \\"),
144
+ (["pwsh", "-e", f"'{_ENCODED_GIT_PUSH[:8]} {_ENCODED_GIT_PUSH[8:]}'"], "git push"),
145
+ (["Start-Process", "git", "-ArgumentList", "'push'"], "git push"),
146
+ (
147
+ ["Start-Process", "-FilePath", "git", "-ArgumentList", "push,origin", "-Wait"],
148
+ "git push origin",
149
+ ),
150
+ (["saps", "git", "-WorkingDirectory", "C:/repo", "-ArgumentList", "push"], "git push"),
151
+ (["Start-Process", "-FilePath:git", "-ArgumentList:'push'"], "git push"),
152
+ (["Start-Process", "git", "-WorkingDirectory:C:/repo", "-ArgumentList:push"], "git push"),
153
+ (["find", ".", "-name", "x"], None),
154
+ (["git", "push"], None),
155
+ ],
156
+ )
157
+ def test_should_return_the_command_an_evaluating_program_runs(
158
+ all_segment_tokens: list[str], expected_inner_command: str | None
159
+ ) -> None:
160
+ assert (
161
+ shell_command_wrappers.evaluated_inner_command(all_segment_tokens) == expected_inner_command
162
+ )
163
+
164
+
165
+ def test_should_unwrap_an_evaluated_command_in_turn() -> None:
166
+ assert "git push" in shell_command_wrappers.all_wrapped_command_texts('eval "git push"')
167
+
168
+
169
+ def test_should_read_a_quoted_value_that_spans_lines() -> None:
170
+ command = f'pwsh -EncodedCommand "{_ENCODED_GIT_PUSH[:8]}\n{_ENCODED_GIT_PUSH[8:]}"'
171
+ assert "git push" in shell_command_wrappers.all_wrapped_command_texts(command)
172
+
173
+
174
+ def test_should_stop_unwrapping_past_the_depth_limit_without_error() -> None:
175
+ nesting_depth = 1000
176
+ command = "echo " + "$(" * nesting_depth + "git push" + ")" * nesting_depth
177
+ all_command_texts = shell_command_wrappers.all_wrapped_command_texts(command)
178
+ assert all_command_texts[0] == command
179
+ assert len(all_command_texts) <= nesting_depth
180
+
181
+
182
+ def test_should_parse_a_repeated_inner_command_once() -> None:
183
+ all_command_texts = shell_command_wrappers.all_wrapped_command_texts(
184
+ 'bash -c "git push"; bash -c "git push"'
185
+ )
186
+ assert all_command_texts.count("git push") == 1
@@ -9,7 +9,7 @@ import sys
9
9
  from hooks_constants.harness_scratchpad_constants import (
10
10
  CLAUDE_SESSION_ID_ENVIRONMENT_VARIABLE_NAME,
11
11
  )
12
- from hooks_constants.headless_claude_broker_gate_constants import CLOUD_SESSION_ENV_VAR
12
+ from hooks_constants.cloud_session_constants import CLOUD_SESSION_ENV_VAR
13
13
 
14
14
 
15
15
  def test_live_session_id_is_absent_inside_each_test() -> None:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.79.0",
3
+ "version": "8.80.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -43,9 +43,6 @@ from dev_env_scripts_constants.account_broker_constants import (
43
43
  JobOutcome,
44
44
  LOGIN_REFRESH_TIMEOUT_SECONDS,
45
45
  METER_READ_ATTEMPTS,
46
- METER_READ_DELAY_FACTOR,
47
- METER_READ_FIRST_DELAY_SECONDS,
48
- METER_READ_MAX_DELAY_SECONDS,
49
46
  Meters,
50
47
  Product,
51
48
  ProductAdapter,
@@ -54,6 +51,7 @@ from dev_env_scripts_constants.account_broker_constants import (
54
51
  REPORT_INDENT_SPACES,
55
52
  SubprocessRunner,
56
53
  codex_usage_limit_signatures,
54
+ meter_read_delay_seconds,
57
55
  parse_utc_time,
58
56
  utc_time_text,
59
57
  )
@@ -70,7 +68,6 @@ from dev_env_scripts_constants.codex_account_constants import (
70
68
  CODEX_ACCOUNT_LAUNCHERS_FILE_NAME,
71
69
  CODEX_ACCOUNT_PROFILES_ENVIRONMENT_VARIABLE,
72
70
  CODEX_HOME_ENVIRONMENT_VARIABLE,
73
- WEEKLY_WINDOW_MINUTES,
74
71
  )
75
72
  from dev_env_scripts_constants.shared_tree_constants import CLAUDE_CONFIG_DIR_ENV_VAR
76
73
  from shared_tree_paths import resolve_shared_process_tree_scripts_directory
@@ -303,14 +300,13 @@ def read_codex_account_meters(account: Account) -> Meters:
303
300
 
304
301
  Raises:
305
302
  CodexMeterUnreadError: The meter read failed.
306
- OSError: No Codex executable was found.
303
+ CodexExecutableNotFoundError: No Codex executable was found, so
304
+ read_meters_with_backoff stops without a retry.
307
305
  """
308
306
  codex_path = codex_account_meters.resolve_codex_path(None)
309
307
  usage = codex_account_meters.read_codex_meters(codex_path, account.home)
310
- weekly = [window for window in usage.all_windows if window.duration_minutes is None or window.duration_minutes >= WEEKLY_WINDOW_MINUTES]
311
- short = [window for window in usage.all_windows if window.duration_minutes is not None and window.duration_minutes < WEEKLY_WINDOW_MINUTES]
312
- weekly_window = max(weekly, key=lambda window: window.used_percent) if weekly else None
313
- short_window = max(short, key=lambda window: window.used_percent) if short else None
308
+ short_window = usage.short_window
309
+ weekly_window = usage.weekly_window
314
310
  return Meters(
315
311
  usage.short_window_percent_left,
316
312
  short_window.resets_at if short_window else None,
@@ -328,14 +324,17 @@ def read_meters_with_backoff(read_meters: Callable[[Account], Meters | None], ac
328
324
 
329
325
  Returns:
330
326
  The meters from the first read that succeeds, or None with the last
331
- failure text once every attempt failed.
327
+ failure text once every attempt failed. A missing Codex executable
328
+ fails at once, without a retry.
332
329
  """
333
330
  unread_reason = None
334
331
  for each_attempt in range(METER_READ_ATTEMPTS):
335
332
  if each_attempt:
336
- time.sleep(min(METER_READ_FIRST_DELAY_SECONDS * METER_READ_DELAY_FACTOR ** (each_attempt - 1), METER_READ_MAX_DELAY_SECONDS))
333
+ time.sleep(meter_read_delay_seconds(each_attempt))
337
334
  try:
338
335
  return read_meters(account), None
336
+ except codex_account_meters.CodexExecutableNotFoundError as error:
337
+ return None, str(error)
339
338
  except (
340
339
  codex_account_meters.CodexMeterUnreadError,
341
340
  WeeklyUtilizationProbeError,
@@ -3,14 +3,17 @@
3
3
 
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
- (5 hours) and a weekly one. The account's room is the smaller of the two::
6
+ (5 hours) and a weekly one. The account broker counts the account's room as
7
+ the smaller of the two::
7
8
 
8
9
  5-hour window 30% used, week 85% used -> 15% left
9
10
  week window only, 40% used -> 60% left
10
11
  no window, error reply, no reply -> CodexMeterUnreadError
11
12
 
12
- Every failure raises ``CodexMeterUnreadError``. The caller decides what an
13
- unread account means; this module never guesses a number.
13
+ Every read failure raises ``CodexMeterUnreadError``. A missing Codex
14
+ executable raises ``CodexExecutableNotFoundError`` from ``resolve_codex_path``.
15
+ The caller decides what an unread account means; this module never guesses a
16
+ number.
14
17
  """
15
18
 
16
19
  from __future__ import annotations
@@ -69,6 +72,10 @@ class CodexMeterUnreadError(Exception):
69
72
  """Raised when one account's rate-limit windows cannot be read."""
70
73
 
71
74
 
75
+ class CodexExecutableNotFoundError(FileNotFoundError):
76
+ """Raised when no Codex executable is on PATH or at its install path."""
77
+
78
+
72
79
  @dataclass(frozen=True)
73
80
  class UsageWindow:
74
81
  """One rate-limit window: its length, the percent used, and when it resets."""
@@ -77,56 +84,36 @@ class UsageWindow:
77
84
  used_percent: float
78
85
  resets_at: datetime | None
79
86
 
87
+ @property
88
+ def is_short(self) -> bool:
89
+ """Whether the window reports a length shorter than a week. A window with no length counts as weekly."""
90
+ return self.duration_minutes is not None and self.duration_minutes < WEEKLY_WINDOW_MINUTES
91
+
80
92
 
81
93
  @dataclass(frozen=True)
82
94
  class CodexAccountMeters:
83
- """One account's windows, with percent_left, short_window_percent_left and resets_before_room."""
95
+ """One account's windows, with its tightest short window and its tightest weekly window."""
84
96
 
85
97
  all_windows: tuple[UsageWindow, ...]
86
98
 
87
99
  @property
88
- def percent_left(self) -> float:
89
- """Room left in the tightest window, from 0 to 100."""
90
- most_used = max(each_window.used_percent for each_window in self.all_windows)
91
- return max(0.0, FULL_PERCENT - most_used)
100
+ def short_window(self) -> UsageWindow | None:
101
+ """The most used window shorter than a week, or None when the account has none."""
102
+ all_short_windows = [each_window for each_window in self.all_windows if each_window.is_short]
103
+ return max(all_short_windows, key=lambda each_window: each_window.used_percent, default=None)
104
+
105
+ @property
106
+ def weekly_window(self) -> UsageWindow | None:
107
+ """The most used window of a week or of no reported length, or None when the account has none."""
108
+ all_weekly_windows = [each_window for each_window in self.all_windows if not each_window.is_short]
109
+ return max(all_weekly_windows, key=lambda each_window: each_window.used_percent, default=None)
92
110
 
93
111
  @property
94
112
  def short_window_percent_left(self) -> float | None:
95
113
  """Room left in the windows shorter than a week, or None when the account has none."""
96
- all_short_used = [
97
- each_window.used_percent
98
- for each_window in self.all_windows
99
- if each_window.duration_minutes is not None
100
- and each_window.duration_minutes < WEEKLY_WINDOW_MINUTES
101
- ]
102
- if not all_short_used:
103
- return None
104
- return max(0.0, FULL_PERCENT - max(all_short_used))
105
-
106
- def resets_before_room(self, minimum_percent_left: float) -> datetime | None:
107
- """When every window that holds the account at or under a bar has reset.
108
-
109
- Args:
110
- minimum_percent_left: The percent left the account must pass.
111
-
112
- Returns:
113
- The latest reset among the blocking windows, or None when one
114
- carries no reset time or none blocks.
115
- """
116
- all_blocking = [
117
- each_window
118
- for each_window in self.all_windows
119
- if FULL_PERCENT - each_window.used_percent <= minimum_percent_left
120
- ]
121
- if not all_blocking or any(
122
- each_window.resets_at is None for each_window in all_blocking
123
- ):
114
+ if self.short_window is None:
124
115
  return None
125
- return max(
126
- each_window.resets_at
127
- for each_window in all_blocking
128
- if each_window.resets_at is not None
129
- )
116
+ return FULL_PERCENT - self.short_window.used_percent
130
117
 
131
118
 
132
119
  def _finite_number(raw_number: object) -> float | None:
@@ -396,7 +383,7 @@ def resolve_codex_path(explicit_path: Path | None) -> Path:
396
383
  The named path, else ``codex`` on PATH, else a known install path.
397
384
 
398
385
  Raises:
399
- FileNotFoundError: No Codex executable was found.
386
+ CodexExecutableNotFoundError: No Codex executable was found.
400
387
  """
401
388
  if explicit_path is not None:
402
389
  return explicit_path
@@ -407,4 +394,4 @@ def resolve_codex_path(explicit_path: Path | None) -> Path:
407
394
  candidate = Path.home().joinpath(*each_parts)
408
395
  if candidate.is_file():
409
396
  return candidate
410
- raise FileNotFoundError("no codex executable on PATH or at its install path")
397
+ raise CodexExecutableNotFoundError("no codex executable on PATH or at its install path")
@@ -112,6 +112,11 @@ def codex_usage_limit_signatures() -> tuple[str, ...]:
112
112
  return module.ALL_USAGE_LIMIT_MARKERS
113
113
 
114
114
 
115
+ def meter_read_delay_seconds(attempt_index: int) -> float:
116
+ """Seconds to wait before a meter read retry, where the first retry has index 1."""
117
+ return min(METER_READ_FIRST_DELAY_SECONDS * METER_READ_DELAY_FACTOR ** (attempt_index - 1), METER_READ_MAX_DELAY_SECONDS)
118
+
119
+
115
120
  def utc_time_text(moment: datetime | None) -> str | None:
116
121
  return moment.astimezone(timezone.utc).isoformat() if moment is not None else None
117
122
 
@@ -217,16 +222,26 @@ class ProductAdapter:
217
222
  usage_limit_signatures: tuple[str, ...]
218
223
 
219
224
 
225
+ def _is_at_or_under(room: float | None, floor: float) -> bool:
226
+ return room is not None and room <= floor
227
+
228
+
229
+ def _claude_blocking_flags(meters: Meters, is_main: bool) -> tuple[bool, bool]:
230
+ session_key, weekly_key = (
231
+ ("main_session_used_ceiling", "main_weekly_used_ceiling")
232
+ if is_main
233
+ else ("extra_session_used_ceiling", "extra_weekly_used_ceiling")
234
+ )
235
+ return (
236
+ _is_at_or_under(meters.session_percent_left, FULL_PERCENT - ALL_CLAUDE_FLOORS[session_key]),
237
+ _is_at_or_under(meters.weekly_percent_left, FULL_PERCENT - ALL_CLAUDE_FLOORS[weekly_key]),
238
+ )
239
+
240
+
220
241
  def _claude_extra_tier(meters: Meters | None) -> str | None:
221
242
  if meters is None or meters.tightest_percent_left is None:
222
243
  return None
223
- if (
224
- meters.weekly_percent_left is not None
225
- and meters.weekly_percent_left <= FULL_PERCENT - ALL_CLAUDE_FLOORS["extra_weekly_used_ceiling"]
226
- ) or (
227
- meters.session_percent_left is not None
228
- and meters.session_percent_left <= FULL_PERCENT - ALL_CLAUDE_FLOORS["extra_session_used_ceiling"]
229
- ):
244
+ if any(_claude_blocking_flags(meters, is_main=False)):
230
245
  return None
231
246
  return "normal"
232
247
 
@@ -253,24 +268,19 @@ def _account_reset(reading: Reading, is_spent: bool = False) -> datetime | None:
253
268
  return None
254
269
  if is_spent:
255
270
  return min((reset for reset in (meters.session_resets_at, meters.weekly_resets_at) if reset is not None), default=None)
256
- is_claude_main = reading.account.product is Product.CLAUDE and reading.account.is_main
257
- if is_claude_main:
258
- blocking = (
259
- (meters.session_percent_left, FULL_PERCENT - ALL_CLAUDE_FLOORS["main_session_used_ceiling"], meters.session_resets_at),
260
- (meters.weekly_percent_left, FULL_PERCENT - ALL_CLAUDE_FLOORS["main_weekly_used_ceiling"], meters.weekly_resets_at),
261
- )
262
- elif reading.account.product is Product.CLAUDE:
263
- blocking = (
264
- (meters.session_percent_left, FULL_PERCENT - ALL_CLAUDE_FLOORS["extra_session_used_ceiling"], meters.session_resets_at),
265
- (meters.weekly_percent_left, FULL_PERCENT - ALL_CLAUDE_FLOORS["extra_weekly_used_ceiling"], meters.weekly_resets_at),
266
- )
271
+ if reading.account.product is Product.CLAUDE:
272
+ all_blocking_flags = _claude_blocking_flags(meters, reading.account.is_main)
267
273
  else:
268
- blocking = (
269
- (meters.session_percent_left, ALL_CODEX_FLOORS["luna_short_minimum_left"], meters.session_resets_at),
270
- (meters.weekly_percent_left, ALL_CODEX_FLOORS["luna_stop_left"], meters.weekly_resets_at),
274
+ all_blocking_flags = (
275
+ meters.session_percent_left is not None and meters.session_percent_left < ALL_CODEX_FLOORS["luna_short_minimum_left"],
276
+ _is_at_or_under(meters.weekly_percent_left, ALL_CODEX_FLOORS["luna_stop_left"]),
271
277
  )
272
- all_blocking_resets = [reset for room, floor, reset in blocking if room is not None and room <= floor and reset is not None]
273
- return max(all_blocking_resets, default=None)
278
+ all_resets = (meters.session_resets_at, meters.weekly_resets_at)
279
+ all_blocking_resets = [reset for is_blocking, reset in zip(all_blocking_flags, all_resets) if is_blocking]
280
+ all_known_resets = [reset for reset in all_blocking_resets if reset is not None]
281
+ if len(all_known_resets) < len(all_blocking_resets):
282
+ return None
283
+ return max(all_known_resets, default=None)
274
284
 
275
285
 
276
286
  def _rank_key(reading: Reading) -> tuple[bool, int, float]:
@@ -284,8 +294,7 @@ def _main_has_room(meters: Meters | None) -> bool:
284
294
  meters is not None
285
295
  and meters.session_percent_left is not None
286
296
  and meters.weekly_percent_left is not None
287
- and meters.session_percent_left > FULL_PERCENT - ALL_CLAUDE_FLOORS["main_session_used_ceiling"]
288
- and meters.weekly_percent_left > FULL_PERCENT - ALL_CLAUDE_FLOORS["main_weekly_used_ceiling"]
297
+ and not any(_claude_blocking_flags(meters, is_main=True))
289
298
  )
290
299
 
291
300
 
@@ -181,15 +181,6 @@ REASON_ALL_EXTRAS_WAIT_TEMPLATE: str = (
181
181
  )
182
182
  """Reason when every configured extra profile is blocked."""
183
183
 
184
- REASON_WAIT_TEMPLATE: str = (
185
- "second account is at {weekly_used_percent:.0f}% of its week"
186
- " and {session_used_percent:.0f}% of its 5-hour window; next reset {next_reset}"
187
- )
188
- """Reason when neither account has room."""
189
-
190
- UNKNOWN_RESET_TEXT: str = "unknown"
191
- """Reset text when the blocking meter carries no reset time."""
192
-
193
184
  JSON_ACCOUNT_KEY: str = "account"
194
185
  """Picker JSON key naming the chosen account."""
195
186
 
@@ -174,26 +174,8 @@ EXIT_CODE_ROOM: int = 0
174
174
  EXIT_CODE_NO_ROOM: int = 3
175
175
  """Check answer: the account is at or below the floor, or its meter is unread."""
176
176
 
177
- REASON_NORMAL_TEMPLATE: str = "{account} has {percent_left:.0f}% left"
178
- """Reason when an account takes the job at its normal model."""
179
-
180
- REASON_LUNA_TEMPLATE: str = (
181
- "every account is at or under {bar:.0f}% left; {account} has"
182
- " {percent_left:.0f}% and runs Luna until {stop:.0f}%"
183
- )
184
- """Reason when the job falls back to Luna."""
185
-
186
- REASON_WAIT_TEMPLATE: str = "no account has room; {account} resets first, at {reset}"
187
- """Reason when every account is out of room."""
188
-
189
- REASON_WAIT_UNREAD: str = "no account meter could be read"
190
- """Reason when no account's meter reads at all."""
191
-
192
177
  UNREAD_NOT_SIGNED_IN: str = "not signed in"
193
178
  """Unread reason when the account's Codex home holds no sign-in."""
194
179
 
195
- UNKNOWN_RESET_TEXT: str = "an unknown time"
196
- """Reset text when no blocking window carries a reset time."""
197
-
198
180
  TEXT_ENCODING: str = "utf-8"
199
181
  """Encoding for the server pipes and the JSON reports."""
@@ -189,3 +189,45 @@ def test_should_treat_accounts_that_differ_only_in_profile_as_one_account() -> N
189
189
  home = Path("/profiles/first")
190
190
 
191
191
  assert Account(Product.CLAUDE, "first", home, profile="first") == Account(Product.CLAUDE, "first", home)
192
+
193
+
194
+ def _codex_reading(meters: broker_constants.Meters) -> broker_constants.Reading:
195
+ return broker_constants.Reading(Account(Product.CODEX, "codex-1", Path("/profiles/codex-1")), meters)
196
+
197
+
198
+ def test_should_let_a_codex_account_at_the_short_window_floor_take_luna() -> None:
199
+ weekly_reset = datetime(2026, 10, 4, tzinfo=timezone.utc)
200
+ session_reset = weekly_reset + timedelta(hours=4)
201
+ meters = broker_constants.Meters(
202
+ LUNA_TIER_SHORT_WINDOW_MINIMUM_PERCENT_LEFT, session_reset, LUNA_TIER_STOP_PERCENT_LEFT / 2, weekly_reset
203
+ )
204
+
205
+ assert broker_constants._codex_tier(
206
+ dataclasses.replace(meters, weekly_percent_left=NORMAL_TIER_MINIMUM_PERCENT_LEFT)
207
+ ) == "luna"
208
+ assert broker_constants._account_reset(_codex_reading(meters)) == weekly_reset
209
+
210
+
211
+ def test_should_name_no_codex_reset_when_a_blocking_window_has_none() -> None:
212
+ session_reset = datetime(2026, 10, 3, 2, tzinfo=timezone.utc)
213
+ meters = broker_constants.Meters(
214
+ LUNA_TIER_SHORT_WINDOW_MINIMUM_PERCENT_LEFT / 2, session_reset, LUNA_TIER_STOP_PERCENT_LEFT / 2, None
215
+ )
216
+
217
+ assert broker_constants._account_reset(_codex_reading(meters)) is None
218
+
219
+
220
+ def test_should_name_the_latest_codex_reset_when_every_blocking_window_has_one() -> None:
221
+ session_reset = datetime(2026, 10, 3, 2, tzinfo=timezone.utc)
222
+ weekly_reset = datetime(2026, 10, 4, tzinfo=timezone.utc)
223
+ meters = broker_constants.Meters(
224
+ LUNA_TIER_SHORT_WINDOW_MINIMUM_PERCENT_LEFT / 2, session_reset, LUNA_TIER_STOP_PERCENT_LEFT / 2, weekly_reset
225
+ )
226
+
227
+ assert broker_constants._account_reset(_codex_reading(meters)) == weekly_reset
228
+
229
+
230
+ def test_should_double_the_meter_read_delay_up_to_its_cap() -> None:
231
+ all_delays = [broker_constants.meter_read_delay_seconds(each_attempt) for each_attempt in range(1, 6)]
232
+
233
+ assert all_delays == [1.0, 2.0, 4.0, 8.0, 8.0]
@@ -0,0 +1,119 @@
1
+ """The Codex account constants match what docs/codex-accounts.md tells the reader."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from dev_env_scripts_constants import codex_account_constants as constants
10
+ from dev_env_scripts_constants.account_broker_constants import METER_READ_ATTEMPTS, meter_read_delay_seconds
11
+ from dev_env_scripts_constants.claude_account_constants import ALL_LAUNCHER_DIRECTORY_RELATIVE_PARTS
12
+
13
+ CODEX_ACCOUNTS_DOC_PATH = Path(__file__).resolve().parents[2] / "docs" / "codex-accounts.md"
14
+ SECTION_HEADING_PREFIX = "## "
15
+ CHOOSE_COMMAND_SUFFIX = "choose --product codex"
16
+ CODE_FENCE = "```"
17
+
18
+
19
+ def documented_section_lines(heading: str) -> list[str]:
20
+ all_doc_lines = CODEX_ACCOUNTS_DOC_PATH.read_text(encoding="utf-8").splitlines()
21
+ heading_index = all_doc_lines.index(SECTION_HEADING_PREFIX + heading)
22
+ all_section_lines: list[str] = []
23
+ for each_line in all_doc_lines[heading_index + 1 :]:
24
+ if each_line.startswith(SECTION_HEADING_PREFIX):
25
+ break
26
+ all_section_lines.append(each_line)
27
+ return all_section_lines
28
+
29
+
30
+ def documented_section(heading: str) -> str:
31
+ return " ".join(" ".join(documented_section_lines(heading)).split())
32
+
33
+
34
+ def documented_choose_sample() -> dict[str, Any]:
35
+ all_section_lines = documented_section_lines("Which account a job uses")
36
+ command_index = next(
37
+ index for index, each_line in enumerate(all_section_lines) if each_line.endswith(CHOOSE_COMMAND_SUFFIX)
38
+ )
39
+ fence_index = all_section_lines.index(CODE_FENCE, command_index)
40
+ return json.loads("\n".join(all_section_lines[command_index + 1 : fence_index]))
41
+
42
+
43
+ def documented_list(all_names: list[str]) -> str:
44
+ *all_leading_names, last_name = [f"`{each_name}`" for each_name in all_names]
45
+ if not all_leading_names:
46
+ return last_name
47
+ return ", ".join(all_leading_names) + f" and {last_name}"
48
+
49
+
50
+ def documented_percent(percent: float) -> str:
51
+ return f"{percent:g}%"
52
+
53
+
54
+ def test_fallback_roster_matches_the_documented_accounts() -> None:
55
+ expected_sentence = (
56
+ "When neither exists, the accounts are "
57
+ + documented_list(list(constants.ALL_CODEX_ACCOUNT_NAMES))
58
+ + "."
59
+ )
60
+ assert expected_sentence in documented_section("Names and order")
61
+
62
+
63
+ def test_shared_home_entries_match_the_documented_list() -> None:
64
+ expected_sentence = "They are " + documented_list(sorted(constants.ALL_SHARED_CODEX_HOME_NAMES)) + "."
65
+ assert expected_sentence in documented_section("Sign in once")
66
+
67
+
68
+ def test_sign_in_file_stays_per_account() -> None:
69
+ expected_sentence = f"The sign-in lives in that folder's `{constants.CODEX_AUTH_FILE_NAME}`."
70
+ assert expected_sentence in documented_section("Sign in once")
71
+ assert constants.CODEX_AUTH_FILE_NAME not in constants.ALL_SHARED_CODEX_HOME_NAMES
72
+
73
+
74
+ def test_launcher_matches_the_documented_name_directory_and_variable() -> None:
75
+ launcher_file_name = constants.CODEX_LAUNCHER_PROGRAM.file_name_template.format(profile_name="<name>")
76
+ launcher_directory = "/".join(["~", *ALL_LAUNCHER_DIRECTORY_RELATIVE_PARTS])
77
+ expected_sentence = (
78
+ f"Each roster account gets a launcher, `{launcher_file_name}`, in `{launcher_directory}`. "
79
+ f"It sets `{constants.CODEX_LAUNCHER_PROGRAM.environment_variable}` to that account's home "
80
+ f"and runs Codex with every argument"
81
+ )
82
+ assert expected_sentence in documented_section("Named launchers")
83
+
84
+
85
+ def test_tier_bars_match_the_documented_table() -> None:
86
+ normal_bar = documented_percent(constants.NORMAL_TIER_MINIMUM_PERCENT_LEFT)
87
+ luna_stop = documented_percent(constants.LUNA_TIER_STOP_PERCENT_LEFT)
88
+ luna_short_window_floor = documented_percent(constants.LUNA_TIER_SHORT_WINDOW_MINIMUM_PERCENT_LEFT)
89
+ 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 |",
92
+ 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
+ f"| No account over {luna_stop} | `{constants.TIER_WAIT}`, naming the next reset when known |",
94
+ ]
95
+ section_text = documented_section("Which account a job uses")
96
+ for each_row in all_expected_rows:
97
+ assert each_row in section_text
98
+
99
+
100
+ def test_meter_retries_match_the_documented_table() -> None:
101
+ all_delays = [meter_read_delay_seconds(each_attempt) for each_attempt in range(1, METER_READ_ATTEMPTS)]
102
+ all_delay_texts = [f"{each_delay:g} second" + ("" if each_delay == 1 else "s") for each_delay in all_delays]
103
+ expected_row = (
104
+ f"| A meter read fails | The broker reads it again, up to {METER_READ_ATTEMPTS} reads, "
105
+ f"waiting {' and then '.join(all_delay_texts)} |"
106
+ )
107
+ assert expected_row in documented_section("Which account a job uses")
108
+
109
+
110
+ def test_documented_choose_sample_runs_normal_on_the_tightest_window() -> None:
111
+ sample = documented_choose_sample()
112
+ decision = sample["decision"]
113
+ chosen_meters = next(
114
+ each_account["meters"] for each_account in sample["accounts"] if each_account["name"] == decision["account"]
115
+ )
116
+ room = min(chosen_meters["session_percent_left"], chosen_meters["weekly_percent_left"])
117
+ assert decision["tier"] == constants.TIER_NORMAL
118
+ assert room > constants.NORMAL_TIER_MINIMUM_PERCENT_LEFT
119
+ assert decision["reason"] == f"{decision['account']} has {room:g}% left"