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,167 @@
1
+ """Type definitions for the model catalog.
2
+
3
+ This module defines the dataclasses that represent model specifications
4
+ and the catalog structure. All types are immutable (frozen) to ensure
5
+ the catalog remains a stable reference.
6
+ """
7
+
8
+ from dataclasses import dataclass, field
9
+ from typing import Literal
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class TemperatureSpec:
14
+ """Temperature constraints for a model."""
15
+
16
+ min: float
17
+ default: float
18
+ max: float
19
+
20
+ def __post_init__(self) -> None:
21
+ if not (self.min <= self.default <= self.max):
22
+ raise ValueError(
23
+ f"Temperature invariant violated: min ({self.min}) <= default ({self.default}) <= max ({self.max})"
24
+ )
25
+ if self.min < 0:
26
+ raise ValueError(f"Temperature min must be >= 0, got {self.min}")
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class ModelSpec:
31
+ """Intrinsic properties of a model.
32
+
33
+ These are facts about what the model CAN do, not operational config.
34
+ Operational config (tier mappings, routing, defaults) belongs in template YAMLs.
35
+ """
36
+
37
+ # Basic identity
38
+ friendly_name: str
39
+ intelligence_score: int
40
+
41
+ # Token limits
42
+ context_window_tokens: int
43
+ max_output_tokens: int
44
+ max_thinking_tokens: int | None = None
45
+
46
+ # Display
47
+ short_name: str | None = None # Compact display name (e.g., "gemini-flash"); None = derive algorithmically
48
+
49
+ # Capability flags
50
+ supports_thinking: bool = False
51
+ supports_images: bool = False
52
+ supports_verbosity: bool = False
53
+ supports_top_p: bool = True
54
+ supports_sampling_overrides: bool = True
55
+ supports_1m_context: bool = False
56
+
57
+ # Temperature configuration
58
+ temperature_constraint: Literal["fixed", "range"] = "range"
59
+ temperature: TemperatureSpec = field(default_factory=lambda: TemperatureSpec(0.0, 1.0, 2.0))
60
+
61
+ # Verbosity configuration (GPT-5 non-Codex models via Responses API)
62
+ verbosity_levels: tuple[str, ...] | None = None
63
+
64
+ # API configuration
65
+ use_responses_api: bool = False
66
+
67
+ # Reasoning/thinking configuration
68
+ # Native parameter name varies by provider:
69
+ # - OpenAI: "reasoning_effort"
70
+ # - Anthropic: "output_config.effort"
71
+ # - Gemini 2.5: "thinking_budget"
72
+ # - Gemini 3: "thinking_level"
73
+ native_thinking_param: str | None = None
74
+
75
+ # LiteLLM abstraction - supported reasoning_effort values
76
+ # These are the values LiteLLM accepts and maps to native params
77
+ litellm_reasoning_efforts: tuple[str, ...] | None = None
78
+ default_reasoning_effort: str | None = None
79
+ thinking_modes: tuple[str, ...] | None = None
80
+
81
+ # Gemini 3 specific - thinking levels (different from reasoning_effort)
82
+ thinking_levels: tuple[str, ...] | None = None
83
+ default_thinking_level: str | None = None
84
+
85
+ # Metadata
86
+ token_estimate_multiplier: float = 1.0
87
+ system_prompt_addendum: str | None = None
88
+ tags: tuple[str, ...] = field(default_factory=tuple)
89
+
90
+ def __post_init__(self) -> None:
91
+ if self.context_window_tokens <= 0:
92
+ raise ValueError(f"context_window_tokens must be > 0, got {self.context_window_tokens}")
93
+ if self.max_output_tokens <= 0:
94
+ raise ValueError(f"max_output_tokens must be > 0, got {self.max_output_tokens}")
95
+ if self.max_thinking_tokens is not None and self.max_thinking_tokens <= 0:
96
+ raise ValueError(f"max_thinking_tokens must be > 0 or null, got {self.max_thinking_tokens}")
97
+ if not (0 <= self.intelligence_score <= 100):
98
+ raise ValueError(f"intelligence_score must be 0-100, got {self.intelligence_score}")
99
+ if self.token_estimate_multiplier <= 0:
100
+ raise ValueError(f"token_estimate_multiplier must be > 0, got {self.token_estimate_multiplier}")
101
+ if self.temperature_constraint == "fixed":
102
+ if self.temperature.min != self.temperature.max:
103
+ raise ValueError(
104
+ f"Fixed temperature constraint requires min == max, "
105
+ f"got min={self.temperature.min}, max={self.temperature.max}"
106
+ )
107
+
108
+
109
+ REQUIRED_TIERS = frozenset({"haiku", "sonnet", "opus"})
110
+
111
+
112
+ @dataclass(frozen=True)
113
+ class ModelCatalog:
114
+ """The complete model catalog.
115
+
116
+ Immutable container for all models and their aliases.
117
+ Use the module-level functions to query the catalog.
118
+ """
119
+
120
+ schema_version: int
121
+ models: dict[str, ModelSpec]
122
+ aliases: dict[str, str]
123
+ defaults: dict[str, dict[str, str]] = field(default_factory=dict) # provider -> tier -> canonical model ID
124
+
125
+ def resolve(self, model_or_alias: str) -> str:
126
+ """Resolve a model ID or alias to its canonical ID.
127
+
128
+ Args:
129
+ model_or_alias: A canonical model ID or an alias.
130
+
131
+ Returns:
132
+ The canonical model ID.
133
+
134
+ Raises:
135
+ KeyError: If the model/alias is not found in the catalog.
136
+ """
137
+ if model_or_alias in self.models:
138
+ return model_or_alias
139
+ if model_or_alias in self.aliases:
140
+ return self.aliases[model_or_alias]
141
+ raise KeyError(f"Unknown model or alias: {model_or_alias!r}")
142
+
143
+ def get(self, model_or_alias: str) -> ModelSpec:
144
+ """Get the model spec for a model ID or alias.
145
+
146
+ Args:
147
+ model_or_alias: A canonical model ID or an alias.
148
+
149
+ Returns:
150
+ The ModelSpec for the resolved model.
151
+
152
+ Raises:
153
+ KeyError: If the model/alias is not found in the catalog.
154
+ """
155
+ canonical_id = self.resolve(model_or_alias)
156
+ return self.models[canonical_id]
157
+
158
+ def get_default(self, provider: str, tier: str) -> str:
159
+ """Return the canonical model ID for a provider+tier default.
160
+
161
+ Raises KeyError if provider or tier is not in defaults.
162
+ """
163
+ return self.defaults[provider][tier]
164
+
165
+ def __contains__(self, model_or_alias: str) -> bool:
166
+ """Check if a model or alias exists in the catalog."""
167
+ return model_or_alias in self.models or model_or_alias in self.aliases
forge/core/naming.py ADDED
@@ -0,0 +1,212 @@
1
+ """Random name generation for human-friendly identifiers.
2
+
3
+ Provides consistent naming across Forge components using the coolname library.
4
+
5
+ Session names use default coolname (adjective-animal): 'spirited-coati'.
6
+ Proxy names use a custom color-fruit generator: 'teal-lemon'.
7
+
8
+ Usage:
9
+ from forge.core.naming import generate_name, generate_unique_name
10
+ from forge.core.naming import generate_proxy_name, generate_unique_proxy_name
11
+
12
+ name = generate_name() # 'mottled-crab'
13
+ unique = generate_unique_name(existing) # Avoids collisions
14
+ proxy = generate_proxy_name() # 'teal-lemon'
15
+ unique_proxy = generate_unique_proxy_name(existing)
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import random
21
+ from typing import Literal
22
+
23
+ import coolname
24
+ from coolname import RandomGenerator
25
+
26
+ # Type alias for supported word counts
27
+ WordCount = Literal[2, 3, 4]
28
+
29
+ # Default word count for generated names
30
+ DEFAULT_WORDS: WordCount = 2
31
+
32
+
33
+ def generate_name(words: WordCount = DEFAULT_WORDS) -> str:
34
+ """Generate a random human-friendly name.
35
+
36
+ Args:
37
+ words: Number of words in the name (2, 3, or 4).
38
+ - 2: 'adjective-noun' (e.g., 'happy-fox') - ~10^5 combinations
39
+ - 3: 'adjective-adjective-noun' (e.g., 'big-maize-lori') - ~10^8 combinations
40
+ - 4: 'adjective-adjective-noun-of-noun' (e.g., 'military-diamond-tuatara-of-endeavor') - ~10^10 combinations
41
+
42
+ Returns:
43
+ A hyphenated lowercase name.
44
+
45
+ Example:
46
+ >>> name = generate_name()
47
+ >>> '-' in name
48
+ True
49
+ """
50
+ return coolname.generate_slug(words)
51
+
52
+
53
+ def generate_unique_name(
54
+ existing_names: set[str],
55
+ words: WordCount = DEFAULT_WORDS,
56
+ max_attempts: int = 100,
57
+ ) -> str:
58
+ """Generate a random name that doesn't conflict with existing names.
59
+
60
+ Args:
61
+ existing_names: Set of names that already exist.
62
+ words: Number of words in the name (2, 3, or 4).
63
+ max_attempts: Maximum attempts before falling back to suffix strategy.
64
+
65
+ Returns:
66
+ A unique random name not in existing_names.
67
+
68
+ Note:
69
+ If max_attempts is exceeded, falls back to appending random suffixes
70
+ until a unique name is found. With ~10^5 combinations for 2-word names,
71
+ collisions are rare unless existing_names is very large.
72
+
73
+ Example:
74
+ >>> existing = {"happy-fox", "brave-wolf"}
75
+ >>> name = generate_unique_name(existing)
76
+ >>> name not in existing
77
+ True
78
+ """
79
+ # Try generating names without suffix
80
+ for _ in range(max_attempts):
81
+ name = generate_name(words)
82
+ if name not in existing_names:
83
+ return name
84
+
85
+ # Fallback: append random suffix, loop until unique
86
+ while True:
87
+ base = generate_name(words)
88
+ suffix = random.randint(100, 9999) # 4 digits for more entropy
89
+ name = f"{base}-{suffix}"
90
+ if name not in existing_names:
91
+ return name
92
+
93
+
94
+ def generate_parts(words: WordCount = DEFAULT_WORDS) -> list[str]:
95
+ """Generate name parts as a list (for custom formatting).
96
+
97
+ Args:
98
+ words: Number of words to generate (2, 3, or 4).
99
+
100
+ Returns:
101
+ List of name parts (e.g., ['happy', 'fox']).
102
+
103
+ Example:
104
+ >>> parts = generate_parts()
105
+ >>> len(parts) == 2
106
+ True
107
+ """
108
+ return coolname.generate(words)
109
+
110
+
111
+ # --- Proxy names: color-fruit pattern (visually distinct from session names) ---
112
+
113
+ _PROXY_COLORS = [
114
+ "amber",
115
+ "azure",
116
+ "bronze",
117
+ "cobalt",
118
+ "copper",
119
+ "coral",
120
+ "crimson",
121
+ "cyan",
122
+ "ebony",
123
+ "emerald",
124
+ "garnet",
125
+ "golden",
126
+ "indigo",
127
+ "ivory",
128
+ "jade",
129
+ "lavender",
130
+ "magenta",
131
+ "maroon",
132
+ "navy",
133
+ "ochre",
134
+ "onyx",
135
+ "ruby",
136
+ "sage",
137
+ "scarlet",
138
+ "silver",
139
+ "slate",
140
+ "teal",
141
+ "topaz",
142
+ "turquoise",
143
+ "violet",
144
+ ]
145
+
146
+ _PROXY_FRUITS = [
147
+ "apple",
148
+ "apricot",
149
+ "banana",
150
+ "cherry",
151
+ "citron",
152
+ "coconut",
153
+ "date",
154
+ "fig",
155
+ "grape",
156
+ "guava",
157
+ "kiwi",
158
+ "lemon",
159
+ "lime",
160
+ "lychee",
161
+ "mango",
162
+ "melon",
163
+ "olive",
164
+ "orange",
165
+ "papaya",
166
+ "peach",
167
+ "pear",
168
+ "plum",
169
+ "pomelo",
170
+ "quince",
171
+ "raisin",
172
+ "sorbet",
173
+ "tangerine",
174
+ "walnut",
175
+ "yuzu",
176
+ "zest",
177
+ ]
178
+
179
+ _proxy_generator = RandomGenerator(
180
+ {
181
+ "all": {"type": "cartesian", "lists": ["color", "fruit"]},
182
+ "color": {"type": "words", "words": _PROXY_COLORS},
183
+ "fruit": {"type": "words", "words": _PROXY_FRUITS},
184
+ }
185
+ )
186
+
187
+
188
+ def generate_proxy_name() -> str:
189
+ """Generate a color-fruit proxy name (e.g., 'teal-lemon')."""
190
+ return _proxy_generator.generate_slug()
191
+
192
+
193
+ def generate_unique_proxy_name(
194
+ existing_names: set[str],
195
+ max_attempts: int = 100,
196
+ ) -> str:
197
+ """Generate a unique color-fruit proxy name.
198
+
199
+ 30 colors x 30 fruits = 900 combinations.
200
+ Falls back to numeric suffix if exhausted.
201
+ """
202
+ for _ in range(max_attempts):
203
+ name = generate_proxy_name()
204
+ if name not in existing_names:
205
+ return name
206
+
207
+ while True:
208
+ base = generate_proxy_name()
209
+ suffix = random.randint(100, 9999)
210
+ name = f"{base}-{suffix}"
211
+ if name not in existing_names:
212
+ return name
@@ -0,0 +1,73 @@
1
+ """Command-core operations.
2
+
3
+ This package contains reusable operations that can be invoked from:
4
+
5
+ - the Forge CLI (`forge ...`), and
6
+ - in-chat direct commands (via `%...` routed through `forge hook user-prompt-submit`).
7
+
8
+ Ops must be UI-agnostic: no Click usage, no printing, and no hook JSON.
9
+ """
10
+
11
+ from .context import ExecutionContext
12
+ from .gc import (
13
+ CleanError,
14
+ CleanReport,
15
+ CleanResult,
16
+ OrphanCategory,
17
+ collect_clean_report,
18
+ run_clean,
19
+ )
20
+ from .proxy import (
21
+ ListProxiesItem,
22
+ ListProxiesResult,
23
+ ShowProxyResult,
24
+ list_proxies,
25
+ show_proxy,
26
+ )
27
+ from .resolution import (
28
+ ResolvedSession,
29
+ resolve_session_repo_wide,
30
+ )
31
+ from .session import (
32
+ ForgeOpError,
33
+ ListSessionsItem,
34
+ ListSessionsResult,
35
+ ResetOverridesResult,
36
+ ResolveSessionResult,
37
+ SetOverrideResult,
38
+ list_sessions,
39
+ reset_session_overrides,
40
+ resolve_session,
41
+ set_session_override,
42
+ )
43
+
44
+ __all__ = [
45
+ "ExecutionContext",
46
+ "ForgeOpError",
47
+ # GC ops
48
+ "CleanError",
49
+ "CleanReport",
50
+ "CleanResult",
51
+ "OrphanCategory",
52
+ "collect_clean_report",
53
+ "run_clean",
54
+ # Resolution ops
55
+ "ResolvedSession",
56
+ "resolve_session_repo_wide",
57
+ # Session ops
58
+ "ListSessionsItem",
59
+ "ListSessionsResult",
60
+ "list_sessions",
61
+ "ResolveSessionResult",
62
+ "resolve_session",
63
+ "SetOverrideResult",
64
+ "set_session_override",
65
+ "ResetOverridesResult",
66
+ "reset_session_overrides",
67
+ # Proxy ops
68
+ "ListProxiesItem",
69
+ "ListProxiesResult",
70
+ "ShowProxyResult",
71
+ "list_proxies",
72
+ "show_proxy",
73
+ ]
@@ -0,0 +1,141 @@
1
+ """Command-core execution context.
2
+
3
+ This context is intentionally lightweight: it carries paths only.
4
+ Stores (SessionStore/IndexStore/etc.) are cheap file wrappers and should be
5
+ constructed inside ops as needed.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class ExecutionContext:
16
+ """Execution context for command-core ops.
17
+
18
+ Attributes:
19
+ cwd: Current working directory.
20
+ worktree_root: Git checkout root (= checkout_root in the identity model).
21
+ project_root: Git repository root (= logical repo, main checkout).
22
+ forge_root: Forge project root (directory containing .forge/), or None
23
+ if not inside a Forge project.
24
+ """
25
+
26
+ cwd: Path
27
+ worktree_root: Path
28
+ project_root: Path
29
+ forge_root: Path | None = None
30
+
31
+ @classmethod
32
+ def from_cwd(cls, cwd: Path | None = None) -> "ExecutionContext":
33
+ """Create context by deriving paths from the current working directory.
34
+
35
+ Uses git to find the worktree/project root. Falls back to cwd if not
36
+ in a git repository.
37
+
38
+ Args:
39
+ cwd: Working directory. Defaults to Path.cwd().
40
+
41
+ Returns:
42
+ ExecutionContext with derived paths.
43
+ """
44
+ if cwd is None:
45
+ cwd = Path.cwd().resolve()
46
+ else:
47
+ cwd = cwd.resolve()
48
+
49
+ # Try to find git root (works for both regular repos and worktrees)
50
+ worktree_root = _find_git_root(cwd)
51
+ if worktree_root is None:
52
+ # Not in a git repo: use cwd for all paths
53
+ forge_root = find_forge_root(cwd)
54
+ return cls(cwd=cwd, worktree_root=cwd, project_root=cwd, forge_root=forge_root)
55
+
56
+ # For worktrees, find the main repository root
57
+ project_root = _find_main_repo_root(worktree_root)
58
+
59
+ # Find Forge project root (.forge/ directory)
60
+ forge_root = find_forge_root(cwd)
61
+
62
+ return cls(cwd=cwd, worktree_root=worktree_root, project_root=project_root, forge_root=forge_root)
63
+
64
+
65
+ def _find_git_root(start: Path) -> Path | None:
66
+ """Find git root by walking up from start.
67
+
68
+ Returns None if not in a git repository.
69
+ """
70
+ current = start
71
+ while current != current.parent:
72
+ if (current / ".git").exists():
73
+ return current
74
+ current = current.parent
75
+
76
+ # Check root
77
+ if (current / ".git").exists():
78
+ return current
79
+
80
+ return None
81
+
82
+
83
+ def _find_main_repo_root(worktree_root: Path) -> Path:
84
+ """Find the main repository root from a worktree.
85
+
86
+ In a regular repo, .git is a directory and we return worktree_root.
87
+ In a worktree, .git is a file containing 'gitdir: <path>' pointing to
88
+ the worktree's git dir inside the main repo's .git/worktrees/.
89
+ """
90
+ git_path = worktree_root / ".git"
91
+
92
+ if git_path.is_dir():
93
+ # Regular repo, not a worktree
94
+ return worktree_root
95
+
96
+ if git_path.is_file():
97
+ # Worktree: .git file contains 'gitdir: <path>'
98
+ try:
99
+ content = git_path.read_text().strip()
100
+ if content.startswith("gitdir:"):
101
+ gitdir = content[7:].strip()
102
+ # gitdir is typically: /path/to/main/.git/worktrees/<name>
103
+ # We want: /path/to/main
104
+ gitdir_path = Path(gitdir)
105
+ if not gitdir_path.is_absolute():
106
+ gitdir_path = (worktree_root / gitdir_path).resolve()
107
+
108
+ # Navigate up from .git/worktrees/<name> to find main repo
109
+ if "worktrees" in gitdir_path.parts:
110
+ # Find .git directory (parent of worktrees)
111
+ idx = gitdir_path.parts.index("worktrees")
112
+ git_dir = Path(*gitdir_path.parts[:idx])
113
+ if git_dir.name == ".git":
114
+ return git_dir.parent
115
+ except (OSError, ValueError):
116
+ pass
117
+
118
+ # Fallback: return worktree_root
119
+ return worktree_root
120
+
121
+
122
+ def find_forge_root(start: Path) -> Path | None:
123
+ """Find the Forge project root by walking up from start.
124
+
125
+ A Forge project root is a directory containing a ``.forge/`` subdirectory,
126
+ established by ``forge extension enable``.
127
+
128
+ Stops at git repository boundaries (``.git`` directory or file) to avoid
129
+ escaping into a parent repository's ``.forge/``.
130
+
131
+ Returns None if not inside a Forge project.
132
+ """
133
+ current = start
134
+ while current != current.parent:
135
+ if (current / ".forge").is_dir():
136
+ return current
137
+ if (current / ".git").exists():
138
+ return None # Hit git boundary without finding .forge/
139
+ current = current.parent
140
+
141
+ return None