claude-dev-env 8.12.0 → 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.
Files changed (74) hide show
  1. package/.agents/skills/pull-request/scripts/test_pull_request.py +1 -1
  2. package/.agents/skills-archived/pr-converge/scripts/test_check_convergence.py +4 -4
  3. package/_shared/advisor/reference/astra-rung.md +7 -7
  4. package/_shared/advisor/reference/third-party-bind.md +2 -2
  5. package/_shared/advisor/scripts/codex_astra_advisor.py +33 -26
  6. package/_shared/advisor/scripts/codex_astra_preflight.py +57 -55
  7. package/_shared/advisor/scripts/config/advisor_scripts_constants/astra_advisor_constants.py +8 -8
  8. package/_shared/advisor/scripts/tests/test_codex_astra_advisor.py +69 -23
  9. package/_shared/advisor/scripts/tests/test_codex_astra_preflight.py +4 -4
  10. package/_shared/pr-loop/scripts/check_convergence.py +54 -49
  11. package/_shared/pr-loop/scripts/codex_review_scripts_constants/run_constants.py +4 -0
  12. package/_shared/pr-loop/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +10 -1
  13. package/_shared/pr-loop/scripts/run_codex_review.py +4 -6
  14. package/_shared/pr-loop/scripts/test_check_convergence.py +6 -6
  15. package/_shared/pr-loop/scripts/test_check_convergence_codex.py +73 -31
  16. package/_shared/pr-loop/scripts/test_check_convergence_contract.py +2 -2
  17. package/_shared/pr-loop/scripts/tests/test__claude_permissions_common.py +2 -2
  18. package/bin/git_hooks_installer.test.mjs +1 -1
  19. package/bin/install-constants.mjs +1 -6
  20. package/bin/install.cursor-rules.test.mjs +24 -51
  21. package/bin/install.mjs +16 -70
  22. package/docs/codex-accounts.md +79 -0
  23. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +5 -5
  24. package/hooks/blocking/test_pii_scanner.py +1 -1
  25. package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +2 -2
  26. package/hooks/git-hooks/conftest.py +16 -0
  27. package/hooks/git-hooks/git_hooks_constants/verification_notice_constants.py +1 -1
  28. package/hooks/git-hooks/test_post_commit.py +1 -1
  29. package/hooks/git-hooks/test_pre_commit.py +1 -1
  30. package/hooks/git-hooks/test_pre_push.py +2 -2
  31. package/hooks/git-hooks/test_verification_start.py +1 -1
  32. package/hooks/git-hooks/verification_notice.py +2 -2
  33. package/hooks/git-hooks/verification_notice_context.py +21 -2
  34. package/hooks/git-hooks/verification_start.py +2 -3
  35. package/package.json +1 -1
  36. package/rules/durable-post-artifacts.md +4 -3
  37. package/scripts/claude_account_profile.py +21 -9
  38. package/scripts/codex_account_choice.py +396 -0
  39. package/scripts/codex_account_meters.py +372 -0
  40. package/scripts/dev_env_scripts_constants/codex_account_constants.py +141 -0
  41. package/scripts/dev_env_scripts_constants/private_term_constants.py +17 -2
  42. package/scripts/private_term_scan.py +6 -2
  43. package/scripts/repository_checks/config/constants.py +1 -0
  44. package/scripts/repository_checks/tracked_private_terms.py +7 -0
  45. package/scripts/test_claude_account_profile.py +12 -0
  46. package/scripts/test_codex_account_choice.py +276 -0
  47. package/scripts/test_codex_account_meters.py +205 -0
  48. package/scripts/tests/test_automatic_advisory.py +3 -3
  49. package/scripts/tests/test_automatic_advisory_polling.py +1 -1
  50. package/scripts/tests/test_automatic_advisory_recovery.py +1 -1
  51. package/scripts/tests/test_closed_pr_label.py +1 -1
  52. package/scripts/tests/test_private_term_scan.py +28 -0
  53. package/scripts/tests/test_review_closure.py +2 -2
  54. package/scripts/tests/test_review_closure_model.py +2 -2
  55. package/scripts/tests/test_rules.py +2 -83
  56. package/scripts/tests/test_tracked_private_terms.py +15 -0
  57. package/scripts/tests/test_verification_notice.py +29 -13
  58. package/.agents/skills/cert-classification-rule/SKILL.md +0 -224
  59. package/.agents/skills/cert-classification-rule/scripts/resort_cert_folders.py +0 -158
  60. package/_shared/pr-loop/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +0 -82
  61. package/_shared/pr-loop/scripts/codex_usage_probe.py +0 -573
  62. package/_shared/pr-loop/scripts/test_codex_usage_probe.py +0 -683
  63. package/scripts/sync_to_cursor/AGENTS.md +0 -1
  64. package/scripts/sync_to_cursor/CLAUDE.md +0 -1
  65. package/scripts/sync_to_cursor/__init__.py +0 -13
  66. package/scripts/sync_to_cursor/canonical_docs.py +0 -66
  67. package/scripts/sync_to_cursor/config/__init__.py +0 -8
  68. package/scripts/sync_to_cursor/engine.py +0 -219
  69. package/scripts/sync_to_cursor/hashing.py +0 -7
  70. package/scripts/sync_to_cursor/paths.py +0 -18
  71. package/scripts/sync_to_cursor/rules.py +0 -494
  72. package/scripts/sync_to_cursor.py +0 -22
  73. package/scripts/tests/test_engine.py +0 -101
  74. package/scripts/tests/test_sync_to_cursor.py +0 -315
@@ -0,0 +1,372 @@
1
+ #!/usr/bin/env python3
2
+ """Read one Codex account's rate-limit windows from its own Codex home.
3
+
4
+ Codex reports usage through ``codex app-server`` and the JSON-RPC method
5
+ ``account/rateLimits/read``. The reply names up to two windows, a short one
6
+ (5 hours) and a weekly one. The account's room is the smaller of the two::
7
+
8
+ 5-hour window 30% used, week 85% used -> 15% left
9
+ week window only, 40% used -> 60% left
10
+ no window, error reply, no reply -> CodexMeterUnreadError
11
+
12
+ Every failure raises ``CodexMeterUnreadError``. The caller decides what an
13
+ unread account means; this module never guesses a number.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import importlib
19
+ import json
20
+ import math
21
+ import os
22
+ import shutil
23
+ import subprocess
24
+ import sys
25
+ import threading
26
+ from collections.abc import Callable, Mapping, Sequence
27
+ from dataclasses import dataclass
28
+ from datetime import datetime, timezone
29
+ from pathlib import Path
30
+ from typing import IO
31
+
32
+ from dev_env_scripts_constants.codex_account_constants import (
33
+ ALL_APP_SERVER_ARGUMENTS,
34
+ ALL_CODEX_BINARY_CANDIDATE_RELATIVE_PARTS,
35
+ ALL_WINDOW_KEYS,
36
+ APP_SERVER_TIMEOUT_SECONDS,
37
+ CLIENT_NAME,
38
+ CLIENT_VERSION,
39
+ CODEX_BINARY_NAME,
40
+ CODEX_HOME_ENVIRONMENT_VARIABLE,
41
+ FULL_PERCENT,
42
+ INITIALIZE_REQUEST_ID,
43
+ JSONRPC_VERSION,
44
+ METHOD_INITIALIZE,
45
+ METHOD_INITIALIZED,
46
+ METHOD_RATE_LIMITS_READ,
47
+ PROCESS_WAIT_TIMEOUT_SECONDS,
48
+ RATE_LIMITS_REQUEST_ID,
49
+ READER_JOIN_TIMEOUT_SECONDS,
50
+ TEXT_ENCODING,
51
+ WEEKLY_WINDOW_MINUTES,
52
+ )
53
+ from shared_tree_paths import resolve_shared_process_tree_scripts_directory
54
+
55
+ _shared_process_tree_scripts_directory = resolve_shared_process_tree_scripts_directory(
56
+ __file__,
57
+ all_environment=os.environ,
58
+ )
59
+ if str(_shared_process_tree_scripts_directory) not in sys.path:
60
+ sys.path.insert(0, str(_shared_process_tree_scripts_directory))
61
+
62
+ _process_tree_kill = importlib.import_module("process_tree_kill")
63
+ _subprocess_window_access = importlib.import_module("subprocess_window_access")
64
+
65
+ ServerExchange = Callable[[Path, Path, Sequence[Mapping[str, object]]], list[str]]
66
+
67
+
68
+ class CodexMeterUnreadError(Exception):
69
+ """Raised when one account's rate-limit windows cannot be read."""
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class UsageWindow:
74
+ """One rate-limit window: its length, the percent used, and when it resets."""
75
+
76
+ duration_minutes: int | None
77
+ used_percent: float
78
+ resets_at: datetime | None
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class CodexAccountMeters:
83
+ """One account's windows, with percent_left, short_window_percent_left and resets_before_room."""
84
+
85
+ all_windows: tuple[UsageWindow, ...]
86
+
87
+ @property
88
+ def percent_left(self) -> float:
89
+ """Room left in the tightest window, from 0 to 100."""
90
+ most_used = max(each_window.used_percent for each_window in self.all_windows)
91
+ return max(0.0, FULL_PERCENT - most_used)
92
+
93
+ @property
94
+ def short_window_percent_left(self) -> float | None:
95
+ """Room left in the windows shorter than a week, or None when the account has none."""
96
+ all_short_used = [
97
+ each_window.used_percent
98
+ for each_window in self.all_windows
99
+ if each_window.duration_minutes is not None
100
+ and each_window.duration_minutes < WEEKLY_WINDOW_MINUTES
101
+ ]
102
+ if not all_short_used:
103
+ return None
104
+ return max(0.0, FULL_PERCENT - max(all_short_used))
105
+
106
+ def resets_before_room(self, minimum_percent_left: float) -> datetime | None:
107
+ """When every window that holds the account at or under a bar has reset.
108
+
109
+ Args:
110
+ minimum_percent_left: The percent left the account must pass.
111
+
112
+ Returns:
113
+ The latest reset among the blocking windows, or None when one
114
+ carries no reset time or none blocks.
115
+ """
116
+ all_blocking = [
117
+ each_window
118
+ for each_window in self.all_windows
119
+ if FULL_PERCENT - each_window.used_percent <= minimum_percent_left
120
+ ]
121
+ if not all_blocking or any(
122
+ each_window.resets_at is None for each_window in all_blocking
123
+ ):
124
+ return None
125
+ return max(
126
+ each_window.resets_at
127
+ for each_window in all_blocking
128
+ if each_window.resets_at is not None
129
+ )
130
+
131
+
132
+ def _finite_number(raw_number: object) -> float | None:
133
+ if isinstance(raw_number, bool) or not isinstance(raw_number, (int, float)):
134
+ return None
135
+ if not math.isfinite(raw_number):
136
+ return None
137
+ return float(raw_number)
138
+
139
+
140
+ def _parse_resets_at(raw_resets_at: object) -> datetime | None:
141
+ seconds = _finite_number(raw_resets_at)
142
+ if seconds is None:
143
+ return None
144
+ try:
145
+ return datetime.fromtimestamp(seconds, tz=timezone.utc)
146
+ except (OverflowError, OSError, ValueError):
147
+ return None
148
+
149
+
150
+ def _parse_window(raw_window: object) -> UsageWindow | None:
151
+ if not isinstance(raw_window, Mapping):
152
+ return None
153
+ used_percent = _finite_number(raw_window.get("usedPercent"))
154
+ if used_percent is None:
155
+ return None
156
+ duration = _finite_number(raw_window.get("windowDurationMins"))
157
+ return UsageWindow(
158
+ duration_minutes=None if duration is None else int(duration),
159
+ used_percent=min(FULL_PERCENT, max(0.0, used_percent)),
160
+ resets_at=_parse_resets_at(raw_window.get("resetsAt")),
161
+ )
162
+
163
+
164
+ def _rate_limits_mapping(reply_text: str) -> Mapping[str, object]:
165
+ try:
166
+ reply = json.loads(reply_text)
167
+ except json.JSONDecodeError as error:
168
+ raise CodexMeterUnreadError("rate-limit reply is not JSON") from error
169
+ if not isinstance(reply, dict):
170
+ raise CodexMeterUnreadError("rate-limit reply is not an object")
171
+ if reply.get("error") is not None:
172
+ raise CodexMeterUnreadError(f"rate-limit read failed: {reply['error']}")
173
+ body = reply.get("result")
174
+ rate_limits = body.get("rateLimits") if isinstance(body, dict) else None
175
+ if not isinstance(rate_limits, dict):
176
+ raise CodexMeterUnreadError("rate-limit reply names no rateLimits")
177
+ return rate_limits
178
+
179
+
180
+ def parse_rate_limits_reply(reply_text: str) -> CodexAccountMeters:
181
+ """Parse the server's reply to the rate-limit read.
182
+
183
+ ::
184
+
185
+ {"id": 2, "result": {"rateLimits": {"primary": {"usedPercent": 30, ...},
186
+ "secondary": {"usedPercent": 85, ...}}}}
187
+ -> CodexAccountMeters with two windows, 15% left
188
+
189
+ Args:
190
+ reply_text: One line the ``codex app-server`` process wrote.
191
+
192
+ Returns:
193
+ The windows the reply carries.
194
+
195
+ Raises:
196
+ CodexMeterUnreadError: The line is no reply, an error reply, or names
197
+ no window with a used percent.
198
+ """
199
+ rate_limits = _rate_limits_mapping(reply_text)
200
+ all_windows = tuple(
201
+ each_window
202
+ for each_key in ALL_WINDOW_KEYS
203
+ if (each_window := _parse_window(rate_limits.get(each_key))) is not None
204
+ )
205
+ if not all_windows:
206
+ raise CodexMeterUnreadError("rate-limit reply names no usage window")
207
+ return CodexAccountMeters(all_windows=all_windows)
208
+
209
+
210
+ def request_messages() -> list[dict[str, object]]:
211
+ """Build the handshake and the rate-limit read, in send order.
212
+
213
+ Returns:
214
+ The initialize request, the initialized notice, and the read request.
215
+ """
216
+ return [
217
+ {
218
+ "jsonrpc": JSONRPC_VERSION,
219
+ "id": INITIALIZE_REQUEST_ID,
220
+ "method": METHOD_INITIALIZE,
221
+ "params": {
222
+ "clientInfo": {"name": CLIENT_NAME, "version": CLIENT_VERSION},
223
+ "capabilities": {"experimentalApi": True},
224
+ },
225
+ },
226
+ {"jsonrpc": JSONRPC_VERSION, "method": METHOD_INITIALIZED, "params": {}},
227
+ {
228
+ "jsonrpc": JSONRPC_VERSION,
229
+ "id": RATE_LIMITS_REQUEST_ID,
230
+ "method": METHOD_RATE_LIMITS_READ,
231
+ "params": {},
232
+ },
233
+ ]
234
+
235
+
236
+ def _is_rate_limits_reply(line: str) -> bool:
237
+ try:
238
+ message = json.loads(line)
239
+ except json.JSONDecodeError:
240
+ return False
241
+ return isinstance(message, dict) and message.get("id") == RATE_LIMITS_REQUEST_ID
242
+
243
+
244
+ def _read_until_reply(server_stdout: IO[str], all_lines: list[str]) -> None:
245
+ for each_line in server_stdout:
246
+ stripped = each_line.strip()
247
+ if stripped:
248
+ all_lines.append(stripped)
249
+ if _is_rate_limits_reply(stripped):
250
+ return
251
+
252
+
253
+ def _collect_lines(
254
+ server_stdout: IO[str], all_lines: list[str], is_done: threading.Event
255
+ ) -> None:
256
+ try:
257
+ _read_until_reply(server_stdout, all_lines)
258
+ except (OSError, ValueError):
259
+ pass
260
+ is_done.set()
261
+
262
+
263
+ def _stop_server(server: subprocess.Popen[str]) -> None:
264
+ _process_tree_kill.terminate_process_tree(server)
265
+ try:
266
+ server.wait(timeout=PROCESS_WAIT_TIMEOUT_SECONDS)
267
+ except subprocess.TimeoutExpired:
268
+ return
269
+
270
+
271
+ def _send_and_collect(server: subprocess.Popen[str], input_text: str) -> list[str]:
272
+ if server.stdin is None or server.stdout is None:
273
+ _stop_server(server)
274
+ raise OSError("codex app-server offered no stdio pipes")
275
+ all_lines: list[str] = []
276
+ is_done = threading.Event()
277
+ reader = threading.Thread(
278
+ target=_collect_lines, args=(server.stdout, all_lines, is_done), daemon=True
279
+ )
280
+ reader.start()
281
+ try:
282
+ server.stdin.write(input_text)
283
+ server.stdin.flush()
284
+ is_done.wait(timeout=APP_SERVER_TIMEOUT_SECONDS)
285
+ finally:
286
+ _stop_server(server)
287
+ reader.join(timeout=READER_JOIN_TIMEOUT_SECONDS)
288
+ return list(all_lines)
289
+
290
+
291
+ def exchange_with_app_server(
292
+ codex_path: Path,
293
+ codex_home: Path,
294
+ all_messages: Sequence[Mapping[str, object]],
295
+ ) -> list[str]:
296
+ """Run ``codex app-server`` under one Codex home and collect the lines it writes.
297
+
298
+ Input stays open until the reply lands, since the server quits unanswered on end of input.
299
+
300
+ Args:
301
+ codex_path: The Codex executable.
302
+ codex_home: The account's Codex home, passed as ``CODEX_HOME``.
303
+ all_messages: JSON-RPC messages to send.
304
+
305
+ Returns:
306
+ The non-empty lines, ending with the rate-limit reply when it came.
307
+ """
308
+ input_text = "".join(json.dumps(each_message) + "\n" for each_message in all_messages)
309
+ with subprocess.Popen(
310
+ [str(codex_path), *ALL_APP_SERVER_ARGUMENTS],
311
+ stdin=subprocess.PIPE,
312
+ stdout=subprocess.PIPE,
313
+ stderr=subprocess.DEVNULL,
314
+ text=True,
315
+ encoding=TEXT_ENCODING,
316
+ env={**os.environ, CODEX_HOME_ENVIRONMENT_VARIABLE: str(codex_home)},
317
+ start_new_session=_process_tree_kill.should_start_new_session(),
318
+ creationflags=_subprocess_window_access.hidden_window_creation_flags(),
319
+ ) as server:
320
+ return _send_and_collect(server, input_text)
321
+
322
+
323
+ def read_codex_meters(
324
+ codex_path: Path,
325
+ codex_home: Path,
326
+ exchange: ServerExchange = exchange_with_app_server,
327
+ ) -> CodexAccountMeters:
328
+ """Read one account's windows through the app server under its Codex home.
329
+
330
+ Args:
331
+ codex_path: The Codex executable.
332
+ codex_home: The account's Codex home.
333
+ exchange: Sends messages and returns output lines; tests pass a fake.
334
+
335
+ Returns:
336
+ The account's windows.
337
+
338
+ Raises:
339
+ CodexMeterUnreadError: The server failed, timed out, or sent no usable reply.
340
+ """
341
+ try:
342
+ all_lines = exchange(codex_path, codex_home, request_messages())
343
+ except (OSError, subprocess.SubprocessError, UnicodeDecodeError) as error:
344
+ raise CodexMeterUnreadError(f"codex app-server failed: {error}") from error
345
+ for each_line in all_lines:
346
+ if _is_rate_limits_reply(each_line):
347
+ return parse_rate_limits_reply(each_line)
348
+ raise CodexMeterUnreadError("codex app-server sent no rate-limit reply")
349
+
350
+
351
+ def resolve_codex_path(explicit_path: Path | None) -> Path:
352
+ """Find the Codex executable.
353
+
354
+ Args:
355
+ explicit_path: A path the caller named, used as given.
356
+
357
+ Returns:
358
+ The named path, else ``codex`` on PATH, else a known install path.
359
+
360
+ Raises:
361
+ FileNotFoundError: No Codex executable was found.
362
+ """
363
+ if explicit_path is not None:
364
+ return explicit_path
365
+ on_path = shutil.which(CODEX_BINARY_NAME)
366
+ if on_path is not None:
367
+ return Path(on_path)
368
+ for each_parts in ALL_CODEX_BINARY_CANDIDATE_RELATIVE_PARTS:
369
+ candidate = Path.home().joinpath(*each_parts)
370
+ if candidate.is_file():
371
+ return candidate
372
+ raise FileNotFoundError("no codex executable on PATH or at its install path")
@@ -0,0 +1,141 @@
1
+ """Named constants for the Codex accounts a runner job picks between.
2
+
3
+ Each account signs in under its own Codex home. The picker reads every
4
+ account's rate-limit windows through ``codex app-server`` and names the account
5
+ a job runs on, trying the accounts in one fixed order.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ ALL_CODEX_ACCOUNT_NAMES: tuple[str, ...] = ("codex-1", "codex-2", "codex-3", "codex-4")
11
+ """Codex accounts in the order jobs try them. The names say nothing about a plan."""
12
+
13
+ CODEX_PROFILES_ROOT_DIRECTORY_NAME: str = ".codex-profiles"
14
+ """Directory under the user home that holds one Codex home per account."""
15
+
16
+ CODEX_PROFILES_ROOT_ENVIRONMENT_VARIABLE: str = "CODEX_PROFILES_ROOT"
17
+ """Environment variable that relocates the Codex profiles root."""
18
+
19
+ MAIN_CODEX_HOME_DIRECTORY_NAME: str = ".codex"
20
+ """Directory under the user home that holds the shared Codex setup."""
21
+
22
+ CODEX_HOME_ENVIRONMENT_VARIABLE: str = "CODEX_HOME"
23
+ """Environment variable that points Codex at one account's home."""
24
+
25
+ CODEX_AUTH_FILE_NAME: str = "auth.json"
26
+ """File in a Codex home that holds that account's sign-in."""
27
+
28
+ ALL_SHARED_CODEX_HOME_NAMES: frozenset[str] = frozenset(
29
+ {
30
+ "AGENTS.md",
31
+ "config.toml",
32
+ "hooks",
33
+ "hooks.json",
34
+ "plugins",
35
+ "prompts",
36
+ "rules",
37
+ "skills",
38
+ }
39
+ )
40
+ """Codex home entries every account shares. Every other entry stays per account."""
41
+
42
+ NORMAL_TIER_MINIMUM_PERCENT_LEFT: float = 10.0
43
+ """An account runs a job at its normal model only above this percent left."""
44
+
45
+ LUNA_TIER_STOP_PERCENT_LEFT: float = 1.0
46
+ """A Luna fallback job stops once its account is at or below this percent left."""
47
+
48
+ LUNA_TIER_SHORT_WINDOW_MINIMUM_PERCENT_LEFT: float = 20.0
49
+ """An account with a window shorter than a week runs Luna only with this much of it left."""
50
+
51
+ WEEKLY_WINDOW_MINUTES: int = 10080
52
+ """Length of the weekly window. Any shorter window is a short window, such as 5 hours."""
53
+
54
+ FULL_PERCENT: float = 100.0
55
+ """Percent scale ceiling, so percent left is this minus percent used."""
56
+
57
+ TIER_NORMAL: str = "normal"
58
+ """Picker answer: run the job at its normal model on the named account."""
59
+
60
+ TIER_LUNA: str = "luna"
61
+ """Picker answer: every account is at or under the bar, so Luna runs the job."""
62
+
63
+ TIER_WAIT: str = "wait"
64
+ """Picker answer: no account has room, so the job waits."""
65
+
66
+ ALL_CODEX_BINARY_CANDIDATE_RELATIVE_PARTS: tuple[tuple[str, ...], ...] = (
67
+ ("AppData", "Local", "Programs", "OpenAI", "Codex", "bin", "codex.exe"),
68
+ )
69
+ """Install paths under the user home tried when ``codex`` is not on PATH."""
70
+
71
+ CODEX_BINARY_NAME: str = "codex"
72
+ """Codex command name looked up on PATH."""
73
+
74
+ ALL_APP_SERVER_ARGUMENTS: tuple[str, ...] = ("app-server", "--listen", "stdio://")
75
+ """Arguments that start Codex as a JSON-RPC server on standard input and output."""
76
+
77
+ APP_SERVER_TIMEOUT_SECONDS: float = 30.0
78
+ """Longest wait for the rate-limit reply before the read counts as failed."""
79
+
80
+ READER_JOIN_TIMEOUT_SECONDS: float = 2.0
81
+ """Longest wait for the output reader to finish after the server stops."""
82
+
83
+ PROCESS_WAIT_TIMEOUT_SECONDS: float = 5.0
84
+ """Longest wait to reap the server after its process tree is stopped."""
85
+
86
+ JSONRPC_VERSION: str = "2.0"
87
+ """JSON-RPC protocol version on every message."""
88
+
89
+ INITIALIZE_REQUEST_ID: int = 1
90
+ """Request id of the initialize handshake."""
91
+
92
+ RATE_LIMITS_REQUEST_ID: int = 2
93
+ """Request id of the rate-limit read."""
94
+
95
+ METHOD_INITIALIZE: str = "initialize"
96
+ """JSON-RPC method that opens the session."""
97
+
98
+ METHOD_INITIALIZED: str = "initialized"
99
+ """JSON-RPC notification that confirms the session opened."""
100
+
101
+ METHOD_RATE_LIMITS_READ: str = "account/rateLimits/read"
102
+ """JSON-RPC method that returns the account's rate-limit windows."""
103
+
104
+ CLIENT_NAME: str = "codex-account-choice"
105
+ """Client name the handshake reports."""
106
+
107
+ CLIENT_VERSION: str = "1.0.0"
108
+ """Client version the handshake reports."""
109
+
110
+ ALL_WINDOW_KEYS: tuple[str, ...] = ("primary", "secondary")
111
+ """Keys under ``rateLimits`` that each hold one usage window."""
112
+
113
+ EXIT_CODE_ROOM: int = 0
114
+ """Check answer: the account is above the floor."""
115
+
116
+ EXIT_CODE_NO_ROOM: int = 3
117
+ """Check answer: the account is at or below the floor, or its meter is unread."""
118
+
119
+ REASON_NORMAL_TEMPLATE: str = "{account} has {percent_left:.0f}% left"
120
+ """Reason when an account takes the job at its normal model."""
121
+
122
+ REASON_LUNA_TEMPLATE: str = (
123
+ "every account is at or under {bar:.0f}% left; {account} has"
124
+ " {percent_left:.0f}% and runs Luna until {stop:.0f}%"
125
+ )
126
+ """Reason when the job falls back to Luna."""
127
+
128
+ REASON_WAIT_TEMPLATE: str = "no account has room; {account} resets first, at {reset}"
129
+ """Reason when every account is out of room."""
130
+
131
+ REASON_WAIT_UNREAD: str = "no account meter could be read"
132
+ """Reason when no account's meter reads at all."""
133
+
134
+ UNREAD_NOT_SIGNED_IN: str = "not signed in"
135
+ """Unread reason when the account's Codex home holds no sign-in."""
136
+
137
+ UNKNOWN_RESET_TEXT: str = "an unknown time"
138
+ """Reset text when no blocking window carries a reset time."""
139
+
140
+ TEXT_ENCODING: str = "utf-8"
141
+ """Encoding for the server pipes and the JSON reports."""
@@ -1,6 +1,7 @@
1
- """Digests of the private organization names this public repository never names.
1
+ """Digests of the private names this public repository never names.
2
2
 
3
- The names stay out of the tree. Each entry holds the SHA-256 digest of one
3
+ The names cover private organizations, the owner's private repository, the
4
+ owner's personal handle, and a private client. They stay out of the tree. Each entry holds the SHA-256 digest of one
4
5
  name after normalization, which keeps only ASCII letters and digits in lower
5
6
  case::
6
7
 
@@ -31,6 +32,18 @@ ALL_PRIVATE_TERM_DIGESTS: frozenset[PrivateTermDigest] = frozenset(
31
32
  length=10,
32
33
  sha256="c2a70b3186691a74dece4ca824bec178bfaec83be119be28faedd14aa392233f",
33
34
  ),
35
+ PrivateTermDigest(
36
+ length=16,
37
+ sha256="182cb82e3e973d041b8527659064153f8c4af79503bd198508d3655d9a932fdf",
38
+ ),
39
+ PrivateTermDigest(
40
+ length=7,
41
+ sha256="d526db5593d5986500e4770cb6018f2d33c459ed93657487958a6086e52e6147",
42
+ ),
43
+ PrivateTermDigest(
44
+ length=7,
45
+ sha256="968e2d5b08687bf42997461cbdef6c844eabbf04f440cee888c95b864c2a4bcc",
46
+ ),
34
47
  }
35
48
  )
36
49
  PRIVATE_TERM_TEXT_ENCODING = "utf-8"
@@ -57,4 +70,6 @@ SHORT_SHA_LENGTH = 12
57
70
  COMMIT_FIELD_SEPARATOR = "\x1f"
58
71
  COMMIT_RECORD_SEPARATOR = "\x1e"
59
72
  COMMIT_FIELD_SPLIT_LIMIT = 2
73
+ GITHUB_NOREPLY_ADDRESS_PATTERN = r"<[^<>]*@users\.noreply\.github\.com>"
74
+ MASKED_ADDRESS = "<>"
60
75
  COMMIT_LOG_FORMAT = "--format=%H%x1f%an <%ae> %cn <%ce>%x1f%B%x1e"
@@ -12,6 +12,7 @@ from __future__ import annotations
12
12
 
13
13
  import argparse
14
14
  import json
15
+ import re
15
16
  import subprocess
16
17
  import sys
17
18
  from collections.abc import Sequence
@@ -28,6 +29,8 @@ from dev_env_scripts_constants.private_term_constants import (
28
29
  COMMIT_MESSAGE_PART,
29
30
  COMMIT_RECORD_SEPARATOR,
30
31
  FINDING_LINE_TEMPLATE,
32
+ GITHUB_NOREPLY_ADDRESS_PATTERN,
33
+ MASKED_ADDRESS,
31
34
  PRIVATE_TERM_MESSAGE_TEMPLATE,
32
35
  PRIVATE_TERM_TEXT_ENCODING,
33
36
  SHORT_SHA_LENGTH,
@@ -76,7 +79,7 @@ def _commit_findings(record: str) -> list[str]:
76
79
  return [
77
80
  *_findings_for(
78
81
  COMMIT_LABEL_TEMPLATE.format(short_sha=short_sha, part=COMMIT_IDENTITY_PART),
79
- identity,
82
+ re.sub(GITHUB_NOREPLY_ADDRESS_PATTERN, MASKED_ADDRESS, identity, flags=re.IGNORECASE),
80
83
  ),
81
84
  *_findings_for(
82
85
  COMMIT_LABEL_TEMPLATE.format(short_sha=short_sha, part=COMMIT_MESSAGE_PART),
@@ -94,7 +97,8 @@ def scan_commits(repository_root: Path, commit_range: str) -> list[str]:
94
97
 
95
98
  Returns:
96
99
  One finding per commit identity or message line that names a private
97
- organization.
100
+ organization. An identity address ending in users.noreply.github.com
101
+ is left out of the scan, since GitHub builds it from the account handle.
98
102
  """
99
103
  log_text = subprocess.run(
100
104
  ["git", "-C", str(repository_root), "log", "--reverse", COMMIT_LOG_FORMAT, commit_range],
@@ -13,6 +13,7 @@ CHECK_ID_PACKAGE_INVENTORY = "package-inventory"
13
13
  CHECK_ID_PYTEST_TESTPATHS = "pytest-testpaths"
14
14
  CHECK_ID_TRACKED_PERSONAL_DATA = "tracked-secrets"
15
15
  CHECK_ID_TRACKED_PRIVATE_TERMS = "tracked-private-terms"
16
+ PRIVATE_TERMS_SKIPPED_FILE_NAME = "CHANGELOG.md"
16
17
 
17
18
  ALL_CHECK_IDS = (
18
19
  CHECK_ID_CLAUDE_MD_ORPHANS,
@@ -48,11 +48,18 @@ def collect_tracked_private_term_findings(
48
48
  Findings that carry the path and line number, never the name. A
49
49
  repository whose github.com origin owner is itself a private
50
50
  organization returns none, since that organization may name itself.
51
+ A file named CHANGELOG.md is skipped, since release automation writes
52
+ it from commit subjects and history keeps it.
51
53
  """
52
54
  if _is_owned_by_private_organization(repository_root):
53
55
  return []
54
56
  all_findings: list[RepositoryFinding] = []
55
57
  for each_relative_path in all_tracked_paths:
58
+ if (
59
+ Path(each_relative_path).name
60
+ == repository_constants.PRIVATE_TERMS_SKIPPED_FILE_NAME
61
+ ):
62
+ continue
56
63
  if (content := _read_text_or_none(repository_root / each_relative_path)) is None:
57
64
  continue
58
65
  posix_relative_path = each_relative_path.replace(
@@ -76,6 +76,18 @@ class TestSyncProfile:
76
76
  profile_home / "settings.json", main_home / "settings.json"
77
77
  )
78
78
 
79
+ def should_link_only_what_the_callers_rule_shares(self, tmp_path: Path) -> None:
80
+ main_home = build_main_home(tmp_path)
81
+ profile_home = tmp_path / "codex-1"
82
+ report = profile.sync_profile(
83
+ main_home=main_home,
84
+ profile_home=profile_home,
85
+ now=NOW,
86
+ is_local=lambda entry_name: entry_name != "rules",
87
+ )
88
+ assert report.all_linked == ("rules",)
89
+ assert not (profile_home / "skills").exists()
90
+
79
91
  def should_keep_the_accounts_own_sign_in_state_and_history_apart(
80
92
  self, tmp_path: Path
81
93
  ) -> None: