claude-dev-env 2.2.1 → 2.4.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 (170) hide show
  1. package/CLAUDE.md +3 -2
  2. package/_shared/advisor/advisor-protocol.md +2 -2
  3. package/_shared/pr-loop/scripts/CLAUDE.md +1 -0
  4. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -284
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
  6. package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +3 -3
  7. package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -0
  8. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +5 -0
  9. package/_shared/pr-loop/scripts/stale_worktree_rule_sweep.py +107 -0
  10. package/_shared/pr-loop/scripts/tests/CLAUDE.md +2 -0
  11. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +55 -73
  12. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +9 -15
  13. package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +91 -1
  14. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +88 -1
  15. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -0
  16. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -0
  17. package/_shared/pr-loop/worker-spawn.md +1 -1
  18. package/agents/CLAUDE.md +1 -0
  19. package/agents/code-verifier.md +4 -4
  20. package/agents/skill-writer-agent.md +84 -0
  21. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +141 -41
  22. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +29 -13
  23. package/docs/CLAUDE.md +1 -0
  24. package/docs/references/CLAUDE.md +1 -0
  25. package/docs/references/code-review-enforcement.md +97 -0
  26. package/docs/wsl-docker-cowork-starter-matrix.md +89 -0
  27. package/hooks/blocking/CLAUDE.md +8 -1
  28. package/hooks/blocking/code_review_enforcement_config_bootstrap.py +53 -0
  29. package/hooks/blocking/code_review_gate_deny.py +74 -0
  30. package/hooks/blocking/code_review_pr_create_gate.py +194 -0
  31. package/hooks/blocking/code_review_push_gate.py +140 -0
  32. package/hooks/blocking/code_review_stamp_directory_write_blocker.py +340 -0
  33. package/hooks/blocking/code_review_stamp_store.py +233 -0
  34. package/hooks/blocking/code_review_stamp_write_blocker_parts/__init__.py +7 -0
  35. package/hooks/blocking/code_review_stamp_write_blocker_parts/conftest.py +15 -0
  36. package/hooks/blocking/code_review_stamp_write_blocker_parts/obfuscated_stamp_path_reference.py +212 -0
  37. package/hooks/blocking/code_review_stamp_write_blocker_parts/split_directory_change_into_stamp.py +138 -0
  38. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_obfuscated_stamp_path_reference.py +49 -0
  39. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_split_directory_change_into_stamp.py +38 -0
  40. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +39 -27
  41. package/hooks/blocking/config/code_review_enforcement_constants.py +110 -0
  42. package/hooks/blocking/config/test_code_review_enforcement_constants.py +108 -0
  43. package/hooks/blocking/config/verified_commit_constants.py +24 -9
  44. package/hooks/blocking/conftest.py +2 -0
  45. package/hooks/blocking/convergence_gate_blocker.py +112 -23
  46. package/hooks/blocking/destructive_command_blocker.py +19 -6
  47. package/hooks/blocking/pr_description_proof_of_work.py +52 -34
  48. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
  49. package/hooks/blocking/test_code_review_enforcement_config_bootstrap.py +62 -0
  50. package/hooks/blocking/test_code_review_gate_deny.py +54 -0
  51. package/hooks/blocking/test_code_review_pr_create_gate.py +185 -0
  52. package/hooks/blocking/test_code_review_push_gate.py +189 -0
  53. package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +180 -0
  54. package/hooks/blocking/test_code_review_stamp_store.py +205 -0
  55. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +124 -2
  56. package/hooks/blocking/test_code_verifier_tools_contract.py +28 -0
  57. package/hooks/blocking/test_convergence_gate_blocker.py +153 -5
  58. package/hooks/blocking/test_destructive_command_blocker.py +1 -1
  59. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +45 -0
  60. package/hooks/blocking/test_pr_description_proof_of_work.py +151 -0
  61. package/hooks/blocking/test_pre_tool_use_dispatcher.py +8 -8
  62. package/hooks/blocking/test_verification_verdict_store.py +920 -810
  63. package/hooks/blocking/test_volatile_path_in_post_blocker.py +114 -2
  64. package/hooks/blocking/verification_verdict_store.py +118 -5
  65. package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +29 -17
  66. package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +35 -0
  67. package/hooks/blocking/volatile_path_in_post_blocker.py +69 -8
  68. package/hooks/git-hooks/git_hooks_constants/__init__.py +6 -0
  69. package/hooks/git-hooks/pre_push.py +89 -2
  70. package/hooks/git-hooks/test_pre_push.py +103 -0
  71. package/hooks/hooks.json +16 -1
  72. package/hooks/hooks_constants/CLAUDE.md +1 -0
  73. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +8 -0
  74. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
  75. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +26 -11
  76. package/hooks/hooks_constants/convergence_gate_blocker_constants.py +20 -3
  77. package/hooks/hooks_constants/destructive_command_segment_constants.py +3 -1
  78. package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +0 -4
  79. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  80. package/hooks/hooks_constants/pyproject_config_discovery_constants.py +16 -0
  81. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +24 -0
  82. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +6 -0
  83. package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +8 -1
  84. package/hooks/validators/CLAUDE.md +1 -0
  85. package/hooks/validators/mypy_integration.py +63 -50
  86. package/hooks/validators/pyproject_config_discovery.py +101 -0
  87. package/hooks/validators/ruff_integration.py +211 -23
  88. package/hooks/validators/run_all_validators.py +21 -14
  89. package/hooks/validators/test_mypy_integration.py +32 -0
  90. package/hooks/validators/test_pyproject_config_discovery.py +94 -0
  91. package/hooks/validators/test_ruff_integration.py +68 -1
  92. package/hooks/validators/test_run_all_validators.py +25 -0
  93. package/hooks/validators/test_run_all_validators_config_discovery.py +123 -0
  94. package/package.json +1 -1
  95. package/rules/docstring-prose-matches-implementation.md +20 -43
  96. package/rules/durable-post-artifacts.md +7 -0
  97. package/scripts/CLAUDE.md +2 -1
  98. package/scripts/claude-chain.example.json +15 -3
  99. package/scripts/claude_chain_runner.py +130 -27
  100. package/scripts/claude_chain_usage.py +346 -0
  101. package/scripts/codec_forwarding_test_support.py +83 -0
  102. package/scripts/conftest.py +8 -0
  103. package/scripts/dev_env_scripts_constants/CLAUDE.md +4 -3
  104. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +57 -2
  105. package/scripts/dev_env_scripts_constants/claude_chain_usage_constants.py +58 -0
  106. package/scripts/dev_env_scripts_constants/code_review_constants.py +129 -12
  107. package/scripts/dev_env_scripts_constants/test_code_review_constants.py +55 -0
  108. package/scripts/dev_env_scripts_constants/timing.py +1 -1
  109. package/scripts/invoke_code_review.py +550 -38
  110. package/scripts/resolve_worker_spawn.py +8 -1
  111. package/scripts/test_claude_chain_runner.py +412 -6
  112. package/scripts/test_claude_chain_usage.py +534 -0
  113. package/scripts/test_invoke_code_review.py +298 -4
  114. package/scripts/test_invoke_code_review_codec.py +77 -0
  115. package/scripts/test_resolve_worker_spawn_codec.py +101 -0
  116. package/skills/autoconverge/SKILL.md +9 -3
  117. package/skills/autoconverge/reference/convergence.md +2 -1
  118. package/skills/autoconverge/reference/multi-pr.md +6 -1
  119. package/skills/autoconverge/reference/stop-conditions.md +16 -10
  120. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +1 -1
  121. package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +175 -3
  122. package/skills/autoconverge/workflow/converge.contract.test.mjs +19 -0
  123. package/skills/autoconverge/workflow/converge.mjs +24 -8
  124. package/skills/autoconverge/workflow/converge_multi.mjs +7 -3
  125. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +5 -0
  126. package/skills/fresh-branch/CLAUDE.md +2 -0
  127. package/skills/fresh-branch/SKILL.md +2 -0
  128. package/skills/fresh-branch/scripts/create_fresh_branch.py +78 -180
  129. package/skills/fresh-branch/scripts/fresh_branch_git_commands.py +285 -0
  130. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -0
  131. package/skills/fresh-branch/scripts/pytest.ini +4 -0
  132. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +98 -0
  133. package/skills/fresh-branch/scripts/test_fresh_branch_git_commands.py +310 -0
  134. package/skills/orchestrator/SKILL.md +1 -20
  135. package/skills/orchestrator-refresh/SKILL.md +1 -1
  136. package/skills/pr-converge/SKILL.md +3 -3
  137. package/skills/pr-converge/reference/examples.md +3 -3
  138. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  139. package/skills/pr-converge/reference/ground-rules.md +3 -3
  140. package/skills/pr-converge/reference/per-tick.md +5 -5
  141. package/skills/pr-converge/reference/progress-checklist.md +1 -1
  142. package/skills/pr-converge/test_step5_host_branch.py +1 -1
  143. package/skills/prototype/SKILL.md +86 -0
  144. package/skills/prototype/reference/honest-limitations.md +23 -0
  145. package/skills/prototype/reference/promotion-tasks.md +23 -0
  146. package/skills/prototype/scripts/build_sandbox_settings.py +249 -0
  147. package/skills/prototype/scripts/conftest.py +15 -0
  148. package/skills/prototype/scripts/launch_sandbox.py +205 -0
  149. package/skills/prototype/scripts/probe_sandbox_safety.py +311 -0
  150. package/skills/prototype/scripts/prototype_scripts_constants/__init__.py +1 -0
  151. package/skills/prototype/scripts/prototype_scripts_constants/config/__init__.py +0 -0
  152. package/skills/prototype/scripts/prototype_scripts_constants/config/build_sandbox_settings_constants.py +41 -0
  153. package/skills/prototype/scripts/prototype_scripts_constants/config/launch_sandbox_constants.py +23 -0
  154. package/skills/prototype/scripts/prototype_scripts_constants/config/probe_sandbox_safety_constants.py +45 -0
  155. package/skills/prototype/scripts/prototype_scripts_constants/config/prototype_common_constants.py +10 -0
  156. package/skills/prototype/scripts/test_build_sandbox_settings.py +275 -0
  157. package/skills/prototype/scripts/test_launch_sandbox.py +303 -0
  158. package/skills/prototype/scripts/test_probe_sandbox_safety.py +284 -0
  159. package/skills/prototype/workflows/promotion.md +27 -0
  160. package/skills/prototype/workflows/sandbox.md +35 -0
  161. package/skills/skill-builder/CLAUDE.md +3 -3
  162. package/skills/skill-builder/SKILL.md +5 -5
  163. package/skills/skill-builder/references/CLAUDE.md +1 -1
  164. package/skills/skill-builder/references/delegation-map.md +3 -3
  165. package/skills/skill-builder/references/description-field.md +1 -1
  166. package/skills/skill-builder/references/skill-modularity.md +2 -3
  167. package/skills/skill-builder/workflows/CLAUDE.md +1 -1
  168. package/skills/skill-builder/workflows/improve-skill.md +1 -1
  169. package/skills/skill-builder/workflows/new-skill.md +2 -2
  170. package/skills/team-advisor/SKILL.md +5 -4
@@ -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:
@@ -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:]))
@@ -0,0 +1,83 @@
1
+ """Shared fixtures for text-codec keyword-forwarding behavioral tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from collections.abc import Sequence
7
+
8
+ import claude_chain_runner as chain_runner
9
+ import pytest
10
+ from claude_chain_runner import ChainAttempt, ChainInvocationOutcome
11
+
12
+ FIXTURE_ENCODING_KEYWORD_NAME = "encoding"
13
+ FIXTURE_ERRORS_KEYWORD_NAME = "errors"
14
+ FIXTURE_CHAIN_ENCODING = "utf-8"
15
+ FIXTURE_CHAIN_ERRORS = "replace"
16
+ FIXTURE_SERVED_COMMAND = "claude"
17
+
18
+
19
+ def build_served_outcome(*, chain_stdout: str) -> ChainInvocationOutcome:
20
+ return ChainInvocationOutcome(
21
+ served_command=FIXTURE_SERVED_COMMAND,
22
+ returncode=0,
23
+ stdout=chain_stdout,
24
+ stderr="",
25
+ attempts=(ChainAttempt(command=FIXTURE_SERVED_COMMAND, status="served"),),
26
+ )
27
+
28
+
29
+ def build_recording_runner(
30
+ all_observed_runner_keywords: dict[str, object],
31
+ *,
32
+ chain_stdout: str,
33
+ ) -> object:
34
+ def _recording_runner(
35
+ all_invocation_tokens: Sequence[str],
36
+ *all_positionals: object,
37
+ **all_keywords: object,
38
+ ) -> subprocess.CompletedProcess[str]:
39
+ del all_positionals
40
+ all_observed_runner_keywords.update(all_keywords)
41
+ return subprocess.CompletedProcess(
42
+ args=list(all_invocation_tokens),
43
+ returncode=0,
44
+ stdout=chain_stdout,
45
+ stderr="",
46
+ )
47
+
48
+ return _recording_runner
49
+
50
+
51
+ def install_codec_seams(
52
+ monkeypatch: pytest.MonkeyPatch,
53
+ *,
54
+ all_chain_codec_keywords: dict[str, str],
55
+ chain_stdout: str,
56
+ runner_host: object,
57
+ runner_attribute_name: str,
58
+ ) -> dict[str, object]:
59
+ all_observed_runner_keywords: dict[str, object] = {}
60
+
61
+ def _fake_claude(
62
+ all_claude_arguments: list[str], *, timeout_seconds: int
63
+ ) -> ChainInvocationOutcome:
64
+ chain_runner.chain_subprocess_runner(
65
+ [FIXTURE_SERVED_COMMAND, *all_claude_arguments],
66
+ capture_output=True,
67
+ text=True,
68
+ timeout=timeout_seconds,
69
+ check=False,
70
+ **all_chain_codec_keywords,
71
+ )
72
+ return build_served_outcome(chain_stdout=chain_stdout)
73
+
74
+ monkeypatch.setattr(
75
+ chain_runner,
76
+ "chain_subprocess_runner",
77
+ build_recording_runner(
78
+ all_observed_runner_keywords,
79
+ chain_stdout=chain_stdout,
80
+ ),
81
+ )
82
+ monkeypatch.setattr(runner_host, runner_attribute_name, _fake_claude)
83
+ return all_observed_runner_keywords