{"repo":"NousResearch/hermes-agent","public_id":"20048","display_label":"Adding kanban board tools for orchestrator profiles","summary":"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.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":23012,"merged_at":"2026-05-10T12:58:45+00:00"},"resolution":{"state":"resolved","by":"pr-23012"},"freshness":{"state":"verified","last_verified_at":"2026-05-10T12:58:45+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":20048,"pr_number":23012,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-10T12:58:45+00:00"}],"members":[{"kind":"issue","number":20048,"title":"[Feature]: Add kanban_list, kanban_unblock, kanban_assign, kanban_archive tools for orchestrator profiles","url":null,"state":"closed"},{"kind":"pr","number":20568,"title":"feat(kanban): add orchestrator board tools","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#23012","number":23012}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":23012,"title":"feat(kanban): add orchestrator board tools — kanban_list + kanban_unblock (carve-out of #20568)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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":[{"file":"scripts/release.py","hunks":["@@ -700,6 +700,7 @@\n     \"22549957+li0near@users.noreply.github.com\": \"li0near\",\n     \"[redacted:email]\": \"li0near\",\n     \"[redacted:email]\": \"tymrtn\",\n+    \"[redacted:email]\": \"kallidean\",\n     \"23434080+sicnuyudidi@users.noreply.github.com\": \"sicnuyudidi\",\n     \"[redacted:email]\": \"haimu0x\",\n     \"[redacted:email]\": \"NIDNASSER-Abdelmajid\","]},{"file":"tests/tools/test_kanban_tools.py","hunks":["@@ -2,7 +2,7 @@\n \n Verifies:\n   - Tools are gated on HERMES_KANBAN_TASK: a normal chat session sees\n-    zero kanban tools in its schema; a worker session sees all seven.\n+    zero kanban tools in its schema; a worker session sees the kanban set.\n   - Each handler's happy path.\n   - Error paths (missing required args, bad metadata type, etc).\n \"\"\"","@@ -27,9 +27,10 @@ def test_kanban_tools_hidden_without_env_var(monkeypatch, tmp_path):\n     monkeypatch.setenv(\"HERMES_HOME\", str(home))\n \n     import tools.kanban_tools  # ensure registered\n-    from tools.registry import registry\n+    from tools.registry import invalidate_check_fn_cache, registry\n     from toolsets import resolve_toolset\n \n+    invalidate_check_fn_cache()\n     schema = registry.get_definitions(set(resolve_toolset(\"hermes-cli\")), quiet=True)\n     names = {s[\"function\"].get(\"name\") for s in schema if \"function\" in s}\n     kanban = {n for n in names if n and n.startswith(\"kanban_\")}","@@ -39,16 +40,17 @@ def test_kanban_tools_hidden_without_env_var(monkeypatch, tmp_path):\n \n \n def test_kanban_tools_visible_with_env_var(monkeypatch, tmp_path):\n-    \"\"\"Worker sessions (HERMES_KANBAN_TASK set) must have all 7 tools.\"\"\"\n+    \"\"\"Worker sessions get task lifecycle tools, not board-routing tools.\"\"\"\n     monkeypatch.setenv(\"HERMES_KANBAN_TASK\", \"t_fake\")\n     home = tmp_path / \".hermes\"\n     home.mkdir()\n     monkeypatch.setenv(\"HERMES_HOME\", str(home))\n \n     import tools.kanban_tools  # ensure registered\n-    from tools.registry import registry\n+    from tools.registry import invalidate_check_fn_cache, registry\n     from toolsets import resolve_toolset\n \n+    invalidate_check_fn_cache()\n     schema = registry.get_definitions(set(resolve_toolset(\"hermes-cli\")), quiet=True)\n     names = {s[\"function\"].get(\"name\") for s in schema if \"function\" in s}\n     kanban = {n for n in names if n and n.startswith(\"kanban_\")}","@@ -59,6 +61,61 @@ def test_kanban_tools_visible_with_env_var(monkeypatch, tmp_path):\n     assert kanban == expected, f\"expected {expected}, got {kanban}\"\n \n \n+def test_worker_with_kanban_toolset_still_hides_board_routing(monkeypatch, tmp_path):\n+    \"\"\"Task scope wins over profile config for board-routing tools.\n+\n+    Even if a worker process happens to also have ``toolsets: [kanban]``\n+    in its config, the HERMES_KANBAN_TASK env var means it's a focused\n+    worker and must not see kanban_list / kanban_unblock.\n+    \"\"\"\n+    monkeypatch.setenv(\"HERMES_KANBAN_TASK\", \"t_fake\")\n+    home = tmp_path / \".hermes\"\n+    home.mkdir()\n+    (home / \"config.yaml\").write_text(\"toolsets:\\n  - kanban\\n\")\n+    monkeypatch.setenv(\"HERMES_HOME\", str(home))\n+\n+    import tools.kanban_tools  # ensure registered\n+    from tools.registry import invalidate_check_fn_cache, registry\n+    from toolsets import resolve_toolset\n+\n+    invalidate_check_fn_cache()\n+    schema = registry.get_definitions(set(resolve_toolset(\"hermes-cli\")), quiet=True)\n+    names = {s[\"function\"].get(\"name\") for s in schema if \"function\" in s}\n+    kanban = {n for n in names if n and n.startswith(\"kanban_\")}\n+    assert {\n+        \"kanban_list\",\n+        \"kanban_unblock\",\n+    }.isdisjoint(kanban), (\n+        f\"Board-routing tools leaked into worker schema: \"\n+        f\"{kanban & {'kanban_list', 'kanban_unblock'}}\"\n+    )\n+\n+\n+def test_kanban_tools_visible_with_toolset_config(monkeypatch, tmp_path):\n+    \"\"\"Orchestrator profiles with toolsets: [kanban] see all kanban tools.\"\"\"\n+    monkeypatch.delenv(\"HERMES_KANBAN_TASK\", raising=False)\n+    home = tmp_path / \".hermes\"\n+    home.mkdir()\n+    (home / \"config.yaml\").write_text(\"toolsets:\\n  - kanban\\n\")\n+    monkeypatch.setenv(\"HERMES_HOME\", str(home))\n+\n+    import tools.kanban_tools  # ensure registered\n+    from tools.registry import invalidate_check_fn_cache, registry\n+    from toolsets import resolve_toolset\n+\n+    invalidate_check_fn_cache()\n+    schema = registry.get_definitions(set(resolve_toolset(\"hermes-cli\")), quiet=True)\n+    names = {s[\"function\"].get(\"name\") for s in schema if \"function\" in s}\n+    kanban = {n for n in names if n and n.startswith(\"kanban_\")}\n+    expected = {\n+        \"kanban_list\",\n+        \"kanban_show\", \"kanban_complete\", \"kanban_block\", \"kanban_heartbeat\",\n+        \"kanban_comment\", \"kanban_create\", \"kanban_link\",\n+        \"kanban_unblock\",\n+    }\n+    assert kanban == expected, f\"expected {expected}, got {kanban}\"\n+\n+\n # ---------------------------------------------------------------------------\n # Handler happy paths\n # ---------------------------------------------------------------------------","@@ -112,6 +169,100 @@ def test_show_explicit_task_id(worker_env):\n     assert d[\"task\"][\"id\"] == other\n \n \n+def test_list_filters_tasks(monkeypatch, worker_env):\n+    \"\"\"kanban_list gives orchestrators filtered board discovery.\"\"\"\n+    monkeypatch.delenv(\"HERMES_KANBAN_TASK\", raising=False)\n+    from hermes_cli import kanban_db as kb\n+    conn = kb.connect()\n+    try:\n+        a = kb.create_task(conn, title=\"alpha\", assignee=\"factory\", priority=5)\n+        b = kb.create_task(conn, title=\"beta\", assignee=\"reviewer\")\n+        c = kb.create_task(conn, title=\"gamma\", assignee=\"factory\", tenant=\"other\")\n+    finally:\n+        conn.close()\n+\n+    from tools import kanban_tools as kt\n+    out = kt._handle_list({\"assignee\": \"factory\", \"status\": \"ready\", \"limit\": 10})\n+    d = json.loads(out)\n+    ids = [t[\"id\"] for t in d[\"tasks\"]]\n+    assert ids == [a, c]\n+    assert d[\"count\"] == 2\n+    assert d[\"tasks\"][0][\"title\"] == \"alpha\"\n+    assert d[\"tasks\"][0][\"parent_count\"] == 0\n+    assert b not in ids\n+\n+    tenant_out = kt._handle_list({\n+        \"assignee\": \"factory\",\n+        \"status\": \"ready\",\n+        \"tenant\": \"other\",\n+    })\n+    tenant_ids = [t[\"id\"] for t in json.loads(tenant_out)[\"tasks\"]]\n+    assert tenant_ids == [c]\n+\n+\n+def test_list_rejects_invalid_status(monkeypatch, worker_env):\n+    monkeypatch.delenv(\"HERMES_KANBAN_TASK\", raising=False)\n+    from tools import kanban_tools as kt\n+    out = kt._handle_list({\"status\": \"not-a-state\"})\n+    assert \"status must be one of\" in json.loads(out).get(\"error\", \"\")\n+\n+\n+def test_list_rejects_bad_limit(monkeypatch, worker_env):\n+    monkeypatch.delenv(\"HERMES_KANBAN_TASK\", raising=False)\n+    from tools import kanban_tools as kt\n+    assert json.loads(kt._handle_list({\"limit\": \"nope\"})).get(\"error\")\n+    assert json.loads(kt._handle_list({\"limit\": 0})).get(\"error\")\n+\n+\n+def test_list_parses_include_archived_string_false(monkeypatch, worker_env):\n+    monkeypatch.delenv(\"HERMES_KANBAN_TASK\", raising=False)\n+    from hermes_cli import kanban_db as kb\n+    conn = kb.connect()\n+    try:\n+        live = kb.create_task(conn, title=\"live task\", assignee=\"factory\")\n+        archived = kb.create_task(conn, title=\"archived task\", assignee=\"factory\")\n+        assert kb.archive_task(conn, archived)\n+    finally:\n+        conn.close()\n+\n+    from tools import kanban_tools as kt\n+    out = kt._handle_list({\n+        \"assignee\": \"factory\",\n+        \"include_archived\": \"false\",\n+    })\n+    ids = [t[\"id\"] for t in json.loads(out)[\"tasks\"]]\n+    assert live in ids\n+    assert archived not in ids\n+\n+\n+def test_list_parses_include_archived_string_true(monkeypatch, worker_env):\n+    monkeypatch.delenv(\"HERMES_KANBAN_TASK\", raising=False)\n+    from hermes_cli import kanban_db as kb\n+    conn = kb.connect()\n+    try:\n+        live = kb.create_task(conn, title=\"live task\", assignee=\"factory\")\n+        archived = kb.create_task(conn, title=\"archived task\", assignee=\"factory\")\n+        assert kb.archive_task(conn, archived)\n+    finally:\n+        conn.close()\n+\n+    from tools import kanban_tools as kt\n+    out = kt._handle_list({\n+        \"assignee\": \"factory\",\n+        \"include_archived\": \"true\",\n+    })\n+    ids = [t[\"id\"] for t in json.loads(out)[\"tasks\"]]\n+    assert live in ids\n+    assert archived in ids\n+\n+\n+def test_list_rejects_bad_include_archived(monkeypatch, worker_env):\n+    monkeypatch.delenv(\"HERMES_KANBAN_TASK\", raising=False)\n+    from tools import kanban_tools as kt\n+    out = kt._handle_list({\"include_archived\": \"sometimes\"})\n+    assert \"include_archived must be\" in json.loads(out).get(\"error\", \"\")\n+\n+\n def test_complete_happy_path(worker_env):\n     from tools import kanban_tools as kt\n     out = kt._handle_complete({","@@ -368,6 +519,52 @@ def test_create_rejects_non_list_parents(worker_env):\n     assert json.loads(out).get(\"error\")\n \n \n+def test_create_parses_triage_string_false(worker_env):\n+    from tools import kanban_tools as kt\n+    from hermes_cli import kanban_db as kb\n+    out = kt._handle_create({\n+        \"title\": \"not triage\",\n+        \"assignee\": \"peer\",\n+        \"triage\": \"false\",\n+    })\n+    d = json.loads(out)\n+    assert d[\"ok\"] is True\n+    conn = kb.connect()\n+    try:\n+        task = kb.get_task(conn, d[\"task_id\"])\n+        assert task.status == \"ready\"\n+    finally:\n+        conn.close()\n+\n+\n+def test_create_parses_triage_string_true(worker_env):\n+    from tools import kanban_tools as kt\n+    from hermes_cli import kanban_db as kb\n+    out = kt._handle_create({\n+        \"title\": \"needs triage\",\n+        \"assignee\": \"peer\",\n+        \"triage\": \"true\",\n+    })\n+    d = json.loads(out)\n+    assert d[\"ok\"] is True\n+    conn = kb.connect()\n+    try:\n+        task = kb.get_task(conn, d[\"task_id\"])\n+        assert task.status == \"triage\"\n+    finally:\n+        conn.close()\n+\n+\n+def test_create_rejects_bad_triage(worker_env):\n+    from tools import kanban_tools as kt\n+    out = kt._handle_create({\n+        \"title\": \"bad triage\",\n+        \"assignee\": \"peer\",\n+        \"triage\": \"sometimes\",\n+    })\n+    assert \"triage must be\" in json.loads(out).get(\"error\", \"\")\n+\n+\n def test_create_accepts_string_parent(worker_env):\n     \"\"\"Convenience: a single parent id as string is coerced to [id].\"\"\"\n     from tools import kanban_tools as kt","@@ -458,9 +655,35 @@ def test_link_rejects_cycle(worker_env):\n     assert json.loads(out).get(\"error\")\n \n \n-# ---------------------------------------------------------------------------\n-# End-to-end: simulate a full worker lifecycle through the tools\n-# ---------------------------------------------------------------------------\n+def test_unblock_happy_path(monkeypatch, worker_env):\n+    monkeypatch.delenv(\"HERMES_KANBAN_TASK\", raising=False)\n+    from hermes_cli import kanban_db as kb\n+    conn = kb.connect()\n+    try:\n+        tid = kb.create_task(conn, title=\"blocked\", assignee=\"worker\")\n+        kb.block_task(conn, tid, reason=\"waiting\")\n+    finally:\n+        conn.close()\n+\n+    from tools import kanban_tools as kt\n+    out = kt._handle_unblock({\"task_id\": tid})\n+    d = json.loads(out)\n+    assert d[\"ok\"] is True\n+    assert d[\"status\"] == \"ready\"\n+\n+    conn = kb.connect()\n+    try:\n+        assert kb.get_task(conn, tid).status == \"ready\"\n+    finally:\n+        conn.close()\n+\n+\n+def test_unblock_rejects_non_blocked_task(monkeypatch, worker_env):\n+    monkeypatch.delenv(\"HERMES_KANBAN_TASK\", raising=False)\n+    from tools import kanban_tools as kt\n+    out = kt._handle_unblock({\"task_id\": worker_env})\n+    assert json.loads(out).get(\"error\")\n+\n \n def test_worker_lifecycle_through_tools(worker_env):\n     \"\"\"Drive the full claim -> heartbeat -> comment -> complete lifecycle","@@ -599,11 +822,12 @@ def test_kanban_guidance_prompt_size_bounded(monkeypatch, tmp_path):\n # ---------------------------------------------------------------------------\n #\n # A worker process has HERMES_KANBAN_TASK set to its own task id. The\n-# destructive tools (kanban_complete, kanban_block, kanban_heartbeat)\n-# must refuse to operate on any OTHER task id, even if the caller\n-# supplies an explicit `task_id` argument. Workers legitimately call\n-# kanban_show / kanban_comment / kanban_create / kanban_link on other\n-# tasks, so those are unrestricted.\n+# destructive tools (kanban_complete, kanban_block, kanban_heartbeat,\n+# kanban_unblock) must refuse to operate\n+# on any OTHER task id, even if the caller supplies an explicit `task_id`\n+# argument. Workers legitimately call kanban_show / kanban_list /\n+# kanban_comment / kanban_create / kanban_link on other tasks, so those\n+# are unrestricted.\n #\n # Orchestrator profiles (no HERMES_KANBAN_TASK in env) are intentionally\n # exempt — their job is routing, and they sometimes close out child","@@ -712,6 +936,37 @@ def test_worker_can_comment_on_foreign_task(worker_env):\n         conn.close()\n \n \n+def test_worker_unblock_rejects_foreign_task_id(worker_env):\n+    \"\"\"A worker cannot unblock any task — kanban_unblock is orchestrator-only.\n+\n+    The check fires before the per-task ownership check, so the error\n+    surface is the orchestrator-only refusal rather than the\n+    cross-task-ownership refusal. Either is fine — the property we're\n+    pinning is \"worker cannot mutate foreign task via kanban_unblock\".\n+    \"\"\"\n+    from hermes_cli import kanban_db as kb\n+    conn = kb.connect()\n+    try:\n+        other = kb.create_task(conn, title=\"blocked sibling\", assignee=\"peer\")\n+        kb.block_task(conn, other, reason=\"waiting\")\n+    finally:\n+        conn.close()\n+\n+    from tools import kanban_tools as kt\n+    out = kt._handle_unblock({\"task_id\": other})\n+    d = json.loads(out)\n+    err = d.get(\"error\", \"\")\n+    assert \"orchestrator-only\" in err or \"refusing to mutate\" in err, (\n+        f\"expected worker-rejection error, got {err}\"\n+    )\n+\n+    conn = kb.connect()\n+    try:\n+        assert kb.get_task(conn, other).status == \"blocked\"\n+    finally:\n+        conn.close()\n+\n+\n def test_worker_complete_own_task_still_works(worker_env):\n     \"\"\"The ownership check doesn't break the normal own-task happy path.\"\"\"\n     from tools import kanban_tools as kt"]},{"file":"tools/kanban_tools.py","hunks":["@@ -39,8 +39,25 @@\n # Gating\n # ---------------------------------------------------------------------------\n \n+KANBAN_LIST_DEFAULT_LIMIT = 50\n+KANBAN_LIST_MAX_LIMIT = 200\n+\n+\n+def _profile_has_kanban_toolset() -> bool:\n+    # Uses load_config() which has mtime-based caching, so this adds\n+    # negligible overhead. The check_fn results are further TTL-cached\n+    # (~30s) by the tool registry.\n+    try:\n+        from hermes_cli.config import load_config\n+        cfg = load_config()\n+        toolsets = cfg.get(\"toolsets\", [])\n+        return \"kanban\" in toolsets\n+    except Exception:\n+        return False\n+\n+\n def _check_kanban_mode() -> bool:\n-    \"\"\"Tools are available when:\n+    \"\"\"Task-lifecycle tools are available when:\n \n     1. ``HERMES_KANBAN_TASK`` is set (dispatcher-spawned worker), OR\n     2. The current profile has ``kanban`` in its toolsets config","@@ -49,22 +66,25 @@ def _check_kanban_mode() -> bool:\n     Humans running ``hermes chat`` without the kanban toolset see zero\n     kanban tools. Workers spawned by the kanban dispatcher (gateway-\n     embedded by default) and orchestrator profiles with the kanban\n-    toolset enabled see all seven.\n+    toolset enabled see the Kanban lifecycle tool surface.\n     \"\"\"\n     if os.environ.get(\"HERMES_KANBAN_TASK\"):\n         return True\n+    return _profile_has_kanban_toolset()\n \n-    # Check if the current profile has the kanban toolset enabled.\n-    # Uses load_config() which has mtime-based caching, so this adds\n-    # negligible overhead. The check_fn results are further TTL-cached\n-    # (~30s) by the tool registry.\n-    try:\n-        from hermes_cli.config import load_config\n-        cfg = load_config()\n-        toolsets = cfg.get(\"toolsets\", [])\n-        return \"kanban\" in toolsets\n-    except Exception:\n+\n+def _check_kanban_orchestrator_mode() -> bool:\n+    \"\"\"Board-routing tools (kanban_list, kanban_unblock) are intentionally\n+    hidden from task workers.\n+\n+    Dispatcher-spawned workers should close their own task via the\n+    lifecycle tools (complete/block/heartbeat), not enumerate or unblock\n+    board state. Profiles that explicitly opt into the kanban toolset\n+    and are NOT scoped to a single task are the orchestrator surface.\n+    \"\"\"\n+    if os.environ.get(\"HERMES_KANBAN_TASK\"):\n         return False\n+    return _profile_has_kanban_toolset()\n \n \n # ---------------------------------------------------------------------------","@@ -135,6 +155,73 @@ def _ok(**fields: Any) -> str:\n     return json.dumps({\"ok\": True, **fields})\n \n \n+def _normalize_profile(value: Any) -> Optional[str]:\n+    \"\"\"Normalize CLI-compatible assignee sentinels for the tool surface.\"\"\"\n+    if value is None:\n+        return None\n+    text = str(value).strip()\n+    if not text or text.lower() in (\"none\", \"-\", \"null\"):\n+        return None\n+    return text\n+\n+\n+def _parse_bool_arg(args: dict, name: str, *, default: bool = False):\n+    value = args.get(name)\n+    if value is None:\n+        return default, None\n+    if isinstance(value, bool):\n+        return value, None\n+    text = str(value).strip().lower()\n+    if text in (\"true\", \"1\", \"yes\"):\n+        return True, None\n+    if text in (\"false\", \"0\", \"no\"):\n+        return False, None\n+    return default, f\"{name} must be a boolean or 'true'/'false'\"\n+\n+\n+def _require_orchestrator_tool(tool_name: str) -> Optional[str]:\n+    \"\"\"Belt-and-suspenders runtime guard for orchestrator-only handlers.\n+\n+    The check_fn (`_check_kanban_orchestrator_mode`) keeps these tools\n+    out of the worker schema entirely, but in case a stale registration\n+    or test harness routes a worker to one of them anyway, return a\n+    structured tool_error so the model gets a clear refusal instead of\n+    silently mutating board state from a worker context.\n+    \"\"\"\n+    if os.environ.get(\"HERMES_KANBAN_TASK\"):\n+        return tool_error(\n+            f\"{tool_name} is orchestrator-only; dispatcher-spawned workers \"\n+            \"must use kanban_complete, kanban_block, kanban_heartbeat, or \"\n+            \"kanban_comment for their assigned task.\"\n+        )\n+    return None\n+\n+\n+def _task_summary_dict(kb, conn, task) -> dict[str, Any]:\n+    \"\"\"Compact task shape for board-listing tools.\"\"\"\n+    parents = kb.parent_ids(conn, task.id)\n+    children = kb.child_ids(conn, task.id)\n+    return {\n+        \"id\": task.id,\n+        \"title\": task.title,\n+        \"assignee\": task.assignee,\n+        \"status\": task.status,\n+        \"priority\": task.priority,\n+        \"tenant\": task.tenant,\n+        \"workspace_kind\": task.workspace_kind,\n+        \"workspace_path\": task.workspace_path,\n+        \"created_by\": task.created_by,\n+        \"created_at\": task.created_at,\n+        \"started_at\": task.started_at,\n+        \"completed_at\": task.completed_at,\n+        \"current_run_id\": task.current_run_id,\n+        \"parents\": parents,\n+        \"children\": children,\n+        \"parent_count\": len(parents),\n+        \"child_count\": len(children),\n+    }\n+\n+\n # ---------------------------------------------------------------------------\n # Handlers\n # ---------------------------------------------------------------------------","@@ -210,6 +297,66 @@ def _run_dict(r):\n         return tool_error(f\"kanban_show: {e}\")\n \n \n+def _handle_list(args: dict, **kw) -> str:\n+    \"\"\"List task summaries with the same core filters as the CLI.\"\"\"\n+    guard = _require_orchestrator_tool(\"kanban_list\")\n+    if guard:\n+        return guard\n+    assignee = args.get(\"assignee\")\n+    status = args.get(\"status\")\n+    tenant = args.get(\"tenant\")\n+    include_archived, bool_error = _parse_bool_arg(args, \"include_archived\")\n+    if bool_error:\n+        return tool_error(bool_error)\n+    limit = args.get(\"limit\")\n+    if limit is None:\n+        limit = KANBAN_LIST_DEFAULT_LIMIT\n+    try:\n+        limit = int(limit)\n+    except (TypeError, ValueError):\n+        return tool_error(\"limit must be an integer\")\n+    if limit < 1:\n+        return tool_error(\"limit must be >= 1\")\n+    if limit > KANBAN_LIST_MAX_LIMIT:\n+        return tool_error(f\"limit must be <= {KANBAN_LIST_MAX_LIMIT}\")\n+    try:\n+        kb, conn = _connect()\n+        try:\n+            # Match CLI list: dependencies that cleared since the last\n+            # dispatcher tick should be visible to orchestrators immediately.\n+            promoted = kb.recompute_ready(conn)\n+            # Fetch one extra row so model-facing output can report that\n+            # a bounded listing was truncated without dumping the board.\n+            rows = kb.list_tasks(\n+                conn,\n+                assignee=assignee,\n+                status=status,\n+                tenant=tenant,\n+                include_archived=include_archived,\n+                limit=limit + 1,\n+            )\n+            truncated = len(rows) > limit\n+            tasks = rows[:limit]\n+            return json.dumps({\n+                \"tasks\": [_task_summary_dict(kb, conn, t) for t in tasks],\n+                \"count\": len(tasks),\n+                \"limit\": limit,\n+                \"truncated\": truncated,\n+                \"next_limit\": (\n+                    min(limit * 2, KANBAN_LIST_MAX_LIMIT)\n+                    if truncated and limit < KANBAN_LIST_MAX_LIMIT else None\n+                ),\n+                \"promoted\": promoted,\n+            })\n+        finally:\n+            conn.close()\n+    except ValueError as e:\n+        return tool_error(f\"kanban_list: {e}\")\n+    except Exception as e:\n+        logger.exception(\"kanban_list failed\")\n+        return tool_error(f\"kanban_list: {e}\")\n+\n+\n def _handle_complete(args: dict, **kw) -> str:\n     \"\"\"Mark the current task done with a structured handoff.\"\"\"\n     tid = _default_task_id(args.get(\"task_id\"))","@@ -416,7 +563,9 @@ def _handle_create(args: dict, **kw) -> str:\n     priority = args.get(\"priority\")\n     workspace_kind = args.get(\"workspace_kind\") or \"scratch\"\n     workspace_path = args.get(\"workspace_path\")\n-    triage = bool(args.get(\"triage\"))\n+    triage, bool_error = _parse_bool_arg(args, \"triage\")\n+    if bool_error:\n+        return tool_error(bool_error)\n     idempotency_key = args.get(\"idempotency_key\")\n     max_runtime_seconds = args.get(\"max_runtime_seconds\")\n     skills = args.get(\"skills\")","@@ -467,6 +616,31 @@ def _handle_create(args: dict, **kw) -> str:\n         return tool_error(f\"kanban_create: {e}\")\n \n \n+def _handle_unblock(args: dict, **kw) -> str:\n+    \"\"\"Transition a blocked task back to ready.\"\"\"\n+    guard = _require_orchestrator_tool(\"kanban_unblock\")\n+    if guard:\n+        return guard\n+    tid = args.get(\"task_id\")\n+    if not tid:\n+        return tool_error(\"task_id is required\")\n+    ownership_err = _enforce_worker_task_ownership(str(tid))\n+    if ownership_err:\n+        return ownership_err\n+    try:\n+        kb, conn = _connect()\n+        try:\n+            ok = kb.unblock_task(conn, str(tid))\n+            if not ok:\n+                return tool_error(f\"could not unblock {tid} (not blocked or unknown)\")\n+            return _ok(task_id=str(tid), status=\"ready\")\n+        finally:\n+            conn.close()\n+    except Exception as e:\n+        logger.exception(\"kanban_unblock failed\")\n+        return tool_error(f\"kanban_unblock: {e}\")\n+\n+\n def _handle_link(args: dict, **kw) -> str:\n     \"\"\"Add a parent→child dependency edge after the fact.\"\"\"\n     parent_id = args.get(\"parent_id\")","@@ -519,6 +693,50 @@ def _handle_link(args: dict, **kw) -> str:\n     },\n }\n \n+KANBAN_LIST_SCHEMA = {\n+    \"name\": \"kanban_list\",\n+    \"description\": (\n+        \"List Kanban task summaries so an orchestrator profile can discover \"\n+        \"work to route. Supports the same core filters as the CLI: assignee, \"\n+        \"status, tenant, include_archived, and limit. Returns compact rows \"\n+        \"with ids, title, status, assignee, priority, parent/child ids, and \"\n+        \"counts. Bounded to 50 rows by default, 200 max, with truncation \"\n+        \"metadata. Also recomputes ready tasks before listing, matching the \"\n+        \"CLI. Orchestrator-only — dispatcher-spawned task workers never see \"\n+        \"this tool.\"\n+    ),\n+    \"parameters\": {\n+        \"type\": \"object\",\n+        \"properties\": {\n+            \"assignee\": {\n+                \"type\": \"string\",\n+                \"description\": \"Optional assignee/profile filter.\",\n+            },\n+            \"status\": {\n+                \"type\": \"string\",\n+                \"enum\": [\n+                    \"triage\", \"todo\", \"ready\", \"running\",\n+                    \"blocked\", \"done\", \"archived\",\n+                ],\n+                \"description\": \"Optional task status filter.\",\n+            },\n+            \"tenant\": {\n+                \"type\": \"string\",\n+                \"description\": \"Optional tenant/project namespace filter.\",\n+            },\n+            \"include_archived\": {\n+                \"type\": \"boolean\",\n+                \"description\": \"Include archived tasks. Defaults to false.\",\n+            },\n+            \"limit\": {\n+                \"type\": \"integer\",\n+                \"description\": \"Optional maximum rows to return (default 50, max 200).\",\n+            },\n+        },\n+        \"required\": [],\n+    },\n+}\n+\n KANBAN_COMPLETE_SCHEMA = {\n     \"name\": \"kanban_complete\",\n     \"description\": (","@@ -787,6 +1005,25 @@ def _handle_link(args: dict, **kw) -> str:\n     },\n }\n \n+KANBAN_UNBLOCK_SCHEMA = {\n+    \"name\": \"kanban_unblock\",\n+    \"description\": (\n+        \"Move a blocked Kanban task back to ready. Orchestrator-only — only \"\n+        \"profiles with the kanban toolset can unblock routed work; \"\n+        \"dispatcher-spawned task workers never see this tool.\"\n+    ),\n+    \"parameters\": {\n+        \"type\": \"object\",\n+        \"properties\": {\n+            \"task_id\": {\n+                \"type\": \"string\",\n+                \"description\": \"Blocked task id to return to ready.\",\n+            },\n+        },\n+        \"required\": [\"task_id\"],\n+    },\n+}\n+\n KANBAN_LINK_SCHEMA = {\n     \"name\": \"kanban_link\",\n     \"description\": (","@@ -818,6 +1055,15 @@ def _handle_link(args: dict, **kw) -> str:\n     emoji=\"📋\",\n )\n \n+registry.register(\n+    name=\"kanban_list\",\n+    toolset=\"kanban\",\n+    schema=KANBAN_LIST_SCHEMA,\n+    handler=_handle_list,\n+    check_fn=_check_kanban_orchestrator_mode,\n+    emoji=\"📋\",\n+)\n+\n registry.register(\n     name=\"kanban_complete\",\n     toolset=\"kanban\",","@@ -863,6 +1109,15 @@ def _handle_link(args: dict, **kw) -> str:\n     emoji=\"➕\",\n )\n \n+registry.register(\n+    name=\"kanban_unblock\",\n+    toolset=\"kanban\",\n+    schema=KANBAN_UNBLOCK_SCHEMA,\n+    handler=_handle_unblock,\n+    check_fn=_check_kanban_orchestrator_mode,\n+    emoji=\"▶\",\n+)\n+\n registry.register(\n     name=\"kanban_link\",\n     toolset=\"kanban\","]},{"file":"toolsets.py","hunks":["@@ -61,10 +61,13 @@\n     # Home Assistant smart home control (gated on HASS_TOKEN via check_fn)\n     \"ha_list_entities\", \"ha_get_state\", \"ha_list_services\", \"ha_call_service\",\n     # Kanban multi-agent coordination — only in schema when the agent is\n-    # spawned as a kanban worker (HERMES_KANBAN_TASK env set), otherwise\n-    # zero schema footprint. Gated via check_fn in tools/kanban_tools.py.\n-    \"kanban_show\", \"kanban_complete\", \"kanban_block\", \"kanban_heartbeat\",\n+    # spawned as a kanban worker (HERMES_KANBAN_TASK env set) or the current\n+    # profile explicitly enables the kanban toolset. Gated via check_fn in\n+    # tools/kanban_tools.py.\n+    \"kanban_show\", \"kanban_list\",\n+    \"kanban_complete\", \"kanban_block\", \"kanban_heartbeat\",\n     \"kanban_comment\", \"kanban_create\", \"kanban_link\",\n+    \"kanban_unblock\",\n     # Computer use (macOS, gated on cua-driver being installed via check_fn)\n     \"computer_use\",\n ]","@@ -233,12 +236,13 @@\n             \"`kanban.dispatch_in_gateway` in config.yaml. Lets workers mark \"\n             \"tasks done with structured handoffs, block for human input, \"\n             \"heartbeat during long ops, comment on threads, and (for \"\n-            \"orchestrators) fan out into child tasks.\"\n+            \"orchestrators) list, unblock, and fan out tasks.\"\n         ),\n         \"tools\": [\n-            \"kanban_show\", \"kanban_complete\", \"kanban_block\",\n+            \"kanban_show\", \"kanban_list\", \"kanban_complete\", \"kanban_block\",\n             \"kanban_heartbeat\", \"kanban_comment\",\n             \"kanban_create\", \"kanban_link\",\n+            \"kanban_unblock\",\n         ],\n         \"includes\": [],\n     },"]},{"file":"website/docs/user-guide/features/kanban-tutorial.md","hunks":["@@ -10,7 +10,7 @@ hermes dashboard             # opens http://127.0.0.1:9119 in your browser\n # click Kanban in the left nav\n ```\n \n-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.\n+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.\n \n 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.\n "]},{"file":"website/docs/user-guide/features/kanban.md","hunks":["@@ -14,7 +14,7 @@ Hermes Kanban is a durable task board, shared across all your Hermes profiles, t\n \n The board has two front doors, both backed by the same `~/.hermes/kanban.db`:\n \n-- **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.\n+- **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.\n - **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.\n \n 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\n \n ## How workers interact with the board\n \n-**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.\n+**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.\n \n | Tool | Purpose | Required params |\n |---|---|---|\n | `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. | — |\n+| `kanban_list` | List task summaries with filters for `assignee`, `status`, `tenant`, archived visibility, and limit. Intended for orchestrators discovering board work. | — |\n | `kanban_complete` | Finish with `summary` + `metadata` structured handoff. | at least one of `summary` / `result` |\n | `kanban_block` | Escalate for human input with a `reason`. | `reason` |\n | `kanban_heartbeat` | Signal liveness during long operations. Pure side-effect. | — |\n | `kanban_comment` | Append a durable note to the task thread. | `task_id`, `body` |\n | `kanban_create` | (Orchestrators) fan out into child tasks with an `assignee`, optional `parents`, `skills`, etc. | `title`, `assignee` |\n | `kanban_link` | (Orchestrators) add a `parent_id → child_id` dependency edge after the fact. | `parent_id`, `child_id` |\n+| `kanban_unblock` | (Orchestrators) move a blocked task back to `ready`. | `task_id` |\n \n A typical worker turn looks like:\n ","@@ -278,7 +280,7 @@ kanban_create(\n kanban_complete(summary=\"decomposed into 2 research tasks + 1 writer; linked dependencies\")\n ```\n \n-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.\n+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.\n \n ### Why tools instead of shelling to `hermes kanban`\n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}