claude-dev-env 8.68.2 → 8.68.4

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.
@@ -94,7 +94,8 @@ Room is the smaller of an account's two windows: the 5-hour window and the week.
94
94
  | No account over 10%, one or more over 1% | `luna` on the account with the most room |
95
95
  | An account that reports a 5-hour window, with under 20% of that window left | never takes `luna` |
96
96
  | No account over 1% | `wait`, naming the next reset when known |
97
- | A meter is unread | The account has `meters: null` in `accounts` |
97
+ | A meter read fails | The broker reads it once more |
98
+ | A meter is unread after both reads | The account has `meters: null` and the failure text in `unread_reason`; a `wait` reason names the account and that text |
98
99
 
99
100
  ```
100
101
  python packages/claude-dev-env/scripts/account_broker.py choose --product codex
@@ -55,6 +55,7 @@ python packages/claude-dev-env/scripts/account_broker.py choose --product claude
55
55
 
56
56
  The JSON output has a `decision` object and an `accounts` list. Each account's
57
57
  `meters` holds its remaining percent and reset time for the 5-hour window and
58
- the week. An unreadable meter appears as `null`. A wait decision has
58
+ the week. An unreadable meter appears as `null`, and the account's
59
+ `unread_reason` holds the failure text when the reader raised one. A wait decision has
59
60
  `action: "wait"` and a `resets_at` timestamp. The broker exits with code 3 on
60
61
  wait. The worker writes the same reset time to its report.
@@ -13,6 +13,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
13
13
  - `blocking/verify_before_acting.py` blocks a completed mutating call when the agent's reasoning contains an unchecked claim.
14
14
  - `blocking/pr_lifecycle_skill_gate.py` denies a commit, push, pull request action, or merge until the `pr-lifecycle` skill appears in the transcript since the last compaction.
15
15
  - `blocking/artifact_look_gate.py` denies an Artifact publish of an HTML page until an image was opened with Read after the page file was last written, so the agent looks at its page before it ships.
16
+ - `blocking/artifact_dark_mode_gate.py` denies an Artifact page publish when the page, inside the publish wrapper, keeps a light body or shows text under 4.5:1 contrast in dark mode. `blocking/artifact_dark_mode_render.cjs` renders the page in headless Chromium for it.
16
17
 
17
18
  ## When it fires
18
19
 
@@ -25,6 +26,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
25
26
  - `blocking/verify_before_acting.py` runs on `PostToolUse`, matcher `Write|Edit|MultiEdit|NotebookEdit|Agent|Task|apply_patch|Bash|PowerShell|mcp__.*`, timeout `10` seconds in `hooks.json`.
26
27
  - `blocking/pr_lifecycle_skill_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request)`, timeout `10` seconds in `hooks.json`.
27
28
  - `blocking/artifact_look_gate.py` runs on `PreToolUse`, matcher `Artifact|Skill`, timeout `10` seconds in `hooks.json`. It speaks only on an `Artifact` publish of a `.html` or `.htm` file.
29
+ - `blocking/artifact_dark_mode_gate.py` runs on `PreToolUse`, matcher `Artifact`, timeout `60` seconds in `hooks.json`.
28
30
 
29
31
  ## Proving it
30
32
 
@@ -40,6 +42,7 @@ Preconditions:
40
42
  - **Step note.** Input is a tool call after a transcript message without a status line while the flag is on. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_step_note_gate.py -q`. The adjacent test observes a block until a status line appears.
41
43
  - **Checked claim.** Input is a Write after hedged reasoning in the transcript. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_verify_before_acting.py -q`. The adjacent test observes a block and a logged blocked outcome.
42
44
  - **Lifecycle skill.** Input is a `git commit` or `gh pr create` command with no `pr-lifecycle` invocation in the transcript. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_pr_lifecycle_skill_gate.py -q`. The adjacent test observes a denial, then a silent allow once the skill or its slash command appears.
45
+ - **Dark mode page.** Input is an Artifact publish of a page that themes `html` but leaves `body` to the publish wrapper. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_artifact_dark_mode_gate.py -q`. The adjacent test observes a denial that names the text and its contrast; a page that themes `body` passes. The render tests skip on a host with no Node Playwright and Chromium.
43
46
 
44
47
  ## Gotchas
45
48
 
package/hooks/hooks.json CHANGED
@@ -152,9 +152,8 @@
152
152
  "hooks": [
153
153
  {
154
154
  "type": "command",
155
- "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/lifecycle/nested_project_hooks.py",
156
- "timeout": 1800,
157
- "async": true
155
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/artifact_dark_mode_gate.py",
156
+ "timeout": 60
158
157
  }
159
158
  ]
160
159
  },
@@ -278,7 +277,8 @@
278
277
  {
279
278
  "type": "command",
280
279
  "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/lifecycle/nested_project_hooks.py",
281
- "timeout": 1800
280
+ "timeout": 1800,
281
+ "async": true
282
282
  }
283
283
  ]
284
284
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.68.2",
3
+ "version": "8.68.4",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -125,6 +125,7 @@ def readings_payload(all_readings: Sequence[Reading]) -> list[dict[str, object]]
125
125
  "is_main": each_reading.account.is_main,
126
126
  "priority": each_reading.account.priority,
127
127
  "meters": _meter_payload(each_reading.meters),
128
+ "unread_reason": each_reading.unread_reason,
128
129
  }
129
130
  for each_reading in all_readings
130
131
  ]
@@ -222,20 +222,24 @@ def read_claude_meters(account: Account) -> Meters | None:
222
222
  )
223
223
 
224
224
 
225
- def read_codex_account_meters(account: Account) -> Meters | None:
225
+ def read_codex_account_meters(account: Account) -> Meters:
226
226
  """Probe one Codex account.
227
227
 
228
228
  Args:
229
229
  account: Account whose meter is read.
230
230
 
231
231
  Returns:
232
- Its meters, or None when the probe fails.
232
+ Its meters, read again once when the first read fails.
233
+
234
+ Raises:
235
+ CodexMeterUnreadError: Both reads failed; the message is the second failure.
236
+ OSError: No Codex executable was found.
233
237
  """
238
+ codex_path = codex_account_meters.resolve_codex_path(None)
234
239
  try:
235
- codex_path = codex_account_meters.resolve_codex_path(None)
236
240
  usage = codex_account_meters.read_codex_meters(codex_path, account.home)
237
- except (codex_account_meters.CodexMeterUnreadError, OSError):
238
- return None
241
+ except codex_account_meters.CodexMeterUnreadError:
242
+ usage = codex_account_meters.read_codex_meters(codex_path, account.home)
239
243
  weekly = [window for window in usage.all_windows if window.duration_minutes is None or window.duration_minutes >= WEEKLY_WINDOW_MINUTES]
240
244
  short = [window for window in usage.all_windows if window.duration_minutes is not None and window.duration_minutes < WEEKLY_WINDOW_MINUTES]
241
245
  weekly_window = max(weekly, key=lambda window: window.used_percent) if weekly else None
@@ -401,13 +405,16 @@ def _read_one_account(account: Account, adapter: ProductAdapter, all_cache: dict
401
405
  key = _state_key(account)
402
406
  cached = all_cache.get(key)
403
407
  if isinstance(cached, dict) and isinstance(cached.get("read_at"), (int, float)) and 0 <= now.timestamp() - cached["read_at"] < 60:
404
- return Reading(account, _meters_from_payload(cached.get("meters"))), False
408
+ cached_reason = cached.get("unread_reason")
409
+ return Reading(account, _meters_from_payload(cached.get("meters")), cached_reason if isinstance(cached_reason, str) else None), False
410
+ unread_reason = None
405
411
  try:
406
412
  meters = adapter.read_meters(account)
407
- except (OSError, ValueError, RuntimeError, subprocess.SubprocessError):
413
+ except (codex_account_meters.CodexMeterUnreadError, OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error:
408
414
  meters = None
409
- all_cache[key] = {"read_at": now.timestamp(), "meters": _meter_payload(meters)}
410
- return Reading(account, meters), True
415
+ unread_reason = str(error) or type(error).__name__
416
+ all_cache[key] = {"read_at": now.timestamp(), "meters": _meter_payload(meters), "unread_reason": unread_reason}
417
+ return Reading(account, meters, unread_reason), True
411
418
 
412
419
 
413
420
  def read_accounts(product: Product, adapter: ProductAdapter | None = None, *, all_state: dict[str, object] | None = None, now: datetime | None = None) -> tuple[Reading, ...]:
@@ -158,6 +158,7 @@ class Meters:
158
158
  class Reading:
159
159
  account: Account
160
160
  meters: Meters | None
161
+ unread_reason: str | None = None
161
162
 
162
163
 
163
164
  @dataclass(frozen=True)
@@ -318,6 +319,12 @@ class _RetryTime:
318
319
  is_known_reset: bool
319
320
 
320
321
 
322
+ def _unreadable_text(reading: Reading) -> str:
323
+ if reading.unread_reason is None:
324
+ return reading.account.name
325
+ return f"{reading.account.name} ({reading.unread_reason})"
326
+
327
+
321
328
  def _wait_reason(all_unreadable_names: Sequence[str], is_every_account_unreadable: bool, soonest: _RetryTime) -> str:
322
329
  next_event = "reset" if soonest.is_known_reset else "check"
323
330
  next_text = f"next {next_event} at {utc_time_text(soonest.at)}"
@@ -341,7 +348,7 @@ def _wait_decision(
341
348
  all_retry_times.append(_RetryTime(reset, is_known_reset=True) if reset is not None and reset > now else next_check)
342
349
  soonest = min(all_retry_times, key=lambda each_retry_time: each_retry_time.at, default=next_check)
343
350
  all_unreadable_names = [
344
- each_reading.account.name
351
+ _unreadable_text(each_reading)
345
352
  for each_reading in all_readings
346
353
  if each_reading.meters is None and each_reading.account not in all_spent_accounts
347
354
  ]
@@ -75,6 +75,31 @@ def _adapter(accounts: tuple[Account, ...], meters: dict[str, Meters | None]) ->
75
75
  )
76
76
 
77
77
 
78
+ def test_should_name_the_unread_account_and_its_reason_when_no_account_has_room() -> None:
79
+ codex_3 = _account("codex-3")
80
+ codex_4 = _account("codex-4")
81
+
82
+ def read_meter(account: Account) -> Meters:
83
+ if account is codex_3:
84
+ raise OSError("codex app-server failed: timed out")
85
+ return _meters(0, 40, short_reset=NOW + timedelta(hours=2))
86
+
87
+ adapter = ProductAdapter(lambda: (codex_3, codex_4), read_meter, "CODEX_HOME", ())
88
+ state = account_broker._load_state(account_broker.broker_state_path())
89
+ readings = account_broker.read_accounts(Product.CODEX, adapter, all_state=state, now=NOW)
90
+
91
+ decision = choose_from_readings(Product.CODEX, readings, now=NOW)
92
+ payload = account_broker.readings_payload(readings)
93
+
94
+ assert decision.action == "wait"
95
+ assert decision.reason == (
96
+ "no readable account has room; meter unreadable for codex-3 (codex app-server failed: timed out); "
97
+ "next check at 2026-10-03T01:00:00+00:00"
98
+ )
99
+ assert payload[0]["unread_reason"] == "codex app-server failed: timed out"
100
+ assert payload[1]["unread_reason"] is None
101
+
102
+
78
103
  def test_should_rank_by_room_in_the_tighter_window() -> None:
79
104
  readings = (
80
105
  Reading(_account("first"), _meters(12, 80)),
@@ -137,6 +137,59 @@ def test_should_read_codex_meters_through_injected_reader(
137
137
  assert meters.weekly_percent_left == 70.0
138
138
 
139
139
 
140
+ def _codex_meter_reads(
141
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path, all_outcomes: list[object]
142
+ ) -> list[Path]:
143
+ all_homes_read: list[Path] = []
144
+
145
+ def read_codex_meters(_codex_path: Path, codex_home: Path) -> object:
146
+ all_homes_read.append(codex_home)
147
+ outcome = all_outcomes.pop(0)
148
+ if isinstance(outcome, Exception):
149
+ raise outcome
150
+ return outcome
151
+
152
+ monkeypatch.setattr(support.codex_account_meters, "resolve_codex_path", lambda _: tmp_path / "codex")
153
+ monkeypatch.setattr(support.codex_account_meters, "read_codex_meters", read_codex_meters)
154
+ return all_homes_read
155
+
156
+
157
+ def test_should_read_codex_meters_again_after_one_failed_read(
158
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
159
+ ) -> None:
160
+ weekly = SimpleNamespace(duration_minutes=10080, used_percent=30.0, resets_at=None)
161
+ usage = SimpleNamespace(all_windows=(weekly,), short_window_percent_left=None)
162
+ unread = support.codex_account_meters.CodexMeterUnreadError("codex app-server sent no rate-limit reply")
163
+ all_homes_read = _codex_meter_reads(monkeypatch, tmp_path, [unread, usage])
164
+
165
+ meters = support.read_codex_account_meters(Account(Product.CODEX, "one", tmp_path))
166
+
167
+ assert meters is not None
168
+ assert meters.weekly_percent_left == 70.0
169
+ assert all_homes_read == [tmp_path, tmp_path]
170
+
171
+
172
+ def test_should_keep_the_unread_reason_after_two_failed_codex_reads(
173
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
174
+ ) -> None:
175
+ monkeypatch.setattr(support, "broker_state_path", lambda: tmp_path / "broker" / "state.json")
176
+ first_unread = support.codex_account_meters.CodexMeterUnreadError("codex app-server failed: timed out")
177
+ second_unread = support.codex_account_meters.CodexMeterUnreadError("codex app-server sent no rate-limit reply")
178
+ all_homes_read = _codex_meter_reads(monkeypatch, tmp_path, [first_unread, second_unread])
179
+ account = Account(Product.CODEX, "codex-3", tmp_path)
180
+ adapter = ProductAdapter(lambda: (account,), support.read_codex_account_meters, "CODEX_HOME", ())
181
+ state = support._load_state(support.broker_state_path())
182
+ now = datetime(2026, 10, 3, tzinfo=timezone.utc)
183
+
184
+ first_reading = support.read_accounts(Product.CODEX, adapter, all_state=state, now=now)[0]
185
+ cached_reading = support.read_accounts(Product.CODEX, adapter, all_state=state, now=now)[0]
186
+
187
+ assert first_reading.meters is None
188
+ assert first_reading.unread_reason == "codex app-server sent no rate-limit reply"
189
+ assert cached_reading.unread_reason == "codex app-server sent no rate-limit reply"
190
+ assert all_homes_read == [tmp_path, tmp_path]
191
+
192
+
140
193
  def test_should_cache_account_reading_under_injected_state_path(
141
194
  monkeypatch: pytest.MonkeyPatch, tmp_path: Path
142
195
  ) -> None: