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.
Files changed (43) hide show
  1. package/_shared/advisor/advisor-protocol.md +2 -2
  2. package/_shared/pr-loop/scripts/CLAUDE.md +1 -0
  3. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -284
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +3 -3
  6. package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -0
  7. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +5 -0
  8. package/_shared/pr-loop/scripts/stale_worktree_rule_sweep.py +107 -0
  9. package/_shared/pr-loop/scripts/tests/CLAUDE.md +2 -0
  10. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +55 -73
  11. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +9 -15
  12. package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +91 -1
  13. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +88 -1
  14. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -0
  15. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -0
  16. package/_shared/pr-loop/worker-spawn.md +1 -1
  17. package/agents/code-verifier.md +1 -1
  18. package/hooks/blocking/config/verified_commit_constants.py +11 -0
  19. package/hooks/blocking/test_code_verifier_tools_contract.py +28 -0
  20. package/hooks/blocking/test_verification_verdict_store.py +93 -0
  21. package/hooks/blocking/verification_verdict_store.py +91 -0
  22. package/package.json +1 -1
  23. package/scripts/CLAUDE.md +2 -1
  24. package/scripts/claude-chain.example.json +15 -3
  25. package/scripts/claude_chain_runner.py +130 -27
  26. package/scripts/claude_chain_usage.py +346 -0
  27. package/scripts/dev_env_scripts_constants/CLAUDE.md +4 -3
  28. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +13 -1
  29. package/scripts/dev_env_scripts_constants/claude_chain_usage_constants.py +58 -0
  30. package/scripts/dev_env_scripts_constants/code_review_constants.py +1 -1
  31. package/scripts/dev_env_scripts_constants/timing.py +1 -1
  32. package/scripts/test_claude_chain_runner.py +412 -6
  33. package/scripts/test_claude_chain_usage.py +534 -0
  34. package/skills/orchestrator/SKILL.md +1 -20
  35. package/skills/orchestrator-refresh/SKILL.md +1 -1
  36. package/skills/pr-converge/SKILL.md +3 -3
  37. package/skills/pr-converge/reference/examples.md +3 -3
  38. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  39. package/skills/pr-converge/reference/ground-rules.md +3 -3
  40. package/skills/pr-converge/reference/per-tick.md +5 -5
  41. package/skills/pr-converge/reference/progress-checklist.md +1 -1
  42. package/skills/pr-converge/test_step5_host_branch.py +1 -1
  43. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "2.2.1",
3
+ "version": "2.3.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
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 config-driven fallback chain (`~/.claude/claude-chain.json`): the leading binary serves the call, and only a usage-limit failure falls over to the next logged-in binary; usable as an imported module (`run_claude`) or a CLI. Copy `claude-chain.example.json` to `~/.claude/claude-chain.json` and list your binaries in fallback order |
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
- { "command": "claude-1", "extra_args": [] },
5
- { "command": "claude-2", "extra_args": [] },
6
- { "command": "claude-3", "extra_args": [] }
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 config-driven fallback chain.
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 runs the leading binary in the chain. It falls over to the next
7
- binary only on a usage-limit failure. Every other outcome returns to the caller
8
- unchanged.
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 in fallback
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 primary falls over to the second binary::
19
+ A usage-limited first try falls over to the next ranked binary::
18
20
 
19
- primary claude -> exit 1, "usage limit reached" (falls over)
20
- fallback claude-ev -> exit 0 (served)
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 typing import TextIO
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, or the primary binary was absent. The
108
- ``attempts`` trail records every binary tried and how it resolved.
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
- return ChainEntry(command=command, extra_args=extra_args)
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 configured fallback chain.
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
- The leading binary serves the call. Only a usage-limit failure (a non-zero
303
- exit whose output carries a usage-limit signature) falls over to the next
304
- binary. A missing fallback binary is skipped and the walk continues. A
305
- timeout, a missing primary binary, or a non-zero exit without a usage-limit
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
- all_entries = load_chain(chain_config_path())
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 each_index, each_entry in enumerate(all_entries):
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: