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.
- package/docs/CODE_RULES.md +2 -2
- package/hooks/blocking/code_rules_boolean_mustcheck.py +1 -82
- package/hooks/blocking/code_rules_comments.py +14 -23
- package/hooks/blocking/code_rules_enforcer.py +0 -6
- package/hooks/blocking/code_rules_imports_logging.py +1 -120
- package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +9 -12
- package/hooks/blocking/test_code_rules_enforcer_exempt_marker_chained.py +0 -10
- package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +0 -16
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/blocking_check_limits.py +0 -13
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +0 -8
- package/hooks/hooks_constants/local_identity.py +7 -120
- package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +0 -6
- package/hooks/hooks_constants/test_local_identity.py +7 -43
- package/hooks/hooks_constants/thread_spawn_pace_hook_constants.py +45 -0
- package/hooks/hooks_constants/usage_pace_constants.py +49 -0
- package/hooks/routing/conftest.py +10 -0
- package/hooks/routing/test_thread_spawn_pace_hook.py +139 -0
- package/hooks/routing/test_usage_pace.py +142 -0
- package/hooks/routing/thread_spawn_pace_hook.py +220 -0
- package/hooks/routing/usage_pace.py +345 -0
- package/package.json +1 -1
- package/rules/shell-invocation.md +1 -1
- package/scripts/dev_env_scripts_constants/grok_worker_constants.py +0 -4
- package/scripts/repository_checks/config/constants.py +0 -5
- package/scripts/test_resolve_worker_spawn.py +0 -28
- package/docs/nas-ssh-invocation.md +0 -107
- package/hooks/blocking/test_code_rules_enforcer_ignored_must_check_return.py +0 -266
- package/hooks/blocking/test_code_rules_enforcer_split_mustcheck.py +0 -87
- package/hooks/blocking/test_code_rules_logging_printf_tokens.py +0 -134
- package/rules/nas-ssh-invocation.md +0 -29
|
@@ -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
|
@@ -6,7 +6,7 @@ Two constraints govern every shell command an agent issues: which shell runs it,
|
|
|
6
6
|
|
|
7
7
|
Every Bash-tool shell command on Windows uses `pwsh`: `pwsh -NoProfile -File '<script>.ps1' <args>` for scripts, `pwsh -NoProfile -Command "..."` (or a literal `@'...'@` here-string) for inline work, or the built-in `PowerShell` tool for pure-PowerShell workflows (it supports `run_in_background`). Never wrap a script path in `-Command "& '...'"` — `-File` keeps `permissions.allow` matching. The `&` call operator is fine for invoking an executable at a path (`& '<venv>\Scripts\python.exe' script.py`).
|
|
8
8
|
|
|
9
|
-
The mandate covers the shell a command runs through
|
|
9
|
+
The mandate covers the shell a command runs through. A direct interpreter invocation, such as a `python` call on a repo script, conforms as written.
|
|
10
10
|
|
|
11
11
|
Keep `powershell`, `powershell.exe`, `cmd /c`, and `bash -c` out of the `settings.json` permission rules. `Audit-ShellPolicy.ps1` reports those forms and `Migrate-ShellPolicy.ps1` rewrites them to `pwsh`. Both ship in the claude-dev-env repo at `packages/claude-dev-env/scripts/` and run on demand, not as a live gate.
|
|
12
12
|
|
|
@@ -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
|
|
|
@@ -147,10 +147,5 @@ ALL_TRACKED_SECRET_EXACT_EXEMPTIONS: frozenset[tuple[str, str, str]] = frozenset
|
|
|
147
147
|
"home-path",
|
|
148
148
|
"5d956228802fbd4a65af8fa7fb4183f5a9f8793dbe218877aefe887b905f726d",
|
|
149
149
|
),
|
|
150
|
-
(
|
|
151
|
-
"shared_utils/theme_assets/contracts/evidence/theme_editor_asset_resolution_master.oneui9.v1.json",
|
|
152
|
-
"home-path",
|
|
153
|
-
"ec69fe88e2f9d3f1ed3504532c7484120591cbec7ae6fda017b0f9ca57a3fc25",
|
|
154
|
-
),
|
|
155
150
|
)
|
|
156
151
|
)
|
|
@@ -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:
|