claude-dev-env 1.90.0 → 1.92.1
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 +1 -52
- 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 +5 -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/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/hooks.json +7 -2
- package/hooks/hooks_constants/CLAUDE.md +2 -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/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/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 +7 -0
- package/rules/nas-ssh-invocation.md +21 -0
- package/rules/proof-of-work-pr-comments.md +26 -0
- package/rules/testing.md +0 -2
- package/scripts/CLAUDE.md +1 -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 +1 -0
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +124 -0
- package/scripts/sync_to_cursor/rules.py +1 -1
- package/scripts/test_claude_chain_runner.py +472 -0
- package/skills/CLAUDE.md +3 -0
- package/skills/orchestrator/SKILL.md +188 -0
- package/skills/orchestrator-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,25 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: orchestrator-refresh
|
|
3
|
+
description: >-
|
|
4
|
+
Fired by the /orchestrator loop reminder about every 20 minutes to
|
|
5
|
+
re-assert the advisor discipline mid-run. A compressed restatement of
|
|
6
|
+
/orchestrator: 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: '/orchestrator-refresh'.
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Orchestrator 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())
|