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,171 @@
1
+ """TDD bundle policies.
2
+
3
+ Enforces test-driven development workflow:
4
+ - tests-before-impl: Must touch tests before implementing in src/
5
+ - no-skip-tests: Blocks adding pytest.skip or similar patterns
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from forge.guard.deterministic.base import (
13
+ DeterministicPolicy,
14
+ StatefulDeterministicPolicy,
15
+ )
16
+ from forge.guard.types import ActionContext, PolicyDecision, Violation
17
+
18
+ # Patterns that indicate test skipping
19
+ SKIP_PATTERNS = [
20
+ r"pytest\.skip\(",
21
+ r"@pytest\.mark\.skip\b",
22
+ r"@pytest\.mark\.skipif\b",
23
+ r"unittest\.skip\b",
24
+ r"@unittest\.skip\b",
25
+ ]
26
+
27
+
28
+ class TDDEnforcementPolicy(StatefulDeterministicPolicy):
29
+ """Enforce that tests are touched before implementation code.
30
+
31
+ State tracking:
32
+ - When Write/Edit targets tests/, record path in tests_touched
33
+ - When Write/Edit targets src/ and tests_touched is empty, deny (or warn)
34
+
35
+ This policy is stateful because it needs to remember across hook invocations
36
+ which test files have been touched in the current session.
37
+ """
38
+
39
+ def __init__(self, *, strict: bool = True) -> None:
40
+ """Initialize the policy.
41
+
42
+ Args:
43
+ strict: If True, deny impl without tests. If False, warn only.
44
+ """
45
+ self.strict = strict
46
+ self._tests_touched: set[str] = set()
47
+
48
+ @property
49
+ def policy_id(self) -> str:
50
+ return "tdd.tests-before-impl"
51
+
52
+ @property
53
+ def description(self) -> str:
54
+ mode = "strict" if self.strict else "permissive"
55
+ return f"Require test changes before implementation changes ({mode} mode)"
56
+
57
+ @property
58
+ def intent(self) -> str:
59
+ return (
60
+ "Test-driven development: write tests first to define expected behavior, "
61
+ "then implement. This catches design issues early and ensures every change "
62
+ "has test coverage from the start."
63
+ )
64
+
65
+ def applies_to(self, context: ActionContext) -> bool:
66
+ """Apply to Write/Edit on tests/ or src/ paths."""
67
+ if context.tool_name not in ("Write", "Edit"):
68
+ return False
69
+
70
+ path = context.target_path
71
+ if path is None:
72
+ return False
73
+
74
+ # Only care about tests/ and src/ directories
75
+ return self._is_under_directory(path, "tests") or self._is_under_directory(path, "src")
76
+
77
+ def _evaluate(self, context: ActionContext) -> PolicyDecision:
78
+ """Evaluate the TDD workflow.
79
+
80
+ Logic:
81
+ 1. If writing to tests/, record the path and allow
82
+ 2. If writing to src/ and no tests touched, deny (strict) or warn (permissive)
83
+ 3. Otherwise, allow
84
+ """
85
+ path = context.target_path
86
+ if path is None:
87
+ return self._allow()
88
+
89
+ # Touching a test file - record it and allow
90
+ if self._is_under_directory(path, "tests"):
91
+ self._tests_touched.add(path)
92
+ return self._allow()
93
+
94
+ # Touching implementation - check if tests were touched first
95
+ if self._is_under_directory(path, "src"):
96
+ if not self._tests_touched:
97
+ violation = Violation(
98
+ rule_id=self.policy_id,
99
+ message="Implementation changes require test changes first",
100
+ severity="high",
101
+ evidence=f"Writing to {path} without touching any test files",
102
+ suggested_fix="Write or update tests in tests/ directory before modifying src/ code",
103
+ )
104
+
105
+ if self.strict:
106
+ return self._deny([violation])
107
+ else:
108
+ return self._warn([violation.message])
109
+
110
+ return self._allow()
111
+
112
+ def get_state(self) -> dict[str, Any]:
113
+ """Return current state for persistence."""
114
+ return {"tests_touched": list(self._tests_touched)}
115
+
116
+ def set_state(self, state: dict[str, Any]) -> None:
117
+ """Restore state from persisted data."""
118
+ self._tests_touched = set(state.get("tests_touched", []))
119
+
120
+
121
+ class NoSkipTestsPolicy(DeterministicPolicy):
122
+ """Block adding test skip patterns.
123
+
124
+ Prevents:
125
+ - pytest.skip()
126
+ - @pytest.mark.skip
127
+ - @pytest.mark.skipif
128
+ - unittest.skip
129
+ """
130
+
131
+ @property
132
+ def policy_id(self) -> str:
133
+ return "tdd.no-skip-tests"
134
+
135
+ @property
136
+ def description(self) -> str:
137
+ return "Block adding pytest.skip or similar test-skipping patterns"
138
+
139
+ @property
140
+ def intent(self) -> str:
141
+ return (
142
+ "Skipped tests hide broken functionality. Every test should either pass or "
143
+ "be deleted. If a test cannot run, fix the environment or the code rather "
144
+ "than skipping it."
145
+ )
146
+
147
+ def applies_to(self, context: ActionContext) -> bool:
148
+ """Apply to Write/Edit with content that might contain skip patterns."""
149
+ if context.tool_name not in ("Write", "Edit"):
150
+ return False
151
+
152
+ # Only check if there's content to analyze
153
+ return context.new_content is not None
154
+
155
+ def _evaluate(self, context: ActionContext) -> PolicyDecision:
156
+ """Check for skip patterns in content."""
157
+ matched = self._matches_any_pattern(context.new_content, SKIP_PATTERNS)
158
+
159
+ if matched:
160
+ violations = [
161
+ Violation(
162
+ rule_id=self.policy_id,
163
+ message="Test skip patterns are not allowed",
164
+ severity="high",
165
+ evidence=f"Found skip pattern(s): {', '.join(matched)}",
166
+ suggested_fix="Remove the skip pattern and fix the underlying issue",
167
+ )
168
+ ]
169
+ return self._deny(violations)
170
+
171
+ return self._allow()
forge/guard/engine.py ADDED
@@ -0,0 +1,216 @@
1
+ """Policy composition engine.
2
+
3
+ The PolicyEngine evaluates multiple policies against an action and
4
+ composes their decisions using the "any deny blocks" rule.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from dataclasses import dataclass, field
11
+ from typing import Any
12
+
13
+ from forge.core.state import now_iso
14
+ from forge.guard.protocols import Policy, StatefulPolicy
15
+ from forge.guard.types import (
16
+ ActionContext,
17
+ CompositeDecision,
18
+ DecisionType,
19
+ FailMode,
20
+ PolicyDecision,
21
+ )
22
+
23
+ _log = logging.getLogger(__name__)
24
+
25
+
26
+ @dataclass
27
+ class PolicyEngine:
28
+ """Composes multiple policies and produces a unified decision.
29
+
30
+ Composition rules:
31
+ - Policies are evaluated in registration order
32
+ - Any deny blocks the action (unless fail_mode is "open" and it's an error)
33
+ - needs_review is resolved by semantic supervisor when it participates
34
+ - Warnings accumulate from all policies
35
+ - State is collected from stateful policies for persistence
36
+
37
+ Attributes:
38
+ policies: List of registered policies
39
+ fail_mode: Default behavior on policy errors ("open" = allow, "closed" = deny)
40
+ """
41
+
42
+ policies: list[Policy] = field(default_factory=list)
43
+ fail_mode: FailMode = "open"
44
+
45
+ # Collected state from stateful policies (for persistence)
46
+ _collected_state: dict[str, dict[str, Any]] = field(default_factory=dict)
47
+
48
+ def register(self, policy: Policy) -> None:
49
+ """Register a policy with the engine."""
50
+ self.policies.append(policy)
51
+ _log.debug("Registered policy: %s", policy.policy_id)
52
+
53
+ def restore_state(self, persisted_state: dict[str, Any] | None) -> None:
54
+ """Restore state to all stateful policies.
55
+
56
+ Called at the start of evaluation to restore state from the session manifest.
57
+
58
+ Args:
59
+ persisted_state: Dict mapping policy_id to state dict
60
+ """
61
+ if persisted_state is None:
62
+ return
63
+
64
+ for policy in self.policies:
65
+ if isinstance(policy, StatefulPolicy):
66
+ policy_state = persisted_state.get(policy.policy_id)
67
+ if policy_state is not None:
68
+ try:
69
+ policy.set_state(policy_state)
70
+ _log.debug("Restored state for %s", policy.policy_id)
71
+ except Exception as e:
72
+ _log.warning("Failed to restore state for %s: %s", policy.policy_id, e)
73
+
74
+ def get_collected_state(self) -> dict[str, dict[str, Any]]:
75
+ """Get collected state from all stateful policies.
76
+
77
+ Called after evaluation to persist state to the session manifest.
78
+
79
+ Returns:
80
+ Dict mapping policy_id to state dict
81
+ """
82
+ return self._collected_state.copy()
83
+
84
+ def evaluate(self, context: ActionContext) -> CompositeDecision:
85
+ """Evaluate all applicable policies and compose results.
86
+
87
+ Args:
88
+ context: The action being evaluated
89
+
90
+ Returns:
91
+ CompositeDecision with:
92
+ - final_decision: allow/deny/warn/needs_review based on composition
93
+ - decisions: individual policy decisions for debugging
94
+ - blocking_violations: violations that caused deny
95
+ - all_warnings: accumulated warnings
96
+ """
97
+ decisions: list[PolicyDecision] = []
98
+ blocking_violations: list = []
99
+ all_warnings: list[str] = []
100
+ needs_review = False
101
+
102
+ for policy in self.policies:
103
+ # Check if policy applies
104
+ try:
105
+ if not policy.applies_to(context):
106
+ _log.debug(
107
+ "Policy %s does not apply to %s",
108
+ policy.policy_id,
109
+ context.tool_name,
110
+ )
111
+ continue
112
+ except Exception as e:
113
+ _log.warning("Policy %s.applies_to() failed: %s", policy.policy_id, e)
114
+ if self.fail_mode == "closed":
115
+ decisions.append(
116
+ PolicyDecision(
117
+ decision="deny",
118
+ policy_id=policy.policy_id,
119
+ warnings=[f"Policy applies_to() failed (fail-closed): {e}"],
120
+ )
121
+ )
122
+ continue
123
+
124
+ # Evaluate policy
125
+ try:
126
+ decision = policy.evaluate(context)
127
+ decision.evaluated_at = now_iso()
128
+ decisions.append(decision)
129
+
130
+ _log.debug(
131
+ "Policy %s evaluated: %s (%d violations)",
132
+ policy.policy_id,
133
+ decision.decision,
134
+ len(decision.violations),
135
+ )
136
+
137
+ except Exception as e:
138
+ _log.warning("Policy %s.evaluate() failed: %s", policy.policy_id, e)
139
+ if self.fail_mode == "open":
140
+ decisions.append(
141
+ PolicyDecision(
142
+ decision="allow",
143
+ policy_id=policy.policy_id,
144
+ warnings=[f"Policy evaluation failed (fail-open): {e}"],
145
+ )
146
+ )
147
+ else:
148
+ decisions.append(
149
+ PolicyDecision(
150
+ decision="deny",
151
+ policy_id=policy.policy_id,
152
+ warnings=[f"Policy evaluation failed (fail-closed): {e}"],
153
+ )
154
+ )
155
+ continue
156
+
157
+ # Collect state from stateful policies
158
+ if isinstance(policy, StatefulPolicy):
159
+ try:
160
+ self._collected_state[policy.policy_id] = policy.get_state()
161
+ except Exception as e:
162
+ _log.warning("Failed to get state from %s: %s", policy.policy_id, e)
163
+
164
+ # Compose decisions
165
+ final_decision: DecisionType = "allow"
166
+
167
+ for d in decisions:
168
+ all_warnings.extend(d.warnings)
169
+
170
+ if d.decision == "deny":
171
+ final_decision = "deny"
172
+ blocking_violations.extend(d.violations)
173
+ elif d.decision == "needs_review":
174
+ needs_review = True
175
+ elif d.decision == "warn" and final_decision == "allow":
176
+ final_decision = "warn"
177
+
178
+ review_resolved = any(d.policy_id == "semantic.supervisor" and d.decision != "needs_review" for d in decisions)
179
+
180
+ # If any policy needs review and no supervisor resolved it, escalate.
181
+ if needs_review and not review_resolved and final_decision not in ("deny",):
182
+ final_decision = "needs_review"
183
+
184
+ return CompositeDecision(
185
+ final_decision=final_decision,
186
+ decisions=decisions,
187
+ blocking_violations=blocking_violations,
188
+ all_warnings=all_warnings,
189
+ )
190
+
191
+
192
+ def build_engine(
193
+ bundles: list[str],
194
+ fail_mode: FailMode = "open",
195
+ bundle_config: dict[str, dict[str, Any]] | None = None,
196
+ ) -> PolicyEngine:
197
+ """Build a PolicyEngine with policies from the specified bundles.
198
+
199
+ Args:
200
+ bundles: List of bundle names (e.g., ["tdd", "coding_standards"])
201
+ fail_mode: Behavior on policy errors
202
+ bundle_config: Per-bundle configuration (e.g., {"tdd": {"strict": False}}).
203
+
204
+ Returns:
205
+ Configured PolicyEngine
206
+ """
207
+ from forge.guard.deterministic.registry import get_bundle_policies
208
+
209
+ engine = PolicyEngine(fail_mode=fail_mode)
210
+
211
+ for bundle in bundles:
212
+ config = bundle_config.get(bundle) if bundle_config else None
213
+ for policy in get_bundle_policies(bundle, config=config):
214
+ engine.register(policy)
215
+
216
+ return engine
@@ -0,0 +1,91 @@
1
+ """Policy protocol definitions.
2
+
3
+ All policies (deterministic and semantic) implement these protocols,
4
+ enabling uniform composition in the PolicyEngine.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Protocol, runtime_checkable
10
+
11
+ from forge.guard.types import ActionContext, PolicyDecision
12
+
13
+
14
+ @runtime_checkable
15
+ class Policy(Protocol):
16
+ """Interface all policies must implement.
17
+
18
+ Policies are evaluated against an ActionContext and return a PolicyDecision.
19
+ The `applies_to` method enables filtering/short-circuiting before evaluation.
20
+
21
+ Example:
22
+ class MyPolicy:
23
+ @property
24
+ def policy_id(self) -> str:
25
+ return "my-bundle.my-rule"
26
+
27
+ def applies_to(self, context: ActionContext) -> bool:
28
+ return context.tool_name == "Write"
29
+
30
+ def evaluate(self, context: ActionContext) -> PolicyDecision:
31
+ # Check something and return decision
32
+ return PolicyDecision(decision="allow", policy_id=self.policy_id)
33
+ """
34
+
35
+ @property
36
+ def policy_id(self) -> str:
37
+ """Unique identifier for this policy (e.g., 'tdd.tests-before-impl')."""
38
+ ...
39
+
40
+ @property
41
+ def description(self) -> str:
42
+ """Human-readable description of what this policy enforces."""
43
+ ...
44
+
45
+ def applies_to(self, context: ActionContext) -> bool:
46
+ """Return True if this policy should evaluate the given action.
47
+
48
+ Used for filtering/throttling before full evaluation. Policies that
49
+ don't apply to the action should return False to skip evaluation.
50
+ """
51
+ ...
52
+
53
+ def evaluate(self, context: ActionContext) -> PolicyDecision:
54
+ """Evaluate the action and return a decision.
55
+
56
+ For deterministic policies: synchronous, fast.
57
+ For semantic policies: may invoke LLM (should be throttled).
58
+ """
59
+ ...
60
+
61
+
62
+ @runtime_checkable
63
+ class StatefulPolicy(Policy, Protocol):
64
+ """Protocol for policies that track state across actions.
65
+
66
+ Stateful policies (e.g., TDD's "tests touched before impl") need to
67
+ persist state across hook invocations. Since hooks are short-lived
68
+ processes, state is persisted to the session manifest.
69
+
70
+ The PolicyEngine calls get_state() after evaluation to persist state,
71
+ and set_state() at the start to restore it.
72
+
73
+ Example:
74
+ class TDDEnforcementPolicy:
75
+ def __init__(self):
76
+ self._tests_touched: set[str] = set()
77
+
78
+ def get_state(self) -> dict[str, Any]:
79
+ return {"tests_touched": list(self._tests_touched)}
80
+
81
+ def set_state(self, state: dict[str, Any]) -> None:
82
+ self._tests_touched = set(state.get("tests_touched", []))
83
+ """
84
+
85
+ def get_state(self) -> dict[str, Any]:
86
+ """Return current policy state for persistence."""
87
+ ...
88
+
89
+ def set_state(self, state: dict[str, Any]) -> None:
90
+ """Restore policy state from persisted data."""
91
+ ...
forge/guard/queries.py ADDED
@@ -0,0 +1,96 @@
1
+ """Read-only queries about supervisor relationships and session policy state.
2
+
3
+ Used by both the CLI (``forge guard status``) and direct commands
4
+ (``%guard status``) to display supervisor metadata and discover
5
+ supervised sessions.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ from forge.session import SessionStore
13
+ from forge.session.effective import compute_effective_intent
14
+ from forge.session.models import SessionState
15
+
16
+ _UUID_RE = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
17
+
18
+
19
+ def read_scoped_supervisor_target(
20
+ resume_id: str,
21
+ supervisor_forge_root: str | None,
22
+ fallback_forge_root: str | None,
23
+ ) -> SessionState | None:
24
+ """Return supervisor target state, preferring the supervisor's stored scope.
25
+
26
+ Handles both session-name and raw-UUID resume_id values. UUIDs are
27
+ resolved via the index's reverse lookup (find_session_by_uuid).
28
+ """
29
+ try:
30
+ from forge.session.manager import SessionManager
31
+
32
+ mgr = SessionManager()
33
+ fr = supervisor_forge_root or fallback_forge_root
34
+
35
+ # Try name-based lookup first (common case)
36
+ if not _UUID_RE.fullmatch(resume_id):
37
+ return mgr.get_session(resume_id, forge_root=fr)
38
+
39
+ # UUID: reverse lookup through the index
40
+ result = mgr.index_store.find_session_by_uuid(resume_id)
41
+ if result is None:
42
+ return None
43
+ display_name, entry_fr = result
44
+ return mgr.get_session(display_name, forge_root=entry_fr)
45
+ except Exception:
46
+ return None
47
+
48
+
49
+ def find_sessions_supervised_by(
50
+ target_name: str,
51
+ target_uuid: str | None,
52
+ target_forge_root: str | None,
53
+ ) -> list[str]:
54
+ """Find repo-scoped sessions whose supervisor points to the target.
55
+
56
+ Matches on session name or Claude UUID. Verifies forge_root alignment
57
+ when set to prevent false matches from duplicate names across projects.
58
+ Best-effort: skips broken manifests, never crashes.
59
+
60
+ Cost: O(N) manifest reads where N = repo-scoped sessions. Acceptable
61
+ for typical workflows (2-10 sessions per repo).
62
+ """
63
+ try:
64
+ from forge.session.manager import SessionManager
65
+
66
+ mgr = SessionManager()
67
+ if not target_forge_root:
68
+ return []
69
+ project_root = mgr.resolve_project_root(target_forge_root)
70
+ siblings = mgr.list_sessions(project_root_filter=project_root)
71
+ except Exception:
72
+ return []
73
+
74
+ supervised: list[str] = []
75
+ for sib_name, sib_entry in siblings:
76
+ if sib_name == target_name:
77
+ continue
78
+ try:
79
+ sib_store = SessionStore(sib_entry.forge_root or sib_entry.worktree_path, sib_name)
80
+ sib_state = sib_store.read()
81
+ effective = compute_effective_intent(sib_state)
82
+ if not effective.policy or not effective.policy.supervisor:
83
+ continue
84
+ sup = effective.policy.supervisor
85
+ if not sup.resume_id:
86
+ continue
87
+ matched = sup.resume_id == target_name or (target_uuid and sup.resume_id == target_uuid)
88
+ if not matched:
89
+ continue
90
+ if sup.forge_root and target_forge_root and sup.forge_root != target_forge_root:
91
+ continue
92
+ supervised.append(sib_name)
93
+ except Exception:
94
+ continue
95
+
96
+ return supervised
@@ -0,0 +1,34 @@
1
+ """Semantic policies for the Policy Engine.
2
+
3
+ Semantic policies use LLM-based evaluation for nuanced judgment calls
4
+ that cannot be expressed as deterministic rules. The primary use case
5
+ is the Supervisor pattern:
6
+
7
+ 1. Planning session creates and approves a plan (ExitPlanMode)
8
+ 2. Session is forked and promoted to supervisor role
9
+ 3. Executor actions are validated against the plan by the supervisor
10
+ 4. Supervisor returns structured verdicts (aligned/divergent + confidence)
11
+
12
+ Throttling and caching prevent excessive LLM calls:
13
+ - Cache key: sha256(tool_name + file_path + content_hash)[:16]
14
+ - Cached verdicts reused within throttle_seconds window
15
+ - Fail-open on timeout/error (configurable)
16
+ """
17
+
18
+ from forge.guard.semantic.supervisor import (
19
+ SemanticSupervisorPolicy,
20
+ invoke_supervisor,
21
+ )
22
+ from forge.guard.semantic.verdict import (
23
+ SupervisorVerdict,
24
+ parse_supervisor_verdict,
25
+ verdict_to_decision,
26
+ )
27
+
28
+ __all__ = [
29
+ "SemanticSupervisorPolicy",
30
+ "SupervisorVerdict",
31
+ "invoke_supervisor",
32
+ "parse_supervisor_verdict",
33
+ "verdict_to_decision",
34
+ ]
@@ -0,0 +1,18 @@
1
+ """Supervisor promotion flow (deferred).
2
+
3
+ The full "CLI-Fork Supervision" pattern from design.md §4.1.2 involves:
4
+ 1. Forking the planning session via SessionManager
5
+ 2. Establishing supervisor session UUID via claude --fork-session
6
+ 3. Recording supervisor configuration in the executor session
7
+
8
+ The --fork-session flag is available since Claude Code v2.1.77+. The automated
9
+ promotion flow (creating a dedicated supervisor session) is not yet implemented.
10
+
11
+ Preferred approach (available now):
12
+ forge session fork planner --name executor --supervise # At fork time
13
+ forge guard supervise planner # On existing session
14
+ %guard supervise planner # In-session
15
+
16
+ Manual approach (still works):
17
+ forge session set policy.supervisor.resume_id <name-or-uuid>
18
+ """