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