feat(omos): sidecar adapter prototype

Deterministic translator between APM team profiles and
oh-my-opencode-slim presets. Reads team-profile.yaml, resolves
model classes against a user-local mapping table, generates
namespaced presets plus provenance. Includes check (dry-run),
merge-by-preset-id, custom agent generation from purpose fields,
and MCP availability warnings.
This commit is contained in:
Tobias J. Endres 2026-08-23 17:03:40 +02:00
parent 8ada9fb6e4
commit 907f43d40b
7 changed files with 913 additions and 0 deletions

3
.idea/.gitignore generated vendored Normal file
View file

@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

10
.idea/APM-Packages.iml generated Normal file
View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PackageRequirementsSettings" />
<component name="ReSTService" />
</module>

View file

@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

8
.idea/modules.xml generated Normal file
View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/APM-Packages.iml" filepath="$PROJECT_DIR$/.idea/APM-Packages.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

453
apm-omos-agent-teams-v3.md Normal file
View file

@ -0,0 +1,453 @@
# APM, Agent-Teams und das kleine Problem namens Realität
*Wie APM-Packages Team-Empfehlungen ausliefern können mit oh-my-opencode-slim als erstem konkretem Target.*
Der Agenten-Stack entwickelt sich gerade mit der Geschwindigkeit eines explodierenden Werkzeugschuppens. Jeden Monat erscheint ein neues Manifest, ein neues Plugin-Format oder eine neue Orchestrierungsebene. Alles löst einen legitimen Teil des Problems. Fast nichts passt nahtlos zusammen.
Das ist kein Vorwurf. Es ist der normale Zustand eines jungen Ökosystems. Der interessante Teil beginnt dort, wo die bestehenden Stücke bereits ausreichen, um eine sinnvolle Brcke zu bauen.
Diese Brcke kann so aussehen:
> Ein APM-Package liefert Skills, Instructions, MCP-Abhngigkeiten und eine deklarative Empfehlung fr ein Agenten-Team. Ein Harness-spezifischer Adapter übersetzt diese Empfehlung in die lokale Team- und Modellkonfiguration.
Fr oh-my-opencode-slim nenne ich diesen Adapter im Folgenden **`omos`**.
`omos` ist dabei ein Design fr eine Erweiterung bzw. ein Sidecar-Tool. Es ist heute kein offiziell eingebautes APM-Target.
## Das Ökosystem heute
Die vorhandenen Formate lassen sich grob in fnf Gruppen einteilen.
| Ebene | Beispiele | Zustndigkeit |
|---|---|---|
| Portable Komponenten | Agent Plugins | Skills und MCP-Server verteilen |
| Dependency Management | APM | Packages, Lockfile, Policies, Deployment |
| Team-Komposition | Spawnfile, Harness-Configs | Rollen, Delegation, Agentenbeziehungen |
| Runtime-Profile | oh-my-opencode-slim Presets | Modelle und Optionen je Agent |
| Governance | Agent Manifest | Identitt, Grenzen, Verantwortung, Auditierbarkeit |
Das Problem ist nicht fehlende Funktionalitt. Das Problem ist die Lcke zwischen diesen Schichten.
### Agent Plugins
Agent Plugins 1.0 definiert ein bewusst kleines, portables Plugin-Format. Im Kern enthlt ein Plugin eine `plugin.json` und kann Skills sowie MCP-Server bereitstellen. Erweiterungen sind mglich, mssen aber im `extensions`-Block ber einen Reverse-Domain-Namespace isoliert werden.
Das ist sehr gut fr Interoperabilitt. Ein Skill bleibt ein Skill. Ein MCP bleibt ein MCP. Der Standard versucht nicht, die Laufzeit eines jeden Agentenframeworks zu regieren.
Offen bleiben jedoch Dependencies, getestete Versionen, Teamrollen, Modellprofile und die Aktivierung im konkreten Harness.
### APM
APM besetzt die nchste Ebene: Package- und Dependency-Management fr Agent-Kontext.
Ein APM-Manifest kann Abhngigkeiten auf andere APM-Packages, MCP-Server und LSP-Server beschreiben. APM verwaltet Auflsung, Lockfile und Installation. ber Targets kompiliert es Primitives in Harness-spezifische Dateien. Das native `opencode`-Target installiert Skills, Agents und Commands in die passenden `.opencode/`-Pfade.
```yaml
name: acme/research-assistant
version: 1.0.0
type: hybrid
targets:
- opencode
dependencies:
apm:
- acme/base-engineering
mcp:
- name: repository-search
registry: false
transport: stdio
command: repository-search-mcp
tools: [search_code, read_file]
```
APM beantwortet damit: Welche Artefakte gehren zusammen? Welche Versionen wurden zusammen getestet? Welche MCPs werden bentigt? Wohin werden Skills und Instructions installiert?
Was APM aktuell nicht standardisiert beschreibt, ist Team-Semantik: Rollen, Delegation, Modell-Empfehlungen und Aktivierung eines Team-Profils.
### Spawnfile
Spawnfile adressiert die Teamachse. Agenten und Teams knnen deklarativ mit Rollen, Zustndigkeiten, Kommunikationswegen und Subagenten beschrieben werden. Die offene Stelle liegt bei Dependencies und Verteilung: Ein Team-Manifest installiert keine versionierten Skills, MCPs und Policies.
### oh-my-opencode-slim
oh-my-opencode-slim sitzt nahe an der Runtime. Sein Preset-System kann je Agent Modell, Temperatur, Variante und provider-spezifische Optionen umschalten. Presets werden ber `/preset <name>` whrend einer laufenden Session aktiviert.
```jsonc
{
"presets": {
"deep-review": {
"orchestrator": { "model": "provider/strong-generalist" },
"oracle": { "model": "provider/high-reasoning", "variant": "thinking" },
"librarian": { "model": "provider/fast-research" }
}
}
}
```
Das beantwortet die Frage, wie unterschiedliche Rollen unterschiedliche Modellprofile erhalten. Es fehlt die Herkunft: Welches Package empfiehlt dieses Team? Welche Skills und MCPs gehren dazu? Welche Version wurde getestet?
## Die Lcke
```mermaid
flowchart LR
AP[Agent Plugins] -->|Skills und MCPs| PK[Package-Inhalt]
APM[APM] -->|Dependencies, Lockfile, Installation| PK
SF[Spawnfile] -->|Rollen und Teamstruktur| TM[Team-Modell]
OMOS[oh-my-opencode-slim] -->|Presets, Modelle, Runtime-Switching| RT[aktive Runtime]
PK -. fehlende Verbindung .-> TM
TM -. fehlende Verbindung .-> RT
```
Die Lcke verlangt kein zehntes allumfassendes YAML-Format. Sie verlangt eine kleine Kompositionsschicht.
## Die APM-OMOS-Idee
Ein APM-Package bleibt Source of Truth fr Skills, Instructions, MCP-Abhngigkeiten, Versionen, Dependency-Closure und Policies. Zusstzlich enthlt es ein Team-Profil als Empfehlung fr Harness-Adapter.
```yaml
schema: acme.team-profile/v1
id: acme.document-research
description: Team-Profil fr Recherche, Erstellung und Qualittsprfung quellenbasierter Dokumente.
roles:
- id: orchestrator
purpose: Zerlegt Aufgaben, delegiert, integriert Ergebnisse
model_class: strong-generalist
- id: researcher
purpose: Sammelt und bewertet Quellen
model_class: fast-research
capabilities:
mcps: [web-research, repository-search]
skills: [job-matching]
- id: writer
purpose: Erstellt strukturierte Dokumententwrfe
model_class: strong-writing
capabilities:
mcps: [document-write]
skills: [application-writing]
- id: checker
purpose: Prft Quellenbezug, Konsistenz und Vollstndigkeit
model_class: high-reasoning
capabilities:
mcps: [document-read]
skills: [application-review]
```
Das Profil verwendet Modellklassen wie `high-reasoning` statt konkreter Modell-IDs. Der Nutzer hinterlegt in einer lokalen Mapping-Tabelle, welche Modell-IDs fr welche Modellklasse verwendet werden sollen.
```yaml
# ~/.config/apm-team/model-mapping.yaml
model_classes:
strong-generalist: anthropic/claude-sonnet-4-6
fast-research: openai/gpt-5.4-mini
strong-writing: anthropic/claude-sonnet-4-6
high-reasoning: openai/gpt-5.5
```
## Was `omos` macht
`omos` ist der Übersetzer zwischen Team-Profil und oh-my-opencode-slim. Er liest installierte APM-Packages, findet Team-Profile, schlägt Modellklassen gegen die lokale Mapping-Tabelle nach und generiert namespacete Presets fr das Projekt.
```mermaid
flowchart TD
P[APM Package: team-profile.yaml] --> O[omos liest]
M[~/.config/apm-team/model-mapping.yaml] --> O
O -->|Prfe| X{Alle Modellklassen gemappt?}
X -->|Nein| E[Fehler: fehlendes Mapping fr Rolle Y]
X -->|Ja| G[Generiere OMOS-Preset mit konkreten Modell-IDs]
G --> V[Validiere: Modelle in OMOS verfgbar?]
V -->|Nein| F[Fehler: Modell Z nicht im OMOS-Setup]
V -->|Ja| C[Schreibe .opencode/oh-my-opencode-slim.json]
C --> D[Fertig]
```
`omos` rtt nicht. Er übersetzt deterministisch:
1. Team-Profil lesen (Modellklassen + MCP/Skill-Capabilities).
2. Lokale Mapping-Tabelle laden (Modellklasse → konkrete Modell-ID).
3. OMOS-Preset generieren (konkrete Modell-IDs + MCP/Skill-Allowlists).
4. Validieren: Sind die Modelle im OMOS-Setup verfgbar?
5. Preset in `.opencode/oh-my-opencode-slim.json` schreiben.
## Mehrere Packages, ein Projekt
Jedes Profil erhlt eine globale, stabile ID wie `acme.job-applications`, `acme.security-audit` oder `acme.code-review`. `omos` merged Presets anhand dieser IDs. Das aktive Team wird nicht durch die letzte Installation gewhlt.
```mermaid
flowchart LR
A[Package: Job Applications] --> M[Preset Merge]
B[Package: Security Audit] --> M
C[Package: Code Review] --> M
M --> P[Projekt-Preset-Registry]
P --> U[Nutzer oder expliziter Workflow]
U --> S[/preset acme-job-applications]
```
## Beispiel: Autobewerber als Team-Package
Ein Bewerbungsassistent eignet sich gut als Beispiel: Web-Recherche, personenbezogene Daten, Dokumentenproduktion, Qualittsprfung, Benachrichtigungen und externe Aktionen treffen zusammen. Der Mechanismus bleibt derselbe fr Code-Review, Sales Research, Compliance oder Incident Response.
### Rollen und OMOS-Mapping
| Fachliche Rolle | OMOS-Rolle | Zweck |
|---|---|---|
| Koordination | `orchestrator` | Zerlegt Aufgaben, delegiert, integriert |
| Recherche | `librarian` (Alias `researcher`) | Sucht und bewertet Stellen |
| Schreiben | Custom Agent `writer` | Erstellt Anschreiben und Unterlagen |
| Prfung | `oracle` (Alias `checker`) | Prft Fakten, Ton, Vollstndigkeit |
| Benachrichtigung | Custom Agent `notifier` | Informiert den Nutzer |
### Was OMOS tatschlich begrenzen kann
OMOS bietet pro Agent zwei brauchbare Kontrollflchen:
1. **MCP-Zugriff** (`mcps`-Array)
2. **Skill-Zugriff** (`skills`-Array)
Beides kann je Agent in einem Preset als Allowlist formuliert werden. `[]` bedeutet keine Freigabe, `["!*"]` verweigert alles; bei Konflikten gewinnt die Verweigerung.
```jsonc
{
"presets": {
"acme-job-applications": {
"orchestrator": {
"model": "anthropic/claude-sonnet-4-6",
"mcps": [],
"skills": ["job-orchestration"]
},
"librarian": {
"displayName": "researcher",
"model": "openai/gpt-5.4-mini",
"mcps": ["job-search", "candidate-profile-read"],
"skills": ["job-matching"]
},
"writer": {
"model": "anthropic/claude-sonnet-4-6",
"mcps": ["candidate-profile-read", "overleaf"],
"skills": ["application-writing"]
},
"oracle": {
"displayName": "checker",
"model": "openai/gpt-5.5",
"variant": "high",
"mcps": ["candidate-profile-read", "overleaf"],
"skills": ["application-review"]
},
"notifier": {
"model": "openai/gpt-5.4-mini",
"mcps": ["notification-draft"],
"skills": []
}
}
}
}
```
Damit erhlt der Researcher keinen Overleaf-Zugang. Der Writer sieht keine Job-Suchtools. Der Notifier bekommt keinen Browser und keinen Schreibzugriff auf die Bewerbung.
### Die wichtige Einschrnkung
`mcps` und `skills` schtzen nur den Zugriff auf genau diese zwei Ebenen. Die Aussage „der Agent darf niemals eine Bewerbung absenden" bentigt Verteidigung in mehreren Schichten:
| Schicht | Durchsetzung |
|---|---|
| OMOS-Rollenprompt | Klare Verhaltensregel: nie einreichen |
| OMOS MCP-Allowlist | `application-submit` MCP gar nicht zuweisen |
| MCP-Server selbst | Submission-Tool verlangt Approval-Token |
| Workflow-State | `submit` nur aus Zustand `user_approved` erlaubt |
| OpenCode-/Sandbox-Policy | Schreib-, Shell- und Browserrechte begrenzen |
| User Interface | Explizite, sichtbare Freigabe vor jeder Auenwirkung |
Die echte Autorisierungsgrenze muss am **MCP beziehungsweise Backend** liegen. Ein Prompt ist eine Verhaltensanweisung. Eine MCP-Allowlist ist Capability Scoping. Ein Server, der ohne gltiges Freigabe-Token keine Submission annimmt, ist die harte Grenze.
Fr deinen Use Case wrde ich den Notification-MCP so schneiden:
```text
notification-draft → darf Entwurf erzeugen
notification-send → braucht userApprovalId
application-submit → braucht userApprovalId + applicationDraftId
```
Und im Backend:
```mermaid
sequenceDiagram
participant C as Checker
participant N as Notifier MCP
participant U as Nutzer
participant S as Submission MCP
C->>N: createDraftNotification(applicationDraftId)
N-->>U: Stelle gefunden, Entwurf geprft
U->>U: Prft PDF und Fakten
U->>S: approve(applicationDraftId)
S-->>S: issue userApprovalId
U->>S: submit(applicationDraftId, userApprovalId)
S-->>U: Bewerbung eingereicht
```
Der Notifier kann dann technisch niemals eine Bewerbung absenden, weil er keinen Submission-MCP besitzt und das Submission-Backend einen vom Nutzer stammenden Freigabenachweis verlangt.
### Das Team-Profil
```yaml
schema: acme.team-profile/v1
id: acme.job-applications
description: Human-in-the-loop-Team zur Recherche, Vorbereitung und Prfung individueller Bewerbungsunterlagen.
roles:
- id: orchestrator
purpose: Zerlegt Aufgaben, delegiert, integriert Ergebnisse
model_class: strong-generalist
capabilities:
mcps: []
skills: [job-orchestration]
- id: researcher
purpose: Sucht und bewertet Stellenausschreibungen
model_class: fast-research
capabilities:
mcps: [job-search, candidate-profile-read]
skills: [job-matching]
- id: writer
purpose: Erstellt auf Fakten basierende Anschreiben
model_class: strong-writing
capabilities:
mcps: [candidate-profile-read, overleaf]
skills: [application-writing]
- id: checker
purpose: Prft Fakten, Ton und Vollstndigkeit
model_class: high-reasoning
capabilities:
mcps: [candidate-profile-read, overleaf]
skills: [application-review]
- id: notifier
purpose: Informiert den Nutzer ber geprfte Entwrfe
model_class: cheap-reliable
capabilities:
mcps: [notification-draft]
skills: []
```
Die lokale Mapping-Tabelle:
```yaml
# ~/.config/apm-team/model-mapping.yaml
model_classes:
strong-generalist: anthropic/claude-sonnet-4-6
fast-research: openai/gpt-5.4-mini
strong-writing: anthropic/claude-sonnet-4-6
high-reasoning: openai/gpt-5.5
cheap-reliable: openai/gpt-5.4-mini
```
Das generierte OMOS-Preset:
```jsonc
{
"presets": {
"acme-job-applications": {
"orchestrator": {
"model": "anthropic/claude-sonnet-4-6",
"mcps": [],
"skills": ["job-orchestration"]
},
"librarian": {
"displayName": "researcher",
"model": "openai/gpt-5.4-mini",
"mcps": ["job-search", "candidate-profile-read"],
"skills": ["job-matching"]
},
"writer": {
"model": "anthropic/claude-sonnet-4-6",
"mcps": ["candidate-profile-read", "overleaf"],
"skills": ["application-writing"]
},
"oracle": {
"displayName": "checker",
"model": "openai/gpt-5.5",
"variant": "high",
"mcps": ["candidate-profile-read", "overleaf"],
"skills": ["application-review"]
},
"notifier": {
"model": "openai/gpt-5.4-mini",
"mcps": ["notification-draft"],
"skills": []
}
}
}
}
```
Das Ganze lsst sich ber ein OMOS-Preset aktivieren:
```text
/preset acme-job-applications
```
Der Nutzer erhlt danach keine unsichtbare Bewerbungsmaschine. Er erhlt ein explizit aktiviertes, nachvollziehbares Team mit klaren Zustndigkeiten.
## Wie das heute beginnen kann
Die Idee muss nicht auf eine APM-Spec-nderung warten.
Ein erster Prototyp braucht nur drei Bausteine:
1. **Ein normales APM-Package**
Skills, Instructions und MCP-Abhngigkeiten werden ber APM installiert.
2. **Ein Team-Profil als zustzliche Package-Datei**
Zum Beispiel `team-profile.yaml`, mit versioniertem Schema und eindeutiger ID.
3. **Ein `omos` Sidecar-CLI**
Es liest die installierten Packages, generiert namespacete OMOS-Presets und validiert Konflikte.
Der Ablauf wre:
```bash
apm install acme/job-application-team
apm-team render --harness omos
opencode
```
Dann:
```text
/preset acme-job-applications
```
APM selbst muss dafr zunchst keine unbekannten Targets akzeptieren. Tatschlich kennt das aktuelle Manifest nur einen festen Satz dokumentierter Targets; unbekannte Zielnamen fhren zu einem Fehler.
Das Sidecar ist daher der pragmatische Anfang. Es kann als externes Tool reifen, Daten ber realistische Nutzung liefern und spter als offizieller APM-Adapter oder als Erweiterung im APM-Ö´kosystem landen.
## Der Kern
Die Idee lautet nicht: „APM wird jetzt ein Multi-Agent-Framework."
APM soll sein, was es gut kann: Package-Management fr Agent-Kontext.
oh-my-opencode-slim soll sein, was es gut kann: konkrete Rollen, Modelle und Laufzeit-Presets verwalten.
Dazwischen liegt ein kleines, wertvolles Stck Infrastruktur:
> Ein APM-Package beschreibt nicht nur, welche Fhigkeiten installiert werden. Es kann auch erklren, welches Team diese Fhigkeiten sinnvoll verwendet.
Damit wird ein Package vom Ordner voller Skills zu einem reproduzierbaren Arbeitsmodell:
- versionierte Rollen;
- berprfbare Tool-Zugriffe;
- lokale Modell-Policies;
- mehrere Teams pro Projekt;
- explizite Aktivierung;
- menschliche Freigabe an kritischen Grenzen.
Und das ist wesentlich ntzlicher als ein weiterer „autonomer Agent", der nach drei Minuten Browserzugriff beschliet, dass er nun Personalabteilung spielen darf.

427
omos/omos.py Normal file
View file

@ -0,0 +1,427 @@
#!/usr/bin/env python3
"""omos Adapter zwischen APM-Team-Profilen und oh-my-opencode-slim.
Liest ein team-profile.yaml aus einem APM-Package, löst Modellklassen gegen
eine lokale Mapping-Tabelle auf und generiert ein namespacetes Preset für
oh-my-opencode-slim.
Kommandos:
omos render Team-Profil in OMOS-Preset übersetzen und schreiben
omos check Validieren ohne zu schreiben (Dry-Run)
Beispiele:
python3 omos.py render --package examples/package --mapping ~/.config/apm-team/model-mapping.yaml
python3 omos.py check --package examples/package
"""
from __future__ import annotations
import argparse
import copy
import json
import re
import sys
from pathlib import Path
import yaml
# Eingebaute OMOS-Agenten. Alles andere wird als Custom Agent behandelt.
BUILTIN_AGENTS = {
"orchestrator",
"oracle",
"librarian",
"explorer",
"fixer",
"designer",
"council",
"observer",
}
DEFAULT_MAPPING_PATH = Path.home() / ".config" / "apm-team" / "model-mapping.yaml"
DEFAULT_TEAM_FILE = "team-profile.yaml"
DEFAULT_OUTPUT = Path(".opencode") / "oh-my-opencode-slim.json"
PROVENANCE_SUFFIX = ".omos-provenance.json"
SUPPORTED_SCHEMA = ("corentic.team-profile/v1", "acme.team-profile/v1")
class OmosError(Exception):
"""Fehler mit nutzerlesbarer Ursache."""
# ---------------------------------------------------------------------------
# Laden
def load_yaml(path: Path) -> dict:
if not path.exists():
raise OmosError(f"Datei nicht gefunden: {path}")
try:
data = yaml.safe_load(path.read_text(encoding="utf-8"))
except yaml.YAMLError as exc:
raise OmosError(f"YAML-Fehler in {path}: {exc}") from exc
if not isinstance(data, dict):
raise OmosError(f"{path} enthält kein YAML-Mapping")
return data
def load_team_profile(package_dir: Path, team_file: str | None) -> tuple[dict, Path]:
candidates = (
[package_dir / team_file]
if team_file
else [package_dir / DEFAULT_TEAM_FILE, package_dir / "team.yaml"]
)
for candidate in candidates:
if candidate.exists():
profile = load_yaml(candidate)
schema = profile.get("schema")
if schema and schema not in SUPPORTED_SCHEMA:
print(
f"Warnung: Unbekanntes Schema '{schema}'. "
f"Unterstützt: {', '.join(SUPPORTED_SCHEMA)}. Fahre fort."
)
if not profile.get("id"):
raise OmosError(f"{candidate}: Feld 'id' fehlt")
roles = profile.get("roles")
if not isinstance(roles, list) or not roles:
raise OmosError(f"{candidate}: Keine Rollen definiert ('roles')")
return profile, candidate
searched = ", ".join(str(c) for c in candidates)
raise OmosError(f"Kein Team-Profil gefunden. Gesucht: {searched}")
def load_mapping(mapping_path: Path | None) -> dict:
path = mapping_path or DEFAULT_MAPPING_PATH
if not path.exists():
raise OmosError(
f"Modell-Mapping nicht gefunden: {path}\n"
"Lege die Datei an, z. B.:\n"
" model_classes:\n"
" fast-research: ollama/qwen3.5:9b\n"
" high-reasoning:\n"
" model: ollama/qwen3.6:35b-a3b-q4_K_M\n"
" variant: thinking"
)
data = load_yaml(path)
classes = data.get("model_classes")
if not isinstance(classes, dict) or not classes:
raise OmosError(f"{path}: Sektion 'model_classes' fehlt oder ist leer")
return classes
def resolve_model(model_class_entry: object, model_class: str, role_id: str) -> dict:
"""Löst einen Mapping-Eintrag (string oder dict) zu Agent-Feldern auf."""
if isinstance(model_class_entry, str):
return {"model": model_class_entry}
if isinstance(model_class_entry, dict):
entry = {k: v for k, v in model_class_entry.items()}
if "model" not in entry:
raise OmosError(
f"Rolle '{role_id}': Mapping für '{model_class}' hat kein 'model'-Feld"
)
return entry
raise OmosError(f"Ungültiger Mapping-Eintrag für '{model_class}': {model_class_entry!r}")
def sanitize_preset_name(team_id: str) -> str:
return re.sub(r"[^a-zA-Z0-9_-]+", "-", team_id).strip("-").lower()
# ---------------------------------------------------------------------------
# Übersetzung
def build_preset(profile: dict, mapping: dict) -> tuple[dict, dict, list[str]]:
"""Erzeugt (preset, custom_agents, warnings)."""
preset: dict = {}
custom_agents: dict = {}
warnings: list[str] = []
for role in profile["roles"]:
role_id = role.get("id")
if not role_id:
raise OmosError("Rolle ohne 'id' gefunden")
model_class = role.get("model_class")
if not model_class:
raise OmosError(f"Rolle '{role_id}': 'model_class' fehlt")
if model_class not in mapping:
raise OmosError(
f"Rolle '{role_id}': Modellklasse '{model_class}' ist nicht gemappt.\n"
f"Ergänze in deinem Mapping:\n"
f" {model_class}: <provider/model>"
)
agent_fields = resolve_model(mapping[model_class], model_class, role_id)
capabilities = role.get("capabilities", {}) or {}
mcps = list(capabilities.get("mcps", []) or [])
skills = list(capabilities.get("skills", []) or [])
purpose = str(role.get("purpose", "")).strip()
omos_agent = role.get("omos_agent", role_id)
if omos_agent == "custom":
# Explizit als Custom Agent markiert → rolleneigener Name.
agent_key = role_id
else:
agent_key = omos_agent
preset[agent_key] = {
**agent_fields,
"mcps": mcps,
"skills": skills,
}
if agent_key in BUILTIN_AGENTS and agent_key != role_id:
preset[agent_key]["displayName"] = role_id
if agent_key not in BUILTIN_AGENTS or omos_agent == "custom":
if agent_key not in custom_agents:
prompt = (
purpose
if purpose
else f"Custom Agent '{role_id}' aus Team-Profil "
f"'{profile.get('id', 'unbekannt')}'."
)
custom_agents[agent_key] = {
"model": agent_fields["model"],
"description": purpose or f"Custom subagent '{role_id}'",
"prompt": prompt,
# Dem Orchestrator sagen, wann er delegieren soll.
"orchestratorPrompt": (
f"@{agent_key}\n- Rolle: {purpose}\n"
"- Delegiere Aufgaben dieser Rolle an diesen Agenten."
if purpose
else f"@{agent_key}"
),
}
else:
custom_agents[agent_key]["model"] = agent_fields["model"]
if not mcps and not skills:
warnings.append(
f"Rolle '{role_id}' ({agent_key}): keine MCPs/Skills zugewiesen "
"(rein koordinierend?)"
)
orchestrator_present = any(k == "orchestrator" for k in preset)
if not orchestrator_present:
warnings.append(
"Kein 'orchestrator' im Team. Ohne Orchestrator-Preset-Eintrag bleibt "
"dessen Modell unverändert."
)
return preset, custom_agents, warnings
def merge_into_config(config: dict, preset_name: str, preset: dict,
custom_agents: dict) -> dict:
merged = copy.deepcopy(config)
presets = merged.setdefault("presets", {})
existing = presets.get(preset_name)
if existing:
print(
f"Hinweis: Preset '{preset_name}' existierte bereits und wird ersetzt "
"(andere Presets bleiben unberührt)."
)
presets[preset_name] = preset
agents = merged.setdefault("agents", {})
agents.update(custom_agents)
return merged
# ---------------------------------------------------------------------------
# MCP-Verfügbarkeit prüfen (best effort)
def strip_jsonc(text: str) -> str:
text = re.sub(r"/\*.*?\*/", "", text, flags=re.DOTALL)
text = re.sub(r"(^|\s)//[^\n]*", r"\1", text)
return text
def configured_mcps() -> set[str]:
candidates = [
Path.home() / ".config" / "opencode" / "opencode.jsonc",
Path.home() / ".config" / "opencode" / "opencode.json",
]
for path in candidates:
if path.exists():
try:
data = json.loads(strip_jsonc(path.read_text(encoding="utf-8")))
return set((data.get("mcp") or {}).keys())
except (json.JSONDecodeError, OSError):
continue
return set()
def check_mcps(preset: dict, warnings: list[str]) -> None:
available = configured_mcps()
if not available:
warnings.append(
"Konnte opencode.json(c) nicht lesen MCP-Prüfung übersprungen."
)
return
needed: set[str] = set()
for agent in preset.values():
needed.update(agent.get("mcps", []) or [])
missing = sorted(m for m in needed if m not in available)
if missing:
warnings.append(
"MCPs im Team-Profil, aber nicht in opencode.json konfiguriert: "
+ ", ".join(missing)
)
# ---------------------------------------------------------------------------
# Kommandos
def package_output_path(package_dir: Path) -> Path:
return package_dir / DEFAULT_OUTPUT
def cmd_render(args: argparse.Namespace) -> int:
package_dir = Path(args.package).resolve()
profile, profile_path = load_team_profile(package_dir, args.team_file)
mapping = load_mapping(Path(args.mapping) if args.mapping else None)
preset, custom_agents, warnings = build_preset(profile, mapping)
output = Path(args.output) if args.output else package_dir / DEFAULT_OUTPUT
if output.exists():
try:
config = json.loads(output.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise OmosError(f"{output} ist kein gültiges JSON: {exc}")
else:
config = {}
config["$schema"] = (
"https://unpkg.com/oh-my-opencode-slim@latest/"
"oh-my-opencode-slim.schema.json"
)
merged = merge_into_config(
config, sanitize_preset_name(str(profile["id"])), preset, custom_agents
)
if args.dry_run:
print(json.dumps(merged, indent=2, ensure_ascii=False))
_report(warnings, dry_run=True)
return 0
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding="utf-8")
print(f"Geschrieben: {output}")
provenance = {
"profile": profile["id"],
"profileFile": str(profile_path),
"teamId": profile["id"],
"description": profile.get("description", ""),
"presetName": sanitize_preset_name(str(profile["id"])),
"roles": {
r.get("id"): {
"omosAgent": r.get("omos_agent", r.get("id")),
"modelClass": r.get("model_class"),
"resolvedModel": mapping[r["model_class"]],
"mcps": (r.get("capabilities", {}) or {}).get("mcps", []),
"skills": (r.get("capabilities", {}) or {}).get("skills", []),
}
for r in profile["roles"]
},
}
provenance_path = output.with_suffix(PROVENANCE_SUFFIX)
provenance_path.write_text(
json.dumps(provenance, indent=2, ensure_ascii=False), encoding="utf-8"
)
print(f"Provenance: {provenance_path}")
_report(warnings)
print(
"\nNächste Schritte:\n"
" 1. opencode starten (Projekt-Config wird geladen)\n"
f" 2. /preset {sanitize_preset_name(str(profile['id']))}\n"
" 3. OpenCode neu laden → Team aktiv"
)
return 0
def cmd_check(args: argparse.Namespace) -> int:
args.dry_run = True
# Dry-Run: vorhandene Projekt-Config einbeziehen, aber nie schreiben.
existing = package_output_path(Path(args.package).resolve())
args.output = str(existing) if existing.exists() else None
try:
cmd_render(args)
except OmosError as exc:
print(f"Fehler: {exc}", file=sys.stderr)
return 1
return 0
def _report(warnings: list[str], dry_run: bool = False) -> None:
if dry_run:
print("\n--- Dry-Run: Es wurde nichts geschrieben ---")
if warnings:
print("\nWarnungen:")
for warning in warnings:
print(f"{warning}")
else:
print("\nKeine Warnungen.")
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="omos",
description=(
"Adapter zwischen APM-Team-Profilen und oh-my-opencode-slim. "
"Übersetzt deterministisch: Team-Profil + lokales Modell-Mapping "
"-> OMOS-Preset."
),
)
sub = parser.add_subparsers(dest="command", required=True)
def common(p: argparse.ArgumentParser) -> None:
p.add_argument(
"--package",
default=".",
help="Pfad zum APM-Package (mit team-profile.yaml)",
)
p.add_argument(
"--team-file",
help="Alternativer Dateiname des Team-Profils (Default: team-profile.yaml)",
)
p.add_argument(
"--mapping",
help=f"Pfad zur Modell-Mapping-Datei (Default: {DEFAULT_MAPPING_PATH})",
)
p_render = sub.add_parser("render", help="Preset generieren und schreiben")
common(p_render)
p_render.add_argument(
"--output",
help=f"Zieldatei (Default: {DEFAULT_OUTPUT} relativ zum Package)",
)
p_render.add_argument(
"--dry-run",
action="store_true",
help="Nur anzeigen, nichts schreiben",
)
p_render.set_defaults(func=cmd_render)
p_check = sub.add_parser("check", help="Validieren ohne zu schreiben")
common(p_check)
p_check.set_defaults(func=cmd_check)
args = parser.parse_args(argv)
try:
return args.func(args)
except OmosError as exc:
print(f"Fehler: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())