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.
- package/docs/CODE_RULES.md +1 -1
- package/hooks/blocking/code_rules_comments.py +14 -23
- 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/hooks.json +10 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +0 -2
- package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +0 -6
- 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/scripts/dev_env_scripts_constants/grok_worker_constants.py +0 -4
- package/scripts/test_resolve_worker_spawn.py +0 -28
package/docs/CODE_RULES.md
CHANGED
|
@@ -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
|
|
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 ("
|
|
184
|
-
no settings file (
|
|
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
|
|
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
|
|
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
|
-
|
|
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 (
|
|
234
|
-
|
|
235
|
-
|
|
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
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
@@ -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:
|