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
forge/cli/guard.py ADDED
@@ -0,0 +1,1018 @@
1
+ """Guard CLI commands for policy management.
2
+
3
+ Commands for managing policy enforcement:
4
+ - enable: Enable policy bundles for the current session
5
+ - disable: Disable policy enforcement
6
+ - status: Show current policy configuration and state
7
+ - check: Evaluate policies on demand against a file or diff
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import re
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ import click
19
+ from rich.console import Console
20
+ from rich.table import Table
21
+
22
+ from forge.core.paths import display_path
23
+ from forge.guard.queries import (
24
+ find_sessions_supervised_by,
25
+ read_scoped_supervisor_target,
26
+ )
27
+ from forge.session import SessionStore
28
+ from forge.session.effective import compute_effective_intent
29
+ from forge.session.hooks.session_start import ENV_SESSION
30
+ from forge.session.models import PolicyIntent, SessionState
31
+ from forge.session.store import HOOK_LOCK_TIMEOUT_S, MANIFEST_FILENAME, get_sessions_dir
32
+
33
+ console = Console()
34
+
35
+
36
+ def _resolve_session_name(cwd: Path) -> str | None:
37
+ """Resolve current session: FORGE_SESSION env var, or auto-detect if exactly one exists."""
38
+ name = os.environ.get(ENV_SESSION)
39
+ if name:
40
+ return name
41
+
42
+ sessions_dir = get_sessions_dir(cwd)
43
+ if not sessions_dir.is_dir():
44
+ return None
45
+
46
+ candidates = [d.name for d in sessions_dir.iterdir() if d.is_dir() and (d / MANIFEST_FILENAME).exists()]
47
+ if len(candidates) == 1:
48
+ return candidates[0]
49
+ return None
50
+
51
+
52
+ def _resolve_forge_root(cwd: Path) -> str:
53
+ """Resolve forge_root from CWD (falls back to CWD itself)."""
54
+ try:
55
+ from forge.core.ops.context import find_forge_root
56
+
57
+ fr = find_forge_root(cwd)
58
+ return str(fr) if fr else str(cwd)
59
+ except Exception:
60
+ return str(cwd)
61
+
62
+
63
+ def _resolve_session_for_display(
64
+ name: str,
65
+ cwd: Path,
66
+ ) -> tuple[SessionStore, SessionState]:
67
+ """Resolve a named session, repo-scoped with current-project preference.
68
+
69
+ Delegates to the shared two-tier resolver in core.ops.resolution.
70
+ """
71
+ from forge.core.ops.resolution import resolve_session_repo_wide
72
+
73
+ resolved = resolve_session_repo_wide(name, _resolve_forge_root(cwd))
74
+ return resolved.store, resolved.state
75
+
76
+
77
+ @click.group()
78
+ def guard() -> None:
79
+ """Manage policy enforcement for the current session.
80
+
81
+ \b
82
+ Examples:
83
+ forge guard enable --bundle tdd # Enable TDD policy
84
+ forge guard status # Show policy state
85
+ forge guard check --bundle tdd -f src/foo.py # On-demand check
86
+ """
87
+ pass
88
+
89
+
90
+ @guard.command(name="list")
91
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
92
+ def list_bundles(as_json: bool) -> None:
93
+ """List available policy bundles and their rules."""
94
+ from forge.guard.deterministic.registry import BUNDLES, get_bundle_policies
95
+
96
+ if as_json:
97
+ import json
98
+
99
+ data = []
100
+ for bundle_name in sorted(BUNDLES):
101
+ policies = get_bundle_policies(bundle_name)
102
+ data.append(
103
+ {
104
+ "name": bundle_name,
105
+ "policies": [
106
+ {"policy_id": p.policy_id, "description": getattr(p, "description", None)} for p in policies
107
+ ],
108
+ }
109
+ )
110
+ click.echo(json.dumps(data, indent=2, default=str))
111
+ return
112
+
113
+ for bundle_name in sorted(BUNDLES):
114
+ policies = get_bundle_policies(bundle_name)
115
+ console.print(f"[bold cyan]{bundle_name}[/bold cyan]")
116
+ for p in policies:
117
+ console.print(f" {p.policy_id}")
118
+ if hasattr(p, "description") and p.description:
119
+ console.print(f" [dim]{p.description}[/dim]")
120
+ console.print()
121
+
122
+
123
+ @guard.command(name="enable")
124
+ @click.option(
125
+ "--bundle",
126
+ "-b",
127
+ "bundles",
128
+ multiple=True,
129
+ type=click.Choice(["tdd", "coding_standards"]),
130
+ help="Policy bundles to enable (can be repeated)",
131
+ )
132
+ @click.option(
133
+ "--fail-mode",
134
+ type=click.Choice(["open", "closed"]),
135
+ default="open",
136
+ help="Behavior on policy errors (default: open)",
137
+ )
138
+ @click.option(
139
+ "--permissive",
140
+ is_flag=True,
141
+ default=False,
142
+ help="TDD permissive mode: warn instead of deny (sets bundle_config.tdd.strict=false)",
143
+ )
144
+ def enable(bundles: tuple[str, ...], fail_mode: str, permissive: bool) -> None:
145
+ """Enable policy enforcement for the current session.
146
+
147
+ \b
148
+ Examples:
149
+ forge guard enable --bundle tdd --bundle coding_standards
150
+ forge guard enable --bundle tdd --permissive
151
+ """
152
+ if not bundles:
153
+ console.print("[yellow]Warning:[/yellow] No bundles specified. Use --bundle to enable policies.")
154
+ console.print("Available bundles: tdd, coding_standards")
155
+ return
156
+
157
+ cwd = Path.cwd().resolve()
158
+ session_name = _resolve_session_name(cwd)
159
+ if not session_name:
160
+ console.print(f"[red]Error:[/red] No session found in {display_path(cwd)}")
161
+ console.print(" Run 'forge session start' first to create a session.")
162
+ sys.exit(1)
163
+
164
+ store = SessionStore(_resolve_forge_root(cwd), session_name)
165
+
166
+ try:
167
+ store.read() # Verify session exists
168
+ except Exception:
169
+ console.print(f"[red]Error:[/red] No session found in {display_path(cwd)}")
170
+ console.print(" Run 'forge session start' first to create a session.")
171
+ sys.exit(1)
172
+
173
+ bundle_config: dict[str, dict[str, object]] = {}
174
+ if permissive and "tdd" in bundles:
175
+ bundle_config["tdd"] = {"strict": False}
176
+
177
+ def _mutate(m: object) -> None:
178
+ if not isinstance(m, SessionState):
179
+ raise TypeError(f"Expected SessionState, got {type(m)}")
180
+
181
+ m.intent.policy = PolicyIntent(
182
+ enabled=True,
183
+ fail_mode=fail_mode, # type: ignore[arg-type] # click Choice returns str, not Literal
184
+ bundles=list(bundles),
185
+ bundle_config=bundle_config,
186
+ )
187
+
188
+ try:
189
+ store.update(timeout_s=HOOK_LOCK_TIMEOUT_S, mutate=_mutate)
190
+ except Exception as e:
191
+ console.print(f"[red]Error:[/red] Failed to update session: {e}")
192
+ sys.exit(1)
193
+
194
+ console.print(f"[green]Policy enabled[/green] with bundles: {', '.join(bundles)}")
195
+ console.print(f" Fail mode: {fail_mode}")
196
+
197
+ from forge.install.hooks import has_forge_hook
198
+
199
+ if not has_forge_hook(cwd, "PreToolUse", "forge hook policy-check"):
200
+ console.print(
201
+ "\n[yellow]Warning:[/yellow] Policy configured but PreToolUse hook is not installed. "
202
+ "Enforcement will not be active."
203
+ )
204
+ console.print("[dim]Tip: Run 'forge extension enable' to install hooks.[/dim]")
205
+ if bundle_config:
206
+ for bundle, cfg in bundle_config.items():
207
+ cfg_str = ", ".join(f"{k}={v}" for k, v in cfg.items())
208
+ console.print(f" {bundle}: {cfg_str}")
209
+
210
+ from forge.guard.deterministic.registry import get_policy_ids_for_bundle
211
+
212
+ rules = []
213
+ for bundle in bundles:
214
+ rules.extend(get_policy_ids_for_bundle(bundle))
215
+
216
+ if rules:
217
+ console.print(" Active rules:")
218
+ for rule in rules:
219
+ console.print(f" - {rule}")
220
+
221
+
222
+ @guard.command(name="disable")
223
+ def disable() -> None:
224
+ """Disable policy enforcement for the current session."""
225
+ cwd = Path.cwd().resolve()
226
+ session_name = _resolve_session_name(cwd)
227
+ if not session_name:
228
+ console.print(f"[red]Error:[/red] No session found in {display_path(cwd)}")
229
+ sys.exit(1)
230
+
231
+ store = SessionStore(_resolve_forge_root(cwd), session_name)
232
+
233
+ try:
234
+ store.read() # Verify session exists
235
+ except Exception:
236
+ console.print(f"[red]Error:[/red] No session found in {display_path(cwd)}")
237
+ sys.exit(1)
238
+
239
+ def _mutate(m: object) -> None:
240
+ if not isinstance(m, SessionState):
241
+ raise TypeError(f"Expected SessionState, got {type(m)}")
242
+
243
+ if m.intent.policy:
244
+ m.intent.policy.enabled = False
245
+ else:
246
+ m.intent.policy = PolicyIntent(enabled=False)
247
+
248
+ try:
249
+ store.update(timeout_s=HOOK_LOCK_TIMEOUT_S, mutate=_mutate)
250
+ except Exception as e:
251
+ console.print(f"[red]Error:[/red] Failed to update session: {e}")
252
+ sys.exit(1)
253
+
254
+ console.print("[green]Policy enforcement disabled[/green]")
255
+
256
+
257
+ @guard.command(name="status")
258
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
259
+ @click.option("--session", "-s", "session_name", help="Target session (default: auto-detect)")
260
+ def status(as_json: bool, session_name: str | None) -> None:
261
+ """Show current policy configuration and state."""
262
+ cwd = Path.cwd().resolve()
263
+
264
+ if session_name:
265
+ from forge.session.exceptions import ForgeSessionError
266
+
267
+ try:
268
+ store, manifest = _resolve_session_for_display(session_name, cwd)
269
+ except ForgeSessionError as e:
270
+ console.print(f"[red]Error:[/red] Session '{session_name}' not found: {e}")
271
+ sys.exit(1)
272
+ else:
273
+ name = _resolve_session_name(cwd)
274
+ if not name:
275
+ console.print(f"[red]Error:[/red] No session found in {display_path(cwd)}")
276
+ sys.exit(1)
277
+ store = SessionStore(_resolve_forge_root(cwd), name)
278
+ try:
279
+ manifest = store.read()
280
+ except Exception:
281
+ console.print(f"[red]Error:[/red] No session found in {display_path(cwd)}")
282
+ sys.exit(1)
283
+
284
+ try:
285
+ effective = compute_effective_intent(manifest)
286
+ except Exception as exc:
287
+ console.print(f"[red]Error:[/red] Failed to compute effective config: {exc}")
288
+ sys.exit(1)
289
+
290
+ if as_json:
291
+ import json
292
+
293
+ policy_data: dict[str, object] = {"session_name": manifest.name}
294
+ if effective.policy:
295
+ sup = effective.policy.supervisor
296
+ sup_data = None
297
+ if sup:
298
+ sup_data = {
299
+ "resume_id": sup.resume_id,
300
+ "suspended": sup.suspended,
301
+ "plan_override_path": sup.plan_override_path,
302
+ "proxy": sup.proxy,
303
+ "direct": sup.direct,
304
+ "fork_session": sup.fork_session,
305
+ "timeout_seconds": sup.timeout_seconds,
306
+ "throttle_seconds": sup.throttle_seconds,
307
+ "resolved_uuid": None,
308
+ "source_model": None,
309
+ }
310
+ if sup.resume_id:
311
+ ts = read_scoped_supervisor_target(sup.resume_id, sup.forge_root, manifest.forge_root)
312
+ if ts is not None:
313
+ sup_data["resolved_uuid"] = ts.confirmed.claude_session_id
314
+ swp = ts.confirmed.started_with_proxy
315
+ if swp and swp.template:
316
+ sup_data["source_model"] = swp.template
317
+ policy_data["policy"] = {
318
+ "enabled": effective.policy.enabled,
319
+ "fail_mode": effective.policy.fail_mode or "open",
320
+ "bundles": effective.policy.bundles or [],
321
+ "bundle_config": effective.policy.bundle_config or {},
322
+ "supervisor": sup_data,
323
+ }
324
+ else:
325
+ policy_data["policy"] = None
326
+
327
+ confirmed_policy = manifest.confirmed.policy
328
+ if confirmed_policy:
329
+ policy_data["confirmed"] = {
330
+ "decisions_count": len(confirmed_policy.decisions or []),
331
+ "policy_states_count": len(confirmed_policy.policy_states or {}),
332
+ }
333
+ else:
334
+ policy_data["confirmed"] = None
335
+
336
+ supervised = find_sessions_supervised_by(
337
+ manifest.name, manifest.confirmed.claude_session_id, manifest.forge_root
338
+ )
339
+ if supervised:
340
+ policy_data["supervised_sessions"] = supervised
341
+
342
+ click.echo(json.dumps(policy_data, indent=2, default=str))
343
+ return
344
+
345
+ table = Table(title=f"Policy Status: {manifest.name}", show_header=False)
346
+ table.add_column("Key", style="cyan")
347
+ table.add_column("Value")
348
+
349
+ if effective.policy:
350
+ table.add_row("Enabled", "Yes" if effective.policy.enabled else "No")
351
+ table.add_row("Fail Mode", effective.policy.fail_mode or "open")
352
+ table.add_row(
353
+ "Bundles",
354
+ ", ".join(effective.policy.bundles) if effective.policy.bundles else "None",
355
+ )
356
+ if effective.policy.bundle_config:
357
+ for bundle, cfg in effective.policy.bundle_config.items():
358
+ cfg_str = ", ".join(f"{k}={v}" for k, v in cfg.items())
359
+ table.add_row(f" {bundle}", cfg_str)
360
+
361
+ if effective.policy.supervisor:
362
+ sup = effective.policy.supervisor
363
+ status = "Suspended" if sup.suspended else "Configured"
364
+ table.add_row("Supervisor", status)
365
+ if sup.resume_id:
366
+ table.add_row(" Target", sup.resume_id)
367
+ ts = read_scoped_supervisor_target(sup.resume_id, sup.forge_root, manifest.forge_root)
368
+ if ts is not None:
369
+ uuid = ts.confirmed.claude_session_id
370
+ if uuid:
371
+ table.add_row(" Claude UUID", uuid[:16] + "...")
372
+ swp = ts.confirmed.started_with_proxy
373
+ if swp and swp.template:
374
+ table.add_row(" Source model", swp.template)
375
+ if sup.proxy:
376
+ table.add_row(" Routing", f"proxy: {sup.proxy}")
377
+ elif sup.direct:
378
+ table.add_row(" Routing", "direct (no proxy)")
379
+ table.add_row(" Fork session", "Yes" if sup.fork_session else "No")
380
+ table.add_row(" Timeout", f"{sup.timeout_seconds}s")
381
+ table.add_row(" Throttle", f"{sup.throttle_seconds}s")
382
+ if sup.plan_override_path:
383
+ table.add_row(" Plan override", sup.plan_override_path)
384
+ else:
385
+ table.add_row("Supervisor", "Not configured")
386
+ else:
387
+ table.add_row("Enabled", "No (not configured)")
388
+
389
+ console.print(table)
390
+
391
+ if manifest.confirmed.policy:
392
+ confirmed = manifest.confirmed.policy
393
+ console.print()
394
+ state_table = Table(title="Policy State (from hooks)", show_header=False)
395
+ state_table.add_column("Key", style="cyan")
396
+ state_table.add_column("Value")
397
+
398
+ state_table.add_row("Decisions Logged", str(len(confirmed.decisions or [])))
399
+ state_table.add_row("Policy States", str(len(confirmed.policy_states or {})))
400
+
401
+ console.print(state_table)
402
+
403
+ if confirmed.policy_states:
404
+ for policy_id, state in confirmed.policy_states.items():
405
+ items = ", ".join(f"{k}: {len(v) if isinstance(v, (list, dict)) else v}" for k, v in state.items())
406
+ console.print(f" [dim]{policy_id}[/dim]: {items}")
407
+
408
+ # Supervised-sessions tip (always, not gated on "no supervisor" — chains are valid)
409
+ supervised = find_sessions_supervised_by(manifest.name, manifest.confirmed.claude_session_id, manifest.forge_root)
410
+ if supervised:
411
+ names = ", ".join(supervised)
412
+ console.print(
413
+ f"\n[dim]Tip: This session supervises: {names}. "
414
+ f"Check with: forge guard status --session {supervised[0]}[/dim]"
415
+ )
416
+
417
+
418
+ _DIFF_PATH_RE = re.compile(r"^\+\+\+ b/(.+?)(?:\t.*)?$", re.MULTILINE)
419
+
420
+
421
+ def _extract_path_from_diff(diff: str) -> str | None:
422
+ """Extract the first file path from a unified diff.
423
+
424
+ Parses ``+++ b/<path>`` lines, stripping trailing tab-delimited
425
+ metadata (timestamps, etc.). Returns None if no path found.
426
+ """
427
+ m = _DIFF_PATH_RE.search(diff)
428
+ if m:
429
+ path = m.group(1).strip()
430
+ return path if path and path != "/dev/null" else None
431
+ return None
432
+
433
+
434
+ @guard.command(name="check")
435
+ @click.option(
436
+ "--bundle",
437
+ "-b",
438
+ "bundles",
439
+ multiple=True,
440
+ required=True,
441
+ type=click.Choice(["tdd", "coding_standards"]),
442
+ help="Policy bundles to evaluate (can be repeated)",
443
+ )
444
+ @click.option(
445
+ "--file",
446
+ "-f",
447
+ "file_path",
448
+ type=click.Path(exists=True),
449
+ help="File to evaluate policies against",
450
+ )
451
+ @click.option(
452
+ "--diff",
453
+ "use_diff",
454
+ is_flag=True,
455
+ help="Read git diff from stdin",
456
+ )
457
+ @click.option(
458
+ "--fail-mode",
459
+ type=click.Choice(["open", "closed"]),
460
+ default="closed",
461
+ help="Behavior on policy errors (default: closed for on-demand checks)",
462
+ )
463
+ @click.option(
464
+ "--json",
465
+ "json_output",
466
+ is_flag=True,
467
+ help="Output structured JSON",
468
+ )
469
+ def check(
470
+ bundles: tuple[str, ...],
471
+ file_path: str | None,
472
+ use_diff: bool,
473
+ fail_mode: str,
474
+ json_output: bool,
475
+ ) -> None:
476
+ """Evaluate policies on demand against a file or diff.
477
+
478
+ Unlike hook-triggered checks, this runs explicitly and defaults to
479
+ fail-mode=closed (violations are reported, not swallowed).
480
+
481
+ \b
482
+ Examples:
483
+ forge guard check --bundle tdd --file src/foo.py
484
+ forge guard check --bundle tdd --bundle coding_standards -f src/foo.py --json
485
+ git diff | forge guard check --bundle coding_standards --diff
486
+ """
487
+ from forge.guard.engine import build_engine
488
+ from forge.guard.types import ActionContext, extract_added_lines
489
+
490
+ if not file_path and not use_diff:
491
+ console.print("[red]Error:[/red] Provide --file or --diff")
492
+ sys.exit(2)
493
+
494
+ cwd = Path.cwd().resolve()
495
+
496
+ if use_diff:
497
+ if sys.stdin.isatty():
498
+ console.print("[red]Error:[/red] --diff requires input on stdin (e.g., git diff | forge guard check ...)")
499
+ sys.exit(2)
500
+ raw_input = sys.stdin.read()
501
+ tool_name = "Edit"
502
+ target_path = _extract_path_from_diff(raw_input)
503
+ new_content = extract_added_lines(raw_input)
504
+ else:
505
+ assert file_path is not None
506
+ target = Path(file_path)
507
+ try:
508
+ raw_input = target.read_text()
509
+ except Exception as e:
510
+ console.print(f"[red]Error:[/red] Failed to read {display_path(file_path)}: {e}")
511
+ sys.exit(2)
512
+ tool_name = "Write"
513
+ new_content = raw_input
514
+ try:
515
+ target_path = str(target.resolve().relative_to(cwd))
516
+ except ValueError:
517
+ target_path = str(target)
518
+
519
+ context = ActionContext(
520
+ event="OnDemand.Check",
521
+ tool_name=tool_name,
522
+ tool_args={"file_path": file_path or "", "content": new_content[:200]},
523
+ repo_root=str(cwd),
524
+ session_name="on-demand",
525
+ target_path=target_path,
526
+ new_content=new_content[:5000] if new_content else None,
527
+ raw_diff=raw_input[:5000] if use_diff and raw_input else None,
528
+ )
529
+
530
+ try:
531
+ engine = build_engine(list(bundles), fail_mode=fail_mode) # type: ignore[arg-type]
532
+ result = engine.evaluate(context)
533
+ except Exception as e:
534
+ if json_output:
535
+ click.echo(json.dumps({"error": str(e), "passed": False}))
536
+ else:
537
+ console.print(f"[red]Error:[/red] Policy evaluation failed: {e}")
538
+ sys.exit(2)
539
+
540
+ # Determine exit code: allow and warn both exit 0 (warn = advisory)
541
+ passed = result.final_decision in ("allow", "warn")
542
+ exit_code = 0 if passed else 1
543
+
544
+ if json_output:
545
+ # Build violations with intent from their parent decisions
546
+ violations_json = []
547
+ for d in result.decisions:
548
+ if d.decision != "deny":
549
+ continue
550
+ for v in d.violations:
551
+ entry: dict[str, str | None] = {
552
+ "rule_id": v.rule_id,
553
+ "message": v.message,
554
+ "severity": v.severity,
555
+ "suggested_fix": v.suggested_fix,
556
+ }
557
+ if d.intent:
558
+ entry["intent"] = d.intent
559
+ violations_json.append(entry)
560
+ output = {
561
+ "passed": passed,
562
+ "clean": result.final_decision == "allow",
563
+ "final_decision": result.final_decision,
564
+ "violations": violations_json,
565
+ "warnings": result.all_warnings,
566
+ "policies_evaluated": [d.policy_id for d in result.decisions],
567
+ }
568
+ click.echo(json.dumps(output, indent=2))
569
+ else:
570
+ if result.final_decision == "allow":
571
+ console.print("[green]All policies passed[/green]")
572
+ elif result.final_decision == "warn":
573
+ console.print("[yellow]Passed with warnings[/yellow]")
574
+ for w in result.all_warnings:
575
+ console.print(f" ⚠︎ {w}", style="yellow")
576
+ else:
577
+ console.print(f"[red]Policy check failed ({result.final_decision})[/red]")
578
+ for d in result.decisions:
579
+ if d.decision != "deny":
580
+ continue
581
+ table = Table(show_header=True)
582
+ table.add_column("Rule", style="cyan")
583
+ table.add_column("Severity", style="red")
584
+ table.add_column("Message")
585
+ table.add_column("Fix", style="dim")
586
+ for v in d.violations:
587
+ table.add_row(v.rule_id, v.severity, v.message, v.suggested_fix or "")
588
+ if d.intent:
589
+ table.add_row("", "", f"[dim]Intent: {d.intent}[/dim]", "")
590
+ console.print(table)
591
+
592
+ if result.all_warnings and result.final_decision != "warn":
593
+ for w in result.all_warnings:
594
+ console.print(f" [dim]⚠︎ {w}[/dim]")
595
+
596
+ sys.exit(exit_code)
597
+
598
+
599
+ # Prefixes that invoke_supervisor() uses in warnings when it fails open.
600
+ # Used by the CLI to convert allow→exit(2).
601
+ _INFRA_FAILURE_PREFIXES = ("Supervisor error:", "Supervisor skipped")
602
+
603
+
604
+ @guard.command(name="supervisor")
605
+ @click.option(
606
+ "--file",
607
+ "-f",
608
+ "file_path",
609
+ type=click.Path(exists=True),
610
+ required=True,
611
+ help="File to evaluate against the plan",
612
+ )
613
+ @click.option(
614
+ "--resume-id",
615
+ "-r",
616
+ required=True,
617
+ help="Claude session UUID for --resume, or a Forge session name to resolve",
618
+ )
619
+ @click.option(
620
+ "--proxy",
621
+ "proxy_name",
622
+ type=str,
623
+ default=None,
624
+ help="Proxy (proxy_id or template name) for base_url resolution",
625
+ )
626
+ @click.option("--no-proxy", "direct", is_flag=True, default=False, help="Force direct Anthropic routing (bypass proxy)")
627
+ @click.option(
628
+ "--timeout",
629
+ "-t",
630
+ type=int,
631
+ default=45,
632
+ help="Supervisor timeout in seconds (default: 45)",
633
+ )
634
+ @click.option(
635
+ "--json",
636
+ "json_output",
637
+ is_flag=True,
638
+ help="Output structured JSON",
639
+ )
640
+ def supervisor_cmd(
641
+ file_path: str,
642
+ resume_id: str,
643
+ proxy_name: str | None,
644
+ direct: bool,
645
+ timeout: int,
646
+ json_output: bool,
647
+ ) -> None:
648
+ """Evaluate a single file against a supervisor plan (one-shot).
649
+
650
+ For persistent supervisor configuration, use 'forge guard supervise' instead.
651
+
652
+ Fail-closed: exit 0 (aligned), exit 1 (divergent), exit 2 (could not evaluate).
653
+
654
+ \b
655
+ Examples:
656
+ forge guard supervisor -f src/foo.py -r abc-123 --json
657
+ forge guard supervisor -f src/foo.py -r planning-session --json
658
+ forge guard supervisor -f src/foo.py -r abc-123 --proxy openrouter-openai
659
+ forge guard supervisor -f src/foo.py -r abc-123 --no-proxy
660
+ """
661
+ if direct and proxy_name:
662
+ console.print("[red]Error:[/red] --no-proxy and --proxy are mutually exclusive")
663
+ sys.exit(1)
664
+
665
+ from forge.guard.semantic.supervisor import SUPERVISOR_INTENT, invoke_supervisor
666
+ from forge.guard.types import ActionContext
667
+ from forge.session.models import SupervisorConfig
668
+
669
+ target = Path(file_path)
670
+ try:
671
+ file_content = target.read_text()
672
+ except Exception as e:
673
+ if json_output:
674
+ click.echo(json.dumps({"error": str(e), "passed": False}))
675
+ else:
676
+ console.print(f"[red]Error:[/red] Failed to read {display_path(file_path)}: {e}")
677
+ sys.exit(2)
678
+
679
+ cwd = Path.cwd().resolve()
680
+ try:
681
+ target_path = str(target.resolve().relative_to(cwd))
682
+ except ValueError:
683
+ target_path = str(target)
684
+
685
+ config = SupervisorConfig(
686
+ resume_id=resume_id,
687
+ proxy=proxy_name,
688
+ direct=direct,
689
+ timeout_seconds=timeout,
690
+ fork_session=True,
691
+ )
692
+
693
+ context = ActionContext(
694
+ event="OnDemand.Supervisor",
695
+ tool_name="Write",
696
+ tool_args={"file_path": file_path, "content": file_content[:200]},
697
+ repo_root=str(cwd),
698
+ session_name="on-demand",
699
+ target_path=target_path,
700
+ new_content=file_content[:5000],
701
+ )
702
+
703
+ try:
704
+ decision = invoke_supervisor(config, context, intent=SUPERVISOR_INTENT)
705
+ except Exception as e:
706
+ if json_output:
707
+ click.echo(json.dumps({"error": str(e), "passed": False}))
708
+ else:
709
+ console.print(f"[red]Error:[/red] Supervisor invocation failed: {e}")
710
+ sys.exit(2)
711
+
712
+ # Detect infra failures hidden behind fail-open allow decisions
713
+ infra_failure = decision.decision == "allow" and any(
714
+ w.startswith(prefix) for w in (decision.warnings or []) for prefix in _INFRA_FAILURE_PREFIXES
715
+ )
716
+
717
+ if infra_failure:
718
+ passed = False
719
+ exit_code = 2
720
+ elif decision.decision == "deny":
721
+ passed = False
722
+ exit_code = 1
723
+ else:
724
+ passed = True
725
+ exit_code = 0
726
+
727
+ if json_output:
728
+ violations_list = []
729
+ for v in decision.violations:
730
+ v_entry: dict[str, str | None] = {
731
+ "rule_id": v.rule_id,
732
+ "severity": v.severity,
733
+ "message": v.message,
734
+ "evidence": v.evidence,
735
+ "suggested_fix": v.suggested_fix,
736
+ }
737
+ if decision.intent:
738
+ v_entry["intent"] = decision.intent
739
+ violations_list.append(v_entry)
740
+ output = {
741
+ "passed": passed,
742
+ "clean": decision.decision == "allow" and not infra_failure,
743
+ "final_decision": decision.decision if not infra_failure else "error",
744
+ "policy_id": decision.policy_id,
745
+ "violations": violations_list,
746
+ "warnings": decision.warnings or [],
747
+ }
748
+ click.echo(json.dumps(output, indent=2))
749
+ else:
750
+ if exit_code == 0:
751
+ if decision.decision == "allow":
752
+ console.print("[green]Aligned with plan[/green]")
753
+ else:
754
+ console.print("[yellow]Aligned with warnings[/yellow]")
755
+ for w in decision.warnings or []:
756
+ console.print(f" ⚠︎ {w}", style="yellow")
757
+ elif exit_code == 1:
758
+ console.print("[red]Divergent from plan[/red]")
759
+ for w in decision.warnings or []:
760
+ console.print(f" [red]{w}[/red]")
761
+ else:
762
+ console.print("[red]Could not evaluate[/red]")
763
+ for w in decision.warnings or []:
764
+ console.print(f" [dim]{w}[/dim]")
765
+
766
+ sys.exit(exit_code)
767
+
768
+
769
+ @guard.command(name="supervise")
770
+ @click.argument("target", required=False)
771
+ @click.option("--off", is_flag=True, help="Suspend supervisor (preserves config)")
772
+ @click.option("--on", "on_flag", is_flag=True, help="Resume suspended supervisor")
773
+ @click.option("--remove", is_flag=True, help="Remove supervisor configuration entirely")
774
+ @click.option("--reload", "reload_auto", is_flag=True, help="Reload latest relevant approved plan")
775
+ @click.option("--reload-from", "reload_path", default=None, help="Reload plan from explicit file path")
776
+ @click.option("--session", "-s", "session_name", help="Target session (default: auto-detect)")
777
+ @click.option("--supervisor-proxy", type=str, default=None, help="Proxy for supervisor routing (proxy_id or template)")
778
+ @click.option(
779
+ "--no-supervisor-proxy",
780
+ "supervisor_direct",
781
+ is_flag=True,
782
+ default=False,
783
+ help="Force supervisor to use direct Anthropic routing",
784
+ )
785
+ def supervise_cmd(
786
+ target: str | None,
787
+ off: bool,
788
+ on_flag: bool,
789
+ remove: bool,
790
+ reload_auto: bool,
791
+ reload_path: str | None,
792
+ session_name: str | None,
793
+ supervisor_proxy: str | None,
794
+ supervisor_direct: bool,
795
+ ) -> None:
796
+ """Configure the semantic supervisor for the current session.
797
+
798
+ Sets durable plan supervision that persists through session resume.
799
+ Use 'forge guard supervisor' for one-shot file evaluation instead.
800
+
801
+ \b
802
+ Examples:
803
+ forge guard supervise planner # Set planner as supervisor
804
+ forge guard supervise --off # Suspend (preserves config)
805
+ forge guard supervise --on # Resume
806
+ forge guard supervise --remove # Remove entirely
807
+ forge guard supervise --reload # Reload latest relevant approved plan
808
+ forge guard supervise --reload-from p # Reload plan from explicit file
809
+ forge guard supervise # Show current config
810
+ """
811
+ if supervisor_proxy and supervisor_direct:
812
+ console.print("[red]Error:[/red] --supervisor-proxy and --no-supervisor-proxy are mutually exclusive")
813
+ sys.exit(1)
814
+ if (supervisor_proxy or supervisor_direct) and not target:
815
+ console.print("[red]Error:[/red] --supervisor-proxy/--no-supervisor-proxy require a target argument")
816
+ sys.exit(1)
817
+ actions = sum([bool(off), bool(on_flag), bool(remove), bool(reload_auto), bool(reload_path), bool(target)])
818
+ if actions > 1:
819
+ console.print(
820
+ "[red]Error:[/red] Specify only one action (target, --off, --on, --remove, --reload, --reload-from)"
821
+ )
822
+ sys.exit(1)
823
+ cwd = Path.cwd().resolve()
824
+ name = session_name or _resolve_session_name(cwd)
825
+ if not name:
826
+ console.print("[red]Error:[/red] No session found. Start or specify one with --session.")
827
+ sys.exit(1)
828
+
829
+ from forge.session.exceptions import ForgeSessionError
830
+
831
+ if session_name:
832
+ try:
833
+ store, _ = _resolve_session_for_display(name, cwd)
834
+ except ForgeSessionError as e:
835
+ console.print(f"[red]Error:[/red] Session '{name}' not found: {e}")
836
+ sys.exit(1)
837
+ else:
838
+ store = SessionStore(_resolve_forge_root(cwd), name)
839
+
840
+ try:
841
+ store.read()
842
+ except (ForgeSessionError, FileNotFoundError):
843
+ console.print(f"[red]Error:[/red] Session '{name}' not found")
844
+ sys.exit(1)
845
+
846
+ if off:
847
+ manifest = store.read()
848
+ has_sup = (
849
+ manifest.intent.policy and manifest.intent.policy.supervisor and manifest.intent.policy.supervisor.resume_id
850
+ )
851
+ if not has_sup:
852
+ console.print("No supervisor configured.")
853
+ return
854
+
855
+ def _suspend(m: SessionState) -> None:
856
+ if m.intent.policy and m.intent.policy.supervisor:
857
+ m.intent.policy.supervisor.suspended = True
858
+
859
+ store.update(timeout_s=5.0, mutate=_suspend)
860
+ console.print(f"Supervisor suspended for session [cyan]{name}[/cyan]")
861
+ console.print("[dim]Tip: Use --on to resume, --remove to delete.[/dim]")
862
+ return
863
+
864
+ if on_flag:
865
+ manifest = store.read()
866
+ has_sup = (
867
+ manifest.intent.policy and manifest.intent.policy.supervisor and manifest.intent.policy.supervisor.resume_id
868
+ )
869
+ if not has_sup:
870
+ console.print("No supervisor configured. Use 'forge guard supervise <target>' to set one.")
871
+ return
872
+
873
+ def _resume_sup(m: SessionState) -> None:
874
+ if m.intent.policy and m.intent.policy.supervisor:
875
+ m.intent.policy.supervisor.suspended = False
876
+
877
+ store.update(timeout_s=5.0, mutate=_resume_sup)
878
+ console.print(f"Supervisor resumed for session [cyan]{name}[/cyan]")
879
+ return
880
+
881
+ if remove:
882
+ manifest = store.read()
883
+ has_sup = manifest.intent.policy and manifest.intent.policy.supervisor
884
+ if not has_sup:
885
+ console.print("No supervisor configured.")
886
+ return
887
+
888
+ def _remove_sup(m: SessionState) -> None:
889
+ if m.intent.policy and m.intent.policy.supervisor:
890
+ m.intent.policy.supervisor = None
891
+
892
+ store.update(timeout_s=5.0, mutate=_remove_sup)
893
+ console.print(f"Supervisor removed from session [cyan]{name}[/cyan]")
894
+ return
895
+
896
+ if reload_auto or reload_path:
897
+ manifest = store.read()
898
+ effective = compute_effective_intent(manifest)
899
+ if not effective.policy or not effective.policy.supervisor or not effective.policy.supervisor.resume_id:
900
+ console.print("[red]Error:[/red] No supervisor configured.")
901
+ sys.exit(1)
902
+
903
+ if reload_path:
904
+ resolved = Path(reload_path)
905
+ if not resolved.is_absolute():
906
+ resolved = (cwd / resolved).resolve()
907
+ if not resolved.is_file():
908
+ console.print(f"[red]Error:[/red] Plan file not found: {resolved}")
909
+ sys.exit(1)
910
+ plan_path = str(resolved)
911
+ source_desc = str(resolved)
912
+ else:
913
+ from forge.guard.semantic.supervisor import (
914
+ resolve_supervisor_reload_plan_path,
915
+ )
916
+
917
+ result = resolve_supervisor_reload_plan_path(effective.policy.supervisor, manifest)
918
+ if result is None:
919
+ console.print("[red]Error:[/red] No approved plan found for supervisor target or related sessions.")
920
+ sys.exit(1)
921
+ plan_path = result.path
922
+ source_map = {
923
+ "self": "current session",
924
+ "fork": f"review fork '{result.session_name}'",
925
+ "target": "supervisor target",
926
+ }
927
+ source_desc = source_map.get(result.source, result.source)
928
+
929
+ def _set_plan(m: SessionState) -> None:
930
+ if m.intent.policy and m.intent.policy.supervisor:
931
+ m.intent.policy.supervisor.plan_override_path = plan_path
932
+
933
+ store.update(timeout_s=5.0, mutate=_set_plan)
934
+ console.print(f"Supervisor plan updated from {source_desc}")
935
+ return
936
+
937
+ if target:
938
+ from forge.guard.semantic.supervisor import (
939
+ apply_supervisor_routing,
940
+ apply_supervisor_to_intent,
941
+ preflight_supervisor_proxy,
942
+ validate_supervisor_target,
943
+ )
944
+ from forge.session.models import SupervisorConfig
945
+
946
+ if supervisor_proxy:
947
+ try:
948
+ supervisor_proxy = preflight_supervisor_proxy(supervisor_proxy)
949
+ except ValueError as e:
950
+ console.print(f"[red]Error:[/red] {e}")
951
+ sys.exit(1)
952
+
953
+ manifest = store.read()
954
+ # Validate supervisor target in the selected session's scope, not CWD.
955
+ # When --session points to a cross-worktree session, _resolve_forge_root(cwd)
956
+ # would search the wrong project.
957
+ _guard_fr = manifest.forge_root or _resolve_forge_root(cwd)
958
+ try:
959
+ source_state = validate_supervisor_target(target, forge_root=_guard_fr)
960
+ except ValueError as e:
961
+ console.print(f"[red]Error:[/red] {e}")
962
+ sys.exit(1)
963
+ current_template = manifest.intent.proxy.template if manifest.intent.proxy else None
964
+ current_proxy_id = None
965
+ if manifest.intent.proxy and hasattr(manifest.intent.proxy, "proxy_id"):
966
+ current_proxy_id = manifest.intent.proxy.proxy_id # type: ignore[union-attr]
967
+ current_direct = not bool(manifest.intent.proxy)
968
+
969
+ sup_config = SupervisorConfig(resume_id=target, forge_root=source_state.forge_root or _guard_fr)
970
+ routing_display = apply_supervisor_routing(
971
+ sup_config,
972
+ source_state,
973
+ supervisor_proxy=supervisor_proxy,
974
+ supervisor_direct=supervisor_direct,
975
+ current_proxy_id=current_proxy_id,
976
+ current_template=current_template,
977
+ current_direct=current_direct,
978
+ )
979
+
980
+ store.update(timeout_s=5.0, mutate=lambda m: apply_supervisor_to_intent(m, sup_config))
981
+ console.print(f"Supervisor set to [green]{target}[/green] for session [cyan]{name}[/cyan]")
982
+ if routing_display:
983
+ label = "auto-seeded" if not supervisor_proxy and not supervisor_direct else "explicit"
984
+ console.print(f" Routing ({label}): {routing_display}")
985
+ return
986
+
987
+ # No args: show current supervisor config
988
+ manifest = store.read()
989
+ effective = compute_effective_intent(manifest)
990
+
991
+ if not effective.policy or not effective.policy.supervisor or not effective.policy.supervisor.resume_id:
992
+ console.print("No supervisor configured.")
993
+ return
994
+
995
+ sup = effective.policy.supervisor
996
+ assert sup.resume_id is not None # guarded above
997
+ console.print(f"Supervisor: [green]{sup.resume_id}[/green]")
998
+ if sup.suspended:
999
+ console.print(" Status: [yellow]suspended[/yellow]")
1000
+
1001
+ target_state = read_scoped_supervisor_target(sup.resume_id, sup.forge_root, manifest.forge_root)
1002
+ if target_state is not None:
1003
+ uuid = target_state.confirmed.claude_session_id
1004
+ if uuid:
1005
+ console.print(f" Claude UUID: {uuid[:16]}...")
1006
+ swp = target_state.confirmed.started_with_proxy
1007
+ if swp and swp.template:
1008
+ console.print(f" Source model: {swp.template}")
1009
+
1010
+ if sup.proxy:
1011
+ console.print(f" Routing: proxy: {sup.proxy}")
1012
+ elif sup.direct:
1013
+ console.print(" Routing: direct (no proxy)")
1014
+ console.print(f" Fork session: {'yes' if sup.fork_session else 'no'}")
1015
+ console.print(f" Timeout: {sup.timeout_seconds}s")
1016
+ console.print(f" Throttle: {sup.throttle_seconds}s")
1017
+ if sup.plan_override_path:
1018
+ console.print(f" Plan override: {sup.plan_override_path}")