claude-dev-env 2.9.0 → 2.10.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 (289) hide show
  1. package/CLAUDE.md +1 -1
  2. package/_shared/pr-loop/CLAUDE.md +1 -1
  3. package/_shared/pr-loop/audit-contract.md +17 -6
  4. package/_shared/pr-loop/audit-reply-template.md +4 -4
  5. package/_shared/pr-loop/code-rules-gate.md +3 -5
  6. package/_shared/pr-loop/fix-protocol.md +2 -3
  7. package/_shared/pr-loop/gh-payloads.md +1 -1
  8. package/_shared/pr-loop/scripts/CLAUDE.md +1 -1
  9. package/_shared/pr-loop/scripts/README.md +1 -1
  10. package/_shared/pr-loop/scripts/code_rules_gate.py +2 -0
  11. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_running.py +16 -1
  12. package/_shared/pr-loop/scripts/code_rules_gate_parts/git_blob_readers.py +11 -5
  13. package/_shared/pr-loop/scripts/preflight.py +9 -4
  14. package/_shared/pr-loop/scripts/reviews_disabled.py +50 -22
  15. package/_shared/pr-loop/scripts/tests/conftest.py +20 -0
  16. package/_shared/pr-loop/scripts/tests/test_claude_permissions_common.py +6 -6
  17. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +50 -6
  18. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +1 -1
  19. package/_shared/pr-loop/state-schema.md +5 -14
  20. package/agents/CLAUDE.md +2 -2
  21. package/agents/clean-coder.md +58 -548
  22. package/agents/code-quality-agent.md +10 -2
  23. package/agents/code-verifier.md +1 -1
  24. package/agents/test_agent_frontmatter.py +32 -40
  25. package/audit-rubrics/CLAUDE.md +2 -1
  26. package/audit-rubrics/audit-categories.json +704 -0
  27. package/audit-rubrics/prompts/category-i-concurrency.md +1 -1
  28. package/bin/CLAUDE.md +16 -5
  29. package/bin/ever-shipped-skills.mjs +2 -0
  30. package/bin/install-plan.mjs +402 -0
  31. package/bin/install-transaction.mjs +455 -0
  32. package/bin/install.mjs +593 -147
  33. package/bin/install.plan.test.mjs +194 -0
  34. package/bin/install.profile-root.test.mjs +154 -0
  35. package/bin/install.profiles.test.mjs +253 -0
  36. package/bin/install.settings-defaults.test.mjs +200 -0
  37. package/bin/install.transaction.test.mjs +400 -0
  38. package/bin/install.uninstall-transaction.test.mjs +418 -0
  39. package/bin/merge_managed_permissions.mjs +130 -0
  40. package/bin/resolve-install-root.mjs +181 -0
  41. package/bin/select-install-targets.mjs +401 -0
  42. package/commands/CLAUDE.md +0 -2
  43. package/docs/references/CLAUDE.md +1 -0
  44. package/docs/references/advisor-tool.md +1 -1
  45. package/docs/references/prose-style-enforcement.md +25 -0
  46. package/hooks/blocking/CLAUDE.md +6 -6
  47. package/hooks/blocking/_path_setup.py +9 -5
  48. package/hooks/blocking/code_rules_docstrings.py +124 -30
  49. package/hooks/blocking/code_rules_enforcer.py +161 -16
  50. package/hooks/blocking/code_rules_shared.py +40 -23
  51. package/hooks/blocking/config/CLAUDE.md +3 -5
  52. package/hooks/blocking/config/prose_style_enforcement_constants.py +38 -0
  53. package/hooks/blocking/config/test_prose_style_enforcement_constants.py +45 -0
  54. package/hooks/blocking/eli11_reply_enforcer.py +70 -113
  55. package/hooks/blocking/hedging_language_blocker.py +103 -20
  56. package/hooks/blocking/hook_prose_detector_consistency.py +6 -0
  57. package/hooks/blocking/intent_only_ending_blocker.py +6 -0
  58. package/hooks/blocking/plain_language_blocker.py +139 -20
  59. package/hooks/blocking/pre_tool_use_dispatcher.py +102 -20
  60. package/hooks/blocking/state_description_blocker.py +7 -1
  61. package/hooks/blocking/tdd_enforcer.py +8 -0
  62. package/hooks/blocking/test__path_setup.py +28 -0
  63. package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +99 -0
  64. package/hooks/blocking/test_code_rules_enforcer_docstring_args_span_scope.py +232 -10
  65. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +1 -1
  66. package/hooks/blocking/test_code_rules_enforcer_join_separator_magic.py +41 -0
  67. package/hooks/blocking/test_code_rules_enforcer_string_magic.py +98 -0
  68. package/hooks/blocking/test_eli11_reply_enforcer.py +98 -165
  69. package/hooks/blocking/test_hedging_language_blocker.py +120 -1
  70. package/hooks/blocking/test_hook_prose_detector_consistency.py +28 -8
  71. package/hooks/blocking/test_intent_only_ending_blocker.py +27 -2
  72. package/hooks/blocking/test_package_inventory_stale_blocker.py +11 -4
  73. package/hooks/blocking/test_plain_language_blocker.py +129 -19
  74. package/hooks/blocking/test_plain_language_blocker_allowlist.py +70 -26
  75. package/hooks/blocking/test_pre_tool_use_dispatcher.py +99 -26
  76. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +87 -50
  77. package/hooks/blocking/test_state_description_blocker.py +45 -2
  78. package/hooks/blocking/test_stop_dispatcher.py +11 -7
  79. package/hooks/blocking/test_volatile_path_in_post_blocker.py +12 -12
  80. package/hooks/blocking/volatile_path_in_post_blocker.py +2 -2
  81. package/hooks/hooks.json +15 -0
  82. package/hooks/hooks_constants/CLAUDE.md +14 -3
  83. package/hooks/hooks_constants/ask_user_question_shape.py +281 -0
  84. package/hooks/hooks_constants/code_rules_enforcer_constants.py +2 -1
  85. package/hooks/hooks_constants/eli11_reply_enforcer_constants.py +5 -12
  86. package/hooks/hooks_constants/hedging_uncertainty_constants.py +42 -0
  87. package/hooks/hooks_constants/issue_tracker_session_starter_constants.py +23 -0
  88. package/hooks/hooks_constants/orchestrator_auto_starter_constants.py +23 -0
  89. package/hooks/hooks_constants/piped_pytest_blocker_constants.py +4 -1
  90. package/hooks/hooks_constants/plain_language_blocker_constants.py +4 -1
  91. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +6 -0
  92. package/hooks/hooks_constants/project_paths_reader.py +31 -4
  93. package/hooks/hooks_constants/prose_matcher_precision_constants.py +40 -0
  94. package/hooks/hooks_constants/pytest_invocation.py +354 -0
  95. package/hooks/hooks_constants/session_start_injector.py +163 -0
  96. package/hooks/hooks_constants/session_start_injector_constants.py +46 -0
  97. package/hooks/hooks_constants/shell_command_pipeline.py +397 -0
  98. package/hooks/hooks_constants/shell_command_segments.py +5 -0
  99. package/hooks/hooks_constants/test_ask_user_question_shape.py +167 -0
  100. package/hooks/hooks_constants/test_project_paths_reader.py +29 -0
  101. package/hooks/hooks_constants/test_prose_metrics_parity.py +8 -0
  102. package/hooks/hooks_constants/test_pytest_invocation.py +130 -0
  103. package/hooks/hooks_constants/test_session_start_injector.py +168 -0
  104. package/hooks/hooks_constants/test_shell_command_pipeline.py +135 -0
  105. package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +1 -1
  106. package/hooks/hooks_constants/working_style_prompt_constants.py +30 -0
  107. package/hooks/observability/CLAUDE.md +2 -0
  108. package/hooks/observability/prose_matcher_advisory.py +237 -0
  109. package/hooks/observability/test_prose_matcher_advisory.py +143 -0
  110. package/hooks/session/CLAUDE.md +9 -1
  111. package/hooks/session/_path_setup.py +13 -0
  112. package/hooks/session/issue_tracker_session_starter.py +135 -0
  113. package/hooks/session/orchestrator_auto_starter.py +100 -0
  114. package/hooks/session/test__path_setup.py +28 -0
  115. package/hooks/session/test_issue_tracker_session_starter.py +104 -0
  116. package/hooks/session/test_orchestrator_auto_starter.py +99 -0
  117. package/hooks/session/test_working_style_prompt.py +47 -0
  118. package/hooks/session/untracked_repo_detector.py +1 -24
  119. package/hooks/session/working_style_prompt.py +36 -0
  120. package/hooks/validators/_path_setup.py +19 -0
  121. package/hooks/validators/run_all_validators.py +8 -13
  122. package/installable-surfaces.manifest.json +21 -0
  123. package/output-styles/CLAUDE.md +1 -3
  124. package/package.json +4 -2
  125. package/rules/CLAUDE.md +1 -0
  126. package/rules/durable-post-artifacts.md +2 -2
  127. package/rules/eli11-replies.md +6 -1
  128. package/rules/hedging-claims.md +4 -2
  129. package/rules/long-horizon-autonomy.md +3 -1
  130. package/rules/opus5-communication-contract.md +45 -0
  131. package/rules/plain-language.md +2 -2
  132. package/rules/research-mode.md +1 -1
  133. package/scripts/CLAUDE.md +11 -0
  134. package/scripts/Sync-RepoMain.ps1 +215 -0
  135. package/scripts/active_capability_references.py +218 -0
  136. package/scripts/ci/windows-installer-lifecycle.ps1 +78 -0
  137. package/scripts/claude_chain_runner.py +394 -6
  138. package/scripts/claude_chain_usage.py +1 -1
  139. package/scripts/codex_compat_materializer.py +105 -85
  140. package/scripts/dev_env_scripts_constants/CLAUDE.md +2 -0
  141. package/scripts/dev_env_scripts_constants/active_capability_constants.py +46 -0
  142. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +74 -0
  143. package/scripts/dev_env_scripts_constants/verify_installable_package_constants.py +116 -0
  144. package/scripts/profile-isolation-launchers/config/mcp-bundles.json +25 -0
  145. package/scripts/profile-isolation-launchers/config/profile-isolation-constants.mjs +60 -0
  146. package/scripts/profile-isolation-launchers/config/profiles.manifest.json +54 -0
  147. package/scripts/profile-isolation-launchers/config/shared-allowlist.json +64 -0
  148. package/scripts/profile-isolation-launchers/launcher-runtime.mjs +180 -0
  149. package/scripts/profile-isolation-launchers/lib/profile-manifest.mjs +288 -0
  150. package/scripts/profile-isolation-launchers/mcp-bundles.mjs +275 -0
  151. package/scripts/profile-isolation-launchers/profile-isolation-contract.test.mjs +221 -0
  152. package/scripts/profile-isolation-launchers/tests/launcher-runtime.test.mjs +108 -0
  153. package/scripts/profile-isolation-launchers/tests/mcp-bundles.test.mjs +147 -0
  154. package/scripts/profile-isolation-launchers/tests/shortcut-contract.test.ps1 +102 -0
  155. package/scripts/profile-isolation-launchers/tests/version-compatibility.test.mjs +210 -0
  156. package/scripts/profile-isolation-launchers/version-compatibility.mjs +299 -0
  157. package/scripts/profile-isolation-launchers/windows/shortcut-inventory.ps1 +127 -0
  158. package/scripts/profile-isolation-launchers/windows/shortcut-manifest.json +51 -0
  159. package/scripts/profile-isolation-launchers/windows/shortcut-reconcile.ps1 +77 -0
  160. package/scripts/spawn_grok_batch.py +3 -0
  161. package/scripts/test_active_capability_references.py +108 -0
  162. package/scripts/test_claude_chain_runner.py +414 -82
  163. package/scripts/test_claude_chain_usage.py +12 -12
  164. package/scripts/test_resolve_worker_spawn.py +2 -2
  165. package/scripts/test_verify_installable_package.py +208 -0
  166. package/scripts/tests/test_codex_compat_materializer.py +33 -0
  167. package/scripts/verify_installable_package.py +612 -0
  168. package/settings.json +10 -0
  169. package/skills/CLAUDE.md +2 -0
  170. package/skills/_shared/pr-loop/scripts/CLAUDE.md +1 -0
  171. package/skills/_shared/pr-loop/scripts/audit_category_schema.py +355 -0
  172. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +1 -0
  173. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/audit_category_schema_constants.py +32 -0
  174. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +7 -19
  175. package/skills/_shared/pr-loop/scripts/test_audit_category_schema.py +94 -0
  176. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +21 -0
  177. package/skills/autoconverge/reference/convergence.md +2 -1
  178. package/skills/autoconverge/reference/stop-conditions.md +5 -3
  179. package/skills/beat-sheet/SKILL.md +54 -0
  180. package/skills/beat-sheet/reference/visual-beats.md +29 -0
  181. package/skills/bugteam/CONSTRAINTS.md +4 -4
  182. package/skills/bugteam/EXAMPLES.md +1 -1
  183. package/skills/bugteam/reference/README.md +1 -1
  184. package/skills/e-code-review/SKILL.md +26 -5
  185. package/skills/e-code-review/reference/effort-evaluation.md +35 -0
  186. package/skills/e-code-review/reference/medium.md +15 -4
  187. package/skills/e-code-review/scripts/config/e_code_review_effort_constants/__init__.py +41 -0
  188. package/skills/e-code-review/scripts/config/e_code_review_effort_constants/effort_constants.py +40 -0
  189. package/skills/e-code-review/scripts/e_code_review_scripts_constants/finding_pipeline_constants.py +49 -0
  190. package/skills/e-code-review/scripts/effort_defaults_evidence.json +186 -0
  191. package/skills/e-code-review/scripts/effort_evaluation.py +362 -0
  192. package/skills/e-code-review/scripts/finding_pipeline.py +140 -0
  193. package/skills/e-code-review/scripts/fixtures/demanding.json +26 -0
  194. package/skills/e-code-review/scripts/fixtures/easy.json +14 -0
  195. package/skills/e-code-review/scripts/fixtures/medium.json +20 -0
  196. package/skills/e-code-review/scripts/grok_code_review.py +16 -7
  197. package/skills/e-code-review/scripts/test_effort_evaluation.py +180 -0
  198. package/skills/e-code-review/scripts/test_finding_pipeline.py +197 -0
  199. package/skills/e-code-review/scripts/test_grok_code_review.py +77 -0
  200. package/skills/plan-to-pr/scripts/create_packet.py +4 -4
  201. package/skills/plan-to-pr/scripts/load_skill_constants.py +41 -0
  202. package/skills/plan-to-pr/scripts/validate_packet.py +4 -4
  203. package/skills/plan-to-pr/scripts/validate_protocol.py +4 -1
  204. package/skills/plan-to-pr/scripts/validate_run.py +4 -1
  205. package/skills/pr-converge/scripts/check_convergence.py +21 -19
  206. package/skills/pr-converge/scripts/check_convergence_availability.py +50 -7
  207. package/skills/pr-converge/scripts/conftest.py +35 -0
  208. package/skills/pr-converge/scripts/test_check_convergence_availability.py +65 -0
  209. package/skills/pr-converge/scripts/test_check_convergence_codex.py +11 -1
  210. package/skills/pr-converge/scripts/test_check_convergence_contract.py +9 -2
  211. package/skills/pr-loop-cloud-transport/SKILL.md +1 -1
  212. package/skills/rebase/SKILL.md +15 -3
  213. package/skills/reviewer-gates/SKILL.md +2 -2
  214. package/skills/show/SKILL.md +51 -0
  215. package/skills/show/references/accessibility.md +7 -0
  216. package/skills/show/references/art.md +3 -0
  217. package/skills/show/references/charts.md +3 -0
  218. package/skills/show/references/core-design.md +14 -0
  219. package/skills/show/references/erds.md +3 -0
  220. package/skills/show/references/flowcharts.md +3 -0
  221. package/skills/show/references/host-and-html.md +3 -0
  222. package/skills/show/references/illustrative-diagrams.md +10 -0
  223. package/skills/show/references/interaction.md +3 -0
  224. package/skills/show/references/mockups.md +3 -0
  225. package/skills/show/references/quality-gates.md +7 -0
  226. package/skills/show/references/structural-diagrams.md +3 -0
  227. package/skills/show/references/subject-inventory.md +21 -0
  228. package/skills/show/references/svg-contract.md +22 -0
  229. package/skills/show/routing.yaml +30 -0
  230. package/skills/show/samples/pr1262-v2.svg +222 -0
  231. package/skills/show/scripts/README.md +6 -0
  232. package/skills/show/scripts/validate-artifact.py +91 -0
  233. package/skills/show/scripts/validate-package.py +18 -0
  234. package/skills/show/templates/html-widget.html +4 -0
  235. package/skills/show/templates/svg-base.svg +19 -0
  236. package/skills/show/tests/fixtures/css-var.svg +6 -0
  237. package/skills/show/tests/fixtures/dead-ref.svg +7 -0
  238. package/skills/show/tests/fixtures/filled-glyph.svg +8 -0
  239. package/skills/show/tests/fixtures/inherited-fill.svg +18 -0
  240. package/skills/show/tests/fixtures/invalid.svg +1 -0
  241. package/skills/show/tests/fixtures/large-canvas.svg +21 -0
  242. package/skills/show/tests/fixtures/unfilled-connector.svg +15 -0
  243. package/skills/show/tests/fixtures/valid.html +1 -0
  244. package/skills/show/tests/test_validate-artifact.py +74 -0
  245. package/skills/show/tests/test_validators.py +59 -0
  246. package/skills/show/workflows/create-visual.md +13 -0
  247. package/skills/show/workflows/review-visual.md +20 -0
  248. package/skills/split-pr/SKILL.md +85 -0
  249. package/skills/split-pr/reference/path-layers.md +16 -0
  250. package/skills/split-pr/reference/proposal-format.md +15 -0
  251. package/skills/split-pr/reference/split-further-loop.md +10 -0
  252. package/skills/split-pr/reference/splitting-principles.md +26 -0
  253. package/skills/split-pr/scripts/analyze_pr.py +279 -0
  254. package/skills/split-pr/scripts/categorize_files.py +106 -0
  255. package/skills/split-pr/scripts/config/__init__.py +1 -0
  256. package/skills/split-pr/scripts/config/dependency_constants.py +14 -0
  257. package/skills/split-pr/scripts/config/git_operations_constants.py +36 -0
  258. package/skills/split-pr/scripts/config/packing_constants.py +61 -0
  259. package/skills/split-pr/scripts/config/plan_constants.py +49 -0
  260. package/skills/split-pr/scripts/config/split_pr_constants.py +110 -0
  261. package/skills/split-pr/scripts/execute_split_slices.py +82 -0
  262. package/skills/split-pr/scripts/pack_files_into_slices.py +212 -0
  263. package/skills/split-pr/scripts/split_pr_dependency_graph.py +70 -0
  264. package/skills/split-pr/scripts/split_pr_git_operations.py +184 -0
  265. package/skills/split-pr/scripts/split_pr_layer_order.py +58 -0
  266. package/skills/split-pr/scripts/split_pr_paginate.py +119 -0
  267. package/skills/split-pr/scripts/split_pr_process_runner.py +52 -0
  268. package/skills/split-pr/scripts/split_pr_script_types.py +126 -0
  269. package/skills/split-pr/scripts/split_pr_title.py +41 -0
  270. package/skills/split-pr/scripts/test_analyze_pr.py +228 -0
  271. package/skills/split-pr/scripts/test_categorize_files.py +55 -0
  272. package/skills/split-pr/scripts/test_categorize_files_packing.py +59 -0
  273. package/skills/split-pr/scripts/test_execute_split_slices.py +99 -0
  274. package/skills/split-pr/scripts/test_split_pr_dependency_graph.py +47 -0
  275. package/skills/split-pr/scripts/test_split_pr_git_operations.py +125 -0
  276. package/skills/split-pr/scripts/test_split_pr_layer_order.py +36 -0
  277. package/skills/split-pr/scripts/test_split_pr_paginate.py +65 -0
  278. package/skills/split-pr/scripts/test_split_pr_script_types.py +73 -0
  279. package/skills/split-pr/scripts/test_split_pr_title.py +28 -0
  280. package/skills/split-pr/scripts/test_verify_dependency_graph.py +46 -0
  281. package/skills/split-pr/scripts/test_verify_plan.py +56 -0
  282. package/skills/split-pr/scripts/test_verify_plan_contract.py +50 -0
  283. package/skills/split-pr/scripts/test_verify_plan_path_normalization.py +45 -0
  284. package/skills/split-pr/scripts/verify_dependency_graph.py +111 -0
  285. package/skills/split-pr/scripts/verify_plan.py +139 -0
  286. package/system-prompts/software-engineer.xml +11 -2
  287. package/commands/initialize.md +0 -90
  288. package/commands/stubcheck.md +0 -88
  289. package/output-styles/caveman-agent.md +0 -37
@@ -19,7 +19,7 @@ Default try order comes from weekly remaining via ``claude_chain_usage``
19
19
  (usage-pause OAuth probe), not from list position alone::
20
20
 
21
21
  {"chain": [{"command": "claude", "extra_args": []},
22
- {"command": "claude-ev", "extra_args": []}]}
22
+ {"command": "claude-profile-c", "extra_args": []}]}
23
23
 
24
24
  A usage-limited first try falls over to the next ranked binary::
25
25
 
@@ -44,11 +44,12 @@ import argparse
44
44
  import importlib
45
45
  import io
46
46
  import json
47
+ import os
47
48
  import subprocess
48
49
  import sys
49
50
  import tempfile
50
51
  from collections.abc import Callable, Sequence
51
- from dataclasses import dataclass
52
+ from dataclasses import dataclass, field
52
53
  from pathlib import Path
53
54
  from types import ModuleType
54
55
  from typing import Protocol, TextIO
@@ -57,7 +58,27 @@ if __name__ == "__main__":
57
58
  sys.modules.setdefault("claude_chain_runner", sys.modules[__name__])
58
59
 
59
60
  from dev_env_scripts_constants.claude_chain_constants import (
61
+ AFFINITY_BINDING_COMMAND_MISSING_REASON,
62
+ AFFINITY_BINDING_NOT_OBJECT_REASON,
63
+ AFFINITY_BINDING_SESSION_ID_MISSING_REASON,
64
+ AFFINITY_BINDINGS_MISSING_OR_NOT_LIST_REASON,
65
+ AFFINITY_CORRUPT_MESSAGE_TEMPLATE,
66
+ AFFINITY_JSON_INDENT_SPACES,
67
+ AFFINITY_KEY_ALL_BINDINGS,
68
+ AFFINITY_KEY_COMMAND,
69
+ AFFINITY_KEY_SCHEMA_VERSION,
70
+ AFFINITY_KEY_SESSION_ID,
71
+ AFFINITY_MAXIMUM_ENTRIES,
72
+ AFFINITY_MAXIMUM_ENTRIES_MINIMUM_MESSAGE,
73
+ AFFINITY_SESSION_ID_AND_COMMAND_REQUIRED_MESSAGE,
74
+ AFFINITY_STATE_FILENAME,
75
+ AFFINITY_STATE_SCHEMA_VERSION,
76
+ AFFINITY_TEMP_SUFFIX,
77
+ AFFINITY_TOP_LEVEL_NOT_OBJECT_REASON,
78
+ AFFINITY_UNSUPPORTED_SCHEMA_VERSION_REASON_TEMPLATE,
79
+ AFFINITY_WRITE_FAILED_MESSAGE_TEMPLATE,
60
80
  ALL_ROUTING_MODES,
81
+ RESUME_SESSION_FLAG,
61
82
  ALL_USAGE_LIMIT_SIGNATURES,
62
83
  ATTEMPT_STATUS_EXECUTABLE_NOT_FOUND,
63
84
  ATTEMPT_STATUS_NONZERO_EXIT,
@@ -519,6 +540,219 @@ def _session_id_from_json_text(json_text: str) -> str | None:
519
540
  return None
520
541
 
521
542
 
543
+ @dataclass(frozen=True)
544
+ class AffinityBinding:
545
+ """One session-id to chain-binary binding."""
546
+
547
+ session_id: str
548
+ command: str
549
+
550
+
551
+ @dataclass(frozen=True)
552
+ class AffinityStore:
553
+ """Versioned, bounded session-to-binary affinity document."""
554
+
555
+ schema_version: int = AFFINITY_STATE_SCHEMA_VERSION
556
+ all_bindings: list[AffinityBinding] = field(default_factory=list)
557
+
558
+
559
+ def default_affinity_state_path(claude_home_directory: Path) -> Path:
560
+ """Return the default affinity state path under a Claude home directory.
561
+
562
+ Args:
563
+ claude_home_directory: Claude configuration root (for example ``~/.claude``).
564
+
565
+ Returns:
566
+ Path to the affinity state JSON file.
567
+ """
568
+ return claude_home_directory / AFFINITY_STATE_FILENAME
569
+
570
+
571
+ def _affinity_corrupt_error(state_path: Path, error: object) -> ValueError:
572
+ return ValueError(
573
+ AFFINITY_CORRUPT_MESSAGE_TEMPLATE.format(
574
+ state_path=state_path,
575
+ error=error,
576
+ )
577
+ )
578
+
579
+
580
+ def _parse_affinity_binding(
581
+ each_binding: object,
582
+ *,
583
+ state_path: Path,
584
+ ) -> AffinityBinding:
585
+ if not isinstance(each_binding, dict):
586
+ raise _affinity_corrupt_error(state_path, AFFINITY_BINDING_NOT_OBJECT_REASON)
587
+ session_id = each_binding.get(AFFINITY_KEY_SESSION_ID)
588
+ command = each_binding.get(AFFINITY_KEY_COMMAND)
589
+ if not isinstance(session_id, str) or not session_id:
590
+ raise _affinity_corrupt_error(
591
+ state_path, AFFINITY_BINDING_SESSION_ID_MISSING_REASON
592
+ )
593
+ if not isinstance(command, str) or not command:
594
+ raise _affinity_corrupt_error(
595
+ state_path, AFFINITY_BINDING_COMMAND_MISSING_REASON
596
+ )
597
+ return AffinityBinding(session_id=session_id, command=command)
598
+
599
+
600
+ def _bindings_from_payload(
601
+ all_payload_fields: dict[str, object],
602
+ *,
603
+ state_path: Path,
604
+ ) -> list[AffinityBinding]:
605
+ schema_version = all_payload_fields.get(AFFINITY_KEY_SCHEMA_VERSION)
606
+ if schema_version != AFFINITY_STATE_SCHEMA_VERSION:
607
+ raise _affinity_corrupt_error(
608
+ state_path,
609
+ AFFINITY_UNSUPPORTED_SCHEMA_VERSION_REASON_TEMPLATE.format(
610
+ schema_version=schema_version
611
+ ),
612
+ )
613
+ raw_bindings = all_payload_fields.get(AFFINITY_KEY_ALL_BINDINGS)
614
+ if not isinstance(raw_bindings, list):
615
+ raise _affinity_corrupt_error(
616
+ state_path, AFFINITY_BINDINGS_MISSING_OR_NOT_LIST_REASON
617
+ )
618
+ return [
619
+ _parse_affinity_binding(each_binding, state_path=state_path)
620
+ for each_binding in raw_bindings
621
+ ]
622
+
623
+
624
+ def load_affinity_store(state_path: Path) -> AffinityStore:
625
+ """Load a versioned affinity store, or an empty store when the file is absent.
626
+
627
+ Args:
628
+ state_path: Path to the affinity state JSON file.
629
+
630
+ Returns:
631
+ Parsed affinity store.
632
+
633
+ Raises:
634
+ ValueError: When the document is corrupt or uses an unsupported schema.
635
+ """
636
+ if not state_path.is_file():
637
+ return AffinityStore()
638
+ try:
639
+ raw_text = state_path.read_text(encoding=UTF8_ENCODING)
640
+ parsed_payload = json.loads(raw_text)
641
+ except (OSError, json.JSONDecodeError, UnicodeError) as load_error:
642
+ raise _affinity_corrupt_error(state_path, load_error) from load_error
643
+ if not isinstance(parsed_payload, dict):
644
+ raise _affinity_corrupt_error(
645
+ state_path, AFFINITY_TOP_LEVEL_NOT_OBJECT_REASON
646
+ )
647
+ return AffinityStore(
648
+ schema_version=AFFINITY_STATE_SCHEMA_VERSION,
649
+ all_bindings=_bindings_from_payload(parsed_payload, state_path=state_path),
650
+ )
651
+
652
+
653
+ def record_affinity_binding(
654
+ store: AffinityStore,
655
+ *,
656
+ session_id: str,
657
+ command: str,
658
+ maximum_entries: int = AFFINITY_MAXIMUM_ENTRIES,
659
+ ) -> AffinityStore:
660
+ """Return a new store with ``session_id`` bound to ``command``, bounded.
661
+
662
+ Re-binding an existing session moves it to the newest end. When the store
663
+ exceeds ``maximum_entries``, the oldest bindings drop first.
664
+
665
+ Args:
666
+ store: Current affinity store.
667
+ session_id: Claude session id to bind.
668
+ command: Chain binary command that served the session.
669
+ maximum_entries: Hard cap on retained bindings.
670
+
671
+ Returns:
672
+ Updated store (does not mutate ``store``).
673
+
674
+ Raises:
675
+ ValueError: When session_id, command, or maximum_entries is invalid.
676
+ """
677
+ if not session_id or not command:
678
+ raise ValueError(AFFINITY_SESSION_ID_AND_COMMAND_REQUIRED_MESSAGE)
679
+ if maximum_entries < 1:
680
+ raise ValueError(AFFINITY_MAXIMUM_ENTRIES_MINIMUM_MESSAGE)
681
+ all_remaining = [
682
+ each_binding
683
+ for each_binding in store.all_bindings
684
+ if each_binding.session_id != session_id
685
+ ]
686
+ all_remaining.append(AffinityBinding(session_id=session_id, command=command))
687
+ if len(all_remaining) > maximum_entries:
688
+ all_remaining = all_remaining[-maximum_entries:]
689
+ return AffinityStore(
690
+ schema_version=AFFINITY_STATE_SCHEMA_VERSION,
691
+ all_bindings=all_remaining,
692
+ )
693
+
694
+
695
+ def save_affinity_store_atomic(state_path: Path, store: AffinityStore) -> None:
696
+ """Atomically replace the affinity state file with ``store``.
697
+
698
+ Creates a unique sibling temporary file via ``tempfile.mkstemp``, writes
699
+ and flushes the document, then uses ``os.replace`` so readers never
700
+ observe a partial document.
701
+
702
+ Args:
703
+ state_path: Destination affinity state path.
704
+ store: Store document to persist.
705
+
706
+ Raises:
707
+ OSError: When the write or replace fails (message is actionable).
708
+ """
709
+ payload = {
710
+ AFFINITY_KEY_SCHEMA_VERSION: store.schema_version,
711
+ AFFINITY_KEY_ALL_BINDINGS: [
712
+ {
713
+ AFFINITY_KEY_SESSION_ID: each_binding.session_id,
714
+ AFFINITY_KEY_COMMAND: each_binding.command,
715
+ }
716
+ for each_binding in store.all_bindings
717
+ ],
718
+ }
719
+ serialized_document = (
720
+ json.dumps(payload, indent=AFFINITY_JSON_INDENT_SPACES, sort_keys=True)
721
+ + "\n"
722
+ )
723
+ state_path.parent.mkdir(parents=True, exist_ok=True)
724
+ file_descriptor, temporary_name = tempfile.mkstemp(
725
+ prefix=f".{state_path.name}.",
726
+ suffix=AFFINITY_TEMP_SUFFIX,
727
+ dir=state_path.parent,
728
+ )
729
+ temporary_path = Path(temporary_name)
730
+ owned_descriptor = file_descriptor
731
+ try:
732
+ with os.fdopen(file_descriptor, "w", encoding=UTF8_ENCODING) as temporary_file:
733
+ owned_descriptor = -1
734
+ temporary_file.write(serialized_document)
735
+ temporary_file.flush()
736
+ os.fsync(temporary_file.fileno())
737
+ os.replace(temporary_path, state_path)
738
+ except OSError as write_error:
739
+ if owned_descriptor >= 0:
740
+ try:
741
+ os.close(owned_descriptor)
742
+ except OSError:
743
+ pass
744
+ try:
745
+ temporary_path.unlink(missing_ok=True)
746
+ except OSError:
747
+ pass
748
+ raise OSError(
749
+ AFFINITY_WRITE_FAILED_MESSAGE_TEMPLATE.format(
750
+ state_path=state_path,
751
+ error=write_error,
752
+ )
753
+ ) from write_error
754
+
755
+
522
756
  def _served_outcome(
523
757
  served_command: str,
524
758
  completion: subprocess.CompletedProcess[str],
@@ -638,6 +872,80 @@ def _ranked_entries_or_config_order(
638
872
  return list(all_entries)
639
873
 
640
874
 
875
+ def extract_resume_session_id(
876
+ all_claude_arguments: Sequence[str],
877
+ ) -> str | None:
878
+ """Return the session id after ``--resume`` when present.
879
+
880
+ Args:
881
+ all_claude_arguments: Arguments passed after the binary name.
882
+
883
+ Returns:
884
+ The non-empty session id, or ``None`` when the flag is absent.
885
+ """
886
+ for each_index, each_argument in enumerate(all_claude_arguments):
887
+ if each_argument != RESUME_SESSION_FLAG:
888
+ continue
889
+ next_index = each_index + 1
890
+ if next_index >= len(all_claude_arguments):
891
+ return None
892
+ session_id = all_claude_arguments[next_index]
893
+ if session_id and not session_id.startswith("-"):
894
+ return session_id
895
+ return None
896
+ return None
897
+
898
+
899
+ def lookup_affinity_command(
900
+ store: AffinityStore,
901
+ session_id: str,
902
+ ) -> str | None:
903
+ """Return the bound binary command for ``session_id``, if any.
904
+
905
+ Args:
906
+ store: Loaded affinity store.
907
+ session_id: Session id from a prior serve.
908
+
909
+ Returns:
910
+ The bound command string, or ``None`` when unbound.
911
+ """
912
+ for each_binding in store.all_bindings:
913
+ if each_binding.session_id == session_id:
914
+ return each_binding.command
915
+ return None
916
+
917
+
918
+ def order_entries_for_resume(
919
+ all_entries: list[ChainEntry],
920
+ *,
921
+ preferred_command: str | None,
922
+ ) -> list[ChainEntry]:
923
+ """Put the preferred originating binary first; keep relative order of the rest.
924
+
925
+ When ``preferred_command`` is missing from the chain or is ``None``, the
926
+ original list order is returned unchanged (documented fallback).
927
+
928
+ Args:
929
+ all_entries: Chain entries in the mode's base order.
930
+ preferred_command: Originating binary from the affinity store.
931
+
932
+ Returns:
933
+ Ordered walk list for this invocation.
934
+ """
935
+ if preferred_command is None:
936
+ return list(all_entries)
937
+ preferred: list[ChainEntry] = []
938
+ remaining: list[ChainEntry] = []
939
+ for each_entry in all_entries:
940
+ if each_entry.command == preferred_command and not preferred:
941
+ preferred.append(each_entry)
942
+ else:
943
+ remaining.append(each_entry)
944
+ if not preferred:
945
+ return list(all_entries)
946
+ return preferred + remaining
947
+
948
+
641
949
  def _resolve_walk_entries(
642
950
  all_entries: list[ChainEntry],
643
951
  config_path: Path,
@@ -648,6 +956,36 @@ def _resolve_walk_entries(
648
956
  return _ranked_entries_or_config_order(all_entries, config_path)
649
957
 
650
958
 
959
+ def _affinity_preferred_command(
960
+ all_claude_arguments: Sequence[str],
961
+ affinity_path: Path,
962
+ ) -> str | None:
963
+ resume_session_id = extract_resume_session_id(all_claude_arguments)
964
+ if resume_session_id is None:
965
+ return None
966
+ try:
967
+ affinity_store = load_affinity_store(affinity_path)
968
+ except ValueError:
969
+ return None
970
+ return lookup_affinity_command(affinity_store, resume_session_id)
971
+
972
+
973
+ def _walk_entries_for_invocation(
974
+ all_entries: list[ChainEntry],
975
+ config_path: Path,
976
+ routing_mode: str,
977
+ all_claude_arguments: Sequence[str],
978
+ affinity_path: Path,
979
+ ) -> list[ChainEntry]:
980
+ base_order = _resolve_walk_entries(all_entries, config_path, routing_mode)
981
+ preferred_command = _affinity_preferred_command(
982
+ all_claude_arguments, affinity_path
983
+ )
984
+ return order_entries_for_resume(
985
+ base_order, preferred_command=preferred_command
986
+ )
987
+
988
+
651
989
  def _require_known_routing_mode(routing_mode: str) -> str:
652
990
  if routing_mode not in ALL_ROUTING_MODES:
653
991
  raise ValueError(
@@ -702,9 +1040,35 @@ def run_claude(
702
1040
  selected_routing_mode = _require_known_routing_mode(routing_mode)
703
1041
  config_path = chain_config_path()
704
1042
  all_entries = load_chain(config_path)
705
- all_walk_entries = _resolve_walk_entries(
706
- all_entries, config_path, selected_routing_mode
1043
+ affinity_path = default_affinity_state_path(
1044
+ Path.home() / CLAUDE_HOME_SUBDIRECTORY
707
1045
  )
1046
+ all_walk_entries = _walk_entries_for_invocation(
1047
+ all_entries,
1048
+ config_path,
1049
+ selected_routing_mode,
1050
+ all_claude_arguments,
1051
+ affinity_path,
1052
+ )
1053
+ return _walk_chain_attempts(
1054
+ all_walk_entries,
1055
+ all_claude_arguments=all_claude_arguments,
1056
+ timeout_seconds=timeout_seconds,
1057
+ stdin_text=stdin_text,
1058
+ routing_mode=selected_routing_mode,
1059
+ affinity_path=affinity_path,
1060
+ )
1061
+
1062
+
1063
+ def _walk_chain_attempts(
1064
+ all_walk_entries: list[ChainEntry],
1065
+ *,
1066
+ all_claude_arguments: list[str],
1067
+ timeout_seconds: int,
1068
+ stdin_text: str | None,
1069
+ routing_mode: str,
1070
+ affinity_path: Path,
1071
+ ) -> ChainInvocationOutcome:
708
1072
  all_attempts: list[ChainAttempt] = []
709
1073
  last_usage_limited: subprocess.CompletedProcess[str] | None = None
710
1074
  for each_entry in all_walk_entries:
@@ -725,7 +1089,7 @@ def run_claude(
725
1089
  )
726
1090
  timeout_terminal_status = (
727
1091
  TERMINAL_STATUS_ADVISOR_BLOCKED
728
- if selected_routing_mode == ROUTING_MODE_ORDERED_ACCOUNT
1092
+ if routing_mode == ROUTING_MODE_ORDERED_ACCOUNT
729
1093
  else TERMINAL_STATUS_TIMEOUT
730
1094
  )
731
1095
  return _no_process_outcome(
@@ -742,14 +1106,38 @@ def run_claude(
742
1106
  each_entry,
743
1107
  completion,
744
1108
  all_attempts,
745
- routing_mode=selected_routing_mode,
1109
+ routing_mode=routing_mode,
746
1110
  )
747
1111
  if terminal_outcome is not None:
1112
+ _persist_served_affinity(
1113
+ affinity_path,
1114
+ served_command=each_entry.command,
1115
+ session_id=terminal_outcome.session_id,
1116
+ )
748
1117
  return terminal_outcome
749
1118
  last_usage_limited = completion
750
1119
  return _exhausted_outcome(all_attempts, last_usage_limited)
751
1120
 
752
1121
 
1122
+ def _persist_served_affinity(
1123
+ affinity_path: Path,
1124
+ *,
1125
+ served_command: str,
1126
+ session_id: str | None,
1127
+ ) -> None:
1128
+ """Best-effort write of session-to-binary affinity after a successful serve."""
1129
+ if not session_id:
1130
+ return
1131
+ try:
1132
+ store = load_affinity_store(affinity_path)
1133
+ updated = record_affinity_binding(
1134
+ store, session_id=session_id, command=served_command
1135
+ )
1136
+ save_affinity_store_atomic(affinity_path, updated)
1137
+ except (OSError, ValueError):
1138
+ return
1139
+
1140
+
753
1141
  def _build_argument_parser() -> argparse.ArgumentParser:
754
1142
  parser = argparse.ArgumentParser(
755
1143
  description="Run a claude invocation through the fallback chain."
@@ -11,7 +11,7 @@ consumes that ranking to choose try order.
11
11
  python claude_chain_usage.py
12
12
  {"accounts": [
13
13
  {"command": "claude", "weekly_remaining_percent": 58.0},
14
- {"command": "claude-ev", "weekly_remaining_percent": null, "error": "..."}
14
+ {"command": "claude-profile-c", "weekly_remaining_percent": null, "error": "..."}
15
15
  ]}
16
16
  """
17
17