From 832ecde4b08430fa81e7a82bf07cbb0aa77e175a Mon Sep 17 00:00:00 2001 From: teknium1 Date: Tue, 28 Apr 2026 04:30:22 -0700 Subject: [PATCH] feat(kanban): structured tool surface for worker + orchestrator agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven new tools in `tools/kanban_tools.py` that give kanban workers a backend-portable, schema-filtered way to interact with the board from inside their own Python process — no shelling out to `hermes kanban`. Motivation The CLI path (`hermes kanban complete \$TASK --summary ...`) breaks on any remote terminal backend (Docker, Modal, Singularity, SSH). The terminal tool runs `hermes kanban` inside the container, where `hermes` isn't installed and `~/.hermes/kanban.db` isn't mounted. Tools run in the agent's own Python process, so they always reach the board regardless of backend. Also skips shell-quoting fragility on --metadata JSON and gives structured error returns the model can reason about. The seven tools kanban_show read current task (defaults to HERMES_KANBAN_TASK) kanban_complete structured handoff: summary + metadata kanban_block ask for human input kanban_heartbeat signal liveness during long operations kanban_comment append to task thread kanban_create fan out into child tasks (orchestrator path) kanban_link add parent→child dependency after the fact Gating Each tool's check_fn returns True iff HERMES_KANBAN_TASK is set in the process env. The dispatcher sets it when spawning a worker; normal `hermes chat` sessions never have it. Empirically verified: a baseline hermes-cli schema is 27 tools; with HERMES_KANBAN_TASK set it grows to exactly 34 (+7). Zero leak into normal sessions. Also set HERMES_PROFILE in the spawn env so the kanban_comment tool's author default works cleanly (it's what the tool reads to attribute comments). Skill updates - `skills/devops/kanban-worker/SKILL.md`: lifecycle rewritten to use kanban_show / kanban_heartbeat / kanban_block / kanban_complete / kanban_comment / kanban_create directly. CLI fallback section added for human operators / scripts. - `skills/devops/kanban-orchestrator/SKILL.md`: all examples ported from CLI to tool form; top-banner note explaining tools are the primary surface. kanban_create / kanban_link throughout. Docs `website/docs/user-guide/features/kanban.md`: new "How workers interact with the board" section explaining the tool surface, gating mechanism, and why tools vs CLI. The worker skill / orchestrator skill subsections are now nested under it. Tests (+25 in tests/tools/test_kanban_tools.py) - Schema gating: kanban_tools_hidden_without_env_var, kanban_tools_visible_with_env_var. - Happy paths: show (default + explicit task_id), complete (with summary+metadata, with result only), block, heartbeat (with and without note), comment (default + custom author), create (with list parents, with string parent), link. - Error paths: complete rejects no-handoff and non-dict metadata, block rejects empty reason, comment rejects empty body, create rejects no title / no assignee / non-list parents, link rejects self-reference / missing args / cycles. - End-to-end: full worker lifecycle driven entirely through the tools, verified against DB state. 214/214 kanban suite pass under scripts/run_tests.sh. --- hermes_cli/kanban_db.py | 5 + skills/devops/kanban-orchestrator/SKILL.md | 37 +- skills/devops/kanban-worker/SKILL.md | 74 ++- tests/tools/test_kanban_tools.py | 380 +++++++++++ tools/kanban_tools.py | 704 +++++++++++++++++++++ toolsets.py | 21 + website/docs/user-guide/features/kanban.md | 36 +- 7 files changed, 1207 insertions(+), 50 deletions(-) create mode 100644 tests/tools/test_kanban_tools.py create mode 100644 tools/kanban_tools.py diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index a9fa0c9746f..8e923a1c2fe 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -2010,6 +2010,11 @@ def _default_spawn(task: Task, workspace: str) -> Optional[int]: env["HERMES_TENANT"] = task.tenant env["HERMES_KANBAN_TASK"] = task.id env["HERMES_KANBAN_WORKSPACE"] = workspace + # HERMES_PROFILE is the author the kanban_comment tool defaults to. + # `hermes -p ` activates the profile, but the env var is + # what the tool reads — set it explicitly here so comments are + # attributed correctly regardless of how the child loads config. + env["HERMES_PROFILE"] = task.assignee cmd = [ "hermes", diff --git a/skills/devops/kanban-orchestrator/SKILL.md b/skills/devops/kanban-orchestrator/SKILL.md index 1b706b9fca3..252acc9ad60 100644 --- a/skills/devops/kanban-orchestrator/SKILL.md +++ b/skills/devops/kanban-orchestrator/SKILL.md @@ -14,6 +14,8 @@ metadata: Load this skill in an orchestrator profile. An orchestrator's job is to route: read the user's goal, decompose it into well-scoped tasks, assign each to the right specialist profile, link dependencies, and step back. It does NOT do research, writing, coding, or any implementation work itself. +> **Your surface is the `kanban_*` tools.** Because you were spawned with `HERMES_KANBAN_TASK` in your env, you have `kanban_show`, `kanban_create`, `kanban_link`, `kanban_comment`, `kanban_complete`, `kanban_block`, and `kanban_heartbeat` directly in your tool schema. Use them. They work regardless of terminal backend (Docker / Modal / SSH) and skip shell-quoting entirely. The `hermes kanban ` CLI is for humans at a terminal; you're not at a terminal. + ## When to use the board (vs. just doing the work) Create Kanban tasks when any of these are true: @@ -72,15 +74,19 @@ T1 [planner] — meta; this is me ### Step 3 — Create tasks, link dependencies For each leaf-level task: -```bash -hermes kanban create "angle: cost analysis" \ - --assignee researcher \ - --tenant $HERMES_TENANT + +``` +kanban_create( + title="angle: cost analysis", + assignee="researcher", + tenant=os.environ.get("HERMES_TENANT"), +) ``` Repeat per task. Then link them: -```bash -hermes kanban link + +``` +kanban_link(parent_id=parent_tid, child_id=child_tid) ``` **Do not assign something to yourself.** If the orchestrator shows up as an assignee anywhere, you've made a mistake. @@ -89,9 +95,10 @@ hermes kanban link If you were spawned as a task yourself (e.g. `planner` profile was assigned `T1: "investigate foo"`), mark it done with a summary of what you created: -```bash -hermes kanban complete $HERMES_KANBAN_TASK \ - --result "decomposed into T2-T6: 3 research angles, 1 synthesis, 1 brief" +``` +kanban_complete( + summary="decomposed into T2-T6: 3 research angles, 1 synthesis, 1 brief", +) ``` ### Step 5 — Tell the user what you did @@ -124,11 +131,11 @@ The eight collaboration patterns you can instantiate (load the design spec if un User says: *"Analyze whether we should migrate to Postgres. Include a cost analysis and a performance angle."* Your decomposition: -1. `hermes kanban create "research: Postgres cost vs current" --assignee researcher` -2. `hermes kanban create "research: Postgres performance vs current" --assignee researcher` -3. `hermes kanban create "synthesize migration recommendation" --assignee analyst` -4. `hermes kanban link ` ; `hermes kanban link ` -5. `hermes kanban create "draft decision memo" --assignee writer --parent ` +1. `kanban_create(title="research: Postgres cost vs current", assignee="researcher")` +2. `kanban_create(title="research: Postgres performance vs current", assignee="researcher")` +3. `kanban_create(title="synthesize migration recommendation", assignee="analyst")` +4. `kanban_link(parent_id=t1, child_id=t3)` ; `kanban_link(parent_id=t2, child_id=t3)` +5. `kanban_create(title="draft decision memo", assignee="writer", parents=[t3])` 6. Report task IDs and expected flow to the user. ## Pitfalls @@ -137,4 +144,4 @@ Your decomposition: **Reassignment vs. new task.** If a reviewer blocks with "needs changes," create a NEW task linked from the reviewer's task — don't re-run the same task with a stern look. The new task is assigned to the original implementer profile. -**Link order matters.** `hermes kanban link ` — parent first. Mixing them up demotes the wrong task to `todo`. +**Argument order matters.** `kanban_link(parent_id=..., child_id=...)` — parent first. Mixing them up demotes the wrong task to `todo`. diff --git a/skills/devops/kanban-worker/SKILL.md b/skills/devops/kanban-worker/SKILL.md index 3ee456488d5..cb8f49404d8 100644 --- a/skills/devops/kanban-worker/SKILL.md +++ b/skills/devops/kanban-worker/SKILL.md @@ -22,16 +22,16 @@ You are **one run of one specialist profile working one task.** Read the task, d ## Step 1 — Read the full context -```bash -hermes kanban context $HERMES_KANBAN_TASK +Call the **`kanban_show`** tool. It returns the task's title + body, your prior attempts (if this is a retry), parent task handoffs (summary + metadata), comments, and a pre-formatted `worker_context` string suitable for inclusion verbatim in your reasoning. + +``` +kanban_show() # defaults to your current task via HERMES_KANBAN_TASK +kanban_show(task_id="t_abc") # peek at another task ``` -That command prints: -1. Task title + body. -2. Every comment on the task, in order, with author names. -3. Completion results of every `done` parent task (upstream context). +**Read all of it.** The comment thread is the inter-agent protocol — past peers, human clarifications, and blocker resolutions all live there. If a reviewer left feedback or the user answered a blocker, it's in the comments. Prior attempts are the retry lesson: if you're running this task a second time, the first run's summary/error tells you what didn't work. -**Read all of it.** The comment thread is the inter-agent protocol — past peers, human clarifications, and blocker resolutions all live there. If a reviewer left feedback or the user answered a blocker, it's in the comments. +> **Note:** the `kanban_*` tools are only available to you because you were spawned by the dispatcher (`HERMES_KANBAN_TASK` is set in your env). A normal chat session doesn't have them. For CLI debugging or scripting outside an agent run, use `hermes kanban ` — same kernel, different surface. ## Step 2 — Work inside the workspace @@ -64,42 +64,54 @@ Any of these should trigger a block: - Source that needs human input (paywalled article, 2FA-gated login). - Peer profile needs to deliver something first and you can't reach around that. -```bash -hermes kanban block $HERMES_KANBAN_TASK "need decision: IP vs user_id for rate limit key?" +``` +kanban_block(reason="need decision: IP vs user_id for rate limit key?") ``` -`block` also appends your reason as a visible comment. When the user or a peer unblocks and the dispatcher re-spawns you, you'll see the full comment thread including their answer in step 1's context read. +The block event is visible to humans on the board (dashboard + gateway notifier). When the user or a peer unblocks and the dispatcher re-spawns you, you'll see the full comment thread including their answer in step 1's context read. -## Step 5 — Complete with a crisp, machine-readable result +## Step 5 — Complete with a structured handoff -```bash -hermes kanban complete $HERMES_KANBAN_TASK --result "rate_limiter.py implemented; keys on user_id with IP fallback; tests passing" +``` +kanban_complete( + summary="implemented token-bucket rate limiter, keys on user_id with IP fallback, all tests pass", + metadata={ + "changed_files": ["rate_limiter.py", "tests/test_rate_limiter.py"], + "tests_run": 14, + "decisions": ["user_id primary, IP fallback for unauthenticated"], + }, +) ``` -Rules for the `--result` string: -- One to three sentences. It's not a report, it's a handoff note. -- Name concrete artifacts you produced (file paths, URLs, commit SHAs). -- State any caveats a downstream profile needs to know. -- **Do not** include secrets, tokens, or raw PII — results are durable in the board DB forever. +Rules: -Downstream tasks (children linked from this task) will see your `--result` verbatim as part of their parent-result context. +- **`summary`** is the human-readable 1–3 sentence handoff. It appears in the Run History on the dashboard and is what downstream workers see first. Name concrete artifacts (file paths, URLs, commit SHAs). +- **`metadata`** is machine-readable facts — changed files, test counts, findings, decisions. Downstream workers can parse it structurally instead of scraping prose. +- **Do not** include secrets, tokens, or raw PII — run rows are durable in the board DB forever. + +Downstream tasks (children linked from this task) will see your `summary` + `metadata` via `build_worker_context` — that's the handoff channel. ## Step 6 — If follow-up work is obvious, create it. Don't do it. You are one task. If you notice something else needs doing, create a linked child task for the right profile instead of scope-creeping: -```bash -hermes kanban create "add concurrent-request test" \ - --assignee backend-eng \ - --parent $HERMES_KANBAN_TASK +``` +kanban_create( + title="add concurrent-request test", + assignee="backend-eng", + parents=[os.environ["HERMES_KANBAN_TASK"]], +) ``` ## Leave comments to talk to peers If you want to flag something for a reviewer, a future run, or the user — append a comment: -```bash -hermes kanban comment $HERMES_KANBAN_TASK "note: skipped the sqlite driver path; needs separate task" +``` +kanban_comment( + task_id=os.environ["HERMES_KANBAN_TASK"], + body="note: skipped the sqlite driver path; needs separate task", +) ``` Comments are the inter-agent protocol. Direct IPC does not exist; the board is the only channel. @@ -108,11 +120,11 @@ Comments are the inter-agent protocol. Direct IPC does not exist; the board is t If your task forks a long-lived subprocess (training run, video encode, web crawl, batch upload), the dispatcher can't tell whether your Python is stuck or deliberately waiting. Call: -```bash -hermes kanban heartbeat $HERMES_KANBAN_TASK --note "epoch 12/50, loss 0.31" +``` +kanban_heartbeat(note="epoch 12/50, loss 0.31") ``` -…every few minutes during the long wait. The note is optional; the signal itself is the point. Heartbeats show up in the event stream so humans reading `hermes kanban watch` can see you're still alive. Skip heartbeats for short tasks — they're noise below a few-minute runtime. +…every few minutes during the long wait. The note is optional; the signal itself is the point. Heartbeats show up in the event stream so humans reading the dashboard or `hermes kanban watch` can see you're still alive. Skip heartbeats for short tasks — they're noise below a few-minute runtime. ## Do NOT @@ -121,9 +133,13 @@ hermes kanban heartbeat $HERMES_KANBAN_TASK --note "epoch 12/50, loss 0.31" - Do not assign tasks to yourself during your run (you're already running one; create new tasks for follow-ups only). - Do not complete a task you didn't actually finish. Block it instead. +## CLI fallback + +The `kanban_*` tools are the primary surface, but every operation has a CLI equivalent (`hermes kanban show`, `hermes kanban complete --summary ... --metadata '{...}'`, etc.). Use the tools — they're more ergonomic, always work regardless of terminal backend (Docker/Modal/SSH), and avoid shell-quoting issues. The CLI exists for human operators and scripts. + ## Pitfalls -**The task might already be blocked or reassigned when you start.** Between when the dispatcher claimed and when you actually booted up, circumstances can change. Always read the current state at step 1. If `hermes kanban show` reports the task is blocked or reassigned, stop — don't keep running. +**The task might already be blocked or reassigned when you start.** Between when the dispatcher claimed and when you actually booted up, circumstances can change. Always read the current state at step 1. If `kanban_show` reports the task is blocked or reassigned, stop — don't keep running. **The workspace may already have artifacts from a previous run.** Especially for `dir:` and `worktree` workspaces, a previous worker may have written files that are incomplete or stale. Read the comment thread — it usually explains why you're running again. diff --git a/tests/tools/test_kanban_tools.py b/tests/tools/test_kanban_tools.py new file mode 100644 index 00000000000..b6af72479f6 --- /dev/null +++ b/tests/tools/test_kanban_tools.py @@ -0,0 +1,380 @@ +"""Tests for the Kanban tool surface (tools/kanban_tools.py). + +Verifies: + - Tools are gated on HERMES_KANBAN_TASK: a normal chat session sees + zero kanban tools in its schema; a worker session sees all seven. + - Each handler's happy path. + - Error paths (missing required args, bad metadata type, etc). +""" +from __future__ import annotations + +import json +import os + +import pytest + + +# --------------------------------------------------------------------------- +# Gating +# --------------------------------------------------------------------------- + +def test_kanban_tools_hidden_without_env_var(monkeypatch, tmp_path): + """Normal `hermes chat` sessions (no HERMES_KANBAN_TASK) must have + zero kanban_* tools in their schema.""" + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + + import tools.kanban_tools # ensure registered + from tools.registry import registry + from toolsets import resolve_toolset + + 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 == set(), ( + f"kanban tools leaked into normal chat schema: {kanban}" + ) + + +def test_kanban_tools_visible_with_env_var(monkeypatch, tmp_path): + """Worker sessions (HERMES_KANBAN_TASK set) must have all 7 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 toolsets import resolve_toolset + + 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_show", "kanban_complete", "kanban_block", "kanban_heartbeat", + "kanban_comment", "kanban_create", "kanban_link", + } + assert kanban == expected, f"expected {expected}, got {kanban}" + + +# --------------------------------------------------------------------------- +# Handler happy paths +# --------------------------------------------------------------------------- + +@pytest.fixture +def worker_env(monkeypatch, tmp_path): + """Simulate being a worker: HERMES_HOME isolated, HERMES_KANBAN_TASK set + after we've created the task.""" + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setenv("HERMES_PROFILE", "test-worker") + from pathlib import Path as _Path + monkeypatch.setattr(_Path, "home", lambda: tmp_path) + + from hermes_cli import kanban_db as kb + kb._INITIALIZED_PATHS.clear() + kb.init_db() + conn = kb.connect() + try: + tid = kb.create_task(conn, title="worker-test", assignee="test-worker") + kb.claim_task(conn, tid) + finally: + conn.close() + monkeypatch.setenv("HERMES_KANBAN_TASK", tid) + return tid + + +def test_show_defaults_to_env_task_id(worker_env): + from tools import kanban_tools as kt + out = kt._handle_show({}) + d = json.loads(out) + assert "task" in d + assert d["task"]["id"] == worker_env + assert d["task"]["status"] == "running" + assert "worker_context" in d + assert "runs" in d + + +def test_show_explicit_task_id(worker_env): + """Peek at a different task than the one in env.""" + from hermes_cli import kanban_db as kb + conn = kb.connect() + try: + other = kb.create_task(conn, title="other task", assignee="peer") + finally: + conn.close() + from tools import kanban_tools as kt + out = kt._handle_show({"task_id": other}) + d = json.loads(out) + assert d["task"]["id"] == other + + +def test_complete_happy_path(worker_env): + from tools import kanban_tools as kt + out = kt._handle_complete({ + "summary": "got the thing done", + "metadata": {"files": 2}, + }) + d = json.loads(out) + assert d["ok"] is True + assert d["task_id"] == worker_env + # Verify via kernel + from hermes_cli import kanban_db as kb + conn = kb.connect() + try: + run = kb.latest_run(conn, worker_env) + assert run.outcome == "completed" + assert run.summary == "got the thing done" + assert run.metadata == {"files": 2} + finally: + conn.close() + + +def test_complete_with_result_only(worker_env): + """`result` alone (without summary) is accepted for legacy compat.""" + from tools import kanban_tools as kt + out = kt._handle_complete({"result": "legacy result"}) + d = json.loads(out) + assert d["ok"] is True + + +def test_complete_rejects_no_handoff(worker_env): + from tools import kanban_tools as kt + out = kt._handle_complete({}) + assert json.loads(out).get("error"), "should have errored" + + +def test_complete_rejects_non_dict_metadata(worker_env): + from tools import kanban_tools as kt + out = kt._handle_complete({"summary": "x", "metadata": [1, 2, 3]}) + assert json.loads(out).get("error") + + +def test_block_happy_path(worker_env): + from tools import kanban_tools as kt + out = kt._handle_block({"reason": "need clarification"}) + d = json.loads(out) + assert d["ok"] is True + from hermes_cli import kanban_db as kb + conn = kb.connect() + try: + assert kb.get_task(conn, worker_env).status == "blocked" + finally: + conn.close() + + +def test_block_rejects_empty_reason(worker_env): + from tools import kanban_tools as kt + for bad in ["", " ", None]: + out = kt._handle_block({"reason": bad}) + assert json.loads(out).get("error") + + +def test_heartbeat_happy_path(worker_env): + from tools import kanban_tools as kt + out = kt._handle_heartbeat({"note": "progress"}) + d = json.loads(out) + assert d["ok"] is True + + +def test_heartbeat_without_note(worker_env): + """note is optional.""" + from tools import kanban_tools as kt + out = kt._handle_heartbeat({}) + d = json.loads(out) + assert d["ok"] is True + + +def test_comment_happy_path(worker_env): + from tools import kanban_tools as kt + out = kt._handle_comment({ + "task_id": worker_env, + "body": "hello thread", + }) + d = json.loads(out) + assert d["ok"] is True + assert d["comment_id"] + from hermes_cli import kanban_db as kb + conn = kb.connect() + try: + comments = kb.list_comments(conn, worker_env) + assert len(comments) == 1 + # Author defaults to HERMES_PROFILE env we set in the fixture + assert comments[0].author == "test-worker" + assert comments[0].body == "hello thread" + finally: + conn.close() + + +def test_comment_rejects_empty_body(worker_env): + from tools import kanban_tools as kt + out = kt._handle_comment({"task_id": worker_env, "body": " "}) + assert json.loads(out).get("error") + + +def test_comment_custom_author(worker_env): + from tools import kanban_tools as kt + out = kt._handle_comment({ + "task_id": worker_env, "body": "hi", "author": "custom-bot", + }) + assert json.loads(out)["ok"] + from hermes_cli import kanban_db as kb + conn = kb.connect() + try: + comments = kb.list_comments(conn, worker_env) + assert comments[0].author == "custom-bot" + finally: + conn.close() + + +def test_create_happy_path(worker_env): + from tools import kanban_tools as kt + out = kt._handle_create({ + "title": "child task", + "assignee": "peer", + "parents": [worker_env], + }) + d = json.loads(out) + assert d["ok"] is True + assert d["task_id"] + assert d["status"] == "todo" # parent isn't done yet + from hermes_cli import kanban_db as kb + conn = kb.connect() + try: + child = kb.get_task(conn, d["task_id"]) + assert child.title == "child task" + assert child.assignee == "peer" + finally: + conn.close() + + +def test_create_rejects_no_title(worker_env): + from tools import kanban_tools as kt + assert json.loads(kt._handle_create({"assignee": "x"})).get("error") + assert json.loads(kt._handle_create({"title": " ", "assignee": "x"})).get("error") + + +def test_create_rejects_no_assignee(worker_env): + from tools import kanban_tools as kt + assert json.loads(kt._handle_create({"title": "t"})).get("error") + + +def test_create_rejects_non_list_parents(worker_env): + from tools import kanban_tools as kt + out = kt._handle_create({"title": "t", "assignee": "a", "parents": 42}) + assert 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 + out = kt._handle_create({ + "title": "t", "assignee": "a", "parents": worker_env, + }) + assert json.loads(out)["ok"] + + +def test_link_happy_path(worker_env): + from hermes_cli import kanban_db as kb + conn = kb.connect() + try: + a = kb.create_task(conn, title="A", assignee="x") + b = kb.create_task(conn, title="B", assignee="x") + finally: + conn.close() + from tools import kanban_tools as kt + out = kt._handle_link({"parent_id": a, "child_id": b}) + d = json.loads(out) + assert d["ok"] is True + + +def test_link_rejects_self_reference(worker_env): + from tools import kanban_tools as kt + out = kt._handle_link({"parent_id": worker_env, "child_id": worker_env}) + assert json.loads(out).get("error") + + +def test_link_rejects_missing_args(worker_env): + from tools import kanban_tools as kt + assert json.loads(kt._handle_link({"parent_id": "x"})).get("error") + assert json.loads(kt._handle_link({"child_id": "y"})).get("error") + + +def test_link_rejects_cycle(worker_env): + """A → B, then try to link B → A.""" + from hermes_cli import kanban_db as kb + conn = kb.connect() + try: + a = kb.create_task(conn, title="A", assignee="x") + b = kb.create_task(conn, title="B", assignee="x", parents=[a]) + finally: + conn.close() + from tools import kanban_tools as kt + out = kt._handle_link({"parent_id": b, "child_id": a}) + assert json.loads(out).get("error") + + +# --------------------------------------------------------------------------- +# End-to-end: simulate a full worker lifecycle through the tools +# --------------------------------------------------------------------------- + +def test_worker_lifecycle_through_tools(worker_env): + """Drive the full claim -> heartbeat -> comment -> complete lifecycle + exclusively through the tools, then verify the DB state matches what + the dispatcher/notifier expect.""" + from tools import kanban_tools as kt + + # 1. show — worker orientation + show = json.loads(kt._handle_show({})) + assert show["task"]["id"] == worker_env + + # 2. heartbeat during long op + assert json.loads(kt._handle_heartbeat({"note": "warming up"}))["ok"] + + # 3. comment for a future peer + assert json.loads(kt._handle_comment({ + "task_id": worker_env, + "body": "note: using stdlib sqlite3 bindings", + }))["ok"] + + # 4. spawn a child task for follow-up + child_out = json.loads(kt._handle_create({ + "title": "write integration test", + "assignee": "qa", + "parents": [worker_env], + })) + assert child_out["ok"] + + # 5. complete with structured handoff + comp = json.loads(kt._handle_complete({ + "summary": "implemented + spawned QA follow-up", + "metadata": {"child_task": child_out["task_id"]}, + })) + assert comp["ok"] + + # Verify final state + from hermes_cli import kanban_db as kb + conn = kb.connect() + try: + parent = kb.get_task(conn, worker_env) + assert parent.status == "done" + assert parent.current_run_id is None + run = kb.latest_run(conn, worker_env) + assert run.outcome == "completed" + assert run.metadata == {"child_task": child_out["task_id"]} + # Child is todo (parent just finished, but recompute_ready may + # have promoted it — complete_task runs recompute internally). + child = kb.get_task(conn, child_out["task_id"]) + assert child.status == "ready", ( + f"child should be ready after parent done, got {child.status}" + ) + # Comment is visible + assert len(kb.list_comments(conn, worker_env)) == 1 + # Heartbeat event recorded + hb = [e for e in kb.list_events(conn, worker_env) if e.kind == "heartbeat"] + assert len(hb) == 1 + finally: + conn.close() diff --git a/tools/kanban_tools.py b/tools/kanban_tools.py new file mode 100644 index 00000000000..958e1621581 --- /dev/null +++ b/tools/kanban_tools.py @@ -0,0 +1,704 @@ +"""Kanban tools — structured tool-call surface for worker + orchestrator agents. + +These tools are only registered into the model's schema when the agent is +running under the dispatcher (env var ``HERMES_KANBAN_TASK`` set). A +normal ``hermes chat`` session sees **zero** kanban tools in its schema. + +Why tools instead of just shelling out to ``hermes kanban``? + +1. **Backend portability.** A worker whose terminal tool points at Docker + / Modal / Singularity / SSH would run ``hermes kanban complete …`` + inside the container, where ``hermes`` isn't installed and the DB + isn't mounted. Tools run in the agent's Python process, so they + always reach ``~/.hermes/kanban.db`` regardless of terminal backend. + +2. **No shell-quoting footguns.** Passing ``--metadata '{"x": [...]}'`` + through shlex+argparse is fragile. Structured tool args skip it. + +3. **Better errors.** Tool-call failures return structured JSON the + model can reason about, not stderr strings it has to parse. + +Humans continue to use the CLI (``hermes kanban …``), the dashboard +(``hermes dashboard``), and the slash command (``/kanban …``) — all +three bypass the agent entirely. The tools are ONLY for the worker +agent's handoff back to the kernel. +""" +from __future__ import annotations + +import json +import logging +import os +from typing import Any, Optional + +from tools.registry import registry, tool_error + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Gating +# --------------------------------------------------------------------------- + +def _check_kanban_mode() -> bool: + """Tools are available iff the current process has ``HERMES_KANBAN_TASK`` + set in its env, which the dispatcher sets when spawning a worker. + + Humans running ``hermes chat`` see zero kanban tools. Workers spawned + by ``hermes kanban daemon`` see all seven. + """ + return bool(os.environ.get("HERMES_KANBAN_TASK")) + + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + +def _default_task_id(arg: Optional[str]) -> Optional[str]: + """Resolve ``task_id`` arg or fall back to the env var the dispatcher set.""" + if arg: + return arg + env_tid = os.environ.get("HERMES_KANBAN_TASK") + return env_tid or None + + +def _connect(): + """Import + connect lazily so the module imports cleanly in non-kanban + contexts (e.g. test rigs that import every tool module).""" + from hermes_cli import kanban_db as kb + return kb, kb.connect() + + +def _ok(**fields: Any) -> str: + return json.dumps({"ok": True, **fields}) + + +# --------------------------------------------------------------------------- +# Handlers +# --------------------------------------------------------------------------- + +def _handle_show(args: dict, **kw) -> str: + """Read a task's full state: task row, parents, children, comments, + runs (attempt history), and the last N events.""" + tid = _default_task_id(args.get("task_id")) + if not tid: + return tool_error( + "task_id is required (or set HERMES_KANBAN_TASK in the env)" + ) + try: + kb, conn = _connect() + try: + task = kb.get_task(conn, tid) + if task is None: + return tool_error(f"task {tid} not found") + comments = kb.list_comments(conn, tid) + events = kb.list_events(conn, tid) + runs = kb.list_runs(conn, tid) + parents = kb.parent_ids(conn, tid) + children = kb.child_ids(conn, tid) + + def _task_dict(t): + return { + "id": t.id, "title": t.title, "body": t.body, + "assignee": t.assignee, "status": t.status, + "tenant": t.tenant, "priority": t.priority, + "workspace_kind": t.workspace_kind, + "workspace_path": t.workspace_path, + "created_by": t.created_by, "created_at": t.created_at, + "started_at": t.started_at, + "completed_at": t.completed_at, + "result": t.result, + "current_run_id": t.current_run_id, + } + + def _run_dict(r): + return { + "id": r.id, "profile": r.profile, + "status": r.status, "outcome": r.outcome, + "summary": r.summary, "error": r.error, + "metadata": r.metadata, + "started_at": r.started_at, "ended_at": r.ended_at, + } + + return json.dumps({ + "task": _task_dict(task), + "parents": parents, + "children": children, + "comments": [ + {"author": c.author, "body": c.body, + "created_at": c.created_at} + for c in comments + ], + "events": [ + {"kind": e.kind, "payload": e.payload, + "created_at": e.created_at, "run_id": e.run_id} + for e in events[-50:] # cap; full log via CLI + ], + "runs": [_run_dict(r) for r in runs], + # Also surface the worker's own context block so the + # agent can include it directly if it wants. This is + # the same string build_worker_context returns to the + # dispatcher at spawn time. + "worker_context": kb.build_worker_context(conn, tid), + }) + finally: + conn.close() + except Exception as e: + logger.exception("kanban_show failed") + return tool_error(f"kanban_show: {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")) + if not tid: + return tool_error( + "task_id is required (or set HERMES_KANBAN_TASK in the env)" + ) + summary = args.get("summary") + metadata = args.get("metadata") + result = args.get("result") + if not (summary or result): + return tool_error( + "provide at least one of: summary (preferred), result" + ) + if metadata is not None and not isinstance(metadata, dict): + return tool_error( + f"metadata must be an object/dict, got {type(metadata).__name__}" + ) + try: + kb, conn = _connect() + try: + ok = kb.complete_task( + conn, tid, + result=result, summary=summary, metadata=metadata, + ) + if not ok: + return tool_error( + f"could not complete {tid} (unknown id or already terminal)" + ) + run = kb.latest_run(conn, tid) + return _ok(task_id=tid, run_id=run.id if run else None) + finally: + conn.close() + except Exception as e: + logger.exception("kanban_complete failed") + return tool_error(f"kanban_complete: {e}") + + +def _handle_block(args: dict, **kw) -> str: + """Transition the task to blocked with a reason a human will read.""" + tid = _default_task_id(args.get("task_id")) + if not tid: + return tool_error( + "task_id is required (or set HERMES_KANBAN_TASK in the env)" + ) + reason = args.get("reason") + if not reason or not str(reason).strip(): + return tool_error("reason is required — explain what input you need") + try: + kb, conn = _connect() + try: + ok = kb.block_task(conn, tid, reason=reason) + if not ok: + return tool_error( + f"could not block {tid} (unknown id or not in " + f"running/ready)" + ) + run = kb.latest_run(conn, tid) + return _ok(task_id=tid, run_id=run.id if run else None) + finally: + conn.close() + except Exception as e: + logger.exception("kanban_block failed") + return tool_error(f"kanban_block: {e}") + + +def _handle_heartbeat(args: dict, **kw) -> str: + """Signal that the worker is still alive during a long operation.""" + tid = _default_task_id(args.get("task_id")) + if not tid: + return tool_error( + "task_id is required (or set HERMES_KANBAN_TASK in the env)" + ) + note = args.get("note") + try: + kb, conn = _connect() + try: + ok = kb.heartbeat_worker(conn, tid, note=note) + if not ok: + return tool_error( + f"could not heartbeat {tid} (unknown id or not running)" + ) + return _ok(task_id=tid) + finally: + conn.close() + except Exception as e: + logger.exception("kanban_heartbeat failed") + return tool_error(f"kanban_heartbeat: {e}") + + +def _handle_comment(args: dict, **kw) -> str: + """Append a comment to a task's thread.""" + tid = args.get("task_id") + if not tid: + return tool_error( + "task_id is required (use the current task id if that's what " + "you mean — pulls from env but kept explicit here)" + ) + body = args.get("body") + if not body or not str(body).strip(): + return tool_error("body is required") + author = args.get("author") or os.environ.get("HERMES_PROFILE") or "worker" + try: + kb, conn = _connect() + try: + cid = kb.add_comment(conn, tid, author=author, body=str(body)) + return _ok(task_id=tid, comment_id=cid) + finally: + conn.close() + except Exception as e: + logger.exception("kanban_comment failed") + return tool_error(f"kanban_comment: {e}") + + +def _handle_create(args: dict, **kw) -> str: + """Create a child task. Orchestrator workers use this to fan out. + + ``parents`` can be a list of task ids; dependency-gated promotion + works as usual. + """ + title = args.get("title") + if not title or not str(title).strip(): + return tool_error("title is required") + assignee = args.get("assignee") + if not assignee: + return tool_error( + "assignee is required — name the profile that should execute this " + "task (the dispatcher will only spawn tasks with an assignee)" + ) + body = args.get("body") + parents = args.get("parents") or [] + tenant = args.get("tenant") or os.environ.get("HERMES_TENANT") + priority = args.get("priority") + workspace_kind = args.get("workspace_kind") or "scratch" + workspace_path = args.get("workspace_path") + triage = bool(args.get("triage")) + idempotency_key = args.get("idempotency_key") + max_runtime_seconds = args.get("max_runtime_seconds") + if isinstance(parents, str): + parents = [parents] + if not isinstance(parents, (list, tuple)): + return tool_error( + f"parents must be a list of task ids, got {type(parents).__name__}" + ) + try: + kb, conn = _connect() + try: + new_tid = kb.create_task( + conn, + title=str(title).strip(), + body=body, + assignee=str(assignee), + parents=tuple(parents), + tenant=tenant, + priority=int(priority) if priority is not None else 0, + workspace_kind=str(workspace_kind), + workspace_path=workspace_path, + triage=triage, + idempotency_key=idempotency_key, + max_runtime_seconds=( + int(max_runtime_seconds) + if max_runtime_seconds is not None else None + ), + created_by=os.environ.get("HERMES_PROFILE") or "worker", + ) + new_task = kb.get_task(conn, new_tid) + return _ok( + task_id=new_tid, + status=new_task.status if new_task else None, + ) + finally: + conn.close() + except Exception as e: + logger.exception("kanban_create failed") + return tool_error(f"kanban_create: {e}") + + +def _handle_link(args: dict, **kw) -> str: + """Add a parent→child dependency edge after the fact.""" + parent_id = args.get("parent_id") + child_id = args.get("child_id") + if not parent_id or not child_id: + return tool_error("both parent_id and child_id are required") + try: + kb, conn = _connect() + try: + kb.link_tasks(conn, parent_id=parent_id, child_id=child_id) + return _ok(parent_id=parent_id, child_id=child_id) + finally: + conn.close() + except ValueError as e: + # Covers cycle + self-parent rejections + return tool_error(f"kanban_link: {e}") + except Exception as e: + logger.exception("kanban_link failed") + return tool_error(f"kanban_link: {e}") + + +# --------------------------------------------------------------------------- +# Schemas +# --------------------------------------------------------------------------- + +_DESC_TASK_ID_DEFAULT = ( + "Task id. If omitted, defaults to HERMES_KANBAN_TASK from the env " + "(the task the dispatcher spawned you to work on)." +) + +KANBAN_SHOW_SCHEMA = { + "name": "kanban_show", + "description": ( + "Read a task's full state — title, body, assignee, parent task " + "handoffs, your prior attempts on this task if any, comments, " + "and recent events. Use this to (re)orient yourself before " + "starting work, especially on retries. The response includes a " + "pre-formatted ``worker_context`` string suitable for inclusion " + "verbatim in your reasoning." + ), + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": _DESC_TASK_ID_DEFAULT, + }, + }, + "required": [], + }, +} + +KANBAN_COMPLETE_SCHEMA = { + "name": "kanban_complete", + "description": ( + "Mark your current task done with a structured handoff for " + "downstream workers and humans. Prefer ``summary`` for a " + "human-readable 1-3 sentence description of what you did; put " + "machine-readable facts in ``metadata`` (changed_files, " + "tests_run, decisions, findings, etc). At least one of " + "``summary`` or ``result`` is required." + ), + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": _DESC_TASK_ID_DEFAULT, + }, + "summary": { + "type": "string", + "description": ( + "Human-readable handoff, 1-3 sentences. Appears in " + "Run History on the dashboard and in downstream " + "workers' context." + ), + }, + "metadata": { + "type": "object", + "description": ( + "Free-form dict of structured facts about this " + "attempt — {\"changed_files\": [...], \"tests_run\": 12, " + "\"findings\": [...]}. Surfaced to downstream " + "workers alongside ``summary``." + ), + }, + "result": { + "type": "string", + "description": ( + "Short result log line (legacy field, maps to " + "task.result). Use ``summary`` instead when " + "possible; this exists for compatibility with " + "callers that still set --result on the CLI." + ), + }, + }, + "required": [], + }, +} + +KANBAN_BLOCK_SCHEMA = { + "name": "kanban_block", + "description": ( + "Transition the task to blocked because you need human input " + "to proceed. ``reason`` will be shown to the human on the " + "board and included in context when someone unblocks you. " + "Use for genuine blockers only — don't block on things you can " + "resolve yourself." + ), + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": _DESC_TASK_ID_DEFAULT, + }, + "reason": { + "type": "string", + "description": ( + "What you need answered, in one or two sentences. " + "Don't paste the whole conversation; the human has " + "the board and can ask follow-ups via comments." + ), + }, + }, + "required": ["reason"], + }, +} + +KANBAN_HEARTBEAT_SCHEMA = { + "name": "kanban_heartbeat", + "description": ( + "Signal that you're still alive during a long operation " + "(training, encoding, large crawls). Call every few minutes so " + "humans see liveness separately from PID checks. Pure side " + "effect — no work changes." + ), + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": _DESC_TASK_ID_DEFAULT, + }, + "note": { + "type": "string", + "description": ( + "Optional short note describing current progress. " + "Shown in the event log." + ), + }, + }, + "required": [], + }, +} + +KANBAN_COMMENT_SCHEMA = { + "name": "kanban_comment", + "description": ( + "Append a comment to a task's thread. Use for durable notes " + "that should outlive this run (questions for the next worker, " + "partial findings, rationale). Ephemeral reasoning doesn't " + "belong here — use your normal response instead." + ), + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": ( + "Task id. Required (may be your own task or " + "another's — comment threads are per-task)." + ), + }, + "body": { + "type": "string", + "description": "Markdown-supported comment body.", + }, + "author": { + "type": "string", + "description": ( + "Override author name. Defaults to the current " + "profile (HERMES_PROFILE env)." + ), + }, + }, + "required": ["task_id", "body"], + }, +} + +KANBAN_CREATE_SCHEMA = { + "name": "kanban_create", + "description": ( + "Create a new kanban task, optionally as a child of the current " + "one (pass the current task id in ``parents``). Used by " + "orchestrator workers to fan out — decompose work into child " + "tasks with specific assignees, link them into a pipeline, " + "then complete your own task. The dispatcher picks up the new " + "tasks on its next tick and spawns the assigned profiles." + ), + "parameters": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Short task title (required).", + }, + "assignee": { + "type": "string", + "description": ( + "Profile name that should execute this task " + "(e.g. 'researcher-a', 'reviewer', 'writer'). " + "Required — tasks without an assignee are never " + "dispatched." + ), + }, + "body": { + "type": "string", + "description": ( + "Opening post: full spec, acceptance criteria, " + "links. The assigned worker reads this as part of " + "its context." + ), + }, + "parents": { + "type": "array", + "items": {"type": "string"}, + "description": ( + "Parent task ids. The new task stays in 'todo' " + "until every parent reaches 'done'; then it " + "auto-promotes to 'ready'. Typical fan-in: list " + "all the researcher task ids when creating a " + "synthesizer task." + ), + }, + "tenant": { + "type": "string", + "description": ( + "Optional namespace for multi-project isolation. " + "Defaults to HERMES_TENANT env if set." + ), + }, + "priority": { + "type": "integer", + "description": ( + "Dispatcher tiebreaker. Higher = picked sooner " + "when multiple ready tasks share an assignee." + ), + }, + "workspace_kind": { + "type": "string", + "enum": ["scratch", "dir", "worktree"], + "description": ( + "Workspace flavor: 'scratch' (fresh tmp dir, " + "default), 'dir' (shared directory, requires " + "absolute workspace_path), 'worktree' (git worktree)." + ), + }, + "workspace_path": { + "type": "string", + "description": ( + "Absolute path for 'dir' or 'worktree' workspace. " + "Relative paths are rejected at dispatch." + ), + }, + "triage": { + "type": "boolean", + "description": ( + "If true, task lands in 'triage' instead of 'todo' " + "— a specifier profile is expected to flesh out " + "the body before work starts." + ), + }, + "idempotency_key": { + "type": "string", + "description": ( + "If a non-archived task with this key already " + "exists, return that task's id instead of creating " + "a duplicate. Useful for retry-safe automation." + ), + }, + "max_runtime_seconds": { + "type": "integer", + "description": ( + "Per-task runtime cap. When exceeded, the " + "dispatcher SIGTERMs the worker and re-queues the " + "task with outcome='timed_out'." + ), + }, + }, + "required": ["title", "assignee"], + }, +} + +KANBAN_LINK_SCHEMA = { + "name": "kanban_link", + "description": ( + "Add a parent→child dependency edge after both tasks already " + "exist. The child won't promote to 'ready' until all parents " + "are 'done'. Cycles and self-links are rejected." + ), + "parameters": { + "type": "object", + "properties": { + "parent_id": {"type": "string", "description": "Parent task id."}, + "child_id": {"type": "string", "description": "Child task id."}, + }, + "required": ["parent_id", "child_id"], + }, +} + + +# --------------------------------------------------------------------------- +# Registration +# --------------------------------------------------------------------------- + +registry.register( + name="kanban_show", + toolset="kanban", + schema=KANBAN_SHOW_SCHEMA, + handler=_handle_show, + check_fn=_check_kanban_mode, + emoji="📋", +) + +registry.register( + name="kanban_complete", + toolset="kanban", + schema=KANBAN_COMPLETE_SCHEMA, + handler=_handle_complete, + check_fn=_check_kanban_mode, + emoji="✔", +) + +registry.register( + name="kanban_block", + toolset="kanban", + schema=KANBAN_BLOCK_SCHEMA, + handler=_handle_block, + check_fn=_check_kanban_mode, + emoji="⏸", +) + +registry.register( + name="kanban_heartbeat", + toolset="kanban", + schema=KANBAN_HEARTBEAT_SCHEMA, + handler=_handle_heartbeat, + check_fn=_check_kanban_mode, + emoji="💓", +) + +registry.register( + name="kanban_comment", + toolset="kanban", + schema=KANBAN_COMMENT_SCHEMA, + handler=_handle_comment, + check_fn=_check_kanban_mode, + emoji="💬", +) + +registry.register( + name="kanban_create", + toolset="kanban", + schema=KANBAN_CREATE_SCHEMA, + handler=_handle_create, + check_fn=_check_kanban_mode, + emoji="➕", +) + +registry.register( + name="kanban_link", + toolset="kanban", + schema=KANBAN_LINK_SCHEMA, + handler=_handle_link, + check_fn=_check_kanban_mode, + emoji="🔗", +) diff --git a/toolsets.py b/toolsets.py index 1c113afe60a..79b465e0629 100644 --- a/toolsets.py +++ b/toolsets.py @@ -60,6 +60,11 @@ _HERMES_CORE_TOOLS = [ "send_message", # Home Assistant smart home control (gated on HASS_TOKEN via check_fn) "ha_list_entities", "ha_get_state", "ha_list_services", "ha_call_service", + # Kanban multi-agent coordination — only in schema when the agent is + # spawned as a kanban worker (HERMES_KANBAN_TASK env set), otherwise + # zero schema footprint. Gated via check_fn in tools/kanban_tools.py. + "kanban_show", "kanban_complete", "kanban_block", "kanban_heartbeat", + "kanban_comment", "kanban_create", "kanban_link", ] @@ -202,6 +207,22 @@ TOOLSETS = { "includes": [] }, + "kanban": { + "description": ( + "Kanban multi-agent coordination — only active when the agent " + "is spawned by `hermes kanban daemon` (HERMES_KANBAN_TASK env " + "set). 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." + ), + "tools": [ + "kanban_show", "kanban_complete", "kanban_block", + "kanban_heartbeat", "kanban_comment", + "kanban_create", "kanban_link", + ], + "includes": [], + }, + "discord": { "description": "Discord read and participate tools (fetch messages, search members, create threads)", "tools": ["discord"], diff --git a/website/docs/user-guide/features/kanban.md b/website/docs/user-guide/features/kanban.md index f201a61576b..164dc85d297 100644 --- a/website/docs/user-guide/features/kanban.md +++ b/website/docs/user-guide/features/kanban.md @@ -115,14 +115,38 @@ hermes kanban unblock t_abc t_def hermes kanban block t_abc "need input" --ids t_def t_hij ``` -## The worker skill +## How workers interact with the board + +When the dispatcher spawns a worker, it sets `HERMES_KANBAN_TASK` in the child's env. That env var is the gate for a dedicated **kanban toolset** — 7 tools that the normal agent schema never sees: + +| Tool | Purpose | +|---|---| +| `kanban_show` | Read the current task (title, body, prior attempts, parent handoffs, comments, full `worker_context`). Defaults to the env's task id. | +| `kanban_complete` | Finish with `summary` + `metadata` structured handoff. | +| `kanban_block` | Escalate for human input. | +| `kanban_heartbeat` | Signal liveness during long operations. | +| `kanban_comment` | Append to the task thread. | +| `kanban_create` | (Orchestrators) fan out into child tasks. | +| `kanban_link` | (Orchestrators) add dependency edges after the fact. | + +**Why tools and not just shelling to `hermes kanban`?** Three reasons: + +1. **Backend portability.** Workers whose terminal tool points at a remote backend (Docker / Modal / Singularity / SSH) would run `hermes kanban complete` inside the container where `hermes` isn't installed and the DB isn't mounted. The kanban tools run in the agent's own Python process and always reach `~/.hermes/kanban.db` regardless of terminal backend. +2. **No shell-quoting fragility.** Passing `--metadata '{"files": [...]}'` through shlex + argparse is a latent footgun. Structured tool args skip it. +3. **Better errors.** Tool results are structured JSON the model can reason about, not stderr strings it has to parse. + +**Zero schema footprint on normal sessions.** A regular `hermes chat` session has zero `kanban_*` tools in its schema. The `check_fn` on each tool only returns True when `HERMES_KANBAN_TASK` is set, which only happens when the dispatcher spawned this process. No tool bloat for users who never touch kanban. + +The `kanban-worker` and `kanban-orchestrator` skills teach the model which tool to call when and in what order. + +### The worker skill Any profile that should be able to work kanban tasks must load the `kanban-worker` skill. It teaches the worker the full lifecycle: -1. On spawn, read `$HERMES_KANBAN_TASK` env var. -2. Run `hermes kanban context $HERMES_KANBAN_TASK` to read title + body + parent results + full comment thread. -3. `cd $HERMES_KANBAN_WORKSPACE` and do the work there. -4. Complete with `hermes kanban complete --result ""`, or block with `hermes kanban block ""` if stuck. +1. On spawn, call `kanban_show()` to read title + body + parent handoffs + prior attempts + full comment thread. +2. `cd $HERMES_KANBAN_WORKSPACE` and do the work there. +3. Call `kanban_heartbeat(note="...")` every few minutes during long operations. +4. Complete with `kanban_complete(summary="...", metadata={...})`, or `kanban_block(reason="...")` if stuck. Load it with: @@ -130,7 +154,7 @@ Load it with: hermes skills install devops/kanban-worker ``` -## The orchestrator skill +### The orchestrator skill A **well-behaved orchestrator does not do the work itself.** It decomposes the user's goal into tasks, links them, assigns each to a specialist, and steps back. The `kanban-orchestrator` skill encodes this: anti-temptation rules, a standard specialist roster (`researcher`, `writer`, `analyst`, `backend-eng`, `reviewer`, `ops`), and a decomposition playbook.