claude-dev-env 8.46.18 → 8.46.19

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.46.18",
3
+ "version": "8.46.19",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -128,13 +128,22 @@ def readings_payload(all_readings: Sequence[Reading]) -> list[dict[str, object]]
128
128
  ]
129
129
 
130
130
 
131
+ def _stored_reset(raw: object, now: datetime) -> datetime | None:
132
+ if not isinstance(raw, (int, float)) or raw <= now.timestamp():
133
+ return None
134
+ try:
135
+ return datetime.fromtimestamp(raw, timezone.utc)
136
+ except (ValueError, OSError, OverflowError):
137
+ return None
138
+
139
+
131
140
  def _spent_for_readings(all_readings: Sequence[Reading], all_state: dict[str, object], now: datetime) -> tuple[frozenset[Account], dict[Account, datetime]]:
132
141
  marks = all_state["spent"]
133
142
  spent = {}
134
143
  for each_reading in all_readings:
135
- raw = marks.get(_state_key(each_reading.account))
136
- if isinstance(raw, (int, float)) and raw > now.timestamp():
137
- spent[each_reading.account] = datetime.fromtimestamp(raw, timezone.utc)
144
+ reset = _stored_reset(marks.get(_state_key(each_reading.account)), now)
145
+ if reset is not None:
146
+ spent[each_reading.account] = reset
138
147
  return frozenset(spent), spent
139
148
 
140
149
 
@@ -147,21 +156,31 @@ def _mark_spent(all_state: dict[str, object], reading: Reading, now: datetime, r
147
156
  return until
148
157
 
149
158
 
150
- def _outside_roster_resets(product: Product, all_readings: Sequence[Reading], all_state: Mapping[str, object], now: datetime) -> tuple[datetime, ...]:
159
+ def _outside_roster_resets(product: Product, all_readings: Sequence[Reading], all_state: Mapping[str, object], now: datetime) -> dict[str, datetime]:
151
160
  marks = all_state.get("spent") if isinstance(all_state.get("spent"), dict) else {}
152
161
  all_roster_names = {each_reading.account.name for each_reading in all_readings}
153
162
  prefix = f"{product.value}:"
154
163
  placeholder_suffix = f":{Path()}"
155
- return tuple(datetime.fromtimestamp(float(each_raw), timezone.utc) for each_key, each_raw in marks.items() if isinstance(each_key, str) and each_key.startswith(prefix) and each_key.endswith(placeholder_suffix) and each_key[len(prefix):-len(placeholder_suffix)] not in all_roster_names and isinstance(each_raw, (int, float)) and each_raw > now.timestamp())
156
-
157
-
158
- def _with_outside_spent_marks(decision: Decision, all_outside_resets: Sequence[datetime]) -> Decision:
159
- if not all_outside_resets:
164
+ all_resets_by_name = {}
165
+ for each_key, each_raw in marks.items():
166
+ if not (isinstance(each_key, str) and each_key.startswith(prefix) and each_key.endswith(placeholder_suffix)):
167
+ continue
168
+ name = each_key[len(prefix):-len(placeholder_suffix)]
169
+ reset = _stored_reset(each_raw, now)
170
+ if name not in all_roster_names and reset is not None:
171
+ all_resets_by_name[name] = reset
172
+ return all_resets_by_name
173
+
174
+
175
+ def _with_outside_spent_marks(decision: Decision, all_outside_resets_by_name: Mapping[str, datetime]) -> Decision:
176
+ if not all_outside_resets_by_name:
160
177
  return decision
161
- soonest = min(all_outside_resets) if decision.resets_at is None else min(decision.resets_at, *all_outside_resets)
162
- if decision.action == "wait" and decision.resets_at == soonest:
178
+ outside_name, outside_reset = min(all_outside_resets_by_name.items(), key=lambda each_item: each_item[1])
179
+ if decision.action != "wait":
180
+ return Decision("wait", None, outside_reset, f"account {outside_name} outside the roster is spent; next reset at {_time_text(outside_reset)}", "wait")
181
+ if decision.resets_at is not None and decision.resets_at <= outside_reset:
163
182
  return decision
164
- return Decision("wait", None, soonest, f"no account has room; next reset at {_time_text(soonest)}", "wait")
183
+ return Decision("wait", None, outside_reset, f"no account has room; next reset at {_time_text(outside_reset)}", "wait")
165
184
 
166
185
 
167
186
  def _write_report(path: Path, report: Report) -> None:
@@ -328,12 +347,17 @@ def _parser() -> argparse.ArgumentParser:
328
347
 
329
348
  def _parse_spent_mark(all_accounts_by_name: Mapping[str, Reading], mark: str, product: Product) -> tuple[Reading, datetime | None]:
330
349
  name, separator, reset_text = mark.partition(":")
350
+ if not name:
351
+ raise BrokerConfigurationError(f"spent mark {mark!r} needs an account name")
331
352
  try:
332
353
  reset = datetime.fromtimestamp(float(reset_text), timezone.utc) if separator else None
333
354
  except (ValueError, OSError, OverflowError) as error:
334
355
  raise BrokerConfigurationError(f"invalid reset for account {name}") from error
335
356
  reading = all_accounts_by_name.get(name)
336
- return reading if reading is not None else Reading(Account(product, name, Path(), False), None), reset
357
+ if reading is None:
358
+ print(f"warning: spent mark names {name}, an account outside the roster; choose waits until its reset", file=sys.stderr)
359
+ return Reading(Account(product, name, Path(), False), None), reset
360
+ return reading, reset
337
361
 
338
362
 
339
363
  def _save_spent_arguments(all_marks: Sequence[str], all_readings: Sequence[Reading], all_state: dict[str, object], now: datetime, product: Product) -> None:
@@ -696,6 +696,81 @@ def test_should_run_job_through_override(
696
696
  assert calls == [b"input"]
697
697
 
698
698
 
699
+ @pytest.mark.parametrize("marked_name", ["retired", "first"])
700
+ def test_should_run_past_a_stored_spent_mark_beyond_the_platform_range(
701
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], marked_name: str
702
+ ) -> None:
703
+ account = _account("first")
704
+ second = _account("second")
705
+ monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter((account, second), {"first": _meters(80, 80), "second": _meters(80, 80)}))
706
+ _freeze_clock(monkeypatch, NOW)
707
+ marked_account = account if marked_name == "first" else Account(Product.CODEX, marked_name, Path(), False)
708
+ account_broker._save_state(
709
+ account_broker.broker_state_path(),
710
+ {"meters": {}, "spent": {account_broker._state_key(marked_account): 1e20}, "affinity": {}},
711
+ )
712
+
713
+ code = account_broker.main(("choose", "--product", "codex"))
714
+
715
+ decision = json.loads(capsys.readouterr().out)["decision"]
716
+ assert code == 0
717
+ assert decision["action"] == "run"
718
+
719
+
720
+ def test_should_name_the_outside_spent_mark_when_it_forces_a_wait(
721
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
722
+ ) -> None:
723
+ account = _account("first")
724
+ monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter((account,), {"first": _meters(80, 80)}))
725
+ _freeze_clock(monkeypatch, NOW)
726
+ reset = NOW + timedelta(hours=2)
727
+
728
+ account_broker.main(("choose", "--product", "codex", "--spent", f"visitor:{int(reset.timestamp())}"))
729
+
730
+ decision = json.loads(capsys.readouterr().out)["decision"]
731
+ assert decision["action"] == "wait"
732
+ assert decision["reason"] == f"account visitor outside the roster is spent; next reset at {account_broker._time_text(reset)}"
733
+
734
+
735
+ def test_should_warn_when_a_spent_mark_names_an_account_outside_the_roster(
736
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
737
+ ) -> None:
738
+ account = _account("first")
739
+ monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter((account,), {"first": _meters(80, 80)}))
740
+ _freeze_clock(monkeypatch, NOW)
741
+
742
+ account_broker.main(("choose", "--product", "codex", "--spent", "frist", "--spent", "first"))
743
+
744
+ all_warning_lines = capsys.readouterr().err.splitlines()
745
+ assert len(all_warning_lines) == 1
746
+ assert "frist" in all_warning_lines[0]
747
+ assert "outside the roster" in all_warning_lines[0]
748
+
749
+
750
+ @pytest.mark.parametrize("mark", ["", ":1800000000"])
751
+ def test_should_reject_a_spent_mark_without_an_account_name(
752
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], mark: str
753
+ ) -> None:
754
+ account = _account("first")
755
+ monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter((account,), {"first": _meters(80, 80)}))
756
+
757
+ code = account_broker.main(("choose", "--product", "codex", "--spent", mark))
758
+
759
+ assert code == 2
760
+ assert "account name" in capsys.readouterr().err
761
+ assert account_broker._load_state(account_broker.broker_state_path())["spent"] == {}
762
+
763
+
764
+ def test_should_serialize_a_run_and_a_wait_decision() -> None:
765
+ account = _account("first")
766
+ reset = NOW + timedelta(hours=2)
767
+
768
+ run_payload = account_broker.decision_payload(account_broker.Decision("run", account, None, "first has 80% left", "normal"))
769
+ wait_payload = account_broker.decision_payload(account_broker.Decision("wait", None, reset, "no account has room", "wait"))
770
+
771
+ assert run_payload == {"action": "run", "account": "first", "home": str(Path("/profiles/first")), "reason": "first has 80% left", "tier": "normal", "resets_at": None}
772
+ assert wait_payload == {"action": "wait", "account": None, "home": None, "reason": "no account has room", "tier": "wait", "resets_at": reset.isoformat()}
773
+
699
774
 
700
775
  def test_should_print_the_whole_rate_limit_result_for_one_home(
701
776
  monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path