{"repo":"NousResearch/hermes-agent","public_id":"26986","display_label":"Persistent /goal loop control and completion logic","summary":"These issues and pull requests concern the /goal feature's loop behavior: when to stop, how agents signal completion, handling judge errors, and preventing over-continuation or spam.","subsystem":"agent","counts":{"members":12,"open_issues":2,"open_issues_distinct":2,"open_prs":0,"closed_issues":3,"merged_prs":2,"attempt_prs":5},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":67997},"resolution":{"state":"partially_resolved","by":"pr-67997,pr-79680"},"freshness":{"state":"verified","last_verified_at":"2026-08-06T05:32:39+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.12787878787878787},"fix_edges":[{"issue_number":27585,"pr_number":67997,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":34196,"pr_number":79680,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":26986,"title":"Keep persistent goals active when the response explicitly reports incomplete work","url":null,"state":"open"},{"kind":"issue","number":27585,"title":"[Bug]: /goal can spam repeated completion messages when goal_judge errors fail-open to continue","url":null,"state":"closed"},{"kind":"issue","number":29090,"title":"[Bug]: After executing /goal on the Windows version, the /goal trigger will not stop automatically even when the goal is completed; instead, it keeps triggering until the maximum limit is reached.","url":null,"state":"open"},{"kind":"issue","number":31718,"title":"Standing goals: agent should be able to signal completion and close the goal","url":null,"state":"closed"},{"kind":"issue","number":34196,"title":"/goal judge over-continues exploratory goals unless the assistant explicitly says the goal is complete","url":null,"state":"closed"},{"kind":"pr","number":27752,"title":"fix(goals): pause loop when judge errors and response is terminal (#27585)","url":null,"state":"closed","close_reason":{"category":"volume_quota","implication":"active"},"merged":false},{"kind":"pr","number":27760,"title":"fix(goals): add consecutive API error auto-pause to prevent goal spam (#27585)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#67997","number":67997},{"kind":"pr","value":"#54387","number":54387}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":29318,"title":"fix(goal): stop the /goal loop when the agent says it is done (#29090)","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":31766,"title":"feat(agent): add goal_manage tool for agent self-signaled goal completion","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"265bd59c1d9f8dea658f243b257d4fae3685af53","number":0},{"kind":"pr","value":"#31718","number":31718}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":34343,"title":"fix(goals): stop /goal over-continuation on exploratory + scope-narrow goals (#34196, #34197)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#107938","number":107938}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":67997,"title":"fix(goals): auto-pause goal loop on consecutive judge transport failures","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/goals.py","hermes_cli/kanban.py","tests/cli/test_cli_goal_interrupt.py","tests/gateway/test_goal_verdict_send.py","tests/hermes_cli/test_goals.py","tests/hermes_cli/test_kanban_goal_mode.py","tests/tools/test_kanban_tools.py","tools/kanban_tools.py"],"fix_hunks":[{"file":"hermes_cli/goals.py","hunks":["@@ -66,6 +66,11 @@\n # exhausted with every reply shaped like `judge returned empty response` or\n # `judge reply was not JSON`.\n DEFAULT_MAX_CONSECUTIVE_PARSE_FAILURES = 3\n+# Transport failures (API auth errors 401, timeouts, DNS, etc.) are also\n+# tracked and auto-pause the loop after this many consecutive failures.\n+# A broken/invalid API key returns 401 every call — the loop must not\n+# run until the turn budget, wasting every turn on an unreachable judge.\n+DEFAULT_MAX_CONSECUTIVE_TRANSPORT_FAILURES = 5\n \n \n CONTINUATION_PROMPT_TEMPLATE = (","@@ -399,6 +404,10 @@ class GoalState:\n     last_reason: Optional[str] = None\n     paused_reason: Optional[str] = None       # why we auto-paused (budget, etc.)\n     consecutive_parse_failures: int = 0       # judge-output parse failures in a row\n+    # Transport failures are API/auth/network errors.  Broken API keys return\n+    # 401 every call — track them separately so the loop auto-pauses instead\n+    # of burning every turn budget slot on an unreachable judge.\n+    consecutive_transport_failures: int = 0   # judge API/transport errors in a row\n     # User-added criteria appended mid-loop via the /subgoal command.\n     # When non-empty the judge prompt and continuation prompt both\n     # include them so the agent works toward them and the judge factors","@@ -457,6 +466,7 @@ def from_json(cls, raw: str) -> \"GoalState\":\n             last_reason=data.get(\"last_reason\"),\n             paused_reason=data.get(\"paused_reason\"),\n             consecutive_parse_failures=int(data.get(\"consecutive_parse_failures\", 0) or 0),\n+            consecutive_transport_failures=int(data.get(\"consecutive_transport_failures\", 0) or 0),\n             subgoals=subgoals,\n             waiting_on_pid=(int(data[\"waiting_on_pid\"]) if data.get(\"waiting_on_pid\") else None),\n             waiting_on_session=(str(data[\"waiting_on_session\"]) if data.get(\"waiting_on_session\") else None),","@@ -841,17 +851,23 @@ def judge_goal(\n     subgoals: Optional[List[str]] = None,\n     background_processes: Optional[List[Dict[str, Any]]] = None,\n     contract: Optional[GoalContract] = None,\n-) -> Tuple[str, str, bool, Optional[Dict[str, Any]]]:\n+) -> Tuple[str, str, bool, Optional[Dict[str, Any]], bool]:\n     \"\"\"Ask the auxiliary model whether the goal is satisfied.\n \n-    Returns ``(verdict, reason, parse_failed, wait_directive)`` where verdict\n+    Returns ``(verdict, reason, parse_failed, wait_directive, transport_failed)`` where verdict\n     is ``\"done\"``, ``\"continue\"``, ``\"wait\"``, or ``\"skipped\"`` (when the\n     judge couldn't be reached). ``wait_directive`` is set only for ``\"wait\"``\n     (``{\"pid\": int}`` or ``{\"seconds\": int}``); ``None`` otherwise.\n \n     ``parse_failed`` is True only when the judge call succeeded but its output\n     was unusable (empty or non-JSON). API/transport errors return False — they\n-    are transient and should fail-open silently. Callers use this flag to\n+    are transient and should fail-open silently.\n+\n+    ``transport_failed`` is True only when the judge couldn't reach the API at\n+    all (auth 401, timeout, DNS, connection error).  Repeated transport\n+    failures signal a permanent config problem (e.g. invalid API key).  Callers\n+    use this flag to auto-pause after N consecutive transport failures (see\n+    ``DEFAULT_MAX_CONSECUTIVE_TRANSPORT_FAILURES``). Callers use this flag to\n     auto-pause after N consecutive parse failures (see\n     ``DEFAULT_MAX_CONSECUTIVE_PARSE_FAILURES``).\n ","@@ -867,21 +883,23 @@ def judge_goal(\n     judge prompt; when none are set, behavior is identical to the original\n     free-form judge.\n \n-    This is deliberately fail-open: any error returns ``(\"continue\", ..., False, None)``\n-    so a broken judge doesn't wedge progress — the turn budget and the\n-    consecutive-parse-failures auto-pause are the backstops.\n+    This is deliberately fail-open: transport errors return ``(\"continue\", ..., ..., None, True)``\n+    — the ``transport_failed=True`` flag lets callers track and auto-pause after\n+    N consecutive transport failures (see\n+    ``DEFAULT_MAX_CONSECUTIVE_TRANSPORT_FAILURES``) so a permanently broken\n+    judge doesn't burn the entire turn budget.\n     \"\"\"\n     if not goal.strip():\n-        return \"skipped\", \"empty goal\", False, None\n+        return \"skipped\", \"empty goal\", False, None, False\n     if not last_response.strip():\n         # No substantive reply this turn — almost certainly not done yet.\n-        return \"continue\", \"empty response (nothing to evaluate)\", False, None\n+        return \"continue\", \"empty response (nothing to evaluate)\", False, None, False\n \n     try:\n         from agent.auxiliary_client import call_llm\n     except Exception as exc:\n         logger.debug(\"goal judge: auxiliary client import failed: %s\", exc)\n-        return \"continue\", \"auxiliary client unavailable\", False, None\n+        return \"continue\", \"auxiliary client unavailable\", False, None, False\n \n     # Build the prompt. Priority: contract > subgoals > plain. When both a\n     # contract and subgoals exist, the subgoals are appended into the","@@ -941,7 +959,7 @@ def judge_goal(\n         )\n     except Exception as exc:\n         logger.info(\"goal judge: API call failed (%s) — falling through to continue\", exc)\n-        return \"continue\", f\"judge error: {type(exc).__name__}\", False, None\n+        return \"continue\", f\"judge error: {type(exc).__name__}\", False, None, True\n \n     try:\n         raw = resp.choices[0].message.content or \"\"","@@ -954,7 +972,7 @@ def judge_goal(\n         verdict, _truncate(reason, 120),\n         f\" wait={wait_directive}\" if wait_directive else \"\",\n     )\n-    return verdict, reason, parse_failed, wait_directive\n+    return verdict, reason, parse_failed, wait_directive, False\n \n \n def gather_background_processes(task_id: Optional[str] = None) -> List[Dict[str, Any]]:","@@ -1425,7 +1443,7 @@ def evaluate_after_turn(\n         state.turns_used += 1\n         state.last_turn_at = time.time()\n \n-        verdict, reason, parse_failed, wait_directive = judge_goal(\n+        verdict, reason, parse_failed, wait_directive, transport_failed = judge_goal(\n             state.goal,\n             last_response,\n             subgoals=state.subgoals or None,","@@ -1443,6 +1461,16 @@ def evaluate_after_turn(\n         else:\n             state.consecutive_parse_failures = 0\n \n+        # Track consecutive transport failures separately — persistent API\n+        # errors (401 auth, DNS, timeout) signal a broken config, not\n+        # transient network flakiness.  Auto-pause after N consecutive\n+        # transport failures so a permanently broken judge doesn't burn\n+        # every turn budget slot on an unreachable API.\n+        if transport_failed:\n+            state.consecutive_transport_failures += 1\n+        else:\n+            state.consecutive_transport_failures = 0\n+\n         # WAIT verdict: the judge decided the agent is blocked on async work\n         # and re-poking now would be busy-work. Set the barrier and park —\n         # the turn we just counted stands (the judge call happened), but no","@@ -1480,6 +1508,36 @@ def evaluate_after_turn(\n                 \"message\": f\"✓ Goal achieved: {reason}\",\n             }\n \n+        # Auto-pause when the judge cannot reach the API at all N turns in a\n+        # row (401 auth, DNS failure, timeout).  Persistent transport failures\n+        # signal a broken configuration (e.g. invalid API key), not transient\n+        # flakiness.  Without this guard, a permanently broken judge burns\n+        # every turn budget slot on an unreachable API.\n+        if state.consecutive_transport_failures >= DEFAULT_MAX_CONSECUTIVE_TRANSPORT_FAILURES:\n+            state.status = \"paused\"\n+            state.paused_reason = (\n+                f\"judge API unreachable {state.consecutive_transport_failures} turns in a row \"\n+                f\"(check auxiliary.goal_judge provider/key in config.yaml)\"\n+            )\n+            save_goal(self.session_id, state)\n+            return {\n+                \"status\": \"paused\",\n+                \"should_continue\": False,\n+                \"continuation_prompt\": None,\n+                \"verdict\": \"continue\",\n+                \"reason\": reason,\n+                \"message\": (\n+                    f\"⏸ Goal paused — judge API returned errors \"\n+                    f\"({state.consecutive_transport_failures} turns). \"\n+                    \"Check the goal_judge provider/key in ~/.hermes/config.yaml:\\n\"\n+                    \"  auxiliary:\\n\"\n+                    \"    goal_judge:\\n\"\n+                    \"      provider: deepseek\\n\"\n+                    \"      model: deepseek-v4-flash\\n\"\n+                    \"Then /goal resume to continue.\"\n+                ),\n+            }\n+\n         # Auto-pause when the judge model can't produce the expected JSON\n         # verdict N turns in a row. Points the user at the goal_judge config\n         # so they can route this side task to a model that follows the","@@ -1679,7 +1737,7 @@ def _log(msg: str) -> None:\n         # The kanban worker loop has no wait-barrier concept (workers finish\n         # via kanban_complete / kanban_block, not by parking), so a WAIT\n         # verdict is treated as CONTINUE here.\n-        verdict, reason, _parse_failed, _wait = judge_goal(goal_text, last_response)\n+        verdict, reason, _parse_failed, _wait, _transport_failed = judge_goal(goal_text, last_response)\n         if verdict == \"wait\":\n             verdict = \"continue\"\n         _log(f\"kanban goal loop: turn {turns_used}/{max_turns} verdict={verdict} reason={_truncate(reason, 120)}\")"]},{"file":"hermes_cli/kanban.py","hunks":["@@ -2016,10 +2016,11 @@ def _cmd_complete(args: argparse.Namespace) -> int:\n                     reason = \"\"\n                     try:\n                         # judge_goal returns (verdict, reason, parse_failed,\n-                        # wait_directive) — see hermes_cli/goals.py. Unpacking\n-                        # fewer raises ValueError into the fail-open handler\n-                        # below, silently disabling the gate.\n-                        verdict, reason, _, _ = judge_goal(\n+                        # wait_directive, transport_failed) — see\n+                        # hermes_cli/goals.py. Unpacking fewer raises\n+                        # ValueError into the fail-open handler below,\n+                        # silently disabling the gate.\n+                        verdict, reason, _, _, _ = judge_goal(\n                             goal=f\"{task.title}\\n\\n{task.body or ''}\".strip(),\n                             last_response=(summary or args.result or \"\").strip(),\n                         )"]},{"file":"tests/cli/test_cli_goal_interrupt.py","hunks":["@@ -169,7 +169,7 @@ def test_clean_response_enqueues_continuation_when_judge_says_continue(\n         # Force the judge to say \"continue\" without touching the network.\n         with patch(\n             \"hermes_cli.goals.judge_goal\",\n-            return_value=(\"continue\", \"needs more steps\", False, None),\n+            return_value=(\"continue\", \"needs more steps\", False, None, False),\n         ):\n             cli._maybe_continue_goal_after_turn()\n ","@@ -189,7 +189,7 @@ def test_clean_response_marks_done_when_judge_says_done(self, hermes_home):\n \n         with patch(\n             \"hermes_cli.goals.judge_goal\",\n-            return_value=(\"done\", \"goal satisfied\", False, None),\n+            return_value=(\"done\", \"goal satisfied\", False, None, False),\n         ):\n             cli._maybe_continue_goal_after_turn()\n "]},{"file":"tests/gateway/test_goal_verdict_send.py","hunks":["@@ -107,7 +107,7 @@ async def test_goal_verdict_done_sent_via_adapter_send(hermes_home):\n     mgr = GoalManager(session_entry.session_id)\n     mgr.set(\"ship the feature\")\n \n-    with patch(\"hermes_cli.goals.judge_goal\", return_value=(\"done\", \"the feature shipped\", False, None)):\n+    with patch(\"hermes_cli.goals.judge_goal\", return_value=(\"done\", \"the feature shipped\", False, None, False)):\n         await runner._post_turn_goal_continuation(\n             session_entry=session_entry,\n             source=src,","@@ -136,7 +136,7 @@ async def test_goal_verdict_continue_enqueues_continuation(hermes_home):\n     mgr = GoalManager(session_entry.session_id)\n     mgr.set(\"polish the docs\")\n \n-    with patch(\"hermes_cli.goals.judge_goal\", return_value=(\"continue\", \"still needs work\", False, None)):\n+    with patch(\"hermes_cli.goals.judge_goal\", return_value=(\"continue\", \"still needs work\", False, None, False)):\n         await runner._post_turn_goal_continuation(\n             session_entry=session_entry,\n             source=src,","@@ -164,7 +164,7 @@ async def test_goal_verdict_budget_exhausted_sends_pause(hermes_home):\n     state.turns_used = 2\n     save_goal(session_entry.session_id, state)\n \n-    with patch(\"hermes_cli.goals.judge_goal\", return_value=(\"continue\", \"keep going\", False, None)):\n+    with patch(\"hermes_cli.goals.judge_goal\", return_value=(\"continue\", \"keep going\", False, None, False)):\n         await runner._post_turn_goal_continuation(\n             session_entry=session_entry,\n             source=src,","@@ -211,7 +211,7 @@ def __init__(self):\n \n     runner.adapters[Platform.TELEGRAM] = _NoSendAdapter()\n \n-    with patch(\"hermes_cli.goals.judge_goal\", return_value=(\"done\", \"ok\", False, None)):\n+    with patch(\"hermes_cli.goals.judge_goal\", return_value=(\"done\", \"ok\", False, None, False)):\n         # must not raise\n         await runner._post_turn_goal_continuation(\n             session_entry=session_entry,"]},{"file":"tests/hermes_cli/test_goals.py","hunks":["@@ -152,13 +152,13 @@ class TestJudgeGoal:\n     def test_empty_goal_skipped(self):\n         from hermes_cli.goals import judge_goal\n \n-        verdict, _, _, _wd = judge_goal(\"\", \"some response\")\n+        verdict, _, _, _wd, _tf = judge_goal(\"\", \"some response\")\n         assert verdict == \"skipped\"\n \n     def test_empty_response_continues(self):\n         from hermes_cli.goals import judge_goal\n \n-        verdict, _, _, _wd = judge_goal(\"ship the thing\", \"\")\n+        verdict, _, _, _wd, _tf = judge_goal(\"ship the thing\", \"\")\n         assert verdict == \"continue\"\n \n     def test_no_aux_client_continues(self):","@@ -169,7 +169,7 @@ def test_no_aux_client_continues(self):\n             \"agent.auxiliary_client.call_llm\",\n             side_effect=RuntimeError(\"No LLM provider configured\"),\n         ):\n-            verdict, _, _, _wd = goals.judge_goal(\"my goal\", \"my response\")\n+            verdict, _, _, _wd, _tf = goals.judge_goal(\"my goal\", \"my response\")\n         assert verdict == \"continue\"\n \n     def test_api_error_continues(self):","@@ -180,7 +180,7 @@ def test_api_error_continues(self):\n             \"agent.auxiliary_client.call_llm\",\n             side_effect=RuntimeError(\"boom\"),\n         ):\n-            verdict, reason, _, _wd = goals.judge_goal(\"goal\", \"response\")\n+            verdict, reason, _, _wd, _tf = goals.judge_goal(\"goal\", \"response\")\n         assert verdict == \"continue\"\n         assert \"judge error\" in reason.lower()\n ","@@ -193,7 +193,7 @@ def test_judge_says_done(self):\n                 choices=[MagicMock(message=MagicMock(content='{\"done\": true, \"reason\": \"achieved\"}'))]\n             ),\n         ):\n-            verdict, reason, _, _wd = goals.judge_goal(\"goal\", \"agent response\")\n+            verdict, reason, _, _wd, _tf = goals.judge_goal(\"goal\", \"agent response\")\n         assert verdict == \"done\"\n         assert reason == \"achieved\"\n ","@@ -206,7 +206,7 @@ def test_judge_says_continue(self):\n                 choices=[MagicMock(message=MagicMock(content='{\"done\": false, \"reason\": \"not yet\"}'))]\n             ),\n         ):\n-            verdict, reason, _, _wd = goals.judge_goal(\"goal\", \"agent response\")\n+            verdict, reason, _, _wd, _tf = goals.judge_goal(\"goal\", \"agent response\")\n         assert verdict == \"continue\"\n         assert reason == \"not yet\"\n ","@@ -295,7 +295,7 @@ def test_evaluate_after_turn_done(self, hermes_home):\n         mgr = GoalManager(session_id=\"eval-sid-1\")\n         mgr.set(\"ship it\")\n \n-        with patch.object(goals, \"judge_goal\", return_value=(\"done\", \"shipped\", False, None)):\n+        with patch.object(goals, \"judge_goal\", return_value=(\"done\", \"shipped\", False, None, False)):\n             decision = mgr.evaluate_after_turn(\"I shipped the feature.\")\n \n         assert decision[\"verdict\"] == \"done\"","@@ -311,7 +311,7 @@ def test_evaluate_after_turn_continue_under_budget(self, hermes_home):\n         mgr = GoalManager(session_id=\"eval-sid-2\", default_max_turns=5)\n         mgr.set(\"a long goal\")\n \n-        with patch.object(goals, \"judge_goal\", return_value=(\"continue\", \"more work\", False, None)):\n+        with patch.object(goals, \"judge_goal\", return_value=(\"continue\", \"more work\", False, None, False)):\n             decision = mgr.evaluate_after_turn(\"made some progress\")\n \n         assert decision[\"verdict\"] == \"continue\"","@@ -329,7 +329,7 @@ def test_evaluate_after_turn_budget_exhausted(self, hermes_home):\n         mgr = GoalManager(session_id=\"eval-sid-3\", default_max_turns=2)\n         mgr.set(\"hard goal\")\n \n-        with patch.object(goals, \"judge_goal\", return_value=(\"continue\", \"not yet\", False, None)):\n+        with patch.object(goals, \"judge_goal\", return_value=(\"continue\", \"not yet\", False, None, False)):\n             d1 = mgr.evaluate_after_turn(\"step 1\")\n             assert d1[\"should_continue\"] is True\n             assert mgr.state.turns_used == 1","@@ -438,9 +438,12 @@ def test_api_error_does_not_count_as_parse_failure(self):\n             \"agent.auxiliary_client.call_llm\",\n             side_effect=RuntimeError(\"connection reset\"),\n         ):\n-            verdict, _, parse_failed, _wd = goals.judge_goal(\"goal\", \"response\")\n+            verdict, _, parse_failed, _wd, transport_failed = goals.judge_goal(\n+                \"goal\", \"response\"\n+            )\n         assert verdict == \"continue\"\n         assert parse_failed is False\n+        assert transport_failed is True\n \n     def test_empty_judge_reply_flagged_as_parse_failure(self):\n         \"\"\"End-to-end: judge returns empty content → parse_failed=True.\"\"\"","@@ -450,7 +453,7 @@ def test_empty_judge_reply_flagged_as_parse_failure(self):\n             \"agent.auxiliary_client.call_llm\",\n             return_value=MagicMock(choices=[MagicMock(message=MagicMock(content=\"\"))]),\n         ):\n-            verdict, _, parse_failed, _wd = goals.judge_goal(\"goal\", \"response\")\n+            verdict, _, parse_failed, _wd, _tf = goals.judge_goal(\"goal\", \"response\")\n         assert verdict == \"continue\"\n         assert parse_failed is True\n ","@@ -464,7 +467,7 @@ def test_auto_pause_after_three_consecutive_parse_failures(self, hermes_home):\n         mgr.set(\"do a thing\")\n \n         with patch.object(\n-            goals, \"judge_goal\", return_value=(\"continue\", \"judge returned empty response\", True, None)\n+            goals, \"judge_goal\", return_value=(\"continue\", \"judge returned empty response\", True, None, False)\n         ):\n             d1 = mgr.evaluate_after_turn(\"step 1\")\n             assert d1[\"should_continue\"] is True","@@ -493,7 +496,7 @@ def test_parse_failure_counter_resets_on_good_reply(self, hermes_home):\n \n         # Two parse failures…\n         with patch.object(\n-            goals, \"judge_goal\", return_value=(\"continue\", \"not json\", True, None)\n+            goals, \"judge_goal\", return_value=(\"continue\", \"not json\", True, None, False)\n         ):\n             mgr.evaluate_after_turn(\"step 1\")\n             mgr.evaluate_after_turn(\"step 2\")","@@ -501,29 +504,50 @@ def test_parse_failure_counter_resets_on_good_reply(self, hermes_home):\n \n         # …then one clean reply resets the counter.\n         with patch.object(\n-            goals, \"judge_goal\", return_value=(\"continue\", \"making progress\", False, None)\n+            goals, \"judge_goal\", return_value=(\"continue\", \"making progress\", False, None, False)\n         ):\n             d = mgr.evaluate_after_turn(\"step 3\")\n             assert d[\"should_continue\"] is True\n             assert mgr.state.consecutive_parse_failures == 0\n \n-    def test_parse_failure_counter_not_incremented_by_api_errors(self, hermes_home):\n-        \"\"\"API/transport errors must NOT count toward the auto-pause threshold.\"\"\"\n+    def test_transport_failures_do_not_increment_parse_counter(self, hermes_home):\n+        \"\"\"Transport failures use their own counter and a good reply resets both.\"\"\"\n         from hermes_cli import goals\n         from hermes_cli.goals import GoalManager\n \n         mgr = GoalManager(session_id=\"parse-fail-sid-3\", default_max_turns=20)\n         mgr.set(\"goal\")\n+        assert mgr.state is not None\n \n         with patch.object(\n-            goals, \"judge_goal\", return_value=(\"continue\", \"judge error: RuntimeError\", False, None)\n+            goals,\n+            \"judge_goal\",\n+            return_value=(\n+                \"continue\",\n+                \"judge error: RuntimeError\",\n+                False,\n+                None,\n+                True,\n+            ),\n         ):\n-            for _ in range(5):\n+            for _ in range(2):\n                 d = mgr.evaluate_after_turn(\"still going\")\n                 assert d[\"should_continue\"] is True\n             assert mgr.state.consecutive_parse_failures == 0\n+            assert mgr.state.consecutive_transport_failures == 2\n             assert mgr.state.status == \"active\"\n \n+        with patch.object(\n+            goals,\n+            \"judge_goal\",\n+            return_value=(\"continue\", \"making progress\", False, None, False),\n+        ):\n+            d = mgr.evaluate_after_turn(\"recovered\")\n+\n+        assert d[\"should_continue\"] is True\n+        assert mgr.state.consecutive_parse_failures == 0\n+        assert mgr.state.consecutive_transport_failures == 0\n+\n     def test_consecutive_parse_failures_persists_across_goalmanager_reloads(\n         self, hermes_home\n     ):","@@ -535,7 +559,7 @@ def test_consecutive_parse_failures_persists_across_goalmanager_reloads(\n         mgr.set(\"persistent goal\")\n \n         with patch.object(\n-            goals, \"judge_goal\", return_value=(\"continue\", \"empty\", True, None)\n+            goals, \"judge_goal\", return_value=(\"continue\", \"empty\", True, None, False)\n         ):\n             mgr.evaluate_after_turn(\"r\")\n             mgr.evaluate_after_turn(\"r\")","@@ -732,7 +756,7 @@ def _fake_call_llm(**kwargs):\n             return _FakeResp()\n \n         with patch(\"agent.auxiliary_client.call_llm\", side_effect=_fake_call_llm):\n-            verdict, reason, parse_failed, _wd = goals.judge_goal(\n+            verdict, reason, parse_failed, _wd, _tf = goals.judge_goal(\n                 \"ship the feature\",\n                 \"ok shipped\",\n                 subgoals=[\"write tests\", \"update docs\"],","@@ -837,7 +861,7 @@ def test_parked_on_live_pid_does_not_continue_or_judge(self, hermes_home):\n             assert mgr.is_waiting() is True\n \n             # The judge must NOT be called while parked, and no turn is burned.\n-            judge = MagicMock(return_value=(\"continue\", \"x\", False, None))\n+            judge = MagicMock(return_value=(\"continue\", \"x\", False, None, False))\n             with patch.object(goals, \"judge_goal\", judge):\n                 decision = mgr.evaluate_after_turn(\"still waiting on CI\")\n ","@@ -869,7 +893,7 @@ def test_barrier_auto_clears_when_process_exits_and_loop_resumes(self, hermes_ho\n         assert mgr.is_waiting() is False  # lazy auto-clear\n         assert mgr.state.waiting_on_pid is None\n \n-        with patch.object(goals, \"judge_goal\", return_value=(\"continue\", \"more\", False, None)):\n+        with patch.object(goals, \"judge_goal\", return_value=(\"continue\", \"more\", False, None, False)):\n             decision = mgr.evaluate_after_turn(\"process finished, here are results\")\n \n         assert decision[\"verdict\"] == \"continue\"","@@ -886,7 +910,7 @@ def test_dead_pid_never_parks(self, hermes_home):\n         # is_waiting clears the stale barrier immediately.\n         assert mgr.is_waiting() is False\n \n-        with patch.object(goals, \"judge_goal\", return_value=(\"continue\", \"go\", False, None)):\n+        with patch.object(goals, \"judge_goal\", return_value=(\"continue\", \"go\", False, None, False)):\n             decision = mgr.evaluate_after_turn(\"response\")\n         assert decision[\"should_continue\"] is True\n ","@@ -986,7 +1010,7 @@ def test_judge_wait_pid_parks_loop(self, hermes_home):\n             # Judge sees the running process and says wait-on-pid.\n             with patch.object(\n                 goals, \"judge_goal\",\n-                return_value=(\"wait\", \"CI watcher still running\", False, {\"pid\": proc.pid}),\n+                return_value=(\"wait\", \"CI watcher still running\", False, {\"pid\": proc.pid}, False),\n             ):\n                 decision = mgr.evaluate_after_turn(\n                     \"Pushed the PR, watching CI.\",","@@ -1020,7 +1044,7 @@ def test_judge_wait_seconds_parks_loop(self, hermes_home):\n         mgr.set(\"retry after backoff\")\n         with patch.object(\n             goals, \"judge_goal\",\n-            return_value=(\"wait\", \"rate limited\", False, {\"seconds\": 120}),\n+            return_value=(\"wait\", \"rate limited\", False, {\"seconds\": 120}, False),\n         ):\n             decision = mgr.evaluate_after_turn(\"Hit a 429, backing off.\")\n         assert decision[\"verdict\"] == \"wait\"","@@ -1050,7 +1074,7 @@ def test_continue_verdict_still_continues_with_background(self, hermes_home):\n         mgr.set(\"do work\")\n         with patch.object(\n             goals, \"judge_goal\",\n-            return_value=(\"continue\", \"more to do\", False, None),\n+            return_value=(\"continue\", \"more to do\", False, None, False),\n         ):\n             decision = mgr.evaluate_after_turn(\n                 \"made progress\",","@@ -1118,7 +1142,7 @@ def test_goal_parks_on_session_and_releases_on_trigger(self, hermes_home):\n         mgr.set(\"wait for the build to succeed\")\n         with patch.object(\n             goals, \"judge_goal\",\n-            return_value=(\"wait\", \"blocked on build\", False, {\"session_id\": \"proc_t4\"}),\n+            return_value=(\"wait\", \"blocked on build\", False, {\"session_id\": \"proc_t4\"}, False),\n         ):\n             decision = mgr.evaluate_after_turn(\n                 \"Started the build watcher.\",","@@ -1146,7 +1170,7 @@ def test_goal_parks_on_session_and_releases_on_trigger(self, hermes_home):\n \n         # Loop resumes with a real judge verdict.\n         with patch.object(goals, \"judge_goal\",\n-                          return_value=(\"continue\", \"build done\", False, None)):\n+                          return_value=(\"continue\", \"build done\", False, None, False)):\n             d3 = mgr.evaluate_after_turn(\"build succeeded\")\n         assert d3[\"should_continue\"] is True\n ","@@ -1462,7 +1486,7 @@ def test_judge_prompt_carries_contract_and_background(self, hermes_home):\n         }]\n         with patch(\"agent.auxiliary_client.call_llm\",\n                    side_effect=self._capture_call_llm(captured)):\n-            verdict, reason, parse_failed, wait_directive = goals.judge_goal(\n+            verdict, reason, parse_failed, wait_directive, _tf = goals.judge_goal(\n                 \"ship the PR\",\n                 \"I pushed and started the CI watcher; waiting on it now.\",\n                 contract=GoalContract(verification=\"PR CI goes green\"),","@@ -1492,7 +1516,7 @@ def test_contract_goal_can_still_complete_on_evidence(self, hermes_home):\n                        captured,\n                        content='{\"verdict\": \"done\", \"reason\": \"CI is green, evidence shown\"}',\n                    )):\n-            verdict, reason, parse_failed, wait_directive = goals.judge_goal(\n+            verdict, reason, parse_failed, wait_directive, _tf = goals.judge_goal(\n                 \"ship the PR\",\n                 \"CI finished: 30 passed, 0 failed. Done.\",\n                 contract=GoalContract(verification=\"PR CI goes green\"),"]},{"file":"tests/hermes_cli/test_kanban_goal_mode.py","hunks":["@@ -181,8 +181,8 @@ def _patch_judge(monkeypatch, verdicts):\n \n     def _fake_judge(goal, response, subgoals=None, background_processes=None, **_kw):\n         v = seq.pop(0) if seq else \"done\"\n-        # 4-tuple contract: (verdict, reason, parse_failed, wait_directive)\n-        return v, f\"scripted:{v}\", False, None\n+        # 5-tuple contract: verdict, reason, parse failure, wait, transport failure.\n+        return v, f\"scripted:{v}\", False, None, False\n \n     monkeypatch.setattr(goals, \"judge_goal\", _fake_judge)\n ","@@ -347,10 +347,10 @@ def fake_complete_task(conn, tid, **kw):\n             lambda name: _aux_client,\n         )\n         # Match the real judge_goal contract:\n-        # (verdict, reason, parse_failed, wait_directive)\n+        # (verdict, reason, parse_failed, wait_directive, transport_failed)\n         monkeypatch.setattr(\n             \"hermes_cli.goals.judge_goal\",\n-            lambda **kw: (verdict, reason, False, None),\n+            lambda **kw: (verdict, reason, False, None, False),\n         )\n \n         args = argparse.Namespace(task_ids=[\"t1\"], summary=summary, result=None, metadata=None)"]},{"file":"tests/tools/test_kanban_tools.py","hunks":["@@ -649,8 +649,8 @@ def test_complete_goal_mode_rejected_by_judge(monkeypatch, tmp_path):\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         # Match the real judge_goal contract:\n-        # (verdict, reason, parse_failed, wait_directive)\n-        return \"continue\", \"missing verification evidence\", False, None\n+        # (verdict, reason, parse_failed, wait_directive, transport_failed)\n+        return \"continue\", \"missing verification evidence\", False, None, False\n \n     monkeypatch.setattr(\"tools.kanban_tools.judge_goal\", mock_judge_goal)\n     monkeypatch.setattr(\"tools.kanban_tools._goal_judge_available\", lambda: True)"]},{"file":"tools/kanban_tools.py","hunks":["@@ -603,11 +603,11 @@ def _handle_complete(args: dict, **kw) -> str:\n                 reason = \"\"\n                 try:\n                     # judge_goal returns (verdict, reason, parse_failed,\n-                    # wait_directive) — see hermes_cli/goals.py. Unpacking\n-                    # fewer raises ValueError, which the defensive handler\n-                    # below swallows, leaving verdict=\"done\" and silently\n-                    # disabling the gate.\n-                    verdict, reason, _, _ = judge_goal(\n+                    # wait_directive, transport_failed) — see\n+                    # hermes_cli/goals.py. Unpacking fewer raises ValueError,\n+                    # which the defensive handler below swallows, leaving\n+                    # verdict=\"done\" and silently disabling the gate.\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"]}]},{"kind":"pr","number":79680,"title":"feat(goals): quality gates — deterministic commands that must pass before /goal completes","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","gateway/slash_commands.py","hermes_cli/cli_commands_mixin.py","hermes_cli/commands.py","hermes_cli/goals.py","tests/hermes_cli/test_goal_gates.py","website/docs/user-guide/features/goals.md"]}],"trust":{"default_tier":"verified","includes_suggested":false}}