Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 0 additions & 1 deletion conexus/PENDING_RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,3 @@ mechanize, it matters enough to ship.

## Awaiting the next release or plugin cut (pinned: v7.36.1)

(none)
63 changes: 40 additions & 23 deletions conexus/hooks/scripts/_run_python_hook.sh
Original file line number Diff line number Diff line change
@@ -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
# (<tools>/current/bin/python; <tools> 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 "$@"
59 changes: 54 additions & 5 deletions conexus/hooks/scripts/rdr_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
)
sys.exit(1)

import datetime as _dt
import os
import re
import subprocess
from concurrent.futures import ThreadPoolExecutor
Expand Down Expand Up @@ -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: ``<config>/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

Expand Down Expand Up @@ -346,10 +391,14 @@ def main() -> None:
# single-file ``nx index rdr <file>`` 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)
Expand Down
8 changes: 5 additions & 3 deletions conexus/hooks/scripts/version_lockstep_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down