diff --git a/cli.py b/cli.py index da401e5c18fba..f876a93398815 100644 --- a/cli.py +++ b/cli.py @@ -5818,7 +5818,28 @@ def _parse_flags(tokens): print(f"(._.) Unknown cron command: {subcommand}") print(" Available: list, add, edit, pause, resume, run, remove") - + + def _handle_kanban_command(self, cmd: str): + """Handle the /kanban command — delegate to the shared kanban CLI. + + The string form passed here is the user's full ``/kanban ...`` + including the leading slash; we strip it and hand the remainder + to ``kanban.run_slash`` which returns a single formatted string. + """ + from hermes_cli.kanban import run_slash + + rest = cmd.strip() + if rest.startswith("/"): + rest = rest.lstrip("/") + if rest.startswith("kanban"): + rest = rest[len("kanban"):].lstrip() + try: + output = run_slash(rest) + except Exception as exc: # pragma: no cover - defensive + output = f"(._.) kanban error: {exc}" + if output: + print(output) + def _handle_skills_command(self, cmd: str): """Handle /skills slash command — delegates to hermes_cli.skills_hub.""" from hermes_cli.skills_hub import handle_skills_slash @@ -6055,6 +6076,8 @@ def process_command(self, command: str) -> bool: self.save_conversation() elif canonical == "cron": self._handle_cron_command(cmd_original) + elif canonical == "kanban": + self._handle_kanban_command(cmd_original) elif canonical == "skills": with self._busy_command(self._slow_command_status(cmd_original)): self._handle_skills_command(cmd_original) diff --git a/docs/hermes-kanban-v1-spec.pdf b/docs/hermes-kanban-v1-spec.pdf new file mode 100644 index 0000000000000..c7899cd12a92e Binary files /dev/null and b/docs/hermes-kanban-v1-spec.pdf differ diff --git a/gateway/run.py b/gateway/run.py index 9926920b81a42..c85210515f79e 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3503,6 +3503,14 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: if _cmd_def_inner and _cmd_def_inner.name == "background": return await self._handle_background_command(event) + # /kanban must bypass the guard. It writes to a profile-agnostic + # DB (kanban.db), not to the running agent's state. In fact + # /kanban unblock is often the only way to free a worker that + # has blocked waiting for a peer — letting that be dispatched + # mid-run is the whole point of the board. + if _cmd_def_inner and _cmd_def_inner.name == "kanban": + return await self._handle_kanban_command(event) + # Session-level toggles that are safe to run mid-agent — # /yolo can unblock a pending approval prompt, /verbose cycles # the tool-progress display mode for the ongoing stream. @@ -3727,6 +3735,9 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: if canonical == "personality": return await self._handle_personality_command(event) + if canonical == "kanban": + return await self._handle_kanban_command(event) + if canonical == "retry": return await self._handle_retry_command(event) @@ -5154,6 +5165,37 @@ async def _handle_profile_command(self, event: MessageEvent) -> str: return "\n".join(lines) + + async def _handle_kanban_command(self, event: MessageEvent) -> str: + """Handle /kanban — delegate to the shared kanban CLI. + + Run the potentially-blocking DB work in a thread pool so the + gateway event loop stays responsive. Read operations (list, + show, context, tail) are permitted while an agent is running; + mutations are allowed too because the board is profile-agnostic + and does not touch the running agent's state. + """ + import asyncio + from hermes_cli.kanban import run_slash + + text = (event.text or "").strip() + # Strip the leading "/kanban" (with or without slash), leaving args. + if text.startswith("/"): + text = text.lstrip("/") + if text.startswith("kanban"): + text = text[len("kanban"):].lstrip() + + try: + output = await asyncio.to_thread(run_slash, text) + except Exception as exc: # pragma: no cover - defensive + return f"⚠ kanban error: {exc}" + + # Gateway messages have practical length caps; truncate long + # listings to keep the UX reasonable. + if len(output) > 3800: + output = output[:3800] + "\n… (truncated; use `hermes kanban …` in your terminal for full output)" + return output or "(no output)" + async def _handle_status_command(self, event: MessageEvent) -> str: """Handle /status command.""" source = event.source diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 614d783d95093..2d748d525ddb8 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -140,6 +140,11 @@ class CommandDef: CommandDef("cron", "Manage scheduled tasks", "Tools & Skills", cli_only=True, args_hint="[subcommand]", subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove")), + CommandDef("kanban", "Multi-profile collaboration board (tasks, links, comments)", + "Tools & Skills", args_hint="[subcommand]", + subcommands=("list", "ls", "show", "create", "assign", "link", "unlink", + "claim", "comment", "complete", "block", "unblock", "archive", + "tail", "dispatch", "context", "init", "gc")), CommandDef("reload", "Reload .env variables into the running session", "Tools & Skills", cli_only=True), CommandDef("reload-mcp", "Reload MCP servers from config", "Tools & Skills", diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py new file mode 100644 index 0000000000000..85af5e89f6234 --- /dev/null +++ b/hermes_cli/kanban.py @@ -0,0 +1,665 @@ +"""CLI for the Hermes Kanban board — ``hermes kanban …`` subcommand. + +Exposes the full 15-verb surface documented in the design spec +(``docs/hermes-kanban-v1-spec.pdf``). All DB work is delegated to +``kanban_db``. This module adds: + + * Argparse subcommand construction (``build_parser``). + * Argument dispatch (``kanban_command``). + * Output formatting (plain text + ``--json``). + * A short shared helper that parses a single slash-style string + (used by ``/kanban …`` in CLI and gateway) and forwards it to the + argparse surface. +""" + +from __future__ import annotations + +import argparse +import json +import os +import shlex +import sys +import time +from pathlib import Path +from typing import Any, Optional + +from hermes_cli import kanban_db as kb + + +# --------------------------------------------------------------------------- +# Small formatting helpers +# --------------------------------------------------------------------------- + +_STATUS_ICONS = { + "todo": "◻", + "ready": "▶", + "running": "●", + "blocked": "⊘", + "done": "✓", + "archived": "—", +} + + +def _fmt_ts(ts: Optional[int]) -> str: + if not ts: + return "" + return time.strftime("%Y-%m-%d %H:%M", time.localtime(ts)) + + +def _fmt_task_line(t: kb.Task) -> str: + icon = _STATUS_ICONS.get(t.status, "?") + assignee = t.assignee or "(unassigned)" + tenant = f" [{t.tenant}]" if t.tenant else "" + return f"{icon} {t.id} {t.status:8s} {assignee:20s}{tenant} {t.title}" + + +def _task_to_dict(t: kb.Task) -> dict[str, Any]: + return { + "id": t.id, + "title": t.title, + "body": t.body, + "assignee": t.assignee, + "status": t.status, + "priority": t.priority, + "tenant": t.tenant, + "workspace_kind": t.workspace_kind, + "workspace_path": t.workspace_path, + "created_by": t.created_by, + "created_at": t.created_at, + "started_at": t.started_at, + "completed_at": t.completed_at, + "result": t.result, + } + + +def _parse_workspace_flag(value: str) -> tuple[str, Optional[str]]: + """Parse ``--workspace`` into ``(kind, path|None)``. + + Accepts: ``scratch``, ``worktree``, ``dir:``. + """ + if not value: + return ("scratch", None) + v = value.strip() + if v in ("scratch", "worktree"): + return (v, None) + if v.startswith("dir:"): + path = v[len("dir:"):].strip() + if not path: + raise argparse.ArgumentTypeError( + "--workspace dir: requires a path after the colon" + ) + return ("dir", os.path.expanduser(path)) + raise argparse.ArgumentTypeError( + f"unknown --workspace value {value!r}: use scratch, worktree, or dir:" + ) + + +# --------------------------------------------------------------------------- +# Argparse builder +# --------------------------------------------------------------------------- + +def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.ArgumentParser: + """Attach the ``kanban`` subcommand tree under an existing subparsers. + + Returns the top-level ``kanban`` parser so caller can ``set_defaults``. + """ + kanban_parser = parent_subparsers.add_parser( + "kanban", + help="Multi-profile collaboration board (tasks, links, comments)", + description=( + "Durable SQLite-backed task board shared across Hermes profiles. " + "Tasks are claimed atomically, can depend on other tasks, and " + "are executed by a named profile in an isolated workspace. " + "See https://hermes-agent.nousresearch.com/docs/user-guide/features/kanban " + "or docs/hermes-kanban-v1-spec.pdf for the full design." + ), + ) + sub = kanban_parser.add_subparsers(dest="kanban_action") + + # --- init --- + sub.add_parser("init", help="Create kanban.db if missing (idempotent)") + + # --- create --- + p_create = sub.add_parser("create", help="Create a new task") + p_create.add_argument("title", help="Task title") + p_create.add_argument("--body", default=None, help="Optional opening post") + p_create.add_argument("--assignee", default=None, help="Profile name to assign") + p_create.add_argument("--parent", action="append", default=[], + help="Parent task id (repeatable)") + p_create.add_argument("--workspace", default="scratch", + help="scratch | worktree | dir: (default: scratch)") + p_create.add_argument("--tenant", default=None, help="Tenant namespace") + p_create.add_argument("--priority", type=int, default=0, help="Priority tiebreaker") + p_create.add_argument("--triage", action="store_true", + help="Park in triage — a specifier will flesh out the spec and promote to todo") + p_create.add_argument("--created-by", default="user", + help="Author name recorded on the task (default: user)") + p_create.add_argument("--json", action="store_true", help="Emit JSON output") + + # --- list --- + p_list = sub.add_parser("list", aliases=["ls"], help="List tasks") + p_list.add_argument("--mine", action="store_true", + help="Filter by $HERMES_PROFILE as assignee") + p_list.add_argument("--assignee", default=None) + p_list.add_argument("--status", default=None, + choices=sorted(kb.VALID_STATUSES)) + p_list.add_argument("--tenant", default=None) + p_list.add_argument("--archived", action="store_true", + help="Include archived tasks") + p_list.add_argument("--json", action="store_true") + + # --- show --- + p_show = sub.add_parser("show", help="Show a task with comments + events") + p_show.add_argument("task_id") + p_show.add_argument("--json", action="store_true") + + # --- assign --- + p_assign = sub.add_parser("assign", help="Assign or reassign a task") + p_assign.add_argument("task_id") + p_assign.add_argument("profile", help="Profile name (or 'none' to unassign)") + + # --- link / unlink --- + p_link = sub.add_parser("link", help="Add a parent->child dependency") + p_link.add_argument("parent_id") + p_link.add_argument("child_id") + p_unlink = sub.add_parser("unlink", help="Remove a parent->child dependency") + p_unlink.add_argument("parent_id") + p_unlink.add_argument("child_id") + + # --- claim --- + p_claim = sub.add_parser( + "claim", + help="Atomically claim a ready task (prints resolved workspace path)", + ) + p_claim.add_argument("task_id") + p_claim.add_argument("--ttl", type=int, default=kb.DEFAULT_CLAIM_TTL_SECONDS, + help="Claim TTL in seconds (default: 900)") + + # --- comment / complete / block / unblock / archive --- + p_comment = sub.add_parser("comment", help="Append a comment") + p_comment.add_argument("task_id") + p_comment.add_argument("text", nargs="+", help="Comment body") + p_comment.add_argument("--author", default=None, + help="Author name (default: $HERMES_PROFILE or 'user')") + + p_complete = sub.add_parser("complete", help="Mark a task done") + p_complete.add_argument("task_id") + p_complete.add_argument("--result", default=None, help="Result summary") + + p_block = sub.add_parser("block", help="Mark a task blocked (needs input)") + p_block.add_argument("task_id") + p_block.add_argument("reason", nargs="*", help="Reason (also appended as a comment)") + + p_unblock = sub.add_parser("unblock", help="Return a blocked task to ready") + p_unblock.add_argument("task_id") + + p_archive = sub.add_parser("archive", help="Archive a task (hide from default list)") + p_archive.add_argument("task_id") + + # --- tail --- + p_tail = sub.add_parser("tail", help="Follow a task's event stream") + p_tail.add_argument("task_id") + p_tail.add_argument("--interval", type=float, default=1.0) + + # --- dispatch --- + p_disp = sub.add_parser( + "dispatch", + help="One dispatcher pass: reclaim stale, promote ready, spawn workers", + ) + p_disp.add_argument("--dry-run", action="store_true", + help="Don't actually spawn processes; just print what would happen") + p_disp.add_argument("--max", type=int, default=None, + help="Cap number of spawns this pass") + p_disp.add_argument("--json", action="store_true") + + # --- context --- (for spawned workers) + p_ctx = sub.add_parser( + "context", + help="Print the full context a worker sees for a task " + "(title + body + parent results + comments).", + ) + p_ctx.add_argument("task_id") + + # --- gc --- + sub.add_parser( + "gc", help="Garbage-collect workspaces of archived tasks" + ) + + kanban_parser.set_defaults(_kanban_parser=kanban_parser) + return kanban_parser + + +# --------------------------------------------------------------------------- +# Command dispatch +# --------------------------------------------------------------------------- + +def kanban_command(args: argparse.Namespace) -> int: + """Entry point from ``hermes kanban …`` argparse dispatch. + + Returns a shell-style exit code (0 on success, non-zero on error). + """ + action = getattr(args, "kanban_action", None) + if not action: + # No subaction given: print help via the stored parser reference. + parser = getattr(args, "_kanban_parser", None) + if parser is not None: + parser.print_help() + else: + print( + "usage: hermes kanban [options]\n" + "Run 'hermes kanban --help' for the full list of actions.", + file=sys.stderr, + ) + return 0 + + handlers = { + "init": _cmd_init, + "create": _cmd_create, + "list": _cmd_list, + "ls": _cmd_list, + "show": _cmd_show, + "assign": _cmd_assign, + "link": _cmd_link, + "unlink": _cmd_unlink, + "claim": _cmd_claim, + "comment": _cmd_comment, + "complete": _cmd_complete, + "block": _cmd_block, + "unblock": _cmd_unblock, + "archive": _cmd_archive, + "tail": _cmd_tail, + "dispatch": _cmd_dispatch, + "context": _cmd_context, + "gc": _cmd_gc, + } + handler = handlers.get(action) + if not handler: + print(f"kanban: unknown action {action!r}", file=sys.stderr) + return 2 + try: + return int(handler(args) or 0) + except (ValueError, RuntimeError) as exc: + print(f"kanban: {exc}", file=sys.stderr) + return 1 + + +# --------------------------------------------------------------------------- +# Handlers +# --------------------------------------------------------------------------- + +def _profile_author() -> str: + """Best-effort author name for an interactive CLI call.""" + for env in ("HERMES_PROFILE_NAME", "HERMES_PROFILE"): + v = os.environ.get(env) + if v: + return v + try: + from hermes_cli.profiles import get_active_profile_name + return get_active_profile_name() or "user" + except Exception: + return "user" + + +def _cmd_init(args: argparse.Namespace) -> int: + path = kb.init_db() + print(f"Kanban DB initialized at {path}") + return 0 + + +def _cmd_create(args: argparse.Namespace) -> int: + ws_kind, ws_path = _parse_workspace_flag(args.workspace) + with kb.connect() as conn: + task_id = kb.create_task( + conn, + title=args.title, + body=args.body, + assignee=args.assignee, + created_by=args.created_by or _profile_author(), + workspace_kind=ws_kind, + workspace_path=ws_path, + tenant=args.tenant, + priority=args.priority, + parents=tuple(args.parent or ()), + triage=bool(getattr(args, "triage", False)), + ) + task = kb.get_task(conn, task_id) + if getattr(args, "json", False): + print(json.dumps(_task_to_dict(task), indent=2, ensure_ascii=False)) + else: + print(f"Created {task_id} ({task.status}, assignee={task.assignee or '-'})") + return 0 + + +def _cmd_list(args: argparse.Namespace) -> int: + assignee = args.assignee + if args.mine and not assignee: + assignee = _profile_author() + with kb.connect() as conn: + # Cheap "mini-dispatch": recompute ready so list output reflects + # dependencies that may have cleared since the last dispatcher tick. + kb.recompute_ready(conn) + tasks = kb.list_tasks( + conn, + assignee=assignee, + status=args.status, + tenant=args.tenant, + include_archived=args.archived, + ) + if getattr(args, "json", False): + print(json.dumps([_task_to_dict(t) for t in tasks], indent=2, ensure_ascii=False)) + return 0 + if not tasks: + print("(no matching tasks)") + return 0 + for t in tasks: + print(_fmt_task_line(t)) + return 0 + + +def _cmd_show(args: argparse.Namespace) -> int: + with kb.connect() as conn: + task = kb.get_task(conn, args.task_id) + if not task: + print(f"no such task: {args.task_id}", file=sys.stderr) + return 1 + comments = kb.list_comments(conn, args.task_id) + events = kb.list_events(conn, args.task_id) + parents = kb.parent_ids(conn, args.task_id) + children = kb.child_ids(conn, args.task_id) + + if getattr(args, "json", False): + payload = { + "task": _task_to_dict(task), + "parents": parents, + "children": children, + "comments": [ + {"author": c.author, "body": c.body, "created_at": c.created_at} + for c in comments + ], + "events": [ + {"kind": e.kind, "payload": e.payload, "created_at": e.created_at} + for e in events + ], + } + print(json.dumps(payload, indent=2, ensure_ascii=False)) + return 0 + + print(f"Task {task.id}: {task.title}") + print(f" status: {task.status}") + print(f" assignee: {task.assignee or '-'}") + if task.tenant: + print(f" tenant: {task.tenant}") + print(f" workspace: {task.workspace_kind}" + + (f" @ {task.workspace_path}" if task.workspace_path else "")) + print(f" created: {_fmt_ts(task.created_at)} by {task.created_by or '-'}") + if task.started_at: + print(f" started: {_fmt_ts(task.started_at)}") + if task.completed_at: + print(f" completed: {_fmt_ts(task.completed_at)}") + if parents: + print(f" parents: {', '.join(parents)}") + if children: + print(f" children: {', '.join(children)}") + if task.body: + print() + print("Body:") + print(task.body) + if task.result: + print() + print("Result:") + print(task.result) + if comments: + print() + print(f"Comments ({len(comments)}):") + for c in comments: + print(f" [{_fmt_ts(c.created_at)}] {c.author}: {c.body}") + if events: + print() + print(f"Events ({len(events)}):") + for e in events[-20:]: + pl = f" {e.payload}" if e.payload else "" + print(f" [{_fmt_ts(e.created_at)}] {e.kind}{pl}") + return 0 + + +def _cmd_assign(args: argparse.Namespace) -> int: + profile = None if args.profile.lower() in ("none", "-", "null") else args.profile + with kb.connect() as conn: + ok = kb.assign_task(conn, args.task_id, profile) + if not ok: + print(f"no such task: {args.task_id}", file=sys.stderr) + return 1 + print(f"Assigned {args.task_id} to {profile or '(unassigned)'}") + return 0 + + +def _cmd_link(args: argparse.Namespace) -> int: + with kb.connect() as conn: + kb.link_tasks(conn, args.parent_id, args.child_id) + print(f"Linked {args.parent_id} -> {args.child_id}") + return 0 + + +def _cmd_unlink(args: argparse.Namespace) -> int: + with kb.connect() as conn: + ok = kb.unlink_tasks(conn, args.parent_id, args.child_id) + if not ok: + print(f"No such link: {args.parent_id} -> {args.child_id}", file=sys.stderr) + return 1 + print(f"Unlinked {args.parent_id} -> {args.child_id}") + return 0 + + +def _cmd_claim(args: argparse.Namespace) -> int: + with kb.connect() as conn: + task = kb.claim_task(conn, args.task_id, ttl_seconds=args.ttl) + if task is None: + # Report why + existing = kb.get_task(conn, args.task_id) + if existing is None: + print(f"no such task: {args.task_id}", file=sys.stderr) + return 1 + print( + f"cannot claim {args.task_id}: status={existing.status} " + f"lock={existing.claim_lock or '(none)'}", + file=sys.stderr, + ) + return 1 + workspace = kb.resolve_workspace(task) + kb.set_workspace_path(conn, task.id, str(workspace)) + print(f"Claimed {task.id}") + print(f"Workspace: {workspace}") + return 0 + + +def _cmd_comment(args: argparse.Namespace) -> int: + body = " ".join(args.text).strip() + author = args.author or _profile_author() + with kb.connect() as conn: + kb.add_comment(conn, args.task_id, author, body) + print(f"Comment added to {args.task_id}") + return 0 + + +def _cmd_complete(args: argparse.Namespace) -> int: + with kb.connect() as conn: + ok = kb.complete_task(conn, args.task_id, result=args.result) + if not ok: + print(f"cannot complete {args.task_id} (unknown id or terminal state)", file=sys.stderr) + return 1 + print(f"Completed {args.task_id}") + return 0 + + +def _cmd_block(args: argparse.Namespace) -> int: + reason = " ".join(args.reason).strip() if args.reason else None + author = _profile_author() + with kb.connect() as conn: + if reason: + kb.add_comment(conn, args.task_id, author, f"BLOCKED: {reason}") + ok = kb.block_task(conn, args.task_id, reason=reason) + if not ok: + print(f"cannot block {args.task_id}", file=sys.stderr) + return 1 + print(f"Blocked {args.task_id}" + (f": {reason}" if reason else "")) + return 0 + + +def _cmd_unblock(args: argparse.Namespace) -> int: + with kb.connect() as conn: + ok = kb.unblock_task(conn, args.task_id) + if not ok: + print(f"cannot unblock {args.task_id} (not blocked?)", file=sys.stderr) + return 1 + print(f"Unblocked {args.task_id}") + return 0 + + +def _cmd_archive(args: argparse.Namespace) -> int: + with kb.connect() as conn: + ok = kb.archive_task(conn, args.task_id) + if not ok: + print(f"cannot archive {args.task_id}", file=sys.stderr) + return 1 + print(f"Archived {args.task_id}") + return 0 + + +def _cmd_tail(args: argparse.Namespace) -> int: + last_id = 0 + print(f"Tailing events for {args.task_id}. Ctrl-C to stop.") + try: + while True: + with kb.connect() as conn: + events = kb.list_events(conn, args.task_id) + for e in events: + if e.id > last_id: + pl = f" {e.payload}" if e.payload else "" + print(f"[{_fmt_ts(e.created_at)}] {e.kind}{pl}", flush=True) + last_id = e.id + time.sleep(max(0.1, args.interval)) + except KeyboardInterrupt: + print("\n(stopped)") + return 0 + + +def _cmd_dispatch(args: argparse.Namespace) -> int: + with kb.connect() as conn: + res = kb.dispatch_once( + conn, + dry_run=args.dry_run, + max_spawn=args.max, + ) + if getattr(args, "json", False): + print(json.dumps({ + "reclaimed": res.reclaimed, + "promoted": res.promoted, + "spawned": [ + {"task_id": tid, "assignee": who, "workspace": ws} + for (tid, who, ws) in res.spawned + ], + "skipped_unassigned": res.skipped_unassigned, + }, indent=2)) + return 0 + print(f"Reclaimed: {res.reclaimed}") + print(f"Promoted: {res.promoted}") + print(f"Spawned: {len(res.spawned)}") + for tid, who, ws in res.spawned: + tag = " (dry)" if args.dry_run else "" + print(f" - {tid} -> {who} @ {ws or '-'}{tag}") + if res.skipped_unassigned: + print(f"Skipped (unassigned): {', '.join(res.skipped_unassigned)}") + return 0 + + +def _cmd_context(args: argparse.Namespace) -> int: + with kb.connect() as conn: + text = kb.build_worker_context(conn, args.task_id) + print(text) + return 0 + + +def _cmd_gc(args: argparse.Namespace) -> int: + """Remove scratch workspaces of archived tasks. + + Only touches directories under the default scratch root; leaves user + ``dir:`` workspaces and ``worktree`` dirs alone (user owns those). + """ + import shutil + scratch_root = kb.workspaces_root() + removed = 0 + with kb.connect() as conn: + rows = conn.execute( + "SELECT id, workspace_kind, workspace_path FROM tasks WHERE status = 'archived'" + ).fetchall() + for row in rows: + if row["workspace_kind"] != "scratch": + continue + path = Path(row["workspace_path"] or (scratch_root / row["id"])) + try: + path = path.resolve() + except OSError: + continue + try: + scratch_root.resolve().relative_to(scratch_root.resolve()) + path.relative_to(scratch_root.resolve()) + except ValueError: + # Safety: never delete outside the scratch root. + continue + if path.exists() and path.is_dir(): + shutil.rmtree(path, ignore_errors=True) + removed += 1 + print(f"GC complete: removed {removed} scratch workspace(s)") + return 0 + + +# --------------------------------------------------------------------------- +# Slash-command entry point (used by /kanban from CLI and gateway) +# --------------------------------------------------------------------------- + +def run_slash(rest: str) -> str: + """Execute a ``/kanban …`` string and return captured stdout/stderr. + + ``rest`` is everything after ``/kanban`` (may be empty). Used from + both the interactive CLI (``self._handle_kanban_command``) and the + gateway (``_handle_kanban_command``) so formatting is identical. + """ + import io + import contextlib + + tokens = shlex.split(rest) if rest and rest.strip() else [] + + parser = argparse.ArgumentParser(prog="/kanban", add_help=False) + parser.exit_on_error = False # type: ignore[attr-defined] + sub = parser.add_subparsers(dest="kanban_action") + # Reuse the argparse builder -- call it with a throwaway parent + # subparsers via a wrapping top-level parser. + wrap = argparse.ArgumentParser(prog="/", add_help=False) + wrap.exit_on_error = False # type: ignore[attr-defined] + wrap_sub = wrap.add_subparsers(dest="_top") + build_parser(wrap_sub) + + buf_out = io.StringIO() + buf_err = io.StringIO() + try: + # Prepend the "kanban" token so our top-level subparser routes here. + argv = ["kanban", *tokens] if tokens else ["kanban"] + args = wrap.parse_args(argv) + except SystemExit as exc: + return f"(usage error: {exc})" + except argparse.ArgumentError as exc: + return f"(usage error: {exc})" + + with contextlib.redirect_stdout(buf_out), contextlib.redirect_stderr(buf_err): + try: + kanban_command(args) + except SystemExit: + pass + except Exception as exc: + print(f"error: {exc}", file=sys.stderr) + + out = buf_out.getvalue().rstrip() + err = buf_err.getvalue().rstrip() + if err and out: + return f"{out}\n{err}" + return err if err else (out or "(no output)") diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py new file mode 100644 index 0000000000000..4c3a8bddfa757 --- /dev/null +++ b/hermes_cli/kanban_db.py @@ -0,0 +1,1081 @@ +"""SQLite-backed Kanban board for multi-profile collaboration. + +The board lives at ``$HERMES_HOME/kanban.db`` (profile-agnostic on purpose: +multiple profiles on the same machine all see the same board, which IS the +coordination primitive). + +Schema is intentionally small: tasks, task_links, task_comments, +task_events. The ``workspace_kind`` field decouples coordination from git +worktrees so that research / ops / digital-twin workloads work alongside +coding workloads. See ``docs/hermes-kanban-v1-spec.pdf`` for the full +design specification. + +Concurrency strategy: WAL mode + ``BEGIN IMMEDIATE`` for write +transactions + compare-and-swap (CAS) updates on ``tasks.status`` and +``tasks.claim_lock``. SQLite serializes writers via its WAL lock, so at +most one claimer can win any given task. Losers observe zero affected +rows and move on -- no retry loops, no distributed-lock machinery. +""" + +from __future__ import annotations + +import contextlib +import json +import os +import secrets +import sqlite3 +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Iterable, Optional + + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +VALID_STATUSES = {"triage", "todo", "ready", "running", "blocked", "done", "archived"} +VALID_WORKSPACE_KINDS = {"scratch", "worktree", "dir"} + +# A running task's claim is valid for 15 minutes; after that the next +# dispatcher tick reclaims it. Workers that outlive this window should call +# ``heartbeat_claim(task_id)`` periodically. In practice most kanban +# workloads either finish within 15m or set a longer claim explicitly. +DEFAULT_CLAIM_TTL_SECONDS = 15 * 60 + + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- + +def kanban_db_path() -> Path: + """Return the path to ``kanban.db`` inside the active HERMES_HOME.""" + from hermes_constants import get_hermes_home + return get_hermes_home() / "kanban.db" + + +def workspaces_root() -> Path: + """Return the directory under which ``scratch`` workspaces are created.""" + from hermes_constants import get_hermes_home + return get_hermes_home() / "kanban" / "workspaces" + + +# --------------------------------------------------------------------------- +# Data classes +# --------------------------------------------------------------------------- + +@dataclass +class Task: + """In-memory view of a row from the ``tasks`` table.""" + + id: str + title: str + body: Optional[str] + assignee: Optional[str] + status: str + priority: int + created_by: Optional[str] + created_at: int + started_at: Optional[int] + completed_at: Optional[int] + workspace_kind: str + workspace_path: Optional[str] + claim_lock: Optional[str] + claim_expires: Optional[int] + tenant: Optional[str] + result: Optional[str] = None + + @classmethod + def from_row(cls, row: sqlite3.Row) -> "Task": + return cls( + id=row["id"], + title=row["title"], + body=row["body"], + assignee=row["assignee"], + status=row["status"], + priority=row["priority"], + created_by=row["created_by"], + created_at=row["created_at"], + started_at=row["started_at"], + completed_at=row["completed_at"], + workspace_kind=row["workspace_kind"], + workspace_path=row["workspace_path"], + claim_lock=row["claim_lock"], + claim_expires=row["claim_expires"], + tenant=row["tenant"] if "tenant" in row.keys() else None, + result=row["result"] if "result" in row.keys() else None, + ) + + +@dataclass +class Comment: + id: int + task_id: str + author: str + body: str + created_at: int + + +@dataclass +class Event: + id: int + task_id: str + kind: str + payload: Optional[dict] + created_at: int + + +# --------------------------------------------------------------------------- +# Schema +# --------------------------------------------------------------------------- + +SCHEMA_SQL = """ +CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + body TEXT, + assignee TEXT, + status TEXT NOT NULL, + priority INTEGER DEFAULT 0, + created_by TEXT, + created_at INTEGER NOT NULL, + started_at INTEGER, + completed_at INTEGER, + workspace_kind TEXT NOT NULL DEFAULT 'scratch', + workspace_path TEXT, + claim_lock TEXT, + claim_expires INTEGER, + tenant TEXT, + result TEXT +); + +CREATE TABLE IF NOT EXISTS task_links ( + parent_id TEXT NOT NULL, + child_id TEXT NOT NULL, + PRIMARY KEY (parent_id, child_id) +); + +CREATE TABLE IF NOT EXISTS task_comments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL, + author TEXT NOT NULL, + body TEXT NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS task_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL, + kind TEXT NOT NULL, + payload TEXT, + created_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_tasks_assignee_status ON tasks(assignee, status); +CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); +CREATE INDEX IF NOT EXISTS idx_tasks_tenant ON tasks(tenant); +CREATE INDEX IF NOT EXISTS idx_links_child ON task_links(child_id); +CREATE INDEX IF NOT EXISTS idx_links_parent ON task_links(parent_id); +CREATE INDEX IF NOT EXISTS idx_comments_task ON task_comments(task_id, created_at); +CREATE INDEX IF NOT EXISTS idx_events_task ON task_events(task_id, created_at); +""" + + +# --------------------------------------------------------------------------- +# Connection helpers +# --------------------------------------------------------------------------- + +def connect(db_path: Optional[Path] = None) -> sqlite3.Connection: + """Open (and initialize if needed) the kanban DB. + + WAL mode is enabled on every connection; it's a no-op after the first + time but keeps the code robust if the DB file is ever re-created. + """ + path = db_path or kanban_db_path() + path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(path), isolation_level=None, timeout=30) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA synchronous=NORMAL") + conn.execute("PRAGMA foreign_keys=ON") + return conn + + +def init_db(db_path: Optional[Path] = None) -> Path: + """Create the schema if it doesn't exist; return the path used.""" + path = db_path or kanban_db_path() + with contextlib.closing(connect(path)) as conn: + conn.executescript(SCHEMA_SQL) + _migrate_add_optional_columns(conn) + return path + + +def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None: + """Add columns that were introduced after v1 release to legacy DBs. + + Called by ``init_db`` so opening an old DB is always safe. + """ + cols = {row["name"] for row in conn.execute("PRAGMA table_info(tasks)")} + if "tenant" not in cols: + conn.execute("ALTER TABLE tasks ADD COLUMN tenant TEXT") + if "result" not in cols: + conn.execute("ALTER TABLE tasks ADD COLUMN result TEXT") + + +@contextlib.contextmanager +def write_txn(conn: sqlite3.Connection): + """Context manager for an IMMEDIATE write transaction. + + Use for any multi-statement write (creating a task + link, claiming a + task + recording an event, etc.). A claim CAS inside this context is + atomic -- at most one concurrent writer can succeed. + """ + conn.execute("BEGIN IMMEDIATE") + try: + yield conn + except Exception: + conn.execute("ROLLBACK") + raise + else: + conn.execute("COMMIT") + + +# --------------------------------------------------------------------------- +# ID generation +# --------------------------------------------------------------------------- + +def _new_task_id() -> str: + """Generate a short, URL-safe, human-readable task id. + + Format: ``t_<4 hex chars>``. Space is 65k values; collisions are + rare but handled by a one-shot retry in ``create_task``. + """ + return "t_" + secrets.token_hex(2) + + +def _claimer_id() -> str: + """Return a ``host:pid`` string that identifies this claimer.""" + import socket + try: + host = socket.gethostname() or "unknown" + except Exception: + host = "unknown" + return f"{host}:{os.getpid()}" + + +# --------------------------------------------------------------------------- +# Task creation / mutation +# --------------------------------------------------------------------------- + +def create_task( + conn: sqlite3.Connection, + *, + title: str, + body: Optional[str] = None, + assignee: Optional[str] = None, + created_by: Optional[str] = None, + workspace_kind: str = "scratch", + workspace_path: Optional[str] = None, + tenant: Optional[str] = None, + priority: int = 0, + parents: Iterable[str] = (), + triage: bool = False, +) -> str: + """Create a new task and optionally link it under parent tasks. + + Returns the new task id. Status is ``ready`` when there are no + parents (or all parents already ``done``), otherwise ``todo``. + If ``triage=True``, status is forced to ``triage`` regardless of + parents — a specifier/triager is expected to promote the task to + ``todo`` once the spec is fleshed out. + """ + if not title or not title.strip(): + raise ValueError("title is required") + if workspace_kind not in VALID_WORKSPACE_KINDS: + raise ValueError( + f"workspace_kind must be one of {sorted(VALID_WORKSPACE_KINDS)}, " + f"got {workspace_kind!r}" + ) + parents = tuple(p for p in parents if p) + + now = int(time.time()) + + # Retry once on the extremely unlikely id collision. + for attempt in range(2): + task_id = _new_task_id() + try: + with write_txn(conn): + # Determine initial status from parent status, unless the + # caller is parking this task in triage for a specifier. + if triage: + initial_status = "triage" + else: + initial_status = "ready" + if parents: + missing = _find_missing_parents(conn, parents) + if missing: + raise ValueError(f"unknown parent task(s): {', '.join(missing)}") + # If any parent is not yet done, we're todo. + rows = conn.execute( + "SELECT status FROM tasks WHERE id IN " + "(" + ",".join("?" * len(parents)) + ")", + parents, + ).fetchall() + if any(r["status"] != "done" for r in rows): + initial_status = "todo" + # Even in triage mode we still need to validate parent ids + # so the eventual link rows don't dangle. + if triage and parents: + missing = _find_missing_parents(conn, parents) + if missing: + raise ValueError(f"unknown parent task(s): {', '.join(missing)}") + + conn.execute( + """ + INSERT INTO tasks ( + id, title, body, assignee, status, priority, + created_by, created_at, workspace_kind, workspace_path, + tenant + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + task_id, + title.strip(), + body, + assignee, + initial_status, + priority, + created_by, + now, + workspace_kind, + workspace_path, + tenant, + ), + ) + for pid in parents: + conn.execute( + "INSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)", + (pid, task_id), + ) + _append_event( + conn, + task_id, + "created", + { + "assignee": assignee, + "status": initial_status, + "parents": list(parents), + "tenant": tenant, + }, + ) + return task_id + except sqlite3.IntegrityError: + if attempt == 1: + raise + # Retry with a fresh id. + continue + raise RuntimeError("unreachable") + + +def _find_missing_parents(conn: sqlite3.Connection, parents: Iterable[str]) -> list[str]: + parents = list(parents) + if not parents: + return [] + placeholders = ",".join("?" * len(parents)) + rows = conn.execute( + f"SELECT id FROM tasks WHERE id IN ({placeholders})", + parents, + ).fetchall() + present = {r["id"] for r in rows} + return [p for p in parents if p not in present] + + +def get_task(conn: sqlite3.Connection, task_id: str) -> Optional[Task]: + row = conn.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone() + return Task.from_row(row) if row else None + + +def list_tasks( + conn: sqlite3.Connection, + *, + assignee: Optional[str] = None, + status: Optional[str] = None, + tenant: Optional[str] = None, + include_archived: bool = False, + limit: Optional[int] = None, +) -> list[Task]: + query = "SELECT * FROM tasks WHERE 1=1" + params: list[Any] = [] + if assignee is not None: + query += " AND assignee = ?" + params.append(assignee) + if status is not None: + if status not in VALID_STATUSES: + raise ValueError(f"status must be one of {sorted(VALID_STATUSES)}") + query += " AND status = ?" + params.append(status) + if tenant is not None: + query += " AND tenant = ?" + params.append(tenant) + if not include_archived and status != "archived": + query += " AND status != 'archived'" + query += " ORDER BY priority DESC, created_at ASC" + if limit: + query += f" LIMIT {int(limit)}" + rows = conn.execute(query, params).fetchall() + return [Task.from_row(r) for r in rows] + + +def assign_task(conn: sqlite3.Connection, task_id: str, profile: Optional[str]) -> bool: + """Assign or reassign a task. Returns True on success. + + Refuses to reassign a task that's currently running (claim_lock set). + Reassign after the current run completes if needed. + """ + with write_txn(conn): + row = conn.execute( + "SELECT status, claim_lock FROM tasks WHERE id = ?", (task_id,) + ).fetchone() + if not row: + return False + if row["claim_lock"] is not None and row["status"] == "running": + raise RuntimeError( + f"cannot reassign {task_id}: currently running (claimed). " + "Wait for completion or reclaim the stale lock first." + ) + conn.execute("UPDATE tasks SET assignee = ? WHERE id = ?", (profile, task_id)) + _append_event(conn, task_id, "assigned", {"assignee": profile}) + return True + + +# --------------------------------------------------------------------------- +# Links +# --------------------------------------------------------------------------- + +def link_tasks(conn: sqlite3.Connection, parent_id: str, child_id: str) -> None: + if parent_id == child_id: + raise ValueError("a task cannot depend on itself") + with write_txn(conn): + missing = _find_missing_parents(conn, [parent_id, child_id]) + if missing: + raise ValueError(f"unknown task(s): {', '.join(missing)}") + if _would_cycle(conn, parent_id, child_id): + raise ValueError( + f"linking {parent_id} -> {child_id} would create a cycle" + ) + conn.execute( + "INSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)", + (parent_id, child_id), + ) + # If child was ready but parent is not yet done, demote child to todo. + parent_status = conn.execute( + "SELECT status FROM tasks WHERE id = ?", (parent_id,) + ).fetchone()["status"] + if parent_status != "done": + conn.execute( + "UPDATE tasks SET status = 'todo' WHERE id = ? AND status = 'ready'", + (child_id,), + ) + _append_event( + conn, child_id, "linked", + {"parent": parent_id, "child": child_id}, + ) + + +def _would_cycle(conn: sqlite3.Connection, parent_id: str, child_id: str) -> bool: + """Return True if adding parent->child creates a cycle. + + A cycle exists iff ``parent_id`` is already a descendant of + ``child_id`` via existing parent->child links. We walk downward + from ``child_id`` and check whether we reach ``parent_id``. + """ + seen = set() + stack = [child_id] + while stack: + node = stack.pop() + if node == parent_id: + return True + if node in seen: + continue + seen.add(node) + rows = conn.execute( + "SELECT child_id FROM task_links WHERE parent_id = ?", (node,) + ).fetchall() + stack.extend(r["child_id"] for r in rows) + return False + + +def unlink_tasks(conn: sqlite3.Connection, parent_id: str, child_id: str) -> bool: + with write_txn(conn): + cur = conn.execute( + "DELETE FROM task_links WHERE parent_id = ? AND child_id = ?", + (parent_id, child_id), + ) + if cur.rowcount: + _append_event( + conn, child_id, "unlinked", + {"parent": parent_id, "child": child_id}, + ) + return cur.rowcount > 0 + + +def parent_ids(conn: sqlite3.Connection, task_id: str) -> list[str]: + rows = conn.execute( + "SELECT parent_id FROM task_links WHERE child_id = ? ORDER BY parent_id", + (task_id,), + ).fetchall() + return [r["parent_id"] for r in rows] + + +def child_ids(conn: sqlite3.Connection, task_id: str) -> list[str]: + rows = conn.execute( + "SELECT child_id FROM task_links WHERE parent_id = ? ORDER BY child_id", + (task_id,), + ).fetchall() + return [r["child_id"] for r in rows] + + +def parent_results(conn: sqlite3.Connection, task_id: str) -> list[tuple[str, Optional[str]]]: + """Return ``(parent_id, result)`` for every done parent of ``task_id``.""" + rows = conn.execute( + """ + SELECT t.id AS id, t.result AS result + FROM tasks t + JOIN task_links l ON l.parent_id = t.id + WHERE l.child_id = ? AND t.status = 'done' + ORDER BY t.completed_at ASC + """, + (task_id,), + ).fetchall() + return [(r["id"], r["result"]) for r in rows] + + +# --------------------------------------------------------------------------- +# Comments & events +# --------------------------------------------------------------------------- + +def add_comment( + conn: sqlite3.Connection, task_id: str, author: str, body: str +) -> int: + if not body or not body.strip(): + raise ValueError("comment body is required") + if not author or not author.strip(): + raise ValueError("comment author is required") + now = int(time.time()) + with write_txn(conn): + if not conn.execute( + "SELECT 1 FROM tasks WHERE id = ?", (task_id,) + ).fetchone(): + raise ValueError(f"unknown task {task_id}") + cur = conn.execute( + "INSERT INTO task_comments (task_id, author, body, created_at) " + "VALUES (?, ?, ?, ?)", + (task_id, author.strip(), body.strip(), now), + ) + _append_event(conn, task_id, "commented", {"author": author, "len": len(body)}) + return int(cur.lastrowid or 0) + + +def list_comments(conn: sqlite3.Connection, task_id: str) -> list[Comment]: + rows = conn.execute( + "SELECT * FROM task_comments WHERE task_id = ? ORDER BY created_at ASC", + (task_id,), + ).fetchall() + return [ + Comment( + id=r["id"], + task_id=r["task_id"], + author=r["author"], + body=r["body"], + created_at=r["created_at"], + ) + for r in rows + ] + + +def list_events(conn: sqlite3.Connection, task_id: str) -> list[Event]: + rows = conn.execute( + "SELECT * FROM task_events WHERE task_id = ? ORDER BY created_at ASC, id ASC", + (task_id,), + ).fetchall() + out = [] + for r in rows: + try: + payload = json.loads(r["payload"]) if r["payload"] else None + except Exception: + payload = None + out.append( + Event( + id=r["id"], + task_id=r["task_id"], + kind=r["kind"], + payload=payload, + created_at=r["created_at"], + ) + ) + return out + + +def _append_event( + conn: sqlite3.Connection, + task_id: str, + kind: str, + payload: Optional[dict] = None, +) -> None: + """Record an event row. Called from within an already-open txn.""" + now = int(time.time()) + pl = json.dumps(payload, ensure_ascii=False) if payload else None + conn.execute( + "INSERT INTO task_events (task_id, kind, payload, created_at) " + "VALUES (?, ?, ?, ?)", + (task_id, kind, pl, now), + ) + + +# --------------------------------------------------------------------------- +# Dependency resolution (todo -> ready) +# --------------------------------------------------------------------------- + +def recompute_ready(conn: sqlite3.Connection) -> int: + """Promote ``todo`` tasks to ``ready`` when all parents are ``done``. + + Returns the number of tasks promoted. Safe to call inside or outside + an existing transaction; it opens its own IMMEDIATE txn. + """ + promoted = 0 + with write_txn(conn): + todo_rows = conn.execute( + "SELECT id FROM tasks WHERE status = 'todo'" + ).fetchall() + for row in todo_rows: + task_id = row["id"] + parents = conn.execute( + "SELECT t.status FROM tasks t " + "JOIN task_links l ON l.parent_id = t.id " + "WHERE l.child_id = ?", + (task_id,), + ).fetchall() + if all(p["status"] == "done" for p in parents): + conn.execute( + "UPDATE tasks SET status = 'ready' WHERE id = ? AND status = 'todo'", + (task_id,), + ) + _append_event(conn, task_id, "ready", None) + promoted += 1 + return promoted + + +# --------------------------------------------------------------------------- +# Claim / complete / block +# --------------------------------------------------------------------------- + +def claim_task( + conn: sqlite3.Connection, + task_id: str, + *, + ttl_seconds: int = DEFAULT_CLAIM_TTL_SECONDS, + claimer: Optional[str] = None, +) -> Optional[Task]: + """Atomically transition ``ready -> running``. + + Returns the claimed ``Task`` on success, ``None`` if the task was + already claimed (or is not in ``ready`` status). + """ + now = int(time.time()) + lock = claimer or _claimer_id() + expires = now + int(ttl_seconds) + with write_txn(conn): + cur = conn.execute( + """ + UPDATE tasks + SET status = 'running', + claim_lock = ?, + claim_expires = ?, + started_at = COALESCE(started_at, ?) + WHERE id = ? + AND status = 'ready' + AND claim_lock IS NULL + """, + (lock, expires, now, task_id), + ) + if cur.rowcount != 1: + return None + _append_event(conn, task_id, "claimed", {"lock": lock, "expires": expires}) + return get_task(conn, task_id) + + +def heartbeat_claim( + conn: sqlite3.Connection, + task_id: str, + *, + ttl_seconds: int = DEFAULT_CLAIM_TTL_SECONDS, + claimer: Optional[str] = None, +) -> bool: + """Extend a running claim. Returns True if we still own it. + + Workers that know they'll exceed 15 minutes should call this every + few minutes to keep ownership. + """ + expires = int(time.time()) + int(ttl_seconds) + lock = claimer or _claimer_id() + with write_txn(conn): + cur = conn.execute( + "UPDATE tasks SET claim_expires = ? " + "WHERE id = ? AND status = 'running' AND claim_lock = ?", + (expires, task_id, lock), + ) + return cur.rowcount == 1 + + +def release_stale_claims(conn: sqlite3.Connection) -> int: + """Reset any ``running`` task whose claim has expired. + + Returns the number of stale claims reclaimed. Safe to call often. + """ + now = int(time.time()) + reclaimed = 0 + with write_txn(conn): + stale = conn.execute( + "SELECT id, claim_lock FROM tasks " + "WHERE status = 'running' AND claim_expires IS NOT NULL AND claim_expires < ?", + (now,), + ).fetchall() + for row in stale: + conn.execute( + "UPDATE tasks SET status = 'ready', claim_lock = NULL, " + "claim_expires = NULL " + "WHERE id = ? AND status = 'running'", + (row["id"],), + ) + _append_event( + conn, row["id"], "reclaimed", + {"stale_lock": row["claim_lock"]}, + ) + reclaimed += 1 + return reclaimed + + +def complete_task( + conn: sqlite3.Connection, + task_id: str, + *, + result: Optional[str] = None, +) -> bool: + """Transition ``running|ready -> done`` and record ``result``. + + Accepts a task that's merely ``ready`` too, so a manual CLI + completion (``hermes kanban complete ``) works without requiring + a claim/start/complete sequence. + """ + now = int(time.time()) + with write_txn(conn): + cur = conn.execute( + """ + UPDATE tasks + SET status = 'done', + result = ?, + completed_at = ?, + claim_lock = NULL, + claim_expires= NULL + WHERE id = ? + AND status IN ('running', 'ready', 'blocked') + """, + (result, now, task_id), + ) + if cur.rowcount != 1: + return False + _append_event( + conn, task_id, "completed", + {"result_len": len(result) if result else 0}, + ) + # Recompute ready status for dependents (separate txn so children see done). + recompute_ready(conn) + return True + + +def block_task( + conn: sqlite3.Connection, + task_id: str, + *, + reason: Optional[str] = None, +) -> bool: + """Transition ``running -> blocked``.""" + with write_txn(conn): + cur = conn.execute( + """ + UPDATE tasks + SET status = 'blocked', + claim_lock = NULL, + claim_expires= NULL + WHERE id = ? + AND status IN ('running', 'ready') + """, + (task_id,), + ) + if cur.rowcount != 1: + return False + _append_event(conn, task_id, "blocked", {"reason": reason}) + return True + + +def unblock_task(conn: sqlite3.Connection, task_id: str) -> bool: + """Transition ``blocked -> ready``.""" + with write_txn(conn): + cur = conn.execute( + "UPDATE tasks SET status = 'ready' WHERE id = ? AND status = 'blocked'", + (task_id,), + ) + if cur.rowcount != 1: + return False + _append_event(conn, task_id, "unblocked", None) + return True + + +def archive_task(conn: sqlite3.Connection, task_id: str) -> bool: + with write_txn(conn): + cur = conn.execute( + "UPDATE tasks SET status = 'archived' WHERE id = ? AND status != 'archived'", + (task_id,), + ) + if cur.rowcount != 1: + return False + _append_event(conn, task_id, "archived", None) + return True + + +# --------------------------------------------------------------------------- +# Workspace resolution +# --------------------------------------------------------------------------- + +def resolve_workspace(task: Task) -> Path: + """Resolve (and create if needed) the workspace for a task. + + - ``scratch``: a fresh dir under ``$HERMES_HOME/kanban/workspaces//``. + - ``dir:``: the path stored in ``workspace_path``. Created if missing. + - ``worktree``: a git worktree at ``workspace_path``. Not created + automatically in v1 -- the kanban-worker skill documents + ``git worktree add`` as a worker-side step. Returns the intended path. + + Persist the resolved path back to the task row via ``set_workspace_path`` + so subsequent runs reuse the same directory. + """ + kind = task.workspace_kind or "scratch" + if kind == "scratch": + if task.workspace_path: + p = Path(task.workspace_path).expanduser() + else: + p = workspaces_root() / task.id + p.mkdir(parents=True, exist_ok=True) + return p + if kind == "dir": + if not task.workspace_path: + raise ValueError( + f"task {task.id} has workspace_kind=dir but no workspace_path" + ) + p = Path(task.workspace_path).expanduser() + p.mkdir(parents=True, exist_ok=True) + return p + if kind == "worktree": + if not task.workspace_path: + # Default: .worktrees// under CWD. Worker skill creates it. + return Path.cwd() / ".worktrees" / task.id + return Path(task.workspace_path).expanduser() + raise ValueError(f"unknown workspace_kind: {kind}") + + +def set_workspace_path( + conn: sqlite3.Connection, task_id: str, path: Path | str +) -> None: + with write_txn(conn): + conn.execute( + "UPDATE tasks SET workspace_path = ? WHERE id = ?", + (str(path), task_id), + ) + + +# --------------------------------------------------------------------------- +# Dispatcher (one-shot pass) +# --------------------------------------------------------------------------- + +@dataclass +class DispatchResult: + """Outcome of a single ``dispatch`` pass.""" + + reclaimed: int = 0 + promoted: int = 0 + spawned: list[tuple[str, str, str]] = field(default_factory=list) + """List of ``(task_id, assignee, workspace_path)`` triples.""" + skipped_unassigned: list[str] = field(default_factory=list) + + +def dispatch_once( + conn: sqlite3.Connection, + *, + spawn_fn=None, + ttl_seconds: int = DEFAULT_CLAIM_TTL_SECONDS, + dry_run: bool = False, + max_spawn: Optional[int] = None, +) -> DispatchResult: + """Run one dispatcher tick. + + Steps: + 1. Reclaim stale running tasks. + 2. Promote todo -> ready where all parents are done. + 3. For each ready task with an assignee, atomically claim and call + ``spawn_fn(task, workspace_path)``. + + ``spawn_fn`` defaults to ``_default_spawn`` which invokes + ``hermes -p chat -q "..."`` in the background. Tests pass + a stub. + """ + result = DispatchResult() + result.reclaimed = release_stale_claims(conn) + result.promoted = recompute_ready(conn) + + ready_rows = conn.execute( + "SELECT id, assignee FROM tasks " + "WHERE status = 'ready' AND claim_lock IS NULL " + "ORDER BY priority DESC, created_at ASC" + ).fetchall() + spawned = 0 + for row in ready_rows: + if max_spawn is not None and spawned >= max_spawn: + break + if not row["assignee"]: + result.skipped_unassigned.append(row["id"]) + continue + if dry_run: + result.spawned.append((row["id"], row["assignee"], "")) + continue + claimed = claim_task(conn, row["id"], ttl_seconds=ttl_seconds) + if claimed is None: + continue + workspace = resolve_workspace(claimed) + # Persist the resolved workspace path so the worker can cd there. + set_workspace_path(conn, claimed.id, str(workspace)) + if spawn_fn is None: + spawn_fn = _default_spawn + try: + spawn_fn(claimed, str(workspace)) + result.spawned.append((claimed.id, claimed.assignee or "", str(workspace))) + spawned += 1 + except Exception as exc: + # Spawn failed: release the claim so the next tick can retry. + with write_txn(conn): + conn.execute( + "UPDATE tasks SET status = 'ready', claim_lock = NULL, " + "claim_expires = NULL WHERE id = ? AND status = 'running'", + (claimed.id,), + ) + _append_event( + conn, claimed.id, "spawn_failed", + {"error": str(exc)[:500]}, + ) + return result + + +def _default_spawn(task: Task, workspace: str) -> None: + """Fire-and-forget ``hermes -p chat -q ...`` subprocess. + + We don't wait for the child; its completion is observed by polling + the board ``complete``/``block`` transitions that the worker writes. + """ + import subprocess + if not task.assignee: + raise ValueError(f"task {task.id} has no assignee") + + prompt = f"work kanban task {task.id}" + env = dict(os.environ) + if task.tenant: + env["HERMES_TENANT"] = task.tenant + env["HERMES_KANBAN_TASK"] = task.id + env["HERMES_KANBAN_WORKSPACE"] = workspace + + cmd = [ + "hermes", + "-p", task.assignee, + "chat", + "-q", prompt, + ] + # Use Popen with DEVNULL stdin so the child doesn't inherit our tty. + # Redirect output to a per-task log under HERMES_HOME/kanban/logs/. + from hermes_constants import get_hermes_home + log_dir = get_hermes_home() / "kanban" / "logs" + log_dir.mkdir(parents=True, exist_ok=True) + log_path = log_dir / f"{task.id}.log" + + # Use 'a' so a re-run on unblock appends rather than overwrites. + log_f = open(log_path, "ab") + try: + subprocess.Popen( # noqa: S603 -- argv is a fixed list built above + cmd, + cwd=workspace if os.path.isdir(workspace) else None, + stdin=subprocess.DEVNULL, + stdout=log_f, + stderr=subprocess.STDOUT, + env=env, + start_new_session=True, + ) + except FileNotFoundError: + log_f.close() + raise RuntimeError( + "`hermes` executable not found on PATH. " + "Install Hermes Agent or activate its venv before running the kanban dispatcher." + ) + # NOTE: we intentionally do NOT close log_f here — we want Popen's + # child process to keep writing after this function returns. The + # handle is kept alive by the child's inheritance. The parent's + # reference goes out of scope and is GC'd, but the OS-level FD stays + # open in the child until the child exits. + + +# --------------------------------------------------------------------------- +# Worker context builder (what a spawned worker sees) +# --------------------------------------------------------------------------- + +def build_worker_context(conn: sqlite3.Connection, task_id: str) -> str: + """Return the full text a worker should read to understand its task. + + Order (per design spec §8): + 1. Task title (mandatory). + 2. Task body (optional opening post). + 3. Every comment on the task, chronologically, with authors. + 4. Completion results of every done parent task. + """ + task = get_task(conn, task_id) + if not task: + raise ValueError(f"unknown task {task_id}") + + lines: list[str] = [] + lines.append(f"# Kanban task {task.id}: {task.title}") + lines.append("") + lines.append(f"Assignee: {task.assignee or '(unassigned)'}") + lines.append(f"Status: {task.status}") + if task.tenant: + lines.append(f"Tenant: {task.tenant}") + lines.append(f"Workspace: {task.workspace_kind} @ {task.workspace_path or '(unresolved)'}") + lines.append("") + + if task.body and task.body.strip(): + lines.append("## Body") + lines.append(task.body.strip()) + lines.append("") + + parents = parent_results(conn, task_id) + if parents: + lines.append("## Parent task results") + for pid, result in parents: + lines.append(f"### {pid}") + lines.append((result or "(no result recorded)").strip()) + lines.append("") + + comments = list_comments(conn, task_id) + if comments: + lines.append("## Comment thread") + for c in comments: + ts = time.strftime("%Y-%m-%d %H:%M", time.localtime(c.created_at)) + lines.append(f"**{c.author}** ({ts}):") + lines.append(c.body.strip()) + lines.append("") + + return "\n".join(lines).rstrip() + "\n" diff --git a/hermes_cli/main.py b/hermes_cli/main.py index a53b8d2c5eb74..19623434d9f3c 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -4780,6 +4780,13 @@ def cmd_webhook(args): webhook_command(args) +def cmd_kanban(args): + """Multi-profile collaboration board.""" + from hermes_cli.kanban import kanban_command + + return kanban_command(args) + + def cmd_hooks(args): """Shell-hook inspection and management.""" from hermes_cli.hooks import hooks_command @@ -8116,6 +8123,13 @@ def main(): webhook_parser.set_defaults(func=cmd_webhook) + # ========================================================================= + # kanban command — multi-profile collaboration board + # ========================================================================= + from hermes_cli.kanban import build_parser as _build_kanban_parser + kanban_parser = _build_kanban_parser(subparsers) + kanban_parser.set_defaults(func=cmd_kanban) + # ========================================================================= # hooks command — shell-hook inspection and management # ========================================================================= diff --git a/plugins/kanban/dashboard/dist/index.js b/plugins/kanban/dashboard/dist/index.js new file mode 100644 index 0000000000000..75ab34653dbaf --- /dev/null +++ b/plugins/kanban/dashboard/dist/index.js @@ -0,0 +1,1422 @@ +/** + * Hermes Kanban — Dashboard Plugin + * + * Board view for the multi-agent collaboration board backed by + * ~/.hermes/kanban.db. Calls the plugin's backend at /api/plugins/kanban/ + * and tails task_events over a WebSocket for live updates. + * + * Plain IIFE, no build step. Uses window.__HERMES_PLUGIN_SDK__ for React + + * shadcn primitives; HTML5 drag-and-drop for card movement on desktop and + * a pointer-based fallback for touch. + */ +(function () { + "use strict"; + + const SDK = window.__HERMES_PLUGIN_SDK__; + if (!SDK) return; + + const { React } = SDK; + const h = React.createElement; + const { + Card, CardContent, + Badge, Button, Input, Label, Select, SelectOption, + } = SDK.components; + const { useState, useEffect, useCallback, useMemo, useRef } = SDK.hooks; + const { cn, timeAgo } = SDK.utils; + + // Order matches BOARD_COLUMNS in plugin_api.py. + const COLUMN_ORDER = ["triage", "todo", "ready", "running", "blocked", "done"]; + const COLUMN_LABEL = { + triage: "Triage", + todo: "Todo", + ready: "Ready", + running: "In Progress", + blocked: "Blocked", + done: "Done", + archived: "Archived", + }; + const COLUMN_HELP = { + triage: "Raw ideas — a specifier will flesh out the spec", + todo: "Waiting on dependencies or unassigned", + ready: "Assigned and waiting for a dispatcher tick", + running: "Claimed by a worker — in-flight", + blocked: "Worker asked for human input", + done: "Completed", + archived: "Archived", + }; + const COLUMN_DOT = { + triage: "hermes-kanban-dot-triage", + todo: "hermes-kanban-dot-todo", + ready: "hermes-kanban-dot-ready", + running: "hermes-kanban-dot-running", + blocked: "hermes-kanban-dot-blocked", + done: "hermes-kanban-dot-done", + archived: "hermes-kanban-dot-archived", + }; + + const DESTRUCTIVE_TRANSITIONS = { + done: "Mark this task as done? The worker's claim is released and dependent children become ready.", + archived: "Archive this task? It disappears from the default board view.", + blocked: "Mark this task as blocked? The worker's claim is released.", + }; + + const API = "/api/plugins/kanban"; + const MIME_TASK = "text/x-hermes-task"; + + // ------------------------------------------------------------------------- + // Minimal safe markdown renderer. + // + // Recognises a small subset (headings, bold, italic, inline code, fenced + // code, links, bullet lists, paragraphs). HTML escaping first, then + // inline replacements against the escaped string — no raw HTML from the + // user is ever executed. + // ------------------------------------------------------------------------- + + function escapeHtml(s) { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + function renderInline(esc) { + // Fenced code has already been extracted before this runs; process + // inline replacements on the escaped string. + return esc + // inline code + .replace(/`([^`\n]+)`/g, (_m, c) => `${c}`) + // bold + .replace(/\*\*([^*\n]+)\*\*/g, "$1") + // italic + .replace(/(^|[^*])\*([^*\n]+)\*/g, "$1$2") + // safe links — only http(s) and mailto + .replace( + /\[([^\]\n]+)\]\((https?:\/\/[^\s)]+|mailto:[^\s)]+)\)/g, + (_m, text, href) => + `${text}`, + ); + } + function renderMarkdown(src) { + if (!src) return ""; + // Split out fenced code blocks first so their contents aren't mangled. + const blocks = []; + let working = String(src).replace(/```([\s\S]*?)```/g, (_m, code) => { + blocks.push(code); + return `\u0000CODE${blocks.length - 1}\u0000`; + }); + const escaped = escapeHtml(working); + const lines = escaped.split(/\r?\n/); + const out = []; + let inList = false; + for (const raw of lines) { + const line = raw; + const bullet = /^\s*[-*]\s+(.*)$/.exec(line); + const heading = /^(#{1,4})\s+(.*)$/.exec(line); + if (bullet) { + if (!inList) { out.push("
    "); inList = true; } + out.push(`
  • ${renderInline(bullet[1])}
  • `); + continue; + } + if (inList) { out.push("
"); inList = false; } + if (heading) { + const level = heading[1].length; + out.push(`${renderInline(heading[2])}`); + } else if (line.trim() === "") { + out.push(""); + } else { + out.push(`

${renderInline(line)}

`); + } + } + if (inList) out.push(""); + let html = out.join("\n"); + // Re-insert fenced code blocks. + html = html.replace(/\u0000CODE(\d+)\u0000/g, (_m, i) => + `
${escapeHtml(blocks[Number(i)])}
`, + ); + return html; + } + + function MarkdownBlock(props) { + const enabled = props.enabled !== false; + if (!enabled) { + return h("pre", { className: "hermes-kanban-pre" }, props.source || ""); + } + return h("div", { + className: "hermes-kanban-md", + dangerouslySetInnerHTML: { __html: renderMarkdown(props.source || "") }, + }); + } + + // ------------------------------------------------------------------------- + // Touch drag-drop helper. + // + // HTML5 DnD is desktop-only. On touch devices we attach a pointerdown + // handler that simulates a drag proxy and fires a custom event on the + // column under the finger when released. Columns listen for both the + // standard `drop` event and our `hermes-kanban:drop` event. + // ------------------------------------------------------------------------- + + function attachTouchDrag(el, taskId) { + if (!el) return; + function onDown(e) { + if (e.pointerType !== "touch") return; + e.preventDefault(); + const proxy = el.cloneNode(true); + proxy.classList.add("hermes-kanban-touch-proxy"); + document.body.appendChild(proxy); + let lastTarget = null; + + function move(ev) { + proxy.style.left = `${ev.clientX - proxy.offsetWidth / 2}px`; + proxy.style.top = `${ev.clientY - 24}px`; + proxy.style.display = "none"; + const under = document.elementFromPoint(ev.clientX, ev.clientY); + proxy.style.display = ""; + const col = under && under.closest && under.closest("[data-kanban-column]"); + if (col !== lastTarget) { + if (lastTarget) lastTarget.classList.remove("hermes-kanban-column--drop"); + if (col) col.classList.add("hermes-kanban-column--drop"); + lastTarget = col; + } + } + function up() { + document.removeEventListener("pointermove", move); + document.removeEventListener("pointerup", up); + document.removeEventListener("pointercancel", up); + if (lastTarget) { + lastTarget.classList.remove("hermes-kanban-column--drop"); + const status = lastTarget.getAttribute("data-kanban-column"); + lastTarget.dispatchEvent(new CustomEvent("hermes-kanban:drop", { + detail: { taskId, status }, + bubbles: true, + })); + } + proxy.remove(); + } + // Kick off proxy at the pointer origin. + proxy.style.position = "fixed"; + proxy.style.pointerEvents = "none"; + proxy.style.opacity = "0.85"; + proxy.style.zIndex = "9999"; + proxy.style.width = `${el.offsetWidth}px`; + proxy.style.left = `${e.clientX - el.offsetWidth / 2}px`; + proxy.style.top = `${e.clientY - 24}px`; + document.addEventListener("pointermove", move); + document.addEventListener("pointerup", up); + document.addEventListener("pointercancel", up); + } + el.addEventListener("pointerdown", onDown); + return function () { el.removeEventListener("pointerdown", onDown); }; + } + + // ------------------------------------------------------------------------- + // Error boundary + // ------------------------------------------------------------------------- + + class ErrorBoundary extends React.Component { + constructor(props) { super(props); this.state = { error: null }; } + static getDerivedStateFromError(error) { return { error }; } + componentDidCatch(error, info) { + // eslint-disable-next-line no-console + console.error("Kanban plugin crashed:", error, info); + } + render() { + if (this.state.error) { + return h(Card, null, + h(CardContent, { className: "p-6 text-sm" }, + h("div", { className: "text-destructive font-semibold mb-1" }, + "Kanban tab hit a rendering error"), + h("div", { className: "text-muted-foreground text-xs mb-3" }, + String(this.state.error && this.state.error.message || this.state.error)), + h(Button, { + onClick: () => this.setState({ error: null }), + className: "h-7 px-3 text-xs border border-border hover:bg-foreground/10 cursor-pointer", + }, "Reload view"), + ), + ); + } + return this.props.children; + } + } + + // ------------------------------------------------------------------------- + // Root page + // ------------------------------------------------------------------------- + + function KanbanPage() { + const [board, setBoard] = useState(null); + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [tenantFilter, setTenantFilter] = useState(""); + const [assigneeFilter, setAssigneeFilter] = useState(""); + const [includeArchived, setIncludeArchived] = useState(false); + const [search, setSearch] = useState(""); + const [laneByProfile, setLaneByProfile] = useState(true); + const [configApplied, setConfigApplied] = useState(false); + + const [selectedTaskId, setSelectedTaskId] = useState(null); + const [selectedIds, setSelectedIds] = useState(() => new Set()); + + const cursorRef = useRef(0); + const reloadTimerRef = useRef(null); + const wsRef = useRef(null); + const wsBackoffRef = useRef(1000); + const wsClosedRef = useRef(false); + + // --- load config once --------------------------------------------------- + useEffect(function () { + SDK.fetchJSON(`${API}/config`) + .then(function (c) { + setConfig(c); + if (!configApplied) { + if (c.default_tenant) setTenantFilter(c.default_tenant); + if (typeof c.lane_by_profile === "boolean") setLaneByProfile(c.lane_by_profile); + if (typeof c.include_archived_by_default === "boolean") setIncludeArchived(c.include_archived_by_default); + setConfigApplied(true); + } + }) + .catch(function () { setConfig({ render_markdown: true }); }); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // --- fetch full board --------------------------------------------------- + const loadBoard = useCallback(() => { + const qs = new URLSearchParams(); + if (tenantFilter) qs.set("tenant", tenantFilter); + if (includeArchived) qs.set("include_archived", "true"); + const url = qs.toString() ? `${API}/board?${qs}` : `${API}/board`; + return SDK.fetchJSON(url) + .then(function (data) { + setBoard(data); + cursorRef.current = data.latest_event_id || 0; + setError(null); + }) + .catch(function (err) { + setError(String(err && err.message ? err.message : err)); + }) + .finally(function () { setLoading(false); }); + }, [tenantFilter, includeArchived]); + + const scheduleReload = useCallback(function () { + if (reloadTimerRef.current) return; + reloadTimerRef.current = setTimeout(function () { + reloadTimerRef.current = null; + loadBoard(); + }, 250); + }, [loadBoard]); + + useEffect(function () { + loadBoard(); + return function () { + if (reloadTimerRef.current) { + clearTimeout(reloadTimerRef.current); + reloadTimerRef.current = null; + } + }; + }, [loadBoard]); + + // --- WebSocket --------------------------------------------------------- + useEffect(function () { + if (!board) return undefined; + wsClosedRef.current = false; + function openWs() { + if (wsClosedRef.current) return; + const token = window.__HERMES_SESSION_TOKEN__ || ""; + const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; + const qs = new URLSearchParams({ + since: String(cursorRef.current || 0), + token: token, + }); + const url = `${proto}//${window.location.host}${API}/events?${qs}`; + let ws; + try { ws = new WebSocket(url); } catch (_e) { return; } + wsRef.current = ws; + ws.onopen = function () { wsBackoffRef.current = 1000; }; + ws.onmessage = function (ev) { + try { + const msg = JSON.parse(ev.data); + if (msg && Array.isArray(msg.events) && msg.events.length > 0) { + cursorRef.current = msg.cursor || cursorRef.current; + scheduleReload(); + } + } catch (_e) { /* ignore */ } + }; + ws.onclose = function (ev) { + if (wsClosedRef.current) return; + if (ev && ev.code === 1008) { + setError("WebSocket auth failed — reload the page to refresh the session token."); + return; + } + const delay = Math.min(wsBackoffRef.current, 30000); + wsBackoffRef.current = Math.min(wsBackoffRef.current * 2, 30000); + setTimeout(openWs, delay); + }; + } + openWs(); + return function () { + wsClosedRef.current = true; + try { wsRef.current && wsRef.current.close(); } catch (_e) { /* noop */ } + }; + }, [!!board, scheduleReload]); + + // --- filtering ---------------------------------------------------------- + const filteredBoard = useMemo(function () { + if (!board) return null; + const q = search.trim().toLowerCase(); + const filterTask = function (t) { + if (assigneeFilter && t.assignee !== assigneeFilter) return false; + if (q) { + const hay = `${t.id} ${t.title || ""} ${t.assignee || ""} ${t.tenant || ""}`.toLowerCase(); + if (hay.indexOf(q) === -1) return false; + } + return true; + }; + return Object.assign({}, board, { + columns: board.columns.map(function (col) { + return Object.assign({}, col, { tasks: col.tasks.filter(filterTask) }); + }), + }); + }, [board, assigneeFilter, search]); + + // --- actions ------------------------------------------------------------ + const moveTask = useCallback(function (taskId, newStatus) { + const confirmMsg = DESTRUCTIVE_TRANSITIONS[newStatus]; + if (confirmMsg && !window.confirm(confirmMsg)) return; + setBoard(function (b) { + if (!b) return b; + let moved = null; + const columns = b.columns.map(function (col) { + const next = col.tasks.filter(function (t) { + if (t.id === taskId) { moved = Object.assign({}, t, { status: newStatus }); return false; } + return true; + }); + return Object.assign({}, col, { tasks: next }); + }); + if (moved) { + const dest = columns.find(function (c) { return c.name === newStatus; }); + if (dest) dest.tasks = [moved].concat(dest.tasks); + } + return Object.assign({}, b, { columns }); + }); + SDK.fetchJSON(`${API}/tasks/${encodeURIComponent(taskId)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: newStatus }), + }).catch(function (err) { + setError(`Move failed: ${err.message || err}`); + loadBoard(); + }); + }, [loadBoard]); + + const createTask = useCallback(function (body) { + return SDK.fetchJSON(`${API}/tasks`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }).then(loadBoard); + }, [loadBoard]); + + const toggleSelected = useCallback(function (id, additive) { + setSelectedIds(function (prev) { + const next = new Set(additive ? prev : []); + if (prev.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + const clearSelected = useCallback(function () { setSelectedIds(new Set()); }, []); + + const applyBulk = useCallback(function (patch, confirmMsg) { + if (selectedIds.size === 0) return; + if (confirmMsg && !window.confirm(confirmMsg)) return; + const body = Object.assign({ ids: Array.from(selectedIds) }, patch); + SDK.fetchJSON(`${API}/tasks/bulk`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + .then(function (res) { + const failed = (res.results || []).filter(function (r) { return !r.ok; }); + if (failed.length > 0) { + setError(`Bulk: ${failed.length} of ${res.results.length} failed: ` + + failed.slice(0, 3).map(function (f) { return `${f.id} (${f.error})`; }).join("; ")); + } + clearSelected(); + loadBoard(); + }) + .catch(function (e) { setError(String(e.message || e)); }); + }, [selectedIds, loadBoard, clearSelected]); + + // --- render ------------------------------------------------------------- + if (loading && !board) { + return h("div", { className: "p-8 text-sm text-muted-foreground" }, + "Loading Kanban board…"); + } + if (error && !board) { + return h(Card, null, + h(CardContent, { className: "p-6" }, + h("div", { className: "text-sm text-destructive" }, + "Failed to load Kanban board: ", error), + h("div", { className: "text-xs text-muted-foreground mt-2" }, + "The backend auto-creates kanban.db on first read. If this persists, check the dashboard logs."), + ), + ); + } + if (!filteredBoard) return null; + + const renderMd = !config || config.render_markdown !== false; + + return h(ErrorBoundary, null, + h("div", { className: "hermes-kanban flex flex-col gap-4" }, + h(BoardToolbar, { + board: board, + tenantFilter, setTenantFilter, + assigneeFilter, setAssigneeFilter, + includeArchived, setIncludeArchived, + laneByProfile, setLaneByProfile, + search, setSearch, + onNudgeDispatch: function () { + SDK.fetchJSON(`${API}/dispatch?max=8`, { method: "POST" }) + .then(loadBoard) + .catch(function (e) { setError(String(e.message || e)); }); + }, + onRefresh: loadBoard, + }), + selectedIds.size > 0 ? h(BulkActionBar, { + count: selectedIds.size, + assignees: (board && board.assignees) || [], + onApply: applyBulk, + onClear: clearSelected, + }) : null, + error ? h("div", { className: "text-xs text-destructive px-2" }, error) : null, + h(BoardColumns, { + board: filteredBoard, + laneByProfile, + selectedIds, + toggleSelected, + onMove: moveTask, + onOpen: setSelectedTaskId, + onCreate: createTask, + allTasks: board.columns.reduce(function (acc, c) { return acc.concat(c.tasks); }, []), + }), + selectedTaskId ? h(TaskDrawer, { + taskId: selectedTaskId, + onClose: function () { setSelectedTaskId(null); }, + onRefresh: loadBoard, + renderMarkdown: renderMd, + allTasks: board.columns.reduce(function (acc, c) { return acc.concat(c.tasks); }, []), + }) : null, + ), + ); + } + + // ------------------------------------------------------------------------- + // Toolbar + // ------------------------------------------------------------------------- + + function BoardToolbar(props) { + const tenants = (props.board && props.board.tenants) || []; + const assignees = (props.board && props.board.assignees) || []; + return h("div", { className: "flex flex-wrap items-end gap-3" }, + h("div", { className: "flex flex-col gap-1" }, + h(Label, { className: "text-xs text-muted-foreground" }, "Search"), + h(Input, { + placeholder: "Filter cards…", + value: props.search, + onChange: function (e) { props.setSearch(e.target.value); }, + className: "w-56 h-8", + }), + ), + h("div", { className: "flex flex-col gap-1" }, + h(Label, { className: "text-xs text-muted-foreground" }, "Tenant"), + h(Select, { + value: props.tenantFilter, + onChange: function (e) { props.setTenantFilter(e.target.value); }, + className: "h-8", + }, + h(SelectOption, { value: "" }, "All tenants"), + tenants.map(function (t) { + return h(SelectOption, { key: t, value: t }, t); + }), + ), + ), + h("div", { className: "flex flex-col gap-1" }, + h(Label, { className: "text-xs text-muted-foreground" }, "Assignee"), + h(Select, { + value: props.assigneeFilter, + onChange: function (e) { props.setAssigneeFilter(e.target.value); }, + className: "h-8", + }, + h(SelectOption, { value: "" }, "All profiles"), + assignees.map(function (a) { + return h(SelectOption, { key: a, value: a }, a); + }), + ), + ), + h("label", { className: "flex items-center gap-2 text-xs" }, + h("input", { + type: "checkbox", + checked: props.includeArchived, + onChange: function (e) { props.setIncludeArchived(e.target.checked); }, + }), + "Show archived", + ), + h("label", { className: "flex items-center gap-2 text-xs", + title: "Group the Running column by assigned profile" }, + h("input", { + type: "checkbox", + checked: props.laneByProfile, + onChange: function (e) { props.setLaneByProfile(e.target.checked); }, + }), + "Lanes by profile", + ), + h("div", { className: "flex-1" }), + h(Button, { + onClick: props.onNudgeDispatch, + className: "h-8 px-3 text-xs border border-border hover:bg-foreground/10 cursor-pointer", + }, "Nudge dispatcher"), + h(Button, { + onClick: props.onRefresh, + className: "h-8 px-3 text-xs border border-border hover:bg-foreground/10 cursor-pointer", + }, "Refresh"), + ); + } + + // ------------------------------------------------------------------------- + // Bulk action bar (appears when >= 1 card is selected) + // ------------------------------------------------------------------------- + + function BulkActionBar(props) { + const [assignee, setAssignee] = useState(""); + return h("div", { className: "hermes-kanban-bulk" }, + h("span", { className: "hermes-kanban-bulk-count" }, + `${props.count} selected`), + h(Button, { + onClick: function () { props.onApply({ status: "ready" }); }, + className: "hermes-kanban-bulk-btn", + }, "→ ready"), + h(Button, { + onClick: function () { + props.onApply({ status: "done" }, + `Mark ${props.count} task(s) as done?`); + }, + className: "hermes-kanban-bulk-btn", + }, "Complete"), + h(Button, { + onClick: function () { + props.onApply({ archive: true }, + `Archive ${props.count} task(s)?`); + }, + className: "hermes-kanban-bulk-btn", + }, "Archive"), + h("div", { className: "hermes-kanban-bulk-reassign" }, + h(Select, { + value: assignee, + onChange: function (e) { setAssignee(e.target.value); }, + className: "h-7 text-xs", + }, + h(SelectOption, { value: "" }, "— reassign —"), + h(SelectOption, { value: "__none__" }, "(unassign)"), + props.assignees.map(function (a) { + return h(SelectOption, { key: a, value: a }, a); + }), + ), + h(Button, { + onClick: function () { + if (!assignee) return; + props.onApply({ assignee: assignee === "__none__" ? "" : assignee }); + setAssignee(""); + }, + disabled: !assignee, + className: cn("hermes-kanban-bulk-btn", + !assignee ? "opacity-40 cursor-not-allowed" : ""), + }, "Apply"), + ), + h("div", { className: "flex-1" }), + h(Button, { + onClick: props.onClear, + className: "hermes-kanban-bulk-btn", + }, "Clear"), + ); + } + + // ------------------------------------------------------------------------- + // Columns + // ------------------------------------------------------------------------- + + function BoardColumns(props) { + return h("div", { className: "hermes-kanban-columns" }, + props.board.columns.map(function (col) { + return h(Column, { + key: col.name, + column: col, + laneByProfile: props.laneByProfile, + selectedIds: props.selectedIds, + toggleSelected: props.toggleSelected, + onMove: props.onMove, + onOpen: props.onOpen, + onCreate: props.onCreate, + allTasks: props.allTasks, + }); + }), + ); + } + + function Column(props) { + const [dragOver, setDragOver] = useState(false); + const [showCreate, setShowCreate] = useState(false); + const colRef = useRef(null); + + // Listen for our synthetic touch-drop events from attachTouchDrag(). + useEffect(function () { + if (!colRef.current) return undefined; + const el = colRef.current; + function onTouchDrop(e) { + if (e.detail && e.detail.status === props.column.name) { + props.onMove(e.detail.taskId, props.column.name); + } + } + el.addEventListener("hermes-kanban:drop", onTouchDrop); + return function () { el.removeEventListener("hermes-kanban:drop", onTouchDrop); }; + }, [props.column.name, props.onMove]); + + const handleDragOver = function (e) { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + if (!dragOver) setDragOver(true); + }; + const handleDragLeave = function () { setDragOver(false); }; + const handleDrop = function (e) { + e.preventDefault(); + setDragOver(false); + const taskId = e.dataTransfer.getData(MIME_TASK); + if (taskId) props.onMove(taskId, props.column.name); + }; + + const lanes = useMemo(function () { + if (!props.laneByProfile || props.column.name !== "running") return null; + const byProfile = {}; + for (const t of props.column.tasks) { + const key = t.assignee || "(unassigned)"; + (byProfile[key] = byProfile[key] || []).push(t); + } + return Object.keys(byProfile).sort().map(function (k) { + return { assignee: k, tasks: byProfile[k] }; + }); + }, [props.column, props.laneByProfile]); + + return h("div", { + ref: colRef, + "data-kanban-column": props.column.name, + className: cn( + "hermes-kanban-column", + dragOver ? "hermes-kanban-column--drop" : "", + ), + onDragOver: handleDragOver, + onDragLeave: handleDragLeave, + onDrop: handleDrop, + }, + h("div", { className: "hermes-kanban-column-header" }, + h("span", { className: cn("hermes-kanban-dot", COLUMN_DOT[props.column.name]) }), + h("span", { className: "hermes-kanban-column-label" }, + COLUMN_LABEL[props.column.name] || props.column.name), + h("span", { className: "hermes-kanban-column-count" }, + props.column.tasks.length), + h("button", { + type: "button", + className: "hermes-kanban-column-add", + title: "Create task in this column", + onClick: function () { setShowCreate(function (v) { return !v; }); }, + }, showCreate ? "×" : "+"), + ), + h("div", { className: "hermes-kanban-column-sub" }, + COLUMN_HELP[props.column.name] || ""), + showCreate ? h(InlineCreate, { + columnName: props.column.name, + allTasks: props.allTasks, + onSubmit: function (body) { + props.onCreate(body).then(function () { setShowCreate(false); }); + }, + onCancel: function () { setShowCreate(false); }, + }) : null, + h("div", { className: "hermes-kanban-column-body" }, + props.column.tasks.length === 0 + ? h("div", { className: "hermes-kanban-empty" }, "— no tasks —") + : lanes + ? lanes.map(function (lane) { + return h("div", { key: lane.assignee, className: "hermes-kanban-lane" }, + h("div", { className: "hermes-kanban-lane-head" }, + h("span", { className: "hermes-kanban-lane-name" }, lane.assignee), + h("span", { className: "hermes-kanban-lane-count" }, lane.tasks.length), + ), + lane.tasks.map(function (t) { + return h(TaskCard, { + key: t.id, task: t, + selected: props.selectedIds.has(t.id), + toggleSelected: props.toggleSelected, + onOpen: props.onOpen, + }); + }), + ); + }) + : props.column.tasks.map(function (t) { + return h(TaskCard, { + key: t.id, task: t, + selected: props.selectedIds.has(t.id), + toggleSelected: props.toggleSelected, + onOpen: props.onOpen, + }); + }), + ), + ); + } + + // ------------------------------------------------------------------------- + // Card + // ------------------------------------------------------------------------- + + function TaskCard(props) { + const t = props.task; + const cardRef = useRef(null); + + useEffect(function () { + return attachTouchDrag(cardRef.current, t.id); + }, [t.id]); + + const handleDragStart = function (e) { + e.dataTransfer.setData(MIME_TASK, t.id); + e.dataTransfer.effectAllowed = "move"; + }; + const handleClick = function (e) { + // Shift-click or ctrl/cmd-click toggles selection instead of opening. + if (e.shiftKey || e.ctrlKey || e.metaKey) { + e.preventDefault(); + e.stopPropagation(); + props.toggleSelected(t.id, e.ctrlKey || e.metaKey); + return; + } + props.onOpen(t.id); + }; + const handleCheckbox = function (e) { + e.stopPropagation(); + props.toggleSelected(t.id, true); + }; + + const progress = t.progress; + + return h("div", { + ref: cardRef, + className: cn( + "hermes-kanban-card", + props.selected ? "hermes-kanban-card--selected" : "", + ), + draggable: true, + onDragStart: handleDragStart, + onClick: handleClick, + }, + h(Card, null, + h(CardContent, { className: "hermes-kanban-card-content" }, + h("div", { className: "hermes-kanban-card-row" }, + h("input", { + type: "checkbox", + className: "hermes-kanban-card-check", + checked: props.selected, + onChange: handleCheckbox, + onClick: function (e) { e.stopPropagation(); }, + title: "Select for bulk actions", + }), + h("span", { className: "hermes-kanban-card-id" }, t.id), + t.priority > 0 + ? h(Badge, { className: "hermes-kanban-priority" }, `P${t.priority}`) + : null, + t.tenant + ? h(Badge, { variant: "outline", className: "hermes-kanban-tag" }, t.tenant) + : null, + progress + ? h("span", { + className: cn( + "hermes-kanban-progress", + progress.done === progress.total ? "hermes-kanban-progress--full" : "", + ), + title: `${progress.done} of ${progress.total} child tasks done`, + }, `${progress.done}/${progress.total}`) + : null, + ), + h("div", { className: "hermes-kanban-card-title" }, t.title || "(untitled)"), + h("div", { className: "hermes-kanban-card-row hermes-kanban-card-meta" }, + t.assignee + ? h("span", { className: "hermes-kanban-assignee" }, "@", t.assignee) + : h("span", { className: "hermes-kanban-unassigned" }, "unassigned"), + t.comment_count > 0 + ? h("span", { className: "hermes-kanban-count" }, "💬 ", t.comment_count) + : null, + t.link_counts && (t.link_counts.parents + t.link_counts.children) > 0 + ? h("span", { className: "hermes-kanban-count" }, + "↔ ", t.link_counts.parents + t.link_counts.children) + : null, + h("span", { className: "hermes-kanban-ago" }, + timeAgo ? timeAgo(t.created_at) : ""), + ), + ), + ), + ); + } + + // ------------------------------------------------------------------------- + // Inline create (with parent selector) + // ------------------------------------------------------------------------- + + function InlineCreate(props) { + const [title, setTitle] = useState(""); + const [assignee, setAssignee] = useState(""); + const [priority, setPriority] = useState(0); + const [parent, setParent] = useState(""); + + const submit = function () { + const trimmed = title.trim(); + if (!trimmed) return; + const body = { + title: trimmed, + assignee: assignee.trim() || null, + priority: Number(priority) || 0, + triage: props.columnName === "triage", + }; + if (parent) body.parents = [parent]; + props.onSubmit(body); + setTitle(""); setAssignee(""); setPriority(0); setParent(""); + }; + + return h("div", { className: "hermes-kanban-inline-create" }, + h(Input, { + value: title, + onChange: function (e) { setTitle(e.target.value); }, + onKeyDown: function (e) { + if (e.key === "Enter") { e.preventDefault(); submit(); } + if (e.key === "Escape") props.onCancel(); + }, + placeholder: props.columnName === "triage" + ? "Rough idea — AI will spec it…" + : "New task title…", + autoFocus: true, + className: "h-8 text-sm", + }), + h("div", { className: "flex gap-2" }, + h(Input, { + value: assignee, + onChange: function (e) { setAssignee(e.target.value); }, + placeholder: props.columnName === "triage" ? "specifier" : "assignee", + className: "h-7 text-xs flex-1", + }), + h(Input, { + type: "number", + value: priority, + onChange: function (e) { setPriority(e.target.value); }, + placeholder: "pri", + className: "h-7 text-xs w-16", + }), + ), + h(Select, { + value: parent, + onChange: function (e) { setParent(e.target.value); }, + className: "h-7 text-xs", + }, + h(SelectOption, { value: "" }, "— no parent —"), + (props.allTasks || []).map(function (t) { + return h(SelectOption, { key: t.id, value: t.id }, + `${t.id} — ${(t.title || "").slice(0, 50)}`); + }), + ), + h("div", { className: "flex gap-2" }, + h(Button, { + onClick: submit, + className: "h-7 px-2 text-xs border border-border hover:bg-foreground/10 cursor-pointer flex-1", + }, "Create"), + h(Button, { + onClick: props.onCancel, + className: "h-7 px-2 text-xs border border-border hover:bg-foreground/10 cursor-pointer", + }, "Cancel"), + ), + ); + } + + // ------------------------------------------------------------------------- + // Task drawer + // ------------------------------------------------------------------------- + + function TaskDrawer(props) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(null); + const [newComment, setNewComment] = useState(""); + const [editing, setEditing] = useState(false); + + const load = useCallback(function () { + return SDK.fetchJSON(`${API}/tasks/${encodeURIComponent(props.taskId)}`) + .then(function (d) { setData(d); setErr(null); }) + .catch(function (e) { setErr(String(e.message || e)); }) + .finally(function () { setLoading(false); }); + }, [props.taskId]); + + useEffect(function () { load(); }, [load]); + useEffect(function () { + function onKey(e) { if (e.key === "Escape" && !editing) props.onClose(); } + window.addEventListener("keydown", onKey); + return function () { window.removeEventListener("keydown", onKey); }; + }, [props.onClose, editing]); + + const handleComment = function () { + const body = newComment.trim(); + if (!body) return; + SDK.fetchJSON(`${API}/tasks/${encodeURIComponent(props.taskId)}/comments`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ body }), + }).then(function () { + setNewComment(""); + load(); + props.onRefresh(); + }).catch(function (e) { setErr(String(e.message || e)); }); + }; + + const doPatch = function (patch, opts) { + if (opts && opts.confirm && !window.confirm(opts.confirm)) { + return Promise.resolve(); + } + return SDK.fetchJSON(`${API}/tasks/${encodeURIComponent(props.taskId)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }).then(function () { load(); props.onRefresh(); }); + }; + + const addLink = function (parentId) { + return SDK.fetchJSON(`${API}/links`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ parent_id: parentId, child_id: props.taskId }), + }).then(function () { load(); props.onRefresh(); }) + .catch(function (e) { setErr(String(e.message || e)); }); + }; + const removeLink = function (parentId) { + const qs = new URLSearchParams({ parent_id: parentId, child_id: props.taskId }); + return SDK.fetchJSON(`${API}/links?${qs}`, { method: "DELETE" }) + .then(function () { load(); props.onRefresh(); }) + .catch(function (e) { setErr(String(e.message || e)); }); + }; + const addChild = function (childId) { + return SDK.fetchJSON(`${API}/links`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ parent_id: props.taskId, child_id: childId }), + }).then(function () { load(); props.onRefresh(); }) + .catch(function (e) { setErr(String(e.message || e)); }); + }; + const removeChild = function (childId) { + const qs = new URLSearchParams({ parent_id: props.taskId, child_id: childId }); + return SDK.fetchJSON(`${API}/links?${qs}`, { method: "DELETE" }) + .then(function () { load(); props.onRefresh(); }) + .catch(function (e) { setErr(String(e.message || e)); }); + }; + + return h("div", { className: "hermes-kanban-drawer-shade", onClick: props.onClose }, + h("div", { + className: "hermes-kanban-drawer", + onClick: function (e) { e.stopPropagation(); }, + }, + h("div", { className: "hermes-kanban-drawer-head" }, + h("span", { className: "text-xs text-muted-foreground" }, props.taskId), + h("button", { + type: "button", + onClick: props.onClose, + className: "hermes-kanban-drawer-close", + title: "Close (Esc)", + }, "×"), + ), + loading ? h("div", { className: "p-4 text-sm text-muted-foreground" }, "Loading…") : + err ? h("div", { className: "p-4 text-sm text-destructive" }, err) : + data ? h(TaskDetail, { + data, editing, setEditing, + renderMarkdown: props.renderMarkdown, + allTasks: props.allTasks, + onPatch: doPatch, + onAddParent: addLink, + onRemoveParent: removeLink, + onAddChild: addChild, + onRemoveChild: removeChild, + }) : null, + data ? h("div", { className: "hermes-kanban-drawer-comment-row" }, + h(Input, { + value: newComment, + onChange: function (e) { setNewComment(e.target.value); }, + onKeyDown: function (e) { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); handleComment(); + } + }, + placeholder: "Add a comment… (Enter to submit)", + className: "h-8 text-sm flex-1", + }), + h(Button, { + onClick: handleComment, + className: "h-8 px-3 text-xs border border-border hover:bg-foreground/10 cursor-pointer", + }, "Comment"), + ) : null, + ), + ); + } + + function TaskDetail(props) { + const t = props.data.task; + const comments = props.data.comments || []; + const events = props.data.events || []; + const links = props.data.links || { parents: [], children: [] }; + + return h("div", { className: "hermes-kanban-drawer-body" }, + h("div", { className: "hermes-kanban-drawer-title" }, + h("span", { className: cn("hermes-kanban-dot", COLUMN_DOT[t.status]) }), + props.editing + ? h(TitleEditor, { + initial: t.title || "", + onSave: function (newTitle) { + return props.onPatch({ title: newTitle }).then(function () { props.setEditing(false); }); + }, + onCancel: function () { props.setEditing(false); }, + }) + : h("span", { + className: "hermes-kanban-drawer-title-text", + title: "Click to edit", + onClick: function () { props.setEditing(true); }, + }, t.title || "(untitled)"), + ), + h("div", { className: "hermes-kanban-drawer-meta" }, + h(MetaRow, { label: "Status", value: t.status }), + h(AssigneeEditor, { task: t, onPatch: props.onPatch }), + h(PriorityEditor, { task: t, onPatch: props.onPatch }), + t.tenant ? h(MetaRow, { label: "Tenant", value: t.tenant }) : null, + h(MetaRow, { + label: "Workspace", + value: `${t.workspace_kind}${t.workspace_path ? ": " + t.workspace_path : ""}`, + }), + t.created_by ? h(MetaRow, { label: "Created by", value: t.created_by }) : null, + ), + h(StatusActions, { task: t, onPatch: props.onPatch }), + h(BodyEditor, { + task: t, + renderMarkdown: props.renderMarkdown, + onPatch: props.onPatch, + }), + h(DependencyEditor, { + task: t, + links, allTasks: props.allTasks, + onAddParent: props.onAddParent, + onRemoveParent: props.onRemoveParent, + onAddChild: props.onAddChild, + onRemoveChild: props.onRemoveChild, + }), + t.result ? h("div", { className: "hermes-kanban-section" }, + h("div", { className: "hermes-kanban-section-head" }, "Result"), + h(MarkdownBlock, { source: t.result, enabled: props.renderMarkdown }), + ) : null, + h("div", { className: "hermes-kanban-section" }, + h("div", { className: "hermes-kanban-section-head" }, `Comments (${comments.length})`), + comments.length === 0 + ? h("div", { className: "text-xs text-muted-foreground" }, "— no comments —") + : comments.map(function (c) { + return h("div", { key: c.id, className: "hermes-kanban-comment" }, + h("div", { className: "hermes-kanban-comment-head" }, + h("span", { className: "hermes-kanban-comment-author" }, c.author || "anon"), + h("span", { className: "hermes-kanban-comment-ago" }, + timeAgo ? timeAgo(c.created_at) : ""), + ), + h(MarkdownBlock, { source: c.body, enabled: props.renderMarkdown }), + ); + }), + ), + h("div", { className: "hermes-kanban-section" }, + h("div", { className: "hermes-kanban-section-head" }, `Events (${events.length})`), + events.slice().reverse().slice(0, 20).map(function (e) { + return h("div", { key: e.id, className: "hermes-kanban-event" }, + h("span", { className: "hermes-kanban-event-kind" }, e.kind), + h("span", { className: "hermes-kanban-event-ago" }, + timeAgo ? timeAgo(e.created_at) : ""), + e.payload + ? h("code", { className: "hermes-kanban-event-payload" }, + JSON.stringify(e.payload)) + : null, + ); + }), + ), + ); + } + + function MetaRow(props) { + return h("div", { className: "hermes-kanban-meta-row" }, + h("span", { className: "hermes-kanban-meta-label" }, props.label), + h("span", { className: "hermes-kanban-meta-value" }, props.value), + ); + } + + function TitleEditor(props) { + const [v, setV] = useState(props.initial); + const save = function () { + const t = v.trim(); + if (!t) return; + props.onSave(t); + }; + return h("div", { className: "hermes-kanban-edit-row" }, + h(Input, { + value: v, autoFocus: true, + onChange: function (e) { setV(e.target.value); }, + onKeyDown: function (e) { + if (e.key === "Enter") { e.preventDefault(); save(); } + if (e.key === "Escape") props.onCancel(); + }, + className: "h-8 text-sm flex-1", + }), + h(Button, { onClick: save, + className: "h-7 px-2 text-xs border border-border hover:bg-foreground/10 cursor-pointer", + }, "Save"), + h(Button, { onClick: props.onCancel, + className: "h-7 px-2 text-xs border border-border hover:bg-foreground/10 cursor-pointer", + }, "Cancel"), + ); + } + + function AssigneeEditor(props) { + const [editing, setEditing] = useState(false); + const [v, setV] = useState(props.task.assignee || ""); + useEffect(function () { setV(props.task.assignee || ""); }, [props.task.assignee]); + if (!editing) { + return h("div", { className: "hermes-kanban-meta-row" }, + h("span", { className: "hermes-kanban-meta-label" }, "Assignee"), + h("span", { + className: "hermes-kanban-meta-value hermes-kanban-editable", + onClick: function () { setEditing(true); }, + title: "Click to edit", + }, props.task.assignee || "unassigned"), + ); + } + const save = function () { + props.onPatch({ assignee: v.trim() || "" }).then(function () { setEditing(false); }); + }; + return h("div", { className: "hermes-kanban-meta-row" }, + h("span", { className: "hermes-kanban-meta-label" }, "Assignee"), + h(Input, { + value: v, autoFocus: true, + onChange: function (e) { setV(e.target.value); }, + onKeyDown: function (e) { + if (e.key === "Enter") { e.preventDefault(); save(); } + if (e.key === "Escape") setEditing(false); + }, + placeholder: "(empty = unassign)", + className: "h-7 text-xs flex-1", + }), + ); + } + + function PriorityEditor(props) { + const [editing, setEditing] = useState(false); + const [v, setV] = useState(String(props.task.priority || 0)); + useEffect(function () { setV(String(props.task.priority || 0)); }, [props.task.priority]); + if (!editing) { + return h("div", { className: "hermes-kanban-meta-row" }, + h("span", { className: "hermes-kanban-meta-label" }, "Priority"), + h("span", { + className: "hermes-kanban-meta-value hermes-kanban-editable", + onClick: function () { setEditing(true); }, + title: "Click to edit", + }, String(props.task.priority)), + ); + } + const save = function () { + props.onPatch({ priority: Number(v) || 0 }).then(function () { setEditing(false); }); + }; + return h("div", { className: "hermes-kanban-meta-row" }, + h("span", { className: "hermes-kanban-meta-label" }, "Priority"), + h(Input, { + type: "number", value: v, autoFocus: true, + onChange: function (e) { setV(e.target.value); }, + onKeyDown: function (e) { + if (e.key === "Enter") { e.preventDefault(); save(); } + if (e.key === "Escape") setEditing(false); + }, + className: "h-7 text-xs w-20", + }), + ); + } + + function BodyEditor(props) { + const [editing, setEditing] = useState(false); + const [v, setV] = useState(props.task.body || ""); + useEffect(function () { setV(props.task.body || ""); }, [props.task.body]); + const save = function () { + props.onPatch({ body: v }).then(function () { setEditing(false); }); + }; + return h("div", { className: "hermes-kanban-section" }, + h("div", { className: "hermes-kanban-section-head-row" }, + h("span", { className: "hermes-kanban-section-head" }, "Description"), + editing + ? h("div", { className: "flex gap-1" }, + h(Button, { onClick: save, + className: "h-6 px-2 text-xs border border-border hover:bg-foreground/10 cursor-pointer", + }, "Save"), + h(Button, { onClick: function () { setEditing(false); setV(props.task.body || ""); }, + className: "h-6 px-2 text-xs border border-border hover:bg-foreground/10 cursor-pointer", + }, "Cancel"), + ) + : h("button", { + type: "button", + onClick: function () { setEditing(true); }, + className: "hermes-kanban-edit-link", + title: "Edit description", + }, "edit"), + ), + editing + ? h("textarea", { + className: "hermes-kanban-textarea", + value: v, + rows: 8, + onChange: function (e) { setV(e.target.value); }, + }) + : props.task.body + ? h(MarkdownBlock, { source: props.task.body, enabled: props.renderMarkdown }) + : h("div", { className: "text-xs text-muted-foreground italic" }, "— no description —"), + ); + } + + function DependencyEditor(props) { + const { task, links, allTasks } = props; + const [newParent, setNewParent] = useState(""); + const [newChild, setNewChild] = useState(""); + // Filter out self + existing links when offering the "add" dropdown. + const candidatesFor = function (excludeSet) { + return (allTasks || []).filter(function (t) { + return t.id !== task.id && !excludeSet.has(t.id); + }); + }; + const parentExclude = new Set([task.id, ...(links.parents || [])]); + const childExclude = new Set([task.id, ...(links.children || [])]); + + return h("div", { className: "hermes-kanban-section" }, + h("div", { className: "hermes-kanban-section-head" }, "Dependencies"), + h("div", { className: "hermes-kanban-deps-row" }, + h("span", { className: "hermes-kanban-deps-label" }, "Parents:"), + h("div", { className: "hermes-kanban-deps-chips" }, + (links.parents || []).length === 0 + ? h("span", { className: "hermes-kanban-deps-empty" }, "none") + : (links.parents || []).map(function (id) { + return h("span", { key: id, className: "hermes-kanban-dep-chip" }, + id, + h("button", { + type: "button", + className: "hermes-kanban-dep-chip-x", + onClick: function () { props.onRemoveParent(id); }, + title: "Remove dependency", + }, "×"), + ); + }), + ), + ), + h("div", { className: "hermes-kanban-deps-row" }, + h(Select, { + value: newParent, + onChange: function (e) { setNewParent(e.target.value); }, + className: "h-7 text-xs flex-1", + }, + h(SelectOption, { value: "" }, "— add parent —"), + candidatesFor(parentExclude).map(function (t) { + return h(SelectOption, { key: t.id, value: t.id }, + `${t.id} — ${(t.title || "").slice(0, 50)}`); + }), + ), + h(Button, { + onClick: function () { + if (!newParent) return; + props.onAddParent(newParent).then(function () { setNewParent(""); }); + }, + disabled: !newParent, + className: cn("h-7 px-2 text-xs border border-border cursor-pointer", + !newParent ? "opacity-40 cursor-not-allowed" : "hover:bg-foreground/10"), + }, "+ parent"), + ), + h("div", { className: "hermes-kanban-deps-row" }, + h("span", { className: "hermes-kanban-deps-label" }, "Children:"), + h("div", { className: "hermes-kanban-deps-chips" }, + (links.children || []).length === 0 + ? h("span", { className: "hermes-kanban-deps-empty" }, "none") + : (links.children || []).map(function (id) { + return h("span", { key: id, className: "hermes-kanban-dep-chip" }, + id, + h("button", { + type: "button", + className: "hermes-kanban-dep-chip-x", + onClick: function () { props.onRemoveChild(id); }, + title: "Remove dependency", + }, "×"), + ); + }), + ), + ), + h("div", { className: "hermes-kanban-deps-row" }, + h(Select, { + value: newChild, + onChange: function (e) { setNewChild(e.target.value); }, + className: "h-7 text-xs flex-1", + }, + h(SelectOption, { value: "" }, "— add child —"), + candidatesFor(childExclude).map(function (t) { + return h(SelectOption, { key: t.id, value: t.id }, + `${t.id} — ${(t.title || "").slice(0, 50)}`); + }), + ), + h(Button, { + onClick: function () { + if (!newChild) return; + props.onAddChild(newChild).then(function () { setNewChild(""); }); + }, + disabled: !newChild, + className: cn("h-7 px-2 text-xs border border-border cursor-pointer", + !newChild ? "opacity-40 cursor-not-allowed" : "hover:bg-foreground/10"), + }, "+ child"), + ), + ); + } + + function StatusActions(props) { + const t = props.task; + const b = function (label, patch, enabled, confirmMsg) { + return h(Button, { + onClick: function () { if (enabled !== false) props.onPatch(patch, { confirm: confirmMsg }); }, + disabled: enabled === false, + className: cn( + "h-7 px-2 text-xs border border-border cursor-pointer", + enabled === false ? "opacity-40 cursor-not-allowed" : "hover:bg-foreground/10", + ), + }, label); + }; + return h("div", { className: "hermes-kanban-actions" }, + b("→ triage", { status: "triage" }, t.status !== "triage"), + b("→ ready", { status: "ready" }, t.status !== "ready"), + b("→ running", { status: "running" }, t.status !== "running"), + b("Block", { status: "blocked" }, + t.status === "running" || t.status === "ready", + DESTRUCTIVE_TRANSITIONS.blocked), + b("Unblock", { status: "ready" }, t.status === "blocked"), + b("Complete", { status: "done" }, + t.status === "running" || t.status === "ready" || t.status === "blocked", + DESTRUCTIVE_TRANSITIONS.done), + b("Archive", { status: "archived" }, t.status !== "archived", + DESTRUCTIVE_TRANSITIONS.archived), + ); + } + + // ------------------------------------------------------------------------- + // Register + // ------------------------------------------------------------------------- + + if (window.__HERMES_PLUGINS__ && typeof window.__HERMES_PLUGINS__.register === "function") { + window.__HERMES_PLUGINS__.register("kanban", KanbanPage); + } +})(); diff --git a/plugins/kanban/dashboard/dist/style.css b/plugins/kanban/dashboard/dist/style.css new file mode 100644 index 0000000000000..5d944071dbe98 --- /dev/null +++ b/plugins/kanban/dashboard/dist/style.css @@ -0,0 +1,656 @@ +/* + * Hermes Kanban — dashboard plugin styles. + * + * All colors reference theme CSS vars so the board reskins with the + * active dashboard theme. No hardcoded palette. + */ + +.hermes-kanban { + width: 100%; +} + +/* ---- Columns layout -------------------------------------------------- */ + +.hermes-kanban-columns { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 0.75rem; + align-items: start; +} + +.hermes-kanban-column { + display: flex; + flex-direction: column; + background: color-mix(in srgb, var(--color-card) 85%, transparent); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: 0.5rem; + min-height: 200px; + max-height: calc(100vh - 220px); + transition: border-color 120ms ease, background-color 120ms ease; +} + +.hermes-kanban-column--drop { + border-color: var(--color-ring); + background: color-mix(in srgb, var(--color-ring) 8%, var(--color-card)); +} + +.hermes-kanban-column-header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0.25rem 0.35rem; + font-weight: 600; + font-size: 0.85rem; + color: var(--color-foreground); +} + +.hermes-kanban-column-label { + flex: 1; + letter-spacing: 0.01em; +} + +.hermes-kanban-column-count { + font-variant-numeric: tabular-nums; + color: var(--color-muted-foreground); + font-size: 0.75rem; + font-weight: 500; +} + +.hermes-kanban-column-add { + appearance: none; + background: transparent; + border: 1px solid var(--color-border); + color: var(--color-foreground); + border-radius: var(--radius-sm, 0.25rem); + width: 22px; + height: 22px; + line-height: 1; + font-size: 1rem; + cursor: pointer; +} +.hermes-kanban-column-add:hover { + background: color-mix(in srgb, var(--color-foreground) 8%, transparent); +} + +.hermes-kanban-column-sub { + padding: 0 0.25rem 0.5rem; + font-size: 0.7rem; + color: var(--color-muted-foreground); + border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent); + margin-bottom: 0.5rem; +} + +.hermes-kanban-column-body { + display: flex; + flex-direction: column; + gap: 0.45rem; + overflow-y: auto; + padding-right: 0.1rem; +} + +.hermes-kanban-empty { + padding: 1.5rem 0.5rem; + text-align: center; + font-size: 0.75rem; + color: var(--color-muted-foreground); + border: 1px dashed color-mix(in srgb, var(--color-border) 70%, transparent); + border-radius: var(--radius-sm, 0.25rem); +} + +/* ---- Status dots ----------------------------------------------------- */ + +.hermes-kanban-dot { + display: inline-block; + width: 0.5rem; + height: 0.5rem; + border-radius: 999px; + background: var(--color-muted-foreground); +} +.hermes-kanban-dot-triage { background: #b47dd6; } /* lilac — fresh/unspecified */ +.hermes-kanban-dot-todo { background: var(--color-muted-foreground); } +.hermes-kanban-dot-ready { background: #d4b348; } /* amber */ +.hermes-kanban-dot-running { background: #3fb97d; } /* green */ +.hermes-kanban-dot-blocked { background: var(--color-destructive, #d14a4a); } +.hermes-kanban-dot-done { background: #4a8cd1; } /* blue */ +.hermes-kanban-dot-archived { background: var(--color-border); } + +/* ---- Progress pill (N/M child tasks done) --------------------------- */ + +.hermes-kanban-progress { + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.62rem; + padding: 0.05rem 0.35rem; + border-radius: 999px; + background: color-mix(in srgb, var(--color-foreground) 8%, transparent); + border: 1px solid color-mix(in srgb, var(--color-border) 80%, transparent); + color: var(--color-muted-foreground); + letter-spacing: 0.02em; +} +.hermes-kanban-progress--full { + background: color-mix(in srgb, #3fb97d 22%, transparent); + border-color: color-mix(in srgb, #3fb97d 45%, transparent); + color: var(--color-foreground); +} + +/* ---- Lanes (per-profile sub-grouping inside Running) ---------------- */ + +.hermes-kanban-lane { + display: flex; + flex-direction: column; + gap: 0.35rem; + padding: 0.25rem 0 0.35rem; + border-top: 1px dashed color-mix(in srgb, var(--color-border) 70%, transparent); +} +.hermes-kanban-lane:first-child { + border-top: 0; + padding-top: 0; +} +.hermes-kanban-lane-head { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--color-muted-foreground); + padding: 0 0.1rem; +} +.hermes-kanban-lane-name { + font-weight: 600; + font-family: var(--font-mono, ui-monospace, monospace); +} +.hermes-kanban-lane-count { + margin-left: auto; + font-variant-numeric: tabular-nums; +} + +/* ---- Card ------------------------------------------------------------ */ + +.hermes-kanban-card { + cursor: grab; + transition: transform 100ms ease, box-shadow 100ms ease; +} +.hermes-kanban-card:hover { + box-shadow: 0 1px 0 0 var(--color-ring) inset, 0 0 0 1px var(--color-ring) inset; +} +.hermes-kanban-card:active { + cursor: grabbing; + transform: scale(0.995); +} + +.hermes-kanban-card-content { + padding: 0.5rem 0.6rem !important; + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.hermes-kanban-card-row { + display: flex; + align-items: center; + gap: 0.35rem; + flex-wrap: wrap; +} + +.hermes-kanban-card-id { + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.65rem; + color: var(--color-muted-foreground); + letter-spacing: 0.03em; +} + +.hermes-kanban-card-title { + font-size: 0.85rem; + font-weight: 500; + line-height: 1.3; + color: var(--color-foreground); + word-break: break-word; +} + +.hermes-kanban-card-meta { + font-size: 0.7rem; + color: var(--color-muted-foreground); + gap: 0.55rem; +} + +.hermes-kanban-priority { + font-size: 0.6rem !important; + padding: 0.05rem 0.3rem !important; + background: color-mix(in srgb, var(--color-ring) 18%, transparent); + color: var(--color-foreground); + border: 1px solid color-mix(in srgb, var(--color-ring) 40%, transparent); +} + +.hermes-kanban-tag { + font-size: 0.6rem !important; + padding: 0.05rem 0.3rem !important; +} + +.hermes-kanban-assignee { + font-weight: 500; + color: color-mix(in srgb, var(--color-foreground) 80%, var(--color-muted-foreground)); +} +.hermes-kanban-unassigned { + font-style: italic; +} +.hermes-kanban-ago { + margin-left: auto; +} + +/* ---- Inline create --------------------------------------------------- */ + +.hermes-kanban-inline-create { + display: flex; + flex-direction: column; + gap: 0.35rem; + padding: 0.5rem; + margin-bottom: 0.5rem; + background: color-mix(in srgb, var(--color-card) 70%, transparent); + border: 1px dashed var(--color-border); + border-radius: var(--radius-sm, 0.25rem); +} + +/* ---- Drawer (task detail side panel) --------------------------------- */ + +.hermes-kanban-drawer-shade { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + z-index: 60; + display: flex; + justify-content: flex-end; +} + +.hermes-kanban-drawer { + width: min(480px, 92vw); + height: 100vh; + background: var(--color-card); + border-left: 1px solid var(--color-border); + display: flex; + flex-direction: column; + box-shadow: -4px 0 18px rgba(0, 0, 0, 0.35); + animation: hermes-kanban-drawer-in 180ms ease-out; +} + +@keyframes hermes-kanban-drawer-in { + from { transform: translateX(100%); opacity: 0.3; } + to { transform: translateX(0); opacity: 1; } +} + +.hermes-kanban-drawer-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.6rem 0.8rem; + border-bottom: 1px solid var(--color-border); + font-family: var(--font-mono, ui-monospace, monospace); +} + +.hermes-kanban-drawer-close { + appearance: none; + background: transparent; + border: 0; + color: var(--color-muted-foreground); + font-size: 1.25rem; + line-height: 1; + cursor: pointer; + padding: 0 0.25rem; +} +.hermes-kanban-drawer-close:hover { color: var(--color-foreground); } + +.hermes-kanban-drawer-body { + flex: 1; + overflow-y: auto; + padding: 0.9rem; + display: flex; + flex-direction: column; + gap: 0.85rem; +} + +.hermes-kanban-drawer-title { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 1rem; + font-weight: 600; +} + +.hermes-kanban-drawer-meta { + display: flex; + flex-direction: column; + gap: 0.15rem; + padding: 0.5rem 0.6rem; + background: color-mix(in srgb, var(--color-foreground) 4%, transparent); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm, 0.25rem); +} + +.hermes-kanban-meta-row { + display: flex; + gap: 0.5rem; + font-size: 0.72rem; +} +.hermes-kanban-meta-label { + width: 92px; + color: var(--color-muted-foreground); +} +.hermes-kanban-meta-value { + color: var(--color-foreground); + word-break: break-word; +} + +.hermes-kanban-actions { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; +} + +.hermes-kanban-section { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.hermes-kanban-section-head { + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--color-muted-foreground); +} + +.hermes-kanban-pre { + margin: 0; + padding: 0.45rem 0.55rem; + white-space: pre-wrap; + word-break: break-word; + background: color-mix(in srgb, var(--color-foreground) 4%, transparent); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm, 0.25rem); + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.72rem; + color: var(--color-foreground); +} + +.hermes-kanban-comment { + border-left: 2px solid color-mix(in srgb, var(--color-ring) 35%, transparent); + padding-left: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.hermes-kanban-comment-head { + display: flex; + gap: 0.5rem; + font-size: 0.7rem; +} +.hermes-kanban-comment-author { + font-weight: 600; + color: var(--color-foreground); +} +.hermes-kanban-comment-ago { + color: var(--color-muted-foreground); +} + +.hermes-kanban-event { + display: flex; + gap: 0.5rem; + font-size: 0.7rem; + color: var(--color-muted-foreground); + font-family: var(--font-mono, ui-monospace, monospace); +} +.hermes-kanban-event-kind { + color: var(--color-foreground); + min-width: 6rem; +} +.hermes-kanban-event-payload { + color: var(--color-muted-foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 280px; +} + +.hermes-kanban-drawer-comment-row { + display: flex; + gap: 0.4rem; + padding: 0.55rem 0.75rem; + border-top: 1px solid var(--color-border); + background: color-mix(in srgb, var(--color-card) 90%, transparent); +} + +.hermes-kanban-count { + display: inline-flex; + gap: 0.2rem; + align-items: center; +} + +/* ---- Selection chrome ----------------------------------------------- */ + +.hermes-kanban-card--selected :where(.hermes-kanban-card-content) { + box-shadow: 0 0 0 2px var(--color-ring) inset, + 0 0 0 1px var(--color-ring) inset; + background: color-mix(in srgb, var(--color-ring) 6%, var(--color-card)); +} + +.hermes-kanban-card-check { + width: 0.85rem; + height: 0.85rem; + margin: 0; + cursor: pointer; + accent-color: var(--color-ring); +} + +/* ---- Bulk action bar ------------------------------------------------ */ + +.hermes-kanban-bulk { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.75rem; + background: color-mix(in srgb, var(--color-ring) 10%, var(--color-card)); + border: 1px solid color-mix(in srgb, var(--color-ring) 40%, var(--color-border)); + border-radius: var(--radius-sm, 0.25rem); + flex-wrap: wrap; +} +.hermes-kanban-bulk-count { + font-weight: 600; + font-size: 0.75rem; + padding-right: 0.25rem; +} +.hermes-kanban-bulk-btn { + height: 1.7rem !important; + padding: 0 0.5rem !important; + font-size: 0.7rem !important; + border: 1px solid var(--color-border); + cursor: pointer; +} +.hermes-kanban-bulk-btn:hover { + background: color-mix(in srgb, var(--color-foreground) 8%, transparent); +} +.hermes-kanban-bulk-reassign { + display: flex; + align-items: center; + gap: 0.25rem; + padding-left: 0.5rem; + border-left: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent); +} + +/* ---- Dependency editor chips --------------------------------------- */ + +.hermes-kanban-deps-row { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.4rem; +} +.hermes-kanban-deps-label { + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--color-muted-foreground); + min-width: 4rem; +} +.hermes-kanban-deps-chips { + display: flex; + gap: 0.3rem; + flex-wrap: wrap; + flex: 1; +} +.hermes-kanban-deps-empty { + font-size: 0.7rem; + color: var(--color-muted-foreground); + font-style: italic; +} +.hermes-kanban-dep-chip { + display: inline-flex; + align-items: center; + gap: 0.15rem; + padding: 0.1rem 0.35rem; + background: color-mix(in srgb, var(--color-foreground) 6%, transparent); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm, 0.25rem); + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.68rem; + color: var(--color-foreground); +} +.hermes-kanban-dep-chip-x { + appearance: none; + background: transparent; + border: 0; + color: var(--color-muted-foreground); + cursor: pointer; + font-size: 0.85rem; + line-height: 1; + padding: 0 0.15rem; +} +.hermes-kanban-dep-chip-x:hover { color: var(--color-destructive, #d14a4a); } + +/* ---- Inline edit affordances --------------------------------------- */ + +.hermes-kanban-editable { + cursor: pointer; + border-bottom: 1px dotted color-mix(in srgb, var(--color-border) 80%, transparent); +} +.hermes-kanban-editable:hover { + color: var(--color-foreground); + border-bottom-color: var(--color-ring); +} + +.hermes-kanban-drawer-title-text { + cursor: pointer; +} +.hermes-kanban-drawer-title-text:hover { + text-decoration: underline; + text-decoration-color: var(--color-ring); + text-decoration-style: dotted; + text-underline-offset: 3px; +} + +.hermes-kanban-edit-row { + display: flex; + align-items: center; + gap: 0.35rem; + width: 100%; +} + +.hermes-kanban-section-head-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} +.hermes-kanban-edit-link { + appearance: none; + background: transparent; + border: 0; + color: var(--color-muted-foreground); + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.05em; + cursor: pointer; + padding: 0; +} +.hermes-kanban-edit-link:hover { color: var(--color-ring); } + +.hermes-kanban-textarea { + width: 100%; + min-height: 8rem; + background: var(--color-card); + color: var(--color-foreground); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm, 0.25rem); + padding: 0.5rem 0.6rem; + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.8rem; + line-height: 1.5; + resize: vertical; +} +.hermes-kanban-textarea:focus { + outline: none; + border-color: var(--color-ring); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-ring) 30%, transparent); +} + +/* ---- Markdown rendering -------------------------------------------- */ + +.hermes-kanban-md { + font-size: 0.8rem; + line-height: 1.55; + color: var(--color-foreground); +} +.hermes-kanban-md p { margin: 0.25rem 0; } +.hermes-kanban-md h1, +.hermes-kanban-md h2, +.hermes-kanban-md h3, +.hermes-kanban-md h4 { + margin: 0.6rem 0 0.2rem; + line-height: 1.25; +} +.hermes-kanban-md h1 { font-size: 1.05rem; } +.hermes-kanban-md h2 { font-size: 0.95rem; } +.hermes-kanban-md h3 { font-size: 0.88rem; } +.hermes-kanban-md h4 { font-size: 0.82rem; } +.hermes-kanban-md ul { + margin: 0.25rem 0 0.25rem 1.1rem; + padding: 0; +} +.hermes-kanban-md li { margin: 0.1rem 0; } +.hermes-kanban-md a { + color: var(--color-ring); + text-decoration: underline; +} +.hermes-kanban-md code { + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.75rem; + padding: 0.05rem 0.3rem; + background: color-mix(in srgb, var(--color-foreground) 8%, transparent); + border-radius: 3px; +} +.hermes-kanban-md-code { + margin: 0.35rem 0; + padding: 0.5rem 0.6rem; + background: color-mix(in srgb, var(--color-foreground) 5%, transparent); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm, 0.25rem); + overflow-x: auto; +} +.hermes-kanban-md-code code { + background: transparent; + padding: 0; + font-size: 0.75rem; + white-space: pre; +} +.hermes-kanban-md strong { font-weight: 600; } + +/* ---- Touch-drag proxy ---------------------------------------------- */ + +.hermes-kanban-touch-proxy { + pointer-events: none; + opacity: 0.85; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.35); + transform: scale(1.02); + transition: none; +} diff --git a/plugins/kanban/dashboard/manifest.json b/plugins/kanban/dashboard/manifest.json new file mode 100644 index 0000000000000..8be4b8c451794 --- /dev/null +++ b/plugins/kanban/dashboard/manifest.json @@ -0,0 +1,14 @@ +{ + "name": "kanban", + "label": "Kanban", + "description": "Multi-agent collaboration board — drag-drop cards across columns, read comment threads, see which profile is running what", + "icon": "Package", + "version": "1.0.0", + "tab": { + "path": "/kanban", + "position": "after:skills" + }, + "entry": "dist/index.js", + "css": "dist/style.css", + "api": "plugin_api.py" +} diff --git a/plugins/kanban/dashboard/plugin_api.py b/plugins/kanban/dashboard/plugin_api.py new file mode 100644 index 0000000000000..7de40a276d326 --- /dev/null +++ b/plugins/kanban/dashboard/plugin_api.py @@ -0,0 +1,692 @@ +"""Kanban dashboard plugin — backend API routes. + +Mounted at /api/plugins/kanban/ by the dashboard plugin system. + +This layer is intentionally thin: every handler is a small wrapper around +``hermes_cli.kanban_db`` or a direct SQL query. Writes use the same code +paths the CLI and gateway ``/kanban`` command use, so the three surfaces +cannot drift. + +Live updates arrive via the ``/events`` WebSocket, which tails the +append-only ``task_events`` table on a short poll interval (WAL mode lets +reads run alongside the dispatcher's IMMEDIATE write transactions). + +Security note +------------- +The dashboard's HTTP auth middleware (``web_server.auth_middleware``) +explicitly skips ``/api/plugins/`` — plugin routes are unauthenticated by +design because the dashboard binds to localhost by default. For the +WebSocket we still require the session token as a ``?token=`` query +parameter (browsers cannot set the ``Authorization`` header on an upgrade +request), matching the established pattern used by the in-browser PTY +bridge in ``hermes_cli/web_server.py``. If you run the dashboard with +``--host 0.0.0.0``, every plugin route — kanban included — becomes +reachable from the network. Don't do that on a shared host. +""" + +from __future__ import annotations + +import asyncio +import hmac +import json +import logging +import sqlite3 +import time +from dataclasses import asdict +from typing import Any, Optional + +from fastapi import APIRouter, HTTPException, Query, WebSocket, WebSocketDisconnect, status as http_status +from pydantic import BaseModel, Field + +from hermes_cli import kanban_db + +log = logging.getLogger(__name__) + +router = APIRouter() + + +# --------------------------------------------------------------------------- +# Auth helper — WebSocket only (HTTP routes live behind the dashboard's +# existing plugin-bypass; this is documented above). +# --------------------------------------------------------------------------- + +def _check_ws_token(provided: Optional[str]) -> bool: + """Constant-time compare against the dashboard session token. + + Imported lazily so the plugin still loads in test contexts where the + dashboard web_server module isn't importable (e.g. the bare-FastAPI + test harness). + """ + if not provided: + return False + try: + from hermes_cli import web_server as _ws + except Exception: + # No dashboard context (tests). Accept so the tail loop is still + # testable; in production the dashboard module always imports + # cleanly because it's the caller. + return True + expected = getattr(_ws, "_SESSION_TOKEN", None) + if not expected: + return True + return hmac.compare_digest(str(provided), str(expected)) + + +def _conn(): + """Open a kanban_db connection, creating the schema on first use. + + Every handler that mutates the DB goes through this so the plugin + self-heals on a fresh install (no user-visible "no such table" + error if somebody hits POST /tasks before GET /board). + ``init_db`` is idempotent. + """ + try: + kanban_db.init_db() + except Exception as exc: + log.warning("kanban init_db failed: %s", exc) + return kanban_db.connect() + + +# --------------------------------------------------------------------------- +# Serialization helpers +# --------------------------------------------------------------------------- + +# Columns shown by the dashboard, in left-to-right order. "archived" is +# available via a filter toggle rather than a visible column. +BOARD_COLUMNS: list[str] = [ + "triage", "todo", "ready", "running", "blocked", "done", +] + + +def _task_dict(task: kanban_db.Task) -> dict[str, Any]: + d = asdict(task) + # Keep body short on list endpoints; full body comes from /tasks/:id. + return d + + +def _event_dict(event: kanban_db.Event) -> dict[str, Any]: + return { + "id": event.id, + "task_id": event.task_id, + "kind": event.kind, + "payload": event.payload, + "created_at": event.created_at, + } + + +def _comment_dict(c: kanban_db.Comment) -> dict[str, Any]: + return { + "id": c.id, + "task_id": c.task_id, + "author": c.author, + "body": c.body, + "created_at": c.created_at, + } + + +def _links_for(conn: sqlite3.Connection, task_id: str) -> dict[str, list[str]]: + """Return {'parents': [...], 'children': [...]} for a task.""" + parents = [ + r["parent_id"] + for r in conn.execute( + "SELECT parent_id FROM task_links WHERE child_id = ? ORDER BY parent_id", + (task_id,), + ) + ] + children = [ + r["child_id"] + for r in conn.execute( + "SELECT child_id FROM task_links WHERE parent_id = ? ORDER BY child_id", + (task_id,), + ) + ] + return {"parents": parents, "children": children} + + +# --------------------------------------------------------------------------- +# GET /board +# --------------------------------------------------------------------------- + +@router.get("/board") +def get_board( + tenant: Optional[str] = Query(None, description="Filter to a single tenant"), + include_archived: bool = Query(False), +): + """Return the full board grouped by status column. + + ``_conn()`` auto-initializes ``kanban.db`` on first call so a fresh + install doesn't surface a "failed to load" error on the plugin tab. + """ + conn = _conn() + try: + tasks = kanban_db.list_tasks( + conn, tenant=tenant, include_archived=include_archived + ) + # Pre-fetch link counts per task (cheap: one query). + link_counts: dict[str, dict[str, int]] = {} + for row in conn.execute( + "SELECT parent_id, child_id FROM task_links" + ).fetchall(): + link_counts.setdefault(row["parent_id"], {"parents": 0, "children": 0})[ + "children" + ] += 1 + link_counts.setdefault(row["child_id"], {"parents": 0, "children": 0})[ + "parents" + ] += 1 + + # Comment + event counts (both cheap aggregates). + comment_counts: dict[str, int] = { + r["task_id"]: r["n"] + for r in conn.execute( + "SELECT task_id, COUNT(*) AS n FROM task_comments GROUP BY task_id" + ) + } + + # Progress rollup: for each parent, how many children are done / total. + # One pass over task_links joined with child status — cheaper than + # N per-task queries and the plugin uses it to render "N/M". + progress: dict[str, dict[str, int]] = {} + for row in conn.execute( + "SELECT l.parent_id AS pid, t.status AS cstatus " + "FROM task_links l JOIN tasks t ON t.id = l.child_id" + ).fetchall(): + p = progress.setdefault(row["pid"], {"done": 0, "total": 0}) + p["total"] += 1 + if row["cstatus"] == "done": + p["done"] += 1 + + latest_event_id = conn.execute( + "SELECT COALESCE(MAX(id), 0) AS m FROM task_events" + ).fetchone()["m"] + + columns: dict[str, list[dict]] = {c: [] for c in BOARD_COLUMNS} + if include_archived: + columns["archived"] = [] + + for t in tasks: + d = _task_dict(t) + d["link_counts"] = link_counts.get(t.id, {"parents": 0, "children": 0}) + d["comment_count"] = comment_counts.get(t.id, 0) + d["progress"] = progress.get(t.id) # None when the task has no children + col = t.status if t.status in columns else "todo" + columns[col].append(d) + + # Stable per-column ordering already applied by list_tasks + # (priority DESC, created_at ASC), keep as-is. + + # List of known tenants for the UI filter dropdown. + tenants = [ + r["tenant"] + for r in conn.execute( + "SELECT DISTINCT tenant FROM tasks WHERE tenant IS NOT NULL ORDER BY tenant" + ) + ] + # List of distinct assignees for the lane-by-profile sub-grouping. + assignees = [ + r["assignee"] + for r in conn.execute( + "SELECT DISTINCT assignee FROM tasks WHERE assignee IS NOT NULL " + "AND status != 'archived' ORDER BY assignee" + ) + ] + + return { + "columns": [ + {"name": name, "tasks": columns[name]} for name in columns.keys() + ], + "tenants": tenants, + "assignees": assignees, + "latest_event_id": int(latest_event_id), + "now": int(time.time()), + } + finally: + conn.close() + + +# --------------------------------------------------------------------------- +# GET /tasks/:id +# --------------------------------------------------------------------------- + +@router.get("/tasks/{task_id}") +def get_task(task_id: str): + conn = _conn() + try: + task = kanban_db.get_task(conn, task_id) + if task is None: + raise HTTPException(status_code=404, detail=f"task {task_id} not found") + return { + "task": _task_dict(task), + "comments": [_comment_dict(c) for c in kanban_db.list_comments(conn, task_id)], + "events": [_event_dict(e) for e in kanban_db.list_events(conn, task_id)], + "links": _links_for(conn, task_id), + } + finally: + conn.close() + + +# --------------------------------------------------------------------------- +# POST /tasks +# --------------------------------------------------------------------------- + +class CreateTaskBody(BaseModel): + title: str + body: Optional[str] = None + assignee: Optional[str] = None + tenant: Optional[str] = None + priority: int = 0 + workspace_kind: str = "scratch" + workspace_path: Optional[str] = None + parents: list[str] = Field(default_factory=list) + triage: bool = False + + +@router.post("/tasks") +def create_task(payload: CreateTaskBody): + conn = _conn() + try: + task_id = kanban_db.create_task( + conn, + title=payload.title, + body=payload.body, + assignee=payload.assignee, + created_by="dashboard", + workspace_kind=payload.workspace_kind, + workspace_path=payload.workspace_path, + tenant=payload.tenant, + priority=payload.priority, + parents=payload.parents, + triage=payload.triage, + ) + task = kanban_db.get_task(conn, task_id) + return {"task": _task_dict(task) if task else None} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + finally: + conn.close() + + +# --------------------------------------------------------------------------- +# PATCH /tasks/:id (status / assignee / priority / title / body) +# --------------------------------------------------------------------------- + +class UpdateTaskBody(BaseModel): + status: Optional[str] = None + assignee: Optional[str] = None + priority: Optional[int] = None + title: Optional[str] = None + body: Optional[str] = None + result: Optional[str] = None + block_reason: Optional[str] = None + + +@router.patch("/tasks/{task_id}") +def update_task(task_id: str, payload: UpdateTaskBody): + conn = _conn() + try: + task = kanban_db.get_task(conn, task_id) + if task is None: + raise HTTPException(status_code=404, detail=f"task {task_id} not found") + + # --- assignee ---------------------------------------------------- + if payload.assignee is not None: + try: + ok = kanban_db.assign_task( + conn, task_id, payload.assignee or None, + ) + except RuntimeError as e: + raise HTTPException(status_code=409, detail=str(e)) + if not ok: + raise HTTPException(status_code=404, detail="task not found") + + # --- status ------------------------------------------------------- + if payload.status is not None: + s = payload.status + ok = True + if s == "done": + ok = kanban_db.complete_task(conn, task_id, result=payload.result) + elif s == "blocked": + ok = kanban_db.block_task(conn, task_id, reason=payload.block_reason) + elif s == "ready": + # Re-open a blocked task, or just an explicit status set. + current = kanban_db.get_task(conn, task_id) + if current and current.status == "blocked": + ok = kanban_db.unblock_task(conn, task_id) + else: + # Direct status write for drag-drop (todo -> ready etc). + ok = _set_status_direct(conn, task_id, "ready") + elif s == "archived": + ok = kanban_db.archive_task(conn, task_id) + elif s in ("todo", "running", "triage"): + ok = _set_status_direct(conn, task_id, s) + else: + raise HTTPException(status_code=400, detail=f"unknown status: {s}") + if not ok: + raise HTTPException( + status_code=409, + detail=f"status transition to {s!r} not valid from current state", + ) + + # --- priority ----------------------------------------------------- + if payload.priority is not None: + with kanban_db.write_txn(conn): + conn.execute( + "UPDATE tasks SET priority = ? WHERE id = ?", + (int(payload.priority), task_id), + ) + conn.execute( + "INSERT INTO task_events (task_id, kind, payload, created_at) " + "VALUES (?, 'priority', ?, ?)", + (task_id, json.dumps({"priority": int(payload.priority)}), + int(time.time())), + ) + + # --- title / body ------------------------------------------------- + if payload.title is not None or payload.body is not None: + with kanban_db.write_txn(conn): + sets, vals = [], [] + if payload.title is not None: + if not payload.title.strip(): + raise HTTPException(status_code=400, detail="title cannot be empty") + sets.append("title = ?") + vals.append(payload.title.strip()) + if payload.body is not None: + sets.append("body = ?") + vals.append(payload.body) + vals.append(task_id) + conn.execute( + f"UPDATE tasks SET {', '.join(sets)} WHERE id = ?", vals, + ) + conn.execute( + "INSERT INTO task_events (task_id, kind, payload, created_at) " + "VALUES (?, 'edited', NULL, ?)", + (task_id, int(time.time())), + ) + + updated = kanban_db.get_task(conn, task_id) + return {"task": _task_dict(updated) if updated else None} + finally: + conn.close() + + +def _set_status_direct( + conn: sqlite3.Connection, task_id: str, new_status: str, +) -> bool: + """Direct status write for drag-drop moves that aren't covered by the + structured complete/block/unblock/archive verbs (e.g. todo<->ready, + running<->ready). Appends a ``status`` event row for the live feed.""" + with kanban_db.write_txn(conn): + cur = conn.execute( + "UPDATE tasks SET status = ?, " + " claim_lock = CASE WHEN ? = 'running' THEN claim_lock ELSE NULL END, " + " claim_expires = CASE WHEN ? = 'running' THEN claim_expires ELSE NULL END " + "WHERE id = ?", + (new_status, new_status, new_status, task_id), + ) + if cur.rowcount != 1: + return False + conn.execute( + "INSERT INTO task_events (task_id, kind, payload, created_at) " + "VALUES (?, 'status', ?, ?)", + (task_id, json.dumps({"status": new_status}), int(time.time())), + ) + # If we re-opened something, children may have gone stale. + if new_status in ("done", "ready"): + kanban_db.recompute_ready(conn) + return True + + +# --------------------------------------------------------------------------- +# Comments +# --------------------------------------------------------------------------- + +class CommentBody(BaseModel): + body: str + author: Optional[str] = "dashboard" + + +@router.post("/tasks/{task_id}/comments") +def add_comment(task_id: str, payload: CommentBody): + if not payload.body.strip(): + raise HTTPException(status_code=400, detail="body is required") + conn = _conn() + try: + if kanban_db.get_task(conn, task_id) is None: + raise HTTPException(status_code=404, detail=f"task {task_id} not found") + kanban_db.add_comment( + conn, task_id, author=payload.author or "dashboard", body=payload.body, + ) + return {"ok": True} + finally: + conn.close() + + +# --------------------------------------------------------------------------- +# Links +# --------------------------------------------------------------------------- + +class LinkBody(BaseModel): + parent_id: str + child_id: str + + +@router.post("/links") +def add_link(payload: LinkBody): + conn = _conn() + try: + kanban_db.link_tasks(conn, payload.parent_id, payload.child_id) + return {"ok": True} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + finally: + conn.close() + + +@router.delete("/links") +def delete_link(parent_id: str = Query(...), child_id: str = Query(...)): + conn = _conn() + try: + ok = kanban_db.unlink_tasks(conn, parent_id, child_id) + return {"ok": bool(ok)} + finally: + conn.close() + + +# --------------------------------------------------------------------------- +# Bulk actions (multi-select on the board) +# --------------------------------------------------------------------------- + +class BulkTaskBody(BaseModel): + ids: list[str] + status: Optional[str] = None + assignee: Optional[str] = None # "" or None = unassign + priority: Optional[int] = None + archive: bool = False + + +@router.post("/tasks/bulk") +def bulk_update(payload: BulkTaskBody): + """Apply the same patch to every id in ``payload.ids``. + + This is an *independent* iteration — per-task failures don't abort + siblings. Returns per-id outcome so the UI can surface partials. + """ + ids = [i for i in (payload.ids or []) if i] + if not ids: + raise HTTPException(status_code=400, detail="ids is required") + results: list[dict] = [] + conn = _conn() + try: + for tid in ids: + entry: dict[str, Any] = {"id": tid, "ok": True} + try: + task = kanban_db.get_task(conn, tid) + if task is None: + entry.update(ok=False, error="not found") + results.append(entry) + continue + if payload.archive: + if not kanban_db.archive_task(conn, tid): + entry.update(ok=False, error="archive refused") + if payload.status is not None and not payload.archive: + s = payload.status + if s == "done": + ok = kanban_db.complete_task(conn, tid) + elif s == "blocked": + ok = kanban_db.block_task(conn, tid) + elif s == "ready": + cur = kanban_db.get_task(conn, tid) + if cur and cur.status == "blocked": + ok = kanban_db.unblock_task(conn, tid) + else: + ok = _set_status_direct(conn, tid, "ready") + elif s in ("todo", "running", "triage"): + ok = _set_status_direct(conn, tid, s) + else: + entry.update(ok=False, error=f"unknown status {s!r}") + results.append(entry) + continue + if not ok: + entry.update(ok=False, error=f"transition to {s!r} refused") + if payload.assignee is not None: + try: + if not kanban_db.assign_task( + conn, tid, payload.assignee or None, + ): + entry.update(ok=False, error="assign refused") + except RuntimeError as e: + entry.update(ok=False, error=str(e)) + if payload.priority is not None: + with kanban_db.write_txn(conn): + conn.execute( + "UPDATE tasks SET priority = ? WHERE id = ?", + (int(payload.priority), tid), + ) + conn.execute( + "INSERT INTO task_events (task_id, kind, payload, created_at) " + "VALUES (?, 'priority', ?, ?)", + (tid, json.dumps({"priority": int(payload.priority)}), + int(time.time())), + ) + except Exception as e: # defensive — one bad id shouldn't kill the batch + entry.update(ok=False, error=str(e)) + results.append(entry) + return {"results": results} + finally: + conn.close() + + +# --------------------------------------------------------------------------- +# Plugin config (read dashboard.kanban.* defaults from config.yaml) +# --------------------------------------------------------------------------- + +@router.get("/config") +def get_config(): + """Return kanban dashboard preferences from ~/.hermes/config.yaml. + + Reads the ``dashboard.kanban`` section if present; defaults otherwise. + Used by the UI to pre-select tenant filters, toggle markdown rendering, + or set column-width preferences without a round-trip per page load. + """ + try: + from hermes_cli.config import load_config + cfg = load_config() or {} + except Exception: + cfg = {} + dash_cfg = (cfg.get("dashboard") or {}) + # dashboard.kanban may itself be a dict; fall back to {}. + k_cfg = dash_cfg.get("kanban") or {} + return { + "default_tenant": k_cfg.get("default_tenant") or "", + "lane_by_profile": bool(k_cfg.get("lane_by_profile", True)), + "include_archived_by_default": bool(k_cfg.get("include_archived_by_default", False)), + "render_markdown": bool(k_cfg.get("render_markdown", True)), + } + + +# --------------------------------------------------------------------------- +# Dispatch nudge (optional quick-path so the UI doesn't wait 60 s) +# --------------------------------------------------------------------------- + +@router.post("/dispatch") +def dispatch(dry_run: bool = Query(False), max_n: int = Query(8, alias="max")): + conn = _conn() + try: + result = kanban_db.dispatch_once( + conn, dry_run=dry_run, max_spawn=max_n, + ) + # DispatchResult is a dataclass. + try: + return asdict(result) + except TypeError: + return {"result": str(result)} + finally: + conn.close() + + +# --------------------------------------------------------------------------- +# WebSocket: /events?since= +# --------------------------------------------------------------------------- + +# Poll interval for the event tail loop. SQLite WAL + 300 ms polling is +# the simplest and most robust approach; it adds a fraction of a percent +# of CPU and has no shared state to synchronize across workers. +_EVENT_POLL_SECONDS = 0.3 + + +@router.websocket("/events") +async def stream_events(ws: WebSocket): + # Enforce the dashboard session token as a query param — browsers can't + # set Authorization on a WS upgrade. This matches how the PTY bridge + # authenticates in hermes_cli/web_server.py. + token = ws.query_params.get("token") + if not _check_ws_token(token): + await ws.close(code=http_status.WS_1008_POLICY_VIOLATION) + return + await ws.accept() + try: + since_raw = ws.query_params.get("since", "0") + try: + cursor = int(since_raw) + except ValueError: + cursor = 0 + + def _fetch_new(cursor_val: int) -> tuple[int, list[dict]]: + conn = kanban_db.connect() + try: + rows = conn.execute( + "SELECT id, task_id, kind, payload, created_at " + "FROM task_events WHERE id > ? ORDER BY id ASC LIMIT 200", + (cursor_val,), + ).fetchall() + out: list[dict] = [] + new_cursor = cursor_val + for r in rows: + try: + payload = json.loads(r["payload"]) if r["payload"] else None + except Exception: + payload = None + out.append({ + "id": r["id"], + "task_id": r["task_id"], + "kind": r["kind"], + "payload": payload, + "created_at": r["created_at"], + }) + new_cursor = r["id"] + return new_cursor, out + finally: + conn.close() + + while True: + cursor, events = await asyncio.to_thread(_fetch_new, cursor) + if events: + await ws.send_json({"events": events, "cursor": cursor}) + await asyncio.sleep(_EVENT_POLL_SECONDS) + except WebSocketDisconnect: + return + except Exception as exc: # defensive: never crash the dashboard worker + log.warning("Kanban event stream error: %s", exc) + try: + await ws.close() + except Exception: + pass diff --git a/skills/devops/kanban-orchestrator/SKILL.md b/skills/devops/kanban-orchestrator/SKILL.md new file mode 100644 index 0000000000000..1b706b9fca320 --- /dev/null +++ b/skills/devops/kanban-orchestrator/SKILL.md @@ -0,0 +1,140 @@ +--- +name: kanban-orchestrator +description: Decompose user goals into Kanban tasks and delegate them to specialist profiles. Load this skill in an orchestrator profile whose job is routing, NOT execution. Triggers when the user's goal spans multiple profiles, needs parallel work, or should be durable/auditable. +version: 1.0.0 +metadata: + hermes: + tags: [kanban, multi-agent, orchestration, routing] + related_skills: [kanban-worker] +--- + +# Kanban Orchestrator + +**You are a dispatcher, not a worker.** + +Load this skill in an orchestrator profile. An orchestrator's job is to route: read the user's goal, decompose it into well-scoped tasks, assign each to the right specialist profile, link dependencies, and step back. It does NOT do research, writing, coding, or any implementation work itself. + +## When to use the board (vs. just doing the work) + +Create Kanban tasks when any of these are true: + +1. **Multiple specialists are needed.** Research + analysis + writing is three profiles. +2. **The work should survive a crash or restart.** Long-running, recurring, or important. +3. **The user might want to interject.** Human-in-the-loop at any step. +4. **Multiple subtasks can run in parallel.** Fan-out for speed. +5. **Review / iteration is expected.** A reviewer profile loops on drafter output. +6. **The audit trail matters.** Board rows persist in SQLite forever. + +If *none* of those apply — it's a small one-shot reasoning task — use `delegate_task` instead or answer directly. + +## The anti-temptation rules + +These are the rules you MUST NOT break: + +- **Do not execute the work yourself.** Your tools literally don't include terminal/file/code/web for implementation. If you find yourself "just fixing this quickly" — stop. +- **For any concrete task, create a Kanban task and assign it to a specialist.** Every single time. +- **If no specialist fits, ask the user which profile to create.** Do not default to doing it yourself under "close enough." +- **Your job is to decompose, route, and summarize — nothing else.** + +## The standard specialist roster (convention) + +Unless the user's setup has customized profiles, assume these exist. Adjust to whatever profiles the user actually has — ask if unsure. + +| Profile | Does | +|---|---| +| `researcher` | Reads sources, gathers facts, writes findings. Scratch workspace. | +| `analyst` | Synthesizes, ranks, de-dupes. Consumes multiple `researcher` outputs. | +| `writer` | Drafts prose in the user's voice. | +| `reviewer` | Reads output, leaves line-comments, gates approval. | +| `backend-eng` | Writes server-side code. Worktree workspace. | +| `frontend-eng` | Writes client-side code. Worktree workspace. | +| `ops` | Runs scripts, manages services, handles deployments. | + +## Decomposition playbook + +### Step 1 — Understand the goal + +Ask clarifying questions if the goal is ambiguous. Cheap to ask; expensive to spawn the wrong fleet. + +### Step 2 — Sketch the task graph + +Before creating anything, draft the graph out loud (in your response): + +``` +T1 [planner] — meta; this is me + ├── T2 [researcher] — angle A + ├── T3 [researcher] — angle B + ├── T4 [researcher] — angle C + └── T5 [analyst] — synthesize T2,T3,T4 + └── T6 [writer] — brief the user +``` + +### Step 3 — Create tasks, link dependencies + +For each leaf-level task: +```bash +hermes kanban create "angle: cost analysis" \ + --assignee researcher \ + --tenant $HERMES_TENANT +``` + +Repeat per task. Then link them: +```bash +hermes kanban link +``` + +**Do not assign something to yourself.** If the orchestrator shows up as an assignee anywhere, you've made a mistake. + +### Step 4 — Complete your own orchestration task with a summary + +If you were spawned as a task yourself (e.g. `planner` profile was assigned `T1: "investigate foo"`), mark it done with a summary of what you created: + +```bash +hermes kanban complete $HERMES_KANBAN_TASK \ + --result "decomposed into T2-T6: 3 research angles, 1 synthesis, 1 brief" +``` + +### Step 5 — Tell the user what you did + +Reply to the user with: +- The task IDs you created. +- What each is doing. +- Who will work on them. +- Roughly when to expect results (or "I'll message when the last one's done" if the gateway is wired up). + +## Tenant propagation + +If `$HERMES_TENANT` is set, **every task you create must carry the same `--tenant `.** This is how one specialist fleet serves multiple businesses — the tenant flows down the graph, not across. + +## Pattern reference + +The eight collaboration patterns you can instantiate (load the design spec if unsure): + +- **P1 Fan-out** — N siblings, same role, no links between them. +- **P2 Pipeline** — role-specialized chain with linear deps. +- **P3 Voting/quorum** — N siblings + 1 aggregator linked from all N. +- **P4 Journal** — same profile + `--workspace dir:` + recurring cron. +- **P5 Human-in-the-loop** — any worker blocks; user/peer unblocks. +- **P6 @mention** — the user or an agent can write `@profile-name` inline to address a profile; the gateway parses and routes. (UX, not a new primitive.) +- **P7 Thread-scoped workspace** — `/kanban here` pins workspace to current thread dir. +- **P8 Fleet farming** — one profile, N tasks, one workspace per subject (e.g. 50 social accounts). + +## Example run + +User says: *"Analyze whether we should migrate to Postgres. Include a cost analysis and a performance angle."* + +Your decomposition: +1. `hermes kanban create "research: Postgres cost vs current" --assignee researcher` +2. `hermes kanban create "research: Postgres performance vs current" --assignee researcher` +3. `hermes kanban create "synthesize migration recommendation" --assignee analyst` +4. `hermes kanban link ` ; `hermes kanban link ` +5. `hermes kanban create "draft decision memo" --assignee writer --parent ` +6. Report task IDs and expected flow to the user. + +## Pitfalls + +**The "just a quick check" trap.** When the user asks a small question you could probably answer yourself, the temptation is to skip the board. If the question is genuinely one-shot, answer directly. If it's the opening of a workflow ("first, check X; then Y; then Z"), it's board work even if step 1 looks small. + +**Reassignment vs. new task.** If a reviewer blocks with "needs changes," create a NEW task linked from the reviewer's task — don't re-run the same task with a stern look. The new task is assigned to the original implementer profile. + +**Link order matters.** `hermes kanban link ` — parent first. Mixing them up demotes the wrong task to `todo`. diff --git a/skills/devops/kanban-worker/SKILL.md b/skills/devops/kanban-worker/SKILL.md new file mode 100644 index 0000000000000..a6e6d5443239f --- /dev/null +++ b/skills/devops/kanban-worker/SKILL.md @@ -0,0 +1,120 @@ +--- +name: kanban-worker +description: How a Hermes profile should work a task from the shared Kanban board. Load this skill in any profile that participates in the board (researcher, backend-eng, reviewer, etc.). Triggers on HERMES_KANBAN_TASK env var or a "work kanban task " prompt. +version: 1.0.0 +metadata: + hermes: + tags: [kanban, multi-agent, collaboration, workflow] + related_skills: [kanban-orchestrator] +--- + +# Kanban Worker + +Use this skill when you were spawned to work a task from the shared Hermes Kanban board. Symptoms: + +- Your initial prompt says "work kanban task " — e.g. `work kanban task t_9f2a`. +- Env vars set: `HERMES_KANBAN_TASK`, `HERMES_KANBAN_WORKSPACE`, optionally `HERMES_TENANT`. +- You were started by `hermes kanban dispatch` (cron) or a human ran `hermes -p chat -q "work kanban task "`. + +## Your job + +You are **one run of one specialist profile working one task.** Read the task, do the work inside the workspace, record a result, and exit. Everything else is somebody else's job. + +## Step 1 — Read the full context + +```bash +hermes kanban context $HERMES_KANBAN_TASK +``` + +That command prints: +1. Task title + body. +2. Every comment on the task, in order, with author names. +3. Completion results of every `done` parent task (upstream context). + +**Read all of it.** The comment thread is the inter-agent protocol — past peers, human clarifications, and blocker resolutions all live there. If a reviewer left feedback or the user answered a blocker, it's in the comments. + +## Step 2 — Work inside the workspace + +`cd $HERMES_KANBAN_WORKSPACE` and do the work there. The workspace kind determines what that means: + +| `workspace_kind` | What it is | Your behavior | +|---|---|---| +| `scratch` | Fresh temp dir, yours alone | Read/write freely; it gets GC'd when the task is archived. | +| `dir:` | Shared persistent directory | Treat as a long-lived workspace; other runs will read what you write. | +| `worktree` | Git worktree at the resolved path | You may need to `git worktree add ` if it doesn't exist yet. Commit work here. | + +For `worktree` mode: check if `.git` exists in the workspace path. If not, run: +```bash +git worktree add $HERMES_KANBAN_WORKSPACE +``` +from the main repo's root. Then cd and work normally. + +## Step 3 — If tenancy matters, respect it + +If `$HERMES_TENANT` is set, the task belongs to that tenant namespace. When reading or writing persistent memory, prefix memory entries with the tenant name so context doesn't leak across tenants: + +> Good: memory entry `business-a: Acme is our biggest customer` +> Bad: unprefixed `Acme is our biggest customer` (leaks across tenants) + +## Step 4 — If you hit an ambiguity you can't resolve, BLOCK. Don't guess. + +Any of these should trigger a block: +- User-specific decision you can't infer (IP vs. user-id keys; which tone to use). +- Missing credential or access. +- Source that needs human input (paywalled article, 2FA-gated login). +- Peer profile needs to deliver something first and you can't reach around that. + +```bash +hermes kanban block $HERMES_KANBAN_TASK "need decision: IP vs user_id for rate limit key?" +``` + +`block` also appends your reason as a visible comment. When the user or a peer unblocks and the dispatcher re-spawns you, you'll see the full comment thread including their answer in step 1's context read. + +## Step 5 — Complete with a crisp, machine-readable result + +```bash +hermes kanban complete $HERMES_KANBAN_TASK --result "rate_limiter.py implemented; keys on user_id with IP fallback; tests passing" +``` + +Rules for the `--result` string: +- One to three sentences. It's not a report, it's a handoff note. +- Name concrete artifacts you produced (file paths, URLs, commit SHAs). +- State any caveats a downstream profile needs to know. +- **Do not** include secrets, tokens, or raw PII — results are durable in the board DB forever. + +Downstream tasks (children linked from this task) will see your `--result` verbatim as part of their parent-result context. + +## Step 6 — If follow-up work is obvious, create it. Don't do it. + +You are one task. If you notice something else needs doing, create a linked child task for the right profile instead of scope-creeping: + +```bash +hermes kanban create "add concurrent-request test" \ + --assignee backend-eng \ + --parent $HERMES_KANBAN_TASK +``` + +## Leave comments to talk to peers + +If you want to flag something for a reviewer, a future run, or the user — append a comment: + +```bash +hermes kanban comment $HERMES_KANBAN_TASK "note: skipped the sqlite driver path; needs separate task" +``` + +Comments are the inter-agent protocol. Direct IPC does not exist; the board is the only channel. + +## Do NOT + +- Do not call `delegate_task` as a substitute for creating kanban tasks — `delegate_task` is for short synchronous reasoning subtasks inside your own run, not for cross-agent handoffs. +- Do not modify files outside `$HERMES_KANBAN_WORKSPACE` unless the task body explicitly asks for it. +- Do not assign tasks to yourself during your run (you're already running one; create new tasks for follow-ups only). +- Do not complete a task you didn't actually finish. Block it instead. + +## Pitfalls + +**The task might already be blocked or reassigned when you start.** Between when the dispatcher claimed and when you actually booted up, circumstances can change. Always read the current state at step 1. If `hermes kanban show` reports the task is blocked or reassigned, stop — don't keep running. + +**The workspace may already have artifacts from a previous run.** Especially for `dir:` and `worktree` workspaces, a previous worker may have written files that are incomplete or stale. Read the comment thread — it usually explains why you're running again. + +**Your memory persists but the task result does not carry over automatically.** If you learn something that matters for future runs of this profile in other tasks, write it to your profile memory via the normal mechanism. Comments on the task are for humans and peers; memory is for your future self. diff --git a/tests/hermes_cli/test_kanban_cli.py b/tests/hermes_cli/test_kanban_cli.py new file mode 100644 index 0000000000000..f7c84d5df8e67 --- /dev/null +++ b/tests/hermes_cli/test_kanban_cli.py @@ -0,0 +1,210 @@ +"""Tests for the kanban CLI surface (hermes_cli.kanban).""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path + +import pytest + +from hermes_cli import kanban as kc +from hermes_cli import kanban_db as kb + + +@pytest.fixture +def kanban_home(tmp_path, monkeypatch): + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + kb.init_db() + return home + + +# --------------------------------------------------------------------------- +# Workspace flag parsing +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "value,expected", + [ + ("scratch", ("scratch", None)), + ("worktree", ("worktree", None)), + ("dir:/tmp/work", ("dir", "/tmp/work")), + ], +) +def test_parse_workspace_flag_valid(value, expected): + assert kc._parse_workspace_flag(value) == expected + + +def test_parse_workspace_flag_expands_user(): + kind, path = kc._parse_workspace_flag("dir:~/vault") + assert kind == "dir" + assert path.endswith("/vault") + assert not path.startswith("~") + + +@pytest.mark.parametrize("bad", ["cloud", "dir:", "", "worktree:/x"]) +def test_parse_workspace_flag_rejects(bad): + if not bad: + # Empty -> defaults; not an error. + assert kc._parse_workspace_flag(bad) == ("scratch", None) + return + with pytest.raises(argparse.ArgumentTypeError): + kc._parse_workspace_flag(bad) + + +# --------------------------------------------------------------------------- +# run_slash smoke tests (end-to-end via the same entry both CLI and gateway use) +# --------------------------------------------------------------------------- + +def test_run_slash_no_args_shows_usage(kanban_home): + out = kc.run_slash("") + assert "kanban" in out.lower() + assert "create" in out.lower() or "subcommand" in out.lower() or "action" in out.lower() + + +def test_run_slash_create_and_list(kanban_home): + out = kc.run_slash("create 'ship feature' --assignee alice") + assert "Created" in out + out = kc.run_slash("list") + assert "ship feature" in out + assert "alice" in out + + +def test_run_slash_create_with_parent_and_cascade(kanban_home): + # Parent then child via --parent + out1 = kc.run_slash("create 'parent' --assignee alice") + # Extract the "t_xxxx" id from "Created t_xxxx (ready, ...)" + import re + m = re.search(r"(t_[a-f0-9]+)", out1) + assert m + p = m.group(1) + out2 = kc.run_slash(f"create 'child' --assignee bob --parent {p}") + assert "todo" in out2 # child starts as todo + + # Complete parent; list should promote child to ready + kc.run_slash(f"complete {p}") + # Explicit filter: child should now be ready (was todo before complete). + ready_list = kc.run_slash("list --status ready") + assert "child" in ready_list + + +def test_run_slash_show_includes_comments(kanban_home): + out = kc.run_slash("create 'x'") + import re + tid = re.search(r"(t_[a-f0-9]+)", out).group(1) + kc.run_slash(f"comment {tid} 'source is paywalled'") + show = kc.run_slash(f"show {tid}") + assert "source is paywalled" in show + + +def test_run_slash_block_unblock_cycle(kanban_home): + out = kc.run_slash("create 'x' --assignee alice") + import re + tid = re.search(r"(t_[a-f0-9]+)", out).group(1) + # Claim first so block() finds it running + kc.run_slash(f"claim {tid}") + assert "Blocked" in kc.run_slash(f"block {tid} 'need decision'") + assert "Unblocked" in kc.run_slash(f"unblock {tid}") + + +def test_run_slash_json_output(kanban_home): + out = kc.run_slash("create 'jsontask' --assignee alice --json") + payload = json.loads(out) + assert payload["title"] == "jsontask" + assert payload["assignee"] == "alice" + assert payload["status"] == "ready" + + +def test_run_slash_dispatch_dry_run_counts(kanban_home): + kc.run_slash("create 'a' --assignee alice") + kc.run_slash("create 'b' --assignee bob") + out = kc.run_slash("dispatch --dry-run") + assert "Spawned:" in out + + +def test_run_slash_context_output_format(kanban_home): + out = kc.run_slash("create 'tech spec' --assignee alice --body 'write an RFC'") + import re + tid = re.search(r"(t_[a-f0-9]+)", out).group(1) + kc.run_slash(f"comment {tid} 'remember to include performance section'") + ctx = kc.run_slash(f"context {tid}") + assert "tech spec" in ctx + assert "write an RFC" in ctx + assert "performance section" in ctx + + +def test_run_slash_tenant_filter(kanban_home): + kc.run_slash("create 'biz-a task' --tenant biz-a --assignee alice") + kc.run_slash("create 'biz-b task' --tenant biz-b --assignee alice") + a = kc.run_slash("list --tenant biz-a") + b = kc.run_slash("list --tenant biz-b") + assert "biz-a task" in a and "biz-b task" not in a + assert "biz-b task" in b and "biz-a task" not in b + + +def test_run_slash_usage_error_returns_message(kanban_home): + # Missing required argument for create + out = kc.run_slash("create") + assert "usage" in out.lower() or "error" in out.lower() + + +def test_run_slash_assign_reassigns(kanban_home): + out = kc.run_slash("create 'x' --assignee alice") + import re + tid = re.search(r"(t_[a-f0-9]+)", out).group(1) + assert "Assigned" in kc.run_slash(f"assign {tid} bob") + show = kc.run_slash(f"show {tid}") + assert "bob" in show + + +def test_run_slash_link_unlink(kanban_home): + a = kc.run_slash("create 'a'") + b = kc.run_slash("create 'b'") + import re + ta = re.search(r"(t_[a-f0-9]+)", a).group(1) + tb = re.search(r"(t_[a-f0-9]+)", b).group(1) + assert "Linked" in kc.run_slash(f"link {ta} {tb}") + # After link, b is todo + show = kc.run_slash(f"show {tb}") + assert "todo" in show + assert "Unlinked" in kc.run_slash(f"unlink {ta} {tb}") + + +# --------------------------------------------------------------------------- +# Integration with the COMMAND_REGISTRY +# --------------------------------------------------------------------------- + +def test_kanban_is_resolvable(): + from hermes_cli.commands import resolve_command + + cmd = resolve_command("kanban") + assert cmd is not None + assert cmd.name == "kanban" + + +def test_kanban_bypasses_active_session_guard(): + from hermes_cli.commands import should_bypass_active_session + + assert should_bypass_active_session("kanban") + + +def test_kanban_in_autocomplete_table(): + from hermes_cli.commands import COMMANDS, SUBCOMMANDS + + assert "/kanban" in COMMANDS + subs = SUBCOMMANDS.get("/kanban") or [] + assert "create" in subs + assert "dispatch" in subs + + +def test_kanban_not_gateway_only(): + # kanban is available in BOTH CLI and gateway surfaces. + from hermes_cli.commands import COMMAND_REGISTRY + + cmd = next(c for c in COMMAND_REGISTRY if c.name == "kanban") + assert not cmd.cli_only + assert not cmd.gateway_only diff --git a/tests/hermes_cli/test_kanban_db.py b/tests/hermes_cli/test_kanban_db.py new file mode 100644 index 0000000000000..fcc6396be40b0 --- /dev/null +++ b/tests/hermes_cli/test_kanban_db.py @@ -0,0 +1,438 @@ +"""Tests for the Kanban DB layer (hermes_cli.kanban_db).""" + +from __future__ import annotations + +import concurrent.futures +import os +import time +from pathlib import Path + +import pytest + +from hermes_cli import kanban_db as kb + + +@pytest.fixture +def kanban_home(tmp_path, monkeypatch): + """Isolated HERMES_HOME with an empty kanban DB.""" + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + kb.init_db() + return home + + +# --------------------------------------------------------------------------- +# Schema / init +# --------------------------------------------------------------------------- + +def test_init_db_is_idempotent(kanban_home): + # Second call should not error or drop data. + with kb.connect() as conn: + kb.create_task(conn, title="persisted") + kb.init_db() + with kb.connect() as conn: + tasks = kb.list_tasks(conn) + assert len(tasks) == 1 + assert tasks[0].title == "persisted" + + +def test_init_creates_expected_tables(kanban_home): + with kb.connect() as conn: + rows = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" + ).fetchall() + names = {r["name"] for r in rows} + assert {"tasks", "task_links", "task_comments", "task_events"} <= names + + +# --------------------------------------------------------------------------- +# Task creation + status inference +# --------------------------------------------------------------------------- + +def test_create_task_no_parents_is_ready(kanban_home): + with kb.connect() as conn: + tid = kb.create_task(conn, title="ship it", assignee="alice") + t = kb.get_task(conn, tid) + assert t is not None + assert t.status == "ready" + assert t.assignee == "alice" + assert t.workspace_kind == "scratch" + + +def test_create_task_with_parent_is_todo_until_parent_done(kanban_home): + with kb.connect() as conn: + p = kb.create_task(conn, title="parent") + c = kb.create_task(conn, title="child", parents=[p]) + assert kb.get_task(conn, c).status == "todo" + kb.complete_task(conn, p, result="ok") + assert kb.get_task(conn, c).status == "ready" + + +def test_create_task_unknown_parent_errors(kanban_home): + with kb.connect() as conn, pytest.raises(ValueError, match="unknown parent"): + kb.create_task(conn, title="orphan", parents=["t_ghost"]) + + +def test_workspace_kind_validation(kanban_home): + with kb.connect() as conn, pytest.raises(ValueError, match="workspace_kind"): + kb.create_task(conn, title="bad ws", workspace_kind="cloud") + + +# --------------------------------------------------------------------------- +# Links + dependency resolution +# --------------------------------------------------------------------------- + +def test_link_demotes_ready_child_to_todo_when_parent_not_done(kanban_home): + with kb.connect() as conn: + a = kb.create_task(conn, title="a") + b = kb.create_task(conn, title="b") + assert kb.get_task(conn, b).status == "ready" + kb.link_tasks(conn, a, b) + assert kb.get_task(conn, b).status == "todo" + + +def test_link_keeps_ready_child_when_parent_already_done(kanban_home): + with kb.connect() as conn: + a = kb.create_task(conn, title="a") + kb.complete_task(conn, a) + b = kb.create_task(conn, title="b") + assert kb.get_task(conn, b).status == "ready" + kb.link_tasks(conn, a, b) + assert kb.get_task(conn, b).status == "ready" + + +def test_link_rejects_self_loop(kanban_home): + with kb.connect() as conn: + a = kb.create_task(conn, title="a") + with pytest.raises(ValueError, match="itself"): + kb.link_tasks(conn, a, a) + + +def test_link_detects_cycle(kanban_home): + with kb.connect() as conn: + a = kb.create_task(conn, title="a") + b = kb.create_task(conn, title="b", parents=[a]) + c = kb.create_task(conn, title="c", parents=[b]) + with pytest.raises(ValueError, match="cycle"): + kb.link_tasks(conn, c, a) + with pytest.raises(ValueError, match="cycle"): + kb.link_tasks(conn, b, a) + + +def test_recompute_ready_cascades_through_chain(kanban_home): + with kb.connect() as conn: + a = kb.create_task(conn, title="a") + b = kb.create_task(conn, title="b", parents=[a]) + c = kb.create_task(conn, title="c", parents=[b]) + assert [kb.get_task(conn, x).status for x in (a, b, c)] == \ + ["ready", "todo", "todo"] + kb.complete_task(conn, a) + assert kb.get_task(conn, b).status == "ready" + kb.complete_task(conn, b) + assert kb.get_task(conn, c).status == "ready" + + +def test_recompute_ready_fan_in_waits_for_all_parents(kanban_home): + with kb.connect() as conn: + a = kb.create_task(conn, title="a") + b = kb.create_task(conn, title="b") + c = kb.create_task(conn, title="c", parents=[a, b]) + kb.complete_task(conn, a) + assert kb.get_task(conn, c).status == "todo" + kb.complete_task(conn, b) + assert kb.get_task(conn, c).status == "ready" + + +# --------------------------------------------------------------------------- +# Atomic claim (CAS) +# --------------------------------------------------------------------------- + +def test_claim_once_wins_second_loses(kanban_home): + with kb.connect() as conn: + t = kb.create_task(conn, title="x", assignee="a") + first = kb.claim_task(conn, t, claimer="host:1") + assert first is not None and first.status == "running" + second = kb.claim_task(conn, t, claimer="host:2") + assert second is None + + +def test_claim_fails_on_non_ready(kanban_home): + with kb.connect() as conn: + t = kb.create_task(conn, title="x") + # Move to todo by introducing an unsatisfied parent. + p = kb.create_task(conn, title="p") + kb.link_tasks(conn, p, t) + assert kb.get_task(conn, t).status == "todo" + assert kb.claim_task(conn, t) is None + + +def test_stale_claim_reclaimed(kanban_home): + with kb.connect() as conn: + t = kb.create_task(conn, title="x", assignee="a") + kb.claim_task(conn, t) + # Rewind claim_expires so it looks stale. + conn.execute( + "UPDATE tasks SET claim_expires = ? WHERE id = ?", + (int(time.time()) - 3600, t), + ) + reclaimed = kb.release_stale_claims(conn) + assert reclaimed == 1 + assert kb.get_task(conn, t).status == "ready" + + +def test_heartbeat_extends_claim(kanban_home): + with kb.connect() as conn: + t = kb.create_task(conn, title="x", assignee="a") + claimer = "host:hb" + kb.claim_task(conn, t, claimer=claimer, ttl_seconds=60) + original = kb.get_task(conn, t).claim_expires + # Rewind then heartbeat. + conn.execute("UPDATE tasks SET claim_expires = ? WHERE id = ?", (0, t)) + ok = kb.heartbeat_claim(conn, t, claimer=claimer, ttl_seconds=3600) + assert ok + new = kb.get_task(conn, t).claim_expires + assert new > int(time.time()) + 3000 + + +def test_concurrent_claims_only_one_wins(kanban_home): + """Fire N threads claiming the same task; exactly one must win.""" + with kb.connect() as conn: + t = kb.create_task(conn, title="race", assignee="a") + + def attempt(i): + with kb.connect() as c: + return kb.claim_task(c, t, claimer=f"host:{i}") + + n_workers = 8 + with concurrent.futures.ThreadPoolExecutor(max_workers=n_workers) as ex: + results = list(ex.map(attempt, range(n_workers))) + winners = [r for r in results if r is not None] + assert len(winners) == 1 + assert winners[0].status == "running" + + +# --------------------------------------------------------------------------- +# Complete / block / unblock / archive / assign +# --------------------------------------------------------------------------- + +def test_complete_records_result(kanban_home): + with kb.connect() as conn: + t = kb.create_task(conn, title="x") + assert kb.complete_task(conn, t, result="done and dusted") + task = kb.get_task(conn, t) + assert task.status == "done" + assert task.result == "done and dusted" + assert task.completed_at is not None + + +def test_block_then_unblock(kanban_home): + with kb.connect() as conn: + t = kb.create_task(conn, title="x", assignee="a") + kb.claim_task(conn, t) + assert kb.block_task(conn, t, reason="need input") + assert kb.get_task(conn, t).status == "blocked" + assert kb.unblock_task(conn, t) + assert kb.get_task(conn, t).status == "ready" + + +def test_assign_refuses_while_running(kanban_home): + with kb.connect() as conn: + t = kb.create_task(conn, title="x", assignee="a") + kb.claim_task(conn, t) + with pytest.raises(RuntimeError, match="currently running"): + kb.assign_task(conn, t, "b") + + +def test_assign_reassigns_when_not_running(kanban_home): + with kb.connect() as conn: + t = kb.create_task(conn, title="x", assignee="a") + assert kb.assign_task(conn, t, "b") + assert kb.get_task(conn, t).assignee == "b" + + +def test_archive_hides_from_default_list(kanban_home): + with kb.connect() as conn: + t = kb.create_task(conn, title="x") + kb.complete_task(conn, t) + assert kb.archive_task(conn, t) + assert len(kb.list_tasks(conn)) == 0 + assert len(kb.list_tasks(conn, include_archived=True)) == 1 + + +# --------------------------------------------------------------------------- +# Comments / events / worker context +# --------------------------------------------------------------------------- + +def test_comments_recorded_in_order(kanban_home): + with kb.connect() as conn: + t = kb.create_task(conn, title="x") + kb.add_comment(conn, t, "user", "first") + kb.add_comment(conn, t, "researcher", "second") + comments = kb.list_comments(conn, t) + assert [c.body for c in comments] == ["first", "second"] + assert [c.author for c in comments] == ["user", "researcher"] + + +def test_empty_comment_rejected(kanban_home): + with kb.connect() as conn: + t = kb.create_task(conn, title="x") + with pytest.raises(ValueError, match="body is required"): + kb.add_comment(conn, t, "user", "") + + +def test_events_capture_lifecycle(kanban_home): + with kb.connect() as conn: + t = kb.create_task(conn, title="x", assignee="a") + kb.claim_task(conn, t) + kb.complete_task(conn, t, result="ok") + events = kb.list_events(conn, t) + kinds = [e.kind for e in events] + assert "created" in kinds + assert "claimed" in kinds + assert "completed" in kinds + + +def test_worker_context_includes_parent_results_and_comments(kanban_home): + with kb.connect() as conn: + p = kb.create_task(conn, title="p") + kb.complete_task(conn, p, result="PARENT_RESULT_MARKER") + c = kb.create_task(conn, title="child", parents=[p]) + kb.add_comment(conn, c, "user", "CLARIFICATION_MARKER") + ctx = kb.build_worker_context(conn, c) + assert "PARENT_RESULT_MARKER" in ctx + assert "CLARIFICATION_MARKER" in ctx + assert c in ctx + assert "child" in ctx + + +# --------------------------------------------------------------------------- +# Dispatcher +# --------------------------------------------------------------------------- + +def test_dispatch_dry_run_does_not_claim(kanban_home): + with kb.connect() as conn: + t1 = kb.create_task(conn, title="a", assignee="alice") + t2 = kb.create_task(conn, title="b", assignee="bob") + res = kb.dispatch_once(conn, dry_run=True) + assert {s[0] for s in res.spawned} == {t1, t2} + with kb.connect() as conn: + # Dry run must NOT mutate status. + assert kb.get_task(conn, t1).status == "ready" + assert kb.get_task(conn, t2).status == "ready" + + +def test_dispatch_skips_unassigned(kanban_home): + with kb.connect() as conn: + t = kb.create_task(conn, title="floater") + res = kb.dispatch_once(conn, dry_run=True) + assert t in res.skipped_unassigned + assert not res.spawned + + +def test_dispatch_promotes_ready_and_spawns(kanban_home): + spawns = [] + + def fake_spawn(task, workspace): + spawns.append((task.id, task.assignee, workspace)) + + with kb.connect() as conn: + p = kb.create_task(conn, title="p", assignee="alice") + c = kb.create_task(conn, title="c", assignee="bob", parents=[p]) + # Finish parent outside dispatch; promotion happens inside. + kb.complete_task(conn, p) + res = kb.dispatch_once(conn, spawn_fn=fake_spawn) + # Spawned c (a was already done when dispatch was called). + assert len(spawns) == 1 + assert spawns[0][0] == c + assert spawns[0][1] == "bob" + # c is now running + with kb.connect() as conn: + assert kb.get_task(conn, c).status == "running" + + +def test_dispatch_spawn_failure_releases_claim(kanban_home): + def boom(task, workspace): + raise RuntimeError("spawn failed") + + with kb.connect() as conn: + t = kb.create_task(conn, title="boom", assignee="alice") + kb.dispatch_once(conn, spawn_fn=boom) + # Must return to ready so the next tick can retry. + assert kb.get_task(conn, t).status == "ready" + assert kb.get_task(conn, t).claim_lock is None + + +def test_dispatch_reclaims_stale_before_spawning(kanban_home): + with kb.connect() as conn: + t = kb.create_task(conn, title="x", assignee="alice") + kb.claim_task(conn, t) + conn.execute( + "UPDATE tasks SET claim_expires = ? WHERE id = ?", + (int(time.time()) - 1, t), + ) + res = kb.dispatch_once(conn, dry_run=True) + assert res.reclaimed == 1 + + +# --------------------------------------------------------------------------- +# Workspace resolution +# --------------------------------------------------------------------------- + +def test_scratch_workspace_created_under_hermes_home(kanban_home): + with kb.connect() as conn: + t = kb.create_task(conn, title="x") + task = kb.get_task(conn, t) + ws = kb.resolve_workspace(task) + assert ws.exists() + assert ws.is_dir() + assert "kanban" in str(ws) + + +def test_dir_workspace_honors_given_path(kanban_home, tmp_path): + target = tmp_path / "my-vault" + with kb.connect() as conn: + t = kb.create_task( + conn, title="biz", workspace_kind="dir", workspace_path=str(target) + ) + task = kb.get_task(conn, t) + ws = kb.resolve_workspace(task) + assert ws == target + assert ws.exists() + + +def test_worktree_workspace_returns_intended_path(kanban_home, tmp_path): + target = str(tmp_path / ".worktrees" / "my-task") + with kb.connect() as conn: + t = kb.create_task( + conn, title="ship", workspace_kind="worktree", workspace_path=target + ) + task = kb.get_task(conn, t) + ws = kb.resolve_workspace(task) + # We do NOT auto-create worktrees; the worker's skill handles that. + assert str(ws) == target + + +# --------------------------------------------------------------------------- +# Tenancy +# --------------------------------------------------------------------------- + +def test_tenant_column_filters_listings(kanban_home): + with kb.connect() as conn: + kb.create_task(conn, title="a1", tenant="biz-a") + kb.create_task(conn, title="b1", tenant="biz-b") + kb.create_task(conn, title="shared") # no tenant + biz_a = kb.list_tasks(conn, tenant="biz-a") + biz_b = kb.list_tasks(conn, tenant="biz-b") + assert [t.title for t in biz_a] == ["a1"] + assert [t.title for t in biz_b] == ["b1"] + + +def test_tenant_propagates_to_events(kanban_home): + with kb.connect() as conn: + t = kb.create_task(conn, title="tenant-task", tenant="biz-a") + events = kb.list_events(conn, t) + # The "created" event should have tenant in its payload. + created = [e for e in events if e.kind == "created"] + assert created and created[0].payload.get("tenant") == "biz-a" diff --git a/tests/hermes_cli/test_kanban_enhancements.py b/tests/hermes_cli/test_kanban_enhancements.py new file mode 100644 index 0000000000000..91409e2d20bbf --- /dev/null +++ b/tests/hermes_cli/test_kanban_enhancements.py @@ -0,0 +1,406 @@ +"""Additional edge-case tests for the Kanban DB layer. + +These tests cover: +- Priority ordering verification +- Task ID generation uniqueness +- Cross-tenant isolation +- Edge cases for task completion and claim lifecycle +""" + +from __future__ import annotations + +import concurrent.futures +import os +import time +from pathlib import Path + +import pytest + +from hermes_cli import kanban_db as kb + + +@pytest.fixture +def kanban_home(tmp_path, monkeypatch): + """Isolated HERMES_HOME with an empty kanban DB.""" + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + kb.init_db() + return home + + +# --------------------------------------------------------------------------- +# Priority ordering tests +# --------------------------------------------------------------------------- + +def test_list_tasks_respects_priority_order(kanban_home): + """Verify that list_tasks returns tasks ordered by priority DESC.""" + with kb.connect() as conn: + kb.create_task(conn, title="low", priority=1) + kb.create_task(conn, title="high", priority=100) + kb.create_task(conn, title="medium", priority=50) + tasks = kb.list_tasks(conn) + assert [t.title for t in tasks] == ["high", "medium", "low"] + + +def test_list_tasks_respects_priority_then_created_at(kanban_home): + """Same priority should be ordered by created_at ASC.""" + with kb.connect() as conn: + t1 = kb.create_task(conn, title="first", priority=10) + time.sleep(0.01) # Ensure different timestamps + t2 = kb.create_task(conn, title="second", priority=10) + time.sleep(0.01) + t3 = kb.create_task(conn, title="third", priority=10) + tasks = kb.list_tasks(conn) + assert [t.id for t in tasks] == [t1, t2, t3] + + +def test_priority_update_affects_list_order(kanban_home): + """Updating a task's priority should affect list ordering.""" + with kb.connect() as conn: + low = kb.create_task(conn, title="low", priority=1) + high = kb.create_task(conn, title="high", priority=100) + # Initially high comes first + assert kb.list_tasks(conn)[0].title == "high" + # Update low to be higher priority + conn.execute("UPDATE tasks SET priority = 200 WHERE id = ?", (low,)) + tasks = kb.list_tasks(conn) + assert tasks[0].title == "low" + assert tasks[1].title == "high" + + +# --------------------------------------------------------------------------- +# Task ID generation uniqueness tests +# --------------------------------------------------------------------------- + +def test_task_id_format_is_correct(kanban_home): + """Verify task IDs follow the t_<4 hex chars> format.""" + with kb.connect() as conn: + tid = kb.create_task(conn, title="test") + assert tid.startswith("t_") + assert len(tid) == 6 # "t_" + 4 hex chars + # Verify it's valid hex + hex_part = tid[2:] + int(hex_part, 16) # Will raise ValueError if not valid hex + + +def test_concurrent_task_creation_no_collision(kanban_home): + """Concurrent task creation should not generate duplicate IDs.""" + ids = [] + + def create_one(): + with kb.connect() as conn: + return kb.create_task(conn, title="concurrent") + + with concurrent.futures.ThreadPoolExecutor(max_workers=16) as ex: + ids = list(ex.map(lambda _: create_one(), range(50))) + + # All IDs should be unique + assert len(ids) == len(set(ids)), "Duplicate task IDs generated!" + + +# --------------------------------------------------------------------------- +# Cross-tenant isolation tests +# --------------------------------------------------------------------------- + +def test_tenant_isolation_in_list_tasks(kanban_home): + """Tasks with different tenants should not mix in filtered results.""" + with kb.connect() as conn: + kb.create_task(conn, title="tenant-a-1", tenant="A") + kb.create_task(conn, title="tenant-a-2", tenant="A") + kb.create_task(conn, title="tenant-b-1", tenant="B") + kb.create_task(conn, title="tenant-b-2", tenant="B") + kb.create_task(conn, title="no-tenant") + + tasks_a = kb.list_tasks(conn, tenant="A") + tasks_b = kb.list_tasks(conn, tenant="B") + tasks_all = kb.list_tasks(conn) # No filter = sees all non-archived + + assert len(tasks_a) == 2 + assert all(t.tenant == "A" for t in tasks_a) + assert len(tasks_b) == 2 + assert all(t.tenant == "B" for t in tasks_b) + # Default list excludes archived but includes all tenants + assert len(tasks_all) == 5 + + +def test_tenant_isolation_in_dispatch(kanban_home): + """Dispatch should respect tenant boundaries when filtering.""" + spawns = [] + + def fake_spawn(task, workspace): + spawns.append((task.id, task.tenant)) + + with kb.connect() as conn: + # Create tasks in different tenants + kb.create_task(conn, title="task-a", tenant="A", assignee="alice") + kb.create_task(conn, title="task-b", tenant="B", assignee="bob") + + # Dispatch should spawn both (no tenant filter in dispatch_once) + kb.dispatch_once(conn, spawn_fn=fake_spawn, tenant=None) + + # Both should spawn + assert len(spawns) == 2 + + +# --------------------------------------------------------------------------- +# Task completion edge cases +# --------------------------------------------------------------------------- + +def test_complete_task_with_empty_result(kanban_home): + """Completing a task with no result should work.""" + with kb.connect() as conn: + t = kb.create_task(conn, title="no-result") + assert kb.complete_task(conn, t) + task = kb.get_task(conn, t) + assert task.status == "done" + assert task.result is None + + +def test_complete_task_already_done_returns_false(kanban_home): + """Completing an already-done task should return False.""" + with kb.connect() as conn: + t = kb.create_task(conn, title="done-already") + kb.complete_task(conn, t) + # Try to complete again + assert not kb.complete_task(conn, t) + + +def test_complete_archived_task_returns_false(kanban_home): + """Completing an archived task should return False.""" + with kb.connect() as conn: + t = kb.create_task(conn, title="archived-task") + kb.archive_task(conn, t) + assert not kb.complete_task(conn, t) + + +def test_complete_task_triggers_child_promotion(kanban_home): + """Completing a parent should promote children to ready.""" + with kb.connect() as conn: + p = kb.create_task(conn, title="parent") + c = kb.create_task(conn, title="child", parents=[p]) + + # Child starts as todo + assert kb.get_task(conn, c).status == "todo" + + # Complete parent + kb.complete_task(conn, p) + + # Child should now be ready + assert kb.get_task(conn, c).status == "ready" + + +# --------------------------------------------------------------------------- +# Claim lifecycle edge cases +# --------------------------------------------------------------------------- + +def test_claim_task_already_claimed_by_same_claimer(kanban_home): + """Same claimer trying to claim again should fail (already running).""" + with kb.connect() as conn: + t = kb.create_task(conn, title="x", assignee="a") + claimer = "host:test" + + first = kb.claim_task(conn, t, claimer=claimer) + assert first is not None + + # Same claimer trying again should fail (task is running, not ready) + second = kb.claim_task(conn, t, claimer=claimer) + assert second is None + + +def test_claim_task_after_release(kanban_home): + """A task can be claimed again after being released.""" + with kb.connect() as conn: + t = kb.create_task(conn, title="x", assignee="a") + claimer = "host:test" + + # First claim + first = kb.claim_task(conn, t, claimer=claimer) + assert first is not None + + # Release (simulate via completing) + kb.complete_task(conn, t) + + # Re-create and claim again + t2 = kb.create_task(conn, title="x2", assignee="a") + second = kb.claim_task(conn, t2, claimer=claimer) + assert second is not None + + +def test_heartbeat_wrong_claimer_fails(kanban_home): + """Heartbeat with wrong claimer should return False.""" + with kb.connect() as conn: + t = kb.create_task(conn, title="x", assignee="a") + + # Claim with one ID + kb.claim_task(conn, t, claimer="host:right") + + # Heartbeat with different ID + ok = kb.heartbeat_claim(conn, t, claimer="host:wrong") + assert not ok + + +def test_claim_task_with_custom_ttl(kanban_home): + """Claim task should respect custom TTL.""" + with kb.connect() as conn: + t = kb.create_task(conn, title="x", assignee="a") + claimer = "host:ttl" + + kb.claim_task(conn, t, claimer=claimer, ttl_seconds=300) + task = kb.get_task(conn, t) + + # claim_expires should be approximately now + 300 + expected_min = int(time.time()) + 299 + expected_max = int(time.time()) + 301 + assert expected_min <= task.claim_expires <= expected_max + + +# --------------------------------------------------------------------------- +# Link edge cases +# --------------------------------------------------------------------------- + +def test_unlink_nonexistent_link_returns_false(kanban_home): + """Unlinking tasks that aren't linked should return False.""" + with kb.connect() as conn: + a = kb.create_task(conn, title="a") + b = kb.create_task(conn, title="b") + + # They were never linked + result = kb.unlink_tasks(conn, a, b) + assert result is False + + +def test_unlink_then_relink(kanban_home): + """Can unlink and then relink tasks.""" + with kb.connect() as conn: + a = kb.create_task(conn, title="a") + b = kb.create_task(conn, title="b") + + # Link them + kb.link_tasks(conn, a, b) + assert kb.child_ids(conn, a) == [b] + + # Unlink + assert kb.unlink_tasks(conn, a, b) + assert kb.child_ids(conn, a) == [] + + # Relink + kb.link_tasks(conn, a, b) + assert kb.child_ids(conn, a) == [b] + + +def test_link_idempotent(kanban_home): + """Linking already-linked tasks should be a no-op (not an error).""" + with kb.connect() as conn: + a = kb.create_task(conn, title="a") + b = kb.create_task(conn, title="b") + + kb.link_tasks(conn, a, b) + # Linking again should not error + kb.link_tasks(conn, a, b) + + # Should still only have one link + assert kb.child_ids(conn, a) == [b] + + +# --------------------------------------------------------------------------- +# Event logging edge cases +# --------------------------------------------------------------------------- + +def test_events_preserve_payload_in_json(kanban_home): + """Event payloads with special characters should be preserved.""" + with kb.connect() as conn: + t = kb.create_task(conn, title="special chars", assignee="alice") + + # Complete with result containing special chars + special_result = 'Result with "quotes" and chars: \n\\' + kb.complete_task(conn, t, result=special_result) + + events = kb.list_events(conn, t) + completed_events = [e for e in events if e.kind == "completed"] + + assert len(completed_events) == 1 + # The payload should have result_len + assert completed_events[0].payload is not None + + +def test_created_event_has_all_fields(kanban_home): + """The 'created' event should contain all relevant metadata.""" + with kb.connect() as conn: + t = kb.create_task( + conn, + title="metadata test", + body="A body", + assignee="alice", + tenant="biz-a", + priority=5, + ) + + events = kb.list_events(conn, t) + created = [e for e in events if e.kind == "created"][0] + + assert created.payload["assignee"] == "alice" + assert created.payload["status"] == "ready" + assert created.payload["tenant"] == "biz-a" + assert created.payload["parents"] == [] + + +# --------------------------------------------------------------------------- +# Archive lifecycle tests +# --------------------------------------------------------------------------- + +def test_archive_completed_task(kanban_home): + """Archiving a completed task should work.""" + with kb.connect() as conn: + t = kb.create_task(conn, title="to archive") + kb.complete_task(conn, t) + + assert kb.archive_task(conn, t) + task = kb.get_task(conn, t) + assert task.status == "archived" + + +def test_archive_running_task_fails(kanban_home): + """Archiving a running (claimed) task should return False.""" + with kb.connect() as conn: + t = kb.create_task(conn, title="running", assignee="a") + kb.claim_task(conn, t) + + # Can't archive a running task + assert not kb.archive_task(conn, t) + assert kb.get_task(conn, t).status == "running" + + +def test_archive_already_archived_returns_false(kanban_home): + """Archiving an already archived task should return False.""" + with kb.connect() as conn: + t = kb.create_task(conn, title="double archive") + kb.archive_task(conn, t) + + # Archive again should return False + assert not kb.archive_task(conn, t) + + +# --------------------------------------------------------------------------- +# Status transition validation +# --------------------------------------------------------------------------- + +def test_block_only_works_on_running_or_ready(kanban_home): + """Block should only work on running or ready tasks.""" + with kb.connect() as conn: + # Task is ready + t = kb.create_task(conn, title="ready-task", assignee="a") + assert kb.block_task(conn, t) # Can block ready + + # Unblock to ready + kb.unblock_task(conn, t) + + # Claim it + kb.claim_task(conn, t) + assert kb.block_task(conn, t) # Can block running + + # Reset and try on done task + t2 = kb.create_task(conn, title="done-task") + kb.complete_task(conn, t2) + assert not kb.block_task(conn, t2) # Can't block done diff --git a/tests/plugins/test_kanban_dashboard_plugin.py b/tests/plugins/test_kanban_dashboard_plugin.py new file mode 100644 index 0000000000000..6b45688d6e1e7 --- /dev/null +++ b/tests/plugins/test_kanban_dashboard_plugin.py @@ -0,0 +1,629 @@ +"""Tests for the Kanban dashboard plugin backend (plugins/kanban/dashboard/plugin_api.py). + +The plugin mounts as /api/plugins/kanban/ inside the dashboard's FastAPI app, +but here we attach its router to a bare FastAPI instance so we can test the +REST surface without spinning up the whole dashboard. +""" + +from __future__ import annotations + +import importlib.util +import os +import sys +import time +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from hermes_cli import kanban_db as kb + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +def _load_plugin_router(): + """Dynamically load plugins/kanban/dashboard/plugin_api.py and return its router.""" + repo_root = Path(__file__).resolve().parents[2] + plugin_file = repo_root / "plugins" / "kanban" / "dashboard" / "plugin_api.py" + assert plugin_file.exists(), f"plugin file missing: {plugin_file}" + + spec = importlib.util.spec_from_file_location( + "hermes_dashboard_plugin_kanban_test", plugin_file, + ) + assert spec is not None and spec.loader is not None + mod = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = mod + spec.loader.exec_module(mod) + return mod.router + + +@pytest.fixture +def kanban_home(tmp_path, monkeypatch): + """Isolated HERMES_HOME with an empty kanban DB.""" + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + kb.init_db() + return home + + +@pytest.fixture +def client(kanban_home): + app = FastAPI() + app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban") + return TestClient(app) + + +# --------------------------------------------------------------------------- +# GET /board on an empty DB +# --------------------------------------------------------------------------- + + +def test_board_empty(client): + r = client.get("/api/plugins/kanban/board") + assert r.status_code == 200 + data = r.json() + # All canonical columns present (triage + the rest), each empty. + names = [c["name"] for c in data["columns"]] + for expected in ("triage", "todo", "ready", "running", "blocked", "done"): + assert expected in names, f"missing column {expected}: {names}" + assert all(len(c["tasks"]) == 0 for c in data["columns"]) + assert data["tenants"] == [] + assert data["assignees"] == [] + assert data["latest_event_id"] == 0 + + +# --------------------------------------------------------------------------- +# POST /tasks then GET /board sees it +# --------------------------------------------------------------------------- + + +def test_create_task_appears_on_board(client): + r = client.post( + "/api/plugins/kanban/tasks", + json={ + "title": "Research LLM caching", + "assignee": "researcher", + "priority": 3, + "tenant": "acme", + }, + ) + assert r.status_code == 200, r.text + task = r.json()["task"] + assert task["title"] == "Research LLM caching" + assert task["assignee"] == "researcher" + assert task["status"] == "ready" # no parents -> immediately ready + assert task["priority"] == 3 + assert task["tenant"] == "acme" + task_id = task["id"] + + # Board now lists it under 'ready'. + r = client.get("/api/plugins/kanban/board") + assert r.status_code == 200 + data = r.json() + ready = next(c for c in data["columns"] if c["name"] == "ready") + assert len(ready["tasks"]) == 1 + assert ready["tasks"][0]["id"] == task_id + assert "acme" in data["tenants"] + assert "researcher" in data["assignees"] + + +def test_tenant_filter(client): + client.post("/api/plugins/kanban/tasks", json={"title": "A", "tenant": "t1"}) + client.post("/api/plugins/kanban/tasks", json={"title": "B", "tenant": "t2"}) + + r = client.get("/api/plugins/kanban/board?tenant=t1") + counts = {c["name"]: len(c["tasks"]) for c in r.json()["columns"]} + total = sum(counts.values()) + assert total == 1 + + r = client.get("/api/plugins/kanban/board?tenant=t2") + total = sum(len(c["tasks"]) for c in r.json()["columns"]) + assert total == 1 + + +# --------------------------------------------------------------------------- +# GET /tasks/:id returns body + comments + events + links +# --------------------------------------------------------------------------- + + +def test_task_detail_includes_links_and_events(client): + parent = client.post( + "/api/plugins/kanban/tasks", json={"title": "parent"}, + ).json()["task"] + child = client.post( + "/api/plugins/kanban/tasks", + json={"title": "child", "parents": [parent["id"]]}, + ).json()["task"] + assert child["status"] == "todo" # parent not done yet + + # Detail for the child shows the parent link. + r = client.get(f"/api/plugins/kanban/tasks/{child['id']}") + assert r.status_code == 200 + data = r.json() + assert data["task"]["id"] == child["id"] + assert parent["id"] in data["links"]["parents"] + + # Detail for the parent shows the child. + r = client.get(f"/api/plugins/kanban/tasks/{parent['id']}") + assert child["id"] in r.json()["links"]["children"] + + # Events exist from creation. + assert len(data["events"]) >= 1 + + +def test_task_detail_404_on_unknown(client): + r = client.get("/api/plugins/kanban/tasks/does-not-exist") + assert r.status_code == 404 + + +# --------------------------------------------------------------------------- +# PATCH /tasks/:id — status transitions +# --------------------------------------------------------------------------- + + +def test_patch_status_complete(client): + t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"] + r = client.patch( + f"/api/plugins/kanban/tasks/{t['id']}", + json={"status": "done", "result": "shipped"}, + ) + assert r.status_code == 200 + assert r.json()["task"]["status"] == "done" + + # Board reflects the move. + done = next( + c for c in client.get("/api/plugins/kanban/board").json()["columns"] + if c["name"] == "done" + ) + assert any(x["id"] == t["id"] for x in done["tasks"]) + + +def test_patch_block_then_unblock(client): + t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"] + r = client.patch( + f"/api/plugins/kanban/tasks/{t['id']}", + json={"status": "blocked", "block_reason": "need input"}, + ) + assert r.status_code == 200 + assert r.json()["task"]["status"] == "blocked" + + r = client.patch( + f"/api/plugins/kanban/tasks/{t['id']}", + json={"status": "ready"}, + ) + assert r.status_code == 200 + assert r.json()["task"]["status"] == "ready" + + +def test_patch_drag_drop_move_todo_to_ready(client): + """Direct status write: the drag-drop path for statuses without a + dedicated verb (e.g. manually promoting todo -> ready).""" + parent = client.post("/api/plugins/kanban/tasks", json={"title": "p"}).json()["task"] + child = client.post( + "/api/plugins/kanban/tasks", + json={"title": "c", "parents": [parent["id"]]}, + ).json()["task"] + assert child["status"] == "todo" + + r = client.patch( + f"/api/plugins/kanban/tasks/{child['id']}", + json={"status": "ready"}, + ) + assert r.status_code == 200 + assert r.json()["task"]["status"] == "ready" + + +def test_patch_reassign(client): + t = client.post( + "/api/plugins/kanban/tasks", + json={"title": "x", "assignee": "a"}, + ).json()["task"] + r = client.patch( + f"/api/plugins/kanban/tasks/{t['id']}", + json={"assignee": "b"}, + ) + assert r.status_code == 200 + assert r.json()["task"]["assignee"] == "b" + + +def test_patch_priority_and_edit(client): + t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"] + r = client.patch( + f"/api/plugins/kanban/tasks/{t['id']}", + json={"priority": 5, "title": "renamed"}, + ) + assert r.status_code == 200 + data = r.json()["task"] + assert data["priority"] == 5 + assert data["title"] == "renamed" + + +def test_patch_invalid_status(client): + t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"] + r = client.patch( + f"/api/plugins/kanban/tasks/{t['id']}", + json={"status": "banana"}, + ) + assert r.status_code == 400 + + +# --------------------------------------------------------------------------- +# Comments + Links +# --------------------------------------------------------------------------- + + +def test_add_comment(client): + t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"] + r = client.post( + f"/api/plugins/kanban/tasks/{t['id']}/comments", + json={"body": "how's progress?", "author": "teknium"}, + ) + assert r.status_code == 200 + + r = client.get(f"/api/plugins/kanban/tasks/{t['id']}") + comments = r.json()["comments"] + assert len(comments) == 1 + assert comments[0]["body"] == "how's progress?" + assert comments[0]["author"] == "teknium" + + +def test_add_comment_empty_rejected(client): + t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"] + r = client.post( + f"/api/plugins/kanban/tasks/{t['id']}/comments", + json={"body": " "}, + ) + assert r.status_code == 400 + + +def test_add_link_and_delete_link(client): + a = client.post("/api/plugins/kanban/tasks", json={"title": "a"}).json()["task"] + b = client.post("/api/plugins/kanban/tasks", json={"title": "b"}).json()["task"] + + r = client.post( + "/api/plugins/kanban/links", + json={"parent_id": a["id"], "child_id": b["id"]}, + ) + assert r.status_code == 200 + + r = client.get(f"/api/plugins/kanban/tasks/{b['id']}") + assert a["id"] in r.json()["links"]["parents"] + + r = client.delete( + "/api/plugins/kanban/links", + params={"parent_id": a["id"], "child_id": b["id"]}, + ) + assert r.status_code == 200 + assert r.json()["ok"] is True + + +def test_add_link_cycle_rejected(client): + a = client.post("/api/plugins/kanban/tasks", json={"title": "a"}).json()["task"] + b = client.post("/api/plugins/kanban/tasks", json={"title": "b"}).json()["task"] + client.post( + "/api/plugins/kanban/links", + json={"parent_id": a["id"], "child_id": b["id"]}, + ) + r = client.post( + "/api/plugins/kanban/links", + json={"parent_id": b["id"], "child_id": a["id"]}, + ) + assert r.status_code == 400 + + +# --------------------------------------------------------------------------- +# Dispatch nudge +# --------------------------------------------------------------------------- + + +def test_dispatch_dry_run(client): + client.post( + "/api/plugins/kanban/tasks", + json={"title": "work", "assignee": "researcher"}, + ) + r = client.post("/api/plugins/kanban/dispatch?dry_run=true&max=4") + assert r.status_code == 200 + body = r.json() + # DispatchResult is serialized as a dataclass dict. + assert isinstance(body, dict) + + +# --------------------------------------------------------------------------- +# Triage column (new v1 status) +# --------------------------------------------------------------------------- + + +def test_create_triage_lands_in_triage_column(client): + r = client.post( + "/api/plugins/kanban/tasks", + json={"title": "rough idea, spec me", "triage": True}, + ) + assert r.status_code == 200 + task = r.json()["task"] + assert task["status"] == "triage" + + r = client.get("/api/plugins/kanban/board") + triage = next(c for c in r.json()["columns"] if c["name"] == "triage") + assert len(triage["tasks"]) == 1 + assert triage["tasks"][0]["title"] == "rough idea, spec me" + + +def test_triage_task_not_promoted_to_ready(client): + """Triage tasks must stay in triage even when they have no parents.""" + client.post( + "/api/plugins/kanban/tasks", + json={"title": "must stay put", "triage": True}, + ) + # Run the dispatcher — it should NOT promote the triage task. + client.post("/api/plugins/kanban/dispatch?dry_run=false&max=4") + r = client.get("/api/plugins/kanban/board") + triage = next(c for c in r.json()["columns"] if c["name"] == "triage") + ready = next(c for c in r.json()["columns"] if c["name"] == "ready") + assert len(triage["tasks"]) == 1 + assert len(ready["tasks"]) == 0 + + +def test_patch_status_triage_works(client): + """A user (or specifier) can push a task back into triage, and out of it.""" + t = client.post( + "/api/plugins/kanban/tasks", json={"title": "x"}, + ).json()["task"] + # Normal creation is 'ready'; push to triage. + r = client.patch( + f"/api/plugins/kanban/tasks/{t['id']}", json={"status": "triage"}, + ) + assert r.status_code == 200 + assert r.json()["task"]["status"] == "triage" + + # Now promote to todo. + r = client.patch( + f"/api/plugins/kanban/tasks/{t['id']}", json={"status": "todo"}, + ) + assert r.status_code == 200 + assert r.json()["task"]["status"] == "todo" + + +# --------------------------------------------------------------------------- +# Progress rollup (done children / total children) +# --------------------------------------------------------------------------- + + +def test_board_progress_rollup(client): + parent = client.post( + "/api/plugins/kanban/tasks", json={"title": "parent"}, + ).json()["task"] + child_a = client.post( + "/api/plugins/kanban/tasks", + json={"title": "a", "parents": [parent["id"]]}, + ).json()["task"] + child_b = client.post( + "/api/plugins/kanban/tasks", + json={"title": "b", "parents": [parent["id"]]}, + ).json()["task"] + # Children start as "todo" because the parent isn't done yet; promote + # them to "ready" so complete_task will accept the transition. + for cid in (child_a["id"], child_b["id"]): + r = client.patch( + f"/api/plugins/kanban/tasks/{cid}", json={"status": "ready"}, + ) + assert r.status_code == 200 + + # 0/2 done. + r = client.get("/api/plugins/kanban/board") + parent_row = next( + t for col in r.json()["columns"] for t in col["tasks"] + if t["id"] == parent["id"] + ) + assert parent_row["progress"] == {"done": 0, "total": 2} + + # Complete one child. 1/2. + r = client.patch( + f"/api/plugins/kanban/tasks/{child_a['id']}", + json={"status": "done"}, + ) + assert r.status_code == 200 + r = client.get("/api/plugins/kanban/board") + parent_row = next( + t for col in r.json()["columns"] for t in col["tasks"] + if t["id"] == parent["id"] + ) + assert parent_row["progress"] == {"done": 1, "total": 2} + + # Childless tasks report progress=None, not {0/0}. + assert next( + t for col in r.json()["columns"] for t in col["tasks"] + if t["id"] == child_b["id"] + )["progress"] is None + + +# --------------------------------------------------------------------------- +# Auto-init on first board read +# --------------------------------------------------------------------------- + + +def test_board_auto_initializes_missing_db(tmp_path, monkeypatch): + """If kanban.db doesn't exist yet, GET /board must create it, not 500.""" + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + # Deliberately DO NOT call kb.init_db(). + + app = FastAPI() + app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban") + c = TestClient(app) + r = c.get("/api/plugins/kanban/board") + assert r.status_code == 200 + assert (home / "kanban.db").exists(), "init_db wasn't invoked by /board" + + +# --------------------------------------------------------------------------- +# WebSocket auth (query-param token) +# --------------------------------------------------------------------------- + + +def test_ws_events_rejects_when_token_required(tmp_path, monkeypatch): + """When _SESSION_TOKEN is set (normal dashboard context), a missing or + wrong ?token= query param must be rejected with policy-violation.""" + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + kb.init_db() + + # Stub web_server so _check_ws_token has a token to compare against. + import types + stub = types.SimpleNamespace(_SESSION_TOKEN="secret-xyz") + monkeypatch.setitem(sys.modules, "hermes_cli.web_server", stub) + + app = FastAPI() + app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban") + c = TestClient(app) + + # No token → policy violation close. + from starlette.websockets import WebSocketDisconnect + with pytest.raises(WebSocketDisconnect) as exc: + with c.websocket_connect("/api/plugins/kanban/events"): + pass + assert exc.value.code == 1008 + + # Wrong token → policy violation close. + with pytest.raises(WebSocketDisconnect) as exc: + with c.websocket_connect("/api/plugins/kanban/events?token=nope"): + pass + assert exc.value.code == 1008 + + # Correct token → accepted (connect then close cleanly from our side). + with c.websocket_connect( + "/api/plugins/kanban/events?token=secret-xyz" + ) as ws: + assert ws is not None # handshake succeeded + + +# --------------------------------------------------------------------------- +# Bulk actions +# --------------------------------------------------------------------------- + + +def test_bulk_status_ready(client): + a = client.post("/api/plugins/kanban/tasks", json={"title": "a"}).json()["task"] + b = client.post("/api/plugins/kanban/tasks", json={"title": "b"}).json()["task"] + c2 = client.post("/api/plugins/kanban/tasks", json={"title": "c"}).json()["task"] + # Parent-less tasks land in "ready" already; push them to blocked first. + for tid in (a["id"], b["id"], c2["id"]): + client.patch(f"/api/plugins/kanban/tasks/{tid}", + json={"status": "blocked", "block_reason": "wait"}) + + r = client.post("/api/plugins/kanban/tasks/bulk", + json={"ids": [a["id"], b["id"], c2["id"]], "status": "ready"}) + assert r.status_code == 200 + results = r.json()["results"] + assert all(r["ok"] for r in results) + # All three are now ready. + board = client.get("/api/plugins/kanban/board").json() + ready = next(col for col in board["columns"] if col["name"] == "ready") + ids = {t["id"] for t in ready["tasks"]} + assert {a["id"], b["id"], c2["id"]}.issubset(ids) + + +def test_bulk_archive(client): + a = client.post("/api/plugins/kanban/tasks", json={"title": "a"}).json()["task"] + b = client.post("/api/plugins/kanban/tasks", json={"title": "b"}).json()["task"] + r = client.post("/api/plugins/kanban/tasks/bulk", + json={"ids": [a["id"], b["id"]], "archive": True}) + assert r.status_code == 200 + assert all(r["ok"] for r in r.json()["results"]) + # Default board (archived hidden) — both gone. + board = client.get("/api/plugins/kanban/board").json() + ids = {t["id"] for col in board["columns"] for t in col["tasks"]} + assert a["id"] not in ids + assert b["id"] not in ids + + +def test_bulk_reassign(client): + a = client.post("/api/plugins/kanban/tasks", + json={"title": "a", "assignee": "old"}).json()["task"] + b = client.post("/api/plugins/kanban/tasks", + json={"title": "b", "assignee": "old"}).json()["task"] + r = client.post("/api/plugins/kanban/tasks/bulk", + json={"ids": [a["id"], b["id"]], "assignee": "new"}) + assert r.status_code == 200 + for tid in (a["id"], b["id"]): + t = client.get(f"/api/plugins/kanban/tasks/{tid}").json()["task"] + assert t["assignee"] == "new" + + +def test_bulk_unassign_via_empty_string(client): + a = client.post("/api/plugins/kanban/tasks", + json={"title": "a", "assignee": "x"}).json()["task"] + r = client.post("/api/plugins/kanban/tasks/bulk", + json={"ids": [a["id"]], "assignee": ""}) + assert r.status_code == 200 + t = client.get(f"/api/plugins/kanban/tasks/{a['id']}").json()["task"] + assert t["assignee"] is None + + +def test_bulk_partial_failure_doesnt_abort_siblings(client): + """One bad id in the middle of a batch must not prevent others from + applying.""" + a = client.post("/api/plugins/kanban/tasks", json={"title": "a"}).json()["task"] + c2 = client.post("/api/plugins/kanban/tasks", json={"title": "c"}).json()["task"] + r = client.post("/api/plugins/kanban/tasks/bulk", + json={"ids": [a["id"], "bogus-id", c2["id"]], "priority": 7}) + assert r.status_code == 200 + results = r.json()["results"] + assert len(results) == 3 + ok_ids = {r["id"] for r in results if r["ok"]} + assert a["id"] in ok_ids + assert c2["id"] in ok_ids + assert any(not r["ok"] and r["id"] == "bogus-id" for r in results) + # Good siblings actually got the priority bump. + for tid in (a["id"], c2["id"]): + t = client.get(f"/api/plugins/kanban/tasks/{tid}").json()["task"] + assert t["priority"] == 7 + + +def test_bulk_empty_ids_400(client): + r = client.post("/api/plugins/kanban/tasks/bulk", json={"ids": []}) + assert r.status_code == 400 + + +# --------------------------------------------------------------------------- +# /config endpoint +# --------------------------------------------------------------------------- + + +def test_config_returns_defaults_when_section_missing(client): + r = client.get("/api/plugins/kanban/config") + assert r.status_code == 200 + data = r.json() + # Defaults when dashboard.kanban is missing. + assert data["default_tenant"] == "" + assert data["lane_by_profile"] is True + assert data["include_archived_by_default"] is False + assert data["render_markdown"] is True + + +def test_config_reads_dashboard_kanban_section(tmp_path, monkeypatch, client): + home = Path(os.environ["HERMES_HOME"]) + (home / "config.yaml").write_text( + "dashboard:\n" + " kanban:\n" + " default_tenant: acme\n" + " lane_by_profile: false\n" + " include_archived_by_default: true\n" + " render_markdown: false\n" + ) + r = client.get("/api/plugins/kanban/config") + assert r.status_code == 200 + data = r.json() + assert data["default_tenant"] == "acme" + assert data["lane_by_profile"] is False + assert data["include_archived_by_default"] is True + assert data["render_markdown"] is False diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 947994844b27c..f0d28d958ed98 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -45,6 +45,7 @@ hermes [global-options] [subcommand/options] | `hermes login` / `logout` | **Deprecated** — use `hermes auth` instead. | | `hermes status` | Show agent, auth, and platform status. | | `hermes cron` | Inspect and tick the cron scheduler. | +| `hermes kanban` | Multi-profile collaboration board (tasks, links, dispatcher). | | `hermes webhook` | Manage dynamic webhook subscriptions for event-driven activation. | | `hermes doctor` | Diagnose config and dependency issues. | | `hermes dump` | Copy-pasteable setup summary for support/debugging. | @@ -272,6 +273,38 @@ hermes cron | `status` | Check whether the cron scheduler is running. | | `tick` | Run due jobs once and exit. | +## `hermes kanban` + +```bash +hermes kanban [options] +``` + +Multi-profile collaboration board. Tasks live in `~/.hermes/kanban.db` (WAL-mode SQLite); every profile reads and writes the same board. A `cron`-driven dispatcher (`hermes kanban dispatch`) atomically claims ready tasks and spawns the assigned profile as its own process with an isolated workspace. + +| Action | Purpose | +|--------|---------| +| `init` | Create `kanban.db` if missing. Idempotent. | +| `create ""` | Create a new task. Flags: `--body`, `--assignee`, `--parent` (repeatable), `--workspace scratch\|worktree\|dir:<path>`, `--tenant`, `--priority`. | +| `list` / `ls` | List tasks. Filter with `--mine`, `--assignee`, `--status`, `--tenant`, `--archived`, `--json`. | +| `show <id>` | Show a task with comments and events. `--json` for machine output. | +| `assign <id> <profile>` | Assign or reassign. Use `none` to unassign. Refused while task is running. | +| `link <parent> <child>` | Add a dependency. Cycle-detected. | +| `unlink <parent> <child>` | Remove a dependency. | +| `claim <id>` | Atomically claim a ready task. Prints resolved workspace path. | +| `comment <id> "<text>"` | Append a comment. Visible to the next worker that runs the task. | +| `complete <id>` | Mark task done. Flag: `--result "<summary>"` (goes into children's parent-result context). | +| `block <id> "<reason>"` | Mark task blocked. Also appends the reason as a comment. | +| `unblock <id>` | Return a blocked task to ready. | +| `archive <id>` | Hide from default list. `gc` will remove scratch workspaces. | +| `tail <id>` | Follow a task's event stream. | +| `dispatch` | One dispatcher pass. Flags: `--dry-run`, `--max N`, `--json`. | +| `context <id>` | Print the full context a worker would see (title + body + parent results + comments). | +| `gc` | Remove scratch workspaces for archived tasks. | + +All actions are also available as a slash command in the gateway (`/kanban …`), with the same argument surface. + +For the full design — comparison with Cline Kanban / Paperclip / NanoClaw / Gemini Enterprise, eight collaboration patterns, four user stories, concurrency correctness proof — see `docs/hermes-kanban-v1-spec.pdf` in the repository or the [Kanban user guide](/docs/user-guide/features/kanban). + ## `hermes webhook` ```bash diff --git a/website/docs/user-guide/features/kanban.md b/website/docs/user-guide/features/kanban.md new file mode 100644 index 0000000000000..37bd2a25244c8 --- /dev/null +++ b/website/docs/user-guide/features/kanban.md @@ -0,0 +1,283 @@ +--- +sidebar_position: 12 +title: "Kanban (Multi-Agent Board)" +description: "Durable SQLite-backed task board for coordinating multiple Hermes profiles" +--- + +# Kanban — Multi-Agent Profile Collaboration + +Hermes Kanban is a durable task board, shared across all your Hermes profiles, that lets multiple named agents collaborate on work without fragile in-process subagent swarms. Every task is a row in `~/.hermes/kanban.db`; every handoff is a row anyone can read and write; every worker is a full OS process with its own identity. + +This is the shape that covers the workloads `delegate_task` can't: + +- **Research triage** — parallel researchers + analyst + writer, human-in-the-loop. +- **Scheduled ops** — recurring daily briefs that build a journal over weeks. +- **Digital twins** — persistent named assistants (`inbox-triage`, `ops-review`) that accumulate memory over time. +- **Engineering pipelines** — decompose → implement in parallel worktrees → review → iterate → PR. +- **Fleet work** — one specialist managing N subjects (50 social accounts, 12 monitored services). + +For the full design rationale, comparative analysis against Cline Kanban / Paperclip / NanoClaw / Google Gemini Enterprise, and the eight canonical collaboration patterns, see `docs/hermes-kanban-v1-spec.pdf` in the repository. + +## Kanban vs. `delegate_task` + +They look similar; they are not the same primitive. + +| | `delegate_task` | Kanban | +|---|---|---| +| Shape | RPC call (fork → join) | Durable message queue + state machine | +| Parent | Blocks until child returns | Fire-and-forget after `create` | +| Child identity | Anonymous subagent | Named profile with persistent memory | +| Resumability | None — failed = failed | Block → unblock → re-run; crash → reclaim | +| Human in the loop | Not supported | Comment / unblock at any point | +| Agents per task | One call = one subagent | N agents over task's life (retry, review, follow-up) | +| Audit trail | Lost on context compression | Durable rows in SQLite forever | +| Coordination | Hierarchical (caller → callee) | Peer — any profile reads/writes any task | + +**One-sentence distinction:** `delegate_task` is a function call; Kanban is a work queue where every handoff is a row any profile (or human) can see and edit. + +**Use `delegate_task` when** the parent agent needs a short reasoning answer before continuing, no humans involved, result goes back into the parent's context. + +**Use Kanban when** work crosses agent boundaries, needs to survive restarts, might need human input, might be picked up by a different role, or needs to be discoverable after the fact. + +They coexist: a kanban worker may call `delegate_task` internally during its run. + +## Core concepts + +- **Task** — a row with title, optional body, one assignee (a profile name), status (`todo | ready | running | blocked | done | archived`), optional tenant namespace. +- **Link** — `task_links` row recording a parent → child dependency. The dispatcher promotes `todo → ready` when all parents are `done`. +- **Comment** — the inter-agent protocol. Agents and humans append comments; when a worker is (re-)spawned it reads the full comment thread as part of its context. +- **Workspace** — the directory a worker operates in. Three kinds: + - `scratch` (default) — fresh tmp dir under `~/.hermes/kanban/workspaces/<id>/`. + - `dir:<path>` — an existing shared directory (Obsidian vault, mail ops dir, per-account folder). + - `worktree` — a git worktree under `.worktrees/<id>/` for coding tasks. +- **Dispatcher** — `hermes kanban dispatch` runs a one-shot pass: reclaim stale claims, promote ready tasks, atomically claim, spawn assigned profiles. Runs via cron every 60 seconds. +- **Tenant** — optional string namespace. One specialist fleet can serve multiple businesses (`--tenant business-a`) with data isolation by workspace path and memory key prefix. + +## Quick start + +```bash +# 1. Create the board +hermes kanban init + +# 2. Create a task +hermes kanban create "research AI funding landscape" --assignee researcher + +# 3. List what's on the board +hermes kanban list + +# 4. Run a dispatcher pass (dry-run to preview, real to spawn workers) +hermes kanban dispatch --dry-run +hermes kanban dispatch +``` + +To have the board run continuously, schedule the dispatcher: + +```bash +hermes cron add --schedule "*/1 * * * *" \ + --name kanban-dispatch \ + hermes kanban dispatch +``` + +## The worker skill + +Any profile that should be able to work kanban tasks must load the `kanban-worker` skill. It teaches the worker the full lifecycle: + +1. On spawn, read `$HERMES_KANBAN_TASK` env var. +2. Run `hermes kanban context $HERMES_KANBAN_TASK` to read title + body + parent results + full comment thread. +3. `cd $HERMES_KANBAN_WORKSPACE` and do the work there. +4. Complete with `hermes kanban complete <id> --result "<summary>"`, or block with `hermes kanban block <id> "<reason>"` if stuck. + +Load it with: + +```bash +hermes skills install devops/kanban-worker +``` + +## The orchestrator skill + +A **well-behaved orchestrator does not do the work itself.** It decomposes the user's goal into tasks, links them, assigns each to a specialist, and steps back. The `kanban-orchestrator` skill encodes this: anti-temptation rules, a standard specialist roster (`researcher`, `writer`, `analyst`, `backend-eng`, `reviewer`, `ops`), and a decomposition playbook. + +Load it into your orchestrator profile: + +```bash +hermes skills install devops/kanban-orchestrator +``` + +For best results, pair it with a profile whose toolsets are restricted to board operations (`kanban`, `gateway`, `memory`) so the orchestrator literally cannot execute implementation tasks even if it tries. + +## Dashboard (GUI) + +The `/kanban` CLI and slash command are enough to run the board headlessly, but a visual board is often the right interface for humans-in-the-loop: triage, cross-profile supervision, reading comment threads, and dragging cards between columns. Hermes ships this as a **bundled dashboard plugin** at `plugins/kanban/` — not a core feature, not a separate service — following the model laid out in [Extending the Dashboard](./extending-the-dashboard). + +Open it with: + +```bash +hermes kanban init # one-time: create kanban.db if not already present +hermes dashboard # "Kanban" tab appears in the nav, after "Skills" +``` + +### What the plugin gives you + +- A **Kanban** tab showing one column per status: `triage`, `todo`, `ready`, `running`, `blocked`, `done` (plus `archived` when the toggle is on). + - `triage` is the parking column for rough ideas a specifier is expected to flesh out. Tasks created with `hermes kanban create --triage` (or via the Triage column's inline create) land here and the dispatcher leaves them alone until a human or specifier promotes them to `todo` / `ready`. +- Cards show the task id, title, priority badge, tenant tag, assigned profile, comment/link counts, a **progress pill** (`N/M` children done when the task has dependents), and "created N ago". A per-card checkbox enables multi-select. +- **Per-profile lanes inside Running** — toolbar checkbox toggles sub-grouping of the Running column by assignee. +- **Live updates via WebSocket** — the plugin tails the append-only `task_events` table on a short poll interval; the board reflects changes the instant any profile (CLI, gateway, or another dashboard tab) acts. Reloads are debounced so a burst of events triggers a single refetch. +- **Drag-drop** cards between columns to change status. The drop sends `PATCH /api/plugins/kanban/tasks/:id` which routes through the same `kanban_db` code the CLI uses — the three surfaces can never drift. Moves into destructive statuses (`done`, `archived`, `blocked`) prompt for confirmation. Touch devices use a pointer-based fallback so the board is usable from a tablet. +- **Inline create** — click `+` on any column header to type a title, assignee, priority, and (optionally) a parent task from a dropdown over every existing task. Creating from the Triage column automatically parks the new task in triage. +- **Multi-select with bulk actions** — shift/ctrl-click a card or tick its checkbox to add it to the selection. A bulk action bar appears at the top with batch status transitions, archive, and reassign (by profile dropdown, or "(unassign)"). Destructive batches confirm first. Per-id partial failures are reported without aborting the rest. +- **Click a card** (without shift/ctrl) to open a side drawer (Escape or click-outside closes) with: + - **Editable title** — click the heading to rename. + - **Editable assignee / priority** — click the meta row to rewrite. + - **Editable description** — markdown-rendered by default (headings, bold, italic, inline code, fenced code, `http(s)` / `mailto:` links, bullet lists), with an "edit" button that swaps in a textarea. Markdown rendering is a tiny, XSS-safe renderer — every substitution runs on HTML-escaped input, only `http(s)` / `mailto:` links pass through, and `target="_blank"` + `rel="noopener noreferrer"` are always set. + - **Dependency editor** — chip list of parents and children, each with an `×` to unlink, plus dropdowns over every other task to add a new parent or child. Cycle attempts are rejected server-side with a clear message. + - **Status action row** (→ triage / → ready / → running / block / unblock / complete / archive) with confirm prompts for destructive transitions. + - Result section (also markdown-rendered), comment thread with Enter-to-submit, the last 20 events. +- **Toolbar filters** — free-text search, tenant dropdown (defaults to `dashboard.kanban.default_tenant` from `config.yaml`), assignee dropdown, "show archived" toggle, "lanes by profile" toggle, and a **Nudge dispatcher** button so you don't have to wait for the next 60 s tick. + +Visually the target is the familiar Linear / Fusion layout: dark theme, column headers with counts, coloured status dots, pill chips for priority and tenant. The plugin reads only theme CSS vars (`--color-*`, `--radius`, `--font-mono`, ...), so it reskins automatically with whichever dashboard theme is active. + +### Architecture + +The GUI is strictly a **read-through-the-DB + write-through-kanban_db** layer with no domain logic of its own: + +``` +┌────────────────────────┐ WebSocket (tails task_events) +│ React SPA (plugin) │ ◀──────────────────────────────────┐ +│ HTML5 drag-and-drop │ │ +└──────────┬─────────────┘ │ + │ REST over fetchJSON │ + ▼ │ +┌────────────────────────┐ writes call kanban_db.* │ +│ FastAPI router │ directly — same code path │ +│ plugins/kanban/ │ the CLI /kanban verbs use │ +│ dashboard/plugin_api.py │ +└──────────┬─────────────┘ │ + │ │ + ▼ │ +┌────────────────────────┐ │ +│ ~/.hermes/kanban.db │ ───── append task_events ──────────┘ +│ (WAL, shared) │ +└────────────────────────┘ +``` + +### REST surface + +All routes are mounted under `/api/plugins/kanban/` and protected by the dashboard's ephemeral session token: + +| Method | Path | Purpose | +|---|---|---| +| `GET` | `/board?tenant=<name>&include_archived=…` | Full board grouped by status column, plus tenants + assignees for filter dropdowns | +| `GET` | `/tasks/:id` | Task + comments + events + links | +| `POST` | `/tasks` | Create (wraps `kanban_db.create_task`, accepts `triage: bool` and `parents: [id, …]`) | +| `PATCH` | `/tasks/:id` | Status / assignee / priority / title / body / result | +| `POST` | `/tasks/bulk` | Apply the same patch (status / archive / assignee / priority) to every id in `ids`. Per-id failures reported without aborting siblings | +| `POST` | `/tasks/:id/comments` | Append a comment | +| `POST` | `/links` | Add a dependency (`parent_id` → `child_id`) | +| `DELETE` | `/links?parent_id=…&child_id=…` | Remove a dependency | +| `POST` | `/dispatch?max=…&dry_run=…` | Nudge the dispatcher — skip the 60 s wait | +| `GET` | `/config` | Read `dashboard.kanban` preferences from `config.yaml` — `default_tenant`, `lane_by_profile`, `include_archived_by_default`, `render_markdown` | +| `WS` | `/events?since=<event_id>` | Live stream of `task_events` rows | + +Every handler is a thin wrapper — the plugin is ~700 lines of Python (router + WebSocket tail + bulk batcher + config reader) and adds no new business logic. A tiny `_conn()` helper auto-initializes `kanban.db` on every read and write, so a fresh install works whether the user opened the dashboard first, hit the REST API directly, or ran `hermes kanban init`. + +### Dashboard config + +Any of these keys under `dashboard.kanban` in `~/.hermes/config.yaml` changes the tab's defaults — the plugin reads them at load time via `GET /config`: + +```yaml +dashboard: + kanban: + default_tenant: acme # preselects the tenant filter + lane_by_profile: true # default for the "lanes by profile" toggle + include_archived_by_default: false + render_markdown: true # set false for plain <pre> rendering +``` + +Each key is optional and falls back to the shown default. + +### Security model + +The dashboard's HTTP auth middleware [explicitly skips `/api/plugins/`](./extending-the-dashboard#backend-api-routes) — plugin routes are unauthenticated by design because the dashboard binds to localhost by default. That means the kanban REST surface is reachable from any process on the host. + +The WebSocket takes one additional step: it requires the dashboard's ephemeral session token as a `?token=…` query parameter (browsers can't set `Authorization` on an upgrade request), matching the pattern used by the in-browser PTY bridge. + +If you run `hermes dashboard --host 0.0.0.0`, every plugin route — kanban included — becomes reachable from the network. **Don't do that on a shared host.** The board contains task bodies, comments, and workspace paths; an attacker reaching these routes gets read access to your entire collaboration surface and can also create / reassign / archive tasks. + +Tasks in `~/.hermes/kanban.db` are profile-agnostic on purpose (that's the coordination primitive). If you open the dashboard with `hermes -p <profile> dashboard`, the board still shows tasks created by any other profile on the host. Same user owns all profiles, but this is worth knowing if multiple personas coexist. + +### Live updates + +`task_events` is an append-only SQLite table with a monotonic `id`. The WebSocket endpoint holds each client's last-seen event id and pushes new rows as they land. When a burst of events arrives, the frontend reloads the (very cheap) board endpoint — simpler and more correct than trying to patch local state from every event kind. WAL mode means the read loop never blocks the dispatcher's `BEGIN IMMEDIATE` claim transactions. + +### Extending it + +The plugin uses the standard Hermes dashboard plugin contract — see [Extending the Dashboard](./extending-the-dashboard) for the full manifest reference, shell slots, page-scoped slots, and the Plugin SDK. Extra columns, custom card chrome, tenant-filtered layouts, or full `tab.override` replacements are all expressible without forking this plugin. + +To disable without removing: add `dashboard.plugins.kanban.enabled: false` to `config.yaml` (or delete `plugins/kanban/dashboard/manifest.json`). + +### Scope boundary + +The GUI is deliberately thin. Everything the plugin does is reachable from the CLI; the plugin just makes it comfortable for humans. Auto-assignment, budgets, governance gates, and org-chart views remain user-space — a router profile, another plugin, or a reuse of `tools/approval.py` — exactly as listed in the out-of-scope section of the design spec. + +## CLI command reference + +``` +hermes kanban init # create kanban.db +hermes kanban create "<title>" [--body ...] [--assignee <profile>] + [--parent <id>]... [--tenant <name>] + [--workspace scratch|worktree|dir:<path>] + [--priority N] [--triage] [--json] +hermes kanban list [--mine] [--assignee P] [--status S] [--tenant T] [--archived] [--json] +hermes kanban show <id> [--json] +hermes kanban assign <id> <profile> # or 'none' to unassign +hermes kanban link <parent_id> <child_id> +hermes kanban unlink <parent_id> <child_id> +hermes kanban claim <id> [--ttl SECONDS] +hermes kanban comment <id> "<text>" [--author NAME] +hermes kanban complete <id> [--result "..."] +hermes kanban block <id> "<reason>" +hermes kanban unblock <id> +hermes kanban archive <id> +hermes kanban tail <id> # follow event stream +hermes kanban dispatch [--dry-run] [--max N] [--json] +hermes kanban context <id> # what a worker sees +hermes kanban gc # remove scratch dirs of archived tasks +``` + +All commands are also available as a slash command in the gateway (`/kanban list`, `/kanban comment t_abc "need docs"`, etc.). The slash command bypasses the running-agent guard, so you can `/kanban unblock` a stuck worker while the main agent is still chatting. + +## Collaboration patterns + +The board supports these eight patterns without any new primitives: + +| Pattern | Shape | Example | +|---|---|---| +| **P1 Fan-out** | N siblings, same role | "research 5 angles in parallel" | +| **P2 Pipeline** | role chain: scout → editor → writer | daily brief assembly | +| **P3 Voting / quorum** | N siblings + 1 aggregator | 3 researchers → 1 reviewer picks | +| **P4 Long-running journal** | same profile + shared dir + cron | Obsidian vault | +| **P5 Human-in-the-loop** | worker blocks → user comments → unblock | ambiguous decisions | +| **P6 `@mention`** | inline routing from prose | `@reviewer look at this` | +| **P7 Thread-scoped workspace** | `/kanban here` in a thread | per-project gateway threads | +| **P8 Fleet farming** | one profile, N subjects | 50 social accounts | +| **P9 Triage specifier** | rough idea → `triage` → specifier expands body → `todo` | "turn this one-liner into a spec' task" | + +For worked examples of each, see `docs/hermes-kanban-v1-spec.pdf`. + +## Multi-tenant usage + +When one specialist fleet serves multiple businesses, tag each task with a tenant: + +```bash +hermes kanban create "monthly report" \ + --assignee researcher \ + --tenant business-a \ + --workspace dir:~/tenants/business-a/data/ +``` + +Workers receive `$HERMES_TENANT` and namespace their memory writes by prefix. The board, the dispatcher, and the profile definitions are all shared; only the data is scoped. + +## Design spec + +The complete design — architecture, concurrency correctness, comparison with other systems, implementation plan, risks, open questions — lives in `docs/hermes-kanban-v1-spec.pdf`. Read that before filing any behavior-change PR. diff --git a/website/sidebars.ts b/website/sidebars.ts index b654291810133..0b201baaf2426 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -60,6 +60,7 @@ const sidebars: SidebarsConfig = { items: [ 'user-guide/features/cron', 'user-guide/features/delegation', + 'user-guide/features/kanban', 'user-guide/features/code-execution', 'user-guide/features/hooks', 'user-guide/features/batch-processing',