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,430 @@
1
+ import os
2
+ from collections.abc import Sequence
3
+ from pathlib import Path
4
+
5
+ import click
6
+
7
+ from erk.cli.activation import render_activation_script
8
+ from erk.cli.commands.branch.unassign_cmd import execute_unassign
9
+ from erk.cli.commands.wt.create_cmd import ensure_worktree_for_branch
10
+ from erk.cli.ensure import Ensure
11
+ from erk.core.context import ErkContext
12
+ from erk.core.repo_discovery import RepoContext
13
+ from erk.core.worktree_pool import PoolState, SlotAssignment, load_pool_state
14
+ from erk.core.worktree_utils import compute_relative_path_in_worktree
15
+ from erk_shared.debug import debug_log
16
+ from erk_shared.git.abc import WorktreeInfo
17
+ from erk_shared.github.types import PRNotFound
18
+ from erk_shared.output.output import machine_output, user_confirm, user_output
19
+ from erk_shared.scratch.markers import PENDING_EXTRACTION_MARKER, marker_exists
20
+
21
+
22
+ def check_pending_extraction_marker(worktree_path: Path, force: bool) -> None:
23
+ """Check for pending extraction marker and block deletion if present.
24
+
25
+ This provides friction before worktree deletion to ensure insights are
26
+ extracted from the session logs. The marker is created by `erk pr land`
27
+ and deleted by `erk plan extraction raw`.
28
+
29
+ Args:
30
+ worktree_path: Path to the worktree being deleted
31
+ force: If True, warn but don't block deletion
32
+
33
+ Raises:
34
+ SystemExit: If marker exists and force is False
35
+ """
36
+ if not marker_exists(worktree_path, PENDING_EXTRACTION_MARKER):
37
+ return
38
+
39
+ if force:
40
+ user_output(
41
+ click.style("Warning: ", fg="yellow") + "Skipping pending extraction (--force used).\n"
42
+ )
43
+ return
44
+
45
+ user_output(
46
+ click.style("Error: ", fg="red") + "Worktree has pending extraction.\n"
47
+ "Run: erk plan extraction raw\n"
48
+ "Or use --force to skip extraction."
49
+ )
50
+ raise SystemExit(1)
51
+
52
+
53
+ def check_clean_working_tree(ctx: ErkContext) -> None:
54
+ """Check that working tree has no uncommitted changes.
55
+
56
+ Raises SystemExit if uncommitted changes found.
57
+ """
58
+ Ensure.invariant(
59
+ not ctx.git.has_uncommitted_changes(ctx.cwd),
60
+ "Cannot delete current branch with uncommitted changes.\n"
61
+ "Please commit or stash your changes first.",
62
+ )
63
+
64
+
65
+ def verify_pr_closed_or_merged(ctx: ErkContext, repo_root: Path, branch: str, force: bool) -> None:
66
+ """Verify that the branch's PR is closed or merged on GitHub.
67
+
68
+ Warns if no PR exists, raises SystemExit if PR is still OPEN (unless force=True).
69
+ Allows deletion for both MERGED and CLOSED PRs (abandoned/rejected work).
70
+
71
+ Args:
72
+ ctx: Erk context
73
+ repo_root: Path to the repository root
74
+ branch: Branch name to check
75
+ force: If True, prompt for confirmation instead of blocking on open PRs
76
+ """
77
+ pr_details = ctx.github.get_pr_for_branch(repo_root, branch)
78
+
79
+ if isinstance(pr_details, PRNotFound):
80
+ # Warn but continue when no PR exists
81
+ user_output(
82
+ click.style("Warning: ", fg="yellow")
83
+ + f"No pull request found for branch '{branch}'.\n"
84
+ "Proceeding with deletion without PR verification."
85
+ )
86
+ return # Allow deletion to proceed
87
+
88
+ if pr_details.state == "OPEN":
89
+ if force:
90
+ # Show warning and prompt for confirmation
91
+ user_output(
92
+ click.style("Warning: ", fg="yellow")
93
+ + f"Pull request for branch '{branch}' is still open.\n"
94
+ + f"{pr_details.url}"
95
+ )
96
+ if not user_confirm("Delete branch anyway?", default=False):
97
+ raise SystemExit(1)
98
+ return # User confirmed, allow deletion
99
+
100
+ # Block deletion for open PRs (active work in progress)
101
+ user_output(
102
+ click.style("Error: ", fg="red")
103
+ + f"Pull request for branch '{branch}' is still open.\n"
104
+ + f"{pr_details.url}\n"
105
+ + "Only closed or merged branches can be deleted with --delete-current."
106
+ )
107
+ raise SystemExit(1)
108
+
109
+
110
+ def delete_branch_and_worktree(
111
+ ctx: ErkContext, repo: RepoContext, branch: str, worktree_path: Path
112
+ ) -> None:
113
+ """Delete the specified branch and its worktree.
114
+
115
+ Uses two-step deletion: git worktree remove, then branch delete.
116
+ Note: remove_worktree already calls prune internally, so no additional prune needed.
117
+
118
+ Args:
119
+ ctx: Erk context
120
+ repo: Repository context (uses main_repo_root for safe directory operations)
121
+ branch: Branch name to delete
122
+ worktree_path: Path to the worktree to remove
123
+ """
124
+ # Use main_repo_root (not repo.root) to ensure we escape to a directory that
125
+ # still exists after worktree removal. repo.root equals the worktree path when
126
+ # running from inside a worktree.
127
+ # main_repo_root is always set by RepoContext.__post_init__, but ty doesn't know
128
+ main_repo = repo.main_repo_root if repo.main_repo_root else repo.root
129
+
130
+ # Escape the worktree if we're inside it (prevents FileNotFoundError after removal)
131
+ # Both paths must be resolved for reliable comparison - Path.cwd() returns resolved path
132
+ # but worktree_path may not be resolved, causing equality check to fail for same directory
133
+ cwd = Path.cwd().resolve()
134
+ resolved_worktree = worktree_path.resolve()
135
+ if cwd == resolved_worktree or resolved_worktree in cwd.parents:
136
+ os.chdir(main_repo)
137
+
138
+ # Remove the worktree (already calls prune internally)
139
+ ctx.git.remove_worktree(main_repo, worktree_path, force=True)
140
+ user_output(f"✓ Removed worktree: {click.style(str(worktree_path), fg='green')}")
141
+
142
+ # Delete the branch using Git abstraction
143
+ ctx.git.delete_branch_with_graphite(main_repo, branch, force=True)
144
+ user_output(f"✓ Deleted branch: {click.style(branch, fg='yellow')}")
145
+
146
+
147
+ def find_assignment_by_worktree_path(
148
+ state: PoolState, worktree_path: Path
149
+ ) -> SlotAssignment | None:
150
+ """Find a slot assignment by its worktree path.
151
+
152
+ Args:
153
+ state: Current pool state
154
+ worktree_path: Path to the worktree to find
155
+
156
+ Returns:
157
+ SlotAssignment if the worktree is a pool slot, None otherwise
158
+ """
159
+ if not worktree_path.exists():
160
+ return None
161
+ resolved_path = worktree_path.resolve()
162
+ for assignment in state.assignments:
163
+ if not assignment.worktree_path.exists():
164
+ continue
165
+ if assignment.worktree_path.resolve() == resolved_path:
166
+ return assignment
167
+ return None
168
+
169
+
170
+ def unallocate_worktree_and_branch(
171
+ ctx: ErkContext,
172
+ repo: RepoContext,
173
+ branch: str,
174
+ worktree_path: Path,
175
+ ) -> None:
176
+ """Unallocate a worktree and delete its branch.
177
+
178
+ If worktree is a pool slot: unassigns slot (keeps directory for reuse), deletes branch.
179
+ If not a pool slot: removes worktree directory, deletes branch.
180
+
181
+ Args:
182
+ ctx: ErkContext with git operations
183
+ repo: Repository context
184
+ branch: Branch name to delete
185
+ worktree_path: Path to the worktree to unallocate
186
+ """
187
+ main_repo_root = repo.main_repo_root if repo.main_repo_root else repo.root
188
+
189
+ # Check if this is a slot worktree
190
+ state = load_pool_state(repo.pool_json_path)
191
+ assignment: SlotAssignment | None = None
192
+ if state is not None:
193
+ assignment = find_assignment_by_worktree_path(state, worktree_path)
194
+
195
+ if assignment is not None:
196
+ # Slot worktree: unassign instead of delete
197
+ # state is guaranteed to be non-None since assignment was found in it
198
+ assert state is not None
199
+ execute_unassign(ctx, repo, state, assignment)
200
+ ctx.git.delete_branch_with_graphite(main_repo_root, branch, force=True)
201
+ user_output(click.style("✓", fg="green") + " Unassigned slot and deleted branch")
202
+ else:
203
+ # Non-slot worktree: delete both
204
+ ctx.git.remove_worktree(main_repo_root, worktree_path, force=True)
205
+ ctx.git.delete_branch_with_graphite(main_repo_root, branch, force=True)
206
+ user_output(click.style("✓", fg="green") + " Removed worktree and deleted branch")
207
+
208
+
209
+ def activate_root_repo(
210
+ ctx: ErkContext,
211
+ repo: RepoContext,
212
+ script: bool,
213
+ command_name: str,
214
+ post_cd_commands: Sequence[str] | None,
215
+ ) -> None:
216
+ """Activate the root repository and exit.
217
+
218
+ Args:
219
+ ctx: Erk context (for script_writer)
220
+ repo: Repository context
221
+ script: Whether to output script path or user message
222
+ command_name: Name of the command (for script generation)
223
+ post_cd_commands: Optional shell commands to run after cd (e.g., git pull)
224
+
225
+ Raises:
226
+ SystemExit: Always (successful exit after activation)
227
+ """
228
+ # Use main_repo_root (not repo.root) to ensure we reference a directory that
229
+ # still exists after worktree removal. repo.root equals the worktree path when
230
+ # running from inside a worktree.
231
+ root_path = repo.main_repo_root if repo.main_repo_root else repo.root
232
+
233
+ # Compute relative path to preserve user's position within worktree
234
+ worktrees = ctx.git.list_worktrees(repo.root)
235
+ relative_path = compute_relative_path_in_worktree(worktrees, ctx.cwd)
236
+
237
+ if script:
238
+ script_content = render_activation_script(
239
+ worktree_path=root_path,
240
+ target_subpath=relative_path,
241
+ post_cd_commands=post_cd_commands,
242
+ final_message='echo "Went to root repo: $(pwd)"',
243
+ comment="work activate-script (root repo)",
244
+ )
245
+ result = ctx.script_writer.write_activation_script(
246
+ script_content,
247
+ command_name=command_name,
248
+ comment="activate root",
249
+ )
250
+ machine_output(str(result.path), nl=False)
251
+ else:
252
+ user_output(f"Went to root repo: {root_path}")
253
+ user_output(
254
+ "\nShell integration not detected. "
255
+ "Run 'erk init --shell' to set up automatic activation."
256
+ )
257
+ user_output(f"Or use: source <(erk {command_name} --script)")
258
+ raise SystemExit(0)
259
+
260
+
261
+ def activate_worktree(
262
+ *,
263
+ ctx: ErkContext,
264
+ repo: RepoContext,
265
+ target_path: Path,
266
+ script: bool,
267
+ command_name: str,
268
+ preserve_relative_path: bool,
269
+ post_cd_commands: Sequence[str] | None,
270
+ ) -> None:
271
+ """Activate a worktree and exit.
272
+
273
+ Args:
274
+ ctx: Erk context (for script_writer)
275
+ repo: Repository context
276
+ target_path: Path to the target worktree directory
277
+ script: Whether to output script path or user message
278
+ command_name: Name of the command (for script generation and debug logging)
279
+ preserve_relative_path: If True (default), compute and preserve the user's
280
+ relative directory position from the current worktree
281
+ post_cd_commands: Optional shell commands to run after activation (e.g., entry scripts)
282
+
283
+ Raises:
284
+ SystemExit: If worktree not found, or after successful activation
285
+ """
286
+ wt_path = target_path
287
+
288
+ Ensure.path_exists(ctx, wt_path, f"Worktree not found: {wt_path}")
289
+
290
+ worktree_name = wt_path.name
291
+
292
+ # Auto-compute relative path if requested
293
+ relative_path: Path | None = None
294
+ if preserve_relative_path:
295
+ worktrees = ctx.git.list_worktrees(repo.root)
296
+ relative_path = compute_relative_path_in_worktree(worktrees, ctx.cwd)
297
+
298
+ if script:
299
+ activation_script = render_activation_script(
300
+ worktree_path=wt_path,
301
+ target_subpath=relative_path,
302
+ post_cd_commands=post_cd_commands,
303
+ final_message='echo "Activated worktree: $(pwd)"',
304
+ comment="work activate-script",
305
+ )
306
+ result = ctx.script_writer.write_activation_script(
307
+ activation_script,
308
+ command_name=command_name,
309
+ comment=f"activate {worktree_name}",
310
+ )
311
+
312
+ debug_log(f"{command_name.capitalize()}: Generated script at {result.path}")
313
+ debug_log(f"{command_name.capitalize()}: Script content:\n{activation_script}")
314
+ debug_log(f"{command_name.capitalize()}: File exists? {result.path.exists()}")
315
+
316
+ result.output_for_shell_integration()
317
+ else:
318
+ user_output(
319
+ "Shell integration not detected. Run 'erk init --shell' to set up automatic activation."
320
+ )
321
+ user_output(f"\nOr use: source <(erk {command_name} --script)")
322
+ raise SystemExit(0)
323
+
324
+
325
+ def resolve_up_navigation(
326
+ ctx: ErkContext, repo: RepoContext, current_branch: str, worktrees: list[WorktreeInfo]
327
+ ) -> tuple[str, bool]:
328
+ """Resolve --up navigation to determine target branch name.
329
+
330
+ Args:
331
+ ctx: Erk context
332
+ repo: Repository context
333
+ current_branch: Current branch name
334
+ worktrees: List of worktrees from git_ops.list_worktrees()
335
+
336
+ Returns:
337
+ Tuple of (target_branch, was_created)
338
+ - target_branch: Target branch name to switch to
339
+ - was_created: True if worktree was newly created, False if it already existed
340
+
341
+ Raises:
342
+ SystemExit: If navigation fails (at top of stack)
343
+ """
344
+ # Navigate up to child branch
345
+ children = Ensure.truthy(
346
+ ctx.graphite.get_child_branches(ctx.git, repo.root, current_branch),
347
+ "Already at the top of the stack (no child branches)",
348
+ )
349
+
350
+ # Fail explicitly if multiple children exist
351
+ if len(children) > 1:
352
+ children_list = ", ".join(f"'{child}'" for child in children)
353
+ user_output(
354
+ f"Error: Branch '{current_branch}' has multiple children: {children_list}.\n"
355
+ f"Please create worktree for specific child: erk create <branch-name>"
356
+ )
357
+ raise SystemExit(1)
358
+
359
+ # Use the single child
360
+ target_branch = children[0]
361
+
362
+ # Check if target branch has a worktree, create if necessary
363
+ target_wt_path = ctx.git.find_worktree_for_branch(repo.root, target_branch)
364
+ if target_wt_path is None:
365
+ # Auto-create worktree for target branch
366
+ _worktree_path, was_created = ensure_worktree_for_branch(ctx, repo, target_branch)
367
+ return target_branch, was_created
368
+
369
+ return target_branch, False
370
+
371
+
372
+ def resolve_down_navigation(
373
+ ctx: ErkContext,
374
+ repo: RepoContext,
375
+ current_branch: str,
376
+ worktrees: list[WorktreeInfo],
377
+ trunk_branch: str | None,
378
+ ) -> tuple[str, bool]:
379
+ """Resolve --down navigation to determine target branch name.
380
+
381
+ Args:
382
+ ctx: Erk context
383
+ repo: Repository context
384
+ current_branch: Current branch name
385
+ worktrees: List of worktrees from git_ops.list_worktrees()
386
+ trunk_branch: Configured trunk branch name, or None for auto-detection
387
+
388
+ Returns:
389
+ Tuple of (target_branch, was_created)
390
+ - target_branch: Target branch name or 'root' to switch to
391
+ - was_created: True if worktree was newly created, False if it already existed
392
+
393
+ Raises:
394
+ SystemExit: If navigation fails (at bottom of stack)
395
+ """
396
+ # Navigate down to parent branch
397
+ parent_branch = ctx.graphite.get_parent_branch(ctx.git, repo.root, current_branch)
398
+ if parent_branch is None:
399
+ # Check if we're already on trunk
400
+ detected_trunk = ctx.git.detect_trunk_branch(repo.root)
401
+ if current_branch == detected_trunk:
402
+ user_output(f"Already at the bottom of the stack (on trunk branch '{detected_trunk}')")
403
+ raise SystemExit(1)
404
+ else:
405
+ user_output("Error: Could not determine parent branch from Graphite metadata")
406
+ raise SystemExit(1)
407
+
408
+ # Check if parent is the trunk - if so, switch to root
409
+ detected_trunk = ctx.git.detect_trunk_branch(repo.root)
410
+ if parent_branch == detected_trunk:
411
+ # Check if trunk is checked out in root (repo.root path)
412
+ trunk_wt_path = ctx.git.find_worktree_for_branch(repo.root, detected_trunk)
413
+ if trunk_wt_path is not None and trunk_wt_path == repo.root:
414
+ # Trunk is in root repository, not in a dedicated worktree
415
+ return "root", False
416
+ else:
417
+ # Trunk has a dedicated worktree
418
+ if trunk_wt_path is None:
419
+ # Auto-create worktree for trunk branch
420
+ _worktree_path, was_created = ensure_worktree_for_branch(ctx, repo, parent_branch)
421
+ return parent_branch, was_created
422
+ return parent_branch, False
423
+ else:
424
+ # Parent is not trunk, check if it has a worktree
425
+ target_wt_path = ctx.git.find_worktree_for_branch(repo.root, parent_branch)
426
+ if target_wt_path is None:
427
+ # Auto-create worktree for parent branch
428
+ _worktree_path, was_created = ensure_worktree_for_branch(ctx, repo, parent_branch)
429
+ return parent_branch, was_created
430
+ return parent_branch, False
@@ -0,0 +1,16 @@
1
+ """Objective management commands."""
2
+
3
+ import click
4
+
5
+ from erk.cli.alias import register_with_aliases
6
+ from erk.cli.commands.objective.list_cmd import list_objectives
7
+ from erk.cli.help_formatter import ErkCommandGroup
8
+
9
+
10
+ @click.group("objective", cls=ErkCommandGroup, hidden=True)
11
+ def objective_group() -> None:
12
+ """Manage objectives (multi-PR coordination issues)."""
13
+ pass
14
+
15
+
16
+ register_with_aliases(objective_group, list_objectives)
@@ -0,0 +1,47 @@
1
+ """List open objectives."""
2
+
3
+ import click
4
+ from rich.console import Console
5
+ from rich.table import Table
6
+
7
+ from erk.cli.alias import alias
8
+ from erk.cli.core import discover_repo_context
9
+ from erk.core.context import ErkContext
10
+ from erk.core.display_utils import format_relative_time
11
+
12
+
13
+ @alias("ls")
14
+ @click.command("list")
15
+ @click.pass_obj
16
+ def list_objectives(ctx: ErkContext) -> None:
17
+ """List open objectives (GitHub issues with erk-objective label)."""
18
+ repo = discover_repo_context(ctx, ctx.cwd)
19
+
20
+ # Fetch objectives via issues interface
21
+ issues = ctx.issues.list_issues(
22
+ repo.root,
23
+ labels=["erk-objective"],
24
+ state="open",
25
+ )
26
+
27
+ if not issues:
28
+ click.echo("No open objectives found.", err=True)
29
+ return
30
+
31
+ # Build Rich table with minimal columns
32
+ table = Table(show_header=True, header_style="bold", box=None)
33
+ table.add_column("#", style="cyan", no_wrap=True)
34
+ table.add_column("title", no_wrap=False)
35
+ table.add_column("created", no_wrap=True)
36
+ table.add_column("url", no_wrap=True)
37
+
38
+ for issue in issues:
39
+ table.add_row(
40
+ f"[link={issue.url}]#{issue.number}[/link]",
41
+ issue.title,
42
+ format_relative_time(issue.created_at.isoformat()),
43
+ issue.url,
44
+ )
45
+
46
+ console = Console(stderr=True, force_terminal=True)
47
+ console.print(table)
@@ -0,0 +1,132 @@
1
+ """Shared helpers for objective tracking in land commands.
2
+
3
+ These helpers are used by `erk land` to check for linked objectives
4
+ and prompt users to update them after landing.
5
+ """
6
+
7
+ from pathlib import Path
8
+
9
+ import click
10
+
11
+ from erk.cli.output import stream_command_with_feedback
12
+ from erk.core.context import ErkContext
13
+ from erk_shared.github.metadata.plan_header import extract_plan_header_objective_issue
14
+ from erk_shared.naming import extract_leading_issue_number
15
+ from erk_shared.output.output import user_confirm, user_output
16
+
17
+
18
+ def check_and_display_plan_issue_closure(
19
+ ctx: ErkContext,
20
+ repo_root: Path,
21
+ branch: str,
22
+ ) -> int | None:
23
+ """Check and display plan issue closure status after landing.
24
+
25
+ Returns the plan issue number if found, None otherwise.
26
+ This is fail-open: returns None silently if the issue doesn't exist.
27
+ """
28
+ plan_number = extract_leading_issue_number(branch)
29
+ if plan_number is None:
30
+ return None
31
+
32
+ # GitHubIssues.get_issue raises RuntimeError for missing issues.
33
+ # This is a fail-open feature (non-critical), so we catch and return None.
34
+ try:
35
+ issue = ctx.issues.get_issue(repo_root, plan_number)
36
+ except RuntimeError:
37
+ return None
38
+
39
+ if issue.state == "CLOSED":
40
+ user_output(click.style("✓", fg="green") + f" Closed plan issue #{plan_number}")
41
+ else:
42
+ user_output(
43
+ click.style("⚠ ", fg="yellow")
44
+ + f"Plan issue #{plan_number} still open (expected auto-close)"
45
+ )
46
+
47
+ return plan_number
48
+
49
+
50
+ def get_objective_for_branch(ctx: ErkContext, repo_root: Path, branch: str) -> int | None:
51
+ """Extract objective issue number from branch's linked plan issue.
52
+
53
+ Returns objective issue number if:
54
+ 1. Branch has P<number>- prefix (plan issue link)
55
+ 2. Plan issue has objective_issue in its metadata
56
+
57
+ Returns None otherwise (fail-open - never blocks landing).
58
+ """
59
+ plan_number = extract_leading_issue_number(branch)
60
+ if plan_number is None:
61
+ return None
62
+
63
+ # GitHubIssues.get_issue raises RuntimeError for missing issues.
64
+ # This is a fail-open feature (non-critical), so we catch and return None.
65
+ try:
66
+ issue = ctx.issues.get_issue(repo_root, plan_number)
67
+ except RuntimeError:
68
+ return None
69
+
70
+ return extract_plan_header_objective_issue(issue.body)
71
+
72
+
73
+ def prompt_objective_update(
74
+ ctx: ErkContext,
75
+ repo_root: Path,
76
+ objective_number: int,
77
+ pr_number: int,
78
+ branch: str,
79
+ force: bool,
80
+ ) -> None:
81
+ """Prompt user to update objective after landing.
82
+
83
+ Args:
84
+ ctx: ErkContext with claude_executor
85
+ repo_root: Repository root path for Claude execution
86
+ objective_number: The linked objective issue number
87
+ pr_number: The PR number that was just landed
88
+ branch: The branch name that was landed
89
+ force: If True, skip prompt (print command to run later)
90
+ """
91
+ user_output(f" Linked to Objective #{objective_number}")
92
+
93
+ # Build the command with all arguments for context-free execution
94
+ # --auto-close enables automatic objective closing when all steps are complete
95
+ cmd = (
96
+ f"/erk:objective-update-with-landed-pr "
97
+ f"--pr {pr_number} --objective {objective_number} --branch {branch} --auto-close"
98
+ )
99
+
100
+ if force:
101
+ # --force skips all prompts, print command for later
102
+ user_output(f" Run '{cmd}' to update objective")
103
+ return
104
+
105
+ # Ask y/n prompt
106
+ user_output("")
107
+ if not user_confirm("Update objective now? (runs Claude agent)", default=True):
108
+ user_output("")
109
+ user_output("Skipped. To update later, run:")
110
+ user_output(f" {cmd}")
111
+ else:
112
+ # Add feedback BEFORE streaming starts (important for visibility)
113
+ user_output("")
114
+ user_output("Starting objective update...")
115
+
116
+ result = stream_command_with_feedback(
117
+ executor=ctx.claude_executor,
118
+ command=cmd,
119
+ worktree_path=repo_root,
120
+ dangerous=True,
121
+ )
122
+
123
+ # Add feedback AFTER streaming completes
124
+ if result.success:
125
+ user_output("")
126
+ user_output(click.style("✓", fg="green") + " Objective updated successfully")
127
+ else:
128
+ user_output("")
129
+ user_output(
130
+ click.style("⚠", fg="yellow") + f" Objective update failed: {result.error_message}"
131
+ )
132
+ user_output(" Run '/erk:objective-update-with-landed-pr' manually to retry")
@@ -0,0 +1,32 @@
1
+ """Plan command group."""
2
+
3
+ import click
4
+
5
+ from erk.cli.commands.plan.check_cmd import check_plan
6
+ from erk.cli.commands.plan.close_cmd import close_plan
7
+ from erk.cli.commands.plan.create_cmd import create_plan
8
+ from erk.cli.commands.plan.docs import docs_group
9
+ from erk.cli.commands.plan.extraction import extraction_group
10
+ from erk.cli.commands.plan.get import get_plan
11
+ from erk.cli.commands.plan.list_cmd import list_plans
12
+ from erk.cli.commands.plan.log_cmd import plan_log
13
+ from erk.cli.commands.plan.start_cmd import plan_start
14
+ from erk.cli.commands.submit import submit_cmd
15
+
16
+
17
+ @click.group("plan")
18
+ def plan_group() -> None:
19
+ """Manage implementation plans."""
20
+ pass
21
+
22
+
23
+ plan_group.add_command(check_plan)
24
+ plan_group.add_command(close_plan)
25
+ plan_group.add_command(create_plan, name="create")
26
+ plan_group.add_command(docs_group)
27
+ plan_group.add_command(extraction_group)
28
+ plan_group.add_command(get_plan)
29
+ plan_group.add_command(list_plans, name="list")
30
+ plan_group.add_command(plan_log, name="log")
31
+ plan_group.add_command(plan_start, name="start")
32
+ plan_group.add_command(submit_cmd, name="submit")