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,548 @@
1
+ """Session context introspection (command-core).
2
+
3
+ Builds a structured view of everything Forge knows about a session:
4
+ metadata, proxy routing, model family, tier mappings, and policy state.
5
+
6
+ Used by:
7
+ - ``forge session context`` CLI command
8
+ - Skills auto-detecting model family via ``--field model_family``
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import os
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from forge.session import (
20
+ ForgeSessionError,
21
+ SessionManager,
22
+ SessionState,
23
+ SessionStore,
24
+ compute_effective_intent,
25
+ )
26
+ from forge.session.exceptions import AmbiguousSessionError
27
+ from forge.session.index import IndexStore
28
+
29
+ _log = logging.getLogger(__name__)
30
+
31
+ # Vendor prefix → normalized family name
32
+ _VENDOR_TO_FAMILY: dict[str, str] = {
33
+ "openai": "openai",
34
+ "anthropic": "anthropic",
35
+ "vertex_ai": "gemini",
36
+ "vertex_ai_beta": "gemini",
37
+ "google": "gemini",
38
+ }
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class ProxyContext:
43
+ """Proxy routing snapshot for a session."""
44
+
45
+ template: str | None = None
46
+ base_url: str | None = None
47
+ proxy_id: str | None = None
48
+ is_direct: bool = True
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class PolicyContext:
53
+ """Policy state snapshot for a session."""
54
+
55
+ enabled: bool = False
56
+ fail_mode: str = "open"
57
+ bundles: list[str] = field(default_factory=list)
58
+ supervisor_resume_id: str | None = None
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class SessionContext:
63
+ """Complete introspection view of a Forge session."""
64
+
65
+ session_name: str
66
+ claude_session_id: str | None = None
67
+ worktree_path: str | None = None
68
+ project_root: str | None = None
69
+ created_at: str | None = None
70
+ is_fork: bool = False
71
+ is_incognito: bool = False
72
+ parent_session: str | None = None
73
+ proxy: ProxyContext = field(default_factory=ProxyContext)
74
+ model_family: str = "anthropic"
75
+ main_model: str | None = None
76
+ models: dict[str, str] = field(default_factory=dict)
77
+ policy: PolicyContext = field(default_factory=PolicyContext)
78
+ overrides: dict[str, Any] = field(default_factory=dict)
79
+
80
+ def to_dict(self) -> dict[str, Any]:
81
+ """Serialize to a plain dict for JSON output."""
82
+ return {
83
+ "session_name": self.session_name,
84
+ "claude_session_id": self.claude_session_id,
85
+ "worktree_path": self.worktree_path,
86
+ "project_root": self.project_root,
87
+ "created_at": self.created_at,
88
+ "is_fork": self.is_fork,
89
+ "is_incognito": self.is_incognito,
90
+ "parent_session": self.parent_session,
91
+ "proxy": {
92
+ "template": self.proxy.template,
93
+ "base_url": self.proxy.base_url,
94
+ "proxy_id": self.proxy.proxy_id,
95
+ "is_direct": self.proxy.is_direct,
96
+ },
97
+ "model_family": self.model_family,
98
+ "main_model": self.main_model,
99
+ "models": dict(self.models),
100
+ "policy": {
101
+ "enabled": self.policy.enabled,
102
+ "fail_mode": self.policy.fail_mode,
103
+ "bundles": list(self.policy.bundles),
104
+ "supervisor_resume_id": self.policy.supervisor_resume_id,
105
+ },
106
+ "overrides": dict(self.overrides),
107
+ }
108
+
109
+
110
+ def resolve_session_identifier(session: str | None = None) -> tuple[str, str | None]:
111
+ """Resolve a session identifier to a Forge session name and forge_root.
112
+
113
+ Accepts a Forge session name, a Claude session UUID, or None.
114
+
115
+ Resolution order:
116
+ 1. If ``session`` provided: try as name, then as UUID
117
+ 2. ``$FORGE_SESSION`` env var
118
+
119
+ Returns:
120
+ (session_name, forge_root) tuple. forge_root may be None if
121
+ resolved via env var without index context.
122
+
123
+ Raises:
124
+ SessionContextError: if no session can be resolved.
125
+ """
126
+ manager = SessionManager()
127
+
128
+ # Derive forge_root from CWD for scoped lookups
129
+ _cwd_forge_root: str | None = None
130
+ try:
131
+ from forge.core.ops.context import find_forge_root
132
+
133
+ _fr = find_forge_root(Path.cwd().resolve())
134
+ if _fr:
135
+ _cwd_forge_root = str(_fr)
136
+ except Exception:
137
+ pass
138
+
139
+ if session:
140
+ # Try as Forge session name (scoped to current project first, then unscoped)
141
+ try:
142
+ entry = manager.get_session_entry(session, forge_root=_cwd_forge_root)
143
+ return session, entry.root
144
+ except AmbiguousSessionError:
145
+ raise # Propagate with location list intact
146
+ except ForgeSessionError:
147
+ pass
148
+
149
+ # Unscoped fallback for cross-project explicit references
150
+ try:
151
+ entry = manager.get_session_entry(session)
152
+ return session, entry.root
153
+ except AmbiguousSessionError:
154
+ raise # Propagate with location list intact
155
+ except ForgeSessionError as e:
156
+ # Check if it's corruption (in index but manifest bad) vs not found
157
+ try:
158
+ manager.get_session_entry(session)
159
+ except ForgeSessionError:
160
+ pass # Not in index — fall through to UUID lookup
161
+ else:
162
+ raise SessionContextError(str(e)) from e
163
+
164
+ # Try as Claude session UUID (cross-project)
165
+ index = IndexStore()
166
+ uuid_result = index.find_session_by_uuid(session)
167
+ if uuid_result:
168
+ return uuid_result[0], uuid_result[1]
169
+
170
+ # Fall back to scanning session manifests when the index is stale.
171
+ scan_result = _scan_manifests_for_uuid(session)
172
+ if scan_result:
173
+ return scan_result
174
+
175
+ raise SessionContextError(f"No session found for '{session}' (tried as name and UUID)")
176
+
177
+ # Fall back to env var. FORGE_SESSION is set by the Forge launcher, so the
178
+ # session is authoritative by convention. Try scoped first, then unscoped.
179
+ env_session = os.environ.get("FORGE_SESSION")
180
+ if env_session:
181
+ try:
182
+ entry = manager.get_session_entry(env_session, forge_root=_cwd_forge_root)
183
+ return env_session, entry.root
184
+ except ForgeSessionError:
185
+ try:
186
+ entry = manager.get_session_entry(env_session)
187
+ return env_session, entry.root
188
+ except ForgeSessionError:
189
+ pass
190
+
191
+ raise SessionContextError("No session found (no argument, no $FORGE_SESSION)")
192
+
193
+
194
+ class SessionContextError(RuntimeError):
195
+ """Raised when session context cannot be built."""
196
+
197
+
198
+ def detect_model_family(template: str | None) -> str:
199
+ """Map a proxy template to a normalized model family name.
200
+
201
+ Loads the template config, reads the opus-tier model name,
202
+ and extracts the vendor prefix.
203
+
204
+ Returns:
205
+ ``"openai"`` | ``"gemini"`` | ``"anthropic"``
206
+ """
207
+ if template is None:
208
+ return "anthropic"
209
+
210
+ try:
211
+ from forge.config.loader import load_config
212
+
213
+ cfg = load_config(template=template)
214
+ provider = cfg.proxy.get_provider()
215
+
216
+ # Get the opus-tier model (most representative)
217
+ opus_model = provider.tiers.opus
218
+ if not opus_model:
219
+ return "anthropic"
220
+
221
+ return _model_to_family(opus_model)
222
+ except Exception:
223
+ _log.debug("Failed to detect model family for template %r", template, exc_info=True)
224
+ return "anthropic"
225
+
226
+
227
+ def _model_to_family(model_name: str) -> str:
228
+ """Extract normalized family from a model name (possibly vendor-prefixed).
229
+
230
+ Examples:
231
+ ``"openai/gpt-5.5"`` -> ``"openai"``
232
+ ``"vertex_ai/gemini-3.1-pro"`` -> ``"gemini"``
233
+ ``"gpt-5.5"`` -> ``"openai"``
234
+ ``"claude-opus-4-6"`` -> ``"anthropic"``
235
+ """
236
+ # If vendor-prefixed (e.g., "openai/gpt-5.5"), extract prefix
237
+ if "/" in model_name:
238
+ vendor = model_name.split("/", 1)[0]
239
+ family = _VENDOR_TO_FAMILY.get(vendor)
240
+ if family:
241
+ return family
242
+
243
+ # Infer from model name pattern
244
+ bare = model_name.split("/", 1)[-1].lower()
245
+
246
+ if bare.startswith("gpt-") or bare.startswith(("o1", "o3", "o4")):
247
+ return "openai"
248
+ if bare.startswith("claude-"):
249
+ return "anthropic"
250
+ if bare.startswith("gemini-"):
251
+ return "gemini"
252
+
253
+ return "anthropic"
254
+
255
+
256
+ def get_session_context(session: str | None = None) -> SessionContext:
257
+ """Build a complete context view of a session.
258
+
259
+ The ``session`` arg accepts a Forge session name, a Claude session UUID,
260
+ or None (falls back to ``$FORGE_SESSION``).
261
+
262
+ When ``session`` is None and no Forge session can be resolved, falls back
263
+ to building context from environment variables (``ACTIVE_TEMPLATE``,
264
+ ``ANTHROPIC_BASE_URL``). When an explicit session identifier is given
265
+ but cannot be resolved, raises instead of falling back.
266
+
267
+ Returns:
268
+ SessionContext with all available metadata.
269
+
270
+ Raises:
271
+ SessionContextError: if an explicit session identifier cannot be
272
+ resolved, or if the resolved session's state is corrupted.
273
+ """
274
+ try:
275
+ name, resolved_forge_root = resolve_session_identifier(session)
276
+ except SessionContextError:
277
+ if session is not None:
278
+ raise # Explicit session not found — fail-closed
279
+ return _build_env_context()
280
+
281
+ manager = SessionManager()
282
+ try:
283
+ state = manager.get_session(name, forge_root=resolved_forge_root)
284
+ entry = manager.get_session_entry(name, forge_root=resolved_forge_root)
285
+ except ForgeSessionError as e:
286
+ raise SessionContextError(str(e)) from e
287
+
288
+ proxy_ctx = _build_proxy_context(state)
289
+ family, models, main_model = _build_model_context(proxy_ctx, state)
290
+ policy_ctx = _build_policy_context(state)
291
+
292
+ return SessionContext(
293
+ session_name=name,
294
+ claude_session_id=state.confirmed.claude_session_id,
295
+ worktree_path=entry.worktree_path,
296
+ project_root=entry.project_root,
297
+ created_at=state.created_at,
298
+ is_fork=state.is_fork,
299
+ is_incognito=state.is_incognito,
300
+ parent_session=state.parent_session,
301
+ proxy=proxy_ctx,
302
+ model_family=family,
303
+ main_model=main_model,
304
+ models=models,
305
+ policy=policy_ctx,
306
+ overrides=dict(state.overrides),
307
+ )
308
+
309
+
310
+ def _build_env_context() -> SessionContext:
311
+ """Build a minimal context from environment variables when no Forge session exists.
312
+
313
+ Uses ``ACTIVE_TEMPLATE`` and ``ANTHROPIC_BASE_URL`` to infer proxy/model info.
314
+ Called only when ``session`` was None and resolution found nothing.
315
+ """
316
+ template = os.environ.get("ACTIVE_TEMPLATE")
317
+ base_url = os.environ.get("ANTHROPIC_BASE_URL")
318
+
319
+ if template or base_url:
320
+ proxy_ctx = ProxyContext(
321
+ template=template,
322
+ base_url=base_url,
323
+ is_direct=False,
324
+ )
325
+ else:
326
+ proxy_ctx = ProxyContext(is_direct=True)
327
+
328
+ family, models, main_model = _build_model_context(proxy_ctx, None)
329
+
330
+ return SessionContext(
331
+ session_name=os.environ.get("FORGE_SESSION", "(unknown)"),
332
+ claude_session_id=None,
333
+ model_family=family,
334
+ main_model=main_model,
335
+ models=models,
336
+ proxy=proxy_ctx,
337
+ )
338
+
339
+
340
+ def _build_proxy_context(state: SessionState) -> ProxyContext:
341
+ """Extract proxy info from confirmed (preferred) or intent."""
342
+ confirmed = state.confirmed.started_with_proxy
343
+ if confirmed:
344
+ return ProxyContext(
345
+ template=confirmed.template,
346
+ base_url=confirmed.base_url,
347
+ proxy_id=confirmed.proxy_id,
348
+ is_direct=False,
349
+ )
350
+
351
+ intent = state.intent.proxy
352
+ if intent:
353
+ return ProxyContext(
354
+ template=intent.template,
355
+ base_url=intent.base_url,
356
+ proxy_id=None,
357
+ is_direct=False,
358
+ )
359
+
360
+ return ProxyContext(is_direct=True)
361
+
362
+
363
+ def _build_policy_context(state: SessionState) -> PolicyContext:
364
+ """Extract effective policy state from intent + overrides."""
365
+ enabled = False
366
+ fail_mode = "open"
367
+ bundles: list[str] = []
368
+ supervisor_resume_id: str | None = None
369
+
370
+ try:
371
+ effective_intent = compute_effective_intent(state)
372
+ policy = effective_intent.policy
373
+ except Exception:
374
+ _log.debug("Failed to compute effective policy state for session %r", state.name, exc_info=True)
375
+ policy = state.intent.policy
376
+
377
+ if policy:
378
+ enabled = policy.enabled
379
+ fail_mode = policy.fail_mode or "open"
380
+ bundles = list(policy.bundles or [])
381
+ if policy.supervisor:
382
+ supervisor_resume_id = policy.supervisor.resume_id
383
+
384
+ return PolicyContext(
385
+ enabled=enabled,
386
+ fail_mode=fail_mode,
387
+ bundles=bundles,
388
+ supervisor_resume_id=supervisor_resume_id,
389
+ )
390
+
391
+
392
+ def _scan_manifests_for_uuid(session_uuid: str) -> tuple[str, str] | None:
393
+ """Search session manifests for a Claude UUID when the index is stale.
394
+
395
+ Returns (display_name, forge_root) to preserve project scope for
396
+ subsequent lookups, or None if not found.
397
+ """
398
+ index = IndexStore()
399
+ try:
400
+ sessions = index.list_sessions(include_incognito=True)
401
+ except Exception:
402
+ _log.debug("Failed to list sessions while scanning manifests for UUID %r", session_uuid, exc_info=True)
403
+ return None
404
+
405
+ for name, entry in sessions:
406
+ try:
407
+ store = SessionStore(entry.root, name)
408
+ if not store.exists():
409
+ continue
410
+ state = store.read()
411
+ except Exception:
412
+ _log.debug("Failed to read session manifest while scanning for UUID %r", session_uuid, exc_info=True)
413
+ continue
414
+
415
+ if state.confirmed.claude_session_id == session_uuid:
416
+ return name, entry.root
417
+
418
+ return None
419
+
420
+
421
+ def _build_model_context(proxy_ctx: ProxyContext, state: SessionState | None) -> tuple[str, dict[str, str], str | None]:
422
+ """Return model family plus tier mappings using the best available proxy truth."""
423
+ if proxy_ctx.is_direct:
424
+ main_model = None
425
+ if state is not None and state.intent.launch is not None:
426
+ main_model = state.intent.launch.direct_model
427
+ if main_model is None:
428
+ main_model = _direct_main_model_from_env()
429
+ return "anthropic", {}, main_model
430
+
431
+ proxy_config = _load_proxy_instance_for_context(proxy_ctx)
432
+ if proxy_config is not None:
433
+ models = _proxy_instance_tier_models(proxy_config)
434
+ family = _family_from_models(models)
435
+ main_model = models.get(getattr(proxy_config, "default_tier", None) or "sonnet")
436
+ return family, models, main_model
437
+
438
+ template = proxy_ctx.template
439
+ family = detect_model_family(template)
440
+ models = _get_tier_models(template)
441
+ main_model = models.get("sonnet") or models.get("opus") or models.get("haiku")
442
+ return family, models, main_model
443
+
444
+
445
+ def _direct_main_model_from_env() -> str | None:
446
+ """Infer the pinned direct Claude model from Claude Code env vars."""
447
+ tier = (os.environ.get("ANTHROPIC_MODEL") or "").lower()
448
+ env_by_tier = {
449
+ "opus": "ANTHROPIC_DEFAULT_OPUS_MODEL",
450
+ "sonnet": "ANTHROPIC_DEFAULT_SONNET_MODEL",
451
+ "haiku": "ANTHROPIC_DEFAULT_HAIKU_MODEL",
452
+ }
453
+ if tier in env_by_tier:
454
+ return os.environ.get(env_by_tier[tier])
455
+
456
+ return None
457
+
458
+
459
+ def _load_proxy_instance_for_context(proxy_ctx: ProxyContext) -> Any | None:
460
+ """Load proxy.yaml for this session when we can identify the concrete proxy."""
461
+ proxy_id = proxy_ctx.proxy_id
462
+
463
+ if proxy_id is None and proxy_ctx.base_url:
464
+ try:
465
+ from forge.proxy.proxies import ProxyRegistryStore
466
+
467
+ entry = ProxyRegistryStore().find_by_base_url(proxy_ctx.base_url)
468
+ if entry is not None:
469
+ proxy_id = entry.proxy_id
470
+ except Exception:
471
+ _log.debug("Failed to resolve proxy_id from base_url %r", proxy_ctx.base_url, exc_info=True)
472
+
473
+ if proxy_id is None:
474
+ return None
475
+
476
+ try:
477
+ from forge.config.loader import load_proxy_instance_config
478
+
479
+ return load_proxy_instance_config(proxy_id)
480
+ except Exception:
481
+ _log.debug("Failed to load proxy instance config for %r", proxy_id, exc_info=True)
482
+ return None
483
+
484
+
485
+ def _proxy_instance_tier_models(proxy_config: Any) -> dict[str, str]:
486
+ """Extract tier mappings from a proxy instance config."""
487
+ result: dict[str, str] = {}
488
+ if proxy_config.tiers.haiku:
489
+ result["haiku"] = proxy_config.tiers.haiku
490
+ if proxy_config.tiers.sonnet:
491
+ result["sonnet"] = proxy_config.tiers.sonnet
492
+ if proxy_config.tiers.opus:
493
+ result["opus"] = proxy_config.tiers.opus
494
+ return result
495
+
496
+
497
+ def _family_from_models(models: dict[str, str]) -> str:
498
+ """Choose a representative family from tier mappings."""
499
+ for tier in ("opus", "sonnet", "haiku"):
500
+ model_name = models.get(tier)
501
+ if model_name:
502
+ return _model_to_family(model_name)
503
+ return "anthropic"
504
+
505
+
506
+ def _get_tier_models(template: str | None) -> dict[str, str]:
507
+ """Load tier→model mappings from a template."""
508
+ if template is None:
509
+ return {}
510
+
511
+ try:
512
+ from forge.config.loader import load_config
513
+
514
+ cfg = load_config(template=template)
515
+ provider = cfg.proxy.get_provider()
516
+ result: dict[str, str] = {}
517
+ if provider.tiers.haiku:
518
+ result["haiku"] = provider.tiers.haiku
519
+ if provider.tiers.sonnet:
520
+ result["sonnet"] = provider.tiers.sonnet
521
+ if provider.tiers.opus:
522
+ result["opus"] = provider.tiers.opus
523
+ return result
524
+ except Exception:
525
+ _log.debug("Failed to load tier models for template %r", template, exc_info=True)
526
+ return {}
527
+
528
+
529
+ def extract_field(data: dict[str, Any], field_path: str) -> Any:
530
+ """Extract a value from a nested dict using dot notation.
531
+
532
+ Args:
533
+ data: The dict to traverse.
534
+ field_path: Dot-separated path (e.g., ``"proxy.template"``).
535
+
536
+ Returns:
537
+ The value at the path.
538
+
539
+ Raises:
540
+ KeyError: if the path does not exist.
541
+ """
542
+ current: Any = data
543
+ for part in field_path.split("."):
544
+ if isinstance(current, dict):
545
+ current = current[part]
546
+ else:
547
+ raise KeyError(f"Cannot traverse into {type(current).__name__} at '{part}'")
548
+ return current
forge/core/paths.py ADDED
@@ -0,0 +1,38 @@
1
+ """Core path utilities for Forge.
2
+
3
+ Provides the canonical location of the Forge home directory (~/.forge)
4
+ and related path constants. These are cross-cutting concerns used by
5
+ session, proxy, install, backend, and workqueue modules.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from pathlib import Path
12
+
13
+ # The dotfile directory name used by Forge
14
+ FORGE_DIR = ".forge"
15
+
16
+
17
+ def display_path(path: str | Path) -> str:
18
+ """Replace home directory prefix with ``~`` for shorter terminal display."""
19
+ s = str(path)
20
+ home = str(Path.home())
21
+ if s == home:
22
+ return "~"
23
+ if s.startswith(home + "/"):
24
+ return "~" + s[len(home) :]
25
+ return s
26
+
27
+
28
+ def get_forge_home() -> Path:
29
+ """Get the forge home directory (~/.forge).
30
+
31
+ Respects FORGE_HOME environment variable for testing/custom paths.
32
+
33
+ Note: we expand a leading "~" so values like "~/.forge" work correctly,
34
+ including in tests that monkeypatch HOME.
35
+ """
36
+ if forge_home := os.environ.get("FORGE_HOME"):
37
+ return Path(forge_home).expanduser()
38
+ return Path.home() / FORGE_DIR
forge/core/process.py ADDED
@@ -0,0 +1,54 @@
1
+ """Process utilities for Forge.
2
+
3
+ Provides PID checking and port-based process discovery. Used by proxy
4
+ and backend lifecycle management.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import subprocess
11
+
12
+
13
+ def is_pid_alive(pid: int) -> bool:
14
+ """Return True if pid appears to refer to a running process.
15
+
16
+ Uses the standard POSIX check: ``os.kill(pid, 0)``.
17
+
18
+ Notes:
19
+ - If we don't have permission to signal the process
20
+ (``PermissionError``), we treat it as alive.
21
+ - PID reuse is possible but out of scope for stale pruning.
22
+ """
23
+ if pid <= 0:
24
+ return False
25
+
26
+ try:
27
+ os.kill(pid, 0)
28
+ return True
29
+ except ProcessLookupError:
30
+ return False
31
+ except PermissionError:
32
+ return True
33
+
34
+
35
+ def find_pid_by_port(port: int) -> int | None:
36
+ """Find the PID of the process listening on the given TCP port.
37
+
38
+ Uses ``lsof`` on macOS/Linux. Returns None if no process is found,
39
+ ``lsof`` is unavailable, or the command times out.
40
+ """
41
+ try:
42
+ result = subprocess.run(
43
+ ["lsof", "-ti", f"TCP:{port}", "-sTCP:LISTEN"],
44
+ capture_output=True,
45
+ text=True,
46
+ timeout=5,
47
+ )
48
+ if result.returncode != 0 or not result.stdout.strip():
49
+ return None
50
+ # lsof may return multiple PIDs (one per line); take the first
51
+ first_line = result.stdout.strip().splitlines()[0]
52
+ return int(first_line)
53
+ except (FileNotFoundError, subprocess.TimeoutExpired, ValueError):
54
+ return None
@@ -0,0 +1,38 @@
1
+ """Shared reactive library for Forge hook handlers and policies.
2
+
3
+ Provides utilities for subprocess management, caching, structured output
4
+ extraction, and LLM-based classification. These are the building blocks
5
+ for the semantic supervisor, handoff agent, and WorkflowPolicy.
6
+
7
+ Note: ``proxy.py`` is intentionally NOT re-exported here because it
8
+ lazy-imports ``forge.proxy.proxies`` (a top-level component). Consumers
9
+ import directly: ``from forge.core.reactive.proxy import lookup_proxy_base_url``.
10
+ """
11
+
12
+ from .env import (
13
+ FORGE_DEPTH_VAR,
14
+ FORGE_MAX_DEPTH,
15
+ build_claude_env,
16
+ can_use_bare,
17
+ get_forge_depth,
18
+ should_spawn_subprocesses,
19
+ )
20
+ from .session_runner import SessionResult, run_claude_session
21
+ from .structured_output import extract_json_from_response
22
+ from .tagger import tag_action
23
+ from .throttle import ThrottleCache, compute_cache_key
24
+
25
+ __all__ = [
26
+ "FORGE_DEPTH_VAR",
27
+ "FORGE_MAX_DEPTH",
28
+ "build_claude_env",
29
+ "can_use_bare",
30
+ "get_forge_depth",
31
+ "should_spawn_subprocesses",
32
+ "SessionResult",
33
+ "run_claude_session",
34
+ "extract_json_from_response",
35
+ "tag_action",
36
+ "ThrottleCache",
37
+ "compute_cache_key",
38
+ ]