APM-Packages/corentic-package-manager/cpm.py
Tobias J. Endres 9a3b8d62f3 feat(cpm): agent_ref - compose APM-deployed agents into teams
Empirically verified: oh-my-opencode-slim preset entries keyed to an
APM-deployed native OpenCode agent (.opencode/agents/*.md) reconfigure
it in place - model override works, no duplicate agents are created.

- new optional role field 'agent_ref: <name>': persona (prompt,
  identity) comes from the APM primitive; cpm only resolves model_class
  locally and emits a preset entry under the deployed agent's name
- validation: warns when agent_ref target is missing from
  .opencode/agents/ (apm install not run yet) and when 'purpose' is
  redundantly set on agent_ref roles
- collision warning: inline roles shadowing deployed native agents
  suggest using agent_ref instead
- microsoft-design-review example: reviewer role now uses
  agent_ref: design-reviewer, showcasing both role forms
- README: new section on the relationship to APM agent primitives
- blog-review.md: verified findings as material for a blog addendum
2026-08-23 19:37:21 +02:00

757 lines
26 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""cpm Corentic Package Manager.
A CPM package is an agent package consisting of an APM manifest plus a team
configuration (team-profile.yaml). The CPM translates this package into the
native configuration of a target harness. First translation target:
oh-my-opencode-slim. Further targets (oh-my-pi, codex, claude, copilot) are
planned.
Reads a team-profile.yaml, resolves model classes against a local mapping
table and generates a namespaced preset for the target harness.
Commands:
cpm init Create user mapping file (optionally fill interactively)
cpm setup Guided: read profile, fill mapping, render
cpm render Translate team profile into a harness preset and write it
cpm check Validate without writing (dry-run)
Examples:
python3 cpm.py setup --package examples/package
python3 cpm.py render --package examples/package --mapping ~/.config/cpm/model-mapping.yaml
python3 cpm.py check --package examples/package
"""
from __future__ import annotations
import argparse
import copy
import json
import re
import shutil
import subprocess
import sys
from pathlib import Path
import yaml
# Built-in agents of the first translation target oh-my-opencode-slim.
# Everything else is treated as a custom agent.
BUILTIN_AGENTS = {
"orchestrator",
"oracle",
"librarian",
"explorer",
"fixer",
"designer",
"council",
"observer",
}
DEFAULT_MAPPING_PATH = Path.home() / ".config" / "cpm" / "model-mapping.yaml"
DEFAULT_TEAM_FILE = "team-profile.yaml"
DEFAULT_OUTPUT = Path(".opencode") / "oh-my-opencode-slim.json"
PROVENANCE_SUFFIX = ".cpm-provenance.json"
SUPPORTED_SCHEMA = ("corentic.team-profile/v1",)
class CpmError(Exception):
"""Error with a user-readable cause."""
# ---------------------------------------------------------------------------
# Loading
def load_yaml(path: Path) -> dict:
if not path.exists():
raise CpmError(f"File not found: {path}")
try:
data = yaml.safe_load(path.read_text(encoding="utf-8"))
except yaml.YAMLError as exc:
raise CpmError(f"YAML error in {path}: {exc}") from exc
if not isinstance(data, dict):
raise CpmError(f"{path} does not contain a 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"Warning: Unknown schema '{schema}'. "
f"Supported: {', '.join(SUPPORTED_SCHEMA)}. Continuing."
)
if not profile.get("id"):
raise CpmError(f"{candidate}: missing field 'id'")
roles = profile.get("roles")
if not isinstance(roles, list) or not roles:
raise CpmError(f"{candidate}: no roles defined ('roles')")
return profile, candidate
searched = ", ".join(str(c) for c in candidates)
raise CpmError(f"No team profile found. Searched: {searched}")
def load_mapping(mapping_path: Path | None) -> dict:
path = mapping_path or DEFAULT_MAPPING_PATH
if not path.exists():
raise CpmError(
f"Model mapping not found: {path}\n"
"Create the file, e.g.:\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 CpmError(f"{path}: section 'model_classes' missing or empty")
return classes
def resolve_model(model_class_entry: object, model_class: str, role_id: str) -> dict:
"""Resolve a mapping entry (string or dict) into agent fields."""
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 CpmError(
f"Role '{role_id}': mapping for '{model_class}' has no 'model' field"
)
return entry
raise CpmError(f"Invalid mapping entry for '{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()
# ---------------------------------------------------------------------------
# Translation
def deployed_native_agents(package_dir: Path) -> set[str]:
"""Names of APM-deployed native agents (.opencode/agents/*.md)."""
agents_dir = package_dir / ".opencode" / "agents"
if not agents_dir.exists():
return set()
return {p.stem for p in agents_dir.glob("*.md")}
def build_preset(profile: dict, mapping: dict,
package_dir: Path | None = None) -> tuple[dict, dict, list[str]]:
"""Returns (preset, custom_agents, warnings)."""
preset: dict = {}
custom_agents: dict = {}
warnings: list[str] = []
native = deployed_native_agents(package_dir) if package_dir else set()
for role in profile["roles"]:
role_id = role.get("id")
if not role_id:
raise CpmError("Found a role without 'id'")
model_class = role.get("model_class")
if not model_class:
raise CpmError(f"Role '{role_id}': 'model_class' is missing")
if model_class not in mapping:
raise CpmError(
f"Role '{role_id}': model class '{model_class}' is not mapped.\n"
f"Add to your 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()
# Form B: bind the role to an APM-deployed native agent.
agent_ref = role.get("agent_ref")
if agent_ref:
if native and agent_ref not in native:
warnings.append(
f"Role '{role_id}': agent_ref '{agent_ref}' not found in "
f".opencode/agents/ ({', '.join(sorted(native)) or 'empty'}). "
"Run 'apm install' first?"
)
preset[agent_ref] = {
**agent_fields,
"mcps": mcps,
"skills": skills,
}
if not purpose:
pass # persona comes from the APM primitive; nothing to do
else:
warnings.append(
f"Role '{role_id}': 'purpose' ignored - with agent_ref the "
"persona (prompt) comes from the APM agent primitive."
)
continue
runtime_agent = role.get("runtime_agent", role_id)
if runtime_agent == "custom":
# Explicitly marked as custom agent -> use the role's own name.
agent_key = role_id
else:
agent_key = runtime_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 runtime_agent == "custom":
if agent_key not in custom_agents:
prompt = (
purpose
if purpose
else f"Custom agent '{role_id}' from team profile "
f"'{profile.get('id', 'unknown')}'."
)
custom_agents[agent_key] = {
"model": agent_fields["model"],
"description": purpose or f"Custom subagent '{role_id}'",
"prompt": prompt,
# Tell the orchestrator when to delegate to this agent.
"orchestratorPrompt": (
f"@{agent_key}\n- Role: {purpose}\n"
"- Delegate tasks of this role to this agent."
if purpose
else f"@{agent_key}"
),
}
else:
custom_agents[agent_key]["model"] = agent_fields["model"]
if not mcps and not skills:
warnings.append(
f"Role '{role_id}' ({agent_key}): no MCPs/skills assigned "
"(purely coordinating?)"
)
orchestrator_present = any(k == "orchestrator" for k in preset)
if not orchestrator_present:
warnings.append(
"No 'orchestrator' in the team. Without an orchestrator preset "
"entry its model stays unchanged."
)
# Inline (non-agent_ref) roles colliding with deployed native agents
# would create OMOS custom agents that shadow the APM-deployed ones.
for role in profile["roles"]:
if role.get("agent_ref"):
continue
role_id = role.get("id")
runtime_agent = role.get("runtime_agent", role_id)
if runtime_agent == "custom" and native and role_id in native:
warnings.append(
f"Role '{role_id}' would create a custom agent shadowing the "
f"APM-deployed native agent '{role_id}'. Consider "
f"'agent_ref: {role_id}' instead."
)
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"Note: preset '{preset_name}' already existed and will be replaced "
"(other presets remain untouched)."
)
presets[preset_name] = preset
agents = merged.setdefault("agents", {})
agents.update(custom_agents)
return merged
# ---------------------------------------------------------------------------
# MCP availability check (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
OPENCODE_CONFIGS = [
Path.home() / ".config" / "opencode" / "opencode.jsonc",
Path.home() / ".config" / "opencode" / "opencode.json",
]
def configured_mcps() -> set[str]:
for path in OPENCODE_CONFIGS:
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 available_models() -> list[str]:
"""List all provider/model combinations from opencode.json(c)."""
models: list[str] = []
for path in OPENCODE_CONFIGS:
if not path.exists():
continue
try:
data = json.loads(strip_jsonc(path.read_text(encoding="utf-8")))
except (json.JSONDecodeError, OSError):
continue
for provider, pconf in (data.get("provider") or {}).items():
for model in (pconf.get("models") or {}):
models.append(f"{provider}/{model}")
break
return sorted(models)
INIT_TEMPLATE = """\
# Local mapping table of the USER.
# Maps abstract model classes to concrete model IDs.
# Formats:
# class: provider/model
# class:
# model: provider/model
# variant: thinking # optional
# temperature: 0.7 # optional
model_classes:
strong-generalist: PLACEHOLDER
fast-research: PLACEHOLDER
strong-writing: PLACEHOLDER
high-reasoning: PLACEHOLDER
cheap-reliable: PLACEHOLDER
"""
def check_mcps(preset: dict, warnings: list[str]) -> None:
available = configured_mcps()
if not available:
warnings.append(
"Could not read opencode.json(c) - MCP check skipped."
)
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 referenced in the team profile but not configured in "
"opencode.json: " + ", ".join(missing)
)
# ---------------------------------------------------------------------------
# Commands
def package_output_path(package_dir: Path) -> Path:
return package_dir / DEFAULT_OUTPUT
def has_apm_dependencies(package_dir: Path) -> bool:
"""True if the package declares an apm.yml with non-empty APM dependencies."""
manifest = package_dir / "apm.yml"
if not manifest.exists():
return False
try:
data = load_yaml(manifest)
except CpmError:
return False
deps = (data.get("dependencies") or {}).get("apm") or []
return bool(deps)
def apm_cli_available() -> bool:
return shutil.which("apm") is not None
def ensure_apm_install(package_dir: Path, dry_run: bool, interactive: bool) -> None:
"""Make sure the user knows about (and ideally ran) 'apm install'.
The rendered preset only configures agents; the skills/MCP primitives
referenced by the team profile are deployed by APM.
"""
if not has_apm_dependencies(package_dir):
return
print(
"\nAPM dependencies detected: this package references skills/instructions "
"from other packages that must be installed before the team can use them."
)
if dry_run:
print(f" Dry run: run 'apm install' in {package_dir} yourself.")
return
if apm_cli_available() and interactive and sys.stdin.isatty():
answer = input("Run 'apm install' now? [y/N] ").strip().lower()
if answer in ("y", "yes"):
print()
result = subprocess.run(["apm", "install"], cwd=package_dir)
if result.returncode == 0:
print("\napm install finished successfully.")
else:
print(
f"\n'apm install' exited with code {result.returncode}. "
f"Fix the issue above and re-run it in {package_dir}.",
file=sys.stderr,
)
return
else:
if not apm_cli_available():
print(
" The 'apm' CLI was not found on PATH.\n"
" Install it via: pip install apm-cli (or: brew install microsoft/apm/apm)"
)
print(f" Run manually:\n cd {package_dir}\n apm install")
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, package_dir)
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 CpmError(f"{output} is not valid 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"Written: {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"): {
"runtimeAgent": r.get("runtime_agent", r.get("id")),
"agentRef": r.get("agent_ref"),
"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)
steps = [
"\nNext steps:",
f" 1. start opencode from {output.parent.parent.resolve()} "
"(the config lives in its .opencode/ directory)",
]
if has_apm_dependencies(package_dir):
steps.append(
" 2. run 'apm install' there first - the team references skills "
"from APM dependencies that are not deployed yet"
)
steps.append(
f" 3. /preset {sanitize_preset_name(str(profile['id']))}"
)
steps.append(" 4. reload OpenCode -> team is active")
else:
steps.append(
f" 2. /preset {sanitize_preset_name(str(profile['id']))}"
)
steps.append(" 3. reload OpenCode -> team is active")
print("\n".join(steps))
return 0
def cmd_check(args: argparse.Namespace) -> int:
args.dry_run = True
# Dry run: include an existing project config, but never write.
existing = package_output_path(Path(args.package).resolve())
args.output = str(existing) if existing.exists() else None
try:
cmd_render(args)
except CpmError as exc:
print(f"Error: {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: nothing was written ---")
if warnings:
print("\nWarnings:")
for warning in warnings:
print(f"{warning}")
else:
print("\nNo warnings.")
# ---------------------------------------------------------------------------
# Interactive assistants
_MODELS_LISTED = False
def _choose_model(model_class: str, models: list[str]) -> str:
"""Interactively pick a model for a model class."""
global _MODELS_LISTED
print(f"\nNo model assigned for '{model_class}'.")
if models:
if not _MODELS_LISTED:
print("Available models (from opencode.json(c)):")
for i, model in enumerate(models, 1):
print(f" {i}. {model}")
print(" 0. Enter model ID manually")
_MODELS_LISTED = True
while True:
choice = input(f"Selection for '{model_class}': ").strip()
if choice == "0":
return input("Model ID (provider/model): ").strip()
if choice.isdigit() and 1 <= int(choice) <= len(models):
return models[int(choice) - 1]
print(f"Invalid selection. Enter 1-{len(models)} or 0.")
else:
print("No models found in opencode.json(c).")
return input("Model ID (provider/model): ").strip()
def cmd_init(args: argparse.Namespace) -> int:
mapping_path = Path(args.mapping) if args.mapping else DEFAULT_MAPPING_PATH
if mapping_path.exists():
print(f"Mapping already exists: {mapping_path}")
else:
mapping_path.parent.mkdir(parents=True, exist_ok=True)
mapping_path.write_text(INIT_TEMPLATE, encoding="utf-8")
print(f"Created: {mapping_path}")
if args.fill:
missing = [k for k, v in load_mapping(mapping_path).items()
if v == "PLACEHOLDER"]
models = available_models()
if missing and not models:
raise CpmError(
"No models found in ~/.config/opencode/opencode.json(c). "
"Enter the model IDs manually or set up opencode first."
)
data = load_yaml(mapping_path)
for model_class in missing:
data["model_classes"][model_class] = _choose_model(model_class, models)
mapping_path.write_text(
yaml.safe_dump(data, sort_keys=False, allow_unicode=True),
encoding="utf-8",
)
remaining = sum(1 for v in data["model_classes"].values()
if v == "PLACEHOLDER")
if remaining:
print(f"\n{remaining} entr(y/ies) left as PLACEHOLDER - "
"please fill in manually.")
print("\nNext steps:")
if mapping_path == DEFAULT_MAPPING_PATH:
print(" Mapping is in place. Validate or render a package, e.g.:")
print(" cpm.py check --package ./my-cpm-package")
print(" cpm.py setup --package ./my-cpm-package")
else:
print(f" cpm.py check --package <your-package-dir> --mapping {mapping_path}")
return 0
def cmd_setup(args: argparse.Namespace) -> int:
"""Guided flow: read profile -> fill mapping -> render."""
package_dir = Path(args.package).resolve()
profile, _profile_path = load_team_profile(package_dir, args.team_file)
mapping_path = Path(args.mapping) if args.mapping else DEFAULT_MAPPING_PATH
print(f"Team profile: {profile['id']}")
if profile.get("description"):
print(f" {str(profile['description']).strip()}")
print("Roles:")
for role in profile["roles"]:
mcps = (role.get("capabilities", {}) or {}).get("mcps", [])
skills = (role.get("capabilities", {}) or {}).get("skills", [])
print(f" - {role['id']}{role.get('model_class')} "
f"(MCPs: {', '.join(mcps) or 'none'}; "
f"Skills: {', '.join(skills) or 'none'})")
if not mapping_path.exists():
print(f"\nMapping missing - creating: {mapping_path}")
args_init = argparse.Namespace(mapping=str(mapping_path), fill=True)
cmd_init(args_init)
classes = load_mapping(mapping_path)
needed = sorted({r.get("model_class") for r in profile["roles"]
if r.get("model_class")} - set(classes))
models = available_models()
changed = False
for model_class in needed:
chosen = _choose_model(model_class, models)
classes[model_class] = {"model": chosen, "# selected_by": "user"}
changed = True
if changed:
mapping_path.write_text(
yaml.safe_dump(classes, sort_keys=False, allow_unicode=True),
encoding="utf-8",
)
print(f"\nMapping updated: {mapping_path}")
print("\nRendering ...")
args_render = argparse.Namespace(
package=str(package_dir),
team_file=args.team_file,
mapping=str(mapping_path),
output=args.output,
dry_run=args.dry_run,
)
result = cmd_render(args_render)
# Deploy the package primitives (skills/instructions/MCP configs) that
# the team references. The preset alone only configures the agents.
ensure_apm_install(
package_dir,
dry_run=args.dry_run,
interactive=not args.dry_run,
)
return result
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="cpm",
description=(
"Corentic Package Manager. Translates CPM packages (APM manifest + "
"team-profile.yaml) deterministically into harness-native presets: "
"team profile + local model mapping -> harness preset."
),
)
sub = parser.add_subparsers(dest="command", required=True)
def common(p: argparse.ArgumentParser) -> None:
p.add_argument(
"--package",
default=".",
help="Path to the CPM package directory (containing team-profile.yaml)",
)
p.add_argument(
"--team-file",
help="Alternative filename of the team profile (default: team-profile.yaml)",
)
p.add_argument(
"--mapping",
help=f"Path to the model mapping file (default: {DEFAULT_MAPPING_PATH})",
)
p_render = sub.add_parser("render", help="Generate and write the preset")
common(p_render)
p_render.add_argument(
"--output",
help=f"Target file (default: {DEFAULT_OUTPUT} relative to the package)",
)
p_render.add_argument(
"--dry-run",
action="store_true",
help="Print only, write nothing",
)
p_render.set_defaults(func=cmd_render)
p_check = sub.add_parser("check", help="Validate without writing")
common(p_check)
p_check.set_defaults(func=cmd_check)
p_init = sub.add_parser(
"init",
help="Create the model mapping file, optionally filling placeholders",
)
p_init.add_argument(
"--mapping",
help=f"Target path (default: {DEFAULT_MAPPING_PATH})",
)
p_init.add_argument(
"--fill",
action="store_true",
help="Interactively choose missing model classes from opencode.json(c) models",
)
p_init.set_defaults(func=cmd_init)
p_setup = sub.add_parser(
"setup",
help="Guided: read profile, fill mapping, render",
)
common(p_setup)
p_setup.add_argument(
"--output",
help=f"Target file (default: {DEFAULT_OUTPUT} relative to the package)",
)
p_setup.add_argument(
"--dry-run",
action="store_true",
help="Print only, write nothing",
)
p_setup.set_defaults(func=cmd_setup)
args = parser.parse_args(argv)
try:
return args.func(args)
except CpmError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())