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,406 @@
1
+ """Command logging for erk CLI audit trail.
2
+
3
+ This module provides logging of all erk CLI invocations with context,
4
+ similar to zsh's ~/.zsh_history. Used for debugging issues like
5
+ "what command deleted my worktree".
6
+
7
+ Log location: ~/.erk/command_history.jsonl
8
+ Format: One JSON object per line (JSONL) for easy parsing and appending.
9
+ """
10
+
11
+ import atexit
12
+ import fcntl
13
+ import json
14
+ import os
15
+ import sys
16
+ from dataclasses import dataclass
17
+ from datetime import UTC, datetime, timedelta
18
+ from pathlib import Path
19
+
20
+ from erk_shared.gateway.erk_installation.abc import ErkInstallation
21
+ from erk_shared.gateway.erk_installation.real import RealErkInstallation
22
+ from erk_shared.git.real import RealGit
23
+
24
+ # Environment variable to disable command logging
25
+ ENV_DISABLE_LOG = "ERK_NO_COMMAND_LOG"
26
+
27
+ # Maximum log file size in bytes (50MB)
28
+ MAX_LOG_SIZE_BYTES = 50 * 1024 * 1024
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class CommandLogEntry:
33
+ """A single command log entry."""
34
+
35
+ timestamp: str
36
+ command: str
37
+ args: tuple[str, ...]
38
+ cwd: str
39
+ branch: str | None
40
+ exit_code: int | None
41
+ session_id: str | None
42
+ pid: int
43
+
44
+
45
+ def _get_log_file_path(installation: ErkInstallation | None = None) -> Path:
46
+ """Return path to command history log file.
47
+
48
+ Args:
49
+ installation: ErkInstallation instance. If None, uses RealErkInstallation.
50
+ Passing None is for CLI entry points before context exists.
51
+ """
52
+ if installation is None:
53
+ installation = RealErkInstallation()
54
+ return installation.get_command_log_path()
55
+
56
+
57
+ def _is_logging_disabled() -> bool:
58
+ """Check if command logging is disabled via environment variable."""
59
+ return os.environ.get(ENV_DISABLE_LOG) == "1"
60
+
61
+
62
+ def _get_current_branch(cwd: Path) -> str | None:
63
+ """Get current git branch if in a git repository."""
64
+ git = RealGit()
65
+ # get_git_common_dir returns None gracefully when outside a git repo,
66
+ # whereas get_repository_root raises RuntimeError
67
+ git_dir = git.get_git_common_dir(cwd)
68
+ if git_dir is None:
69
+ return None
70
+ repo_root = git.get_repository_root(cwd)
71
+ return git.get_current_branch(repo_root)
72
+
73
+
74
+ def _get_session_id() -> str | None:
75
+ """Get Claude Code session ID if available."""
76
+ return os.environ.get("CLAUDE_CODE_SESSION_ID")
77
+
78
+
79
+ def _rotate_log_if_needed(log_path: Path) -> None:
80
+ """Rotate log file if it exceeds maximum size."""
81
+ if not log_path.exists():
82
+ return
83
+ if log_path.stat().st_size <= MAX_LOG_SIZE_BYTES:
84
+ return
85
+
86
+ # Rotate: rename current to .old (overwriting previous .old)
87
+ old_path = log_path.with_suffix(".jsonl.old")
88
+ if old_path.exists():
89
+ old_path.unlink()
90
+ log_path.rename(old_path)
91
+
92
+
93
+ def _write_entry(log_path: Path, entry_dict: dict[str, str | int | list[str] | None]) -> None:
94
+ """Write a log entry with file locking for concurrent writes."""
95
+ # Ensure parent directory exists
96
+ log_path.parent.mkdir(parents=True, exist_ok=True)
97
+
98
+ # Rotate if needed (before acquiring lock to avoid holding lock during rename)
99
+ _rotate_log_if_needed(log_path)
100
+
101
+ # Open with append mode and exclusive lock
102
+ with log_path.open("a", encoding="utf-8") as f:
103
+ # Acquire exclusive lock for concurrent write safety
104
+ fcntl.flock(f.fileno(), fcntl.LOCK_EX)
105
+ try:
106
+ f.write(json.dumps(entry_dict) + "\n")
107
+ finally:
108
+ fcntl.flock(f.fileno(), fcntl.LOCK_UN)
109
+
110
+
111
+ def log_command_start(args: list[str], cwd: Path) -> str | None:
112
+ """Log command invocation at start.
113
+
114
+ Args:
115
+ args: Command line arguments (sys.argv[1:])
116
+ cwd: Current working directory
117
+
118
+ Returns:
119
+ Entry ID (timestamp) for use with log_command_end, or None if logging disabled
120
+ """
121
+ if _is_logging_disabled():
122
+ return None
123
+
124
+ timestamp = datetime.now(UTC).isoformat()
125
+
126
+ # Build command string from args
127
+ # Extract up to 2 non-flag args as subcommand (e.g., "wt delete")
128
+ # Remaining args are treated as command arguments
129
+ command = "erk"
130
+ remaining_args = list(args)
131
+ subcommand_parts: list[str] = []
132
+
133
+ # Take at most 2 subcommand parts (e.g., "wt delete" but not "wt delete foo")
134
+ max_subcommand_depth = 2
135
+ for arg in args:
136
+ if arg.startswith("-") or len(subcommand_parts) >= max_subcommand_depth:
137
+ break
138
+ subcommand_parts.append(arg)
139
+ remaining_args = remaining_args[1:]
140
+
141
+ if subcommand_parts:
142
+ command = f"erk {' '.join(subcommand_parts)}"
143
+
144
+ entry = {
145
+ "timestamp": timestamp,
146
+ "command": command,
147
+ "args": remaining_args,
148
+ "cwd": str(cwd),
149
+ "branch": _get_current_branch(cwd),
150
+ "exit_code": None, # Will be filled by log_command_end
151
+ "session_id": _get_session_id(),
152
+ "pid": os.getpid(),
153
+ }
154
+
155
+ log_path = _get_log_file_path()
156
+ _write_entry(log_path, entry)
157
+
158
+ return timestamp
159
+
160
+
161
+ def log_command_end(entry_id: str | None, exit_code: int) -> None:
162
+ """Log command completion with exit code.
163
+
164
+ Args:
165
+ entry_id: Entry ID from log_command_start (timestamp)
166
+ exit_code: Process exit code (0 = success)
167
+ """
168
+ if entry_id is None or _is_logging_disabled():
169
+ return
170
+
171
+ # Write a completion entry that references the start entry
172
+ completion_entry = {
173
+ "timestamp": datetime.now(UTC).isoformat(),
174
+ "type": "completion",
175
+ "start_timestamp": entry_id,
176
+ "exit_code": exit_code,
177
+ "pid": os.getpid(),
178
+ }
179
+
180
+ log_path = _get_log_file_path()
181
+ _write_entry(log_path, completion_entry)
182
+
183
+
184
+ def read_log_entries(
185
+ since: datetime | None,
186
+ until: datetime | None,
187
+ command_filter: str | None,
188
+ cwd_filter: str | None,
189
+ limit: int | None,
190
+ ) -> list[CommandLogEntry]:
191
+ """Read log entries with optional filters.
192
+
193
+ Args:
194
+ since: Only entries after this time
195
+ until: Only entries before this time
196
+ command_filter: Only entries matching this command substring
197
+ cwd_filter: Only entries from this directory (exact match)
198
+ limit: Maximum number of entries to return
199
+
200
+ Returns:
201
+ List of matching entries, most recent first
202
+ """
203
+ log_path = _get_log_file_path()
204
+ if not log_path.exists():
205
+ return []
206
+
207
+ entries: list[CommandLogEntry] = []
208
+ completion_map: dict[str, int] = {} # start_timestamp -> exit_code
209
+
210
+ # First pass: collect completion entries
211
+ with log_path.open("r", encoding="utf-8") as f:
212
+ for line in f:
213
+ line = line.strip()
214
+ if not line:
215
+ continue
216
+ try:
217
+ data = json.loads(line)
218
+ except json.JSONDecodeError:
219
+ continue
220
+
221
+ if data.get("type") == "completion":
222
+ start_ts = data.get("start_timestamp")
223
+ exit_code = data.get("exit_code")
224
+ if start_ts is not None and exit_code is not None:
225
+ completion_map[start_ts] = exit_code
226
+
227
+ # Second pass: collect command entries
228
+ with log_path.open("r", encoding="utf-8") as f:
229
+ for line in f:
230
+ line = line.strip()
231
+ if not line:
232
+ continue
233
+ try:
234
+ data = json.loads(line)
235
+ except json.JSONDecodeError:
236
+ continue
237
+
238
+ # Skip completion entries
239
+ if data.get("type") == "completion":
240
+ continue
241
+
242
+ # Parse timestamp
243
+ timestamp_str = data.get("timestamp")
244
+ if timestamp_str is None:
245
+ continue
246
+ try:
247
+ timestamp = datetime.fromisoformat(timestamp_str)
248
+ except ValueError:
249
+ continue
250
+
251
+ # Apply time filters
252
+ if since is not None and timestamp < since:
253
+ continue
254
+ if until is not None and timestamp > until:
255
+ continue
256
+
257
+ # Apply command filter
258
+ command = data.get("command", "")
259
+ if command_filter is not None and command_filter.lower() not in command.lower():
260
+ continue
261
+
262
+ # Apply cwd filter
263
+ entry_cwd = data.get("cwd", "")
264
+ if cwd_filter is not None and entry_cwd != cwd_filter:
265
+ continue
266
+
267
+ # Look up exit code from completion entries
268
+ exit_code = completion_map.get(timestamp_str)
269
+
270
+ entry = CommandLogEntry(
271
+ timestamp=timestamp_str,
272
+ command=command,
273
+ args=tuple(data.get("args", [])),
274
+ cwd=entry_cwd,
275
+ branch=data.get("branch"),
276
+ exit_code=exit_code,
277
+ session_id=data.get("session_id"),
278
+ pid=data.get("pid", 0),
279
+ )
280
+ entries.append(entry)
281
+
282
+ # Sort by timestamp descending (most recent first)
283
+ entries.sort(key=lambda e: e.timestamp, reverse=True)
284
+
285
+ # Apply limit
286
+ if limit is not None:
287
+ entries = entries[:limit]
288
+
289
+ return entries
290
+
291
+
292
+ def get_cli_args() -> list[str]:
293
+ """Get CLI arguments for logging, skipping the program name."""
294
+ return sys.argv[1:]
295
+
296
+
297
+ def register_exit_handler(entry_id: str | None) -> None:
298
+ """Register an atexit handler to log command completion.
299
+
300
+ This ensures logging even on exceptions or SystemExit.
301
+
302
+ Args:
303
+ entry_id: Entry ID from log_command_start (timestamp)
304
+ """
305
+
306
+ def _log_exit() -> None:
307
+ exc_info = sys.exc_info()
308
+ exc = exc_info[1]
309
+ if isinstance(exc, SystemExit):
310
+ exit_code = exc.code if isinstance(exc.code, int) else 1
311
+ elif exc is not None:
312
+ exit_code = 1
313
+ else:
314
+ exit_code = 0
315
+ log_command_end(entry_id, exit_code)
316
+
317
+ atexit.register(_log_exit)
318
+
319
+
320
+ def is_numeric_string(s: str) -> bool:
321
+ """Check if string represents an integer (possibly negative)."""
322
+ if not s:
323
+ return False
324
+ if s[0] in "+-":
325
+ return s[1:].isdigit() if len(s) > 1 else False
326
+ return s.isdigit()
327
+
328
+
329
+ def is_iso_datetime_format(s: str) -> bool:
330
+ """Check if string looks like an ISO datetime format.
331
+
332
+ Validates basic structure: YYYY-MM-DDTHH:MM:SS with optional timezone.
333
+ """
334
+ # Basic length check (minimum: 2024-01-01 = 10 chars)
335
+ if len(s) < 10:
336
+ return False
337
+ # Check date part structure
338
+ if len(s) >= 10 and not (s[4] == "-" and s[7] == "-"):
339
+ return False
340
+ # Check year/month/day are digits
341
+ if not (s[:4].isdigit() and s[5:7].isdigit() and s[8:10].isdigit()):
342
+ return False
343
+ return True
344
+
345
+
346
+ def parse_relative_time(value: str) -> timedelta | None:
347
+ """Parse relative time string like '1 hour ago' into a timedelta.
348
+
349
+ Args:
350
+ value: String like "1 hour ago", "2 days ago", "30 minutes ago"
351
+
352
+ Returns:
353
+ timedelta if valid, None if invalid format
354
+ """
355
+ value = value.strip().lower()
356
+ if not value.endswith(" ago"):
357
+ return None
358
+
359
+ parts = value[:-4].strip().split()
360
+ if len(parts) != 2:
361
+ return None
362
+
363
+ amount_str = parts[0]
364
+ if not is_numeric_string(amount_str):
365
+ return None
366
+
367
+ amount = int(amount_str)
368
+ unit = parts[1].rstrip("s") # "hours" -> "hour"
369
+
370
+ if unit == "minute":
371
+ return timedelta(minutes=amount)
372
+ elif unit == "hour":
373
+ return timedelta(hours=amount)
374
+ elif unit == "day":
375
+ return timedelta(days=amount)
376
+ elif unit == "week":
377
+ return timedelta(weeks=amount)
378
+ return None
379
+
380
+
381
+ def format_relative_time(timestamp_str: str) -> str:
382
+ """Format an ISO timestamp as relative time (e.g., '5m ago').
383
+
384
+ Args:
385
+ timestamp_str: ISO format timestamp
386
+
387
+ Returns:
388
+ Relative time string, or truncated timestamp if parsing fails
389
+ """
390
+ if not is_iso_datetime_format(timestamp_str):
391
+ return timestamp_str[:19]
392
+
393
+ dt = datetime.fromisoformat(timestamp_str)
394
+ now = datetime.now(UTC)
395
+ delta = now - dt
396
+
397
+ if delta < timedelta(minutes=1):
398
+ return "just now"
399
+ elif delta < timedelta(hours=1):
400
+ mins = int(delta.total_seconds() / 60)
401
+ return f"{mins}m ago"
402
+ elif delta < timedelta(days=1):
403
+ hours = int(delta.total_seconds() / 3600)
404
+ return f"{hours}h ago"
405
+ else:
406
+ return f"{delta.days}d ago"
@@ -0,0 +1,234 @@
1
+ """Commit message generation via Claude CLI.
2
+
3
+ This module provides commit message generation for PR submissions,
4
+ using Claude CLI to analyze diffs and generate descriptive messages.
5
+
6
+ The commit message prompt is loaded from the shared prompt file at:
7
+ packages/erk-shared/src/erk_shared/gateway/gt/commit_message_prompt.md
8
+ """
9
+
10
+ from collections.abc import Generator
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ from erk.core.claude_executor import ClaudeExecutor
15
+ from erk_shared.gateway.gt.events import CompletionEvent, ProgressEvent
16
+ from erk_shared.gateway.gt.prompts import COMMIT_MESSAGE_SYSTEM_PROMPT
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class CommitMessageRequest:
21
+ """Request for generating a commit message.
22
+
23
+ Attributes:
24
+ diff_file: Path to the file containing the diff content
25
+ repo_root: Path to the repository root directory
26
+ current_branch: Name of the current branch
27
+ parent_branch: Name of the parent branch
28
+ commit_messages: Optional list of existing commit messages for context
29
+ """
30
+
31
+ diff_file: Path
32
+ repo_root: Path
33
+ current_branch: str
34
+ parent_branch: str
35
+ commit_messages: list[str] | None = None
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class CommitMessageResult:
40
+ """Result of commit message generation.
41
+
42
+ Attributes:
43
+ success: Whether generation succeeded
44
+ title: PR title (first line of commit message) if successful
45
+ body: PR body (remaining lines) if successful
46
+ error_message: Error description if generation failed
47
+ """
48
+
49
+ success: bool
50
+ title: str | None
51
+ body: str | None
52
+ error_message: str | None
53
+
54
+
55
+ class CommitMessageGenerator:
56
+ """Generates commit messages via Claude CLI.
57
+
58
+ This is a concrete class (not ABC) that uses ClaudeExecutor for
59
+ testability. In tests, inject FakeClaudeExecutor with simulated_prompt_output.
60
+ """
61
+
62
+ def __init__(self, executor: ClaudeExecutor, model: str = "haiku") -> None:
63
+ """Initialize generator with executor.
64
+
65
+ Args:
66
+ executor: Claude CLI executor for prompt execution
67
+ model: Model to use for generation (default "haiku" for speed/cost)
68
+ """
69
+ self._executor = executor
70
+ self._model = model
71
+
72
+ def generate(
73
+ self, request: CommitMessageRequest
74
+ ) -> Generator[ProgressEvent | CompletionEvent[CommitMessageResult]]:
75
+ """Generate commit message from diff with progress updates.
76
+
77
+ Reads the diff file, sends it to Claude with the commit message prompt,
78
+ and parses the response into title and body.
79
+
80
+ Args:
81
+ request: CommitMessageRequest with diff file and context
82
+
83
+ Yields:
84
+ ProgressEvent for status updates
85
+ CompletionEvent with CommitMessageResult on completion
86
+ """
87
+ # LBYL: Check diff file exists
88
+ yield ProgressEvent("Reading diff file...")
89
+ if not request.diff_file.exists():
90
+ yield CompletionEvent(
91
+ CommitMessageResult(
92
+ success=False,
93
+ title=None,
94
+ body=None,
95
+ error_message=f"Diff file not found: {request.diff_file}",
96
+ )
97
+ )
98
+ return
99
+
100
+ # Read diff content
101
+ diff_content = request.diff_file.read_text(encoding="utf-8")
102
+ if not diff_content.strip():
103
+ yield CompletionEvent(
104
+ CommitMessageResult(
105
+ success=False,
106
+ title=None,
107
+ body=None,
108
+ error_message="Diff file is empty",
109
+ )
110
+ )
111
+ return
112
+
113
+ diff_size = len(diff_content)
114
+ yield ProgressEvent(f"Diff loaded ({diff_size:,} chars)", style="success")
115
+
116
+ # Build prompt with context
117
+ yield ProgressEvent("Analyzing changes with Claude...")
118
+
119
+ prompt = self._build_prompt(
120
+ diff_content=diff_content,
121
+ current_branch=request.current_branch,
122
+ parent_branch=request.parent_branch,
123
+ commit_messages=request.commit_messages,
124
+ )
125
+
126
+ # Execute prompt via Claude CLI
127
+ result = self._executor.execute_prompt(
128
+ prompt,
129
+ model=self._model,
130
+ cwd=request.repo_root,
131
+ )
132
+
133
+ if not result.success:
134
+ yield CompletionEvent(
135
+ CommitMessageResult(
136
+ success=False,
137
+ title=None,
138
+ body=None,
139
+ error_message=result.error or "Claude CLI execution failed",
140
+ )
141
+ )
142
+ return
143
+
144
+ # Parse output into title and body
145
+ title, body = self._parse_output(result.output)
146
+
147
+ yield ProgressEvent("PR description generated", style="success")
148
+ yield CompletionEvent(
149
+ CommitMessageResult(
150
+ success=True,
151
+ title=title,
152
+ body=body,
153
+ error_message=None,
154
+ )
155
+ )
156
+
157
+ def _build_prompt(
158
+ self,
159
+ diff_content: str,
160
+ current_branch: str,
161
+ parent_branch: str,
162
+ commit_messages: list[str] | None = None,
163
+ ) -> str:
164
+ """Build the full prompt with diff and context."""
165
+ context_section = f"""## Context
166
+
167
+ - Current branch: {current_branch}
168
+ - Parent branch: {parent_branch}"""
169
+
170
+ # Add commit messages section if present
171
+ if commit_messages:
172
+ messages_text = "\n\n---\n\n".join(commit_messages)
173
+ context_section += f"""
174
+
175
+ ## Developer's Commit Messages
176
+
177
+ The following commit messages were written by the developer during implementation:
178
+
179
+ {messages_text}
180
+
181
+ Use these commit messages as additional context. They describe the developer's intent
182
+ and may contain details not visible in the diff alone."""
183
+
184
+ return f"""{COMMIT_MESSAGE_SYSTEM_PROMPT}
185
+
186
+ {context_section}
187
+
188
+ ## Diff
189
+
190
+ ```diff
191
+ {diff_content}
192
+ ```
193
+
194
+ Generate a commit message for this diff:"""
195
+
196
+ def _parse_output(self, output: str) -> tuple[str, str]:
197
+ """Parse Claude output into title and body.
198
+
199
+ The first non-empty line is the title, the rest is the body.
200
+ Handles case where output is wrapped in markdown code fences.
201
+
202
+ Args:
203
+ output: Raw output from Claude
204
+
205
+ Returns:
206
+ Tuple of (title, body)
207
+ """
208
+ lines = output.strip().split("\n")
209
+
210
+ # Strip leading code fence if present (handles ```markdown, ```text, ```, etc.)
211
+ if lines and lines[0].strip().startswith("```"):
212
+ lines = lines[1:]
213
+
214
+ # Strip trailing code fence if present
215
+ if lines and lines[-1].strip() == "```":
216
+ lines = lines[:-1]
217
+
218
+ # Find first non-empty line as title
219
+ title = ""
220
+ body_start_idx = 0
221
+ for i, line in enumerate(lines):
222
+ if line.strip():
223
+ title = line.strip()
224
+ body_start_idx = i + 1
225
+ break
226
+
227
+ # Rest is body (skip empty lines between title and body)
228
+ body_lines = lines[body_start_idx:]
229
+ while body_lines and not body_lines[0].strip():
230
+ body_lines = body_lines[1:]
231
+
232
+ body = "\n".join(body_lines).strip()
233
+
234
+ return title, body
erk/core/completion.py ADDED
@@ -0,0 +1,10 @@
1
+ """Shell completion script generation operations.
2
+
3
+ This is a thin shim that re-exports from erk_shared.gateway.completion.
4
+ All implementations are in erk_shared for sharing across packages.
5
+ """
6
+
7
+ # Re-export all Completion types from erk_shared
8
+ from erk_shared.gateway.completion import Completion as Completion
9
+ from erk_shared.gateway.completion import FakeCompletion as FakeCompletion
10
+ from erk_shared.gateway.completion import RealCompletion as RealCompletion