claude-dev-env 8.43.5 → 8.44.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 (36) hide show
  1. package/_shared/advisor/advisor-protocol.md +2 -2
  2. package/_shared/advisor/reference/cli-chain.md +9 -12
  3. package/_shared/advisor/reference/lifecycle.md +1 -1
  4. package/_shared/advisor/reference/third-party-bind.md +3 -3
  5. package/bin/install.test.mjs +1 -1
  6. package/docs/second-claude-account.md +11 -13
  7. package/hooks/blocking/reply_length_gate.py +43 -11
  8. package/hooks/blocking/test_reply_length_gate.py +55 -0
  9. package/hooks/hooks.json +1 -1
  10. package/hooks/hooks_constants/reply_length_gate_constants.py +11 -0
  11. package/package.json +1 -1
  12. package/scripts/_code_review_test_support.py +29 -39
  13. package/scripts/claude_account_worker.py +46 -297
  14. package/scripts/claude_account_worker_process.py +18 -154
  15. package/scripts/claude_account_worker_report.py +8 -4
  16. package/scripts/claude_chain_usage.py +7 -271
  17. package/scripts/codec_forwarding_test_support.py +11 -65
  18. package/scripts/dev_env_scripts_constants/claude_account_worker_constants.py +6 -24
  19. package/scripts/invoke_code_review.py +34 -56
  20. package/scripts/resolve_worker_spawn.py +40 -72
  21. package/scripts/test_account_broker_guard.py +0 -4
  22. package/scripts/test_claude_account_worker.py +105 -350
  23. package/scripts/test_claude_chain_usage.py +50 -510
  24. package/scripts/test_dispatcher_profile_import.py +5 -9
  25. package/scripts/test_invoke_code_review.py +7 -6
  26. package/scripts/test_invoke_code_review_chain.py +26 -0
  27. package/scripts/test_invoke_code_review_cli.py +8 -8
  28. package/scripts/test_invoke_code_review_codec.py +3 -11
  29. package/scripts/test_invoke_code_review_contract.py +5 -1
  30. package/scripts/test_resolve_worker_spawn.py +118 -265
  31. package/scripts/test_resolve_worker_spawn_codec.py +9 -21
  32. package/scripts/tests/test_code_review_constants.py +2 -2
  33. package/scripts/claude_account_choice.py +0 -422
  34. package/scripts/claude_chain_runner.py +0 -1289
  35. package/scripts/test_claude_account_choice.py +0 -355
  36. package/scripts/test_claude_chain_runner.py +0 -1839
@@ -1,422 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Choose a Claude account for a runner job while protecting the main account.
3
-
4
- The main account takes a job only to spend usage that expires soon. Otherwise,
5
- the picker tries extra profiles in order and waits when every one is full.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- import argparse
11
- import json
12
- import sys
13
- from collections.abc import Sequence
14
- from dataclasses import dataclass
15
- from datetime import datetime
16
- from pathlib import Path
17
-
18
- from claude_account_profile import default_profile_home
19
- from claude_chain_usage import (
20
- AccountUsageMeters,
21
- WeeklyUtilizationProbeError,
22
- probe_account_meters,
23
- )
24
- from dev_env_scripts_constants.claude_account_constants import (
25
- CHOICE_MAIN,
26
- CHOICE_SECOND,
27
- CHOICE_WAIT,
28
- CREDENTIALS_FILE_NAME,
29
- FULL_PERCENT,
30
- JSON_ACCOUNT_KEY,
31
- JSON_CONFIG_DIRECTORY_KEY,
32
- JSON_METERS_KEY,
33
- JSON_REASON_KEY,
34
- JSON_SESSION_RESETS_AT_KEY,
35
- JSON_SESSION_USED_PERCENT_KEY,
36
- JSON_WEEKLY_RESETS_AT_KEY,
37
- JSON_WEEKLY_USED_PERCENT_KEY,
38
- MAIN_CLAUDE_HOME_DIRECTORY_NAME,
39
- MAIN_SESSION_USED_CEILING_PERCENT,
40
- MAIN_SPEND_WINDOW,
41
- MAIN_WEEKLY_USED_CEILING_PERCENT,
42
- REASON_MAIN_EXPIRING_TEMPLATE,
43
- REASON_ALL_EXTRAS_WAIT_TEMPLATE,
44
- REASON_EXTRA_HAS_ROOM_TEMPLATE,
45
- REASON_EXTRA_UNREAD_TEMPLATE,
46
- REASON_SECOND_HAS_ROOM_TEMPLATE,
47
- REASON_SECOND_UNREAD,
48
- REASON_WAIT_TEMPLATE,
49
- SECOND_SESSION_USED_CEILING_PERCENT,
50
- SECOND_WEEKLY_USED_CEILING_PERCENT,
51
- SECONDS_PER_HOUR,
52
- UNKNOWN_RESET_TEXT,
53
- )
54
-
55
-
56
- @dataclass(frozen=True)
57
- class AccountDecision:
58
- """The account a job runs on, and the plain-words reason for it."""
59
-
60
- account: str
61
- reason: str
62
-
63
-
64
- @dataclass(frozen=True)
65
- class ExtraAccount:
66
- """One ordered extra profile and its usage reading."""
67
-
68
- name: str
69
- meters: AccountUsageMeters | None
70
- config_dir: Path | None = None
71
-
72
-
73
- def _main_spendable_reason(
74
- main_meters: AccountUsageMeters | None, now: datetime
75
- ) -> str | None:
76
- if main_meters is None:
77
- return None
78
- session_used = main_meters.session_utilization
79
- weekly_used = main_meters.weekly_utilization
80
- weekly_resets_at = main_meters.weekly_resets_at
81
- if session_used is None or weekly_used is None or weekly_resets_at is None:
82
- return None
83
- time_until_reset = weekly_resets_at - now
84
- is_expiring_soon = time_until_reset <= MAIN_SPEND_WINDOW
85
- has_weekly_room = weekly_used < MAIN_WEEKLY_USED_CEILING_PERCENT
86
- has_session_room = session_used < MAIN_SESSION_USED_CEILING_PERCENT
87
- if not (is_expiring_soon and has_weekly_room and has_session_room):
88
- return None
89
- return REASON_MAIN_EXPIRING_TEMPLATE.format(
90
- hours_until_reset=int(time_until_reset.total_seconds() // SECONDS_PER_HOUR),
91
- remaining_percent=FULL_PERCENT - weekly_used,
92
- )
93
-
94
-
95
- def _blocking_reset(meters: AccountUsageMeters) -> datetime | None:
96
- all_blocking_resets: list[datetime] = []
97
- weekly_used = meters.weekly_utilization
98
- session_used = meters.session_utilization
99
- if weekly_used is not None and weekly_used >= SECOND_WEEKLY_USED_CEILING_PERCENT:
100
- if meters.weekly_resets_at is None:
101
- return None
102
- all_blocking_resets.append(meters.weekly_resets_at)
103
- if session_used is not None and session_used >= SECOND_SESSION_USED_CEILING_PERCENT:
104
- if meters.session_resets_at is None:
105
- return None
106
- all_blocking_resets.append(meters.session_resets_at)
107
- return max(all_blocking_resets, default=None)
108
-
109
-
110
- def _wait_decision(second_meters: AccountUsageMeters) -> AccountDecision:
111
- blocking_reset = _blocking_reset(second_meters)
112
- return AccountDecision(
113
- account=CHOICE_WAIT,
114
- reason=REASON_WAIT_TEMPLATE.format(
115
- weekly_used_percent=second_meters.weekly_utilization,
116
- session_used_percent=second_meters.session_utilization,
117
- next_reset=(
118
- blocking_reset.isoformat() if blocking_reset else UNKNOWN_RESET_TEXT
119
- ),
120
- ),
121
- )
122
-
123
-
124
- def _extra_account_decision(extra_account: ExtraAccount) -> AccountDecision | None:
125
- meters = extra_account.meters
126
- unread_reason = (
127
- REASON_SECOND_UNREAD
128
- if extra_account.name == CHOICE_SECOND
129
- else REASON_EXTRA_UNREAD_TEMPLATE.format(account=extra_account.name)
130
- )
131
- if meters is None:
132
- return AccountDecision(account=extra_account.name, reason=unread_reason)
133
- session_used = meters.session_utilization
134
- weekly_used = meters.weekly_utilization
135
- if session_used is None or weekly_used is None:
136
- return AccountDecision(account=extra_account.name, reason=unread_reason)
137
- is_week_blocked = weekly_used >= SECOND_WEEKLY_USED_CEILING_PERCENT
138
- is_session_blocked = session_used >= SECOND_SESSION_USED_CEILING_PERCENT
139
- if is_week_blocked or is_session_blocked:
140
- return None
141
- reason_template = (
142
- REASON_SECOND_HAS_ROOM_TEMPLATE
143
- if extra_account.name == CHOICE_SECOND
144
- else REASON_EXTRA_HAS_ROOM_TEMPLATE
145
- )
146
- return AccountDecision(
147
- account=extra_account.name,
148
- reason=reason_template.format(
149
- account=extra_account.name,
150
- weekly_remaining_percent=FULL_PERCENT - weekly_used,
151
- session_remaining_percent=FULL_PERCENT - session_used,
152
- ),
153
- )
154
-
155
-
156
- def choose_account_from_extras(
157
- *,
158
- main_meters: AccountUsageMeters | None,
159
- all_extra_accounts: Sequence[ExtraAccount],
160
- now: datetime,
161
- ) -> AccountDecision:
162
- """Choose main or the first extra profile with room in list order.
163
-
164
- Args:
165
- main_meters: The main account's meters, or None when unread.
166
- all_extra_accounts: The extra accounts in the order they are tried.
167
- now: The time the reset windows are measured from.
168
-
169
- Returns:
170
- The chosen account and the reason, or a wait naming the soonest reset.
171
- """
172
- main_reason = _main_spendable_reason(main_meters, now)
173
- if main_reason is not None:
174
- return AccountDecision(account=CHOICE_MAIN, reason=main_reason)
175
- for each_extra_account in all_extra_accounts:
176
- decision = _extra_account_decision(each_extra_account)
177
- if decision is not None:
178
- return decision
179
- return _all_extras_wait_decision(all_extra_accounts)
180
-
181
-
182
- def _all_extras_wait_decision(
183
- all_extra_accounts: Sequence[ExtraAccount],
184
- ) -> AccountDecision:
185
- if len(all_extra_accounts) == 1 and all_extra_accounts[0].meters is not None:
186
- return _wait_decision(all_extra_accounts[0].meters)
187
- all_resets = (
188
- _blocking_reset(each_extra_account.meters)
189
- for each_extra_account in all_extra_accounts
190
- if each_extra_account.meters is not None
191
- )
192
- next_reset = min((reset for reset in all_resets if reset is not None), default=None)
193
- return AccountDecision(
194
- account=CHOICE_WAIT,
195
- reason=REASON_ALL_EXTRAS_WAIT_TEMPLATE.format(
196
- next_reset=next_reset.isoformat() if next_reset else UNKNOWN_RESET_TEXT
197
- ),
198
- )
199
-
200
-
201
- def choose_account(
202
- *,
203
- main_meters: AccountUsageMeters | None,
204
- second_meters: AccountUsageMeters | None,
205
- now: datetime,
206
- ) -> AccountDecision:
207
- """Pick the account a job runs on from both accounts' meters.
208
-
209
- ::
210
-
211
- main: week resets in 5h, 80% used, 5-hour 10% used -> main
212
- main: week resets in 30h -> second, if it has room
213
- main unread, second at 99% of its week -> wait
214
- main unread, second unread -> second
215
-
216
- Main runs a job only to spend leftover usage that expires soon.
217
-
218
- Args:
219
- main_meters: The main account's meters, or None when unread.
220
- second_meters: The second account's meters, or None when unread.
221
- now: The time the reset windows are measured from.
222
-
223
- Returns:
224
- The chosen account and the reason.
225
- """
226
- return choose_account_from_extras(
227
- main_meters=main_meters,
228
- all_extra_accounts=(ExtraAccount(name=CHOICE_SECOND, meters=second_meters),),
229
- now=now,
230
- )
231
-
232
-
233
- def read_account_meters(credentials_path: Path) -> AccountUsageMeters | None:
234
- """Read one account's meters, or None when its credential or probe fails.
235
-
236
- Args:
237
- credentials_path: The account's CLI credential file.
238
-
239
- Returns:
240
- The meters, or None when they cannot be read.
241
- """
242
- try:
243
- return probe_account_meters(credentials_path)
244
- except WeeklyUtilizationProbeError:
245
- return None
246
-
247
-
248
- def read_extra_accounts(
249
- all_config_directories: Sequence[Path],
250
- ) -> tuple[ExtraAccount, ...]:
251
- """Read ordered extra profiles and reject duplicate directories.
252
-
253
- Args:
254
- all_config_directories: The extra profile homes in the order they are tried.
255
-
256
- Returns:
257
- One extra account per directory, with its meters.
258
-
259
- Raises:
260
- ValueError: When two entries name the same directory.
261
- """
262
- all_resolved_directories = [
263
- str(each_directory.resolve()).casefold()
264
- for each_directory in all_config_directories
265
- ]
266
- if len(set(all_resolved_directories)) != len(all_config_directories):
267
- raise ValueError("extra account config directories must be distinct")
268
- return tuple(
269
- ExtraAccount(
270
- name=CHOICE_SECOND if each_index == 1 else f"extra_{each_index}",
271
- meters=read_account_meters(each_directory / CREDENTIALS_FILE_NAME),
272
- config_dir=each_directory,
273
- )
274
- for each_index, each_directory in enumerate(all_config_directories, start=1)
275
- )
276
-
277
-
278
- def config_directory_for_decision(
279
- decision: AccountDecision,
280
- *,
281
- main_config_dir: Path,
282
- all_extra_accounts: Sequence[ExtraAccount],
283
- ) -> Path | None:
284
- """Find the selected account's config directory.
285
-
286
- Args:
287
- decision: The chosen account and reason.
288
- main_config_dir: The main account's Claude home.
289
- all_extra_accounts: The extra accounts the decision chose from.
290
-
291
- Returns:
292
- The chosen account's Claude home, or None on wait.
293
- """
294
- if decision.account == CHOICE_MAIN:
295
- return main_config_dir
296
- return next(
297
- (
298
- each_extra_account.config_dir
299
- for each_extra_account in all_extra_accounts
300
- if each_extra_account.name == decision.account
301
- ),
302
- None,
303
- )
304
-
305
-
306
- def _build_argument_parser() -> argparse.ArgumentParser:
307
- parser = argparse.ArgumentParser(
308
- description="Name the Claude account a runner job uses."
309
- )
310
- parser.add_argument(
311
- "--main-config-dir",
312
- type=Path,
313
- default=Path.home() / MAIN_CLAUDE_HOME_DIRECTORY_NAME,
314
- )
315
- parser.add_argument(
316
- "--second-config-dir",
317
- type=Path,
318
- default=default_profile_home(),
319
- )
320
- parser.add_argument("--extra-config-dir", type=Path, action="append", default=[])
321
- return parser
322
-
323
-
324
- def decision_payload(
325
- decision: AccountDecision, *, config_directory: Path | None
326
- ) -> dict[str, str | None]:
327
- """Shape a decision as the JSON object the runner job reads.
328
-
329
- ::
330
-
331
- AccountDecision("second", "...") with the profile directory
332
- -> {"account": "second", "config_dir": "<profile>", "reason": "..."}
333
-
334
- Args:
335
- decision: The chosen account and reason.
336
- config_directory: The Claude home the account runs under, or None on wait.
337
-
338
- Returns:
339
- The account, its Claude home, and the reason.
340
- """
341
- return {
342
- JSON_ACCOUNT_KEY: decision.account,
343
- JSON_CONFIG_DIRECTORY_KEY: str(config_directory) if config_directory else None,
344
- JSON_REASON_KEY: decision.reason,
345
- }
346
-
347
-
348
- def _iso_or_none(moment: datetime | None) -> str | None:
349
- return moment.isoformat() if moment else None
350
-
351
-
352
- def meters_payload(
353
- account_meters: AccountUsageMeters | None,
354
- ) -> dict[str, float | str | None] | None:
355
- """Shape one account's meters as the JSON object a usage report reads.
356
-
357
- ::
358
-
359
- AccountUsageMeters(12.0, <reset>, 34.0, <reset>)
360
- -> {"session_used_percent": 12.0, "session_resets_at": "2026-09-22T22:00:00+00:00",
361
- "weekly_used_percent": 34.0, "weekly_resets_at": "2026-09-25T21:00:00+00:00"}
362
-
363
- Args:
364
- account_meters: The account's meters, or None when unread.
365
-
366
- Returns:
367
- Each used percent and reset time, or None for an unread account.
368
- """
369
- if account_meters is None:
370
- return None
371
- return {
372
- JSON_SESSION_USED_PERCENT_KEY: account_meters.session_utilization,
373
- JSON_SESSION_RESETS_AT_KEY: _iso_or_none(account_meters.session_resets_at),
374
- JSON_WEEKLY_USED_PERCENT_KEY: account_meters.weekly_utilization,
375
- JSON_WEEKLY_RESETS_AT_KEY: _iso_or_none(account_meters.weekly_resets_at),
376
- }
377
-
378
-
379
- def main(all_command_arguments: list[str]) -> int:
380
- """Print the chosen account and every account's meters as JSON.
381
-
382
- Args:
383
- all_command_arguments: Command-line arguments after the program name.
384
-
385
- Returns:
386
- Zero once the choice is printed.
387
- """
388
- parser = _build_argument_parser()
389
- arguments = parser.parse_args(all_command_arguments)
390
- try:
391
- extra_accounts = read_extra_accounts(
392
- (arguments.second_config_dir, *arguments.extra_config_dir)
393
- )
394
- except ValueError as error:
395
- parser.error(str(error))
396
- main_meters = read_account_meters(arguments.main_config_dir / CREDENTIALS_FILE_NAME)
397
- decision = choose_account_from_extras(
398
- main_meters=main_meters,
399
- all_extra_accounts=extra_accounts,
400
- now=datetime.now().astimezone(),
401
- )
402
- config_directory = config_directory_for_decision(
403
- decision,
404
- main_config_dir=arguments.main_config_dir,
405
- all_extra_accounts=extra_accounts,
406
- )
407
- report = {
408
- **decision_payload(decision, config_directory=config_directory),
409
- JSON_METERS_KEY: {
410
- CHOICE_MAIN: meters_payload(main_meters),
411
- **{
412
- extra_account.name: meters_payload(extra_account.meters)
413
- for extra_account in extra_accounts
414
- },
415
- },
416
- }
417
- print(json.dumps(report))
418
- return 0
419
-
420
-
421
- if __name__ == "__main__":
422
- sys.exit(main(sys.argv[1:]))