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.
- package/.agents/skills/pr-lifecycle/reference/review-loop-before-merge.md +2 -2
- package/bin/install.settings-defaults.test.mjs +27 -2
- package/bin/merge_settings_defaults.mjs +39 -21
- package/bin/merge_settings_defaults.test.mjs +42 -0
- package/docs/account-broker.md +1 -1
- package/docs/codex-accounts.md +6 -5
- package/hooks/blocking/cloud_graphql_gate.py +213 -0
- package/hooks/blocking/headless_claude_broker_gate.py +1 -8
- package/hooks/blocking/merge_over_review_offer_gate.py +114 -0
- package/hooks/blocking/merge_review_gate.py +65 -3
- package/hooks/blocking/pr_lifecycle_skill_gate.py +6 -7
- package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +5 -1
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
- package/hooks/blocking/test_cloud_graphql_gate.py +210 -0
- package/hooks/blocking/test_headless_claude_broker_gate.py +12 -7
- package/hooks/blocking/test_merge_over_review_offer_gate.py +138 -0
- package/hooks/blocking/test_merge_review_gate.py +63 -0
- package/hooks/blocking/test_pr_lifecycle_skill_gate.py +3 -0
- package/hooks/conftest.py +1 -1
- package/hooks/features/bash-dispatchers.md +3 -0
- package/hooks/features/conduct-gates.md +5 -2
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/cloud_graphql_gate_constants.py +116 -0
- package/hooks/hooks_constants/cloud_session.py +17 -0
- package/hooks/hooks_constants/cloud_session_constants.py +11 -0
- package/hooks/hooks_constants/gh_command_words.py +52 -0
- package/hooks/hooks_constants/gh_command_words_constants.py +64 -0
- package/hooks/hooks_constants/headless_claude_broker_gate_constants.py +1 -7
- package/hooks/hooks_constants/hook_block_logger.py +21 -3
- package/hooks/hooks_constants/merge_over_review_offer_gate_constants.py +34 -0
- package/hooks/hooks_constants/merge_review_gate_constants.py +14 -3
- package/hooks/hooks_constants/mod_handoff.py +2 -3
- package/hooks/hooks_constants/mod_handoff_constants.py +0 -4
- package/hooks/hooks_constants/piped_pytest_blocker_constants.py +27 -2
- package/hooks/hooks_constants/pr_lifecycle_skill_gate_constants.py +0 -1
- package/hooks/hooks_constants/shell_command_evaluation_constants.py +67 -0
- package/hooks/hooks_constants/shell_command_substitution_constants.py +25 -0
- package/hooks/hooks_constants/shell_command_wrappers.py +327 -12
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -1
- package/hooks/hooks_constants/test_cloud_session.py +27 -0
- package/hooks/hooks_constants/test_gh_command_words.py +45 -0
- package/hooks/hooks_constants/test_hook_block_logger.py +61 -0
- package/hooks/hooks_constants/test_mod_handoff.py +3 -2
- package/hooks/hooks_constants/test_piped_pytest_blocker_constants.py +11 -0
- package/hooks/hooks_constants/test_shell_command_wrappers.py +125 -0
- package/hooks/test_conftest.py +1 -1
- package/package.json +1 -1
- package/scripts/account_broker.py +23 -11
- package/scripts/account_broker_support.py +23 -14
- package/scripts/codex_account_meters.py +30 -43
- package/scripts/conftest.py +25 -0
- package/scripts/dev_env_scripts_constants/account_broker_constants.py +36 -25
- package/scripts/dev_env_scripts_constants/claude_account_constants.py +0 -9
- package/scripts/dev_env_scripts_constants/codex_account_constants.py +0 -18
- package/scripts/dev_env_scripts_constants/test_account_broker_constants.py +57 -14
- package/scripts/dev_env_scripts_constants/test_codex_account_constants.py +119 -0
- package/scripts/merge_review_receipt.py +56 -12
- package/scripts/policy_lint/adapter_configuration.py +5 -0
- package/scripts/policy_lint/config/constants.py +1 -0
- package/scripts/test_account_broker.py +145 -3
- package/scripts/test_account_broker_support.py +88 -6
- package/scripts/test_claude_chain_usage.py +3 -11
- package/scripts/test_codex_account_meters.py +26 -14
- package/scripts/test_merge_review_receipt.py +86 -0
- package/scripts/test_shared_tree_paths.py +58 -36
- package/scripts/tests/test_adapter_configuration.py +8 -0
- 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
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
74
|
-
if parsed.
|
|
75
|
-
|
|
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
|