""" KanBot — frozen contract. This is the single source of truth. Every other module (mcp_server, conductor, workers, cli, web, telegram) binds to the enums, the transition table, and the Card model defined here. Do NOT hand-roll a state or a transition anywhere else. Design invariants: - A card's column IS its lifecycle state. Moving a card IS a state transition. - Every transition is validated against TRANSITIONS server-side. Illegal moves are rejected, not silently coerced. - Every transition is idempotent, keyed on (card_id, from, to, attempt). """ from __future__ import annotations import enum from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Optional # --------------------------------------------------------------------------- # # Columns = the state machine # --------------------------------------------------------------------------- # class Column(str, enum.Enum): BACKLOG = "backlog" # raw ideas / undecomposed specs READY = "ready" # queued, decomposed, waiting for a worker IN_FLIGHT = "in-flight" # an executor holds a lease and is running REVIEW = "review" # has a result artifact, awaiting critic eval BLOCKED = "blocked" # dependency / human gate; parked DONE = "done" # critic PASS, archived class Role(str, enum.Enum): PLANNER = "planner" # decomposes a spec into child cards EXECUTOR = "executor" # runs the MCP tool loop to do the work CRITIC = "critic" # evals result against acceptance_criteria HUMAN = "human" # you — via CLI / Telegram / web # --------------------------------------------------------------------------- # # Transition table — the ONLY legal moves, plus the guard each requires. # Guards are enforced in mcp_server.move_card(). "requires" is a machine token # the server checks; None means an unconditional move. # --------------------------------------------------------------------------- # @dataclass(frozen=True) class Transition: frm: Column to: Column requires: Optional[str] # guard token, checked server-side note: str TRANSITIONS: tuple[Transition, ...] = ( Transition(Column.BACKLOG, Column.READY, None, "promoted for execution"), Transition(Column.READY, Column.IN_FLIGHT, "claim", "worker claimed + leased"), Transition(Column.IN_FLIGHT, Column.REVIEW, "has_artifact", "result posted to thread"), Transition(Column.IN_FLIGHT, Column.DONE, "planner_done", "planner decomposition complete"), Transition(Column.IN_FLIGHT, Column.READY, None, "reaper reclaimed stale lease"), Transition(Column.IN_FLIGHT, Column.BLOCKED, "reason", "hit a blocker"), Transition(Column.REVIEW, Column.DONE, "critic_pass", "acceptance criteria met"), Transition(Column.REVIEW, Column.READY, "critic_feedback", "failed eval, retry with feedback"), Transition(Column.REVIEW, Column.BLOCKED, "reason", "needs human decision"), Transition(Column.BLOCKED, Column.READY, None, "unblocked, requeued"), Transition(Column.READY, Column.BLOCKED, "reason", "dependency not met"), ) _TRANSITION_INDEX = {(t.frm, t.to): t for t in TRANSITIONS} class IllegalTransition(Exception): """Raised when a (from, to) pair is not in TRANSITIONS.""" class GuardFailed(Exception): """Raised when a legal transition's guard requirement is not satisfied.""" def lookup_transition(frm: Column, to: Column) -> Transition: t = _TRANSITION_INDEX.get((frm, to)) if t is None: legal = [f"{x.to.value}" for x in TRANSITIONS if x.frm == frm] raise IllegalTransition( f"{frm.value} -> {to.value} is not a legal move. " f"From {frm.value} you may go to: {legal or 'nowhere'}." ) return t def check_guard(card, to: Column, requires: Optional[str], *, reason: Optional[str], worker: Optional[str], critic_verdict: Optional[str]) -> None: """The one guard check. The live server and the demo board both call this. `card` needs `.column` (a Column or its value), `.has_artifact()`, and optionally `.assigned_worker`. """ if requires is None: return col = card.column.value if isinstance(card.column, Column) else str(card.column) held = worker or getattr(card, "assigned_worker", None) if requires == "claim" and not held: raise GuardFailed("ready -> in-flight requires a claiming worker (call claim_card).") if requires == "has_artifact" and not card.has_artifact(): raise GuardFailed("in-flight -> review requires an 'artifact' entry in the thread.") if requires == "critic_pass" and critic_verdict != "pass": raise GuardFailed("review -> done requires a critic PASS verdict.") if requires == "critic_feedback" and critic_verdict != "fail": raise GuardFailed("review -> ready (retry) requires critic feedback (fail verdict).") if requires == "planner_done": # Only a planner card may close itself after decomposition, and only # if it actually spawned subtasks (children). An executor with no # artifact still needs the review gate. role = getattr(card, "agent_role", None) role_val = role.value if isinstance(role, Role) else str(role or "").lower() if role_val != Role.PLANNER.value: raise GuardFailed("in-flight -> done (planner_done) is only for planner cards.") if not getattr(card, "children", None): raise GuardFailed("planner_done requires at least one spawned subtask.") return if requires == "reason" and not reason: raise GuardFailed(f"{col} -> {to.value} requires a reason.") # --------------------------------------------------------------------------- # # The card = the atom # --------------------------------------------------------------------------- # def _now() -> datetime: return datetime.now(timezone.utc) @dataclass class ThreadEntry: """One line of inter-agent (or human) comms. The card thread IS the bus.""" role: str # Role value or "system" author: str # worker id / "human:" / "system" ts: str # ISO timestamp content: str kind: str = "comment" # comment | artifact | tool_call | tool_result | eval @dataclass class Card: id: str title: str column: Column = Column.BACKLOG agent_role: Role = Role.EXECUTOR spec: str = "" # what to do acceptance_criteria: list[str] = field(default_factory=list) assigned_worker: Optional[str] = None parent: Optional[str] = None children: list[str] = field(default_factory=list) thread: list[ThreadEntry] = field(default_factory=list) lease_expires_at: Optional[str] = None # ISO; drives the reaper attempt: int = 0 # increments on retry requires_human: bool = False tags: list[str] = field(default_factory=list) # e.g. ["heavy"] -> cloud executor created_at: str = field(default_factory=lambda: _now().isoformat()) updated_at: str = field(default_factory=lambda: _now().isoformat()) # -- guards the server evaluates ------------------------------------- # def has_artifact(self) -> bool: return any(e.kind == "artifact" for e in self.thread) def is_heavy(self) -> bool: return "heavy" in self.tags # --------------------------------------------------------------------------- # # Frozen MCP tool names — the whole API surface. Clients import from here. # --------------------------------------------------------------------------- # class Tool(str, enum.Enum): LIST_COLUMN = "list_column" GET_CARD = "get_card" CREATE_CARD = "create_card" CLAIM_CARD = "claim_card" MOVE_CARD = "move_card" COMMENT = "comment" SPAWN_SUBTASK = "spawn_subtask" BLOCK_CARD = "block_card" REQUEST_HUMAN = "request_human" # Global v0 safety switch. dry-run = executors PROPOSE tool calls as comments and # wait for human approval; they do NOT execute. Flip to False to let the swarm run free. DRY_RUN_DEFAULT = True LEASE_SECONDS_DEFAULT = 900 # 15 min before the reaper reclaims an in-flight card # Retry ceiling — the brake on the critic FAIL -> ready -> re-execute loop. # A card's `attempt` increments on every review->ready bounce; once it hits this, # the critic parks the card in BLOCKED (requires_human) instead of retrying again, # so a card that can't pass never burns compute forever. 0 = unlimited (NOT advised # for unattended free-run). Override with env KANBOT_MAX_ATTEMPTS. MAX_ATTEMPTS_DEFAULT = 3