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,777 @@
1
+ #!/usr/bin/env python3
2
+ """Exit Plan Mode Hook.
3
+
4
+ Prompts user before exiting plan mode when a plan exists. This hook intercepts
5
+ the ExitPlanMode tool via PreToolUse lifecycle to ask whether to save to GitHub
6
+ or implement immediately.
7
+
8
+ Exit codes:
9
+ 0: Success (allow exit - no plan, implement-now marker present, or no session)
10
+ 2: Block (plan exists, no implement-now marker - prompt user)
11
+
12
+ This command is invoked via:
13
+ erk exec exit-plan-mode-hook
14
+
15
+ Marker File State Machine
16
+ =========================
17
+
18
+ This hook uses marker files in .erk/scratch/sessions/<session-id>/ for state management.
19
+ Marker files are self-describing: their names indicate their purpose and their contents
20
+ explain their effect.
21
+
22
+ Marker Files:
23
+ exit-plan-mode-hook.implement-now.marker
24
+ Created by: Agent (when user chooses "Implement now")
25
+ Effect: Next ExitPlanMode call is ALLOWED (exit plan mode, proceed to implementation)
26
+ Lifecycle: Deleted after being read by next hook invocation
27
+
28
+ exit-plan-mode-hook.plan-saved.marker
29
+ Created by: /erk:plan-save command
30
+ Effect: Next ExitPlanMode call is BLOCKED (remain in plan mode, session complete)
31
+ Lifecycle: Deleted after being read by next hook invocation
32
+
33
+ incremental-plan.marker
34
+ Created by: /local:incremental-plan-mode command (via `erk exec marker create --session-id`)
35
+ Effect: Next ExitPlanMode call is ALLOWED, skipping the save prompt entirely
36
+ Lifecycle: Deleted after being read by next hook invocation
37
+ Purpose: Streamlines "plan → implement → submit" loop for PR iteration
38
+
39
+ State Transitions:
40
+ 1. No marker files + plan exists → BLOCK with prompt
41
+ 2. implement-now marker exists → ALLOW (delete marker)
42
+ 3. incremental-plan marker exists → ALLOW (delete marker, skip save prompt)
43
+ 4. plan-saved marker exists → BLOCK with "session complete" message (delete marker)
44
+ """
45
+
46
+ import json
47
+ import os
48
+ import sys
49
+ from dataclasses import dataclass
50
+ from enum import Enum
51
+ from pathlib import Path
52
+ from typing import Self
53
+
54
+ import click
55
+
56
+ from erk.hooks.decorators import HookContext, hook_command
57
+ from erk_shared.branch_manager.abc import BranchManager
58
+ from erk_shared.branch_manager.factory import create_branch_manager
59
+ from erk_shared.extraction.claude_installation.abc import ClaudeInstallation
60
+ from erk_shared.git.abc import Git
61
+ from erk_shared.scratch.plan_snapshots import snapshot_plan_for_session
62
+ from erk_shared.scratch.scratch import get_scratch_dir
63
+
64
+ # Known terminal-based editors that cannot run inside Claude Code
65
+ TERMINAL_EDITORS = frozenset(
66
+ {"vim", "vi", "nvim", "nano", "emacs", "pico", "ne", "micro", "jed", "mcedit", "joe", "ed"}
67
+ )
68
+
69
+
70
+ def is_terminal_editor(editor: str | None) -> bool:
71
+ """Check if editor is a terminal-based (TUI) editor.
72
+
73
+ Terminal editors like vim cannot run inside Claude Code because they
74
+ need exclusive terminal control which conflicts with Claude's UI.
75
+
76
+ Args:
77
+ editor: The EDITOR environment variable value, or None.
78
+
79
+ Returns:
80
+ True if editor is a known terminal-based editor.
81
+ """
82
+ if editor is None:
83
+ return False
84
+ # Extract basename in case of full path like /usr/bin/vim
85
+ editor_name = Path(editor).name
86
+ return editor_name in TERMINAL_EDITORS
87
+
88
+
89
+ # ============================================================================
90
+ # Data Classes for Pure Logic
91
+ # ============================================================================
92
+
93
+
94
+ class ExitAction(Enum):
95
+ """Exit action for the hook."""
96
+
97
+ ALLOW = 0 # Exit code 0 - allow ExitPlanMode
98
+ BLOCK = 2 # Exit code 2 - block ExitPlanMode
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class HookInput:
103
+ """All inputs needed for decision logic."""
104
+
105
+ session_id: str | None
106
+ github_planning_enabled: bool
107
+ implement_now_marker_exists: bool
108
+ plan_saved_marker_exists: bool
109
+ incremental_plan_marker_exists: bool
110
+ objective_context_marker_exists: bool
111
+ objective_issue: int | None # Objective issue number if marker exists
112
+ plan_file_path: Path | None # Path to plan file if exists, None otherwise
113
+ plan_title: str | None # Title extracted from plan file for display
114
+ current_branch: str | None
115
+ worktree_name: str | None # Directory name of current worktree
116
+ pr_number: int | None # PR number if exists for current branch
117
+ plan_issue_number: int | None # Issue number from .impl/issue.json
118
+ editor: str | None # Value of EDITOR env var for TUI detection
119
+
120
+ @classmethod
121
+ def for_test(
122
+ cls,
123
+ *,
124
+ session_id: str | None = "test-session",
125
+ github_planning_enabled: bool = True,
126
+ implement_now_marker_exists: bool = False,
127
+ plan_saved_marker_exists: bool = False,
128
+ incremental_plan_marker_exists: bool = False,
129
+ objective_context_marker_exists: bool = False,
130
+ objective_issue: int | None = None,
131
+ plan_file_path: Path | None = None,
132
+ plan_title: str | None = None,
133
+ current_branch: str | None = "feature-branch",
134
+ worktree_name: str | None = None,
135
+ pr_number: int | None = None,
136
+ plan_issue_number: int | None = None,
137
+ editor: str | None = None,
138
+ ) -> Self:
139
+ """Create a HookInput with test defaults.
140
+
141
+ All fields have sensible defaults for testing:
142
+ - session_id: "test-session"
143
+ - github_planning_enabled: True
144
+ - All marker exists flags: False
145
+ - objective_issue: None
146
+ - plan_file_path: None
147
+ - plan_title: None
148
+ - current_branch: "feature-branch"
149
+ - worktree_name: None
150
+ - pr_number: None
151
+ - plan_issue_number: None
152
+ - editor: None
153
+ """
154
+ return cls(
155
+ session_id=session_id,
156
+ github_planning_enabled=github_planning_enabled,
157
+ implement_now_marker_exists=implement_now_marker_exists,
158
+ plan_saved_marker_exists=plan_saved_marker_exists,
159
+ incremental_plan_marker_exists=incremental_plan_marker_exists,
160
+ objective_context_marker_exists=objective_context_marker_exists,
161
+ objective_issue=objective_issue,
162
+ plan_file_path=plan_file_path,
163
+ plan_title=plan_title,
164
+ current_branch=current_branch,
165
+ worktree_name=worktree_name,
166
+ pr_number=pr_number,
167
+ plan_issue_number=plan_issue_number,
168
+ editor=editor,
169
+ )
170
+
171
+
172
+ @dataclass(frozen=True)
173
+ class HookOutput:
174
+ """Decision result from pure logic."""
175
+
176
+ action: ExitAction
177
+ message: str
178
+ delete_implement_now_marker: bool = False
179
+ delete_plan_saved_marker: bool = False
180
+ delete_incremental_plan_marker: bool = False
181
+ delete_objective_context_marker: bool = False
182
+
183
+
184
+ # ============================================================================
185
+ # Pure Functions (no I/O, fully testable without mocking)
186
+ # ============================================================================
187
+
188
+
189
+ def extract_plan_title(plan_file_path: Path | None) -> str | None:
190
+ """Extract title from plan file for display in menu.
191
+
192
+ Pure function - only reads file content, no other I/O.
193
+
194
+ Looks for:
195
+ 1. First H1 heading (# Title)
196
+ 2. Content after "## Task" section
197
+
198
+ Returns None if file doesn't exist or no title found.
199
+ """
200
+ if plan_file_path is None or not plan_file_path.exists():
201
+ return None
202
+
203
+ text = plan_file_path.read_text(encoding="utf-8")
204
+ lines = text.split("\n")
205
+
206
+ # Look for first H1 (skip generic titles)
207
+ for line in lines[:10]:
208
+ if line.startswith("# "):
209
+ title = line[2:].strip()
210
+ if title.lower() not in ("plan", "implementation plan"):
211
+ return title
212
+
213
+ # Look for ## Task section
214
+ for i, line in enumerate(lines[:20]):
215
+ if line.strip() == "## Task":
216
+ for next_line in lines[i + 1 : i + 5]:
217
+ if next_line.strip():
218
+ return next_line.strip()
219
+
220
+ return None
221
+
222
+
223
+ def build_blocking_message(
224
+ session_id: str,
225
+ current_branch: str | None,
226
+ plan_file_path: Path | None,
227
+ objective_issue: int | None,
228
+ plan_title: str | None,
229
+ worktree_name: str | None,
230
+ pr_number: int | None,
231
+ plan_issue_number: int | None,
232
+ editor: str | None,
233
+ ) -> str:
234
+ """Build the blocking message with AskUserQuestion instructions.
235
+
236
+ Pure function - string building only. Testable without mocking.
237
+
238
+ Args:
239
+ session_id: Claude session ID for marker creation commands.
240
+ current_branch: Current git branch name.
241
+ plan_file_path: Path to the plan file, if it exists.
242
+ objective_issue: Objective issue number, if this plan is part of an objective.
243
+ plan_title: Title extracted from plan file, if available.
244
+ worktree_name: Directory name of current worktree.
245
+ pr_number: PR number if exists for current branch.
246
+ plan_issue_number: Issue number from .impl/issue.json.
247
+ editor: Value of EDITOR env var for TUI detection.
248
+ """
249
+ # Build context lines for the question
250
+ context_lines: list[str] = []
251
+
252
+ # First line: title
253
+ if plan_title:
254
+ context_lines.append(f"📋 {plan_title}")
255
+
256
+ # Second line: statusline-style context
257
+ statusline_parts: list[str] = []
258
+ if worktree_name:
259
+ statusline_parts.append(f"wt:{worktree_name}")
260
+ if current_branch:
261
+ statusline_parts.append(f"br:{current_branch}")
262
+ if pr_number is not None:
263
+ statusline_parts.append(f"gh:#{pr_number}")
264
+ if plan_issue_number is not None:
265
+ statusline_parts.append(f"plan:#{plan_issue_number}")
266
+
267
+ if statusline_parts:
268
+ statusline = " ".join(f"({part})" for part in statusline_parts)
269
+ context_lines.append(statusline)
270
+
271
+ context_block = "\n".join(context_lines)
272
+
273
+ # Build the question text
274
+ if context_block:
275
+ question_text = f"{context_block}\\n\\nWhat would you like to do with this plan?"
276
+ else:
277
+ question_text = "What would you like to do with this plan?"
278
+
279
+ lines = [
280
+ "PLAN SAVE PROMPT",
281
+ "",
282
+ "A plan exists for this session but has not been saved.",
283
+ "",
284
+ "Use AskUserQuestion to ask the user:",
285
+ f' "{question_text}"',
286
+ "",
287
+ "IMPORTANT: Present options in this exact order:",
288
+ ' 1. "Save the plan" (Recommended) - Save plan as a GitHub issue and stop. '
289
+ "Does NOT proceed to implementation.",
290
+ ' 2. "Implement" - Save to GitHub, then immediately implement (full workflow).',
291
+ ' 3. "Incremental implementation" - Skip saving, implement directly in current '
292
+ "worktree (for small PR iterations that don't need issue tracking).",
293
+ ' 4. "View/Edit the plan" - Open plan in editor to review or modify before deciding.',
294
+ ]
295
+
296
+ if current_branch in ("master", "main"):
297
+ lines.extend(
298
+ [
299
+ "",
300
+ f"⚠️ WARNING: Currently on '{current_branch}'. "
301
+ "We strongly discourage editing directly on the trunk branch. "
302
+ "Consider saving the plan and implementing in a dedicated worktree instead.",
303
+ ]
304
+ )
305
+
306
+ # Build the save command with optional --objective-issue flag
307
+ if objective_issue is not None:
308
+ save_cmd = f"/erk:plan-save --objective-issue={objective_issue}"
309
+ else:
310
+ save_cmd = "/erk:plan-save"
311
+
312
+ lines.extend(
313
+ [
314
+ "",
315
+ "If user chooses 'Save the plan':",
316
+ f" 1. Run {save_cmd}",
317
+ " 2. STOP - Do NOT call ExitPlanMode. The plan-save command handles everything.",
318
+ " Stay in plan mode and let the user exit manually if desired.",
319
+ "",
320
+ "If user chooses 'Implement':",
321
+ f" 1. Run {save_cmd}",
322
+ " 2. After save completes, create implement-now marker:",
323
+ f" erk exec marker create --session-id {session_id} \\",
324
+ " exit-plan-mode-hook.implement-now",
325
+ " 3. Call ExitPlanMode",
326
+ " 4. After exiting plan mode, run /erk:plan-implement to execute implementation",
327
+ "",
328
+ "If user chooses 'Incremental implementation':",
329
+ " 1. Create implement-now marker (skip saving):",
330
+ f" erk exec marker create --session-id {session_id} \\",
331
+ " exit-plan-mode-hook.implement-now",
332
+ " 2. Call ExitPlanMode",
333
+ " 3. After exiting plan mode, implement the changes directly",
334
+ " (no issue tracking - this is for small PR iterations)",
335
+ ]
336
+ )
337
+
338
+ if plan_file_path is not None:
339
+ if is_terminal_editor(editor):
340
+ # TUI editors can't run inside Claude Code
341
+ editor_name = Path(editor).name if editor else "your editor"
342
+ lines.extend(
343
+ [
344
+ "",
345
+ "If user chooses 'View/Edit the plan':",
346
+ f" 1. Tell user: '{editor_name} is a terminal-based editor that cannot",
347
+ " run inside Claude Code. Please open the plan in a separate terminal:'",
348
+ f" {editor} {plan_file_path}",
349
+ " 2. Wait for user to confirm they're done editing",
350
+ " 3. Ask the same question again (loop until Save/Implement/Incremental)",
351
+ ]
352
+ )
353
+ else:
354
+ lines.extend(
355
+ [
356
+ "",
357
+ "If user chooses 'View/Edit the plan':",
358
+ f" 1. Run: ${{EDITOR:-code}} {plan_file_path}",
359
+ " 2. After user confirms they're done editing, ask the same question again",
360
+ " (loop until user chooses Save, Implement, or Incremental)",
361
+ ]
362
+ )
363
+
364
+ return "\n".join(lines)
365
+
366
+
367
+ def determine_exit_action(hook_input: HookInput) -> HookOutput:
368
+ """Determine what action to take based on inputs.
369
+
370
+ Pure function - all decision logic, no I/O. Testable without mocking!
371
+ """
372
+ # Early exit if github_planning is disabled
373
+ if not hook_input.github_planning_enabled:
374
+ return HookOutput(ExitAction.ALLOW, "")
375
+
376
+ # No session context
377
+ if hook_input.session_id is None:
378
+ return HookOutput(ExitAction.ALLOW, "No session context available, allowing exit")
379
+
380
+ # Implement-now marker present (user chose "Implement now")
381
+ if hook_input.implement_now_marker_exists:
382
+ return HookOutput(
383
+ ExitAction.ALLOW,
384
+ "Implement-now marker found, allowing exit",
385
+ delete_implement_now_marker=True,
386
+ delete_objective_context_marker=hook_input.objective_context_marker_exists,
387
+ )
388
+
389
+ # Incremental-plan marker present (session started via /local:incremental-plan-mode)
390
+ # Skip the "save as GitHub issue?" prompt and proceed directly to implementation
391
+ if hook_input.incremental_plan_marker_exists:
392
+ return HookOutput(
393
+ ExitAction.ALLOW,
394
+ "Incremental-plan mode: skipping save prompt, proceeding to implementation",
395
+ delete_incremental_plan_marker=True,
396
+ )
397
+
398
+ # Plan-saved marker present (user chose "Save to GitHub")
399
+ if hook_input.plan_saved_marker_exists:
400
+ return HookOutput(
401
+ ExitAction.BLOCK,
402
+ "✅ Plan already saved to GitHub. Session complete - no further action needed.",
403
+ delete_plan_saved_marker=True,
404
+ delete_objective_context_marker=hook_input.objective_context_marker_exists,
405
+ )
406
+
407
+ # No plan file
408
+ if hook_input.plan_file_path is None:
409
+ return HookOutput(
410
+ ExitAction.ALLOW,
411
+ "No plan file found for this session, allowing exit",
412
+ )
413
+
414
+ # Plan exists, no implement-now marker - block and instruct
415
+ return HookOutput(
416
+ ExitAction.BLOCK,
417
+ build_blocking_message(
418
+ hook_input.session_id,
419
+ hook_input.current_branch,
420
+ hook_input.plan_file_path,
421
+ hook_input.objective_issue,
422
+ hook_input.plan_title,
423
+ hook_input.worktree_name,
424
+ hook_input.pr_number,
425
+ hook_input.plan_issue_number,
426
+ hook_input.editor,
427
+ ),
428
+ )
429
+
430
+
431
+ # ============================================================================
432
+ # I/O Helper Functions
433
+ # ============================================================================
434
+
435
+
436
+ def _get_implement_now_marker_path(session_id: str, repo_root: Path) -> Path:
437
+ """Get implement-now marker path in .erk/scratch/sessions/<session_id>/.
438
+
439
+ Args:
440
+ session_id: The session ID to build the path for
441
+ repo_root: Path to the git repository root
442
+
443
+ Returns:
444
+ Path to implement-now marker file
445
+ """
446
+ scratch_dir = get_scratch_dir(session_id, repo_root=repo_root)
447
+ return scratch_dir / "exit-plan-mode-hook.implement-now.marker"
448
+
449
+
450
+ def _get_plan_saved_marker_path(session_id: str, repo_root: Path) -> Path:
451
+ """Get plan-saved marker path in .erk/scratch/sessions/<session_id>/.
452
+
453
+ The plan-saved marker indicates the plan was already saved to GitHub,
454
+ so exit should proceed without triggering implementation.
455
+
456
+ Args:
457
+ session_id: The session ID to build the path for
458
+ repo_root: Path to the git repository root
459
+
460
+ Returns:
461
+ Path to plan-saved marker file
462
+ """
463
+ scratch_dir = get_scratch_dir(session_id, repo_root=repo_root)
464
+ return scratch_dir / "exit-plan-mode-hook.plan-saved.marker"
465
+
466
+
467
+ def _get_incremental_plan_marker_path(session_id: str, repo_root: Path) -> Path:
468
+ """Get incremental-plan marker path in .erk/scratch/sessions/<session_id>/.
469
+
470
+ The incremental-plan marker indicates this session was started via
471
+ /local:incremental-plan, so we should skip the "save as GitHub issue?"
472
+ prompt and proceed directly to implementation.
473
+
474
+ Args:
475
+ session_id: The session ID to build the path for
476
+ repo_root: Path to the git repository root
477
+
478
+ Returns:
479
+ Path to incremental-plan marker file
480
+ """
481
+ return get_scratch_dir(session_id, repo_root=repo_root) / "incremental-plan.marker"
482
+
483
+
484
+ def _get_objective_context_marker_path(session_id: str, repo_root: Path) -> Path:
485
+ """Get objective-context marker path in .erk/scratch/sessions/<session_id>/.
486
+
487
+ The objective-context marker stores the objective issue number when
488
+ a plan is created via /erk:objective-create-plan. This allows the hook
489
+ to suggest the correct --objective-issue flag in the save command.
490
+
491
+ Args:
492
+ session_id: The session ID to build the path for
493
+ repo_root: Path to the git repository root
494
+
495
+ Returns:
496
+ Path to objective-context marker file
497
+ """
498
+ return get_scratch_dir(session_id, repo_root=repo_root) / "objective-context.marker"
499
+
500
+
501
+ def _read_objective_context(session_id: str, repo_root: Path) -> int | None:
502
+ """Read objective issue number from marker, if present.
503
+
504
+ Args:
505
+ session_id: The session ID to look up
506
+ repo_root: Path to the git repository root
507
+
508
+ Returns:
509
+ Objective issue number, or None if marker doesn't exist or is invalid.
510
+ """
511
+ marker_path = _get_objective_context_marker_path(session_id, repo_root)
512
+ if not marker_path.exists():
513
+ return None
514
+ content = marker_path.read_text(encoding="utf-8").strip()
515
+ if not content.isdigit():
516
+ return None
517
+ return int(content)
518
+
519
+
520
+ def _find_session_plan(
521
+ session_id: str, repo_root: Path, claude_installation: ClaudeInstallation
522
+ ) -> Path | None:
523
+ """Find plan file for the given session using slug lookup.
524
+
525
+ Args:
526
+ session_id: The session ID to search for
527
+ repo_root: Path to the git repository root
528
+ claude_installation: Gateway to Claude installation data
529
+
530
+ Returns:
531
+ Path to plan file if found, None otherwise
532
+ """
533
+ return claude_installation.find_plan_for_session(repo_root, session_id)
534
+
535
+
536
+ def _get_worktree_name(git: Git, repo_root: Path) -> str | None:
537
+ """Get the directory name of the current worktree.
538
+
539
+ Args:
540
+ git: Git gateway for worktree operations
541
+ repo_root: Path to the git repository root
542
+
543
+ Returns:
544
+ Worktree directory name, or None if not found
545
+ """
546
+ worktrees = git.list_worktrees(repo_root)
547
+ if not worktrees:
548
+ return None
549
+
550
+ for wt in worktrees:
551
+ if wt.path == repo_root:
552
+ return wt.path.name
553
+
554
+ return None
555
+
556
+
557
+ def _get_pr_number_for_branch(
558
+ branch_manager: BranchManager, repo_root: Path, branch: str
559
+ ) -> int | None:
560
+ """Get PR number for the given branch.
561
+
562
+ Args:
563
+ branch_manager: BranchManager for PR lookups (Graphite or GitHub)
564
+ repo_root: Path to the git repository root
565
+ branch: Branch name to look up
566
+
567
+ Returns:
568
+ PR number if exists, None otherwise
569
+ """
570
+ pr_info = branch_manager.get_pr_for_branch(repo_root, branch)
571
+ if pr_info is None:
572
+ return None
573
+ return pr_info.number
574
+
575
+
576
+ def _get_plan_issue_from_impl(repo_root: Path) -> int | None:
577
+ """Load plan issue number from .impl/issue.json file.
578
+
579
+ Args:
580
+ repo_root: Path to the git repository root
581
+
582
+ Returns:
583
+ Issue number if found, None otherwise
584
+ """
585
+ issue_file = repo_root / ".impl" / "issue.json"
586
+ if not issue_file.is_file():
587
+ return None
588
+
589
+ content = issue_file.read_text(encoding="utf-8")
590
+ if not content.strip():
591
+ return None
592
+
593
+ data = json.loads(content)
594
+ # Try "issue_number" first (preferred), then fall back to "number"
595
+ issue_number = data.get("issue_number") or data.get("number")
596
+ if isinstance(issue_number, int):
597
+ return issue_number
598
+
599
+ return None
600
+
601
+
602
+ # ============================================================================
603
+ # Main Hook Entry Point
604
+ # ============================================================================
605
+
606
+
607
+ def _gather_inputs(
608
+ session_id: str | None,
609
+ repo_root: Path,
610
+ github_planning_enabled: bool,
611
+ claude_installation: ClaudeInstallation,
612
+ git: Git,
613
+ branch_manager: BranchManager,
614
+ ) -> HookInput:
615
+ """Gather all inputs from environment. All I/O happens here.
616
+
617
+ Args:
618
+ session_id: Claude session ID from hook_ctx, or None if not available.
619
+ repo_root: Path to the git repository root.
620
+ github_planning_enabled: Whether github_planning is enabled in config.
621
+ claude_installation: Gateway to Claude installation data.
622
+ git: Git gateway for worktree operations.
623
+ branch_manager: BranchManager for PR lookups.
624
+
625
+ Returns:
626
+ HookInput with all gathered state.
627
+ """
628
+ # Determine marker existence
629
+ implement_now_marker_exists = False
630
+ plan_saved_marker_exists = False
631
+ incremental_plan_marker_exists = False
632
+ objective_context_marker_exists = False
633
+ objective_issue: int | None = None
634
+ if session_id is not None:
635
+ implement_now_marker_exists = _get_implement_now_marker_path(session_id, repo_root).exists()
636
+ plan_saved_marker_exists = _get_plan_saved_marker_path(session_id, repo_root).exists()
637
+ marker_path = _get_incremental_plan_marker_path(session_id, repo_root)
638
+ incremental_plan_marker_exists = marker_path.exists()
639
+ objective_context_marker_exists = _get_objective_context_marker_path(
640
+ session_id, repo_root
641
+ ).exists()
642
+ objective_issue = _read_objective_context(session_id, repo_root)
643
+
644
+ # Find plan file path (None if doesn't exist)
645
+ plan_file_path: Path | None = None
646
+ if session_id is not None:
647
+ plan_file_path = _find_session_plan(session_id, repo_root, claude_installation)
648
+
649
+ # Extract title for display (after finding plan file)
650
+ plan_title: str | None = None
651
+ if plan_file_path is not None:
652
+ plan_title = extract_plan_title(plan_file_path)
653
+
654
+ # Get current branch (only if we need to show the blocking message)
655
+ current_branch: str | None = None
656
+ worktree_name: str | None = None
657
+ pr_number: int | None = None
658
+ plan_issue_number: int | None = None
659
+
660
+ needs_blocking_message = (
661
+ session_id is not None
662
+ and plan_file_path is not None
663
+ and not implement_now_marker_exists
664
+ and not incremental_plan_marker_exists
665
+ and not plan_saved_marker_exists
666
+ )
667
+ # Get EDITOR env var for TUI detection
668
+ editor: str | None = None
669
+ if needs_blocking_message:
670
+ current_branch = git.get_current_branch(repo_root)
671
+ worktree_name = _get_worktree_name(git, repo_root)
672
+ plan_issue_number = _get_plan_issue_from_impl(repo_root)
673
+ editor = os.environ.get("EDITOR")
674
+ # Only lookup PR if we have a branch
675
+ if current_branch is not None:
676
+ pr_number = _get_pr_number_for_branch(branch_manager, repo_root, current_branch)
677
+
678
+ return HookInput(
679
+ session_id=session_id,
680
+ github_planning_enabled=github_planning_enabled,
681
+ implement_now_marker_exists=implement_now_marker_exists,
682
+ plan_saved_marker_exists=plan_saved_marker_exists,
683
+ incremental_plan_marker_exists=incremental_plan_marker_exists,
684
+ objective_context_marker_exists=objective_context_marker_exists,
685
+ objective_issue=objective_issue,
686
+ plan_file_path=plan_file_path,
687
+ plan_title=plan_title,
688
+ current_branch=current_branch,
689
+ worktree_name=worktree_name,
690
+ pr_number=pr_number,
691
+ plan_issue_number=plan_issue_number,
692
+ editor=editor,
693
+ )
694
+
695
+
696
+ def _execute_result(
697
+ result: HookOutput,
698
+ hook_input: HookInput,
699
+ repo_root: Path,
700
+ claude_installation: ClaudeInstallation,
701
+ ) -> None:
702
+ """Execute the decision result. All I/O happens here."""
703
+ session_id = hook_input.session_id
704
+
705
+ if result.delete_implement_now_marker and session_id:
706
+ _get_implement_now_marker_path(session_id, repo_root).unlink()
707
+
708
+ if result.delete_plan_saved_marker and session_id:
709
+ _get_plan_saved_marker_path(session_id, repo_root).unlink()
710
+
711
+ if result.delete_incremental_plan_marker and session_id:
712
+ _get_incremental_plan_marker_path(session_id, repo_root).unlink()
713
+
714
+ if result.delete_objective_context_marker and session_id:
715
+ _get_objective_context_marker_path(session_id, repo_root).unlink()
716
+
717
+ # Snapshot plan whenever a plan exists and user made a decision
718
+ # (implement-now or plan-saved, but NOT when blocking to prompt)
719
+ user_made_decision = result.delete_implement_now_marker or result.delete_plan_saved_marker
720
+ if hook_input.plan_file_path is not None and session_id is not None and user_made_decision:
721
+ snapshot_plan_for_session(
722
+ session_id=session_id,
723
+ plan_file_path=hook_input.plan_file_path,
724
+ project_cwd=repo_root,
725
+ claude_installation=claude_installation,
726
+ repo_root=repo_root,
727
+ )
728
+
729
+ if result.message:
730
+ click.echo(result.message, err=True)
731
+
732
+ sys.exit(result.action.value)
733
+
734
+
735
+ @hook_command(name="exit-plan-mode-hook")
736
+ def exit_plan_mode_hook(ctx: click.Context, *, hook_ctx: HookContext) -> None:
737
+ """Prompt user about plan saving when ExitPlanMode is called.
738
+
739
+ This PreToolUse hook intercepts ExitPlanMode calls to ask the user
740
+ whether to save the plan to GitHub or implement immediately.
741
+
742
+ Exit codes:
743
+ 0: Success - allow exit (no plan, skip marker, or no session)
744
+ 2: Block - plan exists, prompt user for action
745
+ """
746
+ # Scope check: only run in erk-managed projects
747
+ if not hook_ctx.is_erk_project:
748
+ return
749
+
750
+ # Get github_planning from injected context (defaults to True if not configured)
751
+ global_config = ctx.obj.global_config
752
+ github_planning_enabled = global_config.github_planning if global_config is not None else True
753
+
754
+ # Create branch manager for PR lookups
755
+ branch_manager = create_branch_manager(
756
+ git=ctx.obj.git, github=ctx.obj.github, graphite=ctx.obj.graphite
757
+ )
758
+
759
+ # Gather all inputs (I/O layer)
760
+ hook_input = _gather_inputs(
761
+ hook_ctx.session_id,
762
+ hook_ctx.repo_root,
763
+ github_planning_enabled,
764
+ ctx.obj.claude_installation,
765
+ ctx.obj.git,
766
+ branch_manager,
767
+ )
768
+
769
+ # Pure decision logic (no I/O)
770
+ result = determine_exit_action(hook_input)
771
+
772
+ # Execute result (I/O layer)
773
+ _execute_result(result, hook_input, hook_ctx.repo_root, ctx.obj.claude_installation)
774
+
775
+
776
+ if __name__ == "__main__":
777
+ exit_plan_mode_hook()