# NousResearch/hermes-agent · Adding kanban board tools for orchestrator profiles

## Open now

- Nothing open on GitHub.

These items concern the addition of kanban-related tools (list, unblock, assign, archive) intended for orchestrator profiles. The work appears to be developed across related pull requests, including at least one carve-out from a larger effort.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #23012. Verify it addresses your case.
- **Recommended:** read PR #23012 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-23012`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #20048: \[Feature\]: Add kanban_list, kanban_unblock, kanban_assign, kanban_archive tools for orchestrator profiles

## Prior & competing PRs

- **#23012: feat(kanban): add orchestrator board tools — kanban_list + kanban_unblock (carve-out of #20568)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `scripts/release.py`, `tests/tools/test_kanban_tools.py`, `tools/kanban_tools.py`, `toolsets.py`, `website/docs/user-guide/features/kanban-tutorial.md`, `website/docs/user-guide/features/kanban.md`
  - fix hunks:
  - `scripts/release.py`

    ```diff
    @@ -700,6 +700,7 @@
         "22549957+li0near@users.noreply.github.com": "li0near",
         "[redacted:email]": "li0near",
         "[redacted:email]": "tymrtn",
    +    "[redacted:email]": "kallidean",
         "23434080+sicnuyudidi@users.noreply.github.com": "sicnuyudidi",
         "[redacted:email]": "haimu0x",
         "[redacted:email]": "NIDNASSER-Abdelmajid",
    ```
  - `tests/tools/test_kanban_tools.py`

    ```diff
    @@ -2,7 +2,7 @@
     
     Verifies:
       - Tools are gated on HERMES_KANBAN_TASK: a normal chat session sees
    -    zero kanban tools in its schema; a worker session sees all seven.
    +    zero kanban tools in its schema; a worker session sees the kanban set.
       - Each handler's happy path.
       - Error paths (missing required args, bad metadata type, etc).
     """
    ```

    ```diff
    @@ -27,9 +27,10 @@ def test_kanban_tools_hidden_without_env_var(monkeypatch, tmp_path):
         monkeypatch.setenv("HERMES_HOME", str(home))
     
         import tools.kanban_tools  # ensure registered
    -    from tools.registry import registry
    +    from tools.registry import invalidate_check_fn_cache, registry
         from toolsets import resolve_toolset
     
    +    invalidate_check_fn_cache()
         schema = registry.get_definitions(set(resolve_toolset("hermes-cli")), quiet=True)
         names = {s["function"].get("name") for s in schema if "function" in s}
         kanban = {n for n in names if n and n.startswith("kanban_")}
    ```

    ```diff
    @@ -39,16 +40,17 @@ def test_kanban_tools_hidden_without_env_var(monkeypatch, tmp_path):
     
     
     def test_kanban_tools_visible_with_env_var(monkeypatch, tmp_path):
    -    """Worker sessions (HERMES_KANBAN_TASK set) must have all 7 tools."""
    +    """Worker sessions get task lifecycle tools, not board-routing tools."""
         monkeypatch.setenv("HERMES_KANBAN_TASK", "t_fake")
         home = tmp_path / ".hermes"
         home.mkdir()
         monkeypatch.setenv("HERMES_HOME", str(home))
     
         import tools.kanban_tools  # ensure registered
    -    from tools.registry import registry
    +    from tools.registry import invalidate_check_fn_cache, registry
         from toolsets import resolve_toolset
     
    +    invalidate_check_fn_cache()
         schema = registry.get_definitions(set(resolve_toolset("hermes-cli")), quiet=True)
         names = {s["function"].get("name") for s in schema if "function" in s}
         kanban = {n for n in names if n and n.startswith("kanban_")}
    ```

    ```diff
    @@ -59,6 +61,61 @@ def test_kanban_tools_visible_with_env_var(monkeypatch, tmp_path):
         assert kanban == expected, f"expected {expected}, got {kanban}"
     
     
    +def test_worker_with_kanban_toolset_still_hides_board_routing(monkeypatch, tmp_path):
    +    """Task scope wins over profile config for board-routing tools.
    +
    +    Even if a worker process happens to also have ``toolsets: [kanban]``
    +    in its config, the HERMES_KANBAN_TASK env var means it's a focused
    +    worker and must not see kanban_list / kanban_unblock.
    +    """
    +    monkeypatch.setenv("HERMES_KANBAN_TASK", "t_fake")
    +    home = tmp_path / ".hermes"
    +    home.mkdir()
    +    (home / "config.yaml").write_text("toolsets:\n  - kanban\n")
    +    monkeypatch.setenv("HERMES_HOME", str(home))
    +
    +    import tools.kanban_tools  # ensure registered
    +    from tools.registry import invalidate_check_fn_cache, registry
    +    from toolsets import resolve_toolset
    +
    +    invalidate_check_fn_cache()
    +    schema = registry.get_definitions(set(resolve_toolset("hermes-cli")), quiet=True)
    +    names = {s["function"].get("name") for s in schema if "function" in s}
    +    kanban = {n for n in names if n and n.startswith("kanban_")}
    +    assert {
    +        "kanban_list",
    +        "kanban_unblock",
    +    }.isdisjoint(kanban), (
    +        f"Board-routing tools leaked into worker schema: "
    +        f"{kanban & {'kanban_list', 'kanban_unblock'}}"
    +    )
    +
    +
    +def test_kanban_tools_visible_with_toolset_config(monkeypatch, tmp_path):
    +    """Orchestrator profiles with toolsets: [kanban] see all kanban tools."""
    +    monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
    +    home = tmp_path / ".hermes"
    +    home.mkdir()
    +    (home / "config.yaml").write_text("toolsets:\n  - kanban\n")
    +    monkeypatch.setenv("HERMES_HOME", str(home))
    +
    +    import tools.kanban_tools  # ensure registered
    +    from tools.registry import invalidate_check_fn_cache, registry
    +    from toolsets import resolve_toolset
    +
    +    invalidate_check_fn_cache()
    +    schema = registry.get_definitions(set(resolve_toolset("hermes-cli")), quiet=True)
    +    names = {s["function"].get("name") for s in schema if "function" in s}
    +    kanban = {n for n in names if n and n.startswith("kanban_")}
    +    expected = {
    +        "kanban_list",
    +        "kanban_show", "kanban_complete", "kanban_block", "kanban_heartbeat",
    +        "kanban_comment", "kanban_create", "kanban_link",
    +        "kanban_unblock",
    +    }
    +    assert kanban == expected, f"expected {expected}, got {kanban}"
    +
    +
     # ---------------------------------------------------------------------------
     # Handler happy paths
     # ---------------------------------------------------------------------------
    ```

    ```diff
    @@ -112,6 +169,100 @@ def test_show_explicit_task_id(worker_env):
         assert d["task"]["id"] == other
     
     
    +def test_list_filters_tasks(monkeypatch, worker_env):
    +    """kanban_list gives orchestrators filtered board discovery."""
    +    monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
    +    from hermes_cli import kanban_db as kb
    +    conn = kb.connect()
    +    try:
    +        a = kb.create_task(conn, title="alpha", assignee="factory", priority=5)
    +        b = kb.create_task(conn, title="beta", assignee="reviewer")
    +        c = kb.create_task(conn, title="gamma", assignee="factory", tenant="other")
    +    finally:
    +        conn.close()
    +
    +    from tools import kanban_tools as kt
    +    out = kt._handle_list({"assignee": "factory", "status": "ready", "limit": 10})
    +    d = json.loads(out)
    +    ids = [t["id"] for t in d["tasks"]]
    +    assert ids == [a, c]
    +    assert d["count"] == 2
    +    assert d["tasks"][0]["title"] == "alpha"
    +    assert d["tasks"][0]["parent_count"] == 0
    +    assert b not in ids
    +
    +    tenant_out = kt._handle_list({
    +        "assignee": "factory",
    +        "status": "ready",
    +        "tenant": "other",
    +    })
    +    tenant_ids = [t["id"] for t in json.loads(tenant_out)["tasks"]]
    +    assert tenant_ids == [c]
    +
    +
    +def test_list_rejects_invalid_status(monkeypatch, worker_env):
    +    monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
    +    from tools import kanban_tools as kt
    +    out = kt._handle_list({"status": "not-a-state"})
    +    assert "status must be one of" in json.loads(out).get("error", "")
    +
    +
    +def test_list_rejects_bad_limit(monkeypatch, worker_env):
    +    monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
    +    from tools import kanban_tools as kt
    +    assert json.loads(kt._handle_list({"limit": "nope"})).get("error")
    +    assert json.loads(kt._handle_list({"limit": 0})).get("error")
    +
    +
    +def test_list_parses_include_archived_string_false(monkeypatch, worker_env):
    +    monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
    +    from hermes_cli import kanban_db as kb
    +    conn = kb.connect()
    +    try:
    +        live = kb.create_task(conn, title="live task", assignee="factory")
    +        archived = kb.create_task(conn, title="archived task", assignee="factory")
    +        assert kb.archive_task(conn, archived)
    +    finally:
    +        conn.close()
    +
    +    from tools import kanban_tools as kt
    +    out = kt._handle_list({
    +        "assignee": "factory",
    +        "include_archived": "false",
    +    })
    +    ids = [t["id"] for t in json.loads(out)["tasks"]]
    +    assert live in ids
    +    assert archived not in ids
    +
    +
    +def test_list_parses_include_archived_string_true(monkeypatch, worker_env):
    +    monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
    +    from hermes_cli import kanban_db as kb
    +    conn = kb.connect()
    +    try:
    +        live = kb.create_task(conn, title="live task", assignee="factory")
    +        archived = kb.create_task(conn, title="archived task", assignee="factory")
    +        assert kb.archive_task(conn, archived)
    +    finally:
    +        conn.close()
    +
    +    from tools import kanban_tools as kt
    +    out = kt._handle_list({
    +        "assignee": "factory",
    +        "include_archived": "true",
    +    })
    +    ids = [t["id"] for t in json.loads(out)["tasks"]]
    +    assert live in ids
    +    assert archived in ids
    +
    +
    +def test_list_rejects_bad_include_archived(monkeypatch, worker_env):
    +    monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
    +    from tools import kanban_tools as kt
    +    out = kt._handle_list({"include_archived": "sometimes"})
    +    assert "include_archived must be" in json.loads(out).get("error", "")
    +
    +
     def test_complete_happy_path(worker_env):
         from tools import kanban_tools as kt
         out = kt._handle_complete({
    ```

    ```diff
    @@ -368,6 +519,52 @@ def test_create_rejects_non_list_parents(worker_env):
         assert json.loads(out).get("error")
     
     
    +def test_create_parses_triage_string_false(worker_env):
    +    from tools import kanban_tools as kt
    +    from hermes_cli import kanban_db as kb
    +    out = kt._handle_create({
    +        "title": "not triage",
    +        "assignee": "peer",
    +        "triage": "false",
    +    })
    +    d = json.loads(out)
    +    assert d["ok"] is True
    +    conn = kb.connect()
    +    try:
    +        task = kb.get_task(conn, d["task_id"])
    +        assert task.status == "ready"
    +    finally:
    +        conn.close()
    +
    +
    +def test_create_parses_triage_string_true(worker_env):
    +    from tools import kanban_tools as kt
    +    from hermes_cli import kanban_db as kb
    +    out = kt._handle_create({
    +        "title": "needs triage",
    +        "assignee": "peer",
    +        "triage": "true",
    +    })
    +    d = json.loads(out)
    +    assert d["ok"] is True
    +    conn = kb.connect()
    +    try:
    +        task = kb.get_task(conn, d["task_id"])
    +        assert task.status == "triage"
    +    finally:
    +        conn.close()
    +
    +
    +def test_create_rejects_bad_triage(worker_env):
    +    from tools import kanban_tools as kt
    +    out = kt._handle_create({
    +        "title": "bad triage",
    +        "assignee": "peer",
    +        "triage": "sometimes",
    +    })
    +    assert "triage must be" in json.loads(out).get("error", "")
    +
    +
     def test_create_accepts_string_parent(worker_env):
         """Convenience: a single parent id as string is coerced to [id]."""
         from tools import kanban_tools as kt
    ```

    ```diff
    @@ -458,9 +655,35 @@ def test_link_rejects_cycle(worker_env):
         assert json.loads(out).get("error")
     
     
    -# ---------------------------------------------------------------------------
    -# End-to-end: simulate a full worker lifecycle through the tools
    -# ---------------------------------------------------------------------------
    +def test_unblock_happy_path(monkeypatch, worker_env):
    +    monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
    +    from hermes_cli import kanban_db as kb
    +    conn = kb.connect()
    +    try:
    +        tid = kb.create_task(conn, title="blocked", assignee="worker")
    +        kb.block_task(conn, tid, reason="waiting")
    +    finally:
    +        conn.close()
    +
    +    from tools import kanban_tools as kt
    +    out = kt._handle_unblock({"task_id": tid})
    +    d = json.loads(out)
    +    assert d["ok"] is True
    +    assert d["status"] == "ready"
    +
    +    conn = kb.connect()
    +    try:
    +        assert kb.get_task(conn, tid).status == "ready"
    +    finally:
    +        conn.close()
    +
    +
    +def test_unblock_rejects_non_blocked_task(monkeypatch, worker_env):
    +    monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False)
    +    from tools import kanban_tools as kt
    +    out = kt._handle_unblock({"task_id": worker_env})
    +    assert json.loads(out).get("error")
    +
     
     def test_worker_lifecycle_through_tools(worker_env):
         """Drive the full claim -> heartbeat -> comment -> complete lifecycle
    ```

    ```diff
    @@ -599,11 +822,12 @@ def test_kanban_guidance_prompt_size_bounded(monkeypatch, tmp_path):
     # ---------------------------------------------------------------------------
     #
     # A worker process has HERMES_KANBAN_TASK set to its own task id. The
    -# destructive tools (kanban_complete, kanban_block, kanban_heartbeat)
    -# must refuse to operate on any OTHER task id, even if the caller
    -# supplies an explicit `task_id` argument. Workers legitimately call
    -# kanban_show / kanban_comment / kanban_create / kanban_link on other
    -# tasks, so those are unrestricted.
    +# destructive tools (kanban_complete, kanban_block, kanban_heartbeat,
    +# kanban_unblock) must refuse to operate
    +# on any OTHER task id, even if the caller supplies an explicit `task_id`
    +# argument. Workers legitimately call kanban_show / kanban_list /
    +# kanban_comment / kanban_create / kanban_link on other tasks, so those
    +# are unrestricted.
     #
     # Orchestrator profiles (no HERMES_KANBAN_TASK in env) are intentionally
     # exempt — their job is routing, and they sometimes close out child
    ```

    ```diff
    @@ -712,6 +936,37 @@ def test_worker_can_comment_on_foreign_task(worker_env):
             conn.close()
     
     
    +def test_worker_unblock_rejects_foreign_task_id(worker_env):
    +    """A worker cannot unblock any task — kanban_unblock is orchestrator-only.
    +
    +    The check fires before the per-task ownership check, so the error
    +    surface is the orchestrator-only refusal rather than the
    +    cross-task-ownership refusal. Either is fine — the property we're
    +    pinning is "worker cannot mutate foreign task via kanban_unblock".
    +    """
    +    from hermes_cli import kanban_db as kb
    +    conn = kb.connect()
    +    try:
    +        other = kb.create_task(conn, title="blocked sibling", assignee="peer")
    +        kb.block_task(conn, other, reason="waiting")
    +    finally:
    +        conn.close()
    +
    +    from tools import kanban_tools as kt
    +    out = kt._handle_unblock({"task_id": other})
    +    d = json.loads(out)
    +    err = d.get("error", "")
    +    assert "orchestrator-only" in err or "refusing to mutate" in err, (
    +        f"expected worker-rejection error, got {err}"
    +    )
    +
    +    conn = kb.connect()
    +    try:
    +        assert kb.get_task(conn, other).status == "blocked"
    +    finally:
    +        conn.close()
    +
    +
     def test_worker_complete_own_task_still_works(worker_env):
         """The ownership check doesn't break the normal own-task happy path."""
         from tools import kanban_tools as kt
    ```
  - `tools/kanban_tools.py`

    ```diff
    @@ -39,8 +39,25 @@
     # Gating
     # ---------------------------------------------------------------------------
     
    +KANBAN_LIST_DEFAULT_LIMIT = 50
    +KANBAN_LIST_MAX_LIMIT = 200
    +
    +
    +def _profile_has_kanban_toolset() -> bool:
    +    # Uses load_config() which has mtime-based caching, so this adds
    +    # negligible overhead. The check_fn results are further TTL-cached
    +    # (~30s) by the tool registry.
    +    try:
    +        from hermes_cli.config import load_config
    +        cfg = load_config()
    +        toolsets = cfg.get("toolsets", [])
    +        return "kanban" in toolsets
    +    except Exception:
    +        return False
    +
    +
     def _check_kanban_mode() -> bool:
    -    """Tools are available when:
    +    """Task-lifecycle tools are available when:
     
         1. ``HERMES_KANBAN_TASK`` is set (dispatcher-spawned worker), OR
         2. The current profile has ``kanban`` in its toolsets config
    ```

    ```diff
    @@ -49,22 +66,25 @@ def _check_kanban_mode() -> bool:
         Humans running ``hermes chat`` without the kanban toolset see zero
         kanban tools. Workers spawned by the kanban dispatcher (gateway-
         embedded by default) and orchestrator profiles with the kanban
    -    toolset enabled see all seven.
    +    toolset enabled see the Kanban lifecycle tool surface.
         """
         if os.environ.get("HERMES_KANBAN_TASK"):
             return True
    +    return _profile_has_kanban_toolset()
     
    -    # Check if the current profile has the kanban toolset enabled.
    -    # Uses load_config() which has mtime-based caching, so this adds
    -    # negligible overhead. The check_fn results are further TTL-cached
    -    # (~30s) by the tool registry.
    -    try:
    -        from hermes_cli.config import load_config
    -        cfg = load_config()
    -        toolsets = cfg.get("toolsets", [])
    -        return "kanban" in toolsets
    -    except Exception:
    +
    +def _check_kanban_orchestrator_mode() -> bool:
    +    """Board-routing tools (kanban_list, kanban_unblock) are intentionally
    +    hidden from task workers.
    +
    +    Dispatcher-spawned workers should close their own task via the
    +    lifecycle tools (complete/block/heartbeat), not enumerate or unblock
    +    board state. Profiles that explicitly opt into the kanban toolset
    +    and are NOT scoped to a single task are the orchestrator surface.
    +    """
    +    if os.environ.get("HERMES_KANBAN_TASK"):
             return False
    +    return _profile_has_kanban_toolset()
     
     
     # ---------------------------------------------------------------------------
    ```

    ```diff
    @@ -135,6 +155,73 @@ def _ok(**fields: Any) -> str:
         return json.dumps({"ok": True, **fields})
     
     
    +def _normalize_profile(value: Any) -> Optional[str]:
    +    """Normalize CLI-compatible assignee sentinels for the tool surface."""
    +    if value is None:
    +        return None
    +    text = str(value).strip()
    +    if not text or text.lower() in ("none", "-", "null"):
    +        return None
    +    return text
    +
    +
    +def _parse_bool_arg(args: dict, name: str, *, default: bool = False):
    +    value = args.get(name)
    +    if value is None:
    +        return default, None
    +    if isinstance(value, bool):
    +        return value, None
    +    text = str(value).strip().lower()
    +    if text in ("true", "1", "yes"):
    +        return True, None
    +    if text in ("false", "0", "no"):
    +        return False, None
    +    return default, f"{name} must be a boolean or 'true'/'false'"
    +
    +
    +def _require_orchestrator_tool(tool_name: str) -> Optional[str]:
    +    """Belt-and-suspenders runtime guard for orchestrator-only handlers.
    +
    +    The check_fn (`_check_kanban_orchestrator_mode`) keeps these tools
    +    out of the worker schema entirely, but in case a stale registration
    +    or test harness routes a worker to one of them anyway, return a
    +    structured tool_error so the model gets a clear refusal instead of
    +    silently mutating board state from a worker context.
    +    """
    +    if os.environ.get("HERMES_KANBAN_TASK"):
    +        return tool_error(
    +            f"{tool_name} is orchestrator-only; dispatcher-spawned workers "
    +            "must use kanban_complete, kanban_block, kanban_heartbeat, or "
    +            "kanban_comment for their assigned task."
    +        )
    +    return None
    +
    +
    +def _task_summary_dict(kb, conn, task) -> dict[str, Any]:
    +    """Compact task shape for board-listing tools."""
    +    parents = kb.parent_ids(conn, task.id)
    +    children = kb.child_ids(conn, task.id)
    +    return {
    +        "id": task.id,
    +        "title": task.title,
    +        "assignee": task.assignee,
    +        "status": task.status,
    +        "priority": task.priority,
    +        "tenant": task.tenant,
    +        "workspace_kind": task.workspace_kind,
    +        "workspace_path": task.workspace_path,
    +        "created_by": task.created_by,
    +        "created_at": task.created_at,
    +        "started_at": task.started_at,
    +        "completed_at": task.completed_at,
    +        "current_run_id": task.current_run_id,
    +        "parents": parents,
    +        "children": children,
    +        "parent_count": len(parents),
    +        "child_count": len(children),
    +    }
    +
    +
     # ---------------------------------------------------------------------------
     # Handlers
     # ---------------------------------------------------------------------------
    ```

    ```diff
    @@ -210,6 +297,66 @@ def _run_dict(r):
             return tool_error(f"kanban_show: {e}")
     
     
    +def _handle_list(args: dict, **kw) -> str:
    +    """List task summaries with the same core filters as the CLI."""
    +    guard = _require_orchestrator_tool("kanban_list")
    +    if guard:
    +        return guard
    +    assignee = args.get("assignee")
    +    status = args.get("status")
    +    tenant = args.get("tenant")
    +    include_archived, bool_error = _parse_bool_arg(args, "include_archived")
    +    if bool_error:
    +        return tool_error(bool_error)
    +    limit = args.get("limit")
    +    if limit is None:
    +        limit = KANBAN_LIST_DEFAULT_LIMIT
    +    try:
    +        limit = int(limit)
    +    except (TypeError, ValueError):
    +        return tool_error("limit must be an integer")
    +    if limit < 1:
    +        return tool_error("limit must be >= 1")
    +    if limit > KANBAN_LIST_MAX_LIMIT:
    +        return tool_error(f"limit must be <= {KANBAN_LIST_MAX_LIMIT}")
    +    try:
    +        kb, conn = _connect()
    +        try:
    +            # Match CLI list: dependencies that cleared since the last
    +            # dispatcher tick should be visible to orchestrators immediately.
    +            promoted = kb.recompute_ready(conn)
    +            # Fetch one extra row so model-facing output can report that
    +            # a bounded listing was truncated without dumping the board.
    +            rows = kb.list_tasks(
    +                conn,
    +                assignee=assignee,
    +                status=status,
    +                tenant=tenant,
    +                include_archived=include_archived,
    +                limit=limit + 1,
    +            )
    +            truncated = len(rows) > limit
    +            tasks = rows[:limit]
    +            return json.dumps({
    +                "tasks": [_task_summary_dict(kb, conn, t) for t in tasks],
    +                "count": len(tasks),
    +                "limit": limit,
    +                "truncated": truncated,
    +                "next_limit": (
    +                    min(limit * 2, KANBAN_LIST_MAX_LIMIT)
    +                    if truncated and limit < KANBAN_LIST_MAX_LIMIT else None
    +                ),
    +                "promoted": promoted,
    +            })
    +        finally:
    +            conn.close()
    +    except ValueError as e:
    +        return tool_error(f"kanban_list: {e}")
    +    except Exception as e:
    +        logger.exception("kanban_list failed")
    +        return tool_error(f"kanban_list: {e}")
    +
    +
     def _handle_complete(args: dict, **kw) -> str:
         """Mark the current task done with a structured handoff."""
         tid = _default_task_id(args.get("task_id"))
    ```

    ```diff
    @@ -416,7 +563,9 @@ def _handle_create(args: dict, **kw) -> str:
         priority = args.get("priority")
         workspace_kind = args.get("workspace_kind") or "scratch"
         workspace_path = args.get("workspace_path")
    -    triage = bool(args.get("triage"))
    +    triage, bool_error = _parse_bool_arg(args, "triage")
    +    if bool_error:
    +        return tool_error(bool_error)
         idempotency_key = args.get("idempotency_key")
         max_runtime_seconds = args.get("max_runtime_seconds")
         skills = args.get("skills")
    ```

    ```diff
    @@ -467,6 +616,31 @@ def _handle_create(args: dict, **kw) -> str:
             return tool_error(f"kanban_create: {e}")
     
     
    +def _handle_unblock(args: dict, **kw) -> str:
    +    """Transition a blocked task back to ready."""
    +    guard = _require_orchestrator_tool("kanban_unblock")
    +    if guard:
    +        return guard
    +    tid = args.get("task_id")
    +    if not tid:
    +        return tool_error("task_id is required")
    +    ownership_err = _enforce_worker_task_ownership(str(tid))
    +    if ownership_err:
    +        return ownership_err
    +    try:
    +        kb, conn = _connect()
    +        try:
    +            ok = kb.unblock_task(conn, str(tid))
    +            if not ok:
    +                return tool_error(f"could not unblock {tid} (not blocked or unknown)")
    +            return _ok(task_id=str(tid), status="ready")
    +        finally:
    +            conn.close()
    +    except Exception as e:
    +        logger.exception("kanban_unblock failed")
    +        return tool_error(f"kanban_unblock: {e}")
    +
    +
     def _handle_link(args: dict, **kw) -> str:
         """Add a parent→child dependency edge after the fact."""
         parent_id = args.get("parent_id")
    ```

    ```diff
    @@ -519,6 +693,50 @@ def _handle_link(args: dict, **kw) -> str:
         },
     }
     
    +KANBAN_LIST_SCHEMA = {
    +    "name": "kanban_list",
    +    "description": (
    +        "List Kanban task summaries so an orchestrator profile can discover "
    +        "work to route. Supports the same core filters as the CLI: assignee, "
    +        "status, tenant, include_archived, and limit. Returns compact rows "
    +        "with ids, title, status, assignee, priority, parent/child ids, and "
    +        "counts. Bounded to 50 rows by default, 200 max, with truncation "
    +        "metadata. Also recomputes ready tasks before listing, matching the "
    +        "CLI. Orchestrator-only — dispatcher-spawned task workers never see "
    +        "this tool."
    +    ),
    +    "parameters": {
    +        "type": "object",
    +        "properties": {
    +            "assignee": {
    +                "type": "string",
    +                "description": "Optional assignee/profile filter.",
    +            },
    +            "status": {
    +                "type": "string",
    +                "enum": [
    +                    "triage", "todo", "ready", "running",
    +                    "blocked", "done", "archived",
    +                ],
    +                "description": "Optional task status filter.",
    +            },
    +            "tenant": {
    +                "type": "string",
    +                "description": "Optional tenant/project namespace filter.",
    +            },
    +            "include_archived": {
    +                "type": "boolean",
    +                "description": "Include archived tasks. Defaults to false.",
    +            },
    +            "limit": {
    +                "type": "integer",
    +                "description": "Optional maximum rows to return (default 50, max 200).",
    +            },
    +        },
    +        "required": [],
    +    },
    +}
    +
     KANBAN_COMPLETE_SCHEMA = {
         "name": "kanban_complete",
         "description": (
    ```

    ```diff
    @@ -787,6 +1005,25 @@ def _handle_link(args: dict, **kw) -> str:
         },
     }
     
    +KANBAN_UNBLOCK_SCHEMA = {
    +    "name": "kanban_unblock",
    +    "description": (
    +        "Move a blocked Kanban task back to ready. Orchestrator-only — only "
    +        "profiles with the kanban toolset can unblock routed work; "
    +        "dispatcher-spawned task workers never see this tool."
    +    ),
    +    "parameters": {
    +        "type": "object",
    +        "properties": {
    +            "task_id": {
    +                "type": "string",
    +                "description": "Blocked task id to return to ready.",
    +            },
    +        },
    +        "required": ["task_id"],
    +    },
    +}
    +
     KANBAN_LINK_SCHEMA = {
         "name": "kanban_link",
         "description": (
    ```

    ```diff
    @@ -818,6 +1055,15 @@ def _handle_link(args: dict, **kw) -> str:
         emoji="📋",
     )
     
    +registry.register(
    +    name="kanban_list",
    +    toolset="kanban",
    +    schema=KANBAN_LIST_SCHEMA,
    +    handler=_handle_list,
    +    check_fn=_check_kanban_orchestrator_mode,
    +    emoji="📋",
    +)
    +
     registry.register(
         name="kanban_complete",
         toolset="kanban",
    ```

    ```diff
    @@ -863,6 +1109,15 @@ def _handle_link(args: dict, **kw) -> str:
         emoji="➕",
     )
     
    +registry.register(
    +    name="kanban_unblock",
    +    toolset="kanban",
    +    schema=KANBAN_UNBLOCK_SCHEMA,
    +    handler=_handle_unblock,
    +    check_fn=_check_kanban_orchestrator_mode,
    +    emoji="▶",
    +)
    +
     registry.register(
         name="kanban_link",
         toolset="kanban",
    ```
  - `toolsets.py`

    ```diff
    @@ -61,10 +61,13 @@
         # Home Assistant smart home control (gated on HASS_TOKEN via check_fn)
         "ha_list_entities", "ha_get_state", "ha_list_services", "ha_call_service",
         # Kanban multi-agent coordination — only in schema when the agent is
    -    # spawned as a kanban worker (HERMES_KANBAN_TASK env set), otherwise
    -    # zero schema footprint. Gated via check_fn in tools/kanban_tools.py.
    -    "kanban_show", "kanban_complete", "kanban_block", "kanban_heartbeat",
    +    # spawned as a kanban worker (HERMES_KANBAN_TASK env set) or the current
    +    # profile explicitly enables the kanban toolset. Gated via check_fn in
    +    # tools/kanban_tools.py.
    +    "kanban_show", "kanban_list",
    +    "kanban_complete", "kanban_block", "kanban_heartbeat",
         "kanban_comment", "kanban_create", "kanban_link",
    +    "kanban_unblock",
         # Computer use (macOS, gated on cua-driver being installed via check_fn)
         "computer_use",
     ]
    ```

    ```diff
    @@ -233,12 +236,13 @@
                 "`kanban.dispatch_in_gateway` in config.yaml. Lets workers mark "
                 "tasks done with structured handoffs, block for human input, "
                 "heartbeat during long ops, comment on threads, and (for "
    -            "orchestrators) fan out into child tasks."
    +            "orchestrators) list, unblock, and fan out tasks."
             ),
             "tools": [
    -            "kanban_show", "kanban_complete", "kanban_block",
    +            "kanban_show", "kanban_list", "kanban_complete", "kanban_block",
                 "kanban_heartbeat", "kanban_comment",
                 "kanban_create", "kanban_link",
    +            "kanban_unblock",
             ],
             "includes": [],
         },
    ```
  - `website/docs/user-guide/features/kanban-tutorial.md`

    ```diff
    @@ -10,7 +10,7 @@ hermes dashboard             # opens http://127.0.0.1:9119 in your browser
     # click Kanban in the left nav
     ```
     
    -The dashboard is the most comfortable place for **you** to watch the system. Agent workers the dispatcher spawns never see the dashboard or the CLI — they drive the board through a dedicated `kanban_*` [toolset](./kanban#how-workers-interact-with-the-board) (`kanban_show`, `kanban_complete`, `kanban_block`, `kanban_heartbeat`, `kanban_comment`, `kanban_create`, `kanban_link`). All three surfaces — dashboard, CLI, worker tools — route through the same per-board SQLite DB (`~/.hermes/kanban.db` for the default board, `~/.hermes/kanban/boards/<slug>/kanban.db` for any board you create later), so each board is consistent no matter which side of the fence a change came from.
    +The dashboard is the most comfortable place for **you** to watch the system. Agent workers the dispatcher spawns never see the dashboard or the CLI — they drive the board through a dedicated `kanban_*` [toolset](./kanban#how-workers-interact-with-the-board) (`kanban_show`, `kanban_list`, `kanban_complete`, `kanban_block`, `kanban_heartbeat`, `kanban_comment`, `kanban_create`, `kanban_link`, `kanban_unblock`). All three surfaces — dashboard, CLI, worker tools — route through the same per-board SQLite DB (`~/.hermes/kanban.db` for the default board, `~/.hermes/kanban/boards/<slug>/kanban.db` for any board you create later), so each board is consistent no matter which side of the fence a change came from.
     
     This tutorial uses the `default` board throughout. If you want multiple isolated queues (one per project / repo / domain), see [Boards (multi-project)](./kanban#boards-multi-project) in the overview — the same CLI / dashboard / worker flows apply per board, and workers physically cannot see tasks on other boards.
     
    ```
  - `website/docs/user-guide/features/kanban.md`

    ```diff
    @@ -14,7 +14,7 @@ Hermes Kanban is a durable task board, shared across all your Hermes profiles, t
     
     The board has two front doors, both backed by the same `~/.hermes/kanban.db`:
     
    -- **Agents drive the board through a dedicated `kanban_*` toolset** — `kanban_show`, `kanban_complete`, `kanban_block`, `kanban_heartbeat`, `kanban_comment`, `kanban_create`, `kanban_link`. The dispatcher spawns each worker with these tools already in its schema; the model reads its task and hands work off by calling them directly, *not* by shelling out to `hermes kanban`. See [How workers interact with the board](#how-workers-interact-with-the-board) below.
    +- **Agents drive the board through a dedicated `kanban_*` toolset** — `kanban_show`, `kanban_list`, `kanban_complete`, `kanban_block`, `kanban_heartbeat`, `kanban_comment`, `kanban_create`, `kanban_link`, `kanban_unblock`. The dispatcher spawns each worker with these tools already in its schema; orchestrator profiles can also enable the `kanban` toolset explicitly. The model reads and routes tasks by calling tools directly, *not* by shelling out to `hermes kanban`. See [How workers interact with the board](#how-workers-interact-with-the-board) below.
     - **You (and scripts, and cron) drive the board through `hermes kanban …`** on the CLI, `/kanban …` as a slash command, or the dashboard. These are for humans and automation — the places without a tool-calling model behind them.
     
     Both surfaces route through the same `kanban_db` layer, so reads see a consistent view and writes can't drift. The rest of this page shows CLI examples because they're easy to copy-paste, but every CLI verb has a tool-call equivalent the model uses.
    ```

    ```diff
    @@ -231,17 +231,19 @@ hermes kanban block    t_abc "need input" --ids t_def t_hij
     
     ## How workers interact with the board
     
    -**Workers do not shell out to `hermes kanban`.** When the dispatcher spawns a worker it sets `HERMES_KANBAN_TASK=t_abcd` in the child's env, and that env var flips on a dedicated **kanban toolset** in the model's schema — seven tools that read and mutate the board directly via the Python `kanban_db` layer, same as the CLI does. A running worker calls these like any other tool; it never sees or needs the `hermes kanban` CLI.
    +**Workers do not shell out to `hermes kanban`.** When the dispatcher spawns a worker it sets `HERMES_KANBAN_TASK=t_abcd` in the child's env, and that env var flips on a dedicated **kanban toolset** in the model's schema. The same toolset is also available to orchestrator profiles that enable `kanban` in their toolsets config. These tools read and mutate the board directly via the Python `kanban_db` layer, same as the CLI does. A running worker calls these like any other tool; it never sees or needs the `hermes kanban` CLI.
     
     | Tool | Purpose | Required params |
     |---|---|---|
     | `kanban_show` | Read the current task (title, body, prior attempts, parent handoffs, comments, full pre-formatted `worker_context`). Defaults to the env's task id. | — |
    +| `kanban_list` | List task summaries with filters for `assignee`, `status`, `tenant`, archived visibility, and limit. Intended for orchestrators discovering board work. | — |
     | `kanban_complete` | Finish with `summary` + `metadata` structured handoff. | at least one of `summary` / `result` |
     | `kanban_block` | Escalate for human input with a `reason`. | `reason` |
     | `kanban_heartbeat` | Signal liveness during long operations. Pure side-effect. | — |
     | `kanban_comment` | Append a durable note to the task thread. | `task_id`, `body` |
     | `kanban_create` | (Orchestrators) fan out into child tasks with an `assignee`, optional `parents`, `skills`, etc. | `title`, `assignee` |
     | `kanban_link` | (Orchestrators) add a `parent_id → child_id` dependency edge after the fact. | `parent_id`, `child_id` |
    +| `kanban_unblock` | (Orchestrators) move a blocked task back to `ready`. | `task_id` |
     
     A typical worker turn looks like:
     
    ```

    ```diff
    @@ -278,7 +280,7 @@ kanban_create(
     kanban_complete(summary="decomposed into 2 research tasks + 1 writer; linked dependencies")
     ```
     
    -The three "(Orchestrators)" tools — `kanban_create`, `kanban_link`, and `kanban_comment` on foreign tasks — are available to every worker; the convention (enforced by the `kanban-orchestrator` skill) is that worker profiles don't fan out and orchestrator profiles don't execute.
    +The "(Orchestrators)" tools — `kanban_list`, `kanban_create`, `kanban_link`, `kanban_unblock`, and `kanban_comment` on foreign tasks — are available through the same toolset; the convention (enforced by the `kanban-orchestrator` skill) is that worker profiles don't fan out or route unrelated work, and orchestrator profiles don't execute implementation work. Dispatcher-spawned workers are still task-scoped for destructive lifecycle operations and cannot mutate unrelated tasks.
     
     ### Why tools instead of shelling to `hermes kanban`
     
    ```
- **#20568: feat(kanban): add orchestrator board tools**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#23012`

## Fix status

- Merged to main in PR #23012 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-10`.
