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