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
@@ -174,26 +174,8 @@ EXIT_CODE_ROOM: int = 0
174
174
  EXIT_CODE_NO_ROOM: int = 3
175
175
  """Check answer: the account is at or below the floor, or its meter is unread."""
176
176
 
177
- REASON_NORMAL_TEMPLATE: str = "{account} has {percent_left:.0f}% left"
178
- """Reason when an account takes the job at its normal model."""
179
-
180
- REASON_LUNA_TEMPLATE: str = (
181
- "every account is at or under {bar:.0f}% left; {account} has"
182
- " {percent_left:.0f}% and runs Luna until {stop:.0f}%"
183
- )
184
- """Reason when the job falls back to Luna."""
185
-
186
- REASON_WAIT_TEMPLATE: str = "no account has room; {account} resets first, at {reset}"
187
- """Reason when every account is out of room."""
188
-
189
- REASON_WAIT_UNREAD: str = "no account meter could be read"
190
- """Reason when no account's meter reads at all."""
191
-
192
177
  UNREAD_NOT_SIGNED_IN: str = "not signed in"
193
178
  """Unread reason when the account's Codex home holds no sign-in."""
194
179
 
195
- UNKNOWN_RESET_TEXT: str = "an unknown time"
196
- """Reset text when no blocking window carries a reset time."""
197
-
198
180
  TEXT_ENCODING: str = "utf-8"
199
181
  """Encoding for the server pipes and the JSON reports."""
@@ -5,11 +5,11 @@ from __future__ import annotations
5
5
  import dataclasses
6
6
  import importlib
7
7
  import importlib.util
8
- import os
9
8
  import shutil
10
9
  import sys
11
10
  import types
12
11
  from datetime import datetime, timedelta, timezone
12
+ from collections.abc import Callable
13
13
  from pathlib import Path
14
14
 
15
15
  import pytest
@@ -21,7 +21,9 @@ from dev_env_scripts_constants.account_broker_constants import (
21
21
  ALL_CODEX_FLOORS,
22
22
  ALL_PARENT_CLAUDE_SESSION_VARIABLES,
23
23
  CMD_SHELL_METACHARACTERS,
24
+ Account,
24
25
  JobOutcome,
26
+ Product,
25
27
  )
26
28
  from dev_env_scripts_constants.claude_account_constants import (
27
29
  MAIN_SESSION_USED_CEILING_PERCENT,
@@ -113,16 +115,9 @@ def test_parse_utc_time_converts_an_offset_timestamp() -> None:
113
115
  assert parsed.isoformat() == "2026-10-03T11:30:00+00:00"
114
116
 
115
117
 
116
- def _create_directory_link(*, from_link: Path, to_target: Path) -> None:
117
- if sys.platform.startswith("win32"):
118
- importlib.import_module("_winapi").CreateJunction(
119
- str(to_target), str(from_link)
120
- )
121
- return
122
- os.symlink(to_target, from_link, target_is_directory=True)
123
-
124
-
125
- def _build_install_layout(home: Path) -> tuple[Path, Path]:
118
+ def _build_install_layout(
119
+ home: Path, create_directory_link: Callable[..., None]
120
+ ) -> tuple[Path, Path]:
126
121
  agents_constants_directory = (
127
122
  home / ".agents" / "scripts" / "dev_env_scripts_constants"
128
123
  )
@@ -143,7 +138,7 @@ def _build_install_layout(home: Path) -> tuple[Path, Path]:
143
138
  (classifier_directory / "classifier_constants.py").write_text(
144
139
  f"ALL_USAGE_LIMIT_MARKERS = ({TEMP_TREE_MARKER!r},)\n", encoding="utf-8"
145
140
  )
146
- _create_directory_link(
141
+ create_directory_link(
147
142
  from_link=home / ".claude" / "scripts", to_target=home / ".agents" / "scripts"
148
143
  )
149
144
  linked_module = (
@@ -170,10 +165,10 @@ def _load_module_from(
170
165
 
171
166
  @pytest.mark.parametrize("entry_point", ("through_scripts_link", "through_agents_path"))
172
167
  def test_codex_usage_limit_signatures_finds_shared_tree_beside_the_scripts_link(
173
- entry_point: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
168
+ entry_point: str, tmp_path: Path, create_directory_link: Callable[..., None], monkeypatch: pytest.MonkeyPatch
174
169
  ) -> None:
175
170
  monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False)
176
- linked_module, agents_module = _build_install_layout(tmp_path / "home")
171
+ linked_module, agents_module = _build_install_layout(tmp_path / "home", create_directory_link)
177
172
  module_file = (
178
173
  linked_module if entry_point == "through_scripts_link" else agents_module
179
174
  )
@@ -188,3 +183,51 @@ def test_parent_session_variables_name_the_session_link_and_leave_the_account_ho
188
183
  assert "CLAUDE_CODE_SESSION_ID" in ALL_PARENT_CLAUDE_SESSION_VARIABLES
189
184
  assert "CLAUDECODE" in ALL_PARENT_CLAUDE_SESSION_VARIABLES
190
185
  assert "CLAUDE_CONFIG_DIR" not in ALL_PARENT_CLAUDE_SESSION_VARIABLES
186
+
187
+
188
+ def test_should_treat_accounts_that_differ_only_in_profile_as_one_account() -> None:
189
+ home = Path("/profiles/first")
190
+
191
+ assert Account(Product.CLAUDE, "first", home, profile="first") == Account(Product.CLAUDE, "first", home)
192
+
193
+
194
+ def _codex_reading(meters: broker_constants.Meters) -> broker_constants.Reading:
195
+ return broker_constants.Reading(Account(Product.CODEX, "codex-1", Path("/profiles/codex-1")), meters)
196
+
197
+
198
+ def test_should_let_a_codex_account_at_the_short_window_floor_take_luna() -> None:
199
+ weekly_reset = datetime(2026, 10, 4, tzinfo=timezone.utc)
200
+ session_reset = weekly_reset + timedelta(hours=4)
201
+ meters = broker_constants.Meters(
202
+ LUNA_TIER_SHORT_WINDOW_MINIMUM_PERCENT_LEFT, session_reset, LUNA_TIER_STOP_PERCENT_LEFT / 2, weekly_reset
203
+ )
204
+
205
+ assert broker_constants._codex_tier(
206
+ dataclasses.replace(meters, weekly_percent_left=NORMAL_TIER_MINIMUM_PERCENT_LEFT)
207
+ ) == "luna"
208
+ assert broker_constants._account_reset(_codex_reading(meters)) == weekly_reset
209
+
210
+
211
+ def test_should_name_no_codex_reset_when_a_blocking_window_has_none() -> None:
212
+ session_reset = datetime(2026, 10, 3, 2, tzinfo=timezone.utc)
213
+ meters = broker_constants.Meters(
214
+ LUNA_TIER_SHORT_WINDOW_MINIMUM_PERCENT_LEFT / 2, session_reset, LUNA_TIER_STOP_PERCENT_LEFT / 2, None
215
+ )
216
+
217
+ assert broker_constants._account_reset(_codex_reading(meters)) is None
218
+
219
+
220
+ def test_should_name_the_latest_codex_reset_when_every_blocking_window_has_one() -> None:
221
+ session_reset = datetime(2026, 10, 3, 2, tzinfo=timezone.utc)
222
+ weekly_reset = datetime(2026, 10, 4, tzinfo=timezone.utc)
223
+ meters = broker_constants.Meters(
224
+ LUNA_TIER_SHORT_WINDOW_MINIMUM_PERCENT_LEFT / 2, session_reset, LUNA_TIER_STOP_PERCENT_LEFT / 2, weekly_reset
225
+ )
226
+
227
+ assert broker_constants._account_reset(_codex_reading(meters)) == weekly_reset
228
+
229
+
230
+ def test_should_double_the_meter_read_delay_up_to_its_cap() -> None:
231
+ all_delays = [broker_constants.meter_read_delay_seconds(each_attempt) for each_attempt in range(1, 6)]
232
+
233
+ assert all_delays == [1.0, 2.0, 4.0, 8.0, 8.0]
@@ -0,0 +1,119 @@
1
+ """The Codex account constants match what docs/codex-accounts.md tells the reader."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from dev_env_scripts_constants import codex_account_constants as constants
10
+ from dev_env_scripts_constants.account_broker_constants import METER_READ_ATTEMPTS, meter_read_delay_seconds
11
+ from dev_env_scripts_constants.claude_account_constants import ALL_LAUNCHER_DIRECTORY_RELATIVE_PARTS
12
+
13
+ CODEX_ACCOUNTS_DOC_PATH = Path(__file__).resolve().parents[2] / "docs" / "codex-accounts.md"
14
+ SECTION_HEADING_PREFIX = "## "
15
+ CHOOSE_COMMAND_SUFFIX = "choose --product codex"
16
+ CODE_FENCE = "```"
17
+
18
+
19
+ def documented_section_lines(heading: str) -> list[str]:
20
+ all_doc_lines = CODEX_ACCOUNTS_DOC_PATH.read_text(encoding="utf-8").splitlines()
21
+ heading_index = all_doc_lines.index(SECTION_HEADING_PREFIX + heading)
22
+ all_section_lines: list[str] = []
23
+ for each_line in all_doc_lines[heading_index + 1 :]:
24
+ if each_line.startswith(SECTION_HEADING_PREFIX):
25
+ break
26
+ all_section_lines.append(each_line)
27
+ return all_section_lines
28
+
29
+
30
+ def documented_section(heading: str) -> str:
31
+ return " ".join(" ".join(documented_section_lines(heading)).split())
32
+
33
+
34
+ def documented_choose_sample() -> dict[str, Any]:
35
+ all_section_lines = documented_section_lines("Which account a job uses")
36
+ command_index = next(
37
+ index for index, each_line in enumerate(all_section_lines) if each_line.endswith(CHOOSE_COMMAND_SUFFIX)
38
+ )
39
+ fence_index = all_section_lines.index(CODE_FENCE, command_index)
40
+ return json.loads("\n".join(all_section_lines[command_index + 1 : fence_index]))
41
+
42
+
43
+ def documented_list(all_names: list[str]) -> str:
44
+ *all_leading_names, last_name = [f"`{each_name}`" for each_name in all_names]
45
+ if not all_leading_names:
46
+ return last_name
47
+ return ", ".join(all_leading_names) + f" and {last_name}"
48
+
49
+
50
+ def documented_percent(percent: float) -> str:
51
+ return f"{percent:g}%"
52
+
53
+
54
+ def test_fallback_roster_matches_the_documented_accounts() -> None:
55
+ expected_sentence = (
56
+ "When neither exists, the accounts are "
57
+ + documented_list(list(constants.ALL_CODEX_ACCOUNT_NAMES))
58
+ + "."
59
+ )
60
+ assert expected_sentence in documented_section("Names and order")
61
+
62
+
63
+ def test_shared_home_entries_match_the_documented_list() -> None:
64
+ expected_sentence = "They are " + documented_list(sorted(constants.ALL_SHARED_CODEX_HOME_NAMES)) + "."
65
+ assert expected_sentence in documented_section("Sign in once")
66
+
67
+
68
+ def test_sign_in_file_stays_per_account() -> None:
69
+ expected_sentence = f"The sign-in lives in that folder's `{constants.CODEX_AUTH_FILE_NAME}`."
70
+ assert expected_sentence in documented_section("Sign in once")
71
+ assert constants.CODEX_AUTH_FILE_NAME not in constants.ALL_SHARED_CODEX_HOME_NAMES
72
+
73
+
74
+ def test_launcher_matches_the_documented_name_directory_and_variable() -> None:
75
+ launcher_file_name = constants.CODEX_LAUNCHER_PROGRAM.file_name_template.format(profile_name="<name>")
76
+ launcher_directory = "/".join(["~", *ALL_LAUNCHER_DIRECTORY_RELATIVE_PARTS])
77
+ expected_sentence = (
78
+ f"Each roster account gets a launcher, `{launcher_file_name}`, in `{launcher_directory}`. "
79
+ f"It sets `{constants.CODEX_LAUNCHER_PROGRAM.environment_variable}` to that account's home "
80
+ f"and runs Codex with every argument"
81
+ )
82
+ assert expected_sentence in documented_section("Named launchers")
83
+
84
+
85
+ def test_tier_bars_match_the_documented_table() -> None:
86
+ normal_bar = documented_percent(constants.NORMAL_TIER_MINIMUM_PERCENT_LEFT)
87
+ luna_stop = documented_percent(constants.LUNA_TIER_STOP_PERCENT_LEFT)
88
+ luna_short_window_floor = documented_percent(constants.LUNA_TIER_SHORT_WINDOW_MINIMUM_PERCENT_LEFT)
89
+ all_expected_rows = [
90
+ f"| One or more accounts with more than {normal_bar} left | `{constants.TIER_NORMAL}` on the account with the most room |",
91
+ f"| No account over {normal_bar}, one or more over {luna_stop} | `{constants.TIER_LUNA}` on the account with the most room |",
92
+ f"| An account that reports a window shorter than a week, such as the 5-hour window, with under {luna_short_window_floor} of that window left | never takes `{constants.TIER_LUNA}` |",
93
+ f"| No account over {luna_stop} | `{constants.TIER_WAIT}`, naming the next reset when known |",
94
+ ]
95
+ section_text = documented_section("Which account a job uses")
96
+ for each_row in all_expected_rows:
97
+ assert each_row in section_text
98
+
99
+
100
+ def test_meter_retries_match_the_documented_table() -> None:
101
+ all_delays = [meter_read_delay_seconds(each_attempt) for each_attempt in range(1, METER_READ_ATTEMPTS)]
102
+ all_delay_texts = [f"{each_delay:g} second" + ("" if each_delay == 1 else "s") for each_delay in all_delays]
103
+ expected_row = (
104
+ f"| A meter read fails | The broker reads it again, up to {METER_READ_ATTEMPTS} reads, "
105
+ f"waiting {' and then '.join(all_delay_texts)} |"
106
+ )
107
+ assert expected_row in documented_section("Which account a job uses")
108
+
109
+
110
+ def test_documented_choose_sample_runs_normal_on_the_tightest_window() -> None:
111
+ sample = documented_choose_sample()
112
+ decision = sample["decision"]
113
+ chosen_meters = next(
114
+ each_account["meters"] for each_account in sample["accounts"] if each_account["name"] == decision["account"]
115
+ )
116
+ room = min(chosen_meters["session_percent_left"], chosen_meters["weekly_percent_left"])
117
+ assert decision["tier"] == constants.TIER_NORMAL
118
+ assert room > constants.NORMAL_TIER_MINIMUM_PERCENT_LEFT
119
+ assert decision["reason"] == f"{decision['account']} has {room:g}% left"
@@ -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"
@@ -135,6 +135,7 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
135
135
  hooks/blocking/verify_before_acting.py -> exempt
136
136
  hooks/blocking/session_title_format_gate.py -> exempt
137
137
  hooks/blocking/artifact_dark_mode_gate.py -> exempt
138
+ hooks/blocking/merge_over_review_offer_gate.py -> exempt
138
139
  hooks/blocking/some_new_blocker.py -> flagged
139
140
 
140
141
  The Bash PreToolUse dispatcher sits under ``blocking/`` for layout reasons
@@ -166,6 +167,10 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
166
167
  The artifact page gate denies a page publish whose text is unreadable in
167
168
  dark mode. It decides how a page renders and no code or safety policy.
168
169
 
170
+ The merge offer gate denies a decision card whose text offers a merge and
171
+ names open review findings. It reads only the wording of a chat card and
172
+ decides no code or safety policy; the merge review gate holds the merge.
173
+
169
174
  Args:
170
175
  registered_string: One command, path, script, or entrypoint string.
171
176
  """
@@ -74,6 +74,7 @@ ALL_ACTION_BOUNDARY_EXEMPT_REGISTRATION_PATHS = (
74
74
  "blocking/image_read_size_gate.py",
75
75
  "blocking/artifact_dark_mode_gate.py",
76
76
  "blocking/haiku_spawn_slim_gate.py",
77
+ "blocking/merge_over_review_offer_gate.py",
77
78
  )
78
79
  ALL_TEST_DIRECTORY_NAMES = frozenset({"tests"})
79
80
  ALL_TEST_FILE_PREFIXES = ("test_",)
@@ -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
  (
@@ -441,6 +453,7 @@ def test_should_print_decision_and_meters_for_choose(
441
453
  assert account_broker.main(("choose", "--product", "codex")) == 0
442
454
  payload = json.loads(capsys.readouterr().out)
443
455
  assert payload["decision"]["account"] == "roomy"
456
+ assert payload["decision"]["profile"] is None
444
457
  assert payload["accounts"][0]["meters"]["session_percent_left"] == 80
445
458
  assert payload["state_path"].endswith("state.json")
446
459
 
@@ -781,7 +794,7 @@ def test_should_list_accounts_without_reading_meters(
781
794
 
782
795
  assert account_broker.main(("accounts", "--product", "codex")) == 0
783
796
  assert json.loads(capsys.readouterr().out) == {
784
- "accounts": [{"name": "listed", "home": str(account.home), "is_main": False}]
797
+ "accounts": [{"name": "listed", "profile": None, "home": str(account.home), "is_main": False}]
785
798
  }
786
799
 
787
800
 
@@ -931,6 +944,59 @@ def test_should_mark_a_windows_launcher_path_account_spent(
931
944
  assert decision["account"] == "second"
932
945
 
933
946
 
947
+ def test_should_mark_an_account_spent_by_its_profile(
948
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
949
+ ) -> None:
950
+ launcher_name = "C:\\Users\\someone\\.local\\bin\\claude-first.cmd"
951
+ first = Account(Product.CLAUDE, launcher_name, Path("/profiles/first"), command=launcher_name, profile="first")
952
+ second = _account("second", Product.CLAUDE)
953
+ monkeypatch.setitem(
954
+ account_broker.all_product_adapters,
955
+ Product.CLAUDE,
956
+ _adapter((first, second), {launcher_name: _meters(80, 80), "second": _meters(80, 80)}),
957
+ )
958
+ _freeze_clock(monkeypatch, NOW)
959
+
960
+ code = account_broker.main(("choose", "--product", "claude", "--spent", f"first:{(NOW + timedelta(hours=2)).timestamp()}"))
961
+
962
+ decision = json.loads(capsys.readouterr().out)["decision"]
963
+ assert code == 0
964
+ assert decision["account"] == "second"
965
+
966
+
967
+ def test_should_mark_the_account_named_by_a_mark_that_is_also_another_profile(
968
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
969
+ ) -> None:
970
+ launcher_name = "C:\\Users\\someone\\.local\\bin\\claude-editor.cmd"
971
+ profiled = Account(Product.CLAUDE, launcher_name, Path("/profiles/second"), command=launcher_name, profile="second")
972
+ second = _account("second", Product.CLAUDE)
973
+ monkeypatch.setitem(
974
+ account_broker.all_product_adapters,
975
+ Product.CLAUDE,
976
+ _adapter((second, profiled), {launcher_name: _meters(80, 80), "second": _meters(90, 90)}),
977
+ )
978
+ _freeze_clock(monkeypatch, NOW)
979
+
980
+ code = account_broker.main(("choose", "--product", "claude", "--spent", f"second:{(NOW + timedelta(hours=2)).timestamp()}"))
981
+
982
+ decision = json.loads(capsys.readouterr().out)["decision"]
983
+ assert code == 0
984
+ assert decision["account"] == launcher_name
985
+
986
+
987
+ def test_should_save_no_spent_mark_when_a_later_mark_is_unreadable(
988
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
989
+ ) -> None:
990
+ account = _account("first")
991
+ monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter((account,), {"first": _meters(80, 80)}))
992
+
993
+ code = account_broker.main(("choose", "--product", "codex", "--spent", "first", "--spent", "guest:tomorrow"))
994
+
995
+ assert code == 2
996
+ assert "invalid reset" in capsys.readouterr().err
997
+ assert account_broker._load_state(account_broker.broker_state_path()).get("spent", {}) == {}
998
+
999
+
934
1000
  def test_should_reject_an_invalid_spent_reset(
935
1001
  monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
936
1002
  ) -> None:
@@ -1044,6 +1110,22 @@ def test_should_run_past_a_stored_spent_mark_beyond_the_platform_range(
1044
1110
  assert decision["action"] == "run"
1045
1111
 
1046
1112
 
1113
+ def test_should_wait_on_an_outside_spent_mark_under_another_state_key_layout(
1114
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
1115
+ ) -> None:
1116
+ account = _account("first")
1117
+ monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter((account,), {"first": _meters(80, 80)}))
1118
+ monkeypatch.setattr(account_broker, "_state_key", lambda marked: f"{marked.product.value}|{marked.name}|{marked.home}")
1119
+ _freeze_clock(monkeypatch, NOW)
1120
+ reset = NOW + timedelta(hours=2)
1121
+
1122
+ account_broker.main(("choose", "--product", "codex", "--spent", f"visitor:{int(reset.timestamp())}"))
1123
+
1124
+ decision = json.loads(capsys.readouterr().out)["decision"]
1125
+ assert decision["action"] == "wait"
1126
+ assert "account visitor outside the roster is spent" in decision["reason"]
1127
+
1128
+
1047
1129
  def test_should_name_the_outside_spent_mark_when_it_forces_a_wait(
1048
1130
  monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
1049
1131
  ) -> None:
@@ -1106,6 +1188,37 @@ def test_should_warn_when_a_spent_mark_names_an_account_the_roster_lacks(
1106
1188
  assert "outside the roster" in all_warning_lines[0]
1107
1189
 
1108
1190
 
1191
+ def test_should_print_no_outside_roster_warning_when_a_later_mark_is_unreadable(
1192
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
1193
+ ) -> None:
1194
+ account = _account("first")
1195
+ monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter((account,), {"first": _meters(80, 80)}))
1196
+
1197
+ code = account_broker.main(("choose", "--product", "codex", "--spent", "visitor", "--spent", "first:notanumber"))
1198
+
1199
+ all_error_lines = capsys.readouterr().err.splitlines()
1200
+ assert code == 2
1201
+ assert len(all_error_lines) == 1
1202
+ assert "invalid reset in spent mark 'first:notanumber'" in all_error_lines[0]
1203
+ assert account_broker._load_state(account_broker.broker_state_path())["spent"] == {}
1204
+
1205
+
1206
+ def test_should_save_an_outside_launcher_path_given_with_an_explicit_reset(
1207
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
1208
+ ) -> None:
1209
+ account = _account("first")
1210
+ monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter((account,), {"first": _meters(80, 80)}))
1211
+ reset = int((datetime.now(timezone.utc) + timedelta(hours=2)).timestamp())
1212
+
1213
+ code = account_broker.main(("choose", "--product", "codex", "--spent", f"C:\\bin\\old.cmd:{reset}"))
1214
+
1215
+ all_warning_lines = capsys.readouterr().err.splitlines()
1216
+ all_saved_marks = account_broker._load_state(account_broker.broker_state_path())["spent"]
1217
+ assert code == WAIT_EXIT_CODE
1218
+ assert "C:\\bin\\old.cmd" in all_warning_lines[0]
1219
+ assert all_saved_marks == {f"codex:C:\\bin\\old.cmd:{Path()}": float(reset)}
1220
+
1221
+
1109
1222
  @pytest.mark.parametrize("mark", ["", ":1800000000"])
1110
1223
  def test_should_reject_a_spent_mark_without_an_account_name(
1111
1224
  monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], mark: str
@@ -1127,8 +1240,8 @@ def test_should_serialize_a_run_and_a_wait_decision() -> None:
1127
1240
  run_payload = account_broker.decision_payload(account_broker.Decision("run", account, None, "first has 80% left", "normal"))
1128
1241
  wait_payload = account_broker.decision_payload(account_broker.Decision("wait", None, reset, "no account has room", "wait"))
1129
1242
 
1130
- assert run_payload == {"action": "run", "account": "first", "home": str(Path("/profiles/first")), "reason": "first has 80% left", "tier": "normal", "resets_at": None}
1131
- assert wait_payload == {"action": "wait", "account": None, "home": None, "reason": "no account has room", "tier": "wait", "resets_at": reset.isoformat()}
1243
+ assert run_payload == {"action": "run", "account": "first", "profile": None, "home": str(Path("/profiles/first")), "reason": "first has 80% left", "tier": "normal", "resets_at": None}
1244
+ assert wait_payload == {"action": "wait", "account": None, "profile": None, "home": None, "reason": "no account has room", "tier": "wait", "resets_at": reset.isoformat()}
1132
1245
 
1133
1246
 
1134
1247
  def test_should_print_the_whole_rate_limit_result_for_one_home(
@@ -1236,6 +1349,12 @@ def test_should_hand_the_live_log_to_the_subprocess_runner(tmp_path: Path, monke
1236
1349
  assert captured_options["live_log"] == live_log
1237
1350
 
1238
1351
 
1352
+ def test_decision_payload_should_carry_the_profile_of_a_claude_account() -> None:
1353
+ account = Account(Product.CLAUDE, "C:\\bin\\claude-editor.cmd", Path("/profiles/editor"), command="C:\\bin\\claude-editor.cmd", profile="editor")
1354
+
1355
+ assert account_broker.decision_payload(account_broker.Decision("run", account, None, "most room", "normal"))["profile"] == "editor"
1356
+
1357
+
1239
1358
  def test_decision_payload_should_name_the_chosen_account_and_its_home() -> None:
1240
1359
  account = _account("codex-2")
1241
1360
  decision = account_broker.Decision("run", account, None, "most room", "normal")
@@ -1243,6 +1362,7 @@ def test_decision_payload_should_name_the_chosen_account_and_its_home() -> None:
1243
1362
  assert account_broker.decision_payload(decision) == {
1244
1363
  "action": "run",
1245
1364
  "account": "codex-2",
1365
+ "profile": None,
1246
1366
  "home": str(Path("/profiles") / "codex-2"),
1247
1367
  "reason": "most room",
1248
1368
  "tier": "normal",
@@ -1259,3 +1379,25 @@ def test_decision_payload_should_leave_the_account_empty_and_give_the_reset_for_
1259
1379
  assert payload["account"] is None
1260
1380
  assert payload["home"] is None
1261
1381
  assert payload["resets_at"] == "2026-10-03T08:00:00+00:00"
1382
+
1383
+
1384
+ def test_should_save_every_spent_mark_in_one_write(
1385
+ monkeypatch: pytest.MonkeyPatch,
1386
+ ) -> None:
1387
+ accounts = (_account("first"), _account("second"))
1388
+ monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter(accounts, {
1389
+ "first": _meters(80, 80), "second": _meters(70, 70)
1390
+ }))
1391
+ all_saved_marks: list[dict[str, object]] = []
1392
+
1393
+ def save_or_fail(path: Path, all_state: dict[str, object]) -> None:
1394
+ all_saved_marks.append(dict(all_state["spent"]))
1395
+ raise OSError("disk full")
1396
+
1397
+ monkeypatch.setattr(account_broker, "_save_state", save_or_fail)
1398
+
1399
+ with pytest.raises(OSError):
1400
+ account_broker.main(("choose", "--product", "codex", "--spent", "first", "--spent", "second"))
1401
+
1402
+ assert len(all_saved_marks) == 1
1403
+ assert len(all_saved_marks[0]) == 2