claude-dev-env 8.12.0 → 8.14.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 (85) hide show
  1. package/.agents/skills/pull-request/scripts/test_pull_request.py +1 -1
  2. package/.agents/skills/team-advisor/SKILL.md +1 -1
  3. package/.agents/skills-archived/pr-converge/scripts/test_check_convergence.py +4 -4
  4. package/_shared/advisor/advisor-protocol.md +19 -19
  5. package/_shared/advisor/reference/advisor-block.md +1 -1
  6. package/_shared/advisor/reference/astra-rung.md +11 -11
  7. package/_shared/advisor/reference/identity.md +4 -4
  8. package/_shared/advisor/reference/lifecycle.md +2 -2
  9. package/_shared/advisor/reference/spawn-walk-log.md +4 -4
  10. package/_shared/advisor/reference/third-party-bind.md +7 -7
  11. package/_shared/advisor/reference/warm-up.md +3 -3
  12. package/_shared/advisor/scripts/codex_astra_advisor.py +33 -26
  13. package/_shared/advisor/scripts/codex_astra_preflight.py +57 -55
  14. package/_shared/advisor/scripts/config/advisor_scripts_constants/advisor_route_constants.py +2 -2
  15. package/_shared/advisor/scripts/config/advisor_scripts_constants/astra_advisor_constants.py +8 -8
  16. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +3 -3
  17. package/_shared/advisor/scripts/model_tier_run_validator.py +8 -9
  18. package/_shared/advisor/scripts/tests/test_codex_astra_advisor.py +69 -23
  19. package/_shared/advisor/scripts/tests/test_codex_astra_preflight.py +4 -4
  20. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +91 -91
  21. package/_shared/pr-loop/scripts/check_convergence.py +54 -49
  22. package/_shared/pr-loop/scripts/codex_review_scripts_constants/run_constants.py +4 -0
  23. package/_shared/pr-loop/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +10 -1
  24. package/_shared/pr-loop/scripts/run_codex_review.py +4 -6
  25. package/_shared/pr-loop/scripts/test_check_convergence.py +6 -6
  26. package/_shared/pr-loop/scripts/test_check_convergence_codex.py +73 -31
  27. package/_shared/pr-loop/scripts/test_check_convergence_contract.py +2 -2
  28. package/_shared/pr-loop/scripts/tests/test__claude_permissions_common.py +2 -2
  29. package/bin/git_hooks_installer.test.mjs +1 -1
  30. package/bin/install-constants.mjs +1 -6
  31. package/bin/install.cursor-rules.test.mjs +24 -51
  32. package/bin/install.mjs +16 -70
  33. package/docs/codex-accounts.md +79 -0
  34. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +5 -5
  35. package/hooks/blocking/test_pii_scanner.py +1 -1
  36. package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +2 -2
  37. package/hooks/git-hooks/conftest.py +16 -0
  38. package/hooks/git-hooks/git_hooks_constants/verification_notice_constants.py +1 -1
  39. package/hooks/git-hooks/test_post_commit.py +1 -1
  40. package/hooks/git-hooks/test_pre_commit.py +1 -1
  41. package/hooks/git-hooks/test_pre_push.py +2 -2
  42. package/hooks/git-hooks/test_verification_start.py +1 -1
  43. package/hooks/git-hooks/verification_notice.py +2 -2
  44. package/hooks/git-hooks/verification_notice_context.py +21 -2
  45. package/hooks/git-hooks/verification_start.py +2 -3
  46. package/package.json +1 -1
  47. package/rules/durable-post-artifacts.md +4 -3
  48. package/scripts/claude_account_profile.py +21 -9
  49. package/scripts/codex_account_choice.py +396 -0
  50. package/scripts/codex_account_meters.py +372 -0
  51. package/scripts/dev_env_scripts_constants/codex_account_constants.py +141 -0
  52. package/scripts/dev_env_scripts_constants/private_term_constants.py +17 -2
  53. package/scripts/private_term_scan.py +6 -2
  54. package/scripts/repository_checks/config/constants.py +1 -0
  55. package/scripts/repository_checks/tracked_private_terms.py +7 -0
  56. package/scripts/test_claude_account_profile.py +12 -0
  57. package/scripts/test_codex_account_choice.py +276 -0
  58. package/scripts/test_codex_account_meters.py +205 -0
  59. package/scripts/tests/test_automatic_advisory.py +3 -3
  60. package/scripts/tests/test_automatic_advisory_polling.py +1 -1
  61. package/scripts/tests/test_automatic_advisory_recovery.py +1 -1
  62. package/scripts/tests/test_closed_pr_label.py +1 -1
  63. package/scripts/tests/test_private_term_scan.py +28 -0
  64. package/scripts/tests/test_review_closure.py +2 -2
  65. package/scripts/tests/test_review_closure_model.py +2 -2
  66. package/scripts/tests/test_rules.py +2 -83
  67. package/scripts/tests/test_tracked_private_terms.py +15 -0
  68. package/scripts/tests/test_verification_notice.py +29 -13
  69. package/.agents/skills/cert-classification-rule/SKILL.md +0 -224
  70. package/.agents/skills/cert-classification-rule/scripts/resort_cert_folders.py +0 -158
  71. package/_shared/pr-loop/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +0 -82
  72. package/_shared/pr-loop/scripts/codex_usage_probe.py +0 -573
  73. package/_shared/pr-loop/scripts/test_codex_usage_probe.py +0 -683
  74. package/scripts/sync_to_cursor/AGENTS.md +0 -1
  75. package/scripts/sync_to_cursor/CLAUDE.md +0 -1
  76. package/scripts/sync_to_cursor/__init__.py +0 -13
  77. package/scripts/sync_to_cursor/canonical_docs.py +0 -66
  78. package/scripts/sync_to_cursor/config/__init__.py +0 -8
  79. package/scripts/sync_to_cursor/engine.py +0 -219
  80. package/scripts/sync_to_cursor/hashing.py +0 -7
  81. package/scripts/sync_to_cursor/paths.py +0 -18
  82. package/scripts/sync_to_cursor/rules.py +0 -494
  83. package/scripts/sync_to_cursor.py +0 -22
  84. package/scripts/tests/test_engine.py +0 -101
  85. package/scripts/tests/test_sync_to_cursor.py +0 -315
@@ -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:]))