import json import os import re import subprocess from abc import ABC, abstractmethod from typing import Any from .config import Settings from .models import STATUSES, TaskCreate TASK_ID_RE = re.compile(r"^t_[a-zA-Z0-9]+$") SENSITIVE_RE = re.compile(r"(secret|token|password|api[_-]?key|bot[_-]?token)\s*[:=]\s*\S+|secret|token|password", re.IGNORECASE) class HermesCommandError(RuntimeError): def __init__(self, message: str, returncode: int | None = None): super().__init__(sanitize_error(message)) self.returncode = returncode def sanitize_error(message: str) -> str: cleaned = SENSITIVE_RE.sub("[redacted]", message or "Hermes command failed") return cleaned[:500] def normalize_task(raw: dict[str, Any]) -> dict[str, Any]: task_id = str(raw.get("id") or raw.get("task_id") or "") return { **raw, "id": task_id, "title": str(raw.get("title") or raw.get("summary") or task_id), "status": str(raw.get("status") or "todo"), "assignee": raw.get("assignee"), "priority": raw.get("priority"), } def parse_json_or_none(text: str) -> Any | None: try: return json.loads(text) except json.JSONDecodeError: return None def parse_tasks_payload(text: str) -> list[dict[str, Any]]: data = parse_json_or_none(text) if isinstance(data, dict): tasks = data.get("tasks") or data.get("items") or [] if isinstance(tasks, dict): flattened = [] for status, items in tasks.items(): for item in items or []: flattened.append({"status": status, **item}) tasks = flattened return [normalize_task(t) for t in tasks if isinstance(t, dict)] if isinstance(data, list): return [normalize_task(t) for t in data if isinstance(t, dict)] tasks: list[dict[str, Any]] = [] for line in text.splitlines(): line = line.strip() if not line or line.lower().startswith(("id ", "task ", "---")): continue parts = re.split(r"\s{2,}|\t+", line, maxsplit=4) if len(parts) < 2: parts = line.split(maxsplit=4) if not parts or not TASK_ID_RE.match(parts[0]): continue status = parts[1] if len(parts) > 1 and parts[1] in STATUSES else "todo" assignee = parts[2] if len(parts) > 2 else None priority = None title_index = 3 if len(parts) > 3: try: priority = int(parts[3]) title_index = 4 except ValueError: title_index = 3 title = parts[title_index] if len(parts) > title_index else parts[0] tasks.append(normalize_task({"id": parts[0], "status": status, "assignee": assignee, "priority": priority, "title": title})) return tasks def parse_object_payload(text: str) -> dict[str, Any]: data = parse_json_or_none(text) if isinstance(data, dict): return data.get("task") if isinstance(data.get("task"), dict) else data return {"raw": text} class KanbanAdapter(ABC): @abstractmethod def boards(self) -> list[dict[str, Any]]: ... @abstractmethod def list_tasks(self, board: str | None = None) -> list[dict[str, Any]]: ... @abstractmethod def show_task(self, task_id: str, board: str | None = None) -> dict[str, Any]: ... @abstractmethod def task_events(self, task_id: str, board: str | None = None) -> list[dict[str, Any]]: ... @abstractmethod def task_runs(self, task_id: str, board: str | None = None) -> list[dict[str, Any]]: ... @abstractmethod def task_log(self, task_id: str, board: str | None = None, lines: int = 200) -> dict[str, Any]: ... class HermesCliKanbanAdapter(KanbanAdapter): def __init__(self, settings: Settings): self.settings = settings def _env(self) -> dict[str, str]: env = os.environ.copy() if self.settings.hermes_home: env["HERMES_HOME"] = self.settings.hermes_home return env def _run(self, args: list[str]) -> str: argv = [self.settings.hermes_bin, "kanban", *args] result = subprocess.run( argv, shell=False, capture_output=True, text=True, timeout=self.settings.command_timeout_seconds, env=self._env(), ) if result.returncode != 0: raise HermesCommandError(result.stderr or result.stdout, result.returncode) return result.stdout def _board_args(self, board: str | None) -> list[str]: return ["--board", board] if board else [] def boards(self) -> list[dict[str, Any]]: return [{"slug": self.settings.default_board, "current": True}] def list_tasks(self, board: str | None = None) -> list[dict[str, Any]]: return parse_tasks_payload(self._run(["list", "--json", *self._board_args(board)])) def show_task(self, task_id: str, board: str | None = None) -> dict[str, Any]: return parse_object_payload(self._run(["show", task_id, "--json", *self._board_args(board)])) def task_events(self, task_id: str, board: str | None = None) -> list[dict[str, Any]]: task = self.show_task(task_id, board) return task.get("events", []) if isinstance(task.get("events"), list) else [] def task_runs(self, task_id: str, board: str | None = None) -> list[dict[str, Any]]: out = self._run(["runs", task_id, "--json", *self._board_args(board)]) data = parse_json_or_none(out) if isinstance(data, dict): return data.get("runs", []) if isinstance(data.get("runs"), list) else [] return data if isinstance(data, list) else [] def task_log(self, task_id: str, board: str | None = None, lines: int = 200) -> dict[str, Any]: try: out = self._run(["log", task_id, "--lines", str(lines), *self._board_args(board)]) return {"task_id": task_id, "log": out, "missing": False} except HermesCommandError as exc: if "not found" in str(exc).lower() or "no log" in str(exc).lower(): return {"task_id": task_id, "log": "", "missing": True} raise def create_task(self, payload: TaskCreate, board: str | None = None) -> dict[str, Any]: args = ["create", payload.title] if payload.assignee: args += ["--assignee", payload.assignee] if payload.body: args += ["--body", payload.body] args += ["--priority", str(payload.priority), "--workspace-kind", payload.workspace_kind] if payload.workspace_path: args += ["--workspace-path", payload.workspace_path] if payload.goal_mode: args += ["--goal-mode"] if payload.max_runtime_seconds: args += ["--max-runtime-seconds", str(payload.max_runtime_seconds)] args += self._board_args(board) return parse_object_payload(self._run(args)) def comment(self, task_id: str, body: str, board: str | None = None) -> dict[str, Any]: return parse_object_payload(self._run(["comment", task_id, body, *self._board_args(board)])) def assign(self, task_id: str, assignee: str, board: str | None = None) -> dict[str, Any]: return parse_object_payload(self._run(["assign", task_id, assignee, *self._board_args(board)])) def promote(self, task_id: str, board: str | None = None) -> dict[str, Any]: return parse_object_payload(self._run(["promote", task_id, *self._board_args(board)])) def block(self, task_id: str, reason: str, board: str | None = None) -> dict[str, Any]: return parse_object_payload(self._run(["block", task_id, reason, *self._board_args(board)])) def unblock(self, task_id: str, board: str | None = None) -> dict[str, Any]: return parse_object_payload(self._run(["unblock", task_id, *self._board_args(board)])) def archive(self, task_id: str, board: str | None = None) -> dict[str, Any]: return parse_object_payload(self._run(["archive", task_id, *self._board_args(board)])) def dispatch(self, board: str | None = None) -> dict[str, Any]: return parse_object_payload(self._run(["dispatch", *self._board_args(board)]))