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
@@ -7,8 +7,14 @@ The agent loops a code review on its pull request. Each pass is recorded::
7
7
  fix, push, review the new head
8
8
  python3 merge_review_receipt.py owner/name 12 def5678 0 -> CLEAN owner/name#12 def5678
9
9
 
10
+ A pass whose every finding is ruled out with file:line evidence counts as
11
+ clean once the rulings are posted and linked::
12
+
13
+ python3 merge_review_receipt.py owner/name 12 def5678 2 --ruled-out 2 --rulings <url>
14
+ -> CLEAN owner/name#12 def5678 (2 findings ruled out: <url>)
15
+
10
16
  ``hooks/blocking/merge_review_gate.py`` allows a merge pinned to the head of
11
- the newest pass only when that pass counted zero findings.
17
+ the newest pass only when that pass is clean.
12
18
  """
13
19
 
14
20
  from __future__ import annotations
@@ -24,14 +30,23 @@ hooks_directory = str(Path(__file__).resolve().parent.parent / "hooks")
24
30
  if hooks_directory not in sys.path:
25
31
  sys.path.insert(0, hooks_directory)
26
32
 
27
- from blocking.merge_review_gate import receipt_path
33
+ from blocking.merge_review_gate import is_clean_receipt, receipt_path
28
34
  from hooks_constants.merge_review_gate_constants import (
29
35
  RECEIPT_FINDINGS_KEY,
30
36
  RECEIPT_HEAD_KEY,
37
+ RECEIPT_RULED_OUT_KEY,
38
+ RECEIPT_RULINGS_KEY,
31
39
  )
32
40
 
33
41
 
34
- def record_review(slug: str, number: int, head: str, finding_count: int) -> Path:
42
+ def record_review(
43
+ slug: str,
44
+ number: int,
45
+ head: str,
46
+ finding_count: int,
47
+ ruled_out_count: int = 0,
48
+ rulings: str = "",
49
+ ) -> Path:
35
50
  """Write the receipt for one review pass, replacing the previous pass.
36
51
 
37
52
  Args:
@@ -39,21 +54,34 @@ def record_review(slug: str, number: int, head: str, finding_count: int) -> Path
39
54
  number: Pull request number.
40
55
  head: The commit the pass reviewed.
41
56
  finding_count: How many findings the pass reported.
57
+ ruled_out_count: How many of those findings were ruled out.
58
+ rulings: Link to the posted rulings, empty when none were ruled out.
42
59
 
43
60
  Returns:
44
61
  The receipt path written.
45
62
  """
63
+ receipt = _receipt_fields(head, finding_count, ruled_out_count, rulings)
64
+ return _write_receipt(slug, number, receipt)
65
+
66
+
67
+ def _write_receipt(slug: str, number: int, all_receipt_fields: dict[str, object]) -> Path:
46
68
  owner, repo = slug.split("/", 1)
47
69
  path = receipt_path(owner, repo, number)
48
70
  path.parent.mkdir(parents=True, exist_ok=True)
49
- path.write_text(
50
- json.dumps({RECEIPT_HEAD_KEY: head, RECEIPT_FINDINGS_KEY: finding_count})
51
- + "\n",
52
- encoding="utf-8",
53
- )
71
+ path.write_text(json.dumps(all_receipt_fields) + "\n", encoding="utf-8")
54
72
  return path
55
73
 
56
74
 
75
+ def _receipt_fields(
76
+ head: str, finding_count: int, ruled_out_count: int, rulings: str
77
+ ) -> dict[str, object]:
78
+ receipt: dict[str, object] = {RECEIPT_HEAD_KEY: head, RECEIPT_FINDINGS_KEY: finding_count}
79
+ if ruled_out_count:
80
+ receipt[RECEIPT_RULED_OUT_KEY] = ruled_out_count
81
+ receipt[RECEIPT_RULINGS_KEY] = rulings
82
+ return receipt
83
+
84
+
57
85
  def main(all_arguments: Sequence[str], stdout: TextIO = sys.stdout) -> int:
58
86
  """Record one pass and print its verdict line.
59
87
 
@@ -62,17 +90,33 @@ def main(all_arguments: Sequence[str], stdout: TextIO = sys.stdout) -> int:
62
90
  stdout: Stream that receives the verdict.
63
91
 
64
92
  Returns:
65
- 0 for a clean pass, and 1 for a pass with findings.
93
+ 0 for a clean pass, and 1 for a pass with findings still open.
66
94
  """
67
95
  parser = argparse.ArgumentParser(prog="merge_review_receipt")
68
96
  parser.add_argument("slug", help="Repository as owner/name.")
69
97
  parser.add_argument("number", type=int, help="Pull request number.")
70
98
  parser.add_argument("head", help="The head commit the pass reviewed.")
71
99
  parser.add_argument("findings", type=int, help="Findings the pass reported.")
100
+ parser.add_argument(
101
+ "--ruled-out", type=int, default=0, help="Findings ruled out with file:line evidence."
102
+ )
103
+ parser.add_argument("--rulings", default="", help="Link to the posted rulings.")
72
104
  parsed = parser.parse_args(all_arguments)
73
- record_review(parsed.slug, parsed.number, parsed.head, parsed.findings)
74
- if parsed.findings == 0:
75
- stdout.write(f"CLEAN {parsed.slug}#{parsed.number} {parsed.head}\n")
105
+ rulings = parsed.rulings.strip()
106
+ if parsed.ruled_out < 0 or parsed.ruled_out > parsed.findings:
107
+ parser.error("--ruled-out must be between 0 and the finding count")
108
+ if parsed.ruled_out and not rulings:
109
+ parser.error("--ruled-out needs --rulings, a link to the posted rulings")
110
+ if rulings and not parsed.ruled_out:
111
+ parser.error("--rulings needs --ruled-out, the count of findings it rules out")
112
+ receipt = _receipt_fields(parsed.head, parsed.findings, parsed.ruled_out, rulings)
113
+ _write_receipt(parsed.slug, parsed.number, receipt)
114
+ owner, repo = parsed.slug.split("/", 1)
115
+ if is_clean_receipt(receipt, owner, repo, parsed.number):
116
+ ruled_out_note = (
117
+ f" ({parsed.ruled_out} findings ruled out: {rulings})" if parsed.ruled_out else ""
118
+ )
119
+ stdout.write(f"CLEAN {parsed.slug}#{parsed.number} {parsed.head}{ruled_out_note}\n")
76
120
  return 0
77
121
  stdout.write(
78
122
  f"REVIEWED {parsed.slug}#{parsed.number} {parsed.head} {parsed.findings} findings\n"
@@ -226,6 +226,18 @@ def test_should_wait_for_main_5_hour_reset_while_its_week_resets_days_away() ->
226
226
  assert decision.resets_at == short_reset
227
227
 
228
228
 
229
+ def test_should_check_main_again_in_an_hour_when_a_blocking_window_has_no_reset() -> None:
230
+ main = Reading(
231
+ _account("main", Product.CLAUDE, main=True),
232
+ Meters(5, None, 1, NOW + timedelta(hours=4)),
233
+ )
234
+
235
+ decision = choose_from_readings(Product.CLAUDE, (main,), now=NOW)
236
+
237
+ assert decision.action == "wait"
238
+ assert decision.resets_at == NOW + timedelta(hours=1)
239
+
240
+
229
241
  @pytest.mark.parametrize(
230
242
  ("short_left", "weekly_left", "weekly_reset", "expected_reset"),
231
243
  (
@@ -16,6 +16,7 @@ import pytest
16
16
 
17
17
  import account_broker_support as support
18
18
  from account_broker_support import Account, Meters, Product, ProductAdapter
19
+ from codex_account_meters import CodexAccountMeters, UsageWindow
19
20
 
20
21
 
21
22
  def test_should_load_claude_roster_from_both_lists(
@@ -184,16 +185,24 @@ def test_should_read_claude_meters_through_injected_probe(
184
185
  def test_should_read_codex_meters_through_injected_reader(
185
186
  monkeypatch: pytest.MonkeyPatch, tmp_path: Path
186
187
  ) -> None:
187
- short = SimpleNamespace(duration_minutes=300, used_percent=20.0, resets_at=None)
188
- weekly = SimpleNamespace(duration_minutes=10080, used_percent=30.0, resets_at=None)
189
- 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
+ )
190
197
  monkeypatch.setattr(support.codex_account_meters, "resolve_codex_path", lambda _: tmp_path / "codex")
191
198
  monkeypatch.setattr(support.codex_account_meters, "read_codex_meters", lambda *_: usage)
192
199
 
193
200
  meters = support.read_codex_account_meters(Account(Product.CODEX, "one", tmp_path))
194
201
 
195
202
  assert meters.session_percent_left == 80.0
196
- 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
197
206
 
198
207
 
199
208
  def _codex_meter_reads(
@@ -261,8 +270,7 @@ def test_should_read_claude_meters_after_transient_failures_with_growing_delays(
261
270
  def test_should_read_codex_meters_after_transient_failures_with_growing_delays(
262
271
  monkeypatch: pytest.MonkeyPatch, tmp_path: Path
263
272
  ) -> None:
264
- weekly = SimpleNamespace(duration_minutes=10080, used_percent=30.0, resets_at=None)
265
- usage = SimpleNamespace(all_windows=(weekly,), short_window_percent_left=None)
273
+ usage = CodexAccountMeters((UsageWindow(10080, 30.0, None),))
266
274
  unread = support.codex_account_meters.CodexMeterUnreadError("codex app-server sent no rate-limit reply")
267
275
  all_homes_read = _codex_meter_reads(monkeypatch, tmp_path, [unread, unread, usage])
268
276
  all_delays = _recorded_sleeps(monkeypatch)
@@ -275,6 +283,24 @@ def test_should_read_codex_meters_after_transient_failures_with_growing_delays(
275
283
  assert all_delays == [1.0, 2.0]
276
284
 
277
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
+
278
304
  def test_should_keep_the_unread_reason_after_every_claude_read_fails(
279
305
  monkeypatch: pytest.MonkeyPatch, tmp_path: Path
280
306
  ) -> None:
@@ -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"
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
  }