diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index c6155f7a7..59b25f78d 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -11,7 +11,7 @@ "source": "git-subdir", "url": "https://github.com/Hellblazer/nexus.git", "path": "conexus", - "ref": "v7.36.1" + "ref": "plugin-v7.36.1-1" }, "description": "Self-hosted three-tier knowledge management with 13 specialized agents, plan-centric retrieval via nx_answer, semantic search, and RDR decision tracking for Claude Code.", "version": "7.36.1" diff --git a/conexus/PENDING_RELEASE.md b/conexus/PENDING_RELEASE.md index 26465cbd5..c0e8eda46 100644 --- a/conexus/PENDING_RELEASE.md +++ b/conexus/PENDING_RELEASE.md @@ -31,4 +31,3 @@ mechanize, it matters enough to ship. ## Awaiting the next release or plugin cut (pinned: v7.36.1) -(none) diff --git a/conexus/hooks/scripts/_run_python_hook.sh b/conexus/hooks/scripts/_run_python_hook.sh index a62db4629..6c4947a55 100755 --- a/conexus/hooks/scripts/_run_python_hook.sh +++ b/conexus/hooks/scripts/_run_python_hook.sh @@ -1,32 +1,49 @@ #!/usr/bin/env bash -# SPDX-License-Identifier: AGPL-3.0-or-later +# Run a Python hook script under an interpreter that can serve it. # -# Wrapper that picks a Python interpreter satisfying the plugin's >=3.12 -# requirement and execs the given hook script under it. -# -# Probes higher versions first so a system that has python3.13 on PATH -# uses it even when unqualified `python3` resolves to something older -# (the common macOS case where /Library/Frameworks/Python.framework -# wins PATH precedence over /opt/homebrew/bin). Falls back to plain -# `python3` last — if that happens to be too old, the hook script's own -# `sys.version_info < (3, 12)` guard surfaces a clean actionable error -# rather than the worse parser-failure modes we'd see otherwise. -# -# Usage: -# bash _run_python_hook.sh /abs/path/to/hook_script.py [args...] - +# Order: +# 0. $NX_HOOK_PYTHON when set and runnable (an explicit choice, for a dev +# box or a test), then an active $VIRTUAL_ENV whose python imports +# nexus (a developer's checkout venv must win over the installed +# generation, or every nexus-importing hook silently reads production +# while the developer edits the tree; critique [24988]). +# 1. The installed conexus generation's own python +# (/current/bin/python; is $NX_TOOLS_DIR or +# ~/.local/share/nexus/tools). It is the only interpreter on a box that +# is guaranteed to import `nexus` and its dependencies, which the hooks +# that ask the catalog or T3 a question (rdr_hook.py, +# phase_review_close_requires_gate.py) need. Measured 2026-09-08 +# (nexus-owna8's follow-up): under a bare Homebrew python3.13 those +# imports fail, the failure cannot even be logged (structlog is missing +# too), and the rdr hook reported a fully indexed tree as NOT indexed on +# every session start while its own tests passed in the dev venv. +# 2. python3.13, then python3.12 by name, so a macOS framework python3 +# (3.10) that wins PATH precedence does not run the hook. +# 3. Plain python3, so the hook's own version guard can print its error. set -u - -# Order matches conexus's supported Python range (>=3.12,<3.14 in -# pyproject.toml). If conexus widens that range, add new versions here. +if [ -n "${NX_HOOK_PYTHON:-}" ] && [ -x "$NX_HOOK_PYTHON" ] && "$NX_HOOK_PYTHON" -c '' >/dev/null 2>&1; then + exec "$NX_HOOK_PYTHON" "$@" +fi +# The venv wins only when its nexus is THIS checkout's (an editable install +# under the cwd): a stale VIRTUAL_ENV from another worktree, or a venv with +# a packaged nexus, falls through to the generation instead of silently +# reading a different tree. +venv_py="${VIRTUAL_ENV:-}/bin/python" +if [ -n "${VIRTUAL_ENV:-}" ] && [ -x "$venv_py" ] \ + && "$venv_py" -c 'import os, sys, nexus; sys.exit(0 if os.path.realpath(nexus.__file__).startswith(os.path.realpath(os.getcwd()) + os.sep) else 1)' >/dev/null 2>&1; then + exec "$venv_py" "$@" +fi +# ${HOME:-} so a hook launched with no HOME (a scrubbed env) falls through +# instead of dying on `set -u`; the run check so a partially reaped or +# wrong-arch generation python falls through instead of exec failing. +tools="${NX_TOOLS_DIR:-${HOME:-}/.local/share/nexus/tools}" +gen_py="$tools/current/bin/python" +if [ -x "$gen_py" ] && "$gen_py" -c '' >/dev/null 2>&1; then + exec "$gen_py" "$@" +fi for py in python3.13 python3.12; do if command -v "$py" >/dev/null 2>&1; then exec "$py" "$@" fi done - -# Last resort: plain python3. Hook script's own version guard handles -# the too-old case loudly. If python3 happens to be 3.14+, the hook -# itself runs fine (hooks are stdlib-only) but downstream `nx ...` -# subprocess calls will fail since conexus isn't installable there. exec python3 "$@" diff --git a/conexus/hooks/scripts/rdr_hook.py b/conexus/hooks/scripts/rdr_hook.py index 6c1b02618..8f0bd4ae4 100755 --- a/conexus/hooks/scripts/rdr_hook.py +++ b/conexus/hooks/scripts/rdr_hook.py @@ -32,6 +32,8 @@ ) sys.exit(1) +import datetime as _dt +import os import re import subprocess from concurrent.futures import ThreadPoolExecutor @@ -100,11 +102,54 @@ def _resolve_rdr_collection(repo_root: Path) -> str | None: return None +#: Every resolution failure this run, oldest first: the NOT-indexed verdict +#: names them on stdout, the only channel a session sees from an exit-0 +#: SessionStart hook (nexus-4ti7e; stderr needs `claude --debug`). +_RESOLUTION_FAILURES: list[str] = [] + + +def _hook_log_path() -> Path: + """Durable failure log beside the lockstep hook's, honouring + NEXUS_CONFIG_DIR: ``/rdr_hook.log`` (override: NX_RDR_HOOK_LOG).""" + override = os.environ.get("NX_RDR_HOOK_LOG") + if override: + return Path(override) + cfg = os.environ.get("NEXUS_CONFIG_DIR") or str(Path.home() / ".config" / "nexus") + return Path(cfg) / "rdr_hook.log" + + def _log_resolution_error(source: str, exc: BaseException) -> None: """nexus-owna8: a blind except here forced every session onto the path-derived fallback, whose owner id can differ from the catalog's, and the hook then reported a fully indexed tree as NOT indexed. The failure - is logged so the next false verdict names its cause.""" + is recorded for the verdict line, appended to a durable log, written to + stderr, and only then handed to structlog (nexus-4ti7e: the interpreter + that ran this hook on 2026-09-08 had neither nexus nor structlog, and an + exit-0 SessionStart hook's stderr is never shown).""" + detail = str(exc) + if len(detail) > 200: + detail = detail[:200] + "..." + line = f"{source}: {type(exc).__name__}: {detail} [python {sys.executable}]" + _RESOLUTION_FAILURES.append(line) + try: + path = _hook_log_path() + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as fh: + fh.write(f"{_dt.datetime.now(_dt.timezone.utc).isoformat()} resolution failed {line}\n") + except Exception: # noqa: BLE001 — the log is best-effort in a hook + pass + # stderr FIRST, with nothing but sys: on 2026-09-08 the interpreter that + # ran this hook had neither nexus nor structlog, so the structlog line + # below could not be written and the false NOT-indexed verdict shipped + # with an empty stderr (nexus-4ti7e). A guard that needs the package it + # guards is no guard. + try: + sys.stderr.write( + f"rdr_hook: collection resolution failed ({source}): " + f"{type(exc).__name__}: {exc} [python {sys.executable}]\n" + ) + except Exception: # noqa: BLE001 — even stderr is best-effort in a hook + pass try: import structlog # noqa: PLC0415 @@ -346,10 +391,14 @@ def main() -> None: # single-file ``nx index rdr `` now lands there too, but the # whole-tree remedy is the repo index. print(f"RDR: {status_info} in {rdr_dir.relative_to(root)} but NOT indexed.") - if rdr_collection: - print(f" Run: nx index repo {root}") - else: - print(f" Run: nx index repo {root}") + if _RESOLUTION_FAILURES: + # The verdict may be the hook's own failure, not the tree's state. + try: + where = f"; log: {_hook_log_path()}" + except Exception: # noqa: BLE001 — Path.home() can raise in a scrubbed container + where = "" + print(f" (resolution failed: {'; '.join(_RESOLUTION_FAILURES)}{where})") + print(f" Run: nx index repo {root}") for line in _unchecked_fix_edits(root, rdr_files, statuses, _load_gated_commits(repo_name)): print(line) diff --git a/conexus/hooks/scripts/version_lockstep_hook.py b/conexus/hooks/scripts/version_lockstep_hook.py index d8e77cada..3be2ae6ca 100755 --- a/conexus/hooks/scripts/version_lockstep_hook.py +++ b/conexus/hooks/scripts/version_lockstep_hook.py @@ -13,9 +13,11 @@ (the action owns that, on confirmed upgrade only), and NEVER raises (fail-safe exit 0). -Stdlib-only: this runs under whichever bare interpreter -``_run_python_hook.sh`` resolves, which on a ``uv tool install conexus`` -deployment cannot import the ``conexus`` package (same constraint as +Stdlib-only: this runs under whichever interpreter ``_run_python_hook.sh`` +resolves. Since nexus-4ti7e that is the installed generation's python when +one exists, but a ``uv tool install conexus`` deployment or a box with no +generation still gets a bare python that cannot import the ``conexus`` +package, so the hook stays stdlib-only (same constraint as ``t2_prefix_scan.py`` / ``preflight.py``). """ from __future__ import annotations