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
erk/core/pr_utils.py ADDED
@@ -0,0 +1,30 @@
1
+ """Utility functions for PR handling."""
2
+
3
+ from erk_shared.github.types import PullRequestInfo
4
+
5
+
6
+ def select_display_pr(prs: list[PullRequestInfo]) -> PullRequestInfo | None:
7
+ """Select PR to display: prefer open, then merged, then closed.
8
+
9
+ Args:
10
+ prs: List of PRs sorted by created_at descending (most recent first)
11
+
12
+ Returns:
13
+ PR to display, or None if no PRs
14
+ """
15
+ # Check for open PRs (published or draft)
16
+ open_prs = [pr for pr in prs if pr.state in ("OPEN", "DRAFT")]
17
+ if open_prs:
18
+ return open_prs[0] # Most recent open
19
+
20
+ # Fallback to merged PRs
21
+ merged_prs = [pr for pr in prs if pr.state == "MERGED"]
22
+ if merged_prs:
23
+ return merged_prs[0] # Most recent merged
24
+
25
+ # Fallback to closed PRs
26
+ closed_prs = [pr for pr in prs if pr.state == "CLOSED"]
27
+ if closed_prs:
28
+ return closed_prs[0] # Most recent closed
29
+
30
+ return None
@@ -0,0 +1,263 @@
1
+ """Release notes management for erk.
2
+
3
+ Provides functionality for:
4
+ - Parsing CHANGELOG.md into structured data
5
+ - Detecting version changes since last run
6
+ - Displaying upgrade banners
7
+ """
8
+
9
+ import importlib.metadata
10
+ import re
11
+ from dataclasses import dataclass, field
12
+ from functools import cache
13
+ from pathlib import Path
14
+
15
+ from erk_shared.gateway.erk_installation.abc import ErkInstallation
16
+
17
+
18
+ @dataclass
19
+ class ReleaseEntry:
20
+ """A single release entry from the changelog.
21
+
22
+ Items are stored as tuples of (text, indent_level) where indent_level
23
+ is 0 for top-level bullets, 1 for first nesting level, etc.
24
+ """
25
+
26
+ version: str
27
+ date: str | None
28
+ content: str
29
+ items: list[tuple[str, int]] = field(default_factory=list)
30
+ categories: dict[str, list[tuple[str, int]]] = field(default_factory=dict)
31
+
32
+
33
+ @cache
34
+ def _changelog_path() -> Path | None:
35
+ """Get the path to CHANGELOG.md.
36
+
37
+ In development, reads from repo root. In installed package, reads from bundled data dir.
38
+
39
+ Returns:
40
+ Path to CHANGELOG.md if found, None otherwise
41
+ """
42
+ # Bundled location (installed package via force-include)
43
+ bundled = Path(__file__).parent.parent / "data" / "CHANGELOG.md"
44
+ if bundled.exists():
45
+ return bundled
46
+
47
+ # Development fallback: repo root (3 levels up from src/erk/core/)
48
+ dev_root = Path(__file__).parent.parent.parent.parent / "CHANGELOG.md"
49
+ if dev_root.exists():
50
+ return dev_root
51
+
52
+ return None
53
+
54
+
55
+ def get_current_version() -> str:
56
+ """Get the currently installed version of erk.
57
+
58
+ Returns:
59
+ Version string (e.g., "0.2.1")
60
+ """
61
+ return importlib.metadata.version("erk")
62
+
63
+
64
+ def _parse_version(version: str) -> tuple[int, ...]:
65
+ """Parse a semantic version string into a tuple of integers.
66
+
67
+ Args:
68
+ version: Version string (e.g., "0.2.4")
69
+
70
+ Returns:
71
+ Tuple of integers (e.g., (0, 2, 4))
72
+ """
73
+ return tuple(int(part) for part in version.split("."))
74
+
75
+
76
+ def _is_upgrade(current: str, last_seen: str) -> bool:
77
+ """Check if current version is newer than last_seen version.
78
+
79
+ Args:
80
+ current: Current version string
81
+ last_seen: Previously seen version string
82
+
83
+ Returns:
84
+ True if current is a newer version than last_seen
85
+ """
86
+ return _parse_version(current) > _parse_version(last_seen)
87
+
88
+
89
+ def get_last_seen_version(erk_installation: ErkInstallation) -> str | None:
90
+ """Get the last version the user was notified about.
91
+
92
+ Args:
93
+ erk_installation: ErkInstallation gateway for accessing ~/.erk/
94
+
95
+ Returns:
96
+ Version string if tracking file exists, None otherwise
97
+ """
98
+ return erk_installation.get_last_seen_version()
99
+
100
+
101
+ def update_last_seen_version(erk_installation: ErkInstallation, version: str) -> None:
102
+ """Update the last seen version tracking file.
103
+
104
+ Args:
105
+ erk_installation: ErkInstallation gateway for accessing ~/.erk/
106
+ version: Version string to record
107
+ """
108
+ erk_installation.update_last_seen_version(version)
109
+
110
+
111
+ def parse_changelog(content: str) -> list[ReleaseEntry]:
112
+ """Parse CHANGELOG.md content into structured release entries.
113
+
114
+ Args:
115
+ content: Raw markdown content of CHANGELOG.md
116
+
117
+ Returns:
118
+ List of ReleaseEntry objects, one per version section
119
+ """
120
+ entries: list[ReleaseEntry] = []
121
+
122
+ # Match "## [0.2.1] - 2025-12-11" or "## [0.2.1] - 2025-12-11 14:30 PT" or "## [Unreleased]"
123
+ version_pattern = re.compile(
124
+ r"^## \[([^\]]+)\](?:\s*-\s*(\d{4}-\d{2}-\d{2}(?: \d{2}:\d{2} PT)?))?",
125
+ re.MULTILINE,
126
+ )
127
+
128
+ matches = list(version_pattern.finditer(content))
129
+
130
+ for i, match in enumerate(matches):
131
+ version = match.group(1)
132
+ date = match.group(2)
133
+
134
+ # Extract content between this header and the next
135
+ start = match.end()
136
+ end = matches[i + 1].start() if i + 1 < len(matches) else len(content)
137
+ section_content = content[start:end].strip()
138
+
139
+ # Extract bullet items grouped by category (### Added, ### Changed, etc.)
140
+ # Items are stored as (text, indent_level) tuples to preserve nesting
141
+ items: list[tuple[str, int]] = []
142
+ categories: dict[str, list[tuple[str, int]]] = {}
143
+ current_category: str | None = None
144
+
145
+ for line in section_content.split("\n"):
146
+ # Count leading spaces to detect nesting level
147
+ stripped = line.lstrip()
148
+ leading_spaces = len(line) - len(stripped)
149
+ indent_level = leading_spaces // 2 # 2 spaces = 1 nesting level
150
+
151
+ # Check for category header (### Added, ### Changed, ### Fixed, etc.)
152
+ if stripped.startswith("### "):
153
+ current_category = stripped[4:]
154
+ categories[current_category] = []
155
+ elif stripped.startswith("- "):
156
+ item_text = stripped[2:]
157
+ item_tuple = (item_text, indent_level)
158
+ items.append(item_tuple)
159
+ if current_category is not None:
160
+ categories[current_category].append(item_tuple)
161
+
162
+ entries.append(
163
+ ReleaseEntry(
164
+ version=version,
165
+ date=date,
166
+ content=section_content,
167
+ items=items,
168
+ categories=categories,
169
+ )
170
+ )
171
+
172
+ return entries
173
+
174
+
175
+ def get_changelog_content() -> str | None:
176
+ """Read the CHANGELOG.md content.
177
+
178
+ Returns:
179
+ Changelog content if file exists, None otherwise
180
+ """
181
+ path = _changelog_path()
182
+ if path is None:
183
+ return None
184
+ return path.read_text(encoding="utf-8")
185
+
186
+
187
+ def get_releases() -> list[ReleaseEntry]:
188
+ """Get all release entries from the bundled changelog.
189
+
190
+ Returns:
191
+ List of ReleaseEntry objects, empty if changelog not found
192
+ """
193
+ content = get_changelog_content()
194
+ if content is None:
195
+ return []
196
+ return parse_changelog(content)
197
+
198
+
199
+ def get_release_for_version(version: str) -> ReleaseEntry | None:
200
+ """Get the release entry for a specific version.
201
+
202
+ Args:
203
+ version: Version string to look up
204
+
205
+ Returns:
206
+ ReleaseEntry if found, None otherwise
207
+ """
208
+ releases = get_releases()
209
+ for release in releases:
210
+ if release.version == version:
211
+ return release
212
+ return None
213
+
214
+
215
+ def check_for_version_change(
216
+ erk_installation: ErkInstallation,
217
+ ) -> tuple[bool, list[ReleaseEntry]]:
218
+ """Check if the version has changed since last run.
219
+
220
+ Args:
221
+ erk_installation: ErkInstallation gateway for accessing ~/.erk/
222
+
223
+ Returns:
224
+ Tuple of (changed: bool, new_releases: list[ReleaseEntry])
225
+ where new_releases contains all releases newer than last seen
226
+ """
227
+ current = get_current_version()
228
+ last_seen = get_last_seen_version(erk_installation)
229
+
230
+ # First run - no notification needed, just update tracking
231
+ if last_seen is None:
232
+ update_last_seen_version(erk_installation, current)
233
+ return (False, [])
234
+
235
+ # No change
236
+ if current == last_seen:
237
+ return (False, [])
238
+
239
+ # Only show banner for upgrades, not downgrades
240
+ # This prevents repeated banners when switching between worktrees
241
+ # with different erk versions installed
242
+ if not _is_upgrade(current, last_seen):
243
+ # Don't update tracking on downgrade - keep tracking the max version seen
244
+ # This prevents repeated banners when switching between worktrees
245
+ return (False, [])
246
+
247
+ # Upgrade detected - find all releases between last_seen and current
248
+ releases = get_releases()
249
+ new_releases: list[ReleaseEntry] = []
250
+
251
+ for release in releases:
252
+ # Skip unreleased section
253
+ if release.version == "Unreleased":
254
+ continue
255
+ # Stop at last seen version
256
+ if release.version == last_seen:
257
+ break
258
+ new_releases.append(release)
259
+
260
+ # Update tracking file
261
+ update_last_seen_version(erk_installation, current)
262
+
263
+ return (True, new_releases)
@@ -0,0 +1,126 @@
1
+ """Repository discovery functionality.
2
+
3
+ Discovers git repository information from a given path without requiring
4
+ full ErkContext (enables config loading before context creation).
5
+ """
6
+
7
+ from pathlib import Path
8
+
9
+ # Re-export context types from erk_shared for backwards compatibility
10
+ from erk_shared.context.types import NoRepoSentinel as NoRepoSentinel
11
+ from erk_shared.context.types import RepoContext as RepoContext
12
+ from erk_shared.git.abc import Git
13
+ from erk_shared.git.real import RealGit
14
+ from erk_shared.github.parsing import parse_git_remote_url
15
+ from erk_shared.github.types import GitHubRepoId
16
+
17
+
18
+ def in_erk_repo(repo_root: Path) -> bool:
19
+ """Check if the given path is inside the erk development repository.
20
+
21
+ This is used internally to detect when erk is running in its own development
22
+ repo, where artifacts are read directly from source rather than installed.
23
+
24
+ Args:
25
+ repo_root: Repository root path to check
26
+
27
+ Returns:
28
+ True if this appears to be the erk development repo
29
+ """
30
+ return (repo_root / "packages" / "erk-shared").exists()
31
+
32
+
33
+ def discover_repo_or_sentinel(
34
+ cwd: Path, erk_root: Path, git_ops: Git | None = None
35
+ ) -> RepoContext | NoRepoSentinel:
36
+ """Walk up from `cwd` to find a directory containing `.git`.
37
+
38
+ Returns a RepoContext pointing to the repo root and the worktrees directory
39
+ for this repository, or NoRepoSentinel if not inside a git repo.
40
+
41
+ Note: For worktrees, `root` is the worktree directory (where git commands run),
42
+ while `repo_name` is derived from the main repository (for consistent metadata paths).
43
+
44
+ Args:
45
+ cwd: Current working directory to start search from
46
+ erk_root: Global erks root directory (from config)
47
+ git_ops: Git operations interface (defaults to RealGit)
48
+
49
+ Returns:
50
+ RepoContext if inside a git repository, NoRepoSentinel otherwise
51
+ """
52
+ ops = git_ops if git_ops is not None else RealGit()
53
+
54
+ if not ops.path_exists(cwd):
55
+ return NoRepoSentinel(message=f"Start path '{cwd}' does not exist")
56
+
57
+ cur = cwd.resolve()
58
+
59
+ # root: the actual working tree root (where git commands should run)
60
+ # main_repo_root: the main repository root (for deriving repo_name and metadata paths)
61
+ root: Path | None = None
62
+ main_repo_root: Path | None = None
63
+
64
+ git_common_dir = ops.get_git_common_dir(cur)
65
+ if git_common_dir is not None:
66
+ # We're in a git repository (possibly a worktree)
67
+ # git_common_dir points to the main repo's .git directory
68
+ main_repo_root = git_common_dir.parent.resolve()
69
+ # Use --show-toplevel to get the actual worktree root
70
+ root = ops.get_repository_root(cur)
71
+ else:
72
+ for parent in [cur, *cur.parents]:
73
+ git_path = parent / ".git"
74
+ if not ops.path_exists(git_path):
75
+ continue
76
+
77
+ if ops.is_dir(git_path):
78
+ root = parent
79
+ main_repo_root = parent
80
+ break
81
+
82
+ if root is None or main_repo_root is None:
83
+ return NoRepoSentinel(message="Not inside a git repository (no .git found up the tree)")
84
+
85
+ # Use main_repo_root for repo_name to ensure consistent metadata paths across worktrees
86
+ repo_name = main_repo_root.name
87
+ repo_dir = erk_root / "repos" / repo_name
88
+ worktrees_dir = repo_dir / "worktrees"
89
+ pool_json_path = repo_dir / "pool.json"
90
+
91
+ # Extract GitHub identity from remote URL
92
+ repo_id: GitHubRepoId | None = None
93
+ try:
94
+ remote_url = ops.get_remote_url(root, "origin")
95
+ owner_repo = parse_git_remote_url(remote_url)
96
+ repo_id = GitHubRepoId(owner=owner_repo[0], repo=owner_repo[1])
97
+ except ValueError:
98
+ # No origin remote or not a GitHub URL - continue without GitHub identity
99
+ pass
100
+
101
+ return RepoContext(
102
+ root=root,
103
+ main_repo_root=main_repo_root,
104
+ repo_name=repo_name,
105
+ repo_dir=repo_dir,
106
+ worktrees_dir=worktrees_dir,
107
+ pool_json_path=pool_json_path,
108
+ github=repo_id,
109
+ )
110
+
111
+
112
+ def ensure_erk_metadata_dir(repo: RepoContext) -> Path:
113
+ """Ensure the erk metadata directory and worktrees subdirectory exist.
114
+
115
+ Creates repo.repo_dir (~/.erk/repos/<repo-name>) and repo.worktrees_dir
116
+ subdirectory if they don't exist.
117
+
118
+ Args:
119
+ repo: Repository context containing metadata paths
120
+
121
+ Returns:
122
+ Path to the erk metadata directory (repo.repo_dir), not git root
123
+ """
124
+ repo.repo_dir.mkdir(parents=True, exist_ok=True)
125
+ repo.worktrees_dir.mkdir(parents=True, exist_ok=True)
126
+ return repo.repo_dir
@@ -0,0 +1,41 @@
1
+ """Activation script writing operations.
2
+
3
+ This module provides the RealScriptWriter implementation.
4
+ ABC and types are imported from erk_shared.core.
5
+ """
6
+
7
+ from erk.cli.shell_utils import write_script_to_temp
8
+ from erk_shared.core.script_writer import ScriptResult as ScriptResult
9
+ from erk_shared.core.script_writer import ScriptWriter as ScriptWriter
10
+
11
+
12
+ class RealScriptWriter(ScriptWriter):
13
+ """Production implementation that writes real temp files."""
14
+
15
+ def write_activation_script(
16
+ self,
17
+ content: str,
18
+ *,
19
+ command_name: str,
20
+ comment: str,
21
+ ) -> ScriptResult:
22
+ """Write activation script to temp file.
23
+
24
+ Args:
25
+ content: The shell script content
26
+ command_name: Command generating the script
27
+ comment: Description for the script header
28
+
29
+ Returns:
30
+ ScriptResult with path to created temp file and full content
31
+ """
32
+ script_path = write_script_to_temp(
33
+ content,
34
+ command_name=command_name,
35
+ comment=comment,
36
+ )
37
+
38
+ # Read back the full content that was written (includes headers)
39
+ full_content = script_path.read_text(encoding="utf-8")
40
+
41
+ return ScriptResult(path=script_path, content=full_content)
@@ -0,0 +1 @@
1
+ """Service layer for erk operations."""
@@ -0,0 +1,94 @@
1
+ """Service for efficiently fetching plan list data via batched API calls.
2
+
3
+ Uses GraphQL nodes(ids: [...]) for O(1) batch lookup of workflow runs (~200ms for any N).
4
+ All plan issues store last_dispatched_node_id in the plan-header metadata block.
5
+
6
+ Performance optimization: When PR linkages are needed, uses unified GraphQL query via
7
+ get_issues_with_pr_linkages() to fetch issues + PR linkages in a single API call (~600ms),
8
+ instead of separate calls for issues (~500ms) and PR linkages (~1500ms).
9
+ """
10
+
11
+ from erk_shared.core.plan_list_service import PlanListData as PlanListData
12
+ from erk_shared.core.plan_list_service import PlanListService
13
+ from erk_shared.github.abc import GitHub
14
+ from erk_shared.github.issues import GitHubIssues
15
+ from erk_shared.github.metadata.plan_header import extract_plan_header_dispatch_info
16
+ from erk_shared.github.types import GitHubRepoLocation, WorkflowRun
17
+
18
+
19
+ class RealPlanListService(PlanListService):
20
+ """Service for efficiently fetching plan list data.
21
+
22
+ Composes GitHub and GitHubIssues integrations to batch fetch all data
23
+ needed for plan listing. Uses GraphQL nodes(ids: [...]) for efficient
24
+ batch lookup of workflow runs by node_id.
25
+ """
26
+
27
+ def __init__(self, github: GitHub, github_issues: GitHubIssues) -> None:
28
+ """Initialize PlanListService with required integrations.
29
+
30
+ Args:
31
+ github: GitHub integration for PR and workflow operations
32
+ github_issues: GitHub issues integration for issue operations
33
+ """
34
+ self._github = github
35
+ self._github_issues = github_issues
36
+
37
+ def get_plan_list_data(
38
+ self,
39
+ *,
40
+ location: GitHubRepoLocation,
41
+ labels: list[str],
42
+ state: str | None = None,
43
+ limit: int | None = None,
44
+ skip_workflow_runs: bool = False,
45
+ creator: str | None = None,
46
+ ) -> PlanListData:
47
+ """Batch fetch all data needed for plan listing.
48
+
49
+ Args:
50
+ location: GitHub repository location (local root + repo identity)
51
+ labels: Labels to filter issues by (e.g., ["erk-plan"])
52
+ state: Filter by state ("open", "closed", or None for all)
53
+ limit: Maximum number of issues to return (None for no limit)
54
+ skip_workflow_runs: If True, skip fetching workflow runs (for performance)
55
+ creator: Filter by creator username (e.g., "octocat"). If provided,
56
+ only issues created by this user are returned.
57
+
58
+ Returns:
59
+ PlanListData containing issues, PR linkages, and workflow runs
60
+ """
61
+ # Always use unified path: issues + PR linkages in one API call (~600ms)
62
+ issues, pr_linkages = self._github.get_issues_with_pr_linkages(
63
+ location,
64
+ labels,
65
+ state=state,
66
+ limit=limit,
67
+ creator=creator,
68
+ )
69
+
70
+ # Conditionally fetch workflow runs (skip for performance when not needed)
71
+ workflow_runs: dict[int, WorkflowRun | None] = {}
72
+ if not skip_workflow_runs:
73
+ # Extract node_ids from plan-header metadata
74
+ node_id_to_issue: dict[str, int] = {}
75
+ for issue in issues:
76
+ _, node_id, _ = extract_plan_header_dispatch_info(issue.body)
77
+ if node_id is not None:
78
+ node_id_to_issue[node_id] = issue.number
79
+
80
+ # Batch fetch workflow runs via GraphQL nodes(ids: [...])
81
+ if node_id_to_issue:
82
+ runs_by_node_id = self._github.get_workflow_runs_by_node_ids(
83
+ location.root,
84
+ list(node_id_to_issue.keys()),
85
+ )
86
+ for node_id, run in runs_by_node_id.items():
87
+ issue_number = node_id_to_issue[node_id]
88
+ workflow_runs[issue_number] = run
89
+
90
+ return PlanListData(
91
+ issues=issues,
92
+ pr_linkages=pr_linkages,
93
+ workflow_runs=workflow_runs,
94
+ )
erk/core/shell.py ADDED
@@ -0,0 +1,51 @@
1
+ """Shell detection and tool availability operations.
2
+
3
+ This module provides abstraction over shell-specific operations like detecting
4
+ the current shell and checking if command-line tools are installed. This abstraction
5
+ enables dependency injection for testing without mock.patch.
6
+
7
+ The Shell ABC and implementations (RealShell, FakeShell) are defined in erk_shared
8
+ and re-exported here. Erk-specific helper functions remain in this module.
9
+ """
10
+
11
+ import json
12
+
13
+ # Re-export all Shell types from erk_shared
14
+ from erk_shared.gateway.shell import FakeShell as FakeShell
15
+ from erk_shared.gateway.shell import RealShell as RealShell
16
+ from erk_shared.gateway.shell import Shell as Shell
17
+ from erk_shared.gateway.shell import detect_shell_from_env as detect_shell_from_env
18
+ from erk_shared.subprocess_utils import run_subprocess_with_context as run_subprocess_with_context
19
+
20
+
21
+ def _extract_issue_url_from_output(output: str) -> str | None:
22
+ """Extract issue_url from Claude CLI output that may contain mixed content.
23
+
24
+ Claude CLI with --print mode can output conversation/thinking text before
25
+ the final JSON. This function searches from the end of the output to find
26
+ a JSON object containing issue_url.
27
+
28
+ Args:
29
+ output: The stdout from Claude CLI (may contain non-JSON text)
30
+
31
+ Returns:
32
+ The issue_url string if found, None otherwise.
33
+ """
34
+ if not output:
35
+ return None
36
+
37
+ # Search from the end of output to find JSON with issue_url
38
+ for line in reversed(output.strip().split("\n")):
39
+ line = line.strip()
40
+ if not line:
41
+ continue
42
+ try:
43
+ data = json.loads(line)
44
+ if isinstance(data, dict):
45
+ issue_url = data.get("issue_url")
46
+ if isinstance(issue_url, str):
47
+ return issue_url
48
+ except json.JSONDecodeError:
49
+ continue
50
+
51
+ return None
@@ -0,0 +1,11 @@
1
+ """User-facing diagnostic output with mode awareness.
2
+
3
+ This is a thin shim that re-exports from erk_shared.gateway.feedback.
4
+ All implementations are in erk_shared for sharing across packages.
5
+ """
6
+
7
+ # Re-export all UserFeedback types from erk_shared
8
+ from erk_shared.gateway.feedback import FakeUserFeedback as FakeUserFeedback
9
+ from erk_shared.gateway.feedback import InteractiveFeedback as InteractiveFeedback
10
+ from erk_shared.gateway.feedback import SuppressedFeedback as SuppressedFeedback
11
+ from erk_shared.gateway.feedback import UserFeedback as UserFeedback
@@ -0,0 +1,55 @@
1
+ """Version checking for erk tool installation.
2
+
3
+ Compares the installed version against a repository-specified required version.
4
+ Used to warn users when their installed erk is outdated compared to what the
5
+ repository requires.
6
+ """
7
+
8
+ from pathlib import Path
9
+
10
+ from packaging.version import Version
11
+
12
+
13
+ def get_required_version(repo_root: Path) -> str | None:
14
+ """Read required version from .erk/required-erk-uv-tool-version.
15
+
16
+ Args:
17
+ repo_root: Path to the git repository root
18
+
19
+ Returns:
20
+ Version string if file exists, None otherwise
21
+ """
22
+ version_file = repo_root / ".erk" / "required-erk-uv-tool-version"
23
+ if not version_file.exists():
24
+ return None
25
+ return version_file.read_text(encoding="utf-8").strip()
26
+
27
+
28
+ def is_version_mismatch(installed: str, required: str) -> bool:
29
+ """Check if installed version doesn't match required version exactly.
30
+
31
+ Args:
32
+ installed: Currently installed version (e.g., "0.2.7")
33
+ required: Required version from repo (e.g., "0.2.8")
34
+
35
+ Returns:
36
+ True if versions don't match exactly, False if they match
37
+ """
38
+ return Version(installed) != Version(required)
39
+
40
+
41
+ def format_version_warning(installed: str, required: str) -> str:
42
+ """Format warning message for version mismatch.
43
+
44
+ Args:
45
+ installed: Currently installed version
46
+ required: Required version from repo
47
+
48
+ Returns:
49
+ Formatted warning message
50
+ """
51
+ return (
52
+ f"⚠️ Your erk ({installed}) doesn't match required ({required})\n"
53
+ f" You must update or erk may not work properly.\n"
54
+ f" Update: uv tool upgrade erk"
55
+ )