claude-dev-env 1.90.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.
Files changed (63) hide show
  1. package/CLAUDE.md +0 -13
  2. package/agents/clean-coder.md +2 -2
  3. package/agents/code-verifier.md +0 -1
  4. package/agents/test_agent_frontmatter.py +78 -0
  5. package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +1 -1
  6. package/audit-rubrics/prompts/category-j-code-rules-compliance.md +1 -1
  7. package/bin/install.mjs +1 -0
  8. package/docs/CODE_RULES.md +2 -2
  9. package/hooks/blocking/CLAUDE.md +5 -2
  10. package/hooks/blocking/code_rules_comments.py +2 -2
  11. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +44 -0
  12. package/hooks/blocking/config/verified_commit_constants.py +2 -0
  13. package/hooks/blocking/conftest.py +115 -0
  14. package/hooks/blocking/nas_ssh_binary_enforcer.py +191 -0
  15. package/hooks/blocking/pr_description_enforcer.py +46 -22
  16. package/hooks/blocking/pr_description_pr_number.py +5 -3
  17. package/hooks/blocking/pr_description_proof_of_work.py +367 -0
  18. package/hooks/blocking/precommit_code_rules_gate.py +5 -1
  19. package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +8 -2
  20. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +71 -0
  21. package/hooks/blocking/test_nas_ssh_binary_enforcer.py +168 -0
  22. package/hooks/blocking/test_pr_description_enforcer_proof_gate.py +175 -0
  23. package/hooks/blocking/test_pr_description_proof_of_work.py +162 -0
  24. package/hooks/blocking/test_precommit_code_rules_gate.py +89 -0
  25. package/hooks/blocking/test_verdict_directory_write_blocker.py +4 -0
  26. package/hooks/blocking/test_verification_verdict_store.py +11 -0
  27. package/hooks/blocking/test_verified_commit_config_bootstrap.py +49 -0
  28. package/hooks/blocking/test_verified_commit_gate.py +11 -0
  29. package/hooks/blocking/test_verifier_verdict_minter.py +11 -0
  30. package/hooks/blocking/verdict_directory_write_blocker.py +6 -0
  31. package/hooks/blocking/verification_verdict_store.py +73 -5
  32. package/hooks/blocking/verified_commit_config_bootstrap.py +51 -0
  33. package/hooks/blocking/verified_commit_gate.py +6 -0
  34. package/hooks/blocking/verifier_verdict_minter.py +6 -0
  35. package/hooks/hooks.json +7 -2
  36. package/hooks/hooks_constants/CLAUDE.md +2 -0
  37. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
  38. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +3 -0
  39. package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +59 -0
  40. package/hooks/hooks_constants/pr_description_enforcer_constants.py +2 -0
  41. package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +111 -0
  42. package/hooks/validators/run_all_validators.py +216 -4
  43. package/hooks/validators/test_run_all_validators_pretooluse.py +102 -0
  44. package/package.json +1 -1
  45. package/rules/CLAUDE.md +2 -0
  46. package/rules/nas-ssh-invocation.md +21 -0
  47. package/rules/proof-of-work-pr-comments.md +26 -0
  48. package/scripts/CLAUDE.md +1 -0
  49. package/scripts/claude-chain.example.json +8 -0
  50. package/scripts/claude_chain_runner.py +400 -0
  51. package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -0
  52. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +124 -0
  53. package/scripts/sync_to_cursor/rules.py +1 -1
  54. package/scripts/test_claude_chain_runner.py +472 -0
  55. package/skills/CLAUDE.md +3 -0
  56. package/skills/team-advisor/SKILL.md +188 -0
  57. package/skills/team-advisor-refresh/SKILL.md +25 -0
  58. package/skills/usage-pause/SKILL.md +108 -0
  59. package/skills/usage-pause/scripts/resolve_usage_window.py +462 -0
  60. package/skills/usage-pause/scripts/test_resolve_usage_window.py +278 -0
  61. package/skills/usage-pause/scripts/usage_pause_constants/__init__.py +1 -0
  62. package/skills/usage-pause/scripts/usage_pause_constants/resolve_usage_window_constants.py +65 -0
  63. package/system-prompts/software-engineer.xml +3 -2
@@ -0,0 +1,25 @@
1
+ ---
2
+ name: team-advisor-refresh
3
+ description: >-
4
+ Fired by the /team-advisor loop reminder about every 20 minutes to
5
+ re-assert the advisor discipline mid-run. A compressed restatement of
6
+ /team-advisor: orchestrate rather than execute, answer a blocked executor
7
+ with a plan, correction, or stop, and reuse warm agents before spawning new
8
+ ones. Triggers: '/team-advisor-refresh'.
9
+ ---
10
+
11
+ # Team Advisor Refresh
12
+
13
+ 1. **You are the advisor.** Orchestrate and hold the user conversation; spawn
14
+ executor subagents to do all the work — every code edit and build or test
15
+ run.
16
+ 2. **An executor blocked twice on the same thing?** Answer it with one signal
17
+ — a plan, a correction, or a stop — brief. Never take over the edit or the
18
+ tests yourself.
19
+ 3. **Resume before you spawn.** `SendMessage` an existing agent by name or
20
+ `agentId` to reuse its warm context; prefer that over a cold spawn.
21
+ 4. **Fresh spawn only for a genuine task switch.** No tool compacts or clears a
22
+ subagent's context, so a clean context comes from a fresh spawn — never tell
23
+ an agent to compact.
24
+ 5. **Re-schedule the next refresh** (about 1200 seconds out) when the loop
25
+ mechanism needs each firing to queue the following one.
@@ -0,0 +1,108 @@
1
+ ---
2
+ name: usage-pause
3
+ description: Waits out the account's 5-hour usage window in ScheduleWakeup stages that keep every agent context warm. Resolves the window's reset time and remaining headroom by probing the OAuth usage endpoint, or takes a manual override ('/usage-pause 10:20pm', '/usage-pause 74m'). Warns and stops when the weekly limit is near its cap. Each wakeup pings live and idle-warm agents with a one-line no-new-work checkpoint and records finished results; the final wakeup restores the crons the skill cancelled at pause time. Triggers: '/usage-pause', 'pause until the usage window resets', 'wait out the usage limit', 'usage limit pause'.
4
+ argument-hint: "[reset time like 10:20pm | duration like 74m]"
5
+ ---
6
+
7
+ # /usage-pause
8
+
9
+ Pause on purpose before a usage limit kills in-flight work. The skill reads the 5-hour session window's reset time and remaining headroom, breaks the wait into ScheduleWakeup stages, keeps every agent context warm through the pause with checkpoint pings, and hands control back after the reset.
10
+
11
+ ## Step 1 — resolve the usage window
12
+
13
+ Run the bundled resolver:
14
+
15
+ ```
16
+ python "${CLAUDE_SKILL_DIR}/scripts/resolve_usage_window.py"
17
+ ```
18
+
19
+ With an argument (`/usage-pause 10:20pm`, `/usage-pause 74m`), skip the probe and pass it through:
20
+
21
+ ```
22
+ python "${CLAUDE_SKILL_DIR}/scripts/resolve_usage_window.py" --override "10:20pm"
23
+ ```
24
+
25
+ On exit 0 the script prints one JSON object:
26
+
27
+ | Field | Meaning |
28
+ |---|---|
29
+ | `source` | `probe` or `override` |
30
+ | `reset_at` | When the 5-hour window resets (ISO-8601, local zone) |
31
+ | `seconds_until_reset` | The wait the stage plan covers |
32
+ | `stages_seconds` | The ScheduleWakeup stage durations, in firing order |
33
+ | `session_utilization` | Percent of the 5-hour window spent (null on override) |
34
+ | `weekly_utilization` | Percent of the weekly window spent (null on override) |
35
+ | `weekly_resets_at` | When the weekly window resets (null on override) |
36
+ | `weekly_near_cap` | True when the weekly meter is at or past 90 percent |
37
+
38
+ On exit 2 the script prints `{"error": ...}`. Ask the user for a manual reset time via AskUserQuestion (offer clock-time and duration examples), then rerun with `--override`.
39
+
40
+ ### Probe mechanism
41
+
42
+ The resolver reads the Claude Code CLI's OAuth access token from `~/.claude/.credentials.json` (`claudeAiOauth.accessToken`, honored only while its `expiresAt` sits in the future) and sends `GET https://api.anthropic.com/api/oauth/usage` with `Authorization: Bearer <token>` and `anthropic-beta: oauth-2025-04-20`. This is the same endpoint the interactive `/usage` panel reads. The response carries a `five_hour` bucket and a `seven_day` bucket, each with `utilization` (percent spent) and `resets_at`.
43
+
44
+ Fallbacks, in order: an expired or unreadable stored token, a failed request, or a response with no readable `five_hour` reset time all end in exit 2 — the manual-override ask above. The manual path works with no probe at all, so the skill functions even when the credential file is stale.
45
+
46
+ Why this probe and not the others:
47
+
48
+ - The interactive `/usage` panel shows the same data but has no scriptable output.
49
+ - `claude -p --output-format json` spends usage to answer and its metadata reports per-call token counts, not the account window clock.
50
+ - `anthropic-ratelimit-*` response headers cover API-key Messages traffic, not the subscription session window, and reading them also costs a request.
51
+ - Refreshing the OAuth token from a script is out of scope: token rotation would desync the refresh token the CLI has on disk and log the CLI out. The resolver only ever reads the credential file.
52
+
53
+ ## Weekly limit guard
54
+
55
+ When `weekly_near_cap` is true, WARN the user — report `weekly_utilization` and `weekly_resets_at` — and stop. No pause choreography runs around the weekly limit; waiting out a weekly window is its own later build.
56
+
57
+ ## Step 2 — the pause chain
58
+
59
+ Before the first sleep:
60
+
61
+ 1. List active recurring loops (CronList). Record each one's schedule and prompt, then cancel it (CronDelete). Carry the recorded list inside the wakeup prompts so the state survives every stage.
62
+ 2. Schedule the first stage with ScheduleWakeup, using the first duration in `stages_seconds` and the stage prompt template below.
63
+
64
+ The stage plan keeps every stage at or under 58 minutes (3480 seconds, inside the 3600-second clamp) and ends with a roughly 2-minute tail stage so the final firing lands just past the reset. A leftover too short to stand alone folds into the tail.
65
+
66
+ Every wakeup does exactly three things — ping, record, schedule — and dispatches no new work. The cache rationale: an unpinged idle agent left past the cache window resumes cold and re-reads its whole transcript, so pings run tighter than the cache lifetime and keep each agent's next real invocation cheap.
67
+
68
+ ### Stage wakeup prompt template
69
+
70
+ ```
71
+ /usage-pause stage <stage_number> of <stage_total>: pausing until <reset_time_local>.
72
+ Remaining chain (seconds): <remaining_stage_durations>. Cancelled crons: <cancelled_crons_json>.
73
+ 1. Ping every live agent AND every idle-but-warm agent with exactly this one line:
74
+ "status checkpoint: reply with where you stand, do not start new work"
75
+ An unpinged idle agent past the cache window resumes cold and re-reads its whole
76
+ transcript; the ping keeps its next real invocation cheap.
77
+ 2. Record any finished agent results on the task list. Dispatch nothing.
78
+ 3. Take the first duration off the remaining chain and ScheduleWakeup it, passing this
79
+ same prompt with that duration removed and the stage number advanced. When the
80
+ remaining chain is empty, schedule nothing further and treat the next firing as final.
81
+ ```
82
+
83
+ ### Final wakeup prompt template
84
+
85
+ ```
86
+ /usage-pause final stage: the 5-hour window reset at <reset_time_local>.
87
+ 1. Ping every live and idle-but-warm agent with exactly:
88
+ "status checkpoint: reply with where you stand, do not start new work"
89
+ 2. Record any finished agent results on the task list.
90
+ 3. Restore every cron in <cancelled_crons_json>: CronCreate each one with its recorded
91
+ schedule and prompt.
92
+ 4. Hand control back to normal orchestration and resume work from the task list.
93
+ ```
94
+
95
+ Fill each `<slot>` at schedule time: `<remaining_stage_durations>` is the tail of `stages_seconds` after the stage being scheduled, and `<cancelled_crons_json>` is the recorded cron list from pause time, carried verbatim through every stage.
96
+
97
+ ## Layout
98
+
99
+ | File | Role |
100
+ |---|---|
101
+ | `SKILL.md` | This flow: resolve, weekly guard, stage chain, templates |
102
+ | `scripts/resolve_usage_window.py` | The window resolver and stage planner CLI |
103
+ | `scripts/test_resolve_usage_window.py` | Behavioral tests for parsing, staging, token reading, extraction, CLI |
104
+ | `scripts/usage_pause_constants/resolve_usage_window_constants.py` | Endpoint, credential keys, stage sizing, thresholds, result keys |
105
+
106
+ ## Gotchas
107
+
108
+ - The stored access token lives about 8 hours and the CLI rewrites it on its own schedule, so a mid-afternoon probe can find it expired even while the CLI itself still works. That is the designed exit-2 path: give a manual time.
@@ -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())