claude-dev-env 8.78.3 → 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 (68) 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_over_review_offer_gate.py +114 -0
  10. package/hooks/blocking/merge_review_gate.py +65 -3
  11. package/hooks/blocking/pr_lifecycle_skill_gate.py +6 -7
  12. package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +5 -1
  13. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
  14. package/hooks/blocking/test_cloud_graphql_gate.py +210 -0
  15. package/hooks/blocking/test_headless_claude_broker_gate.py +12 -7
  16. package/hooks/blocking/test_merge_over_review_offer_gate.py +138 -0
  17. package/hooks/blocking/test_merge_review_gate.py +63 -0
  18. package/hooks/blocking/test_pr_lifecycle_skill_gate.py +3 -0
  19. package/hooks/conftest.py +1 -1
  20. package/hooks/features/bash-dispatchers.md +3 -0
  21. package/hooks/features/conduct-gates.md +5 -2
  22. package/hooks/hooks.json +10 -0
  23. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -0
  24. package/hooks/hooks_constants/cloud_graphql_gate_constants.py +116 -0
  25. package/hooks/hooks_constants/cloud_session.py +17 -0
  26. package/hooks/hooks_constants/cloud_session_constants.py +11 -0
  27. package/hooks/hooks_constants/gh_command_words.py +52 -0
  28. package/hooks/hooks_constants/gh_command_words_constants.py +64 -0
  29. package/hooks/hooks_constants/headless_claude_broker_gate_constants.py +1 -7
  30. package/hooks/hooks_constants/hook_block_logger.py +21 -3
  31. package/hooks/hooks_constants/merge_over_review_offer_gate_constants.py +34 -0
  32. package/hooks/hooks_constants/merge_review_gate_constants.py +14 -3
  33. package/hooks/hooks_constants/mod_handoff.py +2 -3
  34. package/hooks/hooks_constants/mod_handoff_constants.py +0 -4
  35. package/hooks/hooks_constants/piped_pytest_blocker_constants.py +27 -2
  36. package/hooks/hooks_constants/pr_lifecycle_skill_gate_constants.py +0 -1
  37. package/hooks/hooks_constants/shell_command_evaluation_constants.py +67 -0
  38. package/hooks/hooks_constants/shell_command_substitution_constants.py +25 -0
  39. package/hooks/hooks_constants/shell_command_wrappers.py +327 -12
  40. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -1
  41. package/hooks/hooks_constants/test_cloud_session.py +27 -0
  42. package/hooks/hooks_constants/test_gh_command_words.py +45 -0
  43. package/hooks/hooks_constants/test_hook_block_logger.py +61 -0
  44. package/hooks/hooks_constants/test_mod_handoff.py +3 -2
  45. package/hooks/hooks_constants/test_piped_pytest_blocker_constants.py +11 -0
  46. package/hooks/hooks_constants/test_shell_command_wrappers.py +125 -0
  47. package/hooks/test_conftest.py +1 -1
  48. package/package.json +1 -1
  49. package/scripts/account_broker.py +23 -11
  50. package/scripts/account_broker_support.py +23 -14
  51. package/scripts/codex_account_meters.py +30 -43
  52. package/scripts/conftest.py +25 -0
  53. package/scripts/dev_env_scripts_constants/account_broker_constants.py +36 -25
  54. package/scripts/dev_env_scripts_constants/claude_account_constants.py +0 -9
  55. package/scripts/dev_env_scripts_constants/codex_account_constants.py +0 -18
  56. package/scripts/dev_env_scripts_constants/test_account_broker_constants.py +57 -14
  57. package/scripts/dev_env_scripts_constants/test_codex_account_constants.py +119 -0
  58. package/scripts/merge_review_receipt.py +56 -12
  59. package/scripts/policy_lint/adapter_configuration.py +5 -0
  60. package/scripts/policy_lint/config/constants.py +1 -0
  61. package/scripts/test_account_broker.py +145 -3
  62. package/scripts/test_account_broker_support.py +88 -6
  63. package/scripts/test_claude_chain_usage.py +3 -11
  64. package/scripts/test_codex_account_meters.py +26 -14
  65. package/scripts/test_merge_review_receipt.py +86 -0
  66. package/scripts/test_shared_tree_paths.py +58 -36
  67. package/scripts/tests/test_adapter_configuration.py +8 -0
  68. package/settings.json +3 -0
@@ -8,6 +8,7 @@ import subprocess
8
8
  import sys
9
9
  import time
10
10
  from datetime import datetime, timezone
11
+ from collections.abc import Callable
11
12
  from pathlib import Path
12
13
  from types import SimpleNamespace
13
14
 
@@ -15,6 +16,7 @@ import pytest
15
16
 
16
17
  import account_broker_support as support
17
18
  from account_broker_support import Account, Meters, Product, ProductAdapter
19
+ from codex_account_meters import CodexAccountMeters, UsageWindow
18
20
 
19
21
 
20
22
  def test_should_load_claude_roster_from_both_lists(
@@ -45,6 +47,61 @@ def _claude_home_with_extras(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ex
45
47
  return main_home
46
48
 
47
49
 
50
+ def test_should_name_a_chain_account_profile_from_its_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
51
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
52
+ monkeypatch.delenv("LLM_SETTINGS_PROFILES_ROOT", raising=False)
53
+ main_home = tmp_path / ".claude"
54
+ main_home.mkdir()
55
+ profiles_root = tmp_path / ".claude-profiles"
56
+ launcher = "C:\\bin\\claude-editor.cmd"
57
+ (main_home / "claude-chain.json").write_text(
58
+ json.dumps(
59
+ {
60
+ "chain": [
61
+ {"command": launcher, "credentials_path": str(profiles_root / "editor" / ".credentials.json")},
62
+ {"command": "C:\\bin\\other.cmd", "credentials_path": str(tmp_path / "elsewhere" / ".credentials.json")},
63
+ {"command": "C:\\bin\\reserved.cmd", "credentials_path": str(profiles_root / "main" / ".credentials.json")},
64
+ ]
65
+ }
66
+ ),
67
+ encoding="utf-8",
68
+ )
69
+ (main_home / "extra-profiles.json").write_text('["extra"]', encoding="utf-8")
70
+
71
+ accounts = support.load_claude_accounts()
72
+
73
+ assert [(each_account.name, each_account.profile) for each_account in accounts] == [
74
+ ("main", None),
75
+ (launcher, "editor"),
76
+ ("C:\\bin\\other.cmd", None),
77
+ ("C:\\bin\\reserved.cmd", None),
78
+ ("extra", "extra"),
79
+ ]
80
+
81
+
82
+ @pytest.mark.parametrize(
83
+ ("target_parts", "expected_profile"),
84
+ [(("claude-profiles-link-target", "target"), None), ((".claude-profiles", "target"), "target")],
85
+ )
86
+ def test_should_name_an_extra_account_profile_from_its_resolved_home(
87
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path, create_directory_link: Callable[..., None], target_parts: tuple[str, str], expected_profile: str | None
88
+ ) -> None:
89
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
90
+ monkeypatch.delenv("LLM_SETTINGS_PROFILES_ROOT", raising=False)
91
+ main_home = tmp_path / ".claude"
92
+ main_home.mkdir()
93
+ profiles_root = tmp_path / ".claude-profiles"
94
+ profiles_root.mkdir()
95
+ target = tmp_path.joinpath(*target_parts)
96
+ target.mkdir(parents=True)
97
+ create_directory_link(from_link=profiles_root / "alias", to_target=target)
98
+ (main_home / "extra-profiles.json").write_text('["alias"]', encoding="utf-8")
99
+
100
+ accounts = support.load_claude_accounts()
101
+
102
+ assert [(each_account.name, each_account.profile) for each_account in accounts[1:]] == [("target", expected_profile)]
103
+
104
+
48
105
  def test_should_leave_claude_priorities_unset_without_an_order_file(
49
106
  monkeypatch: pytest.MonkeyPatch, tmp_path: Path
50
107
  ) -> None:
@@ -128,16 +185,24 @@ def test_should_read_claude_meters_through_injected_probe(
128
185
  def test_should_read_codex_meters_through_injected_reader(
129
186
  monkeypatch: pytest.MonkeyPatch, tmp_path: Path
130
187
  ) -> None:
131
- short = SimpleNamespace(duration_minutes=300, used_percent=20.0, resets_at=None)
132
- weekly = SimpleNamespace(duration_minutes=10080, used_percent=30.0, resets_at=None)
133
- usage = SimpleNamespace(all_windows=(short, weekly), short_window_percent_left=80.0)
188
+ short_reset = datetime(2026, 10, 3, 5, tzinfo=timezone.utc)
189
+ unknown_length_reset = datetime(2026, 10, 9, tzinfo=timezone.utc)
190
+ usage = CodexAccountMeters(
191
+ (
192
+ UsageWindow(300, 20.0, short_reset),
193
+ UsageWindow(10080, 30.0, None),
194
+ UsageWindow(None, 50.0, unknown_length_reset),
195
+ )
196
+ )
134
197
  monkeypatch.setattr(support.codex_account_meters, "resolve_codex_path", lambda _: tmp_path / "codex")
135
198
  monkeypatch.setattr(support.codex_account_meters, "read_codex_meters", lambda *_: usage)
136
199
 
137
200
  meters = support.read_codex_account_meters(Account(Product.CODEX, "one", tmp_path))
138
201
 
139
202
  assert meters.session_percent_left == 80.0
140
- assert meters.weekly_percent_left == 70.0
203
+ assert meters.session_resets_at == short_reset
204
+ assert meters.weekly_percent_left == 50.0
205
+ assert meters.weekly_resets_at == unknown_length_reset
141
206
 
142
207
 
143
208
  def _codex_meter_reads(
@@ -205,8 +270,7 @@ def test_should_read_claude_meters_after_transient_failures_with_growing_delays(
205
270
  def test_should_read_codex_meters_after_transient_failures_with_growing_delays(
206
271
  monkeypatch: pytest.MonkeyPatch, tmp_path: Path
207
272
  ) -> None:
208
- weekly = SimpleNamespace(duration_minutes=10080, used_percent=30.0, resets_at=None)
209
- usage = SimpleNamespace(all_windows=(weekly,), short_window_percent_left=None)
273
+ usage = CodexAccountMeters((UsageWindow(10080, 30.0, None),))
210
274
  unread = support.codex_account_meters.CodexMeterUnreadError("codex app-server sent no rate-limit reply")
211
275
  all_homes_read = _codex_meter_reads(monkeypatch, tmp_path, [unread, unread, usage])
212
276
  all_delays = _recorded_sleeps(monkeypatch)
@@ -219,6 +283,24 @@ def test_should_read_codex_meters_after_transient_failures_with_growing_delays(
219
283
  assert all_delays == [1.0, 2.0]
220
284
 
221
285
 
286
+ def test_should_stop_at_once_when_no_codex_executable_is_found(
287
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
288
+ ) -> None:
289
+ unpatched_resolve_codex_path = support.codex_account_meters.resolve_codex_path
290
+ all_homes_read = _codex_meter_reads(monkeypatch, tmp_path, [])
291
+ monkeypatch.setattr(support.codex_account_meters, "resolve_codex_path", unpatched_resolve_codex_path)
292
+ monkeypatch.setenv("PATH", "")
293
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
294
+ all_delays = _recorded_sleeps(monkeypatch)
295
+
296
+ reading = _first_reading(monkeypatch, tmp_path, Account(Product.CODEX, "one", tmp_path), support.read_codex_account_meters)[0]
297
+
298
+ assert reading.meters is None
299
+ assert reading.unread_reason == "no codex executable on PATH or at its install path"
300
+ assert all_homes_read == []
301
+ assert all_delays == []
302
+
303
+
222
304
  def test_should_keep_the_unread_reason_after_every_claude_read_fails(
223
305
  monkeypatch: pytest.MonkeyPatch, tmp_path: Path
224
306
  ) -> None:
@@ -2,12 +2,11 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import importlib
6
- import os
7
5
  import subprocess
8
6
  import sys
9
7
  import urllib.error
10
8
  from datetime import datetime, timezone
9
+ from collections.abc import Callable
11
10
  from pathlib import Path
12
11
 
13
12
  import pytest
@@ -105,18 +104,11 @@ def test_probe_rejects_missing_access_token_for_another_account(
105
104
  usage.probe_account_meters(Path("home") / ".claude-profiles" / "ev" / ".credentials.json")
106
105
 
107
106
 
108
- def _create_directory_link(*, from_link: Path, to_target: Path) -> None:
109
- if sys.platform.startswith("win32"):
110
- importlib.import_module("_winapi").CreateJunction(str(to_target), str(from_link))
111
- return
112
- os.symlink(to_target, from_link, target_is_directory=True)
113
-
114
-
115
- def test_scripts_directory_follows_the_installed_scripts_link(tmp_path: Path) -> None:
107
+ def test_scripts_directory_follows_the_installed_scripts_link(tmp_path: Path, create_directory_link: Callable[..., None]) -> None:
116
108
  home = tmp_path.resolve()
117
109
  (home / ".agents" / "scripts").mkdir(parents=True)
118
110
  (home / ".claude").mkdir()
119
- _create_directory_link(
111
+ create_directory_link(
120
112
  from_link=home / ".claude" / "scripts", to_target=home / ".agents" / "scripts"
121
113
  )
122
114
 
@@ -11,6 +11,7 @@ from pathlib import Path
11
11
  import pytest
12
12
 
13
13
  from codex_account_meters import (
14
+ CodexExecutableNotFoundError,
14
15
  CodexMeterUnreadError,
15
16
  exchange_with_app_server,
16
17
  parse_rate_limits_reply,
@@ -30,7 +31,7 @@ def reply_line(rate_limits: object) -> str:
30
31
 
31
32
 
32
33
  class TestParseRateLimitsReply:
33
- def should_measure_room_by_the_tightest_window(self) -> None:
34
+ def should_read_every_window(self) -> None:
34
35
  meters = parse_rate_limits_reply(
35
36
  reply_line(
36
37
  {
@@ -47,7 +48,7 @@ class TestParseRateLimitsReply:
47
48
  }
48
49
  )
49
50
  )
50
- assert meters.percent_left == 15.0
51
+ assert [each.used_percent for each in meters.all_windows] == [30.0, 85.0]
51
52
  assert [each.duration_minutes for each in meters.all_windows] == [300, 10080]
52
53
  assert meters.all_windows[0].resets_at == datetime.fromtimestamp(
53
54
  RESET_SECONDS, tz=timezone.utc
@@ -62,11 +63,11 @@ class TestParseRateLimitsReply:
62
63
  }
63
64
  )
64
65
  )
65
- assert meters.percent_left == 60.0
66
+ assert [each.used_percent for each in meters.all_windows] == [40.0]
66
67
 
67
68
  def should_clamp_used_percent_past_full(self) -> None:
68
69
  meters = parse_rate_limits_reply(reply_line({"primary": {"usedPercent": 130}}))
69
- assert meters.percent_left == 0.0
70
+ assert [each.used_percent for each in meters.all_windows] == [100.0]
70
71
 
71
72
  @pytest.mark.parametrize(
72
73
  "line",
@@ -103,20 +104,31 @@ class TestShortWindowPercentLeft:
103
104
  )
104
105
  assert meters.short_window_percent_left is None
105
106
 
107
+ def should_count_a_window_without_a_length_as_weekly(self) -> None:
108
+ meters = parse_rate_limits_reply(reply_line({"primary": {"usedPercent": 90}}))
109
+ assert meters.short_window_percent_left is None
110
+ assert meters.weekly_window is not None
111
+ assert meters.weekly_window.used_percent == 90.0
112
+
106
113
 
107
- class TestBlockingReset:
108
- def should_name_when_the_last_blocking_window_resets(self) -> None:
114
+ class TestTightestWindows:
115
+ def should_pick_the_most_used_window_in_each_class(self) -> None:
109
116
  meters = parse_rate_limits_reply(
110
117
  reply_line(
111
118
  {
112
- "primary": {"usedPercent": 95, "resetsAt": RESET_SECONDS},
113
- "secondary": {"usedPercent": 99, "resetsAt": RESET_SECONDS + 3600},
119
+ "primary": {"usedPercent": 70, "windowDurationMins": 300, "resetsAt": RESET_SECONDS},
120
+ "secondary": {"usedPercent": 20, "windowDurationMins": 10080, "resetsAt": RESET_SECONDS + 3600},
114
121
  }
115
122
  )
116
123
  )
117
- assert meters.resets_before_room(10.0) == datetime.fromtimestamp(
118
- RESET_SECONDS + 3600, tz=timezone.utc
119
- )
124
+ assert meters.short_window is not None
125
+ assert meters.short_window.resets_at == datetime.fromtimestamp(RESET_SECONDS, tz=timezone.utc)
126
+ assert meters.weekly_window is not None
127
+ assert meters.weekly_window.resets_at == datetime.fromtimestamp(RESET_SECONDS + 3600, tz=timezone.utc)
128
+
129
+ def should_name_no_weekly_window_for_a_short_only_account(self) -> None:
130
+ meters = parse_rate_limits_reply(reply_line({"primary": {"usedPercent": 70, "windowDurationMins": 300}}))
131
+ assert meters.weekly_window is None
120
132
 
121
133
 
122
134
  class TestReadCodexMeters:
@@ -141,7 +153,7 @@ class TestReadCodexMeters:
141
153
  ]
142
154
 
143
155
  meters = read_codex_meters(Path("codex"), Path("profiles") / "codex-1", fake_exchange)
144
- assert meters.percent_left == 80.0
156
+ assert [each.used_percent for each in meters.all_windows] == [20.0]
145
157
  assert all_calls == [
146
158
  (
147
159
  Path("codex"),
@@ -215,7 +227,7 @@ class TestExchangeWithAppServer:
215
227
 
216
228
  meters = read_codex_meters(fake_codex, tmp_path / "codex-2", exchange_with_app_server)
217
229
 
218
- assert meters.percent_left == 88.0
230
+ assert [each.used_percent for each in meters.all_windows] == [12.0]
219
231
 
220
232
 
221
233
  class TestRequestMessages:
@@ -234,5 +246,5 @@ class TestResolveCodexPath:
234
246
  ) -> None:
235
247
  monkeypatch.setenv("PATH", "")
236
248
  monkeypatch.setattr(Path, "home", lambda: tmp_path)
237
- with pytest.raises(FileNotFoundError):
249
+ with pytest.raises(CodexExecutableNotFoundError):
238
250
  resolve_codex_path(None)
@@ -11,6 +11,7 @@ import pytest
11
11
  import merge_review_receipt
12
12
 
13
13
  HEAD = "abc1234def5678abc1234def5678abc1234def56"
14
+ RULINGS_LINK = "https://github.com/acme/tools/pull/12#issuecomment-1"
14
15
 
15
16
 
16
17
  @pytest.fixture(autouse=True)
@@ -36,3 +37,88 @@ def test_record_review_replaces_the_previous_pass(receipt_folder: Path) -> None:
36
37
  path = merge_review_receipt.record_review("Acme/Tools", 12, HEAD, 0)
37
38
  assert path == receipt_folder / "acme__tools__12.json"
38
39
  assert json.loads(path.read_text(encoding="utf-8")) == {"head": HEAD, "findings": 0}
40
+
41
+
42
+ def test_should_print_clean_when_every_finding_is_ruled_out_with_a_link() -> None:
43
+ verdict_stream = StringIO()
44
+ exit_code = merge_review_receipt.main(
45
+ ["acme/tools", "12", HEAD, "2", "--ruled-out", "2", "--rulings", RULINGS_LINK],
46
+ verdict_stream,
47
+ )
48
+ assert exit_code == 0
49
+ assert verdict_stream.getvalue() == (
50
+ f"CLEAN acme/tools#12 {HEAD} (2 findings ruled out: {RULINGS_LINK})\n"
51
+ )
52
+
53
+
54
+ def test_should_record_the_rulings_link_without_surrounding_whitespace(
55
+ receipt_folder: Path,
56
+ ) -> None:
57
+ verdict_stream = StringIO()
58
+ exit_code = merge_review_receipt.main(
59
+ ["acme/tools", "12", HEAD, "2", "--ruled-out", "2", "--rulings", f" {RULINGS_LINK}\n"],
60
+ verdict_stream,
61
+ )
62
+ assert exit_code == 0
63
+ assert verdict_stream.getvalue() == (
64
+ f"CLEAN acme/tools#12 {HEAD} (2 findings ruled out: {RULINGS_LINK})\n"
65
+ )
66
+ (receipt_file,) = receipt_folder.rglob("*.json")
67
+ assert json.loads(receipt_file.read_text(encoding="utf-8"))["rulings"] == RULINGS_LINK
68
+
69
+
70
+ def test_should_keep_the_raw_finding_count_beside_the_ruled_out_count(
71
+ receipt_folder: Path,
72
+ ) -> None:
73
+ path = merge_review_receipt.record_review("acme/tools", 12, HEAD, 2, 2, RULINGS_LINK)
74
+ assert json.loads(path.read_text(encoding="utf-8")) == {
75
+ "head": HEAD,
76
+ "findings": 2,
77
+ "ruled_out": 2,
78
+ "rulings": RULINGS_LINK,
79
+ }
80
+
81
+
82
+ def test_should_report_reviewed_when_some_findings_stay_open() -> None:
83
+ verdict_stream = StringIO()
84
+ exit_code = merge_review_receipt.main(
85
+ ["acme/tools", "12", HEAD, "3", "--ruled-out", "2", "--rulings", RULINGS_LINK],
86
+ verdict_stream,
87
+ )
88
+ assert exit_code == 1
89
+ assert verdict_stream.getvalue() == f"REVIEWED acme/tools#12 {HEAD} 3 findings\n"
90
+
91
+
92
+ @pytest.mark.parametrize(
93
+ "extra_arguments",
94
+ [["--ruled-out", "2"], ["--ruled-out", "4", "--rulings", RULINGS_LINK], ["--ruled-out", "-1"]],
95
+ )
96
+ def test_should_refuse_a_ruled_out_count_it_cannot_back(extra_arguments: list[str]) -> None:
97
+ with pytest.raises(SystemExit):
98
+ merge_review_receipt.main(["acme/tools", "12", HEAD, "3", *extra_arguments], StringIO())
99
+
100
+
101
+ @pytest.mark.parametrize(
102
+ "all_arguments",
103
+ [
104
+ ["acme/tools", "12", HEAD, "-1"],
105
+ ["acme/tools", "12", HEAD, "2", "--rulings", RULINGS_LINK],
106
+ ],
107
+ )
108
+ def test_should_refuse_counts_and_links_it_cannot_record(
109
+ all_arguments: list[str], receipt_folder: Path
110
+ ) -> None:
111
+ with pytest.raises(SystemExit):
112
+ merge_review_receipt.main(all_arguments, StringIO())
113
+ assert list(receipt_folder.iterdir()) == []
114
+
115
+
116
+ def test_should_report_reviewed_when_the_rulings_link_points_at_another_pull_request() -> None:
117
+ verdict_stream = StringIO()
118
+ other_link = "https://github.com/acme/tools/pull/13#issuecomment-1"
119
+ exit_code = merge_review_receipt.main(
120
+ ["acme/tools", "12", HEAD, "2", "--ruled-out", "2", "--rulings", other_link],
121
+ verdict_stream,
122
+ )
123
+ assert exit_code == 1
124
+ assert verdict_stream.getvalue() == f"REVIEWED acme/tools#12 {HEAD} 2 findings\n"
@@ -2,13 +2,13 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import importlib
6
- import os
7
- import sys
5
+ import subprocess
6
+ from collections.abc import Callable
8
7
  from pathlib import Path
9
8
 
10
9
  import pytest
11
10
 
11
+ import claude_account_profile
12
12
  import shared_tree_paths
13
13
  from dev_env_scripts_constants.grok_worker_constants import (
14
14
  CLAUDE_CONFIG_DIR_ENV_VAR,
@@ -23,27 +23,17 @@ from dev_env_scripts_constants.shared_tree_constants import (
23
23
  )
24
24
 
25
25
 
26
- def _create_directory_link(*, from_link: Path, to_target: Path) -> None:
27
- if sys.platform.startswith("win32"):
28
- importlib.import_module("_winapi").CreateJunction(
29
- str(to_target), str(from_link)
30
- )
31
- return
32
- try:
33
- os.symlink(to_target, from_link, target_is_directory=True)
34
- except OSError:
35
- pytest.skip("directory symlink creation is not available")
36
-
37
-
38
- def _build_junction_scripts_layout(tmp_path: Path) -> Path:
39
- real_scripts_directory = tmp_path / "real" / SCRIPTS_DIRECTORY_NAME
40
- real_scripts_directory.mkdir(parents=True)
41
- (real_scripts_directory / "runner.py").write_text("", encoding="utf-8")
26
+ def _build_junction_scripts_layout(
27
+ tmp_path: Path, create_directory_link: Callable[..., None]
28
+ ) -> Path:
29
+ linked_scripts_directory = tmp_path / "target" / SCRIPTS_DIRECTORY_NAME
30
+ linked_scripts_directory.mkdir(parents=True)
31
+ (linked_scripts_directory / "runner.py").write_text("", encoding="utf-8")
42
32
  managed_root = tmp_path / "root"
43
33
  managed_root.mkdir()
44
- _create_directory_link(
34
+ create_directory_link(
45
35
  from_link=managed_root / SCRIPTS_DIRECTORY_NAME,
46
- to_target=real_scripts_directory,
36
+ to_target=linked_scripts_directory,
47
37
  )
48
38
  return managed_root / SCRIPTS_DIRECTORY_NAME / "runner.py"
49
39
 
@@ -63,9 +53,9 @@ def _write_process_tree_kill_stub(from_root: Path) -> Path:
63
53
 
64
54
 
65
55
  def test_junction_scripts_dir_finds_shared_tree_on_unresolved_parent(
66
- tmp_path: Path,
56
+ tmp_path: Path, create_directory_link: Callable[..., None],
67
57
  ) -> None:
68
- module_file = _build_junction_scripts_layout(tmp_path)
58
+ module_file = _build_junction_scripts_layout(tmp_path, create_directory_link)
69
59
  expected_scripts_directory = _write_process_tree_kill_stub(tmp_path / "root")
70
60
  resolved_scripts_directory = (
71
61
  shared_tree_paths.resolve_shared_process_tree_scripts_directory(
@@ -76,10 +66,10 @@ def test_junction_scripts_dir_finds_shared_tree_on_unresolved_parent(
76
66
 
77
67
 
78
68
  def test_resolved_parent_is_used_when_link_side_has_no_shared_tree(
79
- tmp_path: Path,
69
+ tmp_path: Path, create_directory_link: Callable[..., None],
80
70
  ) -> None:
81
- module_file = _build_junction_scripts_layout(tmp_path)
82
- expected_scripts_directory = _write_process_tree_kill_stub(tmp_path / "real")
71
+ module_file = _build_junction_scripts_layout(tmp_path, create_directory_link)
72
+ expected_scripts_directory = _write_process_tree_kill_stub(tmp_path / "target")
83
73
  resolved_scripts_directory = (
84
74
  shared_tree_paths.resolve_shared_process_tree_scripts_directory(
85
75
  module_file, all_environment={}
@@ -89,9 +79,9 @@ def test_resolved_parent_is_used_when_link_side_has_no_shared_tree(
89
79
 
90
80
 
91
81
  def test_configured_root_is_used_when_neither_parent_holds_the_module(
92
- tmp_path: Path,
82
+ tmp_path: Path, create_directory_link: Callable[..., None],
93
83
  ) -> None:
94
- module_file = _build_junction_scripts_layout(tmp_path)
84
+ module_file = _build_junction_scripts_layout(tmp_path, create_directory_link)
95
85
  configured_root = tmp_path / "configured"
96
86
  expected_scripts_directory = _write_process_tree_kill_stub(configured_root)
97
87
  resolved_scripts_directory = (
@@ -106,9 +96,9 @@ def test_configured_root_is_used_when_neither_parent_holds_the_module(
106
96
 
107
97
 
108
98
  def test_unresolved_parent_wins_when_configured_root_also_holds_the_module(
109
- tmp_path: Path,
99
+ tmp_path: Path, create_directory_link: Callable[..., None],
110
100
  ) -> None:
111
- module_file = _build_junction_scripts_layout(tmp_path)
101
+ module_file = _build_junction_scripts_layout(tmp_path, create_directory_link)
112
102
  expected_scripts_directory = _write_process_tree_kill_stub(tmp_path / "root")
113
103
  configured_root = tmp_path / "configured"
114
104
  _write_process_tree_kill_stub(configured_root)
@@ -123,8 +113,8 @@ def test_unresolved_parent_wins_when_configured_root_also_holds_the_module(
123
113
  assert resolved_scripts_directory == expected_scripts_directory
124
114
 
125
115
 
126
- def test_missing_module_returns_unresolved_candidate(tmp_path: Path) -> None:
127
- module_file = _build_junction_scripts_layout(tmp_path)
116
+ def test_missing_module_returns_unresolved_candidate(tmp_path: Path, create_directory_link: Callable[..., None]) -> None:
117
+ module_file = _build_junction_scripts_layout(tmp_path, create_directory_link)
128
118
  expected_scripts_directory = (
129
119
  tmp_path
130
120
  / "root"
@@ -144,9 +134,9 @@ def test_missing_module_returns_unresolved_candidate(tmp_path: Path) -> None:
144
134
 
145
135
 
146
136
  def test_junction_scripts_dir_finds_advisor_scripts_on_unresolved_parent(
147
- tmp_path: Path,
137
+ tmp_path: Path, create_directory_link: Callable[..., None],
148
138
  ) -> None:
149
- module_file = _build_junction_scripts_layout(tmp_path)
139
+ module_file = _build_junction_scripts_layout(tmp_path, create_directory_link)
150
140
  expected_scripts_directory = (
151
141
  tmp_path
152
142
  / "root"
@@ -198,7 +188,7 @@ def test_generalized_resolver_serves_a_deeper_anchor(tmp_path: Path) -> None:
198
188
 
199
189
  @pytest.mark.parametrize("managed_name", (".claude", "profile-a"))
200
190
  def test_resolved_agents_scripts_find_the_verified_managed_root(
201
- tmp_path: Path, managed_name: str,
191
+ tmp_path: Path, create_directory_link: Callable[..., None], managed_name: str,
202
192
  ) -> None:
203
193
  agents_name = ".agents" if managed_name == ".claude" else f"{managed_name}.agents"
204
194
  agents_root = tmp_path / agents_name
@@ -208,7 +198,7 @@ def test_resolved_agents_scripts_find_the_verified_managed_root(
208
198
  module_file.write_text("", encoding="utf-8")
209
199
  managed_root = tmp_path / managed_name
210
200
  managed_root.mkdir()
211
- _create_directory_link(from_link=managed_root / "scripts", to_target=scripts_directory)
201
+ create_directory_link(from_link=managed_root / "scripts", to_target=scripts_directory)
212
202
  expected = _write_process_tree_kill_stub(managed_root)
213
203
  resolved_directory = shared_tree_paths.resolve_shared_process_tree_scripts_directory(module_file, {})
214
204
  assert resolved_directory == expected
@@ -227,3 +217,35 @@ def test_neighboring_managed_root_cannot_supply_a_foreign_shared_tree(tmp_path:
227
217
  resolved_directory = shared_tree_paths.resolve_shared_process_tree_scripts_directory(module_file, {})
228
218
  expected = agents_root / "_shared" / "process-tree" / "scripts"
229
219
  assert resolved_directory == expected
220
+
221
+
222
+ def test_should_fail_with_the_link_command_output_when_a_link_cannot_be_made(
223
+ tmp_path: Path,
224
+ create_directory_link: Callable[..., None],
225
+ monkeypatch: pytest.MonkeyPatch,
226
+ ) -> None:
227
+ def refuse_link(source_path: Path, link_path: Path) -> None:
228
+ raise subprocess.CalledProcessError(1, ["mklink"], b"", b"Access is denied.")
229
+
230
+ monkeypatch.setattr(claude_account_profile, "_link_directory", refuse_link)
231
+
232
+ with pytest.raises(pytest.fail.Exception) as failure:
233
+ create_directory_link(from_link=tmp_path / "link", to_target=tmp_path)
234
+
235
+ assert "Access is denied." in str(failure.value)
236
+
237
+
238
+ def test_should_fail_with_the_os_error_when_a_symlink_cannot_be_made(
239
+ tmp_path: Path,
240
+ create_directory_link: Callable[..., None],
241
+ monkeypatch: pytest.MonkeyPatch,
242
+ ) -> None:
243
+ def refuse_link(source_path: Path, link_path: Path) -> None:
244
+ raise PermissionError("Operation not permitted")
245
+
246
+ monkeypatch.setattr(claude_account_profile, "_link_directory", refuse_link)
247
+
248
+ with pytest.raises(pytest.fail.Exception) as failure:
249
+ create_directory_link(from_link=tmp_path / "link", to_target=tmp_path)
250
+
251
+ assert "Operation not permitted" in str(failure.value)
@@ -58,6 +58,14 @@ def test_pull_request_lifecycle_gate_registration_is_clean(tmp_path: Path) -> No
58
58
  assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
59
59
 
60
60
 
61
+ def test_new_merge_offer_gate_registration_is_clean(tmp_path: Path) -> None:
62
+ current_document = _hook_document(
63
+ ["hooks/blocking/merge_over_review_offer_gate.py"],
64
+ '{"hooks": {}}',
65
+ )
66
+ assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
67
+
68
+
61
69
  def test_new_step_note_gate_registration_is_clean(tmp_path: Path) -> None:
62
70
  current_document = _hook_document(
63
71
  ["hooks/blocking/step_note_gate.py"],
package/settings.json CHANGED
@@ -22,5 +22,8 @@
22
22
  "Updating or installing the claude-dev-env package is allowed: running `npx claude-dev-env@latest --update` or `npx --yes claude-dev-env@latest --update --target <claude config dir>` is the user's own daily environment sync from the user's own public package, and its writes to the Claude config directory are the intended result. This covers only that package and command.",
23
23
  "Developing the claude-dev-env repository is allowed: editing, testing, committing and pushing its settings, hooks, scripts and tests is the user's requested work on the user's own public package. It changes the package source in the repository; it does not change the running session's own configuration."
24
24
  ]
25
+ },
26
+ "env": {
27
+ "CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS": "100000"
25
28
  }
26
29
  }