fix(kanban): restrict board routing tools

This commit is contained in:
Eric Litovsky 2026-05-06 11:23:42 -06:00
parent f7c395931f
commit ce35185782
5 changed files with 229 additions and 82 deletions

View file

@ -1,8 +1,8 @@
"""Tests for the Kanban tool surface (tools/kanban_tools.py).
Verifies:
- Tools are gated on HERMES_KANBAN_TASK: a normal chat session sees
zero kanban tools in its schema; a worker session sees the kanban set.
- A normal chat session sees zero kanban tools; dispatcher-spawned
workers see lifecycle tools; orchestrator profiles see board tools.
- Each handler's happy path.
- Error paths (missing required args, bad metadata type, etc).
"""
@ -40,7 +40,7 @@ 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 kanban tools."""
"""Worker sessions get task lifecycle tools, not board-routing tools."""
monkeypatch.setenv("HERMES_KANBAN_TASK", "t_fake")
home = tmp_path / ".hermes"
home.mkdir()
@ -55,17 +55,15 @@ def test_kanban_tools_visible_with_env_var(monkeypatch, tmp_path):
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_assign", "kanban_unblock", "kanban_archive",
}
assert kanban == expected, f"expected {expected}, got {kanban}"
def test_kanban_tools_visible_with_toolset_config(monkeypatch, tmp_path):
"""Orchestrator profiles with toolsets: [kanban] see the same tools."""
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
def test_worker_with_kanban_toolset_still_hides_board_routing(monkeypatch, tmp_path):
"""Task scope wins over profile config for board-routing tools."""
monkeypatch.setenv("HERMES_KANBAN_TASK", "t_fake")
home = tmp_path / ".hermes"
home.mkdir()
(home / "config.yaml").write_text("toolsets:\n - kanban\n")
@ -84,7 +82,32 @@ def test_kanban_tools_visible_with_toolset_config(monkeypatch, tmp_path):
"kanban_assign",
"kanban_unblock",
"kanban_archive",
}.issubset(kanban)
}.isdisjoint(kanban)
def test_kanban_tools_visible_with_toolset_config(monkeypatch, tmp_path):
"""Orchestrator profiles with toolsets: [kanban] see all board 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_assign", "kanban_unblock", "kanban_archive",
}
assert kanban == expected, f"expected {expected}, got {kanban}"
# ---------------------------------------------------------------------------
@ -140,8 +163,9 @@ def test_show_explicit_task_id(worker_env):
assert d["task"]["id"] == other
def test_list_filters_tasks(worker_env):
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:
@ -170,19 +194,81 @@ def test_list_filters_tasks(worker_env):
assert tenant_ids == [c]
def test_list_rejects_invalid_status(worker_env):
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(worker_env):
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")
assert json.loads(kt._handle_list({"limit": 201})).get("error")
def test_list_parses_include_archived_string_false(worker_env):
def test_list_defaults_limit_and_reports_truncation(monkeypatch, worker_env):
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
from hermes_cli import kanban_db as kb
conn = kb.connect()
try:
for i in range(55):
kb.create_task(conn, title=f"task {i:02d}", assignee="factory")
finally:
conn.close()
from tools import kanban_tools as kt
d = json.loads(kt._handle_list({"assignee": "factory"}))
assert d["count"] == 50
assert d["limit"] == 50
assert d["truncated"] is True
assert d["next_limit"] == 100
def test_list_treats_null_limit_as_default(monkeypatch, worker_env):
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
from hermes_cli import kanban_db as kb
conn = kb.connect()
try:
kb.create_task(conn, title="task", assignee="factory")
finally:
conn.close()
from tools import kanban_tools as kt
d = json.loads(kt._handle_list({"assignee": "factory", "limit": None}))
assert d["count"] == 1
assert d["limit"] == 50
assert d["truncated"] is False
def test_list_honors_explicit_limit_without_truncation(monkeypatch, worker_env):
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
from hermes_cli import kanban_db as kb
conn = kb.connect()
try:
for i in range(3):
kb.create_task(conn, title=f"task {i}", assignee="factory")
finally:
conn.close()
from tools import kanban_tools as kt
d = json.loads(kt._handle_list({"assignee": "factory", "limit": 10}))
assert d["count"] == 3
assert d["limit"] == 10
assert d["truncated"] is False
assert d["next_limit"] is None
def test_worker_list_is_orchestrator_only(worker_env):
from tools import kanban_tools as kt
out = kt._handle_list({})
assert "orchestrator-only" in json.loads(out).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:
@ -202,7 +288,8 @@ def test_list_parses_include_archived_string_false(worker_env):
assert archived not in ids
def test_list_parses_include_archived_string_true(worker_env):
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:
@ -222,7 +309,8 @@ def test_list_parses_include_archived_string_true(worker_env):
assert archived in ids
def test_list_rejects_bad_include_archived(worker_env):
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", "")
@ -588,13 +676,15 @@ def test_assign_can_unassign(monkeypatch, worker_env):
assert d["assignee"] is None
def test_assign_rejects_missing_args(worker_env):
def test_assign_rejects_missing_args(monkeypatch, worker_env):
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
from tools import kanban_tools as kt
assert json.loads(kt._handle_assign({"assignee": "x"})).get("error")
assert json.loads(kt._handle_assign({"task_id": worker_env})).get("error")
def test_assign_rejects_running_claimed_task(worker_env):
def test_assign_rejects_running_claimed_task(monkeypatch, worker_env):
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
from tools import kanban_tools as kt
out = kt._handle_assign({"task_id": worker_env, "assignee": "reviewer"})
d = json.loads(out)
@ -624,7 +714,8 @@ def test_unblock_happy_path(monkeypatch, worker_env):
conn.close()
def test_unblock_rejects_non_blocked_task(worker_env):
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")
@ -811,12 +902,11 @@ 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,
# kanban_assign, kanban_unblock, kanban_archive) 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.
# lifecycle 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. Board-routing tools (kanban_list,
# kanban_assign, kanban_unblock, kanban_archive) are orchestrator-only and
# hidden from dispatcher-spawned workers entirely.
#
# Orchestrator profiles (no HERMES_KANBAN_TASK in env) are intentionally
# exempt — their job is routing, and they sometimes close out child
@ -889,8 +979,8 @@ def test_worker_heartbeat_rejects_foreign_task_id(worker_env):
assert "refusing to mutate" in d.get("error", "")
def test_worker_assign_rejects_foreign_task_id(worker_env):
"""A worker cannot reassign a task that isn't its own."""
def test_worker_assign_is_orchestrator_only(worker_env):
"""A worker cannot use the assignment router, even on its own task."""
from hermes_cli import kanban_db as kb
conn = kb.connect()
try:
@ -901,7 +991,7 @@ def test_worker_assign_rejects_foreign_task_id(worker_env):
from tools import kanban_tools as kt
out = kt._handle_assign({"task_id": other, "assignee": "reviewer"})
d = json.loads(out)
assert "refusing to mutate" in d.get("error", "")
assert "orchestrator-only" in d.get("error", "")
conn = kb.connect()
try:
@ -910,8 +1000,8 @@ def test_worker_assign_rejects_foreign_task_id(worker_env):
conn.close()
def test_worker_unblock_rejects_foreign_task_id(worker_env):
"""A worker cannot unblock a task that isn't its own."""
def test_worker_unblock_is_orchestrator_only(worker_env):
"""A worker cannot reopen blocked tasks through the router surface."""
from hermes_cli import kanban_db as kb
conn = kb.connect()
try:
@ -923,7 +1013,7 @@ def test_worker_unblock_rejects_foreign_task_id(worker_env):
from tools import kanban_tools as kt
out = kt._handle_unblock({"task_id": other})
d = json.loads(out)
assert "refusing to mutate" in d.get("error", "")
assert "orchestrator-only" in d.get("error", "")
conn = kb.connect()
try:
@ -932,8 +1022,8 @@ def test_worker_unblock_rejects_foreign_task_id(worker_env):
conn.close()
def test_worker_archive_rejects_foreign_task_id(worker_env):
"""A worker cannot archive a task that isn't its own."""
def test_worker_archive_is_orchestrator_only(worker_env):
"""A worker cannot archive tasks instead of completing or blocking."""
from hermes_cli import kanban_db as kb
conn = kb.connect()
try:
@ -944,7 +1034,7 @@ def test_worker_archive_rejects_foreign_task_id(worker_env):
from tools import kanban_tools as kt
out = kt._handle_archive({"task_id": other})
d = json.loads(out)
assert "refusing to mutate" in d.get("error", "")
assert "orchestrator-only" in d.get("error", "")
conn = kb.connect()
try:

View file

@ -1,8 +1,10 @@
"""Kanban tools — structured tool-call surface for worker + orchestrator agents.
These tools are only registered into the model's schema when the agent is
running under the dispatcher (env var ``HERMES_KANBAN_TASK`` set). A
normal ``hermes chat`` session sees **zero** kanban tools in its schema.
Task lifecycle tools are registered into the model's schema when the
agent is running under the dispatcher (env var ``HERMES_KANBAN_TASK``
set). Board-routing tools are registered only for non-worker profiles
that explicitly enable the ``kanban`` toolset. A normal ``hermes chat``
session sees **zero** kanban tools in its schema.
Why tools instead of just shelling out to ``hermes kanban``?
@ -20,8 +22,8 @@ Why tools instead of just shelling out to ``hermes kanban``?
Humans continue to use the CLI (``hermes kanban ``), the dashboard
(``hermes dashboard``), and the slash command (``/kanban ``) all
three bypass the agent entirely. The tools are ONLY for the worker
agent's handoff back to the kernel.
three bypass the agent entirely. The tools are only for agent handoffs
back to the kernel.
"""
from __future__ import annotations
@ -39,8 +41,25 @@ logger = logging.getLogger(__name__)
# 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
@ -53,18 +72,20 @@ def _check_kanban_mode() -> bool:
"""
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 are intentionally hidden from task workers.
Dispatcher-spawned workers should close their own task via the
lifecycle tools (complete/block/heartbeat), not route or archive 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()
# ---------------------------------------------------------------------------
@ -159,6 +180,16 @@ def _parse_bool_arg(args: dict, name: str, *, default: bool = False):
return default, f"{name} must be a boolean or 'true'/'false'"
def _require_orchestrator_tool(tool_name: str) -> Optional[str]:
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)
@ -261,6 +292,9 @@ def _handle_show(args: dict, **kw) -> str:
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")
@ -268,6 +302,8 @@ def _handle_list(args: dict, **kw) -> str:
if bool_error:
return tool_error(bool_error)
limit = args.get("limit")
if limit is None:
limit = KANBAN_LIST_DEFAULT_LIMIT
if limit is not None:
try:
limit = int(limit)
@ -275,23 +311,35 @@ def _handle_list(args: dict, **kw) -> str:
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)
tasks = kb.list_tasks(
rows = kb.list_tasks(
conn,
assignee=assignee,
status=status,
tenant=tenant,
include_archived=include_archived,
limit=limit,
# Fetch one extra row so model-facing output can report that
# a bounded listing was truncated without dumping the board.
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:
@ -539,6 +587,9 @@ def _handle_create(args: dict, **kw) -> str:
def _handle_assign(args: dict, **kw) -> str:
"""Assign or reassign a task. ``assignee`` may be 'none' to unassign."""
guard = _require_orchestrator_tool("kanban_assign")
if guard:
return guard
tid = args.get("task_id")
if not tid:
return tool_error("task_id is required")
@ -567,6 +618,9 @@ def _handle_assign(args: dict, **kw) -> str:
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")
@ -589,6 +643,9 @@ def _handle_unblock(args: dict, **kw) -> str:
def _handle_archive(args: dict, **kw) -> str:
"""Archive a task so it leaves active board views."""
guard = _require_orchestrator_tool("kanban_archive")
if guard:
return guard
tid = args.get("task_id")
if not tid:
return tool_error("task_id is required")
@ -668,7 +725,8 @@ KANBAN_LIST_SCHEMA = {
"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. Also recomputes ready tasks before listing, matching the CLI."
"counts. Bounded to 50 rows by default, 200 max, with truncation "
"metadata. Also recomputes ready tasks before listing, matching the CLI."
),
"parameters": {
"type": "object",
@ -695,7 +753,7 @@ KANBAN_LIST_SCHEMA = {
},
"limit": {
"type": "integer",
"description": "Optional maximum number of tasks to return.",
"description": "Optional maximum rows to return (default 50, max 200).",
},
},
"required": [],
@ -983,8 +1041,8 @@ KANBAN_ASSIGN_SCHEMA = {
"Assign or reassign a Kanban task to a profile/lane. Pass "
"assignee='none' to unassign. Refuses running tasks that still "
"have an active claim, matching the CLI/kernel safety guard. "
"Dispatcher-spawned workers may only assign their own task; "
"orchestrator profiles with the kanban toolset can route board work."
"Only orchestrator profiles with the kanban toolset can route board "
"work; dispatcher-spawned task workers never see this tool."
),
"parameters": {
"type": "object",
@ -1008,9 +1066,9 @@ KANBAN_ASSIGN_SCHEMA = {
KANBAN_UNBLOCK_SCHEMA = {
"name": "kanban_unblock",
"description": (
"Move a blocked Kanban task back to ready. Dispatcher-spawned "
"workers may only unblock their own task; orchestrator profiles "
"with the kanban toolset can unblock routed work."
"Move a blocked Kanban task back to ready. Only orchestrator profiles "
"with the kanban toolset can unblock routed work; dispatcher-spawned "
"task workers never see this tool."
),
"parameters": {
"type": "object",
@ -1029,8 +1087,8 @@ KANBAN_ARCHIVE_SCHEMA = {
"description": (
"Archive a Kanban task so it leaves active board views. If a run is "
"still open, the kernel closes it as reclaimed. Dispatcher-spawned "
"workers may only archive their own task; orchestrator profiles with "
"the kanban toolset can archive routed work."
"task workers never see this tool; orchestrator profiles with the "
"kanban toolset can archive routed work."
),
"parameters": {
"type": "object",
@ -1080,7 +1138,7 @@ registry.register(
toolset="kanban",
schema=KANBAN_LIST_SCHEMA,
handler=_handle_list,
check_fn=_check_kanban_mode,
check_fn=_check_kanban_orchestrator_mode,
emoji="📋",
)
@ -1134,7 +1192,7 @@ registry.register(
toolset="kanban",
schema=KANBAN_ASSIGN_SCHEMA,
handler=_handle_assign,
check_fn=_check_kanban_mode,
check_fn=_check_kanban_orchestrator_mode,
emoji="👤",
)
@ -1143,7 +1201,7 @@ registry.register(
toolset="kanban",
schema=KANBAN_UNBLOCK_SCHEMA,
handler=_handle_unblock,
check_fn=_check_kanban_mode,
check_fn=_check_kanban_orchestrator_mode,
emoji="",
)
@ -1152,7 +1210,7 @@ registry.register(
toolset="kanban",
schema=KANBAN_ARCHIVE_SCHEMA,
handler=_handle_archive,
check_fn=_check_kanban_mode,
check_fn=_check_kanban_orchestrator_mode,
emoji="🗄",
)

View file

@ -60,10 +60,10 @@ _HERMES_CORE_TOOLS = [
"send_message",
# 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) or the current
# profile explicitly enables the kanban toolset. Gated via check_fn in
# tools/kanban_tools.py.
# Kanban multi-agent coordination. Lifecycle tools are visible to
# dispatcher-spawned workers; board-routing tools are visible only to
# non-worker profiles that explicitly enable the kanban toolset. Gated
# per tool via check_fn in tools/kanban_tools.py.
"kanban_show", "kanban_list",
"kanban_complete", "kanban_block", "kanban_heartbeat",
"kanban_comment", "kanban_create", "kanban_link",
@ -218,14 +218,13 @@ TOOLSETS = {
"kanban": {
"description": (
"Kanban multi-agent coordination — only active when the agent "
"is spawned by the kanban dispatcher (HERMES_KANBAN_TASK env "
"set). The dispatcher runs inside the gateway by default; see "
"`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) list, assign, unblock, archive, and fan out "
"tasks."
"Kanban multi-agent coordination. The dispatcher runs inside "
"the gateway by default; see `kanban.dispatch_in_gateway` in "
"config.yaml. Dispatcher-spawned workers get lifecycle tools "
"for show/complete/block/heartbeat/comment/create/link. "
"Non-worker orchestrator profiles that explicitly enable this "
"toolset also get list/assign/unblock/archive board-routing "
"tools."
),
"tools": [
"kanban_show", "kanban_list", "kanban_complete", "kanban_block",

View file

@ -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_list`, `kanban_complete`, `kanban_block`, `kanban_heartbeat`, `kanban_comment`, `kanban_create`, `kanban_link`, `kanban_assign`, `kanban_unblock`, `kanban_archive`). 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 dedicated `kanban_*` [lifecycle tools](./kanban#how-workers-interact-with-the-board) (`kanban_show`, `kanban_complete`, `kanban_block`, `kanban_heartbeat`, `kanban_comment`, `kanban_create`, `kanban_link`). Orchestrator profiles that opt into the `kanban` toolset also get board-routing tools (`kanban_list`, `kanban_assign`, `kanban_unblock`, `kanban_archive`). 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.

View file

@ -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_list`, `kanban_complete`, `kanban_block`, `kanban_heartbeat`, `kanban_comment`, `kanban_create`, `kanban_link`, `kanban_assign`, `kanban_unblock`, `kanban_archive`. 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.
- **Agents drive the board through a dedicated `kanban_*` toolset**task workers get lifecycle tools (`kanban_show`, `kanban_complete`, `kanban_block`, `kanban_heartbeat`, `kanban_comment`, `kanban_create`, `kanban_link`), while orchestrator profiles that explicitly enable `kanban` also get board-routing tools (`kanban_list`, `kanban_assign`, `kanban_unblock`, `kanban_archive`). 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,12 +231,12 @@ 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. 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.
**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 dedicated **kanban lifecycle tools** in the model's schema. Orchestrator profiles that enable `kanban` in their toolsets config also get board-routing tools for listing, assignment, unblocking, and archival. 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_list` | (Orchestrators) list task summaries with filters for `assignee`, `status`, `tenant`, archived visibility, and limit. Defaults to 50 rows, max 200, and reports truncation metadata. | — |
| `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. | — |
@ -282,7 +282,7 @@ kanban_create(
kanban_complete(summary="decomposed into 2 research tasks + 1 writer; linked dependencies")
```
The "(Orchestrators)" tools — `kanban_list`, `kanban_create`, `kanban_link`, `kanban_assign`, `kanban_unblock`, `kanban_archive`, 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.
The "(Orchestrators)" tools — `kanban_list`, `kanban_assign`, `kanban_unblock`, and `kanban_archive` — are only exposed to profiles that explicitly enable the `kanban` toolset and are not scoped to a dispatcher task. Dispatcher-spawned workers still get task lifecycle tools (`kanban_show`, `kanban_complete`, `kanban_block`, `kanban_heartbeat`, `kanban_comment`, `kanban_create`, `kanban_link`) and remain task-scoped for destructive lifecycle operations.
### Why tools instead of shelling to `hermes kanban`
@ -292,7 +292,7 @@ Three reasons:
2. **No shell-quoting fragility.** Passing `--metadata '{"files": [...]}'` through shlex + argparse is a latent footgun. Structured tool args skip it entirely.
3. **Better errors.** Tool results are structured JSON the model can reason about, not stderr strings it has to parse.
**Zero schema footprint on normal sessions.** A regular `hermes chat` session has zero `kanban_*` tools in its schema. The `check_fn` on each tool only returns True when `HERMES_KANBAN_TASK` is set, which only happens when the dispatcher spawned this process. No tool bloat for users who never touch kanban.
**Zero schema footprint on normal sessions.** A regular `hermes chat` session has zero `kanban_*` tools in its schema. Lifecycle tool `check_fn`s return true when `HERMES_KANBAN_TASK` is set, which only happens when the dispatcher spawned this process. Board-routing tool `check_fn`s return true only for non-worker profiles that explicitly enable the `kanban` toolset. No tool bloat for users who never touch kanban.
The `kanban-worker` and `kanban-orchestrator` skills teach the model which tool to call when and in what order.