claude-dev-env 8.25.0 → 8.26.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 (31) hide show
  1. package/docs/CODE_RULES.md +2 -2
  2. package/hooks/blocking/code_rules_boolean_mustcheck.py +1 -82
  3. package/hooks/blocking/code_rules_comments.py +14 -23
  4. package/hooks/blocking/code_rules_enforcer.py +0 -6
  5. package/hooks/blocking/code_rules_imports_logging.py +1 -120
  6. package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +9 -12
  7. package/hooks/blocking/test_code_rules_enforcer_exempt_marker_chained.py +0 -10
  8. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +0 -16
  9. package/hooks/hooks.json +10 -0
  10. package/hooks/hooks_constants/blocking_check_limits.py +0 -13
  11. package/hooks/hooks_constants/code_rules_enforcer_constants.py +0 -8
  12. package/hooks/hooks_constants/local_identity.py +7 -120
  13. package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +0 -6
  14. package/hooks/hooks_constants/test_local_identity.py +7 -43
  15. package/hooks/hooks_constants/thread_spawn_pace_hook_constants.py +45 -0
  16. package/hooks/hooks_constants/usage_pace_constants.py +49 -0
  17. package/hooks/routing/conftest.py +10 -0
  18. package/hooks/routing/test_thread_spawn_pace_hook.py +139 -0
  19. package/hooks/routing/test_usage_pace.py +142 -0
  20. package/hooks/routing/thread_spawn_pace_hook.py +220 -0
  21. package/hooks/routing/usage_pace.py +345 -0
  22. package/package.json +1 -1
  23. package/rules/shell-invocation.md +1 -1
  24. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +0 -4
  25. package/scripts/repository_checks/config/constants.py +0 -5
  26. package/scripts/test_resolve_worker_spawn.py +0 -28
  27. package/docs/nas-ssh-invocation.md +0 -107
  28. package/hooks/blocking/test_code_rules_enforcer_ignored_must_check_return.py +0 -266
  29. package/hooks/blocking/test_code_rules_enforcer_split_mustcheck.py +0 -87
  30. package/hooks/blocking/test_code_rules_logging_printf_tokens.py +0 -134
  31. package/rules/nas-ssh-invocation.md +0 -29
@@ -1,19 +1,17 @@
1
- """Resolve local identity values for NAS ssh and PII exempt-repo hooks.
1
+ """Resolve local identity values for the PII hooks.
2
2
 
3
- The enforcer hooks ship into ``~/.claude/`` and cannot read a repository file,
4
- so they read real machine values from the environment or a git-ignored file in
5
- the Claude home directory. This module supplies NAS host/user/port, the two
6
- NAS deny messages that quote those values, and the owner/repo slug set used by
7
- ``pii_exempt_repository_slugs`` (``CLAUDE_PII_EXEMPT_REPOS`` /
8
- ``pii_exempt_repositories``). The committed NAS defaults are placeholders.
3
+ The hooks ship into ``~/.claude/`` and cannot read a repository file, so they
4
+ read machine values from the environment or a git-ignored file in the Claude
5
+ home directory. This module supplies the NAS host the PII scanner allowlists
6
+ and the owner/repo slug set used by ``pii_exempt_repository_slugs``
7
+ (``CLAUDE_PII_EXEMPT_REPOS`` / ``pii_exempt_repositories``). The committed NAS
8
+ host default is a placeholder.
9
9
 
10
10
  ::
11
11
 
12
12
  CLAUDE_NAS_HOST set to a host -> nas_host() returns that host
13
- CLAUDE_NAS_SSH_PORT set to 2200 -> nas_ssh_port() returns 2200
14
13
  CLAUDE_PII_EXEMPT_REPOS="Owner/repo" -> {"owner/repo"}
15
14
  (env unset, no file) -> nas_host() == "nas.example.local"
16
- nas_ssh_port() == 22
17
15
  pii_exempt_repository_slugs() == frozenset()
18
16
 
19
17
  Each value comes from its environment variable, then
@@ -32,19 +30,11 @@ PII_EXEMPT_REPOS_ENV_VAR = "CLAUDE_PII_EXEMPT_REPOS"
32
30
  PII_EXEMPT_REPOS_JSON_KEY = "pii_exempt_repositories"
33
31
  PII_EXEMPT_REPOS_SEPARATOR = ","
34
32
  PII_ALLOWLISTED_VALUES_JSON_KEY = "pii_allowlisted_values"
35
- NAS_SSH_USER_ENV_VAR = "CLAUDE_NAS_SSH_USER"
36
- NAS_SSH_PORT_ENV_VAR = "CLAUDE_NAS_SSH_PORT"
37
33
  NAS_JSON_KEY = "nas"
38
34
  NAS_HOST_JSON_KEY = "host"
39
- NAS_SSH_USER_JSON_KEY = "ssh_user"
40
- NAS_SSH_PORT_JSON_KEY = "ssh_port"
41
35
  CLAUDE_HOME_DIRECTORY_NAME = ".claude"
42
36
  LOCAL_IDENTITY_FILE_NAME = "local-identity.json"
43
37
  PLACEHOLDER_NAS_HOST = "nas.example.local"
44
- PLACEHOLDER_NAS_SSH_USER = "operator"
45
- PLACEHOLDER_NAS_SSH_PORT = 22
46
- OPENSSH_INVOCATION_EXAMPLE_BINARY = '"/c/Windows/System32/OpenSSH/ssh.exe"'
47
- NAS_SSH_RULE_REFERENCE = "~/.claude/rules/nas-ssh-invocation.md"
48
38
 
49
39
 
50
40
  def _local_identity_file_path() -> Path:
@@ -178,106 +168,3 @@ def nas_host() -> str:
178
168
  if isinstance(stored_host, str) and stored_host:
179
169
  return stored_host
180
170
  return PLACEHOLDER_NAS_HOST
181
-
182
-
183
- def nas_ssh_user() -> str:
184
- """Return the ssh user the enforcer expects for the NAS.
185
-
186
- ::
187
-
188
- CLAUDE_NAS_SSH_USER set to a user -> that user
189
- (env unset, no file) -> "operator"
190
-
191
- The environment variable wins, then the git-ignored file, then the
192
- placeholder default.
193
-
194
- Returns:
195
- The ssh login name for the NAS.
196
- """
197
- user_from_environment = os.environ.get(NAS_SSH_USER_ENV_VAR)
198
- if user_from_environment:
199
- return user_from_environment
200
- stored_user = _nas_section_from_local_file().get(NAS_SSH_USER_JSON_KEY)
201
- if isinstance(stored_user, str) and stored_user:
202
- return stored_user
203
- return PLACEHOLDER_NAS_SSH_USER
204
-
205
-
206
- def nas_ssh_port() -> int:
207
- """Return the ssh port the enforcer expects for the NAS.
208
-
209
- ::
210
-
211
- CLAUDE_NAS_SSH_PORT set to 2200 -> 2200
212
- (env unset, no file) -> 22
213
-
214
- The environment variable wins, then the git-ignored file, then the
215
- placeholder default.
216
-
217
- Returns:
218
- The ssh port number for the NAS.
219
- """
220
- port_from_environment = os.environ.get(NAS_SSH_PORT_ENV_VAR)
221
- if port_from_environment and port_from_environment.isdigit():
222
- return int(port_from_environment)
223
- stored_port = _nas_section_from_local_file().get(NAS_SSH_PORT_JSON_KEY)
224
- if isinstance(stored_port, int):
225
- return stored_port
226
- return PLACEHOLDER_NAS_SSH_PORT
227
-
228
-
229
- def _openssh_invocation_example() -> str:
230
- return (
231
- f"{OPENSSH_INVOCATION_EXAMPLE_BINARY} -o BatchMode=yes -o ConnectTimeout=10 "
232
- f'-p {nas_ssh_port()} {nas_ssh_user()}@{nas_host()} "<cmd>"'
233
- )
234
-
235
-
236
- def bare_ssh_binary_deny_message() -> str:
237
- """Return the deny message for a bare ssh-family word aimed at the NAS.
238
-
239
- ::
240
-
241
- ssh -p 22 operator@nas.example.local "ls" -> this message text
242
- (the text quotes the resolved host, user, and port)
243
-
244
- Names the Git Bash MSYS-ssh hang and points at the Windows OpenSSH binary
245
- with batch mode on.
246
-
247
- Returns:
248
- The full deny-message text for the bare ssh-family case.
249
- """
250
- return (
251
- f"BLOCKED [nas-ssh-binary]: Git Bash's MSYS ssh reads ~/.ssh/id_ed25519 as "
252
- f"world-readable through its ACL mapping, rejects the key as bad permissions, "
253
- f"and falls back to an interactive password prompt that hangs unattended "
254
- f"sessions against the NAS at {nas_host()}.\n\n"
255
- f"Use the Windows OpenSSH binary, which authenticates the key without prompting:\n"
256
- f" {_openssh_invocation_example()}\n\n"
257
- f"See {NAS_SSH_RULE_REFERENCE} for full guidance."
258
- )
259
-
260
-
261
- def missing_batch_mode_deny_message() -> str:
262
- """Return the deny message for the OpenSSH binary without batch mode.
263
-
264
- ::
265
-
266
- "..ssh.exe" -p 22 operator@nas.example.local "ls" -> this message text
267
- (the text quotes the resolved host, user, and port)
268
-
269
- Names the interactive-prompt hang and asks for ``-o BatchMode=yes`` so a key
270
- failure exits loudly.
271
-
272
- Returns:
273
- The full deny-message text for the missing-batch-mode case.
274
- """
275
- return (
276
- f"BLOCKED [nas-ssh-binary]: this NAS ssh command uses the Windows OpenSSH binary "
277
- f"but omits -o BatchMode=yes, so an authentication regression falls back to an "
278
- f"interactive password prompt that hangs unattended sessions against the NAS at "
279
- f"{nas_host()}.\n\n"
280
- f"Add -o BatchMode=yes so a key failure exits loudly rather than prompting:\n"
281
- f" {_openssh_invocation_example()}\n\n"
282
- f"See {NAS_SSH_RULE_REFERENCE} for full guidance."
283
- )
@@ -17,7 +17,6 @@ if str(_HOOKS_ROOT) not in sys.path:
17
17
  sys.path.insert(0, str(_HOOKS_ROOT))
18
18
 
19
19
  from hooks_constants.code_rules_enforcer_constants import (
20
- ALL_FREE_FORM_EXEMPT_COMMENT_BODIES,
21
20
  ALL_VALIDATION_PHASES,
22
21
  CONFIG_DIRECTORY_SEGMENT,
23
22
  CONSTANTS_MODULE_SUFFIX,
@@ -27,7 +26,6 @@ from hooks_constants.code_rules_enforcer_constants import (
27
26
  JSDOC_RETURNS_STRUCTURED_OBJECT_PROMISE_PATTERN,
28
27
  RETURN_CALL_OPENING_PARENTHESIS_PATTERN,
29
28
  SCHEMA_OPTIONS_PROPERTY_KEY_PATTERN,
30
- STEALTH_KEEP_COMMENT_MARKER,
31
29
  UNKNOWN_VALIDATION_PHASE_MESSAGE_TEMPLATE,
32
30
  apply_edits,
33
31
  edits_for_tool,
@@ -108,10 +106,6 @@ def test_constants_module_suffix_and_config_directory_segment_are_defined() -> N
108
106
  assert CONFIG_DIRECTORY_SEGMENT == "config"
109
107
 
110
108
 
111
- def test_free_form_exempt_comment_bodies_include_stealth_keep_marker() -> None:
112
- assert STEALTH_KEEP_COMMENT_MARKER in ALL_FREE_FORM_EXEMPT_COMMENT_BODIES
113
-
114
-
115
109
  def test_validation_phase_constants_are_reexported_and_render() -> None:
116
110
  assert ALL_VALIDATION_PHASES == {EDIT_LANE_PHASE, FULL_GATE_PHASE}
117
111
  rendered_message = UNKNOWN_VALIDATION_PHASE_MESSAGE_TEMPLATE.format(
@@ -1,4 +1,4 @@
1
- """Tests for the NAS local-identity loader used by the ssh enforcer hook."""
1
+ """Tests for the local-identity loader used by the PII hooks."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -14,7 +14,7 @@ except ImportError:
14
14
  sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
15
15
  from hooks_constants import local_identity
16
16
 
17
- ALL_NAS_ENV_VARS = ("CLAUDE_NAS_HOST", "CLAUDE_NAS_SSH_USER", "CLAUDE_NAS_SSH_PORT")
17
+ ALL_NAS_ENV_VARS = ("CLAUDE_NAS_HOST",)
18
18
 
19
19
 
20
20
  def _clear_nas_env() -> None:
@@ -23,30 +23,20 @@ def _clear_nas_env() -> None:
23
23
 
24
24
 
25
25
  class TestNasValuesFromEnvironment:
26
- def should_read_host_user_and_port_from_the_environment(self) -> None:
27
- with patch.dict(
28
- os.environ,
29
- {
30
- "CLAUDE_NAS_HOST": "10.0.0.5",
31
- "CLAUDE_NAS_SSH_USER": "tester",
32
- "CLAUDE_NAS_SSH_PORT": "2200",
33
- },
34
- clear=False,
35
- ):
26
+ def should_read_host_from_the_environment(self) -> None:
27
+ with patch.dict(os.environ, {"CLAUDE_NAS_HOST": "10.0.0.5"}, clear=False):
36
28
  assert local_identity.nas_host() == "10.0.0.5"
37
- assert local_identity.nas_ssh_user() == "tester"
38
- assert local_identity.nas_ssh_port() == 2200
39
29
 
40
30
 
41
31
  class TestNasValuesFromLocalFile:
42
- def should_read_host_user_and_port_from_the_local_identity_file(
32
+ def should_read_host_from_the_local_identity_file(
43
33
  self, tmp_path: Path
44
34
  ) -> None:
45
35
  claude_home = tmp_path / ".claude"
46
36
  claude_home.mkdir()
47
37
  (claude_home / "local-identity.json").write_text(
48
38
  json.dumps(
49
- {"nas": {"host": "10.1.1.9", "ssh_user": "fileuser", "ssh_port": 2222}}
39
+ {"nas": {"host": "10.1.1.9"}}
50
40
  ),
51
41
  encoding="utf-8",
52
42
  )
@@ -54,18 +44,14 @@ class TestNasValuesFromLocalFile:
54
44
  _clear_nas_env()
55
45
  with patch.object(Path, "home", return_value=tmp_path):
56
46
  assert local_identity.nas_host() == "10.1.1.9"
57
- assert local_identity.nas_ssh_user() == "fileuser"
58
- assert local_identity.nas_ssh_port() == 2222
59
47
 
60
48
 
61
49
  class TestNasValuesPlaceholderDefault:
62
- def should_return_placeholders_when_no_env_and_no_file(self, tmp_path: Path) -> None:
50
+ def should_return_placeholder_when_no_env_and_no_file(self, tmp_path: Path) -> None:
63
51
  with patch.dict(os.environ, {}, clear=False):
64
52
  _clear_nas_env()
65
53
  with patch.object(Path, "home", return_value=tmp_path):
66
54
  assert local_identity.nas_host() == "nas.example.local"
67
- assert local_identity.nas_ssh_user() == "operator"
68
- assert local_identity.nas_ssh_port() == 22
69
55
 
70
56
 
71
57
  class TestPiiExemptRepositorySlugs:
@@ -234,25 +220,3 @@ class TestLocalIdentityPathOverride:
234
220
  ), patch.object(Path, "home", return_value=tmp_path):
235
221
  _clear_nas_env()
236
222
  assert local_identity.nas_host() == "10.9.9.9"
237
-
238
-
239
- class TestDenyMessagesQuoteTheResolvedHost:
240
- def should_include_the_resolved_host_and_port_in_the_bare_binary_message(
241
- self,
242
- ) -> None:
243
- with patch.dict(
244
- os.environ,
245
- {"CLAUDE_NAS_HOST": "10.0.0.5", "CLAUDE_NAS_SSH_PORT": "2200"},
246
- clear=False,
247
- ):
248
- os.environ.pop("CLAUDE_NAS_SSH_USER", None)
249
- message = local_identity.bare_ssh_binary_deny_message()
250
- assert "10.0.0.5" in message
251
- assert "-p 2200" in message
252
- assert "BatchMode=yes" in message
253
-
254
- def should_include_the_resolved_host_in_the_missing_batch_mode_message(self) -> None:
255
- with patch.dict(os.environ, {"CLAUDE_NAS_HOST": "10.0.0.5"}, clear=False):
256
- message = local_identity.missing_batch_mode_deny_message()
257
- assert "10.0.0.5" in message
258
- assert "BatchMode=yes" in message
@@ -0,0 +1,45 @@
1
+ """Constants for the thread spawn pace PreToolUse hook.
2
+
3
+ Groups: the usage-pace script it runs, the reshape values for an over-pace
4
+ spawn, and the hook output keys and messages.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ USAGE_PACE_SCRIPT_ENV_VAR = "COORDINATOR_USAGE_PACE_SCRIPT"
10
+ USAGE_PACE_SCRIPT_FILE_NAME = "usage_pace.py"
11
+ USAGE_PACE_TIMEOUT_SECONDS = 15
12
+
13
+ TOOL_INPUT_KEY = "tool_input"
14
+ THREAD_MODEL_WHEN_OVER_PACE = "claude-sonnet-5-5"
15
+ THREAD_EFFORT_WHEN_OVER_PACE = "medium"
16
+ MODEL_INPUT_KEY = "model"
17
+ EFFORT_INPUT_KEY = "effort"
18
+ INSTRUCTIONS_INPUT_KEY = "instructions"
19
+ INSTRUCTIONS_MAXIMUM_BYTES = 8192
20
+ INSTRUCTIONS_SEPARATOR = "\n\n"
21
+ INSTRUCTIONS_ENCODING = "utf-8"
22
+ FABLE_ADVISOR_LINE = (
23
+ "Mandatory Fable advisor: usage is over pace, so this thread runs on "
24
+ "Sonnet 5.5 at medium effort. Invoke /team-advisor before substantive "
25
+ "work and bind a Fable advisor through it. Consult that advisor again "
26
+ "before you report done, and name its verdict in your report."
27
+ )
28
+
29
+ PERMISSION_DENY = "deny"
30
+ PERMISSION_DECISION_REASON_KEY = "permissionDecisionReason"
31
+ ADDITIONAL_CONTEXT_KEY = "additionalContext"
32
+ NOT_AN_OBJECT_REASON = "start_thread_session input is not a JSON object"
33
+ NO_INSTRUCTIONS_REASON = "start_thread_session input has no instructions text"
34
+ OVER_BYTE_CAP_REASON_TEMPLATE = (
35
+ "usage is over pace and the brief plus the mandatory Fable advisor line is "
36
+ "{reshaped_byte_count} bytes, over the {maximum_bytes}-byte cap; "
37
+ "shorten the instructions by {excess_byte_count} bytes"
38
+ )
39
+ RESHAPE_CONTEXT_PREFIX = (
40
+ "Usage over pace or unreadable: this thread spawn now runs on "
41
+ "claude-sonnet-5-5 at medium effort with a mandatory Fable advisor line. "
42
+ "Pace verdict: "
43
+ )
44
+ PACE_VERDICT_CONTEXT_MAXIMUM_CHARACTERS = 600
45
+ LAUNCH_FAILURE_ERROR_TEMPLATE = "usage pace did not finish: {failure_type}"
@@ -0,0 +1,49 @@
1
+ """Constants for the usage-pace reader that the thread spawn pace hook runs.
2
+
3
+ Groups: the OAuth usage endpoint probe, the token and certificate sources,
4
+ the rate windows and their lengths, the exit codes, and the JSON keys of the
5
+ verdict.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import timedelta
11
+
12
+ USAGE_ENDPOINT_URL = "https://api.anthropic.com/api/oauth/usage"
13
+ ANTHROPIC_BETA_HEADER = "anthropic-beta"
14
+ USAGE_BETA_VALUE = "oauth-2025-04-20"
15
+ AUTHORIZATION_HEADER = "Authorization"
16
+ BEARER_PREFIX = "Bearer "
17
+ CONTENT_TYPE_HEADER_NAME = "Content-Type"
18
+ CONTENT_TYPE_JSON = "application/json"
19
+ PROBE_TIMEOUT_SECONDS = 8
20
+
21
+ INGRESS_BEARER_FILE_ENV_VAR = "CLAUDE_SESSION_INGRESS_TOKEN_FILE"
22
+ SSL_CERT_FILE_ENV_VAR = "SSL_CERT_FILE"
23
+ CONTAINER_PROXY_CA_BUNDLE_PATH = "/root/.ccr/ca-bundle.crt"
24
+
25
+ UTILIZATION_KEY = "utilization"
26
+ RESETS_AT_KEY = "resets_at"
27
+ ISO_UTC_SUFFIX = "Z"
28
+ ISO_UTC_OFFSET = "+00:00"
29
+ ALL_RATE_WINDOW_LENGTHS = (
30
+ ("five_hour", timedelta(hours=5)),
31
+ ("seven_day", timedelta(days=7)),
32
+ )
33
+ PERCENT_SCALE = 100.0
34
+ PERCENT_DECIMAL_PLACES = 1
35
+ MINIMUM_ELAPSED_PERCENT_FOR_PACE = 10.0
36
+
37
+ EXIT_CODE_OVER_PACE = 0
38
+ EXIT_CODE_UNDER_PACE = 1
39
+ EXIT_CODE_UNREADABLE = 2
40
+
41
+ RESULT_KEY_OVER_PACE = "over_pace"
42
+ RESULT_KEY_CHECKED_AT = "checked_at"
43
+ RESULT_KEY_WINDOWS = "windows"
44
+ RESULT_KEY_ERROR = "error"
45
+ WINDOW_KEY_NAME = "window"
46
+ WINDOW_KEY_USED_PERCENT = "used_percent"
47
+ WINDOW_KEY_ELAPSED_PERCENT = "elapsed_percent"
48
+ WINDOW_KEY_RESETS_AT = "resets_at"
49
+ WINDOW_KEY_OVER_PACE = "over_pace"
@@ -0,0 +1,10 @@
1
+ """Add hooks/routing and hooks/ to sys.path for every test collected under this directory."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ _ROUTING_DIRECTORY = str(Path(__file__).resolve().parent)
7
+ _HOOKS_DIRECTORY = str(Path(_ROUTING_DIRECTORY).parent)
8
+ for each_directory in (_ROUTING_DIRECTORY, _HOOKS_DIRECTORY):
9
+ if each_directory not in sys.path:
10
+ sys.path.insert(0, each_directory)
@@ -0,0 +1,139 @@
1
+ import json
2
+ import os
3
+ import subprocess
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+ from hooks_constants.thread_spawn_pace_hook_constants import FABLE_ADVISOR_LINE
9
+
10
+ HOOK_SCRIPT = Path(__file__).resolve().parent / "thread_spawn_pace_hook.py"
11
+ SPAWN_INPUT = {
12
+ "thread_id": "cmsg_example",
13
+ "title": "Example thread",
14
+ "ack": "Starting on the example first.",
15
+ "instructions": "Do the example task.",
16
+ "context_message_ids": ["cmsg_example"],
17
+ }
18
+
19
+
20
+ def _write_pace_stub(
21
+ tmp_path: Path, exit_code: int, verdict: dict[str, object]
22
+ ) -> Path:
23
+ stub_path = tmp_path / f"pace_stub_{exit_code}.py"
24
+ stub_path.write_text(
25
+ f"import sys\nprint({json.dumps(json.dumps(verdict))})\nsys.exit({exit_code})\n",
26
+ encoding="utf-8",
27
+ )
28
+ return stub_path
29
+
30
+
31
+ def _run_hook(pace_script: Path, tool_input: object) -> str:
32
+ hook_payload = {
33
+ "hook_event_name": "PreToolUse",
34
+ "tool_name": "mcp__hearthbot__start_thread_session",
35
+ "tool_input": tool_input,
36
+ }
37
+ completed = subprocess.run(
38
+ [sys.executable, str(HOOK_SCRIPT)],
39
+ input=json.dumps(hook_payload),
40
+ capture_output=True,
41
+ text=True,
42
+ env={**os.environ, "COORDINATOR_USAGE_PACE_SCRIPT": str(pace_script)},
43
+ check=True,
44
+ )
45
+ return completed.stdout
46
+
47
+
48
+ def _hook_specific_output(stdout: str) -> dict[str, object]:
49
+ return json.loads(stdout)["hookSpecificOutput"]
50
+
51
+
52
+ def _assert_reshaped(stdout: str) -> None:
53
+ decision = _hook_specific_output(stdout)
54
+ assert decision["hookEventName"] == "PreToolUse"
55
+ assert decision["permissionDecision"] == "allow"
56
+ assert decision["updatedInput"] == {
57
+ **SPAWN_INPUT,
58
+ "model": "claude-sonnet-5-5",
59
+ "effort": "medium",
60
+ "instructions": f"Do the example task.\n\n{FABLE_ADVISOR_LINE}",
61
+ }
62
+
63
+
64
+ def test_should_reshape_the_spawn_when_usage_is_over_pace(tmp_path: Path) -> None:
65
+ _assert_reshaped(
66
+ _run_hook(_write_pace_stub(tmp_path, 0, {"over_pace": True}), SPAWN_INPUT)
67
+ )
68
+
69
+
70
+ def test_should_pass_the_spawn_through_unchanged_when_usage_is_under_pace(
71
+ tmp_path: Path,
72
+ ) -> None:
73
+ assert (
74
+ _run_hook(_write_pace_stub(tmp_path, 1, {"over_pace": False}), SPAWN_INPUT)
75
+ == ""
76
+ )
77
+
78
+
79
+ def test_should_reshape_the_spawn_when_usage_is_unreadable(tmp_path: Path) -> None:
80
+ _assert_reshaped(
81
+ _run_hook(
82
+ _write_pace_stub(tmp_path, 2, {"over_pace": None, "error": "x"}),
83
+ SPAWN_INPUT,
84
+ )
85
+ )
86
+
87
+
88
+ def test_should_reshape_the_spawn_when_the_pace_script_is_missing(
89
+ tmp_path: Path,
90
+ ) -> None:
91
+ _assert_reshaped(_run_hook(tmp_path / "absent.py", SPAWN_INPUT))
92
+
93
+
94
+ def test_should_append_the_advisor_line_once(tmp_path: Path) -> None:
95
+ already_reshaped = {
96
+ **SPAWN_INPUT,
97
+ "instructions": f"Do the example task.\n\n{FABLE_ADVISOR_LINE}",
98
+ }
99
+ stdout = _run_hook(
100
+ _write_pace_stub(tmp_path, 0, {"over_pace": True}), already_reshaped
101
+ )
102
+ assert (
103
+ _hook_specific_output(stdout)["updatedInput"]["instructions"].count(
104
+ FABLE_ADVISOR_LINE
105
+ )
106
+ == 1
107
+ )
108
+
109
+
110
+ @pytest.mark.parametrize(
111
+ ("tool_input", "reason"),
112
+ [
113
+ (["not", "an", "object"], "start_thread_session input is not a JSON object"),
114
+ ({"title": "No brief"}, "start_thread_session input has no instructions text"),
115
+ ],
116
+ )
117
+ def test_should_deny_a_spawn_it_cannot_reshape(
118
+ tmp_path: Path, tool_input: object, reason: str
119
+ ) -> None:
120
+ decision = _hook_specific_output(
121
+ _run_hook(_write_pace_stub(tmp_path, 0, {"over_pace": True}), tool_input)
122
+ )
123
+ assert decision["permissionDecision"] == "deny"
124
+ assert decision["permissionDecisionReason"] == reason
125
+ assert "updatedInput" not in decision
126
+
127
+
128
+ def test_should_deny_a_brief_the_advisor_line_would_push_past_the_byte_cap(
129
+ tmp_path: Path,
130
+ ) -> None:
131
+ full_brief = {**SPAWN_INPUT, "instructions": "x" * 8100}
132
+ decision = _hook_specific_output(
133
+ _run_hook(_write_pace_stub(tmp_path, 0, {"over_pace": True}), full_brief)
134
+ )
135
+ assert decision["permissionDecision"] == "deny"
136
+ over_by = 8100 + 2 + len(FABLE_ADVISOR_LINE.encode("utf-8")) - 8192
137
+ assert decision["permissionDecisionReason"].endswith(
138
+ f"shorten the instructions by {over_by} bytes"
139
+ )
@@ -0,0 +1,142 @@
1
+ import json
2
+ import subprocess
3
+ import sys
4
+ from datetime import datetime, timedelta, timezone
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+ from usage_pace import (
9
+ UsageUnreadable,
10
+ build_verdict,
11
+ measure_account_pace,
12
+ measure_window_pace,
13
+ )
14
+
15
+ USAGE_PACE_SCRIPT = Path(__file__).resolve().parent / "usage_pace.py"
16
+ NOW = datetime(2026, 9, 29, 13, 57, 40, tzinfo=timezone.utc)
17
+ FIVE_HOUR_RESET = "2026-09-29T18:50:00+00:00"
18
+ WEEKLY_RESET = "2026-10-03T20:00:00+00:00"
19
+
20
+
21
+ def _payload(five_hour_used: float, weekly_used: float) -> dict[str, object]:
22
+ return {
23
+ "five_hour": {"utilization": five_hour_used, "resets_at": FIVE_HOUR_RESET},
24
+ "seven_day": {"utilization": weekly_used, "resets_at": WEEKLY_RESET},
25
+ "seven_day_opus": None,
26
+ }
27
+
28
+
29
+ def _run_script(tmp_path: Path, payload: object) -> tuple[int, dict[str, object]]:
30
+ payload_path = tmp_path / "usage.json"
31
+ payload_path.write_text(json.dumps(payload), encoding="utf-8")
32
+ completed = subprocess.run(
33
+ [
34
+ sys.executable,
35
+ str(USAGE_PACE_SCRIPT),
36
+ "--payload-file",
37
+ str(payload_path),
38
+ "--now",
39
+ NOW.isoformat(),
40
+ ],
41
+ capture_output=True,
42
+ text=True,
43
+ check=False,
44
+ )
45
+ return completed.returncode, json.loads(completed.stdout)
46
+
47
+
48
+ def test_should_hold_a_window_clear_before_ten_percent_of_it_has_elapsed() -> None:
49
+ pace = measure_window_pace(
50
+ "five_hour",
51
+ timedelta(hours=5),
52
+ {"utilization": 3.0, "resets_at": FIVE_HOUR_RESET},
53
+ NOW,
54
+ )
55
+ assert pace.elapsed_percent == 2.6
56
+ assert pace.over_pace is False
57
+
58
+
59
+ def test_should_flag_a_window_whose_spend_runs_ahead_of_the_clock_past_the_floor() -> (
60
+ None
61
+ ):
62
+ pace = measure_window_pace(
63
+ "five_hour",
64
+ timedelta(hours=5),
65
+ {"utilization": 50.0, "resets_at": "2026-09-29T17:00:00+00:00"},
66
+ NOW,
67
+ )
68
+ assert pace.elapsed_percent == 39.2
69
+ assert pace.over_pace is True
70
+
71
+
72
+ def test_should_clear_a_window_whose_spend_trails_the_clock() -> None:
73
+ pace = measure_window_pace(
74
+ "seven_day",
75
+ timedelta(days=7),
76
+ {"utilization": 20.0, "resets_at": WEEKLY_RESET},
77
+ NOW,
78
+ )
79
+ assert pace.elapsed_percent == 39.3
80
+ assert pace.over_pace is False
81
+
82
+
83
+ def test_should_clamp_elapsed_time_to_the_window_length() -> None:
84
+ long_past_reset = measure_window_pace(
85
+ "five_hour",
86
+ timedelta(hours=5),
87
+ {"utilization": 99.0, "resets_at": "2026-09-29T10:00:00Z"},
88
+ NOW,
89
+ )
90
+ assert long_past_reset.elapsed_percent == 100.0
91
+ assert long_past_reset.over_pace is False
92
+
93
+
94
+ def test_should_report_over_pace_when_any_window_runs_ahead() -> None:
95
+ verdict = build_verdict(measure_account_pace(_payload(1.0, 93.0), NOW), NOW)
96
+ assert verdict["over_pace"] is True
97
+ assert [each["over_pace"] for each in verdict["windows"]] == [False, True]
98
+
99
+
100
+ def test_should_report_under_pace_when_every_window_trails() -> None:
101
+ verdict = build_verdict(measure_account_pace(_payload(1.0, 20.0), NOW), NOW)
102
+ assert verdict["over_pace"] is False
103
+
104
+
105
+ @pytest.mark.parametrize(
106
+ "broken_payload",
107
+ [
108
+ {"seven_day": {"utilization": 20.0, "resets_at": WEEKLY_RESET}},
109
+ {
110
+ "five_hour": {"utilization": None, "resets_at": FIVE_HOUR_RESET},
111
+ "seven_day": {"utilization": 1.0, "resets_at": WEEKLY_RESET},
112
+ },
113
+ {
114
+ "five_hour": {"utilization": 1.0, "resets_at": "soon"},
115
+ "seven_day": {"utilization": 1.0, "resets_at": WEEKLY_RESET},
116
+ },
117
+ ],
118
+ )
119
+ def test_should_treat_a_missing_or_unreadable_window_as_unreadable(
120
+ broken_payload: dict[str, object],
121
+ ) -> None:
122
+ with pytest.raises(UsageUnreadable):
123
+ measure_account_pace(broken_payload, NOW)
124
+
125
+
126
+ def test_should_exit_zero_with_the_verdict_when_over_pace(tmp_path: Path) -> None:
127
+ exit_code, verdict = _run_script(tmp_path, _payload(3.0, 93.0))
128
+ assert exit_code == 0
129
+ assert verdict["over_pace"] is True
130
+
131
+
132
+ def test_should_exit_one_when_under_pace(tmp_path: Path) -> None:
133
+ exit_code, verdict = _run_script(tmp_path, _payload(1.0, 20.0))
134
+ assert exit_code == 1
135
+ assert verdict["over_pace"] is False
136
+
137
+
138
+ def test_should_exit_two_with_a_null_verdict_when_unreadable(tmp_path: Path) -> None:
139
+ exit_code, verdict = _run_script(tmp_path, {"five_hour": None})
140
+ assert exit_code == 2
141
+ assert verdict["over_pace"] is None
142
+ assert verdict["error"] == "usage response has no five_hour window"