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