# NousResearch/hermes-agent · Kanban dashboard completion summary preservation fix

## Open now

- 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.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #20195. Verify it addresses your case.
- **Recommended:** read PR #20195 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-20195`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member 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.

## Closed / duplicate issues

- #20008: kanban: dashboard Complete button bypasses summary modal; no CLI recovery for null result

## Prior & competing PRs

- **#20195: fix(kanban): preserve dashboard completion summaries + add kanban edit (salvages #20016)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - 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`
  - fix hunks:
  - `hermes_cli/kanban.py`

    ```diff
    @@ -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)")
    ```

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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";
     
    ```

    ```diff
    @@ -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;
    ```

    ```diff
    @@ -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();
    ```

    ```diff
    @@ -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" },
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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")
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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"]
    ```
- **#20016: fix(kanban): preserve dashboard completion summaries**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#20195`

## Fix status

- Merged to main in PR #20195 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-05`.
