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
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Exit Plan Mode Hook.
|
|
3
|
+
|
|
4
|
+
Prompts user before exiting plan mode when a plan exists. This hook intercepts
|
|
5
|
+
the ExitPlanMode tool via PreToolUse lifecycle to ask whether to save to GitHub
|
|
6
|
+
or implement immediately.
|
|
7
|
+
|
|
8
|
+
Exit codes:
|
|
9
|
+
0: Success (allow exit - no plan, implement-now marker present, or no session)
|
|
10
|
+
2: Block (plan exists, no implement-now marker - prompt user)
|
|
11
|
+
|
|
12
|
+
This command is invoked via:
|
|
13
|
+
erk exec exit-plan-mode-hook
|
|
14
|
+
|
|
15
|
+
Marker File State Machine
|
|
16
|
+
=========================
|
|
17
|
+
|
|
18
|
+
This hook uses marker files in .erk/scratch/sessions/<session-id>/ for state management.
|
|
19
|
+
Marker files are self-describing: their names indicate their purpose and their contents
|
|
20
|
+
explain their effect.
|
|
21
|
+
|
|
22
|
+
Marker Files:
|
|
23
|
+
exit-plan-mode-hook.implement-now.marker
|
|
24
|
+
Created by: Agent (when user chooses "Implement now")
|
|
25
|
+
Effect: Next ExitPlanMode call is ALLOWED (exit plan mode, proceed to implementation)
|
|
26
|
+
Lifecycle: Deleted after being read by next hook invocation
|
|
27
|
+
|
|
28
|
+
exit-plan-mode-hook.plan-saved.marker
|
|
29
|
+
Created by: /erk:plan-save command
|
|
30
|
+
Effect: Next ExitPlanMode call is BLOCKED (remain in plan mode, session complete)
|
|
31
|
+
Lifecycle: Deleted after being read by next hook invocation
|
|
32
|
+
|
|
33
|
+
incremental-plan.marker
|
|
34
|
+
Created by: /local:incremental-plan-mode command (via `erk exec marker create --session-id`)
|
|
35
|
+
Effect: Next ExitPlanMode call is ALLOWED, skipping the save prompt entirely
|
|
36
|
+
Lifecycle: Deleted after being read by next hook invocation
|
|
37
|
+
Purpose: Streamlines "plan → implement → submit" loop for PR iteration
|
|
38
|
+
|
|
39
|
+
State Transitions:
|
|
40
|
+
1. No marker files + plan exists → BLOCK with prompt
|
|
41
|
+
2. implement-now marker exists → ALLOW (delete marker)
|
|
42
|
+
3. incremental-plan marker exists → ALLOW (delete marker, skip save prompt)
|
|
43
|
+
4. plan-saved marker exists → BLOCK with "session complete" message (delete marker)
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
import json
|
|
47
|
+
import os
|
|
48
|
+
import sys
|
|
49
|
+
from dataclasses import dataclass
|
|
50
|
+
from enum import Enum
|
|
51
|
+
from pathlib import Path
|
|
52
|
+
from typing import Self
|
|
53
|
+
|
|
54
|
+
import click
|
|
55
|
+
|
|
56
|
+
from erk.hooks.decorators import HookContext, hook_command
|
|
57
|
+
from erk_shared.branch_manager.abc import BranchManager
|
|
58
|
+
from erk_shared.branch_manager.factory import create_branch_manager
|
|
59
|
+
from erk_shared.extraction.claude_installation.abc import ClaudeInstallation
|
|
60
|
+
from erk_shared.git.abc import Git
|
|
61
|
+
from erk_shared.scratch.plan_snapshots import snapshot_plan_for_session
|
|
62
|
+
from erk_shared.scratch.scratch import get_scratch_dir
|
|
63
|
+
|
|
64
|
+
# Known terminal-based editors that cannot run inside Claude Code
|
|
65
|
+
TERMINAL_EDITORS = frozenset(
|
|
66
|
+
{"vim", "vi", "nvim", "nano", "emacs", "pico", "ne", "micro", "jed", "mcedit", "joe", "ed"}
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def is_terminal_editor(editor: str | None) -> bool:
|
|
71
|
+
"""Check if editor is a terminal-based (TUI) editor.
|
|
72
|
+
|
|
73
|
+
Terminal editors like vim cannot run inside Claude Code because they
|
|
74
|
+
need exclusive terminal control which conflicts with Claude's UI.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
editor: The EDITOR environment variable value, or None.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
True if editor is a known terminal-based editor.
|
|
81
|
+
"""
|
|
82
|
+
if editor is None:
|
|
83
|
+
return False
|
|
84
|
+
# Extract basename in case of full path like /usr/bin/vim
|
|
85
|
+
editor_name = Path(editor).name
|
|
86
|
+
return editor_name in TERMINAL_EDITORS
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ============================================================================
|
|
90
|
+
# Data Classes for Pure Logic
|
|
91
|
+
# ============================================================================
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class ExitAction(Enum):
|
|
95
|
+
"""Exit action for the hook."""
|
|
96
|
+
|
|
97
|
+
ALLOW = 0 # Exit code 0 - allow ExitPlanMode
|
|
98
|
+
BLOCK = 2 # Exit code 2 - block ExitPlanMode
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class HookInput:
|
|
103
|
+
"""All inputs needed for decision logic."""
|
|
104
|
+
|
|
105
|
+
session_id: str | None
|
|
106
|
+
github_planning_enabled: bool
|
|
107
|
+
implement_now_marker_exists: bool
|
|
108
|
+
plan_saved_marker_exists: bool
|
|
109
|
+
incremental_plan_marker_exists: bool
|
|
110
|
+
objective_context_marker_exists: bool
|
|
111
|
+
objective_issue: int | None # Objective issue number if marker exists
|
|
112
|
+
plan_file_path: Path | None # Path to plan file if exists, None otherwise
|
|
113
|
+
plan_title: str | None # Title extracted from plan file for display
|
|
114
|
+
current_branch: str | None
|
|
115
|
+
worktree_name: str | None # Directory name of current worktree
|
|
116
|
+
pr_number: int | None # PR number if exists for current branch
|
|
117
|
+
plan_issue_number: int | None # Issue number from .impl/issue.json
|
|
118
|
+
editor: str | None # Value of EDITOR env var for TUI detection
|
|
119
|
+
|
|
120
|
+
@classmethod
|
|
121
|
+
def for_test(
|
|
122
|
+
cls,
|
|
123
|
+
*,
|
|
124
|
+
session_id: str | None = "test-session",
|
|
125
|
+
github_planning_enabled: bool = True,
|
|
126
|
+
implement_now_marker_exists: bool = False,
|
|
127
|
+
plan_saved_marker_exists: bool = False,
|
|
128
|
+
incremental_plan_marker_exists: bool = False,
|
|
129
|
+
objective_context_marker_exists: bool = False,
|
|
130
|
+
objective_issue: int | None = None,
|
|
131
|
+
plan_file_path: Path | None = None,
|
|
132
|
+
plan_title: str | None = None,
|
|
133
|
+
current_branch: str | None = "feature-branch",
|
|
134
|
+
worktree_name: str | None = None,
|
|
135
|
+
pr_number: int | None = None,
|
|
136
|
+
plan_issue_number: int | None = None,
|
|
137
|
+
editor: str | None = None,
|
|
138
|
+
) -> Self:
|
|
139
|
+
"""Create a HookInput with test defaults.
|
|
140
|
+
|
|
141
|
+
All fields have sensible defaults for testing:
|
|
142
|
+
- session_id: "test-session"
|
|
143
|
+
- github_planning_enabled: True
|
|
144
|
+
- All marker exists flags: False
|
|
145
|
+
- objective_issue: None
|
|
146
|
+
- plan_file_path: None
|
|
147
|
+
- plan_title: None
|
|
148
|
+
- current_branch: "feature-branch"
|
|
149
|
+
- worktree_name: None
|
|
150
|
+
- pr_number: None
|
|
151
|
+
- plan_issue_number: None
|
|
152
|
+
- editor: None
|
|
153
|
+
"""
|
|
154
|
+
return cls(
|
|
155
|
+
session_id=session_id,
|
|
156
|
+
github_planning_enabled=github_planning_enabled,
|
|
157
|
+
implement_now_marker_exists=implement_now_marker_exists,
|
|
158
|
+
plan_saved_marker_exists=plan_saved_marker_exists,
|
|
159
|
+
incremental_plan_marker_exists=incremental_plan_marker_exists,
|
|
160
|
+
objective_context_marker_exists=objective_context_marker_exists,
|
|
161
|
+
objective_issue=objective_issue,
|
|
162
|
+
plan_file_path=plan_file_path,
|
|
163
|
+
plan_title=plan_title,
|
|
164
|
+
current_branch=current_branch,
|
|
165
|
+
worktree_name=worktree_name,
|
|
166
|
+
pr_number=pr_number,
|
|
167
|
+
plan_issue_number=plan_issue_number,
|
|
168
|
+
editor=editor,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass(frozen=True)
|
|
173
|
+
class HookOutput:
|
|
174
|
+
"""Decision result from pure logic."""
|
|
175
|
+
|
|
176
|
+
action: ExitAction
|
|
177
|
+
message: str
|
|
178
|
+
delete_implement_now_marker: bool = False
|
|
179
|
+
delete_plan_saved_marker: bool = False
|
|
180
|
+
delete_incremental_plan_marker: bool = False
|
|
181
|
+
delete_objective_context_marker: bool = False
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# ============================================================================
|
|
185
|
+
# Pure Functions (no I/O, fully testable without mocking)
|
|
186
|
+
# ============================================================================
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def extract_plan_title(plan_file_path: Path | None) -> str | None:
|
|
190
|
+
"""Extract title from plan file for display in menu.
|
|
191
|
+
|
|
192
|
+
Pure function - only reads file content, no other I/O.
|
|
193
|
+
|
|
194
|
+
Looks for:
|
|
195
|
+
1. First H1 heading (# Title)
|
|
196
|
+
2. Content after "## Task" section
|
|
197
|
+
|
|
198
|
+
Returns None if file doesn't exist or no title found.
|
|
199
|
+
"""
|
|
200
|
+
if plan_file_path is None or not plan_file_path.exists():
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
text = plan_file_path.read_text(encoding="utf-8")
|
|
204
|
+
lines = text.split("\n")
|
|
205
|
+
|
|
206
|
+
# Look for first H1 (skip generic titles)
|
|
207
|
+
for line in lines[:10]:
|
|
208
|
+
if line.startswith("# "):
|
|
209
|
+
title = line[2:].strip()
|
|
210
|
+
if title.lower() not in ("plan", "implementation plan"):
|
|
211
|
+
return title
|
|
212
|
+
|
|
213
|
+
# Look for ## Task section
|
|
214
|
+
for i, line in enumerate(lines[:20]):
|
|
215
|
+
if line.strip() == "## Task":
|
|
216
|
+
for next_line in lines[i + 1 : i + 5]:
|
|
217
|
+
if next_line.strip():
|
|
218
|
+
return next_line.strip()
|
|
219
|
+
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def build_blocking_message(
|
|
224
|
+
session_id: str,
|
|
225
|
+
current_branch: str | None,
|
|
226
|
+
plan_file_path: Path | None,
|
|
227
|
+
objective_issue: int | None,
|
|
228
|
+
plan_title: str | None,
|
|
229
|
+
worktree_name: str | None,
|
|
230
|
+
pr_number: int | None,
|
|
231
|
+
plan_issue_number: int | None,
|
|
232
|
+
editor: str | None,
|
|
233
|
+
) -> str:
|
|
234
|
+
"""Build the blocking message with AskUserQuestion instructions.
|
|
235
|
+
|
|
236
|
+
Pure function - string building only. Testable without mocking.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
session_id: Claude session ID for marker creation commands.
|
|
240
|
+
current_branch: Current git branch name.
|
|
241
|
+
plan_file_path: Path to the plan file, if it exists.
|
|
242
|
+
objective_issue: Objective issue number, if this plan is part of an objective.
|
|
243
|
+
plan_title: Title extracted from plan file, if available.
|
|
244
|
+
worktree_name: Directory name of current worktree.
|
|
245
|
+
pr_number: PR number if exists for current branch.
|
|
246
|
+
plan_issue_number: Issue number from .impl/issue.json.
|
|
247
|
+
editor: Value of EDITOR env var for TUI detection.
|
|
248
|
+
"""
|
|
249
|
+
# Build context lines for the question
|
|
250
|
+
context_lines: list[str] = []
|
|
251
|
+
|
|
252
|
+
# First line: title
|
|
253
|
+
if plan_title:
|
|
254
|
+
context_lines.append(f"📋 {plan_title}")
|
|
255
|
+
|
|
256
|
+
# Second line: statusline-style context
|
|
257
|
+
statusline_parts: list[str] = []
|
|
258
|
+
if worktree_name:
|
|
259
|
+
statusline_parts.append(f"wt:{worktree_name}")
|
|
260
|
+
if current_branch:
|
|
261
|
+
statusline_parts.append(f"br:{current_branch}")
|
|
262
|
+
if pr_number is not None:
|
|
263
|
+
statusline_parts.append(f"gh:#{pr_number}")
|
|
264
|
+
if plan_issue_number is not None:
|
|
265
|
+
statusline_parts.append(f"plan:#{plan_issue_number}")
|
|
266
|
+
|
|
267
|
+
if statusline_parts:
|
|
268
|
+
statusline = " ".join(f"({part})" for part in statusline_parts)
|
|
269
|
+
context_lines.append(statusline)
|
|
270
|
+
|
|
271
|
+
context_block = "\n".join(context_lines)
|
|
272
|
+
|
|
273
|
+
# Build the question text
|
|
274
|
+
if context_block:
|
|
275
|
+
question_text = f"{context_block}\\n\\nWhat would you like to do with this plan?"
|
|
276
|
+
else:
|
|
277
|
+
question_text = "What would you like to do with this plan?"
|
|
278
|
+
|
|
279
|
+
lines = [
|
|
280
|
+
"PLAN SAVE PROMPT",
|
|
281
|
+
"",
|
|
282
|
+
"A plan exists for this session but has not been saved.",
|
|
283
|
+
"",
|
|
284
|
+
"Use AskUserQuestion to ask the user:",
|
|
285
|
+
f' "{question_text}"',
|
|
286
|
+
"",
|
|
287
|
+
"IMPORTANT: Present options in this exact order:",
|
|
288
|
+
' 1. "Save the plan" (Recommended) - Save plan as a GitHub issue and stop. '
|
|
289
|
+
"Does NOT proceed to implementation.",
|
|
290
|
+
' 2. "Implement" - Save to GitHub, then immediately implement (full workflow).',
|
|
291
|
+
' 3. "Incremental implementation" - Skip saving, implement directly in current '
|
|
292
|
+
"worktree (for small PR iterations that don't need issue tracking).",
|
|
293
|
+
' 4. "View/Edit the plan" - Open plan in editor to review or modify before deciding.',
|
|
294
|
+
]
|
|
295
|
+
|
|
296
|
+
if current_branch in ("master", "main"):
|
|
297
|
+
lines.extend(
|
|
298
|
+
[
|
|
299
|
+
"",
|
|
300
|
+
f"⚠️ WARNING: Currently on '{current_branch}'. "
|
|
301
|
+
"We strongly discourage editing directly on the trunk branch. "
|
|
302
|
+
"Consider saving the plan and implementing in a dedicated worktree instead.",
|
|
303
|
+
]
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
# Build the save command with optional --objective-issue flag
|
|
307
|
+
if objective_issue is not None:
|
|
308
|
+
save_cmd = f"/erk:plan-save --objective-issue={objective_issue}"
|
|
309
|
+
else:
|
|
310
|
+
save_cmd = "/erk:plan-save"
|
|
311
|
+
|
|
312
|
+
lines.extend(
|
|
313
|
+
[
|
|
314
|
+
"",
|
|
315
|
+
"If user chooses 'Save the plan':",
|
|
316
|
+
f" 1. Run {save_cmd}",
|
|
317
|
+
" 2. STOP - Do NOT call ExitPlanMode. The plan-save command handles everything.",
|
|
318
|
+
" Stay in plan mode and let the user exit manually if desired.",
|
|
319
|
+
"",
|
|
320
|
+
"If user chooses 'Implement':",
|
|
321
|
+
f" 1. Run {save_cmd}",
|
|
322
|
+
" 2. After save completes, create implement-now marker:",
|
|
323
|
+
f" erk exec marker create --session-id {session_id} \\",
|
|
324
|
+
" exit-plan-mode-hook.implement-now",
|
|
325
|
+
" 3. Call ExitPlanMode",
|
|
326
|
+
" 4. After exiting plan mode, run /erk:plan-implement to execute implementation",
|
|
327
|
+
"",
|
|
328
|
+
"If user chooses 'Incremental implementation':",
|
|
329
|
+
" 1. Create implement-now marker (skip saving):",
|
|
330
|
+
f" erk exec marker create --session-id {session_id} \\",
|
|
331
|
+
" exit-plan-mode-hook.implement-now",
|
|
332
|
+
" 2. Call ExitPlanMode",
|
|
333
|
+
" 3. After exiting plan mode, implement the changes directly",
|
|
334
|
+
" (no issue tracking - this is for small PR iterations)",
|
|
335
|
+
]
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
if plan_file_path is not None:
|
|
339
|
+
if is_terminal_editor(editor):
|
|
340
|
+
# TUI editors can't run inside Claude Code
|
|
341
|
+
editor_name = Path(editor).name if editor else "your editor"
|
|
342
|
+
lines.extend(
|
|
343
|
+
[
|
|
344
|
+
"",
|
|
345
|
+
"If user chooses 'View/Edit the plan':",
|
|
346
|
+
f" 1. Tell user: '{editor_name} is a terminal-based editor that cannot",
|
|
347
|
+
" run inside Claude Code. Please open the plan in a separate terminal:'",
|
|
348
|
+
f" {editor} {plan_file_path}",
|
|
349
|
+
" 2. Wait for user to confirm they're done editing",
|
|
350
|
+
" 3. Ask the same question again (loop until Save/Implement/Incremental)",
|
|
351
|
+
]
|
|
352
|
+
)
|
|
353
|
+
else:
|
|
354
|
+
lines.extend(
|
|
355
|
+
[
|
|
356
|
+
"",
|
|
357
|
+
"If user chooses 'View/Edit the plan':",
|
|
358
|
+
f" 1. Run: ${{EDITOR:-code}} {plan_file_path}",
|
|
359
|
+
" 2. After user confirms they're done editing, ask the same question again",
|
|
360
|
+
" (loop until user chooses Save, Implement, or Incremental)",
|
|
361
|
+
]
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
return "\n".join(lines)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def determine_exit_action(hook_input: HookInput) -> HookOutput:
|
|
368
|
+
"""Determine what action to take based on inputs.
|
|
369
|
+
|
|
370
|
+
Pure function - all decision logic, no I/O. Testable without mocking!
|
|
371
|
+
"""
|
|
372
|
+
# Early exit if github_planning is disabled
|
|
373
|
+
if not hook_input.github_planning_enabled:
|
|
374
|
+
return HookOutput(ExitAction.ALLOW, "")
|
|
375
|
+
|
|
376
|
+
# No session context
|
|
377
|
+
if hook_input.session_id is None:
|
|
378
|
+
return HookOutput(ExitAction.ALLOW, "No session context available, allowing exit")
|
|
379
|
+
|
|
380
|
+
# Implement-now marker present (user chose "Implement now")
|
|
381
|
+
if hook_input.implement_now_marker_exists:
|
|
382
|
+
return HookOutput(
|
|
383
|
+
ExitAction.ALLOW,
|
|
384
|
+
"Implement-now marker found, allowing exit",
|
|
385
|
+
delete_implement_now_marker=True,
|
|
386
|
+
delete_objective_context_marker=hook_input.objective_context_marker_exists,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
# Incremental-plan marker present (session started via /local:incremental-plan-mode)
|
|
390
|
+
# Skip the "save as GitHub issue?" prompt and proceed directly to implementation
|
|
391
|
+
if hook_input.incremental_plan_marker_exists:
|
|
392
|
+
return HookOutput(
|
|
393
|
+
ExitAction.ALLOW,
|
|
394
|
+
"Incremental-plan mode: skipping save prompt, proceeding to implementation",
|
|
395
|
+
delete_incremental_plan_marker=True,
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
# Plan-saved marker present (user chose "Save to GitHub")
|
|
399
|
+
if hook_input.plan_saved_marker_exists:
|
|
400
|
+
return HookOutput(
|
|
401
|
+
ExitAction.BLOCK,
|
|
402
|
+
"✅ Plan already saved to GitHub. Session complete - no further action needed.",
|
|
403
|
+
delete_plan_saved_marker=True,
|
|
404
|
+
delete_objective_context_marker=hook_input.objective_context_marker_exists,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
# No plan file
|
|
408
|
+
if hook_input.plan_file_path is None:
|
|
409
|
+
return HookOutput(
|
|
410
|
+
ExitAction.ALLOW,
|
|
411
|
+
"No plan file found for this session, allowing exit",
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
# Plan exists, no implement-now marker - block and instruct
|
|
415
|
+
return HookOutput(
|
|
416
|
+
ExitAction.BLOCK,
|
|
417
|
+
build_blocking_message(
|
|
418
|
+
hook_input.session_id,
|
|
419
|
+
hook_input.current_branch,
|
|
420
|
+
hook_input.plan_file_path,
|
|
421
|
+
hook_input.objective_issue,
|
|
422
|
+
hook_input.plan_title,
|
|
423
|
+
hook_input.worktree_name,
|
|
424
|
+
hook_input.pr_number,
|
|
425
|
+
hook_input.plan_issue_number,
|
|
426
|
+
hook_input.editor,
|
|
427
|
+
),
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
# ============================================================================
|
|
432
|
+
# I/O Helper Functions
|
|
433
|
+
# ============================================================================
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _get_implement_now_marker_path(session_id: str, repo_root: Path) -> Path:
|
|
437
|
+
"""Get implement-now marker path in .erk/scratch/sessions/<session_id>/.
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
session_id: The session ID to build the path for
|
|
441
|
+
repo_root: Path to the git repository root
|
|
442
|
+
|
|
443
|
+
Returns:
|
|
444
|
+
Path to implement-now marker file
|
|
445
|
+
"""
|
|
446
|
+
scratch_dir = get_scratch_dir(session_id, repo_root=repo_root)
|
|
447
|
+
return scratch_dir / "exit-plan-mode-hook.implement-now.marker"
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _get_plan_saved_marker_path(session_id: str, repo_root: Path) -> Path:
|
|
451
|
+
"""Get plan-saved marker path in .erk/scratch/sessions/<session_id>/.
|
|
452
|
+
|
|
453
|
+
The plan-saved marker indicates the plan was already saved to GitHub,
|
|
454
|
+
so exit should proceed without triggering implementation.
|
|
455
|
+
|
|
456
|
+
Args:
|
|
457
|
+
session_id: The session ID to build the path for
|
|
458
|
+
repo_root: Path to the git repository root
|
|
459
|
+
|
|
460
|
+
Returns:
|
|
461
|
+
Path to plan-saved marker file
|
|
462
|
+
"""
|
|
463
|
+
scratch_dir = get_scratch_dir(session_id, repo_root=repo_root)
|
|
464
|
+
return scratch_dir / "exit-plan-mode-hook.plan-saved.marker"
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _get_incremental_plan_marker_path(session_id: str, repo_root: Path) -> Path:
|
|
468
|
+
"""Get incremental-plan marker path in .erk/scratch/sessions/<session_id>/.
|
|
469
|
+
|
|
470
|
+
The incremental-plan marker indicates this session was started via
|
|
471
|
+
/local:incremental-plan, so we should skip the "save as GitHub issue?"
|
|
472
|
+
prompt and proceed directly to implementation.
|
|
473
|
+
|
|
474
|
+
Args:
|
|
475
|
+
session_id: The session ID to build the path for
|
|
476
|
+
repo_root: Path to the git repository root
|
|
477
|
+
|
|
478
|
+
Returns:
|
|
479
|
+
Path to incremental-plan marker file
|
|
480
|
+
"""
|
|
481
|
+
return get_scratch_dir(session_id, repo_root=repo_root) / "incremental-plan.marker"
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _get_objective_context_marker_path(session_id: str, repo_root: Path) -> Path:
|
|
485
|
+
"""Get objective-context marker path in .erk/scratch/sessions/<session_id>/.
|
|
486
|
+
|
|
487
|
+
The objective-context marker stores the objective issue number when
|
|
488
|
+
a plan is created via /erk:objective-create-plan. This allows the hook
|
|
489
|
+
to suggest the correct --objective-issue flag in the save command.
|
|
490
|
+
|
|
491
|
+
Args:
|
|
492
|
+
session_id: The session ID to build the path for
|
|
493
|
+
repo_root: Path to the git repository root
|
|
494
|
+
|
|
495
|
+
Returns:
|
|
496
|
+
Path to objective-context marker file
|
|
497
|
+
"""
|
|
498
|
+
return get_scratch_dir(session_id, repo_root=repo_root) / "objective-context.marker"
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _read_objective_context(session_id: str, repo_root: Path) -> int | None:
|
|
502
|
+
"""Read objective issue number from marker, if present.
|
|
503
|
+
|
|
504
|
+
Args:
|
|
505
|
+
session_id: The session ID to look up
|
|
506
|
+
repo_root: Path to the git repository root
|
|
507
|
+
|
|
508
|
+
Returns:
|
|
509
|
+
Objective issue number, or None if marker doesn't exist or is invalid.
|
|
510
|
+
"""
|
|
511
|
+
marker_path = _get_objective_context_marker_path(session_id, repo_root)
|
|
512
|
+
if not marker_path.exists():
|
|
513
|
+
return None
|
|
514
|
+
content = marker_path.read_text(encoding="utf-8").strip()
|
|
515
|
+
if not content.isdigit():
|
|
516
|
+
return None
|
|
517
|
+
return int(content)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _find_session_plan(
|
|
521
|
+
session_id: str, repo_root: Path, claude_installation: ClaudeInstallation
|
|
522
|
+
) -> Path | None:
|
|
523
|
+
"""Find plan file for the given session using slug lookup.
|
|
524
|
+
|
|
525
|
+
Args:
|
|
526
|
+
session_id: The session ID to search for
|
|
527
|
+
repo_root: Path to the git repository root
|
|
528
|
+
claude_installation: Gateway to Claude installation data
|
|
529
|
+
|
|
530
|
+
Returns:
|
|
531
|
+
Path to plan file if found, None otherwise
|
|
532
|
+
"""
|
|
533
|
+
return claude_installation.find_plan_for_session(repo_root, session_id)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _get_worktree_name(git: Git, repo_root: Path) -> str | None:
|
|
537
|
+
"""Get the directory name of the current worktree.
|
|
538
|
+
|
|
539
|
+
Args:
|
|
540
|
+
git: Git gateway for worktree operations
|
|
541
|
+
repo_root: Path to the git repository root
|
|
542
|
+
|
|
543
|
+
Returns:
|
|
544
|
+
Worktree directory name, or None if not found
|
|
545
|
+
"""
|
|
546
|
+
worktrees = git.list_worktrees(repo_root)
|
|
547
|
+
if not worktrees:
|
|
548
|
+
return None
|
|
549
|
+
|
|
550
|
+
for wt in worktrees:
|
|
551
|
+
if wt.path == repo_root:
|
|
552
|
+
return wt.path.name
|
|
553
|
+
|
|
554
|
+
return None
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _get_pr_number_for_branch(
|
|
558
|
+
branch_manager: BranchManager, repo_root: Path, branch: str
|
|
559
|
+
) -> int | None:
|
|
560
|
+
"""Get PR number for the given branch.
|
|
561
|
+
|
|
562
|
+
Args:
|
|
563
|
+
branch_manager: BranchManager for PR lookups (Graphite or GitHub)
|
|
564
|
+
repo_root: Path to the git repository root
|
|
565
|
+
branch: Branch name to look up
|
|
566
|
+
|
|
567
|
+
Returns:
|
|
568
|
+
PR number if exists, None otherwise
|
|
569
|
+
"""
|
|
570
|
+
pr_info = branch_manager.get_pr_for_branch(repo_root, branch)
|
|
571
|
+
if pr_info is None:
|
|
572
|
+
return None
|
|
573
|
+
return pr_info.number
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _get_plan_issue_from_impl(repo_root: Path) -> int | None:
|
|
577
|
+
"""Load plan issue number from .impl/issue.json file.
|
|
578
|
+
|
|
579
|
+
Args:
|
|
580
|
+
repo_root: Path to the git repository root
|
|
581
|
+
|
|
582
|
+
Returns:
|
|
583
|
+
Issue number if found, None otherwise
|
|
584
|
+
"""
|
|
585
|
+
issue_file = repo_root / ".impl" / "issue.json"
|
|
586
|
+
if not issue_file.is_file():
|
|
587
|
+
return None
|
|
588
|
+
|
|
589
|
+
content = issue_file.read_text(encoding="utf-8")
|
|
590
|
+
if not content.strip():
|
|
591
|
+
return None
|
|
592
|
+
|
|
593
|
+
data = json.loads(content)
|
|
594
|
+
# Try "issue_number" first (preferred), then fall back to "number"
|
|
595
|
+
issue_number = data.get("issue_number") or data.get("number")
|
|
596
|
+
if isinstance(issue_number, int):
|
|
597
|
+
return issue_number
|
|
598
|
+
|
|
599
|
+
return None
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
# ============================================================================
|
|
603
|
+
# Main Hook Entry Point
|
|
604
|
+
# ============================================================================
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _gather_inputs(
|
|
608
|
+
session_id: str | None,
|
|
609
|
+
repo_root: Path,
|
|
610
|
+
github_planning_enabled: bool,
|
|
611
|
+
claude_installation: ClaudeInstallation,
|
|
612
|
+
git: Git,
|
|
613
|
+
branch_manager: BranchManager,
|
|
614
|
+
) -> HookInput:
|
|
615
|
+
"""Gather all inputs from environment. All I/O happens here.
|
|
616
|
+
|
|
617
|
+
Args:
|
|
618
|
+
session_id: Claude session ID from hook_ctx, or None if not available.
|
|
619
|
+
repo_root: Path to the git repository root.
|
|
620
|
+
github_planning_enabled: Whether github_planning is enabled in config.
|
|
621
|
+
claude_installation: Gateway to Claude installation data.
|
|
622
|
+
git: Git gateway for worktree operations.
|
|
623
|
+
branch_manager: BranchManager for PR lookups.
|
|
624
|
+
|
|
625
|
+
Returns:
|
|
626
|
+
HookInput with all gathered state.
|
|
627
|
+
"""
|
|
628
|
+
# Determine marker existence
|
|
629
|
+
implement_now_marker_exists = False
|
|
630
|
+
plan_saved_marker_exists = False
|
|
631
|
+
incremental_plan_marker_exists = False
|
|
632
|
+
objective_context_marker_exists = False
|
|
633
|
+
objective_issue: int | None = None
|
|
634
|
+
if session_id is not None:
|
|
635
|
+
implement_now_marker_exists = _get_implement_now_marker_path(session_id, repo_root).exists()
|
|
636
|
+
plan_saved_marker_exists = _get_plan_saved_marker_path(session_id, repo_root).exists()
|
|
637
|
+
marker_path = _get_incremental_plan_marker_path(session_id, repo_root)
|
|
638
|
+
incremental_plan_marker_exists = marker_path.exists()
|
|
639
|
+
objective_context_marker_exists = _get_objective_context_marker_path(
|
|
640
|
+
session_id, repo_root
|
|
641
|
+
).exists()
|
|
642
|
+
objective_issue = _read_objective_context(session_id, repo_root)
|
|
643
|
+
|
|
644
|
+
# Find plan file path (None if doesn't exist)
|
|
645
|
+
plan_file_path: Path | None = None
|
|
646
|
+
if session_id is not None:
|
|
647
|
+
plan_file_path = _find_session_plan(session_id, repo_root, claude_installation)
|
|
648
|
+
|
|
649
|
+
# Extract title for display (after finding plan file)
|
|
650
|
+
plan_title: str | None = None
|
|
651
|
+
if plan_file_path is not None:
|
|
652
|
+
plan_title = extract_plan_title(plan_file_path)
|
|
653
|
+
|
|
654
|
+
# Get current branch (only if we need to show the blocking message)
|
|
655
|
+
current_branch: str | None = None
|
|
656
|
+
worktree_name: str | None = None
|
|
657
|
+
pr_number: int | None = None
|
|
658
|
+
plan_issue_number: int | None = None
|
|
659
|
+
|
|
660
|
+
needs_blocking_message = (
|
|
661
|
+
session_id is not None
|
|
662
|
+
and plan_file_path is not None
|
|
663
|
+
and not implement_now_marker_exists
|
|
664
|
+
and not incremental_plan_marker_exists
|
|
665
|
+
and not plan_saved_marker_exists
|
|
666
|
+
)
|
|
667
|
+
# Get EDITOR env var for TUI detection
|
|
668
|
+
editor: str | None = None
|
|
669
|
+
if needs_blocking_message:
|
|
670
|
+
current_branch = git.get_current_branch(repo_root)
|
|
671
|
+
worktree_name = _get_worktree_name(git, repo_root)
|
|
672
|
+
plan_issue_number = _get_plan_issue_from_impl(repo_root)
|
|
673
|
+
editor = os.environ.get("EDITOR")
|
|
674
|
+
# Only lookup PR if we have a branch
|
|
675
|
+
if current_branch is not None:
|
|
676
|
+
pr_number = _get_pr_number_for_branch(branch_manager, repo_root, current_branch)
|
|
677
|
+
|
|
678
|
+
return HookInput(
|
|
679
|
+
session_id=session_id,
|
|
680
|
+
github_planning_enabled=github_planning_enabled,
|
|
681
|
+
implement_now_marker_exists=implement_now_marker_exists,
|
|
682
|
+
plan_saved_marker_exists=plan_saved_marker_exists,
|
|
683
|
+
incremental_plan_marker_exists=incremental_plan_marker_exists,
|
|
684
|
+
objective_context_marker_exists=objective_context_marker_exists,
|
|
685
|
+
objective_issue=objective_issue,
|
|
686
|
+
plan_file_path=plan_file_path,
|
|
687
|
+
plan_title=plan_title,
|
|
688
|
+
current_branch=current_branch,
|
|
689
|
+
worktree_name=worktree_name,
|
|
690
|
+
pr_number=pr_number,
|
|
691
|
+
plan_issue_number=plan_issue_number,
|
|
692
|
+
editor=editor,
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _execute_result(
|
|
697
|
+
result: HookOutput,
|
|
698
|
+
hook_input: HookInput,
|
|
699
|
+
repo_root: Path,
|
|
700
|
+
claude_installation: ClaudeInstallation,
|
|
701
|
+
) -> None:
|
|
702
|
+
"""Execute the decision result. All I/O happens here."""
|
|
703
|
+
session_id = hook_input.session_id
|
|
704
|
+
|
|
705
|
+
if result.delete_implement_now_marker and session_id:
|
|
706
|
+
_get_implement_now_marker_path(session_id, repo_root).unlink()
|
|
707
|
+
|
|
708
|
+
if result.delete_plan_saved_marker and session_id:
|
|
709
|
+
_get_plan_saved_marker_path(session_id, repo_root).unlink()
|
|
710
|
+
|
|
711
|
+
if result.delete_incremental_plan_marker and session_id:
|
|
712
|
+
_get_incremental_plan_marker_path(session_id, repo_root).unlink()
|
|
713
|
+
|
|
714
|
+
if result.delete_objective_context_marker and session_id:
|
|
715
|
+
_get_objective_context_marker_path(session_id, repo_root).unlink()
|
|
716
|
+
|
|
717
|
+
# Snapshot plan whenever a plan exists and user made a decision
|
|
718
|
+
# (implement-now or plan-saved, but NOT when blocking to prompt)
|
|
719
|
+
user_made_decision = result.delete_implement_now_marker or result.delete_plan_saved_marker
|
|
720
|
+
if hook_input.plan_file_path is not None and session_id is not None and user_made_decision:
|
|
721
|
+
snapshot_plan_for_session(
|
|
722
|
+
session_id=session_id,
|
|
723
|
+
plan_file_path=hook_input.plan_file_path,
|
|
724
|
+
project_cwd=repo_root,
|
|
725
|
+
claude_installation=claude_installation,
|
|
726
|
+
repo_root=repo_root,
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
if result.message:
|
|
730
|
+
click.echo(result.message, err=True)
|
|
731
|
+
|
|
732
|
+
sys.exit(result.action.value)
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
@hook_command(name="exit-plan-mode-hook")
|
|
736
|
+
def exit_plan_mode_hook(ctx: click.Context, *, hook_ctx: HookContext) -> None:
|
|
737
|
+
"""Prompt user about plan saving when ExitPlanMode is called.
|
|
738
|
+
|
|
739
|
+
This PreToolUse hook intercepts ExitPlanMode calls to ask the user
|
|
740
|
+
whether to save the plan to GitHub or implement immediately.
|
|
741
|
+
|
|
742
|
+
Exit codes:
|
|
743
|
+
0: Success - allow exit (no plan, skip marker, or no session)
|
|
744
|
+
2: Block - plan exists, prompt user for action
|
|
745
|
+
"""
|
|
746
|
+
# Scope check: only run in erk-managed projects
|
|
747
|
+
if not hook_ctx.is_erk_project:
|
|
748
|
+
return
|
|
749
|
+
|
|
750
|
+
# Get github_planning from injected context (defaults to True if not configured)
|
|
751
|
+
global_config = ctx.obj.global_config
|
|
752
|
+
github_planning_enabled = global_config.github_planning if global_config is not None else True
|
|
753
|
+
|
|
754
|
+
# Create branch manager for PR lookups
|
|
755
|
+
branch_manager = create_branch_manager(
|
|
756
|
+
git=ctx.obj.git, github=ctx.obj.github, graphite=ctx.obj.graphite
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
# Gather all inputs (I/O layer)
|
|
760
|
+
hook_input = _gather_inputs(
|
|
761
|
+
hook_ctx.session_id,
|
|
762
|
+
hook_ctx.repo_root,
|
|
763
|
+
github_planning_enabled,
|
|
764
|
+
ctx.obj.claude_installation,
|
|
765
|
+
ctx.obj.git,
|
|
766
|
+
branch_manager,
|
|
767
|
+
)
|
|
768
|
+
|
|
769
|
+
# Pure decision logic (no I/O)
|
|
770
|
+
result = determine_exit_action(hook_input)
|
|
771
|
+
|
|
772
|
+
# Execute result (I/O layer)
|
|
773
|
+
_execute_result(result, hook_input, hook_ctx.repo_root, ctx.obj.claude_installation)
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
if __name__ == "__main__":
|
|
777
|
+
exit_plan_mode_hook()
|