diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 54b339e19f97..887eeb70e965 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -404,11 +404,19 @@ def write_board_metadata( icon: Optional[str] = None, color: Optional[str] = None, archived: Optional[bool] = None, + meta_extra: Optional[dict] = None, ) -> dict: """Create / update ``board.json`` for ``board``. Preserves any existing fields not mentioned in the call. Sets ``created_at`` on first write. Returns the resulting metadata dict. + + ``meta_extra`` is a free-form dict whose keys are merged into the + metadata file alongside the known fields. Used by feature layers + (e.g. ``tools/chief_tools.py``) to attach domain metadata like + ``kind``, ``lifetime``, ``parent_chief_id`` without requiring a + schema migration. Keys colliding with reserved names (``slug``, + ``db_path``) are dropped silently to prevent corruption. """ slug = _normalize_board_slug(board) or DEFAULT_BOARD meta = read_board_metadata(slug) @@ -425,6 +433,14 @@ def write_board_metadata( meta["color"] = str(color) if archived is not None: meta["archived"] = bool(archived) + if meta_extra: + # Reserved keys: slug is rebound from filesystem on read; db_path is + # derived. Letting callers overwrite them would corrupt list_boards(). + _RESERVED = {"slug", "db_path"} + for k, v in meta_extra.items(): + if k in _RESERVED: + continue + meta[k] = v if not meta.get("created_at"): meta["created_at"] = int(time.time()) path = board_metadata_path(slug) @@ -444,12 +460,16 @@ def create_board( description: Optional[str] = None, icon: Optional[str] = None, color: Optional[str] = None, + meta_extra: Optional[dict] = None, ) -> dict: """Create a new board directory + DB + metadata. Idempotent. Returns the resulting metadata. Raises :class:`ValueError` for a malformed slug; returns the existing metadata (not an error) if the board already exists — matching ``mkdir -p`` semantics. + + ``meta_extra`` — forward to :func:`write_board_metadata` for attaching + domain metadata (e.g. chief lifecycle fields). """ normed = _normalize_board_slug(slug) if not normed: @@ -460,6 +480,7 @@ def create_board( description=description, icon=icon, color=color, + meta_extra=meta_extra, ) # Touch the DB so list_boards() sees it immediately. init_db(board=normed) diff --git a/skills/devops/chief-manager/SKILL.md b/skills/devops/chief-manager/SKILL.md new file mode 100644 index 000000000000..5b5ef58ff4f4 --- /dev/null +++ b/skills/devops/chief-manager/SKILL.md @@ -0,0 +1,132 @@ +--- +name: chief-manager +description: Long-running project chief — owns a complex task end-to-end on its own kanban board. Spawned dynamically by an orchestrator (e.g. main:manager) via the chief_spawn tool when a request needs sustained operational work without polluting the orchestrator's conversation. +version: 0.1.0-poc +platforms: [linux, macos, windows] +metadata: + hermes: + tags: [kanban, multi-agent, orchestration, project-management, chief] + related_skills: [kanban-worker, kanban-orchestrator] +--- + +# Chief Manager — Project Owner Agent + +> You're seeing this skill because the kanban dispatcher spawned you as the worker on an initial task whose `assignee = "chief-manager"`. You are a **chief** — you own a project end-to-end, isolated from the orchestrator that delegated to you. + +## Your situation + +- You live on your own kanban board (slug visible as `$HERMES_KANBAN_BOARD`). +- Your **initial task** is the one the dispatcher claimed for you (id in `$HERMES_KANBAN_TASK`). Its `body` is your brief from upstream — read it carefully. +- The orchestrator who spawned you has **no shared conversation context** with you. The brief is all you get. If something is unclear, ask via `kanban_comment` on the initial task and `kanban_block` with `reason="awaiting clarification"` — orchestrator will see it on next `chief_status` poll. + +## Lifecycle + +### 1. Orient (first ~30 seconds) + +``` +kanban_show() → read your initial task body + metadata +``` + +Identify: +- **Scope** — what is the deliverable? +- **Acceptance criteria** — how do you know you're done? +- **Constraints** — paths, providers, budgets, deadlines mentioned? +- **Inputs** — files, URLs, prior context referenced? + +### 2. Plan + Decompose (if non-trivial) + +If the work fits in one continuous push (< 15 min, single skill area), just do it. Comment your plan on the initial task first. + +If the work needs decomposition: + +``` +kanban_create(board="", title="...", body="...", assignee="") +``` + +- Sub-tasks live on **your** board. Each `assignee` should be a regular profile (e.g. `cmf-expert`, `researcher`, `engineer`) — the dispatcher will spawn ordinary kanban workers for them. +- For a **truly autonomous sub-project** (long, parallel, isolated): use `chief_spawn(name=..., brief=...)`. This creates an under-chief on its own board. You become its `parent_chief_id`. By default the cascade policy means terminating you will terminate under-chiefs too — fine for most cases. + +### 3. Monitor + +Every ~2 min while sub-tasks are running: + +``` +kanban_list(board="", include_archived=false) +``` + +Comment on your **initial task** with a digest — that's what `chief_status` (called by orchestrator) surfaces back to main:manager. Be concise: 2-3 sentences max per digest. + +``` +kanban_comment(task_id="", body="Stage 2/4 complete: indexing done, 1245 transcripts retrieved. Now embedding.") +``` + +### 4. Heartbeat + +The dispatcher monitors your `last_heartbeat_at` for crash detection. Call once every ~5 min during long work: + +``` +kanban_heartbeat() +``` + +(Inside a tool loop it's automatic; explicit calls matter only when you're doing a long synchronous step that doesn't tick tools for minutes.) + +### 5. Handle blocked sub-tasks + +If any sub-task lands in `blocked`, decide: +- **Fix and retry:** `kanban_unblock(task_id=...)` +- **Re-scope:** edit the brief via comment, then unblock +- **Escalate:** comment on your initial task explaining why you're stuck, then `kanban_block` yourself with reason — orchestrator sees stuck-state in chief_status + +### 6. Complete + +When all sub-tasks are `done` AND acceptance criteria met: + +``` +kanban_complete(task_id="", result="") +``` + +After this: +- If your board metadata says `lifetime == "ephemeral"`: orchestrator (via `chief_status`) will see `alive=false` and call `chief_terminate(chief_id=)`. You don't need to clean up. +- If `lifetime == "permanent"`: you stay alive. Loop back to step 1 to await new tasks on your board. Don't exit until orchestrator explicitly terminates you. + +## What you do NOT do + +- ❌ Talk to the user directly via Telegram or chat. You communicate **only** via kanban events on your board. Orchestrator is the user-facing voice. +- ❌ Spawn sub-chiefs recursively past depth 3. The `chief_spawn` tool will refuse anyway. +- ❌ Delete your own board. Termination is orchestrator's job. +- ❌ Touch other chiefs' boards directly. If you need their output, ask via your orchestrator (comment with `@main:manager: please pass me X from chief-Y`). + +## Tools available to you + +You have the full kanban toolset of an orchestrator: +- `kanban_show` / `kanban_list` / `kanban_create` — task management on your board +- `kanban_comment` / `kanban_heartbeat` — progress + liveness on YOUR initial task +- `kanban_complete` / `kanban_block` / `kanban_unblock` — lifecycle on YOUR tasks +- `chief_spawn` / `chief_status` / `chief_list` / `chief_terminate` — recursive: spawn under-chiefs if needed + +Plus all of Hermes' general tools: terminal, fetch, MCP servers, your profile's domain tools. + +## Communication shape (the digest pattern) + +The orchestrator pulls progress via `chief_status` which surfaces your most recent comment on the initial task. Optimize for that: + +✅ Good digest: +``` +"Phase 2/3 (extraction): processed 145 of 200 docs. 12 OCR-blocked +(reason: scanned PDFs need vision pass), queued for phase 3. ETA 20m. +No issues." +``` + +❌ Bad digest: +``` +"Working on it." +"Files: /opt/data/foo.txt, /opt/data/bar.txt, /opt/data/baz.txt, +/opt/data/qux.txt, [50 more lines]..." +"DEBUG: opened conn, executed SELECT, returned 145 rows..." +``` + +Rule of thumb: orchestrator should be able to relay your digest verbatim to the user without editing. + +## On termination + +The orchestrator may call `chief_terminate(chief_id=, force=False)` at any time. You'll see your board get archived. If `force=False`, you finish your current step then exit on next heartbeat (board archive is the stop signal). If `force=True`, you get SIGTERM — save partial state to a `comment` if possible before the signal lands. diff --git a/tests/tools/test_chief_tools.py b/tests/tools/test_chief_tools.py new file mode 100644 index 000000000000..bc35b13092e0 --- /dev/null +++ b/tests/tools/test_chief_tools.py @@ -0,0 +1,286 @@ +"""Tests for the chief tool surface (tools/chief_tools.py). + +Phase 0+1 POC coverage: + - chief_spawn creates a board with kind=chief metadata + a ready initial task. + - chief_status aggregates the board correctly (alive, stage, counts). + - chief_list filters to chief boards only. + - chief_terminate (cascade policy) recursively archives sub-chiefs. + - Recursion depth guard rejects deeply-nested spawn. + - Schema validation errors are returned as tool_error JSON, not exceptions. +""" +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest + + +# --------------------------------------------------------------------------- +# Fixtures: isolated HERMES_HOME so every test gets a fresh kanban root. +# --------------------------------------------------------------------------- + +@pytest.fixture +def hermes_env(monkeypatch, tmp_path): + """Point Hermes at a clean temp HOME, return the kanban_db module.""" + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + # Profile config with kanban toolset enabled so check_fn passes. + cfg = home / "config.yaml" + cfg.write_text("toolsets: [kanban]\n", encoding="utf-8") + # Make sure the env var that gates worker-only kanban tools is OFF — + # chief tools should be visible without it (orchestrator mode). + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) + # Reset config cache so our test config.yaml is read on every test + # (load_config is mtime-cached at module level). + from hermes_cli import config as cfg_mod + if hasattr(cfg_mod, "_clear_cache"): + cfg_mod._clear_cache() + + import tools.chief_tools as chief_tools # ensure registered + from tools.registry import invalidate_check_fn_cache + invalidate_check_fn_cache() + from hermes_cli import kanban_db + return chief_tools, kanban_db + + +def _parse(result: str) -> dict: + """Tool handlers return JSON strings — decode + sanity assertions.""" + assert isinstance(result, str), f"expected str, got {type(result)}" + return json.loads(result) + + +# --------------------------------------------------------------------------- +# chief_spawn +# --------------------------------------------------------------------------- + +def test_chief_spawn_creates_board_and_initial_task(hermes_env): + chief_tools, kanban_db = hermes_env + out = _parse(chief_tools._handle_chief_spawn({ + "name": "yt-indexer", + "brief": "Index every video on @ExampleChannel and store transcripts.", + })) + assert out["ok"] is True + chief_id = out["chief_id"] + assert chief_id.startswith("chief-yt-indexer-") + assert out["board"] == chief_id + assert out["lifetime"] == "ephemeral" # default + assert out["terminate_policy"] == "cascade" # default + assert out["parent_chief_id"] is None + + # Board metadata persisted with kind=chief + meta = kanban_db.read_board_metadata(chief_id) + assert meta["kind"] == "chief" + assert meta["lifetime"] == "ephemeral" + assert meta["terminate_policy"] == "cascade" + assert meta["spawned_at"] is not None + + # Initial task created with correct assignee + status + conn = kanban_db.connect(board=chief_id) + try: + rows = conn.execute( + "SELECT id, title, body, assignee, status FROM tasks" + ).fetchall() + finally: + conn.close() + assert len(rows) == 1 + r = dict(rows[0]) + assert r["assignee"] == "chief-manager" + assert r["status"] == "ready" + assert "Index every video" in r["body"] + + +def test_chief_spawn_missing_required_args(hermes_env): + chief_tools, _ = hermes_env + # missing brief + r = _parse(chief_tools._handle_chief_spawn({"name": "foo"})) + assert r.get("error") and "brief" in r["error"] + # missing name + r = _parse(chief_tools._handle_chief_spawn({"brief": "do something"})) + assert r.get("error") and "name" in r["error"] + + +def test_chief_spawn_validates_lifetime(hermes_env): + chief_tools, _ = hermes_env + r = _parse(chief_tools._handle_chief_spawn({ + "name": "x", "brief": "y", "lifetime": "forever" + })) + assert r.get("error") and "lifetime" in r["error"] + + +def test_chief_spawn_rejects_unknown_parent(hermes_env): + chief_tools, _ = hermes_env + r = _parse(chief_tools._handle_chief_spawn({ + "name": "x", "brief": "y", "parent_chief_id": "chief-fake-zzz" + })) + assert r.get("error") and "parent_chief_id" in r["error"] + + +def test_chief_spawn_recursion_depth_guard(hermes_env): + chief_tools, kanban_db = hermes_env + # Create three nested chiefs — fourth should be rejected. + root = _parse(chief_tools._handle_chief_spawn({ + "name": "root", "brief": "..." + }))["chief_id"] + lvl1 = _parse(chief_tools._handle_chief_spawn({ + "name": "lvl1", "brief": "...", "parent_chief_id": root + }))["chief_id"] + lvl2 = _parse(chief_tools._handle_chief_spawn({ + "name": "lvl2", "brief": "...", "parent_chief_id": lvl1 + }))["chief_id"] + # 4th level rejected. + r = _parse(chief_tools._handle_chief_spawn({ + "name": "lvl3", "brief": "...", "parent_chief_id": lvl2 + })) + assert r.get("error") and "recursion depth" in r["error"] + + +# --------------------------------------------------------------------------- +# chief_status +# --------------------------------------------------------------------------- + +def test_chief_status_reflects_initial_task_state(hermes_env): + chief_tools, kanban_db = hermes_env + cid = _parse(chief_tools._handle_chief_spawn({ + "name": "test", "brief": "do work" + }))["chief_id"] + + s = _parse(chief_tools._handle_chief_status({"chief_id": cid})) + assert s["ok"] is True + assert s["chief_id"] == cid + assert s["alive"] is True + assert s["initial_status"] == "ready" + assert s["subtasks_total"] == 1 + assert s["subtasks_open"] == 1 + assert s["subtasks_done"] == 0 + assert s["stage"] == "queued" # ready = queued (dispatcher hasn't claimed) + + +def test_chief_status_after_initial_task_done(hermes_env): + chief_tools, kanban_db = hermes_env + cid = _parse(chief_tools._handle_chief_spawn({ + "name": "test", "brief": "do work" + }))["chief_id"] + # Mark the initial task done directly via SQL. + conn = kanban_db.connect(board=cid) + try: + conn.execute("UPDATE tasks SET status = 'done'") + conn.commit() + finally: + conn.close() + + s = _parse(chief_tools._handle_chief_status({"chief_id": cid})) + assert s["alive"] is False + assert s["initial_status"] == "done" + assert s["subtasks_done"] == 1 + assert s["subtasks_open"] == 0 + assert s["stage"] == "completed" + + +def test_chief_status_rejects_non_chief_board(hermes_env): + chief_tools, kanban_db = hermes_env + kanban_db.create_board("ordinary-board") + r = _parse(chief_tools._handle_chief_status({"chief_id": "ordinary-board"})) + assert r.get("error") and "not a known chief" in r["error"] + + +# --------------------------------------------------------------------------- +# chief_list +# --------------------------------------------------------------------------- + +def test_chief_list_filters_to_chiefs_only(hermes_env): + chief_tools, kanban_db = hermes_env + # Create one chief + one plain board. + cid = _parse(chief_tools._handle_chief_spawn({ + "name": "a", "brief": "..." + }))["chief_id"] + kanban_db.create_board("plain-board") + + out = _parse(chief_tools._handle_chief_list({})) + assert out["ok"] is True + slugs = {c["chief_id"] for c in out["chiefs"]} + assert cid in slugs + assert "plain-board" not in slugs + # The default board should never appear either. + assert "default" not in slugs + + +def test_chief_list_shows_parent_link(hermes_env): + chief_tools, _ = hermes_env + root = _parse(chief_tools._handle_chief_spawn({ + "name": "root", "brief": "..." + }))["chief_id"] + child = _parse(chief_tools._handle_chief_spawn({ + "name": "child", "brief": "...", "parent_chief_id": root + }))["chief_id"] + out = _parse(chief_tools._handle_chief_list({})) + by_id = {c["chief_id"]: c for c in out["chiefs"]} + assert by_id[child]["parent_chief_id"] == root + assert by_id[root]["parent_chief_id"] is None + + +# --------------------------------------------------------------------------- +# chief_terminate (cascade) +# --------------------------------------------------------------------------- + +def test_chief_terminate_cascade_archives_descendants(hermes_env): + chief_tools, kanban_db = hermes_env + root = _parse(chief_tools._handle_chief_spawn({ + "name": "root", "brief": "..." + }))["chief_id"] + child = _parse(chief_tools._handle_chief_spawn({ + "name": "child", "brief": "...", "parent_chief_id": root + }))["chief_id"] + grandchild = _parse(chief_tools._handle_chief_spawn({ + "name": "grand", "brief": "...", "parent_chief_id": child + }))["chief_id"] + + # Terminate root → cascade should walk into child + grandchild. + r = _parse(chief_tools._handle_chief_terminate({"chief_id": root})) + assert r["ok"] is True + assert r["terminated"] is True + + # All three boards no longer appear in active list_boards. + active_slugs = {b["slug"] for b in kanban_db.list_boards(include_archived=False)} + assert root not in active_slugs + assert child not in active_slugs + assert grandchild not in active_slugs + + # cascaded summary lists every terminated descendant + def _collect(node): + yield node["chief_id"] + for sub in node.get("cascaded", []): + yield from _collect(sub) + assert {root, child, grandchild} == set(_collect(r)) + + +def test_chief_terminate_rejects_independent_policy_in_mvp(hermes_env): + chief_tools, _ = hermes_env + cid = _parse(chief_tools._handle_chief_spawn({ + "name": "x", "brief": "y", "terminate_policy": "independent" + }))["chief_id"] + r = _parse(chief_tools._handle_chief_terminate({"chief_id": cid})) + assert r.get("error") and "independent" in r["error"] + + +def test_chief_terminate_rejects_unknown_chief(hermes_env): + chief_tools, _ = hermes_env + r = _parse(chief_tools._handle_chief_terminate({"chief_id": "chief-fake-zzz"})) + assert r.get("error") and "not a known chief" in r["error"] + + +# --------------------------------------------------------------------------- +# Registration / discoverability +# --------------------------------------------------------------------------- + +def test_chief_tools_registered_under_kanban_toolset(hermes_env): + """The 4 chief tools must be present in the kanban toolset registry.""" + chief_tools, _ = hermes_env + from tools.registry import registry + for name in ("chief_spawn", "chief_status", "chief_list", "chief_terminate"): + entry = registry.get_entry(name) + assert entry is not None, f"{name} not registered" + assert entry.toolset == "kanban" + assert entry.emoji # set diff --git a/tests/tools/test_kanban_tools.py b/tests/tools/test_kanban_tools.py index ac081a0ae308..9e32f38bf538 100644 --- a/tests/tools/test_kanban_tools.py +++ b/tests/tools/test_kanban_tools.py @@ -1186,7 +1186,9 @@ def multi_board_env(monkeypatch, tmp_path): conn.close() # Alt board — must be explicitly created via create_board() before # connect(); the kanban_db.connect() no-resurrect guard refuses to - # mkdir non-default boards on the fly. + # mkdir non-default boards on the fly (otherwise stale `?board=` + # callers would silently recreate deleted boards — see the dashboard + # bug fixed in fix/kanban-delete-active-board branch). kb.create_board("alt") conn = kb.connect(board="alt") try: @@ -1388,8 +1390,9 @@ def test_board_param_routes_heartbeat_to_alt_board(monkeypatch, tmp_path): from hermes_cli import kanban_db as kb kb._INITIALIZED_PATHS.clear() - # Must create_board first — connect()'s no-resurrect guard rejects - # mkdir on the fly for non-default boards. + # Seed the alt board with a claimed task. Must create_board first — + # connect()'s no-resurrect guard rejects mkdir on the fly for + # non-default boards. kb.create_board("alt") with kb.connect(board="alt") as conn: tid = kb.create_task(conn, title="alt hb", assignee="alt-worker") diff --git a/tools/chief_tools.py b/tools/chief_tools.py new file mode 100644 index 000000000000..bbf4dd7a99e5 --- /dev/null +++ b/tools/chief_tools.py @@ -0,0 +1,695 @@ +"""Chief tools — dynamic spawn / monitor / terminate of project-chief sub-agents. + +A "chief" is a Hermes worker process that owns a high-level project. Spawned +on-demand by main:manager (or another chief) for complex, long-running tasks +that should NOT pollute the orchestrator's conversation context. + +Architecture (see ``plans/dynamic-chief-spawn-and-lifecycle.md`` in the repo +root for the full design): + +* Each chief = one kanban board + one initial ready task with + ``assignee="chief-manager"``. +* The kanban dispatcher (per-board tick, already in gateway) sees the ready + task and spawns a worker process. That worker loads the ``chief-manager`` + skill and operates the project: decomposes into sub-tasks on its OWN + board, comments progress on the initial task, completes when done. +* Main:manager monitors via ``chief_status`` (aggregates board state into a + compact summary) and decides lifetime via ``chief_terminate``. + +Lifecycle policy is fixed at spawn time and stored in ``board.json`` via +``meta_extra``: + +* ``cascade`` (default) — terminating a chief recursively terminates every + sub-chief that lists it as ``parent_chief_id``. Safe, predictable, no + orphans. Implemented in this MVP. +* ``independent`` — sub-chiefs survive parent death (re-parented to user + via a system comment). Planned for Phase 2; not yet implemented. + +POC scope (Phase 0+1 of the plan): +* ``chief_spawn`` — create board + initial task with chief metadata. +* ``chief_status`` — aggregate one chief's board into a summary. +* ``chief_list`` — list all live chiefs across boards. +* ``chief_terminate`` — cascade-only for MVP; ``independent`` raises NYI. +""" +from __future__ import annotations + +import logging +import os +import re +import signal +import time +import uuid +from typing import Any, Optional + +from tools.registry import registry, tool_error + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Gating: chief tools are available wherever kanban orchestrator tools are. +# Workers DO get them too (a chief is itself a worker that may spawn +# under-chiefs), so the env-var check used by kanban_list/etc is too strict. +# We gate on "kanban toolset enabled in profile" — same as orchestrator mode +# but without excluding workers. +# --------------------------------------------------------------------------- + +def _profile_has_kanban_toolset() -> bool: + try: + from hermes_cli.config import load_config + cfg = load_config() + toolsets = cfg.get("toolsets", []) + return "kanban" in toolsets + except Exception: + return False + + +def _check_chief_mode() -> bool: + """Chief tools available to: + 1. Dispatcher-spawned chief workers (HERMES_KANBAN_TASK set + we are + the chief-manager assignee — they can spawn under-chiefs). + 2. Orchestrator profiles with kanban toolset enabled (main:manager). + """ + if os.environ.get("HERMES_KANBAN_TASK"): + return True + return _profile_has_kanban_toolset() + + +# --------------------------------------------------------------------------- +# Constants & helpers +# --------------------------------------------------------------------------- + +CHIEF_BOARD_KIND = "chief" +CHIEF_ASSIGNEE = "chief-manager" +DEFAULT_LIFETIME = "ephemeral" +DEFAULT_TERMINATE_POLICY = "cascade" +DEFAULT_MAX_RUNTIME_MIN = 120 +MAX_NEST_DEPTH = 3 # parent → child → grandchild; further is rejected + + +_SLUG_RE = re.compile(r"[^a-z0-9]+") + + +def _slugify(name: str) -> str: + """Lower-snake with single hyphens, max 32 chars. Empty string never returned.""" + cleaned = _SLUG_RE.sub("-", name.lower()).strip("-") + if not cleaned: + cleaned = "chief" + return cleaned[:32] + + +def _new_chief_id(name: str) -> str: + """``chief--``. ULID tail keeps it sortable + unique.""" + suffix = uuid.uuid4().hex[-6:] + return f"chief-{_slugify(name)}-{suffix}" + + +def _import_kanban_db(): + """Lazy import so tool module loads cleanly in non-kanban contexts.""" + from hermes_cli import kanban_db + return kanban_db + + +def _read_chief_meta(chief_id: str) -> Optional[dict]: + """Return board metadata IFF it's a chief board; else None.""" + kb = _import_kanban_db() + try: + meta = kb.read_board_metadata(chief_id) + except Exception: + return None + if meta.get("kind") != CHIEF_BOARD_KIND: + return None + return meta + + +def _check_recursion_depth(parent_chief_id: Optional[str]) -> Optional[str]: + """Return error message if spawning under this parent would exceed depth.""" + if not parent_chief_id: + return None + depth = 0 + cur = parent_chief_id + while cur and depth < MAX_NEST_DEPTH + 1: + meta = _read_chief_meta(cur) + if not meta: + break + cur = meta.get("parent_chief_id") + depth += 1 + if depth >= MAX_NEST_DEPTH: + return ( + f"chief recursion depth would be {depth + 1}, exceeds limit " + f"{MAX_NEST_DEPTH}. Decompose the work flatter — spawn a peer " + f"chief under the top-level main:manager instead of nesting." + ) + return None + + +def _list_chief_boards(include_archived: bool = False) -> list[dict]: + """Enumerate boards with ``kind=chief`` metadata.""" + kb = _import_kanban_db() + out = [] + for b in kb.list_boards(include_archived=include_archived): + meta = kb.read_board_metadata(b["slug"]) + if meta.get("kind") != CHIEF_BOARD_KIND: + continue + out.append(meta) + return out + + +def _find_initial_task(chief_id: str): + """Initial task = oldest task with assignee=chief-manager on this board.""" + kb = _import_kanban_db() + try: + conn = kb.connect(board=chief_id) + except FileNotFoundError: + return None + try: + row = conn.execute( + "SELECT * FROM tasks WHERE assignee = ? " + "ORDER BY created_at ASC LIMIT 1", + (CHIEF_ASSIGNEE,), + ).fetchone() + return dict(row) if row else None + finally: + conn.close() + + +def _board_task_counts(chief_id: str) -> dict[str, int]: + """Status → count map for the chief's board.""" + kb = _import_kanban_db() + try: + conn = kb.connect(board=chief_id) + except FileNotFoundError: + return {} + try: + rows = conn.execute( + "SELECT status, COUNT(*) AS n FROM tasks GROUP BY status" + ).fetchall() + return {r["status"]: int(r["n"]) for r in rows} + finally: + conn.close() + + +def _derive_stage(initial_task: Optional[dict], events: list) -> str: + """Cheap textual progress signal for Main.""" + if not initial_task: + return "no-initial-task" + status = initial_task.get("status", "unknown") + if status == "done": + return "completed" + if status == "ready": + return "queued" # dispatcher hasn't claimed yet + if status == "running": + # Prefer latest non-system commit/comment kind + for ev in events: + if ev.get("kind") in ("commented", "comment", "heartbeat"): + return f"running:{ev.get('kind')}" + return "running" + return status + + +# --------------------------------------------------------------------------- +# Handlers +# --------------------------------------------------------------------------- + +def _handle_chief_spawn(args: dict, **kw) -> str: + """Create a new chief board + initial ready task. Returns chief_id.""" + name = (args.get("name") or "").strip() + brief = (args.get("brief") or "").strip() + if not name: + return tool_error("chief_spawn: 'name' is required") + if not brief: + return tool_error("chief_spawn: 'brief' is required (the task description)") + lifetime = (args.get("lifetime") or DEFAULT_LIFETIME).lower() + if lifetime not in ("ephemeral", "permanent"): + return tool_error( + f"chief_spawn: lifetime must be 'ephemeral' or 'permanent', got {lifetime!r}" + ) + terminate_policy = (args.get("terminate_policy") or DEFAULT_TERMINATE_POLICY).lower() + if terminate_policy not in ("cascade", "independent"): + return tool_error( + f"chief_spawn: terminate_policy must be 'cascade' or 'independent', got " + f"{terminate_policy!r}" + ) + try: + max_runtime_min = int(args.get("max_runtime_min", DEFAULT_MAX_RUNTIME_MIN)) + except (TypeError, ValueError): + return tool_error("chief_spawn: max_runtime_min must be an integer") + if max_runtime_min < 1: + return tool_error("chief_spawn: max_runtime_min must be >= 1") + + parent_chief_id = args.get("parent_chief_id") + if parent_chief_id and not _read_chief_meta(parent_chief_id): + return tool_error( + f"chief_spawn: parent_chief_id {parent_chief_id!r} is not a known " + f"chief board" + ) + err = _check_recursion_depth(parent_chief_id) + if err: + return tool_error(err) + + kb = _import_kanban_db() + chief_id = _new_chief_id(name) + now = int(time.time()) + chief_meta = { + "kind": CHIEF_BOARD_KIND, + "lifetime": lifetime, + "terminate_policy": terminate_policy, + "max_runtime_min": max_runtime_min, + "parent_chief_id": parent_chief_id, + "spawned_at": now, + "spawned_by_task": os.environ.get("HERMES_KANBAN_TASK"), + } + + try: + kb.create_board( + chief_id, + name=f"{name} chief", + description=brief[:200], + meta_extra=chief_meta, + ) + except ValueError as e: + return tool_error(f"chief_spawn: failed to create board: {e}") + + # Create initial task assigned to chief-manager. Dispatcher will spawn + # the chief worker on next tick. + try: + conn = kb.connect(board=chief_id) + try: + task_id = "t_" + uuid.uuid4().hex[:8] + title = brief.splitlines()[0][:80] if brief else f"{name} brief" + conn.execute( + "INSERT INTO tasks (id, title, body, assignee, status, " + "created_at, priority, max_runtime_seconds) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + task_id, title, brief, CHIEF_ASSIGNEE, + "ready", now, 0, max_runtime_min * 60, + ), + ) + conn.commit() + finally: + conn.close() + except Exception as e: + logger.exception("chief_spawn: failed to create initial task") + return tool_error(f"chief_spawn: failed to create initial task: {e}") + + return _json_ok({ + "chief_id": chief_id, + "board": chief_id, + "initial_task": task_id, + "lifetime": lifetime, + "terminate_policy": terminate_policy, + "parent_chief_id": parent_chief_id, + }) + + +def _handle_chief_status(args: dict, **kw) -> str: + """Aggregate one chief's board state into a Main-friendly summary.""" + chief_id = (args.get("chief_id") or "").strip() + if not chief_id: + return tool_error("chief_status: 'chief_id' is required") + + meta = _read_chief_meta(chief_id) + if not meta: + return tool_error( + f"chief_status: {chief_id!r} is not a known chief board (or board " + f"was archived/deleted)" + ) + + counts = _board_task_counts(chief_id) + initial = _find_initial_task(chief_id) + + kb = _import_kanban_db() + events: list = [] + last_comment = None + try: + conn = kb.connect(board=chief_id) + try: + rows = conn.execute( + "SELECT * FROM task_events ORDER BY created_at DESC LIMIT 20" + ).fetchall() + events = [dict(r) for r in rows] + finally: + conn.close() + except FileNotFoundError: + pass + except Exception as e: + logger.warning("chief_status: events fetch failed: %s", e) + + if initial: + for ev in events: + if ev.get("kind") == "commented" and ev.get("task_id") == initial["id"]: + last_comment = ev.get("payload") + break + + open_count = ( + counts.get("ready", 0) + counts.get("running", 0) + + counts.get("blocked", 0) + counts.get("triage", 0) + + counts.get("todo", 0) + ) + alive = initial is not None and initial.get("status") in ( + "ready", "running", "blocked", "todo", "triage" + ) + + return _json_ok({ + "chief_id": chief_id, + "lifetime": meta.get("lifetime"), + "terminate_policy": meta.get("terminate_policy"), + "parent_chief_id": meta.get("parent_chief_id"), + "alive": alive, + "stage": _derive_stage(initial, events), + "initial_task": initial["id"] if initial else None, + "initial_status": initial.get("status") if initial else None, + "subtasks_total": sum(counts.values()), + "subtasks_open": open_count, + "subtasks_done": counts.get("done", 0), + "by_status": counts, + "last_comment": last_comment, + "last_event_at": events[0]["created_at"] if events else None, + "runtime_min": ( + (int(time.time()) - int(meta.get("spawned_at", 0))) // 60 + if meta.get("spawned_at") else None + ), + }) + + +def _handle_chief_list(args: dict, **kw) -> str: + """List every active chief. Returns compact summaries.""" + include_archived = bool(args.get("include_archived", False)) + chiefs = _list_chief_boards(include_archived=include_archived) + out = [] + for meta in chiefs: + cid = meta["slug"] + counts = _board_task_counts(cid) + initial = _find_initial_task(cid) + out.append({ + "chief_id": cid, + "name": meta.get("name"), + "lifetime": meta.get("lifetime"), + "terminate_policy": meta.get("terminate_policy"), + "parent_chief_id": meta.get("parent_chief_id"), + "alive": bool(initial and initial.get("status") in ( + "ready", "running", "blocked", "todo", "triage" + )), + "initial_status": initial.get("status") if initial else None, + "subtasks_open": ( + counts.get("ready", 0) + counts.get("running", 0) + + counts.get("blocked", 0) + counts.get("triage", 0) + + counts.get("todo", 0) + ), + "subtasks_done": counts.get("done", 0), + "spawned_at": meta.get("spawned_at"), + }) + return _json_ok({"chiefs": out, "count": len(out)}) + + +def _terminate_cascade(chief_id: str, force: bool, _visited=None) -> dict: + """Recursive cascade. Returns summary dict for the JSON response.""" + if _visited is None: + _visited = set() + if chief_id in _visited: + return {"chief_id": chief_id, "terminated": False, "reason": "already visited"} + _visited.add(chief_id) + + kb = _import_kanban_db() + cascaded: list[dict] = [] + # Walk children first so a worker never sees its parent gone before + # itself. + for meta in _list_chief_boards(): + if meta.get("parent_chief_id") == chief_id: + cascaded.append(_terminate_cascade(meta["slug"], force, _visited)) + + killed_workers = 0 + if force: + try: + conn = kb.connect(board=chief_id) + try: + rows = conn.execute( + "SELECT worker_pid FROM tasks " + "WHERE status = 'running' AND worker_pid IS NOT NULL" + ).fetchall() + finally: + conn.close() + for r in rows: + pid = r["worker_pid"] + if not pid: + continue + try: + os.kill(int(pid), signal.SIGTERM) + killed_workers += 1 + except (ProcessLookupError, PermissionError, ValueError): + pass + except FileNotFoundError: + pass + + # Archive the board so dispatcher stops ticking it. With our connect() + # no-resurrect fix (kanban_db.py FileNotFoundError gate), stale callers + # passing the gone slug won't recreate it. + try: + kb.remove_board(chief_id, archive=True) + except ValueError as e: + # default board is protected; chiefs are never named "default" + return {"chief_id": chief_id, "terminated": False, "error": str(e)} + + return { + "chief_id": chief_id, + "terminated": True, + "force": force, + "killed_workers": killed_workers, + "cascaded": cascaded, + } + + +def _handle_chief_terminate(args: dict, **kw) -> str: + """Terminate a chief. Policy selected at spawn determines behaviour.""" + chief_id = (args.get("chief_id") or "").strip() + if not chief_id: + return tool_error("chief_terminate: 'chief_id' is required") + force = bool(args.get("force", False)) + + meta = _read_chief_meta(chief_id) + if not meta: + return tool_error( + f"chief_terminate: {chief_id!r} is not a known chief board" + ) + + policy = meta.get("terminate_policy", DEFAULT_TERMINATE_POLICY) + if policy == "cascade": + result = _terminate_cascade(chief_id, force=force) + return _json_ok(result) + elif policy == "independent": + # Phase 2 — to be implemented. Keep the error structured so Main can + # detect this and fall back to cascade if it really needs cleanup. + return tool_error( + "chief_terminate: terminate_policy='independent' is not yet " + "implemented (Phase 2). Re-spawn the chief with " + "terminate_policy='cascade' or call chief_terminate on each " + "sub-chief manually before terminating this one." + ) + else: + return tool_error( + f"chief_terminate: unknown terminate_policy {policy!r}" + ) + + +# --------------------------------------------------------------------------- +# Output helpers +# --------------------------------------------------------------------------- + +def _json_ok(payload: dict) -> str: + """Stringify success payloads consistently with kanban_tools convention.""" + import json + return json.dumps({"ok": True, **payload}, ensure_ascii=False, default=str) + + +# --------------------------------------------------------------------------- +# Schemas +# --------------------------------------------------------------------------- + +CHIEF_SPAWN_SCHEMA = { + "name": "chief_spawn", + "description": ( + "Spawn a project-chief sub-agent for a complex task you don't want " + "to operate yourself. Creates a new isolated kanban board + initial " + "ready task assigned to 'chief-manager'. The dispatcher's per-board " + "tick will spawn a worker process that loads the chief-manager skill " + "and owns the project end-to-end. Returns chief_id you can pass to " + "chief_status / chief_terminate. Use this for long-running work " + "(>5 min, multiple stages, parallel sub-tasks); for small one-shots " + "use delegate_task instead." + ), + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": ( + "Short slug-friendly project name, e.g. 'yt-indexer' " + "or 'tax-report'. Becomes part of the chief_id." + ), + }, + "brief": { + "type": "string", + "description": ( + "Full task brief for the chief. This is the body of the " + "initial ready task — the chief will read it as 'your " + "assignment from upstream'. Include scope, acceptance " + "criteria, constraints, and any inputs / paths. Be " + "specific: the chief operates with no shared " + "conversation context with you." + ), + }, + "lifetime": { + "type": "string", + "enum": ["ephemeral", "permanent"], + "description": ( + "ephemeral (default): chief auto-completes once its " + "initial task is done. permanent: chief stays alive " + "and pulls additional tasks from its board until " + "explicitly terminated." + ), + }, + "terminate_policy": { + "type": "string", + "enum": ["cascade", "independent"], + "description": ( + "How termination propagates to sub-chiefs spawned BY " + "this chief. cascade (default): terminating recurses " + "into descendants. independent: descendants survive " + "(NYI, Phase 2)." + ), + }, + "max_runtime_min": { + "type": "integer", + "description": ( + "Hard ceiling on the initial task's runtime in minutes. " + "Default 120. Dispatcher will block the task with a " + "timeout reason if exceeded." + ), + }, + "parent_chief_id": { + "type": "string", + "description": ( + "Internal: set automatically when one chief spawns " + "another. main:manager should usually omit this." + ), + }, + }, + "required": ["name", "brief"], + }, +} + +CHIEF_STATUS_SCHEMA = { + "name": "chief_status", + "description": ( + "Get a compact progress summary for one chief. Includes stage, " + "subtask counts (open/done), last comment on the initial task, " + "runtime so far. Poll periodically (~every few minutes) to keep " + "your overview fresh without diving into the chief's board." + ), + "parameters": { + "type": "object", + "properties": { + "chief_id": { + "type": "string", + "description": "The chief_id returned by chief_spawn.", + }, + }, + "required": ["chief_id"], + }, +} + +CHIEF_LIST_SCHEMA = { + "name": "chief_list", + "description": ( + "List every chief board (kind=chief in board metadata) on this " + "host. Includes compact alive flag, subtask counts, parent chief " + "link. Use for an overview before deciding which chiefs to check " + "in detail (chief_status) or terminate (chief_terminate)." + ), + "parameters": { + "type": "object", + "properties": { + "include_archived": { + "type": "boolean", + "description": ( + "Include archived (terminated) chief boards. Defaults " + "to false — usually you only want live ones." + ), + }, + }, + "required": [], + }, +} + +CHIEF_TERMINATE_SCHEMA = { + "name": "chief_terminate", + "description": ( + "Terminate a chief and (per its terminate_policy) any sub-chiefs " + "it spawned. Archives the board so the dispatcher stops ticking " + "it. Use when the chief reports completion (alive=false in " + "chief_status) and is no longer needed, or when you decide the " + "work should stop." + ), + "parameters": { + "type": "object", + "properties": { + "chief_id": { + "type": "string", + "description": "The chief_id to terminate.", + }, + "force": { + "type": "boolean", + "description": ( + "If true, SIGTERM any running worker processes on this " + "chief's board immediately. If false (default), " + "workers finish their current step gracefully then exit " + "on next heartbeat (the board is already archived, so " + "they detect the stop signal)." + ), + }, + }, + "required": ["chief_id"], + }, +} + + +# --------------------------------------------------------------------------- +# Registry +# --------------------------------------------------------------------------- + +registry.register( + name="chief_spawn", + toolset="kanban", + schema=CHIEF_SPAWN_SCHEMA, + handler=_handle_chief_spawn, + check_fn=_check_chief_mode, + emoji="👑", +) + +registry.register( + name="chief_status", + toolset="kanban", + schema=CHIEF_STATUS_SCHEMA, + handler=_handle_chief_status, + check_fn=_check_chief_mode, + emoji="📊", +) + +registry.register( + name="chief_list", + toolset="kanban", + schema=CHIEF_LIST_SCHEMA, + handler=_handle_chief_list, + check_fn=_check_chief_mode, + emoji="📊", +) + +registry.register( + name="chief_terminate", + toolset="kanban", + schema=CHIEF_TERMINATE_SCHEMA, + handler=_handle_chief_terminate, + check_fn=_check_chief_mode, + emoji="🛑", +)