NousResearch/hermes-agent

Kanban dashboard completion summary preservation fix

Cluster 20008. Data as of

Nothing open on GitHub. Fix merged in #20195 on 5 May 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #20195. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #20195. That is separate from the fix event.
Still open
Nothing open on GitHub.

These issues and PRs concern a bug where the kanban dashboard's Complete button bypasses the summary modal, losing completion summaries. The work addresses preserving those summaries and adds a kanban edit capability.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #20195 fix(kanban): preserve dashboard completion summaries + add kanban edit (salvages #20016)

    merged to main

    files: hermes_cli/kanban.py, hermes_cli/kanban_db.py, plugins/kanban/dashboard/dist/index.js, plugins/kanban/dashboard/plugin_api.py, tests/hermes_cli/test_kanban_core_functionality.py, tests/plugins/test_kanban_dashboard_plugin.py

    Show the diff
    • hermes_cli/kanban.py
      @@ -343,6 +343,27 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
                                   help='JSON dict of structured facts (e.g. \'{"changed_files": [...], '
                                        '"tests_run": 12}\'). Stored on the closing run.')
       
      +    p_edit = sub.add_parser(
      +        "edit",
      +        help="Edit recovery fields on an already-completed task",
      +    )
      +    p_edit.add_argument("task_id")
      +    p_edit.add_argument(
      +        "--result",
      +        required=True,
      +        help="Backfilled task result text for a done task",
      +    )
      +    p_edit.add_argument(
      +        "--summary",
      +        default=None,
      +        help="Structured handoff summary. Falls back to --result if omitted.",
      +    )
      +    p_edit.add_argument(
      +        "--metadata",
      +        default=None,
      +        help="JSON dict of structured facts to store on the latest completed run.",
      +    )
      +
           p_block = sub.add_parser("block", help="Mark one or more tasks blocked")
           p_block.add_argument("task_id")
           p_block.add_argument("reason", nargs="*", help="Reason (also appended as a comment)")
      @@ -581,6 +602,7 @@ def kanban_command(args: argparse.Namespace) -> int:
               "claim":    _cmd_claim,
               "comment":  _cmd_comment,
               "complete": _cmd_complete,
      +        "edit":     _cmd_edit,
               "block":    _cmd_block,
               "unblock":  _cmd_unblock,
               "archive":  _cmd_archive,
      @@ -1187,6 +1209,34 @@ def _cmd_complete(args: argparse.Namespace) -> int:
           return 0 if not failed else 1
       
       
      +def _cmd_edit(args: argparse.Namespace) -> int:
      +    raw_meta = getattr(args, "metadata", None)
      +    metadata = None
      +    if raw_meta:
      +        try:
      +            metadata = json.loads(raw_meta)
      +            if not isinstance(metadata, dict):
      +                raise ValueError("must be a JSON object")
      +        except (ValueError, json.JSONDecodeError) as exc:
      +            print(f"kanban: --metadata: {exc}", file=sys.stderr)
      +            return 2
      +    with kb.connect() as conn:
      +        if not kb.edit_completed_task_result(
      +            conn,
      +            args.task_id,
      +            result=args.result,
      +            summary=getattr(args, "summary", None),
      +            metadata=metadata,
      +        ):
      +            print(
      +                f"cannot edit {args.task_id} (unknown id or task is not done)",
      +                file=sys.stderr,
      +            )
      +            return 1
      +    print(f"Edited {args.task_id}")
      +    return 0
      +
      +
       def _cmd_block(args: argparse.Namespace) -> int:
           reason = " ".join(args.reason).strip() if args.reason else None
           author = _profile_author()
    • hermes_cli/kanban_db.py
      @@ -1917,6 +1917,73 @@ def complete_task(
           return True
       
       
      +def edit_completed_task_result(
      +    conn: sqlite3.Connection,
      +    task_id: str,
      +    *,
      +    result: str,
      +    summary: Optional[str] = None,
      +    metadata: Optional[dict] = None,
      +) -> bool:
      +    """Backfill the user-visible result for an already completed task."""
      +    handoff_summary = summary if summary is not None else result
      +    with write_txn(conn):
      +        row = conn.execute(
      +            "SELECT status FROM tasks WHERE id = ?", (task_id,),
      +        ).fetchone()
      +        if not row or row["status"] != "done":
      +            return False
      +        conn.execute(
      +            "UPDATE tasks SET result = ? WHERE id = ?",
      +            (result, task_id),
      +        )
      +        run = conn.execute(
      +            """
      +            SELECT id FROM task_runs
      +             WHERE task_id = ?
      +               AND outcome = 'completed'
      +             ORDER BY COALESCE(ended_at, started_at, 0) DESC, id DESC
      +             LIMIT 1
      +            """,
      +            (task_id,),
      +        ).fetchone()
      +        run_id = int(run["id"]) if run else None
      +        if run_id is None:
      +            run_id = _synthesize_ended_run(
      +                conn, task_id,
      +                outcome="completed",
      +                summary=handoff_summary,
      +                metadata=metadata,
      +            )
      +        else:
      +            conn.execute(
      +                "UPDATE task_runs SET summary = ? WHERE id = ?",
      +                (handoff_summary, run_id),
      +            )
      +            if metadata is not None:
      +                conn.execute(
      +                    "UPDATE task_runs SET metadata = ? WHERE id = ?",
      +                    (json.dumps(metadata, ensure_ascii=False), run_id),
      +                )
      +        ev_summary = (
      +            handoff_summary.strip().splitlines()[0][:400]
      +            if handoff_summary else ""
      +        )
      +        _append_event(
      +            conn, task_id, "edited",
      +            {
      +                "fields": (
      +                    ["result", "summary"]
      +                    + (["metadata"] if metadata is not None else [])
      +                ),
      +                "result_len": len(result) if result else 0,
      +                "summary": ev_summary or None,
      +            },
      +            run_id=run_id,
      +        )
      +    return True
      +
      +
       def block_task(
           conn: sqlite3.Connection,
           task_id: str,
    • plugins/kanban/dashboard/dist/index.js
      @@ -60,6 +60,22 @@
           blocked: "Mark this task as blocked? The worker's claim is released.",
         };
       
      +  function withCompletionSummary(patch, count) {
      +    if (!patch || patch.status !== "done") return patch;
      +    const label = count && count > 1 ? `${count} selected task(s)` : "this task";
      +    const value = window.prompt(
      +      `Completion summary for ${label}. This is stored as the task result.`,
      +      "",
      +    );
      +    if (value === null) return null;
      +    const summary = value.trim();
      +    if (!summary) {
      +      window.alert("Completion summary is required before marking a task done.");
      +      return null;
      +    }
      +    return Object.assign({}, patch, { result: summary, summary });
      +  }
      +
         const API = "/api/plugins/kanban";
         const MIME_TASK = "text/x-hermes-task";
       
      @@ -480,6 +496,8 @@
           const moveTask = useCallback(function (taskId, newStatus) {
             const confirmMsg = DESTRUCTIVE_TRANSITIONS[newStatus];
             if (confirmMsg && !window.confirm(confirmMsg)) return;
      +      const patch = withCompletionSummary({ status: newStatus }, 1);
      +      if (!patch) return;
             setBoardData(function (b) {
               if (!b) return b;
               let moved = null;
      @@ -499,7 +517,7 @@
             SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(taskId)}`, board), {
               method: "PATCH",
               headers: { "Content-Type": "application/json" },
      -        body: JSON.stringify({ status: newStatus }),
      +        body: JSON.stringify(patch),
             }).catch(function (err) {
               setError(`Move failed: ${err.message || err}`);
               loadBoard();
      @@ -538,7 +556,9 @@
           const applyBulk = useCallback(function (patch, confirmMsg) {
             if (selectedIds.size === 0) return;
             if (confirmMsg && !window.confirm(confirmMsg)) return;
      -      const body = Object.assign({ ids: Array.from(selectedIds) }, patch);
      +      const finalPatch = withCompletionSummary(patch, selectedIds.size);
      +      if (!finalPatch) return;
      +      const body = Object.assign({ ids: Array.from(selectedIds) }, finalPatch);
             SDK.fetchJSON(withBoard(`${API}/tasks/bulk`, board), {
               method: "POST",
               headers: { "Content-Type": "application/json" },
      @@ -1426,10 +1446,12 @@
             if (opts && opts.confirm && !window.confirm(opts.confirm)) {
               return Promise.resolve();
             }
      +      const finalPatch = withCompletionSummary(patch, 1);
      +      if (!finalPatch) return Promise.resolve();
             return SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}`, boardSlug), {
               method: "PATCH",
               headers: { "Content-Type": "application/json" },
      -        body: JSON.stringify(patch),
      +        body: JSON.stringify(finalPatch),
             }).then(function () { load(); props.onRefresh(); });
           };
       
    • plugins/kanban/dashboard/plugin_api.py
      @@ -630,6 +630,9 @@ class BulkTaskBody(BaseModel):
           assignee: Optional[str] = None  # "" or None = unassign
           priority: Optional[int] = None
           archive: bool = False
      +    result: Optional[str] = None
      +    summary: Optional[str] = None
      +    metadata: Optional[dict] = None
       
       
       @router.post("/tasks/bulk")
      @@ -660,7 +663,12 @@ def bulk_update(payload: BulkTaskBody, board: Optional[str] = Query(None)):
                       if payload.status is not None and not payload.archive:
                           s = payload.status
                           if s == "done":
      -                        ok = kanban_db.complete_task(conn, tid)
      +                        ok = kanban_db.complete_task(
      +                            conn, tid,
      +                            result=payload.result,
      +                            summary=payload.summary,
      +                            metadata=payload.metadata,
      +                        )
                           elif s == "blocked":
                               ok = kanban_db.block_task(conn, tid)
                           elif s == "ready":
    • tests/hermes_cli/test_kanban_core_functionality.py
      @@ -1389,6 +1389,48 @@ def test_cli_complete_with_summary_and_metadata(kanban_home):
           assert r.metadata == {"files": 3}
       
       
      +def test_cli_edit_backfills_result_on_done_task(kanban_home):
      +    conn = kb.connect()
      +    try:
      +        tid = kb.create_task(conn, title="x", assignee="worker")
      +        kb.complete_task(conn, tid)
      +    finally:
      +        conn.close()
      +
      +    meta = '{"source": "dashboard-recovery"}'
      +    out = run_slash(
      +        "edit " + tid
      +        + " --result \"DECIDED: done\""
      +        + " --summary \"DECIDED: done\""
      +        + " --metadata '" + meta + "'"
      +    )
      +
      +    assert "Edited" in out
      +    conn = kb.connect()
      +    try:
      +        task = kb.get_task(conn, tid)
      +        run = kb.latest_run(conn, tid)
      +        events = kb.list_events(conn, tid)
      +    finally:
      +        conn.close()
      +    assert task.result == "DECIDED: done"
      +    assert run.summary == "DECIDED: done"
      +    assert run.metadata == {"source": "dashboard-recovery"}
      +    assert events[-1].kind == "edited"
      +
      +
      +def test_cli_edit_rejects_non_done_task(kanban_home):
      +    conn = kb.connect()
      +    try:
      +        tid = kb.create_task(conn, title="x", assignee="worker")
      +    finally:
      +        conn.close()
      +
      +    out = run_slash(f"edit {tid} --result nope")
      +
      +    assert "not done" in out
      +
      +
       def test_cli_complete_bad_metadata_exits_nonzero(kanban_home):
           conn = kb.connect()
           try:
    • tests/plugins/test_kanban_dashboard_plugin.py
      @@ -561,6 +561,49 @@ def test_bulk_status_ready(client):
           assert {a["id"], b["id"], c2["id"]}.issubset(ids)
       
       
      +def test_bulk_status_done_forwards_completion_summary(client):
      +    a = client.post("/api/plugins/kanban/tasks", json={"title": "a"}).json()["task"]
      +    b = client.post("/api/plugins/kanban/tasks", json={"title": "b"}).json()["task"]
      +
      +    r = client.post(
      +        "/api/plugins/kanban/tasks/bulk",
      +        json={
      +            "ids": [a["id"], b["id"]],
      +            "status": "done",
      +            "result": "DECIDED: ship it",
      +            "summary": "DECIDED: ship it",
      +            "metadata": {"source": "dashboard"},
      +        },
      +    )
      +
      +    assert r.status_code == 200
      +    assert all(r["ok"] for r in r.json()["results"])
      +    conn = kb.connect()
      +    try:
      +        for tid in (a["id"], b["id"]):
      +            task = kb.get_task(conn, tid)
      +            run = kb.latest_run(conn, tid)
      +            assert task.status == "done"
      +            assert task.result == "DECIDED: ship it"
      +            assert run.summary == "DECIDED: ship it"
      +            assert run.metadata == {"source": "dashboard"}
      +    finally:
      +        conn.close()
      +
      +
      +def test_dashboard_done_actions_prompt_for_completion_summary():
      +    repo_root = Path(__file__).resolve().parents[2]
      +    bundle = (
      +        repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js"
      +    ).read_text()
      +
      +    assert "withCompletionSummary" in bundle
      +    assert "Completion summary" in bundle
      +    assert "result: summary" in bundle
      +    assert "body: JSON.stringify(patch)" in bundle
      +    assert "body: JSON.stringify(finalPatch)" in bundle
      +
      +
       def test_bulk_archive(client):
           a = client.post("/api/plugins/kanban/tasks", json={"title": "a"}).json()["task"]
           b = client.post("/api/plugins/kanban/tasks", json={"title": "b"}).json()["task"]

1 PR closed without merging

From the close comments: merged via another change 1

  • #20016 fix(kanban): preserve dashboard completion summaries · merged via another change → #20195
Closed issues · 1
  • Closed#20008kanban: dashboard Complete button bypasses summary modal; no CLI recovery for null result
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Merged PRs
#20195
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
5 May 2026

For your agent

The same facts, typed.