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.
Files changed (311) hide show
  1. forge/__init__.py +3 -0
  2. forge/_extensions/agents/.gitkeep +0 -0
  3. forge/_extensions/commands/.gitkeep +0 -0
  4. forge/_extensions/skills/analyze/SKILL.md +87 -0
  5. forge/_extensions/skills/challenge/SKILL.md +91 -0
  6. forge/_extensions/skills/consensus/SKILL.md +120 -0
  7. forge/_extensions/skills/consensus/resources/code_consensus_evaluation.md +94 -0
  8. forge/_extensions/skills/consensus/resources/consensus_evaluation.md +70 -0
  9. forge/_extensions/skills/consensus/resources/synthesis.md +101 -0
  10. forge/_extensions/skills/debate/SKILL.md +116 -0
  11. forge/_extensions/skills/debate/resources/code_debate_evaluation.md +101 -0
  12. forge/_extensions/skills/debate/resources/debate_evaluation.md +90 -0
  13. forge/_extensions/skills/panel/SKILL.md +141 -0
  14. forge/_extensions/skills/panel/resources/synthesis.md +103 -0
  15. forge/_extensions/skills/qa/SKILL.md +704 -0
  16. forge/_extensions/skills/qa/resources/checklist/0-enable.md +78 -0
  17. forge/_extensions/skills/qa/resources/checklist/1-preflight.md +24 -0
  18. forge/_extensions/skills/qa/resources/checklist/10-resume.md +143 -0
  19. forge/_extensions/skills/qa/resources/checklist/11-config.md +150 -0
  20. forge/_extensions/skills/qa/resources/checklist/12-search.md +58 -0
  21. forge/_extensions/skills/qa/resources/checklist/13-guard.md +237 -0
  22. forge/_extensions/skills/qa/resources/checklist/14-workflow.md +305 -0
  23. forge/_extensions/skills/qa/resources/checklist/15-skills.md +155 -0
  24. forge/_extensions/skills/qa/resources/checklist/16-handoff.md +224 -0
  25. forge/_extensions/skills/qa/resources/checklist/17-info.md +50 -0
  26. forge/_extensions/skills/qa/resources/checklist/18-disable.md +84 -0
  27. forge/_extensions/skills/qa/resources/checklist/19-uninstall.md +146 -0
  28. forge/_extensions/skills/qa/resources/checklist/2-extensions.md +188 -0
  29. forge/_extensions/skills/qa/resources/checklist/20-cleanup.md +36 -0
  30. forge/_extensions/skills/qa/resources/checklist/3-auth.md +234 -0
  31. forge/_extensions/skills/qa/resources/checklist/4-proxy.md +481 -0
  32. forge/_extensions/skills/qa/resources/checklist/5-session.md +541 -0
  33. forge/_extensions/skills/qa/resources/checklist/6-hooks.md +275 -0
  34. forge/_extensions/skills/qa/resources/checklist/7-costs.md +309 -0
  35. forge/_extensions/skills/qa/resources/checklist/8-status-line.md +174 -0
  36. forge/_extensions/skills/qa/resources/checklist/9-direct-commands.md +146 -0
  37. forge/_extensions/skills/qa/resources/checklist.md +103 -0
  38. forge/_extensions/skills/qa/resources/report-template.md +62 -0
  39. forge/_extensions/skills/qa/scripts/start-container.sh +529 -0
  40. forge/_extensions/skills/qa/scripts/walkthrough-state.py +1137 -0
  41. forge/_extensions/skills/review/SKILL.md +125 -0
  42. forge/_extensions/skills/review/references/claude-4.6.md +474 -0
  43. forge/_extensions/skills/review/references/claude-4.7.md +710 -0
  44. forge/_extensions/skills/review/references/gemini-3.1.md +546 -0
  45. forge/_extensions/skills/review/references/gpt-5.5.md +490 -0
  46. forge/_extensions/skills/review/references/skills-writing-guide.md +1588 -0
  47. forge/_extensions/skills/review/resources/code-anthropic.md +160 -0
  48. forge/_extensions/skills/review/resources/code-gemini.md +184 -0
  49. forge/_extensions/skills/review/resources/code-openai.md +203 -0
  50. forge/_extensions/skills/review/resources/code.md +160 -0
  51. forge/_extensions/skills/review-docs/SKILL.md +121 -0
  52. forge/_extensions/skills/review-docs/resources/docs-anthropic.md +170 -0
  53. forge/_extensions/skills/review-docs/resources/docs-gemini.md +204 -0
  54. forge/_extensions/skills/review-docs/resources/docs-openai.md +231 -0
  55. forge/_extensions/skills/review-docs/resources/docs.md +170 -0
  56. forge/_extensions/skills/smoke-test/SKILL.md +27 -0
  57. forge/_extensions/skills/smoke-test/scripts/smoke-test.sh +118 -0
  58. forge/_extensions/skills/understand/SKILL.md +148 -0
  59. forge/_extensions/skills/understand/resources/code-anthropic.md +163 -0
  60. forge/_extensions/skills/understand/resources/code-gemini.md +194 -0
  61. forge/_extensions/skills/understand/resources/code-openai.md +181 -0
  62. forge/_extensions/skills/understand/resources/code.md +163 -0
  63. forge/_extensions/skills/understand/resources/docs-anthropic.md +177 -0
  64. forge/_extensions/skills/understand/resources/docs-gemini.md +202 -0
  65. forge/_extensions/skills/understand/resources/docs-openai.md +191 -0
  66. forge/_extensions/skills/understand/resources/docs.md +177 -0
  67. forge/_extensions/skills/walkthrough/SKILL.md +599 -0
  68. forge/_extensions/skills/walkthrough/resources/checklist.md +765 -0
  69. forge/_extensions/skills/walkthrough/scripts/run-in-repo.sh +118 -0
  70. forge/_extensions/skills/walkthrough/scripts/setup-test-repo.sh +198 -0
  71. forge/_extensions/skills/walkthrough/scripts/walkthrough-state.py +1137 -0
  72. forge/backend/__init__.py +174 -0
  73. forge/backend/adapters/__init__.py +38 -0
  74. forge/backend/adapters/litellm.py +158 -0
  75. forge/backend/creation.py +89 -0
  76. forge/backend/registry.py +178 -0
  77. forge/cli/__init__.py +16 -0
  78. forge/cli/auth.py +483 -0
  79. forge/cli/backend.py +298 -0
  80. forge/cli/claude.py +411 -0
  81. forge/cli/config_cmd.py +303 -0
  82. forge/cli/extensions.py +1001 -0
  83. forge/cli/gc.py +165 -0
  84. forge/cli/guard.py +1018 -0
  85. forge/cli/guards.py +106 -0
  86. forge/cli/handoff.py +110 -0
  87. forge/cli/hooks/__init__.py +36 -0
  88. forge/cli/hooks/_group.py +20 -0
  89. forge/cli/hooks/_helpers.py +149 -0
  90. forge/cli/hooks/commands.py +1677 -0
  91. forge/cli/hooks/direct_commands.py +1304 -0
  92. forge/cli/hooks/install.py +232 -0
  93. forge/cli/hooks/policy.py +151 -0
  94. forge/cli/hooks/read_hygiene.py +74 -0
  95. forge/cli/hooks/verification.py +370 -0
  96. forge/cli/logs.py +406 -0
  97. forge/cli/main.py +292 -0
  98. forge/cli/proxy.py +1821 -0
  99. forge/cli/proxy_costs.py +313 -0
  100. forge/cli/search.py +416 -0
  101. forge/cli/session.py +892 -0
  102. forge/cli/session_addendum.py +81 -0
  103. forge/cli/session_fork.py +750 -0
  104. forge/cli/session_handoff.py +141 -0
  105. forge/cli/session_lifecycle.py +2053 -0
  106. forge/cli/session_manage.py +1336 -0
  107. forge/cli/session_memory.py +201 -0
  108. forge/cli/status_line.py +1398 -0
  109. forge/cli/workflow.py +1964 -0
  110. forge/config/__init__.py +110 -0
  111. forge/config/dataclass_utils.py +88 -0
  112. forge/config/defaults/__init__.py +0 -0
  113. forge/config/defaults/backends/__init__.py +0 -0
  114. forge/config/defaults/backends/litellm.yaml +196 -0
  115. forge/config/defaults/templates/__init__.py +0 -0
  116. forge/config/defaults/templates/litellm-anthropic-local.yaml +33 -0
  117. forge/config/defaults/templates/litellm-anthropic.yaml +24 -0
  118. forge/config/defaults/templates/litellm-gemini-flash-local.yaml +37 -0
  119. forge/config/defaults/templates/litellm-gemini-local.yaml +32 -0
  120. forge/config/defaults/templates/litellm-gemini-test.yaml +34 -0
  121. forge/config/defaults/templates/litellm-gemini.yaml +21 -0
  122. forge/config/defaults/templates/litellm-openai-codex-local.yaml +36 -0
  123. forge/config/defaults/templates/litellm-openai-local.yaml +38 -0
  124. forge/config/defaults/templates/litellm-openai.yaml +28 -0
  125. forge/config/defaults/templates/openrouter-anthropic.yaml +23 -0
  126. forge/config/defaults/templates/openrouter-deepseek.yaml +26 -0
  127. forge/config/defaults/templates/openrouter-gemini-flash.yaml +26 -0
  128. forge/config/defaults/templates/openrouter-gemini.yaml +23 -0
  129. forge/config/defaults/templates/openrouter-glm.yaml +23 -0
  130. forge/config/defaults/templates/openrouter-kimi.yaml +30 -0
  131. forge/config/defaults/templates/openrouter-minimax.yaml +26 -0
  132. forge/config/defaults/templates/openrouter-openai-codex.yaml +23 -0
  133. forge/config/defaults/templates/openrouter-openai.yaml +28 -0
  134. forge/config/defaults/templates/openrouter-qwen.yaml +25 -0
  135. forge/config/loader.py +675 -0
  136. forge/config/schema.py +448 -0
  137. forge/core/__init__.py +5 -0
  138. forge/core/auth/__init__.py +67 -0
  139. forge/core/auth/capabilities.py +219 -0
  140. forge/core/auth/credentials_file.py +244 -0
  141. forge/core/auth/protocols.py +18 -0
  142. forge/core/auth/secrets.py +243 -0
  143. forge/core/auth/template_secrets.py +112 -0
  144. forge/core/data/__init__.py +5 -0
  145. forge/core/data/model_catalog.yaml +1522 -0
  146. forge/core/data/pricing.yaml +140 -0
  147. forge/core/data/system_prompt_addendums/__init__.py +0 -0
  148. forge/core/data/system_prompt_addendums/gemini.md +330 -0
  149. forge/core/data/system_prompt_addendums/openai.md +328 -0
  150. forge/core/llm/__init__.py +231 -0
  151. forge/core/llm/clients/__init__.py +14 -0
  152. forge/core/llm/clients/base.py +115 -0
  153. forge/core/llm/clients/litellm.py +619 -0
  154. forge/core/llm/clients/openai_compat.py +244 -0
  155. forge/core/llm/clients/openrouter.py +234 -0
  156. forge/core/llm/credentials.py +439 -0
  157. forge/core/llm/detection.py +86 -0
  158. forge/core/llm/errors.py +44 -0
  159. forge/core/llm/protocols.py +80 -0
  160. forge/core/llm/types.py +176 -0
  161. forge/core/logging.py +146 -0
  162. forge/core/models/__init__.py +91 -0
  163. forge/core/models/catalog.py +467 -0
  164. forge/core/models/pricing.py +165 -0
  165. forge/core/models/types.py +167 -0
  166. forge/core/naming.py +212 -0
  167. forge/core/ops/__init__.py +73 -0
  168. forge/core/ops/context.py +141 -0
  169. forge/core/ops/gc.py +802 -0
  170. forge/core/ops/proxy.py +146 -0
  171. forge/core/ops/resolution.py +135 -0
  172. forge/core/ops/session.py +344 -0
  173. forge/core/ops/session_context.py +548 -0
  174. forge/core/paths.py +38 -0
  175. forge/core/process.py +54 -0
  176. forge/core/reactive/__init__.py +38 -0
  177. forge/core/reactive/cost_tracking.py +300 -0
  178. forge/core/reactive/env.py +180 -0
  179. forge/core/reactive/proxy.py +78 -0
  180. forge/core/reactive/routing.py +622 -0
  181. forge/core/reactive/session_runner.py +185 -0
  182. forge/core/reactive/structured_output.py +62 -0
  183. forge/core/reactive/tagger.py +94 -0
  184. forge/core/reactive/throttle.py +132 -0
  185. forge/core/state/__init__.py +59 -0
  186. forge/core/state/exceptions.py +59 -0
  187. forge/core/state/io.py +140 -0
  188. forge/core/state/lock.py +99 -0
  189. forge/core/state/timestamps.py +60 -0
  190. forge/core/transcript.py +78 -0
  191. forge/core/typing_helpers.py +24 -0
  192. forge/core/workqueue/__init__.py +67 -0
  193. forge/core/workqueue/queue.py +552 -0
  194. forge/core/workqueue/types.py +63 -0
  195. forge/guard/__init__.py +26 -0
  196. forge/guard/deterministic/__init__.py +26 -0
  197. forge/guard/deterministic/base.py +158 -0
  198. forge/guard/deterministic/coding_standards.py +256 -0
  199. forge/guard/deterministic/registry.py +148 -0
  200. forge/guard/deterministic/tdd.py +171 -0
  201. forge/guard/engine.py +216 -0
  202. forge/guard/protocols.py +91 -0
  203. forge/guard/queries.py +96 -0
  204. forge/guard/semantic/__init__.py +34 -0
  205. forge/guard/semantic/promotion.py +18 -0
  206. forge/guard/semantic/supervisor.py +813 -0
  207. forge/guard/semantic/verdict.py +183 -0
  208. forge/guard/store.py +124 -0
  209. forge/guard/team/__init__.py +6 -0
  210. forge/guard/team/config.py +24 -0
  211. forge/guard/team/handlers.py +209 -0
  212. forge/guard/team/prompts.py +41 -0
  213. forge/guard/types.py +125 -0
  214. forge/guard/workflow/__init__.py +17 -0
  215. forge/guard/workflow/branches.py +67 -0
  216. forge/guard/workflow/config.py +63 -0
  217. forge/guard/workflow/divergence.py +113 -0
  218. forge/guard/workflow/policy.py +87 -0
  219. forge/guard/workflow/stages.py +205 -0
  220. forge/install/__init__.py +55 -0
  221. forge/install/cli.py +281 -0
  222. forge/install/exceptions.py +163 -0
  223. forge/install/hooks.py +109 -0
  224. forge/install/installer.py +1037 -0
  225. forge/install/models.py +321 -0
  226. forge/install/preset.py +272 -0
  227. forge/install/settings_merge.py +831 -0
  228. forge/install/tracking.py +238 -0
  229. forge/install/version.py +141 -0
  230. forge/proxy/__init__.py +0 -0
  231. forge/proxy/base_client.py +181 -0
  232. forge/proxy/client_adapter.py +476 -0
  233. forge/proxy/client_factory.py +531 -0
  234. forge/proxy/converters.py +1206 -0
  235. forge/proxy/cost_logger.py +132 -0
  236. forge/proxy/cost_tracker.py +242 -0
  237. forge/proxy/data_models.py +338 -0
  238. forge/proxy/error_hints.py +92 -0
  239. forge/proxy/metrics.py +222 -0
  240. forge/proxy/model_spec.py +158 -0
  241. forge/proxy/proxies.py +333 -0
  242. forge/proxy/proxy_identity.py +134 -0
  243. forge/proxy/proxy_orchestrator.py +1018 -0
  244. forge/proxy/proxy_startup.py +54 -0
  245. forge/proxy/server.py +1561 -0
  246. forge/proxy/utils.py +537 -0
  247. forge/review/__init__.py +6 -0
  248. forge/review/adversarial.py +111 -0
  249. forge/review/consensus.py +236 -0
  250. forge/review/engine.py +356 -0
  251. forge/review/models.py +437 -0
  252. forge/review/resources/__init__.py +5 -0
  253. forge/review/resources/codereview-performance.md +85 -0
  254. forge/review/resources/codereview-quick.md +75 -0
  255. forge/review/resources/codereview-security.md +92 -0
  256. forge/review/resources/codereview.md +85 -0
  257. forge/review/resources/docreview-quick.md +75 -0
  258. forge/review/resources/docreview.md +86 -0
  259. forge/review/resources/thinkdeep.md +89 -0
  260. forge/review/routing.py +368 -0
  261. forge/review/synthesis.py +73 -0
  262. forge/runtime_config.py +438 -0
  263. forge/search/__init__.py +55 -0
  264. forge/search/bm25_store.py +264 -0
  265. forge/search/content_store.py +197 -0
  266. forge/search/engine.py +352 -0
  267. forge/search/exceptions.py +51 -0
  268. forge/search/extractor.py +234 -0
  269. forge/search/index_state.py +295 -0
  270. forge/search/store.py +215 -0
  271. forge/search/tokenizer.py +24 -0
  272. forge/session/__init__.py +130 -0
  273. forge/session/active.py +339 -0
  274. forge/session/artifacts.py +202 -0
  275. forge/session/claude/__init__.py +50 -0
  276. forge/session/claude/cleanup.py +105 -0
  277. forge/session/claude/invoke.py +236 -0
  278. forge/session/claude/paths.py +200 -0
  279. forge/session/cleanup.py +216 -0
  280. forge/session/config.py +34 -0
  281. forge/session/direct_model.py +107 -0
  282. forge/session/effective.py +169 -0
  283. forge/session/exceptions.py +255 -0
  284. forge/session/handoff.py +881 -0
  285. forge/session/handoff_agent.py +544 -0
  286. forge/session/hooks/__init__.py +35 -0
  287. forge/session/hooks/models.py +73 -0
  288. forge/session/hooks/session_start.py +507 -0
  289. forge/session/identity.py +84 -0
  290. forge/session/index.py +553 -0
  291. forge/session/manager.py +1506 -0
  292. forge/session/models.py +572 -0
  293. forge/session/overrides.py +344 -0
  294. forge/session/plan_resolution.py +286 -0
  295. forge/session/prev_sessions.py +128 -0
  296. forge/session/store.py +431 -0
  297. forge/session/validation.py +47 -0
  298. forge/session/worktree/__init__.py +65 -0
  299. forge/session/worktree/cleanup.py +262 -0
  300. forge/session/worktree/config_copy.py +203 -0
  301. forge/session/worktree/create.py +332 -0
  302. forge/sidecar/__init__.py +29 -0
  303. forge/sidecar/container.py +161 -0
  304. forge/sidecar/docker.py +86 -0
  305. forge/sidecar/secrets.py +19 -0
  306. multi_forge-0.2.0.dist-info/METADATA +242 -0
  307. multi_forge-0.2.0.dist-info/RECORD +311 -0
  308. multi_forge-0.2.0.dist-info/WHEEL +4 -0
  309. multi_forge-0.2.0.dist-info/entry_points.txt +2 -0
  310. multi_forge-0.2.0.dist-info/licenses/LICENSE +203 -0
  311. 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