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
@@ -50,6 +50,9 @@ from claude_chain_runner import ( # noqa: E402
50
50
  ChainInvocationOutcome,
51
51
  run_claude,
52
52
  )
53
+ from dev_env_scripts_constants.claude_chain_constants import ( # noqa: E402
54
+ collect_forwarded_text_codec,
55
+ )
53
56
  from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
54
57
  AGENT_FLAG,
55
58
  ALL_AGENT_FILENAMES_BY_ROLE,
@@ -177,7 +180,9 @@ def _run_claude_with_headless_overrides(
177
180
  ) -> ChainInvocationOutcome:
178
181
  working_directory_path = str(working_directory)
179
182
  with _HEADLESS_CHAIN_RUNNER_LOCK:
180
- previous_runner = chain_runner.chain_subprocess_runner
183
+ previous_runner: TextCapturingSubprocessRunner = (
184
+ chain_runner.chain_subprocess_runner
185
+ )
181
186
 
182
187
  def _runner_with_headless_overrides(
183
188
  all_invocation_tokens: Sequence[str],
@@ -186,6 +191,7 @@ def _run_claude_with_headless_overrides(
186
191
  ) -> subprocess.CompletedProcess[str]:
187
192
  del all_positionals
188
193
  prompt_stdin.seek(0)
194
+ forwarded_text_codec = collect_forwarded_text_codec(all_keywords)
189
195
  completed_process: subprocess.CompletedProcess[str] = previous_runner(
190
196
  all_invocation_tokens,
191
197
  capture_output=True,
@@ -194,6 +200,7 @@ def _run_claude_with_headless_overrides(
194
200
  check=False,
195
201
  stdin=prompt_stdin,
196
202
  cwd=working_directory_path,
203
+ **forwarded_text_codec,
197
204
  )
198
205
  return completed_process
199
206
 
@@ -14,6 +14,7 @@ if str(_SCRIPTS_DIR) not in sys.path:
14
14
  sys.path.insert(0, str(_SCRIPTS_DIR))
15
15
 
16
16
  import claude_chain_runner as runner # noqa: E402
17
+ import claude_chain_usage as chain_usage # noqa: E402
17
18
  from dev_env_scripts_constants.claude_chain_constants import ( # noqa: E402
18
19
  ALL_USAGE_LIMIT_SIGNATURES,
19
20
  ATTEMPT_STATUS_EXECUTABLE_NOT_FOUND,
@@ -51,6 +52,10 @@ from dev_env_scripts_constants.claude_chain_constants import ( # noqa: E402
51
52
 
52
53
  _A_SIGNATURE = ALL_USAGE_LIMIT_SIGNATURES[0]
53
54
  _PROMPT_ARGUMENTS = ["-p", "hello"]
55
+ _EQUAL_WEEKLY_REMAINING_PERCENT = 50.0
56
+ _HIGH_WEEKLY_REMAINING_PERCENT = 90.0
57
+ _MID_WEEKLY_REMAINING_PERCENT = 50.0
58
+ _LOW_WEEKLY_REMAINING_PERCENT = 10.0
54
59
 
55
60
 
56
61
  def _completed(command, returncode, stdout="", stderr=""):
@@ -74,6 +79,43 @@ def _write_chain_config(tmp_path, chain_entries):
74
79
  return config_file
75
80
 
76
81
 
82
+ def _config_order_usage_reporter(
83
+ *, config_path: Path
84
+ ) -> list[chain_usage.AccountUsageReport]:
85
+ all_entries = runner.load_chain(config_path)
86
+ return [
87
+ chain_usage.AccountUsageReport(
88
+ command=each_entry.command,
89
+ weekly_remaining_percent=_EQUAL_WEEKLY_REMAINING_PERCENT,
90
+ )
91
+ for each_entry in all_entries
92
+ ]
93
+
94
+
95
+ def _usage_reporter_from_remaining(
96
+ remaining_percent_by_command: dict[str, float | None],
97
+ ):
98
+ call_count = {"count": 0}
99
+
100
+ def active_reporter(
101
+ *, config_path: Path
102
+ ) -> list[chain_usage.AccountUsageReport]:
103
+ call_count["count"] += 1
104
+ all_entries = runner.load_chain(config_path)
105
+ return [
106
+ chain_usage.AccountUsageReport(
107
+ command=each_entry.command,
108
+ weekly_remaining_percent=remaining_percent_by_command[
109
+ each_entry.command
110
+ ],
111
+ )
112
+ for each_entry in all_entries
113
+ ]
114
+
115
+ active_reporter.call_count = call_count
116
+ return active_reporter
117
+
118
+
77
119
  class _Recorder:
78
120
  def __init__(self, behavior_by_command):
79
121
  self.behavior_by_command = behavior_by_command
@@ -100,14 +142,364 @@ def _install_tty_stdin(monkeypatch):
100
142
  monkeypatch.setattr(runner.sys, "stdin", _TtyStdin())
101
143
 
102
144
 
103
- def _install(monkeypatch, config_file, behavior_by_command):
145
+ def _install(
146
+ monkeypatch,
147
+ config_file,
148
+ behavior_by_command,
149
+ *,
150
+ weekly_usage_reporter=None,
151
+ ):
104
152
  recorder = _Recorder(behavior_by_command)
105
153
  monkeypatch.setattr(runner, "chain_config_path", lambda: config_file)
106
154
  monkeypatch.setattr(runner, "chain_subprocess_runner", recorder)
155
+ active_reporter = (
156
+ weekly_usage_reporter
157
+ if weekly_usage_reporter is not None
158
+ else _config_order_usage_reporter
159
+ )
160
+ monkeypatch.setattr(runner, "chain_weekly_usage_reporter", active_reporter)
107
161
  _install_tty_stdin(monkeypatch)
108
162
  return recorder
109
163
 
110
164
 
165
+ def test_highest_weekly_remaining_is_tried_first(
166
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
167
+ ) -> None:
168
+ config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-ev")])
169
+ usage_reporter = _usage_reporter_from_remaining(
170
+ {
171
+ "claude": _LOW_WEEKLY_REMAINING_PERCENT,
172
+ "claude-ev": _HIGH_WEEKLY_REMAINING_PERCENT,
173
+ }
174
+ )
175
+ recorder = _install(
176
+ monkeypatch,
177
+ config_file,
178
+ {
179
+ "claude": _completed("claude", 0, stdout="from-claude"),
180
+ "claude-ev": _completed("claude-ev", 0, stdout="from-ev"),
181
+ },
182
+ weekly_usage_reporter=usage_reporter,
183
+ )
184
+ chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
185
+ assert chain_result.served_command == "claude-ev"
186
+ assert chain_result.returncode == 0
187
+ assert chain_result.stdout == "from-ev"
188
+ assert recorder.invocations[0][0] == "claude-ev"
189
+ assert [each_attempt.command for each_attempt in chain_result.attempts] == [
190
+ "claude-ev"
191
+ ]
192
+ assert chain_result.attempts[0].status == ATTEMPT_STATUS_SERVED
193
+
194
+
195
+ def test_usage_limit_failsover_remaining_ranked_accounts(
196
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
197
+ ) -> None:
198
+ config_file = _write_chain_config(
199
+ tmp_path, [_entry("claude"), _entry("claude-ev"), _entry("claude-editor")]
200
+ )
201
+ usage_reporter = _usage_reporter_from_remaining(
202
+ {
203
+ "claude": _MID_WEEKLY_REMAINING_PERCENT,
204
+ "claude-ev": _HIGH_WEEKLY_REMAINING_PERCENT,
205
+ "claude-editor": _LOW_WEEKLY_REMAINING_PERCENT,
206
+ }
207
+ )
208
+ _install(
209
+ monkeypatch,
210
+ config_file,
211
+ {
212
+ "claude-ev": _completed("claude-ev", 1, stderr=_A_SIGNATURE),
213
+ "claude": _completed("claude", 0, stdout="ok"),
214
+ "claude-editor": _completed("claude-editor", 0, stdout="should not run"),
215
+ },
216
+ weekly_usage_reporter=usage_reporter,
217
+ )
218
+ chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
219
+ assert chain_result.served_command == "claude"
220
+ assert chain_result.returncode == 0
221
+ assert [each_attempt.command for each_attempt in chain_result.attempts] == [
222
+ "claude-ev",
223
+ "claude",
224
+ ]
225
+ assert chain_result.attempts[0].status == ATTEMPT_STATUS_USAGE_LIMITED
226
+ assert chain_result.attempts[1].status == ATTEMPT_STATUS_SERVED
227
+
228
+
229
+ def test_non_usage_error_on_highest_ranked_stops_without_rest(
230
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
231
+ ) -> None:
232
+ config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-ev")])
233
+ usage_reporter = _usage_reporter_from_remaining(
234
+ {
235
+ "claude": _LOW_WEEKLY_REMAINING_PERCENT,
236
+ "claude-ev": _HIGH_WEEKLY_REMAINING_PERCENT,
237
+ }
238
+ )
239
+ _install(
240
+ monkeypatch,
241
+ config_file,
242
+ {
243
+ "claude-ev": _completed("claude-ev", 2, stderr="unknown flag"),
244
+ "claude": _completed("claude", 0, stdout="should not run"),
245
+ },
246
+ weekly_usage_reporter=usage_reporter,
247
+ )
248
+ chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
249
+ assert chain_result.served_command == "claude-ev"
250
+ assert chain_result.returncode == 2
251
+ assert len(chain_result.attempts) == 1
252
+ assert chain_result.attempts[0].status == ATTEMPT_STATUS_NONZERO_EXIT
253
+
254
+
255
+ def test_weekly_usage_probe_runs_once_per_run_claude(
256
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
257
+ ) -> None:
258
+ config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-ev")])
259
+ usage_reporter = _usage_reporter_from_remaining(
260
+ {
261
+ "claude": _HIGH_WEEKLY_REMAINING_PERCENT,
262
+ "claude-ev": _LOW_WEEKLY_REMAINING_PERCENT,
263
+ }
264
+ )
265
+ _install(
266
+ monkeypatch,
267
+ config_file,
268
+ {
269
+ "claude": _completed("claude", 1, stderr=_A_SIGNATURE),
270
+ "claude-ev": _completed("claude-ev", 0, stdout="ok"),
271
+ },
272
+ weekly_usage_reporter=usage_reporter,
273
+ )
274
+ runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
275
+ assert usage_reporter.call_count["count"] == 1
276
+
277
+
278
+ def test_usage_reporter_import_failure_falls_back_to_config_order(
279
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
280
+ ) -> None:
281
+ config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-ev")])
282
+
283
+ def broken_usage_reporter(
284
+ *, config_path: Path
285
+ ) -> list[chain_usage.AccountUsageReport]:
286
+ raise ImportError("claude_chain_usage unavailable")
287
+
288
+ recorder = _install(
289
+ monkeypatch,
290
+ config_file,
291
+ {
292
+ "claude": _completed("claude", 0, stdout="from-claude"),
293
+ "claude-ev": _completed("claude-ev", 0, stdout="from-ev"),
294
+ },
295
+ weekly_usage_reporter=broken_usage_reporter,
296
+ )
297
+ chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
298
+ assert chain_result.served_command == "claude"
299
+ assert chain_result.returncode == 0
300
+ assert chain_result.stdout == "from-claude"
301
+ assert recorder.invocations[0][0] == "claude"
302
+ assert [each_attempt.command for each_attempt in chain_result.attempts] == [
303
+ "claude"
304
+ ]
305
+
306
+
307
+ def test_missing_high_remaining_binary_falls_through_to_lower_remaining(
308
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
309
+ ) -> None:
310
+ config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-ev")])
311
+ usage_reporter = _usage_reporter_from_remaining(
312
+ {
313
+ "claude": _LOW_WEEKLY_REMAINING_PERCENT,
314
+ "claude-ev": _HIGH_WEEKLY_REMAINING_PERCENT,
315
+ }
316
+ )
317
+ _install(
318
+ monkeypatch,
319
+ config_file,
320
+ {
321
+ "claude-ev": FileNotFoundError(),
322
+ "claude": _completed("claude", 0, stdout="from-claude"),
323
+ },
324
+ weekly_usage_reporter=usage_reporter,
325
+ )
326
+ chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
327
+ assert chain_result.served_command == "claude"
328
+ assert chain_result.returncode == 0
329
+ assert chain_result.stdout == "from-claude"
330
+ assert [each_attempt.command for each_attempt in chain_result.attempts] == [
331
+ "claude-ev",
332
+ "claude",
333
+ ]
334
+ assert [each_attempt.status for each_attempt in chain_result.attempts] == [
335
+ ATTEMPT_STATUS_EXECUTABLE_NOT_FOUND,
336
+ ATTEMPT_STATUS_SERVED,
337
+ ]
338
+
339
+
340
+ def test_all_missing_binaries_exhausts_chain(
341
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
342
+ ) -> None:
343
+ config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-ev")])
344
+ usage_reporter = _usage_reporter_from_remaining(
345
+ {
346
+ "claude": _LOW_WEEKLY_REMAINING_PERCENT,
347
+ "claude-ev": _HIGH_WEEKLY_REMAINING_PERCENT,
348
+ }
349
+ )
350
+ _install(
351
+ monkeypatch,
352
+ config_file,
353
+ {
354
+ "claude-ev": FileNotFoundError(),
355
+ "claude": FileNotFoundError(),
356
+ },
357
+ weekly_usage_reporter=usage_reporter,
358
+ )
359
+ chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
360
+ assert chain_result.served_command is None
361
+ assert chain_result.returncode == NO_COMPLETED_PROCESS_RETURN_CODE
362
+ assert [each_attempt.command for each_attempt in chain_result.attempts] == [
363
+ "claude-ev",
364
+ "claude",
365
+ ]
366
+ assert [each_attempt.status for each_attempt in chain_result.attempts] == [
367
+ ATTEMPT_STATUS_EXECUTABLE_NOT_FOUND,
368
+ ATTEMPT_STATUS_EXECUTABLE_NOT_FOUND,
369
+ ]
370
+
371
+
372
+ def test_missing_later_ranked_binary_is_skipped(
373
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
374
+ ) -> None:
375
+ config_file = _write_chain_config(
376
+ tmp_path, [_entry("claude"), _entry("claude-ev"), _entry("claude-editor")]
377
+ )
378
+ usage_reporter = _usage_reporter_from_remaining(
379
+ {
380
+ "claude": _MID_WEEKLY_REMAINING_PERCENT,
381
+ "claude-ev": _HIGH_WEEKLY_REMAINING_PERCENT,
382
+ "claude-editor": _LOW_WEEKLY_REMAINING_PERCENT,
383
+ }
384
+ )
385
+ _install(
386
+ monkeypatch,
387
+ config_file,
388
+ {
389
+ "claude-ev": _completed("claude-ev", 1, stderr=_A_SIGNATURE),
390
+ "claude": FileNotFoundError(),
391
+ "claude-editor": _completed("claude-editor", 0, stdout="done"),
392
+ },
393
+ weekly_usage_reporter=usage_reporter,
394
+ )
395
+ chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
396
+ assert chain_result.served_command == "claude-editor"
397
+ assert [each_attempt.command for each_attempt in chain_result.attempts] == [
398
+ "claude-ev",
399
+ "claude",
400
+ "claude-editor",
401
+ ]
402
+ assert [each_attempt.status for each_attempt in chain_result.attempts] == [
403
+ ATTEMPT_STATUS_USAGE_LIMITED,
404
+ ATTEMPT_STATUS_EXECUTABLE_NOT_FOUND,
405
+ ATTEMPT_STATUS_SERVED,
406
+ ]
407
+
408
+
409
+ def test_ranked_walk_preserves_extra_args_for_mapped_entry(
410
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
411
+ ) -> None:
412
+ config_file = _write_chain_config(
413
+ tmp_path,
414
+ [
415
+ _entry("claude", extra_args=["--account", "primary"]),
416
+ _entry("claude-ev", extra_args=["--account", "ev"]),
417
+ ],
418
+ )
419
+ usage_reporter = _usage_reporter_from_remaining(
420
+ {
421
+ "claude": _LOW_WEEKLY_REMAINING_PERCENT,
422
+ "claude-ev": _HIGH_WEEKLY_REMAINING_PERCENT,
423
+ }
424
+ )
425
+ recorder = _install(
426
+ monkeypatch,
427
+ config_file,
428
+ {"claude-ev": _completed("claude-ev", 0)},
429
+ weekly_usage_reporter=usage_reporter,
430
+ )
431
+ runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
432
+ assert recorder.invocations[0] == [
433
+ "claude-ev",
434
+ "-p",
435
+ "hello",
436
+ "--account",
437
+ "ev",
438
+ ]
439
+
440
+
441
+ def test_duplicate_command_entries_are_both_walked(
442
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
443
+ ) -> None:
444
+ config_file = _write_chain_config(
445
+ tmp_path,
446
+ [
447
+ _entry("claude", extra_args=["--account", "primary"]),
448
+ _entry("claude", extra_args=["--account", "secondary"]),
449
+ ],
450
+ )
451
+ usage_reporter = _usage_reporter_from_remaining(
452
+ {"claude": _HIGH_WEEKLY_REMAINING_PERCENT}
453
+ )
454
+ recorder = _install(
455
+ monkeypatch,
456
+ config_file,
457
+ {"claude": _completed("claude", 1, stderr=_A_SIGNATURE)},
458
+ weekly_usage_reporter=usage_reporter,
459
+ )
460
+ chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
461
+ assert [each_invocation[-1] for each_invocation in recorder.invocations] == [
462
+ "primary",
463
+ "secondary",
464
+ ]
465
+ assert [each_attempt.status for each_attempt in chain_result.attempts] == [
466
+ ATTEMPT_STATUS_USAGE_LIMITED,
467
+ ATTEMPT_STATUS_USAGE_LIMITED,
468
+ ]
469
+
470
+
471
+ def test_entry_absent_from_usage_report_is_still_walked(
472
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
473
+ ) -> None:
474
+ config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-ev")])
475
+
476
+ def partial_usage_reporter(
477
+ *, config_path: Path
478
+ ) -> list[chain_usage.AccountUsageReport]:
479
+ return [
480
+ chain_usage.AccountUsageReport(
481
+ command="claude-ev",
482
+ weekly_remaining_percent=_HIGH_WEEKLY_REMAINING_PERCENT,
483
+ )
484
+ ]
485
+
486
+ _install(
487
+ monkeypatch,
488
+ config_file,
489
+ {
490
+ "claude-ev": _completed("claude-ev", 1, stderr=_A_SIGNATURE),
491
+ "claude": _completed("claude", 0, stdout="ok"),
492
+ },
493
+ weekly_usage_reporter=partial_usage_reporter,
494
+ )
495
+ chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
496
+ assert chain_result.served_command == "claude"
497
+ assert [each_attempt.command for each_attempt in chain_result.attempts] == [
498
+ "claude-ev",
499
+ "claude",
500
+ ]
501
+
502
+
111
503
  def test_usage_limited_primary_falls_over_to_second(
112
504
  monkeypatch: pytest.MonkeyPatch, tmp_path: Path
113
505
  ) -> None:
@@ -170,7 +562,7 @@ def test_timeout_on_primary_does_not_fall_over(
170
562
  assert chain_result.attempts[0].status == ATTEMPT_STATUS_TIMEOUT
171
563
 
172
564
 
173
- def test_missing_primary_binary_does_not_fall_over(
565
+ def test_missing_primary_binary_falls_through_to_next(
174
566
  monkeypatch: pytest.MonkeyPatch, tmp_path: Path
175
567
  ) -> None:
176
568
  config_file = _write_chain_config(tmp_path, [_entry("claude"), _entry("claude-ev")])
@@ -179,13 +571,21 @@ def test_missing_primary_binary_does_not_fall_over(
179
571
  config_file,
180
572
  {
181
573
  "claude": FileNotFoundError(),
182
- "claude-ev": _completed("claude-ev", 0),
574
+ "claude-ev": _completed("claude-ev", 0, stdout="from-ev"),
183
575
  },
184
576
  )
185
577
  chain_result = runner.run_claude(_PROMPT_ARGUMENTS, timeout_seconds=5)
186
- assert chain_result.served_command is None
187
- assert len(chain_result.attempts) == 1
188
- assert chain_result.attempts[0].status == ATTEMPT_STATUS_EXECUTABLE_NOT_FOUND
578
+ assert chain_result.served_command == "claude-ev"
579
+ assert chain_result.returncode == 0
580
+ assert chain_result.stdout == "from-ev"
581
+ assert [each_attempt.command for each_attempt in chain_result.attempts] == [
582
+ "claude",
583
+ "claude-ev",
584
+ ]
585
+ assert [each_attempt.status for each_attempt in chain_result.attempts] == [
586
+ ATTEMPT_STATUS_EXECUTABLE_NOT_FOUND,
587
+ ATTEMPT_STATUS_SERVED,
588
+ ]
189
589
 
190
590
 
191
591
  def test_missing_fallback_binary_is_skipped_and_walk_continues(
@@ -530,6 +930,9 @@ def test_real_subprocess_capture_replaces_undecodable_bytes(
530
930
  ) -> None:
531
931
  config_file = _write_chain_config(tmp_path, [_entry(sys.executable)])
532
932
  monkeypatch.setattr(runner, "chain_config_path", lambda: config_file)
933
+ monkeypatch.setattr(
934
+ runner, "chain_weekly_usage_reporter", _config_order_usage_reporter
935
+ )
533
936
  child_code = 'import sys; sys.stdout.buffer.write(b"ok \\x90 end")'
534
937
  chain_result = runner.run_claude(["-c", child_code], timeout_seconds=60)
535
938
  assert chain_result.served_command == sys.executable
@@ -542,6 +945,9 @@ def test_real_subprocess_capture_preserves_utf8_text(
542
945
  ) -> None:
543
946
  config_file = _write_chain_config(tmp_path, [_entry(sys.executable)])
544
947
  monkeypatch.setattr(runner, "chain_config_path", lambda: config_file)
948
+ monkeypatch.setattr(
949
+ runner, "chain_weekly_usage_reporter", _config_order_usage_reporter
950
+ )
545
951
  child_code = 'import sys; sys.stdout.buffer.write("report ✅ done".encode("utf-8"))'
546
952
  chain_result = runner.run_claude(["-c", child_code], timeout_seconds=60)
547
953
  assert chain_result.served_command == sys.executable