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,79 @@
1
+ # Codex accounts
2
+
3
+ The codex_account_choice picker spreads agent work across up to four Codex
4
+ accounts on one machine. Each account signs in under
5
+ its own Codex home, and every home shares the same Codex setup: config, rules,
6
+ skills, plugins and prompts. A job asks the picker which account to use.
7
+
8
+ ## Pieces
9
+
10
+ | File | What it does |
11
+ |---|---|
12
+ | `scripts/codex_account_choice.py` | `choose` names the account and tier a job runs on, `check` tells a running job whether its account is still above a floor, `sync` links the shared setup into every account's home |
13
+ | `scripts/codex_account_meters.py` | Reads one account's rate-limit windows through `codex app-server` with `CODEX_HOME` set to that account's home |
14
+ | `scripts/dev_env_scripts_constants/codex_account_constants.py` | The account names in try order, the shared entry names, the 10% bar, the 1% Luna stop, and the 20% 5-hour floor for Luna |
15
+
16
+ ## Names and order
17
+
18
+ The accounts are `codex-1`, `codex-2`, `codex-3` and `codex-4`. Their homes are
19
+ `~/.codex-profiles/codex-1` and so on, or under `CODEX_PROFILES_ROOT` when set.
20
+ Jobs try them in that order. The names say nothing about a plan, so a plan change
21
+ keeps every name. To change the order, sign the accounts into different folders.
22
+
23
+ ## Sign in once
24
+
25
+ ```
26
+ python packages/claude-dev-env/scripts/codex_account_choice.py sync
27
+ ```
28
+
29
+ Then, for each account, sign in with that account's home:
30
+
31
+ ```
32
+ $env:CODEX_HOME = "$HOME\.codex-profiles\codex-1"; codex login
33
+ ```
34
+
35
+ The sign-in lives in that folder's `auth.json`. Only the entries in
36
+ `ALL_SHARED_CODEX_HOME_NAMES` link to `~/.codex`. Sign-in, sessions, history,
37
+ logs and state files stay per account.
38
+
39
+ ## Which account a job uses
40
+
41
+ Room is the smaller of an account's two windows: the 5-hour window and the week.
42
+
43
+ | Condition | Answer |
44
+ |---|---|
45
+ | First account in order with more than 10% left | `normal` on that account |
46
+ | No account over 10%, one or more over 1% | `luna` on the account with the most room, `stop_below_percent` 1 |
47
+ | An account that reports a 5-hour window, with under 20% of that window left | never takes `luna` |
48
+ | No account over 1% | `wait`, naming the account whose blocking windows reset first |
49
+ | Account not signed in, or its meter unread | skipped, with the reason in `accounts` |
50
+
51
+ ```
52
+ python packages/claude-dev-env/scripts/codex_account_choice.py choose
53
+ {"tier": "normal", "account": "codex-1", "codex_home": "...\\codex-1", "percent_left": 62.0,
54
+ "stop_below_percent": null, "reason": "codex-1 has 62% left", "accounts": [...]}
55
+ ```
56
+
57
+ A job runs Codex with `CODEX_HOME` set to `codex_home`. On `luna`, the job runs
58
+ Luna and polls `check` between steps:
59
+
60
+ ```
61
+ python packages/claude-dev-env/scripts/codex_account_choice.py check codex-2 --floor 1
62
+ ```
63
+
64
+ `check` exits 0 while the account has more than the floor left, and 3 once it has
65
+ not or its meter is unread. The job stops on 3.
66
+
67
+ ## Shared callers
68
+
69
+ Two scripts under `_shared` run `choose` by path. Each finds the picker at
70
+ `scripts/codex_account_choice.py` in the directory that holds `_shared`. Once
71
+ installed, that is `~/.claude/scripts/codex_account_choice.py`.
72
+
73
+ | Caller | What it does with the answer |
74
+ |---|---|
75
+ | `_shared/pr-loop/scripts/check_convergence.py` | Requires a Codex clean stamp on HEAD only on `normal`. `luna`, `wait`, or a failed picker skips the Codex gate |
76
+ | `_shared/advisor/scripts/codex_astra_advisor.py` | Binds Astra only on `normal`, and runs Codex with `CODEX_HOME` set to `codex_home` |
77
+
78
+ Pass `--codex-path` when `codex` is off PATH. Without it the picker also tries
79
+ the desktop install path under the user home.
@@ -172,7 +172,7 @@ def test_path_exempts_test_file_suffix() -> None:
172
172
 
173
173
  def test_path_does_not_exempt_production_module() -> None:
174
174
  assert not path_is_exempt(
175
- "shared_utils/samsung_utils/cert_failure_processor/failure_categorizer.py"
175
+ "shared_utils/vendor_utils/cert_failure_processor/failure_categorizer.py"
176
176
  )
177
177
 
178
178
 
@@ -241,7 +241,7 @@ def test_main_blocks_trio_copy_introduced_by_second_multi_edit() -> None:
241
241
  {
242
242
  "tool_name": "MultiEdit",
243
243
  "tool_input": {
244
- "file_path": "shared_utils/samsung_utils/cert_failure_processor/parser.py",
244
+ "file_path": "shared_utils/vendor_utils/cert_failure_processor/parser.py",
245
245
  "edits": [
246
246
  {"old_string": "a = 1", "new_string": "a = 11"},
247
247
  {"old_string": "b = 2", "new_string": COPIED_TRIO},
@@ -261,7 +261,7 @@ def test_main_allows_clean_multi_edit() -> None:
261
261
  {
262
262
  "tool_name": "MultiEdit",
263
263
  "tool_input": {
264
- "file_path": "shared_utils/samsung_utils/cert_failure_processor/parser.py",
264
+ "file_path": "shared_utils/vendor_utils/cert_failure_processor/parser.py",
265
265
  "edits": [
266
266
  {"old_string": "a = 1", "new_string": "a = 11"},
267
267
  {"old_string": "b = 2", "new_string": "b = 22"},
@@ -301,7 +301,7 @@ def test_main_blocks_local_trio_copy_in_production_module() -> None:
301
301
  "tool_name": "Write",
302
302
  "tool_input": {
303
303
  "file_path": (
304
- "shared_utils/samsung_utils/cert_failure_processor/failure_categorizer.py"
304
+ "shared_utils/vendor_utils/cert_failure_processor/failure_categorizer.py"
305
305
  ),
306
306
  "content": COPIED_TRIO,
307
307
  },
@@ -319,7 +319,7 @@ def test_main_allows_import_of_shared_helper() -> None:
319
319
  {
320
320
  "tool_name": "Write",
321
321
  "tool_input": {
322
- "file_path": "shared_utils/samsung_utils/cleanup.py",
322
+ "file_path": "shared_utils/vendor_utils/cleanup.py",
323
323
  "content": (
324
324
  "from shared_utils.web_automation.utils.windows_filesystem import "
325
325
  "force_rmtree\n\nforce_rmtree(path)\n"
@@ -24,7 +24,7 @@ SYNTHETIC_AWS_KEY = "AKIA" + ("B" * 16)
24
24
  SYNTHETIC_PEM_HEADER = "-----BEGIN RSA PRIVATE KEY-----"
25
25
  SYNTHETIC_REAL_EMAIL = "person.fixture@company-example.io"
26
26
  SYNTHETIC_SAFE_EMAIL = "user@example.com"
27
- SYNTHETIC_GITHUB_NOREPLY_EMAIL = "24366590+octocat@users.noreply.github.com"
27
+ SYNTHETIC_GITHUB_NOREPLY_EMAIL = "81234567+octocat@users.noreply.github.com"
28
28
  SYNTHETIC_HOME_PATH = r"C:\Users\fixture_real_user\notes.txt"
29
29
  SYNTHETIC_PLACEHOLDER_HOME = r"C:\Users\<you>\notes.txt"
30
30
  SYNTHETIC_PRIVATE_IP = "192.168.42.17"
@@ -20,7 +20,7 @@ HOOK_SCRIPT_PATH = HOOK_DIRECTORY / "pytest_testpaths_orphan_blocker.py"
20
20
 
21
21
  PYPROJECT_WITH_EXPLICIT_TESTPATHS = (
22
22
  "[tool.pytest.ini_options]\n"
23
- 'testpaths = [\n "tests",\n "samsung_utils/tests",\n]\n'
23
+ 'testpaths = [\n "tests",\n "vendor_utils/tests",\n]\n'
24
24
  'python_files = ["test_*.py"]\n'
25
25
  )
26
26
 
@@ -91,7 +91,7 @@ def test_find_flags_test_directory_absent_from_explicit_testpaths(tmp_path: Path
91
91
  def test_find_passes_test_directory_listed_in_testpaths(tmp_path: Path) -> None:
92
92
  package_root = tmp_path / "shared_utils"
93
93
  _write_package(package_root, PYPROJECT_WITH_EXPLICIT_TESTPATHS)
94
- registered_test_file = package_root / "samsung_utils" / "tests" / "test_normalizer.py"
94
+ registered_test_file = package_root / "vendor_utils" / "tests" / "test_normalizer.py"
95
95
  assert find_unregistered_test_directory(str(registered_test_file)) is None
96
96
 
97
97
 
@@ -28,3 +28,19 @@ for each_module_name in list(sys.modules):
28
28
  ):
29
29
  del sys.modules[each_module_name]
30
30
  importlib.invalidate_caches()
31
+
32
+
33
+ import pytest
34
+
35
+ from git_hooks_constants.verification_notice_constants import (
36
+ TARGET_REPOSITORY_ENVIRONMENT_VARIABLE,
37
+ )
38
+
39
+ EXAMPLE_TARGET_REPOSITORY = "example-owner/example-repo"
40
+
41
+
42
+ @pytest.fixture(autouse=True)
43
+ def configured_target_repository(monkeypatch: pytest.MonkeyPatch) -> str:
44
+ """Point the verification notice at the example repository for each test."""
45
+ monkeypatch.setenv(TARGET_REPOSITORY_ENVIRONMENT_VARIABLE, EXAMPLE_TARGET_REPOSITORY)
46
+ return EXAMPLE_TARGET_REPOSITORY
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  NOTICE_EVENT_COMMIT: str = "commit"
4
4
  NOTICE_EVENT_PUSH: str = "push"
5
5
  ALL_NOTICE_EVENTS: frozenset[str] = frozenset({NOTICE_EVENT_COMMIT, NOTICE_EVENT_PUSH})
6
- TARGET_REPOSITORY_REMOTE: str = "jonecho/python-automation"
6
+ TARGET_REPOSITORY_ENVIRONMENT_VARIABLE: str = "CLAUDE_VERIFICATION_TARGET_REPOSITORY"
7
7
  GIT_CONFIG_SUBCOMMAND: str = "config"
8
8
  GIT_REV_PARSE_SUBCOMMAND: str = "rev-parse"
9
9
  ALL_GIT_REMOTE_URL_QUERY: tuple[str, ...] = (GIT_CONFIG_SUBCOMMAND, "--get", "remote.origin.url")
@@ -27,7 +27,7 @@ def test_native_git_commit_notice_reports_the_new_head(tmp_path: Path) -> None:
27
27
  repository_path = create_native_repository(
28
28
  tmp_path,
29
29
  "repo",
30
- "https://github.com/JonEcho/python-automation.git",
30
+ "https://github.com/example-owner/example-repo.git",
31
31
  )
32
32
  hook_directory = tmp_path / "hooks"
33
33
  install_native_hook(repository_path, hook_directory, "post-commit", "post_commit")
@@ -44,7 +44,7 @@ def test_native_git_commit_allows_missing_setup_and_prints_advisory(tmp_path: Pa
44
44
  repository_path.mkdir()
45
45
  configure_native_repository(
46
46
  repository_path,
47
- "https://github.com/JonEcho/python-automation.git",
47
+ "https://github.com/example-owner/example-repo.git",
48
48
  )
49
49
  hook_directory = tmp_path / "hooks"
50
50
  install_native_hook(repository_path, hook_directory, "pre-commit", "pre_commit")
@@ -44,7 +44,7 @@ def test_native_git_push_to_local_bare_remote_allows_missing_setup(tmp_path: Pat
44
44
  repository_path = create_native_repository(
45
45
  tmp_path,
46
46
  "repo",
47
- "https://github.com/JonEcho/python-automation.git",
47
+ "https://github.com/example-owner/example-repo.git",
48
48
  initial_branch="main",
49
49
  )
50
50
  run_git(repository_path, "add", "README.md")
@@ -54,7 +54,7 @@ def test_native_git_push_to_local_bare_remote_allows_missing_setup(tmp_path: Pat
54
54
  configure_remote_url_rewrite(
55
55
  repository_path,
56
56
  bare_repository_path,
57
- "https://github.com/JonEcho/python-automation.git",
57
+ "https://github.com/example-owner/example-repo.git",
58
58
  )
59
59
  hook_directory = tmp_path / "hooks"
60
60
  install_native_hook(repository_path, hook_directory, "pre-push", "pre_push")
@@ -23,7 +23,7 @@ def build_context(
23
23
  repository_root: Path,
24
24
  *,
25
25
  event: str = "commit",
26
- repository_remote: str = "jonecho/python-automation",
26
+ repository_remote: str = "example-owner/example-repo",
27
27
  ) -> VerificationNoticeContext:
28
28
  git_directory = repository_root / ".git"
29
29
  return VerificationNoticeContext(
@@ -35,7 +35,6 @@ from git_hooks_constants.verification_notice_constants import (
35
35
  PYTHON_COMMAND,
36
36
  REPORT_FILE_NAME,
37
37
  SCOPED_VERIFICATION_SCRIPT_PATH,
38
- TARGET_REPOSITORY_REMOTE,
39
38
  UNKNOWN_SHA,
40
39
  )
41
40
 
@@ -49,6 +48,7 @@ from verification_notice_context import (
49
48
  VerificationNoticeContext,
50
49
  _load_notice_context,
51
50
  _run_git_query,
51
+ is_target_repository,
52
52
  )
53
53
  from verification_start import load_runner_configuration, start_automatic_advisory
54
54
  from verification_notice_state import (
@@ -71,7 +71,7 @@ def build_verification_notice(context: VerificationNoticeContext) -> str:
71
71
  Returns:
72
72
  The formatted advisory text, or an empty string for another repository.
73
73
  """
74
- if context.repository_remote != TARGET_REPOSITORY_REMOTE:
74
+ if not is_target_repository(context.repository_remote):
75
75
  return ""
76
76
  current_head = context.current_head or UNKNOWN_SHA
77
77
  state, verified_head = _evaluate_context(context, _run_git_query)
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import os
6
7
  import subprocess
7
8
  from collections.abc import Mapping
8
9
  from dataclasses import dataclass
@@ -32,7 +33,7 @@ from git_hooks_constants.verification_notice_constants import (
32
33
  REPOSITORY_PART_INDEX,
33
34
  SELECTED_MANIFEST_PATH_FIELD,
34
35
  SELECTION_FIELD,
35
- TARGET_REPOSITORY_REMOTE,
36
+ TARGET_REPOSITORY_ENVIRONMENT_VARIABLE,
36
37
  )
37
38
  from local_verification.manifest import ManifestRunFatal, load_manifest
38
39
 
@@ -52,6 +53,24 @@ class VerificationNoticeContext:
52
53
  all_report_fields: Mapping[str, object] | None
53
54
 
54
55
 
56
+ def is_target_repository(repository_remote: str | None) -> bool:
57
+ """Report whether a normalized remote names the configured target repository.
58
+
59
+ The target is read from the environment at call time and has no default,
60
+ so an unset or blank setting matches no repository.
61
+
62
+ Args:
63
+ repository_remote: The normalized owner/name remote, or None.
64
+
65
+ Returns:
66
+ True when the remote equals the configured target, case-insensitively.
67
+ """
68
+ configured_target = os.environ.get(TARGET_REPOSITORY_ENVIRONMENT_VARIABLE, "").strip()
69
+ if not configured_target or repository_remote is None:
70
+ return False
71
+ return repository_remote.casefold() == configured_target.casefold()
72
+
73
+
55
74
  def normalize_repository_remote(remote_url: str) -> str | None:
56
75
  """Return an owner/repository slug from common Git remote URL forms.
57
76
 
@@ -85,7 +104,7 @@ def _load_notice_context(
85
104
  normalized_remote = normalize_repository_remote(
86
105
  _run_git_query(repository_root, ALL_GIT_REMOTE_URL_QUERY, False) or ""
87
106
  )
88
- if normalized_remote != TARGET_REPOSITORY_REMOTE or current_head is None:
107
+ if not is_target_repository(normalized_remote) or current_head is None:
89
108
  return None
90
109
  if not git_directory_text:
91
110
  return None
@@ -20,9 +20,8 @@ from git_hooks_constants.verification_notice_constants import (
20
20
  RUNNER_FILE_NAME,
21
21
  RUNNER_PYTHON_FIELD,
22
22
  RUNNER_SETTINGS_FIELD,
23
- TARGET_REPOSITORY_REMOTE,
24
23
  )
25
- from verification_notice_context import VerificationNoticeContext
24
+ from verification_notice_context import VerificationNoticeContext, is_target_repository
26
25
 
27
26
  _subprocess_window_hooks_directory = str(Path(__file__).resolve().parents[2] / "hooks")
28
27
  if _subprocess_window_hooks_directory not in sys.path:
@@ -33,7 +32,7 @@ from hooks_constants.subprocess_window import hidden_window_creation_flags
33
32
 
34
33
  def start_automatic_advisory(context: VerificationNoticeContext) -> None:
35
34
  """Start the detached advisory poller when native metadata is valid."""
36
- if context.repository_remote != TARGET_REPOSITORY_REMOTE:
35
+ if not is_target_repository(context.repository_remote):
37
36
  return
38
37
  if context.event not in ALL_NOTICE_EVENTS:
39
38
  return
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.12.0",
3
+ "version": "8.14.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -67,9 +67,10 @@ server write. Pass the matching action and body file. Use `pr-create`,
67
67
 
68
68
  Pass `--repository <owner>/<name>` for the repository the post targets. A
69
69
  post may name a private organization only inside a repository that
70
- organization owns. The linter holds the names as digests, so it reports the
71
- line that names one without printing the name. Describe the organization in
72
- general terms and drop the link.
70
+ organization owns. The same digests cover the owner's private repository, the
71
+ owner's personal handle, and a private client. The linter holds the names as
72
+ digests, so it reports the line that names one without printing the name.
73
+ Describe the name in general terms and drop the link.
73
74
 
74
75
  The linter reads the body file and reports a volatile local path without
75
76
  printing the body. Fix the body and rerun the linter before posting.
@@ -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 is_account_local(each_entry.name):
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, profile_home: Path, replaced_directory: 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 is_account_local(each_source.name) or links_to(entry_path, each_source):
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
- *, main_home: Path, profile_home: Path, now: datetime
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 Claude home.
264
- profile_home: The second account's Claude home.
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, profile_home, profile_home / REPLACED_DIRECTORY_NAME / _stamp(now)
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,