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,623 @@
1
+ """Orphaned artifact detection for erk-managed .claude/ directories."""
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Literal
7
+
8
+ from erk.artifacts.detection import is_in_erk_repo
9
+ from erk.artifacts.discovery import (
10
+ _compute_directory_hash,
11
+ _compute_file_hash,
12
+ _compute_hook_hash,
13
+ )
14
+ from erk.artifacts.models import (
15
+ ArtifactFileState,
16
+ CompletenessCheckResult,
17
+ InstalledArtifact,
18
+ OrphanCheckResult,
19
+ )
20
+ from erk.artifacts.sync import get_bundled_claude_dir, get_bundled_github_dir
21
+ from erk.core.claude_settings import (
22
+ ERK_EXIT_PLAN_HOOK_COMMAND,
23
+ ERK_USER_PROMPT_HOOK_COMMAND,
24
+ has_exit_plan_hook,
25
+ has_user_prompt_hook,
26
+ )
27
+ from erk.core.release_notes import get_current_version
28
+
29
+ # Bundled artifacts that erk syncs to projects
30
+ BUNDLED_SKILLS = frozenset(
31
+ {
32
+ "dignified-python",
33
+ "learned-docs",
34
+ "erk-diff-analysis",
35
+ }
36
+ )
37
+ BUNDLED_AGENTS = frozenset({"devrun"})
38
+ BUNDLED_WORKFLOWS = frozenset({"erk-impl.yml"})
39
+ # Actions (composite GitHub actions) that erk syncs
40
+ BUNDLED_ACTIONS = frozenset({"setup-claude-erk"})
41
+ # Hook configurations that erk adds to settings.json
42
+ BUNDLED_HOOKS = frozenset({"user-prompt-hook", "exit-plan-mode-hook"})
43
+
44
+
45
+ def is_erk_managed(artifact: InstalledArtifact) -> bool:
46
+ """Check if artifact is managed by erk (bundled with erk package).
47
+
48
+ Args:
49
+ artifact: The artifact to check
50
+
51
+ Returns:
52
+ True if the artifact is bundled with erk, False if it's project-specific
53
+ """
54
+ if artifact.artifact_type == "command":
55
+ return artifact.name.startswith("erk:")
56
+ if artifact.artifact_type == "skill":
57
+ return artifact.name in BUNDLED_SKILLS
58
+ if artifact.artifact_type == "agent":
59
+ return artifact.name in BUNDLED_AGENTS
60
+ if artifact.artifact_type == "workflow":
61
+ return f"{artifact.name}.yml" in BUNDLED_WORKFLOWS
62
+ if artifact.artifact_type == "action":
63
+ return artifact.name in BUNDLED_ACTIONS
64
+ if artifact.artifact_type == "hook":
65
+ return artifact.name in BUNDLED_HOOKS
66
+ return False
67
+
68
+
69
+ # Status types for per-artifact version tracking
70
+ ArtifactStatusType = Literal["up-to-date", "changed-upstream", "locally-modified", "not-installed"]
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class ArtifactStatus:
75
+ """Per-artifact status comparing installed vs bundled state."""
76
+
77
+ name: str # e.g. "skills/dignified-python", "commands/erk/plan-implement.md"
78
+ installed_version: str | None # version at sync time, None if not tracked
79
+ current_version: str # current erk version
80
+ installed_hash: str | None # hash at sync time, None if not tracked
81
+ current_hash: str | None # current computed hash, None if not installed
82
+ status: ArtifactStatusType
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class ArtifactHealthResult:
87
+ """Result of per-artifact health check."""
88
+
89
+ artifacts: list[ArtifactStatus]
90
+ skipped_reason: Literal["erk-repo", "no-claude-dir", "no-bundled-dir"] | None
91
+
92
+
93
+ def _compute_path_hash(path: Path, is_directory: bool) -> str | None:
94
+ """Compute hash of a path, returning None if it doesn't exist.
95
+
96
+ Args:
97
+ path: Path to the file or directory
98
+ is_directory: True for directory hash, False for file hash
99
+ """
100
+ if not path.exists():
101
+ return None
102
+ if is_directory:
103
+ return _compute_directory_hash(path)
104
+ return _compute_file_hash(path)
105
+
106
+
107
+ def _determine_status(
108
+ installed_version: str | None,
109
+ current_version: str,
110
+ installed_hash: str | None,
111
+ current_hash: str | None,
112
+ ) -> ArtifactStatusType:
113
+ """Determine artifact status from version/hash comparison.
114
+
115
+ Logic:
116
+ - current_hash is None → not installed
117
+ - installed_hash != current_hash AND installed_version == current_version → locally modified
118
+ - installed_version != current_version → changed upstream
119
+ - Both match → up-to-date
120
+ """
121
+ if current_hash is None:
122
+ return "not-installed"
123
+
124
+ if installed_hash is None or installed_version is None:
125
+ # No prior state recorded - treat as changed upstream
126
+ return "changed-upstream"
127
+
128
+ if installed_hash != current_hash:
129
+ if installed_version == current_version:
130
+ # Hash changed but version didn't → local modification
131
+ return "locally-modified"
132
+ # Hash changed and version changed → upstream change
133
+ return "changed-upstream"
134
+
135
+ if installed_version != current_version:
136
+ # Version changed but hash didn't → still changed upstream
137
+ return "changed-upstream"
138
+
139
+ return "up-to-date"
140
+
141
+
142
+ def _build_artifact_status(
143
+ key: str,
144
+ current_hash: str | None,
145
+ saved_files: dict[str, ArtifactFileState],
146
+ current_version: str,
147
+ ) -> ArtifactStatus:
148
+ """Build ArtifactStatus from key, hash, and saved state."""
149
+ saved = saved_files.get(key)
150
+ return ArtifactStatus(
151
+ name=key,
152
+ installed_version=saved.version if saved else None,
153
+ current_version=current_version,
154
+ installed_hash=saved.hash if saved else None,
155
+ current_hash=current_hash,
156
+ status=_determine_status(
157
+ saved.version if saved else None,
158
+ current_version,
159
+ saved.hash if saved else None,
160
+ current_hash,
161
+ ),
162
+ )
163
+
164
+
165
+ def get_artifact_health(
166
+ project_dir: Path, saved_files: dict[str, ArtifactFileState]
167
+ ) -> ArtifactHealthResult:
168
+ """Get per-artifact health status comparing installed vs bundled state.
169
+
170
+ Args:
171
+ project_dir: Path to the project root
172
+ saved_files: Per-artifact state from .erk/state.toml (artifact key -> ArtifactFileState)
173
+
174
+ Returns:
175
+ ArtifactHealthResult with status for each bundled artifact
176
+ """
177
+ # Skip if no .claude/ directory
178
+ project_claude_dir = project_dir / ".claude"
179
+ if not project_claude_dir.exists():
180
+ return ArtifactHealthResult(artifacts=[], skipped_reason="no-claude-dir")
181
+
182
+ bundled_claude_dir = get_bundled_claude_dir()
183
+ if not bundled_claude_dir.exists():
184
+ return ArtifactHealthResult(artifacts=[], skipped_reason="no-bundled-dir")
185
+
186
+ project_workflows_dir = project_dir / ".github" / "workflows"
187
+ project_actions_dir = project_dir / ".github" / "actions"
188
+ current_version = get_current_version()
189
+
190
+ artifacts: list[ArtifactStatus] = []
191
+
192
+ # Check skills (always directory-based)
193
+ for name in BUNDLED_SKILLS:
194
+ key = f"skills/{name}"
195
+ path = project_claude_dir / "skills" / name
196
+ installed_hash = _compute_path_hash(path, is_directory=True)
197
+ artifacts.append(_build_artifact_status(key, installed_hash, saved_files, current_version))
198
+
199
+ # Check agents (can be directory-based or single-file)
200
+ # Key format depends on structure:
201
+ # - Directory: agents/{name} (like skills)
202
+ # - Single-file: agents/{name}.md (like commands)
203
+ for name in BUNDLED_AGENTS:
204
+ dir_path = project_claude_dir / "agents" / name
205
+ file_path = project_claude_dir / "agents" / f"{name}.md"
206
+
207
+ # Check bundled structure to determine canonical key format
208
+ bundled_dir = bundled_claude_dir / "agents" / name
209
+ bundled_file = bundled_claude_dir / "agents" / f"{name}.md"
210
+
211
+ # Directory-based takes precedence, then single-file
212
+ if bundled_dir.exists() and bundled_dir.is_dir():
213
+ key = f"agents/{name}"
214
+ installed_hash = _compute_path_hash(dir_path, is_directory=True)
215
+ elif bundled_file.exists() and bundled_file.is_file():
216
+ key = f"agents/{name}.md"
217
+ installed_hash = _compute_path_hash(file_path, is_directory=False)
218
+ elif dir_path.exists() and dir_path.is_dir():
219
+ # Fallback: check installed structure
220
+ key = f"agents/{name}"
221
+ installed_hash = _compute_path_hash(dir_path, is_directory=True)
222
+ elif file_path.exists() and file_path.is_file():
223
+ key = f"agents/{name}.md"
224
+ installed_hash = _compute_path_hash(file_path, is_directory=False)
225
+ else:
226
+ # Not installed anywhere - use single-file key as default for new agents
227
+ key = f"agents/{name}.md"
228
+ installed_hash = None
229
+
230
+ artifacts.append(_build_artifact_status(key, installed_hash, saved_files, current_version))
231
+
232
+ # Check commands (enumerate erk commands from bundled source)
233
+ bundled_erk_commands = bundled_claude_dir / "commands" / "erk"
234
+ if bundled_erk_commands.exists():
235
+ for cmd_file in sorted(bundled_erk_commands.glob("*.md")):
236
+ key = f"commands/erk/{cmd_file.name}"
237
+ path = project_claude_dir / "commands" / "erk" / cmd_file.name
238
+ installed_hash = _compute_path_hash(path, is_directory=False)
239
+ artifacts.append(
240
+ _build_artifact_status(key, installed_hash, saved_files, current_version)
241
+ )
242
+
243
+ # Check workflows
244
+ for workflow_name in BUNDLED_WORKFLOWS:
245
+ key = f"workflows/{workflow_name}"
246
+ path = project_workflows_dir / workflow_name
247
+ installed_hash = _compute_path_hash(path, is_directory=False)
248
+ artifacts.append(_build_artifact_status(key, installed_hash, saved_files, current_version))
249
+
250
+ # Check actions (always directory-based)
251
+ for name in BUNDLED_ACTIONS:
252
+ key = f"actions/{name}"
253
+ path = project_actions_dir / name
254
+ installed_hash = _compute_path_hash(path, is_directory=True)
255
+ artifacts.append(_build_artifact_status(key, installed_hash, saved_files, current_version))
256
+
257
+ # Check hooks
258
+ settings_path = project_claude_dir / "settings.json"
259
+ if settings_path.exists():
260
+ content = settings_path.read_text(encoding="utf-8")
261
+ settings = json.loads(content)
262
+
263
+ hook_checks = [
264
+ ("hooks/user-prompt-hook", has_user_prompt_hook, ERK_USER_PROMPT_HOOK_COMMAND),
265
+ ("hooks/exit-plan-mode-hook", has_exit_plan_hook, ERK_EXIT_PLAN_HOOK_COMMAND),
266
+ ]
267
+ for key, check_fn, command in hook_checks:
268
+ hook_hash = _compute_hook_hash(command) if check_fn(settings) else None
269
+ artifacts.append(_build_artifact_status(key, hook_hash, saved_files, current_version))
270
+ else:
271
+ # No settings.json - all hooks are not installed
272
+ for hook_name in ["user-prompt-hook", "exit-plan-mode-hook"]:
273
+ artifacts.append(
274
+ _build_artifact_status(f"hooks/{hook_name}", None, saved_files, current_version)
275
+ )
276
+
277
+ return ArtifactHealthResult(artifacts=artifacts, skipped_reason=None)
278
+
279
+
280
+ def _find_orphaned_in_directory(local_dir: Path, bundled_dir: Path, folder_key: str) -> list[str]:
281
+ """Find orphaned files in a directory (files in local but not in bundled)."""
282
+ if not local_dir.exists() or not bundled_dir.exists():
283
+ return []
284
+
285
+ bundled_files = {str(f.relative_to(bundled_dir)) for f in bundled_dir.rglob("*") if f.is_file()}
286
+ orphans: list[str] = []
287
+ for local_file in local_dir.rglob("*"):
288
+ if local_file.is_file():
289
+ relative_path = str(local_file.relative_to(local_dir))
290
+ if relative_path not in bundled_files:
291
+ orphans.append(relative_path)
292
+ return orphans
293
+
294
+
295
+ def _find_orphaned_claude_artifacts(
296
+ project_claude_dir: Path,
297
+ bundled_claude_dir: Path,
298
+ ) -> dict[str, list[str]]:
299
+ """Find files in bundled .claude/ folders that exist locally but not in package.
300
+
301
+ Compares bundled artifact directories with the local project's .claude/ directory
302
+ to find orphaned files that should be removed.
303
+
304
+ Args:
305
+ project_claude_dir: Path to project's .claude/ directory
306
+ bundled_claude_dir: Path to bundled .claude/ in erk package
307
+
308
+ Returns:
309
+ Dict mapping folder path (relative to .claude/) to list of orphaned filenames
310
+ """
311
+ orphans: dict[str, list[str]] = {}
312
+
313
+ # Check commands/erk/ directory
314
+ cmd_orphans = _find_orphaned_in_directory(
315
+ project_claude_dir / "commands" / "erk",
316
+ bundled_claude_dir / "commands" / "erk",
317
+ "commands/erk",
318
+ )
319
+ if cmd_orphans:
320
+ orphans["commands/erk"] = cmd_orphans
321
+
322
+ # Check directory-based artifacts (skills, agents)
323
+ for prefix, names in [("skills", BUNDLED_SKILLS), ("agents", BUNDLED_AGENTS)]:
324
+ for name in names:
325
+ folder_key = f"{prefix}/{name}"
326
+ dir_orphans = _find_orphaned_in_directory(
327
+ project_claude_dir / prefix / name,
328
+ bundled_claude_dir / prefix / name,
329
+ folder_key,
330
+ )
331
+ if dir_orphans:
332
+ orphans[folder_key] = dir_orphans
333
+
334
+ return orphans
335
+
336
+
337
+ def _find_orphaned_workflows(
338
+ project_workflows_dir: Path,
339
+ bundled_workflows_dir: Path,
340
+ ) -> dict[str, list[str]]:
341
+ """Find erk-managed workflow files that exist locally but not in package.
342
+
343
+ Only checks files that are in BUNDLED_WORKFLOWS - we don't want to flag
344
+ user workflows that erk doesn't manage.
345
+
346
+ Args:
347
+ project_workflows_dir: Path to project's .github/workflows/ directory
348
+ bundled_workflows_dir: Path to bundled .github/workflows/ in erk package
349
+
350
+ Returns:
351
+ Dict mapping ".github/workflows" to list of orphaned workflow filenames
352
+ """
353
+ if not project_workflows_dir.exists():
354
+ return {}
355
+ if not bundled_workflows_dir.exists():
356
+ return {}
357
+
358
+ orphans: dict[str, list[str]] = {}
359
+
360
+ # Only check erk-managed workflow files
361
+ for workflow_name in BUNDLED_WORKFLOWS:
362
+ local_workflow = project_workflows_dir / workflow_name
363
+ bundled_workflow = bundled_workflows_dir / workflow_name
364
+
365
+ # If file exists locally but not in bundled, it's orphaned
366
+ if local_workflow.exists() and not bundled_workflow.exists():
367
+ folder_key = ".github/workflows"
368
+ if folder_key not in orphans:
369
+ orphans[folder_key] = []
370
+ orphans[folder_key].append(workflow_name)
371
+
372
+ return orphans
373
+
374
+
375
+ def find_orphaned_artifacts(project_dir: Path) -> OrphanCheckResult:
376
+ """Find orphaned files in erk-managed artifact directories.
377
+
378
+ Compares local .claude/ and .github/ artifacts with bundled package to find files
379
+ that exist locally but are not in the current erk package version.
380
+
381
+ Args:
382
+ project_dir: Path to the project root
383
+
384
+ Returns:
385
+ OrphanCheckResult with orphan status
386
+ """
387
+ # Skip check in erk repo - artifacts are source, not synced
388
+ if is_in_erk_repo(project_dir):
389
+ return OrphanCheckResult(
390
+ orphans={},
391
+ skipped_reason="erk-repo",
392
+ )
393
+
394
+ # Skip if no .claude/ directory
395
+ project_claude_dir = project_dir / ".claude"
396
+ if not project_claude_dir.exists():
397
+ return OrphanCheckResult(
398
+ orphans={},
399
+ skipped_reason="no-claude-dir",
400
+ )
401
+
402
+ bundled_claude_dir = get_bundled_claude_dir()
403
+ if not bundled_claude_dir.exists():
404
+ return OrphanCheckResult(
405
+ orphans={},
406
+ skipped_reason="no-bundled-dir",
407
+ )
408
+
409
+ orphans = _find_orphaned_claude_artifacts(project_claude_dir, bundled_claude_dir)
410
+
411
+ # Also check for orphaned workflows
412
+ bundled_github_dir = get_bundled_github_dir()
413
+ project_workflows_dir = project_dir / ".github" / "workflows"
414
+ bundled_workflows_dir = bundled_github_dir / "workflows"
415
+ orphans.update(_find_orphaned_workflows(project_workflows_dir, bundled_workflows_dir))
416
+
417
+ return OrphanCheckResult(
418
+ orphans=orphans,
419
+ skipped_reason=None,
420
+ )
421
+
422
+
423
+ def _find_missing_in_directory(bundled_dir: Path, local_dir: Path) -> list[str]:
424
+ """Find missing files in a directory (files in bundled but not in local)."""
425
+ if not bundled_dir.exists():
426
+ return []
427
+
428
+ local_dir.mkdir(parents=True, exist_ok=True)
429
+ bundled_files = {str(f.relative_to(bundled_dir)) for f in bundled_dir.rglob("*") if f.is_file()}
430
+ local_files = {str(f.relative_to(local_dir)) for f in local_dir.rglob("*") if f.is_file()}
431
+ return sorted(bundled_files - local_files)
432
+
433
+
434
+ def _find_missing_claude_artifacts(
435
+ project_claude_dir: Path,
436
+ bundled_claude_dir: Path,
437
+ ) -> dict[str, list[str]]:
438
+ """Find files in bundled .claude/ that are missing locally.
439
+
440
+ Checks bundled → local direction (opposite of orphan detection).
441
+ Returns dict mapping folder path to list of missing filenames.
442
+
443
+ Args:
444
+ project_claude_dir: Path to project's .claude/ directory
445
+ bundled_claude_dir: Path to bundled .claude/ in erk package
446
+
447
+ Returns:
448
+ Dict mapping folder path (relative to .claude/) to list of missing filenames
449
+ """
450
+ missing: dict[str, list[str]] = {}
451
+
452
+ # Check commands/erk/ directory
453
+ cmd_missing = _find_missing_in_directory(
454
+ bundled_claude_dir / "commands" / "erk",
455
+ project_claude_dir / "commands" / "erk",
456
+ )
457
+ if cmd_missing:
458
+ missing["commands/erk"] = cmd_missing
459
+
460
+ # Check directory-based artifacts (skills, agents)
461
+ for prefix, names in [("skills", BUNDLED_SKILLS), ("agents", BUNDLED_AGENTS)]:
462
+ for name in names:
463
+ folder_key = f"{prefix}/{name}"
464
+ dir_missing = _find_missing_in_directory(
465
+ bundled_claude_dir / prefix / name,
466
+ project_claude_dir / prefix / name,
467
+ )
468
+ if dir_missing:
469
+ missing[folder_key] = dir_missing
470
+
471
+ return missing
472
+
473
+
474
+ def _find_missing_workflows(
475
+ project_workflows_dir: Path,
476
+ bundled_workflows_dir: Path,
477
+ ) -> dict[str, list[str]]:
478
+ """Find erk-managed workflows that exist in bundle but missing locally.
479
+
480
+ Args:
481
+ project_workflows_dir: Path to project's .github/workflows/ directory
482
+ bundled_workflows_dir: Path to bundled .github/workflows/ in erk package
483
+
484
+ Returns:
485
+ Dict mapping ".github/workflows" to list of missing workflow filenames
486
+ """
487
+ if not bundled_workflows_dir.exists():
488
+ return {}
489
+
490
+ project_workflows_dir.mkdir(parents=True, exist_ok=True)
491
+ missing: dict[str, list[str]] = {}
492
+
493
+ for workflow_name in BUNDLED_WORKFLOWS:
494
+ bundled_workflow = bundled_workflows_dir / workflow_name
495
+ local_workflow = project_workflows_dir / workflow_name
496
+
497
+ # If bundled but not local, it's missing
498
+ if bundled_workflow.exists() and not local_workflow.exists():
499
+ folder_key = ".github/workflows"
500
+ if folder_key not in missing:
501
+ missing[folder_key] = []
502
+ missing[folder_key].append(workflow_name)
503
+
504
+ return missing
505
+
506
+
507
+ def _find_missing_actions(
508
+ project_actions_dir: Path,
509
+ bundled_actions_dir: Path,
510
+ ) -> dict[str, list[str]]:
511
+ """Find erk-managed actions that exist in bundle but missing locally.
512
+
513
+ Args:
514
+ project_actions_dir: Path to project's .github/actions/ directory
515
+ bundled_actions_dir: Path to bundled .github/actions/ in erk package
516
+
517
+ Returns:
518
+ Dict mapping ".github/actions" to list of missing action names
519
+ """
520
+ if not bundled_actions_dir.exists():
521
+ return {}
522
+
523
+ missing: dict[str, list[str]] = {}
524
+
525
+ for action_name in BUNDLED_ACTIONS:
526
+ bundled_action = bundled_actions_dir / action_name
527
+ local_action = project_actions_dir / action_name
528
+
529
+ # If bundled but not local, it's missing
530
+ if bundled_action.exists() and not local_action.exists():
531
+ folder_key = ".github/actions"
532
+ if folder_key not in missing:
533
+ missing[folder_key] = []
534
+ missing[folder_key].append(action_name)
535
+
536
+ return missing
537
+
538
+
539
+ def _find_missing_hooks(project_claude_dir: Path) -> dict[str, list[str]]:
540
+ """Find erk-managed hooks that are missing from settings.json.
541
+
542
+ Args:
543
+ project_claude_dir: Path to project's .claude/ directory
544
+
545
+ Returns:
546
+ Dict mapping "settings.json" to list of missing hook names
547
+ """
548
+ settings_path = project_claude_dir / "settings.json"
549
+ missing: dict[str, list[str]] = {}
550
+
551
+ # If no settings.json, all hooks are missing
552
+ if not settings_path.exists():
553
+ return {"settings.json": sorted(BUNDLED_HOOKS)}
554
+
555
+ content = settings_path.read_text(encoding="utf-8")
556
+ settings = json.loads(content)
557
+
558
+ missing_hooks: list[str] = []
559
+
560
+ if not has_user_prompt_hook(settings):
561
+ missing_hooks.append("user-prompt-hook")
562
+
563
+ if not has_exit_plan_hook(settings):
564
+ missing_hooks.append("exit-plan-mode-hook")
565
+
566
+ if missing_hooks:
567
+ missing["settings.json"] = sorted(missing_hooks)
568
+
569
+ return missing
570
+
571
+
572
+ def find_missing_artifacts(project_dir: Path) -> CompletenessCheckResult:
573
+ """Find bundled artifacts that are missing from local installation.
574
+
575
+ Checks bundled → local direction to detect incomplete syncs.
576
+
577
+ Args:
578
+ project_dir: Path to the project root
579
+
580
+ Returns:
581
+ CompletenessCheckResult with missing artifact status
582
+ """
583
+ # Skip in erk repo - artifacts are source
584
+ if is_in_erk_repo(project_dir):
585
+ return CompletenessCheckResult(
586
+ missing={},
587
+ skipped_reason="erk-repo",
588
+ )
589
+
590
+ # Skip if no .claude/ directory
591
+ project_claude_dir = project_dir / ".claude"
592
+ if not project_claude_dir.exists():
593
+ return CompletenessCheckResult(
594
+ missing={},
595
+ skipped_reason="no-claude-dir",
596
+ )
597
+
598
+ bundled_claude_dir = get_bundled_claude_dir()
599
+ if not bundled_claude_dir.exists():
600
+ return CompletenessCheckResult(
601
+ missing={},
602
+ skipped_reason="no-bundled-dir",
603
+ )
604
+
605
+ missing = _find_missing_claude_artifacts(project_claude_dir, bundled_claude_dir)
606
+
607
+ # Check workflows and actions
608
+ bundled_github_dir = get_bundled_github_dir()
609
+ project_workflows_dir = project_dir / ".github" / "workflows"
610
+ bundled_workflows_dir = bundled_github_dir / "workflows"
611
+ missing.update(_find_missing_workflows(project_workflows_dir, bundled_workflows_dir))
612
+
613
+ project_actions_dir = project_dir / ".github" / "actions"
614
+ bundled_actions_dir = bundled_github_dir / "actions"
615
+ missing.update(_find_missing_actions(project_actions_dir, bundled_actions_dir))
616
+
617
+ # Check hooks in settings.json
618
+ missing.update(_find_missing_hooks(project_claude_dir))
619
+
620
+ return CompletenessCheckResult(
621
+ missing=missing,
622
+ skipped_reason=None,
623
+ )
@@ -0,0 +1,16 @@
1
+ """Detection utilities for artifact management."""
2
+
3
+ from pathlib import Path
4
+
5
+
6
+ def is_in_erk_repo(project_dir: Path) -> bool:
7
+ """Check if we're running inside the erk repository itself.
8
+
9
+ When running in the erk repo, artifacts are read from source
10
+ rather than synced from package data.
11
+ """
12
+ pyproject = project_dir / "pyproject.toml"
13
+ if not pyproject.exists():
14
+ return False
15
+ content = pyproject.read_text(encoding="utf-8")
16
+ return 'name = "erk"' in content