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,146 @@
1
+ """Shared proxy operations (command-core).
2
+
3
+ These operations are UI-agnostic and can be invoked from both:
4
+
5
+ - the CLI (`forge proxy ...`), and
6
+ - the in-chat direct command dispatcher (`%proxy ...`).
7
+
8
+ They return structured data and raise typed exceptions on failure.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from dataclasses import dataclass
15
+
16
+ from forge.config.loader import load_proxy_instance_config
17
+ from forge.config.schema import ProxyInstanceConfig
18
+ from forge.proxy.proxies import (
19
+ ProxyEntry,
20
+ ProxyRegistryCorruptedError,
21
+ ProxyRegistryStore,
22
+ )
23
+
24
+ from .context import ExecutionContext
25
+ from .session import ForgeOpError
26
+
27
+ _log = logging.getLogger(__name__)
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class ListProxiesItem:
32
+ proxy_id: str
33
+ entry: ProxyEntry
34
+ config: ProxyInstanceConfig | None # None if proxy.yaml missing/corrupt
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class ListProxiesResult:
39
+ proxies: list[ListProxiesItem]
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class ShowProxyResult:
44
+ proxy_id: str
45
+ entry: ProxyEntry | None # None if proxy has config file but no registry entry
46
+ config: ProxyInstanceConfig | None
47
+ config_yaml: str | None # Raw YAML content for display
48
+
49
+
50
+ def list_proxies(*, ctx: ExecutionContext) -> ListProxiesResult:
51
+ """List all registered proxies with their configurations.
52
+
53
+ This is a global operation (lists from ~/.forge/proxies/index.json).
54
+ The ctx is accepted for API consistency.
55
+
56
+ Args:
57
+ ctx: execution context (unused, for API consistency).
58
+
59
+ Returns:
60
+ ListProxiesResult with proxy entries and configs.
61
+
62
+ Raises:
63
+ ForgeOpError: if the proxy registry cannot be read.
64
+ """
65
+ _log.debug("list_proxies: cwd=%s", ctx.cwd)
66
+
67
+ store = ProxyRegistryStore()
68
+
69
+ try:
70
+ registry = store.read()
71
+ except ProxyRegistryCorruptedError as e:
72
+ raise ForgeOpError(f"Proxy registry error: {e}") from e
73
+
74
+ items: list[ListProxiesItem] = []
75
+ for proxy_id, entry in registry.proxies.items():
76
+ # Best-effort config load
77
+ config: ProxyInstanceConfig | None = None
78
+ try:
79
+ config = load_proxy_instance_config(proxy_id)
80
+ except Exception as e:
81
+ _log.debug("Failed to load config for proxy %r: %s", proxy_id, e)
82
+
83
+ items.append(ListProxiesItem(proxy_id=proxy_id, entry=entry, config=config))
84
+
85
+ # Sort by proxy_id for consistent output
86
+ items.sort(key=lambda x: x.proxy_id)
87
+
88
+ return ListProxiesResult(proxies=items)
89
+
90
+
91
+ def show_proxy(*, ctx: ExecutionContext, proxy_id: str) -> ShowProxyResult:
92
+ """Show details for a specific proxy.
93
+
94
+ The proxy must have either a registry entry or a config file (or both).
95
+ Registry info (status, PID) is best-effort enrichment.
96
+
97
+ Args:
98
+ ctx: execution context (unused, for API consistency).
99
+ proxy_id: the proxy ID to show.
100
+
101
+ Returns:
102
+ ShowProxyResult with entry, config, and raw YAML.
103
+
104
+ Raises:
105
+ ForgeOpError: if the proxy is not found in both registry and filesystem.
106
+ """
107
+ _log.debug("show_proxy: proxy_id=%s", proxy_id)
108
+
109
+ # Best-effort registry lookup
110
+ entry: ProxyEntry | None = None
111
+ store = ProxyRegistryStore()
112
+ try:
113
+ registry = store.read()
114
+ entry = registry.proxies.get(proxy_id)
115
+ except ProxyRegistryCorruptedError:
116
+ _log.debug("Registry unreadable, proceeding without registry info")
117
+
118
+ # Load config
119
+ config: ProxyInstanceConfig | None = None
120
+ config_yaml: str | None = None
121
+
122
+ try:
123
+ config = load_proxy_instance_config(proxy_id)
124
+ except Exception as e:
125
+ _log.debug("Failed to load config for proxy %r: %s", proxy_id, e)
126
+
127
+ # Load raw YAML for display
128
+ from forge.config.loader import get_proxy_file_path
129
+
130
+ proxy_path = get_proxy_file_path(proxy_id)
131
+ if proxy_path.exists():
132
+ try:
133
+ config_yaml = proxy_path.read_text()
134
+ except Exception as e:
135
+ _log.debug("Failed to read proxy file %s: %s", proxy_path, e)
136
+
137
+ # Must have at least one source of truth
138
+ if entry is None and config_yaml is None:
139
+ raise ForgeOpError(f"Proxy '{proxy_id}' not found")
140
+
141
+ return ShowProxyResult(
142
+ proxy_id=proxy_id,
143
+ entry=entry,
144
+ config=config,
145
+ config_yaml=config_yaml,
146
+ )
@@ -0,0 +1,135 @@
1
+ """Repo-wide session resolution.
2
+
3
+ Shared two-tier resolver used by session CLI commands and guard CLI.
4
+ Resolves a named session with current-project preference, falling back
5
+ to a repo-scoped scan when the session lives in a sibling forge_root.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ from forge.session import SessionIndexEntry, SessionManager, SessionState, SessionStore
15
+ from forge.session.exceptions import (
16
+ AmbiguousSessionError,
17
+ ForgeSessionError,
18
+ SessionNotFoundError,
19
+ )
20
+
21
+ _log = logging.getLogger(__name__)
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class ResolvedSession:
26
+ """Result of repo-wide session resolution."""
27
+
28
+ name: str
29
+ entry: SessionIndexEntry
30
+ store: SessionStore
31
+ state: SessionState
32
+ forge_root: str
33
+ is_cross_project: bool
34
+
35
+
36
+ def resolve_session_repo_wide(
37
+ name: str,
38
+ cwd_forge_root: str | None,
39
+ *,
40
+ manager: SessionManager | None = None,
41
+ ) -> ResolvedSession:
42
+ """Resolve a named session with repo-wide scope and current-project preference.
43
+
44
+ Two-tier resolution (no global fast path to prevent cross-repo jumps):
45
+
46
+ 1. Tier 1: Try cwd_forge_root (O(1) compound-key index lookup).
47
+ 2. Tier 2: Repo-scoped scan (project_root_filter) for cross-worktree matches.
48
+
49
+ Tiebreaker: if multiple matches in the same repo, prefer cwd_forge_root.
50
+
51
+ Raises:
52
+ SessionNotFoundError: session not found anywhere in the repo.
53
+ AmbiguousSessionError: multiple matches, none in cwd_forge_root.
54
+ """
55
+ if manager is None:
56
+ manager = SessionManager()
57
+
58
+ # Tier 1: same project (O(1) index lookup)
59
+ if cwd_forge_root is not None:
60
+ try:
61
+ entry = manager.get_session_entry(name, forge_root=cwd_forge_root)
62
+ store = SessionStore(entry.root, name)
63
+ return ResolvedSession(
64
+ name=name,
65
+ entry=entry,
66
+ store=store,
67
+ state=store.read(),
68
+ forge_root=entry.root,
69
+ is_cross_project=False,
70
+ )
71
+ except (ForgeSessionError, FileNotFoundError):
72
+ pass
73
+
74
+ # Tier 2: repo-scoped scan (cross-worktree)
75
+ project_root = _derive_project_root(cwd_forge_root, manager)
76
+ if project_root is None:
77
+ raise SessionNotFoundError(name)
78
+
79
+ siblings = manager.list_sessions(project_root_filter=project_root)
80
+ matches = [(n, e) for n, e in siblings if n == name]
81
+
82
+ if not matches:
83
+ raise SessionNotFoundError(name)
84
+
85
+ if len(matches) == 1:
86
+ e = matches[0][1]
87
+ store = SessionStore(e.root, name)
88
+ return ResolvedSession(
89
+ name=name,
90
+ entry=e,
91
+ store=store,
92
+ state=store.read(),
93
+ forge_root=e.root,
94
+ is_cross_project=e.root != cwd_forge_root,
95
+ )
96
+
97
+ # Multiple matches: prefer current forge_root as tiebreaker
98
+ if cwd_forge_root is not None:
99
+ for _, e in matches:
100
+ if e.root == cwd_forge_root:
101
+ store = SessionStore(e.root, name)
102
+ return ResolvedSession(
103
+ name=name, entry=e, store=store, state=store.read(), forge_root=e.root, is_cross_project=False
104
+ )
105
+
106
+ roots = [e.root for _, e in matches]
107
+ raise AmbiguousSessionError(name, roots)
108
+
109
+
110
+ def _derive_project_root(cwd_forge_root: str | None, manager: SessionManager) -> str | None:
111
+ """Derive the logical repo root for Tier 2 scanning.
112
+
113
+ Uses manager.resolve_project_root first (git subprocess), then falls
114
+ back to ExecutionContext path walking (handles fake .git dirs in tests
115
+ and directories that aren't real git worktrees).
116
+ """
117
+ if cwd_forge_root is not None:
118
+ try:
119
+ pr = manager.resolve_project_root(cwd_forge_root)
120
+ # resolve_project_root falls back to returning the input path
121
+ # when git fails. That's not useful for repo-scoped filtering,
122
+ # so fall through to CWD-based derivation.
123
+ if pr != str(Path(cwd_forge_root).resolve()):
124
+ return pr
125
+ except Exception:
126
+ pass
127
+
128
+ try:
129
+ from forge.core.ops.context import ExecutionContext
130
+
131
+ ctx = ExecutionContext.from_cwd()
132
+ return str(ctx.project_root)
133
+ except Exception:
134
+ _log.debug("Could not derive project_root from CWD or forge_root=%s", cwd_forge_root)
135
+ return None
@@ -0,0 +1,344 @@
1
+ """Shared session operations (command-core).
2
+
3
+ These operations are UI-agnostic and can be invoked from both:
4
+
5
+ - the CLI (`forge session ...`), and
6
+ - the in-chat direct command dispatcher (`%session ...`).
7
+
8
+ They return structured data and raise typed exceptions on failure.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import os
15
+ from dataclasses import dataclass
16
+ from typing import Any
17
+
18
+ from forge.session import (
19
+ ForgeSessionError,
20
+ SessionIndexEntry,
21
+ SessionManager,
22
+ SessionState,
23
+ SessionStore,
24
+ clear_overrides,
25
+ compute_effective_intent,
26
+ delete_override,
27
+ set_override,
28
+ )
29
+ from forge.session.exceptions import (
30
+ InvalidOverrideKeyError,
31
+ InvalidOverrideValueError,
32
+ )
33
+ from forge.session.overrides import parse_value, validate_key
34
+
35
+ from .context import ExecutionContext
36
+
37
+ _log = logging.getLogger(__name__)
38
+
39
+
40
+ class ForgeOpError(RuntimeError):
41
+ """Raised when a command-core operation fails."""
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class ListSessionsItem:
46
+ name: str
47
+ entry: SessionIndexEntry
48
+ proxy_template: str | None
49
+ is_active: bool
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class ListSessionsResult:
54
+ sessions: list[ListSessionsItem]
55
+
56
+
57
+ VALID_SCOPES = {"repo", "project", "all"}
58
+
59
+
60
+ def _scope_filters(ctx: ExecutionContext, scope: str) -> tuple[str | None, str | None]:
61
+ """Compute (project_root_filter, forge_root_filter) for a given scope.
62
+
63
+ Shared by list_sessions() and list_sessions_older_than() to ensure
64
+ identical fallback behavior.
65
+ """
66
+ if scope == "repo":
67
+ return str(ctx.project_root), None
68
+ if scope == "project":
69
+ if ctx.forge_root is not None:
70
+ return None, str(ctx.forge_root)
71
+ _log.debug("No forge_root for --scope project, falling back to repo scope")
72
+ return str(ctx.project_root), None
73
+ # scope == "all"
74
+ return None, None
75
+
76
+
77
+ def list_sessions(*, ctx: ExecutionContext, include_incognito: bool, scope: str = "repo") -> ListSessionsResult:
78
+ """List sessions with lightweight derived metadata.
79
+
80
+ Args:
81
+ ctx: execution context (provides project_root and forge_root for filtering).
82
+ include_incognito: whether to include incognito sessions.
83
+ scope: filtering scope:
84
+ - ``"repo"``: sessions in the same logical repo (project_root match). Default.
85
+ - ``"project"``: sessions in the same Forge project (forge_root match).
86
+ - ``"all"``: no filtering (global).
87
+
88
+ Returns:
89
+ ListSessionsResult.
90
+
91
+ Raises:
92
+ ForgeOpError: if the session subsystem fails or scope is invalid.
93
+ """
94
+ if scope not in VALID_SCOPES:
95
+ raise ForgeOpError(f"Invalid scope: {scope!r}. Must be one of {VALID_SCOPES}")
96
+
97
+ _log.debug(
98
+ "list_sessions: cwd=%s, project_root=%s, forge_root=%s, scope=%s",
99
+ ctx.cwd,
100
+ ctx.project_root,
101
+ ctx.forge_root,
102
+ scope,
103
+ )
104
+
105
+ manager = SessionManager()
106
+ project_root_filter, forge_root_filter = _scope_filters(ctx, scope)
107
+
108
+ try:
109
+ sessions = manager.list_sessions(
110
+ include_incognito=include_incognito,
111
+ project_root_filter=project_root_filter,
112
+ forge_root_filter=forge_root_filter,
113
+ )
114
+ except ForgeSessionError as e:
115
+ raise ForgeOpError(str(e)) from e
116
+
117
+ items: list[ListSessionsItem] = []
118
+ for name, entry in sessions:
119
+ proxy_template: str | None = None
120
+
121
+ try:
122
+ manifest = manager.get_session(name, forge_root=entry.forge_root or entry.worktree_path)
123
+ if manifest.intent.proxy:
124
+ proxy_template = manifest.intent.proxy.template
125
+ else:
126
+ proxy_template = "direct"
127
+ except ForgeSessionError as e:
128
+ # Best-effort: listing should not fail if a manifest is missing/corrupt.
129
+ _log.debug("Failed to read manifest for session %r: %s", name, e)
130
+
131
+ items.append(
132
+ ListSessionsItem(
133
+ name=name,
134
+ entry=entry,
135
+ proxy_template=proxy_template,
136
+ is_active=False,
137
+ )
138
+ )
139
+
140
+ return ListSessionsResult(sessions=items)
141
+
142
+
143
+ def list_sessions_older_than(
144
+ *,
145
+ older_than_days: int,
146
+ include_incognito: bool = True,
147
+ project_root_filter: str | None = None,
148
+ forge_root_filter: str | None = None,
149
+ ) -> list[tuple[str, SessionIndexEntry]]:
150
+ """List sessions whose last_accessed_at is older than the threshold.
151
+
152
+ Entries with unparseable timestamps are excluded (they cannot be confirmed
153
+ as old). This is a shared op used by both CLI and %session list.
154
+ Respects the same scope filters as list_sessions().
155
+ """
156
+ from datetime import UTC, datetime
157
+
158
+ from forge.core.state import parse_iso
159
+
160
+ manager = SessionManager()
161
+ all_sessions = manager.list_sessions(
162
+ include_incognito=include_incognito,
163
+ project_root_filter=project_root_filter,
164
+ forge_root_filter=forge_root_filter,
165
+ )
166
+
167
+ result: list[tuple[str, SessionIndexEntry]] = []
168
+ for name, entry in all_sessions:
169
+ try:
170
+ dt = parse_iso(entry.last_accessed_at)
171
+ age_days = (datetime.now(UTC) - dt).total_seconds() / 86400
172
+ except (ValueError, TypeError, AttributeError):
173
+ continue
174
+ if age_days > older_than_days:
175
+ result.append((name, entry))
176
+ return result
177
+
178
+
179
+ # --- Session resolution ---
180
+
181
+
182
+ @dataclass(frozen=True)
183
+ class ResolveSessionResult:
184
+ """Result of resolving a session by name or CWD."""
185
+
186
+ store: SessionStore
187
+ state: SessionState
188
+
189
+
190
+ def resolve_session(*, ctx: ExecutionContext, session_name: str | None = None) -> ResolveSessionResult:
191
+ """Resolve a session by explicit name or current session from CWD.
192
+
193
+ Named sessions use repo-wide two-tier resolution (current forge_root
194
+ preference, then repo-scoped scan). Unnamed falls back to $FORGE_SESSION.
195
+
196
+ Args:
197
+ ctx: execution context (provides forge_root for scoped resolution).
198
+ session_name: explicit session name. If None, resolves current session.
199
+
200
+ Returns:
201
+ ResolveSessionResult with store and state.
202
+
203
+ Raises:
204
+ ForgeOpError: if no active session or session not found.
205
+ """
206
+ manager = SessionManager()
207
+
208
+ try:
209
+ if session_name:
210
+ from forge.core.ops.resolution import resolve_session_repo_wide
211
+
212
+ cwd_fr = str(ctx.forge_root) if ctx.forge_root else None
213
+ resolved = resolve_session_repo_wide(session_name, cwd_fr, manager=manager)
214
+ return ResolveSessionResult(store=resolved.store, state=resolved.state)
215
+ else:
216
+ env_name = os.environ.get("FORGE_SESSION")
217
+ if env_name:
218
+ store = manager.get_session_store(env_name)
219
+ state = store.read()
220
+ else:
221
+ raise ForgeOpError("No session specified. Use --session or set $FORGE_SESSION.")
222
+ except ForgeSessionError as e:
223
+ raise ForgeOpError(str(e)) from e
224
+
225
+ return ResolveSessionResult(store=store, state=state)
226
+
227
+
228
+ # --- Session override mutations ---
229
+
230
+
231
+ @dataclass(frozen=True)
232
+ class SetOverrideResult:
233
+ """Result of setting a session override."""
234
+
235
+ key: str
236
+ value: Any
237
+
238
+
239
+ def set_session_override(
240
+ *,
241
+ ctx: ExecutionContext,
242
+ session_name: str | None = None,
243
+ key: str,
244
+ value_str: str,
245
+ ) -> SetOverrideResult:
246
+ """Validate, apply, and persist a session override.
247
+
248
+ Args:
249
+ ctx: execution context.
250
+ session_name: explicit session name. If None, resolves current session.
251
+ key: dot-notation override key (e.g., "agent", "proxy.template").
252
+ value_str: string value (parsed as JSON first, then as string).
253
+
254
+ Returns:
255
+ SetOverrideResult with key and parsed value.
256
+
257
+ Raises:
258
+ ForgeOpError: on invalid key, invalid value, validation failure, or IO error.
259
+ """
260
+ resolved = resolve_session(ctx=ctx, session_name=session_name)
261
+ store = resolved.store
262
+
263
+ try:
264
+ # Validate key before acquiring lock (wildcards handled by set_override)
265
+ if "*" not in key:
266
+ validate_key(key)
267
+
268
+ parsed_value = parse_value(value_str)
269
+
270
+ # Apply + validate + persist atomically under lock.
271
+ # The mutate callback receives the fresh state from disk, avoiding TOCTOU.
272
+ def _mutate(m: SessionState) -> None:
273
+ set_override(m.overrides, key, parsed_value)
274
+ compute_effective_intent(m, strict=True, override_key=key)
275
+
276
+ store.update(timeout_s=5.0, mutate=_mutate)
277
+
278
+ return SetOverrideResult(key=key, value=parsed_value)
279
+
280
+ except (InvalidOverrideKeyError, InvalidOverrideValueError) as e:
281
+ raise ForgeOpError(str(e)) from e
282
+ except ForgeSessionError as e:
283
+ raise ForgeOpError(str(e)) from e
284
+
285
+
286
+ @dataclass(frozen=True)
287
+ class ResetOverridesResult:
288
+ """Result of resetting session overrides."""
289
+
290
+ cleared_all: bool
291
+ key: str | None # None if cleared all
292
+ was_present: bool # whether the key had an override (or overrides existed)
293
+
294
+
295
+ def reset_session_overrides(
296
+ *,
297
+ ctx: ExecutionContext,
298
+ session_name: str | None = None,
299
+ key: str | None = None,
300
+ ) -> ResetOverridesResult:
301
+ """Delete a single override or clear all overrides.
302
+
303
+ Args:
304
+ ctx: execution context.
305
+ session_name: explicit session name. If None, resolves current session.
306
+ key: override key to delete. If None, clears all overrides.
307
+
308
+ Returns:
309
+ ResetOverridesResult.
310
+
311
+ Raises:
312
+ ForgeOpError: on invalid key or IO error.
313
+ """
314
+ resolved = resolve_session(ctx=ctx, session_name=session_name)
315
+ store = resolved.store
316
+
317
+ try:
318
+ if key:
319
+ # Mutate under lock: delete_override on fresh state
320
+ result_holder: dict[str, Any] = {}
321
+
322
+ def _mutate_delete(m: SessionState) -> None:
323
+ result_holder["deleted"] = delete_override(m.overrides, key)
324
+
325
+ store.update(timeout_s=5.0, mutate=_mutate_delete)
326
+ return ResetOverridesResult(
327
+ cleared_all=False,
328
+ key=key,
329
+ was_present=result_holder.get("deleted", False),
330
+ )
331
+ else:
332
+ # Peek at current state to report whether overrides existed
333
+ had_overrides = bool(resolved.state.overrides)
334
+ if had_overrides:
335
+ store.update(
336
+ timeout_s=5.0,
337
+ mutate=lambda m: clear_overrides(m.overrides),
338
+ )
339
+ return ResetOverridesResult(cleared_all=True, key=None, was_present=had_overrides)
340
+
341
+ except InvalidOverrideKeyError as e:
342
+ raise ForgeOpError(str(e)) from e
343
+ except ForgeSessionError as e:
344
+ raise ForgeOpError(str(e)) from e