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,507 @@
|
|
|
1
|
+
"""SessionStart hook handler.
|
|
2
|
+
|
|
3
|
+
Claude Code invokes this hook with session info on stdin. The hook:
|
|
4
|
+
1. Resolves session name using env var / UUID lookup / directory scan
|
|
5
|
+
2. Updates manifest confirmed fields (claude_session_id, transcript_path, proxy)
|
|
6
|
+
|
|
7
|
+
1:1 model: UUID is overwritten on /compact or /clear (no accumulation).
|
|
8
|
+
Transcript rollover still captured before overwriting.
|
|
9
|
+
|
|
10
|
+
CRITICAL: Always exit 0 - don't break Claude on errors.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import cast
|
|
20
|
+
from urllib.parse import urlparse
|
|
21
|
+
|
|
22
|
+
from forge.core.state import (
|
|
23
|
+
FileLockTimeoutError,
|
|
24
|
+
now_iso,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
from ..artifacts import get_artifact_paths, resolve_forge_root, safe_copy_file
|
|
28
|
+
from ..exceptions import SessionFileNotFoundError
|
|
29
|
+
from ..index import IndexStore
|
|
30
|
+
from ..models import SessionState, StartedWithProxy
|
|
31
|
+
from ..store import HOOK_LOCK_TIMEOUT_S, SessionStore
|
|
32
|
+
from .models import HookInput, HookResult, HookSource, ResolutionContext
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
# Proxy-related environment variable names
|
|
37
|
+
ENV_ACTIVE_TEMPLATE = "ACTIVE_TEMPLATE"
|
|
38
|
+
ENV_ANTHROPIC_BASE_URL = "ANTHROPIC_BASE_URL"
|
|
39
|
+
|
|
40
|
+
# Environment variable names
|
|
41
|
+
ENV_FORK_NAME = "FORGE_FORK_NAME"
|
|
42
|
+
ENV_SESSION = "FORGE_SESSION"
|
|
43
|
+
ENV_PARENT_SESSION = "FORGE_PARENT_SESSION"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def resolve_session_name(
|
|
47
|
+
source: HookSource,
|
|
48
|
+
session_id: str,
|
|
49
|
+
cwd: Path,
|
|
50
|
+
index_store: IndexStore | None = None,
|
|
51
|
+
) -> ResolutionContext:
|
|
52
|
+
"""Resolve session name using three-level fallback.
|
|
53
|
+
|
|
54
|
+
Resolution order:
|
|
55
|
+
1. FORGE_FORK_NAME env var (fork registration path)
|
|
56
|
+
2. FORGE_SESSION env var (fast path for startup/resume from our CLI)
|
|
57
|
+
3. IndexStore UUID reverse lookup (index-backed, fast)
|
|
58
|
+
|
|
59
|
+
No CWD-based scan — FORGE_SESSION is the authoritative source.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
source: What triggered the hook (for logging/debugging context).
|
|
63
|
+
session_id: Claude's session UUID.
|
|
64
|
+
cwd: Current working directory (worktree root).
|
|
65
|
+
index_store: Optional IndexStore for UUID lookup (uses default if None).
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
ResolutionContext with session_name and resolution_method if found.
|
|
69
|
+
"""
|
|
70
|
+
ctx = ResolutionContext()
|
|
71
|
+
|
|
72
|
+
# Prefer FORGE_FORGE_ROOT env var (set by CLI launcher for exact scope).
|
|
73
|
+
# Fall back to CWD derivation if not set.
|
|
74
|
+
env_forge_root = os.environ.get("FORGE_FORGE_ROOT")
|
|
75
|
+
if env_forge_root:
|
|
76
|
+
ctx.forge_root = env_forge_root
|
|
77
|
+
else:
|
|
78
|
+
try:
|
|
79
|
+
from forge.core.ops.context import find_forge_root
|
|
80
|
+
|
|
81
|
+
_cwd_forge_root = find_forge_root(cwd)
|
|
82
|
+
if _cwd_forge_root:
|
|
83
|
+
ctx.forge_root = str(_cwd_forge_root)
|
|
84
|
+
except Exception:
|
|
85
|
+
pass # Fail-open: forge_root stays None
|
|
86
|
+
|
|
87
|
+
# 1. Check FORGE_FORK_NAME (fork registration)
|
|
88
|
+
fork_name = os.environ.get(ENV_FORK_NAME)
|
|
89
|
+
if fork_name:
|
|
90
|
+
ctx.session_name = fork_name
|
|
91
|
+
ctx.resolution_method = "fork_env"
|
|
92
|
+
return ctx
|
|
93
|
+
|
|
94
|
+
# 2. Check FORGE_SESSION (fast path)
|
|
95
|
+
session_name = os.environ.get(ENV_SESSION)
|
|
96
|
+
if session_name:
|
|
97
|
+
ctx.session_name = session_name
|
|
98
|
+
ctx.resolution_method = "session_env"
|
|
99
|
+
return ctx
|
|
100
|
+
|
|
101
|
+
# 3. IndexStore UUID reverse lookup (index-backed, O(1) after parse)
|
|
102
|
+
store = index_store or IndexStore()
|
|
103
|
+
try:
|
|
104
|
+
uuid_result = store.find_session_by_uuid(session_id, timeout_s=HOOK_LOCK_TIMEOUT_S)
|
|
105
|
+
except FileLockTimeoutError:
|
|
106
|
+
ctx.errors.append("Index lock contended (skipped UUID lookup)")
|
|
107
|
+
uuid_result = None
|
|
108
|
+
except Exception as e:
|
|
109
|
+
# Logged broad catch: IndexStore can raise IndexCorruptedError, OSError,
|
|
110
|
+
# KeyError, etc. Hook must degrade to directory scan, never crash.
|
|
111
|
+
logger.debug("UUID lookup failed for %s: %s", session_id, e)
|
|
112
|
+
uuid_result = None
|
|
113
|
+
if uuid_result:
|
|
114
|
+
ctx.session_name = uuid_result[0] # display name
|
|
115
|
+
ctx.forge_root = uuid_result[1] # for scoped subsequent lookups
|
|
116
|
+
ctx.resolution_method = "uuid_lookup"
|
|
117
|
+
return ctx
|
|
118
|
+
|
|
119
|
+
# No CWD scan — FORGE_SESSION env var is the authoritative source.
|
|
120
|
+
ctx.errors.append(f"Could not resolve session name: no env vars, " f"UUID {session_id[:8]}... not in index")
|
|
121
|
+
return ctx
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def resolve_session_for_hook(
|
|
125
|
+
cwd: Path,
|
|
126
|
+
session_id: str | None = None,
|
|
127
|
+
) -> str | None:
|
|
128
|
+
"""Resolve session name for a hook invocation (fail-open).
|
|
129
|
+
|
|
130
|
+
Resolution order:
|
|
131
|
+
1. FORGE_FORK_NAME env var
|
|
132
|
+
2. FORGE_SESSION env var
|
|
133
|
+
3. IndexStore UUID reverse lookup (fast, index-backed)
|
|
134
|
+
|
|
135
|
+
No CWD-based scan — FORGE_SESSION is the authoritative source.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
Session name if found, None otherwise (fail-open).
|
|
139
|
+
"""
|
|
140
|
+
# 1. Check env vars
|
|
141
|
+
fork_name = os.environ.get(ENV_FORK_NAME)
|
|
142
|
+
if fork_name:
|
|
143
|
+
return fork_name
|
|
144
|
+
name = os.environ.get(ENV_SESSION)
|
|
145
|
+
if name:
|
|
146
|
+
return name
|
|
147
|
+
|
|
148
|
+
# 2. IndexStore UUID lookup (fast path)
|
|
149
|
+
if session_id:
|
|
150
|
+
try:
|
|
151
|
+
store = IndexStore()
|
|
152
|
+
uuid_result = store.find_session_by_uuid(session_id, timeout_s=HOOK_LOCK_TIMEOUT_S)
|
|
153
|
+
if uuid_result:
|
|
154
|
+
return uuid_result[0] # display name
|
|
155
|
+
except Exception:
|
|
156
|
+
pass # Fail-open: index unavailable
|
|
157
|
+
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _resolve_store_root(name: str, cwd: Path, forge_root: str | None = None) -> str:
|
|
162
|
+
"""Resolve the manifest storage root for a session (fail-open).
|
|
163
|
+
|
|
164
|
+
When forge_root is provided (from CWD or UUID resolution), use it
|
|
165
|
+
directly. Otherwise fall back to index lookup or raw CWD.
|
|
166
|
+
"""
|
|
167
|
+
if forge_root:
|
|
168
|
+
return forge_root
|
|
169
|
+
try:
|
|
170
|
+
index = IndexStore()
|
|
171
|
+
entry = index.get_session(name)
|
|
172
|
+
return entry.forge_root or entry.worktree_path
|
|
173
|
+
except Exception:
|
|
174
|
+
return str(cwd)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def resolve_session_store(
|
|
178
|
+
cwd: Path,
|
|
179
|
+
session_id: str | None = None,
|
|
180
|
+
) -> SessionStore | None:
|
|
181
|
+
"""Resolve SessionStore for a hook invocation (fail-open).
|
|
182
|
+
|
|
183
|
+
Uses the full resolution context (including forge_root) to find the
|
|
184
|
+
correct manifest root. Returns None if session name cannot be determined.
|
|
185
|
+
"""
|
|
186
|
+
name = resolve_session_for_hook(cwd, session_id=session_id)
|
|
187
|
+
if not name:
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
# Derive forge_root from env or CWD for scoped store root resolution
|
|
191
|
+
forge_root = os.environ.get("FORGE_FORGE_ROOT")
|
|
192
|
+
if not forge_root:
|
|
193
|
+
try:
|
|
194
|
+
from forge.core.ops.context import find_forge_root
|
|
195
|
+
|
|
196
|
+
fr = find_forge_root(cwd)
|
|
197
|
+
if fr:
|
|
198
|
+
forge_root = str(fr)
|
|
199
|
+
except Exception:
|
|
200
|
+
pass
|
|
201
|
+
|
|
202
|
+
store_root = _resolve_store_root(name, cwd, forge_root=forge_root)
|
|
203
|
+
return SessionStore(store_root, name)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _should_capture_started_with_proxy(base_url: str | None) -> bool:
|
|
207
|
+
"""Return True if we should capture started_with_proxy info.
|
|
208
|
+
|
|
209
|
+
Any non-empty ANTHROPIC_BASE_URL indicates proxy usage, so capture proxy info.
|
|
210
|
+
The previous localhost gating was overly restrictive (missed remote proxies,
|
|
211
|
+
Docker hostnames, IPv6, etc.).
|
|
212
|
+
"""
|
|
213
|
+
return bool(base_url)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _parse_port(base_url: str) -> int | None:
|
|
217
|
+
try:
|
|
218
|
+
parsed = urlparse(base_url)
|
|
219
|
+
except ValueError:
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
if parsed.port is not None:
|
|
223
|
+
return int(parsed.port)
|
|
224
|
+
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _resolve_env_value(*, cwd: Path, key: str) -> str | None:
|
|
229
|
+
"""Resolve an env var value. No forge.env fallback."""
|
|
230
|
+
return os.environ.get(key) or None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def handle_session_start(
|
|
234
|
+
hook_input: HookInput,
|
|
235
|
+
cwd: Path,
|
|
236
|
+
index_store: IndexStore | None = None,
|
|
237
|
+
) -> HookResult:
|
|
238
|
+
"""Handle SessionStart hook invocation.
|
|
239
|
+
|
|
240
|
+
This is the main entry point called by the CLI command.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
hook_input: Parsed hook input from Claude Code.
|
|
244
|
+
cwd: Current working directory (worktree root).
|
|
245
|
+
index_store: Optional IndexStore (uses default if None).
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
HookResult with success status and session info.
|
|
249
|
+
"""
|
|
250
|
+
result = HookResult(
|
|
251
|
+
success=False,
|
|
252
|
+
received_session_id=hook_input.session_id,
|
|
253
|
+
received_transcript_path=hook_input.transcript_path,
|
|
254
|
+
received_source=hook_input.source,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
ctx = resolve_session_name(
|
|
258
|
+
source=hook_input.source,
|
|
259
|
+
session_id=hook_input.session_id,
|
|
260
|
+
cwd=cwd,
|
|
261
|
+
index_store=index_store,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
if not ctx.resolved:
|
|
265
|
+
result.error = "session_not_found"
|
|
266
|
+
result.message = "; ".join(ctx.errors) if ctx.errors else "Could not resolve session name"
|
|
267
|
+
return result
|
|
268
|
+
|
|
269
|
+
session_name = ctx.session_name
|
|
270
|
+
assert session_name is not None # for type checker
|
|
271
|
+
resolved_forge_root = ctx.forge_root # May be None if resolved via env var
|
|
272
|
+
|
|
273
|
+
result.session_name = session_name
|
|
274
|
+
|
|
275
|
+
manifest_store = SessionStore(_resolve_store_root(session_name, cwd, resolved_forge_root), session_name)
|
|
276
|
+
|
|
277
|
+
# Collect transcript rollover capture info under lock, but copy outside lock.
|
|
278
|
+
rollover: tuple[str, str | None] | None = None # (previous_session_id, previous_transcript_path)
|
|
279
|
+
|
|
280
|
+
new_uuid = hook_input.session_id
|
|
281
|
+
|
|
282
|
+
try:
|
|
283
|
+
|
|
284
|
+
def _mutate(state: SessionState) -> None:
|
|
285
|
+
nonlocal rollover
|
|
286
|
+
|
|
287
|
+
# Verify state name matches resolved name.
|
|
288
|
+
if state.name != session_name:
|
|
289
|
+
raise ValueError(f"State name '{state.name}' != resolved name '{session_name}'")
|
|
290
|
+
|
|
291
|
+
confirmed = state.confirmed
|
|
292
|
+
|
|
293
|
+
current_uuid = confirmed.claude_session_id
|
|
294
|
+
current_transcript_path = confirmed.transcript_path
|
|
295
|
+
|
|
296
|
+
# Capture transcript pointer before overwriting UUID on compact/clear
|
|
297
|
+
if hook_input.source in ("compact", "clear") and current_uuid and current_uuid != new_uuid:
|
|
298
|
+
rollover = (str(current_uuid), current_transcript_path)
|
|
299
|
+
|
|
300
|
+
# Diagnostic: detect pre-seed mismatch on startup
|
|
301
|
+
if hook_input.source == "startup" and current_uuid and current_uuid != new_uuid:
|
|
302
|
+
logger.warning(
|
|
303
|
+
"SessionStart: pre-seeded UUID mismatch " "(manifest=%s..., hook=%s...)",
|
|
304
|
+
current_uuid[:8],
|
|
305
|
+
new_uuid[:8],
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
# 1:1 model: overwrite UUID (no accumulation)
|
|
309
|
+
confirmed.claude_session_id = new_uuid
|
|
310
|
+
|
|
311
|
+
confirmed.transcript_path = hook_input.transcript_path
|
|
312
|
+
confirmed.confirmed_at = now_iso()
|
|
313
|
+
confirmed.confirmed_by = f"hook:SessionStart:{hook_input.source}"
|
|
314
|
+
|
|
315
|
+
# Skip proxy capture for sidecar sessions (container-local
|
|
316
|
+
# localhost:8085 is meaningless from host perspective)
|
|
317
|
+
base_url = _resolve_env_value(cwd=cwd, key=ENV_ANTHROPIC_BASE_URL)
|
|
318
|
+
if _should_capture_started_with_proxy(base_url) and not confirmed.is_sandboxed:
|
|
319
|
+
template = _resolve_env_value(cwd=cwd, key=ENV_ACTIVE_TEMPLATE)
|
|
320
|
+
proxy_id: str | None = None
|
|
321
|
+
|
|
322
|
+
# Derive proxy_id from registry (current truth, not stale env)
|
|
323
|
+
if base_url:
|
|
324
|
+
try:
|
|
325
|
+
from forge.proxy.proxies import ProxyRegistryStore
|
|
326
|
+
|
|
327
|
+
entry = ProxyRegistryStore().find_by_base_url(base_url)
|
|
328
|
+
if entry:
|
|
329
|
+
proxy_id = entry.proxy_id
|
|
330
|
+
if not template:
|
|
331
|
+
template = entry.template
|
|
332
|
+
except Exception:
|
|
333
|
+
pass # Fail-open: registry unavailable
|
|
334
|
+
|
|
335
|
+
if base_url:
|
|
336
|
+
confirmed.started_with_proxy = StartedWithProxy(
|
|
337
|
+
base_url=base_url,
|
|
338
|
+
proxy_id=proxy_id,
|
|
339
|
+
template=template,
|
|
340
|
+
port=_parse_port(base_url),
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
manifest_store.update(timeout_s=HOOK_LOCK_TIMEOUT_S, mutate=_mutate)
|
|
344
|
+
|
|
345
|
+
except FileLockTimeoutError:
|
|
346
|
+
# Always fail-open: do not break Claude.
|
|
347
|
+
print(
|
|
348
|
+
"[forge] SessionStart: skipped manifest update (lock contention)",
|
|
349
|
+
file=sys.stderr,
|
|
350
|
+
)
|
|
351
|
+
result.success = True
|
|
352
|
+
result.message = "Skipped manifest update due to lock contention"
|
|
353
|
+
result.error = "skip_lock_contended"
|
|
354
|
+
return result
|
|
355
|
+
|
|
356
|
+
except Exception as e:
|
|
357
|
+
# Always fail-open: do not break Claude.
|
|
358
|
+
msg = str(e)
|
|
359
|
+
|
|
360
|
+
if "State name" in msg and "resolved name" in msg:
|
|
361
|
+
result.error = "name_mismatch"
|
|
362
|
+
result.message = msg
|
|
363
|
+
return result
|
|
364
|
+
|
|
365
|
+
if isinstance(e, SessionFileNotFoundError):
|
|
366
|
+
result.error = "manifest_not_found"
|
|
367
|
+
result.message = f"No manifest found for session '{session_name}' in {cwd}"
|
|
368
|
+
return result
|
|
369
|
+
|
|
370
|
+
result.error = "manifest_update_failed"
|
|
371
|
+
result.message = f"Failed to update manifest: {e}"
|
|
372
|
+
return result
|
|
373
|
+
|
|
374
|
+
# Sync UUID to index and active registry (best-effort, non-critical).
|
|
375
|
+
# Pass forge_root for scoped lookup to avoid updating the wrong project's entry.
|
|
376
|
+
try:
|
|
377
|
+
idx = index_store or IndexStore()
|
|
378
|
+
idx.update_uuid(session_name, new_uuid, forge_root=resolved_forge_root)
|
|
379
|
+
except Exception:
|
|
380
|
+
pass # Index sync is opportunistic; CLI commands also sync
|
|
381
|
+
|
|
382
|
+
try:
|
|
383
|
+
from forge.session.active import ActiveSessionStore
|
|
384
|
+
|
|
385
|
+
ActiveSessionStore().update_uuid(session_name, new_uuid, forge_root=resolved_forge_root)
|
|
386
|
+
except Exception:
|
|
387
|
+
pass # Runtime registry is best-effort; stale-pruning covers crashes
|
|
388
|
+
|
|
389
|
+
# Best-effort capture of the prior transcript copy.
|
|
390
|
+
if rollover is not None:
|
|
391
|
+
previous_session_id, previous_transcript_path = rollover
|
|
392
|
+
_capture_transcript_rollover(
|
|
393
|
+
cwd=cwd,
|
|
394
|
+
session_name=session_name,
|
|
395
|
+
forge_root=resolved_forge_root,
|
|
396
|
+
previous_session_id=previous_session_id,
|
|
397
|
+
previous_transcript_path=previous_transcript_path,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
result.success = True
|
|
401
|
+
result.message = f"Session '{session_name}' reconciled via {ctx.resolution_method}"
|
|
402
|
+
return result
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _capture_transcript_rollover(
|
|
406
|
+
*,
|
|
407
|
+
cwd: Path,
|
|
408
|
+
session_name: str,
|
|
409
|
+
forge_root: str | None,
|
|
410
|
+
previous_session_id: str,
|
|
411
|
+
previous_transcript_path: str | None,
|
|
412
|
+
) -> None:
|
|
413
|
+
"""Best-effort capture of a transcript before compact/clear rollover.
|
|
414
|
+
|
|
415
|
+
This function must never raise (SessionStart hook must not break Claude).
|
|
416
|
+
"""
|
|
417
|
+
|
|
418
|
+
if not previous_transcript_path:
|
|
419
|
+
return
|
|
420
|
+
|
|
421
|
+
try:
|
|
422
|
+
project_root = Path(forge_root) if forge_root else resolve_forge_root(cwd)
|
|
423
|
+
paths = get_artifact_paths(project_root, session_name)
|
|
424
|
+
|
|
425
|
+
src = Path(previous_transcript_path)
|
|
426
|
+
dst_abs = paths.transcripts_abs / f"{previous_session_id}.jsonl"
|
|
427
|
+
dst_rel = paths.transcripts_rel / f"{previous_session_id}.jsonl"
|
|
428
|
+
|
|
429
|
+
# Idempotent copy: skip if already captured.
|
|
430
|
+
copied = safe_copy_file(src, dst_abs, overwrite=False)
|
|
431
|
+
|
|
432
|
+
store = SessionStore(_resolve_store_root(session_name, cwd, forge_root), session_name)
|
|
433
|
+
|
|
434
|
+
def _mutate(state: SessionState) -> None:
|
|
435
|
+
artifacts = state.confirmed.artifacts
|
|
436
|
+
transcripts = artifacts.get("transcripts")
|
|
437
|
+
if isinstance(transcripts, list):
|
|
438
|
+
for artifact in transcripts:
|
|
439
|
+
if not isinstance(artifact, dict):
|
|
440
|
+
continue
|
|
441
|
+
if artifact.get("session_id") == previous_session_id and artifact.get("copied_path") == str(
|
|
442
|
+
dst_rel
|
|
443
|
+
):
|
|
444
|
+
return
|
|
445
|
+
|
|
446
|
+
_append_artifact_entry(
|
|
447
|
+
artifacts,
|
|
448
|
+
kind="transcripts",
|
|
449
|
+
entry={
|
|
450
|
+
"captured_at": now_iso(),
|
|
451
|
+
"reason": "rollover",
|
|
452
|
+
"source_path": previous_transcript_path,
|
|
453
|
+
"session_id": previous_session_id,
|
|
454
|
+
"copied_path": str(dst_rel),
|
|
455
|
+
"copied": copied,
|
|
456
|
+
},
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
store.update(timeout_s=HOOK_LOCK_TIMEOUT_S, mutate=_mutate)
|
|
460
|
+
except Exception as e:
|
|
461
|
+
print(f"[forge] Transcript rollover failed: {e}", file=sys.stderr)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _append_artifact_entry(
|
|
465
|
+
confirmed_artifacts: dict[str, object],
|
|
466
|
+
*,
|
|
467
|
+
kind: str,
|
|
468
|
+
entry: dict[str, object],
|
|
469
|
+
) -> None:
|
|
470
|
+
"""Append an artifact record under confirmed.artifacts in a stable shape."""
|
|
471
|
+
items = confirmed_artifacts.get(kind)
|
|
472
|
+
if items is None:
|
|
473
|
+
confirmed_artifacts[kind] = [entry]
|
|
474
|
+
return
|
|
475
|
+
|
|
476
|
+
if not isinstance(items, list):
|
|
477
|
+
confirmed_artifacts[kind] = [entry]
|
|
478
|
+
return
|
|
479
|
+
|
|
480
|
+
items.append(entry)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def parse_hook_input(data: dict[str, object]) -> HookInput | None:
|
|
484
|
+
"""Parse hook input from JSON dict.
|
|
485
|
+
|
|
486
|
+
Args:
|
|
487
|
+
data: Dict from JSON stdin.
|
|
488
|
+
|
|
489
|
+
Returns:
|
|
490
|
+
HookInput if valid, None if missing required fields.
|
|
491
|
+
"""
|
|
492
|
+
session_id = data.get("session_id")
|
|
493
|
+
transcript_path = data.get("transcript_path")
|
|
494
|
+
source = data.get("source")
|
|
495
|
+
|
|
496
|
+
if not session_id or not isinstance(session_id, str):
|
|
497
|
+
return None
|
|
498
|
+
if not transcript_path or not isinstance(transcript_path, str):
|
|
499
|
+
return None
|
|
500
|
+
if source not in ("startup", "resume", "compact", "clear"):
|
|
501
|
+
return None
|
|
502
|
+
|
|
503
|
+
return HookInput(
|
|
504
|
+
session_id=session_id,
|
|
505
|
+
transcript_path=transcript_path,
|
|
506
|
+
source=cast(HookSource, source),
|
|
507
|
+
)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Session identity helpers for project-scoped session names.
|
|
2
|
+
|
|
3
|
+
The session index and active-session registry use compound keys to allow
|
|
4
|
+
the same session name in different projects. The key format is:
|
|
5
|
+
|
|
6
|
+
{name}|{sha256(forge_root)[:12]}
|
|
7
|
+
|
|
8
|
+
Both IndexStore and ActiveSessionStore share these helpers to avoid
|
|
9
|
+
duplicating compound-key logic.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
from typing import Any, Mapping
|
|
16
|
+
|
|
17
|
+
from forge.session.exceptions import AmbiguousSessionError
|
|
18
|
+
|
|
19
|
+
_KEY_SEP = "|"
|
|
20
|
+
_HASH_LEN = 12
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def session_name_from_key(key: str) -> str:
|
|
24
|
+
"""Extract the display name from a compound index key.
|
|
25
|
+
|
|
26
|
+
``planner|a1b2c3d4e5f6`` -> ``planner``
|
|
27
|
+
"""
|
|
28
|
+
return key.split(_KEY_SEP, 1)[0]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def make_scoped_key(name: str, forge_root: str) -> str:
|
|
32
|
+
"""Build a deterministic compound key for a (name, forge_root) pair."""
|
|
33
|
+
h = hashlib.sha256(forge_root.encode()).hexdigest()[:_HASH_LEN]
|
|
34
|
+
return f"{name}{_KEY_SEP}{h}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def resolve_key_strict(
|
|
38
|
+
sessions: Mapping[str, Any],
|
|
39
|
+
name: str,
|
|
40
|
+
forge_root: str | None,
|
|
41
|
+
) -> str | None:
|
|
42
|
+
"""Resolve a session key for user-facing commands.
|
|
43
|
+
|
|
44
|
+
When ``forge_root`` is provided, returns the deterministic scoped key
|
|
45
|
+
if it exists (O(1)). When ``forge_root`` is None, scans for any matching
|
|
46
|
+
prefix and raises ``AmbiguousSessionError`` if multiple matches exist.
|
|
47
|
+
"""
|
|
48
|
+
if forge_root is not None:
|
|
49
|
+
key = make_scoped_key(name, forge_root)
|
|
50
|
+
return key if key in sessions else None
|
|
51
|
+
|
|
52
|
+
prefix = f"{name}{_KEY_SEP}"
|
|
53
|
+
matches = [k for k in sessions if k.startswith(prefix)]
|
|
54
|
+
if len(matches) == 1:
|
|
55
|
+
return matches[0]
|
|
56
|
+
if len(matches) > 1:
|
|
57
|
+
roots = []
|
|
58
|
+
for k in matches:
|
|
59
|
+
entry = sessions[k]
|
|
60
|
+
root = getattr(entry, "forge_root", None) or getattr(entry, "worktree_path", "?")
|
|
61
|
+
roots.append(str(root))
|
|
62
|
+
raise AmbiguousSessionError(name, roots)
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def resolve_key_best_effort(
|
|
67
|
+
sessions: Mapping[str, Any],
|
|
68
|
+
name: str,
|
|
69
|
+
forge_root: str | None,
|
|
70
|
+
) -> str | None:
|
|
71
|
+
"""Resolve a session key for hooks and cleanup paths (fail-open).
|
|
72
|
+
|
|
73
|
+
When ``forge_root`` is provided, O(1) lookup. When None, returns the
|
|
74
|
+
first prefix match without raising on ambiguity.
|
|
75
|
+
"""
|
|
76
|
+
if forge_root is not None:
|
|
77
|
+
key = make_scoped_key(name, forge_root)
|
|
78
|
+
return key if key in sessions else None
|
|
79
|
+
|
|
80
|
+
prefix = f"{name}{_KEY_SEP}"
|
|
81
|
+
for k in sessions:
|
|
82
|
+
if k.startswith(prefix):
|
|
83
|
+
return k
|
|
84
|
+
return None
|