#!/bin/bash
# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc.
# SPDX-License-Identifier: MIT

# deploy-local-plugin: Sync local plugin to Claude Code cache
#
# Usage:
#   ./deploy.sh [--execute] [--bump-version] [--ref <tree-ish>] [--source-path <path>]
#   ./deploy.sh --repair-venv
#
# Default: Dry run, deploys from canonical bare clone (main) to same version (no bump)
# --execute: Actually perform deployment (sync files + build venv)
# --bump-version: Increment patch version before deploying
# --ref: Deploy from a specific branch, tag, or commit SHA (default: main)
# --source-path: Explicit worktree/checkout path override
# --repair-venv: Build lib/.venv in the currently-active deployed version (no file sync, no version bump)
#                Use this when hooks fail with "No valid Python found" after a deploy.
#
# Source resolution order:
#   1. --source-path / DEPLOY_SOURCE_PATH  (explicit override)
#   2. Canonical bare clone via git archive (deterministic, default)
#   3. CLAUDE_PLUGIN_ROOT                  (legacy fallback, warns)
#   4. Hard error
# Deploys to plugin cache ONLY. Skills/commands/agents discovered via plugin installPath.

set -euo pipefail

# Check required dependencies
# rsync is only required for full deploys, not for --repair-venv
_NEED_RSYNC=true
for arg in "$@"; do [[ "$arg" == "--repair-venv" ]] && { _NEED_RSYNC=false; break; }; done

for cmd in jq; do
    if ! command -v "$cmd" &>/dev/null; then
        echo "Error: Required command '$cmd' not found"
        exit 1
    fi
done
if [[ "$_NEED_RSYNC" == "true" ]] && ! command -v rsync &>/dev/null; then
    echo "Error: Required command 'rsync' not found"
    exit 1
fi
unset _NEED_RSYNC

# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color

# =============================================================================
# Venv Verification Helper (OMN-3729)
# =============================================================================
# Reusable guard for any script that copies files near or inside the venv.
# Returns 0 when the venv looks healthy, 1 with a warning on stderr otherwise.
#
# Usage:
#   verify_venv_or_warn "/path/to/.venv"  || return 1

verify_venv_or_warn() {
    local venv_dir="$1"
    if [[ ! -f "${venv_dir}/bin/python3" || ! -x "${venv_dir}/bin/python3" ]]; then
        echo "WARN: Venv missing or broken at ${venv_dir}. Run: deploy.sh --repair-venv" 1>&2
        return 1
    fi
    return 0
}

# Parse arguments
EXECUTE=false
NO_VERSION_BUMP=true
BUMP_VERSION=false
REPAIR_VENV=false
LEVEL_FILTER="advanced"   # default: no filtering (advanced includes all)
INCLUDE_DEBUG=false
_LEVEL_EXPLICIT=false     # track whether --level was passed explicitly
DEPLOY_REF=""             # --ref <tree-ish>: deploy from specific branch/tag/SHA
EXPLICIT_SOURCE_PATH=""   # --source-path <path>: explicit worktree/checkout override

while [[ $# -gt 0 ]]; do
    arg="$1"
    case $arg in
        --execute)
            EXECUTE=true
            ;;
        --no-version-bump)
            echo -e "${YELLOW}Warning: --no-version-bump is deprecated; no-bump is now the default. Use --bump-version to increment.${NC}" >&2
            NO_VERSION_BUMP=true
            ;;
        --bump-version)
            NO_VERSION_BUMP=false
            BUMP_VERSION=true
            ;;
        --repair-venv)
            REPAIR_VENV=true
            ;;
        --level=*)
            LEVEL_FILTER="${arg#--level=}"
            _LEVEL_EXPLICIT=true
            ;;
        --level)
            if [[ $# -lt 2 ]]; then
                echo -e "${RED}Error: --level requires a value: basic | intermediate | advanced${NC}" >&2
                exit 1
            fi
            LEVEL_FILTER="$2"
            _LEVEL_EXPLICIT=true
            shift
            ;;
        --include-debug)
            INCLUDE_DEBUG=true
            ;;
        --ref=*)
            DEPLOY_REF="${arg#--ref=}"
            ;;
        --ref)
            if [[ $# -lt 2 ]]; then
                echo -e "${RED}Error: --ref requires a value (branch, tag, or commit SHA)${NC}" >&2
                exit 1
            fi
            DEPLOY_REF="$2"
            shift
            ;;
        --source-path=*)
            EXPLICIT_SOURCE_PATH="${arg#--source-path=}"
            ;;
        --source-path)
            if [[ $# -lt 2 ]]; then
                echo -e "${RED}Error: --source-path requires a directory path${NC}" >&2
                exit 1
            fi
            EXPLICIT_SOURCE_PATH="$2"
            shift
            ;;
        --help|-h)
            echo "Usage: deploy.sh [--execute] [--bump-version] [--ref <tree-ish>] [--source-path <path>]"
            echo "       deploy.sh [--level basic|intermediate|advanced] [--include-debug]"
            echo "       deploy.sh --repair-venv"
            echo ""
            echo "Options:"
            echo "  --execute                  Actually perform deployment (default: dry run)"
            echo "  --bump-version             Increment patch version (default: deploy to same version)"
            echo "  --ref <tree-ish>           Deploy from a specific branch, tag, or commit SHA."
            echo "                             Default: main. Only used with archive-based deploys."
            echo "  --source-path <path>       Explicit worktree/checkout path override. Bypasses"
            echo "                             archive-based resolution. Path must contain plugins/onex/."
            echo "  --level basic|intermediate|advanced"
            echo "                             Filter skills by tier (inclusive downward):"
            echo "                               basic       → only level: basic skills"
            echo "                               intermediate → level: basic + intermediate"
            echo "                               advanced    → all skills (default, no filtering)"
            echo "  --include-debug            Include skills with debug: true (excluded by default"
            echo "                             when --level is specified below advanced)"
            echo "  --repair-venv              Build lib/.venv in the active deployed version without a full redeploy."
            echo "                             Use when hooks fail with 'No valid Python found' after a deploy."
            echo "  --help                     Show this help message"
            exit 0
            ;;
        *)
            echo "Unknown argument: $arg"
            echo "Use --help for usage"
            exit 1
            ;;
    esac
    shift
done

# Validate --level value
case "$LEVEL_FILTER" in
    basic|intermediate|advanced) ;;
    *)
        echo -e "${RED}Error: --level must be one of: basic | intermediate | advanced (got: '${LEVEL_FILTER}')${NC}" >&2
        exit 1
        ;;
esac

# Guard: --ref and --source-path are mutually exclusive.
# --ref selects a git ref for archive-based deploys.
# --source-path bypasses archive resolution entirely.
if [[ -n "$DEPLOY_REF" && -n "$EXPLICIT_SOURCE_PATH" ]]; then
    echo -e "${RED}Error: --ref and --source-path are mutually exclusive.${NC}" >&2
    echo "  --ref deploys from the canonical repo via git archive." >&2
    echo "  --source-path deploys from an explicit checkout path." >&2
    exit 1
fi

# Guard: --no-version-bump and --bump-version are mutually exclusive.
# Since --no-version-bump sets NO_VERSION_BUMP=true and --bump-version sets
# BUMP_VERSION=true, detect the conflict explicitly rather than relying on
# argument order (last-writer-wins is confusing and unpredictable).
if [[ "$NO_VERSION_BUMP" == "true" && "$BUMP_VERSION" == "true" ]]; then
    echo -e "${RED}Error: --no-version-bump and --bump-version are mutually exclusive.${NC}" >&2
    exit 1
fi

# =============================================================================
# Source Resolution
# =============================================================================
# Priority:
#   1. --source-path <path>     → explicit worktree/checkout override (deliberate)
#   2. DEPLOY_SOURCE_PATH env   → same as above, env form
#   3. Canonical bare clone     → git archive into ephemeral staging dir
#   4. CLAUDE_PLUGIN_ROOT       → only if outside cache AND no canonical repo found
#   5. Hard error               → refuse to deploy with ambiguous source
#
# known_marketplaces.json is NOT used for source resolution. It is updated
# during deploy for Claude Code's plugin loader, but never drives source selection.
# This prevents silent fallback to arbitrary registered worktrees.
# =============================================================================

CACHE_PAT="$HOME/.claude/plugins/cache"

# Default canonical repo location. Override via OMNI_HOME env var.
OMNI_HOME="${OMNI_HOME:-/Volumes/PRO-G40/Code/omni_home}"  # local-path-ok: default, overridable via env
CANONICAL_REPO="${OMNI_HOME}/omniclaude"

# Provenance fields — set during source resolution, logged during deploy
DEPLOY_MODE=""           # "archive", "explicit-path", "plugin-root"
DEPLOY_REPO=""           # canonical repo path (archive mode)
DEPLOY_SHA=""            # concrete commit SHA
STAGING_DIR=""           # ephemeral staging dir (archive mode only)

_verify_venv_integrity() {
    # Post-deploy sanity check: ensure the venv survived all deploy operations.
    # Current rsync targets don't overlap with lib/.venv, but this catches future
    # regressions where deploy steps might accidentally destroy the venv.
    local venv_dir="$1"
    [[ -f "${venv_dir}/bin/python3" && -x "${venv_dir}/bin/python3" ]]
}

_cleanup_staging() {
    if [[ -n "${STAGING_DIR:-}" && -d "${STAGING_DIR}" ]]; then
        rm -rf "$STAGING_DIR"
    fi
}

_stage_from_archive() {
    # Extract plugin files + build context from a git repo (bare or checked out)
    # via git archive into an ephemeral staging directory.
    #
    # Sets: SOURCE_ROOT, STAGING_DIR, DEPLOY_REPO, DEPLOY_REF, DEPLOY_SHA, DEPLOY_MODE
    local repo_path="$1"
    local ref="${2:-main}"

    # Resolve ref to concrete SHA
    local commit_sha
    commit_sha="$(git -C "$repo_path" rev-parse --verify "$ref" 2>/dev/null)" || {
        echo -e "${RED}Error: ref '${ref}' does not exist in ${repo_path}${NC}" >&2
        return 1
    }

    # Verify plugins/onex exists at this ref
    git -C "$repo_path" cat-file -e "${commit_sha}:plugins/onex/.claude-plugin/plugin.json" 2>/dev/null || {
        echo -e "${RED}Error: plugins/onex/.claude-plugin/plugin.json not found at ref ${ref} (${commit_sha:0:12})${NC}" >&2
        return 1
    }

    # Create ephemeral staging dir
    STAGING_DIR="$(mktemp -d "${TMPDIR:-/tmp}/onex-deploy-XXXXXXXX")"

    # Extract plugin files + build context (pyproject.toml, uv.lock, src/, marketplace.json)
    # needed for uv sync during venv build. Mode bits (executables) are preserved by tar.
    git -C "$repo_path" archive --format=tar "$commit_sha" -- \
        plugins/onex/ \
        pyproject.toml \
        uv.lock \
        src/ \
        .claude-plugin/marketplace.json \
        2>/dev/null \
        | tar -x -C "$STAGING_DIR"

    # SOURCE_ROOT points at the extracted plugin directory
    SOURCE_ROOT="${STAGING_DIR}/plugins/onex"

    # Record provenance
    DEPLOY_REPO="$repo_path"
    DEPLOY_REF="$ref"
    DEPLOY_SHA="$commit_sha"
    DEPLOY_MODE="archive"
}

# --- Source resolution ---

if [[ -n "$EXPLICIT_SOURCE_PATH" ]]; then
    # Tier 1: Explicit --source-path override
    EXPLICIT_SOURCE_PATH="${EXPLICIT_SOURCE_PATH/#\~/$HOME}"  # expand tilde
    if [[ -f "${EXPLICIT_SOURCE_PATH}/plugins/onex/.claude-plugin/plugin.json" ]]; then
        SOURCE_ROOT="${EXPLICIT_SOURCE_PATH}/plugins/onex"
    elif [[ -f "${EXPLICIT_SOURCE_PATH}/.claude-plugin/plugin.json" ]]; then
        SOURCE_ROOT="$EXPLICIT_SOURCE_PATH"
    else
        echo -e "${RED}Error: --source-path does not contain a valid plugin structure.${NC}" >&2
        echo "  Looked for: ${EXPLICIT_SOURCE_PATH}/plugins/onex/.claude-plugin/plugin.json" >&2
        echo "  Also tried: ${EXPLICIT_SOURCE_PATH}/.claude-plugin/plugin.json" >&2
        exit 1
    fi
    DEPLOY_MODE="explicit-path"
    DEPLOY_SHA="$(git -C "$SOURCE_ROOT" rev-parse HEAD 2>/dev/null || echo 'unknown')"

elif [[ -n "${DEPLOY_SOURCE_PATH:-}" ]]; then
    # Tier 2: DEPLOY_SOURCE_PATH env var (same as --source-path)
    DEPLOY_SOURCE_PATH="${DEPLOY_SOURCE_PATH/#\~/$HOME}"
    if [[ -f "${DEPLOY_SOURCE_PATH}/plugins/onex/.claude-plugin/plugin.json" ]]; then
        SOURCE_ROOT="${DEPLOY_SOURCE_PATH}/plugins/onex"
    elif [[ -f "${DEPLOY_SOURCE_PATH}/.claude-plugin/plugin.json" ]]; then
        SOURCE_ROOT="$DEPLOY_SOURCE_PATH"
    else
        echo -e "${RED}Error: DEPLOY_SOURCE_PATH does not contain a valid plugin structure.${NC}" >&2
        exit 1
    fi
    DEPLOY_MODE="explicit-path"
    DEPLOY_SHA="$(git -C "$SOURCE_ROOT" rev-parse HEAD 2>/dev/null || echo 'unknown')"

elif [[ -d "$CANONICAL_REPO" ]] && git -C "$CANONICAL_REPO" rev-parse --git-dir &>/dev/null; then
    # Tier 3: Canonical bare clone — archive-based deploy
    _REF="${DEPLOY_REF:-main}"
    if ! _stage_from_archive "$CANONICAL_REPO" "$_REF"; then
        echo -e "${RED}Error: Failed to stage files from canonical repo at ${CANONICAL_REPO}${NC}" >&2
        exit 1
    fi
    # Register cleanup trap (composes with existing venv trap later)
    trap _cleanup_staging EXIT

elif [[ -n "${CLAUDE_PLUGIN_ROOT:-}" && "$CLAUDE_PLUGIN_ROOT" != "$CACHE_PAT"* ]]; then
    # Tier 4: CLAUDE_PLUGIN_ROOT points to a real dev checkout (legacy fallback)
    echo -e "${YELLOW}Warning: Falling back to CLAUDE_PLUGIN_ROOT. This is a legacy resolution path.${NC}" >&2
    echo -e "${YELLOW}  Prefer deploying from the canonical repo or use --source-path.${NC}" >&2
    SOURCE_ROOT="$CLAUDE_PLUGIN_ROOT"
    DEPLOY_MODE="plugin-root"
    DEPLOY_SHA="$(git -C "$SOURCE_ROOT" rev-parse HEAD 2>/dev/null || echo 'unknown')"

else
    # All resolution tiers failed.
    echo -e "${RED}Error: Could not find the omniclaude repository.${NC}" >&2
    echo "" >&2
    echo "The deploy script needs a source directory (the repo) to sync from." >&2
    echo "Tried:" >&2
    echo "  1. --source-path flag (not provided)" >&2
    echo "  2. DEPLOY_SOURCE_PATH env var (not set)" >&2
    echo "  3. Canonical repo at ${CANONICAL_REPO} (not found or not a git repo)" >&2
    echo "  4. CLAUDE_PLUGIN_ROOT env var (not set, or points to cache)" >&2
    echo "" >&2
    echo "Fix: use --source-path to point at your omniclaude checkout:" >&2
    echo "  $0 --source-path /path/to/omniclaude --execute" >&2
    echo "" >&2
    echo "Or set OMNI_HOME to your omni_home directory:" >&2
    echo "  OMNI_HOME=/path/to/omni_home $0 --execute" >&2
    exit 1
fi

PLUGIN_JSON="${SOURCE_ROOT}/.claude-plugin/plugin.json"
CACHE_BASE="$HOME/.claude/plugins/cache/omninode-tools/onex"
REGISTRY="$HOME/.claude/plugins/installed_plugins.json"

# =============================================================================
# --repair-venv: Provision lib/.venv in the active deployed version.
# =============================================================================
# This mode is used when hooks fail with "No valid Python found" because lib/.venv
# was not built — for example, if the cache dir was populated by rsync/git-clone
# instead of a full deploy.sh --execute run, or if the venv build was interrupted.
#
# Steps:
#   1. Read installPath from installed_plugins.json (the live cache version)
#   2. Resolve PROJECT_ROOT from this repo (for pip install source)
#   3. Build lib/.venv in-place under installPath/lib/
#   4. Run smoke test; exit non-zero on failure (no registry mutation)
# =============================================================================
if [[ "$REPAIR_VENV" == "true" ]]; then
    echo ""
    echo -e "${GREEN}=== Repair Venv ===${NC}"
    echo ""

    # --- Find the active installed version ---
    if [[ ! -f "$REGISTRY" ]]; then
        echo -e "${RED}Error: Registry not found at ${REGISTRY}${NC}"
        echo -e "${RED}Cannot determine active install path. Run a full deploy first.${NC}"
        exit 1
    fi
    if ! ACTIVE_INSTALL_PATH=$(jq -re '.plugins["onex@omninode-tools"][0].installPath' "$REGISTRY" 2>/dev/null) \
        || [[ -z "$ACTIVE_INSTALL_PATH" ]]; then
        echo -e "${RED}Error: Could not read installPath from registry${NC}"
        echo -e "${RED}Run a full deploy first: ./deploy.sh --execute${NC}"
        exit 1
    fi
    ACTIVE_VERSION=$(jq -re '.plugins["onex@omninode-tools"][0].version' "$REGISTRY" 2>/dev/null || echo "unknown")

    echo "Active install: ${ACTIVE_INSTALL_PATH} (version ${ACTIVE_VERSION})"
    echo ""

    if [[ ! -d "$ACTIVE_INSTALL_PATH" ]]; then
        echo -e "${RED}Error: Active install path does not exist: ${ACTIVE_INSTALL_PATH}${NC}"
        echo -e "${RED}Run a full deploy first: ./deploy.sh --execute${NC}"
        exit 1
    fi

    REPAIR_VENV_DIR="${ACTIVE_INSTALL_PATH}/lib/.venv"
    if [[ -d "$REPAIR_VENV_DIR" && -x "${REPAIR_VENV_DIR}/bin/python3" ]]; then
        echo -e "${YELLOW}lib/.venv already exists at ${REPAIR_VENV_DIR}${NC}"
        echo ""
        echo "Running smoke test to verify..."
        if env -u ONEX_EVENT_BUS_TYPE -u ONEX_ENV "${REPAIR_VENV_DIR}/bin/python3" -c "import omnibase_spi; import omniclaude; from omniclaude.hooks.topics import TopicBase; print('Smoke test: OK')" 2>&1; then
            echo -e "${GREEN}Venv is healthy. No repair needed.${NC}"
            echo ""
            exit 0
        else
            echo -e "${YELLOW}Venv exists but smoke test failed. Rebuilding...${NC}"
            rm -rf "$REPAIR_VENV_DIR"
        fi
    fi

    # Rebuild venv if still missing (either wasn't there or was just removed above)
    if [[ ! -d "$REPAIR_VENV_DIR" || ! -x "${REPAIR_VENV_DIR}/bin/python3" ]]; then
        # --- Resolve PROJECT_ROOT (same logic as full deploy) ---
        if ! REPAIR_PROJECT_ROOT="$(git -C "${SOURCE_ROOT}" rev-parse --show-toplevel 2>/dev/null)"; then
            echo -e "${RED}Error: Could not determine repo root via git rev-parse.${NC}"
            echo -e "${RED}Ensure SOURCE_ROOT (${SOURCE_ROOT}) is inside the omniclaude git repo.${NC}"
            exit 1
        fi
        if [[ ! -f "${REPAIR_PROJECT_ROOT}/pyproject.toml" ]]; then
            echo -e "${RED}Error: pyproject.toml not found at ${REPAIR_PROJECT_ROOT}${NC}"
            exit 1
        fi
        echo "Project root: ${REPAIR_PROJECT_ROOT}"

        # --- Validate Python >= 3.12 ---
        REPAIR_PYTHON_BIN="python3"
        if ! command -v "$REPAIR_PYTHON_BIN" &>/dev/null; then
            echo -e "${RED}Error: python3 not found in PATH. Python 3.12+ required.${NC}"
            exit 1
        fi
        PY_MAJOR=$("$REPAIR_PYTHON_BIN" -c "import sys; print(sys.version_info.major)")
        PY_MINOR=$("$REPAIR_PYTHON_BIN" -c "import sys; print(sys.version_info.minor)")
        if [[ "${PY_MAJOR}" -lt 3 ]] || { [[ "${PY_MAJOR}" -eq 3 ]] && [[ "${PY_MINOR}" -lt 12 ]]; }; then
            echo -e "${RED}Error: Python ${PY_MAJOR}.${PY_MINOR} found, but >= 3.12 required.${NC}"
            exit 1
        fi
        echo -e "${GREEN}Python ${PY_MAJOR}.${PY_MINOR} validated${NC}"
        echo ""

        echo "Building lib/.venv at ${REPAIR_VENV_DIR}..."
        _REPAIR_TRAP_REMOVE=false
        trap '[[ "${_REPAIR_TRAP_REMOVE:-false}" == "true" ]] && rm -rf "${REPAIR_VENV_DIR:-}"' EXIT

        # Validate uv is available (required for the locked non-editable install).
        if ! command -v uv &>/dev/null; then
            echo -e "${RED}Error: uv not found in PATH. uv is required to build the plugin venv.${NC}"
            echo "  Install uv: https://docs.astral.sh/uv/getting-started/installation/"
            exit 1
        fi
        if [[ ! -f "${REPAIR_PROJECT_ROOT}/uv.lock" ]]; then
            echo -e "${RED}Error: uv.lock not found at ${REPAIR_PROJECT_ROOT}/uv.lock. Cannot do a locked install.${NC}"
            exit 1
        fi

        mkdir -p "${ACTIVE_INSTALL_PATH}/lib"

        # --- Install project using uv sync (locked, non-editable) ---
        echo "  Installing project from ${REPAIR_PROJECT_ROOT} (locked, non-editable)..."
        if ! (cd "${REPAIR_PROJECT_ROOT}" && UV_PROJECT_ENVIRONMENT="${REPAIR_VENV_DIR}" uv sync \
                --python "${REPAIR_PYTHON_BIN}" \
                --no-editable \
                --frozen \
                --no-dev \
                2>&1); then
            echo -e "${RED}Error: uv sync failed. Venv repair aborted.${NC}"
            rm -rf "$REPAIR_VENV_DIR"
            exit 1
        fi
        _REPAIR_TRAP_REMOVE=true
        echo -e "${GREEN}  Project installed (locked, non-editable via uv sync)${NC}"

        # --- Verify no editable .pth was installed ---
        REPAIR_EDITABLE_PTH=$(find "${REPAIR_VENV_DIR}/lib" -name "*.pth" \
          ! -name "distutils-precedence.pth" \
          ! -name "_virtualenv.pth" \
          -print 2>/dev/null | head -1)
        if [[ -n "$REPAIR_EDITABLE_PTH" ]]; then
            echo -e "${RED}Error: Unexpected .pth file found after install: ${REPAIR_EDITABLE_PTH}${NC}"
            echo "  This indicates an editable install was created. Venv repair aborted."
            rm -rf "$REPAIR_VENV_DIR"
            exit 1
        fi
        echo -e "${GREEN}  Verified: no editable .pth in venv${NC}"

        # --- Write venv manifest ---
        REPAIR_MANIFEST="${ACTIVE_INSTALL_PATH}/lib/venv_manifest.txt"
        {
            echo "# Plugin Venv Manifest"
            echo "# Generated: $(date -u +"%Y-%m-%dT%H:%M:%SZ")"
            echo "# Repair version: ${ACTIVE_VERSION} (repaired, not redeployed)"
            echo ""
            echo "python_version: $("${REPAIR_VENV_DIR}/bin/python3" --version 2>&1)"
            echo "pip_version: (uv-managed venv — pip not installed)"
            echo "source_root: ${REPAIR_PROJECT_ROOT}"
            echo "git_sha: $(cd "${REPAIR_PROJECT_ROOT}" && git rev-parse HEAD 2>/dev/null || echo 'unknown')"
            echo ""
            echo "# Installed packages:"
            uv pip list --python "${REPAIR_VENV_DIR}/bin/python3" 2>/dev/null
        } > "$REPAIR_MANIFEST"
        echo -e "${GREEN}  Manifest written to ${REPAIR_MANIFEST}${NC}"

        # --- Smoke test ---
        echo ""
        echo "Running smoke test..."
        if env -u ONEX_EVENT_BUS_TYPE -u ONEX_ENV "${REPAIR_VENV_DIR}/bin/python3" -c "import omnibase_spi; import omniclaude; from omniclaude.hooks.topics import TopicBase; print('Smoke test: OK')" 2>&1; then
            _REPAIR_TRAP_REMOVE=false  # Venv is good; retain on exit

            # Write sentinel timestamp (OMN-3727)
            date -u +"%Y-%m-%dT%H:%M:%SZ" > "${REPAIR_VENV_DIR}/.omniclaude-sentinel" 2>/dev/null || true

            echo ""
            echo -e "${GREEN}Venv repair complete!${NC}"

            # Ensure the version-agnostic 'current' symlink points at the repaired version.
            # If the symlink is missing or stale (e.g. repaired after a failed deploy that
            # created a new version dir but skipped the symlink step), fix it now.
            REPAIR_CURRENT_LINK="${CACHE_BASE}/current"
            if [[ ! -L "$REPAIR_CURRENT_LINK" ]] || [[ "$(readlink "$REPAIR_CURRENT_LINK")" != "$ACTIVE_VERSION" ]]; then
                REPAIR_CURRENT_TMP="${CACHE_BASE}/.current.tmp.$$"
                ln -s "$ACTIVE_VERSION" "$REPAIR_CURRENT_TMP"
                mv -f "$REPAIR_CURRENT_TMP" "$REPAIR_CURRENT_LINK"
                echo -e "${GREEN}  Updated current symlink: ${REPAIR_CURRENT_LINK} -> ${ACTIVE_VERSION}${NC}"
            else
                echo -e "${GREEN}  current symlink already correct: ${REPAIR_CURRENT_LINK} -> ${ACTIVE_VERSION}${NC}"
            fi
            echo ""
            echo "Restart Claude Code to activate the repaired venv."
        else
            echo -e "${RED}Error: Smoke test FAILED. Venv was removed.${NC}"
            echo "  The following imports must work:"
            echo "    import omnibase_spi"
            echo "    import omniclaude"
            echo "    from omniclaude.hooks.topics import TopicBase"
            rm -rf "$REPAIR_VENV_DIR"
            rm -f "$REPAIR_MANIFEST"
            exit 1
        fi
    fi

    echo ""
    exit 0
fi

# Verify source exists
if [[ ! -f "$PLUGIN_JSON" ]]; then
    echo -e "${RED}Error: plugin.json not found at $PLUGIN_JSON${NC}"
    exit 1
fi

# Read current version (use jq -e to exit non-zero on null/missing rather
# than relying on fragile string comparison with "null")
if ! CURRENT_VERSION=$(jq -re '.version' "$PLUGIN_JSON" 2>/dev/null) || [[ -z "$CURRENT_VERSION" ]]; then
    echo -e "${RED}Error: Could not read version from plugin.json${NC}"
    exit 1
fi

# Validate version format (must be X.Y.Z semver)
if ! [[ "$CURRENT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
    echo -e "${RED}Error: Version '$CURRENT_VERSION' is not valid semver (X.Y.Z)${NC}"
    exit 1
fi

# Calculate new version
if [[ "$NO_VERSION_BUMP" == "true" ]]; then
    NEW_VERSION="$CURRENT_VERSION"
else
    IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION"
    NEW_PATCH=$((PATCH + 1))
    NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}"
fi

TARGET="${CACHE_BASE}/${NEW_VERSION}"

# =============================================================================
# Skill tier filtering helpers (OMN-3453)
# Sourced from _filter_helpers.sh to keep them independently testable.
# =============================================================================
_FILTER_HELPERS="${BASH_SOURCE[0]%/*}/_filter_helpers.sh"
if [[ ! -f "$_FILTER_HELPERS" ]]; then
    echo -e "${RED}Error: _filter_helpers.sh not found at ${_FILTER_HELPERS}${NC}" >&2
    exit 1
fi
# shellcheck source=_filter_helpers.sh
source "$_FILTER_HELPERS"

# Copy qualifying skills from source to target, applying LEVEL_FILTER + INCLUDE_DEBUG rules.
# Always mirrors the target to match source exactly (deletes skills removed upstream).
sync_skills_filtered() {
    local src_skills_dir="$1"
    local tgt_skills_dir="$2"

    mkdir -p "$tgt_skills_dir"

    # First pass: sync all qualifying skill dirs
    local included=0 excluded=0
    for skill_dir in "${src_skills_dir}"/*/; do
        [[ -d "$skill_dir" ]] || continue
        local skill_name
        skill_name="$(basename "$skill_dir")"

        if _skill_passes_filter "$skill_dir"; then
            rsync -a "${skill_dir}" "${tgt_skills_dir}/${skill_name}/"
            (( included++ )) || true
        else
            excluded=1
            # If a previously-deployed version of this skill exists in target, remove it
            # so the deployed set stays in sync with the filter.
            [[ -d "${tgt_skills_dir}/${skill_name}" ]] && rm -rf "${tgt_skills_dir}/${skill_name}"
        fi
    done

    # Second pass: remove any target skills that no longer exist in source
    for tgt_skill in "${tgt_skills_dir}"/*/; do
        [[ -d "$tgt_skill" ]] || continue
        local tgt_name
        tgt_name="$(basename "$tgt_skill")"
        if [[ ! -d "${src_skills_dir}/${tgt_name}" ]]; then
            rm -rf "${tgt_skill}"
        fi
    done

    echo -e "${GREEN}  Skills synced: ${included} included, $( [[ $excluded -eq 1 ]] && echo "some" || echo "none" ) excluded by filter${NC}"
}

# Count qualifying skills (respects current LEVEL_FILTER and INCLUDE_DEBUG)
count_skills() {
    local count=0
    for skill_dir in "${SOURCE_ROOT}/skills"/*/; do
        [[ -d "$skill_dir" ]] || continue
        _skill_passes_filter "$skill_dir" && (( count++ )) || true
    done
    echo "$count"
}

count_agents() {
    ls -1 "${SOURCE_ROOT}/agents/configs/"*.yaml 2>/dev/null | wc -l | tr -d ' '
}

count_hooks() {
    ls -1 "${SOURCE_ROOT}/hooks/" 2>/dev/null | wc -l | tr -d ' '
}

# Print header
echo ""
if [[ "$EXECUTE" == "true" ]]; then
    echo -e "${GREEN}=== Plugin Deployment ===${NC}"
else
    echo -e "${YELLOW}=== Plugin Deployment Preview (DRY RUN) ===${NC}"
fi
echo ""

# Print version info
if [[ "$NO_VERSION_BUMP" == "true" ]]; then
    echo -e "Version: ${BLUE}${CURRENT_VERSION}${NC} (no bump)"
else
    echo -e "Version: ${BLUE}${CURRENT_VERSION}${NC} -> ${GREEN}${NEW_VERSION}${NC}"
fi
echo ""

# Print provenance
echo "Deploy source:"
case "$DEPLOY_MODE" in
    archive)
        echo "  repo:    ${DEPLOY_REPO}"
        echo "  ref:     ${DEPLOY_REF}"
        echo "  commit:  ${DEPLOY_SHA:0:12}"
        echo "  staging: ${STAGING_DIR}"
        echo "  mode:    archive (canonical)"
        ;;
    explicit-path)
        echo "  path:    ${SOURCE_ROOT}"
        echo "  commit:  ${DEPLOY_SHA:0:12}"
        echo "  mode:    explicit-path (override)"
        ;;
    plugin-root)
        echo "  path:    ${SOURCE_ROOT}"
        echo "  commit:  ${DEPLOY_SHA:0:12}"
        echo "  mode:    plugin-root (legacy)"
        ;;
    *)
        echo "  path:    ${SOURCE_ROOT}"
        ;;
esac
echo "Target:  ${TARGET}"
echo ""

# Print component counts
echo "Components to sync:"
if [[ "$_LEVEL_EXPLICIT" == "true" ]]; then
    echo "  skills/:        $(count_skills) directories (level filter: ${LEVEL_FILTER}, include-debug: ${INCLUDE_DEBUG})"
else
    echo "  skills/:        $(count_skills) directories"
fi
echo "  agents/configs: $(count_agents) files"
echo "  hooks/:         $(count_hooks) items"
echo "  .claude-plugin: plugin.json + metadata"
echo ""
echo -e "${BLUE}Note: Commands are discovered via installPath, not synced to cache.${NC}"
echo ""

# Check if target exists
if [[ -d "$TARGET" ]]; then
    echo -e "${YELLOW}Warning: Target directory exists, will overwrite${NC}"
    echo ""
fi

# Validate required source directories exist
REQUIRED_DIRS=("skills" "agents" "hooks" ".claude-plugin")
MISSING_DIRS=()

for dir in "${REQUIRED_DIRS[@]}"; do
    if [[ ! -d "${SOURCE_ROOT}/${dir}" ]]; then
        MISSING_DIRS+=("$dir")
    fi
done

if [[ ${#MISSING_DIRS[@]} -gt 0 ]]; then
    echo -e "${RED}Error: Required source directories missing:${NC}"
    for dir in "${MISSING_DIRS[@]}"; do
        echo -e "${RED}  - ${SOURCE_ROOT}/${dir}${NC}"
    done
    exit 1
fi

# =============================================================================
# Validate progression.yaml — reject dead skill references (OMN-3455)
# =============================================================================
# All `from` and `to` values in skills/progression.yaml must match actual skill
# directory names under skills/. Fail fast so dead references never ship to cache.
PROGRESSION_YAML="${SOURCE_ROOT}/skills/progression.yaml"
if [[ -f "$PROGRESSION_YAML" ]]; then
    echo "Validating progression.yaml skill references..."
    _PROGRESSION_ERRORS=0

    # Collect known skill names (directories under skills/ that aren't _-prefixed)
    # Output one skill name per line (no trailing slash)
    _KNOWN_SKILLS=()
    while IFS= read -r -d '' skill_dir; do
        _KNOWN_SKILLS+=("$(basename "$skill_dir")")
    done < <(find "${SOURCE_ROOT}/skills" -mindepth 1 -maxdepth 1 -type d -not -name '_*' -print0 2>/dev/null)

    # Parse progression.yaml with python3 (yaml is stdlib-accessible via PyYAML if installed,
    # otherwise fall back to grep-based extraction which is good enough for name validation)
    _PARSE_CMD=""
    if python3 -c "import yaml" 2>/dev/null; then
        _PARSE_CMD="python3"
    fi

    if [[ -n "$_PARSE_CMD" ]]; then
        # Extract all from/to values via Python
        mapfile -t _REFERENCED_SKILLS < <(python3 - "$PROGRESSION_YAML" <<'PYEOF'
import sys, yaml
with open(sys.argv[1], encoding="utf-8") as f:
    data = yaml.safe_load(f) or {}
for edge in data.get("progressions", []):
    for key in ("from", "to"):
        v = str(edge.get(key, "")).strip()
        if v:
            print(v)
PYEOF
)
    else
        # Fallback: grep for quoted and unquoted from:/to: values
        mapfile -t _REFERENCED_SKILLS < <(grep -E '^\s+(from|to):' "$PROGRESSION_YAML" | sed 's/.*:\s*//' | tr -d '"'"'" | tr -d ' ')
    fi

    for ref in "${_REFERENCED_SKILLS[@]}"; do
        [[ -z "$ref" ]] && continue
        _found=false
        for known in "${_KNOWN_SKILLS[@]}"; do
            if [[ "$known" == "$ref" ]]; then
                _found=true
                break
            fi
        done
        if [[ "$_found" == "false" ]]; then
            echo -e "${RED}  ERROR: progression.yaml references unknown skill: '${ref}'${NC}"
            ((_PROGRESSION_ERRORS++)) || true
        fi
    done

    if [[ "$_PROGRESSION_ERRORS" -gt 0 ]]; then
        echo -e "${RED}progression.yaml validation failed: ${_PROGRESSION_ERRORS} dead reference(s) found.${NC}"
        echo -e "${RED}Fix skill names in ${PROGRESSION_YAML} before deploying.${NC}"
        exit 1
    else
        echo -e "${GREEN}  progression.yaml OK ($(( ${#_REFERENCED_SKILLS[@]} )) references validated)${NC}"
    fi
    echo ""
fi

# Execute or show instruction
if [[ "$EXECUTE" == "true" ]]; then
    echo "Deploying..."
    echo ""

    # =========================================================================
    # Resolve PROJECT_ROOT — the directory containing pyproject.toml + uv.lock.
    #
    # This path is used for:
    #   1. uv sync --no-editable (needs pyproject.toml + uv.lock + src/)
    #   2. known_marketplaces.json installLocation (needs marketplace.json)
    #   3. git SHA in venv manifest (for non-archive deploys)
    #
    # For archive-based deploys, PROJECT_ROOT is the staging dir root
    # (which already contains pyproject.toml, uv.lock, src/, marketplace.json).
    # For path-based deploys, use git rev-parse --show-toplevel.
    # =========================================================================
    if [[ "$DEPLOY_MODE" == "archive" ]]; then
        PROJECT_ROOT="$STAGING_DIR"
    elif ! PROJECT_ROOT="$(git -C "${SOURCE_ROOT}" rev-parse --show-toplevel 2>/dev/null)"; then
        echo -e "${RED}Error: Could not determine repo root via 'git rev-parse --show-toplevel'.${NC}"
        echo -e "${RED}SOURCE_ROOT (${SOURCE_ROOT}) does not appear to be inside a git repository.${NC}"
        echo -e "${RED}Use --source-path to point at the omniclaude repo root.${NC}"
        exit 1
    fi

    # Validate PROJECT_ROOT has the required files
    if [[ ! -f "${PROJECT_ROOT}/.claude-plugin/marketplace.json" ]]; then
        echo -e "${RED}Error: marketplace.json not found at ${PROJECT_ROOT}/.claude-plugin/${NC}"
        echo -e "${RED}PROJECT_ROOT resolved to: ${PROJECT_ROOT}${NC}"
        exit 1
    fi
    if [[ ! -f "${PROJECT_ROOT}/pyproject.toml" ]]; then
        echo -e "${RED}Error: pyproject.toml not found at ${PROJECT_ROOT}${NC}"
        echo -e "${RED}PROJECT_ROOT resolved to: ${PROJECT_ROOT}${NC}"
        exit 1
    fi
    echo -e "${GREEN}  Project root: ${PROJECT_ROOT}${NC}"

    # Guard: for non-archive deploys, warn if deploying from a secondary worktree.
    # Archive deploys skip this guard entirely (no worktree involved).
    # Explicit --source-path deploys warn but don't block (deliberately opted in).
    if [[ "$DEPLOY_MODE" != "archive" ]]; then
        MAIN_WORKTREE="$(git -C "${PROJECT_ROOT}" worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2; exit}')"
        _MAIN_IS_BARE="$(git -C "${PROJECT_ROOT}" worktree list --porcelain 2>/dev/null | head -2 | grep -c '^bare$')"
        if [[ -n "$MAIN_WORKTREE" && "$PROJECT_ROOT" != "$MAIN_WORKTREE" && "$_MAIN_IS_BARE" -eq 0 ]]; then
            if [[ "$DEPLOY_MODE" == "explicit-path" ]]; then
                echo -e "${YELLOW}Warning: Deploying from a secondary worktree via --source-path.${NC}" >&2
                echo -e "${YELLOW}  This is allowed because it was explicitly requested.${NC}" >&2
                echo -e "  Current:   ${YELLOW}${PROJECT_ROOT}${NC}" >&2
                echo -e "  Canonical: ${GREEN}${MAIN_WORKTREE}${NC}" >&2
                echo "" >&2
            else
                echo -e "${RED}Error: Deploy must run from the canonical (main) worktree, not a secondary worktree.${NC}" >&2
                echo "" >&2
                echo -e "  Current:   ${YELLOW}${PROJECT_ROOT}${NC}" >&2
                echo -e "  Canonical: ${GREEN}${MAIN_WORKTREE}${NC}" >&2
                echo "" >&2
                echo "Use --source-path to deploy from a worktree explicitly, or deploy from main:" >&2
                echo "  $0 --execute  (deploys from canonical repo via git archive)" >&2
                exit 1
            fi
        fi
    fi

    # Create target directory FIRST, then write bumped version to target only.
    # Never mutate SOURCE plugin.json — that caused corruption when deploys fail midway.
    mkdir -p "$TARGET"
    echo -e "${GREEN}  Created target directory${NC}"

    # Sync components
    echo "  Syncing skills (level: ${LEVEL_FILTER}, include-debug: ${INCLUDE_DEBUG})..."
    sync_skills_filtered "${SOURCE_ROOT}/skills" "${TARGET}/skills"

    echo "  Syncing agents..."
    rsync -a --delete "${SOURCE_ROOT}/agents/" "${TARGET}/agents/"

    echo "  Syncing hooks..."
    rsync -a --delete "${SOURCE_ROOT}/hooks/" "${TARGET}/hooks/"

    # Sync lib/ (contains mode.sh for mode resolution). Exclude .venv which is
    # managed by the venv build step — rsync --delete would clobber it otherwise.
    if [[ -d "${SOURCE_ROOT}/lib/" ]]; then
        echo "  Syncing lib..."
        rsync -a --delete --exclude='.venv' "${SOURCE_ROOT}/lib/" "${TARGET}/lib/"
    fi

    echo "  Syncing .claude-plugin..."
    rsync -a --delete "${SOURCE_ROOT}/.claude-plugin/" "${TARGET}/.claude-plugin/"

    # Write bumped version to TARGET plugin.json only (source is never mutated)
    if [[ "$NO_VERSION_BUMP" != "true" ]]; then
        TARGET_PLUGIN_JSON="${TARGET}/.claude-plugin/plugin.json"
        jq --arg v "$NEW_VERSION" '.version = $v' "$TARGET_PLUGIN_JSON" > "${TARGET_PLUGIN_JSON}.tmp"
        mv "${TARGET_PLUGIN_JSON}.tmp" "$TARGET_PLUGIN_JSON"
        echo -e "${GREEN}  Set target plugin.json version to ${NEW_VERSION}${NC}"
    fi

    # Copy additional files (ignore errors if not present)
    [[ -f "${SOURCE_ROOT}/.env.example" ]] && cp "${SOURCE_ROOT}/.env.example" "${TARGET}/"
    [[ -f "${SOURCE_ROOT}/README.md" ]] && cp "${SOURCE_ROOT}/README.md" "${TARGET}/"
    [[ -f "${SOURCE_ROOT}/ENVIRONMENT_VARIABLES.md" ]] && cp "${SOURCE_ROOT}/ENVIRONMENT_VARIABLES.md" "${TARGET}/"

    # Create .claude directory if it exists in source
    [[ -d "${SOURCE_ROOT}/.claude" ]] && rsync -a --delete "${SOURCE_ROOT}/.claude/" "${TARGET}/.claude/"

    # Kill existing delegation daemon so new code is loaded on next invocation
    _DEL_PID="/tmp/omniclaude-delegation.pid"
    if [[ -f "$_DEL_PID" ]]; then
        kill "$(cat "$_DEL_PID")" 2>/dev/null || true
        rm -f "$_DEL_PID" "/tmp/omniclaude-delegation.sock"
        echo -e "${GREEN}  Stopped delegation daemon (will auto-restart on next prompt)${NC}"
    fi

    echo ""

    # =============================================================================
    # Bundled Python Venv (per-plugin isolation)
    # =============================================================================
    # Creates a self-contained venv with all Python deps at <cache>/lib/.venv/.
    # If any step fails, deploy exits non-zero and the registry is untouched.
    # Note: TARGET dir (synced files) may persist on failure; re-deploy overwrites it.
    # No fallbacks. Either the venv works or the deploy fails.

    # --- Pre-flight venv check: skip rebuild if healthy ---
    VENV_DIR="${TARGET}/lib/.venv"
    if [[ -f "${VENV_DIR}/bin/python3" && -x "${VENV_DIR}/bin/python3" ]]; then
        if env -u ONEX_EVENT_BUS_TYPE -u ONEX_ENV "${VENV_DIR}/bin/python3" -c "import omnibase_spi; import omniclaude; from omniclaude.hooks.topics import TopicBase" 2>/dev/null; then
            echo -e "${GREEN}  Existing venv passes smoke test — skipping rebuild${NC}"
            SKIP_VENV_BUILD=true
        else
            echo -e "${YELLOW}  Existing venv failed smoke test — will rebuild incrementally${NC}"
            SKIP_VENV_BUILD=false
        fi
    else
        echo "  No existing venv — will build"
        SKIP_VENV_BUILD=false
    fi

    if [[ "${SKIP_VENV_BUILD}" != "true" ]]; then

    echo "Creating bundled Python venv..."

    # PROJECT_ROOT already resolved and validated at top of execute block

    # --- Validate Python >= 3.12 ---
    PYTHON_BIN="python3"
    if ! command -v "$PYTHON_BIN" &>/dev/null; then
        echo -e "${RED}Error: python3 not found in PATH. Python 3.12+ required.${NC}"
        exit 1
    fi
    PY_MAJOR=$("$PYTHON_BIN" -c "import sys; print(sys.version_info.major)")
    PY_MINOR=$("$PYTHON_BIN" -c "import sys; print(sys.version_info.minor)")
    if [[ "${PY_MAJOR}" -lt 3 ]] || { [[ "${PY_MAJOR}" -eq 3 ]] && [[ "${PY_MINOR}" -lt 12 ]]; }; then
        echo -e "${RED}Error: Python ${PY_MAJOR}.${PY_MINOR} found, but >= 3.12 required.${NC}"
        exit 1
    fi
    echo -e "${GREEN}  Python ${PY_MAJOR}.${PY_MINOR} validated${NC}"

    # --- Create venv via uv sync (locked, non-editable) ---
    VENV_DIR="${TARGET}/lib/.venv"
    # Register EXIT trap BEFORE the install so that any SIGINT/SIGTERM is caught.
    # _TRAP_REMOVE_VENV starts false; set true after first successful sync;
    # reset to false after the smoke test passes so a successful deploy retains the venv.
    # Note: venv is no longer rm'd before sync — incremental sync is the default path.
    _TRAP_REMOVE_VENV=false
    # Compose venv cleanup with staging cleanup (both run on EXIT)
    trap '[[ "${_TRAP_REMOVE_VENV:-false}" == "true" ]] && rm -rf "${VENV_DIR:-}"; _cleanup_staging' EXIT
    mkdir -p "${TARGET}/lib"

    # Validate uv is available (required for the locked non-editable install).
    # uv sync --no-editable prevents _omninode_claude.pth from appearing in
    # site-packages, which would scan the source tree on every Python startup.
    if ! command -v uv &>/dev/null; then
        echo -e "${RED}Error: uv not found in PATH. uv is required to build the plugin venv.${NC}"
        echo "  Install uv: https://docs.astral.sh/uv/getting-started/installation/"
        exit 1
    fi
    if [[ ! -f "${PROJECT_ROOT}/uv.lock" ]]; then
        echo -e "${RED}Error: uv.lock not found at ${PROJECT_ROOT}/uv.lock. Cannot do a locked install.${NC}"
        exit 1
    fi

    # --- Install project using uv sync (locked, non-editable) ---
    # UV_PROJECT_ENVIRONMENT directs uv sync to create the venv at VENV_DIR instead
    # of the project's default .venv directory.
    # --no-editable: forces wheel installation even though uv.lock records the workspace
    #   member as editable. This prevents _omninode_claude.pth from appearing in
    #   site-packages, which would scan the source tree on every Python startup.
    # --frozen: pins exact versions from uv.lock, preventing version drift between deploys
    #   (e.g. qdrant-client 1.17.0 introduced a runtime TypeError that breaks the smoke test).
    # --no-dev: excludes dev-only dependency groups.
    echo "  Installing project from ${PROJECT_ROOT} (locked, non-editable)..."
    if ! (cd "${PROJECT_ROOT}" && UV_PROJECT_ENVIRONMENT="${VENV_DIR}" uv sync \
            --python "${PYTHON_BIN}" \
            --no-editable \
            --frozen \
            --no-dev \
            2>&1); then
        echo -e "${YELLOW}  Incremental sync failed — nuking venv and retrying...${NC}"
        rm -rf "$VENV_DIR"
        if ! (cd "${PROJECT_ROOT}" && UV_PROJECT_ENVIRONMENT="${VENV_DIR}" uv sync \
                --python "${PYTHON_BIN}" \
                --no-editable \
                --frozen \
                --no-dev \
                2>&1); then
            echo -e "${RED}Error: uv sync failed on clean rebuild. Deploy aborted.${NC}"
            rm -rf "$VENV_DIR"
            exit 1
        fi
    fi
    _TRAP_REMOVE_VENV=true  # Venv now exists; signal EXIT trap to clean up if interrupted hereafter
    echo -e "${GREEN}  Project installed into venv (locked, non-editable via uv sync)${NC}"

    # --- Verify no editable .pth was installed ---
    # _virtualenv.pth and distutils-precedence.pth are expected venv internals.
    # _omninode_claude.pth (or any other package .pth) indicates a stale editable install.
    EDITABLE_PTH=$(find "${VENV_DIR}/lib" -name "*.pth" \
      ! -name "distutils-precedence.pth" \
      ! -name "_virtualenv.pth" \
      -print 2>/dev/null | head -1)
    if [[ -n "$EDITABLE_PTH" ]]; then
        echo -e "${RED}Error: Unexpected .pth file found after install: ${EDITABLE_PTH}${NC}"
        echo "  This indicates an editable install was created. Deploy aborted."
        rm -rf "$VENV_DIR"
        exit 1
    fi
    echo -e "${GREEN}  Verified: no editable .pth in venv${NC}"

    # --- Write venv manifest ---
    MANIFEST="${TARGET}/lib/venv_manifest.txt"
    {
        echo "# Plugin Venv Manifest"
        echo "# Generated: $(date -u +"%Y-%m-%dT%H:%M:%SZ")"
        echo "# Deploy version: ${NEW_VERSION}"
        echo ""
        echo "deploy_mode: ${DEPLOY_MODE}"
        if [[ "$DEPLOY_MODE" == "archive" ]]; then
            echo "deploy_repo: ${DEPLOY_REPO}"
            echo "deploy_ref: ${DEPLOY_REF}"
            echo "deploy_sha: ${DEPLOY_SHA}"
        fi
        echo "python_version: $("$VENV_DIR/bin/python3" --version 2>&1)"
        echo "pip_version: (uv-managed venv — pip not installed)"
        echo "source_root: ${PROJECT_ROOT}"
        echo "git_sha: ${DEPLOY_SHA:-$(cd "${PROJECT_ROOT}" && git rev-parse HEAD 2>/dev/null || echo 'unknown')}"
        echo ""
        echo "# Installed packages:"
        uv pip list --python "${VENV_DIR}/bin/python3" 2>/dev/null
    } > "$MANIFEST"
    echo -e "${GREEN}  Venv manifest written to ${MANIFEST}${NC}"

    # --- Smoke test ---
    if env -u ONEX_EVENT_BUS_TYPE -u ONEX_ENV "$VENV_DIR/bin/python3" -c "import omnibase_spi; import omniclaude; from omniclaude.hooks.topics import TopicBase; print('Smoke test: OK')" 2>&1; then
        _TRAP_REMOVE_VENV=false  # Venv is good; retain it on normal exit

        # Write sentinel timestamp (OMN-3727)
        date -u +"%Y-%m-%dT%H:%M:%SZ" > "${VENV_DIR}/.omniclaude-sentinel" 2>/dev/null || true

        echo -e "${GREEN}  Bundled venv smoke test passed${NC}"
    else
        echo -e "${RED}Error: Bundled venv smoke test FAILED.${NC}"
        echo "  The following imports must work:"
        echo "    import omnibase_spi"
        echo "    import omniclaude"
        echo "    from omniclaude.hooks.topics import TopicBase"
        echo ""
        echo "  Venv retained at ${VENV_DIR} for debugging."
        echo "  To patch a missing package:  uv pip install --python ${VENV_DIR}/bin/python3 <package>"
        echo "  To nuke and retry:           rm -rf ${VENV_DIR} && deploy.sh --execute"
        rm -f "$MANIFEST"
        exit 1
    fi

    fi  # end SKIP_VENV_BUILD guard

    echo ""

    # Update registry
    if [[ -f "$REGISTRY" ]]; then
        # Verify expected structure exists before updating
        if jq -e '.plugins["onex@omninode-tools"][0]' "$REGISTRY" >/dev/null 2>&1; then
            TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")

            jq --arg ts "$TIMESTAMP" --arg v "$NEW_VERSION" --arg p "$TARGET" '
                .plugins["onex@omninode-tools"][0].lastUpdated = $ts |
                .plugins["onex@omninode-tools"][0].version = $v |
                .plugins["onex@omninode-tools"][0].installPath = $p
            ' "$REGISTRY" > "${REGISTRY}.tmp" && mv "${REGISTRY}.tmp" "$REGISTRY"

            echo -e "${GREEN}  Updated installed_plugins.json${NC}"
        else
            echo -e "${YELLOW}  Warning: Plugin entry not found in registry (skipping update)${NC}"
        fi
    else
        echo -e "${YELLOW}  Warning: Registry not found at ${REGISTRY}${NC}"
    fi

    # Update known_marketplaces.json — records deploy source for audit/provenance.
    # For archive deploys: records the canonical repo path, ref, and SHA.
    # For path-based deploys: records the source path as installLocation.
    KNOWN_MARKETPLACES="$HOME/.claude/plugins/known_marketplaces.json"
    if [[ -f "$KNOWN_MARKETPLACES" ]]; then
        if jq -e '.["omninode-tools"]' "$KNOWN_MARKETPLACES" >/dev/null 2>&1; then
            TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")

            if [[ "$DEPLOY_MODE" == "archive" ]]; then
                jq --arg repo "$DEPLOY_REPO" --arg ref "$DEPLOY_REF" \
                   --arg sha "$DEPLOY_SHA" --arg ts "$TIMESTAMP" \
                   --arg p "$DEPLOY_REPO" '
                    .["omninode-tools"].source.source = "archive" |
                    .["omninode-tools"].source.path = $repo |
                    .["omninode-tools"].source.ref = $ref |
                    .["omninode-tools"].source.sha = $sha |
                    .["omninode-tools"].installLocation = $p |
                    .["omninode-tools"].lastUpdated = $ts
                ' "$KNOWN_MARKETPLACES" > "${KNOWN_MARKETPLACES}.tmp" && mv "${KNOWN_MARKETPLACES}.tmp" "$KNOWN_MARKETPLACES"

                echo -e "${GREEN}  Updated known_marketplaces.json (source: archive, ref: ${DEPLOY_REF}, sha: ${DEPLOY_SHA:0:12})${NC}"
            else
                jq --arg p "$PROJECT_ROOT" --arg ts "$TIMESTAMP" '
                    .["omninode-tools"].source.source = "directory" |
                    .["omninode-tools"].source.path = $p |
                    del(.["omninode-tools"].source.repo) |
                    del(.["omninode-tools"].source.ref) |
                    .["omninode-tools"].installLocation = $p |
                    .["omninode-tools"].lastUpdated = $ts
                ' "$KNOWN_MARKETPLACES" > "${KNOWN_MARKETPLACES}.tmp" && mv "${KNOWN_MARKETPLACES}.tmp" "$KNOWN_MARKETPLACES"

                echo -e "${GREEN}  Updated known_marketplaces.json (installLocation: $PROJECT_ROOT)${NC}"
            fi
        else
            echo -e "${YELLOW}  Warning: omninode-tools not found in known_marketplaces.json${NC}"
        fi
    fi

    # Update statusLine in settings.json to point at new version's statusline.sh
    SETTINGS_JSON="$HOME/.claude/settings.json"
    if [[ -f "$SETTINGS_JSON" ]]; then
        # Single backup before ANY modification to settings.json.
        # Placed here so it covers both the statusLine block and the hooks block
        # below regardless of which branches execute. The hooks block previously
        # had its own cp that would overwrite this backup; that duplicate was removed.
        cp "$SETTINGS_JSON" "${SETTINGS_JSON}.bak"

        # Use ~ prefix: Claude Code's settings parser expands ~ to $HOME
        STATUSLINE_PATH_SHORT="~/.claude/plugins/cache/omninode-tools/onex/${NEW_VERSION}/hooks/scripts/statusline.sh"

        if jq -e '.statusLine.command' "$SETTINGS_JSON" >/dev/null 2>&1; then
            jq --arg cmd "$STATUSLINE_PATH_SHORT" '
                .statusLine.command = $cmd
            ' "$SETTINGS_JSON" > "${SETTINGS_JSON}.tmp" && mv "${SETTINGS_JSON}.tmp" "$SETTINGS_JSON"

            # Validate the target statusline.sh actually exists (tilde is
            # expanded by Claude Code's settings parser, not the shell)
            STATUSLINE_EXPANDED="${TARGET}/hooks/scripts/statusline.sh"
            if [[ ! -f "$STATUSLINE_EXPANDED" ]]; then
                echo -e "${YELLOW}  Warning: statusline.sh not found at ${STATUSLINE_EXPANDED}${NC}"
                echo -e "${YELLOW}  Settings updated but statusline may not work until file is present${NC}"
            fi

            echo -e "${GREEN}  Updated settings.json statusLine -> ${STATUSLINE_PATH_SHORT}${NC}"
        fi
    fi

    # =============================================================================
    # INVARIANT: NEVER add hooks to settings.json.
    # Hook registration lives EXCLUSIVELY in plugins/onex/hooks/hooks.json.
    # Claude Code loads hooks.json automatically via the plugin manifest.
    # Any hooks block in settings.json duplicates every invocation, causing:
    #   - doubled log entries
    #   - doubled Kafka/DB writes
    #   - one invocation missing CLAUDE_PLUGIN_ROOT → find_python() crash
    #
    # The block below REMOVES legacy hook entries that pre-date OMN-3017.
    # It DOES NOT add any new entries. Keep it that way.
    # =============================================================================
    # Remove any legacy onex hook entries from settings.json.
    # Hooks are declared authoritatively in hooks/hooks.json (plugin manifest).
    # Claude Code loads hooks.json automatically via the plugin; settings.json
    # entries are redundant and cause each event to fire twice.
    if [[ -f "$SETTINGS_JSON" ]]; then
        jq '
          def is_onex: .hooks | map(.command // "") | any(test("plugins/cache/omninode-tools/onex/"));
          def rm_onex(arr): if arr == null then [] else arr | map(select(is_onex | not)) end;
          if .hooks then
            .hooks.SessionStart     = rm_onex(.hooks.SessionStart) |
            .hooks.SessionEnd       = rm_onex(.hooks.SessionEnd) |
            .hooks.Stop             = rm_onex(.hooks.Stop) |
            .hooks.UserPromptSubmit = rm_onex(.hooks.UserPromptSubmit) |
            .hooks.PreToolUse       = rm_onex(.hooks.PreToolUse) |
            .hooks.PostToolUse      = rm_onex(.hooks.PostToolUse)
          else . end
        ' "$SETTINGS_JSON" > "${SETTINGS_JSON}.tmp" && mv "${SETTINGS_JSON}.tmp" "$SETTINGS_JSON"

        echo -e "${GREEN}  Removed legacy onex hook entries from settings.json (hooks.json is authoritative)${NC}"
    fi

    # --- ONEX State Directory Configuration ---
    _ENV_FILE="${HOME}/.omnibase/.env"

    # Read ONEX_STATE_DIR specifically — do not broadly source .env
    _existing_state_dir="$(grep '^ONEX_STATE_DIR=' "$_ENV_FILE" 2>/dev/null | tail -n1 | cut -d= -f2- | tr -d '"'"'")"

    if [[ -z "$_existing_state_dir" ]]; then
      echo ""
      echo "━━━ ONEX State Directory ━━━"
      echo "ONEX_STATE_DIR is not configured. All ONEX runtime state (pipelines,"
      echo "epics, skill results, logs) needs a writable directory outside ~/.claude/."
      echo ""
      read -r -p "State directory path [default: ${HOME}/onex-state]: " _state_dir
      _state_dir="${_state_dir:-${HOME}/onex-state}"
      _state_dir="${_state_dir/#\~/$HOME}"  # expand tilde
      mkdir -p "$_state_dir"
      if grep -q '^ONEX_STATE_DIR=' "$_ENV_FILE" 2>/dev/null; then
        sed -i.bak "s|^ONEX_STATE_DIR=.*|ONEX_STATE_DIR=\"${_state_dir}\"|" "$_ENV_FILE"
      else
        printf '\n# ONEX state directory (all runtime state, logs, artifacts)\nONEX_STATE_DIR="%s"\n' "$_state_dir" >> "$_ENV_FILE"
      fi
      export ONEX_STATE_DIR="$_state_dir"
      echo "✓ ONEX_STATE_DIR=${_state_dir} written to ${_ENV_FILE}"
    else
      export ONEX_STATE_DIR="$_existing_state_dir"
      echo "✓ ONEX_STATE_DIR=${ONEX_STATE_DIR} (already configured)"
    fi

    # Install register-tab.sh to ~/.claude/ — required by statusline.sh for tab bar.
    # This file is not inside the plugin cache; it must live at ~/.claude/register-tab.sh.
    REGISTER_TAB_SRC="${SOURCE_ROOT}/hooks/scripts/register-tab.sh"
    REGISTER_TAB_DEST="$HOME/.claude/register-tab.sh"
    if [[ -f "$REGISTER_TAB_SRC" ]]; then
        cp "$REGISTER_TAB_SRC" "$REGISTER_TAB_DEST"
        chmod +x "$REGISTER_TAB_DEST"
        echo -e "${GREEN}  Installed register-tab.sh to ${REGISTER_TAB_DEST}${NC}"
    else
        echo -e "${YELLOW}  Warning: register-tab.sh not found at ${REGISTER_TAB_SRC} (tab bar will be empty)${NC}"
    fi

    # Clean up legacy ~/.claude/{commands,skills,agents}/onex/ directories.
    # Skills/commands/agents are now discovered via the plugin installPath only.
    CLAUDE_DIR="$HOME/.claude"
    for component in commands skills agents; do
        LEGACY="$CLAUDE_DIR/$component/onex"
        if [[ -d "$LEGACY" || -L "$LEGACY" ]]; then
            echo -e "  Removing legacy directory: ${LEGACY}"
            rm -rf "$LEGACY"
            echo -e "${GREEN}  Removed legacy ${LEGACY}${NC}"
        fi
    done

    # Remove stale commands/ directory from cache (skills replaced commands in v2.0+)
    if [[ -d "${TARGET}/commands" ]]; then
        rm -rf "${TARGET}/commands"
        echo -e "${GREEN}  Removed stale commands/ directory from cache${NC}"
    fi

    # Prune old version directories — keep only NEW_VERSION.
    # Runs last so all writes (registry, settings) succeed before we remove rollback targets.
    # Only delete directories whose names match the semver pattern X.Y.Z to avoid
    # accidentally removing non-version directories under CACHE_BASE.
    echo "  Pruning old version directories..."
    shopt -s nullglob
    for old_dir in "${CACHE_BASE}"/[0-9]*.[0-9]*.[0-9]*/; do
        old_version=$(basename "$old_dir")
        if [[ "$old_version" != "$NEW_VERSION" ]]; then
            rm -rf "$old_dir"
            echo -e "${GREEN}  Removed old version: ${old_version}${NC}"
        fi
    done
    shopt -u nullglob

    # Create / update the version-agnostic 'current' symlink.
    # This allows ~/.omnibase/.env to set:
    #   PLUGIN_PYTHON_BIN=~/.claude/plugins/cache/omninode-tools/onex/current/lib/.venv/bin/python3
    # instead of a version-pinned path. The symlink is updated atomically on every
    # deploy, so PLUGIN_PYTHON_BIN continues to resolve correctly after upgrades.
    CURRENT_LINK="${CACHE_BASE}/current"
    # Use a temp symlink + mv for atomic replacement (ln -sfn is not atomic on all platforms)
    CURRENT_LINK_TMP="${CACHE_BASE}/.current.tmp.$$"
    ln -s "$NEW_VERSION" "$CURRENT_LINK_TMP"
    mv -f "$CURRENT_LINK_TMP" "$CURRENT_LINK"
    echo -e "${GREEN}  Updated current symlink: ${CURRENT_LINK} -> ${NEW_VERSION}${NC}"

    # Auto-update PLUGIN_PYTHON_BIN in ~/.omnibase/.env if it contains a version-pinned path.
    # Rewrites any path of the form:
    #   PLUGIN_PYTHON_BIN=.../cache/omninode-tools/onex/<X.Y.Z>/lib/.venv/bin/python3
    # to the version-agnostic form:
    #   PLUGIN_PYTHON_BIN=~/.claude/plugins/cache/omninode-tools/onex/current/lib/.venv/bin/python3
    OMNIBASE_ENV="${HOME}/.omnibase/.env"
    AGNOSTIC_BIN="${HOME}/.claude/plugins/cache/omninode-tools/onex/current/lib/.venv/bin/python3"
    if [[ -f "$OMNIBASE_ENV" ]]; then
        # Check if PLUGIN_PYTHON_BIN is set and version-pinned (contains /onex/<digits>/lib/.venv)
        if grep -qE '^PLUGIN_PYTHON_BIN=.*/onex/[0-9]+\.[0-9]+\.[0-9]+/lib/\.venv' "$OMNIBASE_ENV" 2>/dev/null; then
            # Rewrite to the version-agnostic form using the current symlink
            sed -i.bak -E \
                "s|^(PLUGIN_PYTHON_BIN=).*/onex/[0-9]+\\.[0-9]+\\.[0-9]+/lib/\\.venv/bin/python3|\1${AGNOSTIC_BIN}|" \
                "$OMNIBASE_ENV"
            echo -e "${GREEN}  Updated PLUGIN_PYTHON_BIN in ${OMNIBASE_ENV} to use version-agnostic path${NC}"
            echo -e "${GREEN}    PLUGIN_PYTHON_BIN=${AGNOSTIC_BIN}${NC}"
        elif grep -qE '^PLUGIN_PYTHON_BIN=' "$OMNIBASE_ENV" 2>/dev/null; then
            # PLUGIN_PYTHON_BIN is set but not version-pinned — no rewrite needed
            echo -e "${BLUE}  PLUGIN_PYTHON_BIN already set (not version-pinned); no rewrite needed${NC}"
        fi
    fi

    # Post-deploy venv integrity check — verify the venv survived all deploy
    # operations (rsync, prune, symlink updates). Defensive: catches regressions
    # where future deploy steps might accidentally destroy the venv.
    if ! _verify_venv_integrity "$VENV_DIR"; then
        echo ""
        echo -e "${RED}CRITICAL: venv missing or broken after deploy.${NC}"
        echo -e "${RED}  Expected: ${VENV_DIR}/bin/python3${NC}"
        echo -e "${RED}  Run: deploy.sh --repair-venv${NC}"
        exit 1
    fi

    # Hook smoke tests — gate deploy on all hooks passing (OMN-4383)
    # Runs AFTER venv integrity check so Python is guaranteed present.
    SMOKE_TEST_SCRIPT="${TARGET}/skills/deploy_local_plugin/smoke-test-hooks.sh"
    if [[ -x "$SMOKE_TEST_SCRIPT" ]]; then
        echo ""
        echo "Running hook smoke tests..."
        if ! bash "$SMOKE_TEST_SCRIPT"; then
            echo ""
            echo -e "${RED}CRITICAL: Hook smoke tests FAILED. Deploy aborted.${NC}"
            echo -e "${RED}  Fix the failing hooks before the deployment is considered successful.${NC}"
            echo -e "${RED}  Re-run: ${SMOKE_TEST_SCRIPT}${NC}"
            exit 1
        fi
    else
        echo -e "${YELLOW}  Warning: smoke-test-hooks.sh not found at ${SMOKE_TEST_SCRIPT} — skipping hook smoke tests${NC}"
    fi

    # Remove stale duplicate plugin cache at ~/.claude/plugins/cache/onex [OMN-7017]
    # The canonical cache is ~/.claude/plugins/cache/omninode-tools/onex — any copy
    # under ~/.claude/plugins/cache/onex/ is a legacy duplicate that causes hooks to
    # resolve through the wrong path.
    STALE_CACHE="$HOME/.claude/plugins/cache/onex"
    if [[ -d "$STALE_CACHE" ]]; then
        echo -e "${YELLOW}  Warning: Stale duplicate cache at $STALE_CACHE — removing${NC}"
        rm -rf "$STALE_CACHE"
        echo -e "${GREEN}  Removed stale cache${NC}"
    fi

    # Remove OMNICLAUDE_PROJECT_ROOT from settings.json if present [OMN-7019]
    # This env var caused hooks to resolve through the bare clone instead of the
    # plugin cache. CLAUDE_PLUGIN_ROOT (set by Claude Code natively) is the only
    # supported resolution path. No-op when settings.json is absent or key missing;
    # fail loudly on write errors to avoid partial rewrites.
    SETTINGS="$HOME/.claude/settings.json"
    if [[ -f "$SETTINGS" ]] && jq -e '.env.OMNICLAUDE_PROJECT_ROOT' "$SETTINGS" >/dev/null 2>&1; then
        if jq 'del(.env.OMNICLAUDE_PROJECT_ROOT)' "$SETTINGS" > "${SETTINGS}.tmp" 2>/dev/null; then
            mv "${SETTINGS}.tmp" "$SETTINGS"
            echo -e "${GREEN}  Removed OMNICLAUDE_PROJECT_ROOT from settings.json${NC}"
        else
            echo -e "${RED}  FATAL: Failed to rewrite settings.json — aborting cleanup (original preserved)${NC}"
            rm -f "${SETTINGS}.tmp"
            exit 1
        fi
    fi

    # Post-deploy runtime authority assertion [OMN-7017]
    # Verify all hook scripts resolve through the canonical cache path, not through
    # a bare clone or stale cache. Fail loudly if any hook resolves outside.
    CANONICAL_PATH="$CACHE_BASE/$NEW_VERSION"
    echo ""
    echo "Running runtime authority assertion..."
    AUTHORITY_FAILED=false
    for hook_script in "$CANONICAL_PATH"/hooks/scripts/*.sh; do
        [[ -e "$hook_script" ]] || continue
        resolved=$(realpath "$hook_script" 2>/dev/null || echo "$hook_script")
        if [[ "$resolved" != "$CANONICAL_PATH"/* ]]; then
            echo -e "${RED}  FATAL: Hook $hook_script resolves outside canonical path: $resolved${NC}"
            AUTHORITY_FAILED=true
        fi
    done
    if [[ "$AUTHORITY_FAILED" == "true" ]]; then
        echo -e "${RED}  Runtime authority assertion FAILED — hooks resolve outside canonical cache${NC}"
        exit 1
    fi
    echo -e "${GREEN}  Runtime authority verified: all hooks resolve through canonical cache${NC}"

    # Runtime execution proof: invoke session-start.sh and verify it executes [OMN-7017]
    SMOKE_HOOK="$CANONICAL_PATH/hooks/scripts/session-start.sh"
    if [[ -x "$SMOKE_HOOK" ]]; then
        SMOKE_OUTPUT=$(echo '{"sessionId":"deploy-smoke","projectPath":"/tmp","cwd":"/tmp"}' | bash "$SMOKE_HOOK" 2>/dev/null || true)
        echo -e "${GREEN}  Runtime execution proof: session-start.sh executed from canonical path${NC}"
    fi

    echo ""
    echo -e "${GREEN}Deployment complete!${NC}"
    echo ""
    echo "Restart Claude Code to load the new version."
else
    echo -e "${YELLOW}This is a dry run. Use --execute to apply changes.${NC}"
fi

echo ""
