erk 0.4.5__py3-none-any.whl

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 (331) hide show
  1. erk/__init__.py +12 -0
  2. erk/__main__.py +6 -0
  3. erk/agent_docs/__init__.py +5 -0
  4. erk/agent_docs/models.py +123 -0
  5. erk/agent_docs/operations.py +666 -0
  6. erk/artifacts/__init__.py +5 -0
  7. erk/artifacts/artifact_health.py +623 -0
  8. erk/artifacts/detection.py +16 -0
  9. erk/artifacts/discovery.py +343 -0
  10. erk/artifacts/models.py +63 -0
  11. erk/artifacts/staleness.py +56 -0
  12. erk/artifacts/state.py +100 -0
  13. erk/artifacts/sync.py +624 -0
  14. erk/cli/__init__.py +0 -0
  15. erk/cli/activation.py +132 -0
  16. erk/cli/alias.py +53 -0
  17. erk/cli/cli.py +221 -0
  18. erk/cli/commands/__init__.py +0 -0
  19. erk/cli/commands/admin.py +153 -0
  20. erk/cli/commands/artifact/__init__.py +1 -0
  21. erk/cli/commands/artifact/check.py +260 -0
  22. erk/cli/commands/artifact/group.py +31 -0
  23. erk/cli/commands/artifact/list_cmd.py +89 -0
  24. erk/cli/commands/artifact/show.py +62 -0
  25. erk/cli/commands/artifact/sync_cmd.py +39 -0
  26. erk/cli/commands/branch/__init__.py +26 -0
  27. erk/cli/commands/branch/assign_cmd.py +152 -0
  28. erk/cli/commands/branch/checkout_cmd.py +357 -0
  29. erk/cli/commands/branch/create_cmd.py +161 -0
  30. erk/cli/commands/branch/list_cmd.py +82 -0
  31. erk/cli/commands/branch/unassign_cmd.py +197 -0
  32. erk/cli/commands/cc/__init__.py +15 -0
  33. erk/cli/commands/cc/jsonl_cmd.py +20 -0
  34. erk/cli/commands/cc/session/AGENTS.md +30 -0
  35. erk/cli/commands/cc/session/CLAUDE.md +1 -0
  36. erk/cli/commands/cc/session/__init__.py +15 -0
  37. erk/cli/commands/cc/session/list_cmd.py +167 -0
  38. erk/cli/commands/cc/session/show_cmd.py +175 -0
  39. erk/cli/commands/completion.py +89 -0
  40. erk/cli/commands/completions.py +165 -0
  41. erk/cli/commands/config.py +327 -0
  42. erk/cli/commands/docs/__init__.py +1 -0
  43. erk/cli/commands/docs/group.py +16 -0
  44. erk/cli/commands/docs/sync.py +121 -0
  45. erk/cli/commands/docs/validate.py +102 -0
  46. erk/cli/commands/doctor.py +243 -0
  47. erk/cli/commands/down.py +171 -0
  48. erk/cli/commands/exec/__init__.py +1 -0
  49. erk/cli/commands/exec/group.py +164 -0
  50. erk/cli/commands/exec/scripts/AGENTS.md +79 -0
  51. erk/cli/commands/exec/scripts/CLAUDE.md +1 -0
  52. erk/cli/commands/exec/scripts/__init__.py +5 -0
  53. erk/cli/commands/exec/scripts/add_reaction_to_comment.py +69 -0
  54. erk/cli/commands/exec/scripts/add_remote_execution_note.py +68 -0
  55. erk/cli/commands/exec/scripts/check_impl.py +152 -0
  56. erk/cli/commands/exec/scripts/ci_update_pr_body.py +294 -0
  57. erk/cli/commands/exec/scripts/create_extraction_branch.py +138 -0
  58. erk/cli/commands/exec/scripts/create_extraction_plan.py +242 -0
  59. erk/cli/commands/exec/scripts/create_issue_from_session.py +103 -0
  60. erk/cli/commands/exec/scripts/create_plan_from_context.py +103 -0
  61. erk/cli/commands/exec/scripts/create_worker_impl_from_issue.py +93 -0
  62. erk/cli/commands/exec/scripts/detect_trunk_branch.py +121 -0
  63. erk/cli/commands/exec/scripts/exit_plan_mode_hook.py +777 -0
  64. erk/cli/commands/exec/scripts/extract_latest_plan.py +49 -0
  65. erk/cli/commands/exec/scripts/extract_session_from_issue.py +150 -0
  66. erk/cli/commands/exec/scripts/find_project_dir.py +214 -0
  67. erk/cli/commands/exec/scripts/generate_pr_summary.py +112 -0
  68. erk/cli/commands/exec/scripts/get_closing_text.py +98 -0
  69. erk/cli/commands/exec/scripts/get_embedded_prompt.py +62 -0
  70. erk/cli/commands/exec/scripts/get_plan_metadata.py +95 -0
  71. erk/cli/commands/exec/scripts/get_pr_body_footer.py +70 -0
  72. erk/cli/commands/exec/scripts/get_pr_discussion_comments.py +149 -0
  73. erk/cli/commands/exec/scripts/get_pr_review_comments.py +155 -0
  74. erk/cli/commands/exec/scripts/impl_init.py +158 -0
  75. erk/cli/commands/exec/scripts/impl_signal.py +375 -0
  76. erk/cli/commands/exec/scripts/impl_verify.py +49 -0
  77. erk/cli/commands/exec/scripts/issue_title_to_filename.py +34 -0
  78. erk/cli/commands/exec/scripts/list_sessions.py +296 -0
  79. erk/cli/commands/exec/scripts/mark_impl_ended.py +188 -0
  80. erk/cli/commands/exec/scripts/mark_impl_started.py +188 -0
  81. erk/cli/commands/exec/scripts/marker.py +163 -0
  82. erk/cli/commands/exec/scripts/objective_save_to_issue.py +109 -0
  83. erk/cli/commands/exec/scripts/plan_save_to_issue.py +269 -0
  84. erk/cli/commands/exec/scripts/plan_update_issue.py +147 -0
  85. erk/cli/commands/exec/scripts/post_extraction_comment.py +237 -0
  86. erk/cli/commands/exec/scripts/post_or_update_pr_summary.py +133 -0
  87. erk/cli/commands/exec/scripts/post_pr_inline_comment.py +143 -0
  88. erk/cli/commands/exec/scripts/post_workflow_started_comment.py +168 -0
  89. erk/cli/commands/exec/scripts/preprocess_session.py +777 -0
  90. erk/cli/commands/exec/scripts/quick_submit.py +32 -0
  91. erk/cli/commands/exec/scripts/rebase_with_conflict_resolution.py +260 -0
  92. erk/cli/commands/exec/scripts/reply_to_discussion_comment.py +173 -0
  93. erk/cli/commands/exec/scripts/resolve_review_thread.py +170 -0
  94. erk/cli/commands/exec/scripts/session_id_injector_hook.py +52 -0
  95. erk/cli/commands/exec/scripts/setup_impl_from_issue.py +159 -0
  96. erk/cli/commands/exec/scripts/slot_objective.py +102 -0
  97. erk/cli/commands/exec/scripts/tripwires_reminder_hook.py +20 -0
  98. erk/cli/commands/exec/scripts/update_dispatch_info.py +116 -0
  99. erk/cli/commands/exec/scripts/user_prompt_hook.py +113 -0
  100. erk/cli/commands/exec/scripts/validate_plan_content.py +98 -0
  101. erk/cli/commands/exec/scripts/wrap_plan_in_metadata_block.py +34 -0
  102. erk/cli/commands/implement.py +695 -0
  103. erk/cli/commands/implement_shared.py +649 -0
  104. erk/cli/commands/info/__init__.py +14 -0
  105. erk/cli/commands/info/release_notes_cmd.py +128 -0
  106. erk/cli/commands/init.py +801 -0
  107. erk/cli/commands/land_cmd.py +690 -0
  108. erk/cli/commands/log_cmd.py +137 -0
  109. erk/cli/commands/md/__init__.py +5 -0
  110. erk/cli/commands/md/check.py +118 -0
  111. erk/cli/commands/md/group.py +14 -0
  112. erk/cli/commands/navigation_helpers.py +430 -0
  113. erk/cli/commands/objective/__init__.py +16 -0
  114. erk/cli/commands/objective/list_cmd.py +47 -0
  115. erk/cli/commands/objective_helpers.py +132 -0
  116. erk/cli/commands/plan/__init__.py +32 -0
  117. erk/cli/commands/plan/check_cmd.py +174 -0
  118. erk/cli/commands/plan/close_cmd.py +69 -0
  119. erk/cli/commands/plan/create_cmd.py +120 -0
  120. erk/cli/commands/plan/docs/__init__.py +18 -0
  121. erk/cli/commands/plan/docs/extract_cmd.py +53 -0
  122. erk/cli/commands/plan/docs/unextract_cmd.py +38 -0
  123. erk/cli/commands/plan/docs/unextracted_cmd.py +72 -0
  124. erk/cli/commands/plan/extraction/__init__.py +16 -0
  125. erk/cli/commands/plan/extraction/complete_cmd.py +101 -0
  126. erk/cli/commands/plan/extraction/create_raw_cmd.py +63 -0
  127. erk/cli/commands/plan/get.py +71 -0
  128. erk/cli/commands/plan/list_cmd.py +754 -0
  129. erk/cli/commands/plan/log_cmd.py +440 -0
  130. erk/cli/commands/plan/start_cmd.py +459 -0
  131. erk/cli/commands/planner/__init__.py +40 -0
  132. erk/cli/commands/planner/configure_cmd.py +73 -0
  133. erk/cli/commands/planner/connect_cmd.py +96 -0
  134. erk/cli/commands/planner/create_cmd.py +148 -0
  135. erk/cli/commands/planner/list_cmd.py +51 -0
  136. erk/cli/commands/planner/register_cmd.py +105 -0
  137. erk/cli/commands/planner/set_default_cmd.py +23 -0
  138. erk/cli/commands/planner/unregister_cmd.py +43 -0
  139. erk/cli/commands/pr/__init__.py +23 -0
  140. erk/cli/commands/pr/check_cmd.py +112 -0
  141. erk/cli/commands/pr/checkout_cmd.py +165 -0
  142. erk/cli/commands/pr/fix_conflicts_cmd.py +82 -0
  143. erk/cli/commands/pr/parse_pr_reference.py +10 -0
  144. erk/cli/commands/pr/submit_cmd.py +360 -0
  145. erk/cli/commands/pr/sync_cmd.py +181 -0
  146. erk/cli/commands/prepare_cwd_recovery.py +60 -0
  147. erk/cli/commands/project/__init__.py +16 -0
  148. erk/cli/commands/project/init_cmd.py +91 -0
  149. erk/cli/commands/run/__init__.py +17 -0
  150. erk/cli/commands/run/list_cmd.py +189 -0
  151. erk/cli/commands/run/logs_cmd.py +54 -0
  152. erk/cli/commands/run/shared.py +19 -0
  153. erk/cli/commands/shell_integration.py +29 -0
  154. erk/cli/commands/slot/__init__.py +23 -0
  155. erk/cli/commands/slot/check_cmd.py +277 -0
  156. erk/cli/commands/slot/common.py +314 -0
  157. erk/cli/commands/slot/init_pool_cmd.py +157 -0
  158. erk/cli/commands/slot/list_cmd.py +228 -0
  159. erk/cli/commands/slot/repair_cmd.py +190 -0
  160. erk/cli/commands/stack/__init__.py +23 -0
  161. erk/cli/commands/stack/consolidate_cmd.py +470 -0
  162. erk/cli/commands/stack/list_cmd.py +79 -0
  163. erk/cli/commands/stack/move_cmd.py +309 -0
  164. erk/cli/commands/stack/split_old/README.md +64 -0
  165. erk/cli/commands/stack/split_old/__init__.py +5 -0
  166. erk/cli/commands/stack/split_old/command.py +233 -0
  167. erk/cli/commands/stack/split_old/display.py +116 -0
  168. erk/cli/commands/stack/split_old/plan.py +216 -0
  169. erk/cli/commands/status.py +58 -0
  170. erk/cli/commands/submit.py +768 -0
  171. erk/cli/commands/up.py +154 -0
  172. erk/cli/commands/upgrade.py +82 -0
  173. erk/cli/commands/wt/__init__.py +29 -0
  174. erk/cli/commands/wt/checkout_cmd.py +110 -0
  175. erk/cli/commands/wt/create_cmd.py +998 -0
  176. erk/cli/commands/wt/current_cmd.py +35 -0
  177. erk/cli/commands/wt/delete_cmd.py +573 -0
  178. erk/cli/commands/wt/list_cmd.py +332 -0
  179. erk/cli/commands/wt/rename_cmd.py +66 -0
  180. erk/cli/config.py +242 -0
  181. erk/cli/constants.py +29 -0
  182. erk/cli/core.py +65 -0
  183. erk/cli/debug.py +9 -0
  184. erk/cli/ensure-conversion-tasks.md +288 -0
  185. erk/cli/ensure.py +628 -0
  186. erk/cli/github_parsing.py +96 -0
  187. erk/cli/graphite.py +81 -0
  188. erk/cli/graphite_command.py +80 -0
  189. erk/cli/help_formatter.py +345 -0
  190. erk/cli/output.py +361 -0
  191. erk/cli/presets/dagster.toml +12 -0
  192. erk/cli/presets/generic.toml +12 -0
  193. erk/cli/prompt_hooks_templates/README.md +68 -0
  194. erk/cli/script_output.py +32 -0
  195. erk/cli/shell_integration/bash_wrapper.sh +32 -0
  196. erk/cli/shell_integration/fish_wrapper.fish +39 -0
  197. erk/cli/shell_integration/handler.py +338 -0
  198. erk/cli/shell_integration/zsh_wrapper.sh +32 -0
  199. erk/cli/shell_utils.py +171 -0
  200. erk/cli/subprocess_utils.py +92 -0
  201. erk/cli/uvx_detection.py +59 -0
  202. erk/core/__init__.py +0 -0
  203. erk/core/claude_executor.py +511 -0
  204. erk/core/claude_settings.py +317 -0
  205. erk/core/command_log.py +406 -0
  206. erk/core/commit_message_generator.py +234 -0
  207. erk/core/completion.py +10 -0
  208. erk/core/consolidation_utils.py +177 -0
  209. erk/core/context.py +570 -0
  210. erk/core/display/__init__.py +4 -0
  211. erk/core/display/abc.py +24 -0
  212. erk/core/display/real.py +30 -0
  213. erk/core/display_utils.py +526 -0
  214. erk/core/file_utils.py +87 -0
  215. erk/core/health_checks.py +1315 -0
  216. erk/core/health_checks_dogfooder/__init__.py +85 -0
  217. erk/core/health_checks_dogfooder/deprecated_dot_agent_config.py +64 -0
  218. erk/core/health_checks_dogfooder/legacy_claude_docs.py +69 -0
  219. erk/core/health_checks_dogfooder/legacy_config_locations.py +122 -0
  220. erk/core/health_checks_dogfooder/legacy_erk_docs_agent.py +61 -0
  221. erk/core/health_checks_dogfooder/legacy_erk_kits_folder.py +60 -0
  222. erk/core/health_checks_dogfooder/legacy_hook_settings.py +104 -0
  223. erk/core/health_checks_dogfooder/legacy_kit_yaml.py +78 -0
  224. erk/core/health_checks_dogfooder/legacy_kits_toml.py +43 -0
  225. erk/core/health_checks_dogfooder/outdated_erk_skill.py +43 -0
  226. erk/core/implementation_queue/__init__.py +1 -0
  227. erk/core/implementation_queue/github/__init__.py +8 -0
  228. erk/core/implementation_queue/github/abc.py +7 -0
  229. erk/core/implementation_queue/github/noop.py +38 -0
  230. erk/core/implementation_queue/github/printing.py +43 -0
  231. erk/core/implementation_queue/github/real.py +119 -0
  232. erk/core/init_utils.py +227 -0
  233. erk/core/output_filter.py +338 -0
  234. erk/core/plan_store/__init__.py +6 -0
  235. erk/core/planner/__init__.py +1 -0
  236. erk/core/planner/registry_abc.py +8 -0
  237. erk/core/planner/registry_fake.py +129 -0
  238. erk/core/planner/registry_real.py +195 -0
  239. erk/core/planner/types.py +7 -0
  240. erk/core/pr_utils.py +30 -0
  241. erk/core/release_notes.py +263 -0
  242. erk/core/repo_discovery.py +126 -0
  243. erk/core/script_writer.py +41 -0
  244. erk/core/services/__init__.py +1 -0
  245. erk/core/services/plan_list_service.py +94 -0
  246. erk/core/shell.py +51 -0
  247. erk/core/user_feedback.py +11 -0
  248. erk/core/version_check.py +55 -0
  249. erk/core/workflow_display.py +75 -0
  250. erk/core/worktree_pool.py +190 -0
  251. erk/core/worktree_utils.py +300 -0
  252. erk/data/CHANGELOG.md +438 -0
  253. erk/data/__init__.py +1 -0
  254. erk/data/claude/agents/devrun.md +180 -0
  255. erk/data/claude/commands/erk/__init__.py +0 -0
  256. erk/data/claude/commands/erk/create-extraction-plan.md +360 -0
  257. erk/data/claude/commands/erk/fix-conflicts.md +25 -0
  258. erk/data/claude/commands/erk/git-pr-push.md +345 -0
  259. erk/data/claude/commands/erk/implement-stacked-plan.md +96 -0
  260. erk/data/claude/commands/erk/land.md +193 -0
  261. erk/data/claude/commands/erk/objective-create.md +370 -0
  262. erk/data/claude/commands/erk/objective-list.md +34 -0
  263. erk/data/claude/commands/erk/objective-next-plan.md +220 -0
  264. erk/data/claude/commands/erk/objective-update-with-landed-pr.md +216 -0
  265. erk/data/claude/commands/erk/plan-implement.md +202 -0
  266. erk/data/claude/commands/erk/plan-save.md +45 -0
  267. erk/data/claude/commands/erk/plan-submit.md +39 -0
  268. erk/data/claude/commands/erk/pr-address.md +367 -0
  269. erk/data/claude/commands/erk/pr-submit.md +58 -0
  270. erk/data/claude/skills/dignified-python/SKILL.md +48 -0
  271. erk/data/claude/skills/dignified-python/cli-patterns.md +155 -0
  272. erk/data/claude/skills/dignified-python/dignified-python-core.md +1190 -0
  273. erk/data/claude/skills/dignified-python/subprocess.md +99 -0
  274. erk/data/claude/skills/dignified-python/versions/python-3.10.md +517 -0
  275. erk/data/claude/skills/dignified-python/versions/python-3.11.md +536 -0
  276. erk/data/claude/skills/dignified-python/versions/python-3.12.md +662 -0
  277. erk/data/claude/skills/dignified-python/versions/python-3.13.md +653 -0
  278. erk/data/claude/skills/erk-diff-analysis/SKILL.md +27 -0
  279. erk/data/claude/skills/erk-diff-analysis/references/commit-message-prompt.md +78 -0
  280. erk/data/claude/skills/learned-docs/SKILL.md +362 -0
  281. erk/data/github/actions/setup-claude-erk/action.yml +11 -0
  282. erk/data/github/prompts/dignified-python-review.md +125 -0
  283. erk/data/github/workflows/dignified-python-review.yml +61 -0
  284. erk/data/github/workflows/erk-impl.yml +251 -0
  285. erk/hooks/__init__.py +1 -0
  286. erk/hooks/decorators.py +319 -0
  287. erk/status/__init__.py +8 -0
  288. erk/status/collectors/__init__.py +9 -0
  289. erk/status/collectors/base.py +52 -0
  290. erk/status/collectors/git.py +76 -0
  291. erk/status/collectors/github.py +81 -0
  292. erk/status/collectors/graphite.py +80 -0
  293. erk/status/collectors/impl.py +145 -0
  294. erk/status/models/__init__.py +4 -0
  295. erk/status/models/status_data.py +404 -0
  296. erk/status/orchestrator.py +169 -0
  297. erk/status/renderers/__init__.py +5 -0
  298. erk/status/renderers/simple.py +322 -0
  299. erk/tui/AGENTS.md +193 -0
  300. erk/tui/CLAUDE.md +1 -0
  301. erk/tui/__init__.py +1 -0
  302. erk/tui/app.py +1404 -0
  303. erk/tui/commands/__init__.py +1 -0
  304. erk/tui/commands/executor.py +66 -0
  305. erk/tui/commands/provider.py +165 -0
  306. erk/tui/commands/real_executor.py +63 -0
  307. erk/tui/commands/registry.py +121 -0
  308. erk/tui/commands/types.py +36 -0
  309. erk/tui/data/__init__.py +1 -0
  310. erk/tui/data/provider.py +492 -0
  311. erk/tui/data/types.py +104 -0
  312. erk/tui/filtering/__init__.py +1 -0
  313. erk/tui/filtering/logic.py +43 -0
  314. erk/tui/filtering/types.py +55 -0
  315. erk/tui/jsonl_viewer/__init__.py +1 -0
  316. erk/tui/jsonl_viewer/app.py +61 -0
  317. erk/tui/jsonl_viewer/models.py +208 -0
  318. erk/tui/jsonl_viewer/widgets.py +204 -0
  319. erk/tui/sorting/__init__.py +6 -0
  320. erk/tui/sorting/logic.py +55 -0
  321. erk/tui/sorting/types.py +68 -0
  322. erk/tui/styles/dash.tcss +95 -0
  323. erk/tui/widgets/__init__.py +1 -0
  324. erk/tui/widgets/command_output.py +112 -0
  325. erk/tui/widgets/plan_table.py +276 -0
  326. erk/tui/widgets/status_bar.py +116 -0
  327. erk-0.4.5.dist-info/METADATA +376 -0
  328. erk-0.4.5.dist-info/RECORD +331 -0
  329. erk-0.4.5.dist-info/WHEEL +4 -0
  330. erk-0.4.5.dist-info/entry_points.txt +2 -0
  331. erk-0.4.5.dist-info/licenses/LICENSE.md +3 -0
@@ -0,0 +1,1190 @@
1
+ ---
2
+ ---
3
+
4
+ # Dignified Python - Core Standards
5
+
6
+ This document contains the core Python coding standards that apply to 80%+ of Python code. These principles are loaded with every skill invocation.
7
+
8
+ For conditional loading of specialized patterns:
9
+
10
+ - CLI development → Load `cli-patterns.md`
11
+ - Subprocess operations → Load `subprocess.md`
12
+
13
+ ---
14
+
15
+ ## The Cornerstone: LBYL Over EAFP
16
+
17
+ **Look Before You Leap: Check conditions proactively, NEVER use exceptions for control flow.**
18
+
19
+ This is the single most important rule in dignified Python. Every pattern below flows from this principle.
20
+
21
+ ```python
22
+ # ✅ CORRECT: Check first
23
+ if key in mapping:
24
+ value = mapping[key]
25
+ process(value)
26
+
27
+ # ❌ WRONG: Exception as control flow
28
+ try:
29
+ value = mapping[key]
30
+ process(value)
31
+ except KeyError:
32
+ pass
33
+ ```
34
+
35
+ ---
36
+
37
+ ## Exception Handling
38
+
39
+ ### Core Principle
40
+
41
+ **ALWAYS use LBYL, NEVER EAFP for control flow**
42
+
43
+ LBYL means checking conditions before acting. EAFP (Easier to Ask for Forgiveness than Permission) means trying operations and catching exceptions. In dignified Python, we strongly prefer LBYL.
44
+
45
+ ### Dictionary Access Patterns
46
+
47
+ ```python
48
+ # ✅ CORRECT: Membership testing
49
+ if key in mapping:
50
+ value = mapping[key]
51
+ process(value)
52
+ else:
53
+ handle_missing()
54
+
55
+ # ✅ ALSO CORRECT: .get() with default
56
+ value = mapping.get(key, default_value)
57
+ process(value)
58
+
59
+ # ✅ CORRECT: Check before nested access
60
+ if "config" in data and "timeout" in data["config"]:
61
+ timeout = data["config"]["timeout"]
62
+
63
+ # ❌ WRONG: KeyError as control flow
64
+ try:
65
+ value = mapping[key]
66
+ except KeyError:
67
+ handle_missing()
68
+ ```
69
+
70
+ ### When Exceptions ARE Acceptable
71
+
72
+ Exceptions are ONLY acceptable at:
73
+
74
+ 1. **Error boundaries** (CLI/API level)
75
+ 2. **Third-party API compatibility** (when no alternative exists)
76
+ 3. **Adding context before re-raising**
77
+
78
+ #### 1. Error Boundaries
79
+
80
+ ```python
81
+ # ✅ ACCEPTABLE: CLI command error boundary
82
+ @click.command("create")
83
+ @click.pass_obj
84
+ def create(ctx: ErkContext, name: str) -> None:
85
+ """Create a worktree."""
86
+ try:
87
+ create_worktree(ctx, name)
88
+ except subprocess.CalledProcessError as e:
89
+ click.echo(f"Error: Git command failed: {e.stderr}", err=True)
90
+ raise SystemExit(1)
91
+ ```
92
+
93
+ #### 2. Third-Party API Compatibility
94
+
95
+ ```python
96
+ # ✅ ACCEPTABLE: Third-party API forces exception handling
97
+ def _get_bigquery_sample(sql_client, table_name):
98
+ """
99
+ BigQuery's TABLESAMPLE doesn't work on views.
100
+ There's no reliable way to determine a priori whether
101
+ a table supports TABLESAMPLE.
102
+ """
103
+ try:
104
+ return sql_client.run_query(f"SELECT * FROM {table_name} TABLESAMPLE...")
105
+ except Exception:
106
+ return sql_client.run_query(f"SELECT * FROM {table_name} ORDER BY RAND()...")
107
+ ```
108
+
109
+ > **The test for "no alternative exists"**: Can you validate or check the condition BEFORE calling the API? If yes (even using a different function/method), use LBYL. The exception only applies when the API provides NO way to determine success a priori—you literally must attempt the operation to know if it will work.
110
+
111
+ #### What Does NOT Qualify as Third-Party API Compatibility
112
+
113
+ Standard library functions with known LBYL alternatives do NOT qualify:
114
+
115
+ ```python
116
+ # ❌ WRONG: int() has LBYL alternative (str.isdigit)
117
+ try:
118
+ port = int(user_input)
119
+ except ValueError:
120
+ port = 80
121
+
122
+ # ✅ CORRECT: Check before calling
123
+ if user_input.lstrip('-+').isdigit():
124
+ port = int(user_input)
125
+ else:
126
+ port = 80
127
+
128
+ # ❌ WRONG: datetime.fromisoformat() can be validated first
129
+ try:
130
+ dt = datetime.fromisoformat(timestamp_str)
131
+ except ValueError:
132
+ dt = None
133
+
134
+ # ✅ CORRECT: Validate format before parsing
135
+ def _is_iso_format(s: str) -> bool:
136
+ return len(s) >= 10 and s[4] == "-" and s[7] == "-"
137
+
138
+ if _is_iso_format(timestamp_str):
139
+ dt = datetime.fromisoformat(timestamp_str)
140
+ else:
141
+ dt = None
142
+ ```
143
+
144
+ #### 3. Adding Context Before Re-raising
145
+
146
+ ```python
147
+ # ✅ ACCEPTABLE: Adding context before re-raising
148
+ try:
149
+ process_file(config_file)
150
+ except yaml.YAMLError as e:
151
+ raise ValueError(f"Failed to parse config file {config_file}: {e}") from e
152
+ ```
153
+
154
+ ### Exception Chaining (B904 Lint Compliance)
155
+
156
+ **Ruff rule B904** requires explicit exception chaining when raising inside an `except` block. This prevents losing the original traceback.
157
+
158
+ ```python
159
+ # ✅ CORRECT: Chain to preserve context
160
+ try:
161
+ parse_config(path)
162
+ except ValueError as e:
163
+ click.echo(json.dumps({"success": False, "error": str(e)}))
164
+ raise SystemExit(1) from e # Preserves traceback
165
+
166
+ # ✅ CORRECT: Explicitly break chain when intentional
167
+ try:
168
+ fetch_from_cache(key)
169
+ except KeyError:
170
+ # Original exception is not relevant to caller
171
+ raise ValueError(f"Unknown key: {key}") from None
172
+
173
+ # ❌ WRONG: Missing exception chain (B904 violation)
174
+ try:
175
+ parse_config(path)
176
+ except ValueError:
177
+ raise SystemExit(1) # Lint error: missing 'from e' or 'from None'
178
+
179
+ # ✅ CORRECT: CLI error boundary with JSON output
180
+ try:
181
+ result = some_operation()
182
+ except RuntimeError as e:
183
+ click.echo(json.dumps({"success": False, "error": str(e)}))
184
+ raise SystemExit(0) from None # Exception is in JSON, traceback irrelevant to CLI user
185
+ ```
186
+
187
+ **When to use each:**
188
+
189
+ - `from e` - Preserve original exception for debugging
190
+ - `from None` - Intentionally suppress original (e.g., transforming exception type, CLI JSON output)
191
+
192
+ ### Exception Anti-Patterns
193
+
194
+ **❌ Never swallow exceptions silently**
195
+
196
+ Even at error boundaries, you must at least log/warn so issues can be diagnosed:
197
+
198
+ ```python
199
+ # ❌ WRONG: Silent exception swallowing
200
+ try:
201
+ risky_operation()
202
+ except:
203
+ pass
204
+
205
+ # ❌ WRONG: Silent swallowing even at error boundary
206
+ try:
207
+ optional_feature()
208
+ except Exception:
209
+ pass # Silent - impossible to diagnose issues
210
+
211
+ # ✅ CORRECT: Let exceptions bubble up (default)
212
+ risky_operation()
213
+
214
+ # ✅ CORRECT: At error boundaries, log the exception
215
+ try:
216
+ optional_feature()
217
+ except Exception as e:
218
+ logging.warning("Optional feature failed: %s", e) # Diagnosable
219
+ ```
220
+
221
+ **❌ Never use silent fallback behavior**
222
+
223
+ ```python
224
+ # ❌ WRONG: Silent fallback masks failure
225
+ def process_text(text: str) -> dict:
226
+ try:
227
+ return llm_client.process(text)
228
+ except Exception:
229
+ return regex_parse_fallback(text)
230
+
231
+ # ✅ CORRECT: Let error bubble to boundary
232
+ def process_text(text: str) -> dict:
233
+ return llm_client.process(text)
234
+ ```
235
+
236
+ ---
237
+
238
+ ## Path Operations
239
+
240
+ ### The Golden Rule
241
+
242
+ **ALWAYS check `.exists()` BEFORE `.resolve()` or `.is_relative_to()`**
243
+
244
+ ### Why This Matters
245
+
246
+ - `.resolve()` raises `OSError` for non-existent paths
247
+ - `.is_relative_to()` raises `ValueError` for invalid comparisons
248
+ - Checking `.exists()` first avoids exceptions entirely (LBYL!)
249
+
250
+ ### Correct Patterns
251
+
252
+ ```python
253
+ from pathlib import Path
254
+
255
+ # ✅ CORRECT: Check exists first
256
+ for wt_path in worktree_paths:
257
+ if wt_path.exists():
258
+ wt_path_resolved = wt_path.resolve()
259
+ if current_dir.is_relative_to(wt_path_resolved):
260
+ current_worktree = wt_path_resolved
261
+ break
262
+
263
+ # ❌ WRONG: Using exceptions for path validation
264
+ try:
265
+ wt_path_resolved = wt_path.resolve()
266
+ if current_dir.is_relative_to(wt_path_resolved):
267
+ current_worktree = wt_path_resolved
268
+ except (OSError, ValueError):
269
+ continue
270
+ ```
271
+
272
+ ### Pathlib Best Practices
273
+
274
+ **Always Use Pathlib (Never os.path)**
275
+
276
+ ```python
277
+ # ✅ CORRECT: Use pathlib.Path
278
+ from pathlib import Path
279
+
280
+ config_file = Path.home() / ".config" / "app.yml"
281
+ if config_file.exists():
282
+ content = config_file.read_text(encoding="utf-8")
283
+
284
+ # ❌ WRONG: Use os.path
285
+ import os.path
286
+ config_file = os.path.join(os.path.expanduser("~"), ".config", "app.yml")
287
+ ```
288
+
289
+ **Always Specify Encoding**
290
+
291
+ ```python
292
+ # ✅ CORRECT: Always specify encoding
293
+ content = path.read_text(encoding="utf-8")
294
+ path.write_text(data, encoding="utf-8")
295
+
296
+ # ❌ WRONG: Default encoding
297
+ content = path.read_text() # Platform-dependent!
298
+ ```
299
+
300
+ ---
301
+
302
+ ## Import Organization
303
+
304
+ ### Core Rules
305
+
306
+ 1. **Default: ALWAYS place imports at module level**
307
+ 2. **Use absolute imports only** (no relative imports)
308
+ 3. **Inline imports only for specific exceptions** (see below)
309
+
310
+ ### Correct Import Patterns
311
+
312
+ ```python
313
+ # ✅ CORRECT: Module-level imports
314
+ import json
315
+ import click
316
+ from pathlib import Path
317
+ from erk.config import load_config
318
+
319
+ def my_function() -> None:
320
+ data = json.loads(content)
321
+ click.echo("Processing")
322
+ config = load_config()
323
+
324
+ # ❌ WRONG: Inline imports without justification
325
+ def my_function() -> None:
326
+ import json # NEVER do this
327
+ import click # NEVER do this
328
+ data = json.loads(content)
329
+ ```
330
+
331
+ ### Legitimate Inline Import Patterns
332
+
333
+ #### 1. Circular Import Prevention
334
+
335
+ ```python
336
+ # commands/sync.py
337
+ def register_commands(cli_group):
338
+ """Register commands with CLI group (avoids circular import)."""
339
+ from myapp.cli import sync_command # Breaks circular dependency
340
+ cli_group.add_command(sync_command)
341
+ ```
342
+
343
+ **When to use:**
344
+
345
+ - CLI command registration
346
+ - Plugin systems with bidirectional dependencies
347
+ - Lazy loading to break import cycles
348
+
349
+ #### 2. Conditional Feature Imports
350
+
351
+ ```python
352
+ def process_data(data: dict, dry_run: bool = False) -> None:
353
+ if dry_run:
354
+ # Inline import: Only needed for dry-run mode
355
+ from myapp.dry_run import NoopProcessor
356
+ processor = NoopProcessor()
357
+ else:
358
+ processor = RealProcessor()
359
+ processor.execute(data)
360
+ ```
361
+
362
+ **When to use:**
363
+
364
+ - Debug/verbose mode utilities
365
+ - Dry-run mode wrappers
366
+ - Optional feature modules
367
+ - Platform-specific implementations
368
+
369
+ #### 3. TYPE_CHECKING Imports
370
+
371
+ ```python
372
+ from typing import TYPE_CHECKING
373
+
374
+ if TYPE_CHECKING:
375
+ from myapp.models import User # Only for type hints
376
+
377
+ def process_user(user: "User") -> None:
378
+ ...
379
+ ```
380
+
381
+ **When to use:**
382
+
383
+ - Avoiding circular dependencies in type hints
384
+ - Forward declarations
385
+
386
+ #### 4. Startup Time Optimization (Rare)
387
+
388
+ Some packages have genuinely heavy import costs (pyspark, jupyter ecosystem, large ML frameworks). Deferring these imports can improve CLI startup time.
389
+
390
+ **However, apply "innocent until proven guilty":**
391
+
392
+ - Default to module-level imports
393
+ - Only defer imports when you have MEASURED evidence of startup impact
394
+ - Document the measured cost in a comment
395
+
396
+ ```python
397
+ # ✅ ACCEPTABLE: Measured heavy import (adds 800ms to startup)
398
+ def run_spark_job(config: SparkConfig) -> None:
399
+ from pyspark.sql import SparkSession # Heavy: 800ms import time
400
+ session = SparkSession.builder.getOrCreate()
401
+ ...
402
+
403
+ # ❌ WRONG: Speculative deferral without measurement
404
+ def check_staleness(project_dir: Path) -> None:
405
+ # Inline imports to avoid import-time side effects <- WRONG: no evidence
406
+ from myapp.staleness import get_version
407
+ ...
408
+ ```
409
+
410
+ **When NOT to defer:**
411
+
412
+ - Standard library modules
413
+ - Lightweight internal modules
414
+ - Modules you haven't measured
415
+ - "Just in case" optimization
416
+
417
+ ### Absolute vs Relative Imports
418
+
419
+ ```python
420
+ # ✅ CORRECT: Absolute import
421
+ from erk.config import load_config
422
+
423
+ # ❌ WRONG: Relative import
424
+ from .config import load_config
425
+ ```
426
+
427
+ ---
428
+
429
+ ## Import-Time Side Effects
430
+
431
+ ### Core Rule
432
+
433
+ **Avoid computation and side effects at import time. Defer to function calls.**
434
+
435
+ Module-level code runs when the module is imported. Side effects at import time cause:
436
+
437
+ 1. **Slower startup** - Every import triggers computation
438
+ 2. **Test brittleness** - Hard to mock/control behavior
439
+ 3. **Circular import issues** - Dependencies evaluated too early
440
+ 4. **Unpredictable order** - Import order affects behavior
441
+
442
+ ### Common Anti-Patterns
443
+
444
+ ```python
445
+ # ❌ WRONG: Path computed at import time
446
+ SESSION_ID_FILE = Path(".erk/scratch/current-session-id")
447
+
448
+ def get_session_id() -> str | None:
449
+ if SESSION_ID_FILE.exists():
450
+ return SESSION_ID_FILE.read_text(encoding="utf-8")
451
+ return None
452
+
453
+ # ❌ WRONG: Config loaded at import time
454
+ CONFIG = load_config() # I/O at import!
455
+
456
+ # ❌ WRONG: Connection established at import time
457
+ DB_CLIENT = DatabaseClient(os.environ["DB_URL"]) # Side effect at import!
458
+ ```
459
+
460
+ ### Correct Patterns
461
+
462
+ **Use `@cache` for deferred computation:**
463
+
464
+ ```python
465
+ from functools import cache
466
+
467
+ # ✅ CORRECT: Defer computation until first call
468
+ @cache
469
+ def _session_id_file_path() -> Path:
470
+ """Return path to session ID file (cached after first call)."""
471
+ return Path(".erk/scratch/current-session-id")
472
+
473
+ def get_session_id() -> str | None:
474
+ session_file = _session_id_file_path()
475
+ if session_file.exists():
476
+ return session_file.read_text(encoding="utf-8")
477
+ return None
478
+ ```
479
+
480
+ **Use functions for resources:**
481
+
482
+ ```python
483
+ # ✅ CORRECT: Defer resource creation to function call
484
+ @cache
485
+ def get_config() -> Config:
486
+ """Load config on first call, cache result."""
487
+ return load_config()
488
+
489
+ @cache
490
+ def get_db_client() -> DatabaseClient:
491
+ """Create database client on first call."""
492
+ return DatabaseClient(os.environ["DB_URL"])
493
+ ```
494
+
495
+ ### When Module-Level Constants ARE Acceptable
496
+
497
+ Simple, static values that don't involve computation or I/O:
498
+
499
+ ```python
500
+ # ✅ ACCEPTABLE: Static constants
501
+ DEFAULT_TIMEOUT = 30
502
+ MAX_RETRIES = 3
503
+ SUPPORTED_FORMATS = frozenset({"json", "yaml", "toml"})
504
+ ```
505
+
506
+ ### Decision Checklist
507
+
508
+ Before writing module-level code:
509
+
510
+ - [ ] Does this involve any computation (even `Path()` construction)?
511
+ - [ ] Does this involve I/O (file, network, environment)?
512
+ - [ ] Could this fail or raise exceptions?
513
+ - [ ] Would tests need to mock this value?
514
+
515
+ If any answer is "yes", wrap in a `@cache`-decorated function instead.
516
+
517
+ ---
518
+
519
+ ## Dependency Injection
520
+
521
+ ### Core Rule
522
+
523
+ **Use ABC for interfaces, NEVER Protocol**
524
+
525
+ ### ABC Interface Pattern
526
+
527
+ ```python
528
+ # ✅ CORRECT: Use ABC for interfaces
529
+ from abc import ABC, abstractmethod
530
+
531
+ class Repository(ABC):
532
+ @abstractmethod
533
+ def save(self, entity: Entity) -> None:
534
+ """Save entity to storage."""
535
+ ...
536
+
537
+ @abstractmethod
538
+ def load(self, id: str) -> Entity:
539
+ """Load entity by ID."""
540
+ ...
541
+
542
+ class PostgresRepository(Repository):
543
+ def save(self, entity: Entity) -> None:
544
+ # Implementation
545
+ pass
546
+
547
+ def load(self, id: str) -> Entity:
548
+ # Implementation
549
+ pass
550
+
551
+ # ❌ WRONG: Using Protocol
552
+ from typing import Protocol
553
+
554
+ class Repository(Protocol):
555
+ def save(self, entity: Entity) -> None: ...
556
+ def load(self, id: str) -> Entity: ...
557
+ ```
558
+
559
+ ### Benefits of ABC
560
+
561
+ 1. **Explicit inheritance** - Clear class hierarchy
562
+ 2. **Runtime validation** - Errors if abstract methods not implemented
563
+ 3. **Better IDE support** - Autocomplete and refactoring work better
564
+ 4. **Documentation** - Clear contract definition
565
+
566
+ ### Complete DI Example
567
+
568
+ ```python
569
+ from abc import ABC, abstractmethod
570
+ from dataclasses import dataclass
571
+
572
+ # Define the interface
573
+ class DataStore(ABC):
574
+ @abstractmethod
575
+ def get(self, key: str) -> str | None:
576
+ """Retrieve value by key."""
577
+ ...
578
+
579
+ @abstractmethod
580
+ def set(self, key: str, value: str) -> None:
581
+ """Store value with key."""
582
+ ...
583
+
584
+ # Real implementation
585
+ class RedisStore(DataStore):
586
+ def get(self, key: str) -> str | None:
587
+ return self.client.get(key)
588
+
589
+ def set(self, key: str, value: str) -> None:
590
+ self.client.set(key, value)
591
+
592
+ # Fake for testing
593
+ class FakeStore(DataStore):
594
+ def __init__(self) -> None:
595
+ self._data: dict[str, str] = {}
596
+
597
+ def get(self, key: str) -> str | None:
598
+ if key not in self._data:
599
+ return None
600
+ return self._data[key]
601
+
602
+ def set(self, key: str, value: str) -> None:
603
+ self._data[key] = value
604
+
605
+ # Business logic accepts interface
606
+ @dataclass
607
+ class Service:
608
+ store: DataStore # Depends on abstraction
609
+
610
+ def process(self, item: str) -> None:
611
+ cached = self.store.get(item)
612
+ if cached is None:
613
+ result = expensive_computation(item)
614
+ self.store.set(item, result)
615
+ else:
616
+ result = cached
617
+ use_result(result)
618
+ ```
619
+
620
+ ---
621
+
622
+ ## Performance Guidelines
623
+
624
+ ### Properties Must Be O(1)
625
+
626
+ ```python
627
+ # ❌ WRONG: Property doing I/O
628
+ @property
629
+ def size(self) -> int:
630
+ return self._fetch_from_db()
631
+
632
+ # ✅ CORRECT: Explicit method name
633
+ def fetch_size_from_db(self) -> int:
634
+ return self._fetch_from_db()
635
+
636
+ # ✅ CORRECT: O(1) property
637
+ @property
638
+ def size(self) -> int:
639
+ return self._cached_size
640
+ ```
641
+
642
+ ### Magic Methods Must Be O(1)
643
+
644
+ ```python
645
+ # ❌ WRONG: __len__ doing iteration
646
+ def __len__(self) -> int:
647
+ return sum(1 for _ in self._items)
648
+
649
+ # ✅ CORRECT: O(1) __len__
650
+ def __len__(self) -> int:
651
+ return self._count
652
+ ```
653
+
654
+ ---
655
+
656
+ ## Using `typing.cast()`
657
+
658
+ ### Core Rule
659
+
660
+ **ALWAYS verify `cast()` with a runtime assertion, unless there's a documented reason not to.**
661
+
662
+ `typing.cast()` is a compile-time only construct—it tells the type checker to trust you but performs no runtime verification. If your assumption is wrong, you'll get silent misbehavior instead of a clear error.
663
+
664
+ ### Required Pattern
665
+
666
+ ```python
667
+ from collections.abc import MutableMapping
668
+ from typing import Any, cast
669
+
670
+ # ✅ CORRECT: Runtime assertion before cast
671
+ assert isinstance(doc, MutableMapping), f"Expected MutableMapping, got {type(doc)}"
672
+ cast(dict[str, Any], doc)["key"] = value
673
+
674
+ # ✅ CORRECT: Alternative with hasattr for duck typing
675
+ assert hasattr(obj, '__setitem__'), f"Expected subscriptable, got {type(obj)}"
676
+ cast(dict[str, Any], obj)["key"] = value
677
+ ```
678
+
679
+ ### Anti-Pattern
680
+
681
+ ```python
682
+ # ❌ WRONG: Cast without runtime verification
683
+ cast(dict[str, Any], doc)["key"] = value # If doc isn't a dict-like, silent failure
684
+ ```
685
+
686
+ ### When to Skip Runtime Verification
687
+
688
+ **Default: Always add the assertion when cost is trivial (O(1) checks like `in`, `isinstance`).**
689
+
690
+ Skip the assertion only in these narrow cases:
691
+
692
+ 1. **Immediately after a type guard**: The check was just performed and would be redundant
693
+
694
+ ```python
695
+ if isinstance(value, str):
696
+ # No assertion needed - we just checked
697
+ result = cast(str, value).upper()
698
+ ```
699
+
700
+ 2. **Performance-critical hot path**: Add a comment explaining the measured overhead
701
+ ```python
702
+ # Skip assertion: called 10M times/sec, isinstance adds 15% overhead
703
+ # Type invariant maintained by _validate_input() at entry point
704
+ cast(int, cached_value)
705
+ ```
706
+
707
+ **What is NOT a valid reason to skip:**
708
+
709
+ - "Click validates the choice set" - Add assertion anyway; cost is trivial
710
+ - "The library guarantees the type" - Add assertion anyway; defense in depth
711
+ - "It's obvious from context" - Add assertion anyway; future readers benefit
712
+
713
+ ### Why This Matters
714
+
715
+ - **Silent bugs are worse than loud bugs**: An assertion failure gives you a stack trace and clear error message
716
+ - **Documentation**: The assertion documents your assumption for future readers
717
+ - **Defense in depth**: Third-party libraries can change behavior between versions
718
+
719
+ ---
720
+
721
+ ## Programmatically Significant Strings
722
+
723
+ **Use `Literal` types for strings that have programmatic meaning.**
724
+
725
+ When strings represent a fixed set of valid values (error codes, status values, command types), model them in the type system using `Literal`.
726
+
727
+ ### Why This Matters
728
+
729
+ 1. **Type safety** - Typos caught at type-check time, not runtime
730
+ 2. **IDE support** - Autocomplete shows valid options
731
+ 3. **Documentation** - Valid values are explicit in the code
732
+ 4. **Refactoring** - Rename operations work correctly
733
+
734
+ ### Naming Convention
735
+
736
+ **Use kebab-case for all internal Literal string values:**
737
+
738
+ ```python
739
+ # ✅ CORRECT: kebab-case for internal values
740
+ IssueCode = Literal["orphan-state", "orphan-dir", "missing-branch"]
741
+ ErrorType = Literal["not-found", "invalid-format", "timeout-exceeded"]
742
+ ```
743
+
744
+ **Exception: When modeling external systems, match the external API's convention:**
745
+
746
+ ```python
747
+ # ✅ CORRECT: Match GitHub API's UPPER_CASE
748
+ PRState = Literal["OPEN", "MERGED", "CLOSED"]
749
+
750
+ # ✅ CORRECT: Match GitHub Actions API's lowercase
751
+ WorkflowStatus = Literal["completed", "in_progress", "queued"]
752
+ ```
753
+
754
+ The rule is: kebab-case by default, external convention when modeling external APIs.
755
+
756
+ ### Pattern
757
+
758
+ ```python
759
+ from dataclasses import dataclass
760
+ from typing import Literal
761
+
762
+ # ✅ CORRECT: Define a type alias for the valid values
763
+ IssueCode = Literal["orphan-state", "orphan-dir", "missing-branch"]
764
+
765
+ @dataclass(frozen=True)
766
+ class Issue:
767
+ code: IssueCode
768
+ message: str
769
+
770
+ def check_state() -> list[Issue]:
771
+ issues: list[Issue] = []
772
+ if problem_detected:
773
+ issues.append(Issue(code="orphan-state", message="description")) # Type-checked!
774
+ return issues
775
+
776
+ # ❌ WRONG: Bare strings without type constraint
777
+ def check_state() -> list[tuple[str, str]]:
778
+ issues: list[tuple[str, str]] = []
779
+ issues.append(("orphen-state", "desc")) # Typo goes unnoticed!
780
+ return issues
781
+ ```
782
+
783
+ ### When to Use Literal
784
+
785
+ - Error/issue codes
786
+ - Status values (pending, complete, failed)
787
+ - Command types or action names
788
+ - Configuration keys with fixed valid values
789
+ - Any string that is compared programmatically
790
+
791
+ ### Decision Checklist
792
+
793
+ Before using a bare `str` type, ask:
794
+
795
+ - Is this string compared with `==` or `in` anywhere?
796
+ - Is there a fixed set of valid values?
797
+ - Would a typo in this string cause a bug?
798
+
799
+ If any answer is "yes", use `Literal` instead.
800
+
801
+ ---
802
+
803
+ ## Anti-Patterns
804
+
805
+ ### Preserving Unnecessary Backwards Compatibility
806
+
807
+ ```python
808
+ # ❌ WRONG: Keeping old API unnecessarily
809
+ def process_data(data: dict, legacy_format: bool = False) -> Result:
810
+ if legacy_format:
811
+ return legacy_process(data)
812
+ return new_process(data)
813
+
814
+ # ✅ CORRECT: Break and migrate immediately
815
+ def process_data(data: dict) -> Result:
816
+ return new_process(data)
817
+ ```
818
+
819
+ ### No Re-Exports: One Canonical Import Path
820
+
821
+ **Core Principle:** Every symbol has exactly one import path. Never re-export.
822
+
823
+ This rule applies to:
824
+
825
+ - `__all__` exports in `__init__.py`
826
+ - Re-exporting symbols from other modules
827
+ - Shim modules that import and expose symbols from elsewhere
828
+
829
+ ```python
830
+ # ❌ WRONG: __all__ exports create duplicate import paths
831
+ # myapp/__init__.py
832
+ from myapp.core import Process
833
+ __all__ = ["Process"]
834
+
835
+ # Now Process can be imported two ways - breaks grepability
836
+
837
+ # ❌ WRONG: Re-exporting symbols in a shim module
838
+ # myapp/compat.py
839
+ from myapp.core import Process, Config, execute
840
+ # These can now be imported from myapp.compat OR myapp.core
841
+
842
+ # ✅ CORRECT: Empty __init__.py, import from canonical location
843
+ # from myapp.core import Process
844
+
845
+ # ✅ CORRECT: Shim imports only what it needs for its own use
846
+ # myapp/cli_entry.py (needs the click command for CLI registration)
847
+ from myapp.core import main_command # Only import what this module uses
848
+ # Other code imports Process, Config from myapp.core directly
849
+ ```
850
+
851
+ **Why prohibited:**
852
+
853
+ 1. Breaks grepability - hard to find all usages
854
+ 2. Confuses static analysis tools
855
+ 3. Impairs refactoring safety
856
+ 4. Violates explicit > implicit
857
+ 5. Creates confusion about canonical import location
858
+
859
+ **Shim modules:** When a module must exist as an entry point (e.g., for plugin systems or CLI registration), import only the minimum symbols needed for that purpose. Document that other symbols should be imported from the canonical location.
860
+
861
+ **CI Review Behavior:**
862
+
863
+ - New `__all__` usage → Always flagged
864
+ - Modifications to existing `__all__` (adding exports) → Flagged
865
+ - Pre-existing `__all__` in file only moved/refactored (unchanged) → Skipped
866
+
867
+ The principle: If you're actively modifying a file, fix its violations. If you're just moving it, don't force unrelated cleanup.
868
+
869
+ **When re-exports ARE required:** Some systems (like plugin entry points) require a module to exist at a specific path and expose a specific symbol. In these cases, use the explicit `import X as X` syntax to signal intentional re-export:
870
+
871
+ ```python
872
+ # ✅ CORRECT: Explicit re-export syntax for required entry points
873
+ # This shim exists because the plugin system expects a module at this path
874
+ from myapp.core.feature import my_function as my_function
875
+
876
+ # ❌ WRONG: Plain import looks like unused import to linters
877
+ from myapp.core.feature import my_function # ruff will flag as F401
878
+ ```
879
+
880
+ The `as X` syntax is the PEP 484 standard for indicating intentional re-exports. It tells both linters and readers that this import is meant to be consumed from this module.
881
+
882
+ ### Default Parameter Values Are Dangerous
883
+
884
+ **Avoid default parameter values unless absolutely necessary.** They are a significant source of bugs.
885
+
886
+ **Why defaults are dangerous:**
887
+
888
+ 1. **Silent incorrect behavior** - Callers forget to pass a parameter and get unexpected results
889
+ 2. **Hidden coupling** - The default encodes an assumption that may not hold for all callers
890
+ 3. **Audit difficulty** - Hard to verify all call sites are using the right value
891
+ 4. **Refactoring hazard** - Adding a new parameter with a default doesn't trigger errors at existing call sites
892
+
893
+ ```python
894
+ # ❌ DANGEROUS: Default that might be wrong for some callers
895
+ def process_file(path: Path, encoding: str = "utf-8") -> str:
896
+ return path.read_text(encoding=encoding)
897
+
898
+ # Caller forgets encoding, silently gets wrong behavior for legacy file
899
+ content = process_file(legacy_latin1_file) # Bug: should be encoding="latin-1"
900
+
901
+ # ✅ SAFER: Require explicit choice
902
+ def process_file(path: Path, encoding: str) -> str:
903
+ return path.read_text(encoding=encoding)
904
+
905
+ # Caller must think about encoding
906
+ content = process_file(legacy_latin1_file, encoding="latin-1")
907
+ ```
908
+
909
+ **When you discover a default is never overridden, eliminate it:**
910
+
911
+ ```python
912
+ # If every call site uses the default...
913
+ activate_worktree(ctx, repo, path, script, "up", preserve_relative_path=True) # Always True
914
+ activate_worktree(ctx, repo, path, script, "down", preserve_relative_path=True) # Always True
915
+
916
+ # ✅ CORRECT: Remove the parameter entirely
917
+ def activate_worktree(ctx, repo, path, script, command_name) -> None:
918
+ # Always preserve relative path - it's just the behavior
919
+ ...
920
+ ```
921
+
922
+ **Acceptable uses of defaults:**
923
+
924
+ 1. **Truly optional behavior** - Where the default is correct for 95%+ of callers
925
+ 2. **Backwards compatibility** - When adding a parameter to existing API (temporary)
926
+ 3. **Test conveniences** - Defaults that simplify test setup
927
+
928
+ **When reviewing code with defaults, ask:**
929
+
930
+ - Do all call sites actually want this default?
931
+ - Would a caller forgetting this parameter cause a bug?
932
+ - Is there a safer design that makes the choice explicit?
933
+
934
+ ---
935
+
936
+ ### Speculative Tests
937
+
938
+ ```python
939
+ # ❌ FORBIDDEN: Tests for future features
940
+ # def test_feature_we_might_add():
941
+ # pass
942
+
943
+ # ✅ CORRECT: TDD for current implementation
944
+ def test_feature_being_built_now():
945
+ result = new_feature()
946
+ assert result == expected
947
+ ```
948
+
949
+ ---
950
+
951
+ ### Speculative Test Infrastructure
952
+
953
+ **Don't add parameters to fakes "just in case" they might be useful for testing.**
954
+
955
+ Fakes should mirror production interfaces. Adding test-only configuration knobs that never get used creates dead code and false complexity.
956
+
957
+ ```python
958
+ # ❌ WRONG: Test-only parameter that's never used in production
959
+ class FakeGitHub:
960
+ def __init__(
961
+ self,
962
+ prs: dict[str, PullRequestInfo] | None = None,
963
+ rate_limited: bool = False, # "Might test this later"
964
+ ) -> None:
965
+ self._rate_limited = rate_limited # Never set to True anywhere
966
+
967
+ # ✅ CORRECT: Only add infrastructure when you need it
968
+ class FakeGitHub:
969
+ def __init__(
970
+ self,
971
+ prs: dict[str, PullRequestInfo] | None = None,
972
+ ) -> None:
973
+ ...
974
+ ```
975
+
976
+ **The test for this:** If grep shows a parameter is only ever passed in test files, and those tests are testing hypothetical scenarios rather than actual production behavior, delete both the parameter and the tests.
977
+
978
+ ---
979
+
980
+ ## Code Organization
981
+
982
+ ### Declare Variables Close to Use
983
+
984
+ **Variables should be declared as close as possible to where they are used.** Avoid early declarations that pollute scope and obscure data flow.
985
+
986
+ ```python
987
+ # ❌ WRONG: Variable declared far from use
988
+ def process_data(ctx, items):
989
+ # Declared here...
990
+ result_path = compute_result_path(ctx)
991
+
992
+ # 20+ lines of other logic...
993
+ validate_items(items)
994
+ transformed = transform_items(items)
995
+ check_permissions(ctx)
996
+
997
+ # ...used here, far below
998
+ save_to_path(transformed, result_path)
999
+
1000
+ # ✅ CORRECT: Inline at use site
1001
+ def process_data(ctx, items):
1002
+ validate_items(items)
1003
+ transformed = transform_items(items)
1004
+ check_permissions(ctx)
1005
+
1006
+ # Computed right where it's needed
1007
+ save_to_path(transformed, compute_result_path(ctx))
1008
+ ```
1009
+
1010
+ **When passing to functions, prefer inline computation:**
1011
+
1012
+ ```python
1013
+ # ❌ WRONG: Unnecessary intermediate variable
1014
+ worktrees = ctx.git.list_worktrees(repo.root)
1015
+ relative_path = compute_relative_path(worktrees, ctx.cwd) # Only used once below
1016
+
1017
+ activation_script = render_activation_script(
1018
+ worktree_path=target_path,
1019
+ target_subpath=relative_path,
1020
+ )
1021
+
1022
+ # ✅ CORRECT: Inline the computation
1023
+ worktrees = ctx.git.list_worktrees(repo.root)
1024
+
1025
+ activation_script = render_activation_script(
1026
+ worktree_path=target_path,
1027
+ target_subpath=compute_relative_path(worktrees, ctx.cwd),
1028
+ )
1029
+ ```
1030
+
1031
+ **Exception:** If a variable is used multiple times or if inline computation hurts readability, a local variable is appropriate.
1032
+
1033
+ ### Don't Destructure Objects Into Single-Use Locals
1034
+
1035
+ **Prefer direct attribute access over intermediate variables.** When you have an object, access its attributes at the point of use rather than extracting them into local variables that are only used once.
1036
+
1037
+ ```python
1038
+ # ❌ WRONG: Unnecessary field extraction
1039
+ result = fetch_user(user_id)
1040
+ name = result.name # only used once below
1041
+ email = result.email # only used once below
1042
+ role = result.role # only used once below
1043
+
1044
+ send_notification(name, email, role)
1045
+
1046
+ # ✅ CORRECT: Access fields directly
1047
+ user = fetch_user(user_id)
1048
+ send_notification(user.name, user.email, user.role)
1049
+ ```
1050
+
1051
+ **Why this matters:**
1052
+
1053
+ - Reduces cognitive load - no need to track extra variable names
1054
+ - Makes data flow clearer - you can see where values come from
1055
+ - Avoids stale variable bugs when object is mutated
1056
+ - The object name (`user`) provides context; `name` alone is ambiguous
1057
+
1058
+ **Exception:** Extract to a local when:
1059
+
1060
+ - The value is used multiple times
1061
+ - The expression is complex and a name improves readability
1062
+ - You need to modify the value before use
1063
+
1064
+ ---
1065
+
1066
+ ### Indentation Depth Limit
1067
+
1068
+ **Maximum indentation: 4 levels**
1069
+
1070
+ ```python
1071
+ # ❌ WRONG: Too deeply nested
1072
+ def process_items(items):
1073
+ for item in items:
1074
+ if item.valid:
1075
+ for child in item.children:
1076
+ if child.enabled:
1077
+ for grandchild in child.descendants:
1078
+ # 5 levels deep!
1079
+ pass
1080
+
1081
+ # ✅ CORRECT: Extract helper functions
1082
+ def process_items(items):
1083
+ for item in items:
1084
+ if item.valid:
1085
+ process_children(item.children)
1086
+
1087
+ def process_children(children):
1088
+ for child in children:
1089
+ if child.enabled:
1090
+ process_descendants(child.descendants)
1091
+ ```
1092
+
1093
+ ---
1094
+
1095
+ ## Backwards Compatibility Philosophy
1096
+
1097
+ **Default stance: NO backwards compatibility preservation**
1098
+
1099
+ Only preserve backwards compatibility when:
1100
+
1101
+ - Code is clearly part of public API
1102
+ - User explicitly requests it
1103
+ - Migration cost is prohibitively high (rare)
1104
+
1105
+ Benefits:
1106
+
1107
+ - Cleaner, maintainable codebase
1108
+ - Faster iteration
1109
+ - No legacy code accumulation
1110
+ - Simpler mental models
1111
+
1112
+ ---
1113
+
1114
+ ## Decision Checklist
1115
+
1116
+ ### Before writing `try/except`:
1117
+
1118
+ - [ ] Is this at an error boundary? (CLI/API level)
1119
+ - [ ] Can I check the condition proactively? (LBYL)
1120
+ - [ ] Am I adding meaningful context, or just hiding?
1121
+ - [ ] Is third-party API forcing me to use exceptions? (No LBYL check exists—not even format validation)
1122
+ - [ ] Have I encapsulated the violation?
1123
+ - [ ] Am I catching specific exceptions, not broad?
1124
+ - [ ] If catching at error boundary, am I logging/warning? (Never silently swallow)
1125
+
1126
+ **Default: Let exceptions bubble up**
1127
+
1128
+ ### Before path operations:
1129
+
1130
+ - [ ] Did I check `.exists()` before `.resolve()`?
1131
+ - [ ] Did I check `.exists()` before `.is_relative_to()`?
1132
+ - [ ] Am I using `pathlib.Path`, not `os.path`?
1133
+ - [ ] Did I specify `encoding="utf-8"`?
1134
+
1135
+ ### Before using `typing.cast()`:
1136
+
1137
+ - [ ] Have I added a runtime assertion to verify the cast?
1138
+ - [ ] Is the assertion cost trivial (O(1))? If yes, always add it.
1139
+ - [ ] If skipping, is it because I just performed an isinstance check (redundant)?
1140
+ - [ ] If skipping for performance, have I documented the measured overhead?
1141
+
1142
+ **Default: Always add runtime assertion before cast when cost is trivial**
1143
+
1144
+ ### Before preserving backwards compatibility:
1145
+
1146
+ - [ ] Did the user explicitly request it?
1147
+ - [ ] Is this a public API with external consumers?
1148
+ - [ ] Have I documented why it's needed?
1149
+ - [ ] Is migration cost prohibitively high?
1150
+
1151
+ **Default: Break the API and migrate callsites immediately**
1152
+
1153
+ ### Before inline imports:
1154
+
1155
+ - [ ] Is this to break a circular dependency?
1156
+ - [ ] Is this for TYPE_CHECKING?
1157
+ - [ ] Is this for conditional features?
1158
+ - [ ] If for startup time: Have I MEASURED the import cost?
1159
+ - [ ] If for startup time: Is the cost significant (>100ms)?
1160
+ - [ ] If for startup time: Have I documented the measured cost in a comment?
1161
+ - [ ] Have I documented why the inline import is needed?
1162
+
1163
+ **Default: Module-level imports**
1164
+
1165
+ ### Before importing/re-exporting symbols:
1166
+
1167
+ - [ ] Is there already a canonical location for this symbol?
1168
+ - [ ] Am I creating a second import path for the same symbol?
1169
+ - [ ] If this is a shim module, am I importing only what's needed for this module's purpose?
1170
+ - [ ] Have I avoided `__all__` exports?
1171
+
1172
+ **Default: Import from canonical location, never re-export**
1173
+
1174
+ ### Before declaring a local variable:
1175
+
1176
+ - [ ] Is this variable used more than once?
1177
+ - [ ] Is this variable used close to where it's declared?
1178
+ - [ ] Would inlining the computation hurt readability?
1179
+ - [ ] Am I extracting object fields into locals that are only used once?
1180
+
1181
+ **Default: Inline single-use computations at the call site; access object attributes directly**
1182
+
1183
+ ### Before adding a default parameter value:
1184
+
1185
+ - [ ] Do 95%+ of callers actually want this default?
1186
+ - [ ] Would forgetting to pass this parameter cause a subtle bug?
1187
+ - [ ] Is there a safer design that makes the choice explicit?
1188
+ - [ ] If the default is never overridden anywhere, should this parameter exist at all?
1189
+
1190
+ **Default: Require explicit values; eliminate unused defaults**