claude-dev-env 8.25.0 → 8.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/docs/CODE_RULES.md +2 -2
  2. package/hooks/blocking/code_rules_boolean_mustcheck.py +1 -82
  3. package/hooks/blocking/code_rules_comments.py +14 -23
  4. package/hooks/blocking/code_rules_enforcer.py +0 -6
  5. package/hooks/blocking/code_rules_imports_logging.py +1 -120
  6. package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +9 -12
  7. package/hooks/blocking/test_code_rules_enforcer_exempt_marker_chained.py +0 -10
  8. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +0 -16
  9. package/hooks/hooks.json +10 -0
  10. package/hooks/hooks_constants/blocking_check_limits.py +0 -13
  11. package/hooks/hooks_constants/code_rules_enforcer_constants.py +0 -8
  12. package/hooks/hooks_constants/local_identity.py +7 -120
  13. package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +0 -6
  14. package/hooks/hooks_constants/test_local_identity.py +7 -43
  15. package/hooks/hooks_constants/thread_spawn_pace_hook_constants.py +45 -0
  16. package/hooks/hooks_constants/usage_pace_constants.py +49 -0
  17. package/hooks/routing/conftest.py +10 -0
  18. package/hooks/routing/test_thread_spawn_pace_hook.py +139 -0
  19. package/hooks/routing/test_usage_pace.py +142 -0
  20. package/hooks/routing/thread_spawn_pace_hook.py +220 -0
  21. package/hooks/routing/usage_pace.py +345 -0
  22. package/package.json +1 -1
  23. package/rules/shell-invocation.md +1 -1
  24. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +0 -4
  25. package/scripts/repository_checks/config/constants.py +0 -5
  26. package/scripts/test_resolve_worker_spawn.py +0 -28
  27. package/docs/nas-ssh-invocation.md +0 -107
  28. package/hooks/blocking/test_code_rules_enforcer_ignored_must_check_return.py +0 -266
  29. package/hooks/blocking/test_code_rules_enforcer_split_mustcheck.py +0 -87
  30. package/hooks/blocking/test_code_rules_logging_printf_tokens.py +0 -134
  31. package/rules/nas-ssh-invocation.md +0 -29
@@ -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.0",
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": {
@@ -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, not every executable a command names. A direct interpreter invocation another rule documents — the paramiko NAS helper in [`nas-ssh-invocation.md`](nas-ssh-invocation.md), a `python` call on a repo script — conforms as written.
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: