claude-dev-env 1.89.0 → 1.92.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/CLAUDE.md +0 -13
- package/_shared/pr-loop/scripts/code_rules_gate.py +106 -14
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +26 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +160 -0
- package/agents/clean-coder.md +2 -2
- package/agents/code-verifier.md +0 -1
- package/agents/test_agent_frontmatter.py +78 -0
- package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +1 -1
- package/audit-rubrics/prompts/category-j-code-rules-compliance.md +1 -1
- package/bin/install.mjs +1 -0
- package/docs/CODE_RULES.md +2 -2
- package/hooks/blocking/CLAUDE.md +6 -2
- package/hooks/blocking/code_rules_comments.py +2 -2
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +44 -0
- package/hooks/blocking/config/verified_commit_constants.py +2 -0
- package/hooks/blocking/conftest.py +115 -0
- package/hooks/blocking/nas_ssh_binary_enforcer.py +191 -0
- package/hooks/blocking/pr_description_enforcer.py +46 -22
- package/hooks/blocking/pr_description_pr_number.py +5 -3
- package/hooks/blocking/pr_description_proof_of_work.py +367 -0
- package/hooks/blocking/precommit_code_rules_gate.py +5 -1
- package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +8 -2
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +71 -0
- package/hooks/blocking/test_nas_ssh_binary_enforcer.py +168 -0
- package/hooks/blocking/test_pr_description_enforcer_proof_gate.py +175 -0
- package/hooks/blocking/test_pr_description_proof_of_work.py +162 -0
- package/hooks/blocking/test_precommit_code_rules_gate.py +89 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +4 -0
- package/hooks/blocking/test_verification_verdict_store.py +11 -0
- package/hooks/blocking/test_verified_commit_config_bootstrap.py +49 -0
- package/hooks/blocking/test_verified_commit_gate.py +11 -0
- package/hooks/blocking/test_verifier_verdict_minter.py +11 -0
- package/hooks/blocking/test_volatile_path_in_post_blocker.py +210 -0
- package/hooks/blocking/verdict_directory_write_blocker.py +6 -0
- package/hooks/blocking/verification_verdict_store.py +73 -5
- package/hooks/blocking/verified_commit_config_bootstrap.py +51 -0
- package/hooks/blocking/verified_commit_gate.py +6 -0
- package/hooks/blocking/verifier_verdict_minter.py +6 -0
- package/hooks/blocking/volatile_path_in_post_blocker.py +351 -0
- package/hooks/hooks.json +32 -2
- package/hooks/hooks_constants/CLAUDE.md +4 -0
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
- package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +3 -0
- package/hooks/hooks_constants/enter_worktree_prefetch_constants.py +18 -0
- package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +59 -0
- package/hooks/hooks_constants/pr_description_enforcer_constants.py +2 -0
- package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +111 -0
- package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +48 -0
- package/hooks/lifecycle/CLAUDE.md +3 -1
- package/hooks/lifecycle/enter_worktree_origin_prefetch.py +146 -0
- package/hooks/lifecycle/test_enter_worktree_origin_prefetch.py +178 -0
- package/hooks/validators/run_all_validators.py +216 -4
- package/hooks/validators/test_run_all_validators_pretooluse.py +102 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +3 -0
- package/rules/durable-post-artifacts.md +35 -0
- package/rules/nas-ssh-invocation.md +21 -0
- package/rules/proof-of-work-pr-comments.md +26 -0
- package/scripts/CLAUDE.md +2 -0
- package/scripts/claude-chain.example.json +8 -0
- package/scripts/claude_chain_runner.py +400 -0
- package/scripts/dev_env_scripts_constants/CLAUDE.md +2 -0
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +124 -0
- package/scripts/dev_env_scripts_constants/gh_artifact_upload_constants.py +43 -0
- package/scripts/gh_artifact_upload.py +256 -0
- package/scripts/sync_to_cursor/rules.py +1 -1
- package/scripts/test_claude_chain_runner.py +472 -0
- package/scripts/tests/test_gh_artifact_upload.py +205 -0
- package/skills/CLAUDE.md +3 -0
- package/skills/team-advisor/SKILL.md +188 -0
- package/skills/team-advisor-refresh/SKILL.md +25 -0
- package/skills/usage-pause/SKILL.md +108 -0
- package/skills/usage-pause/scripts/resolve_usage_window.py +462 -0
- package/skills/usage-pause/scripts/test_resolve_usage_window.py +278 -0
- package/skills/usage-pause/scripts/usage_pause_constants/__init__.py +1 -0
- package/skills/usage-pause/scripts/usage_pause_constants/resolve_usage_window_constants.py +65 -0
- package/system-prompts/software-engineer.xml +3 -2
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Resolve the account's 5-hour usage window and plan the pause stage chain.
|
|
3
|
+
|
|
4
|
+
::
|
|
5
|
+
|
|
6
|
+
python resolve_usage_window.py --override 74m
|
|
7
|
+
{"source": "override", "reset_at": "2026-07-08T10:14:00-07:00",
|
|
8
|
+
"seconds_until_reset": 4440, "stages_seconds": [3480, 960, 120], ...}
|
|
9
|
+
|
|
10
|
+
With no ``--override``, the script reads the Claude Code OAuth access token
|
|
11
|
+
from the CLI credential file and asks the OAuth usage endpoint for the
|
|
12
|
+
``five_hour`` and ``seven_day`` windows. Exit code 2 means the probe cannot
|
|
13
|
+
resolve; the caller then asks the user for a manual reset time.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import re
|
|
22
|
+
import sys
|
|
23
|
+
import urllib.error
|
|
24
|
+
import urllib.request
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from datetime import datetime, timedelta, timezone
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from usage_pause_constants.resolve_usage_window_constants import (
|
|
30
|
+
ALL_CREDENTIALS_RELATIVE_PATH_PARTS,
|
|
31
|
+
ALL_RESETS_AT_KEYS,
|
|
32
|
+
AUTHORIZATION_BEARER_PREFIX,
|
|
33
|
+
AUTHORIZATION_HEADER_NAME,
|
|
34
|
+
BARE_MINUTES_PATTERN,
|
|
35
|
+
CLOCK_HOUR_MAXIMUM,
|
|
36
|
+
CLOCK_PATTERN,
|
|
37
|
+
CONTENT_TYPE_HEADER_NAME,
|
|
38
|
+
CONTENT_TYPE_JSON,
|
|
39
|
+
CREDENTIALS_ACCESS_TOKEN_KEY,
|
|
40
|
+
CREDENTIALS_EXPIRES_AT_KEY,
|
|
41
|
+
CREDENTIALS_OAUTH_SECTION_KEY,
|
|
42
|
+
DURATION_PATTERN,
|
|
43
|
+
EPOCH_MILLISECONDS_THRESHOLD,
|
|
44
|
+
EXIT_CODE_PROBE_UNAVAILABLE,
|
|
45
|
+
EXIT_CODE_RESOLVED,
|
|
46
|
+
FIVE_HOUR_BUCKET_KEY,
|
|
47
|
+
ISO_UTC_OFFSET,
|
|
48
|
+
ISO_UTC_SUFFIX,
|
|
49
|
+
LOGGING_FORMAT,
|
|
50
|
+
MAXIMUM_STAGE_SECONDS,
|
|
51
|
+
MERIDIEM_AM,
|
|
52
|
+
MERIDIEM_PM,
|
|
53
|
+
MILLISECONDS_PER_SECOND,
|
|
54
|
+
MINIMUM_STAGE_SECONDS,
|
|
55
|
+
MINUTES_PER_HOUR,
|
|
56
|
+
NOON_HOUR,
|
|
57
|
+
OAUTH_BETA_HEADER_NAME,
|
|
58
|
+
OAUTH_BETA_HEADER_VALUE,
|
|
59
|
+
OAUTH_USAGE_ENDPOINT_URL,
|
|
60
|
+
PROBE_TIMEOUT_SECONDS,
|
|
61
|
+
RESULT_KEY_ERROR,
|
|
62
|
+
RESULT_KEY_RESET_AT,
|
|
63
|
+
RESULT_KEY_SECONDS_UNTIL_RESET,
|
|
64
|
+
RESULT_KEY_SESSION_UTILIZATION,
|
|
65
|
+
RESULT_KEY_SOURCE,
|
|
66
|
+
RESULT_KEY_STAGES_SECONDS,
|
|
67
|
+
RESULT_KEY_WEEKLY_NEAR_CAP,
|
|
68
|
+
RESULT_KEY_WEEKLY_RESETS_AT,
|
|
69
|
+
RESULT_KEY_WEEKLY_UTILIZATION,
|
|
70
|
+
SEVEN_DAY_BUCKET_KEY,
|
|
71
|
+
SOURCE_OVERRIDE,
|
|
72
|
+
SOURCE_PROBE,
|
|
73
|
+
TAIL_STAGE_SECONDS,
|
|
74
|
+
UTILIZATION_KEY,
|
|
75
|
+
WEEKLY_UTILIZATION_WARN_THRESHOLD,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
logger = logging.getLogger("resolve_usage_window")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class UsageWindows:
|
|
83
|
+
"""The usage meters read from one OAuth usage response.
|
|
84
|
+
|
|
85
|
+
::
|
|
86
|
+
|
|
87
|
+
{"five_hour": {"utilization": 42, "resets_at": ...}} -> session meters
|
|
88
|
+
{"seven_day": {"utilization": 63, "resets_at": ...}} -> weekly meters
|
|
89
|
+
|
|
90
|
+
Attributes:
|
|
91
|
+
session_utilization: Percent of the 5-hour window spent, or None
|
|
92
|
+
when the five_hour bucket is missing or unreadable.
|
|
93
|
+
session_resets_at: When the 5-hour window resets, or None when the
|
|
94
|
+
five_hour reset time is missing or unreadable.
|
|
95
|
+
weekly_utilization: Percent of the weekly window spent, or None
|
|
96
|
+
when the seven_day bucket is missing or unreadable.
|
|
97
|
+
weekly_resets_at: When the weekly window resets, or None when the
|
|
98
|
+
seven_day reset time is missing or unreadable.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
session_utilization: float | None
|
|
102
|
+
session_resets_at: datetime | None
|
|
103
|
+
weekly_utilization: float | None
|
|
104
|
+
weekly_resets_at: datetime | None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def parse_manual_override(override_text: str, now: datetime) -> datetime:
|
|
108
|
+
"""Turn a user-given reset override into the reset datetime.
|
|
109
|
+
|
|
110
|
+
::
|
|
111
|
+
|
|
112
|
+
"74m" -> now + 74 minutes
|
|
113
|
+
"1h30m" -> now + 90 minutes
|
|
114
|
+
"45" -> now + 45 minutes
|
|
115
|
+
"10:20pm" -> today 22:20, or tomorrow 22:20 when already past
|
|
116
|
+
"22:15" -> today 22:15, or tomorrow when already past
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
override_text: The reset time or duration the user typed.
|
|
120
|
+
now: The current local time the override is anchored to.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
The reset time the pause chain should land past.
|
|
124
|
+
|
|
125
|
+
Raises:
|
|
126
|
+
ValueError: The text parses as neither a duration nor a clock time.
|
|
127
|
+
"""
|
|
128
|
+
normalized = override_text.strip().lower()
|
|
129
|
+
if re.fullmatch(BARE_MINUTES_PATTERN, normalized):
|
|
130
|
+
return now + timedelta(minutes=int(normalized))
|
|
131
|
+
duration_match = re.fullmatch(DURATION_PATTERN, normalized)
|
|
132
|
+
if duration_match and (
|
|
133
|
+
duration_match.group("hours") or duration_match.group("minutes")
|
|
134
|
+
):
|
|
135
|
+
duration_hours = int(duration_match.group("hours") or 0)
|
|
136
|
+
duration_minutes = int(duration_match.group("minutes") or 0)
|
|
137
|
+
return now + timedelta(hours=duration_hours, minutes=duration_minutes)
|
|
138
|
+
clock_match = re.fullmatch(CLOCK_PATTERN, normalized)
|
|
139
|
+
if clock_match and (clock_match.group("meridiem") or clock_match.group("minute")):
|
|
140
|
+
return _clock_match_to_datetime(clock_match, now)
|
|
141
|
+
raise ValueError(
|
|
142
|
+
f"cannot read '{override_text}' as a reset time; give a clock time like 10:20pm or a duration like 74m"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _clock_match_to_datetime(clock_match: re.Match[str], now: datetime) -> datetime:
|
|
147
|
+
hour = int(clock_match.group("hour"))
|
|
148
|
+
minute = int(clock_match.group("minute") or 0)
|
|
149
|
+
meridiem = clock_match.group("meridiem")
|
|
150
|
+
if minute >= MINUTES_PER_HOUR:
|
|
151
|
+
raise ValueError(f"minute {minute} is out of range")
|
|
152
|
+
if meridiem is not None:
|
|
153
|
+
if hour < 1 or hour > NOON_HOUR:
|
|
154
|
+
raise ValueError(f"hour {hour} needs to be 1-12 with am/pm")
|
|
155
|
+
if meridiem == MERIDIEM_PM and hour != NOON_HOUR:
|
|
156
|
+
hour += NOON_HOUR
|
|
157
|
+
if meridiem == MERIDIEM_AM and hour == NOON_HOUR:
|
|
158
|
+
hour = 0
|
|
159
|
+
elif hour > CLOCK_HOUR_MAXIMUM:
|
|
160
|
+
raise ValueError(f"hour {hour} is out of range")
|
|
161
|
+
candidate = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
|
162
|
+
if candidate <= now:
|
|
163
|
+
candidate += timedelta(days=1)
|
|
164
|
+
return candidate
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def plan_wakeup_stages(seconds_until_reset: int) -> list[int]:
|
|
168
|
+
"""Split the wait until the reset into ScheduleWakeup stage durations.
|
|
169
|
+
|
|
170
|
+
::
|
|
171
|
+
|
|
172
|
+
4440 (74m to reset) -> [3480, 960, 120]
|
|
173
|
+
|
|
174
|
+
Every stage stays within the stage cap, the chain sums to the wait plus
|
|
175
|
+
the tail buffer so the last firing lands past the reset, and a leftover
|
|
176
|
+
too short to stand alone folds into the tail.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
seconds_until_reset: Seconds from now until the window resets.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
The stage durations in firing order, each one a ScheduleWakeup delay.
|
|
183
|
+
"""
|
|
184
|
+
tail_seconds = TAIL_STAGE_SECONDS
|
|
185
|
+
total_seconds = max(seconds_until_reset + tail_seconds, MINIMUM_STAGE_SECONDS)
|
|
186
|
+
if total_seconds <= tail_seconds + MINIMUM_STAGE_SECONDS:
|
|
187
|
+
return [total_seconds]
|
|
188
|
+
head_seconds = total_seconds - tail_seconds
|
|
189
|
+
full_stage_count, leftover_seconds = divmod(head_seconds, MAXIMUM_STAGE_SECONDS)
|
|
190
|
+
stages = [MAXIMUM_STAGE_SECONDS] * full_stage_count
|
|
191
|
+
if leftover_seconds >= MINIMUM_STAGE_SECONDS:
|
|
192
|
+
stages.append(leftover_seconds)
|
|
193
|
+
else:
|
|
194
|
+
tail_seconds += leftover_seconds
|
|
195
|
+
stages.append(tail_seconds)
|
|
196
|
+
return stages
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def read_oauth_access_token(credentials_path: Path, now: datetime) -> str | None:
|
|
200
|
+
"""Read the CLI's OAuth access token when it is still valid.
|
|
201
|
+
|
|
202
|
+
Returns None on any of: an unreadable or non-JSON credential file, a
|
|
203
|
+
missing or ill-typed token or expiry field, or a token whose stored
|
|
204
|
+
expiry sits at or before ``now``.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
credentials_path: The CLI credential file holding the OAuth section.
|
|
208
|
+
now: The current time the stored expiry is compared against.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
The bearer token for the usage endpoint, or None when unavailable.
|
|
212
|
+
"""
|
|
213
|
+
try:
|
|
214
|
+
credentials_payload = json.loads(credentials_path.read_text(encoding="utf-8"))
|
|
215
|
+
except (OSError, json.JSONDecodeError):
|
|
216
|
+
logger.warning("credential file unreadable at %s", credentials_path)
|
|
217
|
+
return None
|
|
218
|
+
oauth_section = credentials_payload.get(CREDENTIALS_OAUTH_SECTION_KEY)
|
|
219
|
+
if not isinstance(oauth_section, dict):
|
|
220
|
+
logger.warning(
|
|
221
|
+
"credential file has no %s section", CREDENTIALS_OAUTH_SECTION_KEY
|
|
222
|
+
)
|
|
223
|
+
return None
|
|
224
|
+
access_token = oauth_section.get(CREDENTIALS_ACCESS_TOKEN_KEY)
|
|
225
|
+
expires_at_milliseconds = oauth_section.get(CREDENTIALS_EXPIRES_AT_KEY)
|
|
226
|
+
if not isinstance(access_token, str) or not isinstance(
|
|
227
|
+
expires_at_milliseconds, (int, float)
|
|
228
|
+
):
|
|
229
|
+
logger.warning("credential file is missing the token or its expiry")
|
|
230
|
+
return None
|
|
231
|
+
if expires_at_milliseconds <= now.timestamp() * MILLISECONDS_PER_SECOND:
|
|
232
|
+
logger.warning("stored access token is expired; probe unavailable")
|
|
233
|
+
return None
|
|
234
|
+
return access_token
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _parse_resets_at(raw_resets_at: object) -> datetime | None:
|
|
238
|
+
if isinstance(raw_resets_at, bool):
|
|
239
|
+
return None
|
|
240
|
+
if isinstance(raw_resets_at, (int, float)):
|
|
241
|
+
epoch_seconds = float(raw_resets_at)
|
|
242
|
+
if epoch_seconds > EPOCH_MILLISECONDS_THRESHOLD:
|
|
243
|
+
epoch_seconds /= MILLISECONDS_PER_SECOND
|
|
244
|
+
return datetime.fromtimestamp(epoch_seconds, tz=timezone.utc).astimezone()
|
|
245
|
+
if isinstance(raw_resets_at, str):
|
|
246
|
+
try:
|
|
247
|
+
parsed = datetime.fromisoformat(
|
|
248
|
+
raw_resets_at.replace(ISO_UTC_SUFFIX, ISO_UTC_OFFSET)
|
|
249
|
+
)
|
|
250
|
+
except ValueError:
|
|
251
|
+
logger.warning("unreadable resets_at %r", raw_resets_at)
|
|
252
|
+
return None
|
|
253
|
+
return parsed.astimezone()
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _read_bucket(
|
|
258
|
+
all_usage_buckets: dict[str, object], bucket_key: str
|
|
259
|
+
) -> tuple[float | None, datetime | None]:
|
|
260
|
+
bucket = all_usage_buckets.get(bucket_key)
|
|
261
|
+
if not isinstance(bucket, dict):
|
|
262
|
+
return None, None
|
|
263
|
+
raw_utilization = bucket.get(UTILIZATION_KEY)
|
|
264
|
+
utilization = (
|
|
265
|
+
float(raw_utilization) if isinstance(raw_utilization, (int, float)) else None
|
|
266
|
+
)
|
|
267
|
+
resets_at = None
|
|
268
|
+
for each_key in ALL_RESETS_AT_KEYS:
|
|
269
|
+
resets_at = _parse_resets_at(bucket.get(each_key))
|
|
270
|
+
if resets_at is not None:
|
|
271
|
+
break
|
|
272
|
+
return utilization, resets_at
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def extract_usage_windows(all_usage_buckets: dict[str, object]) -> UsageWindows:
|
|
276
|
+
"""Pull the session and weekly meters out of a usage response body.
|
|
277
|
+
|
|
278
|
+
::
|
|
279
|
+
|
|
280
|
+
{"five_hour": {"utilization": 42, "resets_at": "2026-07-08T22:00:00Z"},
|
|
281
|
+
"seven_day": {"utilization": 63, "resets_at": 1783900800}}
|
|
282
|
+
-> UsageWindows(42.0, <today 22:00 UTC>, 63.0, <weekly reset>)
|
|
283
|
+
|
|
284
|
+
A reset time arrives as an ISO-8601 string or an epoch number (seconds
|
|
285
|
+
or milliseconds); a missing or unreadable bucket yields None fields.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
all_usage_buckets: The decoded JSON body of the OAuth usage response.
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
The parsed session and weekly meters.
|
|
292
|
+
"""
|
|
293
|
+
session_utilization, session_resets_at = _read_bucket(
|
|
294
|
+
all_usage_buckets, FIVE_HOUR_BUCKET_KEY
|
|
295
|
+
)
|
|
296
|
+
weekly_utilization, weekly_resets_at = _read_bucket(
|
|
297
|
+
all_usage_buckets, SEVEN_DAY_BUCKET_KEY
|
|
298
|
+
)
|
|
299
|
+
return UsageWindows(
|
|
300
|
+
session_utilization=session_utilization,
|
|
301
|
+
session_resets_at=session_resets_at,
|
|
302
|
+
weekly_utilization=weekly_utilization,
|
|
303
|
+
weekly_resets_at=weekly_resets_at,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def build_pause_plan(
|
|
308
|
+
source: str,
|
|
309
|
+
reset_at: datetime,
|
|
310
|
+
now: datetime,
|
|
311
|
+
session_utilization: float | None,
|
|
312
|
+
weekly_utilization: float | None,
|
|
313
|
+
weekly_resets_at: datetime | None,
|
|
314
|
+
) -> dict[str, object]:
|
|
315
|
+
"""Assemble the resolver's JSON-ready output for one resolved reset time.
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
source: Where the reset time came from, probe or override.
|
|
319
|
+
reset_at: When the 5-hour window resets.
|
|
320
|
+
now: The current local time the wait is measured from.
|
|
321
|
+
session_utilization: Percent of the 5-hour window spent, when known.
|
|
322
|
+
weekly_utilization: Percent of the weekly window spent, when known.
|
|
323
|
+
weekly_resets_at: When the weekly window resets, when known.
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
The plan mapping: source, reset time, seconds until reset, the
|
|
327
|
+
stage plan, both utilization meters, the weekly reset time, and the
|
|
328
|
+
weekly near-cap flag.
|
|
329
|
+
"""
|
|
330
|
+
seconds_until_reset = max(int((reset_at - now).total_seconds()), 0)
|
|
331
|
+
weekly_near_cap = (
|
|
332
|
+
weekly_utilization is not None
|
|
333
|
+
and weekly_utilization >= WEEKLY_UTILIZATION_WARN_THRESHOLD
|
|
334
|
+
)
|
|
335
|
+
return {
|
|
336
|
+
RESULT_KEY_SOURCE: source,
|
|
337
|
+
RESULT_KEY_RESET_AT: reset_at.isoformat(),
|
|
338
|
+
RESULT_KEY_SECONDS_UNTIL_RESET: seconds_until_reset,
|
|
339
|
+
RESULT_KEY_STAGES_SECONDS: plan_wakeup_stages(seconds_until_reset),
|
|
340
|
+
RESULT_KEY_SESSION_UTILIZATION: session_utilization,
|
|
341
|
+
RESULT_KEY_WEEKLY_UTILIZATION: weekly_utilization,
|
|
342
|
+
RESULT_KEY_WEEKLY_RESETS_AT: weekly_resets_at.isoformat()
|
|
343
|
+
if weekly_resets_at
|
|
344
|
+
else None,
|
|
345
|
+
RESULT_KEY_WEEKLY_NEAR_CAP: weekly_near_cap,
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _fetch_usage_payload(access_token: str) -> dict[str, object]:
|
|
350
|
+
usage_request = urllib.request.Request(
|
|
351
|
+
OAUTH_USAGE_ENDPOINT_URL,
|
|
352
|
+
headers={
|
|
353
|
+
AUTHORIZATION_HEADER_NAME: f"{AUTHORIZATION_BEARER_PREFIX}{access_token}",
|
|
354
|
+
OAUTH_BETA_HEADER_NAME: OAUTH_BETA_HEADER_VALUE,
|
|
355
|
+
CONTENT_TYPE_HEADER_NAME: CONTENT_TYPE_JSON,
|
|
356
|
+
},
|
|
357
|
+
)
|
|
358
|
+
with urllib.request.urlopen(
|
|
359
|
+
usage_request, timeout=PROBE_TIMEOUT_SECONDS
|
|
360
|
+
) as usage_reply:
|
|
361
|
+
decoded = json.loads(usage_reply.read())
|
|
362
|
+
if not isinstance(decoded, dict):
|
|
363
|
+
raise ValueError("usage response body is not a JSON object")
|
|
364
|
+
return decoded
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _emit_error(message: str) -> int:
|
|
368
|
+
logger.error("%s", message)
|
|
369
|
+
print(json.dumps({RESULT_KEY_ERROR: message}))
|
|
370
|
+
return EXIT_CODE_PROBE_UNAVAILABLE
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _parse_arguments() -> argparse.Namespace:
|
|
374
|
+
parser = argparse.ArgumentParser(
|
|
375
|
+
description="Resolve the 5-hour usage window reset and plan the pause stage chain.",
|
|
376
|
+
)
|
|
377
|
+
parser.add_argument(
|
|
378
|
+
"--override",
|
|
379
|
+
default=None,
|
|
380
|
+
help="Manual reset time (10:20pm, 22:15) or duration (74m, 1h30m); skips the probe.",
|
|
381
|
+
)
|
|
382
|
+
parser.add_argument(
|
|
383
|
+
"--now",
|
|
384
|
+
default=None,
|
|
385
|
+
help="ISO-8601 time to anchor the plan to; defaults to the current local time.",
|
|
386
|
+
)
|
|
387
|
+
parser.add_argument(
|
|
388
|
+
"--credentials-path",
|
|
389
|
+
default=None,
|
|
390
|
+
help="Path to the CLI credential file; defaults to the home-directory location.",
|
|
391
|
+
)
|
|
392
|
+
return parser.parse_args()
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def main() -> int:
|
|
396
|
+
"""Resolve the usage window from the override or the probe and print the plan.
|
|
397
|
+
|
|
398
|
+
Returns:
|
|
399
|
+
0 with the plan JSON on stdout, or 2 with an error JSON when the
|
|
400
|
+
override is unreadable or the probe cannot resolve.
|
|
401
|
+
"""
|
|
402
|
+
logging.basicConfig(level=logging.INFO, format=LOGGING_FORMAT, stream=sys.stderr)
|
|
403
|
+
arguments = _parse_arguments()
|
|
404
|
+
if arguments.now:
|
|
405
|
+
try:
|
|
406
|
+
now = datetime.fromisoformat(arguments.now).astimezone()
|
|
407
|
+
except ValueError as now_error:
|
|
408
|
+
return _emit_error(f"cannot read --now time {arguments.now!r}: {now_error}")
|
|
409
|
+
else:
|
|
410
|
+
now = datetime.now().astimezone()
|
|
411
|
+
if arguments.override:
|
|
412
|
+
try:
|
|
413
|
+
reset_at = parse_manual_override(arguments.override, now)
|
|
414
|
+
except ValueError as parse_error:
|
|
415
|
+
return _emit_error(str(parse_error))
|
|
416
|
+
print(
|
|
417
|
+
json.dumps(
|
|
418
|
+
build_pause_plan(SOURCE_OVERRIDE, reset_at, now, None, None, None)
|
|
419
|
+
)
|
|
420
|
+
)
|
|
421
|
+
return EXIT_CODE_RESOLVED
|
|
422
|
+
credentials_path = (
|
|
423
|
+
Path(arguments.credentials_path)
|
|
424
|
+
if arguments.credentials_path
|
|
425
|
+
else Path.home().joinpath(*ALL_CREDENTIALS_RELATIVE_PATH_PARTS)
|
|
426
|
+
)
|
|
427
|
+
access_token = read_oauth_access_token(credentials_path, now)
|
|
428
|
+
if access_token is None:
|
|
429
|
+
return _emit_error(
|
|
430
|
+
"OAuth access token unavailable or expired; give a manual reset time, "
|
|
431
|
+
"for example /usage-pause 10:20pm or /usage-pause 74m"
|
|
432
|
+
)
|
|
433
|
+
try:
|
|
434
|
+
usage_payload = _fetch_usage_payload(access_token)
|
|
435
|
+
except (
|
|
436
|
+
urllib.error.URLError,
|
|
437
|
+
TimeoutError,
|
|
438
|
+
OSError,
|
|
439
|
+
ValueError,
|
|
440
|
+
json.JSONDecodeError,
|
|
441
|
+
) as probe_error:
|
|
442
|
+
return _emit_error(f"usage probe failed: {probe_error}")
|
|
443
|
+
windows = extract_usage_windows(usage_payload)
|
|
444
|
+
if windows.session_resets_at is None:
|
|
445
|
+
return _emit_error("usage response carried no five_hour reset time")
|
|
446
|
+
print(
|
|
447
|
+
json.dumps(
|
|
448
|
+
build_pause_plan(
|
|
449
|
+
SOURCE_PROBE,
|
|
450
|
+
windows.session_resets_at,
|
|
451
|
+
now,
|
|
452
|
+
windows.session_utilization,
|
|
453
|
+
windows.weekly_utilization,
|
|
454
|
+
windows.weekly_resets_at,
|
|
455
|
+
)
|
|
456
|
+
)
|
|
457
|
+
)
|
|
458
|
+
return EXIT_CODE_RESOLVED
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
if __name__ == "__main__":
|
|
462
|
+
sys.exit(main())
|