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,107 @@
|
|
|
1
|
+
"""Claude Code direct model pin helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import MutableMapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from forge.core.models.catalog import (
|
|
9
|
+
ModelCatalogError,
|
|
10
|
+
get_model_spec,
|
|
11
|
+
resolve_model_id,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
ONE_M_SUFFIX = "[1m]"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class DirectModelPin:
|
|
19
|
+
"""A Claude Code env-ready direct model pin."""
|
|
20
|
+
|
|
21
|
+
canonical_model: str
|
|
22
|
+
env_model: str
|
|
23
|
+
tier: str
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def env_var(self) -> str:
|
|
27
|
+
return f"ANTHROPIC_DEFAULT_{self.tier.upper()}_MODEL"
|
|
28
|
+
|
|
29
|
+
def env(self) -> dict[str, str]:
|
|
30
|
+
return {
|
|
31
|
+
"ANTHROPIC_MODEL": self.tier,
|
|
32
|
+
self.env_var: self.env_model,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def resolve_direct_model_pin(value: str) -> DirectModelPin:
|
|
37
|
+
"""Resolve a direct-session model value to Claude Code model env vars.
|
|
38
|
+
|
|
39
|
+
The catalog owns aliases and canonical model IDs. Claude Code owns the
|
|
40
|
+
``[1m]`` model-pin suffix, so this helper strips it for catalog lookup and
|
|
41
|
+
restores it on the normalized env-ready model value.
|
|
42
|
+
"""
|
|
43
|
+
raw_value = value.strip()
|
|
44
|
+
if not raw_value:
|
|
45
|
+
raise ValueError("--model cannot be empty")
|
|
46
|
+
|
|
47
|
+
requested_1m = raw_value.endswith(ONE_M_SUFFIX)
|
|
48
|
+
lookup_value = raw_value.removesuffix(ONE_M_SUFFIX) if requested_1m else raw_value
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
canonical = resolve_model_id(lookup_value)
|
|
52
|
+
except ModelCatalogError as e:
|
|
53
|
+
raise ValueError(f"Unknown direct Claude model: {value!r}") from e
|
|
54
|
+
|
|
55
|
+
normalized_1m = requested_1m or canonical.endswith("-1m")
|
|
56
|
+
base_canonical = canonical.removesuffix("-1m") if canonical.endswith("-1m") else canonical
|
|
57
|
+
|
|
58
|
+
if not base_canonical.startswith("claude-"):
|
|
59
|
+
raise ValueError(f"--model only supports Claude models for direct sessions, got {value!r}")
|
|
60
|
+
|
|
61
|
+
tier = _claude_tier(base_canonical)
|
|
62
|
+
if tier is None:
|
|
63
|
+
raise ValueError(f"Unsupported Claude model tier for direct sessions: {value!r}")
|
|
64
|
+
|
|
65
|
+
if normalized_1m:
|
|
66
|
+
spec = get_model_spec(base_canonical)
|
|
67
|
+
if tier not in {"opus", "sonnet"} and not spec.supports_1m_context:
|
|
68
|
+
raise ValueError("[1m] direct model pins are only supported for Opus/Sonnet Claude models")
|
|
69
|
+
|
|
70
|
+
env_model = f"{base_canonical}{ONE_M_SUFFIX}" if normalized_1m else base_canonical
|
|
71
|
+
return DirectModelPin(canonical_model=base_canonical, env_model=env_model, tier=tier)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def direct_model_env(value: str | None) -> dict[str, str]:
|
|
75
|
+
"""Return Claude Code direct-model environment variables for ``value``."""
|
|
76
|
+
if not value:
|
|
77
|
+
return {}
|
|
78
|
+
return resolve_direct_model_pin(value).env()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def apply_direct_model_env(env_vars: MutableMapping[str, str], value: str | None) -> str | None:
|
|
82
|
+
"""Apply direct-model env vars in-place, returning an error message on failure."""
|
|
83
|
+
if not value:
|
|
84
|
+
return None
|
|
85
|
+
try:
|
|
86
|
+
env_vars.update(direct_model_env(value))
|
|
87
|
+
except ValueError as e:
|
|
88
|
+
return str(e)
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def token_estimate_multiplier_for_direct_model(value: str | None) -> float:
|
|
93
|
+
"""Return the catalog token-estimate multiplier for a direct model pin."""
|
|
94
|
+
if not value:
|
|
95
|
+
return 1.0
|
|
96
|
+
pin = resolve_direct_model_pin(value)
|
|
97
|
+
return get_model_spec(pin.canonical_model).token_estimate_multiplier
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _claude_tier(canonical_model: str) -> str | None:
|
|
101
|
+
if canonical_model.startswith("claude-opus-"):
|
|
102
|
+
return "opus"
|
|
103
|
+
if canonical_model.startswith("claude-sonnet-"):
|
|
104
|
+
return "sonnet"
|
|
105
|
+
if canonical_model.startswith("claude-haiku-"):
|
|
106
|
+
return "haiku"
|
|
107
|
+
return None
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Effective configuration computation (intent + overrides).
|
|
2
|
+
|
|
3
|
+
This module provides functions for computing the effective session configuration
|
|
4
|
+
by merging the baseline intent with runtime overrides.
|
|
5
|
+
|
|
6
|
+
Merge semantics:
|
|
7
|
+
- Scalars: override replaces base value
|
|
8
|
+
- Dicts: recursively merge (override keys win on conflict)
|
|
9
|
+
- Lists: override replaces entire list (no concatenation)
|
|
10
|
+
- None in override: clears the field (effective value becomes None)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from copy import deepcopy
|
|
16
|
+
from dataclasses import asdict
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import dacite
|
|
20
|
+
|
|
21
|
+
from .exceptions import InvalidOverrideKeyError, InvalidOverrideValueError
|
|
22
|
+
from .models import SessionIntent, SessionState
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def apply_overrides(base: dict[str, Any], overrides: dict[str, Any]) -> dict[str, Any]:
|
|
26
|
+
"""Apply user overrides to a base configuration dict.
|
|
27
|
+
|
|
28
|
+
This is a generic merge function with no schema awareness.
|
|
29
|
+
Schema validation happens in compute_effective_intent().
|
|
30
|
+
|
|
31
|
+
Merge semantics:
|
|
32
|
+
- Scalars: override replaces base
|
|
33
|
+
- Dicts: recursively merge (both must be dicts)
|
|
34
|
+
- Lists: override replaces entire list (no concatenation)
|
|
35
|
+
- None in override: sets field to None (clears it)
|
|
36
|
+
- New keys in override: added to result
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
base: The base dictionary (typically from intent).
|
|
40
|
+
overrides: The overrides dictionary (sparse, only changed fields).
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
A new dict with overrides applied to base.
|
|
44
|
+
"""
|
|
45
|
+
result = deepcopy(base)
|
|
46
|
+
|
|
47
|
+
for key, value in overrides.items():
|
|
48
|
+
if value is None:
|
|
49
|
+
result[key] = None
|
|
50
|
+
elif isinstance(value, dict) and key in result and isinstance(result[key], dict):
|
|
51
|
+
result[key] = apply_overrides(result[key], value)
|
|
52
|
+
else:
|
|
53
|
+
result[key] = deepcopy(value)
|
|
54
|
+
|
|
55
|
+
return result
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def compute_effective_intent(
|
|
59
|
+
state: SessionState,
|
|
60
|
+
strict: bool = True,
|
|
61
|
+
override_key: str | None = None,
|
|
62
|
+
) -> SessionIntent:
|
|
63
|
+
"""Compute effective config by merging intent with overrides.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
state: The session state containing intent and overrides.
|
|
67
|
+
strict: If True, validate the merged result can become a valid SessionIntent.
|
|
68
|
+
Raises InvalidOverrideValueError on type mismatches.
|
|
69
|
+
override_key: If provided, used in error messages to identify which override
|
|
70
|
+
caused the failure. Typically the key being set via CLI.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
A SessionIntent representing the effective configuration.
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
InvalidOverrideValueError: If strict=True and the merged config has invalid types.
|
|
77
|
+
"""
|
|
78
|
+
intent_dict = asdict(state.intent)
|
|
79
|
+
|
|
80
|
+
if state.overrides:
|
|
81
|
+
merged = apply_overrides(intent_dict, state.overrides)
|
|
82
|
+
else:
|
|
83
|
+
merged = intent_dict
|
|
84
|
+
|
|
85
|
+
if strict:
|
|
86
|
+
try:
|
|
87
|
+
return dacite.from_dict(
|
|
88
|
+
data_class=SessionIntent,
|
|
89
|
+
data=merged,
|
|
90
|
+
config=dacite.Config(strict=True),
|
|
91
|
+
)
|
|
92
|
+
except (dacite.DaciteError, TypeError, ValueError) as e:
|
|
93
|
+
key = override_key or "unknown"
|
|
94
|
+
actual = _infer_actual_type(e, merged)
|
|
95
|
+
expected = _infer_expected_type(e)
|
|
96
|
+
raise InvalidOverrideValueError(key, expected, actual) from e
|
|
97
|
+
|
|
98
|
+
# Non-strict mode: best-effort conversion.
|
|
99
|
+
# Note: With strict v3 manifests and strict override validation, this should
|
|
100
|
+
# generally not encounter unknown keys.
|
|
101
|
+
return dacite.from_dict(
|
|
102
|
+
data_class=SessionIntent,
|
|
103
|
+
data=merged,
|
|
104
|
+
config=dacite.Config(strict=True),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def get_effective_value(state: SessionState, key: str) -> Any | None:
|
|
109
|
+
"""Get effective value for a specific dot-notation key.
|
|
110
|
+
|
|
111
|
+
This function validates the key syntax but returns None for valid keys
|
|
112
|
+
that are not present in the effective config (rather than raising).
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
state: The session state.
|
|
116
|
+
key: Dot-notation path (e.g., "agent", "proxy.template").
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
The effective value, or None if key is valid but not set.
|
|
120
|
+
|
|
121
|
+
Raises:
|
|
122
|
+
InvalidOverrideKeyError: If key syntax is invalid (empty, empty segments).
|
|
123
|
+
"""
|
|
124
|
+
if not key:
|
|
125
|
+
raise InvalidOverrideKeyError(key, "key cannot be empty")
|
|
126
|
+
|
|
127
|
+
parts = key.split(".")
|
|
128
|
+
for part in parts:
|
|
129
|
+
if not part:
|
|
130
|
+
raise InvalidOverrideKeyError(key, "empty segment in path")
|
|
131
|
+
|
|
132
|
+
effective = compute_effective_intent(state, strict=True)
|
|
133
|
+
effective_dict = asdict(effective)
|
|
134
|
+
|
|
135
|
+
current: Any = effective_dict
|
|
136
|
+
for part in parts:
|
|
137
|
+
if not isinstance(current, dict):
|
|
138
|
+
return None
|
|
139
|
+
if part not in current:
|
|
140
|
+
return None
|
|
141
|
+
current = current[part]
|
|
142
|
+
|
|
143
|
+
return current
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _infer_actual_type(error: Exception, merged: dict[str, Any]) -> str:
|
|
147
|
+
"""Try to infer the actual type/value from a dacite error."""
|
|
148
|
+
error_str = str(error)
|
|
149
|
+
|
|
150
|
+
if "expected" in error_str.lower() and "got" in error_str.lower():
|
|
151
|
+
return error_str
|
|
152
|
+
|
|
153
|
+
return f"invalid value ({error_str})"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _infer_expected_type(error: Exception) -> str:
|
|
157
|
+
"""Try to infer the expected type from a dacite error."""
|
|
158
|
+
error_str = str(error)
|
|
159
|
+
|
|
160
|
+
if "str" in error_str:
|
|
161
|
+
return "str"
|
|
162
|
+
if "list" in error_str:
|
|
163
|
+
return "list"
|
|
164
|
+
if "int" in error_str:
|
|
165
|
+
return "int"
|
|
166
|
+
if "bool" in error_str:
|
|
167
|
+
return "bool"
|
|
168
|
+
|
|
169
|
+
return "valid type"
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Exceptions for Forge Session module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ForgeSessionError(Exception):
|
|
7
|
+
"""Base exception for session module."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class InvalidSessionNameError(ForgeSessionError):
|
|
11
|
+
"""Raised when session name validation fails."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SessionNotFoundError(ForgeSessionError):
|
|
15
|
+
"""Raised when a session cannot be found."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, name: str) -> None:
|
|
18
|
+
self.name = name
|
|
19
|
+
super().__init__(f"session '{name}' not found")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SessionExistsError(ForgeSessionError):
|
|
23
|
+
"""Raised when trying to create a session that already exists."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, name: str) -> None:
|
|
26
|
+
self.name = name
|
|
27
|
+
super().__init__(f"session '{name}' already exists")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SessionFileNotFoundError(ForgeSessionError):
|
|
31
|
+
"""Raised when session state file doesn't exist in expected location."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, path: str) -> None:
|
|
34
|
+
self.path = path
|
|
35
|
+
super().__init__(f"session file not found at '{path}'")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ManifestCorruptedError(ForgeSessionError):
|
|
39
|
+
"""Raised when manifest file exists but cannot be parsed."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, path: str, reason: str) -> None:
|
|
42
|
+
self.path = path
|
|
43
|
+
self.reason = reason
|
|
44
|
+
super().__init__(f"manifest at '{path}': {reason}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ManifestValidationError(ForgeSessionError):
|
|
48
|
+
"""Raised when manifest is missing required fields."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, path: str, missing_fields: list[str]) -> None:
|
|
51
|
+
self.path = path
|
|
52
|
+
self.missing_fields = missing_fields
|
|
53
|
+
fields_str = ", ".join(missing_fields)
|
|
54
|
+
super().__init__(f"manifest at '{path}' missing required fields: {fields_str}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class IndexCorruptedError(ForgeSessionError):
|
|
58
|
+
"""Raised when index file exists but cannot be parsed."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, path: str, reason: str) -> None:
|
|
61
|
+
self.path = path
|
|
62
|
+
self.reason = reason
|
|
63
|
+
super().__init__(f"index at '{path}': {reason}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class CannotForkIncognitoError(ForgeSessionError):
|
|
67
|
+
"""Raised when attempting to fork from an incognito session."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, name: str) -> None:
|
|
70
|
+
self.name = name
|
|
71
|
+
super().__init__(f"cannot fork from incognito session '{name}'")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ClaudeInvocationError(ForgeSessionError):
|
|
75
|
+
"""Raised when Claude binary invocation fails."""
|
|
76
|
+
|
|
77
|
+
def __init__(self, reason: str, exit_code: int | None = None) -> None:
|
|
78
|
+
self.reason = reason
|
|
79
|
+
self.exit_code = exit_code
|
|
80
|
+
msg = reason
|
|
81
|
+
if exit_code is not None:
|
|
82
|
+
msg = f"{reason} (exit code: {exit_code})"
|
|
83
|
+
super().__init__(msg)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ProjectRootNotFoundError(ForgeSessionError):
|
|
87
|
+
"""Raised when no git repository can be found."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, path: str) -> None:
|
|
90
|
+
self.path = path
|
|
91
|
+
super().__init__(f"no git repository found at or above '{path}'")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class ForgeNotEnabledError(ForgeSessionError):
|
|
95
|
+
"""Raised when session start is attempted without a Forge project.
|
|
96
|
+
|
|
97
|
+
Rule 1: sessions require ``forge extension enable`` (which creates ``.forge/``).
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(self, path: str) -> None:
|
|
101
|
+
self.path = path
|
|
102
|
+
super().__init__(f"no Forge project at '{path}'. Run 'forge extension enable' first.")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# --- Git Worktree Exceptions ---
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class GitNotFoundError(ForgeSessionError):
|
|
109
|
+
"""Raised when git binary is not found in PATH."""
|
|
110
|
+
|
|
111
|
+
def __init__(self) -> None:
|
|
112
|
+
super().__init__("git binary not found in PATH")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class GitWorktreeError(ForgeSessionError):
|
|
116
|
+
"""Raised when a git worktree operation fails."""
|
|
117
|
+
|
|
118
|
+
def __init__(self, operation: str, reason: str, exit_code: int | None = None) -> None:
|
|
119
|
+
self.operation = operation
|
|
120
|
+
self.reason = reason
|
|
121
|
+
self.exit_code = exit_code
|
|
122
|
+
msg = f"git worktree {operation} failed: {reason}"
|
|
123
|
+
if exit_code is not None:
|
|
124
|
+
msg = f"{msg} (exit code: {exit_code})"
|
|
125
|
+
super().__init__(msg)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class InvalidBranchNameError(ForgeSessionError):
|
|
129
|
+
"""Raised when an explicit --branch name is invalid."""
|
|
130
|
+
|
|
131
|
+
def __init__(self, branch: str, reason: str) -> None:
|
|
132
|
+
self.branch = branch
|
|
133
|
+
self.reason = reason
|
|
134
|
+
super().__init__(f"invalid branch name '{branch}': {reason}")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class BranchExistsError(ForgeSessionError):
|
|
138
|
+
"""Raised when trying to create a branch that already exists."""
|
|
139
|
+
|
|
140
|
+
def __init__(self, branch: str, worktree: str | None = None) -> None:
|
|
141
|
+
self.branch = branch
|
|
142
|
+
self.worktree = worktree
|
|
143
|
+
if worktree:
|
|
144
|
+
msg = f"branch '{branch}' already exists (checked out in '{worktree}')"
|
|
145
|
+
else:
|
|
146
|
+
msg = f"branch '{branch}' already exists"
|
|
147
|
+
super().__init__(msg)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class BranchInUseError(ForgeSessionError):
|
|
151
|
+
"""Raised when a branch is checked out in another worktree."""
|
|
152
|
+
|
|
153
|
+
def __init__(self, branch: str, worktree: str) -> None:
|
|
154
|
+
self.branch = branch
|
|
155
|
+
self.worktree = worktree
|
|
156
|
+
super().__init__(f"branch '{branch}' is checked out in worktree '{worktree}'")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class BranchNotMergedError(ForgeSessionError):
|
|
160
|
+
"""Raised when trying to delete a branch that is not fully merged."""
|
|
161
|
+
|
|
162
|
+
def __init__(self, branch: str) -> None:
|
|
163
|
+
self.branch = branch
|
|
164
|
+
super().__init__(f"branch '{branch}' is not fully merged. Use --force to delete anyway")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class WorktreePathExistsError(ForgeSessionError):
|
|
168
|
+
"""Raised when the target worktree path already exists."""
|
|
169
|
+
|
|
170
|
+
def __init__(self, path: str) -> None:
|
|
171
|
+
self.path = path
|
|
172
|
+
super().__init__(f"worktree path '{path}' already exists")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class DirtyWorktreeError(ForgeSessionError):
|
|
176
|
+
"""Raised when a worktree has uncommitted changes during cleanup."""
|
|
177
|
+
|
|
178
|
+
def __init__(self, path: str) -> None:
|
|
179
|
+
self.path = path
|
|
180
|
+
super().__init__(f"worktree '{path}' has uncommitted changes. Use --force to remove anyway")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# --- Override Exceptions ---
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class InvalidOverrideKeyError(ForgeSessionError):
|
|
187
|
+
"""Raised when an override key is invalid.
|
|
188
|
+
|
|
189
|
+
Keys can be invalid due to:
|
|
190
|
+
- Empty key or empty segment in path (e.g., "foo..bar")
|
|
191
|
+
- Targeting confirmed.* fields
|
|
192
|
+
- Targeting top-level manifest fields (name, schema_version, etc.)
|
|
193
|
+
- Using intent.* prefix (keys should be relative to intent)
|
|
194
|
+
- Unknown field not in SessionIntent schema
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
def __init__(self, key: str, reason: str, hint: str | None = None) -> None:
|
|
198
|
+
self.key = key
|
|
199
|
+
self.reason = reason
|
|
200
|
+
self.hint = hint # e.g., "valid keys: agent, proxy.*, policy.*, ..."
|
|
201
|
+
msg = f"invalid override key '{key}': {reason}"
|
|
202
|
+
if hint:
|
|
203
|
+
msg = f"{msg} ({hint})"
|
|
204
|
+
super().__init__(msg)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class InvalidOverrideValueError(ForgeSessionError):
|
|
208
|
+
"""Raised when an override value has an incompatible type.
|
|
209
|
+
|
|
210
|
+
This occurs when the effective config (intent + overrides) cannot be
|
|
211
|
+
converted to a valid SessionIntent due to type mismatches.
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
def __init__(self, key: str, expected: str, actual: str) -> None:
|
|
215
|
+
self.key = key
|
|
216
|
+
self.expected = expected # e.g., "str", "list[str]", "enum"
|
|
217
|
+
self.actual = actual # e.g., "bool", "True"
|
|
218
|
+
super().__init__(f"invalid value for '{key}': expected {expected}, got {actual}")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# --- Resume Exceptions ---
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class AmbiguousSessionError(ForgeSessionError):
|
|
225
|
+
"""Raised when a session name matches multiple projects.
|
|
226
|
+
|
|
227
|
+
User-facing commands use strict resolution which raises this when
|
|
228
|
+
forge_root is None and duplicate names exist across projects.
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
def __init__(self, name: str, forge_roots: list[str]) -> None:
|
|
232
|
+
self.name = name
|
|
233
|
+
self.forge_roots = forge_roots
|
|
234
|
+
roots_str = ", ".join(forge_roots)
|
|
235
|
+
super().__init__(
|
|
236
|
+
f"session '{name}' exists in multiple projects: {roots_str}. "
|
|
237
|
+
f"Run from within the target project directory to disambiguate."
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class ContextBudgetExceededError(ForgeSessionError):
|
|
242
|
+
"""Raised when parent context exceeds proxy context limit.
|
|
243
|
+
|
|
244
|
+
This is a fail-fast check for the 'full' resume strategy. When the parent
|
|
245
|
+
transcript is too large to fit in the target proxy's context window, we
|
|
246
|
+
fail before launching Claude rather than wasting tokens.
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
def __init__(self, token_estimate: int, context_limit: int) -> None:
|
|
250
|
+
self.token_estimate = token_estimate
|
|
251
|
+
self.context_limit = context_limit
|
|
252
|
+
super().__init__(
|
|
253
|
+
f"Parent transcript ({token_estimate:,} tokens) exceeds context limit "
|
|
254
|
+
f"({context_limit:,}). Use --strategy structured or --strategy minimal."
|
|
255
|
+
)
|