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.
- erk/__init__.py +12 -0
- erk/__main__.py +6 -0
- erk/agent_docs/__init__.py +5 -0
- erk/agent_docs/models.py +123 -0
- erk/agent_docs/operations.py +666 -0
- erk/artifacts/__init__.py +5 -0
- erk/artifacts/artifact_health.py +623 -0
- erk/artifacts/detection.py +16 -0
- erk/artifacts/discovery.py +343 -0
- erk/artifacts/models.py +63 -0
- erk/artifacts/staleness.py +56 -0
- erk/artifacts/state.py +100 -0
- erk/artifacts/sync.py +624 -0
- erk/cli/__init__.py +0 -0
- erk/cli/activation.py +132 -0
- erk/cli/alias.py +53 -0
- erk/cli/cli.py +221 -0
- erk/cli/commands/__init__.py +0 -0
- erk/cli/commands/admin.py +153 -0
- erk/cli/commands/artifact/__init__.py +1 -0
- erk/cli/commands/artifact/check.py +260 -0
- erk/cli/commands/artifact/group.py +31 -0
- erk/cli/commands/artifact/list_cmd.py +89 -0
- erk/cli/commands/artifact/show.py +62 -0
- erk/cli/commands/artifact/sync_cmd.py +39 -0
- erk/cli/commands/branch/__init__.py +26 -0
- erk/cli/commands/branch/assign_cmd.py +152 -0
- erk/cli/commands/branch/checkout_cmd.py +357 -0
- erk/cli/commands/branch/create_cmd.py +161 -0
- erk/cli/commands/branch/list_cmd.py +82 -0
- erk/cli/commands/branch/unassign_cmd.py +197 -0
- erk/cli/commands/cc/__init__.py +15 -0
- erk/cli/commands/cc/jsonl_cmd.py +20 -0
- erk/cli/commands/cc/session/AGENTS.md +30 -0
- erk/cli/commands/cc/session/CLAUDE.md +1 -0
- erk/cli/commands/cc/session/__init__.py +15 -0
- erk/cli/commands/cc/session/list_cmd.py +167 -0
- erk/cli/commands/cc/session/show_cmd.py +175 -0
- erk/cli/commands/completion.py +89 -0
- erk/cli/commands/completions.py +165 -0
- erk/cli/commands/config.py +327 -0
- erk/cli/commands/docs/__init__.py +1 -0
- erk/cli/commands/docs/group.py +16 -0
- erk/cli/commands/docs/sync.py +121 -0
- erk/cli/commands/docs/validate.py +102 -0
- erk/cli/commands/doctor.py +243 -0
- erk/cli/commands/down.py +171 -0
- erk/cli/commands/exec/__init__.py +1 -0
- erk/cli/commands/exec/group.py +164 -0
- erk/cli/commands/exec/scripts/AGENTS.md +79 -0
- erk/cli/commands/exec/scripts/CLAUDE.md +1 -0
- erk/cli/commands/exec/scripts/__init__.py +5 -0
- erk/cli/commands/exec/scripts/add_reaction_to_comment.py +69 -0
- erk/cli/commands/exec/scripts/add_remote_execution_note.py +68 -0
- erk/cli/commands/exec/scripts/check_impl.py +152 -0
- erk/cli/commands/exec/scripts/ci_update_pr_body.py +294 -0
- erk/cli/commands/exec/scripts/create_extraction_branch.py +138 -0
- erk/cli/commands/exec/scripts/create_extraction_plan.py +242 -0
- erk/cli/commands/exec/scripts/create_issue_from_session.py +103 -0
- erk/cli/commands/exec/scripts/create_plan_from_context.py +103 -0
- erk/cli/commands/exec/scripts/create_worker_impl_from_issue.py +93 -0
- erk/cli/commands/exec/scripts/detect_trunk_branch.py +121 -0
- erk/cli/commands/exec/scripts/exit_plan_mode_hook.py +777 -0
- erk/cli/commands/exec/scripts/extract_latest_plan.py +49 -0
- erk/cli/commands/exec/scripts/extract_session_from_issue.py +150 -0
- erk/cli/commands/exec/scripts/find_project_dir.py +214 -0
- erk/cli/commands/exec/scripts/generate_pr_summary.py +112 -0
- erk/cli/commands/exec/scripts/get_closing_text.py +98 -0
- erk/cli/commands/exec/scripts/get_embedded_prompt.py +62 -0
- erk/cli/commands/exec/scripts/get_plan_metadata.py +95 -0
- erk/cli/commands/exec/scripts/get_pr_body_footer.py +70 -0
- erk/cli/commands/exec/scripts/get_pr_discussion_comments.py +149 -0
- erk/cli/commands/exec/scripts/get_pr_review_comments.py +155 -0
- erk/cli/commands/exec/scripts/impl_init.py +158 -0
- erk/cli/commands/exec/scripts/impl_signal.py +375 -0
- erk/cli/commands/exec/scripts/impl_verify.py +49 -0
- erk/cli/commands/exec/scripts/issue_title_to_filename.py +34 -0
- erk/cli/commands/exec/scripts/list_sessions.py +296 -0
- erk/cli/commands/exec/scripts/mark_impl_ended.py +188 -0
- erk/cli/commands/exec/scripts/mark_impl_started.py +188 -0
- erk/cli/commands/exec/scripts/marker.py +163 -0
- erk/cli/commands/exec/scripts/objective_save_to_issue.py +109 -0
- erk/cli/commands/exec/scripts/plan_save_to_issue.py +269 -0
- erk/cli/commands/exec/scripts/plan_update_issue.py +147 -0
- erk/cli/commands/exec/scripts/post_extraction_comment.py +237 -0
- erk/cli/commands/exec/scripts/post_or_update_pr_summary.py +133 -0
- erk/cli/commands/exec/scripts/post_pr_inline_comment.py +143 -0
- erk/cli/commands/exec/scripts/post_workflow_started_comment.py +168 -0
- erk/cli/commands/exec/scripts/preprocess_session.py +777 -0
- erk/cli/commands/exec/scripts/quick_submit.py +32 -0
- erk/cli/commands/exec/scripts/rebase_with_conflict_resolution.py +260 -0
- erk/cli/commands/exec/scripts/reply_to_discussion_comment.py +173 -0
- erk/cli/commands/exec/scripts/resolve_review_thread.py +170 -0
- erk/cli/commands/exec/scripts/session_id_injector_hook.py +52 -0
- erk/cli/commands/exec/scripts/setup_impl_from_issue.py +159 -0
- erk/cli/commands/exec/scripts/slot_objective.py +102 -0
- erk/cli/commands/exec/scripts/tripwires_reminder_hook.py +20 -0
- erk/cli/commands/exec/scripts/update_dispatch_info.py +116 -0
- erk/cli/commands/exec/scripts/user_prompt_hook.py +113 -0
- erk/cli/commands/exec/scripts/validate_plan_content.py +98 -0
- erk/cli/commands/exec/scripts/wrap_plan_in_metadata_block.py +34 -0
- erk/cli/commands/implement.py +695 -0
- erk/cli/commands/implement_shared.py +649 -0
- erk/cli/commands/info/__init__.py +14 -0
- erk/cli/commands/info/release_notes_cmd.py +128 -0
- erk/cli/commands/init.py +801 -0
- erk/cli/commands/land_cmd.py +690 -0
- erk/cli/commands/log_cmd.py +137 -0
- erk/cli/commands/md/__init__.py +5 -0
- erk/cli/commands/md/check.py +118 -0
- erk/cli/commands/md/group.py +14 -0
- erk/cli/commands/navigation_helpers.py +430 -0
- erk/cli/commands/objective/__init__.py +16 -0
- erk/cli/commands/objective/list_cmd.py +47 -0
- erk/cli/commands/objective_helpers.py +132 -0
- erk/cli/commands/plan/__init__.py +32 -0
- erk/cli/commands/plan/check_cmd.py +174 -0
- erk/cli/commands/plan/close_cmd.py +69 -0
- erk/cli/commands/plan/create_cmd.py +120 -0
- erk/cli/commands/plan/docs/__init__.py +18 -0
- erk/cli/commands/plan/docs/extract_cmd.py +53 -0
- erk/cli/commands/plan/docs/unextract_cmd.py +38 -0
- erk/cli/commands/plan/docs/unextracted_cmd.py +72 -0
- erk/cli/commands/plan/extraction/__init__.py +16 -0
- erk/cli/commands/plan/extraction/complete_cmd.py +101 -0
- erk/cli/commands/plan/extraction/create_raw_cmd.py +63 -0
- erk/cli/commands/plan/get.py +71 -0
- erk/cli/commands/plan/list_cmd.py +754 -0
- erk/cli/commands/plan/log_cmd.py +440 -0
- erk/cli/commands/plan/start_cmd.py +459 -0
- erk/cli/commands/planner/__init__.py +40 -0
- erk/cli/commands/planner/configure_cmd.py +73 -0
- erk/cli/commands/planner/connect_cmd.py +96 -0
- erk/cli/commands/planner/create_cmd.py +148 -0
- erk/cli/commands/planner/list_cmd.py +51 -0
- erk/cli/commands/planner/register_cmd.py +105 -0
- erk/cli/commands/planner/set_default_cmd.py +23 -0
- erk/cli/commands/planner/unregister_cmd.py +43 -0
- erk/cli/commands/pr/__init__.py +23 -0
- erk/cli/commands/pr/check_cmd.py +112 -0
- erk/cli/commands/pr/checkout_cmd.py +165 -0
- erk/cli/commands/pr/fix_conflicts_cmd.py +82 -0
- erk/cli/commands/pr/parse_pr_reference.py +10 -0
- erk/cli/commands/pr/submit_cmd.py +360 -0
- erk/cli/commands/pr/sync_cmd.py +181 -0
- erk/cli/commands/prepare_cwd_recovery.py +60 -0
- erk/cli/commands/project/__init__.py +16 -0
- erk/cli/commands/project/init_cmd.py +91 -0
- erk/cli/commands/run/__init__.py +17 -0
- erk/cli/commands/run/list_cmd.py +189 -0
- erk/cli/commands/run/logs_cmd.py +54 -0
- erk/cli/commands/run/shared.py +19 -0
- erk/cli/commands/shell_integration.py +29 -0
- erk/cli/commands/slot/__init__.py +23 -0
- erk/cli/commands/slot/check_cmd.py +277 -0
- erk/cli/commands/slot/common.py +314 -0
- erk/cli/commands/slot/init_pool_cmd.py +157 -0
- erk/cli/commands/slot/list_cmd.py +228 -0
- erk/cli/commands/slot/repair_cmd.py +190 -0
- erk/cli/commands/stack/__init__.py +23 -0
- erk/cli/commands/stack/consolidate_cmd.py +470 -0
- erk/cli/commands/stack/list_cmd.py +79 -0
- erk/cli/commands/stack/move_cmd.py +309 -0
- erk/cli/commands/stack/split_old/README.md +64 -0
- erk/cli/commands/stack/split_old/__init__.py +5 -0
- erk/cli/commands/stack/split_old/command.py +233 -0
- erk/cli/commands/stack/split_old/display.py +116 -0
- erk/cli/commands/stack/split_old/plan.py +216 -0
- erk/cli/commands/status.py +58 -0
- erk/cli/commands/submit.py +768 -0
- erk/cli/commands/up.py +154 -0
- erk/cli/commands/upgrade.py +82 -0
- erk/cli/commands/wt/__init__.py +29 -0
- erk/cli/commands/wt/checkout_cmd.py +110 -0
- erk/cli/commands/wt/create_cmd.py +998 -0
- erk/cli/commands/wt/current_cmd.py +35 -0
- erk/cli/commands/wt/delete_cmd.py +573 -0
- erk/cli/commands/wt/list_cmd.py +332 -0
- erk/cli/commands/wt/rename_cmd.py +66 -0
- erk/cli/config.py +242 -0
- erk/cli/constants.py +29 -0
- erk/cli/core.py +65 -0
- erk/cli/debug.py +9 -0
- erk/cli/ensure-conversion-tasks.md +288 -0
- erk/cli/ensure.py +628 -0
- erk/cli/github_parsing.py +96 -0
- erk/cli/graphite.py +81 -0
- erk/cli/graphite_command.py +80 -0
- erk/cli/help_formatter.py +345 -0
- erk/cli/output.py +361 -0
- erk/cli/presets/dagster.toml +12 -0
- erk/cli/presets/generic.toml +12 -0
- erk/cli/prompt_hooks_templates/README.md +68 -0
- erk/cli/script_output.py +32 -0
- erk/cli/shell_integration/bash_wrapper.sh +32 -0
- erk/cli/shell_integration/fish_wrapper.fish +39 -0
- erk/cli/shell_integration/handler.py +338 -0
- erk/cli/shell_integration/zsh_wrapper.sh +32 -0
- erk/cli/shell_utils.py +171 -0
- erk/cli/subprocess_utils.py +92 -0
- erk/cli/uvx_detection.py +59 -0
- erk/core/__init__.py +0 -0
- erk/core/claude_executor.py +511 -0
- erk/core/claude_settings.py +317 -0
- erk/core/command_log.py +406 -0
- erk/core/commit_message_generator.py +234 -0
- erk/core/completion.py +10 -0
- erk/core/consolidation_utils.py +177 -0
- erk/core/context.py +570 -0
- erk/core/display/__init__.py +4 -0
- erk/core/display/abc.py +24 -0
- erk/core/display/real.py +30 -0
- erk/core/display_utils.py +526 -0
- erk/core/file_utils.py +87 -0
- erk/core/health_checks.py +1315 -0
- erk/core/health_checks_dogfooder/__init__.py +85 -0
- erk/core/health_checks_dogfooder/deprecated_dot_agent_config.py +64 -0
- erk/core/health_checks_dogfooder/legacy_claude_docs.py +69 -0
- erk/core/health_checks_dogfooder/legacy_config_locations.py +122 -0
- erk/core/health_checks_dogfooder/legacy_erk_docs_agent.py +61 -0
- erk/core/health_checks_dogfooder/legacy_erk_kits_folder.py +60 -0
- erk/core/health_checks_dogfooder/legacy_hook_settings.py +104 -0
- erk/core/health_checks_dogfooder/legacy_kit_yaml.py +78 -0
- erk/core/health_checks_dogfooder/legacy_kits_toml.py +43 -0
- erk/core/health_checks_dogfooder/outdated_erk_skill.py +43 -0
- erk/core/implementation_queue/__init__.py +1 -0
- erk/core/implementation_queue/github/__init__.py +8 -0
- erk/core/implementation_queue/github/abc.py +7 -0
- erk/core/implementation_queue/github/noop.py +38 -0
- erk/core/implementation_queue/github/printing.py +43 -0
- erk/core/implementation_queue/github/real.py +119 -0
- erk/core/init_utils.py +227 -0
- erk/core/output_filter.py +338 -0
- erk/core/plan_store/__init__.py +6 -0
- erk/core/planner/__init__.py +1 -0
- erk/core/planner/registry_abc.py +8 -0
- erk/core/planner/registry_fake.py +129 -0
- erk/core/planner/registry_real.py +195 -0
- erk/core/planner/types.py +7 -0
- erk/core/pr_utils.py +30 -0
- erk/core/release_notes.py +263 -0
- erk/core/repo_discovery.py +126 -0
- erk/core/script_writer.py +41 -0
- erk/core/services/__init__.py +1 -0
- erk/core/services/plan_list_service.py +94 -0
- erk/core/shell.py +51 -0
- erk/core/user_feedback.py +11 -0
- erk/core/version_check.py +55 -0
- erk/core/workflow_display.py +75 -0
- erk/core/worktree_pool.py +190 -0
- erk/core/worktree_utils.py +300 -0
- erk/data/CHANGELOG.md +438 -0
- erk/data/__init__.py +1 -0
- erk/data/claude/agents/devrun.md +180 -0
- erk/data/claude/commands/erk/__init__.py +0 -0
- erk/data/claude/commands/erk/create-extraction-plan.md +360 -0
- erk/data/claude/commands/erk/fix-conflicts.md +25 -0
- erk/data/claude/commands/erk/git-pr-push.md +345 -0
- erk/data/claude/commands/erk/implement-stacked-plan.md +96 -0
- erk/data/claude/commands/erk/land.md +193 -0
- erk/data/claude/commands/erk/objective-create.md +370 -0
- erk/data/claude/commands/erk/objective-list.md +34 -0
- erk/data/claude/commands/erk/objective-next-plan.md +220 -0
- erk/data/claude/commands/erk/objective-update-with-landed-pr.md +216 -0
- erk/data/claude/commands/erk/plan-implement.md +202 -0
- erk/data/claude/commands/erk/plan-save.md +45 -0
- erk/data/claude/commands/erk/plan-submit.md +39 -0
- erk/data/claude/commands/erk/pr-address.md +367 -0
- erk/data/claude/commands/erk/pr-submit.md +58 -0
- erk/data/claude/skills/dignified-python/SKILL.md +48 -0
- erk/data/claude/skills/dignified-python/cli-patterns.md +155 -0
- erk/data/claude/skills/dignified-python/dignified-python-core.md +1190 -0
- erk/data/claude/skills/dignified-python/subprocess.md +99 -0
- erk/data/claude/skills/dignified-python/versions/python-3.10.md +517 -0
- erk/data/claude/skills/dignified-python/versions/python-3.11.md +536 -0
- erk/data/claude/skills/dignified-python/versions/python-3.12.md +662 -0
- erk/data/claude/skills/dignified-python/versions/python-3.13.md +653 -0
- erk/data/claude/skills/erk-diff-analysis/SKILL.md +27 -0
- erk/data/claude/skills/erk-diff-analysis/references/commit-message-prompt.md +78 -0
- erk/data/claude/skills/learned-docs/SKILL.md +362 -0
- erk/data/github/actions/setup-claude-erk/action.yml +11 -0
- erk/data/github/prompts/dignified-python-review.md +125 -0
- erk/data/github/workflows/dignified-python-review.yml +61 -0
- erk/data/github/workflows/erk-impl.yml +251 -0
- erk/hooks/__init__.py +1 -0
- erk/hooks/decorators.py +319 -0
- erk/status/__init__.py +8 -0
- erk/status/collectors/__init__.py +9 -0
- erk/status/collectors/base.py +52 -0
- erk/status/collectors/git.py +76 -0
- erk/status/collectors/github.py +81 -0
- erk/status/collectors/graphite.py +80 -0
- erk/status/collectors/impl.py +145 -0
- erk/status/models/__init__.py +4 -0
- erk/status/models/status_data.py +404 -0
- erk/status/orchestrator.py +169 -0
- erk/status/renderers/__init__.py +5 -0
- erk/status/renderers/simple.py +322 -0
- erk/tui/AGENTS.md +193 -0
- erk/tui/CLAUDE.md +1 -0
- erk/tui/__init__.py +1 -0
- erk/tui/app.py +1404 -0
- erk/tui/commands/__init__.py +1 -0
- erk/tui/commands/executor.py +66 -0
- erk/tui/commands/provider.py +165 -0
- erk/tui/commands/real_executor.py +63 -0
- erk/tui/commands/registry.py +121 -0
- erk/tui/commands/types.py +36 -0
- erk/tui/data/__init__.py +1 -0
- erk/tui/data/provider.py +492 -0
- erk/tui/data/types.py +104 -0
- erk/tui/filtering/__init__.py +1 -0
- erk/tui/filtering/logic.py +43 -0
- erk/tui/filtering/types.py +55 -0
- erk/tui/jsonl_viewer/__init__.py +1 -0
- erk/tui/jsonl_viewer/app.py +61 -0
- erk/tui/jsonl_viewer/models.py +208 -0
- erk/tui/jsonl_viewer/widgets.py +204 -0
- erk/tui/sorting/__init__.py +6 -0
- erk/tui/sorting/logic.py +55 -0
- erk/tui/sorting/types.py +68 -0
- erk/tui/styles/dash.tcss +95 -0
- erk/tui/widgets/__init__.py +1 -0
- erk/tui/widgets/command_output.py +112 -0
- erk/tui/widgets/plan_table.py +276 -0
- erk/tui/widgets/status_bar.py +116 -0
- erk-0.4.5.dist-info/METADATA +376 -0
- erk-0.4.5.dist-info/RECORD +331 -0
- erk-0.4.5.dist-info/WHEEL +4 -0
- erk-0.4.5.dist-info/entry_points.txt +2 -0
- erk-0.4.5.dist-info/licenses/LICENSE.md +3 -0
erk/core/command_log.py
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"""Command logging for erk CLI audit trail.
|
|
2
|
+
|
|
3
|
+
This module provides logging of all erk CLI invocations with context,
|
|
4
|
+
similar to zsh's ~/.zsh_history. Used for debugging issues like
|
|
5
|
+
"what command deleted my worktree".
|
|
6
|
+
|
|
7
|
+
Log location: ~/.erk/command_history.jsonl
|
|
8
|
+
Format: One JSON object per line (JSONL) for easy parsing and appending.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import atexit
|
|
12
|
+
import fcntl
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from datetime import UTC, datetime, timedelta
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from erk_shared.gateway.erk_installation.abc import ErkInstallation
|
|
21
|
+
from erk_shared.gateway.erk_installation.real import RealErkInstallation
|
|
22
|
+
from erk_shared.git.real import RealGit
|
|
23
|
+
|
|
24
|
+
# Environment variable to disable command logging
|
|
25
|
+
ENV_DISABLE_LOG = "ERK_NO_COMMAND_LOG"
|
|
26
|
+
|
|
27
|
+
# Maximum log file size in bytes (50MB)
|
|
28
|
+
MAX_LOG_SIZE_BYTES = 50 * 1024 * 1024
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class CommandLogEntry:
|
|
33
|
+
"""A single command log entry."""
|
|
34
|
+
|
|
35
|
+
timestamp: str
|
|
36
|
+
command: str
|
|
37
|
+
args: tuple[str, ...]
|
|
38
|
+
cwd: str
|
|
39
|
+
branch: str | None
|
|
40
|
+
exit_code: int | None
|
|
41
|
+
session_id: str | None
|
|
42
|
+
pid: int
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _get_log_file_path(installation: ErkInstallation | None = None) -> Path:
|
|
46
|
+
"""Return path to command history log file.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
installation: ErkInstallation instance. If None, uses RealErkInstallation.
|
|
50
|
+
Passing None is for CLI entry points before context exists.
|
|
51
|
+
"""
|
|
52
|
+
if installation is None:
|
|
53
|
+
installation = RealErkInstallation()
|
|
54
|
+
return installation.get_command_log_path()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _is_logging_disabled() -> bool:
|
|
58
|
+
"""Check if command logging is disabled via environment variable."""
|
|
59
|
+
return os.environ.get(ENV_DISABLE_LOG) == "1"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _get_current_branch(cwd: Path) -> str | None:
|
|
63
|
+
"""Get current git branch if in a git repository."""
|
|
64
|
+
git = RealGit()
|
|
65
|
+
# get_git_common_dir returns None gracefully when outside a git repo,
|
|
66
|
+
# whereas get_repository_root raises RuntimeError
|
|
67
|
+
git_dir = git.get_git_common_dir(cwd)
|
|
68
|
+
if git_dir is None:
|
|
69
|
+
return None
|
|
70
|
+
repo_root = git.get_repository_root(cwd)
|
|
71
|
+
return git.get_current_branch(repo_root)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _get_session_id() -> str | None:
|
|
75
|
+
"""Get Claude Code session ID if available."""
|
|
76
|
+
return os.environ.get("CLAUDE_CODE_SESSION_ID")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _rotate_log_if_needed(log_path: Path) -> None:
|
|
80
|
+
"""Rotate log file if it exceeds maximum size."""
|
|
81
|
+
if not log_path.exists():
|
|
82
|
+
return
|
|
83
|
+
if log_path.stat().st_size <= MAX_LOG_SIZE_BYTES:
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
# Rotate: rename current to .old (overwriting previous .old)
|
|
87
|
+
old_path = log_path.with_suffix(".jsonl.old")
|
|
88
|
+
if old_path.exists():
|
|
89
|
+
old_path.unlink()
|
|
90
|
+
log_path.rename(old_path)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _write_entry(log_path: Path, entry_dict: dict[str, str | int | list[str] | None]) -> None:
|
|
94
|
+
"""Write a log entry with file locking for concurrent writes."""
|
|
95
|
+
# Ensure parent directory exists
|
|
96
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
|
|
98
|
+
# Rotate if needed (before acquiring lock to avoid holding lock during rename)
|
|
99
|
+
_rotate_log_if_needed(log_path)
|
|
100
|
+
|
|
101
|
+
# Open with append mode and exclusive lock
|
|
102
|
+
with log_path.open("a", encoding="utf-8") as f:
|
|
103
|
+
# Acquire exclusive lock for concurrent write safety
|
|
104
|
+
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
|
105
|
+
try:
|
|
106
|
+
f.write(json.dumps(entry_dict) + "\n")
|
|
107
|
+
finally:
|
|
108
|
+
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def log_command_start(args: list[str], cwd: Path) -> str | None:
|
|
112
|
+
"""Log command invocation at start.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
args: Command line arguments (sys.argv[1:])
|
|
116
|
+
cwd: Current working directory
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
Entry ID (timestamp) for use with log_command_end, or None if logging disabled
|
|
120
|
+
"""
|
|
121
|
+
if _is_logging_disabled():
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
timestamp = datetime.now(UTC).isoformat()
|
|
125
|
+
|
|
126
|
+
# Build command string from args
|
|
127
|
+
# Extract up to 2 non-flag args as subcommand (e.g., "wt delete")
|
|
128
|
+
# Remaining args are treated as command arguments
|
|
129
|
+
command = "erk"
|
|
130
|
+
remaining_args = list(args)
|
|
131
|
+
subcommand_parts: list[str] = []
|
|
132
|
+
|
|
133
|
+
# Take at most 2 subcommand parts (e.g., "wt delete" but not "wt delete foo")
|
|
134
|
+
max_subcommand_depth = 2
|
|
135
|
+
for arg in args:
|
|
136
|
+
if arg.startswith("-") or len(subcommand_parts) >= max_subcommand_depth:
|
|
137
|
+
break
|
|
138
|
+
subcommand_parts.append(arg)
|
|
139
|
+
remaining_args = remaining_args[1:]
|
|
140
|
+
|
|
141
|
+
if subcommand_parts:
|
|
142
|
+
command = f"erk {' '.join(subcommand_parts)}"
|
|
143
|
+
|
|
144
|
+
entry = {
|
|
145
|
+
"timestamp": timestamp,
|
|
146
|
+
"command": command,
|
|
147
|
+
"args": remaining_args,
|
|
148
|
+
"cwd": str(cwd),
|
|
149
|
+
"branch": _get_current_branch(cwd),
|
|
150
|
+
"exit_code": None, # Will be filled by log_command_end
|
|
151
|
+
"session_id": _get_session_id(),
|
|
152
|
+
"pid": os.getpid(),
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
log_path = _get_log_file_path()
|
|
156
|
+
_write_entry(log_path, entry)
|
|
157
|
+
|
|
158
|
+
return timestamp
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def log_command_end(entry_id: str | None, exit_code: int) -> None:
|
|
162
|
+
"""Log command completion with exit code.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
entry_id: Entry ID from log_command_start (timestamp)
|
|
166
|
+
exit_code: Process exit code (0 = success)
|
|
167
|
+
"""
|
|
168
|
+
if entry_id is None or _is_logging_disabled():
|
|
169
|
+
return
|
|
170
|
+
|
|
171
|
+
# Write a completion entry that references the start entry
|
|
172
|
+
completion_entry = {
|
|
173
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
|
174
|
+
"type": "completion",
|
|
175
|
+
"start_timestamp": entry_id,
|
|
176
|
+
"exit_code": exit_code,
|
|
177
|
+
"pid": os.getpid(),
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
log_path = _get_log_file_path()
|
|
181
|
+
_write_entry(log_path, completion_entry)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def read_log_entries(
|
|
185
|
+
since: datetime | None,
|
|
186
|
+
until: datetime | None,
|
|
187
|
+
command_filter: str | None,
|
|
188
|
+
cwd_filter: str | None,
|
|
189
|
+
limit: int | None,
|
|
190
|
+
) -> list[CommandLogEntry]:
|
|
191
|
+
"""Read log entries with optional filters.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
since: Only entries after this time
|
|
195
|
+
until: Only entries before this time
|
|
196
|
+
command_filter: Only entries matching this command substring
|
|
197
|
+
cwd_filter: Only entries from this directory (exact match)
|
|
198
|
+
limit: Maximum number of entries to return
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
List of matching entries, most recent first
|
|
202
|
+
"""
|
|
203
|
+
log_path = _get_log_file_path()
|
|
204
|
+
if not log_path.exists():
|
|
205
|
+
return []
|
|
206
|
+
|
|
207
|
+
entries: list[CommandLogEntry] = []
|
|
208
|
+
completion_map: dict[str, int] = {} # start_timestamp -> exit_code
|
|
209
|
+
|
|
210
|
+
# First pass: collect completion entries
|
|
211
|
+
with log_path.open("r", encoding="utf-8") as f:
|
|
212
|
+
for line in f:
|
|
213
|
+
line = line.strip()
|
|
214
|
+
if not line:
|
|
215
|
+
continue
|
|
216
|
+
try:
|
|
217
|
+
data = json.loads(line)
|
|
218
|
+
except json.JSONDecodeError:
|
|
219
|
+
continue
|
|
220
|
+
|
|
221
|
+
if data.get("type") == "completion":
|
|
222
|
+
start_ts = data.get("start_timestamp")
|
|
223
|
+
exit_code = data.get("exit_code")
|
|
224
|
+
if start_ts is not None and exit_code is not None:
|
|
225
|
+
completion_map[start_ts] = exit_code
|
|
226
|
+
|
|
227
|
+
# Second pass: collect command entries
|
|
228
|
+
with log_path.open("r", encoding="utf-8") as f:
|
|
229
|
+
for line in f:
|
|
230
|
+
line = line.strip()
|
|
231
|
+
if not line:
|
|
232
|
+
continue
|
|
233
|
+
try:
|
|
234
|
+
data = json.loads(line)
|
|
235
|
+
except json.JSONDecodeError:
|
|
236
|
+
continue
|
|
237
|
+
|
|
238
|
+
# Skip completion entries
|
|
239
|
+
if data.get("type") == "completion":
|
|
240
|
+
continue
|
|
241
|
+
|
|
242
|
+
# Parse timestamp
|
|
243
|
+
timestamp_str = data.get("timestamp")
|
|
244
|
+
if timestamp_str is None:
|
|
245
|
+
continue
|
|
246
|
+
try:
|
|
247
|
+
timestamp = datetime.fromisoformat(timestamp_str)
|
|
248
|
+
except ValueError:
|
|
249
|
+
continue
|
|
250
|
+
|
|
251
|
+
# Apply time filters
|
|
252
|
+
if since is not None and timestamp < since:
|
|
253
|
+
continue
|
|
254
|
+
if until is not None and timestamp > until:
|
|
255
|
+
continue
|
|
256
|
+
|
|
257
|
+
# Apply command filter
|
|
258
|
+
command = data.get("command", "")
|
|
259
|
+
if command_filter is not None and command_filter.lower() not in command.lower():
|
|
260
|
+
continue
|
|
261
|
+
|
|
262
|
+
# Apply cwd filter
|
|
263
|
+
entry_cwd = data.get("cwd", "")
|
|
264
|
+
if cwd_filter is not None and entry_cwd != cwd_filter:
|
|
265
|
+
continue
|
|
266
|
+
|
|
267
|
+
# Look up exit code from completion entries
|
|
268
|
+
exit_code = completion_map.get(timestamp_str)
|
|
269
|
+
|
|
270
|
+
entry = CommandLogEntry(
|
|
271
|
+
timestamp=timestamp_str,
|
|
272
|
+
command=command,
|
|
273
|
+
args=tuple(data.get("args", [])),
|
|
274
|
+
cwd=entry_cwd,
|
|
275
|
+
branch=data.get("branch"),
|
|
276
|
+
exit_code=exit_code,
|
|
277
|
+
session_id=data.get("session_id"),
|
|
278
|
+
pid=data.get("pid", 0),
|
|
279
|
+
)
|
|
280
|
+
entries.append(entry)
|
|
281
|
+
|
|
282
|
+
# Sort by timestamp descending (most recent first)
|
|
283
|
+
entries.sort(key=lambda e: e.timestamp, reverse=True)
|
|
284
|
+
|
|
285
|
+
# Apply limit
|
|
286
|
+
if limit is not None:
|
|
287
|
+
entries = entries[:limit]
|
|
288
|
+
|
|
289
|
+
return entries
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def get_cli_args() -> list[str]:
|
|
293
|
+
"""Get CLI arguments for logging, skipping the program name."""
|
|
294
|
+
return sys.argv[1:]
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def register_exit_handler(entry_id: str | None) -> None:
|
|
298
|
+
"""Register an atexit handler to log command completion.
|
|
299
|
+
|
|
300
|
+
This ensures logging even on exceptions or SystemExit.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
entry_id: Entry ID from log_command_start (timestamp)
|
|
304
|
+
"""
|
|
305
|
+
|
|
306
|
+
def _log_exit() -> None:
|
|
307
|
+
exc_info = sys.exc_info()
|
|
308
|
+
exc = exc_info[1]
|
|
309
|
+
if isinstance(exc, SystemExit):
|
|
310
|
+
exit_code = exc.code if isinstance(exc.code, int) else 1
|
|
311
|
+
elif exc is not None:
|
|
312
|
+
exit_code = 1
|
|
313
|
+
else:
|
|
314
|
+
exit_code = 0
|
|
315
|
+
log_command_end(entry_id, exit_code)
|
|
316
|
+
|
|
317
|
+
atexit.register(_log_exit)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def is_numeric_string(s: str) -> bool:
|
|
321
|
+
"""Check if string represents an integer (possibly negative)."""
|
|
322
|
+
if not s:
|
|
323
|
+
return False
|
|
324
|
+
if s[0] in "+-":
|
|
325
|
+
return s[1:].isdigit() if len(s) > 1 else False
|
|
326
|
+
return s.isdigit()
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def is_iso_datetime_format(s: str) -> bool:
|
|
330
|
+
"""Check if string looks like an ISO datetime format.
|
|
331
|
+
|
|
332
|
+
Validates basic structure: YYYY-MM-DDTHH:MM:SS with optional timezone.
|
|
333
|
+
"""
|
|
334
|
+
# Basic length check (minimum: 2024-01-01 = 10 chars)
|
|
335
|
+
if len(s) < 10:
|
|
336
|
+
return False
|
|
337
|
+
# Check date part structure
|
|
338
|
+
if len(s) >= 10 and not (s[4] == "-" and s[7] == "-"):
|
|
339
|
+
return False
|
|
340
|
+
# Check year/month/day are digits
|
|
341
|
+
if not (s[:4].isdigit() and s[5:7].isdigit() and s[8:10].isdigit()):
|
|
342
|
+
return False
|
|
343
|
+
return True
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def parse_relative_time(value: str) -> timedelta | None:
|
|
347
|
+
"""Parse relative time string like '1 hour ago' into a timedelta.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
value: String like "1 hour ago", "2 days ago", "30 minutes ago"
|
|
351
|
+
|
|
352
|
+
Returns:
|
|
353
|
+
timedelta if valid, None if invalid format
|
|
354
|
+
"""
|
|
355
|
+
value = value.strip().lower()
|
|
356
|
+
if not value.endswith(" ago"):
|
|
357
|
+
return None
|
|
358
|
+
|
|
359
|
+
parts = value[:-4].strip().split()
|
|
360
|
+
if len(parts) != 2:
|
|
361
|
+
return None
|
|
362
|
+
|
|
363
|
+
amount_str = parts[0]
|
|
364
|
+
if not is_numeric_string(amount_str):
|
|
365
|
+
return None
|
|
366
|
+
|
|
367
|
+
amount = int(amount_str)
|
|
368
|
+
unit = parts[1].rstrip("s") # "hours" -> "hour"
|
|
369
|
+
|
|
370
|
+
if unit == "minute":
|
|
371
|
+
return timedelta(minutes=amount)
|
|
372
|
+
elif unit == "hour":
|
|
373
|
+
return timedelta(hours=amount)
|
|
374
|
+
elif unit == "day":
|
|
375
|
+
return timedelta(days=amount)
|
|
376
|
+
elif unit == "week":
|
|
377
|
+
return timedelta(weeks=amount)
|
|
378
|
+
return None
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def format_relative_time(timestamp_str: str) -> str:
|
|
382
|
+
"""Format an ISO timestamp as relative time (e.g., '5m ago').
|
|
383
|
+
|
|
384
|
+
Args:
|
|
385
|
+
timestamp_str: ISO format timestamp
|
|
386
|
+
|
|
387
|
+
Returns:
|
|
388
|
+
Relative time string, or truncated timestamp if parsing fails
|
|
389
|
+
"""
|
|
390
|
+
if not is_iso_datetime_format(timestamp_str):
|
|
391
|
+
return timestamp_str[:19]
|
|
392
|
+
|
|
393
|
+
dt = datetime.fromisoformat(timestamp_str)
|
|
394
|
+
now = datetime.now(UTC)
|
|
395
|
+
delta = now - dt
|
|
396
|
+
|
|
397
|
+
if delta < timedelta(minutes=1):
|
|
398
|
+
return "just now"
|
|
399
|
+
elif delta < timedelta(hours=1):
|
|
400
|
+
mins = int(delta.total_seconds() / 60)
|
|
401
|
+
return f"{mins}m ago"
|
|
402
|
+
elif delta < timedelta(days=1):
|
|
403
|
+
hours = int(delta.total_seconds() / 3600)
|
|
404
|
+
return f"{hours}h ago"
|
|
405
|
+
else:
|
|
406
|
+
return f"{delta.days}d ago"
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Commit message generation via Claude CLI.
|
|
2
|
+
|
|
3
|
+
This module provides commit message generation for PR submissions,
|
|
4
|
+
using Claude CLI to analyze diffs and generate descriptive messages.
|
|
5
|
+
|
|
6
|
+
The commit message prompt is loaded from the shared prompt file at:
|
|
7
|
+
packages/erk-shared/src/erk_shared/gateway/gt/commit_message_prompt.md
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from collections.abc import Generator
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from erk.core.claude_executor import ClaudeExecutor
|
|
15
|
+
from erk_shared.gateway.gt.events import CompletionEvent, ProgressEvent
|
|
16
|
+
from erk_shared.gateway.gt.prompts import COMMIT_MESSAGE_SYSTEM_PROMPT
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class CommitMessageRequest:
|
|
21
|
+
"""Request for generating a commit message.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
diff_file: Path to the file containing the diff content
|
|
25
|
+
repo_root: Path to the repository root directory
|
|
26
|
+
current_branch: Name of the current branch
|
|
27
|
+
parent_branch: Name of the parent branch
|
|
28
|
+
commit_messages: Optional list of existing commit messages for context
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
diff_file: Path
|
|
32
|
+
repo_root: Path
|
|
33
|
+
current_branch: str
|
|
34
|
+
parent_branch: str
|
|
35
|
+
commit_messages: list[str] | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class CommitMessageResult:
|
|
40
|
+
"""Result of commit message generation.
|
|
41
|
+
|
|
42
|
+
Attributes:
|
|
43
|
+
success: Whether generation succeeded
|
|
44
|
+
title: PR title (first line of commit message) if successful
|
|
45
|
+
body: PR body (remaining lines) if successful
|
|
46
|
+
error_message: Error description if generation failed
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
success: bool
|
|
50
|
+
title: str | None
|
|
51
|
+
body: str | None
|
|
52
|
+
error_message: str | None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class CommitMessageGenerator:
|
|
56
|
+
"""Generates commit messages via Claude CLI.
|
|
57
|
+
|
|
58
|
+
This is a concrete class (not ABC) that uses ClaudeExecutor for
|
|
59
|
+
testability. In tests, inject FakeClaudeExecutor with simulated_prompt_output.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, executor: ClaudeExecutor, model: str = "haiku") -> None:
|
|
63
|
+
"""Initialize generator with executor.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
executor: Claude CLI executor for prompt execution
|
|
67
|
+
model: Model to use for generation (default "haiku" for speed/cost)
|
|
68
|
+
"""
|
|
69
|
+
self._executor = executor
|
|
70
|
+
self._model = model
|
|
71
|
+
|
|
72
|
+
def generate(
|
|
73
|
+
self, request: CommitMessageRequest
|
|
74
|
+
) -> Generator[ProgressEvent | CompletionEvent[CommitMessageResult]]:
|
|
75
|
+
"""Generate commit message from diff with progress updates.
|
|
76
|
+
|
|
77
|
+
Reads the diff file, sends it to Claude with the commit message prompt,
|
|
78
|
+
and parses the response into title and body.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
request: CommitMessageRequest with diff file and context
|
|
82
|
+
|
|
83
|
+
Yields:
|
|
84
|
+
ProgressEvent for status updates
|
|
85
|
+
CompletionEvent with CommitMessageResult on completion
|
|
86
|
+
"""
|
|
87
|
+
# LBYL: Check diff file exists
|
|
88
|
+
yield ProgressEvent("Reading diff file...")
|
|
89
|
+
if not request.diff_file.exists():
|
|
90
|
+
yield CompletionEvent(
|
|
91
|
+
CommitMessageResult(
|
|
92
|
+
success=False,
|
|
93
|
+
title=None,
|
|
94
|
+
body=None,
|
|
95
|
+
error_message=f"Diff file not found: {request.diff_file}",
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
# Read diff content
|
|
101
|
+
diff_content = request.diff_file.read_text(encoding="utf-8")
|
|
102
|
+
if not diff_content.strip():
|
|
103
|
+
yield CompletionEvent(
|
|
104
|
+
CommitMessageResult(
|
|
105
|
+
success=False,
|
|
106
|
+
title=None,
|
|
107
|
+
body=None,
|
|
108
|
+
error_message="Diff file is empty",
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
diff_size = len(diff_content)
|
|
114
|
+
yield ProgressEvent(f"Diff loaded ({diff_size:,} chars)", style="success")
|
|
115
|
+
|
|
116
|
+
# Build prompt with context
|
|
117
|
+
yield ProgressEvent("Analyzing changes with Claude...")
|
|
118
|
+
|
|
119
|
+
prompt = self._build_prompt(
|
|
120
|
+
diff_content=diff_content,
|
|
121
|
+
current_branch=request.current_branch,
|
|
122
|
+
parent_branch=request.parent_branch,
|
|
123
|
+
commit_messages=request.commit_messages,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# Execute prompt via Claude CLI
|
|
127
|
+
result = self._executor.execute_prompt(
|
|
128
|
+
prompt,
|
|
129
|
+
model=self._model,
|
|
130
|
+
cwd=request.repo_root,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
if not result.success:
|
|
134
|
+
yield CompletionEvent(
|
|
135
|
+
CommitMessageResult(
|
|
136
|
+
success=False,
|
|
137
|
+
title=None,
|
|
138
|
+
body=None,
|
|
139
|
+
error_message=result.error or "Claude CLI execution failed",
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
# Parse output into title and body
|
|
145
|
+
title, body = self._parse_output(result.output)
|
|
146
|
+
|
|
147
|
+
yield ProgressEvent("PR description generated", style="success")
|
|
148
|
+
yield CompletionEvent(
|
|
149
|
+
CommitMessageResult(
|
|
150
|
+
success=True,
|
|
151
|
+
title=title,
|
|
152
|
+
body=body,
|
|
153
|
+
error_message=None,
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def _build_prompt(
|
|
158
|
+
self,
|
|
159
|
+
diff_content: str,
|
|
160
|
+
current_branch: str,
|
|
161
|
+
parent_branch: str,
|
|
162
|
+
commit_messages: list[str] | None = None,
|
|
163
|
+
) -> str:
|
|
164
|
+
"""Build the full prompt with diff and context."""
|
|
165
|
+
context_section = f"""## Context
|
|
166
|
+
|
|
167
|
+
- Current branch: {current_branch}
|
|
168
|
+
- Parent branch: {parent_branch}"""
|
|
169
|
+
|
|
170
|
+
# Add commit messages section if present
|
|
171
|
+
if commit_messages:
|
|
172
|
+
messages_text = "\n\n---\n\n".join(commit_messages)
|
|
173
|
+
context_section += f"""
|
|
174
|
+
|
|
175
|
+
## Developer's Commit Messages
|
|
176
|
+
|
|
177
|
+
The following commit messages were written by the developer during implementation:
|
|
178
|
+
|
|
179
|
+
{messages_text}
|
|
180
|
+
|
|
181
|
+
Use these commit messages as additional context. They describe the developer's intent
|
|
182
|
+
and may contain details not visible in the diff alone."""
|
|
183
|
+
|
|
184
|
+
return f"""{COMMIT_MESSAGE_SYSTEM_PROMPT}
|
|
185
|
+
|
|
186
|
+
{context_section}
|
|
187
|
+
|
|
188
|
+
## Diff
|
|
189
|
+
|
|
190
|
+
```diff
|
|
191
|
+
{diff_content}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Generate a commit message for this diff:"""
|
|
195
|
+
|
|
196
|
+
def _parse_output(self, output: str) -> tuple[str, str]:
|
|
197
|
+
"""Parse Claude output into title and body.
|
|
198
|
+
|
|
199
|
+
The first non-empty line is the title, the rest is the body.
|
|
200
|
+
Handles case where output is wrapped in markdown code fences.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
output: Raw output from Claude
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
Tuple of (title, body)
|
|
207
|
+
"""
|
|
208
|
+
lines = output.strip().split("\n")
|
|
209
|
+
|
|
210
|
+
# Strip leading code fence if present (handles ```markdown, ```text, ```, etc.)
|
|
211
|
+
if lines and lines[0].strip().startswith("```"):
|
|
212
|
+
lines = lines[1:]
|
|
213
|
+
|
|
214
|
+
# Strip trailing code fence if present
|
|
215
|
+
if lines and lines[-1].strip() == "```":
|
|
216
|
+
lines = lines[:-1]
|
|
217
|
+
|
|
218
|
+
# Find first non-empty line as title
|
|
219
|
+
title = ""
|
|
220
|
+
body_start_idx = 0
|
|
221
|
+
for i, line in enumerate(lines):
|
|
222
|
+
if line.strip():
|
|
223
|
+
title = line.strip()
|
|
224
|
+
body_start_idx = i + 1
|
|
225
|
+
break
|
|
226
|
+
|
|
227
|
+
# Rest is body (skip empty lines between title and body)
|
|
228
|
+
body_lines = lines[body_start_idx:]
|
|
229
|
+
while body_lines and not body_lines[0].strip():
|
|
230
|
+
body_lines = body_lines[1:]
|
|
231
|
+
|
|
232
|
+
body = "\n".join(body_lines).strip()
|
|
233
|
+
|
|
234
|
+
return title, body
|
erk/core/completion.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Shell completion script generation operations.
|
|
2
|
+
|
|
3
|
+
This is a thin shim that re-exports from erk_shared.gateway.completion.
|
|
4
|
+
All implementations are in erk_shared for sharing across packages.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
# Re-export all Completion types from erk_shared
|
|
8
|
+
from erk_shared.gateway.completion import Completion as Completion
|
|
9
|
+
from erk_shared.gateway.completion import FakeCompletion as FakeCompletion
|
|
10
|
+
from erk_shared.gateway.completion import RealCompletion as RealCompletion
|