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