{"repo":"NousResearch/hermes-agent","public_id":"38367","display_label":"Kanban goal-mode judge gate fixes and feature","summary":"These issues and PRs concern adding and correcting an external judge gate that controls task completion in kanban goal-mode, with recurring focus on a tuple-unpacking bug where judge_goal()'s 4-tuple return value was being unpacked as 3 values, causing crashes or fail-open behavior that allowed workers to bypass the judge.","subsystem":"cron","counts":{"members":10,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":4,"merged_prs":1,"attempt_prs":5},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":55408},"resolution":{"state":"partially_resolved","by":"pr-55408"},"freshness":{"state":"verified","last_verified_at":"2026-06-30T05:20:20+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.15733333333333333},"fix_edges":[{"issue_number":38367,"pr_number":55408,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":38367,"title":"[Bug] goal_mode does not prevent premature kanban_complete — worker tool call bypasses judge","url":null,"state":"closed"},{"kind":"issue","number":46706,"title":"[Feature]: Add a reviewed Kanban goal mode where workers cannot bypass the external judge","url":null,"state":"closed"},{"kind":"issue","number":59762,"title":"kanban_complete` goal-mode judge gate never rejects: 3-target unpack of 4-tuple `judge_goal()` raises `ValueError`, caught by the fail-open handler","url":null,"state":"closed"},{"kind":"issue","number":61490,"title":"Bug: kanban_tools.py L605 — judge_goal 3-value unpack crashes orchestrator in --goal mode","url":null,"state":"closed"},{"kind":"pr","number":38388,"title":"fix(kanban): gate goal_mode task completion with auxiliary judge","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#55408","number":55408}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":55408,"title":"fix(kanban): gate goal_mode task completion with auxiliary judge","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tools/test_kanban_tools.py","tools/kanban_tools.py"],"fix_hunks":[{"file":"tests/tools/test_kanban_tools.py","hunks":["@@ -589,11 +589,108 @@ def test_complete_retry_with_corrected_created_cards_succeeds(worker_env):\n     }))\n     assert ok.get(\"ok\") is True\n \n+\n+def test_complete_goal_mode_rejected_by_judge(monkeypatch, tmp_path):\n+    \"\"\"Goal-mode tasks must pass the auxiliary judge before completion.\n+    Regression for #38367: workers bypassing the judge via early kanban_complete.\"\"\"\n+    from pathlib import Path as _Path\n+    from hermes_cli import kanban_db as kb\n+    from tools import kanban_tools as kt\n+\n+    # Set up isolated HERMES_HOME\n+    home = tmp_path / \".hermes\"\n+    home.mkdir()\n+    monkeypatch.setenv(\"HERMES_HOME\", str(home))\n+    monkeypatch.setenv(\"HERMES_PROFILE\", \"test-worker\")\n+    monkeypatch.delenv(\"HERMES_SESSION_ID\", raising=False)\n+    monkeypatch.setattr(_Path, \"home\", lambda: tmp_path)\n+\n+    kb._INITIALIZED_PATHS.clear()\n+    kb.init_db()\n     conn = kb.connect()\n     try:\n-        assert kb.get_task(conn, worker_env).status == \"done\"\n+        goal_task_id = kb.create_task(\n+            conn, title=\"goal-mode-test\", assignee=\"test-worker\",\n+            body=\"Must achieve X with verified evidence.\", goal_mode=True\n+        )\n+        kb.claim_task(conn, goal_task_id)\n+    finally:\n+        conn.close()\n+    monkeypatch.setenv(\"HERMES_KANBAN_TASK\", goal_task_id)\n+\n+    # Mock the judge to reject the completion. The gate only runs when a\n+    # judge is reachable, so force the availability probe True as well.\n+    def mock_judge_goal(goal, last_response, *, timeout=30.0, subgoals=None):\n+        return \"continue\", \"missing verification evidence\", False\n+\n+    monkeypatch.setattr(\"tools.kanban_tools.judge_goal\", mock_judge_goal)\n+    monkeypatch.setattr(\"tools.kanban_tools._goal_judge_available\", lambda: True)\n+\n+    # Attempt to complete should be rejected\n+    out = kt._handle_complete({\"summary\": \"I did some stuff but not X\"})\n+    d = json.loads(out)\n+    assert \"error\" in d\n+    assert \"Goal completion rejected by judge\" in d[\"error\"]\n+    assert \"missing verification evidence\" in d[\"error\"]\n+    assert f\"parents=[{goal_task_id}]\" in d[\"error\"]\n+\n+    # Verify the task is NOT completed in the DB\n+    conn2 = kb.connect()\n+    try:\n+        task = kb.get_task(conn2, goal_task_id)\n+        assert task.status == \"running\"  # Should still be running, not done\n+    finally:\n+        conn2.close()\n+\n+\n+def test_complete_goal_mode_allows_when_judge_unavailable(monkeypatch, tmp_path):\n+    \"\"\"Fail-open: an unreachable judge must not wedge a goal_mode worker.\n+\n+    judge_goal returns a \"continue\" verdict when no auxiliary model is\n+    configured, which is indistinguishable from a real \"not done\" judgment.\n+    The gate probes availability first, so completion proceeds rather than\n+    being rejected forever when no judge can be reached.\"\"\"\n+    from pathlib import Path as _Path\n+    from hermes_cli import kanban_db as kb\n+    from tools import kanban_tools as kt\n+\n+    home = tmp_path / \".hermes\"\n+    home.mkdir()\n+    monkeypatch.setenv(\"HERMES_HOME\", str(home))\n+    monkeypatch.setenv(\"HERMES_PROFILE\", \"test-worker\")\n+    monkeypatch.delenv(\"HERMES_SESSION_ID\", raising=False)\n+    monkeypatch.setattr(_Path, \"home\", lambda: tmp_path)\n+\n+    kb._INITIALIZED_PATHS.clear()\n+    kb.init_db()\n+    conn = kb.connect()\n+    try:\n+        goal_task_id = kb.create_task(\n+            conn, title=\"goal-mode-test\", assignee=\"test-worker\",\n+            body=\"Must achieve X with verified evidence.\", goal_mode=True\n+        )\n+        kb.claim_task(conn, goal_task_id)\n     finally:\n         conn.close()\n+    monkeypatch.setenv(\"HERMES_KANBAN_TASK\", goal_task_id)\n+\n+    # No judge reachable. judge_goal must not even be consulted; if it were,\n+    # this stub would reject — so reaching \"done\" proves the probe short-circuit.\n+    def fail_if_called(goal, last_response, *, timeout=30.0, subgoals=None):\n+        raise AssertionError(\"judge_goal must not run when no judge is available\")\n+\n+    monkeypatch.setattr(\"tools.kanban_tools.judge_goal\", fail_if_called)\n+    monkeypatch.setattr(\"tools.kanban_tools._goal_judge_available\", lambda: False)\n+\n+    out = kt._handle_complete({\"summary\": \"done enough\"})\n+    d = json.loads(out)\n+    assert d.get(\"ok\") is True\n+\n+    conn2 = kb.connect()\n+    try:\n+        assert kb.get_task(conn2, goal_task_id).status == \"done\"\n+    finally:\n+        conn2.close()\n \n \n def test_block_happy_path(worker_env):"]},{"file":"tools/kanban_tools.py","hunks":["@@ -34,6 +34,7 @@\n from typing import Any, Optional\n \n from agent.redact import redact_sensitive_text\n+from hermes_cli.goals import judge_goal\n from tools.registry import registry, tool_error\n from hermes_cli.config import cfg_get, load_config\n ","@@ -178,6 +179,27 @@ def _connect(board: Optional[str] = None):\n     return kb, kb.connect(board=board)\n \n \n+def _goal_judge_available() -> bool:\n+    \"\"\"True when an auxiliary client is configured for the goal judge.\n+\n+    ``judge_goal`` is fail-open at the source: when no auxiliary model can\n+    be reached it returns a ``\"continue\"`` verdict that is indistinguishable\n+    from a real \"not done yet\" judgment. The completion gate must not treat\n+    that as a rejection, or an unconfigured/degraded auxiliary model would\n+    wedge every ``goal_mode`` worker (it could never close its own task).\n+\n+    So we probe availability first and only enforce the gate when a judge is\n+    actually reachable. This mirrors the same client lookup ``judge_goal``\n+    performs internally.\n+    \"\"\"\n+    try:\n+        from agent.auxiliary_client import get_text_auxiliary_client\n+        client, model = get_text_auxiliary_client(\"goal_judge\")\n+    except Exception:\n+        return False\n+    return client is not None and bool(model)\n+\n+\n # ---------------------------------------------------------------------------\n # Runtime-activity → board-heartbeat bridge (#31752)\n # ---------------------------------------------------------------------------","@@ -567,6 +589,37 @@ def _handle_complete(args: dict, **kw) -> str:\n     try:\n         kb, conn = _connect(board=board)\n         try:\n+            # Goal-mode pre-completion judge gate (Issue #38367).\n+            # Prevent workers from bypassing the auxiliary judge by\n+            # calling kanban_complete before acceptance criteria are met.\n+            # Only enforce when a judge is actually reachable — see\n+            # _goal_judge_available for why an unavailable judge fails open.\n+            task = kb.get_task(conn, tid)\n+            if task and task.goal_mode and _goal_judge_available():\n+                verdict = \"done\"\n+                reason = \"\"\n+                try:\n+                    verdict, reason, _ = judge_goal(\n+                        goal=f\"{task.title}\\n\\n{task.body or ''}\".strip(),\n+                        last_response=(summary or result or \"\").strip(),\n+                    )\n+                except Exception as judge_exc:\n+                    # Defensive: judge_goal swallows its own errors, but if\n+                    # it ever raises, fail open rather than wedge the worker.\n+                    logger.warning(\n+                        \"goal judge check failed, allowing completion: %s\",\n+                        judge_exc,\n+                        exc_info=True,\n+                    )\n+                if verdict != \"done\":\n+                    return tool_error(\n+                        f\"Goal completion rejected by judge: {reason}. \"\n+                        f\"To proceed, either: (1) provide explicit acceptance \"\n+                        f\"evidence in your summary matching the task's criteria, \"\n+                        f\"or (2) create continuation tasks with parents=[{tid}] \"\n+                        f\"and keep this task alive.\"\n+                    )\n+\n             try:\n                 ok = kb.complete_task(\n                     conn, tid,\n"]}]},{"kind":"pr","number":59284,"title":"fix(kanban): goal-gate judge unpack matches judge_goal's 4-tuple (completes #38367)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#57276","number":57276},{"kind":"pr","value":"#57276","number":57276}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":59849,"title":"fix(tools): honor goal judge verdict in kanban_complete gate","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#57276","number":57276},{"kind":"pr","value":"#58066","number":58066}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":61498,"title":"fix(tools): unpack all 4 judge_goal() return values in kanban complete gate","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#57276","number":57276},{"kind":"pr","value":"#61500","number":61500},{"kind":"pr","value":"#57276","number":57276},{"kind":"pr","value":"#61490","number":61490},{"kind":"pr","value":"#57276","number":57276}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":61510,"title":"fix(kanban): unpack goal judge wait result","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#57276","number":57276}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true}],"trust":{"default_tier":"verified","includes_suggested":false}}