# NousResearch/hermes-agent · Persistent /goal loop control and completion logic

## Open now

- 2 issues, 0 PRs open on GitHub.
- #26986: Keep persistent goals active when the response explicitly reports incomplete work
- #29090: \[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.

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.

## TL;DR

- 12 member(s): 2 open issue(s), 3 closed issue(s), 0 open PR(s), 2 merged PR(s), 5 PR(s) closed without merging.
- A fix was merged to main in PR #67997. Verify it addresses your case.
- **Recommended:** read PR #67997 (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: `partially_resolved` · deciding PR(s): `pr-67997,pr-79680`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.13`: 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.

## Open related issues

- #26986: Keep persistent goals active when the response explicitly reports incomplete work
- #29090: \[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.

## Closed / duplicate issues

- #27585: \[Bug\]: /goal can spam repeated completion messages when goal_judge errors fail-open to continue
- #31718: Standing goals: agent should be able to signal completion and close the goal
- #34196: /goal judge over-continues exploratory goals unless the assistant explicitly says the goal is complete

## Prior & competing PRs

- **#31766: feat(agent): add goal_manage tool for agent self-signaled goal completion**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `265bd59c1d9f8dea658f243b257d4fae3685af53`, `#31718`
- **#67997: fix(goals): auto-pause goal loop on consecutive judge transport failures**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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:
  - `hermes_cli/goals.py`

    ```diff
    @@ -66,6 +66,11 @@
     # exhausted with every reply shaped like `judge returned empty response` or
     # `judge reply was not JSON`.
     DEFAULT_MAX_CONSECUTIVE_PARSE_FAILURES = 3
    +# Transport failures (API auth errors 401, timeouts, DNS, etc.) are also
    +# tracked and auto-pause the loop after this many consecutive failures.
    +# A broken/invalid API key returns 401 every call — the loop must not
    +# run until the turn budget, wasting every turn on an unreachable judge.
    +DEFAULT_MAX_CONSECUTIVE_TRANSPORT_FAILURES = 5
     
     
     CONTINUATION_PROMPT_TEMPLATE = (
    ```

    ```diff
    @@ -399,6 +404,10 @@ class GoalState:
         last_reason: Optional[str] = None
         paused_reason: Optional[str] = None       # why we auto-paused (budget, etc.)
         consecutive_parse_failures: int = 0       # judge-output parse failures in a row
    +    # Transport failures are API/auth/network errors.  Broken API keys return
    +    # 401 every call — track them separately so the loop auto-pauses instead
    +    # of burning every turn budget slot on an unreachable judge.
    +    consecutive_transport_failures: int = 0   # judge API/transport errors in a row
         # User-added criteria appended mid-loop via the /subgoal command.
         # When non-empty the judge prompt and continuation prompt both
         # include them so the agent works toward them and the judge factors
    ```

    ```diff
    @@ -457,6 +466,7 @@ def from_json(cls, raw: str) -> "GoalState":
                 last_reason=data.get("last_reason"),
                 paused_reason=data.get("paused_reason"),
                 consecutive_parse_failures=int(data.get("consecutive_parse_failures", 0) or 0),
    +            consecutive_transport_failures=int(data.get("consecutive_transport_failures", 0) or 0),
                 subgoals=subgoals,
                 waiting_on_pid=(int(data["waiting_on_pid"]) if data.get("waiting_on_pid") else None),
                 waiting_on_session=(str(data["waiting_on_session"]) if data.get("waiting_on_session") else None),
    ```

    ```diff
    @@ -841,17 +851,23 @@ def judge_goal(
         subgoals: Optional[List[str]] = None,
         background_processes: Optional[List[Dict[str, Any]]] = None,
         contract: Optional[GoalContract] = None,
    -) -> Tuple[str, str, bool, Optional[Dict[str, Any]]]:
    +) -> Tuple[str, str, bool, Optional[Dict[str, Any]], bool]:
         """Ask the auxiliary model whether the goal is satisfied.
     
    -    Returns ``(verdict, reason, parse_failed, wait_directive)`` where verdict
    +    Returns ``(verdict, reason, parse_failed, wait_directive, transport_failed)`` where verdict
         is ``"done"``, ``"continue"``, ``"wait"``, or ``"skipped"`` (when the
         judge couldn't be reached). ``wait_directive`` is set only for ``"wait"``
         (``{"pid": int}`` or ``{"seconds": int}``); ``None`` otherwise.
     
         ``parse_failed`` is True only when the judge call succeeded but its output
         was unusable (empty or non-JSON). API/transport errors return False — they
    -    are transient and should fail-open silently. Callers use this flag to
    +    are transient and should fail-open silently.
    +
    +    ``transport_failed`` is True only when the judge couldn't reach the API at
    +    all (auth 401, timeout, DNS, connection error).  Repeated transport
    +    failures signal a permanent config problem (e.g. invalid API key).  Callers
    +    use this flag to auto-pause after N consecutive transport failures (see
    +    ``DEFAULT_MAX_CONSECUTIVE_TRANSPORT_FAILURES``). Callers use this flag to
         auto-pause after N consecutive parse failures (see
         ``DEFAULT_MAX_CONSECUTIVE_PARSE_FAILURES``).
     
    ```

    ```diff
    @@ -867,21 +883,23 @@ def judge_goal(
         judge prompt; when none are set, behavior is identical to the original
         free-form judge.
     
    -    This is deliberately fail-open: any error returns ``("continue", ..., False, None)``
    -    so a broken judge doesn't wedge progress — the turn budget and the
    -    consecutive-parse-failures auto-pause are the backstops.
    +    This is deliberately fail-open: transport errors return ``("continue", ..., ..., None, True)``
    +    — the ``transport_failed=True`` flag lets callers track and auto-pause after
    +    N consecutive transport failures (see
    +    ``DEFAULT_MAX_CONSECUTIVE_TRANSPORT_FAILURES``) so a permanently broken
    +    judge doesn't burn the entire turn budget.
         """
         if not goal.strip():
    -        return "skipped", "empty goal", False, None
    +        return "skipped", "empty goal", False, None, False
         if not last_response.strip():
             # No substantive reply this turn — almost certainly not done yet.
    -        return "continue", "empty response (nothing to evaluate)", False, None
    +        return "continue", "empty response (nothing to evaluate)", False, None, False
     
         try:
             from agent.auxiliary_client import call_llm
         except Exception as exc:
             logger.debug("goal judge: auxiliary client import failed: %s", exc)
    -        return "continue", "auxiliary client unavailable", False, None
    +        return "continue", "auxiliary client unavailable", False, None, False
     
         # Build the prompt. Priority: contract > subgoals > plain. When both a
         # contract and subgoals exist, the subgoals are appended into the
    ```

    ```diff
    @@ -941,7 +959,7 @@ def judge_goal(
             )
         except Exception as exc:
             logger.info("goal judge: API call failed (%s) — falling through to continue", exc)
    -        return "continue", f"judge error: {type(exc).__name__}", False, None
    +        return "continue", f"judge error: {type(exc).__name__}", False, None, True
     
         try:
             raw = resp.choices[0].message.content or ""
    ```

    ```diff
    @@ -954,7 +972,7 @@ def judge_goal(
             verdict, _truncate(reason, 120),
             f" wait={wait_directive}" if wait_directive else "",
         )
    -    return verdict, reason, parse_failed, wait_directive
    +    return verdict, reason, parse_failed, wait_directive, False
     
     
     def gather_background_processes(task_id: Optional[str] = None) -> List[Dict[str, Any]]:
    ```

    ```diff
    @@ -1425,7 +1443,7 @@ def evaluate_after_turn(
             state.turns_used += 1
             state.last_turn_at = time.time()
     
    -        verdict, reason, parse_failed, wait_directive = judge_goal(
    +        verdict, reason, parse_failed, wait_directive, transport_failed = judge_goal(
                 state.goal,
                 last_response,
                 subgoals=state.subgoals or None,
    ```

    ```diff
    @@ -1443,6 +1461,16 @@ def evaluate_after_turn(
             else:
                 state.consecutive_parse_failures = 0
     
    +        # Track consecutive transport failures separately — persistent API
    +        # errors (401 auth, DNS, timeout) signal a broken config, not
    +        # transient network flakiness.  Auto-pause after N consecutive
    +        # transport failures so a permanently broken judge doesn't burn
    +        # every turn budget slot on an unreachable API.
    +        if transport_failed:
    +            state.consecutive_transport_failures += 1
    +        else:
    +            state.consecutive_transport_failures = 0
    +
             # WAIT verdict: the judge decided the agent is blocked on async work
             # and re-poking now would be busy-work. Set the barrier and park —
             # the turn we just counted stands (the judge call happened), but no
    ```

    ```diff
    @@ -1480,6 +1508,36 @@ def evaluate_after_turn(
                     "message": f"✓ Goal achieved: {reason}",
                 }
     
    +        # Auto-pause when the judge cannot reach the API at all N turns in a
    +        # row (401 auth, DNS failure, timeout).  Persistent transport failures
    +        # signal a broken configuration (e.g. invalid API key), not transient
    +        # flakiness.  Without this guard, a permanently broken judge burns
    +        # every turn budget slot on an unreachable API.
    +        if state.consecutive_transport_failures >= DEFAULT_MAX_CONSECUTIVE_TRANSPORT_FAILURES:
    +            state.status = "paused"
    +            state.paused_reason = (
    +                f"judge API unreachable {state.consecutive_transport_failures} turns in a row "
    +                f"(check auxiliary.goal_judge provider/key in config.yaml)"
    +            )
    +            save_goal(self.session_id, state)
    +            return {
    +                "status": "paused",
    +                "should_continue": False,
    +                "continuation_prompt": None,
    +                "verdict": "continue",
    +                "reason": reason,
    +                "message": (
    +                    f"⏸ Goal paused — judge API returned errors "
    +                    f"({state.consecutive_transport_failures} turns). "
    +                    "Check the goal_judge provider/key in ~/.hermes/config.yaml:\n"
    +                    "  auxiliary:\n"
    +                    "    goal_judge:\n"
    +                    "      provider: deepseek\n"
    +                    "      model: deepseek-v4-flash\n"
    +                    "Then /goal resume to continue."
    +                ),
    +            }
    +
             # Auto-pause when the judge model can't produce the expected JSON
             # verdict N turns in a row. Points the user at the goal_judge config
             # so they can route this side task to a model that follows the
    ```

    ```diff
    @@ -1679,7 +1737,7 @@ def _log(msg: str) -> None:
             # The kanban worker loop has no wait-barrier concept (workers finish
             # via kanban_complete / kanban_block, not by parking), so a WAIT
             # verdict is treated as CONTINUE here.
    -        verdict, reason, _parse_failed, _wait = judge_goal(goal_text, last_response)
    +        verdict, reason, _parse_failed, _wait, _transport_failed = judge_goal(goal_text, last_response)
             if verdict == "wait":
                 verdict = "continue"
             _log(f"kanban goal loop: turn {turns_used}/{max_turns} verdict={verdict} reason={_truncate(reason, 120)}")
    ```
  - `hermes_cli/kanban.py`

    ```diff
    @@ -2016,10 +2016,11 @@ def _cmd_complete(args: argparse.Namespace) -> int:
                         reason = ""
                         try:
                             # judge_goal returns (verdict, reason, parse_failed,
    -                        # wait_directive) — see hermes_cli/goals.py. Unpacking
    -                        # fewer raises ValueError into the fail-open handler
    -                        # below, silently disabling the gate.
    -                        verdict, reason, _, _ = judge_goal(
    +                        # wait_directive, transport_failed) — see
    +                        # hermes_cli/goals.py. Unpacking fewer raises
    +                        # ValueError into the fail-open handler below,
    +                        # silently disabling the gate.
    +                        verdict, reason, _, _, _ = judge_goal(
                                 goal=f"{task.title}\n\n{task.body or ''}".strip(),
                                 last_response=(summary or args.result or "").strip(),
                             )
    ```
  - `tests/cli/test_cli_goal_interrupt.py`

    ```diff
    @@ -169,7 +169,7 @@ def test_clean_response_enqueues_continuation_when_judge_says_continue(
             # Force the judge to say "continue" without touching the network.
             with patch(
                 "hermes_cli.goals.judge_goal",
    -            return_value=("continue", "needs more steps", False, None),
    +            return_value=("continue", "needs more steps", False, None, False),
             ):
                 cli._maybe_continue_goal_after_turn()
     
    ```

    ```diff
    @@ -189,7 +189,7 @@ def test_clean_response_marks_done_when_judge_says_done(self, hermes_home):
     
             with patch(
                 "hermes_cli.goals.judge_goal",
    -            return_value=("done", "goal satisfied", False, None),
    +            return_value=("done", "goal satisfied", False, None, False),
             ):
                 cli._maybe_continue_goal_after_turn()
     
    ```
  - `tests/gateway/test_goal_verdict_send.py`

    ```diff
    @@ -107,7 +107,7 @@ async def test_goal_verdict_done_sent_via_adapter_send(hermes_home):
         mgr = GoalManager(session_entry.session_id)
         mgr.set("ship the feature")
     
    -    with patch("hermes_cli.goals.judge_goal", return_value=("done", "the feature shipped", False, None)):
    +    with patch("hermes_cli.goals.judge_goal", return_value=("done", "the feature shipped", False, None, False)):
             await runner._post_turn_goal_continuation(
                 session_entry=session_entry,
                 source=src,
    ```

    ```diff
    @@ -136,7 +136,7 @@ async def test_goal_verdict_continue_enqueues_continuation(hermes_home):
         mgr = GoalManager(session_entry.session_id)
         mgr.set("polish the docs")
     
    -    with patch("hermes_cli.goals.judge_goal", return_value=("continue", "still needs work", False, None)):
    +    with patch("hermes_cli.goals.judge_goal", return_value=("continue", "still needs work", False, None, False)):
             await runner._post_turn_goal_continuation(
                 session_entry=session_entry,
                 source=src,
    ```

    ```diff
    @@ -164,7 +164,7 @@ async def test_goal_verdict_budget_exhausted_sends_pause(hermes_home):
         state.turns_used = 2
         save_goal(session_entry.session_id, state)
     
    -    with patch("hermes_cli.goals.judge_goal", return_value=("continue", "keep going", False, None)):
    +    with patch("hermes_cli.goals.judge_goal", return_value=("continue", "keep going", False, None, False)):
             await runner._post_turn_goal_continuation(
                 session_entry=session_entry,
                 source=src,
    ```

    ```diff
    @@ -211,7 +211,7 @@ def __init__(self):
     
         runner.adapters[Platform.TELEGRAM] = _NoSendAdapter()
     
    -    with patch("hermes_cli.goals.judge_goal", return_value=("done", "ok", False, None)):
    +    with patch("hermes_cli.goals.judge_goal", return_value=("done", "ok", False, None, False)):
             # must not raise
             await runner._post_turn_goal_continuation(
                 session_entry=session_entry,
    ```
  - `tests/hermes_cli/test_goals.py`

    ```diff
    @@ -152,13 +152,13 @@ class TestJudgeGoal:
         def test_empty_goal_skipped(self):
             from hermes_cli.goals import judge_goal
     
    -        verdict, _, _, _wd = judge_goal("", "some response")
    +        verdict, _, _, _wd, _tf = judge_goal("", "some response")
             assert verdict == "skipped"
     
         def test_empty_response_continues(self):
             from hermes_cli.goals import judge_goal
     
    -        verdict, _, _, _wd = judge_goal("ship the thing", "")
    +        verdict, _, _, _wd, _tf = judge_goal("ship the thing", "")
             assert verdict == "continue"
     
         def test_no_aux_client_continues(self):
    ```

    ```diff
    @@ -169,7 +169,7 @@ def test_no_aux_client_continues(self):
                 "agent.auxiliary_client.call_llm",
                 side_effect=RuntimeError("No LLM provider configured"),
             ):
    -            verdict, _, _, _wd = goals.judge_goal("my goal", "my response")
    +            verdict, _, _, _wd, _tf = goals.judge_goal("my goal", "my response")
             assert verdict == "continue"
     
         def test_api_error_continues(self):
    ```

    ```diff
    @@ -180,7 +180,7 @@ def test_api_error_continues(self):
                 "agent.auxiliary_client.call_llm",
                 side_effect=RuntimeError("boom"),
             ):
    -            verdict, reason, _, _wd = goals.judge_goal("goal", "response")
    +            verdict, reason, _, _wd, _tf = goals.judge_goal("goal", "response")
             assert verdict == "continue"
             assert "judge error" in reason.lower()
     
    ```

    ```diff
    @@ -193,7 +193,7 @@ def test_judge_says_done(self):
                     choices=[MagicMock(message=MagicMock(content='{"done": true, "reason": "achieved"}'))]
                 ),
             ):
    -            verdict, reason, _, _wd = goals.judge_goal("goal", "agent response")
    +            verdict, reason, _, _wd, _tf = goals.judge_goal("goal", "agent response")
             assert verdict == "done"
             assert reason == "achieved"
     
    ```

    ```diff
    @@ -206,7 +206,7 @@ def test_judge_says_continue(self):
                     choices=[MagicMock(message=MagicMock(content='{"done": false, "reason": "not yet"}'))]
                 ),
             ):
    -            verdict, reason, _, _wd = goals.judge_goal("goal", "agent response")
    +            verdict, reason, _, _wd, _tf = goals.judge_goal("goal", "agent response")
             assert verdict == "continue"
             assert reason == "not yet"
     
    ```

    ```diff
    @@ -295,7 +295,7 @@ def test_evaluate_after_turn_done(self, hermes_home):
             mgr = GoalManager(session_id="eval-sid-1")
             mgr.set("ship it")
     
    -        with patch.object(goals, "judge_goal", return_value=("done", "shipped", False, None)):
    +        with patch.object(goals, "judge_goal", return_value=("done", "shipped", False, None, False)):
                 decision = mgr.evaluate_after_turn("I shipped the feature.")
     
             assert decision["verdict"] == "done"
    ```

    ```diff
    @@ -311,7 +311,7 @@ def test_evaluate_after_turn_continue_under_budget(self, hermes_home):
             mgr = GoalManager(session_id="eval-sid-2", default_max_turns=5)
             mgr.set("a long goal")
     
    -        with patch.object(goals, "judge_goal", return_value=("continue", "more work", False, None)):
    +        with patch.object(goals, "judge_goal", return_value=("continue", "more work", False, None, False)):
                 decision = mgr.evaluate_after_turn("made some progress")
     
             assert decision["verdict"] == "continue"
    ```

    ```diff
    @@ -329,7 +329,7 @@ def test_evaluate_after_turn_budget_exhausted(self, hermes_home):
             mgr = GoalManager(session_id="eval-sid-3", default_max_turns=2)
             mgr.set("hard goal")
     
    -        with patch.object(goals, "judge_goal", return_value=("continue", "not yet", False, None)):
    +        with patch.object(goals, "judge_goal", return_value=("continue", "not yet", False, None, False)):
                 d1 = mgr.evaluate_after_turn("step 1")
                 assert d1["should_continue"] is True
                 assert mgr.state.turns_used == 1
    ```

    ```diff
    @@ -438,9 +438,12 @@ def test_api_error_does_not_count_as_parse_failure(self):
                 "agent.auxiliary_client.call_llm",
                 side_effect=RuntimeError("connection reset"),
             ):
    -            verdict, _, parse_failed, _wd = goals.judge_goal("goal", "response")
    +            verdict, _, parse_failed, _wd, transport_failed = goals.judge_goal(
    +                "goal", "response"
    +            )
             assert verdict == "continue"
             assert parse_failed is False
    +        assert transport_failed is True
     
         def test_empty_judge_reply_flagged_as_parse_failure(self):
             """End-to-end: judge returns empty content → parse_failed=True."""
    ```

    ```diff
    @@ -450,7 +453,7 @@ def test_empty_judge_reply_flagged_as_parse_failure(self):
                 "agent.auxiliary_client.call_llm",
                 return_value=MagicMock(choices=[MagicMock(message=MagicMock(content=""))]),
             ):
    -            verdict, _, parse_failed, _wd = goals.judge_goal("goal", "response")
    +            verdict, _, parse_failed, _wd, _tf = goals.judge_goal("goal", "response")
             assert verdict == "continue"
             assert parse_failed is True
     
    ```

    ```diff
    @@ -464,7 +467,7 @@ def test_auto_pause_after_three_consecutive_parse_failures(self, hermes_home):
             mgr.set("do a thing")
     
             with patch.object(
    -            goals, "judge_goal", return_value=("continue", "judge returned empty response", True, None)
    +            goals, "judge_goal", return_value=("continue", "judge returned empty response", True, None, False)
             ):
                 d1 = mgr.evaluate_after_turn("step 1")
                 assert d1["should_continue"] is True
    ```

    ```diff
    @@ -493,7 +496,7 @@ def test_parse_failure_counter_resets_on_good_reply(self, hermes_home):
     
             # Two parse failures…
             with patch.object(
    -            goals, "judge_goal", return_value=("continue", "not json", True, None)
    +            goals, "judge_goal", return_value=("continue", "not json", True, None, False)
             ):
                 mgr.evaluate_after_turn("step 1")
                 mgr.evaluate_after_turn("step 2")
    ```

    ```diff
    @@ -501,29 +504,50 @@ def test_parse_failure_counter_resets_on_good_reply(self, hermes_home):
     
             # …then one clean reply resets the counter.
             with patch.object(
    -            goals, "judge_goal", return_value=("continue", "making progress", False, None)
    +            goals, "judge_goal", return_value=("continue", "making progress", False, None, False)
             ):
                 d = mgr.evaluate_after_turn("step 3")
                 assert d["should_continue"] is True
                 assert mgr.state.consecutive_parse_failures == 0
     
    -    def test_parse_failure_counter_not_incremented_by_api_errors(self, hermes_home):
    -        """API/transport errors must NOT count toward the auto-pause threshold."""
    +    def test_transport_failures_do_not_increment_parse_counter(self, hermes_home):
    +        """Transport failures use their own counter and a good reply resets both."""
             from hermes_cli import goals
             from hermes_cli.goals import GoalManager
     
             mgr = GoalManager(session_id="parse-fail-sid-3", default_max_turns=20)
             mgr.set("goal")
    +        assert mgr.state is not None
     
             with patch.object(
    -            goals, "judge_goal", return_value=("continue", "judge error: RuntimeError", False, None)
    +            goals,
    +            "judge_goal",
    +            return_value=(
    +                "continue",
    +                "judge error: RuntimeError",
    +                False,
    +                None,
    +                True,
    +            ),
             ):
    -            for _ in range(5):
    +            for _ in range(2):
                     d = mgr.evaluate_after_turn("still going")
                     assert d["should_continue"] is True
                 assert mgr.state.consecutive_parse_failures == 0
    +            assert mgr.state.consecutive_transport_failures == 2
                 assert mgr.state.status == "active"
     
    +        with patch.object(
    +            goals,
    +            "judge_goal",
    +            return_value=("continue", "making progress", False, None, False),
    +        ):
    +            d = mgr.evaluate_after_turn("recovered")
    +
    +        assert d["should_continue"] is True
    +        assert mgr.state.consecutive_parse_failures == 0
    +        assert mgr.state.consecutive_transport_failures == 0
    +
         def test_consecutive_parse_failures_persists_across_goalmanager_reloads(
             self, hermes_home
         ):
    ```

    ```diff
    @@ -535,7 +559,7 @@ def test_consecutive_parse_failures_persists_across_goalmanager_reloads(
             mgr.set("persistent goal")
     
             with patch.object(
    -            goals, "judge_goal", return_value=("continue", "empty", True, None)
    +            goals, "judge_goal", return_value=("continue", "empty", True, None, False)
             ):
                 mgr.evaluate_after_turn("r")
                 mgr.evaluate_after_turn("r")
    ```

    ```diff
    @@ -732,7 +756,7 @@ def _fake_call_llm(**kwargs):
                 return _FakeResp()
     
             with patch("agent.auxiliary_client.call_llm", side_effect=_fake_call_llm):
    -            verdict, reason, parse_failed, _wd = goals.judge_goal(
    +            verdict, reason, parse_failed, _wd, _tf = goals.judge_goal(
                     "ship the feature",
                     "ok shipped",
                     subgoals=["write tests", "update docs"],
    ```

    ```diff
    @@ -837,7 +861,7 @@ def test_parked_on_live_pid_does_not_continue_or_judge(self, hermes_home):
                 assert mgr.is_waiting() is True
     
                 # The judge must NOT be called while parked, and no turn is burned.
    -            judge = MagicMock(return_value=("continue", "x", False, None))
    +            judge = MagicMock(return_value=("continue", "x", False, None, False))
                 with patch.object(goals, "judge_goal", judge):
                     decision = mgr.evaluate_after_turn("still waiting on CI")
     
    ```

    ```diff
    @@ -869,7 +893,7 @@ def test_barrier_auto_clears_when_process_exits_and_loop_resumes(self, hermes_ho
             assert mgr.is_waiting() is False  # lazy auto-clear
             assert mgr.state.waiting_on_pid is None
     
    -        with patch.object(goals, "judge_goal", return_value=("continue", "more", False, None)):
    +        with patch.object(goals, "judge_goal", return_value=("continue", "more", False, None, False)):
                 decision = mgr.evaluate_after_turn("process finished, here are results")
     
             assert decision["verdict"] == "continue"
    ```

    ```diff
    @@ -886,7 +910,7 @@ def test_dead_pid_never_parks(self, hermes_home):
             # is_waiting clears the stale barrier immediately.
             assert mgr.is_waiting() is False
     
    -        with patch.object(goals, "judge_goal", return_value=("continue", "go", False, None)):
    +        with patch.object(goals, "judge_goal", return_value=("continue", "go", False, None, False)):
                 decision = mgr.evaluate_after_turn("response")
             assert decision["should_continue"] is True
     
    ```

    ```diff
    @@ -986,7 +1010,7 @@ def test_judge_wait_pid_parks_loop(self, hermes_home):
                 # Judge sees the running process and says wait-on-pid.
                 with patch.object(
                     goals, "judge_goal",
    -                return_value=("wait", "CI watcher still running", False, {"pid": proc.pid}),
    +                return_value=("wait", "CI watcher still running", False, {"pid": proc.pid}, False),
                 ):
                     decision = mgr.evaluate_after_turn(
                         "Pushed the PR, watching CI.",
    ```

    ```diff
    @@ -1020,7 +1044,7 @@ def test_judge_wait_seconds_parks_loop(self, hermes_home):
             mgr.set("retry after backoff")
             with patch.object(
                 goals, "judge_goal",
    -            return_value=("wait", "rate limited", False, {"seconds": 120}),
    +            return_value=("wait", "rate limited", False, {"seconds": 120}, False),
             ):
                 decision = mgr.evaluate_after_turn("Hit a 429, backing off.")
             assert decision["verdict"] == "wait"
    ```

    ```diff
    @@ -1050,7 +1074,7 @@ def test_continue_verdict_still_continues_with_background(self, hermes_home):
             mgr.set("do work")
             with patch.object(
                 goals, "judge_goal",
    -            return_value=("continue", "more to do", False, None),
    +            return_value=("continue", "more to do", False, None, False),
             ):
                 decision = mgr.evaluate_after_turn(
                     "made progress",
    ```

    ```diff
    @@ -1118,7 +1142,7 @@ def test_goal_parks_on_session_and_releases_on_trigger(self, hermes_home):
             mgr.set("wait for the build to succeed")
             with patch.object(
                 goals, "judge_goal",
    -            return_value=("wait", "blocked on build", False, {"session_id": "proc_t4"}),
    +            return_value=("wait", "blocked on build", False, {"session_id": "proc_t4"}, False),
             ):
                 decision = mgr.evaluate_after_turn(
                     "Started the build watcher.",
    ```

    ```diff
    @@ -1146,7 +1170,7 @@ def test_goal_parks_on_session_and_releases_on_trigger(self, hermes_home):
     
             # Loop resumes with a real judge verdict.
             with patch.object(goals, "judge_goal",
    -                          return_value=("continue", "build done", False, None)):
    +                          return_value=("continue", "build done", False, None, False)):
                 d3 = mgr.evaluate_after_turn("build succeeded")
             assert d3["should_continue"] is True
     
    ```

    ```diff
    @@ -1462,7 +1486,7 @@ def test_judge_prompt_carries_contract_and_background(self, hermes_home):
             }]
             with patch("agent.auxiliary_client.call_llm",
                        side_effect=self._capture_call_llm(captured)):
    -            verdict, reason, parse_failed, wait_directive = goals.judge_goal(
    +            verdict, reason, parse_failed, wait_directive, _tf = goals.judge_goal(
                     "ship the PR",
                     "I pushed and started the CI watcher; waiting on it now.",
                     contract=GoalContract(verification="PR CI goes green"),
    ```

    ```diff
    @@ -1492,7 +1516,7 @@ def test_contract_goal_can_still_complete_on_evidence(self, hermes_home):
                            captured,
                            content='{"verdict": "done", "reason": "CI is green, evidence shown"}',
                        )):
    -            verdict, reason, parse_failed, wait_directive = goals.judge_goal(
    +            verdict, reason, parse_failed, wait_directive, _tf = goals.judge_goal(
                     "ship the PR",
                     "CI finished: 30 passed, 0 failed. Done.",
                     contract=GoalContract(verification="PR CI goes green"),
    ```
  - `tests/hermes_cli/test_kanban_goal_mode.py`

    ```diff
    @@ -181,8 +181,8 @@ def _patch_judge(monkeypatch, verdicts):
     
         def _fake_judge(goal, response, subgoals=None, background_processes=None, **_kw):
             v = seq.pop(0) if seq else "done"
    -        # 4-tuple contract: (verdict, reason, parse_failed, wait_directive)
    -        return v, f"scripted:{v}", False, None
    +        # 5-tuple contract: verdict, reason, parse failure, wait, transport failure.
    +        return v, f"scripted:{v}", False, None, False
     
         monkeypatch.setattr(goals, "judge_goal", _fake_judge)
     
    ```

    ```diff
    @@ -347,10 +347,10 @@ def fake_complete_task(conn, tid, **kw):
                 lambda name: _aux_client,
             )
             # Match the real judge_goal contract:
    -        # (verdict, reason, parse_failed, wait_directive)
    +        # (verdict, reason, parse_failed, wait_directive, transport_failed)
             monkeypatch.setattr(
                 "hermes_cli.goals.judge_goal",
    -            lambda **kw: (verdict, reason, False, None),
    +            lambda **kw: (verdict, reason, False, None, False),
             )
     
             args = argparse.Namespace(task_ids=["t1"], summary=summary, result=None, metadata=None)
    ```
  - `tests/tools/test_kanban_tools.py`

    ```diff
    @@ -649,8 +649,8 @@ def test_complete_goal_mode_rejected_by_judge(monkeypatch, tmp_path):
         # judge is reachable, so force the availability probe True as well.
         def mock_judge_goal(goal, last_response, *, timeout=30.0, subgoals=None):
             # Match the real judge_goal contract:
    -        # (verdict, reason, parse_failed, wait_directive)
    -        return "continue", "missing verification evidence", False, None
    +        # (verdict, reason, parse_failed, wait_directive, transport_failed)
    +        return "continue", "missing verification evidence", False, None, False
     
         monkeypatch.setattr("tools.kanban_tools.judge_goal", mock_judge_goal)
         monkeypatch.setattr("tools.kanban_tools._goal_judge_available", lambda: True)
    ```
  - `tools/kanban_tools.py`

    ```diff
    @@ -603,11 +603,11 @@ def _handle_complete(args: dict, **kw) -> str:
                     reason = ""
                     try:
                         # judge_goal returns (verdict, reason, parse_failed,
    -                    # wait_directive) — see hermes_cli/goals.py. Unpacking
    -                    # fewer raises ValueError, which the defensive handler
    -                    # below swallows, leaving verdict="done" and silently
    -                    # disabling the gate.
    -                    verdict, reason, _, _ = judge_goal(
    +                    # wait_directive, transport_failed) — see
    +                    # hermes_cli/goals.py. Unpacking fewer raises ValueError,
    +                    # which the defensive handler below swallows, leaving
    +                    # verdict="done" and silently disabling the gate.
    +                    verdict, reason, _, _, _ = judge_goal(
                             goal=f"{task.title}\n\n{task.body or ''}".strip(),
                             last_response=(summary or result or "").strip(),
                         )
    ```
- **#79680: feat(goals): quality gates — deterministic commands that must pass before /goal completes**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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`
- **#27752: fix(goals): pause loop when judge errors and response is terminal (#27585)**. closed without merge
  - maintainer direction: category `volume_quota`; implication `active`
- **#27760: fix(goals): add consecutive API error auto-pause to prevent goal spam (#27585)**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#67997`, `#54387`
- **#29318: fix(goal): stop the /goal loop when the agent says it is done (#29090)**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`
- **#34343: fix(goals): stop /goal over-continuation on exploratory + scope-narrow goals (#34196, #34197)**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#107938`

## Fix status

- Merged to main in PR #67997 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-08-06`.
- Subsystem: `agent`
