diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/APM-Packages.iml b/.idea/APM-Packages.iml new file mode 100644 index 0000000..7aa9c26 --- /dev/null +++ b/.idea/APM-Packages.iml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..90b8dcb --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/apm-omos-agent-teams-v3.md b/apm-omos-agent-teams-v3.md new file mode 100644 index 0000000..01cb359 --- /dev/null +++ b/apm-omos-agent-teams-v3.md @@ -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 ` 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. \ No newline at end of file diff --git a/omos/omos.py b/omos/omos.py new file mode 100644 index 0000000..7d2b865 --- /dev/null +++ b/omos/omos.py @@ -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}: " + ) + + 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())