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). """Tests for the Kanban tool surface (tools/kanban_tools.py).
Verifies: Verifies:
- Tools are gated on HERMES_KANBAN_TASK: a normal chat session sees - A normal chat session sees zero kanban tools; dispatcher-spawned
zero kanban tools in its schema; a worker session sees the kanban set. workers see lifecycle tools; orchestrator profiles see board tools.
- Each handler's happy path. - Each handler's happy path.
- Error paths (missing required args, bad metadata type, etc). - 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): 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") monkeypatch.setenv("HERMES_KANBAN_TASK", "t_fake")
home = tmp_path / ".hermes" home = tmp_path / ".hermes"
home.mkdir() 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} 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_")} kanban = {n for n in names if n and n.startswith("kanban_")}
expected = { expected = {
"kanban_list",
"kanban_show", "kanban_complete", "kanban_block", "kanban_heartbeat", "kanban_show", "kanban_complete", "kanban_block", "kanban_heartbeat",
"kanban_comment", "kanban_create", "kanban_link", "kanban_comment", "kanban_create", "kanban_link",
"kanban_assign", "kanban_unblock", "kanban_archive",
} }
assert kanban == expected, f"expected {expected}, got {kanban}" assert kanban == expected, f"expected {expected}, got {kanban}"
def test_kanban_tools_visible_with_toolset_config(monkeypatch, tmp_path): def test_worker_with_kanban_toolset_still_hides_board_routing(monkeypatch, tmp_path):
"""Orchestrator profiles with toolsets: [kanban] see the same tools.""" """Task scope wins over profile config for board-routing tools."""
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) monkeypatch.setenv("HERMES_KANBAN_TASK", "t_fake")
home = tmp_path / ".hermes" home = tmp_path / ".hermes"
home.mkdir() home.mkdir()
(home / "config.yaml").write_text("toolsets:\n - kanban\n") (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_assign",
"kanban_unblock", "kanban_unblock",
"kanban_archive", "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 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.""" """kanban_list gives orchestrators filtered board discovery."""
monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
from hermes_cli import kanban_db as kb from hermes_cli import kanban_db as kb
conn = kb.connect() conn = kb.connect()
try: try:
@ -170,19 +194,81 @@ def test_list_filters_tasks(worker_env):
assert tenant_ids == [c] 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 from tools import kanban_tools as kt
out = kt._handle_list({"status": "not-a-state"}) out = kt._handle_list({"status": "not-a-state"})
assert "status must be one of" in json.loads(out).get("error", "") 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 from tools import kanban_tools as kt
assert json.loads(kt._handle_list({"limit": "nope"})).get("error") 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": 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 from hermes_cli import kanban_db as kb
conn = kb.connect() conn = kb.connect()
try: try:
@ -202,7 +288,8 @@ def test_list_parses_include_archived_string_false(worker_env):
assert archived not in ids 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 from hermes_cli import kanban_db as kb
conn = kb.connect() conn = kb.connect()
try: try:
@ -222,7 +309,8 @@ def test_list_parses_include_archived_string_true(worker_env):
assert archived in ids 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 from tools import kanban_tools as kt
out = kt._handle_list({"include_archived": "sometimes"}) out = kt._handle_list({"include_archived": "sometimes"})
assert "include_archived must be" in json.loads(out).get("error", "") 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 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 from tools import kanban_tools as kt
assert json.loads(kt._handle_assign({"assignee": "x"})).get("error") assert json.loads(kt._handle_assign({"assignee": "x"})).get("error")
assert json.loads(kt._handle_assign({"task_id": worker_env})).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 from tools import kanban_tools as kt
out = kt._handle_assign({"task_id": worker_env, "assignee": "reviewer"}) out = kt._handle_assign({"task_id": worker_env, "assignee": "reviewer"})
d = json.loads(out) d = json.loads(out)
@ -624,7 +714,8 @@ def test_unblock_happy_path(monkeypatch, worker_env):
conn.close() 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 from tools import kanban_tools as kt
out = kt._handle_unblock({"task_id": worker_env}) out = kt._handle_unblock({"task_id": worker_env})
assert json.loads(out).get("error") 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 # A worker process has HERMES_KANBAN_TASK set to its own task id. The
# destructive tools (kanban_complete, kanban_block, kanban_heartbeat, # lifecycle tools (kanban_complete, kanban_block, kanban_heartbeat) must
# kanban_assign, kanban_unblock, kanban_archive) must refuse to operate # refuse to operate on any OTHER task id, even if the caller supplies an
# on any OTHER task id, even if the caller supplies an explicit `task_id` # explicit `task_id` argument. Board-routing tools (kanban_list,
# argument. Workers legitimately call kanban_show / kanban_list / # kanban_assign, kanban_unblock, kanban_archive) are orchestrator-only and
# kanban_comment / kanban_create / kanban_link on other tasks, so those # hidden from dispatcher-spawned workers entirely.
# are unrestricted.
# #
# Orchestrator profiles (no HERMES_KANBAN_TASK in env) are intentionally # Orchestrator profiles (no HERMES_KANBAN_TASK in env) are intentionally
# exempt — their job is routing, and they sometimes close out child # 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", "") assert "refusing to mutate" in d.get("error", "")
def test_worker_assign_rejects_foreign_task_id(worker_env): def test_worker_assign_is_orchestrator_only(worker_env):
"""A worker cannot reassign a task that isn't its own.""" """A worker cannot use the assignment router, even on its own task."""
from hermes_cli import kanban_db as kb from hermes_cli import kanban_db as kb
conn = kb.connect() conn = kb.connect()
try: try:
@ -901,7 +991,7 @@ def test_worker_assign_rejects_foreign_task_id(worker_env):
from tools import kanban_tools as kt from tools import kanban_tools as kt
out = kt._handle_assign({"task_id": other, "assignee": "reviewer"}) out = kt._handle_assign({"task_id": other, "assignee": "reviewer"})
d = json.loads(out) d = json.loads(out)
assert "refusing to mutate" in d.get("error", "") assert "orchestrator-only" in d.get("error", "")
conn = kb.connect() conn = kb.connect()
try: try:
@ -910,8 +1000,8 @@ def test_worker_assign_rejects_foreign_task_id(worker_env):
conn.close() conn.close()
def test_worker_unblock_rejects_foreign_task_id(worker_env): def test_worker_unblock_is_orchestrator_only(worker_env):
"""A worker cannot unblock a task that isn't its own.""" """A worker cannot reopen blocked tasks through the router surface."""
from hermes_cli import kanban_db as kb from hermes_cli import kanban_db as kb
conn = kb.connect() conn = kb.connect()
try: try:
@ -923,7 +1013,7 @@ def test_worker_unblock_rejects_foreign_task_id(worker_env):
from tools import kanban_tools as kt from tools import kanban_tools as kt
out = kt._handle_unblock({"task_id": other}) out = kt._handle_unblock({"task_id": other})
d = json.loads(out) d = json.loads(out)
assert "refusing to mutate" in d.get("error", "") assert "orchestrator-only" in d.get("error", "")
conn = kb.connect() conn = kb.connect()
try: try:
@ -932,8 +1022,8 @@ def test_worker_unblock_rejects_foreign_task_id(worker_env):
conn.close() conn.close()
def test_worker_archive_rejects_foreign_task_id(worker_env): def test_worker_archive_is_orchestrator_only(worker_env):
"""A worker cannot archive a task that isn't its own.""" """A worker cannot archive tasks instead of completing or blocking."""
from hermes_cli import kanban_db as kb from hermes_cli import kanban_db as kb
conn = kb.connect() conn = kb.connect()
try: try:
@ -944,7 +1034,7 @@ def test_worker_archive_rejects_foreign_task_id(worker_env):
from tools import kanban_tools as kt from tools import kanban_tools as kt
out = kt._handle_archive({"task_id": other}) out = kt._handle_archive({"task_id": other})
d = json.loads(out) d = json.loads(out)
assert "refusing to mutate" in d.get("error", "") assert "orchestrator-only" in d.get("error", "")
conn = kb.connect() conn = kb.connect()
try: try:

View file

@ -1,8 +1,10 @@
"""Kanban tools — structured tool-call surface for worker + orchestrator agents. """Kanban tools — structured tool-call surface for worker + orchestrator agents.
These tools are only registered into the model's schema when the agent is Task lifecycle tools are registered into the model's schema when the
running under the dispatcher (env var ``HERMES_KANBAN_TASK`` set). A agent is running under the dispatcher (env var ``HERMES_KANBAN_TASK``
normal ``hermes chat`` session sees **zero** kanban tools in its schema. 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``? 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 Humans continue to use the CLI (``hermes kanban ``), the dashboard
(``hermes dashboard``), and the slash command (``/kanban ``) all (``hermes dashboard``), and the slash command (``/kanban ``) all
three bypass the agent entirely. The tools are ONLY for the worker three bypass the agent entirely. The tools are only for agent handoffs
agent's handoff back to the kernel. back to the kernel.
""" """
from __future__ import annotations from __future__ import annotations
@ -39,8 +41,25 @@ logger = logging.getLogger(__name__)
# Gating # 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: 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 1. ``HERMES_KANBAN_TASK`` is set (dispatcher-spawned worker), OR
2. The current profile has ``kanban`` in its toolsets config 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"): if os.environ.get("HERMES_KANBAN_TASK"):
return True 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 def _check_kanban_orchestrator_mode() -> bool:
# negligible overhead. The check_fn results are further TTL-cached """Board-routing tools are intentionally hidden from task workers.
# (~30s) by the tool registry.
try: Dispatcher-spawned workers should close their own task via the
from hermes_cli.config import load_config lifecycle tools (complete/block/heartbeat), not route or archive board
cfg = load_config() state. Profiles that explicitly opt into the kanban toolset and are
toolsets = cfg.get("toolsets", []) not scoped to a single task are the orchestrator surface.
return "kanban" in toolsets """
except Exception: if os.environ.get("HERMES_KANBAN_TASK"):
return False 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'" 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]: def _task_summary_dict(kb, conn, task) -> dict[str, Any]:
"""Compact task shape for board-listing tools.""" """Compact task shape for board-listing tools."""
parents = kb.parent_ids(conn, task.id) 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: def _handle_list(args: dict, **kw) -> str:
"""List task summaries with the same core filters as the CLI.""" """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") assignee = args.get("assignee")
status = args.get("status") status = args.get("status")
tenant = args.get("tenant") tenant = args.get("tenant")
@ -268,6 +302,8 @@ def _handle_list(args: dict, **kw) -> str:
if bool_error: if bool_error:
return tool_error(bool_error) return tool_error(bool_error)
limit = args.get("limit") limit = args.get("limit")
if limit is None:
limit = KANBAN_LIST_DEFAULT_LIMIT
if limit is not None: if limit is not None:
try: try:
limit = int(limit) limit = int(limit)
@ -275,23 +311,35 @@ def _handle_list(args: dict, **kw) -> str:
return tool_error("limit must be an integer") return tool_error("limit must be an integer")
if limit < 1: if limit < 1:
return tool_error("limit must be >= 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: try:
kb, conn = _connect() kb, conn = _connect()
try: try:
# Match CLI list: dependencies that cleared since the last # Match CLI list: dependencies that cleared since the last
# dispatcher tick should be visible to orchestrators immediately. # dispatcher tick should be visible to orchestrators immediately.
promoted = kb.recompute_ready(conn) promoted = kb.recompute_ready(conn)
tasks = kb.list_tasks( rows = kb.list_tasks(
conn, conn,
assignee=assignee, assignee=assignee,
status=status, status=status,
tenant=tenant, tenant=tenant,
include_archived=include_archived, 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({ return json.dumps({
"tasks": [_task_summary_dict(kb, conn, t) for t in tasks], "tasks": [_task_summary_dict(kb, conn, t) for t in tasks],
"count": len(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, "promoted": promoted,
}) })
finally: finally:
@ -539,6 +587,9 @@ def _handle_create(args: dict, **kw) -> str:
def _handle_assign(args: dict, **kw) -> str: def _handle_assign(args: dict, **kw) -> str:
"""Assign or reassign a task. ``assignee`` may be 'none' to unassign.""" """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") tid = args.get("task_id")
if not tid: if not tid:
return tool_error("task_id is required") 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: def _handle_unblock(args: dict, **kw) -> str:
"""Transition a blocked task back to ready.""" """Transition a blocked task back to ready."""
guard = _require_orchestrator_tool("kanban_unblock")
if guard:
return guard
tid = args.get("task_id") tid = args.get("task_id")
if not tid: if not tid:
return tool_error("task_id is required") 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: def _handle_archive(args: dict, **kw) -> str:
"""Archive a task so it leaves active board views.""" """Archive a task so it leaves active board views."""
guard = _require_orchestrator_tool("kanban_archive")
if guard:
return guard
tid = args.get("task_id") tid = args.get("task_id")
if not tid: if not tid:
return tool_error("task_id is required") 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, " "work to route. Supports the same core filters as the CLI: assignee, "
"status, tenant, include_archived, and limit. Returns compact rows " "status, tenant, include_archived, and limit. Returns compact rows "
"with ids, title, status, assignee, priority, parent/child ids, and " "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": { "parameters": {
"type": "object", "type": "object",
@ -695,7 +753,7 @@ KANBAN_LIST_SCHEMA = {
}, },
"limit": { "limit": {
"type": "integer", "type": "integer",
"description": "Optional maximum number of tasks to return.", "description": "Optional maximum rows to return (default 50, max 200).",
}, },
}, },
"required": [], "required": [],
@ -983,8 +1041,8 @@ KANBAN_ASSIGN_SCHEMA = {
"Assign or reassign a Kanban task to a profile/lane. Pass " "Assign or reassign a Kanban task to a profile/lane. Pass "
"assignee='none' to unassign. Refuses running tasks that still " "assignee='none' to unassign. Refuses running tasks that still "
"have an active claim, matching the CLI/kernel safety guard. " "have an active claim, matching the CLI/kernel safety guard. "
"Dispatcher-spawned workers may only assign their own task; " "Only orchestrator profiles with the kanban toolset can route board "
"orchestrator profiles with the kanban toolset can route board work." "work; dispatcher-spawned task workers never see this tool."
), ),
"parameters": { "parameters": {
"type": "object", "type": "object",
@ -1008,9 +1066,9 @@ KANBAN_ASSIGN_SCHEMA = {
KANBAN_UNBLOCK_SCHEMA = { KANBAN_UNBLOCK_SCHEMA = {
"name": "kanban_unblock", "name": "kanban_unblock",
"description": ( "description": (
"Move a blocked Kanban task back to ready. Dispatcher-spawned " "Move a blocked Kanban task back to ready. Only orchestrator profiles "
"workers may only unblock their own task; orchestrator profiles " "with the kanban toolset can unblock routed work; dispatcher-spawned "
"with the kanban toolset can unblock routed work." "task workers never see this tool."
), ),
"parameters": { "parameters": {
"type": "object", "type": "object",
@ -1029,8 +1087,8 @@ KANBAN_ARCHIVE_SCHEMA = {
"description": ( "description": (
"Archive a Kanban task so it leaves active board views. If a run is " "Archive a Kanban task so it leaves active board views. If a run is "
"still open, the kernel closes it as reclaimed. Dispatcher-spawned " "still open, the kernel closes it as reclaimed. Dispatcher-spawned "
"workers may only archive their own task; orchestrator profiles with " "task workers never see this tool; orchestrator profiles with the "
"the kanban toolset can archive routed work." "kanban toolset can archive routed work."
), ),
"parameters": { "parameters": {
"type": "object", "type": "object",
@ -1080,7 +1138,7 @@ registry.register(
toolset="kanban", toolset="kanban",
schema=KANBAN_LIST_SCHEMA, schema=KANBAN_LIST_SCHEMA,
handler=_handle_list, handler=_handle_list,
check_fn=_check_kanban_mode, check_fn=_check_kanban_orchestrator_mode,
emoji="📋", emoji="📋",
) )
@ -1134,7 +1192,7 @@ registry.register(
toolset="kanban", toolset="kanban",
schema=KANBAN_ASSIGN_SCHEMA, schema=KANBAN_ASSIGN_SCHEMA,
handler=_handle_assign, handler=_handle_assign,
check_fn=_check_kanban_mode, check_fn=_check_kanban_orchestrator_mode,
emoji="👤", emoji="👤",
) )
@ -1143,7 +1201,7 @@ registry.register(
toolset="kanban", toolset="kanban",
schema=KANBAN_UNBLOCK_SCHEMA, schema=KANBAN_UNBLOCK_SCHEMA,
handler=_handle_unblock, handler=_handle_unblock,
check_fn=_check_kanban_mode, check_fn=_check_kanban_orchestrator_mode,
emoji="", emoji="",
) )
@ -1152,7 +1210,7 @@ registry.register(
toolset="kanban", toolset="kanban",
schema=KANBAN_ARCHIVE_SCHEMA, schema=KANBAN_ARCHIVE_SCHEMA,
handler=_handle_archive, handler=_handle_archive,
check_fn=_check_kanban_mode, check_fn=_check_kanban_orchestrator_mode,
emoji="🗄", emoji="🗄",
) )

View file

@ -60,10 +60,10 @@ _HERMES_CORE_TOOLS = [
"send_message", "send_message",
# Home Assistant smart home control (gated on HASS_TOKEN via check_fn) # Home Assistant smart home control (gated on HASS_TOKEN via check_fn)
"ha_list_entities", "ha_get_state", "ha_list_services", "ha_call_service", "ha_list_entities", "ha_get_state", "ha_list_services", "ha_call_service",
# Kanban multi-agent coordination — only in schema when the agent is # Kanban multi-agent coordination. Lifecycle tools are visible to
# spawned as a kanban worker (HERMES_KANBAN_TASK env set) or the current # dispatcher-spawned workers; board-routing tools are visible only to
# profile explicitly enables the kanban toolset. Gated via check_fn in # non-worker profiles that explicitly enable the kanban toolset. Gated
# tools/kanban_tools.py. # per tool via check_fn in tools/kanban_tools.py.
"kanban_show", "kanban_list", "kanban_show", "kanban_list",
"kanban_complete", "kanban_block", "kanban_heartbeat", "kanban_complete", "kanban_block", "kanban_heartbeat",
"kanban_comment", "kanban_create", "kanban_link", "kanban_comment", "kanban_create", "kanban_link",
@ -218,14 +218,13 @@ TOOLSETS = {
"kanban": { "kanban": {
"description": ( "description": (
"Kanban multi-agent coordination — only active when the agent " "Kanban multi-agent coordination. The dispatcher runs inside "
"is spawned by the kanban dispatcher (HERMES_KANBAN_TASK env " "the gateway by default; see `kanban.dispatch_in_gateway` in "
"set). The dispatcher runs inside the gateway by default; see " "config.yaml. Dispatcher-spawned workers get lifecycle tools "
"`kanban.dispatch_in_gateway` in config.yaml. Lets workers mark " "for show/complete/block/heartbeat/comment/create/link. "
"tasks done with structured handoffs, block for human input, " "Non-worker orchestrator profiles that explicitly enable this "
"heartbeat during long ops, comment on threads, and (for " "toolset also get list/assign/unblock/archive board-routing "
"orchestrators) list, assign, unblock, archive, and fan out " "tools."
"tasks."
), ),
"tools": [ "tools": [
"kanban_show", "kanban_list", "kanban_complete", "kanban_block", "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 # 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. 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`: 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. - **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. 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 ## 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 | | 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_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_complete` | Finish with `summary` + `metadata` structured handoff. | at least one of `summary` / `result` |
| `kanban_block` | Escalate for human input with a `reason`. | `reason` | | `kanban_block` | Escalate for human input with a `reason`. | `reason` |
| `kanban_heartbeat` | Signal liveness during long operations. Pure side-effect. | — | | `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") 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` ### 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. 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. 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. The `kanban-worker` and `kanban-orchestrator` skills teach the model which tool to call when and in what order.