claude-dev-env 8.84.0 → 8.85.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/bin/install.prompt-context.test.mjs +2 -1
- package/hooks/blocking/chat_reply_gate.py +246 -0
- package/hooks/blocking/inventory_intent_records/records.py +2 -5
- package/hooks/blocking/pull_request_proof.py +133 -17
- package/hooks/blocking/test_chat_reply_gate.py +224 -0
- package/hooks/blocking/test_pull_request_proof.py +144 -0
- package/hooks/features/conduct-gates.md +3 -0
- package/hooks/hooks.json +21 -0
- package/hooks/hooks_constants/chat_reply_gate_constants.py +58 -0
- package/hooks/hooks_constants/pull_request_proof_constants.py +18 -5
- package/hooks/hooks_constants/session_file_id.py +17 -0
- package/hooks/hooks_constants/test_session_file_id.py +17 -0
- package/hooks/observability/session_file_edit_tracker.py +2 -5
- package/hooks/session/session_edit_tracker_cleanup.py +2 -5
- package/package.json +1 -1
- package/scripts/conftest.py +22 -13
- package/scripts/dev_env_scripts_constants/account_broker_constants.py +12 -0
- package/scripts/dev_env_scripts_constants/test_account_broker_constants.py +12 -0
- package/scripts/policy_lint/adapter_configuration.py +3 -1
- package/scripts/policy_lint/config/constants.py +1 -0
- package/scripts/test_account_broker.py +0 -6
- package/scripts/test_account_broker_support.py +6 -7
- package/scripts/test_broker_state_isolation.py +7 -8
- package/scripts/test_conftest.py +54 -0
- package/scripts/tests/test_adapter_configuration.py +8 -0
package/scripts/conftest.py
CHANGED
|
@@ -18,6 +18,7 @@ is in place by the time any test module runs its top-level imports.
|
|
|
18
18
|
from __future__ import annotations
|
|
19
19
|
|
|
20
20
|
import importlib
|
|
21
|
+
import site
|
|
21
22
|
import subprocess
|
|
22
23
|
import sys
|
|
23
24
|
from collections.abc import Callable
|
|
@@ -35,25 +36,33 @@ for each_import_directory in (
|
|
|
35
36
|
if str(each_import_directory) not in sys.path:
|
|
36
37
|
sys.path.insert(0, str(each_import_directory))
|
|
37
38
|
|
|
38
|
-
|
|
39
|
+
from dev_env_scripts_constants.account_broker_constants import ALL_ACCOUNT_ENVIRONMENT_VARIABLES
|
|
39
40
|
|
|
40
41
|
|
|
41
42
|
@pytest.fixture(autouse=True)
|
|
42
|
-
def
|
|
43
|
-
"""Point
|
|
44
|
-
|
|
45
|
-
``
|
|
46
|
-
|
|
47
|
-
|
|
43
|
+
def isolated_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
|
|
44
|
+
"""Point the home directory at a per-test folder for every test here.
|
|
45
|
+
|
|
46
|
+
``Path.home()`` reads ``HOME`` on POSIX and ``USERPROFILE`` on Windows, so
|
|
47
|
+
redirecting both keeps call-time home lookups, such as
|
|
48
|
+
``broker_state_path``, off the user's home directory. The variables in
|
|
49
|
+
``ALL_ACCOUNT_ENVIRONMENT_VARIABLES`` are cleared, so account roots fall
|
|
50
|
+
back to the per-test home and no developer roster applies. A value built
|
|
51
|
+
from ``Path.home()`` at import time keeps the user's home.
|
|
52
|
+
``PYTHONUSERBASE`` stays on the user base captured before the redirect, so
|
|
53
|
+
a new interpreter still imports packages installed for this user.
|
|
48
54
|
|
|
49
55
|
Returns:
|
|
50
|
-
The
|
|
56
|
+
The per-test home directory.
|
|
51
57
|
"""
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
58
|
+
user_base = site.getuserbase()
|
|
59
|
+
isolated_home_directory = tmp_path / "home"
|
|
60
|
+
monkeypatch.setenv("PYTHONUSERBASE", user_base)
|
|
61
|
+
monkeypatch.setenv("HOME", str(isolated_home_directory))
|
|
62
|
+
monkeypatch.setenv("USERPROFILE", str(isolated_home_directory))
|
|
63
|
+
for each_account_variable in ALL_ACCOUNT_ENVIRONMENT_VARIABLES:
|
|
64
|
+
monkeypatch.delenv(each_account_variable, raising=False)
|
|
65
|
+
return isolated_home_directory
|
|
57
66
|
|
|
58
67
|
|
|
59
68
|
def _link_directory(*, from_link: Path, to_target: Path) -> None:
|
|
@@ -16,12 +16,15 @@ from dev_env_scripts_constants.claude_account_constants import (
|
|
|
16
16
|
FULL_PERCENT,
|
|
17
17
|
MAIN_SESSION_USED_CEILING_PERCENT,
|
|
18
18
|
MAIN_WEEKLY_USED_CEILING_PERCENT,
|
|
19
|
+
PROFILES_ROOT_ENVIRONMENT_VARIABLE,
|
|
19
20
|
SECOND_SESSION_USED_CEILING_PERCENT,
|
|
20
21
|
SECOND_WEEKLY_USED_CEILING_PERCENT,
|
|
21
22
|
)
|
|
22
23
|
from dev_env_scripts_constants.codex_account_constants import (
|
|
23
24
|
ALL_PRO_PLAN_TYPES,
|
|
25
|
+
CODEX_ACCOUNT_PROFILES_ENVIRONMENT_VARIABLE,
|
|
24
26
|
CODEX_HOME_ENVIRONMENT_VARIABLE,
|
|
27
|
+
CODEX_PROFILES_ROOT_ENVIRONMENT_VARIABLE,
|
|
25
28
|
LUNA_TIER_SHORT_WINDOW_MINIMUM_PERCENT_LEFT,
|
|
26
29
|
LUNA_TIER_STOP_PERCENT_LEFT,
|
|
27
30
|
MAIN_CODEX_HOME_DIRECTORY_NAME,
|
|
@@ -30,6 +33,7 @@ from dev_env_scripts_constants.codex_account_constants import (
|
|
|
30
33
|
TIER_NORMAL,
|
|
31
34
|
)
|
|
32
35
|
from dev_env_scripts_constants.shared_tree_constants import (
|
|
36
|
+
CLAUDE_CONFIG_DIR_ENV_VAR,
|
|
33
37
|
CODEX_CLASSIFIER_CONSTANTS_RELATIVE_PATH,
|
|
34
38
|
CONSTANTS_PACKAGE_ANCHOR_DEPTH,
|
|
35
39
|
PR_LOOP_DIRECTORY_NAME,
|
|
@@ -64,6 +68,14 @@ BROKER_STATE_DIRECTORY_NAME = "account-broker"
|
|
|
64
68
|
BROKER_STATE_FILE_NAME = "state.json"
|
|
65
69
|
BROKER_STATE_TEMP_SUFFIX = ".json"
|
|
66
70
|
BROKER_STATE_LOCK_SUFFIX = ".lock"
|
|
71
|
+
ALL_ACCOUNT_ENVIRONMENT_VARIABLES = (
|
|
72
|
+
CLAUDE_CONFIG_DIR_ENV_VAR,
|
|
73
|
+
CODEX_HOME_ENVIRONMENT_VARIABLE,
|
|
74
|
+
CODEX_ACCOUNT_PROFILES_ENVIRONMENT_VARIABLE,
|
|
75
|
+
CODEX_PROFILES_ROOT_ENVIRONMENT_VARIABLE,
|
|
76
|
+
PROFILES_ROOT_ENVIRONMENT_VARIABLE,
|
|
77
|
+
)
|
|
78
|
+
"""Variables that point the account tools at a developer's own account roots, Codex home and Codex roster."""
|
|
67
79
|
ALL_BATCH_FILE_EXTENSIONS = frozenset({".bat", ".cmd"})
|
|
68
80
|
CMD_SHELL_METACHARACTERS = "&|<>^%!\"\r\n"
|
|
69
81
|
CLAUDE_BINARY_NAME = "claude"
|
|
@@ -15,7 +15,9 @@ from pathlib import Path
|
|
|
15
15
|
import pytest
|
|
16
16
|
|
|
17
17
|
from dev_env_scripts_constants import account_broker_constants as broker_constants
|
|
18
|
+
from dev_env_scripts_constants import claude_account_constants, codex_account_constants, shared_tree_constants
|
|
18
19
|
from dev_env_scripts_constants.account_broker_constants import (
|
|
20
|
+
ALL_ACCOUNT_ENVIRONMENT_VARIABLES,
|
|
19
21
|
ALL_BATCH_FILE_EXTENSIONS,
|
|
20
22
|
ALL_CLAUDE_FLOORS,
|
|
21
23
|
ALL_CODEX_FLOORS,
|
|
@@ -57,6 +59,16 @@ def test_should_keep_codex_floors_tied_to_the_tier_limits() -> None:
|
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
|
|
62
|
+
def test_should_list_every_account_environment_variable() -> None:
|
|
63
|
+
all_declared_variables = {
|
|
64
|
+
each_value
|
|
65
|
+
for each_module in (claude_account_constants, codex_account_constants, shared_tree_constants)
|
|
66
|
+
for each_name, each_value in vars(each_module).items()
|
|
67
|
+
if each_name.endswith(("_ENVIRONMENT_VARIABLE", "_ENV_VAR"))
|
|
68
|
+
}
|
|
69
|
+
assert all_declared_variables == set(ALL_ACCOUNT_ENVIRONMENT_VARIABLES)
|
|
70
|
+
|
|
71
|
+
|
|
60
72
|
def test_batch_command_boundaries_include_shell_metacharacters() -> None:
|
|
61
73
|
assert ALL_BATCH_FILE_EXTENSIONS == frozenset({".bat", ".cmd"})
|
|
62
74
|
assert set(CMD_SHELL_METACHARACTERS) == set('&|<>^%!"\r\n')
|
|
@@ -130,6 +130,7 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
|
|
|
130
130
|
hooks/blocking/pr_lifecycle_skill_gate.py -> exempt
|
|
131
131
|
hooks/blocking/step_note_gate.py -> exempt
|
|
132
132
|
hooks/blocking/reply_length_gate.py -> exempt
|
|
133
|
+
hooks/blocking/chat_reply_gate.py -> exempt
|
|
133
134
|
hooks/blocking/edit_marker_gate.py -> exempt
|
|
134
135
|
hooks/blocking/issue_close_handoff_gate.py -> exempt
|
|
135
136
|
hooks/blocking/verify_before_acting.py -> exempt
|
|
@@ -154,7 +155,8 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
|
|
|
154
155
|
It asks for a readable status line and decides no code or safety policy.
|
|
155
156
|
|
|
156
157
|
The reply length gate caps the sentences in a chat reply to the user. It
|
|
157
|
-
decides the shape of prose and no code or safety policy.
|
|
158
|
+
decides the shape of prose and no code or safety policy. The chat reply
|
|
159
|
+
gate runs the same checks on a reply typed straight into the chat.
|
|
158
160
|
|
|
159
161
|
The edit marker gate keeps strikethrough and edit notes out of an edited
|
|
160
162
|
chat message. It decides the shape of prose and no code or safety policy.
|
|
@@ -67,6 +67,7 @@ ALL_ACTION_BOUNDARY_EXEMPT_REGISTRATION_PATHS = (
|
|
|
67
67
|
"blocking/merge_review_gate.py",
|
|
68
68
|
"blocking/step_note_gate.py",
|
|
69
69
|
"blocking/reply_length_gate.py",
|
|
70
|
+
"blocking/chat_reply_gate.py",
|
|
70
71
|
"blocking/edit_marker_gate.py",
|
|
71
72
|
"blocking/issue_close_handoff_gate.py",
|
|
72
73
|
"blocking/verify_before_acting.py",
|
|
@@ -42,12 +42,6 @@ def _freeze_clock(monkeypatch: pytest.MonkeyPatch, instant: datetime) -> None:
|
|
|
42
42
|
monkeypatch.setattr(account_broker, "datetime", _FrozenClock)
|
|
43
43
|
|
|
44
44
|
|
|
45
|
-
@pytest.fixture(autouse=True)
|
|
46
|
-
def isolated_state(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
47
|
-
monkeypatch.setattr(account_broker, "broker_state_path", lambda: tmp_path / "broker" / "state.json")
|
|
48
|
-
monkeypatch.setattr(account_broker.support, "broker_state_path", lambda: tmp_path / "broker" / "state.json")
|
|
49
|
-
|
|
50
|
-
|
|
51
45
|
def _account(name: str, product: Product = Product.CODEX, main: bool = False) -> Account:
|
|
52
46
|
return Account(product, name, Path("/profiles") / name, main, name)
|
|
53
47
|
|
|
@@ -256,8 +256,7 @@ def _recorded_sleeps(monkeypatch: pytest.MonkeyPatch) -> list[float]:
|
|
|
256
256
|
return all_delays
|
|
257
257
|
|
|
258
258
|
|
|
259
|
-
def _first_reading(
|
|
260
|
-
monkeypatch.setattr(support, "broker_state_path", lambda: tmp_path / "broker" / "state.json")
|
|
259
|
+
def _first_reading(account: Account, read_meters: object) -> object:
|
|
261
260
|
adapter = ProductAdapter(lambda: (account,), read_meters, "HOME", ())
|
|
262
261
|
state = support._load_state(support.broker_state_path())
|
|
263
262
|
now = datetime(2026, 10, 3, tzinfo=timezone.utc)
|
|
@@ -272,7 +271,7 @@ def test_should_read_claude_meters_after_transient_failures_with_growing_delays(
|
|
|
272
271
|
all_paths_read = _claude_meter_reads(monkeypatch, [unread, unread, usage])
|
|
273
272
|
all_delays = _recorded_sleeps(monkeypatch)
|
|
274
273
|
|
|
275
|
-
reading = _first_reading(
|
|
274
|
+
reading = _first_reading(Account(Product.CLAUDE, "org-jon", tmp_path), support.read_claude_meters)[0]
|
|
276
275
|
|
|
277
276
|
assert reading.meters is not None
|
|
278
277
|
assert reading.meters.weekly_percent_left == 70.0
|
|
@@ -289,7 +288,7 @@ def test_should_read_codex_meters_after_transient_failures_with_growing_delays(
|
|
|
289
288
|
all_homes_read = _codex_meter_reads(monkeypatch, tmp_path, [unread, unread, usage])
|
|
290
289
|
all_delays = _recorded_sleeps(monkeypatch)
|
|
291
290
|
|
|
292
|
-
reading = _first_reading(
|
|
291
|
+
reading = _first_reading(Account(Product.CODEX, "one", tmp_path), support.read_codex_account_meters)[0]
|
|
293
292
|
|
|
294
293
|
assert reading.meters is not None
|
|
295
294
|
assert reading.meters.weekly_percent_left == 70.0
|
|
@@ -307,7 +306,7 @@ def test_should_stop_at_once_when_no_codex_executable_is_found(
|
|
|
307
306
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
308
307
|
all_delays = _recorded_sleeps(monkeypatch)
|
|
309
308
|
|
|
310
|
-
reading = _first_reading(
|
|
309
|
+
reading = _first_reading(Account(Product.CODEX, "one", tmp_path), support.read_codex_account_meters)[0]
|
|
311
310
|
|
|
312
311
|
assert reading.meters is None
|
|
313
312
|
assert reading.unread_reason == "no codex executable on PATH or at its install path"
|
|
@@ -322,7 +321,7 @@ def test_should_keep_the_unread_reason_after_every_claude_read_fails(
|
|
|
322
321
|
all_paths_read = _claude_meter_reads(monkeypatch, all_failures)
|
|
323
322
|
all_delays = _recorded_sleeps(monkeypatch)
|
|
324
323
|
|
|
325
|
-
reading, adapter, state, now = _first_reading(
|
|
324
|
+
reading, adapter, state, now = _first_reading(Account(Product.CLAUDE, "org-jon", tmp_path), support.read_claude_meters)
|
|
326
325
|
cached_reading = support.read_accounts(Product.CLAUDE, adapter, all_state=state, now=now)[0]
|
|
327
326
|
|
|
328
327
|
assert reading.meters is None
|
|
@@ -340,7 +339,7 @@ def test_should_keep_the_unread_reason_after_every_codex_read_fails(
|
|
|
340
339
|
all_homes_read = _codex_meter_reads(monkeypatch, tmp_path, [first_unread, first_unread, last_unread])
|
|
341
340
|
_recorded_sleeps(monkeypatch)
|
|
342
341
|
|
|
343
|
-
reading, adapter, state, now = _first_reading(
|
|
342
|
+
reading, adapter, state, now = _first_reading(Account(Product.CODEX, "codex-3", tmp_path), support.read_codex_account_meters)
|
|
344
343
|
cached_reading = support.read_accounts(Product.CODEX, adapter, all_state=state, now=now)[0]
|
|
345
344
|
|
|
346
345
|
assert reading.meters is None
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Broker state written during a test
|
|
1
|
+
"""Broker state written during a test lands in the per-test home directory."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -18,14 +18,11 @@ from dev_env_scripts_constants.account_broker_constants import (
|
|
|
18
18
|
)
|
|
19
19
|
|
|
20
20
|
|
|
21
|
-
def
|
|
21
|
+
def test_meter_cache_write_lands_in_the_isolated_home(
|
|
22
22
|
monkeypatch: pytest.MonkeyPatch,
|
|
23
23
|
tmp_path: Path,
|
|
24
|
-
|
|
24
|
+
isolated_home: Path,
|
|
25
25
|
) -> None:
|
|
26
|
-
fake_home = tmp_path / "home"
|
|
27
|
-
monkeypatch.setenv("HOME", str(fake_home))
|
|
28
|
-
monkeypatch.setenv("USERPROFILE", str(fake_home))
|
|
29
26
|
account = Account(Product.CLAUDE, "only", tmp_path / "only")
|
|
30
27
|
reset_at = datetime(2026, 10, 5, tzinfo=timezone.utc)
|
|
31
28
|
adapter = ProductAdapter(
|
|
@@ -39,10 +36,12 @@ def test_meter_cache_write_lands_outside_the_home_directory(
|
|
|
39
36
|
def runner(argv: list[str], **options: object) -> subprocess.CompletedProcess[str]:
|
|
40
37
|
return subprocess.CompletedProcess(argv, 0, "", "")
|
|
41
38
|
|
|
39
|
+
isolated_broker_state_path = account_broker.broker_state_path()
|
|
40
|
+
assert Path.home() == isolated_home
|
|
41
|
+
assert isolated_broker_state_path.is_relative_to(isolated_home)
|
|
42
|
+
assert not isolated_broker_state_path.exists()
|
|
42
43
|
with account_broker.override_subprocess_runner(runner):
|
|
43
44
|
account_broker.run_job(Product.CLAUDE, ["claude"])
|
|
44
45
|
|
|
45
|
-
home_state_path = fake_home / ".claude" / "account-broker" / "state.json"
|
|
46
|
-
assert not home_state_path.exists()
|
|
47
46
|
state = json.loads(isolated_broker_state_path.read_text(encoding="utf-8"))
|
|
48
47
|
assert f"claude:only:{account.home}" in state["meters"]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
import account_broker_support
|
|
10
|
+
from dev_env_scripts_constants.account_broker_constants import ALL_ACCOUNT_ENVIRONMENT_VARIABLES
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture(scope="module")
|
|
14
|
+
def exported_account_variables(tmp_path_factory: pytest.TempPathFactory) -> Iterator[None]:
|
|
15
|
+
exported_root = tmp_path_factory.mktemp("exported-overrides")
|
|
16
|
+
with pytest.MonkeyPatch.context() as module_monkeypatch:
|
|
17
|
+
for each_account_variable in ALL_ACCOUNT_ENVIRONMENT_VARIABLES:
|
|
18
|
+
module_monkeypatch.setenv(each_account_variable, str(exported_root / each_account_variable))
|
|
19
|
+
yield
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_broker_state_path_follows_the_redirected_home(
|
|
23
|
+
isolated_home: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
24
|
+
) -> None:
|
|
25
|
+
assert Path.home() == isolated_home
|
|
26
|
+
assert account_broker_support.broker_state_path().is_relative_to(isolated_home)
|
|
27
|
+
moved_home = tmp_path / "moved-home"
|
|
28
|
+
monkeypatch.setenv("HOME", str(moved_home))
|
|
29
|
+
monkeypatch.setenv("USERPROFILE", str(moved_home))
|
|
30
|
+
assert account_broker_support.broker_state_path().is_relative_to(moved_home)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_isolated_home_clears_the_exported_account_variables(
|
|
34
|
+
exported_account_variables: None, isolated_home: Path
|
|
35
|
+
) -> None:
|
|
36
|
+
for each_account_variable in ALL_ACCOUNT_ENVIRONMENT_VARIABLES:
|
|
37
|
+
assert each_account_variable not in os.environ
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_isolated_home_keeps_the_python_user_base_for_a_new_interpreter(
|
|
41
|
+
isolated_home: Path,
|
|
42
|
+
) -> None:
|
|
43
|
+
completed = subprocess.run(
|
|
44
|
+
[sys.executable, "-c", "import site; print(site.getuserbase())"],
|
|
45
|
+
check=False,
|
|
46
|
+
capture_output=True,
|
|
47
|
+
text=True,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
assert completed.returncode == 0
|
|
51
|
+
child_user_base = Path(completed.stdout.strip())
|
|
52
|
+
assert str(child_user_base) == os.environ["PYTHONUSERBASE"]
|
|
53
|
+
assert child_user_base != isolated_home
|
|
54
|
+
assert isolated_home not in child_user_base.parents
|
|
@@ -90,6 +90,14 @@ def test_new_reply_length_gate_registration_is_clean(tmp_path: Path) -> None:
|
|
|
90
90
|
assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
|
|
91
91
|
|
|
92
92
|
|
|
93
|
+
def test_new_chat_reply_gate_registration_is_clean(tmp_path: Path) -> None:
|
|
94
|
+
current_document = _hook_document(
|
|
95
|
+
["hooks/blocking/chat_reply_gate.py"],
|
|
96
|
+
'{"hooks": {}}',
|
|
97
|
+
)
|
|
98
|
+
assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
|
|
99
|
+
|
|
100
|
+
|
|
93
101
|
def test_new_edit_marker_gate_registration_is_clean(tmp_path: Path) -> None:
|
|
94
102
|
current_document = _hook_document(
|
|
95
103
|
["hooks/blocking/edit_marker_gate.py"],
|