claude-dev-env 8.11.1 → 8.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/e-code-review/SKILL.md +24 -71
- package/.agents/skills/e-code-review/reference/fix.md +7 -78
- package/.agents/skills/e-code-review/reference/high.md +129 -0
- package/.agents/skills/e-code-review/reference/low.md +20 -51
- package/.agents/skills/e-code-review/reference/max.md +169 -0
- package/.agents/skills/e-code-review/reference/medium.md +7 -191
- package/.agents/skills/e-code-review/reference/xhigh.md +47 -91
- package/.agents/skills/pull-request/scripts/test_pull_request.py +1 -1
- package/.agents/skills-archived/pr-converge/scripts/test_check_convergence.py +4 -4
- package/_shared/advisor/reference/astra-rung.md +7 -7
- package/_shared/advisor/reference/third-party-bind.md +2 -2
- package/_shared/advisor/scripts/codex_astra_advisor.py +33 -26
- package/_shared/advisor/scripts/codex_astra_preflight.py +57 -55
- package/_shared/advisor/scripts/config/advisor_scripts_constants/astra_advisor_constants.py +8 -8
- package/_shared/advisor/scripts/tests/test_codex_astra_advisor.py +69 -23
- package/_shared/advisor/scripts/tests/test_codex_astra_preflight.py +4 -4
- package/_shared/pr-loop/scripts/check_convergence.py +54 -49
- package/_shared/pr-loop/scripts/codex_review_scripts_constants/run_constants.py +4 -0
- package/_shared/pr-loop/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +10 -1
- package/_shared/pr-loop/scripts/run_codex_review.py +4 -6
- package/_shared/pr-loop/scripts/test_check_convergence.py +6 -6
- package/_shared/pr-loop/scripts/test_check_convergence_codex.py +73 -31
- package/_shared/pr-loop/scripts/test_check_convergence_contract.py +2 -2
- package/_shared/pr-loop/scripts/tests/test__claude_permissions_common.py +2 -2
- package/bin/git_hooks_installer.test.mjs +1 -1
- package/bin/install-constants.mjs +1 -6
- package/bin/install.agents-home.test.mjs +9 -9
- package/bin/install.cursor-rules.test.mjs +24 -51
- package/bin/install.mjs +16 -70
- package/commands/sr-loop.md +3 -1
- package/docs/codex-accounts.md +79 -0
- package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +5 -5
- package/hooks/blocking/test_pii_scanner.py +1 -1
- package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +2 -2
- package/hooks/git-hooks/conftest.py +16 -0
- package/hooks/git-hooks/git_hooks_constants/verification_notice_constants.py +1 -1
- package/hooks/git-hooks/test_post_commit.py +1 -1
- package/hooks/git-hooks/test_pre_commit.py +1 -1
- package/hooks/git-hooks/test_pre_push.py +2 -2
- package/hooks/git-hooks/test_verification_start.py +1 -1
- package/hooks/git-hooks/verification_notice.py +2 -2
- package/hooks/git-hooks/verification_notice_context.py +21 -2
- package/hooks/git-hooks/verification_start.py +2 -3
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +0 -1
- package/package.json +1 -1
- package/rules/durable-post-artifacts.md +4 -3
- package/scripts/claude_account_profile.py +21 -9
- package/scripts/codex_account_choice.py +396 -0
- package/scripts/codex_account_meters.py +372 -0
- package/scripts/dev_env_scripts_constants/codex_account_constants.py +141 -0
- package/scripts/dev_env_scripts_constants/private_term_constants.py +17 -2
- package/scripts/private_term_scan.py +6 -2
- package/scripts/repository_checks/config/constants.py +1 -0
- package/scripts/repository_checks/tracked_private_terms.py +7 -0
- package/scripts/test_claude_account_profile.py +12 -0
- package/scripts/test_codex_account_choice.py +276 -0
- package/scripts/test_codex_account_meters.py +205 -0
- package/scripts/tests/test_automatic_advisory.py +3 -3
- package/scripts/tests/test_automatic_advisory_polling.py +1 -1
- package/scripts/tests/test_automatic_advisory_recovery.py +1 -1
- package/scripts/tests/test_closed_pr_label.py +1 -1
- package/scripts/tests/test_private_term_scan.py +28 -0
- package/scripts/tests/test_review_closure.py +2 -2
- package/scripts/tests/test_review_closure_model.py +2 -2
- package/scripts/tests/test_rules.py +2 -83
- package/scripts/tests/test_tracked_private_terms.py +15 -0
- package/scripts/tests/test_verification_notice.py +29 -13
- package/.agents/skills/cert-classification-rule/SKILL.md +0 -224
- package/.agents/skills/cert-classification-rule/scripts/resort_cert_folders.py +0 -158
- package/.agents/skills/e-code-review/preflight-proposal.contract.test.mjs +0 -20
- package/.agents/skills/e-code-review/reference/effort-evaluation.md +0 -35
- package/.agents/skills/e-code-review/reference/loop.md +0 -261
- package/.agents/skills/e-code-review/reference/mode-contract.test.mjs +0 -67
- package/.agents/skills/e-code-review/reference/preflight-proposal.md +0 -75
- package/.agents/skills/e-code-review/reference/runner-selection.md +0 -40
- package/.agents/skills/e-code-review/scripts/config/e_code_review_effort_constants/__init__.py +0 -41
- package/.agents/skills/e-code-review/scripts/config/e_code_review_effort_constants/effort_constants.py +0 -40
- package/.agents/skills/e-code-review/scripts/e_code_review_scripts_constants/__init__.py +0 -1
- package/.agents/skills/e-code-review/scripts/e_code_review_scripts_constants/finding_pipeline_constants.py +0 -49
- package/.agents/skills/e-code-review/scripts/e_code_review_scripts_constants/grok_code_review_constants.py +0 -55
- package/.agents/skills/e-code-review/scripts/effort_defaults_evidence.json +0 -186
- package/.agents/skills/e-code-review/scripts/effort_evaluation.py +0 -362
- package/.agents/skills/e-code-review/scripts/finding_pipeline.py +0 -140
- package/.agents/skills/e-code-review/scripts/fixtures/demanding.json +0 -26
- package/.agents/skills/e-code-review/scripts/fixtures/easy.json +0 -14
- package/.agents/skills/e-code-review/scripts/fixtures/medium.json +0 -20
- package/.agents/skills/e-code-review/scripts/grok_code_review.py +0 -230
- package/.agents/skills/e-code-review/scripts/test_effort_evaluation.py +0 -180
- package/.agents/skills/e-code-review/scripts/test_finding_pipeline.py +0 -197
- package/.agents/skills/e-code-review/scripts/test_grok_code_review.py +0 -289
- package/_shared/pr-loop/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +0 -82
- package/_shared/pr-loop/scripts/codex_usage_probe.py +0 -573
- package/_shared/pr-loop/scripts/test_codex_usage_probe.py +0 -683
- package/scripts/sync_to_cursor/AGENTS.md +0 -1
- package/scripts/sync_to_cursor/CLAUDE.md +0 -1
- package/scripts/sync_to_cursor/__init__.py +0 -13
- package/scripts/sync_to_cursor/canonical_docs.py +0 -66
- package/scripts/sync_to_cursor/config/__init__.py +0 -8
- package/scripts/sync_to_cursor/engine.py +0 -219
- package/scripts/sync_to_cursor/hashing.py +0 -7
- package/scripts/sync_to_cursor/paths.py +0 -18
- package/scripts/sync_to_cursor/rules.py +0 -494
- package/scripts/sync_to_cursor.py +0 -22
- package/scripts/tests/test_engine.py +0 -101
- package/scripts/tests/test_sync_to_cursor.py +0 -315
|
@@ -31,6 +31,7 @@ import json
|
|
|
31
31
|
import os
|
|
32
32
|
import subprocess
|
|
33
33
|
import sys
|
|
34
|
+
from collections.abc import Callable
|
|
34
35
|
from dataclasses import dataclass
|
|
35
36
|
from datetime import datetime, timezone
|
|
36
37
|
from pathlib import Path
|
|
@@ -214,11 +215,11 @@ def _unlink_orphaned_links(main_home: Path, profile_home: Path) -> tuple[str, ..
|
|
|
214
215
|
|
|
215
216
|
|
|
216
217
|
def _unlink_local_names_linked_to_main(
|
|
217
|
-
main_home: Path, profile_home: Path
|
|
218
|
+
main_home: Path, profile_home: Path, is_local: Callable[[str], bool]
|
|
218
219
|
) -> tuple[str, ...]:
|
|
219
220
|
all_unlinked: list[str] = []
|
|
220
221
|
for each_entry in sorted(profile_home.iterdir()):
|
|
221
|
-
if not
|
|
222
|
+
if not is_local(each_entry.name):
|
|
222
223
|
continue
|
|
223
224
|
if not links_to(each_entry, main_home / each_entry.name):
|
|
224
225
|
continue
|
|
@@ -237,13 +238,16 @@ def _clear_entry(entry_path: Path, source_path: Path, replaced_directory: Path)
|
|
|
237
238
|
|
|
238
239
|
|
|
239
240
|
def _link_shared_entries(
|
|
240
|
-
main_home: Path,
|
|
241
|
+
main_home: Path,
|
|
242
|
+
profile_home: Path,
|
|
243
|
+
replaced_directory: Path,
|
|
244
|
+
is_local: Callable[[str], bool],
|
|
241
245
|
) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
|
242
246
|
all_linked: list[str] = []
|
|
243
247
|
all_moved_aside: list[str] = []
|
|
244
248
|
for each_source in sorted(main_home.iterdir()):
|
|
245
249
|
entry_path = profile_home / each_source.name
|
|
246
|
-
if
|
|
250
|
+
if is_local(each_source.name) or links_to(entry_path, each_source):
|
|
247
251
|
continue
|
|
248
252
|
if os.path.lexists(entry_path) and _clear_entry(
|
|
249
253
|
entry_path, each_source, replaced_directory
|
|
@@ -255,14 +259,19 @@ def _link_shared_entries(
|
|
|
255
259
|
|
|
256
260
|
|
|
257
261
|
def sync_profile(
|
|
258
|
-
*,
|
|
262
|
+
*,
|
|
263
|
+
main_home: Path,
|
|
264
|
+
profile_home: Path,
|
|
265
|
+
now: datetime,
|
|
266
|
+
is_local: Callable[[str], bool] = is_account_local,
|
|
259
267
|
) -> ProfileSyncReport:
|
|
260
268
|
"""Link every shared main-home entry into the profile home.
|
|
261
269
|
|
|
262
270
|
Args:
|
|
263
|
-
main_home: The main account's
|
|
264
|
-
profile_home: The
|
|
271
|
+
main_home: The main account's home.
|
|
272
|
+
profile_home: The profile account's home.
|
|
265
273
|
now: The run time that names the folder stale entries move into.
|
|
274
|
+
is_local: Tells whether a top-level entry belongs to one account only.
|
|
266
275
|
|
|
267
276
|
Returns:
|
|
268
277
|
The entries the run linked, moved aside, and unlinked.
|
|
@@ -271,11 +280,14 @@ def sync_profile(
|
|
|
271
280
|
all_unlinked = tuple(
|
|
272
281
|
sorted(
|
|
273
282
|
_unlink_orphaned_links(main_home, profile_home)
|
|
274
|
-
+ _unlink_local_names_linked_to_main(main_home, profile_home)
|
|
283
|
+
+ _unlink_local_names_linked_to_main(main_home, profile_home, is_local)
|
|
275
284
|
)
|
|
276
285
|
)
|
|
277
286
|
all_linked, all_moved_aside = _link_shared_entries(
|
|
278
|
-
main_home,
|
|
287
|
+
main_home,
|
|
288
|
+
profile_home,
|
|
289
|
+
profile_home / REPLACED_DIRECTORY_NAME / _stamp(now),
|
|
290
|
+
is_local,
|
|
279
291
|
)
|
|
280
292
|
return ProfileSyncReport(
|
|
281
293
|
all_linked=all_linked,
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Name the Codex account a runner job uses, and keep each account's home ready.
|
|
3
|
+
|
|
4
|
+
Up to four Codex accounts sign in, each under its own Codex home in
|
|
5
|
+
``~/.codex-profiles/<name>``. Jobs try them in one fixed order::
|
|
6
|
+
|
|
7
|
+
first account with more than 10% left -> normal, that account
|
|
8
|
+
none over 10%, the roomiest one over 1% -> luna, stop at 1% left
|
|
9
|
+
... but an account with a 5-hour window needs 20% of it left for luna
|
|
10
|
+
none over 1%, or no meter reads -> wait, name the first reset
|
|
11
|
+
account not signed in, or its meter unread -> skipped
|
|
12
|
+
|
|
13
|
+
Three commands::
|
|
14
|
+
|
|
15
|
+
python codex_account_choice.py choose
|
|
16
|
+
{"tier": "normal", "account": "codex-1", "codex_home": "C:/Users/me/.codex-profiles/codex-1",
|
|
17
|
+
"percent_left": 62.0, "stop_below_percent": null, "reason": "codex-1 has 62% left",
|
|
18
|
+
"accounts": [...]}
|
|
19
|
+
|
|
20
|
+
python codex_account_choice.py check codex-2 --floor 1
|
|
21
|
+
exit 0 while codex-2 has more than 1% left, exit 3 once it has not
|
|
22
|
+
|
|
23
|
+
python codex_account_choice.py sync
|
|
24
|
+
links the shared Codex setup (config, rules, skills, plugins) into every
|
|
25
|
+
account's home and leaves each sign-in and history its own
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import argparse
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import sys
|
|
34
|
+
from collections.abc import Callable, Sequence
|
|
35
|
+
from dataclasses import dataclass
|
|
36
|
+
from datetime import datetime, timezone
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
from claude_account_profile import sync_profile
|
|
40
|
+
from codex_account_meters import (
|
|
41
|
+
CodexAccountMeters,
|
|
42
|
+
CodexMeterUnreadError,
|
|
43
|
+
read_codex_meters,
|
|
44
|
+
resolve_codex_path,
|
|
45
|
+
)
|
|
46
|
+
from dev_env_scripts_constants.codex_account_constants import (
|
|
47
|
+
ALL_CODEX_ACCOUNT_NAMES,
|
|
48
|
+
ALL_SHARED_CODEX_HOME_NAMES,
|
|
49
|
+
CODEX_AUTH_FILE_NAME,
|
|
50
|
+
CODEX_PROFILES_ROOT_DIRECTORY_NAME,
|
|
51
|
+
CODEX_PROFILES_ROOT_ENVIRONMENT_VARIABLE,
|
|
52
|
+
EXIT_CODE_NO_ROOM,
|
|
53
|
+
EXIT_CODE_ROOM,
|
|
54
|
+
LUNA_TIER_SHORT_WINDOW_MINIMUM_PERCENT_LEFT,
|
|
55
|
+
LUNA_TIER_STOP_PERCENT_LEFT,
|
|
56
|
+
MAIN_CODEX_HOME_DIRECTORY_NAME,
|
|
57
|
+
NORMAL_TIER_MINIMUM_PERCENT_LEFT,
|
|
58
|
+
REASON_LUNA_TEMPLATE,
|
|
59
|
+
REASON_NORMAL_TEMPLATE,
|
|
60
|
+
REASON_WAIT_TEMPLATE,
|
|
61
|
+
REASON_WAIT_UNREAD,
|
|
62
|
+
TIER_LUNA,
|
|
63
|
+
TIER_NORMAL,
|
|
64
|
+
TIER_WAIT,
|
|
65
|
+
UNKNOWN_RESET_TEXT,
|
|
66
|
+
UNREAD_NOT_SIGNED_IN,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
MeterReader = Callable[[Path], CodexAccountMeters]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class AccountReading:
|
|
74
|
+
"""One account's meters, or why they could not be read."""
|
|
75
|
+
|
|
76
|
+
name: str
|
|
77
|
+
codex_home: Path
|
|
78
|
+
meters: CodexAccountMeters | None
|
|
79
|
+
unread_reason: str | None = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class CodexAccountDecision:
|
|
84
|
+
"""The tier and account a job runs on, and the plain-words reason."""
|
|
85
|
+
|
|
86
|
+
tier: str
|
|
87
|
+
reading: AccountReading | None
|
|
88
|
+
stop_below_percent: float | None
|
|
89
|
+
reason: str
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def default_profiles_root() -> Path:
|
|
93
|
+
"""Locate the directory that holds one Codex home per account.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
The root the environment names, else ``~/.codex-profiles``.
|
|
97
|
+
"""
|
|
98
|
+
named_root = os.environ.get(CODEX_PROFILES_ROOT_ENVIRONMENT_VARIABLE)
|
|
99
|
+
return (
|
|
100
|
+
Path(named_root)
|
|
101
|
+
if named_root
|
|
102
|
+
else Path.home() / CODEX_PROFILES_ROOT_DIRECTORY_NAME
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def is_account_local_codex_entry(entry_name: str) -> bool:
|
|
107
|
+
"""Tell whether a Codex home entry belongs to one account only.
|
|
108
|
+
|
|
109
|
+
::
|
|
110
|
+
|
|
111
|
+
"auth.json", "sessions", "history.jsonl", "state_5.sqlite" -> True
|
|
112
|
+
"config.toml", "rules", "skills", "plugins" -> False
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
entry_name: A top-level entry name in a Codex home.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
True for every entry outside the shared set.
|
|
119
|
+
"""
|
|
120
|
+
return entry_name not in ALL_SHARED_CODEX_HOME_NAMES
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def read_account(
|
|
124
|
+
name: str, profiles_root: Path, read_meters: MeterReader
|
|
125
|
+
) -> AccountReading:
|
|
126
|
+
"""Read one account's meters from its Codex home.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
name: The account name.
|
|
130
|
+
profiles_root: The directory that holds every account's Codex home.
|
|
131
|
+
read_meters: Reads the meters under one Codex home.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
The meters, or the reason they are unread.
|
|
135
|
+
"""
|
|
136
|
+
codex_home = profiles_root / name
|
|
137
|
+
if not (codex_home / CODEX_AUTH_FILE_NAME).is_file():
|
|
138
|
+
return AccountReading(name, codex_home, None, UNREAD_NOT_SIGNED_IN)
|
|
139
|
+
try:
|
|
140
|
+
return AccountReading(name, codex_home, read_meters(codex_home))
|
|
141
|
+
except CodexMeterUnreadError as error:
|
|
142
|
+
return AccountReading(name, codex_home, None, str(error))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _first_reset(
|
|
146
|
+
all_read: Sequence[AccountReading],
|
|
147
|
+
) -> tuple[datetime, AccountReading] | None:
|
|
148
|
+
all_resets = [
|
|
149
|
+
(each_reset, each_reading)
|
|
150
|
+
for each_reading in all_read
|
|
151
|
+
if each_reading.meters is not None
|
|
152
|
+
and (
|
|
153
|
+
each_reset := each_reading.meters.resets_before_room(
|
|
154
|
+
NORMAL_TIER_MINIMUM_PERCENT_LEFT
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
is not None
|
|
158
|
+
]
|
|
159
|
+
if not all_resets:
|
|
160
|
+
return None
|
|
161
|
+
return min(all_resets, key=lambda each_pair: each_pair[0])
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _wait_decision(all_read: Sequence[AccountReading]) -> CodexAccountDecision:
|
|
165
|
+
if not all_read:
|
|
166
|
+
return CodexAccountDecision(TIER_WAIT, None, None, REASON_WAIT_UNREAD)
|
|
167
|
+
first_reset = _first_reset(all_read)
|
|
168
|
+
if first_reset is None:
|
|
169
|
+
reset_text, first_reading = UNKNOWN_RESET_TEXT, all_read[0]
|
|
170
|
+
else:
|
|
171
|
+
reset_text, first_reading = first_reset[0].isoformat(), first_reset[1]
|
|
172
|
+
return CodexAccountDecision(
|
|
173
|
+
TIER_WAIT,
|
|
174
|
+
first_reading,
|
|
175
|
+
None,
|
|
176
|
+
REASON_WAIT_TEMPLATE.format(account=first_reading.name, reset=reset_text),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _percent_left(reading: AccountReading) -> float:
|
|
181
|
+
return reading.meters.percent_left if reading.meters is not None else 0.0
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _normal_decision(all_read: Sequence[AccountReading]) -> CodexAccountDecision | None:
|
|
185
|
+
for each_reading in all_read:
|
|
186
|
+
percent_left = _percent_left(each_reading)
|
|
187
|
+
if percent_left > NORMAL_TIER_MINIMUM_PERCENT_LEFT:
|
|
188
|
+
return CodexAccountDecision(
|
|
189
|
+
TIER_NORMAL,
|
|
190
|
+
each_reading,
|
|
191
|
+
None,
|
|
192
|
+
REASON_NORMAL_TEMPLATE.format(
|
|
193
|
+
account=each_reading.name, percent_left=percent_left
|
|
194
|
+
),
|
|
195
|
+
)
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _can_run_luna(reading: AccountReading) -> bool:
|
|
200
|
+
if reading.meters is None or reading.meters.percent_left <= LUNA_TIER_STOP_PERCENT_LEFT:
|
|
201
|
+
return False
|
|
202
|
+
short_window_left = reading.meters.short_window_percent_left
|
|
203
|
+
return (
|
|
204
|
+
short_window_left is None
|
|
205
|
+
or short_window_left >= LUNA_TIER_SHORT_WINDOW_MINIMUM_PERCENT_LEFT
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _luna_decision(all_read: Sequence[AccountReading]) -> CodexAccountDecision | None:
|
|
210
|
+
all_candidates = [each_reading for each_reading in all_read if _can_run_luna(each_reading)]
|
|
211
|
+
if not all_candidates:
|
|
212
|
+
return None
|
|
213
|
+
roomiest = max(all_candidates, key=_percent_left)
|
|
214
|
+
return CodexAccountDecision(
|
|
215
|
+
TIER_LUNA,
|
|
216
|
+
roomiest,
|
|
217
|
+
LUNA_TIER_STOP_PERCENT_LEFT,
|
|
218
|
+
REASON_LUNA_TEMPLATE.format(
|
|
219
|
+
bar=NORMAL_TIER_MINIMUM_PERCENT_LEFT,
|
|
220
|
+
account=roomiest.name,
|
|
221
|
+
percent_left=_percent_left(roomiest),
|
|
222
|
+
stop=LUNA_TIER_STOP_PERCENT_LEFT,
|
|
223
|
+
),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def choose_codex_account(all_readings: Sequence[AccountReading]) -> CodexAccountDecision:
|
|
228
|
+
"""Pick the tier and account a job runs on.
|
|
229
|
+
|
|
230
|
+
::
|
|
231
|
+
|
|
232
|
+
codex-1 8%, codex-2 40% -> normal on codex-2
|
|
233
|
+
codex-1 8%, codex-2 3%, others 0 -> luna on codex-1, stop at 1%
|
|
234
|
+
codex-1 week 8%, 5-hour 15% left -> no luna on codex-1, 5-hour under 20%
|
|
235
|
+
every account 1% or less -> wait, naming the account that resets first
|
|
236
|
+
codex-1 unread, codex-2 40% -> normal on codex-2
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
all_readings: Every account's reading, in try order.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
The tier, the account, where a Luna run stops, and the reason.
|
|
243
|
+
"""
|
|
244
|
+
all_read = [
|
|
245
|
+
each_reading for each_reading in all_readings if each_reading.meters is not None
|
|
246
|
+
]
|
|
247
|
+
return (
|
|
248
|
+
_normal_decision(all_read)
|
|
249
|
+
or _luna_decision(all_read)
|
|
250
|
+
or _wait_decision(all_read)
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _iso_or_none(moment: datetime | None) -> str | None:
|
|
255
|
+
return moment.isoformat() if moment else None
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def reading_payload(reading: AccountReading) -> dict[str, object]:
|
|
259
|
+
"""Shape one account's reading as the JSON object a report prints.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
reading: One account's reading.
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
The name, percent left, each window, and any unread reason.
|
|
266
|
+
"""
|
|
267
|
+
if reading.meters is None:
|
|
268
|
+
return {
|
|
269
|
+
"name": reading.name,
|
|
270
|
+
"percent_left": None,
|
|
271
|
+
"unread": reading.unread_reason,
|
|
272
|
+
}
|
|
273
|
+
return {
|
|
274
|
+
"name": reading.name,
|
|
275
|
+
"percent_left": reading.meters.percent_left,
|
|
276
|
+
"windows": [
|
|
277
|
+
{
|
|
278
|
+
"minutes": each_window.duration_minutes,
|
|
279
|
+
"used_percent": each_window.used_percent,
|
|
280
|
+
"resets_at": _iso_or_none(each_window.resets_at),
|
|
281
|
+
}
|
|
282
|
+
for each_window in reading.meters.all_windows
|
|
283
|
+
],
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def decision_payload(
|
|
288
|
+
decision: CodexAccountDecision, all_readings: Sequence[AccountReading]
|
|
289
|
+
) -> dict[str, object]:
|
|
290
|
+
"""Shape a decision as the JSON object the runner job reads.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
decision: The chosen tier and account.
|
|
294
|
+
all_readings: Every account's reading, for the report.
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
The tier, account, Codex home, room, stop point, reason, and all readings.
|
|
298
|
+
"""
|
|
299
|
+
is_runnable = decision.tier != TIER_WAIT and decision.reading is not None
|
|
300
|
+
chosen = decision.reading if is_runnable else None
|
|
301
|
+
return {
|
|
302
|
+
"tier": decision.tier,
|
|
303
|
+
"account": chosen.name if chosen else None,
|
|
304
|
+
"codex_home": str(chosen.codex_home) if chosen else None,
|
|
305
|
+
"percent_left": chosen.meters.percent_left
|
|
306
|
+
if chosen and chosen.meters
|
|
307
|
+
else None,
|
|
308
|
+
"stop_below_percent": decision.stop_below_percent,
|
|
309
|
+
"reason": decision.reason,
|
|
310
|
+
"accounts": [reading_payload(each_reading) for each_reading in all_readings],
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _meter_reader(codex_path_argument: Path | None) -> MeterReader:
|
|
315
|
+
codex_path = resolve_codex_path(codex_path_argument)
|
|
316
|
+
return lambda codex_home: read_codex_meters(codex_path, codex_home)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _run_choose(arguments: argparse.Namespace) -> int:
|
|
320
|
+
read_meters = _meter_reader(arguments.codex_path)
|
|
321
|
+
all_readings = [
|
|
322
|
+
read_account(each_name, arguments.profiles_root, read_meters)
|
|
323
|
+
for each_name in ALL_CODEX_ACCOUNT_NAMES
|
|
324
|
+
]
|
|
325
|
+
print(
|
|
326
|
+
json.dumps(decision_payload(choose_codex_account(all_readings), all_readings))
|
|
327
|
+
)
|
|
328
|
+
return 0
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _run_check(arguments: argparse.Namespace) -> int:
|
|
332
|
+
reading = read_account(
|
|
333
|
+
arguments.account, arguments.profiles_root, _meter_reader(arguments.codex_path)
|
|
334
|
+
)
|
|
335
|
+
print(json.dumps(reading_payload(reading)))
|
|
336
|
+
if reading.meters is not None and reading.meters.percent_left > arguments.floor:
|
|
337
|
+
return EXIT_CODE_ROOM
|
|
338
|
+
return EXIT_CODE_NO_ROOM
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _run_sync(arguments: argparse.Namespace) -> int:
|
|
342
|
+
now = datetime.now(timezone.utc)
|
|
343
|
+
all_reports = {}
|
|
344
|
+
for each_name in ALL_CODEX_ACCOUNT_NAMES:
|
|
345
|
+
report = sync_profile(
|
|
346
|
+
main_home=arguments.main_home,
|
|
347
|
+
profile_home=arguments.profiles_root / each_name,
|
|
348
|
+
now=now,
|
|
349
|
+
is_local=is_account_local_codex_entry,
|
|
350
|
+
)
|
|
351
|
+
all_reports[each_name] = {
|
|
352
|
+
"linked": list(report.all_linked),
|
|
353
|
+
"moved_aside": list(report.all_moved_aside),
|
|
354
|
+
"unlinked": list(report.all_unlinked),
|
|
355
|
+
}
|
|
356
|
+
print(json.dumps(all_reports))
|
|
357
|
+
return 0
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _build_argument_parser() -> argparse.ArgumentParser:
|
|
361
|
+
parser = argparse.ArgumentParser(
|
|
362
|
+
description="Name the Codex account a runner job uses."
|
|
363
|
+
)
|
|
364
|
+
parser.add_argument("--profiles-root", type=Path, default=default_profiles_root())
|
|
365
|
+
parser.add_argument("--codex-path", type=Path, default=None)
|
|
366
|
+
all_commands = parser.add_subparsers(dest="command", required=True)
|
|
367
|
+
all_commands.add_parser("choose").set_defaults(run=_run_choose)
|
|
368
|
+
check_parser = all_commands.add_parser("check")
|
|
369
|
+
check_parser.add_argument("account", choices=ALL_CODEX_ACCOUNT_NAMES)
|
|
370
|
+
check_parser.add_argument(
|
|
371
|
+
"--floor", type=float, default=LUNA_TIER_STOP_PERCENT_LEFT
|
|
372
|
+
)
|
|
373
|
+
check_parser.set_defaults(run=_run_check)
|
|
374
|
+
sync_parser = all_commands.add_parser("sync")
|
|
375
|
+
sync_parser.add_argument(
|
|
376
|
+
"--main-home", type=Path, default=Path.home() / MAIN_CODEX_HOME_DIRECTORY_NAME
|
|
377
|
+
)
|
|
378
|
+
sync_parser.set_defaults(run=_run_sync)
|
|
379
|
+
return parser
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def main(all_command_arguments: list[str]) -> int:
|
|
383
|
+
"""Run one command and print its JSON report.
|
|
384
|
+
|
|
385
|
+
Args:
|
|
386
|
+
all_command_arguments: Command-line arguments after the program name.
|
|
387
|
+
|
|
388
|
+
Returns:
|
|
389
|
+
Zero for choose and sync; for check, zero with room and three without.
|
|
390
|
+
"""
|
|
391
|
+
arguments = _build_argument_parser().parse_args(all_command_arguments)
|
|
392
|
+
return arguments.run(arguments)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
if __name__ == "__main__":
|
|
396
|
+
sys.exit(main(sys.argv[1:]))
|