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,813 @@
|
|
|
1
|
+
"""Semantic supervisor invocation.
|
|
2
|
+
|
|
3
|
+
The supervisor is an LLM session that validates executor actions against
|
|
4
|
+
an approved plan. It uses `claude -p --resume <session_id> --fork-session`
|
|
5
|
+
to fork the planning session without polluting its conversation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Literal, cast
|
|
15
|
+
|
|
16
|
+
from forge.core.reactive.routing import resolve_subprocess_routing
|
|
17
|
+
from forge.core.reactive.session_runner import run_claude_session
|
|
18
|
+
from forge.core.reactive.throttle import ThrottleCache, compute_cache_key
|
|
19
|
+
from forge.guard.deterministic.base import DeterministicPolicy
|
|
20
|
+
from forge.guard.semantic.verdict import (
|
|
21
|
+
SupervisorVerdict,
|
|
22
|
+
parse_supervisor_verdict,
|
|
23
|
+
verdict_to_decision,
|
|
24
|
+
)
|
|
25
|
+
from forge.guard.types import ActionContext, PolicyDecision
|
|
26
|
+
from forge.session.manager import SessionManager
|
|
27
|
+
from forge.session.models import PolicyIntent, SessionState, SupervisorConfig
|
|
28
|
+
|
|
29
|
+
_log = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
_UUID_PATTERN = re.compile(
|
|
32
|
+
r"^[0-9a-fA-F]{8}-" r"[0-9a-fA-F]{4}-" r"[0-9a-fA-F]{4}-" r"[0-9a-fA-F]{4}-" r"[0-9a-fA-F]{12}$"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
SUPERVISOR_INTENT = (
|
|
36
|
+
"Ensure implementation stays aligned with the approved plan. The supervisor "
|
|
37
|
+
"checks that code changes match what was agreed upon, catching drift before "
|
|
38
|
+
"it compounds."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Supervisor prompt template
|
|
42
|
+
SUPERVISOR_PROMPT = """You are a code alignment supervisor. Evaluate whether this action aligns with the approved plan.
|
|
43
|
+
|
|
44
|
+
## Action Being Evaluated
|
|
45
|
+
Tool: {tool_name}
|
|
46
|
+
Target: {target_path}
|
|
47
|
+
Content/Diff (truncated):
|
|
48
|
+
```
|
|
49
|
+
{content}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Instructions
|
|
53
|
+
1. Compare this action against the approved plan in your context
|
|
54
|
+
2. Determine if the action is ALIGNED or DIVERGENT
|
|
55
|
+
3. If divergent, cite the specific plan section being violated
|
|
56
|
+
4. Express your confidence level (0.0-1.0)
|
|
57
|
+
|
|
58
|
+
## Response Format
|
|
59
|
+
Respond with JSON in a code fence:
|
|
60
|
+
```json
|
|
61
|
+
{{
|
|
62
|
+
"verdict": "aligned" | "divergent",
|
|
63
|
+
"confidence": 0.95,
|
|
64
|
+
"violations": [
|
|
65
|
+
{{
|
|
66
|
+
"severity": "high",
|
|
67
|
+
"evidence": "what was done that violates the plan",
|
|
68
|
+
"suggested_fix": "what should be done instead",
|
|
69
|
+
"citations": ["quoted plan section that was violated"]
|
|
70
|
+
}}
|
|
71
|
+
]
|
|
72
|
+
}}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
If the action aligns with the plan, use an empty violations array:
|
|
76
|
+
```json
|
|
77
|
+
{{
|
|
78
|
+
"verdict": "aligned",
|
|
79
|
+
"confidence": 0.9,
|
|
80
|
+
"violations": []
|
|
81
|
+
}}
|
|
82
|
+
```
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
_PLAN_OVERRIDE_PREAMBLE = """## Updated Plan (supersedes earlier plan in conversation context)
|
|
86
|
+
|
|
87
|
+
The following plan is MORE RECENT than any plan discussed earlier in this conversation.
|
|
88
|
+
Use THIS plan as the authoritative reference for alignment checking. If there are
|
|
89
|
+
conflicts between this plan and earlier conversation context, THIS plan takes precedence.
|
|
90
|
+
|
|
91
|
+
{plan_content}
|
|
92
|
+
|
|
93
|
+
---"""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _plan_fingerprint(path: str, forge_root: str | None) -> str:
|
|
97
|
+
"""Return a cheap fingerprint for cache key differentiation: path:mtime_ns:size."""
|
|
98
|
+
resolved = Path(path)
|
|
99
|
+
if not resolved.is_absolute() and forge_root:
|
|
100
|
+
resolved = Path(forge_root) / resolved
|
|
101
|
+
try:
|
|
102
|
+
st = resolved.stat()
|
|
103
|
+
return f"{resolved}:{st.st_mtime_ns}:{st.st_size}"
|
|
104
|
+
except OSError:
|
|
105
|
+
return f"{path}:missing"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _load_plan_override(config: SupervisorConfig) -> str | None:
|
|
109
|
+
"""Read the plan override file from disk. Returns None if not set, missing, or empty."""
|
|
110
|
+
if not config.plan_override_path:
|
|
111
|
+
return None
|
|
112
|
+
try:
|
|
113
|
+
resolved = Path(config.plan_override_path)
|
|
114
|
+
if not resolved.is_absolute() and config.forge_root:
|
|
115
|
+
resolved = Path(config.forge_root) / resolved
|
|
116
|
+
if not resolved.is_file():
|
|
117
|
+
_log.warning("Supervisor plan_override_path file not found: %s", resolved)
|
|
118
|
+
return None
|
|
119
|
+
content = resolved.read_text(encoding="utf-8").strip()
|
|
120
|
+
if not content:
|
|
121
|
+
_log.warning("Supervisor plan_override_path file is empty: %s", resolved)
|
|
122
|
+
return None
|
|
123
|
+
return content
|
|
124
|
+
except Exception as e:
|
|
125
|
+
_log.warning("Failed to read supervisor plan_override_path: %s", e)
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class SemanticSupervisorPolicy(DeterministicPolicy):
|
|
130
|
+
"""Semantic policy that invokes an LLM supervisor to validate actions.
|
|
131
|
+
|
|
132
|
+
Implements StatefulPolicy to manage the supervisor cache via ThrottleCache.
|
|
133
|
+
Cached verdicts are reused within the throttle window to avoid
|
|
134
|
+
excessive LLM calls.
|
|
135
|
+
|
|
136
|
+
State tracked:
|
|
137
|
+
- cache: ThrottleCache entries {cache_key: {checked_at, verdict, confidence}}
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
def __init__(self, config: SupervisorConfig | None = None) -> None:
|
|
141
|
+
self._config = config
|
|
142
|
+
ttl = config.throttle_seconds if config else 30
|
|
143
|
+
self._cache = ThrottleCache(ttl_seconds=ttl)
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def policy_id(self) -> str:
|
|
147
|
+
return "semantic.supervisor"
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def description(self) -> str:
|
|
151
|
+
return "Validate actions against approved plan via LLM supervisor"
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def intent(self) -> str:
|
|
155
|
+
return SUPERVISOR_INTENT
|
|
156
|
+
|
|
157
|
+
def applies_to(self, context: ActionContext) -> bool:
|
|
158
|
+
"""Apply to Write/Edit when supervisor is configured and not suspended."""
|
|
159
|
+
if context.tool_name not in ("Write", "Edit"):
|
|
160
|
+
return False
|
|
161
|
+
if self._config is None or self._config.resume_id is None:
|
|
162
|
+
return False
|
|
163
|
+
return not self._config.suspended
|
|
164
|
+
|
|
165
|
+
def _evaluate(self, context: ActionContext) -> PolicyDecision:
|
|
166
|
+
"""Evaluate action via supervisor (with caching)."""
|
|
167
|
+
if not self._config or not self._config.resume_id:
|
|
168
|
+
return PolicyDecision(
|
|
169
|
+
decision="allow",
|
|
170
|
+
policy_id=self.policy_id,
|
|
171
|
+
warnings=["Supervisor not configured"],
|
|
172
|
+
)
|
|
173
|
+
if self._config.suspended:
|
|
174
|
+
return PolicyDecision(decision="allow", policy_id=self.policy_id)
|
|
175
|
+
|
|
176
|
+
# Check cache
|
|
177
|
+
cache_key = compute_cache_key(
|
|
178
|
+
context.tool_name,
|
|
179
|
+
context.target_path,
|
|
180
|
+
context.new_content,
|
|
181
|
+
)
|
|
182
|
+
if self._config.plan_override_path:
|
|
183
|
+
cache_key = (
|
|
184
|
+
cache_key + "|plan:" + _plan_fingerprint(self._config.plan_override_path, self._config.forge_root)
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
cached = self._cache.check(cache_key)
|
|
188
|
+
if cached is not None:
|
|
189
|
+
_log.debug("Using cached supervisor verdict for %s", cache_key)
|
|
190
|
+
cached_verdict = cached.get("verdict", "aligned")
|
|
191
|
+
if cached_verdict not in ("aligned", "divergent"):
|
|
192
|
+
cached_verdict = "aligned"
|
|
193
|
+
verdict = SupervisorVerdict(
|
|
194
|
+
verdict=cast(Literal["aligned", "divergent"], cached_verdict),
|
|
195
|
+
confidence=cached.get("confidence", 1.0),
|
|
196
|
+
)
|
|
197
|
+
decision = verdict_to_decision(verdict, intent=self.intent)
|
|
198
|
+
decision.cached = True
|
|
199
|
+
return decision
|
|
200
|
+
|
|
201
|
+
# Invoke supervisor
|
|
202
|
+
decision = invoke_supervisor(self._config, context)
|
|
203
|
+
|
|
204
|
+
# Attach intent to deny decisions
|
|
205
|
+
if decision.decision == "deny":
|
|
206
|
+
decision.intent = self.intent
|
|
207
|
+
|
|
208
|
+
# Only cache genuinely clean allows. Warns, allow-with-warnings
|
|
209
|
+
# (timeout/failure), and denials are NOT cached so they re-evaluate
|
|
210
|
+
# on the next check.
|
|
211
|
+
if decision.decision == "allow" and not decision.warnings:
|
|
212
|
+
self._cache.update(cache_key, verdict="aligned", confidence=1.0)
|
|
213
|
+
|
|
214
|
+
return decision
|
|
215
|
+
|
|
216
|
+
def get_state(self) -> dict[str, Any]:
|
|
217
|
+
"""Return cache state for persistence."""
|
|
218
|
+
return {"cache": self._cache.get_state()}
|
|
219
|
+
|
|
220
|
+
def set_state(self, state: dict[str, Any]) -> None:
|
|
221
|
+
"""Restore cache state from persistence."""
|
|
222
|
+
self._cache.set_state(state.get("cache", {}))
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@dataclass
|
|
226
|
+
class _ResolvedTarget:
|
|
227
|
+
"""Result of resolving a supervisor resume target."""
|
|
228
|
+
|
|
229
|
+
resume_id: str | None = None
|
|
230
|
+
source_cwd: str | None = None # Worktree path of source session (for cross-CWD resolution)
|
|
231
|
+
warning: str | None = None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _latest_transcript_artifact_session_id(state: SessionState) -> str | None:
|
|
235
|
+
"""Return newest transcript artifact UUID, tolerating legacy/raw artifact shapes."""
|
|
236
|
+
artifacts = state.confirmed.artifacts
|
|
237
|
+
if not isinstance(artifacts, dict):
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
transcripts = artifacts.get("transcripts")
|
|
241
|
+
if not isinstance(transcripts, list):
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
for artifact in reversed(transcripts):
|
|
245
|
+
if not isinstance(artifact, dict):
|
|
246
|
+
continue
|
|
247
|
+
session_id = artifact.get("session_id")
|
|
248
|
+
if isinstance(session_id, str) and session_id:
|
|
249
|
+
return session_id
|
|
250
|
+
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _raw_claude_transcript_exists(state: SessionState, session_uuid: str) -> bool:
|
|
255
|
+
"""Return whether Claude can likely resume the given raw UUID."""
|
|
256
|
+
from forge.session.claude.paths import (
|
|
257
|
+
get_transcript_path,
|
|
258
|
+
resolve_claude_project_root,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
roots: list[str] = []
|
|
262
|
+
if isinstance(state.confirmed.claude_project_root, str) and state.confirmed.claude_project_root:
|
|
263
|
+
roots.append(state.confirmed.claude_project_root)
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
resolved = resolve_claude_project_root(state)
|
|
267
|
+
if resolved not in roots:
|
|
268
|
+
roots.append(resolved)
|
|
269
|
+
except Exception:
|
|
270
|
+
pass
|
|
271
|
+
|
|
272
|
+
for root in roots:
|
|
273
|
+
try:
|
|
274
|
+
if get_transcript_path(root, session_uuid).is_file():
|
|
275
|
+
return True
|
|
276
|
+
except Exception:
|
|
277
|
+
continue
|
|
278
|
+
|
|
279
|
+
return False
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _parent_uuid_for_fork_target(
|
|
283
|
+
mgr: "SessionManager", state: SessionState, fallback_forge_root: str | None
|
|
284
|
+
) -> str | None:
|
|
285
|
+
"""Return a fork target's parent UUID when it can be resolved."""
|
|
286
|
+
if state.is_fork is not True or not isinstance(state.parent_session, str) or not state.parent_session:
|
|
287
|
+
return None
|
|
288
|
+
|
|
289
|
+
parent_forge_root = fallback_forge_root
|
|
290
|
+
derivation = state.confirmed.derivation
|
|
291
|
+
if derivation and isinstance(derivation.parent_forge_root, str) and derivation.parent_forge_root:
|
|
292
|
+
parent_forge_root = derivation.parent_forge_root
|
|
293
|
+
elif isinstance(state.forge_root, str) and state.forge_root:
|
|
294
|
+
parent_forge_root = state.forge_root
|
|
295
|
+
|
|
296
|
+
try:
|
|
297
|
+
parent_state = mgr.get_session(state.parent_session, forge_root=parent_forge_root)
|
|
298
|
+
except Exception:
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
parent_uuid = parent_state.confirmed.claude_session_id
|
|
302
|
+
return parent_uuid if isinstance(parent_uuid, str) and parent_uuid else None
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _resolve_resume_target(resume_target: str, forge_root: str | None = None) -> _ResolvedTarget:
|
|
306
|
+
"""Resolve a supervisor resume target to a Claude UUID and source CWD.
|
|
307
|
+
|
|
308
|
+
Accepts raw Claude UUIDs as-is. If the value looks like a Forge session name,
|
|
309
|
+
resolve it through the session index and return that session's confirmed
|
|
310
|
+
Claude UUID plus its worktree path (needed for cross-CWD supervisor
|
|
311
|
+
invocations -- Claude Code scopes --resume to the project CWD).
|
|
312
|
+
"""
|
|
313
|
+
target = resume_target.strip()
|
|
314
|
+
if not target:
|
|
315
|
+
return _ResolvedTarget(warning="Supervisor not configured (no resume_id)")
|
|
316
|
+
|
|
317
|
+
if _UUID_PATTERN.fullmatch(target):
|
|
318
|
+
return _ResolvedTarget(resume_id=target)
|
|
319
|
+
|
|
320
|
+
try:
|
|
321
|
+
mgr = SessionManager()
|
|
322
|
+
state = mgr.get_session(target, forge_root=forge_root)
|
|
323
|
+
except Exception:
|
|
324
|
+
return _ResolvedTarget(resume_id=target)
|
|
325
|
+
|
|
326
|
+
session_uuid = state.confirmed.claude_session_id
|
|
327
|
+
if not session_uuid:
|
|
328
|
+
return _ResolvedTarget(
|
|
329
|
+
warning=f"Supervisor error: Forge session '{target}' has no confirmed Claude session ID, failing open"
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
from forge.session.claude.paths import resolve_claude_project_root
|
|
333
|
+
|
|
334
|
+
source_cwd = resolve_claude_project_root(state)
|
|
335
|
+
|
|
336
|
+
latest_artifact_uuid = _latest_transcript_artifact_session_id(state)
|
|
337
|
+
if latest_artifact_uuid and latest_artifact_uuid != session_uuid:
|
|
338
|
+
if _raw_claude_transcript_exists(state, latest_artifact_uuid):
|
|
339
|
+
_log.warning(
|
|
340
|
+
"Supervisor target '%s' had stale manifest UUID %s...; using latest transcript UUID %s...",
|
|
341
|
+
target,
|
|
342
|
+
session_uuid[:8],
|
|
343
|
+
latest_artifact_uuid[:8],
|
|
344
|
+
)
|
|
345
|
+
session_uuid = latest_artifact_uuid
|
|
346
|
+
else:
|
|
347
|
+
return _ResolvedTarget(
|
|
348
|
+
warning=(
|
|
349
|
+
f"Supervisor error: Forge session '{target}' has inconsistent Claude UUID state "
|
|
350
|
+
f"(manifest {session_uuid[:8]}..., latest transcript {latest_artifact_uuid[:8]}...), failing open"
|
|
351
|
+
)
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
parent_uuid = _parent_uuid_for_fork_target(mgr, state, forge_root)
|
|
355
|
+
if parent_uuid and parent_uuid == session_uuid:
|
|
356
|
+
return _ResolvedTarget(
|
|
357
|
+
warning=(
|
|
358
|
+
f"Supervisor error: Forge session '{target}' is a fork but still points at its parent Claude UUID "
|
|
359
|
+
f"({session_uuid[:8]}...), failing open"
|
|
360
|
+
)
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
_log.debug("Resolved supervisor session %s -> %s (cwd=%s)", target, session_uuid[:16], source_cwd)
|
|
364
|
+
return _ResolvedTarget(resume_id=session_uuid, source_cwd=source_cwd)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def invoke_supervisor(
|
|
368
|
+
config: SupervisorConfig,
|
|
369
|
+
context: ActionContext,
|
|
370
|
+
*,
|
|
371
|
+
intent: str | None = None,
|
|
372
|
+
) -> PolicyDecision:
|
|
373
|
+
"""Invoke the semantic supervisor via claude -p --resume.
|
|
374
|
+
|
|
375
|
+
Args:
|
|
376
|
+
config: Supervisor configuration
|
|
377
|
+
context: Action being evaluated
|
|
378
|
+
intent: Policy intent to attach to deny decisions.
|
|
379
|
+
|
|
380
|
+
Returns:
|
|
381
|
+
PolicyDecision based on supervisor verdict (fail-open on errors)
|
|
382
|
+
"""
|
|
383
|
+
from forge.core.reactive.env import should_spawn_subprocesses
|
|
384
|
+
|
|
385
|
+
if not should_spawn_subprocesses():
|
|
386
|
+
_log.debug("Skipping supervisor at FORGE_DEPTH >= %d", 2)
|
|
387
|
+
return PolicyDecision(
|
|
388
|
+
decision="allow",
|
|
389
|
+
policy_id="semantic.supervisor",
|
|
390
|
+
warnings=["Supervisor skipped (FORGE_DEPTH limit reached)"],
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
if not config.resume_id:
|
|
394
|
+
return PolicyDecision(
|
|
395
|
+
decision="allow",
|
|
396
|
+
policy_id="semantic.supervisor",
|
|
397
|
+
warnings=["Supervisor not configured (no resume_id)"],
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
resolved = _resolve_resume_target(config.resume_id, forge_root=config.forge_root)
|
|
401
|
+
if resolved.warning:
|
|
402
|
+
_log.warning(resolved.warning)
|
|
403
|
+
return PolicyDecision(
|
|
404
|
+
decision="allow",
|
|
405
|
+
policy_id="semantic.supervisor",
|
|
406
|
+
warnings=[resolved.warning],
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
assert resolved.resume_id is not None
|
|
410
|
+
|
|
411
|
+
prompt = SUPERVISOR_PROMPT.format(
|
|
412
|
+
tool_name=context.tool_name,
|
|
413
|
+
target_path=context.target_path or "N/A",
|
|
414
|
+
content=(context.raw_diff or context.new_content or "")[:2000],
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
plan_content = _load_plan_override(config)
|
|
418
|
+
if plan_content:
|
|
419
|
+
prompt = _PLAN_OVERRIDE_PREAMBLE.format(plan_content=plan_content) + "\n\n" + prompt
|
|
420
|
+
|
|
421
|
+
if config.direct:
|
|
422
|
+
base_url = None
|
|
423
|
+
else:
|
|
424
|
+
try:
|
|
425
|
+
routing_result = resolve_subprocess_routing(
|
|
426
|
+
explicit_base_url=config.base_url,
|
|
427
|
+
explicit_proxy=config.proxy,
|
|
428
|
+
require_route=False,
|
|
429
|
+
)
|
|
430
|
+
base_url = routing_result.base_url
|
|
431
|
+
except Exception as e:
|
|
432
|
+
_log.warning("Supervisor proxy '%s' not found: %s", config.proxy, e)
|
|
433
|
+
return PolicyDecision(
|
|
434
|
+
decision="warn",
|
|
435
|
+
policy_id="semantic.supervisor",
|
|
436
|
+
warnings=[f"Supervisor proxy '{config.proxy}' not found: {e}"],
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
from forge.core.reactive.cost_tracking import track_verb_cost
|
|
440
|
+
|
|
441
|
+
tracking_url = base_url
|
|
442
|
+
|
|
443
|
+
with track_verb_cost("supervisor", [tracking_url] if tracking_url else []):
|
|
444
|
+
result = run_claude_session(
|
|
445
|
+
prompt,
|
|
446
|
+
resume_id=resolved.resume_id,
|
|
447
|
+
fork_session=config.fork_session,
|
|
448
|
+
base_url=base_url,
|
|
449
|
+
direct=config.direct,
|
|
450
|
+
timeout_seconds=config.timeout_seconds,
|
|
451
|
+
cwd=resolved.source_cwd,
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
if not result.success:
|
|
455
|
+
_log.warning(
|
|
456
|
+
"Supervisor invocation failed: %s",
|
|
457
|
+
result.error or f"exit {result.returncode}",
|
|
458
|
+
)
|
|
459
|
+
return PolicyDecision(
|
|
460
|
+
decision="allow",
|
|
461
|
+
policy_id="semantic.supervisor",
|
|
462
|
+
warnings=[f"Supervisor error: {result.error or f'exit {result.returncode}'}, failing open"],
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
verdict = parse_supervisor_verdict(result.stdout)
|
|
466
|
+
return verdict_to_decision(verdict, intent=intent)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
# --- Setup-time helpers (used by CLI, direct commands, and --supervise flags) ---
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def validate_supervisor_target(target: str, forge_root: str | None = None) -> SessionState:
|
|
473
|
+
"""Validate a supervisor target session at setup time.
|
|
474
|
+
|
|
475
|
+
Checks that the session exists, has a confirmed Claude UUID, and
|
|
476
|
+
has evidence of a real conversation (hook confirmation or transcript).
|
|
477
|
+
Pre-seeded UUIDs alone are not enough -- the same standard resume uses.
|
|
478
|
+
|
|
479
|
+
Raises ValueError with a user-friendly message on failure. This
|
|
480
|
+
runs at wiring time (not at check time) to fail loud on bad config.
|
|
481
|
+
"""
|
|
482
|
+
try:
|
|
483
|
+
mgr = SessionManager()
|
|
484
|
+
state = mgr.get_session(target, forge_root=forge_root)
|
|
485
|
+
except Exception as e:
|
|
486
|
+
raise ValueError(f"Supervisor target session '{target}' not found: {e}") from e
|
|
487
|
+
|
|
488
|
+
if not state.confirmed.claude_session_id:
|
|
489
|
+
raise ValueError(
|
|
490
|
+
f"Supervisor target session '{target}' has no confirmed Claude session ID. "
|
|
491
|
+
f"Launch the session first so Claude materializes a conversation."
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
parent_uuid = _parent_uuid_for_fork_target(mgr, state, forge_root)
|
|
495
|
+
if parent_uuid and parent_uuid == state.confirmed.claude_session_id:
|
|
496
|
+
raise ValueError(
|
|
497
|
+
f"Supervisor target session '{target}' is a fork but still points at its parent Claude UUID "
|
|
498
|
+
f"({state.confirmed.claude_session_id[:8]}...). Resume or recreate the supervisor session before wiring it."
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
latest_artifact_uuid = _latest_transcript_artifact_session_id(state)
|
|
502
|
+
if latest_artifact_uuid and latest_artifact_uuid != state.confirmed.claude_session_id:
|
|
503
|
+
if not _raw_claude_transcript_exists(state, latest_artifact_uuid):
|
|
504
|
+
raise ValueError(
|
|
505
|
+
f"Supervisor target session '{target}' has inconsistent Claude UUID state "
|
|
506
|
+
f"(manifest {state.confirmed.claude_session_id[:8]}..., "
|
|
507
|
+
f"latest transcript {latest_artifact_uuid[:8]}...). "
|
|
508
|
+
"Recreate or resume the supervisor session before wiring it."
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
if not _has_conversation_evidence(state):
|
|
512
|
+
raise ValueError(
|
|
513
|
+
f"Supervisor target session '{target}' has a pre-seeded UUID but no confirmed "
|
|
514
|
+
f"conversation. Launch the session first so Claude materializes a conversation."
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
return state
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _has_conversation_evidence(state: SessionState) -> bool:
|
|
521
|
+
"""Whether a session has evidence of a real Claude conversation.
|
|
522
|
+
|
|
523
|
+
Mirrors the resume-flow's standard: hook confirmation (confirmed_by)
|
|
524
|
+
or a transcript file on disk. Pre-seeded UUIDs without either are
|
|
525
|
+
rejected to prevent silent supervisor degradation.
|
|
526
|
+
"""
|
|
527
|
+
from pathlib import Path
|
|
528
|
+
|
|
529
|
+
if state.confirmed.confirmed_by is not None:
|
|
530
|
+
return True
|
|
531
|
+
|
|
532
|
+
if state.confirmed.transcript_path and Path(state.confirmed.transcript_path).is_file():
|
|
533
|
+
return True
|
|
534
|
+
|
|
535
|
+
session_id = state.confirmed.claude_session_id
|
|
536
|
+
if session_id:
|
|
537
|
+
from forge.session.claude.paths import (
|
|
538
|
+
get_transcript_path,
|
|
539
|
+
resolve_claude_project_root,
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
try:
|
|
543
|
+
return get_transcript_path(resolve_claude_project_root(state), session_id).is_file()
|
|
544
|
+
except Exception:
|
|
545
|
+
pass
|
|
546
|
+
|
|
547
|
+
return False
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def auto_seed_supervisor_proxy(
|
|
551
|
+
source_state: SessionState,
|
|
552
|
+
current_proxy_id: str | None,
|
|
553
|
+
current_template: str | None,
|
|
554
|
+
current_direct: bool,
|
|
555
|
+
) -> str | None:
|
|
556
|
+
"""Return proxy to seed on SupervisorConfig when routing differs.
|
|
557
|
+
|
|
558
|
+
When the source session used a different proxy/routing than the current
|
|
559
|
+
session, the supervisor needs to reach the source's model. Compares full
|
|
560
|
+
routing tuple (proxy_id, template, direct) to detect mismatches.
|
|
561
|
+
|
|
562
|
+
Returns source's proxy_id or template for seeding, or None if routing
|
|
563
|
+
matches or source has no confirmed proxy. Best-effort: returns None on
|
|
564
|
+
any error.
|
|
565
|
+
"""
|
|
566
|
+
try:
|
|
567
|
+
swp = source_state.confirmed.started_with_proxy
|
|
568
|
+
if not swp:
|
|
569
|
+
return None
|
|
570
|
+
|
|
571
|
+
source_routing = (swp.proxy_id, swp.template, False)
|
|
572
|
+
current_routing = (current_proxy_id, current_template, current_direct)
|
|
573
|
+
|
|
574
|
+
if source_routing == current_routing:
|
|
575
|
+
return None
|
|
576
|
+
|
|
577
|
+
return swp.proxy_id or swp.template
|
|
578
|
+
except Exception:
|
|
579
|
+
return None
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def should_supervisor_use_direct(source_state: SessionState) -> bool:
|
|
583
|
+
"""Whether the supervisor should use direct Anthropic routing.
|
|
584
|
+
|
|
585
|
+
Returns True when the source (planner) session ran in direct mode
|
|
586
|
+
(no proxy). Without this, a proxied executor supervising a direct
|
|
587
|
+
planner would route the supervisor through the executor's proxy
|
|
588
|
+
via inherited ANTHROPIC_BASE_URL.
|
|
589
|
+
"""
|
|
590
|
+
return not source_state.confirmed.started_with_proxy
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def preflight_supervisor_proxy(supervisor_proxy: str) -> str:
|
|
594
|
+
"""Validate supervisor proxy against the registry before state mutation.
|
|
595
|
+
|
|
596
|
+
Checks registry presence only, not liveness — a registered-but-stopped
|
|
597
|
+
proxy passes. Use ``forge proxy clean`` to prune stale entries.
|
|
598
|
+
|
|
599
|
+
Returns the resolved proxy_id. Raises ValueError if the proxy is not found.
|
|
600
|
+
Call this before creating sessions/forks so a bad proxy name doesn't leave
|
|
601
|
+
half-created state.
|
|
602
|
+
"""
|
|
603
|
+
# Lazy import: guard → proxy dependency; kept lazy to avoid circular imports
|
|
604
|
+
from forge.proxy.proxies import (
|
|
605
|
+
ProxyRegistryStore,
|
|
606
|
+
ProxyResolutionError,
|
|
607
|
+
resolve_proxy,
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
registry = ProxyRegistryStore().read()
|
|
611
|
+
try:
|
|
612
|
+
entry = resolve_proxy(registry, supervisor_proxy)
|
|
613
|
+
except ProxyResolutionError:
|
|
614
|
+
raise ValueError(f"Supervisor proxy '{supervisor_proxy}' not found in registry")
|
|
615
|
+
return entry.proxy_id or supervisor_proxy
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def apply_supervisor_routing(
|
|
619
|
+
sup_config: SupervisorConfig,
|
|
620
|
+
source_state: SessionState,
|
|
621
|
+
*,
|
|
622
|
+
supervisor_proxy: str | None = None,
|
|
623
|
+
supervisor_direct: bool = False,
|
|
624
|
+
current_proxy_id: str | None = None,
|
|
625
|
+
current_template: str | None = None,
|
|
626
|
+
current_direct: bool = False,
|
|
627
|
+
) -> str | None:
|
|
628
|
+
"""Apply explicit or auto-seeded supervisor routing to sup_config.
|
|
629
|
+
|
|
630
|
+
When supervisor_proxy is given, stores it directly (caller must have
|
|
631
|
+
already validated via preflight_supervisor_proxy). When supervisor_direct
|
|
632
|
+
is given, sets direct routing. Otherwise falls through to
|
|
633
|
+
auto_seed_supervisor_proxy().
|
|
634
|
+
|
|
635
|
+
Returns a display string for the routing choice (for CLI output), or None
|
|
636
|
+
when routing matched and no override was needed.
|
|
637
|
+
"""
|
|
638
|
+
if supervisor_proxy:
|
|
639
|
+
sup_config.proxy = supervisor_proxy
|
|
640
|
+
return supervisor_proxy
|
|
641
|
+
elif supervisor_direct:
|
|
642
|
+
sup_config.direct = True
|
|
643
|
+
return "direct"
|
|
644
|
+
else:
|
|
645
|
+
seeded = auto_seed_supervisor_proxy(
|
|
646
|
+
source_state,
|
|
647
|
+
current_proxy_id=current_proxy_id,
|
|
648
|
+
current_template=current_template,
|
|
649
|
+
current_direct=current_direct,
|
|
650
|
+
)
|
|
651
|
+
if seeded:
|
|
652
|
+
sup_config.proxy = seeded
|
|
653
|
+
if should_supervisor_use_direct(source_state):
|
|
654
|
+
sup_config.direct = True
|
|
655
|
+
return seeded or "direct"
|
|
656
|
+
return seeded
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def apply_supervisor_to_intent(
|
|
660
|
+
manifest: SessionState,
|
|
661
|
+
sup_config: SupervisorConfig,
|
|
662
|
+
) -> None:
|
|
663
|
+
"""Apply supervisor config to manifest intent (not overrides).
|
|
664
|
+
|
|
665
|
+
Also enables policy enforcement, which is required for the hook to
|
|
666
|
+
evaluate supervisor checks (commands.py:1049 exits early otherwise).
|
|
667
|
+
Clears any ``policy.enabled`` override so a prior ``%guard disable``
|
|
668
|
+
doesn't shadow the intent (overrides take precedence in effective.py).
|
|
669
|
+
|
|
670
|
+
Writes to intent rather than overrides so that supervision persists
|
|
671
|
+
through ``resume --fresh`` which deepcopies ``intent.policy`` into
|
|
672
|
+
child sessions (manager.py:712, 886).
|
|
673
|
+
"""
|
|
674
|
+
from forge.session.overrides import delete_override
|
|
675
|
+
|
|
676
|
+
if manifest.intent.policy is None:
|
|
677
|
+
manifest.intent.policy = PolicyIntent(enabled=True, supervisor=sup_config)
|
|
678
|
+
else:
|
|
679
|
+
manifest.intent.policy.enabled = True
|
|
680
|
+
manifest.intent.policy.supervisor = sup_config
|
|
681
|
+
|
|
682
|
+
# Clear conflicting override so intent.policy.enabled takes effect.
|
|
683
|
+
if manifest.overrides:
|
|
684
|
+
delete_override(manifest.overrides, "policy.enabled")
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
# --- Plan reload resolution ---
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
@dataclass
|
|
691
|
+
class ResolvedReloadPlan:
|
|
692
|
+
"""Result of auto-resolving the latest approved plan for supervisor reload."""
|
|
693
|
+
|
|
694
|
+
path: str
|
|
695
|
+
source: str # "self" | "fork" | "target"
|
|
696
|
+
session_name: str
|
|
697
|
+
captured_at: str
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def resolve_supervisor_reload_plan_path(
|
|
701
|
+
sup: SupervisorConfig,
|
|
702
|
+
current_manifest: SessionState,
|
|
703
|
+
) -> ResolvedReloadPlan | None:
|
|
704
|
+
"""Search the supervision graph for the latest approved plan.
|
|
705
|
+
|
|
706
|
+
Search order: current session -> related forks -> supervisor target.
|
|
707
|
+
Only approved snapshots (ExitPlanMode artifacts) are considered.
|
|
708
|
+
"""
|
|
709
|
+
from forge.guard.queries import read_scoped_supervisor_target
|
|
710
|
+
from forge.session.index import IndexStore
|
|
711
|
+
from forge.session.plan_resolution import latest_snapshot_path, resolve_plan_info
|
|
712
|
+
from forge.session.store import SessionStore
|
|
713
|
+
|
|
714
|
+
current_fr = current_manifest.forge_root
|
|
715
|
+
if not current_fr:
|
|
716
|
+
return None
|
|
717
|
+
|
|
718
|
+
# Pre-step: resolve supervisor target identity (name + forge_root)
|
|
719
|
+
target_name: str | None = None
|
|
720
|
+
target_state: SessionState | None = None
|
|
721
|
+
if sup.resume_id:
|
|
722
|
+
target_state = read_scoped_supervisor_target(sup.resume_id, sup.forge_root, current_fr)
|
|
723
|
+
if target_state is not None:
|
|
724
|
+
target_name = sup.resume_id
|
|
725
|
+
if _UUID_PATTERN.fullmatch(sup.resume_id):
|
|
726
|
+
try:
|
|
727
|
+
match = IndexStore().find_session_by_uuid(sup.resume_id)
|
|
728
|
+
if match:
|
|
729
|
+
target_name = match[0]
|
|
730
|
+
except Exception:
|
|
731
|
+
pass
|
|
732
|
+
|
|
733
|
+
# Step 1: current supervised session (own approved plans only)
|
|
734
|
+
info = resolve_plan_info(current_manifest, current_forge_root=current_fr)
|
|
735
|
+
if info.source == "self" and info.approved_snapshots:
|
|
736
|
+
snap_rel = latest_snapshot_path(info.approved_snapshots)
|
|
737
|
+
if snap_rel:
|
|
738
|
+
snap_abs = Path(current_fr) / snap_rel
|
|
739
|
+
if snap_abs.is_file():
|
|
740
|
+
captured = info.approved_snapshots[-1].get("captured_at", "")
|
|
741
|
+
return ResolvedReloadPlan(
|
|
742
|
+
path=str(snap_abs),
|
|
743
|
+
source="self",
|
|
744
|
+
session_name=current_manifest.name,
|
|
745
|
+
captured_at=captured,
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
# Step 2: related forks in the same forge_root
|
|
749
|
+
if target_name:
|
|
750
|
+
best: ResolvedReloadPlan | None = None
|
|
751
|
+
try:
|
|
752
|
+
entries = IndexStore().list_sessions(forge_root_filter=current_fr)
|
|
753
|
+
for name, _entry in entries:
|
|
754
|
+
if name == current_manifest.name:
|
|
755
|
+
continue
|
|
756
|
+
try:
|
|
757
|
+
fork_state = SessionStore(current_fr, name).read()
|
|
758
|
+
except Exception:
|
|
759
|
+
continue
|
|
760
|
+
# Check parent relationship
|
|
761
|
+
parent = None
|
|
762
|
+
if fork_state.confirmed.derivation:
|
|
763
|
+
parent = fork_state.confirmed.derivation.parent_session
|
|
764
|
+
if not parent:
|
|
765
|
+
parent = fork_state.parent_session
|
|
766
|
+
if parent != target_name:
|
|
767
|
+
continue
|
|
768
|
+
# Check for approved plan snapshots
|
|
769
|
+
plans = fork_state.confirmed.artifacts.get("plans", [])
|
|
770
|
+
if not isinstance(plans, list):
|
|
771
|
+
continue
|
|
772
|
+
for entry in reversed(plans):
|
|
773
|
+
if not isinstance(entry, dict) or entry.get("kind") != "approved":
|
|
774
|
+
continue
|
|
775
|
+
snap = entry.get("snapshot_path")
|
|
776
|
+
if not isinstance(snap, str):
|
|
777
|
+
continue
|
|
778
|
+
snap_abs = Path(current_fr) / snap
|
|
779
|
+
if not snap_abs.is_file():
|
|
780
|
+
continue
|
|
781
|
+
captured_at = entry.get("captured_at", "")
|
|
782
|
+
candidate = ResolvedReloadPlan(
|
|
783
|
+
path=str(snap_abs),
|
|
784
|
+
source="fork",
|
|
785
|
+
session_name=name,
|
|
786
|
+
captured_at=captured_at,
|
|
787
|
+
)
|
|
788
|
+
if best is None or captured_at > best.captured_at:
|
|
789
|
+
best = candidate
|
|
790
|
+
break # Latest snapshot in this session found
|
|
791
|
+
except Exception:
|
|
792
|
+
_log.debug("Error scanning related forks for plan reload", exc_info=True)
|
|
793
|
+
if best is not None:
|
|
794
|
+
return best
|
|
795
|
+
|
|
796
|
+
# Step 3: supervisor target session
|
|
797
|
+
if target_state is not None and target_name:
|
|
798
|
+
target_fr = target_state.forge_root or current_fr
|
|
799
|
+
target_info = resolve_plan_info(target_state, current_forge_root=target_fr)
|
|
800
|
+
if target_info.source == "self" and target_info.approved_snapshots:
|
|
801
|
+
snap_rel = latest_snapshot_path(target_info.approved_snapshots)
|
|
802
|
+
if snap_rel:
|
|
803
|
+
snap_abs = Path(target_fr) / snap_rel
|
|
804
|
+
if snap_abs.is_file():
|
|
805
|
+
captured = target_info.approved_snapshots[-1].get("captured_at", "")
|
|
806
|
+
return ResolvedReloadPlan(
|
|
807
|
+
path=str(snap_abs),
|
|
808
|
+
source="target",
|
|
809
|
+
session_name=target_name,
|
|
810
|
+
captured_at=captured,
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
return None
|