Adding kanban board tools for orchestrator profiles
Cluster 20048. Data as of
Nothing open on GitHub. Fix merged in #23012 on 10 May 2026. Check it covers your case.
The short answer
- Known?
- Yes. 3 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #23012. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #23012. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These items concern the addition of kanban-related tools (list, unblock, assign, archive) intended for orchestrator profiles. The work appears to be developed across related pull requests, including at least one carve-out from a larger effort.
Open issues0
Open PRs0
Fixes on record
-
#23012 feat(kanban): add orchestrator board tools — kanban_list + kanban_unblock (carve-out of #20568)
files:
scripts/release.py,tests/tools/test_kanban_tools.py,tools/kanban_tools.py,toolsets.py,website/docs/user-guide/features/kanban-tutorial.md,website/docs/user-guide/features/kanban.mdShow the diff
-
scripts/release.py@@ -700,6 +700,7 @@ "[email protected]": "li0near", "[redacted:email]": "li0near", "[redacted:email]": "tymrtn", + "[redacted:email]": "kallidean", "[email protected]": "sicnuyudidi", "[redacted:email]": "haimu0x", "[redacted:email]": "NIDNASSER-Abdelmajid", -
tests/tools/test_kanban_tools.py@@ -2,7 +2,7 @@ Verifies: - Tools are gated on HERMES_KANBAN_TASK: a normal chat session sees - zero kanban tools in its schema; a worker session sees all seven. + zero kanban tools in its schema; a worker session sees the kanban set. - Each handler's happy path. - Error paths (missing required args, bad metadata type, etc). """ @@ -27,9 +27,10 @@ def test_kanban_tools_hidden_without_env_var(monkeypatch, tmp_path): monkeypatch.setenv("HERMES_HOME", str(home)) import tools.kanban_tools # ensure registered - from tools.registry import registry + from tools.registry import invalidate_check_fn_cache, registry from toolsets import resolve_toolset + invalidate_check_fn_cache() schema = registry.get_definitions(set(resolve_toolset("hermes-cli")), quiet=True) names = {s["function"].get("name") for s in schema if "function" in s} kanban = {n for n in names if n and n.startswith("kanban_")} @@ -39,16 +40,17 @@ def test_kanban_tools_hidden_without_env_var(monkeypatch, tmp_path): def test_kanban_tools_visible_with_env_var(monkeypatch, tmp_path): - """Worker sessions (HERMES_KANBAN_TASK set) must have all 7 tools.""" + """Worker sessions get task lifecycle tools, not board-routing tools.""" monkeypatch.setenv("HERMES_KANBAN_TASK", "t_fake") home = tmp_path / ".hermes" home.mkdir() monkeypatch.setenv("HERMES_HOME", str(home)) import tools.kanban_tools # ensure registered - from tools.registry import registry + from tools.registry import invalidate_check_fn_cache, registry from toolsets import resolve_toolset + invalidate_check_fn_cache() schema = registry.get_definitions(set(resolve_toolset("hermes-cli")), quiet=True) names = {s["function"].get("name") for s in schema if "function" in s} kanban = {n for n in names if n and n.startswith("kanban_")} @@ -59,6 +61,61 @@ def test_kanban_tools_visible_with_env_var(monkeypatch, tmp_path): assert kanban == expected, f"expected {expected}, got {kanban}" +def test_worker_with_kanban_toolset_still_hides_board_routing(monkeypatch, tmp_path): + """Task scope wins over profile config for board-routing tools. + + Even if a worker process happens to also have ``toolsets: [kanban]`` + in its config, the HERMES_KANBAN_TASK env var means it's a focused + worker and must not see kanban_list / kanban_unblock. + """ + monkeypatch.setenv("HERMES_KANBAN_TASK", "t_fake") + home = tmp_path / ".hermes" + home.mkdir() + (home / "config.yaml").write_text("toolsets:\n - kanban\n") + monkeypatch.setenv("HERMES_HOME", str(home)) + + import tools.kanban_tools # ensure registered + from tools.registry import invalidate_check_fn_cache, registry + from toolsets import resolve_toolset + + invalidate_check_fn_cache() + schema = registry.get_definitions(set(resolve_toolset("hermes-cli")), quiet=True) + names = {s["function"].get("name") for s in schema if "function" in s} + kanban = {n for n in names if n and n.startswith("kanban_")} + assert { + "kanban_list", + "kanban_unblock", + }.isdisjoint(kanban), ( + f"Board-routing tools leaked into worker schema: " + f"{kanban & {'kanban_list', 'kanban_unblock'}}" + ) + + +def test_kanban_tools_visible_with_toolset_config(monkeypatch, tmp_path): + """Orchestrator profiles with toolsets: [kanban] see all kanban tools.""" + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) + home = tmp_path / ".hermes" + home.mkdir() + (home / "config.yaml").write_text("toolsets:\n - kanban\n") + monkeypatch.setenv("HERMES_HOME", str(home)) + + import tools.kanban_tools # ensure registered + from tools.registry import invalidate_check_fn_cache, registry + from toolsets import resolve_toolset + + invalidate_check_fn_cache() + schema = registry.get_definitions(set(resolve_toolset("hermes-cli")), quiet=True) + names = {s["function"].get("name") for s in schema if "function" in s} + kanban = {n for n in names if n and n.startswith("kanban_")} + expected = { + "kanban_list", + "kanban_show", "kanban_complete", "kanban_block", "kanban_heartbeat", + "kanban_comment", "kanban_create", "kanban_link", + "kanban_unblock", + } + assert kanban == expected, f"expected {expected}, got {kanban}" + + # --------------------------------------------------------------------------- # Handler happy paths # --------------------------------------------------------------------------- @@ -112,6 +169,100 @@ def test_show_explicit_task_id(worker_env): assert d["task"]["id"] == other +def test_list_filters_tasks(monkeypatch, worker_env): + """kanban_list gives orchestrators filtered board discovery.""" + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) + from hermes_cli import kanban_db as kb + conn = kb.connect() + try: + a = kb.create_task(conn, title="alpha", assignee="factory", priority=5) + b = kb.create_task(conn, title="beta", assignee="reviewer") + c = kb.create_task(conn, title="gamma", assignee="factory", tenant="other") + finally: + conn.close() + + from tools import kanban_tools as kt + out = kt._handle_list({"assignee": "factory", "status": "ready", "limit": 10}) + d = json.loads(out) + ids = [t["id"] for t in d["tasks"]] + assert ids == [a, c] + assert d["count"] == 2 + assert d["tasks"][0]["title"] == "alpha" + assert d["tasks"][0]["parent_count"] == 0 + assert b not in ids + + tenant_out = kt._handle_list({ + "assignee": "factory", + "status": "ready", + "tenant": "other", + }) + tenant_ids = [t["id"] for t in json.loads(tenant_out)["tasks"]] + assert tenant_ids == [c] + + +def test_list_rejects_invalid_status(monkeypatch, worker_env): + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) + from tools import kanban_tools as kt + out = kt._handle_list({"status": "not-a-state"}) + assert "status must be one of" in json.loads(out).get("error", "") + + +def test_list_rejects_bad_limit(monkeypatch, worker_env): + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) + from tools import kanban_tools as kt + assert json.loads(kt._handle_list({"limit": "nope"})).get("error") + assert json.loads(kt._handle_list({"limit": 0})).get("error") + + +def test_list_parses_include_archived_string_false(monkeypatch, worker_env): + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) + from hermes_cli import kanban_db as kb + conn = kb.connect() + try: + live = kb.create_task(conn, title="live task", assignee="factory") + archived = kb.create_task(conn, title="archived task", assignee="factory") + assert kb.archive_task(conn, archived) + finally: + conn.close() + + from tools import kanban_tools as kt + out = kt._handle_list({ + "assignee": "factory", + "include_archived": "false", + }) + ids = [t["id"] for t in json.loads(out)["tasks"]] + assert live in ids + assert archived not in ids + + +def test_list_parses_include_archived_string_true(monkeypatch, worker_env): + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) + from hermes_cli import kanban_db as kb + conn = kb.connect() + try: + live = kb.create_task(conn, title="live task", assignee="factory") + archived = kb.create_task(conn, title="archived task", assignee="factory") + assert kb.archive_task(conn, archived) + finally: + conn.close() + + from tools import kanban_tools as kt + out = kt._handle_list({ + "assignee": "factory", + "include_archived": "true", + }) + ids = [t["id"] for t in json.loads(out)["tasks"]] + assert live in ids + assert archived in ids + + +def test_list_rejects_bad_include_archived(monkeypatch, worker_env): + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) + from tools import kanban_tools as kt + out = kt._handle_list({"include_archived": "sometimes"}) + assert "include_archived must be" in json.loads(out).get("error", "") + + def test_complete_happy_path(worker_env): from tools import kanban_tools as kt out = kt._handle_complete({ @@ -368,6 +519,52 @@ def test_create_rejects_non_list_parents(worker_env): assert json.loads(out).get("error") +def test_create_parses_triage_string_false(worker_env): + from tools import kanban_tools as kt + from hermes_cli import kanban_db as kb + out = kt._handle_create({ + "title": "not triage", + "assignee": "peer", + "triage": "false", + }) + d = json.loads(out) + assert d["ok"] is True + conn = kb.connect() + try: + task = kb.get_task(conn, d["task_id"]) + assert task.status == "ready" + finally: + conn.close() + + +def test_create_parses_triage_string_true(worker_env): + from tools import kanban_tools as kt + from hermes_cli import kanban_db as kb + out = kt._handle_create({ + "title": "needs triage", + "assignee": "peer", + "triage": "true", + }) + d = json.loads(out) + assert d["ok"] is True + conn = kb.connect() + try: + task = kb.get_task(conn, d["task_id"]) + assert task.status == "triage" + finally: + conn.close() + + +def test_create_rejects_bad_triage(worker_env): + from tools import kanban_tools as kt + out = kt._handle_create({ + "title": "bad triage", + "assignee": "peer", + "triage": "sometimes", + }) + assert "triage must be" in json.loads(out).get("error", "") + + def test_create_accepts_string_parent(worker_env): """Convenience: a single parent id as string is coerced to [id].""" from tools import kanban_tools as kt @@ -458,9 +655,35 @@ def test_link_rejects_cycle(worker_env): assert json.loads(out).get("error") -# --------------------------------------------------------------------------- -# End-to-end: simulate a full worker lifecycle through the tools -# --------------------------------------------------------------------------- +def test_unblock_happy_path(monkeypatch, worker_env): + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) + from hermes_cli import kanban_db as kb + conn = kb.connect() + try: + tid = kb.create_task(conn, title="blocked", assignee="worker") + kb.block_task(conn, tid, reason="waiting") + finally: + conn.close() + + from tools import kanban_tools as kt + out = kt._handle_unblock({"task_id": tid}) + d = json.loads(out) + assert d["ok"] is True + assert d["status"] == "ready" + + conn = kb.connect() + try: + assert kb.get_task(conn, tid).status == "ready" + finally: + conn.close() + + +def test_unblock_rejects_non_blocked_task(monkeypatch, worker_env): + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) + from tools import kanban_tools as kt + out = kt._handle_unblock({"task_id": worker_env}) + assert json.loads(out).get("error") + def test_worker_lifecycle_through_tools(worker_env): """Drive the full claim -> heartbeat -> comment -> complete lifecycle @@ -599,11 +822,12 @@ def test_kanban_guidance_prompt_size_bounded(monkeypatch, tmp_path): # --------------------------------------------------------------------------- # # A worker process has HERMES_KANBAN_TASK set to its own task id. The -# destructive tools (kanban_complete, kanban_block, kanban_heartbeat) -# must refuse to operate on any OTHER task id, even if the caller -# supplies an explicit `task_id` argument. Workers legitimately call -# kanban_show / kanban_comment / kanban_create / kanban_link on other -# tasks, so those are unrestricted. +# destructive tools (kanban_complete, kanban_block, kanban_heartbeat, +# kanban_unblock) must refuse to operate +# on any OTHER task id, even if the caller supplies an explicit `task_id` +# argument. Workers legitimately call kanban_show / kanban_list / +# kanban_comment / kanban_create / kanban_link on other tasks, so those +# are unrestricted. # # Orchestrator profiles (no HERMES_KANBAN_TASK in env) are intentionally # exempt — their job is routing, and they sometimes close out child @@ -712,6 +936,37 @@ def test_worker_can_comment_on_foreign_task(worker_env): conn.close() +def test_worker_unblock_rejects_foreign_task_id(worker_env): + """A worker cannot unblock any task — kanban_unblock is orchestrator-only. + + The check fires before the per-task ownership check, so the error + surface is the orchestrator-only refusal rather than the + cross-task-ownership refusal. Either is fine — the property we're + pinning is "worker cannot mutate foreign task via kanban_unblock". + """ + from hermes_cli import kanban_db as kb + conn = kb.connect() + try: + other = kb.create_task(conn, title="blocked sibling", assignee="peer") + kb.block_task(conn, other, reason="waiting") + finally: + conn.close() + + from tools import kanban_tools as kt + out = kt._handle_unblock({"task_id": other}) + d = json.loads(out) + err = d.get("error", "") + assert "orchestrator-only" in err or "refusing to mutate" in err, ( + f"expected worker-rejection error, got {err}" + ) + + conn = kb.connect() + try: + assert kb.get_task(conn, other).status == "blocked" + finally: + conn.close() + + def test_worker_complete_own_task_still_works(worker_env): """The ownership check doesn't break the normal own-task happy path.""" from tools import kanban_tools as kt -
tools/kanban_tools.py@@ -39,8 +39,25 @@ # Gating # --------------------------------------------------------------------------- +KANBAN_LIST_DEFAULT_LIMIT = 50 +KANBAN_LIST_MAX_LIMIT = 200 + + +def _profile_has_kanban_toolset() -> bool: + # Uses load_config() which has mtime-based caching, so this adds + # negligible overhead. The check_fn results are further TTL-cached + # (~30s) by the tool registry. + 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_kanban_mode() -> bool: - """Tools are available when: + """Task-lifecycle tools are available when: 1. ``HERMES_KANBAN_TASK`` is set (dispatcher-spawned worker), OR 2. The current profile has ``kanban`` in its toolsets config @@ -49,22 +66,25 @@ def _check_kanban_mode() -> bool: Humans running ``hermes chat`` without the kanban toolset see zero kanban tools. Workers spawned by the kanban dispatcher (gateway- embedded by default) and orchestrator profiles with the kanban - toolset enabled see all seven. + toolset enabled see the Kanban lifecycle tool surface. """ if os.environ.get("HERMES_KANBAN_TASK"): return True + return _profile_has_kanban_toolset() - # Check if the current profile has the kanban toolset enabled. - # Uses load_config() which has mtime-based caching, so this adds - # negligible overhead. The check_fn results are further TTL-cached - # (~30s) by the tool registry. - try: - from hermes_cli.config import load_config - cfg = load_config() - toolsets = cfg.get("toolsets", []) - return "kanban" in toolsets - except Exception: + +def _check_kanban_orchestrator_mode() -> bool: + """Board-routing tools (kanban_list, kanban_unblock) are intentionally + hidden from task workers. + + Dispatcher-spawned workers should close their own task via the + lifecycle tools (complete/block/heartbeat), not enumerate or unblock + board state. Profiles that explicitly opt into the kanban toolset + and are NOT scoped to a single task are the orchestrator surface. + """ + if os.environ.get("HERMES_KANBAN_TASK"): return False + return _profile_has_kanban_toolset() # --------------------------------------------------------------------------- @@ -135,6 +155,73 @@ def _ok(**fields: Any) -> str: return json.dumps({"ok": True, **fields}) +def _normalize_profile(value: Any) -> Optional[str]: + """Normalize CLI-compatible assignee sentinels for the tool surface.""" + if value is None: + return None + text = str(value).strip() + if not text or text.lower() in ("none", "-", "null"): + return None + return text + + +def _parse_bool_arg(args: dict, name: str, *, default: bool = False): + value = args.get(name) + if value is None: + return default, None + if isinstance(value, bool): + return value, None + text = str(value).strip().lower() + if text in ("true", "1", "yes"): + return True, None + if text in ("false", "0", "no"): + return False, None + return default, f"{name} must be a boolean or 'true'/'false'" + + +def _require_orchestrator_tool(tool_name: str) -> Optional[str]: + """Belt-and-suspenders runtime guard for orchestrator-only handlers. + + The check_fn (`_check_kanban_orchestrator_mode`) keeps these tools + out of the worker schema entirely, but in case a stale registration + or test harness routes a worker to one of them anyway, return a + structured tool_error so the model gets a clear refusal instead of + silently mutating board state from a worker context. + """ + if os.environ.get("HERMES_KANBAN_TASK"): + return tool_error( + f"{tool_name} is orchestrator-only; dispatcher-spawned workers " + "must use kanban_complete, kanban_block, kanban_heartbeat, or " + "kanban_comment for their assigned task." + ) + return None + + +def _task_summary_dict(kb, conn, task) -> dict[str, Any]: + """Compact task shape for board-listing tools.""" + parents = kb.parent_ids(conn, task.id) + children = kb.child_ids(conn, task.id) + return { + "id": task.id, + "title": task.title, + "assignee": task.assignee, + "status": task.status, + "priority": task.priority, + "tenant": task.tenant, + "workspace_kind": task.workspace_kind, + "workspace_path": task.workspace_path, + "created_by": task.created_by, + "created_at": task.created_at, + "started_at": task.started_at, + "completed_at": task.completed_at, + "current_run_id": task.current_run_id, + "parents": parents, + "children": children, + "parent_count": len(parents), + "child_count": len(children), + } + + # --------------------------------------------------------------------------- # Handlers # --------------------------------------------------------------------------- @@ -210,6 +297,66 @@ def _run_dict(r): return tool_error(f"kanban_show: {e}") +def _handle_list(args: dict, **kw) -> str: + """List task summaries with the same core filters as the CLI.""" + guard = _require_orchestrator_tool("kanban_list") + if guard: + return guard + assignee = args.get("assignee") + status = args.get("status") + tenant = args.get("tenant") + include_archived, bool_error = _parse_bool_arg(args, "include_archived") + if bool_error: + return tool_error(bool_error) + limit = args.get("limit") + if limit is None: + limit = KANBAN_LIST_DEFAULT_LIMIT + try: + limit = int(limit) + except (TypeError, ValueError): + return tool_error("limit must be an integer") + if limit < 1: + return tool_error("limit must be >= 1") + if limit > KANBAN_LIST_MAX_LIMIT: + return tool_error(f"limit must be <= {KANBAN_LIST_MAX_LIMIT}") + try: + kb, conn = _connect() + try: + # Match CLI list: dependencies that cleared since the last + # dispatcher tick should be visible to orchestrators immediately. + promoted = kb.recompute_ready(conn) + # Fetch one extra row so model-facing output can report that + # a bounded listing was truncated without dumping the board. + rows = kb.list_tasks( + conn, + assignee=assignee, + status=status, + tenant=tenant, + include_archived=include_archived, + limit=limit + 1, + ) + truncated = len(rows) > limit + tasks = rows[:limit] + return json.dumps({ + "tasks": [_task_summary_dict(kb, conn, t) for t in tasks], + "count": len(tasks), + "limit": limit, + "truncated": truncated, + "next_limit": ( + min(limit * 2, KANBAN_LIST_MAX_LIMIT) + if truncated and limit < KANBAN_LIST_MAX_LIMIT else None + ), + "promoted": promoted, + }) + finally: + conn.close() + except ValueError as e: + return tool_error(f"kanban_list: {e}") + except Exception as e: + logger.exception("kanban_list failed") + return tool_error(f"kanban_list: {e}") + + def _handle_complete(args: dict, **kw) -> str: """Mark the current task done with a structured handoff.""" tid = _default_task_id(args.get("task_id")) @@ -416,7 +563,9 @@ def _handle_create(args: dict, **kw) -> str: priority = args.get("priority") workspace_kind = args.get("workspace_kind") or "scratch" workspace_path = args.get("workspace_path") - triage = bool(args.get("triage")) + triage, bool_error = _parse_bool_arg(args, "triage") + if bool_error: + return tool_error(bool_error) idempotency_key = args.get("idempotency_key") max_runtime_seconds = args.get("max_runtime_seconds") skills = args.get("skills") @@ -467,6 +616,31 @@ def _handle_create(args: dict, **kw) -> str: return tool_error(f"kanban_create: {e}") +def _handle_unblock(args: dict, **kw) -> str: + """Transition a blocked task back to ready.""" + guard = _require_orchestrator_tool("kanban_unblock") + if guard: + return guard + tid = args.get("task_id") + if not tid: + return tool_error("task_id is required") + ownership_err = _enforce_worker_task_ownership(str(tid)) + if ownership_err: + return ownership_err + try: + kb, conn = _connect() + try: + ok = kb.unblock_task(conn, str(tid)) + if not ok: + return tool_error(f"could not unblock {tid} (not blocked or unknown)") + return _ok(task_id=str(tid), status="ready") + finally: + conn.close() + except Exception as e: + logger.exception("kanban_unblock failed") + return tool_error(f"kanban_unblock: {e}") + + def _handle_link(args: dict, **kw) -> str: """Add a parent→child dependency edge after the fact.""" parent_id = args.get("parent_id") @@ -519,6 +693,50 @@ def _handle_link(args: dict, **kw) -> str: }, } +KANBAN_LIST_SCHEMA = { + "name": "kanban_list", + "description": ( + "List Kanban task summaries so an orchestrator profile can discover " + "work to route. Supports the same core filters as the CLI: assignee, " + "status, tenant, include_archived, and limit. Returns compact rows " + "with ids, title, status, assignee, priority, parent/child ids, and " + "counts. Bounded to 50 rows by default, 200 max, with truncation " + "metadata. Also recomputes ready tasks before listing, matching the " + "CLI. Orchestrator-only — dispatcher-spawned task workers never see " + "this tool." + ), + "parameters": { + "type": "object", + "properties": { + "assignee": { + "type": "string", + "description": "Optional assignee/profile filter.", + }, + "status": { + "type": "string", + "enum": [ + "triage", "todo", "ready", "running", + "blocked", "done", "archived", + ], + "description": "Optional task status filter.", + }, + "tenant": { + "type": "string", + "description": "Optional tenant/project namespace filter.", + }, + "include_archived": { + "type": "boolean", + "description": "Include archived tasks. Defaults to false.", + }, + "limit": { + "type": "integer", + "description": "Optional maximum rows to return (default 50, max 200).", + }, + }, + "required": [], + }, +} + KANBAN_COMPLETE_SCHEMA = { "name": "kanban_complete", "description": ( @@ -787,6 +1005,25 @@ def _handle_link(args: dict, **kw) -> str: }, } +KANBAN_UNBLOCK_SCHEMA = { + "name": "kanban_unblock", + "description": ( + "Move a blocked Kanban task back to ready. Orchestrator-only — only " + "profiles with the kanban toolset can unblock routed work; " + "dispatcher-spawned task workers never see this tool." + ), + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Blocked task id to return to ready.", + }, + }, + "required": ["task_id"], + }, +} + KANBAN_LINK_SCHEMA = { "name": "kanban_link", "description": ( @@ -818,6 +1055,15 @@ def _handle_link(args: dict, **kw) -> str: emoji="📋", ) +registry.register( + name="kanban_list", + toolset="kanban", + schema=KANBAN_LIST_SCHEMA, + handler=_handle_list, + check_fn=_check_kanban_orchestrator_mode, + emoji="📋", +) + registry.register( name="kanban_complete", toolset="kanban", @@ -863,6 +1109,15 @@ def _handle_link(args: dict, **kw) -> str: emoji="➕", ) +registry.register( + name="kanban_unblock", + toolset="kanban", + schema=KANBAN_UNBLOCK_SCHEMA, + handler=_handle_unblock, + check_fn=_check_kanban_orchestrator_mode, + emoji="▶", +) + registry.register( name="kanban_link", toolset="kanban", -
toolsets.py@@ -61,10 +61,13 @@ # Home Assistant smart home control (gated on HASS_TOKEN via check_fn) "ha_list_entities", "ha_get_state", "ha_list_services", "ha_call_service", # Kanban multi-agent coordination — only in schema when the agent is - # spawned as a kanban worker (HERMES_KANBAN_TASK env set), otherwise - # zero schema footprint. Gated via check_fn in tools/kanban_tools.py. - "kanban_show", "kanban_complete", "kanban_block", "kanban_heartbeat", + # spawned as a kanban worker (HERMES_KANBAN_TASK env set) or the current + # profile explicitly enables the kanban toolset. Gated via check_fn in + # tools/kanban_tools.py. + "kanban_show", "kanban_list", + "kanban_complete", "kanban_block", "kanban_heartbeat", "kanban_comment", "kanban_create", "kanban_link", + "kanban_unblock", # Computer use (macOS, gated on cua-driver being installed via check_fn) "computer_use", ] @@ -233,12 +236,13 @@ "`kanban.dispatch_in_gateway` in config.yaml. Lets workers mark " "tasks done with structured handoffs, block for human input, " "heartbeat during long ops, comment on threads, and (for " - "orchestrators) fan out into child tasks." + "orchestrators) list, unblock, and fan out tasks." ), "tools": [ - "kanban_show", "kanban_complete", "kanban_block", + "kanban_show", "kanban_list", "kanban_complete", "kanban_block", "kanban_heartbeat", "kanban_comment", "kanban_create", "kanban_link", + "kanban_unblock", ], "includes": [], }, -
website/docs/user-guide/features/kanban-tutorial.md@@ -10,7 +10,7 @@ hermes dashboard # opens http://127.0.0.1:9119 in your browser # click Kanban in the left nav ``` -The dashboard is the most comfortable place for **you** to watch the system. Agent workers the dispatcher spawns never see the dashboard or the CLI — they drive the board through a dedicated `kanban_*` [toolset](./kanban#how-workers-interact-with-the-board) (`kanban_show`, `kanban_complete`, `kanban_block`, `kanban_heartbeat`, `kanban_comment`, `kanban_create`, `kanban_link`). All three surfaces — dashboard, CLI, worker tools — route through the same per-board SQLite DB (`~/.hermes/kanban.db` for the default board, `~/.hermes/kanban/boards/<slug>/kanban.db` for any board you create later), so each board is consistent no matter which side of the fence a change came from. +The dashboard is the most comfortable place for **you** to watch the system. Agent workers the dispatcher spawns never see the dashboard or the CLI — they drive the board through a dedicated `kanban_*` [toolset](./kanban#how-workers-interact-with-the-board) (`kanban_show`, `kanban_list`, `kanban_complete`, `kanban_block`, `kanban_heartbeat`, `kanban_comment`, `kanban_create`, `kanban_link`, `kanban_unblock`). All three surfaces — dashboard, CLI, worker tools — route through the same per-board SQLite DB (`~/.hermes/kanban.db` for the default board, `~/.hermes/kanban/boards/<slug>/kanban.db` for any board you create later), so each board is consistent no matter which side of the fence a change came from. This tutorial uses the `default` board throughout. If you want multiple isolated queues (one per project / repo / domain), see [Boards (multi-project)](./kanban#boards-multi-project) in the overview — the same CLI / dashboard / worker flows apply per board, and workers physically cannot see tasks on other boards. -
website/docs/user-guide/features/kanban.md@@ -14,7 +14,7 @@ Hermes Kanban is a durable task board, shared across all your Hermes profiles, t The board has two front doors, both backed by the same `~/.hermes/kanban.db`: -- **Agents drive the board through a dedicated `kanban_*` toolset** — `kanban_show`, `kanban_complete`, `kanban_block`, `kanban_heartbeat`, `kanban_comment`, `kanban_create`, `kanban_link`. The dispatcher spawns each worker with these tools already in its schema; the model reads its task and hands work off by calling them directly, *not* by shelling out to `hermes kanban`. See [How workers interact with the board](#how-workers-interact-with-the-board) below. +- **Agents drive the board through a dedicated `kanban_*` toolset** — `kanban_show`, `kanban_list`, `kanban_complete`, `kanban_block`, `kanban_heartbeat`, `kanban_comment`, `kanban_create`, `kanban_link`, `kanban_unblock`. The dispatcher spawns each worker with these tools already in its schema; orchestrator profiles can also enable the `kanban` toolset explicitly. The model reads and routes tasks by calling tools directly, *not* by shelling out to `hermes kanban`. See [How workers interact with the board](#how-workers-interact-with-the-board) below. - **You (and scripts, and cron) drive the board through `hermes kanban …`** on the CLI, `/kanban …` as a slash command, or the dashboard. These are for humans and automation — the places without a tool-calling model behind them. Both surfaces route through the same `kanban_db` layer, so reads see a consistent view and writes can't drift. The rest of this page shows CLI examples because they're easy to copy-paste, but every CLI verb has a tool-call equivalent the model uses. @@ -231,17 +231,19 @@ hermes kanban block t_abc "need input" --ids t_def t_hij ## How workers interact with the board -**Workers do not shell out to `hermes kanban`.** When the dispatcher spawns a worker it sets `HERMES_KANBAN_TASK=t_abcd` in the child's env, and that env var flips on a dedicated **kanban toolset** in the model's schema — seven tools that read and mutate the board directly via the Python `kanban_db` layer, same as the CLI does. A running worker calls these like any other tool; it never sees or needs the `hermes kanban` CLI. +**Workers do not shell out to `hermes kanban`.** When the dispatcher spawns a worker it sets `HERMES_KANBAN_TASK=t_abcd` in the child's env, and that env var flips on a dedicated **kanban toolset** in the model's schema. The same toolset is also available to orchestrator profiles that enable `kanban` in their toolsets config. These tools read and mutate the board directly via the Python `kanban_db` layer, same as the CLI does. A running worker calls these like any other tool; it never sees or needs the `hermes kanban` CLI. | Tool | Purpose | Required params | |---|---|---| | `kanban_show` | Read the current task (title, body, prior attempts, parent handoffs, comments, full pre-formatted `worker_context`). Defaults to the env's task id. | — | +| `kanban_list` | List task summaries with filters for `assignee`, `status`, `tenant`, archived visibility, and limit. Intended for orchestrators discovering board work. | — | | `kanban_complete` | Finish with `summary` + `metadata` structured handoff. | at least one of `summary` / `result` | | `kanban_block` | Escalate for human input with a `reason`. | `reason` | | `kanban_heartbeat` | Signal liveness during long operations. Pure side-effect. | — | | `kanban_comment` | Append a durable note to the task thread. | `task_id`, `body` | | `kanban_create` | (Orchestrators) fan out into child tasks with an `assignee`, optional `parents`, `skills`, etc. | `title`, `assignee` | | `kanban_link` | (Orchestrators) add a `parent_id → child_id` dependency edge after the fact. | `parent_id`, `child_id` | +| `kanban_unblock` | (Orchestrators) move a blocked task back to `ready`. | `task_id` | A typical worker turn looks like: @@ -278,7 +280,7 @@ kanban_create( kanban_complete(summary="decomposed into 2 research tasks + 1 writer; linked dependencies") ``` -The three "(Orchestrators)" tools — `kanban_create`, `kanban_link`, and `kanban_comment` on foreign tasks — are available to every worker; the convention (enforced by the `kanban-orchestrator` skill) is that worker profiles don't fan out and orchestrator profiles don't execute. +The "(Orchestrators)" tools — `kanban_list`, `kanban_create`, `kanban_link`, `kanban_unblock`, and `kanban_comment` on foreign tasks — are available through the same toolset; the convention (enforced by the `kanban-orchestrator` skill) is that worker profiles don't fan out or route unrelated work, and orchestrator profiles don't execute implementation work. Dispatcher-spawned workers are still task-scoped for destructive lifecycle operations and cannot mutate unrelated tasks. ### Why tools instead of shelling to `hermes kanban`
-
1 PR closed without merging
From the close comments: merged via another change 1
-
#20568 feat(kanban): add orchestrator board tools · merged via another change →
#23012
Closed issues · 1
- Closed#20048[Feature]: Add kanban_list, kanban_unblock, kanban_assign, kanban_archive tools for orchestrator profiles
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Merged PRs
- #23012
- Closed issues
- 1
- Attempts
- 1 PR closed without merging
- Fix link checked
- 10 May 2026