claude-dev-env 8.15.0 → 8.16.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 (115) hide show
  1. package/.agents/agents/test_agent_frontmatter.py +0 -72
  2. package/.agents/agents-archived/code-quality-agent.md +16 -16
  3. package/.agents/skills/_shared/end-of-run-gotcha-recommendations.md +5 -21
  4. package/.agents/skills/e-code-review/SKILL.md +1 -3
  5. package/.agents/skills/e-simplify/SKILL.md +6 -6
  6. package/.agents/skills/eli5/SKILL.md +4 -14
  7. package/.agents/skills/eli5/reference/task-seeds.md +3 -4
  8. package/.agents/skills/everything-search/SKILL.md +2 -2
  9. package/.agents/skills/fresh-branch/SKILL.md +2 -3
  10. package/.agents/skills/grok-spawn/SKILL.md +3 -8
  11. package/.agents/skills/issue-tracker/SKILL.md +4 -6
  12. package/.agents/skills/orchestrator-refresh/SKILL.md +7 -7
  13. package/.agents/skills/plugin-eval-standalone-skill/SKILL.md +3 -3
  14. package/.agents/skills/privacy-hygiene/SKILL.md +6 -10
  15. package/.agents/skills/privacy-hygiene/reference/sweep-procedure.md +1 -1
  16. package/.agents/skills/pull-request/SKILL.md +11 -14
  17. package/.agents/skills/pull-request/reference/publication-tasks.md +2 -4
  18. package/.agents/skills/recovering-codex-startup/SKILL.md +3 -6
  19. package/.agents/skills/recovering-codex-startup/reference/task-seeds.md +1 -1
  20. package/.agents/skills/repairing-hook-boundaries/SKILL.md +4 -7
  21. package/.agents/skills/repairing-hook-boundaries/reference/task-seeds.md +1 -2
  22. package/.agents/skills/second-account-workers/SKILL.md +26 -8
  23. package/.agents/skills/syncing-submodules/SKILL.md +7 -17
  24. package/.agents/skills/syncing-submodules/reference/run-tasks.md +1 -1
  25. package/.agents/skills/task-build/SKILL.md +1 -1
  26. package/.agents/skills/task-build/reference/tool-routing.md +1 -1
  27. package/.agents/skills/team-advisor/SKILL.md +2 -4
  28. package/.agents/skills/usage-pause/SKILL.md +1 -1
  29. package/.agents/skills/windows-scheduled-task/SKILL.md +1 -1
  30. package/AGENTS.md +1 -1
  31. package/_shared/advisor/advisor-protocol.md +4 -4
  32. package/_shared/advisor/reference/consult-format.md +1 -1
  33. package/_shared/advisor/reference/identity.md +2 -2
  34. package/_shared/advisor/reference/warm-up.md +1 -1
  35. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  36. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +2 -16
  37. package/bin/install.mjs +1 -0
  38. package/bin/install.prompt-context.test.mjs +7 -3
  39. package/bin/install.test.mjs +3 -2
  40. package/commands/sr-loop.md +2 -2
  41. package/docs/CODE_RULES.md +2 -2
  42. package/docs/DJANGO_PATTERNS.md +3 -3
  43. package/docs/REACT_PATTERNS.md +6 -6
  44. package/docs/TEST_QUALITY.md +10 -16
  45. package/docs/agent-spawn-protocol.md +4 -4
  46. package/docs/codex-compatibility.md +4 -33
  47. package/docs/references/team-advisor-skill.md +0 -1
  48. package/docs/worker-completion-gate.md +0 -1
  49. package/hooks/advisory/migration_safety_advisor.py +3 -2
  50. package/hooks/advisory/refactor_guard.py +3 -3
  51. package/hooks/hooks.json +1 -1
  52. package/hooks/hooks_constants/issue_tracker_session_starter_constants.py +1 -2
  53. package/hooks/hooks_constants/orchestrator_auto_starter_constants.py +1 -2
  54. package/package.json +1 -1
  55. package/rules/explore-thoroughly.md +0 -1
  56. package/rules/filesystem-search.md +1 -1
  57. package/rules/git-workflow.md +5 -5
  58. package/rules/long-horizon-autonomy.md +1 -5
  59. package/rules/re-stage-before-commit.md +0 -2
  60. package/rules/shell-invocation.md +1 -1
  61. package/scripts/claude_account_choice.py +183 -52
  62. package/scripts/claude_account_profile.py +81 -49
  63. package/scripts/claude_account_worker.py +48 -15
  64. package/scripts/dev_env_scripts_constants/claude_account_constants.py +36 -1
  65. package/scripts/dev_env_scripts_constants/claude_account_worker_constants.py +2 -0
  66. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +8 -2
  67. package/scripts/policy_lint/config/constants.py +1 -0
  68. package/scripts/policy_lint/engine.py +5 -2
  69. package/scripts/repository_checks/config/constants.py +1 -1
  70. package/scripts/spawn_grok_batch.py +12 -1
  71. package/scripts/test_claude_account_choice.py +127 -17
  72. package/scripts/test_claude_account_profile.py +63 -16
  73. package/scripts/test_claude_account_worker.py +118 -0
  74. package/scripts/test_spawn_grok_batch.py +22 -0
  75. package/scripts/tests/test_banned_prose_words.py +1 -1
  76. package/scripts/tests/test_policy_lint_rules_archive.py +16 -0
  77. package/scripts/tests/test_policy_lint_rules_engine.py +2 -2
  78. package/scripts/tests/test_rules.py +9 -9
  79. package/system-prompts/software-engineer.xml +15 -30
  80. package/audit-rubrics/audit-categories.json +0 -700
  81. package/audit-rubrics/category_rubrics/category-a-api-contracts.md +0 -90
  82. package/audit-rubrics/category_rubrics/category-b-selector-engine-compat.md +0 -36
  83. package/audit-rubrics/category_rubrics/category-c-resource-cleanup.md +0 -35
  84. package/audit-rubrics/category_rubrics/category-d-scoping-and-ordering.md +0 -35
  85. package/audit-rubrics/category_rubrics/category-e-dead-code.md +0 -40
  86. package/audit-rubrics/category_rubrics/category-f-silent-failures.md +0 -41
  87. package/audit-rubrics/category_rubrics/category-g-bounds-and-overflow.md +0 -38
  88. package/audit-rubrics/category_rubrics/category-h-security-boundaries.md +0 -40
  89. package/audit-rubrics/category_rubrics/category-i-concurrency.md +0 -38
  90. package/audit-rubrics/category_rubrics/category-j-code-rules-compliance.md +0 -59
  91. package/audit-rubrics/category_rubrics/category-k-codebase-conflicts.md +0 -66
  92. package/audit-rubrics/category_rubrics/category-l-behavior-equivalence.md +0 -48
  93. package/audit-rubrics/category_rubrics/category-m-producer-consumer-cardinality.md +0 -44
  94. package/audit-rubrics/category_rubrics/category-n-test-name-scenario-verifier.md +0 -54
  95. package/audit-rubrics/category_rubrics/category-p-name-vs-behavior-contract.md +0 -59
  96. package/audit-rubrics/category_rubrics/category-q-cross-surface-claims.md +0 -46
  97. package/audit-rubrics/prompts/category-a-api-contracts.md +0 -407
  98. package/audit-rubrics/prompts/category-b-selector-engine-compat.md +0 -401
  99. package/audit-rubrics/prompts/category-c-resource-cleanup.md +0 -420
  100. package/audit-rubrics/prompts/category-d-scoping-and-ordering.md +0 -414
  101. package/audit-rubrics/prompts/category-e-dead-code.md +0 -434
  102. package/audit-rubrics/prompts/category-f-silent-failures.md +0 -432
  103. package/audit-rubrics/prompts/category-g-bounds-and-overflow.md +0 -383
  104. package/audit-rubrics/prompts/category-h-security-boundaries.md +0 -423
  105. package/audit-rubrics/prompts/category-i-concurrency.md +0 -429
  106. package/audit-rubrics/prompts/category-j-code-rules-compliance.md +0 -458
  107. package/audit-rubrics/prompts/category-k-codebase-conflicts.md +0 -338
  108. package/audit-rubrics/prompts/category-l-behavior-equivalence.md +0 -131
  109. package/audit-rubrics/prompts/category-m-producer-consumer-cardinality.md +0 -129
  110. package/audit-rubrics/prompts/category-n-test-name-scenario-verifier.md +0 -139
  111. package/audit-rubrics/prompts/category-p-name-vs-behavior-contract.md +0 -89
  112. package/audit-rubrics/prompts/category-q-cross-surface-claims.md +0 -51
  113. package/hooks/hooks_constants/style_reminder_prompt_constants.py +0 -9
  114. package/hooks/session/style_reminder_prompt.py +0 -55
  115. package/hooks/session/test_style_reminder_prompt.py +0 -86
@@ -4,37 +4,31 @@
4
4
 
5
5
  ## Test Infrastructure Anti-Patterns
6
6
 
7
- Test helpers drift toward over-engineering faster than production code, because nothing reviews them for size. Keep a helper to one file of plain functions with the fewest abstractions the current tests need. Add a package, a cache class, or an abstraction only when a second test needs it today.
8
-
9
- **Pre-check (MANDATORY):**
10
- - [ ] ONE file?
11
- - [ ] Functions not classes?
12
- - [ ] Solving problem not building infrastructure?
13
- - [ ] Junior-dev-understandable?
7
+ Test helpers drift toward over-engineering faster than production code, because nothing reviews them for size. Keep a helper to one file of plain functions with the fewest abstractions the current tests need. Add a package, a cache class, or an abstraction only when a second test needs it today. A junior developer reads the helper on the first pass.
14
8
 
15
9
  ## Delete Useless Tests
16
10
 
17
11
  **Tests must add value.** Delete these types of tests:
18
12
 
19
- ### NEVER test:
13
+ ### Tests to delete
20
14
 
21
15
  **Function existence** - "Testing that the function exists doesn't add value. If the function doesn't exist, the code will not run."
22
16
  - Bad: `test_public_api_exports_download_function()` only verified `callable(func)`
23
- - Action: DELETE
17
+ - Action: delete
24
18
 
25
19
  **Constant values** - "It's silly to test that constant values have not changed."
26
20
  - Bad: `assert CACHE_DIR == "cache"`
27
- - Action: DELETE
21
+ - Action: delete
28
22
 
29
23
  **Duplicate coverage** - "Isn't this test case the same as test_X?"
30
- - Action: DELETE redundant tests
24
+ - Action: delete the redundant test
31
25
 
32
- ## Test Dependencies MUST FAIL
26
+ ## Missing dependencies fail the test
33
27
 
34
28
  "The tests should fail if they can't run. Missing system dependencies should make the test fail."
35
29
 
36
- - **NEVER** use `@skip_if_missing_dependency` or similar skip decorators
37
- - Tests FAIL with clear error -> forces installation
30
+ - Leave `@skip_if_missing_dependency` and other skip decorators out of test files. The code-rules lint flags each one.
31
+ - A missing dependency fails the test with a clear error, which prompts the install.
38
32
 
39
33
  ## Core Testing Principles
40
34
 
@@ -46,7 +40,7 @@ Test helpers drift toward over-engineering faster than production code, because
46
40
 
47
41
  ## React Testing Patterns
48
42
 
49
- ### ALWAYS:
43
+ ### Patterns
50
44
 
51
45
  **Test behavior, not implementation** - What user sees/does, not internal state
52
46
  - Bad: `expect(component.state.isOpen).toBe(true)`
@@ -65,7 +59,7 @@ Test helpers drift toward over-engineering faster than production code, because
65
59
  - Use `userEvent` over `fireEvent` (more realistic)
66
60
  - `await userEvent.click(button)` then assert result
67
61
 
68
- ### NEVER:
62
+ ### Anti-patterns
69
63
 
70
64
  **Snapshot test everything** - Only for stable, visual components
71
65
  - Snapshots break on any change, creating noise
@@ -13,9 +13,9 @@ Before writing any agent prompt, confirm you can answer all of these:
13
13
 
14
14
  When any answer is "I don't know", investigate first (read files, search code) or ask the user. Do not spawn with incomplete context.
15
15
 
16
- ## Step 2: Craft the prompt with /prompt-generator
16
+ ## Step 2: Write the prompt
17
17
 
18
- Run the `/prompt-generator` skill to produce a structured prompt. Feed it:
18
+ Write the brief yourself. Include:
19
19
 
20
20
  - The task description and goal
21
21
  - Target files and directories found in Step 1
@@ -23,7 +23,7 @@ Run the `/prompt-generator` skill to produce a structured prompt. Feed it:
23
23
  - Expected output format
24
24
  - Acceptance criteria
25
25
 
26
- The skill asks one to three clarifying questions when information is missing — this is the built-in context verification. Use the skill's output as the agent's `prompt` parameter.
26
+ Use the brief as the agent's `prompt` parameter.
27
27
 
28
28
  ## Step 3: Spawn the agent
29
29
 
@@ -31,7 +31,7 @@ Pass the structured prompt from Step 2 to the Agent or Task tool.
31
31
 
32
32
  ## Why
33
33
 
34
- An agent that receives a vague prompt wastes tokens exploring in circles, produces code that misses constraints, and needs a second pass. A small investment in prompt quality through `/prompt-generator` saves a long agent failure. This holds for Explore agents, which waste context on unfocused searches, and for execution agents, which write wrong code.
34
+ An agent that receives a vague prompt wastes tokens exploring in circles, produces code that misses constraints, and needs a second pass. A small investment in prompt quality saves a long agent failure. This holds for Explore agents, which waste context on unfocused searches, and for execution agents, which write wrong code.
35
35
 
36
36
  ## Relationship to other rules
37
37
 
@@ -36,39 +36,10 @@ The default `.claude` target uses `CODEX_HOME`. A named profile or another
36
36
  target uses `<managed-root>/.codex`, so launch that profile with the same
37
37
  `CODEX_HOME` value.
38
38
 
39
- ## Per-message context injection (manual wiring)
40
-
41
- Codex reads the same `hookSpecificOutput.additionalContext` shape as Claude,
42
- on its own `UserPromptSubmit` event. `hooks/session/style_reminder_prompt.py`
43
- works for both, as is.
44
-
45
- The install already points `$CODEX_HOME/hooks` at the shared hooks folder.
46
- Once installed, the script sits at
47
- `$CODEX_HOME/hooks/session/style_reminder_prompt.py`.
48
-
49
- Add this by hand to `$CODEX_HOME/hooks.json` (merge it into an existing file):
50
-
51
- ```json
52
- {
53
- "hooks": {
54
- "UserPromptSubmit": [
55
- {
56
- "command": "python3 $CODEX_HOME/hooks/session/style_reminder_prompt.py"
57
- }
58
- ]
59
- }
60
- }
61
- ```
62
-
63
- The package installer does not register this optional context hook. Add it by
64
- hand when the host should receive that context.
65
-
66
- ## Luna fast-mode guard
67
-
68
- Commit `0f21faf8` retired `luna_fast_mode_gate.py` with the other blocking
69
- policy hooks. The package ships no replacement, so a Codex `hooks.json` needs
70
- no entry for it, and the spawn tier a Luna spawn asks for is the caller's to
71
- hold.
39
+ ## Luna spawn tier
40
+
41
+ No hook gates the tier a Luna spawn asks for, so a Codex `hooks.json` needs no
42
+ entry for it, and the caller holds that tier.
72
43
 
73
44
  ## Roots and safety
74
45
 
@@ -9,7 +9,6 @@
9
9
  | `.agents/skills/team-advisor/SKILL.md` | Sole-consumer bind wiring and constraints |
10
10
  | `advisor-tool.md` | Consult timing, hard rule, how to treat advice |
11
11
  | `~/.claude/_shared/advisor/advisor-protocol.md` | Session identity, host bind, floor, lifecycle |
12
- | `agents/session-advisor.md` | ENDORSE / CORRECTION / PLAN / STOP |
13
12
 
14
13
  ## When to use
15
14
 
@@ -30,4 +30,3 @@ Mark `completed` only when every row passes.
30
30
  ## Relationship to other rules
31
31
 
32
32
  - `long-horizon-autonomy` covers acting on what you have and not ending a turn on a promise. This gate names the specific completion condition: workers finished and their results merged.
33
- - `skills/pr-converge/reference/state-schema.md` defines the run-state records a worker's result lands in before the task closes.
@@ -52,8 +52,9 @@ def main() -> None:
52
52
  operations = ", ".join(found_unsafe)
53
53
  advisory_message = (
54
54
  f"MIGRATION SAFETY: Contains {operations}. "
55
- "Post-launch, model changes MUST be backwards-compatible. "
56
- "Verify this won't break running instances during deployment."
55
+ "After launch, model changes stay backwards-compatible, because running "
56
+ "instances keep reading the old schema until the deployment finishes. "
57
+ "Check that this migration keeps those instances working."
57
58
  )
58
59
  advisory_payload = {
59
60
  "systemMessage": advisory_message,
@@ -264,9 +264,9 @@ def build_refactor_advisory_context(refactor_description: str, file_path: str) -
264
264
  """Build guidance that names the Edit stage and changed-surface rule."""
265
265
  return (
266
266
  f"[HOOK ADVISORY] Refactor guard — {refactor_description} in {file_path}. "
267
- "Edit-stage guidance: Only modify lines already changed in the current git diff. "
268
- "Ask the user for explicit approval first. If the user approves, "
269
- "create the bypass token then retry."
267
+ "Edit-stage guidance: This edit went through, and it changes lines outside the "
268
+ "current git diff. Keep further edits to lines the task already changes, and ask "
269
+ "the user before refactoring code outside them."
270
270
  )
271
271
 
272
272
 
package/hooks/hooks.json CHANGED
@@ -69,7 +69,7 @@
69
69
  "hooks": [
70
70
  {
71
71
  "type": "command",
72
- "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/style_reminder_prompt.py --include-skill-reminder",
72
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/skill_loaded_reminder.py",
73
73
  "timeout": 10
74
74
  }
75
75
  ]
@@ -18,6 +18,5 @@ ISSUE_TRACKER_STARTER_TIMEOUT_MILLISECONDS: int = 50
18
18
  ISSUE_TRACKER_SESSION_START_DIRECTIVE: str = (
19
19
  "SessionStart issue-tracker opt-in is active for this registered repository. "
20
20
  "Load the issue-tracker skill when the session needs one GitHub issue action "
21
- "(open epic, file sub-issue, update status, refresh checklist, close sub-issue). "
22
- "Manual issue-tracker agent spawn remains available unchanged."
21
+ "(open epic, file sub-issue, update status, refresh checklist, close sub-issue)."
23
22
  )
@@ -18,6 +18,5 @@ ORCHESTRATOR_STARTER_TIMEOUT_MILLISECONDS: int = 50
18
18
  ORCHESTRATOR_SESSION_START_DIRECTIVE: str = (
19
19
  "SessionStart orchestrator opt-in is active. Load the orchestrator skill "
20
20
  "and run in executor-advisor mode for this session's multi-step work. "
21
- "Do not invent a second orchestrator path; follow the skill. Manual "
22
- "/orchestrator remains available unchanged."
21
+ "Do not invent a second orchestrator path; follow the skill."
23
22
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.15.0",
3
+ "version": "8.16.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,7 +9,6 @@ Note: This deliberately chooses exploration depth over the "commit and execute q
9
9
  - Read the relevant files. Understand what exists before proposing what to change.
10
10
  - Map the existing patterns: naming conventions, file organization, architectural decisions.
11
11
  - Identify constraints that could invalidate an approach before investing effort in it.
12
- - For unfamiliar codebases or high-stakes changes, invest more time exploring than feels necessary.
13
12
 
14
13
  ## Exploration scales with risk
15
14
 
@@ -47,5 +47,5 @@ Issue one shell search at a time when the walk is large. Parallel full-tree sear
47
47
 
48
48
  ## Search controls
49
49
 
50
- - No hook denies a walk from an unscoped root. `unscoped_search_blocker` went with the blocking policy hooks in `0f21faf8e`, which left the Bash PreToolUse roster empty. The scope invariant above is guidance a reader follows.
50
+ - No hook denies a walk from an unscoped root. The scope invariant above is guidance a reader follows.
51
51
  - For Everything searches that use a project name, run `python "${CLAUDE_SKILL_DIR}/scripts/everything_search.py" <project-name> <search arguments>`. An exact project name becomes its path from `~/.claude/project-paths.json`. The command then starts `es.exe` without a shell. `scripts/setup_project_paths.py` writes the registry.
@@ -15,7 +15,7 @@ User-level rule: applies to **every** git repo that uses GitHub with `gh`. Small
15
15
 
16
16
  ## Pull request submission rules
17
17
 
18
- **ALWAYS create PRs as DRAFT:** Use `gh pr create --draft` for ALL PRs
18
+ **Create every pull request as a draft.** Use `gh pr create --draft`.
19
19
 
20
20
  **A release bot's PR body is machine input. Leave it alone.** Release automation reads
21
21
  back the body of its own merged pull request to decide it owns that merge. Rewriting the
@@ -47,7 +47,7 @@ untouched.
47
47
 
48
48
  ## Git golden rules
49
49
 
50
- 1. **DRAFT BEFORE PUSH**: When pushing ANYTHING to a PR, it MUST be in draft state first
50
+ 1. **Draft before push.** Put a pull request in draft state before you push to it.
51
51
  - Before push: `gh pr ready --undo`
52
52
  - After review approved: `gh pr ready`
53
53
 
@@ -101,7 +101,7 @@ before you ask anyone to read or merge it.
101
101
 
102
102
  ## Never commit working documents or images
103
103
 
104
- **NEVER commit these files to the repo:**
104
+ **Keep these files out of the repository:**
105
105
 
106
106
  | Pattern | Reason |
107
107
  |---------|--------|
@@ -117,10 +117,10 @@ An image a PR needs as visual evidence is not an exception to that row. Upload i
117
117
  **When this applies:** GitHub PR review feedback on a branch you are fixing.
118
118
 
119
119
  1. Fetch every reviewer comment before making any fix.
120
- 2. Create a TodoWrite checklist with one item per comment.
120
+ 2. Create a checklist in the session's task tool with one item per comment.
121
121
  3. Fix systematically, marking each todo complete.
122
122
  4. Reply to each comment inline.
123
123
 
124
- Repair only reported findings, then re-verify after every repair.
124
+ Repair only the reported findings.
125
125
 
126
126
  Every `gh` post in this workflow uses `--body-file` per `gh-cli-conventions.md` and keeps volatile scratch paths out per `durable-post-artifacts.md`. Stage session edits per `re-stage-before-commit.md` before each commit.
@@ -35,11 +35,7 @@ Authority the task already granted stays granted. A later preference about tone,
35
35
 
36
36
  ## Delegate and keep working
37
37
 
38
- Hand independent subtasks to subagents and keep working while they run; let them run in the background rather than block until each one returns. Reuse a long-lived subagent across related subtasks so its context carries forward and saves repeated reads. Step in when a subagent drifts off track or is missing context.
39
-
40
- ## Verify your work at intervals
41
-
42
- On a long build, set a checkpoint cadence and hold to it. At each interval, check the work so far against the task's stated goals with a fresh-context verifier subagent. A separate verifier in a clean context catches what self-review misses.
38
+ Delegate a large, independent track to a subagent, and keep working while it runs in the background. Do work you can finish in a few tool calls yourself: a few reads, a handful of edits, or a simple check. Reuse a long-lived subagent across related subtasks so its context carries forward and saves repeated reads. Step in when a subagent drifts off track or is missing context.
43
39
 
44
40
  ## Ground every progress claim
45
41
 
@@ -12,5 +12,3 @@ Staging covers tracked files you edited. Do not commit untracked files unless th
12
12
  - **A preceding `git add` or `git stage`.** `git add <paths> && git commit …` stages the files in its own segment before the commit runs.
13
13
 
14
14
  A `--amend` carries the same risk. An amend records the staged snapshot too, so an unstaged session edit is dropped the same way a plain commit drops it.
15
-
16
- `session_file_edit_tracker` (PostToolUse) still records each Write/Edit/MultiEdit path, and `session_edit_tracker_cleanup` (SessionStart, SessionEnd) still clears the session's tracker. No gate reads that record today, so it serves as history rather than a precondition.
@@ -18,7 +18,7 @@ When a script file's literal body needs `$(...)`, author it with the Write tool,
18
18
 
19
19
  ## Enforcement
20
20
 
21
- No PreToolUse hook denies a Bash command. Commit `0f21faf8e` retired the blocking policy hooks and left the Bash PreToolUse roster empty. `shell_substitution_blocker.py` was one of them. The substitution constraint above is guidance a reader follows, and a permission prompt on a wrapped command is the signal that one slipped through.
21
+ No PreToolUse hook denies a Bash command. The substitution constraint above is guidance a reader follows, and a permission prompt on a wrapped command is the signal that one slipped through.
22
22
 
23
23
  One PreToolUse hook does run on a Bash command, and it only rewrites. `blocking/msys_rev_path_rewriter.py` (PreToolUse on Bash, hosted by `bash_pre_tool_use_dispatcher`) reads a git command before it runs and keeps Git Bash from converting a `<rev>:<path>` argument. That roster holds this one hook, and a test asserts its whole content, so a blocking hook added beside it fails the suite.
24
24
 
@@ -1,21 +1,8 @@
1
1
  #!/usr/bin/env python3
2
- """Name the Claude account a runner job uses, protecting the main account.
2
+ """Choose a Claude account for a runner job while protecting the main account.
3
3
 
4
- The main account is the one its owner works on, so a job borrows it only to
5
- spend leftover usage that expires soon. Every other job goes to the second
6
- account while that account has room, and waits when it has none::
7
-
8
- main week resets in 5 hours, 20% left, 5-hour window 10% used -> main
9
- main week resets in 3 days -> second
10
- second at 99% of its week, main not spendable -> wait
11
- main meter unread -> never main
12
-
13
- ::
14
-
15
- python claude_account_choice.py --main-config-dir C:/Users/me/.claude
16
- --second-config-dir C:/Users/me/.claude-profiles/ev
17
- {"account": "second", "config_dir": "C:/Users/me/.claude-profiles/ev",
18
- "reason": "second account has 60% of its week and 90% of its 5-hour window left"}
4
+ The main account takes a job only to spend usage that expires soon. Otherwise,
5
+ the picker tries extra profiles in order and waits when every one is full.
19
6
  """
20
7
 
21
8
  from __future__ import annotations
@@ -23,6 +10,7 @@ from __future__ import annotations
23
10
  import argparse
24
11
  import json
25
12
  import sys
13
+ from collections.abc import Sequence
26
14
  from dataclasses import dataclass
27
15
  from datetime import datetime
28
16
  from pathlib import Path
@@ -52,6 +40,9 @@ from dev_env_scripts_constants.claude_account_constants import (
52
40
  MAIN_SPEND_WINDOW,
53
41
  MAIN_WEEKLY_USED_CEILING_PERCENT,
54
42
  REASON_MAIN_EXPIRING_TEMPLATE,
43
+ REASON_ALL_EXTRAS_WAIT_TEMPLATE,
44
+ REASON_EXTRA_HAS_ROOM_TEMPLATE,
45
+ REASON_EXTRA_UNREAD_TEMPLATE,
55
46
  REASON_SECOND_HAS_ROOM_TEMPLATE,
56
47
  REASON_SECOND_UNREAD,
57
48
  REASON_WAIT_TEMPLATE,
@@ -70,6 +61,15 @@ class AccountDecision:
70
61
  reason: str
71
62
 
72
63
 
64
+ @dataclass(frozen=True)
65
+ class ExtraAccount:
66
+ """One ordered extra profile and its usage reading."""
67
+
68
+ name: str
69
+ meters: AccountUsageMeters | None
70
+ config_dir: Path | None = None
71
+
72
+
73
73
  def _main_spendable_reason(
74
74
  main_meters: AccountUsageMeters | None, now: datetime
75
75
  ) -> str | None:
@@ -92,14 +92,23 @@ def _main_spendable_reason(
92
92
  )
93
93
 
94
94
 
95
- def _wait_decision(
96
- second_meters: AccountUsageMeters, *, is_week_blocked: bool
97
- ) -> AccountDecision:
98
- blocking_reset = (
99
- second_meters.weekly_resets_at
100
- if is_week_blocked
101
- else second_meters.session_resets_at
102
- )
95
+ def _blocking_reset(meters: AccountUsageMeters) -> datetime | None:
96
+ all_blocking_resets: list[datetime] = []
97
+ weekly_used = meters.weekly_utilization
98
+ session_used = meters.session_utilization
99
+ if weekly_used is not None and weekly_used >= SECOND_WEEKLY_USED_CEILING_PERCENT:
100
+ if meters.weekly_resets_at is None:
101
+ return None
102
+ all_blocking_resets.append(meters.weekly_resets_at)
103
+ if session_used is not None and session_used >= SECOND_SESSION_USED_CEILING_PERCENT:
104
+ if meters.session_resets_at is None:
105
+ return None
106
+ all_blocking_resets.append(meters.session_resets_at)
107
+ return max(all_blocking_resets, default=None)
108
+
109
+
110
+ def _wait_decision(second_meters: AccountUsageMeters) -> AccountDecision:
111
+ blocking_reset = _blocking_reset(second_meters)
103
112
  return AccountDecision(
104
113
  account=CHOICE_WAIT,
105
114
  reason=REASON_WAIT_TEMPLATE.format(
@@ -112,28 +121,83 @@ def _wait_decision(
112
121
  )
113
122
 
114
123
 
115
- def _second_account_decision(
116
- second_meters: AccountUsageMeters | None,
117
- ) -> AccountDecision:
118
- if second_meters is None:
119
- return AccountDecision(account=CHOICE_SECOND, reason=REASON_SECOND_UNREAD)
120
- session_used = second_meters.session_utilization
121
- weekly_used = second_meters.weekly_utilization
124
+ def _extra_account_decision(extra_account: ExtraAccount) -> AccountDecision | None:
125
+ meters = extra_account.meters
126
+ unread_reason = (
127
+ REASON_SECOND_UNREAD
128
+ if extra_account.name == CHOICE_SECOND
129
+ else REASON_EXTRA_UNREAD_TEMPLATE.format(account=extra_account.name)
130
+ )
131
+ if meters is None:
132
+ return AccountDecision(account=extra_account.name, reason=unread_reason)
133
+ session_used = meters.session_utilization
134
+ weekly_used = meters.weekly_utilization
122
135
  if session_used is None or weekly_used is None:
123
- return AccountDecision(account=CHOICE_SECOND, reason=REASON_SECOND_UNREAD)
136
+ return AccountDecision(account=extra_account.name, reason=unread_reason)
124
137
  is_week_blocked = weekly_used >= SECOND_WEEKLY_USED_CEILING_PERCENT
125
138
  is_session_blocked = session_used >= SECOND_SESSION_USED_CEILING_PERCENT
126
139
  if is_week_blocked or is_session_blocked:
127
- return _wait_decision(second_meters, is_week_blocked=is_week_blocked)
140
+ return None
141
+ reason_template = (
142
+ REASON_SECOND_HAS_ROOM_TEMPLATE
143
+ if extra_account.name == CHOICE_SECOND
144
+ else REASON_EXTRA_HAS_ROOM_TEMPLATE
145
+ )
128
146
  return AccountDecision(
129
- account=CHOICE_SECOND,
130
- reason=REASON_SECOND_HAS_ROOM_TEMPLATE.format(
147
+ account=extra_account.name,
148
+ reason=reason_template.format(
149
+ account=extra_account.name,
131
150
  weekly_remaining_percent=FULL_PERCENT - weekly_used,
132
151
  session_remaining_percent=FULL_PERCENT - session_used,
133
152
  ),
134
153
  )
135
154
 
136
155
 
156
+ def choose_account_from_extras(
157
+ *,
158
+ main_meters: AccountUsageMeters | None,
159
+ all_extra_accounts: Sequence[ExtraAccount],
160
+ now: datetime,
161
+ ) -> AccountDecision:
162
+ """Choose main or the first extra profile with room in list order.
163
+
164
+ Args:
165
+ main_meters: The main account's meters, or None when unread.
166
+ all_extra_accounts: The extra accounts in the order they are tried.
167
+ now: The time the reset windows are measured from.
168
+
169
+ Returns:
170
+ The chosen account and the reason, or a wait naming the soonest reset.
171
+ """
172
+ main_reason = _main_spendable_reason(main_meters, now)
173
+ if main_reason is not None:
174
+ return AccountDecision(account=CHOICE_MAIN, reason=main_reason)
175
+ for each_extra_account in all_extra_accounts:
176
+ decision = _extra_account_decision(each_extra_account)
177
+ if decision is not None:
178
+ return decision
179
+ return _all_extras_wait_decision(all_extra_accounts)
180
+
181
+
182
+ def _all_extras_wait_decision(
183
+ all_extra_accounts: Sequence[ExtraAccount],
184
+ ) -> AccountDecision:
185
+ if len(all_extra_accounts) == 1 and all_extra_accounts[0].meters is not None:
186
+ return _wait_decision(all_extra_accounts[0].meters)
187
+ all_resets = (
188
+ _blocking_reset(each_extra_account.meters)
189
+ for each_extra_account in all_extra_accounts
190
+ if each_extra_account.meters is not None
191
+ )
192
+ next_reset = min((reset for reset in all_resets if reset is not None), default=None)
193
+ return AccountDecision(
194
+ account=CHOICE_WAIT,
195
+ reason=REASON_ALL_EXTRAS_WAIT_TEMPLATE.format(
196
+ next_reset=next_reset.isoformat() if next_reset else UNKNOWN_RESET_TEXT
197
+ ),
198
+ )
199
+
200
+
137
201
  def choose_account(
138
202
  *,
139
203
  main_meters: AccountUsageMeters | None,
@@ -159,10 +223,11 @@ def choose_account(
159
223
  Returns:
160
224
  The chosen account and the reason.
161
225
  """
162
- main_reason = _main_spendable_reason(main_meters, now)
163
- if main_reason is not None:
164
- return AccountDecision(account=CHOICE_MAIN, reason=main_reason)
165
- return _second_account_decision(second_meters)
226
+ return choose_account_from_extras(
227
+ main_meters=main_meters,
228
+ all_extra_accounts=(ExtraAccount(name=CHOICE_SECOND, meters=second_meters),),
229
+ now=now,
230
+ )
166
231
 
167
232
 
168
233
  def read_account_meters(credentials_path: Path) -> AccountUsageMeters | None:
@@ -180,6 +245,64 @@ def read_account_meters(credentials_path: Path) -> AccountUsageMeters | None:
180
245
  return None
181
246
 
182
247
 
248
+ def read_extra_accounts(
249
+ all_config_directories: Sequence[Path],
250
+ ) -> tuple[ExtraAccount, ...]:
251
+ """Read ordered extra profiles and reject duplicate directories.
252
+
253
+ Args:
254
+ all_config_directories: The extra profile homes in the order they are tried.
255
+
256
+ Returns:
257
+ One extra account per directory, with its meters.
258
+
259
+ Raises:
260
+ ValueError: When two entries name the same directory.
261
+ """
262
+ all_resolved_directories = [
263
+ str(each_directory.resolve()).casefold()
264
+ for each_directory in all_config_directories
265
+ ]
266
+ if len(set(all_resolved_directories)) != len(all_config_directories):
267
+ raise ValueError("extra account config directories must be distinct")
268
+ return tuple(
269
+ ExtraAccount(
270
+ name=CHOICE_SECOND if each_index == 1 else f"extra_{each_index}",
271
+ meters=read_account_meters(each_directory / CREDENTIALS_FILE_NAME),
272
+ config_dir=each_directory,
273
+ )
274
+ for each_index, each_directory in enumerate(all_config_directories, start=1)
275
+ )
276
+
277
+
278
+ def config_directory_for_decision(
279
+ decision: AccountDecision,
280
+ *,
281
+ main_config_dir: Path,
282
+ all_extra_accounts: Sequence[ExtraAccount],
283
+ ) -> Path | None:
284
+ """Find the selected account's config directory.
285
+
286
+ Args:
287
+ decision: The chosen account and reason.
288
+ main_config_dir: The main account's Claude home.
289
+ all_extra_accounts: The extra accounts the decision chose from.
290
+
291
+ Returns:
292
+ The chosen account's Claude home, or None on wait.
293
+ """
294
+ if decision.account == CHOICE_MAIN:
295
+ return main_config_dir
296
+ return next(
297
+ (
298
+ each_extra_account.config_dir
299
+ for each_extra_account in all_extra_accounts
300
+ if each_extra_account.name == decision.account
301
+ ),
302
+ None,
303
+ )
304
+
305
+
183
306
  def _build_argument_parser() -> argparse.ArgumentParser:
184
307
  parser = argparse.ArgumentParser(
185
308
  description="Name the Claude account a runner job uses."
@@ -194,6 +317,7 @@ def _build_argument_parser() -> argparse.ArgumentParser:
194
317
  type=Path,
195
318
  default=default_profile_home(),
196
319
  )
320
+ parser.add_argument("--extra-config-dir", type=Path, action="append", default=[])
197
321
  return parser
198
322
 
199
323
 
@@ -253,7 +377,7 @@ def meters_payload(
253
377
 
254
378
 
255
379
  def main(all_command_arguments: list[str]) -> int:
256
- """Print the chosen account and both accounts' meters as JSON.
380
+ """Print the chosen account and every account's meters as JSON.
257
381
 
258
382
  Args:
259
383
  all_command_arguments: Command-line arguments after the program name.
@@ -261,26 +385,33 @@ def main(all_command_arguments: list[str]) -> int:
261
385
  Returns:
262
386
  Zero once the choice is printed.
263
387
  """
264
- arguments = _build_argument_parser().parse_args(all_command_arguments)
265
- config_directory_by_account = {
266
- CHOICE_MAIN: arguments.main_config_dir,
267
- CHOICE_SECOND: arguments.second_config_dir,
268
- }
388
+ parser = _build_argument_parser()
389
+ arguments = parser.parse_args(all_command_arguments)
390
+ try:
391
+ extra_accounts = read_extra_accounts(
392
+ (arguments.second_config_dir, *arguments.extra_config_dir)
393
+ )
394
+ except ValueError as error:
395
+ parser.error(str(error))
269
396
  main_meters = read_account_meters(arguments.main_config_dir / CREDENTIALS_FILE_NAME)
270
- second_meters = read_account_meters(
271
- arguments.second_config_dir / CREDENTIALS_FILE_NAME
272
- )
273
- decision = choose_account(
397
+ decision = choose_account_from_extras(
274
398
  main_meters=main_meters,
275
- second_meters=second_meters,
399
+ all_extra_accounts=extra_accounts,
276
400
  now=datetime.now().astimezone(),
277
401
  )
278
- config_directory = config_directory_by_account.get(decision.account)
402
+ config_directory = config_directory_for_decision(
403
+ decision,
404
+ main_config_dir=arguments.main_config_dir,
405
+ all_extra_accounts=extra_accounts,
406
+ )
279
407
  report = {
280
408
  **decision_payload(decision, config_directory=config_directory),
281
409
  JSON_METERS_KEY: {
282
410
  CHOICE_MAIN: meters_payload(main_meters),
283
- CHOICE_SECOND: meters_payload(second_meters),
411
+ **{
412
+ extra_account.name: meters_payload(extra_account.meters)
413
+ for extra_account in extra_accounts
414
+ },
284
415
  },
285
416
  }
286
417
  print(json.dumps(report))