claude-dev-env 2.2.0 → 2.3.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 +1 -1
- package/_shared/advisor/advisor-protocol.md +2 -2
- package/_shared/pr-loop/scripts/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -284
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +3 -3
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -0
- package/_shared/pr-loop/scripts/pyproject.toml +22 -0
- package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +5 -0
- package/_shared/pr-loop/scripts/stale_worktree_rule_sweep.py +107 -0
- package/_shared/pr-loop/scripts/tests/CLAUDE.md +2 -0
- package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +55 -73
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +9 -15
- package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +91 -1
- package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +88 -1
- package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -0
- package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -0
- package/_shared/pr-loop/worker-spawn.md +1 -1
- package/agents/code-verifier.md +1 -1
- package/hooks/blocking/code_rules_shared.py +4 -2
- package/hooks/blocking/config/verified_commit_constants.py +11 -0
- package/hooks/blocking/pii_payload_scan.py +78 -20
- package/hooks/blocking/pii_prevention_blocker.py +3 -1
- package/hooks/blocking/test_code_verifier_tools_contract.py +28 -0
- package/hooks/blocking/test_pii_write_surface_ephemeral.py +221 -0
- package/hooks/blocking/test_verification_verdict_store.py +93 -0
- package/hooks/blocking/verification_verdict_store.py +91 -0
- package/hooks/validators/ruff_integration.py +2 -1
- package/hooks/validators/run_all_validators.py +460 -24
- package/hooks/validators/test_ruff_integration.py +21 -0
- package/hooks/validators/test_run_all_validators_pretooluse.py +223 -3
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/cleanup-command-forms.md +23 -0
- package/scripts/CLAUDE.md +2 -1
- package/scripts/check.ps1 +32 -6
- package/scripts/claude-chain.example.json +15 -3
- package/scripts/claude_chain_runner.py +130 -27
- package/scripts/claude_chain_usage.py +346 -0
- package/scripts/dev_env_scripts_constants/CLAUDE.md +4 -3
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +13 -1
- package/scripts/dev_env_scripts_constants/claude_chain_usage_constants.py +58 -0
- package/scripts/dev_env_scripts_constants/code_review_constants.py +1 -1
- package/scripts/dev_env_scripts_constants/timing.py +1 -1
- package/scripts/test_claude_chain_runner.py +412 -6
- package/scripts/test_claude_chain_usage.py +534 -0
- package/skills/orchestrator/SKILL.md +1 -20
- package/skills/orchestrator-refresh/SKILL.md +1 -1
- package/skills/pr-converge/SKILL.md +3 -3
- package/skills/pr-converge/reference/examples.md +3 -3
- package/skills/pr-converge/reference/fix-protocol.md +1 -1
- package/skills/pr-converge/reference/ground-rules.md +3 -3
- package/skills/pr-converge/reference/per-tick.md +5 -5
- package/skills/pr-converge/reference/progress-checklist.md +1 -1
- package/skills/pr-converge/test_step5_host_branch.py +1 -1
- package/skills/team-advisor/SKILL.md +3 -2
- package/skills/usage-pause/SKILL.md +5 -4
- package/skills/usage-pause/scripts/resolve_usage_window.py +68 -13
- package/skills/usage-pause/scripts/test_resolve_usage_window.py +121 -9
- package/skills/usage-pause/scripts/usage_pause_constants/resolve_usage_window_constants.py +7 -3
|
@@ -1,23 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Run a ``claude`` invocation through a
|
|
2
|
+
"""Run a ``claude`` invocation through a usage-ranked fallback chain.
|
|
3
3
|
|
|
4
4
|
An automation that shells out to a single ``claude -p ...`` fails outright when
|
|
5
5
|
that account hits a usage limit. Other logged-in installs sit idle meanwhile.
|
|
6
|
-
This module
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
This module probes remaining weekly usage once per call, ranks chain accounts
|
|
7
|
+
highest remaining first, and tries that order. It falls over to the next
|
|
8
|
+
ranked binary only on a usage-limit failure. Every other outcome returns to
|
|
9
|
+
the caller unchanged.
|
|
9
10
|
|
|
10
11
|
The chain lives in ``~/.claude/claude-chain.json``. Copy the committed
|
|
11
|
-
``claude-chain.example.json`` template there and list your binaries
|
|
12
|
-
order
|
|
12
|
+
``claude-chain.example.json`` template there and list your account binaries.
|
|
13
|
+
Try order comes from weekly remaining via ``claude_chain_usage`` (usage-pause
|
|
14
|
+
OAuth probe), not from list position alone::
|
|
13
15
|
|
|
14
16
|
{"chain": [{"command": "claude", "extra_args": []},
|
|
15
17
|
{"command": "claude-ev", "extra_args": []}]}
|
|
16
18
|
|
|
17
|
-
A usage-limited
|
|
19
|
+
A usage-limited first try falls over to the next ranked binary::
|
|
18
20
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
+
first try (highest remaining) -> exit 1, "usage limit reached" (falls over)
|
|
22
|
+
next ranked binary -> exit 0 (served)
|
|
21
23
|
|
|
22
24
|
When stdin is piped (not a TTY), the runner reads it once and forwards the
|
|
23
25
|
same text to every chain attempt so a piped ``-p`` charter body reaches each
|
|
@@ -33,13 +35,19 @@ Import ``run_claude`` for the outcome object, or run the module as a CLI::
|
|
|
33
35
|
from __future__ import annotations
|
|
34
36
|
|
|
35
37
|
import argparse
|
|
38
|
+
import importlib
|
|
36
39
|
import io
|
|
37
40
|
import json
|
|
38
41
|
import subprocess
|
|
39
42
|
import sys
|
|
43
|
+
from collections.abc import Callable, Sequence
|
|
40
44
|
from dataclasses import dataclass
|
|
41
45
|
from pathlib import Path
|
|
42
|
-
from
|
|
46
|
+
from types import ModuleType
|
|
47
|
+
from typing import Protocol, TextIO
|
|
48
|
+
|
|
49
|
+
if __name__ == "__main__":
|
|
50
|
+
sys.modules.setdefault("claude_chain_runner", sys.modules[__name__])
|
|
43
51
|
|
|
44
52
|
from dev_env_scripts_constants.claude_chain_constants import (
|
|
45
53
|
ALL_USAGE_LIMIT_SIGNATURES,
|
|
@@ -53,6 +61,7 @@ from dev_env_scripts_constants.claude_chain_constants import (
|
|
|
53
61
|
CHAIN_CONFIG_ERROR_EXIT_CODE,
|
|
54
62
|
CHAIN_EXHAUSTED_EXIT_CODE,
|
|
55
63
|
CHAIN_EXHAUSTED_MESSAGE_TEMPLATE,
|
|
64
|
+
CHAIN_USAGE_MODULE_NAME,
|
|
56
65
|
CLAUDE_HOME_SUBDIRECTORY,
|
|
57
66
|
CLI_ARGUMENTS_SEPARATOR,
|
|
58
67
|
CLI_TIMEOUT_FLAG,
|
|
@@ -61,7 +70,9 @@ from dev_env_scripts_constants.claude_chain_constants import (
|
|
|
61
70
|
CONFIG_CHAIN_KEY,
|
|
62
71
|
CONFIG_CHAIN_NOT_LIST_REASON,
|
|
63
72
|
CONFIG_COMMAND_KEY,
|
|
73
|
+
CONFIG_CREDENTIALS_PATH_KEY,
|
|
64
74
|
CONFIG_ENTRY_COMMAND_MISSING_REASON,
|
|
75
|
+
CONFIG_ENTRY_CREDENTIALS_PATH_INVALID_REASON,
|
|
65
76
|
CONFIG_ENTRY_EXTRA_ARGS_INVALID_REASON,
|
|
66
77
|
CONFIG_ENTRY_NOT_OBJECT_REASON,
|
|
67
78
|
CONFIG_EXTRA_ARGS_KEY,
|
|
@@ -84,10 +95,16 @@ class ChainConfigurationError(Exception):
|
|
|
84
95
|
|
|
85
96
|
@dataclass(frozen=True)
|
|
86
97
|
class ChainEntry:
|
|
87
|
-
"""One binary in the fallback chain and its per-account extra arguments.
|
|
98
|
+
"""One binary in the fallback chain and its per-account extra arguments.
|
|
99
|
+
|
|
100
|
+
``credentials_path`` is an optional path to that account's OAuth credentials
|
|
101
|
+
file. The subprocess walk does not pass it; weekly-usage ranking reads it
|
|
102
|
+
when present.
|
|
103
|
+
"""
|
|
88
104
|
|
|
89
105
|
command: str
|
|
90
106
|
extra_args: tuple[str, ...]
|
|
107
|
+
credentials_path: str | None = None
|
|
91
108
|
|
|
92
109
|
|
|
93
110
|
@dataclass(frozen=True)
|
|
@@ -104,8 +121,8 @@ class ChainInvocationOutcome:
|
|
|
104
121
|
|
|
105
122
|
``served_command`` names the binary whose response is returned. It is
|
|
106
123
|
``None`` when no binary served the call: every entry was usage-limited or
|
|
107
|
-
missing, the invocation timed out
|
|
108
|
-
|
|
124
|
+
missing, or the invocation timed out. The ``attempts`` trail records every
|
|
125
|
+
binary tried and how it resolved.
|
|
109
126
|
"""
|
|
110
127
|
|
|
111
128
|
served_command: str | None
|
|
@@ -115,9 +132,31 @@ class ChainInvocationOutcome:
|
|
|
115
132
|
attempts: tuple[ChainAttempt, ...]
|
|
116
133
|
|
|
117
134
|
|
|
135
|
+
class WeeklyUsageAccountReport(Protocol):
|
|
136
|
+
"""Minimal account-report surface the runner needs for ranking and mapping."""
|
|
137
|
+
|
|
138
|
+
command: str
|
|
139
|
+
|
|
140
|
+
|
|
118
141
|
chain_subprocess_runner = subprocess.run
|
|
119
142
|
|
|
120
143
|
|
|
144
|
+
def _load_chain_usage_module() -> ModuleType:
|
|
145
|
+
return importlib.import_module(CHAIN_USAGE_MODULE_NAME)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _default_chain_weekly_usage_reporter(
|
|
149
|
+
*, config_path: Path
|
|
150
|
+
) -> list[WeeklyUsageAccountReport]:
|
|
151
|
+
usage_module = _load_chain_usage_module()
|
|
152
|
+
return usage_module.report_chain_weekly_usage(config_path=config_path)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
chain_weekly_usage_reporter: Callable[..., list[WeeklyUsageAccountReport]] = (
|
|
156
|
+
_default_chain_weekly_usage_reporter
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
121
160
|
def chain_config_path() -> Path:
|
|
122
161
|
"""Return the path to the per-user chain configuration file."""
|
|
123
162
|
return Path.home() / CLAUDE_HOME_SUBDIRECTORY / CONFIG_FILENAME
|
|
@@ -141,6 +180,18 @@ def _coerce_extra_args(raw_extra_args: object, config_path: Path) -> tuple[str,
|
|
|
141
180
|
return tuple(raw_extra_args)
|
|
142
181
|
|
|
143
182
|
|
|
183
|
+
def _coerce_credentials_path(
|
|
184
|
+
raw_credentials_path: object, config_path: Path
|
|
185
|
+
) -> str | None:
|
|
186
|
+
if raw_credentials_path is None:
|
|
187
|
+
return None
|
|
188
|
+
if not isinstance(raw_credentials_path, str) or not raw_credentials_path:
|
|
189
|
+
raise _invalid_shape_error(
|
|
190
|
+
config_path, CONFIG_ENTRY_CREDENTIALS_PATH_INVALID_REASON
|
|
191
|
+
)
|
|
192
|
+
return raw_credentials_path
|
|
193
|
+
|
|
194
|
+
|
|
144
195
|
def _parse_chain_entry(raw_entry: object, config_path: Path) -> ChainEntry:
|
|
145
196
|
if not isinstance(raw_entry, dict):
|
|
146
197
|
raise _invalid_shape_error(config_path, CONFIG_ENTRY_NOT_OBJECT_REASON)
|
|
@@ -150,7 +201,14 @@ def _parse_chain_entry(raw_entry: object, config_path: Path) -> ChainEntry:
|
|
|
150
201
|
extra_args = _coerce_extra_args(
|
|
151
202
|
raw_entry.get(CONFIG_EXTRA_ARGS_KEY, []), config_path
|
|
152
203
|
)
|
|
153
|
-
|
|
204
|
+
credentials_path = _coerce_credentials_path(
|
|
205
|
+
raw_entry.get(CONFIG_CREDENTIALS_PATH_KEY), config_path
|
|
206
|
+
)
|
|
207
|
+
return ChainEntry(
|
|
208
|
+
command=command,
|
|
209
|
+
extra_args=extra_args,
|
|
210
|
+
credentials_path=credentials_path,
|
|
211
|
+
)
|
|
154
212
|
|
|
155
213
|
|
|
156
214
|
def _parse_chain_entries(parsed_config: object, config_path: Path) -> list[ChainEntry]:
|
|
@@ -212,6 +270,33 @@ def _build_invocation(entry: ChainEntry, all_claude_arguments: list[str]) -> lis
|
|
|
212
270
|
return [entry.command, *all_claude_arguments, *entry.extra_args]
|
|
213
271
|
|
|
214
272
|
|
|
273
|
+
def _entries_ranked_by_weekly_remaining(
|
|
274
|
+
all_entries: list[ChainEntry],
|
|
275
|
+
all_usage_reports: Sequence[WeeklyUsageAccountReport],
|
|
276
|
+
) -> list[ChainEntry]:
|
|
277
|
+
usage_module = _load_chain_usage_module()
|
|
278
|
+
entries_by_command: dict[str, list[ChainEntry]] = {}
|
|
279
|
+
for each_entry in all_entries:
|
|
280
|
+
entries_by_command.setdefault(each_entry.command, []).append(each_entry)
|
|
281
|
+
all_ranked_reports = usage_module.rank_accounts_by_weekly_remaining(
|
|
282
|
+
list(all_usage_reports)
|
|
283
|
+
)
|
|
284
|
+
ranked_entries: list[ChainEntry] = []
|
|
285
|
+
seen_commands: set[str] = set()
|
|
286
|
+
for each_report in all_ranked_reports:
|
|
287
|
+
if each_report.command in seen_commands:
|
|
288
|
+
continue
|
|
289
|
+
matched_entries = entries_by_command.get(each_report.command)
|
|
290
|
+
if matched_entries is None:
|
|
291
|
+
continue
|
|
292
|
+
seen_commands.add(each_report.command)
|
|
293
|
+
ranked_entries.extend(matched_entries)
|
|
294
|
+
for each_entry in all_entries:
|
|
295
|
+
if each_entry.command not in seen_commands:
|
|
296
|
+
ranked_entries.append(each_entry)
|
|
297
|
+
return ranked_entries
|
|
298
|
+
|
|
299
|
+
|
|
215
300
|
def _is_usage_limit_failure(completion: subprocess.CompletedProcess[str]) -> bool:
|
|
216
301
|
combined_text = f"{completion.stdout}{completion.stderr}".lower()
|
|
217
302
|
return any(
|
|
@@ -291,21 +376,40 @@ def _classify_completion(
|
|
|
291
376
|
return _served_outcome(entry.command, completion, all_attempts)
|
|
292
377
|
|
|
293
378
|
|
|
379
|
+
def _ranked_entries_or_config_order(
|
|
380
|
+
all_entries: list[ChainEntry],
|
|
381
|
+
config_path: Path,
|
|
382
|
+
) -> list[ChainEntry]:
|
|
383
|
+
try:
|
|
384
|
+
all_usage_reports = chain_weekly_usage_reporter(config_path=config_path)
|
|
385
|
+
return _entries_ranked_by_weekly_remaining(
|
|
386
|
+
all_entries, all_usage_reports
|
|
387
|
+
)
|
|
388
|
+
except (ImportError, AttributeError):
|
|
389
|
+
return list(all_entries)
|
|
390
|
+
|
|
391
|
+
|
|
294
392
|
def run_claude(
|
|
295
393
|
all_claude_arguments: list[str],
|
|
296
394
|
*,
|
|
297
395
|
timeout_seconds: int,
|
|
298
396
|
stdin_text: str | None = None,
|
|
299
397
|
) -> ChainInvocationOutcome:
|
|
300
|
-
"""Run *all_claude_arguments* through the
|
|
398
|
+
"""Run *all_claude_arguments* through the usage-ranked fallback chain.
|
|
399
|
+
|
|
400
|
+
::
|
|
401
|
+
|
|
402
|
+
highest remaining usage-limited, next ranked ok
|
|
403
|
+
-> served_command=next ranked, returncode=0
|
|
404
|
+
first try nonzero without usage-limit signature
|
|
405
|
+
-> served_command=first try (no fallover)
|
|
406
|
+
stdin_text set
|
|
407
|
+
-> same text on every attempt's stdin
|
|
301
408
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
signature stops the walk and returns that outcome unchanged. When
|
|
307
|
-
*stdin_text* is set, that same text is supplied as stdin on every chain
|
|
308
|
-
attempt.
|
|
409
|
+
Probes weekly remaining once, ranks highest first, then walks that order.
|
|
410
|
+
Only a usage-limit failure falls over. Missing binaries are skipped and the
|
|
411
|
+
walk continues; timeout and other nonzero exits stop. When usage ranking
|
|
412
|
+
infrastructure fails to load, the walk uses config order instead.
|
|
309
413
|
|
|
310
414
|
Args:
|
|
311
415
|
all_claude_arguments: Arguments passed after the binary name, such as
|
|
@@ -321,11 +425,12 @@ def run_claude(
|
|
|
321
425
|
Raises:
|
|
322
426
|
ChainConfigurationError: When the chain configuration cannot be loaded.
|
|
323
427
|
"""
|
|
324
|
-
|
|
428
|
+
config_path = chain_config_path()
|
|
429
|
+
all_entries = load_chain(config_path)
|
|
430
|
+
all_ranked_entries = _ranked_entries_or_config_order(all_entries, config_path)
|
|
325
431
|
all_attempts: list[ChainAttempt] = []
|
|
326
432
|
last_usage_limited: subprocess.CompletedProcess[str] | None = None
|
|
327
|
-
for
|
|
328
|
-
is_primary = each_index == 0
|
|
433
|
+
for each_entry in all_ranked_entries:
|
|
329
434
|
try:
|
|
330
435
|
completion = chain_subprocess_runner(
|
|
331
436
|
_build_invocation(each_entry, all_claude_arguments),
|
|
@@ -346,8 +451,6 @@ def run_claude(
|
|
|
346
451
|
all_attempts.append(
|
|
347
452
|
ChainAttempt(each_entry.command, ATTEMPT_STATUS_EXECUTABLE_NOT_FOUND)
|
|
348
453
|
)
|
|
349
|
-
if is_primary:
|
|
350
|
-
return _no_process_outcome(all_attempts, None)
|
|
351
454
|
continue
|
|
352
455
|
terminal_outcome = _classify_completion(each_entry, completion, all_attempts)
|
|
353
456
|
if terminal_outcome is not None:
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Report remaining weekly usage for every account in the Claude chain.
|
|
3
|
+
|
|
4
|
+
Loads ``~/.claude/claude-chain.json`` (or a path the caller names), probes each
|
|
5
|
+
entry's weekly utilization through the usage-pause OAuth endpoint, and prints a
|
|
6
|
+
JSON report. Ranking is available as an importable helper; ``run_claude``
|
|
7
|
+
consumes that ranking to choose try order.
|
|
8
|
+
|
|
9
|
+
::
|
|
10
|
+
|
|
11
|
+
python claude_chain_usage.py
|
|
12
|
+
{"accounts": [
|
|
13
|
+
{"command": "claude", "weekly_remaining_percent": 58.0},
|
|
14
|
+
{"command": "claude-ev", "weekly_remaining_percent": null, "error": "..."}
|
|
15
|
+
]}
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import http.client
|
|
22
|
+
import importlib.util
|
|
23
|
+
import json
|
|
24
|
+
import sys
|
|
25
|
+
import urllib.error
|
|
26
|
+
from collections.abc import Callable
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from datetime import datetime
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from types import ModuleType
|
|
31
|
+
|
|
32
|
+
from dev_env_scripts_constants.claude_chain_constants import CHAIN_CONFIG_ERROR_EXIT_CODE
|
|
33
|
+
from dev_env_scripts_constants.claude_chain_usage_constants import (
|
|
34
|
+
CLI_CONFIG_PATH_FLAG,
|
|
35
|
+
FULL_WEEKLY_PERCENT,
|
|
36
|
+
JSON_ACCOUNTS_KEY,
|
|
37
|
+
JSON_COMMAND_KEY,
|
|
38
|
+
JSON_ERROR_KEY,
|
|
39
|
+
JSON_WEEKLY_REMAINING_PERCENT_KEY,
|
|
40
|
+
NO_ACCESS_TOKEN_ERROR_TEMPLATE,
|
|
41
|
+
RESOLVE_USAGE_WINDOW_FILENAME,
|
|
42
|
+
RESOLVE_USAGE_WINDOW_MISSING_ERROR_TEMPLATE,
|
|
43
|
+
RESOLVE_USAGE_WINDOW_MODULE_NAME,
|
|
44
|
+
USAGE_PAUSE_SCRIPTS_DIRECTORY_NAME,
|
|
45
|
+
USAGE_PAUSE_SKILL_DIRECTORY_NAME,
|
|
46
|
+
USAGE_PAUSE_SKILL_NAME,
|
|
47
|
+
USAGE_PROBE_FAILED_ERROR_TEMPLATE,
|
|
48
|
+
WEEKLY_UTILIZATION_MISSING_ERROR,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
import claude_chain_runner as chain_runner
|
|
52
|
+
|
|
53
|
+
WeeklyUtilizationProbe = Callable[[Path], float]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class WeeklyUtilizationProbeError(Exception):
|
|
57
|
+
"""Raised when the weekly_utilization_probe cannot measure one account."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class AccountUsageReport:
|
|
62
|
+
"""One chain account's remaining weekly usage, or the reason it is unknown."""
|
|
63
|
+
|
|
64
|
+
command: str
|
|
65
|
+
weekly_remaining_percent: float | None
|
|
66
|
+
error: str | None = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _default_credentials_path() -> Path:
|
|
70
|
+
usage_window_resolver = _load_resolve_usage_window_module()
|
|
71
|
+
return Path.home().joinpath(
|
|
72
|
+
*usage_window_resolver.ALL_CREDENTIALS_RELATIVE_PATH_PARTS
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _credentials_path_for_entry(entry: chain_runner.ChainEntry) -> Path:
|
|
77
|
+
if entry.credentials_path is None:
|
|
78
|
+
return _default_credentials_path()
|
|
79
|
+
return Path(entry.credentials_path).expanduser()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _usage_pause_scripts_directory() -> Path:
|
|
83
|
+
package_root = Path(__file__).resolve().parent.parent
|
|
84
|
+
return (
|
|
85
|
+
package_root
|
|
86
|
+
/ USAGE_PAUSE_SKILL_DIRECTORY_NAME
|
|
87
|
+
/ USAGE_PAUSE_SKILL_NAME
|
|
88
|
+
/ USAGE_PAUSE_SCRIPTS_DIRECTORY_NAME
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _discard_sys_path_entry(path_text: str) -> None:
|
|
93
|
+
if path_text in sys.path:
|
|
94
|
+
sys.path.remove(path_text)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _load_resolve_usage_window_module() -> ModuleType:
|
|
98
|
+
already_loaded = sys.modules.get(RESOLVE_USAGE_WINDOW_MODULE_NAME)
|
|
99
|
+
if already_loaded is not None:
|
|
100
|
+
return already_loaded
|
|
101
|
+
scripts_directory = _usage_pause_scripts_directory()
|
|
102
|
+
module_path = scripts_directory / RESOLVE_USAGE_WINDOW_FILENAME
|
|
103
|
+
if not module_path.is_file():
|
|
104
|
+
raise WeeklyUtilizationProbeError(
|
|
105
|
+
RESOLVE_USAGE_WINDOW_MISSING_ERROR_TEMPLATE.format(
|
|
106
|
+
module_path=module_path
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
scripts_directory_text = str(scripts_directory)
|
|
110
|
+
was_path_inserted = False
|
|
111
|
+
if scripts_directory_text not in sys.path:
|
|
112
|
+
sys.path.insert(0, scripts_directory_text)
|
|
113
|
+
was_path_inserted = True
|
|
114
|
+
is_module_ready = False
|
|
115
|
+
try:
|
|
116
|
+
module_specification = importlib.util.spec_from_file_location(
|
|
117
|
+
RESOLVE_USAGE_WINDOW_MODULE_NAME, module_path
|
|
118
|
+
)
|
|
119
|
+
if module_specification is None or module_specification.loader is None:
|
|
120
|
+
raise WeeklyUtilizationProbeError(
|
|
121
|
+
RESOLVE_USAGE_WINDOW_MISSING_ERROR_TEMPLATE.format(
|
|
122
|
+
module_path=module_path
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
loaded_module = importlib.util.module_from_spec(module_specification)
|
|
126
|
+
sys.modules[RESOLVE_USAGE_WINDOW_MODULE_NAME] = loaded_module
|
|
127
|
+
module_specification.loader.exec_module(loaded_module)
|
|
128
|
+
is_module_ready = True
|
|
129
|
+
finally:
|
|
130
|
+
if not is_module_ready:
|
|
131
|
+
sys.modules.pop(RESOLVE_USAGE_WINDOW_MODULE_NAME, None)
|
|
132
|
+
if was_path_inserted:
|
|
133
|
+
_discard_sys_path_entry(scripts_directory_text)
|
|
134
|
+
return loaded_module
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _probe_weekly_utilization(credentials_path: Path) -> float:
|
|
138
|
+
usage_window_resolver = _load_resolve_usage_window_module()
|
|
139
|
+
now = datetime.now().astimezone()
|
|
140
|
+
try:
|
|
141
|
+
access_token = usage_window_resolver.read_oauth_access_token(
|
|
142
|
+
credentials_path, now
|
|
143
|
+
)
|
|
144
|
+
if access_token is None:
|
|
145
|
+
raise WeeklyUtilizationProbeError(
|
|
146
|
+
NO_ACCESS_TOKEN_ERROR_TEMPLATE.format(credentials_path=credentials_path)
|
|
147
|
+
)
|
|
148
|
+
usage_payload = usage_window_resolver._fetch_usage_payload(access_token)
|
|
149
|
+
except WeeklyUtilizationProbeError:
|
|
150
|
+
raise
|
|
151
|
+
except (
|
|
152
|
+
urllib.error.URLError,
|
|
153
|
+
http.client.HTTPException,
|
|
154
|
+
TimeoutError,
|
|
155
|
+
OSError,
|
|
156
|
+
ValueError,
|
|
157
|
+
) as probe_error:
|
|
158
|
+
raise WeeklyUtilizationProbeError(
|
|
159
|
+
USAGE_PROBE_FAILED_ERROR_TEMPLATE.format(error=probe_error)
|
|
160
|
+
) from probe_error
|
|
161
|
+
usage_windows = usage_window_resolver.extract_usage_windows(usage_payload)
|
|
162
|
+
if usage_windows.weekly_utilization is None:
|
|
163
|
+
raise WeeklyUtilizationProbeError(WEEKLY_UTILIZATION_MISSING_ERROR)
|
|
164
|
+
return float(usage_windows.weekly_utilization)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
weekly_utilization_probe: WeeklyUtilizationProbe = _probe_weekly_utilization
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def weekly_remaining_from_utilization(weekly_utilization: float) -> float:
|
|
171
|
+
"""Convert weekly utilization percent into remaining weekly percent.
|
|
172
|
+
|
|
173
|
+
::
|
|
174
|
+
|
|
175
|
+
weekly_remaining_from_utilization(42.0) -> 58.0
|
|
176
|
+
weekly_remaining_from_utilization(9.0) -> 91.0
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
weekly_utilization: Used weekly capacity on the 0–100 scale.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
Remaining weekly capacity on the same 0–100 scale.
|
|
183
|
+
"""
|
|
184
|
+
return FULL_WEEKLY_PERCENT - weekly_utilization
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _report_for_entry(
|
|
188
|
+
entry: chain_runner.ChainEntry,
|
|
189
|
+
active_probe: WeeklyUtilizationProbe,
|
|
190
|
+
) -> AccountUsageReport:
|
|
191
|
+
try:
|
|
192
|
+
credentials_path = _credentials_path_for_entry(entry)
|
|
193
|
+
weekly_utilization = active_probe(credentials_path)
|
|
194
|
+
except (WeeklyUtilizationProbeError, ImportError, AttributeError) as probe_error:
|
|
195
|
+
return AccountUsageReport(
|
|
196
|
+
command=entry.command,
|
|
197
|
+
weekly_remaining_percent=None,
|
|
198
|
+
error=str(probe_error),
|
|
199
|
+
)
|
|
200
|
+
return AccountUsageReport(
|
|
201
|
+
command=entry.command,
|
|
202
|
+
weekly_remaining_percent=weekly_remaining_from_utilization(
|
|
203
|
+
weekly_utilization
|
|
204
|
+
),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def report_chain_weekly_usage(*, config_path: Path) -> list[AccountUsageReport]:
|
|
209
|
+
"""Report remaining weekly usage for every account in the chain config.
|
|
210
|
+
|
|
211
|
+
::
|
|
212
|
+
|
|
213
|
+
report_chain_weekly_usage(config_path=Path("chain.json"))
|
|
214
|
+
-> [AccountUsageReport("claude", 58.0), ...]
|
|
215
|
+
|
|
216
|
+
Uses the module-level ``weekly_utilization_probe`` (tests rebind it).
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
config_path: Chain configuration file to load.
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
One report per chain entry, in config order.
|
|
223
|
+
|
|
224
|
+
Raises:
|
|
225
|
+
ChainConfigurationError: When the chain configuration cannot be loaded.
|
|
226
|
+
"""
|
|
227
|
+
all_entries = chain_runner.load_chain(config_path)
|
|
228
|
+
return [
|
|
229
|
+
_report_for_entry(each_entry, weekly_utilization_probe)
|
|
230
|
+
for each_entry in all_entries
|
|
231
|
+
]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _nulls_last_remaining_rank(report: AccountUsageReport) -> tuple[int, float]:
|
|
235
|
+
if report.weekly_remaining_percent is None:
|
|
236
|
+
return (1, 0.0)
|
|
237
|
+
return (0, -report.weekly_remaining_percent)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def rank_accounts_by_weekly_remaining(
|
|
241
|
+
all_reports: list[AccountUsageReport],
|
|
242
|
+
) -> list[AccountUsageReport]:
|
|
243
|
+
"""Order reports by remaining weekly usage, highest first.
|
|
244
|
+
|
|
245
|
+
::
|
|
246
|
+
|
|
247
|
+
[40%, 70%, null, 70%] -> [70% (earlier), 70% (later), 40%, null]
|
|
248
|
+
|
|
249
|
+
Ties keep config order. Unmeasurable accounts (null remaining) sort last.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
all_reports: Reports in chain-config order.
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
A new list ordered by remaining weekly percent descending.
|
|
256
|
+
"""
|
|
257
|
+
return sorted(all_reports, key=_nulls_last_remaining_rank)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _account_report_to_json_mapping(
|
|
261
|
+
report: AccountUsageReport,
|
|
262
|
+
) -> dict[str, str | float | None]:
|
|
263
|
+
if report.weekly_remaining_percent is None:
|
|
264
|
+
return {
|
|
265
|
+
JSON_COMMAND_KEY: report.command,
|
|
266
|
+
JSON_WEEKLY_REMAINING_PERCENT_KEY: None,
|
|
267
|
+
JSON_ERROR_KEY: report.error,
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
JSON_COMMAND_KEY: report.command,
|
|
271
|
+
JSON_WEEKLY_REMAINING_PERCENT_KEY: report.weekly_remaining_percent,
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def reports_to_json_payload(
|
|
276
|
+
all_reports: list[AccountUsageReport],
|
|
277
|
+
) -> dict[str, list[dict[str, str | float | None]]]:
|
|
278
|
+
"""Build the CLI JSON payload for a list of account reports.
|
|
279
|
+
|
|
280
|
+
::
|
|
281
|
+
|
|
282
|
+
reports_to_json_payload([AccountUsageReport("claude", 58.0)])
|
|
283
|
+
-> {"accounts": [{"command": "claude", "weekly_remaining_percent": 58.0}]}
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
all_reports: Reports in the order they should appear in the payload.
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
The ``{"accounts": [...]}`` envelope.
|
|
290
|
+
"""
|
|
291
|
+
return {
|
|
292
|
+
JSON_ACCOUNTS_KEY: [
|
|
293
|
+
_account_report_to_json_mapping(each_report)
|
|
294
|
+
for each_report in all_reports
|
|
295
|
+
]
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _build_argument_parser() -> argparse.ArgumentParser:
|
|
300
|
+
parser = argparse.ArgumentParser(
|
|
301
|
+
description=(
|
|
302
|
+
"Report remaining weekly usage for every account in the Claude chain."
|
|
303
|
+
)
|
|
304
|
+
)
|
|
305
|
+
parser.add_argument(
|
|
306
|
+
CLI_CONFIG_PATH_FLAG,
|
|
307
|
+
dest="config_path",
|
|
308
|
+
default=None,
|
|
309
|
+
help="Path to claude-chain.json; defaults to the per-user chain config.",
|
|
310
|
+
)
|
|
311
|
+
return parser
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def main(all_command_arguments: list[str]) -> int:
|
|
315
|
+
"""Probe the chain and print the weekly-usage JSON report on stdout.
|
|
316
|
+
|
|
317
|
+
::
|
|
318
|
+
|
|
319
|
+
main(["--config-path", "chain.json"]) -> 0 # JSON on stdout
|
|
320
|
+
main(["--config-path", "missing.json"]) -> 3 # error on stderr
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
all_command_arguments: The argument vector after the program name.
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
Zero on success, or the chain-config error exit code when the config
|
|
327
|
+
cannot be loaded.
|
|
328
|
+
"""
|
|
329
|
+
parser = _build_argument_parser()
|
|
330
|
+
parsed_arguments = parser.parse_args(all_command_arguments)
|
|
331
|
+
resolved_config_path = (
|
|
332
|
+
Path(parsed_arguments.config_path)
|
|
333
|
+
if parsed_arguments.config_path is not None
|
|
334
|
+
else chain_runner.chain_config_path()
|
|
335
|
+
)
|
|
336
|
+
try:
|
|
337
|
+
all_reports = report_chain_weekly_usage(config_path=resolved_config_path)
|
|
338
|
+
except chain_runner.ChainConfigurationError as configuration_error:
|
|
339
|
+
print(str(configuration_error), file=sys.stderr)
|
|
340
|
+
return CHAIN_CONFIG_ERROR_EXIT_CODE
|
|
341
|
+
print(json.dumps(reports_to_json_payload(all_reports), ensure_ascii=True))
|
|
342
|
+
return 0
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
if __name__ == "__main__":
|
|
346
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -8,11 +8,12 @@ Named constants for scripts in `scripts/`. Follows the project convention that t
|
|
|
8
8
|
|---|---|
|
|
9
9
|
| `timing.py` | `sweep_empty_dirs.py` - `DEFAULT_AGE_SECONDS` (smallest age before an empty directory is eligible for removal) and `DEFAULT_POLL_INTERVAL` (seconds between sweep passes in continuous-watch mode); `spawn_grok_batch.py` - `WORKER_STAGGER_SECONDS` (seconds between staggered headless grok worker starts); `invoke_code_review.py` - `DEFAULT_CODE_REVIEW_TIMEOUT_SECONDS` (timeout for one headless `/code-review` chain run) |
|
|
10
10
|
| `gh_artifact_upload_constants.py` | `gh_artifact_upload.py` - the `artifacts` release tag, title, and notes body, the GitHub CLI binary name, the asset-name timestamp format and template, the asset download URL template, the notes-file suffix, and the text encoding |
|
|
11
|
-
| `claude_chain_constants.py` | `claude_chain_runner.py` - the chain config filename and home subdirectory, the usage-limit signature text, the per-binary status labels, the default timeout, CLI flag and separator tokens, config JSON keys, invalid-shape reason text, config-error and exhausted-chain message templates, and CLI exit codes |
|
|
11
|
+
| `claude_chain_constants.py` | `claude_chain_runner.py` - the chain config filename and home subdirectory, the usage-limit signature text, the per-binary status labels, the default timeout, CLI flag and separator tokens, config JSON keys (including optional `credentials_path`), invalid-shape reason text, config-error and exhausted-chain message templates, and CLI exit codes |
|
|
12
|
+
| `claude_chain_usage_constants.py` | `claude_chain_usage.py` - full weekly percent scale, usage-pause skill path segments, CLI config-path flag, JSON report keys, and probe error message templates |
|
|
12
13
|
| `grok_worker_constants.py` | `grok_worker_preflight.py`, `grok_headless_runner.py`, and `spawn_grok_batch.py` - the `grok` binary name and CLI flags, model and subcommand tokens, leader-socket and scratch-file name parts, auth and usage-limit signature lists, outcome classifications, fallthrough reasons, tool-profile names and prompt headers, timeouts and turn caps (including launch-failure return code and post-kill grace), ping-cache keys and TTL, batch-spec and summary JSON keys, the prompt-part and report-stream join separators, and the CLI launch-error stderr prefix |
|
|
13
|
-
| `code_review_constants.py` | `invoke_code_review.py` - the `/code-review
|
|
14
|
+
| `code_review_constants.py` | `invoke_code_review.py` - the `/code-review xhigh --fix` prompt, opus model alias, permission-mode flag and value, result mode and JSON keys, session-model CLI flag, git dirty-check tokens, and in-session return markers |
|
|
14
15
|
| `__init__.py` | Empty package marker |
|
|
15
16
|
|
|
16
17
|
## Convention
|
|
17
18
|
|
|
18
|
-
Every constant is `UPPER_SNAKE_CASE` with an explicit type annotation and a docstring. Scripts import from here rather than embedding literal values in their bodies.
|
|
19
|
+
Every constant is `UPPER_SNAKE_CASE` with an explicit type annotation and a docstring. Scripts import from here rather than embedding literal values in their bodies.
|
|
@@ -18,6 +18,9 @@ CLAUDE_HOME_SUBDIRECTORY: str = ".claude"
|
|
|
18
18
|
CONFIG_FILENAME: str = "claude-chain.json"
|
|
19
19
|
"""Real chain-configuration filename read from the user's home directory."""
|
|
20
20
|
|
|
21
|
+
CHAIN_USAGE_MODULE_NAME: str = "claude_chain_usage"
|
|
22
|
+
"""Import name of the weekly-usage report module loaded lazily by the runner."""
|
|
23
|
+
|
|
21
24
|
EXAMPLE_CONFIG_FILENAME: str = "claude-chain.example.json"
|
|
22
25
|
"""Committed template filename referenced in the config-error guidance."""
|
|
23
26
|
|
|
@@ -30,6 +33,9 @@ CONFIG_COMMAND_KEY: str = "command"
|
|
|
30
33
|
CONFIG_EXTRA_ARGS_KEY: str = "extra_args"
|
|
31
34
|
"""Chain-entry key holding per-account arguments appended to each invocation."""
|
|
32
35
|
|
|
36
|
+
CONFIG_CREDENTIALS_PATH_KEY: str = "credentials_path"
|
|
37
|
+
"""Optional chain-entry key naming that account's OAuth credentials file path."""
|
|
38
|
+
|
|
33
39
|
ALL_USAGE_LIMIT_SIGNATURES: tuple[str, ...] = (
|
|
34
40
|
"hit your session limit",
|
|
35
41
|
"usage limit reached",
|
|
@@ -91,9 +97,15 @@ CONFIG_ENTRY_EXTRA_ARGS_INVALID_REASON: str = (
|
|
|
91
97
|
)
|
|
92
98
|
"""Reason detail when a chain entry's extra_args value is the wrong shape."""
|
|
93
99
|
|
|
100
|
+
CONFIG_ENTRY_CREDENTIALS_PATH_INVALID_REASON: str = (
|
|
101
|
+
"a chain entry's 'credentials_path' is not a non-empty string"
|
|
102
|
+
)
|
|
103
|
+
"""Reason detail when a chain entry's credentials_path value is the wrong shape."""
|
|
104
|
+
|
|
94
105
|
CONFIG_MISSING_MESSAGE_TEMPLATE: str = (
|
|
95
106
|
"Claude chain config not found at {config_path}. Copy {example_filename} to "
|
|
96
|
-
"{config_path} and list your
|
|
107
|
+
"{config_path} and list your account binaries. Try order comes from weekly "
|
|
108
|
+
"remaining; config order is the tiebreak."
|
|
97
109
|
)
|
|
98
110
|
"""Guidance shown when the config file is absent."""
|
|
99
111
|
|