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,73 @@
1
+ """Output formatting for multi-model review results.
2
+
3
+ Provides two output modes:
4
+ - Synthesis prompt: human-readable text for agent consumption
5
+ - JSON output: structured data for skill/script consumption
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from typing import Any
12
+
13
+ from .models import MultiReviewOutput, ReviewResult
14
+
15
+
16
+ def format_synthesis_prompt(output: MultiReviewOutput) -> str:
17
+ """Format review results into a synthesis prompt.
18
+
19
+ Intended for the calling agent to read and synthesize.
20
+ """
21
+ sections: list[str] = []
22
+
23
+ prompt_preview = output.prompt[:500]
24
+ if len(output.prompt) > 500:
25
+ prompt_preview += "..."
26
+ sections.append(f"I asked {len(output.results)} models the same prompt:\n\n> {prompt_preview}")
27
+
28
+ for result in output.results:
29
+ sections.append(f"\n\n-----\n\n## {result.model_name}'s answer:\n")
30
+ if result.success:
31
+ sections.append(result.stdout)
32
+ else:
33
+ sections.append(f"**Error:** {result.error}")
34
+
35
+ sections.append("""
36
+
37
+ -----
38
+
39
+ ## Synthesis Request
40
+
41
+ Now that you've seen all responses:
42
+
43
+ 1. **Points you missed**: Any points covered by other models that you did not cover?
44
+ 2. **Accuracy check**: Can you verify if they are accurate? Anything you disagree with?
45
+ 3. **Overall synthesis**: Provide a unified synthesis combining the best insights from all models.
46
+ """)
47
+
48
+ return "".join(sections)
49
+
50
+
51
+ def format_json_output(output: MultiReviewOutput) -> str:
52
+ """Format review results as structured JSON."""
53
+ data = build_json_dict(output)
54
+ return json.dumps(data, indent=2)
55
+
56
+
57
+ def build_json_dict(output: MultiReviewOutput) -> dict[str, Any]:
58
+ """Build the JSON-serializable dict for output."""
59
+ return {
60
+ "prompt": output.prompt,
61
+ "results": {r.model_name: _result_to_dict(r) for r in output.results},
62
+ "successful": output.successful,
63
+ "failed": output.failed,
64
+ }
65
+
66
+
67
+ def _result_to_dict(result: ReviewResult) -> dict[str, Any]:
68
+ return {
69
+ "response": result.stdout if result.success else None,
70
+ "error": result.error,
71
+ "duration_seconds": round(result.duration_seconds, 2),
72
+ "success": result.success,
73
+ }
@@ -0,0 +1,438 @@
1
+ """Forge runtime configuration (~/.forge/config.yaml).
2
+
3
+ Separate from forge.config (which the proxy imports) to avoid leaking
4
+ runtime preferences into routing. The proxy singleton must never see
5
+ these values — they control CLI/session behavior only.
6
+
7
+ File: ~/.forge/config.yaml (optional, fail-open if missing or invalid).
8
+
9
+ Three-layer resolution (highest precedence wins):
10
+ 1. Built-in defaults (dataclass field defaults)
11
+ 2. ~/.forge/config.yaml
12
+ 3. Environment variables (via _ENV_OVERRIDES mapping)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ import os
19
+ import tempfile
20
+ from dataclasses import asdict, dataclass, fields
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from forge.core.paths import get_forge_home
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ CONFIG_FILENAME = "config.yaml"
29
+
30
+ # Env var → field name mapping. Env vars override YAML values when present.
31
+ # This is the single source of truth for env-to-config overrides.
32
+ _ENV_OVERRIDES: dict[str, str] = {
33
+ "FORGE_DEBUG": "log_level",
34
+ }
35
+
36
+
37
+ @dataclass
38
+ class RuntimeConfig:
39
+ """Global Forge runtime preferences — always reflects effective values.
40
+
41
+ Three-layer resolution: built-in defaults → config.yaml → env vars.
42
+ After loading, all fields represent the effective runtime state.
43
+ They do NOT affect proxy routing (that's ForgeConfig's domain).
44
+
45
+ All fields have sensible defaults — the config file is optional.
46
+ """
47
+
48
+ # Proxy execution mode: "host" runs proxy on host, "sidecar" bundles in Docker
49
+ proxy_mode: str = "host"
50
+
51
+ sidecar_image: str = "forge-sidecar:latest"
52
+
53
+ # Version string sent in the User-Agent header to upstream LLM providers.
54
+ user_agent_claude_code_version: str = ""
55
+
56
+ # Optional model override for direct (non-proxy) sessions.
57
+ # Passed to Claude Code via ANTHROPIC_MODEL + ANTHROPIC_DEFAULT_*_MODEL.
58
+ # Empty string = let Claude Code decide.
59
+ default_direct_model: str = ""
60
+
61
+ # Fallback auto-compact window for proxy mode when model lookup fails.
62
+ # Passed as CLAUDE_CODE_AUTO_COMPACT_WINDOW to Claude Code.
63
+ # Direct sessions don't use this — Claude Code handles its own context.
64
+ context_limit: int = 200000
65
+
66
+ # Status line timeout for proxy/git subprocess calls (seconds)
67
+ status_timeout: float = 2.0
68
+
69
+ # Handoff agent default timeout (seconds)
70
+ handoff_timeout: int = 300
71
+
72
+ # File logging level: "off" (no file logging), "debug", "info", "warning"
73
+ # Override: FORGE_DEBUG env var (1/true/yes → "debug", 0/false/no/off → "off")
74
+ log_level: str = "off"
75
+
76
+ # Show Claude.ai rate limit usage in status line (direct sessions only).
77
+ # Off by default — not relevant for enterprise plans.
78
+ show_rate_limits: bool = False
79
+
80
+ # Auto-delete log files older than N days on CLI startup.
81
+ # 0 = disabled (no auto-cleanup). Positive integer = retention window in days.
82
+ log_retention_days: int = 0
83
+
84
+ # Auto-delete sessions older than N days on CLI startup.
85
+ # 0 = disabled (no auto-cleanup). Positive integer = retention window in days.
86
+ # Keeps worktrees and branches; removes manifests, index entries, and Claude
87
+ # transcripts (*.jsonl in ~/.claude/projects/). Forge artifact snapshots
88
+ # under .forge/artifacts/ are NOT removed.
89
+ session_retention_days: int = 0
90
+
91
+ # Policy summary feedback after evaluations: "on" (default), "off".
92
+ # Gates post-hoc "[forge] Policy: checked ..." summary lines and additionalContext.
93
+ # Does NOT affect deny output or substantive warning lines -- those stay visible always.
94
+ policy_summary_feedback: str = "on"
95
+
96
+ # Log tool failures to ~/.forge/logs/tool_failures/ even without debug mode.
97
+ # Off by default because records may include tool inputs and error payloads.
98
+ log_tool_failures: bool = False
99
+
100
+ # Ignore environment variables for credential resolution.
101
+ # When true, Forge reads credentials only from ~/.forge/credentials.yaml,
102
+ # ignoring shell env vars (ANTHROPIC_API_KEY, OPENROUTER_API_KEY, etc.).
103
+ # Useful when shell API keys are for Claude Code (not Forge subprocesses).
104
+ auth_ignore_env: bool = False
105
+
106
+ def __post_init__(self) -> None:
107
+ valid_modes = {"host", "sidecar"}
108
+ if self.proxy_mode not in valid_modes:
109
+ raise ValueError(
110
+ f"Invalid proxy_mode: '{self.proxy_mode}' " f"(must be one of: {', '.join(sorted(valid_modes))})"
111
+ )
112
+ if self.context_limit < 1:
113
+ raise ValueError(f"context_limit must be >= 1, got {self.context_limit}")
114
+ if self.status_timeout <= 0:
115
+ raise ValueError(f"status_timeout must be > 0, got {self.status_timeout}")
116
+ if self.handoff_timeout < 1:
117
+ raise ValueError(f"handoff_timeout must be >= 1, got {self.handoff_timeout}")
118
+ valid_log_levels = {"off", "debug", "info", "warning"}
119
+ if self.log_level not in valid_log_levels:
120
+ raise ValueError(
121
+ f"Invalid log_level: '{self.log_level}' (must be one of: {', '.join(sorted(valid_log_levels))})"
122
+ )
123
+ if self.log_retention_days < 0:
124
+ raise ValueError(f"log_retention_days must be >= 0, got {self.log_retention_days}")
125
+ if self.session_retention_days < 0:
126
+ raise ValueError(f"session_retention_days must be >= 0, got {self.session_retention_days}")
127
+ valid_feedback = {"on", "off"}
128
+ if self.policy_summary_feedback not in valid_feedback:
129
+ raise ValueError(
130
+ f"Invalid policy_summary_feedback: '{self.policy_summary_feedback}' "
131
+ f"(must be one of: {', '.join(sorted(valid_feedback))})"
132
+ )
133
+
134
+
135
+ def _coerce_debug_to_log_level(raw: str) -> str:
136
+ """Coerce FORGE_DEBUG env var to a log_level string."""
137
+ low = raw.lower()
138
+ if low in ("1", "true", "yes"):
139
+ return "debug"
140
+ if low in ("0", "false", "no", "off"):
141
+ return "off"
142
+ if low in ("debug", "info", "warning"):
143
+ return low
144
+ raise ValueError(f"Cannot coerce FORGE_DEBUG={raw!r} to log level")
145
+
146
+
147
+ def _coerce_env_value(raw: str, field_info: Any) -> Any:
148
+ """Coerce a raw env var string to the field's expected Python type."""
149
+ ftype = field_info.type
150
+ if ftype is int or ftype == "int":
151
+ val = int(raw)
152
+ if val < 1:
153
+ raise ValueError(f"must be >= 1, got {val}")
154
+ return val
155
+ if ftype is float or ftype == "float":
156
+ return float(raw)
157
+ if ftype is bool or ftype == "bool":
158
+ if raw.lower() in ("1", "true", "yes"):
159
+ return True
160
+ if raw.lower() in ("0", "false", "no"):
161
+ return False
162
+ raise ValueError(f"Cannot coerce {raw!r} to bool")
163
+ return raw
164
+
165
+
166
+ def _apply_env_overrides(config: RuntimeConfig) -> RuntimeConfig:
167
+ """Apply environment variable overrides to config values.
168
+
169
+ Per-field: each env var is applied independently. If one parse fails,
170
+ others still apply (fail-open per field, not all-or-nothing).
171
+ Attaches _env_sources dict for display annotation by %config.
172
+ """
173
+ field_map = {f.name: f for f in fields(RuntimeConfig)}
174
+ overrides: dict[str, Any] = {}
175
+ env_sources: dict[str, str] = {}
176
+
177
+ for env_var, field_name in _ENV_OVERRIDES.items():
178
+ raw = os.environ.get(env_var, "").strip()
179
+ if not raw:
180
+ continue
181
+ try:
182
+ if field_name == "log_level":
183
+ coerced = _coerce_debug_to_log_level(raw)
184
+ else:
185
+ coerced = _coerce_env_value(raw, field_map[field_name])
186
+ overrides[field_name] = coerced
187
+ env_sources[field_name] = env_var
188
+ except (ValueError, TypeError) as e:
189
+ logger.warning("Ignoring env %s=%r: %s", env_var, raw, e)
190
+
191
+ if not overrides:
192
+ object.__setattr__(config, "_env_sources", {})
193
+ return config
194
+
195
+ merged = asdict(config)
196
+ merged.update(overrides)
197
+ try:
198
+ result = RuntimeConfig(**merged)
199
+ except (ValueError, TypeError) as e:
200
+ logger.warning("Env override produced invalid config: %s — ignoring overrides", e)
201
+ object.__setattr__(config, "_env_sources", {})
202
+ return config
203
+
204
+ object.__setattr__(result, "_env_sources", env_sources)
205
+ return result
206
+
207
+
208
+ # Singleton cache (must be after RuntimeConfig definition)
209
+ _config: RuntimeConfig | None = None
210
+
211
+
212
+ def get_config_path() -> Path:
213
+ """Get the path to ~/.forge/config.yaml."""
214
+ return get_forge_home() / CONFIG_FILENAME
215
+
216
+
217
+ def ensure_config() -> Path:
218
+ """Ensure the config file exists, creating with defaults if missing.
219
+
220
+ Returns the path to the config file. Idempotent — existing files
221
+ are never overwritten.
222
+ """
223
+ config_path = get_config_path()
224
+ if not config_path.is_file():
225
+ config_path.parent.mkdir(parents=True, exist_ok=True)
226
+ config_path.write_text(get_default_config_content())
227
+ os.chmod(str(config_path), 0o600)
228
+ return config_path
229
+
230
+
231
+ def load_runtime_config(path: Path | None = None) -> RuntimeConfig:
232
+ """Load runtime config from YAML file, then apply env var overrides.
233
+
234
+ Three-layer resolution: built-in defaults → config.yaml → env vars.
235
+ Fail-open: returns defaults if file is missing, unreadable, or invalid YAML.
236
+ Unknown keys are warned and ignored (forward compatibility).
237
+
238
+ Args:
239
+ path: Override config file path (for testing). Defaults to ~/.forge/config.yaml.
240
+ """
241
+ config_path = path or get_config_path()
242
+
243
+ if not config_path.is_file():
244
+ return _apply_env_overrides(RuntimeConfig())
245
+
246
+ try:
247
+ import yaml
248
+
249
+ raw = config_path.read_text(encoding="utf-8")
250
+ data = yaml.safe_load(raw)
251
+ except Exception as e:
252
+ logger.warning("Failed to read %s: %s — using defaults", config_path, e)
253
+ return _apply_env_overrides(RuntimeConfig())
254
+
255
+ if not isinstance(data, dict):
256
+ logger.warning("%s is not a YAML mapping — using defaults", config_path)
257
+ return _apply_env_overrides(RuntimeConfig())
258
+
259
+ return _apply_env_overrides(_dict_to_runtime_config(data, config_path))
260
+
261
+
262
+ def _dict_to_runtime_config(data: dict[str, Any], source: Path) -> RuntimeConfig:
263
+ """Convert a dict to RuntimeConfig, warning on unknown keys.
264
+
265
+ System boundary: user-edited config. Strict on value validation, best-effort
266
+ on unknown keys for forward compat (coding-standards.md §5, system boundaries).
267
+ """
268
+ known_fields = {f.name for f in fields(RuntimeConfig)}
269
+ unknown = set(data.keys()) - known_fields
270
+ if unknown:
271
+ logger.warning(
272
+ "Unknown keys in %s (ignored): %s",
273
+ source,
274
+ ", ".join(sorted(unknown)),
275
+ )
276
+
277
+ kwargs: dict[str, Any] = {}
278
+ for f in fields(RuntimeConfig):
279
+ if f.name in data:
280
+ val = data[f.name]
281
+ # YAML parses "off"/"on"/"yes"/"no" as booleans — coerce back
282
+ # for string fields (e.g., log_level: off → False → "off")
283
+ if isinstance(val, bool) and f.type in ("str", str):
284
+ val = "on" if val else "off"
285
+ # Coerce quoted strings to bool for bool fields
286
+ # (auth_ignore_env: "false" should be False, not truthy string)
287
+ elif isinstance(val, str) and f.type in (bool, "bool"):
288
+ low = val.strip().lower()
289
+ if low in {"1", "true", "yes", "on"}:
290
+ val = True
291
+ elif low in {"0", "false", "no", "off", ""}:
292
+ val = False
293
+ else:
294
+ logger.warning("Invalid boolean for %s: %r — using default", f.name, val)
295
+ continue
296
+ kwargs[f.name] = val
297
+
298
+ try:
299
+ return RuntimeConfig(**kwargs)
300
+ except (ValueError, TypeError) as e:
301
+ logger.warning("Invalid config in %s: %s — using defaults", source, e)
302
+ return RuntimeConfig()
303
+
304
+
305
+ def get_runtime_config() -> RuntimeConfig:
306
+ """Get cached runtime config singleton (lazy-loaded on first access)."""
307
+ global _config
308
+ if _config is None:
309
+ _config = load_runtime_config()
310
+ return _config
311
+
312
+
313
+ def get_default_direct_model() -> str | None:
314
+ """Get the configured direct-session model override, or None if unset."""
315
+ return get_runtime_config().default_direct_model.strip() or None
316
+
317
+
318
+ def reset_runtime_config() -> None:
319
+ """Reset the cached singleton (for testing)."""
320
+ global _config
321
+ _config = None
322
+
323
+
324
+ def write_runtime_config(config_data: dict[str, Any], path: Path | None = None) -> Path:
325
+ """Write runtime config to YAML file atomically.
326
+
327
+ Args:
328
+ config_data: Dict of config values to write.
329
+ path: Override path (for testing).
330
+
331
+ Returns:
332
+ Path to the written file.
333
+ """
334
+ config_path = path or get_config_path()
335
+ config_path.parent.mkdir(parents=True, exist_ok=True)
336
+
337
+ from ruamel.yaml import YAML
338
+
339
+ ruamel = YAML()
340
+ ruamel.preserve_quotes = True
341
+ ruamel.default_flow_style = False
342
+
343
+ # Atomic write: unique temp file + os.replace (matches proxy config pattern)
344
+ fd, tmp_path = tempfile.mkstemp(
345
+ dir=str(config_path.parent),
346
+ prefix=f".{config_path.stem}.",
347
+ suffix=".tmp",
348
+ )
349
+ try:
350
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
351
+ ruamel.dump(config_data, f)
352
+ os.chmod(tmp_path, 0o600)
353
+ os.replace(tmp_path, str(config_path))
354
+ except Exception:
355
+ try:
356
+ os.unlink(tmp_path)
357
+ except OSError:
358
+ pass
359
+ raise
360
+
361
+ reset_runtime_config()
362
+
363
+ return config_path
364
+
365
+
366
+ def get_default_config_content() -> str:
367
+ """Generate default config.yaml content with comments."""
368
+ return """\
369
+ # Forge Runtime Configuration
370
+ # This file is optional — Forge works with built-in defaults.
371
+ # Edit with: forge config edit
372
+ # Set values: forge config set <key>=<value>
373
+
374
+ # Proxy execution mode:
375
+ # host — proxy runs on host (default, no Docker required)
376
+ # sidecar — proxy bundled with Claude in Docker container
377
+ proxy_mode: host
378
+
379
+ # Docker image for sidecar mode
380
+ # sidecar_image: forge-sidecar:latest
381
+
382
+ # Version string for User-Agent header to upstream LLM providers
383
+ # user_agent_claude_code_version: "2.1.76"
384
+
385
+ # Optional model override for direct (non-proxy) sessions.
386
+ # Forge pins this through Claude Code's ANTHROPIC_DEFAULT_*_MODEL env vars.
387
+ # Set to "" to let Claude Code pick. Aliases like "opus" or "sonnet" also work.
388
+ # default_direct_model: claude-opus-4-6
389
+
390
+ # Fallback auto-compact window for proxy mode when model lookup fails.
391
+ # Passed as CLAUDE_CODE_AUTO_COMPACT_WINDOW to Claude Code.
392
+ # Direct sessions don't use this — Claude Code handles its own context.
393
+ # context_limit: 200000
394
+
395
+ # Status line timeout for proxy/git calls (seconds)
396
+ # status_timeout: 2.0
397
+
398
+ # Handoff agent timeout (seconds)
399
+ # handoff_timeout: 300
400
+
401
+ # File logging level: off (no file logging), debug, info, warning
402
+ # Logs written to $FORGE_HOME/logs/
403
+ # Override: FORGE_DEBUG env var (1/true/yes for debug, 0/false/no/off to disable)
404
+ # log_level: "off"
405
+
406
+ # Show Claude.ai rate limit usage in status line (direct sessions only).
407
+ # Not relevant for enterprise plans. Enable with: forge config set show_rate_limits=true
408
+ # show_rate_limits: false
409
+
410
+ # Auto-delete log files older than N days on CLI startup.
411
+ # 0 = disabled (no auto-cleanup). Example: 30 = keep last 30 days.
412
+ # Manual cleanup: forge logs --clean [--older-than DAYS]
413
+ # log_retention_days: 0
414
+
415
+ # Auto-delete sessions older than N days on CLI startup.
416
+ # 0 = disabled (no auto-cleanup). Example: 90 = keep last 90 days.
417
+ # Keeps worktrees and branches; removes manifests, index entries, and
418
+ # Claude transcripts (*.jsonl in ~/.claude/projects/).
419
+ # Forge artifact snapshots (.forge/artifacts/) are NOT removed.
420
+ # Manual cleanup: forge session clean --older-than DAYS
421
+ # session_retention_days: 0
422
+
423
+ # Policy summary feedback: show post-evaluation summary lines and additionalContext.
424
+ # "on" (default) prints what was checked and the verdict after each policy evaluation.
425
+ # "off" silences summary lines. Deny messages and substantive warnings stay visible always.
426
+ # policy_summary_feedback: "on"
427
+
428
+ # Tool failure telemetry for proxied sessions.
429
+ # Records failed tool call inputs and errors to help refine model-family prompt addendums.
430
+ # Off by default because payloads may include file paths, command text, or content snippets.
431
+ # log_tool_failures: false
432
+
433
+ # Ignore environment variables for credential resolution.
434
+ # When true, Forge reads credentials only from ~/.forge/credentials.yaml.
435
+ # Useful when your shell ANTHROPIC_API_KEY is for Claude Code (OAuth/Max),
436
+ # but you want Forge subprocesses to use a separate key from the credential file.
437
+ # auth_ignore_env: false
438
+ """
@@ -0,0 +1,55 @@
1
+ """Forge search infrastructure for transcript indexing and search.
2
+
3
+ Provides:
4
+ - search_from_index(): BM25 search using persistent precomputed index
5
+ - search(): Legacy BM25 search (builds index at query time)
6
+ - extract_document() / decompose_document(): Content extraction and decomposition
7
+ - BM25IndexStore / ContentStore / SearchDocumentStore: Per-project persistence
8
+ - tokenize(): Shared tokenizer for BM25 indexing and querying
9
+ """
10
+
11
+ from .bm25_store import BM25IndexData, BM25IndexStore
12
+ from .content_store import ContentStore
13
+ from .engine import SearchResult, search, search_from_index
14
+ from .exceptions import (
15
+ BM25IndexCorruptedError,
16
+ ContentStoreCorruptedError,
17
+ IndexStateCorruptedError,
18
+ SearchDocumentStoreCorruptedError,
19
+ SearchError,
20
+ )
21
+ from .extractor import (
22
+ SearchDocument,
23
+ SearchDocumentMeta,
24
+ decompose_document,
25
+ extract_document,
26
+ )
27
+ from .index_state import IndexState, IndexStateStore
28
+ from .store import SearchDocumentStore
29
+ from .tokenizer import tokenize
30
+
31
+ __all__ = [
32
+ # Core API
33
+ "search",
34
+ "search_from_index",
35
+ "extract_document",
36
+ "decompose_document",
37
+ "tokenize",
38
+ # Types
39
+ "SearchResult",
40
+ "SearchDocument",
41
+ "SearchDocumentMeta",
42
+ "BM25IndexData",
43
+ # Stores
44
+ "SearchDocumentStore",
45
+ "BM25IndexStore",
46
+ "ContentStore",
47
+ "IndexStateStore",
48
+ "IndexState",
49
+ # Exceptions
50
+ "SearchError",
51
+ "IndexStateCorruptedError",
52
+ "SearchDocumentStoreCorruptedError",
53
+ "BM25IndexCorruptedError",
54
+ "ContentStoreCorruptedError",
55
+ ]