multi-forge 0.2.0__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.
- forge/__init__.py +3 -0
- forge/_extensions/agents/.gitkeep +0 -0
- forge/_extensions/commands/.gitkeep +0 -0
- forge/_extensions/skills/analyze/SKILL.md +87 -0
- forge/_extensions/skills/challenge/SKILL.md +91 -0
- forge/_extensions/skills/consensus/SKILL.md +120 -0
- forge/_extensions/skills/consensus/resources/code_consensus_evaluation.md +94 -0
- forge/_extensions/skills/consensus/resources/consensus_evaluation.md +70 -0
- forge/_extensions/skills/consensus/resources/synthesis.md +101 -0
- forge/_extensions/skills/debate/SKILL.md +116 -0
- forge/_extensions/skills/debate/resources/code_debate_evaluation.md +101 -0
- forge/_extensions/skills/debate/resources/debate_evaluation.md +90 -0
- forge/_extensions/skills/panel/SKILL.md +141 -0
- forge/_extensions/skills/panel/resources/synthesis.md +103 -0
- forge/_extensions/skills/qa/SKILL.md +704 -0
- forge/_extensions/skills/qa/resources/checklist/0-enable.md +78 -0
- forge/_extensions/skills/qa/resources/checklist/1-preflight.md +24 -0
- forge/_extensions/skills/qa/resources/checklist/10-resume.md +143 -0
- forge/_extensions/skills/qa/resources/checklist/11-config.md +150 -0
- forge/_extensions/skills/qa/resources/checklist/12-search.md +58 -0
- forge/_extensions/skills/qa/resources/checklist/13-guard.md +237 -0
- forge/_extensions/skills/qa/resources/checklist/14-workflow.md +305 -0
- forge/_extensions/skills/qa/resources/checklist/15-skills.md +155 -0
- forge/_extensions/skills/qa/resources/checklist/16-handoff.md +224 -0
- forge/_extensions/skills/qa/resources/checklist/17-info.md +50 -0
- forge/_extensions/skills/qa/resources/checklist/18-disable.md +84 -0
- forge/_extensions/skills/qa/resources/checklist/19-uninstall.md +146 -0
- forge/_extensions/skills/qa/resources/checklist/2-extensions.md +188 -0
- forge/_extensions/skills/qa/resources/checklist/20-cleanup.md +36 -0
- forge/_extensions/skills/qa/resources/checklist/3-auth.md +234 -0
- forge/_extensions/skills/qa/resources/checklist/4-proxy.md +481 -0
- forge/_extensions/skills/qa/resources/checklist/5-session.md +541 -0
- forge/_extensions/skills/qa/resources/checklist/6-hooks.md +275 -0
- forge/_extensions/skills/qa/resources/checklist/7-costs.md +309 -0
- forge/_extensions/skills/qa/resources/checklist/8-status-line.md +174 -0
- forge/_extensions/skills/qa/resources/checklist/9-direct-commands.md +146 -0
- forge/_extensions/skills/qa/resources/checklist.md +103 -0
- forge/_extensions/skills/qa/resources/report-template.md +62 -0
- forge/_extensions/skills/qa/scripts/start-container.sh +529 -0
- forge/_extensions/skills/qa/scripts/walkthrough-state.py +1137 -0
- forge/_extensions/skills/review/SKILL.md +125 -0
- forge/_extensions/skills/review/references/claude-4.6.md +474 -0
- forge/_extensions/skills/review/references/claude-4.7.md +710 -0
- forge/_extensions/skills/review/references/gemini-3.1.md +546 -0
- forge/_extensions/skills/review/references/gpt-5.5.md +490 -0
- forge/_extensions/skills/review/references/skills-writing-guide.md +1588 -0
- forge/_extensions/skills/review/resources/code-anthropic.md +160 -0
- forge/_extensions/skills/review/resources/code-gemini.md +184 -0
- forge/_extensions/skills/review/resources/code-openai.md +203 -0
- forge/_extensions/skills/review/resources/code.md +160 -0
- forge/_extensions/skills/review-docs/SKILL.md +121 -0
- forge/_extensions/skills/review-docs/resources/docs-anthropic.md +170 -0
- forge/_extensions/skills/review-docs/resources/docs-gemini.md +204 -0
- forge/_extensions/skills/review-docs/resources/docs-openai.md +231 -0
- forge/_extensions/skills/review-docs/resources/docs.md +170 -0
- forge/_extensions/skills/smoke-test/SKILL.md +27 -0
- forge/_extensions/skills/smoke-test/scripts/smoke-test.sh +118 -0
- forge/_extensions/skills/understand/SKILL.md +148 -0
- forge/_extensions/skills/understand/resources/code-anthropic.md +163 -0
- forge/_extensions/skills/understand/resources/code-gemini.md +194 -0
- forge/_extensions/skills/understand/resources/code-openai.md +181 -0
- forge/_extensions/skills/understand/resources/code.md +163 -0
- forge/_extensions/skills/understand/resources/docs-anthropic.md +177 -0
- forge/_extensions/skills/understand/resources/docs-gemini.md +202 -0
- forge/_extensions/skills/understand/resources/docs-openai.md +191 -0
- forge/_extensions/skills/understand/resources/docs.md +177 -0
- forge/_extensions/skills/walkthrough/SKILL.md +599 -0
- forge/_extensions/skills/walkthrough/resources/checklist.md +765 -0
- forge/_extensions/skills/walkthrough/scripts/run-in-repo.sh +118 -0
- forge/_extensions/skills/walkthrough/scripts/setup-test-repo.sh +198 -0
- forge/_extensions/skills/walkthrough/scripts/walkthrough-state.py +1137 -0
- forge/backend/__init__.py +174 -0
- forge/backend/adapters/__init__.py +38 -0
- forge/backend/adapters/litellm.py +158 -0
- forge/backend/creation.py +89 -0
- forge/backend/registry.py +178 -0
- forge/cli/__init__.py +16 -0
- forge/cli/auth.py +483 -0
- forge/cli/backend.py +298 -0
- forge/cli/claude.py +411 -0
- forge/cli/config_cmd.py +303 -0
- forge/cli/extensions.py +1001 -0
- forge/cli/gc.py +165 -0
- forge/cli/guard.py +1018 -0
- forge/cli/guards.py +106 -0
- forge/cli/handoff.py +110 -0
- forge/cli/hooks/__init__.py +36 -0
- forge/cli/hooks/_group.py +20 -0
- forge/cli/hooks/_helpers.py +149 -0
- forge/cli/hooks/commands.py +1677 -0
- forge/cli/hooks/direct_commands.py +1304 -0
- forge/cli/hooks/install.py +232 -0
- forge/cli/hooks/policy.py +151 -0
- forge/cli/hooks/read_hygiene.py +74 -0
- forge/cli/hooks/verification.py +370 -0
- forge/cli/logs.py +406 -0
- forge/cli/main.py +292 -0
- forge/cli/proxy.py +1821 -0
- forge/cli/proxy_costs.py +313 -0
- forge/cli/search.py +416 -0
- forge/cli/session.py +892 -0
- forge/cli/session_addendum.py +81 -0
- forge/cli/session_fork.py +750 -0
- forge/cli/session_handoff.py +141 -0
- forge/cli/session_lifecycle.py +2053 -0
- forge/cli/session_manage.py +1336 -0
- forge/cli/session_memory.py +201 -0
- forge/cli/status_line.py +1398 -0
- forge/cli/workflow.py +1964 -0
- forge/config/__init__.py +110 -0
- forge/config/dataclass_utils.py +88 -0
- forge/config/defaults/__init__.py +0 -0
- forge/config/defaults/backends/__init__.py +0 -0
- forge/config/defaults/backends/litellm.yaml +196 -0
- forge/config/defaults/templates/__init__.py +0 -0
- forge/config/defaults/templates/litellm-anthropic-local.yaml +33 -0
- forge/config/defaults/templates/litellm-anthropic.yaml +24 -0
- forge/config/defaults/templates/litellm-gemini-flash-local.yaml +37 -0
- forge/config/defaults/templates/litellm-gemini-local.yaml +32 -0
- forge/config/defaults/templates/litellm-gemini-test.yaml +34 -0
- forge/config/defaults/templates/litellm-gemini.yaml +21 -0
- forge/config/defaults/templates/litellm-openai-codex-local.yaml +36 -0
- forge/config/defaults/templates/litellm-openai-local.yaml +38 -0
- forge/config/defaults/templates/litellm-openai.yaml +28 -0
- forge/config/defaults/templates/openrouter-anthropic.yaml +23 -0
- forge/config/defaults/templates/openrouter-deepseek.yaml +26 -0
- forge/config/defaults/templates/openrouter-gemini-flash.yaml +26 -0
- forge/config/defaults/templates/openrouter-gemini.yaml +23 -0
- forge/config/defaults/templates/openrouter-glm.yaml +23 -0
- forge/config/defaults/templates/openrouter-kimi.yaml +30 -0
- forge/config/defaults/templates/openrouter-minimax.yaml +26 -0
- forge/config/defaults/templates/openrouter-openai-codex.yaml +23 -0
- forge/config/defaults/templates/openrouter-openai.yaml +28 -0
- forge/config/defaults/templates/openrouter-qwen.yaml +25 -0
- forge/config/loader.py +675 -0
- forge/config/schema.py +448 -0
- forge/core/__init__.py +5 -0
- forge/core/auth/__init__.py +67 -0
- forge/core/auth/capabilities.py +219 -0
- forge/core/auth/credentials_file.py +244 -0
- forge/core/auth/protocols.py +18 -0
- forge/core/auth/secrets.py +243 -0
- forge/core/auth/template_secrets.py +112 -0
- forge/core/data/__init__.py +5 -0
- forge/core/data/model_catalog.yaml +1522 -0
- forge/core/data/pricing.yaml +140 -0
- forge/core/data/system_prompt_addendums/__init__.py +0 -0
- forge/core/data/system_prompt_addendums/gemini.md +330 -0
- forge/core/data/system_prompt_addendums/openai.md +328 -0
- forge/core/llm/__init__.py +231 -0
- forge/core/llm/clients/__init__.py +14 -0
- forge/core/llm/clients/base.py +115 -0
- forge/core/llm/clients/litellm.py +619 -0
- forge/core/llm/clients/openai_compat.py +244 -0
- forge/core/llm/clients/openrouter.py +234 -0
- forge/core/llm/credentials.py +439 -0
- forge/core/llm/detection.py +86 -0
- forge/core/llm/errors.py +44 -0
- forge/core/llm/protocols.py +80 -0
- forge/core/llm/types.py +176 -0
- forge/core/logging.py +146 -0
- forge/core/models/__init__.py +91 -0
- forge/core/models/catalog.py +467 -0
- forge/core/models/pricing.py +165 -0
- forge/core/models/types.py +167 -0
- forge/core/naming.py +212 -0
- forge/core/ops/__init__.py +73 -0
- forge/core/ops/context.py +141 -0
- forge/core/ops/gc.py +802 -0
- forge/core/ops/proxy.py +146 -0
- forge/core/ops/resolution.py +135 -0
- forge/core/ops/session.py +344 -0
- forge/core/ops/session_context.py +548 -0
- forge/core/paths.py +38 -0
- forge/core/process.py +54 -0
- forge/core/reactive/__init__.py +38 -0
- forge/core/reactive/cost_tracking.py +300 -0
- forge/core/reactive/env.py +180 -0
- forge/core/reactive/proxy.py +78 -0
- forge/core/reactive/routing.py +622 -0
- forge/core/reactive/session_runner.py +185 -0
- forge/core/reactive/structured_output.py +62 -0
- forge/core/reactive/tagger.py +94 -0
- forge/core/reactive/throttle.py +132 -0
- forge/core/state/__init__.py +59 -0
- forge/core/state/exceptions.py +59 -0
- forge/core/state/io.py +140 -0
- forge/core/state/lock.py +99 -0
- forge/core/state/timestamps.py +60 -0
- forge/core/transcript.py +78 -0
- forge/core/typing_helpers.py +24 -0
- forge/core/workqueue/__init__.py +67 -0
- forge/core/workqueue/queue.py +552 -0
- forge/core/workqueue/types.py +63 -0
- forge/guard/__init__.py +26 -0
- forge/guard/deterministic/__init__.py +26 -0
- forge/guard/deterministic/base.py +158 -0
- forge/guard/deterministic/coding_standards.py +256 -0
- forge/guard/deterministic/registry.py +148 -0
- forge/guard/deterministic/tdd.py +171 -0
- forge/guard/engine.py +216 -0
- forge/guard/protocols.py +91 -0
- forge/guard/queries.py +96 -0
- forge/guard/semantic/__init__.py +34 -0
- forge/guard/semantic/promotion.py +18 -0
- forge/guard/semantic/supervisor.py +813 -0
- forge/guard/semantic/verdict.py +183 -0
- forge/guard/store.py +124 -0
- forge/guard/team/__init__.py +6 -0
- forge/guard/team/config.py +24 -0
- forge/guard/team/handlers.py +209 -0
- forge/guard/team/prompts.py +41 -0
- forge/guard/types.py +125 -0
- forge/guard/workflow/__init__.py +17 -0
- forge/guard/workflow/branches.py +67 -0
- forge/guard/workflow/config.py +63 -0
- forge/guard/workflow/divergence.py +113 -0
- forge/guard/workflow/policy.py +87 -0
- forge/guard/workflow/stages.py +205 -0
- forge/install/__init__.py +55 -0
- forge/install/cli.py +281 -0
- forge/install/exceptions.py +163 -0
- forge/install/hooks.py +109 -0
- forge/install/installer.py +1037 -0
- forge/install/models.py +321 -0
- forge/install/preset.py +272 -0
- forge/install/settings_merge.py +831 -0
- forge/install/tracking.py +238 -0
- forge/install/version.py +141 -0
- forge/proxy/__init__.py +0 -0
- forge/proxy/base_client.py +181 -0
- forge/proxy/client_adapter.py +476 -0
- forge/proxy/client_factory.py +531 -0
- forge/proxy/converters.py +1206 -0
- forge/proxy/cost_logger.py +132 -0
- forge/proxy/cost_tracker.py +242 -0
- forge/proxy/data_models.py +338 -0
- forge/proxy/error_hints.py +92 -0
- forge/proxy/metrics.py +222 -0
- forge/proxy/model_spec.py +158 -0
- forge/proxy/proxies.py +333 -0
- forge/proxy/proxy_identity.py +134 -0
- forge/proxy/proxy_orchestrator.py +1018 -0
- forge/proxy/proxy_startup.py +54 -0
- forge/proxy/server.py +1561 -0
- forge/proxy/utils.py +537 -0
- forge/review/__init__.py +6 -0
- forge/review/adversarial.py +111 -0
- forge/review/consensus.py +236 -0
- forge/review/engine.py +356 -0
- forge/review/models.py +437 -0
- forge/review/resources/__init__.py +5 -0
- forge/review/resources/codereview-performance.md +85 -0
- forge/review/resources/codereview-quick.md +75 -0
- forge/review/resources/codereview-security.md +92 -0
- forge/review/resources/codereview.md +85 -0
- forge/review/resources/docreview-quick.md +75 -0
- forge/review/resources/docreview.md +86 -0
- forge/review/resources/thinkdeep.md +89 -0
- forge/review/routing.py +368 -0
- forge/review/synthesis.py +73 -0
- forge/runtime_config.py +438 -0
- forge/search/__init__.py +55 -0
- forge/search/bm25_store.py +264 -0
- forge/search/content_store.py +197 -0
- forge/search/engine.py +352 -0
- forge/search/exceptions.py +51 -0
- forge/search/extractor.py +234 -0
- forge/search/index_state.py +295 -0
- forge/search/store.py +215 -0
- forge/search/tokenizer.py +24 -0
- forge/session/__init__.py +130 -0
- forge/session/active.py +339 -0
- forge/session/artifacts.py +202 -0
- forge/session/claude/__init__.py +50 -0
- forge/session/claude/cleanup.py +105 -0
- forge/session/claude/invoke.py +236 -0
- forge/session/claude/paths.py +200 -0
- forge/session/cleanup.py +216 -0
- forge/session/config.py +34 -0
- forge/session/direct_model.py +107 -0
- forge/session/effective.py +169 -0
- forge/session/exceptions.py +255 -0
- forge/session/handoff.py +881 -0
- forge/session/handoff_agent.py +544 -0
- forge/session/hooks/__init__.py +35 -0
- forge/session/hooks/models.py +73 -0
- forge/session/hooks/session_start.py +507 -0
- forge/session/identity.py +84 -0
- forge/session/index.py +553 -0
- forge/session/manager.py +1506 -0
- forge/session/models.py +572 -0
- forge/session/overrides.py +344 -0
- forge/session/plan_resolution.py +286 -0
- forge/session/prev_sessions.py +128 -0
- forge/session/store.py +431 -0
- forge/session/validation.py +47 -0
- forge/session/worktree/__init__.py +65 -0
- forge/session/worktree/cleanup.py +262 -0
- forge/session/worktree/config_copy.py +203 -0
- forge/session/worktree/create.py +332 -0
- forge/sidecar/__init__.py +29 -0
- forge/sidecar/container.py +161 -0
- forge/sidecar/docker.py +86 -0
- forge/sidecar/secrets.py +19 -0
- multi_forge-0.2.0.dist-info/METADATA +242 -0
- multi_forge-0.2.0.dist-info/RECORD +311 -0
- multi_forge-0.2.0.dist-info/WHEEL +4 -0
- multi_forge-0.2.0.dist-info/entry_points.txt +2 -0
- multi_forge-0.2.0.dist-info/licenses/LICENSE +203 -0
- multi_forge-0.2.0.dist-info/licenses/NOTICE +14 -0
|
@@ -0,0 +1,750 @@
|
|
|
1
|
+
"""Session fork command.
|
|
2
|
+
|
|
3
|
+
Extracted from session_lifecycle.py for file-size compliance.
|
|
4
|
+
Re-exported via session.py so patch("forge.cli.session.fork") works.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
import uuid as _uuid
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
|
|
15
|
+
from forge.cli.session_addendum import (
|
|
16
|
+
resolve_addendum_content_for_proxy,
|
|
17
|
+
write_managed_addendum,
|
|
18
|
+
)
|
|
19
|
+
from forge.core.paths import display_path
|
|
20
|
+
from forge.session import (
|
|
21
|
+
LAUNCH_MODE_HOST,
|
|
22
|
+
ForgeSessionError,
|
|
23
|
+
SessionState,
|
|
24
|
+
)
|
|
25
|
+
from forge.session.direct_model import (
|
|
26
|
+
apply_direct_model_env,
|
|
27
|
+
)
|
|
28
|
+
from forge.session.exceptions import (
|
|
29
|
+
BranchExistsError,
|
|
30
|
+
BranchInUseError,
|
|
31
|
+
BranchNotMergedError,
|
|
32
|
+
CannotForkIncognitoError,
|
|
33
|
+
InvalidBranchNameError,
|
|
34
|
+
SessionNotFoundError,
|
|
35
|
+
WorktreePathExistsError,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _sess(): # type: ignore[return]
|
|
40
|
+
return sys.modules["forge.cli.session"]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
from forge.cli.session import ( # noqa: E402
|
|
44
|
+
ResolvedRouting,
|
|
45
|
+
_apply_routing_override_to_state,
|
|
46
|
+
_combine_prompt_files,
|
|
47
|
+
_get_effective_proxy_for_session,
|
|
48
|
+
_get_launch_preferences,
|
|
49
|
+
_get_runtime_base_url,
|
|
50
|
+
_handle_error,
|
|
51
|
+
_hint_cross_project_session,
|
|
52
|
+
_persist_routing_override,
|
|
53
|
+
_print_routing_summary,
|
|
54
|
+
_resolve_session_artifact_root,
|
|
55
|
+
_resolve_worktree_extension_root,
|
|
56
|
+
console,
|
|
57
|
+
logger,
|
|
58
|
+
)
|
|
59
|
+
from forge.cli.session_lifecycle import ( # noqa: E402
|
|
60
|
+
_launch_claude_for_session,
|
|
61
|
+
_persist_fork_handoff_derivation,
|
|
62
|
+
_print_branch_exists_tip,
|
|
63
|
+
_print_post_exit_tip,
|
|
64
|
+
_resolve_manifest_prompt_file,
|
|
65
|
+
_resume_tip_command,
|
|
66
|
+
session,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
__all__ = ["fork"]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@session.command()
|
|
73
|
+
@click.argument("parent")
|
|
74
|
+
@click.option(
|
|
75
|
+
"--name",
|
|
76
|
+
"-n",
|
|
77
|
+
default=None,
|
|
78
|
+
help="Name for the fork (auto-generated if not provided)",
|
|
79
|
+
)
|
|
80
|
+
@click.option(
|
|
81
|
+
"--proxy",
|
|
82
|
+
"proxy_name",
|
|
83
|
+
type=str,
|
|
84
|
+
default=None,
|
|
85
|
+
help="Proxy to use (proxy_id or template name)",
|
|
86
|
+
)
|
|
87
|
+
@click.option(
|
|
88
|
+
"--no-proxy",
|
|
89
|
+
"direct",
|
|
90
|
+
is_flag=True,
|
|
91
|
+
help="Bypass the proxy and talk to Anthropic directly",
|
|
92
|
+
)
|
|
93
|
+
@click.option("--incognito", "-i", is_flag=True, help="Auto-delete fork on exit")
|
|
94
|
+
@click.option("--worktree", "-w", is_flag=True, help="Create git worktree for fork isolation")
|
|
95
|
+
@click.option("--branch", "-b", help="Override branch name (implies --worktree)")
|
|
96
|
+
@click.option("--no-launch", is_flag=True, help="Create fork without launching Claude")
|
|
97
|
+
@click.option(
|
|
98
|
+
"--extensions/--no-extensions",
|
|
99
|
+
default=None,
|
|
100
|
+
help="Auto-install extensions in worktree (default: inherit from parent)",
|
|
101
|
+
)
|
|
102
|
+
@click.option(
|
|
103
|
+
"--strategy",
|
|
104
|
+
type=click.Choice(["minimal", "structured", "full", "ai-curated"]),
|
|
105
|
+
default="structured",
|
|
106
|
+
help="Context assembly strategy for worktree forks (default: structured)",
|
|
107
|
+
)
|
|
108
|
+
@click.option(
|
|
109
|
+
"--inline-plan",
|
|
110
|
+
is_flag=True,
|
|
111
|
+
default=False,
|
|
112
|
+
help="Inline the approved plan content in handoff context",
|
|
113
|
+
)
|
|
114
|
+
@click.option(
|
|
115
|
+
"--into",
|
|
116
|
+
"into_path",
|
|
117
|
+
type=click.Path(exists=True),
|
|
118
|
+
default=None,
|
|
119
|
+
help="Fork into an existing non-main worktree directory",
|
|
120
|
+
)
|
|
121
|
+
@click.option(
|
|
122
|
+
"--supervise",
|
|
123
|
+
"supervise_target",
|
|
124
|
+
is_flag=True,
|
|
125
|
+
default=False,
|
|
126
|
+
help="Set parent as plan supervisor for the fork (enables policy enforcement)",
|
|
127
|
+
)
|
|
128
|
+
@click.option(
|
|
129
|
+
"--supervisor-proxy",
|
|
130
|
+
type=str,
|
|
131
|
+
default=None,
|
|
132
|
+
help="Proxy for supervisor routing (requires --supervise)",
|
|
133
|
+
)
|
|
134
|
+
@click.option(
|
|
135
|
+
"--no-supervisor-proxy",
|
|
136
|
+
"supervisor_direct",
|
|
137
|
+
is_flag=True,
|
|
138
|
+
default=False,
|
|
139
|
+
help="Force supervisor to use direct Anthropic routing (requires --supervise)",
|
|
140
|
+
)
|
|
141
|
+
@click.option(
|
|
142
|
+
"--force",
|
|
143
|
+
"-f",
|
|
144
|
+
is_flag=True,
|
|
145
|
+
help="Replace existing branch/worktree and skip budget preflight",
|
|
146
|
+
)
|
|
147
|
+
def fork(
|
|
148
|
+
parent: str,
|
|
149
|
+
name: str | None,
|
|
150
|
+
proxy_name: str | None,
|
|
151
|
+
direct: bool,
|
|
152
|
+
incognito: bool,
|
|
153
|
+
worktree: bool,
|
|
154
|
+
branch: str | None,
|
|
155
|
+
no_launch: bool,
|
|
156
|
+
extensions: bool | None,
|
|
157
|
+
strategy: str,
|
|
158
|
+
inline_plan: bool,
|
|
159
|
+
into_path: str | None,
|
|
160
|
+
supervise_target: bool,
|
|
161
|
+
supervisor_proxy: str | None,
|
|
162
|
+
supervisor_direct: bool,
|
|
163
|
+
force: bool,
|
|
164
|
+
) -> None:
|
|
165
|
+
"""Fork an existing session.
|
|
166
|
+
|
|
167
|
+
By default the fork shares the parent's directory so Claude's
|
|
168
|
+
conversation carries over via --fork-session. Use --worktree for
|
|
169
|
+
code isolation in a separate git worktree, or --into for an existing
|
|
170
|
+
non-main worktree.
|
|
171
|
+
|
|
172
|
+
Use --no-proxy to bypass the proxy, or --proxy to route through
|
|
173
|
+
a specific proxy instead of the parent's.
|
|
174
|
+
|
|
175
|
+
\b
|
|
176
|
+
Examples:
|
|
177
|
+
forge session fork parent-session # Fork, same directory
|
|
178
|
+
forge session fork parent-session --worktree # Fork with worktree
|
|
179
|
+
forge session fork parent-session -n child-session # Custom fork name
|
|
180
|
+
forge session fork parent-session --no-proxy # Fork, bypass proxy
|
|
181
|
+
"""
|
|
182
|
+
if direct and proxy_name:
|
|
183
|
+
console.print("[red]Error:[/red] --no-proxy and --proxy are mutually exclusive")
|
|
184
|
+
sys.exit(1)
|
|
185
|
+
if supervisor_proxy and supervisor_direct:
|
|
186
|
+
console.print("[red]Error:[/red] --supervisor-proxy and --no-supervisor-proxy are mutually exclusive")
|
|
187
|
+
sys.exit(1)
|
|
188
|
+
if (supervisor_proxy or supervisor_direct) and not supervise_target:
|
|
189
|
+
console.print("[red]Error:[/red] --supervisor-proxy/--no-supervisor-proxy require --supervise")
|
|
190
|
+
sys.exit(1)
|
|
191
|
+
|
|
192
|
+
if branch:
|
|
193
|
+
worktree = True
|
|
194
|
+
|
|
195
|
+
# --into validation
|
|
196
|
+
into_resolved: str | None = None
|
|
197
|
+
into_branch: str | None = None
|
|
198
|
+
into_target_common: str | None = None
|
|
199
|
+
if into_path is not None:
|
|
200
|
+
if worktree:
|
|
201
|
+
console.print("[red]Error:[/red] --into and --worktree are mutually exclusive")
|
|
202
|
+
sys.exit(1)
|
|
203
|
+
if branch:
|
|
204
|
+
console.print("[red]Error:[/red] --into and --branch are mutually exclusive")
|
|
205
|
+
sys.exit(1)
|
|
206
|
+
|
|
207
|
+
import subprocess as _sp
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
into_resolved = _sp.run(
|
|
211
|
+
["git", "-C", into_path, "rev-parse", "--show-toplevel"],
|
|
212
|
+
capture_output=True,
|
|
213
|
+
text=True,
|
|
214
|
+
check=True,
|
|
215
|
+
).stdout.strip()
|
|
216
|
+
except _sp.CalledProcessError:
|
|
217
|
+
console.print(f"[red]Error:[/red] '{display_path(into_path)}' is not inside a git repository")
|
|
218
|
+
sys.exit(1)
|
|
219
|
+
|
|
220
|
+
# Resolve git-common-dir for the target (absolute, to avoid .git relative path bug)
|
|
221
|
+
try:
|
|
222
|
+
target_common_raw = _sp.run(
|
|
223
|
+
["git", "-C", into_resolved, "rev-parse", "--git-common-dir"],
|
|
224
|
+
capture_output=True,
|
|
225
|
+
text=True,
|
|
226
|
+
check=True,
|
|
227
|
+
).stdout.strip()
|
|
228
|
+
# git returns relative paths from the checkout root; resolve against it
|
|
229
|
+
target_common = str((Path(into_resolved) / target_common_raw).resolve())
|
|
230
|
+
except _sp.CalledProcessError:
|
|
231
|
+
console.print("[red]Error:[/red] Failed to resolve git repository for --into target")
|
|
232
|
+
sys.exit(1)
|
|
233
|
+
|
|
234
|
+
# Store for deferred comparison after parent session is loaded
|
|
235
|
+
into_target_common = target_common
|
|
236
|
+
|
|
237
|
+
# Reject main checkout: the main checkout's --show-toplevel == its own path
|
|
238
|
+
# A real worktree has a different toplevel than the main repo
|
|
239
|
+
try:
|
|
240
|
+
# Use git-common-dir to find the main repo's toplevel
|
|
241
|
+
main_git_dir = _sp.run(
|
|
242
|
+
["git", "-C", into_resolved, "rev-parse", "--git-common-dir"],
|
|
243
|
+
capture_output=True,
|
|
244
|
+
text=True,
|
|
245
|
+
check=True,
|
|
246
|
+
).stdout.strip()
|
|
247
|
+
main_git_dir_abs = (Path(into_resolved) / main_git_dir).resolve()
|
|
248
|
+
# Main repo root is the parent of the .git directory
|
|
249
|
+
main_repo_root = main_git_dir_abs.parent if main_git_dir_abs.name == ".git" else main_git_dir_abs
|
|
250
|
+
if Path(into_resolved).resolve() == main_repo_root:
|
|
251
|
+
console.print(
|
|
252
|
+
"[red]Error:[/red] --into targets existing worktrees, not the main checkout. "
|
|
253
|
+
"Use a same-directory fork instead."
|
|
254
|
+
)
|
|
255
|
+
sys.exit(1)
|
|
256
|
+
except _sp.CalledProcessError:
|
|
257
|
+
pass # Can't determine; allow
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
into_branch = _sp.run(
|
|
261
|
+
["git", "-C", into_resolved, "rev-parse", "--abbrev-ref", "HEAD"],
|
|
262
|
+
capture_output=True,
|
|
263
|
+
text=True,
|
|
264
|
+
check=True,
|
|
265
|
+
).stdout.strip()
|
|
266
|
+
except _sp.CalledProcessError:
|
|
267
|
+
into_branch = None
|
|
268
|
+
|
|
269
|
+
# CWD validation (skip for --into, which has its own path resolution)
|
|
270
|
+
if into_path is None:
|
|
271
|
+
from forge.cli.guards import require_main_repo_root, require_repo_root
|
|
272
|
+
|
|
273
|
+
if worktree:
|
|
274
|
+
require_main_repo_root()
|
|
275
|
+
else:
|
|
276
|
+
require_repo_root()
|
|
277
|
+
|
|
278
|
+
ctx = click.get_current_context()
|
|
279
|
+
_strategy_explicit = ctx.get_parameter_source("strategy") == click.core.ParameterSource.COMMANDLINE
|
|
280
|
+
_inline_plan_explicit = ctx.get_parameter_source("inline_plan") == click.core.ParameterSource.COMMANDLINE
|
|
281
|
+
|
|
282
|
+
manager = _sess().SessionManager()
|
|
283
|
+
_fr = _sess()._cwd_forge_root()
|
|
284
|
+
|
|
285
|
+
# --into cross-repo preflight: reject before fork_session() to avoid orphaned sessions
|
|
286
|
+
if into_resolved is not None and into_target_common is not None:
|
|
287
|
+
import subprocess as _sp2
|
|
288
|
+
|
|
289
|
+
try:
|
|
290
|
+
parent_state_pre = manager.get_session(parent, forge_root=_fr)
|
|
291
|
+
parent_wt_pre = parent_state_pre.worktree.path if parent_state_pre.worktree else None
|
|
292
|
+
if parent_wt_pre:
|
|
293
|
+
parent_common_raw = _sp2.run(
|
|
294
|
+
["git", "-C", parent_wt_pre, "rev-parse", "--git-common-dir"],
|
|
295
|
+
capture_output=True,
|
|
296
|
+
text=True,
|
|
297
|
+
check=True,
|
|
298
|
+
).stdout.strip()
|
|
299
|
+
parent_common = str((Path(parent_wt_pre) / parent_common_raw).resolve())
|
|
300
|
+
if into_target_common != parent_common:
|
|
301
|
+
console.print(
|
|
302
|
+
"[red]Error:[/red] --into target is not part of the same repository as the parent session"
|
|
303
|
+
)
|
|
304
|
+
sys.exit(1)
|
|
305
|
+
except _sp2.CalledProcessError:
|
|
306
|
+
pass # Can't resolve parent repo; allow
|
|
307
|
+
except ForgeSessionError:
|
|
308
|
+
pass # Parent not found; fork_session() will raise the right error
|
|
309
|
+
|
|
310
|
+
# Budget preflight for --strategy full (before fork_session to avoid orphaned sessions/worktrees)
|
|
311
|
+
# Use the child's effective routing: --no-proxy means no proxy, --proxy overrides parent
|
|
312
|
+
is_cross_dir = worktree or into_resolved is not None
|
|
313
|
+
# Resolve --proxy early for preflight (reuses routing resolved later for launch)
|
|
314
|
+
_preflight_routing: ResolvedRouting | None = None
|
|
315
|
+
if proxy_name:
|
|
316
|
+
_preflight_routing = _sess()._resolve_routing_from_cli(proxy_name=proxy_name, direct=False)
|
|
317
|
+
if is_cross_dir and strategy == "full" and not direct:
|
|
318
|
+
try:
|
|
319
|
+
from forge.session.artifacts import resolve_artifact_path
|
|
320
|
+
|
|
321
|
+
parent_state = manager.get_session(parent, forge_root=_fr)
|
|
322
|
+
# --proxy override > parent's proxy for budget check
|
|
323
|
+
if _preflight_routing:
|
|
324
|
+
preflight_ref = _preflight_routing.proxy_id
|
|
325
|
+
else:
|
|
326
|
+
child_template = parent_state.intent.proxy.template if parent_state.intent.proxy else None
|
|
327
|
+
preflight_ref = child_template
|
|
328
|
+
context_limit_preflight = _sess()._resolve_context_limit(preflight_ref)
|
|
329
|
+
if context_limit_preflight is not None:
|
|
330
|
+
from forge.session.handoff import estimate_transcript_tokens
|
|
331
|
+
|
|
332
|
+
artifact_root = _resolve_session_artifact_root(manager=manager, state=parent_state)
|
|
333
|
+
transcripts = parent_state.confirmed.artifacts.get("transcripts", [])
|
|
334
|
+
if transcripts and isinstance(transcripts, list):
|
|
335
|
+
latest = transcripts[-1]
|
|
336
|
+
if isinstance(latest, dict):
|
|
337
|
+
copied_path = latest.get("copied_path")
|
|
338
|
+
if isinstance(copied_path, str):
|
|
339
|
+
transcript_path = resolve_artifact_path(artifact_root, copied_path)
|
|
340
|
+
if transcript_path is not None and transcript_path.is_file():
|
|
341
|
+
token_est = estimate_transcript_tokens(transcript_path)
|
|
342
|
+
if token_est > context_limit_preflight:
|
|
343
|
+
if force:
|
|
344
|
+
console.print(
|
|
345
|
+
f"[yellow]Warning:[/yellow] Parent transcript ({token_est:,} tokens) "
|
|
346
|
+
f"exceeds context limit ({context_limit_preflight:,}). "
|
|
347
|
+
"Proceeding anyway (--force)."
|
|
348
|
+
)
|
|
349
|
+
else:
|
|
350
|
+
console.print(
|
|
351
|
+
f"[red]Error:[/red] Parent transcript ({token_est:,} tokens) exceeds "
|
|
352
|
+
f"context limit ({context_limit_preflight:,})."
|
|
353
|
+
)
|
|
354
|
+
console.print(
|
|
355
|
+
"[dim]Tip: Use --strategy structured or --strategy ai-curated instead.[/dim]"
|
|
356
|
+
)
|
|
357
|
+
sys.exit(1)
|
|
358
|
+
except ForgeSessionError:
|
|
359
|
+
pass # Parent not found; fork_session() will raise the right error
|
|
360
|
+
|
|
361
|
+
# Preflight supervisor proxy BEFORE fork_session() to avoid half-created state
|
|
362
|
+
if supervisor_proxy:
|
|
363
|
+
from forge.guard.semantic.supervisor import preflight_supervisor_proxy
|
|
364
|
+
|
|
365
|
+
try:
|
|
366
|
+
supervisor_proxy = preflight_supervisor_proxy(supervisor_proxy)
|
|
367
|
+
except ValueError as e:
|
|
368
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
369
|
+
sys.exit(1)
|
|
370
|
+
|
|
371
|
+
try:
|
|
372
|
+
parent_manifest, fork_manifest = manager.fork_session(
|
|
373
|
+
parent_name=parent,
|
|
374
|
+
fork_name=name,
|
|
375
|
+
direct=direct,
|
|
376
|
+
is_incognito=incognito,
|
|
377
|
+
create_worktree=worktree,
|
|
378
|
+
branch=into_branch if into_resolved else branch,
|
|
379
|
+
into_path=into_resolved,
|
|
380
|
+
forge_root=_fr,
|
|
381
|
+
force=force,
|
|
382
|
+
)
|
|
383
|
+
except CannotForkIncognitoError as e:
|
|
384
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
385
|
+
console.print("\n[dim]Tip: Incognito sessions cannot be forked.[/dim]")
|
|
386
|
+
sys.exit(1)
|
|
387
|
+
except BranchExistsError as e:
|
|
388
|
+
_print_branch_exists_tip(e)
|
|
389
|
+
sys.exit(1)
|
|
390
|
+
except BranchInUseError as e:
|
|
391
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
392
|
+
console.print("\n[dim]Tip: The branch is checked out in another worktree. Remove that worktree first.[/dim]")
|
|
393
|
+
sys.exit(1)
|
|
394
|
+
except BranchNotMergedError as e:
|
|
395
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
396
|
+
console.print("\n[dim]Tip: Merge or delete the branch manually before using --force.[/dim]")
|
|
397
|
+
sys.exit(1)
|
|
398
|
+
except WorktreePathExistsError as e:
|
|
399
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
400
|
+
console.print("\n[dim]Tip: Remove the directory or use a different fork name.[/dim]")
|
|
401
|
+
sys.exit(1)
|
|
402
|
+
except InvalidBranchNameError as e:
|
|
403
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
404
|
+
sys.exit(1)
|
|
405
|
+
except SessionNotFoundError:
|
|
406
|
+
if not _hint_cross_project_session(parent, _fr):
|
|
407
|
+
console.print(f"[red]Error:[/red] session '{parent}' not found")
|
|
408
|
+
sys.exit(1)
|
|
409
|
+
except ForgeSessionError as e:
|
|
410
|
+
_handle_error(e)
|
|
411
|
+
return
|
|
412
|
+
|
|
413
|
+
# Persist routing override to manifest (ensures --no-launch retains proxy choice)
|
|
414
|
+
fork_worktree_path = Path(fork_manifest.worktree.path) if fork_manifest.worktree else Path.cwd()
|
|
415
|
+
_persist_routing_override(
|
|
416
|
+
forge_root=Path(fork_manifest.forge_root) if fork_manifest.forge_root else fork_worktree_path,
|
|
417
|
+
session_name=fork_manifest.name,
|
|
418
|
+
routing=_preflight_routing,
|
|
419
|
+
direct=direct,
|
|
420
|
+
)
|
|
421
|
+
_apply_routing_override_to_state(state=fork_manifest, routing=_preflight_routing, direct=direct)
|
|
422
|
+
|
|
423
|
+
# --- wire supervisor (if --supervise flag set) ---
|
|
424
|
+
if supervise_target:
|
|
425
|
+
from forge.guard.semantic.supervisor import (
|
|
426
|
+
apply_supervisor_routing,
|
|
427
|
+
apply_supervisor_to_intent,
|
|
428
|
+
)
|
|
429
|
+
from forge.session.models import SupervisorConfig
|
|
430
|
+
from forge.session.store import SessionStore
|
|
431
|
+
|
|
432
|
+
fork_forge_root = fork_manifest.forge_root or str(fork_worktree_path)
|
|
433
|
+
sup_config = SupervisorConfig(
|
|
434
|
+
resume_id=parent,
|
|
435
|
+
forge_root=parent_manifest.forge_root or fork_forge_root,
|
|
436
|
+
)
|
|
437
|
+
apply_supervisor_routing(
|
|
438
|
+
sup_config,
|
|
439
|
+
parent_manifest,
|
|
440
|
+
supervisor_proxy=supervisor_proxy,
|
|
441
|
+
supervisor_direct=supervisor_direct,
|
|
442
|
+
current_proxy_id=_preflight_routing.proxy_id if _preflight_routing else None,
|
|
443
|
+
current_template=_preflight_routing.template if _preflight_routing else None,
|
|
444
|
+
current_direct=direct,
|
|
445
|
+
)
|
|
446
|
+
fork_store = SessionStore(fork_forge_root, fork_manifest.name)
|
|
447
|
+
fork_store.update(timeout_s=5.0, mutate=lambda m: apply_supervisor_to_intent(m, sup_config))
|
|
448
|
+
fork_manifest = fork_store.read()
|
|
449
|
+
|
|
450
|
+
if _preflight_routing:
|
|
451
|
+
effective_template = _preflight_routing.template
|
|
452
|
+
effective_url = _preflight_routing.base_url
|
|
453
|
+
effective_proxy_id = _preflight_routing.proxy_id
|
|
454
|
+
elif proxy_name:
|
|
455
|
+
routing = _sess()._resolve_routing_from_cli(proxy_name=proxy_name, direct=False)
|
|
456
|
+
effective_template = routing.template
|
|
457
|
+
effective_url = routing.base_url
|
|
458
|
+
effective_proxy_id = routing.proxy_id
|
|
459
|
+
else:
|
|
460
|
+
effective_template, effective_url, effective_proxy_id = _get_effective_proxy_for_session(fork_manifest)
|
|
461
|
+
|
|
462
|
+
# Compute context limit (uses exact proxy_id when available for deterministic result)
|
|
463
|
+
context_limit = _sess()._resolve_context_limit(effective_proxy_id or effective_template)
|
|
464
|
+
|
|
465
|
+
console.print(f"Forked [cyan]{parent}[/cyan] -> [green]{fork_manifest.name}[/green]")
|
|
466
|
+
_print_routing_summary(template=effective_template, base_url=effective_url)
|
|
467
|
+
if fork_manifest.worktree and fork_manifest.worktree.is_worktree:
|
|
468
|
+
console.print(f" Worktree: {display_path(fork_manifest.worktree.path)}")
|
|
469
|
+
console.print(f" Branch: {fork_manifest.worktree.branch}")
|
|
470
|
+
if supervise_target:
|
|
471
|
+
console.print(f" Supervisor: {parent}")
|
|
472
|
+
if incognito:
|
|
473
|
+
console.print("[yellow] (will auto-delete on exit)[/yellow]")
|
|
474
|
+
console.print()
|
|
475
|
+
|
|
476
|
+
parent_session_id = parent_manifest.confirmed.claude_session_id
|
|
477
|
+
if not parent_session_id:
|
|
478
|
+
console.print("[red]Error:[/red] Parent session has no UUID")
|
|
479
|
+
console.print("The parent session may not have been started yet.")
|
|
480
|
+
sys.exit(1)
|
|
481
|
+
|
|
482
|
+
use_sidecar, mounts, image = _get_launch_preferences(fork_manifest)
|
|
483
|
+
|
|
484
|
+
# Set env vars for fork registration (hook uses FORGE_FORK_NAME for fork detection)
|
|
485
|
+
env_vars, unset_env_vars = _sess()._build_session_env(
|
|
486
|
+
session_name=fork_manifest.name,
|
|
487
|
+
context_limit=context_limit,
|
|
488
|
+
template=effective_template,
|
|
489
|
+
base_url=effective_url,
|
|
490
|
+
fork_name=fork_manifest.name,
|
|
491
|
+
parent_session=parent,
|
|
492
|
+
forge_root=fork_manifest.forge_root,
|
|
493
|
+
subprocess_proxy=fork_manifest.intent.subprocess_proxy,
|
|
494
|
+
sidecar=use_sidecar,
|
|
495
|
+
)
|
|
496
|
+
fork_name = fork_manifest.name # Capture for cleanup
|
|
497
|
+
is_worktree_fork = bool(fork_manifest.worktree and fork_manifest.worktree.is_worktree)
|
|
498
|
+
if effective_url is None:
|
|
499
|
+
from forge.runtime_config import get_default_direct_model
|
|
500
|
+
|
|
501
|
+
fork_direct_model = fork_manifest.intent.launch.direct_model if fork_manifest.intent.launch else None
|
|
502
|
+
fork_direct_model = fork_direct_model or get_default_direct_model()
|
|
503
|
+
error = apply_direct_model_env(env_vars, fork_direct_model)
|
|
504
|
+
if error:
|
|
505
|
+
console.print(f"[red]Error:[/red] {error}")
|
|
506
|
+
sys.exit(1)
|
|
507
|
+
|
|
508
|
+
# Warn about --strategy/--inline-plan on same-directory forks (only if user explicitly set them)
|
|
509
|
+
if not is_worktree_fork and (_strategy_explicit or _inline_plan_explicit):
|
|
510
|
+
console.print(
|
|
511
|
+
"[dim]Tip: --strategy/--inline-plan only apply to worktree forks "
|
|
512
|
+
"(ignored for same-directory forks).[/dim]"
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
# Worktree forks: Claude Code stores sessions at ~/.claude/projects/<encoded-cwd>/,
|
|
516
|
+
# so --resume --fork-session cannot find the parent's conversation from a different
|
|
517
|
+
# directory. Tested 2026-04-02 with Claude Code 2.1.90: all cross-CWD scenarios fail
|
|
518
|
+
# with "No conversation found." See scripts/experiments/native-resume/.
|
|
519
|
+
# Use handoff (assembled context via --append-system-prompt-file) instead.
|
|
520
|
+
if is_worktree_fork:
|
|
521
|
+
worktree_path = Path(fork_manifest.worktree.path) # type: ignore[union-attr]
|
|
522
|
+
fork_context, prompt_warnings = _sess()._generate_parent_handoff_context(
|
|
523
|
+
manager=manager,
|
|
524
|
+
manifest=fork_manifest,
|
|
525
|
+
parent_state=parent_manifest,
|
|
526
|
+
strategy=strategy,
|
|
527
|
+
inline_plan=inline_plan,
|
|
528
|
+
)
|
|
529
|
+
prompt_files: list[Path] = []
|
|
530
|
+
if fork_context is not None:
|
|
531
|
+
prompt_files.append(fork_context)
|
|
532
|
+
configured_prompt = _resolve_manifest_prompt_file(fork_manifest)
|
|
533
|
+
if configured_prompt is not None:
|
|
534
|
+
prompt_files.append(configured_prompt)
|
|
535
|
+
prompt_file = _combine_prompt_files(
|
|
536
|
+
worktree_path=worktree_path,
|
|
537
|
+
session_name=fork_manifest.name,
|
|
538
|
+
prompt_files=prompt_files,
|
|
539
|
+
)
|
|
540
|
+
if prompt_file:
|
|
541
|
+
prompt_path = Path(prompt_file)
|
|
542
|
+
try:
|
|
543
|
+
console.print(f" Context: {prompt_path.relative_to(worktree_path)}")
|
|
544
|
+
except ValueError:
|
|
545
|
+
console.print(f" Context: {display_path(prompt_path)}")
|
|
546
|
+
for warning in prompt_warnings:
|
|
547
|
+
console.print(f"[yellow]Warning:[/yellow] {warning}")
|
|
548
|
+
|
|
549
|
+
try:
|
|
550
|
+
fork_manifest = _persist_fork_handoff_derivation(
|
|
551
|
+
manifest=fork_manifest,
|
|
552
|
+
strategy=strategy,
|
|
553
|
+
context_path=fork_context,
|
|
554
|
+
)
|
|
555
|
+
except Exception:
|
|
556
|
+
logger.warning("Failed to persist fork derivation handoff details", exc_info=True)
|
|
557
|
+
|
|
558
|
+
_fork_uuid = str(_uuid.uuid4())
|
|
559
|
+
try:
|
|
560
|
+
from forge.session import SessionStore as _ForkStore
|
|
561
|
+
|
|
562
|
+
_fork_wt = Path(fork_manifest.worktree.path) if fork_manifest.worktree else Path.cwd()
|
|
563
|
+
_fork_store_root = Path(fork_manifest.forge_root) if fork_manifest.forge_root else _fork_wt
|
|
564
|
+
_fork_store = _ForkStore(str(_fork_store_root), fork_manifest.name)
|
|
565
|
+
from forge.session.claude.paths import (
|
|
566
|
+
resolve_claude_project_root as _resolve_fork_root_preseed,
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
_fork_cwd_preseed = _resolve_fork_root_preseed(fork_manifest)
|
|
570
|
+
|
|
571
|
+
def _preseed_mutate(m: SessionState) -> None:
|
|
572
|
+
m.confirmed.claude_session_id = _fork_uuid
|
|
573
|
+
m.confirmed.claude_project_root = _fork_cwd_preseed
|
|
574
|
+
|
|
575
|
+
_fork_store.update(timeout_s=5.0, mutate=_preseed_mutate)
|
|
576
|
+
manager.index_store.sync_uuid_from_state(fork_manifest.name, _fork_store.read())
|
|
577
|
+
except Exception:
|
|
578
|
+
logger.debug("Pre-seed UUID write failed (hook will reconcile)", exc_info=True)
|
|
579
|
+
|
|
580
|
+
from forge.session.claude.paths import (
|
|
581
|
+
resolve_claude_project_root as _resolve_fork_root,
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
_fork_cwd = _resolve_fork_root(fork_manifest)
|
|
585
|
+
|
|
586
|
+
_wt_addendum = resolve_addendum_content_for_proxy(effective_proxy_id)
|
|
587
|
+
_wt_prompt = prompt_file
|
|
588
|
+
if _wt_addendum:
|
|
589
|
+
_wt_forge_root = Path(fork_manifest.forge_root) if fork_manifest.forge_root else Path.cwd()
|
|
590
|
+
_wt_addendum_path = write_managed_addendum(_wt_forge_root, fork_manifest.name, _wt_addendum)
|
|
591
|
+
_wt_files: list[Path] = [_wt_addendum_path]
|
|
592
|
+
if _wt_prompt:
|
|
593
|
+
_wt_files.append(Path(_wt_prompt))
|
|
594
|
+
_wt_prompt = _combine_prompt_files(
|
|
595
|
+
worktree_path=worktree_path,
|
|
596
|
+
session_name=fork_manifest.name,
|
|
597
|
+
prompt_files=_wt_files,
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
def _invoke_fork() -> int:
|
|
601
|
+
return _sess().invoke_claude(
|
|
602
|
+
session_id=_fork_uuid,
|
|
603
|
+
name=fork_manifest.name,
|
|
604
|
+
model=None,
|
|
605
|
+
system_prompt_file=_wt_prompt,
|
|
606
|
+
env_vars=env_vars,
|
|
607
|
+
unset_env_vars=unset_env_vars,
|
|
608
|
+
cwd=_fork_cwd,
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
# Same-directory forks: --resume --fork-session works natively.
|
|
612
|
+
else:
|
|
613
|
+
from forge.session.claude.paths import (
|
|
614
|
+
resolve_claude_project_root as _resolve_fork_root,
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
_fork_cwd = _resolve_fork_root(fork_manifest)
|
|
618
|
+
_samedir_addendum = resolve_addendum_content_for_proxy(effective_proxy_id)
|
|
619
|
+
_samedir_prompt: str | None = None
|
|
620
|
+
if _samedir_addendum:
|
|
621
|
+
_samedir_forge_root = Path(fork_manifest.forge_root) if fork_manifest.forge_root else Path.cwd()
|
|
622
|
+
_samedir_prompt = str(write_managed_addendum(_samedir_forge_root, fork_manifest.name, _samedir_addendum))
|
|
623
|
+
|
|
624
|
+
def _invoke_fork() -> int:
|
|
625
|
+
return _sess().invoke_claude(
|
|
626
|
+
resume_id=parent_session_id,
|
|
627
|
+
fork_session=True,
|
|
628
|
+
name=fork_manifest.name,
|
|
629
|
+
model=None,
|
|
630
|
+
system_prompt_file=_samedir_prompt,
|
|
631
|
+
env_vars=env_vars,
|
|
632
|
+
unset_env_vars=unset_env_vars,
|
|
633
|
+
cwd=_fork_cwd,
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
# Auto-install extensions in worktree forks (before no_launch check so --no-launch still prepares the worktree)
|
|
637
|
+
if is_worktree_fork:
|
|
638
|
+
extension_root = _resolve_worktree_extension_root(fork_manifest)
|
|
639
|
+
# For --into, skip if the target already has a local Forge install
|
|
640
|
+
_skip_extensions = False
|
|
641
|
+
if into_resolved is not None and extension_root is not None:
|
|
642
|
+
try:
|
|
643
|
+
from forge.install.tracking import TrackingStore as _TSCheck
|
|
644
|
+
|
|
645
|
+
if _TSCheck().get_installation("local", str(extension_root)) is not None:
|
|
646
|
+
_skip_extensions = True
|
|
647
|
+
logger.debug("Skipping auto-install: target worktree has existing local install")
|
|
648
|
+
except Exception:
|
|
649
|
+
pass
|
|
650
|
+
|
|
651
|
+
if not _skip_extensions and extension_root is not None:
|
|
652
|
+
# Use forge_root (where .claude/ and .forge/ live), not checkout_root.
|
|
653
|
+
# The tracking store keys by forge_root, so get_repo_root() misses when
|
|
654
|
+
# forge_root != checkout_root (e.g., nested .claude/ in a subdirectory).
|
|
655
|
+
_parent_forge_root = Path(
|
|
656
|
+
parent_manifest.forge_root
|
|
657
|
+
or (parent_manifest.worktree.path if parent_manifest.worktree else str(Path.cwd()))
|
|
658
|
+
)
|
|
659
|
+
_sess()._auto_install_extensions(
|
|
660
|
+
install_root=extension_root,
|
|
661
|
+
parent_project_root=_parent_forge_root,
|
|
662
|
+
force_extensions=extensions,
|
|
663
|
+
)
|
|
664
|
+
elif extensions is True:
|
|
665
|
+
console.print("[dim]Tip: --extensions only applies with --worktree.[/dim]")
|
|
666
|
+
|
|
667
|
+
if no_launch:
|
|
668
|
+
console.print("[dim]Fork created (--no-launch: Claude not started)[/dim]")
|
|
669
|
+
if is_worktree_fork:
|
|
670
|
+
console.print(f"\n[dim]Tip: {_resume_tip_command(fork_manifest)}[/dim]")
|
|
671
|
+
sys.exit(0)
|
|
672
|
+
|
|
673
|
+
runtime_base_url = _get_runtime_base_url(use_sidecar=use_sidecar, effective_url=effective_url)
|
|
674
|
+
|
|
675
|
+
if use_sidecar:
|
|
676
|
+
exit_code = 0
|
|
677
|
+
try:
|
|
678
|
+
exit_code = _launch_claude_for_session(
|
|
679
|
+
manifest=fork_manifest,
|
|
680
|
+
session_id=_fork_uuid if is_worktree_fork else None,
|
|
681
|
+
resume_id=None if is_worktree_fork else parent_session_id,
|
|
682
|
+
effective_template=effective_template,
|
|
683
|
+
runtime_base_url=runtime_base_url,
|
|
684
|
+
context_limit=context_limit,
|
|
685
|
+
use_sidecar=True,
|
|
686
|
+
mounts=mounts,
|
|
687
|
+
image=image,
|
|
688
|
+
fork_session=not is_worktree_fork,
|
|
689
|
+
register_fork=is_worktree_fork,
|
|
690
|
+
system_prompt_file=prompt_file if is_worktree_fork else None,
|
|
691
|
+
name=fork_manifest.name,
|
|
692
|
+
proxy_id=effective_proxy_id,
|
|
693
|
+
)
|
|
694
|
+
finally:
|
|
695
|
+
if incognito:
|
|
696
|
+
console.print(f"\n[dim]Cleaning up incognito fork '{fork_name}'...[/dim]")
|
|
697
|
+
try:
|
|
698
|
+
manager.delete_session(
|
|
699
|
+
fork_name,
|
|
700
|
+
delete_transcripts=True,
|
|
701
|
+
force=True,
|
|
702
|
+
forge_root=fork_manifest.forge_root,
|
|
703
|
+
)
|
|
704
|
+
console.print("[green]Cleanup complete.[/green]")
|
|
705
|
+
except ForgeSessionError as e:
|
|
706
|
+
console.print(f"[yellow]Cleanup warning:[/yellow] {e}")
|
|
707
|
+
sys.exit(exit_code)
|
|
708
|
+
|
|
709
|
+
fork_worktree = Path(fork_manifest.worktree.path) if fork_manifest.worktree else Path.cwd()
|
|
710
|
+
# Check hooks from forge_root (where .claude/ lives), not checkout root
|
|
711
|
+
_fork_forge_root = Path(fork_manifest.forge_root) if fork_manifest.forge_root else fork_worktree
|
|
712
|
+
_sess()._warn_if_hooks_missing(_fork_forge_root)
|
|
713
|
+
_sess()._warn_if_version_outdated()
|
|
714
|
+
active_claude_session_id = _fork_uuid if is_worktree_fork else None
|
|
715
|
+
|
|
716
|
+
if incognito:
|
|
717
|
+
exit_code = 0
|
|
718
|
+
try:
|
|
719
|
+
exit_code = _sess().run_with_active_session(
|
|
720
|
+
session_name=fork_name,
|
|
721
|
+
worktree_path=fork_worktree,
|
|
722
|
+
launch_mode=LAUNCH_MODE_HOST,
|
|
723
|
+
forge_root=fork_manifest.forge_root,
|
|
724
|
+
claude_session_id=active_claude_session_id,
|
|
725
|
+
runner=_invoke_fork,
|
|
726
|
+
)
|
|
727
|
+
finally:
|
|
728
|
+
console.print(f"\n[dim]Cleaning up incognito fork '{fork_name}'...[/dim]")
|
|
729
|
+
try:
|
|
730
|
+
manager.delete_session(
|
|
731
|
+
fork_name,
|
|
732
|
+
delete_transcripts=True,
|
|
733
|
+
force=True,
|
|
734
|
+
forge_root=fork_manifest.forge_root,
|
|
735
|
+
)
|
|
736
|
+
console.print("[green]Cleanup complete.[/green]")
|
|
737
|
+
except ForgeSessionError as e:
|
|
738
|
+
console.print(f"[yellow]Cleanup warning:[/yellow] {e}")
|
|
739
|
+
sys.exit(exit_code)
|
|
740
|
+
else:
|
|
741
|
+
exit_code = _sess().run_with_active_session(
|
|
742
|
+
session_name=fork_name,
|
|
743
|
+
worktree_path=fork_worktree,
|
|
744
|
+
launch_mode=LAUNCH_MODE_HOST,
|
|
745
|
+
forge_root=fork_manifest.forge_root,
|
|
746
|
+
claude_session_id=active_claude_session_id,
|
|
747
|
+
runner=_invoke_fork,
|
|
748
|
+
)
|
|
749
|
+
_print_post_exit_tip(fork_manifest)
|
|
750
|
+
sys.exit(exit_code)
|