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,174 @@
1
+ """Command to validate plan format against Schema v2 requirements."""
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ import click
7
+
8
+ from erk.cli.core import discover_repo_context
9
+ from erk.cli.github_parsing import parse_issue_identifier
10
+ from erk.core.context import ErkContext
11
+ from erk.core.repo_discovery import ensure_erk_metadata_dir
12
+ from erk_shared.github.issues.abc import GitHubIssues
13
+ from erk_shared.github.metadata.core import find_metadata_block
14
+ from erk_shared.github.metadata.plan_header import extract_plan_from_comment
15
+ from erk_shared.github.metadata.schemas import PlanHeaderSchema
16
+ from erk_shared.output.output import user_output
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class PlanValidationSuccess:
21
+ """Validation completed (may have passed or failed checks).
22
+
23
+ Attributes:
24
+ passed: True if all validation checks passed
25
+ checks: List of (passed, description) tuples for each check
26
+ failed_count: Number of failed checks
27
+ """
28
+
29
+ passed: bool
30
+ checks: list[tuple[bool, str]]
31
+ failed_count: int
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class PlanValidationError:
36
+ """Could not complete validation (API error, network issue, etc.)."""
37
+
38
+ error: str
39
+
40
+
41
+ PlanValidationResult = PlanValidationSuccess | PlanValidationError
42
+
43
+
44
+ def validate_plan_format(
45
+ github_issues: GitHubIssues,
46
+ repo_root: Path,
47
+ issue_number: int,
48
+ ) -> PlanValidationResult:
49
+ """Validate plan format programmatically.
50
+
51
+ Validates that a plan stored in a GitHub issue conforms to Schema v2:
52
+ - Issue body has plan-header metadata block with required fields
53
+ - First comment has plan-body metadata block with extractable content
54
+
55
+ This function is designed to be called programmatically (e.g., from land_cmd).
56
+ It does not produce output or raise SystemExit. It never raises exceptions -
57
+ API failures are returned as PlanValidationError.
58
+
59
+ Args:
60
+ github_issues: GitHub issues interface
61
+ repo_root: Repository root path
62
+ issue_number: GitHub issue number to validate
63
+
64
+ Returns:
65
+ PlanValidationSuccess if validation completed (may have passed or failed checks)
66
+ PlanValidationError if unable to complete validation (API error, etc.)
67
+ """
68
+ # Track validation results
69
+ checks: list[tuple[bool, str]] = []
70
+
71
+ # Fetch issue from GitHub
72
+ try:
73
+ issue = github_issues.get_issue(repo_root, issue_number)
74
+ except RuntimeError as e:
75
+ return PlanValidationError(error=str(e))
76
+
77
+ issue_body = issue.body if issue.body else ""
78
+
79
+ # Check 1: plan-header metadata block exists
80
+ plan_header_block = find_metadata_block(issue_body, "plan-header")
81
+ if plan_header_block is None:
82
+ checks.append((False, "plan-header metadata block present"))
83
+ else:
84
+ checks.append((True, "plan-header metadata block present"))
85
+
86
+ # Check 2: plan-header has required fields and is valid
87
+ try:
88
+ schema = PlanHeaderSchema()
89
+ schema.validate(plan_header_block.data)
90
+ checks.append((True, "plan-header has required fields"))
91
+ except ValueError as e:
92
+ # Extract first error message for cleaner output
93
+ error_msg = str(e).split("\n")[0]
94
+ checks.append((False, f"plan-header validation failed: {error_msg}"))
95
+
96
+ # Check 3: First comment exists
97
+ try:
98
+ comments = github_issues.get_issue_comments(repo_root, issue_number)
99
+ except RuntimeError as e:
100
+ return PlanValidationError(error=str(e))
101
+
102
+ if not comments:
103
+ checks.append((False, "First comment exists"))
104
+ else:
105
+ checks.append((True, "First comment exists"))
106
+
107
+ # Check 4: plan-body content extractable
108
+ first_comment = comments[0]
109
+ plan_content = extract_plan_from_comment(first_comment)
110
+ if plan_content is None:
111
+ checks.append((False, "plan-body content extractable"))
112
+ else:
113
+ checks.append((True, "plan-body content extractable"))
114
+
115
+ # Determine overall result
116
+ failed_count = sum(1 for passed, _ in checks if not passed)
117
+
118
+ return PlanValidationSuccess(
119
+ passed=failed_count == 0,
120
+ checks=checks,
121
+ failed_count=failed_count,
122
+ )
123
+
124
+
125
+ @click.command("check")
126
+ @click.argument("identifier", type=str)
127
+ @click.pass_obj
128
+ def check_plan(ctx: ErkContext, identifier: str) -> None:
129
+ """Validate a plan's format against Schema v2 requirements.
130
+
131
+ Validates that a plan stored in a GitHub issue conforms to Schema v2:
132
+ - Issue body has plan-header metadata block with required fields
133
+ - First comment has plan-body metadata block with extractable content
134
+
135
+ Args:
136
+ identifier: Plan identifier (e.g., "42" or GitHub URL)
137
+ """
138
+ repo = discover_repo_context(ctx, ctx.cwd)
139
+ ensure_erk_metadata_dir(repo) # Ensure erk metadata directories exist
140
+ repo_root = repo.root # Use git repository root for GitHub operations
141
+
142
+ # Parse identifier - raises click.ClickException if invalid
143
+ issue_number = parse_issue_identifier(identifier)
144
+
145
+ user_output(f"Validating plan #{issue_number}...")
146
+ user_output("")
147
+
148
+ # Run validation
149
+ result = validate_plan_format(ctx.issues, repo_root, issue_number)
150
+
151
+ if isinstance(result, PlanValidationError):
152
+ user_output(click.style("Error: ", fg="red") + f"Failed to validate plan: {result.error}")
153
+ raise SystemExit(1)
154
+
155
+ # result is now PlanValidationSuccess
156
+ # Output results
157
+ for passed, description in result.checks:
158
+ status = click.style("[PASS]", fg="green") if passed else click.style("[FAIL]", fg="red")
159
+ user_output(f"{status} {description}")
160
+
161
+ user_output("")
162
+
163
+ # Determine overall result
164
+ if result.passed:
165
+ user_output(click.style("Plan validation passed", fg="green"))
166
+ raise SystemExit(0)
167
+ else:
168
+ check_word = "checks" if result.failed_count > 1 else "check"
169
+ user_output(
170
+ click.style(
171
+ f"Plan validation failed ({result.failed_count} {check_word} failed)", fg="red"
172
+ )
173
+ )
174
+ raise SystemExit(1)
@@ -0,0 +1,69 @@
1
+ """Command to close a plan."""
2
+
3
+ from pathlib import Path
4
+
5
+ import click
6
+
7
+ from erk.cli.core import discover_repo_context
8
+ from erk.cli.github_parsing import parse_issue_identifier
9
+ from erk.core.context import ErkContext
10
+ from erk.core.repo_discovery import ensure_erk_metadata_dir
11
+ from erk_shared.output.output import user_output
12
+
13
+
14
+ def _close_linked_prs(
15
+ ctx: ErkContext,
16
+ repo_root: Path,
17
+ issue_number: int,
18
+ ) -> list[int]:
19
+ """Close all OPEN PRs linked to an issue.
20
+
21
+ Returns list of PR numbers that were closed.
22
+ """
23
+ linked_prs = ctx.issues.get_prs_referencing_issue(repo_root, issue_number)
24
+
25
+ closed_prs: list[int] = []
26
+ for pr in linked_prs:
27
+ # Close all OPEN PRs (both drafts and non-drafts per user requirement)
28
+ if pr.state == "OPEN":
29
+ ctx.github.close_pr(repo_root, pr.number)
30
+ closed_prs.append(pr.number)
31
+
32
+ return closed_prs
33
+
34
+
35
+ @click.command("close")
36
+ @click.argument("identifier", type=str)
37
+ @click.pass_obj
38
+ def close_plan(ctx: ErkContext, identifier: str) -> None:
39
+ """Close a plan by issue number or GitHub URL.
40
+
41
+ Closes all OPEN PRs linked to the issue in addition to closing the issue itself.
42
+
43
+ Args:
44
+ identifier: Plan identifier (e.g., "42" or GitHub URL)
45
+ """
46
+ repo = discover_repo_context(ctx, ctx.cwd)
47
+ ensure_erk_metadata_dir(repo) # Ensure erk metadata directories exist
48
+ repo_root = repo.root # Use git repository root for GitHub operations
49
+
50
+ # Parse issue number - errors if invalid
51
+ number = parse_issue_identifier(identifier)
52
+
53
+ # Fetch plan - errors if not found
54
+ try:
55
+ _plan = ctx.plan_store.get_plan(repo_root, str(number))
56
+ except RuntimeError as e:
57
+ raise click.ClickException(str(e)) from e
58
+
59
+ # Close linked PRs before closing the plan
60
+ closed_prs = _close_linked_prs(ctx, repo_root, number)
61
+
62
+ # Close the plan (issue)
63
+ ctx.plan_store.close_plan(repo_root, identifier)
64
+
65
+ # Output
66
+ user_output(f"Closed plan #{number}")
67
+ if closed_prs:
68
+ pr_list = ", ".join(f"#{pr}" for pr in closed_prs)
69
+ user_output(f"Closed {len(closed_prs)} linked PR(s): {pr_list}")
@@ -0,0 +1,120 @@
1
+ """Command to create a plan issue from markdown content."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import click
7
+
8
+ from erk.cli.core import discover_repo_context
9
+ from erk.cli.ensure import Ensure
10
+ from erk.core.context import ErkContext
11
+ from erk.core.repo_discovery import ensure_erk_metadata_dir
12
+ from erk_shared.github.plan_issues import create_plan_issue
13
+ from erk_shared.output.output import user_output
14
+
15
+
16
+ @click.command("create")
17
+ @click.option(
18
+ "--file",
19
+ "-f",
20
+ type=click.Path(exists=True, path_type=Path),
21
+ help="Plan file to read",
22
+ )
23
+ @click.option("--title", "-t", type=str, help="Issue title (default: extract from H1)")
24
+ @click.option("--label", "-l", multiple=True, help="Additional labels")
25
+ @click.pass_obj
26
+ def create_plan(
27
+ ctx: ErkContext,
28
+ file: Path | None,
29
+ title: str | None,
30
+ label: tuple[str, ...],
31
+ ) -> None:
32
+ """Create a plan issue from markdown content.
33
+
34
+ Supports two input modes:
35
+ - File: --file PATH (recommended for automation)
36
+ - Stdin: pipe content via shell (for Unix composability)
37
+
38
+ Examples:
39
+ erk create --file plan.md
40
+ cat plan.md | erk create
41
+ erk create --file plan.md --title "Custom Title"
42
+ erk create --file plan.md --label bug --label urgent
43
+ """
44
+ repo = discover_repo_context(ctx, ctx.cwd)
45
+ ensure_erk_metadata_dir(repo)
46
+ repo_root = repo.root
47
+
48
+ # LBYL: Check input sources - exactly one required
49
+ # Priority: --file flag takes precedence over stdin
50
+ content = "" # Initialize to ensure type safety
51
+ if file is not None:
52
+ # Use file input
53
+ Ensure.path_exists(ctx, file, f"File not found: {file}")
54
+ try:
55
+ content = file.read_text(encoding="utf-8")
56
+ except OSError as e:
57
+ user_output(click.style("Error: ", fg="red") + f"Failed to read file: {e}")
58
+ raise SystemExit(1) from e
59
+ elif not sys.stdin.isatty():
60
+ # Use stdin input (piped data)
61
+ try:
62
+ content = sys.stdin.read()
63
+ except OSError as e:
64
+ user_output(click.style("Error: ", fg="red") + f"Failed to read stdin: {e}")
65
+ raise SystemExit(1) from e
66
+ else:
67
+ # No input provided
68
+ Ensure.invariant(False, "No input provided. Use --file or pipe content to stdin.")
69
+
70
+ # Validate content is not empty
71
+ Ensure.not_empty(content.strip(), "Plan content is empty. Provide a non-empty plan.")
72
+
73
+ # Convert extra labels tuple to list
74
+ extra_labels = list(label) if label else None
75
+
76
+ # Determine source_repo for cross-repo plans
77
+ # When plans_repo is configured, plans are stored in a separate repo
78
+ # and source_repo records where implementation will happen
79
+ source_repo: str | None = None
80
+ plans_repo = ctx.local_config.plans_repo if ctx.local_config else None
81
+ if plans_repo is not None and repo.github is not None:
82
+ source_repo = f"{repo.github.owner}/{repo.github.repo}"
83
+
84
+ # Use consolidated create_plan_issue for the entire workflow
85
+ result = create_plan_issue(
86
+ github_issues=ctx.issues,
87
+ repo_root=repo_root,
88
+ plan_content=content,
89
+ title=title,
90
+ plan_type=None,
91
+ extra_labels=extra_labels,
92
+ title_suffix=None,
93
+ source_plan_issues=None,
94
+ extraction_session_ids=None,
95
+ source_repo=source_repo,
96
+ objective_issue=None,
97
+ )
98
+
99
+ if not result.success:
100
+ if result.issue_number is not None:
101
+ # Partial success - issue created but comment failed
102
+ user_output(
103
+ click.style("Warning: ", fg="yellow")
104
+ + f"Issue created but failed to add plan comment: {result.error}"
105
+ )
106
+ user_output(f"Issue #{result.issue_number} created but incomplete.")
107
+ user_output(f"URL: {result.issue_url}")
108
+ else:
109
+ user_output(click.style("Error: ", fg="red") + str(result.error))
110
+ raise SystemExit(1)
111
+
112
+ # Display success message with next steps
113
+ user_output(f"Created plan #{result.issue_number}")
114
+ user_output("")
115
+ user_output(f"Issue: {result.issue_url}")
116
+ user_output("")
117
+ user_output("Next steps:")
118
+ user_output(f" View: erk get {result.issue_number}")
119
+ user_output(f" Implement: erk implement {result.issue_number}")
120
+ user_output(f" Submit: erk submit {result.issue_number}")
@@ -0,0 +1,18 @@
1
+ """Docs subcommand group for plan documentation extraction tracking."""
2
+
3
+ import click
4
+
5
+ from erk.cli.commands.plan.docs.extract_cmd import extract_docs
6
+ from erk.cli.commands.plan.docs.unextract_cmd import unextract_docs
7
+ from erk.cli.commands.plan.docs.unextracted_cmd import list_unextracted
8
+
9
+
10
+ @click.group("docs")
11
+ def docs_group() -> None:
12
+ """Track documentation extraction from plan sessions."""
13
+ pass
14
+
15
+
16
+ docs_group.add_command(list_unextracted, name="unextracted")
17
+ docs_group.add_command(extract_docs, name="extract")
18
+ docs_group.add_command(unextract_docs, name="unextract")
@@ -0,0 +1,53 @@
1
+ """Command to mark a plan as having been analyzed for documentation."""
2
+
3
+ import click
4
+
5
+ from erk.cli.constants import (
6
+ DOCS_EXTRACTED_LABEL,
7
+ DOCS_EXTRACTED_LABEL_COLOR,
8
+ DOCS_EXTRACTED_LABEL_DESCRIPTION,
9
+ )
10
+ from erk.cli.core import discover_repo_context
11
+ from erk.cli.github_parsing import parse_issue_identifier
12
+ from erk.core.context import ErkContext
13
+ from erk.core.repo_discovery import ensure_erk_metadata_dir
14
+ from erk_shared.output.output import user_output
15
+
16
+
17
+ @click.command("extract")
18
+ @click.argument("identifier", type=str)
19
+ @click.pass_obj
20
+ def extract_docs(ctx: ErkContext, identifier: str) -> None:
21
+ """Mark a plan as having been analyzed for documentation.
22
+
23
+ Adds the docs-extracted label to the specified issue, indicating that
24
+ its session logs have been reviewed for documentation improvements.
25
+
26
+ Args:
27
+ identifier: Plan identifier (e.g., "42" or GitHub URL)
28
+ """
29
+ repo = discover_repo_context(ctx, ctx.cwd)
30
+ ensure_erk_metadata_dir(repo)
31
+ repo_root = repo.root
32
+
33
+ # Parse issue number
34
+ issue_number = parse_issue_identifier(identifier)
35
+
36
+ # Ensure label exists in repo (create if needed)
37
+ try:
38
+ ctx.issues.ensure_label_exists(
39
+ repo_root,
40
+ DOCS_EXTRACTED_LABEL,
41
+ DOCS_EXTRACTED_LABEL_DESCRIPTION,
42
+ DOCS_EXTRACTED_LABEL_COLOR,
43
+ )
44
+ except RuntimeError as e:
45
+ raise click.ClickException(f"Failed to ensure label exists: {e}") from e
46
+
47
+ # Add label to issue (idempotent)
48
+ try:
49
+ ctx.issues.ensure_label_on_issue(repo_root, issue_number, DOCS_EXTRACTED_LABEL)
50
+ except RuntimeError as e:
51
+ raise click.ClickException(f"Failed to add label to issue #{issue_number}: {e}") from e
52
+
53
+ user_output(f"Marked plan #{issue_number} as docs-extracted")
@@ -0,0 +1,38 @@
1
+ """Command to remove the docs-extracted label from a plan (for re-analysis)."""
2
+
3
+ import click
4
+
5
+ from erk.cli.constants import DOCS_EXTRACTED_LABEL
6
+ from erk.cli.core import discover_repo_context
7
+ from erk.cli.github_parsing import parse_issue_identifier
8
+ from erk.core.context import ErkContext
9
+ from erk.core.repo_discovery import ensure_erk_metadata_dir
10
+ from erk_shared.output.output import user_output
11
+
12
+
13
+ @click.command("unextract")
14
+ @click.argument("identifier", type=str)
15
+ @click.pass_obj
16
+ def unextract_docs(ctx: ErkContext, identifier: str) -> None:
17
+ """Remove the docs-extracted label from a plan (for re-analysis).
18
+
19
+ Removes the docs-extracted label from the specified issue, allowing it
20
+ to appear again in the unextracted list for future documentation review.
21
+
22
+ Args:
23
+ identifier: Plan identifier (e.g., "42" or GitHub URL)
24
+ """
25
+ repo = discover_repo_context(ctx, ctx.cwd)
26
+ ensure_erk_metadata_dir(repo)
27
+ repo_root = repo.root
28
+
29
+ # Parse issue number
30
+ issue_number = parse_issue_identifier(identifier)
31
+
32
+ # Remove label from issue
33
+ try:
34
+ ctx.issues.remove_label_from_issue(repo_root, issue_number, DOCS_EXTRACTED_LABEL)
35
+ except RuntimeError as e:
36
+ raise click.ClickException(f"Failed to remove label from issue #{issue_number}: {e}") from e
37
+
38
+ user_output(f"Removed docs-extracted label from plan #{issue_number}")
@@ -0,0 +1,72 @@
1
+ """Command to list closed plans that haven't been analyzed for documentation."""
2
+
3
+ import click
4
+ from rich.console import Console
5
+ from rich.table import Table
6
+
7
+ from erk.cli.constants import DOCS_EXTRACTED_LABEL, ERK_PLAN_LABEL
8
+ from erk.cli.core import discover_repo_context
9
+ from erk.core.context import ErkContext
10
+ from erk.core.display_utils import format_relative_time
11
+ from erk.core.repo_discovery import ensure_erk_metadata_dir
12
+ from erk_shared.output.output import user_output
13
+
14
+
15
+ @click.command("unextracted")
16
+ @click.pass_obj
17
+ def list_unextracted(ctx: ErkContext) -> None:
18
+ """List closed plans that haven't been analyzed for documentation.
19
+
20
+ Shows all closed erk-plan issues that don't have the docs-extracted label.
21
+ Use 'erk plan docs extract <number>' to mark a plan as extracted.
22
+ """
23
+ repo = discover_repo_context(ctx, ctx.cwd)
24
+ ensure_erk_metadata_dir(repo)
25
+ repo_root = repo.root
26
+
27
+ # Query all closed issues with erk-plan label
28
+ try:
29
+ issues = ctx.issues.list_issues(
30
+ repo_root,
31
+ labels=[ERK_PLAN_LABEL],
32
+ state="closed",
33
+ )
34
+ except RuntimeError as e:
35
+ raise click.ClickException(f"Failed to list issues: {e}") from e
36
+
37
+ # Filter out issues that already have docs-extracted label
38
+ unextracted = [issue for issue in issues if DOCS_EXTRACTED_LABEL not in issue.labels]
39
+
40
+ if not unextracted:
41
+ user_output("No unextracted plans found. All closed plans have been analyzed.")
42
+ return
43
+
44
+ # Build table
45
+ table = Table(show_header=True, header_style="bold")
46
+ table.add_column("plan", style="cyan", no_wrap=True)
47
+ table.add_column("title", no_wrap=True)
48
+ table.add_column("closed", no_wrap=True)
49
+
50
+ for issue in unextracted:
51
+ # Format issue number with link
52
+ id_text = f"#{issue.number}"
53
+ if issue.url:
54
+ issue_id = f"[link={issue.url}][cyan]{id_text}[/cyan][/link]"
55
+ else:
56
+ issue_id = f"[cyan]{id_text}[/cyan]"
57
+
58
+ # Truncate title
59
+ title = issue.title
60
+ if len(title) > 50:
61
+ title = title[:47] + "..."
62
+
63
+ # Format closed time
64
+ closed_at = format_relative_time(issue.updated_at.isoformat()) if issue.updated_at else "-"
65
+
66
+ table.add_row(issue_id, title, closed_at)
67
+
68
+ user_output(f"\nFound {len(unextracted)} unextracted plan(s):\n")
69
+
70
+ console = Console(stderr=True, width=200, force_terminal=True)
71
+ console.print(table)
72
+ console.print()
@@ -0,0 +1,16 @@
1
+ """Extraction subcommand group for plan documentation extraction workflow."""
2
+
3
+ import click
4
+
5
+ from erk.cli.commands.plan.extraction.complete_cmd import complete_extraction
6
+ from erk.cli.commands.plan.extraction.create_raw_cmd import create_raw
7
+
8
+
9
+ @click.group("extraction")
10
+ def extraction_group() -> None:
11
+ """Manage documentation extraction plans."""
12
+ pass
13
+
14
+
15
+ extraction_group.add_command(complete_extraction, name="complete")
16
+ extraction_group.add_command(create_raw, name="raw")
@@ -0,0 +1,101 @@
1
+ """Command to complete an extraction plan and mark source plans as extracted."""
2
+
3
+ import click
4
+
5
+ from erk.cli.constants import (
6
+ DOCS_EXTRACTED_LABEL,
7
+ DOCS_EXTRACTED_LABEL_COLOR,
8
+ DOCS_EXTRACTED_LABEL_DESCRIPTION,
9
+ )
10
+ from erk.cli.core import discover_repo_context
11
+ from erk.cli.github_parsing import parse_issue_identifier
12
+ from erk.core.context import ErkContext
13
+ from erk.core.repo_discovery import ensure_erk_metadata_dir
14
+ from erk_shared.github.metadata.core import find_metadata_block
15
+ from erk_shared.output.output import user_output
16
+
17
+
18
+ @click.command("complete")
19
+ @click.argument("identifier", type=str)
20
+ @click.pass_obj
21
+ def complete_extraction(ctx: ErkContext, identifier: str) -> None:
22
+ """Complete an extraction plan by marking source plans as docs-extracted.
23
+
24
+ Reads the extraction plan's metadata to find source_plan_issues,
25
+ then adds the docs-extracted label to each source plan.
26
+
27
+ This command is idempotent - safe to run multiple times.
28
+
29
+ Args:
30
+ identifier: Extraction plan identifier (e.g., "42" or GitHub URL)
31
+ """
32
+ repo = discover_repo_context(ctx, ctx.cwd)
33
+ ensure_erk_metadata_dir(repo)
34
+ repo_root = repo.root
35
+
36
+ # Parse extraction plan issue number
37
+ issue_number = parse_issue_identifier(identifier)
38
+
39
+ # Fetch the extraction plan issue to read its metadata
40
+ try:
41
+ issue_info = ctx.issues.get_issue(repo_root, issue_number)
42
+ except RuntimeError as e:
43
+ raise click.ClickException(f"Failed to fetch issue #{issue_number}: {e}") from e
44
+
45
+ # Extract plan-header metadata block
46
+ plan_header = find_metadata_block(issue_info.body, "plan-header")
47
+ if plan_header is None:
48
+ raise click.ClickException(
49
+ f"Issue #{issue_number} does not have a plan-header metadata block. "
50
+ "Is this an erk plan issue?"
51
+ )
52
+
53
+ # Check plan_type
54
+ plan_type = plan_header.data.get("plan_type")
55
+ if plan_type != "extraction":
56
+ raise click.ClickException(
57
+ f"Issue #{issue_number} is not an extraction plan (plan_type: {plan_type}). "
58
+ "This command only works on extraction plans."
59
+ )
60
+
61
+ # Get source_plan_issues
62
+ source_plan_issues = plan_header.data.get("source_plan_issues")
63
+ if not source_plan_issues:
64
+ raise click.ClickException(
65
+ f"Issue #{issue_number} has no source_plan_issues in its metadata. "
66
+ "Cannot determine which plans to mark as extracted."
67
+ )
68
+
69
+ # Ensure docs-extracted label exists
70
+ try:
71
+ ctx.issues.ensure_label_exists(
72
+ repo_root,
73
+ DOCS_EXTRACTED_LABEL,
74
+ DOCS_EXTRACTED_LABEL_DESCRIPTION,
75
+ DOCS_EXTRACTED_LABEL_COLOR,
76
+ )
77
+ except RuntimeError as e:
78
+ raise click.ClickException(f"Failed to ensure label exists: {e}") from e
79
+
80
+ # Mark each source plan as docs-extracted
81
+ marked_count = 0
82
+ for source_issue_number in source_plan_issues:
83
+ try:
84
+ ctx.issues.ensure_label_on_issue(repo_root, source_issue_number, DOCS_EXTRACTED_LABEL)
85
+ user_output(f" Marked plan #{source_issue_number} as docs-extracted")
86
+ marked_count += 1
87
+ except RuntimeError as e:
88
+ # Log the error but continue with other issues
89
+ click.echo(f" Warning: Failed to mark plan #{source_issue_number}: {e}", err=True)
90
+
91
+ # Summary
92
+ if marked_count == len(source_plan_issues):
93
+ user_output(
94
+ f"\nExtraction plan #{issue_number} completed: "
95
+ f"marked {marked_count} source plan(s) as docs-extracted"
96
+ )
97
+ else:
98
+ user_output(
99
+ f"\nExtraction plan #{issue_number} partially completed: "
100
+ f"marked {marked_count}/{len(source_plan_issues)} source plan(s) as docs-extracted"
101
+ )