feat(kanban): attachment toolset + CLI to match the dashboard surface

The kanban board has had full attachment storage and a dashboard HTTP
API (upload/list/download/delete) since #35338, but there was no agent
toolset tool and no `hermes kanban` CLI verb for attachments. Agents and
scripts that don't go through the dashboard server (or can't touch the DB
directly) had no way to create or read real attachments — only links in
comments.

Close that gap by mirroring the existing comment surface:

- `kanban_db.store_attachment_bytes()` — one shared write path (validate
  name, enforce the 25 MB cap, write the blob under the per-task dir with
  collision-free naming, insert the metadata row, clean up an orphan blob
  if the insert fails). `_MAX_ATTACHMENT_BYTES`, `_safe_attachment_name`,
  and a new `_collision_free_path` move here so the dashboard, the tool,
  and the CLI all share one implementation and can't drift.
- Tools (`tools/kanban_tools.py`): `kanban_attach` (inline base64),
  `kanban_attach_url` (server-side http/https fetch with the same cap),
  `kanban_attachments` (list). Write tools respect worker task-ownership;
  list is read-only. Registered in the `kanban` toolset.
- CLI (`hermes_cli/kanban.py`): `attach <id> <path>`, `attachments <id>`,
  `attach-rm <attachment_id>`.
- Dashboard `upload_task_attachment` now imports the shared helpers and
  uses `_collision_free_path` — behavior identical (still streams to disk
  with the cap, still 413 on overflow).
- Docs (AGENTS.md, kanban-worker skill) and toolset membership updated.

Tests: tool round-trip + oversize + bad base64 + ownership; attach_url
against a local HTTP fixture incl. oversize-mid-stream and non-http
scheme rejection; CLI attach/attachments/attach-rm; shared-helper unit
tests; dashboard parity preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
otsune 2026-06-01 11:59:30 +09:00 committed by Teknium
parent 14f023cd00
commit 3fccd698fd
9 changed files with 930 additions and 39 deletions

View file

@ -890,7 +890,7 @@ def test_cli_gc_reports_counts(kanban_home):
# run_slash parity — every verb returns a sensible, non-crashy string
# ---------------------------------------------------------------------------
def test_run_slash_every_verb_returns_sensible_output(kanban_home):
def test_run_slash_every_verb_returns_sensible_output(kanban_home, tmp_path):
"""Smoke-test every verb with minimal args. None may raise, none may
return the empty string (must either succeed or report a usage error)."""
# Set up a pair of tasks to reference.
@ -901,6 +901,9 @@ def test_run_slash_every_verb_returns_sensible_output(kanban_home):
finally:
conn.close()
attach_src = tmp_path / "smoke.txt"
attach_src.write_text("smoke")
invocations = [
"", # no subcommand → help text
"--help",
@ -914,6 +917,8 @@ def test_run_slash_every_verb_returns_sensible_output(kanban_home):
f"unlink {tid_a} {tid_b}",
f"claim {tid_a}",
f"comment {tid_a} hello",
f"attach {tid_a} {attach_src}",
f"attachments {tid_a}",
f"complete {tid_a}",
f"block {tid_b} need input",
f"unblock {tid_b}",