feat(kanban): add orchestrator board tools

This commit is contained in:
Eric Litovsky 2026-05-05 22:59:30 -06:00 committed by Teknium
parent 44cdf555a8
commit 236cbe16b6
5 changed files with 321 additions and 22 deletions

View file

@ -2,7 +2,7 @@
Verifies: Verifies:
- Tools are gated on HERMES_KANBAN_TASK: a normal chat session sees - 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. - Each handler's happy path.
- Error paths (missing required args, bad metadata type, etc). - 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)) monkeypatch.setenv("HERMES_HOME", str(home))
import tools.kanban_tools # ensure registered 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 from toolsets import resolve_toolset
invalidate_check_fn_cache()
schema = registry.get_definitions(set(resolve_toolset("hermes-cli")), quiet=True) schema = registry.get_definitions(set(resolve_toolset("hermes-cli")), quiet=True)
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_")}
@ -39,26 +40,51 @@ 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 all 7 tools.""" """Worker sessions (HERMES_KANBAN_TASK set) must have kanban 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()
monkeypatch.setenv("HERMES_HOME", str(home)) monkeypatch.setenv("HERMES_HOME", str(home))
import tools.kanban_tools # ensure registered 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 from toolsets import resolve_toolset
invalidate_check_fn_cache()
schema = registry.get_definitions(set(resolve_toolset("hermes-cli")), quiet=True) schema = registry.get_definitions(set(resolve_toolset("hermes-cli")), quiet=True)
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_unblock",
} }
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):
"""Orchestrator profiles with toolsets: [kanban] see the same 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_")}
assert {
"kanban_list",
"kanban_unblock",
}.issubset(kanban)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Handler happy paths # Handler happy paths
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -112,6 +138,48 @@ 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):
"""kanban_list gives orchestrators filtered board discovery."""
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(worker_env):
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):
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_complete_happy_path(worker_env): def test_complete_happy_path(worker_env):
from tools import kanban_tools as kt from tools import kanban_tools as kt
out = kt._handle_complete({ out = kt._handle_complete({
@ -458,9 +526,34 @@ def test_link_rejects_cycle(worker_env):
assert json.loads(out).get("error") assert json.loads(out).get("error")
# --------------------------------------------------------------------------- def test_unblock_happy_path(monkeypatch, worker_env):
# End-to-end: simulate a full worker lifecycle through the tools 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(worker_env):
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): def test_worker_lifecycle_through_tools(worker_env):
"""Drive the full claim -> heartbeat -> comment -> complete lifecycle """Drive the full claim -> heartbeat -> comment -> complete lifecycle
@ -599,11 +692,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 # A worker process has HERMES_KANBAN_TASK set to its own task id. The
# destructive tools (kanban_complete, kanban_block, kanban_heartbeat) # destructive tools (kanban_complete, kanban_block, kanban_heartbeat,
# must refuse to operate on any OTHER task id, even if the caller # kanban_unblock) must refuse to operate
# supplies an explicit `task_id` argument. Workers legitimately call # on any OTHER task id, even if the caller supplies an explicit `task_id`
# kanban_show / kanban_comment / kanban_create / kanban_link on other # argument. Workers legitimately call kanban_show / kanban_list /
# tasks, so those are unrestricted. # kanban_comment / kanban_create / kanban_link on other tasks, so those
# 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
@ -712,6 +806,28 @@ def test_worker_can_comment_on_foreign_task(worker_env):
conn.close() conn.close()
def test_worker_unblock_rejects_foreign_task_id(worker_env):
"""A worker cannot unblock a task that isn't its own."""
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)
assert "refusing to mutate" in d.get("error", "")
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): def test_worker_complete_own_task_still_works(worker_env):
"""The ownership check doesn't break the normal own-task happy path.""" """The ownership check doesn't break the normal own-task happy path."""
from tools import kanban_tools as kt from tools import kanban_tools as kt

View file

@ -49,7 +49,7 @@ def _check_kanban_mode() -> bool:
Humans running ``hermes chat`` without the kanban toolset see zero Humans running ``hermes chat`` without the kanban toolset see zero
kanban tools. Workers spawned by the kanban dispatcher (gateway- kanban tools. Workers spawned by the kanban dispatcher (gateway-
embedded by default) and orchestrator profiles with the kanban embedded by default) and orchestrator profiles with the kanban
toolset enabled see all seven. toolset enabled see the Kanban tool surface.
""" """
if os.environ.get("HERMES_KANBAN_TASK"): if os.environ.get("HERMES_KANBAN_TASK"):
return True return True
@ -135,6 +135,41 @@ def _ok(**fields: Any) -> str:
return json.dumps({"ok": True, **fields}) 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 _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 # Handlers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -210,6 +245,48 @@ def _handle_show(args: dict, **kw) -> str:
return tool_error(f"kanban_show: {e}") 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."""
assignee = args.get("assignee")
status = args.get("status")
tenant = args.get("tenant")
include_archived = bool(args.get("include_archived"))
limit = args.get("limit")
if limit is not None:
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")
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(
conn,
assignee=assignee,
status=status,
tenant=tenant,
include_archived=include_archived,
limit=limit,
)
return json.dumps({
"tasks": [_task_summary_dict(kb, conn, t) for t in tasks],
"count": len(tasks),
"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: def _handle_complete(args: dict, **kw) -> str:
"""Mark the current task done with a structured handoff.""" """Mark the current task done with a structured handoff."""
tid = _default_task_id(args.get("task_id")) tid = _default_task_id(args.get("task_id"))
@ -467,6 +544,28 @@ def _handle_create(args: dict, **kw) -> str:
return tool_error(f"kanban_create: {e}") return tool_error(f"kanban_create: {e}")
def _handle_unblock(args: dict, **kw) -> str:
"""Transition a blocked task back to ready."""
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: def _handle_link(args: dict, **kw) -> str:
"""Add a parent→child dependency edge after the fact.""" """Add a parent→child dependency edge after the fact."""
parent_id = args.get("parent_id") parent_id = args.get("parent_id")
@ -519,6 +618,47 @@ KANBAN_SHOW_SCHEMA = {
}, },
} }
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. Also recomputes ready tasks before listing, matching the CLI."
),
"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 number of tasks to return.",
},
},
"required": [],
},
}
KANBAN_COMPLETE_SCHEMA = { KANBAN_COMPLETE_SCHEMA = {
"name": "kanban_complete", "name": "kanban_complete",
"description": ( "description": (
@ -787,6 +927,25 @@ KANBAN_CREATE_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."
),
"parameters": {
"type": "object",
"properties": {
"task_id": {
"type": "string",
"description": "Blocked task id to return to ready.",
},
},
"required": ["task_id"],
},
}
KANBAN_LINK_SCHEMA = { KANBAN_LINK_SCHEMA = {
"name": "kanban_link", "name": "kanban_link",
"description": ( "description": (
@ -818,6 +977,15 @@ registry.register(
emoji="📋", emoji="📋",
) )
registry.register(
name="kanban_list",
toolset="kanban",
schema=KANBAN_LIST_SCHEMA,
handler=_handle_list,
check_fn=_check_kanban_mode,
emoji="📋",
)
registry.register( registry.register(
name="kanban_complete", name="kanban_complete",
toolset="kanban", toolset="kanban",
@ -863,6 +1031,15 @@ registry.register(
emoji="", emoji="",
) )
registry.register(
name="kanban_unblock",
toolset="kanban",
schema=KANBAN_UNBLOCK_SCHEMA,
handler=_handle_unblock,
check_fn=_check_kanban_mode,
emoji="",
)
registry.register( registry.register(
name="kanban_link", name="kanban_link",
toolset="kanban", toolset="kanban",

View file

@ -61,10 +61,13 @@ _HERMES_CORE_TOOLS = [
# 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 — only in schema when the agent is
# spawned as a kanban worker (HERMES_KANBAN_TASK env set), otherwise # spawned as a kanban worker (HERMES_KANBAN_TASK env set) or the current
# zero schema footprint. Gated via check_fn in tools/kanban_tools.py. # profile explicitly enables the kanban toolset. Gated via check_fn in
"kanban_show", "kanban_complete", "kanban_block", "kanban_heartbeat", # tools/kanban_tools.py.
"kanban_show", "kanban_list",
"kanban_complete", "kanban_block", "kanban_heartbeat",
"kanban_comment", "kanban_create", "kanban_link", "kanban_comment", "kanban_create", "kanban_link",
"kanban_unblock",
# Computer use (macOS, gated on cua-driver being installed via check_fn) # Computer use (macOS, gated on cua-driver being installed via check_fn)
"computer_use", "computer_use",
] ]
@ -233,12 +236,13 @@ TOOLSETS = {
"`kanban.dispatch_in_gateway` in config.yaml. Lets workers mark " "`kanban.dispatch_in_gateway` in config.yaml. Lets workers mark "
"tasks done with structured handoffs, block for human input, " "tasks done with structured handoffs, block for human input, "
"heartbeat during long ops, comment on threads, and (for " "heartbeat during long ops, comment on threads, and (for "
"orchestrators) fan out into child tasks." "orchestrators) list, unblock, and fan out tasks."
), ),
"tools": [ "tools": [
"kanban_show", "kanban_complete", "kanban_block", "kanban_show", "kanban_list", "kanban_complete", "kanban_block",
"kanban_heartbeat", "kanban_comment", "kanban_heartbeat", "kanban_comment",
"kanban_create", "kanban_link", "kanban_create", "kanban_link",
"kanban_unblock",
], ],
"includes": [], "includes": [],
}, },

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_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. 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_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. - **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,17 +231,19 @@ 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 — 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 | | 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_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. | — |
| `kanban_comment` | Append a durable note to the task thread. | `task_id`, `body` | | `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_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_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: A typical worker turn looks like:
@ -278,7 +280,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 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` ### Why tools instead of shelling to `hermes kanban`