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,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