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,1315 @@
1
+ """Health check implementations for erk doctor command.
2
+
3
+ This module provides diagnostic checks for erk setup, including
4
+ CLI availability, repository configuration, and Claude settings.
5
+ """
6
+
7
+ import json
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ from erk.artifacts.artifact_health import (
12
+ ArtifactHealthResult,
13
+ ArtifactStatusType,
14
+ get_artifact_health,
15
+ )
16
+ from erk.artifacts.detection import is_in_erk_repo
17
+ from erk.artifacts.models import ArtifactFileState
18
+ from erk.artifacts.state import load_artifact_state
19
+ from erk.core.claude_settings import (
20
+ ERK_PERMISSION,
21
+ StatuslineNotConfigured,
22
+ get_repo_claude_settings_path,
23
+ get_statusline_config,
24
+ has_erk_permission,
25
+ has_erk_statusline,
26
+ has_exit_plan_hook,
27
+ read_claude_settings,
28
+ )
29
+ from erk.core.context import ErkContext
30
+ from erk.core.init_utils import has_shell_integration_in_rc
31
+ from erk.core.repo_discovery import RepoContext
32
+ from erk.core.version_check import get_required_version, is_version_mismatch
33
+ from erk_shared.extraction.claude_installation import ClaudeInstallation
34
+ from erk_shared.gateway.shell.abc import Shell
35
+ from erk_shared.github.issues.abc import GitHubIssues
36
+ from erk_shared.github.plan_issues import get_erk_label_definitions
37
+ from erk_shared.github_admin.abc import GitHubAdmin
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class CheckResult:
42
+ """Result of a single health check.
43
+
44
+ Attributes:
45
+ name: Name of the check
46
+ passed: Whether the check passed
47
+ message: Human-readable message describing the result
48
+ details: Optional additional details (e.g., version info)
49
+ verbose_details: Extended details shown only in verbose mode
50
+ warning: If True and passed=True, displays ⚠️ instead of ✅
51
+ info: If True and passed=True, displays ℹ️ (informational, not success)
52
+ remediation: Optional command/action to fix a failing check
53
+ """
54
+
55
+ name: str
56
+ passed: bool
57
+ message: str
58
+ details: str | None = None
59
+ verbose_details: str | None = None
60
+ warning: bool = False
61
+ info: bool = False
62
+ remediation: str | None = None
63
+
64
+
65
+ def check_erk_version() -> CheckResult:
66
+ """Check erk CLI version."""
67
+ try:
68
+ from importlib.metadata import version
69
+
70
+ erk_version = version("erk")
71
+ return CheckResult(
72
+ name="erk",
73
+ passed=True,
74
+ message=f"erk CLI installed: v{erk_version}",
75
+ )
76
+ except Exception:
77
+ return CheckResult(
78
+ name="erk",
79
+ passed=False,
80
+ message="erk package not found",
81
+ )
82
+
83
+
84
+ def _get_installed_erk_version() -> str | None:
85
+ """Get installed erk version, or None if not installed."""
86
+ try:
87
+ from importlib.metadata import version
88
+
89
+ return version("erk")
90
+ except Exception:
91
+ return None
92
+
93
+
94
+ def check_required_tool_version(repo_root: Path) -> CheckResult:
95
+ """Check that installed erk version matches the required version file.
96
+
97
+ Args:
98
+ repo_root: Path to the repository root
99
+
100
+ Returns:
101
+ CheckResult indicating:
102
+ - FAIL if version file missing
103
+ - FAIL with warning if versions mismatch
104
+ - PASS if versions match
105
+ """
106
+ required_version = get_required_version(repo_root)
107
+ if required_version is None:
108
+ return CheckResult(
109
+ name="required-version",
110
+ passed=False,
111
+ message="Required version file missing (.erk/required-erk-uv-tool-version)",
112
+ remediation="Run 'erk init' to create this file",
113
+ )
114
+
115
+ installed_version = _get_installed_erk_version()
116
+ if installed_version is None:
117
+ return CheckResult(
118
+ name="required-version",
119
+ passed=False,
120
+ message="Could not determine installed erk version",
121
+ )
122
+
123
+ if is_version_mismatch(installed_version, required_version):
124
+ return CheckResult(
125
+ name="required-version",
126
+ passed=False,
127
+ message=f"Version mismatch: installed {installed_version}, required {required_version}",
128
+ remediation="Run 'uv tool upgrade erk' to update",
129
+ )
130
+
131
+ return CheckResult(
132
+ name="required-version",
133
+ passed=True,
134
+ message=f"erk version matches required ({required_version})",
135
+ )
136
+
137
+
138
+ def check_claude_cli(shell: Shell) -> CheckResult:
139
+ """Check if Claude CLI is installed and available in PATH.
140
+
141
+ Args:
142
+ shell: Shell implementation for tool detection
143
+ """
144
+ claude_path = shell.get_installed_tool_path("claude")
145
+ if claude_path is None:
146
+ return CheckResult(
147
+ name="claude",
148
+ passed=False,
149
+ message="Claude CLI not found in PATH",
150
+ details="Install from: https://claude.com/download",
151
+ )
152
+
153
+ # Try to get version
154
+ version_output = shell.get_tool_version("claude")
155
+ if version_output is None:
156
+ return CheckResult(
157
+ name="claude",
158
+ passed=True,
159
+ message="Claude CLI found (version check failed)",
160
+ details="unknown",
161
+ )
162
+
163
+ # Parse version from output (format: "claude X.Y.Z")
164
+ version_str = version_output.split()[-1] if version_output else "unknown"
165
+ return CheckResult(
166
+ name="claude",
167
+ passed=True,
168
+ message=f"Claude CLI installed: {version_str}",
169
+ )
170
+
171
+
172
+ def check_graphite_cli(shell: Shell) -> CheckResult:
173
+ """Check if Graphite CLI (gt) is installed and available in PATH.
174
+
175
+ Args:
176
+ shell: Shell implementation for tool detection
177
+ """
178
+ gt_path = shell.get_installed_tool_path("gt")
179
+ if gt_path is None:
180
+ return CheckResult(
181
+ name="graphite",
182
+ passed=False,
183
+ message="Graphite CLI (gt) not found in PATH",
184
+ details="Install from: https://graphite.dev/docs/installing-the-cli",
185
+ )
186
+
187
+ # Try to get version
188
+ version_output = shell.get_tool_version("gt")
189
+ if version_output is None:
190
+ return CheckResult(
191
+ name="graphite",
192
+ passed=True,
193
+ message="Graphite CLI found (version check failed)",
194
+ details="unknown",
195
+ )
196
+
197
+ return CheckResult(
198
+ name="graphite",
199
+ passed=True,
200
+ message=f"Graphite CLI installed: {version_output}",
201
+ )
202
+
203
+
204
+ def check_github_cli(shell: Shell) -> CheckResult:
205
+ """Check if GitHub CLI (gh) is installed and available in PATH.
206
+
207
+ Args:
208
+ shell: Shell implementation for tool detection
209
+ """
210
+ gh_path = shell.get_installed_tool_path("gh")
211
+ if gh_path is None:
212
+ return CheckResult(
213
+ name="github",
214
+ passed=False,
215
+ message="GitHub CLI (gh) not found in PATH",
216
+ details="Install from: https://cli.github.com/",
217
+ )
218
+
219
+ # Try to get version
220
+ version_output = shell.get_tool_version("gh")
221
+ if version_output is None:
222
+ return CheckResult(
223
+ name="github",
224
+ passed=True,
225
+ message="GitHub CLI found (version check failed)",
226
+ details="unknown",
227
+ )
228
+
229
+ # Take first line only (gh version has multi-line output)
230
+ version_first_line = version_output.split("\n")[0] if version_output else "unknown"
231
+ return CheckResult(
232
+ name="github",
233
+ passed=True,
234
+ message=f"GitHub CLI installed: {version_first_line}",
235
+ )
236
+
237
+
238
+ def check_github_auth(shell: Shell, admin: GitHubAdmin) -> CheckResult:
239
+ """Check if GitHub CLI is authenticated.
240
+
241
+ Args:
242
+ shell: Shell implementation for tool detection
243
+ admin: GitHubAdmin implementation for auth status check
244
+ """
245
+ gh_path = shell.get_installed_tool_path("gh")
246
+ if gh_path is None:
247
+ return CheckResult(
248
+ name="github-auth",
249
+ passed=False,
250
+ message="Cannot check auth: gh not installed",
251
+ )
252
+
253
+ auth_status = admin.check_auth_status()
254
+
255
+ if auth_status.error is not None:
256
+ return CheckResult(
257
+ name="github-auth",
258
+ passed=False,
259
+ message=f"Auth check failed: {auth_status.error}",
260
+ )
261
+
262
+ if auth_status.authenticated:
263
+ if auth_status.username:
264
+ return CheckResult(
265
+ name="github-auth",
266
+ passed=True,
267
+ message=f"GitHub authenticated as {auth_status.username}",
268
+ )
269
+ return CheckResult(
270
+ name="github-auth",
271
+ passed=True,
272
+ message="Authenticated to GitHub",
273
+ )
274
+ else:
275
+ return CheckResult(
276
+ name="github-auth",
277
+ passed=False,
278
+ message="Not authenticated to GitHub",
279
+ remediation="Run 'gh auth login' to authenticate",
280
+ )
281
+
282
+
283
+ def check_workflow_permissions(ctx: ErkContext, repo_root: Path, admin: GitHubAdmin) -> CheckResult:
284
+ """Check if GitHub Actions workflows can create PRs.
285
+
286
+ This is an info-level check - it always passes, but shows whether
287
+ PR creation is enabled for workflows. This is required for erk's
288
+ remote implementation feature.
289
+
290
+ Args:
291
+ ctx: ErkContext for repository access
292
+ repo_root: Path to the repository root
293
+ admin: GitHubAdmin implementation for API calls
294
+
295
+ Returns:
296
+ CheckResult with info about workflow permission status
297
+ """
298
+ # Need GitHub identity to check permissions
299
+ try:
300
+ remote_url = ctx.git.get_remote_url(repo_root, "origin")
301
+ except ValueError:
302
+ return CheckResult(
303
+ name="workflow-permissions",
304
+ passed=True, # Info level
305
+ message="No origin remote configured",
306
+ )
307
+
308
+ # Parse GitHub owner/repo from remote URL
309
+ from erk_shared.github.parsing import parse_git_remote_url
310
+ from erk_shared.github.types import GitHubRepoId, GitHubRepoLocation
311
+
312
+ try:
313
+ owner_repo = parse_git_remote_url(remote_url)
314
+ except ValueError:
315
+ return CheckResult(
316
+ name="workflow-permissions",
317
+ passed=True, # Info level
318
+ message="Not a GitHub repository",
319
+ )
320
+
321
+ repo_id = GitHubRepoId(owner=owner_repo[0], repo=owner_repo[1])
322
+ location = GitHubRepoLocation(root=repo_root, repo_id=repo_id)
323
+
324
+ try:
325
+ perms = admin.get_workflow_permissions(location)
326
+ enabled = perms.get("can_approve_pull_request_reviews", False)
327
+
328
+ if enabled:
329
+ return CheckResult(
330
+ name="workflow-permissions",
331
+ passed=True,
332
+ message="Workflows can create PRs",
333
+ )
334
+ else:
335
+ return CheckResult(
336
+ name="workflow-permissions",
337
+ passed=True, # Info level - always passes
338
+ message="Workflows cannot create PRs",
339
+ details="Run 'erk admin github-pr-setting --enable' to allow",
340
+ )
341
+ except Exception:
342
+ return CheckResult(
343
+ name="workflow-permissions",
344
+ passed=True, # Info level - don't fail on API errors
345
+ message="Could not check workflow permissions",
346
+ )
347
+
348
+
349
+ def check_uv_version(shell: Shell) -> CheckResult:
350
+ """Check if uv is installed.
351
+
352
+ Shows version and upgrade instructions. erk works best with recent uv versions.
353
+
354
+ Args:
355
+ shell: Shell implementation for tool detection
356
+ """
357
+ uv_path = shell.get_installed_tool_path("uv")
358
+ if uv_path is None:
359
+ return CheckResult(
360
+ name="uv",
361
+ passed=False,
362
+ message="uv not found in PATH",
363
+ details="Install from: https://docs.astral.sh/uv/getting-started/installation/",
364
+ )
365
+
366
+ # Get installed version
367
+ version_output = shell.get_tool_version("uv")
368
+ if version_output is None:
369
+ return CheckResult(
370
+ name="uv",
371
+ passed=True,
372
+ message="uv found (version check failed)",
373
+ details="Upgrade: uv self update",
374
+ )
375
+
376
+ # Parse version (format: "uv 0.9.2" or "uv 0.9.2 (Homebrew 2025-10-10)")
377
+ parts = version_output.split()
378
+ version = parts[1] if len(parts) >= 2 else version_output
379
+
380
+ return CheckResult(
381
+ name="uv",
382
+ passed=True,
383
+ message=f"uv installed: {version}",
384
+ )
385
+
386
+
387
+ def check_hooks_disabled(claude_installation: ClaudeInstallation) -> CheckResult:
388
+ """Check if Claude Code hooks are globally disabled.
389
+
390
+ Checks global settings for hooks.disabled=true via the ClaudeInstallation gateway.
391
+
392
+ Args:
393
+ claude_installation: Gateway for accessing Claude settings
394
+
395
+ Returns a warning (not failure) if hooks are disabled, since the user
396
+ may have intentionally disabled them.
397
+ """
398
+ disabled_in: list[str] = []
399
+
400
+ # Check global settings via gateway
401
+ settings = claude_installation.read_settings()
402
+ if settings:
403
+ hooks = settings.get("hooks", {})
404
+ if hooks.get("disabled") is True:
405
+ disabled_in.append("settings.json")
406
+
407
+ # Check local settings file directly (not yet in gateway)
408
+ local_settings_path = claude_installation.get_local_settings_path()
409
+ if local_settings_path.exists():
410
+ content = local_settings_path.read_text(encoding="utf-8")
411
+ local_settings = json.loads(content)
412
+ hooks = local_settings.get("hooks", {})
413
+ if hooks.get("disabled") is True:
414
+ disabled_in.append("settings.local.json")
415
+
416
+ if disabled_in:
417
+ return CheckResult(
418
+ name="claude-hooks",
419
+ passed=True, # Don't fail, just warn
420
+ warning=True,
421
+ message=f"Hooks disabled in {', '.join(disabled_in)}",
422
+ details="Set hooks.disabled=false or remove the setting to enable hooks",
423
+ )
424
+
425
+ return CheckResult(
426
+ name="claude-hooks",
427
+ passed=True,
428
+ message="Hooks enabled (not globally disabled)",
429
+ )
430
+
431
+
432
+ def check_statusline_configured(claude_installation: ClaudeInstallation) -> CheckResult:
433
+ """Check if erk-statusline is configured in global Claude settings.
434
+
435
+ This is an info-level check - it always passes, but informs users
436
+ they can configure the erk statusline feature.
437
+
438
+ Args:
439
+ claude_installation: Gateway for accessing Claude settings
440
+
441
+ Returns:
442
+ CheckResult with info about statusline status
443
+ """
444
+ # Read settings via gateway
445
+ if not claude_installation.settings_exists():
446
+ return CheckResult(
447
+ name="statusline",
448
+ passed=True,
449
+ message="No global Claude settings (statusline not configured)",
450
+ details="Run 'erk init --statusline' to enable erk statusline",
451
+ info=True,
452
+ )
453
+
454
+ settings = claude_installation.read_settings()
455
+
456
+ if has_erk_statusline(settings):
457
+ return CheckResult(
458
+ name="statusline",
459
+ passed=True,
460
+ message="erk-statusline configured",
461
+ )
462
+
463
+ # Check if a different statusline is configured
464
+ statusline_config = get_statusline_config(settings)
465
+ if not isinstance(statusline_config, StatuslineNotConfigured):
466
+ return CheckResult(
467
+ name="statusline",
468
+ passed=True,
469
+ message=f"Different statusline configured: {statusline_config.command}",
470
+ details="Run 'erk init --statusline' to switch to erk statusline",
471
+ info=True,
472
+ )
473
+
474
+ return CheckResult(
475
+ name="statusline",
476
+ passed=True,
477
+ message="erk-statusline not configured",
478
+ details="Run 'erk init --statusline' to enable erk statusline",
479
+ info=True,
480
+ )
481
+
482
+
483
+ def check_shell_integration(shell: Shell) -> CheckResult:
484
+ """Check if shell integration is configured in user's shell RC file.
485
+
486
+ This is an info-level check - it always passes, but informs users
487
+ whether shell integration is configured for their shell.
488
+
489
+ Args:
490
+ shell: Shell implementation for detecting current shell
491
+
492
+ Returns:
493
+ CheckResult with info about shell integration status
494
+ """
495
+ shell_info = shell.detect_shell()
496
+ if shell_info is None:
497
+ return CheckResult(
498
+ name="shell-integration",
499
+ passed=True,
500
+ message="Shell not detected (unsupported shell)",
501
+ info=True,
502
+ )
503
+
504
+ shell_name, rc_path = shell_info
505
+
506
+ if not rc_path.exists():
507
+ return CheckResult(
508
+ name="shell-integration",
509
+ passed=True,
510
+ message=f"Shell RC file not found ({rc_path.name})",
511
+ info=True,
512
+ )
513
+
514
+ if has_shell_integration_in_rc(rc_path):
515
+ return CheckResult(
516
+ name="shell-integration",
517
+ passed=True,
518
+ message=f"Shell integration configured ({shell_name})",
519
+ )
520
+
521
+ return CheckResult(
522
+ name="shell-integration",
523
+ passed=True,
524
+ message=f"Shell integration not configured ({shell_name})",
525
+ info=True,
526
+ remediation="Run 'erk init' to add shell integration",
527
+ )
528
+
529
+
530
+ def check_gitignore_entries(repo_root: Path) -> CheckResult:
531
+ """Check that required gitignore entries exist.
532
+
533
+ Args:
534
+ repo_root: Path to the repository root (where .gitignore should be located)
535
+
536
+ Returns:
537
+ CheckResult indicating whether required entries are present
538
+ """
539
+ required_entries = [".erk/scratch/", ".impl/"]
540
+ gitignore_path = repo_root / ".gitignore"
541
+
542
+ # No gitignore file - pass (user may not have one yet)
543
+ if not gitignore_path.exists():
544
+ return CheckResult(
545
+ name="gitignore",
546
+ passed=True,
547
+ message="No .gitignore file (entries not needed yet)",
548
+ )
549
+
550
+ gitignore_content = gitignore_path.read_text(encoding="utf-8")
551
+
552
+ # Check for missing entries
553
+ missing_entries: list[str] = []
554
+ for entry in required_entries:
555
+ if entry not in gitignore_content:
556
+ missing_entries.append(entry)
557
+
558
+ if missing_entries:
559
+ return CheckResult(
560
+ name="gitignore",
561
+ passed=False,
562
+ message=f"Missing gitignore entries: {', '.join(missing_entries)}",
563
+ remediation="Run 'erk init' to add missing entries",
564
+ )
565
+
566
+ return CheckResult(
567
+ name="gitignore",
568
+ passed=True,
569
+ message="Required gitignore entries present",
570
+ )
571
+
572
+
573
+ def check_post_plan_implement_ci_hook(repo_root: Path) -> CheckResult:
574
+ """Check for post-plan-implement CI instructions hook.
575
+
576
+ When the hook file exists and has content, this returns a success (green).
577
+ When missing, it returns info-level with the path to create.
578
+
579
+ Args:
580
+ repo_root: Path to the repository root
581
+
582
+ Returns:
583
+ CheckResult with CI hook status
584
+ """
585
+ hook_relative_path = ".erk/prompt-hooks/post-plan-implement-ci.md"
586
+ hook_path = repo_root / hook_relative_path
587
+
588
+ if hook_path.exists():
589
+ return CheckResult(
590
+ name="post-plan-implement-ci-hook",
591
+ passed=True,
592
+ message=f"CI instructions hook configured ({hook_relative_path})",
593
+ info=False,
594
+ )
595
+
596
+ return CheckResult(
597
+ name="post-plan-implement-ci-hook",
598
+ passed=True,
599
+ message=f"No CI instructions hook ({hook_relative_path})",
600
+ details=(
601
+ "Create .erk/prompt-hooks/post-plan-implement-ci.md "
602
+ "to add CI instructions for plan implementation"
603
+ ),
604
+ info=True,
605
+ )
606
+
607
+
608
+ def check_legacy_prompt_hooks(repo_root: Path) -> CheckResult:
609
+ """Check for legacy prompt hook files that should be migrated.
610
+
611
+ Checks if .erk/post-implement.md exists (old location) and suggests
612
+ migration to the new .erk/prompt-hooks/ structure.
613
+
614
+ Args:
615
+ repo_root: Path to the repository root
616
+
617
+ Returns:
618
+ CheckResult with migration suggestion if old location found
619
+ """
620
+ old_hook_path = repo_root / ".erk" / "post-implement.md"
621
+ new_hook_path = repo_root / ".erk" / "prompt-hooks" / "post-plan-implement-ci.md"
622
+
623
+ # Old location doesn't exist - all good
624
+ if not old_hook_path.exists():
625
+ return CheckResult(
626
+ name="legacy-prompt-hooks",
627
+ passed=True,
628
+ message="No legacy prompt hooks found",
629
+ )
630
+
631
+ # Old location exists and new location exists - user hasn't cleaned up
632
+ if new_hook_path.exists():
633
+ return CheckResult(
634
+ name="legacy-prompt-hooks",
635
+ passed=True,
636
+ warning=True,
637
+ message="Legacy prompt hook found alongside new location",
638
+ details=f"Remove old file: rm {old_hook_path.relative_to(repo_root)}",
639
+ )
640
+
641
+ # Old location exists, new location doesn't - needs migration
642
+ return CheckResult(
643
+ name="legacy-prompt-hooks",
644
+ passed=True,
645
+ warning=True,
646
+ message="Legacy prompt hook found (needs migration)",
647
+ details=(
648
+ f"Old: {old_hook_path.relative_to(repo_root)}\n"
649
+ f"New: {new_hook_path.relative_to(repo_root)}\n"
650
+ f"Run: mkdir -p .erk/prompt-hooks && "
651
+ f"mv {old_hook_path.relative_to(repo_root)} "
652
+ f"{new_hook_path.relative_to(repo_root)}"
653
+ ),
654
+ )
655
+
656
+
657
+ def check_claude_erk_permission(repo_root: Path) -> CheckResult:
658
+ """Check if erk permission is configured in repo's Claude Code settings.
659
+
660
+ This is an info-level check - it always passes, but shows whether
661
+ the permission is configured or not. The permission allows Claude
662
+ to run erk commands without prompting.
663
+
664
+ Args:
665
+ repo_root: Path to the repository root
666
+
667
+ Returns:
668
+ CheckResult with info about permission status
669
+ """
670
+ settings_path = get_repo_claude_settings_path(repo_root)
671
+ settings = read_claude_settings(settings_path)
672
+ if settings is None:
673
+ return CheckResult(
674
+ name="claude-erk-permission",
675
+ passed=True, # Info level - always passes
676
+ message="No .claude/settings.json in repo",
677
+ )
678
+
679
+ # Check for permission
680
+ if has_erk_permission(settings):
681
+ return CheckResult(
682
+ name="claude-erk-permission",
683
+ passed=True,
684
+ message=f"erk permission configured ({ERK_PERMISSION})",
685
+ )
686
+ else:
687
+ return CheckResult(
688
+ name="claude-erk-permission",
689
+ passed=True, # Info level - always passes
690
+ message="erk permission not configured",
691
+ details=f"Run 'erk init' to add {ERK_PERMISSION} to .claude/settings.json",
692
+ )
693
+
694
+
695
+ def check_plans_repo_labels(
696
+ repo_root: Path,
697
+ plans_repo: str,
698
+ github_issues: GitHubIssues,
699
+ ) -> CheckResult:
700
+ """Check that required erk labels exist in the plans repository.
701
+
702
+ When plans_repo is configured, issues are created in that repository.
703
+ This check verifies that all erk labels (erk-plan, erk-extraction,
704
+ erk-objective) exist in the target repository.
705
+
706
+ Args:
707
+ repo_root: Path to the working repository root (for gh CLI context)
708
+ plans_repo: Target repository in "owner/repo" format
709
+ github_issues: GitHubIssues interface (should be configured with target_repo)
710
+
711
+ Returns:
712
+ CheckResult indicating whether labels are present
713
+ """
714
+ labels = get_erk_label_definitions()
715
+ missing_labels: list[str] = []
716
+
717
+ # Check each label exists (LBYL pattern - check before reporting)
718
+ for label in labels:
719
+ if not github_issues.label_exists(repo_root, label.name):
720
+ missing_labels.append(label.name)
721
+
722
+ if missing_labels:
723
+ return CheckResult(
724
+ name="plans-repo-labels",
725
+ passed=False,
726
+ message=f"Missing labels in {plans_repo}: {', '.join(missing_labels)}",
727
+ remediation="Run 'erk init' to set up labels, or create them manually in GitHub",
728
+ )
729
+
730
+ return CheckResult(
731
+ name="plans-repo-labels",
732
+ passed=True,
733
+ message=f"Labels configured in {plans_repo}",
734
+ )
735
+
736
+
737
+ def check_repository(ctx: ErkContext) -> CheckResult:
738
+ """Check repository setup."""
739
+ # First check if we're in a git repo using git_common_dir
740
+ # (get_repository_root raises on non-git dirs, but git_common_dir returns None)
741
+ git_dir = ctx.git.get_git_common_dir(ctx.cwd)
742
+ if git_dir is None:
743
+ return CheckResult(
744
+ name="repository",
745
+ passed=False,
746
+ message="Not in a git repository",
747
+ )
748
+
749
+ # Now safe to get repo root
750
+ repo_root = ctx.git.get_repository_root(ctx.cwd)
751
+
752
+ # Check for .erk directory at repo root
753
+ erk_dir = repo_root / ".erk"
754
+ if not erk_dir.exists():
755
+ return CheckResult(
756
+ name="repository",
757
+ passed=True,
758
+ message="Git repository detected (no .erk/ directory)",
759
+ details="Run 'erk init' to set up erk for this repository",
760
+ )
761
+
762
+ return CheckResult(
763
+ name="repository",
764
+ passed=True,
765
+ message="Git repository with erk setup detected",
766
+ )
767
+
768
+
769
+ def check_claude_settings(repo_root: Path) -> CheckResult:
770
+ """Check Claude settings for misconfigurations.
771
+
772
+ Args:
773
+ repo_root: Path to the repository root (where .claude/ should be located)
774
+
775
+ Raises:
776
+ json.JSONDecodeError: If settings.json contains invalid JSON
777
+ """
778
+ settings_path = repo_root / ".claude" / "settings.json"
779
+ settings = read_claude_settings(settings_path)
780
+ if settings is None:
781
+ return CheckResult(
782
+ name="claude-settings",
783
+ passed=True,
784
+ message="No .claude/settings.json (using defaults)",
785
+ )
786
+
787
+ return CheckResult(
788
+ name="claude-settings",
789
+ passed=True,
790
+ message=".claude/settings.json looks valid",
791
+ )
792
+
793
+
794
+ def check_user_prompt_hook(repo_root: Path) -> CheckResult:
795
+ """Check that the UserPromptSubmit hook is configured.
796
+
797
+ Verifies that .claude/settings.json contains the erk exec user-prompt-hook
798
+ command for the UserPromptSubmit event.
799
+
800
+ Args:
801
+ repo_root: Path to the repository root (where .claude/ should be located)
802
+ """
803
+ settings_path = repo_root / ".claude" / "settings.json"
804
+ if not settings_path.exists():
805
+ return CheckResult(
806
+ name="user-prompt-hook",
807
+ passed=False,
808
+ message="No .claude/settings.json found",
809
+ remediation="Run 'erk init' to create settings with the hook configured",
810
+ )
811
+ # File exists, so read_claude_settings won't return None
812
+ settings = read_claude_settings(settings_path)
813
+ assert settings is not None # file existence already checked
814
+
815
+ # Look for UserPromptSubmit hooks
816
+ hooks = settings.get("hooks", {})
817
+ user_prompt_hooks = hooks.get("UserPromptSubmit", [])
818
+
819
+ if not user_prompt_hooks:
820
+ return CheckResult(
821
+ name="user-prompt-hook",
822
+ passed=False,
823
+ message="No UserPromptSubmit hook configured",
824
+ remediation="Add 'erk exec user-prompt-hook' hook to .claude/settings.json",
825
+ )
826
+
827
+ # Check if the unified hook is present (handles nested matcher structure)
828
+ expected_command = "erk exec user-prompt-hook"
829
+ for hook_entry in user_prompt_hooks:
830
+ if not isinstance(hook_entry, dict):
831
+ continue
832
+ # Handle nested structure: {matcher: ..., hooks: [...]}
833
+ nested_hooks = hook_entry.get("hooks", [])
834
+ if nested_hooks:
835
+ for hook in nested_hooks:
836
+ if not isinstance(hook, dict):
837
+ continue
838
+ command = hook.get("command", "")
839
+ if expected_command in command:
840
+ return CheckResult(
841
+ name="user-prompt-hook",
842
+ passed=True,
843
+ message="UserPromptSubmit hook configured",
844
+ )
845
+ # Handle flat structure: {type: command, command: ...}
846
+ command = hook_entry.get("command", "")
847
+ if expected_command in command:
848
+ return CheckResult(
849
+ name="user-prompt-hook",
850
+ passed=True,
851
+ message="UserPromptSubmit hook configured",
852
+ )
853
+
854
+ # Hook section exists but doesn't have the expected command
855
+ return CheckResult(
856
+ name="user-prompt-hook",
857
+ passed=False,
858
+ message="UserPromptSubmit hook missing unified hook script",
859
+ details=f"Expected command containing: {expected_command}",
860
+ )
861
+
862
+
863
+ def check_exit_plan_hook(repo_root: Path) -> CheckResult:
864
+ """Check that the ExitPlanMode hook is configured.
865
+
866
+ Verifies that .claude/settings.json contains the erk exec exit-plan-mode-hook
867
+ command for the PreToolUse ExitPlanMode matcher.
868
+
869
+ Args:
870
+ repo_root: Path to the repository root (where .claude/ should be located)
871
+ """
872
+ settings_path = repo_root / ".claude" / "settings.json"
873
+ if not settings_path.exists():
874
+ return CheckResult(
875
+ name="exit-plan-hook",
876
+ passed=False,
877
+ message="No .claude/settings.json found",
878
+ remediation="Run 'erk init' to create settings with the hook configured",
879
+ )
880
+ # File exists, so read_claude_settings won't return None
881
+ settings = read_claude_settings(settings_path)
882
+ assert settings is not None # file existence already checked
883
+
884
+ if has_exit_plan_hook(settings):
885
+ return CheckResult(
886
+ name="exit-plan-hook",
887
+ passed=True,
888
+ message="ExitPlanMode hook configured",
889
+ )
890
+
891
+ return CheckResult(
892
+ name="exit-plan-hook",
893
+ passed=False,
894
+ message="ExitPlanMode hook not configured",
895
+ remediation="Run 'erk init' to add the hook to .claude/settings.json",
896
+ )
897
+
898
+
899
+ def check_hook_health(repo_root: Path) -> CheckResult:
900
+ """Check hook execution health from recent logs.
901
+
902
+ Reads logs from .erk/scratch/sessions/*/hooks/*/*.json for the last 24 hours
903
+ and reports any failures (non-zero exit codes, exceptions).
904
+
905
+ Args:
906
+ repo_root: Path to the repository root
907
+
908
+ Returns:
909
+ CheckResult with hook health status
910
+ """
911
+ from erk_shared.hooks.logging import read_recent_hook_logs
912
+ from erk_shared.hooks.types import HookExitStatus
913
+
914
+ logs = read_recent_hook_logs(repo_root, max_age_hours=24)
915
+
916
+ if not logs:
917
+ return CheckResult(
918
+ name="hooks",
919
+ passed=True,
920
+ message="No hook logs in last 24h",
921
+ )
922
+
923
+ # Count by status
924
+ success_count = 0
925
+ blocked_count = 0
926
+ error_count = 0
927
+ exception_count = 0
928
+
929
+ # Track failures by hook for detailed reporting
930
+ failures_by_hook: dict[str, list[tuple[str, str]]] = {}
931
+
932
+ for log in logs:
933
+ if log.exit_status == HookExitStatus.SUCCESS:
934
+ success_count += 1
935
+ elif log.exit_status == HookExitStatus.BLOCKED:
936
+ blocked_count += 1
937
+ elif log.exit_status == HookExitStatus.ERROR:
938
+ error_count += 1
939
+ hook_key = f"{log.kit_id}/{log.hook_id}"
940
+ if hook_key not in failures_by_hook:
941
+ failures_by_hook[hook_key] = []
942
+ failures_by_hook[hook_key].append(
943
+ (f"error (exit code {log.exit_code})", log.stderr[:200] if log.stderr else "")
944
+ )
945
+ elif log.exit_status == HookExitStatus.EXCEPTION:
946
+ exception_count += 1
947
+ hook_key = f"{log.kit_id}/{log.hook_id}"
948
+ if hook_key not in failures_by_hook:
949
+ failures_by_hook[hook_key] = []
950
+ failures_by_hook[hook_key].append(
951
+ ("exception", log.error_message or log.stderr[:200] if log.stderr else "")
952
+ )
953
+
954
+ total_failures = error_count + exception_count
955
+ total_executions = success_count + blocked_count + error_count + exception_count
956
+
957
+ if total_failures == 0:
958
+ # Build verbose details showing execution stats
959
+ verbose_lines = [f"{total_executions} executions in last 24h"]
960
+ if success_count > 0:
961
+ verbose_lines.append(f" {success_count} successful")
962
+ if blocked_count > 0:
963
+ verbose_lines.append(f" {blocked_count} blocked (expected behavior)")
964
+ verbose_details = "\n".join(verbose_lines)
965
+
966
+ return CheckResult(
967
+ name="hooks",
968
+ passed=True,
969
+ message="Hooks healthy",
970
+ verbose_details=verbose_details,
971
+ )
972
+
973
+ # Build failure details
974
+ details_lines: list[str] = []
975
+ for hook_key, failures in failures_by_hook.items():
976
+ details_lines.append(f" {hook_key}: {len(failures)} failure(s)")
977
+ # Show most recent failure
978
+ if failures:
979
+ status, message = failures[0]
980
+ details_lines.append(f" Last failure: {status}")
981
+ if message:
982
+ # Truncate long messages
983
+ truncated = message[:100] + "..." if len(message) > 100 else message
984
+ details_lines.append(f" {truncated}")
985
+
986
+ return CheckResult(
987
+ name="hooks",
988
+ passed=False,
989
+ message=f"{total_failures} hook failure(s) in last 24h",
990
+ details="\n".join(details_lines),
991
+ )
992
+
993
+
994
+ def _worst_status(statuses: list[ArtifactStatusType]) -> ArtifactStatusType:
995
+ """Determine worst status from a list of statuses.
996
+
997
+ Priority: not-installed > locally-modified > changed-upstream > up-to-date
998
+ """
999
+ if "not-installed" in statuses:
1000
+ return "not-installed"
1001
+ if "locally-modified" in statuses:
1002
+ return "locally-modified"
1003
+ if "changed-upstream" in statuses:
1004
+ return "changed-upstream"
1005
+ return "up-to-date"
1006
+
1007
+
1008
+ def _extract_artifact_type(name: str) -> str:
1009
+ """Extract artifact type from artifact name.
1010
+
1011
+ Examples:
1012
+ skills/dignified-python → skills
1013
+ commands/erk/plan-implement.md → commands
1014
+ agents/devrun → agents
1015
+ workflows/erk-impl.yml → workflows
1016
+ actions/setup-claude-erk → actions
1017
+ hooks/user-prompt-hook → hooks
1018
+ """
1019
+ return name.split("/")[0]
1020
+
1021
+
1022
+ def _status_icon(status: ArtifactStatusType) -> str:
1023
+ """Get status icon for artifact status."""
1024
+ if status == "up-to-date":
1025
+ return "✅"
1026
+ if status == "locally-modified" or status == "changed-upstream":
1027
+ return "⚠️"
1028
+ return "❌"
1029
+
1030
+
1031
+ def _status_description(status: ArtifactStatusType, count: int) -> str:
1032
+ """Get human-readable status description."""
1033
+ if status == "not-installed":
1034
+ if count == 1:
1035
+ return "not installed"
1036
+ return f"{count} not installed"
1037
+ if status == "locally-modified":
1038
+ if count == 1:
1039
+ return "locally modified"
1040
+ return f"{count} locally modified"
1041
+ if status == "changed-upstream":
1042
+ if count == 1:
1043
+ return "changed upstream"
1044
+ return f"{count} changed upstream"
1045
+ return ""
1046
+
1047
+
1048
+ def _build_erk_repo_artifacts_result(result: ArtifactHealthResult) -> CheckResult:
1049
+ """Build CheckResult for erk repo case (all artifacts from source)."""
1050
+ # Group artifacts by type, storing names
1051
+ by_type: dict[str, list[str]] = {}
1052
+ for artifact in result.artifacts:
1053
+ artifact_type = _extract_artifact_type(artifact.name)
1054
+ # Extract display name (e.g. "skills/dignified-python" -> "dignified-python")
1055
+ display_name = artifact.name.split("/", 1)[1] if "/" in artifact.name else artifact.name
1056
+ by_type.setdefault(artifact_type, []).append(display_name)
1057
+
1058
+ # Build per-type summary (all ✅) and verbose details with individual names
1059
+ type_summaries: list[str] = []
1060
+ verbose_summaries: list[str] = []
1061
+ type_order = ["skills", "commands", "agents", "workflows", "actions", "hooks"]
1062
+ for artifact_type in type_order:
1063
+ if artifact_type not in by_type:
1064
+ continue
1065
+ names = sorted(by_type[artifact_type])
1066
+ type_summaries.append(f" ✅ {artifact_type} ({len(names)})")
1067
+ verbose_summaries.append(f" ✅ {artifact_type} ({len(names)})")
1068
+ for name in names:
1069
+ verbose_summaries.append(f" {name}")
1070
+
1071
+ details = "\n".join(type_summaries)
1072
+ verbose_details = "\n".join(verbose_summaries)
1073
+
1074
+ return CheckResult(
1075
+ name="managed-artifacts",
1076
+ passed=True,
1077
+ message="Managed artifacts (from source)",
1078
+ details=details,
1079
+ verbose_details=verbose_details,
1080
+ )
1081
+
1082
+
1083
+ @dataclass(frozen=True)
1084
+ class _ArtifactInfo:
1085
+ """Internal: artifact name and status for grouping."""
1086
+
1087
+ name: str
1088
+ status: ArtifactStatusType
1089
+
1090
+
1091
+ def _build_managed_artifacts_result(result: ArtifactHealthResult) -> CheckResult:
1092
+ """Build CheckResult from ArtifactHealthResult."""
1093
+ # Group artifacts by type, storing name and status
1094
+ by_type: dict[str, list[_ArtifactInfo]] = {}
1095
+ for artifact in result.artifacts:
1096
+ artifact_type = _extract_artifact_type(artifact.name)
1097
+ # Extract display name (e.g. "skills/dignified-python" -> "dignified-python")
1098
+ display_name = artifact.name.split("/", 1)[1] if "/" in artifact.name else artifact.name
1099
+ by_type.setdefault(artifact_type, []).append(
1100
+ _ArtifactInfo(name=display_name, status=artifact.status)
1101
+ )
1102
+
1103
+ # Build per-type summary and verbose details
1104
+ type_summaries: list[str] = []
1105
+ verbose_summaries: list[str] = []
1106
+ overall_worst: ArtifactStatusType = "up-to-date"
1107
+ has_issues = False
1108
+
1109
+ # Consistent type ordering
1110
+ type_order = ["skills", "commands", "agents", "workflows", "actions", "hooks"]
1111
+ for artifact_type in type_order:
1112
+ if artifact_type not in by_type:
1113
+ continue
1114
+
1115
+ artifacts = by_type[artifact_type]
1116
+ statuses: list[ArtifactStatusType] = [a.status for a in artifacts]
1117
+ count = len(statuses)
1118
+ worst = _worst_status(statuses)
1119
+
1120
+ # Track overall worst for header
1121
+ if overall_worst == "up-to-date":
1122
+ overall_worst = worst
1123
+ elif worst == "not-installed":
1124
+ overall_worst = "not-installed"
1125
+ elif worst in ("locally-modified", "changed-upstream") and overall_worst not in (
1126
+ "not-installed",
1127
+ ):
1128
+ overall_worst = worst
1129
+
1130
+ icon = _status_icon(worst)
1131
+ line = f" {icon} {artifact_type} ({count})"
1132
+
1133
+ # Add issue description if not up-to-date
1134
+ if worst != "up-to-date":
1135
+ has_issues = True
1136
+ issue_count = sum(1 for s in statuses if s == worst)
1137
+ desc = _status_description(worst, issue_count)
1138
+ line += f" - {desc}"
1139
+
1140
+ type_summaries.append(line)
1141
+ verbose_summaries.append(line)
1142
+
1143
+ # Add individual artifact names to verbose output
1144
+ for artifact_info in sorted(artifacts, key=lambda a: a.name):
1145
+ if artifact_info.status == "up-to-date":
1146
+ status_indicator = ""
1147
+ else:
1148
+ status_indicator = f" ({artifact_info.status})"
1149
+ verbose_summaries.append(f" {artifact_info.name}{status_indicator}")
1150
+
1151
+ details = "\n".join(type_summaries)
1152
+ verbose_details = "\n".join(verbose_summaries)
1153
+
1154
+ # Determine remediation
1155
+ remediation: str | None = None
1156
+ if overall_worst == "not-installed":
1157
+ remediation = "Run 'erk artifact sync' to restore missing artifacts"
1158
+
1159
+ # Determine overall result
1160
+ if overall_worst == "not-installed":
1161
+ return CheckResult(
1162
+ name="managed-artifacts",
1163
+ passed=False,
1164
+ message="Managed artifacts have issues",
1165
+ details=details,
1166
+ verbose_details=verbose_details,
1167
+ remediation=remediation,
1168
+ )
1169
+ elif has_issues:
1170
+ return CheckResult(
1171
+ name="managed-artifacts",
1172
+ passed=True,
1173
+ warning=True,
1174
+ message="Managed artifacts have issues",
1175
+ details=details,
1176
+ verbose_details=verbose_details,
1177
+ remediation=remediation,
1178
+ )
1179
+ else:
1180
+ return CheckResult(
1181
+ name="managed-artifacts",
1182
+ passed=True,
1183
+ message="Managed artifacts healthy",
1184
+ details=details,
1185
+ verbose_details=verbose_details,
1186
+ )
1187
+
1188
+
1189
+ def check_managed_artifacts(repo_root: Path) -> CheckResult:
1190
+ """Check status of erk-managed artifacts.
1191
+
1192
+ Shows a summary of artifact status by type (skills, commands, agents, etc.)
1193
+ with per-type counts and status indicators.
1194
+
1195
+ Args:
1196
+ repo_root: Path to the repository root
1197
+
1198
+ Returns:
1199
+ CheckResult with artifact health status
1200
+ """
1201
+ in_erk_repo = is_in_erk_repo(repo_root)
1202
+
1203
+ # Check for .claude/ directory
1204
+ claude_dir = repo_root / ".claude"
1205
+ if not claude_dir.exists():
1206
+ return CheckResult(
1207
+ name="managed-artifacts",
1208
+ passed=True,
1209
+ message="No .claude/ directory (nothing to check)",
1210
+ )
1211
+
1212
+ # Load saved artifact state
1213
+ state = load_artifact_state(repo_root)
1214
+ saved_files: dict[str, ArtifactFileState] = dict(state.files) if state else {}
1215
+
1216
+ # Get artifact health
1217
+ result = get_artifact_health(repo_root, saved_files)
1218
+
1219
+ # Handle skipped cases from get_artifact_health
1220
+ if result.skipped_reason == "no-claude-dir":
1221
+ return CheckResult(
1222
+ name="managed-artifacts",
1223
+ passed=True,
1224
+ message="No .claude/ directory (nothing to check)",
1225
+ )
1226
+
1227
+ if result.skipped_reason == "no-bundled-dir":
1228
+ return CheckResult(
1229
+ name="managed-artifacts",
1230
+ passed=True,
1231
+ message="Bundled .claude/ not found (skipping check)",
1232
+ )
1233
+
1234
+ # No artifacts to check
1235
+ if not result.artifacts:
1236
+ return CheckResult(
1237
+ name="managed-artifacts",
1238
+ passed=True,
1239
+ message="No managed artifacts found",
1240
+ )
1241
+
1242
+ # In erk repo, show counts without status comparison (all from source)
1243
+ if in_erk_repo:
1244
+ return _build_erk_repo_artifacts_result(result)
1245
+
1246
+ return _build_managed_artifacts_result(result)
1247
+
1248
+
1249
+ def run_all_checks(ctx: ErkContext) -> list[CheckResult]:
1250
+ """Run all health checks and return results.
1251
+
1252
+ Args:
1253
+ ctx: ErkContext for repository checks (includes github_admin)
1254
+
1255
+ Returns:
1256
+ List of CheckResult objects
1257
+ """
1258
+ shell = ctx.shell
1259
+ admin = ctx.github_admin
1260
+
1261
+ claude_installation = ctx.claude_installation
1262
+
1263
+ results = [
1264
+ check_erk_version(),
1265
+ check_claude_cli(shell),
1266
+ check_graphite_cli(shell),
1267
+ check_github_cli(shell),
1268
+ check_github_auth(shell, admin),
1269
+ check_uv_version(shell),
1270
+ check_hooks_disabled(claude_installation),
1271
+ check_statusline_configured(claude_installation),
1272
+ check_shell_integration(shell),
1273
+ ]
1274
+
1275
+ # Add repository check
1276
+ results.append(check_repository(ctx))
1277
+
1278
+ # Check Claude settings, gitignore, and GitHub checks if we're in a repo
1279
+ # (get_git_common_dir returns None if not in a repo)
1280
+ git_dir = ctx.git.get_git_common_dir(ctx.cwd)
1281
+ if git_dir is not None:
1282
+ repo_root = ctx.git.get_repository_root(ctx.cwd)
1283
+ results.append(check_claude_erk_permission(repo_root))
1284
+ results.append(check_claude_settings(repo_root))
1285
+ results.append(check_user_prompt_hook(repo_root))
1286
+ results.append(check_exit_plan_hook(repo_root))
1287
+ results.append(check_gitignore_entries(repo_root))
1288
+ results.append(check_required_tool_version(repo_root))
1289
+ results.append(check_legacy_prompt_hooks(repo_root))
1290
+ results.append(check_post_plan_implement_ci_hook(repo_root))
1291
+ # Hook health check
1292
+ results.append(check_hook_health(repo_root))
1293
+ # GitHub workflow permissions check (requires repo context)
1294
+ results.append(check_workflow_permissions(ctx, repo_root, admin))
1295
+ # Managed artifacts check (consolidated from orphaned + missing)
1296
+ results.append(check_managed_artifacts(repo_root))
1297
+
1298
+ # Check plans_repo labels if configured
1299
+ from erk.cli.config import load_config as load_repo_config
1300
+ from erk_shared.github.issues.real import RealGitHubIssues
1301
+
1302
+ repo_config = load_repo_config(repo_root)
1303
+ if repo_config.plans_repo is not None:
1304
+ github_issues = RealGitHubIssues(target_repo=repo_config.plans_repo)
1305
+ results.append(
1306
+ check_plans_repo_labels(repo_root, repo_config.plans_repo, github_issues)
1307
+ )
1308
+
1309
+ from erk.core.health_checks_dogfooder import run_early_dogfooder_checks
1310
+
1311
+ # Get metadata_dir if we have a RepoContext (for legacy config detection)
1312
+ metadata_dir = ctx.repo.repo_dir if isinstance(ctx.repo, RepoContext) else None
1313
+ results.extend(run_early_dogfooder_checks(repo_root, metadata_dir))
1314
+
1315
+ return results