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,343 @@
1
+ """Discover artifacts installed in a project's .claude/ and .github/ directories."""
2
+
3
+ import hashlib
4
+ import json
5
+ from pathlib import Path
6
+
7
+ from erk.artifacts.models import ArtifactType, InstalledArtifact
8
+ from erk.core.claude_settings import (
9
+ ERK_EXIT_PLAN_HOOK_COMMAND,
10
+ ERK_USER_PROMPT_HOOK_COMMAND,
11
+ )
12
+
13
+
14
+ def _compute_file_hash(path: Path) -> str:
15
+ """Compute SHA256 hash of single file content."""
16
+ return hashlib.sha256(path.read_bytes()).hexdigest()[:16]
17
+
18
+
19
+ def _compute_directory_hash(path: Path) -> str:
20
+ """Compute combined hash of all files in directory.
21
+
22
+ Includes relative path in hash to detect structural changes (renames, moves).
23
+ Files are processed in sorted order for deterministic hashing.
24
+ """
25
+ hasher = hashlib.sha256()
26
+ for file_path in sorted(path.rglob("*")):
27
+ if file_path.is_file():
28
+ # Include relative path in hash for structural changes
29
+ hasher.update(file_path.relative_to(path).as_posix().encode())
30
+ hasher.update(file_path.read_bytes())
31
+ return hasher.hexdigest()[:16]
32
+
33
+
34
+ def _compute_hook_hash(command: str) -> str:
35
+ """Compute hash of hook command string."""
36
+ return hashlib.sha256(command.encode()).hexdigest()[:16]
37
+
38
+
39
+ def _compute_content_hash(path: Path) -> str:
40
+ """Compute SHA256 hash of file or directory content.
41
+
42
+ For files: hashes the file content directly.
43
+ For directories: hashes all files with their relative paths.
44
+ """
45
+ if path.is_dir():
46
+ return _compute_directory_hash(path)
47
+ return _compute_file_hash(path)
48
+
49
+
50
+ def _discover_skills(claude_dir: Path) -> list[InstalledArtifact]:
51
+ """Discover skills in .claude/skills/ directory.
52
+
53
+ Skills are identified by their SKILL.md entry point file.
54
+ Pattern: skills/<skill-name>/SKILL.md
55
+
56
+ Content hash is computed over the entire skill directory (all files),
57
+ not just the SKILL.md entry point.
58
+ """
59
+ skills_dir = claude_dir / "skills"
60
+ if not skills_dir.exists():
61
+ return []
62
+
63
+ artifacts: list[InstalledArtifact] = []
64
+ for skill_dir in skills_dir.iterdir():
65
+ if not skill_dir.is_dir():
66
+ continue
67
+ skill_file = skill_dir / "SKILL.md"
68
+ if skill_file.exists():
69
+ artifacts.append(
70
+ InstalledArtifact(
71
+ name=skill_dir.name,
72
+ artifact_type="skill",
73
+ path=skill_file,
74
+ # Hash the entire skill directory, not just SKILL.md
75
+ content_hash=_compute_directory_hash(skill_dir),
76
+ )
77
+ )
78
+ return artifacts
79
+
80
+
81
+ def _discover_commands(claude_dir: Path) -> list[InstalledArtifact]:
82
+ """Discover commands in .claude/commands/ directory.
83
+
84
+ Commands can be:
85
+ - Top-level: commands/<command>.md (no namespace)
86
+ - Namespaced: commands/<namespace>/<command>.md
87
+ """
88
+ commands_dir = claude_dir / "commands"
89
+ if not commands_dir.exists():
90
+ return []
91
+
92
+ artifacts: list[InstalledArtifact] = []
93
+
94
+ # Discover top-level commands (no namespace)
95
+ for cmd_file in commands_dir.glob("*.md"):
96
+ artifacts.append(
97
+ InstalledArtifact(
98
+ name=cmd_file.stem,
99
+ artifact_type="command",
100
+ path=cmd_file,
101
+ content_hash=_compute_content_hash(cmd_file),
102
+ )
103
+ )
104
+
105
+ # Discover namespaced commands
106
+ for namespace_dir in commands_dir.iterdir():
107
+ if not namespace_dir.is_dir():
108
+ continue
109
+ for cmd_file in namespace_dir.glob("*.md"):
110
+ # Name includes namespace: "local:fast-ci" or "erk:plan-implement"
111
+ name = f"{namespace_dir.name}:{cmd_file.stem}"
112
+ artifacts.append(
113
+ InstalledArtifact(
114
+ name=name,
115
+ artifact_type="command",
116
+ path=cmd_file,
117
+ content_hash=_compute_content_hash(cmd_file),
118
+ )
119
+ )
120
+ return artifacts
121
+
122
+
123
+ def _discover_agents(claude_dir: Path) -> list[InstalledArtifact]:
124
+ """Discover agents in .claude/agents/ directory.
125
+
126
+ Supports two patterns:
127
+ 1. Directory-based: agents/<name>/<name>.md (hash computed over entire directory)
128
+ 2. Single-file: agents/<name>.md (hash computed over single file)
129
+
130
+ Directory-based agents take precedence if both exist.
131
+ """
132
+ agents_dir = claude_dir / "agents"
133
+ if not agents_dir.exists():
134
+ return []
135
+
136
+ artifacts: list[InstalledArtifact] = []
137
+
138
+ # First, discover directory-based agents: agents/<name>/<name>.md
139
+ for item in agents_dir.iterdir():
140
+ if item.is_dir():
141
+ agent_file = item / f"{item.name}.md"
142
+ if agent_file.exists():
143
+ artifacts.append(
144
+ InstalledArtifact(
145
+ name=item.name,
146
+ artifact_type="agent",
147
+ path=agent_file,
148
+ content_hash=_compute_directory_hash(item),
149
+ )
150
+ )
151
+
152
+ # Track discovered names to avoid duplicates
153
+ discovered_names = {a.name for a in artifacts}
154
+
155
+ # Second, discover single-file agents: agents/<name>.md
156
+ for item in agents_dir.iterdir():
157
+ if item.is_file() and item.suffix == ".md":
158
+ name = item.stem
159
+ if name not in discovered_names:
160
+ artifacts.append(
161
+ InstalledArtifact(
162
+ name=name,
163
+ artifact_type="agent",
164
+ path=item,
165
+ content_hash=_compute_file_hash(item),
166
+ )
167
+ )
168
+
169
+ return artifacts
170
+
171
+
172
+ def _discover_workflows(workflows_dir: Path) -> list[InstalledArtifact]:
173
+ """Discover all workflows in .github/workflows/ directory.
174
+
175
+ Discovers all .yml and .yaml files in the workflows directory.
176
+
177
+ Pattern: .github/workflows/<name>.yml
178
+ """
179
+ if not workflows_dir.exists():
180
+ return []
181
+
182
+ artifacts: list[InstalledArtifact] = []
183
+ for workflow_file in workflows_dir.iterdir():
184
+ if not workflow_file.is_file():
185
+ continue
186
+ if workflow_file.suffix not in (".yml", ".yaml"):
187
+ continue
188
+ artifacts.append(
189
+ InstalledArtifact(
190
+ name=workflow_file.stem,
191
+ artifact_type="workflow",
192
+ path=workflow_file,
193
+ content_hash=_compute_content_hash(workflow_file),
194
+ )
195
+ )
196
+ return artifacts
197
+
198
+
199
+ def _discover_actions(actions_dir: Path) -> list[InstalledArtifact]:
200
+ """Discover all actions in .github/actions/ directory.
201
+
202
+ Actions are directories containing an action.yml or action.yaml file.
203
+
204
+ Pattern: .github/actions/<name>/action.yml
205
+ """
206
+ if not actions_dir.exists():
207
+ return []
208
+
209
+ artifacts: list[InstalledArtifact] = []
210
+ for action_path in actions_dir.iterdir():
211
+ if not action_path.is_dir():
212
+ continue
213
+ # Look for action.yml or action.yaml
214
+ action_file = action_path / "action.yml"
215
+ if not action_file.exists():
216
+ action_file = action_path / "action.yaml"
217
+ if not action_file.exists():
218
+ continue
219
+ artifacts.append(
220
+ InstalledArtifact(
221
+ name=action_path.name,
222
+ artifact_type="action",
223
+ path=action_file,
224
+ content_hash=_compute_directory_hash(action_path),
225
+ )
226
+ )
227
+ return artifacts
228
+
229
+
230
+ def _extract_hook_name(command: str) -> str:
231
+ """Extract a meaningful name from a hook command.
232
+
233
+ For erk hooks, returns the known name.
234
+ For local hooks, returns the full command text for identification.
235
+ """
236
+ # Check for erk-managed hooks first
237
+ if command == ERK_USER_PROMPT_HOOK_COMMAND:
238
+ return "user-prompt-hook"
239
+ if command == ERK_EXIT_PLAN_HOOK_COMMAND:
240
+ return "exit-plan-mode-hook"
241
+
242
+ # For local hooks, use the full command text as the identifier
243
+ return command
244
+
245
+
246
+ def _discover_hooks(claude_dir: Path) -> list[InstalledArtifact]:
247
+ """Discover all hooks configured in .claude/settings.json.
248
+
249
+ Hooks are configuration entries in settings.json, not files.
250
+ Discovers both erk-managed hooks and local/user-defined hooks.
251
+
252
+ Pattern: hooks.<HookType>[].hooks[].command
253
+ """
254
+ settings_path = claude_dir / "settings.json"
255
+ if not settings_path.exists():
256
+ return []
257
+
258
+ content = settings_path.read_text(encoding="utf-8")
259
+ settings = json.loads(content)
260
+ hooks_section = settings.get("hooks", {})
261
+ if not hooks_section:
262
+ return []
263
+
264
+ artifacts: list[InstalledArtifact] = []
265
+ seen_names: set[str] = set()
266
+
267
+ # Iterate through all hook types (UserPromptSubmit, PreToolUse, etc.)
268
+ for hook_entries in hooks_section.values():
269
+ if not isinstance(hook_entries, list):
270
+ continue
271
+ for entry in hook_entries:
272
+ if not isinstance(entry, dict):
273
+ continue
274
+ for hook in entry.get("hooks", []):
275
+ if not isinstance(hook, dict):
276
+ continue
277
+ command = hook.get("command")
278
+ if not command:
279
+ continue
280
+
281
+ name = _extract_hook_name(command)
282
+
283
+ # Avoid duplicates
284
+ if name in seen_names:
285
+ continue
286
+ seen_names.add(name)
287
+
288
+ artifacts.append(
289
+ InstalledArtifact(
290
+ name=name,
291
+ artifact_type="hook",
292
+ path=settings_path,
293
+ content_hash=_compute_hook_hash(command),
294
+ )
295
+ )
296
+
297
+ return artifacts
298
+
299
+
300
+ def discover_artifacts(project_dir: Path) -> list[InstalledArtifact]:
301
+ """Scan project directory and return all installed artifacts.
302
+
303
+ Discovers:
304
+ - skills: .claude/skills/<name>/SKILL.md
305
+ - commands: .claude/commands/<namespace>/<name>.md
306
+ - agents: .claude/agents/<name>/<name>.md
307
+ - workflows: .github/workflows/<name>.yml (all workflows)
308
+ - actions: .github/actions/<name>/action.yml (all actions)
309
+ - hooks: configured in .claude/settings.json
310
+ """
311
+ claude_dir = project_dir / ".claude"
312
+ workflows_dir = project_dir / ".github" / "workflows"
313
+ actions_dir = project_dir / ".github" / "actions"
314
+
315
+ artifacts: list[InstalledArtifact] = []
316
+
317
+ if claude_dir.exists():
318
+ artifacts.extend(_discover_skills(claude_dir))
319
+ artifacts.extend(_discover_commands(claude_dir))
320
+ artifacts.extend(_discover_agents(claude_dir))
321
+ artifacts.extend(_discover_hooks(claude_dir))
322
+
323
+ artifacts.extend(_discover_workflows(workflows_dir))
324
+ artifacts.extend(_discover_actions(actions_dir))
325
+
326
+ # Sort by type then name for consistent output
327
+ return sorted(artifacts, key=lambda a: (a.artifact_type, a.name))
328
+
329
+
330
+ def get_artifact_by_name(
331
+ project_dir: Path, name: str, artifact_type: ArtifactType | None
332
+ ) -> InstalledArtifact | None:
333
+ """Find a specific artifact by name.
334
+
335
+ If artifact_type is provided, only search that type.
336
+ Otherwise, search all types and return first match.
337
+ """
338
+ artifacts = discover_artifacts(project_dir)
339
+ for artifact in artifacts:
340
+ if artifact.name == name:
341
+ if artifact_type is None or artifact.artifact_type == artifact_type:
342
+ return artifact
343
+ return None
@@ -0,0 +1,63 @@
1
+ """Data models for artifact management."""
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Literal
7
+
8
+ # Type of artifact based on directory structure in .claude/ or .github/
9
+ # Note: "hook" is not file-based like others; it's a config entry in settings.json
10
+ ArtifactType = Literal["skill", "command", "agent", "workflow", "action", "hook"]
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class InstalledArtifact:
15
+ """An artifact installed in a project's .claude/ directory."""
16
+
17
+ name: str
18
+ artifact_type: ArtifactType
19
+ path: Path
20
+ # Content hash for staleness detection (optional)
21
+ content_hash: str | None
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class ArtifactFileState:
26
+ """Per-artifact state tracking version and hash at sync time."""
27
+
28
+ version: str # erk version when this artifact was synced
29
+ hash: str # content hash at sync time
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class ArtifactState:
34
+ """State stored in .erk/state.toml tracking installed artifacts."""
35
+
36
+ version: str # last full sync version (keep for backwards compat)
37
+ files: Mapping[str, ArtifactFileState] # key: artifact path like "skills/dignified-python"
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class StalenessResult:
42
+ """Result of checking artifact staleness."""
43
+
44
+ is_stale: bool
45
+ reason: Literal["not-initialized", "version-mismatch", "up-to-date", "erk-repo"]
46
+ current_version: str
47
+ installed_version: str | None
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class OrphanCheckResult:
52
+ """Result of checking for orphaned artifacts."""
53
+
54
+ orphans: dict[str, list[str]] # folder -> list of orphaned filenames
55
+ skipped_reason: Literal["erk-repo", "no-claude-dir", "no-bundled-dir"] | None
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class CompletenessCheckResult:
60
+ """Result of checking for missing bundled artifacts."""
61
+
62
+ missing: dict[str, list[str]] # folder -> list of missing filenames
63
+ skipped_reason: Literal["erk-repo", "no-claude-dir", "no-bundled-dir"] | None
@@ -0,0 +1,56 @@
1
+ """Check artifact staleness by comparing installed vs current erk version."""
2
+
3
+ from pathlib import Path
4
+
5
+ from erk.artifacts.detection import is_in_erk_repo
6
+ from erk.artifacts.models import StalenessResult
7
+ from erk.artifacts.state import load_artifact_state
8
+ from erk.core.release_notes import get_current_version
9
+
10
+
11
+ def check_staleness(project_dir: Path) -> StalenessResult:
12
+ """Check if artifacts are stale compared to current erk version.
13
+
14
+ Returns a StalenessResult with:
15
+ - is_stale: True if artifacts need to be synced
16
+ - reason: "not-initialized", "version-mismatch", "up-to-date", or "erk-repo"
17
+ - current_version: The installed erk package version
18
+ - installed_version: The version artifacts were last synced from (or None)
19
+ """
20
+ current_version = get_current_version()
21
+
22
+ # In erk repo, artifacts are read from source - always up to date
23
+ # Still load state.toml to dogfood the state loading path
24
+ if is_in_erk_repo(project_dir):
25
+ state = load_artifact_state(project_dir)
26
+ return StalenessResult(
27
+ is_stale=False,
28
+ reason="erk-repo",
29
+ current_version=current_version,
30
+ installed_version=state.version if state else None,
31
+ )
32
+
33
+ state = load_artifact_state(project_dir)
34
+
35
+ if state is None:
36
+ return StalenessResult(
37
+ is_stale=True,
38
+ reason="not-initialized",
39
+ current_version=current_version,
40
+ installed_version=None,
41
+ )
42
+
43
+ if state.version != current_version:
44
+ return StalenessResult(
45
+ is_stale=True,
46
+ reason="version-mismatch",
47
+ current_version=current_version,
48
+ installed_version=state.version,
49
+ )
50
+
51
+ return StalenessResult(
52
+ is_stale=False,
53
+ reason="up-to-date",
54
+ current_version=current_version,
55
+ installed_version=state.version,
56
+ )
erk/artifacts/state.py ADDED
@@ -0,0 +1,100 @@
1
+ """Load and save artifact state from .erk/state.toml."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import tomli
7
+ import tomli_w
8
+
9
+ from erk.artifacts.models import ArtifactFileState, ArtifactState
10
+
11
+
12
+ def _state_file_path(project_dir: Path) -> Path:
13
+ """Return path to state file."""
14
+ return project_dir / ".erk" / "state.toml"
15
+
16
+
17
+ def load_artifact_state(project_dir: Path) -> ArtifactState | None:
18
+ """Load artifact state from .erk/state.toml.
19
+
20
+ Returns None if the state file doesn't exist, has no artifacts section,
21
+ or is missing the required files section.
22
+
23
+ Format:
24
+ [artifacts]
25
+ version = "0.3.1"
26
+
27
+ [artifacts.files."skills/dignified-python"]
28
+ version = "0.3.0"
29
+ hash = "a1b2c3d4e5f6g7h8"
30
+ """
31
+ path = _state_file_path(project_dir)
32
+ if not path.exists():
33
+ return None
34
+
35
+ with path.open("rb") as f:
36
+ data = tomli.load(f)
37
+
38
+ if "artifacts" not in data:
39
+ return None
40
+
41
+ artifacts_data = data["artifacts"]
42
+ if "version" not in artifacts_data:
43
+ return None
44
+
45
+ # Require files section
46
+ if "files" not in artifacts_data:
47
+ return None
48
+
49
+ files: dict[str, ArtifactFileState] = {}
50
+ files_data = artifacts_data["files"]
51
+
52
+ for artifact_path, file_data in files_data.items():
53
+ if isinstance(file_data, dict) and "version" in file_data and "hash" in file_data:
54
+ files[artifact_path] = ArtifactFileState(
55
+ version=file_data["version"],
56
+ hash=file_data["hash"],
57
+ )
58
+
59
+ return ArtifactState(version=artifacts_data["version"], files=files)
60
+
61
+
62
+ def save_artifact_state(project_dir: Path, state: ArtifactState) -> None:
63
+ """Save artifact state to .erk/state.toml.
64
+
65
+ Creates the .erk/ directory and state.toml file if they don't exist.
66
+ Preserves other sections in the file if it already exists.
67
+
68
+ Format:
69
+ [artifacts]
70
+ version = "0.3.1"
71
+
72
+ [artifacts.files."skills/dignified-python"]
73
+ version = "0.3.0"
74
+ hash = "a1b2c3d4e5f6g7h8"
75
+ """
76
+ path = _state_file_path(project_dir)
77
+ path.parent.mkdir(parents=True, exist_ok=True)
78
+
79
+ # Load existing data to preserve other sections
80
+ existing_data: dict[str, Any] = {}
81
+ if path.exists():
82
+ with path.open("rb") as f:
83
+ existing_data = tomli.load(f)
84
+
85
+ # Build files section
86
+ files_data: dict[str, dict[str, str]] = {}
87
+ for artifact_path, file_state in state.files.items():
88
+ files_data[artifact_path] = {
89
+ "version": file_state.version,
90
+ "hash": file_state.hash,
91
+ }
92
+
93
+ # Update artifacts section
94
+ existing_data["artifacts"] = {
95
+ "version": state.version,
96
+ "files": files_data,
97
+ }
98
+
99
+ with path.open("wb") as f:
100
+ tomli_w.dump(existing_data, f)