claude-dev-env 8.25.1 → 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.
@@ -13,7 +13,7 @@ Do not add code comments. Preserve existing comments. Docstrings remain allowed.
13
13
  When a change touches code that an existing comment describes or is attached to, remove that comment in the same change and carry its meaning through clear names and structure. Leave comments tied to untouched code unchanged. Keep comment cleanup inside the requested task.
14
14
  Production and tests follow one rule. Changed directive, TODO, FIXME, HACK, XXX, and type-ignore comments are removed rather than added or justified.
15
15
 
16
- A keep marker is the one comment that may be added and kept: a comment that opens with `STEALTH: Keep`, or with a prefix the repository lists under `comment_keep_markers` in `.claude/policy-lint.json`.
16
+ A keep marker is the one comment that may be added and kept: a comment that opens with a prefix the repository lists under `comment_keep_markers` in `.claude/policy-lint.json`.
17
17
 
18
18
  ---
19
19
 
@@ -36,7 +36,6 @@ from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
36
36
  REPOSITORY_LINT_SETTINGS_RELATIVE_PATH,
37
37
  REPOSITORY_ROOT_MARKER_NAME,
38
38
  SETTINGS_TEXT_ENCODING,
39
- STEALTH_KEEP_COMMENT_MARKER,
40
39
  )
41
40
  _javascript_comment_scanner = importlib.import_module("javascript_comment_scanner")
42
41
  extract_javascript_comment_occurrences = (
@@ -180,26 +179,22 @@ def comment_keep_markers(file_path: str) -> tuple[str, ...]:
180
179
  ::
181
180
 
182
181
  repo/.claude/policy-lint.json {"comment_keep_markers": ["VENDOR: Keep"]}
183
- repo/app/main.py ("STEALTH: Keep", "VENDOR: Keep")
184
- no settings file ("STEALTH: Keep",)
182
+ repo/app/main.py ("VENDOR: Keep",)
183
+ no settings file ()
185
184
 
186
185
  The repository that holds the file names its own markers. A missing or
187
- unreadable settings file adds none.
186
+ unreadable settings file names none.
188
187
 
189
188
  Args:
190
189
  file_path: The path of the file under check.
191
190
 
192
191
  Returns:
193
- The package's built-in marker, then the markers the file's repository
194
- adds.
192
+ The markers the file's repository names.
195
193
  """
196
194
  for each_directory in Path(file_path).resolve().parents:
197
195
  if (each_directory / REPOSITORY_ROOT_MARKER_NAME).exists():
198
- return (
199
- STEALTH_KEEP_COMMENT_MARKER,
200
- *_repository_keep_markers(each_directory),
201
- )
202
- return (STEALTH_KEEP_COMMENT_MARKER,)
196
+ return _repository_keep_markers(each_directory)
197
+ return ()
203
198
 
204
199
 
205
200
  def _is_keep_occurrence(comment_text: str, all_keep_markers: tuple[str, ...]) -> bool:
@@ -230,10 +225,9 @@ def check_comment_changes(old_content: str, new_content: str, file_path: str) ->
230
225
  """Check for comment additions or removals between old and new content.
231
226
 
232
227
  Inline and standalone comments are blocking findings when added, except
233
- keep-marker occurrences (``STEALTH: Keep`` and any marker the file's
234
- repository names in ``.claude/policy-lint.json``), which are excluded
235
- entirely so the marker can be added and can persist across edits to its
236
- line.
228
+ keep-marker occurrences (a marker the file's repository names in
229
+ ``.claude/policy-lint.json``), which are excluded entirely so the marker
230
+ can be added and can persist across edits to its line.
237
231
  Existing comments can be removed when the touched code no longer needs them.
238
232
 
239
233
  When the file is Python and either *old_content* or *new_content* cannot
@@ -431,14 +425,11 @@ def _is_exempt_python_comment(comment_token: tokenize.TokenInfo) -> bool:
431
425
  token-anchored directive body never legitimately carries a ``#``
432
426
  (noqa codes, pylint symbols, and pragma directives contain none), so
433
427
  any inner ``#`` reliably marks chained prose. Free-form markers
434
- (``type:``, ``TODO``, ``FIXME``, ``HACK``, ``XXX``, ``STEALTH: Keep``)
435
- accept any trailing prose:
436
- ``# type:`` participates in the documented justification
437
- convention enforced by ``check_type_escape_hatches`` (which
438
- requires a trailing reason), the TODO-family markers carry
439
- annotation text by convention, and ``STEALTH: Keep`` is the
440
- acknowledged-bypass marker the CDP runtime-usage scanner reads
441
- from the flagged line together with its reason.
428
+ (``type:``, ``TODO``, ``FIXME``, ``HACK``, ``XXX``) accept any
429
+ trailing prose: ``# type:`` participates in the documented
430
+ justification convention enforced by ``check_type_escape_hatches``
431
+ (which requires a trailing reason), and the TODO-family markers carry
432
+ annotation text by convention.
442
433
  """
443
434
  comment_string = comment_token.string
444
435
  if comment_string.startswith("#!") and comment_token.start == (1, 0):
@@ -134,18 +134,15 @@ def test_check_comment_changes_allows_comment_removal_without_advisory(
134
134
  assert captured.err == ""
135
135
 
136
136
 
137
- def test_check_comment_changes_does_not_flag_added_stealth_keep_marker() -> None:
138
- old_content = "x = 1\n"
139
- new_content = "x = 1 # STEALTH: Keep -- reason\n"
140
- issues = code_rules_enforcer.check_comment_changes(old_content, new_content, "foo.py")
141
- assert issues == []
142
-
143
-
144
- def test_check_comment_changes_does_not_flag_stealth_keep_retained_on_touched_line() -> None:
145
- old_content = "x = 1 # STEALTH: Keep -- reason\n"
146
- new_content = "x = 2 # STEALTH: Keep -- reason\n"
147
- issues = code_rules_enforcer.check_comment_changes(old_content, new_content, "foo.py")
148
- assert issues == []
137
+ def test_check_comment_changes_flags_stealth_keep_in_a_repository_that_names_no_marker(
138
+ tmp_path: Path,
139
+ ) -> None:
140
+ file_path = _repository_file(tmp_path, None)
141
+ issues = code_rules_enforcer.check_comment_changes(
142
+ "x = 1\n", "x = 1 # STEALTH: Keep -- reason\n", file_path
143
+ )
144
+ assert len(issues) == 1
145
+ assert "comment added" in issues[0]
149
146
 
150
147
 
151
148
  @pytest.mark.parametrize(
@@ -156,16 +156,6 @@ def test_should_exempt_fixme_with_trailing_prose() -> None:
156
156
  assert _is_exempt_python_comment(token) is True
157
157
 
158
158
 
159
- def test_should_exempt_stealth_keep_with_trailing_reason() -> None:
160
- token = _build_comment_token("# STEALTH: Keep bypass reviewed in incident 4021")
161
- assert _is_exempt_python_comment(token) is True
162
-
163
-
164
- def test_should_flag_stealth_marker_missing_required_space_after_colon() -> None:
165
- token = _build_comment_token("# STEALTH:Keep bypass reviewed in incident 4021")
166
- assert _is_exempt_python_comment(token) is False
167
-
168
-
169
159
  def test_should_exempt_shebang_at_line_one_column_zero() -> None:
170
160
  token = tokenize.TokenInfo(
171
161
  type=tokenize.COMMENT,
package/hooks/hooks.json CHANGED
@@ -72,6 +72,16 @@
72
72
  }
73
73
  ]
74
74
  },
75
+ {
76
+ "matcher": "mcp__hearthbot__start_thread_session",
77
+ "hooks": [
78
+ {
79
+ "type": "command",
80
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/routing/thread_spawn_pace_hook.py",
81
+ "timeout": 30
82
+ }
83
+ ]
84
+ },
75
85
  {
76
86
  "matcher": "*",
77
87
  "hooks": [
@@ -133,7 +133,6 @@ ALL_TOKEN_ANCHORED_EXEMPT_COMMENT_BODIES: tuple[str, ...] = (
133
133
  "pragma:",
134
134
  )
135
135
  ALL_TOKEN_ANCHORED_DIRECTIVE_BOUNDARY_CHARACTERS: frozenset[str] = frozenset({":"})
136
- STEALTH_KEEP_COMMENT_MARKER: str = "STEALTH: Keep"
137
136
  REPOSITORY_LINT_SETTINGS_RELATIVE_PATH: str = ".claude/policy-lint.json"
138
137
  COMMENT_KEEP_MARKERS_KEY: str = "comment_keep_markers"
139
138
  REPOSITORY_ROOT_MARKER_NAME: str = ".git"
@@ -144,7 +143,6 @@ ALL_FREE_FORM_EXEMPT_COMMENT_BODIES: tuple[str, ...] = (
144
143
  "FIXME",
145
144
  "HACK",
146
145
  "XXX",
147
- STEALTH_KEEP_COMMENT_MARKER,
148
146
  )
149
147
  CHAINED_INLINE_COMMENT_PATTERN = re.compile(r"#")
150
148
  ALL_JAVASCRIPT_EXEMPT_COMMENT_PREFIXES: tuple[str, ...] = (
@@ -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(
@@ -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"
@@ -0,0 +1,220 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook: reshape a thread spawn when usage runs over pace.
3
+
4
+ Registered on ``mcp__hearthbot__start_thread_session``. It runs ``usage_pace.py``
5
+ beside it, or the script ``COORDINATOR_USAGE_PACE_SCRIPT`` names.
6
+
7
+ ::
8
+
9
+ usage under pace (exit 1) -> no output; the call runs unchanged
10
+ over pace (exit 0) or unreadable -> allow with updatedInput:
11
+ model "claude-sonnet-5-5", effort "medium",
12
+ instructions + the mandatory Fable advisor line
13
+ input that cannot be reshaped -> deny with a one-line reason
14
+
15
+ ``updatedInput`` is the whole tool input the call runs with, so it carries
16
+ every original key.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ import subprocess
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ hooks_root_directory = str(Path(__file__).resolve().parent.parent)
28
+ if hooks_root_directory not in sys.path:
29
+ sys.path.insert(0, hooks_root_directory)
30
+
31
+ from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
32
+ ALLOW_DECISION,
33
+ HOOK_EVENT_NAME,
34
+ )
35
+ from hooks_constants.pre_tool_use_allow_output import (
36
+ HOOK_EVENT_NAME_KEY,
37
+ HOOK_SPECIFIC_OUTPUT_KEY,
38
+ PERMISSION_DECISION_KEY,
39
+ UPDATED_INPUT_KEY,
40
+ )
41
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
42
+ from hooks_constants.thread_spawn_pace_hook_constants import (
43
+ ADDITIONAL_CONTEXT_KEY,
44
+ EFFORT_INPUT_KEY,
45
+ FABLE_ADVISOR_LINE,
46
+ INSTRUCTIONS_ENCODING,
47
+ INSTRUCTIONS_INPUT_KEY,
48
+ INSTRUCTIONS_MAXIMUM_BYTES,
49
+ INSTRUCTIONS_SEPARATOR,
50
+ LAUNCH_FAILURE_ERROR_TEMPLATE,
51
+ MODEL_INPUT_KEY,
52
+ NO_INSTRUCTIONS_REASON,
53
+ NOT_AN_OBJECT_REASON,
54
+ OVER_BYTE_CAP_REASON_TEMPLATE,
55
+ PACE_VERDICT_CONTEXT_MAXIMUM_CHARACTERS,
56
+ PERMISSION_DECISION_REASON_KEY,
57
+ PERMISSION_DENY,
58
+ RESHAPE_CONTEXT_PREFIX,
59
+ THREAD_EFFORT_WHEN_OVER_PACE,
60
+ THREAD_MODEL_WHEN_OVER_PACE,
61
+ TOOL_INPUT_KEY,
62
+ USAGE_PACE_SCRIPT_ENV_VAR,
63
+ USAGE_PACE_SCRIPT_FILE_NAME,
64
+ USAGE_PACE_TIMEOUT_SECONDS,
65
+ )
66
+ from hooks_constants.usage_pace_constants import (
67
+ EXIT_CODE_UNDER_PACE,
68
+ EXIT_CODE_UNREADABLE,
69
+ RESULT_KEY_ERROR,
70
+ RESULT_KEY_OVER_PACE,
71
+ )
72
+
73
+
74
+ class SpawnNotReshapable(Exception):
75
+ """The spawn input cannot carry the over-pace model, effort, and advisor line."""
76
+
77
+
78
+ def _instructions_with_advisor_line(instructions: str) -> str:
79
+ reshaped_instructions = (
80
+ instructions
81
+ if FABLE_ADVISOR_LINE in instructions
82
+ else f"{instructions}{INSTRUCTIONS_SEPARATOR}{FABLE_ADVISOR_LINE}"
83
+ )
84
+ reshaped_byte_count = len(reshaped_instructions.encode(INSTRUCTIONS_ENCODING))
85
+ if reshaped_byte_count > INSTRUCTIONS_MAXIMUM_BYTES:
86
+ raise SpawnNotReshapable(
87
+ OVER_BYTE_CAP_REASON_TEMPLATE.format(
88
+ reshaped_byte_count=reshaped_byte_count,
89
+ maximum_bytes=INSTRUCTIONS_MAXIMUM_BYTES,
90
+ excess_byte_count=reshaped_byte_count - INSTRUCTIONS_MAXIMUM_BYTES,
91
+ )
92
+ )
93
+ return reshaped_instructions
94
+
95
+
96
+ def reshape_thread_spawn(tool_input: object) -> dict[str, object]:
97
+ """Move a thread spawn to Sonnet at medium effort with a Fable advisor line.
98
+
99
+ ::
100
+
101
+ {"title": "t", "instructions": "Do X."}
102
+ -> {"title": "t", "instructions": "Do X.\\n\\nMandatory Fable advisor: ...",
103
+ "model": "claude-sonnet-5-5", "effort": "medium"}
104
+
105
+ An instructions text that already carries the advisor line keeps one copy.
106
+
107
+ Args:
108
+ tool_input: The ``tool_input`` object of the start_thread_session call.
109
+
110
+ Raises:
111
+ SpawnNotReshapable: The input is not an object, has no instructions
112
+ text, or would pass the server's instructions byte cap.
113
+ """
114
+ if not isinstance(tool_input, dict):
115
+ raise SpawnNotReshapable(NOT_AN_OBJECT_REASON)
116
+ instructions = tool_input.get(INSTRUCTIONS_INPUT_KEY)
117
+ if not isinstance(instructions, str):
118
+ raise SpawnNotReshapable(NO_INSTRUCTIONS_REASON)
119
+ return {
120
+ **tool_input,
121
+ MODEL_INPUT_KEY: THREAD_MODEL_WHEN_OVER_PACE,
122
+ EFFORT_INPUT_KEY: THREAD_EFFORT_WHEN_OVER_PACE,
123
+ INSTRUCTIONS_INPUT_KEY: _instructions_with_advisor_line(instructions),
124
+ }
125
+
126
+
127
+ def _hook_output(all_decision_fields: dict[str, object]) -> dict[str, object]:
128
+ return {
129
+ HOOK_SPECIFIC_OUTPUT_KEY: {
130
+ HOOK_EVENT_NAME_KEY: HOOK_EVENT_NAME,
131
+ **all_decision_fields,
132
+ }
133
+ }
134
+
135
+
136
+ def _deny_output(reason: str) -> dict[str, object]:
137
+ return _hook_output(
138
+ {PERMISSION_DECISION_KEY: PERMISSION_DENY, PERMISSION_DECISION_REASON_KEY: reason}
139
+ )
140
+
141
+
142
+ def decide_hook_output(
143
+ tool_input: object, pace_exit_code: int, pace_verdict_text: str
144
+ ) -> dict[str, object] | None:
145
+ """Choose the hook's output for one spawn and one pace reading.
146
+
147
+ Args:
148
+ tool_input: The ``tool_input`` of the PreToolUse payload, or None when
149
+ stdin held no JSON object.
150
+ pace_exit_code: The usage-pace exit code; anything but under pace
151
+ counts as over pace.
152
+ pace_verdict_text: The usage-pace stdout, quoted into the context.
153
+
154
+ Returns:
155
+ None to pass the call through unchanged, else the hook JSON output.
156
+ """
157
+ if pace_exit_code == EXIT_CODE_UNDER_PACE:
158
+ return None
159
+ try:
160
+ reshaped_input = reshape_thread_spawn(tool_input)
161
+ except SpawnNotReshapable as not_reshapable:
162
+ return _deny_output(str(not_reshapable))
163
+ verdict_excerpt = pace_verdict_text.strip()[:PACE_VERDICT_CONTEXT_MAXIMUM_CHARACTERS]
164
+ return _hook_output(
165
+ {
166
+ PERMISSION_DECISION_KEY: ALLOW_DECISION,
167
+ UPDATED_INPUT_KEY: reshaped_input,
168
+ ADDITIONAL_CONTEXT_KEY: RESHAPE_CONTEXT_PREFIX + verdict_excerpt,
169
+ }
170
+ )
171
+
172
+
173
+ def run_usage_pace() -> tuple[int, str]:
174
+ """Run the usage-pace script and return its exit code and stdout.
175
+
176
+ Returns:
177
+ The exit code and stdout. A missing script, a launch failure, or a
178
+ timeout returns the unreadable exit code.
179
+ """
180
+ pace_script = os.environ.get(USAGE_PACE_SCRIPT_ENV_VAR) or str(
181
+ Path(__file__).resolve().parent / USAGE_PACE_SCRIPT_FILE_NAME
182
+ )
183
+ try:
184
+ completed = subprocess.run(
185
+ [sys.executable, pace_script],
186
+ capture_output=True,
187
+ text=True,
188
+ timeout=USAGE_PACE_TIMEOUT_SECONDS,
189
+ check=False,
190
+ )
191
+ except (OSError, subprocess.TimeoutExpired) as launch_error:
192
+ return EXIT_CODE_UNREADABLE, json.dumps(
193
+ {
194
+ RESULT_KEY_OVER_PACE: None,
195
+ RESULT_KEY_ERROR: LAUNCH_FAILURE_ERROR_TEMPLATE.format(
196
+ failure_type=type(launch_error).__name__
197
+ ),
198
+ }
199
+ )
200
+ return completed.returncode, completed.stdout
201
+
202
+
203
+ def main() -> int:
204
+ """Read the PreToolUse payload, read the pace, and print the decision.
205
+
206
+ Returns:
207
+ 0 in every case; a deny travels in the JSON output.
208
+ """
209
+ hook_payload = read_hook_input_dictionary_from_stdin()
210
+ tool_input = hook_payload.get(TOOL_INPUT_KEY) if hook_payload is not None else None
211
+ pace_exit_code, pace_verdict_text = run_usage_pace()
212
+ hook_output = decide_hook_output(tool_input, pace_exit_code, pace_verdict_text)
213
+ if hook_output is not None:
214
+ sys.stdout.write(json.dumps(hook_output))
215
+ sys.stdout.flush()
216
+ return 0
217
+
218
+
219
+ if __name__ == "__main__":
220
+ sys.exit(main())
@@ -0,0 +1,345 @@
1
+ #!/usr/bin/env python3
2
+ """Report whether the account running the project spends faster than its windows reset.
3
+
4
+ ::
5
+
6
+ python usage_pace.py
7
+ {"over_pace": true, "checked_at": "2026-09-29T13:57:40+00:00",
8
+ "windows": [{"window": "five_hour", "used_percent": 3.0,
9
+ "elapsed_percent": 2.6, "resets_at": "...", "over_pace": false},
10
+ {"window": "seven_day", "used_percent": 93.0,
11
+ "elapsed_percent": 39.3, "resets_at": "...", "over_pace": true}]}
12
+
13
+ A window is over pace when at least 10% of it has elapsed and its used percent
14
+ exceeds the percent already elapsed. The floor keeps a window's first minutes,
15
+ where any spend outruns the clock, from reading as over pace. The account is over pace when any window is. The script reads
16
+ the OAuth usage endpoint with the session ingress bearer token, the same probe
17
+ the usage-pause skill's ``resolve_usage_window.py`` uses. It never prints or
18
+ stores the token.
19
+
20
+ Exit codes: 0 over pace, 1 under pace, 2 unreadable. ``--payload-file`` and
21
+ ``--now`` replace the live probe and the clock for a deterministic run.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import json
28
+ import os
29
+ import ssl
30
+ import sys
31
+ import urllib.error
32
+ import urllib.request
33
+ from dataclasses import dataclass
34
+ from datetime import datetime, timedelta, timezone
35
+ from pathlib import Path
36
+
37
+ hooks_root_directory = str(Path(__file__).resolve().parent.parent)
38
+ if hooks_root_directory not in sys.path:
39
+ sys.path.insert(0, hooks_root_directory)
40
+
41
+ from hooks_constants.usage_pace_constants import (
42
+ ALL_RATE_WINDOW_LENGTHS,
43
+ ANTHROPIC_BETA_HEADER,
44
+ AUTHORIZATION_HEADER,
45
+ BEARER_PREFIX,
46
+ CONTAINER_PROXY_CA_BUNDLE_PATH,
47
+ CONTENT_TYPE_HEADER_NAME,
48
+ CONTENT_TYPE_JSON,
49
+ EXIT_CODE_OVER_PACE,
50
+ EXIT_CODE_UNDER_PACE,
51
+ EXIT_CODE_UNREADABLE,
52
+ INGRESS_BEARER_FILE_ENV_VAR,
53
+ ISO_UTC_OFFSET,
54
+ ISO_UTC_SUFFIX,
55
+ MINIMUM_ELAPSED_PERCENT_FOR_PACE,
56
+ PERCENT_DECIMAL_PLACES,
57
+ PERCENT_SCALE,
58
+ PROBE_TIMEOUT_SECONDS,
59
+ RESETS_AT_KEY,
60
+ RESULT_KEY_CHECKED_AT,
61
+ RESULT_KEY_ERROR,
62
+ RESULT_KEY_OVER_PACE,
63
+ RESULT_KEY_WINDOWS,
64
+ SSL_CERT_FILE_ENV_VAR,
65
+ USAGE_BETA_VALUE,
66
+ USAGE_ENDPOINT_URL,
67
+ UTILIZATION_KEY,
68
+ WINDOW_KEY_ELAPSED_PERCENT,
69
+ WINDOW_KEY_NAME,
70
+ WINDOW_KEY_OVER_PACE,
71
+ WINDOW_KEY_RESETS_AT,
72
+ WINDOW_KEY_USED_PERCENT,
73
+ )
74
+
75
+
76
+ class UsageUnreadable(Exception):
77
+ """The usage meters could not be read, so the pace is unknown."""
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class WindowPace:
82
+ """One rate window's spend against the time it has run.
83
+
84
+ Attributes:
85
+ name: The usage bucket key, such as ``five_hour``.
86
+ used_percent: Percent of the window's allowance spent.
87
+ elapsed_percent: Percent of the window's length already passed.
88
+ resets_at: When the window resets.
89
+ """
90
+
91
+ name: str
92
+ used_percent: float
93
+ elapsed_percent: float
94
+ resets_at: datetime
95
+
96
+ @property
97
+ def over_pace(self) -> bool:
98
+ """True when spend runs ahead of the clock past the window's opening stretch.
99
+
100
+ ::
101
+
102
+ elapsed 2.6%, used 3.0% -> False (under the 10% floor)
103
+ elapsed 39.3%, used 93.0% -> True
104
+ elapsed 39.3%, used 20.0% -> False
105
+ """
106
+ return (
107
+ self.elapsed_percent >= MINIMUM_ELAPSED_PERCENT_FOR_PACE
108
+ and self.used_percent > self.elapsed_percent
109
+ )
110
+
111
+
112
+ def _parse_resets_at(raw_resets_at: object, window_name: str) -> datetime:
113
+ if not isinstance(raw_resets_at, str):
114
+ raise UsageUnreadable(f"{window_name} has no resets_at time")
115
+ try:
116
+ parsed = datetime.fromisoformat(raw_resets_at.replace(ISO_UTC_SUFFIX, ISO_UTC_OFFSET))
117
+ except ValueError as parse_error:
118
+ raise UsageUnreadable(f"{window_name} resets_at is unreadable") from parse_error
119
+ if parsed.tzinfo is None:
120
+ raise UsageUnreadable(f"{window_name} resets_at carries no time zone")
121
+ return parsed
122
+
123
+
124
+ def _read_utilization(all_bucket_fields: dict[object, object], window_name: str) -> float:
125
+ raw_utilization = all_bucket_fields.get(UTILIZATION_KEY)
126
+ if isinstance(raw_utilization, bool) or not isinstance(raw_utilization, (int, float)):
127
+ raise UsageUnreadable(f"{window_name} has no utilization number")
128
+ return float(raw_utilization)
129
+
130
+
131
+ def _elapsed_percent(window_length: timedelta, resets_at: datetime, now: datetime) -> float:
132
+ elapsed_fraction = (now - (resets_at - window_length)) / window_length
133
+ clamped_elapsed_fraction = min(max(elapsed_fraction, 0.0), 1.0)
134
+ return round(clamped_elapsed_fraction * PERCENT_SCALE, PERCENT_DECIMAL_PLACES)
135
+
136
+
137
+ def measure_window_pace(
138
+ window_name: str, window_length: timedelta, bucket: object, now: datetime
139
+ ) -> WindowPace:
140
+ """Measure one usage bucket's spend against its elapsed time.
141
+
142
+ ::
143
+
144
+ five_hour, 5h, {"utilization": 3.0, "resets_at": 18:50Z}, now 13:58Z
145
+ -> WindowPace("five_hour", 3.0, 2.7, 18:50Z) over pace
146
+
147
+ Elapsed time counts from one window length before the reset, clamped to 0-100.
148
+
149
+ Args:
150
+ window_name: The usage bucket key.
151
+ window_length: How long the window runs.
152
+ bucket: The bucket object from the usage response.
153
+ now: The time the pace is measured at.
154
+
155
+ Raises:
156
+ UsageUnreadable: The bucket, its utilization, or its reset is unreadable.
157
+ """
158
+ if not isinstance(bucket, dict):
159
+ raise UsageUnreadable(f"usage response has no {window_name} window")
160
+ resets_at = _parse_resets_at(bucket.get(RESETS_AT_KEY), window_name)
161
+ return WindowPace(
162
+ name=window_name,
163
+ used_percent=_read_utilization(bucket, window_name),
164
+ elapsed_percent=_elapsed_percent(window_length, resets_at, now),
165
+ resets_at=resets_at,
166
+ )
167
+
168
+
169
+ def measure_account_pace(
170
+ all_usage_buckets: dict[str, object], now: datetime
171
+ ) -> list[WindowPace]:
172
+ """Measure every rate window the verdict covers.
173
+
174
+ Args:
175
+ all_usage_buckets: The decoded usage response body.
176
+ now: The time the pace is measured at.
177
+
178
+ Returns:
179
+ One WindowPace per rate window, five-hour first.
180
+
181
+ Raises:
182
+ UsageUnreadable: Any covered window is missing or unreadable.
183
+ """
184
+ return [
185
+ measure_window_pace(each_name, each_length, all_usage_buckets.get(each_name), now)
186
+ for each_name, each_length in ALL_RATE_WINDOW_LENGTHS
187
+ ]
188
+
189
+
190
+ def build_verdict(
191
+ all_window_paces: list[WindowPace], now: datetime
192
+ ) -> dict[str, object]:
193
+ """Assemble the JSON verdict for a set of measured windows.
194
+
195
+ Args:
196
+ all_window_paces: The measured windows.
197
+ now: The time the pace was measured at.
198
+
199
+ Returns:
200
+ The verdict mapping: the account-level flag, the check time, and
201
+ each window's percents, reset time, and flag.
202
+ """
203
+ return {
204
+ RESULT_KEY_OVER_PACE: any(
205
+ each_pace.over_pace for each_pace in all_window_paces
206
+ ),
207
+ RESULT_KEY_CHECKED_AT: now.isoformat(),
208
+ RESULT_KEY_WINDOWS: [
209
+ {
210
+ WINDOW_KEY_NAME: each_pace.name,
211
+ WINDOW_KEY_USED_PERCENT: each_pace.used_percent,
212
+ WINDOW_KEY_ELAPSED_PERCENT: each_pace.elapsed_percent,
213
+ WINDOW_KEY_RESETS_AT: each_pace.resets_at.isoformat(),
214
+ WINDOW_KEY_OVER_PACE: each_pace.over_pace,
215
+ }
216
+ for each_pace in all_window_paces
217
+ ],
218
+ }
219
+
220
+
221
+ def _read_session_ingress_bearer() -> str:
222
+ configured_path = os.environ.get(INGRESS_BEARER_FILE_ENV_VAR)
223
+ if not configured_path:
224
+ raise UsageUnreadable(f"{INGRESS_BEARER_FILE_ENV_VAR} is unset")
225
+ try:
226
+ file_text = Path(configured_path).read_text(encoding="utf-8").strip()
227
+ except (OSError, UnicodeDecodeError) as read_error:
228
+ raise UsageUnreadable("session ingress token file is unreadable") from read_error
229
+ if not file_text:
230
+ raise UsageUnreadable("session ingress token file is empty")
231
+ return file_text
232
+
233
+
234
+ def _certificate_context() -> ssl.SSLContext:
235
+ named_bundle = os.environ.get(SSL_CERT_FILE_ENV_VAR)
236
+ if named_bundle:
237
+ return ssl.create_default_context(cafile=named_bundle)
238
+ if Path(CONTAINER_PROXY_CA_BUNDLE_PATH).is_file():
239
+ return ssl.create_default_context(cafile=CONTAINER_PROXY_CA_BUNDLE_PATH)
240
+ return ssl.create_default_context()
241
+
242
+
243
+ def _build_usage_request() -> urllib.request.Request:
244
+ return urllib.request.Request(
245
+ USAGE_ENDPOINT_URL,
246
+ headers={
247
+ AUTHORIZATION_HEADER: f"{BEARER_PREFIX}{_read_session_ingress_bearer()}",
248
+ ANTHROPIC_BETA_HEADER: USAGE_BETA_VALUE,
249
+ CONTENT_TYPE_HEADER_NAME: CONTENT_TYPE_JSON,
250
+ },
251
+ )
252
+
253
+
254
+ def fetch_usage_payload() -> dict[str, object]:
255
+ """Read the usage of the account the session ingress token belongs to.
256
+
257
+ Returns:
258
+ The decoded response body.
259
+
260
+ Raises:
261
+ UsageUnreadable: No token, a failed request, or a body that is not a
262
+ JSON object. The message names the failure type, never the token.
263
+ """
264
+ try:
265
+ with urllib.request.urlopen(
266
+ _build_usage_request(),
267
+ timeout=PROBE_TIMEOUT_SECONDS,
268
+ context=_certificate_context(),
269
+ ) as usage_reply:
270
+ decoded = json.loads(usage_reply.read())
271
+ except urllib.error.HTTPError as http_error:
272
+ raise UsageUnreadable(f"usage endpoint answered HTTP {http_error.code}") from http_error
273
+ except (urllib.error.URLError, TimeoutError, OSError, ValueError) as probe_error:
274
+ raise UsageUnreadable(
275
+ f"usage probe failed with {type(probe_error).__name__}"
276
+ ) from probe_error
277
+ if not isinstance(decoded, dict):
278
+ raise UsageUnreadable("usage response body is not a JSON object")
279
+ return decoded
280
+
281
+
282
+ def _read_payload_file(payload_path: str) -> dict[str, object]:
283
+ try:
284
+ decoded = json.loads(Path(payload_path).read_text(encoding="utf-8"))
285
+ except (OSError, ValueError) as read_error:
286
+ raise UsageUnreadable(
287
+ f"payload file unreadable: {type(read_error).__name__}"
288
+ ) from read_error
289
+ if not isinstance(decoded, dict):
290
+ raise UsageUnreadable("payload file is not a JSON object")
291
+ return decoded
292
+
293
+
294
+ def _parse_arguments() -> argparse.Namespace:
295
+ parser = argparse.ArgumentParser(
296
+ description="Report whether the account running the project spends faster than its windows reset.",
297
+ )
298
+ parser.add_argument(
299
+ "--payload-file",
300
+ default=None,
301
+ help="A saved usage response to read instead of probing the endpoint.",
302
+ )
303
+ parser.add_argument(
304
+ "--now",
305
+ default=None,
306
+ help="ISO-8601 time with a zone to measure at; defaults to the current time.",
307
+ )
308
+ return parser.parse_args()
309
+
310
+
311
+ def _measure_verdict(arguments: argparse.Namespace) -> dict[str, object]:
312
+ now = datetime.fromisoformat(arguments.now) if arguments.now else datetime.now(timezone.utc)
313
+ if now.tzinfo is None:
314
+ raise UsageUnreadable("--now carries no time zone")
315
+ all_usage_buckets = (
316
+ _read_payload_file(arguments.payload_file)
317
+ if arguments.payload_file
318
+ else fetch_usage_payload()
319
+ )
320
+ return build_verdict(measure_account_pace(all_usage_buckets, now), now)
321
+
322
+
323
+ def _write_json_line(all_document_fields: dict[str, object]) -> None:
324
+ sys.stdout.write(json.dumps(all_document_fields) + "\n")
325
+ sys.stdout.flush()
326
+
327
+
328
+ def main() -> int:
329
+ """Print the pace verdict JSON and return its exit code.
330
+
331
+ Returns:
332
+ 0 when any window is over pace, 1 when every window is under pace,
333
+ 2 when the meters or the arguments are unreadable.
334
+ """
335
+ try:
336
+ verdict = _measure_verdict(_parse_arguments())
337
+ except (UsageUnreadable, ValueError) as unreadable:
338
+ _write_json_line({RESULT_KEY_OVER_PACE: None, RESULT_KEY_ERROR: str(unreadable)})
339
+ return EXIT_CODE_UNREADABLE
340
+ _write_json_line(verdict)
341
+ return EXIT_CODE_OVER_PACE if verdict[RESULT_KEY_OVER_PACE] else EXIT_CODE_UNDER_PACE
342
+
343
+
344
+ if __name__ == "__main__":
345
+ sys.exit(main())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.25.1",
3
+ "version": "8.26.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -87,16 +87,12 @@ ROLE_BUGTEAM: str = "bugteam"
87
87
  ROLE_CLEAN_CODER: str = "clean-coder"
88
88
  """Compatibility role for FIX and standards-fix using poteto-agent."""
89
89
 
90
- ROLE_TRANSLATOR: str = "translator"
91
- """Role the shared_utils translation chain dispatches, served by poteto-agent."""
92
-
93
90
  DEFAULT_ROLE: str = ROLE_BUGTEAM
94
91
  """Role applied when the caller does not pass ``--role``."""
95
92
 
96
93
  ALL_AGENT_FILENAMES_BY_ROLE: dict[str, tuple[str, ...]] = {
97
94
  ROLE_BUGTEAM: ("poteto-agent.md",),
98
95
  ROLE_CLEAN_CODER: ("poteto-agent.md",),
99
- ROLE_TRANSLATOR: ("poteto-agent.md",),
100
96
  }
101
97
  """Agent definition filenames required under ``agents/`` for each known role."""
102
98
 
@@ -57,7 +57,6 @@ from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
57
57
  REASON_GROK_AUTH_FAILED,
58
58
  REASON_PROMPT_FILE_MISSING,
59
59
  REASON_TIMEOUT_OUT_OF_BOUNDS,
60
- ROLE_TRANSLATOR,
61
60
  RESULT_KEY_ATTEMPTS,
62
61
  RESULT_KEY_OK,
63
62
  RESULT_KEY_OUTPUT,
@@ -867,33 +866,6 @@ def test_tier_three_argv_includes_agent_for_default_role(
867
866
  )
868
867
 
869
868
 
870
- def test_translator_role_dispatches_the_poteto_agent_on_tier_three(
871
- monkeypatch: pytest.MonkeyPatch, tmp_path: Path
872
- ) -> None:
873
- prompt_file, working_directory, run_state_directory = _paths(tmp_path)
874
- call_log = _install_seams(
875
- monkeypatch,
876
- grok_outcome=_grok_failure(CLASSIFICATION_AUTH_FAILURE),
877
- claude_outcome=_claude_served(),
878
- host_profile=HOST_PROFILE_CLAUDE,
879
- )
880
-
881
- spawn_outcome = dispatcher.resolve_worker_spawn(
882
- role=ROLE_TRANSLATOR,
883
- prompt_file=prompt_file,
884
- working_directory=working_directory,
885
- timeout_seconds=DEFAULT_WORKER_TIMEOUT_SECONDS,
886
- is_claude_tier_enabled=True,
887
- run_state_directory=run_state_directory,
888
- )
889
-
890
- assert spawn_outcome.tier_used == TIER_CLAUDE_HEADLESS
891
- assert call_log.claude_arguments is not None
892
- agent_flag_index = call_log.claude_arguments.index(AGENT_FLAG)
893
- assert call_log.claude_arguments[agent_flag_index + 1] == "poteto-agent"
894
- assert ROLE_TRANSLATOR not in call_log.claude_arguments
895
-
896
-
897
869
  def test_large_prompt_stays_out_of_claude_argv(
898
870
  monkeypatch: pytest.MonkeyPatch, tmp_path: Path
899
871
  ) -> None: