Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions hermes_cli/kanban.py
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,21 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
p_comment.add_argument("--max-len", type=int, default=None,
help="Trim the stored comment body to this many characters")

p_update_body = sub.add_parser(
"update-body",
help="Replace the canonical body of an existing task and audit the change",
)
p_update_body.add_argument("task_id")
p_update_body.add_argument(
"--body", required=True,
help="New canonical task body (passed as one argument; newlines are preserved)",
)
p_update_body.add_argument(
"--author", default=None,
help="Author/source recorded in the body_updated event",
)
p_update_body.add_argument("--json", action="store_true", help="Emit JSON output")

# --- attach / attachments / attach-rm ---
p_attach = sub.add_parser("attach", help="Attach a local file to a task")
p_attach.add_argument("task_id")
Expand Down Expand Up @@ -1058,6 +1073,7 @@ def kanban_command(args: argparse.Namespace) -> int:
"unlink": _cmd_unlink,
"claim": _cmd_claim,
"comment": _cmd_comment,
"update-body": _cmd_update_body,
"attach": _cmd_attach,
"attachments": _cmd_attachments,
"attach-rm": _cmd_attach_rm,
Expand Down Expand Up @@ -2048,6 +2064,32 @@ def _cmd_comment(args: argparse.Namespace) -> int:
return 0


def _cmd_update_body(args: argparse.Namespace) -> int:
author = args.author or _profile_author()
with kb.connect_closing() as conn:
result = kb.update_task_body(
conn,
args.task_id,
args.body,
author=author,
)
if result is None:
print(f"kanban: no such task: {args.task_id}", file=sys.stderr)
return 1
payload = {
"task_id": args.task_id,
"changed": bool(result["changed"]),
"body_sha256": result["new_sha256"],
"body_length": result["new_length"],
}
if getattr(args, "json", False):
print(json.dumps(payload, ensure_ascii=False))
else:
state = "updated" if result["changed"] else "unchanged"
print(f"Canonical body {state} for {args.task_id}")
return 0


def _cmd_attach(args: argparse.Namespace) -> int:
"""Attach a local file to a task.

Expand Down
54 changes: 54 additions & 0 deletions hermes_cli/kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -3521,6 +3521,60 @@ def set_reasoning_effort(
return True


def update_task_body(
conn: sqlite3.Connection,
task_id: str,
body: str,
*,
author: Optional[str] = None,
) -> Optional[dict[str, Any]]:
"""Atomically refresh a task's canonical body and audit the change.

Returns ``None`` when ``task_id`` does not exist. Otherwise returns a
small metadata dict with ``changed`` plus old/new body hashes and lengths.
Repeating the same update is a no-op and does not append an event. The
operation deliberately changes only ``tasks.body`` and its audit event;
it never recomputes readiness or mutates assignment/status fields.

Body contents are not copied into the event payload. Hashes and lengths
are enough to prove what changed without duplicating potentially sensitive
task text in the event log.
"""
if not isinstance(body, str):
raise ValueError("task body must be a string")
actor = (author or "").strip() or None
new_sha256 = hashlib.sha256(body.encode("utf-8")).hexdigest()
with write_txn(conn):
row = conn.execute(
"SELECT body FROM tasks WHERE id = ?", (task_id,)
).fetchone()
if not row:
return None
old_body = row["body"] or ""
old_sha256 = hashlib.sha256(old_body.encode("utf-8")).hexdigest()
changed = old_body != body
if changed:
conn.execute(
"UPDATE tasks SET body = ? WHERE id = ?",
(body, task_id),
)
payload = {
"author": actor,
"old_sha256": old_sha256,
"new_sha256": new_sha256,
"old_length": len(old_body),
"new_length": len(body),
}
_append_event(conn, task_id, "body_updated", payload)
return {
"changed": changed,
"old_sha256": old_sha256,
"new_sha256": new_sha256,
"old_length": len(old_body),
"new_length": len(body),
}


# ---------------------------------------------------------------------------
# Links
# ---------------------------------------------------------------------------
Expand Down
106 changes: 106 additions & 0 deletions tests/hermes_cli/test_kanban_body_update.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""Regression tests for audited canonical Kanban task-body updates."""

from __future__ import annotations

import argparse
import json
from pathlib import Path

import pytest

from hermes_cli import kanban as kc
from hermes_cli import kanban_db as kb


@pytest.fixture
def kanban_home(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
kb.init_db()
return home


def test_update_task_body_is_atomic_audited_and_idempotent(kanban_home):
with kb.connect_closing() as conn:
task_id = kb.create_task(
conn,
title="body drift",
body="historical body",
initial_status="blocked",
)

first = kb.update_task_body(
conn,
task_id,
"current canonical body",
author="p0-kanban-sync",
)
assert first is not None
assert first["changed"] is True
assert first["old_length"] == len("historical body")
assert first["new_length"] == len("current canonical body")

task = kb.get_task(conn, task_id)
assert task is not None
assert task.body == "current canonical body"
assert task.status == "blocked"
assert task.assignee is None
assert task.current_run_id is None

event = conn.execute(
"SELECT kind, payload FROM task_events "
"WHERE task_id = ? AND kind = 'body_updated'",
(task_id,),
).fetchone()
assert event is not None
assert json.loads(event["payload"])["author"] == "p0-kanban-sync"

second = kb.update_task_body(
conn,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please assert the full audit/privacy contract here: expected old/new SHA-256 values and lengths, plus that the decoded payload has no body-text fields. The current assertion only verifies author, so a later regression could persist canonical task text in the event log undetected.

task_id,
"current canonical body",
author="p0-kanban-sync",
)
assert second is not None
assert second["changed"] is False
assert conn.execute(
"SELECT COUNT(*) FROM task_events "
"WHERE task_id = ? AND kind = 'body_updated'",
(task_id,),
).fetchone()[0] == 1


def test_update_body_cli_emits_machine_readable_result(kanban_home, capsys):
with kb.connect_closing() as conn:
task_id = kb.create_task(conn, title="cli body", body="old")

parser = argparse.ArgumentParser(prog="hermes", add_help=False)
sub = parser.add_subparsers(dest="command")
kc.build_parser(sub)
args = parser.parse_args(
[
"kanban",
"--board",
"default",
"update-body",
task_id,
"--body",
"new from cli",
"--author",
"p0-kanban-sync",
"--json",
]
)

assert kc.kanban_command(args) == 0
payload = json.loads(capsys.readouterr().out)
assert payload["task_id"] == task_id
assert payload["changed"] is True
assert payload["body_length"] == len("new from cli")

with kb.connect_closing() as conn:
task = kb.get_task(conn, task_id)
assert task is not None
assert task.body == "new from cli"
1 change: 1 addition & 0 deletions website/docs/reference/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,7 @@ Multi-profile, multi-project collaboration board. Each install can host many boa
| `unlink <parent> <child>` | Remove a dependency. |
| `claim <id>` | Atomically claim a ready task. Prints resolved workspace path. |
| `comment <id> "<text>"` | Append a comment. The next worker that claims the task reads it as part of its `kanban_show()` response. |
| `update-body <id> --body "<text>"` | Replace the canonical body of an existing task and audit the change (appends a `body_updated` event carrying only sha256 + lengths; no body text is copied into the event log). Repeating the same body is a no-op. Flags: `--author` (recorded as the event author), `--json` (machine-readable result). Does not recompute readiness or touch assignment/status. |
| `complete <id>` | Mark task done. Flags: `--result`, `--summary`, `--metadata`. |
| `block <id> "<reason>"` | Mark task blocked for human input. Also appends the reason as a comment. |
| `schedule <id> "<reason>"` | Park time-delay/follow-up work in `scheduled` so it is not shown as a human blocker. |
Expand Down
Loading