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,107 @@
1
+ """Claude Code direct model pin helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import MutableMapping
6
+ from dataclasses import dataclass
7
+
8
+ from forge.core.models.catalog import (
9
+ ModelCatalogError,
10
+ get_model_spec,
11
+ resolve_model_id,
12
+ )
13
+
14
+ ONE_M_SUFFIX = "[1m]"
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class DirectModelPin:
19
+ """A Claude Code env-ready direct model pin."""
20
+
21
+ canonical_model: str
22
+ env_model: str
23
+ tier: str
24
+
25
+ @property
26
+ def env_var(self) -> str:
27
+ return f"ANTHROPIC_DEFAULT_{self.tier.upper()}_MODEL"
28
+
29
+ def env(self) -> dict[str, str]:
30
+ return {
31
+ "ANTHROPIC_MODEL": self.tier,
32
+ self.env_var: self.env_model,
33
+ }
34
+
35
+
36
+ def resolve_direct_model_pin(value: str) -> DirectModelPin:
37
+ """Resolve a direct-session model value to Claude Code model env vars.
38
+
39
+ The catalog owns aliases and canonical model IDs. Claude Code owns the
40
+ ``[1m]`` model-pin suffix, so this helper strips it for catalog lookup and
41
+ restores it on the normalized env-ready model value.
42
+ """
43
+ raw_value = value.strip()
44
+ if not raw_value:
45
+ raise ValueError("--model cannot be empty")
46
+
47
+ requested_1m = raw_value.endswith(ONE_M_SUFFIX)
48
+ lookup_value = raw_value.removesuffix(ONE_M_SUFFIX) if requested_1m else raw_value
49
+
50
+ try:
51
+ canonical = resolve_model_id(lookup_value)
52
+ except ModelCatalogError as e:
53
+ raise ValueError(f"Unknown direct Claude model: {value!r}") from e
54
+
55
+ normalized_1m = requested_1m or canonical.endswith("-1m")
56
+ base_canonical = canonical.removesuffix("-1m") if canonical.endswith("-1m") else canonical
57
+
58
+ if not base_canonical.startswith("claude-"):
59
+ raise ValueError(f"--model only supports Claude models for direct sessions, got {value!r}")
60
+
61
+ tier = _claude_tier(base_canonical)
62
+ if tier is None:
63
+ raise ValueError(f"Unsupported Claude model tier for direct sessions: {value!r}")
64
+
65
+ if normalized_1m:
66
+ spec = get_model_spec(base_canonical)
67
+ if tier not in {"opus", "sonnet"} and not spec.supports_1m_context:
68
+ raise ValueError("[1m] direct model pins are only supported for Opus/Sonnet Claude models")
69
+
70
+ env_model = f"{base_canonical}{ONE_M_SUFFIX}" if normalized_1m else base_canonical
71
+ return DirectModelPin(canonical_model=base_canonical, env_model=env_model, tier=tier)
72
+
73
+
74
+ def direct_model_env(value: str | None) -> dict[str, str]:
75
+ """Return Claude Code direct-model environment variables for ``value``."""
76
+ if not value:
77
+ return {}
78
+ return resolve_direct_model_pin(value).env()
79
+
80
+
81
+ def apply_direct_model_env(env_vars: MutableMapping[str, str], value: str | None) -> str | None:
82
+ """Apply direct-model env vars in-place, returning an error message on failure."""
83
+ if not value:
84
+ return None
85
+ try:
86
+ env_vars.update(direct_model_env(value))
87
+ except ValueError as e:
88
+ return str(e)
89
+ return None
90
+
91
+
92
+ def token_estimate_multiplier_for_direct_model(value: str | None) -> float:
93
+ """Return the catalog token-estimate multiplier for a direct model pin."""
94
+ if not value:
95
+ return 1.0
96
+ pin = resolve_direct_model_pin(value)
97
+ return get_model_spec(pin.canonical_model).token_estimate_multiplier
98
+
99
+
100
+ def _claude_tier(canonical_model: str) -> str | None:
101
+ if canonical_model.startswith("claude-opus-"):
102
+ return "opus"
103
+ if canonical_model.startswith("claude-sonnet-"):
104
+ return "sonnet"
105
+ if canonical_model.startswith("claude-haiku-"):
106
+ return "haiku"
107
+ return None
@@ -0,0 +1,169 @@
1
+ """Effective configuration computation (intent + overrides).
2
+
3
+ This module provides functions for computing the effective session configuration
4
+ by merging the baseline intent with runtime overrides.
5
+
6
+ Merge semantics:
7
+ - Scalars: override replaces base value
8
+ - Dicts: recursively merge (override keys win on conflict)
9
+ - Lists: override replaces entire list (no concatenation)
10
+ - None in override: clears the field (effective value becomes None)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from copy import deepcopy
16
+ from dataclasses import asdict
17
+ from typing import Any
18
+
19
+ import dacite
20
+
21
+ from .exceptions import InvalidOverrideKeyError, InvalidOverrideValueError
22
+ from .models import SessionIntent, SessionState
23
+
24
+
25
+ def apply_overrides(base: dict[str, Any], overrides: dict[str, Any]) -> dict[str, Any]:
26
+ """Apply user overrides to a base configuration dict.
27
+
28
+ This is a generic merge function with no schema awareness.
29
+ Schema validation happens in compute_effective_intent().
30
+
31
+ Merge semantics:
32
+ - Scalars: override replaces base
33
+ - Dicts: recursively merge (both must be dicts)
34
+ - Lists: override replaces entire list (no concatenation)
35
+ - None in override: sets field to None (clears it)
36
+ - New keys in override: added to result
37
+
38
+ Args:
39
+ base: The base dictionary (typically from intent).
40
+ overrides: The overrides dictionary (sparse, only changed fields).
41
+
42
+ Returns:
43
+ A new dict with overrides applied to base.
44
+ """
45
+ result = deepcopy(base)
46
+
47
+ for key, value in overrides.items():
48
+ if value is None:
49
+ result[key] = None
50
+ elif isinstance(value, dict) and key in result and isinstance(result[key], dict):
51
+ result[key] = apply_overrides(result[key], value)
52
+ else:
53
+ result[key] = deepcopy(value)
54
+
55
+ return result
56
+
57
+
58
+ def compute_effective_intent(
59
+ state: SessionState,
60
+ strict: bool = True,
61
+ override_key: str | None = None,
62
+ ) -> SessionIntent:
63
+ """Compute effective config by merging intent with overrides.
64
+
65
+ Args:
66
+ state: The session state containing intent and overrides.
67
+ strict: If True, validate the merged result can become a valid SessionIntent.
68
+ Raises InvalidOverrideValueError on type mismatches.
69
+ override_key: If provided, used in error messages to identify which override
70
+ caused the failure. Typically the key being set via CLI.
71
+
72
+ Returns:
73
+ A SessionIntent representing the effective configuration.
74
+
75
+ Raises:
76
+ InvalidOverrideValueError: If strict=True and the merged config has invalid types.
77
+ """
78
+ intent_dict = asdict(state.intent)
79
+
80
+ if state.overrides:
81
+ merged = apply_overrides(intent_dict, state.overrides)
82
+ else:
83
+ merged = intent_dict
84
+
85
+ if strict:
86
+ try:
87
+ return dacite.from_dict(
88
+ data_class=SessionIntent,
89
+ data=merged,
90
+ config=dacite.Config(strict=True),
91
+ )
92
+ except (dacite.DaciteError, TypeError, ValueError) as e:
93
+ key = override_key or "unknown"
94
+ actual = _infer_actual_type(e, merged)
95
+ expected = _infer_expected_type(e)
96
+ raise InvalidOverrideValueError(key, expected, actual) from e
97
+
98
+ # Non-strict mode: best-effort conversion.
99
+ # Note: With strict v3 manifests and strict override validation, this should
100
+ # generally not encounter unknown keys.
101
+ return dacite.from_dict(
102
+ data_class=SessionIntent,
103
+ data=merged,
104
+ config=dacite.Config(strict=True),
105
+ )
106
+
107
+
108
+ def get_effective_value(state: SessionState, key: str) -> Any | None:
109
+ """Get effective value for a specific dot-notation key.
110
+
111
+ This function validates the key syntax but returns None for valid keys
112
+ that are not present in the effective config (rather than raising).
113
+
114
+ Args:
115
+ state: The session state.
116
+ key: Dot-notation path (e.g., "agent", "proxy.template").
117
+
118
+ Returns:
119
+ The effective value, or None if key is valid but not set.
120
+
121
+ Raises:
122
+ InvalidOverrideKeyError: If key syntax is invalid (empty, empty segments).
123
+ """
124
+ if not key:
125
+ raise InvalidOverrideKeyError(key, "key cannot be empty")
126
+
127
+ parts = key.split(".")
128
+ for part in parts:
129
+ if not part:
130
+ raise InvalidOverrideKeyError(key, "empty segment in path")
131
+
132
+ effective = compute_effective_intent(state, strict=True)
133
+ effective_dict = asdict(effective)
134
+
135
+ current: Any = effective_dict
136
+ for part in parts:
137
+ if not isinstance(current, dict):
138
+ return None
139
+ if part not in current:
140
+ return None
141
+ current = current[part]
142
+
143
+ return current
144
+
145
+
146
+ def _infer_actual_type(error: Exception, merged: dict[str, Any]) -> str:
147
+ """Try to infer the actual type/value from a dacite error."""
148
+ error_str = str(error)
149
+
150
+ if "expected" in error_str.lower() and "got" in error_str.lower():
151
+ return error_str
152
+
153
+ return f"invalid value ({error_str})"
154
+
155
+
156
+ def _infer_expected_type(error: Exception) -> str:
157
+ """Try to infer the expected type from a dacite error."""
158
+ error_str = str(error)
159
+
160
+ if "str" in error_str:
161
+ return "str"
162
+ if "list" in error_str:
163
+ return "list"
164
+ if "int" in error_str:
165
+ return "int"
166
+ if "bool" in error_str:
167
+ return "bool"
168
+
169
+ return "valid type"
@@ -0,0 +1,255 @@
1
+ """Exceptions for Forge Session module."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class ForgeSessionError(Exception):
7
+ """Base exception for session module."""
8
+
9
+
10
+ class InvalidSessionNameError(ForgeSessionError):
11
+ """Raised when session name validation fails."""
12
+
13
+
14
+ class SessionNotFoundError(ForgeSessionError):
15
+ """Raised when a session cannot be found."""
16
+
17
+ def __init__(self, name: str) -> None:
18
+ self.name = name
19
+ super().__init__(f"session '{name}' not found")
20
+
21
+
22
+ class SessionExistsError(ForgeSessionError):
23
+ """Raised when trying to create a session that already exists."""
24
+
25
+ def __init__(self, name: str) -> None:
26
+ self.name = name
27
+ super().__init__(f"session '{name}' already exists")
28
+
29
+
30
+ class SessionFileNotFoundError(ForgeSessionError):
31
+ """Raised when session state file doesn't exist in expected location."""
32
+
33
+ def __init__(self, path: str) -> None:
34
+ self.path = path
35
+ super().__init__(f"session file not found at '{path}'")
36
+
37
+
38
+ class ManifestCorruptedError(ForgeSessionError):
39
+ """Raised when manifest file exists but cannot be parsed."""
40
+
41
+ def __init__(self, path: str, reason: str) -> None:
42
+ self.path = path
43
+ self.reason = reason
44
+ super().__init__(f"manifest at '{path}': {reason}")
45
+
46
+
47
+ class ManifestValidationError(ForgeSessionError):
48
+ """Raised when manifest is missing required fields."""
49
+
50
+ def __init__(self, path: str, missing_fields: list[str]) -> None:
51
+ self.path = path
52
+ self.missing_fields = missing_fields
53
+ fields_str = ", ".join(missing_fields)
54
+ super().__init__(f"manifest at '{path}' missing required fields: {fields_str}")
55
+
56
+
57
+ class IndexCorruptedError(ForgeSessionError):
58
+ """Raised when index file exists but cannot be parsed."""
59
+
60
+ def __init__(self, path: str, reason: str) -> None:
61
+ self.path = path
62
+ self.reason = reason
63
+ super().__init__(f"index at '{path}': {reason}")
64
+
65
+
66
+ class CannotForkIncognitoError(ForgeSessionError):
67
+ """Raised when attempting to fork from an incognito session."""
68
+
69
+ def __init__(self, name: str) -> None:
70
+ self.name = name
71
+ super().__init__(f"cannot fork from incognito session '{name}'")
72
+
73
+
74
+ class ClaudeInvocationError(ForgeSessionError):
75
+ """Raised when Claude binary invocation fails."""
76
+
77
+ def __init__(self, reason: str, exit_code: int | None = None) -> None:
78
+ self.reason = reason
79
+ self.exit_code = exit_code
80
+ msg = reason
81
+ if exit_code is not None:
82
+ msg = f"{reason} (exit code: {exit_code})"
83
+ super().__init__(msg)
84
+
85
+
86
+ class ProjectRootNotFoundError(ForgeSessionError):
87
+ """Raised when no git repository can be found."""
88
+
89
+ def __init__(self, path: str) -> None:
90
+ self.path = path
91
+ super().__init__(f"no git repository found at or above '{path}'")
92
+
93
+
94
+ class ForgeNotEnabledError(ForgeSessionError):
95
+ """Raised when session start is attempted without a Forge project.
96
+
97
+ Rule 1: sessions require ``forge extension enable`` (which creates ``.forge/``).
98
+ """
99
+
100
+ def __init__(self, path: str) -> None:
101
+ self.path = path
102
+ super().__init__(f"no Forge project at '{path}'. Run 'forge extension enable' first.")
103
+
104
+
105
+ # --- Git Worktree Exceptions ---
106
+
107
+
108
+ class GitNotFoundError(ForgeSessionError):
109
+ """Raised when git binary is not found in PATH."""
110
+
111
+ def __init__(self) -> None:
112
+ super().__init__("git binary not found in PATH")
113
+
114
+
115
+ class GitWorktreeError(ForgeSessionError):
116
+ """Raised when a git worktree operation fails."""
117
+
118
+ def __init__(self, operation: str, reason: str, exit_code: int | None = None) -> None:
119
+ self.operation = operation
120
+ self.reason = reason
121
+ self.exit_code = exit_code
122
+ msg = f"git worktree {operation} failed: {reason}"
123
+ if exit_code is not None:
124
+ msg = f"{msg} (exit code: {exit_code})"
125
+ super().__init__(msg)
126
+
127
+
128
+ class InvalidBranchNameError(ForgeSessionError):
129
+ """Raised when an explicit --branch name is invalid."""
130
+
131
+ def __init__(self, branch: str, reason: str) -> None:
132
+ self.branch = branch
133
+ self.reason = reason
134
+ super().__init__(f"invalid branch name '{branch}': {reason}")
135
+
136
+
137
+ class BranchExistsError(ForgeSessionError):
138
+ """Raised when trying to create a branch that already exists."""
139
+
140
+ def __init__(self, branch: str, worktree: str | None = None) -> None:
141
+ self.branch = branch
142
+ self.worktree = worktree
143
+ if worktree:
144
+ msg = f"branch '{branch}' already exists (checked out in '{worktree}')"
145
+ else:
146
+ msg = f"branch '{branch}' already exists"
147
+ super().__init__(msg)
148
+
149
+
150
+ class BranchInUseError(ForgeSessionError):
151
+ """Raised when a branch is checked out in another worktree."""
152
+
153
+ def __init__(self, branch: str, worktree: str) -> None:
154
+ self.branch = branch
155
+ self.worktree = worktree
156
+ super().__init__(f"branch '{branch}' is checked out in worktree '{worktree}'")
157
+
158
+
159
+ class BranchNotMergedError(ForgeSessionError):
160
+ """Raised when trying to delete a branch that is not fully merged."""
161
+
162
+ def __init__(self, branch: str) -> None:
163
+ self.branch = branch
164
+ super().__init__(f"branch '{branch}' is not fully merged. Use --force to delete anyway")
165
+
166
+
167
+ class WorktreePathExistsError(ForgeSessionError):
168
+ """Raised when the target worktree path already exists."""
169
+
170
+ def __init__(self, path: str) -> None:
171
+ self.path = path
172
+ super().__init__(f"worktree path '{path}' already exists")
173
+
174
+
175
+ class DirtyWorktreeError(ForgeSessionError):
176
+ """Raised when a worktree has uncommitted changes during cleanup."""
177
+
178
+ def __init__(self, path: str) -> None:
179
+ self.path = path
180
+ super().__init__(f"worktree '{path}' has uncommitted changes. Use --force to remove anyway")
181
+
182
+
183
+ # --- Override Exceptions ---
184
+
185
+
186
+ class InvalidOverrideKeyError(ForgeSessionError):
187
+ """Raised when an override key is invalid.
188
+
189
+ Keys can be invalid due to:
190
+ - Empty key or empty segment in path (e.g., "foo..bar")
191
+ - Targeting confirmed.* fields
192
+ - Targeting top-level manifest fields (name, schema_version, etc.)
193
+ - Using intent.* prefix (keys should be relative to intent)
194
+ - Unknown field not in SessionIntent schema
195
+ """
196
+
197
+ def __init__(self, key: str, reason: str, hint: str | None = None) -> None:
198
+ self.key = key
199
+ self.reason = reason
200
+ self.hint = hint # e.g., "valid keys: agent, proxy.*, policy.*, ..."
201
+ msg = f"invalid override key '{key}': {reason}"
202
+ if hint:
203
+ msg = f"{msg} ({hint})"
204
+ super().__init__(msg)
205
+
206
+
207
+ class InvalidOverrideValueError(ForgeSessionError):
208
+ """Raised when an override value has an incompatible type.
209
+
210
+ This occurs when the effective config (intent + overrides) cannot be
211
+ converted to a valid SessionIntent due to type mismatches.
212
+ """
213
+
214
+ def __init__(self, key: str, expected: str, actual: str) -> None:
215
+ self.key = key
216
+ self.expected = expected # e.g., "str", "list[str]", "enum"
217
+ self.actual = actual # e.g., "bool", "True"
218
+ super().__init__(f"invalid value for '{key}': expected {expected}, got {actual}")
219
+
220
+
221
+ # --- Resume Exceptions ---
222
+
223
+
224
+ class AmbiguousSessionError(ForgeSessionError):
225
+ """Raised when a session name matches multiple projects.
226
+
227
+ User-facing commands use strict resolution which raises this when
228
+ forge_root is None and duplicate names exist across projects.
229
+ """
230
+
231
+ def __init__(self, name: str, forge_roots: list[str]) -> None:
232
+ self.name = name
233
+ self.forge_roots = forge_roots
234
+ roots_str = ", ".join(forge_roots)
235
+ super().__init__(
236
+ f"session '{name}' exists in multiple projects: {roots_str}. "
237
+ f"Run from within the target project directory to disambiguate."
238
+ )
239
+
240
+
241
+ class ContextBudgetExceededError(ForgeSessionError):
242
+ """Raised when parent context exceeds proxy context limit.
243
+
244
+ This is a fail-fast check for the 'full' resume strategy. When the parent
245
+ transcript is too large to fit in the target proxy's context window, we
246
+ fail before launching Claude rather than wasting tokens.
247
+ """
248
+
249
+ def __init__(self, token_estimate: int, context_limit: int) -> None:
250
+ self.token_estimate = token_estimate
251
+ self.context_limit = context_limit
252
+ super().__init__(
253
+ f"Parent transcript ({token_estimate:,} tokens) exceeds context limit "
254
+ f"({context_limit:,}). Use --strategy structured or --strategy minimal."
255
+ )