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,338 @@
1
+ """Output filtering for Claude CLI stream-json format.
2
+
3
+ This module provides functions to parse and filter Claude CLI output in stream-json
4
+ format, extracting relevant text content and tool summaries while suppressing
5
+ verbose/noisy tool invocations.
6
+ """
7
+
8
+ import json
9
+ from pathlib import Path
10
+
11
+
12
+ def extract_text_content(message: dict) -> str | None:
13
+ """Extract Claude's text response from assistant message.
14
+
15
+ Args:
16
+ message: Assistant message dict from stream-json
17
+
18
+ Returns:
19
+ Extracted text content, or None if no text found
20
+
21
+ Example:
22
+ >>> msg = {"type": "assistant_message", "content": [{"type": "text", "text": "Hello"}]}
23
+ >>> extract_text_content(msg)
24
+ 'Hello'
25
+ """
26
+ content = message.get("content", [])
27
+ if not isinstance(content, list):
28
+ return None
29
+
30
+ text_parts: list[str] = []
31
+ for item in content:
32
+ if isinstance(item, dict) and item.get("type") == "text":
33
+ text = item.get("text")
34
+ if isinstance(text, str) and text.strip():
35
+ text_parts.append(text.strip())
36
+
37
+ if not text_parts:
38
+ return None
39
+
40
+ return "\n".join(text_parts)
41
+
42
+
43
+ def summarize_tool_use(tool_use: dict, worktree_path: Path) -> str | None:
44
+ """Create brief summary for important tools, None for suppressible tools.
45
+
46
+ Args:
47
+ tool_use: Tool use dict from stream-json content
48
+ worktree_path: Path to worktree for relativizing file paths
49
+
50
+ Returns:
51
+ Brief summary string for important tools, None for suppressible tools
52
+
53
+ Example:
54
+ >>> tool = {"name": "Edit", "input": {"file_path": "/repo/src/file.py"}}
55
+ >>> summarize_tool_use(tool, Path("/repo"))
56
+ 'Editing src/file.py...'
57
+ """
58
+ tool_name = tool_use.get("name")
59
+ if not isinstance(tool_name, str):
60
+ return None
61
+
62
+ params = tool_use.get("input", {})
63
+ if not isinstance(params, dict):
64
+ params = {}
65
+
66
+ # Suppress common/noisy tools
67
+ if tool_name in ["Read", "Glob", "Grep"]:
68
+ return None
69
+
70
+ # Bash commands
71
+ if tool_name == "Bash":
72
+ command = params.get("command", "")
73
+ if not isinstance(command, str):
74
+ return None
75
+
76
+ # Check for pytest
77
+ if "pytest" in command:
78
+ return "Running tests..."
79
+
80
+ # Check for CI commands
81
+ if "fast-ci" in command or "all-ci" in command:
82
+ return "Running CI checks..."
83
+
84
+ # Generic bash command
85
+ return f"Running: {command[:50]}..."
86
+
87
+ # Slash commands
88
+ if tool_name == "SlashCommand":
89
+ cmd = params.get("command", "")
90
+ if not isinstance(cmd, str):
91
+ return None
92
+
93
+ if "/gt:pr-submit" in cmd or "/erk:git-pr-push" in cmd:
94
+ return "Creating pull request..."
95
+
96
+ if "/fast-ci" in cmd or "/all-ci" in cmd:
97
+ return "Running CI checks..."
98
+
99
+ return f"Running {cmd}..."
100
+
101
+ # File operations
102
+ if tool_name == "Edit":
103
+ filepath = params.get("file_path", "")
104
+ if isinstance(filepath, str):
105
+ relative = make_relative_to_worktree(filepath, worktree_path)
106
+ return f"Editing {relative}..."
107
+
108
+ if tool_name == "Write":
109
+ filepath = params.get("file_path", "")
110
+ if isinstance(filepath, str):
111
+ relative = make_relative_to_worktree(filepath, worktree_path)
112
+ return f"Writing {relative}..."
113
+
114
+ # Default: show tool name for unknown tools
115
+ return f"Using {tool_name}..."
116
+
117
+
118
+ def make_relative_to_worktree(filepath: str, worktree_path: Path) -> str:
119
+ """Convert absolute path to worktree-relative path.
120
+
121
+ Args:
122
+ filepath: Absolute or relative file path
123
+ worktree_path: Path to worktree root
124
+
125
+ Returns:
126
+ Path relative to worktree if possible, otherwise original filepath
127
+
128
+ Example:
129
+ >>> make_relative_to_worktree("/repo/src/file.py", Path("/repo"))
130
+ 'src/file.py'
131
+ """
132
+ path = Path(filepath)
133
+
134
+ # Check if path is absolute and relative to worktree
135
+ if path.is_absolute():
136
+ if path.exists() and path.is_relative_to(worktree_path):
137
+ return str(path.relative_to(worktree_path))
138
+
139
+ return filepath
140
+
141
+
142
+ def extract_pr_url(tool_result_content: str) -> str | None:
143
+ """Extract PR URL from exec command JSON output.
144
+
145
+ Args:
146
+ tool_result_content: Content string from tool_result
147
+
148
+ Returns:
149
+ PR URL if found in JSON, None otherwise
150
+
151
+ Example:
152
+ >>> content = '{"success": true, "pr_url": "https://github.com/user/repo/pull/123"}'
153
+ >>> extract_pr_url(content)
154
+ 'https://github.com/user/repo/pull/123'
155
+ """
156
+ if not isinstance(tool_result_content, str):
157
+ return None
158
+
159
+ # Parse JSON safely - JSON parsing requires exception handling
160
+ data: dict | None = None
161
+ if tool_result_content.strip():
162
+ try:
163
+ parsed = json.loads(tool_result_content)
164
+ if isinstance(parsed, dict):
165
+ data = parsed
166
+ except json.JSONDecodeError:
167
+ return None
168
+
169
+ if data is None:
170
+ return None
171
+
172
+ pr_url = data.get("pr_url")
173
+ if isinstance(pr_url, str):
174
+ return pr_url
175
+
176
+ return None
177
+
178
+
179
+ def extract_pr_metadata(tool_result_content: str) -> dict[str, str | int | None]:
180
+ """Extract PR metadata from exec command JSON output.
181
+
182
+ Args:
183
+ tool_result_content: Content string from tool_result
184
+
185
+ Returns:
186
+ Dict with pr_url, pr_number, pr_title, and issue_number (all may be None)
187
+
188
+ Example:
189
+ >>> content = '{"success": true, "pr_url": "https://...", "pr_number": 123, '
190
+ >>> content += '"pr_title": "Fix bug", "issue_number": 456}'
191
+ >>> extract_pr_metadata(content)
192
+ {'pr_url': 'https://...', 'pr_number': 123, 'pr_title': 'Fix bug', 'issue_number': 456}
193
+ """
194
+ if not isinstance(tool_result_content, str):
195
+ return {"pr_url": None, "pr_number": None, "pr_title": None, "issue_number": None}
196
+
197
+ # Parse JSON safely - JSON parsing requires exception handling
198
+ data: dict | None = None
199
+ if tool_result_content.strip():
200
+ try:
201
+ parsed = json.loads(tool_result_content)
202
+ if isinstance(parsed, dict):
203
+ data = parsed
204
+ except json.JSONDecodeError:
205
+ return {"pr_url": None, "pr_number": None, "pr_title": None, "issue_number": None}
206
+
207
+ if data is None:
208
+ return {"pr_url": None, "pr_number": None, "pr_title": None, "issue_number": None}
209
+
210
+ pr_url = data.get("pr_url")
211
+ pr_number = data.get("pr_number")
212
+ pr_title = data.get("pr_title")
213
+ issue_number = data.get("issue_number")
214
+
215
+ return {
216
+ "pr_url": pr_url if isinstance(pr_url, str) else None,
217
+ "pr_number": pr_number if isinstance(pr_number, int) else None,
218
+ "pr_title": pr_title if isinstance(pr_title, str) else None,
219
+ "issue_number": issue_number if isinstance(issue_number, int) else None,
220
+ }
221
+
222
+
223
+ def extract_pr_metadata_from_text(text: str) -> dict[str, str | int | None]:
224
+ """Extract PR metadata from agent text output using pattern matching.
225
+
226
+ This is simpler and more robust than parsing nested JSON from tool results.
227
+ The agent's text output contains PR info in human-readable format like:
228
+ - "PR #1311" or "**PR #1311**"
229
+ - "https://github.com/.../pull/1311" or "https://app.graphite.com/.../1311"
230
+ - "issue #1308" or "Linked to issue #1308"
231
+
232
+ Args:
233
+ text: Agent text output containing PR information
234
+
235
+ Returns:
236
+ Dict with pr_url, pr_number, and issue_number (pr_title always None)
237
+
238
+ Example:
239
+ >>> text = "**PR #123** created\\n- **Link**: https://github.com/o/r/pull/123"
240
+ >>> extract_pr_metadata_from_text(text)
241
+ {'pr_url': 'https://github.com/o/r/pull/123', 'pr_number': 123, ...}
242
+ """
243
+ import re
244
+
245
+ result: dict[str, str | int | None] = {
246
+ "pr_url": None,
247
+ "pr_number": None,
248
+ "pr_title": None,
249
+ "issue_number": None,
250
+ }
251
+
252
+ if not isinstance(text, str):
253
+ return result
254
+
255
+ # Extract PR number and title from various patterns:
256
+ # - "PR #123: Title" or "PR #123 - Title"
257
+ # - "#123 - Title" or '#123 - "Title"'
258
+ # - "**PR Updated**: #123 - Title"
259
+ pr_with_title_match = re.search(
260
+ r"#(\d+)\s*[-:]\s*[\"']?(.+?)[\"']?(?:\n|$)", text, re.IGNORECASE
261
+ )
262
+ if pr_with_title_match:
263
+ result["pr_number"] = int(pr_with_title_match.group(1))
264
+ result["pr_title"] = pr_with_title_match.group(2).strip().strip("\"'")
265
+ else:
266
+ # Fallback: just extract PR number without title
267
+ pr_num_match = re.search(r"#(\d+)", text)
268
+ if pr_num_match:
269
+ result["pr_number"] = int(pr_num_match.group(1))
270
+
271
+ # Extract GitHub PR URL
272
+ github_url_match = re.search(r"https://github\.com/[^/]+/[^/]+/pull/(\d+)", text)
273
+ if github_url_match:
274
+ result["pr_url"] = github_url_match.group(0)
275
+ # Also extract pr_number from URL if not found earlier
276
+ if result["pr_number"] is None:
277
+ result["pr_number"] = int(github_url_match.group(1))
278
+
279
+ # Extract Graphite URL as fallback
280
+ if result["pr_url"] is None:
281
+ graphite_url_match = re.search(
282
+ r"https://app\.graphite\.com/github/pr/[^/]+/[^/]+/(\d+)", text
283
+ )
284
+ if graphite_url_match:
285
+ result["pr_url"] = graphite_url_match.group(0)
286
+ if result["pr_number"] is None:
287
+ result["pr_number"] = int(graphite_url_match.group(1))
288
+
289
+ # Extract issue number from patterns like "issue #123" or "Linked to issue #123"
290
+ # or "#1308 (will auto-close"
291
+ issue_match = re.search(r"issue\s*#(\d+)", text, re.IGNORECASE)
292
+ if issue_match:
293
+ result["issue_number"] = int(issue_match.group(1))
294
+ else:
295
+ # Try "Closes #123" pattern
296
+ closes_match = re.search(r"Closes\s*#(\d+)", text, re.IGNORECASE)
297
+ if closes_match:
298
+ result["issue_number"] = int(closes_match.group(1))
299
+
300
+ return result
301
+
302
+
303
+ def determine_spinner_status(tool_use: dict | None, command: str, worktree_path: Path) -> str:
304
+ """Map current activity to spinner status message.
305
+
306
+ Args:
307
+ tool_use: Current tool use dict, or None if no tool running
308
+ command: The slash command being executed
309
+ worktree_path: Path to worktree for relativizing paths
310
+
311
+ Returns:
312
+ Status message for spinner
313
+
314
+ Example:
315
+ >>> determine_spinner_status(None, "/erk:plan-implement", Path("/repo"))
316
+ 'Running /erk:plan-implement...'
317
+ """
318
+ if tool_use is None:
319
+ return f"Running {command}..."
320
+
321
+ # First try to get a detailed summary
322
+ summary = summarize_tool_use(tool_use, worktree_path)
323
+ if summary:
324
+ return summary
325
+
326
+ # For suppressed tools (Read, Glob, Grep), provide a generic but distinct message
327
+ tool_name = tool_use.get("name")
328
+ if isinstance(tool_name, str):
329
+ if tool_name == "Read":
330
+ return "Reading files..."
331
+ if tool_name == "Glob":
332
+ return "Searching for files..."
333
+ if tool_name == "Grep":
334
+ return "Searching code..."
335
+ # Fallback for unknown tools
336
+ return f"Using {tool_name}..."
337
+
338
+ return f"Running {command}..."
@@ -0,0 +1,6 @@
1
+ """Provider-agnostic abstraction for plan storage.
2
+
3
+ This package previously re-exported from erk_shared.plan_store.
4
+ All imports have been migrated to erk_shared.plan_store directly.
5
+ This module remains as an empty namespace marker.
6
+ """
@@ -0,0 +1 @@
1
+ """Planner box management for remote planning with Claude Code."""
@@ -0,0 +1,8 @@
1
+ """Planner registry ABC re-export.
2
+
3
+ ABC is defined in erk_shared.core. This module re-exports for backward compatibility.
4
+ """
5
+
6
+ # Re-export from erk_shared.core
7
+ from erk_shared.core.planner_registry import PlannerRegistry as PlannerRegistry
8
+ from erk_shared.core.planner_registry import RegisteredPlanner as RegisteredPlanner
@@ -0,0 +1,129 @@
1
+ """Fake implementation of PlannerRegistry for testing.
2
+
3
+ Stores planner data in memory without touching filesystem.
4
+ """
5
+
6
+ from dataclasses import replace
7
+ from datetime import datetime
8
+
9
+ from erk.core.planner.registry_abc import PlannerRegistry
10
+ from erk.core.planner.types import RegisteredPlanner
11
+
12
+
13
+ class FakePlannerRegistry(PlannerRegistry):
14
+ """In-memory implementation for testing.
15
+
16
+ Provides mutation tracking via read-only properties for assertions.
17
+ """
18
+
19
+ def __init__(
20
+ self,
21
+ planners: list[RegisteredPlanner] | None = None,
22
+ default_planner: str | None = None,
23
+ ) -> None:
24
+ """Initialize the fake registry.
25
+
26
+ Args:
27
+ planners: Initial list of planners
28
+ default_planner: Name of the default planner (must exist in planners)
29
+ """
30
+ self._planners: dict[str, RegisteredPlanner] = {}
31
+ self._default_planner: str | None = default_planner
32
+
33
+ # Track mutations for assertions
34
+ self._registered: list[RegisteredPlanner] = []
35
+ self._unregistered: list[str] = []
36
+ self._marked_configured: list[str] = []
37
+ self._updated_last_connected: list[tuple[str, datetime]] = []
38
+ self._set_defaults: list[str] = []
39
+
40
+ if planners:
41
+ for planner in planners:
42
+ self._planners[planner.name] = planner
43
+
44
+ def list_planners(self) -> list[RegisteredPlanner]:
45
+ """List all registered planners."""
46
+ return list(self._planners.values())
47
+
48
+ def get(self, name: str) -> RegisteredPlanner | None:
49
+ """Get a planner by name."""
50
+ return self._planners.get(name)
51
+
52
+ def get_default(self) -> RegisteredPlanner | None:
53
+ """Get the default planner."""
54
+ if self._default_planner is None:
55
+ return None
56
+ return self._planners.get(self._default_planner)
57
+
58
+ def get_default_name(self) -> str | None:
59
+ """Get the name of the default planner."""
60
+ return self._default_planner
61
+
62
+ def set_default(self, name: str) -> None:
63
+ """Set the default planner."""
64
+ if name not in self._planners:
65
+ raise ValueError(f"No planner named '{name}' exists")
66
+ self._default_planner = name
67
+ self._set_defaults.append(name)
68
+
69
+ def register(self, planner: RegisteredPlanner) -> None:
70
+ """Register a new planner."""
71
+ if planner.name in self._planners:
72
+ raise ValueError(f"Planner '{planner.name}' already exists")
73
+ self._planners[planner.name] = planner
74
+ self._registered.append(planner)
75
+
76
+ def unregister(self, name: str) -> None:
77
+ """Remove a planner from the registry."""
78
+ if name not in self._planners:
79
+ raise ValueError(f"No planner named '{name}' exists")
80
+ del self._planners[name]
81
+
82
+ # Clear default if we're removing the default planner
83
+ if self._default_planner == name:
84
+ self._default_planner = None
85
+
86
+ self._unregistered.append(name)
87
+
88
+ def mark_configured(self, name: str) -> None:
89
+ """Mark a planner as configured."""
90
+ if name not in self._planners:
91
+ raise ValueError(f"No planner named '{name}' exists")
92
+ old_planner = self._planners[name]
93
+ self._planners[name] = replace(old_planner, configured=True)
94
+ self._marked_configured.append(name)
95
+
96
+ def update_last_connected(self, name: str, timestamp: datetime) -> None:
97
+ """Update the last connected timestamp for a planner."""
98
+ if name not in self._planners:
99
+ raise ValueError(f"No planner named '{name}' exists")
100
+ old_planner = self._planners[name]
101
+ self._planners[name] = replace(old_planner, last_connected_at=timestamp)
102
+ self._updated_last_connected.append((name, timestamp))
103
+
104
+ # Read-only mutation tracking properties for test assertions
105
+
106
+ @property
107
+ def registered_planners(self) -> list[RegisteredPlanner]:
108
+ """Planners registered during test (for assertions)."""
109
+ return list(self._registered)
110
+
111
+ @property
112
+ def unregistered_names(self) -> list[str]:
113
+ """Names of planners unregistered during test (for assertions)."""
114
+ return list(self._unregistered)
115
+
116
+ @property
117
+ def marked_configured_names(self) -> list[str]:
118
+ """Names of planners marked as configured during test (for assertions)."""
119
+ return list(self._marked_configured)
120
+
121
+ @property
122
+ def updated_connections(self) -> list[tuple[str, datetime]]:
123
+ """(name, timestamp) pairs for last_connected updates (for assertions)."""
124
+ return list(self._updated_last_connected)
125
+
126
+ @property
127
+ def set_default_history(self) -> list[str]:
128
+ """History of set_default calls (for assertions)."""
129
+ return list(self._set_defaults)
@@ -0,0 +1,195 @@
1
+ """Real implementation of PlannerRegistry using TOML file storage.
2
+
3
+ Stores planner configuration in ~/.erk/planners.toml.
4
+ """
5
+
6
+ import tomllib
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+
10
+ import tomlkit
11
+
12
+ from erk.core.planner.registry_abc import PlannerRegistry
13
+ from erk.core.planner.types import RegisteredPlanner
14
+
15
+ SCHEMA_VERSION = 1
16
+
17
+
18
+ class RealPlannerRegistry(PlannerRegistry):
19
+ """Production implementation that reads/writes ~/.erk/planners.toml."""
20
+
21
+ def __init__(self, config_path: Path) -> None:
22
+ """Initialize the registry.
23
+
24
+ Args:
25
+ config_path: Path to the planners.toml config file.
26
+ Typically obtained from erk_installation.get_planners_config_path().
27
+ """
28
+ self._config_path = config_path
29
+
30
+ def _load_data(self) -> dict:
31
+ """Load data from TOML file.
32
+
33
+ Returns:
34
+ Parsed TOML data, or empty structure if file doesn't exist
35
+ """
36
+ if not self._config_path.exists():
37
+ return {"schema_version": SCHEMA_VERSION, "planners": {}}
38
+
39
+ content = self._config_path.read_text(encoding="utf-8")
40
+ return tomllib.loads(content)
41
+
42
+ def _save_data(self, data: dict) -> None:
43
+ """Save data to TOML file.
44
+
45
+ Args:
46
+ data: Data structure to save
47
+ """
48
+ # Ensure parent directory exists
49
+ self._config_path.parent.mkdir(parents=True, exist_ok=True)
50
+
51
+ # Use tomlkit to preserve formatting
52
+ doc = tomlkit.document()
53
+ doc["schema_version"] = data.get("schema_version", SCHEMA_VERSION)
54
+
55
+ if "default_planner" in data and data["default_planner"] is not None:
56
+ doc["default_planner"] = data["default_planner"]
57
+
58
+ # Add planners table
59
+ planners_table = tomlkit.table()
60
+ for name, planner_data in data.get("planners", {}).items():
61
+ planner_table = tomlkit.table()
62
+ planner_table["gh_name"] = planner_data["gh_name"]
63
+ planner_table["repository"] = planner_data["repository"]
64
+ planner_table["configured"] = planner_data["configured"]
65
+ planner_table["registered_at"] = planner_data["registered_at"]
66
+ if planner_data.get("last_connected_at") is not None:
67
+ planner_table["last_connected_at"] = planner_data["last_connected_at"]
68
+ planners_table[name] = planner_table
69
+
70
+ doc["planners"] = planners_table
71
+
72
+ self._config_path.write_text(tomlkit.dumps(doc), encoding="utf-8")
73
+
74
+ def _planner_from_dict(self, name: str, data: dict) -> RegisteredPlanner:
75
+ """Convert a dict to a RegisteredPlanner.
76
+
77
+ Args:
78
+ name: Planner name
79
+ data: Dict with planner data
80
+
81
+ Returns:
82
+ RegisteredPlanner instance
83
+ """
84
+ return RegisteredPlanner(
85
+ name=name,
86
+ gh_name=data["gh_name"],
87
+ repository=data["repository"],
88
+ configured=data["configured"],
89
+ registered_at=datetime.fromisoformat(data["registered_at"]),
90
+ last_connected_at=(
91
+ datetime.fromisoformat(data["last_connected_at"])
92
+ if data.get("last_connected_at")
93
+ else None
94
+ ),
95
+ )
96
+
97
+ def _planner_to_dict(self, planner: RegisteredPlanner) -> dict:
98
+ """Convert a RegisteredPlanner to a dict.
99
+
100
+ Args:
101
+ planner: RegisteredPlanner instance
102
+
103
+ Returns:
104
+ Dict representation
105
+ """
106
+ result = {
107
+ "gh_name": planner.gh_name,
108
+ "repository": planner.repository,
109
+ "configured": planner.configured,
110
+ "registered_at": planner.registered_at.isoformat(),
111
+ }
112
+ if planner.last_connected_at is not None:
113
+ result["last_connected_at"] = planner.last_connected_at.isoformat()
114
+ return result
115
+
116
+ def list_planners(self) -> list[RegisteredPlanner]:
117
+ """List all registered planners."""
118
+ data = self._load_data()
119
+ planners = data.get("planners", {})
120
+ return [self._planner_from_dict(name, pdata) for name, pdata in planners.items()]
121
+
122
+ def get(self, name: str) -> RegisteredPlanner | None:
123
+ """Get a planner by name."""
124
+ data = self._load_data()
125
+ planners = data.get("planners", {})
126
+ if name not in planners:
127
+ return None
128
+ return self._planner_from_dict(name, planners[name])
129
+
130
+ def get_default(self) -> RegisteredPlanner | None:
131
+ """Get the default planner."""
132
+ data = self._load_data()
133
+ default_name = data.get("default_planner")
134
+ if default_name is None:
135
+ return None
136
+ return self.get(default_name)
137
+
138
+ def get_default_name(self) -> str | None:
139
+ """Get the name of the default planner."""
140
+ data = self._load_data()
141
+ return data.get("default_planner")
142
+
143
+ def set_default(self, name: str) -> None:
144
+ """Set the default planner."""
145
+ data = self._load_data()
146
+ planners = data.get("planners", {})
147
+ if name not in planners:
148
+ raise ValueError(f"No planner named '{name}' exists")
149
+ data["default_planner"] = name
150
+ self._save_data(data)
151
+
152
+ def register(self, planner: RegisteredPlanner) -> None:
153
+ """Register a new planner."""
154
+ data = self._load_data()
155
+ planners = data.get("planners", {})
156
+ if planner.name in planners:
157
+ raise ValueError(f"Planner '{planner.name}' already exists")
158
+ planners[planner.name] = self._planner_to_dict(planner)
159
+ data["planners"] = planners
160
+ self._save_data(data)
161
+
162
+ def unregister(self, name: str) -> None:
163
+ """Remove a planner from the registry."""
164
+ data = self._load_data()
165
+ planners = data.get("planners", {})
166
+ if name not in planners:
167
+ raise ValueError(f"No planner named '{name}' exists")
168
+ del planners[name]
169
+ data["planners"] = planners
170
+
171
+ # Clear default if we're removing the default planner
172
+ if data.get("default_planner") == name:
173
+ data["default_planner"] = None
174
+
175
+ self._save_data(data)
176
+
177
+ def mark_configured(self, name: str) -> None:
178
+ """Mark a planner as configured."""
179
+ data = self._load_data()
180
+ planners = data.get("planners", {})
181
+ if name not in planners:
182
+ raise ValueError(f"No planner named '{name}' exists")
183
+ planners[name]["configured"] = True
184
+ data["planners"] = planners
185
+ self._save_data(data)
186
+
187
+ def update_last_connected(self, name: str, timestamp: datetime) -> None:
188
+ """Update the last connected timestamp for a planner."""
189
+ data = self._load_data()
190
+ planners = data.get("planners", {})
191
+ if name not in planners:
192
+ raise ValueError(f"No planner named '{name}' exists")
193
+ planners[name]["last_connected_at"] = timestamp.isoformat()
194
+ data["planners"] = planners
195
+ self._save_data(data)
@@ -0,0 +1,7 @@
1
+ """Planner types re-export.
2
+
3
+ Types are defined in erk_shared.core. This module re-exports for backward compatibility.
4
+ """
5
+
6
+ # Re-export from erk_shared.core
7
+ from erk_shared.core.planner_registry import RegisteredPlanner as RegisteredPlanner