claude-dev-env 2.2.1 → 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/_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/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/config/verified_commit_constants.py +11 -0
- package/hooks/blocking/test_code_verifier_tools_contract.py +28 -0
- package/hooks/blocking/test_verification_verdict_store.py +93 -0
- package/hooks/blocking/verification_verdict_store.py +91 -0
- package/package.json +1 -1
- package/scripts/CLAUDE.md +2 -1
- 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
|
@@ -13,6 +13,7 @@ and reading/writing verdict files.
|
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
15
|
import ast
|
|
16
|
+
import datetime
|
|
16
17
|
import hashlib
|
|
17
18
|
import json
|
|
18
19
|
import re
|
|
@@ -20,6 +21,7 @@ import subprocess
|
|
|
20
21
|
import sys
|
|
21
22
|
import time
|
|
22
23
|
from pathlib import Path
|
|
24
|
+
from collections.abc import Mapping
|
|
23
25
|
|
|
24
26
|
blocking_directory = str(Path(__file__).resolve().parent)
|
|
25
27
|
if blocking_directory not in sys.path:
|
|
@@ -45,6 +47,15 @@ from config.verified_commit_constants import (
|
|
|
45
47
|
ALL_FALLBACK_BASE_REFERENCES,
|
|
46
48
|
EMPTY_SURFACE_GUARD_MESSAGE,
|
|
47
49
|
GIT_TIMEOUT_SECONDS,
|
|
50
|
+
LEDGER_KEY_ALL_PASS,
|
|
51
|
+
LEDGER_KEY_BRANCH,
|
|
52
|
+
LEDGER_KEY_FINDING_CHECK_NAMES,
|
|
53
|
+
LEDGER_KEY_FINDING_COUNT,
|
|
54
|
+
LEDGER_KEY_MANIFEST_HASH,
|
|
55
|
+
LEDGER_KEY_MINTED_FROM_AGENT_ID,
|
|
56
|
+
LEDGER_KEY_REPO_ROOT,
|
|
57
|
+
LEDGER_KEY_TIMESTAMP,
|
|
58
|
+
LOGS_DIRECTORY_NAME,
|
|
48
59
|
MANIFEST_HASH_CLI_FLAG,
|
|
49
60
|
MANIFEST_HASH_FOR_BRANCH_CLI_FLAG,
|
|
50
61
|
MINIMUM_STATUS_FIELD_COUNT,
|
|
@@ -65,10 +76,12 @@ from config.verified_commit_constants import (
|
|
|
65
76
|
VERDICT_DIRECTORY_NAME,
|
|
66
77
|
VERDICT_FENCE_PATTERN,
|
|
67
78
|
VERDICT_FILE_GLOB,
|
|
79
|
+
VERDICT_FINDING_CHECK_KEY,
|
|
68
80
|
VERDICT_JSON_INDENT,
|
|
69
81
|
VERDICT_KEY_ALL_PASS,
|
|
70
82
|
VERDICT_KEY_FINDINGS,
|
|
71
83
|
VERDICT_KEY_MANIFEST_SHA256,
|
|
84
|
+
VERIFIER_VERDICTS_JSONL_FILENAME,
|
|
72
85
|
WORKTREE_LIST_BRANCH_PREFIX,
|
|
73
86
|
WORKTREE_LIST_PATH_PREFIX,
|
|
74
87
|
)
|
|
@@ -654,6 +667,74 @@ def workflow_verdict_covers_surface(
|
|
|
654
667
|
return False
|
|
655
668
|
|
|
656
669
|
|
|
670
|
+
def _finding_check_names(all_findings: list) -> list[str]:
|
|
671
|
+
"""Collect the ``check`` name from each finding mapping that carries one.
|
|
672
|
+
|
|
673
|
+
Args:
|
|
674
|
+
all_findings: The verifier's findings list (empty when clean).
|
|
675
|
+
|
|
676
|
+
Returns:
|
|
677
|
+
Ordered list of non-empty check name strings; entries without a
|
|
678
|
+
string ``check`` field are omitted.
|
|
679
|
+
"""
|
|
680
|
+
all_check_names: list[str] = []
|
|
681
|
+
for each_finding in all_findings:
|
|
682
|
+
if not isinstance(each_finding, Mapping):
|
|
683
|
+
continue
|
|
684
|
+
check_name = each_finding.get(VERDICT_FINDING_CHECK_KEY)
|
|
685
|
+
if isinstance(check_name, str) and check_name:
|
|
686
|
+
all_check_names.append(check_name)
|
|
687
|
+
return all_check_names
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _append_verdict_ledger_row(
|
|
691
|
+
repo_root: str,
|
|
692
|
+
bound_manifest_sha256: str,
|
|
693
|
+
is_all_pass: bool,
|
|
694
|
+
all_findings: list,
|
|
695
|
+
minted_from_agent_id: str,
|
|
696
|
+
) -> None:
|
|
697
|
+
"""Append one fail-safe JSONL row for a minted verdict.
|
|
698
|
+
|
|
699
|
+
Writes to ``~/.claude/logs/verifier-verdicts.jsonl``. Swallows home
|
|
700
|
+
resolution and IO errors so a ledger failure never changes the mint.
|
|
701
|
+
|
|
702
|
+
Args:
|
|
703
|
+
repo_root: The repository top-level directory.
|
|
704
|
+
bound_manifest_sha256: Hash of the surface the verdict covers.
|
|
705
|
+
is_all_pass: Whether the verifier reported a clean verdict.
|
|
706
|
+
all_findings: The verifier's findings list (empty when clean).
|
|
707
|
+
minted_from_agent_id: The subagent invocation id, kept for audit.
|
|
708
|
+
"""
|
|
709
|
+
try:
|
|
710
|
+
home_directory = Path.home()
|
|
711
|
+
except RuntimeError:
|
|
712
|
+
return
|
|
713
|
+
try:
|
|
714
|
+
ledger_path = (
|
|
715
|
+
home_directory
|
|
716
|
+
/ CLAUDE_HOME_DIRECTORY_NAME
|
|
717
|
+
/ LOGS_DIRECTORY_NAME
|
|
718
|
+
/ VERIFIER_VERDICTS_JSONL_FILENAME
|
|
719
|
+
)
|
|
720
|
+
ledger_path.parent.mkdir(parents=True, exist_ok=True)
|
|
721
|
+
branch_name = _head_branch_name(repo_root)
|
|
722
|
+
ledger_row = {
|
|
723
|
+
LEDGER_KEY_TIMESTAMP: datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
|
724
|
+
LEDGER_KEY_REPO_ROOT: repo_root,
|
|
725
|
+
LEDGER_KEY_BRANCH: branch_name,
|
|
726
|
+
LEDGER_KEY_MANIFEST_HASH: bound_manifest_sha256,
|
|
727
|
+
LEDGER_KEY_ALL_PASS: is_all_pass,
|
|
728
|
+
LEDGER_KEY_FINDING_COUNT: len(all_findings),
|
|
729
|
+
LEDGER_KEY_FINDING_CHECK_NAMES: _finding_check_names(all_findings),
|
|
730
|
+
LEDGER_KEY_MINTED_FROM_AGENT_ID: minted_from_agent_id,
|
|
731
|
+
}
|
|
732
|
+
with ledger_path.open("a", encoding="utf-8") as ledger_file:
|
|
733
|
+
ledger_file.write(json.dumps(ledger_row) + "\n")
|
|
734
|
+
except OSError:
|
|
735
|
+
return
|
|
736
|
+
|
|
737
|
+
|
|
657
738
|
def write_verdict(
|
|
658
739
|
repo_root: str,
|
|
659
740
|
bound_manifest_sha256: str,
|
|
@@ -663,6 +744,9 @@ def write_verdict(
|
|
|
663
744
|
) -> Path:
|
|
664
745
|
"""Write a verdict file binding a verification outcome to a surface hash.
|
|
665
746
|
|
|
747
|
+
Also appends one durable JSONL ledger row under the Claude logs directory.
|
|
748
|
+
A ledger write failure never changes the mint outcome.
|
|
749
|
+
|
|
666
750
|
Args:
|
|
667
751
|
repo_root: The repository top-level directory.
|
|
668
752
|
bound_manifest_sha256: Hash of the surface manifest the verdict covers.
|
|
@@ -686,6 +770,13 @@ def write_verdict(
|
|
|
686
770
|
verdict_file.write_text(
|
|
687
771
|
json.dumps(verdict_record, indent=VERDICT_JSON_INDENT), encoding="utf-8"
|
|
688
772
|
)
|
|
773
|
+
_append_verdict_ledger_row(
|
|
774
|
+
repo_root,
|
|
775
|
+
bound_manifest_sha256,
|
|
776
|
+
is_all_pass,
|
|
777
|
+
all_findings,
|
|
778
|
+
minted_from_agent_id,
|
|
779
|
+
)
|
|
689
780
|
return verdict_file
|
|
690
781
|
|
|
691
782
|
|
package/package.json
CHANGED
package/scripts/CLAUDE.md
CHANGED
|
@@ -6,7 +6,8 @@ Utility scripts installed into `~/.claude/scripts/` by `bin/install.mjs`. Each s
|
|
|
6
6
|
|
|
7
7
|
| File | Purpose |
|
|
8
8
|
|---|---|
|
|
9
|
-
| `claude_chain_runner.py` | Runs a `claude` invocation through a
|
|
9
|
+
| `claude_chain_runner.py` | Runs a `claude` invocation through a usage-ranked fallback chain (`~/.claude/claude-chain.json`): probes weekly remaining once via `claude_chain_usage` / the usage-pause OAuth probe, tries the highest-remaining account first, and falls over to the next ranked binary only on a usage-limit failure; usable as an imported module (`run_claude`) or a CLI. Copy `claude-chain.example.json` to `~/.claude/claude-chain.json` and list your account binaries. Optional per-entry `credentials_path` names that account's OAuth credentials file for the usage probe |
|
|
10
|
+
| `claude_chain_usage.py` | Reports remaining weekly usage for every account in `~/.claude/claude-chain.json` via the usage-pause OAuth probe; prints JSON (`accounts` with `weekly_remaining_percent` or null plus `error`); importable `report_chain_weekly_usage` and `rank_accounts_by_weekly_remaining` (highest remaining first, ties keep config order, unmeasurable last). The chain runner consumes this ranking for try order |
|
|
10
11
|
| `gh_artifact_upload.py` | Uploads a file to a repo's durable `artifacts` prerelease under a timestamped asset name and prints the permanent download URL a GitHub post can link |
|
|
11
12
|
| `grok_headless_runner.py` | Runs one worker as headless `grok`: builds argv, mints a unique leader socket, captures streams, kills on timeout with grace, classifies ok/usage_limit/auth_failure/timeout/error; imported by `spawn_grok_batch.py` |
|
|
12
13
|
| `grok_worker_preflight.py` | Soft gate for the headless grok tier: binary on PATH, `grok models` auth, install manifest + role agents, opt-in cached live ping; non-zero exit is fallthrough, not failure |
|
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"chain": [
|
|
3
3
|
{ "command": "claude", "extra_args": [] },
|
|
4
|
-
{
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
{
|
|
5
|
+
"command": "claude-1",
|
|
6
|
+
"extra_args": [],
|
|
7
|
+
"credentials_path": "/path/to/account-1/.credentials.json"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"command": "claude-2",
|
|
11
|
+
"extra_args": [],
|
|
12
|
+
"credentials_path": "/path/to/account-2/.credentials.json"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"command": "claude-3",
|
|
16
|
+
"extra_args": [],
|
|
17
|
+
"credentials_path": "/path/to/account-3/.credentials.json"
|
|
18
|
+
}
|
|
7
19
|
]
|
|
8
20
|
}
|
|
@@ -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:
|