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,185 @@
1
+ """Claude subprocess management for headless (-p) mode.
2
+
3
+ Provides a unified interface for running ``claude -p`` as a subprocess
4
+ with structured result handling. Used by the semantic supervisor
5
+ (``claude -p --resume``) and handoff agent (``claude -p``).
6
+
7
+ For interactive sessions (stdin/stdout inherited), use
8
+ ``forge.session.claude.invoke.invoke_claude()`` instead.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import subprocess
15
+ from dataclasses import dataclass
16
+
17
+ from forge.core.reactive.env import (
18
+ FORGE_SUBPROCESS_PROXY_VAR,
19
+ build_claude_env,
20
+ can_use_bare,
21
+ )
22
+
23
+ _log = logging.getLogger(__name__)
24
+
25
+
26
+ @dataclass
27
+ class SessionResult:
28
+ """Result from a ``claude -p`` invocation.
29
+
30
+ The runner never raises — all errors are captured in the ``error`` field.
31
+ Callers inspect ``success`` and ``error`` to decide their own fail
32
+ behavior (fail-open warnings for supervisor, return False for handoff).
33
+ """
34
+
35
+ stdout: str
36
+ stderr: str
37
+ returncode: int
38
+ timed_out: bool = False
39
+ error: str | None = None
40
+
41
+ @property
42
+ def success(self) -> bool:
43
+ """True if the subprocess completed successfully."""
44
+ return self.returncode == 0 and not self.timed_out and self.error is None
45
+
46
+
47
+ def run_claude_session(
48
+ prompt: str,
49
+ *,
50
+ resume_id: str | None = None,
51
+ fork_session: bool = False,
52
+ bare: bool | None = None,
53
+ base_url: str | None = None,
54
+ direct: bool = False,
55
+ timeout_seconds: int = 60,
56
+ cwd: str | None = None,
57
+ extra_env: dict[str, str] | None = None,
58
+ ) -> SessionResult:
59
+ """Run ``claude -p`` as a headless subprocess.
60
+
61
+ Builds the command, environment, and runs ``subprocess.run`` with
62
+ ``capture_output=True``. All exceptions are caught and reported
63
+ via ``SessionResult.error``.
64
+
65
+ Args:
66
+ prompt: Text sent to claude via stdin.
67
+ resume_id: If set, adds ``--resume <id>`` to continue a session.
68
+ fork_session: If True and resume_id is set, adds ``--fork-session``
69
+ to create an ephemeral fork instead of appending to the
70
+ original conversation.
71
+ bare: If True, adds ``--bare`` to skip hooks/LSP/plugins.
72
+ None (default) auto-detects: uses ``--bare`` only when
73
+ ANTHROPIC_API_KEY is present (``--bare`` disables OAuth).
74
+ base_url: Proxy URL (sets ANTHROPIC_BASE_URL in environment).
75
+ timeout_seconds: Maximum seconds to wait for completion.
76
+ cwd: Working directory for the subprocess.
77
+ extra_env: Additional environment variables.
78
+
79
+ Returns:
80
+ SessionResult with stdout/stderr/returncode or error details.
81
+ """
82
+ env = build_claude_env(base_url=base_url, extra_vars=extra_env, direct=direct)
83
+
84
+ use_bare = bare if bare is not None else can_use_bare(env)
85
+ cmd = ["claude", "-p"]
86
+ if use_bare:
87
+ cmd.append("--bare")
88
+ if resume_id:
89
+ cmd.extend(["--resume", resume_id])
90
+ if fork_session:
91
+ cmd.append("--fork-session")
92
+
93
+ # Guard: fail if subprocess proxy was configured but didn't resolve.
94
+ # Prevents silent fallback to direct mode (which would burn subscription quota).
95
+ subprocess_proxy = env.get(FORGE_SUBPROCESS_PROXY_VAR)
96
+ if subprocess_proxy and not base_url and not direct and not env.get("ANTHROPIC_BASE_URL"):
97
+ msg = (
98
+ f"Subprocess proxy '{subprocess_proxy}' not available. "
99
+ f"Start it with: forge proxy start {subprocess_proxy}"
100
+ )
101
+ _log.warning(msg)
102
+ return SessionResult(stdout="", stderr="", returncode=-1, error=msg)
103
+
104
+ # Guard: fail with actionable error if --bare was requested but no API key.
105
+ # Without this, the subprocess would fail with a cryptic Claude CLI error.
106
+ # Only fires when bare mode was explicitly requested (bare=True) — when
107
+ # bare=None and no key exists, can_use_bare() returns False and Claude
108
+ # falls through to OAuth (which may be intentional).
109
+ if bare and not env.get("ANTHROPIC_BASE_URL") and not env.get("ANTHROPIC_API_KEY"):
110
+ try:
111
+ from forge.core.auth.capabilities import (
112
+ CREDENTIALS,
113
+ format_missing_credential_error,
114
+ )
115
+ from forge.runtime_config import get_runtime_config
116
+
117
+ env_ignored = get_runtime_config().auth_ignore_env
118
+ cred = CREDENTIALS.get("anthropic-api")
119
+ if cred:
120
+ msg = format_missing_credential_error(
121
+ cred,
122
+ missing_vars=["ANTHROPIC_API_KEY"],
123
+ context="Forge subprocess (claude -p)",
124
+ extra_hint="Or use --subprocess-proxy to route through an existing proxy.",
125
+ env_ignored=env_ignored,
126
+ )
127
+ _log.warning(msg)
128
+ return SessionResult(stdout="", stderr="", returncode=-1, error=msg)
129
+ except Exception as e:
130
+ _log.debug("Could not format missing Anthropic subprocess credential error: %s", e)
131
+
132
+ try:
133
+ _log.debug(
134
+ "Running claude session: cmd=%s, resume=%s, cwd=%s",
135
+ cmd,
136
+ resume_id and resume_id[:16],
137
+ cwd,
138
+ )
139
+
140
+ result = subprocess.run(
141
+ cmd,
142
+ input=prompt,
143
+ capture_output=True,
144
+ text=True,
145
+ timeout=timeout_seconds,
146
+ cwd=cwd,
147
+ env=env,
148
+ )
149
+
150
+ if result.returncode != 0:
151
+ _log.warning("claude -p returned non-zero exit code: %d", result.returncode)
152
+
153
+ return SessionResult(
154
+ stdout=result.stdout,
155
+ stderr=result.stderr,
156
+ returncode=result.returncode,
157
+ )
158
+
159
+ except subprocess.TimeoutExpired:
160
+ _log.warning("claude -p timed out after %ds", timeout_seconds)
161
+ return SessionResult(
162
+ stdout="",
163
+ stderr="",
164
+ returncode=-1,
165
+ timed_out=True,
166
+ error=f"Timed out after {timeout_seconds}s",
167
+ )
168
+
169
+ except FileNotFoundError:
170
+ _log.error("claude CLI not found in PATH")
171
+ return SessionResult(
172
+ stdout="",
173
+ stderr="",
174
+ returncode=-1,
175
+ error="claude CLI not found in PATH",
176
+ )
177
+
178
+ except Exception as e:
179
+ _log.warning("claude -p failed: %s", e)
180
+ return SessionResult(
181
+ stdout="",
182
+ stderr="",
183
+ returncode=-1,
184
+ error=str(e),
185
+ )
@@ -0,0 +1,62 @@
1
+ """Structured output extraction from LLM responses.
2
+
3
+ Extracts JSON objects from LLM text responses that may contain
4
+ code fences, prose, or raw JSON. Used by verdict parsing,
5
+ workflow policy checkers, and any component that needs structured
6
+ LLM output.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import re
14
+ from typing import Any
15
+
16
+ _log = logging.getLogger(__name__)
17
+
18
+ # Patterns tried in order: ```json ... ```, then ``` ... ```
19
+ _CODE_FENCE_PATTERNS = [
20
+ re.compile(r"```json\s*\n?(.*?)\n?```", re.DOTALL | re.IGNORECASE),
21
+ re.compile(r"```\s*\n?(.*?)\n?```", re.DOTALL | re.IGNORECASE),
22
+ ]
23
+
24
+
25
+ def extract_json_from_response(response: str) -> dict[str, Any] | None:
26
+ """Extract a JSON object from an LLM response.
27
+
28
+ Tries code fences first (````` ```json ````` , then ````` ``` ````` ),
29
+ then falls back to parsing the entire response as raw JSON.
30
+ Returns the first successfully parsed JSON object.
31
+
32
+ Args:
33
+ response: Raw text response from the LLM.
34
+
35
+ Returns:
36
+ Parsed dict if extraction succeeds, None otherwise.
37
+ Callers decide their own fail behavior (fail-open, warn, etc.).
38
+ """
39
+ if not response:
40
+ return None
41
+
42
+ # Try code fences
43
+ for pattern in _CODE_FENCE_PATTERNS:
44
+ matches = pattern.findall(response)
45
+ for match in matches:
46
+ try:
47
+ data = json.loads(match.strip())
48
+ if isinstance(data, dict):
49
+ return data
50
+ except json.JSONDecodeError:
51
+ continue
52
+
53
+ # Fallback: raw JSON
54
+ try:
55
+ data = json.loads(response.strip())
56
+ if isinstance(data, dict):
57
+ return data
58
+ except json.JSONDecodeError:
59
+ pass
60
+
61
+ _log.debug("Could not extract JSON from response (len=%d)", len(response))
62
+ return None
@@ -0,0 +1,94 @@
1
+ """Cheap LLM classification via core.llm.SyncAdapter.
2
+
3
+ Classifies actions into tags using a cheap model for routing
4
+ decisions in WorkflowPolicy branches.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+
12
+ from forge.guard.types import ActionContext
13
+
14
+ _log = logging.getLogger(__name__)
15
+
16
+
17
+ def tag_action(
18
+ context: ActionContext,
19
+ *,
20
+ model: str,
21
+ prompt_template: str,
22
+ ) -> list[str]:
23
+ """Classify an action into tags via a cheap LLM call.
24
+
25
+ Uses ``core.llm.SyncAdapter`` to make a single LLM call. The prompt
26
+ template is formatted with action context fields. The response is
27
+ parsed as either a JSON array or pipe/comma-separated string.
28
+
29
+ Must NOT be called from inside an event loop (SyncAdapter constraint).
30
+
31
+ Args:
32
+ context: Action being classified.
33
+ model: Prefixed model ID (e.g., "gemini/gemini-2.0-flash").
34
+ prompt_template: Template with {tool_name}, {target_path}, {content}
35
+ placeholders.
36
+
37
+ Returns:
38
+ List of tag strings. Empty list on any error (fail-open).
39
+ """
40
+ try:
41
+ from forge.core.llm import SyncAdapter, get_client
42
+
43
+ prompt = prompt_template.format(
44
+ tool_name=context.tool_name,
45
+ target_path=context.target_path or "N/A",
46
+ content=(context.raw_diff or context.new_content or "")[:2000],
47
+ )
48
+
49
+ client = get_client(model)
50
+ adapter = SyncAdapter(client)
51
+ response = adapter.ask(prompt)
52
+
53
+ return _parse_tags(response)
54
+
55
+ except Exception as e:
56
+ _log.warning("tag_action failed (model=%s): %s", model, e)
57
+ return []
58
+
59
+
60
+ def _parse_tags(response: str) -> list[str]:
61
+ """Parse tags from an LLM response.
62
+
63
+ Tries JSON array first, then pipe-separated, then comma-separated.
64
+
65
+ Args:
66
+ response: Raw text from the LLM.
67
+
68
+ Returns:
69
+ List of stripped, non-empty tag strings.
70
+ """
71
+ if not response:
72
+ return []
73
+
74
+ text = response.strip()
75
+
76
+ # Try JSON array
77
+ try:
78
+ data = json.loads(text)
79
+ if isinstance(data, list):
80
+ return [str(t).strip() for t in data if t is not None and str(t).strip()]
81
+ except json.JSONDecodeError:
82
+ pass
83
+
84
+ # Try pipe-separated (e.g., "routine | trivial")
85
+ if "|" in text:
86
+ return [t.strip() for t in text.split("|") if t.strip()]
87
+
88
+ # Try comma-separated (e.g., "routine, trivial")
89
+ if "," in text:
90
+ return [t.strip() for t in text.split(",") if t.strip()]
91
+
92
+ # Single tag
93
+ tag = text.strip()
94
+ return [tag] if tag else []
@@ -0,0 +1,132 @@
1
+ """Generic TTL-based throttle cache.
2
+
3
+ Extracted from forge.guard.store supervisor cache functions.
4
+ Provides a reusable cache for deduplicating expensive calls
5
+ (LLM invocations, subprocess spawns) within a time window.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import logging
12
+ from datetime import datetime, timezone
13
+ from typing import Any
14
+
15
+ from forge.core.state import now_iso
16
+
17
+ _log = logging.getLogger(__name__)
18
+
19
+
20
+ def compute_cache_key(tool_name: str, file_path: str | None, content: str | None) -> str:
21
+ """Compute a cache key from action attributes.
22
+
23
+ Returns a truncated SHA256 hash of tool_name + file_path + content.
24
+
25
+ Args:
26
+ tool_name: The tool being invoked (e.g., "Write").
27
+ file_path: Target file path (may be None).
28
+ content: Content being written (may be None).
29
+
30
+ Returns:
31
+ 16-character hex string cache key.
32
+ """
33
+ parts = [tool_name, file_path or "", content or ""]
34
+ key_string = "|".join(parts)
35
+ return hashlib.sha256(key_string.encode()).hexdigest()[:16]
36
+
37
+
38
+ class ThrottleCache:
39
+ """TTL-based in-memory cache for deduplicating expensive calls.
40
+
41
+ Entries expire after ``ttl_seconds``. The cache is bounded to
42
+ ``max_entries`` (pruned in ``get_state()`` for persistence).
43
+
44
+ The cache does NOT decide *what* to cache — callers own that logic.
45
+ For example, the supervisor only caches clean allows (no warnings).
46
+
47
+ State round-trip via ``get_state()``/``set_state()`` supports
48
+ ``StatefulPolicy`` persistence across hook invocations.
49
+ """
50
+
51
+ def __init__(self, ttl_seconds: int = 30, max_entries: int = 50) -> None:
52
+ self._ttl_seconds = ttl_seconds
53
+ self._max_entries = max_entries
54
+ self._cache: dict[str, dict[str, Any]] = {}
55
+
56
+ def check(self, key: str) -> dict[str, Any] | None:
57
+ """Check if a cached entry is still valid.
58
+
59
+ Args:
60
+ key: Cache key to look up.
61
+
62
+ Returns:
63
+ Cached entry dict if valid (within TTL), None otherwise.
64
+ """
65
+ entry = self._cache.get(key)
66
+ if entry is None:
67
+ return None
68
+
69
+ checked_at = entry.get("checked_at")
70
+ if checked_at is None:
71
+ return None
72
+
73
+ try:
74
+ checked_time = datetime.fromisoformat(checked_at.replace("Z", "+00:00"))
75
+ now = datetime.now(timezone.utc)
76
+ age_seconds = (now - checked_time).total_seconds()
77
+
78
+ if age_seconds < self._ttl_seconds:
79
+ _log.debug("Cache hit for %s (age: %.1fs)", key, age_seconds)
80
+ return entry
81
+
82
+ _log.debug(
83
+ "Cache expired for %s (age: %.1fs > %ds)",
84
+ key,
85
+ age_seconds,
86
+ self._ttl_seconds,
87
+ )
88
+ return None
89
+
90
+ except (ValueError, TypeError) as e:
91
+ _log.warning("Invalid cache timestamp for %s: %s", key, e)
92
+ return None
93
+
94
+ def update(self, key: str, **values: Any) -> None:
95
+ """Add or update a cache entry.
96
+
97
+ Automatically sets ``checked_at`` to the current UTC timestamp.
98
+
99
+ Args:
100
+ key: Cache key.
101
+ **values: Arbitrary key-value pairs to store (e.g., verdict, confidence).
102
+ """
103
+ self._cache[key] = {
104
+ "checked_at": now_iso(),
105
+ **values,
106
+ }
107
+
108
+ def get_state(self) -> dict[str, Any]:
109
+ """Return cache state for persistence, pruned to max_entries most recent.
110
+
111
+ Returns a deep copy so mutations don't affect internal state.
112
+
113
+ Returns:
114
+ Flat dict of ``{key: {checked_at, ...}, ...}``.
115
+ """
116
+ cache = {k: dict(v) for k, v in self._cache.items()}
117
+ if len(cache) > self._max_entries:
118
+ sorted_keys = sorted(
119
+ cache.keys(),
120
+ key=lambda k: cache[k].get("checked_at", ""),
121
+ reverse=True,
122
+ )
123
+ cache = {k: cache[k] for k in sorted_keys[: self._max_entries]}
124
+ return cache
125
+
126
+ def set_state(self, state: dict[str, Any]) -> None:
127
+ """Restore cache state from persisted data.
128
+
129
+ Args:
130
+ state: Flat dict previously returned by ``get_state()``.
131
+ """
132
+ self._cache = dict(state) if state else {}
@@ -0,0 +1,59 @@
1
+ """Shared state utilities for Forge file-based state system.
2
+
3
+ This module provides:
4
+ - Atomic file write operations (tempfile + os.replace pattern)
5
+ - Timestamp helpers (ISO8601, UTC-only)
6
+ - Base exception hierarchy for state operations
7
+
8
+ Usage:
9
+ from forge.core.state import atomic_write_json, now_iso
10
+ from forge.core.state import StateCorruptedError, SchemaVersionError
11
+
12
+ For domain-specific state operations, use the domain modules:
13
+ from forge.session import SessionStore, IndexStore
14
+ from forge.proxy.proxies import ProxyRegistryStore
15
+ """
16
+
17
+ # IO utilities
18
+ from .io import atomic_write_json, atomic_write_text, open_secure_append, read_json
19
+
20
+ # Locking utilities
21
+ from .lock import (
22
+ FileLockTimeoutError,
23
+ file_lock,
24
+ file_lock_for_target,
25
+ get_lock_path_for_target,
26
+ )
27
+
28
+ # Timestamp utilities
29
+ from .timestamps import iso_to_timestamp, now_iso, parse_iso
30
+
31
+ # Exceptions
32
+ from .exceptions import (
33
+ SchemaVersionError,
34
+ StateCorruptedError,
35
+ StateError,
36
+ StateNotFoundError,
37
+ )
38
+
39
+ __all__ = [
40
+ # IO
41
+ "atomic_write_text",
42
+ "atomic_write_json",
43
+ "open_secure_append",
44
+ "read_json",
45
+ # Locking
46
+ "get_lock_path_for_target",
47
+ "file_lock",
48
+ "file_lock_for_target",
49
+ "FileLockTimeoutError",
50
+ # Timestamps
51
+ "now_iso",
52
+ "parse_iso",
53
+ "iso_to_timestamp",
54
+ # Exceptions
55
+ "StateError",
56
+ "StateNotFoundError",
57
+ "StateCorruptedError",
58
+ "SchemaVersionError",
59
+ ]
@@ -0,0 +1,59 @@
1
+ """Base exceptions for Forge state operations.
2
+
3
+ Domain modules (session, proxies) define their own specific exceptions
4
+ that inherit from these bases.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ class StateError(Exception):
11
+ """Base exception for all state operations."""
12
+
13
+
14
+ class StateNotFoundError(StateError):
15
+ """Raised when a state file does not exist.
16
+
17
+ Attributes:
18
+ path: Path to the missing file.
19
+ """
20
+
21
+ def __init__(self, path: str) -> None:
22
+ self.path = path
23
+ super().__init__(f"state file not found: '{path}'")
24
+
25
+
26
+ class StateCorruptedError(StateError):
27
+ """Raised when a state file cannot be parsed or has an incompatible format.
28
+
29
+ Attributes:
30
+ path: Path to the problematic file.
31
+ reason: Description of what went wrong.
32
+ """
33
+
34
+ def __init__(self, path: str, reason: str) -> None:
35
+ self.path = path
36
+ self.reason = reason
37
+ super().__init__(f"'{path}': {reason}")
38
+
39
+
40
+ class SchemaVersionError(StateCorruptedError):
41
+ """Raised when schema version is unsupported.
42
+
43
+ Attributes:
44
+ path: Path to the file.
45
+ expected: Expected version(s).
46
+ actual: Version found in file.
47
+ """
48
+
49
+ def __init__(self, path: str, expected: int | set[int], actual: int) -> None:
50
+ self.expected = expected if isinstance(expected, set) else {expected}
51
+ self.actual = actual
52
+ self.path = path
53
+ self.reason = f"incompatible version {actual} (expected {sorted(self.expected)})"
54
+ Exception.__init__(
55
+ self,
56
+ f"'{path}' has incompatible version {actual} "
57
+ f"(this Forge expects {sorted(self.expected)}). "
58
+ f"Delete this file and retry.",
59
+ )