claude-dev-env 8.46.18 → 8.46.20

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.
@@ -431,6 +431,26 @@ def _existing_scratchpad_root(
431
431
  return None
432
432
 
433
433
 
434
+ def _is_at_or_under_payload_working_directory(file_path: str, hook_payload: dict) -> bool:
435
+ """Return True when file_path's real path sits at or under the payload ``cwd``.
436
+
437
+ Args:
438
+ file_path: The path the write targets.
439
+ hook_payload: The PreToolUse payload that may carry ``cwd``.
440
+
441
+ Returns:
442
+ True when the payload names a working directory and file_path resolves
443
+ at or under its real path.
444
+ """
445
+ working_directory = str(hook_payload.get("cwd") or "")
446
+ if not working_directory:
447
+ return False
448
+ return _is_at_or_under_root(
449
+ _normalized_absolute_comparison_path(os.path.realpath(file_path)),
450
+ _normalized_absolute_comparison_path(os.path.realpath(working_directory)),
451
+ )
452
+
453
+
434
454
  def is_under_session_scratchpad(file_path: str, hook_payload: dict) -> bool:
435
455
  """Return True when file_path resolves under the harness session scratchpad.
436
456
 
@@ -460,13 +480,9 @@ def is_under_session_scratchpad(file_path: str, hook_payload: dict) -> bool:
460
480
  session_id = _session_id_for_scratchpad(hook_payload)
461
481
  if not session_id:
462
482
  return False
463
- real_target = os.path.realpath(file_path)
464
- working_directory = str(hook_payload.get("cwd") or "")
465
- if working_directory and _is_at_or_under_root(
466
- _normalized_absolute_comparison_path(real_target),
467
- _normalized_absolute_comparison_path(os.path.realpath(working_directory)),
468
- ):
483
+ if _is_at_or_under_payload_working_directory(file_path, hook_payload):
469
484
  return False
485
+ real_target = os.path.realpath(file_path)
470
486
  real_temp_root = os.path.realpath(tempfile.gettempdir())
471
487
  all_relative_parts = _relative_parts_under_temp_root(real_target, real_temp_root)
472
488
  return (
@@ -482,10 +498,10 @@ def is_ephemeral_path(file_path: str, hook_payload: dict | None = None) -> bool:
482
498
  scratch directories (``/tmp`` and ``$CLAUDE_JOB_DIR/tmp``), the harness
483
499
  session scratchpad, and a coding agent's own home-directory tooling such as
484
500
  ``~/.grok/runs/``. A path inside the payload's own working directory
485
- (``cwd``) is never treated as scratch, so a repository checked out under
486
- ``/tmp`` still receives full enforcement. The scratchpad match reads the
487
- session id from the payload when supplied, else the harness environment
488
- variable. The code-rules and TDD gates call the underlying predicates
501
+ (``cwd``) is never treated as scratch or agent tooling, so a repository
502
+ checked out under ``/tmp`` or ``~/.grok/runs/`` still receives full
503
+ enforcement. The scratchpad match reads the session id from the payload
504
+ when supplied, else the harness environment variable. The code-rules and TDD gates call the underlying predicates
489
505
  ``is_ephemeral_script_path``, ``is_agent_home_tooling``, and
490
506
  ``is_under_session_scratchpad`` directly.
491
507
 
@@ -501,7 +517,9 @@ def is_ephemeral_path(file_path: str, hook_payload: dict | None = None) -> bool:
501
517
  repository_root = str((hook_payload or {}).get("cwd") or "")
502
518
  if is_ephemeral_script_path(file_path, repository_root):
503
519
  return True
504
- if is_agent_home_tooling(file_path):
520
+ if is_agent_home_tooling(file_path) and not _is_at_or_under_payload_working_directory(
521
+ file_path, hook_payload or {}
522
+ ):
505
523
  return True
506
524
  return is_under_session_scratchpad(file_path, hook_payload or {})
507
525
 
@@ -357,3 +357,20 @@ def test_ephemeral_path_stays_true_for_scratchpad_file_outside_payload_cwd(
357
357
  assert (
358
358
  _SHARED_MODULE.is_ephemeral_path(str(throwaway_script), payload_with_worktree_cwd) is True
359
359
  )
360
+
361
+
362
+ def test_ephemeral_path_is_false_for_file_under_payload_cwd_inside_agent_home() -> None:
363
+ checkout_directory = os.path.join("/home/example", ".grok", "runs", "checkout")
364
+ repository_module = os.path.join(checkout_directory, "src", "service.py")
365
+
366
+ assert (
367
+ _SHARED_MODULE.is_ephemeral_path(repository_module, {"cwd": checkout_directory})
368
+ is False
369
+ )
370
+
371
+
372
+ def test_ephemeral_path_stays_true_for_agent_home_file_outside_payload_cwd() -> None:
373
+ checkout_directory = os.path.join("/home/example", ".grok", "runs", "checkout")
374
+ helper_script = os.path.join("/home/example", ".grok", "runs", "worktree-health", "health.py")
375
+
376
+ assert _SHARED_MODULE.is_ephemeral_path(helper_script, {"cwd": checkout_directory}) is True
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.20",
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