Some checks failed
ci / test (push) Has been cancelled
Add --engagement, --agents, and --bootstrap-cadence flags to scaffold hourly/daily/weekly engagement schedules. Hourly bootstrap keeps cadence: daily with hourly cron overrides per coulomb-loop ADR-003. Document activity-core requirements in activity-core-handoff-engagement.md. Closes KAIZEN-WP-0008 T02 and T04.
262 lines
9.1 KiB
Python
262 lines
9.1 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"
|
|
DEFAULT_ENGAGEMENT_AGENTS = ("coach", "optimization")
|
|
|
|
# Bootstrap cadence presets for customer engagements (coulomb-loop ADR-003).
|
|
# Hourly bootstrap keeps cadence enum ``daily`` so activity-core definitions
|
|
# filtering ``cadence: daily`` still match while per-repo cron overrides fire
|
|
# hourly (see docs/integrations/activity-core-handoff-engagement.md).
|
|
ENGAGEMENT_CADENCE_PRESETS: Dict[str, Dict[str, Dict[str, Any]]] = {
|
|
"hourly": {
|
|
"coach": {"cadence": "daily", "cron": "15 * * * *", "enabled": True},
|
|
"optimization": {"cadence": "daily", "cron": "30 * * * *", "enabled": True},
|
|
"tdd-workflow": {"cadence": "monthly", "enabled": False},
|
|
},
|
|
"daily": {
|
|
"coach": {"cadence": "daily", "cron": "0 8 * * *", "enabled": True},
|
|
"optimization": {"cadence": "daily", "cron": "0 9 * * *", "enabled": True},
|
|
"tdd-workflow": {"cadence": "monthly", "enabled": False},
|
|
},
|
|
"weekly": {
|
|
"coach": {"cadence": "weekly", "cron": "0 9 * * 1", "enabled": True},
|
|
"optimization": {"cadence": "weekly", "cron": "0 10 * * 1", "enabled": True},
|
|
"tdd-workflow": {"cadence": "monthly", "enabled": False},
|
|
},
|
|
}
|
|
|
|
|
|
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
|
|
|
|
|
|
def engagement_schedule_yaml(
|
|
engagement: str,
|
|
*,
|
|
agents: Optional[List[str]] = None,
|
|
bootstrap_cadence: str = "hourly",
|
|
timezone: str = DEFAULT_TIMEZONE,
|
|
) -> str:
|
|
"""Render a customer-engagement bootstrap schedule for `schedule init --engagement`."""
|
|
if bootstrap_cadence not in ENGAGEMENT_CADENCE_PRESETS:
|
|
raise ScheduleError(
|
|
f"unsupported bootstrap cadence '{bootstrap_cadence}' "
|
|
f"(expected one of {', '.join(ENGAGEMENT_CADENCE_PRESETS)})"
|
|
)
|
|
|
|
slug = engagement.strip()
|
|
if not slug:
|
|
raise ScheduleError("engagement slug must not be empty")
|
|
|
|
selected = list(agents or DEFAULT_ENGAGEMENT_AGENTS)
|
|
if not selected:
|
|
raise ScheduleError(
|
|
"at least one agent is required for engagement schedule init"
|
|
)
|
|
|
|
preset = ENGAGEMENT_CADENCE_PRESETS[bootstrap_cadence]
|
|
agent_entries: Dict[str, Dict[str, Any]] = {}
|
|
for name in selected:
|
|
if name not in preset:
|
|
raise ScheduleError(
|
|
f"agent '{name}' has no preset for bootstrap cadence '{bootstrap_cadence}'"
|
|
)
|
|
agent_entries[name] = dict(preset[name])
|
|
|
|
if bootstrap_cadence == "hourly":
|
|
cadence_note = "hourly crons, daily cadence enum"
|
|
else:
|
|
cadence_note = f"{bootstrap_cadence} cadence"
|
|
|
|
document = {
|
|
"version": SCHEDULE_VERSION,
|
|
"timezone": timezone,
|
|
"agents": agent_entries,
|
|
}
|
|
header = (
|
|
"# Kaizen scheduled agent execution manifest (ADR-005)\n"
|
|
f"# Engagement: {slug} bootstrap — {cadence_note}\n"
|
|
"# Regulator promotes cadence per customer engagement policy (ADR-003).\n"
|
|
"# Validate with: kaizen-agentic schedule validate\n"
|
|
)
|
|
body = yaml.safe_dump(document, sort_keys=False, default_flow_style=False)
|
|
return header + body
|