"""Instance manifest — declarative agent instances in consuming repos. Extends ADR-005 `.kaizen/schedule.yml` with harness fields. kaizen-agentic owns the base keys (version, timezone, agents..{cadence,cron,enabled}); agent-harness owns the extension keys (lane, tool_profile, budget, harness, optional blueprint). Same file — no sibling manifest unless kaizen owners later prefer separation. See docs/instance-manifest.md for the full contract. """ from __future__ import annotations from dataclasses import dataclass, field from pathlib import Path from typing import Any import yaml from agent_harness.profiles import PROFILES, UnknownToolProfileError, get_profile MANIFEST_RELATIVE_PATH = Path(".kaizen") / "schedule.yml" MANIFEST_VERSION = "1" VALID_CADENCES = ("daily", "weekly", "monthly") VALID_LANES = ("green", "blue") # Package major this harness release implements (0.x → 0). HARNESS_MAJOR = 0 class ManifestError(ValueError): """Structural failure loading or parsing a manifest.""" @dataclass class AgentInstance: """One agent instance declared in a consuming repo.""" name: str cadence: str enabled: bool = True cron: str | None = None # Harness extension fields (optional in file; defaults applied at load). blueprint: str | None = None # defaults to name lane: str | None = None tool_profile: str | None = None budget: int | None = None # tokens per run; None = unlimited for this field harness: int | None = None # pinned major; None inherits repo-level @property def blueprint_name(self) -> str: return self.blueprint or self.name @dataclass class InstanceManifest: """Parsed instance manifest (schedule.yml + harness extensions).""" version: str timezone: str | None harness: int | None # repo-level pinned harness major agents: list[AgentInstance] = field(default_factory=list) source_path: Path | None = None def agent_for(self, name: str) -> AgentInstance | None: for agent in self.agents: if agent.name == name: return agent return None def enabled_agents(self) -> list[AgentInstance]: return [a for a in self.agents if a.enabled] def effective_harness(self, agent: AgentInstance) -> int | None: if agent.harness is not None: return agent.harness return self.harness def manifest_path(project_root: Path) -> Path: return Path(project_root) / MANIFEST_RELATIVE_PATH def _parse_budget(raw: Any, agent_name: str) -> int | None: if raw is None: return None if isinstance(raw, bool) or not isinstance(raw, int): raise ManifestError( f"agent '{agent_name}': budget must be a positive integer (tokens/run)" ) if raw <= 0: raise ManifestError( f"agent '{agent_name}': budget must be a positive integer (tokens/run)" ) return raw def _parse_harness_major(raw: Any, context: str) -> int | None: if raw is None: return None if isinstance(raw, bool) or not isinstance(raw, int): raise ManifestError(f"{context}: harness must be a non-negative integer major") if raw < 0: raise ManifestError(f"{context}: harness must be a non-negative integer major") return raw def parse_manifest(data: Any, source_path: Path | None = None) -> InstanceManifest: """Parse a raw mapping into InstanceManifest. Structural errors raise.""" if not isinstance(data, dict): raise ManifestError("manifest must be a YAML mapping at the top level") version = data.get("version") if version is None: raise ManifestError("missing required key: version") version = str(version) timezone = data.get("timezone") if timezone is not None and not isinstance(timezone, str): raise ManifestError("timezone must be a string") repo_harness = _parse_harness_major(data.get("harness"), "top-level") agents_raw = data.get("agents", {}) if not isinstance(agents_raw, dict): raise ManifestError("agents must be a mapping of agent-name -> settings") agents: list[AgentInstance] = [] for name, settings in agents_raw.items(): if settings is None: settings = {} if not isinstance(settings, dict): raise ManifestError(f"agent '{name}' settings must be a mapping") cron = settings.get("cron") if cron is not None and not isinstance(cron, str): raise ManifestError(f"agent '{name}' cron must be a string") blueprint = settings.get("blueprint") if blueprint is not None and not isinstance(blueprint, str): raise ManifestError(f"agent '{name}' blueprint must be a string") lane = settings.get("lane") if lane is not None and not isinstance(lane, str): raise ManifestError(f"agent '{name}' lane must be a string") tool_profile = settings.get("tool_profile") if tool_profile is not None and not isinstance(tool_profile, str): raise ManifestError(f"agent '{name}' tool_profile must be a string") agents.append( AgentInstance( name=str(name), cadence=str(settings.get("cadence", "")), enabled=bool(settings.get("enabled", True)), cron=cron, blueprint=blueprint, lane=lane, tool_profile=tool_profile, budget=_parse_budget(settings.get("budget"), str(name)), harness=_parse_harness_major( settings.get("harness"), f"agent '{name}'" ), ) ) return InstanceManifest( version=version, timezone=timezone, harness=repo_harness, agents=agents, source_path=source_path, ) def load_manifest(path: Path | str) -> InstanceManifest: path = Path(path) if not path.exists(): raise ManifestError(f"manifest not found: {path}") try: raw = yaml.safe_load(path.read_text(encoding="utf-8")) except yaml.YAMLError as exc: raise ManifestError(f"invalid YAML in {path}: {exc}") from exc return parse_manifest(raw, source_path=path) def load_manifest_for_repo(project_root: Path | str) -> InstanceManifest: return load_manifest(manifest_path(Path(project_root))) def validate_manifest( manifest: InstanceManifest, *, require_harness_fields: bool = False, ) -> list[str]: """Return human-readable validation errors (empty == valid). When *require_harness_fields* is False (default), ADR-005-only manifests are valid: harness extension fields are checked only when present. When True, every enabled agent must declare lane, tool_profile, and an effective harness pin (repo- or agent-level). """ errors: list[str] = [] if manifest.version != MANIFEST_VERSION: errors.append( f"unsupported version '{manifest.version}' " f"(expected '{MANIFEST_VERSION}')" ) if not manifest.agents: errors.append("no agents declared under 'agents:'") seen: set[str] = set() for agent in manifest.agents: if agent.name in seen: errors.append(f"duplicate agent entry: {agent.name}") seen.add(agent.name) if agent.cadence not in VALID_CADENCES: errors.append( f"agent '{agent.name}': invalid cadence '{agent.cadence}' " f"(expected one of {', '.join(VALID_CADENCES)})" ) if agent.lane is not None and agent.lane not in VALID_LANES: errors.append( f"agent '{agent.name}': invalid lane '{agent.lane}' " f"(expected one of {', '.join(VALID_LANES)})" ) if agent.tool_profile is not None: try: profile = get_profile(agent.tool_profile) except UnknownToolProfileError as exc: errors.append(f"agent '{agent.name}': {exc}") else: if agent.lane is not None and agent.lane != profile.lane: errors.append( f"agent '{agent.name}': lane '{agent.lane}' does not " f"match tool_profile '{profile.name}' (lane={profile.lane})" ) if require_harness_fields and agent.enabled: if agent.lane is None: errors.append( f"agent '{agent.name}': lane is required for harness runs " f"(green|blue)" ) if agent.tool_profile is None: errors.append( f"agent '{agent.name}': tool_profile is required for harness runs" ) if manifest.effective_harness(agent) is None: errors.append( f"agent '{agent.name}': harness major pin required " f"(set top-level harness: or agents.{agent.name}.harness)" ) effective = manifest.effective_harness(agent) if effective is not None and effective != HARNESS_MAJOR: # Soft pin check: warn-style error so validate fails closed for # mismatched majors (instances must upgrade deliberately). errors.append( f"agent '{agent.name}': pinned harness major {effective} " f"does not match this runtime (major {HARNESS_MAJOR})" ) return errors def resolve_run_policy( project_root: Path | str, agent_name: str, *, default_profile: str = "green-commit-only", ) -> tuple[str, int | None, str | None, str]: """Resolve (tool_profile_name, budget_tokens, lane, blueprint) for a run. If the repo has no manifest or no entry for *agent_name*, returns the default profile with no budget and blueprint=agent_name. If the entry names an unknown profile, raises UnknownToolProfileError (refuse to run). """ root = Path(project_root) path = manifest_path(root) if not path.exists(): get_profile(default_profile) # validate default exists return default_profile, None, None, agent_name manifest = load_manifest(path) instance = manifest.agent_for(agent_name) if instance is None or not instance.enabled: get_profile(default_profile) return default_profile, None, None, agent_name profile_name = instance.tool_profile or default_profile get_profile(profile_name) # raises if unknown return profile_name, instance.budget, instance.lane, instance.blueprint_name def known_profile_names() -> list[str]: return sorted(PROFILES)