Enable kaizen agents to run on a regular cadence against a preselected repo roster, orchestrated by activity-core and prepared by kaizen-agentic — without this repo owning cron, Temporal workers, or an LLM runtime. CLI + module: - src/kaizen_agentic/schedule.py — .kaizen/schedule.yml parse/validate/scaffold - `kaizen-agentic schedule` group: init, validate, list, prepare <agent> (prepare bundles agent prompt + memory + metrics + repo pointers, offline) - tests/test_schedule_cli.py — 15 tests Contract & design: - ADR-005 scheduled agent execution; schema doc + example manifest - discover_kaizen_scheduled_repos resolver spec, state-hub roster fields, kaizen.schedule.prepared event payload, activity-core handoff checklist - INTEGRATION_PATTERNS Pattern 2 extended with roster model ActivityDefinition drafts (enabled: false): - weekly-coach-orientation, weekly-optimization-review Docs: agency-framework, CLI cheat sheet, PACKAGE_RELEASE runner prereqs, EcosystemIntegration, CHANGELOG, TODO. Workplan closed (status: done). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
186 lines
6.2 KiB
Python
186 lines
6.2 KiB
Python
"""Repo-local scheduled agent execution manifest (.kaizen/schedule.yml).
|
|
|
|
ADR-005 defines the schedule contract: which agents run on what cadence in an
|
|
opted-in repo. kaizen-agentic owns parsing, validation, and preparing an
|
|
orientation bundle for a scheduled run. It does **not** run cron schedules or
|
|
invoke Claude — activity-core fires the cron and a coding-agent session executes
|
|
the prepared bundle.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
import yaml
|
|
|
|
SCHEDULE_RELATIVE_PATH = Path(".kaizen") / "schedule.yml"
|
|
SCHEDULE_VERSION = "1"
|
|
VALID_CADENCES = ("daily", "weekly", "monthly")
|
|
|
|
# Sensible defaults for `schedule init` — coach + optimization weekly, the
|
|
# heavier tdd-workflow review monthly and disabled until an operator opts in.
|
|
DEFAULT_AGENTS: Dict[str, Dict[str, Any]] = {
|
|
"coach": {"cadence": "weekly", "cron": "0 9 * * 1", "enabled": True},
|
|
"optimization": {"cadence": "weekly", "cron": "0 10 * * 1", "enabled": True},
|
|
"tdd-workflow": {"cadence": "monthly", "enabled": False},
|
|
}
|
|
DEFAULT_TIMEZONE = "Europe/Berlin"
|
|
|
|
|
|
class ScheduleError(Exception):
|
|
"""Raised when a schedule manifest cannot be parsed."""
|
|
|
|
|
|
@dataclass
|
|
class ScheduleEntry:
|
|
"""One scheduled agent run declaration."""
|
|
|
|
agent: str
|
|
cadence: str
|
|
enabled: bool = True
|
|
cron: Optional[str] = None
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
data: Dict[str, Any] = {"cadence": self.cadence, "enabled": self.enabled}
|
|
if self.cron:
|
|
data["cron"] = self.cron
|
|
return data
|
|
|
|
|
|
@dataclass
|
|
class Schedule:
|
|
"""Parsed `.kaizen/schedule.yml` manifest."""
|
|
|
|
version: str
|
|
timezone: Optional[str] = None
|
|
entries: List[ScheduleEntry] = field(default_factory=list)
|
|
source_path: Optional[Path] = None
|
|
|
|
def entry_for(self, agent: str) -> Optional[ScheduleEntry]:
|
|
for entry in self.entries:
|
|
if entry.agent == agent:
|
|
return entry
|
|
return None
|
|
|
|
def enabled_entries(self) -> List[ScheduleEntry]:
|
|
return [e for e in self.entries if e.enabled]
|
|
|
|
|
|
def schedule_path(project_root: Path) -> Path:
|
|
"""Return the canonical schedule.yml path for a project root."""
|
|
return Path(project_root) / SCHEDULE_RELATIVE_PATH
|
|
|
|
|
|
def parse_schedule(data: Any, source_path: Optional[Path] = None) -> Schedule:
|
|
"""Parse a raw mapping into a Schedule (structural errors raise).
|
|
|
|
Semantic validation (known agents, cadence values) is handled by
|
|
:func:`validate_schedule` so callers can collect actionable error lists.
|
|
"""
|
|
if not isinstance(data, dict):
|
|
raise ScheduleError("schedule.yml must be a YAML mapping at the top level")
|
|
|
|
version = data.get("version")
|
|
if version is None:
|
|
raise ScheduleError("schedule.yml is missing required key: version")
|
|
version = str(version)
|
|
|
|
timezone = data.get("timezone")
|
|
if timezone is not None and not isinstance(timezone, str):
|
|
raise ScheduleError("timezone must be a string")
|
|
|
|
agents = data.get("agents", {})
|
|
if not isinstance(agents, dict):
|
|
raise ScheduleError("agents must be a mapping of agent-name -> settings")
|
|
|
|
entries: List[ScheduleEntry] = []
|
|
for name, settings in agents.items():
|
|
if settings is None:
|
|
settings = {}
|
|
if not isinstance(settings, dict):
|
|
raise ScheduleError(f"agent '{name}' settings must be a mapping")
|
|
cron = settings.get("cron")
|
|
if cron is not None and not isinstance(cron, str):
|
|
raise ScheduleError(f"agent '{name}' cron must be a string")
|
|
entries.append(
|
|
ScheduleEntry(
|
|
agent=str(name),
|
|
cadence=str(settings.get("cadence", "")),
|
|
enabled=bool(settings.get("enabled", True)),
|
|
cron=cron,
|
|
)
|
|
)
|
|
|
|
return Schedule(
|
|
version=version,
|
|
timezone=timezone,
|
|
entries=entries,
|
|
source_path=source_path,
|
|
)
|
|
|
|
|
|
def load_schedule(path: Path) -> Schedule:
|
|
"""Load and parse a schedule.yml file (raises ScheduleError)."""
|
|
path = Path(path)
|
|
if not path.exists():
|
|
raise ScheduleError(f"schedule file not found: {path}")
|
|
try:
|
|
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
except yaml.YAMLError as exc: # pragma: no cover - passthrough message
|
|
raise ScheduleError(f"invalid YAML in {path}: {exc}") from exc
|
|
return parse_schedule(raw, source_path=path)
|
|
|
|
|
|
def validate_schedule(
|
|
schedule: Schedule, known_agents: Optional[List[str]] = None
|
|
) -> List[str]:
|
|
"""Return a list of human-readable validation errors (empty == valid)."""
|
|
errors: List[str] = []
|
|
|
|
if schedule.version != SCHEDULE_VERSION:
|
|
errors.append(
|
|
f"unsupported version '{schedule.version}' "
|
|
f"(expected '{SCHEDULE_VERSION}')"
|
|
)
|
|
|
|
if not schedule.entries:
|
|
errors.append("no agents declared under 'agents:'")
|
|
|
|
seen: set = set()
|
|
known = set(known_agents) if known_agents is not None else None
|
|
for entry in schedule.entries:
|
|
if entry.agent in seen:
|
|
errors.append(f"duplicate agent entry: {entry.agent}")
|
|
seen.add(entry.agent)
|
|
|
|
if entry.cadence not in VALID_CADENCES:
|
|
errors.append(
|
|
f"agent '{entry.agent}': invalid cadence '{entry.cadence}' "
|
|
f"(expected one of {', '.join(VALID_CADENCES)})"
|
|
)
|
|
|
|
if known is not None and entry.agent not in known:
|
|
errors.append(
|
|
f"agent '{entry.agent}' is not an installed or packaged agent"
|
|
)
|
|
|
|
return errors
|
|
|
|
|
|
def default_schedule_yaml(timezone: str = DEFAULT_TIMEZONE) -> str:
|
|
"""Render the default schedule.yml scaffold for `schedule init`."""
|
|
document = {
|
|
"version": SCHEDULE_VERSION,
|
|
"timezone": timezone,
|
|
"agents": dict(DEFAULT_AGENTS),
|
|
}
|
|
header = (
|
|
"# Kaizen scheduled agent execution manifest (ADR-005)\n"
|
|
"# Declares which agents run on what cadence in this repo.\n"
|
|
"# Validate with: kaizen-agentic schedule validate\n"
|
|
)
|
|
body = yaml.safe_dump(document, sort_keys=False, default_flow_style=False)
|
|
return header + body
|