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.
Files changed (77) hide show
  1. package/CLAUDE.md +0 -13
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +106 -14
  3. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +26 -0
  4. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +160 -0
  5. package/agents/clean-coder.md +2 -2
  6. package/agents/code-verifier.md +0 -1
  7. package/agents/test_agent_frontmatter.py +78 -0
  8. package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +1 -1
  9. package/audit-rubrics/prompts/category-j-code-rules-compliance.md +1 -1
  10. package/bin/install.mjs +1 -0
  11. package/docs/CODE_RULES.md +2 -2
  12. package/hooks/blocking/CLAUDE.md +6 -2
  13. package/hooks/blocking/code_rules_comments.py +2 -2
  14. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +44 -0
  15. package/hooks/blocking/config/verified_commit_constants.py +2 -0
  16. package/hooks/blocking/conftest.py +115 -0
  17. package/hooks/blocking/nas_ssh_binary_enforcer.py +191 -0
  18. package/hooks/blocking/pr_description_enforcer.py +46 -22
  19. package/hooks/blocking/pr_description_pr_number.py +5 -3
  20. package/hooks/blocking/pr_description_proof_of_work.py +367 -0
  21. package/hooks/blocking/precommit_code_rules_gate.py +5 -1
  22. package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +8 -2
  23. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +71 -0
  24. package/hooks/blocking/test_nas_ssh_binary_enforcer.py +168 -0
  25. package/hooks/blocking/test_pr_description_enforcer_proof_gate.py +175 -0
  26. package/hooks/blocking/test_pr_description_proof_of_work.py +162 -0
  27. package/hooks/blocking/test_precommit_code_rules_gate.py +89 -0
  28. package/hooks/blocking/test_verdict_directory_write_blocker.py +4 -0
  29. package/hooks/blocking/test_verification_verdict_store.py +11 -0
  30. package/hooks/blocking/test_verified_commit_config_bootstrap.py +49 -0
  31. package/hooks/blocking/test_verified_commit_gate.py +11 -0
  32. package/hooks/blocking/test_verifier_verdict_minter.py +11 -0
  33. package/hooks/blocking/test_volatile_path_in_post_blocker.py +210 -0
  34. package/hooks/blocking/verdict_directory_write_blocker.py +6 -0
  35. package/hooks/blocking/verification_verdict_store.py +73 -5
  36. package/hooks/blocking/verified_commit_config_bootstrap.py +51 -0
  37. package/hooks/blocking/verified_commit_gate.py +6 -0
  38. package/hooks/blocking/verifier_verdict_minter.py +6 -0
  39. package/hooks/blocking/volatile_path_in_post_blocker.py +351 -0
  40. package/hooks/hooks.json +32 -2
  41. package/hooks/hooks_constants/CLAUDE.md +4 -0
  42. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
  43. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +3 -0
  44. package/hooks/hooks_constants/enter_worktree_prefetch_constants.py +18 -0
  45. package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +59 -0
  46. package/hooks/hooks_constants/pr_description_enforcer_constants.py +2 -0
  47. package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +111 -0
  48. package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +48 -0
  49. package/hooks/lifecycle/CLAUDE.md +3 -1
  50. package/hooks/lifecycle/enter_worktree_origin_prefetch.py +146 -0
  51. package/hooks/lifecycle/test_enter_worktree_origin_prefetch.py +178 -0
  52. package/hooks/validators/run_all_validators.py +216 -4
  53. package/hooks/validators/test_run_all_validators_pretooluse.py +102 -0
  54. package/package.json +1 -1
  55. package/rules/CLAUDE.md +3 -0
  56. package/rules/durable-post-artifacts.md +35 -0
  57. package/rules/nas-ssh-invocation.md +21 -0
  58. package/rules/proof-of-work-pr-comments.md +26 -0
  59. package/scripts/CLAUDE.md +2 -0
  60. package/scripts/claude-chain.example.json +8 -0
  61. package/scripts/claude_chain_runner.py +400 -0
  62. package/scripts/dev_env_scripts_constants/CLAUDE.md +2 -0
  63. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +124 -0
  64. package/scripts/dev_env_scripts_constants/gh_artifact_upload_constants.py +43 -0
  65. package/scripts/gh_artifact_upload.py +256 -0
  66. package/scripts/sync_to_cursor/rules.py +1 -1
  67. package/scripts/test_claude_chain_runner.py +472 -0
  68. package/scripts/tests/test_gh_artifact_upload.py +205 -0
  69. package/skills/CLAUDE.md +3 -0
  70. package/skills/team-advisor/SKILL.md +188 -0
  71. package/skills/team-advisor-refresh/SKILL.md +25 -0
  72. package/skills/usage-pause/SKILL.md +108 -0
  73. package/skills/usage-pause/scripts/resolve_usage_window.py +462 -0
  74. package/skills/usage-pause/scripts/test_resolve_usage_window.py +278 -0
  75. package/skills/usage-pause/scripts/usage_pause_constants/__init__.py +1 -0
  76. package/skills/usage-pause/scripts/usage_pause_constants/resolve_usage_window_constants.py +65 -0
  77. 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())