# NousResearch/hermes-agent · Update/venv rebuild bricking installs, especially Windows

## Open now

- Nothing open on GitHub.

Multiple bug reports and fix attempts address 'hermes update' corrupting or partially destroying the virtual environment during rebuilds, particularly on Windows, leaving installations broken with missing modules or a damaged git state.

## TL;DR

- 12 member(s): 0 open issue(s), 3 closed issue(s), 0 open PR(s), 2 merged PR(s), 7 PR(s) closed without merging.
- A fix was merged to main in PR #33803. Verify it addresses your case.
- **Recommended:** read PR #33803 (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-33803`. 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.15`: 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.

## Closed / duplicate issues

- #32384: \[Bug\]: \`hermes update\` Corrupts Git Repo and Breaks Installation
- #33788: \[Bug\]: Update got stuck half way, reboot it, then ModuleNotFoundError: No module named 'hermes_cli'
- #37881: \[Bug\]: \`hermes update\` bricks the install on Windows — venv rebuild leaves a venv with no \`pyvenv.cfg\`, then   \`ModuleNotFoundError: hermes_cli\`

## Prior & competing PRs

- **#33803: fix(update): stream + idle-kill webui-build so a stall can't soft-brick the install (#33788)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `hermes_cli/main.py`, `tests/hermes_cli/test_cmd_update.py`, `tests/hermes_cli/test_run_with_idle_timeout.py`, `tests/hermes_cli/test_web_ui_build.py`
  - fix hunks:
  - `hermes_cli/main.py`

    ```diff
    @@ -6509,6 +6509,104 @@ def _web_ui_build_needed(web_dir: Path) -> bool:
         return False
     
     
    +def _run_with_idle_timeout(
    +    cmd: list[str],
    +    cwd: Path,
    +    *,
    +    idle_timeout_seconds: int = 180,
    +    indent: str = "    ",
    +) -> subprocess.CompletedProcess:
    +    """Run a subprocess that streams output, with an idle-output timeout.
    +
    +    Issue #33788: ``npm run build`` (Vite) was invoked with
    +    ``capture_output=True`` and no timeout. On low-memory hosts (notably
    +    WSL2 with the default 4 GB cap) the build can stall or sit silent for
    +    minutes; users see a frozen terminal, assume the update is hung, and
    +    reboot — leaving the editable install in a half-state with the
    +    ``hermes`` launcher present but ``hermes_cli`` not importable.
    +
    +    This helper fixes both halves: stdout is streamed (so the user sees
    +    progress), and if no bytes have appeared on stdout/stderr for
    +    ``idle_timeout_seconds``, the process is terminated and the call
    +    returns with a non-zero ``returncode``. The caller's existing
    +    stale-dist fallback (#23817) takes over from there.
    +
    +    Returns a ``CompletedProcess`` with merged stdout (text), empty
    +    stderr, and an integer returncode. Never raises on idle timeout —
    +    propagation of failure is via the returncode.
    +    """
    +    merged_chunks: list[str] = []
    +    last_output_ts = _time.monotonic()
    +    lock = threading.Lock()
    +
    +    try:
    +        proc = subprocess.Popen(
    +            cmd,
    +            cwd=cwd,
    +            stdout=subprocess.PIPE,
    +            stderr=subprocess.STDOUT,
    +            text=True,
    +            encoding="utf-8",
    +            errors="replace",
    +            bufsize=1,
    +        )
    +    except OSError as exc:
    +        # E.g. npm not on PATH between the which() check and now.
    +        return subprocess.CompletedProcess(cmd, 127, stdout="", stderr=str(exc))
    +
    +    def _reader() -> None:
    +        nonlocal last_output_ts
    +        assert proc.stdout is not None
    +        for line in proc.stdout:
    +            try:
    +                print(f"{indent}{line.rstrip()}", flush=True)
    +            except UnicodeEncodeError:
    +                # Windows cp1252 fallback — same pattern as _say().
    +                enc = getattr(sys.stdout, "encoding", None) or "ascii"
    +                safe = line.rstrip().encode(enc, errors="replace").decode(enc, errors="replace")
    +                print(f"{indent}{safe}", flush=True)
    +            with lock:
    +                merged_chunks.append(line)
    +                last_output_ts = _time.monotonic()
    +
    +    reader_thread = threading.Thread(target=_reader, daemon=True)
    +    reader_thread.start()
    +
    +    idle_killed = False
    +    while True:
    +        try:
    +            rc = proc.wait(timeout=5)
    +            break
    +        except subprocess.TimeoutExpired:
    +            with lock:
    +                idle = _time.monotonic() - last_output_ts
    +            if idle > idle_timeout_seconds:
    +                idle_killed = True
    +                proc.terminate()
    +                try:
    +                    rc = proc.wait(timeout=3)
    +                except subprocess.TimeoutExpired:
    +                    proc.kill()
    +                    rc = proc.wait()
    +                break
    +
    +    # Drain reader so we don't leak the stdout file descriptor.
    +    reader_thread.join(timeout=2)
    +
    +    combined = "".join(merged_chunks)
    +    if idle_killed:
    +        msg = (
    +            f"\n  ⚠ Build produced no output for {idle_timeout_seconds}s — terminated.\n"
    +            "    Common causes: out-of-memory on a low-RAM host (WSL/container),\n"
    +            "    a stuck Node process, or an antivirus scan stalling I/O.\n"
    +        )
    +        combined += msg
    +        # Force a non-zero rc even if terminate() raced with a clean exit.
    +        if rc == 0:
    +            rc = 124  # GNU `timeout` convention
    +    return subprocess.CompletedProcess(cmd, rc, stdout=combined, stderr="")
    +
    +
     def _run_npm_install_deterministic(
         npm: str,
         cwd: Path,
    ```

    ```diff
    @@ -6614,31 +6712,26 @@ def _relay(result: "subprocess.CompletedProcess") -> None:
             if fatal:
                 _say("  Run manually:  cd web && npm install && npm run build")
             return False
    -    # First attempt
    -    r2 = subprocess.run(
    -        [npm, "run", "build"],
    -        cwd=web_dir,
    -        capture_output=True,
    -        text=True,
    -        encoding="utf-8",
    -        errors="replace",
    -    )
    +    # First attempt — stream output via idle-timeout helper (issue #33788).
    +    # capture_output=True on a long Vite build looks identical to a hang;
    +    # users react by rebooting, which leaves the editable install in a
    +    # half-state. Streaming + idle-kill makes failures observable AND
    +    # recoverable (the stale-dist fallback below handles the kill path).
    +    r2 = _run_with_idle_timeout([npm, "run", "build"], cwd=web_dir)
         if r2.returncode != 0:
             # Retry once after a short delay — covers boot-time races on Windows
             # (antivirus scanning Node.js binaries, npm cache not ready, transient
             # I/O when launched via Scheduled Task at logon). See issue #23817.
             _time.sleep(3)
    -        r2 = subprocess.run(
    -            [npm, "run", "build"],
    -            cwd=web_dir,
    -            capture_output=True,
    -            text=True,
    -            encoding="utf-8",
    -            errors="replace",
    -        )
    +        r2 = _run_with_idle_timeout([npm, "run", "build"], cwd=web_dir)
     
         if r2.returncode != 0:
    -        stderr_preview = (r2.stderr or "").strip()
    +        # _run_with_idle_timeout merges stderr into stdout; older callers
    +        # using subprocess.run kept them split. Pull from whichever has
    +        # content so the error surfaces regardless of which path produced
    +        # the CompletedProcess.
    +        build_output = (r2.stderr or "") + (r2.stdout or "")
    +        stderr_preview = build_output.strip()
             stderr_tail = "\n  ".join(stderr_preview.splitlines()[-10:]) if stderr_preview else ""
             dist_dir = web_dir.parent / "hermes_cli" / "web_dist"
             dist_index = dist_dir / "index.html"
    ```

    ```diff
    @@ -7129,6 +7222,11 @@ def _update_via_zip(args):
             _install_python_dependencies_with_optional_fallback(pip_cmd)
     
         _update_node_dependencies()
    +    # Core (Python deps + git pull / ZIP extract) is now complete; the CLI
    +    # is functional from this point onward. The web UI build below is
    +    # optional — a failure here only affects ``hermes dashboard``. Make
    +    # that visible so users don't panic and reboot mid-build (#33788).
    +    print("→ Core update complete. Building dashboard (optional)...")
         _build_web_ui(PROJECT_ROOT / "web")
     
         # Sync skills
    ```

    ```diff
    @@ -9203,6 +9301,10 @@ def _cmd_update_impl(args, gateway_mode: bool):
             _refresh_active_lazy_features()
     
             _update_node_dependencies()
    +        # See note above (ZIP path): core is now complete, web UI build is
    +        # optional from a CLI perspective. Telegraphing this avoids the
    +        # "stuck at webui-build → reboot → broken install" trap (#33788).
    +        print("→ Core update complete. Building dashboard (optional)...")
             _build_web_ui(PROJECT_ROOT / "web")
     
             print()
    ```
  - `tests/hermes_cli/test_cmd_update.py`

    ```diff
    @@ -144,7 +144,13 @@ def test_update_refreshes_repo_and_tui_node_dependencies(
             mock_run.side_effect = _make_run_side_effect(
                 branch="main", verify_ok=True, commit_count="1"
             )
    -        with patch.object(hm, "_is_termux_env", return_value=False):
    +        # The web UI build runs through _run_with_idle_timeout now (issue
    +        # #33788) so it no longer appears in subprocess.run's call list.
    +        # Mock it so the test doesn't actually shell out to ``tsc``.
    +        import subprocess as _subprocess
    +        build_ok = _subprocess.CompletedProcess([], 0, stdout="", stderr="")
    +        with patch.object(hm, "_is_termux_env", return_value=False), \
    +             patch.object(hm, "_run_with_idle_timeout", return_value=build_ok) as mock_idle:
                 cmd_update(mock_args)
     
             npm_calls = [
    ```

    ```diff
    @@ -153,10 +159,11 @@ def test_update_refreshes_repo_and_tui_node_dependencies(
                 if call.args and call.args[0][0] == "/usr/bin/npm"
             ]
     
    -        # cmd_update runs npm commands in three locations:
    -        #   1. repo root  — slash-command / TUI bridge deps
    -        #   2. ui-tui/    — Ink TUI deps
    -        #   3. web/       — install + "npm run build" for the web frontend
    +        # cmd_update runs npm commands in four locations:
    +        #   1. repo root  — slash-command / TUI bridge deps  (subprocess.run)
    +        #   2. ui-tui/    — Ink TUI deps                     (subprocess.run)
    +        #   3. web/       — npm install                      (subprocess.run)
    +        #   4. web/       — npm run build                    (_run_with_idle_timeout)
             #
             # Repo-root and ui-tui installs intentionally omit `--silent` and run
             # without `capture_output` so optional postinstall scripts (e.g.
    ```

    ```diff
    @@ -175,11 +182,18 @@ def test_update_refreshes_repo_and_tui_node_dependencies(
                 (update_flags, PROJECT_ROOT / "ui-tui"),
             ]
             if len(npm_calls) > 2:
    +            # Only the web/ install is left in subprocess.run; the build moved
    +            # to _run_with_idle_timeout to make Vite progress visible (#33788).
                 assert npm_calls[2:] == [
                     (["/usr/bin/npm", "ci", "--silent"], PROJECT_ROOT / "web"),
    -                (["/usr/bin/npm", "run", "build"], PROJECT_ROOT / "web"),
                 ]
     
    +        # The web UI build itself went through the streaming helper.
    +        mock_idle.assert_called_once()
    +        idle_args, idle_kwargs = mock_idle.call_args
    +        assert idle_args[0] == ["/usr/bin/npm", "run", "build"]
    +        assert idle_kwargs["cwd"] == PROJECT_ROOT / "web"
    +
             # Regression for #18840: repo root + ui-tui installs must stream
             # output (capture_output=False) so postinstall progress is visible
             # to the user.
    ```
  - `tests/hermes_cli/test_run_with_idle_timeout.py`

    ```diff
    @@ -0,0 +1,67 @@
    +"""Coverage for _run_with_idle_timeout — the streaming subprocess helper.
    +
    +Kept in a dedicated test file because the tests spawn real ``subprocess.Popen``
    +instances; pytest-isolate runs each test file in its own worker process, so
    +isolating these here prevents real-Popen state from racing with the
    +``subprocess.run`` / ``_run_with_idle_timeout`` patches used by
    +``test_web_ui_build.py``.
    +
    +Added for issue #33788: ``hermes update`` got stuck at "webui-build" because
    +``npm run build`` ran with ``capture_output=True`` and no timeout. The helper
    +fixes both halves — streams output AND idle-kills the process.
    +"""
    +
    +import sys as _sys
    +import time
    +
    +from hermes_cli.main import _run_with_idle_timeout
    +
    +
    +def test_streams_output_and_returns_zero_on_success(tmp_path):
    +    script = tmp_path / "ok.py"
    +    script.write_text("print('line one'); print('line two')\n")
    +    result = _run_with_idle_timeout(
    +        [_sys.executable, str(script)], cwd=tmp_path, idle_timeout_seconds=10
    +    )
    +    assert result.returncode == 0
    +    assert "line one" in result.stdout
    +    assert "line two" in result.stdout
    +
    +
    +def test_propagates_nonzero_exit(tmp_path):
    +    script = tmp_path / "fail.py"
    +    script.write_text("import sys; print('boom', file=sys.stderr); sys.exit(7)\n")
    +    result = _run_with_idle_timeout(
    +        [_sys.executable, str(script)], cwd=tmp_path, idle_timeout_seconds=10
    +    )
    +    assert result.returncode == 7
    +    # stderr is merged into stdout in the helper.
    +    assert "boom" in result.stdout
    +
    +
    +def test_kills_process_on_idle_timeout(tmp_path):
    +    # Sleeps without printing — exactly the failure mode users see when
    +    # `npm run build` stalls. Idle timeout must terminate it.
    +    script = tmp_path / "stall.py"
    +    script.write_text("import time; time.sleep(30)\n")
    +
    +    start = time.monotonic()
    +    result = _run_with_idle_timeout(
    +        [_sys.executable, str(script)],
    +        cwd=tmp_path,
    +        idle_timeout_seconds=1,
    +    )
    +    elapsed = time.monotonic() - start
    +    # Should have died well before the 30s sleep completes.
    +    assert elapsed < 15
    +    assert result.returncode != 0
    +    assert "produced no output" in result.stdout
    +
    +
    +def test_returns_127_when_binary_missing(tmp_path):
    +    result = _run_with_idle_timeout(
    +        ["/nonexistent/binary/does/not/exist"],
    +        cwd=tmp_path,
    +        idle_timeout_seconds=5,
    +    )
    +    assert result.returncode == 127
    ```
  - `tests/hermes_cli/test_web_ui_build.py`

    ```diff
    @@ -113,12 +113,17 @@ def test_runs_npm_when_dist_missing(self, tmp_path):
             web_dir, _ = _make_web_dir(tmp_path)
     
             mock_cp = __import__("subprocess").CompletedProcess([], 0, stdout=b"", stderr=b"")
    +        build_ok = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
             with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
    -             patch("hermes_cli.main.subprocess.run", return_value=mock_cp) as mock_run:
    +             patch("hermes_cli.main.subprocess.run", return_value=mock_cp) as mock_run, \
    +             patch("hermes_cli.main._run_with_idle_timeout", return_value=build_ok) as mock_idle:
                 result = _build_web_ui(web_dir)
     
             assert result is True
    -        assert mock_run.call_count == 2  # npm install + npm run build
    +        # npm install goes through subprocess.run; npm run build goes through
    +        # _run_with_idle_timeout (issue #33788).
    +        assert mock_run.call_count == 1   # install only
    +        assert mock_idle.call_count == 1  # build only
     
         def test_npm_install_uses_utf8_replace_output_decoding(self, tmp_path):
             web_dir, _ = _make_web_dir(tmp_path)
    ```

    ```diff
    @@ -134,19 +139,29 @@ def test_npm_install_uses_utf8_replace_output_decoding(self, tmp_path):
             assert kwargs["encoding"] == "utf-8"
             assert kwargs["errors"] == "replace"
     
    -    def test_web_build_uses_utf8_replace_output_decoding(self, tmp_path):
    +    def test_web_build_uses_idle_timeout_helper(self, tmp_path):
    +        """npm run build now goes through _run_with_idle_timeout (issue #33788).
    +
    +        The install step keeps its capture_output behavior (the existing
    +        retry-on-EPERM contract depends on it); only the long-running build
    +        step is streamed + idle-killed.
    +        """
             web_dir, _ = _make_web_dir(tmp_path)
     
    -        mock_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
    +        install_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
    +        build_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
             with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
    -             patch("hermes_cli.main.subprocess.run", side_effect=[mock_cp, mock_cp]) as mock_run:
    +             patch("hermes_cli.main.subprocess.run", return_value=install_cp), \
    +             patch("hermes_cli.main._run_with_idle_timeout", return_value=build_cp) as mock_idle:
                 result = _build_web_ui(web_dir)
     
             assert result is True
    -        _, build_kwargs = mock_run.call_args_list[1]
    -        assert build_kwargs["text"] is True
    -        assert build_kwargs["encoding"] == "utf-8"
    -        assert build_kwargs["errors"] == "replace"
    +        # Build was invoked through the idle-timeout helper, not subprocess.run.
    +        mock_idle.assert_called_once()
    +        args, kwargs = mock_idle.call_args
    +        # Positional: [npm, "run", "build"]; cwd passed as kwarg.
    +        assert args[0] == ["/usr/bin/npm", "run", "build"]
    +        assert kwargs["cwd"] == web_dir
     
     
     class TestBuildWebUIRetryAndStaleFallback:
    ```

    ```diff
    @@ -155,18 +170,19 @@ class TestBuildWebUIRetryAndStaleFallback:
         def test_retries_build_once_on_failure(self, tmp_path):
             web_dir, _ = _make_web_dir(tmp_path)
             Subprocess = __import__("subprocess")
    -        # install: success; build attempt 1: fail; build attempt 2: success
             install_ok = Subprocess.CompletedProcess([], 0, stdout="", stderr="")
    -        build_fail = Subprocess.CompletedProcess([], 1, stdout="", stderr="EPERM")
    +        # build attempt 1: fail; build attempt 2: success.
    +        build_fail = Subprocess.CompletedProcess([], 1, stdout="EPERM", stderr="")
             build_ok = Subprocess.CompletedProcess([], 0, stdout="", stderr="")
             with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
                  patch("hermes_cli.main._time.sleep") as mock_sleep, \
    -             patch("hermes_cli.main.subprocess.run",
    -                   side_effect=[install_ok, build_fail, build_ok]) as mock_run:
    +             patch("hermes_cli.main.subprocess.run", return_value=install_ok), \
    +             patch("hermes_cli.main._run_with_idle_timeout",
    +                   side_effect=[build_fail, build_ok]) as mock_idle:
                 result = _build_web_ui(web_dir)
     
             assert result is True
    -        assert mock_run.call_count == 3  # install + build + retry
    +        assert mock_idle.call_count == 2  # build + retry
             mock_sleep.assert_called_once_with(3)
     
         def test_falls_back_to_stale_dist_when_retry_also_fails(self, tmp_path, capsys):
    ```

    ```diff
    @@ -177,11 +193,12 @@ def test_falls_back_to_stale_dist_when_retry_also_fails(self, tmp_path, capsys):
     
             Subprocess = __import__("subprocess")
             install_ok = Subprocess.CompletedProcess([], 0, stdout="", stderr="")
    -        build_fail = Subprocess.CompletedProcess([], 1, stdout="", stderr="vite ENOMEM")
    +        build_fail = Subprocess.CompletedProcess([], 1, stdout="vite ENOMEM", stderr="")
             with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
                  patch("hermes_cli.main._time.sleep"), \
    -             patch("hermes_cli.main.subprocess.run",
    -                   side_effect=[install_ok, build_fail, build_fail]):
    +             patch("hermes_cli.main.subprocess.run", return_value=install_ok), \
    +             patch("hermes_cli.main._run_with_idle_timeout",
    +                   side_effect=[build_fail, build_fail]):
                 result = _build_web_ui(web_dir, fatal=True)
     
             # MUST return True (serve stale) — issue #23817 — even with fatal=True,
    ```

    ```diff
    @@ -189,18 +206,19 @@ def test_falls_back_to_stale_dist_when_retry_also_fails(self, tmp_path, capsys):
             assert result is True
             out = capsys.readouterr().out
             assert "serving stale dist as fallback" in out
    -        assert "vite ENOMEM" in out  # stderr surfaced to user
    +        assert "vite ENOMEM" in out  # combined output surfaced to user
     
         def test_hard_fails_when_no_dist_to_fall_back_to(self, tmp_path, capsys):
             web_dir, _ = _make_web_dir(tmp_path)
     
             Subprocess = __import__("subprocess")
             install_ok = Subprocess.CompletedProcess([], 0, stdout="", stderr="")
    -        build_fail = Subprocess.CompletedProcess([], 1, stdout="", stderr="vite ENOMEM")
    +        build_fail = Subprocess.CompletedProcess([], 1, stdout="vite ENOMEM", stderr="")
             with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
                  patch("hermes_cli.main._time.sleep"), \
    -             patch("hermes_cli.main.subprocess.run",
    -                   side_effect=[install_ok, build_fail, build_fail]):
    +             patch("hermes_cli.main.subprocess.run", return_value=install_ok), \
    +             patch("hermes_cli.main._run_with_idle_timeout",
    +                   side_effect=[build_fail, build_fail]):
                 result = _build_web_ui(web_dir, fatal=True)
     
             assert result is False
    ```
- **#37895: fix(cli): pass --clear to uv venv in rebuild_venv to avoid Windows brick**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `c136eb4de`, `#37881`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#37965: fix(update): add --clear flag to uv venv rebuild to prevent Windows bricking #37881**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`
- **#38051: fix(config): add --clear flag to uv venv rebuild to prevent Windows bricking #37881**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `c136eb4de`, `#37881`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#38397: fix(uv): never gut the venv in place when rebuilding on Windows**. closed without merge · fix tier `suggested`
  - maintainer direction: category `partial_fix`; implication `needs_llm`; refs `#38887`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#38887: fix(update): harden venv rebuild + verify core deps after install**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `hermes_cli/main.py`, `hermes_cli/managed_uv.py`, `tests/hermes_cli/test_managed_uv.py`, `tests/hermes_cli/test_verify_core_dependencies.py`
  - fix hunks:
  - `hermes_cli/main.py`

    ```diff
    @@ -9023,6 +9023,222 @@ def _install(args: list[str]) -> None:
                 f"  ⚠ Skipped optional extras that still failed: {', '.join(failed_extras)}"
             )
     
    +    # Belt-and-suspenders: verify every declared core dependency from
    +    # pyproject.toml's [project.dependencies] is actually importable in the
    +    # target venv. uv's incremental resolver has — in the wild — produced
    +    # partial installs where a newly added base dep (e.g. ``pathspec``)
    +    # silently fails to land on top of a half-stale venv, and the only
    +    # symptom is a downstream subprocess crashing with ModuleNotFoundError
    +    # hours later inside ``hermes update``'s desktop-rebuild or skill-sync
    +    # stage. Reinstall with --reinstall to force resolution if anything is
    +    # missing, then re-verify so the failure surfaces here instead of
    +    # downstream.
    +    _verify_core_dependencies_installed(install_cmd_prefix, env=env, group=group)
    +
    +
    +def _verify_core_dependencies_installed(
    +    install_cmd_prefix: list[str],
    +    *,
    +    env: dict[str, str] | None = None,
    +    group: str = "all",
    +) -> None:
    +    """Check that every base dep from pyproject.toml is importable; if not, retry.
    +
    +    Reads ``pyproject.toml`` directly (so we don't trust the venv's stale
    +    metadata), filters out deps gated by ``;`` environment markers that don't
    +    apply to this platform, and runs ``importlib.metadata.version()`` in the
    +    venv interpreter for each one. If anything is missing we reinstall the
    +    base group with ``--reinstall`` to force uv to re-resolve, then check
    +    again. We treat the final state as a warning rather than a hard failure
    +    so a single broken-on-PyPI dep can't block an otherwise-successful
    +    update — but the warning makes the partial install visible at the spot
    +    that caused it, instead of hours later in a downstream subprocess.
    +    """
    +    try:
    +        import tomllib  # Python 3.11+
    +    except ImportError:  # pragma: no cover — Python < 3.11 unsupported but be safe
    +        return
    +
    +    pyproject = PROJECT_ROOT / "pyproject.toml"
    +    if not pyproject.is_file():
    +        return
    +
    +    try:
    +        with open(pyproject, "rb") as f:
    +            data = tomllib.load(f)
    +        raw_deps = data.get("project", {}).get("dependencies", []) or []
    +    except Exception as e:
    +        logger.debug("dep verification: failed to read pyproject.toml: %s", e)
    +        return
    +
    +    # Parse each "name OP version ; marker" string into (dist_name, marker_obj).
    +    # We use packaging.requirements when available (it ships with pip/uv envs),
    +    # falling back to a naive split that's good enough for the canonical
    +    # ``name==version[; marker]`` style this repo uses.
    +    deps: list[tuple[str, "object | None"]] = []
    +    try:
    +        from packaging.requirements import Requirement  # type: ignore
    +
    +        for spec in raw_deps:
    +            try:
    +                req = Requirement(spec)
    +                deps.append((req.name, req.marker))
    +            except Exception:
    +                continue
    +    except Exception:
    +        for spec in raw_deps:
    +            head = spec.split(";", 1)[0]
    +            for op in ("==", ">=", "<=", "~=", ">", "<", "!="):
    +                if op in head:
    +                    head = head.split(op, 1)[0]
    +                    break
    +            name = head.strip().split("[", 1)[0].strip()
    +            if name:
    +                deps.append((name, None))
    +
    +    # Apply environment markers to drop deps that don't apply on this platform
    +    # (e.g. ``ptyprocess ; sys_platform != 'win32'`` is correctly skipped on
    +    # Windows). Without markers we'd false-positive every cross-platform exclusion.
    +    applicable: list[str] = []
    +    for name, marker in deps:
    +        if marker is None:
    +            applicable.append(name)
    +            continue
    +        try:
    +            if marker.evaluate():  # type: ignore[union-attr]
    +                applicable.append(name)
    +        except Exception:
    +            applicable.append(name)
    +
    +    if not applicable:
    +        return
    +
    +    # Run the check inside the venv Python — sys.executable here may be the
    +    # outer Python that drove ``hermes update``, not the venv we just wrote
    +    # to. The uv install_cmd_prefix encodes which environment we targeted
    +    # (either ``[uv, pip]`` with VIRTUAL_ENV in env, or
    +    # ``[sys.executable, -m, pip]`` for the in-process Python); resolve the
    +    # right interpreter for the verification.
    +    venv_python = _resolve_install_target_python(install_cmd_prefix, env)
    +    if venv_python is None:
    +        return
    +
    +    def _missing_deps() -> list[str]:
    +        check_script = (
    +            "import importlib.metadata as md, sys\n"
    +            "missing=[]\n"
    +            "for name in sys.argv[1:]:\n"
    +            "    try: md.version(name)\n"
    +            "    except md.PackageNotFoundError: missing.append(name)\n"
    +            "print('\\n'.join(missing))\n"
    +        )
    +        try:
    +            result = subprocess.run(
    +                [str(venv_python), "-c", check_script, *applicable],
    +                capture_output=True,
    +                text=True,
    +                check=False,
    +                env=env,
    +            )
    +        except Exception as e:
    +            logger.debug("dep verification: subprocess failed: %s", e)
    +            return []
    +        return [line.strip() for line in result.stdout.splitlines() if line.strip()]
    +
    +    missing = _missing_deps()
    +    if not missing:
    +        return
    +
    +    print(
    +        f"  ⚠ Verification: {len(missing)} declared dep(s) missing after install: "
    +        f"{', '.join(missing[:8])}{'...' if len(missing) > 8 else ''}"
    +    )
    +    print("  → Reinstalling base group with --reinstall to repair...")
    +
    +    # Reinstall base group with --reinstall so uv re-resolves from scratch
    +    # against the current pyproject. We don't pass ``[{group}]`` here on
    +    # purpose — the missing dep is in *base* deps; rerunning the full all-
    +    # extras install can cost minutes and trips on whatever optional extra
    +    # was already broken upstream. Base is fast and is what's actually wrong.
    +    repair_args = ["install", "--reinstall", "-e", "."]
    +    try:
    +        _run_install_with_heartbeat(install_cmd_prefix + repair_args, env=env)
    +    except subprocess.CalledProcessError as e:
    +        logger.warning("dep verification: repair install failed: %s", e)
    +        print("  ⚠ Repair install failed; check `hermes update` output above.")
    +        return
    +
    +    still_missing = _missing_deps()
    +    if not still_missing:
    +        print("  ✓ All declared core dependencies now installed")
    +        return
    +
    +    # Last-ditch: install each remaining missing dep with its pin directly.
    +    # Useful when uv's resolver thinks the env is satisfied but the on-disk
    +    # package metadata says otherwise (rare but observed).
    +    name_to_spec = {}
    +    for spec in raw_deps:
    +        head = spec.split(";", 1)[0].strip()
    +        bare = head
    +        for op in ("==", ">=", "<=", "~=", ">", "<", "!="):
    +            if op in bare:
    +                bare = bare.split(op, 1)[0]
    +                break
    +        name_to_spec[bare.strip().split("[", 1)[0].strip()] = head
    +
    +    specs = [name_to_spec.get(n, n) for n in still_missing]
    +    print(
    +        f"  → Force-installing remaining missing dep(s): {', '.join(specs)}"
    +    )
    +    try:
    +        _run_install_with_heartbeat(
    +            install_cmd_prefix + ["install", "--reinstall", *specs], env=env
    +        )
    +    except subprocess.CalledProcessError as e:
    +        logger.warning("dep verification: per-package repair failed: %s", e)
    +        print(
    +            f"  ⚠ Could not install: {', '.join(still_missing)}. "
    +            "Run `hermes update --force` after closing other hermes processes."
    +        )
    +        return
    +
    +    final_missing = _missing_deps()
    +    if final_missing:
    +        print(
    +            f"  ⚠ Still missing after repair: {', '.join(final_missing)}. "
    +            "Run `hermes update --force` after closing other hermes processes."
    +        )
    +    else:
    +        print("  ✓ All declared core dependencies now installed")
    +
    +
    +def _resolve_install_target_python(
    +    install_cmd_prefix: list[str], env: dict[str, str] | None
    +) -> Path | None:
    +    """Figure out which Python interpreter the install just targeted.
    +
    +    ``_install_python_dependencies_with_optional_fallback`` is called with
    +    either ``[uv, pip]`` (and a ``VIRTUAL_ENV`` env var pointing at the
    +    target venv) or ``[sys.executable, -m, pip]`` (the in-process Python).
    +    The verification step needs the *resulting* environment's Python so
    +    ``importlib.metadata`` queries the right site-packages.
    +    """
    +    if env and "VIRTUAL_ENV" in env:
    +        venv_root = Path(env["VIRTUAL_ENV"])
    +        scripts = venv_root / ("Scripts" if _is_windows() else "bin")
    +        candidate = scripts / ("python.exe" if _is_windows() else "python")
    +        if candidate.exists():
    +            return candidate
    +
    +    # Fallback: assume install_cmd_prefix[0] is the python interpreter (the
    +    # ``[sys.executable, -m, pip]`` shape). Skip if it looks like ``uv``.
    +    if install_cmd_prefix:
    +        first = Path(install_cmd_prefix[0])
    +        if first.exists() and "uv" not in first.name.lower():
    +            return first
    +
    +    return None
    +
     
     def _is_termux_env(env: dict[str, str] | None = None) -> bool:
         return _is_termux_startup_environment(env)
    ```
  - `hermes_cli/managed_uv.py`

    ```diff
    @@ -105,17 +105,40 @@ def rebuild_venv(uv_bin: str, venv_dir: Path, python_version: str = "3.11") -> b
         old venv may point to a Python without FTS5, so we rebuild it with a
         fresh interpreter from the current managed uv.  Returns ``True`` on
         success.
    +
    +    On Windows, ``shutil.rmtree(..., ignore_errors=True)`` can silently leave
    +    the venv directory partially intact when another process is holding an
    +    open handle to a file inside it (typical culprits: a running
    +    ``hermes.exe`` REPL, the gateway, AV scanners). If we don't notice that
    +    and just call ``uv venv``, uv refuses with
    +    ``Caused by: A directory already exists at: venv`` and the *whole
    +    update* falls back to installing on top of the stale venv — which has
    +    historically produced partial installs where a freshly added dependency
    +    (e.g. ``pathspec``) silently fails to land. Retry with ``--clear`` to
    +    force uv past that condition before giving up.
         """
         if venv_dir.exists():
             print(f"  → Rebuilding venv (old Python may lack FTS5)...")
             shutil.rmtree(venv_dir, ignore_errors=True)
     
    -    result = subprocess.run(
    -        [uv_bin, "venv", str(venv_dir), "--python", python_version],
    -        capture_output=True,
    -        text=True,
    -        check=False,
    -    )
    +    def _run_uv_venv(extra_args: list[str]) -> subprocess.CompletedProcess[str]:
    +        return subprocess.run(
    +            [uv_bin, "venv", str(venv_dir), "--python", python_version, *extra_args],
    +            capture_output=True,
    +            text=True,
    +            check=False,
    +        )
    +
    +    result = _run_uv_venv([])
    +
    +    # If uv refused because the directory still exists (rmtree above was
    +    # blocked by an open file handle, common on Windows), retry with
    +    # --clear so uv overwrites it. Match on stderr because uv's exit code
    +    # alone doesn't distinguish "dir exists" from real failures.
    +    if result.returncode != 0 and "already exists" in (result.stderr or "").lower():
    +        print("  → venv dir not fully removed (likely an open file handle); retrying with --clear...")
    +        result = _run_uv_venv(["--clear"])
    +
         if result.returncode == 0:
             venv_python = venv_dir / ("Scripts" if platform.system() == "Windows" else "bin") / "python"
             py_ver = subprocess.run(
    ```
  - `tests/hermes_cli/test_managed_uv.py`

    ```diff
    @@ -145,6 +145,85 @@ def test_rebuild_failure_returns_false(self, tmp_path):
                 result = rebuild_venv(uv_bin, venv_dir)
                 assert result is False
     
    +    def test_retries_with_clear_when_dir_already_exists(self, tmp_path):
    +        """On Windows, rmtree can silently fail when an open handle holds a
    +        file in the venv (running hermes.exe, gateway, AV scanner). uv then
    +        refuses with ``Caused by: A directory already exists at: venv``.
    +        Make sure we don't give up — retry with ``--clear`` to force uv past
    +        the stale directory and rebuild successfully."""
    +        venv_dir = tmp_path / "venv"
    +        venv_dir.mkdir()
    +        (venv_dir / "stale_open_handle").write_text("rmtree couldn't delete me")
    +
    +        uv_bin = str(tmp_path / "bin" / "uv")
    +        call_log: list[list[str]] = []
    +
    +        def fake_run(cmd, **kwargs):
    +            call_log.append(list(cmd))
    +            m = MagicMock()
    +            if cmd[1] == "venv" and "--clear" not in cmd:
    +                # First attempt: uv refuses because dir still exists
    +                m.returncode = 1
    +                m.stderr = (
    +                    "error: Failed to create virtual environment\n"
    +                    "  Caused by: A directory already exists at: venv\n"
    +                    "hint: Use the `--clear` flag or set `UV_VENV_CLEAR=1` to replace the existing directory\n"
    +                )
    +                m.stdout = ""
    +                return m
    +            if cmd[1] == "venv" and "--clear" in cmd:
    +                # Retry: succeeds. Simulate uv writing the python shim.
    +                m.returncode = 0
    +                m.stderr = ""
    +                m.stdout = ""
    +                bin_dir = venv_dir / ("Scripts" if os.name == "nt" else "bin")
    +                bin_dir.mkdir(parents=True, exist_ok=True)
    +                python_name = "python.exe" if os.name == "nt" else "python"
    +                (bin_dir / python_name).write_text("#!/bin/sh\necho Python 3.11.0")
    +                return m
    +            if "--version" in cmd:
    +                m.returncode = 0
    +                m.stdout = "Python 3.11.0"
    +                m.stderr = ""
    +                return m
    +            m.returncode = 0
    +            return m
    +
    +        with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run), \
    +             patch("hermes_cli.managed_uv.shutil.rmtree"):
    +            from hermes_cli.managed_uv import rebuild_venv
    +            result = rebuild_venv(uv_bin, venv_dir)
    +
    +        assert result is True, "rebuild should succeed after --clear retry"
    +        # We expect exactly two ``uv venv`` calls: one without --clear, one with.
    +        venv_calls = [c for c in call_log if len(c) >= 2 and c[1] == "venv"]
    +        assert len(venv_calls) == 2, f"expected 2 venv calls, got {venv_calls}"
    +        assert "--clear" not in venv_calls[0], "first call should not pass --clear"
    +        assert "--clear" in venv_calls[1], "retry must pass --clear"
    +
    +    def test_does_not_retry_when_first_failure_is_not_dir_exists(self, tmp_path):
    +        """If uv venv fails for some other reason (e.g. interpreter download
    +        failed, disk full), we should NOT silently retry with --clear —
    +        that would mask a real problem. Just surface the original failure."""
    +        venv_dir = tmp_path / "venv"
    +        uv_bin = str(tmp_path / "bin" / "uv")
    +        call_log: list[list[str]] = []
    +
    +        def fake_run(cmd, **kwargs):
    +            call_log.append(list(cmd))
    +            m = MagicMock(returncode=1, stderr="error: No space left on device", stdout="")
    +            return m
    +
    +        with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run), \
    +             patch("hermes_cli.managed_uv.shutil.rmtree"):
    +            from hermes_cli.managed_uv import rebuild_venv
    +            result = rebuild_venv(uv_bin, venv_dir)
    +
    +        assert result is False
    +        venv_calls = [c for c in call_log if len(c) >= 2 and c[1] == "venv"]
    +        assert len(venv_calls) == 1, "should not retry on non-dir-exists failures"
    +        assert "--clear" not in venv_calls[0]
    +
     
     # ---------------------------------------------------------------------------
     # update_managed_uv
    ```
  - `tests/hermes_cli/test_verify_core_dependencies.py`

    ```diff
    @@ -0,0 +1,223 @@
    +"""Tests for _verify_core_dependencies_installed.
    +
    +Regression coverage for the partial-install bug where uv's incremental
    +resolver silently failed to land ``pathspec`` (and similar newly-added
    +base deps) during ``hermes update``, leaving the venv in a broken state
    +that only surfaced hours later when a downstream subprocess imported the
    +missing module.
    +
    +The verification step:
    +  1. Reads pyproject.toml's [project.dependencies] directly.
    +  2. Filters by environment markers so cross-platform exclusions don't
    +     false-positive (e.g. ``ptyprocess ; sys_platform != 'win32'`` on Windows).
    +  3. Probes ``importlib.metadata.version()`` in the venv interpreter.
    +  4. Reinstalls with --reinstall, then per-package, if anything's missing.
    +"""
    +
    +from __future__ import annotations
    +
    +import subprocess
    +import textwrap
    +from pathlib import Path
    +from unittest.mock import MagicMock, patch
    +
    +import pytest
    +
    +
    [redacted:email]
    +def temp_pyproject(tmp_path, monkeypatch):
    +    """Point hermes_cli.main.PROJECT_ROOT at a tmp dir with a minimal pyproject.
    +
    +    The verification helper opens ``PROJECT_ROOT / 'pyproject.toml'`` directly;
    +    redirecting PROJECT_ROOT keeps the test hermetic.
    +    """
    +    pyproject = tmp_path / "pyproject.toml"
    +    pyproject.write_text(textwrap.dedent("""\
    +        [project]
    +        name = "fake"
    +        version = "0.0.0"
    +        dependencies = [
    +          "pathspec==1.1.1",
    +          "pydantic==2.13.4",
    +          "ptyprocess>=0.7.0,<1; sys_platform != 'win32'",
    +        ]
    +    """))
    +    import hermes_cli.main as main_mod
    +    monkeypatch.setattr(main_mod, "PROJECT_ROOT", tmp_path)
    +    return tmp_path
    +
    +
    [redacted:email]
    +def fake_venv_python(tmp_path):
    +    """Create a fake venv python shim path that exists on disk."""
    +    venv_root = tmp_path / "venv"
    +    scripts = venv_root / "Scripts"
    +    scripts.mkdir(parents=True)
    +    py = scripts / "python.exe"
    +    py.write_text("#!/bin/sh\necho fake python")
    +    return py, venv_root
    +
    +
    +class TestVerifyCoreDependencies:
    +    def test_no_action_when_all_deps_present(self, temp_pyproject, fake_venv_python):
    +        """The happy path: nothing missing, no repair install fires."""
    +        py, venv_root = fake_venv_python
    +        env = {"VIRTUAL_ENV": str(venv_root)}
    +
    +        with patch("hermes_cli.main._resolve_install_target_python", return_value=py), \
    +             patch("hermes_cli.main.subprocess.run") as mock_run, \
    +             patch("hermes_cli.main._run_install_with_heartbeat") as mock_install:
    +            mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
    +
    +            from hermes_cli.main import _verify_core_dependencies_installed
    +            _verify_core_dependencies_installed(["uv", "pip"], env=env)
    +
    +            # Probe ran, repair install did not.
    +            assert mock_run.called, "verification probe should have run"
    +            assert not mock_install.called, "repair install should not fire when nothing is missing"
    +
    +    def test_triggers_reinstall_when_dep_missing(self, temp_pyproject, fake_venv_python):
    +        """The regression: one base dep is missing → trigger --reinstall."""
    +        py, venv_root = fake_venv_python
    +        env = {"VIRTUAL_ENV": str(venv_root)}
    +
    +        # First probe reports pathspec missing; after repair, probe is clean.
    +        probe_calls = {"count": 0}
    +
    +        def fake_subprocess_run(cmd, **kwargs):
    +            # The probe subprocess returns stdout = newline-joined missing names.
    +            probe_calls["count"] += 1
    +            if probe_calls["count"] == 1:
    +                return MagicMock(returncode=0, stdout="pathspec\n", stderr="")
    +            return MagicMock(returncode=0, stdout="", stderr="")
    +
    +        with patch("hermes_cli.main._resolve_install_target_python", return_value=py), \
    +             patch("hermes_cli.main.subprocess.run", side_effect=fake_subprocess_run), \
    +             patch("hermes_cli.main._run_install_with_heartbeat") as mock_install:
    +
    +            from hermes_cli.main import _verify_core_dependencies_installed
    +            _verify_core_dependencies_installed(["uv", "pip"], env=env)
    +
    +            assert mock_install.called, "repair install must fire when a dep is missing"
    +            # First repair must use --reinstall to force re-resolution.
    +            first_repair = mock_install.call_args_list[0]
    +            args = first_repair[0][0]  # positional: install_cmd
    +            assert "--reinstall" in args, f"repair install should pass --reinstall, got {args}"
    +            assert "-e" in args and "." in args, "first repair should be base group reinstall"
    +
    +    def test_falls_back_to_per_package_install_when_reinstall_did_not_help(
    +        self, temp_pyproject, fake_venv_python
    +    ):
    +        """If --reinstall doesn't repair the partial install (uv resolver
    +        thinks the env is satisfied), force-install each missing dep with
    +        its declared pin spec. This is the last-ditch path."""
    +        py, venv_root = fake_venv_python
    +        env = {"VIRTUAL_ENV": str(venv_root)}
    +
    +        probe_calls = {"count": 0}
    +
    +        def fake_subprocess_run(cmd, **kwargs):
    +            probe_calls["count"] += 1
    +            # 1st probe: pathspec missing
    +            # 2nd probe (after --reinstall): still missing
    +            # 3rd probe (after per-package): clean
    +            if probe_calls["count"] in (1, 2):
    +                return MagicMock(returncode=0, stdout="pathspec\n", stderr="")
    +            return MagicMock(returncode=0, stdout="", stderr="")
    +
    +        with patch("hermes_cli.main._resolve_install_target_python", return_value=py), \
    +             patch("hermes_cli.main.subprocess.run", side_effect=fake_subprocess_run), \
    +             patch("hermes_cli.main._run_install_with_heartbeat") as mock_install:
    +
    +            from hermes_cli.main import _verify_core_dependencies_installed
    +            _verify_core_dependencies_installed(["uv", "pip"], env=env)
    +
    +            assert mock_install.call_count >= 2, (
    +                "expected at least 2 repair installs (reinstall + per-package), "
    +                f"got {mock_install.call_count}"
    +            )
    +            # Second repair call should pass the pinned spec for the missing dep.
    +            second_repair_args = mock_install.call_args_list[1][0][0]
    +            assert any("pathspec==1.1.1" in str(a) for a in second_repair_args), (
    +                f"second repair should pin pathspec from pyproject; got {second_repair_args}"
    +            )
    +
    +    def test_skips_deps_excluded_by_environment_markers(self, temp_pyproject, fake_venv_python):
    +        """``ptyprocess ; sys_platform != 'win32'`` should NOT be reported as
    +        missing on Windows. Without marker evaluation, the verification step
    +        would false-positive on every cross-platform exclusion and chase its
    +        tail forever trying to install something that can't apply here."""
    +        py, venv_root = fake_venv_python
    +        env = {"VIRTUAL_ENV": str(venv_root)}
    +        captured_argv: list[list[str]] = []
    +
    +        def fake_subprocess_run(cmd, **kwargs):
    +            captured_argv.append(list(cmd))
    +            return MagicMock(returncode=0, stdout="", stderr="")
    +
    +        # Force sys.platform to look like Windows so the marker filters
    +        # ptyprocess out. (We need the actual marker.evaluate() to see win32.)
    +        with patch("hermes_cli.main._resolve_install_target_python", return_value=py), \
    +             patch("hermes_cli.main.subprocess.run", side_effect=fake_subprocess_run), \
    +             patch("hermes_cli.main._run_install_with_heartbeat"), \
    +             patch("sys.platform", "win32"):
    +
    +            from hermes_cli.main import _verify_core_dependencies_installed
    +            _verify_core_dependencies_installed(["uv", "pip"], env=env)
    +
    +        # Find the probe argv — it's the call that passed the dep names.
    +        probe = next(
    +            (argv for argv in captured_argv if any("importlib.metadata" in str(a) for a in argv)),
    +            None,
    +        )
    +        assert probe is not None, "verification probe should have run"
    +        # The dep names are tacked on after the -c script.
    +        assert "ptyprocess" not in probe, (
    +            "ptyprocess is gated by sys_platform != 'win32' and must be filtered "
    +            f"out on Windows; full probe argv was: {probe}"
    +        )
    +        assert "pathspec" in probe, "core deps without markers must be checked"
    +
    +    def test_no_pyproject_is_noop(self, tmp_path, monkeypatch):
    +        """If pyproject.toml is missing (unusual but possible in some test
    +        envs), the verification step must short-circuit, not crash."""
    +        import hermes_cli.main as main_mod
    +        monkeypatch.setattr(main_mod, "PROJECT_ROOT", tmp_path)
    +        # No pyproject.toml in tmp_path.
    +        with patch("hermes_cli.main._resolve_install_target_python") as mock_resolve, \
    +             patch("hermes_cli.main._run_install_with_heartbeat") as mock_install:
    +            from hermes_cli.main import _verify_core_dependencies_installed
    +            _verify_core_dependencies_installed(["uv", "pip"], env={})
    +            assert not mock_resolve.called
    +            assert not mock_install.called
    +
    +
    +class TestResolveInstallTargetPython:
    +    def test_uses_virtual_env_from_environment(self, tmp_path):
    +        """When VIRTUAL_ENV is set, the verification step must probe THAT
    +        venv's interpreter — not the outer Python that drove `hermes update`.
    +        If we probed sys.executable instead, we'd false-positive every dep
    +        the outer interpreter happens to lack."""
    +        venv_root = tmp_path / "newvenv"
    +        scripts = venv_root / "Scripts"
    +        scripts.mkdir(parents=True)
    +        py = scripts / "python.exe"
    +        py.write_text("fake")
    +
    +        with patch("hermes_cli.main._is_windows", return_value=True):
    +            from hermes_cli.main import _resolve_install_target_python
    +            result = _resolve_install_target_python(
    +                ["uv", "pip"], env={"VIRTUAL_ENV": str(venv_root)}
    +            )
    +            assert result == py
    +
    +    def test_returns_none_when_venv_python_missing(self, tmp_path):
    +        """If the path we'd point at doesn't exist (uv install failed before
    +        the python shim landed), return None so the verification step
    +        cleanly short-circuits instead of crashing on FileNotFoundError."""
    +        with patch("hermes_cli.main._is_windows", return_value=True):
    +            from hermes_cli.main import _resolve_install_target_python
    +            result = _resolve_install_target_python(
    +                ["uv", "pip"], env={"VIRTUAL_ENV": str(tmp_path / "does_not_exist")}
    +            )
    +            assert result is None
    ```
- **#39044: fix(cli): pass --clear to uv venv in rebuild_venv to avoid Windows brick**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `c136eb4de`, `#37881`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#32438: fix(install): auto-recover install.sh update path from stale remote-tracking refs (#32384)**. closed without merge
  - maintainer direction: category `stale_abandoned`; implication `active`
- **#38797: fix(cli): use os.replace to safely rebuild venv when hermes.exe is running (#37881)**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#38887`

## Fix status

- Merged to main in PR #33803 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-04`.
- Subsystem: `cron/scripts`
