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.
- forge/__init__.py +3 -0
- forge/_extensions/agents/.gitkeep +0 -0
- forge/_extensions/commands/.gitkeep +0 -0
- forge/_extensions/skills/analyze/SKILL.md +87 -0
- forge/_extensions/skills/challenge/SKILL.md +91 -0
- forge/_extensions/skills/consensus/SKILL.md +120 -0
- forge/_extensions/skills/consensus/resources/code_consensus_evaluation.md +94 -0
- forge/_extensions/skills/consensus/resources/consensus_evaluation.md +70 -0
- forge/_extensions/skills/consensus/resources/synthesis.md +101 -0
- forge/_extensions/skills/debate/SKILL.md +116 -0
- forge/_extensions/skills/debate/resources/code_debate_evaluation.md +101 -0
- forge/_extensions/skills/debate/resources/debate_evaluation.md +90 -0
- forge/_extensions/skills/panel/SKILL.md +141 -0
- forge/_extensions/skills/panel/resources/synthesis.md +103 -0
- forge/_extensions/skills/qa/SKILL.md +704 -0
- forge/_extensions/skills/qa/resources/checklist/0-enable.md +78 -0
- forge/_extensions/skills/qa/resources/checklist/1-preflight.md +24 -0
- forge/_extensions/skills/qa/resources/checklist/10-resume.md +143 -0
- forge/_extensions/skills/qa/resources/checklist/11-config.md +150 -0
- forge/_extensions/skills/qa/resources/checklist/12-search.md +58 -0
- forge/_extensions/skills/qa/resources/checklist/13-guard.md +237 -0
- forge/_extensions/skills/qa/resources/checklist/14-workflow.md +305 -0
- forge/_extensions/skills/qa/resources/checklist/15-skills.md +155 -0
- forge/_extensions/skills/qa/resources/checklist/16-handoff.md +224 -0
- forge/_extensions/skills/qa/resources/checklist/17-info.md +50 -0
- forge/_extensions/skills/qa/resources/checklist/18-disable.md +84 -0
- forge/_extensions/skills/qa/resources/checklist/19-uninstall.md +146 -0
- forge/_extensions/skills/qa/resources/checklist/2-extensions.md +188 -0
- forge/_extensions/skills/qa/resources/checklist/20-cleanup.md +36 -0
- forge/_extensions/skills/qa/resources/checklist/3-auth.md +234 -0
- forge/_extensions/skills/qa/resources/checklist/4-proxy.md +481 -0
- forge/_extensions/skills/qa/resources/checklist/5-session.md +541 -0
- forge/_extensions/skills/qa/resources/checklist/6-hooks.md +275 -0
- forge/_extensions/skills/qa/resources/checklist/7-costs.md +309 -0
- forge/_extensions/skills/qa/resources/checklist/8-status-line.md +174 -0
- forge/_extensions/skills/qa/resources/checklist/9-direct-commands.md +146 -0
- forge/_extensions/skills/qa/resources/checklist.md +103 -0
- forge/_extensions/skills/qa/resources/report-template.md +62 -0
- forge/_extensions/skills/qa/scripts/start-container.sh +529 -0
- forge/_extensions/skills/qa/scripts/walkthrough-state.py +1137 -0
- forge/_extensions/skills/review/SKILL.md +125 -0
- forge/_extensions/skills/review/references/claude-4.6.md +474 -0
- forge/_extensions/skills/review/references/claude-4.7.md +710 -0
- forge/_extensions/skills/review/references/gemini-3.1.md +546 -0
- forge/_extensions/skills/review/references/gpt-5.5.md +490 -0
- forge/_extensions/skills/review/references/skills-writing-guide.md +1588 -0
- forge/_extensions/skills/review/resources/code-anthropic.md +160 -0
- forge/_extensions/skills/review/resources/code-gemini.md +184 -0
- forge/_extensions/skills/review/resources/code-openai.md +203 -0
- forge/_extensions/skills/review/resources/code.md +160 -0
- forge/_extensions/skills/review-docs/SKILL.md +121 -0
- forge/_extensions/skills/review-docs/resources/docs-anthropic.md +170 -0
- forge/_extensions/skills/review-docs/resources/docs-gemini.md +204 -0
- forge/_extensions/skills/review-docs/resources/docs-openai.md +231 -0
- forge/_extensions/skills/review-docs/resources/docs.md +170 -0
- forge/_extensions/skills/smoke-test/SKILL.md +27 -0
- forge/_extensions/skills/smoke-test/scripts/smoke-test.sh +118 -0
- forge/_extensions/skills/understand/SKILL.md +148 -0
- forge/_extensions/skills/understand/resources/code-anthropic.md +163 -0
- forge/_extensions/skills/understand/resources/code-gemini.md +194 -0
- forge/_extensions/skills/understand/resources/code-openai.md +181 -0
- forge/_extensions/skills/understand/resources/code.md +163 -0
- forge/_extensions/skills/understand/resources/docs-anthropic.md +177 -0
- forge/_extensions/skills/understand/resources/docs-gemini.md +202 -0
- forge/_extensions/skills/understand/resources/docs-openai.md +191 -0
- forge/_extensions/skills/understand/resources/docs.md +177 -0
- forge/_extensions/skills/walkthrough/SKILL.md +599 -0
- forge/_extensions/skills/walkthrough/resources/checklist.md +765 -0
- forge/_extensions/skills/walkthrough/scripts/run-in-repo.sh +118 -0
- forge/_extensions/skills/walkthrough/scripts/setup-test-repo.sh +198 -0
- forge/_extensions/skills/walkthrough/scripts/walkthrough-state.py +1137 -0
- forge/backend/__init__.py +174 -0
- forge/backend/adapters/__init__.py +38 -0
- forge/backend/adapters/litellm.py +158 -0
- forge/backend/creation.py +89 -0
- forge/backend/registry.py +178 -0
- forge/cli/__init__.py +16 -0
- forge/cli/auth.py +483 -0
- forge/cli/backend.py +298 -0
- forge/cli/claude.py +411 -0
- forge/cli/config_cmd.py +303 -0
- forge/cli/extensions.py +1001 -0
- forge/cli/gc.py +165 -0
- forge/cli/guard.py +1018 -0
- forge/cli/guards.py +106 -0
- forge/cli/handoff.py +110 -0
- forge/cli/hooks/__init__.py +36 -0
- forge/cli/hooks/_group.py +20 -0
- forge/cli/hooks/_helpers.py +149 -0
- forge/cli/hooks/commands.py +1677 -0
- forge/cli/hooks/direct_commands.py +1304 -0
- forge/cli/hooks/install.py +232 -0
- forge/cli/hooks/policy.py +151 -0
- forge/cli/hooks/read_hygiene.py +74 -0
- forge/cli/hooks/verification.py +370 -0
- forge/cli/logs.py +406 -0
- forge/cli/main.py +292 -0
- forge/cli/proxy.py +1821 -0
- forge/cli/proxy_costs.py +313 -0
- forge/cli/search.py +416 -0
- forge/cli/session.py +892 -0
- forge/cli/session_addendum.py +81 -0
- forge/cli/session_fork.py +750 -0
- forge/cli/session_handoff.py +141 -0
- forge/cli/session_lifecycle.py +2053 -0
- forge/cli/session_manage.py +1336 -0
- forge/cli/session_memory.py +201 -0
- forge/cli/status_line.py +1398 -0
- forge/cli/workflow.py +1964 -0
- forge/config/__init__.py +110 -0
- forge/config/dataclass_utils.py +88 -0
- forge/config/defaults/__init__.py +0 -0
- forge/config/defaults/backends/__init__.py +0 -0
- forge/config/defaults/backends/litellm.yaml +196 -0
- forge/config/defaults/templates/__init__.py +0 -0
- forge/config/defaults/templates/litellm-anthropic-local.yaml +33 -0
- forge/config/defaults/templates/litellm-anthropic.yaml +24 -0
- forge/config/defaults/templates/litellm-gemini-flash-local.yaml +37 -0
- forge/config/defaults/templates/litellm-gemini-local.yaml +32 -0
- forge/config/defaults/templates/litellm-gemini-test.yaml +34 -0
- forge/config/defaults/templates/litellm-gemini.yaml +21 -0
- forge/config/defaults/templates/litellm-openai-codex-local.yaml +36 -0
- forge/config/defaults/templates/litellm-openai-local.yaml +38 -0
- forge/config/defaults/templates/litellm-openai.yaml +28 -0
- forge/config/defaults/templates/openrouter-anthropic.yaml +23 -0
- forge/config/defaults/templates/openrouter-deepseek.yaml +26 -0
- forge/config/defaults/templates/openrouter-gemini-flash.yaml +26 -0
- forge/config/defaults/templates/openrouter-gemini.yaml +23 -0
- forge/config/defaults/templates/openrouter-glm.yaml +23 -0
- forge/config/defaults/templates/openrouter-kimi.yaml +30 -0
- forge/config/defaults/templates/openrouter-minimax.yaml +26 -0
- forge/config/defaults/templates/openrouter-openai-codex.yaml +23 -0
- forge/config/defaults/templates/openrouter-openai.yaml +28 -0
- forge/config/defaults/templates/openrouter-qwen.yaml +25 -0
- forge/config/loader.py +675 -0
- forge/config/schema.py +448 -0
- forge/core/__init__.py +5 -0
- forge/core/auth/__init__.py +67 -0
- forge/core/auth/capabilities.py +219 -0
- forge/core/auth/credentials_file.py +244 -0
- forge/core/auth/protocols.py +18 -0
- forge/core/auth/secrets.py +243 -0
- forge/core/auth/template_secrets.py +112 -0
- forge/core/data/__init__.py +5 -0
- forge/core/data/model_catalog.yaml +1522 -0
- forge/core/data/pricing.yaml +140 -0
- forge/core/data/system_prompt_addendums/__init__.py +0 -0
- forge/core/data/system_prompt_addendums/gemini.md +330 -0
- forge/core/data/system_prompt_addendums/openai.md +328 -0
- forge/core/llm/__init__.py +231 -0
- forge/core/llm/clients/__init__.py +14 -0
- forge/core/llm/clients/base.py +115 -0
- forge/core/llm/clients/litellm.py +619 -0
- forge/core/llm/clients/openai_compat.py +244 -0
- forge/core/llm/clients/openrouter.py +234 -0
- forge/core/llm/credentials.py +439 -0
- forge/core/llm/detection.py +86 -0
- forge/core/llm/errors.py +44 -0
- forge/core/llm/protocols.py +80 -0
- forge/core/llm/types.py +176 -0
- forge/core/logging.py +146 -0
- forge/core/models/__init__.py +91 -0
- forge/core/models/catalog.py +467 -0
- forge/core/models/pricing.py +165 -0
- forge/core/models/types.py +167 -0
- forge/core/naming.py +212 -0
- forge/core/ops/__init__.py +73 -0
- forge/core/ops/context.py +141 -0
- forge/core/ops/gc.py +802 -0
- forge/core/ops/proxy.py +146 -0
- forge/core/ops/resolution.py +135 -0
- forge/core/ops/session.py +344 -0
- forge/core/ops/session_context.py +548 -0
- forge/core/paths.py +38 -0
- forge/core/process.py +54 -0
- forge/core/reactive/__init__.py +38 -0
- forge/core/reactive/cost_tracking.py +300 -0
- forge/core/reactive/env.py +180 -0
- forge/core/reactive/proxy.py +78 -0
- forge/core/reactive/routing.py +622 -0
- forge/core/reactive/session_runner.py +185 -0
- forge/core/reactive/structured_output.py +62 -0
- forge/core/reactive/tagger.py +94 -0
- forge/core/reactive/throttle.py +132 -0
- forge/core/state/__init__.py +59 -0
- forge/core/state/exceptions.py +59 -0
- forge/core/state/io.py +140 -0
- forge/core/state/lock.py +99 -0
- forge/core/state/timestamps.py +60 -0
- forge/core/transcript.py +78 -0
- forge/core/typing_helpers.py +24 -0
- forge/core/workqueue/__init__.py +67 -0
- forge/core/workqueue/queue.py +552 -0
- forge/core/workqueue/types.py +63 -0
- forge/guard/__init__.py +26 -0
- forge/guard/deterministic/__init__.py +26 -0
- forge/guard/deterministic/base.py +158 -0
- forge/guard/deterministic/coding_standards.py +256 -0
- forge/guard/deterministic/registry.py +148 -0
- forge/guard/deterministic/tdd.py +171 -0
- forge/guard/engine.py +216 -0
- forge/guard/protocols.py +91 -0
- forge/guard/queries.py +96 -0
- forge/guard/semantic/__init__.py +34 -0
- forge/guard/semantic/promotion.py +18 -0
- forge/guard/semantic/supervisor.py +813 -0
- forge/guard/semantic/verdict.py +183 -0
- forge/guard/store.py +124 -0
- forge/guard/team/__init__.py +6 -0
- forge/guard/team/config.py +24 -0
- forge/guard/team/handlers.py +209 -0
- forge/guard/team/prompts.py +41 -0
- forge/guard/types.py +125 -0
- forge/guard/workflow/__init__.py +17 -0
- forge/guard/workflow/branches.py +67 -0
- forge/guard/workflow/config.py +63 -0
- forge/guard/workflow/divergence.py +113 -0
- forge/guard/workflow/policy.py +87 -0
- forge/guard/workflow/stages.py +205 -0
- forge/install/__init__.py +55 -0
- forge/install/cli.py +281 -0
- forge/install/exceptions.py +163 -0
- forge/install/hooks.py +109 -0
- forge/install/installer.py +1037 -0
- forge/install/models.py +321 -0
- forge/install/preset.py +272 -0
- forge/install/settings_merge.py +831 -0
- forge/install/tracking.py +238 -0
- forge/install/version.py +141 -0
- forge/proxy/__init__.py +0 -0
- forge/proxy/base_client.py +181 -0
- forge/proxy/client_adapter.py +476 -0
- forge/proxy/client_factory.py +531 -0
- forge/proxy/converters.py +1206 -0
- forge/proxy/cost_logger.py +132 -0
- forge/proxy/cost_tracker.py +242 -0
- forge/proxy/data_models.py +338 -0
- forge/proxy/error_hints.py +92 -0
- forge/proxy/metrics.py +222 -0
- forge/proxy/model_spec.py +158 -0
- forge/proxy/proxies.py +333 -0
- forge/proxy/proxy_identity.py +134 -0
- forge/proxy/proxy_orchestrator.py +1018 -0
- forge/proxy/proxy_startup.py +54 -0
- forge/proxy/server.py +1561 -0
- forge/proxy/utils.py +537 -0
- forge/review/__init__.py +6 -0
- forge/review/adversarial.py +111 -0
- forge/review/consensus.py +236 -0
- forge/review/engine.py +356 -0
- forge/review/models.py +437 -0
- forge/review/resources/__init__.py +5 -0
- forge/review/resources/codereview-performance.md +85 -0
- forge/review/resources/codereview-quick.md +75 -0
- forge/review/resources/codereview-security.md +92 -0
- forge/review/resources/codereview.md +85 -0
- forge/review/resources/docreview-quick.md +75 -0
- forge/review/resources/docreview.md +86 -0
- forge/review/resources/thinkdeep.md +89 -0
- forge/review/routing.py +368 -0
- forge/review/synthesis.py +73 -0
- forge/runtime_config.py +438 -0
- forge/search/__init__.py +55 -0
- forge/search/bm25_store.py +264 -0
- forge/search/content_store.py +197 -0
- forge/search/engine.py +352 -0
- forge/search/exceptions.py +51 -0
- forge/search/extractor.py +234 -0
- forge/search/index_state.py +295 -0
- forge/search/store.py +215 -0
- forge/search/tokenizer.py +24 -0
- forge/session/__init__.py +130 -0
- forge/session/active.py +339 -0
- forge/session/artifacts.py +202 -0
- forge/session/claude/__init__.py +50 -0
- forge/session/claude/cleanup.py +105 -0
- forge/session/claude/invoke.py +236 -0
- forge/session/claude/paths.py +200 -0
- forge/session/cleanup.py +216 -0
- forge/session/config.py +34 -0
- forge/session/direct_model.py +107 -0
- forge/session/effective.py +169 -0
- forge/session/exceptions.py +255 -0
- forge/session/handoff.py +881 -0
- forge/session/handoff_agent.py +544 -0
- forge/session/hooks/__init__.py +35 -0
- forge/session/hooks/models.py +73 -0
- forge/session/hooks/session_start.py +507 -0
- forge/session/identity.py +84 -0
- forge/session/index.py +553 -0
- forge/session/manager.py +1506 -0
- forge/session/models.py +572 -0
- forge/session/overrides.py +344 -0
- forge/session/plan_resolution.py +286 -0
- forge/session/prev_sessions.py +128 -0
- forge/session/store.py +431 -0
- forge/session/validation.py +47 -0
- forge/session/worktree/__init__.py +65 -0
- forge/session/worktree/cleanup.py +262 -0
- forge/session/worktree/config_copy.py +203 -0
- forge/session/worktree/create.py +332 -0
- forge/sidecar/__init__.py +29 -0
- forge/sidecar/container.py +161 -0
- forge/sidecar/docker.py +86 -0
- forge/sidecar/secrets.py +19 -0
- multi_forge-0.2.0.dist-info/METADATA +242 -0
- multi_forge-0.2.0.dist-info/RECORD +311 -0
- multi_forge-0.2.0.dist-info/WHEEL +4 -0
- multi_forge-0.2.0.dist-info/entry_points.txt +2 -0
- multi_forge-0.2.0.dist-info/licenses/LICENSE +203 -0
- 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
|
+
)
|