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,167 @@
|
|
|
1
|
+
"""Type definitions for the model catalog.
|
|
2
|
+
|
|
3
|
+
This module defines the dataclasses that represent model specifications
|
|
4
|
+
and the catalog structure. All types are immutable (frozen) to ensure
|
|
5
|
+
the catalog remains a stable reference.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class TemperatureSpec:
|
|
14
|
+
"""Temperature constraints for a model."""
|
|
15
|
+
|
|
16
|
+
min: float
|
|
17
|
+
default: float
|
|
18
|
+
max: float
|
|
19
|
+
|
|
20
|
+
def __post_init__(self) -> None:
|
|
21
|
+
if not (self.min <= self.default <= self.max):
|
|
22
|
+
raise ValueError(
|
|
23
|
+
f"Temperature invariant violated: min ({self.min}) <= default ({self.default}) <= max ({self.max})"
|
|
24
|
+
)
|
|
25
|
+
if self.min < 0:
|
|
26
|
+
raise ValueError(f"Temperature min must be >= 0, got {self.min}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class ModelSpec:
|
|
31
|
+
"""Intrinsic properties of a model.
|
|
32
|
+
|
|
33
|
+
These are facts about what the model CAN do, not operational config.
|
|
34
|
+
Operational config (tier mappings, routing, defaults) belongs in template YAMLs.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
# Basic identity
|
|
38
|
+
friendly_name: str
|
|
39
|
+
intelligence_score: int
|
|
40
|
+
|
|
41
|
+
# Token limits
|
|
42
|
+
context_window_tokens: int
|
|
43
|
+
max_output_tokens: int
|
|
44
|
+
max_thinking_tokens: int | None = None
|
|
45
|
+
|
|
46
|
+
# Display
|
|
47
|
+
short_name: str | None = None # Compact display name (e.g., "gemini-flash"); None = derive algorithmically
|
|
48
|
+
|
|
49
|
+
# Capability flags
|
|
50
|
+
supports_thinking: bool = False
|
|
51
|
+
supports_images: bool = False
|
|
52
|
+
supports_verbosity: bool = False
|
|
53
|
+
supports_top_p: bool = True
|
|
54
|
+
supports_sampling_overrides: bool = True
|
|
55
|
+
supports_1m_context: bool = False
|
|
56
|
+
|
|
57
|
+
# Temperature configuration
|
|
58
|
+
temperature_constraint: Literal["fixed", "range"] = "range"
|
|
59
|
+
temperature: TemperatureSpec = field(default_factory=lambda: TemperatureSpec(0.0, 1.0, 2.0))
|
|
60
|
+
|
|
61
|
+
# Verbosity configuration (GPT-5 non-Codex models via Responses API)
|
|
62
|
+
verbosity_levels: tuple[str, ...] | None = None
|
|
63
|
+
|
|
64
|
+
# API configuration
|
|
65
|
+
use_responses_api: bool = False
|
|
66
|
+
|
|
67
|
+
# Reasoning/thinking configuration
|
|
68
|
+
# Native parameter name varies by provider:
|
|
69
|
+
# - OpenAI: "reasoning_effort"
|
|
70
|
+
# - Anthropic: "output_config.effort"
|
|
71
|
+
# - Gemini 2.5: "thinking_budget"
|
|
72
|
+
# - Gemini 3: "thinking_level"
|
|
73
|
+
native_thinking_param: str | None = None
|
|
74
|
+
|
|
75
|
+
# LiteLLM abstraction - supported reasoning_effort values
|
|
76
|
+
# These are the values LiteLLM accepts and maps to native params
|
|
77
|
+
litellm_reasoning_efforts: tuple[str, ...] | None = None
|
|
78
|
+
default_reasoning_effort: str | None = None
|
|
79
|
+
thinking_modes: tuple[str, ...] | None = None
|
|
80
|
+
|
|
81
|
+
# Gemini 3 specific - thinking levels (different from reasoning_effort)
|
|
82
|
+
thinking_levels: tuple[str, ...] | None = None
|
|
83
|
+
default_thinking_level: str | None = None
|
|
84
|
+
|
|
85
|
+
# Metadata
|
|
86
|
+
token_estimate_multiplier: float = 1.0
|
|
87
|
+
system_prompt_addendum: str | None = None
|
|
88
|
+
tags: tuple[str, ...] = field(default_factory=tuple)
|
|
89
|
+
|
|
90
|
+
def __post_init__(self) -> None:
|
|
91
|
+
if self.context_window_tokens <= 0:
|
|
92
|
+
raise ValueError(f"context_window_tokens must be > 0, got {self.context_window_tokens}")
|
|
93
|
+
if self.max_output_tokens <= 0:
|
|
94
|
+
raise ValueError(f"max_output_tokens must be > 0, got {self.max_output_tokens}")
|
|
95
|
+
if self.max_thinking_tokens is not None and self.max_thinking_tokens <= 0:
|
|
96
|
+
raise ValueError(f"max_thinking_tokens must be > 0 or null, got {self.max_thinking_tokens}")
|
|
97
|
+
if not (0 <= self.intelligence_score <= 100):
|
|
98
|
+
raise ValueError(f"intelligence_score must be 0-100, got {self.intelligence_score}")
|
|
99
|
+
if self.token_estimate_multiplier <= 0:
|
|
100
|
+
raise ValueError(f"token_estimate_multiplier must be > 0, got {self.token_estimate_multiplier}")
|
|
101
|
+
if self.temperature_constraint == "fixed":
|
|
102
|
+
if self.temperature.min != self.temperature.max:
|
|
103
|
+
raise ValueError(
|
|
104
|
+
f"Fixed temperature constraint requires min == max, "
|
|
105
|
+
f"got min={self.temperature.min}, max={self.temperature.max}"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
REQUIRED_TIERS = frozenset({"haiku", "sonnet", "opus"})
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True)
|
|
113
|
+
class ModelCatalog:
|
|
114
|
+
"""The complete model catalog.
|
|
115
|
+
|
|
116
|
+
Immutable container for all models and their aliases.
|
|
117
|
+
Use the module-level functions to query the catalog.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
schema_version: int
|
|
121
|
+
models: dict[str, ModelSpec]
|
|
122
|
+
aliases: dict[str, str]
|
|
123
|
+
defaults: dict[str, dict[str, str]] = field(default_factory=dict) # provider -> tier -> canonical model ID
|
|
124
|
+
|
|
125
|
+
def resolve(self, model_or_alias: str) -> str:
|
|
126
|
+
"""Resolve a model ID or alias to its canonical ID.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
model_or_alias: A canonical model ID or an alias.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
The canonical model ID.
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
KeyError: If the model/alias is not found in the catalog.
|
|
136
|
+
"""
|
|
137
|
+
if model_or_alias in self.models:
|
|
138
|
+
return model_or_alias
|
|
139
|
+
if model_or_alias in self.aliases:
|
|
140
|
+
return self.aliases[model_or_alias]
|
|
141
|
+
raise KeyError(f"Unknown model or alias: {model_or_alias!r}")
|
|
142
|
+
|
|
143
|
+
def get(self, model_or_alias: str) -> ModelSpec:
|
|
144
|
+
"""Get the model spec for a model ID or alias.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
model_or_alias: A canonical model ID or an alias.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
The ModelSpec for the resolved model.
|
|
151
|
+
|
|
152
|
+
Raises:
|
|
153
|
+
KeyError: If the model/alias is not found in the catalog.
|
|
154
|
+
"""
|
|
155
|
+
canonical_id = self.resolve(model_or_alias)
|
|
156
|
+
return self.models[canonical_id]
|
|
157
|
+
|
|
158
|
+
def get_default(self, provider: str, tier: str) -> str:
|
|
159
|
+
"""Return the canonical model ID for a provider+tier default.
|
|
160
|
+
|
|
161
|
+
Raises KeyError if provider or tier is not in defaults.
|
|
162
|
+
"""
|
|
163
|
+
return self.defaults[provider][tier]
|
|
164
|
+
|
|
165
|
+
def __contains__(self, model_or_alias: str) -> bool:
|
|
166
|
+
"""Check if a model or alias exists in the catalog."""
|
|
167
|
+
return model_or_alias in self.models or model_or_alias in self.aliases
|
forge/core/naming.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Random name generation for human-friendly identifiers.
|
|
2
|
+
|
|
3
|
+
Provides consistent naming across Forge components using the coolname library.
|
|
4
|
+
|
|
5
|
+
Session names use default coolname (adjective-animal): 'spirited-coati'.
|
|
6
|
+
Proxy names use a custom color-fruit generator: 'teal-lemon'.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
from forge.core.naming import generate_name, generate_unique_name
|
|
10
|
+
from forge.core.naming import generate_proxy_name, generate_unique_proxy_name
|
|
11
|
+
|
|
12
|
+
name = generate_name() # 'mottled-crab'
|
|
13
|
+
unique = generate_unique_name(existing) # Avoids collisions
|
|
14
|
+
proxy = generate_proxy_name() # 'teal-lemon'
|
|
15
|
+
unique_proxy = generate_unique_proxy_name(existing)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import random
|
|
21
|
+
from typing import Literal
|
|
22
|
+
|
|
23
|
+
import coolname
|
|
24
|
+
from coolname import RandomGenerator
|
|
25
|
+
|
|
26
|
+
# Type alias for supported word counts
|
|
27
|
+
WordCount = Literal[2, 3, 4]
|
|
28
|
+
|
|
29
|
+
# Default word count for generated names
|
|
30
|
+
DEFAULT_WORDS: WordCount = 2
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def generate_name(words: WordCount = DEFAULT_WORDS) -> str:
|
|
34
|
+
"""Generate a random human-friendly name.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
words: Number of words in the name (2, 3, or 4).
|
|
38
|
+
- 2: 'adjective-noun' (e.g., 'happy-fox') - ~10^5 combinations
|
|
39
|
+
- 3: 'adjective-adjective-noun' (e.g., 'big-maize-lori') - ~10^8 combinations
|
|
40
|
+
- 4: 'adjective-adjective-noun-of-noun' (e.g., 'military-diamond-tuatara-of-endeavor') - ~10^10 combinations
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
A hyphenated lowercase name.
|
|
44
|
+
|
|
45
|
+
Example:
|
|
46
|
+
>>> name = generate_name()
|
|
47
|
+
>>> '-' in name
|
|
48
|
+
True
|
|
49
|
+
"""
|
|
50
|
+
return coolname.generate_slug(words)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def generate_unique_name(
|
|
54
|
+
existing_names: set[str],
|
|
55
|
+
words: WordCount = DEFAULT_WORDS,
|
|
56
|
+
max_attempts: int = 100,
|
|
57
|
+
) -> str:
|
|
58
|
+
"""Generate a random name that doesn't conflict with existing names.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
existing_names: Set of names that already exist.
|
|
62
|
+
words: Number of words in the name (2, 3, or 4).
|
|
63
|
+
max_attempts: Maximum attempts before falling back to suffix strategy.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
A unique random name not in existing_names.
|
|
67
|
+
|
|
68
|
+
Note:
|
|
69
|
+
If max_attempts is exceeded, falls back to appending random suffixes
|
|
70
|
+
until a unique name is found. With ~10^5 combinations for 2-word names,
|
|
71
|
+
collisions are rare unless existing_names is very large.
|
|
72
|
+
|
|
73
|
+
Example:
|
|
74
|
+
>>> existing = {"happy-fox", "brave-wolf"}
|
|
75
|
+
>>> name = generate_unique_name(existing)
|
|
76
|
+
>>> name not in existing
|
|
77
|
+
True
|
|
78
|
+
"""
|
|
79
|
+
# Try generating names without suffix
|
|
80
|
+
for _ in range(max_attempts):
|
|
81
|
+
name = generate_name(words)
|
|
82
|
+
if name not in existing_names:
|
|
83
|
+
return name
|
|
84
|
+
|
|
85
|
+
# Fallback: append random suffix, loop until unique
|
|
86
|
+
while True:
|
|
87
|
+
base = generate_name(words)
|
|
88
|
+
suffix = random.randint(100, 9999) # 4 digits for more entropy
|
|
89
|
+
name = f"{base}-{suffix}"
|
|
90
|
+
if name not in existing_names:
|
|
91
|
+
return name
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def generate_parts(words: WordCount = DEFAULT_WORDS) -> list[str]:
|
|
95
|
+
"""Generate name parts as a list (for custom formatting).
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
words: Number of words to generate (2, 3, or 4).
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
List of name parts (e.g., ['happy', 'fox']).
|
|
102
|
+
|
|
103
|
+
Example:
|
|
104
|
+
>>> parts = generate_parts()
|
|
105
|
+
>>> len(parts) == 2
|
|
106
|
+
True
|
|
107
|
+
"""
|
|
108
|
+
return coolname.generate(words)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# --- Proxy names: color-fruit pattern (visually distinct from session names) ---
|
|
112
|
+
|
|
113
|
+
_PROXY_COLORS = [
|
|
114
|
+
"amber",
|
|
115
|
+
"azure",
|
|
116
|
+
"bronze",
|
|
117
|
+
"cobalt",
|
|
118
|
+
"copper",
|
|
119
|
+
"coral",
|
|
120
|
+
"crimson",
|
|
121
|
+
"cyan",
|
|
122
|
+
"ebony",
|
|
123
|
+
"emerald",
|
|
124
|
+
"garnet",
|
|
125
|
+
"golden",
|
|
126
|
+
"indigo",
|
|
127
|
+
"ivory",
|
|
128
|
+
"jade",
|
|
129
|
+
"lavender",
|
|
130
|
+
"magenta",
|
|
131
|
+
"maroon",
|
|
132
|
+
"navy",
|
|
133
|
+
"ochre",
|
|
134
|
+
"onyx",
|
|
135
|
+
"ruby",
|
|
136
|
+
"sage",
|
|
137
|
+
"scarlet",
|
|
138
|
+
"silver",
|
|
139
|
+
"slate",
|
|
140
|
+
"teal",
|
|
141
|
+
"topaz",
|
|
142
|
+
"turquoise",
|
|
143
|
+
"violet",
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
_PROXY_FRUITS = [
|
|
147
|
+
"apple",
|
|
148
|
+
"apricot",
|
|
149
|
+
"banana",
|
|
150
|
+
"cherry",
|
|
151
|
+
"citron",
|
|
152
|
+
"coconut",
|
|
153
|
+
"date",
|
|
154
|
+
"fig",
|
|
155
|
+
"grape",
|
|
156
|
+
"guava",
|
|
157
|
+
"kiwi",
|
|
158
|
+
"lemon",
|
|
159
|
+
"lime",
|
|
160
|
+
"lychee",
|
|
161
|
+
"mango",
|
|
162
|
+
"melon",
|
|
163
|
+
"olive",
|
|
164
|
+
"orange",
|
|
165
|
+
"papaya",
|
|
166
|
+
"peach",
|
|
167
|
+
"pear",
|
|
168
|
+
"plum",
|
|
169
|
+
"pomelo",
|
|
170
|
+
"quince",
|
|
171
|
+
"raisin",
|
|
172
|
+
"sorbet",
|
|
173
|
+
"tangerine",
|
|
174
|
+
"walnut",
|
|
175
|
+
"yuzu",
|
|
176
|
+
"zest",
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
_proxy_generator = RandomGenerator(
|
|
180
|
+
{
|
|
181
|
+
"all": {"type": "cartesian", "lists": ["color", "fruit"]},
|
|
182
|
+
"color": {"type": "words", "words": _PROXY_COLORS},
|
|
183
|
+
"fruit": {"type": "words", "words": _PROXY_FRUITS},
|
|
184
|
+
}
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def generate_proxy_name() -> str:
|
|
189
|
+
"""Generate a color-fruit proxy name (e.g., 'teal-lemon')."""
|
|
190
|
+
return _proxy_generator.generate_slug()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def generate_unique_proxy_name(
|
|
194
|
+
existing_names: set[str],
|
|
195
|
+
max_attempts: int = 100,
|
|
196
|
+
) -> str:
|
|
197
|
+
"""Generate a unique color-fruit proxy name.
|
|
198
|
+
|
|
199
|
+
30 colors x 30 fruits = 900 combinations.
|
|
200
|
+
Falls back to numeric suffix if exhausted.
|
|
201
|
+
"""
|
|
202
|
+
for _ in range(max_attempts):
|
|
203
|
+
name = generate_proxy_name()
|
|
204
|
+
if name not in existing_names:
|
|
205
|
+
return name
|
|
206
|
+
|
|
207
|
+
while True:
|
|
208
|
+
base = generate_proxy_name()
|
|
209
|
+
suffix = random.randint(100, 9999)
|
|
210
|
+
name = f"{base}-{suffix}"
|
|
211
|
+
if name not in existing_names:
|
|
212
|
+
return name
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Command-core operations.
|
|
2
|
+
|
|
3
|
+
This package contains reusable operations that can be invoked from:
|
|
4
|
+
|
|
5
|
+
- the Forge CLI (`forge ...`), and
|
|
6
|
+
- in-chat direct commands (via `%...` routed through `forge hook user-prompt-submit`).
|
|
7
|
+
|
|
8
|
+
Ops must be UI-agnostic: no Click usage, no printing, and no hook JSON.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .context import ExecutionContext
|
|
12
|
+
from .gc import (
|
|
13
|
+
CleanError,
|
|
14
|
+
CleanReport,
|
|
15
|
+
CleanResult,
|
|
16
|
+
OrphanCategory,
|
|
17
|
+
collect_clean_report,
|
|
18
|
+
run_clean,
|
|
19
|
+
)
|
|
20
|
+
from .proxy import (
|
|
21
|
+
ListProxiesItem,
|
|
22
|
+
ListProxiesResult,
|
|
23
|
+
ShowProxyResult,
|
|
24
|
+
list_proxies,
|
|
25
|
+
show_proxy,
|
|
26
|
+
)
|
|
27
|
+
from .resolution import (
|
|
28
|
+
ResolvedSession,
|
|
29
|
+
resolve_session_repo_wide,
|
|
30
|
+
)
|
|
31
|
+
from .session import (
|
|
32
|
+
ForgeOpError,
|
|
33
|
+
ListSessionsItem,
|
|
34
|
+
ListSessionsResult,
|
|
35
|
+
ResetOverridesResult,
|
|
36
|
+
ResolveSessionResult,
|
|
37
|
+
SetOverrideResult,
|
|
38
|
+
list_sessions,
|
|
39
|
+
reset_session_overrides,
|
|
40
|
+
resolve_session,
|
|
41
|
+
set_session_override,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"ExecutionContext",
|
|
46
|
+
"ForgeOpError",
|
|
47
|
+
# GC ops
|
|
48
|
+
"CleanError",
|
|
49
|
+
"CleanReport",
|
|
50
|
+
"CleanResult",
|
|
51
|
+
"OrphanCategory",
|
|
52
|
+
"collect_clean_report",
|
|
53
|
+
"run_clean",
|
|
54
|
+
# Resolution ops
|
|
55
|
+
"ResolvedSession",
|
|
56
|
+
"resolve_session_repo_wide",
|
|
57
|
+
# Session ops
|
|
58
|
+
"ListSessionsItem",
|
|
59
|
+
"ListSessionsResult",
|
|
60
|
+
"list_sessions",
|
|
61
|
+
"ResolveSessionResult",
|
|
62
|
+
"resolve_session",
|
|
63
|
+
"SetOverrideResult",
|
|
64
|
+
"set_session_override",
|
|
65
|
+
"ResetOverridesResult",
|
|
66
|
+
"reset_session_overrides",
|
|
67
|
+
# Proxy ops
|
|
68
|
+
"ListProxiesItem",
|
|
69
|
+
"ListProxiesResult",
|
|
70
|
+
"ShowProxyResult",
|
|
71
|
+
"list_proxies",
|
|
72
|
+
"show_proxy",
|
|
73
|
+
]
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Command-core execution context.
|
|
2
|
+
|
|
3
|
+
This context is intentionally lightweight: it carries paths only.
|
|
4
|
+
Stores (SessionStore/IndexStore/etc.) are cheap file wrappers and should be
|
|
5
|
+
constructed inside ops as needed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class ExecutionContext:
|
|
16
|
+
"""Execution context for command-core ops.
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
cwd: Current working directory.
|
|
20
|
+
worktree_root: Git checkout root (= checkout_root in the identity model).
|
|
21
|
+
project_root: Git repository root (= logical repo, main checkout).
|
|
22
|
+
forge_root: Forge project root (directory containing .forge/), or None
|
|
23
|
+
if not inside a Forge project.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
cwd: Path
|
|
27
|
+
worktree_root: Path
|
|
28
|
+
project_root: Path
|
|
29
|
+
forge_root: Path | None = None
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_cwd(cls, cwd: Path | None = None) -> "ExecutionContext":
|
|
33
|
+
"""Create context by deriving paths from the current working directory.
|
|
34
|
+
|
|
35
|
+
Uses git to find the worktree/project root. Falls back to cwd if not
|
|
36
|
+
in a git repository.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
cwd: Working directory. Defaults to Path.cwd().
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
ExecutionContext with derived paths.
|
|
43
|
+
"""
|
|
44
|
+
if cwd is None:
|
|
45
|
+
cwd = Path.cwd().resolve()
|
|
46
|
+
else:
|
|
47
|
+
cwd = cwd.resolve()
|
|
48
|
+
|
|
49
|
+
# Try to find git root (works for both regular repos and worktrees)
|
|
50
|
+
worktree_root = _find_git_root(cwd)
|
|
51
|
+
if worktree_root is None:
|
|
52
|
+
# Not in a git repo: use cwd for all paths
|
|
53
|
+
forge_root = find_forge_root(cwd)
|
|
54
|
+
return cls(cwd=cwd, worktree_root=cwd, project_root=cwd, forge_root=forge_root)
|
|
55
|
+
|
|
56
|
+
# For worktrees, find the main repository root
|
|
57
|
+
project_root = _find_main_repo_root(worktree_root)
|
|
58
|
+
|
|
59
|
+
# Find Forge project root (.forge/ directory)
|
|
60
|
+
forge_root = find_forge_root(cwd)
|
|
61
|
+
|
|
62
|
+
return cls(cwd=cwd, worktree_root=worktree_root, project_root=project_root, forge_root=forge_root)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _find_git_root(start: Path) -> Path | None:
|
|
66
|
+
"""Find git root by walking up from start.
|
|
67
|
+
|
|
68
|
+
Returns None if not in a git repository.
|
|
69
|
+
"""
|
|
70
|
+
current = start
|
|
71
|
+
while current != current.parent:
|
|
72
|
+
if (current / ".git").exists():
|
|
73
|
+
return current
|
|
74
|
+
current = current.parent
|
|
75
|
+
|
|
76
|
+
# Check root
|
|
77
|
+
if (current / ".git").exists():
|
|
78
|
+
return current
|
|
79
|
+
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _find_main_repo_root(worktree_root: Path) -> Path:
|
|
84
|
+
"""Find the main repository root from a worktree.
|
|
85
|
+
|
|
86
|
+
In a regular repo, .git is a directory and we return worktree_root.
|
|
87
|
+
In a worktree, .git is a file containing 'gitdir: <path>' pointing to
|
|
88
|
+
the worktree's git dir inside the main repo's .git/worktrees/.
|
|
89
|
+
"""
|
|
90
|
+
git_path = worktree_root / ".git"
|
|
91
|
+
|
|
92
|
+
if git_path.is_dir():
|
|
93
|
+
# Regular repo, not a worktree
|
|
94
|
+
return worktree_root
|
|
95
|
+
|
|
96
|
+
if git_path.is_file():
|
|
97
|
+
# Worktree: .git file contains 'gitdir: <path>'
|
|
98
|
+
try:
|
|
99
|
+
content = git_path.read_text().strip()
|
|
100
|
+
if content.startswith("gitdir:"):
|
|
101
|
+
gitdir = content[7:].strip()
|
|
102
|
+
# gitdir is typically: /path/to/main/.git/worktrees/<name>
|
|
103
|
+
# We want: /path/to/main
|
|
104
|
+
gitdir_path = Path(gitdir)
|
|
105
|
+
if not gitdir_path.is_absolute():
|
|
106
|
+
gitdir_path = (worktree_root / gitdir_path).resolve()
|
|
107
|
+
|
|
108
|
+
# Navigate up from .git/worktrees/<name> to find main repo
|
|
109
|
+
if "worktrees" in gitdir_path.parts:
|
|
110
|
+
# Find .git directory (parent of worktrees)
|
|
111
|
+
idx = gitdir_path.parts.index("worktrees")
|
|
112
|
+
git_dir = Path(*gitdir_path.parts[:idx])
|
|
113
|
+
if git_dir.name == ".git":
|
|
114
|
+
return git_dir.parent
|
|
115
|
+
except (OSError, ValueError):
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
# Fallback: return worktree_root
|
|
119
|
+
return worktree_root
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def find_forge_root(start: Path) -> Path | None:
|
|
123
|
+
"""Find the Forge project root by walking up from start.
|
|
124
|
+
|
|
125
|
+
A Forge project root is a directory containing a ``.forge/`` subdirectory,
|
|
126
|
+
established by ``forge extension enable``.
|
|
127
|
+
|
|
128
|
+
Stops at git repository boundaries (``.git`` directory or file) to avoid
|
|
129
|
+
escaping into a parent repository's ``.forge/``.
|
|
130
|
+
|
|
131
|
+
Returns None if not inside a Forge project.
|
|
132
|
+
"""
|
|
133
|
+
current = start
|
|
134
|
+
while current != current.parent:
|
|
135
|
+
if (current / ".forge").is_dir():
|
|
136
|
+
return current
|
|
137
|
+
if (current / ".git").exists():
|
|
138
|
+
return None # Hit git boundary without finding .forge/
|
|
139
|
+
current = current.parent
|
|
140
|
+
|
|
141
|
+
return None
|