{"repo":"NousResearch/hermes-agent","public_id":"28822","display_label":"Kanban promote verb for todo→ready recovery","summary":"These members all concern adding a 'promote' command or verb to the Kanban CLI that manually moves items from todo to ready state, intended as a recovery or salvage mechanism. They appear to represent iterations or related proposals for the same feature, including bulk operation support via --ids.","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":31334,"merged_at":"2026-05-24T06:10:37+00:00"},"resolution":{"state":"resolved","by":"pr-31334"},"freshness":{"state":"verified","last_verified_at":"2026-05-24T06:10:37+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":28822,"pr_number":31334,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-24T06:10:37+00:00"}],"members":[{"kind":"issue","number":28822,"title":"Add first-class Kanban promote / force-claim CLI for todo→ready recovery","url":null,"state":"closed"},{"kind":"pr","number":29464,"title":"feat(cli): kanban promote verb for manual todo->ready recovery","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"d46adad22","number":0},{"kind":"commit","value":"b207dc28b","number":0},{"kind":"pr","value":"#31334","number":31334},{"kind":"pr","value":"#28822","number":28822}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":31334,"title":"feat(kanban): promote verb for manual todo->ready recovery (salvage #29464 + bulk --ids)","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","scripts/release.py","tests/hermes_cli/test_kanban_promote.py"],"fix_hunks":[{"file":"hermes_cli/kanban.py","hunks":["@@ -550,6 +550,39 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu\n     p_unblock = sub.add_parser(\"unblock\", help=\"Return one or more blocked/scheduled tasks to ready\")\n     p_unblock.add_argument(\"task_ids\", nargs=\"+\")\n \n+    p_promote = sub.add_parser(\n+        \"promote\",\n+        help=\"Manually move one or more todo/blocked tasks to ready (recovery path)\",\n+    )\n+    p_promote.add_argument(\"task_id\")\n+    p_promote.add_argument(\n+        \"reason\",\n+        nargs=\"*\",\n+        help=\"Audit-trail reason (recorded on the task_events row)\",\n+    )\n+    p_promote.add_argument(\n+        \"--ids\",\n+        nargs=\"+\",\n+        default=None,\n+        help=\"Additional task ids to promote with the same reason (bulk mode)\",\n+    )\n+    p_promote.add_argument(\n+        \"--force\",\n+        action=\"store_true\",\n+        help=\"Promote even if parent dependencies are not yet done/archived\",\n+    )\n+    p_promote.add_argument(\n+        \"--dry-run\",\n+        action=\"store_true\",\n+        help=\"Validate the promotion without mutating state\",\n+    )\n+    p_promote.add_argument(\n+        \"--json\",\n+        dest=\"json\",\n+        action=\"store_true\",\n+        help=\"Emit machine-readable JSON result\",\n+    )\n+\n     p_archive = sub.add_parser(\"archive\", help=\"Archive one or more tasks\")\n     p_archive.add_argument(\"task_ids\", nargs=\"*\",\n                            help=\"Task ids to archive (default mode)\")","@@ -899,6 +932,7 @@ def _restore_board_env() -> None:\n         \"block\":    _cmd_block,\n         \"schedule\": _cmd_schedule,\n         \"unblock\":  _cmd_unblock,\n+        \"promote\":  _cmd_promote,\n         \"archive\":  _cmd_archive,\n         \"tail\":     _cmd_tail,\n         \"dispatch\": _cmd_dispatch,","@@ -1955,6 +1989,57 @@ def _cmd_unblock(args: argparse.Namespace) -> int:\n     return 0 if not failed else 1\n \n \n+def _cmd_promote(args: argparse.Namespace) -> int:\n+    reason = \" \".join(args.reason).strip() if args.reason else None\n+    author = _profile_author()\n+    as_json = getattr(args, \"json\", False)\n+    extra_ids = list(getattr(args, \"ids\", None) or [])\n+    # Dedupe while preserving order; positional task_id always first.\n+    ids: list[str] = []\n+    seen: set[str] = set()\n+    for tid in [args.task_id, *extra_ids]:\n+        if tid not in seen:\n+            ids.append(tid)\n+            seen.add(tid)\n+\n+    results: list[dict[str, object]] = []\n+    with kb.connect() as conn:\n+        for tid in ids:\n+            ok, err = kb.promote_task(\n+                conn,\n+                tid,\n+                actor=author,\n+                reason=reason,\n+                force=bool(args.force),\n+                dry_run=bool(args.dry_run),\n+            )\n+            results.append({\n+                \"task_id\": tid,\n+                \"promoted\": ok,\n+                \"dry_run\": bool(args.dry_run),\n+                \"forced\": bool(args.force),\n+                \"reason\": reason,\n+                \"error\": err,\n+            })\n+\n+    failed = [r for r in results if not r[\"promoted\"]]\n+    if as_json:\n+        # Single-id stays a flat object for back-compat; bulk emits a list.\n+        payload: object = results[0] if len(results) == 1 else results\n+        print(json.dumps(payload, indent=2, ensure_ascii=False))\n+        return 0 if not failed else 1\n+\n+    tag = \" (dry)\" if args.dry_run else \"\"\n+    label = \"Would promote\" if args.dry_run else \"Promoted\"\n+    for r in results:\n+        if r[\"promoted\"]:\n+            suffix = f\": {reason}\" if reason else \"\"\n+            print(f\"{label} {r['task_id']} -> ready{tag}{suffix}\")\n+        else:\n+            print(f\"cannot promote {r['task_id']}: {r['error']}\", file=sys.stderr)\n+    return 0 if not failed else 1\n+\n+\n def _cmd_archive(args: argparse.Namespace) -> int:\n     ids = list(args.task_ids or [])\n     purge_ids = list(getattr(args, \"purge_ids\", None) or [])"]},{"file":"hermes_cli/kanban_db.py","hunks":["@@ -3303,6 +3303,77 @@ def block_task(\n         return True\n \n \n+\n+def promote_task(\n+    conn: sqlite3.Connection,\n+    task_id: str,\n+    *,\n+    actor: str,\n+    reason: Optional[str] = None,\n+    force: bool = False,\n+    dry_run: bool = False,\n+) -> tuple[bool, Optional[str]]:\n+    \"\"\"Manually promote a `todo` or `blocked` task to `ready`.\n+\n+    Mirrors the automatic promotion done by ``recompute_ready`` but\n+    drives it from a deliberate operator action with an audit-trail\n+    entry. Refuses to promote if any parent dep is not in a terminal\n+    state (`done`/`archived`) unless ``force=True``. Does NOT change\n+    assignee or claim state. Returns ``(True, None)`` on success and\n+    ``(False, reason)`` if refused. ``dry_run=True`` validates the\n+    promotion would succeed without mutating state.\n+    \"\"\"\n+    row = conn.execute(\n+        \"SELECT status FROM tasks WHERE id = ?\", (task_id,)\n+    ).fetchone()\n+    if row is None:\n+        return False, f\"task {task_id} not found\"\n+\n+    cur_status = row[\"status\"]\n+    if cur_status not in (\"todo\", \"blocked\"):\n+        return False, (\n+            f\"task {task_id} is {cur_status!r}; promote only applies to \"\n+            f\"'todo' or 'blocked'\"\n+        )\n+\n+    if not force:\n+        parents = conn.execute(\n+            \"SELECT t.id, t.status FROM tasks t \"\n+            \"JOIN task_links l ON l.parent_id = t.id \"\n+            \"WHERE l.child_id = ?\",\n+            (task_id,),\n+        ).fetchall()\n+        unsatisfied = [\n+            p[\"id\"] for p in parents\n+            if p[\"status\"] not in (\"done\", \"archived\")\n+        ]\n+        if unsatisfied:\n+            return False, (\n+                f\"unsatisfied parent dependencies: \"\n+                f\"{', '.join(unsatisfied)} (use --force to override)\"\n+            )\n+\n+    if dry_run:\n+        return True, None\n+\n+    with write_txn(conn):\n+        upd = conn.execute(\n+            \"UPDATE tasks SET status = 'ready' \"\n+            \"WHERE id = ? AND status IN ('todo', 'blocked')\",\n+            (task_id,),\n+        )\n+        if upd.rowcount != 1:\n+            return False, f\"task {task_id} status changed during promotion\"\n+        _append_event(\n+            conn,\n+            task_id,\n+            \"promoted_manual\",\n+            {\"actor\": actor, \"reason\": reason, \"forced\": force},\n+        )\n+\n+    return True, None\n+\n+\n def unblock_task(conn: sqlite3.Connection, task_id: str) -> bool:\n     \"\"\"Transition ``blocked``/``scheduled`` -> ready or todo.\n "]},{"file":"scripts/release.py","hunks":["@@ -77,6 +77,7 @@\n     \"[redacted:email]\": \"olisikh\",\n     \"[redacted:email]\": \"JithendraNara\",\n     \"[redacted:email]\": \"outdoorsea\",\n+    \"54763683+thedavidmurray@users.noreply.github.com\": \"thedavidmurray\",\n     \"[redacted:email]\": \"leoneparise\",\n     \"[redacted:email]\": \"mrshu\",\n     \"[redacted:email]\": \"am423\","]},{"file":"tests/hermes_cli/test_kanban_promote.py","hunks":["@@ -0,0 +1,254 @@\n+\"\"\"Tests for the kanban `promote` verb (issue #28822).\n+\n+The realistic bug scenario from #28822 is: a child task ends up in\n+``todo`` with all its parents already ``done`` (because the\n+auto-promote daemon hasn't run, or a manual close raced it).\n+Direct-SQL setup is used to construct that state deterministically.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import argparse\n+import json\n+from pathlib import Path\n+\n+import pytest\n+\n+from hermes_cli import kanban as kb_cli\n+from hermes_cli import kanban_db as kb\n+\n+\n[redacted:email]\n+def kanban_home(tmp_path, monkeypatch):\n+    home = tmp_path / \".hermes\"\n+    home.mkdir()\n+    monkeypatch.setenv(\"HERMES_HOME\", str(home))\n+    monkeypatch.setattr(Path, \"home\", lambda: tmp_path)\n+    db_path = kb.kanban_db_path(board=\"default\")\n+    kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))\n+    kb.init_db()\n+    return home\n+\n+\n[redacted:email]\n+def conn(kanban_home):\n+    with kb.connect() as c:\n+        yield c\n+\n+\n+def _stuck_todo(conn, *, parents_done=True, n_parents=1):\n+    \"\"\"Build the #28822 scenario: child in 'todo' whose parents may\n+    have closed as 'done' without the auto-promote logic firing.\n+    \"\"\"\n+    parent_ids = [\n+        kb.create_task(conn, title=f\"parent{i}\", assignee=\"setup\")\n+        for i in range(n_parents)\n+    ]\n+    child_id = kb.create_task(\n+        conn, title=\"child\", parents=parent_ids, assignee=\"setup\"\n+    )\n+    assert kb.get_task(conn, child_id).status == \"todo\"\n+    if parents_done:\n+        for pid in parent_ids:\n+            conn.execute(\n+                \"UPDATE tasks SET status='done' WHERE id=?\", (pid,)\n+            )\n+    return child_id, parent_ids\n+\n+\n+def test_promote_stuck_todo_succeeds(conn):\n+    child, _ = _stuck_todo(conn, parents_done=True)\n+    ok, err = kb.promote_task(conn, child, actor=\"tester\")\n+    assert ok and err is None\n+    assert kb.get_task(conn, child).status == \"ready\"\n+\n+\n+def test_promote_refuses_when_parent_not_done(conn):\n+    child, parents = _stuck_todo(conn, parents_done=False)\n+    ok, err = kb.promote_task(conn, child, actor=\"tester\")\n+    assert ok is False\n+    assert err is not None and \"unsatisfied parent dependencies\" in err\n+    assert parents[0] in err\n+    assert kb.get_task(conn, child).status == \"todo\"\n+\n+\n+def test_promote_with_force_bypasses_dependency_check(conn):\n+    child, _ = _stuck_todo(conn, parents_done=False)\n+    ok, err = kb.promote_task(\n+        conn, child, actor=\"tester\", reason=\"recovery\", force=True\n+    )\n+    assert ok and err is None\n+    assert kb.get_task(conn, child).status == \"ready\"\n+\n+\n+def test_promote_emits_audit_event(conn):\n+    child, _ = _stuck_todo(conn, parents_done=True)\n+    kb.promote_task(conn, child, actor=\"tester\", reason=\"manual recovery\")\n+    ev = conn.execute(\n+        \"SELECT kind, payload FROM task_events \"\n+        \"WHERE task_id = ? AND kind = 'promoted_manual'\",\n+        (child,),\n+    ).fetchone()\n+    assert ev is not None\n+    payload = json.loads(ev[\"payload\"])\n+    assert payload[\"actor\"] == \"tester\"\n+    assert payload[\"reason\"] == \"manual recovery\"\n+    assert payload[\"forced\"] is False\n+\n+\n+def test_promote_force_records_forced_flag(conn):\n+    child, _ = _stuck_todo(conn, parents_done=False)\n+    kb.promote_task(conn, child, actor=\"tester\", force=True, reason=\"r\")\n+    ev = conn.execute(\n+        \"SELECT payload FROM task_events \"\n+        \"WHERE task_id = ? AND kind = 'promoted_manual'\",\n+        (child,),\n+    ).fetchone()\n+    assert json.loads(ev[\"payload\"])[\"forced\"] is True\n+\n+\n+def test_promote_does_not_change_assignee(conn):\n+    child, _ = _stuck_todo(conn, parents_done=True)\n+    before = kb.get_task(conn, child).assignee\n+    kb.promote_task(conn, child, actor=\"someone_else\")\n+    after = kb.get_task(conn, child).assignee\n+    assert before == after\n+\n+\n+def test_promote_dry_run_does_not_mutate(conn):\n+    child, _ = _stuck_todo(conn, parents_done=True)\n+    ok, err = kb.promote_task(conn, child, actor=\"tester\", dry_run=True)\n+    assert ok and err is None\n+    assert kb.get_task(conn, child).status == \"todo\"\n+    n = conn.execute(\n+        \"SELECT COUNT(*) AS n FROM task_events \"\n+        \"WHERE task_id = ? AND kind = 'promoted_manual'\",\n+        (child,),\n+    ).fetchone()[\"n\"]\n+    assert n == 0\n+\n+\n+def test_promote_dry_run_reports_dependency_failure(conn):\n+    child, _ = _stuck_todo(conn, parents_done=False)\n+    ok, err = kb.promote_task(conn, child, actor=\"tester\", dry_run=True)\n+    assert ok is False\n+    assert err is not None and \"unsatisfied\" in err\n+\n+\n+def test_promote_rejects_non_todo_status(conn):\n+    tid = kb.create_task(conn, title=\"standalone\")\n+    assert kb.get_task(conn, tid).status == \"ready\"\n+    ok, err = kb.promote_task(conn, tid, actor=\"tester\")\n+    assert ok is False\n+    assert \"'ready'\" in err and \"promote only applies\" in err\n+\n+\n+def test_promote_rejects_unknown_task(conn):\n+    ok, err = kb.promote_task(conn, \"t_doesnotexist\", actor=\"tester\")\n+    assert ok is False\n+    assert err is not None and \"not found\" in err\n+\n+\n+def test_promote_blocked_task_works(conn):\n+    tid = kb.create_task(conn, title=\"t\")\n+    conn.execute(\"UPDATE tasks SET status='blocked' WHERE id=?\", (tid,))\n+    ok, err = kb.promote_task(\n+        conn, tid, actor=\"tester\", reason=\"ready now\"\n+    )\n+    assert ok and err is None\n+    assert kb.get_task(conn, tid).status == \"ready\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# CLI `_cmd_promote` — bulk via `--ids` (the issue's anti-respawn use case:\n+# promote all children of a closed parent in one command).\n+# ---------------------------------------------------------------------------\n+\n+\n+def _promote_ns(task_id, *, ids=None, reason=None, force=False,\n+                dry_run=False, as_json=False):\n+    return argparse.Namespace(\n+        task_id=task_id,\n+        reason=list(reason or []),\n+        ids=list(ids or []) or None,\n+        force=force,\n+        dry_run=dry_run,\n+        json=as_json,\n+    )\n+\n+\n+def test_cli_promote_bulk_ids_promotes_all(kanban_home, capsys):\n+    with kb.connect() as conn:\n+        parent = kb.create_task(conn, title=\"parent\")\n+        children = [\n+            kb.create_task(conn, title=f\"c{i}\", parents=[parent])\n+            for i in range(3)\n+        ]\n+        conn.execute(\"UPDATE tasks SET status='done' WHERE id=?\", (parent,))\n+    rc = kb_cli._cmd_promote(_promote_ns(children[0], ids=children[1:]))\n+    assert rc == 0\n+    out = capsys.readouterr().out\n+    for c in children:\n+        assert c in out\n+    with kb.connect() as conn:\n+        for c in children:\n+            assert kb.get_task(conn, c).status == \"ready\"\n+\n+\n+def test_cli_promote_bulk_partial_failure_exits_1(kanban_home, capsys):\n+    \"\"\"Bulk with one bad id: good ones still promote, exit code reflects failure.\"\"\"\n+    with kb.connect() as conn:\n+        parent = kb.create_task(conn, title=\"parent\")\n+        good = kb.create_task(conn, title=\"good\", parents=[parent])\n+        conn.execute(\"UPDATE tasks SET status='done' WHERE id=?\", (parent,))\n+    rc = kb_cli._cmd_promote(_promote_ns(good, ids=[\"t_nope\"]))\n+    assert rc == 1\n+    captured = capsys.readouterr()\n+    assert good in captured.out  # good one promoted\n+    assert \"t_nope\" in captured.err and \"not found\" in captured.err\n+    with kb.connect() as conn:\n+        assert kb.get_task(conn, good).status == \"ready\"\n+\n+\n+def test_cli_promote_bulk_json_emits_list(kanban_home, capsys):\n+    with kb.connect() as conn:\n+        parent = kb.create_task(conn, title=\"parent\")\n+        a = kb.create_task(conn, title=\"a\", parents=[parent])\n+        b = kb.create_task(conn, title=\"b\", parents=[parent])\n+        conn.execute(\"UPDATE tasks SET status='done' WHERE id=?\", (parent,))\n+    rc = kb_cli._cmd_promote(_promote_ns(a, ids=[b], as_json=True))\n+    assert rc == 0\n+    payload = json.loads(capsys.readouterr().out)\n+    assert isinstance(payload, list) and len(payload) == 2\n+    assert {r[\"task_id\"] for r in payload} == {a, b}\n+    assert all(r[\"promoted\"] for r in payload)\n+\n+\n+def test_cli_promote_single_json_stays_flat_object(kanban_home, capsys):\n+    \"\"\"Back-compat: single-id JSON is still a flat object, not a list.\"\"\"\n+    with kb.connect() as conn:\n+        parent = kb.create_task(conn, title=\"parent\")\n+        child = kb.create_task(conn, title=\"c\", parents=[parent])\n+        conn.execute(\"UPDATE tasks SET status='done' WHERE id=?\", (parent,))\n+    rc = kb_cli._cmd_promote(_promote_ns(child, as_json=True))\n+    assert rc == 0\n+    payload = json.loads(capsys.readouterr().out)\n+    assert isinstance(payload, dict)\n+    assert payload[\"task_id\"] == child and payload[\"promoted\"] is True\n+\n+\n+def test_cli_promote_dedupes_duplicate_ids(kanban_home, capsys):\n+    \"\"\"Same id in positional + --ids must only attempt the promotion once.\"\"\"\n+    with kb.connect() as conn:\n+        parent = kb.create_task(conn, title=\"parent\")\n+        child = kb.create_task(conn, title=\"c\", parents=[parent])\n+        conn.execute(\"UPDATE tasks SET status='done' WHERE id=?\", (parent,))\n+    rc = kb_cli._cmd_promote(_promote_ns(child, ids=[child, child]))\n+    assert rc == 0\n+    with kb.connect() as conn:\n+        n = conn.execute(\n+            \"SELECT COUNT(*) AS n FROM task_events \"\n+            \"WHERE task_id = ? AND kind = 'promoted_manual'\",\n+            (child,),\n+        ).fetchone()[\"n\"]\n+    assert n == 1\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}