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,32 @@
1
+ """Quick commit all changes and submit with Graphite CLI command."""
2
+
3
+ import json
4
+ from dataclasses import asdict
5
+ from pathlib import Path
6
+
7
+ import click
8
+
9
+ from erk_shared.gateway.gt.cli import render_events
10
+ from erk_shared.gateway.gt.operations.quick_submit import execute_quick_submit
11
+ from erk_shared.gateway.gt.real import RealGtKit
12
+ from erk_shared.gateway.gt.types import QuickSubmitError
13
+
14
+
15
+ @click.command("quick-submit")
16
+ def quick_submit() -> None:
17
+ """Quick commit all changes and submit with Graphite.
18
+
19
+ Stages all changes, commits with "update" message if there are changes,
20
+ then runs gt submit. This is a fast iteration shortcut.
21
+
22
+ For proper commit messages, use the pr-submit command instead.
23
+ """
24
+ cwd = Path.cwd()
25
+ ops = RealGtKit(cwd)
26
+ result = render_events(execute_quick_submit(ops, cwd))
27
+
28
+ # Output JSON result
29
+ click.echo(json.dumps(asdict(result), indent=2))
30
+
31
+ if isinstance(result, QuickSubmitError):
32
+ raise SystemExit(1)
@@ -0,0 +1,260 @@
1
+ #!/usr/bin/env python3
2
+ """Rebase onto trunk and resolve conflicts with Claude.
3
+
4
+ This command handles merge conflicts during CI workflows by:
5
+ 1. Fetching the trunk branch
6
+ 2. Checking if the current branch is behind
7
+ 3. Starting a rebase
8
+ 4. Using Claude to resolve any conflicts
9
+ 5. Force pushing after successful rebase
10
+
11
+ Usage:
12
+ erk exec rebase-with-conflict-resolution \
13
+ --trunk-branch master \
14
+ --branch-name feature-branch \
15
+ --model claude-sonnet-4-5
16
+
17
+ Output:
18
+ JSON object with success status
19
+
20
+ Exit Codes:
21
+ 0: Success (rebase completed and pushed, or already up-to-date)
22
+ 1: Error (conflict resolution failed after max attempts)
23
+
24
+ Examples:
25
+ $ erk exec rebase-with-conflict-resolution --trunk-branch main --branch-name my-feature
26
+ {
27
+ "success": true,
28
+ "action": "rebased",
29
+ "commits_behind": 3
30
+ }
31
+
32
+ $ erk exec rebase-with-conflict-resolution --trunk-branch main --branch-name my-feature
33
+ {
34
+ "success": true,
35
+ "action": "already-up-to-date",
36
+ "commits_behind": 0
37
+ }
38
+ """
39
+
40
+ import json
41
+ import subprocess
42
+ from dataclasses import asdict, dataclass
43
+ from pathlib import Path
44
+ from typing import Literal
45
+
46
+ import click
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class RebaseSuccess:
51
+ """Success result for rebase operation."""
52
+
53
+ success: bool
54
+ action: Literal["rebased", "already-up-to-date"]
55
+ commits_behind: int
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class RebaseError:
60
+ """Error result when rebase fails."""
61
+
62
+ success: bool
63
+ error: Literal["fetch-failed", "rebase-failed", "push-failed"]
64
+ message: str
65
+
66
+
67
+ def _get_commits_behind(trunk_branch: str) -> int | None:
68
+ """Get number of commits behind trunk branch.
69
+
70
+ Returns:
71
+ Number of commits behind, or None if command fails.
72
+ """
73
+ result = subprocess.run(
74
+ ["git", "rev-list", "--count", f"HEAD..origin/{trunk_branch}"],
75
+ capture_output=True,
76
+ text=True,
77
+ check=False,
78
+ )
79
+ if result.returncode != 0:
80
+ return None
81
+ count_str = result.stdout.strip()
82
+ if not count_str.isdigit():
83
+ return None
84
+ return int(count_str)
85
+
86
+
87
+ def _is_rebase_in_progress() -> bool:
88
+ """Check if a rebase is currently in progress."""
89
+ git_dir = Path(".git")
90
+ return (git_dir / "rebase-merge").is_dir() or (git_dir / "rebase-apply").is_dir()
91
+
92
+
93
+ def _invoke_claude_for_conflicts(model: str) -> bool:
94
+ """Invoke Claude to fix merge conflicts.
95
+
96
+ Returns:
97
+ True if Claude invocation succeeded (exit code 0).
98
+ """
99
+ prompt = (
100
+ "Fix all merge conflicts in this repository. "
101
+ "For each conflicted file, read it, resolve the conflict markers appropriately, "
102
+ "and save the file. After fixing all conflicts, stage the resolved files with "
103
+ "'git add' and then run 'git rebase --continue' to continue the rebase."
104
+ )
105
+ result = subprocess.run(
106
+ [
107
+ "claude",
108
+ "--print",
109
+ "--model",
110
+ model,
111
+ "--output-format",
112
+ "stream-json",
113
+ "--dangerously-skip-permissions",
114
+ "--verbose",
115
+ prompt,
116
+ ],
117
+ capture_output=False, # Let output stream to stdout/stderr
118
+ check=False, # We check returncode explicitly below
119
+ )
120
+ return result.returncode == 0
121
+
122
+
123
+ def _rebase_with_conflict_resolution_impl(
124
+ trunk_branch: str,
125
+ branch_name: str,
126
+ model: str,
127
+ max_attempts: int,
128
+ ) -> RebaseSuccess | RebaseError:
129
+ """Rebase onto trunk and resolve conflicts with Claude.
130
+
131
+ Args:
132
+ trunk_branch: Trunk branch to rebase onto (e.g., 'main', 'master')
133
+ branch_name: Current branch name for force push
134
+ model: Claude model to use for conflict resolution
135
+ max_attempts: Maximum number of conflict resolution attempts
136
+
137
+ Returns:
138
+ RebaseSuccess on success, RebaseError on failure
139
+ """
140
+ # Fetch trunk branch
141
+ fetch_result = subprocess.run(
142
+ ["git", "fetch", "origin", trunk_branch],
143
+ capture_output=True,
144
+ text=True,
145
+ check=False, # We check returncode explicitly below
146
+ )
147
+ if fetch_result.returncode != 0:
148
+ return RebaseError(
149
+ success=False,
150
+ error="fetch-failed",
151
+ message=f"Failed to fetch origin/{trunk_branch}: {fetch_result.stderr}",
152
+ )
153
+
154
+ # Check if behind
155
+ commits_behind = _get_commits_behind(trunk_branch)
156
+ if commits_behind is None:
157
+ return RebaseError(
158
+ success=False,
159
+ error="fetch-failed",
160
+ message="Failed to determine commits behind trunk",
161
+ )
162
+
163
+ if commits_behind == 0:
164
+ return RebaseSuccess(
165
+ success=True,
166
+ action="already-up-to-date",
167
+ commits_behind=0,
168
+ )
169
+
170
+ # Start rebase (may fail with conflicts, which is expected)
171
+ subprocess.run(
172
+ ["git", "rebase", f"origin/{trunk_branch}"],
173
+ capture_output=True,
174
+ text=True,
175
+ check=False, # Conflicts expected - we check _is_rebase_in_progress()
176
+ )
177
+
178
+ # Loop while rebase has conflicts
179
+ attempt = 0
180
+ while _is_rebase_in_progress() and attempt < max_attempts:
181
+ attempt += 1
182
+ # Invoke Claude to fix conflicts
183
+ _invoke_claude_for_conflicts(model)
184
+
185
+ # Check if rebase completed
186
+ if _is_rebase_in_progress():
187
+ # Abort rebase and return error
188
+ subprocess.run(["git", "rebase", "--abort"], capture_output=True, check=False)
189
+ return RebaseError(
190
+ success=False,
191
+ error="rebase-failed",
192
+ message=f"Failed to resolve conflicts after {max_attempts} attempts",
193
+ )
194
+
195
+ # Force push after successful rebase
196
+ push_result = subprocess.run(
197
+ ["git", "push", "-f", "origin", branch_name],
198
+ capture_output=True,
199
+ text=True,
200
+ check=False, # We check returncode explicitly below
201
+ )
202
+ if push_result.returncode != 0:
203
+ return RebaseError(
204
+ success=False,
205
+ error="push-failed",
206
+ message=f"Failed to force push: {push_result.stderr}",
207
+ )
208
+
209
+ return RebaseSuccess(
210
+ success=True,
211
+ action="rebased",
212
+ commits_behind=commits_behind,
213
+ )
214
+
215
+
216
+ @click.command(name="rebase-with-conflict-resolution")
217
+ @click.option(
218
+ "--trunk-branch",
219
+ required=True,
220
+ help="Trunk branch to rebase onto (e.g., 'main', 'master')",
221
+ )
222
+ @click.option(
223
+ "--branch-name",
224
+ required=True,
225
+ help="Current branch name for force push",
226
+ )
227
+ @click.option(
228
+ "--model",
229
+ default="claude-sonnet-4-5",
230
+ help="Claude model to use for conflict resolution",
231
+ )
232
+ @click.option(
233
+ "--max-attempts",
234
+ default=5,
235
+ type=int,
236
+ help="Maximum number of conflict resolution attempts",
237
+ )
238
+ def rebase_with_conflict_resolution(
239
+ trunk_branch: str,
240
+ branch_name: str,
241
+ model: str,
242
+ max_attempts: int,
243
+ ) -> None:
244
+ """Rebase onto trunk and resolve conflicts with Claude.
245
+
246
+ This command is designed for CI workflows where push may fail due to
247
+ branch divergence. It fetches the trunk branch, rebases onto it,
248
+ and uses Claude to resolve any merge conflicts.
249
+ """
250
+ result = _rebase_with_conflict_resolution_impl(
251
+ trunk_branch=trunk_branch,
252
+ branch_name=branch_name,
253
+ model=model,
254
+ max_attempts=max_attempts,
255
+ )
256
+
257
+ click.echo(json.dumps(asdict(result), indent=2))
258
+
259
+ if isinstance(result, RebaseError):
260
+ raise SystemExit(1)
@@ -0,0 +1,173 @@
1
+ """Reply to a PR discussion comment with a blockquote and action summary.
2
+
3
+ This exec command posts a reply to a discussion comment that:
4
+ 1. Quotes the original comment with author attribution
5
+ 2. Includes an action summary explaining what was done
6
+ 3. Adds a reaction to the original comment
7
+
8
+ Usage:
9
+ erk exec reply-to-discussion-comment --comment-id 12345 --reply "Action taken: ..."
10
+ erk exec reply-to-discussion-comment --comment-id 12345 --pr 789 --reply "..."
11
+
12
+ Output:
13
+ JSON with success status and reply details
14
+
15
+ Exit Codes:
16
+ 0: Success
17
+ 1: Error (comment not found, API failure, etc.)
18
+
19
+ Examples:
20
+ $ erk exec reply-to-discussion-comment --comment-id 12345 --reply "Fixed typo in docs"
21
+ {"success": true, "comment_id": 12345, "reply_id": 67890}
22
+ """
23
+
24
+ import json
25
+ from datetime import UTC, datetime
26
+
27
+ import click
28
+
29
+ from erk.cli.script_output import exit_with_error
30
+ from erk_shared.context.helpers import (
31
+ get_current_branch,
32
+ require_github,
33
+ require_repo_root,
34
+ )
35
+ from erk_shared.context.helpers import (
36
+ require_issues as require_github_issues,
37
+ )
38
+ from erk_shared.github.checks import GitHubChecks
39
+ from erk_shared.non_ideal_state import (
40
+ BranchDetectionFailed,
41
+ GitHubAPIFailed,
42
+ NoPRForBranch,
43
+ PRNotFoundError,
44
+ )
45
+
46
+
47
+ def _format_reply(author: str, url: str, body: str, action_summary: str) -> str:
48
+ """Format a reply with blockquote of original comment and action summary.
49
+
50
+ Args:
51
+ author: Original comment author's GitHub login
52
+ url: URL to the original comment
53
+ body: Body of the original comment
54
+ action_summary: Description of action taken
55
+
56
+ Returns:
57
+ Formatted markdown reply
58
+ """
59
+ # Quote the original comment (truncate if very long)
60
+ quoted_lines = body.strip().split("\n")
61
+ if len(quoted_lines) > 10:
62
+ # Truncate long comments
63
+ quoted_body = "\n".join(quoted_lines[:10]) + "\n> ..."
64
+ else:
65
+ quoted_body = body.strip()
66
+
67
+ # Add blockquote prefix to each line
68
+ quoted = "\n".join(f"> {line}" for line in quoted_body.split("\n"))
69
+
70
+ # Get current timestamp
71
+ now = datetime.now(UTC).strftime("%Y-%m-%d %I:%M %p UTC")
72
+
73
+ return f"""> **@{author}** [commented]({url}):
74
+ {quoted}
75
+
76
+ {action_summary}
77
+
78
+ ---
79
+ <sub>Addressed via `/erk:pr-address` at {now}</sub>"""
80
+
81
+
82
+ @click.command(name="reply-to-discussion-comment")
83
+ @click.option("--comment-id", required=True, type=int, help="Numeric comment ID to reply to")
84
+ @click.option("--pr", type=int, default=None, help="PR number (defaults to current branch's PR)")
85
+ @click.option("--reply", required=True, help="Action summary text (what was done)")
86
+ @click.pass_context
87
+ def reply_to_discussion_comment(
88
+ ctx: click.Context, comment_id: int, pr: int | None, reply: str
89
+ ) -> None:
90
+ """Reply to a PR discussion comment with quote and action summary.
91
+
92
+ Fetches the original comment to get its author and body, then posts a
93
+ formatted reply quoting the original with your action summary. Also adds
94
+ a +1 reaction to the original comment.
95
+ """
96
+ # Get dependencies from context
97
+ repo_root = require_repo_root(ctx)
98
+ github = require_github(ctx)
99
+ github_issues = require_github_issues(ctx)
100
+
101
+ # Get PR details - either from current branch or specified PR number
102
+ if pr is None:
103
+ branch_result = GitHubChecks.branch(get_current_branch(ctx))
104
+ if isinstance(branch_result, BranchDetectionFailed):
105
+ exit_with_error(branch_result.error_type, branch_result.message)
106
+ # Type narrowing: exit_with_error returns NoReturn, so branch_result is str
107
+ assert not isinstance(branch_result, BranchDetectionFailed)
108
+ branch = branch_result
109
+
110
+ pr_result = GitHubChecks.pr_for_branch(github, repo_root, branch)
111
+ if isinstance(pr_result, NoPRForBranch):
112
+ exit_with_error(pr_result.error_type, pr_result.message)
113
+ assert not isinstance(pr_result, NoPRForBranch)
114
+ pr_details = pr_result
115
+ else:
116
+ pr_result = GitHubChecks.pr_by_number(github, repo_root, pr)
117
+ if isinstance(pr_result, PRNotFoundError):
118
+ exit_with_error(pr_result.error_type, pr_result.message)
119
+ assert not isinstance(pr_result, PRNotFoundError)
120
+ pr_details = pr_result
121
+
122
+ # Fetch all discussion comments to find the one we're replying to
123
+ comments_result = GitHubChecks.issue_comments(github_issues, repo_root, pr_details.number)
124
+ if isinstance(comments_result, GitHubAPIFailed):
125
+ exit_with_error(comments_result.error_type, comments_result.message)
126
+ assert not isinstance(comments_result, GitHubAPIFailed)
127
+
128
+ # Find the comment by ID
129
+ target_comment = None
130
+ for comment in comments_result:
131
+ if comment.id == comment_id:
132
+ target_comment = comment
133
+ break
134
+
135
+ if target_comment is None:
136
+ exit_with_error(
137
+ "comment-not-found",
138
+ f"Comment ID {comment_id} not found in PR #{pr_details.number} discussion",
139
+ )
140
+ # Type narrowing: target_comment is not None after the check above
141
+ assert target_comment is not None
142
+
143
+ # Format the reply
144
+ reply_body = _format_reply(
145
+ author=target_comment.author,
146
+ url=target_comment.url,
147
+ body=target_comment.body,
148
+ action_summary=reply,
149
+ )
150
+
151
+ # Post the reply as a new comment
152
+ try:
153
+ reply_comment_id = github_issues.add_comment(repo_root, pr_details.number, reply_body)
154
+ except RuntimeError as e:
155
+ exit_with_error("github-api-error", f"Failed to post reply: {e}")
156
+
157
+ # Add reaction to original comment
158
+ reaction_result = GitHubChecks.add_reaction(github_issues, repo_root, comment_id, "+1")
159
+ if isinstance(reaction_result, GitHubAPIFailed):
160
+ # Non-fatal: reply was posted, just log warning
161
+ click.echo(
162
+ f"Warning: Reply posted but failed to add reaction: {reaction_result.message}",
163
+ err=True,
164
+ )
165
+
166
+ result = {
167
+ "success": True,
168
+ "comment_id": comment_id,
169
+ "reply_id": reply_comment_id,
170
+ "pr_number": pr_details.number,
171
+ }
172
+ click.echo(json.dumps(result, indent=2))
173
+ raise SystemExit(0)
@@ -0,0 +1,170 @@
1
+ """Resolve a PR review thread via GraphQL mutation.
2
+
3
+ This exec command resolves a single PR review thread and outputs
4
+ JSON with the result. Optionally adds a reply comment before resolving.
5
+
6
+ Usage:
7
+ erk exec resolve-review-thread --thread-id "PRRT_xxxx"
8
+ erk exec resolve-review-thread --thread-id "PRRT_xxxx" --comment "Resolved via ..."
9
+
10
+ Output:
11
+ JSON with success status
12
+
13
+ Exit Codes:
14
+ 0: Always (even on error, to support || true pattern)
15
+ 1: Context not initialized
16
+
17
+ Examples:
18
+ $ erk exec resolve-review-thread --thread-id "PRRT_abc123"
19
+ {"success": true, "thread_id": "PRRT_abc123"}
20
+
21
+ $ erk exec resolve-review-thread --thread-id "PRRT_abc123" --comment "Fixed"
22
+ {"success": true, "thread_id": "PRRT_abc123", "comment_added": true}
23
+
24
+ $ erk exec resolve-review-thread --thread-id "invalid"
25
+ {"success": false, "error_type": "resolution_failed", "message": "..."}
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ from dataclasses import asdict, dataclass
32
+ from datetime import datetime
33
+ from pathlib import Path
34
+ from typing import TYPE_CHECKING, TypeVar
35
+
36
+ import click
37
+
38
+ from erk_shared.context.helpers import require_github, require_repo_root
39
+
40
+ if TYPE_CHECKING:
41
+ from erk_shared.github.abc import GitHub
42
+
43
+ T = TypeVar("T")
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class ResolveThreadSuccess:
48
+ """Success response for thread resolution."""
49
+
50
+ success: bool
51
+ thread_id: str
52
+ comment_added: bool = False
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class ResolveThreadError:
57
+ """Error response for thread resolution."""
58
+
59
+ success: bool
60
+ error_type: str
61
+ message: str
62
+
63
+
64
+ def _format_resolution_comment(comment: str) -> str:
65
+ """Format a resolution comment with timestamp and source attribution.
66
+
67
+ Args:
68
+ comment: The user-provided comment text
69
+
70
+ Returns:
71
+ Formatted comment with timestamp and /erk:pr-address attribution
72
+ """
73
+ timestamp = datetime.now().astimezone().strftime("%Y-%m-%d %H:%M %Z")
74
+ return f"{comment}\n\n_Addressed via `/erk:pr-address` at {timestamp}_"
75
+
76
+
77
+ def _ensure_not_error(result: T | ResolveThreadError) -> T:
78
+ """Ensure result is not an error, otherwise output JSON and exit.
79
+
80
+ Provides type narrowing: takes `T | ResolveThreadError` and returns `T`.
81
+
82
+ Args:
83
+ result: Value that may be a ResolveThreadError
84
+
85
+ Returns:
86
+ The value unchanged if not an error (with narrowed type T)
87
+
88
+ Raises:
89
+ SystemExit: If result is ResolveThreadError (with exit code 0)
90
+ """
91
+ if isinstance(result, ResolveThreadError):
92
+ click.echo(json.dumps(asdict(result), indent=2))
93
+ raise SystemExit(0)
94
+ return result
95
+
96
+
97
+ def _add_comment_if_provided(
98
+ github: GitHub,
99
+ repo_root: Path,
100
+ thread_id: str,
101
+ comment: str | None,
102
+ ) -> bool | ResolveThreadError:
103
+ """Add a comment to the thread if provided.
104
+
105
+ Returns:
106
+ True/False for comment_added status, or ResolveThreadError on failure
107
+ """
108
+ if comment is None:
109
+ return False
110
+
111
+ formatted_comment = _format_resolution_comment(comment)
112
+ try:
113
+ return github.add_review_thread_reply(repo_root, thread_id, formatted_comment)
114
+ except RuntimeError as e:
115
+ return ResolveThreadError(
116
+ success=False,
117
+ error_type="comment-failed",
118
+ message=f"Failed to add comment: {e}",
119
+ )
120
+
121
+
122
+ @click.command(name="resolve-review-thread")
123
+ @click.option("--thread-id", required=True, help="GraphQL node ID of the thread to resolve")
124
+ @click.option("--comment", default=None, help="Optional comment to add before resolving")
125
+ @click.pass_context
126
+ def resolve_review_thread(ctx: click.Context, thread_id: str, comment: str | None) -> None:
127
+ """Resolve a PR review thread.
128
+
129
+ Takes a GraphQL node ID (from get-pr-review-comments output) and
130
+ marks the thread as resolved. Optionally adds a reply comment first.
131
+
132
+ THREAD_ID: GraphQL node ID of the review thread
133
+ """
134
+ # Get dependencies from context
135
+ repo_root = require_repo_root(ctx)
136
+ github = require_github(ctx)
137
+
138
+ # Add comment first if provided
139
+ comment_added = _ensure_not_error(
140
+ _add_comment_if_provided(github, repo_root, thread_id, comment)
141
+ )
142
+
143
+ # Attempt to resolve the thread
144
+ try:
145
+ resolved = github.resolve_review_thread(repo_root, thread_id)
146
+ except RuntimeError as e:
147
+ result = ResolveThreadError(
148
+ success=False,
149
+ error_type="github-api-failed",
150
+ message=str(e),
151
+ )
152
+ click.echo(json.dumps(asdict(result), indent=2))
153
+ raise SystemExit(0) from None
154
+
155
+ if resolved:
156
+ result_success = ResolveThreadSuccess(
157
+ success=True,
158
+ thread_id=thread_id,
159
+ comment_added=comment_added,
160
+ )
161
+ click.echo(json.dumps(asdict(result_success), indent=2))
162
+ else:
163
+ result_error = ResolveThreadError(
164
+ success=False,
165
+ error_type="resolution-failed",
166
+ message=f"Failed to resolve thread {thread_id}",
167
+ )
168
+ click.echo(json.dumps(asdict(result_error), indent=2))
169
+
170
+ raise SystemExit(0)
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Session ID Injector Hook
4
+
5
+ This command is invoked via erk exec session-id-injector-hook.
6
+ """
7
+
8
+ import click
9
+
10
+ from erk.hooks.decorators import HookContext, hook_command
11
+ from erk_shared.gateway.erk_installation.real import RealErkInstallation
12
+
13
+
14
+ def _is_github_planning_enabled() -> bool:
15
+ """Check if github_planning is enabled in ~/.erk/config.toml.
16
+
17
+ Returns True (enabled) if config doesn't exist or flag is missing.
18
+ """
19
+ # Use RealErkInstallation directly since hooks run outside normal CLI context
20
+ installation = RealErkInstallation()
21
+ if not installation.config_exists():
22
+ return True # Default enabled
23
+
24
+ config = installation.load_config()
25
+ return config.github_planning
26
+
27
+
28
+ @hook_command(name="session-id-injector-hook")
29
+ def session_id_injector_hook(ctx: click.Context, *, hook_ctx: HookContext) -> None:
30
+ """Inject session ID into conversation context when relevant."""
31
+ # Scope check: only run in erk-managed projects
32
+ if not hook_ctx.is_erk_project:
33
+ return
34
+
35
+ # Early exit if github_planning is disabled - output nothing
36
+ if not _is_github_planning_enabled():
37
+ return
38
+
39
+ # Output session ID if available
40
+ if hook_ctx.session_id is not None:
41
+ # Write to file for CLI tools to read (worktree-scoped persistence)
42
+ session_file = hook_ctx.repo_root / ".erk" / "scratch" / "current-session-id"
43
+ session_file.parent.mkdir(parents=True, exist_ok=True)
44
+ session_file.write_text(hook_ctx.session_id, encoding="utf-8")
45
+
46
+ # Still output reminder for LLM context
47
+ click.echo(f"📌 session: {hook_ctx.session_id}")
48
+ # If no session ID available, output nothing (hook doesn't fire unnecessarily)
49
+
50
+
51
+ if __name__ == "__main__":
52
+ session_id_injector_hook()