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,1304 @@
1
+ """Direct command (%) dispatcher and handlers for UserPromptSubmit hook."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import shlex
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import click
13
+
14
+ from forge.core.paths import display_path
15
+ from forge.core.state import FileLockTimeoutError
16
+ from forge.session import set_override
17
+ from forge.session.effective import compute_effective_intent
18
+ from forge.session.hooks import resolve_session_store
19
+ from forge.session.models import SessionState
20
+ from forge.session.store import HOOK_LOCK_TIMEOUT_S
21
+
22
+ from ._helpers import _output_json
23
+
24
+
25
+ def _parse_direct_command(prompt: str) -> tuple[str, list[str]] | None:
26
+ """Parse `%<cmd> [subcmd] [args...]` direct command.
27
+
28
+ This intentionally feels like a tiny CLI:
29
+
30
+ - supports quoted args via shell-like parsing (shlex)
31
+ - returns (cmd, argv) where cmd does NOT include the `%`
32
+
33
+ Examples:
34
+ - `%help`
35
+ - `%session list`
36
+ - `%guard enable tdd`
37
+ - `%proxy show my-proxy`
38
+ """
39
+
40
+ s = prompt.strip()
41
+ if not s.startswith("%"):
42
+ return None
43
+
44
+ try:
45
+ parts = shlex.split(s[1:])
46
+ except ValueError:
47
+ # Unbalanced quotes, etc.
48
+ return None
49
+
50
+ if not parts:
51
+ return None
52
+
53
+ cmd = parts[0].strip().lower()
54
+ argv = [p for p in parts[1:]]
55
+ if not cmd:
56
+ return None
57
+
58
+ return cmd, argv
59
+
60
+
61
+ def _handle_cmd_help() -> None:
62
+ """Print a short help message for direct commands."""
63
+
64
+ click.echo(
65
+ json.dumps(
66
+ {
67
+ "decision": "block",
68
+ "reason": "Direct commands:\n"
69
+ "- %session show [name] | list\n"
70
+ "- %proxy list | show <id>\n"
71
+ "- %clean [--scope repo|project|all]\n"
72
+ "- %plan\n"
73
+ "- %config (show runtime config)\n"
74
+ "- %guard status | enable | disable | check\n"
75
+ "- %cancel-verification (bypass verification loop)\n"
76
+ "- %h/%help\n"
77
+ "\n"
78
+ "Tip: Use /copy to copy assistant responses (built-in).",
79
+ }
80
+ )
81
+ )
82
+
83
+
84
+ def _handle_cmd_session(data: dict[str, Any], argv: list[str]) -> None:
85
+ """Handle `%session ...` commands (mirrors CLI syntax).
86
+
87
+ Supported:
88
+
89
+ - `%session list` (optionally: `--no-incognito` / `--include-incognito`)
90
+ - `%session show [name]` (default: current session from FORGE_SESSION)
91
+
92
+ Always emits `{decision:block}` when handled.
93
+ """
94
+
95
+ if not argv:
96
+ click.echo(json.dumps({"decision": "block", "reason": "Usage: %session list | show [name]"}))
97
+ return
98
+
99
+ sub = argv[0].lower()
100
+ if sub == "show":
101
+ _handle_session_show(argv[1:])
102
+ return
103
+ if sub != "list":
104
+ click.echo(json.dumps({"decision": "block", "reason": "Usage: %session list | show [name]"}))
105
+ return
106
+
107
+ include_incognito = True
108
+ if "--no-incognito" in argv:
109
+ include_incognito = False
110
+
111
+ # Parse --scope VALUE or --scope=VALUE (default: repo)
112
+ scope = "repo"
113
+ for i, arg in enumerate(argv):
114
+ if arg.startswith("--scope="):
115
+ scope = arg.split("=", 1)[1].lower()
116
+ break
117
+ if arg == "--scope" and i + 1 < len(argv):
118
+ scope = argv[i + 1].lower()
119
+ break
120
+
121
+ from forge.core.ops.context import ExecutionContext
122
+ from forge.core.ops.session import ForgeOpError
123
+ from forge.core.ops.session import list_sessions as list_sessions_op
124
+
125
+ ctx = ExecutionContext.from_cwd()
126
+
127
+ try:
128
+ result = list_sessions_op(ctx=ctx, include_incognito=include_incognito, scope=scope)
129
+ except ForgeOpError as e:
130
+ click.echo(json.dumps({"decision": "block", "reason": f"Error: {e}"}))
131
+ return
132
+
133
+ if not result.sessions:
134
+ click.echo(json.dumps({"decision": "block", "reason": "No sessions found."}))
135
+ return
136
+
137
+ lines = ["Sessions:"]
138
+ for item in result.sessions:
139
+ template = item.proxy_template or "-"
140
+ lines.append(f" {item.name} ({template})")
141
+
142
+ click.echo(json.dumps({"decision": "block", "reason": "\n".join(lines)}))
143
+
144
+
145
+ def _handle_session_show(argv: list[str]) -> None:
146
+ """Handle `%session show [name]`.
147
+
148
+ Default (no name): use FORGE_SESSION env var only — no active-session
149
+ fallback to avoid cross-repo ambiguity.
150
+ """
151
+ from forge.core.ops.session_context import SessionContextError, get_session_context
152
+
153
+ # Explicit name or FORGE_SESSION env var (no active-session fallback)
154
+ session_id: str | None = argv[0] if argv else os.environ.get("FORGE_SESSION")
155
+ if not session_id:
156
+ click.echo(
157
+ json.dumps(
158
+ {
159
+ "decision": "block",
160
+ "reason": "No active session (bare launch).\n"
161
+ "Tip: Use 'forge session start' for managed sessions,\n"
162
+ "or '%session show <name>' to inspect a specific session.",
163
+ }
164
+ )
165
+ )
166
+ return
167
+
168
+ try:
169
+ ctx = get_session_context(session_id)
170
+ except SessionContextError as e:
171
+ click.echo(json.dumps({"decision": "block", "reason": f"Error: {e}"}))
172
+ return
173
+
174
+ lines = [f"Session: {ctx.session_name}"]
175
+ if ctx.claude_session_id:
176
+ lines.append(f" UUID: {ctx.claude_session_id}")
177
+ if ctx.parent_session:
178
+ lines.append(f" Parent: {ctx.parent_session}")
179
+ if ctx.is_fork:
180
+ lines.append(" Type: fork")
181
+ if ctx.proxy.template:
182
+ lines.append(f" Template: {ctx.proxy.template}")
183
+ if ctx.proxy.base_url:
184
+ lines.append(f" Base URL: {ctx.proxy.base_url}")
185
+ if ctx.worktree_path:
186
+ lines.append(f" Worktree: {display_path(ctx.worktree_path)}")
187
+ if ctx.model_family != "anthropic":
188
+ lines.append(f" Family: {ctx.model_family}")
189
+ if ctx.models:
190
+ tier_str = ", ".join(f"{t}={m}" for t, m in sorted(ctx.models.items()))
191
+ lines.append(f" Models: {tier_str}")
192
+
193
+ click.echo(json.dumps({"decision": "block", "reason": "\n".join(lines)}))
194
+
195
+
196
+ def _handle_cmd_proxy(data: dict[str, Any], argv: list[str]) -> None:
197
+ """Handle `%proxy ...` commands (mirrors CLI syntax, read-only).
198
+
199
+ Supported:
200
+
201
+ - `%proxy list`: list all registered proxies
202
+ - `%proxy show <id>`: show details for a specific proxy
203
+
204
+ Always emits `{decision:block}` when handled.
205
+
206
+ Note: Proxy mutations require terminal (`forge proxy ...`), not direct commands.
207
+ """
208
+
209
+ if not argv:
210
+ click.echo(json.dumps({"decision": "block", "reason": "Usage: %proxy list | show <id>"}))
211
+ return
212
+
213
+ sub = argv[0].lower()
214
+
215
+ if sub == "list":
216
+ _handle_proxy_list()
217
+ return
218
+
219
+ if sub == "show":
220
+ if len(argv) < 2:
221
+ click.echo(json.dumps({"decision": "block", "reason": "Usage: %proxy show <id>"}))
222
+ return
223
+ proxy_id = argv[1]
224
+ _handle_proxy_show(proxy_id)
225
+ return
226
+
227
+ click.echo(json.dumps({"decision": "block", "reason": "Usage: %proxy list | show <id>"}))
228
+
229
+
230
+ def _handle_proxy_list() -> None:
231
+ """List all registered proxies."""
232
+ from forge.core.ops.context import ExecutionContext
233
+ from forge.core.ops.proxy import list_proxies as list_proxies_op
234
+ from forge.core.ops.session import ForgeOpError
235
+
236
+ ctx = ExecutionContext.from_cwd()
237
+
238
+ try:
239
+ result = list_proxies_op(ctx=ctx)
240
+ except ForgeOpError as e:
241
+ click.echo(json.dumps({"decision": "block", "reason": f"Error: {e}"}))
242
+ return
243
+
244
+ if not result.proxies:
245
+ click.echo(json.dumps({"decision": "block", "reason": "No proxies found."}))
246
+ return
247
+
248
+ lines = ["Proxies:"]
249
+ for item in result.proxies:
250
+ status = item.entry.status or "unknown"
251
+ template = item.entry.template or "-"
252
+ port = item.entry.port or "-"
253
+ lines.append(f" {item.proxy_id} {template} :{port} ({status})")
254
+
255
+ click.echo(json.dumps({"decision": "block", "reason": "\n".join(lines)}))
256
+
257
+
258
+ def _handle_proxy_show(proxy_id: str) -> None:
259
+ """Show details for a specific proxy."""
260
+ from forge.core.ops.context import ExecutionContext
261
+ from forge.core.ops.proxy import show_proxy as show_proxy_op
262
+ from forge.core.ops.session import ForgeOpError
263
+
264
+ ctx = ExecutionContext.from_cwd()
265
+
266
+ try:
267
+ result = show_proxy_op(ctx=ctx, proxy_id=proxy_id)
268
+ except ForgeOpError as e:
269
+ click.echo(json.dumps({"decision": "block", "reason": f"Error: {e}"}))
270
+ return
271
+
272
+ lines = [f"Proxy: {result.proxy_id}"]
273
+ if result.entry:
274
+ lines.append(f" Template: {result.entry.template}")
275
+ lines.append(f" Base URL: {result.entry.base_url}")
276
+ lines.append(f" Port: {result.entry.port}")
277
+ lines.append(f" Status: {result.entry.status or 'unknown'}")
278
+ else:
279
+ lines.append(" (not in registry — config file only)")
280
+
281
+ if result.config:
282
+ lines.append(f" Provider: {result.config.provider}")
283
+ lines.append(f" Default tier: {result.config.default_tier}")
284
+ if result.config.tiers:
285
+ lines.append(" Tiers:")
286
+ # TierModels is a dataclass with haiku/sonnet/opus attributes
287
+ for tier in ("haiku", "sonnet", "opus"):
288
+ model = getattr(result.config.tiers, tier, "")
289
+ if model:
290
+ lines.append(f" {tier}: {model}")
291
+
292
+ click.echo(json.dumps({"decision": "block", "reason": "\n".join(lines)}))
293
+
294
+
295
+ def _handle_cmd_plan(argv: list[str]) -> None:
296
+ """Handle `%plan` (show the plan file for this session or its immediate parent)."""
297
+ if argv:
298
+ click.echo(json.dumps({"decision": "block", "reason": "Usage: %plan"}))
299
+ return
300
+
301
+ store = resolve_session_store(Path.cwd().resolve())
302
+ if store is None:
303
+ click.echo(json.dumps({"decision": "block", "reason": "No session found"}))
304
+ return
305
+
306
+ try:
307
+ manifest = store.read()
308
+ except Exception as e:
309
+ click.echo(json.dumps({"decision": "block", "reason": f"Error reading session: {e}"}))
310
+ return
311
+
312
+ from forge.session.plan_resolution import (
313
+ resolve_displayed_plan_path,
314
+ resolve_plan_info,
315
+ resolve_plan_launch_root,
316
+ )
317
+
318
+ plan_info = resolve_plan_info(manifest, current_forge_root=str(store.forge_root))
319
+ displayed = resolve_displayed_plan_path(
320
+ plan_info,
321
+ current_forge_root=str(store.forge_root),
322
+ current_launch_root=resolve_plan_launch_root(manifest),
323
+ )
324
+
325
+ if displayed is None or plan_info.source is None:
326
+ click.echo(
327
+ json.dumps(
328
+ {
329
+ "decision": "block",
330
+ "reason": "No plan file recorded for this session or its ancestry",
331
+ }
332
+ )
333
+ )
334
+ return
335
+
336
+ missing = "" if displayed.exists else " (file missing)"
337
+
338
+ if plan_info.approved_snapshots:
339
+ if plan_info.source == "parent":
340
+ reason = (
341
+ f"Approved plan (snapshot, from '{plan_info.parent_session}'): "
342
+ f"{display_path(displayed.path)}{missing}"
343
+ )
344
+ else:
345
+ reason = f"Approved plan (snapshot): {display_path(displayed.path)}{missing}"
346
+ else:
347
+ if plan_info.source == "parent":
348
+ reason = f"Plan (draft, from '{plan_info.parent_session}'): " f"{display_path(displayed.path)}{missing}"
349
+ else:
350
+ reason = f"Plan (draft): {display_path(displayed.path)}{missing}"
351
+
352
+ click.echo(json.dumps({"decision": "block", "reason": reason}))
353
+
354
+
355
+ def _handle_cmd_config(data: dict[str, Any], argv: list[str]) -> None:
356
+ """Handle `%config` command (read-only — shows effective runtime config).
357
+
358
+ No mutations from inside a session (matching %proxy policy).
359
+ """
360
+ from dataclasses import fields as dc_fields
361
+
362
+ from forge.runtime_config import RuntimeConfig, get_config_path, load_runtime_config
363
+
364
+ rc = load_runtime_config()
365
+ config_path = get_config_path()
366
+ env_sources: dict[str, str] = getattr(rc, "_env_sources", {})
367
+
368
+ lines = ["Forge Runtime Config:"]
369
+ if config_path.is_file():
370
+ lines.append(f" Path: {display_path(config_path)}")
371
+ else:
372
+ lines.append(" Path: (no file — using defaults)")
373
+
374
+ for f in dc_fields(RuntimeConfig):
375
+ val = getattr(rc, f.name)
376
+ env_var = env_sources.get(f.name)
377
+ if env_var:
378
+ lines.append(f" {f.name}: {val} (from {env_var})")
379
+ else:
380
+ lines.append(f" {f.name}: {val}")
381
+
382
+ click.echo(json.dumps({"decision": "block", "reason": "\n".join(lines)}))
383
+
384
+
385
+ def _handle_cmd_guard(data: dict[str, Any], argv: list[str]) -> None:
386
+ """Handle `%guard ...` commands (mirrors CLI syntax).
387
+
388
+ Supported:
389
+
390
+ - `%guard status`: show policy configuration and state
391
+ - `%guard enable --bundle tdd`: enable with specified bundles
392
+ - `%guard disable`: disable policy enforcement
393
+ - `%guard check [--staged] [--bundle tdd]`: evaluate git diff against policies
394
+
395
+ Always emits `{decision:block}` when handled.
396
+ """
397
+ if not argv:
398
+ click.echo(
399
+ json.dumps(
400
+ {
401
+ "decision": "block",
402
+ "reason": "Usage: %guard status | enable | disable | check | supervise",
403
+ }
404
+ )
405
+ )
406
+ return
407
+
408
+ sub = argv[0].lower()
409
+
410
+ if sub == "status":
411
+ _handle_guard_status()
412
+ return
413
+
414
+ if sub == "enable":
415
+ _handle_guard_enable(argv[1:])
416
+ return
417
+
418
+ if sub == "disable":
419
+ _handle_guard_disable()
420
+ return
421
+
422
+ if sub == "check":
423
+ _handle_guard_check(argv[1:])
424
+ return
425
+
426
+ if sub == "supervise":
427
+ _handle_guard_supervise(argv[1:])
428
+ return
429
+
430
+ click.echo(
431
+ json.dumps({"decision": "block", "reason": "Usage: %guard status | enable | disable | check | supervise"})
432
+ )
433
+
434
+
435
+ def _handle_guard_status() -> None:
436
+ """Show policy configuration and state."""
437
+ cwd = Path.cwd().resolve()
438
+ store = resolve_session_store(cwd)
439
+ if store is None:
440
+ _output_json({"success": True, "action": "skip", "reason": "no_session"})
441
+ return
442
+
443
+ try:
444
+ manifest = store.read()
445
+ except Exception:
446
+ click.echo(json.dumps({"decision": "block", "reason": "No session found"}))
447
+ return
448
+
449
+ from forge.session.effective import compute_effective_intent
450
+
451
+ try:
452
+ effective = compute_effective_intent(manifest)
453
+ except Exception as e:
454
+ click.echo(json.dumps({"decision": "block", "reason": f"Error: {e}"}))
455
+ return
456
+
457
+ lines = [f"Policy Status: {manifest.name}"]
458
+
459
+ if effective.policy:
460
+ lines.append(f" Enabled: {'Yes' if effective.policy.enabled else 'No'}")
461
+ lines.append(f" Fail Mode: {effective.policy.fail_mode or 'open'}")
462
+ bundles = ", ".join(effective.policy.bundles) if effective.policy.bundles else "None"
463
+ lines.append(f" Bundles: {bundles}")
464
+ if effective.policy.bundle_config:
465
+ for bundle, cfg in effective.policy.bundle_config.items():
466
+ cfg_str = ", ".join(f"{k}={v}" for k, v in cfg.items())
467
+ lines.append(f" {bundle}: {cfg_str}")
468
+
469
+ if effective.policy.supervisor and effective.policy.supervisor.resume_id:
470
+ sup = effective.policy.supervisor
471
+ assert sup.resume_id is not None
472
+ sup_resume: str = sup.resume_id
473
+ lines.append(f" Supervisor: {sup_resume}")
474
+ if sup.suspended:
475
+ lines.append(" Status: suspended")
476
+ try:
477
+ from forge.guard.queries import read_scoped_supervisor_target
478
+
479
+ ts = read_scoped_supervisor_target(sup_resume, sup.forge_root, manifest.forge_root)
480
+ if ts is not None:
481
+ uuid = ts.confirmed.claude_session_id
482
+ if uuid:
483
+ lines.append(f" UUID: {uuid[:16]}...")
484
+ swp = ts.confirmed.started_with_proxy
485
+ if swp and swp.template:
486
+ lines.append(f" Source model: {swp.template}")
487
+ except Exception:
488
+ pass
489
+ if sup.proxy:
490
+ lines.append(f" Routing: proxy: {sup.proxy}")
491
+ elif sup.direct:
492
+ lines.append(" Routing: direct (no proxy)")
493
+ lines.append(f" Fork: {'yes' if sup.fork_session else 'no'}")
494
+ if sup.plan_override_path:
495
+ lines.append(f" Plan override: {sup.plan_override_path}")
496
+ else:
497
+ lines.append(" Supervisor: Not configured")
498
+ else:
499
+ lines.append(" Enabled: No (not configured)")
500
+
501
+ if manifest.confirmed.policy:
502
+ confirmed = manifest.confirmed.policy
503
+ lines.append("")
504
+ lines.append("Policy State:")
505
+ lines.append(f" Decisions Logged: {len(confirmed.decisions or [])}")
506
+ lines.append(f" Policy States: {len(confirmed.policy_states or {})}")
507
+
508
+ # Supervised-sessions tip
509
+ try:
510
+ from forge.guard.queries import find_sessions_supervised_by
511
+
512
+ supervised = find_sessions_supervised_by(
513
+ manifest.name, manifest.confirmed.claude_session_id, manifest.forge_root
514
+ )
515
+ if supervised:
516
+ names = ", ".join(supervised)
517
+ lines.append(
518
+ f"\nTip: This session supervises: {names}. " f"Check with: forge guard status --session {supervised[0]}"
519
+ )
520
+ except Exception:
521
+ pass
522
+
523
+ click.echo(json.dumps({"decision": "block", "reason": "\n".join(lines)}))
524
+
525
+
526
+ def _handle_guard_enable(argv: list[str]) -> None:
527
+ """Enable policy with specified bundles.
528
+
529
+ Uses overrides (not intent mutation) to preserve the original session baseline.
530
+ Resolves session via CWD-based hook resolution (not SessionManager/index).
531
+ """
532
+ from forge.session.models import SessionState
533
+
534
+ bundles: list[str] = []
535
+ fail_mode = "open"
536
+ permissive = False
537
+ i = 0
538
+ while i < len(argv):
539
+ arg = argv[i]
540
+ if arg in ("--bundle", "-b") and i + 1 < len(argv):
541
+ bundle = argv[i + 1]
542
+ if bundle in ("tdd", "coding_standards"):
543
+ bundles.append(bundle)
544
+ i += 2
545
+ elif arg in ("--fail-mode",) and i + 1 < len(argv):
546
+ fm = argv[i + 1]
547
+ if fm in ("open", "closed"):
548
+ fail_mode = fm
549
+ i += 2
550
+ elif arg == "--permissive":
551
+ permissive = True
552
+ i += 1
553
+ else:
554
+ # Try to interpret as a bundle name directly
555
+ if arg in ("tdd", "coding_standards"):
556
+ bundles.append(arg)
557
+ i += 1
558
+
559
+ if not bundles:
560
+ click.echo(
561
+ json.dumps(
562
+ {
563
+ "decision": "block",
564
+ "reason": "Usage: %guard enable --bundle tdd [--bundle coding_standards] [--permissive]",
565
+ }
566
+ )
567
+ )
568
+ return
569
+
570
+ bundle_config: dict[str, dict[str, object]] = {}
571
+ if permissive and "tdd" in bundles:
572
+ bundle_config["tdd"] = {"strict": False}
573
+
574
+ cwd = Path.cwd().resolve()
575
+ store = resolve_session_store(cwd)
576
+ if store is None:
577
+ _output_json({"success": True, "action": "skip", "reason": "no_session"})
578
+ return
579
+
580
+ try:
581
+ store.read() # Verify session exists
582
+ except Exception:
583
+ click.echo(json.dumps({"decision": "block", "reason": "No session found"}))
584
+ return
585
+
586
+ def _mutate(m: object) -> None:
587
+ if not isinstance(m, SessionState):
588
+ raise TypeError(f"Expected SessionState, got {type(m)}")
589
+ set_override(m.overrides, "policy.enabled", True)
590
+ set_override(m.overrides, "policy.bundles", bundles)
591
+ set_override(m.overrides, "policy.fail_mode", fail_mode)
592
+ if bundle_config:
593
+ set_override(m.overrides, "policy.bundle_config", bundle_config)
594
+
595
+ try:
596
+ store.update(timeout_s=HOOK_LOCK_TIMEOUT_S, mutate=_mutate)
597
+ except Exception as e:
598
+ click.echo(json.dumps({"decision": "block", "reason": f"Error: {e}"}))
599
+ return
600
+
601
+ mode_note = " (permissive)" if permissive else ""
602
+ click.echo(
603
+ json.dumps(
604
+ {
605
+ "decision": "block",
606
+ "reason": f"Policy enabled with bundles: {', '.join(bundles)} (fail_mode: {fail_mode}){mode_note}",
607
+ }
608
+ )
609
+ )
610
+
611
+
612
+ def _handle_guard_disable() -> None:
613
+ """Disable policy enforcement.
614
+
615
+ Uses overrides (not intent mutation) to preserve the original session baseline.
616
+ Resolves session via CWD-based hook resolution (not SessionManager/index).
617
+ """
618
+ from forge.session.models import SessionState
619
+
620
+ cwd = Path.cwd().resolve()
621
+ store = resolve_session_store(cwd)
622
+ if store is None:
623
+ _output_json({"success": True, "action": "skip", "reason": "no_session"})
624
+ return
625
+
626
+ try:
627
+ store.read() # Verify session exists
628
+ except Exception:
629
+ click.echo(json.dumps({"decision": "block", "reason": "No session found"}))
630
+ return
631
+
632
+ def _mutate(m: object) -> None:
633
+ if not isinstance(m, SessionState):
634
+ raise TypeError(f"Expected SessionState, got {type(m)}")
635
+ set_override(m.overrides, "policy.enabled", False)
636
+
637
+ try:
638
+ store.update(timeout_s=HOOK_LOCK_TIMEOUT_S, mutate=_mutate)
639
+ except Exception as e:
640
+ click.echo(json.dumps({"decision": "block", "reason": f"Error: {e}"}))
641
+ return
642
+
643
+ click.echo(json.dumps({"decision": "block", "reason": "Policy enforcement disabled"}))
644
+
645
+
646
+ def _handle_guard_supervise(argv: list[str]) -> None:
647
+ """Configure or show the semantic supervisor.
648
+
649
+ Writes to intent (not overrides) so supervisor config survives
650
+ ``resume --fresh`` which deepcopies ``intent.policy`` into child sessions.
651
+
652
+ - ``%guard supervise <target>``: set supervisor
653
+ - ``%guard supervise off``: suspend (preserves config)
654
+ - ``%guard supervise on``: resume suspended supervisor
655
+ - ``%guard supervise remove``: remove supervisor entirely
656
+ - ``%guard supervise reload [path]``: reload latest relevant approved plan
657
+ - ``%guard supervise``: show current config
658
+ """
659
+ from forge.session.models import SessionState
660
+
661
+ cwd = Path.cwd().resolve()
662
+ store = resolve_session_store(cwd)
663
+ if store is None:
664
+ _output_json({"success": True, "action": "skip", "reason": "no_session"})
665
+ return
666
+
667
+ try:
668
+ manifest = store.read()
669
+ except Exception:
670
+ click.echo(json.dumps({"decision": "block", "reason": "No session found"}))
671
+ return
672
+
673
+ cmd = argv[0].lower() if argv else ""
674
+
675
+ # %guard supervise off — suspend
676
+ if cmd == "off":
677
+ has_sup = (
678
+ manifest.intent.policy and manifest.intent.policy.supervisor and manifest.intent.policy.supervisor.resume_id
679
+ )
680
+ if not has_sup:
681
+ click.echo(json.dumps({"decision": "block", "reason": "No supervisor configured"}))
682
+ return
683
+
684
+ def _suspend(m: object) -> None:
685
+ if not isinstance(m, SessionState):
686
+ raise TypeError(f"Expected SessionState, got {type(m)}")
687
+ if m.intent.policy and m.intent.policy.supervisor:
688
+ m.intent.policy.supervisor.suspended = True
689
+
690
+ try:
691
+ store.update(timeout_s=HOOK_LOCK_TIMEOUT_S, mutate=_suspend)
692
+ except Exception as e:
693
+ click.echo(json.dumps({"decision": "block", "reason": f"Error: {e}"}))
694
+ return
695
+ click.echo(
696
+ json.dumps({"decision": "block", "reason": "Supervisor suspended (use 'on' to resume, 'remove' to delete)"})
697
+ )
698
+ return
699
+
700
+ # %guard supervise on — resume
701
+ if cmd == "on":
702
+
703
+ def _resume(m: object) -> None:
704
+ if not isinstance(m, SessionState):
705
+ raise TypeError(f"Expected SessionState, got {type(m)}")
706
+ if m.intent.policy and m.intent.policy.supervisor:
707
+ m.intent.policy.supervisor.suspended = False
708
+
709
+ has_sup = (
710
+ manifest.intent.policy and manifest.intent.policy.supervisor and manifest.intent.policy.supervisor.resume_id
711
+ )
712
+ if not has_sup:
713
+ click.echo(
714
+ json.dumps(
715
+ {
716
+ "decision": "block",
717
+ "reason": "No supervisor configured. Use '%guard supervise <target>' to set one.",
718
+ }
719
+ )
720
+ )
721
+ return
722
+
723
+ try:
724
+ store.update(timeout_s=HOOK_LOCK_TIMEOUT_S, mutate=_resume)
725
+ except Exception as e:
726
+ click.echo(json.dumps({"decision": "block", "reason": f"Error: {e}"}))
727
+ return
728
+ click.echo(json.dumps({"decision": "block", "reason": "Supervisor resumed"}))
729
+ return
730
+
731
+ # %guard supervise remove — destructive
732
+ if cmd == "remove":
733
+ has_sup = manifest.intent.policy and manifest.intent.policy.supervisor
734
+ if not has_sup:
735
+ click.echo(json.dumps({"decision": "block", "reason": "No supervisor configured"}))
736
+ return
737
+
738
+ def _remove(m: object) -> None:
739
+ if not isinstance(m, SessionState):
740
+ raise TypeError(f"Expected SessionState, got {type(m)}")
741
+ if m.intent.policy and m.intent.policy.supervisor:
742
+ m.intent.policy.supervisor = None
743
+
744
+ try:
745
+ store.update(timeout_s=HOOK_LOCK_TIMEOUT_S, mutate=_remove)
746
+ except Exception as e:
747
+ click.echo(json.dumps({"decision": "block", "reason": f"Error: {e}"}))
748
+ return
749
+ click.echo(json.dumps({"decision": "block", "reason": "Supervisor removed"}))
750
+ return
751
+
752
+ # %guard supervise reload [path]
753
+ if cmd == "reload":
754
+ if len(argv) > 2:
755
+ click.echo(json.dumps({"decision": "block", "reason": "Usage: %guard supervise reload [path]"}))
756
+ return
757
+
758
+ from forge.session.effective import compute_effective_intent
759
+
760
+ effective = compute_effective_intent(manifest)
761
+ if not effective.policy or not effective.policy.supervisor or not effective.policy.supervisor.resume_id:
762
+ click.echo(json.dumps({"decision": "block", "reason": "No supervisor configured"}))
763
+ return
764
+
765
+ plan_path: str | None = None
766
+
767
+ if len(argv) == 2:
768
+ # Explicit path — resolve to absolute from CWD
769
+ resolved = Path(argv[1])
770
+ if not resolved.is_absolute():
771
+ resolved = cwd / resolved
772
+ resolved = resolved.resolve()
773
+ if not resolved.is_file():
774
+ click.echo(json.dumps({"decision": "block", "reason": f"Plan file not found: {resolved}"}))
775
+ return
776
+ plan_path = str(resolved)
777
+ source_desc = str(resolved)
778
+ else:
779
+ from forge.guard.semantic.supervisor import (
780
+ resolve_supervisor_reload_plan_path,
781
+ )
782
+
783
+ result = resolve_supervisor_reload_plan_path(effective.policy.supervisor, manifest)
784
+ if result is None:
785
+ click.echo(
786
+ json.dumps(
787
+ {
788
+ "decision": "block",
789
+ "reason": "No approved plan found for supervisor target or related sessions",
790
+ }
791
+ )
792
+ )
793
+ return
794
+ plan_path = result.path
795
+ source_map = {
796
+ "self": "current session",
797
+ "fork": f"review fork '{result.session_name}'",
798
+ "target": "supervisor target",
799
+ }
800
+ source_desc = source_map.get(result.source, result.source)
801
+
802
+ def _set_plan(m: object) -> None:
803
+ if not isinstance(m, SessionState):
804
+ raise TypeError(f"Expected SessionState, got {type(m)}")
805
+ if m.intent.policy and m.intent.policy.supervisor:
806
+ m.intent.policy.supervisor.plan_override_path = plan_path
807
+
808
+ try:
809
+ store.update(timeout_s=HOOK_LOCK_TIMEOUT_S, mutate=_set_plan)
810
+ except Exception as e:
811
+ click.echo(json.dumps({"decision": "block", "reason": f"Error: {e}"}))
812
+ return
813
+ click.echo(json.dumps({"decision": "block", "reason": f"Supervisor plan updated from {source_desc}"}))
814
+ return
815
+
816
+ # %guard supervise <target> — set supervisor
817
+ if argv:
818
+ target = argv[0]
819
+
820
+ from forge.guard.semantic.supervisor import (
821
+ apply_supervisor_to_intent,
822
+ auto_seed_supervisor_proxy,
823
+ should_supervisor_use_direct,
824
+ validate_supervisor_target,
825
+ )
826
+ from forge.session.models import SupervisorConfig
827
+
828
+ _dc_forge_root = manifest.forge_root
829
+ try:
830
+ source_state = validate_supervisor_target(target, forge_root=_dc_forge_root)
831
+ except ValueError as e:
832
+ click.echo(json.dumps({"decision": "block", "reason": str(e)}))
833
+ return
834
+
835
+ sup_config = SupervisorConfig(resume_id=target, forge_root=source_state.forge_root or _dc_forge_root)
836
+ current_template = manifest.intent.proxy.template if manifest.intent.proxy else None
837
+ current_proxy_id = None
838
+ if manifest.intent.proxy and hasattr(manifest.intent.proxy, "proxy_id"):
839
+ current_proxy_id = manifest.intent.proxy.proxy_id # type: ignore[union-attr]
840
+
841
+ seeded_proxy = auto_seed_supervisor_proxy(
842
+ source_state,
843
+ current_proxy_id=current_proxy_id,
844
+ current_template=current_template,
845
+ current_direct=not bool(manifest.intent.proxy),
846
+ )
847
+ if seeded_proxy:
848
+ sup_config.proxy = seeded_proxy
849
+ if should_supervisor_use_direct(source_state):
850
+ sup_config.direct = True
851
+
852
+ def _set(m: object) -> None:
853
+ if not isinstance(m, SessionState):
854
+ raise TypeError(f"Expected SessionState, got {type(m)}")
855
+ apply_supervisor_to_intent(m, sup_config)
856
+
857
+ try:
858
+ store.update(timeout_s=HOOK_LOCK_TIMEOUT_S, mutate=_set)
859
+ except Exception as e:
860
+ click.echo(json.dumps({"decision": "block", "reason": f"Error: {e}"}))
861
+ return
862
+
863
+ msg = f"Supervisor set to '{target}'"
864
+ if seeded_proxy:
865
+ msg += f" (proxy: {seeded_proxy})"
866
+ click.echo(json.dumps({"decision": "block", "reason": msg}))
867
+ return
868
+
869
+ # %guard supervise (no args) — show current config
870
+ from forge.session.effective import compute_effective_intent
871
+
872
+ effective = compute_effective_intent(manifest)
873
+
874
+ if not effective.policy or not effective.policy.supervisor or not effective.policy.supervisor.resume_id:
875
+ click.echo(json.dumps({"decision": "block", "reason": "No supervisor configured"}))
876
+ return
877
+
878
+ sup = effective.policy.supervisor
879
+ assert sup.resume_id is not None # guarded above
880
+ lines = [f"Supervisor: {sup.resume_id}"]
881
+ if sup.suspended:
882
+ lines.append(" Status: suspended")
883
+ try:
884
+ from forge.session.manager import SessionManager
885
+
886
+ target_state = SessionManager().get_session(sup.resume_id, forge_root=sup.forge_root or manifest.forge_root)
887
+ uuid = target_state.confirmed.claude_session_id
888
+ if uuid:
889
+ lines.append(f" UUID: {uuid[:16]}...")
890
+ except Exception:
891
+ pass
892
+ if sup.proxy:
893
+ lines.append(f" Routing: proxy: {sup.proxy}")
894
+ elif sup.direct:
895
+ lines.append(" Routing: direct (no proxy)")
896
+ lines.append(f" Fork: {'yes' if sup.fork_session else 'no'}")
897
+ lines.append(f" Timeout: {sup.timeout_seconds}s, Throttle: {sup.throttle_seconds}s")
898
+ if sup.plan_override_path:
899
+ lines.append(f" Plan override: {sup.plan_override_path}")
900
+
901
+ click.echo(json.dumps({"decision": "block", "reason": "\n".join(lines)}))
902
+
903
+
904
+ # --- %guard check helpers ---
905
+
906
+ # Primary split boundary: diff --git a/<path> b/<path>
907
+ _DIFF_GIT_HEADER_RE = re.compile(r"^diff --git a/(.+?) b/(.+?)$", re.MULTILINE)
908
+ # Fallback path extraction: +++ b/<path> (may be absent for binary files)
909
+ _DIFF_PLUS_PATH_RE = re.compile(r"^\+\+\+ b/(.+?)(?:\t.*)?$", re.MULTILINE)
910
+
911
+
912
+ def _split_diff_per_file(diff: str) -> list[tuple[str, str]]:
913
+ """Split a multi-file unified diff into (path, chunk) pairs.
914
+
915
+ Primary split is on ``diff --git`` boundaries (handles binary diffs).
916
+ Path extracted from ``diff --git a/... b/<path>``, with ``+++ b/<path>``
917
+ as fallback. Deleted files (target /dev/null) are skipped.
918
+ """
919
+ if not diff or not diff.strip():
920
+ return []
921
+
922
+ headers = list(_DIFF_GIT_HEADER_RE.finditer(diff))
923
+ if not headers:
924
+ return []
925
+
926
+ results: list[tuple[str, str]] = []
927
+ for i, match in enumerate(headers):
928
+ start = match.start()
929
+ end = headers[i + 1].start() if i + 1 < len(headers) else len(diff)
930
+ chunk = diff[start:end]
931
+
932
+ # Primary: path from diff --git header (group 2 = b/ path)
933
+ path = match.group(2).strip()
934
+
935
+ # Fallback: if diff --git path looks odd, try +++ b/
936
+ if not path:
937
+ plus_match = _DIFF_PLUS_PATH_RE.search(chunk)
938
+ if plus_match:
939
+ path = plus_match.group(1).strip()
940
+
941
+ if not path:
942
+ continue
943
+
944
+ # Skip deleted files
945
+ if path == "/dev/null":
946
+ continue
947
+ if "\n+++ /dev/null" in chunk:
948
+ continue
949
+
950
+ results.append((path, chunk))
951
+
952
+ return results
953
+
954
+
955
+ def _sort_tests_first(file_diffs: list[tuple[str, str]]) -> list[tuple[str, str]]:
956
+ """Sort file diffs so tests/ paths come before src/ paths.
957
+
958
+ Optimistic ordering for TDD stateful evaluation: test files populate
959
+ ``_tests_touched`` before implementation files are checked.
960
+ """
961
+
962
+ def _sort_key(item: tuple[str, str]) -> int:
963
+ path = item[0]
964
+ if path.startswith("tests/") or path.startswith("tests\\"):
965
+ return 0
966
+ if path.startswith("src/") or path.startswith("src\\"):
967
+ return 2
968
+ return 1
969
+
970
+ return sorted(file_diffs, key=_sort_key)
971
+
972
+
973
+ def _handle_guard_check(argv: list[str]) -> None:
974
+ """Run policy evaluation against the current git diff.
975
+
976
+ Runs ``git diff`` (or ``git diff --staged``) in-process, splits into
977
+ per-file chunks, evaluates each against policy bundles using a single
978
+ engine with tests-first ordering, and reports aggregated results.
979
+ """
980
+ import subprocess
981
+
982
+ from forge.guard.engine import build_engine
983
+ from forge.guard.types import ActionContext, extract_added_lines
984
+
985
+ bundles: list[str] = []
986
+ staged = False
987
+ i = 0
988
+ while i < len(argv):
989
+ arg = argv[i]
990
+ if arg in ("--bundle", "-b") and i + 1 < len(argv):
991
+ bundle = argv[i + 1]
992
+ if bundle in ("tdd", "coding_standards"):
993
+ bundles.append(bundle)
994
+ i += 2
995
+ elif arg == "--staged":
996
+ staged = True
997
+ i += 1
998
+ else:
999
+ # Positional bundle names
1000
+ if arg in ("tdd", "coding_standards"):
1001
+ bundles.append(arg)
1002
+ i += 1
1003
+
1004
+ cwd = Path.cwd().resolve()
1005
+ bundle_config: dict[str, dict[str, object]] = {}
1006
+
1007
+ if not bundles:
1008
+ store = resolve_session_store(cwd)
1009
+ if store is not None:
1010
+ try:
1011
+ manifest = store.read()
1012
+ effective = compute_effective_intent(manifest)
1013
+ if effective.policy and effective.policy.bundles:
1014
+ bundles = list(effective.policy.bundles)
1015
+ if effective.policy and effective.policy.bundle_config:
1016
+ bundle_config = effective.policy.bundle_config
1017
+ except Exception as e:
1018
+ click.echo(
1019
+ json.dumps(
1020
+ {
1021
+ "decision": "block",
1022
+ "passed": False,
1023
+ "reason": f"Error reading session: {e}. Use --bundle to specify bundles explicitly.",
1024
+ }
1025
+ )
1026
+ )
1027
+ return
1028
+
1029
+ if not bundles:
1030
+ click.echo(
1031
+ json.dumps(
1032
+ {
1033
+ "decision": "block",
1034
+ "passed": False,
1035
+ "reason": "No bundles configured. Use --bundle or enable via %guard enable.",
1036
+ }
1037
+ )
1038
+ )
1039
+ return
1040
+
1041
+ try:
1042
+ root_proc = subprocess.run(
1043
+ ["git", "rev-parse", "--show-toplevel"],
1044
+ capture_output=True,
1045
+ text=True,
1046
+ timeout=5,
1047
+ cwd=str(cwd),
1048
+ )
1049
+ except (FileNotFoundError, subprocess.TimeoutExpired):
1050
+ click.echo(json.dumps({"decision": "block", "passed": False, "reason": "Error: git not found or timed out"}))
1051
+ return
1052
+
1053
+ if root_proc.returncode != 0:
1054
+ msg = root_proc.stderr.strip()[:200] if root_proc.stderr else "not a git repository"
1055
+ click.echo(json.dumps({"decision": "block", "passed": False, "reason": f"Error: {msg}"}))
1056
+ return
1057
+
1058
+ repo_root = root_proc.stdout.strip()
1059
+
1060
+ git_cmd = ["git", "diff"]
1061
+ if staged:
1062
+ git_cmd.append("--staged")
1063
+
1064
+ try:
1065
+ proc = subprocess.run(git_cmd, capture_output=True, text=True, timeout=10, cwd=repo_root)
1066
+ except FileNotFoundError:
1067
+ click.echo(json.dumps({"decision": "block", "passed": False, "reason": "Error: git not found"}))
1068
+ return
1069
+ except subprocess.TimeoutExpired:
1070
+ click.echo(json.dumps({"decision": "block", "passed": False, "reason": "Error: git diff timed out"}))
1071
+ return
1072
+
1073
+ if proc.returncode != 0:
1074
+ msg = proc.stderr.strip()[:200] if proc.stderr else "unknown error"
1075
+ click.echo(json.dumps({"decision": "block", "passed": False, "reason": f"Error: git diff failed: {msg}"}))
1076
+ return
1077
+
1078
+ diff_output = proc.stdout
1079
+ if not diff_output.strip():
1080
+ label = "staged" if staged else "unstaged"
1081
+ click.echo(json.dumps({"decision": "block", "passed": True, "reason": f"No {label} changes to check."}))
1082
+ return
1083
+
1084
+ file_diffs = _split_diff_per_file(diff_output)
1085
+ if not file_diffs:
1086
+ click.echo(
1087
+ json.dumps(
1088
+ {
1089
+ "decision": "block",
1090
+ "passed": False,
1091
+ "reason": "Error: diff output present but no files could be parsed",
1092
+ }
1093
+ )
1094
+ )
1095
+ return
1096
+
1097
+ file_diffs = _sort_tests_first(file_diffs)
1098
+
1099
+ try:
1100
+ engine = build_engine(list(bundles), fail_mode="closed", bundle_config=bundle_config or None)
1101
+ except Exception as e:
1102
+ click.echo(json.dumps({"decision": "block", "passed": False, "reason": f"Error building policy engine: {e}"}))
1103
+ return
1104
+
1105
+ all_violations: list[str] = []
1106
+ all_warnings: list[str] = []
1107
+ any_deny = False
1108
+ files_checked = 0
1109
+
1110
+ for file_path, diff_chunk in file_diffs:
1111
+ added = extract_added_lines(diff_chunk) if diff_chunk else None
1112
+ context = ActionContext(
1113
+ event="OnDemand.Check",
1114
+ tool_name="Edit",
1115
+ tool_args={"file_path": file_path, "content": (added or "")[:200]},
1116
+ repo_root=repo_root,
1117
+ session_name="on-demand",
1118
+ target_path=file_path,
1119
+ new_content=added[:5000] if added else None,
1120
+ raw_diff=diff_chunk[:5000] if diff_chunk else None,
1121
+ )
1122
+
1123
+ try:
1124
+ result = engine.evaluate(context)
1125
+ except Exception as e:
1126
+ files_checked += 1
1127
+ any_deny = True
1128
+ all_violations.append(f" [engine-error] {file_path}: evaluation crashed: {e}")
1129
+ continue
1130
+
1131
+ files_checked += 1
1132
+
1133
+ if result.final_decision == "deny":
1134
+ any_deny = True
1135
+ for d in result.decisions:
1136
+ if d.decision != "deny":
1137
+ continue
1138
+ for i, v in enumerate(d.violations):
1139
+ all_violations.append(f" [{v.rule_id}] {file_path}: {v.message}")
1140
+ if d.intent and i == 0:
1141
+ all_violations.append(f" Intent: {d.intent}")
1142
+ if v.suggested_fix:
1143
+ all_violations.append(f" Fix: {v.suggested_fix}")
1144
+
1145
+ all_warnings.extend(f" {file_path}: {w}" for w in result.all_warnings)
1146
+
1147
+ passed = not any_deny
1148
+ lines: list[str] = []
1149
+ bundles_str = ", ".join(bundles)
1150
+
1151
+ if any_deny:
1152
+ lines.append(f"Policy check FAILED ({files_checked} files checked, tests-first ordering)")
1153
+ lines.append("")
1154
+ lines.append("Violations:")
1155
+ lines.extend(all_violations)
1156
+ else:
1157
+ lines.append(f"All policies passed ({files_checked} files checked, tests-first ordering)")
1158
+
1159
+ if all_warnings:
1160
+ lines.append("")
1161
+ lines.append("Warnings:")
1162
+ lines.extend(all_warnings)
1163
+
1164
+ lines.append("")
1165
+ lines.append(f"Bundles: {bundles_str}")
1166
+
1167
+ click.echo(
1168
+ json.dumps(
1169
+ {
1170
+ "decision": "block",
1171
+ "passed": passed,
1172
+ "files_checked": files_checked,
1173
+ "bundles": list(bundles),
1174
+ "reason": "\n".join(lines),
1175
+ }
1176
+ )
1177
+ )
1178
+
1179
+
1180
+ def _handle_cmd_cancel_verification() -> None:
1181
+ """Handle `%cancel-verification` command - bypass verification loop.
1182
+
1183
+ This is an escape hatch for users who are stuck in a verification loop
1184
+ (e.g., when the promise string can't be produced by the assistant).
1185
+
1186
+ Implementation:
1187
+ - Sets `verification.bypass = true` as an override (not mutating intent)
1188
+ - This preserves the original verification config while bypassing it
1189
+ - The bypass takes immediate effect on the next Stop hook invocation
1190
+
1191
+ Robustness:
1192
+ - Uses strict=False for compute_effective_intent to avoid failing on
1193
+ malformed overrides. As an escape hatch, this must work even when
1194
+ session state is broken.
1195
+ """
1196
+ cwd = Path.cwd().resolve()
1197
+ store = resolve_session_store(cwd)
1198
+ if store is None:
1199
+ _output_json({"success": True, "action": "skip", "reason": "no_session"})
1200
+ return
1201
+
1202
+ try:
1203
+ manifest = store.read()
1204
+ except Exception:
1205
+ click.echo(json.dumps({"decision": "block", "reason": "No session found"}))
1206
+ return
1207
+
1208
+ # Use strict=False: escape hatch must work even with malformed overrides
1209
+ try:
1210
+ effective = compute_effective_intent(manifest, strict=False)
1211
+ except Exception:
1212
+ # If even non-strict fails, fall back to raw intent check
1213
+ if manifest.intent.verification is None or not manifest.intent.verification.promise:
1214
+ click.echo(
1215
+ json.dumps(
1216
+ {
1217
+ "decision": "block",
1218
+ "reason": "No verification configured for this session",
1219
+ }
1220
+ )
1221
+ )
1222
+ return
1223
+ # Has verification config, proceed with bypass
1224
+ effective = None
1225
+
1226
+ # Match existing behavior contract: if no verification is configured, refuse.
1227
+ if effective is not None:
1228
+ if not effective.verification or not effective.verification.promise:
1229
+ click.echo(
1230
+ json.dumps(
1231
+ {
1232
+ "decision": "block",
1233
+ "reason": "No verification configured for this session",
1234
+ }
1235
+ )
1236
+ )
1237
+ return
1238
+
1239
+ if effective.verification.bypass:
1240
+ click.echo(json.dumps({"decision": "block", "reason": "Verification already bypassed"}))
1241
+ return
1242
+
1243
+ def _mutate(m: object) -> None:
1244
+ if not isinstance(m, SessionState):
1245
+ raise TypeError(f"Expected SessionState, got {type(m)}")
1246
+ # Use set_override to preserve original intent while bypassing
1247
+ set_override(m.overrides, "verification.bypass", True)
1248
+
1249
+ try:
1250
+ store.update(timeout_s=HOOK_LOCK_TIMEOUT_S, mutate=_mutate)
1251
+ except FileLockTimeoutError:
1252
+ click.echo(json.dumps({"decision": "block", "reason": "Session locked, try again"}))
1253
+ return
1254
+ except Exception as e:
1255
+ click.echo(json.dumps({"decision": "block", "reason": f"Error: {e}"}))
1256
+ return
1257
+
1258
+ click.echo(
1259
+ json.dumps(
1260
+ {
1261
+ "decision": "block",
1262
+ "reason": "Verification bypass enabled. Session can now exit without promise.",
1263
+ }
1264
+ )
1265
+ )
1266
+
1267
+
1268
+ def _handle_cmd_clean(argv: list[str]) -> None:
1269
+ """Handle `%clean` — read-only listing of orphans scoped to current project.
1270
+
1271
+ Always emits `{decision:block}` with the dry-run report.
1272
+ No destructive operations from within a session.
1273
+ """
1274
+ from forge.core.ops.context import ExecutionContext
1275
+ from forge.core.ops.gc import CleanError, collect_clean_report
1276
+
1277
+ scope = "project"
1278
+ for i, arg in enumerate(argv):
1279
+ if arg.startswith("--scope="):
1280
+ scope = arg.split("=", 1)[1].lower()
1281
+ break
1282
+ if arg == "--scope" and i + 1 < len(argv):
1283
+ scope = argv[i + 1].lower()
1284
+ break
1285
+
1286
+ try:
1287
+ ctx = ExecutionContext.from_cwd()
1288
+ report = collect_clean_report(ctx=ctx, scope=scope)
1289
+ except (CleanError, Exception) as e:
1290
+ click.echo(json.dumps({"decision": "block", "reason": f"Error: {e}"}))
1291
+ return
1292
+
1293
+ if report.is_clean:
1294
+ click.echo(json.dumps({"decision": "block", "reason": "Nothing to clean."}))
1295
+ return
1296
+
1297
+ lines = [f"Clean report (scope: {report.scope}):"]
1298
+ for cat in report.categories:
1299
+ if cat.count > 0:
1300
+ lines.append(f" {cat.description}: {cat.count}")
1301
+ lines.append(f"\nTotal: {report.total_count} objects")
1302
+ lines.append("\nRun `forge clean --yes` from terminal to clean.")
1303
+
1304
+ click.echo(json.dumps({"decision": "block", "reason": "\n".join(lines)}))